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

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