* 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:
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user