/** * Roadmap Parser — ROADMAP.md parsing helpers * * ADR-857 rollout phase 2b: extracted from core.cts (issue #870). * Owns shipped-milestone slicing, current-milestone extraction, * milestone/phase lookups, and milestone-phase filtering. * Behaviour is preserved byte-for-behaviour from the prior location; * only the module boundary moved. The core.cjs re-export spine was retired * in epic #1267; callers import roadmap-parser helpers directly. * * Dependencies (leaf modules only — no loadConfig): * - node:fs / node:path (stdlib) * - ./phase-id.cjs (escapeRegex, phaseMarkdownRegexSource) * - ./planning-workspace.cjs (planningDir) * - ./shell-command-projection.cjs (platformReadSync) * - ./markdown-sectionizer.cjs (tokenizeHeadings, stripTaggedBlocks, withSection) */ import fs from 'node:fs'; import path from 'node:path'; // eslint-disable-next-line @typescript-eslint/no-require-imports import phaseIdModule = require('./phase-id.cjs'); const { escapeRegex, phaseMarkdownRegexSource, stripProjectCodePrefix, OPTIONAL_PHASE_TAG_SOURCE, // #2121: roadmapPhaseLookupSources now lives in phase-id.cjs (single owner of // the lookup-source ordering); imported here rather than defined locally. roadmapPhaseLookupSources, } = phaseIdModule; // eslint-disable-next-line @typescript-eslint/no-require-imports import planningWorkspace = require('./planning-workspace.cjs'); const { planningDir } = planningWorkspace; import { platformReadSync } from './shell-command-projection.cjs'; // eslint-disable-next-line @typescript-eslint/no-require-imports import unusableInputMod = require('./unusable-input.cjs'); const { UNUSABLE_REASON, warnUnusableInput } = unusableInputMod; import { tokenizeHeadings, stripTaggedBlocks, withSection } from './markdown-sectionizer.cjs'; import type { HeadingToken } from './markdown-sectionizer.cjs'; // ─── Roadmap milestone scoping ─────────────────────────────────────────────── /** * Strip shipped milestone content wrapped in
blocks. */ function stripShippedMilestones(content: string): string { return stripTaggedBlocks(content, 'details'); } /** * Extract the current milestone section from ROADMAP.md by positive lookup. */ function extractCurrentMilestone(content: string, cwd?: string): string { if (!cwd) return stripShippedMilestones(content); let version: string | null = null; try { const statePath = path.join(planningDir(cwd), 'STATE.md'); const stateRaw = platformReadSync(statePath); if (stateRaw !== null) { const milestoneMatch = stateRaw.match(/^milestone:\s*(.+)/m); if (milestoneMatch) { version = milestoneMatch[1].trim(); } } } catch { /* ignore */ } if (!version) { const inProgressMatch = content.match(/(?:🚧|🔄)\s*\*\*v(\d+\.\d+)\s/); if (inProgressMatch) { version = 'v' + inProgressMatch[1]; } } if (!version) return stripShippedMilestones(content); const escapedVersion = escapeRegex(version); const sectionPattern = new RegExp( `(^#{1,3}\\s+(?!Phase\\s+\\S).*${escapedVersion}\\b[^\\n]*)`, 'gmi' ); const summaryPattern = new RegExp( `]*>([^<]*${escapedVersion}[^<]*)<\\/summary>`, 'i' ); const headingMatches = [...content.matchAll(sectionPattern)]; if (headingMatches.length === 0) { const summaryMatch = content.match(summaryPattern); if (summaryMatch) { const summaryIdx = content.indexOf(summaryMatch[0]); const beforeSummary = content.slice(0, summaryIdx); const detailsOpenIdx = beforeSummary.lastIndexOf('/i); const detailsEnd = closingMatch ? detailsOpenIdx + (closingMatch.index ?? 0) + '
'.length : content.length; const anyMilestoneOrDetails = /^#{1,3}\s+(?!Phase\s+\S)(?:.*v\d+\.\d+|✅|📋|🚧|🔄)|
closedMarkerPattern.test(h) && !activeMarkerPattern.test(h); const firstMatch = allMatches[0]; const selected = allMatches.find((m) => !isClosed(m[1])) || firstMatch; const sectionStart = selected.index; const computeSectionEnd = (headingText: string, headingStart: number): number => { const level = (headingText.match(/^(#{1,3})\s/) ?? ['', '#'])[1].length; const afterHeading = headingStart + headingText.length; // Use tokenizeHeadings (fence-aware, offsets into original content) to find // the next stop boundary without re-implementing fence detection. T4 seam migration. const headings = tokenizeHeadings(content); for (const h of headings) { if (h.offset <= headingStart) continue; if (h.offset < afterHeading) continue; if (h.level > level) continue; // Mirrors old stopPattern: level-bounded, not a Phase heading, milestone marker if (/^Phase\s+\S/i.test(h.text)) continue; if (!/v\d+\.\d+|✅|📋|🚧/i.test(h.text)) continue; return h.offset; } return content.length; }; const sectionEnd = computeSectionEnd(selected[0], sectionStart); const anyMilestonePattern = /^#{1,3}\s+(?!Phase\s+\S)(?:.*v\d+\.\d+|✅|📋|🚧)/im; const firstMilestoneMatch = content.match(anyMilestonePattern); const preambleCutoff = firstMilestoneMatch ? firstMilestoneMatch.index! : firstMatch.index; const beforeMilestones = content.slice(0, preambleCutoff); const currentSection = content.slice(sectionStart, sectionEnd); // Multi-milestone roadmaps split each added milestone across two version-bearing // headings: a `## Phases` checklist subsection (early) and a dedicated // `## Milestone … (Phase Details)` section (late) holding the `### Phase N:` // detail headers. The scope window above stops at the next version-bearing // heading — the current milestone's OWN Phase Details heading — leaving those // detail headers outside `currentSection`. Append that section so phase // resolution and counting see the current milestone's phases. Anchor the lookup // to the SELECTED heading's specific version token (boundary-aware, so a // `v3.0` state does not match a `v3.0-A` sub-milestone) so sibling milestones // that share a version prefix do not cross-pollinate. (#730) const selectedVersionToken = selected[1].match( /v\d+(?:\.\d+)+(?:[-.][A-Za-z0-9]+)*/i, )?.[0]; const detailsVersionBoundary = selectedVersionToken ? new RegExp(`${escapeRegex(selectedVersionToken)}(?![\\w.-])`, 'i') : null; let detailsSection = ''; const detailsMatch = allMatches.find( (m) => /\(Phase\s+Details\)/i.test(m[1]) && !isClosed(m[1]) && (!detailsVersionBoundary || detailsVersionBoundary.test(m[1])) && (m.index ?? 0) >= sectionEnd, ); if (detailsMatch) { const detailsStart = detailsMatch.index ?? 0; detailsSection = content.slice( detailsStart, computeSectionEnd(detailsMatch[0], detailsStart), ); } const preamble = stripTaggedBlocks(beforeMilestones, 'details') // #1729: `(?:\s*\([^)\n]{0,200}\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE). .replace(/^#{2,4}\s*Phase\s+[\w][\w.-]*(?:\s*\([^)\n]{0,200}\))?\s*:[^\n]*(?:\n(?!#{1,6}\s)[^\n]*)*\n?/gim, '') .replace(/^#{1,4}\s*Phase Details\b[^\n]*\n?/gim, ''); return detailsSection ? preamble + currentSection + '\n' + detailsSection : preamble + currentSection; } /** * Replace a pattern only in the current milestone section of ROADMAP.md. */ function replaceInCurrentMilestone(content: string, pattern: RegExp, replacement: string): string { const lastDetailsClose = content.lastIndexOf('
'); if (lastDetailsClose === -1) { return content.replace(pattern, replacement); } const offset = lastDetailsClose + ''.length; const before = content.slice(0, offset); const after = content.slice(offset); return before + after.replace(pattern, replacement); } /** * Resolve a single phase's detail-section heading (`### Phase N: …`, any level * 1–6, via the #2121 phase-id source) and run `edit` against ONLY that * section's body. Delegates to `withSection` (markdown-sectionizer.cjs), so a * per-phase ROADMAP edit is structurally bounded to that phase's own section — * it cannot escape into a sibling phase, a shipped-milestone `
` block, * or a backticked prose literal (ADR-2143 §4). * * `content` is expected to already be scoped to the current milestone's raw * range(s) by the caller (see `currentMilestoneRawRanges`) — `withPhaseSection` * composes with that milestone-level scoping rather than replacing it. * * The matched phase number must be delimited by whitespace, a colon, an * open-paren tag, or end-of-heading — never a bare `\b`. A trailing `\b` sits * between the last digit and a following `.` or letter, so it would let a * query for phase `1` prefix-match a decimal sub-phase heading like * `### Phase 1.1: Sub` or a distinct suffixed phase like `### Phase 1A: …`. * * The phase token must additionally anchor to the START of the heading text * (after an optional leading `[tag]`, mirroring `findRoadmapPhaseInContent` * below) — never merely appear anywhere in it. Without this anchor, a query * for phase `1` would match a SIBLING phase whose own TITLE happens to * mention "Phase 1" (e.g. `### Phase 3: Migrate off Phase 1 legacy pipeline`), * and — because `collectSection` picks the first matching heading in document * order — that sibling would be hijacked instead of the real Phase 1 section. * * The section body is bounded by `{ levelBounded: false }`: it ends at the * next ATX heading of ANY level, not merely a heading at or above the phase * heading's own level. Real ROADMAPs are not guaranteed to use a uniform * phase-heading level, so a level-bounded stop could fold a deeper sibling * heading (e.g. a `####` phase following a `###` phase) into this phase's * body and let `edit` reach into it. */ function withPhaseSection( content: string, phaseId: unknown, edit: (body: string) => string, ): string { const src = phaseMarkdownRegexSource(phaseId); const headingRe = new RegExp(`^\\s*(?:\\[[^\\]]{1,200}\\]\\s*)?Phase\\s+${src}(?=[\\s:(]|$)`, 'i'); return withSection(content, (h: HeadingToken) => headingRe.test(h.text), edit, { levelBounded: false }); } // ─── Roadmap phase lookup ───────────────────────────────────────────────────── // #2199: a bullet/checkbox phase entry, e.g. `- [ ] **Phase 36 — Authentication**` // (the bundled roadmapper emits this in bullet-house-style ROADMAPs). The number // is captured in group 1, the name in group 2; the separator may be an em-dash, // en-dash, hyphen, or colon. Used as a fallback when no ATX heading matches, and // to count phases in a milestone that uses the bullet form. const BULLET_PHASE_LINE_PATTERN = /^\s*[-*]\s+(?:\[[ xX]\]\s+)?\*\*Phase\s+([\w][\w.-]*)(?:\s*\([^)\n]{0,200}\))?\s*[—–:\-]\s*(.+?)\*\*/im; /** Build a bullet-phase-line regex pinned to a specific phase number (#2199). */ function bulletPhaseLineFor(phaseNum: unknown, phaseSource?: string): RegExp { const num = phaseSource ?? phaseMarkdownRegexSource(phaseNum); return new RegExp( `^\\s*[-*]\\s+(?:\\[[ xX]\\]\\s+)?\\*\\*Phase\\s+(${num})${OPTIONAL_PHASE_TAG_SOURCE}\\s*[—–:\\-]\\s*(.+?)\\*\\*`, 'im', ); } interface RoadmapPhaseResult { found: boolean; phase_number: string; phase_name: string; goal: string | null; section: string; } function findRoadmapPhaseInContent(content: string, phaseNum: unknown, phaseSource?: string): RoadmapPhaseResult | null { // #1729: OPTIONAL_PHASE_TAG_SOURCE after the number tolerates a pre-colon ( ) tag. const headingPattern = new RegExp( `^(?:\\[[^\\]]{1,200}\\]\\s*)?Phase\\s+${phaseSource ?? phaseMarkdownRegexSource(phaseNum)}${OPTIONAL_PHASE_TAG_SOURCE}:\\s*(.+)$`, 'i' ); const headings = tokenizeHeadings(content); const headingIndex = headings.findIndex((heading) => headingPattern.test(heading.text)); if (headingIndex === -1) return null; const heading = headings[headingIndex]; const headerMatch = heading.text.match(headingPattern); if (!headerMatch) return null; const phaseName = headerMatch[1].trim(); const nextHeading = headings.slice(headingIndex + 1).find((candidate) => candidate.level <= heading.level); const sectionEnd = nextHeading ? nextHeading.offset : content.length; const section = content.slice(heading.offset, sectionEnd).trim(); const goalMatch = section.match(/\*\*Goal(?:\*\*:|\*?\*?:\*\*)\s*([^\n]+)/i); const goal = goalMatch ? goalMatch[1].trim() : null; return { found: true, phase_number: String(phaseNum), phase_name: phaseName, goal, section, }; } function findRoadmapBulletPhaseInContent(content: string, phaseNum: unknown, phaseSource?: string): RoadmapPhaseResult | null { // #2199: bullet/checkbox entry fallback (`- [ ] **Phase N — name**`). Returns // the single bullet line as the section (no multi-line body) — used only as a // last resort, AFTER heading lookup on scoped + full content has failed, so a // heading with a Requirements/Goal section always wins. const bulletMatch = content.match(bulletPhaseLineFor(phaseNum, phaseSource)); if (!bulletMatch) return null; return { found: true, phase_number: String(phaseNum), phase_name: bulletMatch[2].trim(), goal: null, section: bulletMatch[0].trim(), }; } function getRoadmapPhaseInternal(cwd: string, phaseNum: unknown): RoadmapPhaseResult | null { if (!phaseNum) return null; const normalizedPhase = stripProjectCodePrefix(phaseNum); if (/^999(?:\.|$)/.test(normalizedPhase)) return null; // Resolved INSIDE the try for the same reason as getMilestoneInfo below: planningDir // throws a plain Error for an invalid GSD_WORKSTREAM/GSD_PROJECT segment, and resolving // it outside let that escape uncaught, crashing every caller for a malformed workstream // name. ADR-227 is explicit that throwing breaks pipeline continuity, and this read path // has no reason to be the exception -- it already degrades to null for every other // failure. Absence still returns null before any diagnostic, and when the path never // resolved there is nothing to name. let roadmapPath: string | undefined; try { roadmapPath = path.join(planningDir(cwd), 'ROADMAP.md'); if (!fs.existsSync(roadmapPath)) return null; const roadmapRaw = platformReadSync(roadmapPath); if (roadmapRaw === null) throw new Error('missing'); const content = extractCurrentMilestone(roadmapRaw, cwd); const fullContent = stripShippedMilestones(roadmapRaw); for (const source of roadmapPhaseLookupSources(phaseNum)) { const scopedResult = findRoadmapPhaseInContent(content, phaseNum, source); if (scopedResult) return scopedResult; const fullResult = findRoadmapPhaseInContent(fullContent, phaseNum, source); if (fullResult) return fullResult; } // #2199: no ATX heading matched on scoped or full content — fall back to a // bullet/checkbox entry (em-dash/en-dash/hyphen/colon separator). Last resort // so a bullet never pre-empts a heading that carries the Requirements section. for (const source of roadmapPhaseLookupSources(phaseNum)) { const scopedBullet = findRoadmapBulletPhaseInContent(content, phaseNum, source); if (scopedBullet) return scopedBullet; const fullBullet = findRoadmapBulletPhaseInContent(fullContent, phaseNum, source); if (fullBullet) return fullBullet; } return null; } catch (err) { // Absence already returned above via existsSync; anything caught here is a read fault // or the synthetic missing-marker. The null is preserved exactly either way. if (roadmapPath !== undefined) reportUnreadableRoadmap(err, roadmapPath); return null; } } /** * Report a ROADMAP.md that exists but could not be read (#1881, ADR-1411). * * The discriminator is the errno, and it matters in the SILENT direction. * platformReadSync returns null for ENOENT and both callers convert that null into a * synthetic Error carrying no code, which lands in the same catch as a real EACCES. * Reporting unconditionally here would flag every project that has no ROADMAP.md yet -- * every brand-new project -- as corrupt. A genuine read fault always carries an errno; * absence never does. * * The parse itself is regex over text and cannot throw, so anything reaching a catch is * either a read fault or that synthetic absence marker. Nothing else gets here. */ function reportUnreadableRoadmap(err: unknown, roadmapPath: string): void { const code = (err as { code?: unknown } | null | undefined)?.code; if (typeof code !== 'string') return; warnUnusableInput({ reason: UNUSABLE_REASON.ROADMAP_UNREADABLE, source: roadmapPath }); } // ─── Milestone info lookup ──────────────────────────────────────────────────── interface MilestoneInfo { version: string; name: string; } /** * Strip a leading delimiter run (whitespace, em/en-dash, colon, hyphen) from a * milestone-name capture. Markdown headings commonly take the shape * `## vX.Y — Name` or `## vX.Y: Name`; the raw capture includes the delimiter * because `.trim()` only removes whitespace, not punctuation. A name beginning * with punctuation is a delimiter-led fragment, not the curated name (#2135). * NOTE: do not strip `#` — a name beginning with `#` is a heading-parse failure * that should stay loud rather than be silently cleaned. */ function stripLeadingDelimiter(s: string): string { return s.replace(/^[\s—–:-]+/, '').trim(); } function getMilestoneInfo(cwd: string): MilestoneInfo { // Declared here but RESOLVED INSIDE the try, so the catch can name the file without // moving planningDir() out of the protected region. planningDir throws a plain Error // for an invalid GSD_WORKSTREAM/GSD_PROJECT segment, and hoisting the call let that // escape uncaught — breaking the invariant #2245 relies on, that this function never // throws. When the path never resolved there is nothing to name, so the diagnostic is // skipped and the default is returned exactly as before. let roadmapPath: string | undefined; try { roadmapPath = path.join(planningDir(cwd), 'ROADMAP.md'); const roadmap = platformReadSync(roadmapPath); if (roadmap === null) throw new Error('missing'); let stateVersion: string | null = null; if (cwd) { try { const statePath = path.join(planningDir(cwd), 'STATE.md'); const stateRaw = platformReadSync(statePath); if (stateRaw !== null) { const m = stateRaw.match(/^milestone:\s*(.+)/m); if (m) stateVersion = m[1].trim(); } } catch { /* best-effort (#2245 audit): platformReadSync re-throws for a non-ENOENT * failure (e.g. EACCES) reading STATE.md. Consulting STATE.md's * `milestone:` field is an OPTIONAL enhancement here — on failure this * function already falls back to ROADMAP-only heuristics below, the * same fallback path taken when STATE.md simply doesn't exist. */ } } if (stateVersion) { const escapedVer = escapeRegex(stateVersion); // #2135: consult the 🚧 name-bearing marker FIRST. It is the only construct // guaranteed to carry the milestone's curated name adjacent to its version // (the active-milestone bullet). A `##` heading is often nameless // ("## vX.Y — Active Milestone") and, when unanchored, was matched // spuriously on a copy quoted inside backticks in this very bullet. const listMatch = roadmap.match( new RegExp(`🚧\\s*\\*?\\*?${escapedVer}\\s+([^*\\n]+)`, 'i') ); if (listMatch) { const name = stripLeadingDelimiter(listMatch[1]); if (name) return { version: stateVersion, name }; } // Fall back to the `##` heading — ANCHORED to line start (`^` + `m` flag) // so a heading quoted inside backticks or prose mid-line can no longer // match. Skip shipped (✅) headings. const headingMatch = roadmap.match( new RegExp(`^##[^\\n]*${escapedVer}[:\\s]+([^\\n(]+)`, 'im') ); if (headingMatch && !headingMatch[0].includes('✅')) { // Strip a leading delimiter — `.trim()` removes whitespace, not the // em-dash/colon that conventionally separates version from name. const name = stripLeadingDelimiter(headingMatch[1]); if (name) return { version: stateVersion, name }; } return { version: stateVersion, name: 'milestone' }; } const inProgressMatch = roadmap.match(/🚧\s*\*\*v(\d+(?:\.\d+)+)\s+([^*]+)\*\*/); if (inProgressMatch) { return { version: 'v' + inProgressMatch[1], name: inProgressMatch[2].trim(), }; } const cleaned = stripShippedMilestones(roadmap); const headingMatch = cleaned.match(/## (?!.*✅).*v(\d+(?:\.\d+)+)[:\s]+([^\n(]+)/); if (headingMatch) { return { version: 'v' + headingMatch[1], name: headingMatch[2].trim(), }; } const versionMatch = cleaned.match(/v(\d+(?:\.\d+)+)/); return { version: versionMatch ? versionMatch[0] : 'v1.0', name: 'milestone', }; } catch (err) { // This function has no existsSync guard, so an absent ROADMAP arrives here too, as a // synthetic Error with no errno. Only a real read fault is reported; the populated // default is returned unchanged either way, and a plausible-looking default needs the // diagnostic more than an empty sentinel does, not less (ADR-1411). if (roadmapPath !== undefined) reportUnreadableRoadmap(err, roadmapPath); return { version: 'v1.0', name: 'milestone' }; } } // ─── Milestone phase filter ─────────────────────────────────────────────────── type MilestonePhaseFilter = ((dirName: string) => boolean) & { phaseCount: number; missingExplicitVersion: boolean; }; /** * Returns a filter function that checks whether a phase directory belongs * to the current milestone based on ROADMAP.md phase headings. * * @param cwd - Project working directory. * @param versionOverride - Optional version string to scope the phase filter * to a specific milestone (e.g. 'v1.2'). * @param phaseIdConvention - The resolved `phase_id_convention` config value. * When `'milestone-prefixed'`, a deprecation warning is emitted for * free-form ROADMAPs that lack versioned milestone headings. When absent or * any other value, the warning is suppressed — legacy/default projects must * never see spurious warnings. */ function getMilestonePhaseFilter(cwd: string, versionOverride?: string | null, phaseIdConvention?: string | null): MilestonePhaseFilter { const milestonePhaseNums = new Set(); let missingExplicitVersion = false; try { const roadmapPath = path.join(planningDir(cwd), 'ROADMAP.md'); const roadmapContent = platformReadSync(roadmapPath); if (roadmapContent === null) throw new Error('missing'); let roadmap = extractCurrentMilestone(roadmapContent, cwd); const hasVersionedMilestonesGlobal = /^#{1,3}\s+.*v\d+\.\d+/mi.test(roadmapContent); const hasPhaseHeadings = /#{2,4}\s*(?:\[[^\]]{1,200}\]\s*)?Phase\s+[\w]/i.test(roadmapContent); if (!hasVersionedMilestonesGlobal && hasPhaseHeadings && phaseIdConvention === 'milestone-prefixed') { console.warn( '[gsd] Deprecated: free-form ROADMAP.md detected (no versioned milestone headings). ' + 'The project has phase_id_convention set to "milestone-prefixed" in config.json but the ' + 'ROADMAP does not use versioned milestone headings. Run `gsd-tools roadmap upgrade --convention milestone-prefixed` to migrate (dry-run by default).' ); } if (versionOverride) { const escapedVersion = escapeRegex(versionOverride); const sectionPattern = new RegExp(`(^#{1,3}\\s+(?!Phase\\s+\\S).*${escapedVersion}[^\\n]*)`, 'mi'); let sectionMatch = roadmapContent.match(sectionPattern); if (!sectionMatch) { const summaryPat = new RegExp(`]*>[^<]*${escapedVersion}[^<]*<\\/summary>`, 'i'); const summaryHit = roadmapContent.match(summaryPat); if (summaryHit) { const beforeSummary = roadmapContent.slice(0, summaryHit.index); const detailsIdx = beforeSummary.lastIndexOf(']*>[^<]*${escapedVersion}[^<]*<\\/summary>`, 'i').test(roadmapContent); if (hasVersionedMilestones && !versionInSummary) { roadmap = ''; missingExplicitVersion = true; } } else { const sectionStart = sectionMatch.index!; const headingLevel = (sectionMatch[1].match(/^(#{1,3})\s/) ?? ['', '#'])[1].length; const afterHeading = sectionStart + sectionMatch[0].length; // Use tokenizeHeadings (fence-aware, offsets into original content) to find // the next milestone-boundary heading. T4 seam migration. const allHeadings = tokenizeHeadings(roadmapContent); let sectionEnd = roadmapContent.length; for (const h of allHeadings) { if (h.offset < afterHeading) continue; if (h.level > headingLevel) continue; if (/^Phase\s+\S/i.test(h.text)) continue; if (!/v\d+\.\d+|✅|📋|🚧/i.test(h.text)) continue; sectionEnd = h.offset; break; } const currentSection = roadmapContent.slice(sectionStart, sectionEnd); roadmap = currentSection; } } // Use tokenizeHeadings (fence-aware) instead of stripFencedLines + regex. // T4 seam migration: phase headings inside fences are excluded automatically. // #1729: `(?:\s*\([^)\n]{0,200}\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE). const phaseHeadingPattern = /^(?:\[[^\]]{1,200}\]\s*)?Phase\s+([\w][\w.-]*)(?:\s*\([^)\n]{0,200}\))?\s*:/i; for (const h of tokenizeHeadings(roadmap)) { if (h.level < 2 || h.level > 4) continue; const pm = phaseHeadingPattern.exec(h.text); // Exclude 999.x backlog phases from milestone phase set. Mirrors init.cts filter. if (pm && !/^999\b/.test(pm[1])) milestonePhaseNums.add(pm[1]); } // #2199: also count bullet/checkbox phase entries (`- [ ] **Phase N — name**`) // so a bullet-house-style ROADMAP populates the milestone phase set instead of // collapsing to a zero-count pass-all filter. { let bm: RegExpExecArray | null; const scanner = new RegExp(BULLET_PHASE_LINE_PATTERN.source, 'gim'); while ((bm = scanner.exec(roadmap)) !== null) { if (!/^999\b/.test(bm[1])) milestonePhaseNums.add(bm[1]); } } } catch { /* best-effort (#2245 audit): the real throw source is platformReadSync * at the top of this try (re-throws for a non-ENOENT read failure). On * any failure milestonePhaseNums stays empty, which below already * degrades to the same pass-all filter this function returns when a * ROADMAP genuinely has zero recognizable phase headings — a safe, * non-corrupting (over-inclusive, never under-inclusive) degrade. */ } if (milestonePhaseNums.size === 0) { const passAll = (() => true) as unknown as MilestonePhaseFilter; passAll.phaseCount = 0; passAll.missingExplicitVersion = missingExplicitVersion; return passAll; } const normalized = new Set( [...milestonePhaseNums].map(n => n.split('-').map(seg => (seg.replace(/^0+(?=\d)/, '') || '0')).join('-').toLowerCase()) ); function normalizePhaseIdSegments(id: string): string { return id.split('-').map(seg => seg.replace(/^0+(?=\d)/, '') || '0').join('-'); } const roadmapUsesHyphenedIds = [...normalized].some(n => n.includes('-')); // #2043: milestone-prefixed sub-phase components must be zero-padded — so a // single-digit slug word after the phase // number (e.g. dir "46-6-rs-…") captures "46" and is not silently excluded from // the milestone as a bogus "46-6" id. #2232: the continuation width is exactly 2 // (PHASE_CONTINUATION_SEGMENT_SOURCE), so a year-leading slug word (dir // "14-2026-photos-…") captures "14" and is not excluded as a bogus "14-2026" id. // Built via new RegExp (no /i — the [A-Za-z] letter class does real case handling). const numericRe = roadmapUsesHyphenedIds ? new RegExp( `^0*(\\d+(?:-${phaseIdModule.PHASE_CONTINUATION_SEGMENT_SOURCE})*[A-Za-z]?(?:\\.\\d+)*)`, ) // phase-id-owner: the [A-Za-z] letter class does real case handling here — this regex carries NO /i flag; kept literal, not source-byte-equal to the canonical PHASE_NUMBER_TOKEN_SOURCE. : /^0*(\d+[A-Za-z]?(?:\.\d+)*)/; function isDirInMilestone(dirName: string): boolean { const m2 = dirName.match(numericRe); if (m2 && normalized.has(normalizePhaseIdSegments(m2[1]).toLowerCase())) return true; const customMatch = dirName.match(/^([A-Za-z][A-Za-z0-9]*(?:-[A-Za-z0-9]+)*)/); if (customMatch && normalized.has(customMatch[1].toLowerCase())) return true; const stripped = stripProjectCodePrefix(dirName); if (stripped !== dirName) { const sm = stripped.match(numericRe); if (sm && normalized.has(normalizePhaseIdSegments(sm[1]).toLowerCase())) return true; } return false; } (isDirInMilestone as MilestonePhaseFilter).phaseCount = milestonePhaseNums.size; (isDirInMilestone as MilestonePhaseFilter).missingExplicitVersion = missingExplicitVersion; return isDirInMilestone as MilestonePhaseFilter; } /** * #2200: raw [start,end) offsets of the current milestone's region(s) in ROADMAP * content, for scoping write-path mutations (phase-checkbox flip, Plans-count * writer) so they cannot touch a backticked prose literal, a Backlog entry, or a * same-numbered phase in a shipped milestone. * * Mirrors the region selection in `extractCurrentMilestone` (version detection → * active heading → next milestone boundary → optional Phase Details section). * Returns null when there is no versioned active milestone; callers then fall * back to whole-content mutation (the prior behaviour). * * NOTE: keep the region logic here in sync with extractCurrentMilestone. */ function currentMilestoneRawRanges( content: string, cwd?: string, ): { primary: { start: number; end: number }; details: { start: number; end: number } | null } | null { if (!cwd) return null; let version: string | null = null; try { const statePath = path.join(planningDir(cwd), 'STATE.md'); const stateRaw = platformReadSync(statePath); if (stateRaw !== null) { const milestoneMatch = stateRaw.match(/^milestone:\s*(.+)/m); if (milestoneMatch) version = milestoneMatch[1].trim(); } } catch { /* ignore */ } if (!version) { const inProgressMatch = content.match(/(?:🚧|🔄)\s*\*\*v(\d+\.\d+)\s/); if (inProgressMatch) version = 'v' + inProgressMatch[1]; } if (!version) return null; const escapedVersion = escapeRegex(version); const sectionPattern = new RegExp( `(^#{1,3}\\s+(?!Phase\\s+\\S).*${escapedVersion}\\b[^\\n]*)`, 'gmi', ); const headingMatches = [...content.matchAll(sectionPattern)]; if (headingMatches.length === 0) return null; const closedMarkerPattern = /\b(?:CLOSED|ARCHIVED|ABANDONED|SHIPPED|FAILED)\b|✅|🗄/i; const activeMarkerPattern = /\b(?:STARTED|ACTIVE|WIP)\b|in\s+progress|🚧|🔄/i; const isClosed = (h: string) => closedMarkerPattern.test(h) && !activeMarkerPattern.test(h); const firstMatch = headingMatches[0]; const selected = headingMatches.find((m) => !isClosed(m[1])) || firstMatch; const sectionStart = selected.index ?? 0; const computeSectionEnd = (headingText: string, headingStart: number): number => { const level = (headingText.match(/^(#{1,3})\s/) ?? ['', '#'])[1].length; const afterHeading = headingStart + headingText.length; for (const h of tokenizeHeadings(content)) { if (h.offset <= headingStart) continue; if (h.offset < afterHeading) continue; if (h.level > level) continue; if (/^Phase\s+\S/i.test(h.text)) continue; if (!/v\d+\.\d+|✅|📋|🚧/i.test(h.text)) continue; return h.offset; } return content.length; }; const sectionEnd = computeSectionEnd(selected[0], sectionStart); const selectedVersionToken = selected[1].match( /v\d+(?:\.\d+)+(?:[-.][A-Za-z0-9]+)*/i, )?.[0]; const detailsVersionBoundary = selectedVersionToken ? new RegExp(`${escapeRegex(selectedVersionToken)}(?![\\w.-])`, 'i') : null; const detailsMatch = headingMatches.find( (m) => /\(Phase\s+Details\)/i.test(m[1]) && !isClosed(m[1]) && (!detailsVersionBoundary || detailsVersionBoundary.test(m[1])) && (m.index ?? 0) >= sectionEnd, ); let details: { start: number; end: number } | null = null; if (detailsMatch) { const detailsStart = detailsMatch.index ?? 0; details = { start: detailsStart, end: computeSectionEnd(detailsMatch[0], detailsStart) }; } return { primary: { start: sectionStart, end: sectionEnd }, details }; } export = { stripShippedMilestones, extractCurrentMilestone, replaceInCurrentMilestone, getRoadmapPhaseInternal, getMilestoneInfo, getMilestonePhaseFilter, currentMilestoneRawRanges, withPhaseSection, };