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:
5
.changeset/3493-extract-milestone-generic-heading.md
Normal file
5
.changeset/3493-extract-milestone-generic-heading.md
Normal 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)
|
||||
@@ -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 ──────────────────────────────────────────────────────
|
||||
|
||||
@@ -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) ─────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user