From 15e9c86ead7ae347225438c6902fa64b7108ec31 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Thu, 14 May 2026 07:52:45 -0400 Subject: [PATCH] fix(sdk): extractCurrentMilestone preserves generic Phase Details heading (#3493) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a roadmap places shared phase-detail bodies under a non-version-prefixed `## Phase Details` heading AFTER a `### 📋 vX.Y+ (Planned)` sibling in document order, the entire Phase Details section fell outside the slice returned by `extractCurrentMilestone`. `gsd-sdk query phase.insert N` then reported "Phase N not found in ROADMAP.md" even though `### Phase N:` was unambiguously present. PR #2455 (closing #2422) added a same-version `continue` branch that already handles the version-prefixed variant (e.g. `## v2.0 Phase Details`). This fix extends the same intent to the generic-label variant: after the initial boundary scan, look for a literal `^#{1,3}\s+Phase\s+Details\b` heading past `sectionEnd` and, if found, append the Phase Details block (up to the next real milestone boundary — version-bearing or milestone-emoji-bearing heading — or EOF) to the returned slice. The intervening planned-milestone content is skipped so it does not leak into the active-milestone view. Bounded to a single append so a malformed roadmap can't loop. Only matches the literal `Phase Details` label (canonical per GSD ROADMAP template); anything else continues to terminate the slice. Does not regress the v2.0/v2.1+ version-prefixed handling shipped by #2455 (existing `bug-2422` test still passes). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../3493-extract-milestone-generic-heading.md | 5 ++ sdk/src/query/roadmap.test.ts | 65 +++++++++++++++++++ sdk/src/query/roadmap.ts | 52 ++++++++++++++- 3 files changed, 120 insertions(+), 2 deletions(-) create mode 100644 .changeset/3493-extract-milestone-generic-heading.md diff --git a/.changeset/3493-extract-milestone-generic-heading.md b/.changeset/3493-extract-milestone-generic-heading.md new file mode 100644 index 000000000..d12aa4a99 --- /dev/null +++ b/.changeset/3493-extract-milestone-generic-heading.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 3493 +--- +**`extractCurrentMilestone` preserves generic `## Phase Details` heading** — When a roadmap placed shared phase-detail bodies under a non-version-prefixed `## Phase Details` heading AFTER a `### 📋 vX.Y+ (Planned)` sibling, the entire Phase Details section fell outside the returned milestone slice. `gsd-sdk query phase.insert N` then reported "Phase N not found in ROADMAP.md" even though `### Phase N:` was plainly present. PR #2455 (closing #2422) handled the version-prefixed variant (`## v2.0 Phase Details`) via the same-version `continue` branch; this fix extends the same intent to the generic-label variant by appending the trailing `Phase Details` block (up to the next real milestone boundary or EOF) to the active-milestone slice. The intervening planned-milestone content is skipped so it does not leak in. (#3493) diff --git a/sdk/src/query/roadmap.test.ts b/sdk/src/query/roadmap.test.ts index 04e80e8bf..a6f9f14b3 100644 --- a/sdk/src/query/roadmap.test.ts +++ b/sdk/src/query/roadmap.test.ts @@ -809,6 +809,71 @@ Detail expect(result).toContain('Phase 100'); expect(result).toContain('Phase Details'); }); + + // ─── Bug #3493: generic `## Phase Details` after planned-milestone sibling ── + it('bug-3493: preserves generic `## Phase Details` heading after `### 📋 vX.Y+ (Planned)` sibling', async () => { + // Minimal repro from issue #3493 verbatim: a generic (non-version-prefixed) + // `## Phase Details` heading sits AFTER a `### 📋 v2.1+ (Planned)` sibling + // in document order. The 📋-bearing sibling otherwise terminates the slice + // and the generic Phase Details body — including `### Phase 4: Next` — is + // dropped, even though it belongs to the active v2.0 milestone. + const roadmap = `# Roadmap: Example + +## Phases + +
+✅ v1.0 First Milestone — SHIPPED +- [x] **Phase 1: First** (1/1 plans) +
+ +### v2.0 Active Milestone (Phases 2–5) + +- [x] **Phase 2: Foundation** (5/5 plans) — completed +- [x] **Phase 3: Pipeline** (8/8 plans) — completed +- [ ] **Phase 4: Next** — pending +- [ ] **Phase 5: Final** — pending + +### 📋 v2.1+ (Planned — Not Yet Scoped) + +Candidates pending milestone selection. + +## Phase Details + +### Phase 2: Foundation +**Goal**: Foundation goal. + +### Phase 3: Pipeline +**Goal**: Pipeline goal. + +### Phase 4: Next +**Goal**: Next goal. + +### Phase 5: Final +**Goal**: Final goal. +`; + const state = `---\nmilestone: v2.0\n---\n# State\n`; + await writeFile(join(tmpDir, '.planning', 'STATE.md'), state); + await writeFile(join(tmpDir, '.planning', 'ROADMAP.md'), roadmap); + + const slice = await extractCurrentMilestone(roadmap, tmpDir); + + // The generic Phase Details heading and all four detail sections must + // survive — they belong to the active v2.0 milestone even though they + // sit after the planned-milestone sibling in document order. + expect(slice).toContain('## Phase Details'); + expect(slice).toContain('### Phase 2: Foundation'); + expect(slice).toContain('### Phase 3: Pipeline'); + expect(slice).toContain('### Phase 4: Next'); + expect(slice).toContain('### Phase 5: Final'); + + // And roadmapGetPhase (which calls extractCurrentMilestone internally) + // must locate Phase 4's detail section. + const result = await roadmapGetPhase(['4'], tmpDir); + const data = result.data as Record; + expect(data.found).toBe(true); + expect(data.phase_number).toBe('4'); + expect(data.phase_name).toBe('Next'); + }); }); // ─── roadmapGetPhase ────────────────────────────────────────────────────── diff --git a/sdk/src/query/roadmap.ts b/sdk/src/query/roadmap.ts index d3f00a120..6e3f14cad 100644 --- a/sdk/src/query/roadmap.ts +++ b/sdk/src/query/roadmap.ts @@ -313,9 +313,57 @@ export async function extractCurrentMilestone(content: string, projectDir: strin break; } + // Issue #3493: a generic, non-version-prefixed `## Phase Details` (or + // `### Phase Details`) heading sometimes sits AFTER a planned-milestone + // sibling (e.g. `### 📋 v2.1+ (Planned)`) in document order but holds the + // detail bodies (`### Phase N: ...`) for the *active* milestone. Issue #2422 + // / PR #2455 handled the version-prefixed variant `## v2.0 Phase Details` + // via the same-version `continue` above; this branch handles the generic- + // label variant. When such a heading is found after the initial sectionEnd, + // append the Phase Details block to the returned slice (skipping the + // intervening planned-milestone content so it does not leak in), stopping + // at the next real milestone boundary (version-bearing or milestone-emoji- + // bearing heading) or EOF. + // + // Bounded to a single append so a malformed roadmap can't loop. Only + // matches the literal `Phase Details` label (canonical per GSD ROADMAP + // template); anything else continues to terminate the slice. + let phaseDetailsTail = ''; + if (sectionEnd < content.length) { + const afterEnd = content.slice(sectionEnd); + const genericPhaseDetailsRegex = /^(#{1,3})\s+Phase\s+Details\b[^\n]*$/im; + const phaseDetailsMatch = afterEnd.match(genericPhaseDetailsRegex); + if (phaseDetailsMatch && phaseDetailsMatch.index !== undefined) { + const pdStart = sectionEnd + phaseDetailsMatch.index; + const pdHeadingLen = phaseDetailsMatch[0].length; + const pdLevel = phaseDetailsMatch[1].length; + // Scan from after the Phase Details heading for the next real milestone + // boundary (different version OR milestone emoji). Reuses the same + // (?!Phase\s+\S) phase-heading guard from #2619, plus a + // (?!Phase\s+Details\b) guard so a sibling Phase Details heading + // doesn't terminate the block prematurely. + const afterPdHeading = content.slice(pdStart + pdHeadingLen); + const nextRealMilestoneRegex = new RegExp( + `^#{1,${pdLevel}}\\s+(?!Phase\\s+\\S)(?!Phase\\s+Details\\b)(?:.*v(\\d+(?:\\.\\d+)+)[^\\n]*|.*(?:✅|📋|🚧|🟡))`, + 'gmi' + ); + let pdEnd = content.length; + let mm: RegExpExecArray | null; + while ((mm = nextRealMilestoneRegex.exec(afterPdHeading)) !== null) { + const mv = mm[1]; + if (mv && currentVersionStr && mv === currentVersionStr) continue; + pdEnd = pdStart + pdHeadingLen + mm.index; + break; + } + phaseDetailsTail = '\n' + content.slice(pdStart, pdEnd); + } + } + // Return only the current milestone section — never include the preamble, which - // may contain ## Backlog and other non-current-milestone phases. - return content.slice(sectionStart, sectionEnd); + // may contain ## Backlog and other non-current-milestone phases. Append any + // generic Phase Details block discovered above (#3493) so detail-section + // lookups for the active milestone succeed. + return content.slice(sectionStart, sectionEnd) + phaseDetailsTail; } // ─── Next-milestone helpers (issue #2497) ─────────────────────────────────