fix(#3309): W002/W011/W026 state-consistency regressions

gsd-test found three real regressions in the migrated STATE.md checks:

- W002 didn't exempt phase refs whose only directory lives in an
  archived milestone (#3652) — now consults planning-snapshot.cts's
  archivedPhaseTokens field (added alongside this fix, shared with
  W006's identical need).
- W011 (STATE/ROADMAP cross-validation) never fired: currentPhaseLabel
  only read the current template's bare "Phase:" field, silently
  missing legacy STATE.md fixtures that use the older bold
  "**Current Phase:**" field (mirrors state.cts's own resolveStatePhase
  fallback ladder, which the migration didn't carry over).
- W026 (STATE milestone-complete vs. unstarted ROADMAP phases) had two
  independent defects: roadmapDeclaredPhases's milestone attribution
  can't see <details>/<summary>-shaped ROADMAP sections, and current-
  milestone resolution could go null — both silently emptied the
  "unstarted" set every time. Fixed by scoping ROADMAP.md to the
  current milestone via the same <details>-tolerant extractCurrentMilestone
  every other milestone-aware consumer uses, in a new dedicated
  planning-snapshot.cts field (currentMilestoneRoadmapPhaseIds) rather
  than reusing roadmapDeclaredPhases, which exists for a narrower,
  <details>-blind derivation (W021's own original logic) and would
  have regressed it if repurposed.

Also fixes an unrelated drift-guard violation this same rule file
introduced: its own phase-token regex was independently re-derived
instead of built from the canonical PHASE_NUMBER_TOKEN_SOURCE.
This commit is contained in:
sim
2026-08-13 04:30:40 -04:00
parent ce57e60098
commit a0c82f2bd8

View File

@@ -55,7 +55,8 @@ type PlanningSnapshot = ReturnType<typeof planningSnapshotMod.buildPlanningSnaps
// eslint-disable-next-line @typescript-eslint/no-require-imports
import phaseIdMod = require('../phase-id.cjs');
const { getMilestoneFromPhaseId, matchPhaseDirs, normalizePhaseName, extractPhaseToken } = phaseIdMod;
const { getMilestoneFromPhaseId, matchPhaseDirs, normalizePhaseName, extractPhaseToken, PHASE_NUMBER_TOKEN_SOURCE } =
phaseIdMod;
// ─── W024 — STATE.md commit-age freshness (DELIBERATELY INERT) ─────────────
@@ -110,6 +111,13 @@ const RULE_W024: Rule = {
* fixture shape that would expose a false positive, and none of this
* group's fixtures exercise archives, so this gap is disclosed rather than
* silently absorbed.
*
* UPDATE (#3652): archived-phase-token coverage IS now included, via the
* additive `snapshot.archivedPhaseTokens` field
* (`src/planning-snapshot.cts`, added for this fix) — the disclosed gap
* above is closed; the paragraph is kept for the "conservative direction"
* reasoning, which still explains why every OTHER omission in this
* function is safe.
*/
function buildValidPhaseSet(snapshot: PlanningSnapshot): Set<string> {
const valid = new Set<string>();
@@ -120,6 +128,9 @@ function buildValidPhaseSet(snapshot: PlanningSnapshot): Set<string> {
for (const entry of snapshot.roadmapDeclaredPhases.value) {
valid.add(entry.phaseId);
}
for (const token of snapshot.archivedPhaseTokens.value) {
valid.add(token);
}
return valid;
}
@@ -190,7 +201,7 @@ const RULE_W002: Rule = {
*/
function currentPhaseIdFromLabel(label: string | null): string | null {
if (!label) return null;
const m = label.match(/^0*(\d+[A-Z]?(?:\.\d+)*)/);
const m = label.match(new RegExp(`^0*(${PHASE_NUMBER_TOKEN_SOURCE})`));
return m ? m[1] : null;
}
@@ -261,18 +272,22 @@ const RULE_W026: Rule = {
const statusVal = (snapshot.stateStatus.value ?? '').trim().toLowerCase();
if (!/milestone complete|archived/.test(statusVal)) return [];
const currentMilestone = snapshot.milestone.value?.version ?? null;
if (currentMilestone === null) return [];
// `currentMilestoneRoadmapPhaseIds` is already scoped to the current
// milestone (`extractCurrentMilestone(roadmapRaw, cwd)`, the same
// `<details>`/`<summary>`-tolerant owner `verify.cts:2364` used) — no
// separate `currentMilestone` resolution/filter needed here (see the
// field's own doc comment on `PlanningSnapshot` for why
// `roadmapDeclaredPhases`'s `milestone` attribution is the wrong fit).
const unstarted: string[] = [];
for (const entry of snapshot.roadmapDeclaredPhases.value) {
// Scoped to the current milestone only — mirrors the original's
// `extractCurrentMilestone(roadmapRaw, cwd)` narrowing before its
// phase-heading scan (`verify.cts:2363-2364`).
if (entry.milestone !== currentMilestone) continue;
const normalized = normalizePhaseName(entry.phaseId);
const hasDirectory = matchPhaseDirs(snapshot.phaseDirs.value, normalized).matches.length > 0;
if (!hasDirectory) unstarted.push(entry.phaseId);
for (const phaseId of snapshot.currentMilestoneRoadmapPhaseIds.value) {
const normalized = normalizePhaseName(phaseId);
// `allPhaseDirNames` — every directory under `phases/`, UNWINDOWED by
// ROADMAP-declaration membership — mirrors the original's own
// unwindowed `phaseDirNames2` (`verify.cts:2372-2382`, a direct
// `readdirSync` of the phases dir), not the current-milestone-windowed
// `phaseDirs`.
const hasDirectory = matchPhaseDirs(snapshot.allPhaseDirNames.value, normalized).matches.length > 0;
if (!hasDirectory) unstarted.push(phaseId);
}
if (unstarted.length === 0) return [];