* 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:
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user