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:
Ben Lamm
2026-05-02 22:56:00 -04:00
committed by Tom Boucher
parent 94f835af40
commit 592b676414
2 changed files with 143 additions and 1 deletions

View File

@@ -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: <details><summary> matching the active version (issue #2641).
//
// Many projects (GitHub-friendly collapse pattern) wrap the active
// milestone's phase details inside a collapsible block whose <summary>
// names the version, e.g.:
//
// <details>
// <summary>v0.9 Local-First Bus (active) — Phase Details</summary>
// ### Phase 1: ...
// </details>
//
// The markdown-heading lookup above misses this because <summary> is HTML,
// not a heading. Without this fallback, control falls through to
// stripShippedMilestones() which removes ALL <details> 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.
//
// <details\b[^>]*> tolerates attributes like <details open> and
// <details class="...">. The lazy [\s\S]*? terminates on the first
// </details>; nested <details> 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(
`<details\\b[^>]*>\\s*<summary>[^<]*${escapedVersion}[^<]*</summary>([\\s\\S]*?)</details>`,
'i'
);
const detailsMatch = content.match(detailsPattern);
if (detailsMatch) return detailsMatch[1];
return stripShippedMilestones(content);
}
const sectionStart = sectionMatch.index;