diff --git a/sdk/src/query/roadmap.test.ts b/sdk/src/query/roadmap.test.ts index f63a4d460..94ea62565 100644 --- a/sdk/src/query/roadmap.test.ts +++ b/sdk/src/query/roadmap.test.ts @@ -389,6 +389,80 @@ 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'); + }); + + // ─── 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.'); + }); + // ─── 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 +530,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 ─────────────────────────────────────────────────────── diff --git a/sdk/src/query/roadmap.ts b/sdk/src/query/roadmap.ts index ed0cb003e..eae508938 100644 --- a/sdk/src/query/roadmap.ts +++ b/sdk/src/query/roadmap.ts @@ -181,7 +181,40 @@ export async function extractCurrentMilestone(content: string, projectDir: strin ); 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. + // + // ]*> 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). + const detailsPattern = new RegExp( + `]*>\\s*[^<]*${escapedVersion}[^<]*([\\s\\S]*?)
`, + 'i' + ); + const detailsMatch = content.match(detailsPattern); + if (detailsMatch) return detailsMatch[1]; + return stripShippedMilestones(content); + } const sectionStart = sectionMatch.index;