fix(#2633): ROADMAP.md is the authority for current-milestone phase counts (#2665)

* fix(#2633): use ROADMAP.md as authority for current-milestone phase counts

initMilestoneOp (SDK + CJS) derives phase_count and completed_phases from
the current milestone section of ROADMAP.md instead of counting on-disk
`.planning/phases/` directories. After `phases clear` at the start of a new
milestone the on-disk set is a subset of the roadmap, causing premature
`all_phases_complete: true`.

validateHealth W002 now unions ROADMAP.md phase declarations (all milestones
— current, shipped, backlog) with on-disk dirs when checking STATE.md phase
refs. Eliminates false positives for future-phase refs in the current
milestone and history-phase refs from shipped milestones.

Falls back to legacy on-disk counting when ROADMAP.md is missing or
unparseable so no-roadmap fixtures still work.

Adds vitest regressions for both handlers; all 66 SDK + 118 CJS tests pass.

* fix(#2633): preserve full phase tokens in W002 + completion lookup

CodeRabbit flagged that the parseInt-based normalization collapses distinct
phase IDs (3, 3A, 3.1) into the same integer bucket, masking real
STATE/ROADMAP mismatches and miscounting completions in milestones with
inserted/sub-phases.

Index disk dirs and validate STATE.md refs by canonical full phase token —
strip leading zeros from the integer head only, preserve [A-Z] suffix and
dotted segments, and accept just the leading-zero variant of the integer
prefix as a tolerated alias. 3A and 3 never share a bucket.

Also widens the disk and STATE.md regexes to accept [A-Z]? suffix tokens.
This commit is contained in:
Tom Boucher
2026-04-24 18:11:12 -04:00
committed by GitHub
parent c8ae6b3b4f
commit 7b470f2625
6 changed files with 282 additions and 30 deletions

View File

@@ -432,24 +432,57 @@ export const validateHealth: QueryHandler = async (args, projectDir, _workstream
} else {
try {
const stateContent = await readFile(statePath, 'utf-8');
const phaseRefs = [...stateContent.matchAll(/[Pp]hase\s+(\d+(?:\.\d+)*)/g)].map(m => m[1]);
const diskPhases = new Set<string>();
const phaseRefs = [...stateContent.matchAll(/[Pp]hase\s+(\d+[A-Z]?(?:\.\d+)*)/g)].map(m => m[1]);
// Bug #2633 — ROADMAP.md is the authority for which phases are valid.
// STATE.md may legitimately reference current-milestone future phases
// (not yet materialized on disk) and shipped-milestone history phases
// (archived / cleared off disk). Matching only against on-disk dirs
// produces false W002 warnings in both cases.
const validPhases = new Set<string>();
try {
const entries = await readdir(phasesDir, { withFileTypes: true });
for (const e of entries) {
if (e.isDirectory()) {
const m = e.name.match(/^(\d+(?:\.\d+)*)/);
if (m) diskPhases.add(m[1]);
const m = e.name.match(/^(\d+[A-Z]?(?:\.\d+)*)/);
if (m) validPhases.add(m[1]);
}
}
} catch { /* intentionally empty */ }
// Union in every phase declared anywhere in ROADMAP.md — current milestone,
// shipped milestones (inside <details> / ✅ SHIPPED sections), and any
// preamble/Backlog. We deliberately do NOT filter by current milestone.
try {
const roadmapRaw = await readFile(roadmapPath, 'utf-8');
const all = [...roadmapRaw.matchAll(/#{2,4}\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)/gi)];
for (const m of all) validPhases.add(m[1]);
} catch { /* intentionally empty */ }
// Compare canonical full phase tokens. Also accept a leading-zero
// variant on the integer prefix only (e.g. "03" → "3", "03.1" → "3.1")
// so historic STATE.md formatting still validates. Suffix tokens like
// "3A" must match exactly — never collapsed to "3".
const normalizedValid = new Set<string>();
for (const p of validPhases) {
normalizedValid.add(p);
const dotIdx = p.indexOf('.');
const head = dotIdx === -1 ? p : p.slice(0, dotIdx);
const tail = dotIdx === -1 ? '' : p.slice(dotIdx);
if (/^\d+$/.test(head)) {
normalizedValid.add(head.padStart(2, '0') + tail);
}
}
for (const ref of phaseRefs) {
const normalizedRef = String(parseInt(ref, 10)).padStart(2, '0');
if (!diskPhases.has(ref) && !diskPhases.has(normalizedRef) && !diskPhases.has(String(parseInt(ref, 10)))) {
if (diskPhases.size > 0) {
const dotIdx = ref.indexOf('.');
const head = dotIdx === -1 ? ref : ref.slice(0, dotIdx);
const tail = dotIdx === -1 ? '' : ref.slice(dotIdx);
const padded = /^\d+$/.test(head) ? head.padStart(2, '0') + tail : ref;
if (!normalizedValid.has(ref) && !normalizedValid.has(padded)) {
if (normalizedValid.size > 0) {
addIssue('warning', 'W002',
`STATE.md references phase ${ref}, but only phases ${[...diskPhases].sort().join(', ')} exist`,
`STATE.md references phase ${ref}, but only phases ${[...validPhases].sort().join(', ')} are declared`,
'Review STATE.md manually');
}
}