From 1ba9e5599840432fe779abb8507cf516678298fa Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Fri, 15 May 2026 12:53:43 -0400 Subject: [PATCH 1/3] fix(3559): skip W006 for unchecked future phases --- sdk/src/query/validate.test.ts | 24 ++++++++++++++++++++++++ sdk/src/query/validate.ts | 8 ++++++++ 2 files changed, 32 insertions(+) diff --git a/sdk/src/query/validate.test.ts b/sdk/src/query/validate.test.ts index 4f370f541..aa9844db3 100644 --- a/sdk/src/query/validate.test.ts +++ b/sdk/src/query/validate.test.ts @@ -643,6 +643,30 @@ 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 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..c958c2816 100644 --- a/sdk/src/query/validate.ts +++ b/sdk/src/query/validate.ts @@ -598,6 +598,13 @@ export const validateHealth: QueryHandler = async (args, projectDir, workstream) while ((m = phasePattern.exec(roadmapContent)) !== null) { roadmapPhases.add(m[1]); } + 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) { + notStartedPhases.add(um[1]); + notStartedPhases.add(String(parseInt(um[1], 10)).padStart(2, '0')); + } const diskPhases = new Set(); try { @@ -627,6 +634,7 @@ export const validateHealth: QueryHandler = async (args, projectDir, workstream) for (const p of roadmapPhases) { const padded = String(parseInt(p, 10)).padStart(2, '0'); if (!diskPhases.has(p) && !diskPhases.has(padded)) { + if (notStartedPhases.has(p) || notStartedPhases.has(padded)) continue; addIssue('warning', 'W006', `Phase ${p} in ROADMAP.md but no directory on disk`, 'Create phase directory or remove from roadmap'); } } From 2ce2685b42fea239809b42ad62f020f901fab0ef Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Fri, 15 May 2026 12:54:18 -0400 Subject: [PATCH 2/3] chore(changeset): add fragment for #3559 --- .changeset/zesty-eagles-fly.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/zesty-eagles-fly.md 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. From 9052ed309cb3552d1e31b54bf7c24b789f6931c9 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Fri, 15 May 2026 14:46:11 -0400 Subject: [PATCH 3/3] fix(3559): preserve phase suffixes in W006/W007 normalization --- sdk/src/query/validate.test.ts | 23 +++++++++++++++++++++++ sdk/src/query/validate.ts | 30 +++++++++++++++++++++++------- 2 files changed, 46 insertions(+), 7 deletions(-) diff --git a/sdk/src/query/validate.test.ts b/sdk/src/query/validate.test.ts index aa9844db3..d63ba7001 100644 --- a/sdk/src/query/validate.test.ts +++ b/sdk/src/query/validate.test.ts @@ -667,6 +667,29 @@ describe('validateHealth', () => { 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 c958c2816..dc48c5c67 100644 --- a/sdk/src/query/validate.ts +++ b/sdk/src/query/validate.ts @@ -594,16 +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) { - notStartedPhases.add(um[1]); - notStartedPhases.add(String(parseInt(um[1], 10)).padStart(2, '0')); + for (const variant of phaseVariants(um[1])) notStartedPhases.add(variant); } const diskPhases = new Set(); @@ -632,16 +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)) { - if (notStartedPhases.has(p) || notStartedPhases.has(padded)) continue; + 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'); } }