* chore(#3560): delete two unreachable workflows, gate reachability in lint discovery-phase.md and plan-milestone-gaps.md shipped to all 19 runtime install trees with no command, agent, or skill referencing them. plan-milestone-gaps' command was deleted by #2790 and the workflow was left behind; discovery-phase's own header claimed a caller in plan-phase.md's mandatory_discovery step, and that step does not exist — plan-phase.md contains zero occurrences of "discovery". docs/INVENTORY.md asserted discovery-phase.md was an alternate entry for /gsd-new-project. new-project.md never referenced it. The row and the matching note sentence are removed across all five locales rather than corrected. Adds rule 6 to lint-command-contract: every shipped workflow must be reachable from a loader, walking the transitive closure over the three reference shapes this repo uses. The closure seeds ONLY from commands/agents/skills, so a workflow that references only itself and a pair that reference only each other are both correctly reported rather than satisfying themselves; a visited set makes reference cycles terminate. The measure is a mention in a LOADER — docs/ and install-tree fixtures deliberately do not count, because scan.md proved a file can be documented and shipped while entirely unreached. Ships blocking, not report-only: #3561 is in this branch's base, so the tree reports 0 unreachable from the start. Closes #3560 * test(#3560): drive rule 6 end-to-end, sweep a stale allowlist, update ADR-0002 Review findings. Rule 6 had no end-to-end coverage: the tests exercised the pure closure with in-memory data, so the wiring — file collection, exit code, diagnostic — was unproven, and #3560's acceptance list explicitly wants a fixture showing the rule FAILS on a planted orphan. Adds an optional --root to lint-command-contract (default behavior unchanged) and four tests driving the real CLI through the process seam against a temp fixture: clean=0, planted orphan=1, orphan referenced only from docs/=1, orphan reachable transitively=0. The docs/ case is what pins the Goodhart defense — a mention outside a loader must not confer reachability. Deletes two tests that were byte-identical to a third and could not assert anything loader-specific, since the closure is source-agnostic by design; that distinction lives in the lint script's file collection and is now covered above. Removes a stale ALLOWLIST entry for discovery-phase.md in planner-language-regression — the exact sweep-miss class rule 6 exists to catch, found in the PR that adds the rule. ADR-0002 described five per-file frontmatter checks; rule 6 is a repo-level reachability graph, so the Decision section now says so. Refs #3560 * test(#3560): cut the bug-3298 test pin on the deleted plan-milestone-gaps workflow The remote runner went red with four failures: tests/phase.test.cjs asserted the plan-milestone-gaps workflow exists and checked its mkdir patterns, so deleting the file broke the test that pinned it. This is the fence the epic describes — the content-sync test IS what keeps an unreachable file alive — and cutting the coupling is what makes the deletion safe. Removes only that arm. The bug-3298 block guards three workflows against phase-dir prefix drift; the import and add-backlog arms and both shared mkdir-pattern helpers are untouched. Worth recording where the sweep failed: my reachability walk covered commands, agents, skills, gsd-core and docs, and lint-removed-but-needed covers .github/workflows, gsd-core, docs and package.json. Neither looks at tests/, so a test-pinned deletion is invisible to both and surfaces only on the remote runner. The how-to added by this PR names that gap explicitly so the next deletion searches tests/ by hand. Refs #3560 * docs(#3560): add a how-to for resolving unreachable-workflow findings * chore(#3560): backfill changeset pr number to 3564 --------- Co-authored-by: sim <sim@local>
182 lines
7.0 KiB
JavaScript
182 lines
7.0 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;
|
|
}
|
|
|
|
/**
|
|
* unreachableWorkflows(loaderContents, gsdFiles, workflowPaths)
|
|
*
|
|
* Computes reachability over the gsd-core file graph and reports which
|
|
* `workflowPaths` are never reached, starting only from `loaderContents`
|
|
* (commands/agents/skills — the files a runtime actually loads) and walking
|
|
* `workflowPathRefs` edges transitively through `gsdFiles`.
|
|
*
|
|
* The seed set is deliberately restricted to loaders and never includes a
|
|
* workflow's own content. Seeding from workflows too would let two failure
|
|
* modes hide: a workflow that only references itself would satisfy its own
|
|
* reachability, and a pair of workflows that reference only each other would
|
|
* form an island that looks connected from the inside but that no command,
|
|
* agent, or skill ever actually opens. Both are orphans in every sense that
|
|
* matters — nothing external can reach them — and both must be reported.
|
|
* Requiring every path to originate at a loader is what makes "reachable"
|
|
* mean "a runtime can actually get here," not merely "something points to
|
|
* it."
|
|
*
|
|
* `gsdFiles` covers all of `gsd-core/**`, not just `workflows/`, because a
|
|
* `references/` or `templates/` file can itself name a workflow path and
|
|
* needs to be walked through to propagate reachability — restricting the map
|
|
* to `workflows/` would silently break any chain that passes through a
|
|
* non-workflow file.
|
|
*
|
|
* `visited` guards the walk against reference cycles (including the
|
|
* mutual/self cases above) so traversal always terminates.
|
|
*/
|
|
function unreachableWorkflows(loaderContents, gsdFiles, workflowPaths) {
|
|
const visited = new Set();
|
|
const queue = [];
|
|
|
|
for (const content of loaderContents) {
|
|
for (const ref of workflowPathRefs(content)) queue.push(ref);
|
|
}
|
|
|
|
while (queue.length > 0) {
|
|
const p = queue.pop();
|
|
if (visited.has(p)) continue;
|
|
visited.add(p);
|
|
if (gsdFiles.has(p)) {
|
|
for (const ref of workflowPathRefs(gsdFiles.get(p))) queue.push(ref);
|
|
}
|
|
}
|
|
|
|
return workflowPaths.filter(p => !visited.has(p));
|
|
}
|
|
|
|
module.exports = {
|
|
CANONICAL_TOOLS,
|
|
parseFrontmatter,
|
|
executionContextRefs,
|
|
workflowPathRefs,
|
|
unreachableWorkflows,
|
|
};
|