refactor(#3309): add roadmap-disk-consistency health-diagnostic rules

W006, W007 — ROADMAP entries with no matching disk dir, and disk dirs
with no ROADMAP entry, migrated onto the frozen rule table per
ADR-3180 §8.2.
This commit is contained in:
sim
2026-08-13 01:37:42 -04:00
parent f51f00ef0e
commit 9e74f00ba0
2 changed files with 495 additions and 0 deletions

View File

@@ -0,0 +1,225 @@
/**
* Health Diagnostic — ROADMAP/disk consistency rules (Phase 11, #3309,
* ADR-3180 §8.2/§8.3/§8.5).
*
* Group: "ROADMAP/disk consistency" (design doc, "Rule table organization"
* table) — W006, W007.
*
* Ported behavior-preserving from `cmdValidateHealth`
* (`src/verify.cts:2029-2101`, the exact call sites for W006/W007).
*
* Both rules share ONE matcher — `matchPhaseDirs` + `normalizePhaseName`
* (`src/phase-id.cts`), the same canonical directory-resolution owner
* `verify.cts:2060/2073` already calls (its own #2528 comment explains why:
* pairing roadmap phases against disk by intersecting independently-derived
* TOKEN SETS mislabels digit-leading slugs like `05-80-20-cleanup` in BOTH
* directions at once — phase 5 reads as missing a directory (W006) AND that
* directory reads as not in the roadmap (W007) — so both rules here resolve
* through `matchPhaseDirs`, never a hand-rolled string/token comparison, and
* `dirsForPhase` below is the single call site both go through, so they
* cannot independently drift on what "matches" means (#2528's own bug
* class).
*
* DISK-SIDE SOURCE — `allPhaseDirNames`, NOT `phaseDirs` (found while
* implementing this file, fixed inline rather than deferred).
* `snapshot.phaseDirs` (Phase 10, `listMilestonePhaseDirs`) is WINDOWED: its
* `inWindow` filter (`getMilestonePhaseFilter`, `src/roadmap-parser.cts:1220`)
* admits a directory only when its phase id is a MEMBER of the roadmap's
* current-milestone-declared phase set (`isDirInMilestone`). That makes
* `phaseDirs.value` a subset that, by construction, can never contain a
* directory the roadmap does NOT declare — exactly the directory W007 exists
* to find. Sourced from `phaseDirs`, W007 would be structurally inert: every
* member of the set is already provably claimable. Verified empirically
* (`node -e` trace against a real `buildPlanningSnapshot`): a genuine orphan
* directory (`04-extra`, no roadmap entry) was silently absent from
* `phaseDirs.value` and W007 fired zero diagnostics. `phaseDirs`'s windowing
* also risks a W006 false positive for a phase declared in a NON-current
* milestone section (`roadmapDeclaredPhases` is built from the FULL raw
* ROADMAP, all milestones — `src/planning-snapshot.cts:398-436` — while
* `phaseDirs` is scoped to the current milestone only), so both rules here
* use the new, additive `allPhaseDirNames` field
* (`src/planning-snapshot.cts`) instead: every directory actually present
* under the active `phases/` root, unfiltered by roadmap declaration.
* Archived-milestone directory names (`verify.cts:2050`,
* `collectArchivedPhaseDirNames`) are still not part of `PlanningSnapshot`
* and remain a disclosed fidelity reduction (a phase whose only directory
* lives in a shipped-milestone archive can read as W006-missing; a shipped
* archived dir is never scanned so it cannot spuriously read as
* W007-orphaned either) — unchanged by this fix.
*
* Not-started exclusion (verify.cts:2065/2075-2076,
* `buildNotStartedPhaseVariants`, `src/validate.cts:160`): the design doc's
* field table assigns this group only `roadmapDeclaredPhases`/`phaseDirs`,
* and `roadmapDeclaredPhases` (`src/planning-snapshot.cts:398-436`) does
* NOT filter not-started phases out — it returns every heading- and
* checklist-declared phase id regardless of checked state (confirmed by
* direct read: its `buildRoadmapPhaseVariants` call includes BOTH `[x]` and
* `[ ]` checklist entries). Omitting the exclusion here would regress a
* COMMON case: `gsd-core/templates/roadmap.md`'s "Initial Roadmap" shape
* declares every phase as an unchecked `- [ ] **Phase N: [Name]**` checklist
* item before any phase directory exists, so a freshly created ROADMAP.md
* would immediately spam one W006 per phase. `snapshot.roadmapPhaseCheckboxes`
* (`src/planning-snapshot.cts:457-480`, backs W011 in the STATE.md-consistency
* group) already parses exactly this `[x]`/`[ ]` state — `check(snapshot)`'s
* signature grants the full snapshot, not just this group's assigned column,
* so `isPhaseNotStarted` below reads it directly rather than re-deriving a
* third independent regex over raw ROADMAP text (forbidden by §8.1 rule 2).
* KNOWN GAP, disclosed rather than silently dropped: `roadmapPhaseCheckboxes`
* is keyed by `PHASE_NUMBER_TOKEN_SOURCE` (`phase-id.cts:54`, no dash), so a
* milestone-dash-prefixed phase id ("2-01") can never match a checkbox key —
* unlike the original `buildNotStartedPhaseVariants`, which captures the
* fuller `[\w][\w.-]*` grammar (dashes included). For that id shape only,
* this rule's not-started exclusion silently no-ops (never excludes), which
* is the conservative direction (a possible false W006, not a suppressed
* true one).
*
* Design: .gsd/phase/refactor-3309-health-diagnostic-rule-table/40-design.md
*
* ADR-457 build-at-publish: source in
* src/health-diagnostic-rules/roadmap-disk-consistency.cts, compiled to
* gsd-core/bin/lib/health-diagnostic-rules/roadmap-disk-consistency.cjs
* (gitignored).
*/
// eslint-disable-next-line @typescript-eslint/no-require-imports -- type-only; erased at compile time, no runtime require emitted
import type planningSnapshotMod = require('../planning-snapshot.cjs');
type PlanningSnapshot = ReturnType<typeof planningSnapshotMod.buildPlanningSnapshot>;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import healthDiagnosticMod = require('../health-diagnostic.cjs');
const { SEVERITY, REMEDY_ACTION, REMEDY_RISK } = healthDiagnosticMod;
type Diagnostic = healthDiagnosticMod.Diagnostic;
type Rule = healthDiagnosticMod.Rule;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import planningScopeMod = require('../planning-scope.cjs');
const { SCOPE } = planningScopeMod;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import phaseIdMod = require('../phase-id.cjs');
const { matchPhaseDirs, normalizePhaseName, extractPhaseToken, isSentinelPhaseId } = phaseIdMod;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import validateMod = require('../validate.cjs');
const { phaseVariants } = validateMod;
// ─── Shared matcher — the single call site both W006 and W007 go through ───
/**
* Every on-disk directory (from `allPhaseDirNames.value`) that `phaseId`
* resolves to via the canonical `matchPhaseDirs` selection. Both `checkW006`
* (does ANY directory resolve) and `computeClaimedDirs` (which directories
* does the roadmap claim, for W007) call this — one matcher, reused, per the
* file-level comment.
*/
function dirsForPhase(dirs: string[], phaseId: string): string[] {
return matchPhaseDirs(dirs, normalizePhaseName(phaseId)).matches;
}
/**
* True when `phaseId` has an unchecked (`[ ]`) checklist entry in
* `roadmapPhaseCheckboxes` under any of its padding/case variants
* (`phaseVariants`, `src/validate.cts:101` — the same variant-expansion
* owner `verify.cts:2071/2075` uses for this exact exclusion). See the
* file-level comment for the KNOWN GAP on dash-shaped ids.
*/
function isPhaseNotStarted(phaseId: string, checkboxes: Record<string, boolean>): boolean {
for (const variant of phaseVariants(phaseId)) {
if (Object.prototype.hasOwnProperty.call(checkboxes, variant) && checkboxes[variant] === false) {
return true;
}
}
return false;
}
// ─── W006 — ROADMAP.md declares a phase with no directory on disk ─────────
// (verify.cts:2067-2084)
function checkW006(snapshot: PlanningSnapshot): Diagnostic[] {
// Mirrors verify.cts:2029's `if (fs.existsSync(roadmapPath))` guard: ROADMAP.md
// absent or unreadable means the field degrades to `{value: [], scope:
// UNREADABLE}` (`src/planning-snapshot.cts:401-403/407-409`) and NEITHER
// W006 nor W007 evaluates — an empty declared-phase list must not be
// mistaken for "the roadmap legitimately declares zero phases" here.
if (snapshot.roadmapDeclaredPhases.scope !== SCOPE.COMPLETE) return [];
const dirs = snapshot.allPhaseDirNames.value;
const checkboxes = snapshot.roadmapPhaseCheckboxes.value;
const diagnostics: Diagnostic[] = [];
for (const { phaseId } of snapshot.roadmapDeclaredPhases.value) {
// #3225: sentinel phase ids (999.x/0.x) are never-on-roadmap by
// convention; a sentinel heading shouldn't demand a directory.
if (isSentinelPhaseId(phaseId)) continue;
if (dirsForPhase(dirs, phaseId).length > 0) continue;
if (isPhaseNotStarted(phaseId, checkboxes)) continue;
diagnostics.push({
code: 'W006',
severity: SEVERITY.WARNING,
message: `Phase ${phaseId} in ROADMAP.md but no directory on disk`,
remedy: {
action: REMEDY_ACTION.ADVISE,
risk: REMEDY_RISK.NONE,
args: { command: 'Create phase directory or remove from roadmap' },
},
});
}
return diagnostics;
}
// ─── W007 — an on-disk phase directory has no matching ROADMAP entry ──────
// (verify.cts:2086-2101)
/** Every directory in `dirs` that ANY declared roadmap phase resolves to. */
function computeClaimedDirs(
dirs: string[],
declaredPhases: { phaseId: string; milestone: string | null }[],
): Set<string> {
const claimed = new Set<string>();
for (const { phaseId } of declaredPhases) {
for (const dir of dirsForPhase(dirs, phaseId)) claimed.add(dir);
}
return claimed;
}
function checkW007(snapshot: PlanningSnapshot): Diagnostic[] {
// Same guard as W006 — see its comment.
if (snapshot.roadmapDeclaredPhases.scope !== SCOPE.COMPLETE) return [];
const dirs = snapshot.allPhaseDirNames.value;
const claimedDirs = computeClaimedDirs(dirs, snapshot.roadmapDeclaredPhases.value);
const diagnostics: Diagnostic[] = [];
for (const dirName of dirs) {
// `extractPhaseToken` is the phase-id.cts owner `PHASE_TOKEN_FROM_DIR_RE`
// (`src/validate.cts:73-76`) is documented to match exactly
// (verify.cts's original `p` key from `collectDiskPhaseEntries`,
// `verify.cts:1373-1397`) — same token, relocated read, not reinvented.
const token = extractPhaseToken(dirName);
// #3225: a sentinel dir on disk (999-interim, 0-drafts) is defined as
// never-on-roadmap; it must not trigger W007.
if (isSentinelPhaseId(token)) continue;
if (claimedDirs.has(dirName)) continue;
diagnostics.push({
code: 'W007',
severity: SEVERITY.WARNING,
message: `Phase ${token} exists on disk but not in ROADMAP.md`,
remedy: {
action: REMEDY_ACTION.ADVISE,
risk: REMEDY_RISK.NONE,
args: { command: 'Add to roadmap or remove directory' },
},
});
}
return diagnostics;
}
// ─── Exports ────────────────────────────────────────────────────────────────
const RULES: Rule[] = [
{ code: 'W006', severity: SEVERITY.WARNING, check: checkW006 },
{ code: 'W007', severity: SEVERITY.WARNING, check: checkW007 },
];
export = { RULES };

View File

@@ -0,0 +1,270 @@
'use strict';
/**
* Tests for `src/health-diagnostic-rules/roadmap-disk-consistency.cts`
* (Phase 11, #3309, ADR-3180 §8.2/§8.3/§8.5) — group "ROADMAP/disk
* consistency": W006, W007.
*
* Design: .gsd/phase/refactor-3309-health-diagnostic-rule-table/40-design.md
* Test matrix: .gsd/phase/refactor-3309-health-diagnostic-rule-table/50-test-matrix.md
* | W006 | ROADMAP phase with no disk dir | reused/representative | roadmap entry added, no matching dir created |
* | W007 | disk dir with no ROADMAP entry | reused/representative | dir created, no roadmap entry |
*
* Fixture provenance (§8.5 + CONTRIBUTING "Fixture provenance (#2371)"): both
* rules use CONTENT-SHAPE/MECHANICAL-MUTATION provenance — a realistic
* multi-phase ROADMAP.md (mirroring `gsd-core/templates/roadmap.md`'s
* heading shape) paired with a matching on-disk phase-dir tree, with exactly
* ONE entry perturbed (one dir withheld for W006, one extra dir added for
* W007). Every fixture is built via the REAL `buildPlanningSnapshot(cwd)`
* against a REAL temp directory (mirrors `tests/planning-snapshot.test.cjs`
* and `tests/health-diagnostic-rules/root-existence.test.cjs`) — no
* hand-constructed fake `PlanningSnapshot` object.
*
* TDD RED: `src/health-diagnostic-rules/roadmap-disk-consistency.cts` does
* not exist yet at the start of this batch — this file's
* `require('../../gsd-core/bin/lib/health-diagnostic-rules/roadmap-disk-consistency.cjs')`
* throws MODULE_NOT_FOUND until this batch's implementation lands.
*/
const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { createTempDir, cleanup } = require('../helpers.cjs');
const roadmapDiskConsistency = require('../../gsd-core/bin/lib/health-diagnostic-rules/roadmap-disk-consistency.cjs');
const { RULES } = roadmapDiskConsistency;
const { buildPlanningSnapshot } = require('../../gsd-core/bin/lib/planning-snapshot.cjs');
const { SEVERITY, REMEDY_ACTION, REMEDY_RISK } = require('../../gsd-core/bin/lib/health-diagnostic.cjs');
const { SCOPE } = require('../../gsd-core/bin/lib/planning-scope.cjs');
function planningDirOf(cwd) {
return path.join(cwd, '.planning');
}
function writeRoadmap(cwd, content) {
fs.mkdirSync(planningDirOf(cwd), { recursive: true });
fs.writeFileSync(path.join(planningDirOf(cwd), 'ROADMAP.md'), content);
}
function makePhaseDir(cwd, dirName) {
fs.mkdirSync(path.join(planningDirOf(cwd), 'phases', dirName), { recursive: true });
}
function ruleFor(code) {
const rule = RULES.find((r) => r.code === code);
assert.ok(rule, `rule ${code} not found in RULES`);
return rule;
}
// ─── RULES shape ────────────────────────────────────────────────────────────
describe('RULES (roadmap-disk-consistency group)', () => {
test('exports exactly 2 rules: W006, W007', () => {
assert.deepEqual(RULES.map((r) => r.code).sort(), ['W006', 'W007']);
});
test('both rules are severity WARNING', () => {
assert.equal(ruleFor('W006').severity, SEVERITY.WARNING);
assert.equal(ruleFor('W007').severity, SEVERITY.WARNING);
});
});
// ─── W006 — ROADMAP phase with no disk dir ─────────────────────────────────
describe('W006 — ROADMAP phase with no disk dir', () => {
test('fires for exactly the one perturbed phase (3-phase roadmap, dir withheld for phase 2)', (t) => {
const cwd = createTempDir('gsd-3309-w006-1-');
t.after(() => cleanup(cwd));
writeRoadmap(
cwd,
['## v1.0 Current 🚧', '', '### Phase 1: Foo', '', '### Phase 2: Bar', '', '### Phase 3: Baz'].join('\n'),
);
makePhaseDir(cwd, '01-foo');
// Phase 2 deliberately has no matching directory.
makePhaseDir(cwd, '03-baz');
const snapshot = buildPlanningSnapshot(cwd);
const diagnostics = ruleFor('W006').check(snapshot);
assert.equal(diagnostics.length, 1);
assert.deepEqual(diagnostics[0], {
code: 'W006',
severity: SEVERITY.WARNING,
message: 'Phase 2 in ROADMAP.md but no directory on disk',
remedy: {
action: REMEDY_ACTION.ADVISE,
risk: REMEDY_RISK.NONE,
args: { command: 'Create phase directory or remove from roadmap' },
},
});
});
test('does not fire when every declared phase resolves to a directory (padding/token tolerant)', (t) => {
const cwd = createTempDir('gsd-3309-w006-2-');
t.after(() => cleanup(cwd));
writeRoadmap(cwd, ['## v1.0 Current 🚧', '', '### Phase 1: Foo', '', '### Phase 2: Bar'].join('\n'));
makePhaseDir(cwd, '01-foo');
makePhaseDir(cwd, '02-bar');
const snapshot = buildPlanningSnapshot(cwd);
assert.deepEqual(ruleFor('W006').check(snapshot), []);
});
test('does not fire for a sentinel phase id (999.x) even with no matching directory', (t) => {
const cwd = createTempDir('gsd-3309-w006-3-');
t.after(() => cleanup(cwd));
writeRoadmap(cwd, ['## v1.0 Current 🚧', '', '### Phase 999.1: Icebox'].join('\n'));
const snapshot = buildPlanningSnapshot(cwd);
assert.deepEqual(ruleFor('W006').check(snapshot), []);
});
test('does not fire for a phase explicitly marked "not started" (unchecked checklist entry, no dir)', (t) => {
const cwd = createTempDir('gsd-3309-w006-4-');
t.after(() => cleanup(cwd));
writeRoadmap(cwd, ['## Phases', '', '- [ ] **Phase 5: Widgets** - build them'].join('\n'));
const snapshot = buildPlanningSnapshot(cwd);
// Sanity: the phase IS declared (so this is genuinely testing the
// not-started exclusion, not an empty-declared-phases no-op).
assert.ok(snapshot.roadmapDeclaredPhases.value.some((p) => p.phaseId === '5'));
assert.deepEqual(ruleFor('W006').check(snapshot), []);
});
test('DOES fire for an unrelated checked phase with no dir (not-started exclusion is per-phase, not global)', (t) => {
const cwd = createTempDir('gsd-3309-w006-5-');
t.after(() => cleanup(cwd));
writeRoadmap(
cwd,
['## Phases', '', '- [ ] **Phase 5: Widgets** - build them', '- [x] **Phase 6: Gadgets** - build them'].join(
'\n',
),
);
const snapshot = buildPlanningSnapshot(cwd);
const diagnostics = ruleFor('W006').check(snapshot);
assert.equal(diagnostics.length, 1);
assert.equal(diagnostics[0].message, 'Phase 6 in ROADMAP.md but no directory on disk');
});
test('boundary: zero declared phases and zero phase directories produces zero findings', (t) => {
const cwd = createTempDir('gsd-3309-w006-6-');
t.after(() => cleanup(cwd));
writeRoadmap(cwd, ['## Progress', '', '(no phases declared yet)'].join('\n'));
const snapshot = buildPlanningSnapshot(cwd);
assert.deepEqual(snapshot.roadmapDeclaredPhases.value, []);
assert.deepEqual(ruleFor('W006').check(snapshot), []);
});
test('guard: ROADMAP.md absent does not fire (empty declared-phase list is a non-answer, not "zero declared")', (t) => {
const cwd = createTempDir('gsd-3309-w006-7-');
t.after(() => cleanup(cwd));
fs.mkdirSync(planningDirOf(cwd), { recursive: true });
const snapshot = buildPlanningSnapshot(cwd);
assert.deepEqual(ruleFor('W006').check(snapshot), []);
});
});
// ─── W007 — disk dir with no ROADMAP entry ─────────────────────────────────
describe('W007 — disk dir with no ROADMAP entry', () => {
test('fires for exactly the one perturbed directory (3-phase roadmap, one extra orphan dir)', (t) => {
const cwd = createTempDir('gsd-3309-w007-1-');
t.after(() => cleanup(cwd));
writeRoadmap(
cwd,
['## v1.0 Current 🚧', '', '### Phase 1: Foo', '', '### Phase 2: Bar', '', '### Phase 3: Baz'].join('\n'),
);
makePhaseDir(cwd, '01-foo');
makePhaseDir(cwd, '02-bar');
makePhaseDir(cwd, '03-baz');
// Deliberately orphaned: no roadmap entry claims this directory.
makePhaseDir(cwd, '04-extra');
const snapshot = buildPlanningSnapshot(cwd);
const diagnostics = ruleFor('W007').check(snapshot);
assert.equal(diagnostics.length, 1);
assert.deepEqual(diagnostics[0], {
code: 'W007',
severity: SEVERITY.WARNING,
message: 'Phase 04 exists on disk but not in ROADMAP.md',
remedy: {
action: REMEDY_ACTION.ADVISE,
risk: REMEDY_RISK.NONE,
args: { command: 'Add to roadmap or remove directory' },
},
});
});
test('does not fire when every directory is claimed by a declared phase', (t) => {
const cwd = createTempDir('gsd-3309-w007-2-');
t.after(() => cleanup(cwd));
writeRoadmap(cwd, ['## v1.0 Current 🚧', '', '### Phase 1: Foo', '', '### Phase 2: Bar'].join('\n'));
makePhaseDir(cwd, '01-foo');
makePhaseDir(cwd, '02-bar');
const snapshot = buildPlanningSnapshot(cwd);
assert.deepEqual(ruleFor('W007').check(snapshot), []);
});
test('does not fire for a sentinel directory (999-interim) even with no roadmap entry', (t) => {
const cwd = createTempDir('gsd-3309-w007-3-');
t.after(() => cleanup(cwd));
writeRoadmap(cwd, ['## v1.0 Current 🚧', '', '### Phase 1: Foo'].join('\n'));
makePhaseDir(cwd, '01-foo');
makePhaseDir(cwd, '999-interim');
const snapshot = buildPlanningSnapshot(cwd);
assert.deepEqual(ruleFor('W007').check(snapshot), []);
});
test('boundary: zero declared phases and zero phase directories produces zero findings', (t) => {
const cwd = createTempDir('gsd-3309-w007-4-');
t.after(() => cleanup(cwd));
writeRoadmap(cwd, ['## Progress', '', '(no phases declared yet)'].join('\n'));
const snapshot = buildPlanningSnapshot(cwd);
assert.deepEqual(snapshot.allPhaseDirNames.value, []);
assert.deepEqual(ruleFor('W007').check(snapshot), []);
});
test('regression: fires for a genuine orphan directory outside the roadmap-declared window (the W007-inert defect)', (t) => {
const cwd = createTempDir('gsd-3309-w007-6-');
t.after(() => cleanup(cwd));
// ROADMAP declares only phase 1 — "04-extra" is not declared anywhere,
// so `phaseDirs` (windowed to declared phases) would silently drop it
// and W007 would never see it; `allPhaseDirNames` must not.
writeRoadmap(cwd, ['## v1.0 Current 🚧', '', '### Phase 1: Foo'].join('\n'));
makePhaseDir(cwd, '01-foo');
makePhaseDir(cwd, '04-extra');
const snapshot = buildPlanningSnapshot(cwd);
assert.deepEqual(
snapshot.phaseDirs.value,
['01-foo'],
'sanity: the windowed phaseDirs field must NOT include the orphan (confirms the defect this test guards)',
);
assert.ok(snapshot.allPhaseDirNames.value.includes('04-extra'));
const diagnostics = ruleFor('W007').check(snapshot);
assert.equal(diagnostics.length, 1);
assert.equal(diagnostics[0].message, 'Phase 04 exists on disk but not in ROADMAP.md');
});
test('guard: ROADMAP.md absent does not fire for a pre-existing phase directory (no false positive)', (t) => {
const cwd = createTempDir('gsd-3309-w007-5-');
t.after(() => cleanup(cwd));
fs.mkdirSync(planningDirOf(cwd), { recursive: true });
makePhaseDir(cwd, '01-foo');
const snapshot = buildPlanningSnapshot(cwd);
assert.deepEqual(snapshot.roadmapDeclaredPhases, { value: [], scope: SCOPE.UNREADABLE });
assert.deepEqual(ruleFor('W007').check(snapshot), []);
});
});