diff --git a/.changeset/zesty-eagles-fly.md b/.changeset/zesty-eagles-fly.md new file mode 100644 index 000000000..030b701f6 --- /dev/null +++ b/.changeset/zesty-eagles-fly.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 3565 +--- +**validate.health no longer warns W006 for not-started phases** - unchecked roadmap phase entries are treated as planned/future and no longer require on-disk phase directories until work has started. Fixes #3559. diff --git a/sdk/src/query/validate.test.ts b/sdk/src/query/validate.test.ts index 4f370f541..d63ba7001 100644 --- a/sdk/src/query/validate.test.ts +++ b/sdk/src/query/validate.test.ts @@ -643,6 +643,53 @@ describe('validateHealth', () => { expect(w006s.some(w => String(w.message).includes('Phase 7'))).toBe(false); }); + it('does not emit W006 for unchecked future phases with no directory (#3559)', async () => { + await createHealthyPlanning(); + await writeFile(join(tmpDir, '.planning', 'ROADMAP.md'), [ + '# Roadmap', + '', + '## v1.1: Current', + '', + '- [x] **Phase 21: Active**', + '- [ ] **Phase 22: Future planned**', + '', + '### Phase 21: Active', + '', + '### Phase 22: Future planned', + '', + ].join('\n')); + await mkdir(join(tmpDir, '.planning', 'phases', '21-active'), { recursive: true }); + + const result = await validateHealth([], tmpDir); + const data = result.data as Record; + const warnings = data.warnings as Array>; + const w006s = warnings.filter(w => w.code === 'W006'); + expect(w006s.some(w => String(w.message).includes('Phase 22'))).toBe(false); + }); + + it('does not alias 22A to 22 when suppressing W006 (#3565)', async () => { + await createHealthyPlanning(); + await writeFile(join(tmpDir, '.planning', 'ROADMAP.md'), [ + '# Roadmap', + '', + '## v1.1: Current', + '', + '- [x] **Phase 22: Started phase**', + '- [ ] **Phase 22A: Future variant**', + '', + '### Phase 22: Started phase', + '', + '### Phase 22A: Future variant', + '', + ].join('\n')); + + const result = await validateHealth([], tmpDir); + const data = result.data as Record; + const warnings = data.warnings as Array>; + const w006s = warnings.filter(w => w.code === 'W006'); + expect(w006s.some(w => String(w.message).includes('Phase 22 in ROADMAP.md'))).toBe(true); + }); + it('does not emit I001 when plan file has descriptor but summary uses canonical stem (#3473)', async () => { await createHealthyPlanning(); await mkdir(join(tmpDir, '.planning', 'phases', '68-bug-surface'), { recursive: true }); diff --git a/sdk/src/query/validate.ts b/sdk/src/query/validate.ts index e68e072a8..dc48c5c67 100644 --- a/sdk/src/query/validate.ts +++ b/sdk/src/query/validate.ts @@ -594,9 +594,30 @@ export const validateHealth: QueryHandler = async (args, projectDir, workstream) const roadmapContent = await readFile(roadmapPath, 'utf-8'); const roadmapPhases = new Set(); const phasePattern = /#{2,4}\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)\s*:/gi; + const phaseVariants = (phase: string): Set => { + const variants = new Set([phase]); + const dotIdx = phase.indexOf('.'); + const head = dotIdx === -1 ? phase : phase.slice(0, dotIdx); + const tail = dotIdx === -1 ? '' : phase.slice(dotIdx); + const headMatch = head.match(/^(\d+)([A-Z]?)$/i); + if (!headMatch) return variants; + const numericHead = headMatch[1]; + const letterSuffix = headMatch[2] || ''; + variants.add(`${String(parseInt(numericHead, 10))}${letterSuffix}${tail}`); + variants.add(`${numericHead.padStart(2, '0')}${letterSuffix}${tail}`); + return variants; + }; + const roadmapPhaseVariants = new Set(); let m: RegExpExecArray | null; while ((m = phasePattern.exec(roadmapContent)) !== null) { roadmapPhases.add(m[1]); + for (const variant of phaseVariants(m[1])) roadmapPhaseVariants.add(variant); + } + const notStartedPhases = new Set(); + const uncheckedPattern = /-\s*\[\s\]\s*\*{0,2}Phase\s+(\d+[A-Z]?(?:\.\d+)*)[:\s*]/gi; + let um: RegExpExecArray | null; + while ((um = uncheckedPattern.exec(roadmapContent)) !== null) { + for (const variant of phaseVariants(um[1])) notStartedPhases.add(variant); } const diskPhases = new Set(); @@ -625,15 +646,18 @@ export const validateHealth: QueryHandler = async (args, projectDir, workstream) } catch { /* intentionally empty */ } for (const p of roadmapPhases) { - const padded = String(parseInt(p, 10)).padStart(2, '0'); - if (!diskPhases.has(p) && !diskPhases.has(padded)) { + const variants = phaseVariants(p); + const existsOnDisk = [...variants].some((variant) => diskPhases.has(variant)); + const isNotStarted = [...variants].some((variant) => notStartedPhases.has(variant)); + if (!existsOnDisk) { + if (isNotStarted) continue; addIssue('warning', 'W006', `Phase ${p} in ROADMAP.md but no directory on disk`, 'Create phase directory or remove from roadmap'); } } for (const p of diskPhases) { - const unpadded = String(parseInt(p, 10)); - if (!roadmapPhases.has(p) && !roadmapPhases.has(unpadded)) { + const variants = phaseVariants(p); + if (![...variants].some((variant) => roadmapPhaseVariants.has(variant))) { addIssue('warning', 'W007', `Phase ${p} exists on disk but not in ROADMAP.md`, 'Add to roadmap or remove directory'); } }