* test(#3561): failing-first coverage for the --fast dangling dispatch /gsd-map-codebase --fast routes to "the scan workflow" in prose, but commands/gsd/map-codebase.md names no resolvable path and its execution_context includes only map-codebase.md, so scan.md is never loaded. Same class as epic #1891's F8/F9: dispatch keyed on a token that never arrives. Adds workflowPathRefs() to command-contract-helpers.cjs — one shared pure resolver for the three reference shapes this repo uses (eager @-include, lazy absolute-ish path, lazy parent-relative steps//modes/ path). Placing it in the helpers module keeps the lint script and the test suite reading the same definition, which is what that module exists for; #3560 consumes the same function rather than re-deriving it. Tests 16/17 are RED until the routing fix lands. Refs #3561 * fix(#3561): route --fast to a scan.md path the runtime can resolve commands/gsd/map-codebase.md documented --fast and told the agent to "run the scan workflow", but named no path and included only map-codebase.md in execution_context, so gsd-core/workflows/scan.md was never loaded and the single-agent scan was improvised. Names the path in the routing line so it is read on demand, rather than adding an eager @-include: --fast is the minority path and the progressive-disclosure split (#717) exists to keep the common full-map invocation from paying for it. The accompanying test pins that choice — execution_context must still carry exactly one @-ref. skills/gsd-map-codebase/SKILL.md is regenerated, not hand-edited. Closes #3561 * fix(#3561): bound the .md match and bind the regression test to the --fast line Two majors from the isolated adversarial review. Both resolver regexes ended at a literal .md with no trailing boundary, so a longer extension was truncated into a plausible-looking but wrong path: workflows/foobar.mdx returned workflows/foobar.md. Adds a (?![A-Za-z0-9_]) lookahead to both shapes so .mdx and .md5 are rejected outright rather than silently rewritten. The regression test scanned the whole command file, so it did not bind to the defect — the reviewer showed that an unrelated comment mentioning scan.md anywhere made it pass while the dispatch defect was still present. It now extracts the "- If it is `--fast`" bullet and scans that line alone, and a new test drives a synthetic pre-fix fixture to prove the false-pass path is closed. Refs #3561 * chore(#3561): backfill changeset pr number to 3562 --------- Co-authored-by: sim <sim@local>
128 lines
4.9 KiB
JavaScript
128 lines
4.9 KiB
JavaScript
'use strict';
|
|
/**
|
|
* command-contract-helpers.cjs (ADR-0002)
|
|
*
|
|
* Single source of truth for the commands/gsd/*.md contract constants and
|
|
* parsers shared by scripts/lint-command-contract.cjs and
|
|
* tests/command-contract.test.cjs.
|
|
*
|
|
* Keeping these in one place ensures the lint script and the test suite
|
|
* always agree on what constitutes a valid tool, a valid @-ref, and a valid
|
|
* frontmatter structure. A new canonical tool added here is automatically
|
|
* enforced by both consumers.
|
|
*/
|
|
|
|
const CANONICAL_TOOLS = new Set([
|
|
'Read', 'Write', 'Edit', 'Bash', 'Glob', 'Grep',
|
|
'Task', 'Agent', 'Skill', 'SlashCommand',
|
|
'AskUserQuestion', 'WebFetch', 'WebSearch', 'TodoWrite',
|
|
'mcp__context7__resolve-library-id',
|
|
'mcp__context7__query-docs',
|
|
'mcp__context7__*',
|
|
]);
|
|
|
|
function parseFrontmatter(content) {
|
|
// CRLF-tolerant split: Windows checkouts (autocrlf=true) leave a trailing
|
|
// \r on every line, making lines.indexOf('---', 1) return -1 (the value
|
|
// would be '---\r', not '---') → returns {} → every field appears missing.
|
|
const lines = content.split(/\r?\n/);
|
|
if (lines[0].trim() !== '---') return {};
|
|
const end = lines.indexOf('---', 1);
|
|
if (end === -1) return {};
|
|
const fm = {};
|
|
let key = null;
|
|
for (const line of lines.slice(1, end)) {
|
|
const kv = line.match(/^([a-zA-Z0-9_-]+):\s*(.*)/);
|
|
if (kv) { key = kv[1]; fm[key] = kv[2].trim(); }
|
|
else if (key && line.match(/^\s+-\s+/)) {
|
|
const val = line.replace(/^\s+-\s+/, '').trim();
|
|
fm[key] = fm[key] ? fm[key] + '\n' + val : val;
|
|
}
|
|
}
|
|
return fm;
|
|
}
|
|
|
|
function executionContextRefs(content) {
|
|
const refs = [];
|
|
const re = /<execution_context(?:_extended)?>([\s\S]*?)<\/execution_context(?:_extended)?>/g;
|
|
let m;
|
|
while ((m = re.exec(content)) !== null) {
|
|
for (const rawLine of m[1].split('\n')) {
|
|
const line = rawLine.trim();
|
|
if (!line.startsWith('@')) continue;
|
|
const token = line.split(/\s+/)[0];
|
|
const trailingProse = line.length > token.length;
|
|
const normalized = token
|
|
.replace(/^@(?:~|\$HOME)\//, '')
|
|
.replace(/^(?:\.claude\/)?(?:gsd-core\/)?/, '');
|
|
refs.push({ token, normalized, trailingProse });
|
|
}
|
|
}
|
|
return refs;
|
|
}
|
|
|
|
/**
|
|
* 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 };
|