fix(#4134): refuse punctuation-only milestone heading names (#4358)

* test(#4134): fail-first regression — refuse punctuation-fragment milestone names

A first-milestone ROADMAP.md H1 that puts the version after the name
(# Roadmap: Project — Name (v1.13)) leaves exactly ')' after the heading's
own version token, which the ADR-3180 §7.2 pinned name rule returns as a
COMPLETE-scope milestone name. Failing-first coverage:

- getMilestoneInfo: name-then-version H1 (STATE-anchored + ROADMAP-only
  fallback) must yield TRUNCATED {version, name: null}, never ')'
- the refusal is level-agnostic (H2/H3)
- punctuation-family remainders (')', '()', '**', '.,;:', ']}', emoji-only)
- listMilestoneHeadings enumerates the heading with name: null
- init manager CLI reports milestone_name: null and no lone ')' anywhere
- property (seed 20260905, 300 runs): a word-char remainder is always a
  name, a punctuation-only remainder never is
- negative space: canonical delimiter forms, parenthetical names (#3171),
  trailing markers, digit-only names, CRLF headings, version-last-no-parens
  control

* fix(#4134): refuse punctuation-only milestone heading names

extractMilestoneHeadingName returns everything after the heading's own
version token as the name (ADR-3180 §7.2 pinned rule), which assumes
version-then-name. A name-then-version heading — the H1 a first-ever
ROADMAP.md drifts into ('# Roadmap: Project — Name (v1.13)') — leaves
exactly ')' after the token, and that fragment was returned as a
COMPLETE-scope milestone name, propagating into init.* JSON output and
buildStateFrontmatter's STATE.md writes.

A remainder with no letter or digit anywhere (any script) is heading
structure, not a curated name: refuse it as name: null so callers report
the honest §7.2 rule-6 answer (version kept, TRUNCATED scope). Names
that merely contain punctuation are unaffected — '(' stays an ordinary
name character (#3171) — and digit-only names qualify.

Also closes the template gap that lets the shape occur: the roadmapper
agent's output_formats now templates the version-free canonical H1
('# Roadmap: [Project Name]', per templates/roadmap.md) instead of
leaving a first milestone's title line to invention. The new section
shifts the file's existing bare-gsd-tools prose mention from line 647
to 660, so its line-keyed PROSE_ALLOWLIST entry moves with it.

Emitted-Drift-Ack-Growth: gsd-roadmapper.md — deliberate +498 bytes: new '### 0. Top-Level Title (H1)' output_formats section templating the canonical version-free H1, closing the first-milestone template gap that lets an H1 drift into 'Name (vX.Y)' and corrupt milestone_name extraction (#4134)

* chore(#4134): add changeset

* chore(#4134): backfill PR number in changeset

---------

Co-authored-by: sim <sim@local>
This commit is contained in:
Tom Boucher
2026-09-05 23:31:35 -04:00
committed by GitHub
parent e6d047decc
commit c3e2da153b
8 changed files with 282 additions and 3 deletions

View File

@@ -2332,6 +2332,38 @@ test('listMilestoneHeadingsHeadingFieldIsCrlfFreeAndTrimmed', () => {
assert.strictEqual(result[1].name, 'Old');
});
// (RED) #4134: the §7.2 pinned rule takes everything after the heading's OWN
// version token as the name, so a name-then-version heading (`… (v1.13)`) --
// the shape a first-ever ROADMAP.md drifts into when nothing templates its H1
// -- leaves exactly `)` after the token, which used to be enumerated as the
// milestone's "name". ADR-3180 §7.2 rule 6: a remainder with no letter or
// digit anywhere is heading structure, not a curated name -- enumerate it as
// `name: null` and let consumers report the honest TRUNCATED identity. The
// enumeration itself (which headings, which version, closed status) is
// unchanged; only the garbage "name" is refused.
test('listMilestoneHeadingsRefusesPunctuationOnlyNames', () => {
const content = [
'# Roadmap: GSD Core — Native OMP Runtime Support (v1.13)',
'',
'### Phase 1: Runtime Adapter Interface',
].join('\n');
const result = listMilestoneHeadings(content);
assert.strictEqual(result.length, 1);
assert.strictEqual(result[0].version, 'v1.13');
assert.strictEqual(result[0].closed, false);
assert.strictEqual(result[0].name, null, `name must be null, not ${JSON.stringify(result[0].name)}`);
});
// (RED) #4134 negative space: a name that merely CONTAINS punctuation is a
// name -- `(` is an ordinary name character and never a terminator (#3171).
// Only a remainder with zero word characters is refused.
test('listMilestoneHeadingsKeepsNamesThatContainPunctuation', () => {
const content = ['# Roadmap', '', '## v3.3 — Name (Part 2: Revenge)'].join('\n');
const result = listMilestoneHeadings(content);
assert.strictEqual(result.length, 1);
assert.strictEqual(result[0].name, 'Name (Part 2: Revenge)');
});
test('listMilestoneHeadingsRespectsTheOneToThreeLevelBound', () => {
const content = [
'# v1.0 — Level One',