fix(#3641): make v005/v004 see bracket-convention phase entries (#3723)

* test(#3641): failing-first bracket-window validate rows

* fix(#3641): thread phase convention into hasphaseentries for v004/v005

* test(#3641): review rows - digit-anchor, decoy, probe parity, t.after

* fix(#3641): digit-anchor bracket entry token; thread probe scope axis

* fix(#3641): align frontmatter bound with probe; changeset

* chore(#3641): backfill changeset pr number

---------

Co-authored-by: sim <sim@local>
This commit is contained in:
Tom Boucher
2026-08-20 17:48:50 -04:00
committed by GitHub
parent 03a3f779ce
commit 072b97d276
5 changed files with 436 additions and 42 deletions

View File

@@ -26,7 +26,7 @@ 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;
const { extractCurrentMilestoneScoped, hasPhaseEntries } = roadmapParserMod;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import planningScopeMod = require('./planning-scope.cjs');
const { SCOPE } = planningScopeMod;
@@ -188,11 +188,45 @@ function routeRoadmapCommand({ roadmap, args, cwd, raw, error }: RouteRoadmapCom
}
}
// No recognizable phase structure — at least one `### Phase N:` heading.
// Mirrors the phase-heading pattern used across roadmap-parser.cts.
const hasPhaseEntry = /^#{2,4}\s*Phase\s+\S/im.test(roadmapContent);
// #3641: resolve phase_id_convention ONCE, ahead of every consumer in
// this validate pass — V004's entry check and V005's scope classifier
// below, and the W021 milestone-prefix check after them.
// Authoritative source: .planning/config.json (set by the upgrade
// command). Fallback: ROADMAP.md frontmatter (for projects that set
// the field there directly).
let convention: string | undefined | null;
try {
const cfg = loadConfig(cwd);
convention = cfg['phase_id_convention'] as string | undefined | null;
} catch {
convention = undefined;
}
if (convention === undefined || convention === null) {
// Fallback: read from ROADMAP.md frontmatter. Bounded to match
// cmdRoadmapMilestoneScope's copy exactly (#3641 review NEW-1: an
// unbounded capture here read past 4KB frontmatters the probe's
// bounded copy could not, diverging validate from the probe).
const fmMatch = roadmapContent.match(/^---\r?\n([\s\S]{0,4000}?)\r?\n---/);
if (fmMatch) {
const kvMatch = fmMatch[1].match(/^phase_id_convention:\s*(.*)$/m);
if (kvMatch) {
const val = kvMatch[1].trim();
if (val !== 'null' && val !== '') {
convention = val.replace(/^["']|["']$/g, '');
}
}
}
}
// No recognizable phase structure. #3641: routed through the
// roadmap-parser owner (`hasPhaseEntries` — headings, #2199 bullets,
// #3577 table rows) instead of a private inline heading regex, so the
// document-level check and the V005 scope axis below can never
// disagree about what a phase entry is — and so a bracket-convention
// project's `### [GSD.04] 01:` entries are entries here too.
const hasPhaseEntry = hasPhaseEntries(roadmapContent, convention);
if (!hasPhaseEntry && !warnings.some((w) => w.code === 'V002')) {
warnings.push({ code: 'V004', message: 'ROADMAP.md contains no recognizable phase entries (no "### Phase N:" headings)' });
warnings.push({ code: 'V004', message: 'ROADMAP.md contains no recognizable phase entries (no phase headings, bullet entries, or table rows)' });
}
// #3263: a whole-document phase check (V004 above) is satisfied by a
@@ -207,13 +241,15 @@ function routeRoadmapCommand({ roadmap, args, cwd, raw, error }: RouteRoadmapCom
// an unscoped/unreadable window is a different failure mode with a
// different remediation, deliberately not warned here.
try {
const scoped = extractCurrentMilestoneScoped(contentAfterBom, cwd);
// #3641: thread the resolved convention so the scope axis's
// hasPhaseEntries comparison recognizes bracket phase entries.
const scoped = extractCurrentMilestoneScoped(contentAfterBom, cwd, undefined, convention);
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)',
'active milestone\'s resolved window (check for a heading between the milestone heading and its phase-entry sections)',
});
}
} catch {
@@ -221,29 +257,8 @@ function routeRoadmapCommand({ roadmap, args, cwd, raw, error }: RouteRoadmapCom
// 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).
let convention: string | undefined | null;
try {
const cfg = loadConfig(cwd);
convention = cfg['phase_id_convention'] as string | undefined | null;
} catch {
convention = undefined;
}
if (convention === undefined || convention === null) {
// Fallback: read from ROADMAP.md frontmatter
const fmMatch = roadmapContent.match(/^---\r?\n([\s\S]+?)\r?\n---/);
if (fmMatch) {
const kvMatch = fmMatch[1].match(/^phase_id_convention:\s*(.*)$/m);
if (kvMatch) {
const val = kvMatch[1].trim();
if (val !== 'null' && val !== '') {
convention = val.replace(/^["']|["']$/g, '');
}
}
}
}
// W021 only fires when phase_id_convention is explicitly
// 'milestone-prefixed' — the same hoisted resolution above (#3641).
if (convention === 'milestone-prefixed') {
warnings.push(...checkW021(roadmapContent));
}