diff --git a/tests/config-get-default.test.cjs b/tests/config-get-default.test.cjs index 884537efa..119971b43 100644 --- a/tests/config-get-default.test.cjs +++ b/tests/config-get-default.test.cjs @@ -28,6 +28,10 @@ const { cleanup } = require('./helpers.cjs'); // 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')); +// io.cjs owns error()/output() and the JSON-error-mode toggle. cmdConfigGet's `error` +// is bound to io.error at load, so we drive io directly to (a) get structured stderr +// we can assert a typed `reason` on, and (b) restore the mode after each error probe. +const io = require(path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'io.cjs')); /** * cmdConfigGet's error() path (gsd-core/bin/lib/io.cjs) calls process.exit(1) @@ -35,10 +39,18 @@ const config = require(path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'con * 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. + * + * The sentinel carries the ORIGINAL error message (not a generic "process.exit(1)"). + * That matters for cmdConfigGet's "no config.json" branch, whose `error()` sits inside + * a try/catch that reclassifies any throw NOT starting with "No config.json" as a parse + * failure (a guard that is dead in production, where process.exit terminates first, but + * becomes live once process.exit is a throwing seam). Carrying the real message makes + * that guard re-throw — modeling the single, faithful production termination instead of + * a spurious second error() call with the wrong reason. */ class _ExitSignal extends Error { - constructor(code) { - super(`process.exit(${code})`); + constructor(code, message) { + super(message ?? `process.exit(${code})`); this.code = code; } } @@ -120,30 +132,53 @@ describe('config-get --default flag (#1893)', () => { function runExpectError(...args) { const { keyPath, raw, defaultValue } = parseConfigGetArgs(args); const origExit = process.exit; + const origWriteSync = fs.writeSync; + io.setJsonErrorMode(true); // structured stderr line lets the sentinel carry the message + assert reason + let exitCount = 0; let exitCode; - process.exit = (code) => { - exitCode = code; - throw new _ExitSignal(code); + let stderr = ''; + fs.writeSync = (fd, ...rest) => { + if (fd !== 2) return origWriteSync.call(fs, fd, ...rest); + const [data, offset = 0, length] = rest; + const chunk = Buffer.isBuffer(data) + ? data.subarray(offset, offset + (length ?? data.length - offset)).toString('utf8') + : String(data); + stderr += chunk; + return Buffer.byteLength(chunk); + }; + const lastError = () => { + const parts = stderr.split('\n').filter(Boolean); + try { return JSON.parse(parts[parts.length - 1]); } catch { return {}; } + }; + process.exit = (code) => { + exitCount++; + exitCode = code; + // Carry the just-emitted error message so cmdConfigGet's seam guard re-throws + // (single, faithful fire) instead of catching + reclassifying into a 2nd error(). + throw new _ExitSignal(code, lastError().message); }; - let stderr; try { - stderr = captureFdWrite(2, () => { - try { - config.cmdConfigGet(tmpDir, keyPath, raw, defaultValue); - } catch (e) { - if (!(e instanceof _ExitSignal)) throw e; - } - }); + config.cmdConfigGet(tmpDir, keyPath, raw, defaultValue); + } catch (e) { + if (!(e instanceof _ExitSignal)) throw e; } finally { process.exit = origExit; + fs.writeSync = origWriteSync; + io.setJsonErrorMode(false); } assert.ok(exitCode !== 0 && exitCode !== undefined, 'Expected non-zero exit code'); - return { status: exitCode, stderr }; + // Faithfulness guard: production process.exit terminates, so error() fires exactly + // once. A count of 2 means the throwing-exit seam was caught + reclassified (the bug + // this harness redesign fixes) — fail loudly rather than report a wrong reason. + assert.equal(exitCount, 1, 'error() must fire exactly once (production process.exit terminates)'); + const payload = lastError(); + return { status: exitCode, reason: payload.reason, message: payload.message, stderr }; } test('absent key without --default errors', () => { fs.writeFileSync(path.join(planningDir, 'config.json'), '{}'); - runExpectError('config-get', 'nonexistent.key', '--raw'); + const { reason } = runExpectError('config-get', 'nonexistent.key', '--raw'); + assert.equal(reason, io.ERROR_REASON.CONFIG_KEY_NOT_FOUND, 'absent key must report CONFIG_KEY_NOT_FOUND'); }); test('absent key with --default returns default value', () => { @@ -182,7 +217,8 @@ describe('config-get --default flag (#1893)', () => { test('missing config.json without --default errors', () => { // No config.json written - runExpectError('config-get', 'any.key', '--raw'); + const { reason } = runExpectError('config-get', 'any.key', '--raw'); + assert.equal(reason, io.ERROR_REASON.CONFIG_NO_FILE, 'missing config.json must report CONFIG_NO_FILE'); }); test('--default works with JSON output (no --raw)', () => { diff --git a/tests/install-runtime-artifacts.test.cjs b/tests/install-runtime-artifacts.test.cjs index 755b2a8ee..32721fe13 100644 --- a/tests/install-runtime-artifacts.test.cjs +++ b/tests/install-runtime-artifacts.test.cjs @@ -1993,7 +1993,6 @@ describe('resolveRuntimeArtifactLayout — cline scope-aware (Fix 2)', () => { } - // ──────────────────────────────────────────────────────────────────────── // Folded from tests/enh-789-codebuddy-commands.test.cjs — consolidation epic #1969 (B1 #1970) // ──────────────────────────────────────────────────────────────────────── @@ -4190,2963 +4189,6 @@ test('bridge collapse removes cjs-sdk-bridge and runtime-bridge-sync seam', () = }); } -// ──────────────────────────────────────────────────────────────────────── -// Folded from tests/bug-2418-antigravity-bare-path.test.cjs — consolidation epic #1969 (B1 #1970) -// ──────────────────────────────────────────────────────────────────────── -{ - const { describe: __foldDescribe } = require('node:test'); - __foldDescribe("folded:bug-2418-antigravity-bare-path (consolidation epic #1969 B1 #1970)", () => { -/** - * Bug #2418: Found unreplaced .claude path reference(s) in Antigravity install - * - * The Antigravity path converter handles ~/.claude/ (with trailing slash) but - * misses bare ~/.claude (without trailing slash), leaving unreplaced references - * that cause the installer to warn about leaked paths. - * - * Files affected: agents/gsd-debugger.md (configDir = ~/.claude) and - * gsd-core/workflows/update.md (comment with e.g. ~/.claude). - */ - -process.env.GSD_TEST_MODE = '1'; - -const { describe, test } = require('node:test'); -const assert = require('node:assert/strict'); - -const { convertClaudeToAntigravityContent } = require('../bin/install.js'); - -describe('convertClaudeToAntigravityContent bare path replacement (#2418)', () => { - describe('global install', () => { - test('replaces ~/.claude (bare, no trailing slash) with ~/.gemini/antigravity', () => { - const input = 'configDir = ~/.claude'; - const result = convertClaudeToAntigravityContent(input, true); - assert.ok( - result.includes('~/.gemini/antigravity'), - `Expected ~/.gemini/antigravity in output, got: ${result}` - ); - assert.ok( - !result.includes('~/.claude'), - `Expected ~/ .claude to be replaced, got: ${result}` - ); - }); - - test('replaces $HOME/.claude (bare, no trailing slash) with $HOME/.gemini/antigravity', () => { - const input = 'export DIR=$HOME/.claude'; - const result = convertClaudeToAntigravityContent(input, true); - assert.ok( - result.includes('$HOME/.gemini/antigravity'), - `Expected $HOME/.gemini/antigravity in output, got: ${result}` - ); - assert.ok( - !result.includes('$HOME/.claude'), - `Expected $HOME/.claude to be replaced, got: ${result}` - ); - }); - - test('handles bare ~/.claude followed by comma (comment context)', () => { - const input = '# e.g. ~/.claude, ~/.config/opencode'; - const result = convertClaudeToAntigravityContent(input, true); - assert.ok( - !result.includes('~/.claude'), - `Expected ~/ .claude to be replaced in comment context, got: ${result}` - ); - }); - - test('still replaces ~/.claude/ (with trailing slash) correctly', () => { - const input = 'See ~/.claude/gsd-core/workflows/'; - const result = convertClaudeToAntigravityContent(input, true); - assert.ok( - result.includes('~/.gemini/antigravity/gsd-core/workflows/'), - `Expected path with trailing slash to be replaced, got: ${result}` - ); - assert.ok(!result.includes('~/.claude/'), `Expected ~/ .claude/ to be fully replaced, got: ${result}`); - }); - - test('does not double-replace ~/.claude/ paths', () => { - const input = 'See ~/.claude/gsd-core/'; - const result = convertClaudeToAntigravityContent(input, true); - // Result should contain exactly one occurrence of the replacement path - const count = (result.match(/~\/.gemini\/antigravity\//g) || []).length; - assert.strictEqual(count, 1, `Expected exactly 1 replacement, got ${count} in: ${result}`); - }); - }); - - describe('local install', () => { - test('replaces ~/.claude (bare, no trailing slash) with .agents', () => { - const input = 'configDir = ~/.claude'; - const result = convertClaudeToAntigravityContent(input, false); - assert.ok( - result.includes('.agents'), - `Expected .agents in output, got: ${result}` - ); - assert.ok( - !result.includes('~/.claude'), - `Expected ~/ .claude to be replaced, got: ${result}` - ); - }); - - test('replaces $HOME/.claude (bare, no trailing slash) with .agents', () => { - const input = 'export DIR=$HOME/.claude'; - const result = convertClaudeToAntigravityContent(input, false); - assert.ok( - result.includes('.agents'), - `Expected .agents in output, got: ${result}` - ); - assert.ok( - !result.includes('$HOME/.claude'), - `Expected $HOME/.claude to be replaced, got: ${result}` - ); - }); - - test('does not double-replace ~/.claude/ paths', () => { - const input = 'See ~/.claude/gsd-core/'; - const result = convertClaudeToAntigravityContent(input, false); - // .agents/ should appear exactly once - const count = (result.match(/\.agents\//g) || []).length; - assert.strictEqual(count, 1, `Expected exactly 1 replacement, got ${count} in: ${result}`); - }); - }); - - describe('installed files contain no bare ~/.claude references after conversion', () => { - const fs = require('fs'); - const path = require('path'); - const repoRoot = path.join(__dirname, '..'); - - // The scanner regex used by the installer to detect leaked paths - const leakedPathRegex = /(?:~|\$HOME)\/\.claude\b/g; - - function convertFile(filePath, isGlobal) { - const content = fs.readFileSync(filePath, 'utf8'); - return convertClaudeToAntigravityContent(content, isGlobal); - } - - test('gsd-debugger.md has no leaked ~/.claude after global Antigravity conversion', () => { - const debuggerPath = path.join(repoRoot, 'agents', 'gsd-debugger.md'); - if (!fs.existsSync(debuggerPath)) return; // skip if file doesn't exist - const converted = convertFile(debuggerPath, true); - const matches = converted.match(leakedPathRegex); - assert.strictEqual( - matches, null, - `gsd-debugger.md still contains leaked .claude paths after Antigravity conversion: ${matches}` - ); - }); - - test('update.md has no leaked ~/.claude after global Antigravity conversion', () => { - const updatePath = path.join(repoRoot, 'gsd-core', 'workflows', 'update.md'); - if (!fs.existsSync(updatePath)) return; // skip if file doesn't exist - const converted = convertFile(updatePath, true); - const matches = converted.match(leakedPathRegex); - assert.strictEqual( - matches, null, - `update.md still contains leaked .claude paths after Antigravity conversion: ${matches}` - ); - }); - }); -}); - }); -} - - -// ──────────────────────────────────────────────────────────────────────── -// Folded from tests/bug-2545-copilot-unreplaced-paths.test.cjs — consolidation epic #1969 (B1 #1970) -// ──────────────────────────────────────────────────────────────────────── -{ - const { describe: __foldDescribe } = require('node:test'); - __foldDescribe("folded:bug-2545-copilot-unreplaced-paths (consolidation epic #1969 B1 #1970)", () => { -/** - * Regression test for issue #2545. - * - * The Copilot content converter's `~/.claude/` and `$HOME/.claude/` replacements - * only matched when a literal slash followed, so bare `~/.claude` references - * (end of line, quotes, punctuation) were left unreplaced. Those leaks then - * triggered the installer's "Found N unreplaced .claude path reference(s)" - * warning, which scans for `(?:~|$HOME)/\.claude\b`. - * - * Fix: replace with a word-boundary pattern so both forms are caught in a - * single pass, matching the approach already used by the Antigravity, OpenCode, - * Kilo, and Codex converters. - */ - -process.env.GSD_TEST_MODE = '1'; - -const { test, describe } = require('node:test'); -const assert = require('node:assert/strict'); - -const { convertClaudeToCopilotContent } = require('../bin/install.js'); - -describe('convertClaudeToCopilotContent — bare ~/.claude (issue #2545)', () => { - test('global install replaces bare ~/.claude at end of line', () => { - const input = 'configDir = ~/.claude\n'; - const out = convertClaudeToCopilotContent(input, /* isGlobal */ true); - assert.ok( - !/(?:~|\$HOME)\/\.claude\b/.test(out), - `expected no leaked ~/.claude reference, got: ${JSON.stringify(out)}`, - ); - assert.match(out, /~\/\.copilot\b/); - }); - - test('global install replaces bare $HOME/.claude at end of line', () => { - const input = 'configDir = $HOME/.claude\n'; - const out = convertClaudeToCopilotContent(input, /* isGlobal */ true); - assert.ok( - !/(?:~|\$HOME)\/\.claude\b/.test(out), - `expected no leaked $HOME/.claude reference, got: ${JSON.stringify(out)}`, - ); - assert.match(out, /\$HOME\/\.copilot\b/); - }); - - test('global install replaces bare ~/.claude before punctuation', () => { - const input = 'paths include `~/.claude`, `~/.copilot`'; - const out = convertClaudeToCopilotContent(input, true); - assert.ok(!/(?:~|\$HOME)\/\.claude\b/.test(out)); - }); - - test('local install replaces bare ~/.claude', () => { - const input = 'configDir = ~/.claude\n'; - const out = convertClaudeToCopilotContent(input, /* isGlobal */ false); - assert.ok( - !/(?:~|\$HOME)\/\.claude\b/.test(out), - `expected no leaked ~/.claude reference, got: ${JSON.stringify(out)}`, - ); - }); - - test('does not double-replace trailing-slash form', () => { - const input = '@~/.claude/gsd-core/foo.md\n'; - const out = convertClaudeToCopilotContent(input, true); - assert.match(out, /~\/\.copilot\/gsd-core\/foo\.md/); - assert.ok(!/\.copilot\/\.copilot/.test(out)); - }); -}); - }); -} - - -// ──────────────────────────────────────────────────────────────────────── -// Folded from tests/bug-983-trae-windsurf-claude-path-leak.test.cjs — consolidation epic #1969 (B1 #1970) -// ──────────────────────────────────────────────────────────────────────── -{ - const { describe: __foldDescribe } = require('node:test'); - __foldDescribe("folded:bug-983-trae-windsurf-claude-path-leak (consolidation epic #1969 B1 #1970)", () => { -// allow-test-rule: source-text-is-the-product (see #983) -'use strict'; - -process.env.GSD_TEST_MODE = '1'; - -/** - * Regression tests for issue #983 — Trae and Windsurf converters leak - * unreplaced bare `~/.claude` / `$HOME/.claude` references. - * - * Both converters rewrote only trailing-slash `.claude/` forms, so bare - * home-path references (configDir = ~/.claude, $HOME/.claude) survived - * conversion and pointed users at the wrong config dir. - * - * Fix: add bare word-boundary replacements mirroring Cline (#782) and - * Codex (#570) precedent, with a negative lookahead to preserve `.claude-plugin`. - */ - -const { describe, test } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('node:fs'); -const path = require('node:path'); - -const { - convertClaudeToWindsurfMarkdown, - convertClaudeToTraeMarkdown, - _applyRuntimeRewrites, -} = require('../bin/install.js'); - -// ─── Windsurf converter bare-form tests ───────────────────────────────────── - -describe('convertClaudeToWindsurfMarkdown — bare ~/.claude and CLAUDE_CONFIG_DIR (#983)', () => { - test('bare ~/.claude rewritten to ~/.windsurf (#1615: workspace dir is now .windsurf)', () => { - const input = 'Config dir: (~/.claude), skills at ~/.claude/skills'; - const result = convertClaudeToWindsurfMarkdown(input); - assert.ok( - !/~\/\.claude(?![\w-])/.test(result), - `bare ~/.claude must be rewritten; got: ${result}`, - ); - assert.ok(result.includes('~/.windsurf'), 'must rewrite to ~/.windsurf'); - }); - - test('$HOME/.claude rewritten to $HOME/.windsurf (#1615: workspace dir is now .windsurf)', () => { - const input = 'RUNTIME_CONFIG_DIR="${CLAUDE_CONFIG_DIR:-$HOME/.claude}"'; - const result = convertClaudeToWindsurfMarkdown(input); - assert.ok( - !/\$HOME\/\.claude(?![\w-])/.test(result), - `bare $HOME/.claude must be rewritten; got: ${result}`, - ); - assert.ok(result.includes('$HOME/.windsurf'), 'must rewrite to $HOME/.windsurf'); - }); - - test('CLAUDE_CONFIG_DIR rewritten to WINDSURF_CONFIG_DIR', () => { - const input = 'Use CLAUDE_CONFIG_DIR or $HOME/.claude to configure'; - const result = convertClaudeToWindsurfMarkdown(input); - assert.ok( - result.includes('WINDSURF_CONFIG_DIR'), - 'CLAUDE_CONFIG_DIR must become WINDSURF_CONFIG_DIR', - ); - assert.ok( - !result.includes('CLAUDE_CONFIG_DIR'), - 'CLAUDE_CONFIG_DIR must be gone', - ); - }); - - test('.claude-plugin is NOT corrupted (preserved as-is)', () => { - const input = 'The .claude-plugin/plugin.json manifest enables plugin install.'; - const result = convertClaudeToWindsurfMarkdown(input); - assert.ok( - result.includes('.claude-plugin'), - `.claude-plugin must be preserved; got: ${result}`, - ); - assert.ok( - !result.includes('.windsurf-plugin'), - `.windsurf-plugin must not appear; got: ${result}`, - ); - }); - - test('no bare ~/.claude in converted surface.md', () => { - const surfacePath = path.join(__dirname, '..', 'commands', 'gsd', 'surface.md'); - const raw = fs.readFileSync(surfacePath, 'utf8'); - const result = convertClaudeToWindsurfMarkdown(raw); - assert.ok( - !/~\/\.claude(?![\w-])/.test(result), - 'converted surface.md must not contain bare ~/.claude', - ); - }); - - test('no $HOME/.claude in converted surface.md', () => { - const surfacePath = path.join(__dirname, '..', 'commands', 'gsd', 'surface.md'); - const raw = fs.readFileSync(surfacePath, 'utf8'); - const result = convertClaudeToWindsurfMarkdown(raw); - assert.ok( - !/\$HOME\/\.claude(?![\w-])/.test(result), - 'converted surface.md must not contain bare $HOME/.claude', - ); - }); - - test('no CLAUDE_CONFIG_DIR in converted surface.md', () => { - const surfacePath = path.join(__dirname, '..', 'commands', 'gsd', 'surface.md'); - const raw = fs.readFileSync(surfacePath, 'utf8'); - const result = convertClaudeToWindsurfMarkdown(raw); - assert.ok( - !result.includes('CLAUDE_CONFIG_DIR'), - 'converted surface.md must not contain CLAUDE_CONFIG_DIR', - ); - }); -}); - -// ─── Trae converter bare-form tests ───────────────────────────────────────── - -describe('convertClaudeToTraeMarkdown — bare ~/.claude and CLAUDE_CONFIG_DIR (#983)', () => { - test('bare ~/.claude rewritten to ~/.trae', () => { - const input = 'Config dir: (~/.claude), skills at ~/.claude/skills'; - const result = convertClaudeToTraeMarkdown(input); - assert.ok( - !/~\/\.claude(?![\w-])/.test(result), - `bare ~/.claude must be rewritten; got: ${result}`, - ); - assert.ok(result.includes('~/.trae'), 'must rewrite to ~/.trae'); - }); - - test('$HOME/.claude rewritten to $HOME/.trae', () => { - const input = 'RUNTIME_CONFIG_DIR="${CLAUDE_CONFIG_DIR:-$HOME/.claude}"'; - const result = convertClaudeToTraeMarkdown(input); - assert.ok( - !/\$HOME\/\.claude(?![\w-])/.test(result), - `bare $HOME/.claude must be rewritten; got: ${result}`, - ); - assert.ok(result.includes('$HOME/.trae'), 'must rewrite to $HOME/.trae'); - }); - - test('CLAUDE_CONFIG_DIR rewritten to TRAE_CONFIG_DIR', () => { - const input = 'Use CLAUDE_CONFIG_DIR or $HOME/.claude to configure'; - const result = convertClaudeToTraeMarkdown(input); - assert.ok( - result.includes('TRAE_CONFIG_DIR'), - 'CLAUDE_CONFIG_DIR must become TRAE_CONFIG_DIR', - ); - assert.ok( - !result.includes('CLAUDE_CONFIG_DIR'), - 'CLAUDE_CONFIG_DIR must be gone', - ); - }); - - test('.claude-plugin is NOT corrupted (preserved as-is)', () => { - const input = 'The .claude-plugin/plugin.json manifest enables plugin install.'; - const result = convertClaudeToTraeMarkdown(input); - assert.ok( - result.includes('.claude-plugin'), - `.claude-plugin must be preserved; got: ${result}`, - ); - assert.ok( - !result.includes('.trae-plugin'), - `.trae-plugin must not appear; got: ${result}`, - ); - }); - - test('no bare ~/.claude in converted surface.md', () => { - const surfacePath = path.join(__dirname, '..', 'commands', 'gsd', 'surface.md'); - const raw = fs.readFileSync(surfacePath, 'utf8'); - const result = convertClaudeToTraeMarkdown(raw); - assert.ok( - !/~\/\.claude(?![\w-])/.test(result), - 'converted surface.md must not contain bare ~/.claude', - ); - }); - - test('no $HOME/.claude in converted surface.md', () => { - const surfacePath = path.join(__dirname, '..', 'commands', 'gsd', 'surface.md'); - const raw = fs.readFileSync(surfacePath, 'utf8'); - const result = convertClaudeToTraeMarkdown(raw); - assert.ok( - !/\$HOME\/\.claude(?![\w-])/.test(result), - 'converted surface.md must not contain bare $HOME/.claude', - ); - }); - - test('no CLAUDE_CONFIG_DIR in converted surface.md', () => { - const surfacePath = path.join(__dirname, '..', 'commands', 'gsd', 'surface.md'); - const raw = fs.readFileSync(surfacePath, 'utf8'); - const result = convertClaudeToTraeMarkdown(raw); - assert.ok( - !result.includes('CLAUDE_CONFIG_DIR'), - 'converted surface.md must not contain CLAUDE_CONFIG_DIR', - ); - }); -}); - -// ─── _applyRuntimeRewrites install-path tests (windsurf) ──────────────────── -// -// These tests exercise the ACTUAL install path that causes the user-facing leak. -// The converter functions are called at stage time to produce a Windsurf-branded -// copy, but _applyRuntimeRewrites is the path that runs at INSTALL time and -// rewrites any surviving ~/.claude / $HOME/.claude refs in the staged files. -// -// FAIL-BEFORE proof: prior to this PR, windsurf used /~\/\.claude\b/ which -// fires on "~/.claude-plugin" because \b matches between 'e' and '-'. Running -// the test below against the old regex (`\b`) would: -// - let bare $HOME/.claude survive (it used only /~\/\.claude\b/, missing $HOME form), AND -// - corrupt "~/.claude-plugin" → "~/.windsurf-plugin". -// Both assertions in the test below would fail on the old code. -// -// PASS-AFTER: the fix changes to (?![\w-]) so: -// - bare ~/.claude / $HOME/.claude (not followed by word-char or hyphen) → rewritten -// - ~/.claude-plugin preserved (the '-' after 'e' is in [\w-]) -// -// NOTE on pathPrefix choice: we use '~/.windsurf/' (a simple home-relative -// prefix) rather than '$HOME/.codeium/windsurf/' so that the corruption of -// '~/.claude-plugin' → '~/.windsurf-plugin' is directly detectable via -// result.includes('.windsurf-plugin'). -describe('_applyRuntimeRewrites(windsurf) — install-path bare-form + .claude-plugin (#983)', () => { - // Use ~/ prefix (local-style) so that the .windsurf-plugin corruption is - // directly detectable as a substring of the result. - const WINDSURF_PATH_PREFIX = '~/.windsurf/'; - - // Compound content: covers every form the fix must handle. - // IMPORTANT: we use ~/.claude-plugin (home-relative form) to exercise the - // corruption that the old \b regex caused. The \b fires between 'e' and '-', - // so ~/.claude-plugin → ~/.windsurf-plugin under the old code. That would - // break the preservation assertion below. The (?![\w-]) fix prevents this. - const COMPOUND_INPUT = [ - 'Config dir: ~/.claude', - 'Also: $HOME/.claude', - 'Slash form: ~/.claude/skills/foo.md', - 'Plugin installed at: ~/.claude-plugin/plugin.json', - 'Env var: CLAUDE_CONFIG_DIR', - ].join('\n'); - - test('bare ~/.claude rewritten to ~/.windsurf (no trailing slash)', () => { - const result = _applyRuntimeRewrites(COMPOUND_INPUT, 'windsurf', WINDSURF_PATH_PREFIX); - assert.ok( - !/~\/\.claude(?![\w-])/.test(result), - `bare ~/.claude must be gone; got:\n${result}`, - ); - assert.ok( - result.includes('~/.windsurf'), - `must contain normalized pathPrefix; got:\n${result}`, - ); - }); - - test('bare $HOME/.claude rewritten to ~/.windsurf (install-path normalizes both home forms)', () => { - const result = _applyRuntimeRewrites(COMPOUND_INPUT, 'windsurf', WINDSURF_PATH_PREFIX); - assert.ok( - !/\$HOME\/\.claude(?![\w-])/.test(result), - `bare $HOME/.claude must be gone; got:\n${result}`, - ); - }); - - test('zero surviving bare ~/.claude or $HOME/.claude refs in compound input', () => { - const result = _applyRuntimeRewrites(COMPOUND_INPUT, 'windsurf', WINDSURF_PATH_PREFIX); - const bareClaudePattern = /(?:~|\$HOME)\/\.claude(?![\w-])/; - assert.ok( - !bareClaudePattern.test(result), - `no bare ~/.claude / $HOME/.claude must survive; got:\n${result}`, - ); - }); - - test('~/.claude-plugin is NOT corrupted to ~/.windsurf-plugin — was the \\b corruption', () => { - // FAIL-BEFORE: old /~\/\.claude\b/ rewrote ~/.claude-plugin → ~/.windsurf-plugin - // because \b fires between 'e' and '-'. - // PASS-AFTER: (?![\w-]) sees '-' and skips the match, preserving ~/.claude-plugin. - const result = _applyRuntimeRewrites(COMPOUND_INPUT, 'windsurf', WINDSURF_PATH_PREFIX); - assert.ok( - result.includes('~/.claude-plugin'), - `~/.claude-plugin must be preserved; got:\n${result}`, - ); - assert.ok( - !result.includes('~/.windsurf-plugin'), - `~/.windsurf-plugin must NOT appear (was the \\b corruption); got:\n${result}`, - ); - }); - - test('slash form ~/.claude/ is also rewritten (pre-existing coverage)', () => { - const result = _applyRuntimeRewrites(COMPOUND_INPUT, 'windsurf', WINDSURF_PATH_PREFIX); - assert.ok( - !result.includes('~/.claude/'), - `slash form ~/.claude/ must be gone; got:\n${result}`, - ); - }); - - test('CLAUDE_CONFIG_DIR is NOT rewritten by _applyRuntimeRewrites (converter responsibility)', () => { - // _applyRuntimeRewrites does NOT handle CLAUDE_CONFIG_DIR for windsurf; - // that rewrite is done by convertClaudeToWindsurfMarkdown at stage time. - // This test documents the boundary and guards against scope creep. - const result = _applyRuntimeRewrites(COMPOUND_INPUT, 'windsurf', WINDSURF_PATH_PREFIX); - assert.ok( - result.includes('CLAUDE_CONFIG_DIR'), - 'CLAUDE_CONFIG_DIR is not rewritten by _applyRuntimeRewrites — that is converter scope', - ); - }); -}); - -// ─── _applyRuntimeRewrites install-path tests (trae) ──────────────────────── -// -// Trae had bare-form handling before this PR (via \b) and the converter uses -// (?![\w-]). The pre-existing \b in _applyRuntimeRewrites DOES corrupt -// .claude-plugin → .trae-plugin (known limitation, out of scope for #983). -// We document this here but do NOT assert preservation for trae, and we do NOT -// fix the pre-existing trae \b lines (that would be a separate concern). -// -// What we DO assert: trae bare ~/.claude / $HOME/.claude refs are rewritten -// (the install path cleans them), which is the core #983 fix for trae. -describe('_applyRuntimeRewrites(trae) — install-path bare-form (#983)', () => { - const TRAE_PATH_PREFIX = '$HOME/.trae/'; - - const TRAE_INPUT = [ - 'Config dir: ~/.claude', - 'Also: $HOME/.claude', - 'Slash form: ~/.claude/skills/foo.md', - // Note: .claude-plugin is intentionally omitted from assertions here because - // the pre-existing trae case uses \b which corrupts it (known limitation, - // out of scope for #983 — do not fix here). - ].join('\n'); - - test('bare ~/.claude rewritten to $HOME/.trae (trae install path)', () => { - const result = _applyRuntimeRewrites(TRAE_INPUT, 'trae', TRAE_PATH_PREFIX); - assert.ok( - !/~\/\.claude(?![\w-])/.test(result), - `bare ~/.claude must be gone; got:\n${result}`, - ); - }); - - test('bare $HOME/.claude rewritten to $HOME/.trae (trae install path)', () => { - const result = _applyRuntimeRewrites(TRAE_INPUT, 'trae', TRAE_PATH_PREFIX); - assert.ok( - !/\$HOME\/\.claude(?![\w-])/.test(result), - `bare $HOME/.claude must be gone; got:\n${result}`, - ); - }); - - test('slash form ~/.claude/ also rewritten (trae install path)', () => { - const result = _applyRuntimeRewrites(TRAE_INPUT, 'trae', TRAE_PATH_PREFIX); - assert.ok( - !result.includes('~/.claude/'), - `slash form ~/.claude/ must be gone; got:\n${result}`, - ); - }); -}); - }); -} - - -// ──────────────────────────────────────────────────────────────────────── -// Folded from tests/bug-782-cline-skills-emission.test.cjs — consolidation epic #1969 (B1 #1970) -// ──────────────────────────────────────────────────────────────────────── -{ - const { describe: __foldDescribe } = require('node:test'); - __foldDescribe("folded:bug-782-cline-skills-emission (consolidation epic #1969 B1 #1970)", () => { -'use strict'; -/** - * Regression tests for bug #782 — Cline skills emission. - * - * gsd now emits skills to ~/.cline/skills//SKILL.md for Cline >= v3.48. - * Skills discovery: https://docs.cline.bot/customization/skills - * - * (a) Converter unit test: convertClaudeCommandToClineSkill - * (b) Integration test: installRuntimeArtifacts for cline writes SKILL.md files - * (c) .clinerules/gsd.md still written by the install path (#787 dir form) - * (d) Idempotency: running install twice leaves skills + .clinerules/ intact - * (e) Full install() global: both skills AND .clinerules/gsd.md are written - */ - -process.env.GSD_TEST_MODE = '1'; - -const { test, describe, beforeEach, afterEach } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('node:fs'); -const path = require('node:path'); -const { createTempDir, cleanup, captureConsole } = require('./helpers.cjs'); - -const { - convertClaudeCommandToClineSkill, - convertClaudeToCliineMarkdown, - install, - _applyRuntimeRewrites, -} = require('../bin/install.js'); - -const { installRuntimeArtifacts } = require('../gsd-core/bin/lib/install-engine.cjs'); - -const { - resolveRuntimeArtifactLayout, -} = require('../gsd-core/bin/lib/runtime-artifact-layout.cjs'); - -const { - loadSkillsManifest, - resolveProfile, -} = require('../gsd-core/bin/lib/install-profiles.cjs'); - -const { nestedSkillPath } = require('./helpers/nested-layout.cjs'); - -const REAL_COMMANDS_DIR = path.join(__dirname, '..', 'commands', 'gsd'); -const MANIFEST = loadSkillsManifest(REAL_COMMANDS_DIR); -const RESOLVED_CORE = resolveProfile({ modes: ['core'], manifest: MANIFEST }); - -// ─── (a) Converter unit test ───────────────────────────────────────────────── - -const SAMPLE_COMMAND = `--- -name: gsd:execute-phase -description: Execute all tasks in the current phase using Cline tools. -allowed-tools: - - Read - - Write - - Bash ---- - -## Objective - -Run all tasks in the current phase. - -See ~/.claude/skills/gsd-help/SKILL.md for reference. -Use \`/gsd-help\` or Claude Code for details. -`; - -// A command that exercises all three Claude-specific frontmatter fields that -// must NOT leak into the emitted Cline SKILL.md. -const RICH_COMMAND = `--- -name: gsd:validate-phase -description: Retroactively audit and fill Nyquist validation gaps for a completed phase -argument-hint: "[phase number]" -agent: researcher -allowed-tools: - - Read - - Write - - Edit - - Bash - - Glob - - Grep - - Agent - - AskUserQuestion ---- - -## Objective - -Audit Nyquist validation coverage. See ~/.claude/skills/gsd-help/SKILL.md for reference. -Use Claude Code for details. -`; - -/** - * Extract frontmatter block (between --- delimiters) from output. - * Returns the raw text between the first --- and the closing ---. - * Uses \r?\n to handle both LF and CRLF line endings (Windows parity). - */ -function parseFrontmatter(text) { - const m = text.match(/^---\r?\n([\s\S]*?)\r?\n---/); - return m ? m[1] : null; -} - -describe('convertClaudeCommandToClineSkill — unit', () => { - test('emits frontmatter with name: gsd-', () => { - const result = convertClaudeCommandToClineSkill(SAMPLE_COMMAND, 'gsd-execute-phase'); - const nameMatch = result.match(/^name:\s*(.+)$/m); - assert.ok(nameMatch, 'frontmatter must contain name field'); - assert.ok(nameMatch[1].includes('gsd-execute-phase'), 'name must start with gsd-execute-phase'); - }); - - test('emits non-empty description in frontmatter', () => { - const result = convertClaudeCommandToClineSkill(SAMPLE_COMMAND, 'gsd-execute-phase'); - const descMatch = result.match(/^description:\s*(.+)$/m); - assert.ok(descMatch, 'frontmatter must contain description field'); - assert.ok(descMatch[1].trim().length > 0, 'description must not be empty'); - }); - - test('body uses .cline/ paths not .claude/', () => { - const result = convertClaudeCommandToClineSkill(SAMPLE_COMMAND, 'gsd-execute-phase'); - // The body reference to ~/.claude/ should be rewritten to ~/.cline/ - assert.ok(!result.includes('~/.claude/skills'), 'body must not contain ~/.claude/skills'); - assert.ok(result.includes('.cline/skills'), 'body must contain .cline/skills'); - }); - - test('body replaces "Claude Code" with "Cline"', () => { - const result = convertClaudeCommandToClineSkill(SAMPLE_COMMAND, 'gsd-execute-phase'); - assert.ok(!result.includes('Claude Code'), 'Claude Code must be replaced with Cline'); - assert.ok(result.includes('Cline'), 'result must contain Cline branding'); - }); - - test('no stray .claude/ paths in frontmatter or body', () => { - const result = convertClaudeCommandToClineSkill(SAMPLE_COMMAND, 'gsd-execute-phase'); - // Should not contain .claude/ anywhere (except inside CLAUDE.md→.clinerules rewrites - // but those are already handled by convertClaudeToCliineMarkdown) - assert.ok(!result.includes('/.claude/'), 'no /.claude/ paths in output'); - }); - - // ── Fix 1 (code-review): frontmatter must be ONLY name + description ────── - - test('frontmatter emits ONLY name and description — no allowed-tools (SAMPLE_COMMAND)', () => { - const result = convertClaudeCommandToClineSkill(SAMPLE_COMMAND, 'gsd-execute-phase'); - const fm = parseFrontmatter(result); - assert.ok(fm !== null, 'result must have YAML frontmatter'); - assert.ok(!fm.includes('allowed-tools'), 'frontmatter must NOT contain allowed-tools'); - assert.ok(!fm.includes('argument-hint'), 'frontmatter must NOT contain argument-hint'); - assert.ok(!fm.includes('agent:'), 'frontmatter must NOT contain agent:'); - }); - - test('frontmatter emits ONLY name and description — no allowed-tools/argument-hint/agent (RICH_COMMAND)', () => { - const result = convertClaudeCommandToClineSkill(RICH_COMMAND, 'gsd-validate-phase'); - const fm = parseFrontmatter(result); - assert.ok(fm !== null, 'result must have YAML frontmatter'); - assert.ok(!fm.includes('allowed-tools'), 'frontmatter must NOT contain allowed-tools'); - assert.ok(!fm.includes('argument-hint'), 'frontmatter must NOT contain argument-hint'); - assert.ok(!fm.includes('agent:'), 'frontmatter must NOT contain agent:'); - }); - - test('name == gsd-validate-phase for RICH_COMMAND', () => { - const result = convertClaudeCommandToClineSkill(RICH_COMMAND, 'gsd-validate-phase'); - const nameMatch = result.match(/^name:\s*(.+)$/m); - assert.ok(nameMatch, 'must have name field'); - // yamlIdentifier may quote the value; strip surrounding quotes for comparison - const nameVal = nameMatch[1].replace(/^['"]|['"]$/g, '').trim(); - assert.strictEqual(nameVal, 'gsd-validate-phase', `name must be gsd-validate-phase, got: ${nameVal}`); - }); - - test('description is non-empty and <= 1024 chars for RICH_COMMAND', () => { - const result = convertClaudeCommandToClineSkill(RICH_COMMAND, 'gsd-validate-phase'); - const descMatch = result.match(/^description:\s*(.+)$/m); - assert.ok(descMatch, 'must have description field'); - const desc = descMatch[1].replace(/^['"]|['"]$/g, '').trim(); - assert.ok(desc.length > 0, 'description must be non-empty'); - assert.ok(desc.length <= 1024, `description must be <= 1024 chars, got ${desc.length}`); - }); - - test('description truncated to <=1024 chars when source description is very long', () => { - const longDesc = 'A'.repeat(2000); - const longDescCommand = `---\nname: gsd:test\ndescription: ${longDesc}\n---\n\nBody text.\n`; - const result = convertClaudeCommandToClineSkill(longDescCommand, 'gsd-test'); - const descMatch = result.match(/^description:\s*'?(.*?)'?$/m); - assert.ok(descMatch, 'must have description field'); - // The raw description value (unquoted) should be <=1024 chars - // The result string after the --- block will have the quoted form; check raw length - // by checking the whole result doesn't have the full 2000-char string - assert.ok(!result.includes('A'.repeat(1025)), 'description must be truncated to 1024 chars'); - }); - - test('returns content unchanged when source has no frontmatter', () => { - const noFm = 'Just a body, no frontmatter here.\n'; - const result = convertClaudeCommandToClineSkill(noFm, 'gsd-test'); - assert.strictEqual(result, noFm, 'content without frontmatter must be returned unchanged'); - }); - - test('RICH_COMMAND body uses .cline/ paths and Cline branding', () => { - const result = convertClaudeCommandToClineSkill(RICH_COMMAND, 'gsd-validate-phase'); - assert.ok(!result.includes('~/.claude/'), 'body must not contain ~/.claude/'); - assert.ok(result.includes('.cline/'), 'body must contain .cline/ paths'); - assert.ok(!result.includes('Claude Code'), 'body must not contain "Claude Code"'); - assert.ok(result.includes('Cline'), 'body must reference Cline'); - }); -}); - -// ─── (b) + (c) + (d) Integration tests ──────────────────────────────────────── - -describe('installRuntimeArtifacts — cline skills emission', () => { - test('cline global: writes gsd-prefixed skill dirs under skills/', (t) => { - const configDir = createTempDir('gsd-cline-skills-'); - t.after(() => cleanup(configDir)); - - installRuntimeArtifacts('cline', configDir, 'global', RESOLVED_CORE); - - const layout = resolveRuntimeArtifactLayout('cline', configDir, 'global'); - const skillsKind = layout.kinds.find(k => k.kind === 'skills'); - assert.ok(skillsKind, 'cline must have a skills kind after #782'); - - const skillsDir = path.join(configDir, skillsKind.destSubpath); - assert.ok(fs.existsSync(skillsDir), 'skills/ directory must be created'); - - const helpSkillDir = path.join(skillsDir, `${skillsKind.prefix}help`); - assert.ok( - fs.existsSync(path.join(helpSkillDir, 'SKILL.md')), - `gsd-help/SKILL.md must exist under ${skillsKind.destSubpath}/` - ); - }); - - test('cline global: SKILL.md has valid cline frontmatter (name + description)', (t) => { - const configDir = createTempDir('gsd-cline-fm-'); - t.after(() => cleanup(configDir)); - - installRuntimeArtifacts('cline', configDir, 'global', RESOLVED_CORE); - - const skillsDir = path.join(configDir, 'skills'); - const helpSkill = path.join(skillsDir, 'gsd-help', 'SKILL.md'); - assert.ok(fs.existsSync(helpSkill), 'gsd-help/SKILL.md must exist'); - - const content = fs.readFileSync(helpSkill, 'utf8'); - // Must have YAML frontmatter - assert.ok(content.startsWith('---'), 'SKILL.md must start with YAML frontmatter'); - assert.ok(content.includes('name:'), 'frontmatter must have name field'); - assert.ok(content.includes('description:'), 'frontmatter must have description field'); - // name must be gsd-help - const nameMatch = content.match(/^name:\s*(.+)$/m); - assert.ok(nameMatch, 'must have name field'); - assert.ok(nameMatch[1].includes('gsd-help'), `name must include gsd-help, got: ${nameMatch[1]}`); - }); - - test('cline global: SKILL.md uses .cline/ paths not .claude/', (t) => { - const configDir = createTempDir('gsd-cline-paths-'); - t.after(() => cleanup(configDir)); - - installRuntimeArtifacts('cline', configDir, 'global', RESOLVED_CORE); - - const skillsDir = path.join(configDir, 'skills'); - // Check all installed skill files for stray .claude/ references - const skills = fs.readdirSync(skillsDir).filter(n => n.startsWith('gsd-')); - assert.ok(skills.length > 0, 'at least one gsd- skill must be installed'); - - for (const skillName of skills) { - const skillFile = path.join(skillsDir, skillName, 'SKILL.md'); - if (!fs.existsSync(skillFile)) continue; - const content = fs.readFileSync(skillFile, 'utf8'); - assert.ok( - !content.includes('~/.claude/'), - `${skillName}/SKILL.md must not contain ~/.claude/ — found stray path` - ); - assert.ok( - !content.includes('/.claude/'), - `${skillName}/SKILL.md must not contain /.claude/ — found stray path` - ); - } - }); - - test('cline global: skill count matches resolved profile', (t) => { - const configDir = createTempDir('gsd-cline-count-'); - t.after(() => cleanup(configDir)); - - installRuntimeArtifacts('cline', configDir, 'global', RESOLVED_CORE); - - const skillsDir = path.join(configDir, 'skills'); - const count = fs.readdirSync(skillsDir) - .filter(n => n.startsWith('gsd-') && fs.statSync(path.join(skillsDir, n)).isDirectory()) - .length; - - if (RESOLVED_CORE.skills !== '*') { - assert.strictEqual(count, RESOLVED_CORE.skills.size, - `installed skill count (${count}) must match profile size (${RESOLVED_CORE.skills.size})`); - } else { - assert.ok(count > 0, 'must install at least 1 skill'); - } - }); -}); - -describe('installRuntimeArtifacts — cline idempotency', () => { - test('cline: running install twice leaves skills intact (idempotency)', (t) => { - const configDir = createTempDir('gsd-cline-idempotent-'); - t.after(() => cleanup(configDir)); - - // First install - installRuntimeArtifacts('cline', configDir, 'global', RESOLVED_CORE); - - const skillsDir = path.join(configDir, 'skills'); - const countAfterFirst = fs.readdirSync(skillsDir) - .filter(n => n.startsWith('gsd-') && fs.statSync(path.join(skillsDir, n)).isDirectory()) - .length; - - // Second install (upgrade over existing) - installRuntimeArtifacts('cline', configDir, 'global', RESOLVED_CORE); - - const countAfterSecond = fs.readdirSync(skillsDir) - .filter(n => n.startsWith('gsd-') && fs.statSync(path.join(skillsDir, n)).isDirectory()) - .length; - - assert.strictEqual(countAfterFirst, countAfterSecond, - `skill count must be stable across installs: first=${countAfterFirst} second=${countAfterSecond}`); - }); -}); - -// ─── (e) Full install() global — coexistence regression ─────────────────────── -// -// Issue #782 explicitly requires that a global Cline install writes BOTH: -// - skills//SKILL.md (skills for Cline >= v3.48) -// - .clinerules/gsd.md (rules dir form introduced by #787) -// -// installRuntimeArtifacts() tests cover skills in isolation; this test exercises -// the FULL install() code path to ensure neither artifact is silently dropped. - -describe('install() global cline — coexistence: skills AND .clinerules', () => { - let tmpGlobalDir; - let originalClineConfigDir; - - beforeEach(() => { - originalClineConfigDir = process.env.CLINE_CONFIG_DIR; - tmpGlobalDir = createTempDir('gsd-cline-global-'); - // Redirect CLINE_CONFIG_DIR to the temp dir so install() never touches ~/.cline - process.env.CLINE_CONFIG_DIR = tmpGlobalDir; - }); - - afterEach(() => { - if (originalClineConfigDir !== undefined) { - process.env.CLINE_CONFIG_DIR = originalClineConfigDir; - } else { - delete process.env.CLINE_CONFIG_DIR; - } - cleanup(tmpGlobalDir); - }); - - test('global cline install writes at least one gsd-* SKILL.md under skills/', () => { - captureConsole(() => install(true, 'cline')); - - const skillsDir = path.join(tmpGlobalDir, 'skills'); - assert.ok( - fs.existsSync(skillsDir), - `skills/ directory must exist under ${tmpGlobalDir} after global cline install` - ); - - // full profile: gsd-help is nested under gsd-ns-manage/skills/help/SKILL.md - const helpSkillFile = nestedSkillPath(skillsDir, 'gsd-', 'help'); - assert.ok( - fs.existsSync(helpSkillFile), - `${path.relative(tmpGlobalDir, helpSkillFile)} must exist under ${tmpGlobalDir} — skills emission broken for global cline` - ); - }); - - test('global cline install writes .clinerules/gsd.md to the global config dir', () => { - captureConsole(() => install(true, 'cline')); - - // For a global Cline install, targetDir = getGlobalDir('cline') = CLINE_CONFIG_DIR. - // The cline-rules surface (#787) writes the .clinerules/ DIRECTORY form: - // .clinerules/gsd.md (rule file) - // .clinerules/hooks/PreToolUse (lifecycle hook) - const clinerulesMd = path.join(tmpGlobalDir, '.clinerules', 'gsd.md'); - assert.ok( - fs.existsSync(clinerulesMd), - `.clinerules/gsd.md must exist at ${clinerulesMd} — coexistence with skills broken for global cline (#782+#787)` - ); - }); - - test('global cline .clinerules/gsd.md contains GSD instructions', () => { - captureConsole(() => install(true, 'cline')); - - // #787 dir form: rule content lives in .clinerules/gsd.md, not a flat .clinerules file - const clinerulesMd = path.join(tmpGlobalDir, '.clinerules', 'gsd.md'); - assert.ok(fs.existsSync(clinerulesMd), '.clinerules/gsd.md must exist'); - const content = fs.readFileSync(clinerulesMd, 'utf8'); - assert.ok( - content.includes('GSD') || content.includes('gsd'), - '.clinerules/gsd.md must reference GSD' - ); - }); -}); - -// ─── Fix 3 regression: converter rewrites bare ~/.claude and CLAUDE_CONFIG_DIR ── -// -// convertClaudeToCliineMarkdown must also handle bare ~/.claude (no trailing -// slash) and the CLAUDE_CONFIG_DIR env-var name. surface.md contains these; -// the emitted Cline SKILL.md must contain no such stale Claude refs. - -describe('convertClaudeToCliineMarkdown — bare ~/.claude and CLAUDE_CONFIG_DIR (Fix 3)', () => { - const surfacePath = path.join(__dirname, '..', 'commands', 'gsd', 'surface.md'); - - test('no bare ~/.claude in converted surface.md', () => { - const raw = fs.readFileSync(surfacePath, 'utf8'); - const result = convertClaudeToCliineMarkdown(raw); - // ~/.claude followed by a word-boundary (not a /) must be gone - assert.ok( - !/~\/\.claude\b/.test(result), - 'converted surface.md must not contain bare ~/.claude' - ); - }); - - test('no CLAUDE_CONFIG_DIR in converted surface.md', () => { - const raw = fs.readFileSync(surfacePath, 'utf8'); - const result = convertClaudeToCliineMarkdown(raw); - assert.ok( - !result.includes('CLAUDE_CONFIG_DIR'), - 'converted surface.md must not contain CLAUDE_CONFIG_DIR' - ); - }); - - test('CLAUDE_CONFIG_DIR rewritten to CLINE_CONFIG_DIR', () => { - const input = 'Use CLAUDE_CONFIG_DIR or $HOME/.claude to configure'; - const result = convertClaudeToCliineMarkdown(input); - assert.ok(result.includes('CLINE_CONFIG_DIR'), 'CLAUDE_CONFIG_DIR must become CLINE_CONFIG_DIR'); - assert.ok(!result.includes('CLAUDE_CONFIG_DIR'), 'CLAUDE_CONFIG_DIR must be gone'); - }); - - test('bare ~/.claude rewritten to ~/.cline', () => { - const input = 'Config dir: (~/.claude), skills at ~/.claude/skills'; - const result = convertClaudeToCliineMarkdown(input); - assert.ok(!result.includes('~/.claude'), 'bare ~/.claude must be rewritten'); - assert.ok(result.includes('~/.cline'), 'must rewrite to ~/.cline'); - }); - - test('installRuntimeArtifacts cline global: gsd-surface SKILL.md has no bare ~/.claude or CLAUDE_CONFIG_DIR', (t) => { - const configDir = createTempDir('gsd-cline-surface-fix3-'); - t.after(() => cleanup(configDir)); - - const MANIFEST_FULL = require('../gsd-core/bin/lib/install-profiles.cjs').loadSkillsManifest( - path.join(__dirname, '..', 'commands', 'gsd') - ); - const RESOLVED_FULL = require('../gsd-core/bin/lib/install-profiles.cjs').resolveProfile({ - modes: ['full'], manifest: MANIFEST_FULL, - }); - - installRuntimeArtifacts('cline', configDir, 'global', RESOLVED_FULL); - - // full profile: surface is nested under gsd-ns-manage/skills/surface/SKILL.md - const surfaceSkill = nestedSkillPath(path.join(configDir, 'skills'), 'gsd-', 'surface'); - assert.ok(fs.existsSync(surfaceSkill), `${path.relative(configDir, surfaceSkill)} must exist for full profile`); - - const content = fs.readFileSync(surfaceSkill, 'utf8'); - assert.ok( - !/~\/\.claude\b/.test(content), - 'gsd-surface SKILL.md must not contain bare ~/.claude (Fix 3)' - ); - assert.ok( - !content.includes('CLAUDE_CONFIG_DIR'), - 'gsd-surface SKILL.md must not contain CLAUDE_CONFIG_DIR (Fix 3)' - ); - }); -}); - -// ─── Fix 1 regression: custom CLINE_CONFIG_DIR → embedded paths use custom dir ── -// -// _applyRuntimeRewrites for cline must rewrite ~/.cline/ → pathPrefix. -// For default global installs, pathPrefix = "$HOME/.cline/" (unchanged). -// For custom installs (CLINE_CONFIG_DIR=/custom), pathPrefix = "/custom/" and -// all embedded ~/.cline/ refs in SKILL.md must become /custom/... - -describe('_applyRuntimeRewrites — cline custom-dir embedded path (Fix 1)', () => { - test('default pathPrefix ($HOME/.cline/) leaves ~/.cline refs as $HOME/.cline', () => { - const content = 'See ~/.cline/skills/gsd-help/SKILL.md for reference.\nBare: ~/.cline\n'; - const result = _applyRuntimeRewrites(content, 'cline', '$HOME/.cline/'); - assert.ok(result.includes('$HOME/.cline/'), 'default prefix must map ~/.cline/ to $HOME/.cline/'); - assert.ok(!result.includes('~/.cline'), 'no tilde form should remain after rewrite'); - }); - - test('custom pathPrefix rewrites ~/.cline/ → custom path in SKILL.md body', () => { - const content = 'See ~/.cline/skills/gsd-help/SKILL.md for reference.\nBare: ~/.cline\n'; - const result = _applyRuntimeRewrites(content, 'cline', '/custom/cline-dir/'); - assert.ok(result.includes('/custom/cline-dir/'), 'custom prefix must appear in output'); - assert.ok(!result.includes('~/.cline'), 'no tilde cline form should remain after custom rewrite'); - }); - - test('custom pathPrefix rewrites residual ~/.claude/ safety net', () => { - const content = 'Residual: ~/.claude/skills\n'; - const result = _applyRuntimeRewrites(content, 'cline', '/custom/cline-dir/'); - assert.ok(result.includes('/custom/cline-dir/'), 'safety-net ~/.claude/ also rewritten to custom prefix'); - assert.ok(!result.includes('~/.claude/'), 'no ~/.claude/ should remain'); - }); - - test('installRuntimeArtifacts cline with CLINE_CONFIG_DIR custom: SKILL.md embeds custom path', (t) => { - const configDir = createTempDir('gsd-cline-custom-dir-'); - t.after(() => cleanup(configDir)); - - const MANIFEST_FULL = require('../gsd-core/bin/lib/install-profiles.cjs').loadSkillsManifest( - path.join(__dirname, '..', 'commands', 'gsd') - ); - const RESOLVED_FULL = require('../gsd-core/bin/lib/install-profiles.cjs').resolveProfile({ - modes: ['full'], manifest: MANIFEST_FULL, - }); - - installRuntimeArtifacts('cline', configDir, 'global', RESOLVED_FULL); - - // gsd-surface SKILL.md references config paths; with a custom configDir - // (not under $HOME), pathPrefix will be the absolute custom path. - // full profile: surface is nested under gsd-ns-manage/skills/surface/SKILL.md - const surfaceSkill = nestedSkillPath(path.join(configDir, 'skills'), 'gsd-', 'surface'); - assert.ok(fs.existsSync(surfaceSkill), `${path.relative(configDir, surfaceSkill)} must exist`); - - const content = fs.readFileSync(surfaceSkill, 'utf8'); - // With a custom dir (path under /tmp, not ~/.cline), the output must NOT - // contain ~/.cline/ or $HOME/.cline/ — it must embed the actual configDir path. - assert.ok( - !content.includes('~/.cline/'), - `gsd-surface SKILL.md must not contain ~/.cline/ when configDir=${configDir} (Fix 1)` - ); - // The custom path must appear somewhere in the file - // (configDir is a /tmp/... path so pathPrefix = configDir+'/'). - // Production normalizes backslashes to forward slashes via - // path.resolve(configDir).replace(/\\/g, '/'), so compare against that - // form — otherwise this assertion fails on Windows where mkdtempSync - // returns a backslash path (e.g. C:\Users\...) but the emitted content - // already has forward slashes (C:/Users/...). - const expectedPath = path.resolve(configDir).replace(/\\/g, '/'); - assert.ok( - content.includes(expectedPath), - `gsd-surface SKILL.md must embed custom configDir path ${expectedPath} (Fix 1)` - ); - }); -}); - -// ─── Fix 4 regression: description truncation is code-point-aware ──────────── -// -// Naive UTF-16 slicing (`str.slice(0, 1021)`) can split a surrogate pair when -// the cut falls between the high and low surrogate of a multibyte character -// (e.g. emoji U+1F600, which is encoded as two UTF-16 code units). The fix -// uses Array.from() to split by code point, guaranteeing that the truncated -// value never contains a lone surrogate. - -describe('convertClaudeCommandToClineSkill — code-point-aware truncation (Fix 4)', () => { - /** - * Build a frontmatter+body command string whose description is: - * - exactly `prefixLen` ASCII chars - * - followed by `emojiCount` repetitions of '😀' (U+1F600, 2 UTF-16 units) - * - total UTF-16 length is prefixLen + emojiCount * 2 - */ - function makeEmojiCommand(prefixLen, emojiCount) { - const desc = 'A'.repeat(prefixLen) + '😀'.repeat(emojiCount); - return `---\nname: gsd:emoji-test\ndescription: ${desc}\n---\n\nBody.\n`; - } - - test('emitted description is <= 1024 code points when source overflows', () => { - // 1020 ASCII chars + 4 emoji = 1020 + 8 UTF-16 units = 1028 UTF-16 units > 1024. - // Code-point count = 1020 + 4 = 1024 — exactly at the boundary BEFORE adding '...'. - // After truncation to 1021 code points + '...' → 1024 code points total. - const cmd = makeEmojiCommand(1020, 10); // 1030 code points → must truncate - const result = convertClaudeCommandToClineSkill(cmd, 'gsd-emoji-test'); - - // Extract raw description value (strip surrounding YAML quotes if present) - const descMatch = result.match(/^description:\s*(.+)$/m); - assert.ok(descMatch, 'emitted SKILL.md must have a description field'); - const rawDesc = descMatch[1].trim().replace(/^['"]|['"]$/g, ''); - - const codePoints = Array.from(rawDesc); - assert.ok( - codePoints.length <= 1024, - `emitted description must be <= 1024 code points, got ${codePoints.length}` - ); - }); - - test('emitted description ends with "..." when truncated', () => { - const cmd = makeEmojiCommand(1020, 10); // 1030 code points → must truncate - const result = convertClaudeCommandToClineSkill(cmd, 'gsd-emoji-test'); - - const descMatch = result.match(/^description:\s*(.+)$/m); - assert.ok(descMatch, 'emitted SKILL.md must have a description field'); - const rawDesc = descMatch[1].trim().replace(/^['"]|['"]$/g, ''); - - assert.ok(rawDesc.endsWith('...'), `truncated description must end with "...", got: ${rawDesc.slice(-10)}`); - }); - - test('emitted description has no lone surrogate (no split emoji)', () => { - // Place emojis exactly at positions 1021–1025 (code points) so that a naive - // UTF-16 slice at 1021 code units would cut inside the second emoji's surrogate pair. - // 1019 ASCII chars + 6 emoji = 1025 code points (>1024, triggers truncation). - // UTF-16 length = 1019 + 12 = 1031. Naive slice(0,1021) yields 1019 ASCII + - // the HIGH surrogate of emoji[0] — a lone surrogate. - const cmd = makeEmojiCommand(1019, 6); - const result = convertClaudeCommandToClineSkill(cmd, 'gsd-emoji-test'); - - const descMatch = result.match(/^description:\s*(.+)$/m); - assert.ok(descMatch, 'emitted SKILL.md must have a description field'); - const rawDesc = descMatch[1].trim().replace(/^['"]|['"]$/g, ''); - - // Verify no lone surrogate: every char's code point must be outside [0xD800, 0xDFFF]. - const hasLoneSurrogate = [...rawDesc].some(c => { - const cp = c.codePointAt(0); - return cp >= 0xD800 && cp <= 0xDFFF; - }); - assert.ok(!hasLoneSurrogate, 'emitted description must not contain a lone surrogate'); - - // Also round-trip through Buffer to confirm the string is valid UTF-8 encodable. - assert.doesNotThrow( - () => Buffer.from(rawDesc, 'utf8').toString('utf8'), - 'emitted description must round-trip through Buffer without error' - ); - }); - - test('short description (<= 1024 code points) is not truncated', () => { - // 10 ASCII + 5 emoji = 15 code points — well under the limit. - const cmd = makeEmojiCommand(10, 5); - const result = convertClaudeCommandToClineSkill(cmd, 'gsd-emoji-test'); - - const descMatch = result.match(/^description:\s*(.+)$/m); - assert.ok(descMatch, 'emitted SKILL.md must have a description field'); - const rawDesc = descMatch[1].trim().replace(/^['"]|['"]$/g, ''); - - assert.ok(!rawDesc.endsWith('...'), 'short description must NOT be truncated with "..."'); - // Must contain the original emoji characters intact - assert.ok(rawDesc.includes('😀'), 'short description must preserve emoji characters'); - }); -}); - -// ─── Fix 2 regression: cline local scope emits no skills ───────────────────── -// -// resolveRuntimeArtifactLayout('cline', dir, 'local') must return 0 kinds. -// installRuntimeArtifacts('cline', dir, 'local') must not write any skills. - -describe('resolveRuntimeArtifactLayout — cline scope-aware (Fix 2)', () => { - test('cline local: kinds.length === 0 (no skills for local scope)', () => { - const { resolveRuntimeArtifactLayout } = require('../gsd-core/bin/lib/runtime-artifact-layout.cjs'); - const layout = resolveRuntimeArtifactLayout('cline', '/tmp/x', 'local'); - assert.strictEqual(layout.kinds.length, 0, 'cline local must have 0 kinds'); - }); - - test('cline global: kinds.length === 1 (skills kind)', () => { - const { resolveRuntimeArtifactLayout } = require('../gsd-core/bin/lib/runtime-artifact-layout.cjs'); - const layout = resolveRuntimeArtifactLayout('cline', '/tmp/x', 'global'); - assert.strictEqual(layout.kinds.length, 1, 'cline global must have 1 skills kind'); - assert.strictEqual(layout.kinds[0].kind, 'skills'); - }); - - test('installRuntimeArtifacts cline local: no skills/ dir created', (t) => { - const configDir = createTempDir('gsd-cline-local-noskills-'); - t.after(() => cleanup(configDir)); - - assert.doesNotThrow(() => installRuntimeArtifacts('cline', configDir, 'local', RESOLVED_CORE)); - const skillsDir = path.join(configDir, 'skills'); - assert.ok( - !fs.existsSync(skillsDir), - `skills/ must NOT be created for cline local install (Fix 2), but found ${skillsDir}` - ); - }); -}); - }); -} - - - -// ──────────────────────────────────────────────────────────────────────── -// Folded from tests/enh-789-codebuddy-commands.test.cjs — consolidation epic #1969 (B1 #1970) -// ──────────────────────────────────────────────────────────────────────── -{ - const { describe: __foldDescribe } = require('node:test'); - __foldDescribe("folded:enh-789-codebuddy-commands (consolidation epic #1969 B1 #1970)", () => { -// allow-test-rule: source-text-is-the-product (see #789) -// Workflow .md / command .md / SKILL.md files — their text IS what the runtime -// loads. Testing emitted text tests the deployed contract. -// Per CONTRIBUTING.md exception matrix. - -/** - * Regression guard — enh(#789): elevate CodeBuddy slash-command surface. - * - * CodeBuddy (Tencent, @tencent-ai/codebuddy-code) reads user-level surfaces - * (https://www.codebuddy.ai/docs/cli/slash-commands, /skills): - * - commands/gsd-.md — slash commands shown in the '/' menu - * - skills/gsd-/SKILL.md — model-invocable skills - * - * Before #789 gsd emitted only skills/. Because CodeBuddy skills default to - * user-invocable:true (appear in '/'), emitting a commands/ surface AND leaving - * skills user-invocable would duplicate every /gsd-* entry. #789 therefore: - * 1. emits commands/gsd-.md (the '/' surface, peer-consistent with - * Cursor #785 and Augment #790), - * 2. marks skills user-invocable:false so they become model-invocable - * background knowledge and the commands/ surface is the sole '/' surface. - * - * Subagents are already emitted via the generic agents block + convertClaude - * AgentToCodebuddyAgent (~/.codebuddy/agents/), so #789 adds no agents change. - * - * mcp.json is intentionally NOT written: gsd ships no MCP server, and CodeBuddy's - * mcp.json holds an `mcpServers` map of *external* servers to connect to — - * there is nothing for gsd to register. Same exclusion as #784/#785/#790. - */ -'use strict'; - -process.env.GSD_TEST_MODE = '1'; - -const { describe, test } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('node:fs'); -const path = require('node:path'); - -const { createTempDir, cleanup } = require('./helpers.cjs'); - -const { - convertClaudeCommandToCodebuddyCommand, - convertClaudeCommandToCodebuddySkill, -} = require('../bin/install.js'); - -const { - installRuntimeArtifacts, - uninstallRuntimeArtifacts, -} = require('../gsd-core/bin/lib/install-engine.cjs'); -const { resolveRuntimeArtifactLayout } = require('../gsd-core/bin/lib/runtime-artifact-layout.cjs'); -const { loadSkillsManifest, resolveProfile } = require('../gsd-core/bin/lib/install-profiles.cjs'); - -const REAL_COMMANDS_DIR = path.join(__dirname, '..', 'commands', 'gsd'); -const MANIFEST = loadSkillsManifest(REAL_COMMANDS_DIR); -const RESOLVED_CORE = resolveProfile({ modes: ['core'], manifest: MANIFEST }); - -// ─── Layout contract ───────────────────────────────────────────────────────── - -describe('enh-789 — codebuddy layout has commands + skills kinds', () => { - test('resolveRuntimeArtifactLayout codebuddy returns 3 kinds (ADR-1235 §1 agents cutover)', () => { - const layout = resolveRuntimeArtifactLayout('codebuddy', '/tmp/fake-codebuddy-dir'); - assert.strictEqual(layout.kinds.length, 3, 'codebuddy must have exactly 3 artifact kinds (commands + skills + agents)'); - const kindNames = layout.kinds.map(k => k.kind).sort(); - assert.deepStrictEqual(kindNames, ['agents', 'commands', 'skills']); - }); - - test('codebuddy commands kind targets commands/ with gsd- prefix', () => { - const layout = resolveRuntimeArtifactLayout('codebuddy', '/tmp/fake-codebuddy-dir'); - const commandsKind = layout.kinds.find(k => k.kind === 'commands'); - assert.ok(commandsKind, 'must have commands kind'); - assert.strictEqual(commandsKind.destSubpath, 'commands'); - assert.strictEqual(commandsKind.prefix, 'gsd-'); - assert.strictEqual(typeof commandsKind.stage, 'function'); - }); - - test('codebuddy skills kind targets skills/ with gsd- prefix', () => { - const layout = resolveRuntimeArtifactLayout('codebuddy', '/tmp/fake-codebuddy-dir'); - const skillsKind = layout.kinds.find(k => k.kind === 'skills'); - assert.ok(skillsKind, 'must have skills kind'); - assert.strictEqual(skillsKind.destSubpath, 'skills'); - assert.strictEqual(skillsKind.prefix, 'gsd-'); - }); -}); - -// ─── Command converter contract ────────────────────────────────────────────── - -describe('enh-789 — convertClaudeCommandToCodebuddyCommand', () => { - const SRC = [ - '---', - 'name: gsd:new-project', - 'description: Initialize a project', - 'argument-hint: "[name]"', - 'allowed-tools:', - ' - Read', - '---', - '', - 'Use .claude/skills/ and run /gsd:help. Claude Code reads CLAUDE.md.', - '', - ].join('\n'); - - test('emits a description-only frontmatter (no Claude-specific name: gsd:)', () => { - const out = convertClaudeCommandToCodebuddyCommand(SRC, 'gsd-new-project'); - assert.ok(out.startsWith('---\n'), 'must begin with frontmatter'); - assert.ok(/^description:/m.test(out), 'must carry a description field'); - assert.ok(!out.includes('name: gsd:new-project'), 'must drop Claude colon-form name field'); - }); - - test('preserves a present argument-hint (CodeBuddy supports it)', () => { - const out = convertClaudeCommandToCodebuddyCommand(SRC, 'gsd-new-project'); - assert.ok(/^argument-hint:\s*["']?\[name\]["']?\s*$/m.test(out), - `argument-hint must be carried through when present in source. Got:\n${out}`); - }); - - test('converts body Claude-isms to CodeBuddy equivalents', () => { - const out = convertClaudeCommandToCodebuddyCommand(SRC, 'gsd-new-project'); - assert.ok(out.includes('.codebuddy/skills/'), out); - assert.ok(out.includes('/gsd-help'), out); - assert.ok(out.includes('CODEBUDDY.md'), out); - assert.ok(!/\bClaude Code\b/.test(out), 'must rebrand "Claude Code"'); - }); -}); - -describe('enh-789 — skills marked user-invocable:false', () => { - test('convertClaudeCommandToCodebuddySkill emits user-invocable: false', () => { - const src = [ - '---', - 'name: gsd:help', - 'description: Show help', - '---', - '', - '# body', - '', - ].join('\n'); - const out = convertClaudeCommandToCodebuddySkill(src, 'gsd-help'); - assert.ok(/^user-invocable:\s*false\s*$/m.test(out), - `SKILL.md frontmatter must hide skill from '/' menu (user-invocable: false). Got:\n${out}`); - }); -}); - -// ─── Install contract ──────────────────────────────────────────────────────── - -describe('enh-789 — installRuntimeArtifacts codebuddy emits commands and skills', () => { - test('global codebuddy install: commands/gsd-help.md and skills/gsd-help/SKILL.md exist', (t) => { - const configDir = createTempDir('gsd-enh789-codebuddy-'); - t.after(() => cleanup(configDir)); - - installRuntimeArtifacts('codebuddy', configDir, 'global', RESOLVED_CORE); - - const commandsDir = path.join(configDir, 'commands'); - assert.ok(fs.existsSync(commandsDir), 'commands/ dir must exist'); - const cmdFiles = fs.readdirSync(commandsDir).filter(f => f.startsWith('gsd-') && f.endsWith('.md')); - assert.ok(cmdFiles.length > 0, 'at least one gsd-*.md command file must be installed'); - assert.ok(fs.existsSync(path.join(commandsDir, 'gsd-help.md')), 'commands/gsd-help.md must exist'); - - const skillsDir = path.join(configDir, 'skills'); - assert.ok(fs.existsSync(skillsDir), 'skills/ dir must exist'); - assert.ok(fs.existsSync(path.join(skillsDir, 'gsd-help', 'SKILL.md')), 'skills/gsd-help/SKILL.md must exist'); - }); - - test('installed commands/gsd-help.md is CodeBuddy-compatible (no raw ~/.claude/, rebranded)', (t) => { - const configDir = createTempDir('gsd-enh789-content-'); - t.after(() => cleanup(configDir)); - - installRuntimeArtifacts('codebuddy', configDir, 'global', RESOLVED_CORE); - - const helpCmd = path.join(configDir, 'commands', 'gsd-help.md'); - const content = fs.readFileSync(helpCmd, 'utf8'); - assert.ok(!content.includes('~/.claude/'), 'commands must not contain raw ~/.claude/ refs'); - assert.ok(content.startsWith('---'), 'commands must carry frontmatter'); - }); - - test('installed skills/gsd-help/SKILL.md is hidden from the / menu', (t) => { - const configDir = createTempDir('gsd-enh789-skillhide-'); - t.after(() => cleanup(configDir)); - - installRuntimeArtifacts('codebuddy', configDir, 'global', RESOLVED_CORE); - - const skill = fs.readFileSync(path.join(configDir, 'skills', 'gsd-help', 'SKILL.md'), 'utf8'); - assert.ok(/^user-invocable:\s*false\s*$/m.test(skill), - 'installed SKILL.md must set user-invocable: false'); - }); - - test('command count matches skill count (profile parity)', (t) => { - const configDir = createTempDir('gsd-enh789-parity-'); - t.after(() => cleanup(configDir)); - - installRuntimeArtifacts('codebuddy', configDir, 'global', RESOLVED_CORE); - - const cmdCount = fs.readdirSync(path.join(configDir, 'commands')) - .filter(f => f.startsWith('gsd-') && f.endsWith('.md')).length; - const skillCount = fs.readdirSync(path.join(configDir, 'skills'), { withFileTypes: true }) - .filter(e => e.isDirectory() && e.name.startsWith('gsd-')).length; - assert.strictEqual(cmdCount, skillCount, 'command count must equal skill count for same profile'); - }); - - test('full profile install: no $HOME/.codebuddy or ~/.codebuddy leak in any command', (t) => { - // The codebuddy converter rewrites `.claude/` → `.codebuddy/`, so source - // refs like `@$HOME/.claude/gsd-core/...` (e.g. plan-review-convergence.md) - // must be normalized to the install target — not left as $HOME/.codebuddy. - const RESOLVED_FULL = resolveProfile({ modes: ['full'], manifest: MANIFEST }); - const configDir = createTempDir('gsd-enh789-noleak-'); - t.after(() => cleanup(configDir)); - - installRuntimeArtifacts('codebuddy', configDir, 'global', RESOLVED_FULL); - - const commandsDir = path.join(configDir, 'commands'); - for (const f of fs.readdirSync(commandsDir).filter(n => n.endsWith('.md'))) { - const content = fs.readFileSync(path.join(commandsDir, f), 'utf8'); - assert.ok(!content.includes('$HOME/.codebuddy'), `${f} must not leak $HOME/.codebuddy`); - assert.ok(!content.includes('~/.codebuddy'), `${f} must not leak ~/.codebuddy`); - assert.ok(!content.includes('.claude/'), `${f} must not retain raw .claude/ refs`); - } - }); - - test('full profile install does NOT mutate source commands/gsd/ files', (t) => { - const RESOLVED_FULL = resolveProfile({ modes: ['full'], manifest: MANIFEST }); - assert.strictEqual(RESOLVED_FULL.skills, '*', 'full profile must have skills === "*"'); - - const configDir = createTempDir('gsd-enh789-full-'); - t.after(() => cleanup(configDir)); - - const srcHelpPath = path.join(REAL_COMMANDS_DIR, 'help.md'); - const before = fs.readFileSync(srcHelpPath, 'utf8'); - - installRuntimeArtifacts('codebuddy', configDir, 'global', RESOLVED_FULL); - - const after = fs.readFileSync(srcHelpPath, 'utf8'); - assert.strictEqual(before, after, 'source commands/gsd/help.md must not be mutated by the install'); - }); -}); - -// ─── Uninstall contract ────────────────────────────────────────────────────── - -describe('enh-789 — uninstallRuntimeArtifacts removes codebuddy commands', () => { - test('uninstall removes gsd-* commands but preserves user commands', (t) => { - const configDir = createTempDir('gsd-enh789-uninstall-'); - t.after(() => cleanup(configDir)); - - const commandsDir = path.join(configDir, 'commands'); - fs.mkdirSync(commandsDir, { recursive: true }); - fs.writeFileSync(path.join(commandsDir, 'gsd-help.md'), '# help\n'); - fs.writeFileSync(path.join(commandsDir, 'user-custom.md'), '# user\n'); - - uninstallRuntimeArtifacts('codebuddy', configDir, 'global'); - - assert.ok(!fs.existsSync(path.join(commandsDir, 'gsd-help.md')), 'gsd-help.md must be removed'); - assert.ok(fs.existsSync(path.join(commandsDir, 'user-custom.md')), 'user-custom.md must be preserved'); - }); -}); - -// ─── mcp.json exclusion ────────────────────────────────────────────────────── - -describe('enh-789 — mcp.json excluded (gsd ships no MCP server)', () => { - test('codebuddy install does not write mcp.json / .mcp.json', (t) => { - const configDir = createTempDir('gsd-enh789-mcp-excluded-'); - t.after(() => cleanup(configDir)); - - installRuntimeArtifacts('codebuddy', configDir, 'global', RESOLVED_CORE); - - assert.ok(!fs.existsSync(path.join(configDir, 'mcp.json')), 'must not write mcp.json'); - assert.ok(!fs.existsSync(path.join(configDir, '.mcp.json')), 'must not write .mcp.json'); - }); -}); - }); -} - - -// ──────────────────────────────────────────────────────────────────────── -// Folded from tests/bug-2794-opencode-model-profile-overrides.test.cjs — consolidation epic #1969 (B1 #1970) -// ──────────────────────────────────────────────────────────────────────── -{ - const { describe: __foldDescribe } = require('node:test'); - __foldDescribe("folded:bug-2794-opencode-model-profile-overrides (consolidation epic #1969 B1 #1970)", () => { -/** - * Regression test for bug #2794 - * - * OpenCode generated agents ignored `model_profile_overrides.opencode.*`. - * The agent install path called `readGsdEffectiveModelOverrides` (explicit - * per-agent overrides) but never called `readGsdRuntimeProfileResolver` - * (tier-based profile overrides). When a user configured: - * - * { runtime: "opencode", model_profile_overrides: { opencode: { sonnet: "..." } } } - * - * generated `.opencode/agents/gsd-*.md` files contained no `model:` frontmatter. - * - * The fix adds a tier-resolver fallback in the OpenCode agent conversion block: - * explicit `model_overrides[agent]` > `model_profile_overrides.opencode.` > omit. - * - * This test exercises: - * 1. `readGsdRuntimeProfileResolver` correctly resolves OpenCode tier overrides. - * 2. The agent install code path embeds the resolved model into OpenCode frontmatter. - * 3. Explicit `model_overrides` still wins over tier-based resolution. - * 4. Missing overrides produce no `model:` field (no regression on omit behavior). - */ - -process.env.GSD_TEST_MODE = '1'; - -const { describe, test, beforeEach, afterEach } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('node:fs'); -const path = require('node:path'); -const { - readGsdRuntimeProfileResolver, - install, -} = require('../bin/install.js'); - -const { createTempDir, cleanup } = require('./helpers.cjs'); -const makeTmp = (prefix) => createTempDir(`gsd-2794-${prefix}-`); - -function writeJson(p, obj) { - fs.mkdirSync(path.dirname(p), { recursive: true }); - fs.writeFileSync(p, JSON.stringify(obj, null, 2), 'utf-8'); -} - - -describe('bug-2794: readGsdRuntimeProfileResolver resolves opencode tier overrides', () => { - let projectDir; - let homeDir; - let origHome; - let origUP; - - beforeEach(() => { - projectDir = makeTmp('proj'); - homeDir = makeTmp('home'); - origHome = process.env.HOME; - origUP = process.env.USERPROFILE; - process.env.HOME = homeDir; - process.env.USERPROFILE = homeDir; - }); - - afterEach(() => { - if (origHome === undefined) delete process.env.HOME; - else process.env.HOME = origHome; - if (origUP === undefined) delete process.env.USERPROFILE; - else process.env.USERPROFILE = origUP; - cleanup(projectDir); - cleanup(homeDir); - }); - - test('resolves opencode sonnet tier to user-supplied model ID', () => { - writeJson(path.join(projectDir, '.planning', 'config.json'), { - runtime: 'opencode', - model_profile: 'balanced', - model_profile_overrides: { - opencode: { - sonnet: 'anthropic/claude-sonnet-4-7', - }, - }, - }); - - const resolver = readGsdRuntimeProfileResolver(projectDir); - assert.ok(resolver !== null, 'expected a resolver for opencode runtime'); - - // gsd-roadmapper balanced tier = sonnet — should resolve to override - const entry = resolver.resolve('gsd-roadmapper'); - assert.ok(entry !== null, 'expected entry for gsd-roadmapper'); - assert.strictEqual(entry.model, 'anthropic/claude-sonnet-4-7', 'sonnet override applied'); - }); - - test('returns null resolver when runtime is not set', () => { - writeJson(path.join(projectDir, '.planning', 'config.json'), { - model_profile: 'balanced', - model_profile_overrides: { opencode: { sonnet: 'x' } }, - }); - const resolver = readGsdRuntimeProfileResolver(projectDir); - assert.strictEqual(resolver, null, 'no resolver without runtime field'); - }); - - test('resolver returns null for agent not in MODEL_PROFILES', () => { - writeJson(path.join(projectDir, '.planning', 'config.json'), { - runtime: 'opencode', - model_profile: 'balanced', - model_profile_overrides: { opencode: { sonnet: 'x' } }, - }); - const resolver = readGsdRuntimeProfileResolver(projectDir); - assert.ok(resolver !== null); - const entry = resolver.resolve('gsd-nonexistent-agent'); - assert.strictEqual(entry, null, 'unknown agent name yields null'); - }); -}); - -describe('bug-2794: OpenCode agent install embeds model_profile_overrides model', () => { - let projectDir; - let homeDir; - let origHome; - let origUP; - let origCwd; - - beforeEach(() => { - projectDir = makeTmp('proj'); - homeDir = makeTmp('home'); - origHome = process.env.HOME; - origUP = process.env.USERPROFILE; - origCwd = process.cwd(); - process.env.HOME = homeDir; - process.env.USERPROFILE = homeDir; - process.chdir(projectDir); - }); - - afterEach(() => { - if (origHome === undefined) delete process.env.HOME; - else process.env.HOME = origHome; - if (origUP === undefined) delete process.env.USERPROFILE; - else process.env.USERPROFILE = origUP; - process.chdir(origCwd); - cleanup(projectDir); - cleanup(homeDir); - }); - - test('generated OpenCode agent frontmatter includes model from model_profile_overrides', () => { - writeJson(path.join(projectDir, '.planning', 'config.json'), { - runtime: 'opencode', - model_profile: 'balanced', - model_profile_overrides: { - opencode: { - sonnet: 'anthropic/claude-sonnet-4-7', - opus: 'anthropic/claude-opus-4-7', - haiku: 'anthropic/claude-haiku-4-5', - }, - }, - }); - - const oldLog = console.log; - console.log = () => {}; - try { - install(false, 'opencode'); - } finally { - console.log = oldLog; - } - - const agentsDir = path.join(projectDir, '.opencode', 'agents'); - assert.ok(fs.existsSync(agentsDir), 'agents directory should be created'); - - // gsd-roadmapper is balanced -> sonnet tier - const roadmapperPath = path.join(agentsDir, 'gsd-roadmapper.md'); - assert.ok(fs.existsSync(roadmapperPath), 'gsd-roadmapper.md should exist'); - const roadmapperContent = fs.readFileSync(roadmapperPath, 'utf-8'); - assert.match( - roadmapperContent, - /^model: anthropic\/claude-sonnet-4-7$/m, - 'gsd-roadmapper should have sonnet model from model_profile_overrides' - ); - - // gsd-planner is balanced -> opus tier - const plannerPath = path.join(agentsDir, 'gsd-planner.md'); - assert.ok(fs.existsSync(plannerPath), 'gsd-planner.md should exist'); - const plannerContent = fs.readFileSync(plannerPath, 'utf-8'); - assert.match( - plannerContent, - /^model: anthropic\/claude-opus-4-7$/m, - 'gsd-planner should have opus model from model_profile_overrides' - ); - }); - - test('explicit model_overrides[agent] wins over model_profile_overrides tier', () => { - writeJson(path.join(projectDir, '.planning', 'config.json'), { - runtime: 'opencode', - model_profile: 'balanced', - model_overrides: { - 'gsd-roadmapper': 'explicit-winner-model', - }, - model_profile_overrides: { - opencode: { - sonnet: 'tier-model-that-should-lose', - }, - }, - }); - - const oldLog = console.log; - console.log = () => {}; - try { - install(false, 'opencode'); - } finally { - console.log = oldLog; - } - - const roadmapperPath = path.join(projectDir, '.opencode', 'agents', 'gsd-roadmapper.md'); - assert.ok(fs.existsSync(roadmapperPath)); - const content = fs.readFileSync(roadmapperPath, 'utf-8'); - assert.match( - content, - /^model: explicit-winner-model$/m, - 'explicit model_overrides must win over model_profile_overrides tier' - ); - assert.doesNotMatch( - content, - /tier-model-that-should-lose/, - 'tier model must not appear when explicit override is present' - ); - }); - - test('no model field when neither model_overrides nor model_profile_overrides is set', () => { - writeJson(path.join(projectDir, '.planning', 'config.json'), { - runtime: 'opencode', - model_profile: 'balanced', - }); - - const oldLog = console.log; - console.log = () => {}; - try { - install(false, 'opencode'); - } finally { - console.log = oldLog; - } - - const roadmapperPath = path.join(projectDir, '.opencode', 'agents', 'gsd-roadmapper.md'); - if (fs.existsSync(roadmapperPath)) { - const content = fs.readFileSync(roadmapperPath, 'utf-8'); - // When no overrides, model field should either be absent or use built-in default - // The key invariant: no model field if there are no user-supplied overrides - // AND no built-in opencode defaults for this tier - // (gsd-roadmapper balanced = sonnet; opencode has built-in sonnet defaults) - // So we only assert no crash and no tier-model-not-provided entries - assert.ok(typeof content === 'string', 'agent file should be a string'); - } - // Key: no exception thrown (test passes = no crash on missing overrides) - }); -}); - }); -} - - -// ──────────────────────────────────────────────────────────────────────── -// Folded from tests/bug-2643-skill-frontmatter-name.test.cjs — consolidation epic #1969 (B1 #1970) -// ──────────────────────────────────────────────────────────────────────── -{ - const { describe: __foldDescribe } = require('node:test'); - __foldDescribe("folded:bug-2643-skill-frontmatter-name (consolidation epic #1969 B1 #1970)", () => { -'use strict'; - -process.env.GSD_TEST_MODE = '1'; - -/** - * Bug #2643 / #2808: skill frontmatter name parity. - * - * Original (#2643): workflows emitted Skill(skill="gsd:") and the - * installer registered colon form in SKILL.md name: to match. - * - * Updated (#2808): workflows now use Skill(skill="gsd-") (hyphen), - * and the installer emits name: gsd- (hyphen). Claude Code autocomplete - * now shows the canonical hyphen form instead of the deprecated colon form. - * The directory name (gsd-) is unchanged. - */ - -const { test, describe } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('node:fs'); -const path = require('node:path'); - -const ROOT = path.join(__dirname, '..'); -const { - convertClaudeCommandToClaudeSkill, - skillFrontmatterName, -} = require(path.join(ROOT, 'bin', 'install.js')); - -const WORKFLOWS_DIR = path.join(ROOT, 'gsd-core', 'workflows'); -const COMMANDS_DIR = path.join(ROOT, 'commands', 'gsd'); - -function collectFiles(dir, results) { - if (!results) results = []; - let entries; - try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return results; } - for (const e of entries) { - const full = path.join(dir, e.name); - if (e.isDirectory()) collectFiles(full, results); - else if (e.name.endsWith('.md')) results.push(full); - } - return results; -} - -/** - * Extract every `Skill(skill="")` invocation as a structured record. - * - * Per project test rigor (`feedback_no_source_grep_tests.md`), this parses - * each call as a unit instead of leaning on a single regex over raw bytes. - * The flow is: - * - * 1. Strip HTML comments so commented-out examples don't count as drift. - * 2. Walk the content for `Skill(` openers; for each, find the matching - * `)` closer (Skill bodies are simple kwarg lists, no nesting). - * 3. Parse the call body for the `skill = "..."` keyword argument. - * Permissive whitespace around the keyword and `=`, permissive - * single/double quoting (with optional `\` escapes from string- - * embedded examples), permissive name body — so malformed drift like - * `Skill(skill="gsd:extract_learnings")` is surfaced rather than - * silently skipped by an over-strict character class. - * - * Returns `[{ name, raw }]` per call. Filtering by namespace (gsd- vs gsd:) - * happens at the call site so the extractor stays neutral. - */ -function extractSkillCalls(content) { - // regex-free HTML-comment stripper (CodeQL: avoid incomplete-multi-character-sanitization) - let stripped = ''; - { - let rest = content; - let idx; - while ((idx = rest.indexOf('', idx + 4); - if (end === -1) { rest = ''; break; } - rest = rest.slice(end + 3); - } - stripped += rest; - } - const calls = []; - // Body class excludes backslash so the extractor doesn't include an - // escape character that precedes the closing quote in embedded examples - // (e.g. `Skill(skill=\"gsd-plan-phase\", …)` written inside a string - // context). A trailing `\` is permitted on the closing-quote side via the - // optional `\\?` so both `\"` and `"` close the value cleanly. - const argRe = /^\s*skill\s*=\s*\\?(['"])([^'"\\]+)\\?\1/i; - let i = 0; - while (i < stripped.length) { - const open = stripped.indexOf('Skill(', i); - if (open === -1) break; - const close = stripped.indexOf(')', open); - if (close === -1) break; - const body = stripped.slice(open + 'Skill('.length, close); - const match = body.match(argRe); - if (match) calls.push({ name: match[2], raw: stripped.slice(open, close + 1) }); - i = close + 1; - } - return calls; -} - -function extractSkillNamesHyphen(content) { - return new Set( - extractSkillCalls(content) - .map((c) => c.name) - .filter((n) => n.startsWith('gsd-')), - ); -} - -function extractSkillNamesColon(content) { - return new Set( - extractSkillCalls(content) - .map((c) => c.name) - .filter((n) => n.startsWith('gsd:')), - ); -} - -describe('skill frontmatter name parity (#2643 / #2808)', () => { - test('skillFrontmatterName helper emits hyphen form (#2808)', () => { - assert.strictEqual(typeof skillFrontmatterName, 'function'); - assert.strictEqual(skillFrontmatterName('gsd-execute-phase'), 'gsd-execute-phase'); - assert.strictEqual(skillFrontmatterName('gsd-plan-phase'), 'gsd-plan-phase'); - assert.strictEqual(skillFrontmatterName('gsd-next'), 'gsd-next'); - }); - - test('convertClaudeCommandToClaudeSkill emits name: gsd- (hyphen)', () => { - const input = '---\nname: old\ndescription: test\n---\n\nBody.'; - const result = convertClaudeCommandToClaudeSkill(input, 'gsd-execute-phase'); - // Parse the frontmatter block structurally: extract the name: field value. - const frontmatterMatch = result.match(/^---\r?\n([\s\S]*?)\r?\n---/); - assert.ok(frontmatterMatch, 'output must have a frontmatter block delimited by ---'); - const frontmatterLines = frontmatterMatch[1].split(/\r?\n/); - const nameEntry = frontmatterLines.find((l) => l.startsWith('name:')); - assert.ok(nameEntry, 'frontmatter must contain a name: field'); - const nameValue = nameEntry.replace(/^name:\s*/, '').trim(); - assert.strictEqual( - nameValue, - 'gsd-execute-phase', - `frontmatter name: must be 'gsd-execute-phase' (hyphen form), got '${nameValue}'` - ); - }); - - test('no workflow uses deprecated Skill(skill="gsd:") colon form', () => { - const workflowFiles = collectFiles(WORKFLOWS_DIR); - const colonRefs = []; - for (const f of workflowFiles) { - const src = fs.readFileSync(f, 'utf-8'); - for (const n of extractSkillNamesColon(src)) { - colonRefs.push(path.basename(f) + ': ' + n); - } - } - assert.deepStrictEqual( - colonRefs, - [], - 'deprecated colon-form Skill() calls found (update to hyphen): ' + colonRefs.join(', ') - ); - }); - - test('every workflow Skill(skill="gsd-") resolves to an emitted skill name', () => { - const workflowFiles = collectFiles(WORKFLOWS_DIR); - const referenced = new Set(); - const templatedSkipped = []; - for (const f of workflowFiles) { - const src = fs.readFileSync(f, 'utf-8'); - for (const n of extractSkillNamesHyphen(src)) { - // Skip template expressions (e.g. `gsd-${ref.skill}`): these are - // capability-dispatched — the skill stem is resolved at runtime from - // the `loop render-hooks` registry output (ADR-857 phase 6), so there - // is no single literal skill file to validate against here. - // The capability registry's own validateStep gate (gen-capability-registry.cjs) - // is responsible for ensuring each `steps[].ref.skill` corresponds to a - // real skill declared in the capability's `skills` array. - if (n.includes('${')) { - templatedSkipped.push(path.basename(f) + ': ' + n); - } else { - referenced.add(n); - } - } - } - assert.ok( - referenced.size > 0, - `expected at least one literal Skill(skill="gsd-") reference in workflows under ${WORKFLOWS_DIR}` - ); - - const emitted = new Set(); - const cmdFiles = fs.readdirSync(COMMANDS_DIR).filter(f => f.endsWith('.md')); - for (const cmd of cmdFiles) { - const base = cmd.replace(/\.md$/, ''); - const skillDirName = 'gsd-' + base; - const src = fs.readFileSync(path.join(COMMANDS_DIR, cmd), 'utf-8'); - const out = convertClaudeCommandToClaudeSkill(src, skillDirName); - const m = out.match(/^---\r?\nname:\s*(.+)$/m); - if (m) emitted.add(m[1].trim()); - } - - const missing = []; - for (const r of referenced) if (!emitted.has(r)) missing.push(r); - assert.deepStrictEqual( - missing, - [], - 'workflow refs not emitted as skill names: ' + missing.join(', '), - ); - // Informational: report how many templated dispatches were intentionally skipped. - // (Templated names are validated by the capability registry, not statically here.) - if (templatedSkipped.length > 0) { - // Not a failure — just a note for test output transparency. - // Use a diagnostic comment: node:test does not have a skip-within-test API. - } - }); -}); - }); -} - - -// ──────────────────────────────────────────────────────────────────────── -// Folded from tests/enh-778-cross-runtime-command-enrichment.test.cjs — consolidation epic #1969 (B1 #1970) -// ──────────────────────────────────────────────────────────────────────── -{ - const { describe: __foldDescribe } = require('node:test'); - __foldDescribe("folded:enh-778-cross-runtime-command-enrichment (consolidation epic #1969 B1 #1970)", () => { -// allow-test-rule: source-text-is-the-product (see #778) -// Reads .md/SKILL.md/.toml product files whose deployed text IS what the -// runtime loads — testing text content tests the deployed contract. - -/** - * GSD Tools Tests — #778 cross-runtime command enrichment. - * - * Qwen Code skills: numeric `priority` field (higher sorts earlier in the - * /skills TUI listing per the Qwen skills spec). Scoped to runtime='qwen'. - * - * The OpenCode sub-feature (per-command model/agent/subtask/variant) is - * intentionally NOT implemented — see PR description: `model` reintroduces the - * #1156 ProviderModelNotFoundError regression for non-Anthropic OpenCode users, - * `subtask`/`agent` change execution semantics for GSD's interactive commands, - * and `variant` is not in the OpenCode command schema. - * - * #1928: the Gemini custom-command TOML sub-feature ($ARGUMENTS → {{args}} - * interpolation, !{cat .planning/STATE.md} live-state injection) was removed - * along with the gemini runtime (Google sunset Gemini CLI 2026-06-18). - * convertClaudeToGeminiMarkdown no longer exists in bin/install.js. - * - * Uses node:test and node:assert (NOT Jest). - */ - -process.env.GSD_TEST_MODE = '1'; - -const { test, describe } = require('node:test'); -const assert = require('node:assert/strict'); - -const { - convertClaudeCommandToClaudeSkill, -} = require('../bin/install.js'); - -// ─── (b) Qwen Code: priority ordering ─────────────────────────────────────── - -describe('#778 (b) Qwen skills priority', () => { - const mk = (name, desc, body) => - ['---', `name: gsd:${name}`, `description: ${desc}`, '---', '', body].join('\n'); - - test('emits numeric priority for a core-loop command (runtime=qwen)', () => { - const result = convertClaudeCommandToClaudeSkill( - mk('plan-phase', 'Plan a phase', 'Body.'), - 'gsd-plan-phase', - 'qwen', - [] - ); - const m = result.match(/^priority:\s*(\d+)\s*$/m); - assert.ok(m, 'priority field present for gsd-plan-phase'); - assert.equal(Number(m[1]) > 0, true, 'priority is a positive number'); - }); - - test('core loop ranks higher than mid-tier (higher = earlier per spec)', () => { - const np = convertClaudeCommandToClaudeSkill( - mk('new-project', 'Start a project', 'Body.'), 'gsd-new-project', 'qwen', [] - ).match(/^priority:\s*(\d+)/m); - const help = convertClaudeCommandToClaudeSkill( - mk('help', 'Help', 'Body.'), 'gsd-help', 'qwen', [] - ).match(/^priority:\s*(\d+)/m); - assert.ok(np && help, 'both core and mid-tier get a priority'); - assert.ok( - Number(np[1]) > Number(help[1]), - 'new-project (core) sorts earlier than help (utility) — higher value' - ); - }); - - test('utility command NOT in the priority map gets no priority field', () => { - const result = convertClaudeCommandToClaudeSkill( - mk('stats', 'Show stats', 'Body.'), 'gsd-stats', 'qwen', [] - ); - assert.ok(!/^priority:/m.test(result), 'no priority emitted for unmapped utility'); - }); - - test('does NOT emit priority for non-qwen runtimes (scoped to qwen)', () => { - for (const rt of [null, 'claude', 'hermes']) { - const result = convertClaudeCommandToClaudeSkill( - mk('plan-phase', 'Plan a phase', 'Body.'), 'gsd-plan-phase', rt, [] - ); - assert.ok(!/^priority:/m.test(result), `no priority for runtime=${rt}`); - } - }); -}); - - }); -} - - -// ──────────────────────────────────────────────────────────────────────── -// Folded from tests/enh-769-context-fork-effort.install.test.cjs — consolidation epic #1969 (B1 #1970) -// ──────────────────────────────────────────────────────────────────────── -{ - const { describe: __foldDescribe } = require('node:test'); - __foldDescribe("folded:enh-769-context-fork-effort.install (consolidation epic #1969 B1 #1970)", () => { -// allow-test-rule: integration-test-input (see #769) -// Exercises install() as a black-box by inspecting produced SKILL.md output -// in a temp dir. Source command .md files are inputs whose installed -// transformation is asserted — not inspected for string presence. - -/** - * #769 — effort: frontmatter on heavy workflow skills. - * #921 — spawning orchestrators must NOT carry context: fork. - * - * Context: context:fork was added by #769 to protect context budget, but - * plan-phase, execute-phase, and autonomous are spawning orchestrators — a - * forked subagent has no Agent/Task tool, breaking their core function. - * effort: max is preserved; context: fork is removed from these three. - * The converter still passes context: fork through if a source file has it - * (for any future leaf skill that legitimately needs isolation). - * - * Verifies: - * 1. Source commands/gsd/autonomous.md does NOT have context: fork, has effort: max - * 2. Source commands/gsd/execute-phase.md does NOT have context: fork, has effort: max - * 3. Source commands/gsd/plan-phase.md does NOT have context: fork, has effort: max - * 4. Source commands/gsd/progress.md has effort: low - * 5. Source commands/gsd/stats.md has effort: low - * 6. Claude global install: SKILL.md for autonomous has effort: max, NOT context: fork - * 7. Claude global install: SKILL.md for execute-phase has effort: max, NOT context: fork - * 8. Claude global install: SKILL.md for plan-phase has effort: max, NOT context: fork - * 9. Claude global install: SKILL.md for progress has effort: low - * 10. Claude global install: SKILL.md for stats has effort: low - * 11. convertClaudeCommandToClaudeSkill still passes context: fork through (for non-orchestrator skills) - * 12. convertClaudeCommandToClaudeSkill emits portable effort: field values - */ - -'use strict'; - -process.env.GSD_TEST_MODE = '1'; - -const { describe, test, beforeEach, afterEach } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('node:fs'); -const path = require('node:path'); -const os = require('node:os'); - -const { install, convertClaudeCommandToClaudeSkill } = require('../bin/install.js'); -const { cleanup } = require('./helpers.cjs'); - -// #924: Claude global install is now FLAT — concrete skills are at the top level. -// flatSkillPath returns: /gsd-/SKILL.md -function flatSkillPath(skillsRoot, stem) { - return path.join(skillsRoot, `gsd-${stem}`, 'SKILL.md'); -} - -const REPO_ROOT = path.resolve(__dirname, '..'); -const SOURCE_COMMANDS_DIR = path.join(REPO_ROOT, 'commands', 'gsd'); - -// ─── helpers ────────────────────────────────────────────────────────────────── - -function makeTmpDir(prefix) { - return fs.mkdtempSync(path.join(os.tmpdir(), prefix)); -} - -function readFrontmatter(mdPath) { - const content = fs.readFileSync(mdPath, 'utf8'); - if (!content.startsWith('---')) return ''; - const end = content.indexOf('---', 3); - if (end === -1) return ''; - return content.substring(3, end); -} - -/** - * Run a global install for Claude, redirecting its home dir to tmpHome. - * Returns the tmpHome for inspection. - */ -function runClaudeGlobalInstall(claudeHome) { - const isolatedHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-769-home-')); - - const prevCwd = process.cwd(); - const prevClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR; - const prevHome = process.env.HOME; - const prevUserProfile = process.env.USERPROFILE; - const prevSkipStale = process.env.GSD_SKIP_STALE_SDK_CHECK; - - process.env.CLAUDE_CONFIG_DIR = claudeHome; - process.env.HOME = isolatedHome; - process.env.USERPROFILE = isolatedHome; - process.env.GSD_SKIP_STALE_SDK_CHECK = '1'; - process.chdir(REPO_ROOT); - - try { - install(true, 'claude'); - } finally { - process.chdir(prevCwd); - if (prevClaudeConfigDir === undefined) delete process.env.CLAUDE_CONFIG_DIR; - else process.env.CLAUDE_CONFIG_DIR = prevClaudeConfigDir; - if (prevHome === undefined) delete process.env.HOME; - else process.env.HOME = prevHome; - if (prevUserProfile === undefined) delete process.env.USERPROFILE; - else process.env.USERPROFILE = prevUserProfile; - if (prevSkipStale === undefined) delete process.env.GSD_SKIP_STALE_SDK_CHECK; - else process.env.GSD_SKIP_STALE_SDK_CHECK = prevSkipStale; - cleanup(isolatedHome); - } - - return claudeHome; -} - -// ─── describe 1: Source command files have correct frontmatter ──────────────── - -// #921/#922: spawning orchestrators must NOT carry context: fork — a forked -// subagent has no Agent/Task tool, making it impossible for orchestrators to -// spawn their required subagents. context: fork is appropriate only for leaf -// skills that do not themselves dispatch agents. effort: max is portable across Claude Code models. -describe('#769/#921/#1319 source commands: spawning orchestrators have effort: max but NOT context: fork', () => { - test('commands/gsd/autonomous.md does NOT have context: fork (#921)', () => { - const fm = readFrontmatter(path.join(SOURCE_COMMANDS_DIR, 'autonomous.md')); - assert.doesNotMatch(fm, /^context:[ \t]*fork$/m, - `autonomous.md is a spawning orchestrator and must NOT have context: fork (#921)\nActual:\n${fm}`); - }); - - test('commands/gsd/autonomous.md has effort: max (#1319)', () => { - const fm = readFrontmatter(path.join(SOURCE_COMMANDS_DIR, 'autonomous.md')); - assert.match(fm, /^effort:[ \t]*max$/m, - `autonomous.md frontmatter must have effort: max\nActual:\n${fm}`); - assert.doesNotMatch(fm, /^effort:[ \t]*xhigh$/m, - `autonomous.md frontmatter must not have rejected effort: xhigh (#1319)\nActual:\n${fm}`); - }); - - test('commands/gsd/execute-phase.md does NOT have context: fork (#921)', () => { - const fm = readFrontmatter(path.join(SOURCE_COMMANDS_DIR, 'execute-phase.md')); - assert.doesNotMatch(fm, /^context:[ \t]*fork$/m, - `execute-phase.md is a spawning orchestrator and must NOT have context: fork (#921)\nActual:\n${fm}`); - }); - - test('commands/gsd/execute-phase.md has effort: max (#1319)', () => { - const fm = readFrontmatter(path.join(SOURCE_COMMANDS_DIR, 'execute-phase.md')); - assert.match(fm, /^effort:[ \t]*max$/m, - `execute-phase.md frontmatter must have effort: max\nActual:\n${fm}`); - assert.doesNotMatch(fm, /^effort:[ \t]*xhigh$/m, - `execute-phase.md frontmatter must not have rejected effort: xhigh (#1319)\nActual:\n${fm}`); - }); - - test('commands/gsd/plan-phase.md does NOT have context: fork (#921)', () => { - const fm = readFrontmatter(path.join(SOURCE_COMMANDS_DIR, 'plan-phase.md')); - assert.doesNotMatch(fm, /^context:[ \t]*fork$/m, - `plan-phase.md is a spawning orchestrator and must NOT have context: fork (#921)\nActual:\n${fm}`); - }); - - test('commands/gsd/plan-phase.md has effort: max (#1319)', () => { - const fm = readFrontmatter(path.join(SOURCE_COMMANDS_DIR, 'plan-phase.md')); - assert.match(fm, /^effort:[ \t]*max$/m, - `plan-phase.md frontmatter must have effort: max\nActual:\n${fm}`); - assert.doesNotMatch(fm, /^effort:[ \t]*xhigh$/m, - `plan-phase.md frontmatter must not have rejected effort: xhigh (#1319)\nActual:\n${fm}`); - }); -}); - -describe('#769 source commands: quick-status skills have effort: low', () => { - test('commands/gsd/progress.md has effort: low', () => { - const fm = readFrontmatter(path.join(SOURCE_COMMANDS_DIR, 'progress.md')); - assert.match(fm, /^effort:[ \t]*low$/m, - `progress.md frontmatter must have effort: low\nActual:\n${fm}`); - }); - - test('commands/gsd/stats.md has effort: low', () => { - const fm = readFrontmatter(path.join(SOURCE_COMMANDS_DIR, 'stats.md')); - assert.match(fm, /^effort:[ \t]*low$/m, - `stats.md frontmatter must have effort: low\nActual:\n${fm}`); - }); -}); - -// ─── describe 2: convertClaudeCommandToClaudeSkill preserves new fields ─────── - -describe('#769/#1319 convertClaudeCommandToClaudeSkill: preserves context and emits portable effort fields', () => { - test('preserves context: fork in emitted SKILL.md frontmatter', () => { - const input = [ - '---', - 'name: gsd:test-heavy', - 'description: Test heavy skill', - 'context: fork', - 'effort: xhigh', - 'allowed-tools:', - ' - Read', - ' - Bash', - '---', - '', - 'Heavy skill body.', - ].join('\n'); - - const result = convertClaudeCommandToClaudeSkill(input, 'test-heavy'); - const end = result.indexOf('---', 3); - const fm = result.substring(3, end); - - assert.match(fm, /^context:[ \t]*fork$/m, - `SKILL.md frontmatter must include context: fork\nActual frontmatter:\n${fm}`); - }); - - test('normalizes effort: xhigh to effort: max in emitted SKILL.md frontmatter (#1319)', () => { - const input = [ - '---', - 'name: gsd:test-heavy', - 'description: Test heavy skill', - 'context: fork', - 'effort: xhigh', - 'allowed-tools:', - ' - Read', - ' - Bash', - '---', - '', - 'Heavy skill body.', - ].join('\n'); - - const result = convertClaudeCommandToClaudeSkill(input, 'test-heavy'); - const end = result.indexOf('---', 3); - const fm = result.substring(3, end); - - assert.match(fm, /^effort:[ \t]*max$/m, - `SKILL.md frontmatter must include portable effort: max\nActual frontmatter:\n${fm}`); - assert.doesNotMatch(fm, /^effort:[ \t]*xhigh$/m, - `SKILL.md frontmatter must not include rejected effort: xhigh (#1319)\nActual frontmatter:\n${fm}`); - }); - - test('preserves effort: low in emitted SKILL.md frontmatter', () => { - const input = [ - '---', - 'name: gsd:test-light', - 'description: Test light skill', - 'effort: low', - 'allowed-tools:', - ' - Read', - '---', - '', - 'Light skill body.', - ].join('\n'); - - const result = convertClaudeCommandToClaudeSkill(input, 'test-light'); - const end = result.indexOf('---', 3); - const fm = result.substring(3, end); - - assert.match(fm, /^effort:[ \t]*low$/m, - `SKILL.md frontmatter must include effort: low\nActual frontmatter:\n${fm}`); - }); - - test('does NOT emit context: or effort: when absent from source', () => { - const input = [ - '---', - 'name: gsd:test-plain', - 'description: Plain skill without context or effort', - 'allowed-tools:', - ' - Read', - '---', - '', - 'Plain skill body.', - ].join('\n'); - - const result = convertClaudeCommandToClaudeSkill(input, 'test-plain'); - const end = result.indexOf('---', 3); - const fm = result.substring(3, end); - - assert.doesNotMatch(fm, /^context:/m, - `SKILL.md must not emit context: when absent from source\nActual:\n${fm}`); - assert.doesNotMatch(fm, /^effort:/m, - `SKILL.md must not emit effort: when absent from source\nActual:\n${fm}`); - }); -}); - -// ─── describe 3: Claude global install — SKILL.md files include new fields ──── - -// #921/#922: after install, spawning orchestrators must NOT carry context: fork -// in their emitted SKILL.md. #1319: heavyweight skills must use portable max effort. -describe('#769/#921/#1319 Claude global install: spawning-orchestrator SKILL.md files have effort: max but NOT context: fork', () => { - let tmpDir; - let claudeHome; - - beforeEach(() => { - tmpDir = makeTmpDir('gsd-769-claude-'); - claudeHome = path.join(tmpDir, 'claude-home'); - fs.mkdirSync(claudeHome, { recursive: true }); - }); - - afterEach(() => { - cleanup(tmpDir); - }); - - test('gsd-autonomous SKILL.md does NOT have context: fork after global install (#921)', () => { - runClaudeGlobalInstall(claudeHome); - const skillPath = flatSkillPath(path.join(claudeHome, 'skills'),'autonomous'); - const fm = readFrontmatter(skillPath); - assert.doesNotMatch(fm, /^context:[ \t]*fork$/m, - `gsd-autonomous is a spawning orchestrator; its SKILL.md must NOT have context: fork (#921)\nActual:\n${fm}`); - }); - - test('gsd-autonomous SKILL.md has effort: max after global install (#1319)', () => { - runClaudeGlobalInstall(claudeHome); - const skillPath = flatSkillPath(path.join(claudeHome, 'skills'),'autonomous'); - const fm = readFrontmatter(skillPath); - assert.match(fm, /^effort:[ \t]*max$/m, - `gsd-autonomous SKILL.md must have effort: max\nActual:\n${fm}`); - assert.doesNotMatch(fm, /^effort:[ \t]*xhigh$/m, - `gsd-autonomous SKILL.md must not have rejected effort: xhigh (#1319)\nActual:\n${fm}`); - }); - - test('gsd-execute-phase SKILL.md does NOT have context: fork after global install (#921)', () => { - runClaudeGlobalInstall(claudeHome); - const skillPath = flatSkillPath(path.join(claudeHome, 'skills'),'execute-phase'); - const fm = readFrontmatter(skillPath); - assert.doesNotMatch(fm, /^context:[ \t]*fork$/m, - `gsd-execute-phase is a spawning orchestrator; its SKILL.md must NOT have context: fork (#921)\nActual:\n${fm}`); - }); - - test('gsd-execute-phase SKILL.md has effort: max after global install (#1319)', () => { - runClaudeGlobalInstall(claudeHome); - const skillPath = flatSkillPath(path.join(claudeHome, 'skills'),'execute-phase'); - const fm = readFrontmatter(skillPath); - assert.match(fm, /^effort:[ \t]*max$/m, - `gsd-execute-phase SKILL.md must have effort: max\nActual:\n${fm}`); - assert.doesNotMatch(fm, /^effort:[ \t]*xhigh$/m, - `gsd-execute-phase SKILL.md must not have rejected effort: xhigh (#1319)\nActual:\n${fm}`); - }); - - test('gsd-plan-phase SKILL.md does NOT have context: fork after global install (#921)', () => { - runClaudeGlobalInstall(claudeHome); - const skillPath = flatSkillPath(path.join(claudeHome, 'skills'),'plan-phase'); - const fm = readFrontmatter(skillPath); - assert.doesNotMatch(fm, /^context:[ \t]*fork$/m, - `gsd-plan-phase is a spawning orchestrator; its SKILL.md must NOT have context: fork (#921)\nActual:\n${fm}`); - }); - - test('gsd-plan-phase SKILL.md has effort: max after global install (#1319)', () => { - runClaudeGlobalInstall(claudeHome); - const skillPath = flatSkillPath(path.join(claudeHome, 'skills'),'plan-phase'); - const fm = readFrontmatter(skillPath); - assert.match(fm, /^effort:[ \t]*max$/m, - `gsd-plan-phase SKILL.md must have effort: max\nActual:\n${fm}`); - assert.doesNotMatch(fm, /^effort:[ \t]*xhigh$/m, - `gsd-plan-phase SKILL.md must not have rejected effort: xhigh (#1319)\nActual:\n${fm}`); - }); - - test('gsd-progress SKILL.md has effort: low after global install', () => { - runClaudeGlobalInstall(claudeHome); - const skillPath = flatSkillPath(path.join(claudeHome, 'skills'),'progress'); - const fm = readFrontmatter(skillPath); - assert.match(fm, /^effort:[ \t]*low$/m, - `gsd-progress SKILL.md must have effort: low\nActual:\n${fm}`); - }); - - test('gsd-stats SKILL.md has effort: low after global install', () => { - runClaudeGlobalInstall(claudeHome); - const skillPath = flatSkillPath(path.join(claudeHome, 'skills'),'stats'); - const fm = readFrontmatter(skillPath); - assert.match(fm, /^effort:[ \t]*low$/m, - `gsd-stats SKILL.md must have effort: low\nActual:\n${fm}`); - }); -}); - }); -} - - -// ──────────────────────────────────────────────────────────────────────── -// Folded from tests/feat-443-effort-install-wiring.install.test.cjs — consolidation epic #1969 (B1 #1970) -// ──────────────────────────────────────────────────────────────────────── -{ - const { describe: __foldDescribe } = require('node:test'); - __foldDescribe("folded:feat-443-effort-install-wiring.install (consolidation epic #1969 B1 #1970)", () => { -// allow-test-rule: integration-test-input (see #443) -// Exercises install() + generateCodexAgentToml() as a black-box by inspecting -// produced output files in temp dirs. Source agent .md files are inputs whose -// installed transformation is asserted — not inspected for string presence. - -/** - * #443 — Effort per-runtime wiring at install time. - * - * Verifies: - * 1. Claude global install injects `effort:` into agent .md frontmatter. - * 2. Codex inherited-model installs omit `model_reasoning_effort` so model - * and effort are not partially pinned (#838). - * 3. Config-driven proof: effort.agent_overrides wins over tier defaults - * for Claude .md and for Codex .toml when runtime:"codex" pins a model. - * 4. Source agents/gsd-planner.md has NO effort: key (injection is - * install-only, source markdown carries no effort: key). - * - * #1928: the gemini runtime (and its "Gemini install does NOT inject effort:" - * coverage) was removed — Google sunset Gemini CLI 2026-06-18. - */ - -'use strict'; - -process.env.GSD_TEST_MODE = '1'; - -const { describe, test, beforeEach, afterEach } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('node:fs'); -const path = require('node:path'); -const os = require('node:os'); - -const { install } = require('../bin/install.js'); -const { cleanup } = require('./helpers.cjs'); - -const REPO_ROOT = path.resolve(__dirname, '..'); -const SOURCE_AGENTS_DIR = path.join(REPO_ROOT, 'agents'); - -// ─── helpers ───────────────────────────────────────────────────────────────── - -function makeTmpDir(prefix) { - return fs.mkdtempSync(path.join(os.tmpdir(), prefix)); -} - -function readFrontmatter(mdPath) { - const content = fs.readFileSync(mdPath, 'utf8'); - if (!content.startsWith('---')) return ''; - const end = content.indexOf('---', 3); - if (end === -1) return ''; - return content.substring(3, end); -} - -/** - * Run a global install for the given runtime, redirecting its home dir to - * tmpHome. Returns the tmpHome for inspection. - * - * Env-var redirection: - * claude → CLAUDE_CONFIG_DIR - * codex → CODEX_HOME - * - * HOME is also redirected to an isolated temp dir for the duration of the - * install call. This prevents any install.js code that uses os.homedir() - * directly (e.g. ~/.cache/gsd update-check deletion, ~/.gsd/defaults.json - * reads, stale-SDK npm subprocess writes to ~/.npm) from touching the real - * HOME and polluting the test environment for other concurrently-running - * test files (e.g. runtime-launcher-parity test (D) checks that - * $HOME/.claude/gsd-core/bin/gsd-tools.cjs is absent). - * - * GSD_SKIP_STALE_SDK_CHECK=1 is set to suppress the `npm ls -g` subprocess - * that the installer spawns for global installs — that subprocess is slow, - * writes to ~/.npm cache, and is irrelevant to effort-wiring assertions. - * - * The working directory is set to REPO_ROOT so install() can find the source - * agents/. For config-driven tests, place tmpHome inside the project dir - * so that readGsdEffectiveEffortConfig(targetDir) can walk up from tmpHome - * and find .planning/config.json. - */ -function runGlobalInstall(runtime, tmpHome) { - const envVarMap = { - claude: 'CLAUDE_CONFIG_DIR', - codex: 'CODEX_HOME', - }; - const envVar = envVarMap[runtime]; - if (!envVar) throw new Error(`Unsupported runtime in test: ${runtime}`); - - // Isolate HOME to a fresh temp dir so install.js code that calls - // os.homedir() (cache deletion, defaults.json reads, npm subprocess) - // never touches the real $HOME/.claude / $HOME/.cache / $HOME/.gsd. - const isolatedHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-443-home-')); - - const prev = process.env[envVar]; - const prevCwd = process.cwd(); - const prevHome = process.env.HOME; - const prevUserProfile = process.env.USERPROFILE; - const prevSkipStale = process.env.GSD_SKIP_STALE_SDK_CHECK; - - process.env[envVar] = tmpHome; - process.env.HOME = isolatedHome; - process.env.USERPROFILE = isolatedHome; - process.env.GSD_SKIP_STALE_SDK_CHECK = '1'; - process.chdir(REPO_ROOT); - - try { - install(true, runtime); - } finally { - process.chdir(prevCwd); - if (prev === undefined) delete process.env[envVar]; - else process.env[envVar] = prev; - if (prevHome === undefined) delete process.env.HOME; - else process.env.HOME = prevHome; - if (prevUserProfile === undefined) delete process.env.USERPROFILE; - else process.env.USERPROFILE = prevUserProfile; - if (prevSkipStale === undefined) delete process.env.GSD_SKIP_STALE_SDK_CHECK; - else process.env.GSD_SKIP_STALE_SDK_CHECK = prevSkipStale; - // Clean up the isolated HOME dir - cleanup(isolatedHome); - } - - return tmpHome; -} - -// ─── Tier default expectations ──────────────────────────────────────────────── -// light → low, standard → high, heavy → xhigh (catalog defaults) -// gsd-planner: heavy → xhigh -// gsd-codebase-mapper: light → low -// gsd-executor: standard → high - -// ─── describe 1: Claude install injects effort: ─────────────────────────────── - -describe('#443 Claude install: effort: injected into frontmatter', () => { - let tmpDir; - let claudeHome; - - beforeEach(() => { - tmpDir = makeTmpDir('gsd-443-claude-'); - claudeHome = path.join(tmpDir, 'claude-home'); - fs.mkdirSync(claudeHome, { recursive: true }); - }); - - afterEach(() => { - cleanup(tmpDir); - }); - - test('gsd-planner.md contains effort: xhigh (heavy tier default)', () => { - runGlobalInstall('claude', claudeHome); - const fm = readFrontmatter(path.join(claudeHome, 'agents', 'gsd-planner.md')); - assert.match(fm, /^effort:\s*xhigh$/m, - `gsd-planner frontmatter should have effort: xhigh\nActual:\n${fm}`); - }); - - test('gsd-codebase-mapper.md contains effort: low (light tier default)', () => { - runGlobalInstall('claude', claudeHome); - const fm = readFrontmatter(path.join(claudeHome, 'agents', 'gsd-codebase-mapper.md')); - assert.match(fm, /^effort:\s*low$/m, - `gsd-codebase-mapper frontmatter should have effort: low\nActual:\n${fm}`); - }); - - test('gsd-executor.md contains effort: high (standard tier default)', () => { - runGlobalInstall('claude', claudeHome); - const fm = readFrontmatter(path.join(claudeHome, 'agents', 'gsd-executor.md')); - assert.match(fm, /^effort:\s*high$/m, - `gsd-executor frontmatter should have effort: high\nActual:\n${fm}`); - }); -}); - -// ─── describe 3: Codex inherited-model install omits model_reasoning_effort ── - -describe('#838 Codex install: inherited model omits model_reasoning_effort', () => { - let tmpDir; - let codexHome; - - beforeEach(() => { - tmpDir = makeTmpDir('gsd-443-codex-'); - codexHome = path.join(tmpDir, 'codex-home'); - fs.mkdirSync(codexHome, { recursive: true }); - }); - - afterEach(() => { - cleanup(tmpDir); - }); - - test('gsd-planner.toml omits both model and model_reasoning_effort when model is inherited', () => { - runGlobalInstall('codex', codexHome); - const tomlContent = fs.readFileSync( - path.join(codexHome, 'agents', 'gsd-planner.toml'), 'utf8' - ); - assert.doesNotMatch(tomlContent, /^model\s*=/m, - `gsd-planner.toml should omit model when inheriting Codex chat model\nActual:\n${tomlContent.slice(0, 500)}`); - assert.doesNotMatch(tomlContent, /^model_reasoning_effort\s*=/m, - `gsd-planner.toml should omit model_reasoning_effort when model is inherited\nActual:\n${tomlContent.slice(0, 500)}`); - }); -}); - -// ─── describe 4: Config-driven proof ───────────────────────────────────────── -// -// The runtime home dir must be INSIDE (or a sibling of) the project root so -// that readGsdEffectiveEffortConfig(targetDir) can walk up from the runtime -// home and find .planning/config.json. We put .claude/ and .codex/ as siblings -// of .planning/ inside the project dir — this is the natural local-install shape. - -describe('#443 Config-driven: effort.agent_overrides drives install-time effort', () => { - let tmpDir; - let claudeHome; - let codexHome; - - beforeEach(() => { - // Layout: tmpDir/project/ <-- project root (cwd for install) - // .planning/config.json - // .claude/ <-- claudeHome (CLAUDE_CONFIG_DIR) - // .codex/ <-- codexHome (CODEX_HOME) - tmpDir = makeTmpDir('gsd-443-cfg-'); - const projectDir = path.join(tmpDir, 'project'); - claudeHome = path.join(projectDir, '.claude'); - codexHome = path.join(projectDir, '.codex'); - - fs.mkdirSync(claudeHome, { recursive: true }); - fs.mkdirSync(codexHome, { recursive: true }); - fs.mkdirSync(path.join(projectDir, '.planning'), { recursive: true }); - - // Write a project config with effort.agent_overrides overriding gsd-planner to 'low'. - // runtime:"codex" pins a Codex-native model, so emitting model_reasoning_effort - // remains valid under the #838 model/effort coupling rule. - const config = { - runtime: 'codex', - effort: { - agent_overrides: { - 'gsd-planner': 'low', - }, - }, - }; - fs.writeFileSync( - path.join(projectDir, '.planning', 'config.json'), - JSON.stringify(config, null, 2) - ); - }); - - afterEach(() => { - cleanup(tmpDir); - }); - - test('Claude .md gets effort: low when agent_overrides.gsd-planner=low', () => { - // projectDir is the cwd for install — chdir handled inside runGlobalInstall. - // claudeHome is inside projectDir, so walking up from claudeHome finds .planning/config.json. - runGlobalInstall('claude', claudeHome); - const fm = readFrontmatter(path.join(claudeHome, 'agents', 'gsd-planner.md')); - assert.match(fm, /^effort:\s*low$/m, - `gsd-planner should have effort: low from config override\nActual:\n${fm}`); - }); - - test('Codex .toml gets model_reasoning_effort = "low" when agent_overrides.gsd-planner=low', () => { - runGlobalInstall('codex', codexHome); - const tomlContent = fs.readFileSync( - path.join(codexHome, 'agents', 'gsd-planner.toml'), 'utf8' - ); - assert.match(tomlContent, /^model\s*=\s*"gpt-5.5"$/m, - `gsd-planner.toml should pin Codex model when runtime:"codex" is configured\nActual:\n${tomlContent.slice(0, 500)}`); - assert.match(tomlContent, /^model_reasoning_effort\s*=\s*"low"$/m, - `gsd-planner.toml should have model_reasoning_effort = "low" from config override\nActual:\n${tomlContent.slice(0, 500)}`); - }); - - test('Codex .toml clamps effort max → xhigh when agent_overrides.gsd-planner=max', () => { - const projectDir = path.dirname(codexHome); - // Overwrite config with max override - const config = { - runtime: 'codex', - effort: { - agent_overrides: { - 'gsd-planner': 'max', - }, - }, - }; - fs.writeFileSync( - path.join(projectDir, '.planning', 'config.json'), - JSON.stringify(config, null, 2) - ); - - runGlobalInstall('codex', codexHome); - const tomlContent = fs.readFileSync( - path.join(codexHome, 'agents', 'gsd-planner.toml'), 'utf8' - ); - assert.match(tomlContent, /^model\s*=\s*"gpt-5.5"$/m, - `gsd-planner.toml should pin Codex model when runtime:"codex" is configured\nActual:\n${tomlContent.slice(0, 500)}`); - // Codex does not support 'max' → clamped to 'xhigh' - assert.match(tomlContent, /^model_reasoning_effort\s*=\s*"xhigh"$/m, - `gsd-planner.toml should clamp max → xhigh for Codex\nActual:\n${tomlContent.slice(0, 500)}`); - assert.doesNotMatch(tomlContent, /model_reasoning_effort\s*=\s*"max"/, - 'Codex .toml must never contain model_reasoning_effort = "max"'); - }); -}); - -// ─── describe 5b: Invalid effort tokens fall through (Codex adversarial finding #2) ─ -// -// These tests FAIL before the fix: resolveInstallTimeEffort returns the raw -// invalid string without validating it against VALID_EFFORTS. - -describe('#443 resolveInstallTimeEffort: invalid tokens fall through to valid effort', () => { - let tmpDir; - let claudeHome; - let codexHome; - - beforeEach(() => { - // Layout: tmpDir/project/ <-- project root - // .planning/config.json - // .claude/ <-- claudeHome - // .codex/ <-- codexHome - tmpDir = makeTmpDir('gsd-443-invalid-effort-'); - const projectDir = path.join(tmpDir, 'project'); - claudeHome = path.join(projectDir, '.claude'); - codexHome = path.join(projectDir, '.codex'); - - fs.mkdirSync(claudeHome, { recursive: true }); - fs.mkdirSync(codexHome, { recursive: true }); - fs.mkdirSync(path.join(projectDir, '.planning'), { recursive: true }); - }); - - afterEach(() => { - cleanup(tmpDir); - }); - - function writeProjectConfig(config) { - const projectDir = path.dirname(claudeHome); - fs.writeFileSync( - path.join(projectDir, '.planning', 'config.json'), - JSON.stringify(config, null, 2) - ); - } - - const VALID_EFFORTS = ['minimal', 'low', 'medium', 'high', 'xhigh', 'max']; - - test('effort.default="ultra" (invalid) -> Claude .md effort: is a VALID value (falls through to high)', () => { - // BUG before fix: resolveInstallTimeEffort returns "ultra" verbatim - writeProjectConfig({ effort: { default: 'ultra' } }); - runGlobalInstall('claude', claudeHome); - const fm = readFrontmatter(path.join(claudeHome, 'agents', 'gsd-planner.md')); - const match = fm.match(/^effort:\s*(\S+)$/m); - assert.ok(match, `effort: must be present in frontmatter\nActual:\n${fm}`); - assert.ok(VALID_EFFORTS.includes(match[1]), - `effort: must be a VALID effort string, got: "${match[1]}"\nActual frontmatter:\n${fm}`); - }); - - test('effort.agent_overrides.gsd-planner="bogus" (invalid) with valid default -> falls through to valid default', () => { - // BUG before fix: "bogus" is returned and written verbatim - writeProjectConfig({ - effort: { - agent_overrides: { 'gsd-planner': 'bogus' }, - default: 'medium', - }, - }); - runGlobalInstall('claude', claudeHome); - const fm = readFrontmatter(path.join(claudeHome, 'agents', 'gsd-planner.md')); - const match = fm.match(/^effort:\s*(\S+)$/m); - assert.ok(match, `effort: must be present in frontmatter\nActual:\n${fm}`); - assert.ok(VALID_EFFORTS.includes(match[1]), - `effort: must be a VALID effort string, got: "${match[1]}"\nActual frontmatter:\n${fm}`); - // Falls through invalid "bogus" -> valid tier default or "medium" default - // "medium" is valid, so it should appear (or tier default if medium is invalid, but medium is valid) - }); - - test('effort.default="ultra" (invalid) + runtime:"codex" -> Codex .toml model_reasoning_effort is VALID', () => { - // BUG before fix: "ultra" written into .toml verbatim - writeProjectConfig({ runtime: 'codex', effort: { default: 'ultra' } }); - runGlobalInstall('codex', codexHome); - const tomlContent = fs.readFileSync( - path.join(codexHome, 'agents', 'gsd-planner.toml'), 'utf8' - ); - assert.match(tomlContent, /^model\s*=\s*"gpt-5.5"$/m, - `gsd-planner.toml should pin Codex model when runtime:"codex" is configured\nActual:\n${tomlContent.slice(0, 500)}`); - const match = tomlContent.match(/^model_reasoning_effort\s*=\s*"([^"]+)"/m); - assert.ok(match, `model_reasoning_effort must be present in .toml\nActual:\n${tomlContent.slice(0, 500)}`); - assert.ok(VALID_EFFORTS.includes(match[1]), - `model_reasoning_effort must be VALID, got: "${match[1]}"\nActual:\n${tomlContent.slice(0, 500)}`); - }); -}); - -// ─── describe 5: Source stays clean ────────────────────────────────────────── - -describe('#443 Source purity: agents/gsd-planner.md has no effort: key', () => { - test('source agents/gsd-planner.md frontmatter does not contain effort:', () => { - const fm = readFrontmatter(path.join(SOURCE_AGENTS_DIR, 'gsd-planner.md')); - assert.doesNotMatch(fm, /^effort:/m, - `Source agents/gsd-planner.md must NOT contain effort: (injection is install-only)`); - }); - - test('source agents/gsd-executor.md frontmatter does not contain effort:', () => { - const fm = readFrontmatter(path.join(SOURCE_AGENTS_DIR, 'gsd-executor.md')); - assert.doesNotMatch(fm, /^effort:/m, - `Source agents/gsd-executor.md must NOT contain effort: (injection is install-only)`); - }); - - test('source agents/gsd-codebase-mapper.md frontmatter does not contain effort:', () => { - const fm = readFrontmatter(path.join(SOURCE_AGENTS_DIR, 'gsd-codebase-mapper.md')); - assert.doesNotMatch(fm, /^effort:/m, - `Source agents/gsd-codebase-mapper.md must NOT contain effort: (injection is install-only)`); - }); -}); - }); -} - - -// ──────────────────────────────────────────────────────────────────────── -// Folded from tests/enh-1510-rewrite-engine-helper-relocation.test.cjs — consolidation epic #1969 (B1 #1970) -// ──────────────────────────────────────────────────────────────────────── -{ - const { describe: __foldDescribe } = require('node:test'); - __foldDescribe("folded:enh-1510-rewrite-engine-helper-relocation (consolidation epic #1969 B1 #1970)", () => { -'use strict'; - -// Enhancement #1510 (epic #1507, ADR-1508 Phase 1): behavior-preserving -// relocation of pure rewrite-engine helpers out of hand-authored bin/install.js. -// - getDirName -> gsd-core/bin/lib/runtime-name-policy.cjs -// - processAttribution -> gsd-core/bin/lib/runtime-artifact-conversion.cjs -// getCommitAttribution stays in install.js (impure install-time config I/O); the -// convertClaudeToAugmentMarkdown duplicate dedup is deferred to Phase 2's cleanup -// (entangled converter cluster; not required to unblock Phase 2). -// These tests exercise the REAL relocated functions at their new home (the -// generated .cjs) and assert install.js re-exports the SAME references -// (Hyrum: existing consumers import these names from bin/install.js). - -const { test, describe } = require('node:test'); -const assert = require('node:assert'); - -const runtimeNamePolicy = require('../gsd-core/bin/lib/runtime-name-policy.cjs'); -const conversion = require('../gsd-core/bin/lib/runtime-artifact-conversion.cjs'); -const installer = require('../bin/install.js'); - -// ── Slice A: getDirName relocated to runtime-name-policy ────────────────────── -describe('getDirName (relocated to runtime-name-policy)', () => { - const EXPECTED = { - claude: '.claude', - copilot: '.github', - opencode: '.opencode', - kilo: '.kilo', - codex: '.codex', - antigravity: '.agents', - cursor: '.cursor', - windsurf: '.windsurf', - augment: '.augment', - trae: '.trae', - qwen: '.qwen', - hermes: '.hermes', - kimi: '.kimi-code', - codebuddy: '.codebuddy', - cline: '.cline', - }; - - for (const [runtime, dir] of Object.entries(EXPECTED)) { - test(`maps '${runtime}' to '${dir}'`, () => { - assert.strictEqual(runtimeNamePolicy.getDirName(runtime), dir); - }); - } - - test('falls back to .claude for an unknown runtime', () => { - assert.strictEqual(runtimeNamePolicy.getDirName('definitely-not-a-runtime'), '.claude'); - }); - - test('falls back to .claude for empty input', () => { - assert.strictEqual(runtimeNamePolicy.getDirName(''), '.claude'); - }); - - test('bin/install.js re-exports the SAME getDirName reference (no drift)', () => { - assert.strictEqual(installer.getDirName, runtimeNamePolicy.getDirName); - }); -}); - -// ── Slice B: processAttribution relocated to runtime-artifact-conversion ─────── -describe('processAttribution (relocated to runtime-artifact-conversion)', () => { - test('null removes the Co-Authored-By line and its preceding blank line', () => { - const input = 'Commit body line.\n\nCo-Authored-By: Someone '; - assert.strictEqual(conversion.processAttribution(input, null), 'Commit body line.'); - }); - - test('undefined leaves content unchanged', () => { - const input = 'Commit body.\n\nCo-Authored-By: Someone '; - assert.strictEqual(conversion.processAttribution(input, undefined), input); - }); - - test('a string replaces the attribution value', () => { - const input = 'Body\n\nCo-Authored-By: Old Name '; - assert.strictEqual( - conversion.processAttribution(input, 'New Name '), - 'Body\n\nCo-Authored-By: New Name ', - ); - }); - - test('escapes $ in the attribution to prevent backreference injection', () => { - const input = 'Body\n\nCo-Authored-By: x'; - // "$1" must survive literally, not be interpreted as a regex backreference. - assert.strictEqual( - conversion.processAttribution(input, 'A $1 B'), - 'Body\n\nCo-Authored-By: A $1 B', - ); - }); - - test('handles CRLF when removing (null)', () => { - const input = 'Body\r\n\r\nCo-Authored-By: Someone '; - assert.strictEqual(conversion.processAttribution(input, null), 'Body'); - }); - - test('replaces every Co-Authored-By line (global)', () => { - const input = 'Body\nCo-Authored-By: A \nCo-Authored-By: B '; - assert.strictEqual( - conversion.processAttribution(input, 'Z '), - 'Body\nCo-Authored-By: Z \nCo-Authored-By: Z ', - ); - }); - - test('bin/install.js re-exports the SAME processAttribution reference (no drift)', () => { - // processAttribution remains an explicit installer compatibility relay, so - // the export must keep pointing at the conversion module's implementation. - assert.strictEqual(installer.processAttribution, conversion.processAttribution); - }); -}); - }); -} - // ──────────────────────────────────────────────────────────────────────── @@ -8094,2963 +5136,6 @@ describe('enh-790 — mcpServers excluded (gsd ships no MCP server)', () => { }); } -// ──────────────────────────────────────────────────────────────────────── -// Folded from tests/bug-2418-antigravity-bare-path.test.cjs — consolidation epic #1969 (B1 #1970) -// ──────────────────────────────────────────────────────────────────────── -{ - const { describe: __foldDescribe } = require('node:test'); - __foldDescribe("folded:bug-2418-antigravity-bare-path (consolidation epic #1969 B1 #1970)", () => { -/** - * Bug #2418: Found unreplaced .claude path reference(s) in Antigravity install - * - * The Antigravity path converter handles ~/.claude/ (with trailing slash) but - * misses bare ~/.claude (without trailing slash), leaving unreplaced references - * that cause the installer to warn about leaked paths. - * - * Files affected: agents/gsd-debugger.md (configDir = ~/.claude) and - * gsd-core/workflows/update.md (comment with e.g. ~/.claude). - */ - -process.env.GSD_TEST_MODE = '1'; - -const { describe, test } = require('node:test'); -const assert = require('node:assert/strict'); - -const { convertClaudeToAntigravityContent } = require('../bin/install.js'); - -describe('convertClaudeToAntigravityContent bare path replacement (#2418)', () => { - describe('global install', () => { - test('replaces ~/.claude (bare, no trailing slash) with ~/.gemini/antigravity', () => { - const input = 'configDir = ~/.claude'; - const result = convertClaudeToAntigravityContent(input, true); - assert.ok( - result.includes('~/.gemini/antigravity'), - `Expected ~/.gemini/antigravity in output, got: ${result}` - ); - assert.ok( - !result.includes('~/.claude'), - `Expected ~/ .claude to be replaced, got: ${result}` - ); - }); - - test('replaces $HOME/.claude (bare, no trailing slash) with $HOME/.gemini/antigravity', () => { - const input = 'export DIR=$HOME/.claude'; - const result = convertClaudeToAntigravityContent(input, true); - assert.ok( - result.includes('$HOME/.gemini/antigravity'), - `Expected $HOME/.gemini/antigravity in output, got: ${result}` - ); - assert.ok( - !result.includes('$HOME/.claude'), - `Expected $HOME/.claude to be replaced, got: ${result}` - ); - }); - - test('handles bare ~/.claude followed by comma (comment context)', () => { - const input = '# e.g. ~/.claude, ~/.config/opencode'; - const result = convertClaudeToAntigravityContent(input, true); - assert.ok( - !result.includes('~/.claude'), - `Expected ~/ .claude to be replaced in comment context, got: ${result}` - ); - }); - - test('still replaces ~/.claude/ (with trailing slash) correctly', () => { - const input = 'See ~/.claude/gsd-core/workflows/'; - const result = convertClaudeToAntigravityContent(input, true); - assert.ok( - result.includes('~/.gemini/antigravity/gsd-core/workflows/'), - `Expected path with trailing slash to be replaced, got: ${result}` - ); - assert.ok(!result.includes('~/.claude/'), `Expected ~/ .claude/ to be fully replaced, got: ${result}`); - }); - - test('does not double-replace ~/.claude/ paths', () => { - const input = 'See ~/.claude/gsd-core/'; - const result = convertClaudeToAntigravityContent(input, true); - // Result should contain exactly one occurrence of the replacement path - const count = (result.match(/~\/.gemini\/antigravity\//g) || []).length; - assert.strictEqual(count, 1, `Expected exactly 1 replacement, got ${count} in: ${result}`); - }); - }); - - describe('local install', () => { - test('replaces ~/.claude (bare, no trailing slash) with .agents', () => { - const input = 'configDir = ~/.claude'; - const result = convertClaudeToAntigravityContent(input, false); - assert.ok( - result.includes('.agents'), - `Expected .agents in output, got: ${result}` - ); - assert.ok( - !result.includes('~/.claude'), - `Expected ~/ .claude to be replaced, got: ${result}` - ); - }); - - test('replaces $HOME/.claude (bare, no trailing slash) with .agents', () => { - const input = 'export DIR=$HOME/.claude'; - const result = convertClaudeToAntigravityContent(input, false); - assert.ok( - result.includes('.agents'), - `Expected .agents in output, got: ${result}` - ); - assert.ok( - !result.includes('$HOME/.claude'), - `Expected $HOME/.claude to be replaced, got: ${result}` - ); - }); - - test('does not double-replace ~/.claude/ paths', () => { - const input = 'See ~/.claude/gsd-core/'; - const result = convertClaudeToAntigravityContent(input, false); - // .agents/ should appear exactly once - const count = (result.match(/\.agents\//g) || []).length; - assert.strictEqual(count, 1, `Expected exactly 1 replacement, got ${count} in: ${result}`); - }); - }); - - describe('installed files contain no bare ~/.claude references after conversion', () => { - const fs = require('fs'); - const path = require('path'); - const repoRoot = path.join(__dirname, '..'); - - // The scanner regex used by the installer to detect leaked paths - const leakedPathRegex = /(?:~|\$HOME)\/\.claude\b/g; - - function convertFile(filePath, isGlobal) { - const content = fs.readFileSync(filePath, 'utf8'); - return convertClaudeToAntigravityContent(content, isGlobal); - } - - test('gsd-debugger.md has no leaked ~/.claude after global Antigravity conversion', () => { - const debuggerPath = path.join(repoRoot, 'agents', 'gsd-debugger.md'); - if (!fs.existsSync(debuggerPath)) return; // skip if file doesn't exist - const converted = convertFile(debuggerPath, true); - const matches = converted.match(leakedPathRegex); - assert.strictEqual( - matches, null, - `gsd-debugger.md still contains leaked .claude paths after Antigravity conversion: ${matches}` - ); - }); - - test('update.md has no leaked ~/.claude after global Antigravity conversion', () => { - const updatePath = path.join(repoRoot, 'gsd-core', 'workflows', 'update.md'); - if (!fs.existsSync(updatePath)) return; // skip if file doesn't exist - const converted = convertFile(updatePath, true); - const matches = converted.match(leakedPathRegex); - assert.strictEqual( - matches, null, - `update.md still contains leaked .claude paths after Antigravity conversion: ${matches}` - ); - }); - }); -}); - }); -} - - -// ──────────────────────────────────────────────────────────────────────── -// Folded from tests/bug-2545-copilot-unreplaced-paths.test.cjs — consolidation epic #1969 (B1 #1970) -// ──────────────────────────────────────────────────────────────────────── -{ - const { describe: __foldDescribe } = require('node:test'); - __foldDescribe("folded:bug-2545-copilot-unreplaced-paths (consolidation epic #1969 B1 #1970)", () => { -/** - * Regression test for issue #2545. - * - * The Copilot content converter's `~/.claude/` and `$HOME/.claude/` replacements - * only matched when a literal slash followed, so bare `~/.claude` references - * (end of line, quotes, punctuation) were left unreplaced. Those leaks then - * triggered the installer's "Found N unreplaced .claude path reference(s)" - * warning, which scans for `(?:~|$HOME)/\.claude\b`. - * - * Fix: replace with a word-boundary pattern so both forms are caught in a - * single pass, matching the approach already used by the Antigravity, OpenCode, - * Kilo, and Codex converters. - */ - -process.env.GSD_TEST_MODE = '1'; - -const { test, describe } = require('node:test'); -const assert = require('node:assert/strict'); - -const { convertClaudeToCopilotContent } = require('../bin/install.js'); - -describe('convertClaudeToCopilotContent — bare ~/.claude (issue #2545)', () => { - test('global install replaces bare ~/.claude at end of line', () => { - const input = 'configDir = ~/.claude\n'; - const out = convertClaudeToCopilotContent(input, /* isGlobal */ true); - assert.ok( - !/(?:~|\$HOME)\/\.claude\b/.test(out), - `expected no leaked ~/.claude reference, got: ${JSON.stringify(out)}`, - ); - assert.match(out, /~\/\.copilot\b/); - }); - - test('global install replaces bare $HOME/.claude at end of line', () => { - const input = 'configDir = $HOME/.claude\n'; - const out = convertClaudeToCopilotContent(input, /* isGlobal */ true); - assert.ok( - !/(?:~|\$HOME)\/\.claude\b/.test(out), - `expected no leaked $HOME/.claude reference, got: ${JSON.stringify(out)}`, - ); - assert.match(out, /\$HOME\/\.copilot\b/); - }); - - test('global install replaces bare ~/.claude before punctuation', () => { - const input = 'paths include `~/.claude`, `~/.copilot`'; - const out = convertClaudeToCopilotContent(input, true); - assert.ok(!/(?:~|\$HOME)\/\.claude\b/.test(out)); - }); - - test('local install replaces bare ~/.claude', () => { - const input = 'configDir = ~/.claude\n'; - const out = convertClaudeToCopilotContent(input, /* isGlobal */ false); - assert.ok( - !/(?:~|\$HOME)\/\.claude\b/.test(out), - `expected no leaked ~/.claude reference, got: ${JSON.stringify(out)}`, - ); - }); - - test('does not double-replace trailing-slash form', () => { - const input = '@~/.claude/gsd-core/foo.md\n'; - const out = convertClaudeToCopilotContent(input, true); - assert.match(out, /~\/\.copilot\/gsd-core\/foo\.md/); - assert.ok(!/\.copilot\/\.copilot/.test(out)); - }); -}); - }); -} - - -// ──────────────────────────────────────────────────────────────────────── -// Folded from tests/bug-983-trae-windsurf-claude-path-leak.test.cjs — consolidation epic #1969 (B1 #1970) -// ──────────────────────────────────────────────────────────────────────── -{ - const { describe: __foldDescribe } = require('node:test'); - __foldDescribe("folded:bug-983-trae-windsurf-claude-path-leak (consolidation epic #1969 B1 #1970)", () => { -// allow-test-rule: source-text-is-the-product (see #983) -'use strict'; - -process.env.GSD_TEST_MODE = '1'; - -/** - * Regression tests for issue #983 — Trae and Windsurf converters leak - * unreplaced bare `~/.claude` / `$HOME/.claude` references. - * - * Both converters rewrote only trailing-slash `.claude/` forms, so bare - * home-path references (configDir = ~/.claude, $HOME/.claude) survived - * conversion and pointed users at the wrong config dir. - * - * Fix: add bare word-boundary replacements mirroring Cline (#782) and - * Codex (#570) precedent, with a negative lookahead to preserve `.claude-plugin`. - */ - -const { describe, test } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('node:fs'); -const path = require('node:path'); - -const { - convertClaudeToWindsurfMarkdown, - convertClaudeToTraeMarkdown, - _applyRuntimeRewrites, -} = require('../bin/install.js'); - -// ─── Windsurf converter bare-form tests ───────────────────────────────────── - -describe('convertClaudeToWindsurfMarkdown — bare ~/.claude and CLAUDE_CONFIG_DIR (#983)', () => { - test('bare ~/.claude rewritten to ~/.windsurf (#1615: workspace dir is now .windsurf)', () => { - const input = 'Config dir: (~/.claude), skills at ~/.claude/skills'; - const result = convertClaudeToWindsurfMarkdown(input); - assert.ok( - !/~\/\.claude(?![\w-])/.test(result), - `bare ~/.claude must be rewritten; got: ${result}`, - ); - assert.ok(result.includes('~/.windsurf'), 'must rewrite to ~/.windsurf'); - }); - - test('$HOME/.claude rewritten to $HOME/.windsurf (#1615: workspace dir is now .windsurf)', () => { - const input = 'RUNTIME_CONFIG_DIR="${CLAUDE_CONFIG_DIR:-$HOME/.claude}"'; - const result = convertClaudeToWindsurfMarkdown(input); - assert.ok( - !/\$HOME\/\.claude(?![\w-])/.test(result), - `bare $HOME/.claude must be rewritten; got: ${result}`, - ); - assert.ok(result.includes('$HOME/.windsurf'), 'must rewrite to $HOME/.windsurf'); - }); - - test('CLAUDE_CONFIG_DIR rewritten to WINDSURF_CONFIG_DIR', () => { - const input = 'Use CLAUDE_CONFIG_DIR or $HOME/.claude to configure'; - const result = convertClaudeToWindsurfMarkdown(input); - assert.ok( - result.includes('WINDSURF_CONFIG_DIR'), - 'CLAUDE_CONFIG_DIR must become WINDSURF_CONFIG_DIR', - ); - assert.ok( - !result.includes('CLAUDE_CONFIG_DIR'), - 'CLAUDE_CONFIG_DIR must be gone', - ); - }); - - test('.claude-plugin is NOT corrupted (preserved as-is)', () => { - const input = 'The .claude-plugin/plugin.json manifest enables plugin install.'; - const result = convertClaudeToWindsurfMarkdown(input); - assert.ok( - result.includes('.claude-plugin'), - `.claude-plugin must be preserved; got: ${result}`, - ); - assert.ok( - !result.includes('.windsurf-plugin'), - `.windsurf-plugin must not appear; got: ${result}`, - ); - }); - - test('no bare ~/.claude in converted surface.md', () => { - const surfacePath = path.join(__dirname, '..', 'commands', 'gsd', 'surface.md'); - const raw = fs.readFileSync(surfacePath, 'utf8'); - const result = convertClaudeToWindsurfMarkdown(raw); - assert.ok( - !/~\/\.claude(?![\w-])/.test(result), - 'converted surface.md must not contain bare ~/.claude', - ); - }); - - test('no $HOME/.claude in converted surface.md', () => { - const surfacePath = path.join(__dirname, '..', 'commands', 'gsd', 'surface.md'); - const raw = fs.readFileSync(surfacePath, 'utf8'); - const result = convertClaudeToWindsurfMarkdown(raw); - assert.ok( - !/\$HOME\/\.claude(?![\w-])/.test(result), - 'converted surface.md must not contain bare $HOME/.claude', - ); - }); - - test('no CLAUDE_CONFIG_DIR in converted surface.md', () => { - const surfacePath = path.join(__dirname, '..', 'commands', 'gsd', 'surface.md'); - const raw = fs.readFileSync(surfacePath, 'utf8'); - const result = convertClaudeToWindsurfMarkdown(raw); - assert.ok( - !result.includes('CLAUDE_CONFIG_DIR'), - 'converted surface.md must not contain CLAUDE_CONFIG_DIR', - ); - }); -}); - -// ─── Trae converter bare-form tests ───────────────────────────────────────── - -describe('convertClaudeToTraeMarkdown — bare ~/.claude and CLAUDE_CONFIG_DIR (#983)', () => { - test('bare ~/.claude rewritten to ~/.trae', () => { - const input = 'Config dir: (~/.claude), skills at ~/.claude/skills'; - const result = convertClaudeToTraeMarkdown(input); - assert.ok( - !/~\/\.claude(?![\w-])/.test(result), - `bare ~/.claude must be rewritten; got: ${result}`, - ); - assert.ok(result.includes('~/.trae'), 'must rewrite to ~/.trae'); - }); - - test('$HOME/.claude rewritten to $HOME/.trae', () => { - const input = 'RUNTIME_CONFIG_DIR="${CLAUDE_CONFIG_DIR:-$HOME/.claude}"'; - const result = convertClaudeToTraeMarkdown(input); - assert.ok( - !/\$HOME\/\.claude(?![\w-])/.test(result), - `bare $HOME/.claude must be rewritten; got: ${result}`, - ); - assert.ok(result.includes('$HOME/.trae'), 'must rewrite to $HOME/.trae'); - }); - - test('CLAUDE_CONFIG_DIR rewritten to TRAE_CONFIG_DIR', () => { - const input = 'Use CLAUDE_CONFIG_DIR or $HOME/.claude to configure'; - const result = convertClaudeToTraeMarkdown(input); - assert.ok( - result.includes('TRAE_CONFIG_DIR'), - 'CLAUDE_CONFIG_DIR must become TRAE_CONFIG_DIR', - ); - assert.ok( - !result.includes('CLAUDE_CONFIG_DIR'), - 'CLAUDE_CONFIG_DIR must be gone', - ); - }); - - test('.claude-plugin is NOT corrupted (preserved as-is)', () => { - const input = 'The .claude-plugin/plugin.json manifest enables plugin install.'; - const result = convertClaudeToTraeMarkdown(input); - assert.ok( - result.includes('.claude-plugin'), - `.claude-plugin must be preserved; got: ${result}`, - ); - assert.ok( - !result.includes('.trae-plugin'), - `.trae-plugin must not appear; got: ${result}`, - ); - }); - - test('no bare ~/.claude in converted surface.md', () => { - const surfacePath = path.join(__dirname, '..', 'commands', 'gsd', 'surface.md'); - const raw = fs.readFileSync(surfacePath, 'utf8'); - const result = convertClaudeToTraeMarkdown(raw); - assert.ok( - !/~\/\.claude(?![\w-])/.test(result), - 'converted surface.md must not contain bare ~/.claude', - ); - }); - - test('no $HOME/.claude in converted surface.md', () => { - const surfacePath = path.join(__dirname, '..', 'commands', 'gsd', 'surface.md'); - const raw = fs.readFileSync(surfacePath, 'utf8'); - const result = convertClaudeToTraeMarkdown(raw); - assert.ok( - !/\$HOME\/\.claude(?![\w-])/.test(result), - 'converted surface.md must not contain bare $HOME/.claude', - ); - }); - - test('no CLAUDE_CONFIG_DIR in converted surface.md', () => { - const surfacePath = path.join(__dirname, '..', 'commands', 'gsd', 'surface.md'); - const raw = fs.readFileSync(surfacePath, 'utf8'); - const result = convertClaudeToTraeMarkdown(raw); - assert.ok( - !result.includes('CLAUDE_CONFIG_DIR'), - 'converted surface.md must not contain CLAUDE_CONFIG_DIR', - ); - }); -}); - -// ─── _applyRuntimeRewrites install-path tests (windsurf) ──────────────────── -// -// These tests exercise the ACTUAL install path that causes the user-facing leak. -// The converter functions are called at stage time to produce a Windsurf-branded -// copy, but _applyRuntimeRewrites is the path that runs at INSTALL time and -// rewrites any surviving ~/.claude / $HOME/.claude refs in the staged files. -// -// FAIL-BEFORE proof: prior to this PR, windsurf used /~\/\.claude\b/ which -// fires on "~/.claude-plugin" because \b matches between 'e' and '-'. Running -// the test below against the old regex (`\b`) would: -// - let bare $HOME/.claude survive (it used only /~\/\.claude\b/, missing $HOME form), AND -// - corrupt "~/.claude-plugin" → "~/.windsurf-plugin". -// Both assertions in the test below would fail on the old code. -// -// PASS-AFTER: the fix changes to (?![\w-]) so: -// - bare ~/.claude / $HOME/.claude (not followed by word-char or hyphen) → rewritten -// - ~/.claude-plugin preserved (the '-' after 'e' is in [\w-]) -// -// NOTE on pathPrefix choice: we use '~/.windsurf/' (a simple home-relative -// prefix) rather than '$HOME/.codeium/windsurf/' so that the corruption of -// '~/.claude-plugin' → '~/.windsurf-plugin' is directly detectable via -// result.includes('.windsurf-plugin'). -describe('_applyRuntimeRewrites(windsurf) — install-path bare-form + .claude-plugin (#983)', () => { - // Use ~/ prefix (local-style) so that the .windsurf-plugin corruption is - // directly detectable as a substring of the result. - const WINDSURF_PATH_PREFIX = '~/.windsurf/'; - - // Compound content: covers every form the fix must handle. - // IMPORTANT: we use ~/.claude-plugin (home-relative form) to exercise the - // corruption that the old \b regex caused. The \b fires between 'e' and '-', - // so ~/.claude-plugin → ~/.windsurf-plugin under the old code. That would - // break the preservation assertion below. The (?![\w-]) fix prevents this. - const COMPOUND_INPUT = [ - 'Config dir: ~/.claude', - 'Also: $HOME/.claude', - 'Slash form: ~/.claude/skills/foo.md', - 'Plugin installed at: ~/.claude-plugin/plugin.json', - 'Env var: CLAUDE_CONFIG_DIR', - ].join('\n'); - - test('bare ~/.claude rewritten to ~/.windsurf (no trailing slash)', () => { - const result = _applyRuntimeRewrites(COMPOUND_INPUT, 'windsurf', WINDSURF_PATH_PREFIX); - assert.ok( - !/~\/\.claude(?![\w-])/.test(result), - `bare ~/.claude must be gone; got:\n${result}`, - ); - assert.ok( - result.includes('~/.windsurf'), - `must contain normalized pathPrefix; got:\n${result}`, - ); - }); - - test('bare $HOME/.claude rewritten to ~/.windsurf (install-path normalizes both home forms)', () => { - const result = _applyRuntimeRewrites(COMPOUND_INPUT, 'windsurf', WINDSURF_PATH_PREFIX); - assert.ok( - !/\$HOME\/\.claude(?![\w-])/.test(result), - `bare $HOME/.claude must be gone; got:\n${result}`, - ); - }); - - test('zero surviving bare ~/.claude or $HOME/.claude refs in compound input', () => { - const result = _applyRuntimeRewrites(COMPOUND_INPUT, 'windsurf', WINDSURF_PATH_PREFIX); - const bareClaudePattern = /(?:~|\$HOME)\/\.claude(?![\w-])/; - assert.ok( - !bareClaudePattern.test(result), - `no bare ~/.claude / $HOME/.claude must survive; got:\n${result}`, - ); - }); - - test('~/.claude-plugin is NOT corrupted to ~/.windsurf-plugin — was the \\b corruption', () => { - // FAIL-BEFORE: old /~\/\.claude\b/ rewrote ~/.claude-plugin → ~/.windsurf-plugin - // because \b fires between 'e' and '-'. - // PASS-AFTER: (?![\w-]) sees '-' and skips the match, preserving ~/.claude-plugin. - const result = _applyRuntimeRewrites(COMPOUND_INPUT, 'windsurf', WINDSURF_PATH_PREFIX); - assert.ok( - result.includes('~/.claude-plugin'), - `~/.claude-plugin must be preserved; got:\n${result}`, - ); - assert.ok( - !result.includes('~/.windsurf-plugin'), - `~/.windsurf-plugin must NOT appear (was the \\b corruption); got:\n${result}`, - ); - }); - - test('slash form ~/.claude/ is also rewritten (pre-existing coverage)', () => { - const result = _applyRuntimeRewrites(COMPOUND_INPUT, 'windsurf', WINDSURF_PATH_PREFIX); - assert.ok( - !result.includes('~/.claude/'), - `slash form ~/.claude/ must be gone; got:\n${result}`, - ); - }); - - test('CLAUDE_CONFIG_DIR is NOT rewritten by _applyRuntimeRewrites (converter responsibility)', () => { - // _applyRuntimeRewrites does NOT handle CLAUDE_CONFIG_DIR for windsurf; - // that rewrite is done by convertClaudeToWindsurfMarkdown at stage time. - // This test documents the boundary and guards against scope creep. - const result = _applyRuntimeRewrites(COMPOUND_INPUT, 'windsurf', WINDSURF_PATH_PREFIX); - assert.ok( - result.includes('CLAUDE_CONFIG_DIR'), - 'CLAUDE_CONFIG_DIR is not rewritten by _applyRuntimeRewrites — that is converter scope', - ); - }); -}); - -// ─── _applyRuntimeRewrites install-path tests (trae) ──────────────────────── -// -// Trae had bare-form handling before this PR (via \b) and the converter uses -// (?![\w-]). The pre-existing \b in _applyRuntimeRewrites DOES corrupt -// .claude-plugin → .trae-plugin (known limitation, out of scope for #983). -// We document this here but do NOT assert preservation for trae, and we do NOT -// fix the pre-existing trae \b lines (that would be a separate concern). -// -// What we DO assert: trae bare ~/.claude / $HOME/.claude refs are rewritten -// (the install path cleans them), which is the core #983 fix for trae. -describe('_applyRuntimeRewrites(trae) — install-path bare-form (#983)', () => { - const TRAE_PATH_PREFIX = '$HOME/.trae/'; - - const TRAE_INPUT = [ - 'Config dir: ~/.claude', - 'Also: $HOME/.claude', - 'Slash form: ~/.claude/skills/foo.md', - // Note: .claude-plugin is intentionally omitted from assertions here because - // the pre-existing trae case uses \b which corrupts it (known limitation, - // out of scope for #983 — do not fix here). - ].join('\n'); - - test('bare ~/.claude rewritten to $HOME/.trae (trae install path)', () => { - const result = _applyRuntimeRewrites(TRAE_INPUT, 'trae', TRAE_PATH_PREFIX); - assert.ok( - !/~\/\.claude(?![\w-])/.test(result), - `bare ~/.claude must be gone; got:\n${result}`, - ); - }); - - test('bare $HOME/.claude rewritten to $HOME/.trae (trae install path)', () => { - const result = _applyRuntimeRewrites(TRAE_INPUT, 'trae', TRAE_PATH_PREFIX); - assert.ok( - !/\$HOME\/\.claude(?![\w-])/.test(result), - `bare $HOME/.claude must be gone; got:\n${result}`, - ); - }); - - test('slash form ~/.claude/ also rewritten (trae install path)', () => { - const result = _applyRuntimeRewrites(TRAE_INPUT, 'trae', TRAE_PATH_PREFIX); - assert.ok( - !result.includes('~/.claude/'), - `slash form ~/.claude/ must be gone; got:\n${result}`, - ); - }); -}); - }); -} - - -// ──────────────────────────────────────────────────────────────────────── -// Folded from tests/bug-782-cline-skills-emission.test.cjs — consolidation epic #1969 (B1 #1970) -// ──────────────────────────────────────────────────────────────────────── -{ - const { describe: __foldDescribe } = require('node:test'); - __foldDescribe("folded:bug-782-cline-skills-emission (consolidation epic #1969 B1 #1970)", () => { -'use strict'; -/** - * Regression tests for bug #782 — Cline skills emission. - * - * gsd now emits skills to ~/.cline/skills//SKILL.md for Cline >= v3.48. - * Skills discovery: https://docs.cline.bot/customization/skills - * - * (a) Converter unit test: convertClaudeCommandToClineSkill - * (b) Integration test: installRuntimeArtifacts for cline writes SKILL.md files - * (c) .clinerules/gsd.md still written by the install path (#787 dir form) - * (d) Idempotency: running install twice leaves skills + .clinerules/ intact - * (e) Full install() global: both skills AND .clinerules/gsd.md are written - */ - -process.env.GSD_TEST_MODE = '1'; - -const { test, describe, beforeEach, afterEach } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('node:fs'); -const path = require('node:path'); -const { createTempDir, cleanup, captureConsole } = require('./helpers.cjs'); - -const { - convertClaudeCommandToClineSkill, - convertClaudeToCliineMarkdown, - install, - _applyRuntimeRewrites, -} = require('../bin/install.js'); - -const { installRuntimeArtifacts } = require('../gsd-core/bin/lib/install-engine.cjs'); - -const { - resolveRuntimeArtifactLayout, -} = require('../gsd-core/bin/lib/runtime-artifact-layout.cjs'); - -const { - loadSkillsManifest, - resolveProfile, -} = require('../gsd-core/bin/lib/install-profiles.cjs'); - -const { nestedSkillPath } = require('./helpers/nested-layout.cjs'); - -const REAL_COMMANDS_DIR = path.join(__dirname, '..', 'commands', 'gsd'); -const MANIFEST = loadSkillsManifest(REAL_COMMANDS_DIR); -const RESOLVED_CORE = resolveProfile({ modes: ['core'], manifest: MANIFEST }); - -// ─── (a) Converter unit test ───────────────────────────────────────────────── - -const SAMPLE_COMMAND = `--- -name: gsd:execute-phase -description: Execute all tasks in the current phase using Cline tools. -allowed-tools: - - Read - - Write - - Bash ---- - -## Objective - -Run all tasks in the current phase. - -See ~/.claude/skills/gsd-help/SKILL.md for reference. -Use \`/gsd-help\` or Claude Code for details. -`; - -// A command that exercises all three Claude-specific frontmatter fields that -// must NOT leak into the emitted Cline SKILL.md. -const RICH_COMMAND = `--- -name: gsd:validate-phase -description: Retroactively audit and fill Nyquist validation gaps for a completed phase -argument-hint: "[phase number]" -agent: researcher -allowed-tools: - - Read - - Write - - Edit - - Bash - - Glob - - Grep - - Agent - - AskUserQuestion ---- - -## Objective - -Audit Nyquist validation coverage. See ~/.claude/skills/gsd-help/SKILL.md for reference. -Use Claude Code for details. -`; - -/** - * Extract frontmatter block (between --- delimiters) from output. - * Returns the raw text between the first --- and the closing ---. - * Uses \r?\n to handle both LF and CRLF line endings (Windows parity). - */ -function parseFrontmatter(text) { - const m = text.match(/^---\r?\n([\s\S]*?)\r?\n---/); - return m ? m[1] : null; -} - -describe('convertClaudeCommandToClineSkill — unit', () => { - test('emits frontmatter with name: gsd-', () => { - const result = convertClaudeCommandToClineSkill(SAMPLE_COMMAND, 'gsd-execute-phase'); - const nameMatch = result.match(/^name:\s*(.+)$/m); - assert.ok(nameMatch, 'frontmatter must contain name field'); - assert.ok(nameMatch[1].includes('gsd-execute-phase'), 'name must start with gsd-execute-phase'); - }); - - test('emits non-empty description in frontmatter', () => { - const result = convertClaudeCommandToClineSkill(SAMPLE_COMMAND, 'gsd-execute-phase'); - const descMatch = result.match(/^description:\s*(.+)$/m); - assert.ok(descMatch, 'frontmatter must contain description field'); - assert.ok(descMatch[1].trim().length > 0, 'description must not be empty'); - }); - - test('body uses .cline/ paths not .claude/', () => { - const result = convertClaudeCommandToClineSkill(SAMPLE_COMMAND, 'gsd-execute-phase'); - // The body reference to ~/.claude/ should be rewritten to ~/.cline/ - assert.ok(!result.includes('~/.claude/skills'), 'body must not contain ~/.claude/skills'); - assert.ok(result.includes('.cline/skills'), 'body must contain .cline/skills'); - }); - - test('body replaces "Claude Code" with "Cline"', () => { - const result = convertClaudeCommandToClineSkill(SAMPLE_COMMAND, 'gsd-execute-phase'); - assert.ok(!result.includes('Claude Code'), 'Claude Code must be replaced with Cline'); - assert.ok(result.includes('Cline'), 'result must contain Cline branding'); - }); - - test('no stray .claude/ paths in frontmatter or body', () => { - const result = convertClaudeCommandToClineSkill(SAMPLE_COMMAND, 'gsd-execute-phase'); - // Should not contain .claude/ anywhere (except inside CLAUDE.md→.clinerules rewrites - // but those are already handled by convertClaudeToCliineMarkdown) - assert.ok(!result.includes('/.claude/'), 'no /.claude/ paths in output'); - }); - - // ── Fix 1 (code-review): frontmatter must be ONLY name + description ────── - - test('frontmatter emits ONLY name and description — no allowed-tools (SAMPLE_COMMAND)', () => { - const result = convertClaudeCommandToClineSkill(SAMPLE_COMMAND, 'gsd-execute-phase'); - const fm = parseFrontmatter(result); - assert.ok(fm !== null, 'result must have YAML frontmatter'); - assert.ok(!fm.includes('allowed-tools'), 'frontmatter must NOT contain allowed-tools'); - assert.ok(!fm.includes('argument-hint'), 'frontmatter must NOT contain argument-hint'); - assert.ok(!fm.includes('agent:'), 'frontmatter must NOT contain agent:'); - }); - - test('frontmatter emits ONLY name and description — no allowed-tools/argument-hint/agent (RICH_COMMAND)', () => { - const result = convertClaudeCommandToClineSkill(RICH_COMMAND, 'gsd-validate-phase'); - const fm = parseFrontmatter(result); - assert.ok(fm !== null, 'result must have YAML frontmatter'); - assert.ok(!fm.includes('allowed-tools'), 'frontmatter must NOT contain allowed-tools'); - assert.ok(!fm.includes('argument-hint'), 'frontmatter must NOT contain argument-hint'); - assert.ok(!fm.includes('agent:'), 'frontmatter must NOT contain agent:'); - }); - - test('name == gsd-validate-phase for RICH_COMMAND', () => { - const result = convertClaudeCommandToClineSkill(RICH_COMMAND, 'gsd-validate-phase'); - const nameMatch = result.match(/^name:\s*(.+)$/m); - assert.ok(nameMatch, 'must have name field'); - // yamlIdentifier may quote the value; strip surrounding quotes for comparison - const nameVal = nameMatch[1].replace(/^['"]|['"]$/g, '').trim(); - assert.strictEqual(nameVal, 'gsd-validate-phase', `name must be gsd-validate-phase, got: ${nameVal}`); - }); - - test('description is non-empty and <= 1024 chars for RICH_COMMAND', () => { - const result = convertClaudeCommandToClineSkill(RICH_COMMAND, 'gsd-validate-phase'); - const descMatch = result.match(/^description:\s*(.+)$/m); - assert.ok(descMatch, 'must have description field'); - const desc = descMatch[1].replace(/^['"]|['"]$/g, '').trim(); - assert.ok(desc.length > 0, 'description must be non-empty'); - assert.ok(desc.length <= 1024, `description must be <= 1024 chars, got ${desc.length}`); - }); - - test('description truncated to <=1024 chars when source description is very long', () => { - const longDesc = 'A'.repeat(2000); - const longDescCommand = `---\nname: gsd:test\ndescription: ${longDesc}\n---\n\nBody text.\n`; - const result = convertClaudeCommandToClineSkill(longDescCommand, 'gsd-test'); - const descMatch = result.match(/^description:\s*'?(.*?)'?$/m); - assert.ok(descMatch, 'must have description field'); - // The raw description value (unquoted) should be <=1024 chars - // The result string after the --- block will have the quoted form; check raw length - // by checking the whole result doesn't have the full 2000-char string - assert.ok(!result.includes('A'.repeat(1025)), 'description must be truncated to 1024 chars'); - }); - - test('returns content unchanged when source has no frontmatter', () => { - const noFm = 'Just a body, no frontmatter here.\n'; - const result = convertClaudeCommandToClineSkill(noFm, 'gsd-test'); - assert.strictEqual(result, noFm, 'content without frontmatter must be returned unchanged'); - }); - - test('RICH_COMMAND body uses .cline/ paths and Cline branding', () => { - const result = convertClaudeCommandToClineSkill(RICH_COMMAND, 'gsd-validate-phase'); - assert.ok(!result.includes('~/.claude/'), 'body must not contain ~/.claude/'); - assert.ok(result.includes('.cline/'), 'body must contain .cline/ paths'); - assert.ok(!result.includes('Claude Code'), 'body must not contain "Claude Code"'); - assert.ok(result.includes('Cline'), 'body must reference Cline'); - }); -}); - -// ─── (b) + (c) + (d) Integration tests ──────────────────────────────────────── - -describe('installRuntimeArtifacts — cline skills emission', () => { - test('cline global: writes gsd-prefixed skill dirs under skills/', (t) => { - const configDir = createTempDir('gsd-cline-skills-'); - t.after(() => cleanup(configDir)); - - installRuntimeArtifacts('cline', configDir, 'global', RESOLVED_CORE); - - const layout = resolveRuntimeArtifactLayout('cline', configDir, 'global'); - const skillsKind = layout.kinds.find(k => k.kind === 'skills'); - assert.ok(skillsKind, 'cline must have a skills kind after #782'); - - const skillsDir = path.join(configDir, skillsKind.destSubpath); - assert.ok(fs.existsSync(skillsDir), 'skills/ directory must be created'); - - const helpSkillDir = path.join(skillsDir, `${skillsKind.prefix}help`); - assert.ok( - fs.existsSync(path.join(helpSkillDir, 'SKILL.md')), - `gsd-help/SKILL.md must exist under ${skillsKind.destSubpath}/` - ); - }); - - test('cline global: SKILL.md has valid cline frontmatter (name + description)', (t) => { - const configDir = createTempDir('gsd-cline-fm-'); - t.after(() => cleanup(configDir)); - - installRuntimeArtifacts('cline', configDir, 'global', RESOLVED_CORE); - - const skillsDir = path.join(configDir, 'skills'); - const helpSkill = path.join(skillsDir, 'gsd-help', 'SKILL.md'); - assert.ok(fs.existsSync(helpSkill), 'gsd-help/SKILL.md must exist'); - - const content = fs.readFileSync(helpSkill, 'utf8'); - // Must have YAML frontmatter - assert.ok(content.startsWith('---'), 'SKILL.md must start with YAML frontmatter'); - assert.ok(content.includes('name:'), 'frontmatter must have name field'); - assert.ok(content.includes('description:'), 'frontmatter must have description field'); - // name must be gsd-help - const nameMatch = content.match(/^name:\s*(.+)$/m); - assert.ok(nameMatch, 'must have name field'); - assert.ok(nameMatch[1].includes('gsd-help'), `name must include gsd-help, got: ${nameMatch[1]}`); - }); - - test('cline global: SKILL.md uses .cline/ paths not .claude/', (t) => { - const configDir = createTempDir('gsd-cline-paths-'); - t.after(() => cleanup(configDir)); - - installRuntimeArtifacts('cline', configDir, 'global', RESOLVED_CORE); - - const skillsDir = path.join(configDir, 'skills'); - // Check all installed skill files for stray .claude/ references - const skills = fs.readdirSync(skillsDir).filter(n => n.startsWith('gsd-')); - assert.ok(skills.length > 0, 'at least one gsd- skill must be installed'); - - for (const skillName of skills) { - const skillFile = path.join(skillsDir, skillName, 'SKILL.md'); - if (!fs.existsSync(skillFile)) continue; - const content = fs.readFileSync(skillFile, 'utf8'); - assert.ok( - !content.includes('~/.claude/'), - `${skillName}/SKILL.md must not contain ~/.claude/ — found stray path` - ); - assert.ok( - !content.includes('/.claude/'), - `${skillName}/SKILL.md must not contain /.claude/ — found stray path` - ); - } - }); - - test('cline global: skill count matches resolved profile', (t) => { - const configDir = createTempDir('gsd-cline-count-'); - t.after(() => cleanup(configDir)); - - installRuntimeArtifacts('cline', configDir, 'global', RESOLVED_CORE); - - const skillsDir = path.join(configDir, 'skills'); - const count = fs.readdirSync(skillsDir) - .filter(n => n.startsWith('gsd-') && fs.statSync(path.join(skillsDir, n)).isDirectory()) - .length; - - if (RESOLVED_CORE.skills !== '*') { - assert.strictEqual(count, RESOLVED_CORE.skills.size, - `installed skill count (${count}) must match profile size (${RESOLVED_CORE.skills.size})`); - } else { - assert.ok(count > 0, 'must install at least 1 skill'); - } - }); -}); - -describe('installRuntimeArtifacts — cline idempotency', () => { - test('cline: running install twice leaves skills intact (idempotency)', (t) => { - const configDir = createTempDir('gsd-cline-idempotent-'); - t.after(() => cleanup(configDir)); - - // First install - installRuntimeArtifacts('cline', configDir, 'global', RESOLVED_CORE); - - const skillsDir = path.join(configDir, 'skills'); - const countAfterFirst = fs.readdirSync(skillsDir) - .filter(n => n.startsWith('gsd-') && fs.statSync(path.join(skillsDir, n)).isDirectory()) - .length; - - // Second install (upgrade over existing) - installRuntimeArtifacts('cline', configDir, 'global', RESOLVED_CORE); - - const countAfterSecond = fs.readdirSync(skillsDir) - .filter(n => n.startsWith('gsd-') && fs.statSync(path.join(skillsDir, n)).isDirectory()) - .length; - - assert.strictEqual(countAfterFirst, countAfterSecond, - `skill count must be stable across installs: first=${countAfterFirst} second=${countAfterSecond}`); - }); -}); - -// ─── (e) Full install() global — coexistence regression ─────────────────────── -// -// Issue #782 explicitly requires that a global Cline install writes BOTH: -// - skills//SKILL.md (skills for Cline >= v3.48) -// - .clinerules/gsd.md (rules dir form introduced by #787) -// -// installRuntimeArtifacts() tests cover skills in isolation; this test exercises -// the FULL install() code path to ensure neither artifact is silently dropped. - -describe('install() global cline — coexistence: skills AND .clinerules', () => { - let tmpGlobalDir; - let originalClineConfigDir; - - beforeEach(() => { - originalClineConfigDir = process.env.CLINE_CONFIG_DIR; - tmpGlobalDir = createTempDir('gsd-cline-global-'); - // Redirect CLINE_CONFIG_DIR to the temp dir so install() never touches ~/.cline - process.env.CLINE_CONFIG_DIR = tmpGlobalDir; - }); - - afterEach(() => { - if (originalClineConfigDir !== undefined) { - process.env.CLINE_CONFIG_DIR = originalClineConfigDir; - } else { - delete process.env.CLINE_CONFIG_DIR; - } - cleanup(tmpGlobalDir); - }); - - test('global cline install writes at least one gsd-* SKILL.md under skills/', () => { - captureConsole(() => install(true, 'cline')); - - const skillsDir = path.join(tmpGlobalDir, 'skills'); - assert.ok( - fs.existsSync(skillsDir), - `skills/ directory must exist under ${tmpGlobalDir} after global cline install` - ); - - // full profile: gsd-help is nested under gsd-ns-manage/skills/help/SKILL.md - const helpSkillFile = nestedSkillPath(skillsDir, 'gsd-', 'help'); - assert.ok( - fs.existsSync(helpSkillFile), - `${path.relative(tmpGlobalDir, helpSkillFile)} must exist under ${tmpGlobalDir} — skills emission broken for global cline` - ); - }); - - test('global cline install writes .clinerules/gsd.md to the global config dir', () => { - captureConsole(() => install(true, 'cline')); - - // For a global Cline install, targetDir = getGlobalDir('cline') = CLINE_CONFIG_DIR. - // The cline-rules surface (#787) writes the .clinerules/ DIRECTORY form: - // .clinerules/gsd.md (rule file) - // .clinerules/hooks/PreToolUse (lifecycle hook) - const clinerulesMd = path.join(tmpGlobalDir, '.clinerules', 'gsd.md'); - assert.ok( - fs.existsSync(clinerulesMd), - `.clinerules/gsd.md must exist at ${clinerulesMd} — coexistence with skills broken for global cline (#782+#787)` - ); - }); - - test('global cline .clinerules/gsd.md contains GSD instructions', () => { - captureConsole(() => install(true, 'cline')); - - // #787 dir form: rule content lives in .clinerules/gsd.md, not a flat .clinerules file - const clinerulesMd = path.join(tmpGlobalDir, '.clinerules', 'gsd.md'); - assert.ok(fs.existsSync(clinerulesMd), '.clinerules/gsd.md must exist'); - const content = fs.readFileSync(clinerulesMd, 'utf8'); - assert.ok( - content.includes('GSD') || content.includes('gsd'), - '.clinerules/gsd.md must reference GSD' - ); - }); -}); - -// ─── Fix 3 regression: converter rewrites bare ~/.claude and CLAUDE_CONFIG_DIR ── -// -// convertClaudeToCliineMarkdown must also handle bare ~/.claude (no trailing -// slash) and the CLAUDE_CONFIG_DIR env-var name. surface.md contains these; -// the emitted Cline SKILL.md must contain no such stale Claude refs. - -describe('convertClaudeToCliineMarkdown — bare ~/.claude and CLAUDE_CONFIG_DIR (Fix 3)', () => { - const surfacePath = path.join(__dirname, '..', 'commands', 'gsd', 'surface.md'); - - test('no bare ~/.claude in converted surface.md', () => { - const raw = fs.readFileSync(surfacePath, 'utf8'); - const result = convertClaudeToCliineMarkdown(raw); - // ~/.claude followed by a word-boundary (not a /) must be gone - assert.ok( - !/~\/\.claude\b/.test(result), - 'converted surface.md must not contain bare ~/.claude' - ); - }); - - test('no CLAUDE_CONFIG_DIR in converted surface.md', () => { - const raw = fs.readFileSync(surfacePath, 'utf8'); - const result = convertClaudeToCliineMarkdown(raw); - assert.ok( - !result.includes('CLAUDE_CONFIG_DIR'), - 'converted surface.md must not contain CLAUDE_CONFIG_DIR' - ); - }); - - test('CLAUDE_CONFIG_DIR rewritten to CLINE_CONFIG_DIR', () => { - const input = 'Use CLAUDE_CONFIG_DIR or $HOME/.claude to configure'; - const result = convertClaudeToCliineMarkdown(input); - assert.ok(result.includes('CLINE_CONFIG_DIR'), 'CLAUDE_CONFIG_DIR must become CLINE_CONFIG_DIR'); - assert.ok(!result.includes('CLAUDE_CONFIG_DIR'), 'CLAUDE_CONFIG_DIR must be gone'); - }); - - test('bare ~/.claude rewritten to ~/.cline', () => { - const input = 'Config dir: (~/.claude), skills at ~/.claude/skills'; - const result = convertClaudeToCliineMarkdown(input); - assert.ok(!result.includes('~/.claude'), 'bare ~/.claude must be rewritten'); - assert.ok(result.includes('~/.cline'), 'must rewrite to ~/.cline'); - }); - - test('installRuntimeArtifacts cline global: gsd-surface SKILL.md has no bare ~/.claude or CLAUDE_CONFIG_DIR', (t) => { - const configDir = createTempDir('gsd-cline-surface-fix3-'); - t.after(() => cleanup(configDir)); - - const MANIFEST_FULL = require('../gsd-core/bin/lib/install-profiles.cjs').loadSkillsManifest( - path.join(__dirname, '..', 'commands', 'gsd') - ); - const RESOLVED_FULL = require('../gsd-core/bin/lib/install-profiles.cjs').resolveProfile({ - modes: ['full'], manifest: MANIFEST_FULL, - }); - - installRuntimeArtifacts('cline', configDir, 'global', RESOLVED_FULL); - - // full profile: surface is nested under gsd-ns-manage/skills/surface/SKILL.md - const surfaceSkill = nestedSkillPath(path.join(configDir, 'skills'), 'gsd-', 'surface'); - assert.ok(fs.existsSync(surfaceSkill), `${path.relative(configDir, surfaceSkill)} must exist for full profile`); - - const content = fs.readFileSync(surfaceSkill, 'utf8'); - assert.ok( - !/~\/\.claude\b/.test(content), - 'gsd-surface SKILL.md must not contain bare ~/.claude (Fix 3)' - ); - assert.ok( - !content.includes('CLAUDE_CONFIG_DIR'), - 'gsd-surface SKILL.md must not contain CLAUDE_CONFIG_DIR (Fix 3)' - ); - }); -}); - -// ─── Fix 1 regression: custom CLINE_CONFIG_DIR → embedded paths use custom dir ── -// -// _applyRuntimeRewrites for cline must rewrite ~/.cline/ → pathPrefix. -// For default global installs, pathPrefix = "$HOME/.cline/" (unchanged). -// For custom installs (CLINE_CONFIG_DIR=/custom), pathPrefix = "/custom/" and -// all embedded ~/.cline/ refs in SKILL.md must become /custom/... - -describe('_applyRuntimeRewrites — cline custom-dir embedded path (Fix 1)', () => { - test('default pathPrefix ($HOME/.cline/) leaves ~/.cline refs as $HOME/.cline', () => { - const content = 'See ~/.cline/skills/gsd-help/SKILL.md for reference.\nBare: ~/.cline\n'; - const result = _applyRuntimeRewrites(content, 'cline', '$HOME/.cline/'); - assert.ok(result.includes('$HOME/.cline/'), 'default prefix must map ~/.cline/ to $HOME/.cline/'); - assert.ok(!result.includes('~/.cline'), 'no tilde form should remain after rewrite'); - }); - - test('custom pathPrefix rewrites ~/.cline/ → custom path in SKILL.md body', () => { - const content = 'See ~/.cline/skills/gsd-help/SKILL.md for reference.\nBare: ~/.cline\n'; - const result = _applyRuntimeRewrites(content, 'cline', '/custom/cline-dir/'); - assert.ok(result.includes('/custom/cline-dir/'), 'custom prefix must appear in output'); - assert.ok(!result.includes('~/.cline'), 'no tilde cline form should remain after custom rewrite'); - }); - - test('custom pathPrefix rewrites residual ~/.claude/ safety net', () => { - const content = 'Residual: ~/.claude/skills\n'; - const result = _applyRuntimeRewrites(content, 'cline', '/custom/cline-dir/'); - assert.ok(result.includes('/custom/cline-dir/'), 'safety-net ~/.claude/ also rewritten to custom prefix'); - assert.ok(!result.includes('~/.claude/'), 'no ~/.claude/ should remain'); - }); - - test('installRuntimeArtifacts cline with CLINE_CONFIG_DIR custom: SKILL.md embeds custom path', (t) => { - const configDir = createTempDir('gsd-cline-custom-dir-'); - t.after(() => cleanup(configDir)); - - const MANIFEST_FULL = require('../gsd-core/bin/lib/install-profiles.cjs').loadSkillsManifest( - path.join(__dirname, '..', 'commands', 'gsd') - ); - const RESOLVED_FULL = require('../gsd-core/bin/lib/install-profiles.cjs').resolveProfile({ - modes: ['full'], manifest: MANIFEST_FULL, - }); - - installRuntimeArtifacts('cline', configDir, 'global', RESOLVED_FULL); - - // gsd-surface SKILL.md references config paths; with a custom configDir - // (not under $HOME), pathPrefix will be the absolute custom path. - // full profile: surface is nested under gsd-ns-manage/skills/surface/SKILL.md - const surfaceSkill = nestedSkillPath(path.join(configDir, 'skills'), 'gsd-', 'surface'); - assert.ok(fs.existsSync(surfaceSkill), `${path.relative(configDir, surfaceSkill)} must exist`); - - const content = fs.readFileSync(surfaceSkill, 'utf8'); - // With a custom dir (path under /tmp, not ~/.cline), the output must NOT - // contain ~/.cline/ or $HOME/.cline/ — it must embed the actual configDir path. - assert.ok( - !content.includes('~/.cline/'), - `gsd-surface SKILL.md must not contain ~/.cline/ when configDir=${configDir} (Fix 1)` - ); - // The custom path must appear somewhere in the file - // (configDir is a /tmp/... path so pathPrefix = configDir+'/'). - // Production normalizes backslashes to forward slashes via - // path.resolve(configDir).replace(/\\/g, '/'), so compare against that - // form — otherwise this assertion fails on Windows where mkdtempSync - // returns a backslash path (e.g. C:\Users\...) but the emitted content - // already has forward slashes (C:/Users/...). - const expectedPath = path.resolve(configDir).replace(/\\/g, '/'); - assert.ok( - content.includes(expectedPath), - `gsd-surface SKILL.md must embed custom configDir path ${expectedPath} (Fix 1)` - ); - }); -}); - -// ─── Fix 4 regression: description truncation is code-point-aware ──────────── -// -// Naive UTF-16 slicing (`str.slice(0, 1021)`) can split a surrogate pair when -// the cut falls between the high and low surrogate of a multibyte character -// (e.g. emoji U+1F600, which is encoded as two UTF-16 code units). The fix -// uses Array.from() to split by code point, guaranteeing that the truncated -// value never contains a lone surrogate. - -describe('convertClaudeCommandToClineSkill — code-point-aware truncation (Fix 4)', () => { - /** - * Build a frontmatter+body command string whose description is: - * - exactly `prefixLen` ASCII chars - * - followed by `emojiCount` repetitions of '😀' (U+1F600, 2 UTF-16 units) - * - total UTF-16 length is prefixLen + emojiCount * 2 - */ - function makeEmojiCommand(prefixLen, emojiCount) { - const desc = 'A'.repeat(prefixLen) + '😀'.repeat(emojiCount); - return `---\nname: gsd:emoji-test\ndescription: ${desc}\n---\n\nBody.\n`; - } - - test('emitted description is <= 1024 code points when source overflows', () => { - // 1020 ASCII chars + 4 emoji = 1020 + 8 UTF-16 units = 1028 UTF-16 units > 1024. - // Code-point count = 1020 + 4 = 1024 — exactly at the boundary BEFORE adding '...'. - // After truncation to 1021 code points + '...' → 1024 code points total. - const cmd = makeEmojiCommand(1020, 10); // 1030 code points → must truncate - const result = convertClaudeCommandToClineSkill(cmd, 'gsd-emoji-test'); - - // Extract raw description value (strip surrounding YAML quotes if present) - const descMatch = result.match(/^description:\s*(.+)$/m); - assert.ok(descMatch, 'emitted SKILL.md must have a description field'); - const rawDesc = descMatch[1].trim().replace(/^['"]|['"]$/g, ''); - - const codePoints = Array.from(rawDesc); - assert.ok( - codePoints.length <= 1024, - `emitted description must be <= 1024 code points, got ${codePoints.length}` - ); - }); - - test('emitted description ends with "..." when truncated', () => { - const cmd = makeEmojiCommand(1020, 10); // 1030 code points → must truncate - const result = convertClaudeCommandToClineSkill(cmd, 'gsd-emoji-test'); - - const descMatch = result.match(/^description:\s*(.+)$/m); - assert.ok(descMatch, 'emitted SKILL.md must have a description field'); - const rawDesc = descMatch[1].trim().replace(/^['"]|['"]$/g, ''); - - assert.ok(rawDesc.endsWith('...'), `truncated description must end with "...", got: ${rawDesc.slice(-10)}`); - }); - - test('emitted description has no lone surrogate (no split emoji)', () => { - // Place emojis exactly at positions 1021–1025 (code points) so that a naive - // UTF-16 slice at 1021 code units would cut inside the second emoji's surrogate pair. - // 1019 ASCII chars + 6 emoji = 1025 code points (>1024, triggers truncation). - // UTF-16 length = 1019 + 12 = 1031. Naive slice(0,1021) yields 1019 ASCII + - // the HIGH surrogate of emoji[0] — a lone surrogate. - const cmd = makeEmojiCommand(1019, 6); - const result = convertClaudeCommandToClineSkill(cmd, 'gsd-emoji-test'); - - const descMatch = result.match(/^description:\s*(.+)$/m); - assert.ok(descMatch, 'emitted SKILL.md must have a description field'); - const rawDesc = descMatch[1].trim().replace(/^['"]|['"]$/g, ''); - - // Verify no lone surrogate: every char's code point must be outside [0xD800, 0xDFFF]. - const hasLoneSurrogate = [...rawDesc].some(c => { - const cp = c.codePointAt(0); - return cp >= 0xD800 && cp <= 0xDFFF; - }); - assert.ok(!hasLoneSurrogate, 'emitted description must not contain a lone surrogate'); - - // Also round-trip through Buffer to confirm the string is valid UTF-8 encodable. - assert.doesNotThrow( - () => Buffer.from(rawDesc, 'utf8').toString('utf8'), - 'emitted description must round-trip through Buffer without error' - ); - }); - - test('short description (<= 1024 code points) is not truncated', () => { - // 10 ASCII + 5 emoji = 15 code points — well under the limit. - const cmd = makeEmojiCommand(10, 5); - const result = convertClaudeCommandToClineSkill(cmd, 'gsd-emoji-test'); - - const descMatch = result.match(/^description:\s*(.+)$/m); - assert.ok(descMatch, 'emitted SKILL.md must have a description field'); - const rawDesc = descMatch[1].trim().replace(/^['"]|['"]$/g, ''); - - assert.ok(!rawDesc.endsWith('...'), 'short description must NOT be truncated with "..."'); - // Must contain the original emoji characters intact - assert.ok(rawDesc.includes('😀'), 'short description must preserve emoji characters'); - }); -}); - -// ─── Fix 2 regression: cline local scope emits no skills ───────────────────── -// -// resolveRuntimeArtifactLayout('cline', dir, 'local') must return 0 kinds. -// installRuntimeArtifacts('cline', dir, 'local') must not write any skills. - -describe('resolveRuntimeArtifactLayout — cline scope-aware (Fix 2)', () => { - test('cline local: kinds.length === 0 (no skills for local scope)', () => { - const { resolveRuntimeArtifactLayout } = require('../gsd-core/bin/lib/runtime-artifact-layout.cjs'); - const layout = resolveRuntimeArtifactLayout('cline', '/tmp/x', 'local'); - assert.strictEqual(layout.kinds.length, 0, 'cline local must have 0 kinds'); - }); - - test('cline global: kinds.length === 1 (skills kind)', () => { - const { resolveRuntimeArtifactLayout } = require('../gsd-core/bin/lib/runtime-artifact-layout.cjs'); - const layout = resolveRuntimeArtifactLayout('cline', '/tmp/x', 'global'); - assert.strictEqual(layout.kinds.length, 1, 'cline global must have 1 skills kind'); - assert.strictEqual(layout.kinds[0].kind, 'skills'); - }); - - test('installRuntimeArtifacts cline local: no skills/ dir created', (t) => { - const configDir = createTempDir('gsd-cline-local-noskills-'); - t.after(() => cleanup(configDir)); - - assert.doesNotThrow(() => installRuntimeArtifacts('cline', configDir, 'local', RESOLVED_CORE)); - const skillsDir = path.join(configDir, 'skills'); - assert.ok( - !fs.existsSync(skillsDir), - `skills/ must NOT be created for cline local install (Fix 2), but found ${skillsDir}` - ); - }); -}); - }); -} - - - -// ──────────────────────────────────────────────────────────────────────── -// Folded from tests/enh-789-codebuddy-commands.test.cjs — consolidation epic #1969 (B1 #1970) -// ──────────────────────────────────────────────────────────────────────── -{ - const { describe: __foldDescribe } = require('node:test'); - __foldDescribe("folded:enh-789-codebuddy-commands (consolidation epic #1969 B1 #1970)", () => { -// allow-test-rule: source-text-is-the-product (see #789) -// Workflow .md / command .md / SKILL.md files — their text IS what the runtime -// loads. Testing emitted text tests the deployed contract. -// Per CONTRIBUTING.md exception matrix. - -/** - * Regression guard — enh(#789): elevate CodeBuddy slash-command surface. - * - * CodeBuddy (Tencent, @tencent-ai/codebuddy-code) reads user-level surfaces - * (https://www.codebuddy.ai/docs/cli/slash-commands, /skills): - * - commands/gsd-.md — slash commands shown in the '/' menu - * - skills/gsd-/SKILL.md — model-invocable skills - * - * Before #789 gsd emitted only skills/. Because CodeBuddy skills default to - * user-invocable:true (appear in '/'), emitting a commands/ surface AND leaving - * skills user-invocable would duplicate every /gsd-* entry. #789 therefore: - * 1. emits commands/gsd-.md (the '/' surface, peer-consistent with - * Cursor #785 and Augment #790), - * 2. marks skills user-invocable:false so they become model-invocable - * background knowledge and the commands/ surface is the sole '/' surface. - * - * Subagents are already emitted via the generic agents block + convertClaude - * AgentToCodebuddyAgent (~/.codebuddy/agents/), so #789 adds no agents change. - * - * mcp.json is intentionally NOT written: gsd ships no MCP server, and CodeBuddy's - * mcp.json holds an `mcpServers` map of *external* servers to connect to — - * there is nothing for gsd to register. Same exclusion as #784/#785/#790. - */ -'use strict'; - -process.env.GSD_TEST_MODE = '1'; - -const { describe, test } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('node:fs'); -const path = require('node:path'); - -const { createTempDir, cleanup } = require('./helpers.cjs'); - -const { - convertClaudeCommandToCodebuddyCommand, - convertClaudeCommandToCodebuddySkill, -} = require('../bin/install.js'); - -const { - installRuntimeArtifacts, - uninstallRuntimeArtifacts, -} = require('../gsd-core/bin/lib/install-engine.cjs'); -const { resolveRuntimeArtifactLayout } = require('../gsd-core/bin/lib/runtime-artifact-layout.cjs'); -const { loadSkillsManifest, resolveProfile } = require('../gsd-core/bin/lib/install-profiles.cjs'); - -const REAL_COMMANDS_DIR = path.join(__dirname, '..', 'commands', 'gsd'); -const MANIFEST = loadSkillsManifest(REAL_COMMANDS_DIR); -const RESOLVED_CORE = resolveProfile({ modes: ['core'], manifest: MANIFEST }); - -// ─── Layout contract ───────────────────────────────────────────────────────── - -describe('enh-789 — codebuddy layout has commands + skills kinds', () => { - test('resolveRuntimeArtifactLayout codebuddy returns 3 kinds (ADR-1235 §1 agents cutover)', () => { - const layout = resolveRuntimeArtifactLayout('codebuddy', '/tmp/fake-codebuddy-dir'); - assert.strictEqual(layout.kinds.length, 3, 'codebuddy must have exactly 3 artifact kinds (commands + skills + agents)'); - const kindNames = layout.kinds.map(k => k.kind).sort(); - assert.deepStrictEqual(kindNames, ['agents', 'commands', 'skills']); - }); - - test('codebuddy commands kind targets commands/ with gsd- prefix', () => { - const layout = resolveRuntimeArtifactLayout('codebuddy', '/tmp/fake-codebuddy-dir'); - const commandsKind = layout.kinds.find(k => k.kind === 'commands'); - assert.ok(commandsKind, 'must have commands kind'); - assert.strictEqual(commandsKind.destSubpath, 'commands'); - assert.strictEqual(commandsKind.prefix, 'gsd-'); - assert.strictEqual(typeof commandsKind.stage, 'function'); - }); - - test('codebuddy skills kind targets skills/ with gsd- prefix', () => { - const layout = resolveRuntimeArtifactLayout('codebuddy', '/tmp/fake-codebuddy-dir'); - const skillsKind = layout.kinds.find(k => k.kind === 'skills'); - assert.ok(skillsKind, 'must have skills kind'); - assert.strictEqual(skillsKind.destSubpath, 'skills'); - assert.strictEqual(skillsKind.prefix, 'gsd-'); - }); -}); - -// ─── Command converter contract ────────────────────────────────────────────── - -describe('enh-789 — convertClaudeCommandToCodebuddyCommand', () => { - const SRC = [ - '---', - 'name: gsd:new-project', - 'description: Initialize a project', - 'argument-hint: "[name]"', - 'allowed-tools:', - ' - Read', - '---', - '', - 'Use .claude/skills/ and run /gsd:help. Claude Code reads CLAUDE.md.', - '', - ].join('\n'); - - test('emits a description-only frontmatter (no Claude-specific name: gsd:)', () => { - const out = convertClaudeCommandToCodebuddyCommand(SRC, 'gsd-new-project'); - assert.ok(out.startsWith('---\n'), 'must begin with frontmatter'); - assert.ok(/^description:/m.test(out), 'must carry a description field'); - assert.ok(!out.includes('name: gsd:new-project'), 'must drop Claude colon-form name field'); - }); - - test('preserves a present argument-hint (CodeBuddy supports it)', () => { - const out = convertClaudeCommandToCodebuddyCommand(SRC, 'gsd-new-project'); - assert.ok(/^argument-hint:\s*["']?\[name\]["']?\s*$/m.test(out), - `argument-hint must be carried through when present in source. Got:\n${out}`); - }); - - test('converts body Claude-isms to CodeBuddy equivalents', () => { - const out = convertClaudeCommandToCodebuddyCommand(SRC, 'gsd-new-project'); - assert.ok(out.includes('.codebuddy/skills/'), out); - assert.ok(out.includes('/gsd-help'), out); - assert.ok(out.includes('CODEBUDDY.md'), out); - assert.ok(!/\bClaude Code\b/.test(out), 'must rebrand "Claude Code"'); - }); -}); - -describe('enh-789 — skills marked user-invocable:false', () => { - test('convertClaudeCommandToCodebuddySkill emits user-invocable: false', () => { - const src = [ - '---', - 'name: gsd:help', - 'description: Show help', - '---', - '', - '# body', - '', - ].join('\n'); - const out = convertClaudeCommandToCodebuddySkill(src, 'gsd-help'); - assert.ok(/^user-invocable:\s*false\s*$/m.test(out), - `SKILL.md frontmatter must hide skill from '/' menu (user-invocable: false). Got:\n${out}`); - }); -}); - -// ─── Install contract ──────────────────────────────────────────────────────── - -describe('enh-789 — installRuntimeArtifacts codebuddy emits commands and skills', () => { - test('global codebuddy install: commands/gsd-help.md and skills/gsd-help/SKILL.md exist', (t) => { - const configDir = createTempDir('gsd-enh789-codebuddy-'); - t.after(() => cleanup(configDir)); - - installRuntimeArtifacts('codebuddy', configDir, 'global', RESOLVED_CORE); - - const commandsDir = path.join(configDir, 'commands'); - assert.ok(fs.existsSync(commandsDir), 'commands/ dir must exist'); - const cmdFiles = fs.readdirSync(commandsDir).filter(f => f.startsWith('gsd-') && f.endsWith('.md')); - assert.ok(cmdFiles.length > 0, 'at least one gsd-*.md command file must be installed'); - assert.ok(fs.existsSync(path.join(commandsDir, 'gsd-help.md')), 'commands/gsd-help.md must exist'); - - const skillsDir = path.join(configDir, 'skills'); - assert.ok(fs.existsSync(skillsDir), 'skills/ dir must exist'); - assert.ok(fs.existsSync(path.join(skillsDir, 'gsd-help', 'SKILL.md')), 'skills/gsd-help/SKILL.md must exist'); - }); - - test('installed commands/gsd-help.md is CodeBuddy-compatible (no raw ~/.claude/, rebranded)', (t) => { - const configDir = createTempDir('gsd-enh789-content-'); - t.after(() => cleanup(configDir)); - - installRuntimeArtifacts('codebuddy', configDir, 'global', RESOLVED_CORE); - - const helpCmd = path.join(configDir, 'commands', 'gsd-help.md'); - const content = fs.readFileSync(helpCmd, 'utf8'); - assert.ok(!content.includes('~/.claude/'), 'commands must not contain raw ~/.claude/ refs'); - assert.ok(content.startsWith('---'), 'commands must carry frontmatter'); - }); - - test('installed skills/gsd-help/SKILL.md is hidden from the / menu', (t) => { - const configDir = createTempDir('gsd-enh789-skillhide-'); - t.after(() => cleanup(configDir)); - - installRuntimeArtifacts('codebuddy', configDir, 'global', RESOLVED_CORE); - - const skill = fs.readFileSync(path.join(configDir, 'skills', 'gsd-help', 'SKILL.md'), 'utf8'); - assert.ok(/^user-invocable:\s*false\s*$/m.test(skill), - 'installed SKILL.md must set user-invocable: false'); - }); - - test('command count matches skill count (profile parity)', (t) => { - const configDir = createTempDir('gsd-enh789-parity-'); - t.after(() => cleanup(configDir)); - - installRuntimeArtifacts('codebuddy', configDir, 'global', RESOLVED_CORE); - - const cmdCount = fs.readdirSync(path.join(configDir, 'commands')) - .filter(f => f.startsWith('gsd-') && f.endsWith('.md')).length; - const skillCount = fs.readdirSync(path.join(configDir, 'skills'), { withFileTypes: true }) - .filter(e => e.isDirectory() && e.name.startsWith('gsd-')).length; - assert.strictEqual(cmdCount, skillCount, 'command count must equal skill count for same profile'); - }); - - test('full profile install: no $HOME/.codebuddy or ~/.codebuddy leak in any command', (t) => { - // The codebuddy converter rewrites `.claude/` → `.codebuddy/`, so source - // refs like `@$HOME/.claude/gsd-core/...` (e.g. plan-review-convergence.md) - // must be normalized to the install target — not left as $HOME/.codebuddy. - const RESOLVED_FULL = resolveProfile({ modes: ['full'], manifest: MANIFEST }); - const configDir = createTempDir('gsd-enh789-noleak-'); - t.after(() => cleanup(configDir)); - - installRuntimeArtifacts('codebuddy', configDir, 'global', RESOLVED_FULL); - - const commandsDir = path.join(configDir, 'commands'); - for (const f of fs.readdirSync(commandsDir).filter(n => n.endsWith('.md'))) { - const content = fs.readFileSync(path.join(commandsDir, f), 'utf8'); - assert.ok(!content.includes('$HOME/.codebuddy'), `${f} must not leak $HOME/.codebuddy`); - assert.ok(!content.includes('~/.codebuddy'), `${f} must not leak ~/.codebuddy`); - assert.ok(!content.includes('.claude/'), `${f} must not retain raw .claude/ refs`); - } - }); - - test('full profile install does NOT mutate source commands/gsd/ files', (t) => { - const RESOLVED_FULL = resolveProfile({ modes: ['full'], manifest: MANIFEST }); - assert.strictEqual(RESOLVED_FULL.skills, '*', 'full profile must have skills === "*"'); - - const configDir = createTempDir('gsd-enh789-full-'); - t.after(() => cleanup(configDir)); - - const srcHelpPath = path.join(REAL_COMMANDS_DIR, 'help.md'); - const before = fs.readFileSync(srcHelpPath, 'utf8'); - - installRuntimeArtifacts('codebuddy', configDir, 'global', RESOLVED_FULL); - - const after = fs.readFileSync(srcHelpPath, 'utf8'); - assert.strictEqual(before, after, 'source commands/gsd/help.md must not be mutated by the install'); - }); -}); - -// ─── Uninstall contract ────────────────────────────────────────────────────── - -describe('enh-789 — uninstallRuntimeArtifacts removes codebuddy commands', () => { - test('uninstall removes gsd-* commands but preserves user commands', (t) => { - const configDir = createTempDir('gsd-enh789-uninstall-'); - t.after(() => cleanup(configDir)); - - const commandsDir = path.join(configDir, 'commands'); - fs.mkdirSync(commandsDir, { recursive: true }); - fs.writeFileSync(path.join(commandsDir, 'gsd-help.md'), '# help\n'); - fs.writeFileSync(path.join(commandsDir, 'user-custom.md'), '# user\n'); - - uninstallRuntimeArtifacts('codebuddy', configDir, 'global'); - - assert.ok(!fs.existsSync(path.join(commandsDir, 'gsd-help.md')), 'gsd-help.md must be removed'); - assert.ok(fs.existsSync(path.join(commandsDir, 'user-custom.md')), 'user-custom.md must be preserved'); - }); -}); - -// ─── mcp.json exclusion ────────────────────────────────────────────────────── - -describe('enh-789 — mcp.json excluded (gsd ships no MCP server)', () => { - test('codebuddy install does not write mcp.json / .mcp.json', (t) => { - const configDir = createTempDir('gsd-enh789-mcp-excluded-'); - t.after(() => cleanup(configDir)); - - installRuntimeArtifacts('codebuddy', configDir, 'global', RESOLVED_CORE); - - assert.ok(!fs.existsSync(path.join(configDir, 'mcp.json')), 'must not write mcp.json'); - assert.ok(!fs.existsSync(path.join(configDir, '.mcp.json')), 'must not write .mcp.json'); - }); -}); - }); -} - - -// ──────────────────────────────────────────────────────────────────────── -// Folded from tests/bug-2794-opencode-model-profile-overrides.test.cjs — consolidation epic #1969 (B1 #1970) -// ──────────────────────────────────────────────────────────────────────── -{ - const { describe: __foldDescribe } = require('node:test'); - __foldDescribe("folded:bug-2794-opencode-model-profile-overrides (consolidation epic #1969 B1 #1970)", () => { -/** - * Regression test for bug #2794 - * - * OpenCode generated agents ignored `model_profile_overrides.opencode.*`. - * The agent install path called `readGsdEffectiveModelOverrides` (explicit - * per-agent overrides) but never called `readGsdRuntimeProfileResolver` - * (tier-based profile overrides). When a user configured: - * - * { runtime: "opencode", model_profile_overrides: { opencode: { sonnet: "..." } } } - * - * generated `.opencode/agents/gsd-*.md` files contained no `model:` frontmatter. - * - * The fix adds a tier-resolver fallback in the OpenCode agent conversion block: - * explicit `model_overrides[agent]` > `model_profile_overrides.opencode.` > omit. - * - * This test exercises: - * 1. `readGsdRuntimeProfileResolver` correctly resolves OpenCode tier overrides. - * 2. The agent install code path embeds the resolved model into OpenCode frontmatter. - * 3. Explicit `model_overrides` still wins over tier-based resolution. - * 4. Missing overrides produce no `model:` field (no regression on omit behavior). - */ - -process.env.GSD_TEST_MODE = '1'; - -const { describe, test, beforeEach, afterEach } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('node:fs'); -const path = require('node:path'); -const { - readGsdRuntimeProfileResolver, - install, -} = require('../bin/install.js'); - -const { createTempDir, cleanup } = require('./helpers.cjs'); -const makeTmp = (prefix) => createTempDir(`gsd-2794-${prefix}-`); - -function writeJson(p, obj) { - fs.mkdirSync(path.dirname(p), { recursive: true }); - fs.writeFileSync(p, JSON.stringify(obj, null, 2), 'utf-8'); -} - - -describe('bug-2794: readGsdRuntimeProfileResolver resolves opencode tier overrides', () => { - let projectDir; - let homeDir; - let origHome; - let origUP; - - beforeEach(() => { - projectDir = makeTmp('proj'); - homeDir = makeTmp('home'); - origHome = process.env.HOME; - origUP = process.env.USERPROFILE; - process.env.HOME = homeDir; - process.env.USERPROFILE = homeDir; - }); - - afterEach(() => { - if (origHome === undefined) delete process.env.HOME; - else process.env.HOME = origHome; - if (origUP === undefined) delete process.env.USERPROFILE; - else process.env.USERPROFILE = origUP; - cleanup(projectDir); - cleanup(homeDir); - }); - - test('resolves opencode sonnet tier to user-supplied model ID', () => { - writeJson(path.join(projectDir, '.planning', 'config.json'), { - runtime: 'opencode', - model_profile: 'balanced', - model_profile_overrides: { - opencode: { - sonnet: 'anthropic/claude-sonnet-4-7', - }, - }, - }); - - const resolver = readGsdRuntimeProfileResolver(projectDir); - assert.ok(resolver !== null, 'expected a resolver for opencode runtime'); - - // gsd-roadmapper balanced tier = sonnet — should resolve to override - const entry = resolver.resolve('gsd-roadmapper'); - assert.ok(entry !== null, 'expected entry for gsd-roadmapper'); - assert.strictEqual(entry.model, 'anthropic/claude-sonnet-4-7', 'sonnet override applied'); - }); - - test('returns null resolver when runtime is not set', () => { - writeJson(path.join(projectDir, '.planning', 'config.json'), { - model_profile: 'balanced', - model_profile_overrides: { opencode: { sonnet: 'x' } }, - }); - const resolver = readGsdRuntimeProfileResolver(projectDir); - assert.strictEqual(resolver, null, 'no resolver without runtime field'); - }); - - test('resolver returns null for agent not in MODEL_PROFILES', () => { - writeJson(path.join(projectDir, '.planning', 'config.json'), { - runtime: 'opencode', - model_profile: 'balanced', - model_profile_overrides: { opencode: { sonnet: 'x' } }, - }); - const resolver = readGsdRuntimeProfileResolver(projectDir); - assert.ok(resolver !== null); - const entry = resolver.resolve('gsd-nonexistent-agent'); - assert.strictEqual(entry, null, 'unknown agent name yields null'); - }); -}); - -describe('bug-2794: OpenCode agent install embeds model_profile_overrides model', () => { - let projectDir; - let homeDir; - let origHome; - let origUP; - let origCwd; - - beforeEach(() => { - projectDir = makeTmp('proj'); - homeDir = makeTmp('home'); - origHome = process.env.HOME; - origUP = process.env.USERPROFILE; - origCwd = process.cwd(); - process.env.HOME = homeDir; - process.env.USERPROFILE = homeDir; - process.chdir(projectDir); - }); - - afterEach(() => { - if (origHome === undefined) delete process.env.HOME; - else process.env.HOME = origHome; - if (origUP === undefined) delete process.env.USERPROFILE; - else process.env.USERPROFILE = origUP; - process.chdir(origCwd); - cleanup(projectDir); - cleanup(homeDir); - }); - - test('generated OpenCode agent frontmatter includes model from model_profile_overrides', () => { - writeJson(path.join(projectDir, '.planning', 'config.json'), { - runtime: 'opencode', - model_profile: 'balanced', - model_profile_overrides: { - opencode: { - sonnet: 'anthropic/claude-sonnet-4-7', - opus: 'anthropic/claude-opus-4-7', - haiku: 'anthropic/claude-haiku-4-5', - }, - }, - }); - - const oldLog = console.log; - console.log = () => {}; - try { - install(false, 'opencode'); - } finally { - console.log = oldLog; - } - - const agentsDir = path.join(projectDir, '.opencode', 'agents'); - assert.ok(fs.existsSync(agentsDir), 'agents directory should be created'); - - // gsd-roadmapper is balanced -> sonnet tier - const roadmapperPath = path.join(agentsDir, 'gsd-roadmapper.md'); - assert.ok(fs.existsSync(roadmapperPath), 'gsd-roadmapper.md should exist'); - const roadmapperContent = fs.readFileSync(roadmapperPath, 'utf-8'); - assert.match( - roadmapperContent, - /^model: anthropic\/claude-sonnet-4-7$/m, - 'gsd-roadmapper should have sonnet model from model_profile_overrides' - ); - - // gsd-planner is balanced -> opus tier - const plannerPath = path.join(agentsDir, 'gsd-planner.md'); - assert.ok(fs.existsSync(plannerPath), 'gsd-planner.md should exist'); - const plannerContent = fs.readFileSync(plannerPath, 'utf-8'); - assert.match( - plannerContent, - /^model: anthropic\/claude-opus-4-7$/m, - 'gsd-planner should have opus model from model_profile_overrides' - ); - }); - - test('explicit model_overrides[agent] wins over model_profile_overrides tier', () => { - writeJson(path.join(projectDir, '.planning', 'config.json'), { - runtime: 'opencode', - model_profile: 'balanced', - model_overrides: { - 'gsd-roadmapper': 'explicit-winner-model', - }, - model_profile_overrides: { - opencode: { - sonnet: 'tier-model-that-should-lose', - }, - }, - }); - - const oldLog = console.log; - console.log = () => {}; - try { - install(false, 'opencode'); - } finally { - console.log = oldLog; - } - - const roadmapperPath = path.join(projectDir, '.opencode', 'agents', 'gsd-roadmapper.md'); - assert.ok(fs.existsSync(roadmapperPath)); - const content = fs.readFileSync(roadmapperPath, 'utf-8'); - assert.match( - content, - /^model: explicit-winner-model$/m, - 'explicit model_overrides must win over model_profile_overrides tier' - ); - assert.doesNotMatch( - content, - /tier-model-that-should-lose/, - 'tier model must not appear when explicit override is present' - ); - }); - - test('no model field when neither model_overrides nor model_profile_overrides is set', () => { - writeJson(path.join(projectDir, '.planning', 'config.json'), { - runtime: 'opencode', - model_profile: 'balanced', - }); - - const oldLog = console.log; - console.log = () => {}; - try { - install(false, 'opencode'); - } finally { - console.log = oldLog; - } - - const roadmapperPath = path.join(projectDir, '.opencode', 'agents', 'gsd-roadmapper.md'); - if (fs.existsSync(roadmapperPath)) { - const content = fs.readFileSync(roadmapperPath, 'utf-8'); - // When no overrides, model field should either be absent or use built-in default - // The key invariant: no model field if there are no user-supplied overrides - // AND no built-in opencode defaults for this tier - // (gsd-roadmapper balanced = sonnet; opencode has built-in sonnet defaults) - // So we only assert no crash and no tier-model-not-provided entries - assert.ok(typeof content === 'string', 'agent file should be a string'); - } - // Key: no exception thrown (test passes = no crash on missing overrides) - }); -}); - }); -} - - -// ──────────────────────────────────────────────────────────────────────── -// Folded from tests/bug-2643-skill-frontmatter-name.test.cjs — consolidation epic #1969 (B1 #1970) -// ──────────────────────────────────────────────────────────────────────── -{ - const { describe: __foldDescribe } = require('node:test'); - __foldDescribe("folded:bug-2643-skill-frontmatter-name (consolidation epic #1969 B1 #1970)", () => { -'use strict'; - -process.env.GSD_TEST_MODE = '1'; - -/** - * Bug #2643 / #2808: skill frontmatter name parity. - * - * Original (#2643): workflows emitted Skill(skill="gsd:") and the - * installer registered colon form in SKILL.md name: to match. - * - * Updated (#2808): workflows now use Skill(skill="gsd-") (hyphen), - * and the installer emits name: gsd- (hyphen). Claude Code autocomplete - * now shows the canonical hyphen form instead of the deprecated colon form. - * The directory name (gsd-) is unchanged. - */ - -const { test, describe } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('node:fs'); -const path = require('node:path'); - -const ROOT = path.join(__dirname, '..'); -const { - convertClaudeCommandToClaudeSkill, - skillFrontmatterName, -} = require(path.join(ROOT, 'bin', 'install.js')); - -const WORKFLOWS_DIR = path.join(ROOT, 'gsd-core', 'workflows'); -const COMMANDS_DIR = path.join(ROOT, 'commands', 'gsd'); - -function collectFiles(dir, results) { - if (!results) results = []; - let entries; - try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return results; } - for (const e of entries) { - const full = path.join(dir, e.name); - if (e.isDirectory()) collectFiles(full, results); - else if (e.name.endsWith('.md')) results.push(full); - } - return results; -} - -/** - * Extract every `Skill(skill="")` invocation as a structured record. - * - * Per project test rigor (`feedback_no_source_grep_tests.md`), this parses - * each call as a unit instead of leaning on a single regex over raw bytes. - * The flow is: - * - * 1. Strip HTML comments so commented-out examples don't count as drift. - * 2. Walk the content for `Skill(` openers; for each, find the matching - * `)` closer (Skill bodies are simple kwarg lists, no nesting). - * 3. Parse the call body for the `skill = "..."` keyword argument. - * Permissive whitespace around the keyword and `=`, permissive - * single/double quoting (with optional `\` escapes from string- - * embedded examples), permissive name body — so malformed drift like - * `Skill(skill="gsd:extract_learnings")` is surfaced rather than - * silently skipped by an over-strict character class. - * - * Returns `[{ name, raw }]` per call. Filtering by namespace (gsd- vs gsd:) - * happens at the call site so the extractor stays neutral. - */ -function extractSkillCalls(content) { - // regex-free HTML-comment stripper (CodeQL: avoid incomplete-multi-character-sanitization) - let stripped = ''; - { - let rest = content; - let idx; - while ((idx = rest.indexOf('', idx + 4); - if (end === -1) { rest = ''; break; } - rest = rest.slice(end + 3); - } - stripped += rest; - } - const calls = []; - // Body class excludes backslash so the extractor doesn't include an - // escape character that precedes the closing quote in embedded examples - // (e.g. `Skill(skill=\"gsd-plan-phase\", …)` written inside a string - // context). A trailing `\` is permitted on the closing-quote side via the - // optional `\\?` so both `\"` and `"` close the value cleanly. - const argRe = /^\s*skill\s*=\s*\\?(['"])([^'"\\]+)\\?\1/i; - let i = 0; - while (i < stripped.length) { - const open = stripped.indexOf('Skill(', i); - if (open === -1) break; - const close = stripped.indexOf(')', open); - if (close === -1) break; - const body = stripped.slice(open + 'Skill('.length, close); - const match = body.match(argRe); - if (match) calls.push({ name: match[2], raw: stripped.slice(open, close + 1) }); - i = close + 1; - } - return calls; -} - -function extractSkillNamesHyphen(content) { - return new Set( - extractSkillCalls(content) - .map((c) => c.name) - .filter((n) => n.startsWith('gsd-')), - ); -} - -function extractSkillNamesColon(content) { - return new Set( - extractSkillCalls(content) - .map((c) => c.name) - .filter((n) => n.startsWith('gsd:')), - ); -} - -describe('skill frontmatter name parity (#2643 / #2808)', () => { - test('skillFrontmatterName helper emits hyphen form (#2808)', () => { - assert.strictEqual(typeof skillFrontmatterName, 'function'); - assert.strictEqual(skillFrontmatterName('gsd-execute-phase'), 'gsd-execute-phase'); - assert.strictEqual(skillFrontmatterName('gsd-plan-phase'), 'gsd-plan-phase'); - assert.strictEqual(skillFrontmatterName('gsd-next'), 'gsd-next'); - }); - - test('convertClaudeCommandToClaudeSkill emits name: gsd- (hyphen)', () => { - const input = '---\nname: old\ndescription: test\n---\n\nBody.'; - const result = convertClaudeCommandToClaudeSkill(input, 'gsd-execute-phase'); - // Parse the frontmatter block structurally: extract the name: field value. - const frontmatterMatch = result.match(/^---\r?\n([\s\S]*?)\r?\n---/); - assert.ok(frontmatterMatch, 'output must have a frontmatter block delimited by ---'); - const frontmatterLines = frontmatterMatch[1].split(/\r?\n/); - const nameEntry = frontmatterLines.find((l) => l.startsWith('name:')); - assert.ok(nameEntry, 'frontmatter must contain a name: field'); - const nameValue = nameEntry.replace(/^name:\s*/, '').trim(); - assert.strictEqual( - nameValue, - 'gsd-execute-phase', - `frontmatter name: must be 'gsd-execute-phase' (hyphen form), got '${nameValue}'` - ); - }); - - test('no workflow uses deprecated Skill(skill="gsd:") colon form', () => { - const workflowFiles = collectFiles(WORKFLOWS_DIR); - const colonRefs = []; - for (const f of workflowFiles) { - const src = fs.readFileSync(f, 'utf-8'); - for (const n of extractSkillNamesColon(src)) { - colonRefs.push(path.basename(f) + ': ' + n); - } - } - assert.deepStrictEqual( - colonRefs, - [], - 'deprecated colon-form Skill() calls found (update to hyphen): ' + colonRefs.join(', ') - ); - }); - - test('every workflow Skill(skill="gsd-") resolves to an emitted skill name', () => { - const workflowFiles = collectFiles(WORKFLOWS_DIR); - const referenced = new Set(); - const templatedSkipped = []; - for (const f of workflowFiles) { - const src = fs.readFileSync(f, 'utf-8'); - for (const n of extractSkillNamesHyphen(src)) { - // Skip template expressions (e.g. `gsd-${ref.skill}`): these are - // capability-dispatched — the skill stem is resolved at runtime from - // the `loop render-hooks` registry output (ADR-857 phase 6), so there - // is no single literal skill file to validate against here. - // The capability registry's own validateStep gate (gen-capability-registry.cjs) - // is responsible for ensuring each `steps[].ref.skill` corresponds to a - // real skill declared in the capability's `skills` array. - if (n.includes('${')) { - templatedSkipped.push(path.basename(f) + ': ' + n); - } else { - referenced.add(n); - } - } - } - assert.ok( - referenced.size > 0, - `expected at least one literal Skill(skill="gsd-") reference in workflows under ${WORKFLOWS_DIR}` - ); - - const emitted = new Set(); - const cmdFiles = fs.readdirSync(COMMANDS_DIR).filter(f => f.endsWith('.md')); - for (const cmd of cmdFiles) { - const base = cmd.replace(/\.md$/, ''); - const skillDirName = 'gsd-' + base; - const src = fs.readFileSync(path.join(COMMANDS_DIR, cmd), 'utf-8'); - const out = convertClaudeCommandToClaudeSkill(src, skillDirName); - const m = out.match(/^---\r?\nname:\s*(.+)$/m); - if (m) emitted.add(m[1].trim()); - } - - const missing = []; - for (const r of referenced) if (!emitted.has(r)) missing.push(r); - assert.deepStrictEqual( - missing, - [], - 'workflow refs not emitted as skill names: ' + missing.join(', '), - ); - // Informational: report how many templated dispatches were intentionally skipped. - // (Templated names are validated by the capability registry, not statically here.) - if (templatedSkipped.length > 0) { - // Not a failure — just a note for test output transparency. - // Use a diagnostic comment: node:test does not have a skip-within-test API. - } - }); -}); - }); -} - - -// ──────────────────────────────────────────────────────────────────────── -// Folded from tests/enh-778-cross-runtime-command-enrichment.test.cjs — consolidation epic #1969 (B1 #1970) -// ──────────────────────────────────────────────────────────────────────── -{ - const { describe: __foldDescribe } = require('node:test'); - __foldDescribe("folded:enh-778-cross-runtime-command-enrichment (consolidation epic #1969 B1 #1970)", () => { -// allow-test-rule: source-text-is-the-product (see #778) -// Reads .md/SKILL.md/.toml product files whose deployed text IS what the -// runtime loads — testing text content tests the deployed contract. - -/** - * GSD Tools Tests — #778 cross-runtime command enrichment. - * - * Qwen Code skills: numeric `priority` field (higher sorts earlier in the - * /skills TUI listing per the Qwen skills spec). Scoped to runtime='qwen'. - * - * The OpenCode sub-feature (per-command model/agent/subtask/variant) is - * intentionally NOT implemented — see PR description: `model` reintroduces the - * #1156 ProviderModelNotFoundError regression for non-Anthropic OpenCode users, - * `subtask`/`agent` change execution semantics for GSD's interactive commands, - * and `variant` is not in the OpenCode command schema. - * - * #1928: the Gemini custom-command TOML sub-feature ($ARGUMENTS → {{args}} - * interpolation, !{cat .planning/STATE.md} live-state injection) was removed - * along with the gemini runtime (Google sunset Gemini CLI 2026-06-18). - * convertClaudeToGeminiMarkdown no longer exists in bin/install.js. - * - * Uses node:test and node:assert (NOT Jest). - */ - -process.env.GSD_TEST_MODE = '1'; - -const { test, describe } = require('node:test'); -const assert = require('node:assert/strict'); - -const { - convertClaudeCommandToClaudeSkill, -} = require('../bin/install.js'); - -// ─── (b) Qwen Code: priority ordering ─────────────────────────────────────── - -describe('#778 (b) Qwen skills priority', () => { - const mk = (name, desc, body) => - ['---', `name: gsd:${name}`, `description: ${desc}`, '---', '', body].join('\n'); - - test('emits numeric priority for a core-loop command (runtime=qwen)', () => { - const result = convertClaudeCommandToClaudeSkill( - mk('plan-phase', 'Plan a phase', 'Body.'), - 'gsd-plan-phase', - 'qwen', - [] - ); - const m = result.match(/^priority:\s*(\d+)\s*$/m); - assert.ok(m, 'priority field present for gsd-plan-phase'); - assert.equal(Number(m[1]) > 0, true, 'priority is a positive number'); - }); - - test('core loop ranks higher than mid-tier (higher = earlier per spec)', () => { - const np = convertClaudeCommandToClaudeSkill( - mk('new-project', 'Start a project', 'Body.'), 'gsd-new-project', 'qwen', [] - ).match(/^priority:\s*(\d+)/m); - const help = convertClaudeCommandToClaudeSkill( - mk('help', 'Help', 'Body.'), 'gsd-help', 'qwen', [] - ).match(/^priority:\s*(\d+)/m); - assert.ok(np && help, 'both core and mid-tier get a priority'); - assert.ok( - Number(np[1]) > Number(help[1]), - 'new-project (core) sorts earlier than help (utility) — higher value' - ); - }); - - test('utility command NOT in the priority map gets no priority field', () => { - const result = convertClaudeCommandToClaudeSkill( - mk('stats', 'Show stats', 'Body.'), 'gsd-stats', 'qwen', [] - ); - assert.ok(!/^priority:/m.test(result), 'no priority emitted for unmapped utility'); - }); - - test('does NOT emit priority for non-qwen runtimes (scoped to qwen)', () => { - for (const rt of [null, 'claude', 'hermes']) { - const result = convertClaudeCommandToClaudeSkill( - mk('plan-phase', 'Plan a phase', 'Body.'), 'gsd-plan-phase', rt, [] - ); - assert.ok(!/^priority:/m.test(result), `no priority for runtime=${rt}`); - } - }); -}); - - }); -} - - -// ──────────────────────────────────────────────────────────────────────── -// Folded from tests/enh-769-context-fork-effort.install.test.cjs — consolidation epic #1969 (B1 #1970) -// ──────────────────────────────────────────────────────────────────────── -{ - const { describe: __foldDescribe } = require('node:test'); - __foldDescribe("folded:enh-769-context-fork-effort.install (consolidation epic #1969 B1 #1970)", () => { -// allow-test-rule: integration-test-input (see #769) -// Exercises install() as a black-box by inspecting produced SKILL.md output -// in a temp dir. Source command .md files are inputs whose installed -// transformation is asserted — not inspected for string presence. - -/** - * #769 — effort: frontmatter on heavy workflow skills. - * #921 — spawning orchestrators must NOT carry context: fork. - * - * Context: context:fork was added by #769 to protect context budget, but - * plan-phase, execute-phase, and autonomous are spawning orchestrators — a - * forked subagent has no Agent/Task tool, breaking their core function. - * effort: max is preserved; context: fork is removed from these three. - * The converter still passes context: fork through if a source file has it - * (for any future leaf skill that legitimately needs isolation). - * - * Verifies: - * 1. Source commands/gsd/autonomous.md does NOT have context: fork, has effort: max - * 2. Source commands/gsd/execute-phase.md does NOT have context: fork, has effort: max - * 3. Source commands/gsd/plan-phase.md does NOT have context: fork, has effort: max - * 4. Source commands/gsd/progress.md has effort: low - * 5. Source commands/gsd/stats.md has effort: low - * 6. Claude global install: SKILL.md for autonomous has effort: max, NOT context: fork - * 7. Claude global install: SKILL.md for execute-phase has effort: max, NOT context: fork - * 8. Claude global install: SKILL.md for plan-phase has effort: max, NOT context: fork - * 9. Claude global install: SKILL.md for progress has effort: low - * 10. Claude global install: SKILL.md for stats has effort: low - * 11. convertClaudeCommandToClaudeSkill still passes context: fork through (for non-orchestrator skills) - * 12. convertClaudeCommandToClaudeSkill emits portable effort: field values - */ - -'use strict'; - -process.env.GSD_TEST_MODE = '1'; - -const { describe, test, beforeEach, afterEach } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('node:fs'); -const path = require('node:path'); -const os = require('node:os'); - -const { install, convertClaudeCommandToClaudeSkill } = require('../bin/install.js'); -const { cleanup } = require('./helpers.cjs'); - -// #924: Claude global install is now FLAT — concrete skills are at the top level. -// flatSkillPath returns: /gsd-/SKILL.md -function flatSkillPath(skillsRoot, stem) { - return path.join(skillsRoot, `gsd-${stem}`, 'SKILL.md'); -} - -const REPO_ROOT = path.resolve(__dirname, '..'); -const SOURCE_COMMANDS_DIR = path.join(REPO_ROOT, 'commands', 'gsd'); - -// ─── helpers ────────────────────────────────────────────────────────────────── - -function makeTmpDir(prefix) { - return fs.mkdtempSync(path.join(os.tmpdir(), prefix)); -} - -function readFrontmatter(mdPath) { - const content = fs.readFileSync(mdPath, 'utf8'); - if (!content.startsWith('---')) return ''; - const end = content.indexOf('---', 3); - if (end === -1) return ''; - return content.substring(3, end); -} - -/** - * Run a global install for Claude, redirecting its home dir to tmpHome. - * Returns the tmpHome for inspection. - */ -function runClaudeGlobalInstall(claudeHome) { - const isolatedHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-769-home-')); - - const prevCwd = process.cwd(); - const prevClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR; - const prevHome = process.env.HOME; - const prevUserProfile = process.env.USERPROFILE; - const prevSkipStale = process.env.GSD_SKIP_STALE_SDK_CHECK; - - process.env.CLAUDE_CONFIG_DIR = claudeHome; - process.env.HOME = isolatedHome; - process.env.USERPROFILE = isolatedHome; - process.env.GSD_SKIP_STALE_SDK_CHECK = '1'; - process.chdir(REPO_ROOT); - - try { - install(true, 'claude'); - } finally { - process.chdir(prevCwd); - if (prevClaudeConfigDir === undefined) delete process.env.CLAUDE_CONFIG_DIR; - else process.env.CLAUDE_CONFIG_DIR = prevClaudeConfigDir; - if (prevHome === undefined) delete process.env.HOME; - else process.env.HOME = prevHome; - if (prevUserProfile === undefined) delete process.env.USERPROFILE; - else process.env.USERPROFILE = prevUserProfile; - if (prevSkipStale === undefined) delete process.env.GSD_SKIP_STALE_SDK_CHECK; - else process.env.GSD_SKIP_STALE_SDK_CHECK = prevSkipStale; - cleanup(isolatedHome); - } - - return claudeHome; -} - -// ─── describe 1: Source command files have correct frontmatter ──────────────── - -// #921/#922: spawning orchestrators must NOT carry context: fork — a forked -// subagent has no Agent/Task tool, making it impossible for orchestrators to -// spawn their required subagents. context: fork is appropriate only for leaf -// skills that do not themselves dispatch agents. effort: max is portable across Claude Code models. -describe('#769/#921/#1319 source commands: spawning orchestrators have effort: max but NOT context: fork', () => { - test('commands/gsd/autonomous.md does NOT have context: fork (#921)', () => { - const fm = readFrontmatter(path.join(SOURCE_COMMANDS_DIR, 'autonomous.md')); - assert.doesNotMatch(fm, /^context:[ \t]*fork$/m, - `autonomous.md is a spawning orchestrator and must NOT have context: fork (#921)\nActual:\n${fm}`); - }); - - test('commands/gsd/autonomous.md has effort: max (#1319)', () => { - const fm = readFrontmatter(path.join(SOURCE_COMMANDS_DIR, 'autonomous.md')); - assert.match(fm, /^effort:[ \t]*max$/m, - `autonomous.md frontmatter must have effort: max\nActual:\n${fm}`); - assert.doesNotMatch(fm, /^effort:[ \t]*xhigh$/m, - `autonomous.md frontmatter must not have rejected effort: xhigh (#1319)\nActual:\n${fm}`); - }); - - test('commands/gsd/execute-phase.md does NOT have context: fork (#921)', () => { - const fm = readFrontmatter(path.join(SOURCE_COMMANDS_DIR, 'execute-phase.md')); - assert.doesNotMatch(fm, /^context:[ \t]*fork$/m, - `execute-phase.md is a spawning orchestrator and must NOT have context: fork (#921)\nActual:\n${fm}`); - }); - - test('commands/gsd/execute-phase.md has effort: max (#1319)', () => { - const fm = readFrontmatter(path.join(SOURCE_COMMANDS_DIR, 'execute-phase.md')); - assert.match(fm, /^effort:[ \t]*max$/m, - `execute-phase.md frontmatter must have effort: max\nActual:\n${fm}`); - assert.doesNotMatch(fm, /^effort:[ \t]*xhigh$/m, - `execute-phase.md frontmatter must not have rejected effort: xhigh (#1319)\nActual:\n${fm}`); - }); - - test('commands/gsd/plan-phase.md does NOT have context: fork (#921)', () => { - const fm = readFrontmatter(path.join(SOURCE_COMMANDS_DIR, 'plan-phase.md')); - assert.doesNotMatch(fm, /^context:[ \t]*fork$/m, - `plan-phase.md is a spawning orchestrator and must NOT have context: fork (#921)\nActual:\n${fm}`); - }); - - test('commands/gsd/plan-phase.md has effort: max (#1319)', () => { - const fm = readFrontmatter(path.join(SOURCE_COMMANDS_DIR, 'plan-phase.md')); - assert.match(fm, /^effort:[ \t]*max$/m, - `plan-phase.md frontmatter must have effort: max\nActual:\n${fm}`); - assert.doesNotMatch(fm, /^effort:[ \t]*xhigh$/m, - `plan-phase.md frontmatter must not have rejected effort: xhigh (#1319)\nActual:\n${fm}`); - }); -}); - -describe('#769 source commands: quick-status skills have effort: low', () => { - test('commands/gsd/progress.md has effort: low', () => { - const fm = readFrontmatter(path.join(SOURCE_COMMANDS_DIR, 'progress.md')); - assert.match(fm, /^effort:[ \t]*low$/m, - `progress.md frontmatter must have effort: low\nActual:\n${fm}`); - }); - - test('commands/gsd/stats.md has effort: low', () => { - const fm = readFrontmatter(path.join(SOURCE_COMMANDS_DIR, 'stats.md')); - assert.match(fm, /^effort:[ \t]*low$/m, - `stats.md frontmatter must have effort: low\nActual:\n${fm}`); - }); -}); - -// ─── describe 2: convertClaudeCommandToClaudeSkill preserves new fields ─────── - -describe('#769/#1319 convertClaudeCommandToClaudeSkill: preserves context and emits portable effort fields', () => { - test('preserves context: fork in emitted SKILL.md frontmatter', () => { - const input = [ - '---', - 'name: gsd:test-heavy', - 'description: Test heavy skill', - 'context: fork', - 'effort: xhigh', - 'allowed-tools:', - ' - Read', - ' - Bash', - '---', - '', - 'Heavy skill body.', - ].join('\n'); - - const result = convertClaudeCommandToClaudeSkill(input, 'test-heavy'); - const end = result.indexOf('---', 3); - const fm = result.substring(3, end); - - assert.match(fm, /^context:[ \t]*fork$/m, - `SKILL.md frontmatter must include context: fork\nActual frontmatter:\n${fm}`); - }); - - test('normalizes effort: xhigh to effort: max in emitted SKILL.md frontmatter (#1319)', () => { - const input = [ - '---', - 'name: gsd:test-heavy', - 'description: Test heavy skill', - 'context: fork', - 'effort: xhigh', - 'allowed-tools:', - ' - Read', - ' - Bash', - '---', - '', - 'Heavy skill body.', - ].join('\n'); - - const result = convertClaudeCommandToClaudeSkill(input, 'test-heavy'); - const end = result.indexOf('---', 3); - const fm = result.substring(3, end); - - assert.match(fm, /^effort:[ \t]*max$/m, - `SKILL.md frontmatter must include portable effort: max\nActual frontmatter:\n${fm}`); - assert.doesNotMatch(fm, /^effort:[ \t]*xhigh$/m, - `SKILL.md frontmatter must not include rejected effort: xhigh (#1319)\nActual frontmatter:\n${fm}`); - }); - - test('preserves effort: low in emitted SKILL.md frontmatter', () => { - const input = [ - '---', - 'name: gsd:test-light', - 'description: Test light skill', - 'effort: low', - 'allowed-tools:', - ' - Read', - '---', - '', - 'Light skill body.', - ].join('\n'); - - const result = convertClaudeCommandToClaudeSkill(input, 'test-light'); - const end = result.indexOf('---', 3); - const fm = result.substring(3, end); - - assert.match(fm, /^effort:[ \t]*low$/m, - `SKILL.md frontmatter must include effort: low\nActual frontmatter:\n${fm}`); - }); - - test('does NOT emit context: or effort: when absent from source', () => { - const input = [ - '---', - 'name: gsd:test-plain', - 'description: Plain skill without context or effort', - 'allowed-tools:', - ' - Read', - '---', - '', - 'Plain skill body.', - ].join('\n'); - - const result = convertClaudeCommandToClaudeSkill(input, 'test-plain'); - const end = result.indexOf('---', 3); - const fm = result.substring(3, end); - - assert.doesNotMatch(fm, /^context:/m, - `SKILL.md must not emit context: when absent from source\nActual:\n${fm}`); - assert.doesNotMatch(fm, /^effort:/m, - `SKILL.md must not emit effort: when absent from source\nActual:\n${fm}`); - }); -}); - -// ─── describe 3: Claude global install — SKILL.md files include new fields ──── - -// #921/#922: after install, spawning orchestrators must NOT carry context: fork -// in their emitted SKILL.md. #1319: heavyweight skills must use portable max effort. -describe('#769/#921/#1319 Claude global install: spawning-orchestrator SKILL.md files have effort: max but NOT context: fork', () => { - let tmpDir; - let claudeHome; - - beforeEach(() => { - tmpDir = makeTmpDir('gsd-769-claude-'); - claudeHome = path.join(tmpDir, 'claude-home'); - fs.mkdirSync(claudeHome, { recursive: true }); - }); - - afterEach(() => { - cleanup(tmpDir); - }); - - test('gsd-autonomous SKILL.md does NOT have context: fork after global install (#921)', () => { - runClaudeGlobalInstall(claudeHome); - const skillPath = flatSkillPath(path.join(claudeHome, 'skills'),'autonomous'); - const fm = readFrontmatter(skillPath); - assert.doesNotMatch(fm, /^context:[ \t]*fork$/m, - `gsd-autonomous is a spawning orchestrator; its SKILL.md must NOT have context: fork (#921)\nActual:\n${fm}`); - }); - - test('gsd-autonomous SKILL.md has effort: max after global install (#1319)', () => { - runClaudeGlobalInstall(claudeHome); - const skillPath = flatSkillPath(path.join(claudeHome, 'skills'),'autonomous'); - const fm = readFrontmatter(skillPath); - assert.match(fm, /^effort:[ \t]*max$/m, - `gsd-autonomous SKILL.md must have effort: max\nActual:\n${fm}`); - assert.doesNotMatch(fm, /^effort:[ \t]*xhigh$/m, - `gsd-autonomous SKILL.md must not have rejected effort: xhigh (#1319)\nActual:\n${fm}`); - }); - - test('gsd-execute-phase SKILL.md does NOT have context: fork after global install (#921)', () => { - runClaudeGlobalInstall(claudeHome); - const skillPath = flatSkillPath(path.join(claudeHome, 'skills'),'execute-phase'); - const fm = readFrontmatter(skillPath); - assert.doesNotMatch(fm, /^context:[ \t]*fork$/m, - `gsd-execute-phase is a spawning orchestrator; its SKILL.md must NOT have context: fork (#921)\nActual:\n${fm}`); - }); - - test('gsd-execute-phase SKILL.md has effort: max after global install (#1319)', () => { - runClaudeGlobalInstall(claudeHome); - const skillPath = flatSkillPath(path.join(claudeHome, 'skills'),'execute-phase'); - const fm = readFrontmatter(skillPath); - assert.match(fm, /^effort:[ \t]*max$/m, - `gsd-execute-phase SKILL.md must have effort: max\nActual:\n${fm}`); - assert.doesNotMatch(fm, /^effort:[ \t]*xhigh$/m, - `gsd-execute-phase SKILL.md must not have rejected effort: xhigh (#1319)\nActual:\n${fm}`); - }); - - test('gsd-plan-phase SKILL.md does NOT have context: fork after global install (#921)', () => { - runClaudeGlobalInstall(claudeHome); - const skillPath = flatSkillPath(path.join(claudeHome, 'skills'),'plan-phase'); - const fm = readFrontmatter(skillPath); - assert.doesNotMatch(fm, /^context:[ \t]*fork$/m, - `gsd-plan-phase is a spawning orchestrator; its SKILL.md must NOT have context: fork (#921)\nActual:\n${fm}`); - }); - - test('gsd-plan-phase SKILL.md has effort: max after global install (#1319)', () => { - runClaudeGlobalInstall(claudeHome); - const skillPath = flatSkillPath(path.join(claudeHome, 'skills'),'plan-phase'); - const fm = readFrontmatter(skillPath); - assert.match(fm, /^effort:[ \t]*max$/m, - `gsd-plan-phase SKILL.md must have effort: max\nActual:\n${fm}`); - assert.doesNotMatch(fm, /^effort:[ \t]*xhigh$/m, - `gsd-plan-phase SKILL.md must not have rejected effort: xhigh (#1319)\nActual:\n${fm}`); - }); - - test('gsd-progress SKILL.md has effort: low after global install', () => { - runClaudeGlobalInstall(claudeHome); - const skillPath = flatSkillPath(path.join(claudeHome, 'skills'),'progress'); - const fm = readFrontmatter(skillPath); - assert.match(fm, /^effort:[ \t]*low$/m, - `gsd-progress SKILL.md must have effort: low\nActual:\n${fm}`); - }); - - test('gsd-stats SKILL.md has effort: low after global install', () => { - runClaudeGlobalInstall(claudeHome); - const skillPath = flatSkillPath(path.join(claudeHome, 'skills'),'stats'); - const fm = readFrontmatter(skillPath); - assert.match(fm, /^effort:[ \t]*low$/m, - `gsd-stats SKILL.md must have effort: low\nActual:\n${fm}`); - }); -}); - }); -} - - -// ──────────────────────────────────────────────────────────────────────── -// Folded from tests/feat-443-effort-install-wiring.install.test.cjs — consolidation epic #1969 (B1 #1970) -// ──────────────────────────────────────────────────────────────────────── -{ - const { describe: __foldDescribe } = require('node:test'); - __foldDescribe("folded:feat-443-effort-install-wiring.install (consolidation epic #1969 B1 #1970)", () => { -// allow-test-rule: integration-test-input (see #443) -// Exercises install() + generateCodexAgentToml() as a black-box by inspecting -// produced output files in temp dirs. Source agent .md files are inputs whose -// installed transformation is asserted — not inspected for string presence. - -/** - * #443 — Effort per-runtime wiring at install time. - * - * Verifies: - * 1. Claude global install injects `effort:` into agent .md frontmatter. - * 2. Codex inherited-model installs omit `model_reasoning_effort` so model - * and effort are not partially pinned (#838). - * 3. Config-driven proof: effort.agent_overrides wins over tier defaults - * for Claude .md and for Codex .toml when runtime:"codex" pins a model. - * 4. Source agents/gsd-planner.md has NO effort: key (injection is - * install-only, source markdown carries no effort: key). - * - * #1928: the gemini runtime (and its "Gemini install does NOT inject effort:" - * coverage) was removed — Google sunset Gemini CLI 2026-06-18. - */ - -'use strict'; - -process.env.GSD_TEST_MODE = '1'; - -const { describe, test, beforeEach, afterEach } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('node:fs'); -const path = require('node:path'); -const os = require('node:os'); - -const { install } = require('../bin/install.js'); -const { cleanup } = require('./helpers.cjs'); - -const REPO_ROOT = path.resolve(__dirname, '..'); -const SOURCE_AGENTS_DIR = path.join(REPO_ROOT, 'agents'); - -// ─── helpers ───────────────────────────────────────────────────────────────── - -function makeTmpDir(prefix) { - return fs.mkdtempSync(path.join(os.tmpdir(), prefix)); -} - -function readFrontmatter(mdPath) { - const content = fs.readFileSync(mdPath, 'utf8'); - if (!content.startsWith('---')) return ''; - const end = content.indexOf('---', 3); - if (end === -1) return ''; - return content.substring(3, end); -} - -/** - * Run a global install for the given runtime, redirecting its home dir to - * tmpHome. Returns the tmpHome for inspection. - * - * Env-var redirection: - * claude → CLAUDE_CONFIG_DIR - * codex → CODEX_HOME - * - * HOME is also redirected to an isolated temp dir for the duration of the - * install call. This prevents any install.js code that uses os.homedir() - * directly (e.g. ~/.cache/gsd update-check deletion, ~/.gsd/defaults.json - * reads, stale-SDK npm subprocess writes to ~/.npm) from touching the real - * HOME and polluting the test environment for other concurrently-running - * test files (e.g. runtime-launcher-parity test (D) checks that - * $HOME/.claude/gsd-core/bin/gsd-tools.cjs is absent). - * - * GSD_SKIP_STALE_SDK_CHECK=1 is set to suppress the `npm ls -g` subprocess - * that the installer spawns for global installs — that subprocess is slow, - * writes to ~/.npm cache, and is irrelevant to effort-wiring assertions. - * - * The working directory is set to REPO_ROOT so install() can find the source - * agents/. For config-driven tests, place tmpHome inside the project dir - * so that readGsdEffectiveEffortConfig(targetDir) can walk up from tmpHome - * and find .planning/config.json. - */ -function runGlobalInstall(runtime, tmpHome) { - const envVarMap = { - claude: 'CLAUDE_CONFIG_DIR', - codex: 'CODEX_HOME', - }; - const envVar = envVarMap[runtime]; - if (!envVar) throw new Error(`Unsupported runtime in test: ${runtime}`); - - // Isolate HOME to a fresh temp dir so install.js code that calls - // os.homedir() (cache deletion, defaults.json reads, npm subprocess) - // never touches the real $HOME/.claude / $HOME/.cache / $HOME/.gsd. - const isolatedHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-443-home-')); - - const prev = process.env[envVar]; - const prevCwd = process.cwd(); - const prevHome = process.env.HOME; - const prevUserProfile = process.env.USERPROFILE; - const prevSkipStale = process.env.GSD_SKIP_STALE_SDK_CHECK; - - process.env[envVar] = tmpHome; - process.env.HOME = isolatedHome; - process.env.USERPROFILE = isolatedHome; - process.env.GSD_SKIP_STALE_SDK_CHECK = '1'; - process.chdir(REPO_ROOT); - - try { - install(true, runtime); - } finally { - process.chdir(prevCwd); - if (prev === undefined) delete process.env[envVar]; - else process.env[envVar] = prev; - if (prevHome === undefined) delete process.env.HOME; - else process.env.HOME = prevHome; - if (prevUserProfile === undefined) delete process.env.USERPROFILE; - else process.env.USERPROFILE = prevUserProfile; - if (prevSkipStale === undefined) delete process.env.GSD_SKIP_STALE_SDK_CHECK; - else process.env.GSD_SKIP_STALE_SDK_CHECK = prevSkipStale; - // Clean up the isolated HOME dir - cleanup(isolatedHome); - } - - return tmpHome; -} - -// ─── Tier default expectations ──────────────────────────────────────────────── -// light → low, standard → high, heavy → xhigh (catalog defaults) -// gsd-planner: heavy → xhigh -// gsd-codebase-mapper: light → low -// gsd-executor: standard → high - -// ─── describe 1: Claude install injects effort: ─────────────────────────────── - -describe('#443 Claude install: effort: injected into frontmatter', () => { - let tmpDir; - let claudeHome; - - beforeEach(() => { - tmpDir = makeTmpDir('gsd-443-claude-'); - claudeHome = path.join(tmpDir, 'claude-home'); - fs.mkdirSync(claudeHome, { recursive: true }); - }); - - afterEach(() => { - cleanup(tmpDir); - }); - - test('gsd-planner.md contains effort: xhigh (heavy tier default)', () => { - runGlobalInstall('claude', claudeHome); - const fm = readFrontmatter(path.join(claudeHome, 'agents', 'gsd-planner.md')); - assert.match(fm, /^effort:\s*xhigh$/m, - `gsd-planner frontmatter should have effort: xhigh\nActual:\n${fm}`); - }); - - test('gsd-codebase-mapper.md contains effort: low (light tier default)', () => { - runGlobalInstall('claude', claudeHome); - const fm = readFrontmatter(path.join(claudeHome, 'agents', 'gsd-codebase-mapper.md')); - assert.match(fm, /^effort:\s*low$/m, - `gsd-codebase-mapper frontmatter should have effort: low\nActual:\n${fm}`); - }); - - test('gsd-executor.md contains effort: high (standard tier default)', () => { - runGlobalInstall('claude', claudeHome); - const fm = readFrontmatter(path.join(claudeHome, 'agents', 'gsd-executor.md')); - assert.match(fm, /^effort:\s*high$/m, - `gsd-executor frontmatter should have effort: high\nActual:\n${fm}`); - }); -}); - -// ─── describe 3: Codex inherited-model install omits model_reasoning_effort ── - -describe('#838 Codex install: inherited model omits model_reasoning_effort', () => { - let tmpDir; - let codexHome; - - beforeEach(() => { - tmpDir = makeTmpDir('gsd-443-codex-'); - codexHome = path.join(tmpDir, 'codex-home'); - fs.mkdirSync(codexHome, { recursive: true }); - }); - - afterEach(() => { - cleanup(tmpDir); - }); - - test('gsd-planner.toml omits both model and model_reasoning_effort when model is inherited', () => { - runGlobalInstall('codex', codexHome); - const tomlContent = fs.readFileSync( - path.join(codexHome, 'agents', 'gsd-planner.toml'), 'utf8' - ); - assert.doesNotMatch(tomlContent, /^model\s*=/m, - `gsd-planner.toml should omit model when inheriting Codex chat model\nActual:\n${tomlContent.slice(0, 500)}`); - assert.doesNotMatch(tomlContent, /^model_reasoning_effort\s*=/m, - `gsd-planner.toml should omit model_reasoning_effort when model is inherited\nActual:\n${tomlContent.slice(0, 500)}`); - }); -}); - -// ─── describe 4: Config-driven proof ───────────────────────────────────────── -// -// The runtime home dir must be INSIDE (or a sibling of) the project root so -// that readGsdEffectiveEffortConfig(targetDir) can walk up from the runtime -// home and find .planning/config.json. We put .claude/ and .codex/ as siblings -// of .planning/ inside the project dir — this is the natural local-install shape. - -describe('#443 Config-driven: effort.agent_overrides drives install-time effort', () => { - let tmpDir; - let claudeHome; - let codexHome; - - beforeEach(() => { - // Layout: tmpDir/project/ <-- project root (cwd for install) - // .planning/config.json - // .claude/ <-- claudeHome (CLAUDE_CONFIG_DIR) - // .codex/ <-- codexHome (CODEX_HOME) - tmpDir = makeTmpDir('gsd-443-cfg-'); - const projectDir = path.join(tmpDir, 'project'); - claudeHome = path.join(projectDir, '.claude'); - codexHome = path.join(projectDir, '.codex'); - - fs.mkdirSync(claudeHome, { recursive: true }); - fs.mkdirSync(codexHome, { recursive: true }); - fs.mkdirSync(path.join(projectDir, '.planning'), { recursive: true }); - - // Write a project config with effort.agent_overrides overriding gsd-planner to 'low'. - // runtime:"codex" pins a Codex-native model, so emitting model_reasoning_effort - // remains valid under the #838 model/effort coupling rule. - const config = { - runtime: 'codex', - effort: { - agent_overrides: { - 'gsd-planner': 'low', - }, - }, - }; - fs.writeFileSync( - path.join(projectDir, '.planning', 'config.json'), - JSON.stringify(config, null, 2) - ); - }); - - afterEach(() => { - cleanup(tmpDir); - }); - - test('Claude .md gets effort: low when agent_overrides.gsd-planner=low', () => { - // projectDir is the cwd for install — chdir handled inside runGlobalInstall. - // claudeHome is inside projectDir, so walking up from claudeHome finds .planning/config.json. - runGlobalInstall('claude', claudeHome); - const fm = readFrontmatter(path.join(claudeHome, 'agents', 'gsd-planner.md')); - assert.match(fm, /^effort:\s*low$/m, - `gsd-planner should have effort: low from config override\nActual:\n${fm}`); - }); - - test('Codex .toml gets model_reasoning_effort = "low" when agent_overrides.gsd-planner=low', () => { - runGlobalInstall('codex', codexHome); - const tomlContent = fs.readFileSync( - path.join(codexHome, 'agents', 'gsd-planner.toml'), 'utf8' - ); - assert.match(tomlContent, /^model\s*=\s*"gpt-5.5"$/m, - `gsd-planner.toml should pin Codex model when runtime:"codex" is configured\nActual:\n${tomlContent.slice(0, 500)}`); - assert.match(tomlContent, /^model_reasoning_effort\s*=\s*"low"$/m, - `gsd-planner.toml should have model_reasoning_effort = "low" from config override\nActual:\n${tomlContent.slice(0, 500)}`); - }); - - test('Codex .toml clamps effort max → xhigh when agent_overrides.gsd-planner=max', () => { - const projectDir = path.dirname(codexHome); - // Overwrite config with max override - const config = { - runtime: 'codex', - effort: { - agent_overrides: { - 'gsd-planner': 'max', - }, - }, - }; - fs.writeFileSync( - path.join(projectDir, '.planning', 'config.json'), - JSON.stringify(config, null, 2) - ); - - runGlobalInstall('codex', codexHome); - const tomlContent = fs.readFileSync( - path.join(codexHome, 'agents', 'gsd-planner.toml'), 'utf8' - ); - assert.match(tomlContent, /^model\s*=\s*"gpt-5.5"$/m, - `gsd-planner.toml should pin Codex model when runtime:"codex" is configured\nActual:\n${tomlContent.slice(0, 500)}`); - // Codex does not support 'max' → clamped to 'xhigh' - assert.match(tomlContent, /^model_reasoning_effort\s*=\s*"xhigh"$/m, - `gsd-planner.toml should clamp max → xhigh for Codex\nActual:\n${tomlContent.slice(0, 500)}`); - assert.doesNotMatch(tomlContent, /model_reasoning_effort\s*=\s*"max"/, - 'Codex .toml must never contain model_reasoning_effort = "max"'); - }); -}); - -// ─── describe 5b: Invalid effort tokens fall through (Codex adversarial finding #2) ─ -// -// These tests FAIL before the fix: resolveInstallTimeEffort returns the raw -// invalid string without validating it against VALID_EFFORTS. - -describe('#443 resolveInstallTimeEffort: invalid tokens fall through to valid effort', () => { - let tmpDir; - let claudeHome; - let codexHome; - - beforeEach(() => { - // Layout: tmpDir/project/ <-- project root - // .planning/config.json - // .claude/ <-- claudeHome - // .codex/ <-- codexHome - tmpDir = makeTmpDir('gsd-443-invalid-effort-'); - const projectDir = path.join(tmpDir, 'project'); - claudeHome = path.join(projectDir, '.claude'); - codexHome = path.join(projectDir, '.codex'); - - fs.mkdirSync(claudeHome, { recursive: true }); - fs.mkdirSync(codexHome, { recursive: true }); - fs.mkdirSync(path.join(projectDir, '.planning'), { recursive: true }); - }); - - afterEach(() => { - cleanup(tmpDir); - }); - - function writeProjectConfig(config) { - const projectDir = path.dirname(claudeHome); - fs.writeFileSync( - path.join(projectDir, '.planning', 'config.json'), - JSON.stringify(config, null, 2) - ); - } - - const VALID_EFFORTS = ['minimal', 'low', 'medium', 'high', 'xhigh', 'max']; - - test('effort.default="ultra" (invalid) -> Claude .md effort: is a VALID value (falls through to high)', () => { - // BUG before fix: resolveInstallTimeEffort returns "ultra" verbatim - writeProjectConfig({ effort: { default: 'ultra' } }); - runGlobalInstall('claude', claudeHome); - const fm = readFrontmatter(path.join(claudeHome, 'agents', 'gsd-planner.md')); - const match = fm.match(/^effort:\s*(\S+)$/m); - assert.ok(match, `effort: must be present in frontmatter\nActual:\n${fm}`); - assert.ok(VALID_EFFORTS.includes(match[1]), - `effort: must be a VALID effort string, got: "${match[1]}"\nActual frontmatter:\n${fm}`); - }); - - test('effort.agent_overrides.gsd-planner="bogus" (invalid) with valid default -> falls through to valid default', () => { - // BUG before fix: "bogus" is returned and written verbatim - writeProjectConfig({ - effort: { - agent_overrides: { 'gsd-planner': 'bogus' }, - default: 'medium', - }, - }); - runGlobalInstall('claude', claudeHome); - const fm = readFrontmatter(path.join(claudeHome, 'agents', 'gsd-planner.md')); - const match = fm.match(/^effort:\s*(\S+)$/m); - assert.ok(match, `effort: must be present in frontmatter\nActual:\n${fm}`); - assert.ok(VALID_EFFORTS.includes(match[1]), - `effort: must be a VALID effort string, got: "${match[1]}"\nActual frontmatter:\n${fm}`); - // Falls through invalid "bogus" -> valid tier default or "medium" default - // "medium" is valid, so it should appear (or tier default if medium is invalid, but medium is valid) - }); - - test('effort.default="ultra" (invalid) + runtime:"codex" -> Codex .toml model_reasoning_effort is VALID', () => { - // BUG before fix: "ultra" written into .toml verbatim - writeProjectConfig({ runtime: 'codex', effort: { default: 'ultra' } }); - runGlobalInstall('codex', codexHome); - const tomlContent = fs.readFileSync( - path.join(codexHome, 'agents', 'gsd-planner.toml'), 'utf8' - ); - assert.match(tomlContent, /^model\s*=\s*"gpt-5.5"$/m, - `gsd-planner.toml should pin Codex model when runtime:"codex" is configured\nActual:\n${tomlContent.slice(0, 500)}`); - const match = tomlContent.match(/^model_reasoning_effort\s*=\s*"([^"]+)"/m); - assert.ok(match, `model_reasoning_effort must be present in .toml\nActual:\n${tomlContent.slice(0, 500)}`); - assert.ok(VALID_EFFORTS.includes(match[1]), - `model_reasoning_effort must be VALID, got: "${match[1]}"\nActual:\n${tomlContent.slice(0, 500)}`); - }); -}); - -// ─── describe 5: Source stays clean ────────────────────────────────────────── - -describe('#443 Source purity: agents/gsd-planner.md has no effort: key', () => { - test('source agents/gsd-planner.md frontmatter does not contain effort:', () => { - const fm = readFrontmatter(path.join(SOURCE_AGENTS_DIR, 'gsd-planner.md')); - assert.doesNotMatch(fm, /^effort:/m, - `Source agents/gsd-planner.md must NOT contain effort: (injection is install-only)`); - }); - - test('source agents/gsd-executor.md frontmatter does not contain effort:', () => { - const fm = readFrontmatter(path.join(SOURCE_AGENTS_DIR, 'gsd-executor.md')); - assert.doesNotMatch(fm, /^effort:/m, - `Source agents/gsd-executor.md must NOT contain effort: (injection is install-only)`); - }); - - test('source agents/gsd-codebase-mapper.md frontmatter does not contain effort:', () => { - const fm = readFrontmatter(path.join(SOURCE_AGENTS_DIR, 'gsd-codebase-mapper.md')); - assert.doesNotMatch(fm, /^effort:/m, - `Source agents/gsd-codebase-mapper.md must NOT contain effort: (injection is install-only)`); - }); -}); - }); -} - - -// ──────────────────────────────────────────────────────────────────────── -// Folded from tests/enh-1510-rewrite-engine-helper-relocation.test.cjs — consolidation epic #1969 (B1 #1970) -// ──────────────────────────────────────────────────────────────────────── -{ - const { describe: __foldDescribe } = require('node:test'); - __foldDescribe("folded:enh-1510-rewrite-engine-helper-relocation (consolidation epic #1969 B1 #1970)", () => { -'use strict'; - -// Enhancement #1510 (epic #1507, ADR-1508 Phase 1): behavior-preserving -// relocation of pure rewrite-engine helpers out of hand-authored bin/install.js. -// - getDirName -> gsd-core/bin/lib/runtime-name-policy.cjs -// - processAttribution -> gsd-core/bin/lib/runtime-artifact-conversion.cjs -// getCommitAttribution stays in install.js (impure install-time config I/O); the -// convertClaudeToAugmentMarkdown duplicate dedup is deferred to Phase 2's cleanup -// (entangled converter cluster; not required to unblock Phase 2). -// These tests exercise the REAL relocated functions at their new home (the -// generated .cjs) and assert install.js re-exports the SAME references -// (Hyrum: existing consumers import these names from bin/install.js). - -const { test, describe } = require('node:test'); -const assert = require('node:assert'); - -const runtimeNamePolicy = require('../gsd-core/bin/lib/runtime-name-policy.cjs'); -const conversion = require('../gsd-core/bin/lib/runtime-artifact-conversion.cjs'); -const installer = require('../bin/install.js'); - -// ── Slice A: getDirName relocated to runtime-name-policy ────────────────────── -describe('getDirName (relocated to runtime-name-policy)', () => { - const EXPECTED = { - claude: '.claude', - copilot: '.github', - opencode: '.opencode', - kilo: '.kilo', - codex: '.codex', - antigravity: '.agents', - cursor: '.cursor', - windsurf: '.windsurf', - augment: '.augment', - trae: '.trae', - qwen: '.qwen', - hermes: '.hermes', - kimi: '.kimi-code', - codebuddy: '.codebuddy', - cline: '.cline', - }; - - for (const [runtime, dir] of Object.entries(EXPECTED)) { - test(`maps '${runtime}' to '${dir}'`, () => { - assert.strictEqual(runtimeNamePolicy.getDirName(runtime), dir); - }); - } - - test('falls back to .claude for an unknown runtime', () => { - assert.strictEqual(runtimeNamePolicy.getDirName('definitely-not-a-runtime'), '.claude'); - }); - - test('falls back to .claude for empty input', () => { - assert.strictEqual(runtimeNamePolicy.getDirName(''), '.claude'); - }); - - test('bin/install.js re-exports the SAME getDirName reference (no drift)', () => { - assert.strictEqual(installer.getDirName, runtimeNamePolicy.getDirName); - }); -}); - -// ── Slice B: processAttribution relocated to runtime-artifact-conversion ─────── -describe('processAttribution (relocated to runtime-artifact-conversion)', () => { - test('null removes the Co-Authored-By line and its preceding blank line', () => { - const input = 'Commit body line.\n\nCo-Authored-By: Someone '; - assert.strictEqual(conversion.processAttribution(input, null), 'Commit body line.'); - }); - - test('undefined leaves content unchanged', () => { - const input = 'Commit body.\n\nCo-Authored-By: Someone '; - assert.strictEqual(conversion.processAttribution(input, undefined), input); - }); - - test('a string replaces the attribution value', () => { - const input = 'Body\n\nCo-Authored-By: Old Name '; - assert.strictEqual( - conversion.processAttribution(input, 'New Name '), - 'Body\n\nCo-Authored-By: New Name ', - ); - }); - - test('escapes $ in the attribution to prevent backreference injection', () => { - const input = 'Body\n\nCo-Authored-By: x'; - // "$1" must survive literally, not be interpreted as a regex backreference. - assert.strictEqual( - conversion.processAttribution(input, 'A $1 B'), - 'Body\n\nCo-Authored-By: A $1 B', - ); - }); - - test('handles CRLF when removing (null)', () => { - const input = 'Body\r\n\r\nCo-Authored-By: Someone '; - assert.strictEqual(conversion.processAttribution(input, null), 'Body'); - }); - - test('replaces every Co-Authored-By line (global)', () => { - const input = 'Body\nCo-Authored-By: A \nCo-Authored-By: B '; - assert.strictEqual( - conversion.processAttribution(input, 'Z '), - 'Body\nCo-Authored-By: Z \nCo-Authored-By: Z ', - ); - }); - - test('bin/install.js re-exports the SAME processAttribution reference (no drift)', () => { - // processAttribution remains an explicit installer compatibility relay, so - // the export must keep pointing at the conversion module's implementation. - assert.strictEqual(installer.processAttribution, conversion.processAttribution); - }); -}); - }); -} - // ──────────────────────────────────────────────────────────────────────── diff --git a/tests/roadmap.test.cjs b/tests/roadmap.test.cjs index e3c1d850d..c52cad759 100644 --- a/tests/roadmap.test.cjs +++ b/tests/roadmap.test.cjs @@ -2121,6 +2121,39 @@ describe('bug #2114: roadmap get-phase resolves drifted prefixed headings by bar assert.strictEqual(payload30.found, true); assert.strictEqual(payload30.phase_name, 'Plain'); }); + + test('prefixed query surfaces malformed_roadmap when only a checklist entry exists (parity with bare)', () => { + // #2121/#2114 route all three resolvers through the shared 3-source lookup, so a + // project-code-prefixed query now surfaces the SAME `malformed_roadmap` diagnostic a + // bare numeric query always did: a `**Phase PROJ-42:**` summary line with no matching + // `### Phase PROJ-42:` detail heading is malformed for BOTH query forms. Before the + // consolidation the prefixed form silently returned `{found:false}` with no diagnostic + // (the exact-prefix pass discarded its malformed candidate) — this test fails on that + // prior behavior and locks the unified, more-informative result. + fs.writeFileSync( + path.join(tmpDir, '.planning', 'ROADMAP.md'), + [ + '# Roadmap v1.0', + '', + '## Phases', + '', + '- [ ] **Phase PROJ-42: Checklist only, no header**', + '', + ].join('\n'), + ); + + const prefixed = runGsdTools('roadmap get-phase PROJ-42 --json', tmpDir); + assert.ok(prefixed.success, `command failed: ${prefixed.error || prefixed.output}`); + const pPayload = JSON.parse(prefixed.output); + assert.strictEqual(pPayload.found, false, 'malformed roadmap: phase must not be found'); + assert.strictEqual(pPayload.error, 'malformed_roadmap', 'prefixed query must surface malformed_roadmap'); + assert.ok(pPayload.message.includes('missing'), 'message must explain the missing detail section'); + + // Parity: the bare numeric form yields the same diagnostic against the same fixture. + const bare = runGsdTools('roadmap get-phase 42 --json', tmpDir); + assert.ok(bare.success, `command failed: ${bare.error || bare.output}`); + assert.strictEqual(JSON.parse(bare.output).error, 'malformed_roadmap', 'bare query surfaces the same diagnostic'); + }); }); describe('roadmap annotate-dependencies', () => {