From 592b6764145ec18dce9a036ee137a15f9ec338e5 Mon Sep 17 00:00:00 2001 From: Ben Lamm Date: Sat, 2 May 2026 22:56:00 -0400 Subject: [PATCH 1/8] fix(#2641): recognize
as active-milestone anchor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `extractCurrentMilestone` only matched markdown headings (## v0.9, ### v0.9) to find the active milestone slice. Projects that wrap their active milestone's phase details inside `
vX.Y …` (a common GitHub-friendly collapse pattern, e.g. FAMP) fell through to `stripShippedMilestones`, which strips ALL `
` blocks indiscriminately. Net effect: `roadmapGetPhase` returned `{found:false}` for phases that ARE in the active ROADMAP. The `init.phase-op` safety guard at `init.ts:133` ('drop archived disk match when phase is in current ROADMAP') depends on `roadmapPhase.found`, so it didn't fire. `init.phase-op` then returned a `phase_dir` pointing at an ARCHIVED milestone's same-numbered phase — silently routing downstream workflows (e.g. /gsd-discuss-phase) into completed phases. Fix: when no markdown heading matches the active version, try matching `]*>...vX.Y...`. Returns the inner content of the matching block. Purely additive — `stripShippedMilestones` behavior and its tests are unchanged. The `\b[^>]*>` form tolerates attributes like `
` or `
` (GitHub commonly emits `
` for default-expanded sections). Lazy `[\s\S]*?` matches up to the first `
`; nested `
` inside the active milestone are not expected and would mis-anchor (acceptable; falls through to the existing `stripShippedMilestones` path with no regression vs. today's behavior). Closes #2641. Distinct from the closed #2642 which bundled three orthogonal changes (parser fix + checkbox-scan fix + STATE.md counting auth) into one PR; this PR addresses only the parser anchoring bug, leaving `stripShippedMilestones`, `roadmapAnalyze`, and `initMilestoneOp` untouched. Tests added (3, all in `roadmap.test.ts`): - `bug-2641: finds active milestone wrapped in
vX.Y …` - `bug-2641: finds active milestone in
vX.Y …` - `bug-2641: returns found:true for phase inside
-wrapped active milestone` (end-to-end via `roadmapGetPhase`) All existing `roadmap.test.ts` tests pass (39/39). Real-world repro verified against an FAMP-style ROADMAP: before the fix, `gsd-sdk query roadmap.get-phase 3` returned `{found:false}` despite the phase being at line 113 of the active ROADMAP; after the fix, it returns the correct phase metadata, and `init.phase-op 3` no longer returns the v0.8 archived `phase_dir`. --- sdk/src/query/roadmap.test.ts | 109 ++++++++++++++++++++++++++++++++++ sdk/src/query/roadmap.ts | 35 ++++++++++- 2 files changed, 143 insertions(+), 1 deletion(-) 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; From ba6a3efc3ee9d5243c596642f7fe489676aadf9e Mon Sep 17 00:00:00 2001 From: Ben Lamm Date: Sat, 2 May 2026 23:34:22 -0400 Subject: [PATCH 2/8] fix(#2641): strip YAML quotes from STATE.md milestone version MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address CodeRabbit review on PR #3046: extractCurrentMilestone read the `milestone:` value from STATE.md frontmatter via `.trim()` only, while parseMilestoneFromState() and getMilestoneInfo() both also strip surrounding YAML quotes via `.replace(/^["']|["']$/g, '')`. For projects whose STATE.md uses quoted YAML (`milestone: "v0.9"`), `version` carried literal quotes, `escapedVersion` became `\"v0\.9\"`, and neither the markdown-heading regex nor the new
fallback could match anything — falling through to stripShippedMilestones() and reintroducing the same archived-milestone misrouting this PR addresses. Strip quotes for parity. Three-line addition + one new test. All 41 roadmap.test.ts tests pass. --- sdk/src/query/roadmap.test.ts | 27 +++++++++++++++++++++++++++ sdk/src/query/roadmap.ts | 10 ++++++++-- 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/sdk/src/query/roadmap.test.ts b/sdk/src/query/roadmap.test.ts index 94ea62565..19e11fbbe 100644 --- a/sdk/src/query/roadmap.test.ts +++ b/sdk/src/query/roadmap.test.ts @@ -437,6 +437,33 @@ describe('extractCurrentMilestone', () => { expect(result).not.toContain('Old phase'); }); + // ─── 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 diff --git a/sdk/src/query/roadmap.ts b/sdk/src/query/roadmap.ts index eae508938..73bcf3d1c 100644 --- a/sdk/src/query/roadmap.ts +++ b/sdk/src/query/roadmap.ts @@ -153,13 +153,19 @@ export async function getMilestoneInfo(projectDir: string, workstream?: string): * @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 */ } From c8239f67f8d4f7253e835bbd6b3ad91cd2918af2 Mon Sep 17 00:00:00 2001 From: Ben Lamm Date: Sat, 2 May 2026 23:17:36 -0400 Subject: [PATCH 3/8] 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); } From 4b66ca580061cde2df38039d829e12498a7c028e Mon Sep 17 00:00:00 2001 From: Ben Lamm Date: Sun, 3 May 2026 11:50:55 -0400 Subject: [PATCH 4/8] fix(#2641): harden
fallback per trek-e review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address trek-e's adversarial review on PR #3046. Two critical merge-blockers plus four hardening items, all now covered with tests. CRITICAL #1 — substring-version trap: `[^<]*${escapedVersion}[^<]*` did substring containment, so `milestone: v0.1` matched v0.10 … and returned the v0.10 block's body as the active milestone — confidently-wrong content worse than the pre-PR fall-through. Add `(?![\d.])` non-version-character lookahead, mirroring the same boundary protection used by the existing `currentVersionStr` logic on the heading path. Test asserts v0.1 active with v0.10 sibling block returns v0.1's phases, not v0.10's. CRITICAL #2 — nested
silent truncation: The lazy `[\s\S]*?
` terminates on the FIRST
, which is the inner closer when nesting is present. Prior comment claimed "would mis-anchor (acceptable; falls through)" — factually wrong: the match succeeds with truncated body and is returned with a confident `## ${summary}` heading. Future maintainer investigating a "missing phase" report would be misled. Add `!detailsMatch[2].includes('v0.9
` would synthesize `## v0.9\n` (phantom milestone, zero phases, no error signal). Treat as no-match. - Inline-HTML in : rejected by `[^<]*` capture. Widen to `(?:(?!).)*?` (non-greedy until close tag) and strip tags + leading `#` from the captured summary before promoting to a `##` heading. Covers GitHub-rendered (active), v0.9, ... patterns. - JSDoc: rewrote to describe both anchoring strategies and the synthesized-heading contract; demoted stale "Port of core.cjs lines 1102-1170" to historical context with the divergence list. - Comment block: rewrote in contract style ("any consumer scanning /##\s*.*vX.Y/ sees the active milestone") instead of coupling to specific call sites (roadmapAnalyze, "later in this file"). Adds explicit regex anatomy + hardening-guards section so future readers can audit each guard. OUT OF SCOPE (per trek-e's "Recommended action" tier): - Debug logging on fall-through paths (Suggestion #10) — adds tracing surface to a function that doesn't currently use logger; appropriate for a follow-up if/when other extraction bugs surface. - Uppercase
/ + extended attribute coverage (Test gap #7 last two rows) — already covered by the documented `i` flag and the existing
test; adding redundant cases inflates the test set without locking new contracts. Verification: 45/45 roadmap.test.ts tests pass (was 41/41; added 4 hardening tests). FAMP end-to-end smoke unchanged: roadmap.get-phase 3 returns "Claude Code integration polish", roadmap.analyze surfaces v0.9 Local-First Bus in data.milestones with phase_count: 4. --- sdk/src/query/roadmap.test.ts | 127 ++++++++++++++++++++++++++++++++++ sdk/src/query/roadmap.ts | 67 +++++++++++++----- 2 files changed, 178 insertions(+), 16 deletions(-) diff --git a/sdk/src/query/roadmap.test.ts b/sdk/src/query/roadmap.test.ts index 63bbc3a2f..7ff5e3bcf 100644 --- a/sdk/src/query/roadmap.test.ts +++ b/sdk/src/query/roadmap.test.ts @@ -495,6 +495,133 @@ describe('extractCurrentMilestone', () => { expect(result).toMatch(/^##\s+v0\.9 Local-First Bus/m); }); + // ─── 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 (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 #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 diff --git a/sdk/src/query/roadmap.ts b/sdk/src/query/roadmap.ts index f4d3c0898..cce4f0967 100644 --- a/sdk/src/query/roadmap.ts +++ b/sdk/src/query/roadmap.ts @@ -146,7 +146,21 @@ 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 @@ -207,26 +221,47 @@ export async function extractCurrentMilestone(content: string, projectDir: strin // active ROADMAP. The init.phase-op safety guard then misfires and can // route phase lookups into archived milestones. // - // ]*> tolerates attributes like
and - //
. ]*> 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). + // 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
// - // 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. + // 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}[^<]*)
([\\s\\S]*?)
`, + `]*>\\s*]*>` + + `((?:(?!
).)*?${escapedVersion}(?![\\d.])(?:(?!
).)*)` + + `
([\\s\\S]*?)
`, 'i' ); const detailsMatch = content.match(detailsPattern); - if (detailsMatch) { - const summary = detailsMatch[1].trim(); + 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}`; } From 19041b88241677ab3de3e9ba6bce8ffcfe04cb52 Mon Sep 17 00:00:00 2001 From: Ben Lamm Date: Sun, 3 May 2026 11:58:56 -0400 Subject: [PATCH 5/8] test(#2641): lockdown tests from self-adversarial pass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Self-run adversarial pass on PR #3046 before next reviewer round-trip. Three lockdown tests added — none uncovered new bugs, all lock current behavior so a future change doesn't silently flip a convention. 1. Single-quote YAML version (`milestone: 'v0.9'`) Parity with the existing 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. 2. Heading-anchor wins over
fallback (precedence lock) When a ROADMAP has BOTH `### v0.9` heading AND `
v0.9` block, the heading-level lookup matches first and the fallback never fires. Test asserts the heading slice is returned starting at offset 0 AND the synthesized `## v0.9 ... details-anchored` heading is NOT prepended (proves fallback didn't run). Also documented in-test that the heading-anchor slice naturally includes downstream
blocks verbatim — a property of the heading path, not of this PR's fallback. 3. Multiple
blocks for same version → first match wins `content.match(detailsPattern)` (non-`g`) returns first match in document order. Locked so a future change to the matcher (e.g. switching to `matchAll` and picking last) doesn't silently change which block is treated as active. Adversarial-checklist coverage on commit 781cc6f8: - Boundary cases: empty / whitespace / single-char / single-quote / double-quote / digit-suffix (`v0.91`) / dot-suffix (`v0.9.0`) / hyphen-suffix (`v0.9-rc.1`, intentional same-milestone match per existing currentVersionStr convention) — all covered. - Sibling consistency: parseMilestoneFromState, getMilestoneInfo, extractCurrentMilestone all strip quotes identically. - Comment-vs-behavior: walked nested-guard, empty-guard, lookahead, tag-strip by hand against the regex; all comments accurate. - Downstream consumers: roadmapAnalyze + roadmapGetPhase both verified end-to-end via tests + FAMP smoke. - Failure-mode locality: all fall-through paths produce loud failures (empty arrays, `{found:false}`); no silent confident-wrong outputs. 48/48 roadmap.test.ts tests pass. --- sdk/src/query/roadmap.test.ts | 101 ++++++++++++++++++++++++++++++++++ 1 file changed, 101 insertions(+) diff --git a/sdk/src/query/roadmap.test.ts b/sdk/src/query/roadmap.test.ts index 7ff5e3bcf..64208b2cc 100644 --- a/sdk/src/query/roadmap.test.ts +++ b/sdk/src/query/roadmap.test.ts @@ -622,6 +622,107 @@ Detail 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 From 13bf56477a84c36fb2b718ff6e564126f83b59fa Mon Sep 17 00:00:00 2001 From: Ben Lamm Date: Sun, 3 May 2026 12:19:38 -0400 Subject: [PATCH 6/8] fix(#2641): symmetric attribute tolerance in stripShippedMilestones + lockdown tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address CodeRabbit follow-up review on PR #3046. One real bug + two lockdown gaps + one defensive assertion. REAL BUG — sibling-asymmetry in
attribute tolerance: extractCurrentMilestone's
-aware fallback uses ]*> to tolerate attributes (#2641 hardening commit). stripShippedMilestones still used literal
, so shipped content wrapped in `
` (or any attributed tag) leaked through the strip. This is the failure mode trek-e's review almost caught with the "
" / extended-attribute test gap I deferred — CodeRabbit caught the deeper issue: it's not just a test gap, it's an actual asymmetry between the two functions that handle
blocks. Fix: align stripShippedMilestones's regex with extractCurrentMilestone's ]*> form. Comment explicitly notes the symmetry contract so a future change to either function flags the other. Tests added in stripShippedMilestones describe block: - removes
blocks - removes
blocks LOCKDOWN — leading-# strip in synthesized heading: My existing inline-HTML test exercised tag-stripping but didn't directly exercise the leading-# strip path (`.replace(/^#+\s*/, '')`). Added a dedicated test with `# v0.9 Hash-Prefixed` so a future refactor that drops the strip would fail loudly instead of producing `## # v0.9 …` (which downstream `#{2,4}` regex parses as a 4-hash header). DEFENSIVE — toBeDefined guard in roadmapAnalyze regression test: Added `expect(data.milestones).toBeDefined()` before casting and calling `.some()`. Failure now reports "expected undefined to be defined" instead of TypeError. META: my prior adversarial pass missed the sibling-asymmetry because the checklist's "sibling consistency" item only audited PARSERS for the same INPUT field (STATE.md's `milestone:`), not ADJACENT FUNCTIONS that process the same DATA SHAPE (
blocks). The latter is a wider audit — every adjacent function that touches the data shape my new code relies on. Will refine the learned rule. Verification: 51/51 roadmap.test.ts pass (was 48; +3 tests). FAMP smoke unchanged: roadmap.get-phase 3 returns active milestone phase. --- sdk/src/query/roadmap.test.ts | 43 +++++++++++++++++++++++++++++++++++ sdk/src/query/roadmap.ts | 9 ++++++-- 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/sdk/src/query/roadmap.test.ts b/sdk/src/query/roadmap.test.ts index 64208b2cc..05b844ebc 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'); }); @@ -594,6 +608,32 @@ Detail 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 @@ -937,6 +977,9 @@ describe('roadmapAnalyze', () => { 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 diff --git a/sdk/src/query/roadmap.ts b/sdk/src/query/roadmap.ts index cce4f0967..013af8846 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); From b093301d7da654a7981f23ccafc4c65dcf87565e Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Wed, 6 May 2026 15:41:37 -0400 Subject: [PATCH 7/8] chore: add changeset fragment for #3046 (missing from contributor PR) Co-Authored-By: Claude Sonnet 4.6 --- .changeset/agile-birds-cheer.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/agile-birds-cheer.md 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. From ca148036d2f81bca05911161a0ef93685c2693b9 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Wed, 6 May 2026 20:37:18 -0400 Subject: [PATCH 8/8] fix(roadmap): prevent milestone version substring matches --- sdk/src/query/roadmap.test.ts | 25 +++++++++++++++++++++++++ sdk/src/query/roadmap.ts | 2 +- 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/sdk/src/query/roadmap.test.ts b/sdk/src/query/roadmap.test.ts index 05b844ebc..04e80e8bf 100644 --- a/sdk/src/query/roadmap.test.ts +++ b/sdk/src/query/roadmap.test.ts @@ -509,6 +509,31 @@ describe('extractCurrentMilestone', () => { 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. diff --git a/sdk/src/query/roadmap.ts b/sdk/src/query/roadmap.ts index 013af8846..687d7b2f0 100644 --- a/sdk/src/query/roadmap.ts +++ b/sdk/src/query/roadmap.ts @@ -201,7 +201,7 @@ 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);