From 8757080676f504a94e3c4605f80945489b6a77a0 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Fri, 14 Aug 2026 01:35:35 -0400 Subject: [PATCH] fix(#3263): warn in roadmap validate on truncated milestone window (#3444) * fix(#3263): warn in roadmap validate on truncated milestone window * chore(#3263): backfill pr number in changeset --------- Co-authored-by: sim --- .changeset/serene-tigers-gather.md | 5 ++ src/roadmap-command-router.cts | 32 +++++++++ tests/roadmap.test.cjs | 108 +++++++++++++++++++++++++++++ 3 files changed, 145 insertions(+) create mode 100644 .changeset/serene-tigers-gather.md diff --git a/.changeset/serene-tigers-gather.md b/.changeset/serene-tigers-gather.md new file mode 100644 index 000000000..e1cba0905 --- /dev/null +++ b/.changeset/serene-tigers-gather.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 3444 +--- +roadmap validate now emits a V005 warning and exits non-zero when the active milestone's window is truncated — phase entries exist in ROADMAP.md but are excluded from the milestone's resolved section (e.g. an intervening version-bearing heading closes the window before its own Phase sections). Previously this passed silently with {"warnings":[]}. diff --git a/src/roadmap-command-router.cts b/src/roadmap-command-router.cts index 7b54d8cf4..1761193bd 100644 --- a/src/roadmap-command-router.cts +++ b/src/roadmap-command-router.cts @@ -24,6 +24,12 @@ const { loadConfig } = configLoaderMod; // eslint-disable-next-line @typescript-eslint/no-require-imports import cliExitMod = require('./cli-exit.cjs'); const { ExitError } = cliExitMod; +// eslint-disable-next-line @typescript-eslint/no-require-imports +import roadmapParserMod = require('./roadmap-parser.cjs'); +const { extractCurrentMilestoneScoped } = roadmapParserMod; +// eslint-disable-next-line @typescript-eslint/no-require-imports +import planningScopeMod = require('./planning-scope.cjs'); +const { SCOPE } = planningScopeMod; // ─── Types ──────────────────────────────────────────────────────────────────── @@ -185,6 +191,32 @@ function routeRoadmapCommand({ roadmap, args, cwd, raw, error }: RouteRoadmapCom warnings.push({ code: 'V004', message: 'ROADMAP.md contains no recognizable phase entries (no "### Phase N:" headings)' }); } + // #3263: a whole-document phase check (V004 above) is satisfied by a + // document whose phase entries live OUTSIDE the active milestone's + // resolved window — e.g. an intervening version-bearing heading closes + // the window before its own `### Phase N:` sections. That truncation + // is exactly what the #3184 scope discriminator classifies, so ask + // the single owner (never re-derive the window shape here — + // lint-milestone-window-drift bans local copies) and surface a + // non-COMPLETE-but-not-empty verdict. TRUNCATED only: a genuinely + // phase-less milestone classifies COMPLETE (V004 owns that case) and + // an unscoped/unreadable window is a different failure mode with a + // different remediation, deliberately not warned here. + try { + const scoped = extractCurrentMilestoneScoped(contentAfterBom, cwd); + if (scoped.scope === SCOPE.TRUNCATED) { + warnings.push({ + code: 'V005', + message: + 'Active milestone window is truncated: phase entries exist in ROADMAP.md but are excluded from the ' + + 'active milestone\'s resolved window (check for a heading between the milestone heading and its "### Phase N:" sections)', + }); + } + } catch { + // The classifier is best-effort here — a throw must not mask the + // structural warnings already collected above. + } + // W021 only fires when phase_id_convention is explicitly 'milestone-prefixed'. // Authoritative source: .planning/config.json (set by the upgrade command). // Fallback: ROADMAP.md frontmatter (for projects that set the field there directly). diff --git a/tests/roadmap.test.cjs b/tests/roadmap.test.cjs index 2e562db3c..634b33b39 100644 --- a/tests/roadmap.test.cjs +++ b/tests/roadmap.test.cjs @@ -3708,3 +3708,111 @@ describe('bug #2978: roadmap validate performs structural validation', () => { } finally { cleanup(tmpDir); } }); }); + +// ───────────────────────────────────────────────────────────────────────────── +// bug #3263: roadmap validate is silent when the active milestone window is +// truncated (phase entries exist in the document but are excluded from the +// resolved window). The scope discriminator (#3184) already classifies this +// as SCOPE.TRUNCATED; validate must surface it as V005 + non-zero exit. +// Matrix: .gsd/bug/fix-3263-milestone-window-truncation-retest/50-test-matrix.md +// ───────────────────────────────────────────────────────────────────────────── + +describe('bug #3263: roadmap validate warns on a truncated milestone window', () => { + function writeFixture(tmpDir, roadmapContent, stateFields) { + fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), roadmapContent); + if (stateFields) { + const lines = ['---']; + for (const [k, v] of Object.entries(stateFields)) lines.push(`${k}: ${v}`); + lines.push('---', ''); + fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), lines.join('\n')); + } + } + + test('window closed before its phase entries → V005 warning, non-zero exit', () => { + const tmpDir = createTempProject('gsd-3263-truncated-'); + try { + // v3.0's window closes at the intervening `## v4.0 Next` heading, + // before the phase entries — which exist in the document, under v4.0. + writeFixture(tmpDir, [ + '# Roadmap', + '', + '## v3.0 In Progress 🚧', + '', + 'Some preamble notes. No phase headings here.', + '', + '## v4.0 Next', + '', + '### Phase 1: Foo', + '', + '### Phase 2: Bar', + ].join('\n'), { milestone: 'v3.0' }); + const result = runGsdTools(['roadmap', 'validate', '--raw'], tmpDir); + assert.strictEqual(result.success, false, 'truncated window must exit non-zero'); + const payload = JSON.parse(result.output); + const v005 = payload.warnings.find((w) => w.code === 'V005'); + assert.ok(v005, `truncated window must produce a V005 warning; got: ${JSON.stringify(payload)}`); + assert.ok(v005.message.length > 0, 'V005 carries a message'); + } finally { cleanup(tmpDir); } + }); + + test('phases inside the window → no V005, exit 0 (no false positive)', () => { + const tmpDir = createTempProject('gsd-3263-normal-'); + try { + writeFixture(tmpDir, [ + '# Roadmap', + '', + '## v3.0 In Progress 🚧', + '', + '### Phase 1: Foo', + '', + '### Phase 2: Bar', + '', + '## v4.0 Next', + '', + 'Later plans.', + ].join('\n'), { milestone: 'v3.0' }); + const result = runGsdTools(['roadmap', 'validate', '--raw'], tmpDir); + assert.ok(result.success, `normal roadmap must exit 0; got: ${result.error}`); + const payload = JSON.parse(result.output); + assert.deepStrictEqual(payload.warnings, [], 'normal roadmap must have no warnings'); + } finally { cleanup(tmpDir); } + }); + + test('genuinely phase-less milestone → V004 only, no V005', () => { + const tmpDir = createTempProject('gsd-3263-empty-'); + try { + writeFixture(tmpDir, [ + '# Roadmap', + '', + '## v1.0 Current 🚧', + '', + 'Nothing planned yet.', + ].join('\n'), { milestone: 'v1.0' }); + const result = runGsdTools(['roadmap', 'validate', '--raw'], tmpDir); + assert.strictEqual(result.success, false, 'phase-less roadmap must exit non-zero'); + const payload = JSON.parse(result.output); + assert.ok(payload.warnings.some((w) => w.code === 'V004'), + `V004 still owns the no-phase-entries case; got: ${JSON.stringify(payload)}`); + assert.ok(!payload.warnings.some((w) => w.code === 'V005'), + `a genuinely phase-less milestone must not produce V005; got: ${JSON.stringify(payload)}`); + } finally { cleanup(tmpDir); } + }); + + test('unscoped versioned roadmap (no STATE.md milestone) → no V005 over-fire', () => { + const tmpDir = createTempProject('gsd-3263-unscoped-'); + try { + // No STATE.md: scope is UNSCOPED, not TRUNCATED — must not fire V005. + writeFixture(tmpDir, [ + '# Roadmap', + '', + '## v1.0 Old ✅ SHIPPED', + '', + '### Phase 1: Foo', + ].join('\n')); + const result = runGsdTools(['roadmap', 'validate', '--raw'], tmpDir); + assert.ok(result.success, `unscoped roadmap must exit 0; got: ${result.error}`); + const payload = JSON.parse(result.output); + assert.deepStrictEqual(payload.warnings, [], 'unscoped roadmap must have no warnings'); + } finally { cleanup(tmpDir); } + }); +});