diff --git a/.changeset/eager-bears-wander.md b/.changeset/eager-bears-wander.md new file mode 100644 index 000000000..32094114e --- /dev/null +++ b/.changeset/eager-bears-wander.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 2215 +--- +**`milestone_name` is no longer clobbered with a delimiter-led fragment** — getMilestoneInfo's `##` heading regex was unanchored, so it matched a heading quoted inside backticks in the Milestones bullet and wrote garbage like `— Active Milestone` over the curated milestone name on every phase transition. Now consults the 🚧 marker first, anchors the regex to line start, strips the leading delimiter, and widens the preserve guard so a bad derive keeps the existing name. (#2135) 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 ) { diff --git a/tests/roadmap-parser.test.cjs b/tests/roadmap-parser.test.cjs index ef6789899..ec46fa630 100644 --- a/tests/roadmap-parser.test.cjs +++ b/tests/roadmap-parser.test.cjs @@ -396,6 +396,88 @@ describe('roadmap-parser: getMilestoneInfo', () => { }); }); +// ─── getMilestoneInfo — #2135 milestone_name clobber ────────────────────────── +// The `##` 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`. The fix: consult the 🚧 name-bearing marker +// FIRST, anchor the `##` regex to line start, and strip a leading delimiter. + +describe('roadmap-parser: getMilestoneInfo #2135 — milestone_name clobber', () => { + let tmpDir; + + beforeEach(() => { tmpDir = createTempProject(); }); + afterEach(() => { cleanup(tmpDir); }); + + test('case A: 🚧 bullet quoting a nameless ## heading in backticks', () => { + writeState(tmpDir, { gsd_state_version: '1.0', milestone: 'v1.8' }); + writeRoadmap(tmpDir, [ + '# Roadmap', + '', + '## Milestones', + '', + '- 🚧 **v1.8 user session cleanup** — Phases 36-41 — see `## v1.8 — Active Milestone` below', + '', + '## v1.8 — Active Milestone', + '', + '### Phase 36: Something', + ].join('\n')); + const info = getMilestoneInfo(tmpDir); + assert.strictEqual(info.version, 'v1.8'); + assert.strictEqual(info.name, 'user session cleanup'); + }); + + test('case B: nameless ## heading + 🚧 marker carries the real name', () => { + writeState(tmpDir, { gsd_state_version: '1.0', milestone: 'v1.9' }); + writeRoadmap(tmpDir, [ + '## v1.9 — Active Milestone', + '', + '### 🚧 v1.9 — Falsifiability', + '', + '### Phase 1: Hypothesis', + ].join('\n')); + const info = getMilestoneInfo(tmpDir); + assert.strictEqual(info.version, 'v1.9'); + assert.strictEqual(info.name, 'Falsifiability'); + }); + + test('case C: canonical ## vX.Y: Name (no regression)', () => { + writeState(tmpDir, { gsd_state_version: '1.0', milestone: 'v2.0' }); + writeRoadmap(tmpDir, '## v2.0: The Big Launch\n### Phase 1: Setup\n'); + const info = getMilestoneInfo(tmpDir); + assert.strictEqual(info.version, 'v2.0'); + assert.strictEqual(info.name, 'The Big Launch'); + }); + + test('case D: canonical ## vX.Y — Name (em-dash delimiter stripped)', () => { + writeState(tmpDir, { gsd_state_version: '1.0', milestone: 'v2.5' }); + writeRoadmap(tmpDir, '## v2.5 — Galaxy Release\n### Phase 1: Start\n'); + const info = getMilestoneInfo(tmpDir); + assert.strictEqual(info.version, 'v2.5'); + assert.strictEqual(info.name, 'Galaxy Release'); + }); + + test('case E: 🚧 bullet only, no ## heading (no regression)', () => { + writeState(tmpDir, { gsd_state_version: '1.0', milestone: 'v1.5' }); + writeRoadmap(tmpDir, 'Some intro text.\n\n- 🚧 **v1.5 Quick Fix** — minor\n'); + const info = getMilestoneInfo(tmpDir); + assert.strictEqual(info.version, 'v1.5'); + assert.strictEqual(info.name, 'Quick Fix'); + }); + + test('anchored regex never matches a ## heading quoted inside backticks mid-line', () => { + writeState(tmpDir, { gsd_state_version: '1.0', milestone: 'v3.0' }); + writeRoadmap(tmpDir, [ + '# Roadmap', + '', + 'See `## v3.0 — Active Milestone` referenced here.', + '', + '## v3.0: Real Name', + ].join('\n')); + const info = getMilestoneInfo(tmpDir); + assert.strictEqual(info.name, 'Real Name'); + }); +}); + // ─── getMilestonePhaseFilter ────────────────────────────────────────────────── describe('roadmap-parser: getMilestonePhaseFilter', () => {