fix(sdk): extractCurrentMilestone preserves generic Phase Details heading (#3493)

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) <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-05-14 07:52:45 -04:00
parent 49e7c48007
commit 15e9c86ead
3 changed files with 120 additions and 2 deletions

View File

@@ -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)

View File

@@ -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
<details>
<summary>✅ v1.0 First Milestone — SHIPPED</summary>
- [x] **Phase 1: First** (1/1 plans)
</details>
### 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<string, unknown>;
expect(data.found).toBe(true);
expect(data.phase_number).toBe('4');
expect(data.phase_name).toBe('Next');
});
});
// ─── roadmapGetPhase ──────────────────────────────────────────────────────

View File

@@ -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) ─────────────────────────────────