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>
This commit is contained in:
Tom Boucher
2026-08-15 23:29:30 -04:00
committed by GitHub
parent c2e453e2a1
commit c5b83cb050
36 changed files with 607 additions and 669 deletions

View File

@@ -124,4 +124,58 @@ function workflowPathRefs(content) {
return refs;
}
module.exports = { CANONICAL_TOOLS, parseFrontmatter, executionContextRefs, workflowPathRefs };
/**
* 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,
};

View File

@@ -9,6 +9,9 @@
* 3. allowed-tools: block present, non-empty, all entries from CANONICAL_TOOLS
* 4. execution_context @-refs: every @-reference resolves to an existing file on disk
* 5. execution_context @-refs: each appears on its own line (no trailing prose)
* 6. every gsd-core/workflows/*.md file is reachable from at least one
* commands/agents/skills loader, transitively through gsd-core/**
* (repo-level check, runs once — not per command file)
*
* Exit 0 = clean. Exit 1 = violations (with diagnostics).
*/
@@ -18,7 +21,17 @@
const fs = require('fs');
const path = require('path');
const ROOT = path.join(__dirname, '..');
function resolveRoot(argv) {
const idx = argv.indexOf('--root');
if (idx === -1) return path.join(__dirname, '..');
const value = argv[idx + 1];
if (!value) {
throw new Error('lint-command-contract: --root requires a directory argument');
}
return path.resolve(value);
}
const ROOT = resolveRoot(process.argv.slice(2));
const COMMANDS_DIR = path.join(ROOT, 'commands', 'gsd');
const GSD_ROOT = path.join(ROOT, 'gsd-core');
@@ -26,10 +39,57 @@ const {
CANONICAL_TOOLS,
parseFrontmatter,
executionContextRefs: extractExecutionContextRefs,
unreachableWorkflows,
} = require('./command-contract-helpers.cjs');
const { runMain } = require('./lib/cli-exit.cjs');
// ─── rule 6: repo-level workflow reachability ─────────────────────────────────
function walkMarkdownFiles(dir, acc) {
let entries;
try {
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch (e) {
return acc;
}
for (const entry of entries) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
walkMarkdownFiles(full, acc);
} else if (entry.isFile() && entry.name.endsWith('.md')) {
acc.push(full);
}
}
return acc;
}
function toGsdRelative(absPath) {
return path.relative(GSD_ROOT, absPath).split(path.sep).join('/');
}
function checkWorkflowReachability() {
const loaderFiles = [
...walkMarkdownFiles(path.join(ROOT, 'commands'), []),
...walkMarkdownFiles(path.join(ROOT, 'agents'), []),
...walkMarkdownFiles(path.join(ROOT, 'skills'), []),
];
const loaderContents = loaderFiles.map(f => fs.readFileSync(f, 'utf-8'));
const gsdFileAbsPaths = walkMarkdownFiles(GSD_ROOT, []);
const gsdFiles = new Map();
for (const abs of gsdFileAbsPaths) {
gsdFiles.set(toGsdRelative(abs), fs.readFileSync(abs, 'utf-8'));
}
const workflowAbsPaths = walkMarkdownFiles(path.join(GSD_ROOT, 'workflows'), []);
const workflowPaths = workflowAbsPaths.map(toGsdRelative);
const unreachable = unreachableWorkflows(loaderContents, gsdFiles, workflowPaths);
return { workflowCount: workflowPaths.length, unreachable };
}
// ─── check one file ───────────────────────────────────────────────────────────
function check(filePath) {
@@ -88,27 +148,50 @@ function main() {
.map(f => path.join(COMMANDS_DIR, f));
const results = commandFiles.map(check).filter(Boolean);
const { workflowCount, unreachable } = checkWorkflowReachability();
let ok = true;
if (results.length === 0) {
console.log(
`ok lint-command-contract: ${commandFiles.length} command files checked, 0 violations`,
);
return 0;
} else {
ok = false;
const total = results.reduce((n, r) => n + r.violations.length, 0);
process.stderr.write(
`\nERROR lint-command-contract: ${total} violation(s) across ${results.length} file(s)\n\n`,
);
for (const r of results) {
process.stderr.write(` ${r.file}\n`);
for (const v of r.violations) {
process.stderr.write(` - ${v}\n`);
}
process.stderr.write('\n');
}
process.stderr.write('See docs/adr/0002-command-contract-validation-module.md for the contract spec.\n\n');
}
const total = results.reduce((n, r) => n + r.violations.length, 0);
process.stderr.write(
`\nERROR lint-command-contract: ${total} violation(s) across ${results.length} file(s)\n\n`,
);
for (const r of results) {
process.stderr.write(` ${r.file}\n`);
for (const v of r.violations) {
process.stderr.write(` - ${v}\n`);
if (unreachable.length === 0) {
console.log(
`ok lint-command-contract: ${workflowCount} workflow files, ${workflowCount} reachable, 0 unreachable`,
);
} else {
ok = false;
process.stderr.write(
`\nERROR lint-command-contract: ${unreachable.length} unreachable workflow file(s) of ${workflowCount}\n\n`,
);
for (const p of unreachable) {
process.stderr.write(` gsd-core/${p}\n`);
}
process.stderr.write('\n');
process.stderr.write(
'\nEach file above ships to every runtime but is never referenced by any command,\n' +
'agent, or skill loader (directly or transitively). Either wire it to a loader\n' +
'or delete it — removing a command must sweep its orphaned workflow.\n\n',
);
}
process.stderr.write('See docs/adr/0002-command-contract-validation-module.md for the contract spec.\n\n');
return 1;
return ok ? 0 : 1;
}
runMain(main);