/** * 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) */ 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, phaseMarkdownRegexSourceExact, stripProjectCodePrefix, OPTIONAL_PROJECT_CODE_PREFIX_SOURCE, OPTIONAL_PHASE_TAG_SOURCE, } = 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'; import { tokenizeHeadings } from './markdown-sectionizer.cjs'; // ─── Roadmap milestone scoping ─────────────────────────────────────────────── /** * Strip shipped milestone content wrapped in
blocks. */ function stripShippedMilestones(content: string): string { return content.replace(/
[\s\S]*?<\/details>/gi, ''); } /** * 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+|✅|📋|🚧|🔄)|
[\s\S]*?<\/details>/gi, '') // #1729: `(?:\s*\([^)\n]*\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE). .replace(/^#{2,4}\s*Phase\s+[\w][\w.-]*(?:\s*\([^)\n]*\))?\s*:[^\n]*(?:\n(?!#{1,6}\s)[^\n]*)*\n?/gim, '') .replace(/^#{1,4}\s*Phase Details\b[^\n]*\n?/gim, ''); return preamble + content.slice(detailsOpenIdx, detailsEnd); } } return stripShippedMilestones(content); } const allMatches = headingMatches; 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 = 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 = beforeMilestones .replace(/
[\s\S]*?<\/details>/gi, '') // #1729: `(?:\s*\([^)\n]*\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE). .replace(/^#{2,4}\s*Phase\s+[\w][\w.-]*(?:\s*\([^)\n]*\))?\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); } // ─── Roadmap phase lookup ───────────────────────────────────────────────────── 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( `^(?:\\[[^\\]]+\\]\\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 roadmapPhaseLookupSources(phaseNum: unknown): string[] { const sources: string[] = []; const exactSource = phaseMarkdownRegexSourceExact(phaseNum); if (exactSource) sources.push(exactSource); const numericSource = phaseMarkdownRegexSource(phaseNum); // Source order matters: the bare numeric source is tried before the // prefix-tolerant form so that a canonical bare heading ("Phase 117:") is // preferred over a drifted prefixed heading ("Phase MANIFOLD-117:") when // both exist in the same ROADMAP. The prefix-tolerant form is the fallback // that handles the drifted-only case. sources.push(numericSource); sources.push(`${OPTIONAL_PROJECT_CODE_PREFIX_SOURCE}${numericSource}`); return [...new Set(sources)]; } function getRoadmapPhaseInternal(cwd: string, phaseNum: unknown): RoadmapPhaseResult | null { if (!phaseNum) return null; const normalizedPhase = stripProjectCodePrefix(phaseNum); if (/^999(?:\.|$)/.test(normalizedPhase)) return null; const roadmapPath = path.join(planningDir(cwd), 'ROADMAP.md'); if (!fs.existsSync(roadmapPath)) return null; try { 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; } return null; } catch { return null; } } // ─── Milestone info lookup ──────────────────────────────────────────────────── interface MilestoneInfo { version: string; name: string; } function getMilestoneInfo(cwd: string): MilestoneInfo { try { const roadmap = platformReadSync(path.join(planningDir(cwd), 'ROADMAP.md')); 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 { /* intentionally empty */ } } if (stateVersion) { const escapedVer = escapeRegex(stateVersion); const headingMatch = roadmap.match( new RegExp(`##[^\\n]*${escapedVer}[:\\s]+([^\\n(]+)`, 'i') ); if (headingMatch) { if (!headingMatch[0].includes('✅')) { return { version: stateVersion, name: headingMatch[1].trim() }; } } else { const listMatch = roadmap.match( new RegExp(`🚧\\s*\\*?\\*?${escapedVer}\\s+([^*\\n]+)`, 'i') ); if (listMatch) { return { version: stateVersion, name: listMatch[1].trim() }; } 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 { 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*(?:\[[^\]]+\]\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]*\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE). const phaseHeadingPattern = /^(?:\[[^\]]+\]\s*)?Phase\s+([\w][\w.-]*)(?:\s*\([^)\n]*\))?\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]); } } catch { /* intentionally empty */ } 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 (≥2 digits) // — "-\d{2,}" instead of "-0*\d+" — 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. const numericRe = roadmapUsesHyphenedIds ? /^0*(\d+(?:-\d{2,})*[A-Za-z]?(?:\.\d+)*)/ : /^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; } export = { stripShippedMilestones, extractCurrentMilestone, replaceInCurrentMilestone, getRoadmapPhaseInternal, getMilestoneInfo, getMilestonePhaseFilter, };