diff --git a/.changeset/gentle-wolves-hum.md b/.changeset/gentle-wolves-hum.md new file mode 100644 index 000000000..d52fd5ca2 --- /dev/null +++ b/.changeset/gentle-wolves-hum.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 3562 +--- +**`/gsd-map-codebase --fast` now actually runs the fast scan** — the flag routed to "the scan workflow" in prose but named no path any runtime could resolve, and the command loaded only the full four-agent map workflow, so `scan.md` was never read and the single-agent scan was improvised rather than executed. (#3561) diff --git a/commands/gsd/map-codebase.md b/commands/gsd/map-codebase.md index c11d56d69..35541b4c9 100644 --- a/commands/gsd/map-codebase.md +++ b/commands/gsd/map-codebase.md @@ -34,7 +34,7 @@ Output: .planning/codebase/ folder with 7 structured documents about the codebas Arguments: $ARGUMENTS Parse the first token of $ARGUMENTS: -- If it is `--fast`: strip the flag, run the scan workflow (passing remaining args including optional --focus). +- If it is `--fast`: strip the flag, then read and execute `~/.claude/gsd-core/workflows/scan.md` (passing remaining args including optional --focus). Load it on demand here — it is deliberately not in ``, so the common full-map path does not pay for it. - If it is `--query`: strip the flag, run the intel workflow (passing remaining args as the subcommand). - Otherwise: pass all of $ARGUMENTS as focus area to the map-codebase workflow. diff --git a/scripts/command-contract-helpers.cjs b/scripts/command-contract-helpers.cjs index 209e8c4a4..3a1179d6b 100644 --- a/scripts/command-contract-helpers.cjs +++ b/scripts/command-contract-helpers.cjs @@ -61,4 +61,67 @@ function executionContextRefs(content) { return refs; } -module.exports = { CANONICAL_TOOLS, parseFrontmatter, executionContextRefs }; +/** + * workflowPathRefs(content) + * + * Locates every gsd-core-relative workflow path referenced in a markdown + * string, whether the reference is an eager @-include (already covered by + * executionContextRefs) or a *lazy* path mentioned only in prose/code — a + * path a command reads on demand via Read/Bash rather than an @-inclusion + * the harness inlines automatically. Both kinds are load-bearing: the + * progressive-disclosure split (#717) deliberately keeps most workflow + * content out of the eager path so the common case stays cheap, but that + * means a command naming a workflow only in prose is invisible to + * executionContextRefs even though the runtime still needs the file to + * exist. Recognizes three reference shapes: + * + * A. Any path whose segments include `workflows/`, optionally preceded by + * an eager `@`, a home-dir prefix (`~/` or `$HOME/`), `.claude/`, and/or + * `gsd-core/` — e.g. `@~/.claude/gsd-core/workflows/scan.md`, + * `gsd-core/workflows/x.md`, or a bare `workflows/x.md`. + * B. Same as A but without the eager `@` — a lazy reference read on + * demand rather than inlined at load time. + * C. Parent-relative sub-file paths with no `workflows/` prefix at all — + * `execute-phase/steps/post-merge-gate.md` — implicitly rooted under + * `workflows/` because that's the only place `steps/`, `modes/`, and + * `templates/` subdirectories live. + * + * Traversal segments (`..`) are dropped rather than surfaced: this resolver + * only ever reports paths under `workflows/`, never something a `..` could + * walk outside of it. Results are de-duplicated, first-seen order preserved. + * + * Both regexes anchor `\.md` with a trailing `(?![A-Za-z0-9_])` negative + * lookahead so a longer extension (`.mdx`, `.md5`) is rejected outright + * rather than silently truncated into a plausible-looking `.md` path. + */ +function workflowPathRefs(content) { + const refs = []; + const seen = new Set(); + + function addRef(normalized) { + if (normalized.split('/').includes('..')) return; + if (seen.has(normalized)) return; + seen.add(normalized); + refs.push(normalized); + } + + const shapeARe = /@?(?:(?:~|\$HOME)\/)?(?:\.claude\/)?(?:gsd-core\/)?workflows\/[A-Za-z0-9._/-]+\.md(?![A-Za-z0-9_])/g; + let m; + while ((m = shapeARe.exec(content)) !== null) { + const normalized = m[0] + .replace(/^@/, '') + .replace(/^(?:~|\$HOME)\//, '') + .replace(/^\.claude\//, '') + .replace(/^gsd-core\//, ''); + addRef(normalized); + } + + const shapeCRe = /(?:^|[\s`("'>])([A-Za-z0-9._-]+\/(?:steps|modes|templates)\/[A-Za-z0-9._-]+\.md(?![A-Za-z0-9_]))/gm; + while ((m = shapeCRe.exec(content)) !== null) { + addRef('workflows/' + m[1]); + } + + return refs; +} + +module.exports = { CANONICAL_TOOLS, parseFrontmatter, executionContextRefs, workflowPathRefs }; diff --git a/skills/gsd-map-codebase/SKILL.md b/skills/gsd-map-codebase/SKILL.md index 36c909128..41f047927 100644 --- a/skills/gsd-map-codebase/SKILL.md +++ b/skills/gsd-map-codebase/SKILL.md @@ -34,7 +34,7 @@ Output: .planning/codebase/ folder with 7 structured documents about the codebas Arguments: $ARGUMENTS Parse the first token of $ARGUMENTS: -- If it is `--fast`: strip the flag, run the scan workflow (passing remaining args including optional --focus). +- If it is `--fast`: strip the flag, then read and execute `~/.claude/gsd-core/workflows/scan.md` (passing remaining args including optional --focus). Load it on demand here — it is deliberately not in ``, so the common full-map path does not pay for it. - If it is `--query`: strip the flag, run the intel workflow (passing remaining args as the subcommand). - Otherwise: pass all of $ARGUMENTS as focus area to the map-codebase workflow. diff --git a/tests/command-contract.test.cjs b/tests/command-contract.test.cjs index cf124132e..3d6f136df 100644 --- a/tests/command-contract.test.cjs +++ b/tests/command-contract.test.cjs @@ -32,6 +32,7 @@ const { CANONICAL_TOOLS, parseFrontmatter, executionContextRefs, + workflowPathRefs, } = require('../scripts/command-contract-helpers.cjs'); const commandFiles = fs @@ -114,6 +115,174 @@ describe('command contract: execution_context @-refs on own line (ADR-0002)', () } }); +describe('#3561 — workflowPathRefs resolver', () => { + test('eager @-include', () => { + assert.deepEqual( + workflowPathRefs('@~/.claude/gsd-core/workflows/x.md'), + ['workflows/x.md'], + ); + }); + + test('lazy tilde path', () => { + assert.deepEqual( + workflowPathRefs('read `~/.claude/gsd-core/workflows/x.md` now'), + ['workflows/x.md'], + ); + }); + + test('repo-relative path', () => { + assert.deepEqual( + workflowPathRefs('see gsd-core/workflows/x.md'), + ['workflows/x.md'], + ); + }); + + test('bare workflows path', () => { + assert.deepEqual( + workflowPathRefs('see workflows/x.md'), + ['workflows/x.md'], + ); + }); + + test('parent-relative steps path', () => { + assert.deepEqual( + workflowPathRefs('run execute-phase/steps/post-merge-gate.md'), + ['workflows/execute-phase/steps/post-merge-gate.md'], + ); + }); + + test('parent-relative modes path', () => { + assert.deepEqual( + workflowPathRefs('use discuss-phase/modes/power.md'), + ['workflows/discuss-phase/modes/power.md'], + ); + }); + + test('empty content yields no refs', () => { + assert.deepEqual(workflowPathRefs(''), []); + }); + + test('unrelated prose yields no refs', () => { + assert.deepEqual(workflowPathRefs('nothing to see here'), []); + }); + + test('whitespace-only yields no refs', () => { + assert.deepEqual(workflowPathRefs(' \n\t \n'), []); + }); + + test('de-duplicates repeated refs', () => { + const refs = workflowPathRefs('workflows/x.md and again workflows/x.md'); + assert.deepEqual(refs, ['workflows/x.md']); + assert.equal(refs.length, 1); + }); + + test('CRLF-tolerant', () => { + assert.deepEqual( + workflowPathRefs('workflows/a.md\r\nworkflows/b.md'), + ['workflows/a.md', 'workflows/b.md'], + ); + }); + + test('ignores non-workflow md paths', () => { + assert.deepEqual( + workflowPathRefs('docs/GUIDE.md README.md references/r.md'), + [], + ); + }); + + test('surfaces a ref whose target is absent', () => { + assert.deepEqual( + workflowPathRefs('workflows/does-not-exist.md'), + ['workflows/does-not-exist.md'], + ); + }); + + test('does not emit a traversing path', () => { + const refs = workflowPathRefs('workflows/../../etc/passwd.md'); + for (const ref of refs) { + assert.ok(!ref.includes('..'), `traversal path leaked: ${ref}`); + } + }); + + test('tolerates an overlong path', () => { + const content = 'workflows/' + 'a'.repeat(5000) + '.md'; + assert.doesNotThrow(() => workflowPathRefs(content)); + }); + + test('rejects a .mdx path', () => { + assert.deepEqual(workflowPathRefs('workflows/foobar.mdx'), []); + }); + + test('rejects a .md5 path', () => { + assert.deepEqual(workflowPathRefs('workflows/foo.md5'), []); + }); + + test('still accepts a .md path followed by punctuation', () => { + assert.deepEqual( + workflowPathRefs('see workflows/x.md, then stop'), + ['workflows/x.md'], + ); + }); + + test('binds to the --fast line, not the whole file', () => { + const syntheticFile = [ + '- If it is `--fast`: strip the flag, run the scan workflow.', + '', + ].join('\n'); + const fastLine = syntheticFile + .split(/\r?\n/) + .find(line => /^-\s*If it is\s*`--fast`/.test(line)); + assert.ok(fastLine, 'setup error: synthetic fixture missing --fast routing line'); + assert.ok( + !workflowPathRefs(fastLine).includes('workflows/scan.md'), + 'regression guard: the --fast routing line itself names no resolvable path — ' + + 'an unrelated comment elsewhere in the file must not make this test pass', + ); + }); +}); + +describe('#3561 — /gsd-map-codebase --fast routes to a loadable workflow', () => { + const mapCodebasePath = path.join(COMMANDS_DIR, 'map-codebase.md'); + const mapCodebaseContent = fs.readFileSync(mapCodebasePath, 'utf-8'); + + test('map-codebase: --fast routing names a loadable scan.md', () => { + const fastLine = mapCodebaseContent + .split(/\r?\n/) + .find(line => /^-\s*If it is\s*`--fast`/.test(line)); + assert.ok( + fastLine, + '#3561: commands/gsd/map-codebase.md has no "--fast" routing bullet ' + + '(expected a line matching /^-\\s*If it is\\s*`--fast`/) — the routing logic is missing entirely', + ); + const refs = workflowPathRefs(fastLine); + assert.ok( + refs.includes('workflows/scan.md'), + '#3561: --fast routes to the scan workflow but the routing line names no path ' + + 'the runtime can resolve, so scan.md is never loaded', + ); + }); + + test('full map does not eagerly load scan.md', () => { + const refs = executionContextRefs(mapCodebaseContent); + assert.equal(refs.length, 1); + assert.equal(refs[0].normalized, 'workflows/map-codebase.md'); + }); +}); + +describe('#3561 — every workflow path referenced by a command exists on disk', () => { + for (const { name, full } of commandFiles) { + test(`${name}: all workflowPathRefs paths exist on disk`, () => { + const refs = workflowPathRefs(fs.readFileSync(full, 'utf-8')); + for (const ref of refs) { + assert.ok( + fs.existsSync(path.join(GSD_ROOT, ref)), + `${name}: referenced workflow path "${ref}" does not exist under ${GSD_ROOT}`, + ); + } + }); + } +}); + // ──────────────────────────────────────────────────────────────────────── // Folded from tests/bug-3168-task-to-agent-rename.test.cjs — consolidation epic #1969 (B3 #1972)