fix(#2641): inject normalized ## heading from <details><summary> capture

Address CodeRabbit review on PR #3046: the prior commit returned only the
body inside <details>...</details>, 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 <summary> 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 <details>-wrapped ROADMAPs.

Also widened the inner tag to `<summary\b[^>]*>` for symmetry with the
outer `<details\b[^>]*>` — 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 `<details><summary>` 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
  <details>-wrapped ROADMAPs.
- All 40 roadmap.test.ts tests pass.
This commit is contained in:
Ben Lamm
2026-05-02 23:17:36 -04:00
committed by Tom Boucher
parent ba6a3efc3e
commit c8239f67f8
2 changed files with 65 additions and 7 deletions

View File

@@ -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 <summary> 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 <details>-wrapped active milestones via the synthesized `## ` heading. ───
it('bug-2641: data.milestones contains the active milestone when wrapped in <details>', async () => {
// Without the synthesized heading injected by extractCurrentMilestone's
// <details>-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 <details>-wrapped active milestone
// and `data.milestones` would be empty / wrong.
const roadmap = `# Roadmap
## Milestones
- 📋 **v0.9 Local-First Bus** — active
<details>
<summary>v0.9 Local-First Bus (active) — Phase Details</summary>
### Phase 1: Library
**Goal:** Build the library.
### Phase 3: Polish
**Goal:** Add polish.
</details>
`;
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<string, unknown>;
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<Record<string, unknown>>;
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) ──────

View File

@@ -208,17 +208,28 @@ export async function extractCurrentMilestone(content: string, projectDir: strin
// route phase lookups into archived milestones.
//
// <details\b[^>]*> tolerates attributes like <details open> and
// <details class="...">. The lazy [\s\S]*? terminates on the first
// </details>; nested <details> 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).
// <details class="...">. <summary\b[^>]*> tolerates the same on the
// <summary> tag. The lazy [\s\S]*? terminates on the first </details>;
// nested <details> 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 <summary> 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(
`<details\\b[^>]*>\\s*<summary>[^<]*${escapedVersion}[^<]*</summary>([\\s\\S]*?)</details>`,
`<details\\b[^>]*>\\s*<summary\\b[^>]*>([^<]*${escapedVersion}[^<]*)</summary>([\\s\\S]*?)</details>`,
'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);
}