Address trek-e's adversarial review on PR #3046. Two critical merge-blockers
plus four hardening items, all now covered with tests.
CRITICAL #1 — substring-version trap:
`[^<]*${escapedVersion}[^<]*` did substring containment, so
`milestone: v0.1` matched <summary>v0.10 …</summary> and returned the
v0.10 block's body as the active milestone — confidently-wrong content
worse than the pre-PR fall-through. Add `(?![\d.])` non-version-character
lookahead, mirroring the same boundary protection used by the existing
`currentVersionStr` logic on the heading path. Test asserts v0.1 active
with v0.10 sibling block returns v0.1's phases, not v0.10's.
CRITICAL #2 — nested <details> silent truncation:
The lazy `[\s\S]*?</details>` terminates on the FIRST </details>, which
is the inner closer when nesting is present. Prior comment claimed
"would mis-anchor (acceptable; falls through)" — factually wrong: the
match succeeds with truncated body and is returned with a confident
`## ${summary}` heading. Future maintainer investigating a "missing
phase" report would be misled. Add `!detailsMatch[2].includes('<details')`
guard so nesting falls through to stripShippedMilestones (loud failure)
instead of returning truncated content (silent failure). Test locks
the contract: no synthesized v0.9 heading anchored to truncated body.
HARDENING:
- Empty-body guard: `<details><summary>v0.9</summary></details>` would
synthesize `## v0.9\n` (phantom milestone, zero phases, no error
signal). Treat as no-match.
- Inline-HTML in <summary>: rejected by `[^<]*` capture. Widen to
`(?:(?!</summary>).)*?` (non-greedy until close tag) and strip tags
+ leading `#` from the captured summary before promoting to a `##`
heading. Covers GitHub-rendered <em>(active)</em>, <code>v0.9</code>,
<strong>...</strong> patterns.
- JSDoc: rewrote to describe both anchoring strategies and the
synthesized-heading contract; demoted stale "Port of core.cjs lines
1102-1170" to historical context with the divergence list.
- Comment block: rewrote in contract style ("any consumer scanning
/##\s*.*vX.Y/ sees the active milestone") instead of coupling to
specific call sites (roadmapAnalyze, "later in this file"). Adds
explicit regex anatomy + hardening-guards section so future readers
can audit each guard.
OUT OF SCOPE (per trek-e's "Recommended action" tier):
- Debug logging on fall-through paths (Suggestion #10) — adds tracing
surface to a function that doesn't currently use logger; appropriate
for a follow-up if/when other extraction bugs surface.
- Uppercase <DETAILS>/<SUMMARY> + extended attribute coverage
(Test gap #7 last two rows) — already covered by the documented `i`
flag and the existing <details open> test; adding redundant cases
inflates the test set without locking new contracts.
Verification: 45/45 roadmap.test.ts tests pass (was 41/41; added 4
hardening tests). FAMP end-to-end smoke unchanged: roadmap.get-phase 3
returns "Claude Code integration polish", roadmap.analyze surfaces
v0.9 Local-First Bus in data.milestones with phase_count: 4.
Address CodeRabbit review on PR #3046: the prior commit returned only the
body inside <details>...</details>, which fixed the `roadmapGetPhase`
miss but left `roadmapAnalyze`'s downstream `data.milestones` scan
(`/##\s*(.*v(\d+(?:\.\d+)+)[^(\n]*)/gi` at the bottom of roadmap.ts)
without an active-milestone anchor in the returned slice.
Now capture the <summary> text and prepend it as a synthesized `##`
heading on the returned slice. This makes both `data.phases` (the
original bug) AND `data.milestones` (the downstream consumer) surface
the active milestone correctly for <details>-wrapped ROADMAPs.
Also widened the inner tag to `<summary\b[^>]*>` for symmetry with the
outer `<details\b[^>]*>` — both now tolerate attributes.
Verified end-to-end against FAMP's v0.9 ROADMAP:
- Before this commit (after PR #3046 base):
milestones: [{heading: '# Phase 1: ... (v0.5.2 atomic bump)', version: 'v0.5.2'}]
- After this commit:
milestones: [{heading: 'v0.9 Local-First Bus', version: 'v0.9'},
{heading: '# Phase 1: ... (v0.5.2 ...)', version: 'v0.5.2'}]
(The v0.5.2 entry is pre-existing noise from the loose `##\s*` regex
matching the `### Phase 1: famp-bus (v0.5.2 atomic bump)` body heading;
unrelated to this fix and out of scope for this PR.)
Tests:
- Updated the two `<details><summary>` tests to assert the synthesized
`## v0.9 Local-First Bus` heading is present on the returned slice.
- Added a 4th regression test (`roadmapAnalyze`) confirming
`data.milestones` now contains the active milestone for
<details>-wrapped ROADMAPs.
- All 40 roadmap.test.ts tests pass.
Address CodeRabbit review on PR #3046: extractCurrentMilestone read the
`milestone:` value from STATE.md frontmatter via `.trim()` only, while
parseMilestoneFromState() and getMilestoneInfo() both also strip
surrounding YAML quotes via `.replace(/^["']|["']$/g, '')`.
For projects whose STATE.md uses quoted YAML (`milestone: "v0.9"`),
`version` carried literal quotes, `escapedVersion` became `\"v0\.9\"`,
and neither the markdown-heading regex nor the new <details><summary>
fallback could match anything — falling through to
stripShippedMilestones() and reintroducing the same archived-milestone
misrouting this PR addresses.
Strip quotes for parity. Three-line addition + one new test.
All 41 roadmap.test.ts tests pass.
`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`.