Merge pull request #1985 from open-gsd/test/1975-cli-scripts-consolidation

test(#1975): consolidate CLI remainder + scripts-tooling tests into module suites
This commit is contained in:
Tom Boucher
2026-07-03 10:39:01 -04:00
committed by GitHub
93 changed files with 22533 additions and 12041 deletions

View File

@@ -849,7 +849,7 @@ Migration plan: Phase 1 (#3465) seam additions complete; Phase 2 (#3466) targets
`DEFECT.SHARED-ARTIFACT-MUTATION-IN-CONCURRENT-TEST.examples=#996/88e30d53 — bug-969 hardening tests fs.unlinkSync'd + restored the real gsd-core/bin/lib/core.cjs and set tsBuildInfoFile inside gsd-core/bin/ → next red across the full-test matrix (macOS/Windows) + ubuntu-24 coverage leg, ~40-50 MODULE_NOT_FOUND/ENOENT per leg; reproduced locally on iteration 1; fixed #1001/#1002`
`DEFECT.SHARED-ARTIFACT-MUTATION-IN-CONCURRENT-TEST.detect=grep tests for fs.unlinkSync|rmSync|writeFileSync|renameSync|cpSync targeting paths resolved from the repo root (join(__dirname,'..',...)) under gsd-core/bin/lib or a shared committed fixture, instead of a mkdtempSync temp dir; any build helper (e.g. ensureBuiltArtifacts) invoked with real-tree paths during the concurrent test phase; any tsBuildInfoFile / build-cache path that lands inside a copied/shipped dir (gsd-core/bin/)`
`DEFECT.SHARED-ARTIFACT-MUTATION-IN-CONCURRENT-TEST.fix-forward=tests mutate ONLY isolated mkdtempSync copies — never delete/rewrite shared real build outputs while node --test runs files concurrently; parameterize build helpers to accept {root,srcDir,outDir,tsBuildInfoPath,tsconfigPath} overrides and point the test at a throwaway temp project (precedent: #1002 ensureBuiltArtifacts(overrides)); keep mutable build state (tsbuildinfo) OUTSIDE copied/shipped trees (repo root, gitignored) + best-effort self-heal of stale bin-local copies; this is the concrete instance of the RULESET.TESTS.delete-bad-tests real-race class`
`DEFECT.SHARED-ARTIFACT-MUTATION-IN-CONCURRENT-TEST.test-anchor=tests/bug-969-test-infra-flake-hardening.test.cjs (hermetic temp-project rewrite); regression gate = 10x concurrent run of that suite + tests/state.test.cjs + tests/install.test.cjs must be clean (reproduces on iter 1 when racy)`
`DEFECT.SHARED-ARTIFACT-MUTATION-IN-CONCURRENT-TEST.test-anchor=tests/run-tests-harness.test.cjs (hermetic temp-project rewrite); regression gate = 10x concurrent run of that suite + tests/state.test.cjs + tests/install.test.cjs must be clean (reproduces on iter 1 when racy)`
`DEFECT.STACKED-PR-CANNOT-STAND-ALONE.symptom=patch PR was authored against scaffolding (handler files, lint scripts, generated modules) that exists only on an unmerged upstream feature branch; the PR's "base" on GitHub is the feature branch, not main; merging requires the upstream PR to land first`
`DEFECT.STACKED-PR-CANNOT-STAND-ALONE.examples=#3639 + #3637 both targeted base=feat/3575-enforcement-hardening (the Phase 6 PR #3577); #3639 modifies SDK-bridge calls in 6 family-router files that on main do NOT have any SDK-bridge call yet; #3637 patches scripts/lint-shared-module-handsync.cjs which does not exist on main at all`
@@ -903,8 +903,8 @@ Migration plan: Phase 1 (#3465) seam additions complete; Phase 2 (#3466) targets
`DEFECT.SUBAGENT-LONG-RUNNING-BG-STALL.fix-forward=keep gsd-test-summary --both at the top-level orchestrator; sub-agents either run it foreground with timeout: 1500000 (25min) and block, OR delegate the test step back to the orchestrator (write commits + return); never have a sub-agent fire-and-await a backgrounded long task`
`DEFECT.SUBAGENT-LONG-RUNNING-BG-STALL.anchor=project CLAUDE.md "Top-level orchestrator (cross-turn notifications available) vs Sub-agent worker (no cross-turn notifications)" guidance — load-bearing for multi-worktree parallel fix dispatch`
`DEFECT.AGENT-RETIRED-SLASH-SYNTAX-DRIFT.symptom=sub-agent writes /gsd-<cmd> (legacy hyphen syntax) in code comments or doc strings while implementing a fix; lands as part of the implementation diff`
`DEFECT.AGENT-RETIRED-SLASH-SYNTAX-DRIFT.examples=#3541 implementation included a typical /gsd-update path comment in installer-migration-report.cjs; caught by tests/bug-2543-gsd-slash-namespace.test.cjs (#3443 invariant)`
`DEFECT.AGENT-RETIRED-SLASH-SYNTAX-DRIFT.detect=tests/bug-2543-gsd-slash-namespace.test.cjs prints "Found N retired /gsd-<cmd> reference(s) — use /gsd:<cmd> instead" with line-number-precise violations`
`DEFECT.AGENT-RETIRED-SLASH-SYNTAX-DRIFT.examples=#3541 implementation included a typical /gsd-update path comment in installer-migration-report.cjs; caught by tests/slash-command-namespace.test.cjs (#3443 invariant)`
`DEFECT.AGENT-RETIRED-SLASH-SYNTAX-DRIFT.detect=tests/slash-command-namespace.test.cjs prints "Found N retired /gsd-<cmd> reference(s) — use /gsd:<cmd> instead" with line-number-precise violations`
`DEFECT.AGENT-RETIRED-SLASH-SYNTAX-DRIFT.fix-forward=replace /gsd-<cmd> with /gsd:<cmd> at the cited file:line; healthy emergent property — project-wide invariant test catches drift agents would never self-correct`
`DEFECT.AGENT-RETIRED-SLASH-SYNTAX-DRIFT.lesson=agent-trust-but-verify is load-bearing — sub-agent reporting "done" is not a substitute for running the full suite; the invariant test surfaces drift even in doc-only changes`
`PROC.PARALLEL-FIX-DISPATCH.pattern=bot triage brief → worktree per branch → parallel sub-agents do rubber-duck/RCA/TDD implementation only → top-level orchestrator owns commit + gsd-test + push + PR + changeset-pr-backfill`
@@ -931,7 +931,7 @@ Full detail in `~/.claude/skills/gsd-pr-fix-discipline/SKILL.md`. AI agents MUST
### Slash command two-tier confusion
- **Symptom:** `tests/bug-2543-gsd-slash-namespace.test.cjs` or `tests/init-manager.test.cjs` (folds former `bug-3584-runtime-slash-emitters`, consolidation epic #1969) fails
- **Symptom:** `tests/slash-command-namespace.test.cjs` (folds former `bug-2543-gsd-slash-namespace`, consolidation epic #1969) or `tests/init-manager.test.cjs` (folds former `bug-3584-runtime-slash-emitters`, consolidation epic #1969) fails
- **Affected this session:** #154 (three passes), #164 (added the authoritative matrix)
- **Fix:** Consult `## Slash-command form` section of this file before touching any `/gsd-` or `/gsd:` token — colon for `agents/`/`commands/`, hyphen for runtime emitters

View File

@@ -1718,7 +1718,7 @@ A lint gate enforces the budget:
npm run lint:descriptions
```
The check is also run as part of `npm test` via `tests/enh-2789-description-budget.test.cjs`.
The check is also run as part of `npm test` via `tests/skill-frontmatter-contract.test.cjs`.
---

View File

@@ -1834,7 +1834,7 @@ Test suite that scans all agent, workflow, and command files for embedded inject
- REQ-CTXRED-01: System MUST truncate oversized markdown artifacts to fit within context budgets
- REQ-CTXRED-02: System MUST order prompts for cache-friendly assembly (stable prefixes first)
- REQ-CTXRED-03: Reduction MUST preserve essential information (headings, requirements, task structure)
- REQ-CTXRED-04: Skill `description:` fields MUST be ≤ 100 chars; enforced by `npm run lint:descriptions` (see `scripts/lint-descriptions.cjs` and `tests/enh-2789-description-budget.test.cjs`)
- REQ-CTXRED-04: Skill `description:` fields MUST be ≤ 100 chars; enforced by `npm run lint:descriptions` (see `scripts/lint-descriptions.cjs` and `tests/skill-frontmatter-contract.test.cjs`)
**Process:**
1. **Measure** — Calculate total prompt size for the workflow

View File

@@ -18,7 +18,7 @@ The command file contract defines what makes a valid `commands/gsd/*.md`:
## Context
Before this ADR, the command contract was enforced inconsistently:
- `tests/enh-2790-skill-consolidation.test.cjs` checked existence and frontmatter of specific post-consolidation commands
- `tests/skill-frontmatter-contract.test.cjs` (folds former `enh-2790-skill-consolidation`, consolidation epic #1969) checked existence and frontmatter of specific post-consolidation commands
- `tests/docs-update.test.cjs` (folds former `bug-3135-capture-backlog-workflow`, consolidation epic #1969) checked `execution_context` @-ref resolution (added 2026-05-05)
- No test checked `allowed-tools` validity, `name:` convention, or `description:` non-emptiness across all commands simultaneously

View File

@@ -1509,7 +1509,7 @@ GSD Discord コミュニティに参加するには、GSD README 内のリンク
npm run lint:descriptions
```
このチェックは `tests/enh-2789-description-budget.test.cjs` を介して `npm test` の一部としても実行されます。
このチェックは `tests/skill-frontmatter-contract.test.cjs` を介して `npm test` の一部としても実行されます。
---

View File

@@ -1762,7 +1762,7 @@ Claude が GSD ワークフローコンテキスト外でファイル編集を
- REQ-CTXRED-01: システムはコンテキスト予算内に収まるよう、大きすぎる Markdown アーティファクトを切り詰めなければならない
- REQ-CTXRED-02: キャッシュフレンドリーなアセンブリのためにプロンプトを順序付けなければならない(安定したプレフィックスを先頭に)
- REQ-CTXRED-03: 削減は必須情報(見出し、要件、タスク構造)を保持しなければならない
- REQ-CTXRED-04: スキルの `description:` フィールドは ≤ 100 文字でなければならない;`npm run lint:descriptions` で強制(`scripts/lint-descriptions.cjs` と `tests/enh-2789-description-budget.test.cjs` 参照)
- REQ-CTXRED-04: スキルの `description:` フィールドは ≤ 100 文字でなければならない;`npm run lint:descriptions` で強制(`scripts/lint-descriptions.cjs` と `tests/skill-frontmatter-contract.test.cjs` 参照)
**プロセス:**
1. **計測** — ワークフローの総プロンプトサイズを計算

View File

@@ -1517,7 +1517,7 @@ GSD Discord 커뮤니티에 참여하려면 GSD README의 링크를 방문하거
npm run lint:descriptions
```
이 검사는 `tests/enh-2789-description-budget.test.cjs`를 통해 `npm test`의 일부로도 실행됩니다.
이 검사는 `tests/skill-frontmatter-contract.test.cjs`를 통해 `npm test`의 일부로도 실행됩니다.
---

View File

@@ -1514,7 +1514,7 @@ Um portão de lint impõe o orçamento:
npm run lint:descriptions
```
A verificação também é executada como parte de `npm test` via `tests/enh-2789-description-budget.test.cjs`.
A verificação também é executada como parte de `npm test` via `tests/skill-frontmatter-contract.test.cjs`.
---

View File

@@ -1509,7 +1509,7 @@ node gsd-tools.cjs state planned-phase --phase 3 --plans 2
npm run lint:descriptions
```
该检查也作为 `npm test` 的一部分通过 `tests/enh-2789-description-budget.test.cjs` 运行。
该检查也作为 `npm test` 的一部分通过 `tests/skill-frontmatter-contract.test.cjs` 运行。
---

View File

@@ -1778,7 +1778,7 @@ PreToolUse 钩子,检测 Claude 在 GSD 工作流上下文之外尝试文件
- REQ-CTXRED-01:系统必须截断超大 Markdown 构件以适应上下文预算
- REQ-CTXRED-02:系统必须为缓存友好的组装对提示进行排序(稳定的前缀优先)
- REQ-CTXRED-03:压缩必须保留必要信息(标题、需求、任务结构)
- REQ-CTXRED-04:技能 `description:` 字段必须 ≤ 100 个字符;由 `npm run lint:descriptions` 强制执行(参见 `scripts/lint-descriptions.cjs` 和 `tests/enh-2789-description-budget.test.cjs`)
- REQ-CTXRED-04:技能 `description:` 字段必须 ≤ 100 个字符;由 `npm run lint:descriptions` 强制执行(参见 `scripts/lint-descriptions.cjs` 和 `tests/skill-frontmatter-contract.test.cjs`)
**流程:**
1. **测量** — 计算工作流的总提示大小

View File

@@ -133,7 +133,14 @@ const RULES = [
'tests/install-regressions.test.cjs',
'tests/install-runtime-artifacts.test.cjs',
'tests/install-path-detection.test.cjs',
'tests/release-tarball-smoke.install.test.cjs',
// NOTE: release-tarball-smoke.install.test.cjs is intentionally NOT here.
// It is a 3–6 min `npm pack` + `npm install -g` integration test with its
// OWN dedicated workflow (.github/workflows/install-smoke.yml, triggered on
// the production install paths). Running it in the scoped/targeted lane too
// is redundant and blows the per-chunk Windows timeout when a broad PR
// bundles it with many other changed test files (epic #1969). See the
// SCOPED_LANE_EXCLUDE guard below, which also drops it when it is itself a
// changed test file.
'tests/runtime-artifact-layout.test.cjs',
'tests/golden-install-parity.test.cjs', // any src/installer change can alter emitted install artifacts → re-verify golden install parity (drift guard)
],
@@ -398,6 +405,17 @@ function classify(files) {
}
}
// Heavy integration tests that own a dedicated workflow must never run in the
// scoped/targeted lane — they carry a multi-minute cost that overruns the
// per-chunk timeout (worst on Windows) when a broad PR bundles them with many
// other changed test files, and their production paths already trigger their
// own workflow. Drop them however they entered (matched rule OR changed-file).
const SCOPED_LANE_EXCLUDE = new Set([
// covered by .github/workflows/install-smoke.yml
'tests/release-tarball-smoke.install.test.cjs',
]);
for (const f of SCOPED_LANE_EXCLUDE) { targeted.delete(f); windows.delete(f); }
// code_changed: true when product/pipeline OR inert CI changed.
// Docs-only PRs (neither flag set) get code_changed=false → full matrix skip.
const codeChanged = productOrPipelineChanged || inertCiChanged;

View File

@@ -14,27 +14,6 @@
"tests/autonomous-decomposition.test.cjs :: source-text-is-the-product",
"tests/autonomous-interactive.test.cjs :: source-text-is-the-product",
"tests/autonomous-to-flag.test.cjs :: source-text-is-the-product",
"tests/bug-131-release-tarball-smoke-explicit-home.test.cjs :: integration-test-input",
"tests/bug-211-launcher-home-fallback.test.cjs :: structural/behavioral regression for the ~/.claude fallback arm in",
"tests/bug-2136-sh-hook-version.test.cjs :: structural-regression-guard",
"tests/bug-2543-gsd-slash-namespace.test.cjs :: structural-regression-guard",
"tests/bug-2772-gitmodules-path-intersection.test.cjs :: source-text-is-the-product",
"tests/bug-2808-skill-hyphen-name.test.cjs :: source-text-is-the-product",
"tests/bug-3446-resume-continue-here-discovery.test.cjs :: source-text-is-the-product",
"tests/bug-3491-nested-git-worktree.test.cjs :: source-text-is-the-product",
"tests/bug-3523-cjs-loadconfig-branching-strategy-warning.test.cjs :: validates runtime CLI stdout/stderr warning behavior, not source grep",
"tests/bug-3542-executor-git-stash-prohibition.test.cjs :: source-text-is-the-product",
"tests/bug-3677-agent-colon-namespace-leak.test.cjs :: source-text-is-the-product",
"tests/bug-3678-executor-commit-docs-respect.test.cjs :: source-text-is-the-product",
"tests/bug-3683-command-colon-namespace-leak.test.cjs :: source-text-is-the-product",
"tests/bug-3683-workflow-colon-namespace-leak.test.cjs :: source-text-is-the-product",
"tests/bug-3689-resume-glob-nomatch.test.cjs :: source-text-is-the-product",
"tests/bug-444-resolver-local-claude-install.test.cjs :: structural/behavioral regression for the repo-local .claude/ install",
"tests/bug-619-codebase-drift-gate-shim.test.cjs :: source-text-is-the-product",
"tests/bug-622-graphify-optional-graph-html.test.cjs :: source-text-is-the-product",
"tests/bug-630-wave-cleanup-orchestrator-root.test.cjs :: source-text-is-the-product",
"tests/bug-685-windowshide-spawn.test.cjs :: source-text-is-the-product",
"tests/bug-891-non-claude-runtime-home-fallback.test.cjs :: structural/behavioral regression for non-Claude runtime-home",
"tests/chain-flag-plan-phase.test.cjs :: source-text-is-the-product",
"tests/changeset-cli.test.cjs :: reads a product workflow .md file (not CJS source) to verify",
"tests/check-update-config-dir.test.cjs :: structural-regression-guard",
@@ -72,10 +51,6 @@
"tests/edge-probe-planner-contract.test.cjs :: runtime-contract-is-the-product — plan-phase.md's planner prompt is the deployed runtime contract under assertion",
"tests/edge-probe-spec-phase-contract.test.cjs :: runtime-contract-is-the-product — spec-phase.md Step 5.5 is the deployed workflow runtime contract under assertion",
"tests/edit-phase.test.cjs :: source-text-is-the-product",
"tests/enh-2380-sync-skills.test.cjs :: source-text-is-the-product",
"tests/enh-2789-description-budget.test.cjs :: source-text-is-the-product",
"tests/enh-2790-skill-consolidation.test.cjs :: source-text-is-the-product",
"tests/enh-48-cwd-drift-guard-e2e.test.cjs :: integration-test-input",
"tests/eslint-rules.test.cjs :: <source-grep reason> must still error",
"tests/eslint-rules.test.cjs :: pending migration",
"tests/eslint-rules.test.cjs :: source-text-is-the-product",
@@ -88,7 +63,6 @@
"tests/execute-phase-worktree-artifacts.test.cjs :: source-text-is-the-product",
"tests/explore-command.test.cjs :: source-text-is-the-product",
"tests/extract-learnings.test.cjs :: source-text-is-the-product",
"tests/feat-3039-help-tiered.test.cjs :: source-text-is-the-product",
"tests/forensics.test.cjs :: source-text-is-the-product",
"tests/frontmatter-cli.test.cjs :: source-text-is-the-product",
"tests/gates-taxonomy.test.cjs :: source-text-is-the-product",

View File

@@ -1,42 +1 @@
[
"bug-131-release-tarball-smoke-explicit-home.test.cjs",
"bug-1367-claude-local-flat-command-layout.test.cjs",
"bug-1834-sh-hooks-installed.test.cjs",
"bug-1974-context-exhaustion-record.test.cjs",
"bug-211-launcher-home-fallback.test.cjs",
"bug-2136-sh-hook-version.test.cjs",
"bug-2344-read-guard-claudecode-env.test.cjs",
"bug-2451-context-monitor-over-report.test.cjs",
"bug-2520-read-guard-hook-subprocess-env.test.cjs",
"bug-2543-gsd-slash-namespace.test.cjs",
"bug-260-worktree-path-guard.test.cjs",
"bug-261-worktree-force-add-guard.test.cjs",
"bug-2772-gitmodules-path-intersection.test.cjs",
"bug-2808-skill-hyphen-name.test.cjs",
"bug-2916-handle-branching-default-base.test.cjs",
"bug-2995-post-install-script-paths.test.cjs",
"bug-3019-help-passthrough.test.cjs",
"bug-3442-shim-projection-drift-guard.test.cjs",
"bug-3446-resume-continue-here-discovery.test.cjs",
"bug-3491-nested-git-worktree.test.cjs",
"bug-3509-path-spaces.test.cjs",
"bug-3523-cjs-loadconfig-branching-strategy-warning.test.cjs",
"bug-3542-executor-git-stash-prohibition.test.cjs",
"bug-3588-npm-audit-clean.test.cjs",
"bug-3668-workflow-runtime-resolution.test.cjs",
"bug-3677-agent-colon-namespace-leak.test.cjs",
"bug-3678-executor-commit-docs-respect.test.cjs",
"bug-3683-command-colon-namespace-leak.test.cjs",
"bug-3683-workflow-colon-namespace-leak.test.cjs",
"bug-3689-resume-glob-nomatch.test.cjs",
"bug-444-resolver-local-claude-install.test.cjs",
"bug-619-codebase-drift-gate-shim.test.cjs",
"bug-622-graphify-optional-graph-html.test.cjs",
"bug-630-wave-cleanup-orchestrator-root.test.cjs",
"bug-641-files-from-suite-token.test.cjs",
"bug-685-windowshide-spawn.test.cjs",
"bug-891-non-claude-runtime-home-fallback.test.cjs",
"bug-925-context-monitor-hook-event-name.test.cjs",
"bug-941-managed-hooks-registry-manifest.test.cjs",
"bug-969-test-infra-flake-hardening.test.cjs"
]
[]

View File

@@ -12,7 +12,6 @@
},
"graphify": {
"files": [
"bug-622-graphify-optional-graph-html.test.cjs",
"graphify-auto-update.slow.test.cjs",
"graphify-command-cutover.test.cjs",
"graphify-query.test.cjs",
@@ -113,14 +112,6 @@
],
"issue": "#1165"
},
"docs": {
"files": [
"docs-parity-live-registry.test.cjs",
"docs-update.test.cjs",
"fix-1464-docs-manifest-validation.test.cjs"
],
"issue": "1496"
},
"host-integration": {
"files": [
"host-integration.test.cjs",

View File

@@ -1,244 +0,0 @@
// allow-test-rule: integration-test-input
// Regression test for #131: runNpm() must not fail when HOME points at an
// unwritable directory. The before() hook in release-tarball-smoke.install.test.cjs
// calls runNpm(['pack', ...]) and runNpm(['install', '-g', ...]) — if those inherit
// an unwritable HOME from the environment (common in constrained Docker hosts),
// the entire hook fails and all 6 subtests are cancelled.
//
// Fix: runNpm() must inject an explicit HOME, npm_config_cache, and
// npm_config_userconfig that point into a temp directory it owns, so that npm
// never reads from or writes to the caller's HOME.
//
// Test 3 (added in the second fix pass) verifies that isolatedNpmEnv() — the
// companion export that lets runSmoke() apply the same isolation — also redirects
// HOME away from the caller's HOME. Without this, subtests A-F of
// release-tarball-smoke.install.test.cjs still fail because runSmoke() calls
// spawnSync('npm', ...) internally and was not covered by the runNpm() fix.
'use strict';
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { execFileSync } = require('node:child_process');
// The helpers under test.
const { isolatedNpmEnv, cleanup } = require('./helpers.cjs');
// Resolve a filesystem path to its canonical (symlink-free) form even if the
// leaf does not exist yet (e.g. ~/.npm before npm has written its cache).
// Walks up to the nearest existing ancestor, resolves that, then re-appends
// the trailing segments. This handles macOS /var → /private/var symlinks for
// paths created under os.tmpdir() where the leaf directory may not exist yet.
function safeRealpath(p) {
try {
return fs.realpathSync(p);
} catch (_) {
// Leaf does not exist — resolve the nearest existing ancestor then
// reconstruct the original suffix so the result is still canonical.
const segments = [];
let cur = p;
for (;;) {
const parent = path.dirname(cur);
if (parent === cur) {
// Reached filesystem root — return original path unchanged.
return p;
}
segments.unshift(path.basename(cur));
cur = parent;
try {
return path.join(fs.realpathSync(cur), ...segments);
} catch (__) {
// Keep walking up.
}
}
}
}
describe('bug-131: runNpm isolates HOME from the caller environment', () => {
// ── Test 1 — runNpm works with an unwritable HOME ────────────────────────
// Spawn a child Node process that sets HOME to a chmod-0500 directory, then
// invokes runNpm(['--version']). Without the fix, npm tries to read/write
// HOME/.npmrc and HOME/.npm, fails with EACCES, and runNpm throws.
// With the fix, runNpm injects its own isolated HOME and npm succeeds.
test('runNpm succeeds even when process HOME is unwritable', () => {
// Create an unwritable dir to serve as a poisoned HOME.
const poisonedHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-bug131-poison-'));
try {
fs.chmodSync(poisonedHome, 0o500); // r-x only — not writable
// We exercise the real runNpm() path by running a tiny inline Node script
// that requires helpers.cjs and calls runNpm(['--version']) with HOME set
// to the unwritable dir. The script exits 0 on success, non-zero on throw.
const script = `
process.env.HOME = ${JSON.stringify(poisonedHome)};
process.env.USERPROFILE = ${JSON.stringify(poisonedHome)};
const { runNpm } = require(${JSON.stringify(path.join(__dirname, 'helpers.cjs'))});
try {
const out = runNpm(['--version']);
if (!out || out.trim() === '') process.exit(2); // vacuous success guard
process.stdout.write(out);
process.exit(0);
} catch (e) {
process.stderr.write(e.message + '\\n');
process.exit(1);
}
`;
let stdout = '';
let stderr = '';
let exitCode = 0;
try {
stdout = execFileSync(process.execPath, ['-e', script], {
encoding: 'utf-8',
timeout: 30_000,
});
} catch (err) {
stdout = err.stdout || '';
stderr = err.stderr || '';
exitCode = err.status ?? 1;
}
assert.equal(
exitCode,
0,
`runNpm should succeed with an unwritable HOME but exited ${exitCode}. stderr: ${stderr}`,
);
// npm --version returns something like "10.x.y"
assert.match(
stdout.trim(),
/^\d+\.\d+/,
`expected semver output from npm --version, got: ${stdout}`,
);
} finally {
// Restore write permission before cleanup so the directory can be deleted.
try { fs.chmodSync(poisonedHome, 0o700); } catch (_) { /* best-effort */ }
cleanup(poisonedHome);
}
});
// ── Test 2 — runNpm does not leak a caller-supplied HOME into npm ────────
// Even if the caller exports HOME=/some/real/path, the injected HOME must be
// a different (temp) path so npm writes never touch the caller's $HOME.
test('runNpm injects a HOME distinct from process.env.HOME', () => {
// Capture what HOME runNpm actually passes to npm by asking npm to print
// the value it sees for the $HOME env var. We do this via `npm config get
// cache` which reveals the cache path — if it's under process.env.HOME,
// the fix is absent; if it's under a tmp dir, the fix is present.
const script = `
const { runNpm } = require(${JSON.stringify(path.join(__dirname, 'helpers.cjs'))});
try {
// npm config get cache prints the effective cache directory.
const out = runNpm(['config', 'get', 'cache']);
process.stdout.write(out.trim());
process.exit(0);
} catch (e) {
process.stderr.write(e.message + '\\n');
process.exit(1);
}
`;
let stdout = '';
let stderr = '';
let exitCode = 0;
try {
stdout = execFileSync(process.execPath, ['-e', script], {
encoding: 'utf-8',
timeout: 30_000,
});
} catch (err) {
stdout = err.stdout || '';
stderr = err.stderr || '';
exitCode = err.status ?? 1;
}
assert.equal(
exitCode,
0,
`runNpm config get cache failed with exit ${exitCode}. stderr: ${stderr}`,
);
const effectiveCacheDir = stdout.trim();
// The effective npm cache must NOT be inside the calling process's HOME.
// If it is, the fix was not applied and the Docker regression can still occur.
const callerHome = os.homedir();
assert.ok(
!effectiveCacheDir.startsWith(callerHome),
`npm cache dir ${effectiveCacheDir} is still under caller HOME ${callerHome} — fix not applied`,
);
// It must be somewhere under the system tmp dir, confirming isolation.
// Use safeRealpath on both sides so that macOS /var→/private/var symlinks
// do not cause a false mismatch when os.tmpdir() and the resolved cache
// path differ only in symlink expansion. The cache sub-directory (.npm) may
// not exist yet; safeRealpath walks up to the nearest existing ancestor.
const sysTmp = safeRealpath(os.tmpdir());
const realCacheDir = safeRealpath(effectiveCacheDir);
assert.ok(
realCacheDir.startsWith(sysTmp),
`npm cache dir ${realCacheDir} should be under tmpdir ${sysTmp}`,
);
});
// ── Test 3 — isolatedNpmEnv() redirects HOME away from the caller's HOME ──
// runSmoke() calls spawnSync('npm', ...) with npmEnv from isolatedNpmEnv().
// If isolatedNpmEnv() didn't redirect HOME, subtests A-F would still fail on
// Docker hosts with an unwritable HOME (the original bug #131 root cause,
// manifesting via the sibling runSmoke() path). (#131)
test('isolatedNpmEnv() HOME is distinct from the caller HOME and lives under tmpdir', () => {
const env = isolatedNpmEnv();
// Must expose a HOME key.
assert.ok(
typeof env.HOME === 'string' && env.HOME.length > 0,
'isolatedNpmEnv() must set HOME',
);
// Must not be the caller's HOME.
const callerHome = os.homedir();
assert.notEqual(
env.HOME,
callerHome,
`isolatedNpmEnv() HOME must differ from caller HOME ${callerHome}`,
);
// Must live under the system tmpdir, confirming it is an isolated temp directory.
// Use safeRealpath on both sides so that macOS /var→/private/var symlinks
// do not cause a false mismatch.
const sysTmp = safeRealpath(os.tmpdir());
const realHome = safeRealpath(env.HOME);
assert.ok(
realHome.startsWith(sysTmp),
`isolatedNpmEnv() HOME ${realHome} should be under tmpdir ${sysTmp}`,
);
// npm_config_cache and npm_config_userconfig must also be set and under the isolated HOME.
assert.ok(
typeof env.npm_config_cache === 'string' && env.npm_config_cache.startsWith(env.HOME),
`npm_config_cache ${env.npm_config_cache} should be under isolated HOME ${env.HOME}`,
);
assert.ok(
typeof env.npm_config_userconfig === 'string' && env.npm_config_userconfig.startsWith(env.HOME),
`npm_config_userconfig ${env.npm_config_userconfig} should be under isolated HOME ${env.HOME}`,
);
assert.equal(
env.npm_config_loglevel,
'error',
'isolatedNpmEnv() should suppress npm notice/warn chatter in test gates',
);
assert.equal(
env.npm_config_update_notifier,
'false',
'isolatedNpmEnv() should disable npm update-notifier notices in test gates',
);
assert.equal(
env.NO_UPDATE_NOTIFIER,
'1',
'isolatedNpmEnv() should disable npm update-notifier notices for npm versions that honor NO_UPDATE_NOTIFIER',
);
});
});

View File

@@ -1,174 +0,0 @@
// allow-test-rule: source-text-is-the-product #1367
// Installed command `.md` files — their on-disk path determines the slash-command
// namespace registered by Claude Code. Asserting the layout (flat vs. subdirectory)
// IS a behavioral test of the deploy contract, not source-grep theater.
/**
* Regression for #1367 — project-local Claude Code install writes command files to
* `.claude/commands/gsd/<cmd>.md` (subdirectory, bare names), causing Claude Code
* to register them as `/gsd:<cmd>` (colon namespace). The fix changes the layout to
* write flat `gsd-<cmd>.md` files at `.claude/commands/` level so Claude Code
* registers `/gsd-<cmd>` (hyphen form, matching hooks, statusline, and cross-command
* references everywhere in the framework).
*
* Root cause: `bin/install.js` (the `else` branch for claude local) wrote to a
* `commands/gsd/` subdirectory using `copyWithPathReplacement`. Claude Code treats
* the directory name as a namespace, so `commands/gsd/update.md` became `/gsd:update`.
*
* Fix: write each command as `gsd-<stem>.md` directly in `commands/` (flat layout).
* This is the same approach used for OpenCode/Kilo (see `copyFlattenedCommands`).
*/
'use strict';
process.env.GSD_TEST_MODE = '1';
const { describe, test, before, after } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { execFileSync } = require('node:child_process');
const { cleanup } = require('./helpers.cjs');
const REPO_ROOT = path.resolve(__dirname, '..');
const INSTALL_PATH = path.join(REPO_ROOT, 'bin', 'install.js');
// hooks/dist/ is a gitignored build artifact; the test must ensure it exists before
// invoking the installer (mirrors golden-install-parity's BUILD_SCRIPT pattern). Without
// this, the unit lane — whose ensureBuiltArtifacts() builds only bin/lib, not hooks —
// leaves hooks/dist empty and install.js hard-fails "directory is empty" (#1926).
const BUILD_HOOKS = path.join(REPO_ROOT, 'scripts', 'build-hooks.js');
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/**
* Run `node install.js --claude --local --no-sdk` in cwd.
* GSD_TEST_MODE must be cleared so the install() main block executes.
*/
function runClaudeLocalInstall(cwd) {
const env = { ...process.env };
delete env.GSD_TEST_MODE;
execFileSync(process.execPath, [INSTALL_PATH, '--claude', '--local', '--no-sdk'], {
cwd,
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe'],
env,
});
}
// ---------------------------------------------------------------------------
// Suite — #1367 regression: flat gsd-<cmd>.md layout for claude local install
// ---------------------------------------------------------------------------
describe('bug #1367 — Claude local install uses flat gsd-<cmd>.md command layout', () => {
let tmpDir;
before(() => {
// #1926: build hooks/dist/ so the installer's verifyInstalled(hooks) doesn't hit an
// empty directory. Self-contained — no dependency on the lane having pre-built hooks.
execFileSync(process.execPath, [BUILD_HOOKS], {
cwd: REPO_ROOT,
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe'],
});
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-1367-'));
runClaudeLocalInstall(tmpDir);
});
after(() => {
cleanup(tmpDir);
});
test('L0: commands/ directory exists after local claude install', () => {
const commandsDir = path.join(tmpDir, '.claude', 'commands');
assert.ok(
fs.existsSync(commandsDir),
`commands/ must be created by local claude install at ${commandsDir}`,
);
});
test('L1: command files use flat gsd-<cmd>.md names (not bare names in a subdirectory)', () => {
// The fix: commands land as .claude/commands/gsd-<cmd>.md (flat, hyphen-prefixed).
// Claude Code reads the stem of each file in commands/ as the command name,
// so gsd-update.md → /gsd-update (hyphen). The old layout (commands/gsd/update.md)
// made Claude Code use the directory as a namespace → /gsd:update (colon).
const commandsDir = path.join(tmpDir, '.claude', 'commands');
assert.ok(fs.existsSync(commandsDir), 'commands/ must exist for this check to be meaningful');
const flatGsdFiles = fs.readdirSync(commandsDir, { withFileTypes: true })
.filter(e => e.isFile() && e.name.startsWith('gsd-') && e.name.endsWith('.md'));
assert.ok(
flatGsdFiles.length > 0,
`commands/ must contain flat gsd-*.md files (e.g. gsd-help.md, gsd-update.md). ` +
`Found none. Install may still be writing to commands/gsd/<cmd>.md subdirectory ` +
`which causes /gsd:<cmd> colon namespace in Claude Code.`,
);
});
test('L2: known commands land as flat gsd-<cmd>.md files', () => {
// Spot-check: the three commands mentioned in the issue must be present
// as flat hyphen-prefixed files.
const commandsDir = path.join(tmpDir, '.claude', 'commands');
const knownCommands = ['gsd-update.md', 'gsd-plan-phase.md', 'gsd-help.md'];
for (const name of knownCommands) {
const filePath = path.join(commandsDir, name);
assert.ok(
fs.existsSync(filePath),
`${name} must exist as a flat file at commands/${name}. ` +
`If missing, the flat layout is not being written correctly.`,
);
}
});
test('L3: commands/gsd/ subdirectory does NOT exist (old colon-namespace layout)', () => {
// The old layout wrote to commands/gsd/<cmd>.md. That directory must not
// exist after a fresh install with the fix applied.
const oldSubdir = path.join(tmpDir, '.claude', 'commands', 'gsd');
assert.ok(
!fs.existsSync(oldSubdir),
`commands/gsd/ subdir must NOT exist after install. ` +
`Its presence means the old layout is still being used — Claude Code would ` +
`register commands as /gsd:<cmd> (colon) instead of /gsd-<cmd> (hyphen).`,
);
});
test('L4: total flat command file count matches the staged source', () => {
// There should be a substantial number of commands (not 0, not 1).
// The exact count varies with profile but must be >= 20 for a full install.
const commandsDir = path.join(tmpDir, '.claude', 'commands');
const count = fs.readdirSync(commandsDir, { withFileTypes: true })
.filter(e => e.isFile() && e.name.startsWith('gsd-') && e.name.endsWith('.md'))
.length;
assert.ok(
count >= 20,
`commands/ must have >= 20 flat gsd-*.md files for a full install. ` +
`Got ${count}. Install may be silently dropping commands.`,
);
});
test('L5: legacy migration — re-install on a pre-#1367 tree removes old commands/gsd/ subdir', () => {
// Simulate a pre-#1367 install: create a commands/gsd/ subdirectory with a bare-name file.
// Then re-run the installer and verify the old subdir is cleaned up.
const commandsDir = path.join(tmpDir, '.claude', 'commands');
const legacyDir = path.join(commandsDir, 'gsd');
fs.mkdirSync(legacyDir, { recursive: true });
fs.writeFileSync(path.join(legacyDir, 'update.md'), '# legacy update');
// Re-run install — should remove commands/gsd/ and write flat gsd-*.md
runClaudeLocalInstall(tmpDir);
assert.ok(
!fs.existsSync(legacyDir),
`commands/gsd/ legacy subdir must be removed by re-install. ` +
`The installer's legacy cleanup must remove old commands/gsd/ on upgrade.`,
);
// Flat form must still be present
assert.ok(
fs.existsSync(path.join(commandsDir, 'gsd-update.md')),
`gsd-update.md must exist as flat file after re-install.`,
);
});
});

View File

@@ -1,140 +0,0 @@
/**
* Regression tests for bug #1834
*
* The installer must copy all three .sh hook files to the target hooks/
* directory during installation. In v1.32.0, only .js hooks were deployed
* because the install loop did not handle non-.js files from hooks/dist/.
*
* This test runs the actual installer (not a simulation) and verifies that
* gsd-session-state.sh, gsd-validate-commit.sh, and gsd-phase-boundary.sh
* are present and executable in the target hooks directory.
*
* Distinct from:
* #1656 — .sh files missing from build-hooks.js HOOKS_TO_COPY
* #1817 — settings.json registration ran even when .sh files were absent
*/
'use strict';
const { describe, test, before, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const os = require('os');
const { execFileSync } = require('child_process');
const INSTALL_SCRIPT = path.join(__dirname, '..', 'bin', 'install.js');
const BUILD_SCRIPT = path.join(__dirname, '..', 'scripts', 'build-hooks.js');
const isWindows = process.platform === 'win32';
const SH_HOOKS = [
'gsd-session-state.sh',
'gsd-validate-commit.sh',
'gsd-phase-boundary.sh',
];
// ─── Ensure hooks/dist/ is populated before any install test ────────────────
before(() => {
execFileSync(process.execPath, [BUILD_SCRIPT], {
encoding: 'utf-8',
stdio: 'pipe',
});
});
// ─── Helpers ─────────────────────────────────────────────────────────────────
function createTempDir(prefix) {
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
}
function cleanup(dir) {
// eslint-disable-next-line local/no-raw-rmsync-in-tests -- local cleanup wrapper; try/catch swallows ENOENT so runInstaller teardown never fails the test
try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
}
/**
* Run the installer targeting a temp directory.
* Uses CLAUDE_CONFIG_DIR to redirect the global install target.
* Returns the path to the installed hooks directory.
*/
function runInstaller(configDir) {
// --no-sdk: this test covers hook deployment only; skip SDK build to avoid
// flakiness and keep the test fast (SDK install path has dedicated coverage
// in install-smoke.yml).
execFileSync(process.execPath, [INSTALL_SCRIPT, '--claude', '--global', '--yes', '--no-sdk'], {
encoding: 'utf-8',
stdio: 'pipe',
env: {
...process.env,
CLAUDE_CONFIG_DIR: configDir,
},
});
return path.join(configDir, 'hooks');
}
// ─────────────────────────────────────────────────────────────────────────────
// 1. End-to-end install: .sh hooks are deployed
// ─────────────────────────────────────────────────────────────────────────────
describe('#1834: installer deploys .sh hooks alongside .js hooks', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempDir('gsd-install-1834-');
});
afterEach(() => {
cleanup(tmpDir);
});
test('gsd-session-state.sh is present after install', () => {
const hooksDir = runInstaller(tmpDir);
const target = path.join(hooksDir, 'gsd-session-state.sh');
assert.ok(
fs.existsSync(target),
'gsd-session-state.sh must be installed to hooks/ — missing file causes SessionStart hook errors'
);
});
test('gsd-validate-commit.sh is present after install', () => {
const hooksDir = runInstaller(tmpDir);
const target = path.join(hooksDir, 'gsd-validate-commit.sh');
assert.ok(
fs.existsSync(target),
'gsd-validate-commit.sh must be installed to hooks/ — missing file causes PreToolUse hook errors'
);
});
test('gsd-phase-boundary.sh is present after install', () => {
const hooksDir = runInstaller(tmpDir);
const target = path.join(hooksDir, 'gsd-phase-boundary.sh');
assert.ok(
fs.existsSync(target),
'gsd-phase-boundary.sh must be installed to hooks/ — missing file causes PostToolUse hook errors'
);
});
test('all three .sh hooks are present after a single install', () => {
const hooksDir = runInstaller(tmpDir);
for (const hook of SH_HOOKS) {
assert.ok(
fs.existsSync(path.join(hooksDir, hook)),
`${hook} must be present in hooks/ after install`
);
}
});
test('.sh hooks are executable after install', {
skip: isWindows ? 'Windows does not support POSIX file permissions' : false,
}, () => {
const hooksDir = runInstaller(tmpDir);
for (const hook of SH_HOOKS) {
const stat = fs.statSync(path.join(hooksDir, hook));
assert.ok(
(stat.mode & 0o111) !== 0,
`${hook} must be executable (chmod +x) after install — missing +x causes hook invocation failures`
);
}
});
});

View File

@@ -1,258 +0,0 @@
/**
* Integration tests for gsd-context-monitor.js auto-record on CRITICAL (#1974).
*
* Verifies:
* 1. On CRITICAL + active GSD project, the hook sets criticalRecorded in the
* warn sentinel AND the state record-session command writes the "Stopped At"
* field to STATE.md.
* 2. Subsequent CRITICAL firings within the same session do NOT re-fire
* the subprocess (sentinel guard prevents repeated overwrites).
* 3. When no .planning/STATE.md exists, the subprocess is not spawned.
* 4. Path resolution uses __dirname, not hardcoded ~/.claude/.
* 5. A WARNING-only fire does NOT set criticalRecorded (selectivity counter-test).
*
* Design note (#3726, #3775): the original test used a short wall-clock poll
* against a fire-and-forget spawn().unref() subprocess and flaked under load.
* We keep one deterministic assertion (criticalRecorded sentinel is written
* before hook exit), and use a bounded poll window for the detached writer's
* STATE.md update. A separate test verifies direct record-session invocation.
*/
'use strict';
const { test, describe, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const os = require('node:os');
const { spawnSync } = require('node:child_process');
const { cleanup, delay } = require('./helpers.cjs');
const HOOK_PATH = path.resolve(__dirname, '..', 'hooks', 'gsd-context-monitor.js');
const GSD_TOOLS = path.resolve(__dirname, '..', 'gsd-core', 'bin', 'gsd-tools.cjs');
// Windows can hold a transient handle on the temp dir after a spawnSync child
// exits (AV scanner / handle-release lag), so cleanup()'s internal rmSync retry
// (~5s) occasionally still throws EBUSY/EPERM/ENOTEMPTY under CI load. Restore a
// bounded outer retry with async backoff via the shared delay() helper.
// Re-adds the guard removed in #482. Refs #490.
async function cleanupWithRetry(dir, attempts = 8) {
for (let i = 0; i < attempts; i += 1) {
try { cleanup(dir); return; }
catch (err) {
const transient = err && (err.code === 'EBUSY' || err.code === 'EPERM' || err.code === 'ENOTEMPTY');
if (!transient || i === attempts - 1) throw err;
await delay(100 * (i + 1));
}
}
}
/**
* Run the hook with a given session id and context percentage.
* Writes a bridge metrics file first, then pipes the hook input via stdin.
* Returns after the hook exits.
*/
function runHook(sessionId, remainingPct, cwd) {
// Write the bridge metrics file the hook reads
const bridgePath = path.join(os.tmpdir(), `claude-ctx-${sessionId}.json`);
fs.writeFileSync(bridgePath, JSON.stringify({
session_id: sessionId,
remaining_percentage: remainingPct,
used_pct: 100 - remainingPct,
timestamp: Math.floor(Date.now() / 1000),
}));
const input = JSON.stringify({
session_id: sessionId,
cwd,
});
const result = spawnSync(process.execPath, [HOOK_PATH], {
input,
encoding: 'utf-8',
timeout: 10000,
env: { ...process.env, HOME: process.env.HOME },
});
return { exitCode: result.status, stdout: result.stdout, stderr: result.stderr };
}
/**
* Run gsd-tools state record-session synchronously.
* Returns { exitCode, stdout, stderr }.
* Used to verify the persistence seam deterministically without relying on
* the fire-and-forget subprocess timing that caused flake (#3726).
*/
function runRecordSession(cwd, stoppedAt) {
const result = spawnSync(
process.execPath,
[GSD_TOOLS, 'state', 'record-session', '--stopped-at', stoppedAt, '--cwd', cwd],
{ encoding: 'utf-8', timeout: 30000 }
);
return {
exitCode: result.status,
signal: result.signal,
error: result.error,
stdout: result.stdout,
stderr: result.stderr,
};
}
/**
* Read and parse the warn sentinel file for a session.
* Returns the parsed object, or null if the file does not exist.
*/
function readWarnData(sessionId) {
const warnPath = path.join(os.tmpdir(), `claude-ctx-${sessionId}-warned.json`);
try {
return JSON.parse(fs.readFileSync(warnPath, 'utf-8'));
} catch {
return null;
}
}
describe('#1974 context exhaustion auto-record', () => {
let tmpDir;
let statePath;
let sessionId;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-1974-'));
const planningDir = path.join(tmpDir, '.planning');
fs.mkdirSync(planningDir, { recursive: true });
// Minimal STATE.md with Stopped At field
statePath = path.join(planningDir, 'STATE.md');
fs.writeFileSync(statePath, [
'# Session State',
'',
'**Current Phase:** 1',
'**Status:** executing',
'**Last session:** unset',
'**Last Date:** unset',
'**Stopped At:** None',
'**Resume File:** None',
'',
].join('\n'));
// Minimal config.json required by gsd-tools
fs.writeFileSync(path.join(planningDir, 'config.json'), JSON.stringify({ project_code: 'TEST' }));
sessionId = `test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
});
afterEach(async () => {
// cleanupWithRetry wraps cleanup() with a bounded outer retry (async setTimeout
// backoff, no Atomics.wait) to handle cases where windows-2022 CI load keeps
// the temp dir EBUSY beyond rmSync's internal ~5s retry window. Refs #490.
await cleanupWithRetry(tmpDir);
// Clean up bridge files
try {
const warnPath = path.join(os.tmpdir(), `claude-ctx-${sessionId}-warned.json`);
if (fs.existsSync(warnPath)) fs.unlinkSync(warnPath);
const bridgePath = path.join(os.tmpdir(), `claude-ctx-${sessionId}.json`);
if (fs.existsSync(bridgePath)) fs.unlinkSync(bridgePath);
} catch { /* noop */ }
});
test('sets criticalRecorded sentinel on CRITICAL (synchronous assertion only)', () => {
// Trigger CRITICAL — remaining <= 25
// The detached record-session subprocess timing assertion (waitForStateMatch,
// 45s poll) was removed per #453 (clock-seam): flaky under load. The
// deterministic coverage for STATE.md persistence lives in the
// 'state record-session command persists Stopped At when invoked directly'
// test below, which uses spawnSync instead of a fire-and-forget subprocess.
const result = runHook(sessionId, 20, tmpDir);
assert.strictEqual(result.exitCode, 0, `hook should exit 0: ${result.stderr}`);
// Deterministic: hook writes criticalRecorded:true to warnPath SYNCHRONOUSLY
// before the hook process exits, before the fire-and-forget subprocess runs.
// Since runHook() uses spawnSync, this is guaranteed readable now.
const warnData = readWarnData(sessionId);
assert.ok(warnData, 'warn sentinel file must exist after CRITICAL fire');
assert.strictEqual(
warnData.criticalRecorded,
true,
'hook must set criticalRecorded:true in warn sentinel on CRITICAL'
);
});
test('does NOT spawn subprocess when .planning/STATE.md is absent', () => {
// Delete STATE.md to simulate non-GSD project
fs.unlinkSync(statePath);
const result = runHook(sessionId, 20, tmpDir);
assert.strictEqual(result.exitCode, 0);
// The hook checks isGsdActive via fs.existsSync(STATE.md) before setting
// criticalRecorded. If STATE.md is absent, criticalRecorded must NOT be set.
const warnData = readWarnData(sessionId);
// warnData may exist (hook still debounces) but criticalRecorded must be absent/falsy.
const criticalRecorded = warnData && warnData.criticalRecorded;
assert.ok(!criticalRecorded, 'criticalRecorded must not be set when STATE.md is absent');
assert.ok(!fs.existsSync(statePath), 'STATE.md should not be recreated when absent');
});
test('sentinel prevents repeated firing within same session', () => {
// First CRITICAL fire — should set criticalRecorded synchronously.
const result1 = runHook(sessionId, 20, tmpDir);
assert.strictEqual(result1.exitCode, 0, `first hook fire should exit 0: ${result1.stderr}`);
const warnData1 = readWarnData(sessionId);
assert.ok(warnData1, 'warn sentinel must exist after first CRITICAL fire');
assert.strictEqual(warnData1.criticalRecorded, true, 'first fire must set criticalRecorded:true');
// Second CRITICAL fire — same session, criticalRecorded already true in
// warnPath. Advance callsSinceWarn past DEBOUNCE_CALLS (5, see hook
// line 29) so the hook processes the warning message path and exercises
// the sentinel guard. Using 10 (2× DEBOUNCE_CALLS) ensures we clear the
// debounce threshold regardless of any future DEBOUNCE_CALLS adjustment.
const warnPath = path.join(os.tmpdir(), `claude-ctx-${sessionId}-warned.json`);
const warnDataPatched = { ...warnData1, callsSinceWarn: 10 };
fs.writeFileSync(warnPath, JSON.stringify(warnDataPatched));
const result2 = runHook(sessionId, 18, tmpDir);
assert.strictEqual(result2.exitCode, 0, `second hook fire should exit 0: ${result2.stderr}`);
// The warnData must still carry criticalRecorded:true — the guard was
// active and the hook did not reset or clear it.
const warnData2 = readWarnData(sessionId);
assert.strictEqual(warnData2 && warnData2.criticalRecorded, true, 'sentinel must remain true after second fire');
// The hook's stdout must still emit a CRITICAL warning message (so the
// agent sees context warnings) even though record-session was NOT re-fired.
const output2 = result2.stdout ? (() => { try { return JSON.parse(result2.stdout); } catch { return null; } })() : null;
assert.ok(
output2 && output2.hookSpecificOutput && /CONTEXT CRITICAL/.test(output2.hookSpecificOutput.additionalContext),
'second CRITICAL fire must still emit CONTEXT CRITICAL warning to the agent'
);
});
test('state record-session command persists Stopped At when invoked directly', () => {
const recordResult = runRecordSession(tmpDir, 'context exhaustion at 80% (2026-01-01)');
assert.strictEqual(
recordResult.exitCode,
0,
`record-session should exit 0 (signal=${recordResult.signal || 'none'} error=${recordResult.error ? recordResult.error.message : 'none'}): ${recordResult.stderr}`
);
const content = fs.readFileSync(statePath, 'utf-8');
assert.match(content, /context exhaustion at 80% \(2026-01-01\)/, 'STATE.md must contain direct record-session value');
});
test('WARNING-only fire does NOT set criticalRecorded (selectivity counter-test)', () => {
// Trigger WARNING (remaining 30% — below WARNING_THRESHOLD=35, above CRITICAL_THRESHOLD=25)
const result = runHook(sessionId, 30, tmpDir);
assert.strictEqual(result.exitCode, 0, `hook should exit 0: ${result.stderr}`);
// criticalRecorded must NOT be set on a WARNING-only fire
const warnData = readWarnData(sessionId);
const criticalRecorded = warnData && warnData.criticalRecorded;
assert.ok(!criticalRecorded, 'WARNING-only fire must not set criticalRecorded');
});
// 'hook uses __dirname-based path (runtime-agnostic)' deleted per #453 (clock-seam):
// source-grep of HOOK_PATH for path.join(__dirname is brittle. The behavioral equivalent
// (hook successfully resolves gsd-tools.cjs from any working directory) is already covered
// by the runHook() helper throughout this test file — it calls the hook from an arbitrary
// tmpDir and all tests pass, proving __dirname-relative resolution works.
});

View File

@@ -1,189 +0,0 @@
'use strict';
/**
* Regression test for bug #211: gsd_run launcher must probe
* $HOME/.claude/gsd-core/bin/gsd-tools.cjs before emitting the hard error.
*
* Asserts:
* (A) The canonical snippet file contains the ~/.claude fallback arm.
* (B) A representative propagated workflow file contains the ~/.claude fallback arm.
* (C) Behavioral: when RUNTIME_DIR misses and gsd-tools is NOT on PATH,
* a stub at $HOME/.claude/gsd-core/bin/gsd-tools.cjs is resolved and invoked.
* (D) The resolution order is preserved: local -> PATH -> ~/.claude -> hard error.
* When all three miss, exit non-zero.
*/
// allow-test-rule: structural/behavioral regression for the ~/.claude fallback arm in
// the gsd_run launcher snippet -- asserts literal substring presence and exercises the
// bash resolution path via execFileSync; there is no typed IR for "snippet contains arm X".
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const os = require('node:os');
const { execFileSync } = require('node:child_process');
const { cleanup } = require('./helpers.cjs');
const WORKFLOWS_DIR = path.join(__dirname, '..', 'gsd-core', 'workflows');
const SNIPPET_FILE = path.join(WORKFLOWS_DIR, '_runtime-launcher.snippet.sh');
// Representative propagated workflow file (has a gsd_run call):
const REPRESENTATIVE_FILE = path.join(WORKFLOWS_DIR, 'add-backlog.md');
const CLAUDE_HOME_PROBE = '.claude/gsd-core/bin/';
describe('bug-211: launcher ~/.claude home fallback', () => {
// --- (A) Snippet contains the arm ----------------------------------------
test('(A) snippet file contains the $HOME/.claude fallback arm', () => {
const content = fs.readFileSync(SNIPPET_FILE, 'utf8');
assert.ok(
content.includes(CLAUDE_HOME_PROBE),
`_runtime-launcher.snippet.sh must contain "${CLAUDE_HOME_PROBE}" (the ~/.claude fallback arm). ` +
`Found snippet content:\n${content.trim()}`,
);
});
// --- (B) Representative propagated file contains the arm ------------------
test('(B) add-backlog.md (representative propagated file) contains the $HOME/.claude fallback arm', () => {
const content = fs.readFileSync(REPRESENTATIVE_FILE, 'utf8');
assert.ok(
content.includes(CLAUDE_HOME_PROBE),
`add-backlog.md must contain "${CLAUDE_HOME_PROBE}" after propagation. ` +
`Run \`node scripts/sync-runtime-launcher.cjs\` to propagate the updated snippet.`,
);
});
// --- (C) Behavioral: ~/.claude stub is resolved when local and PATH both miss
test('(C) gsd_run resolves $HOME/.claude/gsd-core/bin/ stub when no local install and gsd-tools not on PATH', () => {
// Build a fake $HOME with a stub at .claude/gsd-core/bin/gsd-tools.cjs
const fakeHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-211-home-'));
// RUNTIME_DIR points to a directory with no gsd-tools.cjs
const fakeRuntime = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-211-rt-'));
try {
const claudeBinDir = path.join(fakeHome, '.claude', 'gsd-core', 'bin');
fs.mkdirSync(claudeBinDir, { recursive: true });
// Stub gsd-tools.cjs that prints a marker
const stubPath = path.join(claudeBinDir, 'gsd-tools.cjs');
fs.writeFileSync(
stubPath,
'#!/usr/bin/env node\nconsole.log("CLAUDE_HOME_STUB:" + process.argv.slice(2).join(","));\n',
);
fs.chmodSync(stubPath, 0o755);
const snippet = fs.readFileSync(SNIPPET_FILE, 'utf8');
const scriptContent =
`unset GSD_TOOLS\n` +
`export RUNTIME_DIR=${JSON.stringify(fakeRuntime)}\n` +
`export HOME=${JSON.stringify(fakeHome)}\n` +
snippet +
`\nprintf "GSD_TOOLS=%s\\n" "$GSD_TOOLS"\n` +
`gsd_run ping test\n`;
const scriptPath = path.join(fakeRuntime, 'test-home-fb.sh');
fs.writeFileSync(scriptPath, scriptContent);
// Build a PATH with no gsd-tools binary to force the ~/.claude arm.
// Filter out directories that contain a gsd-tools executable. If node lives
// in the same directory as gsd-tools, create a dedicated shim dir with a
// symlink to node only (no gsd-tools there).
const nodeBin = execFileSync('which', ['node'], { encoding: 'utf8' }).trim();
const systemPaths = (process.env.PATH || '/usr/bin:/bin')
.split(path.delimiter)
.filter((p) => {
try {
fs.accessSync(path.join(p, 'gsd-tools'), fs.constants.X_OK);
return false;
} catch {
return true;
}
});
// If node's dir was filtered (it contained gsd-tools), create a shim dir
// with just a node symlink so the stub's shebang (#!/usr/bin/env node) resolves.
const nodeShimDir = path.join(fakeRuntime, 'node-shim');
if (!systemPaths.some((p) => {
try { fs.accessSync(path.join(p, 'node'), fs.constants.X_OK); return true; }
catch { return false; }
})) {
fs.mkdirSync(nodeShimDir, { recursive: true });
fs.symlinkSync(nodeBin, path.join(nodeShimDir, 'node'));
systemPaths.unshift(nodeShimDir);
}
const stdout = execFileSync('bash', [scriptPath], {
encoding: 'utf8',
env: { ...process.env, PATH: systemPaths.join(path.delimiter), HOME: fakeHome },
});
// GSD_TOOLS must point into the fake ~/.claude dir
const normStdout = stdout.replace(/\\/g, '/');
assert.ok(
normStdout.includes('.claude/gsd-core/bin/'),
`Expected GSD_TOOLS to resolve into .claude/gsd-core/bin/, got:\n${stdout.trim()}`,
);
// The stub must have been invoked
assert.ok(
stdout.includes('CLAUDE_HOME_STUB:ping,test'),
`Expected stub output "CLAUDE_HOME_STUB:ping,test", got:\n${stdout.trim()}`,
);
} finally {
cleanup(fakeHome);
cleanup(fakeRuntime);
}
});
// --- (D) All three miss -> hard error -------------------------------------
test('(D) hard error when local, PATH, and ~/.claude all miss', () => {
const fakeHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-211-nohome-'));
const fakeRuntime = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-211-nort-'));
// noToolsBin so PATH check finds nothing
const noToolsBin = path.join(fakeHome, 'nobin');
fs.mkdirSync(noToolsBin, { recursive: true });
// NO .claude/gsd-core/bin stub created in fakeHome
try {
const snippet = fs.readFileSync(SNIPPET_FILE, 'utf8');
const scriptContent =
`unset GSD_TOOLS\n` +
`export RUNTIME_DIR=${JSON.stringify(fakeRuntime)}\n` +
`export HOME=${JSON.stringify(fakeHome)}\n` +
snippet +
`\ngsd_run ping test\n`;
const scriptPath = path.join(fakeRuntime, 'test-allfail.sh');
fs.writeFileSync(scriptPath, scriptContent);
const systemPaths = (process.env.PATH || '/usr/bin:/bin')
.split(path.delimiter)
.filter((p) => {
try {
fs.accessSync(path.join(p, 'gsd-tools'), fs.constants.X_OK);
return false;
} catch {
return true;
}
});
const isolatedPath = [noToolsBin, ...systemPaths].join(path.delimiter);
let threw = false;
let stderrOutput = '';
try {
execFileSync('bash', [scriptPath], {
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'pipe'],
env: { ...process.env, PATH: isolatedPath, HOME: fakeHome },
});
} catch (err) {
threw = true;
stderrOutput = err.stderr || '';
}
assert.ok(threw, 'Expected non-zero exit when all three resolution arms miss');
assert.ok(
stderrOutput.includes('not found') || stderrOutput.includes('ERROR'),
`Expected stderr to contain "not found" or "ERROR", got: ${stderrOutput.trim()}`,
);
} finally {
cleanup(fakeHome);
cleanup(fakeRuntime);
}
});
});

View File

@@ -1,308 +0,0 @@
// allow-test-rule: structural-regression-guard
// The shebang line must be `#!/usr/bin/env bash` (PATH-resolved) rather than
// `#!/bin/bash` for cross-distro portability (NixOS, minimal Alpine do not
// ship /bin/bash). This is an architectural constraint that cannot be verified
// by executing the hooks — they run fine with either shebang on distros that
// have /bin/bash, so only a source assertion catches a future regression.
/**
* Regression tests for bug #2136 / #2206
*
* Root cause: three bash hooks (gsd-phase-boundary.sh, gsd-session-state.sh,
* gsd-validate-commit.sh) shipped without a gsd-hook-version header, and the
* stale-hook detector in gsd-check-update.js only matched JavaScript comment
* syntax (//) — not bash comment syntax (#).
*
* Result: every session showed "⚠ stale hooks — run /gsd-update" immediately
* after a fresh install, because the detector saw hookVersion: 'unknown' for
* all three bash hooks.
*
* This fix requires THREE parts working in concert:
* 1. Bash hooks ship with "# gsd-hook-version: {{GSD_VERSION}}"
* 2. install.js substitutes {{GSD_VERSION}} in .sh files at install time
* 3. gsd-check-update.js regex matches both "//" and "#" comment styles
*
* Neither fix alone is sufficient:
* - Headers + regex fix only (no install.js fix): installed hooks contain
* literal "{{GSD_VERSION}}" — the {{-guard silently skips them, making
* bash hook staleness permanently undetectable after future updates.
* - Headers + install.js fix only (no regex fix): installed hooks are
* stamped correctly but the detector still can't read bash "#" comments,
* so they still land in the "unknown / stale" branch on every session.
*/
'use strict';
// NOTE: Do NOT set GSD_TEST_MODE here — the E2E install tests spawn the
// real installer subprocess, which skips all install logic when GSD_TEST_MODE=1.
const { describe, test, before, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const os = require('os');
const { execFileSync } = require('child_process');
const HOOKS_DIR = path.join(__dirname, '..', 'hooks');
const _CHECK_UPDATE_FILE = path.join(HOOKS_DIR, 'gsd-check-update.js');
const WORKER_FILE = path.join(HOOKS_DIR, 'gsd-check-update-worker.js');
const INSTALL_SCRIPT = path.join(__dirname, '..', 'bin', 'install.js');
const BUILD_SCRIPT = path.join(__dirname, '..', 'scripts', 'build-hooks.js');
const SH_HOOKS = [
'gsd-phase-boundary.sh',
'gsd-session-state.sh',
'gsd-validate-commit.sh',
];
// ─── Ensure hooks/dist/ is populated before install tests ────────────────────
before(() => {
execFileSync(process.execPath, [BUILD_SCRIPT], {
encoding: 'utf-8',
stdio: 'pipe',
});
});
// ─── Helpers ─────────────────────────────────────────────────────────────────
function createTempDir(prefix) {
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
}
function cleanup(dir) {
// eslint-disable-next-line local/no-raw-rmsync-in-tests -- local cleanup() helper wrapping rmSync; cannot use imported cleanup() without naming collision
try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
}
function runInstaller(configDir) {
// --no-sdk: this test covers .sh hook version stamping only; skip SDK
// build (covered by install-smoke.yml).
execFileSync(process.execPath, [INSTALL_SCRIPT, '--claude', '--global', '--yes', '--no-sdk'], {
encoding: 'utf-8',
stdio: 'pipe',
env: { ...process.env, CLAUDE_CONFIG_DIR: configDir },
});
return path.join(configDir, 'hooks');
}
// ─────────────────────────────────────────────────────────────────────────────
// Part 1: Bash hook sources carry the version header placeholder
// ─────────────────────────────────────────────────────────────────────────────
describe('bug #2136 part 1: bash hook sources carry gsd-hook-version placeholder', () => {
for (const sh of SH_HOOKS) {
test(`${sh} contains "# gsd-hook-version: {{GSD_VERSION}}"`, () => {
const content = fs.readFileSync(path.join(HOOKS_DIR, sh), 'utf8');
assert.ok(
content.includes('# gsd-hook-version: {{GSD_VERSION}}'),
`${sh} must include "# gsd-hook-version: {{GSD_VERSION}}" so the ` +
`installer can stamp it and gsd-check-update.js can detect staleness`
);
});
}
test('version header is on line 2 (immediately after shebang)', () => {
// Placing the header immediately after the shebang ensures it is always
// found regardless of how much of the file is read. The shebang itself
// must use `#!/usr/bin/env bash` (PATH-resolved) rather than `#!/bin/bash`
// — POSIX guarantees /bin/sh but not /bin/bash, and distros like NixOS
// do not ship /bin/bash by default.
for (const sh of SH_HOOKS) {
const lines = fs.readFileSync(path.join(HOOKS_DIR, sh), 'utf8').split(/\r?\n/);
assert.strictEqual(
lines[0],
'#!/usr/bin/env bash',
`${sh} line 1 must be "#!/usr/bin/env bash" for cross-distro portability`
);
assert.ok(
lines[1].startsWith('# gsd-hook-version:'),
`${sh} line 2 must be the gsd-hook-version header (got: "${lines[1]}")`
);
}
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Part 2: gsd-check-update-worker.js regex handles bash "#" comment syntax
// (Logic moved from inline -e template literal to dedicated worker file)
// ─────────────────────────────────────────────────────────────────────────────
describe('bug #2136 part 2: stale-hook detector handles bash comment syntax', () => {
let src;
before(() => {
src = fs.readFileSync(WORKER_FILE, 'utf8');
});
test('version regex in source matches "#" comment syntax in addition to "//"', () => {
// The regex string in the source must contain the alternation for "#".
// The worker uses plain JS (no template-literal escaping), so the form is
// "(?:\/\/|#)" directly in source.
const hasBashAlternative =
src.includes('(?:\\/\\/|#)') || // escaped form (old template-literal style)
src.includes('(?://|#)'); // direct form in plain JS worker
assert.ok(
hasBashAlternative,
'gsd-check-update-worker.js version regex must include an alternative for bash "#" comments. ' +
'Expected to find (?:\\/\\/|#) or (?://|#) in the source. ' +
'The original "//" only regex causes bash hooks to always report hookVersion: "unknown"'
);
});
test('version regex does not use the old JS-only form as the sole pattern', () => {
// The old regex inside the template literal was the string:
// /\\/\\/ gsd-hook-version:\\s*(.+)/
// which, when evaluated in the subprocess, produced: /\/\/ gsd-hook-version:\s*(.+)/
// That only matched JS "//" comments — never bash "#".
// We verify that the old exact string no longer appears.
assert.ok(
!src.includes('\\/\\/ gsd-hook-version'),
'gsd-check-update-worker.js must not use the old JS-only (\\/\\/ gsd-hook-version) ' +
'escape form as the sole version matcher — it cannot match bash "#" comments'
);
});
test('version regex correctly matches both bash and JS hook version headers', () => {
// Verify that the versionMatch line in the source uses a regex that matches
// both bash "#" and JS "//" comment styles. We check the source contains the
// expected alternation, then directly test the known required pattern.
//
// We do NOT try to extract and evaluate the regex from source (it contains ")"
// which breaks simple extraction), so instead we confirm the source matches
// our expectation and run the regex itself.
assert.ok(
src.includes('gsd-hook-version'),
'gsd-check-update-worker.js must contain a gsd-hook-version version check'
);
// The fixed regex that must be present: matches both comment styles
const fixedRegex = /(?:\/\/|#) gsd-hook-version:\s*(.+)/;
assert.ok(
fixedRegex.test('# gsd-hook-version: 1.36.0'),
'bash-style "# gsd-hook-version: X" must be matchable by the required regex'
);
assert.ok(
fixedRegex.test('// gsd-hook-version: 1.36.0'),
'JS-style "// gsd-hook-version: X" must still match (no regression)'
);
assert.ok(
!fixedRegex.test('gsd-hook-version: 1.36.0'),
'line without a comment prefix must not match (prevents false positives)'
);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Part 4: End-to-end — installed .sh hooks have stamped version, not placeholder
// ─────────────────────────────────────────────────────────────────────────────
describe('bug #2136 part 4: installed .sh hooks contain stamped concrete version', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempDir('gsd-2136-install-');
});
afterEach(() => {
cleanup(tmpDir);
});
test('installed .sh hooks contain a concrete version string, not the template placeholder', () => {
const hooksDir = runInstaller(tmpDir);
for (const sh of SH_HOOKS) {
const hookPath = path.join(hooksDir, sh);
assert.ok(fs.existsSync(hookPath), `${sh} must be installed`);
const content = fs.readFileSync(hookPath, 'utf8');
assert.ok(
content.includes('# gsd-hook-version:'),
`installed ${sh} must contain a "# gsd-hook-version:" header`
);
assert.ok(
!content.includes('{{GSD_VERSION}}'),
`installed ${sh} must not contain literal "{{GSD_VERSION}}" — ` +
`install.js must substitute it with the concrete package version`
);
const versionMatch = content.match(/# gsd-hook-version:\s*(\S+)/);
assert.ok(versionMatch, `installed ${sh} version header must have a version value`);
assert.match(
versionMatch[1],
/^\d+\.\d+\.\d+/,
`installed ${sh} version "${versionMatch[1]}" must be a semver-like string`
);
}
});
test('stale-hook detector reports zero stale bash hooks immediately after fresh install', () => {
// This is the definitive end-to-end proof: after install, run the actual
// version-check logic (extracted from gsd-check-update.js) against the
// installed hooks and verify none are flagged stale.
const hooksDir = runInstaller(tmpDir);
const pkg = require(path.join(__dirname, '..', 'package.json'));
const installedVersion = pkg.version;
// Build a subprocess that runs the staleness check logic in isolation.
// We pass the installed version, hooks dir, and hook filenames as JSON
// to avoid any injection risk.
const checkScript = `
'use strict';
const fs = require('fs');
const path = require('path');
function isNewer(a, b) {
const pa = (a || '').split('.').map(s => Number(s.replace(/-.*/, '')) || 0);
const pb = (b || '').split('.').map(s => Number(s.replace(/-.*/, '')) || 0);
for (let i = 0; i < 3; i++) {
if (pa[i] > pb[i]) return true;
if (pa[i] < pb[i]) return false;
}
return false;
}
const hooksDir = ${JSON.stringify(hooksDir)};
const installed = ${JSON.stringify(installedVersion)};
const shHooks = ${JSON.stringify(SH_HOOKS)};
// Use the same regex that the fixed gsd-check-update.js uses
const versionRe = /(?:\\/\\/|#) gsd-hook-version:\\s*(.+)/;
const staleHooks = [];
for (const hookFile of shHooks) {
const hookPath = path.join(hooksDir, hookFile);
if (!fs.existsSync(hookPath)) {
staleHooks.push({ file: hookFile, hookVersion: 'missing' });
continue;
}
const content = fs.readFileSync(hookPath, 'utf8');
const m = content.match(versionRe);
if (m) {
const hookVersion = m[1].trim();
if (isNewer(installed, hookVersion) && !hookVersion.includes('{{')) {
staleHooks.push({ file: hookFile, hookVersion, installedVersion: installed });
}
} else {
staleHooks.push({ file: hookFile, hookVersion: 'unknown', installedVersion: installed });
}
}
process.stdout.write(JSON.stringify(staleHooks));
`;
const result = execFileSync(process.execPath, ['-e', checkScript], { encoding: 'utf8' });
const staleHooks = JSON.parse(result);
assert.deepStrictEqual(
staleHooks,
[],
`Fresh install must produce zero stale bash hooks.\n` +
`Got: ${JSON.stringify(staleHooks, null, 2)}\n` +
`This indicates either the version header was not stamped by install.js, ` +
`or the detector regex cannot match bash "#" comment syntax.`
);
});
});

View File

@@ -1,101 +0,0 @@
/**
* Regression test for bug #2344
*
* gsd-read-guard.js checked process.env.CLAUDE_SESSION_ID to detect the
* Claude Code runtime and skip its advisory. However, Claude Code CLI exports
* CLAUDECODE=1, not CLAUDE_SESSION_ID. The skip never fired, so the
* READ-BEFORE-EDIT advisory injected on every Edit/Write call inside Claude
* Code — producing noise in long-running sessions.
*
* Fix: check CLAUDECODE (and CLAUDE_SESSION_ID for back-compat) before
* emitting the advisory.
*/
process.env.GSD_TEST_MODE = '1';
const { test, describe, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { execFileSync } = require('node:child_process');
const { createTempDir, cleanup } = require('./helpers.cjs');
const HOOK_PATH = path.join(__dirname, '..', 'hooks', 'gsd-read-guard.js');
function runHook(payload, envOverrides = {}) {
const input = JSON.stringify(payload);
const env = {
...process.env,
CLAUDE_SESSION_ID: '',
CLAUDECODE: '',
CLAUDE_CODE_ENTRYPOINT: '',
CLAUDE_CODE_SSE_PORT: '',
CLAUDE_PROJECT_DIR: '',
...envOverrides,
};
try {
const stdout = execFileSync(process.execPath, [HOOK_PATH], {
input,
encoding: 'utf-8',
timeout: 5000,
stdio: ['pipe', 'pipe', 'pipe'],
env,
});
return { exitCode: 0, stdout: stdout.trim(), stderr: '' };
} catch (err) {
return {
exitCode: err.status ?? 1,
stdout: (err.stdout || '').toString().trim(),
stderr: (err.stderr || '').toString().trim(),
};
}
}
describe('bug #2344: read guard skips on CLAUDECODE env var', () => {
let tmpDir;
beforeEach(() => { tmpDir = createTempDir('gsd-read-guard-2344-'); });
afterEach(() => { cleanup(tmpDir); });
test('skips advisory when CLAUDECODE=1 is set (Claude Code CLI env)', () => {
const filePath = path.join(tmpDir, 'existing.js');
fs.writeFileSync(filePath, 'const x = 1;\n');
const result = runHook(
{ tool_name: 'Edit', tool_input: { file_path: filePath, old_string: 'const x = 1;', new_string: 'const x = 2;' } },
{ CLAUDECODE: '1' }
);
assert.equal(result.exitCode, 0);
assert.equal(result.stdout, '', 'advisory must not fire when CLAUDECODE=1');
});
test('skips advisory when CLAUDE_SESSION_ID is set (back-compat)', () => {
const filePath = path.join(tmpDir, 'existing.js');
fs.writeFileSync(filePath, 'const x = 1;\n');
const result = runHook(
{ tool_name: 'Edit', tool_input: { file_path: filePath, old_string: 'const x = 1;', new_string: 'const x = 2;' } },
{ CLAUDE_SESSION_ID: 'test-session-123' }
);
assert.equal(result.exitCode, 0);
assert.equal(result.stdout, '', 'advisory must not fire when CLAUDE_SESSION_ID is set');
});
test('still injects advisory when neither CLAUDECODE nor CLAUDE_SESSION_ID is set', () => {
const filePath = path.join(tmpDir, 'existing.js');
fs.writeFileSync(filePath, 'const x = 1;\n');
const result = runHook(
{ tool_name: 'Edit', tool_input: { file_path: filePath, old_string: 'const x = 1;', new_string: 'const x = 2;' } },
{ CLAUDECODE: '', CLAUDE_SESSION_ID: '' }
);
assert.equal(result.exitCode, 0);
assert.ok(result.stdout.length > 0, 'advisory should fire on non-Claude-Code runtimes');
const output = JSON.parse(result.stdout);
assert.ok(output.hookSpecificOutput?.additionalContext?.includes('Read'));
});
});

View File

@@ -1,185 +0,0 @@
/**
* Regression test for bug #2451
*
* The GSD context monitor hook over-reports usage by ~13 percentage points
* compared to Claude Code's native /context command. The root cause:
*
* gsd-statusline.js writes two values to the bridge file:
* - remaining_percentage: raw remaining from CC (e.g. 35%)
* - used_pct: normalized "usable" percentage (e.g. 78%) — accounts for
* the 16.5% autocompact buffer by scaling: (100 - remaining - buffer) /
* (100 - buffer) * 100
*
* gsd-context-monitor.js displays used_pct (78%) in warning messages.
* But CC's native /context shows raw used = 100 - remaining = 65%.
* The 13-point gap is exactly the buffer normalization overhead.
*
* Fix: the bridge must write used_pct as the raw value (Math.round(100 -
* remaining)), not the buffer-normalized value. The statusline progress bar
* continues to use the normalized value for its own display; only the bridge
* value that feeds the context monitor needs to be raw/CC-consistent.
*/
'use strict';
const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { execFileSync } = require('node:child_process');
const HOOK_PATH = path.join(__dirname, '..', 'hooks', 'gsd-statusline.js');
const MONITOR_PATH = path.join(__dirname, '..', 'hooks', 'gsd-context-monitor.js');
/**
* Run the statusline hook with a synthetic payload and return the full
* bridge JSON object written to /tmp/claude-ctx-{sessionId}.json.
*/
function runStatuslineHook(remainingPct, totalTokens = 1_000_000, acwEnv = null) {
const sessionId = `test-2451-${Date.now()}-${Math.random().toString(36).slice(2)}`;
const payload = JSON.stringify({
model: { display_name: 'Claude' },
workspace: { current_dir: os.tmpdir() },
session_id: sessionId,
context_window: {
remaining_percentage: remainingPct,
total_tokens: totalTokens,
},
});
const env = { ...process.env };
if (acwEnv != null) {
env.CLAUDE_CODE_AUTO_COMPACT_WINDOW = String(acwEnv);
} else {
delete env.CLAUDE_CODE_AUTO_COMPACT_WINDOW;
}
try {
execFileSync(process.execPath, [HOOK_PATH], {
input: payload,
env,
timeout: 4000,
});
} catch { /* non-zero exit is fine; we only need the bridge file */ }
const bridgePath = path.join(os.tmpdir(), `claude-ctx-${sessionId}.json`);
const bridge = JSON.parse(fs.readFileSync(bridgePath, 'utf-8'));
fs.unlinkSync(bridgePath);
return bridge;
}
/**
* Run the context monitor hook with a pre-written bridge file and return
* the parsed additionalContext string from its stdout.
*/
function runMonitorHook(remainingPct, usedPct) {
const sessionId = `test-2451-mon-${Date.now()}-${Math.random().toString(36).slice(2)}`;
const bridgePath = path.join(os.tmpdir(), `claude-ctx-${sessionId}.json`);
fs.writeFileSync(bridgePath, JSON.stringify({
session_id: sessionId,
remaining_percentage: remainingPct,
used_pct: usedPct,
timestamp: Math.floor(Date.now() / 1000),
}));
const input = JSON.stringify({ session_id: sessionId, cwd: os.tmpdir() });
let stdout = '';
try {
stdout = execFileSync(process.execPath, [MONITOR_PATH], {
input,
encoding: 'utf-8',
timeout: 5000,
});
} catch (e) {
stdout = e.stdout || '';
} finally {
try { fs.unlinkSync(bridgePath); } catch { /* noop */ }
try { fs.unlinkSync(path.join(os.tmpdir(), `claude-ctx-${sessionId}-warned.json`)); } catch { /* noop */ }
}
if (!stdout) return null;
const out = JSON.parse(stdout);
return out?.hookSpecificOutput?.additionalContext || null;
}
// ─── Bridge file used_pct accuracy ──────────────────────────────────────────
describe('bug #2451: bridge used_pct matches CC native reporting', () => {
test('used_pct is raw (100 - remaining), not buffer-normalized', () => {
// CC reports remaining_percentage=35 → CC native "used" = 100-35 = 65%
// Buffer-normalized would give: (100 - (35-16.5)/(100-16.5)*100) ≈ 78%
// The bridge used_pct must be 65 (raw), not 78 (normalized).
const bridge = runStatuslineHook(35);
assert.strictEqual(
bridge.used_pct,
65,
`used_pct should be 65 (raw: 100 - 35) but got ${bridge.used_pct}. ` +
'Buffer normalization must NOT be applied to the bridge used_pct, ' +
'otherwise context monitor messages over-report usage by ~13 points ' +
'compared to CC native /context (root cause of #2451).'
);
});
test('used_pct is raw for high remaining (low usage scenario)', () => {
// remaining=80 → raw used = 20
const bridge = runStatuslineHook(80);
assert.strictEqual(bridge.used_pct, 20,
`used_pct should be 20 (raw: 100-80) but got ${bridge.used_pct}`);
});
test('used_pct is raw for near-critical remaining', () => {
// remaining=20 → raw used = 80
const bridge = runStatuslineHook(20);
assert.strictEqual(bridge.used_pct, 80,
`used_pct should be 80 (raw: 100-20) but got ${bridge.used_pct}`);
});
test('remaining_percentage in bridge matches raw CC value', () => {
// The bridge remaining_percentage should be the exact raw value from CC
const bridge = runStatuslineHook(42);
assert.strictEqual(bridge.remaining_percentage, 42,
'bridge remaining_percentage must be the raw CC value (no normalization)');
});
});
// ─── Context monitor message accuracy ───────────────────────────────────────
describe('bug #2451: context monitor warning messages show CC-consistent percentages', () => {
test('WARNING message shows raw used_pct consistent with CC reporting', () => {
// remaining=30 → raw used=70; bridge stores used_pct=70
// Monitor message must say "Usage at 70%", not a buffer-inflated value
const msg = runMonitorHook(30, 70);
assert.ok(msg, 'hook should emit a warning when remaining=30 (below WARNING_THRESHOLD=35)');
assert.match(
msg,
/Usage at 70%/,
`Warning message should say "Usage at 70%" (raw), got: ${msg}`
);
});
test('CRITICAL message shows raw used_pct consistent with CC reporting', () => {
// remaining=20 → raw used=80
const msg = runMonitorHook(20, 80);
assert.ok(msg, 'hook should emit a critical warning when remaining=20 (below CRITICAL_THRESHOLD=25)');
assert.match(
msg,
/Usage at 80%/,
`Critical message should say "Usage at 80%" (raw), got: ${msg}`
);
});
test('gap between hook used_pct and raw CC value is at most 1 (rounding)', () => {
// With the fix, the only acceptable deviation is ±1 due to Math.round
const rawRemaining = 35;
const bridge = runStatuslineHook(rawRemaining);
const ccNativeUsed = 100 - rawRemaining; // 65
const gap = Math.abs(bridge.used_pct - ccNativeUsed);
assert.ok(
gap <= 1,
`Gap between hook used_pct (${bridge.used_pct}) and CC native used (${ccNativeUsed}) ` +
`is ${gap} points — must be ≤1 (rounding). Larger gaps indicate buffer normalization ` +
'is still being applied to bridge used_pct (root cause of #2451).'
);
});
});

View File

@@ -1,129 +0,0 @@
/**
* Regression test for bug #2520
*
* The fix for #2344 added `|| process.env.CLAUDECODE` to the Claude Code
* skip check. That works in principle — CLAUDECODE=1 is propagated to Bash
* tool subprocesses — but it does NOT reach hook subprocesses on Claude Code
* v2.1.116. Claude Code applies a separate env filter when spawning
* PreToolUse hook commands; that filter drops bare CLAUDECODE and
* CLAUDE_SESSION_ID and keeps only CLAUDE_CODE_*-prefixed vars plus
* CLAUDE_PROJECT_DIR. `data.session_id` is, however, reliably delivered via
* the hook's stdin JSON payload (documented part of Claude Code's hook
* input schema).
*
* Fix: use `data.session_id` as the primary Claude Code signal, with
* CLAUDE_CODE_ENTRYPOINT / CLAUDE_CODE_SSE_PORT as env-var fallbacks, and
* keep legacy CLAUDECODE / CLAUDE_SESSION_ID for back-compat and
* future-proofing.
*/
process.env.GSD_TEST_MODE = '1';
const { test, describe, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { execFileSync } = require('node:child_process');
const { createTempDir, cleanup } = require('./helpers.cjs');
const HOOK_PATH = path.join(__dirname, '..', 'hooks', 'gsd-read-guard.js');
/**
* Spawn the hook with an env that mirrors the actual Claude Code hook
* subprocess env: CLAUDECODE and CLAUDE_SESSION_ID are stripped, only
* CLAUDE_CODE_*-prefixed vars (plus CLAUDE_PROJECT_DIR) remain. Extra env
* overrides can be supplied via `envOverrides`.
*/
function runHookInClaudeCodeSubprocess(payload, envOverrides = {}) {
const input = JSON.stringify(payload);
const baseEnv = { ...process.env };
// Strip env vars Claude Code does NOT propagate to hook subprocesses.
delete baseEnv.CLAUDECODE;
delete baseEnv.CLAUDE_SESSION_ID;
const env = {
...baseEnv,
// Env vars Claude Code DOES propagate to hook subprocesses (observed on
// Claude Code CLI 2.1.116).
CLAUDE_CODE_ENTRYPOINT: 'cli',
CLAUDE_CODE_SSE_PORT: '51291',
CLAUDE_PROJECT_DIR: process.cwd(),
...envOverrides,
};
try {
const stdout = execFileSync(process.execPath, [HOOK_PATH], {
input,
encoding: 'utf-8',
timeout: 5000,
stdio: ['pipe', 'pipe', 'pipe'],
env,
});
return { exitCode: 0, stdout: stdout.trim(), stderr: '' };
} catch (err) {
return {
exitCode: err.status ?? 1,
stdout: (err.stdout || '').toString().trim(),
stderr: (err.stderr || '').toString().trim(),
};
}
}
describe('bug #2520: read guard detects Claude Code without relying on CLAUDECODE env', () => {
let tmpDir;
beforeEach(() => { tmpDir = createTempDir('gsd-read-guard-2520-'); });
afterEach(() => { cleanup(tmpDir); });
test('skips advisory when stdin payload includes session_id (Claude Code hook-subprocess env)', () => {
const filePath = path.join(tmpDir, 'existing.js');
fs.writeFileSync(filePath, 'const x = 1;\n');
// Isolate the stdin `session_id` signal by clearing the CLAUDE_CODE_*
// env fallbacks the helper normally provides. Without this the env
// fallback would rescue the skip even if session_id detection broke,
// hiding a regression of the primary signal.
const result = runHookInClaudeCodeSubprocess(
{
session_id: 'e7123e54-0977-45dd-848a-b9c8a45a5cd3',
tool_name: 'Edit',
tool_input: { file_path: filePath, old_string: 'const x = 1;', new_string: 'const x = 2;' },
},
{ CLAUDE_CODE_ENTRYPOINT: '', CLAUDE_CODE_SSE_PORT: '', CLAUDE_PROJECT_DIR: '' },
);
assert.equal(result.exitCode, 0);
assert.equal(
result.stdout,
'',
'advisory must not fire when session_id is present on stdin (real Claude Code hook env)',
);
});
test('skips advisory when CLAUDE_CODE_ENTRYPOINT is set (env-var fallback, no session_id on stdin)', () => {
const filePath = path.join(tmpDir, 'existing.js');
fs.writeFileSync(filePath, 'const x = 1;\n');
const result = runHookInClaudeCodeSubprocess(
{ tool_name: 'Edit', tool_input: { file_path: filePath, old_string: 'const x = 1;', new_string: 'const x = 2;' } },
{ CLAUDE_CODE_ENTRYPOINT: 'cli', CLAUDE_CODE_SSE_PORT: '' },
);
assert.equal(result.exitCode, 0);
assert.equal(result.stdout, '', 'advisory must not fire when CLAUDE_CODE_ENTRYPOINT is set');
});
test('still injects advisory when no Claude Code signal is present (non-Claude host)', () => {
const filePath = path.join(tmpDir, 'existing.js');
fs.writeFileSync(filePath, 'const x = 1;\n');
const result = runHookInClaudeCodeSubprocess(
{ tool_name: 'Edit', tool_input: { file_path: filePath, old_string: 'const x = 1;', new_string: 'const x = 2;' } },
{ CLAUDE_CODE_ENTRYPOINT: '', CLAUDE_CODE_SSE_PORT: '', CLAUDE_PROJECT_DIR: '' },
);
assert.equal(result.exitCode, 0);
assert.ok(result.stdout.length > 0, 'advisory should fire on non-Claude-Code hosts');
const output = JSON.parse(result.stdout);
assert.ok(output.hookSpecificOutput?.additionalContext?.includes('Read'));
});
});

View File

@@ -1,193 +0,0 @@
'use strict';
// allow-test-rule: structural-regression-guard
/**
* Slash-command namespace invariant (#3443) — SCOPED ACTIVE VARIANT.
*
* History:
* #3443 re-establishes `/gsd:<cmd>` as canonical in Claude-facing source text.
* The source repo is authored for Claude command registration under
* `.claude/commands/gsd/` (namespaced slash commands), while non-Claude runtimes
* perform install-time conversion (for example `/gsd:<cmd>` -> `/gsd-<cmd>`).
*
* Two-tier model (current — see CONTEXT.md § "Slash-command form: directory-level matrix"):
* • Claude-facing SOURCE TEXT (commands/, agents/, workflows/, references/,
* templates/, hooks/, .clinerules): uses `/gsd:<cmd>` (colon).
* THIS test enforces the colon invariant over those directories.
* • Runtime-emitter contexts (runtime-slash.cjs, phase-lifecycle-policy.ts,
* *.generated.cjs, bug-3584 test file): use `/gsd-<cmd>` (hyphen) per
* bug-3584's contract. Those files are EXCLUDED from this scan.
*
* Scoped invariant enforced here:
* No `/gsd-<cmd>` pattern in Claude-facing source files, EXCLUDING the
* runtime-emitter contexts listed in RUNTIME_EMITTER_EXCLUDES below.
*
* Canonical reference for the runtime-emitter (hyphen-form) contract:
* tests/bug-3584-runtime-slash-emitters.test.cjs
*
* DO NOT expand RUNTIME_EMITTER_EXCLUDES without also updating the bug-3584
* test and CONTEXT.md § "Slash-command form: directory-level matrix".
*
* See also: PR #154 first-pass incident (agent applied outdated invariant,
* broke bug-3584 contract); PR #164 Codex adversarial review (surfaced the
* need to re-activate this test with explicit exclusions).
*/
const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const ROOT = path.join(__dirname, '..');
const COMMANDS_DIR = path.join(ROOT, 'commands', 'gsd');
// Runtime-emitter contexts: these files intentionally emit `/gsd-<cmd>` (hyphen)
// as part of the bug-3584 runtime contract. They must NOT be scanned by this
// invariant — doing so caused PR #154 first-pass to revert correct hyphen form
// to colon form, breaking bug-3584-runtime-slash-emitters.test.cjs.
//
// Expand this list only if a new runtime-emitter module is introduced AND the
// bug-3584 test is updated to cover it.
const SEARCH_DIRS = [
// NOTE: gsd-core/bin/lib is intentionally EXCLUDED from SEARCH_DIRS.
// runtime-slash.cjs and *.generated.cjs live there and use the hyphen form
// per bug-3584's runtime-emitter contract. The full bin/lib tree is
// runtime-emitter territory — scanning it would cause false positives.
path.join(ROOT, 'gsd-core', 'workflows'),
path.join(ROOT, 'gsd-core', 'references'),
path.join(ROOT, 'gsd-core', 'templates'),
COMMANDS_DIR,
path.join(ROOT, 'agents'),
path.join(ROOT, 'hooks'),
];
const TOP_LEVEL_FILES = [
path.join(ROOT, '.clinerules'),
];
// Re-use SKIP_DIRS from the production script so the test's directory walker
// stays in lockstep with the fixer's. EXTENSIONS legitimately diverges (the
// guard scans only `.md`/`.cjs`/`.js` per the no-source-grep standard, while
// the fixer also rewrites `.ts`/`.tsx`), so it is not shared.
const { SKIP_DIRS } = require(path.join(ROOT, 'scripts', 'fix-slash-commands.cjs'));
const EXTENSIONS = new Set(['.md', '.cjs', '.js']);
function collectFiles(dir, results = []) {
let entries;
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return results; }
for (const e of entries) {
const full = path.join(dir, e.name);
if (e.isDirectory()) {
if (SKIP_DIRS.has(e.name)) continue;
collectFiles(full, results);
}
else if (EXTENSIONS.has(path.extname(e.name))) results.push(full);
}
return results;
}
const cmdNames = fs.readdirSync(COMMANDS_DIR)
.filter(f => f.endsWith('.md'))
.map(f => f.replace(/\.md$/, ''))
.sort((a, b) => b.length - a.length);
const retiredPattern = new RegExp(`/gsd-(${cmdNames.join('|')})(?=[^a-zA-Z0-9_-]|$)`);
const allFiles = SEARCH_DIRS.flatMap(d => collectFiles(d));
const topLevelFiles = TOP_LEVEL_FILES.filter((file) => fs.existsSync(file));
const allUserFacingFiles = allFiles.concat(topLevelFiles);
describe('slash-command namespace invariant (#3443)', () => {
test('commands/gsd/ directory contains known command files', () => {
assert.ok(cmdNames.length > 0, 'commands/gsd/ must contain .md files');
assert.ok(cmdNames.includes('plan-phase'), 'plan-phase must be a known command');
assert.ok(cmdNames.includes('execute-phase'), 'execute-phase must be a known command');
});
// SCOPED ACTIVE INVARIANT (2026-05-23 re-activation after Codex adversarial review of PR #164).
//
// Scan is scoped to Claude-facing source directories only (SEARCH_DIRS above).
// gsd-core/bin/lib/ is excluded entirely — runtime-slash.cjs and
// *.generated.cjs there use hyphen form per bug-3584's runtime-emitter contract.
//
// If this test fails: check CONTEXT.md § "Slash-command form: directory-level matrix"
// before deciding whether to update the file or add to RUNTIME_EMITTER_EXCLUDES.
test('no /gsd-<cmd> retired syntax in Claude-facing source files (scoped — excludes runtime-emitter contexts)', () => {
const violations = [];
for (const file of allUserFacingFiles) {
const src = fs.readFileSync(file, 'utf-8');
const lines = src.split(/\r?\n/);
for (let i = 0; i < lines.length; i++) {
if (retiredPattern.test(lines[i])) {
violations.push(`${path.relative(ROOT, file)}:${i + 1}: ${lines[i].trim().slice(0, 80)}`);
}
}
}
assert.strictEqual(
violations.length,
0,
`Found ${violations.length} retired /gsd-<cmd> reference(s) — use /gsd:<cmd> instead:\n${violations.slice(0, 10).join('\n')}`,
);
});
test('command filenames use canonical hyphenated command slugs', () => {
const underscoreFiles = fs.readdirSync(COMMANDS_DIR)
.filter((f) => f.endsWith('.md') && f.includes('_'));
assert.deepStrictEqual(
underscoreFiles,
[],
'command filenames feed generated skill/autocomplete names and must not contain underscores',
);
});
describe('fix-slash-commands transformer behavior', () => {
const { transformContent } = require(path.join(ROOT, 'scripts', 'fix-slash-commands.cjs'));
// Use the live command names so the transformer matches the same surface
// the production CLI rewrites.
const liveCmdNames = cmdNames;
test('rewrites /gsd-<cmd> to /gsd:<cmd>', () => {
const out = transformContent('See /gsd-plan-phase for details.', liveCmdNames);
assert.ok(out.includes('/gsd:plan-phase'), `expected /gsd:plan-phase, got: ${out}`);
assert.ok(!out.includes('/gsd-plan-phase'), `dash form must not survive, got: ${out}`);
});
test('rewrites multiple occurrences in one pass', () => {
const out = transformContent('Run /gsd-plan-phase then /gsd-execute-phase.', liveCmdNames);
assert.ok(out.includes('/gsd:plan-phase'));
assert.ok(out.includes('/gsd:execute-phase'));
assert.ok(!out.match(/\/gsd-[a-z]/), `no dash form may remain, got: ${out}`);
});
test('does not rewrite canonical colon form (idempotent)', () => {
const input = '/gsd:plan-phase is the canonical name.';
assert.strictEqual(transformContent(input, liveCmdNames), input,
'transformer must be a no-op when input is already canonical');
});
test('does not rewrite gsd-sdk or gsd-tools (not slash commands)', () => {
const input = 'Run /gsd-sdk query and /gsd-tools init.';
assert.strictEqual(transformContent(input, liveCmdNames), input,
'transformer must leave non-command identifiers alone');
});
test('respects word boundary — does not rewrite /gsd-plan-phase-extra', () => {
const out = transformContent('/gsd-plan-phase-extra', liveCmdNames);
assert.strictEqual(out, '/gsd-plan-phase-extra',
'word-boundary lookahead must prevent partial matches');
});
});
test('transformer leaves non-command identifiers untouched', () => {
const { transformContent } = require(path.join(ROOT, 'scripts', 'fix-slash-commands.cjs'));
const sample = 'Use /gsd-sdk query and node bin/gsd-tools.cjs';
assert.strictEqual(
transformContent(sample, cmdNames),
sample,
'gsd-sdk and gsd-tools are not slash commands and must remain untouched'
);
});
});

View File

@@ -1,660 +0,0 @@
/**
* Regression tests for bug #260 — gsd-worktree-path-guard.js
*
* Executor agents spawned with isolation="worktree" sometimes issue Edit/Write
* calls with absolute paths rooted at the MAIN repository instead of the
* worktree. The prose guard in gsd-executor.md step 0b is skipped under load,
* so we enforce the constraint at the tooling layer with a PreToolUse hook.
*
* This file verifies all guard behaviours:
* 1. No-op in the main repo (.git is a directory)
* 2. Relative path always passes
* 3. Non-Edit/Write tools always pass
* 4. Absolute path inside worktree root passes
* 5. Absolute path outside worktree root is BLOCKED (exit 2)
* 6. Sibling path that merely shares a prefix is BLOCKED (/ boundary check)
* 7. install.js has an fs.existsSync guard for gsd-worktree-path-guard.js
*/
'use strict';
const { describe, test, before, after } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { spawnSync, execFileSync } = require('node:child_process');
const { cleanup } = require('./helpers.cjs');
const HOOK_PATH = path.join(__dirname, '..', 'hooks', 'gsd-worktree-path-guard.js');
const INSTALL_SRC = path.join(__dirname, '..', 'bin', 'install.js');
// ADR-857 phase 5f-1b: settings-json hook registration moved to runtime-hooks-surface.cts.
const HOOKS_SURFACE_SRC = path.join(__dirname, '..', 'src', 'runtime-hooks-surface.cts');
/**
* Resolve symlinks in a path so that we compare the same canonical form
* that `git rev-parse --show-toplevel` returns. On macOS /tmp is a symlink
* to /private/tmp, which causes path prefix checks to fail without this.
*/
function realp(p) {
try { return fs.realpathSync(p); } catch { return p; }
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function git(cwd, args) {
return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
}
/**
* Create a plain git repo (main repo — .git is a directory).
*/
function makeMainRepo() {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-260-main-'));
git(dir, ['init', '-q']);
git(dir, ['config', 'user.email', 'test@example.com']);
git(dir, ['config', 'user.name', 'Test User']);
git(dir, ['config', 'commit.gpgsign', 'false']);
fs.writeFileSync(path.join(dir, 'README.md'), '# test\n');
git(dir, ['add', 'README.md']);
git(dir, ['commit', '-q', '-m', 'chore: init']);
return dir;
}
/**
* Create a worktree off mainRepo and return its path.
* In the worktree, .git is a FILE (the gitdir pointer).
* @param {string} mainRepo - path to the main repo
* @param {string} [branchName] - branch name to use (default: 'worktree-agent-test')
*/
function makeWorktree(mainRepo, branchName) {
const branch = branchName || 'worktree-agent-test';
const wtDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-260-wt-'));
fs.rmdirSync(wtDir); // git worktree add creates the dir itself
git(mainRepo, ['worktree', 'add', '-q', '-b', branch, wtDir]);
return wtDir;
}
/**
* Run the hook with a given payload, returning the spawnSync result.
*/
function runHook(cwd, payload) {
return spawnSync(process.execPath, [HOOK_PATH], {
cwd,
input: JSON.stringify(payload),
encoding: 'utf8',
});
}
// ---------------------------------------------------------------------------
// Fixture lifecycle
// ---------------------------------------------------------------------------
let mainRepo;
let worktreeDir;
before(() => {
mainRepo = realp(makeMainRepo());
worktreeDir = realp(makeWorktree(mainRepo));
});
after(() => {
// Remove worktree registration before deleting the directory
try { git(mainRepo, ['worktree', 'remove', '--force', worktreeDir]); } catch { /* ignore */ }
cleanup(mainRepo);
cleanup(worktreeDir);
});
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('bug #260: gsd-worktree-path-guard.js', () => {
// 1. No-op in main repo
describe('no-op in main repo', () => {
test('Edit call in main repo (.git is a directory) exits 0', () => {
const payload = {
cwd: mainRepo,
tool_name: 'Edit',
tool_input: { file_path: path.join(mainRepo, 'src', 'foo.ts') },
};
const result = runHook(mainRepo, payload);
assert.strictEqual(result.status, 0, `Expected exit 0 in main repo, got ${result.status}. stderr: ${result.stderr}`);
assert.strictEqual(result.stdout, '', 'Expected no stdout in main repo no-op');
});
test('Write call in main repo exits 0', () => {
const payload = {
cwd: mainRepo,
tool_name: 'Write',
tool_input: { file_path: path.join(mainRepo, 'out.txt') },
};
const result = runHook(mainRepo, payload);
assert.strictEqual(result.status, 0);
assert.strictEqual(result.stdout, '');
});
});
// 2. Relative path always passes
describe('relative path', () => {
test('Edit with relative file_path exits 0 even in worktree', () => {
const payload = {
cwd: worktreeDir,
tool_name: 'Edit',
tool_input: { file_path: 'src/foo.ts' },
};
const result = runHook(worktreeDir, payload);
assert.strictEqual(result.status, 0, `Relative path should always pass. stderr: ${result.stderr}`);
assert.strictEqual(result.stdout, '');
});
test('Write with relative file_path exits 0 in worktree', () => {
const payload = {
cwd: worktreeDir,
tool_name: 'Write',
tool_input: { file_path: 'dist/bundle.js' },
};
const result = runHook(worktreeDir, payload);
assert.strictEqual(result.status, 0);
assert.strictEqual(result.stdout, '');
});
});
// 3. Non-Edit/Write tools always pass
describe('non-Edit/Write tools', () => {
test('Bash tool exits 0', () => {
const payload = {
cwd: worktreeDir,
tool_name: 'Bash',
tool_input: { command: 'ls' },
};
const result = runHook(worktreeDir, payload);
assert.strictEqual(result.status, 0);
});
test('Read tool exits 0', () => {
const payload = {
cwd: worktreeDir,
tool_name: 'Read',
tool_input: { file_path: path.join(mainRepo, 'README.md') },
};
const result = runHook(worktreeDir, payload);
assert.strictEqual(result.status, 0);
});
test('Grep tool exits 0', () => {
const payload = {
cwd: worktreeDir,
tool_name: 'Grep',
tool_input: { pattern: 'foo', path: mainRepo },
};
const result = runHook(worktreeDir, payload);
assert.strictEqual(result.status, 0);
});
});
// 4. Absolute path inside worktree passes
describe('path inside worktree', () => {
test('Edit with absolute path inside worktree root exits 0', () => {
const payload = {
cwd: worktreeDir,
tool_name: 'Edit',
tool_input: { file_path: path.join(worktreeDir, 'src', 'foo.ts') },
};
const result = runHook(worktreeDir, payload);
assert.strictEqual(result.status, 0, `Path inside worktree should pass. stderr: ${result.stderr}`);
assert.strictEqual(result.stdout, '');
});
test('Edit targeting exactly the worktree root exits 0', () => {
const payload = {
cwd: worktreeDir,
tool_name: 'Edit',
tool_input: { file_path: worktreeDir },
};
const result = runHook(worktreeDir, payload);
assert.strictEqual(result.status, 0);
});
});
// 5. Absolute path outside worktree is BLOCKED
describe('path outside worktree is blocked', () => {
test('Edit targeting main repo root exits 2 with block decision', () => {
const payload = {
cwd: worktreeDir,
tool_name: 'Edit',
tool_input: { file_path: path.join(mainRepo, 'src', 'index.ts') },
};
const result = runHook(worktreeDir, payload);
assert.strictEqual(result.status, 2, `Expected exit 2 (block), got ${result.status}. stderr: ${result.stderr}`);
let parsed;
assert.doesNotThrow(() => { parsed = JSON.parse(result.stdout); }, 'stdout must be valid JSON');
assert.strictEqual(parsed.decision, 'block', 'Expected decision:"block" in output');
});
test('Write targeting main repo root exits 2 with block decision', () => {
const payload = {
cwd: worktreeDir,
tool_name: 'Write',
tool_input: { file_path: path.join(mainRepo, 'out.txt') },
};
const result = runHook(worktreeDir, payload);
assert.strictEqual(result.status, 2);
const parsed = JSON.parse(result.stdout);
assert.strictEqual(parsed.decision, 'block');
});
test('block output includes the offending path in reason', () => {
const offendingPath = path.join(mainRepo, 'src', 'leak.ts');
const payload = {
cwd: worktreeDir,
tool_name: 'Edit',
tool_input: { file_path: offendingPath },
};
const result = runHook(worktreeDir, payload);
assert.strictEqual(result.status, 2);
const parsed = JSON.parse(result.stdout);
assert.ok(
parsed.reason && parsed.reason.includes(offendingPath),
`block reason should include the offending path. Got: ${parsed.reason}`
);
});
});
// 6. Sibling directory path is BLOCKED (validates the '/' boundary check AND prefix-overlap)
describe('sibling path is blocked', () => {
test('path that shares prefix with worktree root but is a sibling exits 2', () => {
// This test exercises BOTH the prefix-overlap boundary check AND the different-git-root block:
// worktree = <base>/wt
// sibling = <base>/wt-sibling ← shares "wt" prefix with the worktree root
// target = <base>/wt-sibling/file.ts
//
// A naive startsWith(wtRoot) check would wrongly classify "<base>/wt-sibling/..." as inside
// the worktree (it doesn't include the '/' boundary). The hook resolves the sibling's git
// toplevel (a different repo) so the different-git-root block fires regardless.
// (#1342: paths outside all git repos now fail open; only different-git-root blocks.)
const base = realp(fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-260-sib-base-')));
const wtDir = path.join(base, 'wt');
const siblingRepoDir = path.join(base, 'wt-sibling');
// We need a genuine linked worktree at <base>/wt and a separate git repo at <base>/wt-sibling.
// Create a fresh main repo to host this worktree (the fixture worktree is already allocated).
const sibMainRepo = realp(makeMainRepo());
try {
fs.mkdirSync(base, { recursive: true });
// Create linked worktree at <base>/wt (using sibMainRepo as its host).
git(sibMainRepo, ['worktree', 'add', '-q', '-b', 'worktree-agent-sib-test', wtDir]);
// Create a separate git repo at <base>/wt-sibling (shares "wt" prefix).
fs.mkdirSync(siblingRepoDir, { recursive: true });
git(siblingRepoDir, ['init', '-q']);
git(siblingRepoDir, ['config', 'user.email', 'test@example.com']);
git(siblingRepoDir, ['config', 'user.name', 'Test User']);
git(siblingRepoDir, ['config', 'commit.gpgsign', 'false']);
fs.writeFileSync(path.join(siblingRepoDir, 'README.md'), '# sibling\n');
git(siblingRepoDir, ['add', 'README.md']);
git(siblingRepoDir, ['commit', '-q', '-m', 'chore: sibling init']);
// Confirm prefix-overlap: siblingRepoDir starts with wtDir (without trailing sep).
assert.ok(
siblingRepoDir.startsWith(wtDir),
`Sibling "${siblingRepoDir}" must share a string prefix with worktree "${wtDir}" for this test to be meaningful`
);
// Confirm they are genuinely distinct (different toplevel).
assert.notStrictEqual(
realp(siblingRepoDir), realp(wtDir),
'sibling and worktree must be different directories'
);
const siblingPath = path.join(realp(siblingRepoDir), 'file.ts');
const payload = {
cwd: realp(wtDir),
tool_name: 'Edit',
tool_input: { file_path: siblingPath },
};
const result = runHook(realp(wtDir), payload);
assert.strictEqual(result.status, 2,
`Path inside a prefix-sibling git repo "${siblingPath}" must be blocked (exit 2), got ${result.status}. ` +
`This validates both the prefix-overlap boundary and the different-git-root block. stderr: ${result.stderr}`
);
const parsed = JSON.parse(result.stdout);
assert.strictEqual(parsed.decision, 'block');
} finally {
try { git(sibMainRepo, ['worktree', 'remove', '--force', wtDir]); } catch { /* ignore */ }
cleanup(sibMainRepo);
cleanup(base);
}
});
});
// 7. Adversarial: subdirectory cwd still guards correctly (Codex finding #2)
describe('subdirectory cwd', () => {
test('hook fires when cwd is a subdirectory of the worktree, not just its root', () => {
// The orchestrator may set cwd to a subdirectory. The hook must still
// detect the worktree context via git rev-parse --git-dir and block.
const subDir = path.join(worktreeDir, 'src');
fs.mkdirSync(subDir, { recursive: true });
const payload = {
cwd: subDir,
tool_name: 'Edit',
tool_input: { file_path: path.join(mainRepo, 'src', 'index.ts') },
};
const result = runHook(subDir, payload);
assert.strictEqual(result.status, 2,
`Hook must block even when cwd is a subdirectory of the worktree. ` +
`Got exit ${result.status}. stderr: ${result.stderr}`
);
const parsed = JSON.parse(result.stdout);
assert.strictEqual(parsed.decision, 'block');
});
test('path inside worktree passes even when cwd is a subdirectory', () => {
const subDir = path.join(worktreeDir, 'src');
fs.mkdirSync(subDir, { recursive: true });
const payload = {
cwd: subDir,
tool_name: 'Edit',
tool_input: { file_path: path.join(worktreeDir, 'src', 'foo.ts') },
};
const result = runHook(subDir, payload);
assert.strictEqual(result.status, 0,
`Absolute path inside worktree should pass regardless of cwd. ` +
`Got exit ${result.status}. stderr: ${result.stderr}`
);
});
});
// 8. Adversarial: `..` traversal is normalised before the containment check (Codex finding #1)
describe('dot-dot traversal is blocked', () => {
test('path with .. that escapes the worktree is blocked', () => {
// Construct the traversal target inside a SEPARATE git repo that is
// guaranteed to be outside the worktree on every platform (no symlink
// ambiguity). The hook finds the external dir's git toplevel (a different
// repo → different-git-root block).
// (#1342: paths outside all git repos now fail open; only different-git-root blocks,
// so externalDir must be inside a real different git repo to exercise the block.)
const externalDir = realp(makeMainRepo());
try {
// Sanity: the external directory must not be inside the worktree.
assert.ok(
!externalDir.startsWith(worktreeDir + path.sep) && externalDir !== worktreeDir,
`externalDir "${externalDir}" must be outside worktreeDir "${worktreeDir}"`
);
// Build a traversal path that uses ../ segments to climb out of the
// worktree and into externalDir. path.resolve() will normalise it to
// externalDir/file.ts, which is outside the worktree by construction.
// We compute the number of segments needed to reach the filesystem root
// from worktreeDir so the traversal always lands at the right level
// regardless of how deep the worktree path is.
// Build a file_path containing literal `..` segments that climb out of the
// worktree into externalDir. path.relative() yields a ..-laden relative path
// between two same-drive absolute paths (both live under os.tmpdir()); we
// re-anchor it at worktreeDir via STRING CONCAT (NOT path.join, which would
// normalise the `..` away) so the hook's path.resolve() must collapse it.
// Windows-safe: avoids the drive-letter doubling that
// path.join(worktreeDir, '..', absolutePath) produces on win32 (#1342).
const externalTarget = path.join(externalDir, 'file.ts');
const traversalPath = worktreeDir + path.sep + path.relative(worktreeDir, externalTarget);
// Confirm the resolved path is truly outside the worktree (test integrity guard).
const resolved = path.resolve(traversalPath);
assert.ok(
!resolved.startsWith(worktreeDir + path.sep) && resolved !== worktreeDir,
`Traversal resolved to "${resolved}" which is still inside worktreeDir "${worktreeDir}". ` +
`This means the test itself is broken, not a production bug.`
);
const payload = {
cwd: worktreeDir,
tool_name: 'Edit',
tool_input: { file_path: traversalPath },
};
const result = runHook(worktreeDir, payload);
assert.strictEqual(result.status, 2,
`Traversal path "${traversalPath}" resolves to "${resolved}" which is outside the worktree. ` +
`Must be blocked (exit 2). Got exit ${result.status}. stderr: ${result.stderr}`
);
const parsed = JSON.parse(result.stdout);
assert.strictEqual(parsed.decision, 'block',
`Expected decision:"block", got: ${JSON.stringify(parsed)}`
);
} finally {
cleanup(externalDir);
}
});
});
// 9. MultiEdit is also guarded (Codex finding #5)
describe('MultiEdit tool is guarded', () => {
test('MultiEdit with outside absolute path is blocked', () => {
const payload = {
cwd: worktreeDir,
tool_name: 'MultiEdit',
tool_input: { file_path: path.join(mainRepo, 'src', 'index.ts') },
};
const result = runHook(worktreeDir, payload);
assert.strictEqual(result.status, 2,
`MultiEdit targeting outside path must be blocked. Got ${result.status}. stderr: ${result.stderr}`
);
const parsed = JSON.parse(result.stdout);
assert.strictEqual(parsed.decision, 'block');
});
test('MultiEdit with inside absolute path passes', () => {
const payload = {
cwd: worktreeDir,
tool_name: 'MultiEdit',
tool_input: { file_path: path.join(worktreeDir, 'src', 'foo.ts') },
};
const result = runHook(worktreeDir, payload);
assert.strictEqual(result.status, 0,
`MultiEdit inside worktree should pass. Got ${result.status}. stderr: ${result.stderr}`
);
});
});
});
// ---------------------------------------------------------------------------
// #1342 — GSD-activity gate + fail-open for no-repo targets
// ---------------------------------------------------------------------------
describe('#1342 — GSD-activity gate + fail-open for no-repo targets', () => {
// Fixtures: one non-agent linked worktree (plain user branch) + one agent worktree
let mainRepo1342;
let nonAgentWorktree; // on branch 'feature-x' — non-GSD
let agentWorktree; // on branch 'worktree-agent-foo' — GSD-managed
before(() => {
mainRepo1342 = realp(makeMainRepo());
nonAgentWorktree = realp(makeWorktree(mainRepo1342, 'feature-x'));
agentWorktree = realp(makeWorktree(mainRepo1342, 'worktree-agent-foo'));
});
after(() => {
try { git(mainRepo1342, ['worktree', 'remove', '--force', nonAgentWorktree]); } catch { /* ignore */ }
try { git(mainRepo1342, ['worktree', 'remove', '--force', agentWorktree]); } catch { /* ignore */ }
cleanup(mainRepo1342);
cleanup(nonAgentWorktree);
cleanup(agentWorktree);
});
// Test 1 — reporter repro: non-agent worktree writing outside all git repos → exit 0
test('(1) non-agent linked worktree: Write to a path outside all git repos exits 0 (no block)', () => {
// Simulates Claude Code plan-mode writing ~/.claude/plans/<slug>.md from a
// manually-created linked worktree that is NOT on a worktree-agent-* branch.
const plansDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-1342-plans-'));
try {
const targetPath = path.join(plansDir, 'my-plan.md');
const payload = {
cwd: nonAgentWorktree,
tool_name: 'Write',
tool_input: { file_path: targetPath },
};
const result = runHook(nonAgentWorktree, payload);
assert.strictEqual(result.status, 0,
`Non-agent linked worktree writing outside git repos must exit 0 (reporter repro). ` +
`Got exit ${result.status}. stderr: ${result.stderr}`
);
assert.strictEqual(result.stdout, '', 'Expected no block output');
} finally {
cleanup(plansDir);
}
});
// Test 2 — non-agent linked worktree: Edit targeting MAIN repo root → exit 0 (gate no-op)
test('(2) non-agent linked worktree: Edit targeting main repo root exits 0 (gate no-op, not #260 block)', () => {
const payload = {
cwd: nonAgentWorktree,
tool_name: 'Edit',
tool_input: { file_path: path.join(mainRepo1342, 'src', 'index.ts') },
};
const result = runHook(nonAgentWorktree, payload);
assert.strictEqual(result.status, 0,
`Non-agent linked worktree must exit 0 (GSD-activity gate fires before #260 check). ` +
`Got exit ${result.status}. stderr: ${result.stderr}`
);
assert.strictEqual(result.stdout, '', 'Expected no block output');
});
// Test 3 — GSD-managed worktree (worktree-agent-foo): Edit targeting main repo root → exit 2 (block)
test('(3) GSD-managed worktree: Edit targeting main repo root exits 2 with block decision', () => {
const payload = {
cwd: agentWorktree,
tool_name: 'Edit',
tool_input: { file_path: path.join(mainRepo1342, 'src', 'index.ts') },
};
const result = runHook(agentWorktree, payload);
assert.strictEqual(result.status, 2,
`GSD-managed worktree targeting main repo root must be blocked (exit 2). ` +
`Got exit ${result.status}. stderr: ${result.stderr}`
);
let parsed;
assert.doesNotThrow(() => { parsed = JSON.parse(result.stdout); }, 'stdout must be valid JSON');
assert.strictEqual(parsed.decision, 'block', 'Expected decision:"block" in output');
});
// Test 4 — GSD-managed worktree: absolute target INSIDE the active worktree → exit 0
test('(4) GSD-managed worktree: absolute target inside the active worktree exits 0', () => {
const payload = {
cwd: agentWorktree,
tool_name: 'Edit',
tool_input: { file_path: path.join(agentWorktree, 'src', 'foo.ts') },
};
const result = runHook(agentWorktree, payload);
assert.strictEqual(result.status, 0,
`GSD-managed worktree targeting its own subtree must pass. ` +
`Got exit ${result.status}. stderr: ${result.stderr}`
);
assert.strictEqual(result.stdout, '', 'Expected no block output');
});
// Test 5 — GSD-managed worktree: target OUTSIDE all git repos (tmpdir) → exit 0 (fail open)
test('(5) GSD-managed worktree: target outside all git repos exits 0 (fail open, not #260 vector)', () => {
// Create a temp dir that is NOT a git repository (no .git).
// This is the ~/.claude/plans/ scenario — a path that has a real ancestor
// directory but is outside every git repo.
// IMPORTANT: this dir must NOT be inside any .git directory — it must be a plain tempdir
// so the fail-open path (truly outside all repos) is exercised, not the .git-internals block.
const externalDir = realp(fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-1342-ext-')));
try {
const targetPath = path.join(externalDir, 'notes.md');
const payload = {
cwd: agentWorktree,
tool_name: 'Write',
tool_input: { file_path: targetPath },
};
const result = runHook(agentWorktree, payload);
assert.strictEqual(result.status, 0,
`GSD-managed worktree writing to a path outside all git repos must fail open (exit 0). ` +
`Only the different-git-root vector (#260) blocks; no-repo targets are not that vector. ` +
`Got exit ${result.status}. stderr: ${result.stderr}`
);
assert.strictEqual(result.stdout, '', 'Expected no block output');
} finally {
cleanup(externalDir);
}
});
// Test 6 — GSD-managed worktree: Write to .git/config of the MAIN repo → exit 2 (block)
test('(6) blocks absolute writes into the main repo .git internals from a GSD worktree (#1342)', () => {
// A target like /main-repo/.git/config or /main-repo/.git/hooks/pre-commit causes
// `git rev-parse --show-toplevel` to FAIL (a .git dir is not a work tree), so the
// "file not in any git repo" branch fires. Previously that branch failed open — but
// writing into repository internals via an absolute path is still a #260-class escape
// (and dangerous, e.g. injecting a git hook). The fix checks --is-inside-git-dir and
// blocks when true.
const gitConfigPath = path.join(mainRepo1342, '.git', 'config');
const payload = {
cwd: agentWorktree,
tool_name: 'Write',
tool_input: { file_path: gitConfigPath },
};
const result = runHook(agentWorktree, payload);
assert.strictEqual(result.status, 2,
`GSD-managed worktree targeting .git/config of another repo must be blocked (exit 2). ` +
`Got exit ${result.status}. stderr: ${result.stderr}`
);
let parsed;
assert.doesNotThrow(() => { parsed = JSON.parse(result.stdout); }, 'stdout must be valid JSON');
assert.strictEqual(parsed.decision, 'block', 'Expected decision:"block" in output');
assert.ok(
parsed.reason && parsed.reason.includes('.git'),
`Block reason should mention .git internals. Got: ${parsed.reason}`
);
});
});
// ---------------------------------------------------------------------------
// Static analysis: install.js guard
// ---------------------------------------------------------------------------
describe('install.js guard for gsd-worktree-path-guard.js', () => {
let src;
before(() => {
// ADR-857 phase 5f-1b: hook registration moved to runtime-hooks-surface.cts.
// Concatenate both sources so structural assertions find patterns in either file.
const installSrc = fs.readFileSync(INSTALL_SRC, 'utf-8');
let hooksSurfaceSrc = '';
try { hooksSurfaceSrc = fs.readFileSync(HOOKS_SURFACE_SRC, 'utf-8'); } catch { /* ok */ }
src = installSrc + '\n' + hooksSurfaceSrc;
});
test('install.js has hasWorktreePathGuardHook variable', () => {
assert.ok(
src.includes('hasWorktreePathGuardHook'),
'hasWorktreePathGuardHook variable not found in install.js'
);
});
test('install.js checks fs.existsSync before registering gsd-worktree-path-guard.js', () => {
const anchorIdx = src.indexOf('hasWorktreePathGuardHook');
assert.ok(anchorIdx !== -1, 'hasWorktreePathGuardHook not found in install.js');
const blockStart = anchorIdx;
const blockEnd = Math.min(src.length, anchorIdx + 1200);
const block = src.slice(blockStart, blockEnd);
assert.ok(
block.includes('fs.existsSync') || block.includes('existsSync'),
'install.js must call fs.existsSync on the target path before registering ' +
'gsd-worktree-path-guard.js in settings.json. Without this guard, the hook ' +
'is registered even when the .js file was never copied (root cause of #1754).'
);
});
test('install.js emits a skip warning when gsd-worktree-path-guard.js is missing', () => {
const anchorIdx = src.indexOf('hasWorktreePathGuardHook');
assert.ok(anchorIdx !== -1, 'hasWorktreePathGuardHook not found in install.js');
const block = src.slice(anchorIdx, Math.min(src.length, anchorIdx + 1200));
assert.ok(
block.includes('Skipped') && block.includes('gsd-worktree-path-guard'),
'install.js must emit a skip warning mentioning gsd-worktree-path-guard when the file is not found'
);
});
});

View File

@@ -1,162 +0,0 @@
'use strict';
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { execFileSync, spawnSync } = require('node:child_process');
const { cleanup } = require('./helpers.cjs');
const HOOK_PATH = path.join(__dirname, '..', 'hooks', 'gsd-workflow-guard.js');
function git(cwd, args) {
return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
}
function makeRepo(branch) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-bug-261-'));
git(dir, ['init', '-q']);
git(dir, ['config', 'user.email', 'test@example.com']);
git(dir, ['config', 'user.name', 'Test User']);
git(dir, ['config', 'commit.gpgsign', 'false']);
fs.writeFileSync(path.join(dir, 'README.md'), '# test\n');
git(dir, ['add', 'README.md']);
git(dir, ['commit', '-q', '-m', 'chore: init']);
git(dir, ['checkout', '-q', '-b', branch]);
return dir;
}
function setWorkflowGuard(dir, enabled) {
const planningDir = path.join(dir, '.planning');
fs.mkdirSync(planningDir, { recursive: true });
fs.writeFileSync(
path.join(planningDir, 'config.json'),
JSON.stringify({ hooks: { workflow_guard: enabled } }, null, 2)
);
}
function runHookInput(cwd, input) {
return spawnSync(process.execPath, [HOOK_PATH], {
cwd,
encoding: 'utf8',
input: JSON.stringify({ cwd, ...input }),
});
}
function runBashHook(cwd, command) {
return runHookInput(cwd, {
tool_name: 'Bash',
tool_input: { command },
});
}
describe('bug #261: workflow guard blocks forced git add on worktree-agent branches', () => {
test('blocks git add -f on worktree-agent branch when workflow guard is enabled', () => {
const dir = makeRepo('worktree-agent-a1');
try {
setWorkflowGuard(dir, true);
const result = runBashHook(dir, 'git add -f .planning/phases/01/01-01-SUMMARY.md');
assert.strictEqual(result.status, 2);
const envelope = JSON.parse(result.stdout);
assert.strictEqual(envelope.decision, 'block');
assert.strictEqual(envelope.code, 'WORKTREE_AGENT_FORCE_ADD_FORBIDDEN');
} finally {
cleanup(dir);
}
});
test('blocks git add --force with git global options on worktree-agent branch', () => {
const dir = makeRepo('worktree-agent-b2');
try {
setWorkflowGuard(dir, true);
const result = runBashHook(dir, `git -C "${dir}" add --force .planning/SUMMARY.md`);
assert.strictEqual(result.status, 2);
assert.strictEqual(JSON.parse(result.stdout).code, 'WORKTREE_AGENT_FORCE_ADD_FORBIDDEN');
} finally {
cleanup(dir);
}
});
test('allows ordinary git add on worktree-agent branch', () => {
const dir = makeRepo('worktree-agent-c3');
try {
setWorkflowGuard(dir, true);
const result = runBashHook(dir, 'git add .planning/SUMMARY.md');
assert.strictEqual(result.status, 0);
assert.strictEqual(result.stdout, '');
} finally {
cleanup(dir);
}
});
test('allows pathspecs named like force flags after git add -- terminator', () => {
const dir = makeRepo('worktree-agent-d4');
try {
setWorkflowGuard(dir, true);
const result = runBashHook(dir, 'git add -- -f');
assert.strictEqual(result.status, 0);
assert.strictEqual(result.stdout, '');
} finally {
cleanup(dir);
}
});
test('allows git add -f outside worktree-agent branches', () => {
const dir = makeRepo('feature-docs');
try {
setWorkflowGuard(dir, true);
const result = runBashHook(dir, 'git add -f .planning/SUMMARY.md');
assert.strictEqual(result.status, 0);
assert.strictEqual(result.stdout, '');
} finally {
cleanup(dir);
}
});
test('allows git add -f on worktree-agent branch when workflow guard is disabled', () => {
const dir = makeRepo('worktree-agent-e5');
try {
setWorkflowGuard(dir, false);
const result = runBashHook(dir, 'git add -f .planning/SUMMARY.md');
assert.strictEqual(result.status, 0);
assert.strictEqual(result.stdout, '');
} finally {
cleanup(dir);
}
});
test('allows git add -f on worktree-agent branch when no GSD config exists', () => {
const dir = makeRepo('worktree-agent-f6');
try {
const result = runBashHook(dir, 'git add -f .planning/SUMMARY.md');
assert.strictEqual(result.status, 0);
assert.strictEqual(result.stdout, '');
} finally {
cleanup(dir);
}
});
test('applies the advisory path to MultiEdit when workflow guard is enabled', () => {
const dir = makeRepo('feature-multiedit');
try {
setWorkflowGuard(dir, true);
const result = runHookInput(dir, {
tool_name: 'MultiEdit',
tool_input: {
file_path: path.join(dir, 'src.js'),
edits: [],
},
});
assert.strictEqual(result.status, 0);
const envelope = JSON.parse(result.stdout);
assert.match(
envelope.hookSpecificOutput.additionalContext,
/WORKFLOW ADVISORY/
);
} finally {
cleanup(dir);
}
});
});

View File

@@ -1,568 +0,0 @@
// allow-test-rule: source-text-is-the-product
// Workflow .md / agent .md / command .md / reference .md files — their text
// IS what the runtime loads. Testing text content tests the deployed contract.
// Per CONTRIBUTING.md exception matrix.
/**
* Regression test for #2772: worktree isolation is unconditionally disabled
* when `.gitmodules` exists in the repo, even when the plan does not touch
* any submodule path.
*
* Behavioral test: the bash decision pipeline from
* gsd-core/workflows/execute-phase.md is extracted verbatim into an
* executable snippet here, then run via execFileSync('bash', ...) against
* real fixture projects built with `createTempGitProject()`. We assert
* the resulting USE_WORKTREES_FOR_PLAN value (printed on the final line
* of stdout) and the presence/absence of the [worktree] log line for each
* scenario.
*
* If execute-phase.md's bash gate is ever rewritten so the extracted
* snippet stops matching real behavior, this test must be updated to
* track the new pipeline — never replaced with a source grep.
*
* In addition to the per-plan gate behavior, this file also asserts:
* - The workflow markdown actually wires USE_WORKTREES_FOR_PLAN into
* each of the four dispatch sites (worktree-mode gate, sequential-mode
* gate, "worktrees disabled" prose, post-wave cleanup gate). Without
* this, the per-plan computation would be dead code (the original
* #2772 fix shipped in this state — CodeRabbit caught it).
* - The quick.md executor prompt injects SUBMODULE_PATHS and a fail-loud
* pre-commit guard, and the guard actually aborts when staged paths
* fall inside a submodule.
*/
const { describe, test, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const { execFileSync } = require('child_process');
const { createTempGitProject, cleanup } = require('./helpers.cjs');
// Bash snippet extracted from execute-phase.md (the SUBMODULE_PATHS parse +
// per-plan intersection logic with normalization + bidirectional matching).
// Inputs come from env vars: PLAN_FILES (whitespace-separated) and plan_id.
// Output: log lines on stdout, then a final line
// `USE_WORKTREES_FOR_PLAN=<true|false>` for the test to parse.
const GATE_SNIPPET = [
'set -e',
'USE_WORKTREES="${USE_WORKTREES:-true}"',
'if [ -f .gitmodules ]; then',
" SUBMODULE_PATHS=$(git config --file .gitmodules --get-regexp '^submodule\\..*\\.path$' 2>/dev/null | awk '{print $2}')",
'else',
' SUBMODULE_PATHS=""',
'fi',
'USE_WORKTREES_FOR_PLAN="$USE_WORKTREES"',
'if [ -n "$SUBMODULE_PATHS" ] && [ "$USE_WORKTREES_FOR_PLAN" != "false" ]; then',
' if [ -z "$PLAN_FILES" ]; then',
' echo "[worktree] Plan ${plan_id}: files_modified missing/unparseable — disabling worktree isolation as a safety fallback (submodule project)"',
' USE_WORKTREES_FOR_PLAN=false',
' else',
' INTERSECT=""',
' set -f',
' for sm_raw in $SUBMODULE_PATHS; do',
' sm="${sm_raw#./}"',
' sm="${sm%/}"',
' [ -z "$sm" ] && continue',
' for pf_raw in $PLAN_FILES; do',
' pf="${pf_raw#./}"',
' pf="${pf%/}"',
' [ -z "$pf" ] && continue',
' matched=0',
' case "$pf" in',
' "$sm"|"$sm"/*) matched=1 ;;',
' esac',
' if [ "$matched" -eq 0 ]; then',
' case "$sm" in',
' "$pf"|"$pf"/*) matched=1 ;;',
' esac',
' fi',
' if [ "$matched" -eq 0 ]; then',
' case "$pf" in',
" *'*'*|*'?'*|*'['*)",
' prefix="${pf%%[*?[]*}"',
' prefix="${prefix%/}"',
' if [ -n "$prefix" ]; then',
' case "$sm" in',
' "$prefix"|"$prefix"/*) matched=1 ;;',
' esac',
' if [ "$matched" -eq 0 ]; then',
' case "$prefix" in',
' "$sm"|"$sm"/*) matched=1 ;;',
' esac',
' fi',
' fi',
' ;;',
' esac',
' fi',
' if [ "$matched" -eq 1 ]; then',
' INTERSECT="$INTERSECT $pf_raw"',
' fi',
' done',
' done',
' set +f',
' if [ -n "$INTERSECT" ]; then',
' echo "[worktree] Plan ${plan_id}: planned paths intersect submodule paths (${INTERSECT# }) — disabling worktree isolation for this plan"',
' USE_WORKTREES_FOR_PLAN=false',
' fi',
' fi',
'fi',
'echo "USE_WORKTREES_FOR_PLAN=$USE_WORKTREES_FOR_PLAN"',
].join('\n');
function runGate(cwd, env) {
const out = execFileSync('bash', ['-c', GATE_SNIPPET], {
cwd,
encoding: 'utf-8',
env: { ...process.env, ...env },
});
const lines = out.trim().split('\n');
const last = lines[lines.length - 1];
const m = last.match(/^USE_WORKTREES_FOR_PLAN=(true|false)$/);
assert.ok(
m,
`expected final line to be USE_WORKTREES_FOR_PLAN=<bool>, got: ${last}\nfull stdout:\n${out}`
);
return { decision: m[1], stdout: out, logLines: lines.slice(0, -1) };
}
function writeGitmodulesWithSubmodule(repo, submodulePath) {
const content = [
`[submodule "${submodulePath}"]`,
`\tpath = ${submodulePath}`,
`\turl = https://example.invalid/${submodulePath}.git`,
'',
].join('\n');
fs.writeFileSync(path.join(repo, '.gitmodules'), content);
}
describe('Submodule worktree-isolation gate intersects planned paths (#2772)', () => {
let repo;
beforeEach(() => {
repo = createTempGitProject('gsd-test-2772-');
});
afterEach(() => {
cleanup(repo);
});
test('plan touching only src/ in a submodule project keeps worktree isolation ENABLED', () => {
writeGitmodulesWithSubmodule(repo, 'vendor/foo');
const { decision, logLines } = runGate(repo, {
PLAN_FILES: 'src/index.ts src/lib/util.ts',
plan_id: 'plan-001',
});
assert.equal(decision, 'true');
assert.equal(logLines.filter((l) => l.startsWith('[worktree]')).length, 0);
});
test('plan touching vendor/foo/bar.ts in a submodule project DISABLES worktree isolation', () => {
writeGitmodulesWithSubmodule(repo, 'vendor/foo');
const { decision, stdout } = runGate(repo, {
PLAN_FILES: 'src/index.ts vendor/foo/bar.ts',
plan_id: 'plan-002',
});
assert.equal(decision, 'false');
assert.match(stdout, /\[worktree\] Plan plan-002: planned paths intersect submodule paths/);
assert.match(stdout, /vendor\/foo\/bar\.ts/);
});
test('plan whose path equals the submodule root (vendor/foo) DISABLES worktree isolation', () => {
writeGitmodulesWithSubmodule(repo, 'vendor/foo');
const { decision, stdout } = runGate(repo, {
PLAN_FILES: 'vendor/foo',
plan_id: 'plan-003',
});
assert.equal(decision, 'false');
assert.match(stdout, /\[worktree\] Plan plan-003: planned paths intersect submodule paths/);
});
test('missing files_modified in a submodule project falls back to DISABLE with a logged reason', () => {
writeGitmodulesWithSubmodule(repo, 'vendor/foo');
const { decision, stdout } = runGate(repo, {
PLAN_FILES: '',
plan_id: 'plan-004',
});
assert.equal(decision, 'false');
assert.match(stdout, /\[worktree\] Plan plan-004: files_modified missing\/unparseable/);
assert.match(stdout, /safety fallback/);
});
test('repo with no .gitmodules at all keeps worktree isolation ENABLED regardless of plan paths', () => {
const { decision, logLines } = runGate(repo, {
PLAN_FILES: 'vendor/foo/bar.ts src/index.ts',
plan_id: 'plan-005',
});
assert.equal(decision, 'true');
assert.equal(logLines.filter((l) => l.startsWith('[worktree]')).length, 0);
});
test('multiple submodules, plan touches only one of them — DISABLE with that path in the log', () => {
const gitmodules = [
'[submodule "vendor/foo"]',
'\tpath = vendor/foo',
'\turl = https://example.invalid/foo.git',
'[submodule "third_party/bar"]',
'\tpath = third_party/bar',
'\turl = https://example.invalid/bar.git',
'',
].join('\n');
fs.writeFileSync(path.join(repo, '.gitmodules'), gitmodules);
const { decision, stdout } = runGate(repo, {
PLAN_FILES: 'src/a.ts third_party/bar/b.ts',
plan_id: 'plan-006',
});
assert.equal(decision, 'false');
assert.match(stdout, /third_party\/bar\/b\.ts/);
});
test('planned path that merely shares a prefix with a submodule (vendor/foobar) does NOT count as intersection', () => {
writeGitmodulesWithSubmodule(repo, 'vendor/foo');
const { decision, logLines } = runGate(repo, {
PLAN_FILES: 'vendor/foobar/x.ts',
plan_id: 'plan-007',
});
assert.equal(decision, 'true');
assert.equal(logLines.filter((l) => l.startsWith('[worktree]')).length, 0);
});
// ---- Path-normalization & glob coverage (CodeRabbit MAJOR finding) ----
test('planned path with leading "./" normalizes and DISABLES isolation when inside a submodule', () => {
writeGitmodulesWithSubmodule(repo, 'vendor/foo');
const { decision, stdout } = runGate(repo, {
PLAN_FILES: './vendor/foo/bar.c',
plan_id: 'plan-norm-1',
});
assert.equal(decision, 'false', './vendor/foo/bar.c must normalize and intersect vendor/foo');
assert.match(stdout, /vendor\/foo\/bar\.c/);
});
test('planned path with trailing slash equal to submodule DISABLES isolation', () => {
writeGitmodulesWithSubmodule(repo, 'vendor/foo');
const { decision } = runGate(repo, {
PLAN_FILES: 'vendor/foo/',
plan_id: 'plan-norm-2',
});
assert.equal(decision, 'false', 'trailing slash must not defeat the submodule-root match');
});
test('globby planned path "vendor/**/*.c" DISABLES isolation when submodule sits inside vendor/', () => {
writeGitmodulesWithSubmodule(repo, 'vendor/foo');
const { decision, stdout } = runGate(repo, {
PLAN_FILES: 'vendor/**/*.c',
plan_id: 'plan-norm-3',
});
assert.equal(
decision,
'false',
'glob whose literal prefix "vendor" contains submodule vendor/foo must intersect'
);
assert.match(stdout, /vendor\/\*\*\/\*\.c/);
});
test('plan declares a parent directory of the submodule (e.g. "vendor") — DISABLES isolation', () => {
writeGitmodulesWithSubmodule(repo, 'vendor/foo');
const { decision } = runGate(repo, {
PLAN_FILES: 'vendor',
plan_id: 'plan-norm-4',
});
assert.equal(
decision,
'false',
'planned path that contains the submodule must intersect (bidirectional matching)'
);
});
test('submodule path declared with leading "./" in .gitmodules still matches a plain planned path', () => {
const gitmodules = [
'[submodule "vendor/foo"]',
'\tpath = ./vendor/foo',
'\turl = https://example.invalid/foo.git',
'',
].join('\n');
fs.writeFileSync(path.join(repo, '.gitmodules'), gitmodules);
const { decision } = runGate(repo, {
PLAN_FILES: 'vendor/foo/bar.ts',
plan_id: 'plan-norm-5',
});
assert.equal(
decision,
'false',
'submodule "./vendor/foo" must normalize and match plain planned path vendor/foo/bar.ts'
);
});
test('globby planned path that does NOT overlap the submodule keeps isolation ENABLED', () => {
writeGitmodulesWithSubmodule(repo, 'vendor/foo');
const { decision, logLines } = runGate(repo, {
PLAN_FILES: 'src/**/*.ts',
plan_id: 'plan-norm-6',
});
assert.equal(decision, 'true');
assert.equal(logLines.filter((l) => l.startsWith('[worktree]')).length, 0);
});
});
// ---- Workflow-markdown wiring assertions (CodeRabbit CRITICAL finding) ----
//
// The original PR computed USE_WORKTREES_FOR_PLAN but never read it at the
// dispatch sites — the dispatch still branched on the project-level
// USE_WORKTREES, so the per-plan decision was dead code. Assert the markdown
// actually wires the variable into the four dispatch sites.
describe('execute-phase.md dispatch wires USE_WORKTREES_FOR_PLAN (#2772)', () => {
const workflowPath = path.join(
__dirname,
'..',
'gsd-core',
'workflows',
'execute-phase.md'
);
const gatePath = path.join(
__dirname,
'..',
'gsd-core',
'workflows',
'execute-phase',
'steps',
'per-plan-worktree-gate.md'
);
test('workflow file exists and is readable', () => {
assert.ok(fs.existsSync(workflowPath), `expected ${workflowPath} to exist`);
});
test('per-plan worktree gate steps file exists and is readable', () => {
assert.ok(fs.existsSync(gatePath), `expected ${gatePath} to exist`);
});
test('Worktree-mode dispatch gate reads USE_WORKTREES_FOR_PLAN, not USE_WORKTREES', () => {
const md = fs.readFileSync(workflowPath, 'utf-8');
assert.match(
md,
/\*\*Worktree mode\*\*\s*\(`USE_WORKTREES_FOR_PLAN`/,
'Worktree-mode header must gate on USE_WORKTREES_FOR_PLAN per-plan'
);
});
test('Sequential-mode dispatch gate reads USE_WORKTREES_FOR_PLAN', () => {
const md = fs.readFileSync(workflowPath, 'utf-8');
assert.match(
md,
/\*\*Sequential mode\*\*\s*\(`USE_WORKTREES_FOR_PLAN`/,
'Sequential-mode header must gate on USE_WORKTREES_FOR_PLAN per-plan'
);
});
test('"Worktrees disabled" sequential rule is documented per-plan, not project-level', () => {
const md = fs.readFileSync(workflowPath, 'utf-8');
assert.match(
md,
/worktrees are disabled for a plan/i,
'sequential-execution rule must be expressed per-plan'
);
});
test('execute-phase.md hooks the per-plan gate steps file at sub-step 2.5', () => {
const md = fs.readFileSync(workflowPath, 'utf-8');
assert.match(md, /Per-plan worktree decision/, 'sub-step header must exist in execute_waves');
assert.match(
md,
/execute-phase\/steps\/per-plan-worktree-gate\.md/,
'execute-phase.md must reference the extracted gate file'
);
});
test('per-plan gate file documents PLAN_FILES extraction from plan_json', () => {
const md = fs.readFileSync(gatePath, 'utf-8');
assert.match(
md,
/jq -r '\.files_modified \/\/ \[\] \| join\(" "\)' <<<"\$plan_json"/,
'PLAN_FILES extraction from plan_json must be documented in the gate file'
);
});
test('per-plan gate file uses bidirectional case + glob-prefix handling + set -f discipline', () => {
const md = fs.readFileSync(gatePath, 'utf-8');
assert.match(md, /set -f/, 'matcher must disable globbing while iterating');
assert.match(md, /set \+f/, 'matcher must re-enable globbing after iteration');
const pfFirst = md.match(/case "\$pf" in\s+"\$sm"\|"\$sm"\/\*\)/);
const smFirst = md.match(/case "\$sm" in\s+"\$pf"\|"\$pf"\/\*\)/);
assert.ok(pfFirst, 'matcher must check pf inside sm');
assert.ok(smFirst, 'matcher must check sm inside pf (bidirectional)');
assert.match(md, /sm="\$\{sm_raw#\.\/\}"/, 'submodule path must strip leading ./');
assert.match(md, /pf="\$\{pf_raw#\.\/\}"/, 'planned path must strip leading ./');
assert.match(md, /sm="\$\{sm%\/\}"/, 'submodule path must strip trailing /');
assert.match(md, /pf="\$\{pf%\/\}"/, 'planned path must strip trailing /');
});
test('Post-wave worktree-cleanup gate is per-plan, not blanket project-level', () => {
const md = fs.readFileSync(workflowPath, 'utf-8');
assert.match(
md,
/WAVE_WORKTREE_PLANS/,
'post-wave cleanup must track which plans actually used worktrees'
);
});
});
// ---- quick.md SUBMODULE_PATHS executor guard (CodeRabbit CRITICAL #3) ----
//
// Quick mode does NOT have a pre-declared files_modified list. The fail-loud
// guard must (a) be present in the markdown of the executor prompt, and
// (b) actually abort when run against a fixture that stages a submodule path.
describe('quick.md executor pre-commit submodule guard (#2772)', () => {
const quickPath = path.join(__dirname, '..', 'gsd-core', 'workflows', 'quick.md');
test('quick.md executor prompt injects SUBMODULE_PATHS', () => {
const md = fs.readFileSync(quickPath, 'utf-8');
assert.match(
md,
/SUBMODULE_PATHS for this project: \$\{SUBMODULE_PATHS\}/,
'executor prompt must inline SUBMODULE_PATHS so the agent can run the guard'
);
});
test('quick.md executor prompt contains a fail-loud pre-commit guard with ABORT message', () => {
const md = fs.readFileSync(quickPath, 'utf-8');
assert.match(md, /<submodule_commit_guard>/, 'guard block must exist');
assert.match(
md,
/git diff --cached --name-only/,
'guard must inspect staged paths before commit'
);
assert.match(
md,
/ABORT: staged path/,
'guard must surface a fail-loud ABORT message on intersection'
);
assert.match(
md,
/workflow\.use_worktrees=false/,
'guard must tell the user how to recover (re-run without worktrees)'
);
});
// Behavioral: extract the guard logic and run it against a fixture repo.
// We simulate the executor's commit-time guard and assert it aborts when a
// staged path falls inside a SUBMODULE_PATHS entry, and passes otherwise.
const QUICK_GUARD_SNIPPET = [
'set +e',
'STAGED=$(git diff --cached --name-only)',
'if [ -n "$SUBMODULE_PATHS" ]; then',
' for sm_raw in $SUBMODULE_PATHS; do',
' sm="${sm_raw#./}"',
' sm="${sm%/}"',
' [ -z "$sm" ] && continue',
' for f_raw in $STAGED; do',
' f="${f_raw#./}"',
' f="${f%/}"',
' case "$f" in',
' "$sm"|"$sm"/*)',
' echo "ABORT: staged path $f_raw falls inside submodule $sm — re-run with workflow.use_worktrees=false" >&2',
' exit 1 ;;',
' esac',
' done',
' done',
'fi',
'echo "OK"',
].join('\n');
test('guard ABORTs when a staged path falls inside a submodule', () => {
const repo = createTempGitProject('gsd-test-2772-quick-abort-');
try {
// Create a file inside the submodule path and stage it.
fs.mkdirSync(path.join(repo, 'vendor', 'foo'), { recursive: true });
fs.writeFileSync(path.join(repo, 'vendor', 'foo', 'bar.ts'), 'export {};\n');
execFileSync('git', ['add', 'vendor/foo/bar.ts'], { cwd: repo });
let err;
try {
execFileSync('bash', ['-c', QUICK_GUARD_SNIPPET], {
cwd: repo,
encoding: 'utf-8',
env: { ...process.env, SUBMODULE_PATHS: 'vendor/foo' },
});
} catch (e) {
err = e;
}
assert.ok(err, 'guard must exit non-zero when staged path is inside submodule');
assert.equal(err.status, 1, 'guard must exit with status 1');
const stderr = err.stderr ? err.stderr.toString() : '';
assert.match(stderr, /ABORT: staged path vendor\/foo\/bar\.ts/);
assert.match(stderr, /vendor\/foo/);
} finally {
cleanup(repo);
}
});
test('guard passes when no staged path falls inside a submodule', () => {
const repo = createTempGitProject('gsd-test-2772-quick-pass-');
try {
fs.mkdirSync(path.join(repo, 'src'), { recursive: true });
fs.writeFileSync(path.join(repo, 'src', 'index.ts'), 'export {};\n');
execFileSync('git', ['add', 'src/index.ts'], { cwd: repo });
const out = execFileSync('bash', ['-c', QUICK_GUARD_SNIPPET], {
cwd: repo,
encoding: 'utf-8',
env: { ...process.env, SUBMODULE_PATHS: 'vendor/foo' },
});
assert.match(out, /OK/);
} finally {
cleanup(repo);
}
});
test('guard normalizes leading "./" on staged paths and still ABORTs', () => {
const repo = createTempGitProject('gsd-test-2772-quick-norm-');
try {
fs.mkdirSync(path.join(repo, 'vendor', 'foo'), { recursive: true });
fs.writeFileSync(path.join(repo, 'vendor', 'foo', 'bar.ts'), 'export {};\n');
execFileSync('git', ['add', 'vendor/foo/bar.ts'], { cwd: repo });
let err;
try {
// Submodule path declared with ./ prefix — must still match.
execFileSync('bash', ['-c', QUICK_GUARD_SNIPPET], {
cwd: repo,
encoding: 'utf-8',
env: { ...process.env, SUBMODULE_PATHS: './vendor/foo' },
});
} catch (e) {
err = e;
}
assert.ok(err, 'guard must abort even when SUBMODULE_PATHS uses ./ prefix');
assert.equal(err.status, 1);
} finally {
cleanup(repo);
}
});
});

View File

@@ -1,288 +0,0 @@
// allow-test-rule: source-text-is-the-product
// Reads .md/.json/.yml product files whose deployed text IS what the
// runtime loads — testing text content tests the deployed contract.
/**
* Regression test for bug #2808
*
* All 85 GSD SKILL.md files declared `name: gsd:<cmd>` (colon), the deprecated
* form. Claude Code surfaces the `name:` frontmatter field in autocomplete, so
* users saw `/gsd:add-phase` suggestions instead of the canonical `/gsd-add-phase`.
*
* Root cause: skillFrontmatterName() in bin/install.js converted hyphenated
* skill dir names to colon form (gsd-add-phase → gsd:add-phase) because
* workflows called Skill(skill="gsd:<cmd>"). That was the original fix for
* #2643. Since then, workflows have been updated to use hyphen form (#2808).
*
* Fix: skillFrontmatterName() now returns the hyphen form unchanged.
* Workflow Skill() colon calls are updated to hyphen.
*
* This test verifies:
* 1. skillFrontmatterName returns hyphen form (not colon).
* 2. Installed SKILL.md would emit name: gsd-<cmd> (not gsd:<cmd>).
* 3. No workflow contains a Skill(skill="gsd:<cmd>") colon call.
*/
'use strict';
process.env.GSD_TEST_MODE = '1';
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { cleanup, createTempDir } = require('./helpers.cjs');
const ROOT = path.join(__dirname, '..');
const { convertClaudeCommandToClaudeSkill, skillFrontmatterName } =
require(path.join(ROOT, 'bin', 'install.js'));
const { installRuntimeArtifacts } = require(path.join(ROOT, 'gsd-core', 'bin', 'lib', 'install-engine.cjs'));
const {
loadSkillsManifest,
resolveProfile,
} = require(path.join(ROOT, 'gsd-core', 'bin', 'lib', 'install-profiles.cjs'));
// Full resolved profile — installs all available skills from the source dir
const _manifest = loadSkillsManifest();
const resolvedProfileFull = resolveProfile({ modes: [], manifest: _manifest });
const WORKFLOWS_DIR = path.join(ROOT, 'gsd-core', 'workflows');
const COMMANDS_DIR = path.join(ROOT, 'commands', 'gsd');
function walkMd(dir) {
const files = [];
try {
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, e.name);
if (e.isDirectory()) files.push(...walkMd(full));
else if (e.name.endsWith('.md')) files.push(full);
}
} catch (err) {
assert.fail(`failed to read markdown files from ${dir}: ${err.message}`);
}
return files;
}
describe('bug-2808: SKILL.md name: uses hyphen form', () => {
test('skillFrontmatterName returns hyphen form (not colon)', () => {
assert.strictEqual(skillFrontmatterName('gsd-add-phase'), 'gsd-add-phase');
assert.strictEqual(skillFrontmatterName('gsd-plan-phase'), 'gsd-plan-phase');
assert.strictEqual(skillFrontmatterName('gsd-autonomous'), 'gsd-autonomous');
});
test('generated SKILL.md contains name: gsd-<cmd> (not gsd:<cmd>)', () => {
const cmdFiles = fs.readdirSync(COMMANDS_DIR).filter(f => f.endsWith('.md'));
assert.ok(cmdFiles.length > 0, 'expected GSD command files');
for (const cmd of cmdFiles) {
const base = cmd.replace(/\.md$/, '');
const skillDirName = 'gsd-' + base;
const src = fs.readFileSync(path.join(COMMANDS_DIR, cmd), 'utf-8');
const skillContent = convertClaudeCommandToClaudeSkill(src, skillDirName);
// Parse frontmatter structurally: extract name: line from the --- block.
const fmMatch = skillContent.match(/^---\r?\n([\s\S]*?)\r?\n---/);
assert.ok(fmMatch, `${cmd}: generated skill content must have a frontmatter block`);
const fmLines = fmMatch[1].split(/\r?\n/);
const nameEntry = fmLines.find((l) => l.startsWith('name:'));
assert.ok(nameEntry, `${cmd}: generated SKILL.md is missing required name: field`);
const name = nameEntry.replace(/^name:\s*/, '').trim();
assert.ok(
!name.includes(':'),
`${cmd}: SKILL.md name should be hyphen form, got "${name}"`
);
assert.ok(
name.startsWith('gsd-'),
`${cmd}: SKILL.md name should start with gsd-, got "${name}"`
);
// #3583 regression guard: the *body* must not leak retired colon-form
// command references (e.g. /gsd:plan-phase or gsd:review). The converter
// now uses transformContentToHyphen from the shared transformer.
//
// We explicitly scope to the body (after stripping the leading frontmatter
// block) so that descriptions or other frontmatter fields containing example
// gsd: references do not cause spurious failures.
//
// gsd:sdk and gsd:tools are intentionally excluded: they are not slash commands
// (no commands/gsd/sdk.md or tools.md exist), so the transformer correctly leaves
// them alone. They are benign and should not trigger this assertion.
const bodyContent = skillContent.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/, '');
const colonRefs = (bodyContent.match(/\bgsd:[a-z][a-z0-9-]*\b/g) || [])
.filter(r => !/gsd:(sdk|tools)/.test(r));
assert.strictEqual(
colonRefs.length, 0,
`${cmd}: generated SKILL.md body must not contain gsd: command references (found: ${colonRefs.join(', ')})`
);
}
});
test('no workflow contains Skill(skill="gsd:<cmd>") colon form', () => {
const workflowFiles = walkMd(WORKFLOWS_DIR);
assert.ok(
workflowFiles.length > 0,
`expected workflow markdown files under ${WORKFLOWS_DIR}`
);
const colonCalls = [];
for (const f of workflowFiles) {
const src = fs.readFileSync(f, 'utf-8');
// Strip HTML comments to avoid matching commented-out examples.
// regex-free HTML-comment stripper (CodeQL: avoid incomplete-multi-character-sanitization)
let stripped = '';
{
let rest = src;
let idx;
while ((idx = rest.indexOf('<!--')) !== -1) {
stripped += rest.slice(0, idx);
const end = rest.indexOf('-->', idx + 4);
if (end === -1) { rest = ''; break; }
rest = rest.slice(end + 3);
}
stripped += rest;
}
// Scan each line for Skill() calls using the colon form.
// Parsing line-by-line is more precise than a multi-line regex
// and avoids false positives from incidental matches in prose.
for (const line of stripped.split(/\r?\n/)) {
// Tolerate whitespace around the parenthesis, the `skill` keyword,
// and the `=` so variants like `Skill( skill = "gsd:foo" )` are still
// flagged. Without the `\s*` allowances, drift slips through this guard.
//
// The local-name capture must be permissive (`[^'"\s)]+`, not
// `[a-z0-9-]+`) — the whole purpose of this guard is to surface
// *malformed* drift, including legacy underscore-form names like
// `gsd:extract_learnings`. A character-class that excludes the very
// characters we need to flag would silently let drift through.
const colonCallRe = /Skill\(\s*skill\s*=\s*\\?['"]gsd:([^'"\s)]+)\\?['"]/gi;
let m;
while ((m = colonCallRe.exec(line)) !== null) {
colonCalls.push(`${path.basename(f)}: Skill(skill="gsd:${m[1]}")`);
}
}
}
assert.deepStrictEqual(
colonCalls,
[],
'deprecated colon-form Skill() calls found — update to gsd-<cmd>: ' + colonCalls.join(', ')
);
});
test('generated autocomplete skill surface uses hyphen names without underscores', (t) => {
const tmp = createTempDir('gsd-autocomplete-surface-');
t.after(() => cleanup(tmp));
// Use the real COMMANDS_DIR as the source via .gsd-source marker.
// installRuntimeArtifacts('claude', configDir, 'global') writes to
// configDir/skills/ using the same converter as the shim did.
// With the full profile (#924 fix), skills are FLAT: gsd-<stem>/SKILL.md
// (nested layout reverted for Claude — Claude Code scans only one level).
const configDir = path.join(tmp, 'config');
fs.mkdirSync(configDir, { recursive: true });
fs.writeFileSync(path.join(configDir, '.gsd-source'), COMMANDS_DIR + '\n');
installRuntimeArtifacts('claude', configDir, 'global', resolvedProfileFull);
const skillsDir = path.join(configDir, 'skills');
// Recursively collect all SKILL.md files under skills/ (handles both flat and
// nested layouts). Don't filter any paths — that would silently hide exactly
// the kind of drift this test exists to catch (a `gsd:extract-learnings`
// colon variant or a bare `extract-learnings` without the namespace prefix
// would never be collected, and the loop below would never see them).
function collectSkillMds(dir) {
const results = [];
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
results.push(...collectSkillMds(full));
} else if (entry.name === 'SKILL.md') {
results.push(full);
}
}
return results;
}
const allSkillMdPaths = collectSkillMds(skillsDir);
assert.ok(allSkillMdPaths.length > 0, 'expected generated SKILL.md files under skillsDir');
// Validate every SKILL.md's name: field (the consumer-facing name used in
// autocomplete). We also check that the containing dir name doesn't use
// banned characters at any level of nesting.
const allNames = [];
for (const skillMdPath of allSkillMdPaths) {
const relPath = path.relative(skillsDir, skillMdPath);
const skillContent = fs.readFileSync(skillMdPath, 'utf-8');
// Scope the name: lookup to the YAML frontmatter block so a stray
// `name:` line in the body cannot satisfy the assertion.
const fmMatch = skillContent.match(/^---\r?\n([\s\S]*?)\r?\n---/);
assert.ok(fmMatch, `${relPath}: generated SKILL.md must include frontmatter`);
const nameLine = fmMatch[1].split(/\r?\n/).find((l) => /^name:\s*/.test(l));
assert.ok(nameLine, `${relPath}: generated SKILL.md is missing name: frontmatter`);
const name = nameLine.replace(/^name:\s*/, '').trim();
assert.ok(name.startsWith('gsd-'), `${relPath}: autocomplete name must start with gsd-, got ${name}`);
assert.ok(!name.includes(':'), `${relPath}: autocomplete name must not contain colon, got ${name}`);
assert.ok(!name.includes('_'), `${relPath}: autocomplete name must not contain underscore, got ${name}`);
allNames.push(name);
// Also validate each path segment (dir name) in the relative path doesn't
// contain the banned characters — catches mislabeled directory names.
const segments = relPath.split(path.sep).slice(0, -1); // exclude 'SKILL.md' filename
for (const seg of segments) {
assert.ok(!seg.includes(':'), `${relPath}: dir segment "${seg}" must not contain colon`);
assert.ok(!seg.includes('_'), `${relPath}: dir segment "${seg}" must use hyphens, not underscores`);
}
}
assert.ok(allNames.includes('gsd-extract-learnings'), 'autocomplete surface must include gsd-extract-learnings');
assert.ok(!allNames.includes('gsd-extract_learnings'), 'autocomplete surface must not include gsd-extract_learnings');
});
test('transformContentToHyphen (from fix-slash-commands.cjs) rewrites colon to hyphen for known commands', () => {
const transformer = require(path.join(ROOT, 'scripts', 'fix-slash-commands.cjs'));
const { transformContentToHyphen, readCmdNames } = transformer;
const liveCmdNames = readCmdNames();
const input = 'Run /gsd:plan-phase then gsd:execute-phase. Also see /gsd:review and gsd-sdk query.';
const out = transformContentToHyphen(input, liveCmdNames);
assert.ok(out.includes('/gsd-plan-phase'), 'leading-/ colon form must become hyphen');
assert.ok(out.includes('gsd-execute-phase'), 'bare colon form must become hyphen');
assert.ok(out.includes('/gsd-review'), 'another command reference must be rewritten');
assert.ok(out.includes('gsd-sdk'), 'non-command gsd-sdk must be left untouched');
assert.ok(!out.match(/\bgsd:[a-z]/), 'no colon-form command reference may survive');
});
test('respects word boundary — does not rewrite gsd:plan-phase-extra (partial match guard)', () => {
const transformer = require(path.join(ROOT, 'scripts', 'fix-slash-commands.cjs'));
const { transformContentToHyphen, readCmdNames } = transformer;
const liveCmdNames = readCmdNames();
const out = transformContentToHyphen('gsd:plan-phase-extra and /gsd:execute-phase-extra', liveCmdNames);
assert.strictEqual(out, 'gsd:plan-phase-extra and /gsd:execute-phase-extra',
'word-boundary lookahead must prevent partial matches on the reverse transform');
});
test('respects left word boundary — does not rewrite inside larger tokens (e.g. mygsd:cmd)', () => {
const transformer = require(path.join(ROOT, 'scripts', 'fix-slash-commands.cjs'));
const { transformContentToHyphen, readCmdNames } = transformer;
const liveCmdNames = readCmdNames();
const input = 'See mygsd:plan-phase or prefix-gsd:execute in the docs.';
const out = transformContentToHyphen(input, liveCmdNames);
assert.strictEqual(out, input, 'negative lookbehind must prevent left-side in-word matches');
});
test('leaves already-hyphen-form references untouched (idempotent on output)', () => {
const transformer = require(path.join(ROOT, 'scripts', 'fix-slash-commands.cjs'));
const { transformContentToHyphen, readCmdNames } = transformer;
const liveCmdNames = readCmdNames();
const input = 'Run gsd-plan-phase and /gsd-execute-phase then gsd:review.'; // mixed, only colon should change
const out = transformContentToHyphen(input, liveCmdNames);
assert.ok(out.includes('gsd-plan-phase'), 'pre-existing hyphen stays');
assert.ok(out.includes('/gsd-execute-phase'), 'pre-existing hyphen stays');
assert.ok(out.includes('gsd-review'), 'colon form was normalized');
assert.ok(!out.includes('gsd:review'), 'no colon form remains');
});
});

View File

@@ -1,248 +0,0 @@
/**
* Regression test for #2916: execute-phase `handle_branching` step creates the
* per-phase branch off whatever HEAD is currently checked out (typically the
* previous phase's unmerged branch) instead of off `origin/HEAD`.
*
* The bug compounded phases on top of each other and stranded them unpushed
* for weeks. The fix:
* 1. Detect the default branch via `git symbolic-ref refs/remotes/origin/HEAD`.
* 2. If $BRANCH_NAME exists, switch to it (preserve existing behavior).
* 3. Otherwise, ff-update the default branch from origin and create the new
* phase branch off the default-branch tip.
* 4. Refuse-or-warn on dirty working tree.
* 5. Post-creation, assert `git rev-list --count $DEFAULT_BRANCH..HEAD == 0`.
*
* This test extracts the bash payload from the <step name="handle_branching">
* block in execute-phase.md (parsed structurally — no regex on prose), executes
* it inside a fixture git repo where HEAD sits on a previous-phase branch with
* extra commits, and asserts that the new phase branch's tip equals
* `origin/main` (no commits inherited from the previous phase).
*/
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const { execFileSync } = require('node:child_process');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { cleanup } = require('./helpers.cjs');
const EXECUTE_PHASE_PATH = path.join(
__dirname,
'..',
'gsd-core',
'workflows',
'execute-phase.md'
);
const GIT_ENV = Object.freeze({
...process.env,
GIT_AUTHOR_NAME: 'Test',
GIT_AUTHOR_EMAIL: 'test@test.com',
GIT_COMMITTER_NAME: 'Test',
GIT_COMMITTER_EMAIL: 'test@test.com',
});
function git(cwd, ...args) {
return execFileSync('git', args, {
cwd,
env: GIT_ENV,
stdio: ['pipe', 'pipe', 'pipe'],
})
.toString()
.trim();
}
/**
* Structurally extract the bash code that the handle_branching step instructs
* the agent to run. We:
* 1. Locate the <step name="handle_branching"> ... </step> block.
* 2. Walk its body looking for fenced ```bash blocks.
* 3. Concatenate every bash block in the step (the fix may use more than one).
*
* No `.includes()` content checks — we parse fence-delimited code blocks the
* same way a markdown parser would.
*/
function extractHandleBranchingBash() {
const content = fs.readFileSync(EXECUTE_PHASE_PATH, 'utf-8');
const lines = content.split(/\r?\n/);
let start = -1;
let end = -1;
for (let i = 0; i < lines.length; i += 1) {
if (start === -1 && /^<step\s+name="handle_branching">\s*$/.test(lines[i])) {
start = i + 1;
} else if (start !== -1 && /^<\/step>\s*$/.test(lines[i])) {
end = i;
break;
}
}
if (start === -1 || end === -1) {
throw new Error(
'execute-phase.md does not contain a <step name="handle_branching"> ... </step> block'
);
}
const bashBlocks = [];
let inBash = false;
let buffer = [];
for (let i = start; i < end; i += 1) {
const line = lines[i];
if (!inBash && /^```bash\s*$/.test(line)) {
inBash = true;
buffer = [];
continue;
}
if (inBash && /^```\s*$/.test(line)) {
bashBlocks.push(buffer.join('\n'));
inBash = false;
continue;
}
if (inBash) buffer.push(line);
}
if (bashBlocks.length === 0) {
throw new Error(
'handle_branching step contains no ```bash code blocks to execute'
);
}
return bashBlocks.join('\n');
}
/**
* Build a fixture: a bare "origin" repo with the named default branch (one
* commit), a clone with `origin/HEAD` pointed at it, and a checked-out
* previous-phase branch carrying its own unmerged commit.
*
* `defaultBranch` is parameterized so callers can lock in that the workflow
* honors `git symbolic-ref refs/remotes/origin/HEAD` rather than silently
* defaulting to `main` (#2921 CR feedback — quick-branching.test.cjs got the
* same treatment in 80f14cac; this test deserves the same coverage).
*/
function setupFixture(defaultBranch = 'main') {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2916-'));
const seedPath = path.join(root, 'seed');
const originPath = path.join(root, 'origin.git');
const clonePath = path.join(root, 'clone');
fs.mkdirSync(seedPath);
git(seedPath, 'init', '-b', defaultBranch);
git(seedPath, 'config', 'commit.gpgsign', 'false');
fs.writeFileSync(path.join(seedPath, 'README.md'), '# seed\n');
git(seedPath, 'add', 'README.md');
git(seedPath, 'commit', '-m', 'initial');
git(root, 'clone', '--bare', seedPath, originPath);
git(originPath, 'symbolic-ref', 'HEAD', `refs/heads/${defaultBranch}`);
git(root, 'clone', originPath, clonePath);
git(clonePath, 'config', 'commit.gpgsign', 'false');
git(clonePath, 'config', 'user.email', 'test@test.com');
git(clonePath, 'config', 'user.name', 'Test');
// Simulate finishing a previous phase: branch off the default branch, add
// a commit, and *stay* on it (the failure scenario described in the bug).
git(clonePath, 'checkout', '-b', 'feature/phase-01-foundation');
fs.writeFileSync(path.join(clonePath, 'phase01.txt'), 'phase 1 work\n');
git(clonePath, 'add', 'phase01.txt');
git(clonePath, 'commit', '-m', 'phase 01 work');
return { root, clonePath, defaultBranch };
}
function runHandleBranchingStep(bash, cwd, branchName) {
// Write the script to a sibling tempdir, not inside the repo — putting it in
// `cwd` would create an untracked file that trips `git status --porcelain`
// and steers the step into its dirty-tree fallback path.
const scriptDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2916-step-'));
const scriptPath = path.join(scriptDir, 'handle-branching.sh');
const script = `#!/usr/bin/env bash\nset -uo pipefail\nBRANCH_NAME="${branchName}"\n${bash}\n`;
fs.writeFileSync(scriptPath, script, { mode: 0o755 });
try {
return execFileSync('bash', [scriptPath], {
cwd,
env: GIT_ENV,
stdio: ['pipe', 'pipe', 'pipe'],
}).toString();
} finally {
cleanup(scriptDir);
}
}
describe('handle_branching branches off origin/HEAD, not current HEAD (#2916)', () => {
// Run against `main` (conventional default) and `trunk` (non-main default
// exercising the symbolic-ref code path) so a regression that hard-codes
// `main` instead of consulting origin/HEAD will fail the trunk variant.
for (const defaultBranch of ['main', 'trunk']) {
test(`new phase branch branches off origin/${defaultBranch} with 0 inherited commits`, () => {
const bash = extractHandleBranchingBash();
const { root, clonePath } = setupFixture(defaultBranch);
try {
const upstream = `origin/${defaultBranch}`;
assert.equal(
git(clonePath, 'rev-parse', '--abbrev-ref', 'HEAD'),
'feature/phase-01-foundation'
);
assert.equal(
git(clonePath, 'rev-list', '--count', `${upstream}..HEAD`),
'1',
`fixture should be 1 commit ahead of ${upstream}`
);
runHandleBranchingStep(bash, clonePath, 'feature/phase-02-content-sync');
assert.equal(
git(clonePath, 'rev-parse', '--abbrev-ref', 'HEAD'),
'feature/phase-02-content-sync',
'handle_branching should switch to the new phase branch'
);
const inherited = git(clonePath, 'rev-list', '--count', `${upstream}..HEAD`);
assert.equal(
inherited,
'0',
`new phase branch must branch off ${upstream}, but inherited ${inherited} commit(s) from previous-phase HEAD`
);
assert.equal(
git(clonePath, 'rev-parse', 'HEAD'),
git(clonePath, 'rev-parse', upstream),
`new phase branch tip must equal ${upstream} tip`
);
} finally {
cleanup(root);
}
});
}
test('handle_branching reuses an existing branch instead of forking again', () => {
const bash = extractHandleBranchingBash();
const { root, clonePath } = setupFixture();
try {
// Pre-create the target branch off origin/main with its own commit, then
// walk away to a different branch — the step must switch back to it.
git(clonePath, 'checkout', '-B', 'feature/phase-02-content-sync', 'origin/main');
fs.writeFileSync(path.join(clonePath, 'phase02.txt'), 'phase 2 work\n');
git(clonePath, 'add', 'phase02.txt');
git(clonePath, 'commit', '-m', 'phase 02 wip');
const phase02Sha = git(clonePath, 'rev-parse', 'HEAD');
git(clonePath, 'checkout', 'feature/phase-01-foundation');
runHandleBranchingStep(bash, clonePath, 'feature/phase-02-content-sync');
assert.equal(
git(clonePath, 'rev-parse', '--abbrev-ref', 'HEAD'),
'feature/phase-02-content-sync'
);
assert.equal(
git(clonePath, 'rev-parse', 'HEAD'),
phase02Sha,
'existing-branch tip must be preserved (no rebase/reset)'
);
} finally {
cleanup(root);
}
});
});

View File

@@ -1,248 +0,0 @@
'use strict';
process.env.GSD_TEST_MODE = '1';
const { test, describe, before, after } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const ROOT = path.join(__dirname, '..');
const { auditWorkflowScriptPaths, AUDIT_FINDING } = require(
path.join(ROOT, 'scripts', 'audit-workflow-script-paths.cjs'),
);
const { cleanup } = require('./helpers.cjs');
// auditWorkflowScriptPaths is a pure function: it walks workflowsDir,
// extracts every ${GSD_HOME}/<path> script reference, and returns a
// structured report. Tests assert on the typed report — no regex on
// console output.
// #2996 CR: per-fixture repos are rooted under a single tmpRoot so the
// after()-hook actually cleans them up. The previous shape created tmpRoot
// in before() but never used it, leaking each fixture's mkdtempSync dir.
let tmpRoot;
function fixtureRepo({ workflows, files }) {
// workflows: { 'foo.md': '...content with ${GSD_HOME}/...' }
// files: [ 'gsd-core/bin/x.cjs', ... ] — files to create in repo
const repoRoot = fs.mkdtempSync(path.join(tmpRoot, 'repo-'));
const workflowsDir = path.join(repoRoot, 'gsd-core', 'workflows');
fs.mkdirSync(workflowsDir, { recursive: true });
for (const [name, body] of Object.entries(workflows || {})) {
fs.writeFileSync(path.join(workflowsDir, name), body);
}
for (const rel of files || []) {
const full = path.join(repoRoot, rel);
fs.mkdirSync(path.dirname(full), { recursive: true });
fs.writeFileSync(full, '');
}
return { repoRoot, workflowsDir };
}
before(() => { tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2995-')); });
after(() => { cleanup(tmpRoot); });
describe('Bug #2995: post-install script-paths audit (#2995)', () => {
test('AUDIT_FINDING enum exposes the documented codes', () => {
assert.deepEqual(
Object.keys(AUDIT_FINDING).sort(),
['MISSING_FROM_REPO', 'NOT_INSTALLED'].sort(),
);
});
test('returns { ok: true, findings: [] } when workflow refs an existing, installed-path script', () => {
const { repoRoot, workflowsDir } = fixtureRepo({
workflows: {
'good.md': 'node "${GSD_HOME}/gsd-core/bin/foo.cjs" --json\n',
},
files: ['gsd-core/bin/foo.cjs'],
});
const r = auditWorkflowScriptPaths({
workflowsDir,
repoRoot,
installedPrefixes: ['gsd-core', 'commands', 'agents', 'hooks'],
});
assert.deepEqual(r, { ok: true, findings: [] });
});
});
describe('Bug #2995: detection paths', () => {
const { auditWorkflowScriptPaths, AUDIT_FINDING } = require(require('node:path').join(__dirname, '..', 'scripts', 'audit-workflow-script-paths.cjs'));
test('reports MISSING_FROM_REPO when the referenced file does not exist in the repo', () => {
const { repoRoot, workflowsDir } = fixtureRepo({
workflows: {
'foo.md': 'node "${GSD_HOME}/gsd-core/bin/typo.cjs" --json\n',
},
files: [],
});
const r = auditWorkflowScriptPaths({
workflowsDir,
repoRoot,
installedPrefixes: ['gsd-core'],
});
assert.equal(r.ok, false);
assert.equal(r.findings.length, 1);
assert.deepEqual(r.findings[0], {
workflow: 'foo.md',
path: 'gsd-core/bin/typo.cjs',
kind: AUDIT_FINDING.MISSING_FROM_REPO,
});
});
test('reports NOT_INSTALLED when first path segment is outside installedPrefixes (the #2994 case)', () => {
const { repoRoot, workflowsDir } = fixtureRepo({
workflows: {
'foo.md': 'node "${GSD_HOME}/scripts/verify-reapply-patches.cjs"\n',
},
files: ['scripts/verify-reapply-patches.cjs'], // file exists, but `scripts/` not in installed prefixes
});
const r = auditWorkflowScriptPaths({
workflowsDir,
repoRoot,
installedPrefixes: ['gsd-core', 'commands', 'agents', 'hooks'],
});
assert.equal(r.ok, false);
assert.equal(r.findings.length, 1);
assert.deepEqual(r.findings[0], {
workflow: 'foo.md',
path: 'scripts/verify-reapply-patches.cjs',
kind: AUDIT_FINDING.NOT_INSTALLED,
});
});
test('handles ${GSD_HOME:-$HOME/.claude}/... default-fallback syntax', () => {
const { repoRoot, workflowsDir } = fixtureRepo({
workflows: {
'a.md': 'node "${GSD_HOME:-$HOME/.claude}/gsd-core/bin/x.cjs"\n',
},
files: ['gsd-core/bin/x.cjs'],
});
const r = auditWorkflowScriptPaths({
workflowsDir,
repoRoot,
installedPrefixes: ['gsd-core'],
});
assert.deepEqual(r, { ok: true, findings: [] });
});
test('reports both findings when one workflow has multiple problems', () => {
const { repoRoot, workflowsDir } = fixtureRepo({
workflows: {
'multi.md': [
'node "${GSD_HOME}/scripts/a.cjs"',
'node "${GSD_HOME}/gsd-core/bin/b.cjs"',
'node "${GSD_HOME}/gsd-core/bin/missing.cjs"',
].join('\n') + '\n',
},
files: ['scripts/a.cjs', 'gsd-core/bin/b.cjs'],
});
const r = auditWorkflowScriptPaths({
workflowsDir,
repoRoot,
installedPrefixes: ['gsd-core'],
});
assert.equal(r.ok, false);
assert.equal(r.findings.length, 2);
const kinds = r.findings.map((f) => f.kind).sort();
assert.deepEqual(kinds, [AUDIT_FINDING.MISSING_FROM_REPO, AUDIT_FINDING.NOT_INSTALLED]);
});
test('extracts no findings from a workflow without GSD_HOME script refs', () => {
const { repoRoot, workflowsDir } = fixtureRepo({
workflows: {
'plain.md': '# A workflow\n\nSome prose, no script refs.\n',
},
});
const r = auditWorkflowScriptPaths({
workflowsDir,
repoRoot,
installedPrefixes: ['gsd-core'],
});
assert.deepEqual(r, { ok: true, findings: [] });
});
});
describe('Bug #2995: real workflow audit', () => {
const { auditWorkflowScriptPaths, AUDIT_FINDING } = require(require('node:path').join(__dirname, '..', 'scripts', 'audit-workflow-script-paths.cjs'));
// The set of top-level directories the installer (bin/install.js) actually
// copies into ${configDir}/. Touching this set requires updating both
// bin/install.js AND this constant — the parity is intentional.
const INSTALLED_PREFIXES = [
'gsd-core', // workflows, references, bin/lib, templates
'commands', // commands/gsd/*.md (Claude Code local + Gemini global)
'skills', // skills/gsd-*/SKILL.md (Claude Code 2.1.88+ global, Codex, etc.)
'agents', // agents/gsd-*.md
'hooks', // hooks/gsd-*.{sh,js}
];
// Known existing gaps tracked in their own issues. Removing an entry should
// land in the same PR that fixes the underlying issue; CI surfaces any NEW
// gap as a hard failure.
// (#2994 entry removed: this PR moves verify-reapply-patches.cjs to
// gsd-core/bin/ which IS an installed prefix, closing the gap.)
const KNOWN_GAPS = new Set();
test('no NEW workflow refs fail to resolve at the deployed path (KNOWN_GAPS allow-listed)', () => {
const r = auditWorkflowScriptPaths({
workflowsDir: require('node:path').join(ROOT, 'gsd-core', 'workflows'),
repoRoot: ROOT,
installedPrefixes: INSTALLED_PREFIXES,
});
const newGaps = r.findings.filter(
(f) => !KNOWN_GAPS.has(`${f.workflow}|${f.path}|${f.kind}`),
);
if (newGaps.length > 0) {
const summary = newGaps.map(
(f) => ` ${f.workflow}: ${f.path} (${f.kind})`,
).join('\n');
assert.fail(
`New workflow ref does not resolve at the deployed path:\n${summary}\n\n` +
`Either move the script under one of [${INSTALLED_PREFIXES.join(', ')}], ` +
`update bin/install.js to copy the new top-level directory, or ` +
`(if intentionally tracked) add an entry to KNOWN_GAPS with the issue reference.`,
);
}
});
// #2996 CR: a reference that is both outside an installed prefix AND
// missing from the repo must emit BOTH findings in one run. Previously
// the code short-circuited on NOT_INSTALLED, hiding MISSING_FROM_REPO
// until the developer fixed the prefix and re-ran CI.
test('a reference that is both not-installed AND missing-from-repo emits both findings (no short-circuit)', () => {
const { repoRoot, workflowsDir } = fixtureRepo({
workflows: {
'foo.md': '```bash\nnode "${GSD_HOME}/scripts/missing.cjs"\n```\n',
},
// Note: scripts/missing.cjs intentionally NOT created in the repo.
});
const r = auditWorkflowScriptPaths({
workflowsDir,
repoRoot,
installedPrefixes: ['gsd-core', 'agents', 'hooks', 'commands'],
});
assert.equal(r.ok, false);
const kinds = r.findings.filter((f) => f.path === 'scripts/missing.cjs').map((f) => f.kind).sort();
assert.deepEqual(
kinds,
[AUDIT_FINDING.MISSING_FROM_REPO, AUDIT_FINDING.NOT_INSTALLED].sort(),
'expected both NOT_INSTALLED and MISSING_FROM_REPO findings for the same ref',
);
});
test('KNOWN_GAPS entries still match real findings — fixed gaps must be removed from the allow-list', () => {
const r = auditWorkflowScriptPaths({
workflowsDir: require('node:path').join(ROOT, 'gsd-core', 'workflows'),
repoRoot: ROOT,
installedPrefixes: INSTALLED_PREFIXES,
});
const realKeys = new Set(r.findings.map((f) => `${f.workflow}|${f.path}|${f.kind}`));
const stale = [...KNOWN_GAPS].filter((k) => !realKeys.has(k));
assert.deepEqual(
stale,
[],
`KNOWN_GAPS contains entries not present in audit findings — remove these: ${stale.join(', ')}`,
);
});
});

View File

@@ -1,120 +0,0 @@
/**
* Regression test for bug #3019.
*
* `gsd-sdk query <subcommand> --help` returned the top-level SDK USAGE
* instead of contextual help for the subcommand. The query argv parser
* harvested --help as a global flag and main() short-circuited dispatch
* before the registry handler / gsd-tools.cjs fallback could render
* useful help.
*
* Two-layer fix:
* 1. sdk/src/cli.ts — leave --help in queryArgv so it travels to the
* handler/fallback. Only honor the global help flag when there is
* no subcommand to dispatch to.
* 2. gsd-core/bin/gsd-tools.cjs — render the top-level usage on
* --help instead of erroring. Anti-hallucination invariant from
* #1818 is preserved (the destructive command never executes).
*
* Tests the integration: invoke gsd-tools.cjs the same way the SDK
* dispatcher does and assert structured-IR (success flag + usage shape)
* rather than raw substring matches.
*/
'use strict';
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const { runGsdTools, isUsageOutput } = require('./helpers.cjs');
// #3026 CR (Major outside-diff): the SDK fallback wraps gsd-tools.cjs.
// When gsd-tools emits plain-text help (exit 0), the SDK previously
// JSON.parsed stdout and threw "Unexpected token 'U'". Verify the fix
// by invoking the built SDK end-to-end and asserting:
// - exit 0
// - stdout contains the gsd-tools usage
// - stderr does NOT contain a JSON parse error
const path = require('node:path');
const { spawnSync } = require('node:child_process');
const SDK_CLI = path.join(__dirname, '..', 'sdk', 'dist', 'cli.js');
const fs = require('node:fs');
describe('bug #3026 (CR Major outside-diff): SDK forwards plain-text help from gsd-tools fallback', () => {
test('gsd-sdk query phase --help (fallback path) returns usage, not a JSON parse error', (t) => {
if (!fs.existsSync(SDK_CLI)) {
// CR feedback (#3026): a bare `return` here silent-passes the test
// when sdk/dist/cli.js is absent (CI checkouts that haven't run
// `npm run build`), giving no signal that the integration check
// was skipped. Use t.skip() so the omission is visible in the
// test report. The unit-level fix is covered by vitest on
// sdk/src/cli.ts; this integration test only runs when the
// built SDK is on disk.
t.skip('sdk/dist/cli.js not built — run `npm run build` in sdk/ to enable this integration test');
return;
}
// `query phase --help` (no further subcommand) is NOT in the native
// registry, so it routes through the gsd-tools.cjs fallback. That is
// the path that JSON.parsed the help text and threw before this fix.
const result = spawnSync(process.execPath, [SDK_CLI, 'query', 'phase', '--help'], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
timeout: 10000,
});
// The fallback gsd-tools.cjs emits exit 0 with usage on stdout.
assert.strictEqual(result.status, 0,
`must exit 0 — got ${result.status}\nstdout: ${result.stdout}\nstderr: ${result.stderr}`);
// Negative: must NOT see the JSON parse error that was the regression.
assert.ok(!/Unexpected token|not valid JSON/i.test(result.stderr),
`must NOT JSON.parse the help text (stderr): ${result.stderr}`);
// Positive: the usage should reach the user via stdout.
assert.ok(/Usage:\s*gsd-tools/.test(result.stdout) && /Commands:/.test(result.stdout),
`usage must reach stdout: ${result.stdout}`);
});
});
describe('bug #3019: gsd-tools renders usage on --help instead of erroring', () => {
test('bare gsd-tools (no args) renders usage', () => {
const result = runGsdTools([]);
// No args path: error() helper emits to stderr and exits non-zero,
// but the message body is the usage.
assert.strictEqual(result.success, false);
assert.ok(/Usage:\s*gsd-tools/.test(result.error));
assert.ok(/Commands:/.test(result.error));
});
test('gsd-tools --help renders usage on stdout, exits 0', () => {
const result = runGsdTools(['--help']);
assert.strictEqual(result.success, true, '--help should not be an error');
assert.ok(isUsageOutput(result.output), `expected usage on stdout, got: ${result.output}`);
});
test('gsd-tools -h renders usage on stdout, exits 0', () => {
const result = runGsdTools(['-h']);
assert.strictEqual(result.success, true);
assert.ok(isUsageOutput(result.output));
});
test('gsd-tools <subcommand> --help renders usage (does not run subcommand)', () => {
// The classic #3019 surface: the user types a subcommand expecting
// contextual help. We render the top-level usage — strictly better
// than the previous unhelpful "Unknown flag --help" error.
const result = runGsdTools(['phase', 'add', '--help']);
assert.strictEqual(result.success, true);
assert.ok(isUsageOutput(result.output));
});
test('usage hint mentions how to discover argument requirements', () => {
// The usage now points users at the discovery method that actually works
// (run without args → error message names required arguments). Asserting
// on the parsed shape of the usage rather than substring-matching prose:
const result = runGsdTools(['--help']);
assert.strictEqual(result.success, true);
// Structural check: split into sections.
const lines = result.output.split('\n');
const hasUsageLine = lines.some((l) => l.startsWith('Usage:'));
const hasCommandsLine = lines.some((l) => l.startsWith('Commands:'));
const hasDiscoveryHint = lines.some((l) => /argument requirements|without args|invoke the command/i.test(l));
assert.ok(hasUsageLine, 'first section: Usage');
assert.ok(hasCommandsLine, 'second section: Commands');
assert.ok(hasDiscoveryHint, 'third section: how to discover per-command args');
});
});

View File

@@ -1,71 +0,0 @@
'use strict';
process.env.GSD_TEST_MODE = '1';
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { spawnSync } = require('node:child_process');
const { cleanup } = require('./helpers.cjs');
const ROOT = path.resolve(__dirname, '..');
const DRIFT_LINT = path.join(ROOT, 'scripts', 'lint-shell-command-projection-drift.cjs');
function runLint(targetFile) {
return spawnSync(process.execPath, [DRIFT_LINT, targetFile], {
cwd: ROOT,
encoding: 'utf8',
});
}
// (The buildWindowsShimTriple parity test was removed with the gsd-sdk shim,
// #191. The serialized-command drift guard below is retained and unaffected.)
describe('bug #3442: shim/wrapper serialized-command drift guard', () => {
test('drift guard passes for current install.js', () => {
const result = runLint(path.join(ROOT, 'bin', 'install.js'));
assert.equal(result.status, 0, `expected lint pass, got:\n${result.stderr || result.stdout}`);
});
test('drift guard fails when install-owned inline shim text builder is present', () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3442-'));
try {
const fixture = path.join(tmp, 'install-inline-builder.js');
fs.writeFileSync(
fixture,
[
'function badBuilder() {',
" return '@ECHO OFF\\r\\n@SETLOCAL\\r\\n@node \"C:/shim.js\" %*\\r\\n';",
'}',
'',
].join('\n'),
);
const result = runLint(fixture);
assert.notEqual(result.status, 0, 'inline shim renderer should be rejected by the drift guard');
} finally {
cleanup(tmp);
}
});
test('drift guard does not block safe subprocess execution patterns', () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3442-'));
try {
const fixture = path.join(tmp, 'install-subprocess-safe.js');
fs.writeFileSync(
fixture,
[
"const cp = require('node:child_process');",
"cp.spawnSync('cmd.exe', ['/c', 'echo ok']);",
"cp.execFileSync('bash', ['-lc', 'printf %s \"$PATH\"']);",
'',
].join('\n'),
);
const result = runLint(fixture);
assert.equal(result.status, 0, `spawnSync/execFileSync should remain allowed:\n${result.stderr || result.stdout}`);
} finally {
cleanup(tmp);
}
});
});

View File

@@ -1,111 +0,0 @@
// allow-test-rule: source-text-is-the-product
// Workflow `.md` files are the runtime contract executed by Claude Code as
// embedded bash. This test extracts the actual `check_incomplete_work` bash
// block from resume-project.md and exercises it against a planted directory
// layout — that's a behavioral integration test of the workflow contract,
// not regex-on-source.
'use strict';
const { test, describe, before, after } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { spawnSync } = require('node:child_process');
const { createTempDir, cleanup } = require('./helpers.cjs');
const WORKFLOW_PATH = path.join(__dirname, '..', 'gsd-core', 'workflows', 'resume-project.md');
// Extract the first ```bash``` code block inside the
// `<step name="check_incomplete_work">` element. That's the snippet the
// runtime actually executes; it's what we want to validate.
function extractCheckBlock() {
const md = fs.readFileSync(WORKFLOW_PATH, 'utf8');
const stepStart = md.indexOf('<step name="check_incomplete_work">');
assert.ok(stepStart >= 0, 'resume-project.md must contain a check_incomplete_work step');
const stepEnd = md.indexOf('</step>', stepStart);
assert.ok(
stepEnd >= 0,
'check_incomplete_work step must have a closing </step> tag',
);
const stepBody = md.slice(stepStart, stepEnd);
const fenceMatch = stepBody.match(/```(?:bash|sh)\r?\n([\s\S]*?)\r?\n```/);
assert.ok(fenceMatch, 'check_incomplete_work step must embed a ```bash code block');
return fenceMatch[1];
}
function runSnippet(cwd, snippet) {
// has_interrupted_agent is a downstream-orchestrator variable; default it
// to "false" so the embedded `if` branch is a no-op during this test.
return spawnSync('bash', ['-c', snippet], {
cwd,
encoding: 'utf8',
env: { ...process.env, has_interrupted_agent: 'false', interrupted_agent_id: '' },
});
}
describe('bug #3446: resume-project detects non-phase and legacy continue-here handoffs', () => {
let tmpDir;
let snippet;
before(() => {
snippet = extractCheckBlock();
tmpDir = createTempDir('gsd-bug-3446-');
// Plant the three discovery surfaces that bug #3446 was originally
// filed to cover.
fs.mkdirSync(path.join(tmpDir, '.planning'), { recursive: true });
fs.writeFileSync(
path.join(tmpDir, '.planning', '.continue-here.md'),
'---\ncontext: default\n---\nroot-of-.planning handoff\n',
'utf8',
);
fs.mkdirSync(path.join(tmpDir, '.planning', 'sketches', 'SKETCH-001'), { recursive: true });
fs.writeFileSync(
path.join(tmpDir, '.planning', 'sketches', 'SKETCH-001', '.continue-here.md'),
'---\ncontext: sketch\n---\nsketch handoff\n',
'utf8',
);
fs.writeFileSync(
path.join(tmpDir, '.continue-here.md'),
'---\ncontext: legacy\n---\nlegacy repo-root handoff\n',
'utf8',
);
});
after(() => {
cleanup(tmpDir);
});
test('check_incomplete_work surfaces .planning/.continue-here.md (depth 1 under .planning)', () => {
const result = runSnippet(tmpDir, snippet);
assert.equal(result.status, 0, `snippet exited ${result.status}; stderr=${result.stderr}`);
assert.match(
result.stdout,
/\.planning\/\.continue-here\.md/,
`expected .planning/.continue-here.md in stdout; got: ${JSON.stringify(result.stdout)}`,
);
});
test('check_incomplete_work surfaces .planning/sketches/SKETCH-001/.continue-here.md (depth 3 under .planning)', () => {
const result = runSnippet(tmpDir, snippet);
assert.equal(result.status, 0, `snippet exited ${result.status}; stderr=${result.stderr}`);
assert.match(
result.stdout,
/\.planning\/sketches\/SKETCH-001\/\.continue-here\.md/,
`expected sketch handoff in stdout; got: ${JSON.stringify(result.stdout)}`,
);
});
test('check_incomplete_work surfaces legacy repo-root .continue-here.md', () => {
const result = runSnippet(tmpDir, snippet);
assert.equal(result.status, 0, `snippet exited ${result.status}; stderr=${result.stderr}`);
assert.match(
result.stdout,
/(^|\n)\.\/\.continue-here\.md(\n|$)/,
`expected legacy ./.continue-here.md in stdout; got: ${JSON.stringify(result.stdout)}`,
);
});
});

View File

@@ -1,186 +0,0 @@
// allow-test-rule: source-text-is-the-product
// Bug #3491 — new-project workflow creates nested .git in subdirectory when
// parent already has git repo.
//
// The workflow's `has_git` boolean was derived from `pathExists(cwd, '.git')`
// — a shallow check that only sees a `.git` entry directly in the current
// directory. Subdirectories of an existing git worktree therefore reported
// `has_git: false`, causing the workflow's `git init` step to create a nested
// `.git` inside the outer repo's worktree. Subsequent gsd-sdk commits then
// targeted the nested repo instead of the outer one, silently dropping all
// planning artefacts from the outer repo's history.
//
// This test asserts the corrected semantics, mirroring `git rev-parse
// --is-inside-work-tree`:
//
// - `has_git: true` is reported whenever the cwd is inside a git worktree,
// even when no `.git` entry is in cwd itself.
// - The init payload surfaces `git_worktree_root` and `in_nested_subdir` so
// the workflow can warn the user and skip `git init`.
// - The workflow markdown's `git init` step is gated on
// `in_nested_subdir: false`, never unconditional under `has_git: false`.
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { execSync } = require('node:child_process');
const { runGsdTools, cleanup } = require('./helpers.cjs');
const WORKFLOW_PATH = path.join(
__dirname,
'..',
'gsd-core',
'workflows',
'new-project.md',
);
// ─── Helper: create outer git repo with a nested workstream subdir ─────────
// On Windows the runtime emits forward slashes (git's convention) while
// path.join produces backslashes — normalize both sides via the shared
// toPosixPath helper before any equality comparison.
const { toPosixPath: normalizePath } = require('./helpers.cjs');
function createOuterRepoWithSubdir(prefix = 'bug-3491-') {
const outer = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
// macOS /tmp -> /private/tmp; on Windows the runner's %TEMP% is the 8.3
// short-name (RUNNER~1) and the runtime resolves to the long form.
// realpathSync.native handles both; then normalize separators for compare.
const outerReal = fs.realpathSync.native(outer);
execSync('git init', { cwd: outerReal, stdio: 'pipe' });
execSync('git config user.email "test@test.com"', { cwd: outerReal, stdio: 'pipe' });
execSync('git config user.name "Test"', { cwd: outerReal, stdio: 'pipe' });
execSync('git config commit.gpgsign false', { cwd: outerReal, stdio: 'pipe' });
fs.writeFileSync(path.join(outerReal, 'README.md'), '# outer\n');
execSync('git add -A', { cwd: outerReal, stdio: 'pipe' });
execSync('git commit -m "initial"', { cwd: outerReal, stdio: 'pipe' });
const subdir = path.join(outerReal, 'workstreams', 'my-project');
fs.mkdirSync(subdir, { recursive: true });
return { outer: outerReal, subdir };
}
// ─── Behavioural tests against the live `init new-project` handler ─────────
test('bug-3491: init new-project reports has_git: true inside parent git worktree', () => {
const { outer, subdir } = createOuterRepoWithSubdir();
try {
const result = runGsdTools('init new-project', subdir);
assert.ok(result.success, `init new-project failed: ${result.error}`);
const payload = JSON.parse(result.output);
// Core fix: shallow `.git in cwd` check was wrong — we are inside the
// outer worktree, so the workflow MUST see has_git: true.
assert.strictEqual(
payload.has_git,
true,
'expected has_git=true when cwd is inside an existing git worktree (parent .git)',
);
// The workflow needs the worktree root and a nesting flag to decide
// whether to skip `git init` and emit a friendly warning.
assert.strictEqual(
normalizePath(payload.git_worktree_root),
normalizePath(outer),
`expected git_worktree_root to be the outer repo (${outer}), got: ${payload.git_worktree_root}`,
);
assert.strictEqual(
payload.in_nested_subdir,
true,
'expected in_nested_subdir=true when cwd is a subdirectory of the worktree root',
);
} finally {
cleanup(outer);
}
});
test('bug-3491: init new-project reports has_git: true at worktree root with in_nested_subdir: false', () => {
const { outer } = createOuterRepoWithSubdir();
try {
const result = runGsdTools('init new-project', outer);
assert.ok(result.success, `init new-project failed: ${result.error}`);
const payload = JSON.parse(result.output);
assert.strictEqual(payload.has_git, true, 'has_git must be true at the worktree root');
assert.strictEqual(normalizePath(payload.git_worktree_root), normalizePath(outer));
assert.strictEqual(
payload.in_nested_subdir,
false,
'at the worktree root, in_nested_subdir must be false',
);
} finally {
cleanup(outer);
}
});
test('bug-3491: init new-project reports has_git: false outside any git worktree', () => {
const tmp = fs.realpathSync.native(fs.mkdtempSync(path.join(os.tmpdir(), 'bug-3491-bare-')));
try {
const result = runGsdTools('init new-project', tmp);
assert.ok(result.success, `init new-project failed: ${result.error}`);
const payload = JSON.parse(result.output);
assert.strictEqual(payload.has_git, false);
assert.strictEqual(payload.in_nested_subdir, false);
assert.strictEqual(payload.git_worktree_root, null);
} finally {
cleanup(tmp);
}
});
test('bug-3491: init ingest-docs mirrors the same has_git semantics', () => {
// ingest-docs.md has the same shallow check and the same nested-init risk.
const { outer, subdir } = createOuterRepoWithSubdir('bug-3491-ingest-');
try {
const result = runGsdTools('init ingest-docs', subdir);
assert.ok(result.success, `init ingest-docs failed: ${result.error}`);
const payload = JSON.parse(result.output);
assert.strictEqual(
payload.has_git,
true,
'init ingest-docs must also detect parent worktree (#3491 related path)',
);
assert.strictEqual(normalizePath(payload.git_worktree_root), normalizePath(outer));
assert.strictEqual(payload.in_nested_subdir, true);
} finally {
cleanup(outer);
}
});
// ─── Workflow-text test: the deployed `new-project.md` must gate `git init` ─
test('bug-3491: new-project.md gates `git init` on in_nested_subdir, not just has_git', () => {
const content = fs.readFileSync(WORKFLOW_PATH, 'utf-8');
// The pre-fix workflow had the literal sequence:
//
// **If `has_git` is false:** Initialize git:
// ```bash
// git init
// ```
//
// …which fires for any subdirectory of an existing repo. The fix must
// either gate the init on `in_nested_subdir`/worktree-root semantics or
// drop the unconditional `git init` block entirely.
const unconditionalInitPattern =
/\*\*If `has_git` is false:\*\* Initialize git:\s*\r?\n+```bash\s*\r?\ngit init\s*\r?\n```/;
assert.ok(
!unconditionalInitPattern.test(content),
'new-project.md must not run `git init` unconditionally on has_git=false (#3491). ' +
'Gate it on `in_nested_subdir === false` so the workflow refuses to create ' +
'a nested .git inside an existing worktree.',
);
// The fixed workflow MUST mention the new field so reviewers can see the
// gating exists. (Workflow markdown IS the deployed product — testing it
// as text is the only end-to-end signal we have.)
assert.ok(
/in_nested_subdir/.test(content),
'new-project.md must reference `in_nested_subdir` after the #3491 fix',
);
});

View File

@@ -1,137 +0,0 @@
/**
* Regression tests for #3509 — CLI breaks when repo path contains spaces
*
* Root cause: test code embedded space-containing paths into runGsdTools()
* string args; the helper's whitespace tokenizer truncated paths at the first
* space. All calls that carry dynamic paths must use the array form of
* runGsdTools() so execFileSync receives the full path as a single argv slot.
*
* These tests create a tmpdir whose prefix intentionally contains a space so
* they remain red on a broken codebase regardless of the host machine's
* tmpdir location.
*/
const { test, describe, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const os = require('os');
const { runGsdTools, cleanup } = require('./helpers.cjs');
// Create a tmpdir whose name always contains a space — this is the invariant
// that was violated on /Volumes/Mini Me/... machines.
function createSpacedTmpDir(prefix = 'path with spaces-') {
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
}
// ─── dispatcher --cwd= with space in path ────────────────────────────────────
describe('bug-3509: --cwd= survives spaces in path', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createSpacedTmpDir();
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases'), { recursive: true });
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
'# Project State\n\n## Current Position\n\nPhase: 1 of 1 (Test)\n'
);
});
afterEach(() => cleanup(tmpDir));
test('--cwd= array form passes full path with spaces to dispatcher', () => {
// Array form: path is a single argv slot, never split on whitespace
const result = runGsdTools(['--cwd=' + tmpDir, 'state', 'load'], process.cwd());
assert.ok(result.success, `--cwd= with spaced path should succeed, got: ${result.error}`);
});
});
// ─── frontmatter-cli file path with spaces ───────────────────────────────────
describe('bug-3509: frontmatter get/set/merge/validate survive spaces in file path', () => {
let tmpDir;
let tmpFile;
beforeEach(() => {
tmpDir = createSpacedTmpDir();
tmpFile = path.join(tmpDir, 'test.md');
fs.writeFileSync(tmpFile, '---\nphase: 01\nplan: 01\ntype: execute\n---\nbody');
});
afterEach(() => cleanup(tmpDir));
test('frontmatter get returns parsed fields when file path contains spaces', () => {
const result = runGsdTools(['frontmatter', 'get', tmpFile]);
assert.ok(result.success, `Command failed: ${result.error}`);
const parsed = JSON.parse(result.output);
assert.strictEqual(parsed.phase, '01', 'phase field should be "01"');
});
test('frontmatter set works when file path contains spaces', () => {
const setResult = runGsdTools(['frontmatter', 'set', tmpFile, '--field', 'phase', '--value', '02']);
assert.ok(setResult.success, `set failed: ${setResult.error}`);
// Verify behaviorally — round-trip via frontmatter get rather than reading the file
// and grepping (which trips lint-no-source-grep even on tmp files).
const getResult = runGsdTools(['frontmatter', 'get', tmpFile]);
assert.ok(getResult.success, `get failed: ${getResult.error}`);
const parsed = JSON.parse(getResult.output);
assert.strictEqual(parsed.phase, '02', 'field should be updated to "02"');
});
test('frontmatter validate works when file path contains spaces', () => {
// Plan frontmatter schema — file path contains a space; must reach validation, not fail on path
const result = runGsdTools(['frontmatter', 'validate', tmpFile, '--schema', 'plan']);
// Should succeed (exit 0) and return structured JSON with valid/missing, not a path-split error
assert.ok(result.success, `Command should exit 0, got: ${result.error}`);
const out = JSON.parse(result.output);
assert.ok('valid' in out, 'should return structured JSON with "valid" field');
});
});
// ─── verify-path-exists with absolute path containing spaces ─────────────────
describe('bug-3509: verify-path-exists survives absolute paths with spaces', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createSpacedTmpDir();
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases'), { recursive: true });
});
afterEach(() => cleanup(tmpDir));
test('absolute path with spaces resolves correctly via array form', () => {
const absFile = path.join(tmpDir, 'abs-test.txt');
fs.writeFileSync(absFile, 'content');
const result = runGsdTools(['verify-path-exists', absFile], tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.exists, true, 'file should be found');
assert.strictEqual(output.type, 'file');
});
});
// ─── profile-pipeline --path with spaces ─────────────────────────────────────
describe('bug-3509: scan-sessions --path survives spaces in path', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createSpacedTmpDir();
});
afterEach(() => cleanup(tmpDir));
test('scan-sessions --path with spaces returns empty array, not path-split error', () => {
const sessionsDir = path.join(tmpDir, 'projects');
fs.mkdirSync(sessionsDir, { recursive: true });
const result = runGsdTools(['scan-sessions', '--path', sessionsDir, '--raw'], tmpDir);
assert.ok(result.success, `Failed: ${result.error}`);
const out = JSON.parse(result.output);
assert.ok(Array.isArray(out), 'should return an array');
assert.strictEqual(out.length, 0, 'should be empty for empty sessions dir');
});
});

View File

@@ -1,386 +0,0 @@
'use strict';
// allow-test-rule: validates runtime CLI stdout/stderr warning behavior, not source grep
/**
* Regression tests for #3523 — CJS loadConfig must not emit a false
* "unknown config key(s)" warning for `branching_strategy` when that key
* is written at the top level of .planning/config.json.
*
* Root cause: KNOWN_TOP_LEVEL in core.cjs was built from VALID_CONFIG_KEYS
* via k.split('.')[0], which turns 'git.branching_strategy' → 'git', not
* 'branching_strategy'. So a config with the legacy top-level shape tripped
* the unknown-key warning even though core.cjs:485 actively reads the value.
*
* Fix (option 3 — self-healing): mirror the multiRepo → planning.sub_repos
* precedent: graft branching_strategy into fileData.git.branching_strategy
* and delete the top-level key, then persist. The KNOWN_TOP_LEVEL list also
* gains 'branching_strategy' as a deprecated-still-accepted key so the warning
* never fires even on the first read before the write-back occurs.
*
* Double-emission is also reduced: the warning site is guarded by a
* module-level Set so repeated loadConfig calls during one CLI invocation
* don't echo the same line twice.
*
* CJS↔SDK contract: the SDK mergeDefaults() already handles the legacy
* top-level key (PR #3116). This file adds a fixture-level parity check
* that proves both paths produce the same branching_strategy value.
*
* Test strategy: we use `resolve-model` as the minimal CJS entry point that
* calls loadConfig internally, then assert on stderr emptiness (typed-IR
* "no warning" pattern from #2687).
*/
const { describe, test, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { spawnSync } = require('node:child_process');
const { createTempProject, cleanup, TOOLS_PATH } = require('./helpers.cjs');
const TEST_ENV_BASE = {
GSD_SESSION_KEY: '',
CODEX_THREAD_ID: '',
CLAUDE_SESSION_ID: '',
CLAUDE_CODE_SSE_PORT: '',
OPENCODE_SESSION_ID: '',
GEMINI_SESSION_ID: '',
CURSOR_SESSION_ID: '',
WINDSURF_SESSION_ID: '',
TERM_SESSION_ID: '',
WT_SESSION: '',
TMUX_PANE: '',
ZELLIJ_SESSION_NAME: '',
TTY: '',
SSH_TTY: '',
};
/**
* Run gsd-tools and return { stdout, stderr, status }.
* Always captures stderr even when exit code is 0.
*/
function runWithStderr(args, cwd, env = {}) {
const result = spawnSync(process.execPath, [TOOLS_PATH, ...args], {
cwd,
encoding: 'utf-8',
env: { ...process.env, ...TEST_ENV_BASE, ...env },
});
return {
stdout: result.stdout || '',
stderr: result.stderr || '',
status: result.status,
};
}
// ─── Test 1: no warning for legacy top-level branching_strategy ──────────────
describe('bug-3523 — no warning for legacy top-level branching_strategy', () => {
let tmpDir;
afterEach(() => {
if (tmpDir) cleanup(tmpDir);
tmpDir = null;
});
test('loadConfig emits no stderr when config.json has top-level branching_strategy', () => {
tmpDir = createTempProject('gsd-3523-warn-');
const configPath = path.join(tmpDir, '.planning', 'config.json');
fs.writeFileSync(
configPath,
JSON.stringify({
branching_strategy: 'phase',
git: { base_branch: 'main' },
}, null, 2),
'utf-8'
);
// resolve-model calls loadConfig internally, triggering KNOWN_TOP_LEVEL check.
const result = runWithStderr(['resolve-model', 'planner'], tmpDir);
assert.equal(
result.stderr.trim(),
'',
`loadConfig must not warn about top-level branching_strategy (#3523) — got: ${result.stderr}`
);
});
test('branching_strategy value is still surfaced after loadConfig on legacy shape', () => {
tmpDir = createTempProject('gsd-3523-value-');
const configPath = path.join(tmpDir, '.planning', 'config.json');
fs.writeFileSync(
configPath,
JSON.stringify({
branching_strategy: 'milestone',
git: { base_branch: 'main' },
}, null, 2),
'utf-8'
);
// Trigger loadConfig (which runs the migration and writes git.branching_strategy
// back to disk), then read it with config-get to verify the value is preserved.
const triggerResult = runWithStderr(['resolve-model', 'planner'], tmpDir);
assert.equal(
triggerResult.stderr.trim(),
'',
`No warning should fire on legacy shape (#3523) — got: ${triggerResult.stderr}`
);
// After migration write-back, config-get should find git.branching_strategy.
const result = runWithStderr(['config-get', 'git.branching_strategy'], tmpDir);
assert.equal(
result.status,
0,
`config-get command must succeed — exit status ${result.status}, stderr: ${result.stderr}`
);
assert.equal(
result.stderr.trim(),
'',
`No error should fire when reading migrated branching_strategy (#3523) — got: ${result.stderr}`
);
assert.ok(
result.stdout.includes('milestone'),
`Expected git.branching_strategy to be 'milestone' but got: ${result.stdout}`
);
});
});
// ─── Test 2: no duplicated warning (double-emission) ─────────────────────────
describe('bug-3523 — double-emission reduced to single-emission', () => {
let tmpDir;
afterEach(() => {
if (tmpDir) cleanup(tmpDir);
tmpDir = null;
});
test('unknown-key warning appears at most once per process invocation', () => {
// Use a key that IS genuinely unknown (not branching_strategy, which is now
// fixed) to verify the deduplication guard works for other keys too.
// We verify that the count of warning lines for a single unknown key is
// exactly once — not zero and not two — even if loadConfig is invoked twice internally.
tmpDir = createTempProject('gsd-3523-dedup-');
const configPath = path.join(tmpDir, '.planning', 'config.json');
fs.writeFileSync(
configPath,
JSON.stringify({
// intentionally_unknown_key_for_dedup_test: a key that can never be valid
__gsd3523_dedup_sentinel__: true,
}, null, 2),
'utf-8'
);
const result = runWithStderr(['resolve-model', 'planner'], tmpDir);
// Count how many times the sentinel key appears in warnings
const warningLines = result.stderr
.split('\n')
.filter(l => l.includes('__gsd3523_dedup_sentinel__'));
assert.equal(
warningLines.length,
1,
`Unknown-key warning must appear exactly once per process invocation — ` +
`appeared ${warningLines.length} times. stderr:\n${result.stderr}`
);
});
});
// ─── Test 3: on-disk migration (option 3 write-back) ─────────────────────────
describe('bug-3523 — option 3 on-disk migration of branching_strategy', () => {
let tmpDir;
afterEach(() => {
if (tmpDir) cleanup(tmpDir);
tmpDir = null;
});
test('after loadConfig, on-disk config.json has branching_strategy under git.*', () => {
tmpDir = createTempProject('gsd-3523-writeback-');
const configPath = path.join(tmpDir, '.planning', 'config.json');
fs.writeFileSync(
configPath,
JSON.stringify({
branching_strategy: 'phase',
git: { base_branch: 'main' },
}, null, 2),
'utf-8'
);
// Trigger loadConfig by running a command.
runWithStderr(['resolve-model', 'planner'], tmpDir);
// On-disk file should now have git.branching_strategy and no top-level branching_strategy.
const onDisk = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
assert.equal(
onDisk.git?.branching_strategy,
'phase',
'Expected on-disk config.json to have git.branching_strategy = "phase" after migration'
);
assert.equal(
onDisk.branching_strategy,
undefined,
'Expected on-disk config.json to have no top-level branching_strategy after migration'
);
});
test('migration does not clobber existing git.branching_strategy', () => {
// If git.branching_strategy is already set, the top-level value should
// not overwrite it (nested wins, matching SDK mergeDefaults precedence).
tmpDir = createTempProject('gsd-3523-no-clobber-');
const configPath = path.join(tmpDir, '.planning', 'config.json');
fs.writeFileSync(
configPath,
JSON.stringify({
branching_strategy: 'phase', // legacy top-level
git: {
base_branch: 'main',
branching_strategy: 'milestone', // canonical nested — must win
},
}, null, 2),
'utf-8'
);
// Trigger loadConfig.
runWithStderr(['resolve-model', 'planner'], tmpDir);
const onDisk = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
assert.equal(
onDisk.git?.branching_strategy,
'milestone',
'canonical git.branching_strategy must not be overwritten by legacy top-level key'
);
// top-level key should be removed since it was redundant
assert.equal(
onDisk.branching_strategy,
undefined,
'top-level branching_strategy should be removed even when git.branching_strategy already set'
);
});
test('workstream load also self-heals legacy root branching_strategy', () => {
tmpDir = createTempProject('gsd-3523-workstream-root-');
const rootConfigPath = path.join(tmpDir, '.planning', 'config.json');
const workstreamDir = path.join(tmpDir, '.planning', 'workstreams', 'alpha');
fs.mkdirSync(workstreamDir, { recursive: true });
fs.writeFileSync(
rootConfigPath,
JSON.stringify({
branching_strategy: 'phase',
git: { base_branch: 'main' },
}, null, 2),
'utf-8'
);
fs.writeFileSync(
path.join(workstreamDir, 'config.json'),
JSON.stringify({ workflow: { tdd: true } }, null, 2),
'utf-8'
);
const triggerResult = runWithStderr(['resolve-model', 'planner'], tmpDir, {
GSD_WORKSTREAM: 'alpha',
});
assert.equal(
triggerResult.status,
0,
`workstream load command must succeed — exit status ${triggerResult.status}, stderr: ${triggerResult.stderr}`
);
assert.equal(
triggerResult.stderr.trim(),
'',
`No warning should fire while migrating root config for a workstream — got: ${triggerResult.stderr}`
);
const onDisk = JSON.parse(fs.readFileSync(rootConfigPath, 'utf-8'));
assert.equal(
onDisk.git?.branching_strategy,
'phase',
'Expected root config.json to persist git.branching_strategy after workstream load'
);
assert.equal(
onDisk.branching_strategy,
undefined,
'Expected root config.json to remove top-level branching_strategy after workstream load'
);
const rootResult = runWithStderr(['config-get', 'git.branching_strategy'], tmpDir);
assert.equal(
rootResult.status,
0,
`root config-get command must succeed after workstream migration — exit status ${rootResult.status}, stderr: ${rootResult.stderr}`
);
assert.ok(
rootResult.stdout.includes('phase'),
`Expected migrated root git.branching_strategy to be 'phase' but got: ${rootResult.stdout}`
);
});
});
// ─── Test 4: CJS↔SDK contract parity ────────────────────────────────────────
describe('bug-3523 — CJS↔SDK contract: both agree on legacy branching_strategy fixture', () => {
/**
* This is a light-touch contract test: we invoke the CJS path via CLI and
* compare the branching_strategy value it returns against what the SDK's
* mergeDefaults would compute for the same fixture.
*
* We can't import SDK TypeScript here, so we assert on the CJS output and
* use a snapshot of expected SDK behavior derived from the mergeDefaults
* source (sdk/src/config.ts:192-218):
* mergeDefaults({ branching_strategy: 'phase', git: { base_branch: 'main' } })
* → git.branching_strategy = 'phase'
*/
let tmpDir;
afterEach(() => {
if (tmpDir) cleanup(tmpDir);
tmpDir = null;
});
test('CJS loadConfig surfaces branching_strategy matching SDK mergeDefaults behavior', () => {
tmpDir = createTempProject('gsd-3523-parity-');
const configPath = path.join(tmpDir, '.planning', 'config.json');
// The fixture that the SDK's mergeDefaults handles correctly (PR #3116).
fs.writeFileSync(
configPath,
JSON.stringify({
branching_strategy: 'phase',
git: { base_branch: 'main' },
}, null, 2),
'utf-8'
);
// SDK mergeDefaults produces: git.branching_strategy = 'phase'
// CJS loadConfig must produce the same. Trigger loadConfig first (migration
// writes git.branching_strategy to disk), then verify with config-get.
const triggerResult = runWithStderr(['resolve-model', 'planner'], tmpDir);
assert.equal(
triggerResult.stderr.trim(),
'',
`No warning must fire on a standard legacy fixture — got: ${triggerResult.stderr}`
);
// After the migration write-back, config-get must find git.branching_strategy = 'phase',
// matching what the SDK's mergeDefaults would compute.
const result = runWithStderr(['config-get', 'git.branching_strategy'], tmpDir);
assert.equal(
result.status,
0,
`config-get command must succeed — exit status ${result.status}, stderr: ${result.stderr}`
);
assert.equal(
result.stderr.trim(),
'',
`No error when reading post-migration git.branching_strategy — got: ${result.stderr}`
);
assert.ok(
result.stdout.includes('phase'),
`CJS must agree with SDK: git.branching_strategy = 'phase' for legacy fixture. ` +
`Got: ${result.stdout}`
);
});
});

View File

@@ -1,178 +0,0 @@
// allow-test-rule: source-text-is-the-product
// Bug #3542 — Worktree stash storage is shared across agent worktrees;
// `git stash pop` from an executor agent contaminates its isolation.
//
// Git stores stashes at `refs/stash` (plus the stash reflog) inside the
// PARENT `.git/` directory. Every linked worktree shares that ref, so a
// `git stash push` in any worktree (or in the main checkout) is visible —
// and poppable — from every other worktree. From inside a worktree,
// `git stash list` shows the shared list with no indication that an entry
// originated elsewhere.
//
// Incident: an executor agent ran `git stash` (printed "No local changes
// to save" — nothing pushed), then `git stash pop`, which yanked a stash
// from a prior worktree-agent session. Result: 21 files in UU/UD state,
// 16 phantom untracked files, ~12 minutes of recovery work. This breaks
// the `isolation="worktree"` invariant documented in the executor agent.
//
// Two test cases:
//
// A. The agent prompt content asserts the `git stash` family is
// prohibited and documents an alternative. The prompt content IS
// the runtime contract for the agent — source-text-is-the-product
// (per CONTEXT.md `RULESET.TESTS.no-source-grep.exemption`).
//
// B. A behavioural test that pins the git invariant the prohibition
// defends against: a stash pushed in the main checkout is visible in
// a linked worktree's `git stash list`, proving stash storage is
// shared and cannot be relied on for worktree-scoped isolation.
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { execSync } = require('node:child_process');
const { cleanup } = require('./helpers.cjs');
const EXECUTOR_PATH = path.join(__dirname, '..', 'agents', 'gsd-executor.md');
// ─── Test A — prompt content asserts the prohibition ───────────────────────
test('bug-3542: gsd-executor.md prohibits `git stash` family inside worktrees', () => {
const content = fs.readFileSync(EXECUTOR_PATH, 'utf-8');
// The prohibition must call out `git stash` explicitly. Just listing
// "stash" isn't enough — the existing post-wave-hook helper script
// legitimately mentions stash, so we look for the specific forbidden
// commands the agent must never run on its own.
assert.match(
content,
/`git stash`/,
'gsd-executor.md must explicitly forbid `git stash` (bare push) — see #3542',
);
assert.match(
content,
/`git stash pop`/,
'gsd-executor.md must explicitly forbid `git stash pop` — the load-bearing footgun (#3542)',
);
assert.match(
content,
/`git stash apply`/,
'gsd-executor.md must explicitly forbid `git stash apply` — same shared-stack hazard as pop (#3542)',
);
assert.match(
content,
/`git stash drop`/,
'gsd-executor.md must explicitly forbid `git stash drop` — mutates the shared stack (#3542)',
);
// The prohibition must explain WHY (shared storage across worktrees) so
// the agent understands the failure mode rather than treating it as an
// arbitrary rule.
assert.match(
content,
/shared|share[d]?\s+(across|between)/i,
'gsd-executor.md must document that stash storage is shared across worktrees (#3542)',
);
// The prohibition must document at least one alternative the agent CAN
// use to inspect or move work between refs without touching `refs/stash`.
// The triage brief proposes commit-to-throwaway-branch OR read-only
// `git show <ref>:<path>` / `git diff <ref> -- <path>`.
const hasThrowawayBranch = /throwaway[- ]branch|temp(?:orary)?[- ]?branch|scratch[- ]branch/i.test(
content,
);
const hasGitShow = /`git show /i.test(content);
const hasGitDiffRef = /`git diff [^`]*\$?\{?ref\}?|`git diff [A-Z]+:/i.test(content);
assert.ok(
hasThrowawayBranch || hasGitShow || hasGitDiffRef,
'gsd-executor.md must document an alternative to `git stash` ' +
'(commit-to-throwaway-branch, or read-only `git show <ref>:<path>` / ' +
'`git diff <ref> -- <path>`) so the agent has a sanctioned escape path (#3542)',
);
// The issue number must appear so future readers can trace the rule to
// its incident.
assert.match(
content,
/#3542/,
'gsd-executor.md must reference issue #3542 next to the stash prohibition for traceability',
);
});
// ─── Test B — behavioural pin of the git invariant ─────────────────────────
test('bug-3542: stash pushed in main checkout is visible inside a linked worktree', () => {
const tmpRoot = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'bug-3542-stash-')));
const mainRepo = path.join(tmpRoot, 'main');
const linkedWorktree = path.join(tmpRoot, 'wt');
try {
// Set up a normal repo with one commit.
fs.mkdirSync(mainRepo);
const gitOpts = { cwd: mainRepo, stdio: 'pipe' };
execSync('git init -q', gitOpts);
execSync('git config user.email "test@test.com"', gitOpts);
execSync('git config user.name "Test"', gitOpts);
execSync('git config commit.gpgsign false', gitOpts);
fs.writeFileSync(path.join(mainRepo, 'a.txt'), 'initial\n');
execSync('git add a.txt', gitOpts);
execSync('git commit -q -m initial', gitOpts);
// Create a linked worktree on a separate branch — this is what the
// executor agent runs inside.
execSync(`git worktree add -q "${linkedWorktree}" -b wt-branch`, gitOpts);
// Push a stash from the MAIN checkout (simulating a prior session).
fs.writeFileSync(path.join(mainRepo, 'a.txt'), 'wip in main\n');
execSync('git stash push -q -u -m "from-main-checkout"', gitOpts);
// Sanity check: the stash exists in the main checkout's view.
const mainList = execSync('git stash list', { cwd: mainRepo }).toString();
assert.match(
mainList,
/from-main-checkout/,
'pre-condition: main checkout must see its own stash entry',
);
// The load-bearing assertion: the linked worktree sees the same
// stash entry, even though it was pushed from a different working
// tree. This is the invariant that makes `git stash pop` inside an
// executor agent's worktree an isolation violation.
const worktreeList = execSync('git stash list', {
cwd: linkedWorktree,
}).toString();
assert.match(
worktreeList,
/from-main-checkout/,
'bug #3542 invariant: stash entries pushed from any worktree (or the ' +
'main checkout) are visible in every linked worktree, because ' +
'`refs/stash` lives in the shared parent .git directory. If this ' +
'assertion ever stops holding (e.g. git introduces per-worktree ' +
'stash storage in a future release), the executor agent prohibition ' +
'in agents/gsd-executor.md can be relaxed.',
);
// Stronger pin: a `git stash pop` inside the worktree must actually
// pop the stash pushed from main — proving cross-worktree mutation,
// not just visibility. We pop into a clean working tree on a
// different branch, so any applied content is the contamination.
execSync('git stash pop -q', { cwd: linkedWorktree, stdio: 'pipe' });
// On Windows autocrlf=true, git rewrites stashed content with CRLF on
// checkout. Strip \r before content compare — the test pins git's
// shared-stash behavior, not line endings.
const popped = fs.readFileSync(path.join(linkedWorktree, 'a.txt'), 'utf-8').replace(/\r\n/g, '\n');
assert.strictEqual(
popped,
'wip in main\n',
'bug #3542 invariant: `git stash pop` inside a linked worktree applies ' +
'a stash pushed in the main checkout — proving the shared-stack ' +
'contamination the executor prohibition exists to prevent.',
);
} finally {
cleanup(tmpRoot);
}
});

View File

@@ -1,110 +0,0 @@
'use strict';
/**
* Regression test for #3588 — production dependency tree must not carry
* high or moderate npm-audit advisories.
*
* Strategy: run `npm audit --omit=dev --json` against both the root
* workspace and the embedded SDK package and assert that the metadata
* vulnerability counts are zero across info/low/moderate/high/critical.
*
* The test is intentionally strict — any advisory of any severity (other
* than 'low' if the maintainer accepts it; that branch is left explicit
* here) blocks CI. If a future advisory lands without an upstream patch,
* either bump the patched transitive (preferred), or annotate the
* acceptance below with a justification AND a link to the upstream tracker.
*
* Skips automatically when `node_modules/` is absent (a fresh checkout
* before `npm install`) so the test does not falsely report on developer
* machines mid-setup.
*/
const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
const path = require('node:path');
const fs = require('node:fs');
const { execFileSync } = require('node:child_process');
const ROOT = path.resolve(__dirname, '..');
const SDK = path.join(ROOT, 'sdk');
const AUDIT_TIMEOUT_MS = 180_000;
const TEST_TIMEOUT_MS = AUDIT_TIMEOUT_MS + 30_000;
function auditProductionVulns(cwd) {
if (!fs.existsSync(path.join(cwd, 'package.json'))) {
return null; // signal "skip" to caller
}
if (!fs.existsSync(path.join(cwd, 'node_modules'))) {
return null; // signal "skip" to caller
}
const isWindows = process.platform === 'win32';
const npmCandidates = isWindows ? ['npm.cmd', 'npm'] : ['npm'];
const args = ['audit', '--omit=dev', '--json'];
let out;
let lastErr = null;
for (const npmCmd of npmCandidates) {
try {
out = execFileSync(
npmCmd,
args,
{
cwd,
encoding: 'utf-8',
stdio: ['ignore', 'pipe', 'pipe'],
timeout: AUDIT_TIMEOUT_MS,
shell: isWindows,
}
);
lastErr = null;
break;
} catch (e) {
// `npm audit` exits non-zero when advisories are present; the JSON is
// still on stdout in that case. Recover and let the assertion classify.
if (e && typeof e.stdout !== 'undefined' && e.stdout !== undefined && e.stdout !== null) {
out = Buffer.isBuffer(e.stdout) ? e.stdout.toString('utf-8') : String(e.stdout);
lastErr = null;
break;
}
lastErr = e;
}
}
if (lastErr) throw lastErr;
const parsed = JSON.parse(out);
// `null` is reserved for the "node_modules missing → skip" signal above.
// Any other unexpected JSON shape is a real failure of the audit harness
// (npm changed its output format, audit aborted before metadata, etc.) —
// throw so the test fails loudly instead of skipping silently.
if (parsed && parsed.metadata && parsed.metadata.vulnerabilities) {
return parsed.metadata.vulnerabilities;
}
throw new Error(`Unexpected npm audit JSON shape in ${cwd}: missing metadata.vulnerabilities`);
}
describe('#3588: npm audit --omit=dev reports zero advisories', () => {
test('root workspace production tree has no advisories', { timeout: TEST_TIMEOUT_MS }, (t) => {
const vulns = auditProductionVulns(ROOT);
if (vulns === null) {
t.skip('auditable npm package not present or node_modules/ missing');
return;
}
assert.strictEqual(vulns.critical, 0, `expected 0 critical; got ${vulns.critical}`);
assert.strictEqual(vulns.high, 0, `expected 0 high; got ${vulns.high}`);
assert.strictEqual(vulns.moderate, 0, `expected 0 moderate; got ${vulns.moderate}`);
// Low advisories are not explicitly forbidden by the #3588 acceptance
// criterion but the issue listed only high/moderate as actual findings —
// tighten if any future low advisory is introduced.
assert.strictEqual(vulns.low, 0, `expected 0 low; got ${vulns.low}`);
});
test('sdk/ production tree has no advisories', { timeout: TEST_TIMEOUT_MS }, (t) => {
const vulns = auditProductionVulns(SDK);
if (vulns === null) {
t.skip('sdk/ is not an auditable npm package or sdk/node_modules/ is missing');
return;
}
assert.strictEqual(vulns.critical, 0, `expected 0 critical; got ${vulns.critical}`);
assert.strictEqual(vulns.high, 0, `expected 0 high; got ${vulns.high}`);
assert.strictEqual(vulns.moderate, 0, `expected 0 moderate; got ${vulns.moderate}`);
assert.strictEqual(vulns.low, 0, `expected 0 low; got ${vulns.low}`);
});
});

View File

@@ -1,156 +0,0 @@
/**
* Bug #3668: workflow resolver snippets must run from installed user projects.
*
* A user project normally does not contain gsd-core/bin/gsd-tools.cjs.
* The snippets should still prefer RUNTIME_DIR for local/dev installs, then
* fall back to the installed gsd-tools binary on PATH.
*/
'use strict';
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { execFileSync } = require('node:child_process');
const WORKFLOW_PATH = path.join(__dirname, '..', 'gsd-core', 'workflows', 'next.md');
/**
* Extract the canonical runtime resolver snippet from next.md.
*
* Supports two forms:
* - One-line form (canonical): the entire launcher is a single line starting with
* `_GSD_SHIM_NAME="gsd-tools.cjs";` — return that line directly.
* - Multi-line form (legacy): starts with a `# Runtime launcher:` comment or a
* `_GSD_SHIM_NAME=` line followed by separate GSD_TOOLS= and if/elif/else/fi
* lines — scan to the closing `fi`.
*/
function extractResolverSnippet() {
const content = fs.readFileSync(WORKFLOW_PATH, 'utf8');
const lines = content.split(/\r?\n/);
// Find the canonical preamble — prefer _GSD_SHIM_NAME= line (handles both forms)
let start = lines.findIndex((line) => /^_GSD_SHIM_NAME=/.test(line.trim()));
if (start === -1) {
// Fallback: canonical preamble comment (multi-line legacy form)
start = lines.findIndex((line) =>
/^\s*#\s*Runtime launcher:.*prefer local gsd-tools\.cjs.*installed gsd-tools on PATH/.test(line)
);
}
if (start === -1) {
// Last fallback: GSD_TOOLS= with RUNTIME_DIR
start = lines.findIndex((line) => line.includes('GSD_TOOLS="${RUNTIME_DIR:-'));
}
assert.notEqual(
start,
-1,
'next.md must contain the canonical runtime preamble ' +
'(_GSD_SHIM_NAME= line, # Runtime launcher: comment, or GSD_TOOLS= line with RUNTIME_DIR)'
);
// One-line form: the entire launcher (including `if` and `fi`) is on a single line.
// Detect by checking whether the start line contains a semicolon-separated `if` and `fi`.
const startLine = lines[start].trim();
if (/^_GSD_SHIM_NAME=.*;\s*if\s+\[.*\bfi$/.test(startLine)) {
// Single-line canonical launcher — return it as-is
return startLine;
}
// Multi-line form: scan forward from start to the closing `fi`, tracking if-depth
let depth = 0;
let end = -1;
for (let i = start; i < lines.length; i++) {
const t = lines[i].trim();
if (/^if\s+/.test(t)) depth++;
if (/^fi(\s|$)/.test(t)) {
depth--;
if (depth === 0) {
end = i;
break;
}
}
}
assert.notEqual(end, -1, 'runtime preamble must end with a closing `fi`');
return lines.slice(start, end + 1).join('\n');
}
function makeTempDir() {
return fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-runtime-resolution-'));
}
function runResolver({ cwd, runtimeDir, pathDir }) {
const script = [
'set -e',
extractResolverSnippet(),
'printf "GSD_TOOLS=%s\\n" "$GSD_TOOLS"',
'gsd_run query state.json',
].join('\n');
return execFileSync('bash', ['-c', script], {
cwd,
env: {
...process.env,
PATH: `${pathDir}${path.delimiter}${process.env.PATH || ''}`,
RUNTIME_DIR: runtimeDir || '',
},
encoding: 'utf8',
});
}
function writeExecutable(file, content) {
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, content, { mode: 0o755 });
}
describe('bug-3668: workflow SDK resolver supports installed user projects', () => {
test('falls back to installed gsd-tools when project-local runtime copy is absent', () => {
// Bug #3668: when a user project has no local gsd-core/bin/gsd-tools.cjs,
// the elif branch must resolve to the gsd-tools binary on PATH.
// RUNTIME_DIR points to a dir that has no gsd-tools.cjs.
const tmp = makeTempDir();
const project = path.join(tmp, 'user-project');
const runtimeNoLocal = path.join(tmp, 'runtime-no-local');
const pathBin = path.join(tmp, 'bin');
fs.mkdirSync(project, { recursive: true });
fs.mkdirSync(runtimeNoLocal, { recursive: true });
// Place gsd-tools stub on PATH (installed binary)
writeExecutable(
path.join(pathBin, 'gsd-tools'),
'#!/bin/sh\nprintf "installed:%s %s\\n" "$1" "$2"\n',
);
// NO gsd-core/bin/gsd-tools.cjs in runtimeNoLocal
const output = runResolver({ cwd: project, runtimeDir: runtimeNoLocal, pathDir: pathBin });
// GSD_TOOLS must have been reassigned to the PATH binary (not the missing .cjs)
assert.match(output, /GSD_TOOLS=.+gsd-tools(?:\s|$)/m);
// The PATH stub must have been invoked
assert.match(output, /installed:query state\.json/);
});
test('preserves RUNTIME_DIR local gsd-tools.cjs preference over PATH fallback', () => {
const tmp = makeTempDir();
const project = path.join(tmp, 'user-project');
const runtime = path.join(tmp, 'runtime');
const pathBin = path.join(tmp, 'bin');
fs.mkdirSync(project, { recursive: true });
writeExecutable(path.join(pathBin, 'gsd-tools'), '#!/bin/sh\nprintf "path-installed:%s %s\\n" "$1" "$2"\n');
writeExecutable(
path.join(runtime, 'gsd-core', 'bin', 'gsd-tools.cjs'),
'#!/usr/bin/env node\nconsole.log(`runtime:${process.argv[2]} ${process.argv[3]}`);\n',
);
const output = runResolver({ cwd: project, runtimeDir: runtime, pathDir: pathBin });
// Normalize separators so the assertion works on Windows (Git bash emits POSIX paths)
const norm = output.replace(/\\/g, '/');
// The resolved bin is the RUNTIME_DIR local runtime (suffix /gsd-core/bin/gsd-tools.cjs)
// Use .+ instead of \S* to handle paths with spaces (e.g. /Volumes/Mini Me/...)
assert.match(norm, /GSD_TOOLS=.+\/gsd-core\/bin\/gsd-tools\.cjs(?:\s|$)/m);
assert.match(output, /runtime:query state\.json/);
assert.doesNotMatch(output, /path-installed:query state\.json/);
});
});

View File

@@ -1,244 +0,0 @@
// allow-test-rule: source-text-is-the-product
// Tests A1/A2/B inspect agent / installed `.md` bodies whose deployed text IS
// the runtime contract. Tests C exercises the install.js exported pure helper
// `shouldNormalizeHyphenNamespaceInAgentBody` directly — purely behavioral.
/**
* Regression for #3677 — installed agent bodies leak `/gsd:<cmd>` colon refs
* for Claude / Qwen / Hermes (unroutable since #2808).
*
* Root cause: `bin/install.js` agent install loop (around line 8350-8447)
* reads each agent .md, runs runtime-specific transforms via
* `convertClaudeAgentToXAgent()`, then writes the result. For:
* - Self-converting runtimes (Copilot/Codex/Cursor/Windsurf/Augment/Trae/
* Codebuddy/Cline/Antigravity/Opencode/Kilo): their converters handle
* namespace themselves.
* - Gemini: intentionally uses colon namespace.
* - Claude-default / Qwen / Hermes: register hyphen-form `name:` (#2808)
* but copy bodies verbatim (Qwen/Hermes do branding-only swaps; Claude
* does no namespace work). The retired `/gsd:<cmd>` colon refs leak.
*
* Sibling fixes #3583 (SKILL.md, via #3629) and #3584 (runtime emissions, via
* #3606) covered the other two surfaces. This is the agent-body surface.
*
* Fix surface:
* 1. `bin/install.js` exports a pure predicate
* `shouldNormalizeHyphenNamespaceInAgentBody(runtime)` plus a helper
* `normalizeAgentBodyForRuntime(content, runtime, cmdNames)` that
* conditionally applies `transformContentToHyphen` from
* scripts/fix-slash-commands.cjs.
* 2. The agent install loop calls the helper after all runtime-specific
* conversions but before writeFileSync.
* 3. This regression test guards both the predicate and the integration.
*/
'use strict';
process.env.GSD_TEST_MODE = '1';
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const path = require('node:path');
// Single `..` traversal matches the existing tests/helpers.cjs convention
// (TOOLS_PATH at tests/helpers.cjs:21). Avoids `..` chains per CLAUDE.md and
// works in the docker mirror at /work/tests (which has no `.git` to anchor on).
const REPO_ROOT = path.resolve(__dirname, '..');
const install = require(path.join(REPO_ROOT, 'bin', 'install.js'));
const { transformContentToHyphen } = require(path.join(REPO_ROOT, 'scripts', 'fix-slash-commands.cjs'));
// Snapshot of all runtime IDs in the layout table at the time of this fix.
// Keep these two sets covering: any runtime listed in
// runtime-artifact-layout.cjs MUST appear in exactly one bucket.
const HYPHEN_NAME_AGENT_RUNTIMES = ['claude', 'qwen', 'hermes'];
const SELF_CONVERTING_OR_COLON_RUNTIMES = [
'gemini', // intentionally colon-namespaced
'codex', 'copilot', 'antigravity', 'cursor', 'windsurf', 'augment',
'trae', 'codebuddy', 'cline',
'opencode', 'kilo',
];
describe('bug #3677 — agent body colon-namespace leak (Claude / Qwen / Hermes)', () => {
describe('A — install.js exports the pure predicate + helper', () => {
test('A1: shouldNormalizeHyphenNamespaceInAgentBody is an exported function', () => {
assert.strictEqual(
typeof install.shouldNormalizeHyphenNamespaceInAgentBody,
'function',
'bin/install.js must export shouldNormalizeHyphenNamespaceInAgentBody as the runtime predicate (regression seam for #3677)',
);
});
test('A2: normalizeAgentBodyForRuntime is an exported function', () => {
assert.strictEqual(
typeof install.normalizeAgentBodyForRuntime,
'function',
'bin/install.js must export normalizeAgentBodyForRuntime as the wired helper called by the agent install loop',
);
});
});
describe('B — predicate returns true for hyphen-`name:` runtimes and false otherwise', () => {
const { shouldNormalizeHyphenNamespaceInAgentBody } = install;
for (const runtime of HYPHEN_NAME_AGENT_RUNTIMES) {
test(`B+ '${runtime}': normalize hyphen namespace (true)`, () => {
assert.strictEqual(
shouldNormalizeHyphenNamespaceInAgentBody(runtime),
true,
`${runtime} registers hyphen-form 'name:' (#2808) and copies agent bodies verbatim — must normalize`,
);
});
}
for (const runtime of SELF_CONVERTING_OR_COLON_RUNTIMES) {
test(`B- '${runtime}': skip normalization (false)`, () => {
assert.strictEqual(
shouldNormalizeHyphenNamespaceInAgentBody(runtime),
false,
`${runtime} either self-converts via convertClaudeAgentToXAgent or intentionally uses colon — must NOT re-rewrite`,
);
});
}
test('B?: unknown runtime defaults to false (conservative)', () => {
assert.strictEqual(
shouldNormalizeHyphenNamespaceInAgentBody('bogus-runtime-id'),
false,
'unknown runtimes must not be normalized — better to leak than to mangle',
);
});
});
describe('C — normalizeAgentBodyForRuntime applies transformContentToHyphen iff predicate is true', () => {
const { normalizeAgentBodyForRuntime } = install;
// Sample agent body with colon refs that #2808 retired.
const inputBody = [
'# Agent prose',
'',
'Run `/gsd:execute-phase 1 --tdd` to execute the phase.',
'Then `/gsd:verify-work 1` to verify.',
'Reference unchanged: `gsd-sdk query commit` (this is a CLI binary, not a slash command).',
].join('\n');
// Only known commands from commands/gsd/*.md should be rewritten; gsd-sdk
// (a binary) must stay untouched.
const cmdNames = ['execute-phase', 'verify-work', 'plan-phase'];
test('C1: claude — rewrites both colon refs to hyphen', () => {
const out = normalizeAgentBodyForRuntime(inputBody, 'claude', cmdNames);
assert.ok(out.includes('/gsd-execute-phase'), 'execute-phase must be rewritten to hyphen form');
assert.ok(out.includes('/gsd-verify-work'), 'verify-work must be rewritten to hyphen form');
assert.ok(!out.includes('/gsd:execute-phase'), 'colon form for execute-phase must be gone');
assert.ok(!out.includes('/gsd:verify-work'), 'colon form for verify-work must be gone');
assert.ok(out.includes('gsd-sdk query commit'), 'gsd-sdk (CLI binary) must not be touched');
});
test('C2: qwen — same transform applies', () => {
const out = normalizeAgentBodyForRuntime(inputBody, 'qwen', cmdNames);
assert.ok(out.includes('/gsd-execute-phase'));
assert.ok(!out.includes('/gsd:execute-phase'));
});
test('C3: hermes — same transform applies', () => {
const out = normalizeAgentBodyForRuntime(inputBody, 'hermes', cmdNames);
assert.ok(out.includes('/gsd-execute-phase'));
assert.ok(!out.includes('/gsd:execute-phase'));
});
test('C4: gemini — colon refs preserved (intentional namespace)', () => {
const out = normalizeAgentBodyForRuntime(inputBody, 'gemini', cmdNames);
assert.ok(out.includes('/gsd:execute-phase'), 'Gemini intentionally uses colon namespace; do not rewrite');
assert.ok(!out.includes('/gsd-execute-phase'), 'Gemini agents must NOT have hyphen form');
});
test('C5: self-converting runtime (copilot) — body returned unchanged at this layer', () => {
const out = normalizeAgentBodyForRuntime(inputBody, 'copilot', cmdNames);
// Copilot has its own convertClaudeAgentToCopilotAgent that handles
// namespace — the normalize layer is a no-op for it.
assert.strictEqual(out, inputBody);
});
});
describe('D — sanity check: the underlying transform actually works against real cmd names', () => {
test('D1: transformContentToHyphen rewrites /gsd:<cmd> to /gsd-<cmd> for known cmds only', () => {
const out = transformContentToHyphen(
'A /gsd:execute-phase B /gsd:unknown-cmd C /gsd-sdk D',
['execute-phase'],
);
assert.ok(out.includes('/gsd-execute-phase'), 'known cmd rewritten');
assert.ok(out.includes('/gsd:unknown-cmd'), 'unknown cmd preserved (longest-first matcher only rewrites registered names)');
assert.ok(out.includes('/gsd-sdk'), 'gsd-sdk (binary, not slash command) preserved');
});
});
// ---------------------------------------------------------------------------
// E — Behavioral coverage ported from PR #3681 (johnzilla / John Turner).
//
// #3681 proposed the same allow-list fix independently and was closed by its
// author in favor of this PR. Its test file contributed two coverage angles
// worth keeping: real-source efficacy against every `agents/gsd-*.md` (the
// shape of bug that pure-function tests miss) and idempotence-via-fixpoint
// (guards against double-rewrite on reinstall). Credit: johnzilla.
// ---------------------------------------------------------------------------
describe('E — real-source efficacy + idempotence (ported from #3681, credit: johnzilla)', () => {
const fs = require('node:fs');
const { readCmdNames } = require(path.join(REPO_ROOT, 'scripts', 'fix-slash-commands.cjs'));
const cmdNames = readCmdNames();
// Roster regex matches any registered command in `gsd:<cmd>` form with a
// negative lookbehind (so `mygsd:foo` is ignored) and a non-word lookahead
// (so `plan-phase-extra` is not a false match for `plan-phase`).
const roster = () => new RegExp(
`(?<![a-zA-Z0-9_-])gsd:(${[...cmdNames].sort((a, b) => b.length - a.length).join('|')})(?=[^a-zA-Z0-9_-]|$)`,
);
test('E0: command roster is populated and contains the symptom commands', () => {
assert.ok(cmdNames.length > 0, 'command roster must be populated');
assert.ok(cmdNames.includes('execute-phase'));
assert.ok(cmdNames.includes('plan-phase'));
});
test('E1: every agents/gsd-*.md transforms clean — no roster colon refs survive', () => {
const agentsDir = path.join(REPO_ROOT, 'agents');
const offenders = [];
// Not the shared listAgentFiles() helper: this needs full `.md` filenames
// (not stripped basenames) to readFileSync + transform each agent body.
for (const f of fs.readdirSync(agentsDir)) {
if (!f.startsWith('gsd-') || !f.endsWith('.md')) continue;
const src = fs.readFileSync(path.join(agentsDir, f), 'utf-8');
const out = transformContentToHyphen(src, cmdNames);
if (roster().test(out)) offenders.push(f);
}
assert.deepEqual(
offenders,
[],
`agents still carry roster colon refs after transform: ${offenders.join(', ')}`,
);
});
test('E2: idempotent — transform of already-hyphenated input is a no-op', () => {
const input = 'use /gsd-plan-phase next, then /gsd-execute-phase';
assert.strictEqual(
transformContentToHyphen(input, cmdNames),
input,
'reinstalls re-run the transform; double application must not mangle the body',
);
});
test('E3: word boundary — /gsd:plan-phase-extra is not a roster match', () => {
assert.strictEqual(
transformContentToHyphen('/gsd:plan-phase-extra', cmdNames),
'/gsd:plan-phase-extra',
);
});
test('E4: rewrites bare `gsd:<cmd>` shorthand (no leading slash)', () => {
const out = transformContentToHyphen(
'Spawned by the gsd:execute-phase orchestrator.',
cmdNames,
);
assert.strictEqual(out, 'Spawned by the gsd-execute-phase orchestrator.');
});
});
});

View File

@@ -1,251 +0,0 @@
// allow-test-rule: source-text-is-the-product
// Three of the assertions in this file (A1, A2, C) inspect agent / workflow
// `.md` bodies. Those files ARE the runtime contract that GSD loads into agent
// prompts at run time, so source-text inspection is exactly what the
// `source-text-is-the-product` exception covers.
//
// The remaining assertions (B1, B2, B3) are behavioral — they invoke
// `gsd-tools commit` against a temp project and assert on its structured
// JSON return envelope plus the git index state. No raw-text matching on
// rendered output.
/**
* Regression for #3678 — gsd-executor force-commits .planning/ files when
* commit_docs is false.
*
* Root cause: the executor agent prompt (agents/gsd-executor.md) tells the
* agent to call `gsd-sdk query commit "docs(...)" --files .planning/...`
* in the per-plan final_commit block, but the prompt says nothing about
* what to do when the SDK returns `{committed: false, skipped: true,
* reason: 'skipped_commit_docs_false'}`. With no explicit instruction, the
* agent improvises raw `git add` / `git commit` against `.planning/` paths
* (and uses `-f` to bypass gitignore), which is exactly the leakage the
* reporter observed.
*
* Fix surface:
* 1. Agent prompt: explicit handling text in the final_commit section.
* 2. SDK envelope: add `skipped: true` field so agents see "skipped" as a
* first-class success signal, not "committed is missing, must improvise."
* 3. Structural guard: ban `git add -f` / `git add --force` from agent and
* workflow bodies entirely (no GSD-managed surface should force-stage
* gitignored content).
*/
'use strict';
const { describe, test, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { execFileSync } = require('node:child_process');
const { createTempGitProject, cleanup, runGsdTools } = require('./helpers.cjs');
// Repo root resolution. This test file lives in `<repo>/tests/`. Use a single
// parent reference (the established repo-wide pattern, e.g. tests/helpers.cjs
// `path.resolve(__dirname, '..', 'gsd-core', ...)`). A `.git`-anchored
// walker is not portable because the docker test mirror at `/work` strips the
// `.git/` directory before running tests.
const REPO_ROOT = path.resolve(__dirname, '..');
const EXECUTOR_AGENT = path.join(REPO_ROOT, 'agents', 'gsd-executor.md');
// Frozen reason enum mirrors the SDK source — keep in sync with
// `cmdCommit` in gsd-core/bin/lib/commands.cjs.
const COMMIT_REASON = Object.freeze({
SKIPPED_COMMIT_DOCS_FALSE: 'skipped_commit_docs_false',
SKIPPED_GITIGNORED: 'skipped_gitignored',
});
function git(args, cwd) {
return execFileSync('git', args, { cwd, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] });
}
describe('bug #3678 — executor must respect commit_docs:false', () => {
describe('A — agent prompt teaches the agent how to handle commit_docs:false', () => {
test('A1: agent body explicitly references the SDK skipped envelope', () => {
const body = fs.readFileSync(EXECUTOR_AGENT, 'utf-8');
// The prompt must contain at least one literal mention of the skipped
// reason code OR the `committed: false` envelope so the agent knows
// that skipping is an intentional control flow, not a failure to work
// around.
const mentionsSkipReason = body.includes(COMMIT_REASON.SKIPPED_COMMIT_DOCS_FALSE);
const mentionsCommittedFalse = /committed:\s*false/i.test(body);
const mentionsSkippedTrue = /skipped:\s*true/i.test(body);
assert.ok(
mentionsSkipReason || mentionsCommittedFalse || mentionsSkippedTrue,
'agents/gsd-executor.md must teach the agent how to recognize the '
+ 'skipped envelope from `gsd-sdk query commit` (one of: '
+ `'${COMMIT_REASON.SKIPPED_COMMIT_DOCS_FALSE}', 'committed: false', `
+ "'skipped: true').",
);
});
test('A2: agent body explicitly forbids raw git fallback when SDK skips', () => {
const body = fs.readFileSync(EXECUTOR_AGENT, 'utf-8');
// Look for an explicit instruction tying the SDK-skipped signal to the
// forbidden-fallback rule. Accept any of three shapes the doc writer
// might use: "do not", "must not", or "never" + a verb that names the
// forbidden action.
const forbidsFallbackText = /(do not|must not|never)\s+(fall back|fallback|use .*git add|run .*git commit|force[- ]?add)/i;
assert.ok(
forbidsFallbackText.test(body),
'agents/gsd-executor.md must contain an explicit "do not fall back to '
+ 'raw git" instruction tied to the commit_docs:false / skipped envelope. '
+ 'Without it, the agent improvises raw `git add` / `git add -f` to '
+ 'fulfill its "complete plan" goal.',
);
});
});
describe('B — SDK behavior: commit_docs:false leaves repo state untouched', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempGitProject();
// .planning/ already exists from createTempGitProject's setup.
// Set commit_docs to false on the config.
const configPath = path.join(tmpDir, '.planning', 'config.json');
let config = {};
if (fs.existsSync(configPath)) {
config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
}
config.commit_docs = false;
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
// Make a token edit to .planning/STATE.md so there IS something the SDK
// could in principle stage (or that an improvising agent could leak).
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
if (!fs.existsSync(statePath)) {
fs.writeFileSync(statePath, '---\nproject: test\n---\n# State\n');
}
fs.appendFileSync(statePath, '\n<!-- token edit for #3678 repro -->\n');
});
afterEach(() => cleanup(tmpDir));
test('B1: commit returns committed:false with skipped envelope', () => {
const result = runGsdTools(
'commit "docs(test): noop" --files .planning/STATE.md',
tmpDir,
);
assert.ok(result.success, `gsd-tools commit should exit 0 even when skipped: ${result.error || ''}`);
const envelope = JSON.parse(result.output);
assert.strictEqual(envelope.committed, false, 'committed must be false when commit_docs is false');
assert.strictEqual(
envelope.skipped,
true,
'envelope must carry skipped:true so agents see skip as a first-class signal (envelope contract for #3678)',
);
assert.strictEqual(
envelope.reason,
COMMIT_REASON.SKIPPED_COMMIT_DOCS_FALSE,
'reason must be the canonical skipped_commit_docs_false code (frozen enum)',
);
});
test('B2: commit_docs:false leaves the git index empty (no .planning/ staged)', () => {
runGsdTools(
'commit "docs(test): noop" --files .planning/STATE.md',
tmpDir,
);
const stagedAll = git(['diff', '--cached', '--name-only'], tmpDir);
const stagedPlanning = stagedAll
.split(/\r?\n/)
.map(s => s.trim())
.filter(s => s.startsWith('.planning/'));
assert.deepStrictEqual(
stagedPlanning,
[],
'no .planning/ files should be staged when commit_docs is false',
);
});
test('B3: commit_docs:false produces no new commits', () => {
const headBefore = git(['rev-parse', 'HEAD'], tmpDir).trim();
runGsdTools(
'commit "docs(test): noop" --files .planning/STATE.md',
tmpDir,
);
const headAfter = git(['rev-parse', 'HEAD'], tmpDir).trim();
assert.strictEqual(
headAfter,
headBefore,
'HEAD must not advance when commit_docs is false',
);
});
});
test('checklist carve-out preserved for intentional skip', () => {
const body = fs.readFileSync(EXECUTOR_AGENT, 'utf-8');
const checklistLine = body
.split(/\r?\n/)
.find(line => /Final metadata commit made/.test(line));
assert.ok(
checklistLine,
'agents/gsd-executor.md must contain a "Final metadata commit made" checklist line',
);
assert.ok(
checklistLine.includes('Final metadata commit'),
'checklist line must reference "Final metadata commit"',
);
assert.ok(
checklistLine.includes('skipped_commit_docs_false'),
'checklist line must carve out the intentional-skip case by referencing '
+ '"skipped_commit_docs_false" — prevents executor from treating an '
+ 'unchecked mandatory box as a raw-git TODO (regression guard for #3679)',
);
});
describe('C — structural ban on raw force-add in GSD-managed bodies', () => {
function scanForForceAdd(rootDir) {
const offenders = [];
function walk(dir) {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) { walk(full); continue; }
if (!entry.isFile() || !entry.name.endsWith('.md')) continue;
const body = fs.readFileSync(full, 'utf-8');
const lines = body.split(/\r?\n/);
const danger = lines.filter((line) => {
if (!/git\s+add\s+(-f|--force)\b/.test(line)) return false;
// Allow prohibition / warning sentences and code-fence prose that
// frames `git add -f` AS the bug (so an audit comment doesn't
// create a false positive).
if (/(do not|don'?t|must not|never|forbidden|prohibited)/i.test(line)) return false;
if (/(bug|wrong|incorrect|antipattern|anti-pattern|forces?\s+gitignored|leak)/i.test(line)) return false;
return true;
});
if (danger.length > 0) {
offenders.push({
file: full.replace(REPO_ROOT + '/', ''),
lines: danger.map(l => l.trim().slice(0, 120)),
});
}
}
}
walk(rootDir);
return offenders;
}
test('C1: no agent body contains `git add -f` / `git add --force`', () => {
const offenders = scanForForceAdd(path.join(REPO_ROOT, 'agents'));
assert.deepStrictEqual(
offenders,
[],
'no agent body may use `git add -f` / `git add --force` outside a '
+ 'prohibition sentence — agents must never force-stage gitignored '
+ 'content (regression guard for #3678).',
);
});
test('C2: no workflow body contains `git add -f` / `git add --force`', () => {
const offenders = scanForForceAdd(path.join(REPO_ROOT, 'gsd-core', 'workflows'));
assert.deepStrictEqual(
offenders,
[],
'no workflow body may use `git add -f` / `git add --force` outside a '
+ 'prohibition sentence (regression guard for #3678).',
);
});
});
});

View File

@@ -1,236 +0,0 @@
// allow-test-rule: source-text-is-the-product
// Command `.md` files — their staged text IS the runtime contract loaded by
// Claude Code. Asserting that staged bodies lack `/gsd:<cmd>` colon refs is
// a behavioral test of the install transform, not source-grep theater.
/**
* Regression for #3683 — installed command bodies leak `/gsd:<cmd>` colon refs
* for Claude Code local installs.
*
* Root cause: `bin/install.js` command install path (`copyWithPathReplacement`,
* around line 8296 in the `else` branch) copies each command `.md` body without
* applying the hyphen-namespace normalizer that the agent install loop gained in
* PR #3677. Static prose in `commands/gsd/*.md` (e.g. plan-phase.md referencing
* `/gsd:execute-phase`) therefore reaches the model verbatim, causing the model
* to echo the retired colon form at workflow boundaries.
*
* Fix surface:
* Call `normalizeAgentBodyForRuntime` (or an equivalent helper) in the command
* staging path after all other rewrites but before writeFileSync, mirroring
* the agent install loop fix from #3677.
*
* This test guards the behavioral integration: run a real local claude install
* into a temp dir, then assert that no staged command body contains a
* `/gsd:<known-cmd>` colon ref.
*/
'use strict';
process.env.GSD_TEST_MODE = '1';
const { describe, test, before, after } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { execFileSync } = require('node:child_process');
const { cleanup } = require('./helpers.cjs');
const REPO_ROOT = path.resolve(__dirname, '..');
const INSTALL_PATH = path.join(REPO_ROOT, 'bin', 'install.js');
const install = require(INSTALL_PATH);
const { readCmdNames } = require(path.join(REPO_ROOT, 'scripts', 'fix-slash-commands.cjs'));
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/**
* Run `node install.js --claude --local --no-sdk` in tmpDir.
* GSD_TEST_MODE must be cleared so the install() main block executes.
*/
function runClaudeLocalInstall(cwd) {
const env = { ...process.env };
delete env.GSD_TEST_MODE;
execFileSync(process.execPath, [INSTALL_PATH, '--claude', '--local', '--no-sdk'], {
cwd,
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe'],
env,
});
}
/**
* Build the roster regex that matches `/gsd:<known-cmd>` or `gsd:<known-cmd>`
* (with appropriate word boundaries). Mirrors the pattern used in bug-3677.
*/
function buildRosterRegex(cmdNames) {
const sorted = [...cmdNames].sort((a, b) => b.length - a.length);
return new RegExp(
`(?<![a-zA-Z0-9_-])gsd:(${sorted.join('|')})(?=[^a-zA-Z0-9_-]|$)`,
);
}
// ---------------------------------------------------------------------------
// Suite A — export surface: normalizeAgentBodyForRuntime must be exported
// (same seam used for command bodies)
// ---------------------------------------------------------------------------
describe('bug #3683 — command body colon-namespace leak (Claude local install)', () => {
describe('A — install.js exports the normalizer seam', () => {
test('A1: normalizeAgentBodyForRuntime is exported (reused for command bodies)', () => {
assert.strictEqual(
typeof install.normalizeAgentBodyForRuntime,
'function',
'bin/install.js must export normalizeAgentBodyForRuntime — the seam used for both agent and command body normalization',
);
});
test('A2: shouldNormalizeHyphenNamespaceInAgentBody is exported and true for claude', () => {
assert.strictEqual(
typeof install.shouldNormalizeHyphenNamespaceInAgentBody,
'function',
);
assert.strictEqual(
install.shouldNormalizeHyphenNamespaceInAgentBody('claude'),
true,
'claude must normalize hyphen namespace — it is in the allow-list from #2808',
);
});
});
// ---------------------------------------------------------------------------
// B — pure-function coverage: normalizer rewrites command body colon refs
// ---------------------------------------------------------------------------
describe('B — normalizeAgentBodyForRuntime rewrites colon refs in command-body prose', () => {
const { normalizeAgentBodyForRuntime } = install;
const cmdNames = readCmdNames();
test('B0: command roster is populated and includes symptom commands', () => {
assert.ok(cmdNames.length > 0, 'readCmdNames() must return a non-empty list');
assert.ok(cmdNames.includes('execute-phase'), 'roster must include execute-phase');
assert.ok(cmdNames.includes('plan-phase'), 'roster must include plan-phase');
});
test('B1: claude — rewrites /gsd:<cmd> colon refs in command-body prose to hyphen form', () => {
const input = [
'## After planning',
'',
'Run `/gsd:execute-phase 1 --tdd` to begin execution.',
'Then use `/gsd:verify-work 1` when done.',
].join('\n');
const out = normalizeAgentBodyForRuntime(input, 'claude', cmdNames);
assert.ok(out.includes('/gsd-execute-phase'), 'execute-phase must be rewritten to hyphen form');
assert.ok(out.includes('/gsd-verify-work'), 'verify-work must be rewritten to hyphen form');
assert.ok(!out.includes('/gsd:execute-phase'), 'colon form for execute-phase must be absent');
assert.ok(!out.includes('/gsd:verify-work'), 'colon form for verify-work must be absent');
});
test('B2: gemini — colon refs preserved (Gemini intentionally uses colon namespace)', () => {
const input = 'Run `/gsd:execute-phase 1` to begin.';
const out = normalizeAgentBodyForRuntime(input, 'gemini', cmdNames);
assert.ok(out.includes('/gsd:execute-phase'), 'Gemini must keep colon form');
assert.ok(!out.includes('/gsd-execute-phase'), 'Gemini must not have hyphen form injected');
});
});
// ---------------------------------------------------------------------------
// E — Integration: real local claude install produces clean command bodies
// ---------------------------------------------------------------------------
// E — integration: flat gsd-*.md layout + clean bodies (#1367 fix)
//
// Prior to #1367: commands wrote to commands/gsd/<cmd>.md (bare names in a
// subdir), causing Claude Code to namespace them as /gsd:<cmd> (colon form).
// After #1367: commands write flat gsd-<cmd>.md at commands/ level so Claude
// Code registers them as /gsd-<cmd> (hyphen form, matching all framework refs).
// ---------------------------------------------------------------------------
describe('E — integration: staged gsd-*.md flat commands contain no colon-namespace refs', () => {
let tmpDir;
const cmdNames = readCmdNames();
const rosterRegex = buildRosterRegex(cmdNames);
before(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3683-'));
runClaudeLocalInstall(tmpDir);
});
after(() => {
cleanup(tmpDir);
});
test('E0: staged commands/ directory has flat gsd-*.md files after install (#1367)', () => {
// After #1367 fix: commands land at .claude/commands/gsd-<cmd>.md (flat,
// hyphen-prefixed). The old .claude/commands/gsd/<cmd>.md subdirectory
// layout must NOT be created.
const commandsDir = path.join(tmpDir, '.claude', 'commands');
assert.ok(
fs.existsSync(commandsDir),
`commands/ must be created by local claude install at ${commandsDir}`,
);
const flatFiles = fs.readdirSync(commandsDir).filter(f => f.startsWith('gsd-') && f.endsWith('.md'));
assert.ok(
flatFiles.length > 0,
`commands/ must contain flat gsd-*.md files (e.g. gsd-help.md). ` +
`Found none — install may still be using the old commands/gsd/<cmd>.md subdirectory layout.`,
);
// The old subdirectory must NOT exist (it caused /gsd:<cmd> colon namespace)
const oldSubdir = path.join(commandsDir, 'gsd');
assert.ok(
!fs.existsSync(oldSubdir),
`commands/gsd/ subdir must NOT exist after install (it causes /gsd:<cmd> colon namespace in Claude Code). ` +
`#1367 fix: use flat gsd-<cmd>.md at commands/ level instead.`,
);
});
test('E1: no staged command body contains /gsd:<known-cmd> colon refs', () => {
const commandsDir = path.join(tmpDir, '.claude', 'commands');
assert.ok(fs.existsSync(commandsDir), 'commands/ must exist for this check to be meaningful');
const offenders = [];
for (const entry of fs.readdirSync(commandsDir, { withFileTypes: true })) {
if (!entry.isFile() || !entry.name.endsWith('.md')) continue;
if (!entry.name.startsWith('gsd-')) continue;
const fullPath = path.join(commandsDir, entry.name);
const content = fs.readFileSync(fullPath, 'utf-8');
if (rosterRegex.test(content)) {
offenders.push(path.relative(tmpDir, fullPath));
}
}
assert.deepEqual(
offenders,
[],
`Staged command bodies still contain roster colon refs (e.g. /gsd:execute-phase). ` +
`Install must normalize these to /gsd-<cmd> for claude runtime. Offenders: ${offenders.join(', ')}`,
);
});
test('E2: idempotent — re-running install does not double-mangle already-hyphenated refs', () => {
// Run install a second time; if the normalizer double-applies it would
// produce garbled output like /gsd--execute-phase. Verify the commands
// still pass the same cleanliness check after a second install.
runClaudeLocalInstall(tmpDir);
const commandsDir = path.join(tmpDir, '.claude', 'commands');
const doubleRewriteRegex = /\/gsd--[a-z]/;
const garbled = [];
for (const entry of fs.readdirSync(commandsDir, { withFileTypes: true })) {
if (!entry.isFile() || !entry.name.endsWith('.md')) continue;
if (!entry.name.startsWith('gsd-')) continue;
const content = fs.readFileSync(path.join(commandsDir, entry.name), 'utf-8');
if (doubleRewriteRegex.test(content)) {
garbled.push(entry.name);
}
}
assert.deepEqual(
garbled,
[],
`Re-install produced double-hyphen artifacts (/gsd--cmd) — normalizer is not idempotent. Garbled files: ${garbled.join(', ')}`,
);
});
});
});

View File

@@ -1,455 +0,0 @@
// allow-test-rule: source-text-is-the-product
// Workflow and reference `.md` files are deployed verbatim as part of the
// gsd-core skill payload — their staged text IS the runtime contract
// loaded by Claude Code. Asserting that staged bodies lack `/gsd:<cmd>`
// colon refs is a behavioral test of the install transform, not
// source-grep theater.
/**
* Regression for #3683 — installed workflow/reference bodies leak `/gsd:<cmd>`
* colon refs for Claude Code local installs.
*
* Root cause: `copyWithPathReplacement` in `bin/install.js` guarded the
* `normalizeAgentBodyForRuntime` call behind `if (isCommand)`, so the
* `gsd-core/` directory (workflows, references — all `isCommand=false`)
* was copied without applying the hyphen-namespace normalizer. Static prose
* in `gsd-core/workflows/*.md` and `gsd-core/references/*.md`
* (e.g. discuss-phase.md referencing `/gsd:plan-phase`) therefore reached
* the model verbatim, causing it to echo the retired colon form.
*
* Fix surface:
* Remove the `if (isCommand)` guard so `normalizeAgentBodyForRuntime` is
* called unconditionally in `copyWithPathReplacement`. The function
* self-gates on `shouldNormalizeHyphenNamespaceInAgentBody(runtime)` and
* is a no-op for colon-canonical runtimes (Gemini, Codex, etc.).
*
* User repro path: `/gsd-discuss-phase` output ends with `/gsd:nextcommand`
* because discuss-phase.md (7 colon refs) is not normalized at install time.
*/
'use strict';
process.env.GSD_TEST_MODE = '1';
const { describe, test, before, after } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { execFileSync } = require('node:child_process');
const { cleanup } = require('./helpers.cjs');
const REPO_ROOT = path.resolve(__dirname, '..');
const INSTALL_PATH = path.join(REPO_ROOT, 'bin', 'install.js');
require(INSTALL_PATH);
const { readCmdNames } = require(path.join(REPO_ROOT, 'scripts', 'fix-slash-commands.cjs'));
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/**
* Run `node install.js --claude --local --no-sdk` in tmpDir.
* GSD_TEST_MODE must be cleared so the install() main block executes.
*/
function runClaudeLocalInstall(cwd) {
const env = { ...process.env };
delete env.GSD_TEST_MODE;
execFileSync(process.execPath, [INSTALL_PATH, '--claude', '--local', '--no-sdk'], {
cwd,
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe'],
env,
});
}
/**
* Run `node install.js --gemini --local --no-sdk` in tmpDir.
* GSD_TEST_MODE must be cleared so the install() main block executes.
*/
function runGeminiLocalInstall(cwd) {
const env = { ...process.env };
delete env.GSD_TEST_MODE;
execFileSync(process.execPath, [INSTALL_PATH, '--gemini', '--local', '--no-sdk'], {
cwd,
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe'],
env,
});
}
/**
* Build the roster regex that matches `gsd:<known-cmd>` references.
* Mirrors the pattern used by the Cycle 1 command test.
*/
function buildRosterRegex(cmdNames) {
const sorted = [...cmdNames].sort((a, b) => b.length - a.length);
return new RegExp(
`(?<![a-zA-Z0-9_-])gsd:(${sorted.join('|')})(?=[^a-zA-Z0-9_-]|$)`,
);
}
/**
* Walk a directory recursively and collect .md files whose body matches regex.
*/
function collectOffenders(dir, regex) {
const offenders = [];
const walk = (d) => {
for (const entry of fs.readdirSync(d, { withFileTypes: true })) {
const fullPath = path.join(d, entry.name);
if (entry.isDirectory()) {
walk(fullPath);
} else if (entry.name.endsWith('.md')) {
const content = fs.readFileSync(fullPath, 'utf-8');
if (regex.test(content)) {
offenders.push(fullPath);
}
}
}
};
walk(dir);
return offenders;
}
// ---------------------------------------------------------------------------
// Suite — integration: staged gsd-core/workflows/ and references/ must
// have no colon-namespace refs for claude, and must preserve them for gemini.
// ---------------------------------------------------------------------------
describe('bug #3683 — workflow/reference colon-namespace leak (Claude local install)', () => {
// Shared Claude local install used by W and R suites.
// Consolidating to a single install halves disk I/O for this file and
// reduces concurrent load on CI runners — preventing timing interference
// with concurrently-running tests (e.g. the TOCTOU barrier tests in
// locking-bugs-1909-1916-1925-1927.test.cjs).
let claudeTmpDir;
const cmdNames = readCmdNames();
const rosterRegex = buildRosterRegex(cmdNames);
// Shared claude local install — used by W (workflow/reference clean-slate) and
// R (routing-block positive assertion) sub-suites. G suite runs its own separate
// gemini install and does not depend on claudeTmpDir.
before(() => {
claudeTmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3683-claude-'));
runClaudeLocalInstall(claudeTmpDir);
});
after(() => {
cleanup(claudeTmpDir);
});
// -------------------------------------------------------------------------
// W — real local claude install: workflow + reference bodies are clean
// -------------------------------------------------------------------------
describe('W — integration: staged workflows and references contain no colon-namespace refs', () => {
test('W0: staged gsd-core/workflows/ directory exists after install', () => {
const workflowsDir = path.join(claudeTmpDir, '.claude', 'gsd-core', 'workflows');
assert.ok(
fs.existsSync(workflowsDir),
`gsd-core/workflows/ must be created by local claude install at ${workflowsDir}`,
);
});
test('W1: staged gsd-core/references/ directory exists after install', () => {
const refsDir = path.join(claudeTmpDir, '.claude', 'gsd-core', 'references');
assert.ok(
fs.existsSync(refsDir),
`gsd-core/references/ must be created by local claude install at ${refsDir}`,
);
});
test('W2: focused repro — staged discuss-phase.md has zero /gsd: colon refs', () => {
// User-reported repro: /gsd-discuss-phase output ends with /gsd:nextcommand
// because discuss-phase.md ships 7 colon refs that were not normalized.
const stagedFile = path.join(
claudeTmpDir, '.claude', 'gsd-core', 'workflows', 'discuss-phase.md',
);
assert.ok(
fs.existsSync(stagedFile),
`discuss-phase.md must exist in staged gsd-core/workflows/`,
);
const content = fs.readFileSync(stagedFile, 'utf-8');
const colonMatches = content.match(/gsd:[a-z][a-z0-9-]*/g) || [];
// Filter to known-command refs only
const knownColonRefs = colonMatches.filter(m => {
const cmd = m.slice(4); // strip 'gsd:'
return cmdNames.includes(cmd);
});
assert.deepEqual(
knownColonRefs,
[],
`discuss-phase.md still contains colon-namespace refs that install must normalize: ${knownColonRefs.join(', ')}`,
);
});
test('W3: no staged workflow body contains /gsd:<known-cmd> colon refs', () => {
const workflowsDir = path.join(claudeTmpDir, '.claude', 'gsd-core', 'workflows');
assert.ok(fs.existsSync(workflowsDir), 'workflows/ must exist for this check to be meaningful');
const offenders = collectOffenders(workflowsDir, rosterRegex);
const relOffenders = offenders.map(f => path.relative(claudeTmpDir, f));
assert.deepEqual(
relOffenders,
[],
`Staged workflow bodies still contain roster colon refs (e.g. /gsd:plan-phase). ` +
`Install must normalize these to /gsd-<cmd> for claude runtime. Offenders: ${relOffenders.join(', ')}`,
);
});
test('W4: no staged reference body contains /gsd:<known-cmd> colon refs', () => {
const refsDir = path.join(claudeTmpDir, '.claude', 'gsd-core', 'references');
assert.ok(fs.existsSync(refsDir), 'references/ must exist for this check to be meaningful');
const offenders = collectOffenders(refsDir, rosterRegex);
const relOffenders = offenders.map(f => path.relative(claudeTmpDir, f));
assert.deepEqual(
relOffenders,
[],
`Staged reference bodies still contain roster colon refs. ` +
`Install must normalize these to /gsd-<cmd> for claude runtime. Offenders: ${relOffenders.join(', ')}`,
);
});
});
// -------------------------------------------------------------------------
// R — #3646 routing-block positive assertion: ▶-prefixed lines use hyphen
//
// User repro: workflow output ends with "▶ /gsd:validate-phase {N}" (colon
// form) which does not resolve in Claude Code — the installed skill is
// /gsd-validate-phase (hyphen). Workflows emit routing blocks verbatim, so
// the colon form reaches the model and is echoed to the user unchanged.
//
// This suite checks the POSITIVE invariant: lines starting with ▶ that
// reference a GSD slash command must use /gsd-<cmd> (hyphen) in the staged
// output. This is a stricter assertion than W3 (which only checks absence
// of colon globally) because it confirms the routing-position strings were
// NOT omitted — they must be present AND use the correct form.
//
// Source files with known ▶-prefixed routing-block colon refs (#3646):
// - gsd-core/workflows/validate-phase.md:151 ▶ Next: /gsd:audit-milestone
// - gsd-core/workflows/validate-phase.md:158 ▶ Retry: /gsd:validate-phase
// - gsd-core/workflows/secure-phase.md:140 ▶ Fix mitigations: /gsd:secure-phase
// - gsd-core/workflows/secure-phase.md:158 ▶ /gsd:validate-phase
// - gsd-core/workflows/secure-phase.md:159 ▶ /gsd:verify-work
// -------------------------------------------------------------------------
describe('R — #3646 routing-block: ▶-prefixed lines use hyphen form in staged claude install', () => {
// Uses the shared claudeTmpDir from the parent describe block — no separate install needed.
/**
* Collect all lines starting with the ▶ routing marker from a file.
* Returns an array of { lineNo, text } objects.
*/
function collectRoutingLines(filePath) {
if (!fs.existsSync(filePath)) return [];
return fs.readFileSync(filePath, 'utf-8')
.split(/\r?\n/)
.map((text, i) => ({ lineNo: i + 1, text }))
.filter(({ text }) => text.startsWith('▶'));
}
test('R1: staged validate-phase.md routing block uses /gsd-<cmd> hyphen form', () => {
const stagedFile = path.join(
claudeTmpDir, '.claude', 'gsd-core', 'workflows', 'validate-phase.md',
);
assert.ok(
fs.existsSync(stagedFile),
`validate-phase.md must exist in staged gsd-core/workflows/`,
);
const routingLines = collectRoutingLines(stagedFile);
// Exactly two known routing lines (▶ Next / ▶ Retry).
const gsdRoutingLines = routingLines.filter(({ text }) => /\/gsd[-:]/.test(text));
assert.strictEqual(
gsdRoutingLines.length,
2,
`validate-phase.md must have exactly 2 ▶-routing lines referencing a /gsd- command — ` +
`found ${gsdRoutingLines.length}: ${JSON.stringify(gsdRoutingLines)}`,
);
// Positive: every routing line that references gsd must use the hyphen form.
for (const { lineNo, text } of gsdRoutingLines) {
assert.ok(
/\/gsd-[a-z]/.test(text),
`validate-phase.md line ${lineNo}: ▶-routing line must use /gsd-<cmd> hyphen form, got: ${JSON.stringify(text)}`,
);
// Negative: must not contain the colon form.
assert.ok(
!/\/gsd:[a-z]/.test(text),
`validate-phase.md line ${lineNo}: ▶-routing line must not contain /gsd:<cmd> colon form, got: ${JSON.stringify(text)}`,
);
// Token-level: extract real command tokens (/gsd-<cmd> starting with a
// lowercase letter) and assert none contain an embedded colon.
// Skips documentation placeholder tokens like /gsd-[command].
const rawTokens = text.match(/\/gsd[^\s]*/g) || [];
for (const token of rawTokens) {
assert.ok(
!token.includes(':'),
`validate-phase.md line ${lineNo}: /gsd token "${token}" must not contain a colon — embedded colon detected (e.g. /gsd-validate:phase), got: ${JSON.stringify(text)}`,
);
}
}
});
test('R2: staged secure-phase.md routing block uses /gsd-<cmd> hyphen form', () => {
const stagedFile = path.join(
claudeTmpDir, '.claude', 'gsd-core', 'workflows', 'secure-phase.md',
);
assert.ok(
fs.existsSync(stagedFile),
`secure-phase.md must exist in staged gsd-core/workflows/`,
);
const routingLines = collectRoutingLines(stagedFile);
// Exactly three known routing lines (fix-mitigations, validate-phase, verify-work).
const gsdRoutingLines = routingLines.filter(({ text }) => /\/gsd[-:]/.test(text));
assert.strictEqual(
gsdRoutingLines.length,
3,
`secure-phase.md must have exactly 3 ▶-routing lines referencing a /gsd- command — ` +
`found ${gsdRoutingLines.length}: ${JSON.stringify(gsdRoutingLines)}`,
);
for (const { lineNo, text } of gsdRoutingLines) {
assert.ok(
/\/gsd-[a-z]/.test(text),
`secure-phase.md line ${lineNo}: ▶-routing line must use /gsd-<cmd> hyphen form, got: ${JSON.stringify(text)}`,
);
assert.ok(
!/\/gsd:[a-z]/.test(text),
`secure-phase.md line ${lineNo}: ▶-routing line must not contain /gsd:<cmd> colon form, got: ${JSON.stringify(text)}`,
);
// Token-level: extract all /gsd... tokens and assert none contain an
// embedded colon (catches /gsd-validate:phase etc).
// Skips documentation placeholder tokens like /gsd-[command].
const rawTokens = text.match(/\/gsd[^\s]*/g) || [];
for (const token of rawTokens) {
assert.ok(
!token.includes(':'),
`secure-phase.md line ${lineNo}: /gsd token "${token}" must not contain a colon — embedded colon detected (e.g. /gsd-validate:phase), got: ${JSON.stringify(text)}`,
);
}
}
});
test('R3: all staged workflow routing blocks use hyphen form (cross-file sweep)', () => {
// R3 unique value vs W3:
// W3 catches overt /gsd:<cmd> at file level (any line).
// R3 adds:
// (a) ▶-line-scoped assertion (catches drift specifically in routing-block context)
// (b) embedded-colon token check (e.g. /gsd-validate:phase partial-conversion artifacts)
// not detectable by W3's file-level regex
// Sweeps both workflows/ and references/ so routing blocks in reference files
// are covered alongside workflow files.
const gsdDir = path.join(claudeTmpDir, '.claude', 'gsd-core');
const workflowsDir = path.join(gsdDir, 'workflows');
assert.ok(fs.existsSync(workflowsDir), 'workflows/ must exist for R3 to be meaningful');
const colonOffenders = [];
const embeddedColonOffenders = [];
const walk = (d) => {
for (const entry of fs.readdirSync(d, { withFileTypes: true })) {
const fullPath = path.join(d, entry.name);
if (entry.isDirectory()) { walk(fullPath); continue; }
if (!entry.name.endsWith('.md')) continue;
const lines = fs.readFileSync(fullPath, 'utf-8').split(/\r?\n/);
const rel = path.relative(claudeTmpDir, fullPath);
lines.forEach((text, i) => {
if (!text.startsWith('▶')) return;
// Negative: must not contain overt /gsd:<cmd> colon form.
if (/\/gsd:[a-z]/.test(text)) {
colonOffenders.push(`${rel}:${i + 1}: ${text.trim()}`);
}
// Token-level: check each /gsd... token for an embedded colon.
// Catches cases like /gsd-validate:phase where normalizer half-converted.
// Documentation placeholder tokens like /gsd-[command] are skipped
// because their tokens will not contain a colon.
const tokens = text.match(/\/gsd[^\s]*/g) || [];
for (const token of tokens) {
if (token.includes(':')) {
embeddedColonOffenders.push(`${rel}:${i + 1}: token "${token}" in "${text.trim()}"`);
}
}
});
}
};
// Walk both workflows/ and references/ — routing blocks can appear in either.
walk(workflowsDir);
const refsDir = path.join(gsdDir, 'references');
if (fs.existsSync(refsDir)) walk(refsDir);
assert.deepEqual(
colonOffenders,
[],
`Staged workflows contain ▶-routing lines with /gsd:<cmd> colon form — ` +
`these must resolve to /gsd-<cmd> for Claude Code skills-based install. ` +
`Offenders:\n ${colonOffenders.join('\n ')}`,
);
assert.deepEqual(
embeddedColonOffenders,
[],
`Staged workflows contain ▶-routing lines with /gsd tokens that have an embedded ` +
`colon (e.g. /gsd-validate:phase) — normalizer may have partially converted a token. ` +
`Offenders:\n ${embeddedColonOffenders.join('\n ')}`,
);
});
});
// -------------------------------------------------------------------------
// G — negative: gemini install must PRESERVE colon form (no-op normalizer)
// -------------------------------------------------------------------------
describe('G — negative: staged gemini workflows preserve colon-namespace refs', () => {
let tmpDir;
const cmdNames = readCmdNames();
before(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3683-gem-'));
runGeminiLocalInstall(tmpDir);
});
after(() => {
cleanup(tmpDir);
});
test('G0: staged gemini gsd-core/workflows/ directory exists after install', () => {
const workflowsDir = path.join(tmpDir, '.gemini', 'gsd-core', 'workflows');
assert.ok(
fs.existsSync(workflowsDir),
`gemini gsd-core/workflows/ must be created at ${workflowsDir}`,
);
});
test('G1: gemini discuss-phase.md preserves colon form (normalizer is a no-op for gemini)', () => {
// Gemini registers /gsd:<cmd> as its canonical form — normalization
// must NOT fire for this runtime. Verify colon refs survive unchanged.
const stagedFile = path.join(
tmpDir, '.gemini', 'gsd-core', 'workflows', 'discuss-phase.md',
);
assert.ok(
fs.existsSync(stagedFile),
`gemini discuss-phase.md must exist in staged gsd-core/workflows/`,
);
const content = fs.readFileSync(stagedFile, 'utf-8');
// The source has 7 colon refs; at least one must be present in gemini output.
const colonMatches = content.match(/gsd:[a-z][a-z0-9-]*/g) || [];
const knownColonRefs = colonMatches.filter(m => cmdNames.includes(m.slice(4)));
assert.ok(
knownColonRefs.length > 0,
`gemini staged discuss-phase.md must preserve /gsd:<cmd> colon refs — ` +
`they are Gemini's canonical command namespace and must not be rewritten to hyphen form`,
);
});
test('G2: gemini workflows are not over-normalized (no /gsd-- double-hyphen artifacts)', () => {
const workflowsDir = path.join(tmpDir, '.gemini', 'gsd-core', 'workflows');
if (!fs.existsSync(workflowsDir)) return; // guard — G0 already asserts existence
const doubleHyphenRegex = /\/gsd--[a-z]/;
const garbled = collectOffenders(workflowsDir, doubleHyphenRegex);
const relGarbled = garbled.map(f => path.relative(tmpDir, f));
assert.deepEqual(
relGarbled,
[],
`Gemini staged workflows contain /gsd-- double-hyphen artifacts — normalizer ran when it should not have. Garbled: ${relGarbled.join(', ')}`,
);
});
});
});

View File

@@ -1,142 +0,0 @@
// allow-test-rule: source-text-is-the-product
// Workflow `.md` files are the runtime contract executed by Claude Code as
// embedded bash. Asserting on the staged text of resume-project.md and on the
// behavior of the embedded snippet under real shells is a behavioral test of
// the workflow itself, not source-grep theater.
/**
* Regression for #3689 — /gsd-resume-work silently drops
* `.planning/.continue-here*.md` checkpoints under zsh's default NOMATCH.
*
* Root cause: the `check_incomplete_work` step in
* `gsd-core/workflows/resume-project.md` used a chained `ls` with six
* bare-glob arguments. Under zsh's default `NOMATCH` setopt the first
* non-matching glob aborts the entire command during word-expansion — every
* pattern after that point is never evaluated, including the one that holds
* valid pause checkpoints (`.planning/.continue-here*.md`). `2>/dev/null ||
* true` only suppresses ls's own stderr / exit code; it has no effect on the
* shell's pre-exec abort.
*
* Fix: replace the chained `ls` with two `find` calls. `find` does not use
* shell glob expansion, and `find <missing-dir> -maxdepth N -name PATTERN
* -print 2>/dev/null` tolerates absent directories on both bash and zsh.
*
* This test covers:
* 1. zsh under `-o nomatch`: checkpoint at `.planning/.continue-here-*.md`
* is listed even when `.planning/spikes`, `.planning/sketches`,
* `.planning/deliberations` are absent (the common new-project layout).
* 2. bash default: same behavior.
* 3. zsh `-o nomatch` with no `.continue-here` files anywhere: exits 0,
* no output, no error.
* 4. Text invariant: resume-project.md no longer carries the brittle
* chained-ls pattern.
*/
'use strict';
const { describe, test, before, after } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { spawnSync } = require('node:child_process');
const { createTempDir, cleanup } = require('./helpers.cjs');
const REPO_ROOT = path.resolve(__dirname, '..');
const WORKFLOW_PATH = path.join(REPO_ROOT, 'gsd-core', 'workflows', 'resume-project.md');
// The exact snippet the workflow now embeds. Keep in sync with
// resume-project.md `check_incomplete_work` step.
const FIND_SNIPPET = [
"find .planning -maxdepth 3 -name '.continue-here*.md' -print 2>/dev/null || true",
"find . -maxdepth 1 -name '.continue-here*.md' -print 2>/dev/null || true",
].join('\n');
function hasShell(name) {
const result = spawnSync('which', [name], { encoding: 'utf8' });
return result.status === 0 && result.stdout.trim().length > 0;
}
describe('bug #3689 — resume-project.md continue-here scan under zsh NOMATCH', () => {
let tmpDir;
before(() => {
tmpDir = createTempDir('gsd-bug-3689-');
// Reproduce the common new-project layout: a `.planning/` with a
// suffixed continue-here file and *no* spike / sketch / deliberation
// subdirectories.
fs.mkdirSync(path.join(tmpDir, '.planning'), { recursive: true });
fs.writeFileSync(
path.join(tmpDir, '.planning', '.continue-here-AT-1234.md'),
'---\ncontext: default\n---\nhandoff body\n',
'utf8',
);
});
after(() => {
cleanup(tmpDir);
});
test('zsh -o nomatch lists the .planning/.continue-here-* checkpoint', { skip: !hasShell('zsh') }, () => {
const result = spawnSync('zsh', ['-o', 'nomatch', '-c', FIND_SNIPPET], {
cwd: tmpDir,
encoding: 'utf8',
});
assert.equal(result.status, 0, `zsh exited ${result.status}; stderr=${result.stderr}`);
assert.match(
result.stdout,
/\.planning\/\.continue-here-AT-1234\.md/,
`expected checkpoint in stdout, got: ${JSON.stringify(result.stdout)}`,
);
});
test('bash default lists the .planning/.continue-here-* checkpoint', { skip: !hasShell('bash') }, () => {
const result = spawnSync('bash', ['-c', FIND_SNIPPET], {
cwd: tmpDir,
encoding: 'utf8',
});
assert.equal(result.status, 0, `bash exited ${result.status}; stderr=${result.stderr}`);
assert.match(
result.stdout,
/\.planning\/\.continue-here-AT-1234\.md/,
`expected checkpoint in stdout, got: ${JSON.stringify(result.stdout)}`,
);
});
});
describe('bug #3689 — empty workspace exits cleanly', () => {
let tmpDir;
before(() => {
tmpDir = createTempDir('gsd-bug-3689-');
// No .planning/ at all, no .continue-here files. Pure greenfield.
});
after(() => {
cleanup(tmpDir);
});
test('zsh -o nomatch with no checkpoints exits 0, empty output', { skip: !hasShell('zsh') }, () => {
const result = spawnSync('zsh', ['-o', 'nomatch', '-c', FIND_SNIPPET], {
cwd: tmpDir,
encoding: 'utf8',
});
assert.equal(result.status, 0, `zsh exited ${result.status}; stderr=${result.stderr}`);
assert.equal(result.stdout.trim(), '', `expected no stdout, got: ${JSON.stringify(result.stdout)}`);
});
});
describe('bug #3689 — workflow text invariant', () => {
test('resume-project.md no longer chains bare globs through ls', () => {
const body = fs.readFileSync(WORKFLOW_PATH, 'utf8');
assert.doesNotMatch(
body,
/ls\s+\.planning\/spikes\/\*\/\.continue-here/,
'resume-project.md still contains the chained `ls .planning/spikes/*/.continue-here*.md` pattern that aborts under zsh NOMATCH; the find-based scan should replace it.',
);
assert.match(
body,
/find \.planning -maxdepth 3 -name '\.continue-here\*\.md'/,
'resume-project.md must use the find-based scan introduced by the #3689 fix.',
);
});
});

View File

@@ -1,212 +0,0 @@
'use strict';
/**
* Regression test for bug #444: gsd_run resolver must probe
* <repo-root>/.claude/gsd-core/bin/gsd-tools.cjs (the project-local
* `--claude --local` install location) BEFORE checking $HOME/.claude and PATH.
*
* Asserts:
* (A) The canonical snippet file contains the repo-local .claude/ check.
* (B) Behavioral: when RUNTIME_DIR/gsd-core/bin/ misses, but a stub
* exists ONLY at <repo-root>/.claude/gsd-core/bin/gsd-tools.cjs,
* gsd_run resolves to that stub (no PATH stub, no HOME stub).
* (C) Precedence: repo-local .claude/ wins over $HOME/.claude/ when both exist.
*/
// allow-test-rule: structural/behavioral regression for the repo-local .claude/ install
// arm in the gsd_run launcher snippet -- asserts literal substring presence and exercises
// the bash resolution path via execFileSync; there is no typed IR for "snippet contains arm X".
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const os = require('node:os');
const { execFileSync } = require('node:child_process');
const { cleanup } = require('./helpers.cjs');
const WORKFLOWS_DIR = path.join(__dirname, '..', 'gsd-core', 'workflows');
const SNIPPET_FILE = path.join(WORKFLOWS_DIR, '_runtime-launcher.snippet.sh');
// The probe string that must appear in the snippet for the new repo-local check.
// The snippet uses _GSD_RUNTIME_ROOT as the intermediate variable.
const LOCAL_CLAUDE_PROBE = '_GSD_RUNTIME_ROOT}/.claude/gsd-core/bin/';
/**
* Build a PATH that strips gsd-tools but keeps node and system binaries.
* Accepts additional bin dirs to prepend.
*
* We cannot simply remove the whole directory that contains gsd-tools because
* that directory may also contain node (e.g. /opt/homebrew/bin on macOS).
* Instead, we keep the system PATH as-is and rely on the test's RUNTIME_DIR
* having no gsd-core/bin/ sub-path, so the resolver's first two checks
* (RUNTIME_DIR/gsd-core/bin/ and RUNTIME_DIR/.claude/gsd-core/bin/)
* are the only ones exercised before we hit our stub.
*
* The extra extraBefore dirs (e.g. noToolsBin) sit first but have no gsd-tools
* binary, so command -v gsd-tools still falls back to PATH lookup. However,
* the snippet's elif arm that uses `command -v gsd-tools` will find the real
* installed one unless we mask it. To mask it without losing node, we create
* a noToolsBin dir that shadows gsd-tools with a sentinel that must NOT be
* called — and we only call makeIsolatedPath for tests where the .claude stub
* must win before PATH is consulted (i.e. the elif PATH arm is never reached).
*
* For B: stub is at RUNTIME_DIR/.claude/... so resolver picks it at elif-1 (before command -v).
* For C: same — local .claude/ is checked before command -v and before $HOME/.claude.
*/
function makeIsolatedPath(extraBefore = []) {
// Keep full system PATH so node remains accessible.
// Tests B and C exercise only the RUNTIME_DIR/.claude arm which fires
// before command -v gsd-tools — so the real gsd-tools on PATH is never reached.
const systemPaths = (process.env.PATH || '/usr/bin:/bin').split(path.delimiter);
return [...extraBefore, ...systemPaths].join(path.delimiter);
}
describe('bug-444: resolver finds repo-local .claude install', () => {
// --- (A) Snippet contains the repo-local .claude arm ----------------------
test('(A) snippet file contains the repo-local .claude/ check arm before $HOME/.claude/', () => {
const content = fs.readFileSync(SNIPPET_FILE, 'utf8');
// Must contain the repo-local .claude/ check (via _GSD_RUNTIME_ROOT variable)
const localClaudeIdx = content.indexOf(LOCAL_CLAUDE_PROBE);
assert.ok(
localClaudeIdx !== -1,
`_runtime-launcher.snippet.sh must contain the repo-local .claude check ` +
`('${LOCAL_CLAUDE_PROBE}'). ` +
`Found snippet content:\n${content.trim()}`,
);
// Must still contain the $HOME/.claude fallback arm
const homeClaudeIdx = content.indexOf('$HOME/.claude/gsd-core/bin/');
assert.ok(
homeClaudeIdx !== -1,
`Snippet must still contain the $HOME/.claude fallback arm.`,
);
// Repo-local check must appear BEFORE $HOME/.claude check (local overrides global)
assert.ok(
localClaudeIdx < homeClaudeIdx,
`Repo-local .claude/ check (idx ${localClaudeIdx}) must appear BEFORE ` +
`$HOME/.claude/ check (idx ${homeClaudeIdx}) in the snippet (local overrides global).`,
);
});
// --- (B) Behavioral: repo-local .claude stub resolved when only location ---
test('(B) gsd_run resolves repo-local .claude/gsd-core/bin/ stub when no other locations present', () => {
// Create a fake repo root with a stub ONLY at .claude/gsd-core/bin/gsd-tools.cjs
// NO stub at gsd-core/bin/, NOT on PATH, NOT in $HOME/.claude
const fakeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-444-root-'));
const fakeHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-444-home-'));
const noToolsBin = path.join(fakeRoot, 'nobin');
fs.mkdirSync(noToolsBin, { recursive: true });
try {
// Create the stub at the repo-local .claude path ONLY
const localClaudeBinDir = path.join(fakeRoot, '.claude', 'gsd-core', 'bin');
fs.mkdirSync(localClaudeBinDir, { recursive: true });
const stubPath = path.join(localClaudeBinDir, 'gsd-tools.cjs');
fs.writeFileSync(
stubPath,
'#!/usr/bin/env node\nconsole.log("LOCAL_CLAUDE_STUB:" + process.argv.slice(2).join(","));\n',
);
fs.chmodSync(stubPath, 0o755);
const snippet = fs.readFileSync(SNIPPET_FILE, 'utf8');
// Set RUNTIME_DIR to fakeRoot so the resolver uses it as the repo root.
const scriptContent =
`unset GSD_TOOLS\n` +
`export RUNTIME_DIR=${JSON.stringify(fakeRoot)}\n` +
`export HOME=${JSON.stringify(fakeHome)}\n` +
snippet +
`\nprintf "GSD_TOOLS=%s\\n" "$GSD_TOOLS"\n` +
`gsd_run ping test\n`;
const scriptPath = path.join(fakeRoot, 'test-local-claude.sh');
fs.writeFileSync(scriptPath, scriptContent);
// Keep node in PATH (needed to run the .cjs stub); remove gsd-tools
const isolatedPath = makeIsolatedPath([noToolsBin]);
const stdout = execFileSync('bash', [scriptPath], {
encoding: 'utf8',
env: { ...process.env, PATH: isolatedPath, HOME: fakeHome },
});
// Must have resolved to the local .claude stub
const normStdout = stdout.replace(/\\/g, '/');
assert.ok(
normStdout.includes('.claude/gsd-core/bin/gsd-tools.cjs'),
`Expected GSD_TOOLS to resolve to .claude/gsd-core/bin/gsd-tools.cjs, got:\n${stdout.trim()}`,
);
// The stub must have been invoked with the correct arguments
assert.ok(
stdout.includes('LOCAL_CLAUDE_STUB:ping,test'),
`Expected stub output "LOCAL_CLAUDE_STUB:ping,test" but got:\n${stdout.trim()}`,
);
} finally {
cleanup(fakeRoot);
cleanup(fakeHome);
}
});
// --- (C) Precedence: repo-local .claude/ wins over $HOME/.claude/ ----------
test('(C) repo-local .claude/ install wins over $HOME/.claude/ when both exist', () => {
const fakeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-444-prec-root-'));
const fakeHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-444-prec-home-'));
const noToolsBin = path.join(fakeRoot, 'nobin');
fs.mkdirSync(noToolsBin, { recursive: true });
try {
// Stub at repo-local .claude/ path (should be picked)
const localClaudeBinDir = path.join(fakeRoot, '.claude', 'gsd-core', 'bin');
fs.mkdirSync(localClaudeBinDir, { recursive: true });
const localStubPath = path.join(localClaudeBinDir, 'gsd-tools.cjs');
fs.writeFileSync(
localStubPath,
'#!/usr/bin/env node\nconsole.log("LOCAL_WINS:" + process.argv.slice(2).join(","));\n',
);
fs.chmodSync(localStubPath, 0o755);
// Stub at $HOME/.claude/ path (must NOT be picked)
const homeClaudeBinDir = path.join(fakeHome, '.claude', 'gsd-core', 'bin');
fs.mkdirSync(homeClaudeBinDir, { recursive: true });
const homeStubPath = path.join(homeClaudeBinDir, 'gsd-tools.cjs');
fs.writeFileSync(
homeStubPath,
'#!/usr/bin/env node\nconsole.log("HOME_WINS:" + process.argv.slice(2).join(","));\n',
);
fs.chmodSync(homeStubPath, 0o755);
const snippet = fs.readFileSync(SNIPPET_FILE, 'utf8');
const scriptContent =
`unset GSD_TOOLS\n` +
`export RUNTIME_DIR=${JSON.stringify(fakeRoot)}\n` +
`export HOME=${JSON.stringify(fakeHome)}\n` +
snippet +
`\nprintf "GSD_TOOLS=%s\\n" "$GSD_TOOLS"\n` +
`gsd_run check\n`;
const scriptPath = path.join(fakeRoot, 'test-precedence.sh');
fs.writeFileSync(scriptPath, scriptContent);
const isolatedPath = makeIsolatedPath([noToolsBin]);
const stdout = execFileSync('bash', [scriptPath], {
encoding: 'utf8',
env: { ...process.env, PATH: isolatedPath, HOME: fakeHome },
});
assert.ok(
stdout.includes('LOCAL_WINS:check'),
`Expected repo-local .claude stub to be invoked ("LOCAL_WINS:check") ` +
`but got:\n${stdout.trim()}`,
);
assert.ok(
!stdout.includes('HOME_WINS'),
`Expected $HOME/.claude stub NOT to be invoked, but got:\n${stdout.trim()}`,
);
} finally {
cleanup(fakeRoot);
cleanup(fakeHome);
}
});
});

View File

@@ -1,141 +0,0 @@
// allow-test-rule: source-text-is-the-product
// codebase-drift-gate.md is the shipped orchestration step contract. Bug #619:
// the initial drift check ran the bare PATH binary `gsd-tools verify codebase-drift`.
// On a shim-only install (gsd-tools.cjs present, `gsd-tools` not on PATH) that exits
// 127, `2>/dev/null` hides it, and the `|| echo` fallback marks the gate skipped —
// so post-execution drift detection silently never runs. The fix resolves gsd-tools
// through the runtime shim launcher (gsd_run), defining the canonical preamble once in
// this always-run block so the file stays compliant with the single-preamble parity
// invariant (the conditional auto-remap block reuses the launcher via shared shell scope).
//
// This file locks the source contract AND behaviorally proves the shim resolves: it runs
// the exact shipped drift-check block against a shim-only topology and asserts the shim
// actually executes, where the old bare-binary form would have skipped.
'use strict';
const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { execFileSync } = require('node:child_process');
const { cleanup } = require('./helpers.cjs');
const GATE_MD = path.join(
__dirname, '..', 'gsd-core', 'workflows', 'execute-phase', 'steps', 'codebase-drift-gate.md',
);
const SNIPPET_FILE = path.join(__dirname, '..', 'gsd-core', 'workflows', '_runtime-launcher.snippet.sh');
function readGate() {
return fs.readFileSync(GATE_MD, 'utf8');
}
// Extract the Nth (0-based) ```bash fenced block body from the file.
function bashBlock(content, n) {
const blocks = [];
const re = /```bash\r?\n([\s\S]*?)```/g;
let m;
while ((m = re.exec(content)) !== null) blocks.push(m[1]);
assert.ok(blocks.length > n, `expected at least ${n + 1} bash blocks, found ${blocks.length}`);
return blocks[n];
}
describe('bug #619 — codebase-drift-gate resolves gsd-tools via the runtime shim, not the bare PATH binary', () => {
test('codebase-drift-gate.md is readable', () => {
assert.ok(readGate().length > 0, 'codebase-drift-gate.md must not be empty');
});
// ── Source contract (the .md is the product) ──────────────────────────────
test('the drift check resolves gsd-tools via the shim launcher (gsd_run), not the bare binary (#619)', () => {
const content = readGate();
assert.match(
content,
/DRIFT=\$\(gsd_run verify codebase-drift 2>\/dev\/null \|\| echo '\{"skipped":true,"reason":"sdk-failed"\}'\)/,
'drift check must call `gsd_run verify codebase-drift` with the non-blocking skip fallback',
);
assert.doesNotMatch(
content,
/\bgsd-tools verify codebase-drift\b/,
'the bare `gsd-tools verify codebase-drift` PATH-binary call (the #619 bug) must be gone',
);
});
test('non-blocking contract preserved: the skip JSON fallback is intact (#619)', () => {
const content = readGate();
assert.match(
content,
/\|\| echo '\{"skipped":true,"reason":"sdk-failed"\}'/,
'an internal drift-command failure must still fall through to the skip JSON',
);
});
test('exactly one canonical launcher preamble, in the drift-check block, before any launcher call (#619)', () => {
const content = readGate();
const snippet = fs.readFileSync(SNIPPET_FILE, 'utf8').replace(/\r?\n$/, '');
// Count canonical preamble occurrences across the whole file (parity: exactly one).
let count = 0;
let pos = 0;
for (;;) {
const idx = content.indexOf(snippet, pos);
if (idx === -1) break;
count++;
pos = idx + snippet.length;
}
assert.equal(count, 1, `expected exactly one canonical preamble; found ${count}`);
// The preamble must live in the first (drift-check) bash block, before the DRIFT call.
const block0 = bashBlock(content, 0);
assert.ok(block0.includes(snippet), 'the canonical preamble must be in the drift-check block');
assert.ok(
block0.indexOf(snippet) < block0.indexOf('gsd_run verify codebase-drift'),
'the preamble must precede the gsd_run drift call in the same block',
);
// The auto-remap block reuses gsd_run but must NOT carry its own preamble.
const content2 = content.slice(content.indexOf('AGENT_SKILLS_MAPPER'));
assert.ok(!content2.includes(snippet), 'the auto-remap block must not re-declare the preamble (single-preamble parity)');
});
// ── Behavioral proof: the shim resolves on a shim-only topology ───────────
test('shipped drift-check block runs the shim (gsd-tools.cjs), not skip, on a shim-only install (#619)', () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-619-'));
try {
// Shim-only topology: gsd-tools.cjs present under RUNTIME_DIR; no `gsd-tools` on PATH.
const binDir = path.join(tmp, 'gsd-core', 'bin');
fs.mkdirSync(binDir, { recursive: true });
fs.writeFileSync(
path.join(binDir, 'gsd-tools.cjs'),
'if (process.argv[2] === "verify" && process.argv[3] === "codebase-drift") {\n' +
' process.stdout.write(JSON.stringify({ action_required: false, sentinel: "SHIM_RAN" }));\n' +
'}\n',
);
const block = bashBlock(readGate(), 0) + '\nprintf "%s" "$DRIFT"\n';
const out = execFileSync('bash', ['-c', block], {
env: { ...process.env, RUNTIME_DIR: tmp },
encoding: 'utf8',
});
assert.match(out, /SHIM_RAN/, 'the drift check must execute the resolved shim, proving gsd_run resolution');
assert.doesNotMatch(out, /sdk-failed/, 'the gate must NOT silently skip when the shim is present (#619)');
} finally {
cleanup(tmp);
}
});
test('red-proof: the old bare `gsd-tools` form would skip when gsd-tools is not on PATH', () => {
// Documents the #619 bug: the pre-fix bare-binary call, with no `gsd-tools` on PATH,
// hits the 127 → `|| echo` skip path even though the shim (gsd-tools.cjs) exists.
const oldForm =
'DRIFT=$(gsd-tools verify codebase-drift 2>/dev/null || echo \'{"skipped":true,"reason":"sdk-failed"}\'); printf "%s" "$DRIFT"';
const out = execFileSync('bash', ['-c', 'export PATH=/nonexistent-empty-path; ' + oldForm], {
env: { ...process.env },
encoding: 'utf8',
});
assert.match(out, /sdk-failed/, 'sanity: the bare-binary form skips without gsd-tools on PATH — the bug the fix removes');
});
});

View File

@@ -1,217 +0,0 @@
// allow-test-rule: source-text-is-the-product
// This test extracts the deployed Step 3 shell block from commands/gsd/graphify.md
// and executes it to prove that a skipped graph.html (due to the graphify HTML viz
// node limit) does not abort the chain (#622). The deployed markdown text IS the
// product surface — the block the runtime executes — so asserting on its execution
// behavior requires reading the source text.
'use strict';
/**
* Regression test for bug #622.
*
* The `/gsd-graphify build` Step 3 shell chain in commands/gsd/graphify.md
* aborted when `graph.html` was intentionally skipped (graph exceeds the HTML
* viz node limit, default 5000). The unconditional `cp graphify-out/graph.html`
* failed with "cannot stat", and the `&&` chain aborted before the
* GRAPH_REPORT.md copy, snapshot, and status steps ran.
*
* Fix: guard the graph.html copy with
* `{ [ -f graphify-out/graph.html ] && cp … || true; }`
* so the chain continues when the file is absent.
*/
const { describe, test, before, after } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const { spawnSync } = require('child_process');
const { createTempDir, cleanup } = require('./helpers.cjs');
// Path to the command doc (relative to repo root)
const GRAPHIFY_MD = path.join(__dirname, '..', 'commands', 'gsd', 'graphify.md');
/**
* Extract the Step 3 fenced bash block from graphify.md.
* The block starts with the line `graphify update .` and ends at the next
* closing ``` fence.
*
* Returns the bash source text (without the fence lines themselves).
*/
function extractStep3Block() {
const content = fs.readFileSync(GRAPHIFY_MD, 'utf-8');
// Capture the full body of the ```bash fence that CONTAINS `graphify update .`
// (including any leading preamble line), without crossing into other fences.
const match = content.match(/```bash\r?\n((?:(?!```)[\s\S])*?graphify update \.(?:(?!```)[\s\S])*?)\r?\n```/);
return match ? match[1].trim() : null;
}
// ─── shared sandbox dirs ──────────────────────────────────────────────────────
let sandbox;
let fakeBin;
let fakeHome;
before(() => {
sandbox = createTempDir('gsd-622-sandbox-');
fakeBin = createTempDir('gsd-622-fakebin-');
fakeHome = createTempDir('gsd-622-fakehome-');
});
after(() => {
cleanup(sandbox);
cleanup(fakeBin);
cleanup(fakeHome);
});
// ─── helpers ─────────────────────────────────────────────────────────────────
/**
* Write a minimal fake `graphify` executable into fakeBin.
* It just exits 0 so the `graphify update .` step succeeds.
*/
function writeFakeGraphify() {
const exe = path.join(fakeBin, 'graphify');
fs.writeFileSync(exe, ['#!/bin/sh', 'exit 0'].join('\n'), { mode: 0o755 });
}
/**
* Write a minimal gsd-tools.cjs stub into fakeHome that exits 0 for any
* invocation (covers the `graphify build snapshot` and `graphify status` steps).
*/
function writeFakeGsdTools() {
const binDir = path.join(fakeHome, '.claude', 'gsd-core', 'bin');
fs.mkdirSync(binDir, { recursive: true });
fs.writeFileSync(
path.join(binDir, 'gsd-tools.cjs'),
['#!/usr/bin/env node', 'process.exit(0);'].join('\n'),
{ mode: 0o755 },
);
}
/**
* Populate the sandbox with the minimal directory structure and output files
* that a real `graphify update .` would produce. `includeHtml` controls
* whether graphify-out/graph.html is created (simulating the node-limit skip
* when false).
*/
function populateSandbox(includeHtml) {
// graphify-out/ — simulates graphify CLI output directory
const outDir = path.join(sandbox, 'graphify-out');
fs.mkdirSync(outDir, { recursive: true });
fs.writeFileSync(path.join(outDir, 'graph.json'), '{}');
fs.writeFileSync(path.join(outDir, 'GRAPH_REPORT.md'), '# report');
if (includeHtml) {
fs.writeFileSync(path.join(outDir, 'graph.html'), '<html/>');
}
// .planning/graphs/ — destination directory
const graphsDir = path.join(sandbox, '.planning', 'graphs');
fs.mkdirSync(graphsDir, { recursive: true });
}
/**
* Execute the extracted Step 3 block in the sandbox.
*/
function runBlock(block) {
return spawnSync('bash', ['-c', block], {
cwd: sandbox,
env: {
...process.env,
PATH: fakeBin + ':' + process.env.PATH,
HOME: fakeHome,
},
encoding: 'utf8',
});
}
// ─── tests ───────────────────────────────────────────────────────────────────
describe('bug #622: graph.html absence must not abort the Step 3 shell chain', () => {
let block;
before(() => {
block = extractStep3Block();
});
test('Step 3 bash block is present in graphify.md (sanity gate)', () => {
assert.ok(block !== null, 'Step 3 bash block starting with "graphify update ." was not found in commands/gsd/graphify.md');
assert.ok(block.length > 0, 'Extracted bash block must not be empty');
});
test('graph.html absent: chain exits 0 and all other artifacts are copied (#622 regression)', (t) => {
// Use t.after for per-test cleanup so sandbox is fresh for each test
t.after(() => {
// Remove and recreate sandbox so the next test starts with an empty dir
cleanup(sandbox);
fs.mkdirSync(sandbox, { recursive: true });
});
writeFakeGraphify();
writeFakeGsdTools();
populateSandbox(false); // no graph.html — simulates node-limit skip
const result = runBlock(block);
// Chain must not abort
assert.equal(result.status, 0, [
'Expected exit 0 but got ' + result.status,
'stderr: ' + result.stderr,
'stdout: ' + result.stdout,
].join('\n'));
// graph.json was copied (step before the guarded line)
assert.ok(
fs.existsSync(path.join(sandbox, '.planning', 'graphs', 'graph.json')),
'.planning/graphs/graph.json must be copied even when graph.html is absent',
);
// GRAPH_REPORT.md was copied (step AFTER the guarded line — key regression assertion)
assert.ok(
fs.existsSync(path.join(sandbox, '.planning', 'graphs', 'GRAPH_REPORT.md')),
'.planning/graphs/GRAPH_REPORT.md must be copied (the chain must not abort at graph.html)',
);
// graph.html must NOT exist in the destination (correctly skipped)
assert.ok(
!fs.existsSync(path.join(sandbox, '.planning', 'graphs', 'graph.html')),
'.planning/graphs/graph.html must NOT be created when source is absent',
);
});
test('graph.html present: chain exits 0 and graph.html is copied (happy path)', (t) => {
t.after(() => {
cleanup(sandbox);
fs.mkdirSync(sandbox, { recursive: true });
});
writeFakeGraphify();
writeFakeGsdTools();
populateSandbox(true); // include graph.html
const result = runBlock(block);
assert.equal(result.status, 0, [
'Expected exit 0 but got ' + result.status,
'stderr: ' + result.stderr,
'stdout: ' + result.stdout,
].join('\n'));
// graph.html must exist in the destination (normal copy)
assert.ok(
fs.existsSync(path.join(sandbox, '.planning', 'graphs', 'graph.html')),
'.planning/graphs/graph.html must be copied when the source file is present',
);
// Other artifacts also copied
assert.ok(
fs.existsSync(path.join(sandbox, '.planning', 'graphs', 'graph.json')),
'.planning/graphs/graph.json must be copied',
);
assert.ok(
fs.existsSync(path.join(sandbox, '.planning', 'graphs', 'GRAPH_REPORT.md')),
'.planning/graphs/GRAPH_REPORT.md must be copied',
);
});
});

View File

@@ -1,175 +0,0 @@
// allow-test-rule: source-text-is-the-product
// execute-phase.md is the shipped orchestration contract for wave execution and
// cleanup. Bug #630: the two wave-cleanup guards resolved PRIMARY_WT from
// `git worktree list --porcelain`'s first entry — always the main checkout —
// so an orchestrator running from a non-primary (per-phase lane) worktree was
// cd'd off its own lane and tripped the #3174 branch-drift assertion at cleanup,
// refusing merge-back. The fix persists the dispatch-time orchestrator root in
// WAVE_WORKTREE_MANIFEST and pins cleanup to that, falling back to first-entry
// only for pre-#630 manifests.
//
// This file locks the source contract (the .md is the product) AND behaviorally
// proves the pivot by running the shipped manifest-reader one-liner against a
// real non-primary-worktree git topology.
'use strict';
const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { execFileSync } = require('node:child_process');
const { cleanup } = require('./helpers.cjs');
const EXECUTE_PHASE_MD = path.join(__dirname, '..', 'gsd-core', 'workflows', 'execute-phase.md');
function readMd() {
return fs.readFileSync(EXECUTE_PHASE_MD, 'utf8');
}
// Pull the exact `node -e '...'` manifest-reader script shipped in the cleanup
// guard, so the behavioral test exercises the real shipped code, not a copy.
function extractManifestReaderScript() {
const content = readMd();
// Anchor on `PRIMARY_WT=$(MANIFEST=...` so we grab the cleanup READER, not the
// dispatch-time writer one-liner (which shares the `MANIFEST="..." node -e` prefix).
const m = content.match(/PRIMARY_WT=\$\(MANIFEST="\$WAVE_WORKTREE_MANIFEST" node -e '([^']*)'\)/);
assert.ok(m, 'expected a `PRIMARY_WT=$(MANIFEST="$WAVE_WORKTREE_MANIFEST" node -e \'...\')` reader in execute-phase.md');
return m[1];
}
function git(cwd, args) {
return execFileSync('git', args, {
cwd,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
}).trim();
}
// Canonicalize a path the way the OS does. On Windows, os.tmpdir() can yield an 8.3
// short name (RUNNER~1) while `git worktree list` reports the long form (runneradmin);
// realpathSync.native reconciles both to the true canonical path so comparisons are stable.
function canon(p) {
return fs.realpathSync.native(p);
}
describe('bug #630 — wave-cleanup pins to the orchestrator root, not git-worktree-list first entry', () => {
test('execute-phase.md is readable', () => {
assert.ok(readMd().length > 0, 'execute-phase.md must not be empty');
});
// ── Source contract (the .md is the product) ──────────────────────────────
test('dispatch persists the orchestrator root into the manifest (#630)', () => {
const content = readMd();
assert.match(
content,
/ORCH_ROOT=\$\(git rev-parse --show-toplevel\)/,
'manifest init must capture the dispatch-time orchestrator root via show-toplevel',
);
assert.match(
content,
/orchestrator_root:\s*process\.env\.ORCH_ROOT/,
'manifest init must write orchestrator_root into WAVE_WORKTREE_MANIFEST',
);
});
test('both cleanup guards resolve PRIMARY_WT from the manifest orchestrator_root (#630)', () => {
const content = readMd();
const readers = content.match(
/PRIMARY_WT=\$\(MANIFEST="\$WAVE_WORKTREE_MANIFEST" node -e '[^']*orchestrator_root[^']*'\)/g,
);
assert.ok(
readers && readers.length >= 2,
`both wave-cleanup guards (templated + cleanup-tail) must read orchestrator_root from the manifest; found ${readers ? readers.length : 0}`,
);
});
test('first-entry resolution survives only as a guarded fallback, never the sole resolver (#630)', () => {
const content = readMd();
// Every remaining first-entry resolution must be preceded by the `[ -n "$PRIMARY_WT" ] ||`
// guard, i.e. it only runs when the manifest lookup produced nothing.
const firstEntryLines = content.match(/^.*git worktree list --porcelain \| awk '\/\^worktree \/.*$/gm) || [];
for (const line of firstEntryLines) {
assert.match(
line,
/\[ -n "\$PRIMARY_WT" \] \|\|/,
`first-entry resolution must be a guarded fallback, not the primary resolver: ${line.trim()}`,
);
}
assert.ok(firstEntryLines.length >= 2, 'expected the fallback in both cleanup guards');
});
// ── Behavioral proof of the pivot ─────────────────────────────────────────
test('shipped manifest reader resolves to the lane worktree, while first-entry resolves to main (#630)', () => {
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-630-'));
try {
const mainDir = path.join(tmpRoot, 'main');
fs.mkdirSync(mainDir);
git(mainDir, ['-c', 'init.defaultBranch=main', 'init', '-q']);
git(mainDir, ['config', 'user.email', 'test@example.com']);
git(mainDir, ['config', 'user.name', 'Test']);
fs.writeFileSync(path.join(mainDir, 'f.txt'), 'x\n');
git(mainDir, ['add', '.']);
git(mainDir, ['commit', '-q', '-m', 'init']);
// Non-primary worktree on a per-phase lane branch.
const laneDir = path.join(tmpRoot, 'lane');
git(mainDir, ['worktree', 'add', '-q', '-b', 'feat/lane', laneDir]);
const realMain = canon(mainDir);
const realLane = canon(laneDir);
// Manifest as written at dispatch: orchestrator_root is the lane (the orchestrator runs there).
const manifest = path.join(tmpRoot, 'wave.json');
fs.writeFileSync(manifest, JSON.stringify({ orchestrator_root: realLane, worktrees: [] }) + '\n');
// Run the EXACT shipped reader one-liner.
const script = extractManifestReaderScript();
const resolved = execFileSync('node', ['-e', script], {
cwd: laneDir,
env: { ...process.env, MANIFEST: manifest },
encoding: 'utf8',
}).trim();
// The buggy first-entry resolution (run from the lane) yields the MAIN checkout.
const firstEntry = canon(
git(laneDir, ['worktree', 'list', '--porcelain'])
.split('\n')
.find(l => l.startsWith('worktree '))
.slice('worktree '.length),
);
assert.equal(canon(resolved), realLane, 'manifest reader must resolve to the orchestrator lane worktree');
assert.equal(firstEntry, realMain, 'sanity: first-entry resolution points at the main checkout (the #630 bug target)');
assert.notEqual(canon(resolved), firstEntry, 'the fix must diverge from the old first-entry behavior for a lane orchestrator');
// The #3174 branch assertion now passes (pinned to lane → branch matches EXPECTED_BRANCH);
// pinning to first-entry (main) would have failed it.
const expectedBranch = 'feat/lane';
assert.equal(git(resolved, ['rev-parse', '--abbrev-ref', 'HEAD']), expectedBranch, 'lane pin satisfies the #3174 branch check');
assert.notEqual(git(firstEntry, ['rev-parse', '--abbrev-ref', 'HEAD']), expectedBranch, 'first-entry pin would have tripped the #3174 branch check');
} finally {
cleanup(tmpRoot);
}
});
test('manifest reader falls through (empty output) when orchestrator_root is absent — fallback engages (#630)', () => {
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-630-fb-'));
try {
const manifest = path.join(tmpRoot, 'legacy.json');
// Pre-#630 manifest shape: no orchestrator_root.
fs.writeFileSync(manifest, JSON.stringify({ worktrees: [] }) + '\n');
const script = extractManifestReaderScript();
const out = execFileSync('node', ['-e', script], {
env: { ...process.env, MANIFEST: manifest },
encoding: 'utf8',
});
assert.equal(out, '', 'reader must emit nothing for a manifest without orchestrator_root so the first-entry fallback engages');
} finally {
cleanup(tmpRoot);
}
});
});

View File

@@ -1,249 +0,0 @@
// Regression test for issue #641:
// `--files-from` with a bare suite token (e.g. "unit") crashes with
// "requested test file(s) not found: unit" instead of expanding the token
// to the matching suite's files.
//
// The bug: selectExplicitFiles() checked `available.has('unit')` against the
// set of *.test.cjs filenames. 'unit' is not a filename, so it landed in
// `missing` and caused exit 2. The fix teaches selectExplicitFiles() to
// delegate bare SUITES members to selectFiles() before the path-existence
// check.
'use strict';
const { describe, test, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const { spawnSync } = require('child_process');
const fs = require('fs');
const path = require('path');
const { createTempDir, cleanup } = require('./helpers.cjs');
const HARNESS = path.join(__dirname, '..', 'scripts', 'run-tests.cjs');
const PASS_BODY = `'use strict';
const { test } = require('node:test');
test('noop', () => {});
`;
function seed(dir, names) {
for (const name of names) {
fs.writeFileSync(path.join(dir, name), PASS_BODY, 'utf8');
}
}
function runHarness(testDir, args = [], extraEnv = {}) {
const env = { ...process.env, GSD_TEST_DIR: testDir, ...extraEnv };
delete env.NODE_TEST_CONTEXT;
return spawnSync(process.execPath, [HARNESS, ...args], {
cwd: path.join(__dirname, '..'),
env,
encoding: 'utf8',
});
}
describe('bug #641 — --files-from with bare suite token', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempDir('gsd-641-suite-token-');
});
afterEach(() => {
cleanup(tmpDir);
});
test('--files-from with bare "unit" token expands to unit suite, does not exit 2', () => {
// Seed a mix: one unit file, one security file.
seed(tmpDir, ['a.test.cjs', 'b.security.test.cjs']);
const listPath = path.join(tmpDir, 'ci-selected-tests.txt');
fs.writeFileSync(listPath, 'unit\n', 'utf8');
const r = runHarness(tmpDir, ['--files-from', listPath]);
// Must NOT exit 2 with the "not found" error.
assert.notStrictEqual(
r.status,
2,
`Expected exit 0 or 1, got 2.\nstderr: ${r.stderr}\nstdout: ${r.stdout}`,
);
assert.doesNotMatch(
r.stderr,
/requested test file\(s\) not found: unit/,
`Must not emit "not found: unit".\nstderr: ${r.stderr}`,
);
// The unit suite file (a.test.cjs) must appear in the run.
assert.ok(
r.stderr.includes('a.test.cjs'),
`Expected a.test.cjs (unit suite) to be selected.\nstderr: ${r.stderr}`,
);
// The security suite file must NOT be included (unit token = unit only).
assert.ok(
!r.stderr.includes('b.security.test.cjs'),
`Expected b.security.test.cjs (security suite) to be excluded.\nstderr: ${r.stderr}`,
);
});
test('--files-from with bare "unit" token exits 0 (tests run successfully)', () => {
seed(tmpDir, ['a.test.cjs']);
const listPath = path.join(tmpDir, 'ci-selected-tests.txt');
fs.writeFileSync(listPath, 'unit\n', 'utf8');
const r = runHarness(tmpDir, ['--files-from', listPath]);
assert.strictEqual(
r.status,
0,
`Expected exit 0.\nstderr: ${r.stderr}\nstdout: ${r.stdout}`,
);
});
test('--files with bare "unit" token also resolves correctly', () => {
seed(tmpDir, ['a.test.cjs', 'b.security.test.cjs']);
const r = runHarness(tmpDir, ['--files', 'unit']);
assert.notStrictEqual(
r.status,
2,
`Expected exit 0, got 2.\nstderr: ${r.stderr}`,
);
assert.doesNotMatch(r.stderr, /requested test file\(s\) not found: unit/);
assert.ok(r.stderr.includes('a.test.cjs'), `a.test.cjs must be selected.\nstderr: ${r.stderr}`);
assert.ok(!r.stderr.includes('b.security.test.cjs'), `security file must not be selected.\nstderr: ${r.stderr}`);
});
test('mixed: suite token "unit" alongside an explicit file resolves both', () => {
seed(tmpDir, ['a.test.cjs', 'b.test.cjs', 'c.security.test.cjs']);
const listPath = path.join(tmpDir, 'ci-selected-tests.txt');
// 'unit' expands to [a.test.cjs, b.test.cjs]; b.test.cjs is explicit too.
fs.writeFileSync(listPath, 'unit\nb.test.cjs\n', 'utf8');
const r = runHarness(tmpDir, ['--files-from', listPath]);
assert.strictEqual(r.status, 0, `stderr: ${r.stderr}`);
// Both unit files present; security not.
assert.ok(r.stderr.includes('a.test.cjs'), `a.test.cjs must be selected.\nstderr: ${r.stderr}`);
assert.ok(r.stderr.includes('b.test.cjs'), `b.test.cjs must be selected.\nstderr: ${r.stderr}`);
assert.ok(!r.stderr.includes('c.security.test.cjs'), `c.security.test.cjs must be excluded.\nstderr: ${r.stderr}`);
});
test('#408 fallback: ci-test-scope "unit" sentinel does not crash run-tests', () => {
// This test simulates the end-to-end #408 fallback path:
// ci-test-scope produces "unit" (the fallback sentinel for "code changed
// but no rule matched any test"), ci-prepare-test-scope writes it verbatim,
// and run-tests must resolve it rather than crash.
seed(tmpDir, ['a.test.cjs', 'b.security.test.cjs']);
// Simulate what ci-prepare-test-scope writes: "unit\n"
const listPath = path.join(tmpDir, '.ci-selected-tests.txt');
fs.writeFileSync(listPath, 'unit\n', 'utf8');
const r = runHarness(tmpDir, ['--files-from', listPath]);
assert.strictEqual(
r.status,
0,
`#408 fallback: expected exit 0 but got ${r.status}.\nstderr: ${r.stderr}`,
);
assert.doesNotMatch(r.stderr, /not found: unit/);
assert.ok(r.stderr.includes('a.test.cjs'), `unit test must run.\nstderr: ${r.stderr}`);
});
});
// Regression test for issue #1329:
// ci-prepare-test-scope's empty-detection FALLBACK hardcoded an explicit file
// list that included tests/core.test.cjs — a file deleted in #1291. Every
// scoped lane (scope=targeted|windows) that hit the fallback wrote the stale
// path into .ci-selected-tests.txt and crashed run-tests with
// "requested test file(s) not found: core.test.cjs". The fix: existence-filter
// the fallback at write time, fall back to the 'unit' suite sentinel when
// nothing survives, and guard the FALLBACK constant against disk reality.
describe('bug #1329 — ci-prepare-test-scope fallback never emits a deleted file', () => {
const { FALLBACK, FALLBACK_SENTINEL, SUITE_SENTINELS, resolveSelection } =
require('../scripts/ci-prepare-test-scope.cjs');
const REPO_ROOT = path.join(__dirname, '..');
// Generative parity guard (DEFECT.GENERATIVE-FIX): the FALLBACK constant and
// the test files on disk are two surfaces that must stay in sync. This fails
// the instant a refactor deletes a file still named in FALLBACK — which is
// precisely what #1291 did and CI did not catch.
test('every FALLBACK entry resolves on disk or is a known suite sentinel', () => {
for (const entry of FALLBACK) {
const isSentinel = SUITE_SENTINELS.includes(entry);
const exists = fs.existsSync(path.join(REPO_ROOT, entry));
assert.ok(
isSentinel || exists,
`FALLBACK entry "${entry}" is neither an existing test file nor a suite sentinel — stale reference will crash scoped CI lanes (see #1329).`,
);
}
});
let tmpDir;
beforeEach(() => {
tmpDir = createTempDir('gsd-1329-fallback-');
fs.mkdirSync(path.join(tmpDir, 'tests'), { recursive: true });
});
afterEach(() => {
cleanup(tmpDir);
});
test('empty detection drops a non-existent fallback entry instead of emitting it', () => {
// Create all but the last FALLBACK file under a controlled root, simulating
// a since-deleted test (the #1329 mechanism), independent of which files
// FALLBACK happens to name today.
const present = FALLBACK.slice(0, -1);
const absent = FALLBACK[FALLBACK.length - 1];
for (const f of present) {
fs.writeFileSync(path.join(tmpDir, f), PASS_BODY, 'utf8');
}
const lines = resolveSelection({ scope: 'targeted', targeted: '', windows: '', root: tmpDir });
assert.ok(!lines.includes(absent), `absent file "${absent}" must be filtered out, got: ${lines.join(', ')}`);
for (const f of present) {
assert.ok(lines.includes(f), `present file "${f}" must survive, got: ${lines.join(', ')}`);
}
});
test('empty detection with no surviving fallback files falls back to the unit sentinel', () => {
// tmpDir/tests exists but contains none of the FALLBACK files.
const lines = resolveSelection({ scope: 'windows', targeted: '', windows: '', root: tmpDir });
assert.deepStrictEqual(lines, [FALLBACK_SENTINEL]);
});
test('detected list passes through verbatim — files and suite sentinels preserved, not existence-filtered', () => {
// The detected list is already filtered by affected-tests-lib and may carry
// a suite sentinel; ci-prepare-test-scope must not touch it.
const lines = resolveSelection({
scope: 'targeted',
targeted: 'tests/does-not-exist.test.cjs unit',
windows: '',
root: tmpDir,
});
assert.deepStrictEqual(lines, ['tests/does-not-exist.test.cjs', 'unit']);
});
test('end-to-end: the real script writes a fallback list whose every entry resolves', () => {
// Run the real script (subprocess) with empty detection inside an isolated
// root that holds the FALLBACK files, then verify every line it wrote into
// .ci-selected-tests.txt resolves — the exact scoped-lane path that crashed
// in #1329. Hermetic: the temp root is removed by afterEach's cleanup().
for (const f of FALLBACK) {
fs.writeFileSync(path.join(tmpDir, f), PASS_BODY, 'utf8');
}
const prep = spawnSync(
process.execPath,
[path.join(REPO_ROOT, 'scripts', 'ci-prepare-test-scope.cjs')],
{ cwd: tmpDir, env: { ...process.env, TEST_SCOPE: 'targeted', TARGETED_TESTS: '', WINDOWS_TESTS: '' }, encoding: 'utf8' },
);
assert.strictEqual(prep.status, 0, `prepare step failed: ${prep.stderr}`);
const selected = fs.readFileSync(path.join(tmpDir, '.ci-selected-tests.txt'), 'utf8');
for (const line of selected.split(/\r?\n/).filter(Boolean)) {
const isSentinel = SUITE_SENTINELS.includes(line);
assert.ok(
isSentinel || fs.existsSync(path.join(tmpDir, line)),
`selected entry "${line}" does not resolve — would crash run-tests (#1329)`,
);
}
assert.doesNotMatch(selected, /core\.test\.cjs/, 'deleted core.test.cjs must never be selected');
});
});

View File

@@ -1,102 +0,0 @@
// allow-test-rule: source-text-is-the-product
// These spawn/exec sites cannot be behaviourally tested for windowsHide
// off-Windows; the source text is the runtime contract (issue #685). Without
// windowsHide:true a detached or shell:true child allocates a visible console
// window on Windows (the "gsd-core" flash).
'use strict';
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const root = path.resolve(__dirname, '..');
const read = (p) => fs.readFileSync(path.join(root, p), 'utf-8');
// Slice the exact body of one spawn site so the assertion binds that site,
// not merely "windowsHide appears somewhere in the file".
function regionBetween(src, startAnchor, endAnchor) {
const i = src.indexOf(startAnchor);
assert.notEqual(i, -1, `start anchor not found: ${startAnchor}`);
const j = src.indexOf(endAnchor, i);
assert.notEqual(j, -1, `end anchor not found after start: ${endAnchor}`);
return src.slice(i, j);
}
describe('bug #685: Windows spawns must set windowsHide:true (no console-window flash)', () => {
test('gsd-context-monitor record-session spawn sets windowsHide', () => {
const region = regionBetween(read('hooks/gsd-context-monitor.js'), "'record-session'", '.unref()');
assert.match(region, /windowsHide:\s*true/, 'record-session spawn must set windowsHide: true');
});
const cts = () => read('src/shell-command-projection.cts');
const helpers = [
['execGit', 'export function execGit', "_spawnResult(result, 'git')"],
['execNpm', 'export function execNpm', "_spawnResult(result, 'npm')"],
['execTool', 'export function execTool', '_spawnResult(result, program)'],
];
for (const [name, start, end] of helpers) {
test(`shell-command-projection ${name} spawnSync sets windowsHide`, () => {
const region = regionBetween(cts(), start, end);
assert.match(region, /windowsHide:\s*true/, `${name} spawnSync must set windowsHide: true`);
});
}
test('gsd-worktree-path-guard SPAWNOPT sets windowsHide', () => {
const region = regionBetween(read('hooks/gsd-worktree-path-guard.js'), 'const SPAWNOPT', '};');
assert.match(region, /windowsHide:\s*true/, 'gsd-worktree-path-guard SPAWNOPT must set windowsHide: true');
});
test('gsd-workflow-guard currentBranch spawnSync sets windowsHide', () => {
const region = regionBetween(read('hooks/gsd-workflow-guard.js'), "spawnSync('git', ['branch'", '});');
assert.match(region, /windowsHide:\s*true/, 'gsd-workflow-guard git-branch spawn must set windowsHide: true');
});
test('check-command-router recentCommitMessages execFileSync sets windowsHide', () => {
const region = regionBetween(read('src/check-command-router.cts'), "execFileSync('git', ['log'", '});');
assert.match(region, /windowsHide:\s*true/, 'check-command-router git-log execFileSync must set windowsHide: true');
});
test('roadmap-upgrade execSync git calls all set windowsHide', () => {
const src = read('src/roadmap-upgrade.cts');
const calls = src.match(/execSync\([^)]*\)/g) || [];
// #1542 made rollback git-independent (surgical fs restore), so the only
// remaining git execSync is the `git status --porcelain` precondition. The
// durable guard is that EVERY git execSync still present sets windowsHide.
assert.ok(calls.length >= 1, 'expected at least the roadmap-upgrade git status execSync call to be present');
const missing = calls.filter((c) => !/windowsHide:\s*true/.test(c));
assert.deepEqual(missing, [], `execSync without windowsHide:\n${missing.join('\n')}`);
});
test('gsd-check-update spawn retains windowsHide (precedent guard)', () => {
assert.match(read('hooks/gsd-check-update.js'), /windowsHide:\s*true/,
'gsd-check-update.js must keep windowsHide: true');
});
// Durable invariant: ANY external-binary process spawn in the runtime source
// (hooks + src) must set windowsHide — catches future additions, not just the
// sites known today. Handles the `{ ...CONST }` spread indirection.
test('completeness: no external-binary spawn in runtime source omits windowsHide', () => {
const listDir = (dir, re) =>
fs.readdirSync(path.join(root, dir)).filter((f) => re.test(f)).map((f) => `${dir}/${f}`);
const files = [...listDir('hooks', /\.js$/), ...listDir('src', /\.cts$/)];
const callRe = /(?:execSync|execFileSync|spawnSync|spawn)\s*\(\s*(?:`|'|")?(?:git|npm|gh)\b|spawn\s*\(\s*process\.execPath/g;
const offenders = [];
for (const rel of files) {
const src = read(rel);
let m;
while ((m = callRe.exec(src)) !== null) {
const win = src.slice(m.index, m.index + 400);
let ok = /windowsHide:\s*true/.test(win);
if (!ok) {
const spread = win.match(/\{\s*\.\.\.(\w+)/); // e.g. { ...SPAWNOPT, cwd }
if (spread) {
ok = new RegExp(`(?:const|let|var)\\s+${spread[1]}\\s*=\\s*\\{[^}]*windowsHide:\\s*true`).test(src);
}
}
if (!ok) offenders.push(`${rel}: ...${src.slice(m.index, m.index + 48).replace(/\s+/g, ' ')}`);
}
}
assert.deepEqual(offenders, [], `external-binary spawns missing windowsHide:\n${offenders.join('\n')}`);
});
});

View File

@@ -1,433 +0,0 @@
'use strict';
/**
* Regression test for bug #891: gsd_run launcher must probe non-Claude
* runtime homes before emitting the hard error.
*
* The last-resort $HOME/.claude/gsd-core branch is Claude Code-specific.
* Every non-Claude runtime (Hermes, Cursor, Codex, Copilot, Windsurf, …)
* installs gsd-core into a *different* directory that the shim never tried,
* causing a false-positive fatal ERROR on all non-Claude runtimes when
* RUNTIME_DIR is not set and gsd-tools is not on PATH.
*
* Asserts:
* (A) Snippet contains all expected non-Claude runtime home probes (structural).
* (B) HERMES_HOME behavioral: when RUNTIME_DIR misses and gsd-tools is NOT on
* PATH, a stub at ${HERMES_HOME}/gsd-core/bin/gsd-tools.cjs is invoked.
* (C) Default Hermes path behavioral: stub at $HOME/.hermes/gsd-core/bin/
* gsd-tools.cjs is invoked when HERMES_HOME is not set.
* (D) Resolution order: non-Claude homes are probed BEFORE the hard error,
* and AFTER the $HOME/.claude branch.
* (E) Propagation: all workflow .md files using gsd_run contain each probe
* (sync-runtime-launcher.cjs was re-run after editing the snippet).
*/
// allow-test-rule: structural/behavioral regression for non-Claude runtime-home
// fallback arms in the gsd_run launcher snippet -- asserts literal substring
// presence for each runtime-home probe and exercises the bash resolution paths
// via execFileSync; there is no typed IR for "snippet contains arm X".
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const os = require('node:os');
const { execFileSync } = require('node:child_process');
const { cleanup } = require('./helpers.cjs');
const WORKFLOWS_DIR = path.join(__dirname, '..', 'gsd-core', 'workflows');
const SNIPPET_FILE = path.join(WORKFLOWS_DIR, '_runtime-launcher.snippet.sh');
// Every non-Claude runtime home probe the snippet must contain.
// Key: runtime name (for diagnostics). Value: the substring that must appear
// in the snippet (the env-var-with-default expansion that probes that runtime's
// gsd-core install location). Mirrors src/runtime-homes.cts getGlobalConfigDir().
const EXPECTED_RUNTIME_PROBES = {
hermes: '.hermes}/gsd-core/bin/',
cursor: '.cursor}/gsd-core/bin/',
codex: '.codex}/gsd-core/bin/',
gemini: '.gemini}/gsd-core/bin/',
copilot: '.copilot}/gsd-core/bin/',
windsurf: '.codeium/windsurf}/gsd-core/bin/',
augment: '.augment}/gsd-core/bin/',
trae: '.trae}/gsd-core/bin/',
qwen: '.qwen}/gsd-core/bin/',
codebuddy: '.codebuddy}/gsd-core/bin/',
cline: '.cline}/gsd-core/bin/',
grok: '.agents}/gsd-core/bin/',
antigravity: '.gemini/antigravity}/gsd-core/bin/',
opencode: 'opencode}/gsd-core/bin/',
kilo: 'kilo}/gsd-core/bin/',
};
/**
* Collect all workflow .md files recursively.
*/
function collectWorkflowFiles() {
const results = [];
function walk(dir) {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
walk(full);
} else if (entry.isFile() && entry.name.endsWith('.md')) {
results.push(full);
}
}
}
walk(WORKFLOWS_DIR);
return results;
}
/**
* Extract all bash/sh/shell fenced blocks from markdown content.
*/
function extractShellBlocks(content) {
const allLines = content.split('\n');
const blocks = [];
let inBlock = false;
let blockLang = null;
let blockLines = [];
let blockIndent = '';
let closingPattern = null;
for (let i = 0; i < allLines.length; i++) {
const line = allLines[i];
if (!inBlock) {
const fenceOpen = line.match(/^(\s*)```(\w+)?\s*$/);
if (fenceOpen) {
inBlock = true;
blockIndent = fenceOpen[1];
blockLang = (fenceOpen[2] || '').toLowerCase();
blockLines = [];
closingPattern = new RegExp('^' + blockIndent.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '```\\s*$');
continue;
}
} else {
if (closingPattern.test(line)) {
if (['bash', 'sh', 'shell', 'zsh', ''].includes(blockLang)) {
blocks.push({ lines: blockLines });
}
inBlock = false;
blockLang = null;
blockLines = [];
blockIndent = '';
closingPattern = null;
continue;
}
blockLines.push(line);
}
}
return blocks;
}
/**
* Build a PATH with no gsd-tools binary so the PATH fallback branch is skipped,
* while guaranteeing that a bare `node` lookup still resolves regardless of whether
* the real node binary co-locates with a global gsd-tools shim (e.g. fnm/nvm/Homebrew).
*
* Strategy (POSIX only): create a temp dir containing only a `node` symlink →
* process.execPath, prepend it to the gsd-tools-filtered PATH. The filtered
* PATH excludes any directory that contains an executable `gsd-tools`.
*
* On Windows the co-location bug does not apply (gsd-tools resolves via .cmd/.ps1,
* not the bare binary probed here), and symlinks may require elevated privileges,
* so we skip the symlink step entirely on that platform.
*
* The caller is responsible for cleaning up `result.nodeBinDir` when non-null
* (pass it to `cleanup()` in a `t.after` or `finally` block).
*
* @returns {{ isolatedPath: string, nodeBinDir: string|null }}
*/
function buildIsolatedPath() {
const filteredPath = (process.env.PATH || '/usr/bin:/bin')
.split(path.delimiter)
.filter((p) => {
try { fs.accessSync(path.join(p, 'gsd-tools'), fs.constants.X_OK); return false; }
catch { return true; }
})
.join(path.delimiter);
// Windows: no symlink (see JSDoc above); callers must handle nodeBinDir === null.
if (process.platform === 'win32') {
return { isolatedPath: filteredPath, nodeBinDir: null };
}
const nodeBinDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-891-node-'));
try {
fs.symlinkSync(process.execPath, path.join(nodeBinDir, 'node'));
} catch (err) {
cleanup(nodeBinDir);
throw err;
}
return { isolatedPath: nodeBinDir + path.delimiter + filteredPath, nodeBinDir };
}
describe('bug-891: non-Claude runtime home fallback arms', () => {
// ── (A) Structural: snippet contains all expected non-Claude probes ───────
test('(A) snippet contains all non-Claude runtime home probes', () => {
const snippetContent = fs.readFileSync(SNIPPET_FILE, 'utf8');
const missing = [];
for (const [runtime, probe] of Object.entries(EXPECTED_RUNTIME_PROBES)) {
if (!snippetContent.includes(probe)) {
missing.push(`${runtime}: expected snippet to contain "${probe}"`);
}
}
assert.deepStrictEqual(
missing,
[],
`_runtime-launcher.snippet.sh is missing fallback probes for non-Claude runtimes:\n` +
missing.join('\n') +
`\n\nAdd elif arms for each runtime home (e.g. "\${HERMES_HOME:-$HOME/.hermes}/gsd-core/bin/...")` +
` before the hard-error else. Current snippet:\n${snippetContent.trim()}`,
);
});
// ── (A2) Structural: probes appear AFTER .claude arm but BEFORE hard error ─
test('(A2) non-Claude probes appear after .claude/gsd-core arm and before hard error', () => {
const snippetContent = fs.readFileSync(SNIPPET_FILE, 'utf8');
const claudePos = snippetContent.indexOf('.claude/gsd-core/bin/');
const errorPos = snippetContent.indexOf('exit 1');
assert.ok(claudePos !== -1, 'Snippet must still contain .claude/gsd-core/bin/ arm (regression guard)');
assert.ok(errorPos !== -1, 'Snippet must contain exit 1 (hard-error guard)');
for (const [runtime, probe] of Object.entries(EXPECTED_RUNTIME_PROBES)) {
const probePos = snippetContent.indexOf(probe);
assert.ok(
probePos !== -1,
`Snippet must contain probe for ${runtime} ("${probe}")`,
);
assert.ok(
probePos < errorPos,
`${runtime} probe must appear before "exit 1" in snippet (found at ${probePos}, exit 1 at ${errorPos})`,
);
}
});
// ── (B0) Regression: buildIsolatedPath keeps node resolvable when node and ──
// gsd-tools co-locate in the same PATH directory. ─
//
// Machine-independence guarantee: PATH is set to ONLY two controlled dirs —
// fakeBinDir (holds both fake gsd-tools AND a node symlink) plus a fresh
// empty dir (no executables at all). The real system PATH is NOT appended.
//
// Old logic: filters out fakeBinDir → only the empty dir remains → node
// UNresolvable → assertion (ii) FAILS (true-red on any machine).
// New logic: prepends its own nodeBinDir → node resolvable despite fakeBinDir
// being filtered → both assertions pass.
test(
'(B0) buildIsolatedPath: node is resolvable and gsd-tools is not when they share a PATH dir',
{ skip: process.platform === 'win32' ? 'POSIX-only co-location scenario' : false },
(t) => {
// Build a fake bin dir that contains BOTH a gsd-tools executable and a node
// symlink, simulating a dev setup (fnm/nvm/Homebrew) where both land in the
// same bin directory.
const fakeBinDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-891-colocated-'));
// A second fresh empty dir — contains neither gsd-tools nor node.
const emptyDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-891-empty-'));
t.after(() => cleanup(fakeBinDir));
t.after(() => cleanup(emptyDir));
// Fake gsd-tools shim (executable file)
const fakeGsdTools = path.join(fakeBinDir, 'gsd-tools');
fs.writeFileSync(fakeGsdTools, '#!/bin/sh\necho fake-gsd-tools\n');
fs.chmodSync(fakeGsdTools, 0o755);
// node symlink pointing at the real interpreter (co-located with gsd-tools)
fs.symlinkSync(process.execPath, path.join(fakeBinDir, 'node'));
// Set PATH to ONLY the two controlled dirs (no real system dirs).
// This makes the test machine-independent: on any machine, the only place
// node *could* come from before the fix is fakeBinDir — which gets filtered.
const origPath = process.env.PATH;
process.env.PATH = fakeBinDir + path.delimiter + emptyDir;
let result;
try {
result = buildIsolatedPath();
} finally {
process.env.PATH = origPath;
}
t.after(() => cleanup(result.nodeBinDir));
const returnedDirs = result.isolatedPath.split(path.delimiter);
// (i) gsd-tools must NOT be resolvable on the returned PATH
const gsdToolsResolvable = returnedDirs.some((dir) => {
try { fs.accessSync(path.join(dir, 'gsd-tools'), fs.constants.X_OK); return true; }
catch { return false; }
});
assert.equal(
gsdToolsResolvable,
false,
'gsd-tools must not be resolvable on the isolated PATH (home-fallback would be bypassed)',
);
// (ii) node must BE resolvable on the returned PATH (the new nodeBinDir makes it so)
const nodeResolvable = returnedDirs.some((dir) => {
try { fs.accessSync(path.join(dir, 'node'), fs.constants.X_OK); return true; }
catch { return false; }
});
assert.equal(
nodeResolvable,
true,
'node must be resolvable on the isolated PATH (launcher runs: node "$GSD_TOOLS" "$@")',
);
},
);
// ── (B) Behavioral: HERMES_HOME stub is resolved ──────────────────────────
test('(B) gsd_run resolves ${HERMES_HOME}/gsd-core/bin/ stub when set and local+PATH both miss', () => {
const fakeHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-891-home-b-'));
const fakeHermesHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-891-hermes-'));
const fakeRuntime = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-891-rt-'));
const { isolatedPath, nodeBinDir } = buildIsolatedPath();
try {
const hermesBinDir = path.join(fakeHermesHome, 'gsd-core', 'bin');
fs.mkdirSync(hermesBinDir, { recursive: true });
const stubPath = path.join(hermesBinDir, 'gsd-tools.cjs');
fs.writeFileSync(
stubPath,
'#!/usr/bin/env node\nconsole.log("HERMES_HOME_STUB:" + process.argv.slice(2).join(","));\n',
);
fs.chmodSync(stubPath, 0o755);
const snippet = fs.readFileSync(SNIPPET_FILE, 'utf8');
// Export HOME to an isolated temp dir (no .claude install there) so the
// $HOME/.claude arm is skipped and we fall through to the HERMES_HOME arm.
const scriptContent =
`unset GSD_TOOLS\n` +
`export HOME=${JSON.stringify(fakeHome)}\n` +
`export RUNTIME_DIR=${JSON.stringify(fakeRuntime)}\n` +
`export HERMES_HOME=${JSON.stringify(fakeHermesHome)}\n` +
snippet +
`\nprintf "GSD_TOOLS=%s\\n" "$GSD_TOOLS"\n` +
`gsd_run ping test\n`;
const scriptPath = path.join(fakeRuntime, 'test-hermes-home.sh');
fs.writeFileSync(scriptPath, scriptContent);
const stdout = execFileSync('bash', [scriptPath], {
encoding: 'utf8',
env: { ...process.env, PATH: isolatedPath, HOME: fakeHome, HERMES_HOME: fakeHermesHome },
});
const normStdout = stdout.replace(/\\/g, '/');
assert.ok(
normStdout.includes('gsd-core/bin/'),
`Expected GSD_TOOLS to resolve into hermes gsd-core/bin/, got:\n${stdout.trim()}`,
);
assert.ok(
stdout.includes('HERMES_HOME_STUB:ping,test'),
`Expected stub output "HERMES_HOME_STUB:ping,test", got:\n${stdout.trim()}`,
);
} finally {
cleanup(fakeHome);
cleanup(fakeHermesHome);
cleanup(fakeRuntime);
if (nodeBinDir) cleanup(nodeBinDir);
}
});
// ── (C) Behavioral: default .hermes path used when HERMES_HOME not set ────
test('(C) gsd_run resolves $HOME/.hermes/gsd-core/bin/ stub when HERMES_HOME is unset', () => {
const fakeHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-891-home-'));
const fakeRuntime = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-891-rt2-'));
const { isolatedPath, nodeBinDir } = buildIsolatedPath();
try {
const hermesBinDir = path.join(fakeHome, '.hermes', 'gsd-core', 'bin');
fs.mkdirSync(hermesBinDir, { recursive: true });
const stubPath = path.join(hermesBinDir, 'gsd-tools.cjs');
fs.writeFileSync(
stubPath,
'#!/usr/bin/env node\nconsole.log("HERMES_DEFAULT_STUB:" + process.argv.slice(2).join(","));\n',
);
fs.chmodSync(stubPath, 0o755);
const snippet = fs.readFileSync(SNIPPET_FILE, 'utf8');
const scriptContent =
`unset GSD_TOOLS HERMES_HOME\n` +
`export RUNTIME_DIR=${JSON.stringify(fakeRuntime)}\n` +
`export HOME=${JSON.stringify(fakeHome)}\n` +
snippet +
`\nprintf "GSD_TOOLS=%s\\n" "$GSD_TOOLS"\n` +
`gsd_run status\n`;
const scriptPath = path.join(fakeRuntime, 'test-hermes-default.sh');
fs.writeFileSync(scriptPath, scriptContent);
const stdout = execFileSync('bash', [scriptPath], {
encoding: 'utf8',
env: { ...process.env, PATH: isolatedPath, HOME: fakeHome },
});
const normStdout = stdout.replace(/\\/g, '/');
assert.ok(
normStdout.includes('.hermes/gsd-core/bin/'),
`Expected GSD_TOOLS to resolve into .hermes/gsd-core/bin/, got:\n${stdout.trim()}`,
);
assert.ok(
stdout.includes('HERMES_DEFAULT_STUB:status'),
`Expected stub output "HERMES_DEFAULT_STUB:status", got:\n${stdout.trim()}`,
);
} finally {
cleanup(fakeHome);
cleanup(fakeRuntime);
if (nodeBinDir) cleanup(nodeBinDir);
}
});
// ── (D) Resolution order: claude < hermes < hard-error ───────────────────
test('(D) resolution order: .claude probe comes before hermes probe, hermes before hard error', () => {
const snippetContent = fs.readFileSync(SNIPPET_FILE, 'utf8');
const claudePos = snippetContent.indexOf('.claude/gsd-core/bin/');
const hermesPos = snippetContent.indexOf('.hermes}/gsd-core/bin/');
const errorPos = snippetContent.indexOf('exit 1');
assert.ok(claudePos !== -1, 'Snippet must contain .claude/gsd-core/bin/ arm');
assert.ok(hermesPos !== -1, 'Snippet must contain .hermes}/gsd-core/bin/ arm');
assert.ok(errorPos !== -1, 'Snippet must contain exit 1 hard-error');
assert.ok(
claudePos < hermesPos,
`Expected .claude probe (at ${claudePos}) before .hermes probe (at ${hermesPos})`,
);
assert.ok(
hermesPos < errorPos,
`Expected .hermes probe (at ${hermesPos}) before exit 1 (at ${errorPos})`,
);
});
// ── (E) Propagation: workflow .md files using gsd_run contain hermes probe ─
test('(E) all workflow .md files using gsd_run contain the hermes runtime home probe', () => {
const HERMES_PROBE = '.hermes}/gsd-core/bin/';
const files = collectWorkflowFiles();
assert.ok(files.length > 0, 'expected at least one workflow .md file');
const missing = [];
for (const f of files) {
const content = fs.readFileSync(f, 'utf8');
const blocks = extractShellBlocks(content);
const allBlockLines = blocks.flatMap((b) => b.lines);
const fileHasGsdRun = allBlockLines.some((l) => /\bgsd_run\b/.test(l));
if (!fileHasGsdRun) continue;
const allContent = allBlockLines.join('\n');
if (!allContent.includes(HERMES_PROBE)) {
missing.push(path.relative(WORKFLOWS_DIR, f));
}
}
assert.deepStrictEqual(
missing,
[],
`These workflow files use gsd_run but are missing the hermes runtime home probe ("${HERMES_PROBE}"). ` +
`Run \`node scripts/sync-runtime-launcher.cjs\` to propagate:\n` +
missing.join('\n'),
);
});
});

View File

@@ -1,205 +0,0 @@
/**
* Regression test for bug #925
*
* hooks/gsd-context-monitor.js hardcodes `hookEventName: "PostToolUse"` (or
* "AfterTool" for Gemini) regardless of which hook event invoked it. Since
* PR #821 the same script is also registered under Stop, SubagentStop, and
* PreCompact in hooks/hooks.json. Claude Code rejects output whose
* hookSpecificOutput.hookEventName doesn't echo the triggering event:
*
* "expected Stop but got PostToolUse"
*
* Fix: derive hookEventName from the parsed stdin payload's `hook_event_name`
* field (already available in the data object), falling back to the
* Gemini / non-Gemini heuristic for runtimes that don't send it.
*/
'use strict';
const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { execFileSync } = require('node:child_process');
const MONITOR_PATH = path.join(__dirname, '..', 'hooks', 'gsd-context-monitor.js');
/**
* Write a bridge metrics file and invoke the context monitor with the given
* payload fields. Returns the parsed stdout object (or null if the hook
* produced no output).
*
* remainingPct must be <= 35 to cross the WARNING threshold so the hook
* actually emits output.
*/
function runMonitor({ hookEventName, sessionId, remainingPct = 30, usedPct = 70, env = {} }) {
const bridgePath = path.join(os.tmpdir(), `claude-ctx-${sessionId}.json`);
fs.writeFileSync(bridgePath, JSON.stringify({
session_id: sessionId,
remaining_percentage: remainingPct,
used_pct: usedPct,
timestamp: Math.floor(Date.now() / 1000),
}));
const payload = { session_id: sessionId, cwd: os.tmpdir() };
if (hookEventName !== undefined) {
payload.hook_event_name = hookEventName;
}
let stdout = '';
try {
stdout = execFileSync(process.execPath, [MONITOR_PATH], {
input: JSON.stringify(payload),
encoding: 'utf-8',
timeout: 5000,
env: { ...process.env, ...env },
});
} catch (e) {
stdout = e.stdout || '';
} finally {
try { fs.unlinkSync(bridgePath); } catch { /* noop */ }
try {
fs.unlinkSync(path.join(os.tmpdir(), `claude-ctx-${sessionId}-warned.json`));
} catch { /* noop */ }
}
if (!stdout) return null;
return JSON.parse(stdout);
}
function makeSessionId(suffix) {
return `test-925-${suffix}-${Date.now()}-${Math.random().toString(36).slice(2)}`;
}
// ─── hookEventName echoing ────────────────────────────────────────────────────
describe('bug #925: context monitor echoes the invoking hook event name', () => {
test('hookEventName is "Stop" when payload contains hook_event_name: "Stop"', () => {
const out = runMonitor({ hookEventName: 'Stop', sessionId: makeSessionId('stop') });
assert.ok(out, 'hook must emit output when context is below WARNING threshold (remaining=30)');
assert.strictEqual(
out.hookSpecificOutput?.hookEventName,
'Stop',
`Expected hookEventName "Stop" but got "${out.hookSpecificOutput?.hookEventName}". ` +
'The hook must echo the hook_event_name from stdin, not hardcode "PostToolUse".'
);
});
test('hookEventName is "SubagentStop" when payload contains hook_event_name: "SubagentStop"', () => {
const out = runMonitor({ hookEventName: 'SubagentStop', sessionId: makeSessionId('subagent-stop') });
assert.ok(out, 'hook must emit output when context is below WARNING threshold');
assert.strictEqual(
out.hookSpecificOutput?.hookEventName,
'SubagentStop',
`Expected hookEventName "SubagentStop" but got "${out.hookSpecificOutput?.hookEventName}".`
);
});
test('hookEventName is "PreCompact" when payload contains hook_event_name: "PreCompact"', () => {
const out = runMonitor({ hookEventName: 'PreCompact', sessionId: makeSessionId('precompact') });
assert.ok(out, 'hook must emit output when context is below WARNING threshold');
assert.strictEqual(
out.hookSpecificOutput?.hookEventName,
'PreCompact',
`Expected hookEventName "PreCompact" but got "${out.hookSpecificOutput?.hookEventName}".`
);
});
test('hookEventName is "PostToolUse" when payload contains hook_event_name: "PostToolUse"', () => {
const out = runMonitor({ hookEventName: 'PostToolUse', sessionId: makeSessionId('posttools') });
assert.ok(out, 'hook must emit output when context is below WARNING threshold');
assert.strictEqual(
out.hookSpecificOutput?.hookEventName,
'PostToolUse',
`Expected hookEventName "PostToolUse" but got "${out.hookSpecificOutput?.hookEventName}".`
);
});
});
// ─── Fallback behaviour (no hook_event_name in payload) ──────────────────────
describe('bug #925: context monitor falls back to heuristic when hook_event_name absent', () => {
test('falls back to "PostToolUse" when hook_event_name is absent (non-Gemini)', () => {
const env = { ...process.env };
delete env.GEMINI_API_KEY;
const out = runMonitor({
hookEventName: undefined,
sessionId: makeSessionId('fallback-non-gemini'),
env: { GEMINI_API_KEY: '' }, // ensure unset
});
assert.ok(out, 'hook must emit output when context is below WARNING threshold');
assert.strictEqual(
out.hookSpecificOutput?.hookEventName,
'PostToolUse',
`Expected fallback "PostToolUse" for non-Gemini but got "${out.hookSpecificOutput?.hookEventName}".`
);
});
test('falls back to "AfterTool" when hook_event_name is absent and GEMINI_API_KEY is set', () => {
const out = runMonitor({
hookEventName: undefined,
sessionId: makeSessionId('fallback-gemini'),
env: { GEMINI_API_KEY: 'fake-key-for-test' },
});
assert.ok(out, 'hook must emit output when context is below WARNING threshold');
assert.strictEqual(
out.hookSpecificOutput?.hookEventName,
'AfterTool',
`Expected fallback "AfterTool" for Gemini but got "${out.hookSpecificOutput?.hookEventName}".`
);
});
test('falls back to "PostToolUse" when hook_event_name is an empty string (non-Gemini)', () => {
const out = runMonitor({
hookEventName: '',
sessionId: makeSessionId('fallback-empty'),
env: { GEMINI_API_KEY: '' },
});
assert.ok(out, 'hook must emit output when context is below WARNING threshold');
assert.strictEqual(
out.hookSpecificOutput?.hookEventName,
'PostToolUse',
`Expected fallback "PostToolUse" for empty hook_event_name but got "${out.hookSpecificOutput?.hookEventName}".`
);
});
test('falls back to "PostToolUse" when hook_event_name is whitespace-only (non-Gemini)', () => {
// trim() makes " " → "" which is falsy, so the || fallback fires
const out = runMonitor({
hookEventName: ' ',
sessionId: makeSessionId('fallback-whitespace'),
env: { GEMINI_API_KEY: '' },
});
assert.ok(out, 'hook must emit output when context is below WARNING threshold');
assert.strictEqual(
out.hookSpecificOutput?.hookEventName,
'PostToolUse',
`Expected fallback "PostToolUse" for whitespace-only hook_event_name but got "${out.hookSpecificOutput?.hookEventName}".`
);
});
});
// ─── Critical threshold also echoes the event name ───────────────────────────
describe('bug #925: critical threshold warning also uses correct hookEventName', () => {
test('CRITICAL warning emitted under Stop also echoes "Stop"', () => {
const out = runMonitor({
hookEventName: 'Stop',
sessionId: makeSessionId('critical-stop'),
remainingPct: 20,
usedPct: 80,
});
assert.ok(out, 'hook must emit output at critical threshold (remaining=20)');
assert.strictEqual(
out.hookSpecificOutput?.hookEventName,
'Stop',
`Expected hookEventName "Stop" at critical threshold, got "${out.hookSpecificOutput?.hookEventName}".`
);
assert.match(
out.hookSpecificOutput?.additionalContext || '',
/CONTEXT CRITICAL/,
'Output should be a CRITICAL warning at remaining=20'
);
});
});

View File

@@ -1,247 +0,0 @@
/**
* Regression test for bug #941
*
* `managed-hooks-registry.cjs` is shipped alongside gsd-check-update-worker.js
* in hooks/dist/ (it is listed in HOOKS_TO_COPY in scripts/build-hooks.js).
* However, the manifest-writing loop in bin/install.js gated on
* file.startsWith('gsd-') && (file.endsWith('.js') || file.endsWith('.sh'))
* — which `managed-hooks-registry.cjs` fails on both predicates (wrong prefix,
* .cjs extension). The result: after every install, `detect-custom-files`
* found the installed file in the hooks/ dir but had no manifest entry for it
* and reported a perpetual false-positive "Found 1 custom file(s)" warning on
* every `/gsd-update`.
*
* Fix: drive the manifest hooks loop from HOOKS_TO_COPY (the canonical build
* set), so the manifest set is structurally identical to what was installed.
*
* Closes: #941
*/
'use strict';
process.env.GSD_TEST_MODE = '1';
const { describe, test, before, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const os = require('node:os');
const { execFileSync } = require('node:child_process');
const crypto = require('node:crypto');
const INSTALL_SCRIPT = path.join(__dirname, '..', 'bin', 'install.js');
const BUILD_SCRIPT = path.join(__dirname, '..', 'scripts', 'build-hooks.js');
const TOOLS_PATH = path.join(__dirname, '..', 'gsd-core', 'bin', 'gsd-tools.cjs');
const MANIFEST_NAME = 'gsd-file-manifest.json';
const { HOOKS_TO_COPY } = require('../scripts/build-hooks.js');
// ─── Ensure hooks/dist/ is populated before any install test ────────────────
before(() => {
execFileSync(process.execPath, [BUILD_SCRIPT], {
encoding: 'utf-8',
stdio: 'pipe',
});
});
// ─── Helpers ─────────────────────────────────────────────────────────────────
function createTempDir(prefix) {
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
}
function cleanup(dir) {
// eslint-disable-next-line local/no-raw-rmsync-in-tests -- local cleanup helper, swallows ENOENT
try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
}
/**
* Run the installer targeting a temp directory as the claude global config dir.
* Returns the path to configDir.
*/
function runInstaller(configDir) {
// Clear GSD_TEST_MODE so the installer's main() block actually runs.
// The test file sets GSD_TEST_MODE=1 (top of file) to suppress in-process
// import side effects, but when install.js is spawned as a subprocess it
// must not skip the main() gate or the install is a no-op.
const env = { ...process.env, CLAUDE_CONFIG_DIR: configDir };
delete env.GSD_TEST_MODE;
execFileSync(process.execPath, [INSTALL_SCRIPT, '--claude', '--global', '--yes'], {
encoding: 'utf-8',
stdio: 'pipe',
env,
});
return configDir;
}
/**
* Run detect-custom-files and return parsed JSON output.
*/
function detectCustomFiles(configDir) {
const result = execFileSync(process.execPath, [TOOLS_PATH, 'detect-custom-files', '--config-dir', configDir], {
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe'],
env: { ...process.env, GSD_SESSION_KEY: '' },
});
return JSON.parse(result.trim());
}
// ─── Tests ────────────────────────────────────────────────────────────────────
describe('bug #941 — managed-hooks-registry.cjs recorded in file manifest', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempDir('gsd-bug-941-');
});
afterEach(() => {
cleanup(tmpDir);
});
test('managed-hooks-registry.cjs appears in gsd-file-manifest.json after install', () => {
runInstaller(tmpDir);
const manifestPath = path.join(tmpDir, MANIFEST_NAME);
assert.ok(
fs.existsSync(manifestPath),
`${MANIFEST_NAME} must exist after install (not found at ${manifestPath})`,
);
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
assert.ok(
typeof manifest.files === 'object' && manifest.files !== null,
'manifest must have a files map',
);
// The key must use forward slashes (cross-platform manifest format)
const key = 'hooks/managed-hooks-registry.cjs';
assert.ok(
Object.prototype.hasOwnProperty.call(manifest.files, key),
[
`manifest.files must contain '${key}' — managed-hooks-registry.cjs is`,
'shipped to users but was not recorded in the manifest, causing',
`detect-custom-files to flag it as a perpetual false-positive custom file.`,
`Actual manifest hook keys: ${Object.keys(manifest.files).filter(k => k.startsWith('hooks/')).join(', ')}`,
].join(' '),
);
});
test('gsd-file-manifest.json covers the full HOOKS_TO_COPY set (forward-proof)', () => {
runInstaller(tmpDir);
const manifestPath = path.join(tmpDir, MANIFEST_NAME);
assert.ok(fs.existsSync(manifestPath), `${MANIFEST_NAME} must exist after install`);
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
const hooksDir = path.join(tmpDir, 'hooks');
// Every hook in HOOKS_TO_COPY that was actually installed must have a
// manifest entry. This assertion is forward-proof: adding any new hook to
// HOOKS_TO_COPY without updating the manifest loop will fail this test.
for (const hook of HOOKS_TO_COPY) {
const installed = path.join(hooksDir, hook);
if (!fs.existsSync(installed)) {
// Skip hooks that weren't installed (e.g. .sh hooks on non-unix skip
// chmod but still install — only skip if truly absent).
continue;
}
const key = `hooks/${hook}`;
assert.ok(
Object.prototype.hasOwnProperty.call(manifest.files, key),
[
`manifest.files must contain '${key}'.`,
`HOOKS_TO_COPY lists '${hook}' and it was installed, but the manifest`,
`loop in writeManifest() did not record it.`,
`Actual manifest hook keys: ${Object.keys(manifest.files).filter(k => k.startsWith('hooks/')).join(', ')}`,
].join(' '),
);
}
});
test('detect-custom-files reports zero custom files after a clean install (no false positives)', () => {
runInstaller(tmpDir);
let detected;
try {
detected = detectCustomFiles(tmpDir);
} catch (err) {
assert.fail(
`detect-custom-files failed: ${err.message}\nstderr: ${err.stderr || '(none)'}`,
);
}
assert.ok(
detected.manifest_found,
'detect-custom-files must find the manifest after install',
);
const hookCustomFiles = (detected.custom_files || []).filter(f => f.startsWith('hooks/'));
assert.strictEqual(
hookCustomFiles.length,
0,
[
`detect-custom-files must report 0 custom hook files after a clean install, but got ${hookCustomFiles.length}:`,
JSON.stringify(hookCustomFiles, null, 2),
'This is the perpetual false-positive bug #941 — hooks in HOOKS_TO_COPY that',
'were not recorded in the manifest appear as custom files.',
].join('\n'),
);
});
test('manifest hook keys use forward slashes (cross-platform compatibility)', () => {
runInstaller(tmpDir);
const manifest = JSON.parse(fs.readFileSync(path.join(tmpDir, MANIFEST_NAME), 'utf-8'));
const hookKeys = Object.keys(manifest.files).filter(k => k.startsWith('hooks/'));
assert.ok(hookKeys.length > 0, 'manifest must contain at least one hooks/ entry');
for (const key of hookKeys) {
assert.ok(
!key.includes('\\'),
`manifest key '${key}' must use forward slashes, not backslashes`,
);
}
});
test('manifest hash for managed-hooks-registry.cjs matches the installed file contents', () => {
// Strengthened assertion: proves the manifest not only records the right KEY
// but stores a hash that matches the ACTUAL installed file bytes. A future
// refactor that records the key from the wrong path/content would fail here
// even if the key is present.
runInstaller(tmpDir);
const manifestPath = path.join(tmpDir, MANIFEST_NAME);
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
const key = 'hooks/managed-hooks-registry.cjs';
assert.ok(
Object.prototype.hasOwnProperty.call(manifest.files, key),
`manifest.files must contain '${key}' before hash comparison`,
);
// Recompute the hash the same way the installer's fileHash() does:
// sha256 of the raw file bytes as a hex string.
const installedPath = path.join(tmpDir, 'hooks', 'managed-hooks-registry.cjs');
assert.ok(
fs.existsSync(installedPath),
`installed file must exist at ${installedPath}`,
);
const actualHash = crypto
.createHash('sha256')
.update(fs.readFileSync(installedPath))
.digest('hex');
assert.strictEqual(
manifest.files[key],
actualHash,
[
`manifest hash for '${key}' does not match the installed file's actual contents.`,
`This means writeManifest() hashed the wrong path or wrong content.`,
`Expected (from installed file): ${actualHash}`,
`Got (from manifest): ${manifest.files[key]}`,
].join('\n'),
);
});
});

View File

@@ -1,496 +0,0 @@
'use strict';
/**
* Regression tests for bug #969 — test-infra flake hardening.
*
* Two root causes addressed:
*
* A. SIGNATURE A: "X is not a function"
* ensureBuiltArtifacts() previously short-circuited on a single sentinel
* (semver-compare.cjs). If any other migrated .cjs was stale or absent,
* it would be silently loaded in that broken state. This test proves the
* unconditional-build fix: deleting a non-sentinel artifact and invoking
* ensureBuiltArtifacts() regenerates it even when the sentinel is present.
*
* B. SIGNATURE B: misleading assertion failures from killed subprocesses
* runGsdTools() previously had no timeout, so an OOM/SIGKILL'd subprocess
* returned { success: false } and looked like a product error. This test
* proves the kill-discrimination fix: a killed/timed-out invocation now
* throws a labeled resource-starvation error, while a clean non-zero exit
* still returns { success: false, exitCode: N }.
*
* C. SIGNATURE C: "Failed to install hooks: directory is empty" in scoped CI
* hooks/dist is gitignored and NOT built by `prepare` (build:lib only), so
* the scoped test lane starts with it absent. The first install test's
* before() hook triggers build-hooks.js, which creates DIST_DIR empty then
* fills it file-by-file — a window where a concurrently-spawned install
* reader sees zero hooks and hard-fails. ensureBuiltHooks() builds hooks/dist
* ONCE upfront (same chokepoint as ensureBuiltArtifacts) so the empty window
* never exists during concurrent test execution. These tests prove it
* rebuilds when dist is absent/empty/incomplete and no-ops when complete.
*
* RULESET.TESTS.regression-must-fail-first: each test section documents what
* the old behavior would have been (fail-before) and asserts the new behavior
* (pass-after), using only behavioral invocations — no source-grep.
*/
const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
const path = require('node:path');
const fs = require('node:fs');
const os = require('node:os');
const { execFileSync } = require('node:child_process');
const { ensureBuiltArtifacts, ensureBuiltHooks } = require('../scripts/run-tests.cjs');
const { cleanup } = require('./helpers.cjs');
// ---------------------------------------------------------------------------
// Part A — ensureBuiltArtifacts: unconditional rebuild
// ---------------------------------------------------------------------------
describe('bug #969 A — ensureBuiltArtifacts rebuilds stale artifacts', () => {
/**
* Helper: create a self-contained temp TypeScript project with two source files
* (sentinelmod.cts and targetmod.cts) and a tsconfig that emits to <tmp>/out.
* Returns { tmp, overrides, sentinelOut, targetOut, tsBuildInfoPath }.
*
* HERMETIC: all destructive tests use this helper. They NEVER touch the real
* gsd-core/bin/lib/*.cjs or the real tsbuildinfo. (Regression from #996 fixed here.)
*/
function makeTempProject() {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-bug969-'));
const srcDir = path.join(tmp, 'src');
const outDir = path.join(tmp, 'out');
const tsBuildInfoPath = path.join(outDir, '.tsbuildinfo');
const tsconfigPath = path.join(tmp, 'tsconfig.build.json');
fs.mkdirSync(srcDir, { recursive: true });
fs.mkdirSync(outDir, { recursive: true });
fs.writeFileSync(path.join(srcDir, 'sentinelmod.cts'), 'export const sentinelValue = 1;\n');
fs.writeFileSync(path.join(srcDir, 'targetmod.cts'), 'export const targetValue = 2;\n');
fs.writeFileSync(tsconfigPath, JSON.stringify({
compilerOptions: {
rootDir: 'src',
outDir: 'out',
module: 'commonjs',
target: 'es2022',
esModuleInterop: true,
noEmitOnError: true,
incremental: true,
tsBuildInfoFile: 'out/.tsbuildinfo',
},
include: ['src/**/*.cts'],
}, null, 2));
const overrides = { root: tmp, srcDir, outDir, tsBuildInfoPath, tsconfigPath };
const sentinelOut = path.join(outDir, 'sentinelmod.cjs');
const targetOut = path.join(outDir, 'targetmod.cjs');
return { tmp, overrides, sentinelOut, targetOut, tsBuildInfoPath };
}
/**
* FAIL-BEFORE (origin/next behavior):
* The old code contained `if (existsSync(sentinel)) return;`. When the
* sentinel (semver-compare.cjs) was present, the function returned early
* without touching any other .cjs. This test confirms the new code always
* invokes tsc — it would have returned immediately on origin/next.
*
* Specifically: on origin/next, after deleting a non-sentinel artifact +
* its tsbuildinfo and calling ensureBuiltArtifacts() with sentinel present,
* the artifact would remain absent. On the fix, tsc runs unconditionally
* and recreates it.
*
* PASS-AFTER (fix):
* The sentinel guard is removed. ensureBuiltArtifacts() always invokes tsc.
* With no tsbuildinfo present (clean state), tsc performs a full emit and
* recreates all .cjs outputs including the deleted non-sentinel artifact.
*
* HERMETIC: this test operates on a self-contained temp project. It NEVER
* touches gsd-core/bin/lib/core.cjs or the real tsbuildinfo. (Fixed from #996.)
*/
test('rebuilds a non-sentinel artifact (with no tsbuildinfo) even when sentinel exists', () => {
const { tmp, overrides, sentinelOut, targetOut, tsBuildInfoPath } = makeTempProject();
try {
// Initial build — both outputs must appear.
ensureBuiltArtifacts(overrides);
assert.ok(fs.existsSync(sentinelOut), 'initial build: sentinelmod.cjs must exist');
assert.ok(fs.existsSync(targetOut), 'initial build: targetmod.cjs must exist');
// Simulate: fresh CI checkout — target artifact missing, no tsbuildinfo.
fs.unlinkSync(targetOut);
if (fs.existsSync(tsBuildInfoPath)) fs.unlinkSync(tsBuildInfoPath);
assert.ok(!fs.existsSync(targetOut), 'pre-condition: targetmod.cjs must be absent');
assert.ok(fs.existsSync(sentinelOut), 'pre-condition: sentinelmod.cjs must still be present');
// Under the OLD code this returned immediately (sentinel present → return).
// Under the NEW code this calls tsc unconditionally → full emit → recreated.
ensureBuiltArtifacts(overrides);
assert.ok(
fs.existsSync(targetOut),
'ensureBuiltArtifacts must recreate targetmod.cjs even when sentinelmod.cjs ' +
'exists (sentinel-short-circuit was removed in fix #969)'
);
} finally {
cleanup(tmp);
}
});
/**
* PASS-AFTER: the unconditional build emits the expected output (sentinelmod.cjs).
* Uses the temp project helper so this test is fully hermetic — it never touches
* the real gsd-core/bin/lib tree.
*/
test('sentinel (semver-compare.cjs) still exists after unconditional build', () => {
const { tmp, overrides, sentinelOut } = makeTempProject();
try {
ensureBuiltArtifacts(overrides);
assert.ok(fs.existsSync(sentinelOut), 'sentinel output (sentinelmod.cjs) must exist after ensureBuiltArtifacts');
} finally {
cleanup(tmp);
}
});
/**
* PERSISTENT-MIRROR CASE — the residual hole found by adversarial review.
*
* FAIL-BEFORE (incremental: true — the old behavior on this branch):
* With "incremental": true in tsconfig.build.json, tsc reads the .tsbuildinfo
* on disk. If sources are unchanged since the last build, tsc skips re-emitting
* any outputs — including outputs that were deleted or overwritten by an rsync
* from a different branch. This is the persistent-docker-mirror scenario:
* 1. A prior branch rsync'd a stale core.cjs into bin/lib/
* 2. A stale tsbuildinfo is present (from that same branch)
* 3. ensureBuiltArtifacts() calls tsc (incremental)
* 4. tsc sees "sources unchanged vs tsbuildinfo" → no-ops → stale .cjs served
* With "incremental": true this test would FAIL because targetmod.cjs remains absent.
*
* PASS-AFTER (step-3 unlink+clean-reemit logic):
* When a missing/zero-bytes output is detected after the incremental pass,
* ensureBuiltArtifacts() unlinks the tsbuildinfo and runs tsc a second time
* (clean re-emit). The stale/missing output is always regenerated.
*
* HERMETIC: this test operates on a self-contained temp project. It NEVER
* touches gsd-core/bin/lib/core.cjs or the real tsbuildinfo. (Fixed from #996.)
*/
test('PERSISTENT-MIRROR: rebuilds stale output even when tsbuildinfo is present (non-incremental is authoritative)', () => {
const { tmp, overrides, targetOut, tsBuildInfoPath } = makeTempProject();
const STALE_TSBUILDINFO = JSON.stringify({
program: { fileNames: [], options: { incremental: true } },
version: '5.0.0',
_gsd_test_marker: 'stale-persistent-mirror',
});
try {
// Initial build to populate outputs.
ensureBuiltArtifacts(overrides);
assert.ok(fs.existsSync(targetOut), 'initial build: targetmod.cjs must exist');
// Inject a stale tsbuildinfo (mirrors: old branch rsync'd state onto workspace).
fs.writeFileSync(tsBuildInfoPath, STALE_TSBUILDINFO);
// Delete the output .cjs (mirrors: stale/missing output on the persistent mirror).
fs.unlinkSync(targetOut);
assert.ok(!fs.existsSync(targetOut), 'pre-condition: targetmod.cjs must be absent');
assert.ok(fs.existsSync(tsBuildInfoPath), 'pre-condition: tsbuildinfo must be present');
// FAIL-BEFORE (incremental: true, no step-3): tsc would read the stale
// tsbuildinfo, see "sources unchanged", and skip re-emitting targetmod.cjs
// → it would remain absent.
//
// PASS-AFTER (step-3 unlink+clean-reemit): missing output detected after
// incremental pass → tsbuildinfo unlinked → tsc runs again → targetmod.cjs
// is regenerated unconditionally.
ensureBuiltArtifacts(overrides);
assert.ok(
fs.existsSync(targetOut),
'ensureBuiltArtifacts must regenerate targetmod.cjs even when a stale ' +
'tsbuildinfo is present on disk (persistent-mirror scenario — ' +
'incremental:true alone would have no-op\'d here)'
);
// Verify the regenerated file is valid JS.
const regenerated = fs.readFileSync(targetOut, 'utf-8');
assert.ok(regenerated.length > 0, 'regenerated targetmod.cjs must be non-empty');
assert.ok(
regenerated.includes('exports.') || regenerated.includes('"use strict"'),
'regenerated targetmod.cjs must look like a valid CommonJS module'
);
} finally {
cleanup(tmp);
}
});
});
// ---------------------------------------------------------------------------
// Part B — runGsdTools: timeout + kill-signal discrimination
// ---------------------------------------------------------------------------
describe('bug #969 B — runGsdTools kill-signal discrimination', () => {
const TOOLS_PATH = path.join(__dirname, '..', 'gsd-core', 'bin', 'gsd-tools.cjs');
/**
* Shared helper that mirrors the production runGsdTools implementation
* (from tests/helpers.cjs) but accepts an explicit timeout so we can
* trigger the kill path in tests without waiting 60 seconds.
*
* IMPORTANT: this helper is intentionally self-contained so that the test
* proves the CONTRACT of the implementation, not just calls the real
* runGsdTools (which would need a real 60s+ hang to trigger in tests).
* We test the identical logic paths using a tiny timeout.
*/
function runGsdToolsWithTimeout(args, cwd, env, timeoutMs) {
const TEST_ENV_BASE = {
GSD_SESSION_KEY: '',
CODEX_THREAD_ID: '',
CLAUDE_SESSION_ID: '',
};
try {
let result;
const childEnv = { ...process.env, ...TEST_ENV_BASE, ...(env || {}) };
const argv = Array.isArray(args)
? args
: (args.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) || [])
.map(t => t.replace(/"([^"]*)"/g, '$1').replace(/'([^']*)'/g, '$1'));
result = execFileSync(process.execPath, [TOOLS_PATH, ...argv], {
cwd: cwd || process.cwd(),
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe'],
env: childEnv,
timeout: timeoutMs,
});
return { success: true, output: result.trim(), exitCode: 0 };
} catch (err) {
// Production kill-discrimination logic (verbatim from helpers.cjs fix).
if (err.killed || err.signal != null || err.code === 'ETIMEDOUT') {
throw new Error(
`[runGsdTools: resource-starvation / subprocess-kill] ` +
`gsd-tools was killed before completion ` +
`(signal=${err.signal}, code=${err.code}, killed=${err.killed}). ` +
`This indicates host OOM or scheduler contention, not a product bug. ` +
`stdout=${err.stdout?.toString().trim() || ''} ` +
`stderr=${err.stderr?.toString().trim() || ''}`
);
}
const stderrRaw = err.stderr?.toString().trim() || '';
const error = stderrRaw || `${err.message} [stderr: (empty) exit:${err.status ?? 1}]`;
return {
success: false,
output: err.stdout?.toString().trim() || '',
error,
exitCode: err.status ?? 1,
};
}
}
/**
* FAIL-BEFORE (origin/next behavior):
* Without a timeout, an OOM-killed subprocess threw with err.killed=true
* but the catch block fell through to `return { success: false, ... }`.
* The test consumer saw a normal {success:false} result and tried to parse
* gsd-tools output from it, causing a confusing downstream assertion fail.
*
* PASS-AFTER (fix):
* The kill-discrimination guard rethrows immediately with a labeled error
* message containing "resource-starvation / subprocess-kill". The test
* asserts on that throw rather than getting a silent {success:false}.
*
* Mechanism: we use a tiny timeout (1ms) to guarantee a timeout-kill on a
* real gsd-tools invocation (even `--help` takes >1ms to start node).
*/
test('throws a resource-starvation error when subprocess is killed/times out', () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-969-'));
try {
// 1ms timeout guarantees ETIMEDOUT / killed before gsd-tools can respond.
assert.throws(
() => runGsdToolsWithTimeout(['--help'], tmpDir, {}, 1),
(err) => {
assert.ok(
err.message.includes('resource-starvation / subprocess-kill'),
`Expected labeled resource-starvation error, got: ${err.message}`
);
return true;
}
);
} finally {
cleanup(tmpDir);
}
});
/**
* Verify that a normal fast command still returns { success: true } and does
* NOT throw — i.e., the timeout addition does not break the happy path.
*/
test('returns { success: true } for a normal fast command with generous timeout', () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-969-'));
try {
// 30s timeout; gsd-tools --help completes in well under 1s.
const result = runGsdToolsWithTimeout(['--help'], tmpDir, {}, 30000);
assert.ok(result.success === true, `Expected success:true, got ${JSON.stringify(result)}`);
assert.ok(typeof result.output === 'string', 'output must be a string');
} finally {
cleanup(tmpDir);
}
});
/**
* Verify that a clean non-zero exit (a real gsd-tools application error, not
* a kill) still returns { success: false } WITHOUT throwing. This preserves
* existing test behavior that asserts on error shape.
*
* We trigger a clean non-zero by invoking a command that is known to fail
* cleanly (no project directory set up).
*/
test('returns { success: false } for a clean non-zero exit (no throw)', () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-969-'));
try {
// 'phase list' on a directory with no .planning/ produces a clean error exit.
const result = runGsdToolsWithTimeout(['phase', 'list'], tmpDir, {}, 30000);
assert.ok(result.success === false, `Expected success:false for clean error, got ${JSON.stringify(result)}`);
assert.ok(result.exitCode !== 0, 'exitCode must be non-zero');
// Must NOT have thrown — the clean-error path returns normally.
} finally {
cleanup(tmpDir);
}
});
});
// ---------------------------------------------------------------------------
// Part C — ensureBuiltHooks: build hooks/dist once, closing the scoped-CI
// first-build empty-dir race.
// ---------------------------------------------------------------------------
describe('bug #969 C — ensureBuiltHooks populates hooks/dist before concurrent tests', () => {
/**
* Helper: a hermetic temp dist dir + a runBuild spy. The spy records how many
* times a build was requested and, when invoked, writes the given hook files
* (simulating build-hooks.js populating DIST_DIR) so idempotency is testable.
*
* HERMETIC: never touches the real hooks/dist. Uses dependency-injected
* overrides (distDir, hookNames, runBuild) — no fs monkeypatching, so the test
* is deterministic and root/OS-independent.
*/
function makeHooksFixture(hookNames) {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-969-hooks-'));
const distDir = path.join(tmp, 'hooks', 'dist');
let buildCalls = 0;
const runBuild = () => {
buildCalls += 1;
fs.mkdirSync(distDir, { recursive: true });
for (const h of hookNames) {
fs.writeFileSync(path.join(distDir, h), `// ${h}\nmodule.exports = {};\n`);
}
};
const overrides = () => ({ distDir, hookNames, runBuild });
return { tmp, distDir, hookNames, overrides, calls: () => buildCalls };
}
const HOOKS = ['a-hook.js', 'b-hook.js', 'c-hook.sh'];
/**
* FAIL-BEFORE (origin/next): ensureBuiltHooks did not exist, so the export was
* undefined and there was no upfront hooks build — the first concurrent
* install test raced build-hooks.js's empty-then-fill window. The import at the
* top of this file (ensureBuiltHooks) is itself the fail-first anchor: on
* origin/next it is undefined and every test below throws "not a function".
*
* PASS-AFTER: ensureBuiltHooks() builds when hooks/dist is entirely absent.
*/
test('builds when hooks/dist is absent (fresh checkout / scoped CI)', () => {
const fx = makeHooksFixture(HOOKS);
try {
assert.equal(typeof ensureBuiltHooks, 'function',
'ensureBuiltHooks must be exported (absent on origin/next — the fail-first anchor)');
assert.ok(!fs.existsSync(fx.distDir), 'pre-condition: hooks/dist must be absent');
ensureBuiltHooks(fx.overrides());
assert.equal(fx.calls(), 1, 'a build must be triggered when dist is absent');
for (const h of HOOKS) {
assert.ok(fs.existsSync(path.join(fx.distDir, h)), `${h} must exist after build`);
}
} finally {
cleanup(fx.tmp);
}
});
/**
* BOUNDARY: dist exists but is empty (0 of N hooks) — the exact transient state
* build-hooks.js exposes between mkdir(DIST_DIR) and the first file rename.
*/
test('builds when hooks/dist exists but is empty (0 of N)', () => {
const fx = makeHooksFixture(HOOKS);
try {
fs.mkdirSync(fx.distDir, { recursive: true }); // empty dir — the race window
ensureBuiltHooks(fx.overrides());
assert.equal(fx.calls(), 1, 'an empty dist must trigger a build');
} finally {
cleanup(fx.tmp);
}
});
/**
* BOUNDARY: dist has all-but-one hook (N-1 of N) — a partially-filled dir mid
* first-build. Must still be treated as incomplete and rebuilt.
*/
test('builds when hooks/dist is partial (N-1 of N)', () => {
const fx = makeHooksFixture(HOOKS);
try {
fs.mkdirSync(fx.distDir, { recursive: true });
for (const h of HOOKS.slice(0, HOOKS.length - 1)) {
fs.writeFileSync(path.join(fx.distDir, h), 'x');
}
ensureBuiltHooks(fx.overrides());
assert.equal(fx.calls(), 1, 'a partial dist (missing one hook) must trigger a build');
} finally {
cleanup(fx.tmp);
}
});
/**
* BOUNDARY: a zero-byte hook (N of N present, but one is 0 bytes) — a truncated
* mid-write file. statSync().size === 0 must count as incomplete → rebuild.
*/
test('builds when a hook file is present but zero-byte', () => {
const fx = makeHooksFixture(HOOKS);
try {
fs.mkdirSync(fx.distDir, { recursive: true });
HOOKS.forEach((h, i) => {
fs.writeFileSync(path.join(fx.distDir, h), i === 0 ? '' : 'ok'); // first is 0 bytes
});
ensureBuiltHooks(fx.overrides());
assert.equal(fx.calls(), 1, 'a zero-byte hook must be treated as incomplete → rebuild');
} finally {
cleanup(fx.tmp);
}
});
/**
* BOUNDARY + idempotency: dist is complete (all N present, non-empty). No build
* must fire — this keeps nested run-tests spawns and repeat invocations cheap
* and avoids a redundant concurrent build against an already-populated dist.
*/
test('no-op when hooks/dist is complete (N of N non-empty)', () => {
const fx = makeHooksFixture(HOOKS);
try {
fs.mkdirSync(fx.distDir, { recursive: true });
for (const h of HOOKS) fs.writeFileSync(path.join(fx.distDir, h), 'ok');
ensureBuiltHooks(fx.overrides());
assert.equal(fx.calls(), 0, 'a complete dist must NOT trigger a build (idempotent no-op)');
} finally {
cleanup(fx.tmp);
}
});
/**
* Integration guard: the REAL default hook set (from build-hooks.js) is what
* ensureBuiltHooks checks when no override is given. Prove the real export is a
* non-empty list so the completeness predicate can never vacuously pass.
*/
test('default hook set (build-hooks.js HOOKS_TO_COPY) is a non-empty list', () => {
const { HOOKS_TO_COPY } = require('../scripts/build-hooks.js');
assert.ok(Array.isArray(HOOKS_TO_COPY) && HOOKS_TO_COPY.length > 0,
'HOOKS_TO_COPY must be a non-empty array or the completeness check is vacuous');
});
});

View File

@@ -6428,3 +6428,468 @@ describe('enh-1055 descriptor-drive: finishPermissionWriter passthrough', () =>
});
});
}
// ────────────────────────────────────────────────────────────────────────
// Folded from tests/enh-1592-plan-drift-precheck.test.cjs — consolidation epic #1969 (B6 #1975)
// ────────────────────────────────────────────────────────────────────────
{
const { describe: __foldDescribe } = require('node:test');
__foldDescribe("folded:enh-1592-plan-drift-precheck (consolidation epic #1969 B6 #1975)", () => {
'use strict';
// allow-test-rule: source-text-is-the-product see #1592
// The plan-phase.md host-dispatch assertions below read the workflow .md file — its text IS the
// deployed contract the runtime loads (CONTRIBUTING.md exemption category). The registry assertions
// are behavioral: they build the registry from the REAL capabilities/drift declaration via the
// generator, so they fail if the plan:pre gate is ever removed or mutated.
/**
* Enhancement (#1592): plan-time codebase-map freshness pre-check.
*
* The `drift` capability gains a non-blocking `plan:pre` codebase-drift gate so a stale codebase map is
* flagged BEFORE planning, instead of being discovered mid-execution by the existing
* `execute:wave:post` codebase-drift gate. Warn-only at `plan:pre` (no mapper-agent spawn): the
* capability's `drift_action: auto-remap` stays at `execute:wave:post`, so plan time never pays
* speculative mapper-agent cost.
*
* Per maintainer review on #1592 (mod 1a), the plan:pre gate is gated on a DEDICATED
* `workflow.plan_drift_precheck` toggle (default true) rather than reusing `workflow.schema_drift_gate`,
* so autonomous/CI runs can silence the plan-time advisory without disabling the execute-time gates.
* The gate declaration conforms to ADR-857 (`plan:pre` is an enumerated, additive-only loop point).
*
* Issue: #1592 (open-gsd/gsd-core).
*/
const { describe, test, after } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { loadAndValidate, buildRegistry } = require('../scripts/gen-capability-registry.cjs');
const { cleanup } = require('./helpers.cjs');
const REPO_ROOT = path.join(__dirname, '..');
const DRIFT_CAP = JSON.parse(
fs.readFileSync(path.join(REPO_ROOT, 'capabilities', 'drift', 'capability.json'), 'utf8'),
);
const PLAN_PHASE = fs.readFileSync(
path.join(REPO_ROOT, 'gsd-core', 'workflows', 'plan-phase.md'),
'utf8',
);
// Track every temp dir created so the suite can remove them on teardown — leaked
// mkdtemp dirs have been a flake source here before (per #1592 review).
const tempCapDirs = [];
function makeTempCapDir(capabilities) {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'enh-1592-'));
tempCapDirs.push(tmpDir);
for (const [id, cap] of Object.entries(capabilities)) {
const subDir = path.join(tmpDir, id);
fs.mkdirSync(subDir, { recursive: true });
fs.writeFileSync(path.join(subDir, 'capability.json'), JSON.stringify(cap), 'utf8');
}
return tmpDir;
}
after(() => {
for (const dir of tempCapDirs) {
cleanup(dir);
}
});
function planPreDriftGate() {
const capDir = makeTempCapDir({ drift: DRIFT_CAP });
const { capMap, errors } = loadAndValidate(new Set(), capDir);
assert.deepEqual(errors, [], 'drift capability should validate cleanly: ' + JSON.stringify(errors));
const registry = buildRegistry(capMap);
const planPreGates = registry.byLoopPoint['plan:pre'].gates;
assert.ok(Array.isArray(planPreGates), 'plan:pre.gates should be an array');
return planPreGates.find(
(g) => g.capId === 'drift' && g.check && g.check.query === 'verify.codebase-drift',
);
}
describe('#1592 — drift plan:pre codebase-drift gate (registry, behavioral)', () => {
test('the real drift capability registers a non-blocking plan:pre codebase-drift gate', () => {
const driftGate = planPreDriftGate();
assert.ok(driftGate, 'plan:pre.gates must contain the drift codebase-drift gate');
assert.strictEqual(driftGate.blocking, false, 'plan-time drift gate must be NON-blocking');
assert.strictEqual(driftGate.onError, 'skip', 'must fail-soft (skip) — never halt planning');
});
test('the plan:pre gate is gated on the dedicated plan_drift_precheck toggle (mod 1a)', () => {
const driftGate = planPreDriftGate();
assert.strictEqual(
driftGate.when,
'workflow.plan_drift_precheck',
'plan:pre drift gate must use the dedicated toggle so CI/autonomous runs can silence it ' +
'without disabling the execute-time gates',
);
});
test('the execute:wave:post codebase-drift gate is preserved and keeps its OWN toggle (no regression)', () => {
const capDir = makeTempCapDir({ drift: DRIFT_CAP });
const { capMap } = loadAndValidate(new Set(), capDir);
const registry = buildRegistry(capMap);
const execGates = registry.byLoopPoint['execute:wave:post'].gates;
const stillThere = execGates.find(
(g) => g.capId === 'drift' && g.check && g.check.query === 'verify.codebase-drift',
);
assert.ok(stillThere, 'execute:wave:post codebase-drift gate must remain after adding the plan:pre gate');
assert.strictEqual(stillThere.blocking, false, 'execute codebase-drift gate stays non-blocking');
assert.strictEqual(
stillThere.when,
'workflow.schema_drift_gate',
'the execute-time gate keeps schema_drift_gate — the plan-time toggle is separable from it',
);
});
test('plan_drift_precheck is a separate toggle from schema_drift_gate (silencing is independent)', () => {
const planWhen = planPreDriftGate().when;
assert.notStrictEqual(
planWhen,
'workflow.schema_drift_gate',
'silencing the plan-time advisory must not require disabling the execute-time gates',
);
});
test('plan_drift_precheck is declared as a boolean defaulting to true', () => {
const cfg = DRIFT_CAP.config['workflow.plan_drift_precheck'];
assert.ok(cfg, 'workflow.plan_drift_precheck must be declared in the drift capability config');
assert.strictEqual(cfg.type, 'boolean', 'plan_drift_precheck must be a boolean');
assert.strictEqual(cfg.default, true, 'plan_drift_precheck must default to true (on by default)');
});
test('exactly one new config key is introduced (the dedicated plan_drift_precheck toggle)', () => {
const keys = Object.keys(DRIFT_CAP.config).sort();
assert.deepStrictEqual(
keys,
[
'workflow.drift_action',
'workflow.drift_threshold',
'workflow.plan_drift_precheck',
'workflow.schema_drift_gate',
],
'the plan:pre gate adds exactly the dedicated plan_drift_precheck toggle — no other new keys',
);
});
});
describe('#1592 — plan-phase host dispatches the drift plan:pre gate before planning', () => {
const SECTION = PLAN_PHASE.slice(
PLAN_PHASE.indexOf('5.65. Codebase Map Freshness Pre-Check'),
PLAN_PHASE.indexOf('## 6. Check Existing Plans'),
);
test('§5.65 invokes the verify codebase-drift check', () => {
assert.match(PLAN_PHASE, /5\.65\. Codebase Map Freshness Pre-Check/, 'plan-phase must declare §5.65');
assert.match(PLAN_PHASE, /gsd_run verify codebase-drift/, '§5.65 must invoke `verify codebase-drift`');
});
test('the drift pre-check runs BEFORE the planner spawn (load-bearing ordering)', () => {
const preCheckIdx = PLAN_PHASE.indexOf('5.65. Codebase Map Freshness Pre-Check');
const plannerIdx = PLAN_PHASE.indexOf('## 8. Spawn gsd-planner Agent');
assert.ok(preCheckIdx > 0, '§5.65 must exist');
assert.ok(plannerIdx > 0, '§8 planner spawn must exist');
assert.ok(
preCheckIdx < plannerIdx,
'the drift map-freshness pre-check must run before the planner is spawned — the whole point of #1592',
);
});
test('§5.65 is documented as non-blocking and warn-only (no spawn)', () => {
assert.match(SECTION, /non-blocking/i, '§5.65 must state the gate is non-blocking');
assert.match(SECTION, /never blocks, never spawns/i, '§5.65 must state it never spawns the mapper at plan time');
});
test('§5.65 gates on the dedicated plan_drift_precheck toggle (mod 1a)', () => {
assert.match(
SECTION,
/workflow\.plan_drift_precheck/,
'§5.65 must dispatch on the dedicated plan_drift_precheck toggle, not schema_drift_gate',
);
});
});
});
}
// ────────────────────────────────────────────────────────────────────────
// Folded from tests/fix-1464-docs-manifest-validation.test.cjs — consolidation epic #1969 (B6 #1975)
// ────────────────────────────────────────────────────────────────────────
{
const { describe: __foldDescribe } = require('node:test');
__foldDescribe("folded:fix-1464-docs-manifest-validation (consolidation epic #1969 B6 #1975)", () => {
// allow-test-rule: source-text-is-the-product see #1464
// Tutorial docs are the product surface users follow. Reading JSON code blocks
// from them and validating through validateCapability is behavioral, not
// source-grep — it proves the manifests work, not just that they "mention" a term.
'use strict';
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { validateCapability } = require('../scripts/gen-capability-registry.cjs');
const ROOT = path.join(__dirname, '..');
// ─── Extractor ───────────────────────────────────────────────────────────────
// Required top-level fields that distinguish a complete capability manifest
// from a partial output snippet (list-entry, install-result, etc.).
// Partial output snippets have id+role but lack steps/contributions/gates/config.
const MANIFEST_REQUIRED_KEYS = new Set([
'id', 'role', 'title', 'description', 'tier',
'requires', 'runtimeCompat', 'skills', 'agents',
'config', 'steps', 'contributions', 'gates',
]);
/**
* Extract JSON code blocks from markdown that are complete capability manifests.
* A complete manifest has ALL keys in MANIFEST_REQUIRED_KEYS.
* Partial output snippets (list-entries, install-results) have only id+role and are skipped.
*/
function extractManifests(mdContent) {
const manifests = [];
const fenceRe = /```json\s*\r?\n([\s\S]*?)```/g;
let match;
while ((match = fenceRe.exec(mdContent)) !== null) {
let parsed;
try {
parsed = JSON.parse(match[1]);
} catch {
continue;
}
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
const keys = new Set(Object.keys(parsed));
if ([...MANIFEST_REQUIRED_KEYS].every((k) => keys.has(k))) {
manifests.push(parsed);
}
}
}
return manifests;
}
// ─── Suite 1: tutorial manifests validate ────────────────────────────────────
describe('docs tutorial manifests pass validateCapability (#1464 regression)', () => {
test('build-your-first-capability.md: every manifest passes', () => {
const content = fs.readFileSync(
path.join(ROOT, 'docs', 'tutorials', 'build-your-first-capability.md'),
'utf8',
);
const manifests = extractManifests(content);
assert.ok(
manifests.length > 0,
'expected at least one capability manifest in build tutorial',
);
for (const cap of manifests) {
const errors = validateCapability(cap, cap.id);
assert.deepStrictEqual(
errors,
[],
`build tutorial manifest id="${cap.id}" failed validateCapability:\n ${errors.join('\n ')}`,
);
}
});
test('install-your-first-capability.md: every manifest passes', () => {
const content = fs.readFileSync(
path.join(ROOT, 'docs', 'tutorials', 'install-your-first-capability.md'),
'utf8',
);
const manifests = extractManifests(content);
assert.ok(
manifests.length > 0,
'expected at least one capability manifest in install tutorial',
);
for (const cap of manifests) {
const errors = validateCapability(cap, cap.id);
assert.deepStrictEqual(
errors,
[],
`install tutorial manifest id="${cap.id}" failed validateCapability:\n ${errors.join('\n ')}`,
);
}
});
test('capability-manifest.md reference example passes', () => {
const content = fs.readFileSync(
path.join(ROOT, 'docs', 'reference', 'capability-manifest.md'),
'utf8',
);
const manifests = extractManifests(content);
assert.ok(
manifests.length > 0,
'expected at least one capability manifest in reference doc',
);
for (const cap of manifests) {
const errors = validateCapability(cap, cap.id);
assert.deepStrictEqual(
errors,
[],
`reference manifest id="${cap.id}" failed validateCapability:\n ${errors.join('\n ')}`,
);
}
});
});
// ─── Suite 2: adversarial — #1464 failure modes caught ───────────────────────
//
// These are the EXACT failure shapes from issue #1464.
// They must fail validateCapability — proving this test would have caught the bug.
describe('validateCapability catches original #1464 bug shapes', () => {
// #1464 high-1: step missing ref → validateStep rejects it
test('step without ref fails (the original broken tutorial step)', () => {
const cap = {
id: 'hello-note',
role: 'feature',
version: '0.1.0',
title: 'Hello Note',
description: 'Test fixture for #1464 regression.',
tier: 'standard',
requires: [],
engines: { gsd: '>=1.6.0' },
runtimeCompat: { supported: ['*'], unsupported: [] },
skills: [],
agents: [],
config: {},
steps: [
{
// Missing ref — this was the #1464 high-1 bug in the original tutorial
point: 'plan:pre',
produces: ['HELLO.md'],
consumes: [],
onError: 'skip',
},
],
contributions: [],
gates: [],
};
const errors = validateCapability(cap, 'hello-note');
assert.ok(errors.length > 0, 'expected validation errors for step without ref');
assert.ok(
errors.some((e) => /ref/.test(e)),
`expected an error mentioning "ref"; got: ${errors.join('; ')}`,
);
});
// #1464 shape: id must match folder name (folderId contract)
test('id not matching folderId fails', () => {
const cap = {
id: 'hello-note',
role: 'feature',
version: '0.1.0',
title: 'Hello Note',
description: 'Test fixture for id/folderId mismatch.',
tier: 'standard',
requires: [],
runtimeCompat: { supported: ['*'], unsupported: [] },
skills: [],
agents: [],
config: {},
steps: [],
contributions: [],
gates: [],
};
const errors = validateCapability(cap, 'wrong-folder');
assert.ok(errors.length > 0, 'expected id/folderId mismatch to fail validation');
assert.ok(
errors.some((e) => /folder/.test(e) || /equal/.test(e) || /id/.test(e)),
`expected error about id/folderId mismatch; got: ${errors.join('; ')}`,
);
});
// Corrected shape: contribution with fragment + into (the PR #1495 fix)
test('contribution with fragment.path + into passes (the PR #1495 fix shape)', () => {
const cap = {
id: 'hello-note',
role: 'feature',
version: '0.1.0',
title: 'Hello Note',
description: 'Injects a greeting note at plan:pre and produces HELLO.md.',
tier: 'standard',
requires: [],
runtimeCompat: { supported: ['*'], unsupported: [] },
skills: [],
agents: [],
config: {},
steps: [],
contributions: [
{
point: 'plan:pre',
into: 'planner',
fragment: { path: 'fragments/plan-pre.md' },
produces: ['HELLO.md'],
consumes: [],
onError: 'skip',
},
],
gates: [],
};
const errors = validateCapability(cap, 'hello-note');
assert.deepStrictEqual(
errors,
[],
`corrected contribution manifest has unexpected errors: ${errors.join('; ')}`,
);
});
});
// ─── Suite 3: extractManifests helper ────────────────────────────────────────
describe('extractManifests helper unit tests', () => {
test('returns empty array for plain text with no JSON fences', () => {
assert.deepStrictEqual(extractManifests('No code blocks here.'), []);
});
test('skips JSON blocks without all required manifest keys', () => {
// Partial list-entry block — only has id, role, version but not steps/contributions/etc.
const md = '```json\n{"id":"x","role":"feature","version":"1.0.0"}\n```';
assert.deepStrictEqual(extractManifests(md), []);
});
function makeCompleteManifest(overrides) {
return {
id: 'test-cap', role: 'feature', title: 'T', description: 'D',
tier: 'standard', requires: [], runtimeCompat: { supported: ['*'], unsupported: [] },
skills: [], agents: [], config: {}, steps: [], contributions: [], gates: [],
...overrides,
};
}
test('extracts a complete manifest (all required keys present)', () => {
const cap = makeCompleteManifest({ id: 'x' });
const md = '```json\n' + JSON.stringify(cap, null, 2) + '\n```';
const result = extractManifests(md);
assert.strictEqual(result.length, 1);
assert.strictEqual(result[0].id, 'x');
});
test('skips malformed JSON blocks silently', () => {
const complete = makeCompleteManifest({ id: 'y' });
const md = '```json\n{bad json here\n```\n```json\n' + JSON.stringify(complete) + '\n```';
const result = extractManifests(md);
assert.strictEqual(result.length, 1);
assert.strictEqual(result[0].id, 'y');
});
test('extracts multiple complete manifests from one doc', () => {
const a = makeCompleteManifest({ id: 'cap-a' });
const b = makeCompleteManifest({ id: 'cap-b' });
const md = [
'```json\n' + JSON.stringify(a) + '\n```',
'```json\n' + JSON.stringify(b) + '\n```',
].join('\n');
const result = extractManifests(md);
assert.strictEqual(result.length, 2);
});
});
});
}

View File

@@ -161,7 +161,13 @@ describe('ci-test-scope.cjs', () => {
`expected product_changed=true for bin/gsd, got: ${JSON.stringify(result)}`);
assert.strictEqual(result.full_matrix, true);
assert.ok(result.targeted_tests.includes('tests/install.test.cjs'));
assert.ok(result.targeted_tests.includes('tests/release-tarball-smoke.install.test.cjs'));
// release-tarball-smoke.install.test.cjs is intentionally EXCLUDED from the
// scoped/targeted lane (SCOPED_LANE_EXCLUDE in ci-test-scope.cjs): it is a
// 3–6 min npm-pack + global-install integration test that runs via its own
// install-smoke.yml workflow, not here — running it in the scoped lane too is
// redundant and overran the per-chunk Windows timeout (epic #1969).
assert.ok(!result.targeted_tests.includes('tests/release-tarball-smoke.install.test.cjs'),
`release-tarball-smoke.install.test.cjs must not be in the scoped targeted lane; got: ${JSON.stringify(result.targeted_tests)}`);
});
test('missing required CLI values fail with usage', () => {
@@ -285,12 +291,12 @@ describe('ci-test-scope superset invariant (#494, narrowed)', () => {
// so OS-specific breakage in the changed test (the #482 class) is still
// exercised pre-merge. Ubuntu 22/24 coverage comes via targeted_tests.
test('A1: a changed test file joins the windows scoped lane without full_matrix', () => {
const result = scopeFor(['tests/bug-1974-context-exhaustion-record.test.cjs']);
const result = scopeFor(['tests/perf-317-context-monitor-fs.test.cjs']);
assert.strictEqual(result.full_matrix, false,
`expected full_matrix=false for a tests/**-only change, got: ${JSON.stringify(result)}`);
assert.ok(result.targeted_tests.includes('tests/bug-1974-context-exhaustion-record.test.cjs'),
assert.ok(result.targeted_tests.includes('tests/perf-317-context-monitor-fs.test.cjs'),
`expected the changed test in targeted_tests, got: ${JSON.stringify(result.targeted_tests)}`);
assert.ok(result.windows_tests.includes('tests/bug-1974-context-exhaustion-record.test.cjs'),
assert.ok(result.windows_tests.includes('tests/perf-317-context-monitor-fs.test.cjs'),
`expected the changed test in windows_tests, got: ${JSON.stringify(result.windows_tests)}`);
});
@@ -629,3 +635,262 @@ describe('code_changed=false implies clean output invariant', () => {
}
});
});
// ────────────────────────────────────────────────────────────────────────
// Folded from tests/bug-641-files-from-suite-token.test.cjs — consolidation epic #1969 (B6 #1975)
// ────────────────────────────────────────────────────────────────────────
{
const { describe: __foldDescribe } = require('node:test');
__foldDescribe("folded:bug-641-files-from-suite-token (consolidation epic #1969 B6 #1975)", () => {
// Regression test for issue #641:
// `--files-from` with a bare suite token (e.g. "unit") crashes with
// "requested test file(s) not found: unit" instead of expanding the token
// to the matching suite's files.
//
// The bug: selectExplicitFiles() checked `available.has('unit')` against the
// set of *.test.cjs filenames. 'unit' is not a filename, so it landed in
// `missing` and caused exit 2. The fix teaches selectExplicitFiles() to
// delegate bare SUITES members to selectFiles() before the path-existence
// check.
'use strict';
const { describe, test, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const { spawnSync } = require('child_process');
const fs = require('fs');
const path = require('path');
const { createTempDir, cleanup } = require('./helpers.cjs');
const HARNESS = path.join(__dirname, '..', 'scripts', 'run-tests.cjs');
const PASS_BODY = `'use strict';
const { test } = require('node:test');
test('noop', () => {});
`;
function seed(dir, names) {
for (const name of names) {
fs.writeFileSync(path.join(dir, name), PASS_BODY, 'utf8');
}
}
function runHarness(testDir, args = [], extraEnv = {}) {
const env = { ...process.env, GSD_TEST_DIR: testDir, ...extraEnv };
delete env.NODE_TEST_CONTEXT;
return spawnSync(process.execPath, [HARNESS, ...args], {
cwd: path.join(__dirname, '..'),
env,
encoding: 'utf8',
});
}
describe('bug #641 — --files-from with bare suite token', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempDir('gsd-641-suite-token-');
});
afterEach(() => {
cleanup(tmpDir);
});
test('--files-from with bare "unit" token expands to unit suite, does not exit 2', () => {
// Seed a mix: one unit file, one security file.
seed(tmpDir, ['a.test.cjs', 'b.security.test.cjs']);
const listPath = path.join(tmpDir, 'ci-selected-tests.txt');
fs.writeFileSync(listPath, 'unit\n', 'utf8');
const r = runHarness(tmpDir, ['--files-from', listPath]);
// Must NOT exit 2 with the "not found" error.
assert.notStrictEqual(
r.status,
2,
`Expected exit 0 or 1, got 2.\nstderr: ${r.stderr}\nstdout: ${r.stdout}`,
);
assert.doesNotMatch(
r.stderr,
/requested test file\(s\) not found: unit/,
`Must not emit "not found: unit".\nstderr: ${r.stderr}`,
);
// The unit suite file (a.test.cjs) must appear in the run.
assert.ok(
r.stderr.includes('a.test.cjs'),
`Expected a.test.cjs (unit suite) to be selected.\nstderr: ${r.stderr}`,
);
// The security suite file must NOT be included (unit token = unit only).
assert.ok(
!r.stderr.includes('b.security.test.cjs'),
`Expected b.security.test.cjs (security suite) to be excluded.\nstderr: ${r.stderr}`,
);
});
test('--files-from with bare "unit" token exits 0 (tests run successfully)', () => {
seed(tmpDir, ['a.test.cjs']);
const listPath = path.join(tmpDir, 'ci-selected-tests.txt');
fs.writeFileSync(listPath, 'unit\n', 'utf8');
const r = runHarness(tmpDir, ['--files-from', listPath]);
assert.strictEqual(
r.status,
0,
`Expected exit 0.\nstderr: ${r.stderr}\nstdout: ${r.stdout}`,
);
});
test('--files with bare "unit" token also resolves correctly', () => {
seed(tmpDir, ['a.test.cjs', 'b.security.test.cjs']);
const r = runHarness(tmpDir, ['--files', 'unit']);
assert.notStrictEqual(
r.status,
2,
`Expected exit 0, got 2.\nstderr: ${r.stderr}`,
);
assert.doesNotMatch(r.stderr, /requested test file\(s\) not found: unit/);
assert.ok(r.stderr.includes('a.test.cjs'), `a.test.cjs must be selected.\nstderr: ${r.stderr}`);
assert.ok(!r.stderr.includes('b.security.test.cjs'), `security file must not be selected.\nstderr: ${r.stderr}`);
});
test('mixed: suite token "unit" alongside an explicit file resolves both', () => {
seed(tmpDir, ['a.test.cjs', 'b.test.cjs', 'c.security.test.cjs']);
const listPath = path.join(tmpDir, 'ci-selected-tests.txt');
// 'unit' expands to [a.test.cjs, b.test.cjs]; b.test.cjs is explicit too.
fs.writeFileSync(listPath, 'unit\nb.test.cjs\n', 'utf8');
const r = runHarness(tmpDir, ['--files-from', listPath]);
assert.strictEqual(r.status, 0, `stderr: ${r.stderr}`);
// Both unit files present; security not.
assert.ok(r.stderr.includes('a.test.cjs'), `a.test.cjs must be selected.\nstderr: ${r.stderr}`);
assert.ok(r.stderr.includes('b.test.cjs'), `b.test.cjs must be selected.\nstderr: ${r.stderr}`);
assert.ok(!r.stderr.includes('c.security.test.cjs'), `c.security.test.cjs must be excluded.\nstderr: ${r.stderr}`);
});
test('#408 fallback: ci-test-scope "unit" sentinel does not crash run-tests', () => {
// This test simulates the end-to-end #408 fallback path:
// ci-test-scope produces "unit" (the fallback sentinel for "code changed
// but no rule matched any test"), ci-prepare-test-scope writes it verbatim,
// and run-tests must resolve it rather than crash.
seed(tmpDir, ['a.test.cjs', 'b.security.test.cjs']);
// Simulate what ci-prepare-test-scope writes: "unit\n"
const listPath = path.join(tmpDir, '.ci-selected-tests.txt');
fs.writeFileSync(listPath, 'unit\n', 'utf8');
const r = runHarness(tmpDir, ['--files-from', listPath]);
assert.strictEqual(
r.status,
0,
`#408 fallback: expected exit 0 but got ${r.status}.\nstderr: ${r.stderr}`,
);
assert.doesNotMatch(r.stderr, /not found: unit/);
assert.ok(r.stderr.includes('a.test.cjs'), `unit test must run.\nstderr: ${r.stderr}`);
});
});
// Regression test for issue #1329:
// ci-prepare-test-scope's empty-detection FALLBACK hardcoded an explicit file
// list that included tests/core.test.cjs — a file deleted in #1291. Every
// scoped lane (scope=targeted|windows) that hit the fallback wrote the stale
// path into .ci-selected-tests.txt and crashed run-tests with
// "requested test file(s) not found: core.test.cjs". The fix: existence-filter
// the fallback at write time, fall back to the 'unit' suite sentinel when
// nothing survives, and guard the FALLBACK constant against disk reality.
describe('bug #1329 — ci-prepare-test-scope fallback never emits a deleted file', () => {
const { FALLBACK, FALLBACK_SENTINEL, SUITE_SENTINELS, resolveSelection } =
require('../scripts/ci-prepare-test-scope.cjs');
const REPO_ROOT = path.join(__dirname, '..');
// Generative parity guard (DEFECT.GENERATIVE-FIX): the FALLBACK constant and
// the test files on disk are two surfaces that must stay in sync. This fails
// the instant a refactor deletes a file still named in FALLBACK — which is
// precisely what #1291 did and CI did not catch.
test('every FALLBACK entry resolves on disk or is a known suite sentinel', () => {
for (const entry of FALLBACK) {
const isSentinel = SUITE_SENTINELS.includes(entry);
const exists = fs.existsSync(path.join(REPO_ROOT, entry));
assert.ok(
isSentinel || exists,
`FALLBACK entry "${entry}" is neither an existing test file nor a suite sentinel — stale reference will crash scoped CI lanes (see #1329).`,
);
}
});
let tmpDir;
beforeEach(() => {
tmpDir = createTempDir('gsd-1329-fallback-');
fs.mkdirSync(path.join(tmpDir, 'tests'), { recursive: true });
});
afterEach(() => {
cleanup(tmpDir);
});
test('empty detection drops a non-existent fallback entry instead of emitting it', () => {
// Create all but the last FALLBACK file under a controlled root, simulating
// a since-deleted test (the #1329 mechanism), independent of which files
// FALLBACK happens to name today.
const present = FALLBACK.slice(0, -1);
const absent = FALLBACK[FALLBACK.length - 1];
for (const f of present) {
fs.writeFileSync(path.join(tmpDir, f), PASS_BODY, 'utf8');
}
const lines = resolveSelection({ scope: 'targeted', targeted: '', windows: '', root: tmpDir });
assert.ok(!lines.includes(absent), `absent file "${absent}" must be filtered out, got: ${lines.join(', ')}`);
for (const f of present) {
assert.ok(lines.includes(f), `present file "${f}" must survive, got: ${lines.join(', ')}`);
}
});
test('empty detection with no surviving fallback files falls back to the unit sentinel', () => {
// tmpDir/tests exists but contains none of the FALLBACK files.
const lines = resolveSelection({ scope: 'windows', targeted: '', windows: '', root: tmpDir });
assert.deepStrictEqual(lines, [FALLBACK_SENTINEL]);
});
test('detected list passes through verbatim — files and suite sentinels preserved, not existence-filtered', () => {
// The detected list is already filtered by affected-tests-lib and may carry
// a suite sentinel; ci-prepare-test-scope must not touch it.
const lines = resolveSelection({
scope: 'targeted',
targeted: 'tests/does-not-exist.test.cjs unit',
windows: '',
root: tmpDir,
});
assert.deepStrictEqual(lines, ['tests/does-not-exist.test.cjs', 'unit']);
});
test('end-to-end: the real script writes a fallback list whose every entry resolves', () => {
// Run the real script (subprocess) with empty detection inside an isolated
// root that holds the FALLBACK files, then verify every line it wrote into
// .ci-selected-tests.txt resolves — the exact scoped-lane path that crashed
// in #1329. Hermetic: the temp root is removed by afterEach's cleanup().
for (const f of FALLBACK) {
fs.writeFileSync(path.join(tmpDir, f), PASS_BODY, 'utf8');
}
const prep = spawnSync(
process.execPath,
[path.join(REPO_ROOT, 'scripts', 'ci-prepare-test-scope.cjs')],
{ cwd: tmpDir, env: { ...process.env, TEST_SCOPE: 'targeted', TARGETED_TESTS: '', WINDOWS_TESTS: '' }, encoding: 'utf8' },
);
assert.strictEqual(prep.status, 0, `prepare step failed: ${prep.stderr}`);
const selected = fs.readFileSync(path.join(tmpDir, '.ci-selected-tests.txt'), 'utf8');
for (const line of selected.split(/\r?\n/).filter(Boolean)) {
const isSentinel = SUITE_SENTINELS.includes(line);
assert.ok(
isSentinel || fs.existsSync(path.join(tmpDir, line)),
`selected entry "${line}" does not resolve — would crash run-tests (#1329)`,
);
}
assert.doesNotMatch(selected, /core\.test\.cjs/, 'deleted core.test.cjs must never be selected');
});
});
});
}

View File

@@ -183,3 +183,263 @@ describe('plan-phase commit_docs support (#2399)', () => {
});
});
}
// ────────────────────────────────────────────────────────────────────────
// Folded from tests/bug-3678-executor-commit-docs-respect.test.cjs — consolidation epic #1969 (B6 #1975)
// ────────────────────────────────────────────────────────────────────────
{
const { describe: __foldDescribe } = require('node:test');
__foldDescribe("folded:bug-3678-executor-commit-docs-respect (consolidation epic #1969 B6 #1975)", () => {
// allow-test-rule: source-text-is-the-product (see #3678)
// Three of the assertions in this file (A1, A2, C) inspect agent / workflow
// `.md` bodies. Those files ARE the runtime contract that GSD loads into agent
// prompts at run time, so source-text inspection is exactly what the
// `source-text-is-the-product` exception covers.
//
// The remaining assertions (B1, B2, B3) are behavioral — they invoke
// `gsd-tools commit` against a temp project and assert on its structured
// JSON return envelope plus the git index state. No raw-text matching on
// rendered output.
/**
* Regression for #3678 — gsd-executor force-commits .planning/ files when
* commit_docs is false.
*
* Root cause: the executor agent prompt (agents/gsd-executor.md) tells the
* agent to call `gsd-sdk query commit "docs(...)" --files .planning/...`
* in the per-plan final_commit block, but the prompt says nothing about
* what to do when the SDK returns `{committed: false, skipped: true,
* reason: 'skipped_commit_docs_false'}`. With no explicit instruction, the
* agent improvises raw `git add` / `git commit` against `.planning/` paths
* (and uses `-f` to bypass gitignore), which is exactly the leakage the
* reporter observed.
*
* Fix surface:
* 1. Agent prompt: explicit handling text in the final_commit section.
* 2. SDK envelope: add `skipped: true` field so agents see "skipped" as a
* first-class success signal, not "committed is missing, must improvise."
* 3. Structural guard: ban `git add -f` / `git add --force` from agent and
* workflow bodies entirely (no GSD-managed surface should force-stage
* gitignored content).
*/
'use strict';
const { describe, test, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { execFileSync } = require('node:child_process');
const { createTempGitProject, cleanup, runGsdTools } = require('./helpers.cjs');
// Repo root resolution. This test file lives in `<repo>/tests/`. Use a single
// parent reference (the established repo-wide pattern, e.g. tests/helpers.cjs
// `path.resolve(__dirname, '..', 'gsd-core', ...)`). A `.git`-anchored
// walker is not portable because the docker test mirror at `/work` strips the
// `.git/` directory before running tests.
const REPO_ROOT = path.resolve(__dirname, '..');
const EXECUTOR_AGENT = path.join(REPO_ROOT, 'agents', 'gsd-executor.md');
// Frozen reason enum mirrors the SDK source — keep in sync with
// `cmdCommit` in gsd-core/bin/lib/commands.cjs.
const COMMIT_REASON = Object.freeze({
SKIPPED_COMMIT_DOCS_FALSE: 'skipped_commit_docs_false',
SKIPPED_GITIGNORED: 'skipped_gitignored',
});
function git(args, cwd) {
return execFileSync('git', args, { cwd, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] });
}
describe('bug #3678 — executor must respect commit_docs:false', () => {
describe('A — agent prompt teaches the agent how to handle commit_docs:false', () => {
test('A1: agent body explicitly references the SDK skipped envelope', () => {
const body = fs.readFileSync(EXECUTOR_AGENT, 'utf-8');
// The prompt must contain at least one literal mention of the skipped
// reason code OR the `committed: false` envelope so the agent knows
// that skipping is an intentional control flow, not a failure to work
// around.
const mentionsSkipReason = body.includes(COMMIT_REASON.SKIPPED_COMMIT_DOCS_FALSE);
const mentionsCommittedFalse = /committed:\s*false/i.test(body);
const mentionsSkippedTrue = /skipped:\s*true/i.test(body);
assert.ok(
mentionsSkipReason || mentionsCommittedFalse || mentionsSkippedTrue,
'agents/gsd-executor.md must teach the agent how to recognize the '
+ 'skipped envelope from `gsd-sdk query commit` (one of: '
+ `'${COMMIT_REASON.SKIPPED_COMMIT_DOCS_FALSE}', 'committed: false', `
+ "'skipped: true').",
);
});
test('A2: agent body explicitly forbids raw git fallback when SDK skips', () => {
const body = fs.readFileSync(EXECUTOR_AGENT, 'utf-8');
// Look for an explicit instruction tying the SDK-skipped signal to the
// forbidden-fallback rule. Accept any of three shapes the doc writer
// might use: "do not", "must not", or "never" + a verb that names the
// forbidden action.
const forbidsFallbackText = /(do not|must not|never)\s+(fall back|fallback|use .*git add|run .*git commit|force[- ]?add)/i;
assert.ok(
forbidsFallbackText.test(body),
'agents/gsd-executor.md must contain an explicit "do not fall back to '
+ 'raw git" instruction tied to the commit_docs:false / skipped envelope. '
+ 'Without it, the agent improvises raw `git add` / `git add -f` to '
+ 'fulfill its "complete plan" goal.',
);
});
});
describe('B — SDK behavior: commit_docs:false leaves repo state untouched', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempGitProject();
// .planning/ already exists from createTempGitProject's setup.
// Set commit_docs to false on the config.
const configPath = path.join(tmpDir, '.planning', 'config.json');
let config = {};
if (fs.existsSync(configPath)) {
config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
}
config.commit_docs = false;
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
// Make a token edit to .planning/STATE.md so there IS something the SDK
// could in principle stage (or that an improvising agent could leak).
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
if (!fs.existsSync(statePath)) {
fs.writeFileSync(statePath, '---\nproject: test\n---\n# State\n');
}
fs.appendFileSync(statePath, '\n<!-- token edit for #3678 repro -->\n');
});
afterEach(() => cleanup(tmpDir));
test('B1: commit returns committed:false with skipped envelope', () => {
const result = runGsdTools(
'commit "docs(test): noop" --files .planning/STATE.md',
tmpDir,
);
assert.ok(result.success, `gsd-tools commit should exit 0 even when skipped: ${result.error || ''}`);
const envelope = JSON.parse(result.output);
assert.strictEqual(envelope.committed, false, 'committed must be false when commit_docs is false');
assert.strictEqual(
envelope.skipped,
true,
'envelope must carry skipped:true so agents see skip as a first-class signal (envelope contract for #3678)',
);
assert.strictEqual(
envelope.reason,
COMMIT_REASON.SKIPPED_COMMIT_DOCS_FALSE,
'reason must be the canonical skipped_commit_docs_false code (frozen enum)',
);
});
test('B2: commit_docs:false leaves the git index empty (no .planning/ staged)', () => {
runGsdTools(
'commit "docs(test): noop" --files .planning/STATE.md',
tmpDir,
);
const stagedAll = git(['diff', '--cached', '--name-only'], tmpDir);
const stagedPlanning = stagedAll
.split(/\r?\n/)
.map(s => s.trim())
.filter(s => s.startsWith('.planning/'));
assert.deepStrictEqual(
stagedPlanning,
[],
'no .planning/ files should be staged when commit_docs is false',
);
});
test('B3: commit_docs:false produces no new commits', () => {
const headBefore = git(['rev-parse', 'HEAD'], tmpDir).trim();
runGsdTools(
'commit "docs(test): noop" --files .planning/STATE.md',
tmpDir,
);
const headAfter = git(['rev-parse', 'HEAD'], tmpDir).trim();
assert.strictEqual(
headAfter,
headBefore,
'HEAD must not advance when commit_docs is false',
);
});
});
test('checklist carve-out preserved for intentional skip', () => {
const body = fs.readFileSync(EXECUTOR_AGENT, 'utf-8');
const checklistLine = body
.split(/\r?\n/)
.find(line => /Final metadata commit made/.test(line));
assert.ok(
checklistLine,
'agents/gsd-executor.md must contain a "Final metadata commit made" checklist line',
);
assert.ok(
checklistLine.includes('Final metadata commit'),
'checklist line must reference "Final metadata commit"',
);
assert.ok(
checklistLine.includes('skipped_commit_docs_false'),
'checklist line must carve out the intentional-skip case by referencing '
+ '"skipped_commit_docs_false" — prevents executor from treating an '
+ 'unchecked mandatory box as a raw-git TODO (regression guard for #3679)',
);
});
describe('C — structural ban on raw force-add in GSD-managed bodies', () => {
function scanForForceAdd(rootDir) {
const offenders = [];
function walk(dir) {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) { walk(full); continue; }
if (!entry.isFile() || !entry.name.endsWith('.md')) continue;
const body = fs.readFileSync(full, 'utf-8');
const lines = body.split(/\r?\n/);
const danger = lines.filter((line) => {
if (!/git\s+add\s+(-f|--force)\b/.test(line)) return false;
// Allow prohibition / warning sentences and code-fence prose that
// frames `git add -f` AS the bug (so an audit comment doesn't
// create a false positive).
if (/(do not|don'?t|must not|never|forbidden|prohibited)/i.test(line)) return false;
if (/(bug|wrong|incorrect|antipattern|anti-pattern|forces?\s+gitignored|leak)/i.test(line)) return false;
return true;
});
if (danger.length > 0) {
offenders.push({
file: full.replace(REPO_ROOT + '/', ''),
lines: danger.map(l => l.trim().slice(0, 120)),
});
}
}
}
walk(rootDir);
return offenders;
}
test('C1: no agent body contains `git add -f` / `git add --force`', () => {
const offenders = scanForForceAdd(path.join(REPO_ROOT, 'agents'));
assert.deepStrictEqual(
offenders,
[],
'no agent body may use `git add -f` / `git add --force` outside a '
+ 'prohibition sentence — agents must never force-stage gitignored '
+ 'content (regression guard for #3678).',
);
});
test('C2: no workflow body contains `git add -f` / `git add --force`', () => {
const offenders = scanForForceAdd(path.join(REPO_ROOT, 'gsd-core', 'workflows'));
assert.deepStrictEqual(
offenders,
[],
'no workflow body may use `git add -f` / `git add --force` outside a '
+ 'prohibition sentence (regression guard for #3678).',
);
});
});
});
});
}

View File

@@ -376,3 +376,463 @@ describe('bug-2943: config-get returns schema default for context_window', () =>
});
});
}
// ────────────────────────────────────────────────────────────────────────
// Folded from tests/feat-3593-cli-negative-config.test.cjs — consolidation epic #1969 (B6 #1975)
// ────────────────────────────────────────────────────────────────────────
{
const { describe: __foldDescribe } = require('node:test');
__foldDescribe("folded:feat-3593-cli-negative-config (consolidation epic #1969 B6 #1975)", () => {
/**
* CLI negative matrix for the `config` command family (#3593).
*
* Exercises the 12 adversarial input categories enumerated in
* CONTRIBUTING.md §"QA Matrix Requirements / CLI and command routing"
* against `config-get` and `config-set`. The harness in
* `tests/helpers/cli-negative.cjs` shapes spawnSync output into a typed
* IR so every assertion runs on `result.reason`, `result.status`, and
* `result.hasStackTrace` — never on stderr/stdout prose.
*
* Each test gets its own temp project (no shared state) so concurrent
* runs can't observe each other's filesystem mutations. Hostile values
* (shell metacharacters, null bytes, unicode, very long strings) reach
* the CLI as single argv elements via spawnSync — never composed into
* a shell string — so the test framework itself can't be the source of
* a false positive on shell-injection assertions.
*/
'use strict';
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { runCli } = require('./helpers/cli-negative.cjs');
const { createTempProject, cleanup } = require('./helpers.cjs');
/**
* Universal invariants every adversarial case must satisfy when the
* CLI is invoked with --json-errors. Bundling these in a helper keeps
* each test focused on the case-specific reason assertion.
*/
function assertSafeFailure(result, msg = '') {
assert.notEqual(result.status, 0, `${msg} :: expected non-zero exit`);
assert.equal(result.signal, null, `${msg} :: must exit cleanly, not via signal`);
assert.equal(result.hasStackTrace, false, `${msg} :: stderr must not leak a V8 stack frame`);
assert.equal(result.ok, false, `${msg} :: JSON payload ok must be false`);
assert.equal(typeof result.reason, 'string', `${msg} :: reason must be a string`);
assert.notEqual(result.reason, '', `${msg} :: reason must not be empty`);
// The harness's JSON-shape detection runs on the trimmed stderr; if we
// got here with reason set, the payload was a valid object — that already
// implies no rogue prose was mixed in. Re-asserting the trimmed form would
// be redundant.
}
/**
* Snapshot the file inventory of a directory so a later assertion can
* prove the failing CLI invocation did NOT create or modify any file.
*/
function snapshotInventory(dir) {
const entries = [];
function walk(rel) {
const abs = path.join(dir, rel);
let stat;
try { stat = fs.lstatSync(abs); } catch { return; }
if (stat.isDirectory()) {
for (const name of fs.readdirSync(abs).sort()) walk(path.join(rel, name));
} else {
entries.push(`${rel}\t${stat.size}\t${stat.mtimeMs}`);
}
}
walk('.');
return entries.join('\n');
}
// ─── 1. Missing required arg ────────────────────────────────────────────────
test('config-get with no key fails with a typed reason and no stack trace', (t) => {
const projectDir = createTempProject('cli-neg-config-1-');
t.after(() => cleanup(projectDir));
const before = snapshotInventory(projectDir);
const result = runCli(['config-get'], { cwd: projectDir });
assertSafeFailure(result, 'config-get missing key');
assert.equal(snapshotInventory(projectDir), before, 'failing read must not mutate FS');
});
test('config-set with no key fails with a typed reason', (t) => {
const projectDir = createTempProject('cli-neg-config-2-');
t.after(() => cleanup(projectDir));
const result = runCli(['config-set'], { cwd: projectDir });
assertSafeFailure(result, 'config-set missing key');
});
test('config-set with key but no value fails with a typed reason', (t) => {
const projectDir = createTempProject('cli-neg-config-3-');
t.after(() => cleanup(projectDir));
const result = runCli(['config-set', 'model_profile'], { cwd: projectDir });
assertSafeFailure(result, 'config-set missing value');
});
// ─── 2/3. Empty / whitespace arg ────────────────────────────────────────────
test('config-get with empty-string key fails safely', (t) => {
const projectDir = createTempProject('cli-neg-config-4-');
t.after(() => cleanup(projectDir));
const before = snapshotInventory(projectDir);
const result = runCli(['config-get', ''], { cwd: projectDir });
assertSafeFailure(result, 'config-get empty key');
assert.equal(snapshotInventory(projectDir), before, 'failing read must not mutate FS');
});
test('config-get with whitespace-only key fails safely', (t) => {
const projectDir = createTempProject('cli-neg-config-5-');
t.after(() => cleanup(projectDir));
const result = runCli(['config-get', ' \t '], { cwd: projectDir });
assertSafeFailure(result, 'config-get whitespace key');
});
test('config-set with empty key string fails safely', (t) => {
const projectDir = createTempProject('cli-neg-config-6-');
t.after(() => cleanup(projectDir));
const result = runCli(['config-set', '', 'value'], { cwd: projectDir });
assertSafeFailure(result, 'config-set empty key');
});
// ─── 4. Duplicate flags ─────────────────────────────────────────────────────
test('--cwd specified twice does not silently use the wrong one', (t) => {
// Make two real but distinct dirs so the test can't accidentally pass
// because one of the paths is invalid.
const a = createTempProject('cli-neg-config-7a-');
const b = createTempProject('cli-neg-config-7b-');
t.after(() => { cleanup(a); cleanup(b); });
// No --json-errors here on purpose: --cwd is parsed before json mode is
// applied, so we exercise both code paths by running once each.
const result = runCli(['--cwd', a, '--cwd', b, 'config-get', 'model_profile'], { cwd: process.cwd() });
// Either: (a) the CLI rejects duplicate --cwd with a typed reason; OR
// (b) it commits to one of the values deterministically. The safety
// bar is "no stack trace, no half-state mutation in EITHER dir".
assert.equal(result.hasStackTrace, false, 'duplicate --cwd must not crash with a stack trace');
// Neither tmp dir should have a written config since model_profile is
// a read, not a write, and it didn't exist beforehand. Prove the read
// didn't accidentally trigger a write side effect.
assert.equal(fs.existsSync(path.join(a, '.planning', 'config.json')), false);
assert.equal(fs.existsSync(path.join(b, '.planning', 'config.json')), false);
});
// ─── 5. Conflicting flags ───────────────────────────────────────────────────
test('--json-errors with --no-such-flag does not crash with a stack trace', (t) => {
const projectDir = createTempProject('cli-neg-config-8-');
t.after(() => cleanup(projectDir));
const result = runCli(['--no-such-flag', 'config-get', 'model_profile'], { cwd: projectDir });
assert.equal(result.hasStackTrace, false, 'unknown global flag must not crash with a stack trace');
assert.notEqual(result.status, 0, 'unknown global flag must fail');
});
// ─── 6. Malformed assignment / unknown subcommand ──────────────────────────
test('config-FAKE subcommand fails with a typed reason', (t) => {
const projectDir = createTempProject('cli-neg-config-9-');
t.after(() => cleanup(projectDir));
const result = runCli(['config-FAKE'], { cwd: projectDir });
assertSafeFailure(result, 'unknown config-* command');
});
// ─── 7. Unknown subcommands at each command depth ───────────────────────────
test('config family — bare top-level "config" without a subcommand fails safely', (t) => {
const projectDir = createTempProject('cli-neg-config-10-');
t.after(() => cleanup(projectDir));
const result = runCli(['config'], { cwd: projectDir });
// Either "missing subcommand" usage or genuine no-op behavior — what we
// pin is "no stack trace, no FS mutation".
assert.equal(result.hasStackTrace, false);
});
// ─── 8. Values that look like flags ─────────────────────────────────────────
test('config-set value that starts with -- is treated as a value, not a flag', (t) => {
const projectDir = createTempProject('cli-neg-config-11-');
t.after(() => cleanup(projectDir));
// First create a config.json so the set has a target file.
runCli(['config-ensure-section'], { cwd: projectDir });
const result = runCli(['config-set', 'project_code', '--weird'], { cwd: projectDir });
// Acceptable outcomes:
// (a) CLI accepts --weird as the value (and persists it),
// (b) CLI rejects it as a usage error.
// Either way: no stack trace, no half-written corrupt config.
assert.equal(result.hasStackTrace, false, 'value-looking-like-a-flag must not crash');
const configPath = path.join(projectDir, '.planning', 'config.json');
if (fs.existsSync(configPath)) {
// If a config exists, it must still be valid JSON — no half-write corruption.
const raw = fs.readFileSync(configPath, 'utf-8');
assert.doesNotThrow(() => JSON.parse(raw), 'config.json must remain parseable after a failed set');
}
});
// ─── 9. Invalid JSON / corrupt config file ──────────────────────────────────
test('config-get against a corrupt config.json fails with a parse-failed reason', (t) => {
const projectDir = createTempProject('cli-neg-config-12-');
t.after(() => cleanup(projectDir));
const configPath = path.join(projectDir, '.planning', 'config.json');
fs.writeFileSync(configPath, '{ this is not json'); // deliberate corruption
const originalCorrupt = fs.readFileSync(configPath, 'utf-8');
const result = runCli(['config-get', 'model_profile'], { cwd: projectDir });
assertSafeFailure(result, 'corrupt config.json');
// The corrupt file must remain untouched — the CLI must not "helpfully"
// overwrite an unparseable config in the failure path.
assert.equal(fs.readFileSync(configPath, 'utf-8'), originalCorrupt, 'corrupt file must be preserved as-is');
// Specific reason: CONFIG_PARSE_FAILED (or equivalent) — pin this so a
// regression where parse failure leaks as "unknown" is caught.
assert.match(
result.reason,
/^(config_parse_failed|config_no_file|config_invalid_key|usage)$/,
`parse-failure reason must be from the typed ERROR_REASON enum (got: ${result.reason})`,
);
});
// ─── 10. Very long arg ──────────────────────────────────────────────────────
test('config-get with a very long key (50KB) fails safely without hanging', (t) => {
const projectDir = createTempProject('cli-neg-config-13-');
t.after(() => cleanup(projectDir));
const longKey = 'x'.repeat(50000);
const result = runCli(['config-get', longKey], { cwd: projectDir, timeoutMs: 8000 });
assert.equal(result.signal, null, 'long input must not trigger the harness timeout');
assert.equal(result.hasStackTrace, false, 'long input must not crash');
assert.notEqual(result.status, 0, 'unknown 50KB key must fail');
});
// ─── 11. Unicode / non-ASCII ────────────────────────────────────────────────
test('config-get with a Unicode key fails safely', (t) => {
const projectDir = createTempProject('cli-neg-config-14-');
t.after(() => cleanup(projectDir));
const result = runCli(['config-get', 'workflow.🔥_mode'], { cwd: projectDir });
assertSafeFailure(result, 'unicode key');
});
test('config-set with an emoji value persists or rejects without corrupting JSON', (t) => {
const projectDir = createTempProject('cli-neg-config-15-');
t.after(() => cleanup(projectDir));
runCli(['config-ensure-section'], { cwd: projectDir });
const result = runCli(['config-set', 'project_code', '🔥👾'], { cwd: projectDir });
assert.equal(result.hasStackTrace, false);
// If it accepted, the JSON must round-trip cleanly.
const configPath = path.join(projectDir, '.planning', 'config.json');
if (result.status === 0 && fs.existsSync(configPath)) {
const parsed = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
assert.equal(typeof parsed, 'object', 'config.json must be a valid object');
if (parsed.project_code != null) {
assert.equal(typeof parsed.project_code, 'string', 'project_code must remain a string');
}
}
});
// ─── 12. Shell metacharacters (the security-critical case) ──────────────────
const SHELL_PAYLOADS = [
// Each one would, if shell-interpreted, create a sentinel file
// adjacent to the project tree. Argv-based invocation must treat them
// as opaque text.
'$(touch ${PROJECT}/INJ-dollar-paren)',
'`touch ${PROJECT}/INJ-backtick`',
'; touch ${PROJECT}/INJ-semicolon;',
'&& touch ${PROJECT}/INJ-and',
'|| touch ${PROJECT}/INJ-or',
'| tee ${PROJECT}/INJ-pipe',
'> ${PROJECT}/INJ-redirect',
// Quote-balanced payloads — these have historically broken naive
// shell-string composition even when the rest of the code uses argv.
'"; touch ${PROJECT}/INJ-quote;"',
'\'; touch ${PROJECT}/INJ-quote;\'',
];
for (const payload of SHELL_PAYLOADS) {
test(`config-get with shell-metachar key (${payload.slice(0, 25)}…) does NOT execute the payload`, (t) => {
const projectDir = createTempProject('cli-neg-config-shell-');
t.after(() => cleanup(projectDir));
const resolvedPayload = payload.replace(/\$\{PROJECT\}/g, projectDir);
const result = runCli(['config-get', resolvedPayload], { cwd: projectDir });
// No shell interpretation: none of the INJ-* sentinel files must
// exist after the run. Walk the project dir and assert.
const entries = fs.readdirSync(projectDir);
const sentinels = entries.filter((n) => n.startsWith('INJ-'));
assert.deepEqual(sentinels, [], `shell payload must NOT create sentinel files (found: ${sentinels.join(', ')})`);
// The CLI may exit 0 (legitimate — the metacharacter-laden key
// simply doesn't exist in config) or non-zero (typed reason). Both
// are acceptable as long as no payload was executed.
assert.equal(result.hasStackTrace, false);
});
}
// ─── Cross-cutting: --cwd points at a non-existent path ────────────────────
test('--cwd pointing at a non-existent path fails with a typed usage reason', (_t) => {
const nonExistent = path.join(require('os').tmpdir(), 'cli-neg-no-such-dir-' + Date.now() + '-' + Math.random());
assert.equal(fs.existsSync(nonExistent), false, 'pre-check: path must not exist');
const result = runCli(['--cwd', nonExistent, 'config-get', 'model_profile'], { cwd: process.cwd() });
assert.notEqual(result.status, 0);
assert.equal(result.hasStackTrace, false);
// gsd-tools validates --cwd up-front and emits ERROR_REASON.USAGE.
assert.equal(result.reason, 'usage', `expected reason=usage for invalid --cwd, got: ${result.reason}`);
});
test('--cwd with an empty value fails with a typed usage reason', () => {
const result = runCli(['--cwd', '', 'config-get', 'model_profile'], { cwd: process.cwd() });
assert.notEqual(result.status, 0);
assert.equal(result.hasStackTrace, false);
assert.equal(result.reason, 'usage');
});
});
}
// ────────────────────────────────────────────────────────────────────────
// Folded from tests/feat-3593-cli-negative-harness.test.cjs — consolidation epic #1969 (B6 #1975)
// ────────────────────────────────────────────────────────────────────────
{
const { describe: __foldDescribe } = require('node:test');
__foldDescribe("folded:feat-3593-cli-negative-harness (consolidation epic #1969 B6 #1975)", () => {
/**
* Meta-test for the CLI negative-matrix harness (#3593).
*
* The harness in `tests/helpers/cli-negative.cjs` shapes spawnSync
* results into a typed IR that adversarial-input tests consume. This
* file pins the IR contract by exercising the harness against
* deliberate scenarios — not as a placeholder for the real matrix tests
* (those live in sibling feat-3593-* files) but to surface harness
* regressions before they cascade through every matrix test.
*
* Tests deliberately avoid prose-matching: they assert on numeric exit
* codes, boolean flags, and reason codes pulled from the parsed JSON
* payload.
*/
'use strict';
const { test } = require('node:test');
const assert = require('node:assert/strict');
const { runCli, parseSpawnResult } = require('./helpers/cli-negative.cjs');
const { createTempProject, cleanup } = require('./helpers.cjs');
test('runCli rejects non-array argv with TypeError', () => {
assert.throws(
() => runCli('config-get', { cwd: '/tmp' }),
(err) => err instanceof TypeError && /argv/.test(err.message),
);
});
test('runCli rejects missing cwd with TypeError', () => {
assert.throws(
() => runCli(['config-get'], {}),
(err) => err instanceof TypeError && /cwd/.test(err.message),
);
});
test('runCli surfaces typed reason from a known failure path', (t) => {
const projectDir = createTempProject('cli-neg-harness-');
t.after(() => cleanup(projectDir));
// Unknown command — gsd-tools emits ERROR_REASON.SDK_UNKNOWN_COMMAND or
// USAGE depending on dispatch depth. Either is a real reason string;
// the contract we pin here is just "the IR carries a reason from the
// ERROR_REASON enum, never null".
const result = runCli(['this-command-does-not-exist'], { cwd: projectDir });
assert.notEqual(result.status, 0, 'unknown command must exit non-zero');
assert.equal(result.ok, false, 'JSON payload must report ok=false');
assert.equal(typeof result.reason, 'string', 'reason must be a string from ERROR_REASON');
assert.notEqual(result.reason, null);
assert.notEqual(result.reason, '');
assert.equal(result.hasStackTrace, false, 'a typed failure must NOT print a V8 stack trace');
});
test('parseSpawnResult detects stack-trace leakage in stderr', () => {
const fakeSpawn = {
status: 1,
signal: null,
stdout: '',
stderr: 'Error: boom\n at Object.<anonymous> (/some/file.js:10:5)\n at Module._compile\n',
error: null,
};
const ir = parseSpawnResult(fakeSpawn, { jsonErrorsRequested: false });
assert.equal(ir.hasStackTrace, true, 'stack frames in stderr must be flagged');
assert.equal(ir.reason, null, 'non-JSON stderr leaves reason null');
});
test('parseSpawnResult does NOT match the literal word "at" in prose', () => {
// Guard against a regex regression that would catch sentences like
// "command failed at startup" as stack frames.
const fakeSpawn = {
status: 1,
signal: null,
stdout: '',
stderr: 'Error: command failed at startup\nbecause no project was found.\n',
error: null,
};
const ir = parseSpawnResult(fakeSpawn, { jsonErrorsRequested: false });
assert.equal(ir.hasStackTrace, false, 'prose containing the word "at" is not a stack frame');
});
test('parseSpawnResult extracts ok/reason/message from a json-errors payload', () => {
const payload = { ok: false, reason: 'config_invalid_key', message: 'no such key: foo' };
const fakeSpawn = {
status: 1,
signal: null,
stdout: '',
stderr: JSON.stringify(payload) + '\n',
error: null,
};
const ir = parseSpawnResult(fakeSpawn, { jsonErrorsRequested: true });
assert.equal(ir.ok, false);
assert.equal(ir.reason, 'config_invalid_key');
assert.equal(ir.message, 'no such key: foo');
assert.equal(ir.hasStackTrace, false);
});
test('parseSpawnResult ignores malformed JSON in stderr without throwing', () => {
const fakeSpawn = {
status: 1,
signal: null,
stdout: '',
stderr: '{ ok: false, reason }', // missing quotes — invalid JSON
error: null,
};
const ir = parseSpawnResult(fakeSpawn, { jsonErrorsRequested: true });
assert.equal(ir.ok, null, 'malformed JSON must NOT promote partial data into ok');
assert.equal(ir.reason, null);
assert.equal(ir.message, null);
});
test('parseSpawnResult ignores JSON arrays and primitives, only accepts objects', () => {
const cases = [
'["ok", false]', // array
'"just a string"', // primitive
'null', // null literal
'42', // number
];
for (const stderr of cases) {
const ir = parseSpawnResult(
{ status: 1, signal: null, stdout: '', stderr, error: null },
{ jsonErrorsRequested: true },
);
assert.equal(ir.ok, null, `non-object JSON (${stderr}) must not set ok`);
assert.equal(ir.reason, null);
}
});
test('runCli treats jsonErrors=false as an explicit human-formatter path', (t) => {
const projectDir = createTempProject('cli-neg-harness-text-');
t.after(() => cleanup(projectDir));
const result = runCli(['this-command-does-not-exist'], { cwd: projectDir, jsonErrors: false });
assert.notEqual(result.status, 0);
assert.equal(result.jsonErrorsRequested, false);
// Reason fields stay null in human-mode because stderr is prose, not JSON.
assert.equal(result.ok, null);
assert.equal(result.reason, null);
// But the prose still must not include a V8 stack trace.
assert.equal(result.hasStackTrace, false);
});
});
}

View File

@@ -671,3 +671,398 @@ describe('bug #2638 — sub_repos canonical location', () => {
});
});
}
// ────────────────────────────────────────────────────────────────────────
// Folded from tests/bug-3523-cjs-loadconfig-branching-strategy-warning.test.cjs — consolidation epic #1969 (B6 #1975)
// ────────────────────────────────────────────────────────────────────────
{
const { describe: __foldDescribe } = require('node:test');
__foldDescribe("folded:bug-3523-cjs-loadconfig-branching-strategy-warning (consolidation epic #1969 B6 #1975)", () => {
'use strict';
// allow-test-rule: validates runtime CLI stdout/stderr warning behavior, not source grep (see #3523)
/**
* Regression tests for #3523 — CJS loadConfig must not emit a false
* "unknown config key(s)" warning for `branching_strategy` when that key
* is written at the top level of .planning/config.json.
*
* Root cause: KNOWN_TOP_LEVEL in core.cjs was built from VALID_CONFIG_KEYS
* via k.split('.')[0], which turns 'git.branching_strategy' → 'git', not
* 'branching_strategy'. So a config with the legacy top-level shape tripped
* the unknown-key warning even though core.cjs:485 actively reads the value.
*
* Fix (option 3 — self-healing): mirror the multiRepo → planning.sub_repos
* precedent: graft branching_strategy into fileData.git.branching_strategy
* and delete the top-level key, then persist. The KNOWN_TOP_LEVEL list also
* gains 'branching_strategy' as a deprecated-still-accepted key so the warning
* never fires even on the first read before the write-back occurs.
*
* Double-emission is also reduced: the warning site is guarded by a
* module-level Set so repeated loadConfig calls during one CLI invocation
* don't echo the same line twice.
*
* CJS↔SDK contract: the SDK mergeDefaults() already handles the legacy
* top-level key (PR #3116). This file adds a fixture-level parity check
* that proves both paths produce the same branching_strategy value.
*
* Test strategy: we use `resolve-model` as the minimal CJS entry point that
* calls loadConfig internally, then assert on stderr emptiness (typed-IR
* "no warning" pattern from #2687).
*/
const { describe, test, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { spawnSync } = require('node:child_process');
const { createTempProject, cleanup, TOOLS_PATH } = require('./helpers.cjs');
const TEST_ENV_BASE = {
GSD_SESSION_KEY: '',
CODEX_THREAD_ID: '',
CLAUDE_SESSION_ID: '',
CLAUDE_CODE_SSE_PORT: '',
OPENCODE_SESSION_ID: '',
GEMINI_SESSION_ID: '',
CURSOR_SESSION_ID: '',
WINDSURF_SESSION_ID: '',
TERM_SESSION_ID: '',
WT_SESSION: '',
TMUX_PANE: '',
ZELLIJ_SESSION_NAME: '',
TTY: '',
SSH_TTY: '',
};
/**
* Run gsd-tools and return { stdout, stderr, status }.
* Always captures stderr even when exit code is 0.
*/
function runWithStderr(args, cwd, env = {}) {
const result = spawnSync(process.execPath, [TOOLS_PATH, ...args], {
cwd,
encoding: 'utf-8',
env: { ...process.env, ...TEST_ENV_BASE, ...env },
});
return {
stdout: result.stdout || '',
stderr: result.stderr || '',
status: result.status,
};
}
// ─── Test 1: no warning for legacy top-level branching_strategy ──────────────
describe('bug-3523 — no warning for legacy top-level branching_strategy', () => {
let tmpDir;
afterEach(() => {
if (tmpDir) cleanup(tmpDir);
tmpDir = null;
});
test('loadConfig emits no stderr when config.json has top-level branching_strategy', () => {
tmpDir = createTempProject('gsd-3523-warn-');
const configPath = path.join(tmpDir, '.planning', 'config.json');
fs.writeFileSync(
configPath,
JSON.stringify({
branching_strategy: 'phase',
git: { base_branch: 'main' },
}, null, 2),
'utf-8'
);
// resolve-model calls loadConfig internally, triggering KNOWN_TOP_LEVEL check.
const result = runWithStderr(['resolve-model', 'planner'], tmpDir);
assert.equal(
result.stderr.trim(),
'',
`loadConfig must not warn about top-level branching_strategy (#3523) — got: ${result.stderr}`
);
});
test('branching_strategy value is still surfaced after loadConfig on legacy shape', () => {
tmpDir = createTempProject('gsd-3523-value-');
const configPath = path.join(tmpDir, '.planning', 'config.json');
fs.writeFileSync(
configPath,
JSON.stringify({
branching_strategy: 'milestone',
git: { base_branch: 'main' },
}, null, 2),
'utf-8'
);
// Trigger loadConfig (which runs the migration and writes git.branching_strategy
// back to disk), then read it with config-get to verify the value is preserved.
const triggerResult = runWithStderr(['resolve-model', 'planner'], tmpDir);
assert.equal(
triggerResult.stderr.trim(),
'',
`No warning should fire on legacy shape (#3523) — got: ${triggerResult.stderr}`
);
// After migration write-back, config-get should find git.branching_strategy.
const result = runWithStderr(['config-get', 'git.branching_strategy'], tmpDir);
assert.equal(
result.status,
0,
`config-get command must succeed — exit status ${result.status}, stderr: ${result.stderr}`
);
assert.equal(
result.stderr.trim(),
'',
`No error should fire when reading migrated branching_strategy (#3523) — got: ${result.stderr}`
);
assert.ok(
result.stdout.includes('milestone'),
`Expected git.branching_strategy to be 'milestone' but got: ${result.stdout}`
);
});
});
// ─── Test 2: no duplicated warning (double-emission) ─────────────────────────
describe('bug-3523 — double-emission reduced to single-emission', () => {
let tmpDir;
afterEach(() => {
if (tmpDir) cleanup(tmpDir);
tmpDir = null;
});
test('unknown-key warning appears at most once per process invocation', () => {
// Use a key that IS genuinely unknown (not branching_strategy, which is now
// fixed) to verify the deduplication guard works for other keys too.
// We verify that the count of warning lines for a single unknown key is
// exactly once — not zero and not two — even if loadConfig is invoked twice internally.
tmpDir = createTempProject('gsd-3523-dedup-');
const configPath = path.join(tmpDir, '.planning', 'config.json');
fs.writeFileSync(
configPath,
JSON.stringify({
// intentionally_unknown_key_for_dedup_test: a key that can never be valid
__gsd3523_dedup_sentinel__: true,
}, null, 2),
'utf-8'
);
const result = runWithStderr(['resolve-model', 'planner'], tmpDir);
// Count how many times the sentinel key appears in warnings
const warningLines = result.stderr
.split('\n')
.filter(l => l.includes('__gsd3523_dedup_sentinel__'));
assert.equal(
warningLines.length,
1,
`Unknown-key warning must appear exactly once per process invocation — ` +
`appeared ${warningLines.length} times. stderr:\n${result.stderr}`
);
});
});
// ─── Test 3: on-disk migration (option 3 write-back) ─────────────────────────
describe('bug-3523 — option 3 on-disk migration of branching_strategy', () => {
let tmpDir;
afterEach(() => {
if (tmpDir) cleanup(tmpDir);
tmpDir = null;
});
test('after loadConfig, on-disk config.json has branching_strategy under git.*', () => {
tmpDir = createTempProject('gsd-3523-writeback-');
const configPath = path.join(tmpDir, '.planning', 'config.json');
fs.writeFileSync(
configPath,
JSON.stringify({
branching_strategy: 'phase',
git: { base_branch: 'main' },
}, null, 2),
'utf-8'
);
// Trigger loadConfig by running a command.
runWithStderr(['resolve-model', 'planner'], tmpDir);
// On-disk file should now have git.branching_strategy and no top-level branching_strategy.
const onDisk = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
assert.equal(
onDisk.git?.branching_strategy,
'phase',
'Expected on-disk config.json to have git.branching_strategy = "phase" after migration'
);
assert.equal(
onDisk.branching_strategy,
undefined,
'Expected on-disk config.json to have no top-level branching_strategy after migration'
);
});
test('migration does not clobber existing git.branching_strategy', () => {
// If git.branching_strategy is already set, the top-level value should
// not overwrite it (nested wins, matching SDK mergeDefaults precedence).
tmpDir = createTempProject('gsd-3523-no-clobber-');
const configPath = path.join(tmpDir, '.planning', 'config.json');
fs.writeFileSync(
configPath,
JSON.stringify({
branching_strategy: 'phase', // legacy top-level
git: {
base_branch: 'main',
branching_strategy: 'milestone', // canonical nested — must win
},
}, null, 2),
'utf-8'
);
// Trigger loadConfig.
runWithStderr(['resolve-model', 'planner'], tmpDir);
const onDisk = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
assert.equal(
onDisk.git?.branching_strategy,
'milestone',
'canonical git.branching_strategy must not be overwritten by legacy top-level key'
);
// top-level key should be removed since it was redundant
assert.equal(
onDisk.branching_strategy,
undefined,
'top-level branching_strategy should be removed even when git.branching_strategy already set'
);
});
test('workstream load also self-heals legacy root branching_strategy', () => {
tmpDir = createTempProject('gsd-3523-workstream-root-');
const rootConfigPath = path.join(tmpDir, '.planning', 'config.json');
const workstreamDir = path.join(tmpDir, '.planning', 'workstreams', 'alpha');
fs.mkdirSync(workstreamDir, { recursive: true });
fs.writeFileSync(
rootConfigPath,
JSON.stringify({
branching_strategy: 'phase',
git: { base_branch: 'main' },
}, null, 2),
'utf-8'
);
fs.writeFileSync(
path.join(workstreamDir, 'config.json'),
JSON.stringify({ workflow: { tdd: true } }, null, 2),
'utf-8'
);
const triggerResult = runWithStderr(['resolve-model', 'planner'], tmpDir, {
GSD_WORKSTREAM: 'alpha',
});
assert.equal(
triggerResult.status,
0,
`workstream load command must succeed — exit status ${triggerResult.status}, stderr: ${triggerResult.stderr}`
);
assert.equal(
triggerResult.stderr.trim(),
'',
`No warning should fire while migrating root config for a workstream — got: ${triggerResult.stderr}`
);
const onDisk = JSON.parse(fs.readFileSync(rootConfigPath, 'utf-8'));
assert.equal(
onDisk.git?.branching_strategy,
'phase',
'Expected root config.json to persist git.branching_strategy after workstream load'
);
assert.equal(
onDisk.branching_strategy,
undefined,
'Expected root config.json to remove top-level branching_strategy after workstream load'
);
const rootResult = runWithStderr(['config-get', 'git.branching_strategy'], tmpDir);
assert.equal(
rootResult.status,
0,
`root config-get command must succeed after workstream migration — exit status ${rootResult.status}, stderr: ${rootResult.stderr}`
);
assert.ok(
rootResult.stdout.includes('phase'),
`Expected migrated root git.branching_strategy to be 'phase' but got: ${rootResult.stdout}`
);
});
});
// ─── Test 4: CJS↔SDK contract parity ────────────────────────────────────────
describe('bug-3523 — CJS↔SDK contract: both agree on legacy branching_strategy fixture', () => {
/**
* This is a light-touch contract test: we invoke the CJS path via CLI and
* compare the branching_strategy value it returns against what the SDK's
* mergeDefaults would compute for the same fixture.
*
* We can't import SDK TypeScript here, so we assert on the CJS output and
* use a snapshot of expected SDK behavior derived from the mergeDefaults
* source (sdk/src/config.ts:192-218):
* mergeDefaults({ branching_strategy: 'phase', git: { base_branch: 'main' } })
* → git.branching_strategy = 'phase'
*/
let tmpDir;
afterEach(() => {
if (tmpDir) cleanup(tmpDir);
tmpDir = null;
});
test('CJS loadConfig surfaces branching_strategy matching SDK mergeDefaults behavior', () => {
tmpDir = createTempProject('gsd-3523-parity-');
const configPath = path.join(tmpDir, '.planning', 'config.json');
// The fixture that the SDK's mergeDefaults handles correctly (PR #3116).
fs.writeFileSync(
configPath,
JSON.stringify({
branching_strategy: 'phase',
git: { base_branch: 'main' },
}, null, 2),
'utf-8'
);
// SDK mergeDefaults produces: git.branching_strategy = 'phase'
// CJS loadConfig must produce the same. Trigger loadConfig first (migration
// writes git.branching_strategy to disk), then verify with config-get.
const triggerResult = runWithStderr(['resolve-model', 'planner'], tmpDir);
assert.equal(
triggerResult.stderr.trim(),
'',
`No warning must fire on a standard legacy fixture — got: ${triggerResult.stderr}`
);
// After the migration write-back, config-get must find git.branching_strategy = 'phase',
// matching what the SDK's mergeDefaults would compute.
const result = runWithStderr(['config-get', 'git.branching_strategy'], tmpDir);
assert.equal(
result.status,
0,
`config-get command must succeed — exit status ${result.status}, stderr: ${result.stderr}`
);
assert.equal(
result.stderr.trim(),
'',
`No error when reading post-migration git.branching_strategy — got: ${result.stderr}`
);
assert.ok(
result.stdout.includes('phase'),
`CJS must agree with SDK: git.branching_strategy = 'phase' for legacy fixture. ` +
`Got: ${result.stdout}`
);
});
});
});
}

View File

@@ -296,3 +296,133 @@ requirements-completed: [TEST-01]
assert.deepStrictEqual(parsed.requirements_completed, ['TEST-01'], 'requirements_completed should contain TEST-01');
});
});
// ────────────────────────────────────────────────────────────────────────
// Folded from tests/bug-3019-help-passthrough.test.cjs — consolidation epic #1969 (B6 #1975)
// ────────────────────────────────────────────────────────────────────────
{
const { describe: __foldDescribe } = require('node:test');
__foldDescribe("folded:bug-3019-help-passthrough (consolidation epic #1969 B6 #1975)", () => {
/**
* Regression test for bug #3019.
*
* `gsd-sdk query <subcommand> --help` returned the top-level SDK USAGE
* instead of contextual help for the subcommand. The query argv parser
* harvested --help as a global flag and main() short-circuited dispatch
* before the registry handler / gsd-tools.cjs fallback could render
* useful help.
*
* Two-layer fix:
* 1. sdk/src/cli.ts — leave --help in queryArgv so it travels to the
* handler/fallback. Only honor the global help flag when there is
* no subcommand to dispatch to.
* 2. gsd-core/bin/gsd-tools.cjs — render the top-level usage on
* --help instead of erroring. Anti-hallucination invariant from
* #1818 is preserved (the destructive command never executes).
*
* Tests the integration: invoke gsd-tools.cjs the same way the SDK
* dispatcher does and assert structured-IR (success flag + usage shape)
* rather than raw substring matches.
*/
'use strict';
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const { runGsdTools, isUsageOutput } = require('./helpers.cjs');
// #3026 CR (Major outside-diff): the SDK fallback wraps gsd-tools.cjs.
// When gsd-tools emits plain-text help (exit 0), the SDK previously
// JSON.parsed stdout and threw "Unexpected token 'U'". Verify the fix
// by invoking the built SDK end-to-end and asserting:
// - exit 0
// - stdout contains the gsd-tools usage
// - stderr does NOT contain a JSON parse error
const path = require('node:path');
const { spawnSync } = require('node:child_process');
const SDK_CLI = path.join(__dirname, '..', 'sdk', 'dist', 'cli.js');
const fs = require('node:fs');
describe('bug #3026 (CR Major outside-diff): SDK forwards plain-text help from gsd-tools fallback', () => {
test('gsd-sdk query phase --help (fallback path) returns usage, not a JSON parse error', (t) => {
if (!fs.existsSync(SDK_CLI)) {
// CR feedback (#3026): a bare `return` here silent-passes the test
// when sdk/dist/cli.js is absent (CI checkouts that haven't run
// `npm run build`), giving no signal that the integration check
// was skipped. Use t.skip() so the omission is visible in the
// test report. The unit-level fix is covered by vitest on
// sdk/src/cli.ts; this integration test only runs when the
// built SDK is on disk.
t.skip('sdk/dist/cli.js not built — run `npm run build` in sdk/ to enable this integration test');
return;
}
// `query phase --help` (no further subcommand) is NOT in the native
// registry, so it routes through the gsd-tools.cjs fallback. That is
// the path that JSON.parsed the help text and threw before this fix.
const result = spawnSync(process.execPath, [SDK_CLI, 'query', 'phase', '--help'], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
timeout: 10000,
});
// The fallback gsd-tools.cjs emits exit 0 with usage on stdout.
assert.strictEqual(result.status, 0,
`must exit 0 — got ${result.status}\nstdout: ${result.stdout}\nstderr: ${result.stderr}`);
// Negative: must NOT see the JSON parse error that was the regression.
assert.ok(!/Unexpected token|not valid JSON/i.test(result.stderr),
`must NOT JSON.parse the help text (stderr): ${result.stderr}`);
// Positive: the usage should reach the user via stdout.
assert.ok(/Usage:\s*gsd-tools/.test(result.stdout) && /Commands:/.test(result.stdout),
`usage must reach stdout: ${result.stdout}`);
});
});
describe('bug #3019: gsd-tools renders usage on --help instead of erroring', () => {
test('bare gsd-tools (no args) renders usage', () => {
const result = runGsdTools([]);
// No args path: error() helper emits to stderr and exits non-zero,
// but the message body is the usage.
assert.strictEqual(result.success, false);
assert.ok(/Usage:\s*gsd-tools/.test(result.error));
assert.ok(/Commands:/.test(result.error));
});
test('gsd-tools --help renders usage on stdout, exits 0', () => {
const result = runGsdTools(['--help']);
assert.strictEqual(result.success, true, '--help should not be an error');
assert.ok(isUsageOutput(result.output), `expected usage on stdout, got: ${result.output}`);
});
test('gsd-tools -h renders usage on stdout, exits 0', () => {
const result = runGsdTools(['-h']);
assert.strictEqual(result.success, true);
assert.ok(isUsageOutput(result.output));
});
test('gsd-tools <subcommand> --help renders usage (does not run subcommand)', () => {
// The classic #3019 surface: the user types a subcommand expecting
// contextual help. We render the top-level usage — strictly better
// than the previous unhelpful "Unknown flag --help" error.
const result = runGsdTools(['phase', 'add', '--help']);
assert.strictEqual(result.success, true);
assert.ok(isUsageOutput(result.output));
});
test('usage hint mentions how to discover argument requirements', () => {
// The usage now points users at the discovery method that actually works
// (run without args → error message names required arguments). Asserting
// on the parsed shape of the usage rather than substring-matching prose:
const result = runGsdTools(['--help']);
assert.strictEqual(result.success, true);
// Structural check: split into sections.
const lines = result.output.split('\n');
const hasUsageLine = lines.some((l) => l.startsWith('Usage:'));
const hasCommandsLine = lines.some((l) => l.startsWith('Commands:'));
const hasDiscoveryHint = lines.some((l) => /argument requirements|without args|invoke the command/i.test(l));
assert.ok(hasUsageLine, 'first section: Usage');
assert.ok(hasCommandsLine, 'second section: Commands');
assert.ok(hasDiscoveryHint, 'third section: how to discover per-command args');
});
});
});
}

View File

@@ -794,3 +794,154 @@ describe('verify codebase-drift — workflow config read from nested shape (#149
'1 structural file must not exceed threshold of 100');
});
});
// ────────────────────────────────────────────────────────────────────────
// Folded from tests/bug-619-codebase-drift-gate-shim.test.cjs — consolidation epic #1969 (B6 #1975)
// ────────────────────────────────────────────────────────────────────────
{
const { describe: __foldDescribe } = require('node:test');
__foldDescribe("folded:bug-619-codebase-drift-gate-shim (consolidation epic #1969 B6 #1975)", () => {
// allow-test-rule: source-text-is-the-product (see #619)
// codebase-drift-gate.md is the shipped orchestration step contract. Bug #619:
// the initial drift check ran the bare PATH binary `gsd-tools verify codebase-drift`.
// On a shim-only install (gsd-tools.cjs present, `gsd-tools` not on PATH) that exits
// 127, `2>/dev/null` hides it, and the `|| echo` fallback marks the gate skipped —
// so post-execution drift detection silently never runs. The fix resolves gsd-tools
// through the runtime shim launcher (gsd_run), defining the canonical preamble once in
// this always-run block so the file stays compliant with the single-preamble parity
// invariant (the conditional auto-remap block reuses the launcher via shared shell scope).
//
// This file locks the source contract AND behaviorally proves the shim resolves: it runs
// the exact shipped drift-check block against a shim-only topology and asserts the shim
// actually executes, where the old bare-binary form would have skipped.
'use strict';
const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { execFileSync } = require('node:child_process');
const { cleanup } = require('./helpers.cjs');
const GATE_MD = path.join(
__dirname, '..', 'gsd-core', 'workflows', 'execute-phase', 'steps', 'codebase-drift-gate.md',
);
const SNIPPET_FILE = path.join(__dirname, '..', 'gsd-core', 'workflows', '_runtime-launcher.snippet.sh');
function readGate() {
return fs.readFileSync(GATE_MD, 'utf8');
}
// Extract the Nth (0-based) ```bash fenced block body from the file.
function bashBlock(content, n) {
const blocks = [];
const re = /```bash\r?\n([\s\S]*?)```/g;
let m;
while ((m = re.exec(content)) !== null) blocks.push(m[1]);
assert.ok(blocks.length > n, `expected at least ${n + 1} bash blocks, found ${blocks.length}`);
return blocks[n];
}
describe('bug #619 — codebase-drift-gate resolves gsd-tools via the runtime shim, not the bare PATH binary', () => {
test('codebase-drift-gate.md is readable', () => {
assert.ok(readGate().length > 0, 'codebase-drift-gate.md must not be empty');
});
// ── Source contract (the .md is the product) ──────────────────────────────
test('the drift check resolves gsd-tools via the shim launcher (gsd_run), not the bare binary (#619)', () => {
const content = readGate();
assert.match(
content,
/DRIFT=\$\(gsd_run verify codebase-drift 2>\/dev\/null \|\| echo '\{"skipped":true,"reason":"sdk-failed"\}'\)/,
'drift check must call `gsd_run verify codebase-drift` with the non-blocking skip fallback',
);
assert.doesNotMatch(
content,
/\bgsd-tools verify codebase-drift\b/,
'the bare `gsd-tools verify codebase-drift` PATH-binary call (the #619 bug) must be gone',
);
});
test('non-blocking contract preserved: the skip JSON fallback is intact (#619)', () => {
const content = readGate();
assert.match(
content,
/\|\| echo '\{"skipped":true,"reason":"sdk-failed"\}'/,
'an internal drift-command failure must still fall through to the skip JSON',
);
});
test('exactly one canonical launcher preamble, in the drift-check block, before any launcher call (#619)', () => {
const content = readGate();
const snippet = fs.readFileSync(SNIPPET_FILE, 'utf8').replace(/\r?\n$/, '');
// Count canonical preamble occurrences across the whole file (parity: exactly one).
let count = 0;
let pos = 0;
for (;;) {
const idx = content.indexOf(snippet, pos);
if (idx === -1) break;
count++;
pos = idx + snippet.length;
}
assert.equal(count, 1, `expected exactly one canonical preamble; found ${count}`);
// The preamble must live in the first (drift-check) bash block, before the DRIFT call.
const block0 = bashBlock(content, 0);
assert.ok(block0.includes(snippet), 'the canonical preamble must be in the drift-check block');
assert.ok(
block0.indexOf(snippet) < block0.indexOf('gsd_run verify codebase-drift'),
'the preamble must precede the gsd_run drift call in the same block',
);
// The auto-remap block reuses gsd_run but must NOT carry its own preamble.
const content2 = content.slice(content.indexOf('AGENT_SKILLS_MAPPER'));
assert.ok(!content2.includes(snippet), 'the auto-remap block must not re-declare the preamble (single-preamble parity)');
});
// ── Behavioral proof: the shim resolves on a shim-only topology ───────────
test('shipped drift-check block runs the shim (gsd-tools.cjs), not skip, on a shim-only install (#619)', () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-619-'));
try {
// Shim-only topology: gsd-tools.cjs present under RUNTIME_DIR; no `gsd-tools` on PATH.
const binDir = path.join(tmp, 'gsd-core', 'bin');
fs.mkdirSync(binDir, { recursive: true });
fs.writeFileSync(
path.join(binDir, 'gsd-tools.cjs'),
'if (process.argv[2] === "verify" && process.argv[3] === "codebase-drift") {\n' +
' process.stdout.write(JSON.stringify({ action_required: false, sentinel: "SHIM_RAN" }));\n' +
'}\n',
);
const block = bashBlock(readGate(), 0) + '\nprintf "%s" "$DRIFT"\n';
const out = execFileSync('bash', ['-c', block], {
env: { ...process.env, RUNTIME_DIR: tmp },
encoding: 'utf8',
});
assert.match(out, /SHIM_RAN/, 'the drift check must execute the resolved shim, proving gsd_run resolution');
assert.doesNotMatch(out, /sdk-failed/, 'the gate must NOT silently skip when the shim is present (#619)');
} finally {
cleanup(tmp);
}
});
test('red-proof: the old bare `gsd-tools` form would skip when gsd-tools is not on PATH', () => {
// Documents the #619 bug: the pre-fix bare-binary call, with no `gsd-tools` on PATH,
// hits the 127 → `|| echo` skip path even though the shim (gsd-tools.cjs) exists.
const oldForm =
'DRIFT=$(gsd-tools verify codebase-drift 2>/dev/null || echo \'{"skipped":true,"reason":"sdk-failed"}\'); printf "%s" "$DRIFT"';
const out = execFileSync('bash', ['-c', 'export PATH=/nonexistent-empty-path; ' + oldForm], {
env: { ...process.env },
encoding: 'utf8',
});
assert.match(out, /sdk-failed/, 'sanity: the bare-binary form skips without gsd-tools on PATH — the bug the fix removes');
});
});
});
}

View File

@@ -1,177 +0,0 @@
'use strict';
// allow-test-rule: source-text-is-the-product see #1592
// The plan-phase.md host-dispatch assertions below read the workflow .md file — its text IS the
// deployed contract the runtime loads (CONTRIBUTING.md exemption category). The registry assertions
// are behavioral: they build the registry from the REAL capabilities/drift declaration via the
// generator, so they fail if the plan:pre gate is ever removed or mutated.
/**
* Enhancement (#1592): plan-time codebase-map freshness pre-check.
*
* The `drift` capability gains a non-blocking `plan:pre` codebase-drift gate so a stale codebase map is
* flagged BEFORE planning, instead of being discovered mid-execution by the existing
* `execute:wave:post` codebase-drift gate. Warn-only at `plan:pre` (no mapper-agent spawn): the
* capability's `drift_action: auto-remap` stays at `execute:wave:post`, so plan time never pays
* speculative mapper-agent cost.
*
* Per maintainer review on #1592 (mod 1a), the plan:pre gate is gated on a DEDICATED
* `workflow.plan_drift_precheck` toggle (default true) rather than reusing `workflow.schema_drift_gate`,
* so autonomous/CI runs can silence the plan-time advisory without disabling the execute-time gates.
* The gate declaration conforms to ADR-857 (`plan:pre` is an enumerated, additive-only loop point).
*
* Issue: #1592 (open-gsd/gsd-core).
*/
const { describe, test, after } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { loadAndValidate, buildRegistry } = require('../scripts/gen-capability-registry.cjs');
const { cleanup } = require('./helpers.cjs');
const REPO_ROOT = path.join(__dirname, '..');
const DRIFT_CAP = JSON.parse(
fs.readFileSync(path.join(REPO_ROOT, 'capabilities', 'drift', 'capability.json'), 'utf8'),
);
const PLAN_PHASE = fs.readFileSync(
path.join(REPO_ROOT, 'gsd-core', 'workflows', 'plan-phase.md'),
'utf8',
);
// Track every temp dir created so the suite can remove them on teardown — leaked
// mkdtemp dirs have been a flake source here before (per #1592 review).
const tempCapDirs = [];
function makeTempCapDir(capabilities) {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'enh-1592-'));
tempCapDirs.push(tmpDir);
for (const [id, cap] of Object.entries(capabilities)) {
const subDir = path.join(tmpDir, id);
fs.mkdirSync(subDir, { recursive: true });
fs.writeFileSync(path.join(subDir, 'capability.json'), JSON.stringify(cap), 'utf8');
}
return tmpDir;
}
after(() => {
for (const dir of tempCapDirs) {
cleanup(dir);
}
});
function planPreDriftGate() {
const capDir = makeTempCapDir({ drift: DRIFT_CAP });
const { capMap, errors } = loadAndValidate(new Set(), capDir);
assert.deepEqual(errors, [], 'drift capability should validate cleanly: ' + JSON.stringify(errors));
const registry = buildRegistry(capMap);
const planPreGates = registry.byLoopPoint['plan:pre'].gates;
assert.ok(Array.isArray(planPreGates), 'plan:pre.gates should be an array');
return planPreGates.find(
(g) => g.capId === 'drift' && g.check && g.check.query === 'verify.codebase-drift',
);
}
describe('#1592 — drift plan:pre codebase-drift gate (registry, behavioral)', () => {
test('the real drift capability registers a non-blocking plan:pre codebase-drift gate', () => {
const driftGate = planPreDriftGate();
assert.ok(driftGate, 'plan:pre.gates must contain the drift codebase-drift gate');
assert.strictEqual(driftGate.blocking, false, 'plan-time drift gate must be NON-blocking');
assert.strictEqual(driftGate.onError, 'skip', 'must fail-soft (skip) — never halt planning');
});
test('the plan:pre gate is gated on the dedicated plan_drift_precheck toggle (mod 1a)', () => {
const driftGate = planPreDriftGate();
assert.strictEqual(
driftGate.when,
'workflow.plan_drift_precheck',
'plan:pre drift gate must use the dedicated toggle so CI/autonomous runs can silence it ' +
'without disabling the execute-time gates',
);
});
test('the execute:wave:post codebase-drift gate is preserved and keeps its OWN toggle (no regression)', () => {
const capDir = makeTempCapDir({ drift: DRIFT_CAP });
const { capMap } = loadAndValidate(new Set(), capDir);
const registry = buildRegistry(capMap);
const execGates = registry.byLoopPoint['execute:wave:post'].gates;
const stillThere = execGates.find(
(g) => g.capId === 'drift' && g.check && g.check.query === 'verify.codebase-drift',
);
assert.ok(stillThere, 'execute:wave:post codebase-drift gate must remain after adding the plan:pre gate');
assert.strictEqual(stillThere.blocking, false, 'execute codebase-drift gate stays non-blocking');
assert.strictEqual(
stillThere.when,
'workflow.schema_drift_gate',
'the execute-time gate keeps schema_drift_gate — the plan-time toggle is separable from it',
);
});
test('plan_drift_precheck is a separate toggle from schema_drift_gate (silencing is independent)', () => {
const planWhen = planPreDriftGate().when;
assert.notStrictEqual(
planWhen,
'workflow.schema_drift_gate',
'silencing the plan-time advisory must not require disabling the execute-time gates',
);
});
test('plan_drift_precheck is declared as a boolean defaulting to true', () => {
const cfg = DRIFT_CAP.config['workflow.plan_drift_precheck'];
assert.ok(cfg, 'workflow.plan_drift_precheck must be declared in the drift capability config');
assert.strictEqual(cfg.type, 'boolean', 'plan_drift_precheck must be a boolean');
assert.strictEqual(cfg.default, true, 'plan_drift_precheck must default to true (on by default)');
});
test('exactly one new config key is introduced (the dedicated plan_drift_precheck toggle)', () => {
const keys = Object.keys(DRIFT_CAP.config).sort();
assert.deepStrictEqual(
keys,
[
'workflow.drift_action',
'workflow.drift_threshold',
'workflow.plan_drift_precheck',
'workflow.schema_drift_gate',
],
'the plan:pre gate adds exactly the dedicated plan_drift_precheck toggle — no other new keys',
);
});
});
describe('#1592 — plan-phase host dispatches the drift plan:pre gate before planning', () => {
const SECTION = PLAN_PHASE.slice(
PLAN_PHASE.indexOf('5.65. Codebase Map Freshness Pre-Check'),
PLAN_PHASE.indexOf('## 6. Check Existing Plans'),
);
test('§5.65 invokes the verify codebase-drift check', () => {
assert.match(PLAN_PHASE, /5\.65\. Codebase Map Freshness Pre-Check/, 'plan-phase must declare §5.65');
assert.match(PLAN_PHASE, /gsd_run verify codebase-drift/, '§5.65 must invoke `verify codebase-drift`');
});
test('the drift pre-check runs BEFORE the planner spawn (load-bearing ordering)', () => {
const preCheckIdx = PLAN_PHASE.indexOf('5.65. Codebase Map Freshness Pre-Check');
const plannerIdx = PLAN_PHASE.indexOf('## 8. Spawn gsd-planner Agent');
assert.ok(preCheckIdx > 0, '§5.65 must exist');
assert.ok(plannerIdx > 0, '§8 planner spawn must exist');
assert.ok(
preCheckIdx < plannerIdx,
'the drift map-freshness pre-check must run before the planner is spawned — the whole point of #1592',
);
});
test('§5.65 is documented as non-blocking and warn-only (no spawn)', () => {
assert.match(SECTION, /non-blocking/i, '§5.65 must state the gate is non-blocking');
assert.match(SECTION, /never blocks, never spawns/i, '§5.65 must state it never spawns the mapper at plan time');
});
test('§5.65 gates on the dedicated plan_drift_precheck toggle (mod 1a)', () => {
assert.match(
SECTION,
/workflow\.plan_drift_precheck/,
'§5.65 must dispatch on the dedicated plan_drift_precheck toggle, not schema_drift_gate',
);
});
});

View File

@@ -1,208 +0,0 @@
'use strict';
// allow-test-rule: source-text-is-the-product
// Reads .md/.json/.yml product files whose deployed text IS what the
// runtime loads — testing text content tests the deployed contract.
/**
* Tests for #2380 — /gsd-sync-skills cross-runtime skill sync.
*
* Verifies:
* 1. install.js --skills-root <runtime> resolves correct paths
* 2. sync-skills.md workflow covers required behavioral specs
* 3. commands/gsd/sync-skills.md slash command exists
* 4. INVENTORY in sync
*/
const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { spawnSync } = require('node:child_process');
const os = require('node:os');
const INSTALL_JS = path.join(__dirname, '../bin/install.js');
const WORKFLOW = path.join(__dirname, '../gsd-core/workflows/sync-skills.md');
const COMMAND = path.join(__dirname, '../commands/gsd/sync-skills.md');
function readWorkflow() {
return fs.readFileSync(WORKFLOW, 'utf-8');
}
// ── install.js --skills-root ──────────────────────────────────────────────────
describe('install.js --skills-root', () => {
const CASES = [
{ runtime: 'claude', expected: path.join(os.homedir(), '.claude', 'skills') },
{ runtime: 'codex', expected: path.join(os.homedir(), '.codex', 'skills') },
{ runtime: 'copilot', expected: path.join(os.homedir(), '.copilot', 'skills') },
{ runtime: 'cursor', expected: path.join(os.homedir(), '.cursor', 'skills') },
{ runtime: 'gemini', expected: path.join(os.homedir(), '.gemini', 'skills') },
];
for (const { runtime, expected } of CASES) {
test(`resolves correct skills root for ${runtime}`, () => {
const result = spawnSync(process.execPath, [INSTALL_JS, '--skills-root', runtime], {
encoding: 'utf-8',
env: { ...process.env, GSD_TEST_MODE: undefined }, // ensure not in test mode
});
// Strip trailing newline
const actual = result.stdout.trim();
assert.strictEqual(actual, expected, `Expected ${expected}, got ${actual}`);
});
}
test('exits non-zero when runtime arg is missing', () => {
const result = spawnSync(process.execPath, [INSTALL_JS, '--skills-root'], {
encoding: 'utf-8',
});
assert.notStrictEqual(result.status, 0, 'Should exit with error when runtime arg is missing');
});
test('returns a path ending in /skills', () => {
const result = spawnSync(process.execPath, [INSTALL_JS, '--skills-root', 'windsurf'], {
encoding: 'utf-8',
});
assert.ok(result.stdout.trim().endsWith('skills'), 'Skills root must end in /skills');
});
});
// ── sync-skills.md workflow content ──────────────────────────────────────────
describe('sync-skills.md — required behavioral specs', () => {
let content;
test('workflow file exists', () => {
content = readWorkflow();
assert.ok(content.length > 0, 'sync-skills.md must exist and be non-empty');
});
test('--dry-run is the default (no writes without --apply)', () => {
content = content || readWorkflow();
assert.ok(
content.includes('dry-run') && (content.includes('default') || content.includes('Default')),
'workflow must document --dry-run as default'
);
});
test('--apply flag is required to execute writes', () => {
content = content || readWorkflow();
assert.ok(content.includes('--apply'), 'workflow must document --apply flag');
});
test('--from flag documented', () => {
content = content || readWorkflow();
assert.ok(content.includes('--from'), 'workflow must document --from flag');
});
test('--to flag documented (runtime|all)', () => {
content = content || readWorkflow();
assert.ok(
content.includes('--to') && content.includes('all'),
'workflow must document --to flag with "all" option'
);
});
test('only gsd-* directories are touched (non-GSD preservation)', () => {
content = content || readWorkflow();
assert.ok(
content.includes('gsd-*') && (content.includes('non-GSD') || content.includes('Non-GSD') || content.includes('not starting with')),
'workflow must document that only gsd-* dirs are modified'
);
});
test('idempotency documented (second apply = zero changes)', () => {
content = content || readWorkflow();
assert.ok(
content.includes('dempoten') || content.includes('Idempoten') || content.includes('zero changes') || content.includes('second run'),
'workflow must document idempotency'
);
});
test('install.js --skills-root is used for path resolution', () => {
content = content || readWorkflow();
assert.ok(
content.includes('--skills-root'),
'workflow must reference install.js --skills-root for path resolution'
);
});
test('diff report format: CREATE / UPDATE / REMOVE documented', () => {
content = content || readWorkflow();
assert.ok(content.includes('CREATE'), 'workflow must document CREATE in diff report');
assert.ok(content.includes('UPDATE'), 'workflow must document UPDATE in diff report');
assert.ok(content.includes('REMOVE'), 'workflow must document REMOVE in diff report');
});
test('source-not-found error guidance documented', () => {
content = content || readWorkflow();
assert.ok(
content.includes('source skills root not found') || content.includes('source root') || content.includes('not found'),
'workflow must document error when source skills root is missing'
);
});
test('safety rule: dry-run performs no writes', () => {
content = content || readWorkflow();
const safetySection = content.includes('Safety Rules') || content.includes('safety');
assert.ok(
safetySection || content.includes('no writes') || content.includes('--dry-run performs no writes'),
'workflow must have a safety rule that dry-run performs no writes'
);
});
});
// ── commands/gsd/sync-skills.md ───────────────────────────────────────────────
// #2790: sync-skills.md was consolidated into update.md as the --sync flag.
describe('commands/gsd/sync-skills.md', () => {
test('sync-skills is now --sync flag on update.md (#2790)', () => {
const updateCmd = path.join(__dirname, '../commands/gsd/update.md');
assert.ok(fs.existsSync(updateCmd), 'commands/gsd/update.md must exist');
const content = fs.readFileSync(updateCmd, 'utf-8');
assert.ok(
content.includes('--sync'),
'update.md must document --sync flag (absorbed sync-skills)'
);
});
test('sync-skills.md command file is deleted (#2790)', () => {
assert.ok(!fs.existsSync(COMMAND), 'commands/gsd/sync-skills.md should be deleted (consolidated into update.md)');
});
});
// ── INVENTORY sync ────────────────────────────────────────────────────────────
describe('INVENTORY sync', () => {
test('INVENTORY.md lists /gsd-update --sync command (#2790: absorbed /gsd-sync-skills)', () => {
const inventory = fs.readFileSync(path.join(__dirname, '../docs/INVENTORY.md'), 'utf-8');
assert.ok(inventory.includes('/gsd-update --sync'), 'INVENTORY.md must list /gsd-update --sync (absorbed /gsd-sync-skills in #2790)');
});
test('INVENTORY.md lists sync-skills.md workflow', () => {
const inventory = fs.readFileSync(path.join(__dirname, '../docs/INVENTORY.md'), 'utf-8');
assert.ok(inventory.includes('sync-skills.md'), 'INVENTORY.md must list sync-skills.md workflow');
});
test('INVENTORY-MANIFEST.json includes /gsd-update (#2790: sync-skills absorbed into update.md --sync)', () => {
// #2790: /gsd-sync-skills was absorbed into /gsd-update as the --sync flag.
// The manifest now records /gsd-update instead of /gsd-sync-skills.
const manifest = JSON.parse(
fs.readFileSync(path.join(__dirname, '../docs/INVENTORY-MANIFEST.json'), 'utf-8')
);
assert.ok(
manifest.families.commands.includes('/gsd-update'),
'INVENTORY-MANIFEST.json must include /gsd-update in commands (absorbed /gsd-sync-skills via #2790)'
);
});
test('INVENTORY-MANIFEST.json includes sync-skills.md', () => {
const manifest = JSON.parse(
fs.readFileSync(path.join(__dirname, '../docs/INVENTORY-MANIFEST.json'), 'utf-8')
);
assert.ok(
manifest.families.workflows.includes('sync-skills.md'),
'INVENTORY-MANIFEST.json must include sync-skills.md in workflows'
);
});
});

View File

@@ -1,193 +0,0 @@
'use strict';
// allow-test-rule: source-text-is-the-product
// commands/gsd/*.md text IS what the runtime loads — testing description
// length tests the deployed system-prompt contract.
/**
* Tests for #2789 — Trim skill description anti-patterns; enforce 100-char budget
*
* Verifies:
* 1. All skill descriptions in commands/gsd/*.md are <= 100 chars
* 2. No descriptions contain flag documentation anti-patterns (Use --)
* 3. No descriptions contain "Triggers:" keyword stuffing
* 4. lint-descriptions.cjs rejects descriptions over 100 chars
* 5. lint-descriptions.cjs accepts descriptions under 100 chars
*/
const { test, describe, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { spawnSync } = require('node:child_process');
const os = require('node:os');
const { cleanup } = require('./helpers.cjs');
const COMMANDS_DIR = path.join(__dirname, '../commands/gsd');
const LINT_SCRIPT = path.join(__dirname, '../scripts/lint-descriptions.cjs');
const MAX_DESCRIPTION_LENGTH = 100;
/**
* Parse the description field from a frontmatter block in a .md file.
* Returns null if no description is found.
*/
function parseDescription(content) {
// Extract frontmatter block between --- markers
const fmMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
if (!fmMatch) return null;
const fm = fmMatch[1];
// Handle multi-line or quoted values: description: "..." or description: plain text
// Match: description: "value" or description: value (to end of line)
const quoted = fm.match(/^description:\s+"((?:[^"\\]|\\.)*)"\s*$/m);
if (quoted) return quoted[1];
const plain = fm.match(/^description:\s+(.+)$/m);
if (plain) return plain[1].trim();
return null;
}
/**
* Get all .md files in commands/gsd/ with their descriptions.
*/
function getAllCommandDescriptions() {
const files = fs.readdirSync(COMMANDS_DIR).filter(f => f.endsWith('.md'));
return files.map(file => {
const filePath = path.join(COMMANDS_DIR, file);
const content = fs.readFileSync(filePath, 'utf-8');
const description = parseDescription(content);
return { file, filePath, description };
});
}
// ── Test 1: All descriptions <= 100 chars ────────────────────────────────────
describe('description length budget', () => {
test('all commands/gsd/*.md descriptions are <= 100 chars', () => {
const commands = getAllCommandDescriptions();
const violators = commands
.filter(c => c.description !== null && c.description.length > MAX_DESCRIPTION_LENGTH)
.map(c => [
'length=' + c.description.length,
'file=' + c.file,
'desc=' + c.description,
].join(' | '));
assert.strictEqual(
violators.length,
0,
[
`${violators.length} description(s) exceed ${MAX_DESCRIPTION_LENGTH} chars:`,
...violators.map(v => ' ' + v),
].join('\n')
);
});
});
// ── Test 2: No flag documentation anti-patterns ──────────────────────────────
describe('description anti-patterns', () => {
test('no descriptions contain flag documentation (Use --, use --, via --)', () => {
const commands = getAllCommandDescriptions();
const FLAG_PATTERNS = ['Use --', 'use --', 'via --'];
const violators = commands
.filter(c => {
if (!c.description) return false;
return FLAG_PATTERNS.some(p => c.description.includes(p));
})
.map(c => 'file=' + c.file + ' | desc=' + c.description);
assert.strictEqual(
violators.length,
0,
[
`${violators.length} description(s) contain flag documentation anti-patterns:`,
...violators.map(v => ' ' + v),
].join('\n')
);
});
// ── Test 3: No Triggers: keyword stuffing ─────────────────────────────────
test('no descriptions contain "Triggers:" keyword stuffing', () => {
const commands = getAllCommandDescriptions();
const violators = commands
.filter(c => c.description && /triggers:/i.test(c.description))
.map(c => 'file=' + c.file + ' | desc=' + c.description);
assert.strictEqual(
violators.length,
0,
[
`${violators.length} description(s) contain "Triggers:" keyword stuffing:`,
...violators.map(v => ' ' + v),
].join('\n')
);
});
});
// ── Test 4 & 5: lint-descriptions.cjs script ─────────────────────────────────
describe('lint-descriptions.cjs', () => {
let tmpDir;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-lint-desc-test-'));
});
afterEach(() => {
cleanup(tmpDir);
});
test('rejects a command file with a description over 100 chars', () => {
const longDesc = 'A'.repeat(101);
const content = [
'---',
'name: gsd:test-long',
'description: ' + longDesc,
'---',
'',
'Body text.',
].join('\n');
const tmpFile = path.join(tmpDir, 'long-desc.md');
fs.writeFileSync(tmpFile, content, 'utf-8');
const result = spawnSync(process.execPath, [LINT_SCRIPT, tmpFile], {
encoding: 'utf-8',
});
assert.notStrictEqual(result.status, 0, [
'lint-descriptions.cjs should exit non-zero for description > 100 chars',
'stdout: ' + result.stdout,
'stderr: ' + result.stderr,
].join('\n'));
});
test('accepts a command file with a description under 100 chars', () => {
const shortDesc = 'Short routing description for this skill.';
const content = [
'---',
'name: gsd:test-short',
'description: ' + shortDesc,
'---',
'',
'Body text.',
].join('\n');
const tmpFile = path.join(tmpDir, 'short-desc.md');
fs.writeFileSync(tmpFile, content, 'utf-8');
const result = spawnSync(process.execPath, [LINT_SCRIPT, tmpFile], {
encoding: 'utf-8',
});
assert.strictEqual(result.status, 0, [
'lint-descriptions.cjs should exit 0 for description <= 100 chars',
'stdout: ' + result.stdout,
'stderr: ' + result.stderr,
].join('\n'));
});
});

View File

@@ -1,413 +0,0 @@
// allow-test-rule: source-text-is-the-product
// commands/gsd/*.md files ARE what the runtime loads — testing their
// existence/non-existence tests the deployed skill surface contract.
'use strict';
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const { assertWithinAllowlist } = require('../scripts/lib/allowlist-ratchet.cjs');
// ---------------------------------------------------------------------------
// Allowlisted set of user-invocable skills (commands/gsd/*.md, ns-* excluded).
// Consolidation target ~58; this set may only SHRINK.
// Adding a new skill requires adding it here with justification.
// Removing a consolidated skill requires pruning it here.
// ---------------------------------------------------------------------------
const KNOWN_SKILLS = new Set([
'add-tests.md',
'ai-integration-phase.md',
'audit-fix.md',
'audit-milestone.md',
'audit-uat.md',
'autonomous.md',
'capture.md',
'cleanup.md',
'code-review.md',
'complete-milestone.md',
'config.md',
'debug.md',
'discuss-phase.md',
'docs-update.md',
'eval-review.md',
'execute-phase.md',
'explore.md',
'extract-learnings.md',
'fast.md',
'forensics.md',
'graphify.md',
'health.md',
'help.md',
'import.md',
'inbox.md',
'ingest-docs.md',
'manager.md',
'map-codebase.md',
'mempalace-capture.md',
'mempalace-recall.md',
'milestone-summary.md',
'mvp-phase.md',
'new-milestone.md',
'new-project.md',
'pause-work.md',
'phase.md',
'plan-phase.md',
'plan-review-convergence.md',
'pr-branch.md',
'profile-user.md',
'progress.md',
'quick.md',
'resume-work.md',
'review-backlog.md',
'review.md',
'secure-phase.md',
'settings.md',
'ship.md',
'sketch.md',
'spec-phase.md',
'spike.md',
'stats.md',
'surface.md',
'thread.md',
'ui-phase.md',
'ui-review.md',
'ultraplan-phase.md',
'undo.md',
'update.md',
'validate-phase.md',
'verify-work.md',
'workspace.md',
'workstreams.md',
]);
const COMMANDS_DIR = path.join(__dirname, '..', 'commands', 'gsd');
/**
* Parse the YAML frontmatter from a skill .md file.
* Returns an object with the frontmatter fields as strings.
* Only handles simple scalar and array values needed by these tests.
*/
function parseFrontmatter(filePath) {
const raw = fs.readFileSync(filePath, 'utf8');
// CRLF-tolerant: Windows checkouts leave \r on every line. lines.indexOf('---', 1)
// would never match because elements would be '---\r' instead of '---'.
const lines = raw.split(/\r?\n/);
if (lines[0].trim() !== '---') return {};
const endIdx = lines.indexOf('---', 1);
if (endIdx === -1) return {};
const fmLines = lines.slice(1, endIdx);
const result = {};
let currentKey = null;
for (const line of fmLines) {
const kvMatch = line.match(/^([a-zA-Z0-9_-]+):\s*(.*)/);
if (kvMatch) {
currentKey = kvMatch[1];
result[currentKey] = kvMatch[2].trim();
} else if (currentKey && line.match(/^\s+-\s+/)) {
// array item — append to existing string value so callers can check membership
const item = line.replace(/^\s+-\s+/, '').trim();
result[currentKey] = result[currentKey] ? [result[currentKey], item].join('\n') : item;
}
}
return result;
}
function skillPath(name) {
return path.join(COMMANDS_DIR, `${name}.md`);
}
// ---------------------------------------------------------------------------
// Group: New consolidated skills exist
// ---------------------------------------------------------------------------
describe('new consolidated skills exist', () => {
test('commands/gsd/capture.md exists', () => {
assert.ok(fs.existsSync(skillPath('capture')), 'capture.md does not exist');
});
test('commands/gsd/phase.md exists', () => {
assert.ok(fs.existsSync(skillPath('phase')), 'phase.md does not exist');
});
test('commands/gsd/config.md exists', () => {
assert.ok(fs.existsSync(skillPath('config')), 'config.md does not exist');
});
test('commands/gsd/workspace.md exists', () => {
assert.ok(fs.existsSync(skillPath('workspace')), 'workspace.md does not exist');
});
});
// ---------------------------------------------------------------------------
// Group: Absorbed skills are removed
// ---------------------------------------------------------------------------
describe('absorbed skills are removed', () => {
const absorbed = [
['add-todo', 'absorbed into capture.md'],
['note', 'absorbed into capture.md'],
['add-backlog', 'absorbed into capture.md'],
['plant-seed', 'absorbed into capture.md'],
['check-todos', 'absorbed into capture.md'],
['add-phase', 'absorbed into phase.md'],
['insert-phase', 'absorbed into phase.md'],
['remove-phase', 'absorbed into phase.md'],
['edit-phase', 'absorbed into phase.md'],
['settings-advanced', 'absorbed into config.md'],
['settings-integrations', 'absorbed into config.md'],
['set-profile', 'absorbed into config.md'],
['new-workspace', 'absorbed into workspace.md'],
['list-workspaces', 'absorbed into workspace.md'],
['remove-workspace', 'absorbed into workspace.md'],
['sync-skills', 'absorbed into update.md'],
['reapply-patches', 'absorbed into update.md'],
['sketch-wrap-up', 'absorbed into sketch.md'],
['spike-wrap-up', 'absorbed into spike.md'],
['scan', 'absorbed into map-codebase.md'],
['intel', 'absorbed into map-codebase.md'],
['code-review-fix', 'absorbed into code-review.md'],
['next', 'absorbed into progress.md'],
['do', 'absorbed into progress.md'],
];
for (const [name, reason] of absorbed) {
test(`commands/gsd/${name}.md does NOT exist (${reason})`, () => {
assert.ok(
!fs.existsSync(skillPath(name)),
[
`${name}.md still exists but should have been deleted`,
`(${reason})`,
].join(' '),
);
});
}
});
// ---------------------------------------------------------------------------
// Group: Outright deletions
// ---------------------------------------------------------------------------
describe('outright deleted dead skills are removed', () => {
const deleted = [
'join-discord',
// research-phase → plan-phase --research-phase (PR #3045, already absorbed)
// plan-milestone-gaps → inline in audit-milestone (PR #3038, already absorbed)
// list-phase-assumptions → discuss-phase --assumptions (pending #3131)
// session-report → pause-work --report (pending #3131)
// analyze-dependencies → manager --analyze-deps (pending #3131)
// from-gsd2 → import --from-gsd2 (pending #3131)
];
for (const name of deleted) {
test(`commands/gsd/${name}.md does NOT exist`, () => {
assert.ok(
!fs.existsSync(skillPath(name)),
`${name}.md still exists but should have been deleted (outright dead skill)`,
);
});
}
});
// ---------------------------------------------------------------------------
// Group: #3131 — re-wired workflows absorbed as flags
// ---------------------------------------------------------------------------
describe('#3131 re-wired workflows: standalone command files must not exist', () => {
const rewired = [
['list-phase-assumptions', 'absorbed into discuss-phase.md --assumptions'],
['session-report', 'absorbed into pause-work.md --report'],
['analyze-dependencies', 'absorbed into manager.md --analyze-deps'],
['from-gsd2', 'absorbed into import.md --from-gsd2'],
];
for (const [name, reason] of rewired) {
test(`commands/gsd/${name}.md does NOT exist (${reason})`, () => {
assert.ok(
!fs.existsSync(skillPath(name)),
`${name}.md still exists as a standalone command but should be absorbed (${reason})`,
);
});
}
});
describe('#3131 re-wired workflows: parent command argument-hints advertise the new flags', () => {
test('discuss-phase.md argument-hint contains --assumptions', () => {
const fm = parseFrontmatter(skillPath('discuss-phase'));
assert.ok(
(fm['argument-hint'] || '').includes('--assumptions'),
'discuss-phase.md argument-hint does not contain --assumptions. got: ' + (fm['argument-hint'] || '(none)'),
);
});
test('pause-work.md argument-hint contains --report', () => {
const fm = parseFrontmatter(skillPath('pause-work'));
assert.ok(
(fm['argument-hint'] || '').includes('--report'),
'pause-work.md argument-hint does not contain --report. got: ' + (fm['argument-hint'] || '(none)'),
);
});
test('manager.md argument-hint contains --analyze-deps', () => {
const fm = parseFrontmatter(skillPath('manager'));
assert.ok(
(fm['argument-hint'] || '').includes('--analyze-deps'),
'manager.md argument-hint does not contain --analyze-deps. got: ' + (fm['argument-hint'] || '(none)'),
);
});
test('import.md argument-hint contains --from-gsd2', () => {
const fm = parseFrontmatter(skillPath('import'));
assert.ok(
(fm['argument-hint'] || '').includes('--from-gsd2'),
'import.md argument-hint does not contain --from-gsd2. got: ' + (fm['argument-hint'] || '(none)'),
);
});
});
describe('#3131 re-wired workflows: parent command bodies dispatch to workflow files', () => {
function bodyContains(name, substring) {
const raw = fs.readFileSync(skillPath(name), 'utf8');
return raw.includes(substring);
}
test('discuss-phase.md body references list-phase-assumptions.md', () => {
assert.ok(
bodyContains('discuss-phase', 'list-phase-assumptions.md'),
'discuss-phase.md body does not reference list-phase-assumptions.md — --assumptions flag dispatch is missing',
);
});
test('pause-work.md body references session-report.md', () => {
assert.ok(
bodyContains('pause-work', 'session-report.md'),
'pause-work.md body does not reference session-report.md — --report flag dispatch is missing',
);
});
test('manager.md body references analyze-dependencies.md', () => {
assert.ok(
bodyContains('manager', 'analyze-dependencies.md'),
'manager.md body does not reference analyze-dependencies.md — --analyze-deps flag dispatch is missing',
);
});
test('import.md body references from-gsd2', () => {
assert.ok(
bodyContains('import', 'from-gsd2'),
'import.md body does not reference from-gsd2 — --from-gsd2 flag dispatch is missing',
);
});
});
// ---------------------------------------------------------------------------
// Group: Parent skills updated with new flags
// ---------------------------------------------------------------------------
describe('parent skills updated with new flags in argument-hint', () => {
test('update.md argument-hint contains --sync', () => {
const fm = parseFrontmatter(skillPath('update'));
assert.ok(
(fm['argument-hint'] || '').includes('--sync'),
[
'update.md argument-hint does not contain --sync',
'got: ' + (fm['argument-hint'] || '(none)'),
].join('. '),
);
});
test('update.md argument-hint contains --reapply', () => {
const fm = parseFrontmatter(skillPath('update'));
assert.ok(
(fm['argument-hint'] || '').includes('--reapply'),
[
'update.md argument-hint does not contain --reapply',
'got: ' + (fm['argument-hint'] || '(none)'),
].join('. '),
);
});
test('sketch.md argument-hint contains --wrap-up', () => {
const fm = parseFrontmatter(skillPath('sketch'));
assert.ok(
(fm['argument-hint'] || '').includes('--wrap-up'),
[
'sketch.md argument-hint does not contain --wrap-up',
'got: ' + (fm['argument-hint'] || '(none)'),
].join('. '),
);
});
test('spike.md argument-hint contains --wrap-up', () => {
const fm = parseFrontmatter(skillPath('spike'));
assert.ok(
(fm['argument-hint'] || '').includes('--wrap-up'),
[
'spike.md argument-hint does not contain --wrap-up',
'got: ' + (fm['argument-hint'] || '(none)'),
].join('. '),
);
});
test('map-codebase.md argument-hint contains --fast', () => {
const fm = parseFrontmatter(skillPath('map-codebase'));
assert.ok(
(fm['argument-hint'] || '').includes('--fast'),
[
'map-codebase.md argument-hint does not contain --fast',
'got: ' + (fm['argument-hint'] || '(none)'),
].join('. '),
);
});
test('code-review.md argument-hint contains --fix', () => {
const fm = parseFrontmatter(skillPath('code-review'));
assert.ok(
(fm['argument-hint'] || '').includes('--fix'),
[
'code-review.md argument-hint does not contain --fix',
'got: ' + (fm['argument-hint'] || '(none)'),
].join('. '),
);
});
test('progress.md argument-hint contains --do', () => {
const fm = parseFrontmatter(skillPath('progress'));
assert.ok(
(fm['argument-hint'] || '').includes('--do'),
[
'progress.md argument-hint does not contain --do',
'got: ' + (fm['argument-hint'] || '(none)'),
].join('. '),
);
});
});
// ---------------------------------------------------------------------------
// Group: settings.md is NOT deleted
// ---------------------------------------------------------------------------
describe('settings.md is kept (merged into config entry point or remains standalone)', () => {
test('commands/gsd/settings.md still exists', () => {
assert.ok(
fs.existsSync(skillPath('settings')),
'settings.md was deleted — it should be kept (or renamed to config.md, but not both missing)',
);
});
});
// ---------------------------------------------------------------------------
// Group: Skill set allowlisted (identity-based, consolidating toward ~58)
// ---------------------------------------------------------------------------
describe('skill set', () => {
test('user-invocable skill set is allowlisted (consolidating toward ~58)', () => {
// Exclude `ns-*.md` namespace meta-skills (#2792) from this guard.
// Those are descriptor-only routers selected first by the model and
// are not part of the consolidation surface this test tracks; their
// own contract is enforced by tests/enh-2792-namespace-skills.test.cjs.
const currentBasenames = fs.readdirSync(COMMANDS_DIR)
.filter((f) => f.endsWith('.md') && !f.startsWith('ns-'));
assertWithinAllowlist({
label: 'user-invocable skills (commands/gsd)',
current: currentBasenames,
known: KNOWN_SKILLS,
fail: assert.fail,
pruneHint: 'edit KNOWN_SKILLS in tests/enh-2790-skill-consolidation.test.cjs',
});
});
});

View File

@@ -1,232 +0,0 @@
// allow-test-rule: integration-test-input
// Reads execute-phase.md to extract + execute the cwd-drift guard bash snippet against real git worktrees.
'use strict';
const { describe, test, before, after } = require('node:test');
const assert = require('node:assert/strict');
const { execSync, spawnSync } = require('node:child_process');
const fs = require('node:fs');
const path = require('node:path');
const os = require('node:os');
const { cleanup } = require('./helpers.cjs');
const REPO_ROOT = path.join(__dirname, '..');
const EXECUTE_PHASE_PATH = path.join(REPO_ROOT, 'gsd-core', 'workflows', 'execute-phase.md');
// ---------------------------------------------------------------------------
// Extract the cwd-drift guard bash block from execute-phase.md
// ---------------------------------------------------------------------------
/**
* Reads execute-phase.md and extracts the bash fenced block that implements
* the orchestrator cwd-drift guard inside <step name="execute_waves">.
*
* Algorithm:
* 1. Find <step name="execute_waves">
* 2. After that, find the first occurrence of "cwd-drift guard"
* 3. After that, find the first ```bash fence
* 4. Return the body between ```bash\n and the closing ```
*
* Throws with a clear message if any step fails or sanity checks don't pass.
*/
function extractCwdGuardBash() {
const content = fs.readFileSync(EXECUTE_PHASE_PATH, 'utf-8');
const stepMarker = '<step name="execute_waves">';
const stepIdx = content.indexOf(stepMarker);
if (stepIdx === -1) {
throw new Error(`extractCwdGuardBash: could not find "${stepMarker}" in ${EXECUTE_PHASE_PATH}`);
}
const afterStep = content.slice(stepIdx + stepMarker.length);
const driftMarker = 'cwd-drift guard';
const driftIdx = afterStep.indexOf(driftMarker);
if (driftIdx === -1) {
throw new Error(`extractCwdGuardBash: could not find "${driftMarker}" after execute_waves step in ${EXECUTE_PHASE_PATH}`);
}
const afterDrift = afterStep.slice(driftIdx + driftMarker.length);
// Extract the first ```bash|sh fenced block using a CRLF-safe regex.
// \r?\n tolerates both LF (Unix) and CRLF (Windows autocrlf=true checkouts).
const fenceRe = /```(?:bash|sh)\r?\n([\s\S]*?)```/;
const fenceMatch = fenceRe.exec(afterDrift);
if (!fenceMatch) {
throw new Error(`extractCwdGuardBash: could not find \`\`\`bash fence after cwd-drift guard heading in ${EXECUTE_PHASE_PATH}`);
}
const guardBash = fenceMatch[1];
if (!guardBash.trim()) {
throw new Error('extractCwdGuardBash: extracted bash block is empty');
}
if (!guardBash.includes('git rev-parse --show-toplevel')) {
throw new Error('extractCwdGuardBash: sanity check failed — extracted block does not contain "git rev-parse --show-toplevel"');
}
if (!guardBash.includes('worktree-agent-')) {
throw new Error('extractCwdGuardBash: sanity check failed — extracted block does not contain "worktree-agent-"');
}
return guardBash;
}
// ---------------------------------------------------------------------------
// Run guard helper
// ---------------------------------------------------------------------------
/**
* Run the guard bash snippet in a given cwd using bash -c.
* Returns { status, stderr }.
*/
function runGuard(guardBash, cwd) {
const result = spawnSync('bash', ['-c', guardBash], {
cwd,
encoding: 'utf-8',
});
return { status: result.status, stderr: result.stderr || '' };
}
// ---------------------------------------------------------------------------
// Fixtures
// ---------------------------------------------------------------------------
let upstreamDir; // bare upstream git repo (the main worktree)
let featureDir; // normal feature worktree on branch workspace/feature-x
let agentWtDir; // agent worktree on branch worktree-agent-deadbeef
let agentSubdir; // subdirectory inside agentWtDir
let legitUnderClaude; // non-agent worktree whose PATH is under .claude/worktrees/
const dirsToCleanup = [];
function git(cwd, args) {
return execSync(`git ${args.map(a => `"${a}"`).join(' ')}`, {
cwd,
encoding: 'utf-8',
stdio: ['ignore', 'pipe', 'pipe'],
});
}
before(() => {
// --- upstream: the main repo with an initial commit ---
upstreamDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-48-upstream-'));
dirsToCleanup.push(upstreamDir);
git(upstreamDir, ['init', '-b', 'main']);
git(upstreamDir, ['config', 'user.email', 'test@example.com']);
git(upstreamDir, ['config', 'user.name', 'Test User']);
git(upstreamDir, ['config', 'commit.gpgsign', 'false']);
fs.writeFileSync(path.join(upstreamDir, 'README.md'), '# test\n');
git(upstreamDir, ['add', 'README.md']);
git(upstreamDir, ['commit', '-m', 'chore: init']);
// --- feature worktree: non-agent branch, path outside .claude/worktrees ---
featureDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-48-feature-'));
dirsToCleanup.push(featureDir);
// git worktree add creates the directory itself; remove so it can do so
fs.rmdirSync(featureDir);
git(upstreamDir, ['worktree', 'add', '-b', 'workspace/feature-x', featureDir]);
// --- agent worktree: branch worktree-agent-deadbeef ---
// Sits under featureDir/.claude/worktrees/agent-deadbeef
const agentWtParent = path.join(featureDir, '.claude', 'worktrees');
fs.mkdirSync(agentWtParent, { recursive: true });
agentWtDir = path.join(agentWtParent, 'agent-deadbeef');
git(upstreamDir, ['worktree', 'add', '-b', 'worktree-agent-deadbeef', agentWtDir]);
// --- subdir inside agent worktree ---
agentSubdir = path.join(agentWtDir, 'src', 'deep');
fs.mkdirSync(agentSubdir, { recursive: true });
// --- legitUnderClaude: non-agent worktree whose PATH is under .claude/worktrees/ ---
// This proves the guard discriminates by branch name, not path.
const legitParent = path.join(upstreamDir, '.claude', 'worktrees');
fs.mkdirSync(legitParent, { recursive: true });
legitUnderClaude = path.join(legitParent, 'legit-feature');
git(upstreamDir, ['worktree', 'add', '-b', 'workspace/legit', legitUnderClaude]);
});
after(() => {
// Prune stale worktree metadata before removing dirs
try { git(upstreamDir, ['worktree', 'prune']); } catch (_) { /* best-effort */ }
for (const d of dirsToCleanup) {
try { cleanup(d); } catch (_) { /* best-effort */ }
}
});
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('bug #48: orchestrator cwd-drift guard — executable e2e', () => {
let guardBash;
before(() => {
guardBash = extractCwdGuardBash();
});
test('guard passes from a feature worktree on a non-agent branch (exit 0)', () => {
const { status, stderr } = runGuard(guardBash, featureDir);
assert.equal(
status, 0,
`Expected exit 0 from feature worktree, got ${status}. stderr: ${stderr}`,
);
});
test('guard fails closed (exit 1) when cwd is inside an agent worktree', () => {
const { status, stderr } = runGuard(guardBash, agentWtDir);
assert.equal(
status, 1,
`Expected exit 1 from agent worktree, got ${status}. stderr: ${stderr}`,
);
assert.match(
stderr,
/agent worktree/i,
`Expected stderr to mention "agent worktree", got: ${stderr}`,
);
});
test('guard fails closed (exit 1) from a SUBDIRECTORY of an agent worktree (root resolution)', () => {
// git rev-parse --show-toplevel resolves to the worktree root regardless of cwd subdir.
// The guard must catch this via the branch-name check, not the path check.
const { status, stderr } = runGuard(guardBash, agentSubdir);
assert.equal(
status, 1,
`Expected exit 1 from agent worktree subdir, got ${status}. stderr: ${stderr}`,
);
});
test('guard does NOT blanket-refuse a non-agent worktree located under .claude/worktrees/ (exit 0)', () => {
// Discriminator is the worktree-agent-* branch namespace, NOT the path.
const { status, stderr } = runGuard(guardBash, legitUnderClaude);
assert.equal(
status, 0,
`Expected exit 0 from non-agent worktree under .claude/worktrees/, got ${status}. stderr: ${stderr}`,
);
});
test('guard fails closed (exit 1) when not inside a git repo', (t) => {
const nonRepoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-48-nongit-'));
try {
// Verify that git rev-parse --show-toplevel actually fails here.
// On some systems /tmp itself might be inside a git repo (e.g. if the
// user's HOME is a git repo). If it resolves, we must skip this test.
const check = spawnSync('git', ['rev-parse', '--show-toplevel'], {
cwd: nonRepoDir,
encoding: 'utf-8',
});
if (check.status === 0) {
t.skip('nonRepoDir unexpectedly resolved to a git repo — skipping');
return;
}
const { status, stderr } = runGuard(guardBash, nonRepoDir);
assert.equal(
status, 1,
`Expected exit 1 when not inside a git repo, got ${status}. stderr: ${stderr}`,
);
} finally {
try { cleanup(nonRepoDir); } catch (_) { /* best-effort */ }
}
});
});

View File

@@ -1,355 +0,0 @@
'use strict';
// allow-test-rule: source-text-is-the-product
// `workflows/help/modes/*.md` files ARE the help output — their text is what
// the runtime emits when the user runs `/gsd:help [--brief|--full|<topic>]`.
// Asserting on their structure tests the deployed contract directly.
/**
* Feature #3039: tiered /gsd:help output.
*
* The legacy single-file 747-line help is replaced by:
* - workflows/help.md — small dispatcher (progressive disclosure)
* - workflows/help/modes/brief.md — ~one-liner refresher
* - workflows/help/modes/default.md — one-page newcomer tour
* - workflows/help/modes/full.md — complete reference (former help.md body)
* - workflows/help/modes/topic.md — section-extraction logic + alias table
*
* This test enforces the contract:
* 1. All four mode files exist with a single `<reference>` block.
* 2. brief and default fit a "one screen" budget; full stays under LARGE tier cap.
* 3. The dispatcher routes on $ARGUMENTS to all four mode files (structural parse).
* 4. Dispatcher conflict-resolution rules are documented:
* - `--brief` + `--full` without a topic → prefer `--full`
* - `--brief <topic>` → topic.md in compact scope (composable)
* - bare or `--full <topic>` → topic.md in full scope
* 5. topic.md documents an explicit routing preamble + compact-scope rule.
* 6. Every topic alias in topic.md resolves to a heading that exists in full.md.
* 7. Every /gsd:* sub-block token in topic.md's alias table appears in full.md.
* 8. Every full.md heading is either aliased or in the intentional-orphan allowlist.
* 9. The `commands/gsd/help.md` shim passes `$ARGUMENTS` through and advertises
* the composable `--brief <topic>` form.
*
* Tighten-only invariant (issue #597): ceilings track the per-tier high-water mark
* within GRACE lines. Budgets may only decrease, never silently creep upward.
* The assertTightCeiling() calls below enforce this automatically.
*/
const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { assertTightCeiling } = require('../scripts/lib/allowlist-ratchet.cjs');
const ROOT = path.join(__dirname, '..');
const WORKFLOWS = path.join(ROOT, 'gsd-core', 'workflows');
const MODES = path.join(WORKFLOWS, 'help', 'modes');
const DISPATCHER = path.join(WORKFLOWS, 'help.md');
const COMMAND_SHIM = path.join(ROOT, 'commands', 'gsd', 'help.md');
const MODE_FILES = ['brief.md', 'default.md', 'full.md', 'topic.md'];
// "One screen" budgets, including frontmatter/<purpose>/<reference> tags.
// These are conservative (one-page conceptual size of ~25 lines of usable
// content) but allow for the wrapping tags. Tighten as content stabilizes.
//
// Ceilings tightened to actualMax + SMALL_GRACE per the ratchet-down rule (#597).
// BRIEF ceiling kept at 30 (actualMax=22, slack=8 ≤ SMALL_GRACE=10).
const BRIEF_BUDGET = 30;
// DEFAULT ceiling lowered from 70 → 60 (actualMax=50; #597 ratchet-down).
const DEFAULT_BUDGET = 60;
// full.md is the LARGE tier (see workflow-size-budget.test.cjs — now byte-based per #717;
// this FULL_BUDGET is a separate line-count budget for help/modes/full.md).
// The size-budget test is non-recursive so full.md is not covered there; cap it here.
// FULL ceiling lowered from 1500 → 844 (actualMax=784; #597 ratchet-down).
const FULL_BUDGET = 844;
// Grace bands:
// SMALL_GRACE — for the tiny brief/default/dispatcher files (≤ ~70 lines):
// 10 lines of breathing room is proportionate and prevents trivial edits from
// failing while still catching any meaningful upward creep.
// LARGE_GRACE — for full.md where content fluctuates more:
// 60 lines matches the line-budget GRACE used in the other size-budget tests.
const SMALL_GRACE = 10;
const LARGE_GRACE = 60;
function read(file) {
return fs.readFileSync(file, 'utf8');
}
function lineCount(file) {
const c = read(file);
if (c.length === 0) return 0;
const trail = c.endsWith('\n') ? 1 : 0;
return c.split('\n').length - trail;
}
describe('feature #3039: tiered help — file structure', () => {
for (const f of MODE_FILES) {
test(`mode file exists: ${f}`, () => {
assert.ok(fs.existsSync(path.join(MODES, f)), `missing ${path.join(MODES, f)}`);
});
}
// Dispatcher ceiling lowered from 40 → 34 (actualMax=24; #597 ratchet-down).
const DISPATCHER_BUDGET = 34;
test(`dispatcher exists and is small (≤ ${DISPATCHER_BUDGET} lines)`, () => {
assert.ok(fs.existsSync(DISPATCHER));
const n = lineCount(DISPATCHER);
assert.ok(n <= DISPATCHER_BUDGET, `dispatcher should be small; got ${n} lines`);
assertTightCeiling({ label: 'dispatcher', actualMax: n, ceiling: DISPATCHER_BUDGET, grace: SMALL_GRACE, fail: assert.fail });
});
for (const f of MODE_FILES) {
test(`${f} has exactly one <reference> block (line-anchored)`, () => {
const content = read(path.join(MODES, f));
// Anchor on start-of-line so prose mentions of `<reference>` inside
// <purpose> blocks aren't counted.
const opens = (content.match(/^<reference>$/gm) || []).length;
const closes = (content.match(/^<\/reference>$/gm) || []).length;
assert.equal(opens, 1, `${f}: expected 1 <reference> opening line, got ${opens}`);
assert.equal(closes, 1, `${f}: expected 1 </reference> closing line, got ${closes}`);
});
}
});
describe('feature #3039: tiered help — size budgets', () => {
test(`brief.md fits one screen (≤ ${BRIEF_BUDGET} lines)`, () => {
const n = lineCount(path.join(MODES, 'brief.md'));
assert.ok(n <= BRIEF_BUDGET, `brief.md is ${n} lines, budget ${BRIEF_BUDGET}`);
assertTightCeiling({ label: 'BRIEF', actualMax: n, ceiling: BRIEF_BUDGET, grace: SMALL_GRACE, fail: assert.fail });
});
test(`default.md fits one screen (≤ ${DEFAULT_BUDGET} lines)`, () => {
const n = lineCount(path.join(MODES, 'default.md'));
assert.ok(n <= DEFAULT_BUDGET, `default.md is ${n} lines, budget ${DEFAULT_BUDGET}`);
assertTightCeiling({ label: 'DEFAULT', actualMax: n, ceiling: DEFAULT_BUDGET, grace: SMALL_GRACE, fail: assert.fail });
});
test('full.md preserves the complete reference (≥ 600 lines)', () => {
// The pre-#3039 reference was 747 lines. Guard against accidental shrinkage
// that would amount to silently removing content from --full.
const n = lineCount(path.join(MODES, 'full.md'));
assert.ok(n >= 600, `full.md is ${n} lines — too small, content may have been lost`);
});
test(`full.md stays under LARGE workflow budget (≤ ${FULL_BUDGET} lines)`, () => {
// full.md lives in a subdirectory and is not enumerated by the non-recursive
// workflow-size-budget.test.cjs. Cap it here at the LARGE tier limit.
const n = lineCount(path.join(MODES, 'full.md'));
assert.ok(n <= FULL_BUDGET, `full.md grew to ${n} lines (LARGE budget: ${FULL_BUDGET})`);
assertTightCeiling({ label: 'FULL', actualMax: n, ceiling: FULL_BUDGET, grace: LARGE_GRACE, fail: assert.fail });
});
});
describe('feature #3039: tiered help — dispatcher routing (structural)', () => {
const dispatcher = read(DISPATCHER);
function extractDisclosureBlock(src) {
const m = src.match(/<progressive_disclosure>([\s\S]*?)<\/progressive_disclosure>/);
assert.ok(m, 'dispatcher must contain a <progressive_disclosure> block');
return m[1];
}
test('dispatcher <progressive_disclosure> block has exactly 5 routing rows', () => {
// 4 base tiers (brief, full, default, topic) + 1 composable row (--brief <topic>).
const block = extractDisclosureBlock(dispatcher);
// Table rows are lines starting with `|`, excluding the header and separator rows.
const rows = block.split('\n')
.filter(l => /^\|/.test(l))
.filter(l => !/^\|\s*[-:]+\s*\|/.test(l)) // strip separator rows
.filter(l => !/when.*arguments/i.test(l)); // strip header row
assert.equal(rows.length, 5,
`dispatcher routing table must have exactly 5 rows; got ${rows.length}:\n${rows.join('\n')}`);
});
test('dispatcher routes --brief to brief.md', () => {
const block = extractDisclosureBlock(dispatcher);
assert.match(block, /`--brief`[\s\S]*?brief\.md/);
});
test('dispatcher routes --full to full.md', () => {
const block = extractDisclosureBlock(dispatcher);
assert.match(block, /`--full`[\s\S]*?full\.md/);
});
test('dispatcher routes empty/no-flag args to default.md', () => {
const block = extractDisclosureBlock(dispatcher);
assert.match(block, /(empty|unset)[\s\S]*?default\.md/i);
});
test('dispatcher routes topic args to topic.md', () => {
const block = extractDisclosureBlock(dispatcher);
assert.match(block, /topic[\s\S]*?topic\.md/i);
});
});
describe('feature #3039: tiered help — dispatcher conflict-resolution rules', () => {
const dispatcher = read(DISPATCHER);
test('dispatcher documents --brief + --full (without topic) conflict resolution (prefer --full)', () => {
// help.md argument parsing rules: "if both appear *without* a topic, prefer `--full`"
assert.match(dispatcher, /prefer.*--full/);
});
test('dispatcher routes --brief <topic> to topic.md in compact scope (composable)', () => {
// help.md argument parsing rules: "--brief combined with a topic invokes topic.md
// in compact scope" — the composable scoped-lookup form (trek-e review finding #4).
assert.match(dispatcher, /--brief[^|]*<topic>[\s\S]*?topic\.md[\s\S]*?compact/i);
});
test('dispatcher routes --full <topic> (or bare topic) to topic.md in full scope', () => {
// Bare topic, `--full <topic>`, or topic with leading `--` → full scope.
assert.match(dispatcher, /(bare topic|--full <topic>)[\s\S]*?full scope/i);
});
test('dispatcher tells topic.md to retain --brief when delegating', () => {
// The dispatcher passes $ARGUMENTS through; topic.md needs to see --brief to
// choose compact scope. Guard against accidental flag-stripping.
assert.match(dispatcher, /retain.*--brief|pass.*--brief/i);
});
});
describe('feature #3039: tiered help — command shim passes $ARGUMENTS', () => {
const shim = read(COMMAND_SHIM);
test('shim references $ARGUMENTS', () => {
assert.match(shim, /\$ARGUMENTS/);
});
test('shim declares argument-hint frontmatter', () => {
assert.match(shim, /argument-hint:/);
});
test('shim argument-hint advertises composable --brief <topic>', () => {
// Discoverability: users need to know the composable form is supported
// (trek-e review finding #4).
assert.match(shim, /argument-hint:[^\n]*--brief[^\n]*<topic>/);
});
test('shim references the help workflow', () => {
assert.match(shim, /workflows\/help\.md/);
});
});
describe('feature #3039: tiered help — topic.md routing visibility + compact scope', () => {
const topicSrc = read(path.join(MODES, 'topic.md'));
test('topic.md documents an explicit resolved-routing preamble', () => {
// Trek-e review finding #3: routing must be explicit in output so the user
// can see which alias matched which heading and at what scope.
assert.match(topicSrc, /\*\*Topic:\*\*[\s\S]*<alias>[\s\S]*<heading>/);
assert.match(topicSrc, /scope:.*full.*\|.*compact/i);
});
test('topic.md documents a compact scope distinct from full scope', () => {
// Trek-e review finding #4: --brief <topic> must produce a compact
// scoped lookup (signature + one-line summary), not the full section.
assert.match(topicSrc, /compact scope/i);
assert.match(topicSrc, /signature.*one-line summary|signature \+ one-line/i);
});
test('topic.md parses --brief flag and strips it before resolving the alias', () => {
// Compact scope must trigger off the --brief flag in $ARGUMENTS; the
// remaining token is the alias.
assert.match(topicSrc, /--brief.*-b.*compact scope|compact scope[\s\S]*--brief/i);
});
test('topic.md closing "More:" line advertises the composable form', () => {
assert.match(topicSrc, /More:[\s\S]*--brief <topic>/);
});
});
describe('feature #3039: tiered help — topic alias coverage', () => {
const topicSrc = read(path.join(MODES, 'topic.md'));
const fullSrc = read(path.join(MODES, 'full.md'));
// Extract the alias table portion of topic.md (before "**Output rules:**")
function aliasTableSection(src) {
return src.split('**Output rules:**')[0];
}
// Extract the canonical heading text referenced from each row of the
// alias table. Rows look like: `| aliases | \`## Heading\` ... |`.
// We accept either ## or ### and pull the literal heading text.
function extractReferencedHeadings(src) {
const headings = new Set();
const re = /`(#{2,3} [^`]+?)`/g;
let m;
while ((m = re.exec(src)) !== null) {
headings.add(m[1].trim());
}
return headings;
}
function fullHeadings(src) {
const set = new Set();
for (const line of src.split('\n')) {
const m = line.match(/^(#{2,3}) (.+?)\s*$/);
if (m) set.add(`${m[1]} ${m[2]}`);
}
return set;
}
test('every heading referenced in topic.md exists in full.md', () => {
const referenced = extractReferencedHeadings(aliasTableSection(topicSrc));
const present = fullHeadings(fullSrc);
const missing = [...referenced].filter((h) => !present.has(h)).sort();
assert.deepEqual(missing, [],
`topic.md references headings not present in full.md: ${missing.join(' | ')}`);
});
test('every /gsd:* sub-block token in topic.md alias table exists in full.md', () => {
// Validates fix for review finding #2: sub-block aliases reference bold-line
// anchors (**`/gsd:X`**) — assert each token actually appears in full.md.
const tableSection = aliasTableSection(topicSrc);
const tokens = [...tableSection.matchAll(/`(\/gsd:[a-z-]+(?:\s+--[a-z-]+)?)`/g)].map(m => m[1]);
assert.ok(tokens.length > 0, 'expected at least one /gsd:* token in alias table');
const missing = tokens.filter(t => !fullSrc.includes(t));
assert.deepEqual(missing, [],
`topic.md references /gsd:* tokens not present in full.md: ${missing.join(' | ')}`);
});
test('every full.md heading is either aliased or in the intentional-orphan allowlist', () => {
// Catches newly added headings that have no alias (contributor must either
// alias the section or explicitly add it to INTENTIONAL_ORPHANS below).
const INTENTIONAL_ORPHANS = new Set([
'## Quick Start',
'## Staying Updated',
'### Utility Commands', // covered by cleanup/update sub-block aliases
'## Additional Commands',
'### Discovery & Specification',
'### Planning & Execution',
'### Quality, Review & Verification',
'### Diagnostics & Maintenance',
'### Knowledge & Context',
'### Workflow & Orchestration',
'### Repository Integration',
'### Namespace Routers (model-facing meta-skills)',
]);
const allHeadings = fullSrc.split('\n')
.filter(l => /^#{2,3} /.test(l))
.map(l => l.trim());
const aliased = extractReferencedHeadings(aliasTableSection(topicSrc));
const orphans = allHeadings.filter(h => !aliased.has(h) && !INTENTIONAL_ORPHANS.has(h));
assert.deepEqual(orphans, [],
`full.md headings not aliased in topic.md (add to INTENTIONAL_ORPHANS if intentional): ${orphans.join(' | ')}`);
});
test('topic.md covers the core topics promised in default.md', () => {
// Surface contract: default.md advertises a "Topics:" line. Each alias
// there must appear as a recognized topic in topic.md's alias table.
const def = read(path.join(MODES, 'default.md'));
const topicsLine = def.split('\n').find((l) => /^Topics:/i.test(l));
assert.ok(topicsLine, 'default.md must advertise a "Topics:" line for users');
// Strip the leading "Topics:" prefix, then pull every backticked token.
const aliases = [...topicsLine.matchAll(/`([a-z][a-z0-9-]*)`/g)].map((m) => m[1]);
assert.ok(aliases.length >= 5, `expected at least 5 promoted topic aliases; got ${aliases.length}`);
const missing = aliases.filter((a) => !new RegExp(`\`${a}\``).test(topicSrc));
assert.deepEqual(missing, [],
`default.md promotes topic aliases that topic.md does not recognize: ${missing.join(', ')}`);
});
});

View File

@@ -1,303 +0,0 @@
/**
* CLI negative matrix for the `config` command family (#3593).
*
* Exercises the 12 adversarial input categories enumerated in
* CONTRIBUTING.md §"QA Matrix Requirements / CLI and command routing"
* against `config-get` and `config-set`. The harness in
* `tests/helpers/cli-negative.cjs` shapes spawnSync output into a typed
* IR so every assertion runs on `result.reason`, `result.status`, and
* `result.hasStackTrace` — never on stderr/stdout prose.
*
* Each test gets its own temp project (no shared state) so concurrent
* runs can't observe each other's filesystem mutations. Hostile values
* (shell metacharacters, null bytes, unicode, very long strings) reach
* the CLI as single argv elements via spawnSync — never composed into
* a shell string — so the test framework itself can't be the source of
* a false positive on shell-injection assertions.
*/
'use strict';
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { runCli } = require('./helpers/cli-negative.cjs');
const { createTempProject, cleanup } = require('./helpers.cjs');
/**
* Universal invariants every adversarial case must satisfy when the
* CLI is invoked with --json-errors. Bundling these in a helper keeps
* each test focused on the case-specific reason assertion.
*/
function assertSafeFailure(result, msg = '') {
assert.notEqual(result.status, 0, `${msg} :: expected non-zero exit`);
assert.equal(result.signal, null, `${msg} :: must exit cleanly, not via signal`);
assert.equal(result.hasStackTrace, false, `${msg} :: stderr must not leak a V8 stack frame`);
assert.equal(result.ok, false, `${msg} :: JSON payload ok must be false`);
assert.equal(typeof result.reason, 'string', `${msg} :: reason must be a string`);
assert.notEqual(result.reason, '', `${msg} :: reason must not be empty`);
// The harness's JSON-shape detection runs on the trimmed stderr; if we
// got here with reason set, the payload was a valid object — that already
// implies no rogue prose was mixed in. Re-asserting the trimmed form would
// be redundant.
}
/**
* Snapshot the file inventory of a directory so a later assertion can
* prove the failing CLI invocation did NOT create or modify any file.
*/
function snapshotInventory(dir) {
const entries = [];
function walk(rel) {
const abs = path.join(dir, rel);
let stat;
try { stat = fs.lstatSync(abs); } catch { return; }
if (stat.isDirectory()) {
for (const name of fs.readdirSync(abs).sort()) walk(path.join(rel, name));
} else {
entries.push(`${rel}\t${stat.size}\t${stat.mtimeMs}`);
}
}
walk('.');
return entries.join('\n');
}
// ─── 1. Missing required arg ────────────────────────────────────────────────
test('config-get with no key fails with a typed reason and no stack trace', (t) => {
const projectDir = createTempProject('cli-neg-config-1-');
t.after(() => cleanup(projectDir));
const before = snapshotInventory(projectDir);
const result = runCli(['config-get'], { cwd: projectDir });
assertSafeFailure(result, 'config-get missing key');
assert.equal(snapshotInventory(projectDir), before, 'failing read must not mutate FS');
});
test('config-set with no key fails with a typed reason', (t) => {
const projectDir = createTempProject('cli-neg-config-2-');
t.after(() => cleanup(projectDir));
const result = runCli(['config-set'], { cwd: projectDir });
assertSafeFailure(result, 'config-set missing key');
});
test('config-set with key but no value fails with a typed reason', (t) => {
const projectDir = createTempProject('cli-neg-config-3-');
t.after(() => cleanup(projectDir));
const result = runCli(['config-set', 'model_profile'], { cwd: projectDir });
assertSafeFailure(result, 'config-set missing value');
});
// ─── 2/3. Empty / whitespace arg ────────────────────────────────────────────
test('config-get with empty-string key fails safely', (t) => {
const projectDir = createTempProject('cli-neg-config-4-');
t.after(() => cleanup(projectDir));
const before = snapshotInventory(projectDir);
const result = runCli(['config-get', ''], { cwd: projectDir });
assertSafeFailure(result, 'config-get empty key');
assert.equal(snapshotInventory(projectDir), before, 'failing read must not mutate FS');
});
test('config-get with whitespace-only key fails safely', (t) => {
const projectDir = createTempProject('cli-neg-config-5-');
t.after(() => cleanup(projectDir));
const result = runCli(['config-get', ' \t '], { cwd: projectDir });
assertSafeFailure(result, 'config-get whitespace key');
});
test('config-set with empty key string fails safely', (t) => {
const projectDir = createTempProject('cli-neg-config-6-');
t.after(() => cleanup(projectDir));
const result = runCli(['config-set', '', 'value'], { cwd: projectDir });
assertSafeFailure(result, 'config-set empty key');
});
// ─── 4. Duplicate flags ─────────────────────────────────────────────────────
test('--cwd specified twice does not silently use the wrong one', (t) => {
// Make two real but distinct dirs so the test can't accidentally pass
// because one of the paths is invalid.
const a = createTempProject('cli-neg-config-7a-');
const b = createTempProject('cli-neg-config-7b-');
t.after(() => { cleanup(a); cleanup(b); });
// No --json-errors here on purpose: --cwd is parsed before json mode is
// applied, so we exercise both code paths by running once each.
const result = runCli(['--cwd', a, '--cwd', b, 'config-get', 'model_profile'], { cwd: process.cwd() });
// Either: (a) the CLI rejects duplicate --cwd with a typed reason; OR
// (b) it commits to one of the values deterministically. The safety
// bar is "no stack trace, no half-state mutation in EITHER dir".
assert.equal(result.hasStackTrace, false, 'duplicate --cwd must not crash with a stack trace');
// Neither tmp dir should have a written config since model_profile is
// a read, not a write, and it didn't exist beforehand. Prove the read
// didn't accidentally trigger a write side effect.
assert.equal(fs.existsSync(path.join(a, '.planning', 'config.json')), false);
assert.equal(fs.existsSync(path.join(b, '.planning', 'config.json')), false);
});
// ─── 5. Conflicting flags ───────────────────────────────────────────────────
test('--json-errors with --no-such-flag does not crash with a stack trace', (t) => {
const projectDir = createTempProject('cli-neg-config-8-');
t.after(() => cleanup(projectDir));
const result = runCli(['--no-such-flag', 'config-get', 'model_profile'], { cwd: projectDir });
assert.equal(result.hasStackTrace, false, 'unknown global flag must not crash with a stack trace');
assert.notEqual(result.status, 0, 'unknown global flag must fail');
});
// ─── 6. Malformed assignment / unknown subcommand ──────────────────────────
test('config-FAKE subcommand fails with a typed reason', (t) => {
const projectDir = createTempProject('cli-neg-config-9-');
t.after(() => cleanup(projectDir));
const result = runCli(['config-FAKE'], { cwd: projectDir });
assertSafeFailure(result, 'unknown config-* command');
});
// ─── 7. Unknown subcommands at each command depth ───────────────────────────
test('config family — bare top-level "config" without a subcommand fails safely', (t) => {
const projectDir = createTempProject('cli-neg-config-10-');
t.after(() => cleanup(projectDir));
const result = runCli(['config'], { cwd: projectDir });
// Either "missing subcommand" usage or genuine no-op behavior — what we
// pin is "no stack trace, no FS mutation".
assert.equal(result.hasStackTrace, false);
});
// ─── 8. Values that look like flags ─────────────────────────────────────────
test('config-set value that starts with -- is treated as a value, not a flag', (t) => {
const projectDir = createTempProject('cli-neg-config-11-');
t.after(() => cleanup(projectDir));
// First create a config.json so the set has a target file.
runCli(['config-ensure-section'], { cwd: projectDir });
const result = runCli(['config-set', 'project_code', '--weird'], { cwd: projectDir });
// Acceptable outcomes:
// (a) CLI accepts --weird as the value (and persists it),
// (b) CLI rejects it as a usage error.
// Either way: no stack trace, no half-written corrupt config.
assert.equal(result.hasStackTrace, false, 'value-looking-like-a-flag must not crash');
const configPath = path.join(projectDir, '.planning', 'config.json');
if (fs.existsSync(configPath)) {
// If a config exists, it must still be valid JSON — no half-write corruption.
const raw = fs.readFileSync(configPath, 'utf-8');
assert.doesNotThrow(() => JSON.parse(raw), 'config.json must remain parseable after a failed set');
}
});
// ─── 9. Invalid JSON / corrupt config file ──────────────────────────────────
test('config-get against a corrupt config.json fails with a parse-failed reason', (t) => {
const projectDir = createTempProject('cli-neg-config-12-');
t.after(() => cleanup(projectDir));
const configPath = path.join(projectDir, '.planning', 'config.json');
fs.writeFileSync(configPath, '{ this is not json'); // deliberate corruption
const originalCorrupt = fs.readFileSync(configPath, 'utf-8');
const result = runCli(['config-get', 'model_profile'], { cwd: projectDir });
assertSafeFailure(result, 'corrupt config.json');
// The corrupt file must remain untouched — the CLI must not "helpfully"
// overwrite an unparseable config in the failure path.
assert.equal(fs.readFileSync(configPath, 'utf-8'), originalCorrupt, 'corrupt file must be preserved as-is');
// Specific reason: CONFIG_PARSE_FAILED (or equivalent) — pin this so a
// regression where parse failure leaks as "unknown" is caught.
assert.match(
result.reason,
/^(config_parse_failed|config_no_file|config_invalid_key|usage)$/,
`parse-failure reason must be from the typed ERROR_REASON enum (got: ${result.reason})`,
);
});
// ─── 10. Very long arg ──────────────────────────────────────────────────────
test('config-get with a very long key (50KB) fails safely without hanging', (t) => {
const projectDir = createTempProject('cli-neg-config-13-');
t.after(() => cleanup(projectDir));
const longKey = 'x'.repeat(50000);
const result = runCli(['config-get', longKey], { cwd: projectDir, timeoutMs: 8000 });
assert.equal(result.signal, null, 'long input must not trigger the harness timeout');
assert.equal(result.hasStackTrace, false, 'long input must not crash');
assert.notEqual(result.status, 0, 'unknown 50KB key must fail');
});
// ─── 11. Unicode / non-ASCII ────────────────────────────────────────────────
test('config-get with a Unicode key fails safely', (t) => {
const projectDir = createTempProject('cli-neg-config-14-');
t.after(() => cleanup(projectDir));
const result = runCli(['config-get', 'workflow.🔥_mode'], { cwd: projectDir });
assertSafeFailure(result, 'unicode key');
});
test('config-set with an emoji value persists or rejects without corrupting JSON', (t) => {
const projectDir = createTempProject('cli-neg-config-15-');
t.after(() => cleanup(projectDir));
runCli(['config-ensure-section'], { cwd: projectDir });
const result = runCli(['config-set', 'project_code', '🔥👾'], { cwd: projectDir });
assert.equal(result.hasStackTrace, false);
// If it accepted, the JSON must round-trip cleanly.
const configPath = path.join(projectDir, '.planning', 'config.json');
if (result.status === 0 && fs.existsSync(configPath)) {
const parsed = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
assert.equal(typeof parsed, 'object', 'config.json must be a valid object');
if (parsed.project_code != null) {
assert.equal(typeof parsed.project_code, 'string', 'project_code must remain a string');
}
}
});
// ─── 12. Shell metacharacters (the security-critical case) ──────────────────
const SHELL_PAYLOADS = [
// Each one would, if shell-interpreted, create a sentinel file
// adjacent to the project tree. Argv-based invocation must treat them
// as opaque text.
'$(touch ${PROJECT}/INJ-dollar-paren)',
'`touch ${PROJECT}/INJ-backtick`',
'; touch ${PROJECT}/INJ-semicolon;',
'&& touch ${PROJECT}/INJ-and',
'|| touch ${PROJECT}/INJ-or',
'| tee ${PROJECT}/INJ-pipe',
'> ${PROJECT}/INJ-redirect',
// Quote-balanced payloads — these have historically broken naive
// shell-string composition even when the rest of the code uses argv.
'"; touch ${PROJECT}/INJ-quote;"',
'\'; touch ${PROJECT}/INJ-quote;\'',
];
for (const payload of SHELL_PAYLOADS) {
test(`config-get with shell-metachar key (${payload.slice(0, 25)}…) does NOT execute the payload`, (t) => {
const projectDir = createTempProject('cli-neg-config-shell-');
t.after(() => cleanup(projectDir));
const resolvedPayload = payload.replace(/\$\{PROJECT\}/g, projectDir);
const result = runCli(['config-get', resolvedPayload], { cwd: projectDir });
// No shell interpretation: none of the INJ-* sentinel files must
// exist after the run. Walk the project dir and assert.
const entries = fs.readdirSync(projectDir);
const sentinels = entries.filter((n) => n.startsWith('INJ-'));
assert.deepEqual(sentinels, [], `shell payload must NOT create sentinel files (found: ${sentinels.join(', ')})`);
// The CLI may exit 0 (legitimate — the metacharacter-laden key
// simply doesn't exist in config) or non-zero (typed reason). Both
// are acceptable as long as no payload was executed.
assert.equal(result.hasStackTrace, false);
});
}
// ─── Cross-cutting: --cwd points at a non-existent path ────────────────────
test('--cwd pointing at a non-existent path fails with a typed usage reason', (_t) => {
const nonExistent = path.join(require('os').tmpdir(), 'cli-neg-no-such-dir-' + Date.now() + '-' + Math.random());
assert.equal(fs.existsSync(nonExistent), false, 'pre-check: path must not exist');
const result = runCli(['--cwd', nonExistent, 'config-get', 'model_profile'], { cwd: process.cwd() });
assert.notEqual(result.status, 0);
assert.equal(result.hasStackTrace, false);
// gsd-tools validates --cwd up-front and emits ERROR_REASON.USAGE.
assert.equal(result.reason, 'usage', `expected reason=usage for invalid --cwd, got: ${result.reason}`);
});
test('--cwd with an empty value fails with a typed usage reason', () => {
const result = runCli(['--cwd', '', 'config-get', 'model_profile'], { cwd: process.cwd() });
assert.notEqual(result.status, 0);
assert.equal(result.hasStackTrace, false);
assert.equal(result.reason, 'usage');
});

View File

@@ -1,138 +0,0 @@
/**
* Meta-test for the CLI negative-matrix harness (#3593).
*
* The harness in `tests/helpers/cli-negative.cjs` shapes spawnSync
* results into a typed IR that adversarial-input tests consume. This
* file pins the IR contract by exercising the harness against
* deliberate scenarios — not as a placeholder for the real matrix tests
* (those live in sibling feat-3593-* files) but to surface harness
* regressions before they cascade through every matrix test.
*
* Tests deliberately avoid prose-matching: they assert on numeric exit
* codes, boolean flags, and reason codes pulled from the parsed JSON
* payload.
*/
'use strict';
const { test } = require('node:test');
const assert = require('node:assert/strict');
const { runCli, parseSpawnResult } = require('./helpers/cli-negative.cjs');
const { createTempProject, cleanup } = require('./helpers.cjs');
test('runCli rejects non-array argv with TypeError', () => {
assert.throws(
() => runCli('config-get', { cwd: '/tmp' }),
(err) => err instanceof TypeError && /argv/.test(err.message),
);
});
test('runCli rejects missing cwd with TypeError', () => {
assert.throws(
() => runCli(['config-get'], {}),
(err) => err instanceof TypeError && /cwd/.test(err.message),
);
});
test('runCli surfaces typed reason from a known failure path', (t) => {
const projectDir = createTempProject('cli-neg-harness-');
t.after(() => cleanup(projectDir));
// Unknown command — gsd-tools emits ERROR_REASON.SDK_UNKNOWN_COMMAND or
// USAGE depending on dispatch depth. Either is a real reason string;
// the contract we pin here is just "the IR carries a reason from the
// ERROR_REASON enum, never null".
const result = runCli(['this-command-does-not-exist'], { cwd: projectDir });
assert.notEqual(result.status, 0, 'unknown command must exit non-zero');
assert.equal(result.ok, false, 'JSON payload must report ok=false');
assert.equal(typeof result.reason, 'string', 'reason must be a string from ERROR_REASON');
assert.notEqual(result.reason, null);
assert.notEqual(result.reason, '');
assert.equal(result.hasStackTrace, false, 'a typed failure must NOT print a V8 stack trace');
});
test('parseSpawnResult detects stack-trace leakage in stderr', () => {
const fakeSpawn = {
status: 1,
signal: null,
stdout: '',
stderr: 'Error: boom\n at Object.<anonymous> (/some/file.js:10:5)\n at Module._compile\n',
error: null,
};
const ir = parseSpawnResult(fakeSpawn, { jsonErrorsRequested: false });
assert.equal(ir.hasStackTrace, true, 'stack frames in stderr must be flagged');
assert.equal(ir.reason, null, 'non-JSON stderr leaves reason null');
});
test('parseSpawnResult does NOT match the literal word "at" in prose', () => {
// Guard against a regex regression that would catch sentences like
// "command failed at startup" as stack frames.
const fakeSpawn = {
status: 1,
signal: null,
stdout: '',
stderr: 'Error: command failed at startup\nbecause no project was found.\n',
error: null,
};
const ir = parseSpawnResult(fakeSpawn, { jsonErrorsRequested: false });
assert.equal(ir.hasStackTrace, false, 'prose containing the word "at" is not a stack frame');
});
test('parseSpawnResult extracts ok/reason/message from a json-errors payload', () => {
const payload = { ok: false, reason: 'config_invalid_key', message: 'no such key: foo' };
const fakeSpawn = {
status: 1,
signal: null,
stdout: '',
stderr: JSON.stringify(payload) + '\n',
error: null,
};
const ir = parseSpawnResult(fakeSpawn, { jsonErrorsRequested: true });
assert.equal(ir.ok, false);
assert.equal(ir.reason, 'config_invalid_key');
assert.equal(ir.message, 'no such key: foo');
assert.equal(ir.hasStackTrace, false);
});
test('parseSpawnResult ignores malformed JSON in stderr without throwing', () => {
const fakeSpawn = {
status: 1,
signal: null,
stdout: '',
stderr: '{ ok: false, reason }', // missing quotes — invalid JSON
error: null,
};
const ir = parseSpawnResult(fakeSpawn, { jsonErrorsRequested: true });
assert.equal(ir.ok, null, 'malformed JSON must NOT promote partial data into ok');
assert.equal(ir.reason, null);
assert.equal(ir.message, null);
});
test('parseSpawnResult ignores JSON arrays and primitives, only accepts objects', () => {
const cases = [
'["ok", false]', // array
'"just a string"', // primitive
'null', // null literal
'42', // number
];
for (const stderr of cases) {
const ir = parseSpawnResult(
{ status: 1, signal: null, stdout: '', stderr, error: null },
{ jsonErrorsRequested: true },
);
assert.equal(ir.ok, null, `non-object JSON (${stderr}) must not set ok`);
assert.equal(ir.reason, null);
}
});
test('runCli treats jsonErrors=false as an explicit human-formatter path', (t) => {
const projectDir = createTempProject('cli-neg-harness-text-');
t.after(() => cleanup(projectDir));
const result = runCli(['this-command-does-not-exist'], { cwd: projectDir, jsonErrors: false });
assert.notEqual(result.status, 0);
assert.equal(result.jsonErrorsRequested, false);
// Reason fields stay null in human-mode because stderr is prose, not JSON.
assert.equal(result.ok, null);
assert.equal(result.reason, null);
// But the prose still must not include a V8 stack trace.
assert.equal(result.hasStackTrace, false);
});

View File

@@ -1,269 +0,0 @@
// allow-test-rule: source-text-is-the-product see #1464
// Tutorial docs are the product surface users follow. Reading JSON code blocks
// from them and validating through validateCapability is behavioral, not
// source-grep — it proves the manifests work, not just that they "mention" a term.
'use strict';
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { validateCapability } = require('../scripts/gen-capability-registry.cjs');
const ROOT = path.join(__dirname, '..');
// ─── Extractor ───────────────────────────────────────────────────────────────
// Required top-level fields that distinguish a complete capability manifest
// from a partial output snippet (list-entry, install-result, etc.).
// Partial output snippets have id+role but lack steps/contributions/gates/config.
const MANIFEST_REQUIRED_KEYS = new Set([
'id', 'role', 'title', 'description', 'tier',
'requires', 'runtimeCompat', 'skills', 'agents',
'config', 'steps', 'contributions', 'gates',
]);
/**
* Extract JSON code blocks from markdown that are complete capability manifests.
* A complete manifest has ALL keys in MANIFEST_REQUIRED_KEYS.
* Partial output snippets (list-entries, install-results) have only id+role and are skipped.
*/
function extractManifests(mdContent) {
const manifests = [];
const fenceRe = /```json\s*\r?\n([\s\S]*?)```/g;
let match;
while ((match = fenceRe.exec(mdContent)) !== null) {
let parsed;
try {
parsed = JSON.parse(match[1]);
} catch {
continue;
}
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
const keys = new Set(Object.keys(parsed));
if ([...MANIFEST_REQUIRED_KEYS].every((k) => keys.has(k))) {
manifests.push(parsed);
}
}
}
return manifests;
}
// ─── Suite 1: tutorial manifests validate ────────────────────────────────────
describe('docs tutorial manifests pass validateCapability (#1464 regression)', () => {
test('build-your-first-capability.md: every manifest passes', () => {
const content = fs.readFileSync(
path.join(ROOT, 'docs', 'tutorials', 'build-your-first-capability.md'),
'utf8',
);
const manifests = extractManifests(content);
assert.ok(
manifests.length > 0,
'expected at least one capability manifest in build tutorial',
);
for (const cap of manifests) {
const errors = validateCapability(cap, cap.id);
assert.deepStrictEqual(
errors,
[],
`build tutorial manifest id="${cap.id}" failed validateCapability:\n ${errors.join('\n ')}`,
);
}
});
test('install-your-first-capability.md: every manifest passes', () => {
const content = fs.readFileSync(
path.join(ROOT, 'docs', 'tutorials', 'install-your-first-capability.md'),
'utf8',
);
const manifests = extractManifests(content);
assert.ok(
manifests.length > 0,
'expected at least one capability manifest in install tutorial',
);
for (const cap of manifests) {
const errors = validateCapability(cap, cap.id);
assert.deepStrictEqual(
errors,
[],
`install tutorial manifest id="${cap.id}" failed validateCapability:\n ${errors.join('\n ')}`,
);
}
});
test('capability-manifest.md reference example passes', () => {
const content = fs.readFileSync(
path.join(ROOT, 'docs', 'reference', 'capability-manifest.md'),
'utf8',
);
const manifests = extractManifests(content);
assert.ok(
manifests.length > 0,
'expected at least one capability manifest in reference doc',
);
for (const cap of manifests) {
const errors = validateCapability(cap, cap.id);
assert.deepStrictEqual(
errors,
[],
`reference manifest id="${cap.id}" failed validateCapability:\n ${errors.join('\n ')}`,
);
}
});
});
// ─── Suite 2: adversarial — #1464 failure modes caught ───────────────────────
//
// These are the EXACT failure shapes from issue #1464.
// They must fail validateCapability — proving this test would have caught the bug.
describe('validateCapability catches original #1464 bug shapes', () => {
// #1464 high-1: step missing ref → validateStep rejects it
test('step without ref fails (the original broken tutorial step)', () => {
const cap = {
id: 'hello-note',
role: 'feature',
version: '0.1.0',
title: 'Hello Note',
description: 'Test fixture for #1464 regression.',
tier: 'standard',
requires: [],
engines: { gsd: '>=1.6.0' },
runtimeCompat: { supported: ['*'], unsupported: [] },
skills: [],
agents: [],
config: {},
steps: [
{
// Missing ref — this was the #1464 high-1 bug in the original tutorial
point: 'plan:pre',
produces: ['HELLO.md'],
consumes: [],
onError: 'skip',
},
],
contributions: [],
gates: [],
};
const errors = validateCapability(cap, 'hello-note');
assert.ok(errors.length > 0, 'expected validation errors for step without ref');
assert.ok(
errors.some((e) => /ref/.test(e)),
`expected an error mentioning "ref"; got: ${errors.join('; ')}`,
);
});
// #1464 shape: id must match folder name (folderId contract)
test('id not matching folderId fails', () => {
const cap = {
id: 'hello-note',
role: 'feature',
version: '0.1.0',
title: 'Hello Note',
description: 'Test fixture for id/folderId mismatch.',
tier: 'standard',
requires: [],
runtimeCompat: { supported: ['*'], unsupported: [] },
skills: [],
agents: [],
config: {},
steps: [],
contributions: [],
gates: [],
};
const errors = validateCapability(cap, 'wrong-folder');
assert.ok(errors.length > 0, 'expected id/folderId mismatch to fail validation');
assert.ok(
errors.some((e) => /folder/.test(e) || /equal/.test(e) || /id/.test(e)),
`expected error about id/folderId mismatch; got: ${errors.join('; ')}`,
);
});
// Corrected shape: contribution with fragment + into (the PR #1495 fix)
test('contribution with fragment.path + into passes (the PR #1495 fix shape)', () => {
const cap = {
id: 'hello-note',
role: 'feature',
version: '0.1.0',
title: 'Hello Note',
description: 'Injects a greeting note at plan:pre and produces HELLO.md.',
tier: 'standard',
requires: [],
runtimeCompat: { supported: ['*'], unsupported: [] },
skills: [],
agents: [],
config: {},
steps: [],
contributions: [
{
point: 'plan:pre',
into: 'planner',
fragment: { path: 'fragments/plan-pre.md' },
produces: ['HELLO.md'],
consumes: [],
onError: 'skip',
},
],
gates: [],
};
const errors = validateCapability(cap, 'hello-note');
assert.deepStrictEqual(
errors,
[],
`corrected contribution manifest has unexpected errors: ${errors.join('; ')}`,
);
});
});
// ─── Suite 3: extractManifests helper ────────────────────────────────────────
describe('extractManifests helper unit tests', () => {
test('returns empty array for plain text with no JSON fences', () => {
assert.deepStrictEqual(extractManifests('No code blocks here.'), []);
});
test('skips JSON blocks without all required manifest keys', () => {
// Partial list-entry block — only has id, role, version but not steps/contributions/etc.
const md = '```json\n{"id":"x","role":"feature","version":"1.0.0"}\n```';
assert.deepStrictEqual(extractManifests(md), []);
});
function makeCompleteManifest(overrides) {
return {
id: 'test-cap', role: 'feature', title: 'T', description: 'D',
tier: 'standard', requires: [], runtimeCompat: { supported: ['*'], unsupported: [] },
skills: [], agents: [], config: {}, steps: [], contributions: [], gates: [],
...overrides,
};
}
test('extracts a complete manifest (all required keys present)', () => {
const cap = makeCompleteManifest({ id: 'x' });
const md = '```json\n' + JSON.stringify(cap, null, 2) + '\n```';
const result = extractManifests(md);
assert.strictEqual(result.length, 1);
assert.strictEqual(result[0].id, 'x');
});
test('skips malformed JSON blocks silently', () => {
const complete = makeCompleteManifest({ id: 'y' });
const md = '```json\n{bad json here\n```\n```json\n' + JSON.stringify(complete) + '\n```';
const result = extractManifests(md);
assert.strictEqual(result.length, 1);
assert.strictEqual(result[0].id, 'y');
});
test('extracts multiple complete manifests from one doc', () => {
const a = makeCompleteManifest({ id: 'cap-a' });
const b = makeCompleteManifest({ id: 'cap-b' });
const md = [
'```json\n' + JSON.stringify(a) + '\n```',
'```json\n' + JSON.stringify(b) + '\n```',
].join('\n');
const result = extractManifests(md);
assert.strictEqual(result.length, 2);
});
});

View File

@@ -1,95 +0,0 @@
'use strict';
/**
* E2E regression tests for #1521: real install path (copyWithPathReplacement)
* MUST stamp non-Claude runtime defaults into emitted gsd-core/workflows/*.md.
*
* The earlier unit tests in fix-1521-non-claude-runtime-default-resolution.test.cjs
* only verify the engine (_applyRuntimeRewrites). This test verifies the wiring:
* that a REAL `node bin/install.js --codex/--cursor --global` actually emits
* execute-phase.md with --default codex / --default cursor (not --default claude).
*
* Root cause: copyWithPathReplacement is the emit path for gsd-core/workflows/*.md;
* it did its own inline path rewrites but never called _stampNonClaudeRuntimeDefaults,
* so the stamping was dead-on-arrival in real installs.
*
* This test must be RED before the fix is applied (Step 1) and GREEN after (Step 2).
*/
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { spawnSync } = require('node:child_process');
const { cleanup } = require('./helpers.cjs');
const INSTALL = path.join(__dirname, '..', 'bin', 'install.js');
/**
* Run a real install into a temp config dir and return the emitted
* execute-phase.md content.
* @param {string} runtime e.g. 'codex', 'cursor', 'claude'
* @returns {string}
*/
function installAndRead(runtime) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), `gsd-inst-${runtime}-`));
const res = spawnSync(
process.execPath,
[INSTALL, `--${runtime}`, '--global', '--config-dir', dir],
{ encoding: 'utf8', timeout: 120000 },
);
assert.strictEqual(res.status, 0, `install --${runtime} failed: ${res.stderr || res.stdout}`);
const wf = path.join(dir, 'gsd-core', 'workflows', 'execute-phase.md');
assert.ok(fs.existsSync(wf), `emitted workflow missing for ${runtime}: ${wf}`);
const content = fs.readFileSync(wf, 'utf8');
cleanup(dir);
return content;
}
// ---------------------------------------------------------------------------
// RED tests: these MUST FAIL before the copyWithPathReplacement wiring is added
// ---------------------------------------------------------------------------
test('real install: codex-emitted execute-phase.md resolves runtime=codex and defaults worktrees off (#1521)', () => {
const c = installAndRead('codex');
assert.ok(
c.includes('config-get runtime --default codex --raw'),
'codex runtime default not stamped in real install',
);
assert.ok(
c.includes('config-get workflow.use_worktrees --default false --raw'),
'codex use_worktrees not defaulted false in real install',
);
assert.ok(
!c.includes('config-get runtime --default claude --raw'),
'residual claude default in codex install',
);
});
test('real install: cursor-emitted execute-phase.md resolves runtime=cursor (#1521)', () => {
const c = installAndRead('cursor');
assert.ok(
c.includes('config-get runtime --default cursor --raw'),
'cursor runtime default not stamped in real install',
);
assert.ok(
!c.includes('config-get runtime --default claude --raw'),
'residual claude default in cursor install',
);
});
test('real install: claude-emitted execute-phase.md keeps claude default + worktrees on (#1521)', () => {
const c = installAndRead('claude');
assert.ok(
c.includes('config-get runtime --default claude --raw'),
'claude default changed in claude install',
);
assert.ok(
c.includes('config-get workflow.use_worktrees --raw 2>/dev/null || echo "true"'),
'claude worktrees default changed (should still be true)',
);
assert.ok(
!c.includes('config-get workflow.use_worktrees --default false --raw'),
'claude install must NOT have use_worktrees=false stamped',
);
});

View File

@@ -1,208 +0,0 @@
'use strict';
/**
* Regression tests for #1920: the installer must produce a capability-ecosystem-
* complete flattened layout, and the capability loader must resolve the real host
* version in that layout.
*
* Two gaps broke third-party capabilities on installed (flattened) layouts:
*
* Gap 1 — host version read as 0.0.0. `readHostVersion()` resolved the running GSD
* version via require('../../../package.json'), which in the installed layout is the
* marker package.json ({"type":"commonjs"}, no version) → the fail-closed fallback
* reported 0.0.0, so `capability install` rejected any manifest with a real
* engines.gsd range as "incompatible with GSD 0.0.0". Worse, for runtimes that get
* no marker and for local installs, that walked-up package.json could be the USER's
* own project, reporting a wrong version. Fix: readHostVersion() prefers the
* authoritative gsd-core/VERSION the installer writes for EVERY runtime.
*
* Gap 2 — the registry generator was never shipped. The loader composes overlays via
* require('../../../scripts/gen-capability-registry.cjs'); the installer never copied
* it (nor its sibling gen-loop-host-contract.cjs), so the never-crash invariant
* discarded EVERY overlay and fell back to the frozen first-party registry —
* installed third-party capabilities were silently inert. Same class of gap as #1223
* (scripts/fix-slash-commands.cjs).
*
* These tests are RED before the fix (loader/install.js) and GREEN after.
*/
process.env.GSD_TEST_MODE = '1';
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { spawnSync } = require('node:child_process');
const { cleanup } = require('./helpers.cjs');
const ROOT = path.join(__dirname, '..');
const INSTALL = path.join(ROOT, 'bin', 'install.js');
const MANIFEST_NAME = 'gsd-file-manifest.json';
// The generator scripts the capability loader requires by relative path.
const GENERATORS = ['gen-capability-registry.cjs', 'gen-loop-host-contract.cjs'];
// ---------------------------------------------------------------------------
// Gap 1 — readHostVersion() prefers gsd-core/VERSION (the installer-written,
// all-runtime authoritative source) over an ambient/absent package.json.
// ---------------------------------------------------------------------------
describe('Gap 1: readHostVersion resolves the real host version in an installed layout (#1920)', () => {
const { readHostVersion } = require('../gsd-core/bin/lib/capability-loader.cjs');
/** Build a fake installed tree and return its gsd-core/bin/lib dir (the module libDir). */
function fakeTree({ version, pkg }) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-1920-ver-'));
const libDir = path.join(root, 'gsd-core', 'bin', 'lib');
fs.mkdirSync(libDir, { recursive: true });
if (version !== undefined) fs.writeFileSync(path.join(root, 'gsd-core', 'VERSION'), version);
if (pkg !== undefined) fs.writeFileSync(path.join(root, 'package.json'), JSON.stringify(pkg));
return { root, libDir };
}
test('prefers gsd-core/VERSION over a wrong ambient package.json version', () => {
// The walked-up package.json belongs to the user's project (wrong version) — must be ignored.
const { root, libDir } = fakeTree({ version: '9.9.9\n', pkg: { name: 'user-app', version: '1.2.3', type: 'commonjs' } });
try {
assert.strictEqual(readHostVersion(libDir), '9.9.9');
} finally {
cleanup(root);
}
});
test('falls back to the runtime-root package.json when no VERSION file (dev/source tree)', () => {
const { root, libDir } = fakeTree({ pkg: { version: '2.3.4' } });
try {
assert.strictEqual(readHostVersion(libDir), '2.3.4');
} finally {
cleanup(root);
}
});
test('fail-closes to 0.0.0 when neither VERSION nor a package.json version resolves', () => {
const { root, libDir } = fakeTree({});
try {
assert.strictEqual(readHostVersion(libDir), '0.0.0');
} finally {
cleanup(root);
}
});
test('a real global install writes gsd-core/VERSION carrying the host version', () => {
const dir = realInstall();
try {
const vfile = path.join(dir, 'gsd-core', 'VERSION');
assert.ok(fs.existsSync(vfile), 'installer must write gsd-core/VERSION');
assert.strictEqual(
fs.readFileSync(vfile, 'utf8').trim(),
require('../package.json').version,
'gsd-core/VERSION must carry the real host version readHostVersion() reads',
);
} finally {
cleanup(dir);
}
});
});
// ---------------------------------------------------------------------------
// Gap 2 — the installer ships (and uninstalls / manifest-tracks) the capability
// registry generator scripts.
// ---------------------------------------------------------------------------
/** Run a real global install into a fresh temp config dir; return that dir. */
function realInstall() {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-1920-'));
// The module-level GSD_TEST_MODE=1 gates the installer's main() off entirely —
// strip it from the child env for the spawned REAL install.
const childEnv = { ...process.env };
delete childEnv.GSD_TEST_MODE;
const res = spawnSync(
process.execPath,
[INSTALL, '--claude', '--global', '--config-dir', dir],
{ encoding: 'utf8', timeout: 120000, env: childEnv },
);
assert.strictEqual(res.status, 0, `install --claude failed: ${res.stderr || res.stdout}`);
return dir;
}
// ---------------------------------------------------------------------------
// Gap 1 (end-to-end CLI) — the actual repro: `gsd-tools capability install` on an
// INSTALLED layout must resolve the real host version for the engines.gsd gate, not
// 0.0.0. The dev tree always has a versioned package.json two levels up, so this only
// reproduces against a real install (where ../../package.json is the versionless marker
// and gsd-core/VERSION carries the truth). The CLI computes hostVersion itself, so this
// covers capHostVersion() in gsd-tools.cjs — a path the loader unit test does not touch.
// ---------------------------------------------------------------------------
describe('Gap 1 (end-to-end CLI): installed capability install uses the real host version (#1920)', () => {
const HOST_MAJOR = require('../package.json').version.split('.')[0];
function writeProbeCapability(engines) {
const src = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-1920-cap-'));
const cap = {
id: 'p1920-probe', role: 'feature', version: '1.0.0', title: 'probe',
description: 'test capability', tier: 'standard', requires: [],
runtimeCompat: { supported: ['*'], unsupported: [] },
skills: [], agents: [], hooks: [], config: {}, steps: [],
contributions: [], gates: [], engines,
};
fs.writeFileSync(path.join(src, 'capability.json'), JSON.stringify(cap, null, 2));
return src;
}
test('a capability requiring engines.gsd ">=<host major>.0.0" is not rejected as GSD 0.0.0', () => {
const dir = realInstall();
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-1920-home-'));
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-1920-cwd-'));
fs.mkdirSync(path.join(cwd, '.planning'), { recursive: true });
fs.writeFileSync(path.join(cwd, '.planning', 'config.json'), '{}');
const src = writeProbeCapability({ gsd: `>=${HOST_MAJOR}.0.0` });
try {
const installedTools = path.join(dir, 'gsd-core', 'bin', 'gsd-tools.cjs');
const env = { ...process.env, GSD_HOME: home, GSD_WORKSTREAM: '', GSD_PROJECT: '', GSD_SESSION_KEY: '', CLAUDE_SESSION_ID: '' };
delete env.GSD_TEST_MODE;
const res = spawnSync(
process.execPath,
[installedTools, 'capability', 'install', src, '--scope', 'global', '--yes', '--json'],
{ cwd, env, encoding: 'utf8', timeout: 60000 },
);
const combined = `${res.stdout || ''}\n${res.stderr || ''}`;
assert.doesNotMatch(
combined,
/incompatible with GSD 0\.0\.0/,
`installed CLI saw host version 0.0.0 — the engines gate read the versionless marker: ${combined}`,
);
assert.strictEqual(res.status, 0, `capability install failed on the installed layout: ${combined}`);
} finally {
cleanup(dir); cleanup(home); cleanup(cwd); cleanup(src);
}
});
});
describe('Gap 2: installer ships the capability registry generator scripts (#1920)', () => {
test('the generator scripts are copied into scripts/', () => {
const dir = realInstall();
try {
for (const gen of GENERATORS) {
const dest = path.join(dir, 'scripts', gen);
assert.ok(fs.existsSync(dest), `installer must ship scripts/${gen}`);
assert.ok(fs.statSync(dest).size > 0, `scripts/${gen} must not be empty`);
}
} finally {
cleanup(dir);
}
});
test('the shipped generator scripts are tracked in the file manifest', () => {
const dir = realInstall();
try {
const manifest = JSON.parse(fs.readFileSync(path.join(dir, MANIFEST_NAME), 'utf8'));
for (const gen of GENERATORS) {
assert.ok(
manifest.files[`scripts/${gen}`],
`manifest must track scripts/${gen} for drift/uninstall accounting`,
);
}
} finally {
cleanup(dir);
}
});
});

View File

@@ -436,3 +436,260 @@ describe('bug #2004: pr-branch preserves structural planning commits', () => {
});
});
}
// ────────────────────────────────────────────────────────────────────────
// Folded from tests/bug-2916-handle-branching-default-base.test.cjs — consolidation epic #1969 (B6 #1975)
// ────────────────────────────────────────────────────────────────────────
{
const { describe: __foldDescribe } = require('node:test');
__foldDescribe("folded:bug-2916-handle-branching-default-base (consolidation epic #1969 B6 #1975)", () => {
/**
* Regression test for #2916: execute-phase `handle_branching` step creates the
* per-phase branch off whatever HEAD is currently checked out (typically the
* previous phase's unmerged branch) instead of off `origin/HEAD`.
*
* The bug compounded phases on top of each other and stranded them unpushed
* for weeks. The fix:
* 1. Detect the default branch via `git symbolic-ref refs/remotes/origin/HEAD`.
* 2. If $BRANCH_NAME exists, switch to it (preserve existing behavior).
* 3. Otherwise, ff-update the default branch from origin and create the new
* phase branch off the default-branch tip.
* 4. Refuse-or-warn on dirty working tree.
* 5. Post-creation, assert `git rev-list --count $DEFAULT_BRANCH..HEAD == 0`.
*
* This test extracts the bash payload from the <step name="handle_branching">
* block in execute-phase.md (parsed structurally — no regex on prose), executes
* it inside a fixture git repo where HEAD sits on a previous-phase branch with
* extra commits, and asserts that the new phase branch's tip equals
* `origin/main` (no commits inherited from the previous phase).
*/
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const { execFileSync } = require('node:child_process');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { cleanup } = require('./helpers.cjs');
const EXECUTE_PHASE_PATH = path.join(
__dirname,
'..',
'gsd-core',
'workflows',
'execute-phase.md'
);
const GIT_ENV = Object.freeze({
...process.env,
GIT_AUTHOR_NAME: 'Test',
GIT_AUTHOR_EMAIL: 'test@test.com',
GIT_COMMITTER_NAME: 'Test',
GIT_COMMITTER_EMAIL: 'test@test.com',
});
function git(cwd, ...args) {
return execFileSync('git', args, {
cwd,
env: GIT_ENV,
stdio: ['pipe', 'pipe', 'pipe'],
})
.toString()
.trim();
}
/**
* Structurally extract the bash code that the handle_branching step instructs
* the agent to run. We:
* 1. Locate the <step name="handle_branching"> ... </step> block.
* 2. Walk its body looking for fenced ```bash blocks.
* 3. Concatenate every bash block in the step (the fix may use more than one).
*
* No `.includes()` content checks — we parse fence-delimited code blocks the
* same way a markdown parser would.
*/
function extractHandleBranchingBash() {
const content = fs.readFileSync(EXECUTE_PHASE_PATH, 'utf-8');
const lines = content.split(/\r?\n/);
let start = -1;
let end = -1;
for (let i = 0; i < lines.length; i += 1) {
if (start === -1 && /^<step\s+name="handle_branching">\s*$/.test(lines[i])) {
start = i + 1;
} else if (start !== -1 && /^<\/step>\s*$/.test(lines[i])) {
end = i;
break;
}
}
if (start === -1 || end === -1) {
throw new Error(
'execute-phase.md does not contain a <step name="handle_branching"> ... </step> block'
);
}
const bashBlocks = [];
let inBash = false;
let buffer = [];
for (let i = start; i < end; i += 1) {
const line = lines[i];
if (!inBash && /^```bash\s*$/.test(line)) {
inBash = true;
buffer = [];
continue;
}
if (inBash && /^```\s*$/.test(line)) {
bashBlocks.push(buffer.join('\n'));
inBash = false;
continue;
}
if (inBash) buffer.push(line);
}
if (bashBlocks.length === 0) {
throw new Error(
'handle_branching step contains no ```bash code blocks to execute'
);
}
return bashBlocks.join('\n');
}
/**
* Build a fixture: a bare "origin" repo with the named default branch (one
* commit), a clone with `origin/HEAD` pointed at it, and a checked-out
* previous-phase branch carrying its own unmerged commit.
*
* `defaultBranch` is parameterized so callers can lock in that the workflow
* honors `git symbolic-ref refs/remotes/origin/HEAD` rather than silently
* defaulting to `main` (#2921 CR feedback — quick-branching.test.cjs got the
* same treatment in 80f14cac; this test deserves the same coverage).
*/
function setupFixture(defaultBranch = 'main') {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2916-'));
const seedPath = path.join(root, 'seed');
const originPath = path.join(root, 'origin.git');
const clonePath = path.join(root, 'clone');
fs.mkdirSync(seedPath);
git(seedPath, 'init', '-b', defaultBranch);
git(seedPath, 'config', 'commit.gpgsign', 'false');
fs.writeFileSync(path.join(seedPath, 'README.md'), '# seed\n');
git(seedPath, 'add', 'README.md');
git(seedPath, 'commit', '-m', 'initial');
git(root, 'clone', '--bare', seedPath, originPath);
git(originPath, 'symbolic-ref', 'HEAD', `refs/heads/${defaultBranch}`);
git(root, 'clone', originPath, clonePath);
git(clonePath, 'config', 'commit.gpgsign', 'false');
git(clonePath, 'config', 'user.email', 'test@test.com');
git(clonePath, 'config', 'user.name', 'Test');
// Simulate finishing a previous phase: branch off the default branch, add
// a commit, and *stay* on it (the failure scenario described in the bug).
git(clonePath, 'checkout', '-b', 'feature/phase-01-foundation');
fs.writeFileSync(path.join(clonePath, 'phase01.txt'), 'phase 1 work\n');
git(clonePath, 'add', 'phase01.txt');
git(clonePath, 'commit', '-m', 'phase 01 work');
return { root, clonePath, defaultBranch };
}
function runHandleBranchingStep(bash, cwd, branchName) {
// Write the script to a sibling tempdir, not inside the repo — putting it in
// `cwd` would create an untracked file that trips `git status --porcelain`
// and steers the step into its dirty-tree fallback path.
const scriptDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2916-step-'));
const scriptPath = path.join(scriptDir, 'handle-branching.sh');
const script = `#!/usr/bin/env bash\nset -uo pipefail\nBRANCH_NAME="${branchName}"\n${bash}\n`;
fs.writeFileSync(scriptPath, script, { mode: 0o755 });
try {
return execFileSync('bash', [scriptPath], {
cwd,
env: GIT_ENV,
stdio: ['pipe', 'pipe', 'pipe'],
}).toString();
} finally {
cleanup(scriptDir);
}
}
describe('handle_branching branches off origin/HEAD, not current HEAD (#2916)', () => {
// Run against `main` (conventional default) and `trunk` (non-main default
// exercising the symbolic-ref code path) so a regression that hard-codes
// `main` instead of consulting origin/HEAD will fail the trunk variant.
for (const defaultBranch of ['main', 'trunk']) {
test(`new phase branch branches off origin/${defaultBranch} with 0 inherited commits`, () => {
const bash = extractHandleBranchingBash();
const { root, clonePath } = setupFixture(defaultBranch);
try {
const upstream = `origin/${defaultBranch}`;
assert.equal(
git(clonePath, 'rev-parse', '--abbrev-ref', 'HEAD'),
'feature/phase-01-foundation'
);
assert.equal(
git(clonePath, 'rev-list', '--count', `${upstream}..HEAD`),
'1',
`fixture should be 1 commit ahead of ${upstream}`
);
runHandleBranchingStep(bash, clonePath, 'feature/phase-02-content-sync');
assert.equal(
git(clonePath, 'rev-parse', '--abbrev-ref', 'HEAD'),
'feature/phase-02-content-sync',
'handle_branching should switch to the new phase branch'
);
const inherited = git(clonePath, 'rev-list', '--count', `${upstream}..HEAD`);
assert.equal(
inherited,
'0',
`new phase branch must branch off ${upstream}, but inherited ${inherited} commit(s) from previous-phase HEAD`
);
assert.equal(
git(clonePath, 'rev-parse', 'HEAD'),
git(clonePath, 'rev-parse', upstream),
`new phase branch tip must equal ${upstream} tip`
);
} finally {
cleanup(root);
}
});
}
test('handle_branching reuses an existing branch instead of forking again', () => {
const bash = extractHandleBranchingBash();
const { root, clonePath } = setupFixture();
try {
// Pre-create the target branch off origin/main with its own commit, then
// walk away to a different branch — the step must switch back to it.
git(clonePath, 'checkout', '-B', 'feature/phase-02-content-sync', 'origin/main');
fs.writeFileSync(path.join(clonePath, 'phase02.txt'), 'phase 2 work\n');
git(clonePath, 'add', 'phase02.txt');
git(clonePath, 'commit', '-m', 'phase 02 wip');
const phase02Sha = git(clonePath, 'rev-parse', 'HEAD');
git(clonePath, 'checkout', 'feature/phase-01-foundation');
runHandleBranchingStep(bash, clonePath, 'feature/phase-02-content-sync');
assert.equal(
git(clonePath, 'rev-parse', '--abbrev-ref', 'HEAD'),
'feature/phase-02-content-sync'
);
assert.equal(
git(clonePath, 'rev-parse', 'HEAD'),
phase02Sha,
'existing-branch tip must be preserved (no rebase/reset)'
);
} finally {
cleanup(root);
}
});
});
});
}

View File

@@ -573,3 +573,230 @@ describe('regressions', () => {
});
});
});
// ────────────────────────────────────────────────────────────────────────
// Folded from tests/bug-622-graphify-optional-graph-html.test.cjs — consolidation epic #1969 (B6 #1975)
// ────────────────────────────────────────────────────────────────────────
{
const { describe: __foldDescribe } = require('node:test');
__foldDescribe("folded:bug-622-graphify-optional-graph-html (consolidation epic #1969 B6 #1975)", () => {
// allow-test-rule: source-text-is-the-product (see #622)
// This test extracts the deployed Step 3 shell block from commands/gsd/graphify.md
// and executes it to prove that a skipped graph.html (due to the graphify HTML viz
// node limit) does not abort the chain (#622). The deployed markdown text IS the
// product surface — the block the runtime executes — so asserting on its execution
// behavior requires reading the source text.
'use strict';
/**
* Regression test for bug #622.
*
* The `/gsd-graphify build` Step 3 shell chain in commands/gsd/graphify.md
* aborted when `graph.html` was intentionally skipped (graph exceeds the HTML
* viz node limit, default 5000). The unconditional `cp graphify-out/graph.html`
* failed with "cannot stat", and the `&&` chain aborted before the
* GRAPH_REPORT.md copy, snapshot, and status steps ran.
*
* Fix: guard the graph.html copy with
* `{ [ -f graphify-out/graph.html ] && cp … || true; }`
* so the chain continues when the file is absent.
*/
const { describe, test, before, after } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const { spawnSync } = require('child_process');
const { createTempDir, cleanup } = require('./helpers.cjs');
// Path to the command doc (relative to repo root)
const GRAPHIFY_MD = path.join(__dirname, '..', 'commands', 'gsd', 'graphify.md');
/**
* Extract the Step 3 fenced bash block from graphify.md.
* The block starts with the line `graphify update .` and ends at the next
* closing ``` fence.
*
* Returns the bash source text (without the fence lines themselves).
*/
function extractStep3Block() {
const content = fs.readFileSync(GRAPHIFY_MD, 'utf-8');
// Capture the full body of the ```bash fence that CONTAINS `graphify update .`
// (including any leading preamble line), without crossing into other fences.
const match = content.match(/```bash\r?\n((?:(?!```)[\s\S])*?graphify update \.(?:(?!```)[\s\S])*?)\r?\n```/);
return match ? match[1].trim() : null;
}
// ─── shared sandbox dirs ──────────────────────────────────────────────────────
let sandbox;
let fakeBin;
let fakeHome;
before(() => {
sandbox = createTempDir('gsd-622-sandbox-');
fakeBin = createTempDir('gsd-622-fakebin-');
fakeHome = createTempDir('gsd-622-fakehome-');
});
after(() => {
cleanup(sandbox);
cleanup(fakeBin);
cleanup(fakeHome);
});
// ─── helpers ─────────────────────────────────────────────────────────────────
/**
* Write a minimal fake `graphify` executable into fakeBin.
* It just exits 0 so the `graphify update .` step succeeds.
*/
function writeFakeGraphify() {
const exe = path.join(fakeBin, 'graphify');
fs.writeFileSync(exe, ['#!/bin/sh', 'exit 0'].join('\n'), { mode: 0o755 });
}
/**
* Write a minimal gsd-tools.cjs stub into fakeHome that exits 0 for any
* invocation (covers the `graphify build snapshot` and `graphify status` steps).
*/
function writeFakeGsdTools() {
const binDir = path.join(fakeHome, '.claude', 'gsd-core', 'bin');
fs.mkdirSync(binDir, { recursive: true });
fs.writeFileSync(
path.join(binDir, 'gsd-tools.cjs'),
['#!/usr/bin/env node', 'process.exit(0);'].join('\n'),
{ mode: 0o755 },
);
}
/**
* Populate the sandbox with the minimal directory structure and output files
* that a real `graphify update .` would produce. `includeHtml` controls
* whether graphify-out/graph.html is created (simulating the node-limit skip
* when false).
*/
function populateSandbox(includeHtml) {
// graphify-out/ — simulates graphify CLI output directory
const outDir = path.join(sandbox, 'graphify-out');
fs.mkdirSync(outDir, { recursive: true });
fs.writeFileSync(path.join(outDir, 'graph.json'), '{}');
fs.writeFileSync(path.join(outDir, 'GRAPH_REPORT.md'), '# report');
if (includeHtml) {
fs.writeFileSync(path.join(outDir, 'graph.html'), '<html/>');
}
// .planning/graphs/ — destination directory
const graphsDir = path.join(sandbox, '.planning', 'graphs');
fs.mkdirSync(graphsDir, { recursive: true });
}
/**
* Execute the extracted Step 3 block in the sandbox.
*/
function runBlock(block) {
return spawnSync('bash', ['-c', block], {
cwd: sandbox,
env: {
...process.env,
PATH: fakeBin + ':' + process.env.PATH,
HOME: fakeHome,
},
encoding: 'utf8',
});
}
// ─── tests ───────────────────────────────────────────────────────────────────
describe('bug #622: graph.html absence must not abort the Step 3 shell chain', () => {
let block;
before(() => {
block = extractStep3Block();
});
test('Step 3 bash block is present in graphify.md (sanity gate)', () => {
assert.ok(block !== null, 'Step 3 bash block starting with "graphify update ." was not found in commands/gsd/graphify.md');
assert.ok(block.length > 0, 'Extracted bash block must not be empty');
});
test('graph.html absent: chain exits 0 and all other artifacts are copied (#622 regression)', (t) => {
// Use t.after for per-test cleanup so sandbox is fresh for each test
t.after(() => {
// Remove and recreate sandbox so the next test starts with an empty dir
cleanup(sandbox);
fs.mkdirSync(sandbox, { recursive: true });
});
writeFakeGraphify();
writeFakeGsdTools();
populateSandbox(false); // no graph.html — simulates node-limit skip
const result = runBlock(block);
// Chain must not abort
assert.equal(result.status, 0, [
'Expected exit 0 but got ' + result.status,
'stderr: ' + result.stderr,
'stdout: ' + result.stdout,
].join('\n'));
// graph.json was copied (step before the guarded line)
assert.ok(
fs.existsSync(path.join(sandbox, '.planning', 'graphs', 'graph.json')),
'.planning/graphs/graph.json must be copied even when graph.html is absent',
);
// GRAPH_REPORT.md was copied (step AFTER the guarded line — key regression assertion)
assert.ok(
fs.existsSync(path.join(sandbox, '.planning', 'graphs', 'GRAPH_REPORT.md')),
'.planning/graphs/GRAPH_REPORT.md must be copied (the chain must not abort at graph.html)',
);
// graph.html must NOT exist in the destination (correctly skipped)
assert.ok(
!fs.existsSync(path.join(sandbox, '.planning', 'graphs', 'graph.html')),
'.planning/graphs/graph.html must NOT be created when source is absent',
);
});
test('graph.html present: chain exits 0 and graph.html is copied (happy path)', (t) => {
t.after(() => {
cleanup(sandbox);
fs.mkdirSync(sandbox, { recursive: true });
});
writeFakeGraphify();
writeFakeGsdTools();
populateSandbox(true); // include graph.html
const result = runBlock(block);
assert.equal(result.status, 0, [
'Expected exit 0 but got ' + result.status,
'stderr: ' + result.stderr,
'stdout: ' + result.stdout,
].join('\n'));
// graph.html must exist in the destination (normal copy)
assert.ok(
fs.existsSync(path.join(sandbox, '.planning', 'graphs', 'graph.html')),
'.planning/graphs/graph.html must be copied when the source file is present',
);
// Other artifacts also copied
assert.ok(
fs.existsSync(path.join(sandbox, '.planning', 'graphs', 'graph.json')),
'.planning/graphs/graph.json must be copied',
);
assert.ok(
fs.existsSync(path.join(sandbox, '.planning', 'graphs', 'GRAPH_REPORT.md')),
'.planning/graphs/GRAPH_REPORT.md must be copied',
);
});
});
});
}

View File

@@ -2271,3 +2271,199 @@ describe('#1912 — init.progress fails safe in workstream mode with no active w
// ─────────────────────────────────────────────────────────────────────────────
// roadmap analyze command
// ─────────────────────────────────────────────────────────────────────────────
// ────────────────────────────────────────────────────────────────────────
// Folded from tests/bug-3491-nested-git-worktree.test.cjs — consolidation epic #1969 (B6 #1975)
// ────────────────────────────────────────────────────────────────────────
{
const { describe: __foldDescribe } = require('node:test');
__foldDescribe("folded:bug-3491-nested-git-worktree (consolidation epic #1969 B6 #1975)", () => {
// allow-test-rule: source-text-is-the-product (see #3491)
// Bug #3491 — new-project workflow creates nested .git in subdirectory when
// parent already has git repo.
//
// The workflow's `has_git` boolean was derived from `pathExists(cwd, '.git')`
// — a shallow check that only sees a `.git` entry directly in the current
// directory. Subdirectories of an existing git worktree therefore reported
// `has_git: false`, causing the workflow's `git init` step to create a nested
// `.git` inside the outer repo's worktree. Subsequent gsd-sdk commits then
// targeted the nested repo instead of the outer one, silently dropping all
// planning artefacts from the outer repo's history.
//
// This test asserts the corrected semantics, mirroring `git rev-parse
// --is-inside-work-tree`:
//
// - `has_git: true` is reported whenever the cwd is inside a git worktree,
// even when no `.git` entry is in cwd itself.
// - The init payload surfaces `git_worktree_root` and `in_nested_subdir` so
// the workflow can warn the user and skip `git init`.
// - The workflow markdown's `git init` step is gated on
// `in_nested_subdir: false`, never unconditional under `has_git: false`.
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { execSync } = require('node:child_process');
const { runGsdTools, cleanup } = require('./helpers.cjs');
const WORKFLOW_PATH = path.join(
__dirname,
'..',
'gsd-core',
'workflows',
'new-project.md',
);
// ─── Helper: create outer git repo with a nested workstream subdir ─────────
// On Windows the runtime emits forward slashes (git's convention) while
// path.join produces backslashes — normalize both sides via the shared
// toPosixPath helper before any equality comparison.
const { toPosixPath: normalizePath } = require('./helpers.cjs');
function createOuterRepoWithSubdir(prefix = 'bug-3491-') {
const outer = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
// macOS /tmp -> /private/tmp; on Windows the runner's %TEMP% is the 8.3
// short-name (RUNNER~1) and the runtime resolves to the long form.
// realpathSync.native handles both; then normalize separators for compare.
const outerReal = fs.realpathSync.native(outer);
execSync('git init', { cwd: outerReal, stdio: 'pipe' });
execSync('git config user.email "test@test.com"', { cwd: outerReal, stdio: 'pipe' });
execSync('git config user.name "Test"', { cwd: outerReal, stdio: 'pipe' });
execSync('git config commit.gpgsign false', { cwd: outerReal, stdio: 'pipe' });
fs.writeFileSync(path.join(outerReal, 'README.md'), '# outer\n');
execSync('git add -A', { cwd: outerReal, stdio: 'pipe' });
execSync('git commit -m "initial"', { cwd: outerReal, stdio: 'pipe' });
const subdir = path.join(outerReal, 'workstreams', 'my-project');
fs.mkdirSync(subdir, { recursive: true });
return { outer: outerReal, subdir };
}
// ─── Behavioural tests against the live `init new-project` handler ─────────
test('bug-3491: init new-project reports has_git: true inside parent git worktree', () => {
const { outer, subdir } = createOuterRepoWithSubdir();
try {
const result = runGsdTools('init new-project', subdir);
assert.ok(result.success, `init new-project failed: ${result.error}`);
const payload = JSON.parse(result.output);
// Core fix: shallow `.git in cwd` check was wrong — we are inside the
// outer worktree, so the workflow MUST see has_git: true.
assert.strictEqual(
payload.has_git,
true,
'expected has_git=true when cwd is inside an existing git worktree (parent .git)',
);
// The workflow needs the worktree root and a nesting flag to decide
// whether to skip `git init` and emit a friendly warning.
assert.strictEqual(
normalizePath(payload.git_worktree_root),
normalizePath(outer),
`expected git_worktree_root to be the outer repo (${outer}), got: ${payload.git_worktree_root}`,
);
assert.strictEqual(
payload.in_nested_subdir,
true,
'expected in_nested_subdir=true when cwd is a subdirectory of the worktree root',
);
} finally {
cleanup(outer);
}
});
test('bug-3491: init new-project reports has_git: true at worktree root with in_nested_subdir: false', () => {
const { outer } = createOuterRepoWithSubdir();
try {
const result = runGsdTools('init new-project', outer);
assert.ok(result.success, `init new-project failed: ${result.error}`);
const payload = JSON.parse(result.output);
assert.strictEqual(payload.has_git, true, 'has_git must be true at the worktree root');
assert.strictEqual(normalizePath(payload.git_worktree_root), normalizePath(outer));
assert.strictEqual(
payload.in_nested_subdir,
false,
'at the worktree root, in_nested_subdir must be false',
);
} finally {
cleanup(outer);
}
});
test('bug-3491: init new-project reports has_git: false outside any git worktree', () => {
const tmp = fs.realpathSync.native(fs.mkdtempSync(path.join(os.tmpdir(), 'bug-3491-bare-')));
try {
const result = runGsdTools('init new-project', tmp);
assert.ok(result.success, `init new-project failed: ${result.error}`);
const payload = JSON.parse(result.output);
assert.strictEqual(payload.has_git, false);
assert.strictEqual(payload.in_nested_subdir, false);
assert.strictEqual(payload.git_worktree_root, null);
} finally {
cleanup(tmp);
}
});
test('bug-3491: init ingest-docs mirrors the same has_git semantics', () => {
// ingest-docs.md has the same shallow check and the same nested-init risk.
const { outer, subdir } = createOuterRepoWithSubdir('bug-3491-ingest-');
try {
const result = runGsdTools('init ingest-docs', subdir);
assert.ok(result.success, `init ingest-docs failed: ${result.error}`);
const payload = JSON.parse(result.output);
assert.strictEqual(
payload.has_git,
true,
'init ingest-docs must also detect parent worktree (#3491 related path)',
);
assert.strictEqual(normalizePath(payload.git_worktree_root), normalizePath(outer));
assert.strictEqual(payload.in_nested_subdir, true);
} finally {
cleanup(outer);
}
});
// ─── Workflow-text test: the deployed `new-project.md` must gate `git init` ─
test('bug-3491: new-project.md gates `git init` on in_nested_subdir, not just has_git', () => {
const content = fs.readFileSync(WORKFLOW_PATH, 'utf-8');
// The pre-fix workflow had the literal sequence:
//
// **If `has_git` is false:** Initialize git:
// ```bash
// git init
// ```
//
// …which fires for any subdirectory of an existing repo. The fix must
// either gate the init on `in_nested_subdir`/worktree-root semantics or
// drop the unconditional `git init` block entirely.
const unconditionalInitPattern =
/\*\*If `has_git` is false:\*\* Initialize git:\s*\r?\n+```bash\s*\r?\ngit init\s*\r?\n```/;
assert.ok(
!unconditionalInitPattern.test(content),
'new-project.md must not run `git init` unconditionally on has_git=false (#3491). ' +
'Gate it on `in_nested_subdir === false` so the workflow refuses to create ' +
'a nested .git inside an existing worktree.',
);
// The fixed workflow MUST mention the new field so reviewers can see the
// gating exists. (Workflow markdown IS the deployed product — testing it
// as text is the only end-to-end signal we have.)
assert.ok(
/in_nested_subdir/.test(content),
'new-project.md must reference `in_nested_subdir` after the #3491 fix',
);
});
});
}

File diff suppressed because it is too large Load Diff

View File

@@ -638,3 +638,150 @@ describe('decodeFishUniversalValue: round-trip properties (#323)', () => {
);
});
});
// ────────────────────────────────────────────────────────────────────────
// Folded from tests/bug-3509-path-spaces.test.cjs — consolidation epic #1969 (B6 #1975)
// ────────────────────────────────────────────────────────────────────────
{
const { describe: __foldDescribe } = require('node:test');
__foldDescribe("folded:bug-3509-path-spaces (consolidation epic #1969 B6 #1975)", () => {
/**
* Regression tests for #3509 — CLI breaks when repo path contains spaces
*
* Root cause: test code embedded space-containing paths into runGsdTools()
* string args; the helper's whitespace tokenizer truncated paths at the first
* space. All calls that carry dynamic paths must use the array form of
* runGsdTools() so execFileSync receives the full path as a single argv slot.
*
* These tests create a tmpdir whose prefix intentionally contains a space so
* they remain red on a broken codebase regardless of the host machine's
* tmpdir location.
*/
const { test, describe, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const os = require('os');
const { runGsdTools, cleanup } = require('./helpers.cjs');
// Create a tmpdir whose name always contains a space — this is the invariant
// that was violated on /Volumes/Mini Me/... machines.
function createSpacedTmpDir(prefix = 'path with spaces-') {
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
}
// ─── dispatcher --cwd= with space in path ────────────────────────────────────
describe('bug-3509: --cwd= survives spaces in path', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createSpacedTmpDir();
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases'), { recursive: true });
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
'# Project State\n\n## Current Position\n\nPhase: 1 of 1 (Test)\n'
);
});
afterEach(() => cleanup(tmpDir));
test('--cwd= array form passes full path with spaces to dispatcher', () => {
// Array form: path is a single argv slot, never split on whitespace
const result = runGsdTools(['--cwd=' + tmpDir, 'state', 'load'], process.cwd());
assert.ok(result.success, `--cwd= with spaced path should succeed, got: ${result.error}`);
});
});
// ─── frontmatter-cli file path with spaces ───────────────────────────────────
describe('bug-3509: frontmatter get/set/merge/validate survive spaces in file path', () => {
let tmpDir;
let tmpFile;
beforeEach(() => {
tmpDir = createSpacedTmpDir();
tmpFile = path.join(tmpDir, 'test.md');
fs.writeFileSync(tmpFile, '---\nphase: 01\nplan: 01\ntype: execute\n---\nbody');
});
afterEach(() => cleanup(tmpDir));
test('frontmatter get returns parsed fields when file path contains spaces', () => {
const result = runGsdTools(['frontmatter', 'get', tmpFile]);
assert.ok(result.success, `Command failed: ${result.error}`);
const parsed = JSON.parse(result.output);
assert.strictEqual(parsed.phase, '01', 'phase field should be "01"');
});
test('frontmatter set works when file path contains spaces', () => {
const setResult = runGsdTools(['frontmatter', 'set', tmpFile, '--field', 'phase', '--value', '02']);
assert.ok(setResult.success, `set failed: ${setResult.error}`);
// Verify behaviorally — round-trip via frontmatter get rather than reading the file
// and grepping (which trips lint-no-source-grep even on tmp files).
const getResult = runGsdTools(['frontmatter', 'get', tmpFile]);
assert.ok(getResult.success, `get failed: ${getResult.error}`);
const parsed = JSON.parse(getResult.output);
assert.strictEqual(parsed.phase, '02', 'field should be updated to "02"');
});
test('frontmatter validate works when file path contains spaces', () => {
// Plan frontmatter schema — file path contains a space; must reach validation, not fail on path
const result = runGsdTools(['frontmatter', 'validate', tmpFile, '--schema', 'plan']);
// Should succeed (exit 0) and return structured JSON with valid/missing, not a path-split error
assert.ok(result.success, `Command should exit 0, got: ${result.error}`);
const out = JSON.parse(result.output);
assert.ok('valid' in out, 'should return structured JSON with "valid" field');
});
});
// ─── verify-path-exists with absolute path containing spaces ─────────────────
describe('bug-3509: verify-path-exists survives absolute paths with spaces', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createSpacedTmpDir();
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases'), { recursive: true });
});
afterEach(() => cleanup(tmpDir));
test('absolute path with spaces resolves correctly via array form', () => {
const absFile = path.join(tmpDir, 'abs-test.txt');
fs.writeFileSync(absFile, 'content');
const result = runGsdTools(['verify-path-exists', absFile], tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.exists, true, 'file should be found');
assert.strictEqual(output.type, 'file');
});
});
// ─── profile-pipeline --path with spaces ─────────────────────────────────────
describe('bug-3509: scan-sessions --path survives spaces in path', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createSpacedTmpDir();
});
afterEach(() => cleanup(tmpDir));
test('scan-sessions --path with spaces returns empty array, not path-split error', () => {
const sessionsDir = path.join(tmpDir, 'projects');
fs.mkdirSync(sessionsDir, { recursive: true });
const result = runGsdTools(['scan-sessions', '--path', sessionsDir, '--raw'], tmpDir);
assert.ok(result.success, `Failed: ${result.error}`);
const out = JSON.parse(result.output);
assert.ok(Array.isArray(out), 'should return an array');
assert.strictEqual(out.length, 0, 'should be empty for empty sessions dir');
});
});
});
}

File diff suppressed because it is too large Load Diff

View File

@@ -1750,3 +1750,909 @@ describe('N3: Windows-separator confinement logic (path.win32 semantics)', () =>
});
});
}
// ────────────────────────────────────────────────────────────────────────
// Folded from tests/bug-2998-pristine-dir-populated.test.cjs — consolidation epic #1969 (B1 #1970)
// ────────────────────────────────────────────────────────────────────────
{
const { describe: __foldDescribe } = require('node:test');
__foldDescribe("folded:bug-2998-pristine-dir-populated (consolidation epic #1969 B1 #1970)", () => {
'use strict';
process.env.GSD_TEST_MODE = '1';
/**
* Bug #2998: gsd-pristine/ snapshot is documented but never populated by
* the installer. saveLocalPatches declared a pristineDir variable and
* promised "saves pristine copies (from manifest) to gsd-pristine/ to
* enable three-way merge during reapply-patches" -- but no code ever
* wrote to that directory. Effect: the /gsd-reapply-patches Step 5
* verifier (#2972) silently degrades to its over-broad fallback heuristic
* ("every significant backup line"), exactly the silent-success-on-lost-
* content failure mode #2969 was designed to prevent.
*
* Fix: new populatePristineDir({...}) helper runs the install transform
* pipeline (copyWithPathReplacement) into a tmp staging dir, then copies
* out the modified-file paths into gsd-pristine/. saveLocalPatches now
* accepts a pristineCtx and calls the helper when local patches are
* detected.
*/
const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const os = require('node:os');
const crypto = require('node:crypto');
const ROOT = path.join(__dirname, '..');
const INSTALL = require(path.join(ROOT, 'bin', 'install.js'));
const { cleanup } = require('./helpers.cjs');
function sha256(content) {
return crypto.createHash('sha256').update(content).digest('hex');
}
describe('Bug #2998: populatePristineDir is exported and writes pristine for modified files', () => {
test('exported as a function', () => {
assert.equal(typeof INSTALL.populatePristineDir, 'function',
'expected populatePristineDir in install.js exports (#2998)');
});
test('returns 0 when no files are modified (no-op)', () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2998-'));
try {
const written = INSTALL.populatePristineDir({
packageSrc: ROOT,
pristineDir: path.join(tmp, 'gsd-pristine'),
modified: [],
runtime: 'claude',
pathPrefix: '$HOME/.claude/',
isGlobal: true,
});
assert.equal(written, 0);
} finally {
cleanup(tmp);
}
});
test('writes one pristine file per modified path that exists in source', () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2998-'));
const pristineDir = path.join(tmp, 'gsd-pristine');
try {
// Pick a real installed-side relPath from the package source. The
// install transforms map source `gsd-core/<rel>` to installed
// `gsd-core/<rel>` for skills-aware runtimes (like claude),
// so the relPath is the same on both sides.
const candidate = path.join('gsd-core', 'workflows', 'reapply-patches.md');
const sourcePath = path.join(ROOT, candidate);
assert.equal(fs.existsSync(sourcePath), true,
`precondition: source file exists at ${candidate}`);
const written = INSTALL.populatePristineDir({
packageSrc: ROOT,
pristineDir,
modified: [candidate],
runtime: 'claude',
pathPrefix: '$HOME/.claude/',
isGlobal: true,
});
assert.equal(written, 1, 'expected exactly one pristine file written');
const out = path.join(pristineDir, candidate);
assert.equal(fs.existsSync(out), true, `expected pristine file at ${out}`);
// The pristine content should be the transformed version (not raw source):
// copyWithPathReplacement substitutes ~/.claude/ for the runtime path prefix.
// For claude+global, the prefix is $HOME/.claude/ which equals the original,
// so the transform is effectively identity here. We assert the content is a
// non-empty markdown file rather than asserting on transform specifics.
const content = fs.readFileSync(out, 'utf-8');
assert.ok(content.length > 0, 'pristine file should be non-empty');
} finally {
cleanup(tmp);
}
});
test('skips paths not present in source (does not corrupt pristine with stale data)', () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2998-'));
const pristineDir = path.join(tmp, 'gsd-pristine');
try {
const written = INSTALL.populatePristineDir({
packageSrc: ROOT,
pristineDir,
modified: ['gsd-core/this-path-does-not-exist.md'],
runtime: 'claude',
pathPrefix: '$HOME/.claude/',
isGlobal: true,
});
assert.equal(written, 0, 'expected zero pristine files for non-existent source paths');
const out = path.join(pristineDir, 'gsd-core/this-path-does-not-exist.md');
assert.equal(fs.existsSync(out), false, 'pristine should not contain ghost paths');
} finally {
cleanup(tmp);
}
});
test('pristine files have stable content (transformations are deterministic)', () => {
// Determinism is what makes the verifier's hash check meaningful:
// backup-meta.json records pristine_hashes computed at this same step,
// so re-running with the same inputs must yield byte-identical files.
const tmp1 = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2998-d1-'));
const tmp2 = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2998-d2-'));
try {
const candidate = path.join('gsd-core', 'workflows', 'reapply-patches.md');
const ctx = {
packageSrc: ROOT,
modified: [candidate],
runtime: 'claude',
pathPrefix: '$HOME/.claude/',
isGlobal: true,
};
INSTALL.populatePristineDir(Object.assign({ pristineDir: path.join(tmp1, 'gsd-pristine') }, ctx));
INSTALL.populatePristineDir(Object.assign({ pristineDir: path.join(tmp2, 'gsd-pristine') }, ctx));
const a = fs.readFileSync(path.join(tmp1, 'gsd-pristine', candidate));
const b = fs.readFileSync(path.join(tmp2, 'gsd-pristine', candidate));
assert.equal(sha256(a), sha256(b), 'two runs of the same inputs must yield identical pristine content');
} finally {
cleanup(tmp1);
cleanup(tmp2);
}
});
});
// ─── #3004 CR follow-up: multi-root pristine expansion ─────────────────────
describe('Bug #2998 (#3004 CR): pristine expansion covers every manifest install root', () => {
test('paths under agents/ are staged via copyWithPathReplacement, not silently skipped', () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2998-multi-'));
const pristineDir = path.join(tmp, 'gsd-pristine');
try {
const candidate = path.join('agents', 'gsd-planner.md');
const sourcePath = path.join(ROOT, candidate);
assert.equal(fs.existsSync(sourcePath), true,
`precondition: source file exists at ${candidate}`);
const written = INSTALL.populatePristineDir({
packageSrc: ROOT,
pristineDir,
modified: [candidate],
runtime: 'claude',
pathPrefix: '$HOME/.claude/',
isGlobal: true,
});
assert.equal(written, 1, 'expected agents/ path to be staged and copied to pristine');
assert.equal(fs.existsSync(path.join(pristineDir, candidate)), true);
} finally {
cleanup(tmp);
}
});
test('a mix of gsd-core/ and agents/ paths in modified list are all staged', () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2998-mix-'));
const pristineDir = path.join(tmp, 'gsd-pristine');
try {
const a = path.join('gsd-core', 'workflows', 'reapply-patches.md');
const b = path.join('agents', 'gsd-planner.md');
assert.equal(fs.existsSync(path.join(ROOT, a)), true);
assert.equal(fs.existsSync(path.join(ROOT, b)), true);
const written = INSTALL.populatePristineDir({
packageSrc: ROOT,
pristineDir,
modified: [a, b],
runtime: 'claude',
pathPrefix: '$HOME/.claude/',
isGlobal: true,
});
assert.equal(written, 2, 'expected both top-level dirs to be staged');
assert.equal(fs.existsSync(path.join(pristineDir, a)), true);
assert.equal(fs.existsSync(path.join(pristineDir, b)), true);
} finally {
cleanup(tmp);
}
});
});
describe('Bug #2998: saveLocalPatches no longer leaves the pristineDir variable unused', () => {
test('saveLocalPatches accepts a pristineCtx and exposes the helper for direct testing', () => {
// Structural assertion: the function exists with the new signature shape.
// Behavioral end-to-end is covered by the populatePristineDir tests above
// (that helper is what saveLocalPatches calls internally).
assert.equal(typeof INSTALL.populatePristineDir, 'function');
// The signature for saveLocalPatches isn't exported, but the helper IS,
// and it's the unit of behavior the bug is about. Asserting on the helper
// is the structural-IR equivalent of the no-source-grep convention.
});
});
});
}
// ────────────────────────────────────────────────────────────────────────
// Folded from tests/bug-3407-pristine-stale-content.test.cjs — consolidation epic #1969 (B1 #1970)
// ────────────────────────────────────────────────────────────────────────
{
const { describe: __foldDescribe } = require('node:test');
__foldDescribe("folded:bug-3407-pristine-stale-content (consolidation epic #1969 B1 #1970)", () => {
'use strict';
process.env.GSD_TEST_MODE = '1';
/**
* Bug #3407: Installer leaves stale content in gsd-pristine/
*
* Root cause: populatePristineDir() in saveLocalPatches() snapshots from
* pristineCtx.packageSrc — the NEWLY-downloaded release tree — and writes
* those bytes into gsd-pristine/. For files changed between the old and new
* release, this writes the NEW bytes into the pristine baseline instead of
* the OLD bytes. The three-way-diff verifier then classifies upstream-changed
* lines as user-added → Step 5a gate fails with false FAIL_USER_LINES_MISSING.
*
* The #3657 fix (OK_PRISTINE_DRIFT_DETECTED) was a symptom workaround: the
* verifier detects hash mismatch (backup-meta.json records old-release hash
* but gsd-pristine/ has new-release bytes) and skips to over-broad mode
* instead of false-failing. The root-cause stale write was never fixed.
*
* Fix: when a correctly-populated gsd-pristine/ already exists from the
* previous install (i.e., the file's sha256 matches the originalHash recorded
* in the manifest), preserve it — do NOT wipe and re-populate from the new
* release source. This ensures gsd-pristine/ holds old-release bytes even
* after an upgrade where the file content changed upstream.
*
* Regression contract (byte-comparison):
* After saveLocalPatches() is called with a user-modified file whose
* gsd-pristine/ entry was correctly set by the previous install, the
* gsd-pristine/ file MUST still contain the old-release bytes, not the
* new-release bytes supplied in pristineCtx.packageSrc.
*
* Closes: #3407
*/
const { test, describe, beforeEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const os = require('node:os');
const crypto = require('node:crypto');
const ROOT = path.join(__dirname, '..');
const INSTALL = require(path.join(ROOT, 'bin', 'install.js'));
const { cleanup } = require('./helpers.cjs');
const MANIFEST_NAME = 'gsd-file-manifest.json';
const PATCHES_DIR_NAME = 'gsd-local-patches';
function sha256(content) {
return crypto.createHash('sha256').update(content instanceof Buffer ? content : Buffer.from(content)).digest('hex');
}
// ─── Bug #3407: gsd-pristine/ must preserve OLD-release bytes across upgrade ──
describe('Bug #3407: saveLocalPatches preserves old-release pristine across upgrade', () => {
let tmpDir;
let configDir;
let fakeSrcDir;
beforeEach((t) => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3407-'));
configDir = path.join(tmpDir, 'config');
fakeSrcDir = path.join(tmpDir, 'new-release-src');
fs.mkdirSync(configDir, { recursive: true });
fs.mkdirSync(fakeSrcDir, { recursive: true });
t.after(() => {
cleanup(tmpDir);
});
});
/**
* Core regression test.
*
* Timeline:
* Install v1: file content = OLD_RELEASE_CONTENT, gsd-pristine/ROOT_FILE
* = OLD_RELEASE_CONTENT (correctly set by previous install),
* manifest hash = sha256(OLD_RELEASE_CONTENT)
* User edits: configDir/ROOT_FILE = USER_MODIFIED_CONTENT
* Upgrade v2: pristineCtx.packageSrc has NEW_RELEASE_CONTENT for ROOT_FILE
* saveLocalPatches is called before the wipe.
*
* Expected AFTER fix: gsd-pristine/ROOT_FILE still == OLD_RELEASE_CONTENT
* Actual BEFORE fix: gsd-pristine/ROOT_FILE == NEW_RELEASE_CONTENT (stale)
*/
test('gsd-pristine/ retains old-release bytes when upgrading a user-modified file', () => {
const OLD_RELEASE_CONTENT = '# Old Release Content\nThis is v1 pristine.\n';
const NEW_RELEASE_CONTENT = '# New Release Content\nThis is v2 — upstream changed this line.\n';
const USER_MODIFIED_CONTENT = '# Old Release Content\nThis is v1 pristine.\n## User addition\nUser customization here.\n';
const oldHash = sha256(OLD_RELEASE_CONTENT);
// Simulate a root-level installed file. Root-level files in the manifest
// are denoted without a subdirectory (slash-free relPath).
const relPath = 'test-root-file.md';
// Set up configDir: user-modified installed file + manifest recording old hash
fs.writeFileSync(path.join(configDir, relPath), USER_MODIFIED_CONTENT);
fs.writeFileSync(
path.join(configDir, MANIFEST_NAME),
JSON.stringify({ version: '1.0.0', files: { [relPath]: oldHash } }, null, 2)
);
// Set up fakeSrcDir (new release): the file has NEW content
fs.writeFileSync(path.join(fakeSrcDir, relPath), NEW_RELEASE_CONTENT);
// Set up gsd-pristine/ with OLD content (as correctly populated by previous install)
const pristineDir = path.join(configDir, 'gsd-pristine');
fs.mkdirSync(pristineDir, { recursive: true });
fs.writeFileSync(path.join(pristineDir, relPath), OLD_RELEASE_CONTENT);
// Call saveLocalPatches with the new release as packageSrc (the buggy scenario)
INSTALL.saveLocalPatches(configDir, {
packageSrc: fakeSrcDir,
runtime: 'claude',
pathPrefix: '$HOME/.claude/',
isGlobal: true,
});
// Assert: gsd-pristine/ must still contain OLD-release bytes
const pristineFile = path.join(pristineDir, relPath);
assert.ok(
fs.existsSync(pristineFile),
`gsd-pristine/${relPath} must exist after saveLocalPatches`
);
const actualPristineContent = fs.readFileSync(pristineFile, 'utf8');
assert.equal(
sha256(actualPristineContent),
oldHash,
[
`gsd-pristine/${relPath} must contain OLD-release bytes (sha256=${oldHash.slice(0, 12)}…)`,
`but got sha256=${sha256(actualPristineContent).slice(0, 12)}…`,
`(If equal to sha256(NEW_RELEASE_CONTENT)=${sha256(NEW_RELEASE_CONTENT).slice(0, 12)}… then #3407 is NOT fixed)`,
].join(' ')
);
// Secondary: confirm backup-meta records the old hash (not new)
const backupMeta = JSON.parse(
fs.readFileSync(path.join(configDir, PATCHES_DIR_NAME, 'backup-meta.json'), 'utf8')
);
assert.ok(
Object.prototype.hasOwnProperty.call(backupMeta.pristine_hashes, relPath),
'backup-meta.json must record pristine_hash for modified file'
);
assert.equal(
backupMeta.pristine_hashes[relPath],
oldHash,
'backup-meta.json pristine_hash must equal old-release hash (not new-release hash)'
);
});
/**
* Regression test for Codex finding: when gsd-pristine/ entry is absent
* (e.g., post-buggy-run deletion or first upgrade without prior pristine)
* but the file is UNCHANGED between old and new release, the hash-validated
* regeneration path must restore the pristine entry using new-release source.
*
* When sha256(newReleaseBytesForFile) === originalHash, the file is identical
* between releases — new-release generated bytes ARE the old-release pristine
* and may be safely promoted.
*
* Previously (before the regeneration path was added): missing entries were
* left absent unconditionally, causing permanent over-broad fallback even
* when the file was unchanged upstream.
*/
test('gsd-pristine/ is regenerated for missing entries when file is unchanged between releases', () => {
const SHARED_RELEASE_CONTENT = '# Shared Content\nThis file is identical in v1 and v2.\n';
const USER_MODIFIED_CONTENT = '# Shared Content\nThis file is identical in v1 and v2.\n## User addition\nCustom.\n';
const oldHash = sha256(SHARED_RELEASE_CONTENT);
const relPath = 'test-unchanged-file.md';
// configDir has user-modified file + manifest with old-release hash
fs.writeFileSync(path.join(configDir, relPath), USER_MODIFIED_CONTENT);
fs.writeFileSync(
path.join(configDir, MANIFEST_NAME),
JSON.stringify({ version: '1.0.0', files: { [relPath]: oldHash } }, null, 2)
);
// fakeSrcDir (new release) has the SAME content — file was not changed upstream
fs.writeFileSync(path.join(fakeSrcDir, relPath), SHARED_RELEASE_CONTENT);
// NOTE: gsd-pristine/ does NOT exist (simulating post-buggy-run or first-time scenario)
INSTALL.saveLocalPatches(configDir, {
packageSrc: fakeSrcDir,
runtime: 'claude',
pathPrefix: '$HOME/.claude/',
isGlobal: true,
});
// The regeneration path should have detected that sha256(new-release candidate)
// === originalHash, and promoted the candidate into gsd-pristine/.
const pristineFile = path.join(configDir, 'gsd-pristine', relPath);
assert.ok(
fs.existsSync(pristineFile),
[
`gsd-pristine/${relPath} must exist after hash-validated regeneration.`,
`When new-release bytes hash to originalHash, the file was unchanged between`,
`releases and the candidate should be promoted to restore the pristine baseline.`,
].join(' ')
);
const actualContent = fs.readFileSync(pristineFile, 'utf8');
assert.equal(
sha256(actualContent),
oldHash,
[
`gsd-pristine/${relPath} must contain bytes matching originalHash after regeneration`,
`(sha256=${oldHash.slice(0, 12)}…)`,
].join(' ')
);
});
/**
* Stale-pristine recovery test (pre-fix bug artifact).
*
* Timeline:
* Buggy run: gsd-pristine/<rel> was written with NEW_RELEASE_CONTENT
* (the exact #3407 artifact — stale bytes from a buggy populatePristineDir).
* Fix run: saveLocalPatches detects the hash mismatch
* (sha256(NEW_RELEASE_CONTENT) !== originalHash recorded in manifest),
* removes the stale entry, then attempts regeneration.
*
* When the file CHANGED between releases (NEW !== OLD):
* - The stale entry is removed.
* - Regeneration discards the new-release candidate (hash mismatch).
* - gsd-pristine/<rel> must be ABSENT (over-broad fallback — correct).
*
* When the file is UNCHANGED between releases (NEW === OLD):
* - The stale entry (which happens to have correct bytes despite the bug) is
* detected as correct (hash matches originalHash) and PRESERVED.
* - gsd-pristine/<rel> must remain present with the correct bytes.
*
* This test covers the "file changed across release boundary" case.
* The "unchanged" case is already covered by the regeneration test above.
*/
test('stale gsd-pristine/ entry (new-release bytes) is removed when file changed between releases', () => {
const OLD_RELEASE_CONTENT = '# Old Release\nv1 content here.\n';
const NEW_RELEASE_CONTENT = '# New Release\nv2 content — upstream changed this.\n';
const USER_MODIFIED_CONTENT = '# Old Release\nv1 content here.\n## User section\nCustom work.\n';
const oldHash = sha256(OLD_RELEASE_CONTENT);
const relPath = 'test-stale-recovery.md';
// configDir: user-modified file + manifest recording OLD hash
fs.writeFileSync(path.join(configDir, relPath), USER_MODIFIED_CONTENT);
fs.writeFileSync(
path.join(configDir, MANIFEST_NAME),
JSON.stringify({ version: '1.0.0', files: { [relPath]: oldHash } }, null, 2)
);
// fakeSrcDir (new release): contains the NEW content
fs.writeFileSync(path.join(fakeSrcDir, relPath), NEW_RELEASE_CONTENT);
// Pre-populate gsd-pristine/ with NEW_RELEASE_CONTENT — the exact pre-fix bug artifact.
// This simulates a prior buggy run that wrote new-release bytes into the pristine baseline.
const STALE_BYTES = NEW_RELEASE_CONTENT; // named constant for clarity
const pristineDir = path.join(configDir, 'gsd-pristine');
fs.mkdirSync(pristineDir, { recursive: true });
fs.writeFileSync(path.join(pristineDir, relPath), STALE_BYTES);
// Verify the pre-condition: stale bytes do NOT match the original hash.
// If this assert fails, the test fixture is wrong (not a fix regression).
assert.notEqual(
sha256(STALE_BYTES),
oldHash,
'test fixture check: stale bytes must differ from originalHash'
);
INSTALL.saveLocalPatches(configDir, {
packageSrc: fakeSrcDir,
runtime: 'claude',
pathPrefix: '$HOME/.claude/',
isGlobal: true,
});
// The fix must detect the hash mismatch (stale entry) and remove it.
// The regeneration path discards the new-release candidate (its hash !== oldHash).
// Result: gsd-pristine/<rel> must be ABSENT — over-broad fallback is the safe outcome.
const pristineFile = path.join(pristineDir, relPath);
assert.strictEqual(
fs.existsSync(pristineFile),
false,
[
`expected gsd-pristine/${relPath} to be absent after stale-pristine recovery.`,
`The stale entry (new-release bytes, sha256=${sha256(STALE_BYTES).slice(0, 12)}…)`,
`must be removed; regeneration must discard the candidate because`,
`sha256(new-release)=${sha256(NEW_RELEASE_CONTENT).slice(0, 12)}… !== originalHash=${oldHash.slice(0, 12)}….`,
`Presence of the file means the stale bytes were NOT cleaned up (pre-fix behavior).`,
].join(' ')
);
});
/**
* Second scenario: gsd-pristine/ does NOT pre-exist (first upgrade with no
* prior pristine population). In this case there is no way to obtain the
* old-release pristine bytes — populatePristineDir must NOT write the new-
* release bytes either. The correct outcome is: gsd-pristine/ stays empty
* for this file, and the verifier falls back to over-broad mode (safe).
*/
test('gsd-pristine/ stays empty when no prior pristine exists (first upgrade, no stale write)', () => {
const OLD_RELEASE_CONTENT = '# Old Release Content\nThis is v1.\n';
const NEW_RELEASE_CONTENT = '# New Release Content\nThis is v2 — changed.\n';
const USER_MODIFIED_CONTENT = '# Old Release Content\nThis is v1.\n## User addition\nCustom.\n';
const oldHash = sha256(OLD_RELEASE_CONTENT);
const relPath = 'test-first-upgrade.md';
// configDir has user-modified file + manifest
fs.writeFileSync(path.join(configDir, relPath), USER_MODIFIED_CONTENT);
fs.writeFileSync(
path.join(configDir, MANIFEST_NAME),
JSON.stringify({ version: '1.0.0', files: { [relPath]: oldHash } }, null, 2)
);
// fakeSrcDir (new release) has new content
fs.writeFileSync(path.join(fakeSrcDir, relPath), NEW_RELEASE_CONTENT);
// NOTE: gsd-pristine/ does NOT exist yet (first upgrade)
INSTALL.saveLocalPatches(configDir, {
packageSrc: fakeSrcDir,
runtime: 'claude',
pathPrefix: '$HOME/.claude/',
isGlobal: true,
});
const pristineFile = path.join(configDir, 'gsd-pristine', relPath);
assert.strictEqual(
fs.existsSync(pristineFile),
false,
[
`expected gsd-pristine/${relPath} to be absent when file changed across release boundary.`,
`Writing new-release bytes as pristine for a file whose hash is unknown leads to`,
`false FAIL_USER_LINES_MISSING in the reapply-patches verifier (#3407).`,
`Over-broad fallback mode is the correct outcome here.`,
].join(' ')
);
});
});
// The former "Antipattern hunt" describe block (structural typeof checks only) was
// removed — it provided no real behavioral coverage and was a vacuous-truth pattern
// per /test-rigor skill. Behavioral tests for populatePristineDir are covered above.
});
}
// ────────────────────────────────────────────────────────────────────────
// Folded from tests/bug-2995-post-install-script-paths.test.cjs — consolidation epic #1969 (B6 #1975)
// ────────────────────────────────────────────────────────────────────────
{
const { describe: __foldDescribe } = require('node:test');
__foldDescribe("folded:bug-2995-post-install-script-paths (consolidation epic #1969 B6 #1975)", () => {
'use strict';
process.env.GSD_TEST_MODE = '1';
const { test, describe, before, after } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const ROOT = path.join(__dirname, '..');
const { auditWorkflowScriptPaths, AUDIT_FINDING } = require(
path.join(ROOT, 'scripts', 'audit-workflow-script-paths.cjs'),
);
const { cleanup } = require('./helpers.cjs');
// auditWorkflowScriptPaths is a pure function: it walks workflowsDir,
// extracts every ${GSD_HOME}/<path> script reference, and returns a
// structured report. Tests assert on the typed report — no regex on
// console output.
// #2996 CR: per-fixture repos are rooted under a single tmpRoot so the
// after()-hook actually cleans them up. The previous shape created tmpRoot
// in before() but never used it, leaking each fixture's mkdtempSync dir.
let tmpRoot;
function fixtureRepo({ workflows, files }) {
// workflows: { 'foo.md': '...content with ${GSD_HOME}/...' }
// files: [ 'gsd-core/bin/x.cjs', ... ] — files to create in repo
const repoRoot = fs.mkdtempSync(path.join(tmpRoot, 'repo-'));
const workflowsDir = path.join(repoRoot, 'gsd-core', 'workflows');
fs.mkdirSync(workflowsDir, { recursive: true });
for (const [name, body] of Object.entries(workflows || {})) {
fs.writeFileSync(path.join(workflowsDir, name), body);
}
for (const rel of files || []) {
const full = path.join(repoRoot, rel);
fs.mkdirSync(path.dirname(full), { recursive: true });
fs.writeFileSync(full, '');
}
return { repoRoot, workflowsDir };
}
before(() => { tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2995-')); });
after(() => { cleanup(tmpRoot); });
describe('Bug #2995: post-install script-paths audit (#2995)', () => {
test('AUDIT_FINDING enum exposes the documented codes', () => {
assert.deepEqual(
Object.keys(AUDIT_FINDING).sort(),
['MISSING_FROM_REPO', 'NOT_INSTALLED'].sort(),
);
});
test('returns { ok: true, findings: [] } when workflow refs an existing, installed-path script', () => {
const { repoRoot, workflowsDir } = fixtureRepo({
workflows: {
'good.md': 'node "${GSD_HOME}/gsd-core/bin/foo.cjs" --json\n',
},
files: ['gsd-core/bin/foo.cjs'],
});
const r = auditWorkflowScriptPaths({
workflowsDir,
repoRoot,
installedPrefixes: ['gsd-core', 'commands', 'agents', 'hooks'],
});
assert.deepEqual(r, { ok: true, findings: [] });
});
});
describe('Bug #2995: detection paths', () => {
const { auditWorkflowScriptPaths, AUDIT_FINDING } = require(require('node:path').join(__dirname, '..', 'scripts', 'audit-workflow-script-paths.cjs'));
test('reports MISSING_FROM_REPO when the referenced file does not exist in the repo', () => {
const { repoRoot, workflowsDir } = fixtureRepo({
workflows: {
'foo.md': 'node "${GSD_HOME}/gsd-core/bin/typo.cjs" --json\n',
},
files: [],
});
const r = auditWorkflowScriptPaths({
workflowsDir,
repoRoot,
installedPrefixes: ['gsd-core'],
});
assert.equal(r.ok, false);
assert.equal(r.findings.length, 1);
assert.deepEqual(r.findings[0], {
workflow: 'foo.md',
path: 'gsd-core/bin/typo.cjs',
kind: AUDIT_FINDING.MISSING_FROM_REPO,
});
});
test('reports NOT_INSTALLED when first path segment is outside installedPrefixes (the #2994 case)', () => {
const { repoRoot, workflowsDir } = fixtureRepo({
workflows: {
'foo.md': 'node "${GSD_HOME}/scripts/verify-reapply-patches.cjs"\n',
},
files: ['scripts/verify-reapply-patches.cjs'], // file exists, but `scripts/` not in installed prefixes
});
const r = auditWorkflowScriptPaths({
workflowsDir,
repoRoot,
installedPrefixes: ['gsd-core', 'commands', 'agents', 'hooks'],
});
assert.equal(r.ok, false);
assert.equal(r.findings.length, 1);
assert.deepEqual(r.findings[0], {
workflow: 'foo.md',
path: 'scripts/verify-reapply-patches.cjs',
kind: AUDIT_FINDING.NOT_INSTALLED,
});
});
test('handles ${GSD_HOME:-$HOME/.claude}/... default-fallback syntax', () => {
const { repoRoot, workflowsDir } = fixtureRepo({
workflows: {
'a.md': 'node "${GSD_HOME:-$HOME/.claude}/gsd-core/bin/x.cjs"\n',
},
files: ['gsd-core/bin/x.cjs'],
});
const r = auditWorkflowScriptPaths({
workflowsDir,
repoRoot,
installedPrefixes: ['gsd-core'],
});
assert.deepEqual(r, { ok: true, findings: [] });
});
test('reports both findings when one workflow has multiple problems', () => {
const { repoRoot, workflowsDir } = fixtureRepo({
workflows: {
'multi.md': [
'node "${GSD_HOME}/scripts/a.cjs"',
'node "${GSD_HOME}/gsd-core/bin/b.cjs"',
'node "${GSD_HOME}/gsd-core/bin/missing.cjs"',
].join('\n') + '\n',
},
files: ['scripts/a.cjs', 'gsd-core/bin/b.cjs'],
});
const r = auditWorkflowScriptPaths({
workflowsDir,
repoRoot,
installedPrefixes: ['gsd-core'],
});
assert.equal(r.ok, false);
assert.equal(r.findings.length, 2);
const kinds = r.findings.map((f) => f.kind).sort();
assert.deepEqual(kinds, [AUDIT_FINDING.MISSING_FROM_REPO, AUDIT_FINDING.NOT_INSTALLED]);
});
test('extracts no findings from a workflow without GSD_HOME script refs', () => {
const { repoRoot, workflowsDir } = fixtureRepo({
workflows: {
'plain.md': '# A workflow\n\nSome prose, no script refs.\n',
},
});
const r = auditWorkflowScriptPaths({
workflowsDir,
repoRoot,
installedPrefixes: ['gsd-core'],
});
assert.deepEqual(r, { ok: true, findings: [] });
});
});
describe('Bug #2995: real workflow audit', () => {
const { auditWorkflowScriptPaths, AUDIT_FINDING } = require(require('node:path').join(__dirname, '..', 'scripts', 'audit-workflow-script-paths.cjs'));
// The set of top-level directories the installer (bin/install.js) actually
// copies into ${configDir}/. Touching this set requires updating both
// bin/install.js AND this constant — the parity is intentional.
const INSTALLED_PREFIXES = [
'gsd-core', // workflows, references, bin/lib, templates
'commands', // commands/gsd/*.md (Claude Code local + Gemini global)
'skills', // skills/gsd-*/SKILL.md (Claude Code 2.1.88+ global, Codex, etc.)
'agents', // agents/gsd-*.md
'hooks', // hooks/gsd-*.{sh,js}
];
// Known existing gaps tracked in their own issues. Removing an entry should
// land in the same PR that fixes the underlying issue; CI surfaces any NEW
// gap as a hard failure.
// (#2994 entry removed: this PR moves verify-reapply-patches.cjs to
// gsd-core/bin/ which IS an installed prefix, closing the gap.)
const KNOWN_GAPS = new Set();
test('no NEW workflow refs fail to resolve at the deployed path (KNOWN_GAPS allow-listed)', () => {
const r = auditWorkflowScriptPaths({
workflowsDir: require('node:path').join(ROOT, 'gsd-core', 'workflows'),
repoRoot: ROOT,
installedPrefixes: INSTALLED_PREFIXES,
});
const newGaps = r.findings.filter(
(f) => !KNOWN_GAPS.has(`${f.workflow}|${f.path}|${f.kind}`),
);
if (newGaps.length > 0) {
const summary = newGaps.map(
(f) => ` ${f.workflow}: ${f.path} (${f.kind})`,
).join('\n');
assert.fail(
`New workflow ref does not resolve at the deployed path:\n${summary}\n\n` +
`Either move the script under one of [${INSTALLED_PREFIXES.join(', ')}], ` +
`update bin/install.js to copy the new top-level directory, or ` +
`(if intentionally tracked) add an entry to KNOWN_GAPS with the issue reference.`,
);
}
});
// #2996 CR: a reference that is both outside an installed prefix AND
// missing from the repo must emit BOTH findings in one run. Previously
// the code short-circuited on NOT_INSTALLED, hiding MISSING_FROM_REPO
// until the developer fixed the prefix and re-ran CI.
test('a reference that is both not-installed AND missing-from-repo emits both findings (no short-circuit)', () => {
const { repoRoot, workflowsDir } = fixtureRepo({
workflows: {
'foo.md': '```bash\nnode "${GSD_HOME}/scripts/missing.cjs"\n```\n',
},
// Note: scripts/missing.cjs intentionally NOT created in the repo.
});
const r = auditWorkflowScriptPaths({
workflowsDir,
repoRoot,
installedPrefixes: ['gsd-core', 'agents', 'hooks', 'commands'],
});
assert.equal(r.ok, false);
const kinds = r.findings.filter((f) => f.path === 'scripts/missing.cjs').map((f) => f.kind).sort();
assert.deepEqual(
kinds,
[AUDIT_FINDING.MISSING_FROM_REPO, AUDIT_FINDING.NOT_INSTALLED].sort(),
'expected both NOT_INSTALLED and MISSING_FROM_REPO findings for the same ref',
);
});
test('KNOWN_GAPS entries still match real findings — fixed gaps must be removed from the allow-list', () => {
const r = auditWorkflowScriptPaths({
workflowsDir: require('node:path').join(ROOT, 'gsd-core', 'workflows'),
repoRoot: ROOT,
installedPrefixes: INSTALLED_PREFIXES,
});
const realKeys = new Set(r.findings.map((f) => `${f.workflow}|${f.path}|${f.kind}`));
const stale = [...KNOWN_GAPS].filter((k) => !realKeys.has(k));
assert.deepEqual(
stale,
[],
`KNOWN_GAPS contains entries not present in audit findings — remove these: ${stale.join(', ')}`,
);
});
});
});
}
// ────────────────────────────────────────────────────────────────────────
// Folded from tests/bug-3442-shim-projection-drift-guard.test.cjs — consolidation epic #1969 (B6 #1975)
// ────────────────────────────────────────────────────────────────────────
{
const { describe: __foldDescribe } = require('node:test');
__foldDescribe("folded:bug-3442-shim-projection-drift-guard (consolidation epic #1969 B6 #1975)", () => {
'use strict';
process.env.GSD_TEST_MODE = '1';
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { spawnSync } = require('node:child_process');
const { cleanup } = require('./helpers.cjs');
const ROOT = path.resolve(__dirname, '..');
const DRIFT_LINT = path.join(ROOT, 'scripts', 'lint-shell-command-projection-drift.cjs');
function runLint(targetFile) {
return spawnSync(process.execPath, [DRIFT_LINT, targetFile], {
cwd: ROOT,
encoding: 'utf8',
});
}
// (The buildWindowsShimTriple parity test was removed with the gsd-sdk shim,
// #191. The serialized-command drift guard below is retained and unaffected.)
describe('bug #3442: shim/wrapper serialized-command drift guard', () => {
test('drift guard passes for current install.js', () => {
const result = runLint(path.join(ROOT, 'bin', 'install.js'));
assert.equal(result.status, 0, `expected lint pass, got:\n${result.stderr || result.stdout}`);
});
test('drift guard fails when install-owned inline shim text builder is present', () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3442-'));
try {
const fixture = path.join(tmp, 'install-inline-builder.js');
fs.writeFileSync(
fixture,
[
'function badBuilder() {',
" return '@ECHO OFF\\r\\n@SETLOCAL\\r\\n@node \"C:/shim.js\" %*\\r\\n';",
'}',
'',
].join('\n'),
);
const result = runLint(fixture);
assert.notEqual(result.status, 0, 'inline shim renderer should be rejected by the drift guard');
} finally {
cleanup(tmp);
}
});
test('drift guard does not block safe subprocess execution patterns', () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3442-'));
try {
const fixture = path.join(tmp, 'install-subprocess-safe.js');
fs.writeFileSync(
fixture,
[
"const cp = require('node:child_process');",
"cp.spawnSync('cmd.exe', ['/c', 'echo ok']);",
"cp.execFileSync('bash', ['-lc', 'printf %s \"$PATH\"']);",
'',
].join('\n'),
);
const result = runLint(fixture);
assert.equal(result.status, 0, `spawnSync/execFileSync should remain allowed:\n${result.stderr || result.stdout}`);
} finally {
cleanup(tmp);
}
});
});
});
}

File diff suppressed because it is too large Load Diff

View File

@@ -66,3 +66,586 @@ describe('bug #2136: MANAGED_HOOKS must include all shipped hook files', () => {
}
});
});
// ────────────────────────────────────────────────────────────────────────
// Folded from tests/bug-2136-sh-hook-version.test.cjs — consolidation epic #1969 (B6 #1975)
// ────────────────────────────────────────────────────────────────────────
{
const { describe: __foldDescribe } = require('node:test');
__foldDescribe("folded:bug-2136-sh-hook-version (consolidation epic #1969 B6 #1975)", () => {
// Consolidation #1969: this block spawns a REAL install and asserts side effects.
// The host suite sets GSD_TEST_MODE=1 at collection time, which the install child
// inherits via process.env and which suppresses hook/skill writes. Clear it for
// this block's duration (standalone had it unset); restore after.
const { before: __gtmBefore, after: __gtmAfter } = require('node:test');
let __savedGsdTestMode;
__gtmBefore(() => { __savedGsdTestMode = process.env.GSD_TEST_MODE; delete process.env.GSD_TEST_MODE; });
__gtmAfter(() => { if (__savedGsdTestMode === undefined) delete process.env.GSD_TEST_MODE; else process.env.GSD_TEST_MODE = __savedGsdTestMode; });
// allow-test-rule: structural-regression-guard (see #2136)
// The shebang line must be `#!/usr/bin/env bash` (PATH-resolved) rather than
// `#!/bin/bash` for cross-distro portability (NixOS, minimal Alpine do not
// ship /bin/bash). This is an architectural constraint that cannot be verified
// by executing the hooks — they run fine with either shebang on distros that
// have /bin/bash, so only a source assertion catches a future regression.
/**
* Regression tests for bug #2136 / #2206
*
* Root cause: three bash hooks (gsd-phase-boundary.sh, gsd-session-state.sh,
* gsd-validate-commit.sh) shipped without a gsd-hook-version header, and the
* stale-hook detector in gsd-check-update.js only matched JavaScript comment
* syntax (//) — not bash comment syntax (#).
*
* Result: every session showed "⚠ stale hooks — run /gsd-update" immediately
* after a fresh install, because the detector saw hookVersion: 'unknown' for
* all three bash hooks.
*
* This fix requires THREE parts working in concert:
* 1. Bash hooks ship with "# gsd-hook-version: {{GSD_VERSION}}"
* 2. install.js substitutes {{GSD_VERSION}} in .sh files at install time
* 3. gsd-check-update.js regex matches both "//" and "#" comment styles
*
* Neither fix alone is sufficient:
* - Headers + regex fix only (no install.js fix): installed hooks contain
* literal "{{GSD_VERSION}}" — the {{-guard silently skips them, making
* bash hook staleness permanently undetectable after future updates.
* - Headers + install.js fix only (no regex fix): installed hooks are
* stamped correctly but the detector still can't read bash "#" comments,
* so they still land in the "unknown / stale" branch on every session.
*/
'use strict';
// NOTE: Do NOT set GSD_TEST_MODE here — the E2E install tests spawn the
// real installer subprocess, which skips all install logic when GSD_TEST_MODE=1.
const { describe, test, before, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const os = require('os');
const { execFileSync } = require('child_process');
const HOOKS_DIR = path.join(__dirname, '..', 'hooks');
const _CHECK_UPDATE_FILE = path.join(HOOKS_DIR, 'gsd-check-update.js');
const WORKER_FILE = path.join(HOOKS_DIR, 'gsd-check-update-worker.js');
const INSTALL_SCRIPT = path.join(__dirname, '..', 'bin', 'install.js');
const BUILD_SCRIPT = path.join(__dirname, '..', 'scripts', 'build-hooks.js');
const SH_HOOKS = [
'gsd-phase-boundary.sh',
'gsd-session-state.sh',
'gsd-validate-commit.sh',
];
// ─── Ensure hooks/dist/ is populated before install tests ────────────────────
before(() => {
execFileSync(process.execPath, [BUILD_SCRIPT], {
encoding: 'utf-8',
stdio: 'pipe',
});
});
// ─── Helpers ─────────────────────────────────────────────────────────────────
function createTempDir(prefix) {
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
}
function cleanup(dir) {
// eslint-disable-next-line local/no-raw-rmsync-in-tests -- local cleanup() helper wrapping rmSync; cannot use imported cleanup() without naming collision
try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
}
function runInstaller(configDir) {
// --no-sdk: this test covers .sh hook version stamping only; skip SDK
// build (covered by install-smoke.yml).
execFileSync(process.execPath, [INSTALL_SCRIPT, '--claude', '--global', '--yes', '--no-sdk'], {
encoding: 'utf-8',
stdio: 'pipe',
env: { ...process.env, CLAUDE_CONFIG_DIR: configDir },
});
return path.join(configDir, 'hooks');
}
// ─────────────────────────────────────────────────────────────────────────────
// Part 1: Bash hook sources carry the version header placeholder
// ─────────────────────────────────────────────────────────────────────────────
describe('bug #2136 part 1: bash hook sources carry gsd-hook-version placeholder', () => {
for (const sh of SH_HOOKS) {
test(`${sh} contains "# gsd-hook-version: {{GSD_VERSION}}"`, () => {
const content = fs.readFileSync(path.join(HOOKS_DIR, sh), 'utf8');
assert.ok(
content.includes('# gsd-hook-version: {{GSD_VERSION}}'),
`${sh} must include "# gsd-hook-version: {{GSD_VERSION}}" so the ` +
`installer can stamp it and gsd-check-update.js can detect staleness`
);
});
}
test('version header is on line 2 (immediately after shebang)', () => {
// Placing the header immediately after the shebang ensures it is always
// found regardless of how much of the file is read. The shebang itself
// must use `#!/usr/bin/env bash` (PATH-resolved) rather than `#!/bin/bash`
// — POSIX guarantees /bin/sh but not /bin/bash, and distros like NixOS
// do not ship /bin/bash by default.
for (const sh of SH_HOOKS) {
const lines = fs.readFileSync(path.join(HOOKS_DIR, sh), 'utf8').split(/\r?\n/);
assert.strictEqual(
lines[0],
'#!/usr/bin/env bash',
`${sh} line 1 must be "#!/usr/bin/env bash" for cross-distro portability`
);
assert.ok(
lines[1].startsWith('# gsd-hook-version:'),
`${sh} line 2 must be the gsd-hook-version header (got: "${lines[1]}")`
);
}
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Part 2: gsd-check-update-worker.js regex handles bash "#" comment syntax
// (Logic moved from inline -e template literal to dedicated worker file)
// ─────────────────────────────────────────────────────────────────────────────
describe('bug #2136 part 2: stale-hook detector handles bash comment syntax', () => {
let src;
before(() => {
src = fs.readFileSync(WORKER_FILE, 'utf8');
});
test('version regex in source matches "#" comment syntax in addition to "//"', () => {
// The regex string in the source must contain the alternation for "#".
// The worker uses plain JS (no template-literal escaping), so the form is
// "(?:\/\/|#)" directly in source.
const hasBashAlternative =
src.includes('(?:\\/\\/|#)') || // escaped form (old template-literal style)
src.includes('(?://|#)'); // direct form in plain JS worker
assert.ok(
hasBashAlternative,
'gsd-check-update-worker.js version regex must include an alternative for bash "#" comments. ' +
'Expected to find (?:\\/\\/|#) or (?://|#) in the source. ' +
'The original "//" only regex causes bash hooks to always report hookVersion: "unknown"'
);
});
test('version regex does not use the old JS-only form as the sole pattern', () => {
// The old regex inside the template literal was the string:
// /\\/\\/ gsd-hook-version:\\s*(.+)/
// which, when evaluated in the subprocess, produced: /\/\/ gsd-hook-version:\s*(.+)/
// That only matched JS "//" comments — never bash "#".
// We verify that the old exact string no longer appears.
assert.ok(
!src.includes('\\/\\/ gsd-hook-version'),
'gsd-check-update-worker.js must not use the old JS-only (\\/\\/ gsd-hook-version) ' +
'escape form as the sole version matcher — it cannot match bash "#" comments'
);
});
test('version regex correctly matches both bash and JS hook version headers', () => {
// Verify that the versionMatch line in the source uses a regex that matches
// both bash "#" and JS "//" comment styles. We check the source contains the
// expected alternation, then directly test the known required pattern.
//
// We do NOT try to extract and evaluate the regex from source (it contains ")"
// which breaks simple extraction), so instead we confirm the source matches
// our expectation and run the regex itself.
assert.ok(
src.includes('gsd-hook-version'),
'gsd-check-update-worker.js must contain a gsd-hook-version version check'
);
// The fixed regex that must be present: matches both comment styles
const fixedRegex = /(?:\/\/|#) gsd-hook-version:\s*(.+)/;
assert.ok(
fixedRegex.test('# gsd-hook-version: 1.36.0'),
'bash-style "# gsd-hook-version: X" must be matchable by the required regex'
);
assert.ok(
fixedRegex.test('// gsd-hook-version: 1.36.0'),
'JS-style "// gsd-hook-version: X" must still match (no regression)'
);
assert.ok(
!fixedRegex.test('gsd-hook-version: 1.36.0'),
'line without a comment prefix must not match (prevents false positives)'
);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Part 4: End-to-end — installed .sh hooks have stamped version, not placeholder
// ─────────────────────────────────────────────────────────────────────────────
describe('bug #2136 part 4: installed .sh hooks contain stamped concrete version', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempDir('gsd-2136-install-');
});
afterEach(() => {
cleanup(tmpDir);
});
test('installed .sh hooks contain a concrete version string, not the template placeholder', () => {
const hooksDir = runInstaller(tmpDir);
for (const sh of SH_HOOKS) {
const hookPath = path.join(hooksDir, sh);
assert.ok(fs.existsSync(hookPath), `${sh} must be installed`);
const content = fs.readFileSync(hookPath, 'utf8');
assert.ok(
content.includes('# gsd-hook-version:'),
`installed ${sh} must contain a "# gsd-hook-version:" header`
);
assert.ok(
!content.includes('{{GSD_VERSION}}'),
`installed ${sh} must not contain literal "{{GSD_VERSION}}" — ` +
`install.js must substitute it with the concrete package version`
);
const versionMatch = content.match(/# gsd-hook-version:\s*(\S+)/);
assert.ok(versionMatch, `installed ${sh} version header must have a version value`);
assert.match(
versionMatch[1],
/^\d+\.\d+\.\d+/,
`installed ${sh} version "${versionMatch[1]}" must be a semver-like string`
);
}
});
test('stale-hook detector reports zero stale bash hooks immediately after fresh install', () => {
// This is the definitive end-to-end proof: after install, run the actual
// version-check logic (extracted from gsd-check-update.js) against the
// installed hooks and verify none are flagged stale.
const hooksDir = runInstaller(tmpDir);
const pkg = require(path.join(__dirname, '..', 'package.json'));
const installedVersion = pkg.version;
// Build a subprocess that runs the staleness check logic in isolation.
// We pass the installed version, hooks dir, and hook filenames as JSON
// to avoid any injection risk.
const checkScript = `
'use strict';
const fs = require('fs');
const path = require('path');
function isNewer(a, b) {
const pa = (a || '').split('.').map(s => Number(s.replace(/-.*/, '')) || 0);
const pb = (b || '').split('.').map(s => Number(s.replace(/-.*/, '')) || 0);
for (let i = 0; i < 3; i++) {
if (pa[i] > pb[i]) return true;
if (pa[i] < pb[i]) return false;
}
return false;
}
const hooksDir = ${JSON.stringify(hooksDir)};
const installed = ${JSON.stringify(installedVersion)};
const shHooks = ${JSON.stringify(SH_HOOKS)};
// Use the same regex that the fixed gsd-check-update.js uses
const versionRe = /(?:\\/\\/|#) gsd-hook-version:\\s*(.+)/;
const staleHooks = [];
for (const hookFile of shHooks) {
const hookPath = path.join(hooksDir, hookFile);
if (!fs.existsSync(hookPath)) {
staleHooks.push({ file: hookFile, hookVersion: 'missing' });
continue;
}
const content = fs.readFileSync(hookPath, 'utf8');
const m = content.match(versionRe);
if (m) {
const hookVersion = m[1].trim();
if (isNewer(installed, hookVersion) && !hookVersion.includes('{{')) {
staleHooks.push({ file: hookFile, hookVersion, installedVersion: installed });
}
} else {
staleHooks.push({ file: hookFile, hookVersion: 'unknown', installedVersion: installed });
}
}
process.stdout.write(JSON.stringify(staleHooks));
`;
const result = execFileSync(process.execPath, ['-e', checkScript], { encoding: 'utf8' });
const staleHooks = JSON.parse(result);
assert.deepStrictEqual(
staleHooks,
[],
`Fresh install must produce zero stale bash hooks.\n` +
`Got: ${JSON.stringify(staleHooks, null, 2)}\n` +
`This indicates either the version header was not stamped by install.js, ` +
`or the detector regex cannot match bash "#" comment syntax.`
);
});
});
});
}
// ────────────────────────────────────────────────────────────────────────
// Folded from tests/bug-941-managed-hooks-registry-manifest.test.cjs — consolidation epic #1969 (B6 #1975)
// ────────────────────────────────────────────────────────────────────────
{
const { describe: __foldDescribe } = require('node:test');
__foldDescribe("folded:bug-941-managed-hooks-registry-manifest (consolidation epic #1969 B6 #1975)", () => {
/**
* Regression test for bug #941
*
* `managed-hooks-registry.cjs` is shipped alongside gsd-check-update-worker.js
* in hooks/dist/ (it is listed in HOOKS_TO_COPY in scripts/build-hooks.js).
* However, the manifest-writing loop in bin/install.js gated on
* file.startsWith('gsd-') && (file.endsWith('.js') || file.endsWith('.sh'))
* — which `managed-hooks-registry.cjs` fails on both predicates (wrong prefix,
* .cjs extension). The result: after every install, `detect-custom-files`
* found the installed file in the hooks/ dir but had no manifest entry for it
* and reported a perpetual false-positive "Found 1 custom file(s)" warning on
* every `/gsd-update`.
*
* Fix: drive the manifest hooks loop from HOOKS_TO_COPY (the canonical build
* set), so the manifest set is structurally identical to what was installed.
*
* Closes: #941
*/
'use strict';
process.env.GSD_TEST_MODE = '1';
const { describe, test, before, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const os = require('node:os');
const { execFileSync } = require('node:child_process');
const crypto = require('node:crypto');
const INSTALL_SCRIPT = path.join(__dirname, '..', 'bin', 'install.js');
const BUILD_SCRIPT = path.join(__dirname, '..', 'scripts', 'build-hooks.js');
const TOOLS_PATH = path.join(__dirname, '..', 'gsd-core', 'bin', 'gsd-tools.cjs');
const MANIFEST_NAME = 'gsd-file-manifest.json';
const { HOOKS_TO_COPY } = require('../scripts/build-hooks.js');
// ─── Ensure hooks/dist/ is populated before any install test ────────────────
before(() => {
execFileSync(process.execPath, [BUILD_SCRIPT], {
encoding: 'utf-8',
stdio: 'pipe',
});
});
// ─── Helpers ─────────────────────────────────────────────────────────────────
function createTempDir(prefix) {
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
}
function cleanup(dir) {
// eslint-disable-next-line local/no-raw-rmsync-in-tests -- local cleanup helper, swallows ENOENT
try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
}
/**
* Run the installer targeting a temp directory as the claude global config dir.
* Returns the path to configDir.
*/
function runInstaller(configDir) {
// Clear GSD_TEST_MODE so the installer's main() block actually runs.
// The test file sets GSD_TEST_MODE=1 (top of file) to suppress in-process
// import side effects, but when install.js is spawned as a subprocess it
// must not skip the main() gate or the install is a no-op.
const env = { ...process.env, CLAUDE_CONFIG_DIR: configDir };
delete env.GSD_TEST_MODE;
execFileSync(process.execPath, [INSTALL_SCRIPT, '--claude', '--global', '--yes'], {
encoding: 'utf-8',
stdio: 'pipe',
env,
});
return configDir;
}
/**
* Run detect-custom-files and return parsed JSON output.
*/
function detectCustomFiles(configDir) {
const result = execFileSync(process.execPath, [TOOLS_PATH, 'detect-custom-files', '--config-dir', configDir], {
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe'],
env: { ...process.env, GSD_SESSION_KEY: '' },
});
return JSON.parse(result.trim());
}
// ─── Tests ────────────────────────────────────────────────────────────────────
describe('bug #941 — managed-hooks-registry.cjs recorded in file manifest', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempDir('gsd-bug-941-');
});
afterEach(() => {
cleanup(tmpDir);
});
test('managed-hooks-registry.cjs appears in gsd-file-manifest.json after install', () => {
runInstaller(tmpDir);
const manifestPath = path.join(tmpDir, MANIFEST_NAME);
assert.ok(
fs.existsSync(manifestPath),
`${MANIFEST_NAME} must exist after install (not found at ${manifestPath})`,
);
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
assert.ok(
typeof manifest.files === 'object' && manifest.files !== null,
'manifest must have a files map',
);
// The key must use forward slashes (cross-platform manifest format)
const key = 'hooks/managed-hooks-registry.cjs';
assert.ok(
Object.prototype.hasOwnProperty.call(manifest.files, key),
[
`manifest.files must contain '${key}' — managed-hooks-registry.cjs is`,
'shipped to users but was not recorded in the manifest, causing',
`detect-custom-files to flag it as a perpetual false-positive custom file.`,
`Actual manifest hook keys: ${Object.keys(manifest.files).filter(k => k.startsWith('hooks/')).join(', ')}`,
].join(' '),
);
});
test('gsd-file-manifest.json covers the full HOOKS_TO_COPY set (forward-proof)', () => {
runInstaller(tmpDir);
const manifestPath = path.join(tmpDir, MANIFEST_NAME);
assert.ok(fs.existsSync(manifestPath), `${MANIFEST_NAME} must exist after install`);
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
const hooksDir = path.join(tmpDir, 'hooks');
// Every hook in HOOKS_TO_COPY that was actually installed must have a
// manifest entry. This assertion is forward-proof: adding any new hook to
// HOOKS_TO_COPY without updating the manifest loop will fail this test.
for (const hook of HOOKS_TO_COPY) {
const installed = path.join(hooksDir, hook);
if (!fs.existsSync(installed)) {
// Skip hooks that weren't installed (e.g. .sh hooks on non-unix skip
// chmod but still install — only skip if truly absent).
continue;
}
const key = `hooks/${hook}`;
assert.ok(
Object.prototype.hasOwnProperty.call(manifest.files, key),
[
`manifest.files must contain '${key}'.`,
`HOOKS_TO_COPY lists '${hook}' and it was installed, but the manifest`,
`loop in writeManifest() did not record it.`,
`Actual manifest hook keys: ${Object.keys(manifest.files).filter(k => k.startsWith('hooks/')).join(', ')}`,
].join(' '),
);
}
});
test('detect-custom-files reports zero custom files after a clean install (no false positives)', () => {
runInstaller(tmpDir);
let detected;
try {
detected = detectCustomFiles(tmpDir);
} catch (err) {
assert.fail(
`detect-custom-files failed: ${err.message}\nstderr: ${err.stderr || '(none)'}`,
);
}
assert.ok(
detected.manifest_found,
'detect-custom-files must find the manifest after install',
);
const hookCustomFiles = (detected.custom_files || []).filter(f => f.startsWith('hooks/'));
assert.strictEqual(
hookCustomFiles.length,
0,
[
`detect-custom-files must report 0 custom hook files after a clean install, but got ${hookCustomFiles.length}:`,
JSON.stringify(hookCustomFiles, null, 2),
'This is the perpetual false-positive bug #941 — hooks in HOOKS_TO_COPY that',
'were not recorded in the manifest appear as custom files.',
].join('\n'),
);
});
test('manifest hook keys use forward slashes (cross-platform compatibility)', () => {
runInstaller(tmpDir);
const manifest = JSON.parse(fs.readFileSync(path.join(tmpDir, MANIFEST_NAME), 'utf-8'));
const hookKeys = Object.keys(manifest.files).filter(k => k.startsWith('hooks/'));
assert.ok(hookKeys.length > 0, 'manifest must contain at least one hooks/ entry');
for (const key of hookKeys) {
assert.ok(
!key.includes('\\'),
`manifest key '${key}' must use forward slashes, not backslashes`,
);
}
});
test('manifest hash for managed-hooks-registry.cjs matches the installed file contents', () => {
// Strengthened assertion: proves the manifest not only records the right KEY
// but stores a hash that matches the ACTUAL installed file bytes. A future
// refactor that records the key from the wrong path/content would fail here
// even if the key is present.
runInstaller(tmpDir);
const manifestPath = path.join(tmpDir, MANIFEST_NAME);
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
const key = 'hooks/managed-hooks-registry.cjs';
assert.ok(
Object.prototype.hasOwnProperty.call(manifest.files, key),
`manifest.files must contain '${key}' before hash comparison`,
);
// Recompute the hash the same way the installer's fileHash() does:
// sha256 of the raw file bytes as a hex string.
const installedPath = path.join(tmpDir, 'hooks', 'managed-hooks-registry.cjs');
assert.ok(
fs.existsSync(installedPath),
`installed file must exist at ${installedPath}`,
);
const actualHash = crypto
.createHash('sha256')
.update(fs.readFileSync(installedPath))
.digest('hex');
assert.strictEqual(
manifest.files[key],
actualHash,
[
`manifest hash for '${key}' does not match the installed file's actual contents.`,
`This means writeManifest() hashed the wrong path or wrong content.`,
`Expected (from installed file): ${actualHash}`,
`Got (from manifest): ${manifest.files[key]}`,
].join('\n'),
);
});
});
});
}

View File

@@ -167,3 +167,123 @@ describe('#114: npm integrity gate — --help output', () => {
);
});
});
// ────────────────────────────────────────────────────────────────────────
// Folded from tests/bug-3588-npm-audit-clean.test.cjs — consolidation epic #1969 (B6 #1975)
// ────────────────────────────────────────────────────────────────────────
{
const { describe: __foldDescribe } = require('node:test');
__foldDescribe("folded:bug-3588-npm-audit-clean (consolidation epic #1969 B6 #1975)", () => {
'use strict';
/**
* Regression test for #3588 — production dependency tree must not carry
* high or moderate npm-audit advisories.
*
* Strategy: run `npm audit --omit=dev --json` against both the root
* workspace and the embedded SDK package and assert that the metadata
* vulnerability counts are zero across info/low/moderate/high/critical.
*
* The test is intentionally strict — any advisory of any severity (other
* than 'low' if the maintainer accepts it; that branch is left explicit
* here) blocks CI. If a future advisory lands without an upstream patch,
* either bump the patched transitive (preferred), or annotate the
* acceptance below with a justification AND a link to the upstream tracker.
*
* Skips automatically when `node_modules/` is absent (a fresh checkout
* before `npm install`) so the test does not falsely report on developer
* machines mid-setup.
*/
const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
const path = require('node:path');
const fs = require('node:fs');
const { execFileSync } = require('node:child_process');
const ROOT = path.resolve(__dirname, '..');
const SDK = path.join(ROOT, 'sdk');
const AUDIT_TIMEOUT_MS = 180_000;
const TEST_TIMEOUT_MS = AUDIT_TIMEOUT_MS + 30_000;
function auditProductionVulns(cwd) {
if (!fs.existsSync(path.join(cwd, 'package.json'))) {
return null; // signal "skip" to caller
}
if (!fs.existsSync(path.join(cwd, 'node_modules'))) {
return null; // signal "skip" to caller
}
const isWindows = process.platform === 'win32';
const npmCandidates = isWindows ? ['npm.cmd', 'npm'] : ['npm'];
const args = ['audit', '--omit=dev', '--json'];
let out;
let lastErr = null;
for (const npmCmd of npmCandidates) {
try {
out = execFileSync(
npmCmd,
args,
{
cwd,
encoding: 'utf-8',
stdio: ['ignore', 'pipe', 'pipe'],
timeout: AUDIT_TIMEOUT_MS,
shell: isWindows,
}
);
lastErr = null;
break;
} catch (e) {
// `npm audit` exits non-zero when advisories are present; the JSON is
// still on stdout in that case. Recover and let the assertion classify.
if (e && typeof e.stdout !== 'undefined' && e.stdout !== undefined && e.stdout !== null) {
out = Buffer.isBuffer(e.stdout) ? e.stdout.toString('utf-8') : String(e.stdout);
lastErr = null;
break;
}
lastErr = e;
}
}
if (lastErr) throw lastErr;
const parsed = JSON.parse(out);
// `null` is reserved for the "node_modules missing → skip" signal above.
// Any other unexpected JSON shape is a real failure of the audit harness
// (npm changed its output format, audit aborted before metadata, etc.) —
// throw so the test fails loudly instead of skipping silently.
if (parsed && parsed.metadata && parsed.metadata.vulnerabilities) {
return parsed.metadata.vulnerabilities;
}
throw new Error(`Unexpected npm audit JSON shape in ${cwd}: missing metadata.vulnerabilities`);
}
describe('#3588: npm audit --omit=dev reports zero advisories', () => {
test('root workspace production tree has no advisories', { timeout: TEST_TIMEOUT_MS }, (t) => {
const vulns = auditProductionVulns(ROOT);
if (vulns === null) {
t.skip('auditable npm package not present or node_modules/ missing');
return;
}
assert.strictEqual(vulns.critical, 0, `expected 0 critical; got ${vulns.critical}`);
assert.strictEqual(vulns.high, 0, `expected 0 high; got ${vulns.high}`);
assert.strictEqual(vulns.moderate, 0, `expected 0 moderate; got ${vulns.moderate}`);
// Low advisories are not explicitly forbidden by the #3588 acceptance
// criterion but the issue listed only high/moderate as actual findings —
// tighten if any future low advisory is introduced.
assert.strictEqual(vulns.low, 0, `expected 0 low; got ${vulns.low}`);
});
test('sdk/ production tree has no advisories', { timeout: TEST_TIMEOUT_MS }, (t) => {
const vulns = auditProductionVulns(SDK);
if (vulns === null) {
t.skip('sdk/ is not an auditable npm package or sdk/node_modules/ is missing');
return;
}
assert.strictEqual(vulns.critical, 0, `expected 0 critical; got ${vulns.critical}`);
assert.strictEqual(vulns.high, 0, `expected 0 high; got ${vulns.high}`);
assert.strictEqual(vulns.moderate, 0, `expected 0 moderate; got ${vulns.moderate}`);
assert.strictEqual(vulns.low, 0, `expected 0 low; got ${vulns.low}`);
});
});
});
}

View File

@@ -68,3 +68,276 @@ describe('pause-work improvements', () => {
);
});
});
// ────────────────────────────────────────────────────────────────────────
// Folded from tests/bug-3446-resume-continue-here-discovery.test.cjs — consolidation epic #1969 (B6 #1975)
// ────────────────────────────────────────────────────────────────────────
{
const { describe: __foldDescribe } = require('node:test');
__foldDescribe("folded:bug-3446-resume-continue-here-discovery (consolidation epic #1969 B6 #1975)", () => {
// allow-test-rule: source-text-is-the-product (see #3446)
// Workflow `.md` files are the runtime contract executed by Claude Code as
// embedded bash. This test extracts the actual `check_incomplete_work` bash
// block from resume-project.md and exercises it against a planted directory
// layout — that's a behavioral integration test of the workflow contract,
// not regex-on-source.
'use strict';
const { test, describe, before, after } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { spawnSync } = require('node:child_process');
const { createTempDir, cleanup } = require('./helpers.cjs');
const WORKFLOW_PATH = path.join(__dirname, '..', 'gsd-core', 'workflows', 'resume-project.md');
// Extract the first ```bash``` code block inside the
// `<step name="check_incomplete_work">` element. That's the snippet the
// runtime actually executes; it's what we want to validate.
function extractCheckBlock() {
const md = fs.readFileSync(WORKFLOW_PATH, 'utf8');
const stepStart = md.indexOf('<step name="check_incomplete_work">');
assert.ok(stepStart >= 0, 'resume-project.md must contain a check_incomplete_work step');
const stepEnd = md.indexOf('</step>', stepStart);
assert.ok(
stepEnd >= 0,
'check_incomplete_work step must have a closing </step> tag',
);
const stepBody = md.slice(stepStart, stepEnd);
const fenceMatch = stepBody.match(/```(?:bash|sh)\r?\n([\s\S]*?)\r?\n```/);
assert.ok(fenceMatch, 'check_incomplete_work step must embed a ```bash code block');
return fenceMatch[1];
}
function runSnippet(cwd, snippet) {
// has_interrupted_agent is a downstream-orchestrator variable; default it
// to "false" so the embedded `if` branch is a no-op during this test.
return spawnSync('bash', ['-c', snippet], {
cwd,
encoding: 'utf8',
env: { ...process.env, has_interrupted_agent: 'false', interrupted_agent_id: '' },
});
}
describe('bug #3446: resume-project detects non-phase and legacy continue-here handoffs', () => {
let tmpDir;
let snippet;
before(() => {
snippet = extractCheckBlock();
tmpDir = createTempDir('gsd-bug-3446-');
// Plant the three discovery surfaces that bug #3446 was originally
// filed to cover.
fs.mkdirSync(path.join(tmpDir, '.planning'), { recursive: true });
fs.writeFileSync(
path.join(tmpDir, '.planning', '.continue-here.md'),
'---\ncontext: default\n---\nroot-of-.planning handoff\n',
'utf8',
);
fs.mkdirSync(path.join(tmpDir, '.planning', 'sketches', 'SKETCH-001'), { recursive: true });
fs.writeFileSync(
path.join(tmpDir, '.planning', 'sketches', 'SKETCH-001', '.continue-here.md'),
'---\ncontext: sketch\n---\nsketch handoff\n',
'utf8',
);
fs.writeFileSync(
path.join(tmpDir, '.continue-here.md'),
'---\ncontext: legacy\n---\nlegacy repo-root handoff\n',
'utf8',
);
});
after(() => {
cleanup(tmpDir);
});
test('check_incomplete_work surfaces .planning/.continue-here.md (depth 1 under .planning)', () => {
const result = runSnippet(tmpDir, snippet);
assert.equal(result.status, 0, `snippet exited ${result.status}; stderr=${result.stderr}`);
assert.match(
result.stdout,
/\.planning\/\.continue-here\.md/,
`expected .planning/.continue-here.md in stdout; got: ${JSON.stringify(result.stdout)}`,
);
});
test('check_incomplete_work surfaces .planning/sketches/SKETCH-001/.continue-here.md (depth 3 under .planning)', () => {
const result = runSnippet(tmpDir, snippet);
assert.equal(result.status, 0, `snippet exited ${result.status}; stderr=${result.stderr}`);
assert.match(
result.stdout,
/\.planning\/sketches\/SKETCH-001\/\.continue-here\.md/,
`expected sketch handoff in stdout; got: ${JSON.stringify(result.stdout)}`,
);
});
test('check_incomplete_work surfaces legacy repo-root .continue-here.md', () => {
const result = runSnippet(tmpDir, snippet);
assert.equal(result.status, 0, `snippet exited ${result.status}; stderr=${result.stderr}`);
assert.match(
result.stdout,
/(^|\n)\.\/\.continue-here\.md(\n|$)/,
`expected legacy ./.continue-here.md in stdout; got: ${JSON.stringify(result.stdout)}`,
);
});
});
});
}
// ────────────────────────────────────────────────────────────────────────
// Folded from tests/bug-3689-resume-glob-nomatch.test.cjs — consolidation epic #1969 (B6 #1975)
// ────────────────────────────────────────────────────────────────────────
{
const { describe: __foldDescribe } = require('node:test');
__foldDescribe("folded:bug-3689-resume-glob-nomatch (consolidation epic #1969 B6 #1975)", () => {
// allow-test-rule: source-text-is-the-product (see #3689)
// Workflow `.md` files are the runtime contract executed by Claude Code as
// embedded bash. Asserting on the staged text of resume-project.md and on the
// behavior of the embedded snippet under real shells is a behavioral test of
// the workflow itself, not source-grep theater.
/**
* Regression for #3689 — /gsd-resume-work silently drops
* `.planning/.continue-here*.md` checkpoints under zsh's default NOMATCH.
*
* Root cause: the `check_incomplete_work` step in
* `gsd-core/workflows/resume-project.md` used a chained `ls` with six
* bare-glob arguments. Under zsh's default `NOMATCH` setopt the first
* non-matching glob aborts the entire command during word-expansion — every
* pattern after that point is never evaluated, including the one that holds
* valid pause checkpoints (`.planning/.continue-here*.md`). `2>/dev/null ||
* true` only suppresses ls's own stderr / exit code; it has no effect on the
* shell's pre-exec abort.
*
* Fix: replace the chained `ls` with two `find` calls. `find` does not use
* shell glob expansion, and `find <missing-dir> -maxdepth N -name PATTERN
* -print 2>/dev/null` tolerates absent directories on both bash and zsh.
*
* This test covers:
* 1. zsh under `-o nomatch`: checkpoint at `.planning/.continue-here-*.md`
* is listed even when `.planning/spikes`, `.planning/sketches`,
* `.planning/deliberations` are absent (the common new-project layout).
* 2. bash default: same behavior.
* 3. zsh `-o nomatch` with no `.continue-here` files anywhere: exits 0,
* no output, no error.
* 4. Text invariant: resume-project.md no longer carries the brittle
* chained-ls pattern.
*/
'use strict';
const { describe, test, before, after } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { spawnSync } = require('node:child_process');
const { createTempDir, cleanup } = require('./helpers.cjs');
const REPO_ROOT = path.resolve(__dirname, '..');
const WORKFLOW_PATH = path.join(REPO_ROOT, 'gsd-core', 'workflows', 'resume-project.md');
// The exact snippet the workflow now embeds. Keep in sync with
// resume-project.md `check_incomplete_work` step.
const FIND_SNIPPET = [
"find .planning -maxdepth 3 -name '.continue-here*.md' -print 2>/dev/null || true",
"find . -maxdepth 1 -name '.continue-here*.md' -print 2>/dev/null || true",
].join('\n');
function hasShell(name) {
const result = spawnSync('which', [name], { encoding: 'utf8' });
return result.status === 0 && result.stdout.trim().length > 0;
}
describe('bug #3689 — resume-project.md continue-here scan under zsh NOMATCH', () => {
let tmpDir;
before(() => {
tmpDir = createTempDir('gsd-bug-3689-');
// Reproduce the common new-project layout: a `.planning/` with a
// suffixed continue-here file and *no* spike / sketch / deliberation
// subdirectories.
fs.mkdirSync(path.join(tmpDir, '.planning'), { recursive: true });
fs.writeFileSync(
path.join(tmpDir, '.planning', '.continue-here-AT-1234.md'),
'---\ncontext: default\n---\nhandoff body\n',
'utf8',
);
});
after(() => {
cleanup(tmpDir);
});
test('zsh -o nomatch lists the .planning/.continue-here-* checkpoint', { skip: !hasShell('zsh') }, () => {
const result = spawnSync('zsh', ['-o', 'nomatch', '-c', FIND_SNIPPET], {
cwd: tmpDir,
encoding: 'utf8',
});
assert.equal(result.status, 0, `zsh exited ${result.status}; stderr=${result.stderr}`);
assert.match(
result.stdout,
/\.planning\/\.continue-here-AT-1234\.md/,
`expected checkpoint in stdout, got: ${JSON.stringify(result.stdout)}`,
);
});
test('bash default lists the .planning/.continue-here-* checkpoint', { skip: !hasShell('bash') }, () => {
const result = spawnSync('bash', ['-c', FIND_SNIPPET], {
cwd: tmpDir,
encoding: 'utf8',
});
assert.equal(result.status, 0, `bash exited ${result.status}; stderr=${result.stderr}`);
assert.match(
result.stdout,
/\.planning\/\.continue-here-AT-1234\.md/,
`expected checkpoint in stdout, got: ${JSON.stringify(result.stdout)}`,
);
});
});
describe('bug #3689 — empty workspace exits cleanly', () => {
let tmpDir;
before(() => {
tmpDir = createTempDir('gsd-bug-3689-');
// No .planning/ at all, no .continue-here files. Pure greenfield.
});
after(() => {
cleanup(tmpDir);
});
test('zsh -o nomatch with no checkpoints exits 0, empty output', { skip: !hasShell('zsh') }, () => {
const result = spawnSync('zsh', ['-o', 'nomatch', '-c', FIND_SNIPPET], {
cwd: tmpDir,
encoding: 'utf8',
});
assert.equal(result.status, 0, `zsh exited ${result.status}; stderr=${result.stderr}`);
assert.equal(result.stdout.trim(), '', `expected no stdout, got: ${JSON.stringify(result.stdout)}`);
});
});
describe('bug #3689 — workflow text invariant', () => {
test('resume-project.md no longer chains bare globs through ls', () => {
const body = fs.readFileSync(WORKFLOW_PATH, 'utf8');
assert.doesNotMatch(
body,
/ls\s+\.planning\/spikes\/\*\/\.continue-here/,
'resume-project.md still contains the chained `ls .planning/spikes/*/.continue-here*.md` pattern that aborts under zsh NOMATCH; the find-based scan should replace it.',
);
assert.match(
body,
/find \.planning -maxdepth 3 -name '\.continue-here\*\.md'/,
'resume-project.md must use the find-based scan introduced by the #3689 fix.',
);
});
});
});
}

View File

@@ -279,3 +279,681 @@ describe('perf #317: warn sentinel absent/present (exercises sentinel ENOENT pat
'escalated message must say CONTEXT CRITICAL');
});
});
// ────────────────────────────────────────────────────────────────────────
// Folded from tests/bug-1974-context-exhaustion-record.test.cjs — consolidation epic #1969 (B6 #1975)
// ────────────────────────────────────────────────────────────────────────
{
const { describe: __foldDescribe } = require('node:test');
__foldDescribe("folded:bug-1974-context-exhaustion-record (consolidation epic #1969 B6 #1975)", () => {
/**
* Integration tests for gsd-context-monitor.js auto-record on CRITICAL (#1974).
*
* Verifies:
* 1. On CRITICAL + active GSD project, the hook sets criticalRecorded in the
* warn sentinel AND the state record-session command writes the "Stopped At"
* field to STATE.md.
* 2. Subsequent CRITICAL firings within the same session do NOT re-fire
* the subprocess (sentinel guard prevents repeated overwrites).
* 3. When no .planning/STATE.md exists, the subprocess is not spawned.
* 4. Path resolution uses __dirname, not hardcoded ~/.claude/.
* 5. A WARNING-only fire does NOT set criticalRecorded (selectivity counter-test).
*
* Design note (#3726, #3775): the original test used a short wall-clock poll
* against a fire-and-forget spawn().unref() subprocess and flaked under load.
* We keep one deterministic assertion (criticalRecorded sentinel is written
* before hook exit), and use a bounded poll window for the detached writer's
* STATE.md update. A separate test verifies direct record-session invocation.
*/
'use strict';
const { test, describe, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const os = require('node:os');
const { spawnSync } = require('node:child_process');
const { cleanup, delay } = require('./helpers.cjs');
const HOOK_PATH = path.resolve(__dirname, '..', 'hooks', 'gsd-context-monitor.js');
const GSD_TOOLS = path.resolve(__dirname, '..', 'gsd-core', 'bin', 'gsd-tools.cjs');
// Windows can hold a transient handle on the temp dir after a spawnSync child
// exits (AV scanner / handle-release lag), so cleanup()'s internal rmSync retry
// (~5s) occasionally still throws EBUSY/EPERM/ENOTEMPTY under CI load. Restore a
// bounded outer retry with async backoff via the shared delay() helper.
// Re-adds the guard removed in #482. Refs #490.
async function cleanupWithRetry(dir, attempts = 8) {
for (let i = 0; i < attempts; i += 1) {
try { cleanup(dir); return; }
catch (err) {
const transient = err && (err.code === 'EBUSY' || err.code === 'EPERM' || err.code === 'ENOTEMPTY');
if (!transient || i === attempts - 1) throw err;
await delay(100 * (i + 1));
}
}
}
/**
* Run the hook with a given session id and context percentage.
* Writes a bridge metrics file first, then pipes the hook input via stdin.
* Returns after the hook exits.
*/
function runHook(sessionId, remainingPct, cwd) {
// Write the bridge metrics file the hook reads
const bridgePath = path.join(os.tmpdir(), `claude-ctx-${sessionId}.json`);
fs.writeFileSync(bridgePath, JSON.stringify({
session_id: sessionId,
remaining_percentage: remainingPct,
used_pct: 100 - remainingPct,
timestamp: Math.floor(Date.now() / 1000),
}));
const input = JSON.stringify({
session_id: sessionId,
cwd,
});
const result = spawnSync(process.execPath, [HOOK_PATH], {
input,
encoding: 'utf-8',
timeout: 10000,
env: { ...process.env, HOME: process.env.HOME },
});
return { exitCode: result.status, stdout: result.stdout, stderr: result.stderr };
}
/**
* Run gsd-tools state record-session synchronously.
* Returns { exitCode, stdout, stderr }.
* Used to verify the persistence seam deterministically without relying on
* the fire-and-forget subprocess timing that caused flake (#3726).
*/
function runRecordSession(cwd, stoppedAt) {
const result = spawnSync(
process.execPath,
[GSD_TOOLS, 'state', 'record-session', '--stopped-at', stoppedAt, '--cwd', cwd],
{ encoding: 'utf-8', timeout: 30000 }
);
return {
exitCode: result.status,
signal: result.signal,
error: result.error,
stdout: result.stdout,
stderr: result.stderr,
};
}
/**
* Read and parse the warn sentinel file for a session.
* Returns the parsed object, or null if the file does not exist.
*/
function readWarnData(sessionId) {
const warnPath = path.join(os.tmpdir(), `claude-ctx-${sessionId}-warned.json`);
try {
return JSON.parse(fs.readFileSync(warnPath, 'utf-8'));
} catch {
return null;
}
}
describe('#1974 context exhaustion auto-record', () => {
let tmpDir;
let statePath;
let sessionId;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-1974-'));
const planningDir = path.join(tmpDir, '.planning');
fs.mkdirSync(planningDir, { recursive: true });
// Minimal STATE.md with Stopped At field
statePath = path.join(planningDir, 'STATE.md');
fs.writeFileSync(statePath, [
'# Session State',
'',
'**Current Phase:** 1',
'**Status:** executing',
'**Last session:** unset',
'**Last Date:** unset',
'**Stopped At:** None',
'**Resume File:** None',
'',
].join('\n'));
// Minimal config.json required by gsd-tools
fs.writeFileSync(path.join(planningDir, 'config.json'), JSON.stringify({ project_code: 'TEST' }));
sessionId = `test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
});
afterEach(async () => {
// cleanupWithRetry wraps cleanup() with a bounded outer retry (async setTimeout
// backoff, no Atomics.wait) to handle cases where windows-2022 CI load keeps
// the temp dir EBUSY beyond rmSync's internal ~5s retry window. Refs #490.
await cleanupWithRetry(tmpDir);
// Clean up bridge files
try {
const warnPath = path.join(os.tmpdir(), `claude-ctx-${sessionId}-warned.json`);
if (fs.existsSync(warnPath)) fs.unlinkSync(warnPath);
const bridgePath = path.join(os.tmpdir(), `claude-ctx-${sessionId}.json`);
if (fs.existsSync(bridgePath)) fs.unlinkSync(bridgePath);
} catch { /* noop */ }
});
test('sets criticalRecorded sentinel on CRITICAL (synchronous assertion only)', () => {
// Trigger CRITICAL — remaining <= 25
// The detached record-session subprocess timing assertion (waitForStateMatch,
// 45s poll) was removed per #453 (clock-seam): flaky under load. The
// deterministic coverage for STATE.md persistence lives in the
// 'state record-session command persists Stopped At when invoked directly'
// test below, which uses spawnSync instead of a fire-and-forget subprocess.
const result = runHook(sessionId, 20, tmpDir);
assert.strictEqual(result.exitCode, 0, `hook should exit 0: ${result.stderr}`);
// Deterministic: hook writes criticalRecorded:true to warnPath SYNCHRONOUSLY
// before the hook process exits, before the fire-and-forget subprocess runs.
// Since runHook() uses spawnSync, this is guaranteed readable now.
const warnData = readWarnData(sessionId);
assert.ok(warnData, 'warn sentinel file must exist after CRITICAL fire');
assert.strictEqual(
warnData.criticalRecorded,
true,
'hook must set criticalRecorded:true in warn sentinel on CRITICAL'
);
});
test('does NOT spawn subprocess when .planning/STATE.md is absent', () => {
// Delete STATE.md to simulate non-GSD project
fs.unlinkSync(statePath);
const result = runHook(sessionId, 20, tmpDir);
assert.strictEqual(result.exitCode, 0);
// The hook checks isGsdActive via fs.existsSync(STATE.md) before setting
// criticalRecorded. If STATE.md is absent, criticalRecorded must NOT be set.
const warnData = readWarnData(sessionId);
// warnData may exist (hook still debounces) but criticalRecorded must be absent/falsy.
const criticalRecorded = warnData && warnData.criticalRecorded;
assert.ok(!criticalRecorded, 'criticalRecorded must not be set when STATE.md is absent');
assert.ok(!fs.existsSync(statePath), 'STATE.md should not be recreated when absent');
});
test('sentinel prevents repeated firing within same session', () => {
// First CRITICAL fire — should set criticalRecorded synchronously.
const result1 = runHook(sessionId, 20, tmpDir);
assert.strictEqual(result1.exitCode, 0, `first hook fire should exit 0: ${result1.stderr}`);
const warnData1 = readWarnData(sessionId);
assert.ok(warnData1, 'warn sentinel must exist after first CRITICAL fire');
assert.strictEqual(warnData1.criticalRecorded, true, 'first fire must set criticalRecorded:true');
// Second CRITICAL fire — same session, criticalRecorded already true in
// warnPath. Advance callsSinceWarn past DEBOUNCE_CALLS (5, see hook
// line 29) so the hook processes the warning message path and exercises
// the sentinel guard. Using 10 (2× DEBOUNCE_CALLS) ensures we clear the
// debounce threshold regardless of any future DEBOUNCE_CALLS adjustment.
const warnPath = path.join(os.tmpdir(), `claude-ctx-${sessionId}-warned.json`);
const warnDataPatched = { ...warnData1, callsSinceWarn: 10 };
fs.writeFileSync(warnPath, JSON.stringify(warnDataPatched));
const result2 = runHook(sessionId, 18, tmpDir);
assert.strictEqual(result2.exitCode, 0, `second hook fire should exit 0: ${result2.stderr}`);
// The warnData must still carry criticalRecorded:true — the guard was
// active and the hook did not reset or clear it.
const warnData2 = readWarnData(sessionId);
assert.strictEqual(warnData2 && warnData2.criticalRecorded, true, 'sentinel must remain true after second fire');
// The hook's stdout must still emit a CRITICAL warning message (so the
// agent sees context warnings) even though record-session was NOT re-fired.
const output2 = result2.stdout ? (() => { try { return JSON.parse(result2.stdout); } catch { return null; } })() : null;
assert.ok(
output2 && output2.hookSpecificOutput && /CONTEXT CRITICAL/.test(output2.hookSpecificOutput.additionalContext),
'second CRITICAL fire must still emit CONTEXT CRITICAL warning to the agent'
);
});
test('state record-session command persists Stopped At when invoked directly', () => {
const recordResult = runRecordSession(tmpDir, 'context exhaustion at 80% (2026-01-01)');
assert.strictEqual(
recordResult.exitCode,
0,
`record-session should exit 0 (signal=${recordResult.signal || 'none'} error=${recordResult.error ? recordResult.error.message : 'none'}): ${recordResult.stderr}`
);
const content = fs.readFileSync(statePath, 'utf-8');
assert.match(content, /context exhaustion at 80% \(2026-01-01\)/, 'STATE.md must contain direct record-session value');
});
test('WARNING-only fire does NOT set criticalRecorded (selectivity counter-test)', () => {
// Trigger WARNING (remaining 30% — below WARNING_THRESHOLD=35, above CRITICAL_THRESHOLD=25)
const result = runHook(sessionId, 30, tmpDir);
assert.strictEqual(result.exitCode, 0, `hook should exit 0: ${result.stderr}`);
// criticalRecorded must NOT be set on a WARNING-only fire
const warnData = readWarnData(sessionId);
const criticalRecorded = warnData && warnData.criticalRecorded;
assert.ok(!criticalRecorded, 'WARNING-only fire must not set criticalRecorded');
});
// 'hook uses __dirname-based path (runtime-agnostic)' deleted per #453 (clock-seam):
// source-grep of HOOK_PATH for path.join(__dirname is brittle. The behavioral equivalent
// (hook successfully resolves gsd-tools.cjs from any working directory) is already covered
// by the runHook() helper throughout this test file — it calls the hook from an arbitrary
// tmpDir and all tests pass, proving __dirname-relative resolution works.
});
});
}
// ────────────────────────────────────────────────────────────────────────
// Folded from tests/bug-2451-context-monitor-over-report.test.cjs — consolidation epic #1969 (B6 #1975)
// ────────────────────────────────────────────────────────────────────────
{
const { describe: __foldDescribe } = require('node:test');
__foldDescribe("folded:bug-2451-context-monitor-over-report (consolidation epic #1969 B6 #1975)", () => {
/**
* Regression test for bug #2451
*
* The GSD context monitor hook over-reports usage by ~13 percentage points
* compared to Claude Code's native /context command. The root cause:
*
* gsd-statusline.js writes two values to the bridge file:
* - remaining_percentage: raw remaining from CC (e.g. 35%)
* - used_pct: normalized "usable" percentage (e.g. 78%) — accounts for
* the 16.5% autocompact buffer by scaling: (100 - remaining - buffer) /
* (100 - buffer) * 100
*
* gsd-context-monitor.js displays used_pct (78%) in warning messages.
* But CC's native /context shows raw used = 100 - remaining = 65%.
* The 13-point gap is exactly the buffer normalization overhead.
*
* Fix: the bridge must write used_pct as the raw value (Math.round(100 -
* remaining)), not the buffer-normalized value. The statusline progress bar
* continues to use the normalized value for its own display; only the bridge
* value that feeds the context monitor needs to be raw/CC-consistent.
*/
'use strict';
const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { execFileSync } = require('node:child_process');
const HOOK_PATH = path.join(__dirname, '..', 'hooks', 'gsd-statusline.js');
const MONITOR_PATH = path.join(__dirname, '..', 'hooks', 'gsd-context-monitor.js');
/**
* Run the statusline hook with a synthetic payload and return the full
* bridge JSON object written to /tmp/claude-ctx-{sessionId}.json.
*/
function runStatuslineHook(remainingPct, totalTokens = 1_000_000, acwEnv = null) {
const sessionId = `test-2451-${Date.now()}-${Math.random().toString(36).slice(2)}`;
const payload = JSON.stringify({
model: { display_name: 'Claude' },
workspace: { current_dir: os.tmpdir() },
session_id: sessionId,
context_window: {
remaining_percentage: remainingPct,
total_tokens: totalTokens,
},
});
const env = { ...process.env };
if (acwEnv != null) {
env.CLAUDE_CODE_AUTO_COMPACT_WINDOW = String(acwEnv);
} else {
delete env.CLAUDE_CODE_AUTO_COMPACT_WINDOW;
}
try {
execFileSync(process.execPath, [HOOK_PATH], {
input: payload,
env,
timeout: 4000,
});
} catch { /* non-zero exit is fine; we only need the bridge file */ }
const bridgePath = path.join(os.tmpdir(), `claude-ctx-${sessionId}.json`);
const bridge = JSON.parse(fs.readFileSync(bridgePath, 'utf-8'));
fs.unlinkSync(bridgePath);
return bridge;
}
/**
* Run the context monitor hook with a pre-written bridge file and return
* the parsed additionalContext string from its stdout.
*/
function runMonitorHook(remainingPct, usedPct) {
const sessionId = `test-2451-mon-${Date.now()}-${Math.random().toString(36).slice(2)}`;
const bridgePath = path.join(os.tmpdir(), `claude-ctx-${sessionId}.json`);
fs.writeFileSync(bridgePath, JSON.stringify({
session_id: sessionId,
remaining_percentage: remainingPct,
used_pct: usedPct,
timestamp: Math.floor(Date.now() / 1000),
}));
const input = JSON.stringify({ session_id: sessionId, cwd: os.tmpdir() });
let stdout = '';
try {
stdout = execFileSync(process.execPath, [MONITOR_PATH], {
input,
encoding: 'utf-8',
timeout: 5000,
});
} catch (e) {
stdout = e.stdout || '';
} finally {
try { fs.unlinkSync(bridgePath); } catch { /* noop */ }
try { fs.unlinkSync(path.join(os.tmpdir(), `claude-ctx-${sessionId}-warned.json`)); } catch { /* noop */ }
}
if (!stdout) return null;
const out = JSON.parse(stdout);
return out?.hookSpecificOutput?.additionalContext || null;
}
// ─── Bridge file used_pct accuracy ──────────────────────────────────────────
describe('bug #2451: bridge used_pct matches CC native reporting', () => {
test('used_pct is raw (100 - remaining), not buffer-normalized', () => {
// CC reports remaining_percentage=35 → CC native "used" = 100-35 = 65%
// Buffer-normalized would give: (100 - (35-16.5)/(100-16.5)*100) ≈ 78%
// The bridge used_pct must be 65 (raw), not 78 (normalized).
const bridge = runStatuslineHook(35);
assert.strictEqual(
bridge.used_pct,
65,
`used_pct should be 65 (raw: 100 - 35) but got ${bridge.used_pct}. ` +
'Buffer normalization must NOT be applied to the bridge used_pct, ' +
'otherwise context monitor messages over-report usage by ~13 points ' +
'compared to CC native /context (root cause of #2451).'
);
});
test('used_pct is raw for high remaining (low usage scenario)', () => {
// remaining=80 → raw used = 20
const bridge = runStatuslineHook(80);
assert.strictEqual(bridge.used_pct, 20,
`used_pct should be 20 (raw: 100-80) but got ${bridge.used_pct}`);
});
test('used_pct is raw for near-critical remaining', () => {
// remaining=20 → raw used = 80
const bridge = runStatuslineHook(20);
assert.strictEqual(bridge.used_pct, 80,
`used_pct should be 80 (raw: 100-20) but got ${bridge.used_pct}`);
});
test('remaining_percentage in bridge matches raw CC value', () => {
// The bridge remaining_percentage should be the exact raw value from CC
const bridge = runStatuslineHook(42);
assert.strictEqual(bridge.remaining_percentage, 42,
'bridge remaining_percentage must be the raw CC value (no normalization)');
});
});
// ─── Context monitor message accuracy ───────────────────────────────────────
describe('bug #2451: context monitor warning messages show CC-consistent percentages', () => {
test('WARNING message shows raw used_pct consistent with CC reporting', () => {
// remaining=30 → raw used=70; bridge stores used_pct=70
// Monitor message must say "Usage at 70%", not a buffer-inflated value
const msg = runMonitorHook(30, 70);
assert.ok(msg, 'hook should emit a warning when remaining=30 (below WARNING_THRESHOLD=35)');
assert.match(
msg,
/Usage at 70%/,
`Warning message should say "Usage at 70%" (raw), got: ${msg}`
);
});
test('CRITICAL message shows raw used_pct consistent with CC reporting', () => {
// remaining=20 → raw used=80
const msg = runMonitorHook(20, 80);
assert.ok(msg, 'hook should emit a critical warning when remaining=20 (below CRITICAL_THRESHOLD=25)');
assert.match(
msg,
/Usage at 80%/,
`Critical message should say "Usage at 80%" (raw), got: ${msg}`
);
});
test('gap between hook used_pct and raw CC value is at most 1 (rounding)', () => {
// With the fix, the only acceptable deviation is ±1 due to Math.round
const rawRemaining = 35;
const bridge = runStatuslineHook(rawRemaining);
const ccNativeUsed = 100 - rawRemaining; // 65
const gap = Math.abs(bridge.used_pct - ccNativeUsed);
assert.ok(
gap <= 1,
`Gap between hook used_pct (${bridge.used_pct}) and CC native used (${ccNativeUsed}) ` +
`is ${gap} points — must be ≤1 (rounding). Larger gaps indicate buffer normalization ` +
'is still being applied to bridge used_pct (root cause of #2451).'
);
});
});
});
}
// ────────────────────────────────────────────────────────────────────────
// Folded from tests/bug-925-context-monitor-hook-event-name.test.cjs — consolidation epic #1969 (B6 #1975)
// ────────────────────────────────────────────────────────────────────────
{
const { describe: __foldDescribe } = require('node:test');
__foldDescribe("folded:bug-925-context-monitor-hook-event-name (consolidation epic #1969 B6 #1975)", () => {
/**
* Regression test for bug #925
*
* hooks/gsd-context-monitor.js hardcodes `hookEventName: "PostToolUse"` (or
* "AfterTool" for Gemini) regardless of which hook event invoked it. Since
* PR #821 the same script is also registered under Stop, SubagentStop, and
* PreCompact in hooks/hooks.json. Claude Code rejects output whose
* hookSpecificOutput.hookEventName doesn't echo the triggering event:
*
* "expected Stop but got PostToolUse"
*
* Fix: derive hookEventName from the parsed stdin payload's `hook_event_name`
* field (already available in the data object), falling back to the
* Gemini / non-Gemini heuristic for runtimes that don't send it.
*/
'use strict';
const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { execFileSync } = require('node:child_process');
const MONITOR_PATH = path.join(__dirname, '..', 'hooks', 'gsd-context-monitor.js');
/**
* Write a bridge metrics file and invoke the context monitor with the given
* payload fields. Returns the parsed stdout object (or null if the hook
* produced no output).
*
* remainingPct must be <= 35 to cross the WARNING threshold so the hook
* actually emits output.
*/
function runMonitor({ hookEventName, sessionId, remainingPct = 30, usedPct = 70, env = {} }) {
const bridgePath = path.join(os.tmpdir(), `claude-ctx-${sessionId}.json`);
fs.writeFileSync(bridgePath, JSON.stringify({
session_id: sessionId,
remaining_percentage: remainingPct,
used_pct: usedPct,
timestamp: Math.floor(Date.now() / 1000),
}));
const payload = { session_id: sessionId, cwd: os.tmpdir() };
if (hookEventName !== undefined) {
payload.hook_event_name = hookEventName;
}
let stdout = '';
try {
stdout = execFileSync(process.execPath, [MONITOR_PATH], {
input: JSON.stringify(payload),
encoding: 'utf-8',
timeout: 5000,
env: { ...process.env, ...env },
});
} catch (e) {
stdout = e.stdout || '';
} finally {
try { fs.unlinkSync(bridgePath); } catch { /* noop */ }
try {
fs.unlinkSync(path.join(os.tmpdir(), `claude-ctx-${sessionId}-warned.json`));
} catch { /* noop */ }
}
if (!stdout) return null;
return JSON.parse(stdout);
}
function makeSessionId(suffix) {
return `test-925-${suffix}-${Date.now()}-${Math.random().toString(36).slice(2)}`;
}
// ─── hookEventName echoing ────────────────────────────────────────────────────
describe('bug #925: context monitor echoes the invoking hook event name', () => {
test('hookEventName is "Stop" when payload contains hook_event_name: "Stop"', () => {
const out = runMonitor({ hookEventName: 'Stop', sessionId: makeSessionId('stop') });
assert.ok(out, 'hook must emit output when context is below WARNING threshold (remaining=30)');
assert.strictEqual(
out.hookSpecificOutput?.hookEventName,
'Stop',
`Expected hookEventName "Stop" but got "${out.hookSpecificOutput?.hookEventName}". ` +
'The hook must echo the hook_event_name from stdin, not hardcode "PostToolUse".'
);
});
test('hookEventName is "SubagentStop" when payload contains hook_event_name: "SubagentStop"', () => {
const out = runMonitor({ hookEventName: 'SubagentStop', sessionId: makeSessionId('subagent-stop') });
assert.ok(out, 'hook must emit output when context is below WARNING threshold');
assert.strictEqual(
out.hookSpecificOutput?.hookEventName,
'SubagentStop',
`Expected hookEventName "SubagentStop" but got "${out.hookSpecificOutput?.hookEventName}".`
);
});
test('hookEventName is "PreCompact" when payload contains hook_event_name: "PreCompact"', () => {
const out = runMonitor({ hookEventName: 'PreCompact', sessionId: makeSessionId('precompact') });
assert.ok(out, 'hook must emit output when context is below WARNING threshold');
assert.strictEqual(
out.hookSpecificOutput?.hookEventName,
'PreCompact',
`Expected hookEventName "PreCompact" but got "${out.hookSpecificOutput?.hookEventName}".`
);
});
test('hookEventName is "PostToolUse" when payload contains hook_event_name: "PostToolUse"', () => {
const out = runMonitor({ hookEventName: 'PostToolUse', sessionId: makeSessionId('posttools') });
assert.ok(out, 'hook must emit output when context is below WARNING threshold');
assert.strictEqual(
out.hookSpecificOutput?.hookEventName,
'PostToolUse',
`Expected hookEventName "PostToolUse" but got "${out.hookSpecificOutput?.hookEventName}".`
);
});
});
// ─── Fallback behaviour (no hook_event_name in payload) ──────────────────────
describe('bug #925: context monitor falls back to heuristic when hook_event_name absent', () => {
test('falls back to "PostToolUse" when hook_event_name is absent (non-Gemini)', () => {
const env = { ...process.env };
delete env.GEMINI_API_KEY;
const out = runMonitor({
hookEventName: undefined,
sessionId: makeSessionId('fallback-non-gemini'),
env: { GEMINI_API_KEY: '' }, // ensure unset
});
assert.ok(out, 'hook must emit output when context is below WARNING threshold');
assert.strictEqual(
out.hookSpecificOutput?.hookEventName,
'PostToolUse',
`Expected fallback "PostToolUse" for non-Gemini but got "${out.hookSpecificOutput?.hookEventName}".`
);
});
test('falls back to "AfterTool" when hook_event_name is absent and GEMINI_API_KEY is set', () => {
const out = runMonitor({
hookEventName: undefined,
sessionId: makeSessionId('fallback-gemini'),
env: { GEMINI_API_KEY: 'fake-key-for-test' },
});
assert.ok(out, 'hook must emit output when context is below WARNING threshold');
assert.strictEqual(
out.hookSpecificOutput?.hookEventName,
'AfterTool',
`Expected fallback "AfterTool" for Gemini but got "${out.hookSpecificOutput?.hookEventName}".`
);
});
test('falls back to "PostToolUse" when hook_event_name is an empty string (non-Gemini)', () => {
const out = runMonitor({
hookEventName: '',
sessionId: makeSessionId('fallback-empty'),
env: { GEMINI_API_KEY: '' },
});
assert.ok(out, 'hook must emit output when context is below WARNING threshold');
assert.strictEqual(
out.hookSpecificOutput?.hookEventName,
'PostToolUse',
`Expected fallback "PostToolUse" for empty hook_event_name but got "${out.hookSpecificOutput?.hookEventName}".`
);
});
test('falls back to "PostToolUse" when hook_event_name is whitespace-only (non-Gemini)', () => {
// trim() makes " " → "" which is falsy, so the || fallback fires
const out = runMonitor({
hookEventName: ' ',
sessionId: makeSessionId('fallback-whitespace'),
env: { GEMINI_API_KEY: '' },
});
assert.ok(out, 'hook must emit output when context is below WARNING threshold');
assert.strictEqual(
out.hookSpecificOutput?.hookEventName,
'PostToolUse',
`Expected fallback "PostToolUse" for whitespace-only hook_event_name but got "${out.hookSpecificOutput?.hookEventName}".`
);
});
});
// ─── Critical threshold also echoes the event name ───────────────────────────
describe('bug #925: critical threshold warning also uses correct hookEventName', () => {
test('CRITICAL warning emitted under Stop also echoes "Stop"', () => {
const out = runMonitor({
hookEventName: 'Stop',
sessionId: makeSessionId('critical-stop'),
remainingPct: 20,
usedPct: 80,
});
assert.ok(out, 'hook must emit output at critical threshold (remaining=20)');
assert.strictEqual(
out.hookSpecificOutput?.hookEventName,
'Stop',
`Expected hookEventName "Stop" at critical threshold, got "${out.hookSpecificOutput?.hookEventName}".`
);
assert.match(
out.hookSpecificOutput?.additionalContext || '',
/CONTEXT CRITICAL/,
'Output should be a CRITICAL warning at remaining=20'
);
});
});
});
}

View File

@@ -254,3 +254,253 @@ describe('gsd-read-guard hook', () => {
assert.equal(result.stdout, '', 'should produce no output on Claude Code');
});
});
// ────────────────────────────────────────────────────────────────────────
// Folded from tests/bug-2344-read-guard-claudecode-env.test.cjs — consolidation epic #1969 (B6 #1975)
// ────────────────────────────────────────────────────────────────────────
{
const { describe: __foldDescribe } = require('node:test');
__foldDescribe("folded:bug-2344-read-guard-claudecode-env (consolidation epic #1969 B6 #1975)", () => {
/**
* Regression test for bug #2344
*
* gsd-read-guard.js checked process.env.CLAUDE_SESSION_ID to detect the
* Claude Code runtime and skip its advisory. However, Claude Code CLI exports
* CLAUDECODE=1, not CLAUDE_SESSION_ID. The skip never fired, so the
* READ-BEFORE-EDIT advisory injected on every Edit/Write call inside Claude
* Code — producing noise in long-running sessions.
*
* Fix: check CLAUDECODE (and CLAUDE_SESSION_ID for back-compat) before
* emitting the advisory.
*/
process.env.GSD_TEST_MODE = '1';
const { test, describe, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { execFileSync } = require('node:child_process');
const { createTempDir, cleanup } = require('./helpers.cjs');
const HOOK_PATH = path.join(__dirname, '..', 'hooks', 'gsd-read-guard.js');
function runHook(payload, envOverrides = {}) {
const input = JSON.stringify(payload);
const env = {
...process.env,
CLAUDE_SESSION_ID: '',
CLAUDECODE: '',
CLAUDE_CODE_ENTRYPOINT: '',
CLAUDE_CODE_SSE_PORT: '',
CLAUDE_PROJECT_DIR: '',
...envOverrides,
};
try {
const stdout = execFileSync(process.execPath, [HOOK_PATH], {
input,
encoding: 'utf-8',
timeout: 5000,
stdio: ['pipe', 'pipe', 'pipe'],
env,
});
return { exitCode: 0, stdout: stdout.trim(), stderr: '' };
} catch (err) {
return {
exitCode: err.status ?? 1,
stdout: (err.stdout || '').toString().trim(),
stderr: (err.stderr || '').toString().trim(),
};
}
}
describe('bug #2344: read guard skips on CLAUDECODE env var', () => {
let tmpDir;
beforeEach(() => { tmpDir = createTempDir('gsd-read-guard-2344-'); });
afterEach(() => { cleanup(tmpDir); });
test('skips advisory when CLAUDECODE=1 is set (Claude Code CLI env)', () => {
const filePath = path.join(tmpDir, 'existing.js');
fs.writeFileSync(filePath, 'const x = 1;\n');
const result = runHook(
{ tool_name: 'Edit', tool_input: { file_path: filePath, old_string: 'const x = 1;', new_string: 'const x = 2;' } },
{ CLAUDECODE: '1' }
);
assert.equal(result.exitCode, 0);
assert.equal(result.stdout, '', 'advisory must not fire when CLAUDECODE=1');
});
test('skips advisory when CLAUDE_SESSION_ID is set (back-compat)', () => {
const filePath = path.join(tmpDir, 'existing.js');
fs.writeFileSync(filePath, 'const x = 1;\n');
const result = runHook(
{ tool_name: 'Edit', tool_input: { file_path: filePath, old_string: 'const x = 1;', new_string: 'const x = 2;' } },
{ CLAUDE_SESSION_ID: 'test-session-123' }
);
assert.equal(result.exitCode, 0);
assert.equal(result.stdout, '', 'advisory must not fire when CLAUDE_SESSION_ID is set');
});
test('still injects advisory when neither CLAUDECODE nor CLAUDE_SESSION_ID is set', () => {
const filePath = path.join(tmpDir, 'existing.js');
fs.writeFileSync(filePath, 'const x = 1;\n');
const result = runHook(
{ tool_name: 'Edit', tool_input: { file_path: filePath, old_string: 'const x = 1;', new_string: 'const x = 2;' } },
{ CLAUDECODE: '', CLAUDE_SESSION_ID: '' }
);
assert.equal(result.exitCode, 0);
assert.ok(result.stdout.length > 0, 'advisory should fire on non-Claude-Code runtimes');
const output = JSON.parse(result.stdout);
assert.ok(output.hookSpecificOutput?.additionalContext?.includes('Read'));
});
});
});
}
// ────────────────────────────────────────────────────────────────────────
// Folded from tests/bug-2520-read-guard-hook-subprocess-env.test.cjs — consolidation epic #1969 (B6 #1975)
// ────────────────────────────────────────────────────────────────────────
{
const { describe: __foldDescribe } = require('node:test');
__foldDescribe("folded:bug-2520-read-guard-hook-subprocess-env (consolidation epic #1969 B6 #1975)", () => {
/**
* Regression test for bug #2520
*
* The fix for #2344 added `|| process.env.CLAUDECODE` to the Claude Code
* skip check. That works in principle — CLAUDECODE=1 is propagated to Bash
* tool subprocesses — but it does NOT reach hook subprocesses on Claude Code
* v2.1.116. Claude Code applies a separate env filter when spawning
* PreToolUse hook commands; that filter drops bare CLAUDECODE and
* CLAUDE_SESSION_ID and keeps only CLAUDE_CODE_*-prefixed vars plus
* CLAUDE_PROJECT_DIR. `data.session_id` is, however, reliably delivered via
* the hook's stdin JSON payload (documented part of Claude Code's hook
* input schema).
*
* Fix: use `data.session_id` as the primary Claude Code signal, with
* CLAUDE_CODE_ENTRYPOINT / CLAUDE_CODE_SSE_PORT as env-var fallbacks, and
* keep legacy CLAUDECODE / CLAUDE_SESSION_ID for back-compat and
* future-proofing.
*/
process.env.GSD_TEST_MODE = '1';
const { test, describe, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { execFileSync } = require('node:child_process');
const { createTempDir, cleanup } = require('./helpers.cjs');
const HOOK_PATH = path.join(__dirname, '..', 'hooks', 'gsd-read-guard.js');
/**
* Spawn the hook with an env that mirrors the actual Claude Code hook
* subprocess env: CLAUDECODE and CLAUDE_SESSION_ID are stripped, only
* CLAUDE_CODE_*-prefixed vars (plus CLAUDE_PROJECT_DIR) remain. Extra env
* overrides can be supplied via `envOverrides`.
*/
function runHookInClaudeCodeSubprocess(payload, envOverrides = {}) {
const input = JSON.stringify(payload);
const baseEnv = { ...process.env };
// Strip env vars Claude Code does NOT propagate to hook subprocesses.
delete baseEnv.CLAUDECODE;
delete baseEnv.CLAUDE_SESSION_ID;
const env = {
...baseEnv,
// Env vars Claude Code DOES propagate to hook subprocesses (observed on
// Claude Code CLI 2.1.116).
CLAUDE_CODE_ENTRYPOINT: 'cli',
CLAUDE_CODE_SSE_PORT: '51291',
CLAUDE_PROJECT_DIR: process.cwd(),
...envOverrides,
};
try {
const stdout = execFileSync(process.execPath, [HOOK_PATH], {
input,
encoding: 'utf-8',
timeout: 5000,
stdio: ['pipe', 'pipe', 'pipe'],
env,
});
return { exitCode: 0, stdout: stdout.trim(), stderr: '' };
} catch (err) {
return {
exitCode: err.status ?? 1,
stdout: (err.stdout || '').toString().trim(),
stderr: (err.stderr || '').toString().trim(),
};
}
}
describe('bug #2520: read guard detects Claude Code without relying on CLAUDECODE env', () => {
let tmpDir;
beforeEach(() => { tmpDir = createTempDir('gsd-read-guard-2520-'); });
afterEach(() => { cleanup(tmpDir); });
test('skips advisory when stdin payload includes session_id (Claude Code hook-subprocess env)', () => {
const filePath = path.join(tmpDir, 'existing.js');
fs.writeFileSync(filePath, 'const x = 1;\n');
// Isolate the stdin `session_id` signal by clearing the CLAUDE_CODE_*
// env fallbacks the helper normally provides. Without this the env
// fallback would rescue the skip even if session_id detection broke,
// hiding a regression of the primary signal.
const result = runHookInClaudeCodeSubprocess(
{
session_id: 'e7123e54-0977-45dd-848a-b9c8a45a5cd3',
tool_name: 'Edit',
tool_input: { file_path: filePath, old_string: 'const x = 1;', new_string: 'const x = 2;' },
},
{ CLAUDE_CODE_ENTRYPOINT: '', CLAUDE_CODE_SSE_PORT: '', CLAUDE_PROJECT_DIR: '' },
);
assert.equal(result.exitCode, 0);
assert.equal(
result.stdout,
'',
'advisory must not fire when session_id is present on stdin (real Claude Code hook env)',
);
});
test('skips advisory when CLAUDE_CODE_ENTRYPOINT is set (env-var fallback, no session_id on stdin)', () => {
const filePath = path.join(tmpDir, 'existing.js');
fs.writeFileSync(filePath, 'const x = 1;\n');
const result = runHookInClaudeCodeSubprocess(
{ tool_name: 'Edit', tool_input: { file_path: filePath, old_string: 'const x = 1;', new_string: 'const x = 2;' } },
{ CLAUDE_CODE_ENTRYPOINT: 'cli', CLAUDE_CODE_SSE_PORT: '' },
);
assert.equal(result.exitCode, 0);
assert.equal(result.stdout, '', 'advisory must not fire when CLAUDE_CODE_ENTRYPOINT is set');
});
test('still injects advisory when no Claude Code signal is present (non-Claude host)', () => {
const filePath = path.join(tmpDir, 'existing.js');
fs.writeFileSync(filePath, 'const x = 1;\n');
const result = runHookInClaudeCodeSubprocess(
{ tool_name: 'Edit', tool_input: { file_path: filePath, old_string: 'const x = 1;', new_string: 'const x = 2;' } },
{ CLAUDE_CODE_ENTRYPOINT: '', CLAUDE_CODE_SSE_PORT: '', CLAUDE_PROJECT_DIR: '' },
);
assert.equal(result.exitCode, 0);
assert.ok(result.stdout.length > 0, 'advisory should fire on non-Claude-Code hosts');
const output = JSON.parse(result.stdout);
assert.ok(output.hookSpecificOutput?.additionalContext?.includes('Read'));
});
});
});
}

View File

@@ -178,3 +178,257 @@ describe('release-tarball-smoke', () => {
);
});
});
// ────────────────────────────────────────────────────────────────────────
// Folded from tests/bug-131-release-tarball-smoke-explicit-home.test.cjs — consolidation epic #1969 (B6 #1975)
// ────────────────────────────────────────────────────────────────────────
{
const { describe: __foldDescribe } = require('node:test');
__foldDescribe("folded:bug-131-release-tarball-smoke-explicit-home (consolidation epic #1969 B6 #1975)", () => {
// allow-test-rule: integration-test-input (see #131)
// Regression test for #131: runNpm() must not fail when HOME points at an
// unwritable directory. The before() hook in release-tarball-smoke.install.test.cjs
// calls runNpm(['pack', ...]) and runNpm(['install', '-g', ...]) — if those inherit
// an unwritable HOME from the environment (common in constrained Docker hosts),
// the entire hook fails and all 6 subtests are cancelled.
//
// Fix: runNpm() must inject an explicit HOME, npm_config_cache, and
// npm_config_userconfig that point into a temp directory it owns, so that npm
// never reads from or writes to the caller's HOME.
//
// Test 3 (added in the second fix pass) verifies that isolatedNpmEnv() — the
// companion export that lets runSmoke() apply the same isolation — also redirects
// HOME away from the caller's HOME. Without this, subtests A-F of
// release-tarball-smoke.install.test.cjs still fail because runSmoke() calls
// spawnSync('npm', ...) internally and was not covered by the runNpm() fix.
'use strict';
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { execFileSync } = require('node:child_process');
// The helpers under test.
const { isolatedNpmEnv, cleanup } = require('./helpers.cjs');
// Resolve a filesystem path to its canonical (symlink-free) form even if the
// leaf does not exist yet (e.g. ~/.npm before npm has written its cache).
// Walks up to the nearest existing ancestor, resolves that, then re-appends
// the trailing segments. This handles macOS /var → /private/var symlinks for
// paths created under os.tmpdir() where the leaf directory may not exist yet.
function safeRealpath(p) {
try {
return fs.realpathSync(p);
} catch (_) {
// Leaf does not exist — resolve the nearest existing ancestor then
// reconstruct the original suffix so the result is still canonical.
const segments = [];
let cur = p;
for (;;) {
const parent = path.dirname(cur);
if (parent === cur) {
// Reached filesystem root — return original path unchanged.
return p;
}
segments.unshift(path.basename(cur));
cur = parent;
try {
return path.join(fs.realpathSync(cur), ...segments);
} catch (__) {
// Keep walking up.
}
}
}
}
describe('bug-131: runNpm isolates HOME from the caller environment', () => {
// ── Test 1 — runNpm works with an unwritable HOME ────────────────────────
// Spawn a child Node process that sets HOME to a chmod-0500 directory, then
// invokes runNpm(['--version']). Without the fix, npm tries to read/write
// HOME/.npmrc and HOME/.npm, fails with EACCES, and runNpm throws.
// With the fix, runNpm injects its own isolated HOME and npm succeeds.
test('runNpm succeeds even when process HOME is unwritable', () => {
// Create an unwritable dir to serve as a poisoned HOME.
const poisonedHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-bug131-poison-'));
try {
fs.chmodSync(poisonedHome, 0o500); // r-x only — not writable
// We exercise the real runNpm() path by running a tiny inline Node script
// that requires helpers.cjs and calls runNpm(['--version']) with HOME set
// to the unwritable dir. The script exits 0 on success, non-zero on throw.
const script = `
process.env.HOME = ${JSON.stringify(poisonedHome)};
process.env.USERPROFILE = ${JSON.stringify(poisonedHome)};
const { runNpm } = require(${JSON.stringify(path.join(__dirname, 'helpers.cjs'))});
try {
const out = runNpm(['--version']);
if (!out || out.trim() === '') process.exit(2); // vacuous success guard
process.stdout.write(out);
process.exit(0);
} catch (e) {
process.stderr.write(e.message + '\\n');
process.exit(1);
}
`;
let stdout = '';
let stderr = '';
let exitCode = 0;
try {
stdout = execFileSync(process.execPath, ['-e', script], {
encoding: 'utf-8',
timeout: 30_000,
});
} catch (err) {
stdout = err.stdout || '';
stderr = err.stderr || '';
exitCode = err.status ?? 1;
}
assert.equal(
exitCode,
0,
`runNpm should succeed with an unwritable HOME but exited ${exitCode}. stderr: ${stderr}`,
);
// npm --version returns something like "10.x.y"
assert.match(
stdout.trim(),
/^\d+\.\d+/,
`expected semver output from npm --version, got: ${stdout}`,
);
} finally {
// Restore write permission before cleanup so the directory can be deleted.
try { fs.chmodSync(poisonedHome, 0o700); } catch (_) { /* best-effort */ }
cleanup(poisonedHome);
}
});
// ── Test 2 — runNpm does not leak a caller-supplied HOME into npm ────────
// Even if the caller exports HOME=/some/real/path, the injected HOME must be
// a different (temp) path so npm writes never touch the caller's $HOME.
test('runNpm injects a HOME distinct from process.env.HOME', () => {
// Capture what HOME runNpm actually passes to npm by asking npm to print
// the value it sees for the $HOME env var. We do this via `npm config get
// cache` which reveals the cache path — if it's under process.env.HOME,
// the fix is absent; if it's under a tmp dir, the fix is present.
const script = `
const { runNpm } = require(${JSON.stringify(path.join(__dirname, 'helpers.cjs'))});
try {
// npm config get cache prints the effective cache directory.
const out = runNpm(['config', 'get', 'cache']);
process.stdout.write(out.trim());
process.exit(0);
} catch (e) {
process.stderr.write(e.message + '\\n');
process.exit(1);
}
`;
let stdout = '';
let stderr = '';
let exitCode = 0;
try {
stdout = execFileSync(process.execPath, ['-e', script], {
encoding: 'utf-8',
timeout: 30_000,
});
} catch (err) {
stdout = err.stdout || '';
stderr = err.stderr || '';
exitCode = err.status ?? 1;
}
assert.equal(
exitCode,
0,
`runNpm config get cache failed with exit ${exitCode}. stderr: ${stderr}`,
);
const effectiveCacheDir = stdout.trim();
// The effective npm cache must NOT be inside the calling process's HOME.
// If it is, the fix was not applied and the Docker regression can still occur.
const callerHome = os.homedir();
assert.ok(
!effectiveCacheDir.startsWith(callerHome),
`npm cache dir ${effectiveCacheDir} is still under caller HOME ${callerHome} — fix not applied`,
);
// It must be somewhere under the system tmp dir, confirming isolation.
// Use safeRealpath on both sides so that macOS /var→/private/var symlinks
// do not cause a false mismatch when os.tmpdir() and the resolved cache
// path differ only in symlink expansion. The cache sub-directory (.npm) may
// not exist yet; safeRealpath walks up to the nearest existing ancestor.
const sysTmp = safeRealpath(os.tmpdir());
const realCacheDir = safeRealpath(effectiveCacheDir);
assert.ok(
realCacheDir.startsWith(sysTmp),
`npm cache dir ${realCacheDir} should be under tmpdir ${sysTmp}`,
);
});
// ── Test 3 — isolatedNpmEnv() redirects HOME away from the caller's HOME ──
// runSmoke() calls spawnSync('npm', ...) with npmEnv from isolatedNpmEnv().
// If isolatedNpmEnv() didn't redirect HOME, subtests A-F would still fail on
// Docker hosts with an unwritable HOME (the original bug #131 root cause,
// manifesting via the sibling runSmoke() path). (#131)
test('isolatedNpmEnv() HOME is distinct from the caller HOME and lives under tmpdir', () => {
const env = isolatedNpmEnv();
// Must expose a HOME key.
assert.ok(
typeof env.HOME === 'string' && env.HOME.length > 0,
'isolatedNpmEnv() must set HOME',
);
// Must not be the caller's HOME.
const callerHome = os.homedir();
assert.notEqual(
env.HOME,
callerHome,
`isolatedNpmEnv() HOME must differ from caller HOME ${callerHome}`,
);
// Must live under the system tmpdir, confirming it is an isolated temp directory.
// Use safeRealpath on both sides so that macOS /var→/private/var symlinks
// do not cause a false mismatch.
const sysTmp = safeRealpath(os.tmpdir());
const realHome = safeRealpath(env.HOME);
assert.ok(
realHome.startsWith(sysTmp),
`isolatedNpmEnv() HOME ${realHome} should be under tmpdir ${sysTmp}`,
);
// npm_config_cache and npm_config_userconfig must also be set and under the isolated HOME.
assert.ok(
typeof env.npm_config_cache === 'string' && env.npm_config_cache.startsWith(env.HOME),
`npm_config_cache ${env.npm_config_cache} should be under isolated HOME ${env.HOME}`,
);
assert.ok(
typeof env.npm_config_userconfig === 'string' && env.npm_config_userconfig.startsWith(env.HOME),
`npm_config_userconfig ${env.npm_config_userconfig} should be under isolated HOME ${env.HOME}`,
);
assert.equal(
env.npm_config_loglevel,
'error',
'isolatedNpmEnv() should suppress npm notice/warn chatter in test gates',
);
assert.equal(
env.npm_config_update_notifier,
'false',
'isolatedNpmEnv() should disable npm update-notifier notices in test gates',
);
assert.equal(
env.NO_UPDATE_NOTIFIER,
'1',
'isolatedNpmEnv() should disable npm update-notifier notices for npm versions that honor NO_UPDATE_NOTIFIER',
);
});
});
});
}

View File

@@ -761,3 +761,509 @@ describe('parseShardArg (#1212)', () => {
});
}
});
// ────────────────────────────────────────────────────────────────────────
// Folded from tests/bug-969-test-infra-flake-hardening.test.cjs — consolidation epic #1969 (B6 #1975)
// ────────────────────────────────────────────────────────────────────────
{
const { describe: __foldDescribe } = require('node:test');
__foldDescribe("folded:bug-969-test-infra-flake-hardening (consolidation epic #1969 B6 #1975)", () => {
'use strict';
/**
* Regression tests for bug #969 — test-infra flake hardening.
*
* Two root causes addressed:
*
* A. SIGNATURE A: "X is not a function"
* ensureBuiltArtifacts() previously short-circuited on a single sentinel
* (semver-compare.cjs). If any other migrated .cjs was stale or absent,
* it would be silently loaded in that broken state. This test proves the
* unconditional-build fix: deleting a non-sentinel artifact and invoking
* ensureBuiltArtifacts() regenerates it even when the sentinel is present.
*
* B. SIGNATURE B: misleading assertion failures from killed subprocesses
* runGsdTools() previously had no timeout, so an OOM/SIGKILL'd subprocess
* returned { success: false } and looked like a product error. This test
* proves the kill-discrimination fix: a killed/timed-out invocation now
* throws a labeled resource-starvation error, while a clean non-zero exit
* still returns { success: false, exitCode: N }.
*
* C. SIGNATURE C: "Failed to install hooks: directory is empty" in scoped CI
* hooks/dist is gitignored and NOT built by `prepare` (build:lib only), so
* the scoped test lane starts with it absent. The first install test's
* before() hook triggers build-hooks.js, which creates DIST_DIR empty then
* fills it file-by-file — a window where a concurrently-spawned install
* reader sees zero hooks and hard-fails. ensureBuiltHooks() builds hooks/dist
* ONCE upfront (same chokepoint as ensureBuiltArtifacts) so the empty window
* never exists during concurrent test execution. These tests prove it
* rebuilds when dist is absent/empty/incomplete and no-ops when complete.
*
* RULESET.TESTS.regression-must-fail-first: each test section documents what
* the old behavior would have been (fail-before) and asserts the new behavior
* (pass-after), using only behavioral invocations — no source-grep.
*/
const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
const path = require('node:path');
const fs = require('node:fs');
const os = require('node:os');
const { execFileSync } = require('node:child_process');
const { ensureBuiltArtifacts, ensureBuiltHooks } = require('../scripts/run-tests.cjs');
const { cleanup } = require('./helpers.cjs');
// ---------------------------------------------------------------------------
// Part A — ensureBuiltArtifacts: unconditional rebuild
// ---------------------------------------------------------------------------
describe('bug #969 A — ensureBuiltArtifacts rebuilds stale artifacts', () => {
/**
* Helper: create a self-contained temp TypeScript project with two source files
* (sentinelmod.cts and targetmod.cts) and a tsconfig that emits to <tmp>/out.
* Returns { tmp, overrides, sentinelOut, targetOut, tsBuildInfoPath }.
*
* HERMETIC: all destructive tests use this helper. They NEVER touch the real
* gsd-core/bin/lib/*.cjs or the real tsbuildinfo. (Regression from #996 fixed here.)
*/
function makeTempProject() {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-bug969-'));
const srcDir = path.join(tmp, 'src');
const outDir = path.join(tmp, 'out');
const tsBuildInfoPath = path.join(outDir, '.tsbuildinfo');
const tsconfigPath = path.join(tmp, 'tsconfig.build.json');
fs.mkdirSync(srcDir, { recursive: true });
fs.mkdirSync(outDir, { recursive: true });
fs.writeFileSync(path.join(srcDir, 'sentinelmod.cts'), 'export const sentinelValue = 1;\n');
fs.writeFileSync(path.join(srcDir, 'targetmod.cts'), 'export const targetValue = 2;\n');
fs.writeFileSync(tsconfigPath, JSON.stringify({
compilerOptions: {
rootDir: 'src',
outDir: 'out',
module: 'commonjs',
target: 'es2022',
esModuleInterop: true,
noEmitOnError: true,
incremental: true,
tsBuildInfoFile: 'out/.tsbuildinfo',
},
include: ['src/**/*.cts'],
}, null, 2));
const overrides = { root: tmp, srcDir, outDir, tsBuildInfoPath, tsconfigPath };
const sentinelOut = path.join(outDir, 'sentinelmod.cjs');
const targetOut = path.join(outDir, 'targetmod.cjs');
return { tmp, overrides, sentinelOut, targetOut, tsBuildInfoPath };
}
/**
* FAIL-BEFORE (origin/next behavior):
* The old code contained `if (existsSync(sentinel)) return;`. When the
* sentinel (semver-compare.cjs) was present, the function returned early
* without touching any other .cjs. This test confirms the new code always
* invokes tsc — it would have returned immediately on origin/next.
*
* Specifically: on origin/next, after deleting a non-sentinel artifact +
* its tsbuildinfo and calling ensureBuiltArtifacts() with sentinel present,
* the artifact would remain absent. On the fix, tsc runs unconditionally
* and recreates it.
*
* PASS-AFTER (fix):
* The sentinel guard is removed. ensureBuiltArtifacts() always invokes tsc.
* With no tsbuildinfo present (clean state), tsc performs a full emit and
* recreates all .cjs outputs including the deleted non-sentinel artifact.
*
* HERMETIC: this test operates on a self-contained temp project. It NEVER
* touches gsd-core/bin/lib/core.cjs or the real tsbuildinfo. (Fixed from #996.)
*/
test('rebuilds a non-sentinel artifact (with no tsbuildinfo) even when sentinel exists', () => {
const { tmp, overrides, sentinelOut, targetOut, tsBuildInfoPath } = makeTempProject();
try {
// Initial build — both outputs must appear.
ensureBuiltArtifacts(overrides);
assert.ok(fs.existsSync(sentinelOut), 'initial build: sentinelmod.cjs must exist');
assert.ok(fs.existsSync(targetOut), 'initial build: targetmod.cjs must exist');
// Simulate: fresh CI checkout — target artifact missing, no tsbuildinfo.
fs.unlinkSync(targetOut);
if (fs.existsSync(tsBuildInfoPath)) fs.unlinkSync(tsBuildInfoPath);
assert.ok(!fs.existsSync(targetOut), 'pre-condition: targetmod.cjs must be absent');
assert.ok(fs.existsSync(sentinelOut), 'pre-condition: sentinelmod.cjs must still be present');
// Under the OLD code this returned immediately (sentinel present → return).
// Under the NEW code this calls tsc unconditionally → full emit → recreated.
ensureBuiltArtifacts(overrides);
assert.ok(
fs.existsSync(targetOut),
'ensureBuiltArtifacts must recreate targetmod.cjs even when sentinelmod.cjs ' +
'exists (sentinel-short-circuit was removed in fix #969)'
);
} finally {
cleanup(tmp);
}
});
/**
* PASS-AFTER: the unconditional build emits the expected output (sentinelmod.cjs).
* Uses the temp project helper so this test is fully hermetic — it never touches
* the real gsd-core/bin/lib tree.
*/
test('sentinel (semver-compare.cjs) still exists after unconditional build', () => {
const { tmp, overrides, sentinelOut } = makeTempProject();
try {
ensureBuiltArtifacts(overrides);
assert.ok(fs.existsSync(sentinelOut), 'sentinel output (sentinelmod.cjs) must exist after ensureBuiltArtifacts');
} finally {
cleanup(tmp);
}
});
/**
* PERSISTENT-MIRROR CASE — the residual hole found by adversarial review.
*
* FAIL-BEFORE (incremental: true — the old behavior on this branch):
* With "incremental": true in tsconfig.build.json, tsc reads the .tsbuildinfo
* on disk. If sources are unchanged since the last build, tsc skips re-emitting
* any outputs — including outputs that were deleted or overwritten by an rsync
* from a different branch. This is the persistent-docker-mirror scenario:
* 1. A prior branch rsync'd a stale core.cjs into bin/lib/
* 2. A stale tsbuildinfo is present (from that same branch)
* 3. ensureBuiltArtifacts() calls tsc (incremental)
* 4. tsc sees "sources unchanged vs tsbuildinfo" → no-ops → stale .cjs served
* With "incremental": true this test would FAIL because targetmod.cjs remains absent.
*
* PASS-AFTER (step-3 unlink+clean-reemit logic):
* When a missing/zero-bytes output is detected after the incremental pass,
* ensureBuiltArtifacts() unlinks the tsbuildinfo and runs tsc a second time
* (clean re-emit). The stale/missing output is always regenerated.
*
* HERMETIC: this test operates on a self-contained temp project. It NEVER
* touches gsd-core/bin/lib/core.cjs or the real tsbuildinfo. (Fixed from #996.)
*/
test('PERSISTENT-MIRROR: rebuilds stale output even when tsbuildinfo is present (non-incremental is authoritative)', () => {
const { tmp, overrides, targetOut, tsBuildInfoPath } = makeTempProject();
const STALE_TSBUILDINFO = JSON.stringify({
program: { fileNames: [], options: { incremental: true } },
version: '5.0.0',
_gsd_test_marker: 'stale-persistent-mirror',
});
try {
// Initial build to populate outputs.
ensureBuiltArtifacts(overrides);
assert.ok(fs.existsSync(targetOut), 'initial build: targetmod.cjs must exist');
// Inject a stale tsbuildinfo (mirrors: old branch rsync'd state onto workspace).
fs.writeFileSync(tsBuildInfoPath, STALE_TSBUILDINFO);
// Delete the output .cjs (mirrors: stale/missing output on the persistent mirror).
fs.unlinkSync(targetOut);
assert.ok(!fs.existsSync(targetOut), 'pre-condition: targetmod.cjs must be absent');
assert.ok(fs.existsSync(tsBuildInfoPath), 'pre-condition: tsbuildinfo must be present');
// FAIL-BEFORE (incremental: true, no step-3): tsc would read the stale
// tsbuildinfo, see "sources unchanged", and skip re-emitting targetmod.cjs
// → it would remain absent.
//
// PASS-AFTER (step-3 unlink+clean-reemit): missing output detected after
// incremental pass → tsbuildinfo unlinked → tsc runs again → targetmod.cjs
// is regenerated unconditionally.
ensureBuiltArtifacts(overrides);
assert.ok(
fs.existsSync(targetOut),
'ensureBuiltArtifacts must regenerate targetmod.cjs even when a stale ' +
'tsbuildinfo is present on disk (persistent-mirror scenario — ' +
'incremental:true alone would have no-op\'d here)'
);
// Verify the regenerated file is valid JS.
const regenerated = fs.readFileSync(targetOut, 'utf-8');
assert.ok(regenerated.length > 0, 'regenerated targetmod.cjs must be non-empty');
assert.ok(
regenerated.includes('exports.') || regenerated.includes('"use strict"'),
'regenerated targetmod.cjs must look like a valid CommonJS module'
);
} finally {
cleanup(tmp);
}
});
});
// ---------------------------------------------------------------------------
// Part B — runGsdTools: timeout + kill-signal discrimination
// ---------------------------------------------------------------------------
describe('bug #969 B — runGsdTools kill-signal discrimination', () => {
const TOOLS_PATH = path.join(__dirname, '..', 'gsd-core', 'bin', 'gsd-tools.cjs');
/**
* Shared helper that mirrors the production runGsdTools implementation
* (from tests/helpers.cjs) but accepts an explicit timeout so we can
* trigger the kill path in tests without waiting 60 seconds.
*
* IMPORTANT: this helper is intentionally self-contained so that the test
* proves the CONTRACT of the implementation, not just calls the real
* runGsdTools (which would need a real 60s+ hang to trigger in tests).
* We test the identical logic paths using a tiny timeout.
*/
function runGsdToolsWithTimeout(args, cwd, env, timeoutMs) {
const TEST_ENV_BASE = {
GSD_SESSION_KEY: '',
CODEX_THREAD_ID: '',
CLAUDE_SESSION_ID: '',
};
try {
let result;
const childEnv = { ...process.env, ...TEST_ENV_BASE, ...(env || {}) };
const argv = Array.isArray(args)
? args
: (args.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) || [])
.map(t => t.replace(/"([^"]*)"/g, '$1').replace(/'([^']*)'/g, '$1'));
result = execFileSync(process.execPath, [TOOLS_PATH, ...argv], {
cwd: cwd || process.cwd(),
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe'],
env: childEnv,
timeout: timeoutMs,
});
return { success: true, output: result.trim(), exitCode: 0 };
} catch (err) {
// Production kill-discrimination logic (verbatim from helpers.cjs fix).
if (err.killed || err.signal != null || err.code === 'ETIMEDOUT') {
throw new Error(
`[runGsdTools: resource-starvation / subprocess-kill] ` +
`gsd-tools was killed before completion ` +
`(signal=${err.signal}, code=${err.code}, killed=${err.killed}). ` +
`This indicates host OOM or scheduler contention, not a product bug. ` +
`stdout=${err.stdout?.toString().trim() || ''} ` +
`stderr=${err.stderr?.toString().trim() || ''}`
);
}
const stderrRaw = err.stderr?.toString().trim() || '';
const error = stderrRaw || `${err.message} [stderr: (empty) exit:${err.status ?? 1}]`;
return {
success: false,
output: err.stdout?.toString().trim() || '',
error,
exitCode: err.status ?? 1,
};
}
}
/**
* FAIL-BEFORE (origin/next behavior):
* Without a timeout, an OOM-killed subprocess threw with err.killed=true
* but the catch block fell through to `return { success: false, ... }`.
* The test consumer saw a normal {success:false} result and tried to parse
* gsd-tools output from it, causing a confusing downstream assertion fail.
*
* PASS-AFTER (fix):
* The kill-discrimination guard rethrows immediately with a labeled error
* message containing "resource-starvation / subprocess-kill". The test
* asserts on that throw rather than getting a silent {success:false}.
*
* Mechanism: we use a tiny timeout (1ms) to guarantee a timeout-kill on a
* real gsd-tools invocation (even `--help` takes >1ms to start node).
*/
test('throws a resource-starvation error when subprocess is killed/times out', () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-969-'));
try {
// 1ms timeout guarantees ETIMEDOUT / killed before gsd-tools can respond.
assert.throws(
() => runGsdToolsWithTimeout(['--help'], tmpDir, {}, 1),
(err) => {
assert.ok(
err.message.includes('resource-starvation / subprocess-kill'),
`Expected labeled resource-starvation error, got: ${err.message}`
);
return true;
}
);
} finally {
cleanup(tmpDir);
}
});
/**
* Verify that a normal fast command still returns { success: true } and does
* NOT throw — i.e., the timeout addition does not break the happy path.
*/
test('returns { success: true } for a normal fast command with generous timeout', () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-969-'));
try {
// 30s timeout; gsd-tools --help completes in well under 1s.
const result = runGsdToolsWithTimeout(['--help'], tmpDir, {}, 30000);
assert.ok(result.success === true, `Expected success:true, got ${JSON.stringify(result)}`);
assert.ok(typeof result.output === 'string', 'output must be a string');
} finally {
cleanup(tmpDir);
}
});
/**
* Verify that a clean non-zero exit (a real gsd-tools application error, not
* a kill) still returns { success: false } WITHOUT throwing. This preserves
* existing test behavior that asserts on error shape.
*
* We trigger a clean non-zero by invoking a command that is known to fail
* cleanly (no project directory set up).
*/
test('returns { success: false } for a clean non-zero exit (no throw)', () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-969-'));
try {
// 'phase list' on a directory with no .planning/ produces a clean error exit.
const result = runGsdToolsWithTimeout(['phase', 'list'], tmpDir, {}, 30000);
assert.ok(result.success === false, `Expected success:false for clean error, got ${JSON.stringify(result)}`);
assert.ok(result.exitCode !== 0, 'exitCode must be non-zero');
// Must NOT have thrown — the clean-error path returns normally.
} finally {
cleanup(tmpDir);
}
});
});
// ---------------------------------------------------------------------------
// Part C — ensureBuiltHooks: build hooks/dist once, closing the scoped-CI
// first-build empty-dir race.
// ---------------------------------------------------------------------------
describe('bug #969 C — ensureBuiltHooks populates hooks/dist before concurrent tests', () => {
/**
* Helper: a hermetic temp dist dir + a runBuild spy. The spy records how many
* times a build was requested and, when invoked, writes the given hook files
* (simulating build-hooks.js populating DIST_DIR) so idempotency is testable.
*
* HERMETIC: never touches the real hooks/dist. Uses dependency-injected
* overrides (distDir, hookNames, runBuild) — no fs monkeypatching, so the test
* is deterministic and root/OS-independent.
*/
function makeHooksFixture(hookNames) {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-969-hooks-'));
const distDir = path.join(tmp, 'hooks', 'dist');
let buildCalls = 0;
const runBuild = () => {
buildCalls += 1;
fs.mkdirSync(distDir, { recursive: true });
for (const h of hookNames) {
fs.writeFileSync(path.join(distDir, h), `// ${h}\nmodule.exports = {};\n`);
}
};
const overrides = () => ({ distDir, hookNames, runBuild });
return { tmp, distDir, hookNames, overrides, calls: () => buildCalls };
}
const HOOKS = ['a-hook.js', 'b-hook.js', 'c-hook.sh'];
/**
* FAIL-BEFORE (origin/next): ensureBuiltHooks did not exist, so the export was
* undefined and there was no upfront hooks build — the first concurrent
* install test raced build-hooks.js's empty-then-fill window. The import at the
* top of this file (ensureBuiltHooks) is itself the fail-first anchor: on
* origin/next it is undefined and every test below throws "not a function".
*
* PASS-AFTER: ensureBuiltHooks() builds when hooks/dist is entirely absent.
*/
test('builds when hooks/dist is absent (fresh checkout / scoped CI)', () => {
const fx = makeHooksFixture(HOOKS);
try {
assert.equal(typeof ensureBuiltHooks, 'function',
'ensureBuiltHooks must be exported (absent on origin/next — the fail-first anchor)');
assert.ok(!fs.existsSync(fx.distDir), 'pre-condition: hooks/dist must be absent');
ensureBuiltHooks(fx.overrides());
assert.equal(fx.calls(), 1, 'a build must be triggered when dist is absent');
for (const h of HOOKS) {
assert.ok(fs.existsSync(path.join(fx.distDir, h)), `${h} must exist after build`);
}
} finally {
cleanup(fx.tmp);
}
});
/**
* BOUNDARY: dist exists but is empty (0 of N hooks) — the exact transient state
* build-hooks.js exposes between mkdir(DIST_DIR) and the first file rename.
*/
test('builds when hooks/dist exists but is empty (0 of N)', () => {
const fx = makeHooksFixture(HOOKS);
try {
fs.mkdirSync(fx.distDir, { recursive: true }); // empty dir — the race window
ensureBuiltHooks(fx.overrides());
assert.equal(fx.calls(), 1, 'an empty dist must trigger a build');
} finally {
cleanup(fx.tmp);
}
});
/**
* BOUNDARY: dist has all-but-one hook (N-1 of N) — a partially-filled dir mid
* first-build. Must still be treated as incomplete and rebuilt.
*/
test('builds when hooks/dist is partial (N-1 of N)', () => {
const fx = makeHooksFixture(HOOKS);
try {
fs.mkdirSync(fx.distDir, { recursive: true });
for (const h of HOOKS.slice(0, HOOKS.length - 1)) {
fs.writeFileSync(path.join(fx.distDir, h), 'x');
}
ensureBuiltHooks(fx.overrides());
assert.equal(fx.calls(), 1, 'a partial dist (missing one hook) must trigger a build');
} finally {
cleanup(fx.tmp);
}
});
/**
* BOUNDARY: a zero-byte hook (N of N present, but one is 0 bytes) — a truncated
* mid-write file. statSync().size === 0 must count as incomplete → rebuild.
*/
test('builds when a hook file is present but zero-byte', () => {
const fx = makeHooksFixture(HOOKS);
try {
fs.mkdirSync(fx.distDir, { recursive: true });
HOOKS.forEach((h, i) => {
fs.writeFileSync(path.join(fx.distDir, h), i === 0 ? '' : 'ok'); // first is 0 bytes
});
ensureBuiltHooks(fx.overrides());
assert.equal(fx.calls(), 1, 'a zero-byte hook must be treated as incomplete → rebuild');
} finally {
cleanup(fx.tmp);
}
});
/**
* BOUNDARY + idempotency: dist is complete (all N present, non-empty). No build
* must fire — this keeps nested run-tests spawns and repeat invocations cheap
* and avoids a redundant concurrent build against an already-populated dist.
*/
test('no-op when hooks/dist is complete (N of N non-empty)', () => {
const fx = makeHooksFixture(HOOKS);
try {
fs.mkdirSync(fx.distDir, { recursive: true });
for (const h of HOOKS) fs.writeFileSync(path.join(fx.distDir, h), 'ok');
ensureBuiltHooks(fx.overrides());
assert.equal(fx.calls(), 0, 'a complete dist must NOT trigger a build (idempotent no-op)');
} finally {
cleanup(fx.tmp);
}
});
/**
* Integration guard: the REAL default hook set (from build-hooks.js) is what
* ensureBuiltHooks checks when no override is given. Prove the real export is a
* non-empty list so the completeness predicate can never vacuously pass.
*/
test('default hook set (build-hooks.js HOOKS_TO_COPY) is a non-empty list', () => {
const { HOOKS_TO_COPY } = require('../scripts/build-hooks.js');
assert.ok(Array.isArray(HOOKS_TO_COPY) && HOOKS_TO_COPY.length > 0,
'HOOKS_TO_COPY must be a non-empty array or the completeness check is vacuous');
});
});
});
}

File diff suppressed because it is too large Load Diff

View File

@@ -212,3 +212,994 @@ describe('skill frontmatter: /gsd-plan-phase --research-phase flag absorbs the s
);
});
});
// ────────────────────────────────────────────────────────────────────────
// Folded from tests/enh-2789-description-budget.test.cjs — consolidation epic #1969 (B6 #1975)
// ────────────────────────────────────────────────────────────────────────
{
const { describe: __foldDescribe } = require('node:test');
__foldDescribe("folded:enh-2789-description-budget (consolidation epic #1969 B6 #1975)", () => {
'use strict';
// allow-test-rule: source-text-is-the-product (see #2789)
// commands/gsd/*.md text IS what the runtime loads — testing description
// length tests the deployed system-prompt contract.
/**
* Tests for #2789 — Trim skill description anti-patterns; enforce 100-char budget
*
* Verifies:
* 1. All skill descriptions in commands/gsd/*.md are <= 100 chars
* 2. No descriptions contain flag documentation anti-patterns (Use --)
* 3. No descriptions contain "Triggers:" keyword stuffing
* 4. lint-descriptions.cjs rejects descriptions over 100 chars
* 5. lint-descriptions.cjs accepts descriptions under 100 chars
*/
const { test, describe, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { spawnSync } = require('node:child_process');
const os = require('node:os');
const { cleanup } = require('./helpers.cjs');
const COMMANDS_DIR = path.join(__dirname, '../commands/gsd');
const LINT_SCRIPT = path.join(__dirname, '../scripts/lint-descriptions.cjs');
const MAX_DESCRIPTION_LENGTH = 100;
/**
* Parse the description field from a frontmatter block in a .md file.
* Returns null if no description is found.
*/
function parseDescription(content) {
// Extract frontmatter block between --- markers
const fmMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
if (!fmMatch) return null;
const fm = fmMatch[1];
// Handle multi-line or quoted values: description: "..." or description: plain text
// Match: description: "value" or description: value (to end of line)
const quoted = fm.match(/^description:\s+"((?:[^"\\]|\\.)*)"\s*$/m);
if (quoted) return quoted[1];
const plain = fm.match(/^description:\s+(.+)$/m);
if (plain) return plain[1].trim();
return null;
}
/**
* Get all .md files in commands/gsd/ with their descriptions.
*/
function getAllCommandDescriptions() {
const files = fs.readdirSync(COMMANDS_DIR).filter(f => f.endsWith('.md'));
return files.map(file => {
const filePath = path.join(COMMANDS_DIR, file);
const content = fs.readFileSync(filePath, 'utf-8');
const description = parseDescription(content);
return { file, filePath, description };
});
}
// ── Test 1: All descriptions <= 100 chars ────────────────────────────────────
describe('description length budget', () => {
test('all commands/gsd/*.md descriptions are <= 100 chars', () => {
const commands = getAllCommandDescriptions();
const violators = commands
.filter(c => c.description !== null && c.description.length > MAX_DESCRIPTION_LENGTH)
.map(c => [
'length=' + c.description.length,
'file=' + c.file,
'desc=' + c.description,
].join(' | '));
assert.strictEqual(
violators.length,
0,
[
`${violators.length} description(s) exceed ${MAX_DESCRIPTION_LENGTH} chars:`,
...violators.map(v => ' ' + v),
].join('\n')
);
});
});
// ── Test 2: No flag documentation anti-patterns ──────────────────────────────
describe('description anti-patterns', () => {
test('no descriptions contain flag documentation (Use --, use --, via --)', () => {
const commands = getAllCommandDescriptions();
const FLAG_PATTERNS = ['Use --', 'use --', 'via --'];
const violators = commands
.filter(c => {
if (!c.description) return false;
return FLAG_PATTERNS.some(p => c.description.includes(p));
})
.map(c => 'file=' + c.file + ' | desc=' + c.description);
assert.strictEqual(
violators.length,
0,
[
`${violators.length} description(s) contain flag documentation anti-patterns:`,
...violators.map(v => ' ' + v),
].join('\n')
);
});
// ── Test 3: No Triggers: keyword stuffing ─────────────────────────────────
test('no descriptions contain "Triggers:" keyword stuffing', () => {
const commands = getAllCommandDescriptions();
const violators = commands
.filter(c => c.description && /triggers:/i.test(c.description))
.map(c => 'file=' + c.file + ' | desc=' + c.description);
assert.strictEqual(
violators.length,
0,
[
`${violators.length} description(s) contain "Triggers:" keyword stuffing:`,
...violators.map(v => ' ' + v),
].join('\n')
);
});
});
// ── Test 4 & 5: lint-descriptions.cjs script ─────────────────────────────────
describe('lint-descriptions.cjs', () => {
let tmpDir;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-lint-desc-test-'));
});
afterEach(() => {
cleanup(tmpDir);
});
test('rejects a command file with a description over 100 chars', () => {
const longDesc = 'A'.repeat(101);
const content = [
'---',
'name: gsd:test-long',
'description: ' + longDesc,
'---',
'',
'Body text.',
].join('\n');
const tmpFile = path.join(tmpDir, 'long-desc.md');
fs.writeFileSync(tmpFile, content, 'utf-8');
const result = spawnSync(process.execPath, [LINT_SCRIPT, tmpFile], {
encoding: 'utf-8',
});
assert.notStrictEqual(result.status, 0, [
'lint-descriptions.cjs should exit non-zero for description > 100 chars',
'stdout: ' + result.stdout,
'stderr: ' + result.stderr,
].join('\n'));
});
test('accepts a command file with a description under 100 chars', () => {
const shortDesc = 'Short routing description for this skill.';
const content = [
'---',
'name: gsd:test-short',
'description: ' + shortDesc,
'---',
'',
'Body text.',
].join('\n');
const tmpFile = path.join(tmpDir, 'short-desc.md');
fs.writeFileSync(tmpFile, content, 'utf-8');
const result = spawnSync(process.execPath, [LINT_SCRIPT, tmpFile], {
encoding: 'utf-8',
});
assert.strictEqual(result.status, 0, [
'lint-descriptions.cjs should exit 0 for description <= 100 chars',
'stdout: ' + result.stdout,
'stderr: ' + result.stderr,
].join('\n'));
});
});
});
}
// ────────────────────────────────────────────────────────────────────────
// Folded from tests/enh-2790-skill-consolidation.test.cjs — consolidation epic #1969 (B6 #1975)
// ────────────────────────────────────────────────────────────────────────
{
const { describe: __foldDescribe } = require('node:test');
__foldDescribe("folded:enh-2790-skill-consolidation (consolidation epic #1969 B6 #1975)", () => {
// allow-test-rule: source-text-is-the-product (see #2790)
// commands/gsd/*.md files ARE what the runtime loads — testing their
// existence/non-existence tests the deployed skill surface contract.
'use strict';
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const { assertWithinAllowlist } = require('../scripts/lib/allowlist-ratchet.cjs');
// ---------------------------------------------------------------------------
// Allowlisted set of user-invocable skills (commands/gsd/*.md, ns-* excluded).
// Consolidation target ~58; this set may only SHRINK.
// Adding a new skill requires adding it here with justification.
// Removing a consolidated skill requires pruning it here.
// ---------------------------------------------------------------------------
const KNOWN_SKILLS = new Set([
'add-tests.md',
'ai-integration-phase.md',
'audit-fix.md',
'audit-milestone.md',
'audit-uat.md',
'autonomous.md',
'capture.md',
'cleanup.md',
'code-review.md',
'complete-milestone.md',
'config.md',
'debug.md',
'discuss-phase.md',
'docs-update.md',
'eval-review.md',
'execute-phase.md',
'explore.md',
'extract-learnings.md',
'fast.md',
'forensics.md',
'graphify.md',
'health.md',
'help.md',
'import.md',
'inbox.md',
'ingest-docs.md',
'manager.md',
'map-codebase.md',
'mempalace-capture.md',
'mempalace-recall.md',
'milestone-summary.md',
'mvp-phase.md',
'new-milestone.md',
'new-project.md',
'pause-work.md',
'phase.md',
'plan-phase.md',
'plan-review-convergence.md',
'pr-branch.md',
'profile-user.md',
'progress.md',
'quick.md',
'resume-work.md',
'review-backlog.md',
'review.md',
'secure-phase.md',
'settings.md',
'ship.md',
'sketch.md',
'spec-phase.md',
'spike.md',
'stats.md',
'surface.md',
'thread.md',
'ui-phase.md',
'ui-review.md',
'ultraplan-phase.md',
'undo.md',
'update.md',
'validate-phase.md',
'verify-work.md',
'workspace.md',
'workstreams.md',
]);
const COMMANDS_DIR = path.join(__dirname, '..', 'commands', 'gsd');
/**
* Parse the YAML frontmatter from a skill .md file.
* Returns an object with the frontmatter fields as strings.
* Only handles simple scalar and array values needed by these tests.
*/
function parseFrontmatter(filePath) {
const raw = fs.readFileSync(filePath, 'utf8');
// CRLF-tolerant: Windows checkouts leave \r on every line. lines.indexOf('---', 1)
// would never match because elements would be '---\r' instead of '---'.
const lines = raw.split(/\r?\n/);
if (lines[0].trim() !== '---') return {};
const endIdx = lines.indexOf('---', 1);
if (endIdx === -1) return {};
const fmLines = lines.slice(1, endIdx);
const result = {};
let currentKey = null;
for (const line of fmLines) {
const kvMatch = line.match(/^([a-zA-Z0-9_-]+):\s*(.*)/);
if (kvMatch) {
currentKey = kvMatch[1];
result[currentKey] = kvMatch[2].trim();
} else if (currentKey && line.match(/^\s+-\s+/)) {
// array item — append to existing string value so callers can check membership
const item = line.replace(/^\s+-\s+/, '').trim();
result[currentKey] = result[currentKey] ? [result[currentKey], item].join('\n') : item;
}
}
return result;
}
function skillPath(name) {
return path.join(COMMANDS_DIR, `${name}.md`);
}
// ---------------------------------------------------------------------------
// Group: New consolidated skills exist
// ---------------------------------------------------------------------------
describe('new consolidated skills exist', () => {
test('commands/gsd/capture.md exists', () => {
assert.ok(fs.existsSync(skillPath('capture')), 'capture.md does not exist');
});
test('commands/gsd/phase.md exists', () => {
assert.ok(fs.existsSync(skillPath('phase')), 'phase.md does not exist');
});
test('commands/gsd/config.md exists', () => {
assert.ok(fs.existsSync(skillPath('config')), 'config.md does not exist');
});
test('commands/gsd/workspace.md exists', () => {
assert.ok(fs.existsSync(skillPath('workspace')), 'workspace.md does not exist');
});
});
// ---------------------------------------------------------------------------
// Group: Absorbed skills are removed
// ---------------------------------------------------------------------------
describe('absorbed skills are removed', () => {
const absorbed = [
['add-todo', 'absorbed into capture.md'],
['note', 'absorbed into capture.md'],
['add-backlog', 'absorbed into capture.md'],
['plant-seed', 'absorbed into capture.md'],
['check-todos', 'absorbed into capture.md'],
['add-phase', 'absorbed into phase.md'],
['insert-phase', 'absorbed into phase.md'],
['remove-phase', 'absorbed into phase.md'],
['edit-phase', 'absorbed into phase.md'],
['settings-advanced', 'absorbed into config.md'],
['settings-integrations', 'absorbed into config.md'],
['set-profile', 'absorbed into config.md'],
['new-workspace', 'absorbed into workspace.md'],
['list-workspaces', 'absorbed into workspace.md'],
['remove-workspace', 'absorbed into workspace.md'],
['sync-skills', 'absorbed into update.md'],
['reapply-patches', 'absorbed into update.md'],
['sketch-wrap-up', 'absorbed into sketch.md'],
['spike-wrap-up', 'absorbed into spike.md'],
['scan', 'absorbed into map-codebase.md'],
['intel', 'absorbed into map-codebase.md'],
['code-review-fix', 'absorbed into code-review.md'],
['next', 'absorbed into progress.md'],
['do', 'absorbed into progress.md'],
];
for (const [name, reason] of absorbed) {
test(`commands/gsd/${name}.md does NOT exist (${reason})`, () => {
assert.ok(
!fs.existsSync(skillPath(name)),
[
`${name}.md still exists but should have been deleted`,
`(${reason})`,
].join(' '),
);
});
}
});
// ---------------------------------------------------------------------------
// Group: Outright deletions
// ---------------------------------------------------------------------------
describe('outright deleted dead skills are removed', () => {
const deleted = [
'join-discord',
// research-phase → plan-phase --research-phase (PR #3045, already absorbed)
// plan-milestone-gaps → inline in audit-milestone (PR #3038, already absorbed)
// list-phase-assumptions → discuss-phase --assumptions (pending #3131)
// session-report → pause-work --report (pending #3131)
// analyze-dependencies → manager --analyze-deps (pending #3131)
// from-gsd2 → import --from-gsd2 (pending #3131)
];
for (const name of deleted) {
test(`commands/gsd/${name}.md does NOT exist`, () => {
assert.ok(
!fs.existsSync(skillPath(name)),
`${name}.md still exists but should have been deleted (outright dead skill)`,
);
});
}
});
// ---------------------------------------------------------------------------
// Group: #3131 — re-wired workflows absorbed as flags
// ---------------------------------------------------------------------------
describe('#3131 re-wired workflows: standalone command files must not exist', () => {
const rewired = [
['list-phase-assumptions', 'absorbed into discuss-phase.md --assumptions'],
['session-report', 'absorbed into pause-work.md --report'],
['analyze-dependencies', 'absorbed into manager.md --analyze-deps'],
['from-gsd2', 'absorbed into import.md --from-gsd2'],
];
for (const [name, reason] of rewired) {
test(`commands/gsd/${name}.md does NOT exist (${reason})`, () => {
assert.ok(
!fs.existsSync(skillPath(name)),
`${name}.md still exists as a standalone command but should be absorbed (${reason})`,
);
});
}
});
describe('#3131 re-wired workflows: parent command argument-hints advertise the new flags', () => {
test('discuss-phase.md argument-hint contains --assumptions', () => {
const fm = parseFrontmatter(skillPath('discuss-phase'));
assert.ok(
(fm['argument-hint'] || '').includes('--assumptions'),
'discuss-phase.md argument-hint does not contain --assumptions. got: ' + (fm['argument-hint'] || '(none)'),
);
});
test('pause-work.md argument-hint contains --report', () => {
const fm = parseFrontmatter(skillPath('pause-work'));
assert.ok(
(fm['argument-hint'] || '').includes('--report'),
'pause-work.md argument-hint does not contain --report. got: ' + (fm['argument-hint'] || '(none)'),
);
});
test('manager.md argument-hint contains --analyze-deps', () => {
const fm = parseFrontmatter(skillPath('manager'));
assert.ok(
(fm['argument-hint'] || '').includes('--analyze-deps'),
'manager.md argument-hint does not contain --analyze-deps. got: ' + (fm['argument-hint'] || '(none)'),
);
});
test('import.md argument-hint contains --from-gsd2', () => {
const fm = parseFrontmatter(skillPath('import'));
assert.ok(
(fm['argument-hint'] || '').includes('--from-gsd2'),
'import.md argument-hint does not contain --from-gsd2. got: ' + (fm['argument-hint'] || '(none)'),
);
});
});
describe('#3131 re-wired workflows: parent command bodies dispatch to workflow files', () => {
function bodyContains(name, substring) {
const raw = fs.readFileSync(skillPath(name), 'utf8');
return raw.includes(substring);
}
test('discuss-phase.md body references list-phase-assumptions.md', () => {
assert.ok(
bodyContains('discuss-phase', 'list-phase-assumptions.md'),
'discuss-phase.md body does not reference list-phase-assumptions.md — --assumptions flag dispatch is missing',
);
});
test('pause-work.md body references session-report.md', () => {
assert.ok(
bodyContains('pause-work', 'session-report.md'),
'pause-work.md body does not reference session-report.md — --report flag dispatch is missing',
);
});
test('manager.md body references analyze-dependencies.md', () => {
assert.ok(
bodyContains('manager', 'analyze-dependencies.md'),
'manager.md body does not reference analyze-dependencies.md — --analyze-deps flag dispatch is missing',
);
});
test('import.md body references from-gsd2', () => {
assert.ok(
bodyContains('import', 'from-gsd2'),
'import.md body does not reference from-gsd2 — --from-gsd2 flag dispatch is missing',
);
});
});
// ---------------------------------------------------------------------------
// Group: Parent skills updated with new flags
// ---------------------------------------------------------------------------
describe('parent skills updated with new flags in argument-hint', () => {
test('update.md argument-hint contains --sync', () => {
const fm = parseFrontmatter(skillPath('update'));
assert.ok(
(fm['argument-hint'] || '').includes('--sync'),
[
'update.md argument-hint does not contain --sync',
'got: ' + (fm['argument-hint'] || '(none)'),
].join('. '),
);
});
test('update.md argument-hint contains --reapply', () => {
const fm = parseFrontmatter(skillPath('update'));
assert.ok(
(fm['argument-hint'] || '').includes('--reapply'),
[
'update.md argument-hint does not contain --reapply',
'got: ' + (fm['argument-hint'] || '(none)'),
].join('. '),
);
});
test('sketch.md argument-hint contains --wrap-up', () => {
const fm = parseFrontmatter(skillPath('sketch'));
assert.ok(
(fm['argument-hint'] || '').includes('--wrap-up'),
[
'sketch.md argument-hint does not contain --wrap-up',
'got: ' + (fm['argument-hint'] || '(none)'),
].join('. '),
);
});
test('spike.md argument-hint contains --wrap-up', () => {
const fm = parseFrontmatter(skillPath('spike'));
assert.ok(
(fm['argument-hint'] || '').includes('--wrap-up'),
[
'spike.md argument-hint does not contain --wrap-up',
'got: ' + (fm['argument-hint'] || '(none)'),
].join('. '),
);
});
test('map-codebase.md argument-hint contains --fast', () => {
const fm = parseFrontmatter(skillPath('map-codebase'));
assert.ok(
(fm['argument-hint'] || '').includes('--fast'),
[
'map-codebase.md argument-hint does not contain --fast',
'got: ' + (fm['argument-hint'] || '(none)'),
].join('. '),
);
});
test('code-review.md argument-hint contains --fix', () => {
const fm = parseFrontmatter(skillPath('code-review'));
assert.ok(
(fm['argument-hint'] || '').includes('--fix'),
[
'code-review.md argument-hint does not contain --fix',
'got: ' + (fm['argument-hint'] || '(none)'),
].join('. '),
);
});
test('progress.md argument-hint contains --do', () => {
const fm = parseFrontmatter(skillPath('progress'));
assert.ok(
(fm['argument-hint'] || '').includes('--do'),
[
'progress.md argument-hint does not contain --do',
'got: ' + (fm['argument-hint'] || '(none)'),
].join('. '),
);
});
});
// ---------------------------------------------------------------------------
// Group: settings.md is NOT deleted
// ---------------------------------------------------------------------------
describe('settings.md is kept (merged into config entry point or remains standalone)', () => {
test('commands/gsd/settings.md still exists', () => {
assert.ok(
fs.existsSync(skillPath('settings')),
'settings.md was deleted — it should be kept (or renamed to config.md, but not both missing)',
);
});
});
// ---------------------------------------------------------------------------
// Group: Skill set allowlisted (identity-based, consolidating toward ~58)
// ---------------------------------------------------------------------------
describe('skill set', () => {
test('user-invocable skill set is allowlisted (consolidating toward ~58)', () => {
// Exclude `ns-*.md` namespace meta-skills (#2792) from this guard.
// Those are descriptor-only routers selected first by the model and
// are not part of the consolidation surface this test tracks; their
// own contract is enforced by tests/enh-2792-namespace-skills.test.cjs.
const currentBasenames = fs.readdirSync(COMMANDS_DIR)
.filter((f) => f.endsWith('.md') && !f.startsWith('ns-'));
assertWithinAllowlist({
label: 'user-invocable skills (commands/gsd)',
current: currentBasenames,
known: KNOWN_SKILLS,
fail: assert.fail,
pruneHint: 'edit KNOWN_SKILLS in tests/enh-2790-skill-consolidation.test.cjs',
});
});
});
});
}
// ────────────────────────────────────────────────────────────────────────
// Folded from tests/feat-3039-help-tiered.test.cjs — consolidation epic #1969 (B6 #1975)
// ────────────────────────────────────────────────────────────────────────
{
const { describe: __foldDescribe } = require('node:test');
__foldDescribe("folded:feat-3039-help-tiered (consolidation epic #1969 B6 #1975)", () => {
'use strict';
// allow-test-rule: source-text-is-the-product (see #3039)
// `workflows/help/modes/*.md` files ARE the help output — their text is what
// the runtime emits when the user runs `/gsd:help [--brief|--full|<topic>]`.
// Asserting on their structure tests the deployed contract directly.
/**
* Feature #3039: tiered /gsd:help output.
*
* The legacy single-file 747-line help is replaced by:
* - workflows/help.md — small dispatcher (progressive disclosure)
* - workflows/help/modes/brief.md — ~one-liner refresher
* - workflows/help/modes/default.md — one-page newcomer tour
* - workflows/help/modes/full.md — complete reference (former help.md body)
* - workflows/help/modes/topic.md — section-extraction logic + alias table
*
* This test enforces the contract:
* 1. All four mode files exist with a single `<reference>` block.
* 2. brief and default fit a "one screen" budget; full stays under LARGE tier cap.
* 3. The dispatcher routes on $ARGUMENTS to all four mode files (structural parse).
* 4. Dispatcher conflict-resolution rules are documented:
* - `--brief` + `--full` without a topic → prefer `--full`
* - `--brief <topic>` → topic.md in compact scope (composable)
* - bare or `--full <topic>` → topic.md in full scope
* 5. topic.md documents an explicit routing preamble + compact-scope rule.
* 6. Every topic alias in topic.md resolves to a heading that exists in full.md.
* 7. Every /gsd:* sub-block token in topic.md's alias table appears in full.md.
* 8. Every full.md heading is either aliased or in the intentional-orphan allowlist.
* 9. The `commands/gsd/help.md` shim passes `$ARGUMENTS` through and advertises
* the composable `--brief <topic>` form.
*
* Tighten-only invariant (issue #597): ceilings track the per-tier high-water mark
* within GRACE lines. Budgets may only decrease, never silently creep upward.
* The assertTightCeiling() calls below enforce this automatically.
*/
const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { assertTightCeiling } = require('../scripts/lib/allowlist-ratchet.cjs');
const ROOT = path.join(__dirname, '..');
const WORKFLOWS = path.join(ROOT, 'gsd-core', 'workflows');
const MODES = path.join(WORKFLOWS, 'help', 'modes');
const DISPATCHER = path.join(WORKFLOWS, 'help.md');
const COMMAND_SHIM = path.join(ROOT, 'commands', 'gsd', 'help.md');
const MODE_FILES = ['brief.md', 'default.md', 'full.md', 'topic.md'];
// "One screen" budgets, including frontmatter/<purpose>/<reference> tags.
// These are conservative (one-page conceptual size of ~25 lines of usable
// content) but allow for the wrapping tags. Tighten as content stabilizes.
//
// Ceilings tightened to actualMax + SMALL_GRACE per the ratchet-down rule (#597).
// BRIEF ceiling kept at 30 (actualMax=22, slack=8 ≤ SMALL_GRACE=10).
const BRIEF_BUDGET = 30;
// DEFAULT ceiling lowered from 70 → 60 (actualMax=50; #597 ratchet-down).
const DEFAULT_BUDGET = 60;
// full.md is the LARGE tier (see workflow-size-budget.test.cjs — now byte-based per #717;
// this FULL_BUDGET is a separate line-count budget for help/modes/full.md).
// The size-budget test is non-recursive so full.md is not covered there; cap it here.
// FULL ceiling lowered from 1500 → 844 (actualMax=784; #597 ratchet-down).
const FULL_BUDGET = 844;
// Grace bands:
// SMALL_GRACE — for the tiny brief/default/dispatcher files (≤ ~70 lines):
// 10 lines of breathing room is proportionate and prevents trivial edits from
// failing while still catching any meaningful upward creep.
// LARGE_GRACE — for full.md where content fluctuates more:
// 60 lines matches the line-budget GRACE used in the other size-budget tests.
const SMALL_GRACE = 10;
const LARGE_GRACE = 60;
function read(file) {
return fs.readFileSync(file, 'utf8');
}
function lineCount(file) {
const c = read(file);
if (c.length === 0) return 0;
const trail = c.endsWith('\n') ? 1 : 0;
return c.split('\n').length - trail;
}
describe('feature #3039: tiered help — file structure', () => {
for (const f of MODE_FILES) {
test(`mode file exists: ${f}`, () => {
assert.ok(fs.existsSync(path.join(MODES, f)), `missing ${path.join(MODES, f)}`);
});
}
// Dispatcher ceiling lowered from 40 → 34 (actualMax=24; #597 ratchet-down).
const DISPATCHER_BUDGET = 34;
test(`dispatcher exists and is small (≤ ${DISPATCHER_BUDGET} lines)`, () => {
assert.ok(fs.existsSync(DISPATCHER));
const n = lineCount(DISPATCHER);
assert.ok(n <= DISPATCHER_BUDGET, `dispatcher should be small; got ${n} lines`);
assertTightCeiling({ label: 'dispatcher', actualMax: n, ceiling: DISPATCHER_BUDGET, grace: SMALL_GRACE, fail: assert.fail });
});
for (const f of MODE_FILES) {
test(`${f} has exactly one <reference> block (line-anchored)`, () => {
const content = read(path.join(MODES, f));
// Anchor on start-of-line so prose mentions of `<reference>` inside
// <purpose> blocks aren't counted.
const opens = (content.match(/^<reference>$/gm) || []).length;
const closes = (content.match(/^<\/reference>$/gm) || []).length;
assert.equal(opens, 1, `${f}: expected 1 <reference> opening line, got ${opens}`);
assert.equal(closes, 1, `${f}: expected 1 </reference> closing line, got ${closes}`);
});
}
});
describe('feature #3039: tiered help — size budgets', () => {
test(`brief.md fits one screen (≤ ${BRIEF_BUDGET} lines)`, () => {
const n = lineCount(path.join(MODES, 'brief.md'));
assert.ok(n <= BRIEF_BUDGET, `brief.md is ${n} lines, budget ${BRIEF_BUDGET}`);
assertTightCeiling({ label: 'BRIEF', actualMax: n, ceiling: BRIEF_BUDGET, grace: SMALL_GRACE, fail: assert.fail });
});
test(`default.md fits one screen (≤ ${DEFAULT_BUDGET} lines)`, () => {
const n = lineCount(path.join(MODES, 'default.md'));
assert.ok(n <= DEFAULT_BUDGET, `default.md is ${n} lines, budget ${DEFAULT_BUDGET}`);
assertTightCeiling({ label: 'DEFAULT', actualMax: n, ceiling: DEFAULT_BUDGET, grace: SMALL_GRACE, fail: assert.fail });
});
test('full.md preserves the complete reference (≥ 600 lines)', () => {
// The pre-#3039 reference was 747 lines. Guard against accidental shrinkage
// that would amount to silently removing content from --full.
const n = lineCount(path.join(MODES, 'full.md'));
assert.ok(n >= 600, `full.md is ${n} lines — too small, content may have been lost`);
});
test(`full.md stays under LARGE workflow budget (≤ ${FULL_BUDGET} lines)`, () => {
// full.md lives in a subdirectory and is not enumerated by the non-recursive
// workflow-size-budget.test.cjs. Cap it here at the LARGE tier limit.
const n = lineCount(path.join(MODES, 'full.md'));
assert.ok(n <= FULL_BUDGET, `full.md grew to ${n} lines (LARGE budget: ${FULL_BUDGET})`);
assertTightCeiling({ label: 'FULL', actualMax: n, ceiling: FULL_BUDGET, grace: LARGE_GRACE, fail: assert.fail });
});
});
describe('feature #3039: tiered help — dispatcher routing (structural)', () => {
const dispatcher = read(DISPATCHER);
function extractDisclosureBlock(src) {
const m = src.match(/<progressive_disclosure>([\s\S]*?)<\/progressive_disclosure>/);
assert.ok(m, 'dispatcher must contain a <progressive_disclosure> block');
return m[1];
}
test('dispatcher <progressive_disclosure> block has exactly 5 routing rows', () => {
// 4 base tiers (brief, full, default, topic) + 1 composable row (--brief <topic>).
const block = extractDisclosureBlock(dispatcher);
// Table rows are lines starting with `|`, excluding the header and separator rows.
const rows = block.split('\n')
.filter(l => /^\|/.test(l))
.filter(l => !/^\|\s*[-:]+\s*\|/.test(l)) // strip separator rows
.filter(l => !/when.*arguments/i.test(l)); // strip header row
assert.equal(rows.length, 5,
`dispatcher routing table must have exactly 5 rows; got ${rows.length}:\n${rows.join('\n')}`);
});
test('dispatcher routes --brief to brief.md', () => {
const block = extractDisclosureBlock(dispatcher);
assert.match(block, /`--brief`[\s\S]*?brief\.md/);
});
test('dispatcher routes --full to full.md', () => {
const block = extractDisclosureBlock(dispatcher);
assert.match(block, /`--full`[\s\S]*?full\.md/);
});
test('dispatcher routes empty/no-flag args to default.md', () => {
const block = extractDisclosureBlock(dispatcher);
assert.match(block, /(empty|unset)[\s\S]*?default\.md/i);
});
test('dispatcher routes topic args to topic.md', () => {
const block = extractDisclosureBlock(dispatcher);
assert.match(block, /topic[\s\S]*?topic\.md/i);
});
});
describe('feature #3039: tiered help — dispatcher conflict-resolution rules', () => {
const dispatcher = read(DISPATCHER);
test('dispatcher documents --brief + --full (without topic) conflict resolution (prefer --full)', () => {
// help.md argument parsing rules: "if both appear *without* a topic, prefer `--full`"
assert.match(dispatcher, /prefer.*--full/);
});
test('dispatcher routes --brief <topic> to topic.md in compact scope (composable)', () => {
// help.md argument parsing rules: "--brief combined with a topic invokes topic.md
// in compact scope" — the composable scoped-lookup form (trek-e review finding #4).
assert.match(dispatcher, /--brief[^|]*<topic>[\s\S]*?topic\.md[\s\S]*?compact/i);
});
test('dispatcher routes --full <topic> (or bare topic) to topic.md in full scope', () => {
// Bare topic, `--full <topic>`, or topic with leading `--` → full scope.
assert.match(dispatcher, /(bare topic|--full <topic>)[\s\S]*?full scope/i);
});
test('dispatcher tells topic.md to retain --brief when delegating', () => {
// The dispatcher passes $ARGUMENTS through; topic.md needs to see --brief to
// choose compact scope. Guard against accidental flag-stripping.
assert.match(dispatcher, /retain.*--brief|pass.*--brief/i);
});
});
describe('feature #3039: tiered help — command shim passes $ARGUMENTS', () => {
const shim = read(COMMAND_SHIM);
test('shim references $ARGUMENTS', () => {
assert.match(shim, /\$ARGUMENTS/);
});
test('shim declares argument-hint frontmatter', () => {
assert.match(shim, /argument-hint:/);
});
test('shim argument-hint advertises composable --brief <topic>', () => {
// Discoverability: users need to know the composable form is supported
// (trek-e review finding #4).
assert.match(shim, /argument-hint:[^\n]*--brief[^\n]*<topic>/);
});
test('shim references the help workflow', () => {
assert.match(shim, /workflows\/help\.md/);
});
});
describe('feature #3039: tiered help — topic.md routing visibility + compact scope', () => {
const topicSrc = read(path.join(MODES, 'topic.md'));
test('topic.md documents an explicit resolved-routing preamble', () => {
// Trek-e review finding #3: routing must be explicit in output so the user
// can see which alias matched which heading and at what scope.
assert.match(topicSrc, /\*\*Topic:\*\*[\s\S]*<alias>[\s\S]*<heading>/);
assert.match(topicSrc, /scope:.*full.*\|.*compact/i);
});
test('topic.md documents a compact scope distinct from full scope', () => {
// Trek-e review finding #4: --brief <topic> must produce a compact
// scoped lookup (signature + one-line summary), not the full section.
assert.match(topicSrc, /compact scope/i);
assert.match(topicSrc, /signature.*one-line summary|signature \+ one-line/i);
});
test('topic.md parses --brief flag and strips it before resolving the alias', () => {
// Compact scope must trigger off the --brief flag in $ARGUMENTS; the
// remaining token is the alias.
assert.match(topicSrc, /--brief.*-b.*compact scope|compact scope[\s\S]*--brief/i);
});
test('topic.md closing "More:" line advertises the composable form', () => {
assert.match(topicSrc, /More:[\s\S]*--brief <topic>/);
});
});
describe('feature #3039: tiered help — topic alias coverage', () => {
const topicSrc = read(path.join(MODES, 'topic.md'));
const fullSrc = read(path.join(MODES, 'full.md'));
// Extract the alias table portion of topic.md (before "**Output rules:**")
function aliasTableSection(src) {
return src.split('**Output rules:**')[0];
}
// Extract the canonical heading text referenced from each row of the
// alias table. Rows look like: `| aliases | \`## Heading\` ... |`.
// We accept either ## or ### and pull the literal heading text.
function extractReferencedHeadings(src) {
const headings = new Set();
const re = /`(#{2,3} [^`]+?)`/g;
let m;
while ((m = re.exec(src)) !== null) {
headings.add(m[1].trim());
}
return headings;
}
function fullHeadings(src) {
const set = new Set();
for (const line of src.split('\n')) {
const m = line.match(/^(#{2,3}) (.+?)\s*$/);
if (m) set.add(`${m[1]} ${m[2]}`);
}
return set;
}
test('every heading referenced in topic.md exists in full.md', () => {
const referenced = extractReferencedHeadings(aliasTableSection(topicSrc));
const present = fullHeadings(fullSrc);
const missing = [...referenced].filter((h) => !present.has(h)).sort();
assert.deepEqual(missing, [],
`topic.md references headings not present in full.md: ${missing.join(' | ')}`);
});
test('every /gsd:* sub-block token in topic.md alias table exists in full.md', () => {
// Validates fix for review finding #2: sub-block aliases reference bold-line
// anchors (**`/gsd:X`**) — assert each token actually appears in full.md.
const tableSection = aliasTableSection(topicSrc);
const tokens = [...tableSection.matchAll(/`(\/gsd:[a-z-]+(?:\s+--[a-z-]+)?)`/g)].map(m => m[1]);
assert.ok(tokens.length > 0, 'expected at least one /gsd:* token in alias table');
const missing = tokens.filter(t => !fullSrc.includes(t));
assert.deepEqual(missing, [],
`topic.md references /gsd:* tokens not present in full.md: ${missing.join(' | ')}`);
});
test('every full.md heading is either aliased or in the intentional-orphan allowlist', () => {
// Catches newly added headings that have no alias (contributor must either
// alias the section or explicitly add it to INTENTIONAL_ORPHANS below).
const INTENTIONAL_ORPHANS = new Set([
'## Quick Start',
'## Staying Updated',
'### Utility Commands', // covered by cleanup/update sub-block aliases
'## Additional Commands',
'### Discovery & Specification',
'### Planning & Execution',
'### Quality, Review & Verification',
'### Diagnostics & Maintenance',
'### Knowledge & Context',
'### Workflow & Orchestration',
'### Repository Integration',
'### Namespace Routers (model-facing meta-skills)',
]);
const allHeadings = fullSrc.split('\n')
.filter(l => /^#{2,3} /.test(l))
.map(l => l.trim());
const aliased = extractReferencedHeadings(aliasTableSection(topicSrc));
const orphans = allHeadings.filter(h => !aliased.has(h) && !INTENTIONAL_ORPHANS.has(h));
assert.deepEqual(orphans, [],
`full.md headings not aliased in topic.md (add to INTENTIONAL_ORPHANS if intentional): ${orphans.join(' | ')}`);
});
test('topic.md covers the core topics promised in default.md', () => {
// Surface contract: default.md advertises a "Topics:" line. Each alias
// there must appear as a recognized topic in topic.md's alias table.
const def = read(path.join(MODES, 'default.md'));
const topicsLine = def.split('\n').find((l) => /^Topics:/i.test(l));
assert.ok(topicsLine, 'default.md must advertise a "Topics:" line for users');
// Strip the leading "Topics:" prefix, then pull every backticked token.
const aliases = [...topicsLine.matchAll(/`([a-z][a-z0-9-]*)`/g)].map((m) => m[1]);
assert.ok(aliases.length >= 5, `expected at least 5 promoted topic aliases; got ${aliases.length}`);
const missing = aliases.filter((a) => !new RegExp(`\`${a}\``).test(topicSrc));
assert.deepEqual(missing, [],
`default.md promotes topic aliases that topic.md does not recognize: ${missing.join(', ')}`);
});
});
});
}

File diff suppressed because it is too large Load Diff

View File

@@ -203,3 +203,115 @@ describe('@file: handoff in workflows', () => {
);
});
});
// ────────────────────────────────────────────────────────────────────────
// Folded from tests/bug-685-windowshide-spawn.test.cjs — consolidation epic #1969 (B6 #1975)
// ────────────────────────────────────────────────────────────────────────
{
const { describe: __foldDescribe } = require('node:test');
__foldDescribe("folded:bug-685-windowshide-spawn (consolidation epic #1969 B6 #1975)", () => {
// allow-test-rule: source-text-is-the-product (see #685)
// These spawn/exec sites cannot be behaviourally tested for windowsHide
// off-Windows; the source text is the runtime contract (issue #685). Without
// windowsHide:true a detached or shell:true child allocates a visible console
// window on Windows (the "gsd-core" flash).
'use strict';
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const root = path.resolve(__dirname, '..');
const read = (p) => fs.readFileSync(path.join(root, p), 'utf-8');
// Slice the exact body of one spawn site so the assertion binds that site,
// not merely "windowsHide appears somewhere in the file".
function regionBetween(src, startAnchor, endAnchor) {
const i = src.indexOf(startAnchor);
assert.notEqual(i, -1, `start anchor not found: ${startAnchor}`);
const j = src.indexOf(endAnchor, i);
assert.notEqual(j, -1, `end anchor not found after start: ${endAnchor}`);
return src.slice(i, j);
}
describe('bug #685: Windows spawns must set windowsHide:true (no console-window flash)', () => {
test('gsd-context-monitor record-session spawn sets windowsHide', () => {
const region = regionBetween(read('hooks/gsd-context-monitor.js'), "'record-session'", '.unref()');
assert.match(region, /windowsHide:\s*true/, 'record-session spawn must set windowsHide: true');
});
const cts = () => read('src/shell-command-projection.cts');
const helpers = [
['execGit', 'export function execGit', "_spawnResult(result, 'git')"],
['execNpm', 'export function execNpm', "_spawnResult(result, 'npm')"],
['execTool', 'export function execTool', '_spawnResult(result, program)'],
];
for (const [name, start, end] of helpers) {
test(`shell-command-projection ${name} spawnSync sets windowsHide`, () => {
const region = regionBetween(cts(), start, end);
assert.match(region, /windowsHide:\s*true/, `${name} spawnSync must set windowsHide: true`);
});
}
test('gsd-worktree-path-guard SPAWNOPT sets windowsHide', () => {
const region = regionBetween(read('hooks/gsd-worktree-path-guard.js'), 'const SPAWNOPT', '};');
assert.match(region, /windowsHide:\s*true/, 'gsd-worktree-path-guard SPAWNOPT must set windowsHide: true');
});
test('gsd-workflow-guard currentBranch spawnSync sets windowsHide', () => {
const region = regionBetween(read('hooks/gsd-workflow-guard.js'), "spawnSync('git', ['branch'", '});');
assert.match(region, /windowsHide:\s*true/, 'gsd-workflow-guard git-branch spawn must set windowsHide: true');
});
test('check-command-router recentCommitMessages execFileSync sets windowsHide', () => {
const region = regionBetween(read('src/check-command-router.cts'), "execFileSync('git', ['log'", '});');
assert.match(region, /windowsHide:\s*true/, 'check-command-router git-log execFileSync must set windowsHide: true');
});
test('roadmap-upgrade execSync git calls all set windowsHide', () => {
const src = read('src/roadmap-upgrade.cts');
const calls = src.match(/execSync\([^)]*\)/g) || [];
// #1542 made rollback git-independent (surgical fs restore), so the only
// remaining git execSync is the `git status --porcelain` precondition. The
// durable guard is that EVERY git execSync still present sets windowsHide.
assert.ok(calls.length >= 1, 'expected at least the roadmap-upgrade git status execSync call to be present');
const missing = calls.filter((c) => !/windowsHide:\s*true/.test(c));
assert.deepEqual(missing, [], `execSync without windowsHide:\n${missing.join('\n')}`);
});
test('gsd-check-update spawn retains windowsHide (precedent guard)', () => {
assert.match(read('hooks/gsd-check-update.js'), /windowsHide:\s*true/,
'gsd-check-update.js must keep windowsHide: true');
});
// Durable invariant: ANY external-binary process spawn in the runtime source
// (hooks + src) must set windowsHide — catches future additions, not just the
// sites known today. Handles the `{ ...CONST }` spread indirection.
test('completeness: no external-binary spawn in runtime source omits windowsHide', () => {
const listDir = (dir, re) =>
fs.readdirSync(path.join(root, dir)).filter((f) => re.test(f)).map((f) => `${dir}/${f}`);
const files = [...listDir('hooks', /\.js$/), ...listDir('src', /\.cts$/)];
const callRe = /(?:execSync|execFileSync|spawnSync|spawn)\s*\(\s*(?:`|'|")?(?:git|npm|gh)\b|spawn\s*\(\s*process\.execPath/g;
const offenders = [];
for (const rel of files) {
const src = read(rel);
let m;
while ((m = callRe.exec(src)) !== null) {
const win = src.slice(m.index, m.index + 400);
let ok = /windowsHide:\s*true/.test(win);
if (!ok) {
const spread = win.match(/\{\s*\.\.\.(\w+)/); // e.g. { ...SPAWNOPT, cwd }
if (spread) {
ok = new RegExp(`(?:const|let|var)\\s+${spread[1]}\\s*=\\s*\\{[^}]*windowsHide:\\s*true`).test(src);
}
}
if (!ok) offenders.push(`${rel}: ...${src.slice(m.index, m.index + 48).replace(/\s+/g, ' ')}`);
}
}
assert.deepEqual(offenders, [], `external-binary spawns missing windowsHide:\n${offenders.join('\n')}`);
});
});
});
}

View File

@@ -1272,3 +1272,429 @@ describe('bug-2838: SUMMARY rescue delegates to SDK (worktree.cleanup-wave)', ()
});
});
}
// ────────────────────────────────────────────────────────────────────────
// Folded from tests/bug-630-wave-cleanup-orchestrator-root.test.cjs — consolidation epic #1969 (B6 #1975)
// ────────────────────────────────────────────────────────────────────────
{
const { describe: __foldDescribe } = require('node:test');
__foldDescribe("folded:bug-630-wave-cleanup-orchestrator-root (consolidation epic #1969 B6 #1975)", () => {
// allow-test-rule: source-text-is-the-product (see #630)
// execute-phase.md is the shipped orchestration contract for wave execution and
// cleanup. Bug #630: the two wave-cleanup guards resolved PRIMARY_WT from
// `git worktree list --porcelain`'s first entry — always the main checkout —
// so an orchestrator running from a non-primary (per-phase lane) worktree was
// cd'd off its own lane and tripped the #3174 branch-drift assertion at cleanup,
// refusing merge-back. The fix persists the dispatch-time orchestrator root in
// WAVE_WORKTREE_MANIFEST and pins cleanup to that, falling back to first-entry
// only for pre-#630 manifests.
//
// This file locks the source contract (the .md is the product) AND behaviorally
// proves the pivot by running the shipped manifest-reader one-liner against a
// real non-primary-worktree git topology.
'use strict';
const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { execFileSync } = require('node:child_process');
const { cleanup } = require('./helpers.cjs');
const EXECUTE_PHASE_MD = path.join(__dirname, '..', 'gsd-core', 'workflows', 'execute-phase.md');
function readMd() {
return fs.readFileSync(EXECUTE_PHASE_MD, 'utf8');
}
// Pull the exact `node -e '...'` manifest-reader script shipped in the cleanup
// guard, so the behavioral test exercises the real shipped code, not a copy.
function extractManifestReaderScript() {
const content = readMd();
// Anchor on `PRIMARY_WT=$(MANIFEST=...` so we grab the cleanup READER, not the
// dispatch-time writer one-liner (which shares the `MANIFEST="..." node -e` prefix).
const m = content.match(/PRIMARY_WT=\$\(MANIFEST="\$WAVE_WORKTREE_MANIFEST" node -e '([^']*)'\)/);
assert.ok(m, 'expected a `PRIMARY_WT=$(MANIFEST="$WAVE_WORKTREE_MANIFEST" node -e \'...\')` reader in execute-phase.md');
return m[1];
}
function git(cwd, args) {
return execFileSync('git', args, {
cwd,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
}).trim();
}
// Canonicalize a path the way the OS does. On Windows, os.tmpdir() can yield an 8.3
// short name (RUNNER~1) while `git worktree list` reports the long form (runneradmin);
// realpathSync.native reconciles both to the true canonical path so comparisons are stable.
function canon(p) {
return fs.realpathSync.native(p);
}
describe('bug #630 — wave-cleanup pins to the orchestrator root, not git-worktree-list first entry', () => {
test('execute-phase.md is readable', () => {
assert.ok(readMd().length > 0, 'execute-phase.md must not be empty');
});
// ── Source contract (the .md is the product) ──────────────────────────────
test('dispatch persists the orchestrator root into the manifest (#630)', () => {
const content = readMd();
assert.match(
content,
/ORCH_ROOT=\$\(git rev-parse --show-toplevel\)/,
'manifest init must capture the dispatch-time orchestrator root via show-toplevel',
);
assert.match(
content,
/orchestrator_root:\s*process\.env\.ORCH_ROOT/,
'manifest init must write orchestrator_root into WAVE_WORKTREE_MANIFEST',
);
});
test('both cleanup guards resolve PRIMARY_WT from the manifest orchestrator_root (#630)', () => {
const content = readMd();
const readers = content.match(
/PRIMARY_WT=\$\(MANIFEST="\$WAVE_WORKTREE_MANIFEST" node -e '[^']*orchestrator_root[^']*'\)/g,
);
assert.ok(
readers && readers.length >= 2,
`both wave-cleanup guards (templated + cleanup-tail) must read orchestrator_root from the manifest; found ${readers ? readers.length : 0}`,
);
});
test('first-entry resolution survives only as a guarded fallback, never the sole resolver (#630)', () => {
const content = readMd();
// Every remaining first-entry resolution must be preceded by the `[ -n "$PRIMARY_WT" ] ||`
// guard, i.e. it only runs when the manifest lookup produced nothing.
const firstEntryLines = content.match(/^.*git worktree list --porcelain \| awk '\/\^worktree \/.*$/gm) || [];
for (const line of firstEntryLines) {
assert.match(
line,
/\[ -n "\$PRIMARY_WT" \] \|\|/,
`first-entry resolution must be a guarded fallback, not the primary resolver: ${line.trim()}`,
);
}
assert.ok(firstEntryLines.length >= 2, 'expected the fallback in both cleanup guards');
});
// ── Behavioral proof of the pivot ─────────────────────────────────────────
test('shipped manifest reader resolves to the lane worktree, while first-entry resolves to main (#630)', () => {
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-630-'));
try {
const mainDir = path.join(tmpRoot, 'main');
fs.mkdirSync(mainDir);
git(mainDir, ['-c', 'init.defaultBranch=main', 'init', '-q']);
git(mainDir, ['config', 'user.email', 'test@example.com']);
git(mainDir, ['config', 'user.name', 'Test']);
fs.writeFileSync(path.join(mainDir, 'f.txt'), 'x\n');
git(mainDir, ['add', '.']);
git(mainDir, ['commit', '-q', '-m', 'init']);
// Non-primary worktree on a per-phase lane branch.
const laneDir = path.join(tmpRoot, 'lane');
git(mainDir, ['worktree', 'add', '-q', '-b', 'feat/lane', laneDir]);
const realMain = canon(mainDir);
const realLane = canon(laneDir);
// Manifest as written at dispatch: orchestrator_root is the lane (the orchestrator runs there).
const manifest = path.join(tmpRoot, 'wave.json');
fs.writeFileSync(manifest, JSON.stringify({ orchestrator_root: realLane, worktrees: [] }) + '\n');
// Run the EXACT shipped reader one-liner.
const script = extractManifestReaderScript();
const resolved = execFileSync('node', ['-e', script], {
cwd: laneDir,
env: { ...process.env, MANIFEST: manifest },
encoding: 'utf8',
}).trim();
// The buggy first-entry resolution (run from the lane) yields the MAIN checkout.
const firstEntry = canon(
git(laneDir, ['worktree', 'list', '--porcelain'])
.split('\n')
.find(l => l.startsWith('worktree '))
.slice('worktree '.length),
);
assert.equal(canon(resolved), realLane, 'manifest reader must resolve to the orchestrator lane worktree');
assert.equal(firstEntry, realMain, 'sanity: first-entry resolution points at the main checkout (the #630 bug target)');
assert.notEqual(canon(resolved), firstEntry, 'the fix must diverge from the old first-entry behavior for a lane orchestrator');
// The #3174 branch assertion now passes (pinned to lane → branch matches EXPECTED_BRANCH);
// pinning to first-entry (main) would have failed it.
const expectedBranch = 'feat/lane';
assert.equal(git(resolved, ['rev-parse', '--abbrev-ref', 'HEAD']), expectedBranch, 'lane pin satisfies the #3174 branch check');
assert.notEqual(git(firstEntry, ['rev-parse', '--abbrev-ref', 'HEAD']), expectedBranch, 'first-entry pin would have tripped the #3174 branch check');
} finally {
cleanup(tmpRoot);
}
});
test('manifest reader falls through (empty output) when orchestrator_root is absent — fallback engages (#630)', () => {
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-630-fb-'));
try {
const manifest = path.join(tmpRoot, 'legacy.json');
// Pre-#630 manifest shape: no orchestrator_root.
fs.writeFileSync(manifest, JSON.stringify({ worktrees: [] }) + '\n');
const script = extractManifestReaderScript();
const out = execFileSync('node', ['-e', script], {
env: { ...process.env, MANIFEST: manifest },
encoding: 'utf8',
});
assert.equal(out, '', 'reader must emit nothing for a manifest without orchestrator_root so the first-entry fallback engages');
} finally {
cleanup(tmpRoot);
}
});
});
});
}
// ────────────────────────────────────────────────────────────────────────
// Folded from tests/enh-48-cwd-drift-guard-e2e.test.cjs — consolidation epic #1969 (B6 #1975)
// ────────────────────────────────────────────────────────────────────────
{
const { describe: __foldDescribe } = require('node:test');
__foldDescribe("folded:enh-48-cwd-drift-guard-e2e (consolidation epic #1969 B6 #1975)", () => {
// allow-test-rule: integration-test-input (see #48)
// Reads execute-phase.md to extract + execute the cwd-drift guard bash snippet against real git worktrees.
'use strict';
const { describe, test, before, after } = require('node:test');
const assert = require('node:assert/strict');
const { execSync, spawnSync } = require('node:child_process');
const fs = require('node:fs');
const path = require('node:path');
const os = require('node:os');
const { cleanup } = require('./helpers.cjs');
const REPO_ROOT = path.join(__dirname, '..');
const EXECUTE_PHASE_PATH = path.join(REPO_ROOT, 'gsd-core', 'workflows', 'execute-phase.md');
// ---------------------------------------------------------------------------
// Extract the cwd-drift guard bash block from execute-phase.md
// ---------------------------------------------------------------------------
/**
* Reads execute-phase.md and extracts the bash fenced block that implements
* the orchestrator cwd-drift guard inside <step name="execute_waves">.
*
* Algorithm:
* 1. Find <step name="execute_waves">
* 2. After that, find the first occurrence of "cwd-drift guard"
* 3. After that, find the first ```bash fence
* 4. Return the body between ```bash\n and the closing ```
*
* Throws with a clear message if any step fails or sanity checks don't pass.
*/
function extractCwdGuardBash() {
const content = fs.readFileSync(EXECUTE_PHASE_PATH, 'utf-8');
const stepMarker = '<step name="execute_waves">';
const stepIdx = content.indexOf(stepMarker);
if (stepIdx === -1) {
throw new Error(`extractCwdGuardBash: could not find "${stepMarker}" in ${EXECUTE_PHASE_PATH}`);
}
const afterStep = content.slice(stepIdx + stepMarker.length);
const driftMarker = 'cwd-drift guard';
const driftIdx = afterStep.indexOf(driftMarker);
if (driftIdx === -1) {
throw new Error(`extractCwdGuardBash: could not find "${driftMarker}" after execute_waves step in ${EXECUTE_PHASE_PATH}`);
}
const afterDrift = afterStep.slice(driftIdx + driftMarker.length);
// Extract the first ```bash|sh fenced block using a CRLF-safe regex.
// \r?\n tolerates both LF (Unix) and CRLF (Windows autocrlf=true checkouts).
const fenceRe = /```(?:bash|sh)\r?\n([\s\S]*?)```/;
const fenceMatch = fenceRe.exec(afterDrift);
if (!fenceMatch) {
throw new Error(`extractCwdGuardBash: could not find \`\`\`bash fence after cwd-drift guard heading in ${EXECUTE_PHASE_PATH}`);
}
const guardBash = fenceMatch[1];
if (!guardBash.trim()) {
throw new Error('extractCwdGuardBash: extracted bash block is empty');
}
if (!guardBash.includes('git rev-parse --show-toplevel')) {
throw new Error('extractCwdGuardBash: sanity check failed — extracted block does not contain "git rev-parse --show-toplevel"');
}
if (!guardBash.includes('worktree-agent-')) {
throw new Error('extractCwdGuardBash: sanity check failed — extracted block does not contain "worktree-agent-"');
}
return guardBash;
}
// ---------------------------------------------------------------------------
// Run guard helper
// ---------------------------------------------------------------------------
/**
* Run the guard bash snippet in a given cwd using bash -c.
* Returns { status, stderr }.
*/
function runGuard(guardBash, cwd) {
const result = spawnSync('bash', ['-c', guardBash], {
cwd,
encoding: 'utf-8',
});
return { status: result.status, stderr: result.stderr || '' };
}
// ---------------------------------------------------------------------------
// Fixtures
// ---------------------------------------------------------------------------
let upstreamDir; // bare upstream git repo (the main worktree)
let featureDir; // normal feature worktree on branch workspace/feature-x
let agentWtDir; // agent worktree on branch worktree-agent-deadbeef
let agentSubdir; // subdirectory inside agentWtDir
let legitUnderClaude; // non-agent worktree whose PATH is under .claude/worktrees/
const dirsToCleanup = [];
function git(cwd, args) {
return execSync(`git ${args.map(a => `"${a}"`).join(' ')}`, {
cwd,
encoding: 'utf-8',
stdio: ['ignore', 'pipe', 'pipe'],
});
}
before(() => {
// --- upstream: the main repo with an initial commit ---
upstreamDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-48-upstream-'));
dirsToCleanup.push(upstreamDir);
git(upstreamDir, ['init', '-b', 'main']);
git(upstreamDir, ['config', 'user.email', 'test@example.com']);
git(upstreamDir, ['config', 'user.name', 'Test User']);
git(upstreamDir, ['config', 'commit.gpgsign', 'false']);
fs.writeFileSync(path.join(upstreamDir, 'README.md'), '# test\n');
git(upstreamDir, ['add', 'README.md']);
git(upstreamDir, ['commit', '-m', 'chore: init']);
// --- feature worktree: non-agent branch, path outside .claude/worktrees ---
featureDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-48-feature-'));
dirsToCleanup.push(featureDir);
// git worktree add creates the directory itself; remove so it can do so
fs.rmdirSync(featureDir);
git(upstreamDir, ['worktree', 'add', '-b', 'workspace/feature-x', featureDir]);
// --- agent worktree: branch worktree-agent-deadbeef ---
// Sits under featureDir/.claude/worktrees/agent-deadbeef
const agentWtParent = path.join(featureDir, '.claude', 'worktrees');
fs.mkdirSync(agentWtParent, { recursive: true });
agentWtDir = path.join(agentWtParent, 'agent-deadbeef');
git(upstreamDir, ['worktree', 'add', '-b', 'worktree-agent-deadbeef', agentWtDir]);
// --- subdir inside agent worktree ---
agentSubdir = path.join(agentWtDir, 'src', 'deep');
fs.mkdirSync(agentSubdir, { recursive: true });
// --- legitUnderClaude: non-agent worktree whose PATH is under .claude/worktrees/ ---
// This proves the guard discriminates by branch name, not path.
const legitParent = path.join(upstreamDir, '.claude', 'worktrees');
fs.mkdirSync(legitParent, { recursive: true });
legitUnderClaude = path.join(legitParent, 'legit-feature');
git(upstreamDir, ['worktree', 'add', '-b', 'workspace/legit', legitUnderClaude]);
});
after(() => {
// Prune stale worktree metadata before removing dirs
try { git(upstreamDir, ['worktree', 'prune']); } catch (_) { /* best-effort */ }
for (const d of dirsToCleanup) {
try { cleanup(d); } catch (_) { /* best-effort */ }
}
});
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('bug #48: orchestrator cwd-drift guard — executable e2e', () => {
let guardBash;
before(() => {
guardBash = extractCwdGuardBash();
});
test('guard passes from a feature worktree on a non-agent branch (exit 0)', () => {
const { status, stderr } = runGuard(guardBash, featureDir);
assert.equal(
status, 0,
`Expected exit 0 from feature worktree, got ${status}. stderr: ${stderr}`,
);
});
test('guard fails closed (exit 1) when cwd is inside an agent worktree', () => {
const { status, stderr } = runGuard(guardBash, agentWtDir);
assert.equal(
status, 1,
`Expected exit 1 from agent worktree, got ${status}. stderr: ${stderr}`,
);
assert.match(
stderr,
/agent worktree/i,
`Expected stderr to mention "agent worktree", got: ${stderr}`,
);
});
test('guard fails closed (exit 1) from a SUBDIRECTORY of an agent worktree (root resolution)', () => {
// git rev-parse --show-toplevel resolves to the worktree root regardless of cwd subdir.
// The guard must catch this via the branch-name check, not the path check.
const { status, stderr } = runGuard(guardBash, agentSubdir);
assert.equal(
status, 1,
`Expected exit 1 from agent worktree subdir, got ${status}. stderr: ${stderr}`,
);
});
test('guard does NOT blanket-refuse a non-agent worktree located under .claude/worktrees/ (exit 0)', () => {
// Discriminator is the worktree-agent-* branch namespace, NOT the path.
const { status, stderr } = runGuard(guardBash, legitUnderClaude);
assert.equal(
status, 0,
`Expected exit 0 from non-agent worktree under .claude/worktrees/, got ${status}. stderr: ${stderr}`,
);
});
test('guard fails closed (exit 1) when not inside a git repo', (t) => {
const nonRepoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-48-nongit-'));
try {
// Verify that git rev-parse --show-toplevel actually fails here.
// On some systems /tmp itself might be inside a git repo (e.g. if the
// user's HOME is a git repo). If it resolves, we must skip this test.
const check = spawnSync('git', ['rev-parse', '--show-toplevel'], {
cwd: nonRepoDir,
encoding: 'utf-8',
});
if (check.status === 0) {
t.skip('nonRepoDir unexpectedly resolved to a git repo — skipping');
return;
}
const { status, stderr } = runGuard(guardBash, nonRepoDir);
assert.equal(
status, 1,
`Expected exit 1 when not inside a git repo, got ${status}. stderr: ${stderr}`,
);
} finally {
try { cleanup(nonRepoDir); } catch (_) { /* best-effort */ }
}
});
});
});
}

File diff suppressed because it is too large Load Diff