diff --git a/src/runtime-artifact-conversion.cts b/src/runtime-artifact-conversion.cts index 7977909e7..261c06879 100644 --- a/src/runtime-artifact-conversion.cts +++ b/src/runtime-artifact-conversion.cts @@ -2522,10 +2522,12 @@ function rewriteStagedSkillBodies(stagedDir, opts) { * attribution from opts, then delegates to applyRuntimeContentRewritesForCommandsInPlace * (single copy+rewrite owner). * - * @internal — symmetric companion to rewriteStagedSkillBodies; retained as the deep-seam - * API for command bodies. No production caller today (install rewrites commands via - * copyWithPathReplacement → applyRuntimeContentRewritesForCommandsInPlace). Kept for - * API symmetry + test coverage. + * @internal — symmetric companion to rewriteStagedSkillBodies; the deep-seam API for + * command bodies. Production callers: applySurface (surface.cts) and the install path + * in createRuntimeArtifactInstallPlan (runtime-artifact-install-plan.cts) — both keep + * the returned temp dir alive until they have copied its contents out, then clean it up + * in their own finally. (A test that treats this as a throwaway shared-tmp path will + * race those live temp dirs under --test-concurrency; see #1575/#2090.) * * @returns {string} path to the temp dir (caller is responsible for cleanup) */ diff --git a/tests/config-get-default.test.cjs b/tests/config-get-default.test.cjs index 278c1c654..884537efa 100644 --- a/tests/config-get-default.test.cjs +++ b/tests/config-get-default.test.cjs @@ -13,11 +13,83 @@ const { describe, test, beforeEach, afterEach } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('fs'); const path = require('path'); -const { execFileSync } = require('child_process'); const os = require('os'); const { cleanup } = require('./helpers.cjs'); -const GSD_TOOLS = path.join(__dirname, '..', 'gsd-core', 'bin', 'gsd-tools.cjs'); +// In-process invocation, not execFileSync: cmdConfigGet is a pure CJS +// function reachable without spawning `node` as a child. The prior +// execFileSync(..., { timeout: 5000 }) raced a real subprocess's startup +// (full node boot + gsd-tools.cjs's large eager require graph — capability +// registry, phase/roadmap/agent/check/task routers, verify.cjs, +// cli-skew-check, findProjectRoot, etc.) against a fixed 5s wall clock, with +// no retry. Under Docker host contention that wall clock loses +// nondeterministically (ETIMEDOUT) — a test-harness race, not a product +// defect. bin/lib/config.cjs requires none of that dispatcher machinery, so +// calling cmdConfigGet directly removes the subprocess-spawn cost and the +// wall-clock race entirely: no timeout of any size can flake this. +const config = require(path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'config.cjs')); + +/** + * cmdConfigGet's error() path (gsd-core/bin/lib/io.cjs) calls process.exit(1) + * directly (it predates the ExitError/runMain seam used by the CLI + * entrypoint's non-error paths). Intercepting process.exit with a throwable + * sentinel lets the error path be exercised in-process without killing the + * test worker. + */ +class _ExitSignal extends Error { + constructor(code) { + super(`process.exit(${code})`); + this.code = code; + } +} + +/** + * bin/lib/io.cjs's output()/error() write directly to the raw fd (1 or 2) + * via fs.writeSync — they bypass console.log entirely, so + * tests/helpers.cjs's captureConsole() cannot observe them (see + * tests/io.test.cjs: "output() writes directly to fd 1"). Monkeypatch + * fs.writeSync itself — save the original, override, restore in a finally, + * the project's standard IO capture/fault-injection seam — to capture what + * would have hit the fd. + */ +function captureFdWrite(fd, fn) { + const orig = fs.writeSync; + let captured = Buffer.alloc(0); + fs.writeSync = (writeFd, ...rest) => { + if (writeFd !== fd) return orig.call(fs, writeFd, ...rest); + const [data, offset = 0, length] = rest; + const chunk = Buffer.isBuffer(data) + ? data.subarray(offset, offset + (length ?? data.length - offset)) + : Buffer.from(String(data), 'utf8'); + captured = Buffer.concat([captured, chunk]); + return chunk.length; + }; + try { + fn(); + } finally { + fs.writeSync = orig; + } + return captured.toString('utf-8'); +} + +/** + * Parse a CLI-style config-get argv (mirrors gsd-core/bin/gsd-tools.cjs's + * 'config-get' case: key is args[1], optional --default , optional + * --raw) into cmdConfigGet's positional params. Keeps the test bodies below + * expressed in the same CLI-args vocabulary they always were. + */ +function parseConfigGetArgs(args) { + const rest = args.slice(1); // drop the leading 'config-get' + let raw = false; + let defaultValue; + const positional = []; + for (let i = 0; i < rest.length; i++) { + if (rest[i] === '--raw') { raw = true; continue; } + if (rest[i] === '--default') { defaultValue = rest[i + 1] ?? ''; i++; continue; } + positional.push(rest[i]); + } + return { keyPath: positional[0], raw, defaultValue }; +} describe('config-get --default flag (#1893)', () => { let tmpDir; @@ -34,10 +106,11 @@ describe('config-get --default flag (#1893)', () => { }); function run(...args) { - return execFileSync('node', [GSD_TOOLS, ...args, '--cwd', tmpDir], { - encoding: 'utf-8', - timeout: 5000, - }).trim(); + const { keyPath, raw, defaultValue } = parseConfigGetArgs(args); + const out = captureFdWrite(1, () => { + config.cmdConfigGet(tmpDir, keyPath, raw, defaultValue); + }); + return out.trim(); } function runRaw(...args) { @@ -45,17 +118,27 @@ describe('config-get --default flag (#1893)', () => { } function runExpectError(...args) { + const { keyPath, raw, defaultValue } = parseConfigGetArgs(args); + const origExit = process.exit; + let exitCode; + process.exit = (code) => { + exitCode = code; + throw new _ExitSignal(code); + }; + let stderr; try { - execFileSync('node', [GSD_TOOLS, ...args, '--cwd', tmpDir], { - encoding: 'utf-8', - timeout: 5000, - stdio: ['pipe', 'pipe', 'pipe'], + stderr = captureFdWrite(2, () => { + try { + config.cmdConfigGet(tmpDir, keyPath, raw, defaultValue); + } catch (e) { + if (!(e instanceof _ExitSignal)) throw e; + } }); - assert.fail('Expected command to exit non-zero'); - } catch (err) { - assert.ok(err.status !== 0, 'Expected non-zero exit code'); - return err; + } finally { + process.exit = origExit; } + assert.ok(exitCode !== 0 && exitCode !== undefined, 'Expected non-zero exit code'); + return { status: exitCode, stderr }; } test('absent key without --default errors', () => { diff --git a/tests/install-runtime-artifacts.test.cjs b/tests/install-runtime-artifacts.test.cjs index 96a96afc4..755b2a8ee 100644 --- a/tests/install-runtime-artifacts.test.cjs +++ b/tests/install-runtime-artifacts.test.cjs @@ -4002,13 +4002,19 @@ describe('applyRuntimeContentRewritesForCommandsInPlace — error-path tempDir c const stagedDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-test-error-path-')); fs.writeFileSync(path.join(stagedDir, 'x.md'), '# test\n'); - const before = new Set( - fs.readdirSync(os.tmpdir()).filter(n => n.startsWith('gsd-cmd-rewrites-')) - ); - + // Capture the EXACT tempDir THIS invocation creates (via the function's own + // fs.mkdtempSync call) instead of diffing the shared os.tmpdir() listing. + // The old diff-and-sweep approach raced any concurrently-running test file + // that mkdtemps its own gsd-cmd-rewrites-* dir under --test-concurrency: it + // misattributed a sibling's live dir as this test's leak AND force-deleted + // it mid-use (the #1575 ENOENT on graphify.md). Owning a single, + // self-generated fixture makes this Independent + Repeatable under any + // parallelism. + const origMkdtempSync = fs.mkdtempSync; const origReadFileSync = fs.readFileSync; - let leaked = []; + let capturedTempDir = null; try { + fs.mkdtempSync = (...args) => (capturedTempDir = origMkdtempSync.apply(fs, args)); fs.readFileSync = () => { throw new Error('injected read failure'); }; assert.throws( @@ -4016,21 +4022,22 @@ describe('applyRuntimeContentRewritesForCommandsInPlace — error-path tempDir c /injected read failure/, ); - // Restore before any further fs use so the snapshot read is trustworthy. + // Restore before any further fs use so the existsSync check is trustworthy. + fs.mkdtempSync = origMkdtempSync; fs.readFileSync = origReadFileSync; - const after = fs.readdirSync(os.tmpdir()).filter(n => n.startsWith('gsd-cmd-rewrites-')); - leaked = after.filter(n => !before.has(n)); - assert.deepStrictEqual(leaked, [], `tempDir not cleaned up on error: ${leaked.join(',')}`); + assert.ok(capturedTempDir, 'function under test must create a tempDir before failing'); + assert.equal( + fs.existsSync(capturedTempDir), + false, + `tempDir not cleaned up on error: ${capturedTempDir}`, + ); } finally { // Idempotent restore — guard against early-throw paths above. + fs.mkdtempSync = origMkdtempSync; fs.readFileSync = origReadFileSync; - // Clean up the staged dir created for this test. + // Clean up only OUR OWN fixture — never sweep the shared os.tmpdir(). cleanup(stagedDir); - // Clean up any genuinely leaked gsd-cmd-rewrites-* dirs so the runner stays clean. - for (const n of leaked) { - cleanup(path.join(os.tmpdir(), n)); - } } }); }); @@ -7142,421 +7149,6 @@ describe('processAttribution (relocated to runtime-artifact-conversion)', () => // ──────────────────────────────────────────────────────────────────────── -// Folded from tests/enh-1511-rewrite-engine-relocation.test.cjs — consolidation epic #1969 (B1 #1970) -// ──────────────────────────────────────────────────────────────────────── -{ - const { describe: __foldDescribe } = require('node:test'); - __foldDescribe("folded:enh-1511-rewrite-engine-relocation (consolidation epic #1969 B1 #1970)", () => { -'use strict'; -/** - * Tests for ADR-1508 Phase 2: rewrite engine relocation to runtime-artifact-conversion. - * Issue #1511 — verifies the deep public seam signatures and behavior. - * - * Tests are behavioral (no source-grep). All filesystem operations use tmp dirs. - */ - -const { describe, test, before } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('node:fs'); -const os = require('node:os'); -const path = require('node:path'); -const { cleanup } = require('./helpers.cjs'); - -let conversion; -before(() => { - process.env['GSD_TEST_MODE'] = '1'; - conversion = require('../gsd-core/bin/lib/runtime-artifact-conversion.cjs'); -}); - -// --------------------------------------------------------------------------- -// _computePathPrefix unit tests -// --------------------------------------------------------------------------- - -describe('_computePathPrefix', () => { - test('global under home → $HOME/... form', () => { - const prefix = conversion._computePathPrefix({ - isGlobal: true, - isOpencode: false, - isWindowsHost: false, - resolvedTarget: '/home/u/.cursor', - homeDir: '/home/u', - }); - assert.equal(prefix, '$HOME/.cursor/'); - }); - - test('non-global → resolvedTarget/ form', () => { - const prefix = conversion._computePathPrefix({ - isGlobal: false, - isOpencode: false, - isWindowsHost: false, - resolvedTarget: '/project/.cursor', - homeDir: '/home/u', - }); - assert.equal(prefix, '/project/.cursor/'); - }); - - test('global opencode skips $HOME shorthand', () => { - // OpenCode uses ~/.config/opencode which breaks $HOME shorthand in content - const prefix = conversion._computePathPrefix({ - isGlobal: true, - isOpencode: true, - isWindowsHost: false, - resolvedTarget: '/home/u/.config/opencode', - homeDir: '/home/u', - }); - assert.equal(prefix, '/home/u/.config/opencode/'); - }); - - test('global target outside home → resolvedTarget/ form', () => { - const prefix = conversion._computePathPrefix({ - isGlobal: true, - isOpencode: false, - isWindowsHost: false, - resolvedTarget: '/opt/custom-cursor', - homeDir: '/home/u', - }); - assert.equal(prefix, '/opt/custom-cursor/'); - }); - - test('isWindowsHost tripwire — Windows paths collapse to $HOME/ same as POSIX (no-op today)', () => { - // Documents CURRENT behavior: isWindowsHost is accepted but not branched on. - // Both win32=true and win32=false return '$HOME/.cursor/' for a home-relative target. - // If a future Windows-specific branch is added, this tripwire fails and forces - // an explicit decision about what to return on Windows. - const withWindows = conversion._computePathPrefix({ - isGlobal: true, - isOpencode: false, - isWindowsHost: true, - resolvedTarget: 'C:/Users/matte/.cursor', - homeDir: 'C:/Users/matte', - }); - const withoutWindows = conversion._computePathPrefix({ - isGlobal: true, - isOpencode: false, - isWindowsHost: false, - resolvedTarget: 'C:/Users/matte/.cursor', - homeDir: 'C:/Users/matte', - }); - assert.equal(withWindows, '$HOME/.cursor/'); - assert.strictEqual(withWindows, withoutWindows); - }); - - test('backslash-style resolvedTarget is normalized to forward slashes (#1615 regression)', () => { - // path.join on Windows produces backslashes; the returned prefix is - // substituted into markdown @-references which must use POSIX paths. - // Without normalization the backslashes leak into workflow file content - // and break substring checks on Windows CI. - const prefix = conversion._computePathPrefix({ - isGlobal: false, - isOpencode: false, - isWindowsHost: true, - resolvedTarget: 'C:\\Users\\runner\\AppData\\Local\\Temp\\gsd-1615-windsurf', - homeDir: 'C:\\Users\\runner', - }); - assert.strictEqual(prefix, 'C:/Users/runner/AppData/Local/Temp/gsd-1615-windsurf/'); - assert.ok(!prefix.includes('\\'), `prefix must not contain backslashes: ${prefix}`); - }); -}); - -// --------------------------------------------------------------------------- -// _applyRuntimeRewrites with injected attribution -// --------------------------------------------------------------------------- - -describe('_applyRuntimeRewrites — attribution injection', () => { - const PREFIX = '$HOME/.cursor/'; - - test('attribution=null removes Co-Authored-By line', () => { - const content = '# Hello\n\nSome text\n\nCo-Authored-By: Claude\n'; - const result = conversion._applyRuntimeRewrites(content, 'cursor', PREFIX, true, null); - assert.ok(!result.includes('Co-Authored-By:'), 'Co-Authored-By should be removed'); - }); - - test('attribution=undefined leaves Co-Authored-By unchanged', () => { - const content = '# Hello\n\nCo-Authored-By: Claude\n'; - const result = conversion._applyRuntimeRewrites(content, 'cursor', PREFIX, true, undefined); - assert.ok(result.includes('Co-Authored-By: Claude'), 'Co-Authored-By should be preserved when attribution=undefined'); - }); - - test('attribution=string replaces Co-Authored-By value', () => { - const content = '# Hello\n\nCo-Authored-By: OldName\n'; - const result = conversion._applyRuntimeRewrites(content, 'cursor', PREFIX, true, 'NewName '); - assert.ok(result.includes('Co-Authored-By: NewName '), 'Co-Authored-By should be replaced'); - }); - - test('cursor runtime replaces ~/.claude/ paths', () => { - const content = 'See ~/.claude/skills/ for more info\n'; - const result = conversion._applyRuntimeRewrites(content, 'cursor', '/home/u/.cursor/', false, undefined); - assert.ok(result.includes('/home/u/.cursor/skills/'), 'cursor should replace ~/.claude/ with pathPrefix'); - }); -}); - -// --------------------------------------------------------------------------- -// rewriteStagedSkillBodies — behavioral filesystem test -// --------------------------------------------------------------------------- - -describe('rewriteStagedSkillBodies', () => { - test('rewrites .md files in-place for cursor runtime', () => { - const stagedDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-test-staged-')); - const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-test-config-')); - try { - // Create a skill dir with a SKILL.md referencing ~/.claude/skills/foo - // NOTE: the rewrite engine handles path replacement and attribution only. - // Bash→Shell conversion is done by the stage-1 skill converter, not the engine. - const skillDir = path.join(stagedDir, 'gsd-test-skill'); - fs.mkdirSync(skillDir, { recursive: true }); - const content = '# Test\n\nSee ~/.claude/skills/foo\n\nAlso ~/.cursor/skills/bar\n'; - fs.writeFileSync(path.join(skillDir, 'SKILL.md'), content); - - // Call with injected homedir + platform for determinism - conversion.rewriteStagedSkillBodies(stagedDir, { - runtime: 'cursor', - configDir, - scope: 'global', - homedir: () => '/home/u', - platform: 'linux', - }); - - const result = fs.readFileSync(path.join(skillDir, 'SKILL.md'), 'utf8'); - // cursor rewrites ~/.claude/ → pathPrefix - // configDir is a tmpdir, not under /home/u, so prefix = resolvedTarget + '/' - // Mirror the engine's backslash→slash normalization so the assertion holds on Windows. - const resolvedTarget = path.resolve(configDir).replace(/\\/g, '/'); - assert.ok(result.includes(`${resolvedTarget}/skills/foo`), `Should replace ~/.claude/skills/ with ${resolvedTarget}/skills/`); - // cursor also rewrites ~/.cursor/ → pathPrefix - assert.ok(result.includes(`${resolvedTarget}/skills/bar`), `Should replace ~/.cursor/skills/ with ${resolvedTarget}/skills/`); - } finally { - cleanup(stagedDir); - cleanup(configDir); - } - }); - - test('with injected homedir: global under home uses $HOME prefix', () => { - // Real absolute path so Windows path.resolve does not re-root a POSIX literal onto a drive. - // The dir need not exist — the engine only string-processes it. - const HOME = path.resolve(os.tmpdir(), 'gsd-1511-fake-home'); - const configDir = path.join(HOME, '.cursor'); - const stagedDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-test-staged-')); - try { - const skillDir = path.join(stagedDir, 'gsd-help'); - fs.mkdirSync(skillDir, { recursive: true }); - fs.writeFileSync(path.join(skillDir, 'SKILL.md'), 'Use ~/.claude/skills/ here\n'); - - conversion.rewriteStagedSkillBodies(stagedDir, { - runtime: 'cursor', - configDir, - scope: 'global', - homedir: () => HOME, - platform: process.platform, - }); - - const result = fs.readFileSync(path.join(skillDir, 'SKILL.md'), 'utf8'); - assert.ok(result.includes('$HOME/.cursor/skills/'), 'Should use $HOME shorthand when configDir is under homedir'); - } finally { - cleanup(stagedDir); - } - }); - - test('non-existent stagedDir is a no-op', () => { - assert.doesNotThrow(() => { - conversion.rewriteStagedSkillBodies('/nonexistent/dir', { - runtime: 'cursor', - configDir: '/tmp/fake', - scope: 'global', - }); - }); - }); -}); - -// --------------------------------------------------------------------------- -// rewriteStagedCommandBodies — returns temp dir, does not mutate source -// --------------------------------------------------------------------------- - -describe('rewriteStagedCommandBodies', () => { - test('returns a temp dir (not the source dir) with rewritten content', () => { - const stagedDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-test-cmd-')); - const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-test-config-')); - let tempDir; - try { - // NOTE: rewrite engine handles path replacement + attribution, NOT tool renames. - fs.writeFileSync(path.join(stagedDir, 'help.md'), '# Help\n\nSee ~/.claude/skills/\n\nSee ~/.cursor/skills/\n'); - - tempDir = conversion.rewriteStagedCommandBodies(stagedDir, { - runtime: 'cursor', - configDir, - scope: 'global', - homedir: () => '/home/u', - platform: 'linux', - }); - - assert.notEqual(tempDir, stagedDir, 'must return a different dir, never the source'); - assert.ok(fs.existsSync(tempDir), 'returned tempDir should exist'); - - const result = fs.readFileSync(path.join(tempDir, 'help.md'), 'utf8'); - // Source dir should be unchanged - const source = fs.readFileSync(path.join(stagedDir, 'help.md'), 'utf8'); - assert.ok(source.includes('~/.claude/skills/'), 'source file must not be mutated'); - // configDir is /tmp/... (not under /home/u), so prefix = resolvedTarget + '/' - const resolvedTarget = path.resolve(configDir).replace(/\\/g, '/'); - assert.ok(result.includes(`${resolvedTarget}/skills/`), 'output should have cursor path rewrite applied'); - // ~/.cursor/ also rewrites to prefix - assert.ok(!result.includes('~/.cursor/'), 'output should have ~/.cursor/ replaced too'); - } finally { - cleanup(stagedDir); - cleanup(configDir); - if (tempDir && tempDir !== stagedDir) { - cleanup(tempDir); - } - } - }); - - test('non-existent stagedDir returns stagedDir unchanged (safe)', () => { - const result = conversion.rewriteStagedCommandBodies('/nonexistent/dir', { - runtime: 'cursor', - configDir: '/tmp/fake', - scope: 'global', - }); - assert.equal(result, '/nonexistent/dir', 'should return input path unchanged for missing dir'); - }); -}); - -// --------------------------------------------------------------------------- -// Error-path: applyRuntimeContentRewritesForCommandsInPlace must rm the tempDir -// on any exception and NOT leave an orphaned gsd-cmd-rewrites-* directory. -// --------------------------------------------------------------------------- - -describe('applyRuntimeContentRewritesForCommandsInPlace — error-path tempDir cleanup', () => { - test('rmSync is called on the tempDir when readFileSync throws (deterministic monkeypatch)', () => { - // Asserting the injected error propagates proves the throw happens AFTER the tempDir is - // created (the function creates tempDir, then reads .md), so the catch's rmSync cleanup - // is genuinely exercised — deterministic on every platform/uid. - const stagedDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-test-error-path-')); - fs.writeFileSync(path.join(stagedDir, 'x.md'), '# test\n'); - - const before = new Set( - fs.readdirSync(os.tmpdir()).filter(n => n.startsWith('gsd-cmd-rewrites-')) - ); - - const origReadFileSync = fs.readFileSync; - let leaked = []; - try { - fs.readFileSync = () => { throw new Error('injected read failure'); }; - - assert.throws( - () => conversion.applyRuntimeContentRewritesForCommandsInPlace(stagedDir, 'cursor', '/tmp/x/', false), - /injected read failure/, - ); - - // Restore before any further fs use so the snapshot read is trustworthy. - fs.readFileSync = origReadFileSync; - - const after = fs.readdirSync(os.tmpdir()).filter(n => n.startsWith('gsd-cmd-rewrites-')); - leaked = after.filter(n => !before.has(n)); - assert.deepStrictEqual(leaked, [], `tempDir not cleaned up on error: ${leaked.join(',')}`); - } finally { - // Idempotent restore — guard against early-throw paths above. - fs.readFileSync = origReadFileSync; - // Clean up the staged dir created for this test. - cleanup(stagedDir); - // Clean up any genuinely leaked gsd-cmd-rewrites-* dirs so the runner stays clean. - for (const n of leaked) { - cleanup(path.join(os.tmpdir(), n)); - } - } - }); -}); - -// --------------------------------------------------------------------------- -// Guard: runtime-artifact-layout no longer exports getInstallExports -// --------------------------------------------------------------------------- - -describe('layout module no longer exports getInstallExports', () => { - test('getInstallExports is not on the layout module export', () => { - process.env['GSD_TEST_MODE'] = '1'; - const layout = require('../gsd-core/bin/lib/runtime-artifact-layout.cjs'); - assert.equal( - typeof layout.getInstallExports, - 'undefined', - 'getInstallExports should have been removed from runtime-artifact-layout exports (ADR-1508 Phase 2)', - ); - }); -}); - -// --------------------------------------------------------------------------- -// DEFECT.GENERATIVE-FIX: single-owner reference-identity guard (#1511) -// Proves install.js binds to the conversion module's implementation, not a -// duplicate local copy. If these fail, a duplicate body was re-introduced. -// --------------------------------------------------------------------------- - -describe('single-owner reference-identity guard (ADR-1508 / #1511 Phase 2)', () => { - let install; - let conversionCjs; - before(() => { - process.env['GSD_TEST_MODE'] = '1'; - install = require('../bin/install.js'); - conversionCjs = require('../gsd-core/bin/lib/runtime-artifact-conversion.cjs'); - }); - - test('install.computePathPrefix === conversion._computePathPrefix (single implementation)', () => { - assert.strictEqual( - install.computePathPrefix, - conversionCjs._computePathPrefix, - 'install.js must bind computePathPrefix from conversion (not a duplicate body)', - ); - }); - - test('install.applyRuntimeContentRewritesInPlace === conversion.applyRuntimeContentRewritesInPlace (single walk loop)', () => { - assert.strictEqual( - install.applyRuntimeContentRewritesInPlace, - conversionCjs.applyRuntimeContentRewritesInPlace, - 'install.js must bind applyRuntimeContentRewritesInPlace from conversion (not a duplicate walk loop)', - ); - }); - - test('install.applyRuntimeContentRewritesForCommandsInPlace === conversion.applyRuntimeContentRewritesForCommandsInPlace (single copy+rewrite loop)', () => { - assert.strictEqual( - install.applyRuntimeContentRewritesForCommandsInPlace, - conversionCjs.applyRuntimeContentRewritesForCommandsInPlace, - 'install.js must bind applyRuntimeContentRewritesForCommandsInPlace from conversion (not a duplicate copy+rewrite loop)', - ); - }); - - test('install._applyRuntimeRewrites === conversion._applyRuntimeRewrites (single switch engine)', () => { - assert.strictEqual( - install._applyRuntimeRewrites, - conversionCjs._applyRuntimeRewrites, - 'install.js must bind _applyRuntimeRewrites from conversion (not a local shim)', - ); - }); - - // #1675 (ADR-1508): the augment converter family is single-sourced in the - // conversion module. install.js must re-bind (not re-define) these so there - // is exactly one body — the generative-drift hazard the dedup removes. - test('install.convertClaudeToAugmentMarkdown === conversion.convertClaudeToAugmentMarkdown (single converter)', () => { - assert.strictEqual( - install.convertClaudeToAugmentMarkdown, - conversionCjs.convertClaudeToAugmentMarkdown, - 'install.js must bind convertClaudeToAugmentMarkdown from conversion (not a duplicate body)', - ); - }); - - test('install.convertClaudeCommandToAugmentSkill === conversion.convertClaudeCommandToAugmentSkill (single converter)', () => { - assert.strictEqual( - install.convertClaudeCommandToAugmentSkill, - conversionCjs.convertClaudeCommandToAugmentSkill, - 'install.js must bind convertClaudeCommandToAugmentSkill from conversion (not a duplicate body)', - ); - }); - - test('install.convertClaudeAgentToAugmentAgent === conversion.convertClaudeAgentToAugmentAgent (single converter)', () => { - assert.strictEqual( - install.convertClaudeAgentToAugmentAgent, - conversionCjs.convertClaudeAgentToAugmentAgent, - 'install.js must bind convertClaudeAgentToAugmentAgent from conversion (not a duplicate body)', - ); - }); -}); - }); -} // ──────────────────────────────────────────────────────────────────────── @@ -11461,421 +11053,6 @@ describe('processAttribution (relocated to runtime-artifact-conversion)', () => // ──────────────────────────────────────────────────────────────────────── -// Folded from tests/enh-1511-rewrite-engine-relocation.test.cjs — consolidation epic #1969 (B1 #1970) -// ──────────────────────────────────────────────────────────────────────── -{ - const { describe: __foldDescribe } = require('node:test'); - __foldDescribe("folded:enh-1511-rewrite-engine-relocation (consolidation epic #1969 B1 #1970)", () => { -'use strict'; -/** - * Tests for ADR-1508 Phase 2: rewrite engine relocation to runtime-artifact-conversion. - * Issue #1511 — verifies the deep public seam signatures and behavior. - * - * Tests are behavioral (no source-grep). All filesystem operations use tmp dirs. - */ - -const { describe, test, before } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('node:fs'); -const os = require('node:os'); -const path = require('node:path'); -const { cleanup } = require('./helpers.cjs'); - -let conversion; -before(() => { - process.env['GSD_TEST_MODE'] = '1'; - conversion = require('../gsd-core/bin/lib/runtime-artifact-conversion.cjs'); -}); - -// --------------------------------------------------------------------------- -// _computePathPrefix unit tests -// --------------------------------------------------------------------------- - -describe('_computePathPrefix', () => { - test('global under home → $HOME/... form', () => { - const prefix = conversion._computePathPrefix({ - isGlobal: true, - isOpencode: false, - isWindowsHost: false, - resolvedTarget: '/home/u/.cursor', - homeDir: '/home/u', - }); - assert.equal(prefix, '$HOME/.cursor/'); - }); - - test('non-global → resolvedTarget/ form', () => { - const prefix = conversion._computePathPrefix({ - isGlobal: false, - isOpencode: false, - isWindowsHost: false, - resolvedTarget: '/project/.cursor', - homeDir: '/home/u', - }); - assert.equal(prefix, '/project/.cursor/'); - }); - - test('global opencode skips $HOME shorthand', () => { - // OpenCode uses ~/.config/opencode which breaks $HOME shorthand in content - const prefix = conversion._computePathPrefix({ - isGlobal: true, - isOpencode: true, - isWindowsHost: false, - resolvedTarget: '/home/u/.config/opencode', - homeDir: '/home/u', - }); - assert.equal(prefix, '/home/u/.config/opencode/'); - }); - - test('global target outside home → resolvedTarget/ form', () => { - const prefix = conversion._computePathPrefix({ - isGlobal: true, - isOpencode: false, - isWindowsHost: false, - resolvedTarget: '/opt/custom-cursor', - homeDir: '/home/u', - }); - assert.equal(prefix, '/opt/custom-cursor/'); - }); - - test('isWindowsHost tripwire — Windows paths collapse to $HOME/ same as POSIX (no-op today)', () => { - // Documents CURRENT behavior: isWindowsHost is accepted but not branched on. - // Both win32=true and win32=false return '$HOME/.cursor/' for a home-relative target. - // If a future Windows-specific branch is added, this tripwire fails and forces - // an explicit decision about what to return on Windows. - const withWindows = conversion._computePathPrefix({ - isGlobal: true, - isOpencode: false, - isWindowsHost: true, - resolvedTarget: 'C:/Users/matte/.cursor', - homeDir: 'C:/Users/matte', - }); - const withoutWindows = conversion._computePathPrefix({ - isGlobal: true, - isOpencode: false, - isWindowsHost: false, - resolvedTarget: 'C:/Users/matte/.cursor', - homeDir: 'C:/Users/matte', - }); - assert.equal(withWindows, '$HOME/.cursor/'); - assert.strictEqual(withWindows, withoutWindows); - }); - - test('backslash-style resolvedTarget is normalized to forward slashes (#1615 regression)', () => { - // path.join on Windows produces backslashes; the returned prefix is - // substituted into markdown @-references which must use POSIX paths. - // Without normalization the backslashes leak into workflow file content - // and break substring checks on Windows CI. - const prefix = conversion._computePathPrefix({ - isGlobal: false, - isOpencode: false, - isWindowsHost: true, - resolvedTarget: 'C:\\Users\\runner\\AppData\\Local\\Temp\\gsd-1615-windsurf', - homeDir: 'C:\\Users\\runner', - }); - assert.strictEqual(prefix, 'C:/Users/runner/AppData/Local/Temp/gsd-1615-windsurf/'); - assert.ok(!prefix.includes('\\'), `prefix must not contain backslashes: ${prefix}`); - }); -}); - -// --------------------------------------------------------------------------- -// _applyRuntimeRewrites with injected attribution -// --------------------------------------------------------------------------- - -describe('_applyRuntimeRewrites — attribution injection', () => { - const PREFIX = '$HOME/.cursor/'; - - test('attribution=null removes Co-Authored-By line', () => { - const content = '# Hello\n\nSome text\n\nCo-Authored-By: Claude\n'; - const result = conversion._applyRuntimeRewrites(content, 'cursor', PREFIX, true, null); - assert.ok(!result.includes('Co-Authored-By:'), 'Co-Authored-By should be removed'); - }); - - test('attribution=undefined leaves Co-Authored-By unchanged', () => { - const content = '# Hello\n\nCo-Authored-By: Claude\n'; - const result = conversion._applyRuntimeRewrites(content, 'cursor', PREFIX, true, undefined); - assert.ok(result.includes('Co-Authored-By: Claude'), 'Co-Authored-By should be preserved when attribution=undefined'); - }); - - test('attribution=string replaces Co-Authored-By value', () => { - const content = '# Hello\n\nCo-Authored-By: OldName\n'; - const result = conversion._applyRuntimeRewrites(content, 'cursor', PREFIX, true, 'NewName '); - assert.ok(result.includes('Co-Authored-By: NewName '), 'Co-Authored-By should be replaced'); - }); - - test('cursor runtime replaces ~/.claude/ paths', () => { - const content = 'See ~/.claude/skills/ for more info\n'; - const result = conversion._applyRuntimeRewrites(content, 'cursor', '/home/u/.cursor/', false, undefined); - assert.ok(result.includes('/home/u/.cursor/skills/'), 'cursor should replace ~/.claude/ with pathPrefix'); - }); -}); - -// --------------------------------------------------------------------------- -// rewriteStagedSkillBodies — behavioral filesystem test -// --------------------------------------------------------------------------- - -describe('rewriteStagedSkillBodies', () => { - test('rewrites .md files in-place for cursor runtime', () => { - const stagedDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-test-staged-')); - const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-test-config-')); - try { - // Create a skill dir with a SKILL.md referencing ~/.claude/skills/foo - // NOTE: the rewrite engine handles path replacement and attribution only. - // Bash→Shell conversion is done by the stage-1 skill converter, not the engine. - const skillDir = path.join(stagedDir, 'gsd-test-skill'); - fs.mkdirSync(skillDir, { recursive: true }); - const content = '# Test\n\nSee ~/.claude/skills/foo\n\nAlso ~/.cursor/skills/bar\n'; - fs.writeFileSync(path.join(skillDir, 'SKILL.md'), content); - - // Call with injected homedir + platform for determinism - conversion.rewriteStagedSkillBodies(stagedDir, { - runtime: 'cursor', - configDir, - scope: 'global', - homedir: () => '/home/u', - platform: 'linux', - }); - - const result = fs.readFileSync(path.join(skillDir, 'SKILL.md'), 'utf8'); - // cursor rewrites ~/.claude/ → pathPrefix - // configDir is a tmpdir, not under /home/u, so prefix = resolvedTarget + '/' - // Mirror the engine's backslash→slash normalization so the assertion holds on Windows. - const resolvedTarget = path.resolve(configDir).replace(/\\/g, '/'); - assert.ok(result.includes(`${resolvedTarget}/skills/foo`), `Should replace ~/.claude/skills/ with ${resolvedTarget}/skills/`); - // cursor also rewrites ~/.cursor/ → pathPrefix - assert.ok(result.includes(`${resolvedTarget}/skills/bar`), `Should replace ~/.cursor/skills/ with ${resolvedTarget}/skills/`); - } finally { - cleanup(stagedDir); - cleanup(configDir); - } - }); - - test('with injected homedir: global under home uses $HOME prefix', () => { - // Real absolute path so Windows path.resolve does not re-root a POSIX literal onto a drive. - // The dir need not exist — the engine only string-processes it. - const HOME = path.resolve(os.tmpdir(), 'gsd-1511-fake-home'); - const configDir = path.join(HOME, '.cursor'); - const stagedDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-test-staged-')); - try { - const skillDir = path.join(stagedDir, 'gsd-help'); - fs.mkdirSync(skillDir, { recursive: true }); - fs.writeFileSync(path.join(skillDir, 'SKILL.md'), 'Use ~/.claude/skills/ here\n'); - - conversion.rewriteStagedSkillBodies(stagedDir, { - runtime: 'cursor', - configDir, - scope: 'global', - homedir: () => HOME, - platform: process.platform, - }); - - const result = fs.readFileSync(path.join(skillDir, 'SKILL.md'), 'utf8'); - assert.ok(result.includes('$HOME/.cursor/skills/'), 'Should use $HOME shorthand when configDir is under homedir'); - } finally { - cleanup(stagedDir); - } - }); - - test('non-existent stagedDir is a no-op', () => { - assert.doesNotThrow(() => { - conversion.rewriteStagedSkillBodies('/nonexistent/dir', { - runtime: 'cursor', - configDir: '/tmp/fake', - scope: 'global', - }); - }); - }); -}); - -// --------------------------------------------------------------------------- -// rewriteStagedCommandBodies — returns temp dir, does not mutate source -// --------------------------------------------------------------------------- - -describe('rewriteStagedCommandBodies', () => { - test('returns a temp dir (not the source dir) with rewritten content', () => { - const stagedDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-test-cmd-')); - const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-test-config-')); - let tempDir; - try { - // NOTE: rewrite engine handles path replacement + attribution, NOT tool renames. - fs.writeFileSync(path.join(stagedDir, 'help.md'), '# Help\n\nSee ~/.claude/skills/\n\nSee ~/.cursor/skills/\n'); - - tempDir = conversion.rewriteStagedCommandBodies(stagedDir, { - runtime: 'cursor', - configDir, - scope: 'global', - homedir: () => '/home/u', - platform: 'linux', - }); - - assert.notEqual(tempDir, stagedDir, 'must return a different dir, never the source'); - assert.ok(fs.existsSync(tempDir), 'returned tempDir should exist'); - - const result = fs.readFileSync(path.join(tempDir, 'help.md'), 'utf8'); - // Source dir should be unchanged - const source = fs.readFileSync(path.join(stagedDir, 'help.md'), 'utf8'); - assert.ok(source.includes('~/.claude/skills/'), 'source file must not be mutated'); - // configDir is /tmp/... (not under /home/u), so prefix = resolvedTarget + '/' - const resolvedTarget = path.resolve(configDir).replace(/\\/g, '/'); - assert.ok(result.includes(`${resolvedTarget}/skills/`), 'output should have cursor path rewrite applied'); - // ~/.cursor/ also rewrites to prefix - assert.ok(!result.includes('~/.cursor/'), 'output should have ~/.cursor/ replaced too'); - } finally { - cleanup(stagedDir); - cleanup(configDir); - if (tempDir && tempDir !== stagedDir) { - cleanup(tempDir); - } - } - }); - - test('non-existent stagedDir returns stagedDir unchanged (safe)', () => { - const result = conversion.rewriteStagedCommandBodies('/nonexistent/dir', { - runtime: 'cursor', - configDir: '/tmp/fake', - scope: 'global', - }); - assert.equal(result, '/nonexistent/dir', 'should return input path unchanged for missing dir'); - }); -}); - -// --------------------------------------------------------------------------- -// Error-path: applyRuntimeContentRewritesForCommandsInPlace must rm the tempDir -// on any exception and NOT leave an orphaned gsd-cmd-rewrites-* directory. -// --------------------------------------------------------------------------- - -describe('applyRuntimeContentRewritesForCommandsInPlace — error-path tempDir cleanup', () => { - test('rmSync is called on the tempDir when readFileSync throws (deterministic monkeypatch)', () => { - // Asserting the injected error propagates proves the throw happens AFTER the tempDir is - // created (the function creates tempDir, then reads .md), so the catch's rmSync cleanup - // is genuinely exercised — deterministic on every platform/uid. - const stagedDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-test-error-path-')); - fs.writeFileSync(path.join(stagedDir, 'x.md'), '# test\n'); - - const before = new Set( - fs.readdirSync(os.tmpdir()).filter(n => n.startsWith('gsd-cmd-rewrites-')) - ); - - const origReadFileSync = fs.readFileSync; - let leaked = []; - try { - fs.readFileSync = () => { throw new Error('injected read failure'); }; - - assert.throws( - () => conversion.applyRuntimeContentRewritesForCommandsInPlace(stagedDir, 'cursor', '/tmp/x/', false), - /injected read failure/, - ); - - // Restore before any further fs use so the snapshot read is trustworthy. - fs.readFileSync = origReadFileSync; - - const after = fs.readdirSync(os.tmpdir()).filter(n => n.startsWith('gsd-cmd-rewrites-')); - leaked = after.filter(n => !before.has(n)); - assert.deepStrictEqual(leaked, [], `tempDir not cleaned up on error: ${leaked.join(',')}`); - } finally { - // Idempotent restore — guard against early-throw paths above. - fs.readFileSync = origReadFileSync; - // Clean up the staged dir created for this test. - cleanup(stagedDir); - // Clean up any genuinely leaked gsd-cmd-rewrites-* dirs so the runner stays clean. - for (const n of leaked) { - cleanup(path.join(os.tmpdir(), n)); - } - } - }); -}); - -// --------------------------------------------------------------------------- -// Guard: runtime-artifact-layout no longer exports getInstallExports -// --------------------------------------------------------------------------- - -describe('layout module no longer exports getInstallExports', () => { - test('getInstallExports is not on the layout module export', () => { - process.env['GSD_TEST_MODE'] = '1'; - const layout = require('../gsd-core/bin/lib/runtime-artifact-layout.cjs'); - assert.equal( - typeof layout.getInstallExports, - 'undefined', - 'getInstallExports should have been removed from runtime-artifact-layout exports (ADR-1508 Phase 2)', - ); - }); -}); - -// --------------------------------------------------------------------------- -// DEFECT.GENERATIVE-FIX: single-owner reference-identity guard (#1511) -// Proves install.js binds to the conversion module's implementation, not a -// duplicate local copy. If these fail, a duplicate body was re-introduced. -// --------------------------------------------------------------------------- - -describe('single-owner reference-identity guard (ADR-1508 / #1511 Phase 2)', () => { - let install; - let conversionCjs; - before(() => { - process.env['GSD_TEST_MODE'] = '1'; - install = require('../bin/install.js'); - conversionCjs = require('../gsd-core/bin/lib/runtime-artifact-conversion.cjs'); - }); - - test('install.computePathPrefix === conversion._computePathPrefix (single implementation)', () => { - assert.strictEqual( - install.computePathPrefix, - conversionCjs._computePathPrefix, - 'install.js must bind computePathPrefix from conversion (not a duplicate body)', - ); - }); - - test('install.applyRuntimeContentRewritesInPlace === conversion.applyRuntimeContentRewritesInPlace (single walk loop)', () => { - assert.strictEqual( - install.applyRuntimeContentRewritesInPlace, - conversionCjs.applyRuntimeContentRewritesInPlace, - 'install.js must bind applyRuntimeContentRewritesInPlace from conversion (not a duplicate walk loop)', - ); - }); - - test('install.applyRuntimeContentRewritesForCommandsInPlace === conversion.applyRuntimeContentRewritesForCommandsInPlace (single copy+rewrite loop)', () => { - assert.strictEqual( - install.applyRuntimeContentRewritesForCommandsInPlace, - conversionCjs.applyRuntimeContentRewritesForCommandsInPlace, - 'install.js must bind applyRuntimeContentRewritesForCommandsInPlace from conversion (not a duplicate copy+rewrite loop)', - ); - }); - - test('install._applyRuntimeRewrites === conversion._applyRuntimeRewrites (single switch engine)', () => { - assert.strictEqual( - install._applyRuntimeRewrites, - conversionCjs._applyRuntimeRewrites, - 'install.js must bind _applyRuntimeRewrites from conversion (not a local shim)', - ); - }); - - // #1675 (ADR-1508): the augment converter family is single-sourced in the - // conversion module. install.js must re-bind (not re-define) these so there - // is exactly one body — the generative-drift hazard the dedup removes. - test('install.convertClaudeToAugmentMarkdown === conversion.convertClaudeToAugmentMarkdown (single converter)', () => { - assert.strictEqual( - install.convertClaudeToAugmentMarkdown, - conversionCjs.convertClaudeToAugmentMarkdown, - 'install.js must bind convertClaudeToAugmentMarkdown from conversion (not a duplicate body)', - ); - }); - - test('install.convertClaudeCommandToAugmentSkill === conversion.convertClaudeCommandToAugmentSkill (single converter)', () => { - assert.strictEqual( - install.convertClaudeCommandToAugmentSkill, - conversionCjs.convertClaudeCommandToAugmentSkill, - 'install.js must bind convertClaudeCommandToAugmentSkill from conversion (not a duplicate body)', - ); - }); - - test('install.convertClaudeAgentToAugmentAgent === conversion.convertClaudeAgentToAugmentAgent (single converter)', () => { - assert.strictEqual( - install.convertClaudeAgentToAugmentAgent, - conversionCjs.convertClaudeAgentToAugmentAgent, - 'install.js must bind convertClaudeAgentToAugmentAgent from conversion (not a duplicate body)', - ); - }); -}); - }); -} // ──────────────────────────────────────────────────────────────────────── diff --git a/tests/issue-1575-agent-descriptor-parity.test.cjs b/tests/issue-1575-agent-descriptor-parity.test.cjs index 3557391b7..f4c65cca1 100644 --- a/tests/issue-1575-agent-descriptor-parity.test.cjs +++ b/tests/issue-1575-agent-descriptor-parity.test.cjs @@ -98,6 +98,8 @@ describe('#1575 — golden-parity: surface path matches install path for descrip 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) => { @@ -124,8 +126,6 @@ describe('#1575 — golden-parity: surface path matches install path for descrip `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-'));