From c8239f67f8d4f7253e835bbd6b3ad91cd2918af2 Mon Sep 17 00:00:00 2001 From: Ben Lamm Date: Sat, 2 May 2026 23:17:36 -0400 Subject: [PATCH] fix(#2641): inject normalized ## heading from
capture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address CodeRabbit review on PR #3046: the prior commit returned only the body inside
...
, which fixed the `roadmapGetPhase` miss but left `roadmapAnalyze`'s downstream `data.milestones` scan (`/##\s*(.*v(\d+(?:\.\d+)+)[^(\n]*)/gi` at the bottom of roadmap.ts) without an active-milestone anchor in the returned slice. Now capture the text and prepend it as a synthesized `##` heading on the returned slice. This makes both `data.phases` (the original bug) AND `data.milestones` (the downstream consumer) surface the active milestone correctly for
-wrapped ROADMAPs. Also widened the inner tag to `]*>` for symmetry with the outer `]*>` — both now tolerate attributes. Verified end-to-end against FAMP's v0.9 ROADMAP: - Before this commit (after PR #3046 base): milestones: [{heading: '# Phase 1: ... (v0.5.2 atomic bump)', version: 'v0.5.2'}] - After this commit: milestones: [{heading: 'v0.9 Local-First Bus', version: 'v0.9'}, {heading: '# Phase 1: ... (v0.5.2 ...)', version: 'v0.5.2'}] (The v0.5.2 entry is pre-existing noise from the loose `##\s*` regex matching the `### Phase 1: famp-bus (v0.5.2 atomic bump)` body heading; unrelated to this fix and out of scope for this PR.) Tests: - Updated the two `
` tests to assert the synthesized `## v0.9 Local-First Bus` heading is present on the returned slice. - Added a 4th regression test (`roadmapAnalyze`) confirming `data.milestones` now contains the active milestone for
-wrapped ROADMAPs. - All 40 roadmap.test.ts tests pass. --- sdk/src/query/roadmap.test.ts | 47 +++++++++++++++++++++++++++++++++++ sdk/src/query/roadmap.ts | 25 +++++++++++++------ 2 files changed, 65 insertions(+), 7 deletions(-) diff --git a/sdk/src/query/roadmap.test.ts b/sdk/src/query/roadmap.test.ts index 19e11fbbe..63bbc3a2f 100644 --- a/sdk/src/query/roadmap.test.ts +++ b/sdk/src/query/roadmap.test.ts @@ -435,6 +435,10 @@ describe('extractCurrentMilestone', () => { expect(result).toContain('Add polish.'); // Shipped milestone phases must not bleed in expect(result).not.toContain('Old phase'); + // The text is normalized as a `## ` milestone heading so + // downstream consumers (e.g. roadmapAnalyze's data.milestones scan) see + // the active milestone anchor — not just the body. + expect(result).toMatch(/^##\s+v0\.9 Local-First Bus \(active\) — Phase Details/m); }); // ─── Bug #2641 (CodeRabbit follow-up): quoted YAML version normalization ─── @@ -488,6 +492,7 @@ describe('extractCurrentMilestone', () => { expect(result).toContain('### Phase 3: Polish'); expect(result).toContain('Add polish.'); + expect(result).toMatch(/^##\s+v0\.9 Local-First Bus/m); }); // ─── Bug #2422: same-version sub-heading truncation ─────────────────── @@ -674,6 +679,48 @@ describe('roadmapAnalyze', () => { expect((data1.phases as unknown[]).length).toBe((data2.phases as unknown[]).length); }); + + // ─── Bug #2641 (regression): roadmapAnalyze populates milestones array + // for
-wrapped active milestones via the synthesized `## ` heading. ─── + it('bug-2641: data.milestones contains the active milestone when wrapped in
', async () => { + // Without the synthesized heading injected by extractCurrentMilestone's + //
-aware fallback, the milestone-heading scan at the bottom of + // roadmapAnalyze (`/##\s*(.*v(\d+(?:\.\d+)+)[^(\n]*)/gi`) would find + // nothing useful inside the body of a
-wrapped active milestone + // and `data.milestones` would be empty / wrong. + const roadmap = `# Roadmap + +## Milestones +- 📋 **v0.9 Local-First Bus** — active + +
+v0.9 Local-First Bus (active) — Phase Details + +### Phase 1: Library +**Goal:** Build the library. + +### Phase 3: Polish +**Goal:** Add polish. +
+`; + const state = `---\nmilestone: v0.9\n---\n# State\n`; + await writeFile(join(tmpDir, '.planning', 'STATE.md'), state); + await writeFile(join(tmpDir, '.planning', 'ROADMAP.md'), roadmap); + + const result = await roadmapAnalyze([], tmpDir); + const data = result.data as Record; + const milestones = data.milestones as Array<{ heading: string; version: string }>; + + // Active milestone surfaces with correct version + expect(milestones.some(m => m.version === 'v0.9')).toBe(true); + expect(milestones.some(m => m.heading.includes('Local-First Bus'))).toBe(true); + + // Phases are also surfaced (the original bug) + const phases = data.phases as Array>; + expect(phases.length).toBe(2); + expect(phases.some(p => p.number === '1')).toBe(true); + expect(phases.some(p => p.number === '3')).toBe(true); + }); }); // ─── extractPhasesFromSection + extractNextMilestoneSection (#2497) ────── diff --git a/sdk/src/query/roadmap.ts b/sdk/src/query/roadmap.ts index 73bcf3d1c..f4d3c0898 100644 --- a/sdk/src/query/roadmap.ts +++ b/sdk/src/query/roadmap.ts @@ -208,17 +208,28 @@ export async function extractCurrentMilestone(content: string, projectDir: strin // route phase lookups into archived milestones. // // ]*> tolerates attributes like
and - //
. The lazy [\s\S]*? terminates on the first - //
; nested
inside the active milestone are not - // expected and would mis-anchor (acceptable; FAMP-style ROADMAPs do not - // nest, and any project that does will fall through to the existing - // stripShippedMilestones path with no regression vs. today's behavior). + //
. ]*> tolerates the same on the + // tag. The lazy [\s\S]*? terminates on the first
; + // nested
inside the active milestone are not expected and would + // mis-anchor (acceptable; FAMP-style ROADMAPs do not nest, and any project + // that does will fall through to the existing stripShippedMilestones path + // with no regression vs. today's behavior). + // + // We capture the text and prepend it as a normalized `##` + // milestone heading on the returned slice. This keeps downstream consumers + // that scan for `##` milestone headings (e.g. roadmapAnalyze's + // data.milestones loop later in this file) producing a meaningful entry + // for the active milestone instead of seeing an unanchored body. const detailsPattern = new RegExp( - `]*>\\s*[^<]*${escapedVersion}[^<]*([\\s\\S]*?)
`, + `]*>\\s*]*>([^<]*${escapedVersion}[^<]*)
([\\s\\S]*?)
`, 'i' ); const detailsMatch = content.match(detailsPattern); - if (detailsMatch) return detailsMatch[1]; + if (detailsMatch) { + const summary = detailsMatch[1].trim(); + const body = detailsMatch[2]; + return `## ${summary}\n${body}`; + } return stripShippedMilestones(content); }