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

@@ -0,0 +1,5 @@
---
type: Fixed
pr: 4358
---
**`milestone_name` no longer corrupts to ")" for a first-milestone ROADMAP whose H1 puts the version after the name** — a punctuation-only heading remainder (e.g. the closing paren of `# Roadmap: Project — Name (v1.13)`) is refused as a name, so `init.*` output reports `null` instead of garbage, and the roadmapper agent now templates the canonical version-free H1. (#4134)

File diff suppressed because one or more lines are too long

View File

@@ -331,6 +331,19 @@ After roadmap creation, REQUIREMENTS.md gets updated with phase mappings:
**CRITICAL: ROADMAP.md requires TWO phase representations. Both are mandatory.**
### 0. Top-Level Title (H1)
The H1 carries the PROJECT name only — never a version and never a milestone name:
```markdown
# Roadmap: [Project Name]
```
Milestone identity (version + name) lives in milestone headings (`## vX.Y — [Name]`) or
`## Milestones` bullets (`🚧 **vX.Y [Name]**`), never in the H1. A trailing version in the
H1 (`# Roadmap: [Project] — [Name] (vX.Y)`) corrupts milestone-name extraction (#4134).
`~/.claude/gsd-core/templates/roadmap.md` is the canonical shape.
### 1. Summary Checklist (under `## Phases`)
Use the form matching `phase_id_convention` from config.

View File

@@ -1589,6 +1589,13 @@ function stripLeadingDelimiter(s: string): string {
* one implementation. Returns `null` when `headingText` carries no version
* token at all (e.g. a non-milestone heading reached this by mistake).
*
* #4134 (§7.2 rule 6 floor): the rule's direction assumes version-then-name.
* A name-then-version heading (`# Roadmap: Project — Name (v1.13)`) leaves a
* punctuation fragment (`)`) after the token; a remainder with no letter or
* digit anywhere is heading structure, not a curated name, and is refused as
* `name: null` so callers report the honest rule-6 answer instead of
* fabricating garbage. Names that merely CONTAIN punctuation are unaffected.
*
* @param expectedVersion - When the caller already knows the exact version it
* is looking for (the STATE-anchored `getMilestoneInfo` path, which located
* this heading via `selectMilestoneHeading(roadmap, stateVersion)`), pass it
@@ -1627,7 +1634,18 @@ function extractMilestoneHeadingName(
// whitespace — the marker is already carried structurally by `closed`, so
// duplicating it inside `name` (e.g. "Old ✅") is redundant and wrong. Only
// these three markers, only at the end; a marker inside a name is untouched.
const name = stripLeadingDelimiter(afterVersion).replace(/\s*(?:[✅📋🚧]\s*)+$/, '') || null;
const candidate = stripLeadingDelimiter(afterVersion).replace(/\s*(?:[✅📋🚧]\s*)+$/, '') || null;
// #4134 (§7.2 rule 6 floor): a "name" with no letter or digit anywhere is
// heading structure, not a curated name. The pinned rule takes everything
// AFTER the version token, so a name-then-version heading (`# Roadmap:
// Project — Name (v1.13)` — the shape a first-ever ROADMAP.md drifts into)
// leaves exactly `)` there, which used to be returned as a COMPLETE-scope
// name and propagated into init.* output and STATE.md. Refuse it: callers
// already report the honest rule-6 answer (version kept, `name: null`,
// scope TRUNCATED) for an unresolvable name. A name that merely CONTAINS
// punctuation is untouched — `(` is an ordinary name character (#3171) —
// and digits alone qualify (`## v4.0 — 42` is the name `42`).
const name = candidate !== null && /[\p{L}\p{N}]/u.test(candidate) ? candidate : null;
return { version, name };
}

View File

@@ -495,6 +495,55 @@ describe('init manager', () => {
assert.strictEqual(output.phases[0].is_active, false);
});
// #4134 — a first-ever ROADMAP.md whose H1 puts the version after the name
// (`# Roadmap: <Project> — <Milestone Name> (v1.13)` — the shape nothing
// templates for a project's first milestone) used to make the §7.2 pinned
// name rule return the literal `)` left after the version token as the
// milestone's "name", and init manager displayed it verbatim. The refusal
// reports the honest ADR-3180 §7.2 rule-6 answer instead: the version is
// real, the name is not resolvable from that heading, milestone_name is
// null — never a punctuation fragment.
test('#4134 — milestone_name is null, never ")", for a name-then-version H1', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
[
'# Roadmap: GSD Core — Native OMP Runtime Support (v1.13)',
'',
'## Phases',
'',
'- [ ] **Phase 1: Runtime Adapter Interface**',
'',
'## Phase Details',
'',
'### Phase 1: Runtime Adapter Interface',
'',
'**Goal:** Define the adapter contract.',
'',
].join('\n')
);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
[
'---',
'gsd_state_version: 1.0',
'milestone: v1.13',
'milestone_name: Native OMP Runtime Support',
'status: planning',
'---',
'',
'# Project State',
'',
].join('\n')
);
const result = runGsdTools('init manager', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.milestone_version, 'v1.13');
assert.strictEqual(output.milestone_name, null);
assert.ok(!result.output.includes('")"'), 'a lone ")" value must not appear anywhere in the JSON');
});
// #3314 — ADR-456 subprocess clock pin: cmdInitManager's `is_active` gate
// (nowMs - newestMtime < 300000) is CLI-subprocess tested, so only the
// GSD_TEST_MODE+GSD_NOW_MS pin can control "now" here (t.mock.timers in the

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',

View File

@@ -105,7 +105,7 @@ const BARE_COMMAND_RE = new RegExp(
const PROSE_ALLOWLIST = [
{ file: 'agents/gsd-executor.md', line: 823, reason: 'describes the SDK return envelope of `gsd-tools query commit`; not an instruction to run the bare word' },
{ file: 'agents/gsd-phase-researcher.md', line: 33, reason: 'package-legitimacy provenance rule names the command as the source of an OK verdict; descriptive' },
{ file: 'agents/gsd-roadmapper.md', line: 647, reason: 'parenthetical "e.g." naming SDK queries a user *could* run; not an agent instruction' },
{ file: 'agents/gsd-roadmapper.md', line: 660, reason: 'parenthetical "e.g." naming SDK queries a user *could* run; not an agent instruction (#4134 shifted it from 647: the H1 template section added above moved the line, the mention is unchanged)' },
{ file: 'agents/gsd-intel-updater.md', line: 40, reason: 'cross-platform note names the `gsd-tools intel <subcommand>` CLI surface descriptively ("CLI invocations go through..."); not an agent instruction' },
{ file: 'gsd-core/workflows/execute-plan.md', line: 419, reason: 'describes the downstream SDK validation step (`validated downstream by ...`); names the mechanism, does not instruct the agent to type it' },
];

View File

@@ -949,6 +949,168 @@ describe('roadmap-parser: getMilestoneInfo #2135 — milestone_name clobber', ()
});
});
// ─── getMilestoneInfo — #4134 punctuation-fragment name refusal ───────────────
// The §7.2 pinned rule takes everything AFTER the heading's own version token
// as the name. For a name-then-version heading (`# Roadmap: Project — Name
// (v1.13)` — the shape a first-ever ROADMAP.md drifts into, since nothing
// templates its H1) that remainder is literally `)`, which the rule used to
// return as a COMPLETE-scope "name". ADR-3180 §7.2 rule 6 is the floor this
// violates: a version known but a name unresolvable is TRUNCATED carrying
// `name: null` — a punctuation-only remainder is heading structure, not a
// curated name (#4134).
describe('roadmap-parser: getMilestoneInfo #4134 — name-then-version heading', () => {
let tmpDir;
beforeEach(() => { tmpDir = createTempProject(); });
afterEach(() => { cleanup(tmpDir); });
test('#4134 — name-then-version H1 never yields a punctuation-fragment name (rule 6: TRUNCATED, name null)', () => {
writeState(tmpDir, { milestone: 'v1.13' });
writeRoadmap(tmpDir, [
'# Roadmap: GSD Core — Native OMP Runtime Support (v1.13)',
'',
'### Phase 1: Runtime Adapter Interface',
].join('\n'));
const info = getMilestoneInfo(tmpDir);
assert.strictEqual(info.scope, SCOPE.TRUNCATED, `scope: ${JSON.stringify(info)}`);
assert.strictEqual(info.value.version, 'v1.13');
assert.strictEqual(info.value.name, null);
});
test('#4134 — ROADMAP-only fallback path also refuses the ")" fragment', () => {
// No STATE.md: the first open milestone heading supplies the version.
writeRoadmap(tmpDir, [
'# Roadmap: GSD Core — Native OMP Runtime Support (v1.13)',
'',
'### Phase 1: Runtime Adapter Interface',
].join('\n'));
const info = getMilestoneInfo(tmpDir);
assert.strictEqual(info.scope, SCOPE.TRUNCATED, `scope: ${JSON.stringify(info)}`);
assert.strictEqual(info.value.version, 'v1.13');
assert.strictEqual(info.value.name, null);
});
test('#4134 — the refusal is level-agnostic (H2/H3 carry the same fragment)', () => {
for (const [level, heading] of [
[2, '## Native OMP Runtime Support (v1.13)'],
[3, '### Native OMP Runtime Support (v1.13)'],
]) {
writeState(tmpDir, { milestone: 'v1.13' });
writeRoadmap(tmpDir, `${heading}\n\n### Phase 1: Setup\n`);
const info = getMilestoneInfo(tmpDir);
assert.strictEqual(info.scope, SCOPE.TRUNCATED, `H${level}: ${JSON.stringify(info)}`);
assert.strictEqual(info.value.version, 'v1.13');
assert.strictEqual(info.value.name, null);
}
});
test('#4134 — every punctuation-only remainder is refused (garbage family)', () => {
// Each fragment survives stripLeadingDelimiter (it does not START with a
// delimiter char) and carries no letter or digit anywhere — the exact
// shape that used to be returned as a "name".
const fragments = [')', '()', '**', '.,;:', ']}', '🎉'];
for (const fragment of fragments) {
writeState(tmpDir, { milestone: 'v1.2' });
writeRoadmap(tmpDir, `## v1.2 — ${fragment}\n\n### Phase 1: Setup\n`);
const info = getMilestoneInfo(tmpDir);
assert.strictEqual(info.scope, SCOPE.TRUNCATED, `fragment ${JSON.stringify(fragment)}: ${JSON.stringify(info)}`);
assert.strictEqual(info.value.version, 'v1.2');
assert.strictEqual(info.value.name, null, `fragment ${JSON.stringify(fragment)} must not become a name`);
}
});
test('#4134 control — version-last without parens was already name:null and stays so', () => {
writeState(tmpDir, { milestone: 'v1.2.3' });
writeRoadmap(tmpDir, '# Milestone Name v1.2.3\n\n### Phase 1: Setup\n');
const info = getMilestoneInfo(tmpDir);
assert.strictEqual(info.value.version, 'v1.2.3');
assert.strictEqual(info.value.name, null);
assert.strictEqual(info.scope, SCOPE.TRUNCATED);
});
test('#4134 negative space — canonical delimiter forms parse identically', () => {
const cases = [
['## v2.0: The Big Launch', 'The Big Launch'],
['## v2.5 — Galaxy Release', 'Galaxy Release'],
['## v2.6 – En Dash Form', 'En Dash Form'],
['## v2.7 - Hyphen Form', 'Hyphen Form'],
['## v2.8 Space Only Form', 'Space Only Form'],
];
for (const [heading, expected] of cases) {
writeState(tmpDir, { milestone: heading.match(/v\d+(?:\.\d+)*/)[0] });
writeRoadmap(tmpDir, `${heading}\n\n### Phase 1: Setup\n`);
const info = getMilestoneInfo(tmpDir);
assert.strictEqual(info.scope, SCOPE.COMPLETE, `${heading}: ${JSON.stringify(info)}`);
assert.strictEqual(info.value.name, expected, `${heading}: ${JSON.stringify(info.value)}`);
}
});
test('#4134 negative space — parenthetical names are retained (#3171)', () => {
writeState(tmpDir, { milestone: 'v1.2' });
writeRoadmap(tmpDir, '## v1.2 — Name (Part 2)\n\n### Phase 1: Setup\n');
const info = getMilestoneInfo(tmpDir);
assert.strictEqual(info.scope, SCOPE.COMPLETE);
assert.strictEqual(info.value.name, 'Name (Part 2)');
});
test('#4134 negative space — markers, digit-only names, CRLF headings unchanged', () => {
// Trailing status marker still stripped, not treated as a "name" (a ✅
// TRAILING marker would make the heading closed and skipped — 📋 does not).
writeState(tmpDir, { milestone: 'v3.0' });
writeRoadmap(tmpDir, '## v3.0 — Planned 📋\n\n### Phase 1: Setup\n');
let info = getMilestoneInfo(tmpDir);
assert.strictEqual(info.scope, SCOPE.COMPLETE);
assert.strictEqual(info.value.name, 'Planned');
// A digit-only name IS a name (\p{N} counts as a word character).
writeState(tmpDir, { milestone: 'v4.0' });
writeRoadmap(tmpDir, '## v4.0 — 42\n\n### Phase 1: Setup\n');
info = getMilestoneInfo(tmpDir);
assert.strictEqual(info.scope, SCOPE.COMPLETE);
assert.strictEqual(info.value.name, '42');
// CRLF heading: the trailing \r must never become part of the verdict.
writeState(tmpDir, { milestone: 'v2.0' });
writeRoadmap(tmpDir, '## v2.0 — CRLF Name\r\n\r\n### Phase 1: Setup\r\n');
info = getMilestoneInfo(tmpDir);
assert.strictEqual(info.scope, SCOPE.COMPLETE);
assert.strictEqual(info.value.name, 'CRLF Name');
});
test('#4134 — property: a word-char remainder is always a name, a punctuation-only remainder never is', () => {
// Document-shaped generator (#2371): fixed literal token alphabets, NOT
// derived from the parser's own regexes. Fragments are token lists joined
// with single spaces, so no token can glue onto the version token and
// trigger the sub-milestone continuation grammar (`v1.3-B`).
const WORD = fc.constantFrom('Alpha', 'Beta', 'R2D2', '42', '名称', 'küche');
const PUNCT = fc.constantFrom(')', '(', '—', ':', '.', '**', ']');
const minor = fc.integer({ min: 0, max: 9 });
const tokens = fc.array(fc.oneof(WORD, PUNCT), { minLength: 1, maxLength: 6 });
const prop = fc.property(minor, tokens, (m, toks) => {
const version = `v1.${m}`;
const fragment = toks.join(' ').trim();
const hasWordChar = /[\p{L}\p{N}]/u.test(fragment);
const out = roadmapParser.listMilestoneHeadings(`## ${version} ${fragment}\n`);
assert.strictEqual(out.length, 1, `heading not enumerated: ${version} ${fragment}`);
assert.strictEqual(out[0].version, version, `continuation grammar leaked into the version: ${JSON.stringify(out[0])}`);
// The biconditional IS the #4134 contract: a remainder with at least one
// letter/digit is a curated name; one with none is heading structure.
assert.strictEqual(
out[0].name !== null,
hasWordChar,
`fragment ${JSON.stringify(fragment)} (hasWordChar=${hasWordChar}) yielded name ${JSON.stringify(out[0].name)}`,
);
});
const result = fc.check(prop, { seed: 20260905, numRuns: 300 });
if (result.failed) {
assert.fail(`#4134 property violated (replay seed=20260905): ${JSON.stringify(result.counterexample)}`);
}
});
});
// ─── isMilestoneShippedInRoadmap ──────────────────────────────────────────────
// #2562: this module owns milestone-heading classification, so its own shipped