From ad07f76a31fc245a58c9215113494d8e0c0020bc Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Tue, 11 Aug 2026 23:35:44 -0400 Subject: [PATCH] =?UTF-8?q?test(#3336):=20fold=20the=20installer=20&=20run?= =?UTF-8?q?time=20surface=20issue-*=20cluster=20=E2=80=94=20Wave=204=20(#3?= =?UTF-8?q?376)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test(#3336): fold the installer & runtime surface issue-* cluster — Wave 4 Folds 10 legacy issue-*.test.cjs regression files (79 test() blocks) into their module's main suite, per H3 (#3315) of the test-hygiene epic (#3053). First of 4 issue-* waves (following the 3 fix-* waves, all merged). - 1 file with no prior target coverage: renamed (git mv) into legacy-cleanup.test.cjs (sole comprehensive suite for that module). - 9 files merged into 6 pre-existing suites: golden-parity-single-source, runtime-artifact-layout-surface, codex-config (4 sources merged jointly in one pass per the issue's own instruction, to catch overlap between the 4 sources themselves, not just against the pre-existing target — zero overlap found, all 20 blocks additive), runtime-config-adapter-registry (1 of 10 source blocks dropped as a proven subset of existing coverage), cline-install, install.test.cjs. Incidental fixes required to keep this wave's own ratchets green: - Fixed a stale ADR doc reference (docs/adr/1235) to a folded-away filename. - scripts/lint-allow-test-rule-refs: pruned 4 stale allowlist entries for renamed/merged-away files, cited 2 previously-uncited allow-test-rule comments that surfaced as "new" only because their file path changed, added 1 fresh allowlist entry for a pre-existing uncited comment that predates this PR, and tightened the exemption-file ceiling 309 -> 305 to match the real post-fold high-water mark. Zero net test-coverage loss. No production code changed. * test(#3336): fix orthogonal-review findings — Wave 4 fold Standards-axis review + Memtrace graph pass found real issues in the just-folded suites, all fixed here: - Standardized the fold-wrapper convention (block-scoped __foldDescribe) across golden-parity-single-source.test.cjs, runtime-artifact-layout- surface.test.cjs, runtime-config-adapter-registry.test.cjs, and cline-install.test.cjs to match the pattern already used by codex-config.test.cjs and install.test.cjs in this same wave (and by earlier folds elsewhere in the epic) — repeats the exact inconsistency Wave 3 (#3335) already fixed once in this epic. - Fixed a stale allowlist entry's alphabetical position (cosmetic, not tool-gated, caught by review anyway). - Fixed two stale test-filename references in PRODUCTION code comments (src/capability-writer.cts, src/runtime-config-adapter-registry.cts) caught by lint-removed-but-needed — a class of stale reference this wave's fold agents didn't check for, since they were scoped to docs/ and gsd-core/references/ only, not src/. First fix attempt wrongly edited the gitignored gsd-core/bin/lib/*.cjs BUILD OUTPUT instead of the tracked .cts source; caught and corrected before commit. - Fixed one remaining stale doc reference in docs/adr/1235 (a prior partial fix in this same wave missed it). No test() count changed in any file. No production code BEHAVIOR changed — comment-only fixes in src/. --------- Co-authored-by: sim --- ...iptor-driven-agent-conversion-migration.md | 2 +- .../lint-allow-test-rule-refs.allowlist.json | 6 +- .../lint-allow-test-rule-refs.ceiling.json | 2 +- src/capability-writer.cts | 5 +- src/runtime-config-adapter-registry.cts | 5 +- tests/cline-install.test.cjs | 285 +++++++ tests/codex-config.test.cjs | 753 ++++++++++++++++++ tests/golden-parity-single-source.test.cjs | 174 +++- tests/install.test.cjs | 312 ++++++++ ...ssue-1575-agent-descriptor-parity.test.cjs | 176 ---- ...ue-2639-codex-toml-neutralization.test.cjs | 111 --- tests/issue-2695-codex-hook-set.test.cjs | 338 -------- ...2834-codex-install-model-ordering.test.cjs | 43 - ...-2940-codex-config-merge-trailing.test.cjs | 194 ----- ...issue-57-runtime-install-no-drift.test.cjs | 234 ------ ...sue-607-installer-dry-run.install.test.cjs | 302 ------- tests/issue-69-surface-keeps-nested.test.cjs | 131 --- tests/issue-787-cline-hooks-agents.test.cjs | 315 -------- ...eanup.test.cjs => legacy-cleanup.test.cjs} | 0 .../runtime-artifact-layout-surface.test.cjs | 120 +++ .../runtime-config-adapter-registry.test.cjs | 178 +++++ 21 files changed, 1830 insertions(+), 1856 deletions(-) delete mode 100644 tests/issue-1575-agent-descriptor-parity.test.cjs delete mode 100644 tests/issue-2639-codex-toml-neutralization.test.cjs delete mode 100644 tests/issue-2695-codex-hook-set.test.cjs delete mode 100644 tests/issue-2834-codex-install-model-ordering.test.cjs delete mode 100644 tests/issue-2940-codex-config-merge-trailing.test.cjs delete mode 100644 tests/issue-57-runtime-install-no-drift.test.cjs delete mode 100644 tests/issue-607-installer-dry-run.install.test.cjs delete mode 100644 tests/issue-69-surface-keeps-nested.test.cjs delete mode 100644 tests/issue-787-cline-hooks-agents.test.cjs rename tests/{issue-607-legacy-cleanup.test.cjs => legacy-cleanup.test.cjs} (100%) diff --git a/docs/adr/1235-descriptor-driven-agent-conversion-migration.md b/docs/adr/1235-descriptor-driven-agent-conversion-migration.md index e41f10daf..ec4c4ce01 100644 --- a/docs/adr/1235-descriptor-driven-agent-conversion-migration.md +++ b/docs/adr/1235-descriptor-driven-agent-conversion-migration.md @@ -82,7 +82,7 @@ Cross-cutting steps (a, b, c) are applied by the descriptor pipeline for the app ### Cutover progress (#1575) -- **Step 0 (parity harness):** shipped in `tests/issue-1575-agent-descriptor-parity.test.cjs`. Asserts `applySurface` output is byte-identical to `installRuntimeArtifacts` for all descriptor-driven runtimes. Covers stale-cleanup convergence (pre-existing legacy `.agent.md` pruned correctly). +- **Step 0 (parity harness):** shipped in `tests/golden-parity-single-source.test.cjs` (folded as `folded:issue-1575-agent-descriptor-parity` in the #3336 test-hygiene sweep). Asserts `applySurface` output is byte-identical to `installRuntimeArtifacts` for all descriptor-driven runtimes. Covers stale-cleanup convergence (pre-existing legacy `.agent.md` pruned correctly). - **Step 1 (trivial converters):** cursor, windsurf, augment, trae, codebuddy — install-path cutover complete (PR #1438); surface-path parity shipped (#1575: `applySurface` now builds `agentCtx` and passes it to `kind.stage()` for agents, applying path-rewrite + attribution + converter + normalize). - **Step 2 (scope-aware):** copilot and antigravity — cutover complete (#1575: declared `agents` kind in `capability.json`, added to `_DESCRIPTOR_AGENTS_RUNTIMES`, copilot `.agent.md` rename handled in both `_copyStaged` and `_syncGsdDir`). - **Cline:** deferred — rules-only local branch + local/global complication not handled by the descriptor-driven path. diff --git a/scripts/lint-allow-test-rule-refs.allowlist.json b/scripts/lint-allow-test-rule-refs.allowlist.json index c3b457443..0b71e4bfa 100644 --- a/scripts/lint-allow-test-rule-refs.allowlist.json +++ b/scripts/lint-allow-test-rule-refs.allowlist.json @@ -77,14 +77,10 @@ "tests/intel.test.cjs :: source-text-is-the-product", "tests/inventory-headings-countfree.test.cjs :: source-text-is-the-product", "tests/ios-scaffold-safety.test.cjs :: source-text-is-the-product", - "tests/issue-2639-codex-toml-neutralization.test.cjs :: source-text-is-the-product", "tests/issue-429-comment-text-gate.test.cjs :: source-text-is-the-product", "tests/issue-498-update-backup-runtime-dir.test.cjs :: source-text-is-the-product", - "tests/issue-57-runtime-install-no-drift.test.cjs :: structural-regression-guard", - "tests/issue-607-installer-dry-run.install.test.cjs :: integration-test-input", - "tests/issue-607-legacy-cleanup.test.cjs :: integration-test-input", - "tests/issue-787-cline-hooks-agents.test.cjs :: source-text-is-the-product", "tests/issue-815-update-next-channel.test.cjs :: source-text-is-the-product", + "tests/legacy-cleanup.test.cjs :: integration-test-input", "tests/locking-bugs-1909-1916-1925-1927.test.cjs :: architectural-invariant", "tests/mcp-tool-inheritance.test.cjs :: source-text-is-the-product", "tests/milestone-summary.test.cjs :: source-text-is-the-product", diff --git a/scripts/lint-allow-test-rule-refs.ceiling.json b/scripts/lint-allow-test-rule-refs.ceiling.json index cb8229538..d9ef39660 100644 --- a/scripts/lint-allow-test-rule-refs.ceiling.json +++ b/scripts/lint-allow-test-rule-refs.ceiling.json @@ -1,4 +1,4 @@ { - "maxFiles": 309, + "maxFiles": 305, "grace": 3 } diff --git a/src/capability-writer.cts b/src/capability-writer.cts index e9f48c6fe..69014a22e 100644 --- a/src/capability-writer.cts +++ b/src/capability-writer.cts @@ -372,8 +372,9 @@ function setCapabilityState( // access to getCommitAttribution (which lives in bin/install.js). Until that // is refactored into a shared module, surface-path agents for descriptor- // driven runtimes will lack the Co-Authored-By trailer that install adds. - // Parity is proven when resolveAttribution IS provided (see - // tests/issue-1575-agent-descriptor-parity.test.cjs). + // Parity is proven when resolveAttribution IS provided (see the + // folded:issue-1575-agent-descriptor-parity describe block in + // tests/golden-parity-single-source.test.cjs). // eslint-disable-next-line @typescript-eslint/no-unsafe-argument applySurface(resolvedConfigDir, layout, manifest, undefined, registry, opts?.materialize?.resolveAttribution ? { resolveAttribution: opts.materialize.resolveAttribution } diff --git a/src/runtime-config-adapter-registry.cts b/src/runtime-config-adapter-registry.cts index 57c1b2429..46c2a8abd 100644 --- a/src/runtime-config-adapter-registry.cts +++ b/src/runtime-config-adapter-registry.cts @@ -101,8 +101,9 @@ type RuntimeDescriptorMap = Record | * (Marketplace/VSIX-distributed, never dispatched through * install()/finishInstall()), so it is not a "config-adapter runtime" by * definition. This keeps this set in lockstep with bin/install.js's - * `allRuntimes` (see tests/issue-57-runtime-install-no-drift.test.cjs) without - * needing a separate hand-kept exclusion list. + * `allRuntimes` (see the folded:issue-57-runtime-install-no-drift describe + * block in tests/runtime-config-adapter-registry.test.cjs) without needing a + * separate hand-kept exclusion list. */ const ALLOWED_CONFIG_RUNTIMES: ReadonlySet = new Set( Object.entries(runtimes) diff --git a/tests/cline-install.test.cjs b/tests/cline-install.test.cjs index a0064cba9..df7e1b81d 100644 --- a/tests/cline-install.test.cjs +++ b/tests/cline-install.test.cjs @@ -27,6 +27,11 @@ const fs = require('node:fs'); const path = require('node:path'); const os = require('node:os'); const { createTempDir, cleanup } = require('./helpers.cjs'); +const { runNode } = require('./helpers/process-seam.cjs'); +// #3145: class-norm timeouts, not per-suite values — see helpers/timeouts.cjs. +const { PROBE_TIMEOUT_MS, INSTALL_TIMEOUT_MS } = require('./helpers/timeouts.cjs'); + +const INSTALL_SCRIPT = path.join(__dirname, '..', 'bin', 'install.js'); const { getDirName, @@ -34,6 +39,14 @@ const { convertClaudeToCliineMarkdown, install, finishInstall, + uninstall, + buildClineRulesBody, + buildClinePreToolUseHook, + buildClineAgentsMdBody, + mergeGsdAgentsMd, + stripGsdFromAgentsMd, + GSD_AGENTS_MD_MARKER, + GSD_AGENTS_MD_CLOSE_MARKER, } = require('../bin/install.js'); const { getGlobalConfigDir } = require('../gsd-core/bin/lib/runtime-homes.cjs'); @@ -209,3 +222,275 @@ describe('Cline install (local)', () => { scanDir(engineDir); }); }); + +// ─── Folded from tests/issue-787-cline-hooks-agents.test.cjs ──────────────────── +// Issue #787 — elevate Cline: write hooks (.clinerules/hooks/) + AGENTS.md. +// Verifies the installer emits Cline directory-form rules, a PreToolUse +// lifecycle hook (Cline JSON stdin -> {cancel,errorMessage,contextModification} +// protocol), and a global ~/.agents/AGENTS.md instruction target. +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe('folded:issue-787-cline-hooks-agents', () => { + +describe('#787 Cline pure helpers', () => { + test('buildClineRulesBody returns GSD directory-form rules markdown', () => { + const body = buildClineRulesBody(); + assert.equal(typeof body, 'string'); + assert.match(body, /GSD workflows live in `gsd-core\/workflows\/`/); + assert.ok(body.endsWith('\n'), 'rules body should end with a trailing newline'); + }); + + test('buildClinePreToolUseHook returns a syntactically valid Node script', () => { + const script = buildClinePreToolUseHook(); + assert.match(script, /^#!\/usr\/bin\/env node/, 'must carry a node shebang'); + // Cline protocol fields must be present in the emitted decision surface. + assert.match(script, /cancel/); + assert.match(script, /errorMessage/); + const tmp = createTempDir('gsd-787-hookcheck-'); + try { + const p = path.join(tmp, 'PreToolUse'); + fs.writeFileSync(p, script); + const res = runNode(['--check', p], { timeoutMs: PROBE_TIMEOUT_MS }); + assert.equal(res.exitCode, 0, `node --check failed: ${res.stderr}`); + } finally { + cleanup(tmp); + } + }); + + test('PreToolUse hook allows a normal tool call (cancel:false)', () => { + const tmp = createTempDir('gsd-787-hookrun-'); + try { + const p = path.join(tmp, 'PreToolUse'); + fs.writeFileSync(p, buildClinePreToolUseHook()); + const res = runNode([p], { + input: JSON.stringify({ toolName: 'read_file', toolInput: { path: 'src/index.ts' } }), + timeoutMs: PROBE_TIMEOUT_MS, + }); + assert.equal(res.exitCode, 0); + const out = JSON.parse(res.stdout); + assert.equal(out.cancel, false); + } finally { + cleanup(tmp); + } + }); + + test('PreToolUse hook cancels a write into .planning/ with an errorMessage', () => { + const tmp = createTempDir('gsd-787-hookguard-'); + try { + const p = path.join(tmp, 'PreToolUse'); + fs.writeFileSync(p, buildClinePreToolUseHook()); + const res = runNode([p], { + input: JSON.stringify({ toolName: 'write_to_file', toolInput: { path: '.planning/ROADMAP.md', content: 'x' } }), + timeoutMs: PROBE_TIMEOUT_MS, + }); + assert.equal(res.exitCode, 0); + const out = JSON.parse(res.stdout); + assert.equal(out.cancel, true); + assert.match(out.errorMessage, /\.planning/); + } finally { + cleanup(tmp); + } + }); + + test('PreToolUse hook does NOT cancel a write to a non-planning path whose CONTENT mentions .planning/', () => { + const tmp = createTempDir('gsd-787-hookfp-'); + try { + const p = path.join(tmp, 'PreToolUse'); + fs.writeFileSync(p, buildClinePreToolUseHook()); + const res = runNode([p], { + input: JSON.stringify({ + toolName: 'write_to_file', + toolInput: { path: 'docs/guide.md', content: 'Edit your .planning/ROADMAP.md via /gsd commands.' }, + }), + timeoutMs: PROBE_TIMEOUT_MS, + }); + assert.equal(res.exitCode, 0); + assert.equal(JSON.parse(res.stdout).cancel, false, 'content mentioning .planning must not trigger a cancel'); + } finally { + cleanup(tmp); + } + }); + + test('PreToolUse hook fails open on malformed stdin', () => { + const tmp = createTempDir('gsd-787-hookbad-'); + try { + const p = path.join(tmp, 'PreToolUse'); + fs.writeFileSync(p, buildClinePreToolUseHook()); + const res = runNode([p], { input: 'not json{', timeoutMs: PROBE_TIMEOUT_MS }); + assert.equal(res.exitCode, 0); + assert.equal(JSON.parse(res.stdout).cancel, false); + } finally { + cleanup(tmp); + } + }); + + test('mergeGsdAgentsMd creates a marker-delimited block when no file exists', () => { + const tmp = createTempDir('gsd-787-agents-new-'); + try { + const p = path.join(tmp, 'AGENTS.md'); + mergeGsdAgentsMd(p, buildClineAgentsMdBody()); + const content = fs.readFileSync(p, 'utf8'); + assert.ok(content.includes(GSD_AGENTS_MD_MARKER)); + assert.ok(content.includes(GSD_AGENTS_MD_CLOSE_MARKER)); + assert.match(content, /GSD/); + } finally { + cleanup(tmp); + } + }); + + test('mergeGsdAgentsMd preserves pre-existing user content', () => { + const tmp = createTempDir('gsd-787-agents-merge-'); + try { + const p = path.join(tmp, 'AGENTS.md'); + fs.writeFileSync(p, '# My rules\n\nKeep me.\n'); + mergeGsdAgentsMd(p, buildClineAgentsMdBody()); + const content = fs.readFileSync(p, 'utf8'); + assert.match(content, /Keep me\./); + assert.ok(content.includes(GSD_AGENTS_MD_MARKER)); + // Idempotent: second merge does not duplicate the block. + mergeGsdAgentsMd(p, buildClineAgentsMdBody()); + const twice = fs.readFileSync(p, 'utf8'); + const occurrences = twice.split(GSD_AGENTS_MD_MARKER).length - 1; + assert.equal(occurrences, 1, 'GSD block must not duplicate on re-merge'); + assert.match(twice, /Keep me\./); + } finally { + cleanup(tmp); + } + }); + + test('stripGsdFromAgentsMd returns null when file was GSD-only, else cleaned content', () => { + const onlyGsd = `${GSD_AGENTS_MD_MARKER}\nhi\n${GSD_AGENTS_MD_CLOSE_MARKER}\n`; + assert.equal(stripGsdFromAgentsMd(onlyGsd), null); + const mixed = `# Keep\n\n${GSD_AGENTS_MD_MARKER}\nhi\n${GSD_AGENTS_MD_CLOSE_MARKER}\n`; + const cleaned = stripGsdFromAgentsMd(mixed); + assert.match(cleaned, /# Keep/); + assert.ok(!cleaned.includes(GSD_AGENTS_MD_MARKER)); + }); +}); + +// ─── Local install: directory form + hook ─────────────────────────────────────── + +describe('#787 Cline local install — directory form + PreToolUse hook', () => { + let tmpDir; + let previousCwd; + + beforeEach(() => { + tmpDir = createTempDir('gsd-787-cline-local-'); + previousCwd = process.cwd(); + process.chdir(tmpDir); + }); + + afterEach(() => { + process.chdir(previousCwd); + cleanup(tmpDir); + }); + + test('writes .clinerules/ as a directory containing gsd.md', () => { + install(false, 'cline'); + const dir = path.join(tmpDir, '.clinerules'); + assert.ok(fs.statSync(dir).isDirectory(), '.clinerules must be a directory'); + const ruleFile = path.join(dir, 'gsd.md'); + assert.ok(fs.existsSync(ruleFile), '.clinerules/gsd.md must exist'); + assert.match(fs.readFileSync(ruleFile, 'utf8'), /gsd-core\/workflows\//); + }); + + test('writes an executable PreToolUse hook with no extension', () => { + install(false, 'cline'); + const hook = path.join(tmpDir, '.clinerules', 'hooks', 'PreToolUse'); + assert.ok(fs.existsSync(hook), '.clinerules/hooks/PreToolUse must exist'); + if (process.platform !== 'win32') { + const mode = fs.statSync(hook).mode; + assert.ok((mode & 0o111) !== 0, 'PreToolUse must be executable'); + } + }); + + test('migrates a legacy single-file .clinerules into the directory form', () => { + // Simulate a pre-#787 install that wrote a .clinerules FILE. + fs.writeFileSync(path.join(tmpDir, '.clinerules'), '# legacy file\n'); + install(false, 'cline'); + const dir = path.join(tmpDir, '.clinerules'); + assert.ok(fs.statSync(dir).isDirectory(), 'legacy file must be replaced by a directory'); + assert.ok(fs.existsSync(path.join(dir, 'gsd.md'))); + }); + + test('does not follow a symlinked .clinerules (writes the real directory in place)', () => { + if (process.platform === 'win32') return; // symlink perms differ on Windows + // Point .clinerules at an external directory via symlink; install must NOT + // write GSD files through the link. + const external = path.join(tmpDir, 'external-target'); + fs.mkdirSync(external); + fs.symlinkSync(external, path.join(tmpDir, '.clinerules')); + install(false, 'cline'); + const dir = path.join(tmpDir, '.clinerules'); + assert.ok(fs.lstatSync(dir).isDirectory() && !fs.lstatSync(dir).isSymbolicLink(), + '.clinerules must be a real directory, not the symlink'); + assert.ok(!fs.existsSync(path.join(external, 'gsd.md')), 'must not write through the symlink target'); + assert.ok(fs.existsSync(path.join(dir, 'gsd.md'))); + }); + + test('manifest tracks the new directory-form artifacts', () => { + install(false, 'cline'); + const manifestPath = path.join(tmpDir, 'gsd-file-manifest.json'); + assert.ok(fs.existsSync(manifestPath)); + const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); + assert.ok(manifest.files['.clinerules/gsd.md'], 'manifest should track .clinerules/gsd.md'); + assert.ok(manifest.files['.clinerules/hooks/PreToolUse'], 'manifest should track the hook'); + }); +}); + +// ─── Global install: ~/.agents/AGENTS.md (subprocess, HOME-isolated) ───────────── + +describe('#787 Cline global install — ~/.agents/AGENTS.md', () => { + function runGlobalClineInstall() { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-787-cline-global-')); + const env = { ...process.env, HOME: root, USERPROFILE: root }; + delete env.GSD_TEST_MODE; + const res = runNode( + [INSTALL_SCRIPT, '--cline', '--global', '--config-dir', path.join(root, '.cline')], + { cwd: root, env, timeoutMs: INSTALL_TIMEOUT_MS }, + ); + return { root, res }; + } + + test('writes ~/.agents/AGENTS.md with a GSD marker block', () => { + const { root, res } = runGlobalClineInstall(); + try { + assert.equal(res.exitCode, 0, `installer failed: ${res.stderr}`); + const agents = path.join(root, '.agents', 'AGENTS.md'); + assert.ok(fs.existsSync(agents), '~/.agents/AGENTS.md must exist after a global Cline install'); + const content = fs.readFileSync(agents, 'utf8'); + assert.ok(content.includes(GSD_AGENTS_MD_MARKER)); + assert.match(content, /GSD/); + } finally { + cleanup(root); + } + }); +}); + +// ─── Uninstall symmetry ───────────────────────────────────────────────────────── + +describe('#787 Cline uninstall removes managed artifacts', () => { + let tmpDir; + let previousCwd; + + beforeEach(() => { + tmpDir = createTempDir('gsd-787-cline-uninstall-'); + previousCwd = process.cwd(); + process.chdir(tmpDir); + }); + + afterEach(() => { + process.chdir(previousCwd); + cleanup(tmpDir); + }); + + test('local uninstall removes .clinerules/gsd.md and the hook', () => { + install(false, 'cline'); + assert.ok(fs.existsSync(path.join(tmpDir, '.clinerules', 'gsd.md'))); + uninstall(false, 'cline'); + assert.ok(!fs.existsSync(path.join(tmpDir, '.clinerules', 'gsd.md')), 'gsd.md should be removed'); + assert.ok(!fs.existsSync(path.join(tmpDir, '.clinerules', 'hooks', 'PreToolUse')), 'hook should be removed'); + }); +}); + }); +} diff --git a/tests/codex-config.test.cjs b/tests/codex-config.test.cjs index f46e46db0..702367a33 100644 --- a/tests/codex-config.test.cjs +++ b/tests/codex-config.test.cjs @@ -2050,6 +2050,217 @@ describe('mergeCodexConfig', () => { }); }); + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/issue-2940-codex-config-merge-trailing.test.cjs — consolidation epic #1969 (H3 W4 #3336) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-2940-codex-config-merge-trailing (consolidation epic #1969 H3 W4 #3336)", () => { +'use strict'; +process.env.GSD_TEST_MODE = '1'; + +/** + * Regression test for #2940 — `gsd-update` overwrites `~/.codex/config.toml`, + * removing any user/Codex-CLI settings added after the GSD-managed marker block. + * + * Root cause: `mergeCodexConfig`'s Case 2 (marker present) preserved content + * BEFORE the marker but unconditionally discarded everything from the marker to + * EOF, replacing it with a freshly generated GSD block. Since a fresh install + * writes the GSD block as the file's entire content, any settings the user or + * Codex CLI later adds (`[model]`, `[mcp_servers.*]`, `[profiles.*]`) land AFTER + * the block, and every subsequent update wiped them. + * + * The fix preserves genuine trailing TOML by routing the post-marker region + * through the existing `stripLeakedGsdCodexSections` (which removes GSD's own + * managed/leaked sections while keeping user tables), then re-appending it after + * the regenerated GSD block — without regressing #2406's de-dup. + * + * Matrix: .gsd/bug/fix/2940-codex-config-merge-preserves-trailing-content/50-test-matrix.md + * + * NOTE: this describe block covers trailing-content-after-the-marker preservation + * ([model]/[mcp_servers.*]/[profiles.*] appended AFTER the GSD block) — a case the + * pre-existing 'mergeCodexConfig' suite above does not exercise (that suite's cases + * write user content BEFORE the marker/block, not after). Verified non-duplicate + * against both the pre-existing target and the other three folded sources. + */ + +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 { cleanup } = require('./helpers.cjs'); + +const { + generateCodexConfigBlock, + mergeCodexConfig, + GSD_CODEX_MARKER, +} = require('../bin/install.js'); + +describe('mergeCodexConfig trailing-content preservation (#2940)', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2940-merge-')); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + /** A GSD block with one agent (the shape installCodexConfig passes). */ + const block = () => + generateCodexConfigBlock([{ name: 'gsd-executor', description: 'Executes plans' }]); + + test('trailingUserModelSectionPreserved', () => { + // Row 1 (failing-first regression): a config with the GSD block FIRST, then a user + // [model] section after it (the real-world layout — fresh install fills the file, + // user settings land after). Re-merge must preserve [model] byte-for-byte. + const configPath = path.join(tmpDir, 'config.toml'); + const trailing = '[model]\nname = "gpt-5.4"\n'; + // First write: GSD block + user content after it (no content before the marker). + fs.writeFileSync(configPath, block() + '\n' + trailing); + + mergeCodexConfig(configPath, block()); + + const content = fs.readFileSync(configPath, 'utf8'); + assert.ok(content.includes('[model]'), 'user [model] section preserved after re-merge'); + assert.ok(content.includes('name = "gpt-5.4"'), 'user model value preserved verbatim'); + assert.ok(content.includes(GSD_CODEX_MARKER), 'GSD marker still present'); + const markerCount = (content.match(new RegExp(GSD_CODEX_MARKER.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g')) || []).length; + assert.strictEqual(markerCount, 1, 'exactly one marker (no duplication)'); + assert.ok(content.includes('max_depth ='), 'GSD-managed [agents] block regenerated'); + }); + + test('multipleTrailingTablesPreserved', () => { + // Row 2: multiple trailing user tables ([mcp_servers.*], [profiles.*]). + const configPath = path.join(tmpDir, 'config.toml'); + const trailing = [ + '[mcp_servers.figma]', + 'command = "npx"', + 'args = ["-y", "figma-mcp"]', + '', + '[profiles.dev]', + 'model = "o3"', + 'sandbox_mode = "workspace-write"', + ].join('\n'); + fs.writeFileSync(configPath, block() + '\n' + trailing + '\n'); + + mergeCodexConfig(configPath, block()); + + const content = fs.readFileSync(configPath, 'utf8'); + assert.ok(content.includes('[mcp_servers.figma]'), 'mcp_servers table preserved'); + assert.ok(content.includes('[profiles.dev]'), 'profiles table preserved'); + assert.ok(content.includes('sandbox_mode = "workspace-write"'), 'profile value preserved'); + assert.ok(content.includes(GSD_CODEX_MARKER), 'GSD block regenerated'); + }); + + test('reMergeIsIdempotent', () => { + // Row 3 (acceptance #2): merging the result of a merge again yields identical content. + const configPath = path.join(tmpDir, 'config.toml'); + fs.writeFileSync(configPath, block() + '\n[model]\nname = "o3"\n'); + + mergeCodexConfig(configPath, block()); + const afterFirst = fs.readFileSync(configPath, 'utf8'); + + mergeCodexConfig(configPath, block()); + const afterSecond = fs.readFileSync(configPath, 'utf8'); + + assert.strictEqual(afterSecond, afterFirst, 'second merge is idempotent (no further change)'); + }); + + test('leakedGsdSectionAfterMarkerStillStripped', () => { + // Row 4 (#2406 non-regression): a leaked GSD-managed [agents.gsd-*] section AFTER the + // marker is still REMOVED (not regrown), while genuine user content after it is preserved. + const configPath = path.join(tmpDir, 'config.toml'); + const leakedAndUser = [ + '[agents.gsd-executor]', + 'description = "stale leaked"', + 'config_file = "agents/gsd-executor.toml"', + '', + '[model]', + 'name = "o3"', + ].join('\n'); + fs.writeFileSync(configPath, block() + '\n' + leakedAndUser + '\n'); + + mergeCodexConfig(configPath, block()); + + const content = fs.readFileSync(configPath, 'utf8'); + const gsdStructCount = (content.match(/^\[agents\.gsd-executor\]\s*$/gm) || []).length; + assert.strictEqual(gsdStructCount, 0, 'leaked [agents.gsd-executor] after marker is stripped (not regrown)'); + assert.ok(content.includes('[model]'), 'genuine user [model] after the leaked section still preserved'); + }); + + test('bareAgentsAfterMarkerHandled', () => { + // Row 5: a user AgentsToml scalar (max_threads) the user folded INTO the managed [agents] + // block (the valid, realistic shape — two [agents] tables would be invalid TOML), PLUS a + // separate trailing [model] section. The fix must preserve the user scalar via the existing + // spliceCodexAgentsScalars path AND preserve the trailing [model] via the new trailing-region + // logic, while regenerating exactly one managed [agents] table. + const configPath = path.join(tmpDir, 'config.toml'); + // Simulate: fresh install wrote the GSD block; the user then added max_threads into the + // [agents] table and added a [model] section after it. + const existing = [ + GSD_CODEX_MARKER, + '', + '[agents]', + 'max_depth = 1', + 'max_threads = 4', + '', + '[model]', + 'name = "o3"', + ].join('\n'); + fs.writeFileSync(configPath, existing + '\n'); + + mergeCodexConfig(configPath, block()); + + const content = fs.readFileSync(configPath, 'utf8'); + // The user's max_threads scalar is preserved (spliced into the regenerated managed [agents]); + // there is exactly one [agents] table (the managed one). + assert.ok(content.includes('max_threads = 4'), 'user AgentsToml scalar (max_threads) preserved in managed block'); + const agentsHeaders = (content.match(/^\[agents\]\s*$/gm) || []).length; + assert.strictEqual(agentsHeaders, 1, 'exactly one [agents] table (the managed one)'); + assert.ok(content.includes('max_depth = 1'), 'GSD-managed max_depth still present'); + assert.ok(content.includes('[model]'), 'trailing [model] still preserved'); + }); + + test('beforeAndAfterMarkerBothPreserved', () => { + // Row 6: content both BEFORE and AFTER the marker is preserved; GSD block regenerated once. + const configPath = path.join(tmpDir, 'config.toml'); + const before = '[profiles.work]\nmodel = "gpt-5.4"\n'; + const after = '[mcp_servers.github]\ncommand = "gh-mcp"\n'; + fs.writeFileSync(configPath, before + '\n' + block() + '\n' + after + '\n'); + + mergeCodexConfig(configPath, block()); + + const content = fs.readFileSync(configPath, 'utf8'); + assert.ok(content.includes('[profiles.work]'), 'content before marker preserved'); + assert.ok(content.includes('[mcp_servers.github]'), 'content after marker preserved'); + const markerCount = (content.match(new RegExp(GSD_CODEX_MARKER.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g')) || []).length; + assert.strictEqual(markerCount, 1, 'exactly one marker'); + }); + + test('noTrailingContentUnchanged', () => { + // Row 7 (zero-trailing boundary): a config with ONLY the GSD block (fresh-install case) + // re-merges to just the regenerated block — no spurious blank-line artifacts introduced + // by the trailing-preservation logic. + const configPath = path.join(tmpDir, 'config.toml'); + fs.writeFileSync(configPath, block() + '\n'); + + mergeCodexConfig(configPath, block()); + + const content = fs.readFileSync(configPath, 'utf8'); + // No spurious trailing blank lines beyond the single trailing newline. Use a CRLF-safe + // pattern (\r?\n) so the assertion holds under Windows git-autocrlf line endings. + assert.ok(!/(?:\r?\n){3,}$/.test(content), 'no spurious run of blank lines at end of file'); + assert.strictEqual(content.trim(), block().trim(), 'content is exactly the regenerated block (whitespace-trimmed)'); + }); +}); + }); +} + + // ─── Integration: installCodexConfig ──────────────────────────────────────────── describe('installCodexConfig (integration)', () => { @@ -2151,6 +2362,191 @@ describe('installCodexConfig (integration)', () => { }); }); + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/issue-2834-codex-install-model-ordering.test.cjs — consolidation epic #1969 (H3 W4 #3336) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-2834-codex-install-model-ordering (consolidation epic #1969 H3 W4 #3336)", () => { +// allow-test-rule: structural-implementation-guard (#2834) +'use strict'; + +// Regression guard for #2834: on a clean Codex install, agent TOMLs contained no +// model-routing fields because defaults.json (resolve_model_ids + runtime) was written +// AFTER installCodexConfig generated the TOMLs. The fix extracts writeNonClaudeDefaults +// and calls it BEFORE installCodexConfig. This test asserts the ordering invariant in +// the install source so a future edit can't silently re-introduce the gap. +// +// Verified non-duplicate: no existing coverage in this file asserts on +// writeNonClaudeDefaults / the install-flow call ordering (source-text guard), and +// none of the other three folded sources touch this. + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); + +const INSTALL_JS = path.join(__dirname, '..', 'bin', 'install.js'); + +test('writeNonClaudeDefaults is called before installCodexConfig in the Codex install flow (#2834)', () => { + const src = fs.readFileSync(INSTALL_JS, 'utf8'); + + // Find the call to writeNonClaudeDefaults that precedes installCodexConfig. + const writeIdx = src.indexOf('writeNonClaudeDefaults(runtime);'); + assert.ok(writeIdx !== -1, 'writeNonClaudeDefaults(runtime) must be called in the install flow'); + + // Find the FIRST installCodexConfig call AFTER the writeNonClaudeDefaults call. + const codexGenIdx = src.indexOf('installCodexConfig(targetDir', writeIdx); + assert.ok(codexGenIdx !== -1 && codexGenIdx > writeIdx, + 'installCodexConfig must be called AFTER writeNonClaudeDefaults so defaults.json ' + + '(resolve_model_ids + runtime) exists before agent TOML generation reads it (#2834)'); + + // The #2834 comment must be present at the call site. + const callSite = src.slice(writeIdx - 300, writeIdx + 100); + assert.ok(/#2834/.test(callSite), 'the writeNonClaudeDefaults call must carry the #2834 rationale comment'); +}); + +test('writeNonClaudeDefaults function exists and is a no-op for Claude (#2834)', () => { + const src = fs.readFileSync(INSTALL_JS, 'utf8'); + const fnIdx = src.indexOf('function writeNonClaudeDefaults('); + assert.ok(fnIdx !== -1, 'writeNonClaudeDefaults must be defined as a function'); + const fnBody = src.slice(fnIdx, fnIdx + 1200); + assert.ok(/nativeModelAliases/.test(fnBody), 'writeNonClaudeDefaults must early-return for Claude (nativeModelAliases check)'); + assert.ok(/resolve_model_ids/.test(fnBody), 'writeNonClaudeDefaults must write resolve_model_ids'); + assert.ok(/defaults\.runtime/.test(fnBody), 'writeNonClaudeDefaults must write runtime'); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/issue-2639-codex-toml-neutralization.test.cjs — consolidation epic #1969 (H3 W4 #3336) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-2639-codex-toml-neutralization (consolidation epic #1969 H3 W4 #3336)", () => { +// 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: issue #2639 — Codex install generated agent TOMLs with stale + * Claude-specific references (CLAUDE.md, .claude/skills/, .claudeignore). + * + * RCA: `installCodexConfig()` applied a narrow path-only regex pass before + * calling `generateCodexAgentToml()`, bypassing the full + * `convertClaudeToCodexMarkdown()` + `neutralizeAgentReferences(..., 'AGENTS.md')` + * pipeline used on the .md emit path. Fix routes the TOML path through the + * same pipeline and extends the pipeline to cover bare `.claude/skills/`, + * `.claude/commands/`, `.claude/agents/`, and `.claudeignore`. + * + * Verified non-duplicate: the pre-existing 'generateCodexAgentToml' suite covers + * model_overrides/sandbox_mode/reasoning-effort, not CLAUDE.md/.claudeignore/skills-path + * neutralization in the emitted TOML; the '#570 — Codex leak scanner sub-bugs' suite + * covers ~/.claude path leaks via convertClaudeToCodexMarkdown but not the + * installCodexConfig()-level TOML-emit pipeline this regression targets. + */ + +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 os = require('os'); + +const { installCodexConfig } = require('../bin/install.js'); +const { cleanup } = require('./helpers.cjs'); + +function makeTempDir() { + return fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2639-')); +} + +function writeAgentFixture(agentsSrc, name, body) { + const content = `--- +name: ${name} +description: Test agent for #2639 +--- + +${body} +`; + fs.writeFileSync(path.join(agentsSrc, `${name}.md`), content); +} + +describe('#2639 — Codex TOML emit routes through full neutralization pipeline', () => { + let tmpDir; + let agentsSrc; + let targetDir; + + beforeEach(() => { + tmpDir = makeTempDir(); + agentsSrc = path.join(tmpDir, 'agents'); + targetDir = path.join(tmpDir, 'codex'); + fs.mkdirSync(agentsSrc, { recursive: true }); + fs.mkdirSync(targetDir, { recursive: true }); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('strips CLAUDE.md, .claude/skills/, .claude/commands/, .claude/agents/, and .claudeignore from emitted TOML', () => { + writeAgentFixture(agentsSrc, 'gsd-code-reviewer', [ + '**Project instructions:** Read `./CLAUDE.md` if it exists.', + '', + '**CLAUDE.md enforcement:** If `./CLAUDE.md` exists, treat it as hard constraints.', + '', + '**Project skills:** Check `.claude/skills/` or `.agents/skills/` directory.', + '', + 'Also check `.claude/commands/` and `.claude/agents/` for definitions.', + '', + 'DO respect .gitignore and .claudeignore. Do not review ignored files.', + '', + 'Claude will refuse the task if policy violated.', + ].join('\n')); + + installCodexConfig(targetDir, agentsSrc); + + const tomlPath = path.join(targetDir, 'agents', 'gsd-code-reviewer.toml'); + assert.ok(fs.existsSync(tomlPath), 'per-agent TOML written'); + const toml = fs.readFileSync(tomlPath, 'utf8'); + + assert.ok(!toml.includes('CLAUDE.md'), 'no CLAUDE.md references remain in TOML'); + assert.ok(!toml.includes('.claude/skills/'), 'no .claude/skills/ references remain'); + assert.ok(!toml.includes('.claude/commands/'), 'no .claude/commands/ references remain'); + assert.ok(!toml.includes('.claude/agents/'), 'no .claude/agents/ references remain'); + assert.ok(!toml.includes('.claudeignore'), 'no .claudeignore references remain'); + + assert.ok(toml.includes('AGENTS.md'), 'AGENTS.md substituted for CLAUDE.md'); + assert.ok( + toml.includes('.codex/skills/') || toml.includes('.agents/skills/'), + 'skills path neutralized' + ); + + // Standalone "Claude" agent-name references replaced + assert.ok(!/\bClaude\b(?! Code| Opus| Sonnet| Haiku| native| based)/.test(toml), + 'standalone Claude agent-name references replaced'); + }); + + test('preserves Claude product/model names (Claude Code, Claude Opus) in TOML', () => { + writeAgentFixture(agentsSrc, 'gsd-executor', [ + 'This agent runs under Claude Code with the Claude Opus 4 model.', + 'Do not confuse with Claude Sonnet or Claude Haiku.', + ].join('\n')); + + installCodexConfig(targetDir, agentsSrc); + const toml = fs.readFileSync(path.join(targetDir, 'agents', 'gsd-executor.toml'), 'utf8'); + + assert.ok(toml.includes('Claude Code'), 'Claude Code product name preserved'); + assert.ok(toml.includes('Claude Opus'), 'Claude Opus model name preserved'); + }); +}); + }); +} + + // ─── Codex config.toml [features] safety (#1202) ───────────────────────────── describe('codex features section safety', () => { @@ -2920,6 +3316,363 @@ describe('Codex install hook configuration (e2e)', () => { }); }); + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/issue-2695-codex-hook-set.test.cjs — consolidation epic #1969 (H3 W4 #3336) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-2695-codex-hook-set (consolidation epic #1969 H3 W4 #3336)", () => { +// Regression tests for #2695 — Codex native updates omit the update-hook worker +// and the managed-hooks registry. +// +// The Codex install branch in bin/install.js used to allowlist only two of the +// four hook files the shipped build emits (gsd-check-update.js + +// gsd-context-monitor.js), and gated the entire branch on !isMinimalMode so the +// `core` profile installed none of them. The parent SessionStart hook spawn()s +// the worker, which require()s the registry — so Codex was wired to a dependency +// chain the same installer never delivered. +// +// These tests drive the real installer (bin/install.js) behaviorally into an +// isolated temp config dir and assert the complete four-file set is delivered +// for both profiles, the registry is byte-for-byte, the version stamps resolve +// to the installed package version, and unrelated user files are preserved. +// +// Verified non-duplicate: the pre-existing 'Codex install hook configuration +// (e2e)' suite above only asserts gsd-check-update.js delivery/wiring — it never +// asserts on gsd-check-update-worker.js, managed-hooks-registry.cjs, or +// gsd-context-monitor.js delivery, the core/full profile matrix, upgrade-refresh, +// byte-for-byte registry copy, idempotency of the four-file set, user-file +// preservation, or the core-profile negative-space (no agent files) — all +// genuinely distinct assertions this fold adds. + +'use strict'; + +const { test, describe, before } = 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 { runNode } = require('./helpers/process-seam.cjs'); +const { throwIfFailed } = require('./helpers/git-fixture.cjs'); + +const { cleanup } = require('./helpers.cjs'); +const { + INSTALL_SCRIPT, + BUILD_SCRIPT, + HOOKS_DIST, + installerEnv, +} = require('./helpers/install-shared.cjs'); + +const PKG_VERSION = require('../package.json').version; + +// #3145: class-norm timeouts, not per-suite values — see helpers/timeouts.cjs. +const { + BUILD_TIMEOUT_MS: BUILD_HOOKS_TIMEOUT_MS, + INSTALL_TIMEOUT_MS, +} = require('./helpers/timeouts.cjs'); + +// The four-file hook set the Codex surface must deliver together (#2695). +const CODEX_HOOK_FILES = [ + 'gsd-check-update.js', + 'gsd-check-update-worker.js', + 'managed-hooks-registry.cjs', + 'gsd-context-monitor.js', +]; + +// Build hooks/dist before any install runs (the installer copies from there). +before(() => { + const r = runNode([BUILD_SCRIPT], { timeoutMs: BUILD_HOOKS_TIMEOUT_MS }); + throwIfFailed(r, `node ${BUILD_SCRIPT}`); +}); + +function hooksDirOf(configDir) { + return path.join(configDir, 'hooks'); +} + +/** Run the Codex installer into an isolated temp config dir. */ +function runCodexInstall({ profile, preseed }) { + const configDir = fs.mkdtempSync(path.join(os.tmpdir(), `gsd-2695-${profile}-`)); + if (preseed) { + const hooksDest = hooksDirOf(configDir); + fs.mkdirSync(hooksDest, { recursive: true }); + for (const [name, body] of Object.entries(preseed)) { + fs.writeFileSync(path.join(hooksDest, name), body); + } + } + // Sandbox HOME/USERPROFILE to configDir: Codex's skills-kind `home: ".agents"` + // override resolves via os.homedir(); sandboxing keeps the spawn self-contained + // (mirrors tests/install-minimal-hooks.test.cjs Codex downgrade test). + const result = runNode( + [INSTALL_SCRIPT, '--codex', '--global', '--config-dir', configDir, `--profile=${profile}`], + { env: installerEnv({ HOME: configDir, USERPROFILE: configDir }), timeoutMs: INSTALL_TIMEOUT_MS }, + ); + return { configDir, result }; +} + +// Older-version stamp used to pre-seed an "upgrade" scenario. +const OLDER_VERSION = '1.7.0'; + +describe('#2695: fresh Codex installs deliver the complete four-file hook set', () => { + for (const profile of ['core', 'full']) { + test(`fresh --profile=${profile} installs all four hook files`, (t) => { + const { configDir, result } = runCodexInstall({ profile }); + t.after(() => cleanup(configDir)); + + const hooksDir = hooksDirOf(configDir); + for (const file of CODEX_HOOK_FILES) { + assert.ok( + fs.existsSync(path.join(hooksDir, file)), + `expected ${file} under /hooks for --profile=${profile}\n` + + `installer stdout: ${result.stdout}\ninstaller stderr: ${result.stderr}`, + ); + } + }); + } +}); + +describe('#2695: Codex upgrades refresh all four hook files to the current version', () => { + // Pre-seed all four files stamped at OLDER_VERSION so an upgrade must overwrite them. + function olderSeed() { + const seed = {}; + for (const name of CODEX_HOOK_FILES) { + // Registry carries no version token; seed it with a stale sentinel body. + if (name.endsWith('.cjs')) { + seed[name] = `// stale registry ${OLDER_VERSION}\nmodule.exports = {};\n`; + } else { + seed[name] = `// gsd-hook-version: ${OLDER_VERSION}\n// stale\n`; + } + } + return seed; + } + + for (const profile of ['core', 'full']) { + test(`--profile=${profile} upgrade refreshes all four hook files`, (t) => { + const { configDir, result } = runCodexInstall({ profile, preseed: olderSeed() }); + t.after(() => cleanup(configDir)); + + const hooksDir = hooksDirOf(configDir); + // All four must now carry the current version stamp where one exists, and + // the registry must no longer be the stale sentinel. + for (const name of CODEX_HOOK_FILES) { + const dest = path.join(hooksDir, name); + assert.ok( + fs.existsSync(dest), + `expected refreshed ${name} for --profile=${profile}\n` + + `installer stdout: ${result.stdout}\ninstaller stderr: ${result.stderr}`, + ); + } + // The registry must be REFRESHED on upgrade, not merely present: assert it no + // longer carries the stale sentinel and now matches the shipped dist byte-for-byte + // (the raw-copy fallback must overwrite an existing dest, not skip it). + const registryDest = path.join(hooksDir, 'managed-hooks-registry.cjs'); + const registryBytes = fs.readFileSync(registryDest, 'utf8'); + assert.ok( + !registryBytes.includes(`stale registry ${OLDER_VERSION}`), + `registry must be refreshed on upgrade for --profile=${profile} (still carries the stale sentinel)`, + ); + assert.deepStrictEqual( + fs.readFileSync(registryDest), + fs.readFileSync(path.join(HOOKS_DIST, 'managed-hooks-registry.cjs')), + `refreshed registry must match hooks/dist byte-for-byte for --profile=${profile}`, + ); + // Version stamps resolved (acceptance #2/#3). + const workerStamp = readHookVersionLine(path.join(hooksDir, 'gsd-check-update-worker.js')); + assert.strictEqual( + workerStamp, PKG_VERSION, + `worker gsd-hook-version stamp must be the installed package version (${PKG_VERSION}), ` + + `got "${workerStamp}" for --profile=${profile}`, + ); + const parentStamp = readHookVersionLine(path.join(hooksDir, 'gsd-check-update.js')); + assert.strictEqual( + parentStamp, PKG_VERSION, + `parent gsd-check-update stamp must be the installed package version (${PKG_VERSION}), ` + + `got "${parentStamp}" for --profile=${profile}`, + ); + }); + } +}); + +describe('#2695: managed-hooks-registry.cjs is copied byte-for-byte', () => { + for (const profile of ['core', 'full']) { + test(`--profile=${profile} registry matches hooks/dist byte-for-byte`, (t) => { + const { configDir, result } = runCodexInstall({ profile }); + t.after(() => cleanup(configDir)); + + const dest = path.join(hooksDirOf(configDir), 'managed-hooks-registry.cjs'); + assert.ok(fs.existsSync(dest), `registry missing for --profile=${profile}\nstdout: ${result.stdout}`); + const distBytes = fs.readFileSync(path.join(HOOKS_DIST, 'managed-hooks-registry.cjs')); + const destBytes = fs.readFileSync(dest); + assert.deepStrictEqual( + destBytes, distBytes, + `managed-hooks-registry.cjs must be copied byte-for-byte (no version/path transform) for --profile=${profile}`, + ); + }); + } +}); + +describe('#2695: worker hook-version stamp is a literal install-time value', () => { + test('the stamp is the literal package version, never a placeholder or a runtime lookup', (t) => { + const { configDir } = runCodexInstall({ profile: 'full' }); + t.after(() => cleanup(configDir)); + + const workerPath = path.join(hooksDirOf(configDir), 'gsd-check-update-worker.js'); + const content = fs.readFileSync(workerPath, 'utf8'); + // The placeholder must have been replaced — a leftover {{GSD_VERSION}} is the bug shape. + assert.ok( + !content.includes('{{GSD_VERSION}}'), + 'worker still carries an unresolved {{GSD_VERSION}} placeholder — stamping did not run', + ); + // And the resolved value must be the literal version, present on the version-comment line. + const stamp = readHookVersionLine(workerPath); + assert.strictEqual(stamp, PKG_VERSION, `worker stamp must equal package.json version, got "${stamp}"`); + }); +}); + +describe('#2695: unrelated user-owned hook files are preserved', () => { + for (const profile of ['core', 'full']) { + test(`--profile=${profile} leaves a pre-existing user hook untouched`, (t) => { + const userOwned = 'my-custom-hook.js'; + const userBody = '// user-owned hook — do not touch\nconsole.log("mine");\n'; + const { configDir, result } = runCodexInstall({ profile, preseed: { [userOwned]: userBody } }); + t.after(() => cleanup(configDir)); + + const dest = path.join(hooksDirOf(configDir), userOwned); + assert.ok(fs.existsSync(dest), `user-owned ${userOwned} must be preserved for --profile=${profile}\nstdout: ${result.stdout}`); + assert.strictEqual( + fs.readFileSync(dest, 'utf8'), userBody, + `user-owned ${userOwned} bytes must be unchanged for --profile=${profile}`, + ); + }); + } +}); + +describe('#2695: re-running the installer is idempotent for the four-file set', () => { + test('a second full install leaves all four files present and correctly stamped', (t) => { + const first = runCodexInstall({ profile: 'full' }); + t.after(() => cleanup(first.configDir)); + // Second run into the SAME config dir. + const result2 = runNode( + [INSTALL_SCRIPT, '--codex', '--global', '--config-dir', first.configDir, '--profile=full'], + { env: installerEnv({ HOME: first.configDir, USERPROFILE: first.configDir }), timeoutMs: INSTALL_TIMEOUT_MS }, + ); + assert.ok(result2.stdout || result2.stderr); + + const hooksDir = hooksDirOf(first.configDir); + for (const name of CODEX_HOOK_FILES) { + assert.ok(fs.existsSync(path.join(hooksDir, name)), `${name} must survive a second install`); + } + assert.strictEqual( + readHookVersionLine(path.join(hooksDir, 'gsd-check-update-worker.js')), + PKG_VERSION, + 'worker stamp must remain correct after a second install', + ); + }); +}); + +describe('#2695: the core profile enables the hook feature and wires SessionStart (intended)', () => { + // For the update-check/context-monitor hooks to actually fire, Codex needs both + // the feature flag in config.toml AND the hooks.json routing — copying inert + // files alone would leave `core` with scripts Codex never invokes. Entering the + // codex-toml branch for `core` (the #2695 gate change) synthesizes `[features] + // hooks = true` via ensureCodexHooksFeature, writes config.toml, and registers + // the hooks. This is the intended behavior of the fix, not a side effect — these + // assertions pin it so a future re-gating cannot silently regress it. + test('--profile=core writes config.toml enabling the hooks feature', (t) => { + const { configDir } = runCodexInstall({ profile: 'core' }); + t.after(() => cleanup(configDir)); + + const configPath = path.join(configDir, 'config.toml'); + assert.ok(fs.existsSync(configPath), 'core must write config.toml so the hooks feature is enabled'); + const config = fs.readFileSync(configPath, 'utf8'); + assert.ok(/^\s*hooks\s*=\s*true\s*$/m.test(config), 'config.toml must enable hooks = true for core'); + }); + + test('--profile=core wires the SessionStart update-check hook in hooks.json', (t) => { + const { configDir } = runCodexInstall({ profile: 'core' }); + t.after(() => cleanup(configDir)); + + const hooksJsonPath = path.join(configDir, 'hooks.json'); + assert.ok(fs.existsSync(hooksJsonPath), 'core must write hooks.json'); + const hooksJson = JSON.parse(fs.readFileSync(hooksJsonPath, 'utf8')); + const sessionStartCmds = collectHookCommands(hooksJson, 'SessionStart'); + // The command points at the gsd-check-update hook script. Its extension is + // platform-specific — Windows routes through a .cmd shim, POSIX through .js — + // so assert on the basename prefix, not a hardcoded extension (Windows parity). + const routedToUpdateHook = sessionStartCmds.some((c) => { + const token = c.replace(/"/g, '').replace(/\\/g, '/'); + const segs = token.split('/'); + const last = segs[segs.length - 1]; + return last.startsWith('gsd-check-update.'); + }); + assert.ok( + routedToUpdateHook, + `core must route SessionStart to the gsd-check-update hook in hooks.json; got: ${JSON.stringify(sessionStartCmds)}`, + ); + }); +}); + +describe('#2695: the core profile still installs no agent files (negative space)', () => { + test('--profile=core delivers hooks but no gsd-* agent files', (t) => { + const { configDir } = runCodexInstall({ profile: 'core' }); + t.after(() => cleanup(configDir)); + + // Hooks delivered (the fix)… + for (const name of CODEX_HOOK_FILES) { + assert.ok(fs.existsSync(path.join(hooksDirOf(configDir), name)), `${name} delivered for core`); + } + // …but the full agent surface is still absent (core stays minimal). Codex agents + // are .toml ([agents.gsd-*] in config.toml + agents/gsd-*.toml), so check both + // extensions — a .md-only filter would miss a Codex agent-surface regression. + const agentsDir = path.join(configDir, 'agents'); + if (fs.existsSync(agentsDir)) { + const gsdAgents = fs.readdirSync(agentsDir).filter( + (f) => f.startsWith('gsd-') && (f.endsWith('.md') || f.endsWith('.toml')), + ); + assert.deepStrictEqual(gsdAgents, [], 'core must not install the full agent surface'); + } + // And config.toml must carry no agent role sections. + const configPath = path.join(configDir, 'config.toml'); + if (fs.existsSync(configPath)) { + const config = fs.readFileSync(configPath, 'utf8'); + assert.ok( + !/^\[agents\.gsd-/m.test(config), + 'core config.toml must not declare [agents.gsd-*] roles (full agent surface stays a full-profile concern)', + ); + } + }); +}); + +/** + * Read the `// gsd-hook-version: ` comment value from a hook file. + * Returns the trimmed literal. Used so tests assert on the structured stamp, + * not on raw `.includes()` prose (CONTRIBUTING raw-text-matching rule). + */ +function readHookVersionLine(hookPath) { + const content = fs.readFileSync(hookPath, 'utf8'); + const m = content.match(/^\/\/ gsd-hook-version:\s*(.+?)\s*$/m); + return m ? m[1] : null; +} + +/** + * Collect every hook command string registered under a given Codex hooks.json + * event key. Used so the SessionStart-wiring test asserts on the structured + * hook entries (commands), not on raw text matching against the whole file. + */ +function collectHookCommands(hooksJson, eventName) { + const entries = (hooksJson && hooksJson.hooks && Array.isArray(hooksJson.hooks[eventName])) + ? hooksJson.hooks[eventName] + : []; + return entries.flatMap((entry) => + (entry && Array.isArray(entry.hooks) ? entry.hooks : []) + .map((h) => (h && typeof h.command === 'string' ? h.command : null)) + .filter(Boolean), + ); +} + }); +} + + describe('Codex uninstall symmetry for hook-enabled configs', () => { let tmpDir; let codexHome; diff --git a/tests/golden-parity-single-source.test.cjs b/tests/golden-parity-single-source.test.cjs index 2dfaa3581..2ee5491fd 100644 --- a/tests/golden-parity-single-source.test.cjs +++ b/tests/golden-parity-single-source.test.cjs @@ -28,10 +28,11 @@ * function or the exclusion constants. */ -const { test } = require('node:test'); +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 ROOT = path.join(__dirname, '..'); @@ -110,3 +111,174 @@ for (const consumer of CONSUMERS) { } }); } + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/issue-1575-agent-descriptor-parity.test.cjs — consolidation epic #1969 (H3 #3336) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe('folded:issue-1575-agent-descriptor-parity', () => { + +// --- #1575 — surface/install byte-identical agent-output parity (ADR-1235 §0) --- +// Folded in from tests/issue-1575-agent-descriptor-parity.test.cjs (H3 wave 4, +// #3336). Distinct anti-divergence concern from the manifest-builder guards +// above: this asserts applySurface() and installRuntimeArtifacts() produce +// byte-identical agent output for every descriptor-driven runtime, run against +// the SAME configDir so pathPrefix/attribution/converter outputs must match. + +process.env.GSD_TEST_MODE = '1'; + +const { installRuntimeArtifacts } = require('../gsd-core/bin/lib/install-engine.cjs'); +const { applySurface } = require('../gsd-core/bin/lib/surface.cjs'); +const { loadSkillsManifest, resolveProfile } = require('../gsd-core/bin/lib/install-profiles.cjs'); +const { resolveRuntimeArtifactLayout } = require('../gsd-core/bin/lib/runtime-artifact-layout.cjs'); +const { cleanup } = require('./helpers.cjs'); + +const COMMANDS_GSD = path.join(ROOT, 'commands', 'gsd'); + +// The 7 descriptor-driven agent runtimes (cline deferred per code comment: +// rules-only local branch + local/global complication). +const DESCRIPTOR_RUNTIMES = [ + 'cursor', + 'windsurf', + 'augment', + 'trae', + 'codebuddy', + 'copilot', + 'antigravity', +]; + +function snapshotAgents(agentsDir) { + const snap = new Map(); + if (!fs.existsSync(agentsDir)) return snap; + for (const name of fs.readdirSync(agentsDir)) { + if (!name.startsWith('gsd-')) continue; + if (!name.endsWith('.md') && !name.endsWith('.agent.md')) continue; + snap.set(name, fs.readFileSync(path.join(agentsDir, name), 'utf8')); + } + return snap; +} + +// Shared manifest + profile so both paths see the same source agents. +const parity1575Manifest = loadSkillsManifest(COMMANDS_GSD); +const parity1575Profile = resolveProfile({ modes: ['full'], manifest: parity1575Manifest }); +// Same attribution resolver for both paths (undefined → no Co-Authored-By mutation). +const resolveAttribution1575 = () => undefined; + +describe('#1575 — golden-parity: surface path matches install path for descriptor-driven agents', () => { + + for (const runtime of DESCRIPTOR_RUNTIMES) { + test(`${runtime}: surface agents byte-identical to install agents`, (t) => { + const configDir = fs.mkdtempSync(path.join(os.tmpdir(), `gsd-1575-${runtime}-`)); + t.after(() => { try { cleanup(configDir); } catch { /* best-effort */ } }); + + // Step 1: install path writes agents + installRuntimeArtifacts(runtime, configDir, 'global', parity1575Profile, resolveAttribution1575); + + // Step 2: snapshot agent files + const agentsDir = path.join(configDir, 'agents'); + const installSnap = snapshotAgents(agentsDir); + assert.ok(installSnap.size > 0, `${runtime}: install must produce at least one gsd-* agent`); + + // Step 3: surface path re-materializes into the SAME configDir + const layout = resolveRuntimeArtifactLayout(runtime, configDir, 'global'); + applySurface(configDir, layout, parity1575Manifest, undefined, undefined, { resolveAttribution: resolveAttribution1575 }); + + // Step 4: compare byte-for-byte + const surfaceSnap = snapshotAgents(agentsDir); + + // File lists must match + const installFiles = [...installSnap.keys()].sort(); + const surfaceFiles = [...surfaceSnap.keys()].sort(); + assert.deepEqual( + surfaceFiles, + installFiles, + `${runtime}: file lists must match after surface. Install: [${installFiles.join(', ')}] Surface: [${surfaceFiles.join(', ')}]`, + ); + + // Content must match byte-for-byte + for (const [fileName, installContent] of installSnap) { + const surfaceContent = surfaceSnap.get(fileName); + assert.strictEqual( + surfaceContent, + installContent, + `${runtime}/${fileName}: surface content must be byte-identical to install content`, + ); + } + }); + } + + test('cursor with non-undefined attribution: surface agents byte-identical to install agents (M2 coverage)', (t) => { + // M2 regression guard: verify parity holds when resolveAttribution returns + // a real value. Source agents don't carry Co-Authored-By, so processAttribution + // is a no-op (it replaces existing lines, doesn't add new ones). But this test + // proves the agentCtx threading is correct for both paths regardless. + const attrResolver = () => 'Test Bot '; + const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-1575-attr-')); + t.after(() => { try { cleanup(configDir); } catch { /* best-effort */ } }); + + installRuntimeArtifacts('cursor', configDir, 'global', parity1575Profile, attrResolver); + + const agentsDir = path.join(configDir, 'agents'); + const installSnap = snapshotAgents(agentsDir); + assert.ok(installSnap.size > 0, 'install must produce agents'); + + const layout = resolveRuntimeArtifactLayout('cursor', configDir, 'global'); + applySurface(configDir, layout, parity1575Manifest, undefined, undefined, { resolveAttribution: attrResolver }); + + const surfaceSnap = snapshotAgents(agentsDir); + for (const [fileName, installContent] of installSnap) { + assert.strictEqual(surfaceSnap.get(fileName), installContent, + `cursor/${fileName}: content must be byte-identical with non-undefined attribution`); + } + }); + + test('copilot: agents installed as .agent.md (filename rename parity)', (t) => { + const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-1575-copilot-rename-')); + t.after(() => { try { cleanup(configDir); } catch { /* best-effort */ } }); + + installRuntimeArtifacts('copilot', configDir, 'global', parity1575Profile, resolveAttribution1575); + + const agentsDir = path.join(configDir, 'agents'); + assert.ok(fs.existsSync(agentsDir), 'copilot agents dir must exist'); + const agentFiles = fs.readdirSync(agentsDir).filter((f) => f.startsWith('gsd-')); + assert.ok(agentFiles.length > 0, 'copilot must have installed agents'); + assert.ok( + agentFiles.every((f) => f.endsWith('.agent.md')), + `copilot agents must be .agent.md, got: [${agentFiles.slice(0, 3).join(', ')}]`, + ); + }); +}); + +describe('#1575 — surface path: no prune data-loss over pre-existing legacy agents', () => { + test('pre-existing gsd-* agents not in staged set are pruned; user agents preserved', (t) => { + const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-1575-prune-')); + t.after(() => { try { cleanup(configDir); } catch { /* best-effort */ } }); + + // Seed a pre-existing legacy .agent.md (simulating a prior install) + const agentsDir = path.join(configDir, 'agents'); + fs.mkdirSync(agentsDir, { recursive: true }); + fs.writeFileSync(path.join(agentsDir, 'gsd-old-defunct.agent.md'), '# Old\n'); + fs.writeFileSync(path.join(agentsDir, 'user-custom.md'), '# User\n'); + + // Install (should prune stale gsd-*, preserve user agents) + installRuntimeArtifacts('copilot', configDir, 'global', parity1575Profile, resolveAttribution1575); + + const afterInstall = fs.readdirSync(agentsDir); + assert.ok(!afterInstall.includes('gsd-old-defunct.agent.md'), 'stale gsd-* agent must be pruned'); + assert.ok(afterInstall.includes('user-custom.md'), 'user agent must be preserved'); + + // Now surface over the install — must converge to the same state + const layout = resolveRuntimeArtifactLayout('copilot', configDir, 'global'); + applySurface(configDir, layout, parity1575Manifest, undefined, undefined, { resolveAttribution: resolveAttribution1575 }); + + const afterSurface = fs.readdirSync(agentsDir); + // Same set of agent files as after install + const installAgents = afterInstall.filter((f) => f.startsWith('gsd-')).sort(); + const surfaceAgents = afterSurface.filter((f) => f.startsWith('gsd-')).sort(); + assert.deepEqual(surfaceAgents, installAgents, 'surface must converge to same agent set as install'); + assert.ok(afterSurface.includes('user-custom.md'), 'user agent still preserved after surface'); + }); +}); + }); +} diff --git a/tests/install.test.cjs b/tests/install.test.cjs index e0407ed30..45fc41820 100644 --- a/tests/install.test.cjs +++ b/tests/install.test.cjs @@ -7444,3 +7444,315 @@ describe('#3184: scripts/lib/ and scripts/changeset/ install/uninstall parity', 'array so uninstall() removes it (otherwise it ships to every install and orphans on uninstall).'); }); }); + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/issue-607-installer-dry-run.install.test.cjs — test-hygiene +// sweep (H3 Wave 4, #3336) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:issue-607-installer-dry-run (test-hygiene sweep #3336 H3 Wave 4)", () => { +// allow-test-rule: integration-test-input (#607) +// Test-created temp dirs are the only filesystem reads here — not repo source files. +// This is an integration test that seeds fixture files in OS temp dirs and +// asserts that the installer correctly handles --dry-run and the +// cleanupLegacyGsdCc exported helper. + +/** + * #607 — --dry-run flag and cleanupLegacyGsdCc wiring. + * + * Covers: + * 1. Spawning `node bin/install.js --claude --global --dry-run` with an + * isolated HOME that contains a seeded legacy artifact. Asserts exit 0, + * stdout names the artifact and contains "dry" (case-insensitive), and + * no files are mutated (artifact still present; no .claude install). + * Also asserts the per-package cache path appears AT MOST ONCE (no + * double-print regression). + * 2. Spawning `node bin/install.js --claude --dry-run --uninstall` asserts + * the "does not preview --uninstall" warning prints and exits 0 without + * uninstalling anything. + * 3. Direct unit call to the exported cleanupLegacyGsdCc helper: + * - dryRun:true → plan lists the artifact, removes nothing. + * - dryRun:false → seeded leftover removed, dev-preferences.md preserved. + */ + +'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 { spawnSync } = require('node:child_process'); + +const REPO_ROOT = path.resolve(__dirname, '..'); +const INSTALL_BIN = path.join(REPO_ROOT, 'bin', 'install.js'); +const { cleanup } = require('./helpers.cjs'); + +// ─── helpers ───────────────────────────────────────────────────────────────── + +function mkTmp(prefix) { + return fs.mkdtempSync(path.join(os.tmpdir(), prefix)); +} + +function writeFile(filePath, content) { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, content, 'utf8'); +} + +// The assembled signal string used as file content to trigger +// content-references-old-package detection. +const LEGACY_PKG_SIGNAL = 'gsd-core' + '-cc'; + +// ─── Suite 1: spawn --dry-run, assert no mutations ─────────────────────────── + +describe('#607 --dry-run flag: spawned installer exits 0 and mutates nothing', () => { + let tmpHome; + + beforeEach(() => { + tmpHome = mkTmp('gsd-607-dryhome-'); + }); + + afterEach(() => { + cleanup(tmpHome); + }); + + test('exits 0; stdout names artifact and contains "dry"; no install; artifact preserved; no double-print', () => { + // Seed a legacy artifact: a .cjs hook file under HOME/.gemini/hooks/ whose + // content contains the old package name (content-signal, not orphan-by-name). + // This exercises the content-references-old-package reason exclusively. + const legacyHook = path.join(tmpHome, '.gemini', 'hooks', 'gsd-old-update-worker.cjs'); + writeFile(legacyHook, `// installed via ${LEGACY_PKG_SIGNAL}\nconsole.log("old worker");`); + + // Seed the legacy shared cache file + const legacyCache = path.join(tmpHome, '.cache', 'gsd', 'gsd-update-check.json'); + writeFile(legacyCache, JSON.stringify({ legacy: true })); + + // Spawn the installer with --dry-run + const result = spawnSync( + process.execPath, + [INSTALL_BIN, '--claude', '--global', '--dry-run'], + { + env: { + ...process.env, + HOME: tmpHome, + USERPROFILE: tmpHome, + // Redirect Claude config dir into isolated tmp home + CLAUDE_CONFIG_DIR: path.join(tmpHome, '.claude'), + // Suppress slow stale-SDK npm check + GSD_SKIP_STALE_SDK_CHECK: '1', + // Do NOT set GSD_TEST_MODE — we want the main() block to run + GSD_TEST_MODE: undefined, + }, + cwd: REPO_ROOT, + encoding: 'utf8', + timeout: 30_000, + } + ); + + // Exit code must be 0 + assert.equal( + result.status, + 0, + `Expected exit 0 but got ${result.status}.\nstdout: ${result.stdout}\nstderr: ${result.stderr}` + ); + + const stdout = result.stdout + result.stderr; + + // stdout must contain the word "dry" (case-insensitive) + assert.match( + stdout, + /dry/i, + `Expected stdout to contain "dry". Got:\n${stdout}` + ); + + // stdout must mention the seeded legacy artifact path + assert.ok( + stdout.includes(legacyHook), + `Expected stdout to mention ${legacyHook}.\nGot:\n${stdout}` + ); + + // The seeded artifact must STILL EXIST (no mutations) + assert.ok( + fs.existsSync(legacyHook), + `Legacy hook must still exist after --dry-run: ${legacyHook}` + ); + + // The legacy cache must STILL EXIST + assert.ok( + fs.existsSync(legacyCache), + `Legacy cache must still exist after --dry-run: ${legacyCache}` + ); + + // No actual install happened — .claude/gsd-core must not exist + const installDir = path.join(tmpHome, '.claude', 'gsd-core'); + assert.equal( + fs.existsSync(installDir), + false, + `No install should happen during --dry-run; found: ${installDir}` + ); + + // Regression: the per-package cache path must appear AT MOST ONCE + // (guard against the duplicate-print bug where it was printed both inside + // cleanupLegacyGsdCc and again in the outer --dry-run block). + const updateCacheFileName = require( + path.join(REPO_ROOT, 'gsd-core', 'bin', 'lib', 'package-identity.cjs') + ).updateCacheFileName; + const perPkgCacheFile = path.join(tmpHome, '.cache', 'gsd', updateCacheFileName); + const occurrences = stdout.split(perPkgCacheFile).length - 1; + assert.ok( + occurrences <= 1, + `Per-package cache path must appear at most once in stdout; found ${occurrences} times.\nstdout:\n${stdout}` + ); + }); + + test('--uninstall --dry-run prints "does not preview --uninstall" warning and exits 0', () => { + const result = spawnSync( + process.execPath, + [INSTALL_BIN, '--claude', '--uninstall', '--dry-run'], + { + env: { + ...process.env, + HOME: tmpHome, + USERPROFILE: tmpHome, + CLAUDE_CONFIG_DIR: path.join(tmpHome, '.claude'), + GSD_SKIP_STALE_SDK_CHECK: '1', + GSD_TEST_MODE: undefined, + }, + cwd: REPO_ROOT, + encoding: 'utf8', + timeout: 30_000, + } + ); + + assert.equal( + result.status, + 0, + `Expected exit 0 but got ${result.status}.\nstdout: ${result.stdout}\nstderr: ${result.stderr}` + ); + + const stdout = result.stdout + result.stderr; + + // Must print the warning about --uninstall not being previewed + assert.ok( + stdout.includes('does not preview --uninstall'), + `Expected "does not preview --uninstall" warning.\nGot:\n${stdout}` + ); + + // No uninstall occurred — .claude/gsd-core must not have been removed + // (it never existed, but we confirm the installer didn't blow up) + assert.equal( + result.status, + 0, + 'Process must exit 0' + ); + }); +}); + +// ─── Suite 2: direct helper unit tests ─────────────────────────────────────── + +describe('#607 cleanupLegacyGsdCc: exported helper unit tests', () => { + // GSD_TEST_MODE is already set at the top so requiring install.js is safe. + const { cleanupLegacyGsdCc } = require(INSTALL_BIN); + + let tmpRoot; + let homeDir; + + beforeEach(() => { + tmpRoot = mkTmp('gsd-607-unit-'); + homeDir = path.join(tmpRoot, 'home'); + fs.mkdirSync(homeDir, { recursive: true }); + }); + + afterEach(() => { + cleanup(tmpRoot); + }); + + test('dryRun:true — plan lists seeded artifact; nothing removed', () => { + // Seed a content-signal code file under homeDir/.gemini/hooks/ + const legacyHook = path.join(homeDir, '.gemini', 'hooks', 'gsd-old-update-worker.cjs'); + writeFile(legacyHook, `// installed via ${LEGACY_PKG_SIGNAL}\nconsole.log("old worker");`); + + const logMessages = []; + const mockLogger = { log: (msg) => logMessages.push(msg) }; + + const { plan, result } = cleanupLegacyGsdCc({ + homeDir, + dryRun: true, + logger: mockLogger, + }); + + // Plan must include the seeded artifact + const planEntry = plan.find((p) => p.path === legacyHook); + assert.ok(planEntry, `Plan must list seeded artifact: ${legacyHook}\nActual plan: ${JSON.stringify(plan)}`); + + // dryRun result must flag it as skipped, not removed + assert.equal(result.dryRun, true); + assert.equal(result.removed.length, 0, 'dryRun must remove nothing'); + + // The artifact must still exist + assert.ok( + fs.existsSync(legacyHook), + `Artifact must survive dry-run: ${legacyHook}` + ); + + // Logger should have been called at least once + assert.ok(logMessages.length > 0, 'Logger should have been called'); + }); + + test('dryRun:false — seeded leftover removed; dev-preferences.md preserved', () => { + // Seed a content-signal code file + const legacyHook = path.join(homeDir, '.gemini', 'hooks', 'gsd-old-update-worker.cjs'); + writeFile(legacyHook, `// installed via ${LEGACY_PKG_SIGNAL}\nconsole.log("old worker");`); + + // Seed a dev-preferences.md that must NOT be removed + const devPrefs = path.join(homeDir, '.gemini', 'gsd-core', 'dev-preferences.md'); + writeFile(devPrefs, '# My prefs\n\nSome user content — must not be touched.'); + + const { plan, result } = cleanupLegacyGsdCc({ + homeDir, + dryRun: false, + }); + + // The legacy hook must be in the plan + const planEntry = plan.find((p) => p.path === legacyHook); + assert.ok(planEntry, `Legacy hook must appear in plan: ${legacyHook}\nActual plan: ${JSON.stringify(plan)}`); + + // The legacy hook must have been removed + assert.equal( + fs.existsSync(legacyHook), + false, + `Legacy hook must be removed: ${legacyHook}` + ); + + // The removed list must include the legacy hook + assert.ok( + result.removed.includes(legacyHook), + `removed[] must include legacy hook\nActual removed: ${JSON.stringify(result.removed)}` + ); + + // dev-preferences.md must NOT be in the plan and must still exist + const devPrefsInPlan = plan.find((p) => p.path === devPrefs); + assert.equal(devPrefsInPlan, undefined, 'dev-preferences.md must never appear in plan'); + assert.ok( + fs.existsSync(devPrefs), + `dev-preferences.md must be preserved: ${devPrefs}` + ); + }); + + test('dryRun:true — returns plan and result without error (no files present)', () => { + // homeDir exists but no legacy artifacts seeded + const { plan, result } = cleanupLegacyGsdCc({ + homeDir, + dryRun: true, + }); + + assert.ok(Array.isArray(plan), 'plan must be an array'); + assert.equal(result.dryRun, true); + assert.equal(result.removed.length, 0, 'nothing to remove'); + }); +}); + }); +} diff --git a/tests/issue-1575-agent-descriptor-parity.test.cjs b/tests/issue-1575-agent-descriptor-parity.test.cjs deleted file mode 100644 index f4c65cca1..000000000 --- a/tests/issue-1575-agent-descriptor-parity.test.cjs +++ /dev/null @@ -1,176 +0,0 @@ -'use strict'; - -// #1575 — Golden-parity harness (ADR-1235 §0). -// -// Asserts that the surface path (applySurface) produces byte-for-byte identical -// agent output to the install path (installRuntimeArtifacts) for every -// descriptor-driven runtime. Both paths run against the SAME configDir so -// pathPrefix, attribution, and converter outputs match. -// -// The harness: -// 1. installRuntimeArtifacts(runtime, configDir, 'global', profile, resolveAttribution) -// 2. Snapshot every gsd-* agent file in configDir/agents/ -// 3. applySurface(configDir, layout, manifest, ..., opts) -// 4. Compare every agent file byte-for-byte: snapshot === current - -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 os = require('node:os'); - -const ROOT = path.join(__dirname, '..'); -const COMMANDS_GSD = path.join(ROOT, 'commands', 'gsd'); - -const { installRuntimeArtifacts } = require('../gsd-core/bin/lib/install-engine.cjs'); -const { applySurface } = require('../gsd-core/bin/lib/surface.cjs'); -const { loadSkillsManifest, resolveProfile } = require('../gsd-core/bin/lib/install-profiles.cjs'); -const { resolveRuntimeArtifactLayout } = require('../gsd-core/bin/lib/runtime-artifact-layout.cjs'); -const { cleanup } = require('./helpers.cjs'); - -// The 7 descriptor-driven agent runtimes (cline deferred per code comment: -// rules-only local branch + local/global complication). -const DESCRIPTOR_RUNTIMES = [ - 'cursor', - 'windsurf', - 'augment', - 'trae', - 'codebuddy', - 'copilot', - 'antigravity', -]; - -function snapshotAgents(agentsDir) { - const snap = new Map(); - if (!fs.existsSync(agentsDir)) return snap; - for (const name of fs.readdirSync(agentsDir)) { - if (!name.startsWith('gsd-')) continue; - if (!name.endsWith('.md') && !name.endsWith('.agent.md')) continue; - snap.set(name, fs.readFileSync(path.join(agentsDir, name), 'utf8')); - } - return snap; -} - -// Shared manifest + profile so both paths see the same source agents. -const manifest = loadSkillsManifest(COMMANDS_GSD); -const profile = resolveProfile({ modes: ['full'], manifest }); -// Same attribution resolver for both paths (undefined → no Co-Authored-By mutation). -const resolveAttribution = () => undefined; - -describe('#1575 — golden-parity: surface path matches install path for descriptor-driven agents', () => { - - for (const runtime of DESCRIPTOR_RUNTIMES) { - test(`${runtime}: surface agents byte-identical to install agents`, (t) => { - const configDir = fs.mkdtempSync(path.join(os.tmpdir(), `gsd-1575-${runtime}-`)); - t.after(() => { try { cleanup(configDir); } catch { /* best-effort */ } }); - - // Step 1: install path writes agents - installRuntimeArtifacts(runtime, configDir, 'global', profile, resolveAttribution); - - // Step 2: snapshot agent files - const agentsDir = path.join(configDir, 'agents'); - const installSnap = snapshotAgents(agentsDir); - assert.ok(installSnap.size > 0, `${runtime}: install must produce at least one gsd-* agent`); - - // Step 3: surface path re-materializes into the SAME configDir - const layout = resolveRuntimeArtifactLayout(runtime, configDir, 'global'); - applySurface(configDir, layout, manifest, undefined, undefined, { resolveAttribution }); - - // Step 4: compare byte-for-byte - const surfaceSnap = snapshotAgents(agentsDir); - - // File lists must match - const installFiles = [...installSnap.keys()].sort(); - const surfaceFiles = [...surfaceSnap.keys()].sort(); - assert.deepEqual( - surfaceFiles, - installFiles, - `${runtime}: file lists must match after surface. Install: [${installFiles.join(', ')}] Surface: [${surfaceFiles.join(', ')}]`, - ); - - // Content must match byte-for-byte - for (const [fileName, installContent] of installSnap) { - const surfaceContent = surfaceSnap.get(fileName); - assert.strictEqual( - surfaceContent, - installContent, - `${runtime}/${fileName}: surface content must be byte-identical to install content`, - ); - } - }); - } - - test('cursor with non-undefined attribution: surface agents byte-identical to install agents (M2 coverage)', (t) => { - // M2 regression guard: verify parity holds when resolveAttribution returns - // a real value. Source agents don't carry Co-Authored-By, so processAttribution - // is a no-op (it replaces existing lines, doesn't add new ones). But this test - // proves the agentCtx threading is correct for both paths regardless. - const attrResolver = () => 'Test Bot '; - const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-1575-attr-')); - t.after(() => { try { cleanup(configDir); } catch { /* best-effort */ } }); - - installRuntimeArtifacts('cursor', configDir, 'global', profile, attrResolver); - - const agentsDir = path.join(configDir, 'agents'); - const installSnap = snapshotAgents(agentsDir); - assert.ok(installSnap.size > 0, 'install must produce agents'); - - const layout = resolveRuntimeArtifactLayout('cursor', configDir, 'global'); - applySurface(configDir, layout, manifest, undefined, undefined, { resolveAttribution: attrResolver }); - - const surfaceSnap = snapshotAgents(agentsDir); - for (const [fileName, installContent] of installSnap) { - assert.strictEqual(surfaceSnap.get(fileName), installContent, - `cursor/${fileName}: content must be byte-identical with non-undefined attribution`); - } - }); - - test('copilot: agents installed as .agent.md (filename rename parity)', (t) => { - const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-1575-copilot-rename-')); - t.after(() => { try { cleanup(configDir); } catch { /* best-effort */ } }); - - installRuntimeArtifacts('copilot', configDir, 'global', profile, resolveAttribution); - - const agentsDir = path.join(configDir, 'agents'); - assert.ok(fs.existsSync(agentsDir), 'copilot agents dir must exist'); - const agentFiles = fs.readdirSync(agentsDir).filter((f) => f.startsWith('gsd-')); - assert.ok(agentFiles.length > 0, 'copilot must have installed agents'); - assert.ok( - agentFiles.every((f) => f.endsWith('.agent.md')), - `copilot agents must be .agent.md, got: [${agentFiles.slice(0, 3).join(', ')}]`, - ); - }); -}); - -describe('#1575 — surface path: no prune data-loss over pre-existing legacy agents', () => { - test('pre-existing gsd-* agents not in staged set are pruned; user agents preserved', (t) => { - const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-1575-prune-')); - t.after(() => { try { cleanup(configDir); } catch { /* best-effort */ } }); - - // Seed a pre-existing legacy .agent.md (simulating a prior install) - const agentsDir = path.join(configDir, 'agents'); - fs.mkdirSync(agentsDir, { recursive: true }); - fs.writeFileSync(path.join(agentsDir, 'gsd-old-defunct.agent.md'), '# Old\n'); - fs.writeFileSync(path.join(agentsDir, 'user-custom.md'), '# User\n'); - - // Install (should prune stale gsd-*, preserve user agents) - installRuntimeArtifacts('copilot', configDir, 'global', profile, resolveAttribution); - - const afterInstall = fs.readdirSync(agentsDir); - assert.ok(!afterInstall.includes('gsd-old-defunct.agent.md'), 'stale gsd-* agent must be pruned'); - assert.ok(afterInstall.includes('user-custom.md'), 'user agent must be preserved'); - - // Now surface over the install — must converge to the same state - const layout = resolveRuntimeArtifactLayout('copilot', configDir, 'global'); - applySurface(configDir, layout, manifest, undefined, undefined, { resolveAttribution }); - - const afterSurface = fs.readdirSync(agentsDir); - // Same set of agent files as after install - const installAgents = afterInstall.filter((f) => f.startsWith('gsd-')).sort(); - const surfaceAgents = afterSurface.filter((f) => f.startsWith('gsd-')).sort(); - assert.deepEqual(surfaceAgents, installAgents, 'surface must converge to same agent set as install'); - assert.ok(afterSurface.includes('user-custom.md'), 'user agent still preserved after surface'); - }); -}); diff --git a/tests/issue-2639-codex-toml-neutralization.test.cjs b/tests/issue-2639-codex-toml-neutralization.test.cjs deleted file mode 100644 index b8e299cb7..000000000 --- a/tests/issue-2639-codex-toml-neutralization.test.cjs +++ /dev/null @@ -1,111 +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: issue #2639 — Codex install generated agent TOMLs with stale - * Claude-specific references (CLAUDE.md, .claude/skills/, .claudeignore). - * - * RCA: `installCodexConfig()` applied a narrow path-only regex pass before - * calling `generateCodexAgentToml()`, bypassing the full - * `convertClaudeToCodexMarkdown()` + `neutralizeAgentReferences(..., 'AGENTS.md')` - * pipeline used on the .md emit path. Fix routes the TOML path through the - * same pipeline and extends the pipeline to cover bare `.claude/skills/`, - * `.claude/commands/`, `.claude/agents/`, and `.claudeignore`. - */ - -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 os = require('os'); - -const { installCodexConfig } = require('../bin/install.js'); -const { cleanup } = require('./helpers.cjs'); - -function makeTempDir() { - return fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2639-')); -} - -function writeAgentFixture(agentsSrc, name, body) { - const content = `--- -name: ${name} -description: Test agent for #2639 ---- - -${body} -`; - fs.writeFileSync(path.join(agentsSrc, `${name}.md`), content); -} - -describe('#2639 — Codex TOML emit routes through full neutralization pipeline', () => { - let tmpDir; - let agentsSrc; - let targetDir; - - beforeEach(() => { - tmpDir = makeTempDir(); - agentsSrc = path.join(tmpDir, 'agents'); - targetDir = path.join(tmpDir, 'codex'); - fs.mkdirSync(agentsSrc, { recursive: true }); - fs.mkdirSync(targetDir, { recursive: true }); - }); - - afterEach(() => { - cleanup(tmpDir); - }); - - test('strips CLAUDE.md, .claude/skills/, .claude/commands/, .claude/agents/, and .claudeignore from emitted TOML', () => { - writeAgentFixture(agentsSrc, 'gsd-code-reviewer', [ - '**Project instructions:** Read `./CLAUDE.md` if it exists.', - '', - '**CLAUDE.md enforcement:** If `./CLAUDE.md` exists, treat it as hard constraints.', - '', - '**Project skills:** Check `.claude/skills/` or `.agents/skills/` directory.', - '', - 'Also check `.claude/commands/` and `.claude/agents/` for definitions.', - '', - 'DO respect .gitignore and .claudeignore. Do not review ignored files.', - '', - 'Claude will refuse the task if policy violated.', - ].join('\n')); - - installCodexConfig(targetDir, agentsSrc); - - const tomlPath = path.join(targetDir, 'agents', 'gsd-code-reviewer.toml'); - assert.ok(fs.existsSync(tomlPath), 'per-agent TOML written'); - const toml = fs.readFileSync(tomlPath, 'utf8'); - - assert.ok(!toml.includes('CLAUDE.md'), 'no CLAUDE.md references remain in TOML'); - assert.ok(!toml.includes('.claude/skills/'), 'no .claude/skills/ references remain'); - assert.ok(!toml.includes('.claude/commands/'), 'no .claude/commands/ references remain'); - assert.ok(!toml.includes('.claude/agents/'), 'no .claude/agents/ references remain'); - assert.ok(!toml.includes('.claudeignore'), 'no .claudeignore references remain'); - - assert.ok(toml.includes('AGENTS.md'), 'AGENTS.md substituted for CLAUDE.md'); - assert.ok( - toml.includes('.codex/skills/') || toml.includes('.agents/skills/'), - 'skills path neutralized' - ); - - // Standalone "Claude" agent-name references replaced - assert.ok(!/\bClaude\b(?! Code| Opus| Sonnet| Haiku| native| based)/.test(toml), - 'standalone Claude agent-name references replaced'); - }); - - test('preserves Claude product/model names (Claude Code, Claude Opus) in TOML', () => { - writeAgentFixture(agentsSrc, 'gsd-executor', [ - 'This agent runs under Claude Code with the Claude Opus 4 model.', - 'Do not confuse with Claude Sonnet or Claude Haiku.', - ].join('\n')); - - installCodexConfig(targetDir, agentsSrc); - const toml = fs.readFileSync(path.join(targetDir, 'agents', 'gsd-executor.toml'), 'utf8'); - - assert.ok(toml.includes('Claude Code'), 'Claude Code product name preserved'); - assert.ok(toml.includes('Claude Opus'), 'Claude Opus model name preserved'); - }); -}); diff --git a/tests/issue-2695-codex-hook-set.test.cjs b/tests/issue-2695-codex-hook-set.test.cjs deleted file mode 100644 index 98fc43fb7..000000000 --- a/tests/issue-2695-codex-hook-set.test.cjs +++ /dev/null @@ -1,338 +0,0 @@ -// Regression tests for #2695 — Codex native updates omit the update-hook worker -// and the managed-hooks registry. -// -// The Codex install branch in bin/install.js used to allowlist only two of the -// four hook files the shipped build emits (gsd-check-update.js + -// gsd-context-monitor.js), and gated the entire branch on !isMinimalMode so the -// `core` profile installed none of them. The parent SessionStart hook spawn()s -// the worker, which require()s the registry — so Codex was wired to a dependency -// chain the same installer never delivered. -// -// These tests drive the real installer (bin/install.js) behaviorally into an -// isolated temp config dir and assert the complete four-file set is delivered -// for both profiles, the registry is byte-for-byte, the version stamps resolve -// to the installed package version, and unrelated user files are preserved. - -'use strict'; - -const { test, describe, before } = 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 { runNode } = require('./helpers/process-seam.cjs'); -const { throwIfFailed } = require('./helpers/git-fixture.cjs'); - -const { cleanup } = require('./helpers.cjs'); -const { - INSTALL_SCRIPT, - BUILD_SCRIPT, - HOOKS_DIST, - installerEnv, -} = require('./helpers/install-shared.cjs'); - -const PKG_VERSION = require('../package.json').version; - -// #3145: class-norm timeouts, not per-suite values — see helpers/timeouts.cjs. -const { - BUILD_TIMEOUT_MS: BUILD_HOOKS_TIMEOUT_MS, - INSTALL_TIMEOUT_MS, -} = require('./helpers/timeouts.cjs'); - -// The four-file hook set the Codex surface must deliver together (#2695). -const CODEX_HOOK_FILES = [ - 'gsd-check-update.js', - 'gsd-check-update-worker.js', - 'managed-hooks-registry.cjs', - 'gsd-context-monitor.js', -]; - -// Build hooks/dist before any install runs (the installer copies from there). -before(() => { - const r = runNode([BUILD_SCRIPT], { timeoutMs: BUILD_HOOKS_TIMEOUT_MS }); - throwIfFailed(r, `node ${BUILD_SCRIPT}`); -}); - -function hooksDirOf(configDir) { - return path.join(configDir, 'hooks'); -} - -/** Run the Codex installer into an isolated temp config dir. */ -function runCodexInstall({ profile, preseed }) { - const configDir = fs.mkdtempSync(path.join(os.tmpdir(), `gsd-2695-${profile}-`)); - if (preseed) { - const hooksDest = hooksDirOf(configDir); - fs.mkdirSync(hooksDest, { recursive: true }); - for (const [name, body] of Object.entries(preseed)) { - fs.writeFileSync(path.join(hooksDest, name), body); - } - } - // Sandbox HOME/USERPROFILE to configDir: Codex's skills-kind `home: ".agents"` - // override resolves via os.homedir(); sandboxing keeps the spawn self-contained - // (mirrors tests/install-minimal-hooks.test.cjs Codex downgrade test). - const result = runNode( - [INSTALL_SCRIPT, '--codex', '--global', '--config-dir', configDir, `--profile=${profile}`], - { env: installerEnv({ HOME: configDir, USERPROFILE: configDir }), timeoutMs: INSTALL_TIMEOUT_MS }, - ); - return { configDir, result }; -} - -// Older-version stamp used to pre-seed an "upgrade" scenario. -const OLDER_VERSION = '1.7.0'; - -describe('#2695: fresh Codex installs deliver the complete four-file hook set', () => { - for (const profile of ['core', 'full']) { - test(`fresh --profile=${profile} installs all four hook files`, (t) => { - const { configDir, result } = runCodexInstall({ profile }); - t.after(() => cleanup(configDir)); - - const hooksDir = hooksDirOf(configDir); - for (const file of CODEX_HOOK_FILES) { - assert.ok( - fs.existsSync(path.join(hooksDir, file)), - `expected ${file} under /hooks for --profile=${profile}\n` + - `installer stdout: ${result.stdout}\ninstaller stderr: ${result.stderr}`, - ); - } - }); - } -}); - -describe('#2695: Codex upgrades refresh all four hook files to the current version', () => { - // Pre-seed all four files stamped at OLDER_VERSION so an upgrade must overwrite them. - function olderSeed() { - const seed = {}; - for (const name of CODEX_HOOK_FILES) { - // Registry carries no version token; seed it with a stale sentinel body. - if (name.endsWith('.cjs')) { - seed[name] = `// stale registry ${OLDER_VERSION}\nmodule.exports = {};\n`; - } else { - seed[name] = `// gsd-hook-version: ${OLDER_VERSION}\n// stale\n`; - } - } - return seed; - } - - for (const profile of ['core', 'full']) { - test(`--profile=${profile} upgrade refreshes all four hook files`, (t) => { - const { configDir, result } = runCodexInstall({ profile, preseed: olderSeed() }); - t.after(() => cleanup(configDir)); - - const hooksDir = hooksDirOf(configDir); - // All four must now carry the current version stamp where one exists, and - // the registry must no longer be the stale sentinel. - for (const name of CODEX_HOOK_FILES) { - const dest = path.join(hooksDir, name); - assert.ok( - fs.existsSync(dest), - `expected refreshed ${name} for --profile=${profile}\n` + - `installer stdout: ${result.stdout}\ninstaller stderr: ${result.stderr}`, - ); - } - // The registry must be REFRESHED on upgrade, not merely present: assert it no - // longer carries the stale sentinel and now matches the shipped dist byte-for-byte - // (the raw-copy fallback must overwrite an existing dest, not skip it). - const registryDest = path.join(hooksDir, 'managed-hooks-registry.cjs'); - const registryBytes = fs.readFileSync(registryDest, 'utf8'); - assert.ok( - !registryBytes.includes(`stale registry ${OLDER_VERSION}`), - `registry must be refreshed on upgrade for --profile=${profile} (still carries the stale sentinel)`, - ); - assert.deepStrictEqual( - fs.readFileSync(registryDest), - fs.readFileSync(path.join(HOOKS_DIST, 'managed-hooks-registry.cjs')), - `refreshed registry must match hooks/dist byte-for-byte for --profile=${profile}`, - ); - // Version stamps resolved (acceptance #2/#3). - const workerStamp = readHookVersionLine(path.join(hooksDir, 'gsd-check-update-worker.js')); - assert.strictEqual( - workerStamp, PKG_VERSION, - `worker gsd-hook-version stamp must be the installed package version (${PKG_VERSION}), ` + - `got "${workerStamp}" for --profile=${profile}`, - ); - const parentStamp = readHookVersionLine(path.join(hooksDir, 'gsd-check-update.js')); - assert.strictEqual( - parentStamp, PKG_VERSION, - `parent gsd-check-update stamp must be the installed package version (${PKG_VERSION}), ` + - `got "${parentStamp}" for --profile=${profile}`, - ); - }); - } -}); - -describe('#2695: managed-hooks-registry.cjs is copied byte-for-byte', () => { - for (const profile of ['core', 'full']) { - test(`--profile=${profile} registry matches hooks/dist byte-for-byte`, (t) => { - const { configDir, result } = runCodexInstall({ profile }); - t.after(() => cleanup(configDir)); - - const dest = path.join(hooksDirOf(configDir), 'managed-hooks-registry.cjs'); - assert.ok(fs.existsSync(dest), `registry missing for --profile=${profile}\nstdout: ${result.stdout}`); - const distBytes = fs.readFileSync(path.join(HOOKS_DIST, 'managed-hooks-registry.cjs')); - const destBytes = fs.readFileSync(dest); - assert.deepStrictEqual( - destBytes, distBytes, - `managed-hooks-registry.cjs must be copied byte-for-byte (no version/path transform) for --profile=${profile}`, - ); - }); - } -}); - -describe('#2695: worker hook-version stamp is a literal install-time value', () => { - test('the stamp is the literal package version, never a placeholder or a runtime lookup', (t) => { - const { configDir } = runCodexInstall({ profile: 'full' }); - t.after(() => cleanup(configDir)); - - const workerPath = path.join(hooksDirOf(configDir), 'gsd-check-update-worker.js'); - const content = fs.readFileSync(workerPath, 'utf8'); - // The placeholder must have been replaced — a leftover {{GSD_VERSION}} is the bug shape. - assert.ok( - !content.includes('{{GSD_VERSION}}'), - 'worker still carries an unresolved {{GSD_VERSION}} placeholder — stamping did not run', - ); - // And the resolved value must be the literal version, present on the version-comment line. - const stamp = readHookVersionLine(workerPath); - assert.strictEqual(stamp, PKG_VERSION, `worker stamp must equal package.json version, got "${stamp}"`); - }); -}); - -describe('#2695: unrelated user-owned hook files are preserved', () => { - for (const profile of ['core', 'full']) { - test(`--profile=${profile} leaves a pre-existing user hook untouched`, (t) => { - const userOwned = 'my-custom-hook.js'; - const userBody = '// user-owned hook — do not touch\nconsole.log("mine");\n'; - const { configDir, result } = runCodexInstall({ profile, preseed: { [userOwned]: userBody } }); - t.after(() => cleanup(configDir)); - - const dest = path.join(hooksDirOf(configDir), userOwned); - assert.ok(fs.existsSync(dest), `user-owned ${userOwned} must be preserved for --profile=${profile}\nstdout: ${result.stdout}`); - assert.strictEqual( - fs.readFileSync(dest, 'utf8'), userBody, - `user-owned ${userOwned} bytes must be unchanged for --profile=${profile}`, - ); - }); - } -}); - -describe('#2695: re-running the installer is idempotent for the four-file set', () => { - test('a second full install leaves all four files present and correctly stamped', (t) => { - const first = runCodexInstall({ profile: 'full' }); - t.after(() => cleanup(first.configDir)); - // Second run into the SAME config dir. - const result2 = runNode( - [INSTALL_SCRIPT, '--codex', '--global', '--config-dir', first.configDir, '--profile=full'], - { env: installerEnv({ HOME: first.configDir, USERPROFILE: first.configDir }), timeoutMs: INSTALL_TIMEOUT_MS }, - ); - assert.ok(result2.stdout || result2.stderr); - - const hooksDir = hooksDirOf(first.configDir); - for (const name of CODEX_HOOK_FILES) { - assert.ok(fs.existsSync(path.join(hooksDir, name)), `${name} must survive a second install`); - } - assert.strictEqual( - readHookVersionLine(path.join(hooksDir, 'gsd-check-update-worker.js')), - PKG_VERSION, - 'worker stamp must remain correct after a second install', - ); - }); -}); - -describe('#2695: the core profile enables the hook feature and wires SessionStart (intended)', () => { - // For the update-check/context-monitor hooks to actually fire, Codex needs both - // the feature flag in config.toml AND the hooks.json routing — copying inert - // files alone would leave `core` with scripts Codex never invokes. Entering the - // codex-toml branch for `core` (the #2695 gate change) synthesizes `[features] - // hooks = true` via ensureCodexHooksFeature, writes config.toml, and registers - // the hooks. This is the intended behavior of the fix, not a side effect — these - // assertions pin it so a future re-gating cannot silently regress it. - test('--profile=core writes config.toml enabling the hooks feature', (t) => { - const { configDir } = runCodexInstall({ profile: 'core' }); - t.after(() => cleanup(configDir)); - - const configPath = path.join(configDir, 'config.toml'); - assert.ok(fs.existsSync(configPath), 'core must write config.toml so the hooks feature is enabled'); - const config = fs.readFileSync(configPath, 'utf8'); - assert.ok(/^\s*hooks\s*=\s*true\s*$/m.test(config), 'config.toml must enable hooks = true for core'); - }); - - test('--profile=core wires the SessionStart update-check hook in hooks.json', (t) => { - const { configDir } = runCodexInstall({ profile: 'core' }); - t.after(() => cleanup(configDir)); - - const hooksJsonPath = path.join(configDir, 'hooks.json'); - assert.ok(fs.existsSync(hooksJsonPath), 'core must write hooks.json'); - const hooksJson = JSON.parse(fs.readFileSync(hooksJsonPath, 'utf8')); - const sessionStartCmds = collectHookCommands(hooksJson, 'SessionStart'); - // The command points at the gsd-check-update hook script. Its extension is - // platform-specific — Windows routes through a .cmd shim, POSIX through .js — - // so assert on the basename prefix, not a hardcoded extension (Windows parity). - const routedToUpdateHook = sessionStartCmds.some((c) => { - const token = c.replace(/"/g, '').replace(/\\/g, '/'); - const segs = token.split('/'); - const last = segs[segs.length - 1]; - return last.startsWith('gsd-check-update.'); - }); - assert.ok( - routedToUpdateHook, - `core must route SessionStart to the gsd-check-update hook in hooks.json; got: ${JSON.stringify(sessionStartCmds)}`, - ); - }); -}); - -describe('#2695: the core profile still installs no agent files (negative space)', () => { - test('--profile=core delivers hooks but no gsd-* agent files', (t) => { - const { configDir } = runCodexInstall({ profile: 'core' }); - t.after(() => cleanup(configDir)); - - // Hooks delivered (the fix)… - for (const name of CODEX_HOOK_FILES) { - assert.ok(fs.existsSync(path.join(hooksDirOf(configDir), name)), `${name} delivered for core`); - } - // …but the full agent surface is still absent (core stays minimal). Codex agents - // are .toml ([agents.gsd-*] in config.toml + agents/gsd-*.toml), so check both - // extensions — a .md-only filter would miss a Codex agent-surface regression. - const agentsDir = path.join(configDir, 'agents'); - if (fs.existsSync(agentsDir)) { - const gsdAgents = fs.readdirSync(agentsDir).filter( - (f) => f.startsWith('gsd-') && (f.endsWith('.md') || f.endsWith('.toml')), - ); - assert.deepStrictEqual(gsdAgents, [], 'core must not install the full agent surface'); - } - // And config.toml must carry no agent role sections. - const configPath = path.join(configDir, 'config.toml'); - if (fs.existsSync(configPath)) { - const config = fs.readFileSync(configPath, 'utf8'); - assert.ok( - !/^\[agents\.gsd-/m.test(config), - 'core config.toml must not declare [agents.gsd-*] roles (full agent surface stays a full-profile concern)', - ); - } - }); -}); - -/** - * Read the `// gsd-hook-version: ` comment value from a hook file. - * Returns the trimmed literal. Used so tests assert on the structured stamp, - * not on raw `.includes()` prose (CONTRIBUTING raw-text-matching rule). - */ -function readHookVersionLine(hookPath) { - const content = fs.readFileSync(hookPath, 'utf8'); - const m = content.match(/^\/\/ gsd-hook-version:\s*(.+?)\s*$/m); - return m ? m[1] : null; -} - -/** - * Collect every hook command string registered under a given Codex hooks.json - * event key. Used so the SessionStart-wiring test asserts on the structured - * hook entries (commands), not on raw text matching against the whole file. - */ -function collectHookCommands(hooksJson, eventName) { - const entries = (hooksJson && hooksJson.hooks && Array.isArray(hooksJson.hooks[eventName])) - ? hooksJson.hooks[eventName] - : []; - return entries.flatMap((entry) => - (entry && Array.isArray(entry.hooks) ? entry.hooks : []) - .map((h) => (h && typeof h.command === 'string' ? h.command : null)) - .filter(Boolean), - ); -} diff --git a/tests/issue-2834-codex-install-model-ordering.test.cjs b/tests/issue-2834-codex-install-model-ordering.test.cjs deleted file mode 100644 index 6be649462..000000000 --- a/tests/issue-2834-codex-install-model-ordering.test.cjs +++ /dev/null @@ -1,43 +0,0 @@ -// allow-test-rule: structural-implementation-guard (#2834) -'use strict'; - -// Regression guard for #2834: on a clean Codex install, agent TOMLs contained no -// model-routing fields because defaults.json (resolve_model_ids + runtime) was written -// AFTER installCodexConfig generated the TOMLs. The fix extracts writeNonClaudeDefaults -// and calls it BEFORE installCodexConfig. This test asserts the ordering invariant in -// the install source so a future edit can't silently re-introduce the gap. - -const { test } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('fs'); -const path = require('path'); - -const INSTALL_JS = path.join(__dirname, '..', 'bin', 'install.js'); - -test('writeNonClaudeDefaults is called before installCodexConfig in the Codex install flow (#2834)', () => { - const src = fs.readFileSync(INSTALL_JS, 'utf8'); - - // Find the call to writeNonClaudeDefaults that precedes installCodexConfig. - const writeIdx = src.indexOf('writeNonClaudeDefaults(runtime);'); - assert.ok(writeIdx !== -1, 'writeNonClaudeDefaults(runtime) must be called in the install flow'); - - // Find the FIRST installCodexConfig call AFTER the writeNonClaudeDefaults call. - const codexGenIdx = src.indexOf('installCodexConfig(targetDir', writeIdx); - assert.ok(codexGenIdx !== -1 && codexGenIdx > writeIdx, - 'installCodexConfig must be called AFTER writeNonClaudeDefaults so defaults.json ' + - '(resolve_model_ids + runtime) exists before agent TOML generation reads it (#2834)'); - - // The #2834 comment must be present at the call site. - const callSite = src.slice(writeIdx - 300, writeIdx + 100); - assert.ok(/#2834/.test(callSite), 'the writeNonClaudeDefaults call must carry the #2834 rationale comment'); -}); - -test('writeNonClaudeDefaults function exists and is a no-op for Claude (#2834)', () => { - const src = fs.readFileSync(INSTALL_JS, 'utf8'); - const fnIdx = src.indexOf('function writeNonClaudeDefaults('); - assert.ok(fnIdx !== -1, 'writeNonClaudeDefaults must be defined as a function'); - const fnBody = src.slice(fnIdx, fnIdx + 1200); - assert.ok(/nativeModelAliases/.test(fnBody), 'writeNonClaudeDefaults must early-return for Claude (nativeModelAliases check)'); - assert.ok(/resolve_model_ids/.test(fnBody), 'writeNonClaudeDefaults must write resolve_model_ids'); - assert.ok(/defaults\.runtime/.test(fnBody), 'writeNonClaudeDefaults must write runtime'); -}); diff --git a/tests/issue-2940-codex-config-merge-trailing.test.cjs b/tests/issue-2940-codex-config-merge-trailing.test.cjs deleted file mode 100644 index dc1739af0..000000000 --- a/tests/issue-2940-codex-config-merge-trailing.test.cjs +++ /dev/null @@ -1,194 +0,0 @@ -'use strict'; -process.env.GSD_TEST_MODE = '1'; - -/** - * Regression test for #2940 — `gsd-update` overwrites `~/.codex/config.toml`, - * removing any user/Codex-CLI settings added after the GSD-managed marker block. - * - * Root cause: `mergeCodexConfig`'s Case 2 (marker present) preserved content - * BEFORE the marker but unconditionally discarded everything from the marker to - * EOF, replacing it with a freshly generated GSD block. Since a fresh install - * writes the GSD block as the file's entire content, any settings the user or - * Codex CLI later adds (`[model]`, `[mcp_servers.*]`, `[profiles.*]`) land AFTER - * the block, and every subsequent update wiped them. - * - * The fix preserves genuine trailing TOML by routing the post-marker region - * through the existing `stripLeakedGsdCodexSections` (which removes GSD's own - * managed/leaked sections while keeping user tables), then re-appending it after - * the regenerated GSD block — without regressing #2406's de-dup. - * - * Matrix: .gsd/bug/fix/2940-codex-config-merge-preserves-trailing-content/50-test-matrix.md - */ - -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 { cleanup } = require('./helpers.cjs'); - -const { - generateCodexConfigBlock, - mergeCodexConfig, - GSD_CODEX_MARKER, -} = require('../bin/install.js'); - -describe('mergeCodexConfig trailing-content preservation (#2940)', () => { - let tmpDir; - - beforeEach(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2940-merge-')); - }); - - afterEach(() => { - cleanup(tmpDir); - }); - - /** A GSD block with one agent (the shape installCodexConfig passes). */ - const block = () => - generateCodexConfigBlock([{ name: 'gsd-executor', description: 'Executes plans' }]); - - test('trailingUserModelSectionPreserved', () => { - // Row 1 (failing-first regression): a config with the GSD block FIRST, then a user - // [model] section after it (the real-world layout — fresh install fills the file, - // user settings land after). Re-merge must preserve [model] byte-for-byte. - const configPath = path.join(tmpDir, 'config.toml'); - const trailing = '[model]\nname = "gpt-5.4"\n'; - // First write: GSD block + user content after it (no content before the marker). - fs.writeFileSync(configPath, block() + '\n' + trailing); - - mergeCodexConfig(configPath, block()); - - const content = fs.readFileSync(configPath, 'utf8'); - assert.ok(content.includes('[model]'), 'user [model] section preserved after re-merge'); - assert.ok(content.includes('name = "gpt-5.4"'), 'user model value preserved verbatim'); - assert.ok(content.includes(GSD_CODEX_MARKER), 'GSD marker still present'); - const markerCount = (content.match(new RegExp(GSD_CODEX_MARKER.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g')) || []).length; - assert.strictEqual(markerCount, 1, 'exactly one marker (no duplication)'); - assert.ok(content.includes('max_depth ='), 'GSD-managed [agents] block regenerated'); - }); - - test('multipleTrailingTablesPreserved', () => { - // Row 2: multiple trailing user tables ([mcp_servers.*], [profiles.*]). - const configPath = path.join(tmpDir, 'config.toml'); - const trailing = [ - '[mcp_servers.figma]', - 'command = "npx"', - 'args = ["-y", "figma-mcp"]', - '', - '[profiles.dev]', - 'model = "o3"', - 'sandbox_mode = "workspace-write"', - ].join('\n'); - fs.writeFileSync(configPath, block() + '\n' + trailing + '\n'); - - mergeCodexConfig(configPath, block()); - - const content = fs.readFileSync(configPath, 'utf8'); - assert.ok(content.includes('[mcp_servers.figma]'), 'mcp_servers table preserved'); - assert.ok(content.includes('[profiles.dev]'), 'profiles table preserved'); - assert.ok(content.includes('sandbox_mode = "workspace-write"'), 'profile value preserved'); - assert.ok(content.includes(GSD_CODEX_MARKER), 'GSD block regenerated'); - }); - - test('reMergeIsIdempotent', () => { - // Row 3 (acceptance #2): merging the result of a merge again yields identical content. - const configPath = path.join(tmpDir, 'config.toml'); - fs.writeFileSync(configPath, block() + '\n[model]\nname = "o3"\n'); - - mergeCodexConfig(configPath, block()); - const afterFirst = fs.readFileSync(configPath, 'utf8'); - - mergeCodexConfig(configPath, block()); - const afterSecond = fs.readFileSync(configPath, 'utf8'); - - assert.strictEqual(afterSecond, afterFirst, 'second merge is idempotent (no further change)'); - }); - - test('leakedGsdSectionAfterMarkerStillStripped', () => { - // Row 4 (#2406 non-regression): a leaked GSD-managed [agents.gsd-*] section AFTER the - // marker is still REMOVED (not regrown), while genuine user content after it is preserved. - const configPath = path.join(tmpDir, 'config.toml'); - const leakedAndUser = [ - '[agents.gsd-executor]', - 'description = "stale leaked"', - 'config_file = "agents/gsd-executor.toml"', - '', - '[model]', - 'name = "o3"', - ].join('\n'); - fs.writeFileSync(configPath, block() + '\n' + leakedAndUser + '\n'); - - mergeCodexConfig(configPath, block()); - - const content = fs.readFileSync(configPath, 'utf8'); - const gsdStructCount = (content.match(/^\[agents\.gsd-executor\]\s*$/gm) || []).length; - assert.strictEqual(gsdStructCount, 0, 'leaked [agents.gsd-executor] after marker is stripped (not regrown)'); - assert.ok(content.includes('[model]'), 'genuine user [model] after the leaked section still preserved'); - }); - - test('bareAgentsAfterMarkerHandled', () => { - // Row 5: a user AgentsToml scalar (max_threads) the user folded INTO the managed [agents] - // block (the valid, realistic shape — two [agents] tables would be invalid TOML), PLUS a - // separate trailing [model] section. The fix must preserve the user scalar via the existing - // spliceCodexAgentsScalars path AND preserve the trailing [model] via the new trailing-region - // logic, while regenerating exactly one managed [agents] table. - const configPath = path.join(tmpDir, 'config.toml'); - // Simulate: fresh install wrote the GSD block; the user then added max_threads into the - // [agents] table and added a [model] section after it. - const existing = [ - GSD_CODEX_MARKER, - '', - '[agents]', - 'max_depth = 1', - 'max_threads = 4', - '', - '[model]', - 'name = "o3"', - ].join('\n'); - fs.writeFileSync(configPath, existing + '\n'); - - mergeCodexConfig(configPath, block()); - - const content = fs.readFileSync(configPath, 'utf8'); - // The user's max_threads scalar is preserved (spliced into the regenerated managed [agents]); - // there is exactly one [agents] table (the managed one). - assert.ok(content.includes('max_threads = 4'), 'user AgentsToml scalar (max_threads) preserved in managed block'); - const agentsHeaders = (content.match(/^\[agents\]\s*$/gm) || []).length; - assert.strictEqual(agentsHeaders, 1, 'exactly one [agents] table (the managed one)'); - assert.ok(content.includes('max_depth = 1'), 'GSD-managed max_depth still present'); - assert.ok(content.includes('[model]'), 'trailing [model] still preserved'); - }); - - test('beforeAndAfterMarkerBothPreserved', () => { - // Row 6: content both BEFORE and AFTER the marker is preserved; GSD block regenerated once. - const configPath = path.join(tmpDir, 'config.toml'); - const before = '[profiles.work]\nmodel = "gpt-5.4"\n'; - const after = '[mcp_servers.github]\ncommand = "gh-mcp"\n'; - fs.writeFileSync(configPath, before + '\n' + block() + '\n' + after + '\n'); - - mergeCodexConfig(configPath, block()); - - const content = fs.readFileSync(configPath, 'utf8'); - assert.ok(content.includes('[profiles.work]'), 'content before marker preserved'); - assert.ok(content.includes('[mcp_servers.github]'), 'content after marker preserved'); - const markerCount = (content.match(new RegExp(GSD_CODEX_MARKER.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g')) || []).length; - assert.strictEqual(markerCount, 1, 'exactly one marker'); - }); - - test('noTrailingContentUnchanged', () => { - // Row 7 (zero-trailing boundary): a config with ONLY the GSD block (fresh-install case) - // re-merges to just the regenerated block — no spurious blank-line artifacts introduced - // by the trailing-preservation logic. - const configPath = path.join(tmpDir, 'config.toml'); - fs.writeFileSync(configPath, block() + '\n'); - - mergeCodexConfig(configPath, block()); - - const content = fs.readFileSync(configPath, 'utf8'); - // No spurious trailing blank lines beyond the single trailing newline. Use a CRLF-safe - // pattern (\r?\n) so the assertion holds under Windows git-autocrlf line endings. - assert.ok(!/(?:\r?\n){3,}$/.test(content), 'no spurious run of blank lines at end of file'); - assert.strictEqual(content.trim(), block().trim(), 'content is exactly the regenerated block (whitespace-trimmed)'); - }); -}); diff --git a/tests/issue-57-runtime-install-no-drift.test.cjs b/tests/issue-57-runtime-install-no-drift.test.cjs deleted file mode 100644 index 626b7d246..000000000 --- a/tests/issue-57-runtime-install-no-drift.test.cjs +++ /dev/null @@ -1,234 +0,0 @@ -'use strict'; - -// Issue #57 — Runtime Install No-Drift Tests. -// -// Protects the Runtime Install Policy Module boundary (ADR-58) and the explicit -// Runtime Config Adapter Registry (#60) now that the policy boundary (#58), -// explicit adapter registry (#60), and legacy directory-helper retirement (#56) -// have landed. These guards FAIL when: -// -// (AC1) supported-runtime metadata is added to an installer/query call site -// without going through the runtime registry projection, or -// (AC2) config-mutation dispatch bypasses the explicit adapter registry. -// -// (AC3) Assertions are behavioral (require + reflect on live exports) wherever -// behavior can cover the contract; the two source-text assertions are structural -// guards that behavioral checks cannot replace, and are annotated per repo -// convention. (AC4) The existing installer / runtime-policy / runtime-global-skills -// suites must stay green — verified by running them alongside this file, not -// asserted here. -// -// Known INTENTIONAL asymmetries — these are not drift; do not "fix" them by -// tightening the invariants: -// - `grok` appears in runtime-homes.cjs's getGlobalConfigDir switch but NOT in -// the registry / artifact-layout supported sets (it resolves a config-dir home -// but is not an installable artifact target). So runtime-homes' full switch set -// is never tied into the equality invariant — it is only probed forward, per -// installable runtime. -// - getGlobalConfigDir() falls back to ~/.claude for an UNKNOWN runtime instead -// of throwing (a deliberately liberal projection). Only the registry and -// artifact-layout projections are loud gates, so only those are asserted to -// throw on an unknown runtime. -// -// Coverage boundary (deliberate, see #57 follow-up): the structural guard below -// catches a NEW inline `runtime === '...'` branch against an UNREGISTERED runtime. -// It cannot catch a duplicate inline config write added for an ALREADY-registered -// runtime — distinguishing that from the ~169 legitimate per-runtime comparisons in -// the installer requires driving install()/finishInstall() against a mocked -// filesystem and asserting the written surfaces match resolveRuntimeConfigIntent(). -// That behavioral install-driver harness is out of scope for this no-drift pass. -// -// The forward invariant `allRuntimes ⊆ artifact-layout` is already covered by -// tests/install-runtime-artifacts.test.cjs; this file does not duplicate it. - -process.env.GSD_TEST_MODE = '1'; // must precede require of bin/install.js - -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 ROOT = path.join(__dirname, '..'); -const LIB = path.join(ROOT, 'gsd-core', 'bin', 'lib'); - -const { allRuntimes, runtimeMap } = require(path.join(ROOT, 'bin', 'install.js')); -const { - resolveRuntimeConfigIntent, - ALLOWED_CONFIG_RUNTIMES, - INSTALL_SURFACES, -} = require(path.join(LIB, 'runtime-config-adapter-registry.cjs')); -const { resolveRuntimeArtifactLayout } = require( - path.join(LIB, 'runtime-artifact-layout.cjs'), -); -const { getGlobalConfigDir } = require(path.join(LIB, 'runtime-homes.cjs')); - -const sorted = (iterable) => [...iterable].sort(); - -// A runtime name that is deliberately not real and is not a prototype-chain key. -const SENTINEL = '__drift_sentinel_runtime__'; - -describe('issue-57 AC1 — supported-runtime metadata has one projected source of truth', () => { - test('installer allRuntimes, interactive runtimeMap, and registry agree on the supported set', () => { - const installable = sorted(allRuntimes); - assert.deepStrictEqual( - installable, - sorted(Object.values(runtimeMap)), - 'Drift: bin/install.js `allRuntimes` and the interactive `runtimeMap` selection menu ' - + 'diverged. A runtime selectable in the prompt but absent from allRuntimes (or vice ' - + 'versa) is a supported-runtime call site that skipped the projection.', - ); - assert.deepStrictEqual( - installable, - sorted(ALLOWED_CONFIG_RUNTIMES), - 'Drift: bin/install.js `allRuntimes` and `ALLOWED_CONFIG_RUNTIMES` (runtime config ' - + 'adapter registry) diverged. A runtime added to an installer call site without a ' - + 'registry adapter entry bypasses the registry projection — register it in ' - + 'src/runtime-config-adapter-registry.cts.', - ); - }); - - test('every installable runtime resolves a config intent through the registry', () => { - for (const runtime of allRuntimes) { - const intent = resolveRuntimeConfigIntent(runtime); - assert.equal( - intent.runtime, - runtime, - `${runtime} must resolve its own config intent through resolveRuntimeConfigIntent`, - ); - } - }); - - test('every installable runtime resolves a global config dir through runtime-homes', () => { - for (const runtime of allRuntimes) { - const dir = getGlobalConfigDir(runtime); - assert.equal(typeof dir, 'string', `${runtime} config dir must be a string`); - assert.ok(dir.length > 0, `${runtime} must resolve a non-empty global config dir`); - } - }); -}); - -describe('issue-57 AC2 — config-mutation dispatch is closed over the explicit registry', () => { - test('every config intent uses a registry-declared install surface', () => { - const surfaces = new Set(INSTALL_SURFACES); - for (const runtime of allRuntimes) { - const { installSurface } = resolveRuntimeConfigIntent(runtime); - assert.ok( - surfaces.has(installSurface), - `${runtime} dispatches config via unregistered surface "${installSurface}" — add it ` - + 'to INSTALL_SURFACES in the registry instead of branching on it inline.', - ); - } - }); - - test('every finishInstall permission writer is null or a registry-known runtime', () => { - // Registry-derived (no hand-maintained vocabulary): a permission writer either - // names a runtime that is itself in the registry, or is null. A writer pointing - // at an unregistered runtime would mean finishInstall dispatches a config mutation - // outside the registry's known set. - for (const runtime of allRuntimes) { - const { finishPermissionWriter } = resolveRuntimeConfigIntent(runtime); - assert.ok( - finishPermissionWriter === null || ALLOWED_CONFIG_RUNTIMES.has(finishPermissionWriter), - `${runtime} uses finishPermissionWriter "${finishPermissionWriter}", which is neither ` - + 'null nor a registry-known runtime — route it through a registered adapter.', - ); - } - }); - - test('unknown runtime fails loudly through both strict projections (no silent fallthrough)', () => { - assert.throws( - () => resolveRuntimeConfigIntent(SENTINEL), - TypeError, - 'config adapter registry must reject an unknown runtime, not dispatch it silently', - ); - assert.throws( - () => resolveRuntimeArtifactLayout(SENTINEL, path.join(os.tmpdir(), 'gsd-57'), 'global'), - TypeError, - 'artifact-layout projection must reject an unknown runtime', - ); - }); - - test('registry rejects prototype-chain keys (no proto-pollution dispatch bypass)', () => { - for (const key of ['__proto__', 'constructor', 'prototype', 'toString']) { - assert.throws( - () => resolveRuntimeConfigIntent(key), - TypeError, - `${key} must throw, not resolve via the prototype chain`, - ); - } - }); - - // allow-test-rule: structural-regression-guard - // structural guard over bin/install.js source. Behavioral assertions - // cannot observe inline `runtime === '...'` config branching, so this enforces that - // every inline per-runtime branch references a runtime the adapter registry knows - // about — a NEW branch against an unregistered runtime name fails here. It matches - // positive equality only (`runtime === ''` / `runtime === ""`, both quote - // styles), so `runtime !== 'string'`-style type guards are not implicated. See the - // "coverage boundary" note at the top of the file for what this can and cannot catch. - test('every inline `runtime === "..."` branch references a registry-known runtime', () => { - const src = fs.readFileSync(path.join(ROOT, 'bin', 'install.js'), 'utf8'); - const literals = new Set( - [...src.matchAll(/runtime === (?:'([a-z][a-z0-9-]*)'|"([a-z][a-z0-9-]*)")/g)] - .map((m) => m[1] ?? m[2]), - ); - assert.ok(literals.size > 0, 'expected to find inline runtime comparisons in bin/install.js'); - const unregistered = [...literals].filter((r) => !ALLOWED_CONFIG_RUNTIMES.has(r)); - assert.deepStrictEqual( - unregistered, - [], - `inline 'runtime === "..."' branch(es) reference runtimes absent from the config adapter ` - + `registry: ${unregistered.join(', ')} — register them in ` - + 'src/runtime-config-adapter-registry.cts or route the logic through ' - + 'resolveRuntimeConfigIntent instead of branching inline.', - ); - }); - - // allow-test-rule: structural-regression-guard (#2103) - // structural guard over bin/install.js source. VS Code - // (capabilities/vscode/capability.json) is a registry runtime (role:runtime, for - // validator/host-integration coverage) but is NEVER CLI-installed — it is a - // Marketplace/VSIX extension with no --vscode flag and no allRuntimes membership - // (see NON_INSTALLABLE_RUNTIMES in tests/runtime-flags.test.cjs). It must stay - // fully descriptor-driven: bin/install.js must never special-case it by name. - // This is a stricter, clearer-failure-message sibling of the generic - // "every inline runtime === ..." guard above (which would also catch this, but - // with a misleading "register it in the adapter registry" suggestion — vscode - // must never be registered there at all, see the ALLOWED_CONFIG_RUNTIMES filter - // in src/runtime-config-adapter-registry.cts). - test('#2103: bin/install.js has ZERO runtime === "vscode" / isVscode branches (vscode stays fully descriptor-driven)', () => { - const src = fs.readFileSync(path.join(ROOT, 'bin', 'install.js'), 'utf8'); - const runtimeComparisons = [...src.matchAll(/runtime === (?:'vscode'|"vscode")/g)]; - assert.deepStrictEqual( - runtimeComparisons.map((m) => m[0]), - [], - 'bin/install.js must not special-case vscode via `runtime === "vscode"` — vscode has no ' - + 'install surface at all (installSurface: "none") and is never CLI-installed; any ' - + 'vscode-specific behavior belongs in capabilities/vscode/capability.json, not an inline branch.', - ); - const isVscodeRefs = [...src.matchAll(/\bisVscode\b/g)]; - assert.deepStrictEqual( - isVscodeRefs.map((m) => m[0]), - [], - 'bin/install.js must not introduce an isVscode flag — vscode is intentionally excluded ' - + 'from runtimeFlags (Marketplace-distributed, never CLI-installed).', - ); - }); - - // allow-test-rule: structural-regression-guard - // delegation-presence guard. Catches wholesale removal of the registry - // dispatch (a regression to scattered per-runtime config branching). Presence-style, not - // absence-grep, so it does not bite on incidental non-config `runtime === '...'` checks. - test('bin/install.js requires the config adapter registry and dispatches through it', () => { - const src = fs.readFileSync(path.join(ROOT, 'bin', 'install.js'), 'utf8'); - assert.ok( - src.includes('runtime-config-adapter-registry'), - 'bin/install.js no longer requires the runtime config adapter registry', - ); - assert.ok( - src.includes('resolveInstallPlan('), - 'bin/install.js no longer dispatches config through resolveInstallPlan', - ); - }); -}); diff --git a/tests/issue-607-installer-dry-run.install.test.cjs b/tests/issue-607-installer-dry-run.install.test.cjs deleted file mode 100644 index 7c3b1eed1..000000000 --- a/tests/issue-607-installer-dry-run.install.test.cjs +++ /dev/null @@ -1,302 +0,0 @@ -// allow-test-rule: integration-test-input -// Test-created temp dirs are the only filesystem reads here — not repo source files. -// This is an integration test that seeds fixture files in OS temp dirs and -// asserts that the installer correctly handles --dry-run and the -// cleanupLegacyGsdCc exported helper. - -/** - * #607 — --dry-run flag and cleanupLegacyGsdCc wiring. - * - * Covers: - * 1. Spawning `node bin/install.js --claude --global --dry-run` with an - * isolated HOME that contains a seeded legacy artifact. Asserts exit 0, - * stdout names the artifact and contains "dry" (case-insensitive), and - * no files are mutated (artifact still present; no .claude install). - * Also asserts the per-package cache path appears AT MOST ONCE (no - * double-print regression). - * 2. Spawning `node bin/install.js --claude --dry-run --uninstall` asserts - * the "does not preview --uninstall" warning prints and exits 0 without - * uninstalling anything. - * 3. Direct unit call to the exported cleanupLegacyGsdCc helper: - * - dryRun:true → plan lists the artifact, removes nothing. - * - dryRun:false → seeded leftover removed, dev-preferences.md preserved. - */ - -'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 { spawnSync } = require('node:child_process'); - -const REPO_ROOT = path.resolve(__dirname, '..'); -const INSTALL_BIN = path.join(REPO_ROOT, 'bin', 'install.js'); -const { cleanup } = require('./helpers.cjs'); - -// ─── helpers ───────────────────────────────────────────────────────────────── - -function mkTmp(prefix) { - return fs.mkdtempSync(path.join(os.tmpdir(), prefix)); -} - -function writeFile(filePath, content) { - fs.mkdirSync(path.dirname(filePath), { recursive: true }); - fs.writeFileSync(filePath, content, 'utf8'); -} - -// The assembled signal string used as file content to trigger -// content-references-old-package detection. -const LEGACY_PKG_SIGNAL = 'gsd-core' + '-cc'; - -// ─── Suite 1: spawn --dry-run, assert no mutations ─────────────────────────── - -describe('#607 --dry-run flag: spawned installer exits 0 and mutates nothing', () => { - let tmpHome; - - beforeEach(() => { - tmpHome = mkTmp('gsd-607-dryhome-'); - }); - - afterEach(() => { - cleanup(tmpHome); - }); - - test('exits 0; stdout names artifact and contains "dry"; no install; artifact preserved; no double-print', () => { - // Seed a legacy artifact: a .cjs hook file under HOME/.gemini/hooks/ whose - // content contains the old package name (content-signal, not orphan-by-name). - // This exercises the content-references-old-package reason exclusively. - const legacyHook = path.join(tmpHome, '.gemini', 'hooks', 'gsd-old-update-worker.cjs'); - writeFile(legacyHook, `// installed via ${LEGACY_PKG_SIGNAL}\nconsole.log("old worker");`); - - // Seed the legacy shared cache file - const legacyCache = path.join(tmpHome, '.cache', 'gsd', 'gsd-update-check.json'); - writeFile(legacyCache, JSON.stringify({ legacy: true })); - - // Spawn the installer with --dry-run - const result = spawnSync( - process.execPath, - [INSTALL_BIN, '--claude', '--global', '--dry-run'], - { - env: { - ...process.env, - HOME: tmpHome, - USERPROFILE: tmpHome, - // Redirect Claude config dir into isolated tmp home - CLAUDE_CONFIG_DIR: path.join(tmpHome, '.claude'), - // Suppress slow stale-SDK npm check - GSD_SKIP_STALE_SDK_CHECK: '1', - // Do NOT set GSD_TEST_MODE — we want the main() block to run - GSD_TEST_MODE: undefined, - }, - cwd: REPO_ROOT, - encoding: 'utf8', - timeout: 30_000, - } - ); - - // Exit code must be 0 - assert.equal( - result.status, - 0, - `Expected exit 0 but got ${result.status}.\nstdout: ${result.stdout}\nstderr: ${result.stderr}` - ); - - const stdout = result.stdout + result.stderr; - - // stdout must contain the word "dry" (case-insensitive) - assert.match( - stdout, - /dry/i, - `Expected stdout to contain "dry". Got:\n${stdout}` - ); - - // stdout must mention the seeded legacy artifact path - assert.ok( - stdout.includes(legacyHook), - `Expected stdout to mention ${legacyHook}.\nGot:\n${stdout}` - ); - - // The seeded artifact must STILL EXIST (no mutations) - assert.ok( - fs.existsSync(legacyHook), - `Legacy hook must still exist after --dry-run: ${legacyHook}` - ); - - // The legacy cache must STILL EXIST - assert.ok( - fs.existsSync(legacyCache), - `Legacy cache must still exist after --dry-run: ${legacyCache}` - ); - - // No actual install happened — .claude/gsd-core must not exist - const installDir = path.join(tmpHome, '.claude', 'gsd-core'); - assert.equal( - fs.existsSync(installDir), - false, - `No install should happen during --dry-run; found: ${installDir}` - ); - - // Regression: the per-package cache path must appear AT MOST ONCE - // (guard against the duplicate-print bug where it was printed both inside - // cleanupLegacyGsdCc and again in the outer --dry-run block). - const updateCacheFileName = require( - path.join(REPO_ROOT, 'gsd-core', 'bin', 'lib', 'package-identity.cjs') - ).updateCacheFileName; - const perPkgCacheFile = path.join(tmpHome, '.cache', 'gsd', updateCacheFileName); - const occurrences = stdout.split(perPkgCacheFile).length - 1; - assert.ok( - occurrences <= 1, - `Per-package cache path must appear at most once in stdout; found ${occurrences} times.\nstdout:\n${stdout}` - ); - }); - - test('--uninstall --dry-run prints "does not preview --uninstall" warning and exits 0', () => { - const result = spawnSync( - process.execPath, - [INSTALL_BIN, '--claude', '--uninstall', '--dry-run'], - { - env: { - ...process.env, - HOME: tmpHome, - USERPROFILE: tmpHome, - CLAUDE_CONFIG_DIR: path.join(tmpHome, '.claude'), - GSD_SKIP_STALE_SDK_CHECK: '1', - GSD_TEST_MODE: undefined, - }, - cwd: REPO_ROOT, - encoding: 'utf8', - timeout: 30_000, - } - ); - - assert.equal( - result.status, - 0, - `Expected exit 0 but got ${result.status}.\nstdout: ${result.stdout}\nstderr: ${result.stderr}` - ); - - const stdout = result.stdout + result.stderr; - - // Must print the warning about --uninstall not being previewed - assert.ok( - stdout.includes('does not preview --uninstall'), - `Expected "does not preview --uninstall" warning.\nGot:\n${stdout}` - ); - - // No uninstall occurred — .claude/gsd-core must not have been removed - // (it never existed, but we confirm the installer didn't blow up) - assert.equal( - result.status, - 0, - 'Process must exit 0' - ); - }); -}); - -// ─── Suite 2: direct helper unit tests ─────────────────────────────────────── - -describe('#607 cleanupLegacyGsdCc: exported helper unit tests', () => { - // GSD_TEST_MODE is already set at the top so requiring install.js is safe. - const { cleanupLegacyGsdCc } = require(INSTALL_BIN); - - let tmpRoot; - let homeDir; - - beforeEach(() => { - tmpRoot = mkTmp('gsd-607-unit-'); - homeDir = path.join(tmpRoot, 'home'); - fs.mkdirSync(homeDir, { recursive: true }); - }); - - afterEach(() => { - cleanup(tmpRoot); - }); - - test('dryRun:true — plan lists seeded artifact; nothing removed', () => { - // Seed a content-signal code file under homeDir/.gemini/hooks/ - const legacyHook = path.join(homeDir, '.gemini', 'hooks', 'gsd-old-update-worker.cjs'); - writeFile(legacyHook, `// installed via ${LEGACY_PKG_SIGNAL}\nconsole.log("old worker");`); - - const logMessages = []; - const mockLogger = { log: (msg) => logMessages.push(msg) }; - - const { plan, result } = cleanupLegacyGsdCc({ - homeDir, - dryRun: true, - logger: mockLogger, - }); - - // Plan must include the seeded artifact - const planEntry = plan.find((p) => p.path === legacyHook); - assert.ok(planEntry, `Plan must list seeded artifact: ${legacyHook}\nActual plan: ${JSON.stringify(plan)}`); - - // dryRun result must flag it as skipped, not removed - assert.equal(result.dryRun, true); - assert.equal(result.removed.length, 0, 'dryRun must remove nothing'); - - // The artifact must still exist - assert.ok( - fs.existsSync(legacyHook), - `Artifact must survive dry-run: ${legacyHook}` - ); - - // Logger should have been called at least once - assert.ok(logMessages.length > 0, 'Logger should have been called'); - }); - - test('dryRun:false — seeded leftover removed; dev-preferences.md preserved', () => { - // Seed a content-signal code file - const legacyHook = path.join(homeDir, '.gemini', 'hooks', 'gsd-old-update-worker.cjs'); - writeFile(legacyHook, `// installed via ${LEGACY_PKG_SIGNAL}\nconsole.log("old worker");`); - - // Seed a dev-preferences.md that must NOT be removed - const devPrefs = path.join(homeDir, '.gemini', 'gsd-core', 'dev-preferences.md'); - writeFile(devPrefs, '# My prefs\n\nSome user content — must not be touched.'); - - const { plan, result } = cleanupLegacyGsdCc({ - homeDir, - dryRun: false, - }); - - // The legacy hook must be in the plan - const planEntry = plan.find((p) => p.path === legacyHook); - assert.ok(planEntry, `Legacy hook must appear in plan: ${legacyHook}\nActual plan: ${JSON.stringify(plan)}`); - - // The legacy hook must have been removed - assert.equal( - fs.existsSync(legacyHook), - false, - `Legacy hook must be removed: ${legacyHook}` - ); - - // The removed list must include the legacy hook - assert.ok( - result.removed.includes(legacyHook), - `removed[] must include legacy hook\nActual removed: ${JSON.stringify(result.removed)}` - ); - - // dev-preferences.md must NOT be in the plan and must still exist - const devPrefsInPlan = plan.find((p) => p.path === devPrefs); - assert.equal(devPrefsInPlan, undefined, 'dev-preferences.md must never appear in plan'); - assert.ok( - fs.existsSync(devPrefs), - `dev-preferences.md must be preserved: ${devPrefs}` - ); - }); - - test('dryRun:true — returns plan and result without error (no files present)', () => { - // homeDir exists but no legacy artifacts seeded - const { plan, result } = cleanupLegacyGsdCc({ - homeDir, - dryRun: true, - }); - - assert.ok(Array.isArray(plan), 'plan must be an array'); - assert.equal(result.dryRun, true); - assert.equal(result.removed.length, 0, 'nothing to remove'); - }); -}); diff --git a/tests/issue-69-surface-keeps-nested.test.cjs b/tests/issue-69-surface-keeps-nested.test.cjs deleted file mode 100644 index baf123af9..000000000 --- a/tests/issue-69-surface-keeps-nested.test.cjs +++ /dev/null @@ -1,131 +0,0 @@ -// #69 regression: applySurface must NOT re-flatten the nested skill layout -// -// Bug: stageSkillsForRuntimeAsSkills gated nesting on `resolvedProfile.skills === '*'` -// (the sentinel). applySurface → resolveSurface materializes the full profile into a -// concrete Set, so the sentinel check was never true on the surface path. -// Result: applySurface called kind.stage(resolved) → stageSkillsForRuntimeAsSkills with -// a concrete Set → doNest = false → flat layout, overwriting the nested install. -// -// Fix (install-profiles.cts): gate nesting on full OR full-equivalent (all routerStems -// present in the concrete Set) so that the surface path preserves nesting. -// -// NOTE: As of #924 Claude has been REVERTED to FLAT. This test now uses Cline as the -// representative nested runtime. The original claude-global test below is updated to -// assert the flat layout (>= 60 top-level gsd-* entries, concrete skills discoverable). - -'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 os = require('node:os'); - -const ROOT = path.join(__dirname, '..'); -const COMMANDS_GSD = path.join(ROOT, 'commands', 'gsd'); - -const { installRuntimeArtifacts } = require('../gsd-core/bin/lib/install-engine.cjs'); -const { applySurface } = require('../gsd-core/bin/lib/surface.cjs'); -const { loadSkillsManifest, resolveProfile } = require('../gsd-core/bin/lib/install-profiles.cjs'); -const { resolveRuntimeArtifactLayout } = require('../gsd-core/bin/lib/runtime-artifact-layout.cjs'); -const { cleanup } = require('./helpers.cjs'); - -describe('issue-69: applySurface preserves nested skill layout (no re-flatten)', () => { - // #924: Claude is now flat; use Cline as the representative nested runtime. - test('cline global full: applySurface keeps 6 router dirs and nested gsd-ns-manage/skills/help/SKILL.md', (t) => { - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-69-surface-')); - t.after(() => { try { cleanup(tmpDir); } catch { /* best-effort */ } }); - - // Step 1: full install - const manifest = loadSkillsManifest(COMMANDS_GSD); - const resolved = resolveProfile({ modes: ['full'], manifest }); - installRuntimeArtifacts('cline', tmpDir, 'global', resolved); - - const skillsDir = path.join(tmpDir, 'skills'); - - // Sanity: install must produce nested layout (6 top-level router dirs) - const topLevelAfterInstall = fs.readdirSync(skillsDir).filter((n) => n.startsWith('gsd-')); - assert.strictEqual( - topLevelAfterInstall.length, - 6, - `Install must produce exactly 6 gsd-* top-level dirs (routers). Got ${topLevelAfterInstall.length}: [${topLevelAfterInstall.join(', ')}]`, - ); - assert.ok( - fs.existsSync(path.join(skillsDir, 'gsd-ns-workflow', 'skills', 'plan-phase', 'SKILL.md')), - 'After install: gsd-ns-workflow/skills/plan-phase/SKILL.md must exist', - ); - - // Step 2: applySurface (full surface, no surface state file → resolves to full) - const layout = resolveRuntimeArtifactLayout('cline', tmpDir, 'global'); - applySurface(tmpDir, layout, manifest); - - // Step 3: assert nested layout is preserved after applySurface - const topLevelAfterSurface = fs.readdirSync(skillsDir).filter((n) => n.startsWith('gsd-')); - assert.strictEqual( - topLevelAfterSurface.length, - 6, - `After applySurface: expected exactly 6 gsd-* top-level dirs (routers only). Got ${topLevelAfterSurface.length}: [${topLevelAfterSurface.join(', ')}]. ` + - 'Re-flattening detected: applySurface must preserve nested layout (#69 regression).', - ); - - // The nested SKILL.md must still exist (not re-flattened to top-level concrete dir) - assert.ok( - fs.existsSync(path.join(skillsDir, 'gsd-ns-workflow', 'skills', 'plan-phase', 'SKILL.md')), - 'After applySurface: gsd-ns-workflow/skills/plan-phase/SKILL.md must still exist (nested layout preserved)', - ); - - // The concrete skill must NOT have been promoted to a top-level flat dir - assert.ok( - !fs.existsSync(path.join(skillsDir, 'gsd-plan-phase', 'SKILL.md')), - 'After applySurface: gsd-plan-phase/ must NOT exist at top level (#69 re-flatten regression guard)', - ); - }); - - // #924 companion: Claude must use FLAT layout and applySurface must NOT re-nest it. - test('claude global full: install produces flat layout and applySurface preserves it (#924)', (t) => { - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-924-69-')); - t.after(() => { try { cleanup(tmpDir); } catch { /* best-effort */ } }); - - const manifest = loadSkillsManifest(COMMANDS_GSD); - const resolved = resolveProfile({ modes: ['full'], manifest }); - installRuntimeArtifacts('claude', tmpDir, 'global', resolved); - - const skillsDir = path.join(tmpDir, 'skills'); - - // Install must produce FLAT layout (>= 60 gsd-* dirs) - const topLevelAfterInstall = fs.readdirSync(skillsDir).filter((n) => n.startsWith('gsd-')); - assert.ok( - topLevelAfterInstall.length >= 60, - `Claude install must produce >= 60 gsd-* top-level dirs (flat, #924). Got ${topLevelAfterInstall.length}.`, - ); - - // gsd-plan-phase must be directly at top level - assert.ok( - fs.existsSync(path.join(skillsDir, 'gsd-plan-phase', 'SKILL.md')), - 'After claude install: gsd-plan-phase/SKILL.md must be at top level (flat layout, #924)', - ); - - // No nested skills/ subdirs under gsd-ns-* in Claude - assert.ok( - !fs.existsSync(path.join(skillsDir, 'gsd-ns-workflow', 'skills')), - 'After claude install: gsd-ns-workflow/skills/ must NOT exist (flat layout, no nesting, #924)', - ); - - // applySurface must preserve flat layout - const layout = resolveRuntimeArtifactLayout('claude', tmpDir, 'global'); - applySurface(tmpDir, layout, manifest); - - const topLevelAfterSurface = fs.readdirSync(skillsDir).filter((n) => n.startsWith('gsd-')); - assert.ok( - topLevelAfterSurface.length >= 60, - `After applySurface: claude must still have >= 60 gsd-* dirs (flat preserved). Got ${topLevelAfterSurface.length}.`, - ); - - assert.ok( - fs.existsSync(path.join(skillsDir, 'gsd-plan-phase', 'SKILL.md')), - 'After applySurface: gsd-plan-phase/SKILL.md must remain at top level (#924)', - ); - }); -}); diff --git a/tests/issue-787-cline-hooks-agents.test.cjs b/tests/issue-787-cline-hooks-agents.test.cjs deleted file mode 100644 index 2bb058b95..000000000 --- a/tests/issue-787-cline-hooks-agents.test.cjs +++ /dev/null @@ -1,315 +0,0 @@ -// allow-test-rule: source-text-is-the-product -// The Cline rules markdown, the PreToolUse hook script, and the AGENTS.md block -// ARE the deployed contract that the Cline runtime loads/executes — testing their -// text/behavior tests the shipped artifact. Per CONTRIBUTING.md exception matrix. - -/** - * Issue #787 — elevate Cline: write hooks (.clinerules/hooks/) + AGENTS.md. - * - * Verifies the installer now emits the Cline directory-form rules, a - * PreToolUse lifecycle hook (Cline JSON stdin → {cancel,errorMessage, - * contextModification} protocol), and a global ~/.agents/AGENTS.md instruction - * target. Self-contained: does NOT depend on the #782 Cline skills work. - * - * Primary sources adjudicated: - * - https://cline.bot/blog/cline-v3-36-hooks - * hooks live at .clinerules/hooks/ (project) and - * ~/Documents/Cline/Rules/Hooks/ (global); executable scripts named - * exactly after the event with no extension; JSON stdin → JSON stdout - * with cancel / errorMessage / contextModification. - * - https://docs.cline.bot/customization/cline-rules - * Cline processes all .md/.txt files inside a .clinerules/ directory and - * reads cross-tool global instructions from ~/.agents/AGENTS.md. - */ - -'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 os = require('node:os'); -const { runNode } = require('./helpers/process-seam.cjs'); -const { createTempDir, cleanup } = require('./helpers.cjs'); - -const INSTALL_SCRIPT = path.join(__dirname, '..', 'bin', 'install.js'); - -// #3145: class-norm timeouts, not per-suite values — see helpers/timeouts.cjs. -const { PROBE_TIMEOUT_MS, INSTALL_TIMEOUT_MS } = require('./helpers/timeouts.cjs'); - -const { - install, - uninstall, - buildClineRulesBody, - buildClinePreToolUseHook, - buildClineAgentsMdBody, - mergeGsdAgentsMd, - stripGsdFromAgentsMd, - GSD_AGENTS_MD_MARKER, - GSD_AGENTS_MD_CLOSE_MARKER, -} = require('../bin/install.js'); - -// ─── Pure helpers ───────────────────────────────────────────────────────────── - -describe('#787 Cline pure helpers', () => { - test('buildClineRulesBody returns GSD directory-form rules markdown', () => { - const body = buildClineRulesBody(); - assert.equal(typeof body, 'string'); - assert.match(body, /GSD workflows live in `gsd-core\/workflows\/`/); - assert.ok(body.endsWith('\n'), 'rules body should end with a trailing newline'); - }); - - test('buildClinePreToolUseHook returns a syntactically valid Node script', () => { - const script = buildClinePreToolUseHook(); - assert.match(script, /^#!\/usr\/bin\/env node/, 'must carry a node shebang'); - // Cline protocol fields must be present in the emitted decision surface. - assert.match(script, /cancel/); - assert.match(script, /errorMessage/); - const tmp = createTempDir('gsd-787-hookcheck-'); - try { - const p = path.join(tmp, 'PreToolUse'); - fs.writeFileSync(p, script); - const res = runNode(['--check', p], { timeoutMs: PROBE_TIMEOUT_MS }); - assert.equal(res.exitCode, 0, `node --check failed: ${res.stderr}`); - } finally { - cleanup(tmp); - } - }); - - test('PreToolUse hook allows a normal tool call (cancel:false)', () => { - const tmp = createTempDir('gsd-787-hookrun-'); - try { - const p = path.join(tmp, 'PreToolUse'); - fs.writeFileSync(p, buildClinePreToolUseHook()); - const res = runNode([p], { - input: JSON.stringify({ toolName: 'read_file', toolInput: { path: 'src/index.ts' } }), - timeoutMs: PROBE_TIMEOUT_MS, - }); - assert.equal(res.exitCode, 0); - const out = JSON.parse(res.stdout); - assert.equal(out.cancel, false); - } finally { - cleanup(tmp); - } - }); - - test('PreToolUse hook cancels a write into .planning/ with an errorMessage', () => { - const tmp = createTempDir('gsd-787-hookguard-'); - try { - const p = path.join(tmp, 'PreToolUse'); - fs.writeFileSync(p, buildClinePreToolUseHook()); - const res = runNode([p], { - input: JSON.stringify({ toolName: 'write_to_file', toolInput: { path: '.planning/ROADMAP.md', content: 'x' } }), - timeoutMs: PROBE_TIMEOUT_MS, - }); - assert.equal(res.exitCode, 0); - const out = JSON.parse(res.stdout); - assert.equal(out.cancel, true); - assert.match(out.errorMessage, /\.planning/); - } finally { - cleanup(tmp); - } - }); - - test('PreToolUse hook does NOT cancel a write to a non-planning path whose CONTENT mentions .planning/', () => { - const tmp = createTempDir('gsd-787-hookfp-'); - try { - const p = path.join(tmp, 'PreToolUse'); - fs.writeFileSync(p, buildClinePreToolUseHook()); - const res = runNode([p], { - input: JSON.stringify({ - toolName: 'write_to_file', - toolInput: { path: 'docs/guide.md', content: 'Edit your .planning/ROADMAP.md via /gsd commands.' }, - }), - timeoutMs: PROBE_TIMEOUT_MS, - }); - assert.equal(res.exitCode, 0); - assert.equal(JSON.parse(res.stdout).cancel, false, 'content mentioning .planning must not trigger a cancel'); - } finally { - cleanup(tmp); - } - }); - - test('PreToolUse hook fails open on malformed stdin', () => { - const tmp = createTempDir('gsd-787-hookbad-'); - try { - const p = path.join(tmp, 'PreToolUse'); - fs.writeFileSync(p, buildClinePreToolUseHook()); - const res = runNode([p], { input: 'not json{', timeoutMs: PROBE_TIMEOUT_MS }); - assert.equal(res.exitCode, 0); - assert.equal(JSON.parse(res.stdout).cancel, false); - } finally { - cleanup(tmp); - } - }); - - test('mergeGsdAgentsMd creates a marker-delimited block when no file exists', () => { - const tmp = createTempDir('gsd-787-agents-new-'); - try { - const p = path.join(tmp, 'AGENTS.md'); - mergeGsdAgentsMd(p, buildClineAgentsMdBody()); - const content = fs.readFileSync(p, 'utf8'); - assert.ok(content.includes(GSD_AGENTS_MD_MARKER)); - assert.ok(content.includes(GSD_AGENTS_MD_CLOSE_MARKER)); - assert.match(content, /GSD/); - } finally { - cleanup(tmp); - } - }); - - test('mergeGsdAgentsMd preserves pre-existing user content', () => { - const tmp = createTempDir('gsd-787-agents-merge-'); - try { - const p = path.join(tmp, 'AGENTS.md'); - fs.writeFileSync(p, '# My rules\n\nKeep me.\n'); - mergeGsdAgentsMd(p, buildClineAgentsMdBody()); - const content = fs.readFileSync(p, 'utf8'); - assert.match(content, /Keep me\./); - assert.ok(content.includes(GSD_AGENTS_MD_MARKER)); - // Idempotent: second merge does not duplicate the block. - mergeGsdAgentsMd(p, buildClineAgentsMdBody()); - const twice = fs.readFileSync(p, 'utf8'); - const occurrences = twice.split(GSD_AGENTS_MD_MARKER).length - 1; - assert.equal(occurrences, 1, 'GSD block must not duplicate on re-merge'); - assert.match(twice, /Keep me\./); - } finally { - cleanup(tmp); - } - }); - - test('stripGsdFromAgentsMd returns null when file was GSD-only, else cleaned content', () => { - const onlyGsd = `${GSD_AGENTS_MD_MARKER}\nhi\n${GSD_AGENTS_MD_CLOSE_MARKER}\n`; - assert.equal(stripGsdFromAgentsMd(onlyGsd), null); - const mixed = `# Keep\n\n${GSD_AGENTS_MD_MARKER}\nhi\n${GSD_AGENTS_MD_CLOSE_MARKER}\n`; - const cleaned = stripGsdFromAgentsMd(mixed); - assert.match(cleaned, /# Keep/); - assert.ok(!cleaned.includes(GSD_AGENTS_MD_MARKER)); - }); -}); - -// ─── Local install: directory form + hook ─────────────────────────────────────── - -describe('#787 Cline local install — directory form + PreToolUse hook', () => { - let tmpDir; - let previousCwd; - - beforeEach(() => { - tmpDir = createTempDir('gsd-787-cline-local-'); - previousCwd = process.cwd(); - process.chdir(tmpDir); - }); - - afterEach(() => { - process.chdir(previousCwd); - cleanup(tmpDir); - }); - - test('writes .clinerules/ as a directory containing gsd.md', () => { - install(false, 'cline'); - const dir = path.join(tmpDir, '.clinerules'); - assert.ok(fs.statSync(dir).isDirectory(), '.clinerules must be a directory'); - const ruleFile = path.join(dir, 'gsd.md'); - assert.ok(fs.existsSync(ruleFile), '.clinerules/gsd.md must exist'); - assert.match(fs.readFileSync(ruleFile, 'utf8'), /gsd-core\/workflows\//); - }); - - test('writes an executable PreToolUse hook with no extension', () => { - install(false, 'cline'); - const hook = path.join(tmpDir, '.clinerules', 'hooks', 'PreToolUse'); - assert.ok(fs.existsSync(hook), '.clinerules/hooks/PreToolUse must exist'); - if (process.platform !== 'win32') { - const mode = fs.statSync(hook).mode; - assert.ok((mode & 0o111) !== 0, 'PreToolUse must be executable'); - } - }); - - test('migrates a legacy single-file .clinerules into the directory form', () => { - // Simulate a pre-#787 install that wrote a .clinerules FILE. - fs.writeFileSync(path.join(tmpDir, '.clinerules'), '# legacy file\n'); - install(false, 'cline'); - const dir = path.join(tmpDir, '.clinerules'); - assert.ok(fs.statSync(dir).isDirectory(), 'legacy file must be replaced by a directory'); - assert.ok(fs.existsSync(path.join(dir, 'gsd.md'))); - }); - - test('does not follow a symlinked .clinerules (writes the real directory in place)', () => { - if (process.platform === 'win32') return; // symlink perms differ on Windows - // Point .clinerules at an external directory via symlink; install must NOT - // write GSD files through the link. - const external = path.join(tmpDir, 'external-target'); - fs.mkdirSync(external); - fs.symlinkSync(external, path.join(tmpDir, '.clinerules')); - install(false, 'cline'); - const dir = path.join(tmpDir, '.clinerules'); - assert.ok(fs.lstatSync(dir).isDirectory() && !fs.lstatSync(dir).isSymbolicLink(), - '.clinerules must be a real directory, not the symlink'); - assert.ok(!fs.existsSync(path.join(external, 'gsd.md')), 'must not write through the symlink target'); - assert.ok(fs.existsSync(path.join(dir, 'gsd.md'))); - }); - - test('manifest tracks the new directory-form artifacts', () => { - install(false, 'cline'); - const manifestPath = path.join(tmpDir, 'gsd-file-manifest.json'); - assert.ok(fs.existsSync(manifestPath)); - const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); - assert.ok(manifest.files['.clinerules/gsd.md'], 'manifest should track .clinerules/gsd.md'); - assert.ok(manifest.files['.clinerules/hooks/PreToolUse'], 'manifest should track the hook'); - }); -}); - -// ─── Global install: ~/.agents/AGENTS.md (subprocess, HOME-isolated) ───────────── - -describe('#787 Cline global install — ~/.agents/AGENTS.md', () => { - function runGlobalClineInstall() { - const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-787-cline-global-')); - const env = { ...process.env, HOME: root, USERPROFILE: root }; - delete env.GSD_TEST_MODE; - const res = runNode( - [INSTALL_SCRIPT, '--cline', '--global', '--config-dir', path.join(root, '.cline')], - { cwd: root, env, timeoutMs: INSTALL_TIMEOUT_MS }, - ); - return { root, res }; - } - - test('writes ~/.agents/AGENTS.md with a GSD marker block', () => { - const { root, res } = runGlobalClineInstall(); - try { - assert.equal(res.exitCode, 0, `installer failed: ${res.stderr}`); - const agents = path.join(root, '.agents', 'AGENTS.md'); - assert.ok(fs.existsSync(agents), '~/.agents/AGENTS.md must exist after a global Cline install'); - const content = fs.readFileSync(agents, 'utf8'); - assert.ok(content.includes(GSD_AGENTS_MD_MARKER)); - assert.match(content, /GSD/); - } finally { - cleanup(root); - } - }); -}); - -// ─── Uninstall symmetry ───────────────────────────────────────────────────────── - -describe('#787 Cline uninstall removes managed artifacts', () => { - let tmpDir; - let previousCwd; - - beforeEach(() => { - tmpDir = createTempDir('gsd-787-cline-uninstall-'); - previousCwd = process.cwd(); - process.chdir(tmpDir); - }); - - afterEach(() => { - process.chdir(previousCwd); - cleanup(tmpDir); - }); - - test('local uninstall removes .clinerules/gsd.md and the hook', () => { - install(false, 'cline'); - assert.ok(fs.existsSync(path.join(tmpDir, '.clinerules', 'gsd.md'))); - uninstall(false, 'cline'); - assert.ok(!fs.existsSync(path.join(tmpDir, '.clinerules', 'gsd.md')), 'gsd.md should be removed'); - assert.ok(!fs.existsSync(path.join(tmpDir, '.clinerules', 'hooks', 'PreToolUse')), 'hook should be removed'); - }); -}); diff --git a/tests/issue-607-legacy-cleanup.test.cjs b/tests/legacy-cleanup.test.cjs similarity index 100% rename from tests/issue-607-legacy-cleanup.test.cjs rename to tests/legacy-cleanup.test.cjs diff --git a/tests/runtime-artifact-layout-surface.test.cjs b/tests/runtime-artifact-layout-surface.test.cjs index 23f731705..8b3a92f74 100644 --- a/tests/runtime-artifact-layout-surface.test.cjs +++ b/tests/runtime-artifact-layout-surface.test.cjs @@ -1415,3 +1415,123 @@ describe('installOpencodeFamilySkills destination parity (#2911 sibling coverage }); } }); + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/issue-69-surface-keeps-nested.test.cjs — consolidation epic #1969 (H3 #3336) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe('folded:issue-69-surface-keeps-nested', () => { + +// #69 regression, folded from issue-69-surface-keeps-nested.test.cjs: +// stageSkillsForRuntimeAsSkills gated nesting on `resolvedProfile.skills === '*'` +// (the sentinel). applySurface → resolveSurface materializes the full profile +// into a concrete Set, so the sentinel check was never true on the +// surface path, causing applySurface to re-flatten a nested install. Fix +// (install-profiles.cts): gate nesting on full OR full-equivalent (all +// routerStems present in the concrete Set). +// +// #924: Claude was reverted to FLAT, so the claude case below asserts the +// flat layout is preserved (not re-nested) rather than a nested one. +describe('issue-69: applySurface preserves nested skill layout (no re-flatten)', () => { + test('cline global full: applySurface keeps 6 router dirs and nested gsd-ns-manage/skills/help/SKILL.md', (t) => { + process.env.GSD_TEST_MODE = '1'; + const { installRuntimeArtifacts } = require('../gsd-core/bin/lib/install-engine.cjs'); + const dir = tmpDir('gsd-69-surface-'); + t.after(() => { try { cleanup(dir); } catch { /* best-effort */ } }); + + // Step 1: full install + const manifest = loadSkillsManifest(REAL_COMMANDS_DIR); + const resolved = resolveProfile({ modes: ['full'], manifest }); + installRuntimeArtifacts('cline', dir, 'global', resolved); + + const skillsDir = path.join(dir, 'skills'); + + // Sanity: install must produce nested layout (6 top-level router dirs) + const topLevelAfterInstall = fs.readdirSync(skillsDir).filter((n) => n.startsWith('gsd-')); + assert.strictEqual( + topLevelAfterInstall.length, + 6, + `Install must produce exactly 6 gsd-* top-level dirs (routers). Got ${topLevelAfterInstall.length}: [${topLevelAfterInstall.join(', ')}]`, + ); + assert.ok( + fs.existsSync(path.join(skillsDir, 'gsd-ns-workflow', 'skills', 'plan-phase', 'SKILL.md')), + 'After install: gsd-ns-workflow/skills/plan-phase/SKILL.md must exist', + ); + + // Step 2: applySurface (full surface, no surface state file → resolves to full) + const layout = resolveRuntimeArtifactLayout('cline', dir, 'global'); + applySurface(dir, layout, manifest); + + // Step 3: assert nested layout is preserved after applySurface + const topLevelAfterSurface = fs.readdirSync(skillsDir).filter((n) => n.startsWith('gsd-')); + assert.strictEqual( + topLevelAfterSurface.length, + 6, + `After applySurface: expected exactly 6 gsd-* top-level dirs (routers only). Got ${topLevelAfterSurface.length}: [${topLevelAfterSurface.join(', ')}]. ` + + 'Re-flattening detected: applySurface must preserve nested layout (#69 regression).', + ); + + // The nested SKILL.md must still exist (not re-flattened to top-level concrete dir) + assert.ok( + fs.existsSync(path.join(skillsDir, 'gsd-ns-workflow', 'skills', 'plan-phase', 'SKILL.md')), + 'After applySurface: gsd-ns-workflow/skills/plan-phase/SKILL.md must still exist (nested layout preserved)', + ); + + // The concrete skill must NOT have been promoted to a top-level flat dir + assert.ok( + !fs.existsSync(path.join(skillsDir, 'gsd-plan-phase', 'SKILL.md')), + 'After applySurface: gsd-plan-phase/ must NOT exist at top level (#69 re-flatten regression guard)', + ); + }); + + // #924 companion: Claude must use FLAT layout and applySurface must NOT re-nest it. + test('claude global full: install produces flat layout and applySurface preserves it (#924)', (t) => { + process.env.GSD_TEST_MODE = '1'; + const { installRuntimeArtifacts } = require('../gsd-core/bin/lib/install-engine.cjs'); + const dir = tmpDir('gsd-924-69-'); + t.after(() => { try { cleanup(dir); } catch { /* best-effort */ } }); + + const manifest = loadSkillsManifest(REAL_COMMANDS_DIR); + const resolved = resolveProfile({ modes: ['full'], manifest }); + installRuntimeArtifacts('claude', dir, 'global', resolved); + + const skillsDir = path.join(dir, 'skills'); + + // Install must produce FLAT layout (>= 60 gsd-* dirs) + const topLevelAfterInstall = fs.readdirSync(skillsDir).filter((n) => n.startsWith('gsd-')); + assert.ok( + topLevelAfterInstall.length >= 60, + `Claude install must produce >= 60 gsd-* top-level dirs (flat, #924). Got ${topLevelAfterInstall.length}.`, + ); + + // gsd-plan-phase must be directly at top level + assert.ok( + fs.existsSync(path.join(skillsDir, 'gsd-plan-phase', 'SKILL.md')), + 'After claude install: gsd-plan-phase/SKILL.md must be at top level (flat layout, #924)', + ); + + // No nested skills/ subdirs under gsd-ns-* in Claude + assert.ok( + !fs.existsSync(path.join(skillsDir, 'gsd-ns-workflow', 'skills')), + 'After claude install: gsd-ns-workflow/skills/ must NOT exist (flat layout, no nesting, #924)', + ); + + // applySurface must preserve flat layout + const layout = resolveRuntimeArtifactLayout('claude', dir, 'global'); + applySurface(dir, layout, manifest); + + const topLevelAfterSurface = fs.readdirSync(skillsDir).filter((n) => n.startsWith('gsd-')); + assert.ok( + topLevelAfterSurface.length >= 60, + `After applySurface: claude must still have >= 60 gsd-* dirs (flat preserved). Got ${topLevelAfterSurface.length}.`, + ); + + assert.ok( + fs.existsSync(path.join(skillsDir, 'gsd-plan-phase', 'SKILL.md')), + 'After applySurface: gsd-plan-phase/SKILL.md must remain at top level (#924)', + ); + }); +}); + }); +} diff --git a/tests/runtime-config-adapter-registry.test.cjs b/tests/runtime-config-adapter-registry.test.cjs index 98e52f25a..8834e81d1 100644 --- a/tests/runtime-config-adapter-registry.test.cjs +++ b/tests/runtime-config-adapter-registry.test.cjs @@ -26,6 +26,18 @@ const { } = require(path.join(ROOT, 'gsd-core', 'bin', 'lib', 'runtime-config-adapter-registry.cjs')); const registry = require(path.join(ROOT, 'gsd-core', 'bin', 'lib', 'capability-registry.cjs')); +// Folded from tests/issue-57-runtime-install-no-drift.test.cjs (issue #57). +process.env.GSD_TEST_MODE = '1'; // must precede require of bin/install.js +const fs = require('node:fs'); +const os = require('node:os'); +const { allRuntimes, runtimeMap } = require(path.join(ROOT, 'bin', 'install.js')); +const { resolveRuntimeArtifactLayout } = require( + path.join(ROOT, 'gsd-core', 'bin', 'lib', 'runtime-artifact-layout.cjs'), +); +const { getGlobalConfigDir } = require(path.join(ROOT, 'gsd-core', 'bin', 'lib', 'runtime-homes.cjs')); + +const sorted = (iterable) => [...iterable].sort(); + // --------------------------------------------------------------------------- // Source-of-truth table — DERIVED from the capability registry descriptors. // Each row is the descriptor projection of one runtime's config intent. This is @@ -373,3 +385,169 @@ describe('resolveInstallPlan — descriptor-projection contract (count-agnostic) } }); }); + +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe('folded:issue-57-runtime-install-no-drift', () => { +// --------------------------------------------------------------------------- +// Folded from tests/issue-57-runtime-install-no-drift.test.cjs (issue #57, +// H3 Wave 4 consolidation). Protects the Runtime Install Policy Module +// boundary (ADR-58) and the explicit Runtime Config Adapter Registry (#60): +// these guards fail when supported-runtime metadata bypasses the registry +// projection (AC1) or config-mutation dispatch bypasses the explicit adapter +// registry (AC2). Known intentional asymmetry: `grok` appears in +// runtime-homes.cjs's getGlobalConfigDir switch but not in the registry / +// artifact-layout supported sets, and getGlobalConfigDir() falls back to +// ~/.claude for an unknown runtime instead of throwing (deliberately +// liberal) — only the registry and artifact-layout projections are loud +// gates. One source case was dropped as subsumed (see note below). +// --------------------------------------------------------------------------- + +describe('issue-57 AC1 — supported-runtime metadata has one projected source of truth', () => { + test('installer allRuntimes, interactive runtimeMap, and registry agree on the supported set', () => { + const installable = sorted(allRuntimes); + assert.deepStrictEqual( + installable, + sorted(Object.values(runtimeMap)), + 'Drift: bin/install.js `allRuntimes` and the interactive `runtimeMap` selection menu ' + + 'diverged. A runtime selectable in the prompt but absent from allRuntimes (or vice ' + + 'versa) is a supported-runtime call site that skipped the projection.', + ); + assert.deepStrictEqual( + installable, + sorted(ALLOWED_CONFIG_RUNTIMES), + 'Drift: bin/install.js `allRuntimes` and `ALLOWED_CONFIG_RUNTIMES` (runtime config ' + + 'adapter registry) diverged. A runtime added to an installer call site without a ' + + 'registry adapter entry bypasses the registry projection — register it in ' + + 'src/runtime-config-adapter-registry.cts.', + ); + }); + + // NOTE: source also asserted `resolveRuntimeConfigIntent(runtime).runtime === runtime` + // for every `allRuntimes` entry — dropped here as subsumed by the + // descriptor-projection contract test above (deep-equal over every registry + // runtime, a strict superset of `allRuntimes` per the equality just asserted). + + test('every installable runtime resolves a global config dir through runtime-homes', () => { + for (const runtime of allRuntimes) { + const dir = getGlobalConfigDir(runtime); + assert.equal(typeof dir, 'string', `${runtime} config dir must be a string`); + assert.ok(dir.length > 0, `${runtime} must resolve a non-empty global config dir`); + } + }); +}); + +describe('issue-57 AC2 — config-mutation dispatch is closed over the explicit registry', () => { + test('every config intent uses a registry-declared install surface', () => { + const surfaces = new Set(INSTALL_SURFACES); + for (const runtime of allRuntimes) { + const { installSurface } = resolveRuntimeConfigIntent(runtime); + assert.ok( + surfaces.has(installSurface), + `${runtime} dispatches config via unregistered surface "${installSurface}" — add it ` + + 'to INSTALL_SURFACES in the registry instead of branching on it inline.', + ); + } + }); + + test('every finishInstall permission writer is null or a registry-known runtime', () => { + for (const runtime of allRuntimes) { + const { finishPermissionWriter } = resolveRuntimeConfigIntent(runtime); + assert.ok( + finishPermissionWriter === null || ALLOWED_CONFIG_RUNTIMES.has(finishPermissionWriter), + `${runtime} uses finishPermissionWriter "${finishPermissionWriter}", which is neither ` + + 'null nor a registry-known runtime — route it through a registered adapter.', + ); + } + }); + + test('unknown runtime fails loudly through both strict projections (no silent fallthrough)', () => { + const SENTINEL = '__drift_sentinel_runtime__'; + assert.throws( + () => resolveRuntimeConfigIntent(SENTINEL), + TypeError, + 'config adapter registry must reject an unknown runtime, not dispatch it silently', + ); + assert.throws( + () => resolveRuntimeArtifactLayout(SENTINEL, path.join(os.tmpdir(), 'gsd-57'), 'global'), + TypeError, + 'artifact-layout projection must reject an unknown runtime', + ); + }); + + test('registry rejects prototype-chain keys (no proto-pollution dispatch bypass)', () => { + // Overlaps __proto__/constructor/toString with the individually-named + // cases above; folded anyway to keep the 'prototype' key covered (not + // asserted individually elsewhere in this file). + for (const key of ['__proto__', 'constructor', 'prototype', 'toString']) { + assert.throws( + () => resolveRuntimeConfigIntent(key), + TypeError, + `${key} must throw, not resolve via the prototype chain`, + ); + } + }); + + // allow-test-rule: structural-regression-guard (#3336) + // structural guard over bin/install.js source. Behavioral assertions + // cannot observe inline `runtime === '...'` config branching, so this enforces that + // every inline per-runtime branch references a runtime the adapter registry knows + // about — a NEW branch against an unregistered runtime name fails here. + test('every inline `runtime === "..."` branch references a registry-known runtime', () => { + const src = fs.readFileSync(path.join(ROOT, 'bin', 'install.js'), 'utf8'); + const literals = new Set( + [...src.matchAll(/runtime === (?:'([a-z][a-z0-9-]*)'|"([a-z][a-z0-9-]*)")/g)] + .map((m) => m[1] ?? m[2]), + ); + assert.ok(literals.size > 0, 'expected to find inline runtime comparisons in bin/install.js'); + const unregistered = [...literals].filter((r) => !ALLOWED_CONFIG_RUNTIMES.has(r)); + assert.deepStrictEqual( + unregistered, + [], + `inline 'runtime === "..."' branch(es) reference runtimes absent from the config adapter ` + + `registry: ${unregistered.join(', ')} — register them in ` + + 'src/runtime-config-adapter-registry.cts or route the logic through ' + + 'resolveRuntimeConfigIntent instead of branching inline.', + ); + }); + + // allow-test-rule: structural-regression-guard (#2103) + // VS Code is a registry runtime but is NEVER CLI-installed (Marketplace/VSIX + // extension); it must stay fully descriptor-driven — bin/install.js must + // never special-case it by name. + test('#2103: bin/install.js has ZERO runtime === "vscode" / isVscode branches (vscode stays fully descriptor-driven)', () => { + const src = fs.readFileSync(path.join(ROOT, 'bin', 'install.js'), 'utf8'); + const runtimeComparisons = [...src.matchAll(/runtime === (?:'vscode'|"vscode")/g)]; + assert.deepStrictEqual( + runtimeComparisons.map((m) => m[0]), + [], + 'bin/install.js must not special-case vscode via `runtime === "vscode"` — vscode has no ' + + 'install surface at all (installSurface: "none") and is never CLI-installed; any ' + + 'vscode-specific behavior belongs in capabilities/vscode/capability.json, not an inline branch.', + ); + const isVscodeRefs = [...src.matchAll(/\bisVscode\b/g)]; + assert.deepStrictEqual( + isVscodeRefs.map((m) => m[0]), + [], + 'bin/install.js must not introduce an isVscode flag — vscode is intentionally excluded ' + + 'from runtimeFlags (Marketplace-distributed, never CLI-installed).', + ); + }); + + // allow-test-rule: structural-regression-guard (#3336) + // delegation-presence guard. Catches wholesale removal of the registry + // dispatch (a regression to scattered per-runtime config branching). + test('bin/install.js requires the config adapter registry and dispatches through it', () => { + const src = fs.readFileSync(path.join(ROOT, 'bin', 'install.js'), 'utf8'); + assert.ok( + src.includes('runtime-config-adapter-registry'), + 'bin/install.js no longer requires the runtime config adapter registry', + ); + assert.ok( + src.includes('resolveInstallPlan('), + 'bin/install.js no longer dispatches config through resolveInstallPlan', + ); + }); +}); + }); +}