fix(#2641): recognize <details><summary> as active-milestone anchor
`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 `<details><summary>vX.Y …</summary>`
(a common GitHub-friendly collapse pattern, e.g. FAMP) fell through to
`stripShippedMilestones`, which strips ALL `<details>` 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
`<details\b[^>]*><summary>...vX.Y...</summary>`. Returns the inner content
of the matching block. Purely additive — `stripShippedMilestones` behavior
and its tests are unchanged.
The `\b[^>]*>` form tolerates attributes like `<details open>` or
`<details class="...">` (GitHub commonly emits `<details open>` for
default-expanded sections). Lazy `[\s\S]*?` matches up to the first
`</details>`; nested `<details>` 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 <details><summary>vX.Y …</summary>`
- `bug-2641: finds active milestone in <details open><summary>vX.Y …</summary>`
- `bug-2641: returns found:true for phase inside <details>-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`.
This commit is contained in:
@@ -389,6 +389,80 @@ describe('extractCurrentMilestone', () => {
|
||||
expect(result).toContain('### Phase 19: Security Audit');
|
||||
});
|
||||
|
||||
// ─── Bug #2641: <details><summary>vX.Y …</summary> not recognized as anchor ───
|
||||
it('bug-2641: finds active milestone wrapped in <details><summary>vX.Y …</summary>', async () => {
|
||||
// Many projects (GitHub-friendly collapse) wrap the active milestone's
|
||||
// phase details inside <details><summary>v0.9 …</summary>. Without the
|
||||
// <details>-aware fallback, extractCurrentMilestone misses the heading
|
||||
// anchor (because <summary> is HTML), falls through to
|
||||
// stripShippedMilestones, and loses all <details> 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
|
||||
|
||||
<details>
|
||||
<summary>✅ v0.8 Foundation — SHIPPED 2026-04-15</summary>
|
||||
|
||||
### Phase 1: Old phase
|
||||
**Goal:** Old goal.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>v0.9 Local-First Bus (active) — Phase Details</summary>
|
||||
|
||||
### Phase 1: Library
|
||||
**Goal:** Build the library.
|
||||
|
||||
### Phase 3: Polish
|
||||
**Goal:** Add polish.
|
||||
</details>
|
||||
`;
|
||||
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 <details> tag (e.g. <details open>) ───
|
||||
it('bug-2641: finds active milestone in <details open><summary>vX.Y …</summary>', async () => {
|
||||
// GitHub auto-renders <details open> for sections that should default to
|
||||
// expanded. The <details>-aware fallback regex must use <details\b[^>]*>
|
||||
// (not literal <details>) so attribute-bearing tags also anchor correctly.
|
||||
const roadmapWithDetailsOpen = `# Roadmap
|
||||
|
||||
## Phases
|
||||
|
||||
<details open>
|
||||
<summary>v0.9 Local-First Bus (active) — Phase Details</summary>
|
||||
|
||||
### Phase 3: Polish
|
||||
**Goal:** Add polish.
|
||||
</details>
|
||||
`;
|
||||
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 <details>-wrapped active milestone', async () => {
|
||||
// End-to-end coverage: roadmapGetPhase calls extractCurrentMilestone
|
||||
// internally. Without the <details>-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
|
||||
|
||||
<details>
|
||||
<summary>v0.9 Local-First Bus (active) — Phase Details</summary>
|
||||
|
||||
### Phase 3: Polish
|
||||
|
||||
**Goal:** Add polish.
|
||||
|
||||
**Success Criteria**:
|
||||
1. Polish applied
|
||||
2. Tests pass
|
||||
</details>
|
||||
`;
|
||||
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<string, unknown>;
|
||||
expect(data.found).toBe(true);
|
||||
expect(data.phase_number).toBe('3');
|
||||
expect(data.phase_name).toBe('Polish');
|
||||
expect(data.goal).toBe('Add polish.');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── roadmapAnalyze ───────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user