diff --git a/.changeset/agile-birds-cheer.md b/.changeset/agile-birds-cheer.md new file mode 100644 index 000000000..fd1ce4a6e --- /dev/null +++ b/.changeset/agile-birds-cheer.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 3046 +--- +extractCurrentMilestone no longer silently falls through to archived milestones when the active milestone uses a
vX.Y… structure. Phase lookups now correctly resolve to the active milestone's phases in FAMP-style ROADMAPs. Closes #2641. diff --git a/sdk/src/query/roadmap.test.ts b/sdk/src/query/roadmap.test.ts index f63a4d460..04e80e8bf 100644 --- a/sdk/src/query/roadmap.test.ts +++ b/sdk/src/query/roadmap.test.ts @@ -99,6 +99,20 @@ describe('stripShippedMilestones', () => { expect(stripShippedMilestones(content)).toBe('middleend'); }); + // Bug #2641 (symmetry): tolerate attributes on
tag, matching + // extractCurrentMilestone's attribute-tolerant fallback. Without this, + // shipped content wrapped in `
` (a common GitHub pattern for + // sections that should default to expanded) would leak through the strip. + it('removes
blocks (attribute-bearing tags)', () => { + const content = 'before\n
\nshipped content\n
\nafter'; + expect(stripShippedMilestones(content)).toBe('before\n\nafter'); + }); + + it('removes
blocks (attribute-bearing tags)', () => { + const content = 'a
x
b'; + expect(stripShippedMilestones(content)).toBe('ab'); + }); + it('returns content unchanged when no details blocks', () => { expect(stripShippedMilestones('no details here')).toBe('no details here'); }); @@ -389,6 +403,391 @@ describe('extractCurrentMilestone', () => { expect(result).toContain('### Phase 19: Security Audit'); }); + // ─── Bug #2641:
vX.Y … not recognized as anchor ─── + it('bug-2641: finds active milestone wrapped in
vX.Y …', async () => { + // Many projects (GitHub-friendly collapse) wrap the active milestone's + // phase details inside
v0.9 …. Without the + //
-aware fallback, extractCurrentMilestone misses the heading + // anchor (because is HTML), falls through to + // stripShippedMilestones, and loses all
blocks — including + // the active one. Result: roadmapGetPhase returns {found:false} for + // phases that ARE in the active ROADMAP. + const roadmapWithActiveDetails = `# Roadmap + +## Milestones +- ✅ **v0.8 Foundation** — shipped +- 📋 **v0.9 Local-First Bus** — active + +## Phases + +
+✅ v0.8 Foundation — SHIPPED 2026-04-15 + +### Phase 1: Old phase +**Goal:** Old goal. +
+ +
+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'), roadmapWithActiveDetails); + + const result = await extractCurrentMilestone(roadmapWithActiveDetails, tmpDir); + + // Active milestone's phases must survive + expect(result).toContain('### Phase 1: Library'); + expect(result).toContain('### Phase 3: Polish'); + 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 ─── + it('bug-2641: handles quoted YAML version (milestone: "v0.9") in STATE.md', async () => { + // STATE.md may use quoted YAML (`milestone: "v0.9"`). Without quote-stripping, + // version would carry literal quotes, escapedVersion would be `\"v0\.9\"`, + // and neither the markdown-heading regex nor the
fallback + // would match — falling through to stripShippedMilestones and reintroducing + // the archived-milestone misrouting this PR addresses. Parity with + // parseMilestoneFromState() and getMilestoneInfo() (which both strip quotes). + const roadmap = `# Roadmap + +
+v0.9 Local-First Bus (active) — Phase Details + +### Phase 3: Polish +**Goal:** Add polish. +
+`; + const stateQuoted = `---\nmilestone: "v0.9"\n---\n# State\n`; + await writeFile(join(tmpDir, '.planning', 'STATE.md'), stateQuoted); + await writeFile(join(tmpDir, '.planning', 'ROADMAP.md'), roadmap); + + const result = await extractCurrentMilestone(roadmap, tmpDir); + + expect(result).toContain('### Phase 3: Polish'); + expect(result).toMatch(/^##\s+v0\.9 Local-First Bus/m); + }); + + // ─── Bug #2641: tolerate attributes on
tag (e.g.
) ─── + it('bug-2641: finds active milestone in
vX.Y …', async () => { + // GitHub auto-renders
for sections that should default to + // expanded. The
-aware fallback regex must use ]*> + // (not literal
) so attribute-bearing tags also anchor correctly. + const roadmapWithDetailsOpen = `# Roadmap + +## Phases + +
+v0.9 Local-First Bus (active) — Phase Details + +### 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'), roadmapWithDetailsOpen); + + const result = await extractCurrentMilestone(roadmapWithDetailsOpen, tmpDir); + + expect(result).toContain('### Phase 3: Polish'); + expect(result).toContain('Add polish.'); + expect(result).toMatch(/^##\s+v0\.9 Local-First Bus/m); + }); + + it('bug-2641: v0.1 must not substring-match v0.10 in markdown heading anchor path', async () => { + const roadmap = `# Roadmap + +## v0.10 Future Milestone + +### Phase 7: Wrong Phase +**Goal:** This is from v0.10, not v0.1. + +## v0.1 Active Milestone + +### Phase 1: Right Phase +**Goal:** This is the active milestone. +`; + const state = `---\nmilestone: v0.1\n---\n# State\n`; + await writeFile(join(tmpDir, '.planning', 'STATE.md'), state); + await writeFile(join(tmpDir, '.planning', 'ROADMAP.md'), roadmap); + + const result = await extractCurrentMilestone(roadmap, tmpDir); + + expect(result).toContain('### Phase 1: Right Phase'); + expect(result).toContain('This is the active milestone'); + expect(result).not.toContain('Phase 7: Wrong Phase'); + expect(result).not.toContain('This is from v0.10'); + }); + + // ─── Bug #2641 (review hardening): substring-version trap ─── + it('bug-2641: v0.1 must not substring-match v0.10 …', async () => { + // The fallback regex anchors on `escapedVersion` inside `` text. + // Without a non-version-character lookahead, `v0.1` matches inside `v0.10`, + // and the function returns the v0.10 block's body as the active milestone + // — confidently-wrong content (worse than the pre-fix fall-through, which + // returned known-incomplete content). The synthesized `## v0.10 …` heading + // would then mask the bug from downstream debugging. Lock the boundary. + const roadmap = `# Roadmap + +
+v0.10 Future Milestone — Phase Details + +### Phase 7: Wrong Phase +**Goal:** This is from v0.10, not v0.1. +
+ +
+v0.1 Active — Phase Details + +### Phase 1: Right Phase +**Goal:** This is the active milestone. +
+`; + const state = `---\nmilestone: v0.1\n---\n# State\n`; + await writeFile(join(tmpDir, '.planning', 'STATE.md'), state); + await writeFile(join(tmpDir, '.planning', 'ROADMAP.md'), roadmap); + + const result = await extractCurrentMilestone(roadmap, tmpDir); + + expect(result).toContain('### Phase 1: Right Phase'); + expect(result).toContain('This is the active milestone'); + expect(result).not.toContain('Phase 7: Wrong Phase'); + expect(result).not.toContain('This is from v0.10'); + }); + + // ─── Bug #2641 (review hardening): nested
guard ─── + it('bug-2641: nested
falls through (does not silently truncate)', async () => { + // The lazy [\s\S]*?
terminates on the FIRST
, which + // is the inner closer when nesting is present. Without a guard, the + // function returns truncated body and silently loses everything after the + // inner
. Detect nesting and fall through to the existing + // stripShippedMilestones path so the failure mode is loud (no match) not + // silent (truncated content). + const roadmap = `# Roadmap + +
+v0.9 Local-First Bus — Phase Details + +### Phase 1: Library +
+Implementation notes +Detail +
+ +### Phase 2: Polish — would be silently lost without the guard +**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 extractCurrentMilestone(roadmap, tmpDir); + + // The critical contract: must NOT return a synthesized `## v0.9` heading + // anchored to truncated body. The truncation case (without the nested- + // guard) would emit `## v0.9 Local-First Bus\n\n### Phase 1: Library\n + //
Implementation notes\nDetail` and silently + // lose Phase 2 — confidently-wrong content. Falling through to + // stripShippedMilestones() may leak unrelated content but doesn't claim + // to be the active milestone. Loud failure > silent truncation. + expect(result).not.toMatch(/^##\s+v0\.9 Local-First Bus/m); + // The Phase 1 detail block (which sits between the outer
open + // and the inner
) must not appear under a v0.9 heading. + expect(result).not.toMatch(/##\s+v0\.9[\s\S]*Phase 1: Library/); + }); + + // ─── Bug #2641 (review hardening): empty
body guard ─── + it('bug-2641: empty
body falls through (no phantom milestone)', async () => { + //
v0.9
with no body would synthesize + // `## v0.9\n` — a phantom milestone with zero phases. roadmapAnalyze would + // then return {phases: []} with no error signal. Treat as no-match. + const roadmap = `# Roadmap + +
+v0.9 Empty +
+`; + 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 extractCurrentMilestone(roadmap, tmpDir); + + // Must not synthesize a phantom heading + expect(result).not.toMatch(/^##\s+v0\.9/m); + }); + + // ─── Bug #2641 (lockdown): leading `#` in stripped from synthesized heading ─── + it('bug-2641: strips leading # from text in synthesized heading', async () => { + // Prevents a `# v0.9 …` from producing `## # v0.9 …`, + // which downstream `#{2,4}` heading regexes would parse as a 4-hash + // header. The implementation uses `.replace(/^#+\s*/, '')` on the captured + // summary; this test pins that path so a future refactor doesn't drop it. + const roadmap = `# Roadmap + +
+# v0.9 Hash-Prefixed + +### Phase 1: Test +**Goal:** Works. +
+`; + 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 extractCurrentMilestone(roadmap, tmpDir); + + // Synthesized heading must be `## v0.9 …`, not `## # v0.9 …` + expect(result).toMatch(/^##\s+v0\.9 Hash-Prefixed/m); + expect(result).not.toMatch(/^##\s+#+/m); + }); + + // ─── Bug #2641 (review hardening): inline HTML in + leading # ─── + it('bug-2641: tolerates inline HTML in and strips it from synthesized heading', async () => { + // GitHub-rendered summaries commonly contain inline tags like + // (active) or v0.9. The summary capture must allow + // them through and the synthesized `## ` heading must strip the tags so + // the result is clean markdown (no `## ...`). + const roadmap = `# Roadmap + +
+v0.9 Local-First Bus (active) + +### 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 extractCurrentMilestone(roadmap, tmpDir); + + expect(result).toContain('### Phase 3: Polish'); + expect(result).toMatch(/^##\s+v0\.9 Local-First Bus\s+\(active\)/m); + // Tags must be stripped from the synthesized heading + expect(result).not.toMatch(/^##.*/m); + expect(result).not.toMatch(/^##.*/m); + }); + + // ─── Bug #2641 (lockdown): single-quote YAML version ─── + it('bug-2641: handles single-quote YAML version (milestone: \'v0.9\') in STATE.md', async () => { + // Parity coverage with the double-quote test. The strip pattern + // `/^["']|["']$/g` handles both — locked here so a future change to + // either character class doesn't silently regress one form. + const roadmap = `# Roadmap + +
+v0.9 Local-First Bus — Phase Details + +### Phase 3: Polish +**Goal:** Add polish. +
+`; + const stateSingle = `---\nmilestone: 'v0.9'\n---\n# State\n`; + await writeFile(join(tmpDir, '.planning', 'STATE.md'), stateSingle); + await writeFile(join(tmpDir, '.planning', 'ROADMAP.md'), roadmap); + + const result = await extractCurrentMilestone(roadmap, tmpDir); + + expect(result).toContain('### Phase 3: Polish'); + expect(result).toMatch(/^##\s+v0\.9 Local-First Bus/m); + }); + + // ─── Bug #2641 (lockdown): heading wins when BOTH heading and
match ─── + it('bug-2641: markdown heading anchor wins over
fallback', async () => { + // The
fallback only fires when the heading-level lookup MISSES. + // If a ROADMAP has both `### v0.9 …` heading AND `
v0.9 …` + // for the same version, the heading anchor must win. Locks precedence so a + // future refactor doesn't accidentally flip the order and silently change + // which slice gets returned. + const roadmap = `# Roadmap + +### v0.9 Local-First Bus (heading-anchored) + +### Phase 1: Heading-anchored Phase +**Goal:** From the heading slice. + +
+v0.9 Local-First Bus — Phase Details (details-anchored) + +### Phase 99: Details-anchored Phase +**Goal:** From the details slice. +
+`; + 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 extractCurrentMilestone(roadmap, tmpDir); + + // Heading slice is what got returned — original `### v0.9` heading + // present, Phase 1 from the heading slice present. + expect(result).toContain('### v0.9 Local-First Bus (heading-anchored)'); + expect(result).toContain('### Phase 1: Heading-anchored Phase'); + // Critical: the
fallback did NOT fire, so no synthesized + // `## ` heading is prepended. (The heading-anchor slice extends to the + // next milestone boundary and includes the downstream
block + // verbatim — that's a property of the heading-anchor path, not the + // fallback. We're locking which CODE PATH ran, not how its output looks.) + expect(result).not.toMatch(/^##\s+v0\.9 Local-First Bus.*details-anchored/im); + // The original heading must appear at the START of the slice (the + // heading-anchor path returns content starting at the matched heading). + expect(result.indexOf('### v0.9 Local-First Bus (heading-anchored)')).toBe(0); + }); + + // ─── Bug #2641 (lockdown): multiple
blocks for same version ─── + it('bug-2641: when multiple
match the version, the FIRST is returned', async () => { + // `content.match(detailsPattern)` (non-`g`) returns the first match in + // document order. Lock this so a future change to the matcher (e.g. + // switching to `matchAll` and picking the last) doesn't silently change + // which block is treated as the active milestone. Document-order-first is + // intentional: in real ROADMAPs, the active milestone is conventionally + // listed before any duplicates (e.g. retro-active or branch-merge artefacts). + const roadmap = `# Roadmap + +
+v0.9 Local-First Bus — Phase Details (FIRST) + +### Phase 1: First-block Phase +**Goal:** Should be returned. +
+ +
+v0.9 Local-First Bus — Phase Details (SECOND) + +### Phase 99: Second-block Phase +**Goal:** Should NOT be returned. +
+`; + 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 extractCurrentMilestone(roadmap, tmpDir); + + expect(result).toContain('### Phase 1: First-block Phase'); + expect(result).not.toContain('### Phase 99: Second-block Phase'); + expect(result).toMatch(/^##\s+v0\.9 Local-First Bus.*FIRST/m); + }); + // ─── Bug #2422: same-version sub-heading truncation ─────────────────── it('bug-2422: does not truncate at same-version sub-heading (## v2.0 Phase Details)', async () => { const roadmapWithDetails = `# ROADMAP @@ -456,6 +855,41 @@ describe('roadmapGetPhase', () => { expect(data.found).toBe(false); expect(data.error).toBe('ROADMAP.md not found'); }); + + // ─── Bug #2641 (regression): end-to-end via roadmapGetPhase ─── + it('bug-2641: returns found:true for phase inside
-wrapped active milestone', async () => { + // End-to-end coverage: roadmapGetPhase calls extractCurrentMilestone + // internally. Without the
-aware fallback, the active + // milestone's phases were stripped before the phase-heading lookup, + // and roadmapGetPhase returned {found:false} for phases that exist. + const roadmap = `# Roadmap + +## Milestones +- 📋 **v0.9 Local-First Bus** — active + +
+v0.9 Local-First Bus (active) — Phase Details + +### Phase 3: Polish + +**Goal:** Add polish. + +**Success Criteria**: +1. Polish applied +2. Tests pass +
+`; + 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 roadmapGetPhase(['3'], tmpDir); + const data = result.data as Record; + expect(data.found).toBe(true); + expect(data.phase_number).toBe('3'); + expect(data.phase_name).toBe('Polish'); + expect(data.goal).toBe('Add polish.'); + }); }); // ─── roadmapAnalyze ─────────────────────────────────────────────────────── @@ -538,6 +972,51 @@ 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; + // Defensive guard: fail with a clear message if roadmapAnalyze didn't + // populate data.milestones, rather than throwing TypeError on `.some()`. + expect(data.milestones).toBeDefined(); + 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 ed0cb003e..687d7b2f0 100644 --- a/sdk/src/query/roadmap.ts +++ b/sdk/src/query/roadmap.ts @@ -50,8 +50,13 @@ interface PhaseSection { * Port of stripShippedMilestones from core.cjs line 1082-1084. */ export function stripShippedMilestones(content: string): string { - // Pattern 1:
...
blocks (explicit collapse) - let result = content.replace(/
[\s\S]*?<\/details>/gi, ''); + // Pattern 1:
...
blocks (explicit collapse). + // ]*> tolerates attributes (e.g.
,
). + // Symmetry with extractCurrentMilestone()'s
-aware fallback (#2641): + // both functions must agree on what counts as a
opening tag, or + // shipped content wrapped in attributed tags would leak through here while + // the active-milestone anchor in extractCurrentMilestone() correctly fires. + let result = content.replace(/]*>[\s\S]*?<\/details>/gi, ''); // Pattern 2: inline milestone headings marked as shipped. // Keep aligned with heading levels accepted by extractCurrentMilestone() (## and ###). const sections = result.split(/(?=^#{2,3}\s)/m); @@ -146,20 +151,40 @@ export async function getMilestoneInfo(projectDir: string, workstream?: string): /** * Extract the current milestone section from ROADMAP.md. * - * Port of extractCurrentMilestone from core.cjs lines 1102-1170. + * Two anchoring strategies, tried in order: + * 1. Markdown heading containing the active version (`^#{1,3}\s+.*vX.Y…`). + * 2. `
vX.Y……
` block (the GitHub-friendly + * collapse pattern; see #2641). When this fallback fires, the captured + * `` text is synthesized as a `##` heading prepended to the + * returned slice so downstream consumers that scan for milestone headings + * (e.g. the `data.milestones` loop in `roadmapAnalyze`) still see an + * active-milestone anchor. + * + * If neither strategy matches the active version, falls through to + * `stripShippedMilestones(content)`. + * + * Originally ported from core.cjs lines 1102-1170; the TS implementation has + * since diverged (Backlog-leak fix #2422, phase-vX.Y truncation fix #2619, + * fenced-code-block tracking #2787, `
` fallback #2641). * * @param content - Full ROADMAP.md content * @param projectDir - Working directory for reading STATE.md * @returns Content scoped to current milestone */ export async function extractCurrentMilestone(content: string, projectDir: string, workstream?: string): Promise { - // Get version from STATE.md frontmatter + // Get version from STATE.md frontmatter. + // Strip optional surrounding YAML quotes (e.g. `milestone: "v0.9"`) for parity + // with parseMilestoneFromState() above and getMilestoneInfo()'s STATE.md path. + // Without this, a quoted version yields `escapedVersion = '\\"v0\\.9\\"'` + // which matches neither markdown headings nor text, falling + // through to stripShippedMilestones() — and reintroducing the same archived- + // milestone misrouting this fallback addresses. let version: string | null = null; try { const stateRaw = await readFile(planningPaths(projectDir, workstream).state, 'utf-8'); const milestoneMatch = stateRaw.match(/^milestone:\s*(.+)/m); if (milestoneMatch) { - version = milestoneMatch[1].trim(); + version = milestoneMatch[1].trim().replace(/^["']|["']$/g, ''); } } catch { /* intentionally empty */ } @@ -176,12 +201,77 @@ export async function extractCurrentMilestone(content: string, projectDir: strin // Find section matching this version const escapedVersion = escapeRegex(version); const sectionPattern = new RegExp( - `(^#{1,3}\\s+.*${escapedVersion}[^\\n]*)`, + `(^#{1,3}\\s+.*${escapedVersion}(?![\\d.])[^\\n]*)`, 'mi' ); const sectionMatch = content.match(sectionPattern); - if (!sectionMatch || sectionMatch.index === undefined) return stripShippedMilestones(content); + if (!sectionMatch || sectionMatch.index === undefined) { + // Fallback:
matching the active version (issue #2641). + // + // Many projects (GitHub-friendly collapse pattern) wrap the active + // milestone's phase details inside a collapsible block whose + // names the version, e.g.: + // + //
+ // v0.9 Local-First Bus (active) — Phase Details + // ### Phase 1: ... + //
+ // + // The markdown-heading lookup above misses this because is HTML, + // not a heading. Without this fallback, control falls through to + // stripShippedMilestones() which removes ALL
blocks + // indiscriminately — including the active milestone's — causing + // roadmapGetPhase() to return {found:false} for phases that ARE in the + // active ROADMAP. The init.phase-op safety guard then misfires and can + // route phase lookups into archived milestones. + // + // Regex anatomy: + // ]*> tolerate attributes (e.g.
) + // \s*]*> tolerate attributes on + // ((?:(?!).)*? non-greedy summary capture; tolerates + // ${escapedVersion} inline HTML in the summary text + // (?![\d.]) non-version-character lookahead — prevents + // `v0.1` from substring-matching `v0.10` + // (?:(?!
).)*) + //
end of summary + // ([\s\S]*?)
lazy body capture to the FIRST
+ // + // Contract: any consumer that scans the returned slice for milestone + // headings (e.g. /##\s*.*vX.Y/) sees the active milestone's anchor. We + // synthesize that heading from the captured text rather than + // returning the body alone. + // + // Hardening guards: + // - Nested
: the lazy quantifier truncates at the inner + //
, silently losing trailing phases. Detect and fall through + // to stripShippedMilestones() instead of returning truncated content. + // - Empty body: a
block with no body would synthesize a heading + // with nothing under it. Treat as no-match. + // - Summary sanitization: strip inline HTML (e.g. active) and + // leading `#` tokens before promoting to a `##` heading, so the result + // is a single well-formed markdown heading. + const detailsPattern = new RegExp( + `]*>\\s*]*>` + + `((?:(?!
).)*?${escapedVersion}(?![\\d.])(?:(?!
).)*)` + + `
([\\s\\S]*?)
`, + 'i' + ); + const detailsMatch = content.match(detailsPattern); + if ( + detailsMatch && + detailsMatch[2].trim() && // empty-body guard + !detailsMatch[2].includes(' guard + ) { + const summary = detailsMatch[1] + .replace(/<[^>]+>/g, '') // strip inline HTML + .replace(/^#+\s*/, '') // strip leading `#` + .trim(); + const body = detailsMatch[2]; + return `## ${summary}\n${body}`; + } + return stripShippedMilestones(content); + } const sectionStart = sectionMatch.index;