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 <sim@local>
This commit is contained in:
Tom Boucher
2026-08-14 01:35:35 -04:00
committed by GitHub
parent 8ecbac0350
commit 8757080676
3 changed files with 145 additions and 0 deletions

View File

@@ -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":[]}.

View File

@@ -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).

View File

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