Files
msd-core/scripts/command-contract-helpers.cjs
Tom Boucher c5b83cb050 chore(#3560): delete two unreachable workflows, gate workflow reachability in lint (#3564)
* 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>
2026-08-15 23:29:30 -04:00

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,
};