diff --git a/CONTEXT.md b/CONTEXT.md index f2abc2440..702803990 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -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- (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- reference(s) — use /gsd: 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- reference(s) — use /gsd: instead" with line-number-precise violations` `DEFECT.AGENT-RETIRED-SLASH-SYNTAX-DRIFT.fix-forward=replace /gsd- with /gsd: 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 diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md index 3c34e4508..03d7a95a8 100644 --- a/docs/COMMANDS.md +++ b/docs/COMMANDS.md @@ -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`. --- diff --git a/docs/FEATURES.md b/docs/FEATURES.md index 9ed6de2ed..c959ba8e5 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -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 diff --git a/docs/adr/0002-command-contract-validation-module.md b/docs/adr/0002-command-contract-validation-module.md index 45ecfc545..fac60d04c 100644 --- a/docs/adr/0002-command-contract-validation-module.md +++ b/docs/adr/0002-command-contract-validation-module.md @@ -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 diff --git a/docs/ja-JP/COMMANDS.md b/docs/ja-JP/COMMANDS.md index 8bfad5ab7..d63034e51 100644 --- a/docs/ja-JP/COMMANDS.md +++ b/docs/ja-JP/COMMANDS.md @@ -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` の一部としても実行されます。 --- diff --git a/docs/ja-JP/FEATURES.md b/docs/ja-JP/FEATURES.md index 0109debc3..6f91953c6 100644 --- a/docs/ja-JP/FEATURES.md +++ b/docs/ja-JP/FEATURES.md @@ -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. **計測** — ワークフローの総プロンプトサイズを計算 diff --git a/docs/ko-KR/COMMANDS.md b/docs/ko-KR/COMMANDS.md index 768320955..56c8f3625 100644 --- a/docs/ko-KR/COMMANDS.md +++ b/docs/ko-KR/COMMANDS.md @@ -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`의 일부로도 실행됩니다. --- diff --git a/docs/pt-BR/COMMANDS.md b/docs/pt-BR/COMMANDS.md index c4591298a..e8b74bb13 100644 --- a/docs/pt-BR/COMMANDS.md +++ b/docs/pt-BR/COMMANDS.md @@ -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`. --- diff --git a/docs/zh-CN/COMMANDS.md b/docs/zh-CN/COMMANDS.md index aa187089c..10d808254 100644 --- a/docs/zh-CN/COMMANDS.md +++ b/docs/zh-CN/COMMANDS.md @@ -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` 运行。 --- diff --git a/docs/zh-CN/FEATURES.md b/docs/zh-CN/FEATURES.md index 993df8f55..abfbea94e 100644 --- a/docs/zh-CN/FEATURES.md +++ b/docs/zh-CN/FEATURES.md @@ -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. **测量** — 计算工作流的总提示大小 diff --git a/scripts/lint-allow-test-rule-refs.allowlist.json b/scripts/lint-allow-test-rule-refs.allowlist.json index 73f23c696..8f5070836 100644 --- a/scripts/lint-allow-test-rule-refs.allowlist.json +++ b/scripts/lint-allow-test-rule-refs.allowlist.json @@ -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 :: 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", diff --git a/scripts/lint-regression-test-names.allowlist.json b/scripts/lint-regression-test-names.allowlist.json index f4e51c55e..fe51488c7 100644 --- a/scripts/lint-regression-test-names.allowlist.json +++ b/scripts/lint-regression-test-names.allowlist.json @@ -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" -] +[] diff --git a/scripts/lint-test-file-count.allowlist.json b/scripts/lint-test-file-count.allowlist.json index d096d1033..14116eb1b 100644 --- a/scripts/lint-test-file-count.allowlist.json +++ b/scripts/lint-test-file-count.allowlist.json @@ -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", diff --git a/tests/bug-131-release-tarball-smoke-explicit-home.test.cjs b/tests/bug-131-release-tarball-smoke-explicit-home.test.cjs deleted file mode 100644 index f0109c31f..000000000 --- a/tests/bug-131-release-tarball-smoke-explicit-home.test.cjs +++ /dev/null @@ -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', - ); - }); -}); diff --git a/tests/bug-1367-claude-local-flat-command-layout.test.cjs b/tests/bug-1367-claude-local-flat-command-layout.test.cjs deleted file mode 100644 index e333139ca..000000000 --- a/tests/bug-1367-claude-local-flat-command-layout.test.cjs +++ /dev/null @@ -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/.md` (subdirectory, bare names), causing Claude Code - * to register them as `/gsd:` (colon namespace). The fix changes the layout to - * write flat `gsd-.md` files at `.claude/commands/` level so Claude Code - * registers `/gsd-` (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-.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-.md layout for claude local install -// --------------------------------------------------------------------------- - -describe('bug #1367 — Claude local install uses flat gsd-.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-.md names (not bare names in a subdirectory)', () => { - // The fix: commands land as .claude/commands/gsd-.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/.md subdirectory ` + - `which causes /gsd: colon namespace in Claude Code.`, - ); - }); - - test('L2: known commands land as flat gsd-.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/.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: (colon) instead of /gsd- (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.`, - ); - }); -}); diff --git a/tests/bug-1834-sh-hooks-installed.test.cjs b/tests/bug-1834-sh-hooks-installed.test.cjs deleted file mode 100644 index 1ba7e3beb..000000000 --- a/tests/bug-1834-sh-hooks-installed.test.cjs +++ /dev/null @@ -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` - ); - } - }); -}); diff --git a/tests/bug-1974-context-exhaustion-record.test.cjs b/tests/bug-1974-context-exhaustion-record.test.cjs deleted file mode 100644 index aed404607..000000000 --- a/tests/bug-1974-context-exhaustion-record.test.cjs +++ /dev/null @@ -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. -}); diff --git a/tests/bug-211-launcher-home-fallback.test.cjs b/tests/bug-211-launcher-home-fallback.test.cjs deleted file mode 100644 index fe4909953..000000000 --- a/tests/bug-211-launcher-home-fallback.test.cjs +++ /dev/null @@ -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); - } - }); -}); diff --git a/tests/bug-2136-sh-hook-version.test.cjs b/tests/bug-2136-sh-hook-version.test.cjs deleted file mode 100644 index e24f107f5..000000000 --- a/tests/bug-2136-sh-hook-version.test.cjs +++ /dev/null @@ -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.` - ); - }); -}); diff --git a/tests/bug-2344-read-guard-claudecode-env.test.cjs b/tests/bug-2344-read-guard-claudecode-env.test.cjs deleted file mode 100644 index 0f52425da..000000000 --- a/tests/bug-2344-read-guard-claudecode-env.test.cjs +++ /dev/null @@ -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')); - }); -}); diff --git a/tests/bug-2451-context-monitor-over-report.test.cjs b/tests/bug-2451-context-monitor-over-report.test.cjs deleted file mode 100644 index 6b666890e..000000000 --- a/tests/bug-2451-context-monitor-over-report.test.cjs +++ /dev/null @@ -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).' - ); - }); -}); diff --git a/tests/bug-2520-read-guard-hook-subprocess-env.test.cjs b/tests/bug-2520-read-guard-hook-subprocess-env.test.cjs deleted file mode 100644 index 7b16cd2c1..000000000 --- a/tests/bug-2520-read-guard-hook-subprocess-env.test.cjs +++ /dev/null @@ -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')); - }); -}); diff --git a/tests/bug-2543-gsd-slash-namespace.test.cjs b/tests/bug-2543-gsd-slash-namespace.test.cjs deleted file mode 100644 index bd313bf9f..000000000 --- a/tests/bug-2543-gsd-slash-namespace.test.cjs +++ /dev/null @@ -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:` 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:` -> `/gsd-`). - * - * 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:` (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-` (hyphen) per - * bug-3584's contract. Those files are EXCLUDED from this scan. - * - * Scoped invariant enforced here: - * No `/gsd-` 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-` (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- 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- reference(s) — use /gsd: 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- to /gsd:', () => { - 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' - ); - }); -}); diff --git a/tests/bug-260-worktree-path-guard.test.cjs b/tests/bug-260-worktree-path-guard.test.cjs deleted file mode 100644 index 93d02f967..000000000 --- a/tests/bug-260-worktree-path-guard.test.cjs +++ /dev/null @@ -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 = /wt - // sibling = /wt-sibling ← shares "wt" prefix with the worktree root - // target = /wt-sibling/file.ts - // - // A naive startsWith(wtRoot) check would wrongly classify "/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 /wt and a separate git repo at /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 /wt (using sibMainRepo as its host). - git(sibMainRepo, ['worktree', 'add', '-q', '-b', 'worktree-agent-sib-test', wtDir]); - // Create a separate git repo at /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/.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' - ); - }); -}); diff --git a/tests/bug-261-worktree-force-add-guard.test.cjs b/tests/bug-261-worktree-force-add-guard.test.cjs deleted file mode 100644 index 070b91821..000000000 --- a/tests/bug-261-worktree-force-add-guard.test.cjs +++ /dev/null @@ -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); - } - }); -}); diff --git a/tests/bug-2772-gitmodules-path-intersection.test.cjs b/tests/bug-2772-gitmodules-path-intersection.test.cjs deleted file mode 100644 index 9efae6448..000000000 --- a/tests/bug-2772-gitmodules-path-intersection.test.cjs +++ /dev/null @@ -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=` 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=, 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, //, '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); - } - }); -}); diff --git a/tests/bug-2808-skill-hyphen-name.test.cjs b/tests/bug-2808-skill-hyphen-name.test.cjs deleted file mode 100644 index 28f885a61..000000000 --- a/tests/bug-2808-skill-hyphen-name.test.cjs +++ /dev/null @@ -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:` (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:"). 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- (not gsd:). - * 3. No workflow contains a Skill(skill="gsd:") 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- (not gsd:)', () => { - 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:") 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('', 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-: ' + 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-/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'); - }); -}); diff --git a/tests/bug-2916-handle-branching-default-base.test.cjs b/tests/bug-2916-handle-branching-default-base.test.cjs deleted file mode 100644 index 4225f4823..000000000 --- a/tests/bug-2916-handle-branching-default-base.test.cjs +++ /dev/null @@ -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 - * 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 ... 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 && /^\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 ... 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); - } - }); -}); diff --git a/tests/bug-2995-post-install-script-paths.test.cjs b/tests/bug-2995-post-install-script-paths.test.cjs deleted file mode 100644 index 4478c6075..000000000 --- a/tests/bug-2995-post-install-script-paths.test.cjs +++ /dev/null @@ -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}/ 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(', ')}`, - ); - }); -}); diff --git a/tests/bug-3019-help-passthrough.test.cjs b/tests/bug-3019-help-passthrough.test.cjs deleted file mode 100644 index 3bb311dd9..000000000 --- a/tests/bug-3019-help-passthrough.test.cjs +++ /dev/null @@ -1,120 +0,0 @@ -/** - * Regression test for bug #3019. - * - * `gsd-sdk query --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 --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'); - }); -}); diff --git a/tests/bug-3442-shim-projection-drift-guard.test.cjs b/tests/bug-3442-shim-projection-drift-guard.test.cjs deleted file mode 100644 index c959b7d13..000000000 --- a/tests/bug-3442-shim-projection-drift-guard.test.cjs +++ /dev/null @@ -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); - } - }); -}); diff --git a/tests/bug-3446-resume-continue-here-discovery.test.cjs b/tests/bug-3446-resume-continue-here-discovery.test.cjs deleted file mode 100644 index 9d1323ed2..000000000 --- a/tests/bug-3446-resume-continue-here-discovery.test.cjs +++ /dev/null @@ -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 -// `` 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(''); - assert.ok(stepStart >= 0, 'resume-project.md must contain a check_incomplete_work step'); - const stepEnd = md.indexOf('', stepStart); - assert.ok( - stepEnd >= 0, - 'check_incomplete_work step must have a closing 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)}`, - ); - }); -}); diff --git a/tests/bug-3491-nested-git-worktree.test.cjs b/tests/bug-3491-nested-git-worktree.test.cjs deleted file mode 100644 index d31754f60..000000000 --- a/tests/bug-3491-nested-git-worktree.test.cjs +++ /dev/null @@ -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', - ); -}); diff --git a/tests/bug-3509-path-spaces.test.cjs b/tests/bug-3509-path-spaces.test.cjs deleted file mode 100644 index 2b233bc1f..000000000 --- a/tests/bug-3509-path-spaces.test.cjs +++ /dev/null @@ -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'); - }); -}); diff --git a/tests/bug-3523-cjs-loadconfig-branching-strategy-warning.test.cjs b/tests/bug-3523-cjs-loadconfig-branching-strategy-warning.test.cjs deleted file mode 100644 index 11bbdc1d0..000000000 --- a/tests/bug-3523-cjs-loadconfig-branching-strategy-warning.test.cjs +++ /dev/null @@ -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}` - ); - }); -}); diff --git a/tests/bug-3542-executor-git-stash-prohibition.test.cjs b/tests/bug-3542-executor-git-stash-prohibition.test.cjs deleted file mode 100644 index 0f48fea7b..000000000 --- a/tests/bug-3542-executor-git-stash-prohibition.test.cjs +++ /dev/null @@ -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 :` / `git diff -- `. - 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 :` / ' + - '`git diff -- `) 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); - } -}); diff --git a/tests/bug-3588-npm-audit-clean.test.cjs b/tests/bug-3588-npm-audit-clean.test.cjs deleted file mode 100644 index 88adb394c..000000000 --- a/tests/bug-3588-npm-audit-clean.test.cjs +++ /dev/null @@ -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}`); - }); -}); diff --git a/tests/bug-3668-workflow-runtime-resolution.test.cjs b/tests/bug-3668-workflow-runtime-resolution.test.cjs deleted file mode 100644 index 9c228a5b1..000000000 --- a/tests/bug-3668-workflow-runtime-resolution.test.cjs +++ /dev/null @@ -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/); - }); -}); diff --git a/tests/bug-3677-agent-colon-namespace-leak.test.cjs b/tests/bug-3677-agent-colon-namespace-leak.test.cjs deleted file mode 100644 index 0355db8c8..000000000 --- a/tests/bug-3677-agent-colon-namespace-leak.test.cjs +++ /dev/null @@ -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:` 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:` 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: to /gsd- 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:` 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( - `(? 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:` 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.'); - }); - }); -}); diff --git a/tests/bug-3678-executor-commit-docs-respect.test.cjs b/tests/bug-3678-executor-commit-docs-respect.test.cjs deleted file mode 100644 index f863a9300..000000000 --- a/tests/bug-3678-executor-commit-docs-respect.test.cjs +++ /dev/null @@ -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 `/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\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).', - ); - }); - }); -}); diff --git a/tests/bug-3683-command-colon-namespace-leak.test.cjs b/tests/bug-3683-command-colon-namespace-leak.test.cjs deleted file mode 100644 index c7354b8ee..000000000 --- a/tests/bug-3683-command-colon-namespace-leak.test.cjs +++ /dev/null @@ -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:` colon refs is -// a behavioral test of the install transform, not source-grep theater. - -/** - * Regression for #3683 — installed command bodies leak `/gsd:` 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:` 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:` or `gsd:` - * (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( - `(? { - - 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: 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/.md (bare names in a - // subdir), causing Claude Code to namespace them as /gsd: (colon form). - // After #1367: commands write flat gsd-.md at commands/ level so Claude - // Code registers them as /gsd- (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-.md (flat, - // hyphen-prefixed). The old .claude/commands/gsd/.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/.md subdirectory layout.`, - ); - // The old subdirectory must NOT exist (it caused /gsd: colon namespace) - const oldSubdir = path.join(commandsDir, 'gsd'); - assert.ok( - !fs.existsSync(oldSubdir), - `commands/gsd/ subdir must NOT exist after install (it causes /gsd: colon namespace in Claude Code). ` + - `#1367 fix: use flat gsd-.md at commands/ level instead.`, - ); - }); - - test('E1: no staged command body contains /gsd: 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- 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(', ')}`, - ); - }); - }); -}); diff --git a/tests/bug-3683-workflow-colon-namespace-leak.test.cjs b/tests/bug-3683-workflow-colon-namespace-leak.test.cjs deleted file mode 100644 index 4732c715b..000000000 --- a/tests/bug-3683-workflow-colon-namespace-leak.test.cjs +++ /dev/null @@ -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:` -// colon refs is a behavioral test of the install transform, not -// source-grep theater. - -/** - * Regression for #3683 — installed workflow/reference bodies leak `/gsd:` - * 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:` 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( - `(? { - 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: 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- for claude runtime. Offenders: ${relOffenders.join(', ')}`, - ); - }); - - test('W4: no staged reference body contains /gsd: 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- 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- (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- 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- 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: colon form, got: ${JSON.stringify(text)}`, - ); - // Token-level: extract real command tokens (/gsd- 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- 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- hyphen form, got: ${JSON.stringify(text)}`, - ); - assert.ok( - !/\/gsd:[a-z]/.test(text), - `secure-phase.md line ${lineNo}: ▶-routing line must not contain /gsd: 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: 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: 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: colon form — ` + - `these must resolve to /gsd- 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: 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: 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(', ')}`, - ); - }); - }); -}); diff --git a/tests/bug-3689-resume-glob-nomatch.test.cjs b/tests/bug-3689-resume-glob-nomatch.test.cjs deleted file mode 100644 index 0fecc4258..000000000 --- a/tests/bug-3689-resume-glob-nomatch.test.cjs +++ /dev/null @@ -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 -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.', - ); - }); -}); diff --git a/tests/bug-444-resolver-local-claude-install.test.cjs b/tests/bug-444-resolver-local-claude-install.test.cjs deleted file mode 100644 index c20ee5c31..000000000 --- a/tests/bug-444-resolver-local-claude-install.test.cjs +++ /dev/null @@ -1,212 +0,0 @@ -'use strict'; -/** - * Regression test for bug #444: gsd_run resolver must probe - * /.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 /.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); - } - }); -}); diff --git a/tests/bug-619-codebase-drift-gate-shim.test.cjs b/tests/bug-619-codebase-drift-gate-shim.test.cjs deleted file mode 100644 index 919e95540..000000000 --- a/tests/bug-619-codebase-drift-gate-shim.test.cjs +++ /dev/null @@ -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'); - }); -}); diff --git a/tests/bug-622-graphify-optional-graph-html.test.cjs b/tests/bug-622-graphify-optional-graph-html.test.cjs deleted file mode 100644 index a3a206a5e..000000000 --- a/tests/bug-622-graphify-optional-graph-html.test.cjs +++ /dev/null @@ -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'), ''); - } - - // .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', - ); - }); -}); diff --git a/tests/bug-630-wave-cleanup-orchestrator-root.test.cjs b/tests/bug-630-wave-cleanup-orchestrator-root.test.cjs deleted file mode 100644 index 28f357cad..000000000 --- a/tests/bug-630-wave-cleanup-orchestrator-root.test.cjs +++ /dev/null @@ -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); - } - }); -}); diff --git a/tests/bug-641-files-from-suite-token.test.cjs b/tests/bug-641-files-from-suite-token.test.cjs deleted file mode 100644 index 327266de3..000000000 --- a/tests/bug-641-files-from-suite-token.test.cjs +++ /dev/null @@ -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'); - }); -}); diff --git a/tests/bug-685-windowshide-spawn.test.cjs b/tests/bug-685-windowshide-spawn.test.cjs deleted file mode 100644 index 7d8fc683c..000000000 --- a/tests/bug-685-windowshide-spawn.test.cjs +++ /dev/null @@ -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')}`); - }); -}); diff --git a/tests/bug-891-non-claude-runtime-home-fallback.test.cjs b/tests/bug-891-non-claude-runtime-home-fallback.test.cjs deleted file mode 100644 index 0e2770540..000000000 --- a/tests/bug-891-non-claude-runtime-home-fallback.test.cjs +++ /dev/null @@ -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'), - ); - }); -}); diff --git a/tests/bug-925-context-monitor-hook-event-name.test.cjs b/tests/bug-925-context-monitor-hook-event-name.test.cjs deleted file mode 100644 index 9257d5936..000000000 --- a/tests/bug-925-context-monitor-hook-event-name.test.cjs +++ /dev/null @@ -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' - ); - }); -}); diff --git a/tests/bug-941-managed-hooks-registry-manifest.test.cjs b/tests/bug-941-managed-hooks-registry-manifest.test.cjs deleted file mode 100644 index b1d3bdef9..000000000 --- a/tests/bug-941-managed-hooks-registry-manifest.test.cjs +++ /dev/null @@ -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'), - ); - }); -}); diff --git a/tests/bug-969-test-infra-flake-hardening.test.cjs b/tests/bug-969-test-infra-flake-hardening.test.cjs deleted file mode 100644 index 9bc2b3e58..000000000 --- a/tests/bug-969-test-infra-flake-hardening.test.cjs +++ /dev/null @@ -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 /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'); - }); -}); diff --git a/tests/capability-registry.test.cjs b/tests/capability-registry.test.cjs index c84ed54b8..a994b2f87 100644 --- a/tests/capability-registry.test.cjs +++ b/tests/capability-registry.test.cjs @@ -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); + }); +}); + }); +} diff --git a/tests/ci-test-scope.test.cjs b/tests/ci-test-scope.test.cjs index 57fd32344..4642eda6e 100644 --- a/tests/ci-test-scope.test.cjs +++ b/tests/ci-test-scope.test.cjs @@ -629,3 +629,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'); + }); +}); + }); +} diff --git a/tests/commit-docs-bypass.test.cjs b/tests/commit-docs-bypass.test.cjs index ea73ca021..809fb8419 100644 --- a/tests/commit-docs-bypass.test.cjs +++ b/tests/commit-docs-bypass.test.cjs @@ -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 `/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\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).', + ); + }); + }); +}); + }); +} diff --git a/tests/config-get-default.test.cjs b/tests/config-get-default.test.cjs index baf525063..278c1c654 100644 --- a/tests/config-get-default.test.cjs +++ b/tests/config-get-default.test.cjs @@ -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. (/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); +}); + }); +} diff --git a/tests/config-loader.test.cjs b/tests/config-loader.test.cjs index 2cd95036c..8fe33753d 100644 --- a/tests/config-loader.test.cjs +++ b/tests/config-loader.test.cjs @@ -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}` + ); + }); +}); + }); +} diff --git a/tests/dispatcher.test.cjs b/tests/dispatcher.test.cjs index 30454b3d8..254fedf0f 100644 --- a/tests/dispatcher.test.cjs +++ b/tests/dispatcher.test.cjs @@ -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 --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 --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'); + }); +}); + }); +} diff --git a/tests/drift-detection.test.cjs b/tests/drift-detection.test.cjs index ebb59615d..b6d569ea8 100644 --- a/tests/drift-detection.test.cjs +++ b/tests/drift-detection.test.cjs @@ -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'); + }); +}); + }); +} diff --git a/tests/enh-1592-plan-drift-precheck.test.cjs b/tests/enh-1592-plan-drift-precheck.test.cjs deleted file mode 100644 index 4555ac2c1..000000000 --- a/tests/enh-1592-plan-drift-precheck.test.cjs +++ /dev/null @@ -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', - ); - }); -}); diff --git a/tests/enh-2380-sync-skills.test.cjs b/tests/enh-2380-sync-skills.test.cjs deleted file mode 100644 index 5e6855ed6..000000000 --- a/tests/enh-2380-sync-skills.test.cjs +++ /dev/null @@ -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 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' - ); - }); -}); diff --git a/tests/enh-2789-description-budget.test.cjs b/tests/enh-2789-description-budget.test.cjs deleted file mode 100644 index 41337bde8..000000000 --- a/tests/enh-2789-description-budget.test.cjs +++ /dev/null @@ -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')); - }); -}); diff --git a/tests/enh-2790-skill-consolidation.test.cjs b/tests/enh-2790-skill-consolidation.test.cjs deleted file mode 100644 index 9cb2f7486..000000000 --- a/tests/enh-2790-skill-consolidation.test.cjs +++ /dev/null @@ -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', - }); - }); -}); diff --git a/tests/enh-48-cwd-drift-guard-e2e.test.cjs b/tests/enh-48-cwd-drift-guard-e2e.test.cjs deleted file mode 100644 index a8bd9805a..000000000 --- a/tests/enh-48-cwd-drift-guard-e2e.test.cjs +++ /dev/null @@ -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 . - * - * Algorithm: - * 1. Find - * 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 = ''; - 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 */ } - } - }); -}); diff --git a/tests/feat-3039-help-tiered.test.cjs b/tests/feat-3039-help-tiered.test.cjs deleted file mode 100644 index e97d2e8ee..000000000 --- a/tests/feat-3039-help-tiered.test.cjs +++ /dev/null @@ -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|]`. -// 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 `` 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.md in compact scope (composable) - * - bare or `--full ` → 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 ` 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// 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 block (line-anchored)`, () => { - const content = read(path.join(MODES, f)); - // Anchor on start-of-line so prose mentions of `` inside - // blocks aren't counted. - const opens = (content.match(/^$/gm) || []).length; - const closes = (content.match(/^<\/reference>$/gm) || []).length; - assert.equal(opens, 1, `${f}: expected 1 opening line, got ${opens}`); - assert.equal(closes, 1, `${f}: expected 1 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(/([\s\S]*?)<\/progressive_disclosure>/); - assert.ok(m, 'dispatcher must contain a block'); - return m[1]; - } - - test('dispatcher block has exactly 5 routing rows', () => { - // 4 base tiers (brief, full, default, topic) + 1 composable row (--brief ). - 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 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[^|]*[\s\S]*?topic\.md[\s\S]*?compact/i); - }); - - test('dispatcher routes --full (or bare topic) to topic.md in full scope', () => { - // Bare topic, `--full `, or topic with leading `--` → full scope. - assert.match(dispatcher, /(bare topic|--full )[\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 ', () => { - // Discoverability: users need to know the composable form is supported - // (trek-e review finding #4). - assert.match(shim, /argument-hint:[^\n]*--brief[^\n]*/); - }); - - 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]*[\s\S]*/); - assert.match(topicSrc, /scope:.*full.*\|.*compact/i); - }); - - test('topic.md documents a compact scope distinct from full scope', () => { - // Trek-e review finding #4: --brief 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 /); - }); -}); - -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(', ')}`); - }); -}); diff --git a/tests/feat-3593-cli-negative-config.test.cjs b/tests/feat-3593-cli-negative-config.test.cjs deleted file mode 100644 index 46c612006..000000000 --- a/tests/feat-3593-cli-negative-config.test.cjs +++ /dev/null @@ -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'); -}); diff --git a/tests/feat-3593-cli-negative-harness.test.cjs b/tests/feat-3593-cli-negative-harness.test.cjs deleted file mode 100644 index 5ca2dfde6..000000000 --- a/tests/feat-3593-cli-negative-harness.test.cjs +++ /dev/null @@ -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. (/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); -}); diff --git a/tests/fix-1464-docs-manifest-validation.test.cjs b/tests/fix-1464-docs-manifest-validation.test.cjs deleted file mode 100644 index 78cd25df9..000000000 --- a/tests/fix-1464-docs-manifest-validation.test.cjs +++ /dev/null @@ -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); - }); -}); diff --git a/tests/fix-1521-real-install-stamping.test.cjs b/tests/fix-1521-real-install-stamping.test.cjs deleted file mode 100644 index 74d7b9cd8..000000000 --- a/tests/fix-1521-real-install-stamping.test.cjs +++ /dev/null @@ -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', - ); -}); diff --git a/tests/fix-1920-installer-ships-capability-generators.test.cjs b/tests/fix-1920-installer-ships-capability-generators.test.cjs deleted file mode 100644 index fda6e6856..000000000 --- a/tests/fix-1920-installer-ships-capability-generators.test.cjs +++ /dev/null @@ -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 ">=.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); - } - }); -}); diff --git a/tests/git-base-branch.test.cjs b/tests/git-base-branch.test.cjs index 11353b9ab..b6b06fdca 100644 --- a/tests/git-base-branch.test.cjs +++ b/tests/git-base-branch.test.cjs @@ -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 + * 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 ... 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 && /^\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 ... 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); + } + }); +}); + }); +} diff --git a/tests/graphify-visualization.test.cjs b/tests/graphify-visualization.test.cjs index b4b3dfc6a..92eda8f05 100644 --- a/tests/graphify-visualization.test.cjs +++ b/tests/graphify-visualization.test.cjs @@ -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'), ''); + } + + // .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', + ); + }); +}); + }); +} diff --git a/tests/init.test.cjs b/tests/init.test.cjs index f4b1f7242..964ce48f1 100644 --- a/tests/init.test.cjs +++ b/tests/init.test.cjs @@ -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', + ); +}); + }); +} diff --git a/tests/install-minimal-hooks.test.cjs b/tests/install-minimal-hooks.test.cjs index c886ba6cb..eb54239ca 100644 --- a/tests/install-minimal-hooks.test.cjs +++ b/tests/install-minimal-hooks.test.cjs @@ -3046,3 +3046,1942 @@ describe('enh-770: managed-hooks-registry includes gsd-config-reload.js', () => }); }); } + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-1754-js-hook-guard.test.cjs — consolidation epic #1969 (B1 #1970) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-1754-js-hook-guard (consolidation epic #1969 B1 #1970)", () => { +/** + * Regression tests for bug #1754 + * + * The installer must NOT register .js hook entries in settings.json when the + * corresponding .js file does not exist at the target path. The original bug: + * on fresh installs where hooks/dist/ was missing from the npm package (as in + * v1.32.0), the hook copy step produced no files, yet the registration step + * ran unconditionally for .js hooks — leaving users with "PreToolUse:Bash + * hook error" on every tool invocation. + * + * The .sh hooks already had fs.existsSync() guards (added in #1817). This + * test verifies the same defensive pattern exists for all .js hooks. + */ + +'use strict'; + +const { describe, test, before } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); + +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'); + +const JS_HOOKS = [ + { name: 'gsd-check-update.js', registrationAnchor: 'hasGsdUpdateHook' }, + { name: 'gsd-context-monitor.js', registrationAnchor: 'hasContextMonitorHook' }, + { name: 'gsd-prompt-guard.js', registrationAnchor: 'hasPromptGuardHook' }, + { name: 'gsd-read-guard.js', registrationAnchor: 'hasReadGuardHook' }, + { name: 'gsd-workflow-guard.js', registrationAnchor: 'hasWorkflowGuardHook' }, + { name: 'gsd-worktree-path-guard.js', registrationAnchor: 'hasWorktreePathGuardHook' }, +]; + +describe('bug #1754: .js hook registration guards', () => { + 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; + }); + + for (const { name, registrationAnchor } of JS_HOOKS) { + describe(`${name} registration`, () => { + test(`install.js checks file existence before registering ${name}`, () => { + // Find the registration block by locating the "has...Hook" variable + const anchorIdx = src.indexOf(registrationAnchor); + assert.ok( + anchorIdx !== -1, + `${registrationAnchor} variable not found in install.js` + ); + + // Extract a window around the registration block to find the guard + const blockStart = anchorIdx; + const blockEnd = Math.min(src.length, anchorIdx + 1200); + const block = src.slice(blockStart, blockEnd); + + // The block must contain an fs.existsSync check for the hook file + assert.ok( + block.includes('fs.existsSync') || block.includes('existsSync'), + `install.js must call fs.existsSync on the target path before registering ${name} ` + + `in settings.json. Without this guard, hooks are registered even when the .js file ` + + `was never copied (the root cause of #1754).` + ); + }); + + test(`install.js emits a warning when ${name} is missing`, () => { + // The hook file name (without extension) should appear in a warning message + const hookBaseName = name.replace('.js', ''); + const warnPattern = `Skipped`; + const anchorIdx = src.indexOf(registrationAnchor); + const block = src.slice(anchorIdx, Math.min(src.length, anchorIdx + 1200)); + + assert.ok( + block.includes(warnPattern) && block.includes(hookBaseName), + `install.js must emit a skip warning when ${name} is not found at the target path` + ); + }); + }); + } + + test('all .js hooks use the same guard pattern as .sh hooks', () => { + // Count existsSync calls in the hook registration section. + // There should be guards for all JS hooks plus the existing SH hooks. + // This test ensures new hooks added in the future follow the same pattern. + // ADR-857 phase 5f-1b: registration moved to runtime-hooks-surface.cts so scan the + // full concatenated source (install.js + runtime-hooks-surface.cts) rather than slicing. + const registrationSection = src; + + // Count unique hook file existence checks (pattern: path.join(targetDir, 'hooks', 'gsd-*.js')) + const jsGuards = (registrationSection.match(/gsd-[\w-]+\.js.*not found at target/g) || []); + const shGuards = (registrationSection.match(/gsd-[\w-]+\.sh.*not found at target/g) || []); + + assert.ok( + jsGuards.length >= JS_HOOKS.length, + `Expected at least ${JS_HOOKS.length} .js hook guards, found ${jsGuards.length}. ` + + `Every .js hook registration must check file existence before registering.` + ); + + assert.ok( + shGuards.length >= 3, + `Expected at least 3 .sh hook guards (validate-commit, session-state, phase-boundary), ` + + `found ${shGuards.length}.` + ); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-1817-sh-hook-guard.test.cjs — consolidation epic #1969 (B1 #1970) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-1817-sh-hook-guard (consolidation epic #1969 B1 #1970)", () => { +/** + * Regression tests for bug #1817 + * + * The installer must NOT register .sh hook entries in settings.json when the + * corresponding .sh file does not exist at the target path. The original bug: + * v1.32.0's npm package omitted the .sh files from hooks/dist/, so the copy + * step produced no files, yet the registration step ran unconditionally — + * leaving users with hook errors on every tool invocation. + * + * Defensive guard: before registering each .sh hook in settings.json, + * install.js must verify the target file exists. If it doesn't, skip + * registration and emit a warning. + */ + +'use strict'; + +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); + +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'); + +const SH_HOOKS = [ + { name: 'gsd-validate-commit.sh', settingsVar: 'validateCommitCommand' }, + { name: 'gsd-session-state.sh', settingsVar: 'sessionStateCommand' }, + { name: 'gsd-phase-boundary.sh', settingsVar: 'phaseBoundaryCommand' }, +]; + +describe('bug #1817: .sh hook registration guards', () => { + let src; + + // Read once — all tests in this suite share the same source snapshot. + // ADR-857 phase 5f-1b: hook registration moved to runtime-hooks-surface.cts. + // Concatenate both sources so structural assertions find patterns in either file. + try { + 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; + } catch { + src = ''; + } + + for (const { name, settingsVar } of SH_HOOKS) { + describe(`${name} registration`, () => { + test(`install.js checks file existence before registering ${name}`, () => { + // Find the block where this .sh hook is registered. + // Each registration block is preceded by the command variable declaration + // and followed by the next hook or end of registration section. + const varIdx = src.indexOf(settingsVar); + assert.ok(varIdx !== -1, `${settingsVar} variable not found in install.js`); + + // Extract ~900 chars around the variable to find the registration block + const blockStart = Math.max(0, varIdx - 50); + const blockEnd = Math.min(src.length, varIdx + 900); + 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 ${name} in settings.json. ` + + `Without this guard, hooks are registered even when the .sh file was never copied ` + + `(the root cause of #1817).` + ); + }); + }); + } +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/enh-1076-extended-hook-events-drive.test.cjs — consolidation epic #1969 (B1 #1970) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:enh-1076-extended-hook-events-drive (consolidation epic #1969 B1 #1970)", () => { +'use strict'; + +process.env.GSD_TEST_MODE = '1'; + +/** + * ADR-857 phase 5f-3: extended hook event guards are driven by the + * extendedHookEvents descriptor field, not hardcoded runtime-name checks. + * + * Before this change: + * - SubagentStop/Stop/PreCompact were wired only when (isQwen || runtime==='claude') + * - FileChanged was wired only when (runtime === 'claude') + * - BeforeAgent/AfterAgent/BeforeModel were wired only when (isGemini) + * + * After this change: + * - All three guard blocks are driven purely by extendedEvents.includes(eventName) + * - Any runtime (or arbitrary string) that passes the right extendedHookEvents + * array gets exactly those events registered, regardless of its runtime name. + * + * This suite proves descriptor-drive by calling applySettingsJsonHooks directly + * with a controlled extendedHookEvents array and asserting on settings.hooks. + * No source-grep; purely behavioral. + */ + +const { test, describe, before } = 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 REPO_ROOT = path.resolve(__dirname, '..'); +const HOOKS_DIST_DIR = path.join(REPO_ROOT, 'hooks', 'dist'); +const BUILD_HOOKS_SCRIPT = path.join(REPO_ROOT, 'scripts', 'build-hooks.js'); + +/** Idempotently ensure hooks/dist contains built .js files. */ +function ensureHooksDist() { + if (!fs.existsSync(HOOKS_DIST_DIR) || fs.readdirSync(HOOKS_DIST_DIR).filter(f => f.endsWith('.js')).length === 0) { + execFileSync(process.execPath, [BUILD_HOOKS_SCRIPT], { stdio: 'pipe' }); + } +} + +before(() => { + ensureHooksDist(); +}); + +const { applySettingsJsonHooks } = require('../bin/install.js'); +const { cleanup } = require('./helpers.cjs'); + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +/** Return all hook commands registered under an event key. */ +function hooksForEvent(settings, eventName) { + if (!settings || !settings.hooks || !Array.isArray(settings.hooks[eventName])) return []; + return settings.hooks[eventName].flatMap(entry => + (entry && Array.isArray(entry.hooks) ? entry.hooks : []) + .map(h => h && h.command) + .filter(Boolean) + ); +} + +/** True if any hook is registered under eventName. */ +function hasHooksFor(settings, eventName) { + return hooksForEvent(settings, eventName).length > 0; +} + +/** + * Create a temporary directory with stub hook files so fs.existsSync guards pass. + * Returns the targetDir path. + */ +function createStubTargetDir() { + const tmpDir = fs.mkdtempSync(path.join(require('node:os').tmpdir(), 'gsd-1076-')); + const hooksDir = path.join(tmpDir, 'hooks'); + fs.mkdirSync(hooksDir, { recursive: true }); + // Stubs for the hooks applySettingsJsonHooks existsSync-checks + const stubs = [ + 'gsd-check-update.js', + 'gsd-context-monitor.js', + 'gsd-prompt-guard.js', + 'gsd-read-guard.js', + 'gsd-read-injection-scanner.js', + 'gsd-config-reload.js', + 'gsd-workflow-guard.js', + 'gsd-worktree-path-guard.js', + 'gsd-validate-commit.sh', + 'gsd-session-state.sh', + 'gsd-phase-boundary.sh', + 'gsd-graphify-update.sh', + ]; + const hooksDistDir = path.join(REPO_ROOT, 'hooks', 'dist'); + for (const stub of stubs) { + const dest = path.join(hooksDir, stub); + const distSrc = path.join(hooksDistDir, stub); + if (fs.existsSync(distSrc)) { + fs.copyFileSync(distSrc, dest); + } else { + // Minimal stub so existsSync passes + const ext = path.extname(stub); + fs.writeFileSync(dest, ext === '.sh' ? '#!/bin/bash\n# stub\n' : '#!/usr/bin/env node\n// stub\n'); + } + try { fs.chmodSync(dest, 0o755); } catch { /* Windows */ } + } + return tmpDir; +} + +function cleanupDir(dir) { + cleanup(dir); +} + +/** + * Build the minimal opts bag for applySettingsJsonHooks. + * postToolEvent: 'PostToolUse' (default dialect). + * All commands: non-null strings so the "command truthy" guard passes. + */ +function buildOpts(targetDir, { runtime, extendedHookEvents }) { + const hookOpts = { platform: process.platform, runtime }; + const node = process.execPath; + return { + runtime, + isGlobal: true, + targetDir, + postToolEvent: 'PostToolUse', + hookEvents: undefined, // not the hookEvents dialect — we're testing extendedHookEvents + extendedHookEvents, + updateCheckCommand: `${node} "${path.join(targetDir, 'hooks', 'gsd-check-update.js')}"`, + contextMonitorCommand: `${node} "${path.join(targetDir, 'hooks', 'gsd-context-monitor.js')}"`, + promptGuardCommand: `${node} "${path.join(targetDir, 'hooks', 'gsd-prompt-guard.js')}"`, + readGuardCommand: `${node} "${path.join(targetDir, 'hooks', 'gsd-read-guard.js')}"`, + readInjectionScannerCommand: `${node} "${path.join(targetDir, 'hooks', 'gsd-read-injection-scanner.js')}"`, + configReloadCommand: `${node} "${path.join(targetDir, 'hooks', 'gsd-config-reload.js')}"`, + hookOpts, + localCmd: () => null, + localShellCmd: () => null, + }; +} + +// ─── Suite 1: claude shape (SubagentStop+Stop+PreCompact+FileChanged) ───────── + +describe('enh-1076 phase 5f-3: claude extendedHookEvents → SubagentStop/Stop/PreCompact/FileChanged', () => { + let targetDir; + let settings; + + before(() => { + targetDir = createStubTargetDir(); + settings = { hooks: {} }; + const opts = buildOpts(targetDir, { + runtime: 'claude', + extendedHookEvents: ['SubagentStop', 'Stop', 'PreCompact', 'FileChanged'], + }); + applySettingsJsonHooks(settings, opts); + }); + + test('SubagentStop is wired (descriptor-driven)', () => { + assert.ok( + hasHooksFor(settings, 'SubagentStop'), + `Expected SubagentStop hooks; hooks keys: ${JSON.stringify(Object.keys(settings.hooks || {}))}` + ); + }); + + test('Stop is wired (descriptor-driven)', () => { + assert.ok( + hasHooksFor(settings, 'Stop'), + `Expected Stop hooks; hooks keys: ${JSON.stringify(Object.keys(settings.hooks || {}))}` + ); + }); + + test('PreCompact is wired (descriptor-driven)', () => { + assert.ok( + hasHooksFor(settings, 'PreCompact'), + `Expected PreCompact hooks; hooks keys: ${JSON.stringify(Object.keys(settings.hooks || {}))}` + ); + }); + + test('FileChanged is wired (descriptor-driven)', () => { + assert.ok( + hasHooksFor(settings, 'FileChanged'), + `Expected FileChanged hooks; hooks keys: ${JSON.stringify(Object.keys(settings.hooks || {}))}` + ); + }); + + test('cleanup', () => { + cleanupDir(targetDir); + }); +}); + +// ─── Suite 2: qwen shape (SubagentStop+Stop+PreCompact, no FileChanged) ─────── + +describe('enh-1076 phase 5f-3: qwen extendedHookEvents → SubagentStop/Stop/PreCompact only', () => { + let targetDir; + let settings; + + before(() => { + targetDir = createStubTargetDir(); + settings = { hooks: {} }; + const opts = buildOpts(targetDir, { + runtime: 'qwen', + extendedHookEvents: ['SubagentStop', 'Stop', 'PreCompact'], + }); + applySettingsJsonHooks(settings, opts); + }); + + test('SubagentStop is wired', () => { + assert.ok(hasHooksFor(settings, 'SubagentStop')); + }); + + test('Stop is wired', () => { + assert.ok(hasHooksFor(settings, 'Stop')); + }); + + test('PreCompact is wired', () => { + assert.ok(hasHooksFor(settings, 'PreCompact')); + }); + + test('FileChanged is NOT wired (not in extendedHookEvents)', () => { + assert.strictEqual( + hasHooksFor(settings, 'FileChanged'), + false, + `FileChanged must NOT be wired for qwen shape; hooks keys: ${JSON.stringify(Object.keys(settings.hooks || {}))}` + ); + }); + + test('cleanup', () => { + cleanupDir(targetDir); + }); +}); + +// ─── Suite 3: gemini shape (BeforeAgent+AfterAgent+BeforeModel) ─────────────── + +describe('enh-1076 phase 5f-3: gemini extendedHookEvents → BeforeAgent/AfterAgent/BeforeModel', () => { + let targetDir; + let settings; + + before(() => { + targetDir = createStubTargetDir(); + settings = { hooks: {} }; + const opts = buildOpts(targetDir, { + runtime: 'gemini', + extendedHookEvents: ['BeforeAgent', 'AfterAgent', 'BeforeModel'], + }); + applySettingsJsonHooks(settings, opts); + }); + + test('BeforeAgent is wired', () => { + assert.ok( + hasHooksFor(settings, 'BeforeAgent'), + `Expected BeforeAgent hooks; hooks keys: ${JSON.stringify(Object.keys(settings.hooks || {}))}` + ); + }); + + test('AfterAgent is wired', () => { + assert.ok(hasHooksFor(settings, 'AfterAgent')); + }); + + test('BeforeModel is wired', () => { + assert.ok(hasHooksFor(settings, 'BeforeModel')); + }); + + test('SubagentStop is NOT wired (not in extendedHookEvents)', () => { + assert.strictEqual( + hasHooksFor(settings, 'SubagentStop'), + false, + 'SubagentStop must NOT be wired for gemini shape' + ); + }); + + test('FileChanged is NOT wired (not in extendedHookEvents)', () => { + assert.strictEqual( + hasHooksFor(settings, 'FileChanged'), + false, + 'FileChanged must NOT be wired for gemini shape' + ); + }); + + test('cleanup', () => { + cleanupDir(targetDir); + }); +}); + +// ─── Suite 4: empty extendedHookEvents → none of the extended events ────────── + +describe('enh-1076 phase 5f-3: empty extendedHookEvents → no extended events wired', () => { + let targetDir; + let settings; + + before(() => { + targetDir = createStubTargetDir(); + settings = { hooks: {} }; + // Use runtime='someruntime' to prove it's the descriptor, not the name, that matters + const opts = buildOpts(targetDir, { + runtime: 'someruntime', + extendedHookEvents: [], + }); + applySettingsJsonHooks(settings, opts); + }); + + const EXTENDED_EVENTS = [ + 'SubagentStop', 'Stop', 'PreCompact', 'FileChanged', + 'BeforeAgent', 'AfterAgent', 'BeforeModel', + ]; + + for (const event of EXTENDED_EVENTS) { + test(`${event} is NOT wired when extendedHookEvents is empty`, () => { + assert.strictEqual( + hasHooksFor(settings, event), + false, + `${event} must not be wired when extendedHookEvents=[] (runtime=someruntime); hooks keys: ${JSON.stringify(Object.keys(settings.hooks || {}))}` + ); + }); + } + + test('cleanup', () => { + cleanupDir(targetDir); + }); +}); + +// ─── Suite 5: descriptor-drive is runtime-name-agnostic ─────────────────────── +// Pass an arbitrary runtime name ('hypothetical') with SubagentStop in its +// extendedHookEvents. This could NEVER have worked under the old hardcoded check. +// Under the new descriptor-driven guard it MUST work. + +describe('enh-1076 phase 5f-3: arbitrary runtime with SubagentStop in descriptor gets it wired', () => { + let targetDir; + let settings; + + before(() => { + targetDir = createStubTargetDir(); + settings = { hooks: {} }; + const opts = buildOpts(targetDir, { + runtime: 'hypothetical', // NOT 'claude' or 'qwen' — would have been skipped before + extendedHookEvents: ['SubagentStop'], + }); + applySettingsJsonHooks(settings, opts); + }); + + test('SubagentStop IS wired for a hypothetical runtime when descriptor includes it', () => { + assert.ok( + hasHooksFor(settings, 'SubagentStop'), + `SubagentStop must be wired via descriptor even for unknown runtime names; hooks keys: ${JSON.stringify(Object.keys(settings.hooks || {}))}` + ); + }); + + test('Stop is NOT wired (not in extendedHookEvents)', () => { + assert.strictEqual(hasHooksFor(settings, 'Stop'), false); + }); + + test('cleanup', () => { + cleanupDir(targetDir); + }); +}); + +// ─── Suite 6: hooksSurface drive (ADR-857 phase 5g drive 3) ────────────────── +// +// applySettingsJsonHooks is gated by opts.hooksSurface !== 'none'. +// - hooksSurface:'none' → entire body is skipped; no hooks written +// - hooksSurface:'settings-json'→ hooks are written (even for a runtime whose +// name was previously hardcoded to skip, e.g. 'opencode') +// +// This proves the skip is driven by the descriptor field, not the runtime name. + +describe('enh-1076 phase 5g drive 3: hooksSurface:none skips all hooks regardless of runtime', () => { + let targetDir; + let settings; + + before(() => { + targetDir = createStubTargetDir(); + settings = { hooks: {} }; + // 'claude' would normally write hooks, but hooksSurface:'none' must skip entirely. + const opts = { + ...buildOpts(targetDir, { runtime: 'claude', extendedHookEvents: ['SubagentStop'] }), + hooksSurface: 'none', + }; + applySettingsJsonHooks(settings, opts); + }); + + test('SessionStart is NOT written when hooksSurface is "none"', () => { + assert.strictEqual( + hasHooksFor(settings, 'SessionStart'), + false, + `SessionStart must not be written when hooksSurface="none"; hooks keys: ${JSON.stringify(Object.keys(settings.hooks || {}))}` + ); + }); + + test('PostToolUse is NOT written when hooksSurface is "none"', () => { + assert.strictEqual(hasHooksFor(settings, 'PostToolUse'), false); + }); + + test('PreToolUse is NOT written when hooksSurface is "none"', () => { + assert.strictEqual(hasHooksFor(settings, 'PreToolUse'), false); + }); + + test('cleanup', () => { + cleanupDir(targetDir); + }); +}); + +describe('enh-1076 phase 5g drive 3: hooksSurface:settings-json writes hooks even for previously-skipped runtime name', () => { + let targetDir; + let settings; + + before(() => { + targetDir = createStubTargetDir(); + settings = { hooks: {} }; + // 'opencode' previously was hardcoded to skip hooks; with descriptor drive it + // should write hooks whenever hooksSurface !== 'none'. + const opts = { + ...buildOpts(targetDir, { runtime: 'opencode', extendedHookEvents: [] }), + hooksSurface: 'settings-json', + }; + applySettingsJsonHooks(settings, opts); + }); + + test('SessionStart IS written with at least one command when hooksSurface is "settings-json" (even for opencode name)', () => { + // ensureHooksDist() in before() guarantees hooks/dist is built, so the + // existsSync guards inside applySettingsJsonHooks pass and commands are registered. + assert.ok( + settings.hooks && typeof settings.hooks === 'object', + `settings.hooks must be initialized when hooksSurface="settings-json"`, + ); + assert.ok( + hasHooksFor(settings, 'SessionStart'), + `settings.hooks.SessionStart must contain at least one registered command when hooksSurface="settings-json"; ` + + `keys: ${JSON.stringify(Object.keys(settings.hooks))}`, + ); + }); + + test('cleanup', () => { + cleanupDir(targetDir); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/enh-1077-install-hook-events-dialect-drive.test.cjs — consolidation epic #1969 (B1 #1970) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:enh-1077-install-hook-events-dialect-drive (consolidation epic #1969 B1 #1970)", () => { +'use strict'; + +process.env.GSD_TEST_MODE = '1'; + +/** + * ADR-857 phase 5f-2: hook-events dialect is driven from the registry descriptor. + * + * Before this change, postToolEvent and preToolEvent were hardcoded strings + * derived from runtime-name checks: + * + * (runtime === 'gemini' || runtime === 'antigravity') ? 'AfterTool' : 'PostToolUse' + * (runtime === 'gemini' || runtime === 'antigravity') ? 'BeforeTool' : 'PreToolUse' + * + * After phase 5f-2, both are driven by the registry descriptor's + * `hookEvents` field: hookEvents === 'gemini' → AfterTool/BeforeTool; + * any other value (or missing) → PostToolUse/PreToolUse. + * + * Equivalence (i.e. identical observable behaviour for all runtimes): + * hookEvents === 'gemini' iff runtime ∈ {gemini, antigravity} + * + * This suite asserts the equivalence and the registry-parity invariant: + * any runtime whose descriptor carries hookEvents='gemini' gets the + * AfterTool/BeforeTool dialect; all others get PostToolUse/PreToolUse. + */ + +const { test, describe, before, 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 { install } = require('../bin/install.js'); +const { createTempDir, cleanup } = require('./helpers.cjs'); + +// ─── hooks/dist build guard ─────────────────────────────────────────────────── +// +// hooks/dist/ is gitignored and only produced by `npm run build:hooks`. +// In CI the scoped/windows test jobs do NOT run build:hooks before running +// tests, so install() finds no hook files → event arrays come back empty → +// every "expected AfterTool/PostToolUse/BeforeTool/PreToolUse hooks" assertion +// fails. This mirrors the pattern in bug-376-claude-js-hook-gsd-rewriter.test.cjs. + +const REPO_ROOT = path.resolve(__dirname, '..'); +const HOOKS_DIST_DIR = path.join(REPO_ROOT, 'hooks', 'dist'); +const BUILD_HOOKS_SCRIPT = path.join(REPO_ROOT, 'scripts', 'build-hooks.js'); + +/** + * Idempotently ensure hooks/dist contains built .js files. + * Runs build-hooks.js only when the directory is absent or empty of .js files. + */ +function ensureHooksDist() { + if (!fs.existsSync(HOOKS_DIST_DIR) || fs.readdirSync(HOOKS_DIST_DIR).filter(f => f.endsWith('.js')).length === 0) { + execFileSync(process.execPath, [BUILD_HOOKS_SCRIPT], { stdio: 'pipe' }); + } +} + +before(() => { + ensureHooksDist(); +}); + +// ─── Registry lookup ────────────────────────────────────────────────────────── + +const REGISTRY_PATH = path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'capability-registry.cjs'); +const registry = (() => { + try { return require(REGISTRY_PATH); } catch { return undefined; } +})(); + +/** + * Return the hookEvents dialect for a runtime ID from the live registry. + * Returns undefined when the registry is absent or the runtime has no descriptor. + */ +function registryHookEvents(runtimeId) { + return registry?.runtimes?.[runtimeId]?.runtime?.hookEvents; +} + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +/** Collect all hook commands registered under a settings event key. */ +function hooksForEvent(settings, eventName) { + if (!settings || !settings.hooks || !Array.isArray(settings.hooks[eventName])) return []; + return settings.hooks[eventName].flatMap(entry => + (entry && Array.isArray(entry.hooks) ? entry.hooks : []) + .map(h => h && h.command) + .filter(Boolean) + ); +} + +/** True if at least one hook is registered under eventName. */ +function hasHooksFor(settings, eventName) { + return hooksForEvent(settings, eventName).length > 0; +} + +// ─── Suite 1: Gemini-dialect runtimes use AfterTool/BeforeTool ─────────────── +// +// Registry runtimes with hookEvents='gemini': gemini, antigravity + +describe('enh-1077 phase 5f-2: gemini hookEvents dialect → AfterTool/BeforeTool', () => { + // ── gemini ── + + describe('gemini install uses AfterTool for post-tool hooks', () => { + let tmpDir; + let previousCwd; + let settings; + + beforeEach(() => { + tmpDir = createTempDir('gsd-1077-gemini-'); + previousCwd = process.cwd(); + process.chdir(tmpDir); + + const geminiDir = path.join(tmpDir, '.gemini'); + fs.mkdirSync(geminiDir, { recursive: true }); + const result = install(false, 'gemini'); + settings = result && result.settings; + }); + + afterEach(() => { + process.chdir(previousCwd); + cleanup(tmpDir); + }); + + test('registry confirms gemini hookEvents is "gemini"', () => { + // Parity assertion: if the registry changes, this test fails first. + const he = registryHookEvents('gemini'); + if (he !== undefined) { + assert.strictEqual(he, 'gemini', + 'Registry descriptor for gemini must declare hookEvents="gemini"'); + } + }); + + test('gemini install returns a settings object', () => { + assert.ok(settings !== null && typeof settings === 'object', + 'gemini install must return a non-null settings object'); + }); + + test('gemini install registers at least one hook under AfterTool (post-tool)', () => { + assert.ok(hasHooksFor(settings, 'AfterTool'), + `Expected AfterTool hooks on gemini; got hooks keys: ${JSON.stringify(Object.keys((settings && settings.hooks) || {}))}`); + }); + + test('gemini install does NOT register context-monitor under PostToolUse (wrong dialect)', () => { + const cmds = hooksForEvent(settings, 'PostToolUse'); + const hasMonitor = cmds.some(c => c && c.includes('gsd-context-monitor')); + assert.strictEqual(hasMonitor, false, + `gemini must NOT use PostToolUse for context-monitor; got PostToolUse commands: ${JSON.stringify(cmds)}`); + }); + + test('gemini install registers at least one pre-tool hook (prompt-guard) under BeforeTool', () => { + const cmds = hooksForEvent(settings, 'BeforeTool'); + const hasPromptGuard = cmds.some(c => c && c.includes('gsd-prompt-guard')); + assert.ok(hasPromptGuard, + `Expected prompt-guard hook under BeforeTool on gemini; BeforeTool commands: ${JSON.stringify(cmds)}; hooks keys: ${JSON.stringify(Object.keys((settings && settings.hooks) || {}))}`); + }); + + test('gemini install does NOT register prompt-guard under PreToolUse (wrong pre-tool dialect)', () => { + const cmds = hooksForEvent(settings, 'PreToolUse'); + const hasPromptGuard = cmds.some(c => c && c.includes('gsd-prompt-guard')); + assert.strictEqual(hasPromptGuard, false, + `gemini must NOT use PreToolUse for prompt-guard; got PreToolUse commands: ${JSON.stringify(cmds)}`); + }); + }); + + // ── antigravity ── + + describe('antigravity install uses AfterTool/BeforeTool (gemini dialect)', () => { + let tmpDir; + let previousCwd; + let settings; + + beforeEach(() => { + tmpDir = createTempDir('gsd-1077-antigrav-'); + previousCwd = process.cwd(); + process.chdir(tmpDir); + + const agDir = path.join(tmpDir, '.gemini', 'antigravity'); + fs.mkdirSync(agDir, { recursive: true }); + const result = install(false, 'antigravity'); + settings = result && result.settings; + }); + + afterEach(() => { + process.chdir(previousCwd); + cleanup(tmpDir); + }); + + test('registry confirms antigravity hookEvents is "gemini"', () => { + const he = registryHookEvents('antigravity'); + if (he !== undefined) { + assert.strictEqual(he, 'gemini', + 'Registry descriptor for antigravity must declare hookEvents="gemini"'); + } + }); + + test('antigravity install returns a settings object', () => { + assert.ok(settings !== null && typeof settings === 'object', + 'antigravity install must return a non-null settings object'); + }); + + test('antigravity install registers at least one hook under AfterTool', () => { + assert.ok(hasHooksFor(settings, 'AfterTool'), + `Expected AfterTool hooks on antigravity; got hooks keys: ${JSON.stringify(Object.keys((settings && settings.hooks) || {}))}`); + }); + + test('antigravity install does NOT register context-monitor under PostToolUse', () => { + const cmds = hooksForEvent(settings, 'PostToolUse'); + const hasMonitor = cmds.some(c => c && c.includes('gsd-context-monitor')); + assert.strictEqual(hasMonitor, false, + `antigravity must NOT use PostToolUse for context-monitor; got: ${JSON.stringify(cmds)}`); + }); + + test('antigravity install registers at least one pre-tool hook (prompt-guard) under BeforeTool', () => { + const cmds = hooksForEvent(settings, 'BeforeTool'); + const hasPromptGuard = cmds.some(c => c && c.includes('gsd-prompt-guard')); + assert.ok(hasPromptGuard, + `Expected prompt-guard hook under BeforeTool on antigravity; BeforeTool commands: ${JSON.stringify(cmds)}; hooks keys: ${JSON.stringify(Object.keys((settings && settings.hooks) || {}))}`); + }); + + test('antigravity install does NOT register prompt-guard under PreToolUse (wrong pre-tool dialect)', () => { + const cmds = hooksForEvent(settings, 'PreToolUse'); + const hasPromptGuard = cmds.some(c => c && c.includes('gsd-prompt-guard')); + assert.strictEqual(hasPromptGuard, false, + `antigravity must NOT use PreToolUse for prompt-guard; got PreToolUse commands: ${JSON.stringify(cmds)}`); + }); + }); +}); + +// ─── Suite 2: Claude-dialect runtimes use PostToolUse/PreToolUse ────────────── +// +// Registry runtimes with hookEvents='claude': claude, augment + +describe('enh-1077 phase 5f-2: claude hookEvents dialect → PostToolUse/PreToolUse', () => { + // ── claude ── + + describe('claude install uses PostToolUse for post-tool hooks', () => { + let tmpDir; + let previousCwd; + let settings; + + beforeEach(() => { + tmpDir = createTempDir('gsd-1077-claude-'); + previousCwd = process.cwd(); + process.chdir(tmpDir); + + const claudeDir = path.join(tmpDir, '.claude'); + fs.mkdirSync(claudeDir, { recursive: true }); + const result = install(false, 'claude'); + settings = result && result.settings; + }); + + afterEach(() => { + process.chdir(previousCwd); + cleanup(tmpDir); + }); + + test('registry confirms claude hookEvents is "claude"', () => { + const he = registryHookEvents('claude'); + if (he !== undefined) { + assert.strictEqual(he, 'claude', + 'Registry descriptor for claude must declare hookEvents="claude"'); + } + }); + + test('claude install returns a settings object', () => { + assert.ok(settings !== null && typeof settings === 'object', + 'claude install must return a non-null settings object'); + }); + + test('claude install registers at least one hook under PostToolUse', () => { + assert.ok(hasHooksFor(settings, 'PostToolUse'), + `Expected PostToolUse hooks on claude; got hooks keys: ${JSON.stringify(Object.keys((settings && settings.hooks) || {}))}`); + }); + + test('claude install does NOT register context-monitor under AfterTool (wrong dialect)', () => { + const cmds = hooksForEvent(settings, 'AfterTool'); + const hasMonitor = cmds.some(c => c && c.includes('gsd-context-monitor')); + assert.strictEqual(hasMonitor, false, + `claude must NOT use AfterTool for context-monitor; got AfterTool commands: ${JSON.stringify(cmds)}`); + }); + + test('claude install registers at least one pre-tool hook (prompt-guard) under PreToolUse', () => { + const cmds = hooksForEvent(settings, 'PreToolUse'); + const hasPromptGuard = cmds.some(c => c && c.includes('gsd-prompt-guard')); + assert.ok(hasPromptGuard, + `Expected prompt-guard hook under PreToolUse on claude; PreToolUse commands: ${JSON.stringify(cmds)}; hooks keys: ${JSON.stringify(Object.keys((settings && settings.hooks) || {}))}`); + }); + + test('claude install does NOT register prompt-guard under BeforeTool (wrong pre-tool dialect)', () => { + const cmds = hooksForEvent(settings, 'BeforeTool'); + const hasPromptGuard = cmds.some(c => c && c.includes('gsd-prompt-guard')); + assert.strictEqual(hasPromptGuard, false, + `claude must NOT use BeforeTool for prompt-guard; got BeforeTool commands: ${JSON.stringify(cmds)}`); + }); + }); + + // ── augment ── + + describe('augment install uses PostToolUse/PreToolUse (claude dialect)', () => { + let tmpDir; + let previousCwd; + let settings; + + beforeEach(() => { + tmpDir = createTempDir('gsd-1077-augment-'); + previousCwd = process.cwd(); + process.chdir(tmpDir); + + const augDir = path.join(tmpDir, '.augment'); + fs.mkdirSync(augDir, { recursive: true }); + const result = install(false, 'augment'); + settings = result && result.settings; + }); + + afterEach(() => { + process.chdir(previousCwd); + cleanup(tmpDir); + }); + + test('registry confirms augment hookEvents is "claude"', () => { + const he = registryHookEvents('augment'); + if (he !== undefined) { + assert.strictEqual(he, 'claude', + 'Registry descriptor for augment must declare hookEvents="claude"'); + } + }); + + test('augment install returns a settings object', () => { + assert.ok(settings !== null && typeof settings === 'object', + 'augment install must return a non-null settings object'); + }); + + test('augment install registers at least one hook under PostToolUse', () => { + assert.ok(hasHooksFor(settings, 'PostToolUse'), + `Expected PostToolUse hooks on augment; got hooks keys: ${JSON.stringify(Object.keys((settings && settings.hooks) || {}))}`); + }); + + test('augment install does NOT register context-monitor under AfterTool', () => { + const cmds = hooksForEvent(settings, 'AfterTool'); + const hasMonitor = cmds.some(c => c && c.includes('gsd-context-monitor')); + assert.strictEqual(hasMonitor, false, + `augment must NOT use AfterTool for context-monitor; got: ${JSON.stringify(cmds)}`); + }); + + test('augment install registers at least one pre-tool hook (prompt-guard) under PreToolUse', () => { + const cmds = hooksForEvent(settings, 'PreToolUse'); + const hasPromptGuard = cmds.some(c => c && c.includes('gsd-prompt-guard')); + assert.ok(hasPromptGuard, + `Expected prompt-guard hook under PreToolUse on augment; PreToolUse commands: ${JSON.stringify(cmds)}; hooks keys: ${JSON.stringify(Object.keys((settings && settings.hooks) || {}))}`); + }); + + test('augment install does NOT register prompt-guard under BeforeTool (wrong pre-tool dialect)', () => { + const cmds = hooksForEvent(settings, 'BeforeTool'); + const hasPromptGuard = cmds.some(c => c && c.includes('gsd-prompt-guard')); + assert.strictEqual(hasPromptGuard, false, + `augment must NOT use BeforeTool for prompt-guard; got BeforeTool commands: ${JSON.stringify(cmds)}`); + }); + }); +}); + +// ─── Suite 3: Registry-parity invariant ────────────────────────────────────── +// +// For every runtime in the registry that exposes a settings.json surface +// (i.e. hookEvents is defined), assert that the installed hook dialect matches +// the registry value. This is the generative-fix parity assertion +// (DEFECT.GENERATIVE-FIX): adding a new runtime with hookEvents to the +// registry automatically requires a passing install test for that runtime. + +describe('enh-1077 phase 5f-2: registry-parity — hookEvents descriptor drives install dialect', () => { + test('all registry runtimes with hookEvents use the matching install dialect', () => { + if (!registry || !registry.runtimes) { + // Registry absent — skip parity check (equivalence still verified above) + return; + } + + // Runtimes that have settings.json surfaces and a hookEvents descriptor + const SETTINGS_JSON_RUNTIMES = ['claude', 'gemini', 'antigravity', 'augment', 'qwen', 'hermes', 'codebuddy']; + + const failures = []; + + for (const runtimeId of SETTINGS_JSON_RUNTIMES) { + const he = registryHookEvents(runtimeId); + if (he === undefined) continue; // no hookEvents in descriptor — skip + + const expectedPostEvent = he === 'gemini' ? 'AfterTool' : 'PostToolUse'; + const unexpectedPostEvent = he === 'gemini' ? 'PostToolUse' : 'AfterTool'; + const expectedPreEvent = he === 'gemini' ? 'BeforeTool' : 'PreToolUse'; + const unexpectedPreEvent = he === 'gemini' ? 'PreToolUse' : 'BeforeTool'; + + const previousCwd = process.cwd(); + const tmpDir = createTempDir(`gsd-1077-parity-${runtimeId}-`); + try { + process.chdir(tmpDir); + const result = install(false, runtimeId); + const settings = result && result.settings; + if (!settings) continue; // non-settings-json surface, skip + + // Post-tool event assertions + const hasExpected = hasHooksFor(settings, expectedPostEvent); + const hasUnexpected = hooksForEvent(settings, unexpectedPostEvent) + .some(c => c && c.includes('gsd-context-monitor')); + + if (!hasExpected) { + failures.push(`${runtimeId}: expected context-monitor hook under ${expectedPostEvent} (hookEvents=${he}), but none found`); + } + if (hasUnexpected) { + failures.push(`${runtimeId}: must NOT register context-monitor under ${unexpectedPostEvent}, but it was found`); + } + + // Pre-tool event assertions: prompt-guard must land under the dialect-correct key. + const preToolCmdsExpected = hooksForEvent(settings, expectedPreEvent); + const hasPromptGuardExpected = preToolCmdsExpected.some(c => c && c.includes('gsd-prompt-guard')); + const preToolCmdsUnexpected = hooksForEvent(settings, unexpectedPreEvent); + const hasPromptGuardUnexpected = preToolCmdsUnexpected.some(c => c && c.includes('gsd-prompt-guard')); + + if (!hasPromptGuardExpected) { + failures.push(`${runtimeId}: expected prompt-guard hook under ${expectedPreEvent} (hookEvents=${he}), but none found; ${expectedPreEvent} cmds: ${JSON.stringify(preToolCmdsExpected)}`); + } + if (hasPromptGuardUnexpected) { + failures.push(`${runtimeId}: must NOT register prompt-guard under ${unexpectedPreEvent} (hookEvents=${he}), but it was found`); + } + } finally { + process.chdir(previousCwd); + cleanup(tmpDir); + } + } + + assert.deepEqual(failures, [], + 'Registry-parity failures (hookEvents descriptor must drive install dialect):\n' + + failures.join('\n')); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/enh-776-install-gemini-hook-events.test.cjs — consolidation epic #1969 (B1 #1970) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:enh-776-install-gemini-hook-events (consolidation epic #1969 B1 #1970)", () => { +'use strict'; + +process.env.GSD_TEST_MODE = '1'; + +/** + * Enhancement #776: Adopt new Gemini hook events + detect hooksConfig.enabled:false. + * + * Gemini CLI exposes several hook events beyond BeforeTool/AfterTool that gsd + * previously did not register. This suite asserts that a Gemini install + * registers the 3 new high-value events: + * - BeforeAgent — fires before the agent plans (context headroom tracking) + * - AfterAgent — fires after final response generation (context tracking) + * - BeforeModel — fires before each LLM call (per-turn context awareness) + * + * All three are wired to gsd-context-monitor.js — the same hook used for + * AfterTool — so context headroom warnings surface at these lifecycle moments. + * + * Also asserts: + * - Claude Code installs do NOT gain these Gemini-only events (strict scope guard). + * - Reinstalls are idempotent (no hook duplication). + * - Uninstall removes the new event registrations. + * - hooksConfig.enabled:false warning is emitted during a Gemini install. + * + * Source: https://github.com/google-gemini/gemini-cli/blob/main/docs/hooks/reference.md + */ + +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 { install, uninstall, validateHookFields } = require('../bin/install.js'); +const { createTempDir, cleanup } = require('./helpers.cjs'); + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +/** Extract all hook commands registered under `eventName` from settings. */ +function hooksForEvent(settings, eventName) { + if (!settings || !settings.hooks || !Array.isArray(settings.hooks[eventName])) return []; + return settings.hooks[eventName].flatMap(entry => + (entry && Array.isArray(entry.hooks) ? entry.hooks : []) + .map(h => h && h.command) + .filter(Boolean) + ); +} + +// Stub JS hook files that the installer checks with fs.existsSync() so hook +// registration guards pass even when hooks/dist/ isn't built. +// +// For Gemini, the installer migration baseline includes 'hooks/' in its surface +// list (unlike Qwen which excludes it). Pre-install stubs placed in .gemini/hooks/ +// are classified as 'bundled-gsd-hook' and auto-removed by the migration before +// registration can succeed. The workaround: run a first install (which writes the +// gsd-file-manifest.json), then add the stubs, then run install again. On the +// second install the manifest marks the hook files as managed, so migration keeps +// them and registration guards (fs.existsSync) pass. +const HOOKS_SRC = path.join(__dirname, '..', 'hooks'); +const STUB_HOOKS = [ + 'gsd-context-monitor.js', + 'gsd-prompt-guard.js', + 'gsd-check-update.js', +]; + +function stubHooksIntoTarget(targetDir) { + const hooksDest = path.join(targetDir, 'hooks'); + fs.mkdirSync(hooksDest, { recursive: true }); + for (const hookFile of STUB_HOOKS) { + const src = path.join(HOOKS_SRC, hookFile); + const dest = path.join(hooksDest, hookFile); + if (fs.existsSync(src)) { + fs.copyFileSync(src, dest); + } else { + // Minimal stub so existsSync passes + fs.writeFileSync(dest, '#!/usr/bin/env node\n// stub\n'); + } + try { fs.chmodSync(dest, 0o755); } catch { /* Windows */ } + } +} + +/** + * Two-pass Gemini install. + * + * Pass 1: install() with no hook stubs — writes gsd-file-manifest.json. + * Migration runs on an empty hooks/ so nothing gets auto-removed. + * Pass 2: stub hooks into the target dir (now manifest-tracked on next scan), + * then run install() again. Migration now classifies the hooks as + * managed-unchanged and preserves them; registration guards pass. + * + * Returns the settings from pass 2. + */ +function twoPassGeminiInstall(tmpDir) { + const targetDir = path.join(tmpDir, '.gemini'); + fs.mkdirSync(targetDir, { recursive: true }); + + // Pass 1 — no hook stubs yet; writes the manifest + const result1 = install(false, 'gemini'); + persistSettings(result1.settingsPath, result1.settings); + + // Inject stubs so the registration guards (fs.existsSync) pass on pass 2 + stubHooksIntoTarget(targetDir); + + // Pass 2 — manifest exists, migration keeps stubs, registration succeeds + process.chdir(tmpDir); + const result2 = install(false, 'gemini'); + return result2; +} + +/** + * Persist in-memory settings to disk, simulating what finishInstall() does + * (finishInstall is not exported). Required for tests that call install() + * twice and need the second call to read the first call's hook registrations. + */ +function persistSettings(settingsPath, settings) { + fs.mkdirSync(path.dirname(settingsPath), { recursive: true }); + fs.writeFileSync(settingsPath, JSON.stringify(validateHookFields(settings), null, 2) + '\n', 'utf8'); +} + +// ─── Suite 1: Gemini — new events are registered ───────────────────────────── + +describe('enh-776: Gemini install registers 3 new hook events', () => { + let tmpDir; + let previousCwd; + let settings; + + beforeEach(() => { + tmpDir = createTempDir('gsd-776-gemini-'); + previousCwd = process.cwd(); + process.chdir(tmpDir); + + // Two-pass install: first pass writes manifest, second pass with stubs + // so registration guards (fs.existsSync) pass. + const result = twoPassGeminiInstall(tmpDir); + settings = result.settings; + }); + + afterEach(() => { + process.chdir(previousCwd); + cleanup(tmpDir); + }); + + test('install returns a settings object (not null)', () => { + assert.ok(settings !== null && typeof settings === 'object', + 'Gemini install must return a non-null settings object'); + }); + + test('BeforeAgent event is registered with at least one hook', () => { + const cmds = hooksForEvent(settings, 'BeforeAgent'); + assert.ok(cmds.length > 0, + `Expected BeforeAgent hooks; got hooks: ${JSON.stringify(settings && settings.hooks)}`); + }); + + test('AfterAgent event is registered with at least one hook', () => { + const cmds = hooksForEvent(settings, 'AfterAgent'); + assert.ok(cmds.length > 0, + `Expected AfterAgent hooks; got hooks: ${JSON.stringify(settings && settings.hooks)}`); + }); + + test('BeforeModel event is registered with at least one hook', () => { + const cmds = hooksForEvent(settings, 'BeforeModel'); + assert.ok(cmds.length > 0, + `Expected BeforeModel hooks; got hooks: ${JSON.stringify(settings && settings.hooks)}`); + }); + + test('BeforeAgent / AfterAgent / BeforeModel all use gsd-context-monitor', () => { + for (const event of ['BeforeAgent', 'AfterAgent', 'BeforeModel']) { + const cmds = hooksForEvent(settings, event); + assert.ok( + cmds.some(c => c.includes('gsd-context-monitor')), + `Event ${event} should use gsd-context-monitor; got commands: ${JSON.stringify(cmds)}` + ); + } + }); +}); + +// ─── Suite 2: Non-Gemini installs do NOT get the new events ────────────────── +// +// Two runtimes are particularly important to guard: +// Claude — the canonical non-Gemini runtime +// Antigravity — shares Gemini-style BeforeTool/AfterTool naming and uses +// isGemini-adjacent logic; a future accidental +// `isGemini || isAntigravity` change must be caught here. + +describe('enh-776: Claude install does NOT register Gemini-only hook events', () => { + let tmpDir; + let previousCwd; + let settings; + + beforeEach(() => { + tmpDir = createTempDir('gsd-776-claude-'); + previousCwd = process.cwd(); + process.chdir(tmpDir); + + const result = install(false, 'claude'); + settings = result && result.settings; + }); + + afterEach(() => { + process.chdir(previousCwd); + cleanup(tmpDir); + }); + + test('Claude install does not register BeforeAgent', () => { + const cmds = hooksForEvent(settings, 'BeforeAgent'); + assert.strictEqual(cmds.length, 0, + `Claude should NOT have BeforeAgent; got: ${JSON.stringify(cmds)}`); + }); + + test('Claude install does not register AfterAgent', () => { + const cmds = hooksForEvent(settings, 'AfterAgent'); + assert.strictEqual(cmds.length, 0, + `Claude should NOT have AfterAgent; got: ${JSON.stringify(cmds)}`); + }); + + test('Claude install does not register BeforeModel', () => { + const cmds = hooksForEvent(settings, 'BeforeModel'); + assert.strictEqual(cmds.length, 0, + `Claude should NOT have BeforeModel; got: ${JSON.stringify(cmds)}`); + }); +}); + +describe('enh-776: Antigravity install does NOT register Gemini-only hook events', () => { + let tmpDir; + let previousCwd; + let settings; + + beforeEach(() => { + tmpDir = createTempDir('gsd-776-antigravity-'); + previousCwd = process.cwd(); + process.chdir(tmpDir); + + const result = install(false, 'antigravity'); + settings = result && result.settings; + }); + + afterEach(() => { + process.chdir(previousCwd); + cleanup(tmpDir); + }); + + test('Antigravity install does not register BeforeAgent', () => { + const cmds = hooksForEvent(settings, 'BeforeAgent'); + assert.strictEqual(cmds.length, 0, + `Antigravity should NOT have BeforeAgent; got: ${JSON.stringify(cmds)}`); + }); + + test('Antigravity install does not register AfterAgent', () => { + const cmds = hooksForEvent(settings, 'AfterAgent'); + assert.strictEqual(cmds.length, 0, + `Antigravity should NOT have AfterAgent; got: ${JSON.stringify(cmds)}`); + }); + + test('Antigravity install does not register BeforeModel', () => { + const cmds = hooksForEvent(settings, 'BeforeModel'); + assert.strictEqual(cmds.length, 0, + `Antigravity should NOT have BeforeModel; got: ${JSON.stringify(cmds)}`); + }); +}); + +// ─── Suite 3: Idempotency — persisted reinstall does not duplicate hooks ────── + +describe('enh-776: Gemini install is idempotent across persisted reinstalls', () => { + let tmpDir; + let previousCwd; + + beforeEach(() => { + tmpDir = createTempDir('gsd-776-idem-'); + previousCwd = process.cwd(); + process.chdir(tmpDir); + }); + + afterEach(() => { + process.chdir(previousCwd); + cleanup(tmpDir); + }); + + test('re-running after persisted first install does not duplicate hook entries', () => { + // Two-pass to get hooks installed. + const result2 = twoPassGeminiInstall(tmpDir); + const s2 = result2.settings; + + // Assert hooks ARE registered after pass 2 (guards against false-pass where + // hooks never registered and idempotency passes trivially at count=0). + for (const event of ['BeforeAgent', 'AfterAgent', 'BeforeModel']) { + const cmds = hooksForEvent(s2, event); + assert.strictEqual(cmds.length, 1, + `Event ${event} should have exactly 1 hook after two-pass install; got ${cmds.length}: ${JSON.stringify(cmds)}`); + } + + persistSettings(result2.settingsPath, s2); + + // Third install: reads the persisted settings.json — dedup guards apply + process.chdir(tmpDir); + const result3 = install(false, 'gemini'); + const s3 = result3.settings; + + for (const event of ['BeforeAgent', 'AfterAgent', 'BeforeModel']) { + const cmds = hooksForEvent(s3, event); + assert.strictEqual(cmds.length, 1, + `Event ${event} should have exactly 1 hook command after idempotent reinstall; got ${cmds.length}: ${JSON.stringify(cmds)}`); + } + }); +}); + +// ─── Suite 4: Uninstall removes the new event registrations ────────────────── + +describe('enh-776: Gemini uninstall removes new hook event entries', () => { + let tmpDir; + let previousCwd; + + beforeEach(() => { + tmpDir = createTempDir('gsd-776-uninstall-'); + previousCwd = process.cwd(); + process.chdir(tmpDir); + + // Two-pass install and persist so uninstall has a settings.json to clean + const result = twoPassGeminiInstall(tmpDir); + persistSettings(result.settingsPath, result.settings); + }); + + afterEach(() => { + process.chdir(previousCwd); + cleanup(tmpDir); + }); + + test('settings.json hook entries are removed on uninstall', () => { + uninstall(false, 'gemini'); + const settingsPath = path.join(tmpDir, '.gemini', 'settings.json'); + if (!fs.existsSync(settingsPath)) return; // file removed entirely is fine + const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); + for (const event of ['BeforeAgent', 'AfterAgent', 'BeforeModel']) { + const cmds = hooksForEvent(settings, event); + assert.strictEqual(cmds.length, 0, + `After uninstall, ${event} should have 0 hooks; got: ${JSON.stringify(cmds)}`); + } + }); +}); + +// ─── Suite 5: hooksConfig.enabled:false warning ─────────────────────────────── + +describe('enh-776: hooksConfig.enabled:false warning during Gemini install', () => { + let tmpDir; + let previousCwd; + let stderrLines; + let originalWarn; + + beforeEach(() => { + tmpDir = createTempDir('gsd-776-hookscfg-'); + previousCwd = process.cwd(); + process.chdir(tmpDir); + + const targetDir = path.join(tmpDir, '.gemini'); + fs.mkdirSync(targetDir, { recursive: true }); + + // Capture console.warn output + stderrLines = []; + originalWarn = console.warn; + console.warn = (...args) => { stderrLines.push(args.join(' ')); }; + }); + + afterEach(() => { + console.warn = originalWarn; + process.chdir(previousCwd); + cleanup(tmpDir); + }); + + test('emits a warning when hooksConfig.enabled is false', () => { + // Write a settings.json with hooksConfig.enabled: false BEFORE install + // (the check in install() reads the existing settings.json on disk). + const targetDir = path.join(tmpDir, '.gemini'); + const settingsPath = path.join(targetDir, 'settings.json'); + fs.writeFileSync(settingsPath, JSON.stringify({ hooksConfig: { enabled: false } }, null, 2) + '\n', 'utf8'); + + install(false, 'gemini'); + const warnText = stderrLines.join('\n'); + assert.ok( + warnText.includes('hooksConfig.enabled is false'), + `Expected hooksConfig.enabled warning; got console.warn output:\n${warnText}` + ); + }); + + test('does NOT emit the hooksConfig warning when hooksConfig.enabled is true', () => { + const targetDir = path.join(tmpDir, '.gemini'); + const settingsPath = path.join(targetDir, 'settings.json'); + fs.writeFileSync(settingsPath, JSON.stringify({ hooksConfig: { enabled: true } }, null, 2) + '\n', 'utf8'); + + install(false, 'gemini'); + const warnText = stderrLines.join('\n'); + assert.ok( + !warnText.includes('hooksConfig.enabled is false'), + `Should NOT warn when hooksConfig.enabled is true; got:\n${warnText}` + ); + }); + + test('does NOT emit the hooksConfig warning when hooksConfig is absent', () => { + const targetDir = path.join(tmpDir, '.gemini'); + const settingsPath = path.join(targetDir, 'settings.json'); + fs.writeFileSync(settingsPath, JSON.stringify({}, null, 2) + '\n', 'utf8'); + + install(false, 'gemini'); + const warnText = stderrLines.join('\n'); + assert.ok( + !warnText.includes('hooksConfig.enabled is false'), + `Should NOT warn when hooksConfig is absent; got:\n${warnText}` + ); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/enh-788-qwen-hook-events.test.cjs — consolidation epic #1969 (B1 #1970) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:enh-788-qwen-hook-events (consolidation epic #1969 B1 #1970)", () => { +'use strict'; + +process.env.GSD_TEST_MODE = '1'; + +/** + * Enhancement #788: Expand Qwen Code hook-event coverage. + * + * Qwen Code supports 15 hook events; gsd previously registered only + * SessionStart and PostToolUse. This suite asserts that a Qwen install + * registers the 3 new high-value events: + * - SubagentStop — subagent lifecycle finalisation (context tracking) + * - Stop — model stop / final-response hook (context tracking) + * - PreCompact — pre-compaction awareness (context tracking) + * + * All three are wired to gsd-context-monitor.js — the same hook used for + * PostToolUse — so context headroom warnings surface at these moments too. + * + * Note: UserPromptSubmit is NOT wired — gsd-prompt-guard exits unless + * tool_name is Write|Edit (PreToolUse shape), so it would be a no-op for + * the UserPromptSubmit payload. Deferred to a follow-on issue. + * + * Also asserts the inverse: Claude Code installs do NOT gain these events + * (strict isQwen scope guard). + * + * Source: https://qwenlm.github.io/qwen-code-docs/en/users/features/hooks/ + */ + +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 { install, uninstall, validateHookFields } = require('../bin/install.js'); +const { createTempDir, cleanup } = require('./helpers.cjs'); + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +/** Extract all hook commands registered under `eventName` from settings. */ +function hooksForEvent(settings, eventName) { + if (!settings || !settings.hooks || !Array.isArray(settings.hooks[eventName])) return []; + return settings.hooks[eventName].flatMap(entry => + (entry && Array.isArray(entry.hooks) ? entry.hooks : []) + .map(h => h && h.command) + .filter(Boolean) + ); +} + +// Stub JS hook files that the installer checks with fs.existsSync() so hook +// registration guards pass even when hooks/dist/ isn't built. +const HOOKS_SRC = path.join(__dirname, '..', 'hooks'); +const STUB_HOOKS = [ + 'gsd-context-monitor.js', + 'gsd-prompt-guard.js', + 'gsd-check-update.js', + 'gsd-config-reload.js', // Added in #770 +]; + +function stubHooksIntoTarget(targetDir) { + const hooksDest = path.join(targetDir, 'hooks'); + fs.mkdirSync(hooksDest, { recursive: true }); + for (const hookFile of STUB_HOOKS) { + const src = path.join(HOOKS_SRC, hookFile); + const dest = path.join(hooksDest, hookFile); + if (fs.existsSync(src)) { + fs.copyFileSync(src, dest); + } else { + // Minimal stub so existsSync passes + fs.writeFileSync(dest, '#!/usr/bin/env node\n// stub\n'); + } + try { fs.chmodSync(dest, 0o755); } catch { /* Windows */ } + } +} + +/** + * Persist in-memory settings to disk, simulating what finishInstall() does + * (finishInstall is not exported). Required for tests that call install() + * twice and need the second call to read the first call's hook registrations. + */ +function persistSettings(settingsPath, settings) { + fs.mkdirSync(path.dirname(settingsPath), { recursive: true }); + fs.writeFileSync(settingsPath, JSON.stringify(validateHookFields(settings), null, 2) + '\n', 'utf8'); +} + +// ─── Suite 1: Qwen — new events are registered ─────────────────────────────── + +describe('enh-788: Qwen install registers 3 new hook events', () => { + let tmpDir; + let previousCwd; + let settings; + + beforeEach(() => { + tmpDir = createTempDir('gsd-788-qwen-'); + previousCwd = process.cwd(); + process.chdir(tmpDir); + + const targetDir = path.join(tmpDir, '.qwen'); + fs.mkdirSync(targetDir, { recursive: true }); + // Pre-populate hook files so installer registration guards (fs.existsSync) + // pass and hooks are actually registered in settings.json. + stubHooksIntoTarget(targetDir); + + const result = install(false, 'qwen'); + settings = result.settings; + }); + + afterEach(() => { + process.chdir(previousCwd); + cleanup(tmpDir); + }); + + test('install returns a settings object (not null)', () => { + assert.ok(settings !== null && typeof settings === 'object', + 'Qwen install must return a non-null settings object'); + }); + + test('SubagentStop event is registered with at least one hook', () => { + const cmds = hooksForEvent(settings, 'SubagentStop'); + assert.ok(cmds.length > 0, + `Expected SubagentStop hooks; got hooks: ${JSON.stringify(settings && settings.hooks)}`); + }); + + test('Stop event is registered with at least one hook', () => { + const cmds = hooksForEvent(settings, 'Stop'); + assert.ok(cmds.length > 0, + `Expected Stop hooks; got hooks: ${JSON.stringify(settings && settings.hooks)}`); + }); + + test('PreCompact event is registered with at least one hook', () => { + const cmds = hooksForEvent(settings, 'PreCompact'); + assert.ok(cmds.length > 0, + `Expected PreCompact hooks; got hooks: ${JSON.stringify(settings && settings.hooks)}`); + }); + + test('UserPromptSubmit is NOT registered (handler not yet implemented for that payload shape)', () => { + // gsd-prompt-guard exits unless tool_name is Write|Edit — it is a no-op + // for UserPromptSubmit payloads. Registration is deferred until a + // dedicated hook can process the user-prompt payload shape. + const cmds = hooksForEvent(settings, 'UserPromptSubmit'); + assert.strictEqual(cmds.length, 0, + `UserPromptSubmit should NOT be registered yet; got: ${JSON.stringify(cmds)}`); + }); + + test('SubagentStop / Stop / PreCompact all use gsd-context-monitor', () => { + for (const event of ['SubagentStop', 'Stop', 'PreCompact']) { + const cmds = hooksForEvent(settings, event); + assert.ok( + cmds.some(c => c.includes('gsd-context-monitor')), + `Event ${event} should use gsd-context-monitor; got commands: ${JSON.stringify(cmds)}` + ); + } + }); + + test('FileChanged is NOT registered for Qwen (Claude-only event)', () => { + // gsd-config-reload / FileChanged is a Claude Code-only registration. + // Qwen does not support the FileChanged hook event at all. + const cmds = hooksForEvent(settings, 'FileChanged'); + assert.strictEqual(cmds.length, 0, + `FileChanged should NOT be registered for Qwen; got: ${JSON.stringify(cmds)}`); + }); +}); + +// ─── Suite 2: Claude install DOES get the context events (since #770) ─────── +// Note: Prior to #770, these were Qwen-only events. #770 extended them to +// Claude Code. This suite is updated to match the new expected behavior. + +describe('enh-788 (updated by #770): Claude install registers context lifecycle events', () => { + let tmpDir; + let previousCwd; + let settings; + + beforeEach(() => { + tmpDir = createTempDir('gsd-788-claude-'); + previousCwd = process.cwd(); + process.chdir(tmpDir); + stubHooksIntoTarget(path.join(tmpDir, '.claude')); + + const result = install(false, 'claude', { installerMigrations: [] }); + settings = result && result.settings; + }); + + afterEach(() => { + process.chdir(previousCwd); + cleanup(tmpDir); + }); + + test('Claude install registers SubagentStop (since #770)', () => { + const cmds = hooksForEvent(settings, 'SubagentStop'); + assert.ok(cmds.length > 0, + `Claude should have SubagentStop since #770; got: ${JSON.stringify(cmds)}`); + }); + + test('Claude install registers Stop (since #770)', () => { + const cmds = hooksForEvent(settings, 'Stop'); + assert.ok(cmds.length > 0, + `Claude should have Stop since #770; got: ${JSON.stringify(cmds)}`); + }); + + test('Claude install registers PreCompact (since #770)', () => { + const cmds = hooksForEvent(settings, 'PreCompact'); + assert.ok(cmds.length > 0, + `Claude should have PreCompact since #770; got: ${JSON.stringify(cmds)}`); + }); +}); + +// ─── Suite 3: Idempotency — persisted reinstall does not duplicate hooks ────── + +describe('enh-788: Qwen install is idempotent across persisted reinstalls', () => { + let tmpDir; + let previousCwd; + + beforeEach(() => { + tmpDir = createTempDir('gsd-788-idem-'); + previousCwd = process.cwd(); + process.chdir(tmpDir); + + const targetDir = path.join(tmpDir, '.qwen'); + fs.mkdirSync(targetDir, { recursive: true }); + stubHooksIntoTarget(targetDir); + }); + + afterEach(() => { + process.chdir(previousCwd); + cleanup(tmpDir); + }); + + test('re-running after persisted first install does not duplicate hook entries', () => { + // First install: get settings and persist to disk (simulating finishInstall) + const result1 = install(false, 'qwen'); + persistSettings(result1.settingsPath, result1.settings); + + // Second install: reads the persisted settings.json — dedup guards apply + process.chdir(tmpDir); + const result2 = install(false, 'qwen'); + const s2 = result2.settings; + + for (const event of ['SubagentStop', 'Stop', 'PreCompact']) { + const cmds = hooksForEvent(s2, event); + assert.strictEqual(cmds.length, 1, + `Event ${event} should have exactly 1 hook command after idempotent reinstall; got ${cmds.length}: ${JSON.stringify(cmds)}`); + } + }); +}); + +// ─── Suite 4: Uninstall removes the new event registrations ────────────────── + +describe('enh-788: Qwen uninstall removes new hook event entries', () => { + let tmpDir; + let previousCwd; + + beforeEach(() => { + tmpDir = createTempDir('gsd-788-uninstall-'); + previousCwd = process.cwd(); + process.chdir(tmpDir); + + const targetDir = path.join(tmpDir, '.qwen'); + fs.mkdirSync(targetDir, { recursive: true }); + stubHooksIntoTarget(targetDir); + + // Install and persist to disk so uninstall has a settings.json to clean + const result = install(false, 'qwen'); + persistSettings(result.settingsPath, result.settings); + }); + + afterEach(() => { + process.chdir(previousCwd); + cleanup(tmpDir); + }); + + test('settings.json hook entries are removed on uninstall', () => { + uninstall(false, 'qwen'); + const settingsPath = path.join(tmpDir, '.qwen', 'settings.json'); + if (!fs.existsSync(settingsPath)) return; // file removed entirely is fine + const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); + for (const event of ['SubagentStop', 'Stop', 'PreCompact']) { + const cmds = hooksForEvent(settings, event); + assert.strictEqual(cmds.length, 0, + `After uninstall, ${event} should have 0 hooks; got: ${JSON.stringify(cmds)}`); + } + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-1834-sh-hooks-installed.test.cjs — consolidation epic #1969 (B6 #1975) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-1834-sh-hooks-installed (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; }); +/** + * 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` + ); + } + }); +}); + }); +} diff --git a/tests/install-path-detection.test.cjs b/tests/install-path-detection.test.cjs index faf9e8022..980b1b06a 100644 --- a/tests/install-path-detection.test.cjs +++ b/tests/install-path-detection.test.cjs @@ -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'); + }); +}); + }); +} diff --git a/tests/install-runtime-artifacts.test.cjs b/tests/install-runtime-artifacts.test.cjs index 489ef05d7..06555f065 100644 --- a/tests/install-runtime-artifacts.test.cjs +++ b/tests/install-runtime-artifacts.test.cjs @@ -9245,3 +9245,4272 @@ describe('enh-790 — mcpServers excluded (gsd ships no MCP server)', () => { }); }); } + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-2418-antigravity-bare-path.test.cjs — consolidation epic #1969 (B1 #1970) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-2418-antigravity-bare-path (consolidation epic #1969 B1 #1970)", () => { +/** + * Bug #2418: Found unreplaced .claude path reference(s) in Antigravity install + * + * The Antigravity path converter handles ~/.claude/ (with trailing slash) but + * misses bare ~/.claude (without trailing slash), leaving unreplaced references + * that cause the installer to warn about leaked paths. + * + * Files affected: agents/gsd-debugger.md (configDir = ~/.claude) and + * gsd-core/workflows/update.md (comment with e.g. ~/.claude). + */ + +process.env.GSD_TEST_MODE = '1'; + +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); + +const { convertClaudeToAntigravityContent } = require('../bin/install.js'); + +describe('convertClaudeToAntigravityContent bare path replacement (#2418)', () => { + describe('global install', () => { + test('replaces ~/.claude (bare, no trailing slash) with ~/.gemini/antigravity', () => { + const input = 'configDir = ~/.claude'; + const result = convertClaudeToAntigravityContent(input, true); + assert.ok( + result.includes('~/.gemini/antigravity'), + `Expected ~/.gemini/antigravity in output, got: ${result}` + ); + assert.ok( + !result.includes('~/.claude'), + `Expected ~/ .claude to be replaced, got: ${result}` + ); + }); + + test('replaces $HOME/.claude (bare, no trailing slash) with $HOME/.gemini/antigravity', () => { + const input = 'export DIR=$HOME/.claude'; + const result = convertClaudeToAntigravityContent(input, true); + assert.ok( + result.includes('$HOME/.gemini/antigravity'), + `Expected $HOME/.gemini/antigravity in output, got: ${result}` + ); + assert.ok( + !result.includes('$HOME/.claude'), + `Expected $HOME/.claude to be replaced, got: ${result}` + ); + }); + + test('handles bare ~/.claude followed by comma (comment context)', () => { + const input = '# e.g. ~/.claude, ~/.config/opencode'; + const result = convertClaudeToAntigravityContent(input, true); + assert.ok( + !result.includes('~/.claude'), + `Expected ~/ .claude to be replaced in comment context, got: ${result}` + ); + }); + + test('still replaces ~/.claude/ (with trailing slash) correctly', () => { + const input = 'See ~/.claude/gsd-core/workflows/'; + const result = convertClaudeToAntigravityContent(input, true); + assert.ok( + result.includes('~/.gemini/antigravity/gsd-core/workflows/'), + `Expected path with trailing slash to be replaced, got: ${result}` + ); + assert.ok(!result.includes('~/.claude/'), `Expected ~/ .claude/ to be fully replaced, got: ${result}`); + }); + + test('does not double-replace ~/.claude/ paths', () => { + const input = 'See ~/.claude/gsd-core/'; + const result = convertClaudeToAntigravityContent(input, true); + // Result should contain exactly one occurrence of the replacement path + const count = (result.match(/~\/.gemini\/antigravity\//g) || []).length; + assert.strictEqual(count, 1, `Expected exactly 1 replacement, got ${count} in: ${result}`); + }); + }); + + describe('local install', () => { + test('replaces ~/.claude (bare, no trailing slash) with .agents', () => { + const input = 'configDir = ~/.claude'; + const result = convertClaudeToAntigravityContent(input, false); + assert.ok( + result.includes('.agents'), + `Expected .agents in output, got: ${result}` + ); + assert.ok( + !result.includes('~/.claude'), + `Expected ~/ .claude to be replaced, got: ${result}` + ); + }); + + test('replaces $HOME/.claude (bare, no trailing slash) with .agents', () => { + const input = 'export DIR=$HOME/.claude'; + const result = convertClaudeToAntigravityContent(input, false); + assert.ok( + result.includes('.agents'), + `Expected .agents in output, got: ${result}` + ); + assert.ok( + !result.includes('$HOME/.claude'), + `Expected $HOME/.claude to be replaced, got: ${result}` + ); + }); + + test('does not double-replace ~/.claude/ paths', () => { + const input = 'See ~/.claude/gsd-core/'; + const result = convertClaudeToAntigravityContent(input, false); + // .agents/ should appear exactly once + const count = (result.match(/\.agents\//g) || []).length; + assert.strictEqual(count, 1, `Expected exactly 1 replacement, got ${count} in: ${result}`); + }); + }); + + describe('installed files contain no bare ~/.claude references after conversion', () => { + const fs = require('fs'); + const path = require('path'); + const repoRoot = path.join(__dirname, '..'); + + // The scanner regex used by the installer to detect leaked paths + const leakedPathRegex = /(?:~|\$HOME)\/\.claude\b/g; + + function convertFile(filePath, isGlobal) { + const content = fs.readFileSync(filePath, 'utf8'); + return convertClaudeToAntigravityContent(content, isGlobal); + } + + test('gsd-debugger.md has no leaked ~/.claude after global Antigravity conversion', () => { + const debuggerPath = path.join(repoRoot, 'agents', 'gsd-debugger.md'); + if (!fs.existsSync(debuggerPath)) return; // skip if file doesn't exist + const converted = convertFile(debuggerPath, true); + const matches = converted.match(leakedPathRegex); + assert.strictEqual( + matches, null, + `gsd-debugger.md still contains leaked .claude paths after Antigravity conversion: ${matches}` + ); + }); + + test('update.md has no leaked ~/.claude after global Antigravity conversion', () => { + const updatePath = path.join(repoRoot, 'gsd-core', 'workflows', 'update.md'); + if (!fs.existsSync(updatePath)) return; // skip if file doesn't exist + const converted = convertFile(updatePath, true); + const matches = converted.match(leakedPathRegex); + assert.strictEqual( + matches, null, + `update.md still contains leaked .claude paths after Antigravity conversion: ${matches}` + ); + }); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-2545-copilot-unreplaced-paths.test.cjs — consolidation epic #1969 (B1 #1970) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-2545-copilot-unreplaced-paths (consolidation epic #1969 B1 #1970)", () => { +/** + * Regression test for issue #2545. + * + * The Copilot content converter's `~/.claude/` and `$HOME/.claude/` replacements + * only matched when a literal slash followed, so bare `~/.claude` references + * (end of line, quotes, punctuation) were left unreplaced. Those leaks then + * triggered the installer's "Found N unreplaced .claude path reference(s)" + * warning, which scans for `(?:~|$HOME)/\.claude\b`. + * + * Fix: replace with a word-boundary pattern so both forms are caught in a + * single pass, matching the approach already used by the Antigravity, OpenCode, + * Kilo, and Codex converters. + */ + +process.env.GSD_TEST_MODE = '1'; + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); + +const { convertClaudeToCopilotContent } = require('../bin/install.js'); + +describe('convertClaudeToCopilotContent — bare ~/.claude (issue #2545)', () => { + test('global install replaces bare ~/.claude at end of line', () => { + const input = 'configDir = ~/.claude\n'; + const out = convertClaudeToCopilotContent(input, /* isGlobal */ true); + assert.ok( + !/(?:~|\$HOME)\/\.claude\b/.test(out), + `expected no leaked ~/.claude reference, got: ${JSON.stringify(out)}`, + ); + assert.match(out, /~\/\.copilot\b/); + }); + + test('global install replaces bare $HOME/.claude at end of line', () => { + const input = 'configDir = $HOME/.claude\n'; + const out = convertClaudeToCopilotContent(input, /* isGlobal */ true); + assert.ok( + !/(?:~|\$HOME)\/\.claude\b/.test(out), + `expected no leaked $HOME/.claude reference, got: ${JSON.stringify(out)}`, + ); + assert.match(out, /\$HOME\/\.copilot\b/); + }); + + test('global install replaces bare ~/.claude before punctuation', () => { + const input = 'paths include `~/.claude`, `~/.copilot`'; + const out = convertClaudeToCopilotContent(input, true); + assert.ok(!/(?:~|\$HOME)\/\.claude\b/.test(out)); + }); + + test('local install replaces bare ~/.claude', () => { + const input = 'configDir = ~/.claude\n'; + const out = convertClaudeToCopilotContent(input, /* isGlobal */ false); + assert.ok( + !/(?:~|\$HOME)\/\.claude\b/.test(out), + `expected no leaked ~/.claude reference, got: ${JSON.stringify(out)}`, + ); + }); + + test('does not double-replace trailing-slash form', () => { + const input = '@~/.claude/gsd-core/foo.md\n'; + const out = convertClaudeToCopilotContent(input, true); + assert.match(out, /~\/\.copilot\/gsd-core\/foo\.md/); + assert.ok(!/\.copilot\/\.copilot/.test(out)); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-983-trae-windsurf-claude-path-leak.test.cjs — consolidation epic #1969 (B1 #1970) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-983-trae-windsurf-claude-path-leak (consolidation epic #1969 B1 #1970)", () => { +// allow-test-rule: source-text-is-the-product (see #983) +'use strict'; + +process.env.GSD_TEST_MODE = '1'; + +/** + * Regression tests for issue #983 — Trae and Windsurf converters leak + * unreplaced bare `~/.claude` / `$HOME/.claude` references. + * + * Both converters rewrote only trailing-slash `.claude/` forms, so bare + * home-path references (configDir = ~/.claude, $HOME/.claude) survived + * conversion and pointed users at the wrong config dir. + * + * Fix: add bare word-boundary replacements mirroring Cline (#782) and + * Codex (#570) precedent, with a negative lookahead to preserve `.claude-plugin`. + */ + +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const { + convertClaudeToWindsurfMarkdown, + convertClaudeToTraeMarkdown, + _applyRuntimeRewrites, +} = require('../bin/install.js'); + +// ─── Windsurf converter bare-form tests ───────────────────────────────────── + +describe('convertClaudeToWindsurfMarkdown — bare ~/.claude and CLAUDE_CONFIG_DIR (#983)', () => { + test('bare ~/.claude rewritten to ~/.windsurf (#1615: workspace dir is now .windsurf)', () => { + const input = 'Config dir: (~/.claude), skills at ~/.claude/skills'; + const result = convertClaudeToWindsurfMarkdown(input); + assert.ok( + !/~\/\.claude(?![\w-])/.test(result), + `bare ~/.claude must be rewritten; got: ${result}`, + ); + assert.ok(result.includes('~/.windsurf'), 'must rewrite to ~/.windsurf'); + }); + + test('$HOME/.claude rewritten to $HOME/.windsurf (#1615: workspace dir is now .windsurf)', () => { + const input = 'RUNTIME_CONFIG_DIR="${CLAUDE_CONFIG_DIR:-$HOME/.claude}"'; + const result = convertClaudeToWindsurfMarkdown(input); + assert.ok( + !/\$HOME\/\.claude(?![\w-])/.test(result), + `bare $HOME/.claude must be rewritten; got: ${result}`, + ); + assert.ok(result.includes('$HOME/.windsurf'), 'must rewrite to $HOME/.windsurf'); + }); + + test('CLAUDE_CONFIG_DIR rewritten to WINDSURF_CONFIG_DIR', () => { + const input = 'Use CLAUDE_CONFIG_DIR or $HOME/.claude to configure'; + const result = convertClaudeToWindsurfMarkdown(input); + assert.ok( + result.includes('WINDSURF_CONFIG_DIR'), + 'CLAUDE_CONFIG_DIR must become WINDSURF_CONFIG_DIR', + ); + assert.ok( + !result.includes('CLAUDE_CONFIG_DIR'), + 'CLAUDE_CONFIG_DIR must be gone', + ); + }); + + test('.claude-plugin is NOT corrupted (preserved as-is)', () => { + const input = 'The .claude-plugin/plugin.json manifest enables plugin install.'; + const result = convertClaudeToWindsurfMarkdown(input); + assert.ok( + result.includes('.claude-plugin'), + `.claude-plugin must be preserved; got: ${result}`, + ); + assert.ok( + !result.includes('.windsurf-plugin'), + `.windsurf-plugin must not appear; got: ${result}`, + ); + }); + + test('no bare ~/.claude in converted surface.md', () => { + const surfacePath = path.join(__dirname, '..', 'commands', 'gsd', 'surface.md'); + const raw = fs.readFileSync(surfacePath, 'utf8'); + const result = convertClaudeToWindsurfMarkdown(raw); + assert.ok( + !/~\/\.claude(?![\w-])/.test(result), + 'converted surface.md must not contain bare ~/.claude', + ); + }); + + test('no $HOME/.claude in converted surface.md', () => { + const surfacePath = path.join(__dirname, '..', 'commands', 'gsd', 'surface.md'); + const raw = fs.readFileSync(surfacePath, 'utf8'); + const result = convertClaudeToWindsurfMarkdown(raw); + assert.ok( + !/\$HOME\/\.claude(?![\w-])/.test(result), + 'converted surface.md must not contain bare $HOME/.claude', + ); + }); + + test('no CLAUDE_CONFIG_DIR in converted surface.md', () => { + const surfacePath = path.join(__dirname, '..', 'commands', 'gsd', 'surface.md'); + const raw = fs.readFileSync(surfacePath, 'utf8'); + const result = convertClaudeToWindsurfMarkdown(raw); + assert.ok( + !result.includes('CLAUDE_CONFIG_DIR'), + 'converted surface.md must not contain CLAUDE_CONFIG_DIR', + ); + }); +}); + +// ─── Trae converter bare-form tests ───────────────────────────────────────── + +describe('convertClaudeToTraeMarkdown — bare ~/.claude and CLAUDE_CONFIG_DIR (#983)', () => { + test('bare ~/.claude rewritten to ~/.trae', () => { + const input = 'Config dir: (~/.claude), skills at ~/.claude/skills'; + const result = convertClaudeToTraeMarkdown(input); + assert.ok( + !/~\/\.claude(?![\w-])/.test(result), + `bare ~/.claude must be rewritten; got: ${result}`, + ); + assert.ok(result.includes('~/.trae'), 'must rewrite to ~/.trae'); + }); + + test('$HOME/.claude rewritten to $HOME/.trae', () => { + const input = 'RUNTIME_CONFIG_DIR="${CLAUDE_CONFIG_DIR:-$HOME/.claude}"'; + const result = convertClaudeToTraeMarkdown(input); + assert.ok( + !/\$HOME\/\.claude(?![\w-])/.test(result), + `bare $HOME/.claude must be rewritten; got: ${result}`, + ); + assert.ok(result.includes('$HOME/.trae'), 'must rewrite to $HOME/.trae'); + }); + + test('CLAUDE_CONFIG_DIR rewritten to TRAE_CONFIG_DIR', () => { + const input = 'Use CLAUDE_CONFIG_DIR or $HOME/.claude to configure'; + const result = convertClaudeToTraeMarkdown(input); + assert.ok( + result.includes('TRAE_CONFIG_DIR'), + 'CLAUDE_CONFIG_DIR must become TRAE_CONFIG_DIR', + ); + assert.ok( + !result.includes('CLAUDE_CONFIG_DIR'), + 'CLAUDE_CONFIG_DIR must be gone', + ); + }); + + test('.claude-plugin is NOT corrupted (preserved as-is)', () => { + const input = 'The .claude-plugin/plugin.json manifest enables plugin install.'; + const result = convertClaudeToTraeMarkdown(input); + assert.ok( + result.includes('.claude-plugin'), + `.claude-plugin must be preserved; got: ${result}`, + ); + assert.ok( + !result.includes('.trae-plugin'), + `.trae-plugin must not appear; got: ${result}`, + ); + }); + + test('no bare ~/.claude in converted surface.md', () => { + const surfacePath = path.join(__dirname, '..', 'commands', 'gsd', 'surface.md'); + const raw = fs.readFileSync(surfacePath, 'utf8'); + const result = convertClaudeToTraeMarkdown(raw); + assert.ok( + !/~\/\.claude(?![\w-])/.test(result), + 'converted surface.md must not contain bare ~/.claude', + ); + }); + + test('no $HOME/.claude in converted surface.md', () => { + const surfacePath = path.join(__dirname, '..', 'commands', 'gsd', 'surface.md'); + const raw = fs.readFileSync(surfacePath, 'utf8'); + const result = convertClaudeToTraeMarkdown(raw); + assert.ok( + !/\$HOME\/\.claude(?![\w-])/.test(result), + 'converted surface.md must not contain bare $HOME/.claude', + ); + }); + + test('no CLAUDE_CONFIG_DIR in converted surface.md', () => { + const surfacePath = path.join(__dirname, '..', 'commands', 'gsd', 'surface.md'); + const raw = fs.readFileSync(surfacePath, 'utf8'); + const result = convertClaudeToTraeMarkdown(raw); + assert.ok( + !result.includes('CLAUDE_CONFIG_DIR'), + 'converted surface.md must not contain CLAUDE_CONFIG_DIR', + ); + }); +}); + +// ─── _applyRuntimeRewrites install-path tests (windsurf) ──────────────────── +// +// These tests exercise the ACTUAL install path that causes the user-facing leak. +// The converter functions are called at stage time to produce a Windsurf-branded +// copy, but _applyRuntimeRewrites is the path that runs at INSTALL time and +// rewrites any surviving ~/.claude / $HOME/.claude refs in the staged files. +// +// FAIL-BEFORE proof: prior to this PR, windsurf used /~\/\.claude\b/ which +// fires on "~/.claude-plugin" because \b matches between 'e' and '-'. Running +// the test below against the old regex (`\b`) would: +// - let bare $HOME/.claude survive (it used only /~\/\.claude\b/, missing $HOME form), AND +// - corrupt "~/.claude-plugin" → "~/.windsurf-plugin". +// Both assertions in the test below would fail on the old code. +// +// PASS-AFTER: the fix changes to (?![\w-]) so: +// - bare ~/.claude / $HOME/.claude (not followed by word-char or hyphen) → rewritten +// - ~/.claude-plugin preserved (the '-' after 'e' is in [\w-]) +// +// NOTE on pathPrefix choice: we use '~/.windsurf/' (a simple home-relative +// prefix) rather than '$HOME/.codeium/windsurf/' so that the corruption of +// '~/.claude-plugin' → '~/.windsurf-plugin' is directly detectable via +// result.includes('.windsurf-plugin'). +describe('_applyRuntimeRewrites(windsurf) — install-path bare-form + .claude-plugin (#983)', () => { + // Use ~/ prefix (local-style) so that the .windsurf-plugin corruption is + // directly detectable as a substring of the result. + const WINDSURF_PATH_PREFIX = '~/.windsurf/'; + + // Compound content: covers every form the fix must handle. + // IMPORTANT: we use ~/.claude-plugin (home-relative form) to exercise the + // corruption that the old \b regex caused. The \b fires between 'e' and '-', + // so ~/.claude-plugin → ~/.windsurf-plugin under the old code. That would + // break the preservation assertion below. The (?![\w-]) fix prevents this. + const COMPOUND_INPUT = [ + 'Config dir: ~/.claude', + 'Also: $HOME/.claude', + 'Slash form: ~/.claude/skills/foo.md', + 'Plugin installed at: ~/.claude-plugin/plugin.json', + 'Env var: CLAUDE_CONFIG_DIR', + ].join('\n'); + + test('bare ~/.claude rewritten to ~/.windsurf (no trailing slash)', () => { + const result = _applyRuntimeRewrites(COMPOUND_INPUT, 'windsurf', WINDSURF_PATH_PREFIX); + assert.ok( + !/~\/\.claude(?![\w-])/.test(result), + `bare ~/.claude must be gone; got:\n${result}`, + ); + assert.ok( + result.includes('~/.windsurf'), + `must contain normalized pathPrefix; got:\n${result}`, + ); + }); + + test('bare $HOME/.claude rewritten to ~/.windsurf (install-path normalizes both home forms)', () => { + const result = _applyRuntimeRewrites(COMPOUND_INPUT, 'windsurf', WINDSURF_PATH_PREFIX); + assert.ok( + !/\$HOME\/\.claude(?![\w-])/.test(result), + `bare $HOME/.claude must be gone; got:\n${result}`, + ); + }); + + test('zero surviving bare ~/.claude or $HOME/.claude refs in compound input', () => { + const result = _applyRuntimeRewrites(COMPOUND_INPUT, 'windsurf', WINDSURF_PATH_PREFIX); + const bareClaudePattern = /(?:~|\$HOME)\/\.claude(?![\w-])/; + assert.ok( + !bareClaudePattern.test(result), + `no bare ~/.claude / $HOME/.claude must survive; got:\n${result}`, + ); + }); + + test('~/.claude-plugin is NOT corrupted to ~/.windsurf-plugin — was the \\b corruption', () => { + // FAIL-BEFORE: old /~\/\.claude\b/ rewrote ~/.claude-plugin → ~/.windsurf-plugin + // because \b fires between 'e' and '-'. + // PASS-AFTER: (?![\w-]) sees '-' and skips the match, preserving ~/.claude-plugin. + const result = _applyRuntimeRewrites(COMPOUND_INPUT, 'windsurf', WINDSURF_PATH_PREFIX); + assert.ok( + result.includes('~/.claude-plugin'), + `~/.claude-plugin must be preserved; got:\n${result}`, + ); + assert.ok( + !result.includes('~/.windsurf-plugin'), + `~/.windsurf-plugin must NOT appear (was the \\b corruption); got:\n${result}`, + ); + }); + + test('slash form ~/.claude/ is also rewritten (pre-existing coverage)', () => { + const result = _applyRuntimeRewrites(COMPOUND_INPUT, 'windsurf', WINDSURF_PATH_PREFIX); + assert.ok( + !result.includes('~/.claude/'), + `slash form ~/.claude/ must be gone; got:\n${result}`, + ); + }); + + test('CLAUDE_CONFIG_DIR is NOT rewritten by _applyRuntimeRewrites (converter responsibility)', () => { + // _applyRuntimeRewrites does NOT handle CLAUDE_CONFIG_DIR for windsurf; + // that rewrite is done by convertClaudeToWindsurfMarkdown at stage time. + // This test documents the boundary and guards against scope creep. + const result = _applyRuntimeRewrites(COMPOUND_INPUT, 'windsurf', WINDSURF_PATH_PREFIX); + assert.ok( + result.includes('CLAUDE_CONFIG_DIR'), + 'CLAUDE_CONFIG_DIR is not rewritten by _applyRuntimeRewrites — that is converter scope', + ); + }); +}); + +// ─── _applyRuntimeRewrites install-path tests (trae) ──────────────────────── +// +// Trae had bare-form handling before this PR (via \b) and the converter uses +// (?![\w-]). The pre-existing \b in _applyRuntimeRewrites DOES corrupt +// .claude-plugin → .trae-plugin (known limitation, out of scope for #983). +// We document this here but do NOT assert preservation for trae, and we do NOT +// fix the pre-existing trae \b lines (that would be a separate concern). +// +// What we DO assert: trae bare ~/.claude / $HOME/.claude refs are rewritten +// (the install path cleans them), which is the core #983 fix for trae. +describe('_applyRuntimeRewrites(trae) — install-path bare-form (#983)', () => { + const TRAE_PATH_PREFIX = '$HOME/.trae/'; + + const TRAE_INPUT = [ + 'Config dir: ~/.claude', + 'Also: $HOME/.claude', + 'Slash form: ~/.claude/skills/foo.md', + // Note: .claude-plugin is intentionally omitted from assertions here because + // the pre-existing trae case uses \b which corrupts it (known limitation, + // out of scope for #983 — do not fix here). + ].join('\n'); + + test('bare ~/.claude rewritten to $HOME/.trae (trae install path)', () => { + const result = _applyRuntimeRewrites(TRAE_INPUT, 'trae', TRAE_PATH_PREFIX); + assert.ok( + !/~\/\.claude(?![\w-])/.test(result), + `bare ~/.claude must be gone; got:\n${result}`, + ); + }); + + test('bare $HOME/.claude rewritten to $HOME/.trae (trae install path)', () => { + const result = _applyRuntimeRewrites(TRAE_INPUT, 'trae', TRAE_PATH_PREFIX); + assert.ok( + !/\$HOME\/\.claude(?![\w-])/.test(result), + `bare $HOME/.claude must be gone; got:\n${result}`, + ); + }); + + test('slash form ~/.claude/ also rewritten (trae install path)', () => { + const result = _applyRuntimeRewrites(TRAE_INPUT, 'trae', TRAE_PATH_PREFIX); + assert.ok( + !result.includes('~/.claude/'), + `slash form ~/.claude/ must be gone; got:\n${result}`, + ); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-782-cline-skills-emission.test.cjs — consolidation epic #1969 (B1 #1970) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-782-cline-skills-emission (consolidation epic #1969 B1 #1970)", () => { +'use strict'; +/** + * Regression tests for bug #782 — Cline skills emission. + * + * gsd now emits skills to ~/.cline/skills//SKILL.md for Cline >= v3.48. + * Skills discovery: https://docs.cline.bot/customization/skills + * + * (a) Converter unit test: convertClaudeCommandToClineSkill + * (b) Integration test: installRuntimeArtifacts for cline writes SKILL.md files + * (c) .clinerules/gsd.md still written by the install path (#787 dir form) + * (d) Idempotency: running install twice leaves skills + .clinerules/ intact + * (e) Full install() global: both skills AND .clinerules/gsd.md are written + */ + +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 { createTempDir, cleanup, captureConsole } = require('./helpers.cjs'); + +const { + convertClaudeCommandToClineSkill, + convertClaudeToCliineMarkdown, + install, + _applyRuntimeRewrites, +} = require('../bin/install.js'); + +const { installRuntimeArtifacts } = require('../gsd-core/bin/lib/install-engine.cjs'); + +const { + resolveRuntimeArtifactLayout, +} = require('../gsd-core/bin/lib/runtime-artifact-layout.cjs'); + +const { + loadSkillsManifest, + resolveProfile, +} = require('../gsd-core/bin/lib/install-profiles.cjs'); + +const { nestedSkillPath } = require('./helpers/nested-layout.cjs'); + +const REAL_COMMANDS_DIR = path.join(__dirname, '..', 'commands', 'gsd'); +const MANIFEST = loadSkillsManifest(REAL_COMMANDS_DIR); +const RESOLVED_CORE = resolveProfile({ modes: ['core'], manifest: MANIFEST }); + +// ─── (a) Converter unit test ───────────────────────────────────────────────── + +const SAMPLE_COMMAND = `--- +name: gsd:execute-phase +description: Execute all tasks in the current phase using Cline tools. +allowed-tools: + - Read + - Write + - Bash +--- + +## Objective + +Run all tasks in the current phase. + +See ~/.claude/skills/gsd-help/SKILL.md for reference. +Use \`/gsd-help\` or Claude Code for details. +`; + +// A command that exercises all three Claude-specific frontmatter fields that +// must NOT leak into the emitted Cline SKILL.md. +const RICH_COMMAND = `--- +name: gsd:validate-phase +description: Retroactively audit and fill Nyquist validation gaps for a completed phase +argument-hint: "[phase number]" +agent: researcher +allowed-tools: + - Read + - Write + - Edit + - Bash + - Glob + - Grep + - Agent + - AskUserQuestion +--- + +## Objective + +Audit Nyquist validation coverage. See ~/.claude/skills/gsd-help/SKILL.md for reference. +Use Claude Code for details. +`; + +/** + * Extract frontmatter block (between --- delimiters) from output. + * Returns the raw text between the first --- and the closing ---. + * Uses \r?\n to handle both LF and CRLF line endings (Windows parity). + */ +function parseFrontmatter(text) { + const m = text.match(/^---\r?\n([\s\S]*?)\r?\n---/); + return m ? m[1] : null; +} + +describe('convertClaudeCommandToClineSkill — unit', () => { + test('emits frontmatter with name: gsd-', () => { + const result = convertClaudeCommandToClineSkill(SAMPLE_COMMAND, 'gsd-execute-phase'); + const nameMatch = result.match(/^name:\s*(.+)$/m); + assert.ok(nameMatch, 'frontmatter must contain name field'); + assert.ok(nameMatch[1].includes('gsd-execute-phase'), 'name must start with gsd-execute-phase'); + }); + + test('emits non-empty description in frontmatter', () => { + const result = convertClaudeCommandToClineSkill(SAMPLE_COMMAND, 'gsd-execute-phase'); + const descMatch = result.match(/^description:\s*(.+)$/m); + assert.ok(descMatch, 'frontmatter must contain description field'); + assert.ok(descMatch[1].trim().length > 0, 'description must not be empty'); + }); + + test('body uses .cline/ paths not .claude/', () => { + const result = convertClaudeCommandToClineSkill(SAMPLE_COMMAND, 'gsd-execute-phase'); + // The body reference to ~/.claude/ should be rewritten to ~/.cline/ + assert.ok(!result.includes('~/.claude/skills'), 'body must not contain ~/.claude/skills'); + assert.ok(result.includes('.cline/skills'), 'body must contain .cline/skills'); + }); + + test('body replaces "Claude Code" with "Cline"', () => { + const result = convertClaudeCommandToClineSkill(SAMPLE_COMMAND, 'gsd-execute-phase'); + assert.ok(!result.includes('Claude Code'), 'Claude Code must be replaced with Cline'); + assert.ok(result.includes('Cline'), 'result must contain Cline branding'); + }); + + test('no stray .claude/ paths in frontmatter or body', () => { + const result = convertClaudeCommandToClineSkill(SAMPLE_COMMAND, 'gsd-execute-phase'); + // Should not contain .claude/ anywhere (except inside CLAUDE.md→.clinerules rewrites + // but those are already handled by convertClaudeToCliineMarkdown) + assert.ok(!result.includes('/.claude/'), 'no /.claude/ paths in output'); + }); + + // ── Fix 1 (code-review): frontmatter must be ONLY name + description ────── + + test('frontmatter emits ONLY name and description — no allowed-tools (SAMPLE_COMMAND)', () => { + const result = convertClaudeCommandToClineSkill(SAMPLE_COMMAND, 'gsd-execute-phase'); + const fm = parseFrontmatter(result); + assert.ok(fm !== null, 'result must have YAML frontmatter'); + assert.ok(!fm.includes('allowed-tools'), 'frontmatter must NOT contain allowed-tools'); + assert.ok(!fm.includes('argument-hint'), 'frontmatter must NOT contain argument-hint'); + assert.ok(!fm.includes('agent:'), 'frontmatter must NOT contain agent:'); + }); + + test('frontmatter emits ONLY name and description — no allowed-tools/argument-hint/agent (RICH_COMMAND)', () => { + const result = convertClaudeCommandToClineSkill(RICH_COMMAND, 'gsd-validate-phase'); + const fm = parseFrontmatter(result); + assert.ok(fm !== null, 'result must have YAML frontmatter'); + assert.ok(!fm.includes('allowed-tools'), 'frontmatter must NOT contain allowed-tools'); + assert.ok(!fm.includes('argument-hint'), 'frontmatter must NOT contain argument-hint'); + assert.ok(!fm.includes('agent:'), 'frontmatter must NOT contain agent:'); + }); + + test('name == gsd-validate-phase for RICH_COMMAND', () => { + const result = convertClaudeCommandToClineSkill(RICH_COMMAND, 'gsd-validate-phase'); + const nameMatch = result.match(/^name:\s*(.+)$/m); + assert.ok(nameMatch, 'must have name field'); + // yamlIdentifier may quote the value; strip surrounding quotes for comparison + const nameVal = nameMatch[1].replace(/^['"]|['"]$/g, '').trim(); + assert.strictEqual(nameVal, 'gsd-validate-phase', `name must be gsd-validate-phase, got: ${nameVal}`); + }); + + test('description is non-empty and <= 1024 chars for RICH_COMMAND', () => { + const result = convertClaudeCommandToClineSkill(RICH_COMMAND, 'gsd-validate-phase'); + const descMatch = result.match(/^description:\s*(.+)$/m); + assert.ok(descMatch, 'must have description field'); + const desc = descMatch[1].replace(/^['"]|['"]$/g, '').trim(); + assert.ok(desc.length > 0, 'description must be non-empty'); + assert.ok(desc.length <= 1024, `description must be <= 1024 chars, got ${desc.length}`); + }); + + test('description truncated to <=1024 chars when source description is very long', () => { + const longDesc = 'A'.repeat(2000); + const longDescCommand = `---\nname: gsd:test\ndescription: ${longDesc}\n---\n\nBody text.\n`; + const result = convertClaudeCommandToClineSkill(longDescCommand, 'gsd-test'); + const descMatch = result.match(/^description:\s*'?(.*?)'?$/m); + assert.ok(descMatch, 'must have description field'); + // The raw description value (unquoted) should be <=1024 chars + // The result string after the --- block will have the quoted form; check raw length + // by checking the whole result doesn't have the full 2000-char string + assert.ok(!result.includes('A'.repeat(1025)), 'description must be truncated to 1024 chars'); + }); + + test('returns content unchanged when source has no frontmatter', () => { + const noFm = 'Just a body, no frontmatter here.\n'; + const result = convertClaudeCommandToClineSkill(noFm, 'gsd-test'); + assert.strictEqual(result, noFm, 'content without frontmatter must be returned unchanged'); + }); + + test('RICH_COMMAND body uses .cline/ paths and Cline branding', () => { + const result = convertClaudeCommandToClineSkill(RICH_COMMAND, 'gsd-validate-phase'); + assert.ok(!result.includes('~/.claude/'), 'body must not contain ~/.claude/'); + assert.ok(result.includes('.cline/'), 'body must contain .cline/ paths'); + assert.ok(!result.includes('Claude Code'), 'body must not contain "Claude Code"'); + assert.ok(result.includes('Cline'), 'body must reference Cline'); + }); +}); + +// ─── (b) + (c) + (d) Integration tests ──────────────────────────────────────── + +describe('installRuntimeArtifacts — cline skills emission', () => { + test('cline global: writes gsd-prefixed skill dirs under skills/', (t) => { + const configDir = createTempDir('gsd-cline-skills-'); + t.after(() => cleanup(configDir)); + + installRuntimeArtifacts('cline', configDir, 'global', RESOLVED_CORE); + + const layout = resolveRuntimeArtifactLayout('cline', configDir, 'global'); + const skillsKind = layout.kinds.find(k => k.kind === 'skills'); + assert.ok(skillsKind, 'cline must have a skills kind after #782'); + + const skillsDir = path.join(configDir, skillsKind.destSubpath); + assert.ok(fs.existsSync(skillsDir), 'skills/ directory must be created'); + + const helpSkillDir = path.join(skillsDir, `${skillsKind.prefix}help`); + assert.ok( + fs.existsSync(path.join(helpSkillDir, 'SKILL.md')), + `gsd-help/SKILL.md must exist under ${skillsKind.destSubpath}/` + ); + }); + + test('cline global: SKILL.md has valid cline frontmatter (name + description)', (t) => { + const configDir = createTempDir('gsd-cline-fm-'); + t.after(() => cleanup(configDir)); + + installRuntimeArtifacts('cline', configDir, 'global', RESOLVED_CORE); + + const skillsDir = path.join(configDir, 'skills'); + const helpSkill = path.join(skillsDir, 'gsd-help', 'SKILL.md'); + assert.ok(fs.existsSync(helpSkill), 'gsd-help/SKILL.md must exist'); + + const content = fs.readFileSync(helpSkill, 'utf8'); + // Must have YAML frontmatter + assert.ok(content.startsWith('---'), 'SKILL.md must start with YAML frontmatter'); + assert.ok(content.includes('name:'), 'frontmatter must have name field'); + assert.ok(content.includes('description:'), 'frontmatter must have description field'); + // name must be gsd-help + const nameMatch = content.match(/^name:\s*(.+)$/m); + assert.ok(nameMatch, 'must have name field'); + assert.ok(nameMatch[1].includes('gsd-help'), `name must include gsd-help, got: ${nameMatch[1]}`); + }); + + test('cline global: SKILL.md uses .cline/ paths not .claude/', (t) => { + const configDir = createTempDir('gsd-cline-paths-'); + t.after(() => cleanup(configDir)); + + installRuntimeArtifacts('cline', configDir, 'global', RESOLVED_CORE); + + const skillsDir = path.join(configDir, 'skills'); + // Check all installed skill files for stray .claude/ references + const skills = fs.readdirSync(skillsDir).filter(n => n.startsWith('gsd-')); + assert.ok(skills.length > 0, 'at least one gsd- skill must be installed'); + + for (const skillName of skills) { + const skillFile = path.join(skillsDir, skillName, 'SKILL.md'); + if (!fs.existsSync(skillFile)) continue; + const content = fs.readFileSync(skillFile, 'utf8'); + assert.ok( + !content.includes('~/.claude/'), + `${skillName}/SKILL.md must not contain ~/.claude/ — found stray path` + ); + assert.ok( + !content.includes('/.claude/'), + `${skillName}/SKILL.md must not contain /.claude/ — found stray path` + ); + } + }); + + test('cline global: skill count matches resolved profile', (t) => { + const configDir = createTempDir('gsd-cline-count-'); + t.after(() => cleanup(configDir)); + + installRuntimeArtifacts('cline', configDir, 'global', RESOLVED_CORE); + + const skillsDir = path.join(configDir, 'skills'); + const count = fs.readdirSync(skillsDir) + .filter(n => n.startsWith('gsd-') && fs.statSync(path.join(skillsDir, n)).isDirectory()) + .length; + + if (RESOLVED_CORE.skills !== '*') { + assert.strictEqual(count, RESOLVED_CORE.skills.size, + `installed skill count (${count}) must match profile size (${RESOLVED_CORE.skills.size})`); + } else { + assert.ok(count > 0, 'must install at least 1 skill'); + } + }); +}); + +describe('installRuntimeArtifacts — cline idempotency', () => { + test('cline: running install twice leaves skills intact (idempotency)', (t) => { + const configDir = createTempDir('gsd-cline-idempotent-'); + t.after(() => cleanup(configDir)); + + // First install + installRuntimeArtifacts('cline', configDir, 'global', RESOLVED_CORE); + + const skillsDir = path.join(configDir, 'skills'); + const countAfterFirst = fs.readdirSync(skillsDir) + .filter(n => n.startsWith('gsd-') && fs.statSync(path.join(skillsDir, n)).isDirectory()) + .length; + + // Second install (upgrade over existing) + installRuntimeArtifacts('cline', configDir, 'global', RESOLVED_CORE); + + const countAfterSecond = fs.readdirSync(skillsDir) + .filter(n => n.startsWith('gsd-') && fs.statSync(path.join(skillsDir, n)).isDirectory()) + .length; + + assert.strictEqual(countAfterFirst, countAfterSecond, + `skill count must be stable across installs: first=${countAfterFirst} second=${countAfterSecond}`); + }); +}); + +// ─── (e) Full install() global — coexistence regression ─────────────────────── +// +// Issue #782 explicitly requires that a global Cline install writes BOTH: +// - skills//SKILL.md (skills for Cline >= v3.48) +// - .clinerules/gsd.md (rules dir form introduced by #787) +// +// installRuntimeArtifacts() tests cover skills in isolation; this test exercises +// the FULL install() code path to ensure neither artifact is silently dropped. + +describe('install() global cline — coexistence: skills AND .clinerules', () => { + let tmpGlobalDir; + let originalClineConfigDir; + + beforeEach(() => { + originalClineConfigDir = process.env.CLINE_CONFIG_DIR; + tmpGlobalDir = createTempDir('gsd-cline-global-'); + // Redirect CLINE_CONFIG_DIR to the temp dir so install() never touches ~/.cline + process.env.CLINE_CONFIG_DIR = tmpGlobalDir; + }); + + afterEach(() => { + if (originalClineConfigDir !== undefined) { + process.env.CLINE_CONFIG_DIR = originalClineConfigDir; + } else { + delete process.env.CLINE_CONFIG_DIR; + } + cleanup(tmpGlobalDir); + }); + + test('global cline install writes at least one gsd-* SKILL.md under skills/', () => { + captureConsole(() => install(true, 'cline')); + + const skillsDir = path.join(tmpGlobalDir, 'skills'); + assert.ok( + fs.existsSync(skillsDir), + `skills/ directory must exist under ${tmpGlobalDir} after global cline install` + ); + + // full profile: gsd-help is nested under gsd-ns-manage/skills/help/SKILL.md + const helpSkillFile = nestedSkillPath(skillsDir, 'gsd-', 'help'); + assert.ok( + fs.existsSync(helpSkillFile), + `${path.relative(tmpGlobalDir, helpSkillFile)} must exist under ${tmpGlobalDir} — skills emission broken for global cline` + ); + }); + + test('global cline install writes .clinerules/gsd.md to the global config dir', () => { + captureConsole(() => install(true, 'cline')); + + // For a global Cline install, targetDir = getGlobalDir('cline') = CLINE_CONFIG_DIR. + // The cline-rules surface (#787) writes the .clinerules/ DIRECTORY form: + // .clinerules/gsd.md (rule file) + // .clinerules/hooks/PreToolUse (lifecycle hook) + const clinerulesMd = path.join(tmpGlobalDir, '.clinerules', 'gsd.md'); + assert.ok( + fs.existsSync(clinerulesMd), + `.clinerules/gsd.md must exist at ${clinerulesMd} — coexistence with skills broken for global cline (#782+#787)` + ); + }); + + test('global cline .clinerules/gsd.md contains GSD instructions', () => { + captureConsole(() => install(true, 'cline')); + + // #787 dir form: rule content lives in .clinerules/gsd.md, not a flat .clinerules file + const clinerulesMd = path.join(tmpGlobalDir, '.clinerules', 'gsd.md'); + assert.ok(fs.existsSync(clinerulesMd), '.clinerules/gsd.md must exist'); + const content = fs.readFileSync(clinerulesMd, 'utf8'); + assert.ok( + content.includes('GSD') || content.includes('gsd'), + '.clinerules/gsd.md must reference GSD' + ); + }); +}); + +// ─── Fix 3 regression: converter rewrites bare ~/.claude and CLAUDE_CONFIG_DIR ── +// +// convertClaudeToCliineMarkdown must also handle bare ~/.claude (no trailing +// slash) and the CLAUDE_CONFIG_DIR env-var name. surface.md contains these; +// the emitted Cline SKILL.md must contain no such stale Claude refs. + +describe('convertClaudeToCliineMarkdown — bare ~/.claude and CLAUDE_CONFIG_DIR (Fix 3)', () => { + const surfacePath = path.join(__dirname, '..', 'commands', 'gsd', 'surface.md'); + + test('no bare ~/.claude in converted surface.md', () => { + const raw = fs.readFileSync(surfacePath, 'utf8'); + const result = convertClaudeToCliineMarkdown(raw); + // ~/.claude followed by a word-boundary (not a /) must be gone + assert.ok( + !/~\/\.claude\b/.test(result), + 'converted surface.md must not contain bare ~/.claude' + ); + }); + + test('no CLAUDE_CONFIG_DIR in converted surface.md', () => { + const raw = fs.readFileSync(surfacePath, 'utf8'); + const result = convertClaudeToCliineMarkdown(raw); + assert.ok( + !result.includes('CLAUDE_CONFIG_DIR'), + 'converted surface.md must not contain CLAUDE_CONFIG_DIR' + ); + }); + + test('CLAUDE_CONFIG_DIR rewritten to CLINE_CONFIG_DIR', () => { + const input = 'Use CLAUDE_CONFIG_DIR or $HOME/.claude to configure'; + const result = convertClaudeToCliineMarkdown(input); + assert.ok(result.includes('CLINE_CONFIG_DIR'), 'CLAUDE_CONFIG_DIR must become CLINE_CONFIG_DIR'); + assert.ok(!result.includes('CLAUDE_CONFIG_DIR'), 'CLAUDE_CONFIG_DIR must be gone'); + }); + + test('bare ~/.claude rewritten to ~/.cline', () => { + const input = 'Config dir: (~/.claude), skills at ~/.claude/skills'; + const result = convertClaudeToCliineMarkdown(input); + assert.ok(!result.includes('~/.claude'), 'bare ~/.claude must be rewritten'); + assert.ok(result.includes('~/.cline'), 'must rewrite to ~/.cline'); + }); + + test('installRuntimeArtifacts cline global: gsd-surface SKILL.md has no bare ~/.claude or CLAUDE_CONFIG_DIR', (t) => { + const configDir = createTempDir('gsd-cline-surface-fix3-'); + t.after(() => cleanup(configDir)); + + const MANIFEST_FULL = require('../gsd-core/bin/lib/install-profiles.cjs').loadSkillsManifest( + path.join(__dirname, '..', 'commands', 'gsd') + ); + const RESOLVED_FULL = require('../gsd-core/bin/lib/install-profiles.cjs').resolveProfile({ + modes: ['full'], manifest: MANIFEST_FULL, + }); + + installRuntimeArtifacts('cline', configDir, 'global', RESOLVED_FULL); + + // full profile: surface is nested under gsd-ns-manage/skills/surface/SKILL.md + const surfaceSkill = nestedSkillPath(path.join(configDir, 'skills'), 'gsd-', 'surface'); + assert.ok(fs.existsSync(surfaceSkill), `${path.relative(configDir, surfaceSkill)} must exist for full profile`); + + const content = fs.readFileSync(surfaceSkill, 'utf8'); + assert.ok( + !/~\/\.claude\b/.test(content), + 'gsd-surface SKILL.md must not contain bare ~/.claude (Fix 3)' + ); + assert.ok( + !content.includes('CLAUDE_CONFIG_DIR'), + 'gsd-surface SKILL.md must not contain CLAUDE_CONFIG_DIR (Fix 3)' + ); + }); +}); + +// ─── Fix 1 regression: custom CLINE_CONFIG_DIR → embedded paths use custom dir ── +// +// _applyRuntimeRewrites for cline must rewrite ~/.cline/ → pathPrefix. +// For default global installs, pathPrefix = "$HOME/.cline/" (unchanged). +// For custom installs (CLINE_CONFIG_DIR=/custom), pathPrefix = "/custom/" and +// all embedded ~/.cline/ refs in SKILL.md must become /custom/... + +describe('_applyRuntimeRewrites — cline custom-dir embedded path (Fix 1)', () => { + test('default pathPrefix ($HOME/.cline/) leaves ~/.cline refs as $HOME/.cline', () => { + const content = 'See ~/.cline/skills/gsd-help/SKILL.md for reference.\nBare: ~/.cline\n'; + const result = _applyRuntimeRewrites(content, 'cline', '$HOME/.cline/'); + assert.ok(result.includes('$HOME/.cline/'), 'default prefix must map ~/.cline/ to $HOME/.cline/'); + assert.ok(!result.includes('~/.cline'), 'no tilde form should remain after rewrite'); + }); + + test('custom pathPrefix rewrites ~/.cline/ → custom path in SKILL.md body', () => { + const content = 'See ~/.cline/skills/gsd-help/SKILL.md for reference.\nBare: ~/.cline\n'; + const result = _applyRuntimeRewrites(content, 'cline', '/custom/cline-dir/'); + assert.ok(result.includes('/custom/cline-dir/'), 'custom prefix must appear in output'); + assert.ok(!result.includes('~/.cline'), 'no tilde cline form should remain after custom rewrite'); + }); + + test('custom pathPrefix rewrites residual ~/.claude/ safety net', () => { + const content = 'Residual: ~/.claude/skills\n'; + const result = _applyRuntimeRewrites(content, 'cline', '/custom/cline-dir/'); + assert.ok(result.includes('/custom/cline-dir/'), 'safety-net ~/.claude/ also rewritten to custom prefix'); + assert.ok(!result.includes('~/.claude/'), 'no ~/.claude/ should remain'); + }); + + test('installRuntimeArtifacts cline with CLINE_CONFIG_DIR custom: SKILL.md embeds custom path', (t) => { + const configDir = createTempDir('gsd-cline-custom-dir-'); + t.after(() => cleanup(configDir)); + + const MANIFEST_FULL = require('../gsd-core/bin/lib/install-profiles.cjs').loadSkillsManifest( + path.join(__dirname, '..', 'commands', 'gsd') + ); + const RESOLVED_FULL = require('../gsd-core/bin/lib/install-profiles.cjs').resolveProfile({ + modes: ['full'], manifest: MANIFEST_FULL, + }); + + installRuntimeArtifacts('cline', configDir, 'global', RESOLVED_FULL); + + // gsd-surface SKILL.md references config paths; with a custom configDir + // (not under $HOME), pathPrefix will be the absolute custom path. + // full profile: surface is nested under gsd-ns-manage/skills/surface/SKILL.md + const surfaceSkill = nestedSkillPath(path.join(configDir, 'skills'), 'gsd-', 'surface'); + assert.ok(fs.existsSync(surfaceSkill), `${path.relative(configDir, surfaceSkill)} must exist`); + + const content = fs.readFileSync(surfaceSkill, 'utf8'); + // With a custom dir (path under /tmp, not ~/.cline), the output must NOT + // contain ~/.cline/ or $HOME/.cline/ — it must embed the actual configDir path. + assert.ok( + !content.includes('~/.cline/'), + `gsd-surface SKILL.md must not contain ~/.cline/ when configDir=${configDir} (Fix 1)` + ); + // The custom path must appear somewhere in the file + // (configDir is a /tmp/... path so pathPrefix = configDir+'/'). + // Production normalizes backslashes to forward slashes via + // path.resolve(configDir).replace(/\\/g, '/'), so compare against that + // form — otherwise this assertion fails on Windows where mkdtempSync + // returns a backslash path (e.g. C:\Users\...) but the emitted content + // already has forward slashes (C:/Users/...). + const expectedPath = path.resolve(configDir).replace(/\\/g, '/'); + assert.ok( + content.includes(expectedPath), + `gsd-surface SKILL.md must embed custom configDir path ${expectedPath} (Fix 1)` + ); + }); +}); + +// ─── Fix 4 regression: description truncation is code-point-aware ──────────── +// +// Naive UTF-16 slicing (`str.slice(0, 1021)`) can split a surrogate pair when +// the cut falls between the high and low surrogate of a multibyte character +// (e.g. emoji U+1F600, which is encoded as two UTF-16 code units). The fix +// uses Array.from() to split by code point, guaranteeing that the truncated +// value never contains a lone surrogate. + +describe('convertClaudeCommandToClineSkill — code-point-aware truncation (Fix 4)', () => { + /** + * Build a frontmatter+body command string whose description is: + * - exactly `prefixLen` ASCII chars + * - followed by `emojiCount` repetitions of '😀' (U+1F600, 2 UTF-16 units) + * - total UTF-16 length is prefixLen + emojiCount * 2 + */ + function makeEmojiCommand(prefixLen, emojiCount) { + const desc = 'A'.repeat(prefixLen) + '😀'.repeat(emojiCount); + return `---\nname: gsd:emoji-test\ndescription: ${desc}\n---\n\nBody.\n`; + } + + test('emitted description is <= 1024 code points when source overflows', () => { + // 1020 ASCII chars + 4 emoji = 1020 + 8 UTF-16 units = 1028 UTF-16 units > 1024. + // Code-point count = 1020 + 4 = 1024 — exactly at the boundary BEFORE adding '...'. + // After truncation to 1021 code points + '...' → 1024 code points total. + const cmd = makeEmojiCommand(1020, 10); // 1030 code points → must truncate + const result = convertClaudeCommandToClineSkill(cmd, 'gsd-emoji-test'); + + // Extract raw description value (strip surrounding YAML quotes if present) + const descMatch = result.match(/^description:\s*(.+)$/m); + assert.ok(descMatch, 'emitted SKILL.md must have a description field'); + const rawDesc = descMatch[1].trim().replace(/^['"]|['"]$/g, ''); + + const codePoints = Array.from(rawDesc); + assert.ok( + codePoints.length <= 1024, + `emitted description must be <= 1024 code points, got ${codePoints.length}` + ); + }); + + test('emitted description ends with "..." when truncated', () => { + const cmd = makeEmojiCommand(1020, 10); // 1030 code points → must truncate + const result = convertClaudeCommandToClineSkill(cmd, 'gsd-emoji-test'); + + const descMatch = result.match(/^description:\s*(.+)$/m); + assert.ok(descMatch, 'emitted SKILL.md must have a description field'); + const rawDesc = descMatch[1].trim().replace(/^['"]|['"]$/g, ''); + + assert.ok(rawDesc.endsWith('...'), `truncated description must end with "...", got: ${rawDesc.slice(-10)}`); + }); + + test('emitted description has no lone surrogate (no split emoji)', () => { + // Place emojis exactly at positions 1021–1025 (code points) so that a naive + // UTF-16 slice at 1021 code units would cut inside the second emoji's surrogate pair. + // 1019 ASCII chars + 6 emoji = 1025 code points (>1024, triggers truncation). + // UTF-16 length = 1019 + 12 = 1031. Naive slice(0,1021) yields 1019 ASCII + + // the HIGH surrogate of emoji[0] — a lone surrogate. + const cmd = makeEmojiCommand(1019, 6); + const result = convertClaudeCommandToClineSkill(cmd, 'gsd-emoji-test'); + + const descMatch = result.match(/^description:\s*(.+)$/m); + assert.ok(descMatch, 'emitted SKILL.md must have a description field'); + const rawDesc = descMatch[1].trim().replace(/^['"]|['"]$/g, ''); + + // Verify no lone surrogate: every char's code point must be outside [0xD800, 0xDFFF]. + const hasLoneSurrogate = [...rawDesc].some(c => { + const cp = c.codePointAt(0); + return cp >= 0xD800 && cp <= 0xDFFF; + }); + assert.ok(!hasLoneSurrogate, 'emitted description must not contain a lone surrogate'); + + // Also round-trip through Buffer to confirm the string is valid UTF-8 encodable. + assert.doesNotThrow( + () => Buffer.from(rawDesc, 'utf8').toString('utf8'), + 'emitted description must round-trip through Buffer without error' + ); + }); + + test('short description (<= 1024 code points) is not truncated', () => { + // 10 ASCII + 5 emoji = 15 code points — well under the limit. + const cmd = makeEmojiCommand(10, 5); + const result = convertClaudeCommandToClineSkill(cmd, 'gsd-emoji-test'); + + const descMatch = result.match(/^description:\s*(.+)$/m); + assert.ok(descMatch, 'emitted SKILL.md must have a description field'); + const rawDesc = descMatch[1].trim().replace(/^['"]|['"]$/g, ''); + + assert.ok(!rawDesc.endsWith('...'), 'short description must NOT be truncated with "..."'); + // Must contain the original emoji characters intact + assert.ok(rawDesc.includes('😀'), 'short description must preserve emoji characters'); + }); +}); + +// ─── Fix 2 regression: cline local scope emits no skills ───────────────────── +// +// resolveRuntimeArtifactLayout('cline', dir, 'local') must return 0 kinds. +// installRuntimeArtifacts('cline', dir, 'local') must not write any skills. + +describe('resolveRuntimeArtifactLayout — cline scope-aware (Fix 2)', () => { + test('cline local: kinds.length === 0 (no skills for local scope)', () => { + const { resolveRuntimeArtifactLayout } = require('../gsd-core/bin/lib/runtime-artifact-layout.cjs'); + const layout = resolveRuntimeArtifactLayout('cline', '/tmp/x', 'local'); + assert.strictEqual(layout.kinds.length, 0, 'cline local must have 0 kinds'); + }); + + test('cline global: kinds.length === 1 (skills kind)', () => { + const { resolveRuntimeArtifactLayout } = require('../gsd-core/bin/lib/runtime-artifact-layout.cjs'); + const layout = resolveRuntimeArtifactLayout('cline', '/tmp/x', 'global'); + assert.strictEqual(layout.kinds.length, 1, 'cline global must have 1 skills kind'); + assert.strictEqual(layout.kinds[0].kind, 'skills'); + }); + + test('installRuntimeArtifacts cline local: no skills/ dir created', (t) => { + const configDir = createTempDir('gsd-cline-local-noskills-'); + t.after(() => cleanup(configDir)); + + assert.doesNotThrow(() => installRuntimeArtifacts('cline', configDir, 'local', RESOLVED_CORE)); + const skillsDir = path.join(configDir, 'skills'); + assert.ok( + !fs.existsSync(skillsDir), + `skills/ must NOT be created for cline local install (Fix 2), but found ${skillsDir}` + ); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-3037-gemini-duplicate-commands.test.cjs — consolidation epic #1969 (B1 #1970) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-3037-gemini-duplicate-commands (consolidation epic #1969 B1 #1970)", () => { +/** + * Bug #3037: Gemini global+local install creates duplicate /gsd:* commands + * across user (HOME/.gemini/) and workspace (PROJECT/.gemini/) scopes. + * + * Reproduction (from issue body): + * 1. install --gemini --global with HOME=tmpHome + * 2. cd tmpProject; install --gemini --local + * → both ~/.gemini/commands/gsd/ and PROJECT/.gemini/commands/gsd/ contain + * 65 overlapping command filenames. + * → Gemini conflict detection renames every overlapping command to + * /workspace.gsd:* and /user.gsd:*, breaking the documented /gsd:* + * namespace. + * + * Fix: when the local Gemini install detects the user-scope GSD command + * directory already exists with managed-shape content, skip the local copy + * and emit a clear warning explaining the conflict avoidance. + * + * Tests assert on the post-install filesystem shape and capture the skip + * warning so the full test log remains warning-clean. + */ + +'use strict'; + +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 { createTempDir, cleanup, captureConsole } = require('./helpers.cjs'); + +const { install } = require('../bin/install.js'); + +describe('bug #3037: Gemini global+local install must not create duplicate command scopes', () => { + let tmpHome; + let tmpProject; + let originalHome; + let originalUserprofile; + let originalCwd; + + beforeEach(() => { + tmpHome = createTempDir('gsd-3037-home-'); + tmpProject = createTempDir('gsd-3037-work-'); + originalHome = process.env.HOME; + originalUserprofile = process.env.USERPROFILE; + originalCwd = process.cwd(); + // Point HOME at the temp dir so install(true, 'gemini') writes to + // tmpHome/.gemini, not the developer's real home. + process.env.HOME = tmpHome; + process.env.USERPROFILE = tmpHome; + }); + + afterEach(() => { + if (originalHome === undefined) delete process.env.HOME; + else process.env.HOME = originalHome; + // CR #3041: also restore USERPROFILE so the temp HOME doesn't leak + // into later tests and create order-dependent failures on Windows + // or any code path that reads USERPROFILE. + if (originalUserprofile === undefined) delete process.env.USERPROFILE; + else process.env.USERPROFILE = originalUserprofile; + process.chdir(originalCwd); + cleanup(tmpHome); + cleanup(tmpProject); + }); + + function listCommandFiles(geminiCommandsRoot) { + if (!fs.existsSync(geminiCommandsRoot)) return []; + const out = []; + 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()) out.push(path.relative(geminiCommandsRoot, full)); + } + } + walk(geminiCommandsRoot); + return out.sort(); + } + + function runInstall(...args) { + return captureConsole(() => install(...args)); + } + + test('global install populates HOME/.gemini/commands/gsd', () => { + runInstall(true, 'gemini'); + const globalCmds = path.join(tmpHome, '.gemini', 'commands', 'gsd'); + const files = listCommandFiles(globalCmds); + assert.ok( + files.length > 0, + 'global install must populate HOME/.gemini/commands/gsd' + ); + }); + + test('local install after global does NOT populate PROJECT/.gemini/commands/gsd (avoids /gsd:* namespace conflict)', () => { + // Step 1: global install + runInstall(true, 'gemini'); + const globalCmds = path.join(tmpHome, '.gemini', 'commands', 'gsd'); + const globalFiles = listCommandFiles(globalCmds); + assert.ok(globalFiles.length > 0, 'precondition: global install must succeed'); + + // Step 2: local install in a temp project + process.chdir(tmpProject); + const { stdout } = runInstall(false, 'gemini'); + assert.match( + stdout, + /Skipping commands\/gsd\/ for local install/, + 'local install must explain why it skips duplicate Gemini commands' + ); + + // Assertion: the local commands/gsd/ directory must NOT exist (or must + // be empty) so Gemini's conflict detection has nothing to rename. The + // fix may either skip the directory entirely (preferred — no leftover + // file system noise) or create an empty directory (acceptable but odd). + const localCmds = path.join(tmpProject, '.gemini', 'commands', 'gsd'); + const localFiles = listCommandFiles(localCmds); + assert.equal( + localFiles.length, + 0, + `local install must skip commands/gsd/ when global already exists; ` + + `found ${localFiles.length} duplicate command file(s) at ${localCmds}` + ); + }); + + test('local install with NO existing global GSD does still populate PROJECT/.gemini/commands/gsd', () => { + // No global install first — local should proceed normally so users who + // only ever run --local still get GSD commands in their project. + process.chdir(tmpProject); + runInstall(false, 'gemini'); + + const localCmds = path.join(tmpProject, '.gemini', 'commands', 'gsd'); + const localFiles = listCommandFiles(localCmds); + assert.ok( + localFiles.length > 0, + `local-only install must populate PROJECT/.gemini/commands/gsd; ` + + `found ${localFiles.length} files at ${localCmds}` + ); + }); + + test('local install when HOME has hand-dropped overrides UNDER commands/gsd/ (but no full GSD) still populates locally', () => { + // CR #3041 regression: the previous detection was + // `fs.readdirSync(homeGeminiGsd).length > 0` which would skip the + // local install for a user who manually dropped a single override + // command at ~/.gemini/commands/gsd/.toml without ever + // running --gemini --global. The fix narrows detection to require + // at least 3 canonical GSD command files (help.toml, progress.toml, + // new-project.toml) — a marker that's structurally impossible to + // produce by accident. + const homeGsdDir = path.join(tmpHome, '.gemini', 'commands', 'gsd'); + fs.mkdirSync(homeGsdDir, { recursive: true }); + fs.writeFileSync( + path.join(homeGsdDir, 'my-override.toml'), + 'description = "user override"\nprompt = "..."\n' + ); + + process.chdir(tmpProject); + runInstall(false, 'gemini'); + + const localCmds = path.join(tmpProject, '.gemini', 'commands', 'gsd'); + const localFiles = listCommandFiles(localCmds); + assert.ok( + localFiles.length > 0, + `local install must proceed when HOME/.gemini/commands/gsd contains ` + + `only user overrides (not the full GSD canary set); ` + + `found ${localFiles.length} files at ${localCmds}` + ); + }); + + test('local install when HOME/.gemini exists but commands/gsd is absent (non-GSD Gemini user) still populates locally', () => { + // Simulate a user who has Gemini configured but never installed GSD + // globally. ~/.gemini/ exists with unrelated content; ~/.gemini/commands/ + // may or may not exist with non-gsd subdirectories. Local install must + // still proceed because no GSD-managed user-scope directory is present. + fs.mkdirSync(path.join(tmpHome, '.gemini', 'commands', 'someone-else'), { + recursive: true, + }); + fs.writeFileSync( + path.join(tmpHome, '.gemini', 'commands', 'someone-else', 'foo.toml'), + 'description = "user command"\nprompt = "..."\n' + ); + + process.chdir(tmpProject); + runInstall(false, 'gemini'); + + const localCmds = path.join(tmpProject, '.gemini', 'commands', 'gsd'); + const localFiles = listCommandFiles(localCmds); + assert.ok( + localFiles.length > 0, + `local install must proceed when no GSD-managed user-scope directory ` + + `exists, even if other Gemini commands are present at the user scope` + ); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/enh-789-codebuddy-commands.test.cjs — consolidation epic #1969 (B1 #1970) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:enh-789-codebuddy-commands (consolidation epic #1969 B1 #1970)", () => { +// allow-test-rule: source-text-is-the-product (see #789) +// Workflow .md / command .md / SKILL.md files — their text IS what the runtime +// loads. Testing emitted text tests the deployed contract. +// Per CONTRIBUTING.md exception matrix. + +/** + * Regression guard — enh(#789): elevate CodeBuddy slash-command surface. + * + * CodeBuddy (Tencent, @tencent-ai/codebuddy-code) reads user-level surfaces + * (https://www.codebuddy.ai/docs/cli/slash-commands, /skills): + * - commands/gsd-.md — slash commands shown in the '/' menu + * - skills/gsd-/SKILL.md — model-invocable skills + * + * Before #789 gsd emitted only skills/. Because CodeBuddy skills default to + * user-invocable:true (appear in '/'), emitting a commands/ surface AND leaving + * skills user-invocable would duplicate every /gsd-* entry. #789 therefore: + * 1. emits commands/gsd-.md (the '/' surface, peer-consistent with + * Cursor #785 and Augment #790), + * 2. marks skills user-invocable:false so they become model-invocable + * background knowledge and the commands/ surface is the sole '/' surface. + * + * Subagents are already emitted via the generic agents block + convertClaude + * AgentToCodebuddyAgent (~/.codebuddy/agents/), so #789 adds no agents change. + * + * mcp.json is intentionally NOT written: gsd ships no MCP server, and CodeBuddy's + * mcp.json holds an `mcpServers` map of *external* servers to connect to — + * there is nothing for gsd to register. Same exclusion as #784/#785/#790. + */ +'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 { createTempDir, cleanup } = require('./helpers.cjs'); + +const { + convertClaudeCommandToCodebuddyCommand, + convertClaudeCommandToCodebuddySkill, +} = require('../bin/install.js'); + +const { + installRuntimeArtifacts, + uninstallRuntimeArtifacts, +} = require('../gsd-core/bin/lib/install-engine.cjs'); +const { resolveRuntimeArtifactLayout } = require('../gsd-core/bin/lib/runtime-artifact-layout.cjs'); +const { loadSkillsManifest, resolveProfile } = require('../gsd-core/bin/lib/install-profiles.cjs'); + +const REAL_COMMANDS_DIR = path.join(__dirname, '..', 'commands', 'gsd'); +const MANIFEST = loadSkillsManifest(REAL_COMMANDS_DIR); +const RESOLVED_CORE = resolveProfile({ modes: ['core'], manifest: MANIFEST }); + +// ─── Layout contract ───────────────────────────────────────────────────────── + +describe('enh-789 — codebuddy layout has commands + skills kinds', () => { + test('resolveRuntimeArtifactLayout codebuddy returns 3 kinds (ADR-1235 §1 agents cutover)', () => { + const layout = resolveRuntimeArtifactLayout('codebuddy', '/tmp/fake-codebuddy-dir'); + assert.strictEqual(layout.kinds.length, 3, 'codebuddy must have exactly 3 artifact kinds (commands + skills + agents)'); + const kindNames = layout.kinds.map(k => k.kind).sort(); + assert.deepStrictEqual(kindNames, ['agents', 'commands', 'skills']); + }); + + test('codebuddy commands kind targets commands/ with gsd- prefix', () => { + const layout = resolveRuntimeArtifactLayout('codebuddy', '/tmp/fake-codebuddy-dir'); + const commandsKind = layout.kinds.find(k => k.kind === 'commands'); + assert.ok(commandsKind, 'must have commands kind'); + assert.strictEqual(commandsKind.destSubpath, 'commands'); + assert.strictEqual(commandsKind.prefix, 'gsd-'); + assert.strictEqual(typeof commandsKind.stage, 'function'); + }); + + test('codebuddy skills kind targets skills/ with gsd- prefix', () => { + const layout = resolveRuntimeArtifactLayout('codebuddy', '/tmp/fake-codebuddy-dir'); + const skillsKind = layout.kinds.find(k => k.kind === 'skills'); + assert.ok(skillsKind, 'must have skills kind'); + assert.strictEqual(skillsKind.destSubpath, 'skills'); + assert.strictEqual(skillsKind.prefix, 'gsd-'); + }); +}); + +// ─── Command converter contract ────────────────────────────────────────────── + +describe('enh-789 — convertClaudeCommandToCodebuddyCommand', () => { + const SRC = [ + '---', + 'name: gsd:new-project', + 'description: Initialize a project', + 'argument-hint: "[name]"', + 'allowed-tools:', + ' - Read', + '---', + '', + 'Use .claude/skills/ and run /gsd:help. Claude Code reads CLAUDE.md.', + '', + ].join('\n'); + + test('emits a description-only frontmatter (no Claude-specific name: gsd:)', () => { + const out = convertClaudeCommandToCodebuddyCommand(SRC, 'gsd-new-project'); + assert.ok(out.startsWith('---\n'), 'must begin with frontmatter'); + assert.ok(/^description:/m.test(out), 'must carry a description field'); + assert.ok(!out.includes('name: gsd:new-project'), 'must drop Claude colon-form name field'); + }); + + test('preserves a present argument-hint (CodeBuddy supports it)', () => { + const out = convertClaudeCommandToCodebuddyCommand(SRC, 'gsd-new-project'); + assert.ok(/^argument-hint:\s*["']?\[name\]["']?\s*$/m.test(out), + `argument-hint must be carried through when present in source. Got:\n${out}`); + }); + + test('converts body Claude-isms to CodeBuddy equivalents', () => { + const out = convertClaudeCommandToCodebuddyCommand(SRC, 'gsd-new-project'); + assert.ok(out.includes('.codebuddy/skills/'), out); + assert.ok(out.includes('/gsd-help'), out); + assert.ok(out.includes('CODEBUDDY.md'), out); + assert.ok(!/\bClaude Code\b/.test(out), 'must rebrand "Claude Code"'); + }); +}); + +describe('enh-789 — skills marked user-invocable:false', () => { + test('convertClaudeCommandToCodebuddySkill emits user-invocable: false', () => { + const src = [ + '---', + 'name: gsd:help', + 'description: Show help', + '---', + '', + '# body', + '', + ].join('\n'); + const out = convertClaudeCommandToCodebuddySkill(src, 'gsd-help'); + assert.ok(/^user-invocable:\s*false\s*$/m.test(out), + `SKILL.md frontmatter must hide skill from '/' menu (user-invocable: false). Got:\n${out}`); + }); +}); + +// ─── Install contract ──────────────────────────────────────────────────────── + +describe('enh-789 — installRuntimeArtifacts codebuddy emits commands and skills', () => { + test('global codebuddy install: commands/gsd-help.md and skills/gsd-help/SKILL.md exist', (t) => { + const configDir = createTempDir('gsd-enh789-codebuddy-'); + t.after(() => cleanup(configDir)); + + installRuntimeArtifacts('codebuddy', configDir, 'global', RESOLVED_CORE); + + const commandsDir = path.join(configDir, 'commands'); + assert.ok(fs.existsSync(commandsDir), 'commands/ dir must exist'); + const cmdFiles = fs.readdirSync(commandsDir).filter(f => f.startsWith('gsd-') && f.endsWith('.md')); + assert.ok(cmdFiles.length > 0, 'at least one gsd-*.md command file must be installed'); + assert.ok(fs.existsSync(path.join(commandsDir, 'gsd-help.md')), 'commands/gsd-help.md must exist'); + + const skillsDir = path.join(configDir, 'skills'); + assert.ok(fs.existsSync(skillsDir), 'skills/ dir must exist'); + assert.ok(fs.existsSync(path.join(skillsDir, 'gsd-help', 'SKILL.md')), 'skills/gsd-help/SKILL.md must exist'); + }); + + test('installed commands/gsd-help.md is CodeBuddy-compatible (no raw ~/.claude/, rebranded)', (t) => { + const configDir = createTempDir('gsd-enh789-content-'); + t.after(() => cleanup(configDir)); + + installRuntimeArtifacts('codebuddy', configDir, 'global', RESOLVED_CORE); + + const helpCmd = path.join(configDir, 'commands', 'gsd-help.md'); + const content = fs.readFileSync(helpCmd, 'utf8'); + assert.ok(!content.includes('~/.claude/'), 'commands must not contain raw ~/.claude/ refs'); + assert.ok(content.startsWith('---'), 'commands must carry frontmatter'); + }); + + test('installed skills/gsd-help/SKILL.md is hidden from the / menu', (t) => { + const configDir = createTempDir('gsd-enh789-skillhide-'); + t.after(() => cleanup(configDir)); + + installRuntimeArtifacts('codebuddy', configDir, 'global', RESOLVED_CORE); + + const skill = fs.readFileSync(path.join(configDir, 'skills', 'gsd-help', 'SKILL.md'), 'utf8'); + assert.ok(/^user-invocable:\s*false\s*$/m.test(skill), + 'installed SKILL.md must set user-invocable: false'); + }); + + test('command count matches skill count (profile parity)', (t) => { + const configDir = createTempDir('gsd-enh789-parity-'); + t.after(() => cleanup(configDir)); + + installRuntimeArtifacts('codebuddy', configDir, 'global', RESOLVED_CORE); + + const cmdCount = fs.readdirSync(path.join(configDir, 'commands')) + .filter(f => f.startsWith('gsd-') && f.endsWith('.md')).length; + const skillCount = fs.readdirSync(path.join(configDir, 'skills'), { withFileTypes: true }) + .filter(e => e.isDirectory() && e.name.startsWith('gsd-')).length; + assert.strictEqual(cmdCount, skillCount, 'command count must equal skill count for same profile'); + }); + + test('full profile install: no $HOME/.codebuddy or ~/.codebuddy leak in any command', (t) => { + // The codebuddy converter rewrites `.claude/` → `.codebuddy/`, so source + // refs like `@$HOME/.claude/gsd-core/...` (e.g. plan-review-convergence.md) + // must be normalized to the install target — not left as $HOME/.codebuddy. + const RESOLVED_FULL = resolveProfile({ modes: ['full'], manifest: MANIFEST }); + const configDir = createTempDir('gsd-enh789-noleak-'); + t.after(() => cleanup(configDir)); + + installRuntimeArtifacts('codebuddy', configDir, 'global', RESOLVED_FULL); + + const commandsDir = path.join(configDir, 'commands'); + for (const f of fs.readdirSync(commandsDir).filter(n => n.endsWith('.md'))) { + const content = fs.readFileSync(path.join(commandsDir, f), 'utf8'); + assert.ok(!content.includes('$HOME/.codebuddy'), `${f} must not leak $HOME/.codebuddy`); + assert.ok(!content.includes('~/.codebuddy'), `${f} must not leak ~/.codebuddy`); + assert.ok(!content.includes('.claude/'), `${f} must not retain raw .claude/ refs`); + } + }); + + test('full profile install does NOT mutate source commands/gsd/ files', (t) => { + const RESOLVED_FULL = resolveProfile({ modes: ['full'], manifest: MANIFEST }); + assert.strictEqual(RESOLVED_FULL.skills, '*', 'full profile must have skills === "*"'); + + const configDir = createTempDir('gsd-enh789-full-'); + t.after(() => cleanup(configDir)); + + const srcHelpPath = path.join(REAL_COMMANDS_DIR, 'help.md'); + const before = fs.readFileSync(srcHelpPath, 'utf8'); + + installRuntimeArtifacts('codebuddy', configDir, 'global', RESOLVED_FULL); + + const after = fs.readFileSync(srcHelpPath, 'utf8'); + assert.strictEqual(before, after, 'source commands/gsd/help.md must not be mutated by the install'); + }); +}); + +// ─── Uninstall contract ────────────────────────────────────────────────────── + +describe('enh-789 — uninstallRuntimeArtifacts removes codebuddy commands', () => { + test('uninstall removes gsd-* commands but preserves user commands', (t) => { + const configDir = createTempDir('gsd-enh789-uninstall-'); + t.after(() => cleanup(configDir)); + + const commandsDir = path.join(configDir, 'commands'); + fs.mkdirSync(commandsDir, { recursive: true }); + fs.writeFileSync(path.join(commandsDir, 'gsd-help.md'), '# help\n'); + fs.writeFileSync(path.join(commandsDir, 'user-custom.md'), '# user\n'); + + uninstallRuntimeArtifacts('codebuddy', configDir, 'global'); + + assert.ok(!fs.existsSync(path.join(commandsDir, 'gsd-help.md')), 'gsd-help.md must be removed'); + assert.ok(fs.existsSync(path.join(commandsDir, 'user-custom.md')), 'user-custom.md must be preserved'); + }); +}); + +// ─── mcp.json exclusion ────────────────────────────────────────────────────── + +describe('enh-789 — mcp.json excluded (gsd ships no MCP server)', () => { + test('codebuddy install does not write mcp.json / .mcp.json', (t) => { + const configDir = createTempDir('gsd-enh789-mcp-excluded-'); + t.after(() => cleanup(configDir)); + + installRuntimeArtifacts('codebuddy', configDir, 'global', RESOLVED_CORE); + + assert.ok(!fs.existsSync(path.join(configDir, 'mcp.json')), 'must not write mcp.json'); + assert.ok(!fs.existsSync(path.join(configDir, '.mcp.json')), 'must not write .mcp.json'); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-2794-opencode-model-profile-overrides.test.cjs — consolidation epic #1969 (B1 #1970) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-2794-opencode-model-profile-overrides (consolidation epic #1969 B1 #1970)", () => { +/** + * Regression test for bug #2794 + * + * OpenCode generated agents ignored `model_profile_overrides.opencode.*`. + * The agent install path called `readGsdEffectiveModelOverrides` (explicit + * per-agent overrides) but never called `readGsdRuntimeProfileResolver` + * (tier-based profile overrides). When a user configured: + * + * { runtime: "opencode", model_profile_overrides: { opencode: { sonnet: "..." } } } + * + * generated `.opencode/agents/gsd-*.md` files contained no `model:` frontmatter. + * + * The fix adds a tier-resolver fallback in the OpenCode agent conversion block: + * explicit `model_overrides[agent]` > `model_profile_overrides.opencode.` > omit. + * + * This test exercises: + * 1. `readGsdRuntimeProfileResolver` correctly resolves OpenCode tier overrides. + * 2. The agent install code path embeds the resolved model into OpenCode frontmatter. + * 3. Explicit `model_overrides` still wins over tier-based resolution. + * 4. Missing overrides produce no `model:` field (no regression on omit behavior). + */ + +process.env.GSD_TEST_MODE = '1'; + +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 { + readGsdRuntimeProfileResolver, + install, +} = require('../bin/install.js'); + +const { createTempDir, cleanup } = require('./helpers.cjs'); +const makeTmp = (prefix) => createTempDir(`gsd-2794-${prefix}-`); + +function writeJson(p, obj) { + fs.mkdirSync(path.dirname(p), { recursive: true }); + fs.writeFileSync(p, JSON.stringify(obj, null, 2), 'utf-8'); +} + + +describe('bug-2794: readGsdRuntimeProfileResolver resolves opencode tier overrides', () => { + let projectDir; + let homeDir; + let origHome; + let origUP; + + beforeEach(() => { + projectDir = makeTmp('proj'); + homeDir = makeTmp('home'); + origHome = process.env.HOME; + origUP = process.env.USERPROFILE; + process.env.HOME = homeDir; + process.env.USERPROFILE = homeDir; + }); + + afterEach(() => { + if (origHome === undefined) delete process.env.HOME; + else process.env.HOME = origHome; + if (origUP === undefined) delete process.env.USERPROFILE; + else process.env.USERPROFILE = origUP; + cleanup(projectDir); + cleanup(homeDir); + }); + + test('resolves opencode sonnet tier to user-supplied model ID', () => { + writeJson(path.join(projectDir, '.planning', 'config.json'), { + runtime: 'opencode', + model_profile: 'balanced', + model_profile_overrides: { + opencode: { + sonnet: 'anthropic/claude-sonnet-4-7', + }, + }, + }); + + const resolver = readGsdRuntimeProfileResolver(projectDir); + assert.ok(resolver !== null, 'expected a resolver for opencode runtime'); + + // gsd-roadmapper balanced tier = sonnet — should resolve to override + const entry = resolver.resolve('gsd-roadmapper'); + assert.ok(entry !== null, 'expected entry for gsd-roadmapper'); + assert.strictEqual(entry.model, 'anthropic/claude-sonnet-4-7', 'sonnet override applied'); + }); + + test('returns null resolver when runtime is not set', () => { + writeJson(path.join(projectDir, '.planning', 'config.json'), { + model_profile: 'balanced', + model_profile_overrides: { opencode: { sonnet: 'x' } }, + }); + const resolver = readGsdRuntimeProfileResolver(projectDir); + assert.strictEqual(resolver, null, 'no resolver without runtime field'); + }); + + test('resolver returns null for agent not in MODEL_PROFILES', () => { + writeJson(path.join(projectDir, '.planning', 'config.json'), { + runtime: 'opencode', + model_profile: 'balanced', + model_profile_overrides: { opencode: { sonnet: 'x' } }, + }); + const resolver = readGsdRuntimeProfileResolver(projectDir); + assert.ok(resolver !== null); + const entry = resolver.resolve('gsd-nonexistent-agent'); + assert.strictEqual(entry, null, 'unknown agent name yields null'); + }); +}); + +describe('bug-2794: OpenCode agent install embeds model_profile_overrides model', () => { + let projectDir; + let homeDir; + let origHome; + let origUP; + let origCwd; + + beforeEach(() => { + projectDir = makeTmp('proj'); + homeDir = makeTmp('home'); + origHome = process.env.HOME; + origUP = process.env.USERPROFILE; + origCwd = process.cwd(); + process.env.HOME = homeDir; + process.env.USERPROFILE = homeDir; + process.chdir(projectDir); + }); + + afterEach(() => { + if (origHome === undefined) delete process.env.HOME; + else process.env.HOME = origHome; + if (origUP === undefined) delete process.env.USERPROFILE; + else process.env.USERPROFILE = origUP; + process.chdir(origCwd); + cleanup(projectDir); + cleanup(homeDir); + }); + + test('generated OpenCode agent frontmatter includes model from model_profile_overrides', () => { + writeJson(path.join(projectDir, '.planning', 'config.json'), { + runtime: 'opencode', + model_profile: 'balanced', + model_profile_overrides: { + opencode: { + sonnet: 'anthropic/claude-sonnet-4-7', + opus: 'anthropic/claude-opus-4-7', + haiku: 'anthropic/claude-haiku-4-5', + }, + }, + }); + + const oldLog = console.log; + console.log = () => {}; + try { + install(false, 'opencode'); + } finally { + console.log = oldLog; + } + + const agentsDir = path.join(projectDir, '.opencode', 'agents'); + assert.ok(fs.existsSync(agentsDir), 'agents directory should be created'); + + // gsd-roadmapper is balanced -> sonnet tier + const roadmapperPath = path.join(agentsDir, 'gsd-roadmapper.md'); + assert.ok(fs.existsSync(roadmapperPath), 'gsd-roadmapper.md should exist'); + const roadmapperContent = fs.readFileSync(roadmapperPath, 'utf-8'); + assert.match( + roadmapperContent, + /^model: anthropic\/claude-sonnet-4-7$/m, + 'gsd-roadmapper should have sonnet model from model_profile_overrides' + ); + + // gsd-planner is balanced -> opus tier + const plannerPath = path.join(agentsDir, 'gsd-planner.md'); + assert.ok(fs.existsSync(plannerPath), 'gsd-planner.md should exist'); + const plannerContent = fs.readFileSync(plannerPath, 'utf-8'); + assert.match( + plannerContent, + /^model: anthropic\/claude-opus-4-7$/m, + 'gsd-planner should have opus model from model_profile_overrides' + ); + }); + + test('explicit model_overrides[agent] wins over model_profile_overrides tier', () => { + writeJson(path.join(projectDir, '.planning', 'config.json'), { + runtime: 'opencode', + model_profile: 'balanced', + model_overrides: { + 'gsd-roadmapper': 'explicit-winner-model', + }, + model_profile_overrides: { + opencode: { + sonnet: 'tier-model-that-should-lose', + }, + }, + }); + + const oldLog = console.log; + console.log = () => {}; + try { + install(false, 'opencode'); + } finally { + console.log = oldLog; + } + + const roadmapperPath = path.join(projectDir, '.opencode', 'agents', 'gsd-roadmapper.md'); + assert.ok(fs.existsSync(roadmapperPath)); + const content = fs.readFileSync(roadmapperPath, 'utf-8'); + assert.match( + content, + /^model: explicit-winner-model$/m, + 'explicit model_overrides must win over model_profile_overrides tier' + ); + assert.doesNotMatch( + content, + /tier-model-that-should-lose/, + 'tier model must not appear when explicit override is present' + ); + }); + + test('no model field when neither model_overrides nor model_profile_overrides is set', () => { + writeJson(path.join(projectDir, '.planning', 'config.json'), { + runtime: 'opencode', + model_profile: 'balanced', + }); + + const oldLog = console.log; + console.log = () => {}; + try { + install(false, 'opencode'); + } finally { + console.log = oldLog; + } + + const roadmapperPath = path.join(projectDir, '.opencode', 'agents', 'gsd-roadmapper.md'); + if (fs.existsSync(roadmapperPath)) { + const content = fs.readFileSync(roadmapperPath, 'utf-8'); + // When no overrides, model field should either be absent or use built-in default + // The key invariant: no model field if there are no user-supplied overrides + // AND no built-in opencode defaults for this tier + // (gsd-roadmapper balanced = sonnet; opencode has built-in sonnet defaults) + // So we only assert no crash and no tier-model-not-provided entries + assert.ok(typeof content === 'string', 'agent file should be a string'); + } + // Key: no exception thrown (test passes = no crash on missing overrides) + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-2643-skill-frontmatter-name.test.cjs — consolidation epic #1969 (B1 #1970) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-2643-skill-frontmatter-name (consolidation epic #1969 B1 #1970)", () => { +'use strict'; + +process.env.GSD_TEST_MODE = '1'; + +/** + * Bug #2643 / #2808: skill frontmatter name parity. + * + * Original (#2643): workflows emitted Skill(skill="gsd:") and the + * installer registered colon form in SKILL.md name: to match. + * + * Updated (#2808): workflows now use Skill(skill="gsd-") (hyphen), + * and the installer emits name: gsd- (hyphen). Claude Code autocomplete + * now shows the canonical hyphen form instead of the deprecated colon form. + * The directory name (gsd-) is unchanged. + */ + +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 { + convertClaudeCommandToClaudeSkill, + skillFrontmatterName, +} = require(path.join(ROOT, 'bin', 'install.js')); + +const WORKFLOWS_DIR = path.join(ROOT, 'gsd-core', 'workflows'); +const COMMANDS_DIR = path.join(ROOT, 'commands', 'gsd'); + +function collectFiles(dir, results) { + if (!results) 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()) collectFiles(full, results); + else if (e.name.endsWith('.md')) results.push(full); + } + return results; +} + +/** + * Extract every `Skill(skill="")` invocation as a structured record. + * + * Per project test rigor (`feedback_no_source_grep_tests.md`), this parses + * each call as a unit instead of leaning on a single regex over raw bytes. + * The flow is: + * + * 1. Strip HTML comments so commented-out examples don't count as drift. + * 2. Walk the content for `Skill(` openers; for each, find the matching + * `)` closer (Skill bodies are simple kwarg lists, no nesting). + * 3. Parse the call body for the `skill = "..."` keyword argument. + * Permissive whitespace around the keyword and `=`, permissive + * single/double quoting (with optional `\` escapes from string- + * embedded examples), permissive name body — so malformed drift like + * `Skill(skill="gsd:extract_learnings")` is surfaced rather than + * silently skipped by an over-strict character class. + * + * Returns `[{ name, raw }]` per call. Filtering by namespace (gsd- vs gsd:) + * happens at the call site so the extractor stays neutral. + */ +function extractSkillCalls(content) { + // regex-free HTML-comment stripper (CodeQL: avoid incomplete-multi-character-sanitization) + let stripped = ''; + { + let rest = content; + let idx; + while ((idx = rest.indexOf('', idx + 4); + if (end === -1) { rest = ''; break; } + rest = rest.slice(end + 3); + } + stripped += rest; + } + const calls = []; + // Body class excludes backslash so the extractor doesn't include an + // escape character that precedes the closing quote in embedded examples + // (e.g. `Skill(skill=\"gsd-plan-phase\", …)` written inside a string + // context). A trailing `\` is permitted on the closing-quote side via the + // optional `\\?` so both `\"` and `"` close the value cleanly. + const argRe = /^\s*skill\s*=\s*\\?(['"])([^'"\\]+)\\?\1/i; + let i = 0; + while (i < stripped.length) { + const open = stripped.indexOf('Skill(', i); + if (open === -1) break; + const close = stripped.indexOf(')', open); + if (close === -1) break; + const body = stripped.slice(open + 'Skill('.length, close); + const match = body.match(argRe); + if (match) calls.push({ name: match[2], raw: stripped.slice(open, close + 1) }); + i = close + 1; + } + return calls; +} + +function extractSkillNamesHyphen(content) { + return new Set( + extractSkillCalls(content) + .map((c) => c.name) + .filter((n) => n.startsWith('gsd-')), + ); +} + +function extractSkillNamesColon(content) { + return new Set( + extractSkillCalls(content) + .map((c) => c.name) + .filter((n) => n.startsWith('gsd:')), + ); +} + +describe('skill frontmatter name parity (#2643 / #2808)', () => { + test('skillFrontmatterName helper emits hyphen form (#2808)', () => { + assert.strictEqual(typeof skillFrontmatterName, 'function'); + assert.strictEqual(skillFrontmatterName('gsd-execute-phase'), 'gsd-execute-phase'); + assert.strictEqual(skillFrontmatterName('gsd-plan-phase'), 'gsd-plan-phase'); + assert.strictEqual(skillFrontmatterName('gsd-next'), 'gsd-next'); + }); + + test('convertClaudeCommandToClaudeSkill emits name: gsd- (hyphen)', () => { + const input = '---\nname: old\ndescription: test\n---\n\nBody.'; + const result = convertClaudeCommandToClaudeSkill(input, 'gsd-execute-phase'); + // Parse the frontmatter block structurally: extract the name: field value. + const frontmatterMatch = result.match(/^---\r?\n([\s\S]*?)\r?\n---/); + assert.ok(frontmatterMatch, 'output must have a frontmatter block delimited by ---'); + const frontmatterLines = frontmatterMatch[1].split(/\r?\n/); + const nameEntry = frontmatterLines.find((l) => l.startsWith('name:')); + assert.ok(nameEntry, 'frontmatter must contain a name: field'); + const nameValue = nameEntry.replace(/^name:\s*/, '').trim(); + assert.strictEqual( + nameValue, + 'gsd-execute-phase', + `frontmatter name: must be 'gsd-execute-phase' (hyphen form), got '${nameValue}'` + ); + }); + + test('no workflow uses deprecated Skill(skill="gsd:") colon form', () => { + const workflowFiles = collectFiles(WORKFLOWS_DIR); + const colonRefs = []; + for (const f of workflowFiles) { + const src = fs.readFileSync(f, 'utf-8'); + for (const n of extractSkillNamesColon(src)) { + colonRefs.push(path.basename(f) + ': ' + n); + } + } + assert.deepStrictEqual( + colonRefs, + [], + 'deprecated colon-form Skill() calls found (update to hyphen): ' + colonRefs.join(', ') + ); + }); + + test('every workflow Skill(skill="gsd-") resolves to an emitted skill name', () => { + const workflowFiles = collectFiles(WORKFLOWS_DIR); + const referenced = new Set(); + const templatedSkipped = []; + for (const f of workflowFiles) { + const src = fs.readFileSync(f, 'utf-8'); + for (const n of extractSkillNamesHyphen(src)) { + // Skip template expressions (e.g. `gsd-${ref.skill}`): these are + // capability-dispatched — the skill stem is resolved at runtime from + // the `loop render-hooks` registry output (ADR-857 phase 6), so there + // is no single literal skill file to validate against here. + // The capability registry's own validateStep gate (gen-capability-registry.cjs) + // is responsible for ensuring each `steps[].ref.skill` corresponds to a + // real skill declared in the capability's `skills` array. + if (n.includes('${')) { + templatedSkipped.push(path.basename(f) + ': ' + n); + } else { + referenced.add(n); + } + } + } + assert.ok( + referenced.size > 0, + `expected at least one literal Skill(skill="gsd-") reference in workflows under ${WORKFLOWS_DIR}` + ); + + const emitted = new Set(); + const cmdFiles = fs.readdirSync(COMMANDS_DIR).filter(f => f.endsWith('.md')); + 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 out = convertClaudeCommandToClaudeSkill(src, skillDirName); + const m = out.match(/^---\r?\nname:\s*(.+)$/m); + if (m) emitted.add(m[1].trim()); + } + + const missing = []; + for (const r of referenced) if (!emitted.has(r)) missing.push(r); + assert.deepStrictEqual( + missing, + [], + 'workflow refs not emitted as skill names: ' + missing.join(', '), + ); + // Informational: report how many templated dispatches were intentionally skipped. + // (Templated names are validated by the capability registry, not statically here.) + if (templatedSkipped.length > 0) { + // Not a failure — just a note for test output transparency. + // Use a diagnostic comment: node:test does not have a skip-within-test API. + } + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/enh-778-cross-runtime-command-enrichment.test.cjs — consolidation epic #1969 (B1 #1970) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:enh-778-cross-runtime-command-enrichment (consolidation epic #1969 B1 #1970)", () => { +// allow-test-rule: source-text-is-the-product (see #778) +// Reads .md/SKILL.md/.toml product files whose deployed text IS what the +// runtime loads — testing text content tests the deployed contract. + +/** + * GSD Tools Tests — #778 cross-runtime command enrichment. + * + * Two independently-verified, additive sub-features: + * (b) Qwen Code skills: numeric `priority` field (higher sorts earlier in the + * /skills TUI listing per the Qwen skills spec). Scoped to runtime='qwen'. + * (c) Gemini custom-command TOML: $ARGUMENTS → {{args}} interpolation, and a + * fixed `!{cat .planning/STATE.md}` live-state injection on the + * situational `progress` command (injection-safe — no interpolated input). + * + * The OpenCode sub-feature (per-command model/agent/subtask/variant) is + * intentionally NOT implemented — see PR description: `model` reintroduces the + * #1156 ProviderModelNotFoundError regression for non-Anthropic OpenCode users, + * `subtask`/`agent` change execution semantics for GSD's interactive commands, + * and `variant` is not in the OpenCode command schema. + * + * Uses node:test and node:assert (NOT Jest). + */ + +process.env.GSD_TEST_MODE = '1'; + +const { test, describe, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); +const { createTempDir, cleanup } = require('./helpers.cjs'); + +const { + convertClaudeCommandToClaudeSkill, + convertClaudeToGeminiMarkdown, + install, +} = require('../bin/install.js'); + +// ─── (b) Qwen Code: priority ordering ─────────────────────────────────────── + +describe('#778 (b) Qwen skills priority', () => { + const mk = (name, desc, body) => + ['---', `name: gsd:${name}`, `description: ${desc}`, '---', '', body].join('\n'); + + test('emits numeric priority for a core-loop command (runtime=qwen)', () => { + const result = convertClaudeCommandToClaudeSkill( + mk('plan-phase', 'Plan a phase', 'Body.'), + 'gsd-plan-phase', + 'qwen', + [] + ); + const m = result.match(/^priority:\s*(\d+)\s*$/m); + assert.ok(m, 'priority field present for gsd-plan-phase'); + assert.equal(Number(m[1]) > 0, true, 'priority is a positive number'); + }); + + test('core loop ranks higher than mid-tier (higher = earlier per spec)', () => { + const np = convertClaudeCommandToClaudeSkill( + mk('new-project', 'Start a project', 'Body.'), 'gsd-new-project', 'qwen', [] + ).match(/^priority:\s*(\d+)/m); + const help = convertClaudeCommandToClaudeSkill( + mk('help', 'Help', 'Body.'), 'gsd-help', 'qwen', [] + ).match(/^priority:\s*(\d+)/m); + assert.ok(np && help, 'both core and mid-tier get a priority'); + assert.ok( + Number(np[1]) > Number(help[1]), + 'new-project (core) sorts earlier than help (utility) — higher value' + ); + }); + + test('utility command NOT in the priority map gets no priority field', () => { + const result = convertClaudeCommandToClaudeSkill( + mk('stats', 'Show stats', 'Body.'), 'gsd-stats', 'qwen', [] + ); + assert.ok(!/^priority:/m.test(result), 'no priority emitted for unmapped utility'); + }); + + test('does NOT emit priority for non-qwen runtimes (scoped to qwen)', () => { + for (const rt of [null, 'claude', 'hermes']) { + const result = convertClaudeCommandToClaudeSkill( + mk('plan-phase', 'Plan a phase', 'Body.'), 'gsd-plan-phase', rt, [] + ); + assert.ok(!/^priority:/m.test(result), `no priority for runtime=${rt}`); + } + }); +}); + +// ─── (c) Gemini: {{args}} interpolation ───────────────────────────────────── + +describe('#778 (c) Gemini {{args}} interpolation', () => { + const cmd = (body) => + ['---', 'name: gsd:demo', 'description: Demo', '---', '', body].join('\n'); + + test('maps $ARGUMENTS to {{args}} in the TOML prompt', () => { + const out = convertClaudeToGeminiMarkdown( + cmd('Operate on $ARGUMENTS now.'), + { isCommand: true, commandName: 'demo' } + ); + assert.ok(out.includes('{{args}}'), '{{args}} present'); + assert.ok(!out.includes('$ARGUMENTS'), 'literal $ARGUMENTS removed'); + assert.ok(out.startsWith('description =') || out.includes('prompt ='), 'TOML shape'); + }); + + test('command without $ARGUMENTS gets no injected {{args}}', () => { + const out = convertClaudeToGeminiMarkdown( + cmd('No arguments referenced here.'), + { isCommand: true, commandName: 'demo' } + ); + assert.ok(!out.includes('{{args}}'), 'no spurious {{args}}'); + }); + + test('non-command Gemini content is not TOML-converted and keeps $ARGUMENTS', () => { + const out = convertClaudeToGeminiMarkdown( + cmd('Reference $ARGUMENTS.'), + { isCommand: false } + ); + // isCommand:false keeps markdown — no TOML wrap and no {{args}} mapping + // (the $ARGUMENTS→{{args}} translation is scoped to the TOML command path). + assert.ok(!out.startsWith('prompt ='), 'not wrapped as TOML prompt'); + assert.ok(out.includes('$ARGUMENTS'), '$ARGUMENTS left intact for non-command content'); + assert.ok(!out.includes('{{args}}'), 'no {{args}} injected outside the command path'); + }); +}); + +// ─── (c) Gemini: end-to-end install wiring ────────────────────────────────── +// Proves the install path derives the per-command name from the file stem so a +// regression in the call-site wiring (not just the converter) is caught. + +describe('#778 (c) Gemini install wiring (end-to-end)', () => { + let tmpDir; + let tmpHome; + let prevCwd; + let prevHome; + let prevUserprofile; + + beforeEach(() => { + tmpDir = createTempDir('gsd-enh778-gem-'); + tmpHome = createTempDir('gsd-enh778-home-'); + prevCwd = process.cwd(); + prevHome = process.env.HOME; + prevUserprofile = process.env.USERPROFILE; + process.chdir(tmpDir); + // Isolate HOME so a real ~/.gemini/commands/gsd/ doesn't trigger the #3037 + // local-install conflict-skip path. + process.env.HOME = tmpHome; + process.env.USERPROFILE = tmpHome; + }); + + afterEach(() => { + process.chdir(prevCwd); + if (prevHome === undefined) delete process.env.HOME; else process.env.HOME = prevHome; + if (prevUserprofile === undefined) delete process.env.USERPROFILE; + else process.env.USERPROFILE = prevUserprofile; + cleanup(tmpDir); + cleanup(tmpHome); + }); + + test('installed progress.toml carries the !{} block; arg-bearing commands get {{args}}', () => { + const oldLog = console.log; + console.log = () => {}; + try { + install(false, 'gemini'); + } finally { + console.log = oldLog; + } + + const commandsDir = path.join(tmpDir, '.gemini', 'commands', 'gsd'); + const progressToml = path.join(commandsDir, 'progress.toml'); + assert.ok(fs.existsSync(progressToml), 'progress.toml installed'); + const progress = fs.readFileSync(progressToml, 'utf8'); + // Proves commandName was derived as 'progress' from the file stem. + assert.ok( + progress.includes('!{cat .planning/STATE.md 2>/dev/null}'), + 'progress.toml has the live-state shell block' + ); + + // A non-situational command must NOT receive the shell block. + const helpToml = path.join(commandsDir, 'help.toml'); + if (fs.existsSync(helpToml)) { + assert.ok(!fs.readFileSync(helpToml, 'utf8').includes('!{'), 'help.toml has no shell block'); + } + + // At least one installed command must use {{args}} and none may retain a + // literal $ARGUMENTS (every command body's $ARGUMENTS is translated). + const tomls = fs.readdirSync(commandsDir).filter((f) => f.endsWith('.toml')); + const withArgs = tomls.filter((f) => + fs.readFileSync(path.join(commandsDir, f), 'utf8').includes('{{args}}')); + const withLiteral = tomls.filter((f) => + fs.readFileSync(path.join(commandsDir, f), 'utf8').includes('$ARGUMENTS')); + assert.ok(withArgs.length > 0, 'at least one installed command interpolates {{args}}'); + assert.equal(withLiteral.length, 0, 'no installed command retains literal $ARGUMENTS'); + }); +}); + +// ─── (c) Gemini: !{...} live-state injection (progress) ───────────────────── + +describe('#778 (c) Gemini !{} live-state injection', () => { + const cmd = (name) => + ['---', `name: gsd:${name}`, `description: ${name}`, '---', '', 'Workflow body.'].join('\n'); + + test('progress command injects a fixed !{cat .planning/STATE.md} block', () => { + const out = convertClaudeToGeminiMarkdown( + cmd('progress'), + { isCommand: true, commandName: 'progress' } + ); + assert.ok(out.includes('!{cat .planning/STATE.md'), 'STATE.md injection present'); + }); + + test('non-progress commands get no !{} shell block', () => { + const out = convertClaudeToGeminiMarkdown( + cmd('help'), + { isCommand: true, commandName: 'help' } + ); + assert.ok(!out.includes('!{'), 'no shell block for non-situational command'); + }); + + test('SECURITY: the !{} block interpolates NO user input ({{args}})', () => { + const out = convertClaudeToGeminiMarkdown( + ['---', 'name: gsd:progress', 'description: progress', '---', '', + 'Body uses $ARGUMENTS too.'].join('\n'), + { isCommand: true, commandName: 'progress' } + ); + const blocks = out.match(/!\{([^}]*)\}/g) || []; + assert.equal(blocks.length, 1, 'exactly one shell block'); + assert.ok(!/\{\{args\}\}/.test(blocks[0]), 'no {{args}} inside the shell block'); + assert.ok(/^!\{cat \.planning\/STATE\.md/.test(blocks[0]), 'fixed cat command only'); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/enh-769-context-fork-effort.install.test.cjs — consolidation epic #1969 (B1 #1970) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:enh-769-context-fork-effort.install (consolidation epic #1969 B1 #1970)", () => { +// allow-test-rule: integration-test-input (see #769) +// Exercises install() as a black-box by inspecting produced SKILL.md output +// in a temp dir. Source command .md files are inputs whose installed +// transformation is asserted — not inspected for string presence. + +/** + * #769 — effort: frontmatter on heavy workflow skills. + * #921 — spawning orchestrators must NOT carry context: fork. + * + * Context: context:fork was added by #769 to protect context budget, but + * plan-phase, execute-phase, and autonomous are spawning orchestrators — a + * forked subagent has no Agent/Task tool, breaking their core function. + * effort: max is preserved; context: fork is removed from these three. + * The converter still passes context: fork through if a source file has it + * (for any future leaf skill that legitimately needs isolation). + * + * Verifies: + * 1. Source commands/gsd/autonomous.md does NOT have context: fork, has effort: max + * 2. Source commands/gsd/execute-phase.md does NOT have context: fork, has effort: max + * 3. Source commands/gsd/plan-phase.md does NOT have context: fork, has effort: max + * 4. Source commands/gsd/progress.md has effort: low + * 5. Source commands/gsd/stats.md has effort: low + * 6. Claude global install: SKILL.md for autonomous has effort: max, NOT context: fork + * 7. Claude global install: SKILL.md for execute-phase has effort: max, NOT context: fork + * 8. Claude global install: SKILL.md for plan-phase has effort: max, NOT context: fork + * 9. Claude global install: SKILL.md for progress has effort: low + * 10. Claude global install: SKILL.md for stats has effort: low + * 11. convertClaudeCommandToClaudeSkill still passes context: fork through (for non-orchestrator skills) + * 12. convertClaudeCommandToClaudeSkill emits portable effort: field values + */ + +'use strict'; + +process.env.GSD_TEST_MODE = '1'; + +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 os = require('node:os'); + +const { install, convertClaudeCommandToClaudeSkill } = require('../bin/install.js'); +const { cleanup } = require('./helpers.cjs'); + +// #924: Claude global install is now FLAT — concrete skills are at the top level. +// flatSkillPath returns: /gsd-/SKILL.md +function flatSkillPath(skillsRoot, stem) { + return path.join(skillsRoot, `gsd-${stem}`, 'SKILL.md'); +} + +const REPO_ROOT = path.resolve(__dirname, '..'); +const SOURCE_COMMANDS_DIR = path.join(REPO_ROOT, 'commands', 'gsd'); + +// ─── helpers ────────────────────────────────────────────────────────────────── + +function makeTmpDir(prefix) { + return fs.mkdtempSync(path.join(os.tmpdir(), prefix)); +} + +function readFrontmatter(mdPath) { + const content = fs.readFileSync(mdPath, 'utf8'); + if (!content.startsWith('---')) return ''; + const end = content.indexOf('---', 3); + if (end === -1) return ''; + return content.substring(3, end); +} + +/** + * Run a global install for Claude, redirecting its home dir to tmpHome. + * Returns the tmpHome for inspection. + */ +function runClaudeGlobalInstall(claudeHome) { + const isolatedHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-769-home-')); + + const prevCwd = process.cwd(); + const prevClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR; + const prevHome = process.env.HOME; + const prevUserProfile = process.env.USERPROFILE; + const prevSkipStale = process.env.GSD_SKIP_STALE_SDK_CHECK; + + process.env.CLAUDE_CONFIG_DIR = claudeHome; + process.env.HOME = isolatedHome; + process.env.USERPROFILE = isolatedHome; + process.env.GSD_SKIP_STALE_SDK_CHECK = '1'; + process.chdir(REPO_ROOT); + + try { + install(true, 'claude'); + } finally { + process.chdir(prevCwd); + if (prevClaudeConfigDir === undefined) delete process.env.CLAUDE_CONFIG_DIR; + else process.env.CLAUDE_CONFIG_DIR = prevClaudeConfigDir; + if (prevHome === undefined) delete process.env.HOME; + else process.env.HOME = prevHome; + if (prevUserProfile === undefined) delete process.env.USERPROFILE; + else process.env.USERPROFILE = prevUserProfile; + if (prevSkipStale === undefined) delete process.env.GSD_SKIP_STALE_SDK_CHECK; + else process.env.GSD_SKIP_STALE_SDK_CHECK = prevSkipStale; + cleanup(isolatedHome); + } + + return claudeHome; +} + +// ─── describe 1: Source command files have correct frontmatter ──────────────── + +// #921/#922: spawning orchestrators must NOT carry context: fork — a forked +// subagent has no Agent/Task tool, making it impossible for orchestrators to +// spawn their required subagents. context: fork is appropriate only for leaf +// skills that do not themselves dispatch agents. effort: max is portable across Claude Code models. +describe('#769/#921/#1319 source commands: spawning orchestrators have effort: max but NOT context: fork', () => { + test('commands/gsd/autonomous.md does NOT have context: fork (#921)', () => { + const fm = readFrontmatter(path.join(SOURCE_COMMANDS_DIR, 'autonomous.md')); + assert.doesNotMatch(fm, /^context:[ \t]*fork$/m, + `autonomous.md is a spawning orchestrator and must NOT have context: fork (#921)\nActual:\n${fm}`); + }); + + test('commands/gsd/autonomous.md has effort: max (#1319)', () => { + const fm = readFrontmatter(path.join(SOURCE_COMMANDS_DIR, 'autonomous.md')); + assert.match(fm, /^effort:[ \t]*max$/m, + `autonomous.md frontmatter must have effort: max\nActual:\n${fm}`); + assert.doesNotMatch(fm, /^effort:[ \t]*xhigh$/m, + `autonomous.md frontmatter must not have rejected effort: xhigh (#1319)\nActual:\n${fm}`); + }); + + test('commands/gsd/execute-phase.md does NOT have context: fork (#921)', () => { + const fm = readFrontmatter(path.join(SOURCE_COMMANDS_DIR, 'execute-phase.md')); + assert.doesNotMatch(fm, /^context:[ \t]*fork$/m, + `execute-phase.md is a spawning orchestrator and must NOT have context: fork (#921)\nActual:\n${fm}`); + }); + + test('commands/gsd/execute-phase.md has effort: max (#1319)', () => { + const fm = readFrontmatter(path.join(SOURCE_COMMANDS_DIR, 'execute-phase.md')); + assert.match(fm, /^effort:[ \t]*max$/m, + `execute-phase.md frontmatter must have effort: max\nActual:\n${fm}`); + assert.doesNotMatch(fm, /^effort:[ \t]*xhigh$/m, + `execute-phase.md frontmatter must not have rejected effort: xhigh (#1319)\nActual:\n${fm}`); + }); + + test('commands/gsd/plan-phase.md does NOT have context: fork (#921)', () => { + const fm = readFrontmatter(path.join(SOURCE_COMMANDS_DIR, 'plan-phase.md')); + assert.doesNotMatch(fm, /^context:[ \t]*fork$/m, + `plan-phase.md is a spawning orchestrator and must NOT have context: fork (#921)\nActual:\n${fm}`); + }); + + test('commands/gsd/plan-phase.md has effort: max (#1319)', () => { + const fm = readFrontmatter(path.join(SOURCE_COMMANDS_DIR, 'plan-phase.md')); + assert.match(fm, /^effort:[ \t]*max$/m, + `plan-phase.md frontmatter must have effort: max\nActual:\n${fm}`); + assert.doesNotMatch(fm, /^effort:[ \t]*xhigh$/m, + `plan-phase.md frontmatter must not have rejected effort: xhigh (#1319)\nActual:\n${fm}`); + }); +}); + +describe('#769 source commands: quick-status skills have effort: low', () => { + test('commands/gsd/progress.md has effort: low', () => { + const fm = readFrontmatter(path.join(SOURCE_COMMANDS_DIR, 'progress.md')); + assert.match(fm, /^effort:[ \t]*low$/m, + `progress.md frontmatter must have effort: low\nActual:\n${fm}`); + }); + + test('commands/gsd/stats.md has effort: low', () => { + const fm = readFrontmatter(path.join(SOURCE_COMMANDS_DIR, 'stats.md')); + assert.match(fm, /^effort:[ \t]*low$/m, + `stats.md frontmatter must have effort: low\nActual:\n${fm}`); + }); +}); + +// ─── describe 2: convertClaudeCommandToClaudeSkill preserves new fields ─────── + +describe('#769/#1319 convertClaudeCommandToClaudeSkill: preserves context and emits portable effort fields', () => { + test('preserves context: fork in emitted SKILL.md frontmatter', () => { + const input = [ + '---', + 'name: gsd:test-heavy', + 'description: Test heavy skill', + 'context: fork', + 'effort: xhigh', + 'allowed-tools:', + ' - Read', + ' - Bash', + '---', + '', + 'Heavy skill body.', + ].join('\n'); + + const result = convertClaudeCommandToClaudeSkill(input, 'test-heavy'); + const end = result.indexOf('---', 3); + const fm = result.substring(3, end); + + assert.match(fm, /^context:[ \t]*fork$/m, + `SKILL.md frontmatter must include context: fork\nActual frontmatter:\n${fm}`); + }); + + test('normalizes effort: xhigh to effort: max in emitted SKILL.md frontmatter (#1319)', () => { + const input = [ + '---', + 'name: gsd:test-heavy', + 'description: Test heavy skill', + 'context: fork', + 'effort: xhigh', + 'allowed-tools:', + ' - Read', + ' - Bash', + '---', + '', + 'Heavy skill body.', + ].join('\n'); + + const result = convertClaudeCommandToClaudeSkill(input, 'test-heavy'); + const end = result.indexOf('---', 3); + const fm = result.substring(3, end); + + assert.match(fm, /^effort:[ \t]*max$/m, + `SKILL.md frontmatter must include portable effort: max\nActual frontmatter:\n${fm}`); + assert.doesNotMatch(fm, /^effort:[ \t]*xhigh$/m, + `SKILL.md frontmatter must not include rejected effort: xhigh (#1319)\nActual frontmatter:\n${fm}`); + }); + + test('preserves effort: low in emitted SKILL.md frontmatter', () => { + const input = [ + '---', + 'name: gsd:test-light', + 'description: Test light skill', + 'effort: low', + 'allowed-tools:', + ' - Read', + '---', + '', + 'Light skill body.', + ].join('\n'); + + const result = convertClaudeCommandToClaudeSkill(input, 'test-light'); + const end = result.indexOf('---', 3); + const fm = result.substring(3, end); + + assert.match(fm, /^effort:[ \t]*low$/m, + `SKILL.md frontmatter must include effort: low\nActual frontmatter:\n${fm}`); + }); + + test('does NOT emit context: or effort: when absent from source', () => { + const input = [ + '---', + 'name: gsd:test-plain', + 'description: Plain skill without context or effort', + 'allowed-tools:', + ' - Read', + '---', + '', + 'Plain skill body.', + ].join('\n'); + + const result = convertClaudeCommandToClaudeSkill(input, 'test-plain'); + const end = result.indexOf('---', 3); + const fm = result.substring(3, end); + + assert.doesNotMatch(fm, /^context:/m, + `SKILL.md must not emit context: when absent from source\nActual:\n${fm}`); + assert.doesNotMatch(fm, /^effort:/m, + `SKILL.md must not emit effort: when absent from source\nActual:\n${fm}`); + }); +}); + +// ─── describe 3: Claude global install — SKILL.md files include new fields ──── + +// #921/#922: after install, spawning orchestrators must NOT carry context: fork +// in their emitted SKILL.md. #1319: heavyweight skills must use portable max effort. +describe('#769/#921/#1319 Claude global install: spawning-orchestrator SKILL.md files have effort: max but NOT context: fork', () => { + let tmpDir; + let claudeHome; + + beforeEach(() => { + tmpDir = makeTmpDir('gsd-769-claude-'); + claudeHome = path.join(tmpDir, 'claude-home'); + fs.mkdirSync(claudeHome, { recursive: true }); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('gsd-autonomous SKILL.md does NOT have context: fork after global install (#921)', () => { + runClaudeGlobalInstall(claudeHome); + const skillPath = flatSkillPath(path.join(claudeHome, 'skills'),'autonomous'); + const fm = readFrontmatter(skillPath); + assert.doesNotMatch(fm, /^context:[ \t]*fork$/m, + `gsd-autonomous is a spawning orchestrator; its SKILL.md must NOT have context: fork (#921)\nActual:\n${fm}`); + }); + + test('gsd-autonomous SKILL.md has effort: max after global install (#1319)', () => { + runClaudeGlobalInstall(claudeHome); + const skillPath = flatSkillPath(path.join(claudeHome, 'skills'),'autonomous'); + const fm = readFrontmatter(skillPath); + assert.match(fm, /^effort:[ \t]*max$/m, + `gsd-autonomous SKILL.md must have effort: max\nActual:\n${fm}`); + assert.doesNotMatch(fm, /^effort:[ \t]*xhigh$/m, + `gsd-autonomous SKILL.md must not have rejected effort: xhigh (#1319)\nActual:\n${fm}`); + }); + + test('gsd-execute-phase SKILL.md does NOT have context: fork after global install (#921)', () => { + runClaudeGlobalInstall(claudeHome); + const skillPath = flatSkillPath(path.join(claudeHome, 'skills'),'execute-phase'); + const fm = readFrontmatter(skillPath); + assert.doesNotMatch(fm, /^context:[ \t]*fork$/m, + `gsd-execute-phase is a spawning orchestrator; its SKILL.md must NOT have context: fork (#921)\nActual:\n${fm}`); + }); + + test('gsd-execute-phase SKILL.md has effort: max after global install (#1319)', () => { + runClaudeGlobalInstall(claudeHome); + const skillPath = flatSkillPath(path.join(claudeHome, 'skills'),'execute-phase'); + const fm = readFrontmatter(skillPath); + assert.match(fm, /^effort:[ \t]*max$/m, + `gsd-execute-phase SKILL.md must have effort: max\nActual:\n${fm}`); + assert.doesNotMatch(fm, /^effort:[ \t]*xhigh$/m, + `gsd-execute-phase SKILL.md must not have rejected effort: xhigh (#1319)\nActual:\n${fm}`); + }); + + test('gsd-plan-phase SKILL.md does NOT have context: fork after global install (#921)', () => { + runClaudeGlobalInstall(claudeHome); + const skillPath = flatSkillPath(path.join(claudeHome, 'skills'),'plan-phase'); + const fm = readFrontmatter(skillPath); + assert.doesNotMatch(fm, /^context:[ \t]*fork$/m, + `gsd-plan-phase is a spawning orchestrator; its SKILL.md must NOT have context: fork (#921)\nActual:\n${fm}`); + }); + + test('gsd-plan-phase SKILL.md has effort: max after global install (#1319)', () => { + runClaudeGlobalInstall(claudeHome); + const skillPath = flatSkillPath(path.join(claudeHome, 'skills'),'plan-phase'); + const fm = readFrontmatter(skillPath); + assert.match(fm, /^effort:[ \t]*max$/m, + `gsd-plan-phase SKILL.md must have effort: max\nActual:\n${fm}`); + assert.doesNotMatch(fm, /^effort:[ \t]*xhigh$/m, + `gsd-plan-phase SKILL.md must not have rejected effort: xhigh (#1319)\nActual:\n${fm}`); + }); + + test('gsd-progress SKILL.md has effort: low after global install', () => { + runClaudeGlobalInstall(claudeHome); + const skillPath = flatSkillPath(path.join(claudeHome, 'skills'),'progress'); + const fm = readFrontmatter(skillPath); + assert.match(fm, /^effort:[ \t]*low$/m, + `gsd-progress SKILL.md must have effort: low\nActual:\n${fm}`); + }); + + test('gsd-stats SKILL.md has effort: low after global install', () => { + runClaudeGlobalInstall(claudeHome); + const skillPath = flatSkillPath(path.join(claudeHome, 'skills'),'stats'); + const fm = readFrontmatter(skillPath); + assert.match(fm, /^effort:[ \t]*low$/m, + `gsd-stats SKILL.md must have effort: low\nActual:\n${fm}`); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/feat-443-effort-install-wiring.install.test.cjs — consolidation epic #1969 (B1 #1970) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:feat-443-effort-install-wiring.install (consolidation epic #1969 B1 #1970)", () => { +// allow-test-rule: integration-test-input (see #443) +// Exercises install() + generateCodexAgentToml() as a black-box by inspecting +// produced output files in temp dirs. Source agent .md files are inputs whose +// installed transformation is asserted — not inspected for string presence. + +/** + * #443 — Effort per-runtime wiring at install time. + * + * Verifies: + * 1. Claude global install injects `effort:` into agent .md frontmatter. + * 2. Gemini global install does NOT inject `effort:` (Gemini-safe .md). + * 3. Codex inherited-model installs omit `model_reasoning_effort` so model + * and effort are not partially pinned (#838). + * 4. Config-driven proof: effort.agent_overrides wins over tier defaults + * for Claude .md and for Codex .toml when runtime:"codex" pins a model. + * 5. Source agents/gsd-planner.md has NO effort: key (injection is + * install-only, source stays Gemini-safe). + */ + +'use strict'; + +process.env.GSD_TEST_MODE = '1'; + +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 os = require('node:os'); + +const { install } = require('../bin/install.js'); +const { cleanup } = require('./helpers.cjs'); + +const REPO_ROOT = path.resolve(__dirname, '..'); +const SOURCE_AGENTS_DIR = path.join(REPO_ROOT, 'agents'); + +// ─── helpers ───────────────────────────────────────────────────────────────── + +function makeTmpDir(prefix) { + return fs.mkdtempSync(path.join(os.tmpdir(), prefix)); +} + +function readFrontmatter(mdPath) { + const content = fs.readFileSync(mdPath, 'utf8'); + if (!content.startsWith('---')) return ''; + const end = content.indexOf('---', 3); + if (end === -1) return ''; + return content.substring(3, end); +} + +/** + * Run a global install for the given runtime, redirecting its home dir to + * tmpHome. Returns the tmpHome for inspection. + * + * Env-var redirection: + * claude → CLAUDE_CONFIG_DIR + * gemini → GEMINI_CONFIG_DIR + * codex → CODEX_HOME + * + * HOME is also redirected to an isolated temp dir for the duration of the + * install call. This prevents any install.js code that uses os.homedir() + * directly (e.g. ~/.cache/gsd update-check deletion, ~/.gsd/defaults.json + * reads, stale-SDK npm subprocess writes to ~/.npm) from touching the real + * HOME and polluting the test environment for other concurrently-running + * test files (e.g. runtime-launcher-parity test (D) checks that + * $HOME/.claude/gsd-core/bin/gsd-tools.cjs is absent). + * + * GSD_SKIP_STALE_SDK_CHECK=1 is set to suppress the `npm ls -g` subprocess + * that the installer spawns for global installs — that subprocess is slow, + * writes to ~/.npm cache, and is irrelevant to effort-wiring assertions. + * + * The working directory is set to REPO_ROOT so install() can find the source + * agents/. For config-driven tests, place tmpHome inside the project dir + * so that readGsdEffectiveEffortConfig(targetDir) can walk up from tmpHome + * and find .planning/config.json. + */ +function runGlobalInstall(runtime, tmpHome) { + const envVarMap = { + claude: 'CLAUDE_CONFIG_DIR', + gemini: 'GEMINI_CONFIG_DIR', + codex: 'CODEX_HOME', + }; + const envVar = envVarMap[runtime]; + if (!envVar) throw new Error(`Unsupported runtime in test: ${runtime}`); + + // Isolate HOME to a fresh temp dir so install.js code that calls + // os.homedir() (cache deletion, defaults.json reads, npm subprocess) + // never touches the real $HOME/.claude / $HOME/.cache / $HOME/.gsd. + const isolatedHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-443-home-')); + + const prev = process.env[envVar]; + const prevCwd = process.cwd(); + const prevHome = process.env.HOME; + const prevUserProfile = process.env.USERPROFILE; + const prevSkipStale = process.env.GSD_SKIP_STALE_SDK_CHECK; + + process.env[envVar] = tmpHome; + process.env.HOME = isolatedHome; + process.env.USERPROFILE = isolatedHome; + process.env.GSD_SKIP_STALE_SDK_CHECK = '1'; + process.chdir(REPO_ROOT); + + try { + install(true, runtime); + } finally { + process.chdir(prevCwd); + if (prev === undefined) delete process.env[envVar]; + else process.env[envVar] = prev; + if (prevHome === undefined) delete process.env.HOME; + else process.env.HOME = prevHome; + if (prevUserProfile === undefined) delete process.env.USERPROFILE; + else process.env.USERPROFILE = prevUserProfile; + if (prevSkipStale === undefined) delete process.env.GSD_SKIP_STALE_SDK_CHECK; + else process.env.GSD_SKIP_STALE_SDK_CHECK = prevSkipStale; + // Clean up the isolated HOME dir + cleanup(isolatedHome); + } + + return tmpHome; +} + +// ─── Tier default expectations ──────────────────────────────────────────────── +// light → low, standard → high, heavy → xhigh (catalog defaults) +// gsd-planner: heavy → xhigh +// gsd-codebase-mapper: light → low +// gsd-executor: standard → high + +// ─── describe 1: Claude install injects effort: ─────────────────────────────── + +describe('#443 Claude install: effort: injected into frontmatter', () => { + let tmpDir; + let claudeHome; + + beforeEach(() => { + tmpDir = makeTmpDir('gsd-443-claude-'); + claudeHome = path.join(tmpDir, 'claude-home'); + fs.mkdirSync(claudeHome, { recursive: true }); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('gsd-planner.md contains effort: xhigh (heavy tier default)', () => { + runGlobalInstall('claude', claudeHome); + const fm = readFrontmatter(path.join(claudeHome, 'agents', 'gsd-planner.md')); + assert.match(fm, /^effort:\s*xhigh$/m, + `gsd-planner frontmatter should have effort: xhigh\nActual:\n${fm}`); + }); + + test('gsd-codebase-mapper.md contains effort: low (light tier default)', () => { + runGlobalInstall('claude', claudeHome); + const fm = readFrontmatter(path.join(claudeHome, 'agents', 'gsd-codebase-mapper.md')); + assert.match(fm, /^effort:\s*low$/m, + `gsd-codebase-mapper frontmatter should have effort: low\nActual:\n${fm}`); + }); + + test('gsd-executor.md contains effort: high (standard tier default)', () => { + runGlobalInstall('claude', claudeHome); + const fm = readFrontmatter(path.join(claudeHome, 'agents', 'gsd-executor.md')); + assert.match(fm, /^effort:\s*high$/m, + `gsd-executor frontmatter should have effort: high\nActual:\n${fm}`); + }); +}); + +// ─── describe 2: Gemini install does NOT inject effort: ────────────────────── + +describe('#443 Gemini install: effort: absent (Gemini-safe)', () => { + let tmpDir; + let geminiHome; + + beforeEach(() => { + tmpDir = makeTmpDir('gsd-443-gemini-'); + geminiHome = path.join(tmpDir, 'gemini-home'); + fs.mkdirSync(geminiHome, { recursive: true }); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('gsd-planner.md does NOT contain effort: (Gemini install)', () => { + runGlobalInstall('gemini', geminiHome); + const fm = readFrontmatter(path.join(geminiHome, 'agents', 'gsd-planner.md')); + assert.doesNotMatch(fm, /^effort:/m, + `gsd-planner (Gemini) frontmatter must NOT have effort:\nActual:\n${fm}`); + }); + + test('gsd-executor.md does NOT contain effort: (Gemini install)', () => { + runGlobalInstall('gemini', geminiHome); + const fm = readFrontmatter(path.join(geminiHome, 'agents', 'gsd-executor.md')); + assert.doesNotMatch(fm, /^effort:/m, + `gsd-executor (Gemini) frontmatter must NOT have effort:\nActual:\n${fm}`); + }); +}); + +// ─── describe 3: Codex inherited-model install omits model_reasoning_effort ── + +describe('#838 Codex install: inherited model omits model_reasoning_effort', () => { + let tmpDir; + let codexHome; + + beforeEach(() => { + tmpDir = makeTmpDir('gsd-443-codex-'); + codexHome = path.join(tmpDir, 'codex-home'); + fs.mkdirSync(codexHome, { recursive: true }); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('gsd-planner.toml omits both model and model_reasoning_effort when model is inherited', () => { + runGlobalInstall('codex', codexHome); + const tomlContent = fs.readFileSync( + path.join(codexHome, 'agents', 'gsd-planner.toml'), 'utf8' + ); + assert.doesNotMatch(tomlContent, /^model\s*=/m, + `gsd-planner.toml should omit model when inheriting Codex chat model\nActual:\n${tomlContent.slice(0, 500)}`); + assert.doesNotMatch(tomlContent, /^model_reasoning_effort\s*=/m, + `gsd-planner.toml should omit model_reasoning_effort when model is inherited\nActual:\n${tomlContent.slice(0, 500)}`); + }); +}); + +// ─── describe 4: Config-driven proof ───────────────────────────────────────── +// +// The runtime home dir must be INSIDE (or a sibling of) the project root so +// that readGsdEffectiveEffortConfig(targetDir) can walk up from the runtime +// home and find .planning/config.json. We put .claude/ and .codex/ as siblings +// of .planning/ inside the project dir — this is the natural local-install shape. + +describe('#443 Config-driven: effort.agent_overrides drives install-time effort', () => { + let tmpDir; + let claudeHome; + let codexHome; + + beforeEach(() => { + // Layout: tmpDir/project/ <-- project root (cwd for install) + // .planning/config.json + // .claude/ <-- claudeHome (CLAUDE_CONFIG_DIR) + // .codex/ <-- codexHome (CODEX_HOME) + tmpDir = makeTmpDir('gsd-443-cfg-'); + const projectDir = path.join(tmpDir, 'project'); + claudeHome = path.join(projectDir, '.claude'); + codexHome = path.join(projectDir, '.codex'); + + fs.mkdirSync(claudeHome, { recursive: true }); + fs.mkdirSync(codexHome, { recursive: true }); + fs.mkdirSync(path.join(projectDir, '.planning'), { recursive: true }); + + // Write a project config with effort.agent_overrides overriding gsd-planner to 'low'. + // runtime:"codex" pins a Codex-native model, so emitting model_reasoning_effort + // remains valid under the #838 model/effort coupling rule. + const config = { + runtime: 'codex', + effort: { + agent_overrides: { + 'gsd-planner': 'low', + }, + }, + }; + fs.writeFileSync( + path.join(projectDir, '.planning', 'config.json'), + JSON.stringify(config, null, 2) + ); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('Claude .md gets effort: low when agent_overrides.gsd-planner=low', () => { + // projectDir is the cwd for install — chdir handled inside runGlobalInstall. + // claudeHome is inside projectDir, so walking up from claudeHome finds .planning/config.json. + runGlobalInstall('claude', claudeHome); + const fm = readFrontmatter(path.join(claudeHome, 'agents', 'gsd-planner.md')); + assert.match(fm, /^effort:\s*low$/m, + `gsd-planner should have effort: low from config override\nActual:\n${fm}`); + }); + + test('Codex .toml gets model_reasoning_effort = "low" when agent_overrides.gsd-planner=low', () => { + runGlobalInstall('codex', codexHome); + const tomlContent = fs.readFileSync( + path.join(codexHome, 'agents', 'gsd-planner.toml'), 'utf8' + ); + assert.match(tomlContent, /^model\s*=\s*"gpt-5.5"$/m, + `gsd-planner.toml should pin Codex model when runtime:"codex" is configured\nActual:\n${tomlContent.slice(0, 500)}`); + assert.match(tomlContent, /^model_reasoning_effort\s*=\s*"low"$/m, + `gsd-planner.toml should have model_reasoning_effort = "low" from config override\nActual:\n${tomlContent.slice(0, 500)}`); + }); + + test('Codex .toml clamps effort max → xhigh when agent_overrides.gsd-planner=max', () => { + const projectDir = path.dirname(codexHome); + // Overwrite config with max override + const config = { + runtime: 'codex', + effort: { + agent_overrides: { + 'gsd-planner': 'max', + }, + }, + }; + fs.writeFileSync( + path.join(projectDir, '.planning', 'config.json'), + JSON.stringify(config, null, 2) + ); + + runGlobalInstall('codex', codexHome); + const tomlContent = fs.readFileSync( + path.join(codexHome, 'agents', 'gsd-planner.toml'), 'utf8' + ); + assert.match(tomlContent, /^model\s*=\s*"gpt-5.5"$/m, + `gsd-planner.toml should pin Codex model when runtime:"codex" is configured\nActual:\n${tomlContent.slice(0, 500)}`); + // Codex does not support 'max' → clamped to 'xhigh' + assert.match(tomlContent, /^model_reasoning_effort\s*=\s*"xhigh"$/m, + `gsd-planner.toml should clamp max → xhigh for Codex\nActual:\n${tomlContent.slice(0, 500)}`); + assert.doesNotMatch(tomlContent, /model_reasoning_effort\s*=\s*"max"/, + 'Codex .toml must never contain model_reasoning_effort = "max"'); + }); +}); + +// ─── describe 5b: Invalid effort tokens fall through (Codex adversarial finding #2) ─ +// +// These tests FAIL before the fix: resolveInstallTimeEffort returns the raw +// invalid string without validating it against VALID_EFFORTS. + +describe('#443 resolveInstallTimeEffort: invalid tokens fall through to valid effort', () => { + let tmpDir; + let claudeHome; + let codexHome; + + beforeEach(() => { + // Layout: tmpDir/project/ <-- project root + // .planning/config.json + // .claude/ <-- claudeHome + // .codex/ <-- codexHome + tmpDir = makeTmpDir('gsd-443-invalid-effort-'); + const projectDir = path.join(tmpDir, 'project'); + claudeHome = path.join(projectDir, '.claude'); + codexHome = path.join(projectDir, '.codex'); + + fs.mkdirSync(claudeHome, { recursive: true }); + fs.mkdirSync(codexHome, { recursive: true }); + fs.mkdirSync(path.join(projectDir, '.planning'), { recursive: true }); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + function writeProjectConfig(config) { + const projectDir = path.dirname(claudeHome); + fs.writeFileSync( + path.join(projectDir, '.planning', 'config.json'), + JSON.stringify(config, null, 2) + ); + } + + const VALID_EFFORTS = ['minimal', 'low', 'medium', 'high', 'xhigh', 'max']; + + test('effort.default="ultra" (invalid) -> Claude .md effort: is a VALID value (falls through to high)', () => { + // BUG before fix: resolveInstallTimeEffort returns "ultra" verbatim + writeProjectConfig({ effort: { default: 'ultra' } }); + runGlobalInstall('claude', claudeHome); + const fm = readFrontmatter(path.join(claudeHome, 'agents', 'gsd-planner.md')); + const match = fm.match(/^effort:\s*(\S+)$/m); + assert.ok(match, `effort: must be present in frontmatter\nActual:\n${fm}`); + assert.ok(VALID_EFFORTS.includes(match[1]), + `effort: must be a VALID effort string, got: "${match[1]}"\nActual frontmatter:\n${fm}`); + }); + + test('effort.agent_overrides.gsd-planner="bogus" (invalid) with valid default -> falls through to valid default', () => { + // BUG before fix: "bogus" is returned and written verbatim + writeProjectConfig({ + effort: { + agent_overrides: { 'gsd-planner': 'bogus' }, + default: 'medium', + }, + }); + runGlobalInstall('claude', claudeHome); + const fm = readFrontmatter(path.join(claudeHome, 'agents', 'gsd-planner.md')); + const match = fm.match(/^effort:\s*(\S+)$/m); + assert.ok(match, `effort: must be present in frontmatter\nActual:\n${fm}`); + assert.ok(VALID_EFFORTS.includes(match[1]), + `effort: must be a VALID effort string, got: "${match[1]}"\nActual frontmatter:\n${fm}`); + // Falls through invalid "bogus" -> valid tier default or "medium" default + // "medium" is valid, so it should appear (or tier default if medium is invalid, but medium is valid) + }); + + test('effort.default="ultra" (invalid) + runtime:"codex" -> Codex .toml model_reasoning_effort is VALID', () => { + // BUG before fix: "ultra" written into .toml verbatim + writeProjectConfig({ runtime: 'codex', effort: { default: 'ultra' } }); + runGlobalInstall('codex', codexHome); + const tomlContent = fs.readFileSync( + path.join(codexHome, 'agents', 'gsd-planner.toml'), 'utf8' + ); + assert.match(tomlContent, /^model\s*=\s*"gpt-5.5"$/m, + `gsd-planner.toml should pin Codex model when runtime:"codex" is configured\nActual:\n${tomlContent.slice(0, 500)}`); + const match = tomlContent.match(/^model_reasoning_effort\s*=\s*"([^"]+)"/m); + assert.ok(match, `model_reasoning_effort must be present in .toml\nActual:\n${tomlContent.slice(0, 500)}`); + assert.ok(VALID_EFFORTS.includes(match[1]), + `model_reasoning_effort must be VALID, got: "${match[1]}"\nActual:\n${tomlContent.slice(0, 500)}`); + }); +}); + +// ─── describe 5: Source stays clean ────────────────────────────────────────── + +describe('#443 Source purity: agents/gsd-planner.md has no effort: key', () => { + test('source agents/gsd-planner.md frontmatter does not contain effort:', () => { + const fm = readFrontmatter(path.join(SOURCE_AGENTS_DIR, 'gsd-planner.md')); + assert.doesNotMatch(fm, /^effort:/m, + `Source agents/gsd-planner.md must NOT contain effort: (injection is install-only)`); + }); + + test('source agents/gsd-executor.md frontmatter does not contain effort:', () => { + const fm = readFrontmatter(path.join(SOURCE_AGENTS_DIR, 'gsd-executor.md')); + assert.doesNotMatch(fm, /^effort:/m, + `Source agents/gsd-executor.md must NOT contain effort: (injection is install-only)`); + }); + + test('source agents/gsd-codebase-mapper.md frontmatter does not contain effort:', () => { + const fm = readFrontmatter(path.join(SOURCE_AGENTS_DIR, 'gsd-codebase-mapper.md')); + assert.doesNotMatch(fm, /^effort:/m, + `Source agents/gsd-codebase-mapper.md must NOT contain effort: (injection is install-only)`); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/enh-1510-rewrite-engine-helper-relocation.test.cjs — consolidation epic #1969 (B1 #1970) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:enh-1510-rewrite-engine-helper-relocation (consolidation epic #1969 B1 #1970)", () => { +'use strict'; + +// Enhancement #1510 (epic #1507, ADR-1508 Phase 1): behavior-preserving +// relocation of pure rewrite-engine helpers out of hand-authored bin/install.js. +// - getDirName -> gsd-core/bin/lib/runtime-name-policy.cjs +// - processAttribution -> gsd-core/bin/lib/runtime-artifact-conversion.cjs +// getCommitAttribution stays in install.js (impure install-time config I/O); the +// convertClaudeToAugmentMarkdown duplicate dedup is deferred to Phase 2's cleanup +// (entangled converter cluster; not required to unblock Phase 2). +// These tests exercise the REAL relocated functions at their new home (the +// generated .cjs) and assert install.js re-exports the SAME references +// (Hyrum: existing consumers import these names from bin/install.js). + +const { test, describe } = require('node:test'); +const assert = require('node:assert'); + +const runtimeNamePolicy = require('../gsd-core/bin/lib/runtime-name-policy.cjs'); +const conversion = require('../gsd-core/bin/lib/runtime-artifact-conversion.cjs'); +const installer = require('../bin/install.js'); + +// ── Slice A: getDirName relocated to runtime-name-policy ────────────────────── +describe('getDirName (relocated to runtime-name-policy)', () => { + const EXPECTED = { + claude: '.claude', + copilot: '.github', + opencode: '.opencode', + gemini: '.gemini', + kilo: '.kilo', + codex: '.codex', + antigravity: '.agents', + cursor: '.cursor', + windsurf: '.windsurf', + augment: '.augment', + trae: '.trae', + qwen: '.qwen', + hermes: '.hermes', + kimi: '.kimi-code', + codebuddy: '.codebuddy', + cline: '.cline', + }; + + for (const [runtime, dir] of Object.entries(EXPECTED)) { + test(`maps '${runtime}' to '${dir}'`, () => { + assert.strictEqual(runtimeNamePolicy.getDirName(runtime), dir); + }); + } + + test('falls back to .claude for an unknown runtime', () => { + assert.strictEqual(runtimeNamePolicy.getDirName('definitely-not-a-runtime'), '.claude'); + }); + + test('falls back to .claude for empty input', () => { + assert.strictEqual(runtimeNamePolicy.getDirName(''), '.claude'); + }); + + test('bin/install.js re-exports the SAME getDirName reference (no drift)', () => { + assert.strictEqual(installer.getDirName, runtimeNamePolicy.getDirName); + }); +}); + +// ── Slice B: processAttribution relocated to runtime-artifact-conversion ─────── +describe('processAttribution (relocated to runtime-artifact-conversion)', () => { + test('null removes the Co-Authored-By line and its preceding blank line', () => { + const input = 'Commit body line.\n\nCo-Authored-By: Someone '; + assert.strictEqual(conversion.processAttribution(input, null), 'Commit body line.'); + }); + + test('undefined leaves content unchanged', () => { + const input = 'Commit body.\n\nCo-Authored-By: Someone '; + assert.strictEqual(conversion.processAttribution(input, undefined), input); + }); + + test('a string replaces the attribution value', () => { + const input = 'Body\n\nCo-Authored-By: Old Name '; + assert.strictEqual( + conversion.processAttribution(input, 'New Name '), + 'Body\n\nCo-Authored-By: New Name ', + ); + }); + + test('escapes $ in the attribution to prevent backreference injection', () => { + const input = 'Body\n\nCo-Authored-By: x'; + // "$1" must survive literally, not be interpreted as a regex backreference. + assert.strictEqual( + conversion.processAttribution(input, 'A $1 B'), + 'Body\n\nCo-Authored-By: A $1 B', + ); + }); + + test('handles CRLF when removing (null)', () => { + const input = 'Body\r\n\r\nCo-Authored-By: Someone '; + assert.strictEqual(conversion.processAttribution(input, null), 'Body'); + }); + + test('replaces every Co-Authored-By line (global)', () => { + const input = 'Body\nCo-Authored-By: A \nCo-Authored-By: B '; + assert.strictEqual( + conversion.processAttribution(input, 'Z '), + 'Body\nCo-Authored-By: Z \nCo-Authored-By: Z ', + ); + }); + + test('bin/install.js re-exports the SAME processAttribution reference (no drift)', () => { + // processAttribution remains an explicit installer compatibility relay, so + // the export must keep pointing at the conversion module's implementation. + assert.strictEqual(installer.processAttribution, conversion.processAttribution); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/enh-1511-rewrite-engine-relocation.test.cjs — consolidation epic #1969 (B1 #1970) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:enh-1511-rewrite-engine-relocation (consolidation epic #1969 B1 #1970)", () => { +'use strict'; +/** + * Tests for ADR-1508 Phase 2: rewrite engine relocation to runtime-artifact-conversion. + * Issue #1511 — verifies the deep public seam signatures and behavior. + * + * Tests are behavioral (no source-grep). All filesystem operations use tmp dirs. + */ + +const { describe, test, before } = 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 { cleanup } = require('./helpers.cjs'); + +let conversion; +before(() => { + process.env['GSD_TEST_MODE'] = '1'; + conversion = require('../gsd-core/bin/lib/runtime-artifact-conversion.cjs'); +}); + +// --------------------------------------------------------------------------- +// _computePathPrefix unit tests +// --------------------------------------------------------------------------- + +describe('_computePathPrefix', () => { + test('global under home → $HOME/... form', () => { + const prefix = conversion._computePathPrefix({ + isGlobal: true, + isOpencode: false, + isWindowsHost: false, + resolvedTarget: '/home/u/.cursor', + homeDir: '/home/u', + }); + assert.equal(prefix, '$HOME/.cursor/'); + }); + + test('non-global → resolvedTarget/ form', () => { + const prefix = conversion._computePathPrefix({ + isGlobal: false, + isOpencode: false, + isWindowsHost: false, + resolvedTarget: '/project/.cursor', + homeDir: '/home/u', + }); + assert.equal(prefix, '/project/.cursor/'); + }); + + test('global opencode skips $HOME shorthand', () => { + // OpenCode uses ~/.config/opencode which breaks $HOME shorthand in content + const prefix = conversion._computePathPrefix({ + isGlobal: true, + isOpencode: true, + isWindowsHost: false, + resolvedTarget: '/home/u/.config/opencode', + homeDir: '/home/u', + }); + assert.equal(prefix, '/home/u/.config/opencode/'); + }); + + test('global target outside home → resolvedTarget/ form', () => { + const prefix = conversion._computePathPrefix({ + isGlobal: true, + isOpencode: false, + isWindowsHost: false, + resolvedTarget: '/opt/custom-cursor', + homeDir: '/home/u', + }); + assert.equal(prefix, '/opt/custom-cursor/'); + }); + + test('isWindowsHost tripwire — Windows paths collapse to $HOME/ same as POSIX (no-op today)', () => { + // Documents CURRENT behavior: isWindowsHost is accepted but not branched on. + // Both win32=true and win32=false return '$HOME/.cursor/' for a home-relative target. + // If a future Windows-specific branch is added, this tripwire fails and forces + // an explicit decision about what to return on Windows. + const withWindows = conversion._computePathPrefix({ + isGlobal: true, + isOpencode: false, + isWindowsHost: true, + resolvedTarget: 'C:/Users/matte/.cursor', + homeDir: 'C:/Users/matte', + }); + const withoutWindows = conversion._computePathPrefix({ + isGlobal: true, + isOpencode: false, + isWindowsHost: false, + resolvedTarget: 'C:/Users/matte/.cursor', + homeDir: 'C:/Users/matte', + }); + assert.equal(withWindows, '$HOME/.cursor/'); + assert.strictEqual(withWindows, withoutWindows); + }); + + test('backslash-style resolvedTarget is normalized to forward slashes (#1615 regression)', () => { + // path.join on Windows produces backslashes; the returned prefix is + // substituted into markdown @-references which must use POSIX paths. + // Without normalization the backslashes leak into workflow file content + // and break substring checks on Windows CI. + const prefix = conversion._computePathPrefix({ + isGlobal: false, + isOpencode: false, + isWindowsHost: true, + resolvedTarget: 'C:\\Users\\runner\\AppData\\Local\\Temp\\gsd-1615-windsurf', + homeDir: 'C:\\Users\\runner', + }); + assert.strictEqual(prefix, 'C:/Users/runner/AppData/Local/Temp/gsd-1615-windsurf/'); + assert.ok(!prefix.includes('\\'), `prefix must not contain backslashes: ${prefix}`); + }); +}); + +// --------------------------------------------------------------------------- +// _applyRuntimeRewrites with injected attribution +// --------------------------------------------------------------------------- + +describe('_applyRuntimeRewrites — attribution injection', () => { + const PREFIX = '$HOME/.cursor/'; + + test('attribution=null removes Co-Authored-By line', () => { + const content = '# Hello\n\nSome text\n\nCo-Authored-By: Claude\n'; + const result = conversion._applyRuntimeRewrites(content, 'cursor', PREFIX, true, null); + assert.ok(!result.includes('Co-Authored-By:'), 'Co-Authored-By should be removed'); + }); + + test('attribution=undefined leaves Co-Authored-By unchanged', () => { + const content = '# Hello\n\nCo-Authored-By: Claude\n'; + const result = conversion._applyRuntimeRewrites(content, 'cursor', PREFIX, true, undefined); + assert.ok(result.includes('Co-Authored-By: Claude'), 'Co-Authored-By should be preserved when attribution=undefined'); + }); + + test('attribution=string replaces Co-Authored-By value', () => { + const content = '# Hello\n\nCo-Authored-By: OldName\n'; + const result = conversion._applyRuntimeRewrites(content, 'cursor', PREFIX, true, 'NewName '); + assert.ok(result.includes('Co-Authored-By: NewName '), 'Co-Authored-By should be replaced'); + }); + + test('cursor runtime replaces ~/.claude/ paths', () => { + const content = 'See ~/.claude/skills/ for more info\n'; + const result = conversion._applyRuntimeRewrites(content, 'cursor', '/home/u/.cursor/', false, undefined); + assert.ok(result.includes('/home/u/.cursor/skills/'), 'cursor should replace ~/.claude/ with pathPrefix'); + }); +}); + +// --------------------------------------------------------------------------- +// rewriteStagedSkillBodies — behavioral filesystem test +// --------------------------------------------------------------------------- + +describe('rewriteStagedSkillBodies', () => { + test('rewrites .md files in-place for cursor runtime', () => { + const stagedDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-test-staged-')); + const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-test-config-')); + try { + // Create a skill dir with a SKILL.md referencing ~/.claude/skills/foo + // NOTE: the rewrite engine handles path replacement and attribution only. + // Bash→Shell conversion is done by the stage-1 skill converter, not the engine. + const skillDir = path.join(stagedDir, 'gsd-test-skill'); + fs.mkdirSync(skillDir, { recursive: true }); + const content = '# Test\n\nSee ~/.claude/skills/foo\n\nAlso ~/.cursor/skills/bar\n'; + fs.writeFileSync(path.join(skillDir, 'SKILL.md'), content); + + // Call with injected homedir + platform for determinism + conversion.rewriteStagedSkillBodies(stagedDir, { + runtime: 'cursor', + configDir, + scope: 'global', + homedir: () => '/home/u', + platform: 'linux', + }); + + const result = fs.readFileSync(path.join(skillDir, 'SKILL.md'), 'utf8'); + // cursor rewrites ~/.claude/ → pathPrefix + // configDir is a tmpdir, not under /home/u, so prefix = resolvedTarget + '/' + // Mirror the engine's backslash→slash normalization so the assertion holds on Windows. + const resolvedTarget = path.resolve(configDir).replace(/\\/g, '/'); + assert.ok(result.includes(`${resolvedTarget}/skills/foo`), `Should replace ~/.claude/skills/ with ${resolvedTarget}/skills/`); + // cursor also rewrites ~/.cursor/ → pathPrefix + assert.ok(result.includes(`${resolvedTarget}/skills/bar`), `Should replace ~/.cursor/skills/ with ${resolvedTarget}/skills/`); + } finally { + cleanup(stagedDir); + cleanup(configDir); + } + }); + + test('with injected homedir: global under home uses $HOME prefix', () => { + // Real absolute path so Windows path.resolve does not re-root a POSIX literal onto a drive. + // The dir need not exist — the engine only string-processes it. + const HOME = path.resolve(os.tmpdir(), 'gsd-1511-fake-home'); + const configDir = path.join(HOME, '.cursor'); + const stagedDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-test-staged-')); + try { + const skillDir = path.join(stagedDir, 'gsd-help'); + fs.mkdirSync(skillDir, { recursive: true }); + fs.writeFileSync(path.join(skillDir, 'SKILL.md'), 'Use ~/.claude/skills/ here\n'); + + conversion.rewriteStagedSkillBodies(stagedDir, { + runtime: 'cursor', + configDir, + scope: 'global', + homedir: () => HOME, + platform: process.platform, + }); + + const result = fs.readFileSync(path.join(skillDir, 'SKILL.md'), 'utf8'); + assert.ok(result.includes('$HOME/.cursor/skills/'), 'Should use $HOME shorthand when configDir is under homedir'); + } finally { + cleanup(stagedDir); + } + }); + + test('non-existent stagedDir is a no-op', () => { + assert.doesNotThrow(() => { + conversion.rewriteStagedSkillBodies('/nonexistent/dir', { + runtime: 'cursor', + configDir: '/tmp/fake', + scope: 'global', + }); + }); + }); +}); + +// --------------------------------------------------------------------------- +// rewriteStagedCommandBodies — returns temp dir, does not mutate source +// --------------------------------------------------------------------------- + +describe('rewriteStagedCommandBodies', () => { + test('returns a temp dir (not the source dir) with rewritten content', () => { + const stagedDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-test-cmd-')); + const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-test-config-')); + let tempDir; + try { + // NOTE: rewrite engine handles path replacement + attribution, NOT tool renames. + fs.writeFileSync(path.join(stagedDir, 'help.md'), '# Help\n\nSee ~/.claude/skills/\n\nSee ~/.cursor/skills/\n'); + + tempDir = conversion.rewriteStagedCommandBodies(stagedDir, { + runtime: 'cursor', + configDir, + scope: 'global', + homedir: () => '/home/u', + platform: 'linux', + }); + + assert.notEqual(tempDir, stagedDir, 'must return a different dir, never the source'); + assert.ok(fs.existsSync(tempDir), 'returned tempDir should exist'); + + const result = fs.readFileSync(path.join(tempDir, 'help.md'), 'utf8'); + // Source dir should be unchanged + const source = fs.readFileSync(path.join(stagedDir, 'help.md'), 'utf8'); + assert.ok(source.includes('~/.claude/skills/'), 'source file must not be mutated'); + // configDir is /tmp/... (not under /home/u), so prefix = resolvedTarget + '/' + const resolvedTarget = path.resolve(configDir).replace(/\\/g, '/'); + assert.ok(result.includes(`${resolvedTarget}/skills/`), 'output should have cursor path rewrite applied'); + // ~/.cursor/ also rewrites to prefix + assert.ok(!result.includes('~/.cursor/'), 'output should have ~/.cursor/ replaced too'); + } finally { + cleanup(stagedDir); + cleanup(configDir); + if (tempDir && tempDir !== stagedDir) { + cleanup(tempDir); + } + } + }); + + test('non-existent stagedDir returns stagedDir unchanged (safe)', () => { + const result = conversion.rewriteStagedCommandBodies('/nonexistent/dir', { + runtime: 'cursor', + configDir: '/tmp/fake', + scope: 'global', + }); + assert.equal(result, '/nonexistent/dir', 'should return input path unchanged for missing dir'); + }); +}); + +// --------------------------------------------------------------------------- +// Error-path: applyRuntimeContentRewritesForCommandsInPlace must rm the tempDir +// on any exception and NOT leave an orphaned gsd-cmd-rewrites-* directory. +// --------------------------------------------------------------------------- + +describe('applyRuntimeContentRewritesForCommandsInPlace — error-path tempDir cleanup', () => { + test('rmSync is called on the tempDir when readFileSync throws (deterministic monkeypatch)', () => { + // Asserting the injected error propagates proves the throw happens AFTER the tempDir is + // created (the function creates tempDir, then reads .md), so the catch's rmSync cleanup + // is genuinely exercised — deterministic on every platform/uid. + const stagedDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-test-error-path-')); + fs.writeFileSync(path.join(stagedDir, 'x.md'), '# test\n'); + + const before = new Set( + fs.readdirSync(os.tmpdir()).filter(n => n.startsWith('gsd-cmd-rewrites-')) + ); + + const origReadFileSync = fs.readFileSync; + let leaked = []; + try { + fs.readFileSync = () => { throw new Error('injected read failure'); }; + + assert.throws( + () => conversion.applyRuntimeContentRewritesForCommandsInPlace(stagedDir, 'cursor', '/tmp/x/', false), + /injected read failure/, + ); + + // Restore before any further fs use so the snapshot read is trustworthy. + fs.readFileSync = origReadFileSync; + + const after = fs.readdirSync(os.tmpdir()).filter(n => n.startsWith('gsd-cmd-rewrites-')); + leaked = after.filter(n => !before.has(n)); + assert.deepStrictEqual(leaked, [], `tempDir not cleaned up on error: ${leaked.join(',')}`); + } finally { + // Idempotent restore — guard against early-throw paths above. + fs.readFileSync = origReadFileSync; + // Clean up the staged dir created for this test. + cleanup(stagedDir); + // Clean up any genuinely leaked gsd-cmd-rewrites-* dirs so the runner stays clean. + for (const n of leaked) { + cleanup(path.join(os.tmpdir(), n)); + } + } + }); +}); + +// --------------------------------------------------------------------------- +// Guard: runtime-artifact-layout no longer exports getInstallExports +// --------------------------------------------------------------------------- + +describe('layout module no longer exports getInstallExports', () => { + test('getInstallExports is not on the layout module export', () => { + process.env['GSD_TEST_MODE'] = '1'; + const layout = require('../gsd-core/bin/lib/runtime-artifact-layout.cjs'); + assert.equal( + typeof layout.getInstallExports, + 'undefined', + 'getInstallExports should have been removed from runtime-artifact-layout exports (ADR-1508 Phase 2)', + ); + }); +}); + +// --------------------------------------------------------------------------- +// DEFECT.GENERATIVE-FIX: single-owner reference-identity guard (#1511) +// Proves install.js binds to the conversion module's implementation, not a +// duplicate local copy. If these fail, a duplicate body was re-introduced. +// --------------------------------------------------------------------------- + +describe('single-owner reference-identity guard (ADR-1508 / #1511 Phase 2)', () => { + let install; + let conversionCjs; + before(() => { + process.env['GSD_TEST_MODE'] = '1'; + install = require('../bin/install.js'); + conversionCjs = require('../gsd-core/bin/lib/runtime-artifact-conversion.cjs'); + }); + + test('install.computePathPrefix === conversion._computePathPrefix (single implementation)', () => { + assert.strictEqual( + install.computePathPrefix, + conversionCjs._computePathPrefix, + 'install.js must bind computePathPrefix from conversion (not a duplicate body)', + ); + }); + + test('install.applyRuntimeContentRewritesInPlace === conversion.applyRuntimeContentRewritesInPlace (single walk loop)', () => { + assert.strictEqual( + install.applyRuntimeContentRewritesInPlace, + conversionCjs.applyRuntimeContentRewritesInPlace, + 'install.js must bind applyRuntimeContentRewritesInPlace from conversion (not a duplicate walk loop)', + ); + }); + + test('install.applyRuntimeContentRewritesForCommandsInPlace === conversion.applyRuntimeContentRewritesForCommandsInPlace (single copy+rewrite loop)', () => { + assert.strictEqual( + install.applyRuntimeContentRewritesForCommandsInPlace, + conversionCjs.applyRuntimeContentRewritesForCommandsInPlace, + 'install.js must bind applyRuntimeContentRewritesForCommandsInPlace from conversion (not a duplicate copy+rewrite loop)', + ); + }); + + test('install._applyRuntimeRewrites === conversion._applyRuntimeRewrites (single switch engine)', () => { + assert.strictEqual( + install._applyRuntimeRewrites, + conversionCjs._applyRuntimeRewrites, + 'install.js must bind _applyRuntimeRewrites from conversion (not a local shim)', + ); + }); + + // #1675 (ADR-1508): the augment converter family is single-sourced in the + // conversion module. install.js must re-bind (not re-define) these so there + // is exactly one body — the generative-drift hazard the dedup removes. + test('install.convertClaudeToAugmentMarkdown === conversion.convertClaudeToAugmentMarkdown (single converter)', () => { + assert.strictEqual( + install.convertClaudeToAugmentMarkdown, + conversionCjs.convertClaudeToAugmentMarkdown, + 'install.js must bind convertClaudeToAugmentMarkdown from conversion (not a duplicate body)', + ); + }); + + test('install.convertClaudeCommandToAugmentSkill === conversion.convertClaudeCommandToAugmentSkill (single converter)', () => { + assert.strictEqual( + install.convertClaudeCommandToAugmentSkill, + conversionCjs.convertClaudeCommandToAugmentSkill, + 'install.js must bind convertClaudeCommandToAugmentSkill from conversion (not a duplicate body)', + ); + }); + + test('install.convertClaudeAgentToAugmentAgent === conversion.convertClaudeAgentToAugmentAgent (single converter)', () => { + assert.strictEqual( + install.convertClaudeAgentToAugmentAgent, + conversionCjs.convertClaudeAgentToAugmentAgent, + 'install.js must bind convertClaudeAgentToAugmentAgent from conversion (not a duplicate body)', + ); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-2808-skill-hyphen-name.test.cjs — consolidation epic #1969 (B6 #1975) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-2808-skill-hyphen-name (consolidation epic #1969 B6 #1975)", () => { +// allow-test-rule: source-text-is-the-product (see #2808) +// 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:` (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:"). 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- (not gsd:). + * 3. No workflow contains a Skill(skill="gsd:") 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- (not gsd:)', () => { + 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:") 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('', 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-: ' + 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-/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'); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/fix-1920-installer-ships-capability-generators.test.cjs — consolidation epic #1969 (B6 #1975) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:fix-1920-installer-ships-capability-generators (consolidation epic #1969 B6 #1975)", () => { +'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 ">=.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); + } + }); +}); + }); +} diff --git a/tests/install-write-confinement.test.cjs b/tests/install-write-confinement.test.cjs index 546cbb605..f2417e058 100644 --- a/tests/install-write-confinement.test.cjs +++ b/tests/install-write-confinement.test.cjs @@ -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/` to installed + // `gsd-core/` 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/ 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/ 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/ 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/ 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}/ 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); + } + }); +}); + }); +} diff --git a/tests/install.test.cjs b/tests/install.test.cjs index aa3f89056..7db338019 100644 --- a/tests/install.test.cjs +++ b/tests/install.test.cjs @@ -5818,3 +5818,4418 @@ test('install.js tier-defaults object has exactly the same keys as manifest effo }); }); } + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-2256-model-overrides-transport.test.cjs — consolidation epic #1969 (B1 #1970) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-2256-model-overrides-transport (consolidation epic #1969 B1 #1970)", () => { +/** + * Regression tests for issue #2256 — per-agent model_overrides transport + * for Codex and OpenCode runtimes. + * + * The bug: model_overrides set in per-project `.planning/config.json` were + * never read by the Codex / OpenCode install paths, which only probed + * `~/.gsd/defaults.json`. As a result, the configured per-agent model was + * dropped and child agents inherited the runtime's default model. + * + * These tests lock in the fix: per-project overrides must be honored, and + * per-project keys must win over global when both are present. + */ + +process.env.GSD_TEST_MODE = '1'; + +const { test, describe, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); + +const isWindows = process.platform === 'win32'; + +const { + readGsdEffectiveModelOverrides, + generateCodexAgentToml, + convertClaudeToOpencodeFrontmatter, + getCodexSkillAdapterHeader, +} = require('../bin/install.js'); + +const { createTempDir, cleanup } = require('./helpers.cjs'); +const makeTmp = (prefix) => createTempDir(`gsd-2256-${prefix}-`); + +function writeJson(p, obj) { + fs.mkdirSync(path.dirname(p), { recursive: true }); + fs.writeFileSync(p, JSON.stringify(obj, null, 2)); +} + +describe('bug #2256 — readGsdEffectiveModelOverrides', () => { + let projectDir; + let homeDir; + let origHome; + let origUserProfile; + + beforeEach(() => { + projectDir = makeTmp('proj'); + homeDir = makeTmp('home'); + origHome = process.env.HOME; + // On Windows, os.homedir() reads USERPROFILE (not HOME). Tests that + // need to redirect ~ must override both — otherwise the SUT reads + // the real user's home and the fixture is invisible. + origUserProfile = process.env.USERPROFILE; + process.env.HOME = homeDir; + if (isWindows) process.env.USERPROFILE = homeDir; + }); + + afterEach(() => { + if (origHome === undefined) delete process.env.HOME; + else process.env.HOME = origHome; + if (isWindows) { + if (origUserProfile === undefined) delete process.env.USERPROFILE; + else process.env.USERPROFILE = origUserProfile; + } + cleanup(projectDir); + cleanup(homeDir); + }); + + test('returns null when neither source defines model_overrides', () => { + const result = readGsdEffectiveModelOverrides(projectDir); + assert.strictEqual(result, null); + }); + + test('reads overrides from ~/.gsd/defaults.json (global only)', () => { + writeJson(path.join(homeDir, '.gsd', 'defaults.json'), { + model_overrides: { 'gsd-codebase-mapper': 'gpt-5-mini' }, + }); + const result = readGsdEffectiveModelOverrides(projectDir); + assert.deepStrictEqual(result, { 'gsd-codebase-mapper': 'gpt-5-mini' }); + }); + + test('reads overrides from per-project .planning/config.json', () => { + writeJson(path.join(projectDir, '.planning', 'config.json'), { + model_overrides: { 'gsd-codebase-mapper': 'claude-haiku-4-5' }, + }); + const result = readGsdEffectiveModelOverrides(projectDir); + assert.deepStrictEqual(result, { 'gsd-codebase-mapper': 'claude-haiku-4-5' }); + }); + + test('per-project overrides win over global on conflict', () => { + writeJson(path.join(homeDir, '.gsd', 'defaults.json'), { + model_overrides: { 'gsd-codebase-mapper': 'global-model', 'gsd-planner': 'opus' }, + }); + writeJson(path.join(projectDir, '.planning', 'config.json'), { + model_overrides: { 'gsd-codebase-mapper': 'project-model' }, + }); + const result = readGsdEffectiveModelOverrides(projectDir); + // Per-project wins on conflict; non-conflicting global keys are preserved. + assert.deepStrictEqual(result, { + 'gsd-codebase-mapper': 'project-model', + 'gsd-planner': 'opus', + }); + }); + + test('walks up from nested targetDir to find .planning/', () => { + writeJson(path.join(projectDir, '.planning', 'config.json'), { + model_overrides: { 'gsd-planner': 'project-opus' }, + }); + const nested = path.join(projectDir, '.codex'); + fs.mkdirSync(nested, { recursive: true }); + const result = readGsdEffectiveModelOverrides(nested); + assert.deepStrictEqual(result, { 'gsd-planner': 'project-opus' }); + }); +}); + +describe('bug #2256 — Codex adapter embeds per-project override', () => { + const agentContent = `---\nname: gsd-codebase-mapper\ndescription: Maps codebase\n---\n\nbody\n`; + + test('generateCodexAgentToml embeds model when override provided', () => { + const toml = generateCodexAgentToml( + 'gsd-codebase-mapper', + agentContent, + { 'gsd-codebase-mapper': 'gpt-5-mini' }, + ); + assert.match(toml, /^model = "gpt-5-mini"$/m); + }); + + test('generateCodexAgentToml omits model when no override', () => { + const toml = generateCodexAgentToml('gsd-codebase-mapper', agentContent, null); + assert.doesNotMatch(toml, /^model\s*=/m); + }); +}); + +describe('bug #2256 — OpenCode adapter embeds per-project override', () => { + test('convertClaudeToOpencodeFrontmatter embeds model on agent frontmatter', () => { + const input = `---\nname: gsd-codebase-mapper\ndescription: Maps codebase\n---\n\nbody\n`; + const out = convertClaudeToOpencodeFrontmatter(input, { + isAgent: true, + modelOverride: 'claude-haiku-4-5', + }); + assert.match(out, /^model: claude-haiku-4-5$/m); + assert.match(out, /^mode: subagent$/m); + }); + + test('convertClaudeToOpencodeFrontmatter omits model when override absent', () => { + const input = `---\nname: gsd-codebase-mapper\ndescription: Maps codebase\n---\n\nbody\n`; + const out = convertClaudeToOpencodeFrontmatter(input, { isAgent: true, modelOverride: null }); + assert.doesNotMatch(out, /^model:/m); + }); +}); + +describe('bug #2256 — Codex skill adapter header documents transport', () => { + test('Task(model=...) line no longer says "omit" without explanation', () => { + const header = getCodexSkillAdapterHeader('gsd-plan-phase'); + // Header must mention that per-agent model_overrides are embedded in agent + // TOML so spawn_agent picks them up automatically — the old text said + // "Codex uses per-role config, not inline model selection" which left + // users thinking their model_overrides were silently ignored. + assert.match(header, /model_overrides/); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-3181-node-cellar-path.test.cjs — consolidation epic #1969 (B1 #1970) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-3181-node-cellar-path (consolidation epic #1969 B1 #1970)", () => { +'use strict'; + +process.env.GSD_TEST_MODE = '1'; + +/** + * Bug #3181: `resolveNodeRunner()` bakes versioned Homebrew Cellar paths + * (e.g. `/usr/local/Cellar/node/25.8.1/bin/node`) into hook commands in + * `~/.claude/settings.json`. After `brew upgrade node` the Cellar binary + * fails with `dyld: Library not loaded` because shared libraries have + * changed SOVERSION. + * + * Fix: prefer the stable Homebrew symlinks (`/usr/local/bin/node` for Intel + * Macs, `/opt/homebrew/bin/node` for Apple Silicon) when a Cellar path is + * detected. Non-Homebrew paths (NVM, system node, Windows, etc.) are + * returned unchanged. + * + * Also: `rewriteLegacyManagedNodeHookCommands()` must normalize Cellar paths + * baked into existing hook commands so reinstall doesn't re-bake them. + * + * All assertions go against exported function return values — no source-grep. + */ + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); +const path = require('node:path'); + +const INSTALL = require(path.join(__dirname, '..', 'bin', 'install.js')); +const { normalizeNodePath, resolveNodeRunner, rewriteLegacyManagedNodeHookCommands } = INSTALL; + +// ─── normalizeNodePath ──────────────────────────────────────────────────────── + +describe('Bug #3181: normalizeNodePath — exported as a function', () => { + test('normalizeNodePath is exported', () => { + assert.equal(typeof normalizeNodePath, 'function'); + }); +}); + +describe('Bug #3181: normalizeNodePath — Intel Homebrew Cellar paths → /usr/local/bin/node', () => { + test('simple versioned Intel Cellar path', () => { + const result = normalizeNodePath('/usr/local/Cellar/node/25.8.1/bin/node'); + assert.equal(result, '/usr/local/bin/node'); + }); + + test('Intel Cellar path with long semver', () => { + const result = normalizeNodePath('/usr/local/Cellar/node/20.11.0/bin/node'); + assert.equal(result, '/usr/local/bin/node'); + }); + + test('Intel Cellar path with prerelease version segment', () => { + const result = normalizeNodePath('/usr/local/Cellar/node/22.0.0-rc.1/bin/node'); + assert.equal(result, '/usr/local/bin/node'); + }); + + test('Intel versioned formula Cellar path (node@20) maps to stable symlink', () => { + const result = normalizeNodePath('/usr/local/Cellar/node@20/20.11.0/bin/node'); + assert.equal(result, '/usr/local/bin/node'); + }); +}); + +describe('Bug #3181: normalizeNodePath — Apple Silicon Homebrew Cellar paths → /opt/homebrew/bin/node', () => { + test('simple versioned Apple Silicon Cellar path', () => { + const result = normalizeNodePath('/opt/homebrew/Cellar/node/25.8.1/bin/node'); + assert.equal(result, '/opt/homebrew/bin/node'); + }); + + test('Apple Silicon Cellar path with another version', () => { + const result = normalizeNodePath('/opt/homebrew/Cellar/node/18.20.4/bin/node'); + assert.equal(result, '/opt/homebrew/bin/node'); + }); + + test('Apple Silicon versioned formula Cellar path (node@18) maps to stable symlink', () => { + const result = normalizeNodePath('/opt/homebrew/Cellar/node@18/18.20.4/bin/node'); + assert.equal(result, '/opt/homebrew/bin/node'); + }); +}); + +describe('Bug #3181: normalizeNodePath — non-Homebrew paths are returned unchanged', () => { + test('NVM path is unchanged', () => { + const nvm = '/Users/dev/.nvm/versions/node/v20.11.0/bin/node'; + assert.equal(normalizeNodePath(nvm), nvm); + }); + + test('already-stable Intel Homebrew symlink is unchanged', () => { + assert.equal(normalizeNodePath('/usr/local/bin/node'), '/usr/local/bin/node'); + }); + + test('already-stable Apple Silicon Homebrew symlink is unchanged', () => { + assert.equal(normalizeNodePath('/opt/homebrew/bin/node'), '/opt/homebrew/bin/node'); + }); + + test('system node (/usr/bin/node) is unchanged', () => { + assert.equal(normalizeNodePath('/usr/bin/node'), '/usr/bin/node'); + }); + + test('Windows path is unchanged', () => { + const win = 'C:\\Program Files\\nodejs\\node.exe'; + assert.equal(normalizeNodePath(win), win); + }); + + test('empty string is returned as-is', () => { + assert.equal(normalizeNodePath(''), ''); + }); + + test('null is returned as-is', () => { + assert.equal(normalizeNodePath(null), null); + }); +}); + +// ─── resolveNodeRunner ──────────────────────────────────────────────────────── + +describe('Bug #3181: resolveNodeRunner — maps Cellar execPath to stable symlink', () => { + test('Intel Cellar execPath → stable symlink quoted token', () => { + const orig = process.execPath; + try { + Object.defineProperty(process, 'execPath', { + value: '/usr/local/Cellar/node/25.8.1/bin/node', + configurable: true, + }); + const runner = resolveNodeRunner(); + assert.equal(runner, '"/usr/local/bin/node"', + `expected stable Intel symlink, got: ${runner}`); + } finally { + Object.defineProperty(process, 'execPath', { value: orig, configurable: true }); + } + }); + + test('Apple Silicon Cellar execPath → stable symlink quoted token', () => { + const orig = process.execPath; + try { + Object.defineProperty(process, 'execPath', { + value: '/opt/homebrew/Cellar/node/25.8.1/bin/node', + configurable: true, + }); + const runner = resolveNodeRunner(); + assert.equal(runner, '"/opt/homebrew/bin/node"', + `expected stable Apple Silicon symlink, got: ${runner}`); + } finally { + Object.defineProperty(process, 'execPath', { value: orig, configurable: true }); + } + }); + + test('non-Homebrew execPath is returned as a quoted absolute path unchanged', () => { + const orig = process.execPath; + const nvmPath = '/Users/dev/.nvm/versions/node/v20.11.0/bin/node'; + try { + Object.defineProperty(process, 'execPath', { value: nvmPath, configurable: true }); + const runner = resolveNodeRunner(); + assert.equal(runner, JSON.stringify(nvmPath)); + } finally { + Object.defineProperty(process, 'execPath', { value: orig, configurable: true }); + } + }); + + test('returns null when execPath is empty (existing null-guard is preserved)', () => { + const orig = process.execPath; + try { + Object.defineProperty(process, 'execPath', { value: '', configurable: true }); + assert.equal(resolveNodeRunner(), null); + } finally { + Object.defineProperty(process, 'execPath', { value: orig, configurable: true }); + } + }); +}); + +// ─── rewriteLegacyManagedNodeHookCommands — Cellar runner rewrite ───────────── + +describe('Bug #3181: rewriteLegacyManagedNodeHookCommands — rewrites baked Cellar runner to stable symlink', () => { + test('Intel Cellar runner in a managed hook is rewritten to the stable symlink', () => { + const settings = { + hooks: { + SessionStart: [{ + hooks: [{ + type: 'command', + command: '"/usr/local/Cellar/node/25.8.1/bin/node" "/Users/x/.gemini/hooks/gsd-check-update.js"', + }], + }], + }, + }; + const runner = '"/usr/local/bin/node"'; + const changed = rewriteLegacyManagedNodeHookCommands(settings, runner); + assert.equal(changed, true, 'expected rewrite to occur'); + assert.equal( + settings.hooks.SessionStart[0].hooks[0].command, + '"/usr/local/bin/node" "/Users/x/.gemini/hooks/gsd-check-update.js"', + ); + }); + + test('Apple Silicon Cellar runner in a managed hook is rewritten to the stable symlink', () => { + const settings = { + hooks: { + SessionStart: [{ + hooks: [{ + type: 'command', + command: '"/opt/homebrew/Cellar/node/25.8.1/bin/node" "/Users/x/.gemini/hooks/gsd-check-update.js"', + }], + }], + }, + }; + const runner = '"/opt/homebrew/bin/node"'; + const changed = rewriteLegacyManagedNodeHookCommands(settings, runner); + assert.equal(changed, true, 'expected rewrite to occur'); + assert.equal( + settings.hooks.SessionStart[0].hooks[0].command, + '"/opt/homebrew/bin/node" "/Users/x/.gemini/hooks/gsd-check-update.js"', + ); + }); + + test('a hook already using the stable runner is NOT rewritten (no churn)', () => { + const settings = { + hooks: { + SessionStart: [{ + hooks: [{ + type: 'command', + command: '"/usr/local/bin/node" "/Users/x/.gemini/hooks/gsd-check-update.js"', + }], + }], + }, + }; + const runner = '"/usr/local/bin/node"'; + const before = settings.hooks.SessionStart[0].hooks[0].command; + const changed = rewriteLegacyManagedNodeHookCommands(settings, runner); + assert.equal(changed, false, 'already-stable entry must not be touched'); + assert.equal(settings.hooks.SessionStart[0].hooks[0].command, before); + }); + + test('a user hook using a Cellar runner but an unmanaged filename is NOT rewritten', () => { + const settings = { + hooks: { + SessionStart: [{ + hooks: [{ + type: 'command', + command: '"/usr/local/Cellar/node/25.8.1/bin/node" "/Users/x/my-custom-hook.js"', + }], + }], + }, + }; + const runner = '"/usr/local/bin/node"'; + const before = settings.hooks.SessionStart[0].hooks[0].command; + const changed = rewriteLegacyManagedNodeHookCommands(settings, runner); + assert.equal(changed, false, 'unmanaged hooks with Cellar runner must not be touched'); + assert.equal(settings.hooks.SessionStart[0].hooks[0].command, before); + }); + + // Existing bare-node rewrite still works alongside the new Cellar rewrite + test('bare `node` managed hook is still rewritten (existing #2979 behaviour preserved)', () => { + const settings = { + hooks: { + SessionStart: [{ + hooks: [{ + type: 'command', + command: 'node "/Users/x/.gemini/hooks/gsd-check-update.js"', + }], + }], + }, + }; + const runner = '"/usr/local/bin/node"'; + const changed = rewriteLegacyManagedNodeHookCommands(settings, runner); + assert.equal(changed, true); + assert.equal( + settings.hooks.SessionStart[0].hooks[0].command, + '"/usr/local/bin/node" "/Users/x/.gemini/hooks/gsd-check-update.js"', + ); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-977-fnm-multishell-path.test.cjs — consolidation epic #1969 (B1 #1970) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-977-fnm-multishell-path (consolidation epic #1969 B1 #1970)", () => { +'use strict'; + +process.env.GSD_TEST_MODE = '1'; + +/** + * Bug #977: `resolveNodeRunner()` bakes an ephemeral fnm multishell shim path + * (e.g. `C:/Users/u/AppData/Local/fnm_multishells/_/node.exe`) into + * managed `.js` hook commands. fnm cleans up these per-shell-session directories + * when the shell exits, so the captured path later points at nothing — every + * managed hook fails to spawn until reinstall. + * + * Fix: when `normalizeNodePath` detects a path matching the fnm multishell + * directory pattern (`fnm_multishells//node(\.exe)?$`), it probes a stable + * alias path derived from `FNM_DIR` or `APPDATA` env vars (with injected + * `existsSync` for testability) and returns the first that exists. Falls back to + * the raw execPath if no stable alias is found. + * + * All assertions go against exported function return values — no source-grep. + */ + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); +const path = require('node:path'); + +const INSTALL = require(path.join(__dirname, '..', 'bin', 'install.js')); +const { normalizeNodePath, resolveNodeRunner } = INSTALL; + +// ─── Synthetic paths used across tests ─────────────────────────────────────── + +const EPHEMERAL_FNM_WIN = 'C:/Users/u/AppData/Local/fnm_multishells/15600_1781041703752/node.exe'; +const EPHEMERAL_FNM_WIN_BACKSLASH = 'C:\\Users\\u\\AppData\\Local\\fnm_multishells\\15600_1781041703752\\node.exe'; +const FNM_DIR_WIN = 'C:/Users/u/AppData/Roaming/fnm'; +const APPDATA_WIN = 'C:/Users/u/AppData/Roaming'; +const STABLE_FNM_DIR_NODE = `${FNM_DIR_WIN}/aliases/default/node.exe`; +const STABLE_APPDATA_NODE = `${APPDATA_WIN}/fnm/aliases/default/node.exe`; + +// ─── normalizeNodePath — fnm multishell ephemeral path → stable alias ──────── + +describe('Bug #977: normalizeNodePath — fnm multishell path with FNM_DIR → stable alias', () => { + test('forward-slash Windows ephemeral path + FNM_DIR set + alias exists → stable FNM_DIR alias', () => { + const result = normalizeNodePath(EPHEMERAL_FNM_WIN, { + env: { FNM_DIR: FNM_DIR_WIN }, + existsSync: p => p === STABLE_FNM_DIR_NODE, + }); + assert.equal( + result, + STABLE_FNM_DIR_NODE, + `expected stable FNM_DIR alias, got: ${result}`, + ); + }); + + test('backslash Windows ephemeral path + FNM_DIR set + alias exists → stable FNM_DIR alias', () => { + const result = normalizeNodePath(EPHEMERAL_FNM_WIN_BACKSLASH, { + env: { FNM_DIR: FNM_DIR_WIN }, + existsSync: p => p === STABLE_FNM_DIR_NODE, + }); + assert.equal( + result, + STABLE_FNM_DIR_NODE, + `expected stable FNM_DIR alias, got: ${result}`, + ); + }); + + test('FNM_DIR alias does not exist → falls through to APPDATA alias → returns APPDATA alias', () => { + const result = normalizeNodePath(EPHEMERAL_FNM_WIN, { + env: { FNM_DIR: FNM_DIR_WIN, APPDATA: APPDATA_WIN }, + existsSync: p => p === STABLE_APPDATA_NODE, // FNM_DIR alias absent, APPDATA alias present + }); + assert.equal( + result, + STABLE_APPDATA_NODE, + `expected stable APPDATA alias, got: ${result}`, + ); + }); + + test('no alias exists → returns raw execPath unchanged (graceful fallback)', () => { + const result = normalizeNodePath(EPHEMERAL_FNM_WIN, { + env: { FNM_DIR: FNM_DIR_WIN, APPDATA: APPDATA_WIN }, + existsSync: () => false, // nothing exists + }); + assert.equal( + result, + EPHEMERAL_FNM_WIN, + `expected raw execPath fallback, got: ${result}`, + ); + }); + + test('no FNM_DIR or APPDATA in env → returns raw execPath unchanged', () => { + const result = normalizeNodePath(EPHEMERAL_FNM_WIN, { + env: {}, + existsSync: () => false, + }); + assert.equal( + result, + EPHEMERAL_FNM_WIN, + `expected raw execPath fallback, got: ${result}`, + ); + }); +}); + +// ─── normalizeNodePath — non-fnm paths are NOT affected by the new branch ──── + +describe('Bug #977: normalizeNodePath — non-fnm paths are unaffected (no regression to existing behavior)', () => { + test('NVM path is unchanged', () => { + const nvm = '/Users/dev/.nvm/versions/node/v20.11.0/bin/node'; + assert.equal(normalizeNodePath(nvm), nvm); + }); + + test('Intel Homebrew Cellar path still maps to stable symlink', () => { + assert.equal( + normalizeNodePath('/usr/local/Cellar/node/25.8.1/bin/node'), + '/usr/local/bin/node', + ); + }); + + test('Apple Silicon Homebrew Cellar path still maps to stable symlink', () => { + assert.equal( + normalizeNodePath('/opt/homebrew/Cellar/node/25.8.1/bin/node'), + '/opt/homebrew/bin/node', + ); + }); + + test('regular Windows nodejs path is unchanged', () => { + const win = 'C:\\Program Files\\nodejs\\node.exe'; + assert.equal(normalizeNodePath(win), win); + }); + + test('empty string is returned as-is', () => { + assert.equal(normalizeNodePath(''), ''); + }); + + test('null is returned as-is', () => { + assert.equal(normalizeNodePath(null), null); + }); +}); + +// ─── normalizeNodePath — already-stable fnm alias path is not re-processed ─── + +describe('Bug #977: normalizeNodePath — already-stable fnm alias path passes through unchanged', () => { + test('stable FNM_DIR alias path is returned as-is', () => { + assert.equal( + normalizeNodePath(STABLE_FNM_DIR_NODE), + STABLE_FNM_DIR_NODE, + ); + }); +}); + +// ─── normalizeNodePath — false-positive guard: non-numeric id must NOT remap ── + +describe('Bug #977: normalizeNodePath — non-ephemeral fnm_multishells path is not remapped', () => { + test('non-numeric id segment (e.g. custom-dir) returns raw execPath unchanged even when alias exists', () => { + const nonEphemeral = 'C:/Users/u/AppData/Local/fnm_multishells/custom-dir/node.exe'; + const stableAlias = 'C:/Users/u/AppData/Roaming/fnm/aliases/default/node.exe'; + const result = normalizeNodePath(nonEphemeral, { + env: { FNM_DIR: 'C:/Users/u/AppData/Roaming/fnm' }, + // existsSync returns true for the alias to prove the regex — not the existsSync — is the guard + existsSync: p => p === stableAlias, + }); + assert.equal( + result, + nonEphemeral, + `expected raw execPath (non-ephemeral id must not be remapped), got: ${result}`, + ); + }); +}); + +// ─── resolveNodeRunner — opts pass-through ──────────────────────────────────── + +describe('Bug #977: resolveNodeRunner — passes opts through to normalizeNodePath', () => { + test('fnm multishell execPath is resolved to stable alias via injected opts', () => { + const orig = process.execPath; + try { + Object.defineProperty(process, 'execPath', { + value: EPHEMERAL_FNM_WIN, + configurable: true, + }); + const runner = resolveNodeRunner({ + env: { FNM_DIR: FNM_DIR_WIN }, + existsSync: p => p === STABLE_FNM_DIR_NODE, + }); + assert.equal( + runner, + JSON.stringify(STABLE_FNM_DIR_NODE), + `expected stable FNM_DIR alias quoted, got: ${runner}`, + ); + } finally { + Object.defineProperty(process, 'execPath', { value: orig, configurable: true }); + } + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-2979-hook-absolute-node.test.cjs — consolidation epic #1969 (B1 #1970) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-2979-hook-absolute-node (consolidation epic #1969 B1 #1970)", () => { +'use strict'; + +process.env.GSD_TEST_MODE = '1'; + +/** + * Bug #2979: Managed JS hooks fail in GUI/minimal-PATH runtimes because + * the installer emits bare `node`. + * + * Reporter evidence: in a stripped PATH like /usr/bin:/bin:/usr/sbin:/sbin + * (the default for Finder-launched/Antigravity-spawned processes on macOS), + * `node` is not resolvable. Hook commands like + * `node "/.gemini/hooks/gsd-check-update.js"` + * fail with `/bin/sh: node: command not found` (exit 127). + * + * Fix: emit the absolute node path (`process.execPath`, the binary + * running the installer itself) as the runner. Forward-slash-normalized + * and double-quoted so it works on POSIX and Windows. + * + * This test exercises the public buildHookCommand surface plus the + * resolveNodeRunner helper, asserting on structured records: + * - the runner field is an absolute path (not bare 'node') + * - it ends with /node or \\node (or .exe on Windows simulation) + * - .sh hooks still use bare 'bash' (PATH-resolved; portable across + * distros that don't ship /bin/bash, like NixOS) + * + * No source-grep on install.js content — assertions go against the + * value returned by the exported function and the parsed structure of + * the emitted hook command (split into runner + args). + */ + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); +const path = require('node:path'); + +const INSTALL = require(path.join(__dirname, '..', 'bin', 'install.js')); +const { buildHookCommand, resolveNodeRunner } = INSTALL; + +/** + * Parse a hook command string into { runner, hookPath } structured + * record. The shape is ` ""` where may itself + * be a quoted absolute path (containing spaces), so we split on the + * trailing quoted-path token rather than the first space. + */ +function parseHookCommand(cmd) { + // Trailing token: a double-quoted string ending the command. + const m = cmd.match(/^(.+?)\s+"([^"]+)"\s*$/); + if (!m) { + return { runner: null, hookPath: null, raw: cmd }; + } + return { runner: m[1], hookPath: m[2], raw: cmd }; +} + +describe('Bug #2979: resolveNodeRunner returns absolute, quoted, forward-slash node path', () => { + test('exported as a function', () => { + assert.equal(typeof resolveNodeRunner, 'function'); + }); + + test('returns a double-quoted absolute path', () => { + const runner = resolveNodeRunner(); + assert.ok(runner.startsWith('"'), `expected leading double-quote, got: ${runner}`); + assert.ok(runner.endsWith('"'), `expected trailing double-quote, got: ${runner}`); + const inner = runner.slice(1, -1); + assert.ok(path.isAbsolute(inner.replace(/\//g, path.sep)), `expected absolute path, got: ${inner}`); + }); + + test('uses forward slashes (Windows-safe, matches buildHookCommand convention)', () => { + const runner = resolveNodeRunner(); + assert.ok(!runner.includes('\\'), `expected forward slashes, got: ${runner}`); + }); + + test('points at a node binary (basename starts with "node")', () => { + const runner = resolveNodeRunner(); + const inner = runner.slice(1, -1); + const base = path.posix.basename(inner); + assert.ok(/^node(\.exe)?$/i.test(base), `expected basename node or node.exe, got: ${base}`); + }); +}); + +describe('Bug #2979: buildHookCommand for .js hooks emits absolute node runner', () => { + test('global install: .js hook uses absolute node path, not bare "node"', () => { + const cmd = buildHookCommand('/tmp/.claude', 'gsd-check-update.js'); + const parsed = parseHookCommand(cmd); + assert.notEqual(parsed.runner, null, `failed to parse: ${cmd}`); + assert.notEqual(parsed.runner, 'node', `must not emit bare node (#2979): ${cmd}`); + // The runner should be a quoted absolute path. + assert.ok(parsed.runner.startsWith('"') && parsed.runner.endsWith('"'), + `runner must be quoted absolute path, got: ${parsed.runner}`); + }); + + test('global install: .js hook command parses with hookPath at expected location', () => { + const cmd = buildHookCommand('/tmp/.gemini', 'gsd-statusline.js'); + const parsed = parseHookCommand(cmd); + assert.equal(parsed.hookPath, '/tmp/.gemini/hooks/gsd-statusline.js'); + }); + + test('portableHooks global install: .js hook still uses absolute node (only the path is $HOME-relative)', () => { + const home = require('node:os').homedir().replace(/\\/g, '/'); + const configDir = home + '/.gemini'; + const cmd = buildHookCommand(configDir, 'gsd-check-update.js', { portableHooks: true }); + const parsed = parseHookCommand(cmd); + assert.notEqual(parsed.runner, 'node', `portableHooks must also use absolute node (#2979): ${cmd}`); + assert.equal(parsed.hookPath, '$HOME/.gemini/hooks/gsd-check-update.js'); + }); +}); + +describe('Bug #3362 / #3413: Windows hook commands are runtime-aware', () => { + test('Gemini global install: .js hook command starts with & so quoted runners execute in PowerShell', () => { + const cmd = buildHookCommand('C:/Program Files/Gemini/.gemini', 'gsd-check-update.js', { + platform: 'win32', + runtime: 'gemini', + }); + assert.ok(cmd.startsWith('& '), `Gemini PowerShell commands need call operator, got: ${cmd}`); + assert.ok(cmd.includes('"C:/Program Files/Gemini/.gemini/hooks/gsd-check-update.js"')); + }); + + test('Gemini portable install: .js hook command also uses & on Windows PowerShell', () => { + const home = require('node:os').homedir().replace(/\\/g, '/'); + const cmd = buildHookCommand(`${home}/.gemini`, 'gsd-check-update.js', { + portableHooks: true, + platform: 'win32', + runtime: 'gemini', + }); + assert.ok(cmd.startsWith('& '), `Gemini PowerShell commands need call operator, got: ${cmd}`); + assert.equal(parseHookCommand(cmd.slice(2)).hookPath, '$HOME/.gemini/hooks/gsd-check-update.js'); + }); + + test('Claude global install: .js hook command stays shell-neutral on Windows Git Bash', () => { + const cmd = buildHookCommand('C:/Users/me/.claude', 'gsd-check-update.js', { + platform: 'win32', + runtime: 'claude', + }); + assert.ok(!cmd.startsWith('& '), `Claude hook command must not use PowerShell call operator: ${cmd}`); + assert.equal(parseHookCommand(cmd).hookPath, 'C:/Users/me/.claude/hooks/gsd-check-update.js'); + }); + + test('Windows .js hook with no runtime stays shell-neutral', () => { + const cmd = buildHookCommand('C:/Users/me/.claude', 'gsd-check-update.js', { + platform: 'win32', + }); + assert.ok(!cmd.startsWith('& '), `Missing runtime must not imply PowerShell syntax: ${cmd}`); + assert.equal(parseHookCommand(cmd).hookPath, 'C:/Users/me/.claude/hooks/gsd-check-update.js'); + }); + + test('Gemini runtime on non-Windows platform does not get PowerShell syntax', () => { + const cmd = buildHookCommand('/home/me/.claude', 'gsd-check-update.js', { + platform: 'linux', + runtime: 'gemini', + }); + assert.ok(!cmd.startsWith('& '), `Non-Windows Gemini hook must stay shell-neutral: ${cmd}`); + assert.equal(parseHookCommand(cmd).hookPath, '/home/me/.claude/hooks/gsd-check-update.js'); + }); +}); + +describe('Bug #2979: buildHookCommand for .sh hooks still uses bare "bash" (POSIX std PATH always has /bin)', () => { + test('.sh hook runner is exactly "bash" — bash is in /usr/bin:/bin and resolves under minimal PATH', () => { + const cmd = buildHookCommand('/tmp/.claude', 'gsd-session-state.sh', { platform: 'linux' }); + const parsed = parseHookCommand(cmd); + assert.equal(parsed.runner, 'bash'); + }); + + test('Windows .sh hook uses resolved Git Bash path instead of bare bash (#3393)', () => { + const cmd = buildHookCommand('C:/Users/me/.codex', 'gsd-validate-commit.sh', { + platform: 'win32', + env: { ProgramFiles: 'C:\\Program Files' }, + existsSync: (candidate) => candidate === 'C:\\Program Files\\Git\\bin\\bash.exe', + }); + assert.equal( + cmd, + '"C:/Program Files/Git/bin/bash.exe" "C:/Users/me/.codex/hooks/gsd-validate-commit.sh"', + ); + }); + + test('Windows .sh hook returns null when no supported Bash runner is found (#3393)', () => { + const cmd = buildHookCommand('C:/Users/me/.codex', 'gsd-phase-boundary.sh', { + platform: 'win32', + env: {}, + existsSync: () => false, + }); + assert.equal(cmd, null); + }); + + test('Windows Claude .sh hook omits explicit bash.exe wrapper (#166)', () => { + const cmd = buildHookCommand('C:/Users/me/.claude', 'gsd-session-state.sh', { + platform: 'win32', + runtime: 'claude', + env: { ProgramFiles: 'C:\\Program Files' }, + existsSync: (candidate) => candidate === 'C:\\Program Files\\Git\\bin\\bash.exe', + }); + assert.equal( + cmd, + '"C:/Users/me/.claude/hooks/gsd-session-state.sh"', + 'Claude win32 .sh hooks should serialize as script-only commands' + ); + }); +}); + +// ─── #3002 CR follow-up: legacy-bare-node migration ───────────────────────── + +const { rewriteLegacyManagedNodeHookCommands } = INSTALL; + +describe('Bug #2979 (#3002 CR): rewriteLegacyManagedNodeHookCommands rewrites bare-node managed hooks on reinstall', () => { + test('exported as a function', () => { + assert.equal(typeof rewriteLegacyManagedNodeHookCommands, 'function'); + }); + + test('rewrites a managed hook entry that uses bare `node ` to the absolute runner', () => { + const settings = { + hooks: { + SessionStart: [{ + hooks: [ + { type: 'command', command: 'node "/Users/x/.gemini/hooks/gsd-check-update.js"' }, + ], + }], + }, + }; + const runner = '"/usr/local/bin/node"'; + const changed = rewriteLegacyManagedNodeHookCommands(settings, runner); + assert.equal(changed, true); + assert.equal( + settings.hooks.SessionStart[0].hooks[0].command, + '"/usr/local/bin/node" "/Users/x/.gemini/hooks/gsd-check-update.js"', + ); + }); + + test('does NOT touch entries that already use a quoted absolute runner', () => { + const settings = { + hooks: { + SessionStart: [{ + hooks: [{ type: 'command', command: '"/usr/local/bin/node" "/x/hooks/gsd-statusline.js"' }], + }], + }, + }; + const runner = '"/usr/local/bin/node"'; + const before = settings.hooks.SessionStart[0].hooks[0].command; + const changed = rewriteLegacyManagedNodeHookCommands(settings, runner); + assert.equal(changed, false); + assert.equal(settings.hooks.SessionStart[0].hooks[0].command, before); + }); + + test('Gemini on Windows adds PowerShell call operator to existing quoted managed hooks', () => { + const settings = { + hooks: { + SessionStart: [{ + hooks: [{ type: 'command', command: '"/usr/local/bin/node" "C:/Program Files/Gemini/.gemini/hooks/gsd-check-update.js"' }], + }], + }, + }; + const runner = '"/usr/local/bin/node"'; + const changed = rewriteLegacyManagedNodeHookCommands(settings, runner, { platform: 'win32', runtime: 'gemini' }); + assert.equal(changed, true); + assert.equal( + settings.hooks.SessionStart[0].hooks[0].command, + '& "/usr/local/bin/node" "C:/Program Files/Gemini/.gemini/hooks/gsd-check-update.js"', + ); + }); + + test('Gemini on Windows does NOT double-prefix managed hooks that already use the PowerShell call operator', () => { + const settings = { + hooks: { + SessionStart: [{ + hooks: [{ type: 'command', command: '& "/usr/local/bin/node" "C:/Program Files/Gemini/.gemini/hooks/gsd-check-update.js"' }], + }], + }, + }; + const runner = '"/usr/local/bin/node"'; + const before = settings.hooks.SessionStart[0].hooks[0].command; + const changed = rewriteLegacyManagedNodeHookCommands(settings, runner, { platform: 'win32', runtime: 'gemini' }); + assert.equal(changed, false); + assert.equal(settings.hooks.SessionStart[0].hooks[0].command, before); + }); + + test('Gemini on Windows rewrites PowerShell bare-node managed hooks to absolute runner without dropping &', () => { + const settings = { + hooks: { + SessionStart: [{ + hooks: [{ type: 'command', command: '& node "C:/Users/me/.gemini/hooks/gsd-check-update.js"' }], + }], + }, + }; + const runner = '"/usr/local/bin/node"'; + const changed = rewriteLegacyManagedNodeHookCommands(settings, runner, { platform: 'win32', runtime: 'gemini' }); + assert.equal(changed, true); + assert.equal( + settings.hooks.SessionStart[0].hooks[0].command, + '& "/usr/local/bin/node" "C:/Users/me/.gemini/hooks/gsd-check-update.js"', + ); + }); + + test('Claude on Windows strips stale PowerShell prefix from managed hooks on reinstall (#3413)', () => { + const settings = { + hooks: { + SessionStart: [{ + hooks: [{ type: 'command', command: '& "/usr/local/bin/node" "C:/Users/me/.claude/hooks/gsd-check-update.js"' }], + }], + }, + }; + const runner = '"/usr/local/bin/node"'; + const changed = rewriteLegacyManagedNodeHookCommands(settings, runner, { platform: 'win32', runtime: 'claude' }); + assert.equal(changed, true); + assert.equal( + settings.hooks.SessionStart[0].hooks[0].command, + '"/usr/local/bin/node" "C:/Users/me/.claude/hooks/gsd-check-update.js"', + ); + }); + + test('does NOT touch user-authored bare-node hooks (filename not in managed allowlist)', () => { + const settings = { + hooks: { + SessionStart: [{ + hooks: [{ type: 'command', command: 'node /home/me/my-custom-hook.js' }], + }], + }, + }; + const runner = '"/usr/local/bin/node"'; + const before = settings.hooks.SessionStart[0].hooks[0].command; + const changed = rewriteLegacyManagedNodeHookCommands(settings, runner); + assert.equal(changed, false); + assert.equal(settings.hooks.SessionStart[0].hooks[0].command, before); + }); + + test('does NOT touch .sh hooks (they correctly use bare bash)', () => { + const settings = { + hooks: { + SessionStart: [{ + hooks: [{ type: 'command', command: 'bash "/x/hooks/gsd-session-state.sh"' }], + }], + }, + }; + const runner = '"/usr/local/bin/node"'; + const changed = rewriteLegacyManagedNodeHookCommands(settings, runner); + assert.equal(changed, false); + }); + + test('is a no-op when absoluteRunner is null (resolveNodeRunner failed)', () => { + const settings = { + hooks: { + SessionStart: [{ + hooks: [{ type: 'command', command: 'node "/x/hooks/gsd-check-update.js"' }], + }], + }, + }; + const before = settings.hooks.SessionStart[0].hooks[0].command; + const changed = rewriteLegacyManagedNodeHookCommands(settings, null); + assert.equal(changed, false); + assert.equal(settings.hooks.SessionStart[0].hooks[0].command, before); + }); + + // #3002 CR: substring containment was a false-positive vector. + // User-authored hooks whose path happened to CONTAIN a managed filename + // as a substring would get unconditionally rewritten with the GSD runner. + // The fix matches by basename equality. + test('does NOT rewrite a user hook whose path contains a managed filename as a substring', () => { + const settings = { + hooks: { + SessionStart: [{ + hooks: [{ + type: 'command', + // Path contains gsd-check-update.js as substring of a longer + // filename, but is NOT actually that file. + command: 'node /home/me/scripts/wraps-gsd-check-update.js-helper.js', + }], + }], + }, + }; + const runner = '"/usr/local/bin/node"'; + const before = settings.hooks.SessionStart[0].hooks[0].command; + const changed = rewriteLegacyManagedNodeHookCommands(settings, runner); + assert.equal(changed, false, 'must not rewrite user hooks with managed-filename-as-substring paths'); + assert.equal(settings.hooks.SessionStart[0].hooks[0].command, before); + }); + + test('rewrites a managed entry whose path is quoted with single quotes', () => { + const settings = { + hooks: { + SessionStart: [{ + hooks: [{ type: 'command', command: "node '/x/hooks/gsd-statusline.js'" }], + }], + }, + }; + const runner = '"/usr/local/bin/node"'; + const changed = rewriteLegacyManagedNodeHookCommands(settings, runner, { platform: 'linux' }); + assert.equal(changed, true); + assert.equal( + settings.hooks.SessionStart[0].hooks[0].command, + `"/usr/local/bin/node" '/x/hooks/gsd-statusline.js'`, + ); + }); + + test('rewrites a managed entry with no path quoting (bareword)', () => { + const settings = { + hooks: { + SessionStart: [{ + hooks: [{ type: 'command', command: 'node /x/hooks/gsd-context-monitor.js' }], + }], + }, + }; + const runner = '"/usr/local/bin/node"'; + const changed = rewriteLegacyManagedNodeHookCommands(settings, runner, { platform: 'linux' }); + assert.equal(changed, true); + assert.equal( + settings.hooks.SessionStart[0].hooks[0].command, + '"/usr/local/bin/node" /x/hooks/gsd-context-monitor.js', + ); + }); + + test('handles Windows-style backslash path separators when extracting basename', () => { + const settings = { + hooks: { + SessionStart: [{ + hooks: [{ type: 'command', command: 'node "C:\\\\Users\\\\me\\\\.claude\\\\hooks\\\\gsd-prompt-guard.js"' }], + }], + }, + }; + const runner = '"/usr/local/bin/node"'; + const changed = rewriteLegacyManagedNodeHookCommands(settings, runner); + assert.equal(changed, true); + }); + + test('Gemini on Windows normalizes single-quoted managed hook paths to double-quoted forward-slash paths (#3392)', () => { + const settings = { + hooks: { + PreToolUse: [{ + hooks: [{ + type: 'command', + command: "node 'C:\\Users\\me\\.gemini\\hooks\\gsd-prompt-guard.js'", + }], + }], + }, + }; + const runner = '"C:/nvm4w/nodejs/node.exe"'; + const changed = rewriteLegacyManagedNodeHookCommands(settings, runner, { platform: 'win32', runtime: 'gemini' }); + assert.equal(changed, true); + assert.equal( + settings.hooks.PreToolUse[0].hooks[0].command, + '& "C:/nvm4w/nodejs/node.exe" "C:/Users/me/.gemini/hooks/gsd-prompt-guard.js"', + ); + }); +}); + +describe('Bug #2979 (#3002 CR): resolveNodeRunner returns null when execPath unavailable', () => { + test('returns null instead of bare "node" when process.execPath is empty', () => { + const orig = process.execPath; + try { + Object.defineProperty(process, 'execPath', { value: '', configurable: true }); + const r = resolveNodeRunner(); + assert.equal(r, null, 'expected null, not bare "node"'); + } finally { + Object.defineProperty(process, 'execPath', { value: orig, configurable: true }); + } + }); + + test('buildHookCommand returns null when execPath is unavailable (caller skips registration)', () => { + const orig = process.execPath; + try { + Object.defineProperty(process, 'execPath', { value: '', configurable: true }); + const cmd = buildHookCommand('/tmp/.claude', 'gsd-statusline.js'); + assert.equal(cmd, null); + } finally { + Object.defineProperty(process, 'execPath', { value: orig, configurable: true }); + } + }); +}); + +// ─── #3002 CR follow-up #2: null-command guards in settings.json ────────── + +const { validateHookFields } = INSTALL; + +describe('Bug #2979 (#3002 CR follow-up): no command:null hook entries survive serialization', () => { + // CR feedback: assert structurally on the resulting settings object, not by + // grepping bin/install.js source. The push-site guards (each `if` clause's + // `&& ` token) skip null-command pushes at the source. As a + // backstop, install.js now runs validateHookFields(settings) right before + // writeSettings; this test exercises that backstop directly. + // + // Construct a settings object that contains exactly the kind of null-command + // entries that the registration code would have written if my push-site + // guards regressed. Run validateHookFields on it. Assert the null entries + // are gone and the well-formed entries survive. + + function nullCommandEntry(matcher) { + const entry = { hooks: [{ type: 'command', command: null }] }; + if (matcher) entry.matcher = matcher; + return entry; + } + function realCommandEntry(matcher, command) { + const entry = { hooks: [{ type: 'command', command }] }; + if (matcher) entry.matcher = matcher; + return entry; + } + + const MANAGED_JS_HOOKS = [ + { event: 'SessionStart', matcher: undefined, label: 'gsd-check-update.js' }, + { event: 'PostToolUse', matcher: 'Bash|Edit|Write|MultiEdit|Agent|Task', label: 'gsd-context-monitor.js' }, + { event: 'PreToolUse', matcher: 'Write|Edit', label: 'gsd-prompt-guard.js' }, + { event: 'PreToolUse', matcher: 'Write|Edit', label: 'gsd-read-guard.js' }, + { event: 'PostToolUse', matcher: 'Read', label: 'gsd-read-injection-scanner.js' }, + { event: 'PreToolUse', matcher: 'Bash|Edit|Write|MultiEdit', label: 'gsd-workflow-guard.js' }, + ]; + + for (const { event, matcher, label } of MANAGED_JS_HOOKS) { + test(`validateHookFields strips a null-command ${label} entry from settings.hooks.${event}`, () => { + const settings = { + hooks: { + [event]: [ + nullCommandEntry(matcher), + realCommandEntry(matcher, '"/usr/local/bin/node" "/x/hooks/other.js"'), + ], + }, + }; + const out = validateHookFields(settings); + const survivors = out.hooks[event] || []; + // The well-formed entry must remain. + assert.equal(survivors.length, 1, `expected the real-command entry to survive`); + // No survivor entry contains a hook with command === null. + for (const e of survivors) { + for (const h of e.hooks || []) { + assert.notEqual(h.command, null, 'no surviving hook should have command:null'); + } + } + }); + } + + test('validateHookFields drops the entry entirely when all its hooks have null commands', () => { + const settings = { + hooks: { + SessionStart: [nullCommandEntry()], + }, + }; + const out = validateHookFields(settings); + // Empty event arrays should be cleaned up (the entire SessionStart key + // gets removed when nothing valid remains). + assert.ok( + !out.hooks.SessionStart || out.hooks.SessionStart.length === 0, + 'expected SessionStart to be empty/removed after the only entry was dropped', + ); + }); + + test('validateHookFields preserves agent-type hooks while stripping command:null sibling hooks', () => { + const settings = { + hooks: { + SessionStart: [{ + hooks: [ + { type: 'command', command: null }, + { type: 'agent', prompt: 'analyze the session' }, + { type: 'command', command: '"/usr/local/bin/node" "/x/hooks/y.js"' }, + ], + }], + }, + }; + const out = validateHookFields(settings); + const survivors = out.hooks.SessionStart[0].hooks; + assert.equal(survivors.length, 2, 'expected 2 of 3 hooks to survive (the null-command one is stripped)'); + assert.equal(survivors.find(h => h.command === null), undefined, 'no surviving hook should have command:null'); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-442-config-dir-equals-in-path.test.cjs — consolidation epic #1969 (B1 #1970) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-442-config-dir-equals-in-path (consolidation epic #1969 B1 #1970)", () => { +'use strict'; +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); + +// parseConfigDirArg is not exported directly from bin/install.js (it closes +// over the module-level `args` array). We expose a pure seam here: +// parseConfigDirFromArgs(args) that mirrors the function's logic so we can +// test the equals-form parsing without spawning a child process. +// +// The implementation under test is inlined below (RED: before the fix it will +// reproduce the truncation bug). Once the fix lands, we swap in the real +// implementation via require. + +/** + * Pure seam that replicates the equals-form parse logic from bin/install.js. + * We import it via a thin wrapper so that the function can be tested without + * executing the entire install script. + * + * During RED the bug is: `split('=')[1]` drops everything after the second `=`. + */ +const { parseConfigDirFromArgs } = require('../bin/install.js'); + +describe('bug-442: --config-dir= equals-form path parsing', () => { + // ── Happy-path: single = in path ───────────────────────────────────────── + test('--config-dir= with one = in value returns full value', () => { + const result = parseConfigDirFromArgs(['--config-dir=/tmp/gsd=a']); + assert.equal(result, '/tmp/gsd=a'); + }); + + // ── Happy-path: multiple = in path ─────────────────────────────────────── + test('--config-dir= with multiple = in value returns full value', () => { + const result = parseConfigDirFromArgs(['--config-dir=/tmp/a=b=c']); + assert.equal(result, '/tmp/a=b=c'); + }); + + // ── Short form -c= ──────────────────────────────────────────────────────── + test('-c= with = in value returns full value', () => { + const result = parseConfigDirFromArgs(['-c=/tmp/gsd=a']); + assert.equal(result, '/tmp/gsd=a'); + }); + + test('-c= with multiple = in value returns full value', () => { + const result = parseConfigDirFromArgs(['-c=/tmp/a=b=c']); + assert.equal(result, '/tmp/a=b=c'); + }); + + // ── Contract: empty value ───────────────────────────────────────────────── + // --config-dir= (no value after the =) → returns empty string ''. + // The caller (parseConfigDirArg) treats '' as missing and errors; the seam + // itself should faithfully return '' rather than null/undefined so the + // caller can make the error decision. + test('--config-dir= with no value returns empty string', () => { + const result = parseConfigDirFromArgs(['--config-dir=']); + assert.equal(result, ''); + }); + + test('-c= with no value returns empty string', () => { + const result = parseConfigDirFromArgs(['-c=']); + assert.equal(result, ''); + }); + + // ── Space-separated form is unaffected (regression guard) ───────────────── + test('--config-dir space-separated still returns the path', () => { + const result = parseConfigDirFromArgs(['--config-dir', '/tmp/gsd=a']); + assert.equal(result, '/tmp/gsd=a'); + }); + + test('-c space-separated still returns the path', () => { + const result = parseConfigDirFromArgs(['-c', '/tmp/gsd=a']); + assert.equal(result, '/tmp/gsd=a'); + }); + + // ── No config-dir flag → null ───────────────────────────────────────────── + test('returns null when no --config-dir flag is present', () => { + const result = parseConfigDirFromArgs(['--global', '--claude']); + assert.equal(result, null); + }); + + // ── Negative matrix (CLI edge cases) ───────────────────────────────────── + // Flag-looking value after space form: next arg starts with - → null (no + // valid value; the real function would process.exit but the seam returns null + // so tests stay in-process). + test('space form with next arg being a flag returns null (flag-looking value)', () => { + const result = parseConfigDirFromArgs(['--config-dir', '--other-flag']); + assert.equal(result, null); + }); + + // Equals form where value is a path with no = (plain path, no regression) + test('--config-dir= without any = in path still works', () => { + const result = parseConfigDirFromArgs(['--config-dir=/tmp/plain']); + assert.equal(result, '/tmp/plain'); + }); + + // Flag appears after other args (positional ordering should not matter) + test('--config-dir= flag after other args is parsed correctly', () => { + const result = parseConfigDirFromArgs(['--global', '--config-dir=/tmp/a=b', '--claude']); + assert.equal(result, '/tmp/a=b'); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/enh-1559-installer-export-audit.test.cjs — consolidation epic #1969 (B1 #1970) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:enh-1559-installer-export-audit (consolidation epic #1969 B1 #1970)", () => { +'use strict'; + +const { describe, test, before } = require('node:test'); +const assert = require('node:assert/strict'); + +let installer; +let conversion; + +before(() => { + process.env['GSD_TEST_MODE'] = '1'; + installer = require('../bin/install.js'); + conversion = require('../gsd-core/bin/lib/runtime-artifact-conversion.cjs'); +}); + +describe('bin/install.js compatibility export audit (#1559)', () => { + test('retains audited compatibility relays for shared rewrite helpers', () => { + assert.strictEqual(installer.processAttribution, conversion.processAttribution); + assert.strictEqual( + installer.applyRuntimeContentRewritesForCommandsInPlace, + conversion.applyRuntimeContentRewritesForCommandsInPlace, + ); + }); + + test('does not leak unaudited conversion-module helpers through the installer', () => { + for (const name of [ + 'yamlQuote', + 'toSingleLine', + 'extractFrontmatterAndBody', + 'extractFrontmatterField', + 'convertClaudeToCursorMarkdown', + 'convertClaudeToCodexMarkdown', + 'transformContentToHyphen', + 'claudeToGeminiTools', + 'convertGeminiToolName', + 'rewriteStagedSkillBodies', + 'rewriteStagedCommandBodies', + '_computePathPrefix', + '_stampNonClaudeRuntimeDefaults', + 'NON_CLAUDE_RUNTIMES', + ]) { + assert.ok(name in conversion, `${name} remains available from the conversion module`); + assert.equal(installer[name], undefined, `${name} is not an installer compatibility export`); + } + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-1908-uninstall-manifest.test.cjs — consolidation epic #1969 (B1 #1970) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-1908-uninstall-manifest (consolidation epic #1969 B1 #1970)", () => { +/** + * Regression test for bug #1908 + * + * `--uninstall` did not remove `gsd-file-manifest.json` from the target + * directory, leaving a stale metadata file after uninstall. + * + * Fix: `uninstall()` must call + * fs.rmSync(path.join(targetDir, MANIFEST_NAME), { force: true }) + * after cleaning up the rest of the GSD artefacts. + */ + +'use strict'; + +process.env.GSD_TEST_MODE = '1'; + +const { describe, test, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); +const os = require('os'); + +const { uninstall } = require('../bin/install.js'); + +const MANIFEST_NAME = 'gsd-file-manifest.json'; + +// ─── helpers ────────────────────────────────────────────────────────────────── + +function createFakeInstall(prefix = 'gsd-uninstall-test-') { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + + // Simulate the minimum directory/file layout produced by the installer: + // gsd-core/ directory, agents/ directory, and the manifest file. + fs.mkdirSync(path.join(dir, 'gsd-core', 'workflows'), { recursive: true }); + fs.writeFileSync(path.join(dir, 'gsd-core', 'workflows', 'execute-phase.md'), '# stub'); + + fs.mkdirSync(path.join(dir, 'agents'), { recursive: true }); + fs.writeFileSync(path.join(dir, 'agents', 'gsd-executor.md'), '# stub'); + + const manifest = { + version: '1.34.0', + timestamp: new Date().toISOString(), + files: { + 'gsd-core/workflows/execute-phase.md': 'abc123', + 'agents/gsd-executor.md': 'def456', + }, + }; + fs.writeFileSync(path.join(dir, MANIFEST_NAME), JSON.stringify(manifest, null, 2)); + + return dir; +} + +function cleanup(dir) { + // eslint-disable-next-line local/no-raw-rmsync-in-tests -- local teardown helper predates helpers.cjs; renaming would collide with the imported cleanup + try { fs.rmSync(dir, { recursive: true, force: true }); } catch {} +} + +// ─── tests ──────────────────────────────────────────────────────────────────── + +describe('uninstall — manifest cleanup (#1908)', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createFakeInstall(); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('gsd-file-manifest.json is removed after global uninstall', () => { + const manifestPath = path.join(tmpDir, MANIFEST_NAME); + + // Pre-condition: manifest exists before uninstall + assert.ok( + fs.existsSync(manifestPath), + 'Test setup failure: manifest file should exist before uninstall' + ); + + // Run uninstall against tmpDir (pass it via CLAUDE_CONFIG_DIR so getGlobalDir() + // resolves to our temp directory; pass isGlobal=true) + const savedEnv = process.env.CLAUDE_CONFIG_DIR; + process.env.CLAUDE_CONFIG_DIR = tmpDir; + try { + uninstall(true, 'claude'); + } finally { + if (savedEnv === undefined) { + delete process.env.CLAUDE_CONFIG_DIR; + } else { + process.env.CLAUDE_CONFIG_DIR = savedEnv; + } + } + + assert.ok( + !fs.existsSync(manifestPath), + [ + `${MANIFEST_NAME} must be removed by uninstall() but still exists at`, + manifestPath, + ].join(' ') + ); + }); + + test('gsd-file-manifest.json is removed after local uninstall', () => { + const manifestPath = path.join(tmpDir, MANIFEST_NAME); + + assert.ok( + fs.existsSync(manifestPath), + 'Test setup failure: manifest file should exist before uninstall' + ); + + // For a local install, getGlobalDir is not called — targetDir = cwd + dirName. + // Simulate by creating .claude/ inside tmpDir and placing artefacts there. + const localDir = path.join(tmpDir, '.claude'); + fs.mkdirSync(path.join(localDir, 'gsd-core', 'workflows'), { recursive: true }); + fs.writeFileSync(path.join(localDir, 'gsd-core', 'workflows', 'execute-phase.md'), '# stub'); + const localManifestPath = path.join(localDir, MANIFEST_NAME); + fs.writeFileSync(localManifestPath, JSON.stringify({ version: '1.34.0', files: {} }, null, 2)); + + const savedCwd = process.cwd(); + process.chdir(tmpDir); + try { + uninstall(false, 'claude'); + } finally { + process.chdir(savedCwd); + } + + assert.ok( + !fs.existsSync(localManifestPath), + [ + `${MANIFEST_NAME} must be removed by uninstall() (local) but still exists at`, + localManifestPath, + ].join(' ') + ); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-2771-user-profile-manifest.test.cjs — consolidation epic #1969 (B1 #1970) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-2771-user-profile-manifest (consolidation epic #1969 B1 #1970)", () => { +/** + * Regression tests for bug #2771: USER-PROFILE.md tracked in install manifest + * + * USER-PROFILE.md is a user-owned artifact created/refreshed by /gsd-profile-user. + * preserveUserArtifacts() correctly preserves it across reinstalls. But writeManifest() + * also records it under "gsd-core/USER-PROFILE.md" with a SHA-256 of whatever was + * on disk at install time. On the next install, saveLocalPatches() compares the on-disk + * (refreshed) hash to the manifest hash, finds them different, and emits the spurious + * "Found N locally modified GSD file(s) — backed up to gsd-local-patches/" warning. + * + * Invariant: a file is either distribution (manifest-tracked, diff'd against manifest) + * or user artifact (preserved across installs, never diff'd). It cannot be both. The + * shared truth source must be a single USER_OWNED_ARTIFACTS list referenced by both + * preserveUserArtifacts callers and writeManifest. + * + * Closes: #2771 + */ + +'use strict'; + +const { describe, test, beforeEach, afterEach, before } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); +const crypto = require('crypto'); +const { execFileSync } = require('child_process'); +const { createTempDir, cleanup } = require('./helpers.cjs'); + +const INSTALL_SCRIPT = path.join(__dirname, '..', 'bin', 'install.js'); +const BUILD_SCRIPT = path.join(__dirname, '..', 'scripts', 'build-hooks.js'); +const MANIFEST_NAME = 'gsd-file-manifest.json'; +const PATCHES_DIR_NAME = 'gsd-local-patches'; + +before(() => { + execFileSync(process.execPath, [BUILD_SCRIPT], { encoding: 'utf-8', stdio: 'pipe' }); +}); + +function runInstaller(configDir) { + const env = { ...process.env, CLAUDE_CONFIG_DIR: configDir }; + delete env.GSD_TEST_MODE; + return execFileSync( + process.execPath, + [INSTALL_SCRIPT, '--claude', '--global', '--yes', '--no-sdk'], + { encoding: 'utf-8', stdio: 'pipe', env } + ); +} + +// ─── Test 1: writeManifest must NOT record USER-PROFILE.md ──────────────────── + +describe('#2771: USER-PROFILE.md is excluded from gsd-file-manifest.json', () => { + let tmpDir; + + beforeEach(() => { tmpDir = createTempDir('gsd-2771-manifest-'); }); + afterEach(() => { cleanup(tmpDir); }); + + test('writeManifest excludes gsd-core/USER-PROFILE.md even when present on disk', () => { + runInstaller(tmpDir); + + // Simulate /gsd-profile-user creating USER-PROFILE.md + const profilePath = path.join(tmpDir, 'gsd-core', 'USER-PROFILE.md'); + fs.writeFileSync(profilePath, '# My Profile\n\nFirst version.\n'); + + // Re-install: writeManifest runs again with USER-PROFILE.md present on disk + runInstaller(tmpDir); + + const manifestPath = path.join(tmpDir, MANIFEST_NAME); + assert.ok(fs.existsSync(manifestPath), 'manifest must be written'); + const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); + + assert.ok( + !Object.prototype.hasOwnProperty.call(manifest.files, 'gsd-core/USER-PROFILE.md'), + 'manifest.files must NOT contain gsd-core/USER-PROFILE.md — it is a user artifact, not distribution' + ); + }); +}); + +// ─── Test 2: preserveUserArtifacts still preserves USER-PROFILE.md ──────────── + +describe('#2771: USER-PROFILE.md is still preserved across reinstall', () => { + let tmpDir; + + beforeEach(() => { tmpDir = createTempDir('gsd-2771-preserve-'); }); + afterEach(() => { cleanup(tmpDir); }); + + test('USER-PROFILE.md content survives reinstall (preservation regression guard)', () => { + runInstaller(tmpDir); + + const profilePath = path.join(tmpDir, 'gsd-core', 'USER-PROFILE.md'); + const content = '# Profile\n\nUser content from /gsd-profile-user.\n'; + fs.writeFileSync(profilePath, content); + + runInstaller(tmpDir); + + assert.ok(fs.existsSync(profilePath), 'USER-PROFILE.md must survive reinstall'); + assert.strictEqual(fs.readFileSync(profilePath, 'utf8'), content); + }); +}); + +// ─── Test 3: no spurious "local patches" hit for USER-PROFILE.md refresh ────── + +describe('#2771: refreshed USER-PROFILE.md does not trigger local-patches warning', () => { + let tmpDir; + + beforeEach(() => { tmpDir = createTempDir('gsd-2771-patches-'); }); + afterEach(() => { cleanup(tmpDir); }); + + test('saveLocalPatches does not classify a refreshed USER-PROFILE.md as a local patch', () => { + // Initial install + runInstaller(tmpDir); + + // /gsd-profile-user creates USER-PROFILE.md (v1) + const profilePath = path.join(tmpDir, 'gsd-core', 'USER-PROFILE.md'); + fs.writeFileSync(profilePath, '# Profile v1\n'); + + // Reinstall — manifest written with v1 contents (under buggy code) or excluded (under fix) + runInstaller(tmpDir); + + // /gsd-profile-user --refresh rewrites USER-PROFILE.md (v2 != v1) + fs.writeFileSync(profilePath, '# Profile v2 — refreshed\n'); + + // Reinstall — saveLocalPatches scans manifest. Under bug, v2 hash != v1 manifest + // hash → patch detected. Under fix, file is not in manifest → no patch. + const output = runInstaller(tmpDir); + + const patchesDir = path.join(tmpDir, PATCHES_DIR_NAME); + const patchFile = path.join(patchesDir, 'gsd-core', 'USER-PROFILE.md'); + assert.ok( + !fs.existsSync(patchFile), + 'USER-PROFILE.md must NOT appear in gsd-local-patches/ — it is a user artifact, not a modified distribution file' + ); + + const offendingLine = output + .split('\n') + .find((line) => /locally modified GSD file/.test(line) && /USER-PROFILE/.test(line)); + assert.strictEqual( + offendingLine, + undefined, + 'installer output must not report USER-PROFILE.md as a locally modified GSD file on any single line. Output was:\n' + output + ); + }); +}); + +// ─── Test 5: legacy manifest with USER-PROFILE.md entry is normalized ───────── + +describe('#2771: legacy manifest entries for USER_OWNED_ARTIFACTS are normalized', () => { + let tmpDir; + + beforeEach(() => { tmpDir = createTempDir('gsd-2771-legacy-'); }); + afterEach(() => { cleanup(tmpDir); }); + + test('pre-existing manifest entry for USER-PROFILE.md does not trigger patches warning', () => { + // Initial install + runInstaller(tmpDir); + + const profilePath = path.join(tmpDir, 'gsd-core', 'USER-PROFILE.md'); + fs.writeFileSync(profilePath, '# Profile v1\n'); + + // Reinstall to populate manifest under the (now-fixed) writer + runInstaller(tmpDir); + + // Inject a stale manifest entry simulating a pre-#2771 install: a hash for + // USER-PROFILE.md that does NOT match current content. + const manifestPath = path.join(tmpDir, MANIFEST_NAME); + const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); + manifest.files = manifest.files || {}; + manifest.files['gsd-core/USER-PROFILE.md'] = 'deadbeef'.repeat(8); // stale hash + fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2)); + + // /gsd-profile-user --refresh rewrites USER-PROFILE.md + fs.writeFileSync(profilePath, '# Profile v2 — refreshed\n'); + + // Reinstall — saveLocalPatches must strip the legacy entry before scanning + const output = runInstaller(tmpDir); + + const patchesDir = path.join(tmpDir, PATCHES_DIR_NAME); + const patchFile = path.join(patchesDir, 'gsd-core', 'USER-PROFILE.md'); + assert.ok( + !fs.existsSync(patchFile), + 'legacy USER-PROFILE.md manifest entry must be normalized away — not backed up as a patch' + ); + + const offendingLine = output + .split('\n') + .find((line) => /locally modified GSD file/.test(line) && /USER-PROFILE/.test(line)); + assert.strictEqual( + offendingLine, + undefined, + 'legacy manifest entry must not surface a USER-PROFILE.md patches warning. Output was:\n' + output + ); + }); +}); + +// ─── Test 4: shared constant exists and is used by both call sites ──────────── + +describe('#2771: USER_OWNED_ARTIFACTS is a single source of truth', () => { + test('install.js exports USER_OWNED_ARTIFACTS containing USER-PROFILE.md', () => { + const origMode = process.env.GSD_TEST_MODE; + process.env.GSD_TEST_MODE = '1'; + let mod; + try { + delete require.cache[require.resolve(INSTALL_SCRIPT)]; + mod = require(INSTALL_SCRIPT); + } finally { + if (origMode === undefined) delete process.env.GSD_TEST_MODE; + else process.env.GSD_TEST_MODE = origMode; + } + + assert.ok( + Array.isArray(mod.USER_OWNED_ARTIFACTS) || mod.USER_OWNED_ARTIFACTS instanceof Set, + 'install.js must export USER_OWNED_ARTIFACTS as a single source of truth' + ); + const list = Array.isArray(mod.USER_OWNED_ARTIFACTS) + ? mod.USER_OWNED_ARTIFACTS + : Array.from(mod.USER_OWNED_ARTIFACTS); + assert.ok( + list.includes('USER-PROFILE.md'), + 'USER_OWNED_ARTIFACTS must include USER-PROFILE.md' + ); + }); +}); + +describe('manifest path safety', () => { + let tmpDir; + let outside; + + beforeEach(() => { + tmpDir = createTempDir('gsd-manifest-path-safety-'); + outside = path.join(tmpDir, '..', `outside-managed-file-${path.basename(tmpDir)}.txt`); + }); + afterEach(() => { + cleanup(outside); + cleanup(tmpDir); + }); + + test('saveLocalPatches ignores manifest entries that escape the install root', () => { + const origMode = process.env.GSD_TEST_MODE; + process.env.GSD_TEST_MODE = '1'; + let mod; + try { + delete require.cache[require.resolve(INSTALL_SCRIPT)]; + mod = require(INSTALL_SCRIPT); + } finally { + if (origMode === undefined) delete process.env.GSD_TEST_MODE; + else process.env.GSD_TEST_MODE = origMode; + } + + fs.writeFileSync(outside, 'outside user data\n', 'utf8'); + fs.writeFileSync( + path.join(tmpDir, MANIFEST_NAME), + JSON.stringify({ + version: 'legacy', + timestamp: '2026-05-11T00:00:00.000Z', + files: { + '../outside-managed-file.txt': 'deadbeef', + }, + }, null, 2), + 'utf8' + ); + + const modified = mod.saveLocalPatches(tmpDir); + + assert.deepEqual(modified, []); + assert.equal(fs.readFileSync(outside, 'utf8'), 'outside user data\n'); + assert.equal(fs.existsSync(path.join(tmpDir, PATCHES_DIR_NAME, '..', path.basename(outside))), false); + }); + + test('saveLocalPatches does not follow symlinked patch directories outside the install root', () => { + const origMode = process.env.GSD_TEST_MODE; + process.env.GSD_TEST_MODE = '1'; + let mod; + try { + delete require.cache[require.resolve(INSTALL_SCRIPT)]; + mod = require(INSTALL_SCRIPT); + } finally { + if (origMode === undefined) delete process.env.GSD_TEST_MODE; + else process.env.GSD_TEST_MODE = origMode; + } + + const hookPath = path.join(tmpDir, 'hooks', 'managed.js'); + fs.mkdirSync(path.dirname(hookPath), { recursive: true }); + fs.writeFileSync(hookPath, 'user edited hook\n', 'utf8'); + fs.writeFileSync( + path.join(tmpDir, MANIFEST_NAME), + JSON.stringify({ + version: 'legacy', + timestamp: '2026-05-11T00:00:00.000Z', + files: { + 'hooks/managed.js': crypto.createHash('sha256').update('managed hook\n').digest('hex'), + }, + }, null, 2), + 'utf8' + ); + + fs.mkdirSync(outside, { recursive: true }); + try { + fs.symlinkSync(outside, path.join(tmpDir, PATCHES_DIR_NAME), 'dir'); + } catch { + return; + } + + const modified = mod.saveLocalPatches(tmpDir); + + assert.deepEqual(modified, []); + assert.equal(fs.existsSync(path.join(outside, 'hooks', 'managed.js')), false); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-3571-configuration-manifest-install-path.test.cjs — consolidation epic #1969 (B1 #1970) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-3571-configuration-manifest-install-path (consolidation epic #1969 B1 #1970)", () => { +/** + * Regression test for #3571: configuration.cjs used the source + * checkout sdk/shared path only, which breaks installed gsd-tools.cjs because + * runtime installs copy gsd-core/ but not sdk/. + */ + +'use strict'; + +process.env.GSD_TEST_MODE = '1'; + +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 REPO_ROOT = path.join(__dirname, '..'); +const CONFIGURATION_CJS = path.join(REPO_ROOT, 'gsd-core', 'bin', 'lib', 'configuration.cjs'); +const SHARED_DIR = path.join(REPO_ROOT, 'gsd-core', 'bin', 'shared'); + +const { install } = require('../bin/install.js'); + +const { createTempDir, cleanup } = require('./helpers.cjs'); +const makeTmpDir = () => createTempDir('gsd-3571-'); + +function silenceConsole(fn) { + const original = { + log: console.log, + warn: console.warn, + error: console.error, + }; + console.log = () => {}; + console.warn = () => {}; + console.error = () => {}; + try { + return fn(); + } finally { + console.log = original.log; + console.warn = original.warn; + console.error = original.error; + } +} + +describe('bug #3571: configuration generated manifests resolve in install layout', () => { + let tmpRoot; + let savedHome; + let savedUserProfile; + let savedExplicitConfigDir; + + beforeEach(() => { + tmpRoot = makeTmpDir(); + savedHome = process.env.HOME; + // On Windows, os.homedir() reads USERPROFILE; install() resolves via it. + savedUserProfile = process.env.USERPROFILE; + savedExplicitConfigDir = process.env.GSD_EXPLICIT_CONFIG_DIR; + delete process.env.GSD_EXPLICIT_CONFIG_DIR; + }); + + afterEach(() => { + process.env.HOME = savedHome; + if (savedUserProfile === undefined) delete process.env.USERPROFILE; + else process.env.USERPROFILE = savedUserProfile; + if (savedExplicitConfigDir === undefined) { + delete process.env.GSD_EXPLICIT_CONFIG_DIR; + } else { + process.env.GSD_EXPLICIT_CONFIG_DIR = savedExplicitConfigDir; + } + cleanup(tmpRoot); + }); + + test('co-located bin/shared manifests let configuration.cjs load without sdk/shared', () => { + const gsdBinDir = path.join(tmpRoot, '.codex', 'gsd-core', 'bin'); + const gsdLibDir = path.join(gsdBinDir, 'lib'); + const gsdSharedDir = path.join(gsdBinDir, 'shared'); + fs.mkdirSync(gsdLibDir, { recursive: true }); + fs.mkdirSync(gsdSharedDir, { recursive: true }); + + const installedCjs = path.join(gsdLibDir, 'configuration.cjs'); + fs.copyFileSync(CONFIGURATION_CJS, installedCjs); + fs.copyFileSync( + path.join(SHARED_DIR, 'config-defaults.manifest.json'), + path.join(gsdSharedDir, 'config-defaults.manifest.json') + ); + fs.copyFileSync( + path.join(SHARED_DIR, 'config-schema.manifest.json'), + path.join(gsdSharedDir, 'config-schema.manifest.json') + ); + + delete require.cache[installedCjs]; + let mod; + assert.doesNotThrow(() => { + mod = require(installedCjs); + }, 'installed configuration.cjs must not require ~/.codex/sdk/shared'); + + assert.ok(mod.VALID_CONFIG_KEYS.has('workflow.plan_review_convergence')); + }); + + test('post-install: install() copies configuration manifests to co-located bin/shared', () => { + process.env.HOME = tmpRoot; + process.env.USERPROFILE = tmpRoot; + + silenceConsole(() => { + install(true, 'codex'); + }); + + const sharedDir = path.join(tmpRoot, '.codex', 'gsd-core', 'bin', 'shared'); + for (const fileName of ['config-defaults.manifest.json', 'config-schema.manifest.json']) { + const installedManifest = path.join(sharedDir, fileName); + assert.ok(fs.existsSync(installedManifest), `${fileName} must be copied to ${sharedDir}`); + assert.doesNotThrow(() => { + JSON.parse(fs.readFileSync(installedManifest, 'utf8')); + }, `${fileName} must be valid JSON`); + } + + const installedCjs = path.join( + tmpRoot, + '.codex', + 'gsd-core', + 'bin', + 'lib', + 'configuration.cjs' + ); + + delete require.cache[installedCjs]; + assert.doesNotThrow(() => { + require(installedCjs); + }, 'post-install configuration.cjs must load from co-located manifests'); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-3288-model-catalog-install-path.test.cjs — consolidation epic #1969 (B1 #1970) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-3288-model-catalog-install-path (consolidation epic #1969 B1 #1970)", () => { +/** + * Regression test for #3288: model-catalog.cjs uses brittle relative path + * that breaks after install. + * + * Repro: + * After `node bin/install.js --global --claude`, the installed + * `~/.claude/gsd-core/bin/lib/model-catalog.cjs` tries: + * require(path.join(__dirname, '..', '..', '..', 'sdk', 'shared', 'model-catalog.json')) + * which resolves to `~/.claude/sdk/shared/model-catalog.json`. + * The installer copies `gsd-core/` but never copies `sdk/shared/`, + * so the require throws MODULE_NOT_FOUND. + * + * Fix contract: + * 1. model-catalog.cjs must use a resolve-chain that checks a co-located + * path first (bin/shared/model-catalog.json) before the legacy + * source-repo path. + * 2. bin/install.js must copy shared model-catalog.json into + * gsd-core/bin/shared/model-catalog.json (co-located inside the + * gsd-core/ payload). + * + * Both halves must be true for the install layout to work. + */ + +'use strict'; + +process.env.GSD_TEST_MODE = '1'; + +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 REPO_ROOT = path.join(__dirname, '..'); +const MODEL_CATALOG_CJS = path.join(REPO_ROOT, 'gsd-core', 'bin', 'lib', 'model-catalog.cjs'); +const MODEL_CATALOG_JSON = path.join(REPO_ROOT, 'gsd-core', 'bin', 'shared', 'model-catalog.json'); + +const { install } = require('../bin/install.js'); + +// ─── helpers ───────────────────────────────────────────────────────────────── + +const { createTempDir, cleanup } = require('./helpers.cjs'); +const makeTmpDir = createTempDir; + +const rmTmpDir = cleanup; + +/** + * Silence console output during install to avoid noise in test output. + */ +function silenceConsole(fn) { + const orig = { + log: console.log, + warn: console.warn, + error: console.error, + }; + console.log = () => {}; + console.warn = () => {}; + console.error = () => {}; + try { + return fn(); + } finally { + console.log = orig.log; + console.warn = orig.warn; + console.error = orig.error; + } +} + +// ─── test 1: fake-install layout reproduces MODULE_NOT_FOUND ──────────────── +// +// Build a fake post-install layout that mirrors what the OLD install did: +// /.claude/gsd-core/bin/lib/model-catalog.cjs (copy of real file) +// /.claude/sdk/shared/model-catalog.json ABSENT +// +// Then attempt to require model-catalog.cjs from that layout. +// Under the old path scheme (3 levels up → sdk/shared/) this should throw. +// After the fix, if we DON'T also copy the json, it should still throw — this +// confirms the co-located path IS required. + +describe('bug #3288: model-catalog.cjs install-layout resolution', () => { + let tmpRoot; + let savedHome; + let savedUserProfile; + let savedExplicitConfigDir; + + beforeEach(() => { + tmpRoot = makeTmpDir('gsd-3288-'); + savedHome = process.env.HOME; + // On Windows, os.homedir() reads USERPROFILE (and HOMEDRIVE+HOMEPATH), NOT + // HOME. install() resolves the install destination via os.homedir(), so the + // tests must also redirect USERPROFILE → tmpRoot on win32 to keep the + // installer writing inside the fixture. + savedUserProfile = process.env.USERPROFILE; + // Stash and clear explicitConfigDir via env so install() picks up our tmp dir. + // Must delete (not just save) so any CI-set value doesn't leak into install() + // and target a different directory than tmpRoot (CR finding, PR #3293). + savedExplicitConfigDir = process.env.GSD_EXPLICIT_CONFIG_DIR; + delete process.env.GSD_EXPLICIT_CONFIG_DIR; + }); + + afterEach(() => { + process.env.HOME = savedHome; + if (savedUserProfile === undefined) delete process.env.USERPROFILE; + else process.env.USERPROFILE = savedUserProfile; + if (savedExplicitConfigDir === undefined) { + delete process.env.GSD_EXPLICIT_CONFIG_DIR; + } else { + process.env.GSD_EXPLICIT_CONFIG_DIR = savedExplicitConfigDir; + } + rmTmpDir(tmpRoot); + }); + + // ── test A ────────────────────────────────────────────────────────────────── + test('OLD layout (3-level __dirname, no co-located json) fails to require', () => { + // Build the old install layout manually: + // /.claude/gsd-core/bin/lib/model-catalog.cjs (copy of the real CJS) + // sdk/shared/model-catalog.json ABSENT + const gsdLibDir = path.join(tmpRoot, '.claude', 'gsd-core', 'bin', 'lib'); + fs.mkdirSync(gsdLibDir, { recursive: true }); + + // Write a minimal model-catalog.cjs that uses ONLY the 3-level path (the old/broken path). + const oldCjsContent = `'use strict'; +const path = require('node:path'); +// This is the BRITTLE path: 3 levels up from bin/lib → sdk/shared/ +const catalog = require(path.join(__dirname, '..', '..', '..', 'sdk', 'shared', 'model-catalog.json')); +module.exports = { catalog }; +`; + const catalogCjsPath = path.join(gsdLibDir, 'model-catalog.cjs'); + fs.writeFileSync(catalogCjsPath, oldCjsContent); + + // Deliberately do NOT create sdk/shared/model-catalog.json (simulates missing file post-install). + + // Require must fail with MODULE_NOT_FOUND. + assert.throws( + () => { + // Delete from require cache to force a fresh load. + delete require.cache[catalogCjsPath]; + require(catalogCjsPath); + }, + (err) => { + assert.ok( + err.code === 'MODULE_NOT_FOUND' || err.message.includes('model-catalog.json'), + `Expected MODULE_NOT_FOUND or model-catalog.json error, got: ${err.message}`, + ); + return true; + }, + 'OLD 3-level path must fail when sdk/shared/model-catalog.json is not present (install layout)', + ); + }); + + // ── test B ────────────────────────────────────────────────────────────────── + test('NEW layout (co-located bin/shared/model-catalog.json) resolves correctly', () => { + // Build the new install layout: + // /.claude/gsd-core/bin/lib/model-catalog.cjs (copy of real CJS) + // /.claude/gsd-core/bin/shared/model-catalog.json (co-located copy) + const gsdBinDir = path.join(tmpRoot, '.claude', 'gsd-core', 'bin'); + const gsdLibDir = path.join(gsdBinDir, 'lib'); + const gsdSharedDir = path.join(gsdBinDir, 'shared'); + fs.mkdirSync(gsdLibDir, { recursive: true }); + fs.mkdirSync(gsdSharedDir, { recursive: true }); + + // Copy the real model-catalog.cjs into the fake install. + const catalogCjsPath = path.join(gsdLibDir, 'model-catalog.cjs'); + fs.copyFileSync(MODEL_CATALOG_CJS, catalogCjsPath); + + // Copy the real model-catalog.json to the co-located path. + fs.copyFileSync(MODEL_CATALOG_JSON, path.join(gsdSharedDir, 'model-catalog.json')); + + // Require must succeed and expose catalog with expected shape. + delete require.cache[catalogCjsPath]; + let mod; + assert.doesNotThrow(() => { + mod = require(catalogCjsPath); + }, 'NEW co-located layout must not throw MODULE_NOT_FOUND'); + + assert.ok(mod.catalog, 'module must export catalog'); + assert.ok(Array.isArray(mod.VALID_PROFILES), 'module must export VALID_PROFILES'); + assert.ok(mod.VALID_PROFILES.length > 0, 'VALID_PROFILES must not be empty'); + }); + + // ── test C ────────────────────────────────────────────────────────────────── + test('post-install: install() copies model-catalog.json to co-located path', () => { + // Run the real installer against a tmp target dir, then assert the co-located + // json is present and parseable. + const claudeDir = path.join(tmpRoot, '.claude'); + fs.mkdirSync(claudeDir, { recursive: true }); + process.env.HOME = tmpRoot; + process.env.USERPROFILE = tmpRoot; + + // Capture process.exit to prevent the test from being killed. + const origExit = process.exit; + let exitCalled = false; + process.exit = (code) => { + exitCalled = true; + throw new Error(`process.exit(${code}) during install — should not happen`); + }; + + try { + silenceConsole(() => { + install(true /* isGlobal */, 'claude'); + }); + } catch (e) { + if (exitCalled) { + assert.fail(`install() called process.exit — unexpected: ${e.message}`); + } + throw e; + } finally { + process.exit = origExit; + } + + // The co-located json must be present after install. + const colocatedJson = path.join( + claudeDir, + 'gsd-core', + 'bin', + 'shared', + 'model-catalog.json', + ); + assert.ok( + fs.existsSync(colocatedJson), + `model-catalog.json must be present at co-located path post-install: ${colocatedJson}`, + ); + + // The json must be valid and have expected shape. + let parsed; + assert.doesNotThrow(() => { + parsed = JSON.parse(fs.readFileSync(colocatedJson, 'utf8')); + }, 'co-located model-catalog.json must be valid JSON'); + + assert.ok(Array.isArray(parsed.profiles), 'catalog.profiles must be an array'); + assert.ok(parsed.profiles.length > 0, 'catalog.profiles must not be empty'); + + // And the installed model-catalog.cjs must be requireable from its install location. + const installedCjs = path.join( + claudeDir, + 'gsd-core', + 'bin', + 'lib', + 'model-catalog.cjs', + ); + assert.ok(fs.existsSync(installedCjs), `model-catalog.cjs must be installed at: ${installedCjs}`); + + delete require.cache[installedCjs]; + let installedMod; + assert.doesNotThrow(() => { + installedMod = require(installedCjs); + }, 'installed model-catalog.cjs must not throw MODULE_NOT_FOUND after install'); + + assert.ok(installedMod.catalog, 'installed module must export catalog'); + assert.ok(installedMod.VALID_PROFILES.length > 0, 'installed module must have valid profiles'); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-130-finishinstall-opencode-testmode.test.cjs — consolidation epic #1969 (B1 #1970) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-130-finishinstall-opencode-testmode (consolidation epic #1969 B1 #1970)", () => { +'use strict'; + +process.env.GSD_TEST_MODE = '1'; + +/** + * Bug #130: finishInstall calls configureOpencodePermissions unconditionally, + * violating the GSD_TEST_MODE side-effect-free contract. + * + * configureOpencodePermissions does fs.mkdirSync + fs.writeFileSync, which + * must NOT run under GSD_TEST_MODE='1'. This test asserts that the opencode + * config file (opencode.json) is NOT created when GSD_TEST_MODE is set. + */ + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); +const path = require('node:path'); +const os = require('node:os'); +const fs = require('node:fs'); + +const ROOT = path.join(__dirname, '..'); + +// Point HOME at a temp dir so configureOpencodePermissions can't write to +// the real ~/.config/opencode/ even if the guard is missing. +const FAKE_HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-130-test-')); +// Consolidation #1969: scope the HOME/USERPROFILE mutation to before/after so it +// does not leak into sibling folded suites (was process-isolated when standalone). +const { before: __foldBefore, after: __foldAfter } = require('node:test'); +const __savedHome = process.env.HOME; +const __savedUserProfile = process.env.USERPROFILE; +__foldBefore(() => { + process.env.HOME = FAKE_HOME; + process.env.USERPROFILE = FAKE_HOME; +}); +__foldAfter(() => { + if (__savedHome === undefined) delete process.env.HOME; + else process.env.HOME = __savedHome; + if (__savedUserProfile === undefined) delete process.env.USERPROFILE; + else process.env.USERPROFILE = __savedUserProfile; +}); + +// The opencode config dir that configureOpencodePermissions would use for a +// global install when configDir=null: /.config/opencode/ +// The file it writes is opencode.json (or opencode.jsonc if pre-existing). +const OPENCODE_CONFIG_DIR = path.join(FAKE_HOME, '.config', 'opencode'); +const OPENCODE_CONFIG_FILE = path.join(OPENCODE_CONFIG_DIR, 'opencode.json'); + +// configDir is passed explicitly so the function targets our FAKE_HOME dir +// regardless of how getGlobalDir resolves. +const installModule = require(path.join(ROOT, 'bin', 'install.js')); + +const SETTINGS_PATH = path.join(FAKE_HOME, `gsd-test-settings-${process.pid}.json`); + +function callFinishInstall() { + const original = console.log; + console.log = () => {}; + try { + installModule.finishInstall( + SETTINGS_PATH, + {}, + null, + false, + 'opencode', + true, + OPENCODE_CONFIG_DIR, // pass explicit configDir pointing at our temp dir + ); + } finally { + console.log = original; + } +} + +describe('Bug #130: finishInstall opencode + GSD_TEST_MODE side-effect guard', () => { + test('configureOpencodePermissions does NOT write opencode.json under GSD_TEST_MODE', () => { + // Confirm the file does not exist before the call + assert.equal( + fs.existsSync(OPENCODE_CONFIG_FILE), + false, + 'opencode.json should not exist before finishInstall call', + ); + + callFinishInstall(); + + // Assert the file was NOT created — the side-effect must be suppressed + assert.equal( + fs.existsSync(OPENCODE_CONFIG_FILE), + false, + `opencode.json must NOT be created under GSD_TEST_MODE; found at ${OPENCODE_CONFIG_FILE}`, + ); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-410-install-defaults-test-mode-guard.test.cjs — consolidation epic #1969 (B1 #1970) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-410-install-defaults-test-mode-guard (consolidation epic #1969 B1 #1970)", () => { +'use strict'; + +/** + * Bug #410: finishInstall writes ~/.gsd/defaults.json for non-Claude runtimes + * without a GSD_TEST_MODE guard, polluting the real developer home directory + * during test runs. + * + * The opencode permission-config write a few lines above already carries the + * GSD_TEST_MODE guard (added for #130) — this test covers the un-fixed sibling + * (the resolve_model_ids: "omit" write). + */ + +const { test, describe } = require('node:test'); +const { cleanup } = require('./helpers.cjs'); +const assert = require('node:assert/strict'); +const path = require('node:path'); +const os = require('node:os'); +const fs = require('node:fs'); + +const ROOT = path.join(__dirname, '..'); + +// Point HOME at a temp dir so the defaults.json write can't reach the real +// ~/.gsd/ even if the guard is missing. +// On Windows, os.homedir() reads USERPROFILE (not HOME). Set both so +// finishInstall's path.join(os.homedir(), '.gsd') resolves into FAKE_HOME +// on every platform. Node docs: https://nodejs.org/docs/latest-v22.x/api/os.html#oshomedir +const FAKE_HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-410-test-')); +// Consolidation #1969: scope the HOME/USERPROFILE mutation to before/after so it +// does not leak into sibling folded suites (was process-isolated when standalone). +const { before: __foldBefore, after: __foldAfter } = require('node:test'); +const __savedHome = process.env.HOME; +const __savedUserProfile = process.env.USERPROFILE; +__foldBefore(() => { + process.env.HOME = FAKE_HOME; + process.env.USERPROFILE = FAKE_HOME; +}); +__foldAfter(() => { + if (__savedHome === undefined) delete process.env.HOME; + else process.env.HOME = __savedHome; + if (__savedUserProfile === undefined) delete process.env.USERPROFILE; + else process.env.USERPROFILE = __savedUserProfile; +}); + +// The path that finishInstall would write to for a non-Claude runtime. +const GSD_DIR = path.join(FAKE_HOME, '.gsd'); +const DEFAULTS_PATH = path.join(GSD_DIR, 'defaults.json'); + +// Set GSD_TEST_MODE before requiring install.js so any module-level guards +// also see the flag. +process.env.GSD_TEST_MODE = '1'; + +const installModule = require(path.join(ROOT, 'bin', 'install.js')); + +// A synthetic settingsPath that won't exist — finishInstall should cope. +const SETTINGS_PATH = path.join(FAKE_HOME, `gsd-test-settings-${process.pid}.json`); + +function callFinishInstallForRuntime(runtime) { + const original = console.log; + console.log = () => {}; + try { + installModule.finishInstall( + SETTINGS_PATH, + {}, // empty settings + null, // statuslineCommand + false, // shouldInstallStatusline + runtime, + true, // isGlobal + null, // configDir + ); + } finally { + console.log = original; + } +} + +describe('Bug #410: finishInstall non-Claude runtime + GSD_TEST_MODE side-effect guard', () => { + test('defaults.json is NOT written for opencode runtime under GSD_TEST_MODE', () => { + assert.equal( + fs.existsSync(DEFAULTS_PATH), + false, + 'defaults.json should not exist before finishInstall call', + ); + + callFinishInstallForRuntime('opencode'); + + assert.equal( + fs.existsSync(DEFAULTS_PATH), + false, + `defaults.json must NOT be created under GSD_TEST_MODE; found at ${DEFAULTS_PATH}`, + ); + }); + + test('defaults.json is NOT written for gemini runtime under GSD_TEST_MODE', () => { + // Reset in case previous test left artifacts (it shouldn't). + assert.equal( + fs.existsSync(DEFAULTS_PATH), + false, + 'defaults.json should not exist before gemini test', + ); + + callFinishInstallForRuntime('gemini'); + + assert.equal( + fs.existsSync(DEFAULTS_PATH), + false, + `defaults.json must NOT be created under GSD_TEST_MODE for gemini; found at ${DEFAULTS_PATH}`, + ); + }); + + test('defaults.json IS written for opencode runtime when GSD_TEST_MODE is unset', () => { + // Temporarily unset GSD_TEST_MODE to verify the user-facing path still works. + const saved = process.env.GSD_TEST_MODE; + delete process.env.GSD_TEST_MODE; + try { + callFinishInstallForRuntime('opencode'); + assert.equal( + fs.existsSync(DEFAULTS_PATH), + true, + `defaults.json must be written for non-Claude runtime when GSD_TEST_MODE is unset`, + ); + // Verify the written content is correct. + const contents = JSON.parse(fs.readFileSync(DEFAULTS_PATH, 'utf8')); + assert.equal(contents.resolve_model_ids, 'omit', 'resolve_model_ids must be "omit"'); + } finally { + // Restore GSD_TEST_MODE and clean up the written file. + process.env.GSD_TEST_MODE = saved; + cleanup(DEFAULTS_PATH); + try { fs.rmdirSync(GSD_DIR); } catch { /* not empty or already gone */ } + } + }); +}); + +// Bug #1569 folded here (sibling on the SAME finishInstall resolve_model_ids block): +// the #1156 default-to-"omit" step keyed its write on `!== "omit"`, so an explicit +// `resolve_model_ids: true` opt-in (resolveModelInternal returns full materialized +// model IDs) was silently clobbered across all 14 non-Claude runtimes. The fix +// preserves `true` and only defaults absent/falsy → "omit". Reuses the #410 harness. + +describe('Bug #1569: non-Claude finishInstall preserves explicit resolve_model_ids:true', () => { + function seedDefaults(obj) { + fs.mkdirSync(GSD_DIR, { recursive: true }); + fs.writeFileSync(DEFAULTS_PATH, JSON.stringify(obj, null, 2) + '\n', 'utf8'); + } + + function withUserPath(fn) { + const saved = process.env.GSD_TEST_MODE; + delete process.env.GSD_TEST_MODE; + try { + return fn(); + } finally { + process.env.GSD_TEST_MODE = saved; + } + } + + test('explicit resolve_model_ids:true survives a codex global install (the reported case)', () => { + withUserPath(() => { + seedDefaults({ runtime: 'codex', model_profile: 'balanced', resolve_model_ids: true }); + callFinishInstallForRuntime('codex'); + const after = JSON.parse(fs.readFileSync(DEFAULTS_PATH, 'utf8')); + assert.equal( + after.resolve_model_ids, + true, + 'explicit resolve_model_ids:true must be preserved across a codex install, not clobbered to "omit"', + ); + }); + }); + + // The clobber guard is runtime-agnostic (`runtime !== 'claude'`); parameterize + // across a representative slice of non-Claude runtimes. + for (const runtime of ['codex', 'opencode', 'gemini']) { + test(`explicit resolve_model_ids:true survives a ${runtime} global install`, () => { + withUserPath(() => { + seedDefaults({ runtime, resolve_model_ids: true }); + callFinishInstallForRuntime(runtime); + const after = JSON.parse(fs.readFileSync(DEFAULTS_PATH, 'utf8')); + assert.equal( + after.resolve_model_ids, + true, + `explicit resolve_model_ids:true must be preserved for ${runtime}`, + ); + }); + }); + } + + test('absent resolve_model_ids still defaults to "omit" (preserves #1156 intent)', () => { + withUserPath(() => { + seedDefaults({ runtime: 'codex' }); + callFinishInstallForRuntime('codex'); + const after = JSON.parse(fs.readFileSync(DEFAULTS_PATH, 'utf8')); + assert.equal( + after.resolve_model_ids, + 'omit', + 'absent resolve_model_ids must still default to "omit" for non-Claude runtimes', + ); + }); + }); + + test('explicit resolve_model_ids:false still defaults to "omit"', () => { + withUserPath(() => { + seedDefaults({ runtime: 'codex', resolve_model_ids: false }); + callFinishInstallForRuntime('codex'); + const after = JSON.parse(fs.readFileSync(DEFAULTS_PATH, 'utf8')); + assert.equal(after.resolve_model_ids, 'omit', 'false must still be normalized to "omit"'); + }); + }); + + test('non-canonical resolve_model_ids values (0, "", "yes", {}) default to "omit" — no Claude alias leak (#1569 codex review)', () => { + // The domain is true/false/"omit"/absent. Any OTHER value is malformed; the safe + // non-Claude default is "omit" (don't leak Claude aliases the runtime can't resolve). + withUserPath(() => { + for (const bad of [0, '', 'yes', {}]) { + seedDefaults({ runtime: 'codex', resolve_model_ids: bad }); + callFinishInstallForRuntime('codex'); + const after = JSON.parse(fs.readFileSync(DEFAULTS_PATH, 'utf8')); + assert.equal( + after.resolve_model_ids, + 'omit', + `non-canonical resolve_model_ids:${JSON.stringify(bad)} must default to "omit", not pass through`, + ); + } + }); + }); + + test('already-"omit" is left unchanged (idempotent, no rewrite churn)', () => { + withUserPath(() => { + seedDefaults({ runtime: 'codex', resolve_model_ids: 'omit' }); + const beforeMtime = fs.statSync(DEFAULTS_PATH).mtimeMs; + // fs mtime resolution can be coarse; wait briefly so an accidental rewrite is detectable. + const start = Date.now(); + while (Date.now() - start < 20) { /* spin briefly */ } + callFinishInstallForRuntime('codex'); + const after = JSON.parse(fs.readFileSync(DEFAULTS_PATH, 'utf8')); + const afterMtime = fs.statSync(DEFAULTS_PATH).mtimeMs; + assert.equal(after.resolve_model_ids, 'omit'); + assert.equal( + afterMtime, + beforeMtime, + 'defaults.json must not be rewritten when resolve_model_ids is already "omit" (idempotent)', + ); + }); + }); + + test('claude runtime never touches resolve_model_ids (cross-runtime parity)', () => { + withUserPath(() => { + seedDefaults({ runtime: 'claude', resolve_model_ids: true }); + callFinishInstallForRuntime('claude'); + const after = JSON.parse(fs.readFileSync(DEFAULTS_PATH, 'utf8')); + assert.equal( + after.resolve_model_ids, + true, + 'claude install must never rewrite resolve_model_ids', + ); + }); + }); + + test('malformed defaults.json does not crash — still defaults to "omit"', () => { + withUserPath(() => { + fs.mkdirSync(GSD_DIR, { recursive: true }); + fs.writeFileSync(DEFAULTS_PATH, '{ not valid json }', 'utf8'); + // Must not throw. + callFinishInstallForRuntime('codex'); + const after = JSON.parse(fs.readFileSync(DEFAULTS_PATH, 'utf8')); + assert.equal( + after.resolve_model_ids, + 'omit', + 'malformed defaults.json must be recovered to a valid state with resolve_model_ids:omit', + ); + }); + }); +}); + +// Bug #1657 — finishInstall reads ~/.gsd/defaults.json with JSON.parse but did not +// validate the result is a plain object. A valid-JSON-but-non-object value (null, [], +// 42, "str") bypassed the catch and flowed through, leaving the malformed file on disk +// unrecovered (and, for null, throwing a TypeError swallowed by the outer try/catch). +// Folded into the owning install-defaults test (no new top-level bug-NNNN file). +describe('Bug #1657: finishInstall recovers a malformed (non-object) defaults.json', () => { + function seedDefaultsRaw(raw) { + fs.mkdirSync(GSD_DIR, { recursive: true }); + fs.writeFileSync(DEFAULTS_PATH, raw, 'utf8'); + } + function runAndRead(runtime) { + const saved = process.env.GSD_TEST_MODE; + delete process.env.GSD_TEST_MODE; + const log = console.log; console.log = () => {}; + let threw = null; + try { + installModule.finishInstall(SETTINGS_PATH, {}, null, false, runtime, true, null); + } catch (e) { threw = e.message; } finally { console.log = log; process.env.GSD_TEST_MODE = saved; } + let after = null; + try { after = JSON.parse(fs.readFileSync(DEFAULTS_PATH, 'utf8')); } catch (e) { after = 'UNPARSEABLE: ' + e.message; } + return { threw, after }; + } + + for (const [label, raw] of [['null', 'null'], ['array', '[]'], ['number', '42'], ['string', '"oops"']]) { + test(`seed ${label} (${raw}) recovers to a valid object with resolve_model_ids:omit`, () => { + seedDefaultsRaw(raw); + const { threw, after } = runAndRead('codex'); + assert.equal(threw, null, `must not throw for seed ${label} (got: ${threw})`); + assert.equal( + after !== null && typeof after === 'object' && !Array.isArray(after) && after.resolve_model_ids === 'omit', + true, + `seed ${label} must recover to { resolve_model_ids: 'omit' }, got: ${JSON.stringify(after)}`, + ); + }); + } +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-1736-local-install-commands.test.cjs — consolidation epic #1969 (B1 #1970) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-1736-local-install-commands (consolidation epic #1969 B1 #1970)", () => { +/** + * Regression test for #1736: local Claude install missing commands/gsd/ + * + * After a fresh local install (`--claude --local`), all /gsd-* commands + * except /gsd-help return "Unknown skill: gsd-quick" because + * .claude/commands/gsd/ was not populated. Claude Code reads local project + * commands from .claude/commands/ (one level up) using the file stem as the + * command name. + * + * #1367 follow-up: the fix changed the layout from the old commands/gsd/.md + * (which caused /gsd: colon namespace) to flat commands/gsd-.md + * (which produces /gsd- hyphen form). This test has been updated to assert + * the new flat layout while preserving the core invariant from #1736: commands + * must be present and usable after a local install. + */ + +'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('fs'); +const path = require('path'); +const os = require('os'); +const { execFileSync } = require('child_process'); + +const INSTALL_SRC = path.join(__dirname, '..', 'bin', 'install.js'); +const BUILD_SCRIPT = path.join(__dirname, '..', 'scripts', 'build-hooks.js'); +const { install } = require(INSTALL_SRC); +const { cleanup } = require('./helpers.cjs'); + +// ─── Ensure hooks/dist/ is populated before install tests ──────────────────── +// With --test-concurrency=4, other install tests (bug-1834, bug-1924) run +// build-hooks.js concurrently. That script creates hooks/dist/ empty first, +// then copies files — creating a window where this test sees an empty dir and +// install() fails with "directory is empty" → process.exit(1). + +before(() => { + execFileSync(process.execPath, [BUILD_SCRIPT], { + encoding: 'utf-8', + stdio: 'pipe', + }); +}); + +// ─── #1736 + #1367: local install deploys commands in flat gsd-.md layout ─── + +describe('#1736: local Claude install deploys slash commands (flat gsd-.md layout, #1367)', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-local-install-1736-')); + }); + + afterEach(() => { + // Use the shared helper which has a 5s Windows-EBUSY retry budget + // (20×250ms). The inline 1s budget here was insufficient on cold runners. + cleanup(tmpDir); + }); + + test('local install creates .claude/commands/ directory with flat gsd-*.md files (#1367)', (t) => { + // #1736 invariant: commands must be deployed. + // #1367 fix: commands land as flat gsd-.md at commands/ (not commands/gsd/.md). + const origCwd = process.cwd(); + t.after(() => { process.chdir(origCwd); }); + process.chdir(tmpDir); + install(false, 'claude'); + + const commandsDir = path.join(tmpDir, '.claude', 'commands'); + assert.ok( + fs.existsSync(commandsDir), + '.claude/commands/ directory must exist after local install' + ); + const flatFiles = fs.readdirSync(commandsDir).filter(f => f.startsWith('gsd-') && f.endsWith('.md')); + assert.ok( + flatFiles.length > 0, + `.claude/commands/ must have flat gsd-*.md files (e.g. gsd-help.md). Found: ${JSON.stringify(flatFiles)}` + ); + // The old commands/gsd/ subdirectory must NOT exist (#1367) + const oldSubdir = path.join(commandsDir, 'gsd'); + assert.ok( + !fs.existsSync(oldSubdir), + '.claude/commands/gsd/ subdir must NOT exist — flat gsd-.md layout required (#1367)' + ); + }); + + test('local install deploys at least one .md command file to .claude/commands/ (#1736 invariant)', (t) => { + const origCwd = process.cwd(); + t.after(() => { process.chdir(origCwd); }); + process.chdir(tmpDir); + install(false, 'claude'); + + const commandsDir = path.join(tmpDir, '.claude', 'commands'); + assert.ok( + fs.existsSync(commandsDir), + '.claude/commands/ must exist' + ); + + const files = fs.readdirSync(commandsDir).filter(f => f.startsWith('gsd-') && f.endsWith('.md')); + assert.ok( + files.length > 0, + `.claude/commands/ must contain at least one gsd-*.md file, found: ${JSON.stringify(files)}` + ); + }); + + test('local install deploys gsd-quick.md to .claude/commands/ (#1367: flat hyphen form)', (t) => { + // Was: .claude/commands/gsd/quick.md (caused /gsd:quick colon form). + // Now: .claude/commands/gsd-quick.md (produces /gsd-quick hyphen form). + const origCwd = process.cwd(); + t.after(() => { process.chdir(origCwd); }); + process.chdir(tmpDir); + install(false, 'claude'); + + const quickCmd = path.join(tmpDir, '.claude', 'commands', 'gsd-quick.md'); + assert.ok( + fs.existsSync(quickCmd), + '.claude/commands/gsd-quick.md must exist after local install (#1367 flat layout)' + ); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-2248-local-install-statusline.test.cjs — consolidation epic #1969 (B1 #1970) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-2248-local-install-statusline (consolidation epic #1969 B1 #1970)", () => { +/** + * Regression test for #2248: local Claude install clobbers profile-level statusLine + * + * When installing with `--claude --local`, the repo-level `.claude/settings.json` + * takes precedence over the user's profile-level `~/.claude/settings.json` in + * Claude Code. Writing `statusLine` to repo settings during a local install + * silently overrides any profile-level statusLine the user configured. + * + * Fix: local installs skip writing `statusLine` to settings.json unless + * `--force-statusline` is passed. + * + * Note: `install()` only copies files. `finishInstall()` writes settings.json. + * The production code calls both from `installAllRuntimes()`. Tests must mirror + * that two-phase pattern. + */ + +'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('fs'); +const path = require('path'); +const os = require('os'); +const { execFileSync } = require('child_process'); + +const INSTALL_SRC = path.join(__dirname, '..', 'bin', 'install.js'); +const BUILD_SCRIPT = path.join(__dirname, '..', 'scripts', 'build-hooks.js'); +const { install, finishInstall } = require(INSTALL_SRC); +const { cleanup, captureConsole } = require('./helpers.cjs'); + +// ─── Ensure hooks/dist/ is populated before install tests ──────────────────── +before(() => { + execFileSync(process.execPath, [BUILD_SCRIPT], { + encoding: 'utf-8', + stdio: 'pipe', + }); +}); + +// ─── #2248: local install must NOT write statusLine to repo settings.json ──── + +describe('#2248: local Claude install does not clobber profile-level statusLine', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-local-install-2248-')); + }); + + afterEach(() => { + // Use the shared 5s Windows-EBUSY retry budget instead of inline 1s. + cleanup(tmpDir); + }); + + test('local install writes hooks to .claude/settings.local.json and does not write statusLine', (t) => { + const origCwd = process.cwd(); + t.after(() => { process.chdir(origCwd); }); + process.chdir(tmpDir); + + // Phase 1: copy files (mirrors installAllRuntimes) + const result = install(false, 'claude'); + + // Phase 2: configure settings.local.json (mirrors installAllRuntimes → finalize) + // #338: local Claude installs now write to settings.local.json, not settings.json. + // shouldInstallStatusline=true mirrors what handleStatusline picks for a fresh install + const { stdout } = captureConsole(() => { + finishInstall( + result.settingsPath, + result.settings, + result.statuslineCommand, + true, // shouldInstallStatusline + 'claude', + false // isGlobal=false -> local install + ); + }); + assert.match( + stdout, + /Skipping statusLine for local install/, + 'Local install must explain that it skipped statusLine unless --force-statusline is passed' + ); + + // #338: local installs write to settings.local.json, not settings.json + const localSettingsPath = path.join(tmpDir, '.claude', 'settings.local.json'); + assert.ok( + fs.existsSync(localSettingsPath), + '.claude/settings.local.json must exist after local Claude install (#338)' + ); + + const settings = JSON.parse(fs.readFileSync(localSettingsPath, 'utf-8')); + assert.strictEqual( + settings.statusLine, + undefined, + 'Local install must not write statusLine to settings.local.json — it would clobber profile-level settings (#2248)' + ); + + // settings.json must not be touched by a fresh local install + const sharedSettingsPath = path.join(tmpDir, '.claude', 'settings.json'); + assert.strictEqual( + fs.existsSync(sharedSettingsPath), + false, + '.claude/settings.json must NOT be created by a fresh local Claude install (#338)' + ); + }); + + test('global install still writes statusLine to settings.json', (t) => { + const origCwd = process.cwd(); + t.after(() => { process.chdir(origCwd); }); + + // Global install writes to CLAUDE_CONFIG_DIR; point it at our tmpDir + const configDir = path.join(tmpDir, '.claude'); + fs.mkdirSync(configDir, { recursive: true }); + const origEnv = process.env.CLAUDE_CONFIG_DIR; + process.env.CLAUDE_CONFIG_DIR = configDir; + t.after(() => { + if (origEnv === undefined) { + delete process.env.CLAUDE_CONFIG_DIR; + } else { + process.env.CLAUDE_CONFIG_DIR = origEnv; + } + }); + + // Phase 1: copy files + const result = install(true, 'claude'); + + // Phase 2: configure settings.json + finishInstall( + result.settingsPath, + result.settings, + result.statuslineCommand, + true, // shouldInstallStatusline + 'claude', + true // isGlobal=true + ); + + const settingsPath = path.join(configDir, 'settings.json'); + assert.ok( + fs.existsSync(settingsPath), + '~/.claude/settings.json must exist after global install' + ); + + const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')); + assert.ok( + settings.statusLine !== undefined, + 'Global install should write statusLine to settings.json' + ); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-338-local-install-settings-local-json.test.cjs — consolidation epic #1969 (B1 #1970) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-338-local-install-settings-local-json (consolidation epic #1969 B1 #1970)", () => { +/** + * Regression tests for #338: Claude --local installs must write hook wiring to + * `.claude/settings.local.json` (Claude Code's per-user gitignored slot) instead + * of the repo-shared `.claude/settings.json`. + * + * Three cases: + * 1. Fresh local install: settings.local.json is created with hook block; + * settings.json is not touched. + * 2. Global install (regression guard): continues to write to settings.json. + * 3. Migration: if a prior local install wrote GSD entries to settings.json, + * re-running local install moves them to settings.local.json and removes + * them from settings.json in the same run. + * + * Note: `install()` only copies files. `finishInstall()` writes settings. + * The production code calls both from `installAllRuntimes()`. Tests mirror + * that two-phase pattern. + */ + +'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('fs'); +const path = require('path'); +const os = require('os'); +const { execFileSync } = require('child_process'); + +const INSTALL_SRC = path.join(__dirname, '..', 'bin', 'install.js'); +const BUILD_SCRIPT = path.join(__dirname, '..', 'scripts', 'build-hooks.js'); +const { install, finishInstall } = require(INSTALL_SRC); +const { cleanup } = require('./helpers.cjs'); + +// ─── Ensure hooks/dist/ is populated before install tests ──────────────────── +before(() => { + execFileSync(process.execPath, [BUILD_SCRIPT], { + encoding: 'utf-8', + stdio: 'pipe', + }); +}); + +// ─── Helper: run both install phases ───────────────────────────────────────── + +/** + * Run install + finishInstall (mirrors installAllRuntimes two-phase pattern). + * @param {boolean} isGlobal + * @param {object} [opts] + * @param {boolean} [opts.shouldInstallStatusline] + * @returns {{ result: object }} + */ +function runInstall(isGlobal, opts = {}) { + const { shouldInstallStatusline = false } = opts; + const result = install(isGlobal, 'claude'); + finishInstall( + result.settingsPath, + result.settings, + result.statuslineCommand, + shouldInstallStatusline, + 'claude', + isGlobal + ); + return { result }; +} + +// ─── Case 1: fresh local install → settings.local.json, not settings.json ─── + +describe('#338 case 1: fresh local Claude install writes to settings.local.json', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-338-local-')); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('settings.local.json is created with hook block', (t) => { + const origCwd = process.cwd(); + t.after(() => { process.chdir(origCwd); }); + process.chdir(tmpDir); + + runInstall(false); + + const localSettingsPath = path.join(tmpDir, '.claude', 'settings.local.json'); + assert.ok( + fs.existsSync(localSettingsPath), + '.claude/settings.local.json must exist after local Claude install (#338)' + ); + + const settings = JSON.parse(fs.readFileSync(localSettingsPath, 'utf-8')); + assert.ok( + settings && typeof settings === 'object', + 'settings.local.json must be a valid JSON object' + ); + // Hook block must be present (hooks key or at minimum the file was written) + assert.ok( + settings.hooks !== undefined || Object.keys(settings).length >= 0, + 'settings.local.json must contain the hook block' + ); + }); + + test('settings.json is NOT created by a fresh local install', (t) => { + const origCwd = process.cwd(); + t.after(() => { process.chdir(origCwd); }); + process.chdir(tmpDir); + + runInstall(false); + + const sharedSettingsPath = path.join(tmpDir, '.claude', 'settings.json'); + assert.strictEqual( + fs.existsSync(sharedSettingsPath), + false, + '.claude/settings.json must NOT be created by a fresh local Claude install (#338) — ' + + 'engineer-specific absolute paths must not leak into the repo-shared file' + ); + }); + + test('install() returns settingsPath pointing to settings.local.json', (t) => { + const origCwd = process.cwd(); + t.after(() => { process.chdir(origCwd); }); + process.chdir(tmpDir); + + const result = install(false, 'claude'); + assert.ok( + result.settingsPath.endsWith('settings.local.json'), + `install() must return settingsPath ending in settings.local.json for local Claude installs; got: ${result.settingsPath}` + ); + }); +}); + +// ─── Case 2: global Claude install (regression guard) ──────────────────────── + +describe('#338 case 2: global Claude install continues to write to settings.json', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-338-global-')); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('global install writes hook block to settings.json', (t) => { + const origCwd = process.cwd(); + t.after(() => { process.chdir(origCwd); }); + + // Point CLAUDE_CONFIG_DIR at a subdir of tmpDir to avoid polluting ~/.claude + const configDir = path.join(tmpDir, '.claude'); + fs.mkdirSync(configDir, { recursive: true }); + const origEnv = process.env.CLAUDE_CONFIG_DIR; + process.env.CLAUDE_CONFIG_DIR = configDir; + t.after(() => { + if (origEnv === undefined) { + delete process.env.CLAUDE_CONFIG_DIR; + } else { + process.env.CLAUDE_CONFIG_DIR = origEnv; + } + }); + + runInstall(true); + + const settingsPath = path.join(configDir, 'settings.json'); + assert.ok( + fs.existsSync(settingsPath), + '~/.claude/settings.json must exist after global Claude install (regression guard for #338)' + ); + const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')); + assert.ok( + settings && typeof settings === 'object', + 'settings.json must be a valid JSON object after global install' + ); + }); + + test('global install does NOT create settings.local.json', (t) => { + const origCwd = process.cwd(); + t.after(() => { process.chdir(origCwd); }); + + const configDir = path.join(tmpDir, '.claude'); + fs.mkdirSync(configDir, { recursive: true }); + const origEnv = process.env.CLAUDE_CONFIG_DIR; + process.env.CLAUDE_CONFIG_DIR = configDir; + t.after(() => { + if (origEnv === undefined) { + delete process.env.CLAUDE_CONFIG_DIR; + } else { + process.env.CLAUDE_CONFIG_DIR = origEnv; + } + }); + + runInstall(true); + + const localSettingsPath = path.join(configDir, 'settings.local.json'); + assert.strictEqual( + fs.existsSync(localSettingsPath), + false, + '~/.claude/settings.local.json must NOT be created by a global Claude install' + ); + }); +}); + +// ─── Case 3: migration — prior local install wrote GSD entries to settings.json ─ + +describe('#338 case 3: migration of prior local install GSD entries from settings.json to settings.local.json', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-338-migrate-')); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('GSD hook entries are moved from settings.json to settings.local.json', (t) => { + const origCwd = process.cwd(); + t.after(() => { process.chdir(origCwd); }); + process.chdir(tmpDir); + + // Pre-populate .claude/settings.json with a GSD-shaped hook block (simulating + // a prior local install that wrote to the wrong file). + const claudeDir = path.join(tmpDir, '.claude'); + fs.mkdirSync(claudeDir, { recursive: true }); + const sharedSettingsPath = path.join(claudeDir, 'settings.json'); + const priorSettings = { + hooks: { + SessionStart: [ + { + hooks: [ + { + type: 'command', + command: `${process.execPath} ${path.join(claudeDir, 'hooks', 'gsd-check-update.js')}`, + } + ] + } + ], + PostToolUse: [ + { + matcher: 'Bash|Edit|Write|MultiEdit|Agent|Task', + hooks: [ + { + type: 'command', + command: `${process.execPath} ${path.join(claudeDir, 'hooks', 'gsd-context-monitor.js')}`, + timeout: 10, + } + ] + } + ] + } + }; + fs.writeFileSync(sharedSettingsPath, JSON.stringify(priorSettings, null, 2) + '\n'); + + // Run a fresh local install — this should trigger migration + runInstall(false); + + // Verify GSD entries are now in settings.local.json + const localSettingsPath = path.join(claudeDir, 'settings.local.json'); + assert.ok( + fs.existsSync(localSettingsPath), + '.claude/settings.local.json must exist after migration run' + ); + const localSettings = JSON.parse(fs.readFileSync(localSettingsPath, 'utf-8')); + const sessionStartHooks = (localSettings.hooks && localSettings.hooks.SessionStart) || []; + const hasGsdUpdateHook = sessionStartHooks.some( + entry => entry && entry.hooks && Array.isArray(entry.hooks) && + entry.hooks.some(h => h && h.command && h.command.includes('gsd-check-update')) + ); + assert.ok( + hasGsdUpdateHook, + 'settings.local.json must contain the migrated gsd-check-update hook after migration' + ); + }); + + test('GSD hook entries are removed from settings.json after migration', (t) => { + const origCwd = process.cwd(); + t.after(() => { process.chdir(origCwd); }); + process.chdir(tmpDir); + + const claudeDir = path.join(tmpDir, '.claude'); + fs.mkdirSync(claudeDir, { recursive: true }); + const sharedSettingsPath = path.join(claudeDir, 'settings.json'); + const priorSettings = { + // Include a non-GSD key to verify user content is preserved + myCustomKey: 'keep-me', + hooks: { + SessionStart: [ + { + hooks: [ + { + type: 'command', + command: `${process.execPath} ${path.join(claudeDir, 'hooks', 'gsd-check-update.js')}`, + } + ] + } + ] + } + }; + fs.writeFileSync(sharedSettingsPath, JSON.stringify(priorSettings, null, 2) + '\n'); + + runInstall(false); + + // settings.json must exist (we don't delete it — user may have other content) + assert.ok( + fs.existsSync(sharedSettingsPath), + '.claude/settings.json must still exist after migration (may have non-GSD user content)' + ); + const sharedSettings = JSON.parse(fs.readFileSync(sharedSettingsPath, 'utf-8')); + + // GSD hooks must be gone from settings.json + const sessionStartHooks = (sharedSettings.hooks && sharedSettings.hooks.SessionStart) || []; + const hasGsdHook = sessionStartHooks.some( + entry => entry && entry.hooks && Array.isArray(entry.hooks) && + entry.hooks.some(h => h && h.command && h.command.includes('gsd-check-update')) + ); + assert.strictEqual( + hasGsdHook, + false, + 'GSD hook entries must be removed from settings.json after migration to settings.local.json' + ); + + // Non-GSD user content must be preserved + assert.strictEqual( + sharedSettings.myCustomKey, + 'keep-me', + 'Non-GSD user content in settings.json must be preserved during migration' + ); + }); + + test('settings.json with no GSD entries is left unchanged', (t) => { + const origCwd = process.cwd(); + t.after(() => { process.chdir(origCwd); }); + process.chdir(tmpDir); + + const claudeDir = path.join(tmpDir, '.claude'); + fs.mkdirSync(claudeDir, { recursive: true }); + const sharedSettingsPath = path.join(claudeDir, 'settings.json'); + const userOnlySettings = { + userKey: 'user-value', + hooks: { + SessionStart: [ + { + hooks: [ + { + type: 'command', + command: '/usr/local/bin/my-own-hook.sh', + } + ] + } + ] + } + }; + const originalContent = JSON.stringify(userOnlySettings, null, 2) + '\n'; + fs.writeFileSync(sharedSettingsPath, originalContent); + + runInstall(false); + + // settings.json must be unchanged (no GSD entries to migrate) + const afterContent = fs.readFileSync(sharedSettingsPath, 'utf-8'); + const afterSettings = JSON.parse(afterContent); + assert.strictEqual( + afterSettings.userKey, + 'user-value', + 'Non-GSD settings.json must be untouched when no GSD entries are present' + ); + // User hook must still be there + const sessionStart = (afterSettings.hooks && afterSettings.hooks.SessionStart) || []; + const hasUserHook = sessionStart.some( + entry => entry && entry.hooks && entry.hooks.some(h => h && h.command === '/usr/local/bin/my-own-hook.sh') + ); + assert.ok( + hasUserHook, + 'User hook in settings.json must be preserved when no migration occurs' + ); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-2957-claude-global-postinstall-message.test.cjs — consolidation epic #1969 (B1 #1970) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-2957-claude-global-postinstall-message (consolidation epic #1969 B1 #1970)", () => { +'use strict'; + +process.env.GSD_TEST_MODE = '1'; + +/** + * Bug #2957: post-install message for `--claude --global` must instruct + * users to restart Claude Code and offer the skill-name fallback, since + * the skills-only install layout (CC 2.1.88+) leaves nothing in + * commands/gsd/ for the slash menu to read on older configurations. + * + * Captures the call to finishInstall(runtime='claude', isGlobal=true) and + * asserts the printed message contains both invocation paths. + */ + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); +const path = require('node:path'); +const os = require('node:os'); + +const ROOT = path.join(__dirname, '..'); +const SETTINGS_PATH = path.join(os.tmpdir(), `gsd-test-settings-${process.pid}.json`); +const installModule = require(path.join(ROOT, 'bin', 'install.js')); + +function captureFinishInstallOutput(runtime, isGlobal) { + const original = console.log; + const lines = []; + console.log = (...args) => { lines.push(args.join(' ')); }; + try { + installModule.finishInstall( + SETTINGS_PATH, + {}, + null, + false, + runtime, + isGlobal, + null, + ); + } finally { + console.log = original; + } + // Strip ANSI color escapes so message-content assertions don't couple to colors. + // eslint-disable-next-line no-control-regex -- \x1b (ESC) is the required leading byte of ANSI SGR color sequences; matching it is the purpose of stripping ANSI codes from captured CLI/console output + return lines.join('\n').replace(/\x1B\[[0-9;]*m/g, ''); +} + +describe('Bug #2957: claude+global post-install message', () => { + test('claude+global message tells the user to restart and offers skill-name fallback', () => { + const output = captureFinishInstallOutput('claude', true); + + assert.match(output, /restart claude code/i, 'should mention restart'); + assert.match(output, /\/gsd-new-project/, 'should still mention /gsd-new-project'); + assert.match(output, /gsd-new-project skill/i, 'should mention the skill name fallback'); + assert.doesNotMatch( + output, + /open a blank directory/i, + 'global claude install should replace, not extend, the legacy generic instruction', + ); + }); + + test('claude+local message keeps the original /gsd-new-project instruction', () => { + const output = captureFinishInstallOutput('claude', false); + + assert.match(output, /\/gsd-new-project/, 'should still mention /gsd-new-project'); + assert.doesNotMatch(output, /restart claude code/i, 'local install does not require the skills restart note'); + }); + + test('non-claude runtimes keep their original message format', () => { + const output = captureFinishInstallOutput('opencode', true); + + assert.match(output, /Open a blank directory/, 'opencode message should be unchanged'); + assert.doesNotMatch(output, /restart/i, 'opencode message should not have the claude-specific restart note'); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-505-remove-dead-sdk-verification.test.cjs — consolidation epic #1969 (B1 #1970) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-505-remove-dead-sdk-verification (consolidation epic #1969 B1 #1970)", () => { +/** + * Regression guard for #505: dead SDK-shim verification subsystem removed. + * + * Post-ADR-0174 the `@opengsd/gsd-sdk` package was retired; `sdk/` no longer + * ships. `installSdkIfNeeded` and all functions it transitively called are + * dead code with no live callers. This test asserts: + * + * 1. All removed symbols are NO LONGER exported from bin/install.js. + * 2. The two live stale-standalone-SDK helpers (detectStaleStandaloneSdk, + * formatStaleStandaloneSdkWarning) are STILL exported as functions — they + * handle a real user-facing condition (#3406) and MUST NOT be removed. + */ + +'use strict'; + +process.env.GSD_TEST_MODE = '1'; + +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); + +const inst = require('../bin/install.js'); + +describe('bug #505: dead SDK verification subsystem removed from bin/install.js', () => { + // ---------------------------------------------------------------- + // Dead symbols — must NOT be exported after removal + // ---------------------------------------------------------------- + const deadSymbols = [ + 'installSdkIfNeeded', + 'classifySdkInstall', + 'buildSdkFailFastReport', + 'renderSdkFailFastReport', + 'buildGsdSdkVersionMismatchReport', + 'renderGsdSdkVersionMismatchReport', + 'readGsdSdkVersion', + 'parseGsdSdkVersion', + 'findGsdSdkOnPath', + 'isGsdSdkOnPath', + 'isLegacyGsdSdkShim', + 'trySelfLinkGsdSdk', + 'trySelfLinkGsdSdkWindows', + 'filterNpxFromPath', + 'getUserShellPath', + 'getUserShellWindowsPersistentPath', + ]; + + for (const sym of deadSymbols) { + test(`dead symbol '${sym}' is not exported`, () => { + assert.equal( + typeof inst[sym], + 'undefined', + `'${sym}' should have been removed (post #505 dead-code removal) but is still exported as ${typeof inst[sym]}`, + ); + }); + } + + // ---------------------------------------------------------------- + // The stale-standalone-SDK helpers (detectStaleStandaloneSdk, + // formatStaleStandaloneSdkWarning) and the gsd-sdk shim contract surface + // (buildWindowsShimTriple, formatSdkPathDiagnostic) that #505 kept were + // removed when the gsd-sdk shim itself was retired (#191). Their absence is + // covered by the dead-symbol assertions above. + // ---------------------------------------------------------------- +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-376-claude-js-hook-gsd-rewriter.test.cjs — consolidation epic #1969 (B1 #1970) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-376-claude-js-hook-gsd-rewriter (consolidation epic #1969 B1 #1970)", () => { +'use strict'; + +/** + * Regression for bug #376 — Claude-installed hook JS files ship with raw + * /gsd: command literals because the hook-copy loop in install.js had + * no /gsd: → /gsd- rewrite for the claude runtime. + * + * Fix: the `.js` branch of the hook-copy loop now applies + * `content.replace(/gsd:/gi, 'gsd-')` when + * `shouldNormalizeHyphenNamespaceInAgentBody(runtime)` is true (covers + * claude, qwen, hermes). + * + * Test plan: + * 1. Claude install to tmp prefix — installed .js hook files must contain + * no user-facing /gsd: literals (// comment occurrences exempted). + * 2. Cursor install regression — still rewrites correctly (pre-existing + * branch must remain intact). + * 3. Source files in hooks/ must be byte-identical before and after both + * installs (install-time rewrite only, no in-tree mutation). + */ + +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 HOOKS_DIST_DIR = path.join(REPO_ROOT, 'hooks', 'dist'); +const BUILD_HOOKS_SCRIPT = path.join(REPO_ROOT, 'scripts', 'build-hooks.js'); + +/** + * Ensure hooks/dist is populated before any suite that reads it. + * hooks/dist/ is gitignored and only produced by `npm run build:hooks`. + * In CI the scoped/windows test jobs do NOT run build:hooks before running + * tests, so the first test that needs hooks/dist would fail. This mirrors + * the pattern used in bug-3357-codex-legacy-hooks-json-migration.test.cjs. + */ +function ensureHooksDist() { + if (!fs.existsSync(HOOKS_DIST_DIR) || fs.readdirSync(HOOKS_DIST_DIR).filter(f => f.endsWith('.js')).length === 0) { + execFileSync(process.execPath, [BUILD_HOOKS_SCRIPT], { stdio: 'pipe' }); + } +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** + * Run `node install.js <...args>` from cwd. + * GSD_TEST_MODE is cleared so the install() main block executes. + */ +function runInstall(cwd, args) { + const env = { ...process.env }; + delete env.GSD_TEST_MODE; + execFileSync(process.execPath, [INSTALL_PATH, ...args], { + cwd, + encoding: 'utf-8', + stdio: ['pipe', 'pipe', 'pipe'], + env, + timeout: 60000, + }); +} + +/** + * Return an array of { rel, path } for all .js files under dir. + */ +function findJsFiles(dir) { + const results = []; + function walk(d) { + for (const entry of fs.readdirSync(d, { withFileTypes: true })) { + const full = path.join(d, entry.name); + if (entry.isDirectory()) walk(full); + else if (entry.name.endsWith('.js') || entry.name.endsWith('.cjs')) { + results.push({ rel: path.relative(dir, full), full }); + } + } + } + walk(dir); + return results; +} + +/** + * Split a JS file's lines into comment and non-comment buckets. + * A line is treated as a comment if it starts with optional whitespace + * followed by // (single-line comment). Block comments are not checked + * since none of the hook files use them for command refs. + */ +function nonCommentLines(content) { + return content.split('\n').filter(line => !/^\s*\/\//.test(line)); +} + +/** + * Return lines (from nonCommentLines) that contain a user-facing /gsd: ref. + */ +function colonRefs(content) { + return nonCommentLines(content).filter(line => /\/gsd:/.test(line)); +} + +// --------------------------------------------------------------------------- +// Prerequisite: hooks/dist must exist (built by `npm run build:hooks`) +// --------------------------------------------------------------------------- +describe('bug #376 — prerequisite: hooks/dist is present', () => { + before(() => { + // hooks/dist is gitignored; build it on demand so this test is + // deterministic in CI scoped/windows jobs that don't pre-run build:hooks. + ensureHooksDist(); + }); + + test('hooks/dist directory exists (run npm run build:hooks if missing)', () => { + assert.ok( + fs.existsSync(HOOKS_DIST_DIR), + `hooks/dist not found at ${HOOKS_DIST_DIR}. Run: npm run build:hooks`, + ); + }); + + test('hooks/dist contains at least one .js hook file with a /gsd: literal', () => { + const jsFiles = findJsFiles(HOOKS_DIST_DIR); + assert.ok(jsFiles.length > 0, 'hooks/dist must contain .js files'); + + const withColonRef = jsFiles.filter(({ full }) => { + const content = fs.readFileSync(full, 'utf-8'); + return colonRefs(content).length > 0; + }); + + assert.ok( + withColonRef.length > 0, + 'Expected at least one hooks/dist .js file with a non-comment /gsd: literal ' + + '— this confirms the test is guarding a real regression surface. ' + + `Files checked: ${jsFiles.map(f => f.rel).join(', ')}`, + ); + }); +}); + +// --------------------------------------------------------------------------- +// Suite 1 — Claude install: no /gsd: colon refs in installed .js hook files +// --------------------------------------------------------------------------- +describe('bug #376 — Suite 1: Claude install rewrites /gsd: → /gsd- in hook .js files', () => { + let tmpDir; + + before(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-376-claude-')); + runInstall(tmpDir, ['--claude', '--local', '--no-sdk']); + }); + + after(() => { + cleanup(tmpDir); + }); + + test('1a: hooks/ directory is created by the Claude local install', () => { + const hooksDir = path.join(tmpDir, '.claude', 'hooks'); + assert.ok( + fs.existsSync(hooksDir), + `hooks/ must be created at ${hooksDir} by Claude local install`, + ); + }); + + test('1b: installed .js hook files contain no user-facing /gsd: colon refs', () => { + const hooksDir = path.join(tmpDir, '.claude', 'hooks'); + if (!fs.existsSync(hooksDir)) { + // If hooks/ wasn't created (hooks/dist missing at install time), skip gracefully + return; + } + + const jsFiles = findJsFiles(hooksDir); + assert.ok(jsFiles.length > 0, 'At least one .js hook file must be installed'); + + const offenders = []; + for (const { rel, full } of jsFiles) { + const content = fs.readFileSync(full, 'utf-8'); + const badLines = colonRefs(content); + if (badLines.length > 0) { + offenders.push({ rel, lines: badLines }); + } + } + + assert.deepEqual( + offenders, + [], + 'Installed Claude hook .js files must not contain /gsd: colon refs ' + + '(non-comment occurrences). The install-time rewriter must replace these with /gsd-. ' + + 'Offenders: ' + JSON.stringify(offenders, null, 2), + ); + }); + + test('1c: installed .js hook files DO contain the hyphen form /gsd- (rewrite happened)', () => { + const hooksDir = path.join(tmpDir, '.claude', 'hooks'); + if (!fs.existsSync(hooksDir)) return; + + const jsFiles = findJsFiles(hooksDir); + const withHyphen = jsFiles.filter(({ full }) => { + const content = fs.readFileSync(full, 'utf-8'); + return /\/gsd-/.test(content); + }); + + assert.ok( + withHyphen.length > 0, + 'At least one installed .js hook file must contain /gsd- (confirming rewrite ran). ' + + `Files checked: ${jsFiles.map(f => f.rel).join(', ')}`, + ); + }); +}); + +// --------------------------------------------------------------------------- +// Suite 2 — Cursor install regression: /gsd: → /gsd- still works (pre-existing) +// +// Note: Cursor installs its own hooks (gsd-cursor-session-start.js and +// gsd-cursor-post-tool.js) via the cursor-hooks-json installSurface (issue #777). +// It does NOT install the bundled Claude-style hooks/dist files (no gsd-session-state.sh +// etc.). The Cursor /gsd: rewrite applies in `copyWithPathReplacement` to JS files +// under the agent/skill tree (.cursor/gsd-core/*.js etc). We verify that Cursor's +// installed .js files under .cursor/ have no /gsd: colon refs, and that the hooks/ +// directory contains only the Cursor-specific managed hooks. +// --------------------------------------------------------------------------- +describe('bug #376 — Suite 2: Cursor install still rewrites /gsd: → /gsd- (regression)', () => { + let tmpDir; + + before(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-376-cursor-')); + runInstall(tmpDir, ['--cursor', '--local', '--no-sdk']); + }); + + after(() => { + cleanup(tmpDir); + }); + + test('2a: .cursor/ directory is created by the Cursor local install', () => { + const cursorDir = path.join(tmpDir, '.cursor'); + assert.ok( + fs.existsSync(cursorDir), + `Cursor install must create .cursor/ directory at ${cursorDir}`, + ); + }); + + test('2b: Cursor-installed .js files contain no user-facing /gsd: colon refs', () => { + const cursorDir = path.join(tmpDir, '.cursor'); + if (!fs.existsSync(cursorDir)) return; + + // Infrastructure files whose /gsd: occurrences are intentional implementation + // details — NOT user-facing command references that Cursor would invoke. + // + // scripts/fix-slash-commands.cjs is the slash-command rewriter engine, required + // by gsd-core/bin/lib/command-roster.cjs on ALL runtimes (including Cursor). + // It must be installed verbatim and must NOT be content-rewritten: it needs to + // emit `/gsd:${cmd}` for non-Cursor runtimes, and its /gsd: strings are internal + // implementation/docs (transform patterns, regex literals, template literals), + // not commands a Cursor user would type. Rewriting it would corrupt the transformer. + const INFRA_BASENAMES = new Set(['fix-slash-commands.cjs']); + + const jsFiles = findJsFiles(cursorDir); + // Cursor may not install any .js files depending on what agent/skill content exists; + // if none, skip gracefully. + if (jsFiles.length === 0) return; + + const offenders = []; + for (const { rel, full } of jsFiles) { + // Skip infrastructure files whose /gsd: strings are intentional (see above). + if (INFRA_BASENAMES.has(path.basename(full))) continue; + const content = fs.readFileSync(full, 'utf-8'); + const badLines = colonRefs(content); + if (badLines.length > 0) { + offenders.push({ rel, lines: badLines }); + } + } + + assert.deepEqual( + offenders, + [], + 'Cursor-installed .js files must not contain /gsd: colon refs. ' + + 'The existing Cursor branch in copyWithPathReplacement must still apply /gsd:/gi → gsd- rewrite. ' + + 'Offenders: ' + JSON.stringify(offenders, null, 2), + ); + }); + + test('2c: Cursor install creates a hooks/ directory with only Cursor-specific managed hooks', () => { + // Since issue #777, Cursor installs gsd-cursor-session-start.js and + // gsd-cursor-post-tool.js into /hooks/. These are Cursor-native + // hooks — NOT the bundled Claude-style hooks (no gsd-session-state.sh etc.). + // Verify: hooks/ exists AND does NOT contain any Claude-bundled hooks. + const hooksDir = path.join(tmpDir, '.cursor', 'hooks'); + assert.ok( + fs.existsSync(hooksDir), + 'Cursor install must create a hooks/ directory for its managed hook scripts (#777)', + ); + const CLAUDE_BUNDLED_HOOKS = ['gsd-session-state.sh', 'gsd-context-monitor.js', 'gsd-statusline.js']; + for (const hook of CLAUDE_BUNDLED_HOOKS) { + assert.strictEqual( + fs.existsSync(path.join(hooksDir, hook)), + false, + `Cursor hooks/ must NOT contain Claude-bundled hook ${hook} — only Cursor-native hooks are installed`, + ); + } + // The two Cursor-specific managed hooks must be present. + assert.ok( + fs.existsSync(path.join(hooksDir, 'gsd-cursor-session-start.js')), + 'gsd-cursor-session-start.js must be installed in .cursor/hooks/ (#777)', + ); + assert.ok( + fs.existsSync(path.join(hooksDir, 'gsd-cursor-post-tool.js')), + 'gsd-cursor-post-tool.js must be installed in .cursor/hooks/ (#777)', + ); + }); +}); + +// --------------------------------------------------------------------------- +// Suite 3 — Source files in hooks/ are untouched +// --------------------------------------------------------------------------- +describe('bug #376 — Suite 3: hooks/ source files are unchanged by install', () => { + let snapshotBefore; + + before(() => { + // Ensure hooks/dist is built before snapshotting; it may be absent in CI + // scoped/windows jobs that don't pre-run build:hooks (#777 fix). + ensureHooksDist(); + // Snapshot hooks/dist JS files before any install in this suite + snapshotBefore = {}; + if (fs.existsSync(HOOKS_DIST_DIR)) { + for (const { rel, full } of findJsFiles(HOOKS_DIST_DIR)) { + snapshotBefore[rel] = fs.readFileSync(full, 'utf-8'); + } + } + }); + + test('3a: hooks/dist .js source files still contain /gsd: literals (not mutated)', () => { + // The source must remain in colon form — the rewrite is install-time only + const jsFiles = findJsFiles(HOOKS_DIST_DIR); + const withColonRef = jsFiles.filter(({ full }) => { + const content = fs.readFileSync(full, 'utf-8'); + return colonRefs(content).length > 0; + }); + + // We know from the prerequisite suite that at least one file had a colon ref; + // if the source was mutated by install, this would now be zero. + assert.ok( + withColonRef.length > 0, + 'hooks/dist .js files must still contain /gsd: literals after install — ' + + 'the install-time rewrite must NOT modify the source tree. ' + + `Files that still have colon refs: ${withColonRef.map(f => f.rel).join(', ')}`, + ); + }); + + test('3b: hooks/dist .js source file contents match pre-test snapshot (byte-identical)', () => { + if (Object.keys(snapshotBefore).length === 0) { + // hooks/dist was absent before; skip + return; + } + + for (const [rel, before] of Object.entries(snapshotBefore)) { + const full = path.join(HOOKS_DIST_DIR, rel); + const after = fs.readFileSync(full, 'utf-8'); + assert.strictEqual( + after, + before, + `hooks/dist/${rel} was mutated by install — install must only rewrite the installed copy, not the source`, + ); + } + }); +}); + +// --------------------------------------------------------------------------- +// Suite 4 — Pure-function: shouldNormalizeHyphenNamespaceInAgentBody covers claude +// --------------------------------------------------------------------------- +describe('bug #376 — Suite 4: shouldNormalizeHyphenNamespaceInAgentBody covers claude', () => { + const install = require(INSTALL_PATH); + + test('4a: shouldNormalizeHyphenNamespaceInAgentBody is exported', () => { + assert.strictEqual( + typeof install.shouldNormalizeHyphenNamespaceInAgentBody, + 'function', + 'install.js must export shouldNormalizeHyphenNamespaceInAgentBody', + ); + }); + + test('4b: claude is in the hyphen-namespace set', () => { + assert.strictEqual( + install.shouldNormalizeHyphenNamespaceInAgentBody('claude'), + true, + 'claude must be a hyphen-namespace runtime', + ); + }); + + test('4c: qwen is in the hyphen-namespace set', () => { + assert.strictEqual( + install.shouldNormalizeHyphenNamespaceInAgentBody('qwen'), + true, + ); + }); + + test('4d: hermes is in the hyphen-namespace set', () => { + assert.strictEqual( + install.shouldNormalizeHyphenNamespaceInAgentBody('hermes'), + true, + ); + }); + + test('4e: gemini is NOT in the hyphen-namespace set', () => { + assert.strictEqual( + install.shouldNormalizeHyphenNamespaceInAgentBody('gemini'), + false, + 'gemini intentionally keeps colon namespace and must not be in the hyphen set', + ); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-1367-claude-local-flat-command-layout.test.cjs — consolidation epic #1969 (B6 #1975) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-1367-claude-local-flat-command-layout (consolidation epic #1969 B6 #1975)", () => { +// 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/.md` (subdirectory, bare names), causing Claude Code + * to register them as `/gsd:` (colon namespace). The fix changes the layout to + * write flat `gsd-.md` files at `.claude/commands/` level so Claude Code + * registers `/gsd-` (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-.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-.md layout for claude local install +// --------------------------------------------------------------------------- + +describe('bug #1367 — Claude local install uses flat gsd-.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-.md names (not bare names in a subdirectory)', () => { + // The fix: commands land as .claude/commands/gsd-.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/.md subdirectory ` + + `which causes /gsd: colon namespace in Claude Code.`, + ); + }); + + test('L2: known commands land as flat gsd-.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/.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: (colon) instead of /gsd- (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.`, + ); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/enh-2380-sync-skills.test.cjs — consolidation epic #1969 (B6 #1975) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:enh-2380-sync-skills (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; }); +'use strict'; + +// allow-test-rule: source-text-is-the-product (see #2380) +// 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 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' + ); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/fix-1521-real-install-stamping.test.cjs — consolidation epic #1969 (B6 #1975) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:fix-1521-real-install-stamping (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; }); +'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', + ); +}); + }); +} diff --git a/tests/managed-hooks.test.cjs b/tests/managed-hooks.test.cjs index dd55302b7..4f4071d20 100644 --- a/tests/managed-hooks.test.cjs +++ b/tests/managed-hooks.test.cjs @@ -66,3 +66,578 @@ 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)", () => { + +// 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'), + ); + }); +}); + }); +} diff --git a/tests/npm-integrity-gate.test.cjs b/tests/npm-integrity-gate.test.cjs index 752c7c614..036381897 100644 --- a/tests/npm-integrity-gate.test.cjs +++ b/tests/npm-integrity-gate.test.cjs @@ -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}`); + }); +}); + }); +} diff --git a/tests/pause-work-improvements.test.cjs b/tests/pause-work-improvements.test.cjs index 7dbdb4b2d..aad4ceede 100644 --- a/tests/pause-work-improvements.test.cjs +++ b/tests/pause-work-improvements.test.cjs @@ -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 +// `` 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(''); + assert.ok(stepStart >= 0, 'resume-project.md must contain a check_incomplete_work step'); + const stepEnd = md.indexOf('', stepStart); + assert.ok( + stepEnd >= 0, + 'check_incomplete_work step must have a closing 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 -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.', + ); + }); +}); + }); +} diff --git a/tests/perf-317-context-monitor-fs.test.cjs b/tests/perf-317-context-monitor-fs.test.cjs index c71b72630..31da5bae7 100644 --- a/tests/perf-317-context-monitor-fs.test.cjs +++ b/tests/perf-317-context-monitor-fs.test.cjs @@ -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' + ); + }); +}); + }); +} diff --git a/tests/read-guard.test.cjs b/tests/read-guard.test.cjs index 086ef11be..b813e8733 100644 --- a/tests/read-guard.test.cjs +++ b/tests/read-guard.test.cjs @@ -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')); + }); +}); + }); +} diff --git a/tests/release-tarball-smoke.install.test.cjs b/tests/release-tarball-smoke.install.test.cjs index fdbf8effa..1c212e83a 100644 --- a/tests/release-tarball-smoke.install.test.cjs +++ b/tests/release-tarball-smoke.install.test.cjs @@ -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', + ); + }); +}); + }); +} diff --git a/tests/run-tests-harness.test.cjs b/tests/run-tests-harness.test.cjs index 1f3750a1c..6a1310676 100644 --- a/tests/run-tests-harness.test.cjs +++ b/tests/run-tests-harness.test.cjs @@ -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 /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'); + }); +}); + }); +} diff --git a/tests/runtime-launcher-parity.test.cjs b/tests/runtime-launcher-parity.test.cjs index bc40ceabb..b22a0bf76 100644 --- a/tests/runtime-launcher-parity.test.cjs +++ b/tests/runtime-launcher-parity.test.cjs @@ -723,3 +723,1039 @@ describe('runtime-launcher-parity — agents (#1041)', () => { ); }); }); + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-211-launcher-home-fallback.test.cjs — consolidation epic #1969 (B6 #1975) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-211-launcher-home-fallback (consolidation epic #1969 B6 #1975)", () => { +'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 (see #211) +// 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); + } + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-891-non-claude-runtime-home-fallback.test.cjs — consolidation epic #1969 (B6 #1975) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-891-non-claude-runtime-home-fallback (consolidation epic #1969 B6 #1975)", () => { +'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 (see #891) +// 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'), + ); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-3668-workflow-runtime-resolution.test.cjs — consolidation epic #1969 (B6 #1975) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-3668-workflow-runtime-resolution (consolidation epic #1969 B6 #1975)", () => { +/** + * 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'); + + // Consolidation #1969: POSIX-shell resolver. These tests create an + // extension-less `gsd-tools` PATH stub (mode 0o755) and exec it via `bash -c`; + // Windows Git Bash ignores the exec bit for extension-less PATH scripts, so the + // suite is guarded to POSIX (matches the host suite's own bash -c guard). + if (process.platform === 'win32') return ''; + + 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', { skip: process.platform === 'win32' }, () => { + 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/); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-444-resolver-local-claude-install.test.cjs — consolidation epic #1969 (B6 #1975) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-444-resolver-local-claude-install (consolidation epic #1969 B6 #1975)", () => { +'use strict'; +/** + * Regression test for bug #444: gsd_run resolver must probe + * /.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 /.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 (see #444) +// 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); + } + }); +}); + }); +} diff --git a/tests/skill-frontmatter-contract.test.cjs b/tests/skill-frontmatter-contract.test.cjs index 8e64e2ba3..fe902822d 100644 --- a/tests/skill-frontmatter-contract.test.cjs +++ b/tests/skill-frontmatter-contract.test.cjs @@ -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|]`. +// 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 `` 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.md in compact scope (composable) + * - bare or `--full ` → 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 ` 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// 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 block (line-anchored)`, () => { + const content = read(path.join(MODES, f)); + // Anchor on start-of-line so prose mentions of `` inside + // blocks aren't counted. + const opens = (content.match(/^$/gm) || []).length; + const closes = (content.match(/^<\/reference>$/gm) || []).length; + assert.equal(opens, 1, `${f}: expected 1 opening line, got ${opens}`); + assert.equal(closes, 1, `${f}: expected 1 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(/([\s\S]*?)<\/progressive_disclosure>/); + assert.ok(m, 'dispatcher must contain a block'); + return m[1]; + } + + test('dispatcher block has exactly 5 routing rows', () => { + // 4 base tiers (brief, full, default, topic) + 1 composable row (--brief ). + 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 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[^|]*[\s\S]*?topic\.md[\s\S]*?compact/i); + }); + + test('dispatcher routes --full (or bare topic) to topic.md in full scope', () => { + // Bare topic, `--full `, or topic with leading `--` → full scope. + assert.match(dispatcher, /(bare topic|--full )[\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 ', () => { + // Discoverability: users need to know the composable form is supported + // (trek-e review finding #4). + assert.match(shim, /argument-hint:[^\n]*--brief[^\n]*/); + }); + + 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]*[\s\S]*/); + assert.match(topicSrc, /scope:.*full.*\|.*compact/i); + }); + + test('topic.md documents a compact scope distinct from full scope', () => { + // Trek-e review finding #4: --brief 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 /); + }); +}); + +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(', ')}`); + }); +}); + }); +} diff --git a/tests/slash-command-namespace.test.cjs b/tests/slash-command-namespace.test.cjs new file mode 100644 index 000000000..265dd0bcb --- /dev/null +++ b/tests/slash-command-namespace.test.cjs @@ -0,0 +1,1178 @@ +'use strict'; + +// Slash / colon command-namespace invariant tests. +// +// Consolidated home for the namespace-leak regression suites (epic #1969, batch +// B6 #1975). Each block below is folded verbatim from its origin issue-named +// file and carries its origin issue number for provenance. These tests share no +// production module — they assert the cross-surface invariant that GSD command, +// agent, and workflow bodies use the hyphen form (`gsd-`) and never leak the +// deprecated colon/slash namespace after install/conversion. + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-2543-gsd-slash-namespace.test.cjs — consolidation epic #1969 (B6 #1975) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-2543-gsd-slash-namespace (consolidation epic #1969 B6 #1975)", () => { +'use strict'; + +// allow-test-rule: structural-regression-guard (see #2543) + +/** + * Slash-command namespace invariant (#3443) — SCOPED ACTIVE VARIANT. + * + * History: + * #3443 re-establishes `/gsd:` 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:` -> `/gsd-`). + * + * 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:` (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-` (hyphen) per + * bug-3584's contract. Those files are EXCLUDED from this scan. + * + * Scoped invariant enforced here: + * No `/gsd-` 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-` (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- 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- reference(s) — use /gsd: 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- to /gsd:', () => { + 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' + ); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-3677-agent-colon-namespace-leak.test.cjs — consolidation epic #1969 (B6 #1975) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-3677-agent-colon-namespace-leak (consolidation epic #1969 B6 #1975)", () => { +// allow-test-rule: source-text-is-the-product (see #3677) +// 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:` 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:` 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: to /gsd- 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:` 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( + `(? 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:` 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.'); + }); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-3683-command-colon-namespace-leak.test.cjs — consolidation epic #1969 (B6 #1975) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-3683-command-colon-namespace-leak (consolidation epic #1969 B6 #1975)", () => { +// allow-test-rule: source-text-is-the-product (see #3683) +// Command `.md` files — their staged text IS the runtime contract loaded by +// Claude Code. Asserting that staged bodies lack `/gsd:` colon refs is +// a behavioral test of the install transform, not source-grep theater. + +/** + * Regression for #3683 — installed command bodies leak `/gsd:` 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:` 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:` or `gsd:` + * (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( + `(? { + + 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: 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/.md (bare names in a + // subdir), causing Claude Code to namespace them as /gsd: (colon form). + // After #1367: commands write flat gsd-.md at commands/ level so Claude + // Code registers them as /gsd- (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-.md (flat, + // hyphen-prefixed). The old .claude/commands/gsd/.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/.md subdirectory layout.`, + ); + // The old subdirectory must NOT exist (it caused /gsd: colon namespace) + const oldSubdir = path.join(commandsDir, 'gsd'); + assert.ok( + !fs.existsSync(oldSubdir), + `commands/gsd/ subdir must NOT exist after install (it causes /gsd: colon namespace in Claude Code). ` + + `#1367 fix: use flat gsd-.md at commands/ level instead.`, + ); + }); + + test('E1: no staged command body contains /gsd: 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- 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(', ')}`, + ); + }); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-3683-workflow-colon-namespace-leak.test.cjs — consolidation epic #1969 (B6 #1975) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-3683-workflow-colon-namespace-leak (consolidation epic #1969 B6 #1975)", () => { +// allow-test-rule: source-text-is-the-product (see #3683) +// 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:` +// colon refs is a behavioral test of the install transform, not +// source-grep theater. + +/** + * Regression for #3683 — installed workflow/reference bodies leak `/gsd:` + * 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:` 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( + `(? { + 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: 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- for claude runtime. Offenders: ${relOffenders.join(', ')}`, + ); + }); + + test('W4: no staged reference body contains /gsd: 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- 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- (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- 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- 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: colon form, got: ${JSON.stringify(text)}`, + ); + // Token-level: extract real command tokens (/gsd- 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- 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- hyphen form, got: ${JSON.stringify(text)}`, + ); + assert.ok( + !/\/gsd:[a-z]/.test(text), + `secure-phase.md line ${lineNo}: ▶-routing line must not contain /gsd: 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: 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: 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: colon form — ` + + `these must resolve to /gsd- 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: 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: 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(', ')}`, + ); + }); + }); +}); + }); +} diff --git a/tests/windows-robustness.test.cjs b/tests/windows-robustness.test.cjs index c913e1abf..1e830c270 100644 --- a/tests/windows-robustness.test.cjs +++ b/tests/windows-robustness.test.cjs @@ -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')}`); + }); +}); + }); +} diff --git a/tests/worktree-cleanup.test.cjs b/tests/worktree-cleanup.test.cjs index a7dde1ded..468bd1c2e 100644 --- a/tests/worktree-cleanup.test.cjs +++ b/tests/worktree-cleanup.test.cjs @@ -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 . + * + * Algorithm: + * 1. Find + * 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 = ''; + 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 */ } + } + }); +}); + }); +} diff --git a/tests/worktree-safety.test.cjs b/tests/worktree-safety.test.cjs index 58b97ba69..af081d664 100644 --- a/tests/worktree-safety.test.cjs +++ b/tests/worktree-safety.test.cjs @@ -2660,3 +2660,1610 @@ describe('bug #3384: adjacent worktree data-loss guards', () => { }); }); } + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-260-worktree-path-guard.test.cjs — consolidation epic #1969 (B6 #1975) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-260-worktree-path-guard (consolidation epic #1969 B6 #1975)", () => { +/** + * 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 = /wt + // sibling = /wt-sibling ← shares "wt" prefix with the worktree root + // target = /wt-sibling/file.ts + // + // A naive startsWith(wtRoot) check would wrongly classify "/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 /wt and a separate git repo at /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 /wt (using sibMainRepo as its host). + git(sibMainRepo, ['worktree', 'add', '-q', '-b', 'worktree-agent-sib-test', wtDir]); + // Create a separate git repo at /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/.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' + ); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-261-worktree-force-add-guard.test.cjs — consolidation epic #1969 (B6 #1975) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-261-worktree-force-add-guard (consolidation epic #1969 B6 #1975)", () => { +'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); + } + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-2772-gitmodules-path-intersection.test.cjs — consolidation epic #1969 (B6 #1975) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-2772-gitmodules-path-intersection (consolidation epic #1969 B6 #1975)", () => { +// allow-test-rule: source-text-is-the-product (see #2772) +// 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=` 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=, 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, //, '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); + } + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-3542-executor-git-stash-prohibition.test.cjs — consolidation epic #1969 (B6 #1975) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-3542-executor-git-stash-prohibition (consolidation epic #1969 B6 #1975)", () => { +// allow-test-rule: source-text-is-the-product (see #3542) +// 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 :` / `git diff -- `. + 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 :` / ' + + '`git diff -- `) 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); + } +}); + }); +}