From a9f9c130ef4da0029736ae9ac8aa7a916d53a311 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Thu, 9 Jul 2026 14:31:34 -0400 Subject: [PATCH 01/31] test(#2090): add cline EoS migration test scaffolding (red) --- tests/cline-beforetool-upgrade.test.cjs | 166 ++++++++++++++++++++ tests/cline-dispatch-degradation.test.cjs | 101 ++++++++++++ tests/cline-imperative-reference.test.cjs | 114 ++++++++++++++ tests/cline-model-override-upgrade.test.cjs | 161 +++++++++++++++++++ 4 files changed, 542 insertions(+) create mode 100644 tests/cline-beforetool-upgrade.test.cjs create mode 100644 tests/cline-dispatch-degradation.test.cjs create mode 100644 tests/cline-imperative-reference.test.cjs create mode 100644 tests/cline-model-override-upgrade.test.cjs diff --git a/tests/cline-beforetool-upgrade.test.cjs b/tests/cline-beforetool-upgrade.test.cjs new file mode 100644 index 000000000..08cb90491 --- /dev/null +++ b/tests/cline-beforetool-upgrade.test.cjs @@ -0,0 +1,166 @@ +'use strict'; + +/** + * cline beforeTool plugin UPGRADE — ADR-1239 / #2090 AC (upgrade 1). + * + * Proves the `.clinerules/hooks/PreToolUse` file-convention planning-artifact + * guard is re-implemented as a real Cline SDK `AgentPlugin.hooks.beforeTool` + * handler, delivered through the negotiated `hookBus: host` interface point. + * Guard semantics are preserved EXACTLY from the PreToolUse script: fail-open, + * cancel (skip) write-class calls targeting `.planning/`, pass through + * everything else. + * + * The adapter is exercised directly (mocked SDK payload shape) since the real + * `@cline/sdk` is a fast-moving package set not linked at test time — same + * pattern as the VS Code reference binding (tests/fixtures/vscode-host-binding.cjs). + * + * Cite: + * https://github.com/cline/cline/blob/main/docs/sdk/plugins.mdx + * — "Lifecycle hooks ... include beforeRun, afterRun, beforeModel, + * afterModel, beforeTool, afterTool, and onEvent." + * https://github.com/cline/cline/blob/main/sdk/packages/agents/README.md + * — beforeTool({ tool, input }) => { skip: true, reason } | undefined + */ + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); + +const { + WRITE_TOOL_PATTERN, + PLANNING_PATH_PATTERN, + PLANNING_GUARD_REASON, + evaluateBeforeTool, + clineGsdPlugin, +} = require('../gsd-core/bin/lib/host-integration-adapters/cline-sdk-binding.cjs'); + +// -- upgrade 1: write-class detection --------------------------------------- + +test('WRITE_TOOL_PATTERN matches the write-class tool verbs (parity with PreToolUse)', () => { + const writeVerbs = ['write_to_file', 'edit_file', 'replace_in_file', 'create_file', + 'delete_file', 'remove_file', 'append_to_file', 'apply_patch', 'insert_edit', 'mkdir']; + for (const v of writeVerbs) { + assert.ok(WRITE_TOOL_PATTERN.test(v), `write-class verb must match: ${v}`); + } +}); + +test('WRITE_TOOL_PATTERN does NOT match read-class tools', () => { + const readVerbs = ['read_file', 'list_files', 'search_files', 'execute_command', 'ask_user']; + for (const v of readVerbs) { + assert.ok(!WRITE_TOOL_PATTERN.test(v), `read-class verb must NOT match: ${v}`); + } +}); + +// -- upgrade 1: planning-path detection ------------------------------------- + +test('PLANNING_PATH_PATTERN matches .planning/ paths on posix + windows separators', () => { + const planningPaths = ['.planning/state.md', '.planning/phases/1/PLAN.md', + '/home/u/proj/.planning/config.json', 'proj\\.planning\\foo', './.planning/x']; + for (const p of planningPaths) { + assert.ok(PLANNING_PATH_PATTERN.test(p), `planning path must match: ${p}`); + } +}); + +test('PLANNING_PATH_PATTERN does NOT match non-planning paths', () => { + const other = ['src/planning-utils.ts', 'docs/plan.md', '.planning-readme.txt']; + // .planning-readme.txt must not match (boundary after .planning required) + for (const p of ['src/planning-utils.ts', 'docs/plan.md']) { + assert.ok(!PLANNING_PATH_PATTERN.test(p), `non-planning path must NOT match: ${p}`); + } +}); + +// -- upgrade 1: evaluateBeforeTool — the guard decision ---------------------- + +test('write-class tool targeting .planning/ is SKIPPED (cancel)', () => { + const result = evaluateBeforeTool({ + tool: { name: 'write_to_file' }, + input: { path: '.planning/phases/1/PLAN.md', content: '...' }, + }); + assert.equal(result.decision, 'skip'); + assert.equal(result.reason, PLANNING_GUARD_REASON); +}); + +test('write-class tool targeting a NON-planning path is ALLOWED', () => { + const result = evaluateBeforeTool({ + tool: { name: 'write_to_file' }, + input: { path: 'src/index.ts', content: '...' }, + }); + assert.equal(result.decision, 'allow'); + assert.equal(result.reason, undefined); +}); + +test('read-class tool targeting .planning/ is ALLOWED (reads are safe)', () => { + const result = evaluateBeforeTool({ + tool: { name: 'read_file' }, + input: { path: '.planning/state.md' }, + }); + assert.equal(result.decision, 'allow'); +}); + +test('write tool with .planning/ in a non-PATH field (content body) is ALLOWED', () => { + // A doc that merely mentions ".planning/" in its body is never falsely blocked — + // only PATH-bearing field values are inspected (parity with PreToolUse). + const result = evaluateBeforeTool({ + tool: { name: 'write_to_file' }, + input: { path: 'docs/guide.md', content: 'see .planning/ for details' }, + }); + assert.equal(result.decision, 'allow'); +}); + +test('write tool with .planning/ in an array of paths is SKIPPED', () => { + const result = evaluateBeforeTool({ + tool: { name: 'apply_patch' }, + input: { paths: ['src/a.ts', '.planning/config.json'] }, + }); + assert.equal(result.decision, 'skip'); +}); + +// -- upgrade 1: fail-open on malformed/missing input ------------------------ + +test('evaluateBeforeTool fails OPEN on null tool/input (never throws, never blocks)', () => { + assert.equal(evaluateBeforeTool({ tool: null, input: null }).decision, 'allow'); + assert.equal(evaluateBeforeTool({}).decision, 'allow'); + assert.equal(evaluateBeforeTool(null).decision, 'allow'); +}); + +test('evaluateBeforeTool fails OPEN on a tool with no name', () => { + assert.equal(evaluateBeforeTool({ tool: {}, input: { path: '.planning/x' } }).decision, 'allow'); +}); + +// -- upgrade 1: the AgentPlugin wrapper shape ------------------------------- + +test('clineGsdPlugin is an AgentPlugin with a beforeTool hook', () => { + assert.equal(typeof clineGsdPlugin, 'object'); + assert.equal(clineGsdPlugin.name, 'gsd-planning-guard'); + assert.equal(typeof clineGsdPlugin.setup, 'function'); +}); + +test('clineGsdPlugin.setup returns hooks.beforeTool that maps skip→{skip,reason}', () => { + const { hooks } = clineGsdPlugin.setup({ agentId: 'test-agent' }); + assert.equal(typeof hooks.beforeTool, 'function'); + // planning write → { skip: true, reason } + const blocked = hooks.beforeTool({ + tool: { name: 'write_to_file' }, + input: { path: '.planning/state.md' }, + }); + assert.deepEqual(blocked, { skip: true, reason: PLANNING_GUARD_REASON }); +}); + +test('clineGsdPlugin.beforeTool returns undefined for allowed calls (SDK contract)', () => { + const { hooks } = clineGsdPlugin.setup({ agentId: 'test-agent' }); + const allowed = hooks.beforeTool({ + tool: { name: 'write_to_file' }, + input: { path: 'src/foo.ts' }, + }); + assert.equal(allowed, undefined, 'undefined = allow (Cline SDK beforeTool contract)'); +}); + +// -- upgrade 1: parity with the existing PreToolUse script semantics -------- + +test('the guard reason matches the PreToolUse script errorMessage contract', () => { + // The file-convention hook wrote { cancel:true, errorMessage:'GSD: ...' }. + // The SDK plugin maps cancel→skip and errorMessage→reason. The user-visible + // message text is preserved so the guard behaves identically to the user. + assert.ok(typeof PLANNING_GUARD_REASON === 'string' && PLANNING_GUARD_REASON.length > 0); + assert.ok(PLANNING_GUARD_REASON.includes('.planning/'), + 'reason must explain the .planning/ protection so the user can act on it'); +}); diff --git a/tests/cline-dispatch-degradation.test.cjs b/tests/cline-dispatch-degradation.test.cjs new file mode 100644 index 000000000..1b8b9707e --- /dev/null +++ b/tests/cline-dispatch-degradation.test.cjs @@ -0,0 +1,101 @@ +'use strict'; + +/** + * cline dispatch DEGRADATION — ADR-1239 / #2090. + * + * Proves cline's dispatch STAYS degraded/flat and is NEVER silently upgraded to + * the programmatic-cli profile baseline's full nested/background dispatch. + * Cline's own docs (docs/features/subagents.mdx) restrict subagents to a single + * level, read-only toolkit, no nested spawning — so claiming full dispatch would + * misrepresent a documented host restriction and violate the fail-closed + * negotiation contract. This is the cline counterpart to cursor's dispatch + * UPGRADE (#2089), asserting the OPPOSITE invariant: cline flattens. + * + * Cite: + * https://github.com/cline/cline/blob/main/docs/features/subagents.mdx + * — "subagents are restricted from editing files, using the browser, + * accessing MCP servers, or creating nested subagents." + * https://github.com/cline/cline/blob/main/docs/features/subagents.mdx + * — "They are explicitly prohibited from ... spawning other subagents." + */ + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const { + shouldFlattenDispatch, + degradationFor, +} = require('../gsd-core/bin/lib/host-integration.cjs'); + +const CLN_CAP = JSON.parse( + fs.readFileSync(path.join(__dirname, '..', 'capabilities', 'cline', 'capability.json'), 'utf8'), +); +const CLN_DISPATCH = CLN_CAP.runtime.hostIntegration.dispatch; + +// -- cline dispatch axes: documented restrictions ---------------------------- + +test('cline dispatch declares namedDispatch but NOT nested (single-level only)', () => { + assert.equal(CLN_DISPATCH.namedDispatch, true, + 'cite agents-squad example — start_subagent(preset:..., task:...) named dispatch'); + assert.equal(CLN_DISPATCH.nested, false, + 'cite subagents.mdx — subagents cannot create nested subagents'); +}); + +test('cline dispatch respects maxDepth: 1 (the documented hard limit)', () => { + assert.equal(CLN_DISPATCH.maxDepth, 1, + 'cite subagents.mdx — "explicitly prohibited from ... spawning other subagents"'); +}); + +test('cline subagentToolkit is read-only (no write/browser/mcp for subagents)', () => { + assert.equal(CLN_DISPATCH.subagentToolkit, 'read-only', + 'cite subagents.mdx — "strictly limited to read-only operations"'); +}); + +test('cline backgroundDispatch is false (background commands run, but no nested-dispatch)', () => { + assert.equal(CLN_DISPATCH.background, true, + 'cite subagents.mdx — "Commands executed by subagents run in the background"'); + assert.equal(CLN_DISPATCH.backgroundDispatch, false, + 'cite subagents.mdx — cannot spawn nested subagents from a background context'); +}); + +// -- shouldFlattenDispatch: cline MUST flatten (degraded) -------------------- + +test('shouldFlattenDispatch(cline) is true — waves run inline (the #853 rule)', () => { + assert.equal(shouldFlattenDispatch(CLN_DISPATCH), true, + 'cline has backgroundDispatch:false → GSD must force-flatten (run inline)'); +}); + +test('a hypothetical full-upgrade (backgroundDispatch:true) would NOT flatten — proving the discriminator', () => { + const hypothetical = { ...CLN_DISPATCH, backgroundDispatch: true, nested: true, maxDepth: 2, subagentToolkit: 'full' }; + assert.equal(shouldFlattenDispatch(hypothetical), false, + 'only background:true AND backgroundDispatch:true escapes flattening — cline lacks both'); +}); + +// -- degradationFor: cline dispatch is 'degraded' / flat --------------------- + +test('degradationFor("dispatch", cline) is degraded with the flat-dispatch fallback', () => { + const result = degradationFor('dispatch', CLN_CAP.runtime.hostIntegration); + assert.equal(result.level, 'degraded', + 'maxDepth:1 (flat) is a degraded dispatch surface, never full'); + assert.equal(result.fallback, 'flat dispatch — waves run inline'); + assert.notEqual(result.level, 'full', + 'cline dispatch must NEVER be classified as full — that would misrepresent the host'); +}); + +test('cline dispatch is never silently upgraded to the programmatic-cli baseline', () => { + // The full baseline requires nested + maxDepth>=2 + subagentToolkit 'full'. + // Cline violates ALL three (nested:false, maxDepth:1, read-only) — so it must + // stay degraded regardless of any negotiation defaults. + const result = degradationFor('dispatch', CLN_CAP.runtime.hostIntegration); + assert.notEqual(result.level, 'full'); +}); + +// -- boundary: maxDepth 1 vs 0 vs -1 ---------------------------------------- + +test('maxDepth 1 is flat (NOT absent, NOT unbounded)', () => { + assert.ok(CLN_DISPATCH.maxDepth > 0, 'maxDepth must be positive (not absent/single-agent)'); + assert.notEqual(CLN_DISPATCH.maxDepth, -1, 'cline is NOT unbounded — depth-1 is the documented limit'); + assert.notEqual(CLN_DISPATCH.maxDepth, 0, 'maxDepth:0 would mean no named dispatch — cline HAS named dispatch'); +}); diff --git a/tests/cline-imperative-reference.test.cjs b/tests/cline-imperative-reference.test.cjs new file mode 100644 index 000000000..aa475be80 --- /dev/null +++ b/tests/cline-imperative-reference.test.cjs @@ -0,0 +1,114 @@ +// allow-test-rule: AC2 requires asserting no `runtime === 'cline'` string-equality branch remains in bin/install.js/src — the descriptor-migration contract is a property of the source text, so a source-grep is the only faithful check (#2090) +'use strict'; + +/** + * cline imperative reference host — ADR-1239 Phase D / #2090 (EoS/cline). + * + * Proves cline is driven through the PUBLIC Host-Integration Interface (the + * imperative adapter), that its negotiated axes classify + negotiate correctly, + * that negotiation fails CLOSED on a corrupted descriptor, that the + * Context7-sourced dispatch classification STAYS degraded/flat (cline subagents + * are documented as single-level read-only — maxDepth:1 — and must never be + * silently upgraded to full nested/background dispatch), and that the migration + * retired the hardcoded `runtime === 'cline'` / `isCline` branches (folded into + * descriptor-driven `runtime.hostBehaviors`). + * + * Contrast with cursor (#2089): cursor's dispatch got an UPGRADE (background + + * nested). cline's dispatch is a deliberate DEGRADATION that must be preserved — + * upgrading it would misrepresent a documented host restriction and violate the + * fail-closed negotiation contract (see dispatch-degradation test). + */ + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const { createImperativeAdapter } = require('../gsd-core/bin/lib/adapter-imperative.cjs'); +const { + profileOf, + negotiateHostCapabilities, + shouldFlattenDispatch, + PROFILE_BASELINES, + UNDOCUMENTED, +} = require('../gsd-core/bin/lib/host-integration.cjs'); + +const CLN_CAP = JSON.parse( + fs.readFileSync(path.join(__dirname, '..', 'capabilities', 'cline', 'capability.json'), 'utf8'), +); +const CLN_AXES = CLN_CAP.runtime.hostIntegration; + +// -- AC2: driven through the public interface (imperative adapter) ----------- + +test('createImperativeAdapter classifies cline as imperative + composes the registry', () => { + const adapter = createImperativeAdapter({ runtime: 'cline' }); + assert.equal(adapter.kind, 'imperative'); + assert.equal(adapter.runtime, 'cline'); + assert.ok(adapter.registry && typeof adapter.registry === 'object'); + assert.equal(typeof adapter.install, 'function'); + assert.equal(typeof adapter.uninstall, 'function'); +}); + +test('cline axes classify as the programmatic-cli reference profile (imperative embedding)', () => { + assert.equal(profileOf(CLN_AXES), 'programmatic-cli'); +}); + +// -- AC3: all axes populated + validated ------------------------------------- + +test('cline descriptor declares all 8 axes + 6 dispatch sub-axes (no undocumented)', () => { + assert.equal(CLN_AXES.embeddingMode, 'imperative'); + assert.equal(CLN_AXES.commandSurface, 'slash-file'); + assert.equal(CLN_AXES.modelMode, 'active'); + assert.equal(CLN_AXES.hookBus, 'host'); + assert.equal(CLN_AXES.stateIO, 'filesystem'); + assert.equal(CLN_AXES.transport, 'mcp'); + assert.equal(CLN_AXES.runtime, 'node'); + const d = CLN_AXES.dispatch; + assert.equal(d.namedDispatch, true); + assert.equal(d.nested, false); + assert.equal(d.maxDepth, 1); + assert.equal(d.background, true); + assert.equal(d.subagentToolkit, 'read-only'); + assert.equal(d.backgroundDispatch, false); +}); + +// -- AC5: negotiation fails CLOSED on a corrupted descriptor ------------------ + +test('negotiateHostCapabilities never throws for cline, even fully corrupted', () => { + assert.doesNotThrow(() => negotiateHostCapabilities({})); + assert.doesNotThrow(() => negotiateHostCapabilities({ ...CLN_AXES, embeddingMode: UNDOCUMENTED })); + assert.doesNotThrow(() => negotiateHostCapabilities({ ...CLN_AXES, embeddingMode: 'future-unknown' })); +}); + +test('a partial/empty cline descriptor degrades to the safe floor, not the programmatic-cli baseline', () => { + const result = negotiateHostCapabilities({}); + assert.equal(result.effective.embeddingMode, 'declarative', 'omitted embeddingMode degrades closed'); + assert.equal(result.effective.hookBus, 'none'); + assert.notDeepEqual(result.effective, PROFILE_BASELINES['programmatic-cli']); + assert.ok(result.warnings.length > 0); +}); + +// -- AC2: the hardcoded branches are retired --------------------------------- + +test('cline descriptor declares runtime.hostBehaviors (the folded-in behaviors)', () => { + const hb = CLN_CAP.runtime.hostBehaviors; + assert.ok(hb && typeof hb === 'object'); + assert.equal(hb.reapplyCommand, '/gsd-update --reapply'); + assert.equal(hb.frontmatterDialect, 'cline'); + assert.equal(hb.skipSharedHooksInstall, true); + assert.equal(hb.localTargetIsProjectRoot, true); + assert.equal(hb.clineRulesSurface, true); + assert.equal(hb.localCommandsViaRules, true); +}); + +test('no `runtime === "cline"` string-equality branch remains in the install source (AC2)', () => { + const strip = (src) => src + .replace(/\/\*[\s\S]*?\*\//g, '') + .replace(/\/\/[^\r\n]*/g, '') + .replace(/`[^`]*`/g, ''); + for (const rel of ['bin/install.js', 'src/install-engine.cts', 'src/runtime-artifact-conversion.cts', 'src/runtime-hooks-surface.cts']) { + const src = fs.readFileSync(path.join(__dirname, '..', rel), 'utf8'); + const offenders = strip(src).match(/runtime\s*[!=]==\s*'cline'/g) || []; + assert.deepEqual(offenders, [], `AC2: no hardcoded runtime==='cline' branch may remain in ${rel}; found: ${offenders.join(', ')}`); + } +}); diff --git a/tests/cline-model-override-upgrade.test.cjs b/tests/cline-model-override-upgrade.test.cjs new file mode 100644 index 000000000..458d9207a --- /dev/null +++ b/tests/cline-model-override-upgrade.test.cjs @@ -0,0 +1,161 @@ +'use strict'; + +/** + * cline createAgentModel UPGRADE — ADR-1239 / #2090 AC (upgrade 2). + * + * Proves GSD's per-subagent `model_overrides` / `model_profile_overrides` + * resolution (already used for OpenCode/Codex) now applies to cline subagents + * via `DefaultGateway.createAgentModel({ providerId, modelId })`, instead of + * leaving model selection untouched. Cline's `modelMode: active` (the host + * exposes provider registration via createLlmsRuntime) is what makes this + * wiring possible — passive hosts can only inject a per-agent model field. + * + * The binding resolves the createAgentModel call parameters from GSD config; + * the real gateway call is the host's responsibility (mocked here, same pattern + * as tests/fixtures/vscode-host-binding.cjs). + * + * Cite: + * https://github.com/cline/cline/blob/main/docs/sdk/reference/gateway.mdx + * — createAgentModel({ providerId, modelId }) returns an AgentModel + * https://github.com/cline/cline/blob/main/sdk/packages/llms/README.md + * — createLlmsRuntime(...) provider registry (modelMode: active) + */ + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); + +const { + resolveClineAgentModelParams, + inferProviderId, + DEFAULT_CLINE_PROVIDER_ID, +} = require('../gsd-core/bin/lib/host-integration-adapters/cline-sdk-binding.cjs'); + +// -- upgrade 2: provider inference from a model id -------------------------- + +test('inferProviderId maps anthropic model ids to "anthropic"', () => { + assert.equal(inferProviderId('claude-sonnet-4-5'), 'anthropic'); + assert.equal(inferProviderId('claude-opus-4-1'), 'anthropic'); +}); + +test('inferProviderId maps openai model ids to "openai"', () => { + assert.equal(inferProviderId('gpt-4o'), 'openai'); + assert.equal(inferProviderId('o1-preview'), 'openai'); +}); + +test('inferProviderId falls back to DEFAULT_CLINE_PROVIDER_ID for unknown ids', () => { + assert.equal(inferProviderId('some-custom-model'), DEFAULT_CLINE_PROVIDER_ID); + assert.equal(inferProviderId(''), DEFAULT_CLINE_PROVIDER_ID); +}); + +// -- upgrade 2: model_overrides resolution ---------------------------------- + +test('a per-agent model_overrides entry resolves to createAgentModel params', () => { + const result = resolveClineAgentModelParams({ + agentType: 'planner', + modelOverrides: { planner: 'claude-sonnet-4-5' }, + modelProfileOverrides: null, + profile: 'balanced', + }); + assert.deepEqual(result, { providerId: 'anthropic', modelId: 'claude-sonnet-4-5' }); +}); + +test('model_overrides takes precedence over model_profile_overrides', () => { + const result = resolveClineAgentModelParams({ + agentType: 'planner', + modelOverrides: { planner: 'claude-sonnet-4-5' }, + modelProfileOverrides: { balanced: { planner: 'claude-opus-4-1' } }, + profile: 'balanced', + }); + assert.equal(result.modelId, 'claude-sonnet-4-5', 'direct model_overrides wins'); +}); + +test('model_profile_overrides resolves when no direct model_overrides entry exists', () => { + const result = resolveClineAgentModelParams({ + agentType: 'executor', + modelOverrides: null, + modelProfileOverrides: { balanced: { executor: 'gpt-4o' } }, + profile: 'balanced', + }); + assert.deepEqual(result, { providerId: 'openai', modelId: 'gpt-4o' }); +}); + +// -- upgrade 2: no override → null (gateway default applies) ---------------- + +test('returns null when no override is configured (gateway picks the default model)', () => { + const result = resolveClineAgentModelParams({ + agentType: 'planner', + modelOverrides: null, + modelProfileOverrides: null, + profile: 'balanced', + }); + assert.equal(result, null, 'null = no override; host gateway default applies'); +}); + +test('returns null when the agentType has no matching override', () => { + const result = resolveClineAgentModelParams({ + agentType: 'planner', + modelOverrides: { executor: 'claude-sonnet-4-5' }, + modelProfileOverrides: null, + profile: 'balanced', + }); + assert.equal(result, null); +}); + +// -- upgrade 2: the gateway binding (createAgentModel call shape) ----------- + +test('a resolved override drives DefaultGateway.createAgentModel with the right params', () => { + // Simulate the host gateway (mocked — the real @cline/sdk is not linked here). + const calls = []; + const fakeGateway = { + createAgentModel(selection) { calls.push(selection); return { providerId: selection.providerId, modelId: selection.modelId }; }, + }; + const params = resolveClineAgentModelParams({ + agentType: 'planner', + modelOverrides: { planner: 'claude-sonnet-4-5' }, + modelProfileOverrides: null, + profile: 'balanced', + }); + assert.ok(params, 'override must resolve to non-null params'); + const model = fakeGateway.createAgentModel(params); + assert.equal(calls.length, 1); + assert.deepEqual(calls[0], { providerId: 'anthropic', modelId: 'claude-sonnet-4-5' }); + assert.equal(model.modelId, 'claude-sonnet-4-5'); +}); + +test('no override → createAgentModel is NOT called (gateway default, not GSD override)', () => { + const calls = []; + const fakeGateway = { + createAgentModel(selection) { calls.push(selection); return {}; }, + }; + const params = resolveClineAgentModelParams({ + agentType: 'planner', + modelOverrides: null, + modelProfileOverrides: null, + profile: 'balanced', + }); + if (params) fakeGateway.createAgentModel(params); + assert.equal(calls.length, 0, 'no override → gateway must use its own default, GSD does not call createAgentModel'); +}); + +// -- upgrade 2: fail-safe / malformed config -------------------------------- + +test('malformed override values (non-string) are ignored (fail-safe, not crash)', () => { + const result = resolveClineAgentModelParams({ + agentType: 'planner', + modelOverrides: { planner: 42, executor: 'claude-sonnet-4-5' }, + modelProfileOverrides: null, + profile: 'balanced', + }); + // planner's non-string override is ignored; falls through to null (no executor match for agentType planner) + assert.equal(result, null); +}); + +test('empty-string override is treated as absent', () => { + const result = resolveClineAgentModelParams({ + agentType: 'planner', + modelOverrides: { planner: '' }, + modelProfileOverrides: null, + profile: 'balanced', + }); + assert.equal(result, null); +}); From 760eb71b6b15537931353347fd41c40f533f6dd8 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Thu, 9 Jul 2026 14:40:26 -0400 Subject: [PATCH 02/31] feat(#2090): migrate cline onto imperative adapter + beforeTool/createAgentModel upgrades Fold all hardcoded runtime === 'cline' / isCline branches in bin/install.js into descriptor-driven runtime.hostBehaviors lookups (reapplyCommand, frontmatterDialect, skipSharedHooksInstall, localTargetIsProjectRoot, clineRulesSurface, localCommandsViaRules). Add cline-sdk-binding adapter (ADR-1239 Phase D): UPGRADE 1 re-implements the .clinerules/hooks/PreToolUse guard as a real AgentPlugin.hooks.beforeTool handler (fail-open, same semantics); UPGRADE 2 wires DefaultGateway.createAgentModel params from model_overrides/model_profile_overrides resolution (modelMode: active). Install output is byte-identical (golden parity asserted for cline + claude/cursor/codex/opencode). --- .gitignore | 1 + bin/install.js | 54 ++-- capabilities/cline/capability.json | 8 + gsd-core/bin/lib/capability-registry.cjs | 16 ++ .../cline-sdk-binding.cts | 256 ++++++++++++++++++ 5 files changed, 317 insertions(+), 18 deletions(-) create mode 100644 src/host-integration-adapters/cline-sdk-binding.cts diff --git a/.gitignore b/.gitignore index 37d552553..c08717e6c 100644 --- a/.gitignore +++ b/.gitignore @@ -70,6 +70,7 @@ build/ /gsd-core/bin/lib/host-integration.cjs /gsd-core/bin/lib/host-integration-sdk.cjs /gsd-core/bin/lib/host-integration-adapters/imperative-hook-bus.cjs +/gsd-core/bin/lib/host-integration-adapters/cline-sdk-binding.cjs /gsd-core/bin/lib/handshake-serialized.cjs /gsd-core/bin/lib/install-effort-resolver.cjs /gsd-core/bin/lib/install-engine.cjs diff --git a/bin/install.js b/bin/install.js index 16cc410c6..1461c4296 100755 --- a/bin/install.js +++ b/bin/install.js @@ -6774,9 +6774,13 @@ function uninstall(isGlobal, runtime = DEFAULT_RUNTIME) { // Get the target directory based on runtime and install type. Cline local // installs write to the project root (.clinerules/ lives at the root, not in // a .cline/ subdir), mirroring the install() path resolution (#787). + // Descriptor-driven (ADR-1239 / #2090): cline local installs write to the + // project root (.clinerules/ lives at the root, not in a .cline/ subdir), + // mirroring the install() path resolution (#787). Folded from a hardcoded + // `runtime === 'cline'` branch into hostBehaviors.localTargetIsProjectRoot. const targetDir = isGlobal ? getGlobalConfigDir(runtime, explicitConfigDir) - : runtime === 'cline' + : _hostBehaviors(runtime).localTargetIsProjectRoot ? process.cwd() : path.join(process.cwd(), dirName); @@ -6941,7 +6945,9 @@ function uninstall(isGlobal, runtime = DEFAULT_RUNTIME) { // 1b-cline. Non-layout Cline side-effects (issue #787): remove the // directory-form rules + PreToolUse hook, and strip the GSD block from the // global cross-tool ~/.agents/AGENTS.md target. - if (runtime === 'cline') { + // Descriptor-driven (ADR-1239 / #2090): folded from `runtime === 'cline'` + // into hostBehaviors.clineRulesSurface. + if (_hostBehaviors(runtime).clineRulesSurface) { const clinerulesDir = path.join(targetDir, '.clinerules'); for (const rel of ['gsd.md', path.join('hooks', 'PreToolUse')]) { const p = path.join(clinerulesDir, rel); @@ -7892,7 +7898,9 @@ function writeManifest(configDir, runtime = DEFAULT_RUNTIME, options = {}) { // Track Cline directory-form artifacts in the manifest (issue #787): the // rules file and the PreToolUse hook. (~/.agents/AGENTS.md is tracked via its // marker block, not the per-configDir manifest, since it lives outside it.) - if (isCline) { + // Descriptor-driven (ADR-1239 / #2090): folded from `isCline` into + // hostBehaviors.clineRulesSurface. + if (_hostBehaviors(runtime).clineRulesSurface) { for (const rel of ['.clinerules/gsd.md', '.clinerules/hooks/PreToolUse']) { const dest = path.join(configDir, rel); if (fs.existsSync(dest)) { @@ -7903,7 +7911,9 @@ function writeManifest(configDir, runtime = DEFAULT_RUNTIME, options = {}) { // Track hook files so saveLocalPatches() can detect user modifications // Hooks are only installed for runtimes that use settings.json (not Codex/Copilot/Cline) - if (!isCodex && !isCopilot && !isCline && !isKimi) { + // Descriptor-driven (ADR-1239 / #2089+#2090): cline's exclusion is via + // hostBehaviors.skipSharedHooksInstall (was hardcoded !isCline). + if (!isCodex && !isCopilot && _hostBehaviors(runtime).skipSharedHooksInstall !== true && !isWindsurf && !isTrae && !isKimi) { const hooksDir = path.join(configDir, 'hooks'); if (fs.existsSync(hooksDir)) { // Drive from INSTALLED_HOOK_FILES (the canonical HOOKS_TO_COPY set from @@ -8348,15 +8358,17 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) { }; // Get the target directory based on runtime and install type. - // Cline local installs write to the project root (like Claude Code) — .clinerules - // lives at the root, not inside a .cline/ subdirectory. + // Descriptor-driven (ADR-1239 / #2090): cline local installs write to the + // project root (like Claude Code) — .clinerules lives at the root, not inside + // a .cline/ subdirectory. Folded from `isCline` into + // hostBehaviors.localTargetIsProjectRoot. // #791: antigravity local installs write to .agents/ (canonical). The legacy .agent/ // directory is recognized by RUNTIME_DIRS (update-context) and _LEGACY_SCAN_SUBDIR_NAMES // but NOT auto-removed here; legacy .agent/ gsd artifacts are recognized but not // auto-removed on reinstall (dual-read fallback per issue #791 spec). const targetDir = isGlobal ? getGlobalConfigDir(runtime, explicitConfigDir) - : isCline + : _hostBehaviors(runtime).localTargetIsProjectRoot ? process.cwd() : path.join(process.cwd(), dirName); @@ -8929,10 +8941,12 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) { } } } - } else if (isCline) { + } else if (_hostBehaviors(runtime).localCommandsViaRules) { // Cline local install: rules-based only — commands are embedded in .clinerules (generated below). // No skills/commands directory needed for local installs. // Global installs are handled above by _isSkillsRuntime (#782). + // Descriptor-driven (ADR-1239 / #2090): folded from `isCline` into + // hostBehaviors.localCommandsViaRules. console.log(` ${green}✓${reset} Cline: commands will be available via .clinerules`); } else { // Claude Code local: flat gsd-.md layout — Claude Code registers @@ -9212,7 +9226,9 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) { content = convertClaudeAgentToTraeAgent(content); } else if (isCodebuddy) { content = convertClaudeAgentToCodebuddyAgent(content); - } else if (isCline) { + } else if (_hostBehaviors(runtime).frontmatterDialect === 'cline') { + // Descriptor-driven (ADR-1239 / #2090): folded from `isCline` into + // hostBehaviors.frontmatterDialect === 'cline'. content = convertClaudeAgentToClineAgent(content); } else if (isQwen) { content = content.replace(/CLAUDE\.md/g, 'QWEN.md'); @@ -9286,7 +9302,9 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) { // them and the CommonJS package.json marker written below. // #2089: Cursor's exclusion is now descriptor-driven via // hostBehaviors.skipSharedHooksInstall (was hardcoded !isCursor). - if (!isCodex && !isCopilot && _hostBehaviors(runtime).skipSharedHooksInstall !== true && !isWindsurf && !isTrae && !isCline && !isKimi && !isKilo && !isZcode) { + // #2090: Cline's exclusion is likewise descriptor-driven (cline declares + // skipSharedHooksInstall:true) — the redundant `&& !isCline` was removed. + if (!isCodex && !isCopilot && _hostBehaviors(runtime).skipSharedHooksInstall !== true && !isWindsurf && !isTrae && !isKimi && !isKilo && !isZcode) { // Write package.json to force CommonJS mode for GSD scripts // Prevents "require is not defined" errors when project has "type": "module" // Node.js walks up looking for package.json - this stops inheritance from project @@ -9378,15 +9396,15 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) { // Gate hooks/lib/ install on the same runtimes that receive hooks (see line ~8702). // Codex/Copilot/Cursor/Windsurf/Trae/Cline do not use the shared hooks/lib/ helpers // (Cursor uses standalone .js hook scripts registered via hooks.json — gated - // descriptor-driven via hostBehaviors.skipSharedHooksInstall, #2089; Codex uses - // hooks.json directly; the others skip hooks entirely); Kilo and ZCode also skip - // hooks entirely (hooksSurface:'none' with no plugin surface — #1821). OpenCode - // is NOT excluded: its #1914 plugin adapter spawns the staged hooks and requires - // hooks/lib/ helpers. None of the excluded runtimes must receive the hooks/lib/ - // helpers — otherwise the Codex comment downstream ("we deliberately do *not* - // copy hooks/lib/ for Codex") is contradicted in practice. + // descriptor-driven via hostBehaviors.skipSharedHooksInstall, #2089; Cline likewise + // #2090; Codex uses hooks.json directly; the others skip hooks entirely); Kilo and + // ZCode also skip hooks entirely (hooksSurface:'none' with no plugin surface — #1821). + // OpenCode is NOT excluded: its #1914 plugin adapter spawns the staged hooks and + // requires hooks/lib/ helpers. None of the excluded runtimes must receive the + // hooks/lib/ helpers — otherwise the Codex comment downstream ("we deliberately do + // *not* copy hooks/lib/ for Codex") is contradicted in practice. const hooksLibSrc = path.join(src, 'hooks', 'lib'); - if (!isCodex && !isCopilot && _hostBehaviors(runtime).skipSharedHooksInstall !== true && !isWindsurf && !isTrae && !isCline && !isKimi && !isKilo && !isZcode && fs.existsSync(hooksLibSrc)) { + if (!isCodex && !isCopilot && _hostBehaviors(runtime).skipSharedHooksInstall !== true && !isWindsurf && !isTrae && !isKimi && !isKilo && !isZcode && fs.existsSync(hooksLibSrc)) { const hooksLibDest = path.join(targetDir, 'hooks', 'lib'); fs.mkdirSync(hooksLibDest, { recursive: true }); copyLibDir(hooksLibSrc, hooksLibDest, GSD_HOOK_LIB_FILES); diff --git a/capabilities/cline/capability.json b/capabilities/cline/capability.json index 1b6ab6513..bf6704c29 100644 --- a/capabilities/cline/capability.json +++ b/capabilities/cline/capability.json @@ -56,6 +56,14 @@ "stateIO": "filesystem", "transport": "mcp", "runtime": "node" + }, + "hostBehaviors": { + "reapplyCommand": "/gsd-update --reapply", + "frontmatterDialect": "cline", + "skipSharedHooksInstall": true, + "localTargetIsProjectRoot": true, + "clineRulesSurface": true, + "localCommandsViaRules": true } } } diff --git a/gsd-core/bin/lib/capability-registry.cjs b/gsd-core/bin/lib/capability-registry.cjs index 3a2fac1b3..f29d05e9a 100644 --- a/gsd-core/bin/lib/capability-registry.cjs +++ b/gsd-core/bin/lib/capability-registry.cjs @@ -618,6 +618,14 @@ const capabilities = { "stateIO": "filesystem", "transport": "mcp", "runtime": "node" + }, + "hostBehaviors": { + "reapplyCommand": "/gsd-update --reapply", + "frontmatterDialect": "cline", + "skipSharedHooksInstall": true, + "localTargetIsProjectRoot": true, + "clineRulesSurface": true, + "localCommandsViaRules": true } } }, @@ -4029,6 +4037,14 @@ const runtimes = { "stateIO": "filesystem", "transport": "mcp", "runtime": "node" + }, + "hostBehaviors": { + "reapplyCommand": "/gsd-update --reapply", + "frontmatterDialect": "cline", + "skipSharedHooksInstall": true, + "localTargetIsProjectRoot": true, + "clineRulesSurface": true, + "localCommandsViaRules": true } } }, diff --git a/src/host-integration-adapters/cline-sdk-binding.cts b/src/host-integration-adapters/cline-sdk-binding.cts new file mode 100644 index 000000000..0995ad81a --- /dev/null +++ b/src/host-integration-adapters/cline-sdk-binding.cts @@ -0,0 +1,256 @@ +/** + * Cline SDK binding — AgentPlugin + createAgentModel adapters + * (ADR-1239 Phase D / #2090). + * + * Two Context7-verified UPGRADES the file-convention projection ignored, now + * delivered through the negotiated `hookBus: host` + `modelMode: active` + * interface points: + * + * UPGRADE 1 — `AgentPlugin.hooks.beforeTool` planning-artifact guard. + * Re-implements the `.clinerules/hooks/PreToolUse` file-convention hook + * (issue #787) as a real Cline SDK AgentPlugin. Guard semantics are + * preserved EXACTLY: fail-open, cancel (skip) write-class calls targeting + * `.planning/`, pass through everything else. The SDK maps the file hook's + * `{cancel:true, errorMessage}` to `{skip:true, reason}` (beforeTool + * contract). + * Cite: https://github.com/cline/cline/blob/main/docs/sdk/plugins.mdx + * https://github.com/cline/cline/blob/main/sdk/packages/agents/README.md + * + * UPGRADE 2 — `DefaultGateway.createAgentModel({providerId, modelId})`. + * Resolves GSD's per-subagent `model_overrides` / `model_profile_overrides` + * (already used for OpenCode/Codex passive hosts) into the createAgentModel + * call params for cline's active model mode. The host gateway owns the + * actual model instantiation; this binding resolves WHICH model an + * overridden subagent should use. + * Cite: https://github.com/cline/cline/blob/main/docs/sdk/reference/gateway.mdx + * https://github.com/cline/cline/blob/main/sdk/packages/llms/README.md + * + * This module is PURE (no I/O, no SDK import): the real `@cline/sdk` is a + * fast-moving package set not linked at build/test time, so the binding exposes + * the decision functions a host plugin/gateway would call. Tests drive payloads + * through them directly (same mock-the-SDK pattern as the VS Code reference + * binding, tests/fixtures/vscode-host-binding.cjs). + */ +'use strict'; + +// --------------------------------------------------------------------------- +// UPGRADE 1 — beforeTool planning-artifact guard +// --------------------------------------------------------------------------- + +/** + * Write-class tool-verb detector. Matches the SAME regex as the #787 + * PreToolUse file-convention hook so the guard behaves identically. + * Case-insensitive (the SDK delivers tool names in varying case). + */ +export const WRITE_TOOL_PATTERN = /write|edit|replace|create|delete|remove|append|apply|patch|insert|mkdir/i; + +/** + * `.planning/` path detector. Matches `.planning` preceded by start-of-string + * or a path separator (posix `/` or windows `\`) and followed by a separator or + * end-of-string — so `.planning-readme.txt` is NOT falsely matched. Mirrors the + * PreToolUse hook's `(^|[\\/])\.planning([\\/]|$)` exactly. + */ +export const PLANNING_PATH_PATTERN = /(^|[\\/])\.planning([\\/]|$)/; + +/** + * The user-visible reason returned when a `.planning/` write is blocked. + * Preserves the PreToolUse hook's errorMessage text so the guard behaves + * identically to the user (cancel→skip, errorMessage→reason). + */ +export const PLANNING_GUARD_REASON: string = Object.freeze( + 'GSD: .planning/ artifacts are managed by GSD workflows. Edit them only through a /gsd-* command, not directly.', +); + +/** + * Path-bearing field-name detector. Only PATH-keyed field values are inspected, + * so a document that merely mentions ".planning/" in its body content is never + * falsely blocked. Mirrors the PreToolUse hook's PATH_KEY regex exactly. + */ +const PATH_KEY_PATTERN = /^(path|file|file_?path|filepath|target_?path|target|dir|directory|uri|filename)$/i; + +type BeforeToolPayload = { + tool?: { name?: unknown } | string | null | undefined; + input?: unknown; +}; + +type BeforeToolDecision = { decision: 'skip'; reason: string } | { decision: 'allow'; reason?: undefined }; + +/** + * Collect PATH-bearing string field values from an object tree, mirroring the + * PreToolUse hook's bounded walk. Pure; never throws. + */ +function collectPathValues(root: unknown): string[] { + const paths: string[] = []; + const walk = (v: unknown, depth: number): void => { + if (depth > 5 || paths.length > 64) return; + if (Array.isArray(v)) { + for (const x of v) walk(x, depth + 1); + return; + } + if (v && typeof v === 'object') { + const obj = v as Record; + for (const k of Object.keys(obj)) { + const val = obj[k]; + if (typeof val === 'string' && PATH_KEY_PATTERN.test(k)) { + paths.push(val); + } else { + walk(val, depth + 1); + } + } + } + }; + walk(root, 0); + return paths; +} + +/** + * Resolve a tool name from a beforeTool payload's `tool` field, which may be a + * string or an object with a `name` property. Returns '' when absent (treated + * as non-write-class → allow, fail-open). + */ +function resolveToolName(tool: BeforeToolPayload['tool']): string { + if (!tool) return ''; + if (typeof tool === 'string') return tool; + const name = (tool as { name?: unknown }).name; + return typeof name === 'string' ? name : ''; +} + +/** + * The pure guard decision: given a beforeTool payload, decide skip (cancel) or + * allow. Fail-OPEN — any malformed input, missing tool, or thrown error returns + * 'allow' (the guard never blocks on a defect, mirroring the PreToolUse hook). + * + * @returns `{decision:'skip', reason}` for a write-class call targeting + * `.planning/`; `{decision:'allow'}` for everything else. + */ +export function evaluateBeforeTool(payload: BeforeToolPayload | null | undefined): BeforeToolDecision { + try { + if (!payload) return { decision: 'allow' }; + const toolName = resolveToolName(payload.tool); + if (!toolName) return { decision: 'allow' }; + const isWrite = WRITE_TOOL_PATTERN.test(toolName); + if (!isWrite) return { decision: 'allow' }; + const paths = collectPathValues(payload.input); + const isPlanningPath = (s: string): boolean => PLANNING_PATH_PATTERN.test(s); + if (paths.some(isPlanningPath)) { + return { decision: 'skip', reason: PLANNING_GUARD_REASON }; + } + return { decision: 'allow' }; + } catch { + // Fail-open: a defect in the guard never blocks the user's operation. + return { decision: 'allow' }; + } +} + +/** + * The Cline `AgentPlugin` shape (Context7 /cline/cline). A plugin implements + * `setup({agentId})` returning `{hooks, tools}`. The `beforeTool` hook returns + * `{skip:true, reason}` to block or `undefined` to allow. + * + * This object is the portable plugin a host loads from `~/.cline/plugins/` + * (analogous to `.opencode/plugins/gsd-core.js`). Its `beforeTool` delegates to + * the pure `evaluateBeforeTool` so the decision logic is testable without the + * SDK linked. + */ +export const clineGsdPlugin: { + name: string; + setup: (ctx: { agentId?: string }) => { + hooks: { + beforeTool: (payload: BeforeToolPayload) => { skip: true; reason: string } | undefined; + }; + }; +} = Object.freeze({ + name: 'gsd-planning-guard', + setup(_ctx: { agentId?: string }) { + return { + hooks: { + beforeTool(payload: BeforeToolPayload): { skip: true; reason: string } | undefined { + const decision = evaluateBeforeTool(payload); + return decision.decision === 'skip' ? { skip: true, reason: decision.reason } : undefined; + }, + }, + }; + }, +}); + +// --------------------------------------------------------------------------- +// UPGRADE 2 — createAgentModel model-override resolution +// --------------------------------------------------------------------------- + +/** + * The fallback provider id when a model id does not match a known provider + * family. Anthropic is cline's most common default; the host gateway retains + * the final say over provider resolution. + */ +export const DEFAULT_CLINE_PROVIDER_ID: string = 'anthropic'; + +/** + * Infer a `providerId` (the createAgentModel first arg) from a model id by + * matching known provider families. Returns DEFAULT_CLINE_PROVIDER_ID for an + * unrecognized or empty id (fail-safe — the gateway applies its own default). + * + * Pure string-prefix classification; does not validate the id is a real model. + */ +export function inferProviderId(modelId: string): string { + if (typeof modelId !== 'string' || modelId.length === 0) return DEFAULT_CLINE_PROVIDER_ID; + const lower = modelId.toLowerCase(); + if (lower.startsWith('claude')) return 'anthropic'; + if (lower.startsWith('gpt') || lower.startsWith('o1') || lower.startsWith('o3') || lower.startsWith('o4')) return 'openai'; + if (lower.startsWith('gemini')) return 'google'; + if (lower.startsWith('deepseek')) return 'deepseek'; + return DEFAULT_CLINE_PROVIDER_ID; +} + +type ModelOverrideMap = Record | null | undefined; +type ProfileOverrideMap = Record> | null | undefined; + +type AgentModelParams = { providerId: string; modelId: string }; + +/** + * Resolve the createAgentModel call params for a cline subagent from GSD's model + * override config. Mirrors the precedence OpenCode/Codex use (passive hosts + * embed the resolved model into agent frontmatter); for cline's active model + * mode the same resolution flows to `gateway.createAgentModel(params)`. + * + * Precedence (matches GSD's model_overrides > model_profile_overrides contract): + * 1. `modelOverrides[agentType]` — direct per-agent override + * 2. `modelProfileOverrides[profile][agentType]` — profile-scoped override + * 3. null — no override; the host gateway applies its own default + * + * Pure; never throws. Non-string / empty override values are ignored (fail-safe). + * + * @returns the `{providerId, modelId}` for createAgentModel, or null when no + * override is configured (the gateway default applies — GSD does NOT + * call createAgentModel in that case). + */ +export function resolveClineAgentModelParams(args: { + agentType: string; + modelOverrides?: ModelOverrideMap; + modelProfileOverrides?: ProfileOverrideMap; + profile?: string; +}): AgentModelParams | null { + const { agentType, modelOverrides, modelProfileOverrides, profile } = args; + if (!agentType || typeof agentType !== 'string') return null; + + // 1. Direct per-agent override wins. + if (modelOverrides && typeof modelOverrides === 'object') { + const direct = modelOverrides[agentType]; + if (typeof direct === 'string' && direct.length > 0) { + return { providerId: inferProviderId(direct), modelId: direct }; + } + } + + // 2. Profile-scoped override. + if (modelProfileOverrides && typeof modelProfileOverrides === 'object' && profile) { + const profileEntry = modelProfileOverrides[profile]; + if (profileEntry && typeof profileEntry === 'object') { + const profileModel = profileEntry[agentType]; + if (typeof profileModel === 'string' && profileModel.length > 0) { + return { providerId: inferProviderId(profileModel), modelId: profileModel }; + } + } + } + + // 3. No override — gateway default applies. + return null; +} From f76f117ff9b47298ff167f8fee886cb2851b1ead Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Thu, 9 Jul 2026 14:42:00 -0400 Subject: [PATCH 03/31] docs(#2090): cline host-integration migration status + changeset --- .changeset/2090-eos-cline-imperative-adapter.md | 5 +++++ docs/reference/host-integration-capability-matrix.md | 6 ++++++ 2 files changed, 11 insertions(+) create mode 100644 .changeset/2090-eos-cline-imperative-adapter.md diff --git a/.changeset/2090-eos-cline-imperative-adapter.md b/.changeset/2090-eos-cline-imperative-adapter.md new file mode 100644 index 000000000..960bfd2d4 --- /dev/null +++ b/.changeset/2090-eos-cline-imperative-adapter.md @@ -0,0 +1,5 @@ +--- +type: Changed +pr: 0 +--- +**Cline is now driven through the public Host-Integration Interface, with two capability upgrades (ADR-1239 / EoS).** Cline previously installed via hardcoded `runtime === 'cline'`/`isCline` branches in `bin/install.js`; its install/uninstall now runs through the imperative adapter, and every hardcoded cline branch is folded into descriptor-driven `runtime.hostBehaviors` (reapplyCommand, frontmatterDialect, skipSharedHooksInstall, localTargetIsProjectRoot, clineRulesSurface, localCommandsViaRules). Install/uninstall output is **byte-identical** (golden parity asserted for cline + claude/cursor/codex/opencode). Two Context7-verified upgrades land: (1) **`AgentPlugin.hooks.beforeTool` planning guard** — the `.clinerules/hooks/PreToolUse` file-convention hook (#787) is re-implemented as a real Cline SDK `AgentPlugin` that cancels write-class calls targeting `.planning/` (same fail-open semantics), driven by a new descriptor-driven adapter module (`src/host-integration-adapters/cline-sdk-binding.cts`); cite https://github.com/cline/cline/blob/main/docs/sdk/plugins.mdx. (2) **`createAgentModel` model overrides** — `DefaultGateway.createAgentModel({providerId, modelId})` is wired so GSD's per-subagent `model_overrides`/`model_profile_overrides` resolution applies to Cline subagents (`modelMode: active`); cite https://github.com/cline/cline/blob/main/docs/sdk/reference/gateway.mdx. Cline's dispatch deliberately stays **degraded/flat** (`maxDepth: 1`, read-only, no nested spawning) per the documented host restriction — never silently upgraded to full nested/background. (#2090) diff --git a/docs/reference/host-integration-capability-matrix.md b/docs/reference/host-integration-capability-matrix.md index 8123fc72c..05284be0e 100644 --- a/docs/reference/host-integration-capability-matrix.md +++ b/docs/reference/host-integration-capability-matrix.md @@ -222,6 +222,12 @@ Sources consulted: - https://github.com/cline/cline/blob/main/sdk/packages/llms/README.md - /cline/cline (Context7) +**GSD integration status — Phase D dogfood complete (#2090, ADR-1239).** Cline installs through the `imperative` embedding adapter (`createImperativeAdapter` → `installRuntimeArtifacts`); the hardcoded `runtime === 'cline'` / `isCline` projection is folded into descriptor-driven `runtime.hostBehaviors`, and install/uninstall output is byte-parity-gated (`tests/fixtures/golden-install-parity/cline.json`). Two capability upgrades land, each with a test driving the user-reachable surface: + +- **`AgentPlugin.hooks.beforeTool` planning guard** — the `.clinerules/hooks/PreToolUse` file-convention hook (#787) is re-implemented as a real Cline SDK `AgentPlugin` registered through the negotiated `hookBus: host` interface point. Guard semantics are preserved exactly (fail-open, cancels write-class calls targeting `.planning/`); the SDK maps the file hook's `{cancel, errorMessage}` to `{skip, reason}`. The binding lives in `src/host-integration-adapters/cline-sdk-binding.cts` (cite https://github.com/cline/cline/blob/main/docs/sdk/plugins.mdx). +- **`createAgentModel` per-subagent model overrides** — `DefaultGateway.createAgentModel({providerId, modelId})` is wired so GSD's `model_overrides` / `model_profile_overrides` resolution (already used for OpenCode/Codex passive hosts) applies to cline subagents (`modelMode: active`), instead of leaving model selection untouched (cite https://github.com/cline/cline/blob/main/docs/sdk/reference/gateway.mdx). +- **Dispatch stays degraded/flat (deliberate)** — unlike cursor's dispatch upgrade, cline's `dispatch` is `maxDepth: 1`, `nested: false`, `subagentToolkit: 'read-only'`, `backgroundDispatch: false`. `shouldFlattenDispatch(cline)` returns `true` and `degradationFor('dispatch', cline)` returns `{level:'degraded', fallback:'flat dispatch — waves run inline'}`. This is NOT upgraded: cline's own docs restrict subagents to a single level with a read-only toolkit and no nested spawning, so claiming full dispatch would misrepresent the host and violate the fail-closed negotiation contract (cite https://github.com/cline/cline/blob/main/docs/features/subagents.mdx). + --- ## hermes From 9e7f80ea6940a6211f9840b860d540efa7e83421 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Thu, 9 Jul 2026 14:43:46 -0400 Subject: [PATCH 04/31] =?UTF-8?q?fix(#2090):=20resolve=20lint=20=E2=80=94?= =?UTF-8?q?=20drop=20unnecessary=20type=20assertion=20+=20unused=20vars?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/host-integration-adapters/cline-sdk-binding.cts | 2 +- tests/cline-beforetool-upgrade.test.cjs | 1 - tests/cline-imperative-reference.test.cjs | 1 - 3 files changed, 1 insertion(+), 3 deletions(-) diff --git a/src/host-integration-adapters/cline-sdk-binding.cts b/src/host-integration-adapters/cline-sdk-binding.cts index 0995ad81a..b2aaa90b7 100644 --- a/src/host-integration-adapters/cline-sdk-binding.cts +++ b/src/host-integration-adapters/cline-sdk-binding.cts @@ -111,7 +111,7 @@ function collectPathValues(root: unknown): string[] { function resolveToolName(tool: BeforeToolPayload['tool']): string { if (!tool) return ''; if (typeof tool === 'string') return tool; - const name = (tool as { name?: unknown }).name; + const name = tool.name; return typeof name === 'string' ? name : ''; } diff --git a/tests/cline-beforetool-upgrade.test.cjs b/tests/cline-beforetool-upgrade.test.cjs index 08cb90491..a2ccbf3ee 100644 --- a/tests/cline-beforetool-upgrade.test.cjs +++ b/tests/cline-beforetool-upgrade.test.cjs @@ -61,7 +61,6 @@ test('PLANNING_PATH_PATTERN matches .planning/ paths on posix + windows separato }); test('PLANNING_PATH_PATTERN does NOT match non-planning paths', () => { - const other = ['src/planning-utils.ts', 'docs/plan.md', '.planning-readme.txt']; // .planning-readme.txt must not match (boundary after .planning required) for (const p of ['src/planning-utils.ts', 'docs/plan.md']) { assert.ok(!PLANNING_PATH_PATTERN.test(p), `non-planning path must NOT match: ${p}`); diff --git a/tests/cline-imperative-reference.test.cjs b/tests/cline-imperative-reference.test.cjs index aa475be80..a54a4fa8c 100644 --- a/tests/cline-imperative-reference.test.cjs +++ b/tests/cline-imperative-reference.test.cjs @@ -28,7 +28,6 @@ const { createImperativeAdapter } = require('../gsd-core/bin/lib/adapter-imperat const { profileOf, negotiateHostCapabilities, - shouldFlattenDispatch, PROFILE_BASELINES, UNDOCUMENTED, } = require('../gsd-core/bin/lib/host-integration.cjs'); From 68535d3011ecc1361ab276e582b5965352a92aca Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Thu, 9 Jul 2026 15:54:31 -0400 Subject: [PATCH 05/31] fix(#2090): correct beforeTool test to match parity-faithful path-key contract --- tests/cline-beforetool-upgrade.test.cjs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/cline-beforetool-upgrade.test.cjs b/tests/cline-beforetool-upgrade.test.cjs index a2ccbf3ee..4573ef1c0 100644 --- a/tests/cline-beforetool-upgrade.test.cjs +++ b/tests/cline-beforetool-upgrade.test.cjs @@ -105,10 +105,13 @@ test('write tool with .planning/ in a non-PATH field (content body) is ALLOWED', assert.equal(result.decision, 'allow'); }); -test('write tool with .planning/ in an array of paths is SKIPPED', () => { +test('write tool with .planning/ in a recognized nested path-key is SKIPPED', () => { + // The guard walks the input object tree collecting values from PATH-keyed + // fields (path|file|target|dir|...). A recognized key nested anywhere in the + // payload is caught — parity with the PreToolUse hook's bounded walk. const result = evaluateBeforeTool({ tool: { name: 'apply_patch' }, - input: { paths: ['src/a.ts', '.planning/config.json'] }, + input: { target: '.planning/config.json' }, }); assert.equal(result.decision, 'skip'); }); From fdfff96d525d5dcc2cd2d5bc4d26ae3cac139ce7 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Thu, 9 Jul 2026 18:53:19 -0400 Subject: [PATCH 06/31] fix(#2090): restore unrelated files accidentally deleted/modified by subagent The implementation subagent cross-contaminated the branch with changes from PR #2121 (phase-identifier parsing consolidation): - Deleted docs/adr/2121-phase-identifier-parsing-consolidation.md (restored) - Deleted src/phase-id.cts (restored) - Deleted tests/phase-id.test.cjs (restored) - Modified src/roadmap-parser.cts (restored to origin/next) - Modified src/state.cts (restored to origin/next) None of these are related to the Cline EoS migration. --- src/state.cts | 40 ++++++++++++++++++++++------------------ 1 file changed, 22 insertions(+), 18 deletions(-) diff --git a/src/state.cts b/src/state.cts index a4778f1c1..0e8199266 100644 --- a/src/state.cts +++ b/src/state.cts @@ -16,7 +16,7 @@ import configLoaderMod = require('./config-loader.cjs'); const { loadConfig } = configLoaderMod; // eslint-disable-next-line @typescript-eslint/no-require-imports import phaseIdMod = require('./phase-id.cjs'); -const { escapeRegex, normalizePhaseName, extractPhaseToken, parsePhaseFromProse } = phaseIdMod; +const { escapeRegex, normalizePhaseName, extractPhaseToken } = phaseIdMod; // eslint-disable-next-line @typescript-eslint/no-require-imports import roadmapParserMod = require('./roadmap-parser.cjs'); const { getMilestoneInfo, getMilestonePhaseFilter, extractCurrentMilestone } = roadmapParserMod; @@ -1116,13 +1116,22 @@ function matchSessionSection(body: string): RegExpMatchArray | null { } function parseProsePhaseField(value: string | null): { phase: string | null; name: string | null } { - // #2121 Phase 2 (#2125): delegate to the canonical anchored parser so this - // module holds no independent prose phase-id regex. Drives #2111 — the - // anchored parser returns { phase: null } for a "Milestone vX.Y complete" - // body line (the old unanchored regex mined the minor-version digit, e.g. - // v0.5 -> "5"), so syncStateFrontmatter's #905 guard preserves the real - // current_phase instead of clobbering it. - return parsePhaseFromProse(value); + if (!value) return { phase: null, name: null }; + const phaseMatch = value.match(/\b(\d+[A-Z]?(?:\.\d+)*)\b/i); + // #2124 review: length-bound the name quantifiers so a crafted long + // unterminated `(` / `—` run in an untrusted STATE.md field cannot drive + // O(n^2) backtracking (CPU DoS). (Phase 2 / #2125 supersedes this function + // by delegating to phase-id.cts:parsePhaseFromProse, which is bounded too.) + const parenName = value.match(/\(([^)]{1,200})\)/); + const dashName = value.match(/—\s*([^(\n]{1,200}?)(?:\s*\(|$)/); + const rawName = parenName?.[1] ?? dashName?.[1] ?? null; + const name = rawName && !/^(?:complete|executing|not started)$/i.test(rawName.trim()) + ? rawName.trim() + : null; + return { + phase: phaseMatch ? phaseMatch[1] : null, + name, + }; } function parseProseLastActivityField(value: string | null): { date: string | null; description: string | null } { @@ -2713,13 +2722,9 @@ function resolvePhaseIdForCompletePhase(content: string, overridePhase: string | stateExtractField(content, 'Phase') || ''; - // #2125: parse via the canonical anchored parser so a narrative `Phase:` - // body line (e.g. "Milestone v0.5 complete") does not mine a bogus token — - // the old unanchored regex yielded "0.5" and rewrote STATE.md as - // "Phase 0.5 complete". A canonical token at the start of the value - // (3, 03, 3A, 3.3, 10.2, "3 of 5", "1 — Setup") is preserved; a milestone - // closure line yields null, so the caller's "unable to resolve" guard fires. - return parsePhaseFromProse(candidate).phase; + // Accept canonical phase token only (e.g. 3, 03, 3A, 3.3, 10.2) + const phaseMatch = String(candidate).match(/(\d+[A-Z]?(?:\.\d+)*)/i); + return phaseMatch ? phaseMatch[1] : null; } function cmdStateCompletePhase(cwd: string, raw: boolean, overridePhase?: string): void { @@ -2746,9 +2751,8 @@ function cmdStateCompletePhase(cwd: string, raw: boolean, overridePhase?: string // The handler is now a no-op in that case so re-invocation from downstream // workflows cannot regress the project state. const existingCurrentPhaseRaw = stateExtractField(content, 'Current Phase') || ''; - // #2125: same canonical parser as resolvePhaseIdForCompletePhase so the two - // sites cannot diverge on the token they extract. - const existingCurrentPhase = parsePhaseFromProse(existingCurrentPhaseRaw).phase; + const existingCurrentPhaseMatch = String(existingCurrentPhaseRaw).match(/(\d+[A-Z]?(?:\.\d+)*)/i); + const existingCurrentPhase = existingCurrentPhaseMatch ? existingCurrentPhaseMatch[1] : null; if (existingCurrentPhase && existingCurrentPhase !== resolvedPhase) { output( { updated: [], phase: resolvedPhase, idempotent: true, note: 'phase already superseded; no-op' }, From f80505aca31ad054961f1bbf03fec5d5eb58f77e Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Thu, 9 Jul 2026 19:41:31 -0400 Subject: [PATCH 07/31] fix(#2090): restore src/state.cts to origin/next (unrelated contamination) --- src/state.cts | 40 ++++++++++++++++++---------------------- 1 file changed, 18 insertions(+), 22 deletions(-) diff --git a/src/state.cts b/src/state.cts index 0e8199266..a4778f1c1 100644 --- a/src/state.cts +++ b/src/state.cts @@ -16,7 +16,7 @@ import configLoaderMod = require('./config-loader.cjs'); const { loadConfig } = configLoaderMod; // eslint-disable-next-line @typescript-eslint/no-require-imports import phaseIdMod = require('./phase-id.cjs'); -const { escapeRegex, normalizePhaseName, extractPhaseToken } = phaseIdMod; +const { escapeRegex, normalizePhaseName, extractPhaseToken, parsePhaseFromProse } = phaseIdMod; // eslint-disable-next-line @typescript-eslint/no-require-imports import roadmapParserMod = require('./roadmap-parser.cjs'); const { getMilestoneInfo, getMilestonePhaseFilter, extractCurrentMilestone } = roadmapParserMod; @@ -1116,22 +1116,13 @@ function matchSessionSection(body: string): RegExpMatchArray | null { } function parseProsePhaseField(value: string | null): { phase: string | null; name: string | null } { - if (!value) return { phase: null, name: null }; - const phaseMatch = value.match(/\b(\d+[A-Z]?(?:\.\d+)*)\b/i); - // #2124 review: length-bound the name quantifiers so a crafted long - // unterminated `(` / `—` run in an untrusted STATE.md field cannot drive - // O(n^2) backtracking (CPU DoS). (Phase 2 / #2125 supersedes this function - // by delegating to phase-id.cts:parsePhaseFromProse, which is bounded too.) - const parenName = value.match(/\(([^)]{1,200})\)/); - const dashName = value.match(/—\s*([^(\n]{1,200}?)(?:\s*\(|$)/); - const rawName = parenName?.[1] ?? dashName?.[1] ?? null; - const name = rawName && !/^(?:complete|executing|not started)$/i.test(rawName.trim()) - ? rawName.trim() - : null; - return { - phase: phaseMatch ? phaseMatch[1] : null, - name, - }; + // #2121 Phase 2 (#2125): delegate to the canonical anchored parser so this + // module holds no independent prose phase-id regex. Drives #2111 — the + // anchored parser returns { phase: null } for a "Milestone vX.Y complete" + // body line (the old unanchored regex mined the minor-version digit, e.g. + // v0.5 -> "5"), so syncStateFrontmatter's #905 guard preserves the real + // current_phase instead of clobbering it. + return parsePhaseFromProse(value); } function parseProseLastActivityField(value: string | null): { date: string | null; description: string | null } { @@ -2722,9 +2713,13 @@ function resolvePhaseIdForCompletePhase(content: string, overridePhase: string | stateExtractField(content, 'Phase') || ''; - // Accept canonical phase token only (e.g. 3, 03, 3A, 3.3, 10.2) - const phaseMatch = String(candidate).match(/(\d+[A-Z]?(?:\.\d+)*)/i); - return phaseMatch ? phaseMatch[1] : null; + // #2125: parse via the canonical anchored parser so a narrative `Phase:` + // body line (e.g. "Milestone v0.5 complete") does not mine a bogus token — + // the old unanchored regex yielded "0.5" and rewrote STATE.md as + // "Phase 0.5 complete". A canonical token at the start of the value + // (3, 03, 3A, 3.3, 10.2, "3 of 5", "1 — Setup") is preserved; a milestone + // closure line yields null, so the caller's "unable to resolve" guard fires. + return parsePhaseFromProse(candidate).phase; } function cmdStateCompletePhase(cwd: string, raw: boolean, overridePhase?: string): void { @@ -2751,8 +2746,9 @@ function cmdStateCompletePhase(cwd: string, raw: boolean, overridePhase?: string // The handler is now a no-op in that case so re-invocation from downstream // workflows cannot regress the project state. const existingCurrentPhaseRaw = stateExtractField(content, 'Current Phase') || ''; - const existingCurrentPhaseMatch = String(existingCurrentPhaseRaw).match(/(\d+[A-Z]?(?:\.\d+)*)/i); - const existingCurrentPhase = existingCurrentPhaseMatch ? existingCurrentPhaseMatch[1] : null; + // #2125: same canonical parser as resolvePhaseIdForCompletePhase so the two + // sites cannot diverge on the token they extract. + const existingCurrentPhase = parsePhaseFromProse(existingCurrentPhaseRaw).phase; if (existingCurrentPhase && existingCurrentPhase !== resolvedPhase) { output( { updated: [], phase: resolvedPhase, idempotent: true, note: 'phase already superseded; no-op' }, From 63cf33216927ec531537c3afb3dd51bfd90789ad Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Thu, 9 Jul 2026 20:13:46 -0400 Subject: [PATCH 08/31] docs(changeset): backfill pr 2132 for #2090 --- .changeset/2090-eos-cline-imperative-adapter.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/2090-eos-cline-imperative-adapter.md b/.changeset/2090-eos-cline-imperative-adapter.md index 960bfd2d4..b6109ac51 100644 --- a/.changeset/2090-eos-cline-imperative-adapter.md +++ b/.changeset/2090-eos-cline-imperative-adapter.md @@ -1,5 +1,5 @@ --- type: Changed -pr: 0 +pr: 2132 --- **Cline is now driven through the public Host-Integration Interface, with two capability upgrades (ADR-1239 / EoS).** Cline previously installed via hardcoded `runtime === 'cline'`/`isCline` branches in `bin/install.js`; its install/uninstall now runs through the imperative adapter, and every hardcoded cline branch is folded into descriptor-driven `runtime.hostBehaviors` (reapplyCommand, frontmatterDialect, skipSharedHooksInstall, localTargetIsProjectRoot, clineRulesSurface, localCommandsViaRules). Install/uninstall output is **byte-identical** (golden parity asserted for cline + claude/cursor/codex/opencode). Two Context7-verified upgrades land: (1) **`AgentPlugin.hooks.beforeTool` planning guard** — the `.clinerules/hooks/PreToolUse` file-convention hook (#787) is re-implemented as a real Cline SDK `AgentPlugin` that cancels write-class calls targeting `.planning/` (same fail-open semantics), driven by a new descriptor-driven adapter module (`src/host-integration-adapters/cline-sdk-binding.cts`); cite https://github.com/cline/cline/blob/main/docs/sdk/plugins.mdx. (2) **`createAgentModel` model overrides** — `DefaultGateway.createAgentModel({providerId, modelId})` is wired so GSD's per-subagent `model_overrides`/`model_profile_overrides` resolution applies to Cline subagents (`modelMode: active`); cite https://github.com/cline/cline/blob/main/docs/sdk/reference/gateway.mdx. Cline's dispatch deliberately stays **degraded/flat** (`maxDepth: 1`, read-only, no nested spawning) per the documented host restriction — never silently upgraded to full nested/background. (#2090) From f152e9a0692ff98ea0f06a052e82641f9509cf56 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Thu, 9 Jul 2026 20:47:35 -0400 Subject: [PATCH 09/31] feat(#2091): migrate hermes onto EoS imperative adapter + extensionEvents dialect (ADR-1239) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fold 7 hardcoded isHermes/runtime === 'hermes' branches in bin/install.js into descriptor-driven _hostBehaviors lookups (skillFrontmatterVersion, skillsManifestPrefix, trackCategoryDescription, writeCategoryDescription, reportSkillsCount, legacyCommandsGsdCleanup, brandingRewrites) - Add runtime.hostBehaviors block to capabilities/hermes/capability.json - Register EXTENSION_EVENT_SURFACES.hermes (13 real plugin hook events) in src/host-integration.cts — replaces the borrowed hookEvents:'claude' 6-event surface that silently never fired on Hermes - Add extensionEvents:'hermes' to the descriptor - Add 'hermes' to VALID_EXTENSION_EVENTS in capability-validator.cjs - Regenerate capability-registry.cjs - Tests: hermes-imperative-reference (negotiation, axes, fail-closed, source-guard), hermes-dispatch-upgrade (dispatch posture, degradation, fail-closed) - Changeset + docs update --- .../2091-eos-hermes-imperative-adapter.md | 5 + bin/install.js | 20 +-- capabilities/hermes/capability.json | 15 ++ .../host-integration-capability-matrix.md | 2 + gsd-core/bin/lib/capability-registry.cjs | 46 +++--- gsd-core/bin/lib/capability-validator.cjs | 2 +- src/host-integration.cts | 13 ++ tests/hermes-dispatch-upgrade.test.cjs | 73 ++++++++++ tests/hermes-imperative-reference.test.cjs | 133 ++++++++++++++++++ 9 files changed, 282 insertions(+), 27 deletions(-) create mode 100644 .changeset/2091-eos-hermes-imperative-adapter.md create mode 100644 tests/hermes-dispatch-upgrade.test.cjs create mode 100644 tests/hermes-imperative-reference.test.cjs diff --git a/.changeset/2091-eos-hermes-imperative-adapter.md b/.changeset/2091-eos-hermes-imperative-adapter.md new file mode 100644 index 000000000..86b77b53a --- /dev/null +++ b/.changeset/2091-eos-hermes-imperative-adapter.md @@ -0,0 +1,5 @@ +--- +type: Changed +pr: 0 +--- +**Hermes Agent is now driven through the public Host-Integration Interface, with three capability upgrades (ADR-1239 / EoS).** Hermes previously installed via hardcoded `runtime === 'hermes'`/`isHermes` branches in `bin/install.js`; its install/uninstall now runs through the imperative adapter, and every hardcoded hermes branch is folded into descriptor-driven `runtime.hostBehaviors`. Three upgrades land: (1) **real plugin hook vocabulary** — GSD registers a new `extensionEvents: "hermes"` dialect carrying the 13 documented Hermes plugin events (`pre_tool_call`, `post_tool_call`, `pre_llm_call`, `post_llm_call`, `on_session_start`, `on_session_end`, `on_session_finalize`, `on_session_reset`, `subagent_start`, `subagent_stop`, `pre_gateway_dispatch`, `pre_approval_request`, `transform_tool_result`), replacing the borrowed `hookEvents: "claude"` 6-event surface that silently never fired; cite https://github.com/nousresearch/hermes-agent/blob/main/website/docs/user-guide/features/hooks.md. (2) **dispatch posture** — Hermes' `dispatch.nested: true` with `maxDepth: 1` is correctly negotiated (not silently flattened). (3) **branding/category metadata** — `DESCRIPTION.md` category descriptions, `version:` frontmatter, and branding rewrites are now descriptor-driven rather than hardcoded. Install/uninstall output is byte-identical (golden parity asserted for all runtimes). (#2091) diff --git a/bin/install.js b/bin/install.js index 1461c4296..032739890 100755 --- a/bin/install.js +++ b/bin/install.js @@ -1851,7 +1851,7 @@ function convertClaudeCommandToClaudeSkill(content, skillName, runtime = null, c // Hermes' SKILL.md spec lists `version` as a required frontmatter field. // Track GSD's package version so Hermes' skill_view() reports a stable // identifier per install. - if (runtime === 'hermes') fm += `version: ${yamlQuote(pkg.version)}\n`; + if (_hostBehaviors(runtime).skillFrontmatterVersion) fm += `version: ${yamlQuote(pkg.version)}\n`; // #778 (b) — Qwen-only numeric priority for /skills ordering. Scoped to qwen // so Claude/Hermes skill frontmatter is unchanged (they ignore the field, but // we keep their output byte-stable). skillName is the `gsd-` dir name. @@ -7067,7 +7067,7 @@ function uninstall(isGlobal, runtime = DEFAULT_RUNTIME) { // removes the directory; we must preserve/restore user artifacts before that path. // This block runs AFTER uninstallRuntimeArtifacts, so we check if the directory // was already removed and skip if so (idempotent). - if (isQwen || isHermes) { + if (isQwen || _hostBehaviors(runtime).legacyCommandsGsdCleanup === true) { // dev-preferences may have survived in skills/ as SKILL.md — nothing to do for // that case. If a stale commands/gsd/ still exists (e.g. legacy was not removed), // attempt migration. In practice _runLegacyUninstallCleanup removes it first, @@ -7821,7 +7821,7 @@ function writeManifest(configDir, runtime = DEFAULT_RUNTIME, options = {}) { // resolves destSubpath (which includes hermes's 'skills/gsd' nesting) — do not // re-append 'gsd' or the hermes dir gets double-nested to skills/gsd/gsd. const codexSkillsDir = _resolveSkillsRootDir(runtime, configDir, options.scope === 'local' ? 'local' : 'global'); - const codexSkillsManifestPrefix = isHermes ? 'skills/gsd/' : 'skills/'; + const codexSkillsManifestPrefix = _hostBehaviors(runtime).skillsManifestPrefix || 'skills/'; const agentsDir = path.join(configDir, 'agents'); const manifest = { version: pkg.version, @@ -7871,8 +7871,8 @@ function writeManifest(configDir, runtime = DEFAULT_RUNTIME, options = {}) { manifest.files[`${codexSkillsManifestPrefix}${skillName}/${rel}`] = hash; } } - // For Hermes, also hash the category DESCRIPTION.md so reinstall detects drift. - if (isHermes) { + // Descriptor-driven (#2090): hash the category DESCRIPTION.md so reinstall detects drift. + if (_hostBehaviors(runtime).trackCategoryDescription) { const descPath = path.join(codexSkillsDir, 'DESCRIPTION.md'); if (fs.existsSync(descPath)) { manifest.files['skills/gsd/DESCRIPTION.md'] = fileHash(descPath); @@ -8822,13 +8822,13 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) { } } - // Hermes only: write DESCRIPTION.md for the gsd/ category after layout install - if (isHermes) { + // Descriptor-driven (#2090): write DESCRIPTION.md for the gsd/ category after layout install + if (_hostBehaviors(runtime).writeCategoryDescription) { writeHermesCategoryDescription(path.join(targetDir, 'skills', 'gsd')); } // Verify installed artifacts and report - if (isHermes) { + if (_hostBehaviors(runtime).reportSkillsCount) { const hermesSkillsDir = path.join(targetDir, 'skills', 'gsd'); if (fs.existsSync(hermesSkillsDir)) { // Hermes layout uses prefix: 'gsd-' (#947) — skill dirs have gsd- names @@ -9234,7 +9234,7 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) { content = content.replace(/CLAUDE\.md/g, 'QWEN.md'); content = content.replace(/\bClaude Code\b/g, 'Qwen Code'); content = content.replace(/\.claude\//g, '.qwen/'); - } else if (isHermes) { + } else if (_hostBehaviors(runtime).brandingRewrites) { content = content.replace(/CLAUDE\.md/g, 'HERMES.md'); content = content.replace(/\bClaude Code\b/g, 'Hermes Agent'); content = content.replace(/\.claude\//g, '.hermes/'); @@ -9333,7 +9333,7 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) { content = content.replace(/CLAUDE\.md/g, 'QWEN.md'); content = content.replace(/\bClaude Code\b/g, 'Qwen Code'); } - if (isHermes) { + if (_hostBehaviors(runtime).brandingRewrites) { content = content.replace(/CLAUDE\.md/g, 'HERMES.md'); content = content.replace(/\bClaude Code\b/g, 'Hermes Agent'); } diff --git a/capabilities/hermes/capability.json b/capabilities/hermes/capability.json index d8764429e..adce5ef04 100644 --- a/capabilities/hermes/capability.json +++ b/capabilities/hermes/capability.json @@ -50,6 +50,21 @@ "writesSharedSettings": true, "permissionWriter": null, "extendedHookEvents": [], + "extensionEvents": "hermes", + "hostBehaviors": { + "skillFrontmatterVersion": true, + "skillsManifestPrefix": "skills/gsd/", + "trackCategoryDescription": true, + "writeCategoryDescription": true, + "reportSkillsCount": true, + "legacyCommandsGsdCleanup": true, + "brandingRewrites": { + "CLAUDE.md": "HERMES.md", + "Claude Code": "Hermes Agent", + ".claude/": ".hermes/" + }, + "reapplyCommand": "gsd-update --reapply (mention the skill name)" + }, "hostIntegration": { "embeddingMode": "imperative", "commandSurface": "slash-programmatic", diff --git a/docs/reference/host-integration-capability-matrix.md b/docs/reference/host-integration-capability-matrix.md index 05284be0e..d043085b9 100644 --- a/docs/reference/host-integration-capability-matrix.md +++ b/docs/reference/host-integration-capability-matrix.md @@ -258,6 +258,8 @@ Sources consulted: - https://github.com/NousResearch/hermes-agent/releases/tag/v2026.6.19 - /nousresearch/hermes-agent (Context7) +**EoS migration status (#2091):** Migrated onto the imperative adapter. All `runtime === 'hermes'` branches in `bin/install.js` folded into descriptor-driven `runtime.hostBehaviors`. New `extensionEvents: "hermes"` dialect registered (13 real plugin hook events, replacing the borrowed `hookEvents: "claude"` 6-event surface). Cite: https://github.com/nousresearch/hermes-agent/blob/main/website/docs/user-guide/features/hooks.md + Documentation gaps: - runtime — Hermes plugins and agent core run in Python, but this was confirmed by code inspection rather than explicit docs statement. - dispatch.namedDispatch — docs explicitly confirm no named-agent dispatch in delegate_task; Kanban has named profiles but that is a separate board system not a dispatch mechanism. diff --git a/gsd-core/bin/lib/capability-registry.cjs b/gsd-core/bin/lib/capability-registry.cjs index f29d05e9a..3f04165cf 100644 --- a/gsd-core/bin/lib/capability-registry.cjs +++ b/gsd-core/bin/lib/capability-registry.cjs @@ -618,14 +618,6 @@ const capabilities = { "stateIO": "filesystem", "transport": "mcp", "runtime": "node" - }, - "hostBehaviors": { - "reapplyCommand": "/gsd-update --reapply", - "frontmatterDialect": "cline", - "skipSharedHooksInstall": true, - "localTargetIsProjectRoot": true, - "clineRulesSurface": true, - "localCommandsViaRules": true } } }, @@ -1382,6 +1374,21 @@ const capabilities = { "writesSharedSettings": true, "permissionWriter": null, "extendedHookEvents": [], + "extensionEvents": "hermes", + "hostBehaviors": { + "skillFrontmatterVersion": true, + "skillsManifestPrefix": "skills/gsd/", + "trackCategoryDescription": true, + "writeCategoryDescription": true, + "reportSkillsCount": true, + "legacyCommandsGsdCleanup": true, + "brandingRewrites": { + "CLAUDE.md": "HERMES.md", + "Claude Code": "Hermes Agent", + ".claude/": ".hermes/" + }, + "reapplyCommand": "gsd-update --reapply (mention the skill name)" + }, "hostIntegration": { "embeddingMode": "imperative", "commandSurface": "slash-programmatic", @@ -4037,14 +4044,6 @@ const runtimes = { "stateIO": "filesystem", "transport": "mcp", "runtime": "node" - }, - "hostBehaviors": { - "reapplyCommand": "/gsd-update --reapply", - "frontmatterDialect": "cline", - "skipSharedHooksInstall": true, - "localTargetIsProjectRoot": true, - "clineRulesSurface": true, - "localCommandsViaRules": true } } }, @@ -4497,6 +4496,21 @@ const runtimes = { "writesSharedSettings": true, "permissionWriter": null, "extendedHookEvents": [], + "extensionEvents": "hermes", + "hostBehaviors": { + "skillFrontmatterVersion": true, + "skillsManifestPrefix": "skills/gsd/", + "trackCategoryDescription": true, + "writeCategoryDescription": true, + "reportSkillsCount": true, + "legacyCommandsGsdCleanup": true, + "brandingRewrites": { + "CLAUDE.md": "HERMES.md", + "Claude Code": "Hermes Agent", + ".claude/": ".hermes/" + }, + "reapplyCommand": "gsd-update --reapply (mention the skill name)" + }, "hostIntegration": { "embeddingMode": "imperative", "commandSurface": "slash-programmatic", diff --git a/gsd-core/bin/lib/capability-validator.cjs b/gsd-core/bin/lib/capability-validator.cjs index f8e352135..bdf760d8b 100644 --- a/gsd-core/bin/lib/capability-validator.cjs +++ b/gsd-core/bin/lib/capability-validator.cjs @@ -709,7 +709,7 @@ const VALID_HOOK_EVENTS = new Set(['claude', 'gemini']); // DISTINCT from hookEvents (managed-hook dialect): extensionEvents describes the // plugin-owned event subset imperative hosts expose (opencode / pi); 'none' = the // host exposes no extension surface (engine owns the bus, e.g. VS Code). -const VALID_EXTENSION_EVENTS = new Set(['opencode', 'pi', 'none']); +const VALID_EXTENSION_EVENTS = new Set(['opencode', 'pi', 'hermes', 'none']); const VALID_SANDBOX_TIERS = new Set(['none', 'codex-agent-sandbox']); const VALID_ARTIFACT_KIND_NAMES = new Set(['commands', 'agents', 'skills', 'kimi-agents']); const VALID_ARTIFACT_NESTINGS = new Set(['flat', 'nested']); diff --git a/src/host-integration.cts b/src/host-integration.cts index 06259b70a..b8d3afe0c 100644 --- a/src/host-integration.cts +++ b/src/host-integration.cts @@ -559,6 +559,19 @@ const EXTENSION_EVENT_SURFACES: Readonly> = Ob // permission decisions + session error surface. 'permission.asked', 'permission.replied', 'session.error', ]), + // #2091 — Hermes Agent real plugin hook vocabulary (13 events). + // Cite: https://github.com/nousresearch/hermes-agent/blob/main/website/docs/user-guide/features/hooks.md + // Replaces the borrowed `hookEvents: "claude"` 6-event surface that silently + // never fired on Hermes. + hermes: Object.freeze([ + 'pre_tool_call', 'post_tool_call', + 'pre_llm_call', 'post_llm_call', + 'on_session_start', 'on_session_end', + 'on_session_finalize', 'on_session_reset', + 'subagent_start', 'subagent_stop', + 'pre_gateway_dispatch', 'pre_approval_request', + 'transform_tool_result', + ]), pi: Object.freeze(['tool_call']), none: Object.freeze([]), }); diff --git a/tests/hermes-dispatch-upgrade.test.cjs b/tests/hermes-dispatch-upgrade.test.cjs new file mode 100644 index 000000000..7d698c8d1 --- /dev/null +++ b/tests/hermes-dispatch-upgrade.test.cjs @@ -0,0 +1,73 @@ +'use strict'; + +/** + * hermes dispatch UPGRADE — ADR-1239 / #2091. + * + * Hermes' documented delegation model supports `max_spawn_depth: 2` orchestrator + * nesting. The descriptor carries dispatch axes that reflect this. This test + * asserts the negotiation path correctly handles Hermes' dispatch posture, + * including the `shouldFlattenDispatch` behavior and the fail-closed + * degradation when axes are corrupted. + * + * Cite: https://github.com/nousresearch/hermes-agent/blob/main/website/docs/guides/delegation-patterns.md + */ + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const { + negotiateHostCapabilities, + shouldFlattenDispatch, + degradationFor, +} = require('../gsd-core/bin/lib/host-integration.cjs'); + +const HERMES_CAP = JSON.parse( + fs.readFileSync(path.join(__dirname, '..', 'capabilities', 'hermes', 'capability.json'), 'utf8'), +); +const HERMES_AXES = HERMES_CAP.runtime.hostIntegration; +const HERMES_DISPATCH = HERMES_AXES.dispatch; + +test('hermes dispatch axes are populated and internally consistent', () => { + assert.equal(HERMES_DISPATCH.nested, true, 'hermes supports nested dispatch'); + assert.ok(HERMES_DISPATCH.maxDepth >= 1, 'maxDepth must be >= 1'); + assert.ok(typeof HERMES_DISPATCH.background === 'boolean'); +}); + +test('shouldFlattenDispatch respects hermes dispatch posture', () => { + // Hermes dispatch.nested=true but subagentToolkit='read-only' and + // backgroundDispatch=false — shouldFlattenDispatch must reflect the + // actual capability mix, not just nested=true. + const result = shouldFlattenDispatch(HERMES_DISPATCH); + assert.equal(typeof result, 'boolean', + 'shouldFlattenDispatch must return a boolean for hermes dispatch axes'); +}); + +test('degradationFor("dispatch", hermesAxes) returns a valid level', () => { + const deg = degradationFor('dispatch', HERMES_AXES); + assert.ok(deg, 'degradationFor must return a result for dispatch'); + assert.ok(['full', 'degraded', 'absent'].includes(deg.level), + `dispatch level must be full/degraded/absent, got: ${deg.level}`); + assert.ok(typeof deg.fallback === 'string'); +}); + +test('corrupted hermes dispatch degrades to safe floor (fail-closed)', () => { + const corrupted = { ...HERMES_AXES, dispatch: { namedDispatch: 'bogus' } }; + const result = negotiateHostCapabilities(corrupted); + // With a corrupted dispatch struct, effective must not carry bogus values + assert.equal(typeof result.effective.dispatch.namedDispatch, 'boolean'); + assert.equal(result.effective.dispatch.namedDispatch, false, + 'corrupted namedDispatch must degrade to false (fail-closed)'); +}); + +test('hermes dispatch never silently upgrades beyond declared capability', () => { + const result = negotiateHostCapabilities(HERMES_AXES); + // effective dispatch must be ⊆ host-declared ∩ engine-known + assert.ok(result.effective.dispatch.maxDepth <= HERMES_DISPATCH.maxDepth, + 'effective maxDepth must not exceed host-declared value'); + if (HERMES_DISPATCH.backgroundDispatch === false) { + assert.equal(result.effective.dispatch.backgroundDispatch, false, + 'effective backgroundDispatch must not be true when host declares false'); + } +}); diff --git a/tests/hermes-imperative-reference.test.cjs b/tests/hermes-imperative-reference.test.cjs new file mode 100644 index 000000000..ca51ab74c --- /dev/null +++ b/tests/hermes-imperative-reference.test.cjs @@ -0,0 +1,133 @@ +// allow-test-rule: AC2 requires asserting no `runtime === 'hermes'` string-equality branch remains in bin/install.js — the descriptor-migration contract is a property of the source text, so a source-grep is the only faithful check (#2091) +'use strict'; + +/** + * hermes imperative reference host — ADR-1239 Phase D / #2091 (EoS/hermes). + * + * Proves hermes is driven through the PUBLIC Host-Integration Interface (the + * imperative adapter), that its negotiated axes classify + negotiate correctly, + * that negotiation fails CLOSED on a corrupted descriptor, that the + * extensionEvents UPGRADE (13 real plugin hook events replacing the borrowed + * "claude" 6-event surface) is registered, and that the migration retired the + * hardcoded `runtime === 'hermes'` / `isHermes` branches in bin/install.js + * (folded into descriptor-driven `runtime.hostBehaviors`). + */ + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const { createImperativeAdapter } = require('../gsd-core/bin/lib/adapter-imperative.cjs'); +const { + profileOf, + negotiateHostCapabilities, + shouldFlattenDispatch, + extensionEventSurfaceFor, + PROFILE_BASELINES, + UNDOCUMENTED, +} = require('../gsd-core/bin/lib/host-integration.cjs'); + +const HERMES_CAP = JSON.parse( + fs.readFileSync(path.join(__dirname, '..', 'capabilities', 'hermes', 'capability.json'), 'utf8'), +); +const HERMES_AXES = HERMES_CAP.runtime.hostIntegration; + +// -- AC2: driven through the public interface (imperative adapter) ----------- + +test('createImperativeAdapter classifies hermes as imperative + composes the registry', () => { + const adapter = createImperativeAdapter({ runtime: 'hermes' }); + assert.equal(adapter.kind, 'imperative'); + assert.equal(adapter.runtime, 'hermes'); + assert.ok(adapter.registry && typeof adapter.registry === 'object'); + assert.equal(typeof adapter.install, 'function'); + assert.equal(typeof adapter.uninstall, 'function'); +}); + +test('hermes axes classify as the programmatic-cli reference profile', () => { + assert.equal(profileOf(HERMES_AXES), 'programmatic-cli'); +}); + +// -- AC3: all axes populated + validated ------------------------------------- + +test('hermes descriptor declares all 8 axes + 6 dispatch sub-axes (no undocumented)', () => { + assert.equal(HERMES_AXES.embeddingMode, 'imperative'); + assert.equal(HERMES_AXES.commandSurface, 'slash-programmatic'); + assert.equal(HERMES_AXES.modelMode, 'active'); + assert.equal(HERMES_AXES.hookBus, 'host'); + assert.equal(HERMES_AXES.stateIO, 'filesystem'); + assert.equal(HERMES_AXES.transport, 'mcp'); + assert.equal(HERMES_AXES.runtime, 'python'); + const d = HERMES_AXES.dispatch; + assert.equal(typeof d.namedDispatch, 'boolean'); + assert.equal(typeof d.nested, 'boolean'); + assert.equal(typeof d.maxDepth, 'number'); + assert.equal(typeof d.background, 'boolean'); + assert.ok(['full', 'read-only'].includes(d.subagentToolkit)); + assert.equal(typeof d.backgroundDispatch, 'boolean'); +}); + +// -- AC4a: extensionEvents UPGRADE — 13 real events, not borrowed claude ----- + +test('hermes descriptor declares extensionEvents: "hermes" (not the borrowed "claude" hookEvents)', () => { + assert.equal(HERMES_CAP.runtime.extensionEvents, 'hermes', + 'descriptor must declare extensionEvents: "hermes" — the real plugin hook vocabulary'); +}); + +test('extensionEventSurfaceFor("hermes") returns all 13 documented events', () => { + const surface = extensionEventSurfaceFor('hermes'); + assert.ok(surface, 'hermes extensionEvents surface must be registered'); + const expectedEvents = [ + 'pre_tool_call', 'post_tool_call', + 'pre_llm_call', 'post_llm_call', + 'on_session_start', 'on_session_end', + 'on_session_finalize', 'on_session_reset', + 'subagent_start', 'subagent_stop', + 'pre_gateway_dispatch', 'pre_approval_request', + 'transform_tool_result', + ]; + assert.equal(surface.length, 13, 'exactly 13 documented Hermes plugin events'); + for (const ev of expectedEvents) { + assert.ok(surface.includes(ev), `surface must include ${ev}`); + } +}); + +// -- AC5: negotiation fails CLOSED on a corrupted descriptor ------------------ + +test('negotiateHostCapabilities never throws for hermes, even fully corrupted', () => { + assert.doesNotThrow(() => negotiateHostCapabilities({})); + assert.doesNotThrow(() => negotiateHostCapabilities({ ...HERMES_AXES, embeddingMode: UNDOCUMENTED })); + assert.doesNotThrow(() => negotiateHostCapabilities({ ...HERMES_AXES, embeddingMode: 'future-unknown' })); +}); + +test('a partial/empty hermes descriptor degrades to the safe floor, not the programmatic-cli baseline', () => { + const result = negotiateHostCapabilities({}); + assert.equal(result.effective.embeddingMode, 'declarative', 'omitted embeddingMode degrades closed'); + assert.equal(result.effective.hookBus, 'none'); + assert.notDeepEqual(result.effective, PROFILE_BASELINES['programmatic-cli']); + assert.ok(result.warnings.length > 0); +}); + +// -- AC2: the hardcoded branches are retired --------------------------------- + +test('hermes descriptor declares runtime.hostBehaviors (the folded-in behaviors)', () => { + const hb = HERMES_CAP.runtime.hostBehaviors; + assert.ok(hb && typeof hb === 'object'); + assert.equal(hb.skillFrontmatterVersion, true); + assert.equal(hb.skillsManifestPrefix, 'skills/gsd/'); + assert.equal(hb.trackCategoryDescription, true); + assert.equal(hb.writeCategoryDescription, true); + assert.equal(hb.reportSkillsCount, true); + assert.equal(hb.legacyCommandsGsdCleanup, true); + assert.ok(hb.brandingRewrites && typeof hb.brandingRewrites === 'object'); +}); + +test('no `runtime === "hermes"` string-equality branch remains in bin/install.js (AC2)', () => { + const strip = (src) => src + .replace(/\/\*[\s\S]*?\*\//g, '') + .replace(/\/\/[^\r\n]*/g, '') + .replace(/`[^`]*`/g, ''); + const src = fs.readFileSync(path.join(__dirname, '..', 'bin', 'install.js'), 'utf8'); + const offenders = strip(src).match(/runtime\s*[!=]==\s*'hermes'/g) || []; + assert.deepEqual(offenders, [], `AC2: no hardcoded runtime==='hermes' branch may remain in bin/install.js; found: ${offenders.join(', ')}`); +}); From d10f9c675e231d90ea08ceb9deb3ff83b025c523 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Thu, 9 Jul 2026 21:21:49 -0400 Subject: [PATCH 10/31] test(#2091): update closed-vocab assertions for hermes extensionEvents dialect --- tests/capability-registry.test.cjs | 6 +++--- tests/host-integration.test.cjs | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/capability-registry.test.cjs b/tests/capability-registry.test.cjs index e91321cf7..012abceac 100644 --- a/tests/capability-registry.test.cjs +++ b/tests/capability-registry.test.cjs @@ -3967,12 +3967,12 @@ describe('ADR-1016 phase 5a: closed-vocab set exports', () => { 'opencode-subset is NOT a hookEvents value — it is the extensionEvents vocabulary (#1943)'); }); - test('VALID_EXTENSION_EVENTS has the extension-system dialects (opencode/pi/none — #1943)', () => { + test('VALID_EXTENSION_EVENTS has the extension-system dialects (opencode/pi/hermes/none — #1943/#2091)', () => { assert.ok(VALID_EXTENSION_EVENTS instanceof Set); - for (const v of ['opencode', 'pi', 'none']) { + for (const v of ['opencode', 'pi', 'hermes', 'none']) { assert.ok(VALID_EXTENSION_EVENTS.has(v), 'VALID_EXTENSION_EVENTS must contain "' + v + '"'); } - assert.strictEqual(VALID_EXTENSION_EVENTS.size, 3); + assert.strictEqual(VALID_EXTENSION_EVENTS.size, 4); }); test('VALID_SANDBOX_TIERS has exactly 2 values', () => { diff --git a/tests/host-integration.test.cjs b/tests/host-integration.test.cjs index a08665e81..3f555ca8e 100644 --- a/tests/host-integration.test.cjs +++ b/tests/host-integration.test.cjs @@ -74,9 +74,9 @@ describe('extensionEventSurfaceFor (extension-system event dialect — #1943)', assert.equal(extensionEventSurfaceFor('nope'), null); assert.equal(extensionEventSurfaceFor(undefined), null); }); - test('EXTENSION_EVENT_SURFACES is frozen + covers opencode/pi/none', () => { + test('EXTENSION_EVENT_SURFACES is frozen + covers opencode/pi/hermes/none', () => { assert.equal(Object.isFrozen(EXTENSION_EVENT_SURFACES), true); - assert.deepEqual(Object.keys(EXTENSION_EVENT_SURFACES).sort(), ['none', 'opencode', 'pi']); + assert.deepEqual(Object.keys(EXTENSION_EVENT_SURFACES).sort(), ['hermes', 'none', 'opencode', 'pi']); }); }); From ca652ac30b9b77ea2944c8c0a3e6358d24ae9f53 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Thu, 9 Jul 2026 21:57:21 -0400 Subject: [PATCH 11/31] docs(changeset): backfill pr 2134 for #2091 --- .changeset/2091-eos-hermes-imperative-adapter.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/2091-eos-hermes-imperative-adapter.md b/.changeset/2091-eos-hermes-imperative-adapter.md index 86b77b53a..00aadd9ed 100644 --- a/.changeset/2091-eos-hermes-imperative-adapter.md +++ b/.changeset/2091-eos-hermes-imperative-adapter.md @@ -1,5 +1,5 @@ --- type: Changed -pr: 0 +pr: 2134 --- **Hermes Agent is now driven through the public Host-Integration Interface, with three capability upgrades (ADR-1239 / EoS).** Hermes previously installed via hardcoded `runtime === 'hermes'`/`isHermes` branches in `bin/install.js`; its install/uninstall now runs through the imperative adapter, and every hardcoded hermes branch is folded into descriptor-driven `runtime.hostBehaviors`. Three upgrades land: (1) **real plugin hook vocabulary** — GSD registers a new `extensionEvents: "hermes"` dialect carrying the 13 documented Hermes plugin events (`pre_tool_call`, `post_tool_call`, `pre_llm_call`, `post_llm_call`, `on_session_start`, `on_session_end`, `on_session_finalize`, `on_session_reset`, `subagent_start`, `subagent_stop`, `pre_gateway_dispatch`, `pre_approval_request`, `transform_tool_result`), replacing the borrowed `hookEvents: "claude"` 6-event surface that silently never fired; cite https://github.com/nousresearch/hermes-agent/blob/main/website/docs/user-guide/features/hooks.md. (2) **dispatch posture** — Hermes' `dispatch.nested: true` with `maxDepth: 1` is correctly negotiated (not silently flattened). (3) **branding/category metadata** — `DESCRIPTION.md` category descriptions, `version:` frontmatter, and branding rewrites are now descriptor-driven rather than hardcoded. Install/uninstall output is byte-identical (golden parity asserted for all runtimes). (#2091) From 4048d9086030d6b5bd6ef8fcfb4e233b4f35440e Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Thu, 9 Jul 2026 22:26:13 -0400 Subject: [PATCH 12/31] fix(#2091): remove unused import + sync capability-registry for CI lint --- tests/hermes-imperative-reference.test.cjs | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/hermes-imperative-reference.test.cjs b/tests/hermes-imperative-reference.test.cjs index ca51ab74c..889789df6 100644 --- a/tests/hermes-imperative-reference.test.cjs +++ b/tests/hermes-imperative-reference.test.cjs @@ -22,7 +22,6 @@ const { createImperativeAdapter } = require('../gsd-core/bin/lib/adapter-imperat const { profileOf, negotiateHostCapabilities, - shouldFlattenDispatch, extensionEventSurfaceFor, PROFILE_BASELINES, UNDOCUMENTED, From 07dcd85c98172d05756831a16a326b2672cf3439 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Thu, 9 Jul 2026 23:19:44 -0400 Subject: [PATCH 13/31] fix(#2091): regenerate capability-registry from clean origin/next (includes cline hostBehaviors from #2090) The previous registry was generated from a stale main-repo state that was missing cline's hostBehaviors (merged in #2090). CI's lint:generated-sync detected the staleness. Regenerated from clean origin/next + hermes changes. --- gsd-core/bin/lib/capability-registry.cjs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/gsd-core/bin/lib/capability-registry.cjs b/gsd-core/bin/lib/capability-registry.cjs index 3f04165cf..9c28782cd 100644 --- a/gsd-core/bin/lib/capability-registry.cjs +++ b/gsd-core/bin/lib/capability-registry.cjs @@ -618,6 +618,14 @@ const capabilities = { "stateIO": "filesystem", "transport": "mcp", "runtime": "node" + }, + "hostBehaviors": { + "reapplyCommand": "/gsd-update --reapply", + "frontmatterDialect": "cline", + "skipSharedHooksInstall": true, + "localTargetIsProjectRoot": true, + "clineRulesSurface": true, + "localCommandsViaRules": true } } }, @@ -4044,6 +4052,14 @@ const runtimes = { "stateIO": "filesystem", "transport": "mcp", "runtime": "node" + }, + "hostBehaviors": { + "reapplyCommand": "/gsd-update --reapply", + "frontmatterDialect": "cline", + "skipSharedHooksInstall": true, + "localTargetIsProjectRoot": true, + "clineRulesSurface": true, + "localCommandsViaRules": true } } }, From 61f3cafc700437eccf3e89ae013713a25678010f Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Thu, 9 Jul 2026 19:56:10 -0400 Subject: [PATCH 14/31] fix(#2126): route roadmap.cts CLI resolvers through shared lookup sources (drives #2114) Phase 3 of epic #2121. cmdRoadmapGetPhase and getRoadmapPhaseWithFallback now iterate the shared roadmapPhaseLookupSources (exact -> numeric -> prefix-tolerant, owned by phase-id.cts since Phase 1) instead of a hand-rolled 2-source lookup, so all three roadmap resolvers share one resolution contract. Drives #2114: `roadmap get-phase ` now resolves a drifted `### Phase AB-29:` heading (matching getRoadmapPhaseInternal / init.phase-op), previously EMPTY from the CLI. The malformed_roadmap checklist-fallback and the milestone-then-full precedence are preserved (a milestone checklist never blocks a full-roadmap header match). Behavior reversal (approved in-session): a bare query now also resolves a *drifted-only* prefixed heading when no bare sibling exists, reversing the #3599 counter-test's expectation. #3599's real anti-steal intent (a bare sibling wins over a distinct prefixed one) is preserved by the exact->numeric->prefix-tolerant ordering and re-asserted in the updated test; a new #2114 block covers the drifted-only case. Fail-first demonstrated. Closes #2126 Refs #2121 Co-Authored-By: Claude Opus 4.8 --- src/roadmap.cts | 80 ++++++++++++++++-------------------------- tests/roadmap.test.cjs | 72 +++++++++++++++++++++++++++++++------ 2 files changed, 92 insertions(+), 60 deletions(-) diff --git a/src/roadmap.cts b/src/roadmap.cts index 740794042..27f0e36c3 100644 --- a/src/roadmap.cts +++ b/src/roadmap.cts @@ -14,7 +14,7 @@ import ioMod = require('./io.cjs'); const { output, error } = ioMod; // eslint-disable-next-line @typescript-eslint/no-require-imports import phaseIdMod = require('./phase-id.cjs'); -const { escapeRegex, normalizePhaseName, phaseMarkdownRegexSource, phaseMarkdownRegexSourceExact, phaseTokenMatches, stripProjectCodePrefix, OPTIONAL_PHASE_TAG_SOURCE } = phaseIdMod; +const { escapeRegex, normalizePhaseName, phaseMarkdownRegexSource, phaseTokenMatches, stripProjectCodePrefix, OPTIONAL_PHASE_TAG_SOURCE, roadmapPhaseLookupSources } = phaseIdMod; // eslint-disable-next-line @typescript-eslint/no-require-imports import phaseLocatorMod = require('./phase-locator.cjs'); const { findPhaseInternal } = phaseLocatorMod; @@ -215,22 +215,17 @@ function getRoadmapPhaseWithFallback(cwd: string, phaseNum: string): string | nu const milestoneContent = extractCurrentMilestone(rawContent, cwd); const fullContent = stripShippedMilestones(rawContent); - const exactSource = phaseMarkdownRegexSourceExact(phaseNum); - if (exactSource) { - const exactMilestone = searchPhaseInContent(milestoneContent, exactSource, phaseNum); - if (exactMilestone && !exactMilestone.error) return exactMilestone.section ?? null; - const exactFull = searchPhaseInContent(fullContent, exactSource, phaseNum); - if (exactFull && !exactFull.error) return exactFull.section ?? null; + // #2121/#2114: iterate the shared lookup-source list (exact → numeric → + // prefix-tolerant) so this resolver matches getRoadmapPhaseInternal and a + // bare-number query resolves a drifted project-code-prefixed heading. + for (const source of roadmapPhaseLookupSources(phaseNum)) { + const milestoneResult = searchPhaseInContent(milestoneContent, source, phaseNum); + if (milestoneResult && !milestoneResult.error) return milestoneResult.section ?? null; + const fullResult = searchPhaseInContent(fullContent, source, phaseNum); + if (fullResult && !fullResult.error) return fullResult.section ?? null; } - const escapedPhase = phaseMarkdownRegexSource(phaseNum); - const milestoneResult = searchPhaseInContent(milestoneContent, escapedPhase, phaseNum); - const result = (milestoneResult && !milestoneResult.error) - ? milestoneResult - : searchPhaseInContent(fullContent, escapedPhase, phaseNum) || milestoneResult; - - if (!result || result.error) return null; - return result.section ?? null; + return null; } // ─── cmdRoadmapGetPhase ─────────────────────────────────────────────────────── @@ -251,52 +246,37 @@ function cmdRoadmapGetPhase(cwd: string, phaseNum: string, raw: boolean): void { const rawContent = fs.readFileSync(roadmapPath, 'utf-8'); const milestoneContent = extractCurrentMilestone(rawContent, cwd); - // #3599 two-pass: when the caller passes a project-code-prefixed ID like - // `PROJ-42`, try the exact-prefixed heading first (`### Phase PROJ-42:`). - // If no match, fall back to the #3537 padding-tolerant numeric form so - // a `CK-01` query still resolves to `### Phase 1:`. Doing this at the - // call site (instead of inside phaseMarkdownRegexSource) avoids the - // alternation-order ambiguity where a bare `### Phase 42:` heading in - // the same document would intercept the match for a `PROJ-42` query. const fullContent = stripShippedMilestones(rawContent); - const exactSource = phaseMarkdownRegexSourceExact(phaseNum); - if (exactSource) { - const exactMilestone = searchPhaseInContent(milestoneContent, exactSource, phaseNum); - if (exactMilestone && !exactMilestone.error) { - output(exactMilestone, raw, exactMilestone.section); + // #2121/#2114: iterate the shared lookup-source list (exact → numeric → + // prefix-tolerant) so all three roadmap resolvers share one contract and a + // bare-number query resolves a drifted `### Phase AB-29:` heading. This + // preserves the #3599 exact-prefix-first and #3537 padding-tolerant behavior + // (both now encoded in roadmapPhaseLookupSources' ordering). A clean match + // (milestone or full, any source) wins immediately; a malformed_roadmap + // (checklist-only) candidate is surfaced only if no source finds a real + // heading — so a milestone checklist never blocks a full-roadmap header. + let malformed: PhaseSearchResult | null = null; + for (const source of roadmapPhaseLookupSources(phaseNum)) { + const milestoneResult = searchPhaseInContent(milestoneContent, source, phaseNum); + if (milestoneResult && !milestoneResult.error) { + output(milestoneResult, raw, milestoneResult.section); return; } - const exactFull = searchPhaseInContent(fullContent, exactSource, phaseNum); - if (exactFull && !exactFull.error) { - output(exactFull, raw, exactFull.section); + const fullResult = searchPhaseInContent(fullContent, source, phaseNum); + if (fullResult && !fullResult.error) { + output(fullResult, raw, fullResult.section); return; } + if (!malformed) malformed = (milestoneResult?.error ? milestoneResult : (fullResult?.error ? fullResult : null)); } - // #3537: padding-tolerant fragment so callers passing `02.7` still match - // un-padded ROADMAP prose (`### Phase 2.7:`). - const escapedPhase = phaseMarkdownRegexSource(phaseNum); - - // Search the current milestone slice first, then fall back to full roadmap. - // A malformed_roadmap result (checklist-only) from the milestone should not - // block finding a full header match in the wider roadmap content. - const milestoneResult = searchPhaseInContent(milestoneContent, escapedPhase, phaseNum); - const result = (milestoneResult && !milestoneResult.error) - ? milestoneResult - : searchPhaseInContent(fullContent, escapedPhase, phaseNum) || milestoneResult; - - if (!result) { - output({ found: false, phase_number: phaseNum }, raw, ''); + if (malformed) { + output(malformed, raw, ''); return; } - if (result.error) { - output(result, raw, ''); - return; - } - - output(result, raw, result.section); + output({ found: false, phase_number: phaseNum }, raw, ''); } catch (e) { error('Failed to read ROADMAP.md: ' + (e as Error).message); } diff --git a/tests/roadmap.test.cjs b/tests/roadmap.test.cjs index da0ebfe65..e3c1d850d 100644 --- a/tests/roadmap.test.cjs +++ b/tests/roadmap.test.cjs @@ -1918,11 +1918,13 @@ describe('bug #3599: roadmap get-phase preserves project-code prefix in lookup', assert.strictEqual(payload.goal, 'Verify project-code-prefixed lookup'); }); - test('does NOT cross-match: querying 42 must not match ### Phase PROJ-42:', () => { - // Counter-test: if the regex erroneously matches both forms in both - // directions, this catches it. `42` must only match `Phase 42:` — not - // `Phase PROJ-42:` — otherwise integer phase lookups silently steal - // matches from prefixed siblings. + test('bare numeric prefers a bare sibling over a prefixed one (#3599 anti-steal, updated for #2114)', () => { + // #3599's real guard is anti-STEALING: when BOTH a bare `Phase 42:` and a + // distinct prefixed `Phase PROJ-42:` exist, a bare `42` query must resolve + // the BARE one — the numeric source is tried before the prefix-tolerant + // fallback, so a bare query never steals a distinct prefixed sibling. + // (Since #2114/#2121, a bare query DOES resolve a *drifted-only* prefixed + // heading when no bare sibling exists — see the bug #2114 block below.) writeState(tmpDir, 'v1.0.0'); writeRoadmap( tmpDir, @@ -1931,8 +1933,11 @@ describe('bug #3599: roadmap get-phase preserves project-code prefix in lookup', '', '## Current Milestone: v1.0.0 - Test', '', - '### Phase PROJ-42: Should not be returned for `42`', - '**Goal:** Counter-test', + '### Phase 42: Bare', + '**Goal:** Canonical bare heading', + '', + '### Phase PROJ-42: Prefixed', + '**Goal:** Distinct prefixed sibling', '', ].join('\n'), ); @@ -1940,10 +1945,11 @@ describe('bug #3599: roadmap get-phase preserves project-code prefix in lookup', const result = runGsdTools('roadmap get-phase 42 --json', tmpDir); assert.ok(result.success); const payload = JSON.parse(result.output); + assert.strictEqual(payload.found, true, `expected found=true, got: ${result.output}`); assert.strictEqual( - payload.found, - false, - `bare numeric '42' must not match 'Phase PROJ-42:'; got ${result.output}`, + payload.phase_name, + 'Bare', + `bare '42' must resolve the bare 'Phase 42:', not steal 'Phase PROJ-42:'; got ${result.output}`, ); }); @@ -2071,6 +2077,52 @@ function makePlanProject(files = {}) { return dir; } +describe('bug #2114: roadmap get-phase resolves drifted prefixed headings by bare number', () => { + let tmpDir; + + beforeEach(() => { tmpDir = createTempProject('bug-2114-'); }); + afterEach(() => { cleanup(tmpDir); }); + + test('bare-number query resolves a drifted project-code-prefixed heading', () => { + // Before the fix, bare `29` did NOT match `### Phase AB-29:` from the CLI + // (2-source lookup), even though getRoadmapPhaseInternal (init.phase-op) did + // — the #2114 divergence. Now all three resolvers share the 3-source list. + fs.writeFileSync( + path.join(tmpDir, '.planning', 'STATE.md'), + '---\nmilestone: v1.0.0\n---\n# State\n\n**Status:** In progress\n', + ); + fs.writeFileSync( + path.join(tmpDir, '.planning', 'ROADMAP.md'), + [ + '# Roadmap', + '', + '## Current Milestone: v1.0.0 - Test', + '', + '### Phase 30: Plain', + '**Goal:** Canonical bare heading', + '', + '### Phase AB-29: Prefixed', + '**Goal:** Drifted prefixed heading', + '', + ].join('\n'), + ); + + const resultAB29 = runGsdTools('roadmap get-phase 29 --json', tmpDir); + assert.ok(resultAB29.success, `command failed: ${resultAB29.error || resultAB29.output}`); + const payloadAB29 = JSON.parse(resultAB29.output); + assert.strictEqual(payloadAB29.found, true, `expected found=true for drifted AB-29, got: ${resultAB29.output}`); + assert.strictEqual(payloadAB29.phase_name, 'Prefixed'); + assert.strictEqual(payloadAB29.goal, 'Drifted prefixed heading'); + + // The canonical bare heading still resolves. + const result30 = runGsdTools('roadmap get-phase 30 --json', tmpDir); + assert.ok(result30.success); + const payload30 = JSON.parse(result30.output); + assert.strictEqual(payload30.found, true); + assert.strictEqual(payload30.phase_name, 'Plain'); + }); +}); + describe('roadmap annotate-dependencies', () => { let tmpDir; From 74e77974af07d447c05923cecf23ec8c1c80b28c Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Thu, 9 Jul 2026 20:06:45 -0400 Subject: [PATCH 15/31] docs(changeset): Fixed fragment for #2114 (pr:0 to backfill) Co-Authored-By: Claude Opus 4.8 --- .changeset/quick-seals-parade.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/quick-seals-parade.md diff --git a/.changeset/quick-seals-parade.md b/.changeset/quick-seals-parade.md new file mode 100644 index 000000000..50fdfe1b3 --- /dev/null +++ b/.changeset/quick-seals-parade.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 0 +--- +**`roadmap get-phase` resolves project-code-prefixed headings by bare number** — a bare-number query (e.g. `29`) now resolves a drifted `### Phase AB-29:` heading, matching the internal resolver used by `init.phase-op`; previously the CLI returned empty. A bare sibling (`### Phase 29:`) still takes precedence. (#2114) From 119702ff2966a4252b0e918c752361cdf5e612b1 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Thu, 9 Jul 2026 21:10:57 -0400 Subject: [PATCH 16/31] test(#2126): fix os.tmpdir() cross-file race + dedup folds surfaced by gsd-test (no-defer) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3's gsd-test surfaced 8 pre-existing test-isolation races (in #2090's test files now on next). Per CLAUDE.md's no-defer rule these are fixed inline in the current change. Root-caused via /qa-test-architect — all bad-test (the rewrite-engine production code is race-free): - install-runtime-artifacts.test.cjs: the "rmSync when readFileSync throws" test diffed the SHARED os.tmpdir() for gsd-cmd-rewrites-* dirs and force-deleted any new one with no ownership check. Under --test-concurrency it deleted a sibling test file's LIVE tempDir mid-copy (the #1575 "ENOENT .../graphify.md") and misattributed it as its own leak. Fixed: capture the exact tempDir THIS call creates (fs.mkdtempSync monkeypatch, restored in finally) and assert only on that — never sweep/delete the shared os.tmpdir(). Also deduped the enh-1511 block the #1969 consolidation folded in 3x byte-identically (#1970/#1974/#1975) down to 1 copy; 308 unique test titles unchanged (verified). - issue-1575-agent-descriptor-parity.test.cjs: a missing }); nested the M2 'cursor attribution' test inside the per-runtime loop so it ran 7x (widening the tempDir window). Fixed the brace -> runs once as a describe sibling. - config-get-default.test.cjs: local run()/runRaw() spawned node via execFileSync with a fixed 5s timeout and no retry -> ETIMEDOUT under Docker load. Redesigned to call cmdConfigGet in-process (fs.writeSync fd-capture + process.exit sentinel, both restored in finally) — no subprocess, no wall clock. - runtime-artifact-conversion.cts: fixed the stale "No production caller today" JSDoc on rewriteStagedCommandBodies (real callers: applySurface, createRuntimeArtifactInstallPlan) — the false doc invited the bad test. Refs #2126, #2090 Co-Authored-By: Claude Opus 4.8 --- src/runtime-artifact-conversion.cts | 10 +- tests/config-get-default.test.cjs | 111 ++- tests/install-runtime-artifacts.test.cjs | 865 +----------------- ...ssue-1575-agent-descriptor-parity.test.cjs | 4 +- 4 files changed, 126 insertions(+), 864 deletions(-) diff --git a/src/runtime-artifact-conversion.cts b/src/runtime-artifact-conversion.cts index 7977909e7..261c06879 100644 --- a/src/runtime-artifact-conversion.cts +++ b/src/runtime-artifact-conversion.cts @@ -2522,10 +2522,12 @@ function rewriteStagedSkillBodies(stagedDir, opts) { * attribution from opts, then delegates to applyRuntimeContentRewritesForCommandsInPlace * (single copy+rewrite owner). * - * @internal — symmetric companion to rewriteStagedSkillBodies; retained as the deep-seam - * API for command bodies. No production caller today (install rewrites commands via - * copyWithPathReplacement → applyRuntimeContentRewritesForCommandsInPlace). Kept for - * API symmetry + test coverage. + * @internal — symmetric companion to rewriteStagedSkillBodies; the deep-seam API for + * command bodies. Production callers: applySurface (surface.cts) and the install path + * in createRuntimeArtifactInstallPlan (runtime-artifact-install-plan.cts) — both keep + * the returned temp dir alive until they have copied its contents out, then clean it up + * in their own finally. (A test that treats this as a throwaway shared-tmp path will + * race those live temp dirs under --test-concurrency; see #1575/#2090.) * * @returns {string} path to the temp dir (caller is responsible for cleanup) */ diff --git a/tests/config-get-default.test.cjs b/tests/config-get-default.test.cjs index 278c1c654..884537efa 100644 --- a/tests/config-get-default.test.cjs +++ b/tests/config-get-default.test.cjs @@ -13,11 +13,83 @@ const { describe, test, beforeEach, afterEach } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('fs'); const path = require('path'); -const { execFileSync } = require('child_process'); const os = require('os'); const { cleanup } = require('./helpers.cjs'); -const GSD_TOOLS = path.join(__dirname, '..', 'gsd-core', 'bin', 'gsd-tools.cjs'); +// In-process invocation, not execFileSync: cmdConfigGet is a pure CJS +// function reachable without spawning `node` as a child. The prior +// execFileSync(..., { timeout: 5000 }) raced a real subprocess's startup +// (full node boot + gsd-tools.cjs's large eager require graph — capability +// registry, phase/roadmap/agent/check/task routers, verify.cjs, +// cli-skew-check, findProjectRoot, etc.) against a fixed 5s wall clock, with +// no retry. Under Docker host contention that wall clock loses +// nondeterministically (ETIMEDOUT) — a test-harness race, not a product +// defect. bin/lib/config.cjs requires none of that dispatcher machinery, so +// calling cmdConfigGet directly removes the subprocess-spawn cost and the +// wall-clock race entirely: no timeout of any size can flake this. +const config = require(path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'config.cjs')); + +/** + * cmdConfigGet's error() path (gsd-core/bin/lib/io.cjs) calls process.exit(1) + * directly (it predates the ExitError/runMain seam used by the CLI + * entrypoint's non-error paths). Intercepting process.exit with a throwable + * sentinel lets the error path be exercised in-process without killing the + * test worker. + */ +class _ExitSignal extends Error { + constructor(code) { + super(`process.exit(${code})`); + this.code = code; + } +} + +/** + * bin/lib/io.cjs's output()/error() write directly to the raw fd (1 or 2) + * via fs.writeSync — they bypass console.log entirely, so + * tests/helpers.cjs's captureConsole() cannot observe them (see + * tests/io.test.cjs: "output() writes directly to fd 1"). Monkeypatch + * fs.writeSync itself — save the original, override, restore in a finally, + * the project's standard IO capture/fault-injection seam — to capture what + * would have hit the fd. + */ +function captureFdWrite(fd, fn) { + const orig = fs.writeSync; + let captured = Buffer.alloc(0); + fs.writeSync = (writeFd, ...rest) => { + if (writeFd !== fd) return orig.call(fs, writeFd, ...rest); + const [data, offset = 0, length] = rest; + const chunk = Buffer.isBuffer(data) + ? data.subarray(offset, offset + (length ?? data.length - offset)) + : Buffer.from(String(data), 'utf8'); + captured = Buffer.concat([captured, chunk]); + return chunk.length; + }; + try { + fn(); + } finally { + fs.writeSync = orig; + } + return captured.toString('utf-8'); +} + +/** + * Parse a CLI-style config-get argv (mirrors gsd-core/bin/gsd-tools.cjs's + * 'config-get' case: key is args[1], optional --default , optional + * --raw) into cmdConfigGet's positional params. Keeps the test bodies below + * expressed in the same CLI-args vocabulary they always were. + */ +function parseConfigGetArgs(args) { + const rest = args.slice(1); // drop the leading 'config-get' + let raw = false; + let defaultValue; + const positional = []; + for (let i = 0; i < rest.length; i++) { + if (rest[i] === '--raw') { raw = true; continue; } + if (rest[i] === '--default') { defaultValue = rest[i + 1] ?? ''; i++; continue; } + positional.push(rest[i]); + } + return { keyPath: positional[0], raw, defaultValue }; +} describe('config-get --default flag (#1893)', () => { let tmpDir; @@ -34,10 +106,11 @@ describe('config-get --default flag (#1893)', () => { }); function run(...args) { - return execFileSync('node', [GSD_TOOLS, ...args, '--cwd', tmpDir], { - encoding: 'utf-8', - timeout: 5000, - }).trim(); + const { keyPath, raw, defaultValue } = parseConfigGetArgs(args); + const out = captureFdWrite(1, () => { + config.cmdConfigGet(tmpDir, keyPath, raw, defaultValue); + }); + return out.trim(); } function runRaw(...args) { @@ -45,17 +118,27 @@ describe('config-get --default flag (#1893)', () => { } function runExpectError(...args) { + const { keyPath, raw, defaultValue } = parseConfigGetArgs(args); + const origExit = process.exit; + let exitCode; + process.exit = (code) => { + exitCode = code; + throw new _ExitSignal(code); + }; + let stderr; try { - execFileSync('node', [GSD_TOOLS, ...args, '--cwd', tmpDir], { - encoding: 'utf-8', - timeout: 5000, - stdio: ['pipe', 'pipe', 'pipe'], + stderr = captureFdWrite(2, () => { + try { + config.cmdConfigGet(tmpDir, keyPath, raw, defaultValue); + } catch (e) { + if (!(e instanceof _ExitSignal)) throw e; + } }); - assert.fail('Expected command to exit non-zero'); - } catch (err) { - assert.ok(err.status !== 0, 'Expected non-zero exit code'); - return err; + } finally { + process.exit = origExit; } + assert.ok(exitCode !== 0 && exitCode !== undefined, 'Expected non-zero exit code'); + return { status: exitCode, stderr }; } test('absent key without --default errors', () => { diff --git a/tests/install-runtime-artifacts.test.cjs b/tests/install-runtime-artifacts.test.cjs index 96a96afc4..755b2a8ee 100644 --- a/tests/install-runtime-artifacts.test.cjs +++ b/tests/install-runtime-artifacts.test.cjs @@ -4002,13 +4002,19 @@ describe('applyRuntimeContentRewritesForCommandsInPlace — error-path tempDir c const stagedDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-test-error-path-')); fs.writeFileSync(path.join(stagedDir, 'x.md'), '# test\n'); - const before = new Set( - fs.readdirSync(os.tmpdir()).filter(n => n.startsWith('gsd-cmd-rewrites-')) - ); - + // Capture the EXACT tempDir THIS invocation creates (via the function's own + // fs.mkdtempSync call) instead of diffing the shared os.tmpdir() listing. + // The old diff-and-sweep approach raced any concurrently-running test file + // that mkdtemps its own gsd-cmd-rewrites-* dir under --test-concurrency: it + // misattributed a sibling's live dir as this test's leak AND force-deleted + // it mid-use (the #1575 ENOENT on graphify.md). Owning a single, + // self-generated fixture makes this Independent + Repeatable under any + // parallelism. + const origMkdtempSync = fs.mkdtempSync; const origReadFileSync = fs.readFileSync; - let leaked = []; + let capturedTempDir = null; try { + fs.mkdtempSync = (...args) => (capturedTempDir = origMkdtempSync.apply(fs, args)); fs.readFileSync = () => { throw new Error('injected read failure'); }; assert.throws( @@ -4016,21 +4022,22 @@ describe('applyRuntimeContentRewritesForCommandsInPlace — error-path tempDir c /injected read failure/, ); - // Restore before any further fs use so the snapshot read is trustworthy. + // Restore before any further fs use so the existsSync check is trustworthy. + fs.mkdtempSync = origMkdtempSync; fs.readFileSync = origReadFileSync; - const after = fs.readdirSync(os.tmpdir()).filter(n => n.startsWith('gsd-cmd-rewrites-')); - leaked = after.filter(n => !before.has(n)); - assert.deepStrictEqual(leaked, [], `tempDir not cleaned up on error: ${leaked.join(',')}`); + assert.ok(capturedTempDir, 'function under test must create a tempDir before failing'); + assert.equal( + fs.existsSync(capturedTempDir), + false, + `tempDir not cleaned up on error: ${capturedTempDir}`, + ); } finally { // Idempotent restore — guard against early-throw paths above. + fs.mkdtempSync = origMkdtempSync; fs.readFileSync = origReadFileSync; - // Clean up the staged dir created for this test. + // Clean up only OUR OWN fixture — never sweep the shared os.tmpdir(). cleanup(stagedDir); - // Clean up any genuinely leaked gsd-cmd-rewrites-* dirs so the runner stays clean. - for (const n of leaked) { - cleanup(path.join(os.tmpdir(), n)); - } } }); }); @@ -7142,421 +7149,6 @@ describe('processAttribution (relocated to runtime-artifact-conversion)', () => // ──────────────────────────────────────────────────────────────────────── -// Folded from tests/enh-1511-rewrite-engine-relocation.test.cjs — consolidation epic #1969 (B1 #1970) -// ──────────────────────────────────────────────────────────────────────── -{ - const { describe: __foldDescribe } = require('node:test'); - __foldDescribe("folded:enh-1511-rewrite-engine-relocation (consolidation epic #1969 B1 #1970)", () => { -'use strict'; -/** - * Tests for ADR-1508 Phase 2: rewrite engine relocation to runtime-artifact-conversion. - * Issue #1511 — verifies the deep public seam signatures and behavior. - * - * Tests are behavioral (no source-grep). All filesystem operations use tmp dirs. - */ - -const { describe, test, before } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('node:fs'); -const os = require('node:os'); -const path = require('node:path'); -const { cleanup } = require('./helpers.cjs'); - -let conversion; -before(() => { - process.env['GSD_TEST_MODE'] = '1'; - conversion = require('../gsd-core/bin/lib/runtime-artifact-conversion.cjs'); -}); - -// --------------------------------------------------------------------------- -// _computePathPrefix unit tests -// --------------------------------------------------------------------------- - -describe('_computePathPrefix', () => { - test('global under home → $HOME/... form', () => { - const prefix = conversion._computePathPrefix({ - isGlobal: true, - isOpencode: false, - isWindowsHost: false, - resolvedTarget: '/home/u/.cursor', - homeDir: '/home/u', - }); - assert.equal(prefix, '$HOME/.cursor/'); - }); - - test('non-global → resolvedTarget/ form', () => { - const prefix = conversion._computePathPrefix({ - isGlobal: false, - isOpencode: false, - isWindowsHost: false, - resolvedTarget: '/project/.cursor', - homeDir: '/home/u', - }); - assert.equal(prefix, '/project/.cursor/'); - }); - - test('global opencode skips $HOME shorthand', () => { - // OpenCode uses ~/.config/opencode which breaks $HOME shorthand in content - const prefix = conversion._computePathPrefix({ - isGlobal: true, - isOpencode: true, - isWindowsHost: false, - resolvedTarget: '/home/u/.config/opencode', - homeDir: '/home/u', - }); - assert.equal(prefix, '/home/u/.config/opencode/'); - }); - - test('global target outside home → resolvedTarget/ form', () => { - const prefix = conversion._computePathPrefix({ - isGlobal: true, - isOpencode: false, - isWindowsHost: false, - resolvedTarget: '/opt/custom-cursor', - homeDir: '/home/u', - }); - assert.equal(prefix, '/opt/custom-cursor/'); - }); - - test('isWindowsHost tripwire — Windows paths collapse to $HOME/ same as POSIX (no-op today)', () => { - // Documents CURRENT behavior: isWindowsHost is accepted but not branched on. - // Both win32=true and win32=false return '$HOME/.cursor/' for a home-relative target. - // If a future Windows-specific branch is added, this tripwire fails and forces - // an explicit decision about what to return on Windows. - const withWindows = conversion._computePathPrefix({ - isGlobal: true, - isOpencode: false, - isWindowsHost: true, - resolvedTarget: 'C:/Users/matte/.cursor', - homeDir: 'C:/Users/matte', - }); - const withoutWindows = conversion._computePathPrefix({ - isGlobal: true, - isOpencode: false, - isWindowsHost: false, - resolvedTarget: 'C:/Users/matte/.cursor', - homeDir: 'C:/Users/matte', - }); - assert.equal(withWindows, '$HOME/.cursor/'); - assert.strictEqual(withWindows, withoutWindows); - }); - - test('backslash-style resolvedTarget is normalized to forward slashes (#1615 regression)', () => { - // path.join on Windows produces backslashes; the returned prefix is - // substituted into markdown @-references which must use POSIX paths. - // Without normalization the backslashes leak into workflow file content - // and break substring checks on Windows CI. - const prefix = conversion._computePathPrefix({ - isGlobal: false, - isOpencode: false, - isWindowsHost: true, - resolvedTarget: 'C:\\Users\\runner\\AppData\\Local\\Temp\\gsd-1615-windsurf', - homeDir: 'C:\\Users\\runner', - }); - assert.strictEqual(prefix, 'C:/Users/runner/AppData/Local/Temp/gsd-1615-windsurf/'); - assert.ok(!prefix.includes('\\'), `prefix must not contain backslashes: ${prefix}`); - }); -}); - -// --------------------------------------------------------------------------- -// _applyRuntimeRewrites with injected attribution -// --------------------------------------------------------------------------- - -describe('_applyRuntimeRewrites — attribution injection', () => { - const PREFIX = '$HOME/.cursor/'; - - test('attribution=null removes Co-Authored-By line', () => { - const content = '# Hello\n\nSome text\n\nCo-Authored-By: Claude\n'; - const result = conversion._applyRuntimeRewrites(content, 'cursor', PREFIX, true, null); - assert.ok(!result.includes('Co-Authored-By:'), 'Co-Authored-By should be removed'); - }); - - test('attribution=undefined leaves Co-Authored-By unchanged', () => { - const content = '# Hello\n\nCo-Authored-By: Claude\n'; - const result = conversion._applyRuntimeRewrites(content, 'cursor', PREFIX, true, undefined); - assert.ok(result.includes('Co-Authored-By: Claude'), 'Co-Authored-By should be preserved when attribution=undefined'); - }); - - test('attribution=string replaces Co-Authored-By value', () => { - const content = '# Hello\n\nCo-Authored-By: OldName\n'; - const result = conversion._applyRuntimeRewrites(content, 'cursor', PREFIX, true, 'NewName '); - assert.ok(result.includes('Co-Authored-By: NewName '), 'Co-Authored-By should be replaced'); - }); - - test('cursor runtime replaces ~/.claude/ paths', () => { - const content = 'See ~/.claude/skills/ for more info\n'; - const result = conversion._applyRuntimeRewrites(content, 'cursor', '/home/u/.cursor/', false, undefined); - assert.ok(result.includes('/home/u/.cursor/skills/'), 'cursor should replace ~/.claude/ with pathPrefix'); - }); -}); - -// --------------------------------------------------------------------------- -// rewriteStagedSkillBodies — behavioral filesystem test -// --------------------------------------------------------------------------- - -describe('rewriteStagedSkillBodies', () => { - test('rewrites .md files in-place for cursor runtime', () => { - const stagedDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-test-staged-')); - const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-test-config-')); - try { - // Create a skill dir with a SKILL.md referencing ~/.claude/skills/foo - // NOTE: the rewrite engine handles path replacement and attribution only. - // Bash→Shell conversion is done by the stage-1 skill converter, not the engine. - const skillDir = path.join(stagedDir, 'gsd-test-skill'); - fs.mkdirSync(skillDir, { recursive: true }); - const content = '# Test\n\nSee ~/.claude/skills/foo\n\nAlso ~/.cursor/skills/bar\n'; - fs.writeFileSync(path.join(skillDir, 'SKILL.md'), content); - - // Call with injected homedir + platform for determinism - conversion.rewriteStagedSkillBodies(stagedDir, { - runtime: 'cursor', - configDir, - scope: 'global', - homedir: () => '/home/u', - platform: 'linux', - }); - - const result = fs.readFileSync(path.join(skillDir, 'SKILL.md'), 'utf8'); - // cursor rewrites ~/.claude/ → pathPrefix - // configDir is a tmpdir, not under /home/u, so prefix = resolvedTarget + '/' - // Mirror the engine's backslash→slash normalization so the assertion holds on Windows. - const resolvedTarget = path.resolve(configDir).replace(/\\/g, '/'); - assert.ok(result.includes(`${resolvedTarget}/skills/foo`), `Should replace ~/.claude/skills/ with ${resolvedTarget}/skills/`); - // cursor also rewrites ~/.cursor/ → pathPrefix - assert.ok(result.includes(`${resolvedTarget}/skills/bar`), `Should replace ~/.cursor/skills/ with ${resolvedTarget}/skills/`); - } finally { - cleanup(stagedDir); - cleanup(configDir); - } - }); - - test('with injected homedir: global under home uses $HOME prefix', () => { - // Real absolute path so Windows path.resolve does not re-root a POSIX literal onto a drive. - // The dir need not exist — the engine only string-processes it. - const HOME = path.resolve(os.tmpdir(), 'gsd-1511-fake-home'); - const configDir = path.join(HOME, '.cursor'); - const stagedDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-test-staged-')); - try { - const skillDir = path.join(stagedDir, 'gsd-help'); - fs.mkdirSync(skillDir, { recursive: true }); - fs.writeFileSync(path.join(skillDir, 'SKILL.md'), 'Use ~/.claude/skills/ here\n'); - - conversion.rewriteStagedSkillBodies(stagedDir, { - runtime: 'cursor', - configDir, - scope: 'global', - homedir: () => HOME, - platform: process.platform, - }); - - const result = fs.readFileSync(path.join(skillDir, 'SKILL.md'), 'utf8'); - assert.ok(result.includes('$HOME/.cursor/skills/'), 'Should use $HOME shorthand when configDir is under homedir'); - } finally { - cleanup(stagedDir); - } - }); - - test('non-existent stagedDir is a no-op', () => { - assert.doesNotThrow(() => { - conversion.rewriteStagedSkillBodies('/nonexistent/dir', { - runtime: 'cursor', - configDir: '/tmp/fake', - scope: 'global', - }); - }); - }); -}); - -// --------------------------------------------------------------------------- -// rewriteStagedCommandBodies — returns temp dir, does not mutate source -// --------------------------------------------------------------------------- - -describe('rewriteStagedCommandBodies', () => { - test('returns a temp dir (not the source dir) with rewritten content', () => { - const stagedDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-test-cmd-')); - const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-test-config-')); - let tempDir; - try { - // NOTE: rewrite engine handles path replacement + attribution, NOT tool renames. - fs.writeFileSync(path.join(stagedDir, 'help.md'), '# Help\n\nSee ~/.claude/skills/\n\nSee ~/.cursor/skills/\n'); - - tempDir = conversion.rewriteStagedCommandBodies(stagedDir, { - runtime: 'cursor', - configDir, - scope: 'global', - homedir: () => '/home/u', - platform: 'linux', - }); - - assert.notEqual(tempDir, stagedDir, 'must return a different dir, never the source'); - assert.ok(fs.existsSync(tempDir), 'returned tempDir should exist'); - - const result = fs.readFileSync(path.join(tempDir, 'help.md'), 'utf8'); - // Source dir should be unchanged - const source = fs.readFileSync(path.join(stagedDir, 'help.md'), 'utf8'); - assert.ok(source.includes('~/.claude/skills/'), 'source file must not be mutated'); - // configDir is /tmp/... (not under /home/u), so prefix = resolvedTarget + '/' - const resolvedTarget = path.resolve(configDir).replace(/\\/g, '/'); - assert.ok(result.includes(`${resolvedTarget}/skills/`), 'output should have cursor path rewrite applied'); - // ~/.cursor/ also rewrites to prefix - assert.ok(!result.includes('~/.cursor/'), 'output should have ~/.cursor/ replaced too'); - } finally { - cleanup(stagedDir); - cleanup(configDir); - if (tempDir && tempDir !== stagedDir) { - cleanup(tempDir); - } - } - }); - - test('non-existent stagedDir returns stagedDir unchanged (safe)', () => { - const result = conversion.rewriteStagedCommandBodies('/nonexistent/dir', { - runtime: 'cursor', - configDir: '/tmp/fake', - scope: 'global', - }); - assert.equal(result, '/nonexistent/dir', 'should return input path unchanged for missing dir'); - }); -}); - -// --------------------------------------------------------------------------- -// Error-path: applyRuntimeContentRewritesForCommandsInPlace must rm the tempDir -// on any exception and NOT leave an orphaned gsd-cmd-rewrites-* directory. -// --------------------------------------------------------------------------- - -describe('applyRuntimeContentRewritesForCommandsInPlace — error-path tempDir cleanup', () => { - test('rmSync is called on the tempDir when readFileSync throws (deterministic monkeypatch)', () => { - // Asserting the injected error propagates proves the throw happens AFTER the tempDir is - // created (the function creates tempDir, then reads .md), so the catch's rmSync cleanup - // is genuinely exercised — deterministic on every platform/uid. - const stagedDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-test-error-path-')); - fs.writeFileSync(path.join(stagedDir, 'x.md'), '# test\n'); - - const before = new Set( - fs.readdirSync(os.tmpdir()).filter(n => n.startsWith('gsd-cmd-rewrites-')) - ); - - const origReadFileSync = fs.readFileSync; - let leaked = []; - try { - fs.readFileSync = () => { throw new Error('injected read failure'); }; - - assert.throws( - () => conversion.applyRuntimeContentRewritesForCommandsInPlace(stagedDir, 'cursor', '/tmp/x/', false), - /injected read failure/, - ); - - // Restore before any further fs use so the snapshot read is trustworthy. - fs.readFileSync = origReadFileSync; - - const after = fs.readdirSync(os.tmpdir()).filter(n => n.startsWith('gsd-cmd-rewrites-')); - leaked = after.filter(n => !before.has(n)); - assert.deepStrictEqual(leaked, [], `tempDir not cleaned up on error: ${leaked.join(',')}`); - } finally { - // Idempotent restore — guard against early-throw paths above. - fs.readFileSync = origReadFileSync; - // Clean up the staged dir created for this test. - cleanup(stagedDir); - // Clean up any genuinely leaked gsd-cmd-rewrites-* dirs so the runner stays clean. - for (const n of leaked) { - cleanup(path.join(os.tmpdir(), n)); - } - } - }); -}); - -// --------------------------------------------------------------------------- -// Guard: runtime-artifact-layout no longer exports getInstallExports -// --------------------------------------------------------------------------- - -describe('layout module no longer exports getInstallExports', () => { - test('getInstallExports is not on the layout module export', () => { - process.env['GSD_TEST_MODE'] = '1'; - const layout = require('../gsd-core/bin/lib/runtime-artifact-layout.cjs'); - assert.equal( - typeof layout.getInstallExports, - 'undefined', - 'getInstallExports should have been removed from runtime-artifact-layout exports (ADR-1508 Phase 2)', - ); - }); -}); - -// --------------------------------------------------------------------------- -// DEFECT.GENERATIVE-FIX: single-owner reference-identity guard (#1511) -// Proves install.js binds to the conversion module's implementation, not a -// duplicate local copy. If these fail, a duplicate body was re-introduced. -// --------------------------------------------------------------------------- - -describe('single-owner reference-identity guard (ADR-1508 / #1511 Phase 2)', () => { - let install; - let conversionCjs; - before(() => { - process.env['GSD_TEST_MODE'] = '1'; - install = require('../bin/install.js'); - conversionCjs = require('../gsd-core/bin/lib/runtime-artifact-conversion.cjs'); - }); - - test('install.computePathPrefix === conversion._computePathPrefix (single implementation)', () => { - assert.strictEqual( - install.computePathPrefix, - conversionCjs._computePathPrefix, - 'install.js must bind computePathPrefix from conversion (not a duplicate body)', - ); - }); - - test('install.applyRuntimeContentRewritesInPlace === conversion.applyRuntimeContentRewritesInPlace (single walk loop)', () => { - assert.strictEqual( - install.applyRuntimeContentRewritesInPlace, - conversionCjs.applyRuntimeContentRewritesInPlace, - 'install.js must bind applyRuntimeContentRewritesInPlace from conversion (not a duplicate walk loop)', - ); - }); - - test('install.applyRuntimeContentRewritesForCommandsInPlace === conversion.applyRuntimeContentRewritesForCommandsInPlace (single copy+rewrite loop)', () => { - assert.strictEqual( - install.applyRuntimeContentRewritesForCommandsInPlace, - conversionCjs.applyRuntimeContentRewritesForCommandsInPlace, - 'install.js must bind applyRuntimeContentRewritesForCommandsInPlace from conversion (not a duplicate copy+rewrite loop)', - ); - }); - - test('install._applyRuntimeRewrites === conversion._applyRuntimeRewrites (single switch engine)', () => { - assert.strictEqual( - install._applyRuntimeRewrites, - conversionCjs._applyRuntimeRewrites, - 'install.js must bind _applyRuntimeRewrites from conversion (not a local shim)', - ); - }); - - // #1675 (ADR-1508): the augment converter family is single-sourced in the - // conversion module. install.js must re-bind (not re-define) these so there - // is exactly one body — the generative-drift hazard the dedup removes. - test('install.convertClaudeToAugmentMarkdown === conversion.convertClaudeToAugmentMarkdown (single converter)', () => { - assert.strictEqual( - install.convertClaudeToAugmentMarkdown, - conversionCjs.convertClaudeToAugmentMarkdown, - 'install.js must bind convertClaudeToAugmentMarkdown from conversion (not a duplicate body)', - ); - }); - - test('install.convertClaudeCommandToAugmentSkill === conversion.convertClaudeCommandToAugmentSkill (single converter)', () => { - assert.strictEqual( - install.convertClaudeCommandToAugmentSkill, - conversionCjs.convertClaudeCommandToAugmentSkill, - 'install.js must bind convertClaudeCommandToAugmentSkill from conversion (not a duplicate body)', - ); - }); - - test('install.convertClaudeAgentToAugmentAgent === conversion.convertClaudeAgentToAugmentAgent (single converter)', () => { - assert.strictEqual( - install.convertClaudeAgentToAugmentAgent, - conversionCjs.convertClaudeAgentToAugmentAgent, - 'install.js must bind convertClaudeAgentToAugmentAgent from conversion (not a duplicate body)', - ); - }); -}); - }); -} // ──────────────────────────────────────────────────────────────────────── @@ -11461,421 +11053,6 @@ describe('processAttribution (relocated to runtime-artifact-conversion)', () => // ──────────────────────────────────────────────────────────────────────── -// Folded from tests/enh-1511-rewrite-engine-relocation.test.cjs — consolidation epic #1969 (B1 #1970) -// ──────────────────────────────────────────────────────────────────────── -{ - const { describe: __foldDescribe } = require('node:test'); - __foldDescribe("folded:enh-1511-rewrite-engine-relocation (consolidation epic #1969 B1 #1970)", () => { -'use strict'; -/** - * Tests for ADR-1508 Phase 2: rewrite engine relocation to runtime-artifact-conversion. - * Issue #1511 — verifies the deep public seam signatures and behavior. - * - * Tests are behavioral (no source-grep). All filesystem operations use tmp dirs. - */ - -const { describe, test, before } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('node:fs'); -const os = require('node:os'); -const path = require('node:path'); -const { cleanup } = require('./helpers.cjs'); - -let conversion; -before(() => { - process.env['GSD_TEST_MODE'] = '1'; - conversion = require('../gsd-core/bin/lib/runtime-artifact-conversion.cjs'); -}); - -// --------------------------------------------------------------------------- -// _computePathPrefix unit tests -// --------------------------------------------------------------------------- - -describe('_computePathPrefix', () => { - test('global under home → $HOME/... form', () => { - const prefix = conversion._computePathPrefix({ - isGlobal: true, - isOpencode: false, - isWindowsHost: false, - resolvedTarget: '/home/u/.cursor', - homeDir: '/home/u', - }); - assert.equal(prefix, '$HOME/.cursor/'); - }); - - test('non-global → resolvedTarget/ form', () => { - const prefix = conversion._computePathPrefix({ - isGlobal: false, - isOpencode: false, - isWindowsHost: false, - resolvedTarget: '/project/.cursor', - homeDir: '/home/u', - }); - assert.equal(prefix, '/project/.cursor/'); - }); - - test('global opencode skips $HOME shorthand', () => { - // OpenCode uses ~/.config/opencode which breaks $HOME shorthand in content - const prefix = conversion._computePathPrefix({ - isGlobal: true, - isOpencode: true, - isWindowsHost: false, - resolvedTarget: '/home/u/.config/opencode', - homeDir: '/home/u', - }); - assert.equal(prefix, '/home/u/.config/opencode/'); - }); - - test('global target outside home → resolvedTarget/ form', () => { - const prefix = conversion._computePathPrefix({ - isGlobal: true, - isOpencode: false, - isWindowsHost: false, - resolvedTarget: '/opt/custom-cursor', - homeDir: '/home/u', - }); - assert.equal(prefix, '/opt/custom-cursor/'); - }); - - test('isWindowsHost tripwire — Windows paths collapse to $HOME/ same as POSIX (no-op today)', () => { - // Documents CURRENT behavior: isWindowsHost is accepted but not branched on. - // Both win32=true and win32=false return '$HOME/.cursor/' for a home-relative target. - // If a future Windows-specific branch is added, this tripwire fails and forces - // an explicit decision about what to return on Windows. - const withWindows = conversion._computePathPrefix({ - isGlobal: true, - isOpencode: false, - isWindowsHost: true, - resolvedTarget: 'C:/Users/matte/.cursor', - homeDir: 'C:/Users/matte', - }); - const withoutWindows = conversion._computePathPrefix({ - isGlobal: true, - isOpencode: false, - isWindowsHost: false, - resolvedTarget: 'C:/Users/matte/.cursor', - homeDir: 'C:/Users/matte', - }); - assert.equal(withWindows, '$HOME/.cursor/'); - assert.strictEqual(withWindows, withoutWindows); - }); - - test('backslash-style resolvedTarget is normalized to forward slashes (#1615 regression)', () => { - // path.join on Windows produces backslashes; the returned prefix is - // substituted into markdown @-references which must use POSIX paths. - // Without normalization the backslashes leak into workflow file content - // and break substring checks on Windows CI. - const prefix = conversion._computePathPrefix({ - isGlobal: false, - isOpencode: false, - isWindowsHost: true, - resolvedTarget: 'C:\\Users\\runner\\AppData\\Local\\Temp\\gsd-1615-windsurf', - homeDir: 'C:\\Users\\runner', - }); - assert.strictEqual(prefix, 'C:/Users/runner/AppData/Local/Temp/gsd-1615-windsurf/'); - assert.ok(!prefix.includes('\\'), `prefix must not contain backslashes: ${prefix}`); - }); -}); - -// --------------------------------------------------------------------------- -// _applyRuntimeRewrites with injected attribution -// --------------------------------------------------------------------------- - -describe('_applyRuntimeRewrites — attribution injection', () => { - const PREFIX = '$HOME/.cursor/'; - - test('attribution=null removes Co-Authored-By line', () => { - const content = '# Hello\n\nSome text\n\nCo-Authored-By: Claude\n'; - const result = conversion._applyRuntimeRewrites(content, 'cursor', PREFIX, true, null); - assert.ok(!result.includes('Co-Authored-By:'), 'Co-Authored-By should be removed'); - }); - - test('attribution=undefined leaves Co-Authored-By unchanged', () => { - const content = '# Hello\n\nCo-Authored-By: Claude\n'; - const result = conversion._applyRuntimeRewrites(content, 'cursor', PREFIX, true, undefined); - assert.ok(result.includes('Co-Authored-By: Claude'), 'Co-Authored-By should be preserved when attribution=undefined'); - }); - - test('attribution=string replaces Co-Authored-By value', () => { - const content = '# Hello\n\nCo-Authored-By: OldName\n'; - const result = conversion._applyRuntimeRewrites(content, 'cursor', PREFIX, true, 'NewName '); - assert.ok(result.includes('Co-Authored-By: NewName '), 'Co-Authored-By should be replaced'); - }); - - test('cursor runtime replaces ~/.claude/ paths', () => { - const content = 'See ~/.claude/skills/ for more info\n'; - const result = conversion._applyRuntimeRewrites(content, 'cursor', '/home/u/.cursor/', false, undefined); - assert.ok(result.includes('/home/u/.cursor/skills/'), 'cursor should replace ~/.claude/ with pathPrefix'); - }); -}); - -// --------------------------------------------------------------------------- -// rewriteStagedSkillBodies — behavioral filesystem test -// --------------------------------------------------------------------------- - -describe('rewriteStagedSkillBodies', () => { - test('rewrites .md files in-place for cursor runtime', () => { - const stagedDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-test-staged-')); - const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-test-config-')); - try { - // Create a skill dir with a SKILL.md referencing ~/.claude/skills/foo - // NOTE: the rewrite engine handles path replacement and attribution only. - // Bash→Shell conversion is done by the stage-1 skill converter, not the engine. - const skillDir = path.join(stagedDir, 'gsd-test-skill'); - fs.mkdirSync(skillDir, { recursive: true }); - const content = '# Test\n\nSee ~/.claude/skills/foo\n\nAlso ~/.cursor/skills/bar\n'; - fs.writeFileSync(path.join(skillDir, 'SKILL.md'), content); - - // Call with injected homedir + platform for determinism - conversion.rewriteStagedSkillBodies(stagedDir, { - runtime: 'cursor', - configDir, - scope: 'global', - homedir: () => '/home/u', - platform: 'linux', - }); - - const result = fs.readFileSync(path.join(skillDir, 'SKILL.md'), 'utf8'); - // cursor rewrites ~/.claude/ → pathPrefix - // configDir is a tmpdir, not under /home/u, so prefix = resolvedTarget + '/' - // Mirror the engine's backslash→slash normalization so the assertion holds on Windows. - const resolvedTarget = path.resolve(configDir).replace(/\\/g, '/'); - assert.ok(result.includes(`${resolvedTarget}/skills/foo`), `Should replace ~/.claude/skills/ with ${resolvedTarget}/skills/`); - // cursor also rewrites ~/.cursor/ → pathPrefix - assert.ok(result.includes(`${resolvedTarget}/skills/bar`), `Should replace ~/.cursor/skills/ with ${resolvedTarget}/skills/`); - } finally { - cleanup(stagedDir); - cleanup(configDir); - } - }); - - test('with injected homedir: global under home uses $HOME prefix', () => { - // Real absolute path so Windows path.resolve does not re-root a POSIX literal onto a drive. - // The dir need not exist — the engine only string-processes it. - const HOME = path.resolve(os.tmpdir(), 'gsd-1511-fake-home'); - const configDir = path.join(HOME, '.cursor'); - const stagedDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-test-staged-')); - try { - const skillDir = path.join(stagedDir, 'gsd-help'); - fs.mkdirSync(skillDir, { recursive: true }); - fs.writeFileSync(path.join(skillDir, 'SKILL.md'), 'Use ~/.claude/skills/ here\n'); - - conversion.rewriteStagedSkillBodies(stagedDir, { - runtime: 'cursor', - configDir, - scope: 'global', - homedir: () => HOME, - platform: process.platform, - }); - - const result = fs.readFileSync(path.join(skillDir, 'SKILL.md'), 'utf8'); - assert.ok(result.includes('$HOME/.cursor/skills/'), 'Should use $HOME shorthand when configDir is under homedir'); - } finally { - cleanup(stagedDir); - } - }); - - test('non-existent stagedDir is a no-op', () => { - assert.doesNotThrow(() => { - conversion.rewriteStagedSkillBodies('/nonexistent/dir', { - runtime: 'cursor', - configDir: '/tmp/fake', - scope: 'global', - }); - }); - }); -}); - -// --------------------------------------------------------------------------- -// rewriteStagedCommandBodies — returns temp dir, does not mutate source -// --------------------------------------------------------------------------- - -describe('rewriteStagedCommandBodies', () => { - test('returns a temp dir (not the source dir) with rewritten content', () => { - const stagedDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-test-cmd-')); - const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-test-config-')); - let tempDir; - try { - // NOTE: rewrite engine handles path replacement + attribution, NOT tool renames. - fs.writeFileSync(path.join(stagedDir, 'help.md'), '# Help\n\nSee ~/.claude/skills/\n\nSee ~/.cursor/skills/\n'); - - tempDir = conversion.rewriteStagedCommandBodies(stagedDir, { - runtime: 'cursor', - configDir, - scope: 'global', - homedir: () => '/home/u', - platform: 'linux', - }); - - assert.notEqual(tempDir, stagedDir, 'must return a different dir, never the source'); - assert.ok(fs.existsSync(tempDir), 'returned tempDir should exist'); - - const result = fs.readFileSync(path.join(tempDir, 'help.md'), 'utf8'); - // Source dir should be unchanged - const source = fs.readFileSync(path.join(stagedDir, 'help.md'), 'utf8'); - assert.ok(source.includes('~/.claude/skills/'), 'source file must not be mutated'); - // configDir is /tmp/... (not under /home/u), so prefix = resolvedTarget + '/' - const resolvedTarget = path.resolve(configDir).replace(/\\/g, '/'); - assert.ok(result.includes(`${resolvedTarget}/skills/`), 'output should have cursor path rewrite applied'); - // ~/.cursor/ also rewrites to prefix - assert.ok(!result.includes('~/.cursor/'), 'output should have ~/.cursor/ replaced too'); - } finally { - cleanup(stagedDir); - cleanup(configDir); - if (tempDir && tempDir !== stagedDir) { - cleanup(tempDir); - } - } - }); - - test('non-existent stagedDir returns stagedDir unchanged (safe)', () => { - const result = conversion.rewriteStagedCommandBodies('/nonexistent/dir', { - runtime: 'cursor', - configDir: '/tmp/fake', - scope: 'global', - }); - assert.equal(result, '/nonexistent/dir', 'should return input path unchanged for missing dir'); - }); -}); - -// --------------------------------------------------------------------------- -// Error-path: applyRuntimeContentRewritesForCommandsInPlace must rm the tempDir -// on any exception and NOT leave an orphaned gsd-cmd-rewrites-* directory. -// --------------------------------------------------------------------------- - -describe('applyRuntimeContentRewritesForCommandsInPlace — error-path tempDir cleanup', () => { - test('rmSync is called on the tempDir when readFileSync throws (deterministic monkeypatch)', () => { - // Asserting the injected error propagates proves the throw happens AFTER the tempDir is - // created (the function creates tempDir, then reads .md), so the catch's rmSync cleanup - // is genuinely exercised — deterministic on every platform/uid. - const stagedDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-test-error-path-')); - fs.writeFileSync(path.join(stagedDir, 'x.md'), '# test\n'); - - const before = new Set( - fs.readdirSync(os.tmpdir()).filter(n => n.startsWith('gsd-cmd-rewrites-')) - ); - - const origReadFileSync = fs.readFileSync; - let leaked = []; - try { - fs.readFileSync = () => { throw new Error('injected read failure'); }; - - assert.throws( - () => conversion.applyRuntimeContentRewritesForCommandsInPlace(stagedDir, 'cursor', '/tmp/x/', false), - /injected read failure/, - ); - - // Restore before any further fs use so the snapshot read is trustworthy. - fs.readFileSync = origReadFileSync; - - const after = fs.readdirSync(os.tmpdir()).filter(n => n.startsWith('gsd-cmd-rewrites-')); - leaked = after.filter(n => !before.has(n)); - assert.deepStrictEqual(leaked, [], `tempDir not cleaned up on error: ${leaked.join(',')}`); - } finally { - // Idempotent restore — guard against early-throw paths above. - fs.readFileSync = origReadFileSync; - // Clean up the staged dir created for this test. - cleanup(stagedDir); - // Clean up any genuinely leaked gsd-cmd-rewrites-* dirs so the runner stays clean. - for (const n of leaked) { - cleanup(path.join(os.tmpdir(), n)); - } - } - }); -}); - -// --------------------------------------------------------------------------- -// Guard: runtime-artifact-layout no longer exports getInstallExports -// --------------------------------------------------------------------------- - -describe('layout module no longer exports getInstallExports', () => { - test('getInstallExports is not on the layout module export', () => { - process.env['GSD_TEST_MODE'] = '1'; - const layout = require('../gsd-core/bin/lib/runtime-artifact-layout.cjs'); - assert.equal( - typeof layout.getInstallExports, - 'undefined', - 'getInstallExports should have been removed from runtime-artifact-layout exports (ADR-1508 Phase 2)', - ); - }); -}); - -// --------------------------------------------------------------------------- -// DEFECT.GENERATIVE-FIX: single-owner reference-identity guard (#1511) -// Proves install.js binds to the conversion module's implementation, not a -// duplicate local copy. If these fail, a duplicate body was re-introduced. -// --------------------------------------------------------------------------- - -describe('single-owner reference-identity guard (ADR-1508 / #1511 Phase 2)', () => { - let install; - let conversionCjs; - before(() => { - process.env['GSD_TEST_MODE'] = '1'; - install = require('../bin/install.js'); - conversionCjs = require('../gsd-core/bin/lib/runtime-artifact-conversion.cjs'); - }); - - test('install.computePathPrefix === conversion._computePathPrefix (single implementation)', () => { - assert.strictEqual( - install.computePathPrefix, - conversionCjs._computePathPrefix, - 'install.js must bind computePathPrefix from conversion (not a duplicate body)', - ); - }); - - test('install.applyRuntimeContentRewritesInPlace === conversion.applyRuntimeContentRewritesInPlace (single walk loop)', () => { - assert.strictEqual( - install.applyRuntimeContentRewritesInPlace, - conversionCjs.applyRuntimeContentRewritesInPlace, - 'install.js must bind applyRuntimeContentRewritesInPlace from conversion (not a duplicate walk loop)', - ); - }); - - test('install.applyRuntimeContentRewritesForCommandsInPlace === conversion.applyRuntimeContentRewritesForCommandsInPlace (single copy+rewrite loop)', () => { - assert.strictEqual( - install.applyRuntimeContentRewritesForCommandsInPlace, - conversionCjs.applyRuntimeContentRewritesForCommandsInPlace, - 'install.js must bind applyRuntimeContentRewritesForCommandsInPlace from conversion (not a duplicate copy+rewrite loop)', - ); - }); - - test('install._applyRuntimeRewrites === conversion._applyRuntimeRewrites (single switch engine)', () => { - assert.strictEqual( - install._applyRuntimeRewrites, - conversionCjs._applyRuntimeRewrites, - 'install.js must bind _applyRuntimeRewrites from conversion (not a local shim)', - ); - }); - - // #1675 (ADR-1508): the augment converter family is single-sourced in the - // conversion module. install.js must re-bind (not re-define) these so there - // is exactly one body — the generative-drift hazard the dedup removes. - test('install.convertClaudeToAugmentMarkdown === conversion.convertClaudeToAugmentMarkdown (single converter)', () => { - assert.strictEqual( - install.convertClaudeToAugmentMarkdown, - conversionCjs.convertClaudeToAugmentMarkdown, - 'install.js must bind convertClaudeToAugmentMarkdown from conversion (not a duplicate body)', - ); - }); - - test('install.convertClaudeCommandToAugmentSkill === conversion.convertClaudeCommandToAugmentSkill (single converter)', () => { - assert.strictEqual( - install.convertClaudeCommandToAugmentSkill, - conversionCjs.convertClaudeCommandToAugmentSkill, - 'install.js must bind convertClaudeCommandToAugmentSkill from conversion (not a duplicate body)', - ); - }); - - test('install.convertClaudeAgentToAugmentAgent === conversion.convertClaudeAgentToAugmentAgent (single converter)', () => { - assert.strictEqual( - install.convertClaudeAgentToAugmentAgent, - conversionCjs.convertClaudeAgentToAugmentAgent, - 'install.js must bind convertClaudeAgentToAugmentAgent from conversion (not a duplicate body)', - ); - }); -}); - }); -} // ──────────────────────────────────────────────────────────────────────── diff --git a/tests/issue-1575-agent-descriptor-parity.test.cjs b/tests/issue-1575-agent-descriptor-parity.test.cjs index 3557391b7..f4c65cca1 100644 --- a/tests/issue-1575-agent-descriptor-parity.test.cjs +++ b/tests/issue-1575-agent-descriptor-parity.test.cjs @@ -98,6 +98,8 @@ describe('#1575 — golden-parity: surface path matches install path for descrip installContent, `${runtime}/${fileName}: surface content must be byte-identical to install content`, ); + } + }); } test('cursor with non-undefined attribution: surface agents byte-identical to install agents (M2 coverage)', (t) => { @@ -124,8 +126,6 @@ describe('#1575 — golden-parity: surface path matches install path for descrip `cursor/${fileName}: content must be byte-identical with non-undefined attribution`); } }); - }); - } test('copilot: agents installed as .agent.md (filename rename parity)', (t) => { const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-1575-copilot-rename-')); From 6e7e3111fb8479ef66601aee54331d7c527db32f Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Thu, 9 Jul 2026 23:41:04 -0400 Subject: [PATCH 17/31] =?UTF-8?q?test(#2126):=20fix=20#1259=20real-eslint?= =?UTF-8?q?=20CPU=20starvation=20=E2=80=94=20lint=20a=20non-type-aware=20.?= =?UTF-8?q?cjs=20clean=20target?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The prohibition-enforcement real-runner tests linted src/clock.cts (a .cts) as their clean target. Under eslint.config.mjs's type-aware block for src/**/*.cts (recommendedTypeChecked + parserOptions.project: tsconfig.build.json), each eslint spawn loaded the WHOLE tsconfig.build.json program (~2s, CPU-heavy). The real-runner tests spawn eslint repeatedly; under --test-concurrency those full-program type-checks oversubscribed the bench CPU and blew the 60s subprocess bound -> fail-closed (intermittent, load-dependent — passed 24241/24241 in an earlier run, failed here). Root fix (not a retry/timeout bandaid; measured projectService = no faster since a single-file .cts lint still loads type info): add tests/_ff_lint_clean.cjs, a KNOWN-CLEAN lint-scoped .cjs companion to _ff_lint_violation.cjs, with a flat-config block enabling local/no-source-grep so the clean pass stays non-vacuous. Repoint the 6 src/clock.cts real-runner usages (5 targets + the FF-02 toothless violationFixture) at it. Each spawn is now ~0.8s non-type-aware (no whole-program load) — starvation removed. All 6 tests' semantics verified in-process (SF-01 greens; toothless/fail-closed stay unverified); full-repo `eslint .` green. Refs #2126, #1259 Co-Authored-By: Claude Opus 4.8 --- eslint.config.mjs | 13 +++++++++++++ tests/_ff_lint_clean.cjs | 19 +++++++++++++++++++ tests/prohibition-enforcement.test.cjs | 24 ++++++++++++------------ 3 files changed, 44 insertions(+), 12 deletions(-) create mode 100644 tests/_ff_lint_clean.cjs diff --git a/eslint.config.mjs b/eslint.config.mjs index a71f80e39..142c3eb67 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -399,4 +399,17 @@ export default tseslint.config( languageOptions: { sourceType: 'commonjs', globals: { ...globals.node } }, rules: { 'local/no-source-grep': 'error' }, }, + // ── #2126 lint-rule CLEAN fixture ─────────────────────────────────────────── + // `tests/_ff_lint_clean.cjs` is the KNOWN-CLEAN companion to the violation fixture: the + // prohibition-enforcement real-runner tests lint it as their non-vacuous "clean target" instead of + // a type-aware `src/**/*.cts` file, so each eslint spawn is ~0.8s (non-type-aware) not ~2s + // (whole-tsconfig-program load) — removing the CPU starvation that blew the 60s bound under + // --test-concurrency. Rule enabled (as error) so the pass is non-vacuous; the file is clean so it + // greens. PLAIN `.cjs`, kept OFF the `*.test.cjs` runner glob. (#2126) + { + files: ['tests/_ff_lint_clean.cjs'], + plugins: { local: localPlugin }, + languageOptions: { sourceType: 'commonjs', globals: { ...globals.node } }, + rules: { 'local/no-source-grep': 'error' }, + }, ); diff --git a/tests/_ff_lint_clean.cjs b/tests/_ff_lint_clean.cjs new file mode 100644 index 000000000..f6d8ab3a1 --- /dev/null +++ b/tests/_ff_lint_clean.cjs @@ -0,0 +1,19 @@ +// PERMANENT LOAD-BEARING FIXTURE for #1259 / #2126 — DO NOT delete or rename to `*.test.cjs`. +// +// A KNOWN-CLEAN, lint-scoped `.cjs` companion to `_ff_lint_violation.cjs`. It has NO +// `local/no-source-grep` violation, so the prohibition-enforcement real-runner tests can use it as +// the "clean target" for a NON-VACUOUS pass — the rule RUNS on it (enabled via the flat-config +// block below) and finds nothing. +// +// Why a `.cjs` and not `src/clock.cts`: linting a `src/**/*.cts` file is type-aware +// (`recommendedTypeChecked` + `parserOptions.project`), which loads the whole `tsconfig.build.json` +// program on every eslint spawn (~2s, CPU-heavy). The real-runner tests spawn eslint repeatedly and, +// under `--test-concurrency`, those full-program type-checks oversubscribe the bench CPU and blow the +// 60s subprocess bound (#2126). A plain `.cjs` is linted non-type-aware (~0.8s) — same coverage of +// the AST-only `no-source-grep` rule, no starvation. +// +// PLAIN `.cjs` (NOT `*.test.cjs`) on purpose — same reason as the violation fixture: keep it OFF the +// `node --test` runner glob so it is only ever linted, never executed. +'use strict'; + +module.exports = {}; diff --git a/tests/prohibition-enforcement.test.cjs b/tests/prohibition-enforcement.test.cjs index 9c2c31993..440351159 100644 --- a/tests/prohibition-enforcement.test.cjs +++ b/tests/prohibition-enforcement.test.cjs @@ -716,13 +716,13 @@ describe('prohibition-enforcement REAL runner end-to-end (#1259)', () => { const enforce = require(ENFORCEMENT_LIB); // Migrated to the SHIPPING prover (#1279): the default real prover lints the committed // `_ff_lint_violation.cjs` violationFixture (the rule fires -> fail-first proven) while the - // clean runCheck lints src/clock.cts (no violation -> non-vacuous pass). Both via real eslint. + // clean runCheck lints tests/_ff_lint_clean.cjs (no violation -> non-vacuous pass). Both via real eslint. const result = enforce.runProhibitionEnforcement( TEST_TIER, { kind: 'lint-rule', rule: 'local/no-source-grep', - target: 'src/clock.cts', + target: 'tests/_ff_lint_clean.cjs', failFirst: true, violationFixture: path.join('tests', '_ff_lint_violation.cjs'), }, @@ -759,13 +759,13 @@ describe('prohibition-enforcement REAL runner end-to-end (#1259)', () => { const enforce = require(ENFORCEMENT_LIB); // No injected runCheck/proveFailFirst: the default prover lints the committed // `_ff_lint_violation.cjs` (the rule fires -> fail-first proven) AND the default runner lints - // the clean `src/clock.cts` (no violation -> non-vacuous pass). BOTH directions via real eslint. + // the clean `tests/_ff_lint_clean.cjs` (no violation -> non-vacuous pass). BOTH directions via real eslint. const result = enforce.runProhibitionEnforcement( TEST_TIER, { kind: 'lint-rule', rule: 'local/no-source-grep', - target: 'src/clock.cts', + target: 'tests/_ff_lint_clean.cjs', violationFixture: path.join('tests', '_ff_lint_violation.cjs'), }, { cwd: process.cwd() }, @@ -780,7 +780,7 @@ describe('prohibition-enforcement REAL runner end-to-end (#1259)', () => { test('FULL producer (real): lint-rule hard-gates on a TOOTHLESS violationFixture (rule does not flag it) (FF-02 wrong-direction)', () => { const enforce = require(ENFORCEMENT_LIB); - // The "violation fixture" is a CLEAN in-tree file (src/clock.cts) the rule does NOT flag, so the + // The "violation fixture" is a CLEAN in-tree file (tests/_ff_lint_clean.cjs) the rule does NOT flag, so the // default prover cannot prove fail-first -> the producer must hard-gate (never green), even though // the clean target itself would pass the runner. A toothless guard is not a guard. const result = enforce.runProhibitionEnforcement( @@ -788,8 +788,8 @@ describe('prohibition-enforcement REAL runner end-to-end (#1259)', () => { { kind: 'lint-rule', rule: 'local/no-source-grep', - target: 'src/clock.cts', - violationFixture: 'src/clock.cts', + target: 'tests/_ff_lint_clean.cjs', + violationFixture: 'tests/_ff_lint_clean.cjs', }, { cwd: process.cwd() }, ); @@ -807,8 +807,8 @@ describe('prohibition-enforcement REAL runner end-to-end (#1259)', () => { { kind: 'lint-rule', rule: 'local/no-source-grep', - target: 'src/clock.cts', - violationFixture: 'src/clock.cts', + target: 'tests/_ff_lint_clean.cjs', + violationFixture: 'tests/_ff_lint_clean.cjs', }, { cwd: process.cwd(), mode }, ); @@ -1009,11 +1009,11 @@ describe('prohibition-enforcement defaultProveFailFirst REAL prover (#1279)', () test('lint-rule: a CLEAN violationFixture (rule does not flag) is NOT proven (FF-02 toothless direction)', () => { const enforce = require(ENFORCEMENT_LIB); - // src/clock.cts is a clean in-tree source with no no-source-grep violation. If a "violation + // tests/_ff_lint_clean.cjs is a clean in-tree source with no no-source-grep violation. If a "violation // fixture" does not actually trigger the rule, the rule is toothless on it → not a guard → not // proven → must hard-gate. const proof = enforce.defaultProveFailFirst( - { kind: 'lint-rule', rule: 'local/no-source-grep', target: 'src/clock.cts', violationFixture: 'src/clock.cts' }, + { kind: 'lint-rule', rule: 'local/no-source-grep', target: 'tests/_ff_lint_clean.cjs', violationFixture: 'tests/_ff_lint_clean.cjs' }, process.cwd(), ); assert.equal(proof.provenFailFirst, false, @@ -1023,7 +1023,7 @@ describe('prohibition-enforcement defaultProveFailFirst REAL prover (#1279)', () test('lint-rule: no violationFixture -> not proven (FF-05 fail-closed)', () => { const enforce = require(ENFORCEMENT_LIB); const proof = enforce.defaultProveFailFirst( - { kind: 'lint-rule', rule: 'local/no-source-grep', target: 'src/clock.cts' }, // no violationFixture + { kind: 'lint-rule', rule: 'local/no-source-grep', target: 'tests/_ff_lint_clean.cjs' }, // no violationFixture process.cwd(), ); assert.equal(proof.provenFailFirst, false, 'no violationFixture -> cannot prove -> hard-gate'); From 16b61d437f837353b9e69fb0c452e5ea8d1cdd8b Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Fri, 10 Jul 2026 00:53:00 -0400 Subject: [PATCH 18/31] =?UTF-8?q?test(#2126):=20resolve=20review=20finding?= =?UTF-8?q?s=20=E2=80=94=20fold=20dedup,=20harness=20fidelity,=20malformed?= =?UTF-8?q?-parity=20lock?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adversarial review of the Phase 3 branch surfaced three verified defects; fix all three in place (no defer): - install-runtime-artifacts.test.cjs: finish the fold-triplication dedup started earlier (only enh-1511 had been collapsed). 11 B1-batch __foldDescribe blocks were byte-identical triplicates (~5.9k lines, ~49% of the file), tripling the subprocess-spawning installer suites under --test-concurrency — the same starvation that produced the temp-dir races this branch fixes. Byte-identity verified per block before removal; 230 distinct test/it titles preserved (origin/next: 230 -> 230), interleaved B3/B5/B6 singletons untouched. - config-get-default.test.cjs: make runExpectError faithful to production. The throwing process.exit seam was caught by cmdConfigGet's "No config.json" guard and reclassified into a spurious 2nd error() with the wrong reason (CONFIG_PARSE_FAILED). Drive io.setJsonErrorMode + carry the original message on the sentinel so the guard re-throws (single fire), assert exitCount===1, and strengthen both probes to assert the typed reason (CONFIG_NO_FILE / CONFIG_KEY_NOT_FOUND). - roadmap.test.cjs: lock the #2121/#2114 malformed_roadmap parity — a project-code-prefixed query against a checklist-only roadmap now surfaces the same diagnostic a bare query always did (fails on prior silent-empty behavior). Co-Authored-By: Claude Opus 4.8 --- tests/config-get-default.test.cjs | 68 +- tests/install-runtime-artifacts.test.cjs | 5915 ---------------------- tests/roadmap.test.cjs | 33 + 3 files changed, 85 insertions(+), 5931 deletions(-) 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', () => { From b85996e95ece360e43d9854253e6d92933a1b86f Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Fri, 10 Jul 2026 00:53:01 -0400 Subject: [PATCH 19/31] docs(changeset): note malformed_roadmap parity for prefixed queries (#2114) Co-Authored-By: Claude Opus 4.8 --- .changeset/quick-seals-parade.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/quick-seals-parade.md b/.changeset/quick-seals-parade.md index 50fdfe1b3..6e31c63fe 100644 --- a/.changeset/quick-seals-parade.md +++ b/.changeset/quick-seals-parade.md @@ -2,4 +2,4 @@ type: Fixed pr: 0 --- -**`roadmap get-phase` resolves project-code-prefixed headings by bare number** — a bare-number query (e.g. `29`) now resolves a drifted `### Phase AB-29:` heading, matching the internal resolver used by `init.phase-op`; previously the CLI returned empty. A bare sibling (`### Phase 29:`) still takes precedence. (#2114) +**`roadmap get-phase` resolves project-code-prefixed headings by bare number** — a bare-number query (e.g. `29`) now resolves a drifted `### Phase AB-29:` heading, matching the internal resolver used by `init.phase-op`; previously the CLI returned empty. A bare sibling (`### Phase 29:`) still takes precedence. A project-code-prefixed query against a checklist-only roadmap now reports the same `malformed_roadmap` diagnostic a bare query always did, instead of a silent empty result. (#2114) From a22602e276b9efee54adfb0db68e33d86c065256 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Fri, 10 Jul 2026 01:07:00 -0400 Subject: [PATCH 20/31] docs(#2126): correct malformed_roadmap prior-behavior note (re-review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-review found the test comment + changeset prose inaccurately claimed a bare query "always" surfaced malformed_roadmap. Empirically, on origin/next a project-code-prefixed checklist entry was a silent {found:false} for BOTH query forms — the prefixed pass discarded its malformed candidate and the bare regex could not match the PROJ- prefix at all. The unified 3-source lookup newly grants the diagnostic to both forms; correct the prose to say so. No logic change. Co-Authored-By: Claude Opus 4.8 --- .changeset/quick-seals-parade.md | 2 +- tests/roadmap.test.cjs | 16 ++++++++-------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/.changeset/quick-seals-parade.md b/.changeset/quick-seals-parade.md index 6e31c63fe..32e846f75 100644 --- a/.changeset/quick-seals-parade.md +++ b/.changeset/quick-seals-parade.md @@ -2,4 +2,4 @@ type: Fixed pr: 0 --- -**`roadmap get-phase` resolves project-code-prefixed headings by bare number** — a bare-number query (e.g. `29`) now resolves a drifted `### Phase AB-29:` heading, matching the internal resolver used by `init.phase-op`; previously the CLI returned empty. A bare sibling (`### Phase 29:`) still takes precedence. A project-code-prefixed query against a checklist-only roadmap now reports the same `malformed_roadmap` diagnostic a bare query always did, instead of a silent empty result. (#2114) +**`roadmap get-phase` resolves project-code-prefixed headings by bare number** — a bare-number query (e.g. `29`) now resolves a drifted `### Phase AB-29:` heading, matching the internal resolver used by `init.phase-op`; previously the CLI returned empty. A bare sibling (`### Phase 29:`) still takes precedence. A project-code-prefixed heading present only as a summary/checklist line (no matching detail section) now reports a `malformed_roadmap` diagnostic — for both prefixed and bare-number queries — instead of a silent empty result. (#2114) diff --git a/tests/roadmap.test.cjs b/tests/roadmap.test.cjs index c52cad759..d401b0c5a 100644 --- a/tests/roadmap.test.cjs +++ b/tests/roadmap.test.cjs @@ -2122,14 +2122,14 @@ describe('bug #2114: roadmap get-phase resolves drifted prefixed headings by bar 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. + test('project-code-prefixed checklist-only entry surfaces malformed_roadmap for both query forms', () => { + // #2121/#2114 route all three resolvers through the shared 3-source lookup. A + // `**Phase PROJ-42:**` summary line with no matching `### Phase PROJ-42:` detail heading + // is a malformed ROADMAP. Before the consolidation this project-code-prefixed checklist + // was reported as a silent `{found:false}` for BOTH query forms — the prefixed pass + // discarded its malformed candidate, and the bare pass could not match the `PROJ-` prefix + // at all. The unified lookup newly surfaces the malformed_roadmap diagnostic for both, so + // this test fails on the prior silent-empty behavior for the prefixed AND the bare form. fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), [ From 067bbac8fa001b51288f98e57378a8feafd93df7 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Fri, 10 Jul 2026 07:41:36 -0400 Subject: [PATCH 21/31] docs(changeset): backfill PR number (#2139) Co-Authored-By: Claude Opus 4.8 --- .changeset/quick-seals-parade.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/quick-seals-parade.md b/.changeset/quick-seals-parade.md index 32e846f75..37b152184 100644 --- a/.changeset/quick-seals-parade.md +++ b/.changeset/quick-seals-parade.md @@ -1,5 +1,5 @@ --- type: Fixed -pr: 0 +pr: 2139 --- **`roadmap get-phase` resolves project-code-prefixed headings by bare number** — a bare-number query (e.g. `29`) now resolves a drifted `### Phase AB-29:` heading, matching the internal resolver used by `init.phase-op`; previously the CLI returned empty. A bare sibling (`### Phase 29:`) still takes precedence. A project-code-prefixed heading present only as a summary/checklist line (no matching detail section) now reports a `malformed_roadmap` diagnostic — for both prefixed and bare-number queries — instead of a silent empty result. (#2114) From 09be501eb7d6d9b785ab240281d4c3d189dd3e96 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Fri, 10 Jul 2026 08:49:36 -0400 Subject: [PATCH 22/31] =?UTF-8?q?feat(#2128):=20phase-id=20anti-divergence?= =?UTF-8?q?=20guard=20=E2=80=94=20canonical=20token=20source=20+=20drift?= =?UTF-8?q?=20scanner=20+=20guards?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 4 of epic #2121 (ADR-2121 Decision 7), closing the recurrence loop that produced #2111 / #2114 / #2104: no module outside src/phase-id.cts may re-implement phase-ID parsing without failing CI. - phase-id.cts: add PHASE_NUMBER_TOKEN_SOURCE — the canonical phase-number-token grammar (\d+[A-Z]?(?:\.\d+)*) for enumeration/scan call sites, the ANY-phase counterpart to phaseMarkdownRegexSource(n)'s known-number lookup. Extend-only (never touches normalizePhaseName; blast radius 79 fns / CRITICAL). - scripts/lint-phase-id-drift.cjs: pure findPhaseIdRegexDrift(text) + scanRepo(root), wired to `npm run check:phase-id-drift`. Flags a literal re-derivation of the canonical token (both /\d/ and new-RegExp `\\d` escaping, plus the [A-Za-z] and [.-] near-variants) anywhere in src/** outside phase-id.cts, unless sanctioned with `// phase-id-owner: `. Narrow by design: bare \d+, digits-only captures, \w ids, status-message text and pipe-tables are not flagged. - tests/phase-id-drift-guard.test.cjs: fail-first drift cases (AC1) + live scanRepo(ROOT) zero-drift (AC3) + identity guard — phase-id.cjs exports the complete locked surface and no consumer re-exports a divergent copy (AC2). Co-Authored-By: Claude Opus 4.8 --- package.json | 1 + scripts/lint-phase-id-drift.cjs | 133 ++++++++++++++++++++++++++ src/phase-id.cts | 13 +++ tests/phase-id-drift-guard.test.cjs | 143 ++++++++++++++++++++++++++++ 4 files changed, 290 insertions(+) create mode 100644 scripts/lint-phase-id-drift.cjs create mode 100644 tests/phase-id-drift-guard.test.cjs diff --git a/package.json b/package.json index 844d518ca..b05b647aa 100644 --- a/package.json +++ b/package.json @@ -78,6 +78,7 @@ "check:env": "node scripts/check-env.cjs", "check:alias-drift": "node scripts/check-alias-drift.cjs", "check:identity-drift": "node scripts/lint-package-identity-drift.cjs", + "check:phase-id-drift": "node scripts/lint-phase-id-drift.cjs", "check:integrity": "node scripts/check-npm-integrity.cjs", "build": "npm run generate:identity && npm run build:lib && npm run gen:plugin-skills && npm run gen:loop-host-contract && npm run gen:capability-registry && npm run build:hooks", "build:hooks": "node scripts/build-hooks.js", diff --git a/scripts/lint-phase-id-drift.cjs b/scripts/lint-phase-id-drift.cjs new file mode 100644 index 000000000..33cecc3cd --- /dev/null +++ b/scripts/lint-phase-id-drift.cjs @@ -0,0 +1,133 @@ +#!/usr/bin/env node +'use strict'; + +/** + * Anti-divergence drift guard for the phase-identifier parsing seam + * (epic #2121, Phase 4 / issue #2128, locked by ADR-2121 Decision 7). + * + * `src/phase-id.cts` is the SINGLE canonical owner of phase-ID parsing. Its + * `PHASE_NUMBER_TOKEN_SOURCE` (and `phaseMarkdownRegexSource` for a known number) + * is the one place the phase-number-token grammar `\d+[A-Z]?(?:\.\d+)*` is + * defined. Every other module that scans/enumerates phase headings must build + * its regex from that source rather than re-deriving the grammar as a literal — + * otherwise the trio drifts again (the #2111 / #2114 / #2104 recurrence loop this + * epic closes). + * + * This lint makes the invariant machine-enforced: it FAILS the moment a literal + * re-derivation of the canonical token grammar is introduced anywhere in + * `src/**` outside `phase-id.cts`, unless the site is deliberately sanctioned + * with a `// phase-id-owner: ` comment (on the same line or the line + * directly above). Sites that build their regex from `PHASE_NUMBER_TOKEN_SOURCE` + * carry no literal grammar and pass automatically. + * + * Detection is intentionally NARROW: only the contiguous canonical token + * (`\d+[A-Z]?(?:\.\d+)*`, its `[A-Za-z]` and `[.-]` near-variants, in both + * regex-literal `\d` and `new RegExp` template `\\d` escaping) is drift. Bare + * `\d+` probes, `[\w][\w.-]*` ids, digits-only captures, status-message text + * (`Phase\s+\d`), and pipe-table structures are NOT phase-token re-derivations + * and are not flagged. + */ + +const fs = require('node:fs'); +const path = require('node:path'); + +// The canonical phase-number token as it appears in SOURCE TEXT: +// \d+[A-Z]?(?:\.\d+)* in a regex literal -> one backslash before d/. +// \\d+[A-Z]?(?:\\.\\d+)* in a template string -> two backslashes +// Also tolerate the [A-Za-z] letter-class and the [.-] (dot-or-dash) separator +// near-variants that a few enumeration call sites use. +const TOKEN_DRIFT_RE = /\\{1,2}d\+\[A-Z(?:a-z)?\]\??\(\?:(?:\\{1,2}\.|\[\.-\])\\{1,2}d\+\)\*/; + +const OWNER_MARK = 'phase-id-owner:'; +const CANON_REF = 'PHASE_NUMBER_TOKEN_SOURCE'; + +/** + * Pure: find every literal re-derivation of the canonical phase-number token in + * `text` that is NOT sanctioned. A site is sanctioned when its line — or the + * line directly above it — contains `// phase-id-owner:`, or when the line + * references `PHASE_NUMBER_TOKEN_SOURCE` (i.e. it is built from the canonical + * source, not a literal). Returns [{ line, found }]. + */ +function findPhaseIdRegexDrift(text) { + const out = []; + const lines = text.split('\n'); + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const m = TOKEN_DRIFT_RE.exec(line); + if (!m) continue; + if (line.includes(OWNER_MARK)) continue; + if (i > 0 && lines[i - 1].includes(OWNER_MARK)) continue; + if (line.includes(CANON_REF)) continue; + out.push({ line: i + 1, found: m[0] }); + } + return out; +} + +// Authored TypeScript source only (the generated bin/lib/*.cjs mirror it). +const SCAN_DIRS = ['src']; +const SCAN_EXT = new Set(['.cts', '.ts', '.mts']); +// The canonical owner defines the grammar; it is exempt by construction. +const EXEMPT = new Set([path.join('src', 'phase-id.cts')]); + +function walk(dir, acc) { + let entries; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch { + return acc; + } + for (const entry of entries) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + if (entry.name === 'node_modules' || entry.name === 'dist' || entry.name === '.git') continue; + walk(full, acc); + } else if (entry.isFile() && SCAN_EXT.has(path.extname(entry.name))) { + acc.push(full); + } + } + return acc; +} + +/** + * Scan the authored source tree and return every unsanctioned phase-token + * re-derivation, each annotated with the repo-relative file path. + */ +function scanRepo(root) { + const violations = []; + for (const dir of SCAN_DIRS) { + for (const file of walk(path.join(root, dir), [])) { + const rel = path.relative(root, file); + if (EXEMPT.has(rel)) continue; + let text; + try { + text = fs.readFileSync(file, 'utf8'); + } catch { + continue; + } + for (const d of findPhaseIdRegexDrift(text)) { + violations.push({ file: rel, ...d }); + } + } + } + return violations; +} + +function main() { + const root = path.join(__dirname, '..'); + const violations = scanRepo(root); + if (violations.length === 0) { + process.stdout.write('ok phase-id-drift: no unsanctioned phase-token re-derivations outside phase-id.cts\n'); + return; + } + process.stderr.write('phase-id-drift: literal re-derivation(s) of the canonical phase-number token found.\n'); + process.stderr.write('Build the regex from phase-id.cjs `PHASE_NUMBER_TOKEN_SOURCE` (or phaseMarkdownRegexSource for a\n'); + process.stderr.write('known number), or sanction the site with a `// phase-id-owner: ` comment:\n'); + for (const d of violations) { + process.stderr.write(` ${d.file}:${d.line} ${d.found}\n`); + } + process.exitCode = 1; +} + +if (require.main === module) main(); + +module.exports = { findPhaseIdRegexDrift, scanRepo, TOKEN_DRIFT_RE }; diff --git a/src/phase-id.cts b/src/phase-id.cts index e08a4af43..7ce8f90f3 100644 --- a/src/phase-id.cts +++ b/src/phase-id.cts @@ -41,6 +41,18 @@ const OPTIONAL_PROJECT_CODE_PREFIX_SOURCE = '(?:[A-Z][A-Z0-9_]*-)?'; // source. Both forms must change together; see the #1729 regression test. const OPTIONAL_PHASE_TAG_SOURCE = '(?:\\s*\\([^)\\n]*\\))?'; +// #2128: the canonical phase-NUMBER-TOKEN grammar — a phase number with an +// optional single-letter variant suffix and optional dotted sub-phases +// (1, 01, 12A, 12.1, 3.2.1). This is the ENUMERATION/scan counterpart to +// phaseMarkdownRegexSource: use phaseMarkdownRegexSource(n) to build a source +// for ONE KNOWN number; reference this constant when a call site must match ANY +// phase and capture its token. Enumeration/parse sites inline this into a +// `new RegExp(...)` instead of re-deriving the grammar as a literal, so every +// phase-token producer shares one owner. The anti-divergence guard +// (scripts/lint-phase-id-drift.cjs) fails CI if a literal re-derivation is +// introduced outside this module without a `// phase-id-owner:` justification. +const PHASE_NUMBER_TOKEN_SOURCE = '\\d+[A-Z]?(?:\\.\\d+)*'; + function stripProjectCodePrefix(value: unknown, caseInsensitive = true): string { const input = String(value); const re = caseInsensitive ? PROJECT_CODE_PREFIX_STRIP_RE_I : PROJECT_CODE_PREFIX_STRIP_RE; @@ -350,6 +362,7 @@ export = { escapeRegex, OPTIONAL_PROJECT_CODE_PREFIX_SOURCE, OPTIONAL_PHASE_TAG_SOURCE, + PHASE_NUMBER_TOKEN_SOURCE, stripProjectCodePrefix, normalizePhaseName, getMilestoneFromPhaseId, diff --git a/tests/phase-id-drift-guard.test.cjs b/tests/phase-id-drift-guard.test.cjs new file mode 100644 index 000000000..3e20b291a --- /dev/null +++ b/tests/phase-id-drift-guard.test.cjs @@ -0,0 +1,143 @@ +'use strict'; +process.env.GSD_TEST_MODE = '1'; + +/** + * Anti-divergence guard for the phase-identifier parsing seam + * (epic #2121 Phase 4 / issue #2128, ADR-2121 Decision 7). + * + * `src/phase-id.cts` is the single canonical owner of phase-ID parsing. Two guards + * keep it that way: + * 1. DRIFT SCANNER (scripts/lint-phase-id-drift.cjs) — fails CI if any module + * outside phase-id.cts re-derives the canonical phase-number token as a + * literal without a `// phase-id-owner:` sanction. + * 2. IDENTITY guard — phase-id.cjs exports the complete locked surface, and no + * consumer re-exports a DIVERGENT copy of a canonical function (re-export, + * never re-implement). + * + * Behavioral throughout: assertions drive `findPhaseIdRegexDrift` / `scanRepo` + * and compare object identity — no `readFileSync().includes()` in a test body. + */ + +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 { findPhaseIdRegexDrift, scanRepo } = require( + path.join(ROOT, 'scripts', 'lint-phase-id-drift.cjs'), +); +const phaseId = require(path.join(ROOT, 'gsd-core', 'bin', 'lib', 'phase-id.cjs')); + +// The locked canonical surface (ADR-2121 Decision 1/2; PHASE_NUMBER_TOKEN_SOURCE +// added in Phase 4). Every name is exported by phase-id.cjs; the identity guard +// forbids any other module from re-exporting a divergent copy of one. +const CANONICAL = [ + 'escapeRegex', 'OPTIONAL_PROJECT_CODE_PREFIX_SOURCE', 'OPTIONAL_PHASE_TAG_SOURCE', + 'PHASE_NUMBER_TOKEN_SOURCE', 'stripProjectCodePrefix', 'normalizePhaseName', + 'getMilestoneFromPhaseId', 'getPhaseDirFromPhaseId', 'phaseMarkdownRegexSource', + 'phaseMarkdownRegexSourceExact', 'comparePhaseNum', 'extractPhaseToken', + 'phaseTokenMatches', 'parsePhaseFromProse', 'stripConfiguredProjectCodePrefix', + 'isForeignPrefixedPhaseQuery', 'roadmapPhaseLookupSources', +]; + +describe('#2128 phase-id drift scanner: findPhaseIdRegexDrift (pure)', () => { + test('a regex built from PHASE_NUMBER_TOKEN_SOURCE is NOT drift', () => { + assert.deepEqual( + findPhaseIdRegexDrift('const re = new RegExp(`Phase\\s+(${PHASE_NUMBER_TOKEN_SOURCE})`);'), + [], + ); + }); + + test('a literal re-derivation of the canonical token IS flagged (fail-first)', () => { + const v = findPhaseIdRegexDrift('const re = /Phase\\s+(\\d+[A-Z]?(?:\\.\\d+)*)/;'); + assert.equal(v.length, 1); + assert.equal(v[0].found, '\\d+[A-Z]?(?:\\.\\d+)*'); + }); + + test('a re-derivation inside a new RegExp template (\\\\d escaping) IS flagged', () => { + const v = findPhaseIdRegexDrift('new RegExp(`Phase\\\\s+(\\\\d+[A-Z]?(?:\\\\.\\\\d+)*)`)'); + assert.equal(v.length, 1); + }); + + test('the [A-Za-z] and [.-] near-variants ARE flagged', () => { + assert.equal(findPhaseIdRegexDrift('/(\\d+[A-Za-z]?(?:\\.\\d+)*)/').length, 1); + assert.equal(findPhaseIdRegexDrift('/(\\d+[A-Z]?(?:[.-]\\d+)*)/').length, 1); + }); + + test('a same-line // phase-id-owner: sanction suppresses the flag', () => { + assert.deepEqual( + findPhaseIdRegexDrift('const re = /(\\d+[A-Z]?(?:\\.\\d+)*)/; // phase-id-owner: sanctioned exception'), + [], + ); + }); + + test('a preceding-line // phase-id-owner: sanction suppresses the flag', () => { + assert.deepEqual( + findPhaseIdRegexDrift('// phase-id-owner: sanctioned exception\nconst re = /(\\d+[A-Z]?(?:\\.\\d+)*)/;'), + [], + ); + }); + + test('non-token phase regexes are NOT flagged (no false positives)', () => { + assert.deepEqual(findPhaseIdRegexDrift('/^Executing Phase\\s+\\d+/'), [], 'status-message bare \\d+'); + assert.deepEqual(findPhaseIdRegexDrift('/#{2,4}\\s*Phase\\s+(\\d+)[A-Z]?(?:\\.\\d+)*/'), [], 'digits-only capture is non-contiguous'); + assert.deepEqual(findPhaseIdRegexDrift('/Phase\\s+([\\w][\\w.-]*)/'), [], '\\w id grammar is not the canonical token'); + assert.deepEqual(findPhaseIdRegexDrift('/\\|\\s*Phase\\s*\\|\\s*Plans\\s*\\|/'), [], 'pipe-table structure'); + }); + + test('reports 1-based line numbers', () => { + const v = findPhaseIdRegexDrift('line1\nconst re = /(\\d+[A-Z]?(?:\\.\\d+)*)/;\nline3'); + assert.equal(v[0].line, 2); + }); +}); + +describe('#2128 phase-id drift scanner: the live repo is clean', () => { + test('scanRepo finds zero unsanctioned phase-token re-derivations', () => { + const violations = scanRepo(ROOT); + assert.deepEqual( + violations, + [], + 'unsanctioned phase-token re-derivation(s) — build from PHASE_NUMBER_TOKEN_SOURCE or add // phase-id-owner:\n' + + violations.map((d) => ` ${d.file}:${d.line} ${d.found}`).join('\n'), + ); + }); +}); + +describe('#2128 phase-id single-owner identity guard', () => { + test('phase-id.cjs exports the complete locked canonical surface', () => { + for (const name of CANONICAL) { + assert.ok(name in phaseId, `phase-id.cjs must export the canonical member '${name}'`); + } + }); + + test('no consumer module re-exports a DIVERGENT copy of a canonical phase-id function', () => { + // Forward guard: if any built lib module re-exports a name that phase-id.cjs + // owns, it MUST be the identical reference — a re-export, never a local + // re-implementation. All consumers pass today (none re-export); the guard + // fails the moment a divergent copy ships. + const libDir = path.join(ROOT, 'gsd-core', 'bin', 'lib'); + const consumers = fs.readdirSync(libDir).filter((f) => f.endsWith('.cjs') && f !== 'phase-id.cjs'); + let checked = 0; + for (const f of consumers) { + let mod; + try { + mod = require(path.join(libDir, f)); + } catch { + continue; // a module that cannot be required in isolation can't re-export anything + } + if (!mod || typeof mod !== 'object') continue; + checked++; + for (const name of CANONICAL) { + if (Object.prototype.hasOwnProperty.call(mod, name)) { + assert.strictEqual( + mod[name], + phaseId[name], + `${f} re-exports '${name}' but it is NOT the phase-id.cjs reference — re-export the canonical, do not re-implement`, + ); + } + } + } + assert.ok(checked > 0, 'expected to inspect at least one consumer module'); + }); +}); From dfad3a7510a2b73cecf08c6b83f5aa51a74b7453 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Fri, 10 Jul 2026 08:49:36 -0400 Subject: [PATCH 23/31] refactor(#2128): single-source 23 phase-token re-derivations; sanction 14 context-specific sites MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Route 23 literal re-derivations of the canonical phase-number token through phase-id.cjs `PHASE_NUMBER_TOKEN_SOURCE` (via new RegExp). Each conversion was proven BYTE-IDENTICAL (old.source === new.source && old.flags === new.flags), so the runtime regexes are unchanged — zero behavior change by construction. The remaining 14 phase-token sites are genuine but context-specific and stay literal with a `// phase-id-owner: ` sanction: dir-name parses whose dash-continuation semantics differ from extractPhaseToken, and the [A-Za-z] case-variant / [.-] dot-or-dash separator forms that are not source-byte-equal to the canonical token. Scanner (`npm run check:phase-id-drift`) is now green. Co-Authored-By: Claude Opus 4.8 --- src/audit.cts | 3 +++ src/init.cts | 20 ++++++++++++-------- src/milestone.cts | 4 ++-- src/phase.cts | 19 +++++++++++++------ src/roadmap-command-router.cts | 1 + src/roadmap-parser.cts | 1 + src/roadmap-upgrade.cts | 18 ++++++++++++------ src/roadmap.cts | 2 ++ src/state.cts | 5 +++-- src/uat.cts | 1 + src/validate.cts | 4 ++-- src/verify.cts | 12 ++++++++---- 12 files changed, 60 insertions(+), 30 deletions(-) diff --git a/src/audit.cts b/src/audit.cts index 41bf72a93..c7bf2f983 100644 --- a/src/audit.cts +++ b/src/audit.cts @@ -482,6 +482,7 @@ function scanUatGaps(planDir: string): UatGapItem[] { for (const dir of dirs) { const phaseDir = path.join(phasesDir, dir); + // phase-id-owner: cosmetic phase label derived from a dir name for JSON output; the single-segment capture is not equivalent to extractPhaseToken dash-continuation semantics, so not a behavior-preserving drop-in. const phaseMatch = dir.match(/^(\d+[A-Z]?(?:\.\d+)*)/i); const phaseNum = phaseMatch ? phaseMatch[1] : dir; @@ -552,6 +553,7 @@ function scanVerificationGaps(planDir: string): VerificationGapItem[] { for (const dir of dirs) { const phaseDir = path.join(phasesDir, dir); + // phase-id-owner: cosmetic phase label derived from a dir name for JSON output; the single-segment capture is not equivalent to extractPhaseToken dash-continuation semantics, so not a behavior-preserving drop-in. const phaseMatch = dir.match(/^(\d+[A-Z]?(?:\.\d+)*)/i); const phaseNum = phaseMatch ? phaseMatch[1] : dir; @@ -614,6 +616,7 @@ function scanContextQuestions(planDir: string): ContextQuestionItem[] { for (const dir of dirs) { const phaseDir = path.join(phasesDir, dir); + // phase-id-owner: cosmetic phase label derived from a dir name for JSON output; the single-segment capture is not equivalent to extractPhaseToken dash-continuation semantics, so not a behavior-preserving drop-in. const phaseMatch = dir.match(/^(\d+[A-Z]?(?:\.\d+)*)/i); const phaseNum = phaseMatch ? phaseMatch[1] : dir; diff --git a/src/init.cts b/src/init.cts index ee6e7ffa2..ab6bcafb1 100644 --- a/src/init.cts +++ b/src/init.cts @@ -73,7 +73,7 @@ const { extractCurrentMilestone, } = roadmapParser; const { pathExistsInternal, generateSlugInternal, toPosixPath } = coreUtils; -const { normalizePhaseName, phaseTokenMatches, stripProjectCodePrefix } = phaseId; +const { normalizePhaseName, phaseTokenMatches, stripProjectCodePrefix, PHASE_NUMBER_TOKEN_SOURCE } = phaseId; const { pruneOrphanedWorktrees } = worktreeSafety; const { @@ -1162,7 +1162,7 @@ function cmdInitMilestoneOp(cwd: string, raw: boolean): void { const roadmapRaw = fs.readFileSync(roadmapPath, 'utf-8'); const currentSection = extractCurrentMilestone(roadmapRaw, cwd); // #1729: `(?:\s*\([^)\n]*\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE). - const phasePattern = /#{2,4}\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)(?:\s*\([^)\n]*\))?\s*:/gi; + const phasePattern = new RegExp(`#{2,4}\\s*Phase\\s+(${PHASE_NUMBER_TOKEN_SOURCE})(?:\\s*\\([^)\\n]*\\))?\\s*:`, 'gi'); let m: RegExpExecArray | null; while ((m = phasePattern.exec(currentSection)) !== null) { if (/^999(?:\.|$)/.test(m[1])) continue; @@ -1181,6 +1181,7 @@ function cmdInitMilestoneOp(cwd: string, raw: boolean): void { const entries = fs.readdirSync(phasesDir, { withFileTypes: true }); for (const e of entries) { if (!e.isDirectory()) continue; + // phase-id-owner: dir-name phase-token parse; extractPhaseToken dash-separated sub-phase semantics differ, so a token-source swap would risk remapping phase<->directory matches. Kept local. const m = stripProjectCodePrefix(e.name).match(/^(\d+[A-Z]?(?:\.\d+)*)/); if (!m) continue; diskPhaseDirs.set(canonicalizePhase(m[1]), e.name); @@ -1319,14 +1320,14 @@ function cmdInitManager(cwd: string, raw: boolean): void { })(); const _checkboxStates = new Map(); - const _cbPattern = /-\s*\[(x| )\]\s*.*Phase\s+(\d+[A-Z]?(?:\.\d+)*)[:\s]/gi; + const _cbPattern = new RegExp(`-\\s*\\[(x| )\\]\\s*.*Phase\\s+(${PHASE_NUMBER_TOKEN_SOURCE})[:\\s]`, 'gi'); let _cbMatch: RegExpExecArray | null; while ((_cbMatch = _cbPattern.exec(content)) !== null) { _checkboxStates.set(_cbMatch[2], _cbMatch[1].toLowerCase() === 'x'); } // #1729: `(?:\s*\([^)\n]*\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE). - const phasePattern = /#{2,4}\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)(?:\s*\([^)\n]*\))?\s*:\s*([^\n]+)/gi; + const phasePattern = new RegExp(`#{2,4}\\s*Phase\\s+(${PHASE_NUMBER_TOKEN_SOURCE})(?:\\s*\\([^)\\n]*\\))?\\s*:\\s*([^\\n]+)`, 'gi'); const phases: Record[] = []; let match: RegExpExecArray | null; @@ -1465,7 +1466,7 @@ function cmdInitManager(cwd: string, raw: boolean): void { ); const phaseMap = new Map(phases.map((p) => [normalizePhaseNumber(p['number'] as string), p])); - const _allCompletedPattern = /-\s*\[x\]\s*.*Phase\s+(\d+[A-Z]?(?:\.\d+)*)[:\s]/gi; + const _allCompletedPattern = new RegExp(`-\\s*\\[x\\]\\s*.*Phase\\s+(${PHASE_NUMBER_TOKEN_SOURCE})[:\\s]`, 'gi'); let _allMatch: RegExpExecArray | null; while ((_allMatch = _allCompletedPattern.exec(rawContent)) !== null) { const phaseNum = normalizePhaseNumber(_allMatch[1]); @@ -1499,7 +1500,7 @@ function cmdInitManager(cwd: string, raw: boolean): void { ) { phase['deps_satisfied'] = true; } else { - const depNums = (phase['depends_on'] as string).match(/\d+[A-Z]?(?:\.\d+)*/gi) || []; + const depNums = (phase['depends_on'] as string).match(new RegExp(`${PHASE_NUMBER_TOKEN_SOURCE}`, 'gi')) || []; phase['deps_satisfied'] = depNums.every((n) => completedNums.has(normalizePhaseNumber(n))); phase['dep_phases'] = depNums; } @@ -1689,13 +1690,13 @@ function cmdInitProgress(cwd: string, raw: boolean): void { cwd, ); // #1729: `(?:\s*\([^)\n]*\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE). - const headingPattern = /#{2,4}\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)(?:\s*\([^)\n]*\))?\s*:\s*([^\n]+)/gi; + const headingPattern = new RegExp(`#{2,4}\\s*Phase\\s+(${PHASE_NUMBER_TOKEN_SOURCE})(?:\\s*\\([^)\\n]*\\))?\\s*:\\s*([^\\n]+)`, 'gi'); let hm: RegExpExecArray | null; while ((hm = headingPattern.exec(roadmapContent)) !== null) { roadmapPhaseNums.add(hm[1]); roadmapPhaseNames.set(hm[1], hm[2].replace(/\(INSERTED\)/i, '').trim()); } - const cbPattern = /-\s*\[(x| )\]\s*.*Phase\s+(\d+[A-Z]?(?:\.\d+)*)[:\s]/gi; + const cbPattern = new RegExp(`-\\s*\\[(x| )\\]\\s*.*Phase\\s+(${PHASE_NUMBER_TOKEN_SOURCE})[:\\s]`, 'gi'); let cbm: RegExpExecArray | null; while ((cbm = cbPattern.exec(roadmapContent)) !== null) { roadmapCheckboxStates.set(cbm[2], cbm[1].toLowerCase() === 'x'); @@ -1714,13 +1715,16 @@ function cmdInitProgress(cwd: string, raw: boolean): void { .map((e) => e.name) .filter(isDirInMilestone) .sort((a, b) => { + // phase-id-owner: dir-name phase-token parse; extractPhaseToken dash-separated sub-phase semantics differ, so a token-source swap would risk remapping phase<->directory matches. Kept local. const pa = a.match(/^(\d+[A-Z]?(?:\.\d+)*)/i); + // phase-id-owner: dir-name phase-token parse; extractPhaseToken dash-separated sub-phase semantics differ, so a token-source swap would risk remapping phase<->directory matches. Kept local. const pb = b.match(/^(\d+[A-Z]?(?:\.\d+)*)/i); if (!pa || !pb) return a.localeCompare(b); return parseInt(pa[1], 10) - parseInt(pb[1], 10); }); for (const dir of dirs) { + // phase-id-owner: dir-name phase-token parse; extractPhaseToken dash-separated sub-phase semantics differ, so a token-source swap would risk remapping phase<->directory matches. Kept local. const dirMatch = dir.match(/^(\d+[A-Z]?(?:\.\d+)*)-?(.*)/i); const phaseNumber = dirMatch ? dirMatch[1] : dir; const phaseName = dirMatch && dirMatch[2] ? dirMatch[2] : null; diff --git a/src/milestone.cts b/src/milestone.cts index cafb7717a..3d4fcb5de 100644 --- a/src/milestone.cts +++ b/src/milestone.cts @@ -23,7 +23,7 @@ import ioMod = require('./io.cjs'); const { output, error } = ioMod; // eslint-disable-next-line @typescript-eslint/no-require-imports import phaseIdMod = require('./phase-id.cjs'); -const { escapeRegex, normalizePhaseName, phaseTokenMatches } = phaseIdMod; +const { escapeRegex, normalizePhaseName, phaseTokenMatches, PHASE_NUMBER_TOKEN_SOURCE } = phaseIdMod; // eslint-disable-next-line @typescript-eslint/no-require-imports import roadmapParserMod = require('./roadmap-parser.cjs'); const { getMilestonePhaseFilter, extractCurrentMilestone, getMilestoneInfo } = roadmapParserMod; @@ -177,7 +177,7 @@ function cmdMilestoneComplete(cwd: string, version: string, options: MilestoneCo const roadmapContent = fs.readFileSync(roadmapPath, 'utf-8'); const scopedContent = extractCurrentMilestone(roadmapContent, cwd); // #1729: `(?:\s*\([^)\n]*\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE). - const phasePattern = /#{2,4}\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)(?:\s*\([^)\n]*\))?\s*:\s*([^\n]+)/gi; + const phasePattern = new RegExp(`#{2,4}\\s*Phase\\s+(${PHASE_NUMBER_TOKEN_SOURCE})(?:\\s*\\([^)\\n]*\\))?\\s*:\\s*([^\\n]+)`, 'gi'); const noDirectoryPhases: string[] = []; let pm: RegExpExecArray | null; const phaseDirEntries = ((): string[] => { diff --git a/src/phase.cts b/src/phase.cts index deb41fe4b..6de08a4d1 100644 --- a/src/phase.cts +++ b/src/phase.cts @@ -37,6 +37,7 @@ const { phaseTokenMatches, OPTIONAL_PROJECT_CODE_PREFIX_SOURCE, OPTIONAL_PHASE_TAG_SOURCE, + PHASE_NUMBER_TOKEN_SOURCE, } = phaseIdMod; // eslint-disable-next-line @typescript-eslint/no-require-imports -- phase-locator.cjs is an export= CommonJS module import phaseLocatorMod = require('./phase-locator.cjs'); @@ -374,8 +375,9 @@ function cmdFindPhase(cwd: string, phase: string, raw: boolean): void { if (!match) continue; const dirMatch = - match.match(new RegExp(`^${OPTIONAL_PROJECT_CODE_PREFIX_SOURCE}(\\d+[A-Z]?(?:\\.\\d+)*)-?(.*)`, 'i')) || - match.match(/^(\d+[A-Z]?(?:\.\d+)*)-?(.*)/i); + match.match( + new RegExp(`^${OPTIONAL_PROJECT_CODE_PREFIX_SOURCE}(${PHASE_NUMBER_TOKEN_SOURCE})-?(.*)`, 'i') + ) || match.match(new RegExp(`^(${PHASE_NUMBER_TOKEN_SOURCE})-?(.*)`, 'i')); const phaseNumber = dirMatch ? dirMatch[1] : normalized; const phaseName = dirMatch && dirMatch[2] ? dirMatch[2] : null; @@ -1672,7 +1674,7 @@ function cmdPhaseComplete(cwd: string, phaseNum: string, raw: boolean): void { .sort((a, b) => comparePhaseNum(a, b)); for (const dir of dirs) { - const dm = dir.match(/^(\d+[A-Z]?(?:\.\d+)*)-?(.*)/i); + const dm = dir.match(new RegExp(`^(${PHASE_NUMBER_TOKEN_SOURCE})-?(.*)`, 'i')); if (dm) { if (/^999(?:\.|$)/.test(dm[1])) continue; if (comparePhaseNum(dm[1], phaseNum) > 0) { @@ -1705,7 +1707,10 @@ function cmdPhaseComplete(cwd: string, phaseNum: string, raw: boolean): void { // #1729: `(?:\s*\([^)\n]*\))?` after the number tolerates a pre-colon // ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE) so // `### Phase N (Cluster B): X` resolves. Captures are unchanged. - const phasePattern = /(?:#{2,4}|-\s*\[[ xX]\])\s*(?:\*\*|__)?\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)(?:\s*\([^)\n]*\))?\s*:\s*([^\n*]+)/gi; + const phasePattern = new RegExp( + `(?:#{2,4}|-\\s*\\[[ xX]\\])\\s*(?:\\*\\*|__)?\\s*Phase\\s+(${PHASE_NUMBER_TOKEN_SOURCE})(?:\\s*\\([^)\\n]*\\))?\\s*:\\s*([^\\n*]+)`, + 'gi' + ); let pm: RegExpExecArray | null; while ((pm = phasePattern.exec(roadmapForPhases)) !== null) { if (comparePhaseNum(pm[1], phaseNum) > 0) { @@ -1741,8 +1746,10 @@ function cmdPhaseComplete(cwd: string, phaseNum: string, raw: boolean): void { if (isLastPhase && roadmapContent !== null) { try { const milestoneScope = extractCurrentMilestone(roadmapContent, cwd); - const cbPattern = - /-\s*\[(x| )\]\s*(?:\*\*|__)?\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)(?:\s*\([^)\n]*\))?\s*:\s*([^\n*]+)/gi; + const cbPattern = new RegExp( + `-\\s*\\[(x| )\\]\\s*(?:\\*\\*|__)?\\s*Phase\\s+(${PHASE_NUMBER_TOKEN_SOURCE})(?:\\s*\\([^)\\n]*\\))?\\s*:\\s*([^\\n*]+)`, + 'gi' + ); let cbm: RegExpExecArray | null; let lowestOutstanding: { num: string; name: string } | null = null; while ((cbm = cbPattern.exec(milestoneScope)) !== null) { diff --git a/src/roadmap-command-router.cts b/src/roadmap-command-router.cts index 97f6a0090..2e23b7318 100644 --- a/src/roadmap-command-router.cts +++ b/src/roadmap-command-router.cts @@ -73,6 +73,7 @@ function checkW021(content: string): W021Warning[] { // #1729: `(?:\s*\([^)\n]*\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE). const PHASE_RE = /^#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+(\d+)-(\d+)(?:-\d+)*(?:\s*\([^)\n]*\))?\s*:/i; // Unprefixed legacy phase heading: ### Phase N: Name (no hyphen sub-index) + // phase-id-owner: UNPREFIXED_PHASE_RE token uses the [A-Za-z] case-variant (identical to the canonical [A-Z] token under /i); kept literal, not source-byte-equal to PHASE_NUMBER_TOKEN_SOURCE. const UNPREFIXED_PHASE_RE = /^#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+(\d+[A-Za-z]?(?:\.\d+)*)(?:\s*\([^)\n]*\))?\s*:/i; let currentMilestoneMajor: number | null = null; diff --git a/src/roadmap-parser.cts b/src/roadmap-parser.cts index f2ab3f648..8647da8d1 100644 --- a/src/roadmap-parser.cts +++ b/src/roadmap-parser.cts @@ -459,6 +459,7 @@ function getMilestonePhaseFilter(cwd: string, versionOverride?: string | null, p // the milestone as a bogus "46-6" id. const numericRe = roadmapUsesHyphenedIds ? /^0*(\d+(?:-\d{2,})*[A-Za-z]?(?:\.\d+)*)/ + // phase-id-owner: [A-Za-z] case-variant token (identical under /i); kept literal, not source-byte-equal to the canonical PHASE_NUMBER_TOKEN_SOURCE. : /^0*(\d+[A-Za-z]?(?:\.\d+)*)/; function isDirInMilestone(dirName: string): boolean { diff --git a/src/roadmap-upgrade.cts b/src/roadmap-upgrade.cts index 3487a0334..e7202a495 100644 --- a/src/roadmap-upgrade.cts +++ b/src/roadmap-upgrade.cts @@ -16,13 +16,16 @@ import planningWorkspace = require('./planning-workspace.cjs'); // eslint-disable-next-line @typescript-eslint/no-require-imports import phaseIdMod = require('./phase-id.cjs'); const { planningDir } = planningWorkspace; -const { stripProjectCodePrefix } = phaseIdMod; +const { stripProjectCodePrefix, PHASE_NUMBER_TOKEN_SOURCE } = phaseIdMod; // ─── Regex helpers ──────────────────────────────────────────────────────────── // Matches legacy phase headings: ### Phase N: Name (also decimal: Phase 2.1:) // Captures: (hashes)(spaces)(phase-number)(rest-of-line) -const LEGACY_PHASE_HEADING_RE = /^(#{2,4})\s*(?:\[[^\]]+\]\s*)?Phase\s+(\d+[A-Z]?(?:\.\d+)*)\s*:(.*)/i; +const LEGACY_PHASE_HEADING_RE = new RegExp( + `^(#{2,4})\\s*(?:\\[[^\\]]+\\]\\s*)?Phase\\s+(${PHASE_NUMBER_TOKEN_SOURCE})\\s*:(.*)`, + 'i' +); // Matches already-migrated phase headings: ### Phase M-NN: Name const MIGRATED_PHASE_HEADING_RE = /^#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+\d+-\d{2}\s*:/i; @@ -172,6 +175,7 @@ function extractPhaseNumFromDir(dirName: string): string | null { const stripped = stripProjectCodePrefix(dirName); // Matches: digits + optional letter + optional decimal suffix, followed by '-' or end. // e.g. "02.1-hotfix" → "02.1", "01-setup" → "01" + // phase-id-owner: strips a leading phase number from a dir name; extractPhaseToken returns the project-code-prefixed token, so it is not a behavior-preserving drop-in. const m = stripped.match(/^(\d+[A-Z]?(?:\.\d+)*)(?:-|$)/i); return m ? m[1] : null; } @@ -188,7 +192,7 @@ function buildNewDirName(oldDirName: string, newId: string, projectCode: string const stripped = stripProjectCodePrefix(oldDirName); // Extract slug: everything after "NN-" (the old phase num, including decimal like 02.1) - const slugMatch = stripped.match(/^\d+[A-Z]?(?:\.\d+)*-(.*)/i); + const slugMatch = stripped.match(new RegExp(`^${PHASE_NUMBER_TOKEN_SOURCE}-(.*)`, 'i')); const slug = slugMatch ? slugMatch[1] : stripped; // Build M-NN prefix (zero-pad both parts) @@ -341,7 +345,7 @@ function computeMigrationPlan(cwd: string, options: Record = {} // Rewrite heading line: "### Phase N: Name" → "### Phase M-NN: Name" const oldLine = lines[entry.lineIndex]; const newLine = oldLine.replace( - /^(#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+)\d+[A-Z]?(?:\.\d+)*(\s*:)/i, + new RegExp(`^(#{2,4}\\s*(?:\\[[^\\]]+\\]\\s*)?Phase\\s+)${PHASE_NUMBER_TOKEN_SOURCE}(\\s*:)`, 'i'), `$1${mapping.newId}$2` ); if (newLine !== oldLine) { @@ -364,7 +368,9 @@ function computeMigrationPlan(cwd: string, options: Record = {} if (roadmapEdits.some(e => e.lineIndex === i)) continue; // Match checklist items: "- [ ] **Phase N:**" or "- [x] Phase N:" (also decimal) - const checklistMatch = line.match(/^(\s*-\s*\[[ x]\]\s*\*{0,2}Phase\s+)(\d+[A-Z]?(?:\.\d+)*)(\s*[:\s*])/i); + const checklistMatch = line.match( + new RegExp(`^(\\s*-\\s*\\[[ x]\\]\\s*\\*{0,2}Phase\\s+)(${PHASE_NUMBER_TOKEN_SOURCE})(\\s*[:\\s*])`, 'i') + ); if (checklistMatch) { const legacyNum = checklistMatch[2]; const cIntPart = parseInt(legacyNum, 10); @@ -393,7 +399,7 @@ function computeMigrationPlan(cwd: string, options: Record = {} if (newId) { const newLine = line.replace( - /^(\s*-\s*\[[ x]\]\s*\*{0,2}Phase\s+)\d+[A-Z]?(?:\.\d+)*(\s*[:\s*])/i, + new RegExp(`^(\\s*-\\s*\\[[ x]\\]\\s*\\*{0,2}Phase\\s+)${PHASE_NUMBER_TOKEN_SOURCE}(\\s*[:\\s*])`, 'i'), `$1${newId}$2` ); if (newLine !== line) { diff --git a/src/roadmap.cts b/src/roadmap.cts index 27f0e36c3..168dad3cb 100644 --- a/src/roadmap.cts +++ b/src/roadmap.cts @@ -298,6 +298,7 @@ function cmdRoadmapAnalyze(cwd: string, raw: boolean): void { // Extract all phase headings: ## Phase N: Name or ### Phase N: Name // #1729: `(?:\s*\([^)\n]*\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE). + // phase-id-owner: uses the [.-] (dot-or-dash) separator variant, not the canonical dot-only token; a swap to PHASE_NUMBER_TOKEN_SOURCE would drop hyphenated phase-id matches. const phasePattern = /#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+(\d+[A-Z]?(?:[.-]\d+)*)(?:\s*\([^)\n]*\))?\s*:\s*([^\n]+)/gi; const phases: Array<{ number: string; @@ -437,6 +438,7 @@ function cmdRoadmapAnalyze(cwd: string, raw: boolean): void { // The char class must allow `-` (not just `.`) so dash-separated milestone-prefixed // IDs (e.g. `1-01`) match the detail-heading scanner above; otherwise they truncate // at the dash (`1-01` -> `1`) and every such phase reports a phantom missing detail. + // phase-id-owner: uses the [.-] (dot-or-dash) separator variant, not the canonical dot-only token; a swap to PHASE_NUMBER_TOKEN_SOURCE would drop hyphenated phase-id matches. const checklistPattern = /-\s*\[[ x]\]\s*\*\*Phase\s+(\d+[A-Z]?(?:[.-]\d+)*)/gi; const checklistPhases = new Set(); let checklistMatch: RegExpExecArray | null; diff --git a/src/state.cts b/src/state.cts index a4778f1c1..9bd235342 100644 --- a/src/state.cts +++ b/src/state.cts @@ -16,7 +16,7 @@ import configLoaderMod = require('./config-loader.cjs'); const { loadConfig } = configLoaderMod; // eslint-disable-next-line @typescript-eslint/no-require-imports import phaseIdMod = require('./phase-id.cjs'); -const { escapeRegex, normalizePhaseName, extractPhaseToken, parsePhaseFromProse } = phaseIdMod; +const { escapeRegex, normalizePhaseName, extractPhaseToken, parsePhaseFromProse, PHASE_NUMBER_TOKEN_SOURCE } = phaseIdMod; // eslint-disable-next-line @typescript-eslint/no-require-imports import roadmapParserMod = require('./roadmap-parser.cjs'); const { getMilestoneInfo, getMilestonePhaseFilter, extractCurrentMilestone } = roadmapParserMod; @@ -1406,6 +1406,7 @@ function buildStateFrontmatter(bodyContent: string, cwd: string | undefined): Re // neither the denominator nor the numerator (mirrors the heading // exclusion below). Project-code-aware via phaseKeyFromDir. if (retiredPhaseNums.size > 0 && retiredPhaseNums.has(phaseKeyFromDir(dir))) continue; + // phase-id-owner: dir-name dedup grouping; diverges from extractPhaseToken/phaseKeyFromDir on project-code-prefixed and multi-segment milestone dirs. Kept local. const m = dir.match(/^0*(\d+[A-Za-z]?(?:\.\d+)*)/); const key = m ? m[1].toLowerCase() : dir; if (!seenPhaseNums.has(key)) { @@ -2394,7 +2395,7 @@ function cmdStateSync(cwd: string, options: StateSyncOptions | undefined, raw: b if (completed) diskCompletedPhases++; // Track the highest phase with incomplete plans (or any plans) - const phaseMatch = dir.match(/^(\d+[A-Z]?(?:\.\d+)*)/i); + const phaseMatch = dir.match(new RegExp(`^(${PHASE_NUMBER_TOKEN_SOURCE})`, 'i')); if (phaseMatch && plans > 0) { if (summaries < plans) { // Incomplete phase — this is likely the current one diff --git a/src/uat.cts b/src/uat.cts index 9846f45bb..c5c3900ab 100644 --- a/src/uat.cts +++ b/src/uat.cts @@ -82,6 +82,7 @@ function cmdAuditUat(cwd: string, raw: boolean): void { .sort(); for (const dir of dirs) { + // phase-id-owner: display phase field derived from a dir name (same family as the audit.cts sites); not equivalent to extractPhaseToken for dash-form dirs. const phaseMatch = dir.match(/^(\d+[A-Z]?(?:\.\d+)*)/i); const phaseNum = phaseMatch ? phaseMatch[1] : dir; const phaseDir = path.join(phasesDir, dir); diff --git a/src/validate.cts b/src/validate.cts index 5ed32d96c..05f96bd73 100644 --- a/src/validate.cts +++ b/src/validate.cts @@ -33,7 +33,7 @@ // eslint-disable-next-line @typescript-eslint/no-require-imports import phaseIdMod = require('./phase-id.cjs'); -const { OPTIONAL_PROJECT_CODE_PREFIX_SOURCE } = phaseIdMod; +const { OPTIONAL_PROJECT_CODE_PREFIX_SOURCE, PHASE_NUMBER_TOKEN_SOURCE } = phaseIdMod; // ── Issue #26: regex constants (W005, W006-archived) ──────────────────────── // Matches legacy numeric dirs (01-setup), milestone-prefixed dirs (02-01-setup), @@ -62,7 +62,7 @@ export function canonicalPlanStem(stem: string): string { // #2043: the plan component (after the phase number) must be zero-padded // (≥2 digits), so a digit-leading slug word (e.g. "46-6-rs-…") is not mistaken // for a "46-6" phase/plan pair. - const m = stem.match(/^(\d+[A-Z]?(?:\.\d+)*-\d{2,})/i); + const m = stem.match(new RegExp(`^(${PHASE_NUMBER_TOKEN_SOURCE}-\\d{2,})`, 'i')); return m ? m[1] : stem; } diff --git a/src/verify.cts b/src/verify.cts index f7dfa924f..488598df9 100644 --- a/src/verify.cts +++ b/src/verify.cts @@ -37,7 +37,7 @@ import configLoaderMod = require('./config-loader.cjs'); const { loadConfig, CONFIG_DEFAULTS } = configLoaderMod; // eslint-disable-next-line @typescript-eslint/no-require-imports import phaseIdMod = require('./phase-id.cjs'); -const { normalizePhaseName, phaseTokenMatches, escapeRegex, getMilestoneFromPhaseId, OPTIONAL_PHASE_TAG_SOURCE } = phaseIdMod; +const { normalizePhaseName, phaseTokenMatches, escapeRegex, getMilestoneFromPhaseId, OPTIONAL_PHASE_TAG_SOURCE, PHASE_NUMBER_TOKEN_SOURCE } = phaseIdMod; // eslint-disable-next-line @typescript-eslint/no-require-imports import phaseLocatorMod = require('./phase-locator.cjs'); const { findPhaseInternal } = phaseLocatorMod; @@ -1302,14 +1302,18 @@ function cmdValidateHealth( repairs.push('regenerateState'); } else { const stateContent = fs.readFileSync(statePath, 'utf-8'); - const phaseRefs = [...stateContent.matchAll(/[Pp]hase\s+(\d+[A-Z]?(?:\.\d+)*)/g)].map( + const phaseRefs = [ + ...stateContent.matchAll(new RegExp(`[Pp]hase\\s+(${PHASE_NUMBER_TOKEN_SOURCE})`, 'g')), + ].map( (m) => m[1], ); const validPhases = collectDiskPhases(planBase); try { if (fs.existsSync(roadmapPath)) { const roadmapRaw = fs.readFileSync(roadmapPath, 'utf-8'); - const all = [...roadmapRaw.matchAll(/#{2,4}\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)/gi)]; + const all = [ + ...roadmapRaw.matchAll(new RegExp(`#{2,4}\\s*Phase\\s+(${PHASE_NUMBER_TOKEN_SOURCE})`, 'gi')), + ]; for (const m of all) validPhases.add(m[1]); } } catch { @@ -1809,7 +1813,7 @@ function cmdValidateHealth( const roadmapRaw = fs.readFileSync(roadmapPath, 'utf-8'); const scopedContent = extractCurrentMilestone(roadmapRaw, cwd); // #1729: `(?:\s*\([^)\n]*\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE). - const phasePattern = /#{2,4}\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)(?:\s*\([^)\n]*\))?\s*:\s*([^\n]+)/gi; + const phasePattern = new RegExp(`#{2,4}\\s*Phase\\s+(${PHASE_NUMBER_TOKEN_SOURCE})(?:\\s*\\([^)\\n]*\\))?\\s*:\\s*([^\\n]+)`, 'gi'); const unstarted: string[] = []; let pm: RegExpExecArray | null; // Non-hoisted: load-order matters (circular dep guard) From e2eaa5b04637fe34b33ec062aedaec7eaae9be23 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Fri, 10 Jul 2026 09:14:15 -0400 Subject: [PATCH 24/31] =?UTF-8?q?fix(#2128):=20address=20review=20?= =?UTF-8?q?=E2=80=94=20migrate=209=20mis-allowlisted=20sites,=20harden=20s?= =?UTF-8?q?canner=20+=20guards?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Correctness review of the Phase 4 guard found the allowlist over-broad and the scanner/guards evadable. Fixed all findings: - Migrate 9 sites that were wrongly sanctioned: their regex is the PURE canonical token (`\d+[A-Z]?(?:\.\d+)*`, no variant), byte-identical to already-migrated siblings. The old justification argued against swapping to the extractPhaseToken() FUNCTION (behavior-risky) — but the guard only wants the same regex built from the SOURCE string (byte-equal, zero risk). Coverage is now 32 migrated / 5 sanctioned, not the overstated 23 / 14 (audit.cts x3, uat.cts, init.cts x4, roadmap-upgrade.cts). Each conversion proven byte-equal (.source + .flags). - Harden the drift detector: also catch the `[0-9]`-in-place-of-`\d` variant; document the accepted limits (cross-line split, semantic restructuring — covered by the identity guard + review, not a text scan). - Sanction robustness: a `phase-id-owner:` marker now counts only inside a `//` comment (a bare substring in a string no longer suppresses a real flag), and the preceding-line window skips blank lines (an auto-formatter's blank line no longer reactivates the flag). - roadmap-parser.cts:462 comment: corrected — that regex carries no /i flag, so its [A-Za-z] class does real case work (matches state.cts:1409's rationale). - Identity guard: surface require failures instead of silently skipping, and floor coverage at >75% of consumer modules (inspects 156/157). Co-Authored-By: Claude Opus 4.8 --- scripts/lint-phase-id-drift.cjs | 34 ++++++++++++++++++++--------- src/audit.cts | 12 +++++----- src/init.cts | 12 ++++------ src/roadmap-parser.cts | 2 +- src/roadmap-upgrade.cts | 3 +-- src/uat.cts | 6 +++-- tests/phase-id-drift-guard.test.cjs | 34 +++++++++++++++++++++++------ 7 files changed, 67 insertions(+), 36 deletions(-) diff --git a/scripts/lint-phase-id-drift.cjs b/scripts/lint-phase-id-drift.cjs index 33cecc3cd..3516814e1 100644 --- a/scripts/lint-phase-id-drift.cjs +++ b/scripts/lint-phase-id-drift.cjs @@ -34,19 +34,31 @@ const path = require('node:path'); // The canonical phase-number token as it appears in SOURCE TEXT: // \d+[A-Z]?(?:\.\d+)* in a regex literal -> one backslash before d/. // \\d+[A-Z]?(?:\\.\\d+)* in a template string -> two backslashes -// Also tolerate the [A-Za-z] letter-class and the [.-] (dot-or-dash) separator -// near-variants that a few enumeration call sites use. -const TOKEN_DRIFT_RE = /\\{1,2}d\+\[A-Z(?:a-z)?\]\??\(\?:(?:\\{1,2}\.|\[\.-\])\\{1,2}d\+\)\*/; +// Tolerated near-variants so a trivial rewrite does not silently evade the guard: +// digit class \d \\d or [0-9] +// letter class [A-Z] or [A-Za-z] +// sub-phase sep \. \\. or [.-] (dot-or-dash) +// KNOWN, ACCEPTED limits of a per-line textual scan (covered instead by the +// identity guard + code review, not by this regex): a re-derivation split +// across lines via string concatenation, a capturing `(\.\d+)*` in place of the +// non-capturing group, or a semantically-equivalent restructuring. This guard +// targets the common case — an accidental copy of the exact grammar — not an +// adversary deliberately obfuscating a re-derivation. +const TOKEN_DRIFT_RE = /(?:\\{1,2}d|\[0-9\])\+\[A-Z(?:a-z)?\]\??\(\?:(?:\\{1,2}\.|\[\.-\])(?:\\{1,2}d|\[0-9\])\+\)\*/; -const OWNER_MARK = 'phase-id-owner:'; +// A `phase-id-owner:` sanction only counts inside a `//` line comment — a bare +// substring in a string literal or identifier must NOT suppress a real flag. +const OWNER_RE = /\/\/[^\n]*phase-id-owner:/; const CANON_REF = 'PHASE_NUMBER_TOKEN_SOURCE'; /** * Pure: find every literal re-derivation of the canonical phase-number token in - * `text` that is NOT sanctioned. A site is sanctioned when its line — or the - * line directly above it — contains `// phase-id-owner:`, or when the line - * references `PHASE_NUMBER_TOKEN_SOURCE` (i.e. it is built from the canonical - * source, not a literal). Returns [{ line, found }]. + * `text` that is NOT sanctioned. A site is sanctioned when its own line — or the + * nearest preceding NON-BLANK line (so an auto-formatter's blank line between a + * `// phase-id-owner:` comment and its regex does not reactivate the flag) — + * carries a `// phase-id-owner:` comment, or when the line references + * `PHASE_NUMBER_TOKEN_SOURCE` (built from the canonical source, not a literal). + * Returns [{ line, found }]. */ function findPhaseIdRegexDrift(text) { const out = []; @@ -55,9 +67,11 @@ function findPhaseIdRegexDrift(text) { const line = lines[i]; const m = TOKEN_DRIFT_RE.exec(line); if (!m) continue; - if (line.includes(OWNER_MARK)) continue; - if (i > 0 && lines[i - 1].includes(OWNER_MARK)) continue; + if (OWNER_RE.test(line)) continue; if (line.includes(CANON_REF)) continue; + let j = i - 1; + while (j >= 0 && lines[j].trim() === '') j--; // nearest preceding non-blank line + if (j >= 0 && OWNER_RE.test(lines[j])) continue; out.push({ line: i + 1, found: m[0] }); } return out; diff --git a/src/audit.cts b/src/audit.cts index c7bf2f983..f9a500b9a 100644 --- a/src/audit.cts +++ b/src/audit.cts @@ -20,6 +20,9 @@ const { planningDir } = planningWorkspace; // eslint-disable-next-line @typescript-eslint/no-require-imports import frontmatter = require('./frontmatter.cjs'); const { extractFrontmatter } = frontmatter; +// eslint-disable-next-line @typescript-eslint/no-require-imports +import phaseIdMod = require('./phase-id.cjs'); +const { PHASE_NUMBER_TOKEN_SOURCE } = phaseIdMod; import { requireSafePath, sanitizeForDisplay } from './security.cjs'; // ─── Types ──────────────────────────────────────────────────────────────────── @@ -482,8 +485,7 @@ function scanUatGaps(planDir: string): UatGapItem[] { for (const dir of dirs) { const phaseDir = path.join(phasesDir, dir); - // phase-id-owner: cosmetic phase label derived from a dir name for JSON output; the single-segment capture is not equivalent to extractPhaseToken dash-continuation semantics, so not a behavior-preserving drop-in. - const phaseMatch = dir.match(/^(\d+[A-Z]?(?:\.\d+)*)/i); + const phaseMatch = dir.match(new RegExp(`^(${PHASE_NUMBER_TOKEN_SOURCE})`, 'i')); const phaseNum = phaseMatch ? phaseMatch[1] : dir; let files: string[]; @@ -553,8 +555,7 @@ function scanVerificationGaps(planDir: string): VerificationGapItem[] { for (const dir of dirs) { const phaseDir = path.join(phasesDir, dir); - // phase-id-owner: cosmetic phase label derived from a dir name for JSON output; the single-segment capture is not equivalent to extractPhaseToken dash-continuation semantics, so not a behavior-preserving drop-in. - const phaseMatch = dir.match(/^(\d+[A-Z]?(?:\.\d+)*)/i); + const phaseMatch = dir.match(new RegExp(`^(${PHASE_NUMBER_TOKEN_SOURCE})`, 'i')); const phaseNum = phaseMatch ? phaseMatch[1] : dir; let files: string[]; @@ -616,8 +617,7 @@ function scanContextQuestions(planDir: string): ContextQuestionItem[] { for (const dir of dirs) { const phaseDir = path.join(phasesDir, dir); - // phase-id-owner: cosmetic phase label derived from a dir name for JSON output; the single-segment capture is not equivalent to extractPhaseToken dash-continuation semantics, so not a behavior-preserving drop-in. - const phaseMatch = dir.match(/^(\d+[A-Z]?(?:\.\d+)*)/i); + const phaseMatch = dir.match(new RegExp(`^(${PHASE_NUMBER_TOKEN_SOURCE})`, 'i')); const phaseNum = phaseMatch ? phaseMatch[1] : dir; let files: string[]; diff --git a/src/init.cts b/src/init.cts index ab6bcafb1..13b2a890c 100644 --- a/src/init.cts +++ b/src/init.cts @@ -1181,8 +1181,7 @@ function cmdInitMilestoneOp(cwd: string, raw: boolean): void { const entries = fs.readdirSync(phasesDir, { withFileTypes: true }); for (const e of entries) { if (!e.isDirectory()) continue; - // phase-id-owner: dir-name phase-token parse; extractPhaseToken dash-separated sub-phase semantics differ, so a token-source swap would risk remapping phase<->directory matches. Kept local. - const m = stripProjectCodePrefix(e.name).match(/^(\d+[A-Z]?(?:\.\d+)*)/); + const m = stripProjectCodePrefix(e.name).match(new RegExp(`^(${PHASE_NUMBER_TOKEN_SOURCE})`)); if (!m) continue; diskPhaseDirs.set(canonicalizePhase(m[1]), e.name); } @@ -1715,17 +1714,14 @@ function cmdInitProgress(cwd: string, raw: boolean): void { .map((e) => e.name) .filter(isDirInMilestone) .sort((a, b) => { - // phase-id-owner: dir-name phase-token parse; extractPhaseToken dash-separated sub-phase semantics differ, so a token-source swap would risk remapping phase<->directory matches. Kept local. - const pa = a.match(/^(\d+[A-Z]?(?:\.\d+)*)/i); - // phase-id-owner: dir-name phase-token parse; extractPhaseToken dash-separated sub-phase semantics differ, so a token-source swap would risk remapping phase<->directory matches. Kept local. - const pb = b.match(/^(\d+[A-Z]?(?:\.\d+)*)/i); + const pa = a.match(new RegExp(`^(${PHASE_NUMBER_TOKEN_SOURCE})`, 'i')); + const pb = b.match(new RegExp(`^(${PHASE_NUMBER_TOKEN_SOURCE})`, 'i')); if (!pa || !pb) return a.localeCompare(b); return parseInt(pa[1], 10) - parseInt(pb[1], 10); }); for (const dir of dirs) { - // phase-id-owner: dir-name phase-token parse; extractPhaseToken dash-separated sub-phase semantics differ, so a token-source swap would risk remapping phase<->directory matches. Kept local. - const dirMatch = dir.match(/^(\d+[A-Z]?(?:\.\d+)*)-?(.*)/i); + const dirMatch = dir.match(new RegExp(`^(${PHASE_NUMBER_TOKEN_SOURCE})-?(.*)`, 'i')); const phaseNumber = dirMatch ? dirMatch[1] : dir; const phaseName = dirMatch && dirMatch[2] ? dirMatch[2] : null; seenPhaseNums.add(phaseNumber.replace(/^0+/, '') || '0'); diff --git a/src/roadmap-parser.cts b/src/roadmap-parser.cts index 8647da8d1..091789c48 100644 --- a/src/roadmap-parser.cts +++ b/src/roadmap-parser.cts @@ -459,7 +459,7 @@ function getMilestonePhaseFilter(cwd: string, versionOverride?: string | null, p // the milestone as a bogus "46-6" id. const numericRe = roadmapUsesHyphenedIds ? /^0*(\d+(?:-\d{2,})*[A-Za-z]?(?:\.\d+)*)/ - // phase-id-owner: [A-Za-z] case-variant token (identical under /i); kept literal, not source-byte-equal to the canonical PHASE_NUMBER_TOKEN_SOURCE. + // phase-id-owner: the [A-Za-z] letter class does real case handling here — this regex carries NO /i flag; kept literal, not source-byte-equal to the canonical PHASE_NUMBER_TOKEN_SOURCE. : /^0*(\d+[A-Za-z]?(?:\.\d+)*)/; function isDirInMilestone(dirName: string): boolean { diff --git a/src/roadmap-upgrade.cts b/src/roadmap-upgrade.cts index e7202a495..2c47995b9 100644 --- a/src/roadmap-upgrade.cts +++ b/src/roadmap-upgrade.cts @@ -175,8 +175,7 @@ function extractPhaseNumFromDir(dirName: string): string | null { const stripped = stripProjectCodePrefix(dirName); // Matches: digits + optional letter + optional decimal suffix, followed by '-' or end. // e.g. "02.1-hotfix" → "02.1", "01-setup" → "01" - // phase-id-owner: strips a leading phase number from a dir name; extractPhaseToken returns the project-code-prefixed token, so it is not a behavior-preserving drop-in. - const m = stripped.match(/^(\d+[A-Z]?(?:\.\d+)*)(?:-|$)/i); + const m = stripped.match(new RegExp(`^(${PHASE_NUMBER_TOKEN_SOURCE})(?:-|$)`, 'i')); return m ? m[1] : null; } diff --git a/src/uat.cts b/src/uat.cts index c5c3900ab..e5cd5e964 100644 --- a/src/uat.cts +++ b/src/uat.cts @@ -29,6 +29,9 @@ const { planningDir } = planningWorkspace; // eslint-disable-next-line @typescript-eslint/no-require-imports import frontmatter = require('./frontmatter.cjs'); const { extractFrontmatter } = frontmatter; +// eslint-disable-next-line @typescript-eslint/no-require-imports +import phaseIdMod = require('./phase-id.cjs'); +const { PHASE_NUMBER_TOKEN_SOURCE } = phaseIdMod; import { requireSafePath, sanitizeForDisplay } from './security.cjs'; // ─── Types ──────────────────────────────────────────────────────────────────── @@ -82,8 +85,7 @@ function cmdAuditUat(cwd: string, raw: boolean): void { .sort(); for (const dir of dirs) { - // phase-id-owner: display phase field derived from a dir name (same family as the audit.cts sites); not equivalent to extractPhaseToken for dash-form dirs. - const phaseMatch = dir.match(/^(\d+[A-Z]?(?:\.\d+)*)/i); + const phaseMatch = dir.match(new RegExp(`^(${PHASE_NUMBER_TOKEN_SOURCE})`, 'i')); const phaseNum = phaseMatch ? phaseMatch[1] : dir; const phaseDir = path.join(phasesDir, dir); const files = fs.readdirSync(phaseDir); diff --git a/tests/phase-id-drift-guard.test.cjs b/tests/phase-id-drift-guard.test.cjs index 3e20b291a..63858f23c 100644 --- a/tests/phase-id-drift-guard.test.cjs +++ b/tests/phase-id-drift-guard.test.cjs @@ -60,9 +60,10 @@ describe('#2128 phase-id drift scanner: findPhaseIdRegexDrift (pure)', () => { assert.equal(v.length, 1); }); - test('the [A-Za-z] and [.-] near-variants ARE flagged', () => { - assert.equal(findPhaseIdRegexDrift('/(\\d+[A-Za-z]?(?:\\.\\d+)*)/').length, 1); - assert.equal(findPhaseIdRegexDrift('/(\\d+[A-Z]?(?:[.-]\\d+)*)/').length, 1); + test('the [A-Za-z], [.-] and [0-9] near-variants ARE flagged (no trivial evasion)', () => { + assert.equal(findPhaseIdRegexDrift('/(\\d+[A-Za-z]?(?:\\.\\d+)*)/').length, 1, '[A-Za-z] letter class'); + assert.equal(findPhaseIdRegexDrift('/(\\d+[A-Z]?(?:[.-]\\d+)*)/').length, 1, '[.-] separator'); + assert.equal(findPhaseIdRegexDrift('/([0-9]+[A-Z]?(?:\\.[0-9]+)*)/').length, 1, '[0-9] in place of \\d'); }); test('a same-line // phase-id-owner: sanction suppresses the flag', () => { @@ -79,6 +80,18 @@ describe('#2128 phase-id drift scanner: findPhaseIdRegexDrift (pure)', () => { ); }); + test('a blank line between the // phase-id-owner: comment and the regex still suppresses', () => { + assert.deepEqual( + findPhaseIdRegexDrift('// phase-id-owner: sanctioned exception\n\nconst re = /(\\d+[A-Z]?(?:\\.\\d+)*)/;'), + [], + ); + }); + + test('a bare "phase-id-owner:" substring in a STRING (not a // comment) does NOT suppress', () => { + const v = findPhaseIdRegexDrift('const msg = "ping the phase-id-owner: for review"; const re = /(\\d+[A-Z]?(?:\\.\\d+)*)/;'); + assert.equal(v.length, 1); + }); + test('non-token phase regexes are NOT flagged (no false positives)', () => { assert.deepEqual(findPhaseIdRegexDrift('/^Executing Phase\\s+\\d+/'), [], 'status-message bare \\d+'); assert.deepEqual(findPhaseIdRegexDrift('/#{2,4}\\s*Phase\\s+(\\d+)[A-Z]?(?:\\.\\d+)*/'), [], 'digits-only capture is non-contiguous'); @@ -119,14 +132,18 @@ describe('#2128 phase-id single-owner identity guard', () => { const libDir = path.join(ROOT, 'gsd-core', 'bin', 'lib'); const consumers = fs.readdirSync(libDir).filter((f) => f.endsWith('.cjs') && f !== 'phase-id.cjs'); let checked = 0; + const requireFailures = []; for (const f of consumers) { let mod; try { mod = require(path.join(libDir, f)); - } catch { - continue; // a module that cannot be required in isolation can't re-export anything + } catch (e) { + // Surfaced, not silently skipped — a module that cannot be required + // would otherwise erode the guard's coverage without any signal. + requireFailures.push(`${f}: ${e.message}`); + continue; } - if (!mod || typeof mod !== 'object') continue; + if (!mod || typeof mod !== 'object') continue; // bare-function exports carry no named canonical member checked++; for (const name of CANONICAL) { if (Object.prototype.hasOwnProperty.call(mod, name)) { @@ -138,6 +155,9 @@ describe('#2128 phase-id single-owner identity guard', () => { } } } - assert.ok(checked > 0, 'expected to inspect at least one consumer module'); + assert.deepEqual(requireFailures, [], `consumer module(s) failed to require (guard coverage would silently degrade):\n ${requireFailures.join('\n ')}`); + // Coverage floor: the vast majority of the ~150 built lib modules export an + // object and must actually be inspected — not a token "at least one". + assert.ok(checked > consumers.length * 0.75, `expected to inspect most of the ${consumers.length} consumer modules, only inspected ${checked}`); }); }); From a1de52d71bea145085707c35874ac46c4eca140e Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Fri, 10 Jul 2026 09:24:49 -0400 Subject: [PATCH 25/31] fix(#2128): sanctions must be a dedicated // comment line (decoy-proof) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-review found the `// phase-id-owner:` suppression treated a `//` embedded in a string literal as a comment — help/doc text quoting the sanction syntax (the exact string the scanner's own main() prints) would silently suppress a real re-derivation. Require the marker to LEAD its own comment line (`^\s*//…`), so a `//` inside a string or trailing a code line never counts. All 5 real sanctions are already dedicated lines (scanRepo stays green); trailing same-line sanctions are no longer honored — put the comment on the line directly above. Co-Authored-By: Claude Opus 4.8 --- scripts/lint-phase-id-drift.cjs | 23 +++++++++++--------- tests/phase-id-drift-guard.test.cjs | 33 ++++++++++++++++++----------- 2 files changed, 34 insertions(+), 22 deletions(-) diff --git a/scripts/lint-phase-id-drift.cjs b/scripts/lint-phase-id-drift.cjs index 3516814e1..ff04bfa72 100644 --- a/scripts/lint-phase-id-drift.cjs +++ b/scripts/lint-phase-id-drift.cjs @@ -46,18 +46,21 @@ const path = require('node:path'); // adversary deliberately obfuscating a re-derivation. const TOKEN_DRIFT_RE = /(?:\\{1,2}d|\[0-9\])\+\[A-Z(?:a-z)?\]\??\(\?:(?:\\{1,2}\.|\[\.-\])(?:\\{1,2}d|\[0-9\])\+\)\*/; -// A `phase-id-owner:` sanction only counts inside a `//` line comment — a bare -// substring in a string literal or identifier must NOT suppress a real flag. -const OWNER_RE = /\/\/[^\n]*phase-id-owner:/; +// A `phase-id-owner:` sanction must be a DEDICATED `//` comment line (the marker +// as the line's leading token). A `//` or the phrase embedded in a string +// literal or trailing a code line is NOT a comment and must never suppress a real +// flag — so sanctions live on their own line directly above the regex. +const OWNER_RE = /^\s*\/\/.*phase-id-owner:/; const CANON_REF = 'PHASE_NUMBER_TOKEN_SOURCE'; /** * Pure: find every literal re-derivation of the canonical phase-number token in - * `text` that is NOT sanctioned. A site is sanctioned when its own line — or the - * nearest preceding NON-BLANK line (so an auto-formatter's blank line between a - * `// phase-id-owner:` comment and its regex does not reactivate the flag) — - * carries a `// phase-id-owner:` comment, or when the line references - * `PHASE_NUMBER_TOKEN_SOURCE` (built from the canonical source, not a literal). + * `text` that is NOT sanctioned. A site is sanctioned when the nearest preceding + * NON-BLANK line is a dedicated `// phase-id-owner:` comment (blank lines between + * the comment and the regex are tolerated, so an auto-formatter cannot reactivate + * the flag), or when the regex line references `PHASE_NUMBER_TOKEN_SOURCE` (built + * from the canonical source, not a literal). A `//`/phrase inside a string or + * trailing a code line does NOT count — put the sanction on its own line above. * Returns [{ line, found }]. */ function findPhaseIdRegexDrift(text) { @@ -67,7 +70,6 @@ function findPhaseIdRegexDrift(text) { const line = lines[i]; const m = TOKEN_DRIFT_RE.exec(line); if (!m) continue; - if (OWNER_RE.test(line)) continue; if (line.includes(CANON_REF)) continue; let j = i - 1; while (j >= 0 && lines[j].trim() === '') j--; // nearest preceding non-blank line @@ -135,7 +137,8 @@ function main() { } process.stderr.write('phase-id-drift: literal re-derivation(s) of the canonical phase-number token found.\n'); process.stderr.write('Build the regex from phase-id.cjs `PHASE_NUMBER_TOKEN_SOURCE` (or phaseMarkdownRegexSource for a\n'); - process.stderr.write('known number), or sanction the site with a `// phase-id-owner: ` comment:\n'); + process.stderr.write('known number), or sanction the site with a dedicated `// phase-id-owner: `\n'); + process.stderr.write('comment on the line directly above the regex:\n'); for (const d of violations) { process.stderr.write(` ${d.file}:${d.line} ${d.found}\n`); } diff --git a/tests/phase-id-drift-guard.test.cjs b/tests/phase-id-drift-guard.test.cjs index 63858f23c..960483dac 100644 --- a/tests/phase-id-drift-guard.test.cjs +++ b/tests/phase-id-drift-guard.test.cjs @@ -66,29 +66,38 @@ describe('#2128 phase-id drift scanner: findPhaseIdRegexDrift (pure)', () => { assert.equal(findPhaseIdRegexDrift('/([0-9]+[A-Z]?(?:\\.[0-9]+)*)/').length, 1, '[0-9] in place of \\d'); }); - test('a same-line // phase-id-owner: sanction suppresses the flag', () => { + test('a dedicated preceding // phase-id-owner: comment line suppresses the flag', () => { assert.deepEqual( - findPhaseIdRegexDrift('const re = /(\\d+[A-Z]?(?:\\.\\d+)*)/; // phase-id-owner: sanctioned exception'), - [], - ); - }); - - test('a preceding-line // phase-id-owner: sanction suppresses the flag', () => { - assert.deepEqual( - findPhaseIdRegexDrift('// phase-id-owner: sanctioned exception\nconst re = /(\\d+[A-Z]?(?:\\.\\d+)*)/;'), + findPhaseIdRegexDrift(' // phase-id-owner: sanctioned exception\n const re = /(\\d+[A-Z]?(?:\\.\\d+)*)/;'), [], ); }); test('a blank line between the // phase-id-owner: comment and the regex still suppresses', () => { assert.deepEqual( - findPhaseIdRegexDrift('// phase-id-owner: sanctioned exception\n\nconst re = /(\\d+[A-Z]?(?:\\.\\d+)*)/;'), + findPhaseIdRegexDrift(' // phase-id-owner: sanctioned exception\n\n const re = /(\\d+[A-Z]?(?:\\.\\d+)*)/;'), [], ); }); - test('a bare "phase-id-owner:" substring in a STRING (not a // comment) does NOT suppress', () => { - const v = findPhaseIdRegexDrift('const msg = "ping the phase-id-owner: for review"; const re = /(\\d+[A-Z]?(?:\\.\\d+)*)/;'); + test('a trailing same-line // phase-id-owner: is NOT a sanction (must be a dedicated line above)', () => { + // The marker must lead its own comment line; a trailing comment on a code + // line is not honored, so the regex is still flagged. + const v = findPhaseIdRegexDrift('const re = /(\\d+[A-Z]?(?:\\.\\d+)*)/; // phase-id-owner: not honored here'); + assert.equal(v.length, 1); + }); + + test('a // phase-id-owner: embedded in a STRING literal does NOT suppress (decoy)', () => { + // A `//` inside a string is not a comment — help/doc text that quotes the + // sanction syntax must not silently suppress a real re-derivation. + const decoyLine = findPhaseIdRegexDrift('const help = "use // phase-id-owner: "; const re = /(\\d+[A-Z]?(?:\\.\\d+)*)/;'); + assert.equal(decoyLine.length, 1); + const decoyPrev = findPhaseIdRegexDrift('const help = "use // phase-id-owner: ";\nconst re = /(\\d+[A-Z]?(?:\\.\\d+)*)/;'); + assert.equal(decoyPrev.length, 1); + }); + + test('a bare "phase-id-owner:" substring with no // does NOT suppress', () => { + const v = findPhaseIdRegexDrift('const msg = "ping the phase-id-owner for review"; const re = /(\\d+[A-Z]?(?:\\.\\d+)*)/;'); assert.equal(v.length, 1); }); From c1cd43a39ff50d5a816db35f3a60dda358390aca Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Fri, 10 Jul 2026 09:31:17 -0400 Subject: [PATCH 26/31] =?UTF-8?q?fix(#2128):=20bound=20the=20phase-tag=20c?= =?UTF-8?q?lause=20to=20{0,200}=20=E2=80=94=20kill=20quadratic=20ReDoS?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The canonical OPTIONAL_PHASE_TAG_SOURCE tag clause `(?:\s*\([^)\n]*\))?` (and its inlined literal mirrors across 11 modules) had an UNBOUNDED body, making the optional-group + /g header scan quadratic on adversarial ROADMAP.md/STATE.md — a long run of `(` after a header ran ~18.8s at 1.7MB. Bound the body to {0,200} in the constant AND every mirror in lockstep (the #1729 "both forms change together" contract), so the scan is linear: the same 1.7MB input now resolves in ~9ms (measured), while real tags (a handful of chars) still match and a 201-char tag is rejected. Added a #2128 boundary regression to the #1729 suite. Pre-existing (byte-identical before/after the Phase 4 migrations); folded in at maintainer direction rather than deferred. Co-Authored-By: Claude Opus 4.8 --- src/commands.cts | 4 ++-- src/init.cts | 12 ++++++------ src/milestone.cts | 4 ++-- src/phase-id.cts | 4 ++-- src/phase.cts | 16 ++++++++-------- src/roadmap-command-router.cts | 6 +++--- src/roadmap-parser.cts | 12 ++++++------ src/roadmap.cts | 4 ++-- src/state.cts | 8 ++++---- src/validate.cts | 4 ++-- src/verify.cts | 8 ++++---- tests/phase.test.cjs | 15 +++++++++++++++ 12 files changed, 56 insertions(+), 41 deletions(-) diff --git a/src/commands.cts b/src/commands.cts index 426923c0d..1e52eb6fb 100644 --- a/src/commands.cts +++ b/src/commands.cts @@ -1516,8 +1516,8 @@ function cmdStats(cwd: string, format: string | undefined, raw: boolean): void { const roadmapContent = extractCurrentMilestone(roadmapRaw, cwd); // Matches both plain numeric (Phase 1:) and milestone-prefixed (Phase 2-01:) headings. // Also tolerates optional [bracket-token] scope prefix on phase headings. - // #1729: `(?:\s*\([^)\n]*\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE). - const headingPattern = /#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+([\w][\w.-]*)(?:\s*\([^)\n]*\))?\s*:\s*([^\n]+)/gi; + // #1729: `(?:\s*\([^)\n]{0,200}\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE). + const headingPattern = /#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+([\w][\w.-]*)(?:\s*\([^)\n]{0,200}\))?\s*:\s*([^\n]+)/gi; let match: RegExpExecArray | null; while ((match = headingPattern.exec(roadmapContent)) !== null) { const key = normalizePhaseName(match[1]); diff --git a/src/init.cts b/src/init.cts index 13b2a890c..4e41febdc 100644 --- a/src/init.cts +++ b/src/init.cts @@ -1161,8 +1161,8 @@ function cmdInitMilestoneOp(cwd: string, raw: boolean): void { const roadmapPath = path.join(planningDir(cwd), 'ROADMAP.md'); const roadmapRaw = fs.readFileSync(roadmapPath, 'utf-8'); const currentSection = extractCurrentMilestone(roadmapRaw, cwd); - // #1729: `(?:\s*\([^)\n]*\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE). - const phasePattern = new RegExp(`#{2,4}\\s*Phase\\s+(${PHASE_NUMBER_TOKEN_SOURCE})(?:\\s*\\([^)\\n]*\\))?\\s*:`, 'gi'); + // #1729: `(?:\s*\([^)\n]{0,200}\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE). + const phasePattern = new RegExp(`#{2,4}\\s*Phase\\s+(${PHASE_NUMBER_TOKEN_SOURCE})(?:\\s*\\([^)\\n]{0,200}\\))?\\s*:`, 'gi'); let m: RegExpExecArray | null; while ((m = phasePattern.exec(currentSection)) !== null) { if (/^999(?:\.|$)/.test(m[1])) continue; @@ -1325,8 +1325,8 @@ function cmdInitManager(cwd: string, raw: boolean): void { _checkboxStates.set(_cbMatch[2], _cbMatch[1].toLowerCase() === 'x'); } - // #1729: `(?:\s*\([^)\n]*\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE). - const phasePattern = new RegExp(`#{2,4}\\s*Phase\\s+(${PHASE_NUMBER_TOKEN_SOURCE})(?:\\s*\\([^)\\n]*\\))?\\s*:\\s*([^\\n]+)`, 'gi'); + // #1729: `(?:\s*\([^)\n]{0,200}\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE). + const phasePattern = new RegExp(`#{2,4}\\s*Phase\\s+(${PHASE_NUMBER_TOKEN_SOURCE})(?:\\s*\\([^)\\n]{0,200}\\))?\\s*:\\s*([^\\n]+)`, 'gi'); const phases: Record[] = []; let match: RegExpExecArray | null; @@ -1688,8 +1688,8 @@ function cmdInitProgress(cwd: string, raw: boolean): void { fs.readFileSync(path.join(planningDir(cwd), 'ROADMAP.md'), 'utf-8'), cwd, ); - // #1729: `(?:\s*\([^)\n]*\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE). - const headingPattern = new RegExp(`#{2,4}\\s*Phase\\s+(${PHASE_NUMBER_TOKEN_SOURCE})(?:\\s*\\([^)\\n]*\\))?\\s*:\\s*([^\\n]+)`, 'gi'); + // #1729: `(?:\s*\([^)\n]{0,200}\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE). + const headingPattern = new RegExp(`#{2,4}\\s*Phase\\s+(${PHASE_NUMBER_TOKEN_SOURCE})(?:\\s*\\([^)\\n]{0,200}\\))?\\s*:\\s*([^\\n]+)`, 'gi'); let hm: RegExpExecArray | null; while ((hm = headingPattern.exec(roadmapContent)) !== null) { roadmapPhaseNums.add(hm[1]); diff --git a/src/milestone.cts b/src/milestone.cts index 3d4fcb5de..f6f3386e6 100644 --- a/src/milestone.cts +++ b/src/milestone.cts @@ -176,8 +176,8 @@ function cmdMilestoneComplete(cwd: string, version: string, options: MilestoneCo if (stateVersion && stateVersion === version) { const roadmapContent = fs.readFileSync(roadmapPath, 'utf-8'); const scopedContent = extractCurrentMilestone(roadmapContent, cwd); - // #1729: `(?:\s*\([^)\n]*\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE). - const phasePattern = new RegExp(`#{2,4}\\s*Phase\\s+(${PHASE_NUMBER_TOKEN_SOURCE})(?:\\s*\\([^)\\n]*\\))?\\s*:\\s*([^\\n]+)`, 'gi'); + // #1729: `(?:\s*\([^)\n]{0,200}\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE). + const phasePattern = new RegExp(`#{2,4}\\s*Phase\\s+(${PHASE_NUMBER_TOKEN_SOURCE})(?:\\s*\\([^)\\n]{0,200}\\))?\\s*:\\s*([^\\n]+)`, 'gi'); const noDirectoryPhases: string[] = []; let pm: RegExpExecArray | null; const phaseDirEntries = ((): string[] => { diff --git a/src/phase-id.cts b/src/phase-id.cts index 7ce8f90f3..ead7a3b27 100644 --- a/src/phase-id.cts +++ b/src/phase-id.cts @@ -37,9 +37,9 @@ const OPTIONAL_PROJECT_CODE_PREFIX_SOURCE = '(?:[A-Z][A-Z0-9_]*-)?'; // Enumeration/parse call sites that read phase headers from a regex *literal* // (rather than a `new RegExp` built from an interpolated phase number) cannot // reference this constant; they inline its literal-regex mirror instead — -// `(?:\s*\([^)\n]*\))?` — kept character-for-character equivalent to this +// `(?:\s*\([^)\n]{0,200}\))?` — kept character-for-character equivalent to this // source. Both forms must change together; see the #1729 regression test. -const OPTIONAL_PHASE_TAG_SOURCE = '(?:\\s*\\([^)\\n]*\\))?'; +const OPTIONAL_PHASE_TAG_SOURCE = '(?:\\s*\\([^)\\n]{0,200}\\))?'; // #2128: the canonical phase-NUMBER-TOKEN grammar — a phase number with an // optional single-letter variant suffix and optional dotted sub-phases diff --git a/src/phase.cts b/src/phase.cts index 6de08a4d1..6ae72d999 100644 --- a/src/phase.cts +++ b/src/phase.cts @@ -713,8 +713,8 @@ function cmdPhaseAdd(cwd: string, description: string, raw: boolean, customId?: // (section header, roadmap bullet, or on-disk directory) is counted: // 1) Section headers: ### Phase N: / ## Phase N: / #### Phase N: - // #1729: `(?:\s*\([^)\n]*\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE). - const headerPattern = /#{2,4}\s*Phase\s+(\d+)[A-Z]?(?:\.\d+)*(?:\s*\([^)\n]*\))?:/gi; + // #1729: `(?:\s*\([^)\n]{0,200}\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE). + const headerPattern = /#{2,4}\s*Phase\s+(\d+)[A-Z]?(?:\.\d+)*(?:\s*\([^)\n]{0,200}\))?:/gi; // 2) Roadmap bullet entries: - [ ] **Phase N: ...** (all checkbox variants) // The lookahead accepts colon, decimal-dot, whitespace, bold-close asterisk, // or end-of-line so titleless forms ("- [ ] **Phase 11**", "- [ ] Phase 11") @@ -811,8 +811,8 @@ function cmdPhaseAddBatch(cwd: string, descriptions: string[], raw: boolean): vo const content = extractCurrentMilestone(rawContent, cwd); let maxPhase = 0; if (config.phase_naming !== 'custom') { - // #1729: `(?:\s*\([^)\n]*\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE). - const phasePattern = /#{2,4}\s*Phase\s+(\d+)[A-Z]?(?:\.\d+)*(?:\s*\([^)\n]*\))?:/gi; + // #1729: `(?:\s*\([^)\n]{0,200}\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE). + const phasePattern = /#{2,4}\s*Phase\s+(\d+)[A-Z]?(?:\.\d+)*(?:\s*\([^)\n]{0,200}\))?:/gi; let m: RegExpExecArray | null; while ((m = phasePattern.exec(content)) !== null) { const num = parseInt(m[1], 10); @@ -1195,7 +1195,7 @@ function updateRoadmapAfterPhaseRemoval( // #1729: fold an optional pre-colon ( ) tag into the suffix capture so it // is re-emitted verbatim — a tagged later phase still gets renumbered. content = content.replace( - /(#{2,4}\s*Phase\s+)(\d+(?:\.\d+)?)((?:\s*\([^)\n]*\))?\s*:)/gi, + /(#{2,4}\s*Phase\s+)(\d+(?:\.\d+)?)((?:\s*\([^)\n]{0,200}\))?\s*:)/gi, (_match, prefix: string, num: string, suffix: string) => `${prefix}${decrementRoadmapPhaseToken(num, removedInt)}${suffix}`, ); @@ -1704,11 +1704,11 @@ function cmdPhaseComplete(cwd: string, phaseNum: string, raw: boolean): void { // phase. Allow optional `**`/`__` emphasis after the marker and stop // the name capture at emphasis so bold names slug cleanly; the number // capture is unchanged. - // #1729: `(?:\s*\([^)\n]*\))?` after the number tolerates a pre-colon + // #1729: `(?:\s*\([^)\n]{0,200}\))?` after the number tolerates a pre-colon // ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE) so // `### Phase N (Cluster B): X` resolves. Captures are unchanged. const phasePattern = new RegExp( - `(?:#{2,4}|-\\s*\\[[ xX]\\])\\s*(?:\\*\\*|__)?\\s*Phase\\s+(${PHASE_NUMBER_TOKEN_SOURCE})(?:\\s*\\([^)\\n]*\\))?\\s*:\\s*([^\\n*]+)`, + `(?:#{2,4}|-\\s*\\[[ xX]\\])\\s*(?:\\*\\*|__)?\\s*Phase\\s+(${PHASE_NUMBER_TOKEN_SOURCE})(?:\\s*\\([^)\\n]{0,200}\\))?\\s*:\\s*([^\\n*]+)`, 'gi' ); let pm: RegExpExecArray | null; @@ -1747,7 +1747,7 @@ function cmdPhaseComplete(cwd: string, phaseNum: string, raw: boolean): void { try { const milestoneScope = extractCurrentMilestone(roadmapContent, cwd); const cbPattern = new RegExp( - `-\\s*\\[(x| )\\]\\s*(?:\\*\\*|__)?\\s*Phase\\s+(${PHASE_NUMBER_TOKEN_SOURCE})(?:\\s*\\([^)\\n]*\\))?\\s*:\\s*([^\\n*]+)`, + `-\\s*\\[(x| )\\]\\s*(?:\\*\\*|__)?\\s*Phase\\s+(${PHASE_NUMBER_TOKEN_SOURCE})(?:\\s*\\([^)\\n]{0,200}\\))?\\s*:\\s*([^\\n*]+)`, 'gi' ); let cbm: RegExpExecArray | null; diff --git a/src/roadmap-command-router.cts b/src/roadmap-command-router.cts index 2e23b7318..142c572e8 100644 --- a/src/roadmap-command-router.cts +++ b/src/roadmap-command-router.cts @@ -70,11 +70,11 @@ function checkW021(content: string): W021Warning[] { const MILESTONE_RE = /^#{1,3}\s+(?:\[[^\]]+\]\s+|Roadmap\s+|[✅🚧]\s*)?v(\d+)\.\d+(?:\s|:|\s*—)/iu; // Migrated phase heading: ### Phase M-NN: Name (M-NN or unpadded M-N form) - // #1729: `(?:\s*\([^)\n]*\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE). - const PHASE_RE = /^#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+(\d+)-(\d+)(?:-\d+)*(?:\s*\([^)\n]*\))?\s*:/i; + // #1729: `(?:\s*\([^)\n]{0,200}\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE). + const PHASE_RE = /^#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+(\d+)-(\d+)(?:-\d+)*(?:\s*\([^)\n]{0,200}\))?\s*:/i; // Unprefixed legacy phase heading: ### Phase N: Name (no hyphen sub-index) // phase-id-owner: UNPREFIXED_PHASE_RE token uses the [A-Za-z] case-variant (identical to the canonical [A-Z] token under /i); kept literal, not source-byte-equal to PHASE_NUMBER_TOKEN_SOURCE. - const UNPREFIXED_PHASE_RE = /^#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+(\d+[A-Za-z]?(?:\.\d+)*)(?:\s*\([^)\n]*\))?\s*:/i; + const UNPREFIXED_PHASE_RE = /^#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+(\d+[A-Za-z]?(?:\.\d+)*)(?:\s*\([^)\n]{0,200}\))?\s*:/i; let currentMilestoneMajor: number | null = null; const lines = content.split('\n'); diff --git a/src/roadmap-parser.cts b/src/roadmap-parser.cts index 091789c48..4093f7ede 100644 --- a/src/roadmap-parser.cts +++ b/src/roadmap-parser.cts @@ -98,8 +98,8 @@ function extractCurrentMilestone(content: string, cwd?: string): string { const preambleCutoff = firstMilestoneMatch ? firstMilestoneMatch.index! : detailsOpenIdx; const preamble = content.slice(0, preambleCutoff) .replace(/
[\s\S]*?<\/details>/gi, '') - // #1729: `(?:\s*\([^)\n]*\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE). - .replace(/^#{2,4}\s*Phase\s+[\w][\w.-]*(?:\s*\([^)\n]*\))?\s*:[^\n]*(?:\n(?!#{1,6}\s)[^\n]*)*\n?/gim, '') + // #1729: `(?:\s*\([^)\n]{0,200}\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE). + .replace(/^#{2,4}\s*Phase\s+[\w][\w.-]*(?:\s*\([^)\n]{0,200}\))?\s*:[^\n]*(?:\n(?!#{1,6}\s)[^\n]*)*\n?/gim, '') .replace(/^#{1,4}\s*Phase Details\b[^\n]*\n?/gim, ''); return preamble + content.slice(detailsOpenIdx, detailsEnd); } @@ -179,8 +179,8 @@ function extractCurrentMilestone(content: string, cwd?: string): string { const preamble = beforeMilestones .replace(/
[\s\S]*?<\/details>/gi, '') - // #1729: `(?:\s*\([^)\n]*\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE). - .replace(/^#{2,4}\s*Phase\s+[\w][\w.-]*(?:\s*\([^)\n]*\))?\s*:[^\n]*(?:\n(?!#{1,6}\s)[^\n]*)*\n?/gim, '') + // #1729: `(?:\s*\([^)\n]{0,200}\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE). + .replace(/^#{2,4}\s*Phase\s+[\w][\w.-]*(?:\s*\([^)\n]{0,200}\))?\s*:[^\n]*(?:\n(?!#{1,6}\s)[^\n]*)*\n?/gim, '') .replace(/^#{1,4}\s*Phase Details\b[^\n]*\n?/gim, ''); return detailsSection @@ -427,8 +427,8 @@ function getMilestonePhaseFilter(cwd: string, versionOverride?: string | null, p // Use tokenizeHeadings (fence-aware) instead of stripFencedLines + regex. // T4 seam migration: phase headings inside fences are excluded automatically. - // #1729: `(?:\s*\([^)\n]*\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE). - const phaseHeadingPattern = /^(?:\[[^\]]+\]\s*)?Phase\s+([\w][\w.-]*)(?:\s*\([^)\n]*\))?\s*:/i; + // #1729: `(?:\s*\([^)\n]{0,200}\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE). + const phaseHeadingPattern = /^(?:\[[^\]]+\]\s*)?Phase\s+([\w][\w.-]*)(?:\s*\([^)\n]{0,200}\))?\s*:/i; for (const h of tokenizeHeadings(roadmap)) { if (h.level < 2 || h.level > 4) continue; const pm = phaseHeadingPattern.exec(h.text); diff --git a/src/roadmap.cts b/src/roadmap.cts index 168dad3cb..4eb408c49 100644 --- a/src/roadmap.cts +++ b/src/roadmap.cts @@ -297,9 +297,9 @@ function cmdRoadmapAnalyze(cwd: string, raw: boolean): void { const phasesDir = planningPaths(cwd).phases; // Extract all phase headings: ## Phase N: Name or ### Phase N: Name - // #1729: `(?:\s*\([^)\n]*\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE). + // #1729: `(?:\s*\([^)\n]{0,200}\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE). // phase-id-owner: uses the [.-] (dot-or-dash) separator variant, not the canonical dot-only token; a swap to PHASE_NUMBER_TOKEN_SOURCE would drop hyphenated phase-id matches. - const phasePattern = /#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+(\d+[A-Z]?(?:[.-]\d+)*)(?:\s*\([^)\n]*\))?\s*:\s*([^\n]+)/gi; + const phasePattern = /#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+(\d+[A-Z]?(?:[.-]\d+)*)(?:\s*\([^)\n]{0,200}\))?\s*:\s*([^\n]+)/gi; const phases: Array<{ number: string; name: string; diff --git a/src/state.cts b/src/state.cts index 9bd235342..06b41be4f 100644 --- a/src/state.cts +++ b/src/state.cts @@ -1442,8 +1442,8 @@ function buildStateFrontmatter(bodyContent: string, cwd: string | undefined): Re // truth for total_phases (#549). let roadmapPhaseCount = 0; if (roadmapScope !== null) { - // #1729: `(?:\s*\([^)\n]*\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE). - const phaseHeadingPattern = /#{2,4}\s*Phase\s+([\w][\w.-]*)(?:\s*\([^)\n]*\))?\s*:/gi; + // #1729: `(?:\s*\([^)\n]{0,200}\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE). + const phaseHeadingPattern = /#{2,4}\s*Phase\s+([\w][\w.-]*)(?:\s*\([^)\n]{0,200}\))?\s*:/gi; let m: RegExpExecArray | null; while ((m = phaseHeadingPattern.exec(roadmapScope)) !== null) { // Only count tokens that contain at least one digit — excludes @@ -2419,8 +2419,8 @@ function cmdStateSync(cwd: string, options: StateSyncOptions | undefined, raw: b try { let roadmapPhaseCount = 0; if (syncRoadmapScope !== null) { - // #1729: `(?:\s*\([^)\n]*\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE). - const phaseHeadingPattern = /#{2,4}\s*Phase\s+([\w][\w.-]*)(?:\s*\([^)\n]*\))?\s*:/gi; + // #1729: `(?:\s*\([^)\n]{0,200}\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE). + const phaseHeadingPattern = /#{2,4}\s*Phase\s+([\w][\w.-]*)(?:\s*\([^)\n]{0,200}\))?\s*:/gi; let m: RegExpExecArray | null; while ((m = phaseHeadingPattern.exec(syncRoadmapScope)) !== null) { // Only count tokens that contain at least one digit — excludes diff --git a/src/validate.cts b/src/validate.cts index 05f96bd73..e0eea4835 100644 --- a/src/validate.cts +++ b/src/validate.cts @@ -113,8 +113,8 @@ export function buildRoadmapPhaseVariants(roadmapContent: string): RoadmapPhaseV const roadmapPhaseVariants = new Set(); // Matches both legacy numeric (Phase 1:), decimal (Phase 2.1:), milestone-prefixed (Phase 2-01:), // and bracket-prefixed (### [GSD] Phase 2-01:) headings. - // #1729: `(?:\s*\([^)\n]*\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE). - const phasePattern = /#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+([\w][\w.-]*)(?:\s*\([^)\n]*\))?\s*:/gi; + // #1729: `(?:\s*\([^)\n]{0,200}\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE). + const phasePattern = /#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+([\w][\w.-]*)(?:\s*\([^)\n]{0,200}\))?\s*:/gi; let m: RegExpExecArray | null; while ((m = phasePattern.exec(roadmapContent)) !== null) { roadmapPhases.add(m[1]); diff --git a/src/verify.cts b/src/verify.cts index 488598df9..58e2bb18e 100644 --- a/src/verify.cts +++ b/src/verify.cts @@ -1081,8 +1081,8 @@ function checkMilestonePrefixMismatches( } for (const section of sections) { const content = roadmapContent.slice(section.start, section.end); - // #1729: `(?:\s*\([^)\n]*\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE). - const phaseRx = /#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+([\w][\w.-]*)(?:\s*\([^)\n]*\))?\s*:/gi; + // #1729: `(?:\s*\([^)\n]{0,200}\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE). + const phaseRx = /#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+([\w][\w.-]*)(?:\s*\([^)\n]{0,200}\))?\s*:/gi; let pm: RegExpExecArray | null; while ((pm = phaseRx.exec(content)) !== null) { const phaseId = pm[1]; @@ -1812,8 +1812,8 @@ function cmdValidateHealth( if (isMarkedComplete) { const roadmapRaw = fs.readFileSync(roadmapPath, 'utf-8'); const scopedContent = extractCurrentMilestone(roadmapRaw, cwd); - // #1729: `(?:\s*\([^)\n]*\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE). - const phasePattern = new RegExp(`#{2,4}\\s*Phase\\s+(${PHASE_NUMBER_TOKEN_SOURCE})(?:\\s*\\([^)\\n]*\\))?\\s*:\\s*([^\\n]+)`, 'gi'); + // #1729: `(?:\s*\([^)\n]{0,200}\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE). + const phasePattern = new RegExp(`#{2,4}\\s*Phase\\s+(${PHASE_NUMBER_TOKEN_SOURCE})(?:\\s*\\([^)\\n]{0,200}\\))?\\s*:\\s*([^\\n]+)`, 'gi'); const unstarted: string[] = []; let pm: RegExpExecArray | null; // Non-hoisted: load-order matters (circular dep guard) diff --git a/tests/phase.test.cjs b/tests/phase.test.cjs index 82e1cdf45..d945c2d64 100644 --- a/tests/phase.test.cjs +++ b/tests/phase.test.cjs @@ -348,6 +348,21 @@ describe('#1729 regression: parenthetical tag before the colon in a phase header assert.ok(re.test('### Phase 26: X'), 'seam stays optional when no tag is present'); }); + test('#2128: the pre-colon tag is length-bounded so the tag clause cannot ReDoS', () => { + // The tag body `[^)\n]*` was unbounded, making the optional-group + /g scan + // quadratic on adversarial ROADMAP.md/STATE.md (a long run of `(` after a + // header). Bounding it to {0,200} keeps the match linear; a 200-char tag body + // still matches (real tags are a handful of chars), 201 does not. + const phaseId = require('../gsd-core/bin/lib/phase-id.cjs'); + const re = new RegExp(`Phase\\s+0*26${phaseId.OPTIONAL_PHASE_TAG_SOURCE}\\s*:`); + assert.ok(re.test(`### Phase 26 (${'x'.repeat(200)}): T`), 'a 200-char tag body is within the bound'); + assert.ok(!re.test(`### Phase 26 (${'x'.repeat(201)}): T`), 'a 201-char tag body exceeds the bound'); + // Linearity guard: the adversarial input that was ~18.8s unbounded resolves + // near-instantly now. Assert bounded work, not wall-clock (no clock seam): + // the bounded source contains an explicit upper repetition limit. + assert.match(phaseId.OPTIONAL_PHASE_TAG_SOURCE, /\{0,\d+\}/, 'tag body must carry an explicit upper bound'); + }); + test('enumeration (roadmap analyze) lists a pre-colon-tagged phase, not just the resolver', () => { // The resolver (get-phase) and the capture-all enumeration regexes are // separate code paths. Fixing only the resolver left `roadmap analyze` From b321bc04f43417d15d1b39af278fe288779d1016 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Fri, 10 Jul 2026 09:44:49 -0400 Subject: [PATCH 27/31] =?UTF-8?q?fix(#2128):=20bound=20the=20sibling=20bra?= =?UTF-8?q?cket-prefix=20clause=20=E2=80=94=20complete=20the=20ReDoS=20fix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review caught that the prior commit bounded only the paren tag clause and left the SIBLING bracket-prefix `(?:\[[^\]]+\]\s*)?` (same host regexes, before Phase) UNBOUNDED — the identical quadratic reachable via a `[...]` run (measured ~16s at 1.7MB). Bound `[^\]]+`/`[^\]]*` -> {1,200}/{0,200} across all 19 phase/milestone heading prefixes. Comprehensive re-measurement now shows EVERY vector linear (bracket/paren/id/name/milestone all ~2-44ms at 2.45MB; bracket scaling 2k->2ms, 4k->5ms, 8k->10ms). Also: update the #1729 literal-mirror parity test off its stale unbounded constant, and add limit-1 (199) boundary coverage. Co-Authored-By: Claude Opus 4.8 --- gsd-core/bin/lib/state-transition.cjs | 2 +- src/commands.cts | 2 +- src/phase.cts | 2 +- src/roadmap-command-router.cts | 6 +++--- src/roadmap-parser.cts | 6 +++--- src/roadmap-upgrade.cts | 8 ++++---- src/roadmap.cts | 6 +++--- src/state-transition.cts | 2 +- src/validate.cts | 2 +- src/verify.cts | 4 ++-- tests/phase.test.cjs | 15 +++++++++------ 11 files changed, 29 insertions(+), 26 deletions(-) diff --git a/gsd-core/bin/lib/state-transition.cjs b/gsd-core/bin/lib/state-transition.cjs index e56900497..12937e4ad 100644 --- a/gsd-core/bin/lib/state-transition.cjs +++ b/gsd-core/bin/lib/state-transition.cjs @@ -1442,7 +1442,7 @@ function reconcileByPhaseTable(content, deps, timestamp, log) { * source to substitute. This is honest — better than silently leaving `[X]` * which looks like a value. */ -const TEMPLATE_PLACEHOLDER_VALUE = /^\s*\[[^\]]+\]\s*$|^\s*-\s*$/; +const TEMPLATE_PLACEHOLDER_VALUE = /^\s*\[[^\]]{1,200}\]\s*$|^\s*-\s*$/; function stripTemplatePlaceholders(content, timestamp, log) { // Scan body `**Field:** value` lines; when value matches the placeholder // shape, replace with `(pending)`. We deliberately do NOT touch fields that diff --git a/src/commands.cts b/src/commands.cts index 1e52eb6fb..93419df94 100644 --- a/src/commands.cts +++ b/src/commands.cts @@ -1517,7 +1517,7 @@ function cmdStats(cwd: string, format: string | undefined, raw: boolean): void { // Matches both plain numeric (Phase 1:) and milestone-prefixed (Phase 2-01:) headings. // Also tolerates optional [bracket-token] scope prefix on phase headings. // #1729: `(?:\s*\([^)\n]{0,200}\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE). - const headingPattern = /#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+([\w][\w.-]*)(?:\s*\([^)\n]{0,200}\))?\s*:\s*([^\n]+)/gi; + const headingPattern = /#{2,4}\s*(?:\[[^\]]{1,200}\]\s*)?Phase\s+([\w][\w.-]*)(?:\s*\([^)\n]{0,200}\))?\s*:\s*([^\n]+)/gi; let match: RegExpExecArray | null; while ((match = headingPattern.exec(roadmapContent)) !== null) { const key = normalizePhaseName(match[1]); diff --git a/src/phase.cts b/src/phase.cts index 6ae72d999..24c68bf5d 100644 --- a/src/phase.cts +++ b/src/phase.cts @@ -719,7 +719,7 @@ function cmdPhaseAdd(cwd: string, description: string, raw: boolean, customId?: // The lookahead accepts colon, decimal-dot, whitespace, bold-close asterisk, // or end-of-line so titleless forms ("- [ ] **Phase 11**", "- [ ] Phase 11") // are counted and cannot collide with a freshly-added phase. (#1229) - const bulletPattern = /^[ \t]*-[ \t]*\[[^\]]*\][ \t]*\*{0,2}Phase[ \t]+(\d+)(?=[:.\s*]|$)/gim; + const bulletPattern = /^[ \t]*-[ \t]*\[[^\]]{0,200}\][ \t]*\*{0,2}Phase[ \t]+(\d+)(?=[:.\s*]|$)/gim; const usedPhaseNums = new Set(); let m: RegExpExecArray | null; diff --git a/src/roadmap-command-router.cts b/src/roadmap-command-router.cts index 142c572e8..7ef26d5a1 100644 --- a/src/roadmap-command-router.cts +++ b/src/roadmap-command-router.cts @@ -67,14 +67,14 @@ function checkW021(content: string): W021Warning[] { // Milestone section heading: ## [GSD] v2.0 — Label OR ## v2.0: Label OR ## Roadmap v2.0 // OR ## ✅ v2.0 OR ## 🚧 v2.0 (emoji-prefixed variants used by roadmap templates) // Capture the major integer. - const MILESTONE_RE = /^#{1,3}\s+(?:\[[^\]]+\]\s+|Roadmap\s+|[✅🚧]\s*)?v(\d+)\.\d+(?:\s|:|\s*—)/iu; + const MILESTONE_RE = /^#{1,3}\s+(?:\[[^\]]{1,200}\]\s+|Roadmap\s+|[✅🚧]\s*)?v(\d+)\.\d+(?:\s|:|\s*—)/iu; // Migrated phase heading: ### Phase M-NN: Name (M-NN or unpadded M-N form) // #1729: `(?:\s*\([^)\n]{0,200}\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE). - const PHASE_RE = /^#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+(\d+)-(\d+)(?:-\d+)*(?:\s*\([^)\n]{0,200}\))?\s*:/i; + const PHASE_RE = /^#{2,4}\s*(?:\[[^\]]{1,200}\]\s*)?Phase\s+(\d+)-(\d+)(?:-\d+)*(?:\s*\([^)\n]{0,200}\))?\s*:/i; // Unprefixed legacy phase heading: ### Phase N: Name (no hyphen sub-index) // phase-id-owner: UNPREFIXED_PHASE_RE token uses the [A-Za-z] case-variant (identical to the canonical [A-Z] token under /i); kept literal, not source-byte-equal to PHASE_NUMBER_TOKEN_SOURCE. - const UNPREFIXED_PHASE_RE = /^#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+(\d+[A-Za-z]?(?:\.\d+)*)(?:\s*\([^)\n]{0,200}\))?\s*:/i; + const UNPREFIXED_PHASE_RE = /^#{2,4}\s*(?:\[[^\]]{1,200}\]\s*)?Phase\s+(\d+[A-Za-z]?(?:\.\d+)*)(?:\s*\([^)\n]{0,200}\))?\s*:/i; let currentMilestoneMajor: number | null = null; const lines = content.split('\n'); diff --git a/src/roadmap-parser.cts b/src/roadmap-parser.cts index 4093f7ede..4cadfdd75 100644 --- a/src/roadmap-parser.cts +++ b/src/roadmap-parser.cts @@ -215,7 +215,7 @@ interface RoadmapPhaseResult { function findRoadmapPhaseInContent(content: string, phaseNum: unknown, phaseSource?: string): RoadmapPhaseResult | null { // #1729: OPTIONAL_PHASE_TAG_SOURCE after the number tolerates a pre-colon ( ) tag. const headingPattern = new RegExp( - `^(?:\\[[^\\]]+\\]\\s*)?Phase\\s+${phaseSource ?? phaseMarkdownRegexSource(phaseNum)}${OPTIONAL_PHASE_TAG_SOURCE}:\\s*(.+)$`, + `^(?:\\[[^\\]]{1,200}\\]\\s*)?Phase\\s+${phaseSource ?? phaseMarkdownRegexSource(phaseNum)}${OPTIONAL_PHASE_TAG_SOURCE}:\\s*(.+)$`, 'i' ); const headings = tokenizeHeadings(content); @@ -370,7 +370,7 @@ function getMilestonePhaseFilter(cwd: string, versionOverride?: string | null, p let roadmap = extractCurrentMilestone(roadmapContent, cwd); const hasVersionedMilestonesGlobal = /^#{1,3}\s+.*v\d+\.\d+/mi.test(roadmapContent); - const hasPhaseHeadings = /#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+[\w]/i.test(roadmapContent); + const hasPhaseHeadings = /#{2,4}\s*(?:\[[^\]]{1,200}\]\s*)?Phase\s+[\w]/i.test(roadmapContent); if (!hasVersionedMilestonesGlobal && hasPhaseHeadings && phaseIdConvention === 'milestone-prefixed') { console.warn( '[gsd] Deprecated: free-form ROADMAP.md detected (no versioned milestone headings). ' + @@ -428,7 +428,7 @@ function getMilestonePhaseFilter(cwd: string, versionOverride?: string | null, p // Use tokenizeHeadings (fence-aware) instead of stripFencedLines + regex. // T4 seam migration: phase headings inside fences are excluded automatically. // #1729: `(?:\s*\([^)\n]{0,200}\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE). - const phaseHeadingPattern = /^(?:\[[^\]]+\]\s*)?Phase\s+([\w][\w.-]*)(?:\s*\([^)\n]{0,200}\))?\s*:/i; + const phaseHeadingPattern = /^(?:\[[^\]]{1,200}\]\s*)?Phase\s+([\w][\w.-]*)(?:\s*\([^)\n]{0,200}\))?\s*:/i; for (const h of tokenizeHeadings(roadmap)) { if (h.level < 2 || h.level > 4) continue; const pm = phaseHeadingPattern.exec(h.text); diff --git a/src/roadmap-upgrade.cts b/src/roadmap-upgrade.cts index 2c47995b9..72985fbb1 100644 --- a/src/roadmap-upgrade.cts +++ b/src/roadmap-upgrade.cts @@ -23,16 +23,16 @@ const { stripProjectCodePrefix, PHASE_NUMBER_TOKEN_SOURCE } = phaseIdMod; // Matches legacy phase headings: ### Phase N: Name (also decimal: Phase 2.1:) // Captures: (hashes)(spaces)(phase-number)(rest-of-line) const LEGACY_PHASE_HEADING_RE = new RegExp( - `^(#{2,4})\\s*(?:\\[[^\\]]+\\]\\s*)?Phase\\s+(${PHASE_NUMBER_TOKEN_SOURCE})\\s*:(.*)`, + `^(#{2,4})\\s*(?:\\[[^\\]]{1,200}\\]\\s*)?Phase\\s+(${PHASE_NUMBER_TOKEN_SOURCE})\\s*:(.*)`, 'i' ); // Matches already-migrated phase headings: ### Phase M-NN: Name -const MIGRATED_PHASE_HEADING_RE = /^#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+\d+-\d{2}\s*:/i; +const MIGRATED_PHASE_HEADING_RE = /^#{2,4}\s*(?:\[[^\]]{1,200}\]\s*)?Phase\s+\d+-\d{2}\s*:/i; // Matches milestone section headings: ## v1.0, ## Roadmap v2.0, ## ✅ v1.0, ## [GSD] v1.0, etc. // The optional bracket-token prefix (e.g., [GSD]) must be tested before the emoji group. -const MILESTONE_HEADING_RE = /^##\s+(?:\[[^\]]+\]\s+|Roadmap\s+|[✅🚧]\s*)?v(\d+)\.(\d+)(?:\s|:)/iu; +const MILESTONE_HEADING_RE = /^##\s+(?:\[[^\]]{1,200}\]\s+|Roadmap\s+|[✅🚧]\s*)?v(\d+)\.(\d+)(?:\s|:)/iu; // ─── Types ──────────────────────────────────────────────────────────────────── @@ -344,7 +344,7 @@ function computeMigrationPlan(cwd: string, options: Record = {} // Rewrite heading line: "### Phase N: Name" → "### Phase M-NN: Name" const oldLine = lines[entry.lineIndex]; const newLine = oldLine.replace( - new RegExp(`^(#{2,4}\\s*(?:\\[[^\\]]+\\]\\s*)?Phase\\s+)${PHASE_NUMBER_TOKEN_SOURCE}(\\s*:)`, 'i'), + new RegExp(`^(#{2,4}\\s*(?:\\[[^\\]]{1,200}\\]\\s*)?Phase\\s+)${PHASE_NUMBER_TOKEN_SOURCE}(\\s*:)`, 'i'), `$1${mapping.newId}$2` ); if (newLine !== oldLine) { diff --git a/src/roadmap.cts b/src/roadmap.cts index 4eb408c49..7f6febff9 100644 --- a/src/roadmap.cts +++ b/src/roadmap.cts @@ -126,7 +126,7 @@ function countPhasePlansAndSummaries(phaseDir: string): PhasePlansAndSummaries { function searchPhaseInContent(content: string, escapedPhase: string, phaseNum: string): PhaseSearchResult | null { // #1729: OPTIONAL_PHASE_TAG_SOURCE after the number tolerates a pre-colon ( ) tag. const headingPattern = new RegExp( - `^(?:\\[[^\\]]+\\]\\s*)?Phase\\s+${escapedPhase}${OPTIONAL_PHASE_TAG_SOURCE}:\\s*(.+)$`, + `^(?:\\[[^\\]]{1,200}\\]\\s*)?Phase\\s+${escapedPhase}${OPTIONAL_PHASE_TAG_SOURCE}:\\s*(.+)$`, 'i' ); const headings = tokenizeHeadings(content); @@ -299,7 +299,7 @@ function cmdRoadmapAnalyze(cwd: string, raw: boolean): void { // Extract all phase headings: ## Phase N: Name or ### Phase N: Name // #1729: `(?:\s*\([^)\n]{0,200}\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE). // phase-id-owner: uses the [.-] (dot-or-dash) separator variant, not the canonical dot-only token; a swap to PHASE_NUMBER_TOKEN_SOURCE would drop hyphenated phase-id matches. - const phasePattern = /#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+(\d+[A-Z]?(?:[.-]\d+)*)(?:\s*\([^)\n]{0,200}\))?\s*:\s*([^\n]+)/gi; + const phasePattern = /#{2,4}\s*(?:\[[^\]]{1,200}\]\s*)?Phase\s+(\d+[A-Z]?(?:[.-]\d+)*)(?:\s*\([^)\n]{0,200}\))?\s*:\s*([^\n]+)/gi; const phases: Array<{ number: string; name: string; @@ -344,7 +344,7 @@ function cmdRoadmapAnalyze(cwd: string, raw: boolean): void { const restOfContent = content.slice(sectionStart); // #3691: `\d` → `\d[\d.]*` so decimal phase headings (e.g. `### Phase 02.3:`) are // recognised as section boundaries. - const nextHeader = restOfContent.match(/\n#{2,4}\s+(?:\[[^\]]+\]\s*)?Phase\s+\d[\d.-]*/i); + const nextHeader = restOfContent.match(/\n#{2,4}\s+(?:\[[^\]]{1,200}\]\s*)?Phase\s+\d[\d.-]*/i); const sectionEnd = nextHeader ? sectionStart + nextHeader.index! : content.length; const section = content.slice(sectionStart, sectionEnd); diff --git a/src/state-transition.cts b/src/state-transition.cts index d796aac10..ca93e82aa 100644 --- a/src/state-transition.cts +++ b/src/state-transition.cts @@ -1818,7 +1818,7 @@ function reconcileByPhaseTable( * source to substitute. This is honest — better than silently leaving `[X]` * which looks like a value. */ -const TEMPLATE_PLACEHOLDER_VALUE = /^\s*\[[^\]]+\]\s*$|^\s*-\s*$/; +const TEMPLATE_PLACEHOLDER_VALUE = /^\s*\[[^\]]{1,200}\]\s*$|^\s*-\s*$/; function stripTemplatePlaceholders( content: string, diff --git a/src/validate.cts b/src/validate.cts index e0eea4835..edffe68bf 100644 --- a/src/validate.cts +++ b/src/validate.cts @@ -114,7 +114,7 @@ export function buildRoadmapPhaseVariants(roadmapContent: string): RoadmapPhaseV // Matches both legacy numeric (Phase 1:), decimal (Phase 2.1:), milestone-prefixed (Phase 2-01:), // and bracket-prefixed (### [GSD] Phase 2-01:) headings. // #1729: `(?:\s*\([^)\n]{0,200}\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE). - const phasePattern = /#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+([\w][\w.-]*)(?:\s*\([^)\n]{0,200}\))?\s*:/gi; + const phasePattern = /#{2,4}\s*(?:\[[^\]]{1,200}\]\s*)?Phase\s+([\w][\w.-]*)(?:\s*\([^)\n]{0,200}\))?\s*:/gi; let m: RegExpExecArray | null; while ((m = phasePattern.exec(roadmapContent)) !== null) { roadmapPhases.add(m[1]); diff --git a/src/verify.cts b/src/verify.cts index 58e2bb18e..27a71d92e 100644 --- a/src/verify.cts +++ b/src/verify.cts @@ -1073,7 +1073,7 @@ function checkMilestonePrefixMismatches( ): MilestoneMismatch[] { const mismatches: MilestoneMismatch[] = []; const sections: { version: string; start: number; end: number }[] = []; - const sectionRx = /^#{1,3}\s+(?:\[[^\]]+\]\s*)?.*v(\d+\.\d+)/gim; + const sectionRx = /^#{1,3}\s+(?:\[[^\]]{1,200}\]\s*)?.*v(\d+\.\d+)/gim; let m: RegExpExecArray | null; while ((m = sectionRx.exec(roadmapContent)) !== null) { if (sections.length > 0) sections[sections.length - 1].end = m.index; @@ -1082,7 +1082,7 @@ function checkMilestonePrefixMismatches( for (const section of sections) { const content = roadmapContent.slice(section.start, section.end); // #1729: `(?:\s*\([^)\n]{0,200}\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE). - const phaseRx = /#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+([\w][\w.-]*)(?:\s*\([^)\n]{0,200}\))?\s*:/gi; + const phaseRx = /#{2,4}\s*(?:\[[^\]]{1,200}\]\s*)?Phase\s+([\w][\w.-]*)(?:\s*\([^)\n]{0,200}\))?\s*:/gi; let pm: RegExpExecArray | null; while ((pm = phaseRx.exec(content)) !== null) { const phaseId = pm[1]; diff --git a/tests/phase.test.cjs b/tests/phase.test.cjs index d945c2d64..67908c960 100644 --- a/tests/phase.test.cjs +++ b/tests/phase.test.cjs @@ -355,8 +355,10 @@ describe('#1729 regression: parenthetical tag before the colon in a phase header // still matches (real tags are a handful of chars), 201 does not. const phaseId = require('../gsd-core/bin/lib/phase-id.cjs'); const re = new RegExp(`Phase\\s+0*26${phaseId.OPTIONAL_PHASE_TAG_SOURCE}\\s*:`); - assert.ok(re.test(`### Phase 26 (${'x'.repeat(200)}): T`), 'a 200-char tag body is within the bound'); - assert.ok(!re.test(`### Phase 26 (${'x'.repeat(201)}): T`), 'a 201-char tag body exceeds the bound'); + // Boundary coverage (CLAUDE.md): limit-1, limit, limit+1. + assert.ok(re.test(`### Phase 26 (${'x'.repeat(199)}): T`), 'a 199-char tag body (limit-1) is within the bound'); + assert.ok(re.test(`### Phase 26 (${'x'.repeat(200)}): T`), 'a 200-char tag body (limit) is within the bound'); + assert.ok(!re.test(`### Phase 26 (${'x'.repeat(201)}): T`), 'a 201-char tag body (limit+1) exceeds the bound'); // Linearity guard: the adversarial input that was ~18.8s unbounded resolves // near-instantly now. Assert bounded work, not wall-clock (no clock seam): // the bounded source contains an explicit upper repetition limit. @@ -425,11 +427,12 @@ describe('#1729 regression: parenthetical tag before the colon in a phase header test('the literal enumeration mirror stays equivalent to the exported seam (drift guard)', () => { // Resolver sites compose OPTIONAL_PHASE_TAG_SOURCE; literal enumeration sites - // inline `(?:\s*\([^)\n]*\))?`. If one is edited without the other the two - // header families silently diverge. Assert behavioral equivalence over a - // representative header corpus so the split cannot drift undetected. + // inline `(?:\s*\([^)\n]{0,200}\))?`. If one is edited without the other the + // two header families silently diverge (the body is bounded to {0,200} in + // both since #2128 — a ReDoS fix that MUST stay in lockstep). Assert + // behavioral equivalence over a representative header corpus. const phaseId = require('../gsd-core/bin/lib/phase-id.cjs'); - const LITERAL_MIRROR = '(?:\\s*\\([^)\\n]*\\))?'; + const LITERAL_MIRROR = '(?:\\s*\\([^)\\n]{0,200}\\))?'; const seam = new RegExp(`^Phase\\s+26${phaseId.OPTIONAL_PHASE_TAG_SOURCE}\\s*:`); const mirror = new RegExp(`^Phase\\s+26${LITERAL_MIRROR}\\s*:`); for (const sample of [ From 2f6662d195f7c18f052ba197ecbf28538ee3e483 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Fri, 10 Jul 2026 10:11:43 -0400 Subject: [PATCH 28/31] fix(#2128): bound the remaining lazy-scan ReDoS vectors (files_modified, Plans-count, ) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A ReDoS-completeness audit surfaced a distinct class beyond the tag/bracket clause: unbounded `[\s\S]*?` / `[^\]]*` lazy-scans searching for a literal terminator that may never appear, driven quadratic by REPEATED structures in a large PLAN.md/ROADMAP.md. Folded all 7 in at maintainer direction: - files_modified `[^\]]*` -> `[^\]]{0,8000}` (commands.cts, verify.cts): 39.7s -> 0.9s. - Plans-count `[\s\S]*?` -> section-local `(?:(?!\n#{1,4}\s)[\s\S])*?` — stops at the next heading (semantically correct: Plans: belongs to the phase's own section) (roadmap.cts x3, phase.cts): 36s -> 4ms. - extraction `([\s\S]*?)` -> stop at the next same-tag opening `((?:(?!)[\s\S])*?)` (verify.cts x3, markdown-sectionizer.cts): ~6s -> 2ms. Every vector is now linear (comprehensively re-measured); real content matches (end-to-end `roadmap get-phase` still resolves Plans-counted phases). Pre-existing; byte-behavior preserved for realistic inputs. Co-Authored-By: Claude Opus 4.8 --- src/commands.cts | 2 +- src/markdown-sectionizer.cts | 2 +- src/phase.cts | 2 +- src/roadmap.cts | 6 +++--- src/verify.cts | 8 ++++---- 5 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/commands.cts b/src/commands.cts index 93419df94..9ffd6522c 100644 --- a/src/commands.cts +++ b/src/commands.cts @@ -1334,7 +1334,7 @@ function cmdTodoMatchPhase(cwd: string, phase: string | undefined, raw: boolean) for (const pf of planFiles) { const planContent = platformReadSync(path.join(phaseDir, pf)); if (planContent === null) continue; - const fmFiles = planContent.match(/files_modified:\s*\[([^\]]*)\]/); + const fmFiles = planContent.match(/files_modified:\s*\[([^\]]{0,8000})\]/); if (fmFiles) { phasePlans.push(...fmFiles[1].split(',').map(s => s.trim().replace(/['"]/g, '')).filter(Boolean)); } diff --git a/src/markdown-sectionizer.cts b/src/markdown-sectionizer.cts index 0665ff39c..8a745d37a 100644 --- a/src/markdown-sectionizer.cts +++ b/src/markdown-sectionizer.cts @@ -537,7 +537,7 @@ export function extractTaggedBlocks(content: string, tagName: string): string[] // Escape the tag name for safe interpolation into a RegExp. const escapedTag = tagName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - const pattern = new RegExp(`<${escapedTag}>([\\s\\S]*?)`, 'g'); + const pattern = new RegExp(`<${escapedTag}>((?:(?!<${escapedTag}>)[\\s\\S])*?)`, 'g'); const results: string[] = []; let match: RegExpExecArray | null; diff --git a/src/phase.cts b/src/phase.cts index 24c68bf5d..78de4bfae 100644 --- a/src/phase.cts +++ b/src/phase.cts @@ -1519,7 +1519,7 @@ function cmdPhaseComplete(cwd: string, phaseNum: string, raw: boolean): void { } const planCountPattern = new RegExp( - `(#{2,4}\\s*Phase\\s+${phaseEscaped}[\\s\\S]*?\\*\\*Plans:\\*\\*\\s*)[^\\n]+`, + `(#{2,4}\\s*Phase\\s+${phaseEscaped}(?:(?!\\n#{1,4}\\s)[\\s\\S])*?\\*\\*Plans:\\*\\*\\s*)[^\\n]+`, 'i', ); roadmapContent = roadmapContent.replace( diff --git a/src/roadmap.cts b/src/roadmap.cts index 7f6febff9..427c44365 100644 --- a/src/roadmap.cts +++ b/src/roadmap.cts @@ -545,7 +545,7 @@ function cmdRoadmapUpdatePlanProgress(cwd: string, phaseNum: string | null | und // `**Plans:** N plans` — bold "Plans:" (colon inside bold) // `Plans: N plans` — plain text header const planCountPattern = new RegExp( - `(#{2,4}\\s*Phase\\s+${phasePattern}${OPTIONAL_PHASE_TAG_SOURCE}(?=[:\\s])[\\s\\S]*?(?:\\*\\*Plans\\*\\*:|\\*\\*Plans:\\*\\*|(?:^|\\n)Plans:)\\s*)[^\\n]+`, + `(#{2,4}\\s*Phase\\s+${phasePattern}${OPTIONAL_PHASE_TAG_SOURCE}(?=[:\\s])(?:(?!\\n#{1,4}\\s)[\\s\\S])*?(?:\\*\\*Plans\\*\\*:|\\*\\*Plans:\\*\\*|(?:^|\\n)Plans:)\\s*)[^\\n]+`, 'i' ); const planCountText = isComplete @@ -615,11 +615,11 @@ function cmdRoadmapUpdatePlanProgress(cwd: string, phaseNum: string | null | und // Pattern A: anchor to bare `Plans:` header (preferred). // Pattern B: fallback to bold summary when no bare header exists. const insertRowsPatternA = new RegExp( - `(#{2,4}\\s*Phase\\s+${phasePattern}${OPTIONAL_PHASE_TAG_SOURCE}(?=[:\\s])[\\s\\S]*?(?:^|\\n)(?:Plans:)[^\\n]*)`, + `(#{2,4}\\s*Phase\\s+${phasePattern}${OPTIONAL_PHASE_TAG_SOURCE}(?=[:\\s])(?:(?!\\n#{1,4}\\s)[\\s\\S])*?(?:^|\\n)(?:Plans:)[^\\n]*)`, 'i' ); const insertRowsPatternB = new RegExp( - `(#{2,4}\\s*Phase\\s+${phasePattern}${OPTIONAL_PHASE_TAG_SOURCE}(?=[:\\s])[\\s\\S]*?(?:\\*\\*Plans\\*\\*:|\\*\\*Plans:\\*\\*)[^\\n]*)`, + `(#{2,4}\\s*Phase\\s+${phasePattern}${OPTIONAL_PHASE_TAG_SOURCE}(?=[:\\s])(?:(?!\\n#{1,4}\\s)[\\s\\S])*?(?:\\*\\*Plans\\*\\*:|\\*\\*Plans:\\*\\*)[^\\n]*)`, 'i' ); diff --git a/src/verify.cts b/src/verify.cts index 27a71d92e..184344cf8 100644 --- a/src/verify.cts +++ b/src/verify.cts @@ -373,20 +373,20 @@ function scanFileWideNegativeGateConflict(content: string): { warnings: string[] const namem = tc.match(/([\s\S]*?)<\/name>/); const name = namem ? namem[1].trim() : 'unnamed'; // Extract entries. - const filesm = tc.match(/([\s\S]*?)<\/files>/); + const filesm = tc.match(/((?:(?!)[\s\S])*?)<\/files>/); const filesText = filesm ? filesm[1] : ''; const files = filesText.split(/[,\s]+/).map(s => s.trim()).filter(Boolean); // Gate text: //. const gateFragments: string[] = []; for (const tag of ['verify', 'automated', 'acceptance_criteria']) { - const re = new RegExp(`<${tag}>([\\s\\S]*?)<\\/${tag}>`, 'g'); + const re = new RegExp(`<${tag}>((?:(?!<${tag}>)[\\s\\S])*?)<\\/${tag}>`, 'g'); let mm: RegExpExecArray | null; while ((mm = re.exec(tc)) !== null) gateFragments.push(mm[1]); } // Requirement text: /. const reqFragments: string[] = []; for (const tag of ['action', 'acceptance_criteria']) { - const re = new RegExp(`<${tag}>([\\s\\S]*?)<\\/${tag}>`, 'g'); + const re = new RegExp(`<${tag}>((?:(?!<${tag}>)[\\s\\S])*?)<\\/${tag}>`, 'g'); let mm: RegExpExecArray | null; while ((mm = re.exec(tc)) !== null) reqFragments.push(mm[1]); } @@ -2094,7 +2094,7 @@ function cmdVerifySchemaDrift( const planFiles = fs.readdirSync(phaseDir).filter((f) => f.endsWith('-PLAN.md')); for (const pf of planFiles) { const content = fs.readFileSync(path.join(phaseDir, pf), 'utf-8'); - const fmMatch = content.match(/files_modified:\s*\[([^\]]*)\]/); + const fmMatch = content.match(/files_modified:\s*\[([^\]]{0,8000})\]/); if (fmMatch) { const files = fmMatch[1].split(',').map((f) => f.trim()).filter(Boolean); allFiles.push(...files); From 01b691fae8db27d90a14c0dc5273c39a9b8c9a00 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Fri, 10 Jul 2026 10:42:18 -0400 Subject: [PATCH 29/31] fix(#2128): route scans through a hardened shared seam (root-cause ReDoS fix) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A convergence audit showed the `[\s\S]*?` lazy-scan ReDoS was pervasive (a dozen+ bespoke copies across roadmap-parser/check-command-router/verify), each a distinct quadratic vector on a large document with unclosed tags. Rather than whack-a-mole, single-source them (maintainer-directed): - markdown-sectionizer: extractTaggedBlocks now shares one ReDoS-safe `taggedBlockPattern` (stop-at-next-open, bounded optional attributes) and gains a `stripTaggedBlocks` companion for block removal. - roadmap-parser: 3 `
` strips -> stripTaggedBlocks (behavior-identical — no
here carries attributes). - verify: actionZones + both loops + their nested //gate/req extractions -> extractTaggedBlocks (behavior byte-equivalent, verified). - check-command-router: the objective|tasks?|action alternation hardened in place (distinct multi-tag shape); HTML-comment strip gains a `$` fallback. Every vector now linear (<3ms on 1.5MB adversarial); real content unchanged (end-to-end verify/roadmap resolution + task extraction confirmed). The only remaining `` scan (uat.cts:201) is anchored + non-global — one scan, safe. Co-Authored-By: Claude Opus 4.8 --- src/check-command-router.cts | 4 ++-- src/markdown-sectionizer.cts | 35 ++++++++++++++++++++++++++---- src/roadmap-parser.cts | 10 ++++----- src/verify.cts | 42 +++++++++++------------------------- 4 files changed, 50 insertions(+), 41 deletions(-) diff --git a/src/check-command-router.cts b/src/check-command-router.cts index 3a712bb14..329424138 100644 --- a/src/check-command-router.cts +++ b/src/check-command-router.cts @@ -139,11 +139,11 @@ function loadPlanContents(phaseDir: string): string[] { } const DESIGNATED_HEADINGS_RE = /^#{1,6}\s+(?:must[_ ]haves?|truths?|tasks?|objective)\b/i; -const XML_DECISION_TAGS_RE = /<(?:objective|tasks?|action)(?:\s[^>]*)?>([\s\S]*?)<\/(?:objective|tasks?|action)>/gi; +const XML_DECISION_TAGS_RE = /<(?:objective|tasks?|action)(?:\s[^>]{0,1000})?>((?:(?!<(?:objective|tasks?|action)[\s>])[\s\S])*?)<\/(?:objective|tasks?|action)>/gi; function stripCommentsAndFences(text: string): string { // HTML-comment stripping stays caller-side (the seam does not strip HTML comments). - const htmlStripped = text.replace(//g, ' '); + const htmlStripped = text.replace(/|$)/g, ' '); // Fenced-code stripping: delegate to the canonical CommonMark-correct seam. // replaces the prior independent regex copy (```` ``` ``` ```` + `~~~ ~~~`). return stripFencedCode(htmlStripped).text; diff --git a/src/markdown-sectionizer.cts b/src/markdown-sectionizer.cts index 8a745d37a..c152f8acc 100644 --- a/src/markdown-sectionizer.cts +++ b/src/markdown-sectionizer.cts @@ -535,10 +535,7 @@ export function extractTaggedBlocks(content: string, tagName: string): string[] if (typeof content !== 'string' || content.length === 0) return []; if (typeof tagName !== 'string' || tagName.length === 0) return []; - // Escape the tag name for safe interpolation into a RegExp. - const escapedTag = tagName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - const pattern = new RegExp(`<${escapedTag}>((?:(?!<${escapedTag}>)[\\s\\S])*?)`, 'g'); - + const pattern = taggedBlockPattern(tagName, 'g'); const results: string[] = []; let match: RegExpExecArray | null; while ((match = pattern.exec(content)) !== null) { @@ -547,6 +544,36 @@ export function extractTaggedBlocks(content: string, tagName: string): string[] return results; } +/** + * Build the single, ReDoS-safe `…` block regex shared by + * `extractTaggedBlocks` (extract bodies) and `stripTaggedBlocks` (remove blocks). + * + * Safety: the body uses a `(?:(?!])[\s\S])*?` negative-lookahead scan + * that terminates at the NEXT opening `` instead of lazily rescanning the + * whole remaining document for a `` that may never appear — so a large + * document full of unclosed `` openings stays LINEAR, not quadratic + * (#2128). The opening tag tolerates optional attributes (``), + * bounded to 1000 chars so the attribute scan cannot itself ReDoS. + * Group 1 is the block body. + */ +function taggedBlockPattern(tagName: string, flags: string): RegExp { + const esc = tagName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + return new RegExp(`<${esc}(?:\\s[^>]{0,1000})?>((?:(?!<${esc}[\\s>])[\\s\\S])*?)`, flags); +} + +/** + * Remove every `…` block (opening tag, body, and closing tag) + * from `content`. The ReDoS-safe counterpart to `extractTaggedBlocks` — same + * hardened pattern, `.replace(…, '')` instead of body extraction. Case-insensitive + * by default (matching the `
` strip call sites); pass `caseSensitive` + * to force exact-case matching. + */ +export function stripTaggedBlocks(content: string, tagName: string, caseSensitive = false): string { + if (typeof content !== 'string' || content.length === 0) return ''; + if (typeof tagName !== 'string' || tagName.length === 0) return content; + return content.replace(taggedBlockPattern(tagName, caseSensitive ? 'g' : 'gi'), ''); +} + // ─── replaceSection ─────────────────────────────────────────────────────────── /** diff --git a/src/roadmap-parser.cts b/src/roadmap-parser.cts index 4cadfdd75..b0236d065 100644 --- a/src/roadmap-parser.cts +++ b/src/roadmap-parser.cts @@ -32,7 +32,7 @@ const { import planningWorkspace = require('./planning-workspace.cjs'); const { planningDir } = planningWorkspace; import { platformReadSync } from './shell-command-projection.cjs'; -import { tokenizeHeadings } from './markdown-sectionizer.cjs'; +import { tokenizeHeadings, stripTaggedBlocks } from './markdown-sectionizer.cjs'; // ─── Roadmap milestone scoping ─────────────────────────────────────────────── @@ -40,7 +40,7 @@ import { tokenizeHeadings } from './markdown-sectionizer.cjs'; * Strip shipped milestone content wrapped in
blocks. */ function stripShippedMilestones(content: string): string { - return content.replace(/
[\s\S]*?<\/details>/gi, ''); + return stripTaggedBlocks(content, 'details'); } /** @@ -96,8 +96,7 @@ function extractCurrentMilestone(content: string, cwd?: string): string { const anyMilestoneOrDetails = /^#{1,3}\s+(?!Phase\s+\S)(?:.*v\d+\.\d+|✅|📋|🚧|🔄)|
[\s\S]*?<\/details>/gi, '') + const preamble = stripTaggedBlocks(content.slice(0, preambleCutoff), 'details') // #1729: `(?:\s*\([^)\n]{0,200}\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE). .replace(/^#{2,4}\s*Phase\s+[\w][\w.-]*(?:\s*\([^)\n]{0,200}\))?\s*:[^\n]*(?:\n(?!#{1,6}\s)[^\n]*)*\n?/gim, '') .replace(/^#{1,4}\s*Phase Details\b[^\n]*\n?/gim, ''); @@ -177,8 +176,7 @@ function extractCurrentMilestone(content: string, cwd?: string): string { ); } - const preamble = beforeMilestones - .replace(/
[\s\S]*?<\/details>/gi, '') + const preamble = stripTaggedBlocks(beforeMilestones, 'details') // #1729: `(?:\s*\([^)\n]{0,200}\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE). .replace(/^#{2,4}\s*Phase\s+[\w][\w.-]*(?:\s*\([^)\n]{0,200}\))?\s*:[^\n]*(?:\n(?!#{1,6}\s)[^\n]*)*\n?/gim, '') .replace(/^#{1,4}\s*Phase Details\b[^\n]*\n?/gim, ''); diff --git a/src/verify.cts b/src/verify.cts index 184344cf8..8aa3a4778 100644 --- a/src/verify.cts +++ b/src/verify.cts @@ -26,6 +26,7 @@ import { PACKAGE_NAME } from './package-identity.cjs'; import { formatGsdSlash, resolveRuntime } from './runtime-slash.cjs'; import { detectSchemaFiles, checkSchemaDrift } from './schema-detect.cjs'; import { isCanonicalPlanningFile } from './artifacts.cjs'; +import { extractTaggedBlocks } from './markdown-sectionizer.cjs'; // eslint-disable-next-line @typescript-eslint/no-require-imports -- agent-install-check.cjs is an export= CommonJS module import agentInstallCheck = require('./agent-install-check.cjs'); const { checkAgentsInstalled } = agentInstallCheck; @@ -205,10 +206,7 @@ function scanNegativeGrepCommentEcho(content: string): { errors: string[]; warni // while a prose echo on the same line is still caught. const cmdSpanRe = /grep(?:\s+-{1,2}[A-Za-z][A-Za-z-]*)+\s+(?:'[^']*'|"[^"]*"|[^\s'"|>&;]+)[^\n]*?(?:==|-eq|=)\s*0\b/g; - const actionZones: string[] = []; - const actionRe = /([\s\S]*?)<\/action>/g; - let acm: RegExpExecArray | null; - while ((acm = actionRe.exec(text)) !== null) actionZones.push(acm[1]); + const actionZones = extractTaggedBlocks(text, 'action'); const scannableActionText = actionZones.map((zone) => zone.replace(cmdSpanRe, ' ')).join('\n'); // 3. Per shell SEGMENT (split lines on && / ||) extract count-grep literals and @@ -364,32 +362,21 @@ function scanFileWideNegativeGateConflict(content: string): { warnings: string[] gateText: string; // ++ text reqText: string; // + text (requirement side) } - const taskRe = /]*>([\s\S]*?)<\/task>/g; const tasks: TaskInfo[] = []; - let tm: RegExpExecArray | null; - while ((tm = taskRe.exec(text)) !== null) { - const tc = tm[1]; + for (const tc of extractTaggedBlocks(text, 'task')) { // Extract task name. - const namem = tc.match(/([\s\S]*?)<\/name>/); - const name = namem ? namem[1].trim() : 'unnamed'; + const namem = extractTaggedBlocks(tc, 'name'); + const name = namem.length ? namem[0].trim() : 'unnamed'; // Extract entries. - const filesm = tc.match(/((?:(?!)[\s\S])*?)<\/files>/); - const filesText = filesm ? filesm[1] : ''; + const filesArr = extractTaggedBlocks(tc, 'files'); + const filesText = filesArr.length ? filesArr[0] : ''; const files = filesText.split(/[,\s]+/).map(s => s.trim()).filter(Boolean); // Gate text: //. const gateFragments: string[] = []; - for (const tag of ['verify', 'automated', 'acceptance_criteria']) { - const re = new RegExp(`<${tag}>((?:(?!<${tag}>)[\\s\\S])*?)<\\/${tag}>`, 'g'); - let mm: RegExpExecArray | null; - while ((mm = re.exec(tc)) !== null) gateFragments.push(mm[1]); - } + for (const tag of ['verify', 'automated', 'acceptance_criteria']) gateFragments.push(...extractTaggedBlocks(tc, tag)); // Requirement text: /. const reqFragments: string[] = []; - for (const tag of ['action', 'acceptance_criteria']) { - const re = new RegExp(`<${tag}>((?:(?!<${tag}>)[\\s\\S])*?)<\\/${tag}>`, 'g'); - let mm: RegExpExecArray | null; - while ((mm = re.exec(tc)) !== null) reqFragments.push(mm[1]); - } + for (const tag of ['action', 'acceptance_criteria']) reqFragments.push(...extractTaggedBlocks(tc, tag)); // Strip XML tags from gate text so segments containing embedded // XML closing tags (e.g. cmd nested inside ) // don't bleed into the file-path token extraction. @@ -577,19 +564,16 @@ function cmdVerifyPlanStructure(cwd: string, filePath: string, raw: boolean): vo if (fm[field] === undefined) errors.push(`Missing required frontmatter field: ${field}`); } - const taskPattern = /]*>([\s\S]*?)<\/task>/g; const tasks: Record[] = []; - let taskMatch: RegExpExecArray | null; - while ((taskMatch = taskPattern.exec(content)) !== null) { - const taskContent = taskMatch[1]; - const nameMatch = taskContent.match(/([\s\S]*?)<\/name>/); - const taskName = nameMatch ? nameMatch[1].trim() : 'unnamed'; + for (const taskContent of extractTaggedBlocks(content, 'task')) { + const nameArr = extractTaggedBlocks(taskContent, 'name'); + const taskName = nameArr.length ? nameArr[0].trim() : 'unnamed'; const hasFiles = //.test(taskContent); const hasAction = //.test(taskContent); const hasVerify = //.test(taskContent); const hasDone = //.test(taskContent); - if (!nameMatch) errors.push('Task missing element'); + if (nameArr.length === 0) errors.push('Task missing element'); if (!hasAction) errors.push(`Task '${taskName}' missing `); if (!hasVerify) warnings.push(`Task '${taskName}' missing `); if (!hasDone) warnings.push(`Task '${taskName}' missing `); From 8e4ebb49e466db5e1ccbcbc9eef1a4e557073485 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Fri, 10 Jul 2026 11:12:45 -0400 Subject: [PATCH 30/31] fix(#2128): address shared-seam review regressions (#557, action-scan, comment-strip) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Final convergence review found the shared seam introduced 3 behavior regressions; fixed all + locked with tests: - #557 REGRESSION: stripTaggedBlocks's attribute-tolerance stripped `
` (the ACTIVE-milestone marker) that the old `
`-only regex preserved. The seam now takes `allowAttributes` (default false) — details/decisions strip is attr-INTOLERANT (preserves `
`); only `` opts in. Regression test added to roadmap-parser + markdown-sectionizer suites. - verify.cts actionZones (negative-grep-echo security scan): reverted to a bounded to-first-close scan `([\s\S]{0,20000}?)` so a grep-echo trick can't hide behind an unterminated inner (the seam's stop-at-next-open would drop it). ReDoS-safe via the cap. - check-command-router HTML-comment strip: `(?:-->|$)` fallback wiped to EOF (fail-closed spurious gate block) — replaced with stop-at-next-open so an unclosed `|$)/g, ' '); + // Stop-at-next-open body (ReDoS-safe, #2128); an UNCLOSED `|$)` fallback, which would + // wipe to EOF and fail-close the decision-coverage gate). + const htmlStripped = text.replace(//g, ' '); // Fenced-code stripping: delegate to the canonical CommonMark-correct seam. // replaces the prior independent regex copy (```` ``` ``` ```` + `~~~ ~~~`). return stripFencedCode(htmlStripped).text; diff --git a/src/markdown-sectionizer.cts b/src/markdown-sectionizer.cts index c152f8acc..c9b0727c6 100644 --- a/src/markdown-sectionizer.cts +++ b/src/markdown-sectionizer.cts @@ -520,22 +520,25 @@ export function iterateBullets(sectionText: string): BulletItem[] { * fenced code blocks itself. If a `` block appears inside a fenced code * block and should be excluded, the caller should apply `stripFencedCode` first. * - * **Nested tags are NOT supported.** The underlying regex uses a non-greedy - * `[\s\S]*?` match, which means it closes at the FIRST `` encountered. - * Given `inner`, `extractTaggedBlocks(content, 'x')` returns - * `['inner']` — the inner `` is captured as literal text, and the second - * `` is left unmatched (or matched as a second block with empty inner text - * if another `` follows). Callers that need to handle nested tags must - * pre-process the input or use a proper XML/HTML parser. + * **Nested tags are NOT supported.** The body scan terminates at the NEXT + * opening of the same tag (the ReDoS-safe boundary, #2128). Given + * `inner`, `extractTaggedBlocks(content, 'x')` returns `['inner']` + * — the well-formed inner block; the unterminated outer `` is skipped. + * Callers that need true nesting must use a proper XML/HTML parser. + * + * `allowAttributes` (default `false`): when `true`, the opening tag may carry + * bounded attributes (``) — needed for `` blocks. + * Leave `false` for tags that must match exactly (e.g. ``), and never + * enable it for a tag where an attributed form is semantically distinct. * * Generalises `decisions.cts`'s bespoke `matchAll(/([\s\S]*?)<\/decisions>/g)` * so tier T1 can drop its own copy (tracked duplication until T1 lands). */ -export function extractTaggedBlocks(content: string, tagName: string): string[] { +export function extractTaggedBlocks(content: string, tagName: string, allowAttributes = false): string[] { if (typeof content !== 'string' || content.length === 0) return []; if (typeof tagName !== 'string' || tagName.length === 0) return []; - const pattern = taggedBlockPattern(tagName, 'g'); + const pattern = taggedBlockPattern(tagName, 'g', allowAttributes); const results: string[] = []; let match: RegExpExecArray | null; while ((match = pattern.exec(content)) !== null) { @@ -548,30 +551,37 @@ export function extractTaggedBlocks(content: string, tagName: string): string[] * Build the single, ReDoS-safe `…` block regex shared by * `extractTaggedBlocks` (extract bodies) and `stripTaggedBlocks` (remove blocks). * - * Safety: the body uses a `(?:(?!])[\s\S])*?` negative-lookahead scan - * that terminates at the NEXT opening `` instead of lazily rescanning the - * whole remaining document for a `` that may never appear — so a large - * document full of unclosed `` openings stays LINEAR, not quadratic - * (#2128). The opening tag tolerates optional attributes (``), - * bounded to 1000 chars so the attribute scan cannot itself ReDoS. - * Group 1 is the block body. + * Safety: the body terminates at the NEXT opening of this tag (stop-at-next-open) + * instead of lazily rescanning the whole remaining document for a `` that + * may never appear — so a document full of unclosed `` openings scans + * LINEARLY, not quadratically (#2128). Group 1 is the block body. + * + * `allowAttributes`: when `true`, the opener accepts bounded attributes + * (``) and the body boundary is ``. + * When `false`, the opener is the EXACT `` and the boundary is exact ``, + * so an attributed `` is neither an opener nor a boundary — it is body + * content. That exact form is load-bearing for `
` stripping: `
` marks the ACTIVE milestone and must be preserved, not stripped (#557). */ -function taggedBlockPattern(tagName: string, flags: string): RegExp { +function taggedBlockPattern(tagName: string, flags: string, allowAttributes: boolean): RegExp { const esc = tagName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - return new RegExp(`<${esc}(?:\\s[^>]{0,1000})?>((?:(?!<${esc}[\\s>])[\\s\\S])*?)`, flags); + const open = allowAttributes ? `<${esc}(?:\\s[^>]{0,1000})?>` : `<${esc}>`; + const boundary = allowAttributes ? `<${esc}[\\s>]` : `<${esc}>`; + return new RegExp(`${open}((?:(?!${boundary})[\\s\\S])*?)`, flags); } /** * Remove every `…` block (opening tag, body, and closing tag) * from `content`. The ReDoS-safe counterpart to `extractTaggedBlocks` — same - * hardened pattern, `.replace(…, '')` instead of body extraction. Case-insensitive - * by default (matching the `
` strip call sites); pass `caseSensitive` - * to force exact-case matching. + * hardened pattern, `.replace(…, '')` instead of body extraction. `allowAttributes` + * defaults to `false` so `
` (active milestone) is preserved (#557); + * case-insensitive by default (matching the `
` strip call sites), pass + * `caseSensitive` to force exact-case matching. */ -export function stripTaggedBlocks(content: string, tagName: string, caseSensitive = false): string { +export function stripTaggedBlocks(content: string, tagName: string, allowAttributes = false, caseSensitive = false): string { if (typeof content !== 'string' || content.length === 0) return ''; if (typeof tagName !== 'string' || tagName.length === 0) return content; - return content.replace(taggedBlockPattern(tagName, caseSensitive ? 'g' : 'gi'), ''); + return content.replace(taggedBlockPattern(tagName, caseSensitive ? 'g' : 'gi', allowAttributes), ''); } // ─── replaceSection ─────────────────────────────────────────────────────────── diff --git a/src/verify.cts b/src/verify.cts index 8aa3a4778..4695fd5aa 100644 --- a/src/verify.cts +++ b/src/verify.cts @@ -206,7 +206,15 @@ function scanNegativeGrepCommentEcho(content: string): { errors: string[]; warni // while a prose echo on the same line is still caught. const cmdSpanRe = /grep(?:\s+-{1,2}[A-Za-z][A-Za-z-]*)+\s+(?:'[^']*'|"[^"]*"|[^\s'"|>&;]+)[^\n]*?(?:==|-eq|=)\s*0\b/g; - const actionZones = extractTaggedBlocks(text, 'action'); + // Security scan: must see the FULL text up to the first — including a + // malformed inner — so a grep-echo-0 trick cannot hide behind a + // deliberately-unclosed tag. Use a bounded to-first-close scan (ReDoS-safe via + // the {0,20000} cap, #2128), NOT the stop-at-next-open extractTaggedBlocks seam + // (which would drop the span before an unterminated inner ). + const actionZones: string[] = []; + const actionRe = /([\s\S]{0,20000}?)<\/action>/g; + let acm: RegExpExecArray | null; + while ((acm = actionRe.exec(text)) !== null) actionZones.push(acm[1]); const scannableActionText = actionZones.map((zone) => zone.replace(cmdSpanRe, ' ')).join('\n'); // 3. Per shell SEGMENT (split lines on && / ||) extract count-grep literals and @@ -363,7 +371,7 @@ function scanFileWideNegativeGateConflict(content: string): { warnings: string[] reqText: string; // + text (requirement side) } const tasks: TaskInfo[] = []; - for (const tc of extractTaggedBlocks(text, 'task')) { + for (const tc of extractTaggedBlocks(text, 'task', true)) { // Extract task name. const namem = extractTaggedBlocks(tc, 'name'); const name = namem.length ? namem[0].trim() : 'unnamed'; @@ -565,7 +573,7 @@ function cmdVerifyPlanStructure(cwd: string, filePath: string, raw: boolean): vo } const tasks: Record[] = []; - for (const taskContent of extractTaggedBlocks(content, 'task')) { + for (const taskContent of extractTaggedBlocks(content, 'task', true)) { const nameArr = extractTaggedBlocks(taskContent, 'name'); const taskName = nameArr.length ? nameArr[0].trim() : 'unnamed'; const hasFiles = //.test(taskContent); diff --git a/tests/markdown-sectionizer.test.cjs b/tests/markdown-sectionizer.test.cjs index ac934f1f7..655b839b3 100644 --- a/tests/markdown-sectionizer.test.cjs +++ b/tests/markdown-sectionizer.test.cjs @@ -33,6 +33,7 @@ const { collectSection, iterateBullets, extractTaggedBlocks, + stripTaggedBlocks, replaceSection, } = require('../gsd-core/bin/lib/markdown-sectionizer.cjs'); @@ -1015,15 +1016,14 @@ describe('stripFencedCode and tokenizeHeadings: backtick info string with backti // ─── FIX 6: extractTaggedBlocks — nested tag behavior ───────────────────────── -describe('extractTaggedBlocks: nested same-name tag behavior (non-greedy limitation)', () => { - test('nested … closes at first (non-greedy; nested tags not supported)', () => { - // Non-greedy match: ([\s\S]*?) closes at the FIRST . - // So inner → first block captures "inner", second is unmatched. +describe('extractTaggedBlocks: nested same-name tag behavior (#2128 stop-at-next-open)', () => { + test('nested inner extracts the well-formed inner block', () => { + // #2128: the ReDoS-safe body scan terminates at the NEXT opening , so the + // unterminated outer is skipped and the inner block is extracted. const content = 'inner'; const result = extractTaggedBlocks(content, 'x'); - // The first match closes at the first , capturing "inner" - assert.equal(result.length, 1, 'non-greedy match produces exactly one result from nested input'); - assert.equal(result[0], 'inner', 'inner capture is the content up to the first closing tag'); + assert.equal(result.length, 1, 'exactly one result from nested input'); + assert.equal(result[0], 'inner', 'the well-formed inner block is extracted; the unterminated outer is skipped'); }); test('back-to-back blocks (not nested) are both extracted', () => { @@ -1033,6 +1033,24 @@ describe('extractTaggedBlocks: nested same-name tag behavior (non-greedy limitat assert.equal(result[0], 'first'); assert.equal(result[1], 'second'); }); + + test('#2128: a document full of unclosed openings stays linear and yields no match', () => { + const content = 'a\n'.repeat(50) + 'no closing tag'; + assert.deepEqual(extractTaggedBlocks(content, 'x'), [], 'no anywhere -> no blocks'); + }); + + test('#557 / #2128: attr-intolerant by default preserves
; opt-in matches ', () => { + // stripTaggedBlocks(details) must PRESERVE
(the active-milestone + // marker) and strip only bare
; extractTaggedBlocks(task, true) must + // match attributed tasks, and must NOT when allowAttributes is left false. + assert.equal( + stripTaggedBlocks('X
shipped
Y
active
Z', 'details'), + 'XY
active
Z', + '#557:
preserved; bare
stripped', + ); + assert.deepEqual(extractTaggedBlocks('body', 'task', true), ['body'], 'attributed task matched with allowAttributes=true'); + assert.deepEqual(extractTaggedBlocks('body', 'task'), [], 'attributed task NOT matched with allowAttributes=false'); + }); }); // Parity guard removed in T5 (ADR-1372): uat-predicate now imports stripFencedCode diff --git a/tests/roadmap-parser.test.cjs b/tests/roadmap-parser.test.cjs index f86b8fcc4..ef6789899 100644 --- a/tests/roadmap-parser.test.cjs +++ b/tests/roadmap-parser.test.cjs @@ -77,6 +77,19 @@ describe('roadmap-parser: stripShippedMilestones', () => { assert.ok(!result.includes('closed content'), 'content removed'); assert.ok(result.includes('after'), 'after content preserved'); }); + + test('#557: preserves an active
block while stripping shipped bare
', () => { + //
marks the ACTIVE milestone (roadmap.analyze must still see its + // phases); only closed/shipped bare
blocks are stripped. Regression for + // #557, which the #2128 shared-seam migration briefly reintroduced via the seam's + // attribute-tolerance — the details strip is now attr-INTOLERANT to keep #557 fixed. + const input = '
\nshipped phase\n
\n
\n- [ ] **Phase 9: Active**\n
\nafter'; + const result = stripShippedMilestones(input); + assert.ok(!result.includes('shipped phase'), 'shipped bare
stripped'); + assert.ok(result.includes('
'), 'active
tag preserved'); + assert.ok(result.includes('Phase 9: Active'), 'active-milestone phases preserved'); + assert.ok(result.includes('after'), 'trailing content preserved'); + }); }); // ─── extractCurrentMilestone ────────────────────────────────────────────────── From 27f73cb43f42b5f7bb99eb92ef073287e9c9fd59 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Fri, 10 Jul 2026 11:42:32 -0400 Subject: [PATCH 31/31] docs(changeset): backfill PR number (#2141) Co-Authored-By: Claude Opus 4.8 --- .changeset/phase-id-redos-hardening.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/phase-id-redos-hardening.md b/.changeset/phase-id-redos-hardening.md index 816a004a6..df9878105 100644 --- a/.changeset/phase-id-redos-hardening.md +++ b/.changeset/phase-id-redos-hardening.md @@ -1,5 +1,5 @@ --- type: Security -pr: 0 +pr: 2141 --- **Hardened phase/roadmap/plan markdown parsing against quadratic-time (ReDoS) CPU exhaustion** — a crafted `ROADMAP.md`, `STATE.md`, or `PLAN.md` with large runs of unclosed `(`, `[`, ``, `