From c2dc9e2532a0d9c26075a5ea01dec3e86a9c7f73 Mon Sep 17 00:00:00 2001 From: Cristian Uibar Date: Mon, 18 May 2026 07:14:10 +0300 Subject: [PATCH] Fix W002 false positives on archived phase references in STATE.md body (#3655) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * W002 health check: cross-reference milestones archive for STATE.md phase refs After /gsd-complete-milestone, phase dirs move into milestones/vX.Y-phases/ and their `#### Phase N:` headings in ROADMAP.md are collapsed inside
blocks. The ROADMAP heading scan misses them, so W002 fired for every archived phase number mentioned in STATE.md's historical narrative body ("Recent", "Decisions", "Deferred Items") — leaving every project that ever ran /gsd-complete-milestone permanently degraded with proportional W002 noise. Union archived milestone phase directories into the validPhases set used by the W002 check, mirroring the same milestone-archive lookup that W006 already uses (sdk/src/query/validate.ts:723-736). Closes #3652 * Address review: use shared regex constants and mirror W002 archive fix into CJS path CodeRabbit (PR #3655 review 1): the ad-hoc /^(\d+[A-Z]?(?:\.\d+)*)/i used to extract phase tokens from archived phase dirs would skip project-code-prefixed names like `CK-64-...`. Switch the new SDK block to the shared PHASE_TOKEN_FROM_DIR_RE / MILESTONE_ARCHIVE_DIR_RE constants (defined at sdk/src/query/validate.ts:32-33) so prefixed archives are recognised. Added a companion regression test using `CK-`-prefixed dirs. Codex review: the shipped CJS health command path (get-shit-done/bin/lib/verify.cjs cmdValidateHealth, routed by validate-command-router.cjs) only unions collectDiskPhases (active archive only) plus ROADMAP heading scan — same bug as the SDK had. Port the all-archive scan into the CJS path via listMilestoneArchiveDirs + PHASE_TOKEN_FROM_DIR_RE (already declared at verify.cjs:401-402). Added a CJS regression test covering the multi-sub-milestone (v1.3a + v1.3b) scenario from the issue report. Adds .changeset/lucky-lynx-wave.md. * Address Gemini findings: shared regex + helper reuse + cross-platform path P1 #2 — refactor the new W002 archive-scan block to reuse the existing listMilestoneArchiveDirs helper (sdk/src/query/validate.ts:40) instead of re-implementing readdir + filter inline. Eliminates duplication and prevents the two call sites from drifting apart. P2 #4 — listMilestoneArchiveDirs sorted by `a.slice(a.lastIndexOf('/') + 1)`, which returns the full path on Windows where path.join produces backslashes. Switch to path.basename(a) so the numeric version sort works cross-platform. Brings the SDK helper in line with the CJS sibling at get-shit-done/bin/lib/verify.cjs:411 which already uses path.basename. P1 #1 / P2 #5 — the pre-existing W006/W007 archive + active phase scans (Check 8) used an ad-hoc `^(\d+[A-Z]?(?:\.\d+)*)` regex that silently skipped project-code-prefixed phase dirs like `CK-64-foo`, so W006 fired for a correctly-archived phase and W007 fired for a correctly-on-disk phase. Switch both scans to the shared PHASE_TOKEN_FROM_DIR_RE constant declared at sdk/src/query/validate.ts:32. The W006 archive loop also now reuses listMilestoneArchiveDirs for consistency. P2 #6 — strengthen the CK-prefix regression test to also assert no W006 fires for `#### Phase 64: Prior shipped` (placed inside
so the heading scan picks it up while the on-disk scan does not), pinning the shared-regex behaviour in the W006 path. * CI: switch retired /gsd- comment syntax to canonical /gsd: The bug-2543 slash-namespace invariant lint scans get-shit-done/bin/lib/** for /gsd- patterns and fails CI when one slips into a comment. Use the canonical /gsd:complete-milestone form in the new verify.cjs comment (and mirror the change in the SDK + tests + changeset entry so all docstrings referencing the milestone-completion command share one spelling). Also: extract a small forEachArchivedPhaseToken helper in validate.ts (Gemini P3 finding from review pass 2) so Check 4 (W002) and Check 8 (W006) share the archive-walking loop instead of inlining it twice. * Address rev3 review: shared regex parity, numeric sort, drop dead try/catch Gemini P1 — Check 4's flat phases/ scan still used the ad-hoc /^(\d+[A-Z]?(?:\.\d+)*)/ regex while the archive scan used the shared PHASE_TOKEN_FROM_DIR_RE. Project-code-prefixed dirs (e.g. CK-65-current) on the flat layout would have slipped past validity, so the W002 check could still mis-classify them. Use PHASE_TOKEN_FROM_DIR_RE here too. Gemini P3 — `[...validPhases].sort()` ordered tokens alphabetically, producing error messages like "phases 1, 10, 19, 2, 20" instead of "1, 2, 10, 19, 20". Switch both SDK and CJS to numeric localeCompare so the displayed list is human-readable. Mirrored in both paths. Grok P3 — the CJS Check 4 archive block wrapped listMilestoneArchiveDirs in an outer try/catch even though the helper already swallows ENOENT/EACCES into []. The outer catch was unreachable. Removed; only the per-archive readdir needs a catch. Grok P2 / Gemini P1 (CJS Check 8 archive scan) — the assertion that CJS Check 8 needs the same archive union as Check 4 was repeatedly raised across review passes. It is incorrect: Check 8 filters ROADMAP.md through extractCurrentMilestone() before scanning headings, which strips shipped milestones (collapsed in
or not) so archived phase numbers never reach `roadmapPhases`. Added an inline note documenting this and a positive regression assertion in the CJS test that W006 does NOT fire for the archived phases in the multi-sub-milestone fixture. (Skipped a parallel W007 assertion because the active-archive fallback in getActiveMilestoneArchiveDir is pre-existing behavior unrelated to #3652.) * Port forEachArchivedPhaseToken helper to verify.cjs for SDK parity Gemini rev5 P2 — the CJS Check 4 inlined the archive-walking loop while the SDK already factored it into forEachArchivedPhaseToken(). Add a mirror helper in verify.cjs so both seams use the same primitive, matching the cooperating-sibling pattern documented in scripts/shared-module-handsync-allowlist.json. * ci: retrigger to clear unrelated TOCTOU flake The previous CI run failed at the pre-existing #1925 concurrency test (state add-blocker concurrent calls) on macos-24 and ubuntu-22 but passed on ubuntu-24 — and the same test passed on the prior CI run of this branch (commit 8a246916). The state add-blocker code path is completely independent of the W002 archive-union changes in this PR. --- .changeset/lucky-lynx-wave.md | 5 ++ get-shit-done/bin/lib/verify.cjs | 37 ++++++++++++- sdk/src/query/validate.test.ts | 93 ++++++++++++++++++++++++++++++++ sdk/src/query/validate.ts | 62 +++++++++++++-------- tests/verify-health.test.cjs | 57 ++++++++++++++++++++ 5 files changed, 229 insertions(+), 25 deletions(-) create mode 100644 .changeset/lucky-lynx-wave.md diff --git a/.changeset/lucky-lynx-wave.md b/.changeset/lucky-lynx-wave.md new file mode 100644 index 000000000..f6103b3bf --- /dev/null +++ b/.changeset/lucky-lynx-wave.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 3655 +--- +**`/gsd:health` no longer fires W002 for archived phase numbers in STATE.md body (#3652)** — after `/gsd:complete-milestone`, phase directories move into `milestones/vX.Y-phases/` and their `#### Phase N:` headings in ROADMAP.md are collapsed inside `
` blocks. Neither the on-disk phases scan nor the ROADMAP heading scan picked them up, so W002 fired for every archived phase number referenced in STATE.md's historical narrative body (`## Recent`, `## Decisions`, `## Deferred Items`). Projects ran permanently `degraded` with W002 noise growing proportionally to lifetime phase count. The validity set now also unions phase directories from any `milestones/vX.Y-phases/` archive, mirroring the W006 archive-lookup. Uses the shared `PHASE_TOKEN_FROM_DIR_RE` / `MILESTONE_ARCHIVE_DIR_RE` constants so project-code-prefixed dirs (e.g. `CK-64-foo`) are recognised. diff --git a/get-shit-done/bin/lib/verify.cjs b/get-shit-done/bin/lib/verify.cjs index d50884aff..218ad857a 100644 --- a/get-shit-done/bin/lib/verify.cjs +++ b/get-shit-done/bin/lib/verify.cjs @@ -413,6 +413,26 @@ function listMilestoneArchiveDirs(planBase) { } } +/** + * Walk every milestone archive directory and call `onPhase` with the phase + * token (e.g. `64`, `64A`, `64.1`) extracted from each archived phase dir's + * name. Mirrors `forEachArchivedPhaseToken` in sdk/src/query/validate.ts so + * Check 4 (W002) on the CJS side has the same archive-walking primitive. + * Bug #3652. + */ +function forEachArchivedPhaseToken(planBase, onPhase) { + for (const archiveDir of listMilestoneArchiveDirs(planBase)) { + try { + const entries = fs.readdirSync(archiveDir, { withFileTypes: true }); + for (const e of entries) { + if (!e.isDirectory()) continue; + const m = e.name.match(PHASE_TOKEN_FROM_DIR_RE); + if (m) onPhase(m[1]); + } + } catch { /* archive dir absent/unreadable */ } + } +} + function getActiveMilestoneArchiveDir(planBase) { const archiveDirs = listMilestoneArchiveDirs(planBase); if (archiveDirs.length === 0) return null; @@ -665,6 +685,14 @@ function cmdValidateHealth(cwd, options, raw) { for (const m of all) validPhases.add(m[1]); } } catch { /* intentionally empty */ } + // Bug #3652 — also union phases from every milestone archive, not only + // the active one. After /gsd:complete-milestone, historical phase dirs + // live under milestones/vX.Y-phases/ and their `#### Phase N:` headings + // get collapsed inside
blocks (which the heading regex above + // misses). collectDiskPhases() only scans the active archive, so + // without this step STATE.md's narrative references to older shipped + // phases fire false W002. + forEachArchivedPhaseToken(planBase, (token) => validPhases.add(token)); // Compare canonical full phase tokens. Also accept a leading-zero variant // on the integer prefix only (e.g. "03" matching "3", "03.1" matching // "3.1") so historic STATE.md formatting still validates. Suffix tokens @@ -691,7 +719,7 @@ function cmdValidateHealth(cwd, options, raw) { addIssue( 'warning', 'W002', - `STATE.md references phase ${ref}, but only phases ${[...validPhases].sort().join(', ')} are declared`, + `STATE.md references phase ${ref}, but only phases ${[...validPhases].sort((a, b) => a.localeCompare(b, undefined, { numeric: true })).join(', ')} are declared`, `Review STATE.md manually before changing it; ${slash('health')} --repair will not overwrite an existing STATE.md for phase mismatches` ); } @@ -803,7 +831,12 @@ function cmdValidateHealth(cwd, options, raw) { } catch { /* intentionally empty — agent check is non-blocking */ } // ─── Check 8: Run existing consistency checks ───────────────────────────── - // Inline subset of cmdValidateConsistency + // Inline subset of cmdValidateConsistency. Note: unlike Check 4 (W002), + // this check intentionally filters ROADMAP.md through extractCurrentMilestone + // first — shipped milestones (whether collapsed in
or not) are + // stripped before the heading scan, so archived phase numbers never reach + // `roadmapPhases` and W006/W007 cannot fire for them. That is why the + // #3652 archive-union added to Check 4 is NOT mirrored here. if (fs.existsSync(roadmapPath)) { const roadmapContentRaw = fs.readFileSync(roadmapPath, 'utf-8'); const roadmapContent = extractCurrentMilestone(roadmapContentRaw, cwd); diff --git a/sdk/src/query/validate.test.ts b/sdk/src/query/validate.test.ts index 06ca231d2..501262451 100644 --- a/sdk/src/query/validate.test.ts +++ b/sdk/src/query/validate.test.ts @@ -593,6 +593,99 @@ describe('validateHealth', () => { expect(w002s).toEqual([]); }); + // Regression: #3652 — after /gsd:complete-milestone, STATE.md body retains + // historical phase references across milestones while their `#### Phase N:` + // headings in ROADMAP.md are collapsed into
blocks and the phase + // dirs are moved to `milestones/vX.Y-phases/`. The heading-scan regex misses + // collapsed phases, so W002 used to fire for every archived phase mentioned + // in narrative prose. Cross-referencing the milestones archive suppresses it. + it('does not emit W002 for phase refs that live in milestones archive (#3652)', async () => { + const planning = join(tmpDir, '.planning'); + await mkdir(join(planning, 'phases', '23-current'), { recursive: true }); + await mkdir(join(planning, 'milestones', 'v1.3a-phases', '12-old-phase'), { recursive: true }); + for (const n of ['19-alpha', '20-beta', '21-gamma', '22-delta']) { + await mkdir(join(planning, 'milestones', 'v1.3b-phases', n), { recursive: true }); + } + + await writeFile(join(planning, 'PROJECT.md'), '# Project\n\n## What This Is\n\nA project.\n\n## Core Value\n\nValue here.\n\n## Requirements\n\n- Req 1\n'); + await writeFile(join(planning, 'ROADMAP.md'), [ + '# Roadmap', '', + '
v1.3a: Shipped', '', + '- Phase 12: archived', '', + '
', '', + '
v1.3b: Shipped', '', + '- Phase 19, 20, 21, 22: archived', '', + '
', '', + '## v1.4: Current', '', + '### Phase 23: Current work', '**Goal:** stuff', '', + ].join('\n')); + await writeFile(join(planning, 'STATE.md'), [ + '---', 'milestone: v1.4', 'milestone_name: Current', 'status: executing', '---', '', + '# State', '', + '**Current Phase:** 23', '', + '## Recent', '- Phase 19 shipped', '- Phase 20 shipped', '- Phase 21 shipped', '- Phase 22 shipped', + '', '## Decisions', '- Decision from Phase 12 still applies', + '', '## Deferred Items', '- Note from Phase 19', + ].join('\n')); + await writeFile(join(planning, 'config.json'), JSON.stringify({ + model_profile: 'balanced', + workflow: { nyquist_validation: true }, + }, null, 2)); + + const result = await validateHealth([], tmpDir); + const data = result.data as Record; + const warnings = data.warnings as Array>; + const w002s = warnings.filter(w => w.code === 'W002'); + expect(w002s).toEqual([]); + }); + + // Regression: #3652 — project-code-prefixed archive dirs (e.g. `CK-64-...`) + // must also be recognised as valid phase declarations. The shared + // PHASE_TOKEN_FROM_DIR_RE / MILESTONE_ARCHIVE_DIR_RE constants handle the + // prefix; an ad-hoc /^\d+/-style regex here would miss them. + it('recognises project-code-prefixed archive dirs as valid for W002 (#3652)', async () => { + const planning = join(tmpDir, '.planning'); + await mkdir(join(planning, 'phases', 'CK-65-current'), { recursive: true }); + await mkdir(join(planning, 'milestones', 'v2.0-phases', 'CK-64-prior-shipped'), { recursive: true }); + + await writeFile(join(planning, 'PROJECT.md'), '# Project\n\n## What This Is\n\nA project.\n\n## Core Value\n\nValue here.\n\n## Requirements\n\n- Req 1\n'); + await writeFile(join(planning, 'ROADMAP.md'), [ + '# Roadmap', '', + '
v2.0: Shipped', '', + // `#### Phase 64:` lives inside
so the heading-scan + // picks it up but the on-disk active phases scan does NOT. The W006 + // archive scan must recognise `CK-64-prior-shipped` to suppress the + // warning — proving the shared regex is in use. + '#### Phase 64: Prior shipped', '', + '
', '', + '## v2.1: Current', '', + '### Phase 65: Current', '**Goal:** stuff', '', + ].join('\n')); + await writeFile(join(planning, 'STATE.md'), [ + '---', 'milestone: v2.1', 'milestone_name: Current', 'status: executing', '---', '', + '# State', '', + '**Current Phase:** 65', '', + '## Recent', '- Phase 64 shipped', + ].join('\n')); + await writeFile(join(planning, 'config.json'), JSON.stringify({ + model_profile: 'balanced', + workflow: { nyquist_validation: true }, + }, null, 2)); + + const result = await validateHealth([], tmpDir); + const data = result.data as Record; + const warnings = data.warnings as Array>; + const w002s = warnings.filter(w => w.code === 'W002'); + expect(w002s).toEqual([]); + // Same prefixed-archive recognition must also suppress W006 for any + // ROADMAP heading that points at the CK-prefixed archived phase, so the + // pre-existing W006 archive scan doesn't regress to the old ad-hoc regex. + const w006sForArchived = warnings.filter( + w => w.code === 'W006' && String(w.message).includes('Phase 64'), + ); + expect(w006sForArchived).toEqual([]); + }); + it('returns warning W005 for bad phase directory naming', async () => { await createHealthyPlanning(); await mkdir(join(tmpDir, '.planning', 'phases', 'bad_name'), { recursive: true }); diff --git a/sdk/src/query/validate.ts b/sdk/src/query/validate.ts index db21706fe..ea9eb09a4 100644 --- a/sdk/src/query/validate.ts +++ b/sdk/src/query/validate.ts @@ -16,7 +16,7 @@ import { readFile, readdir, writeFile } from 'node:fs/promises'; import { existsSync } from 'node:fs'; -import { dirname, join, resolve } from 'node:path'; +import { basename, dirname, join, resolve } from 'node:path'; import { homedir } from 'node:os'; import { MODEL_PROFILES } from './config-query.js'; @@ -44,11 +44,7 @@ async function listMilestoneArchiveDirs(planBase: string): Promise { return entries .filter((e) => e.isDirectory() && MILESTONE_ARCHIVE_DIR_RE.test(e.name)) .map((e) => join(milestonesDir, e.name)) - .sort((a, b) => { - const an = a.slice(a.lastIndexOf('/') + 1); - const bn = b.slice(b.lastIndexOf('/') + 1); - return an.localeCompare(bn, undefined, { numeric: true }); - }); + .sort((a, b) => basename(a).localeCompare(basename(b), undefined, { numeric: true })); } catch { return []; } @@ -80,6 +76,28 @@ async function getActiveMilestoneArchiveDir(planBase: string): Promise void, +): Promise { + for (const archiveDir of await listMilestoneArchiveDirs(planBase)) { + try { + const entries = await readdir(archiveDir, { withFileTypes: true }); + for (const e of entries) { + if (!e.isDirectory()) continue; + const m = e.name.match(PHASE_TOKEN_FROM_DIR_RE); + if (m) onPhase(m[1]); + } + } catch { /* archive dir absent/unreadable */ } + } +} + /** * Collect the active phase roots to validate against. When the flat * `.planning/phases/` directory exists, it counts. When an active @@ -539,7 +557,7 @@ export const validateHealth: QueryHandler = async (args, projectDir, workstream) const entries = await readdir(phasesDir, { withFileTypes: true }); for (const e of entries) { if (e.isDirectory()) { - const m = e.name.match(/^(\d+[A-Z]?(?:\.\d+)*)/); + const m = e.name.match(PHASE_TOKEN_FROM_DIR_RE); if (m) validPhases.add(m[1]); } } @@ -554,6 +572,15 @@ export const validateHealth: QueryHandler = async (args, projectDir, workstream) for (const m of all) validPhases.add(m[1]); } catch { /* intentionally empty */ } + // Bug #3652 — STATE.md body retains historical phase references across + // milestones. After /gsd:complete-milestone, phases are moved into + // `milestones/vX.Y-phases/` and their `#### Phase N:` headings in + // ROADMAP.md are collapsed (e.g. inside
blocks), so neither + // the on-disk phases dir nor the ROADMAP heading scan picks them up. + // Treat any phase directory present in any archived milestone as a + // valid phase reference. + await forEachArchivedPhaseToken(planBase, (token) => validPhases.add(token)); + // 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 @@ -577,7 +604,7 @@ export const validateHealth: QueryHandler = async (args, projectDir, workstream) if (!normalizedValid.has(ref) && !normalizedValid.has(padded)) { if (normalizedValid.size > 0) { addIssue('warning', 'W002', - `STATE.md references phase ${ref}, but only phases ${[...validPhases].sort().join(', ')} are declared`, + `STATE.md references phase ${ref}, but only phases ${[...validPhases].sort((a, b) => a.localeCompare(b, undefined, { numeric: true })).join(', ')} are declared`, 'Review STATE.md manually'); } } @@ -712,7 +739,7 @@ export const validateHealth: QueryHandler = async (args, projectDir, workstream) const entries = await readdir(phasesDir, { withFileTypes: true }); for (const e of entries) { if (e.isDirectory()) { - const dm = e.name.match(/^(\d+[A-Z]?(?:\.\d+)*)/i); + const dm = e.name.match(PHASE_TOKEN_FROM_DIR_RE); if (dm) { diskPhases.add(dm[1]); activeDiskPhases.add(dm[1]); @@ -720,20 +747,9 @@ export const validateHealth: QueryHandler = async (args, projectDir, workstream) } } } catch { /* intentionally empty */ } - // Include archived milestone phase directories as valid on-disk locations - // for historical ROADMAP phases. - try { - const milestoneEntries = await readdir(join(planBase, 'milestones'), { withFileTypes: true }); - for (const milestoneEntry of milestoneEntries) { - if (!milestoneEntry.isDirectory() || !/-phases$/i.test(milestoneEntry.name)) continue; - const archivedPhaseEntries = await readdir(join(planBase, 'milestones', milestoneEntry.name), { withFileTypes: true }); - for (const archivedPhase of archivedPhaseEntries) { - if (!archivedPhase.isDirectory()) continue; - const dm = archivedPhase.name.match(/^(\d+[A-Z]?(?:\.\d+)*)/i); - if (dm) diskPhases.add(dm[1]); - } - } - } catch { /* intentionally empty */ } + // Include archived milestone phase directories as valid on-disk + // locations for historical ROADMAP phases. + await forEachArchivedPhaseToken(planBase, (token) => diskPhases.add(token)); for (const p of roadmapPhases) { const variants = phaseVariants(p); diff --git a/tests/verify-health.test.cjs b/tests/verify-health.test.cjs index c36c1e9f8..8f89b9fc3 100644 --- a/tests/verify-health.test.cjs +++ b/tests/verify-health.test.cjs @@ -201,6 +201,63 @@ describe('validate health command', () => { assert.strictEqual(w002.repairable, false, 'W002 should not be auto-repairable'); }); + // Regression: #3652 — after /gsd:complete-milestone, phase dirs move into + // milestones/vX.Y-phases/ and their `#### Phase N:` headings in ROADMAP.md + // get collapsed inside
blocks. The heading-scan regex misses + // collapsed phases and collectDiskPhases() only walks the active archive, + // so W002 used to fire for every historical phase number mentioned in + // STATE.md's narrative body. Cross-referencing every milestone archive + // suppresses the false positive. + test('does not warn W002 for phase refs that live in any milestones archive (#3652)', () => { + writeMinimalProjectMd(tmpDir); + writeValidConfigJson(tmpDir); + fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '23-current'), { recursive: true }); + fs.mkdirSync(path.join(tmpDir, '.planning', 'milestones', 'v1.3a-phases', '12-old-phase'), { recursive: true }); + for (const n of ['19-alpha', '20-beta', '21-gamma', '22-delta']) { + fs.mkdirSync(path.join(tmpDir, '.planning', 'milestones', 'v1.3b-phases', n), { recursive: true }); + } + fs.writeFileSync( + path.join(tmpDir, '.planning', 'ROADMAP.md'), + [ + '# Roadmap', '', + '
v1.3a: Shipped', '', + '- Phase 12: archived', '', + '
', '', + '
v1.3b: Shipped', '', + '- Phase 19, 20, 21, 22: archived', '', + '
', '', + '## v1.4: Current', '', + '### Phase 23: Current work', '', + ].join('\n') + ); + fs.writeFileSync( + path.join(tmpDir, '.planning', 'STATE.md'), + [ + '---', 'milestone: v1.4', 'milestone_name: Current', 'status: executing', '---', '', + '# State', '', + '**Current Phase:** 23', '', + '## Recent', '- Phase 19 shipped', '- Phase 20 shipped', '- Phase 21 shipped', '- Phase 22 shipped', + '', '## Decisions', '- Decision from Phase 12 still applies', + ].join('\n') + ); + + const result = runGsdTools('validate health', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + const w002s = (output.warnings || []).filter(w => w.code === 'W002'); + assert.strictEqual(w002s.length, 0, `Did not expect W002s for archived phases: ${JSON.stringify(w002s)}`); + // Also no W006 for the archived phases — extractCurrentMilestone strips + // shipped milestones before the Check 8 heading scan in the CJS path, + // so archived phase numbers never reach `roadmapPhases`. Pins the + // assumption that drove the decision NOT to mirror the W002 archive + // union into Check 8 on the CJS side. + const w006s = (output.warnings || []).filter(w => + w.code === 'W006' && /Phase (?:12|19|20|21|22)\b/.test(String(w.message)), + ); + assert.strictEqual(w006s.length, 0, `Did not expect W006s for archived phases: ${JSON.stringify(w006s)}`); + }); + // ─── Check 5: config.json valid JSON + valid schema ─────────────────────── test('warns when config.json is missing with repairable true', () => {