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'); } }