test(#3336): fold the installer & runtime surface issue-* cluster — Wave 4 (#3376)

* 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 <sim@local>
This commit is contained in:
Tom Boucher
2026-08-11 23:35:44 -04:00
committed by GitHub
parent 23e6d49929
commit ad07f76a31
21 changed files with 1830 additions and 1856 deletions

View File

@@ -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.

View File

@@ -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",

View File

@@ -1,4 +1,4 @@
{
"maxFiles": 309,
"maxFiles": 305,
"grace": 3
}

View File

@@ -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 }

View File

@@ -101,8 +101,9 @@ type RuntimeDescriptorMap = Record<string, { runtime: Record<string, unknown> |
* (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<string> = new Set(
Object.entries(runtimes)

View File

@@ -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');
});
});
});
}

View File

@@ -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 <config>/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: <value>` 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;

View File

@@ -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 <test@example.com>';
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');
});
});
});
}

View File

@@ -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');
});
});
});
}

View File

@@ -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 <test@example.com>';
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');
});
});

View File

@@ -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');
});
});

View File

@@ -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 <config>/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: <value>` 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),
);
}

View File

@@ -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');
});

View File

@@ -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)');
});
});

View File

@@ -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 === '<name>'` / `runtime === "<name>"`, 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',
);
});
});

View File

@@ -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');
});
});

View File

@@ -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<string>, 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)',
);
});
});

View File

@@ -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/<EventName> (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');
});
});

View File

@@ -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<string>, 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)',
);
});
});
});
}

View File

@@ -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',
);
});
});
});
}