From 3bc53c8116c5c134dd5c42611f3ee0f500ff2e97 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Sun, 12 Jul 2026 11:39:59 -0400 Subject: [PATCH] fix(#2135): anchor milestone heading regex + strip delimiter, widen preserve guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getMilestoneInfo's `##` heading regex was unanchored (no `^`/`m`), so it matched a `##` quoted mid-line inside a Milestones bullet and captured a delimiter-led fragment into milestone_name — clobbering the curated name on every phase transition. roadmap-parser.cts (load-bearing): - Consult the 🚧 name-bearing marker FIRST (reorder; it already existed but was shadowed by a spuriously-successful heading match). - Anchor the `##` regex to line start (`^` + `m` flag) so a heading quoted in backticks/prose can no longer match. - stripLeadingDelimiter removes a leading em/en-dash/colon/hyphen run that .trim() cannot (the `## vX.Y — Name` convention). state.cts (defense in depth): - Widen the #948 preserve guard from 'derived equals placeholder' to 'derived does not look like a name' (non-empty, not placeholder, not punctuation-led), so a future bad derive preserves the curated name instead of silently overwriting it. Closes #2135 --- src/roadmap-parser.cts | 53 +++++++++++++++++++++++++++++++----------- src/state.cts | 12 +++++++++- 2 files changed, 50 insertions(+), 15 deletions(-) diff --git a/src/roadmap-parser.cts b/src/roadmap-parser.cts index b0236d065..33845d957 100644 --- a/src/roadmap-parser.cts +++ b/src/roadmap-parser.cts @@ -275,6 +275,19 @@ interface MilestoneInfo { name: string; } +/** + * Strip a leading delimiter run (whitespace, em/en-dash, colon, hyphen) from a + * milestone-name capture. Markdown headings commonly take the shape + * `## vX.Y — Name` or `## vX.Y: Name`; the raw capture includes the delimiter + * because `.trim()` only removes whitespace, not punctuation. A name beginning + * with punctuation is a delimiter-led fragment, not the curated name (#2135). + * NOTE: do not strip `#` — a name beginning with `#` is a heading-parse failure + * that should stay loud rather than be silently cleaned. + */ +function stripLeadingDelimiter(s: string): string { + return s.replace(/^[\s—–:-]+/, '').trim(); +} + function getMilestoneInfo(cwd: string): MilestoneInfo { try { const roadmap = platformReadSync(path.join(planningDir(cwd), 'ROADMAP.md')); @@ -294,22 +307,34 @@ function getMilestoneInfo(cwd: string): MilestoneInfo { if (stateVersion) { const escapedVer = escapeRegex(stateVersion); - const headingMatch = roadmap.match( - new RegExp(`##[^\\n]*${escapedVer}[:\\s]+([^\\n(]+)`, 'i') + + // #2135: consult the 🚧 name-bearing marker FIRST. It is the only construct + // guaranteed to carry the milestone's curated name adjacent to its version + // (the active-milestone bullet). A `##` heading is often nameless + // ("## vX.Y — Active Milestone") and, when unanchored, was matched + // spuriously on a copy quoted inside backticks in this very bullet. + const listMatch = roadmap.match( + new RegExp(`🚧\\s*\\*?\\*?${escapedVer}\\s+([^*\\n]+)`, 'i') ); - if (headingMatch) { - if (!headingMatch[0].includes('āœ…')) { - return { version: stateVersion, name: headingMatch[1].trim() }; - } - } else { - const listMatch = roadmap.match( - new RegExp(`🚧\\s*\\*?\\*?${escapedVer}\\s+([^*\\n]+)`, 'i') - ); - if (listMatch) { - return { version: stateVersion, name: listMatch[1].trim() }; - } - return { version: stateVersion, name: 'milestone' }; + if (listMatch) { + const name = stripLeadingDelimiter(listMatch[1]); + if (name) return { version: stateVersion, name }; } + + // Fall back to the `##` heading — ANCHORED to line start (`^` + `m` flag) + // so a heading quoted inside backticks or prose mid-line can no longer + // match. Skip shipped (āœ…) headings. + const headingMatch = roadmap.match( + new RegExp(`^##[^\\n]*${escapedVer}[:\\s]+([^\\n(]+)`, 'im') + ); + if (headingMatch && !headingMatch[0].includes('āœ…')) { + // Strip a leading delimiter — `.trim()` removes whitespace, not the + // em-dash/colon that conventionally separates version from name. + const name = stripLeadingDelimiter(headingMatch[1]); + if (name) return { version: stateVersion, name }; + } + + return { version: stateVersion, name: 'milestone' }; } const inProgressMatch = roadmap.match(/🚧\s*\*\*v(\d+(?:\.\d+)+)\s+([^*]+)\*\*/); diff --git a/src/state.cts b/src/state.cts index 06b41be4f..b28faeb40 100644 --- a/src/state.cts +++ b/src/state.cts @@ -1573,8 +1573,18 @@ function syncStateFrontmatter(content: string, cwd: string | undefined): string // existing frontmatter already holds; only an empty derived value falls through // to this guard (the primary #905 preserve path below handles that). const MILESTONE_NAME_PLACEHOLDER = 'milestone'; + // #2135: widen the preserve guard. A bad derive is not always the literal + // placeholder — getMilestoneInfo can return a delimiter-led fragment + // ("— Active Milestone") when the roadmap regex mis-binds. Preserve the + // existing curated name unless the derived value actually looks like a name: + // non-empty, not the placeholder, and not punctuation-led. + const derivedName = derivedFm['milestone_name']; + const derivedLooksLikeName = typeof derivedName === 'string' + && derivedName.length > 0 + && derivedName !== MILESTONE_NAME_PLACEHOLDER + && !/^[\s—–:-]/.test(derivedName); if ( - derivedFm['milestone_name'] === MILESTONE_NAME_PLACEHOLDER && + !derivedLooksLikeName && existingFm['milestone_name'] && existingFm['milestone_name'] !== MILESTONE_NAME_PLACEHOLDER ) {