diff --git a/.changeset/quick-dogs-munch.md b/.changeset/quick-dogs-munch.md new file mode 100644 index 000000000..28d21482d --- /dev/null +++ b/.changeset/quick-dogs-munch.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 3723 +--- +**`roadmap validate` and `roadmap milestone-scope` now see bracket-convention phase entries (`### [GSD.04] 01:`)** — with `phase_id_convention: "bracket"` set, a genuinely truncated milestone window warned as nothing (V005 could never fire) while V004 falsely reported "no recognizable phase entries". Both now resolve the convention (config.json, ROADMAP frontmatter fallback) and route V004 through the shared entry predicate, so validate and the milestone-scope probe agree. Bracket milestone headings (`[GSD.02] Name`, no digit token) never count as entries. (#3641) diff --git a/src/roadmap-command-router.cts b/src/roadmap-command-router.cts index b45aa47aa..82f549a2f 100644 --- a/src/roadmap-command-router.cts +++ b/src/roadmap-command-router.cts @@ -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)); } diff --git a/src/roadmap-parser.cts b/src/roadmap-parser.cts index d28c3c1c5..9c0079479 100644 --- a/src/roadmap-parser.cts +++ b/src/roadmap-parser.cts @@ -32,6 +32,10 @@ const { roadmapPhaseLookupSources, extractPhaseToken, isSentinelPhaseId, + // #3641: the single-owner heading-intro and digit-token grammar sources — + // see BRACKET_PHASE_ENTRY_HEADING_RE below. + PHASE_HEADING_PREFIX_SRC, + PHASE_NUMBER_TOKEN_SOURCE, } = phaseIdModule; // eslint-disable-next-line @typescript-eslint/no-require-imports import planningWorkspace = require('./planning-workspace.cjs'); @@ -412,12 +416,40 @@ function sliceMilestoneWindow(content: string, version: string): string | null { * exist" — a window containing only sentinel phases still reached the * region and must read COMPLETE, not TRUNCATED. */ -function hasPhaseEntries(markdown: string): boolean { +// #3641: the bracket-convention phase-ENTRY heading shape — ADR-612 Decision +// 1's own discriminator: a phase heading is a bracket followed by a +// DIGIT-then-colon (`### [GSD.04] 01: Name`); a bracket followed by a NAME +// is a milestone heading and must never count. Every fragment interpolates a +// single-owner export from phase-id.cts — the heading intro +// (PHASE_HEADING_PREFIX_SRC: a `[...]` bracket optionally followed by a +// `Phase ` label, or a bare `Phase ` label), the digit-bearing token +// (PHASE_NUMBER_TOKEN_SOURCE, which also covers the dotted sub-phase form +// `[GSD.02] 05.03:`), and the optional pre-colon tag +// (OPTIONAL_PHASE_TAG_SOURCE) — never a re-typed grammar. Tested IN +// ADDITION to the legacy pattern below, so bracket mode is a strict +// superset: mid-migration legacy-labeled headings (`Phase AUTH-101:`-style +// custom ids included) keep their existing recognition. Review finding: an +// earlier single-alternative form with a `[\\w]` token admitted +// `[bracket] Word:` shapes — a colon-bearing MILESTONE heading inside the +// window read as an entry (defeating V005 outright for that spelling) and a +// decoy `### [GSD.04] Notes:` outside the window manufactured a false V005 +// while suppressing the correct V004. The digit anchor forecloses both. +const BRACKET_PHASE_ENTRY_HEADING_RE = new RegExp( + `^${PHASE_HEADING_PREFIX_SRC}${PHASE_NUMBER_TOKEN_SOURCE}${OPTIONAL_PHASE_TAG_SOURCE}\\s*:`, + 'i', +); + +function hasPhaseEntries(markdown: string, phaseIdConvention?: string | null): boolean { // #1729: `(?:\s*\([^)\n]{0,200}\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE). + // #3641: the widened grammar engages ONLY when the resolved convention is + // 'bracket' — a project that has not opted in runs the legacy pattern + // alone, byte-identically. const phaseHeadingPattern = /^(?:\[[^\]]{1,200}\]\s*)?Phase\s+([\w][\w.-]*)(?:\s*\([^)\n]{0,200}\))?\s*:/i; + const bracketMode = phaseIdConvention === 'bracket'; for (const h of tokenizeHeadings(markdown)) { if (h.level < 2 || h.level > 4) continue; if (phaseHeadingPattern.test(h.text)) return true; + if (bracketMode && BRACKET_PHASE_ENTRY_HEADING_RE.test(h.text)) return true; } // #3184 review finding: the bullet fallback must be fence-aware too, or a // FENCED markdown EXAMPLE of the `- [ ] **Phase N — Name**` syntax (e.g. a @@ -598,8 +630,18 @@ function classifyMilestoneWindow(input: { * symbols, 20 direct callers) means its signature and return type do not * change. This is the real owner; `extractCurrentMilestone` becomes a * one-line wrapper returning `.value` so every existing caller is untouched. + * + * @param phaseIdConvention - #3641: the RESOLVED `phase_id_convention` + * config value, threaded to `hasPhaseEntries` so the scope axis's row-8 + * comparison recognizes bracket-convention phase entries + * (`### [GSD.04] 01: Name`). Optional: absent, or any value other than + * `'bracket'`, compiles the legacy entry grammar byte-identically — the + * widening engages only for a project that resolved the convention + * explicitly. `extractCurrentMilestone`'s wrapper deliberately does NOT + * expose it (its 20 callers are not the scope-axis consumers; V005's + * router site and `getMilestonePhaseFilter` resolve and thread it). */ -function extractCurrentMilestoneScoped(content: string, cwd?: string, ws?: string | null): { value: string; scope: Scope } { +function extractCurrentMilestoneScoped(content: string, cwd?: string, ws?: string | null, phaseIdConvention?: string | null): { value: string; scope: Scope } { if (!cwd) { // Row 1: a deliberate unscoped read (no cwd supplied) is a real answer — // the caller asked for no scoping, so whole-document is COMPLETE. @@ -640,13 +682,13 @@ function extractCurrentMilestoneScoped(content: string, cwd?: string, ws?: strin versionResolved, hasVersionedMilestones: versionedMilestonesPresent, headingFound: false, - windowHasPhaseEntries: hasPhaseEntries(value), - documentHasPhaseEntries: hasPhaseEntries(value), + windowHasPhaseEntries: hasPhaseEntries(value, phaseIdConvention), + documentHasPhaseEntries: hasPhaseEntries(value, phaseIdConvention), }), }; } - const documentHasPhaseEntries = hasPhaseEntries(stripShippedMilestones(content)); + const documentHasPhaseEntries = hasPhaseEntries(stripShippedMilestones(content), phaseIdConvention); const summaryPattern = new RegExp( `]*>([^<]*${escapeRegex(version)}[^<]*)<\\/summary>`, 'i' @@ -680,7 +722,7 @@ function extractCurrentMilestoneScoped(content: string, cwd?: string, ws?: strin versionResolved, hasVersionedMilestones: versionedMilestonesPresent, headingFound: true, - windowHasPhaseEntries: hasPhaseEntries(value), + windowHasPhaseEntries: hasPhaseEntries(value, phaseIdConvention), documentHasPhaseEntries, }), }; @@ -694,7 +736,7 @@ function extractCurrentMilestoneScoped(content: string, cwd?: string, ws?: strin versionResolved, hasVersionedMilestones: versionedMilestonesPresent, headingFound: false, - windowHasPhaseEntries: hasPhaseEntries(value), + windowHasPhaseEntries: hasPhaseEntries(value, phaseIdConvention), documentHasPhaseEntries, }), }; @@ -787,7 +829,7 @@ function extractCurrentMilestoneScoped(content: string, cwd?: string, ws?: strin versionResolved, hasVersionedMilestones: versionedMilestonesPresent, headingFound: true, - windowHasPhaseEntries: hasPhaseEntries(value), + windowHasPhaseEntries: hasPhaseEntries(value, phaseIdConvention), documentHasPhaseEntries, }), }; @@ -1380,7 +1422,7 @@ function getMilestonePhaseFilter(cwd: string, versionOverride?: string | null, p const roadmapPath = path.join(planningDir(cwd, ws), 'ROADMAP.md'); const roadmapContent = platformReadSync(roadmapPath); if (roadmapContent === null) throw new Error('missing'); - const scopedResult = extractCurrentMilestoneScoped(roadmapContent, cwd, ws); + const scopedResult = extractCurrentMilestoneScoped(roadmapContent, cwd, ws, phaseIdConvention); let roadmap = scopedResult.value; // Default: the filter's window IS extractCurrentMilestoneScoped's own // window (reused verbatim, not re-derived — ADR-3180 Decision 4c). @@ -1417,7 +1459,7 @@ function getMilestonePhaseFilter(cwd: string, versionOverride?: string | null, p // via `storedMilestone`). const sliced = sliceMilestoneWindow(roadmapContent, versionOverride); - const documentHasPhaseEntries = hasPhaseEntries(stripShippedMilestones(roadmapContent)); + const documentHasPhaseEntries = hasPhaseEntries(stripShippedMilestones(roadmapContent), phaseIdConvention); if (sliced !== null) { versionScoped = true; @@ -1441,7 +1483,7 @@ function getMilestonePhaseFilter(cwd: string, versionOverride?: string | null, p versionResolved: true, hasVersionedMilestones: hasVersionedMilestonesGlobal, headingFound: sliced !== null, - windowHasPhaseEntries: hasPhaseEntries(roadmap), + windowHasPhaseEntries: hasPhaseEntries(roadmap, phaseIdConvention), documentHasPhaseEntries, }); } @@ -1662,4 +1704,8 @@ export = { scanMilestonePhaseIds, collectTablePhaseRows, findMilestoneScopeHeadingLines, + // #3641: the scope axis's phase-ENTRY predicate, exported so roadmap + // validate's V004 document-level check routes through the same single + // owner (and its convention gate) instead of a private inline copy. + hasPhaseEntries, }; diff --git a/src/roadmap.cts b/src/roadmap.cts index 706536cfb..ca4ae69ef 100644 --- a/src/roadmap.cts +++ b/src/roadmap.cts @@ -34,6 +34,11 @@ import { platformWriteSync } from './shell-command-projection.cjs'; // eslint-disable-next-line @typescript-eslint/no-require-imports import planningWorkspace = require('./planning-workspace.cjs'); const { planningPaths, withPlanningLock, findContextMdIn } = planningWorkspace; +// #3641: milestone-scope's convention resolution reads the project config +// (no cycle — config-loader does not import this module). +// eslint-disable-next-line @typescript-eslint/no-require-imports +import configLoaderForScope = require('./config-loader.cjs'); +const { loadConfig: loadConfigForScope } = configLoaderForScope; // eslint-disable-next-line @typescript-eslint/no-require-imports import scanPhasePlans = require('./plan-scan.cjs'); // eslint-disable-next-line @typescript-eslint/no-require-imports @@ -707,7 +712,35 @@ function cmdRoadmapMilestoneScope(cwd: string, raw: boolean): void { } const rawContent = fs.readFileSync(roadmapPath, 'utf-8'); - const { value: window, scope } = extractCurrentMilestoneScoped(rawContent, cwd); + // #3641: resolve phase_id_convention and thread it into the scope axis, so + // this probe and `roadmap validate`'s V005 answer the SAME question the + // SAME way for a bracket-convention project — a window the classifier + // calls TRUNCATED in validate must never read COMPLETE here (the #3262 + // capture/compare guard consumes this scope). Resolution mirrors the + // validate router's: .planning/config.json first, ROADMAP.md frontmatter + // as fallback. + let phaseIdConvention: string | undefined | null; + try { + const cfg = loadConfigForScope(cwd); + phaseIdConvention = cfg['phase_id_convention'] as string | undefined | null; + } catch { + phaseIdConvention = undefined; + } + if (phaseIdConvention === undefined || phaseIdConvention === null) { + // Bounded per local/no-unbounded-quantifier (#2128): frontmatter is a + // short header block — 4KB is orders of magnitude beyond any real one. + const fmMatch = rawContent.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 !== '') { + phaseIdConvention = val.replace(/^["']|["']$/g, ''); + } + } + } + } + const { value: window, scope } = extractCurrentMilestoneScoped(rawContent, cwd, undefined, phaseIdConvention); // Document order (Set insertion order) — deterministic for a given document. const phases = [...scanMilestonePhaseIds(window)]; output({ scope, phases, phase_count: phases.length }, raw, undefined); diff --git a/tests/roadmap.test.cjs b/tests/roadmap.test.cjs index 336eaef96..f23ebeece 100644 --- a/tests/roadmap.test.cjs +++ b/tests/roadmap.test.cjs @@ -3851,3 +3851,298 @@ describe('bug #3263: roadmap validate warns on a truncated milestone window', () } finally { cleanup(tmpDir); } }); }); + +// ───────────────────────────────────────────────────────────────────────────── +// #3641: hasPhaseEntries is convention-blind — V005 (and V004) cannot see +// bracket-convention phase entries (`### [GSD.04] 01: Name`), so a genuinely +// truncated bracket window classifies COMPLETE and validate stays silent +// (while V004 falsely reports "no recognizable phase entries"). The fix +// threads the resolved `phase_id_convention` into hasPhaseEntries and routes +// V004 through the same owner. Mirrors the #3263 harness directly above. +// Matrix: .gsd/bug/fix-3641-hasphaseentries-convention-v005/50-test-matrix.md +// ───────────────────────────────────────────────────────────────────────────── + +describe('bug #3641: bracket-convention windows are visible to validate (V005/V004)', () => { + const ROADMAP_PARSER_LIB = path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'roadmap-parser.cjs'); + + function writeFixture3641(tmpDir, roadmapContent, stateFields, conventionSource) { + 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')); + } + if (conventionSource && conventionSource.kind === 'config') { + fs.writeFileSync( + path.join(tmpDir, '.planning', 'config.json'), + JSON.stringify({ phase_id_convention: 'bracket' }, null, 2), + ); + } + // kind === 'frontmatter': the roadmap content itself carries the frontmatter. + } + + const BRACKET_TRUNCATED_ROADMAP = [ + '# Roadmap', + '', + '## v3.0 In Progress 🚧', + '', + 'Some preamble notes. No phase headings here.', + '', + '## v4.0 Next', + '', + '### [GSD.04] 01: Foo', + '', + '### [GSD.04] 02: Bar', + ].join('\n'); + + test('bracket truncated window (config convention) → V005 fires, no false V004', (t) => { + const tmpDir = createTempProject('gsd-3641-bracket-truncated-'); + t.after(() => cleanup(tmpDir)); + writeFixture3641(tmpDir, BRACKET_TRUNCATED_ROADMAP, { milestone: 'v3.0' }, { kind: 'config' }); + const result = runGsdTools(['roadmap', 'validate', '--raw'], tmpDir); + assert.strictEqual(result.success, false, 'truncated bracket window must exit non-zero'); + const payload = JSON.parse(result.output); + assert.ok(payload.warnings.some((w) => w.code === 'V005'), + `truncated bracket window must produce V005; got: ${JSON.stringify(payload)}`); + assert.ok(!payload.warnings.some((w) => w.code === 'V004'), + `the document HAS bracket phase entries — V004 must not fire; got: ${JSON.stringify(payload)}`); + }); + + test('bracket complete window → no warnings, exit 0', (t) => { + const tmpDir = createTempProject('gsd-3641-bracket-complete-'); + t.after(() => cleanup(tmpDir)); + writeFixture3641(tmpDir, [ + '# Roadmap', + '', + '## v3.0 In Progress 🚧', + '', + '### [GSD.03] 01: Foo', + '', + '### [GSD.03] 02: Bar', + '', + '## v4.0 Next', + '', + 'Later plans.', + ].join('\n'), { milestone: 'v3.0' }, { kind: 'config' }); + const result = runGsdTools(['roadmap', 'validate', '--raw'], tmpDir); + assert.ok(result.success, `complete bracket window must exit 0; got: ${result.error}`); + const payload = JSON.parse(result.output); + assert.deepStrictEqual(payload.warnings, [], `complete bracket window must have no warnings; got: ${JSON.stringify(payload)}`); + }); + + test('bracket truncated window (frontmatter convention) → V005 fires', (t) => { + const tmpDir = createTempProject('gsd-3641-bracket-fm-'); + t.after(() => cleanup(tmpDir)); + const withFrontmatter = [ + '---', + 'phase_id_convention: bracket', + '---', + '', + BRACKET_TRUNCATED_ROADMAP, + ].join('\n'); + writeFixture3641(tmpDir, withFrontmatter, { milestone: 'v3.0' }, { kind: 'frontmatter' }); + const result = runGsdTools(['roadmap', 'validate', '--raw'], tmpDir); + assert.strictEqual(result.success, false, 'frontmatter-convention truncated window must exit non-zero'); + const payload = JSON.parse(result.output); + assert.ok(payload.warnings.some((w) => w.code === 'V005'), + `frontmatter convention must resolve for V005; got: ${JSON.stringify(payload)}`); + }); + + test('legacy truncated window without convention → V005 still fires (parity)', (t) => { + const tmpDir = createTempProject('gsd-3641-legacy-parity-'); + t.after(() => cleanup(tmpDir)); + writeFixture3641(tmpDir, [ + '# Roadmap', + '', + '## v3.0 In Progress 🚧', + '', + 'Some preamble notes.', + '', + '## v4.0 Next', + '', + '### Phase 1: Foo', + '', + '### Phase 2: Bar', + ].join('\n'), { milestone: 'v3.0' }, null); + const result = runGsdTools(['roadmap', 'validate', '--raw'], tmpDir); + assert.strictEqual(result.success, false, 'legacy truncated window must still exit non-zero'); + const payload = JSON.parse(result.output); + assert.ok(payload.warnings.some((w) => w.code === 'V005'), + `legacy V005 behavior must be unchanged; got: ${JSON.stringify(payload)}`); + }); + + test('bracket-spelling roadmap without convention → not widened (V004 only)', (t) => { + const tmpDir = createTempProject('gsd-3641-no-convention-'); + t.after(() => cleanup(tmpDir)); + writeFixture3641(tmpDir, BRACKET_TRUNCATED_ROADMAP, { milestone: 'v3.0' }, null); + const result = runGsdTools(['roadmap', 'validate', '--raw'], tmpDir); + assert.strictEqual(result.success, false, 'unrecognized entries must still exit non-zero'); + const payload = JSON.parse(result.output); + assert.ok(payload.warnings.some((w) => w.code === 'V004'), + `a project that never opted in keeps the legacy reading (V004); got: ${JSON.stringify(payload)}`); + assert.ok(!payload.warnings.some((w) => w.code === 'V005'), + `no convention declared — the widened grammar must not engage; got: ${JSON.stringify(payload)}`); + }); + + test('bracket phase-less roadmap with bracket milestone name headings → V004 only, no V005', (t) => { + const tmpDir = createTempProject('gsd-3641-bracket-phaseless-'); + t.after(() => cleanup(tmpDir)); + writeFixture3641(tmpDir, [ + '# Roadmap', + '', + '## [GSD.02] Foundation', + '', + 'Nothing planned yet. A bracket MILESTONE heading is a bracket plus a', + 'name — not a phase entry (no digit-then-colon tail).', + ].join('\n'), { milestone: 'v3.0' }, { kind: 'config' }); + const result = runGsdTools(['roadmap', 'validate', '--raw'], tmpDir); + assert.strictEqual(result.success, false, 'genuinely phase-less bracket roadmap must exit non-zero'); + const payload = JSON.parse(result.output); + assert.ok(payload.warnings.some((w) => w.code === 'V004'), + `V004 owns the phase-less case under bracket too; got: ${JSON.stringify(payload)}`); + assert.ok(!payload.warnings.some((w) => w.code === 'V005'), + `a phase-less window must never produce V005; got: ${JSON.stringify(payload)}`); + }); + + test('fenced bracket heading example does not count as a phase entry', (t) => { + const tmpDir = createTempProject('gsd-3641-bracket-fence-'); + t.after(() => cleanup(tmpDir)); + writeFixture3641(tmpDir, [ + '# Roadmap', + '', + '## v3.0 In Progress 🚧', + '', + 'Documentation example of the syntax:', + '', + '```markdown', + '### [GSD.03] 01: Documented example', + '```', + '', + 'No real phase headings anywhere.', + '', + '## v4.0 Next', + '', + 'Later plans.', + ].join('\n'), { milestone: 'v3.0' }, { kind: 'config' }); + const result = runGsdTools(['roadmap', 'validate', '--raw'], tmpDir); + assert.strictEqual(result.success, false, 'fenced-example-only roadmap must exit non-zero'); + const payload = JSON.parse(result.output); + assert.ok(payload.warnings.some((w) => w.code === 'V004'), + `a fenced example is not a real entry; got: ${JSON.stringify(payload)}`); + assert.ok(!payload.warnings.some((w) => w.code === 'V005'), + `fenced examples must not flip the scope axis; got: ${JSON.stringify(payload)}`); + }); + + test('bracket+label mixed spelling still recognized under bracket convention', (t) => { + const tmpDir = createTempProject('gsd-3641-bracket-mixed-'); + t.after(() => cleanup(tmpDir)); + writeFixture3641(tmpDir, [ + '# Roadmap', + '', + '## v3.0 In Progress 🚧', + '', + '### [GSD.03] Phase 01: Foo', + '', + '## v4.0 Next', + '', + 'Later plans.', + ].join('\n'), { milestone: 'v3.0' }, { kind: 'config' }); + const result = runGsdTools(['roadmap', 'validate', '--raw'], tmpDir); + assert.ok(result.success, `mixed spelling is already-recognized — must exit 0; got: ${result.error}`); + const payload = JSON.parse(result.output); + assert.deepStrictEqual(payload.warnings, [], `mixed spelling must stay recognized; got: ${JSON.stringify(payload)}`); + }); + + test('colon-bearing bracket milestone heading in-window does not read as a phase entry (#3641 review HIGH)', (t) => { + const tmpDir = createTempProject('gsd-3641-bracket-colon-milestone-'); + t.after(() => cleanup(tmpDir)); + writeFixture3641(tmpDir, [ + '# Roadmap', + '', + '## [GSD.03] v3.0: Current', + '', + 'No phases under the active milestone — and its own heading carries a', + 'colon, which must NOT make the window read as having phase entries.', + '', + '## [GSD.04] v4.0: Next', + '', + '### [GSD.04] 01: Foo', + ].join('\n'), { milestone: 'v3.0' }, { kind: 'config' }); + const result = runGsdTools(['roadmap', 'validate', '--raw'], tmpDir); + assert.strictEqual(result.success, false, 'truncated bracket window (colon-bearing milestone heading) must exit non-zero'); + const payload = JSON.parse(result.output); + assert.ok(payload.warnings.some((w) => w.code === 'V005'), + `ADR-612 discriminator: a bracket + NAME(:version) heading is a milestone, not an entry — the window is still truncated; got: ${JSON.stringify(payload)}`); + assert.ok(!payload.warnings.some((w) => w.code === 'V004'), `real bracket entries exist; got: ${JSON.stringify(payload)}`); + }); + + test('decoy bracket notes heading outside the window does not manufacture V005 (#3641 review MEDIUM)', (t) => { + const tmpDir = createTempProject('gsd-3641-bracket-decoy-'); + t.after(() => cleanup(tmpDir)); + writeFixture3641(tmpDir, [ + '# Roadmap', + '', + '## v3.0 Current 🚧', + '', + 'Nothing planned yet — a genuinely phase-less milestone.', + '', + '## v4.0 Next', + '', + '### [GSD.04] Notes: follow-ups', + '', + 'Prose under a later milestone, not a phase entry.', + ].join('\n'), { milestone: 'v3.0' }, { kind: 'config' }); + const result = runGsdTools(['roadmap', 'validate', '--raw'], tmpDir); + assert.strictEqual(result.success, false, 'phase-less bracket roadmap must exit non-zero'); + const payload = JSON.parse(result.output); + assert.ok(payload.warnings.some((w) => w.code === 'V004'), + `no real entries — V004 owns the verdict; got: ${JSON.stringify(payload)}`); + assert.ok(!payload.warnings.some((w) => w.code === 'V005'), + `a decoy colon heading must never flip the scope axis to TRUNCATED; got: ${JSON.stringify(payload)}`); + }); + + test('milestone-scope probe agrees with validate on a truncated bracket window (#3641 review MEDIUM)', (t) => { + const tmpDir = createTempProject('gsd-3641-bracket-probe-'); + t.after(() => cleanup(tmpDir)); + writeFixture3641(tmpDir, BRACKET_TRUNCATED_ROADMAP, { milestone: 'v3.0' }, { kind: 'config' }); + const result = runGsdTools(['roadmap', 'milestone-scope', '--raw'], tmpDir); + assert.ok(result.success, `probe must exit 0; got: ${result.error}`); + const payload = JSON.parse(result.output); + assert.strictEqual(payload.scope, 'truncated', + `the #3262 capture/compare signal must agree with validate's V005 classifier; got: ${JSON.stringify(payload)}`); + }); + + test('colon-less legacy phase heading alone still yields V004 (owner-entry definition pin)', (t) => { + const tmpDir = createTempProject('gsd-3641-colonless-'); + t.after(() => cleanup(tmpDir)); + writeFixture3641(tmpDir, [ + '# Roadmap', + '', + '## v3.0 Current 🚧', + '', + '### Phase 1 — Foo', + ].join('\n'), { milestone: 'v3.0' }, null); + const result = runGsdTools(['roadmap', 'validate', '--raw'], tmpDir); + assert.strictEqual(result.success, false, 'colon-less-only roadmap must exit non-zero'); + const payload = JSON.parse(result.output); + assert.ok(payload.warnings.some((w) => w.code === 'V004'), + `the canonical entry grammar is colon-terminated — a colon-less heading is not an entry for ANY reader; got: ${JSON.stringify(payload)}`); + }); + + test('hasPhaseEntries gates the widened grammar on the bracket convention (unit seam)', () => { + // Direct rows on the exported predicate: the widened heading grammar + // engages ONLY when the resolved convention is 'bracket'. + const roadmapParser = require(ROADMAP_PARSER_LIB); + assert.strictEqual(typeof roadmapParser.hasPhaseEntries, 'function', + 'hasPhaseEntries must be exported for the validate seam (#3641)'); + const bracketHeading = '### [GSD.04] 01: Foo'; + const legacyHeading = '### Phase 01: Foo'; + assert.strictEqual(roadmapParser.hasPhaseEntries(bracketHeading, 'bracket'), true, + "bracket convention: '[GSD.04] 01:' is a phase entry"); + assert.strictEqual(roadmapParser.hasPhaseEntries(bracketHeading, null), false, + 'no convention: the bracket+bare-number spelling is not an entry'); + assert.strictEqual(roadmapParser.hasPhaseEntries(legacyHeading, 'bracket'), true, + 'bracket convention is a superset — the legacy label stays recognized'); + }); +});