From fa1118afa73c5dd899359fc884f23faca79a2822 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Mon, 8 Jun 2026 12:34:17 -0400 Subject: [PATCH] refactor(#870): extract ROADMAP.md parsing into roadmap-parser.cts (#876) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR-857 rollout phase 2b. Move the 6 ROADMAP.md-parsing functions (stripShippedMilestones, extractCurrentMilestone, replaceInCurrentMilestone, getRoadmapPhaseInternal, getMilestoneInfo, getMilestonePhaseFilter) + their interfaces out of core.cts into a new leaf module src/roadmap-parser.cts. core.cts re-exports them (behavior-preserving); ~9 external callers unchanged. Resolves the parse/write straddle: roadmap.cts (ROADMAP.md mutation) now imports its 3 parsing helpers from roadmap-parser.cjs directly instead of reaching through core. roadmap-parser depends only on leaves (phase-id, planning-workspace, shell-command-projection) — cycle-free, enabled by phase 2a. New-CLI-module checklist done (.gitignore, eslint, INVENTORY 92->93 + row, manifest, ARCHITECTURE, CONTEXT.md "Roadmap Parser Module"). Adds tests/roadmap-parser.test.cjs (46 tests: behavioral + shim-identity + adversarial ROADMAP.md fixtures). Adversarial review surfaced a pre-existing fence-blindness bug in getMilestonePhaseFilter (matches phase headings inside fenced code blocks); filed as #875 and left for a separate fix (out of scope for this behavior-preserving extraction). The two fenced-fixture tests characterize the current behavior with a #875 reference and flip when it's fixed. Gates: lint, code-review, security-review, codex adversarial-review (0 correctness findings). gsd-test: clean-build docker + Mac green; the full-suite docker run's "X is not a function" errors on re-exported symbols were local incremental-tsc staleness (verified: clean rebuild of the affected files = 195 pass, 0 fail; Mac = 4001 pass). Closes #870 Co-authored-by: Claude Opus 4.8 --- .gitignore | 1 + CONTEXT.md | 3 + docs/ARCHITECTURE.md | 1 + docs/INVENTORY-MANIFEST.json | 1 + docs/INVENTORY.md | 3 +- eslint.config.mjs | 1 + src/core.cts | 435 +------------------------- src/roadmap-parser.cts | 469 ++++++++++++++++++++++++++++ src/roadmap.cts | 5 +- tests/roadmap-parser.test.cjs | 562 ++++++++++++++++++++++++++++++++++ 10 files changed, 1053 insertions(+), 428 deletions(-) create mode 100644 src/roadmap-parser.cts create mode 100644 tests/roadmap-parser.test.cjs diff --git a/.gitignore b/.gitignore index 43e98f2cb..6819519db 100644 --- a/.gitignore +++ b/.gitignore @@ -129,6 +129,7 @@ build/ /gsd-core/bin/lib/core.cjs /gsd-core/bin/lib/io.cjs /gsd-core/bin/lib/phase-id.cjs +/gsd-core/bin/lib/roadmap-parser.cjs /gsd-core/bin/lib/drift.cjs /gsd-core/bin/lib/cjs-command-router-adapter.cjs /gsd-core/bin/lib/phase-command-router.cjs diff --git a/CONTEXT.md b/CONTEXT.md index 994670b42..92a884183 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -112,6 +112,9 @@ Primary installer for all runtimes. Single production file: `bin/install.js` (ge ### I/O Module Module owning the tool's CLI I/O primitives: `output()` result emission (with large-payload temp-file spillover via `GSD_TEMP_DIR`/`ensureGsdTempDir`/`reapStaleTempFiles`), `error()` stderr emission with exit-code mapping, and the JSON-error-mode toggle (`setJsonErrorMode`/`getJsonErrorMode`, `ERROR_REASON`). Extracted from the Core module per ADR-857 rollout phase 1 (#859) so feature modules (`graphify`, `intel`, `audit`, `profile-pipeline`) depend on a small I/O seam instead of the core god-module; `core.cjs` re-exports the primitives for back-compat. Source of truth: `gsd-core/bin/lib/io.cjs` (generated from `src/io.cts`). +### Roadmap Parser Module +Module owning ROADMAP.md parsing: shipped-milestone slicing, current-milestone extraction, milestone/phase lookups, and milestone-phase filtering (`stripShippedMilestones`, `extractCurrentMilestone`, `replaceInCurrentMilestone`, `getRoadmapPhaseInternal`, `getMilestoneInfo`, `getMilestonePhaseFilter`). Depends only on leaf modules (`phase-id`, `planning-workspace`, `shell-command-projection`) — no `loadConfig`, no other core dependency. Extracted from the Core module per ADR-857 rollout phase 2b (#870), resolving the ROADMAP.md parse/write straddle so the Roadmap module (`roadmap.cjs`, which owns ROADMAP.md mutation) imports parsing directly instead of through Core; `core.cjs` re-exports the helpers for back-compat. Source of truth: `gsd-core/bin/lib/roadmap-parser.cjs` (generated from `src/roadmap-parser.cts`). + ### Package Identity Module [Planned] Single seam owning GSD's published-package coordinates so a repoint/rename is a one-line change instead of a tree-wide sweep. Source of truth is `package.json`; values are *derived*, not re-typed: `packageName` (`.name` → `@opengsd/get-shit-done-redux`), `binName` (`Object.keys(.bin)[0]` → `get-shit-done-redux`), `repoSlug` (parsed from `.repository.url` → `open-gsd/get-shit-done-redux`), plus derived `changelogRawUrl` and `manualInstallCommand({ scope, runtime })`. Generated `.cjs` per ADR-457 (generated-single-source); shipped under `gsd-core/bin/lib/`. Three consumer worlds: **Node** consumers `require()` it at runtime (worker, `check-latest-version.cjs`, `bin/install.js`); the **bash launcher** snippet receives the literal injected by `scripts/sync-runtime-launcher.cjs` at sync time; **prose/help** literals (`update.md`, installer help) carry a committed copy. A drift-guard lint (`scripts/lint-package-identity-drift.cjs`, sibling to `check:alias-drift`) fails CI on any raw package/repo literal outside `package.json`, the generated module, and the value-checked materialization sites — this is what keeps the seam real (`two adapters`, not one). Replaces the contradictory pair it consolidates: the runtime-broken `require('../package.json').name` in `hooks/gsd-check-update-worker.js` (#378, resolves to `undefined` post-install) and the hardcoded constant in `check-latest-version.cjs` (#2992). _Avoid_: "package name string", "the npm name" (when you mean the seam). See ADR-457 and Installer Module. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 05bdbd192..ea83a45eb 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -345,6 +345,7 @@ Node.js CLI utility (`gsd-tools.cjs`) with domain modules split across `gsd-core | `core.cjs` | Shared utilities; compatibility re-exports for planning, I/O (`io.cjs`), and phase-id helpers | | `io.cjs` | CLI I/O primitives — output/error emission, JSON-error mode, large-payload temp-file spillover | | `phase-id.cjs` | Pure phase-id parsing/matching helpers — normalize, token match, regex builders (extracted from `core.cjs`, ADR-857) | +| `roadmap-parser.cjs` | ROADMAP.md parsing — milestone slicing, current-milestone extraction, phase/milestone lookups, milestone-phase filter (extracted from `core.cjs`, ADR-857) | | `planning-workspace.cjs` | Planning seam (`planningDir`, `planningPaths`, active workstream routing, `.planning/.lock`) | | `state.cjs` | STATE.md parsing, updating, progression, metrics | | `phase.cjs` | Phase directory operations, decimal numbering, plan indexing | diff --git a/docs/INVENTORY-MANIFEST.json b/docs/INVENTORY-MANIFEST.json index 634a0fc41..31d1c607d 100644 --- a/docs/INVENTORY-MANIFEST.json +++ b/docs/INVENTORY-MANIFEST.json @@ -324,6 +324,7 @@ "research-store.cjs", "review-reviewer-selection.cjs", "roadmap-command-router.cjs", + "roadmap-parser.cjs", "roadmap-upgrade.cjs", "roadmap.cjs", "runtime-artifact-layout.cjs", diff --git a/docs/INVENTORY.md b/docs/INVENTORY.md index d99ced286..21458ddac 100644 --- a/docs/INVENTORY.md +++ b/docs/INVENTORY.md @@ -370,7 +370,7 @@ The `gsd-planner` agent is decomposed into a core agent plus reference modules t --- -## CLI Modules (92 shipped) +## CLI Modules (93 shipped) Full listing: `gsd-core/bin/lib/*.cjs`. @@ -435,6 +435,7 @@ Full listing: `gsd-core/bin/lib/*.cjs`. | `research-store.cjs` | Content-addressed research cache: sha256 keys, per-source TTL staleness, two-tier (user ~/.gsd / project .planning) store | | `review-reviewer-selection.cjs` | Reviewer selection/normalization helpers for `/gsd-review` default reviewer policy and precedence | | `roadmap-command-router.cjs` | Thin CJS subcommand router adapter for `gsd-tools roadmap` | +| `roadmap-parser.cjs` | ROADMAP.md parsing — milestone slicing, current-milestone extraction, phase/milestone lookups, milestone-phase filter (extracted from `core.cjs`, ADR-857) | | `roadmap-upgrade.cjs` | Migration tool for converting legacy `Phase N` entries to milestone-prefixed `Phase M-NN` convention; `computeMigrationPlan` + `applyMigration` with dry-run default and atomic rollback | | `roadmap.cjs` | ROADMAP.md parsing, phase extraction, plan progress | | `runtime-artifact-layout.cjs` | Runtime artifact layout module — resolves the artifact directory shapes (commands, agents, skills) for each supported runtime; single source of truth for per-runtime artifact placement (#3663) | diff --git a/eslint.config.mjs b/eslint.config.mjs index 757642109..a50b32cd5 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -91,6 +91,7 @@ export default tseslint.config( 'gsd-core/bin/lib/core.cjs', 'gsd-core/bin/lib/io.cjs', 'gsd-core/bin/lib/phase-id.cjs', + 'gsd-core/bin/lib/roadmap-parser.cjs', 'gsd-core/bin/lib/drift.cjs', 'gsd-core/bin/lib/cjs-command-router-adapter.cjs', 'gsd-core/bin/lib/phase-command-router.cjs', diff --git a/src/core.cts b/src/core.cts index 23b986e93..d608163c4 100644 --- a/src/core.cts +++ b/src/core.cts @@ -17,6 +17,9 @@ const { output, error, ERROR_REASON, setJsonErrorMode, getJsonErrorMode, GSD_TEM import phaseIdModule = require('./phase-id.cjs'); const { escapeRegex, normalizePhaseName, getMilestoneFromPhaseId, getPhaseDirFromPhaseId, phaseMarkdownRegexSource, phaseMarkdownRegexSourceExact, comparePhaseNum, extractPhaseToken, phaseTokenMatches } = phaseIdModule; // eslint-disable-next-line @typescript-eslint/no-require-imports +import roadmapParserModule = require('./roadmap-parser.cjs'); +const { stripShippedMilestones, extractCurrentMilestone, replaceInCurrentMilestone, getRoadmapPhaseInternal, getMilestoneInfo, getMilestonePhaseFilter } = roadmapParserModule; +// eslint-disable-next-line @typescript-eslint/no-require-imports import modelProfiles = require('./model-profiles.cjs'); const { MODEL_PROFILES, AGENT_TO_PHASE_TYPE, VALID_PHASE_TYPES: _VALID_PHASE_TYPES, AGENT_DEFAULT_TIERS, VALID_AGENT_TIERS, nextTier } = modelProfiles; import { MODEL_ALIAS_MAP, RUNTIME_PROFILE_MAP, KNOWN_RUNTIMES, RUNTIMES_WITH_REASONING_EFFORT, RUNTIMES_WITH_FAST_MODE, PROVIDER_PRESETS, KNOWN_PROVIDERS } from './model-catalog.cjs'; @@ -679,235 +682,10 @@ function getArchivedPhaseDirs(cwd: string): ArchivedPhaseDir[] { return results; } -// ─── 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, '') - .replace(/^#{2,4}\s*Phase\s+[\w][\w.-]*\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 rest = content.slice(headingStart + headingText.length); - const stopPattern = new RegExp( - `^#{1,${level}}\\s+(?!Phase\\s+\\S)(?:.*v\\d+\\.\\d+|✅|📋|🚧)`, - 'i', - ); - let end = content.length; - let fc: string | null = null; - let fl = 0; - let off = 0; - for (const line of rest.split('\n')) { - const fm = line.match(/^\s{0,3}((?:`{3,}|~{3,}))(.*)/); - if (fm) { - const ch = fm[1][0]; - const ln = fm[1].length; - const trailing = fm[2] || ''; - if (!fc) { - fc = ch; - fl = ln; - } else if (ch === fc && ln >= fl && /^\s*$/.test(trailing)) { - fc = null; - fl = 0; - } - } else if (!fc && stopPattern.test(line)) { - end = headingStart + headingText.length + off; - break; - } - off += line.length + 1; - } - return end; - }; - - 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, '') - .replace(/^#{2,4}\s*Phase\s+[\w][\w.-]*\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 & model utilities ──────────────────────────────────────────────── - -interface RoadmapPhaseResult { - found: boolean; - phase_number: string; - phase_name: string; - goal: string | null; - section: string; -} - -function getRoadmapPhaseInternal(cwd: string, phaseNum: unknown): RoadmapPhaseResult | null { - if (!phaseNum) 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 phasePattern = new RegExp( - `#{2,4}\\s*(?:\\[[^\\]]+\\]\\s*)?Phase\\s+${phaseMarkdownRegexSource(phaseNum)}:\\s*([^\\n]+)`, - 'i' - ); - const headerMatch = content.match(phasePattern); - if (!headerMatch) return null; - - const phaseName = headerMatch[1].trim(); - const headerIndex = headerMatch.index!; - const restOfContent = content.slice(headerIndex); - const nextHeaderMatch = restOfContent.match(/\n#{2,4}\s+(?:\[[^\]]+\]\s*)?Phase\s+[\w]/i); - const sectionEnd = nextHeaderMatch ? headerIndex + nextHeaderMatch.index! : content.length; - const section = content.slice(headerIndex, sectionEnd).trim(); - - const goalMatch = section.match(/\*\*Goal(?:\*\*:|\*?\*?:\*\*)\s*([^\n]+)/i); - const goal = goalMatch ? goalMatch[1].trim() : null; - - return { - found: true, - // eslint-disable-next-line @typescript-eslint/no-base-to-string - phase_number: String(phaseNum), - phase_name: phaseName, - goal, - section, - }; - } catch { - return null; - } -} +// ─── Roadmap milestone scoping (re-exported from roadmap-parser.cjs) ────────── +// stripShippedMilestones, extractCurrentMilestone, replaceInCurrentMilestone, +// getRoadmapPhaseInternal, getMilestoneInfo, getMilestonePhaseFilter +// — all imported via `roadmapParserModule` above; internal callers use the destructured bindings. // ─── Agent installation validation (#1371) ─────────────────────────────────── @@ -1596,203 +1374,8 @@ function generateSlugInternal(text: string | null | undefined): string | null { return text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').substring(0, 60); } -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' }; - } -} - -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. - */ -function getMilestonePhaseFilter(cwd: string, versionOverride?: 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) { - console.warn( - '[gsd] Deprecated: free-form ROADMAP.md detected (no versioned milestone headings). ' + - 'Set phase_id_convention in config.json to suppress this warning.' - ); - } - - 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 restContent = roadmapContent.slice(sectionStart + sectionMatch[0].length); - const nextMilestonePattern = new RegExp(`^#{1,${headingLevel}}\\s+(?!Phase\\s+\\S)(?:.*v\\d+\\.\\d+|✅|📋|🚧)`, 'i'); - - let sectionEnd = roadmapContent.length; - let fenceChar: string | null = null; - let fenceLen = 0; - let charOffset = 0; - for (const line of restContent.split('\n')) { - const fenceMatch = line.match(/^\s{0,3}((?:`{3,}|~{3,}))(.*)/); - if (fenceMatch) { - const char = fenceMatch[1][0]; - const len = fenceMatch[1].length; - const trailing = fenceMatch[2] || ''; - if (!fenceChar) { - fenceChar = char; - fenceLen = len; - } else if (char === fenceChar && len >= fenceLen && /^\s*$/.test(trailing)) { - fenceChar = null; - fenceLen = 0; - } - } else if (!fenceChar && nextMilestonePattern.test(line)) { - sectionEnd = sectionStart + sectionMatch[0].length + charOffset; - break; - } - charOffset += line.length + 1; - } - - const currentSection = roadmapContent.slice(sectionStart, sectionEnd); - roadmap = currentSection; - } - } - - const phasePattern = /#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+([\w][\w.-]*)\s*:/gi; - let m: RegExpExecArray | null; - while ((m = phasePattern.exec(roadmap)) !== null) { - milestonePhaseNums.add(m[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('-')); - const numericRe = roadmapUsesHyphenedIds - ? /^0*(\d+(?:-0*\d+)*[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 = dirName.replace(/^[A-Z]{1,6}-(?=\d)/i, ''); - 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; -} +// MilestoneInfo, MilestonePhaseFilter, getMilestoneInfo, getMilestonePhaseFilter +// — all re-exported from roadmap-parser.cjs via roadmapParserModule above. // ─── Phase file helpers ────────────────────────────────────────────────────── diff --git a/src/roadmap-parser.cts b/src/roadmap-parser.cts new file mode 100644 index 000000000..9e9c15cba --- /dev/null +++ b/src/roadmap-parser.cts @@ -0,0 +1,469 @@ +/** + * 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. core.cjs re-exports every symbol here + * under its own `export =` object so existing consumers are unaffected. + * + * New imports should pull roadmap-parser helpers from roadmap-parser.cjs directly. + * + * Dependencies (leaf modules only — no core.cjs, 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 } = 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'; + +// ─── 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, '') + .replace(/^#{2,4}\s*Phase\s+[\w][\w.-]*\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 rest = content.slice(headingStart + headingText.length); + const stopPattern = new RegExp( + `^#{1,${level}}\\s+(?!Phase\\s+\\S)(?:.*v\\d+\\.\\d+|✅|📋|🚧)`, + 'i', + ); + let end = content.length; + let fc: string | null = null; + let fl = 0; + let off = 0; + for (const line of rest.split('\n')) { + const fm = line.match(/^\s{0,3}((?:`{3,}|~{3,}))(.*)/); + if (fm) { + const ch = fm[1][0]; + const ln = fm[1].length; + const trailing = fm[2] || ''; + if (!fc) { + fc = ch; + fl = ln; + } else if (ch === fc && ln >= fl && /^\s*$/.test(trailing)) { + fc = null; + fl = 0; + } + } else if (!fc && stopPattern.test(line)) { + end = headingStart + headingText.length + off; + break; + } + off += line.length + 1; + } + return end; + }; + + 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, '') + .replace(/^#{2,4}\s*Phase\s+[\w][\w.-]*\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 getRoadmapPhaseInternal(cwd: string, phaseNum: unknown): RoadmapPhaseResult | null { + if (!phaseNum) 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 phasePattern = new RegExp( + `#{2,4}\\s*(?:\\[[^\\]]+\\]\\s*)?Phase\\s+${phaseMarkdownRegexSource(phaseNum)}:\\s*([^\\n]+)`, + 'i' + ); + const headerMatch = content.match(phasePattern); + if (!headerMatch) return null; + + const phaseName = headerMatch[1].trim(); + const headerIndex = headerMatch.index!; + const restOfContent = content.slice(headerIndex); + const nextHeaderMatch = restOfContent.match(/\n#{2,4}\s+(?:\[[^\]]+\]\s*)?Phase\s+[\w]/i); + const sectionEnd = nextHeaderMatch ? headerIndex + nextHeaderMatch.index! : content.length; + const section = content.slice(headerIndex, sectionEnd).trim(); + + const goalMatch = section.match(/\*\*Goal(?:\*\*:|\*?\*?:\*\*)\s*([^\n]+)/i); + const goal = goalMatch ? goalMatch[1].trim() : null; + + return { + found: true, + // eslint-disable-next-line @typescript-eslint/no-base-to-string + phase_number: String(phaseNum), + phase_name: phaseName, + goal, + section, + }; + } 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. + */ +function getMilestonePhaseFilter(cwd: string, versionOverride?: 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) { + console.warn( + '[gsd] Deprecated: free-form ROADMAP.md detected (no versioned milestone headings). ' + + 'Set phase_id_convention in config.json to suppress this warning.' + ); + } + + 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 restContent = roadmapContent.slice(sectionStart + sectionMatch[0].length); + const nextMilestonePattern = new RegExp(`^#{1,${headingLevel}}\\s+(?!Phase\\s+\\S)(?:.*v\\d+\\.\\d+|✅|📋|🚧)`, 'i'); + + let sectionEnd = roadmapContent.length; + let fenceChar: string | null = null; + let fenceLen = 0; + let charOffset = 0; + for (const line of restContent.split('\n')) { + const fenceMatch = line.match(/^\s{0,3}((?:`{3,}|~{3,}))(.*)/); + if (fenceMatch) { + const char = fenceMatch[1][0]; + const len = fenceMatch[1].length; + const trailing = fenceMatch[2] || ''; + if (!fenceChar) { + fenceChar = char; + fenceLen = len; + } else if (char === fenceChar && len >= fenceLen && /^\s*$/.test(trailing)) { + fenceChar = null; + fenceLen = 0; + } + } else if (!fenceChar && nextMilestonePattern.test(line)) { + sectionEnd = sectionStart + sectionMatch[0].length + charOffset; + break; + } + charOffset += line.length + 1; + } + + const currentSection = roadmapContent.slice(sectionStart, sectionEnd); + roadmap = currentSection; + } + } + + const phasePattern = /#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+([\w][\w.-]*)\s*:/gi; + let m: RegExpExecArray | null; + while ((m = phasePattern.exec(roadmap)) !== null) { + milestonePhaseNums.add(m[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('-')); + const numericRe = roadmapUsesHyphenedIds + ? /^0*(\d+(?:-0*\d+)*[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 = dirName.replace(/^[A-Z]{1,6}-(?=\d)/i, ''); + 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, +}; diff --git a/src/roadmap.cts b/src/roadmap.cts index 47a021b1c..b5335fa62 100644 --- a/src/roadmap.cts +++ b/src/roadmap.cts @@ -10,7 +10,10 @@ import fs from 'node:fs'; import path from 'node:path'; // eslint-disable-next-line @typescript-eslint/no-require-imports import core = require('./core.cjs'); -const { escapeRegex, normalizePhaseName, phaseMarkdownRegexSource, phaseMarkdownRegexSourceExact, output, error, findPhaseInternal, stripShippedMilestones, extractCurrentMilestone, replaceInCurrentMilestone, phaseTokenMatches } = core; +const { escapeRegex, normalizePhaseName, phaseMarkdownRegexSource, phaseMarkdownRegexSourceExact, output, error, findPhaseInternal, phaseTokenMatches } = core; +// eslint-disable-next-line @typescript-eslint/no-require-imports +import roadmapParserModule = require('./roadmap-parser.cjs'); +const { stripShippedMilestones, extractCurrentMilestone, replaceInCurrentMilestone } = roadmapParserModule; import { platformWriteSync } from './shell-command-projection.cjs'; // eslint-disable-next-line @typescript-eslint/no-require-imports import planningWorkspace = require('./planning-workspace.cjs'); diff --git a/tests/roadmap-parser.test.cjs b/tests/roadmap-parser.test.cjs new file mode 100644 index 000000000..36c973fca --- /dev/null +++ b/tests/roadmap-parser.test.cjs @@ -0,0 +1,562 @@ +/** + * roadmap-parser.cjs — unit tests + * + * Covers the 6 functions extracted from core.cjs per ADR-857 rollout + * phase 2b (#870): stripShippedMilestones, extractCurrentMilestone, + * replaceInCurrentMilestone, getRoadmapPhaseInternal, getMilestoneInfo, + * getMilestonePhaseFilter. + * + * Includes: + * - Behavioral tests against realistic ROADMAP.md content + * - Adversarial fixtures (malformed frontmatter, unclosed fences, + * headings inside fences, unicode headings, repeated/decimal phase + * IDs, mixed CRLF/LF) + * - Shim-identity assertions verifying core.cjs re-exports are the + * same function objects as roadmap-parser.cjs exports + */ + +'use strict'; + +const { describe, test, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const roadmapParser = require('../gsd-core/bin/lib/roadmap-parser.cjs'); +const core = require('../gsd-core/bin/lib/core.cjs'); +const { createTempProject, cleanup } = require('./helpers.cjs'); + +const { + stripShippedMilestones, + extractCurrentMilestone, + replaceInCurrentMilestone, + getRoadmapPhaseInternal, + getMilestoneInfo, + getMilestonePhaseFilter, +} = roadmapParser; + +// ─── helpers ───────────────────────────────────────────────────────────────── + +function writeRoadmap(tmpDir, content) { + fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), content); +} + +function writeState(tmpDir, fields) { + const lines = Object.entries(fields).map(([k, v]) => `${k}: ${v}`); + fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), lines.join('\n') + '\n'); +} + +// ─── Shim-identity assertions ───────────────────────────────────────────────── + +describe('roadmap-parser: shim-identity — core.cjs re-exports same function objects', () => { + test('core.extractCurrentMilestone === roadmapParser.extractCurrentMilestone', () => { + assert.strictEqual(core.extractCurrentMilestone, roadmapParser.extractCurrentMilestone); + }); + test('core.stripShippedMilestones === roadmapParser.stripShippedMilestones', () => { + assert.strictEqual(core.stripShippedMilestones, roadmapParser.stripShippedMilestones); + }); + test('core.replaceInCurrentMilestone === roadmapParser.replaceInCurrentMilestone', () => { + assert.strictEqual(core.replaceInCurrentMilestone, roadmapParser.replaceInCurrentMilestone); + }); + test('core.getRoadmapPhaseInternal === roadmapParser.getRoadmapPhaseInternal', () => { + assert.strictEqual(core.getRoadmapPhaseInternal, roadmapParser.getRoadmapPhaseInternal); + }); + test('core.getMilestoneInfo === roadmapParser.getMilestoneInfo', () => { + assert.strictEqual(core.getMilestoneInfo, roadmapParser.getMilestoneInfo); + }); + test('core.getMilestonePhaseFilter === roadmapParser.getMilestonePhaseFilter', () => { + assert.strictEqual(core.getMilestonePhaseFilter, roadmapParser.getMilestonePhaseFilter); + }); +}); + +// ─── stripShippedMilestones ─────────────────────────────────────────────────── + +describe('roadmap-parser: stripShippedMilestones', () => { + test('strips a single
block', () => { + const input = 'before\n
\nsome shipped content\n
\nafter'; + const result = stripShippedMilestones(input); + assert.ok(!result.includes('
'), 'details tag should be removed'); + assert.ok(!result.includes('shipped content'), 'shipped content should be removed'); + assert.ok(result.includes('before'), 'before content preserved'); + assert.ok(result.includes('after'), 'after content preserved'); + }); + + test('strips multiple
blocks', () => { + const input = '
\nA\n
\nmiddle\n
\nB\n
\nend'; + const result = stripShippedMilestones(input); + assert.ok(result.includes('middle'), 'middle content preserved'); + assert.ok(result.includes('end'), 'end content preserved'); + assert.ok(!result.includes('
'), 'all details tags removed'); + }); + + test('returns unchanged string when no
blocks', () => { + const input = '## v1.0: Launch\n### Phase 1: Setup\n**Goal:** init\n'; + assert.strictEqual(stripShippedMilestones(input), input); + }); + + test('handles case-insensitive
tags', () => { + const input = '
\nclosed content\n
\nafter'; + const result = stripShippedMilestones(input); + assert.ok(!result.includes('closed content'), 'content removed'); + assert.ok(result.includes('after'), 'after content preserved'); + }); +}); + +// ─── extractCurrentMilestone ────────────────────────────────────────────────── + +describe('roadmap-parser: extractCurrentMilestone', () => { + let tmpDir; + + beforeEach(() => { tmpDir = createTempProject(); }); + afterEach(() => { cleanup(tmpDir); }); + + test('no cwd — strips
only', () => { + const input = '
\nshipped\n
\n## v2.0: Next\n### Phase 1: Setup\n'; + const result = extractCurrentMilestone(input); + assert.ok(!result.includes('
'), 'details stripped'); + assert.ok(result.includes('v2.0'), 'version heading preserved'); + }); + + test('reads milestone from STATE.md and extracts that section', () => { + writeState(tmpDir, { milestone: 'v2.0' }); + const content = [ + '
', + 'v1.0', + '### Phase 1: Old', + '
', + '## v2.0: Current', + '### Phase 2-01: Setup', + '**Goal:** build', + ].join('\n'); + writeRoadmap(tmpDir, content); + + const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8'); + const result = extractCurrentMilestone(roadmap, tmpDir); + assert.ok(result.includes('v2.0'), 'current milestone section included'); + assert.ok(!result.includes('Old'), 'shipped milestone section excluded'); + }); + + test('falls back to 🚧 marker when STATE.md has no milestone field', () => { + writeState(tmpDir, { phase: 'some-phase' }); + const content = [ + '## 🚧 **v2.0 Work in Progress**', + '### Phase 1: Active', + '**Goal:** do work', + ].join('\n'); + writeRoadmap(tmpDir, content); + + const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8'); + const result = extractCurrentMilestone(roadmap, tmpDir); + assert.ok(result.includes('v2.0'), 'inferred v2.0 milestone section included'); + }); + + test('strips shipped milestones when no STATE.md and no 🚧 marker', () => { + const content = [ + '
', + 'v1.0 done', + '### Phase 1: Done', + '
', + '## v2.0: Next (no WIP marker)', + '### Phase 2: Future', + ].join('\n'); + + const result = extractCurrentMilestone(content); + assert.ok(!result.includes('
'), 'details stripped'); + assert.ok(result.includes('v2.0'), 'remaining content preserved'); + }); + + test('unicode heading — emoji-prefixed milestone', () => { + writeState(tmpDir, { milestone: 'v3.0' }); + const content = [ + '## ✅ v1.0: Shipped', + '## 🚧 v3.0: In Progress', + '### Phase 3-01: Unicode Héros', + '**Goal:** тест', + ].join('\n'); + writeRoadmap(tmpDir, content); + + const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8'); + const result = extractCurrentMilestone(roadmap, tmpDir); + assert.ok(result.includes('v3.0'), 'v3.0 heading included'); + assert.ok(result.includes('Unicode'), 'unicode phase name included'); + }); + + test('CRLF line endings are handled', () => { + writeState(tmpDir, { milestone: 'v1.0' }); + const content = '## v1.0: CRLF\r\n### Phase 1: Setup\r\n**Goal:** crlf goal\r\n'; + writeRoadmap(tmpDir, content); + const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8'); + const result = extractCurrentMilestone(roadmap, tmpDir); + assert.ok(result.includes('v1.0'), 'section found despite CRLF'); + }); + + test('heading inside fenced code block not confused for milestone boundary', () => { + writeState(tmpDir, { milestone: 'v1.0' }); + const content = [ + '## v1.0: Current Milestone', + '### Phase 1: Real Phase', + '**Goal:** real goal', + '```markdown', + '## v2.0: Fake Heading Inside Fence', + '```', + '### Phase 2: Also Real', + '**Goal:** also real', + ].join('\n'); + writeRoadmap(tmpDir, content); + const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8'); + const result = extractCurrentMilestone(roadmap, tmpDir); + // The section should include Phase 1 content; the fenced heading should not terminate section early + assert.ok(result.includes('real goal'), 'phase 1 content included'); + assert.ok(result.includes('Also Real'), 'phase 2 content also included'); + }); +}); + +// ─── replaceInCurrentMilestone ──────────────────────────────────────────────── + +describe('roadmap-parser: replaceInCurrentMilestone', () => { + test('replaces in content after last
when present', () => { + const content = '
\nold\n
\n**Plans:** 0/1 plans'; + const result = replaceInCurrentMilestone(content, /0\/1 plans/, '1/1 plans complete'); + assert.ok(result.includes('1/1 plans complete'), 'replacement applied after
'); + assert.ok(result.includes('
'), 'details block untouched'); + }); + + test('replaces anywhere when no
present', () => { + const content = '**Plans:** 0/1 plans'; + const result = replaceInCurrentMilestone(content, /0\/1 plans/, '1/1 plans complete'); + assert.strictEqual(result, '**Plans:** 1/1 plans complete'); + }); + + test('does not replace in shipped sections', () => { + const content = '
\n**Plans:** 0/1 plans\n
\n## v2.0\n**Plans:** 0/1 plans'; + const result = replaceInCurrentMilestone(content, /0\/1 plans/, '1/1 plans complete'); + // Only the SECOND occurrence (after
) should be replaced + assert.ok(result.includes('
\n**Plans:** 0/1 plans\n
'), 'shipped section unchanged'); + assert.ok(result.includes('## v2.0\n**Plans:** 1/1 plans complete'), 'current section updated'); + }); +}); + +// ─── getRoadmapPhaseInternal ────────────────────────────────────────────────── + +describe('roadmap-parser: getRoadmapPhaseInternal', () => { + let tmpDir; + + beforeEach(() => { tmpDir = createTempProject(); }); + afterEach(() => { cleanup(tmpDir); }); + + test('returns null when ROADMAP.md missing', () => { + const result = getRoadmapPhaseInternal(tmpDir, '1'); + assert.strictEqual(result, null); + }); + + test('returns null when phaseNum is falsy', () => { + writeRoadmap(tmpDir, '### Phase 1: Foo\n**Goal:** bar\n'); + assert.strictEqual(getRoadmapPhaseInternal(tmpDir, null), null); + assert.strictEqual(getRoadmapPhaseInternal(tmpDir, ''), null); + assert.strictEqual(getRoadmapPhaseInternal(tmpDir, 0), null); + }); + + test('finds a phase by number', () => { + writeRoadmap(tmpDir, [ + '## v1.0: Current', + '### Phase 1: Foundation', + '**Goal:** Set up infrastructure', + '', + '### Phase 2: API', + '**Goal:** Build the API', + ].join('\n')); + + const result = getRoadmapPhaseInternal(tmpDir, '1'); + assert.ok(result !== null, 'result should not be null'); + assert.strictEqual(result.found, true); + assert.strictEqual(result.phase_name, 'Foundation'); + assert.strictEqual(result.goal, 'Set up infrastructure'); + }); + + test('returns null for missing phase number', () => { + writeRoadmap(tmpDir, '### Phase 1: Foo\n**Goal:** bar\n'); + const result = getRoadmapPhaseInternal(tmpDir, '99'); + assert.strictEqual(result, null); + }); + + test('finds milestone-prefixed phase ID (e.g. 2-01)', () => { + writeState(tmpDir, { milestone: 'v2.0' }); + writeRoadmap(tmpDir, [ + '## v2.0: Current', + '### Phase 2-01: Alpha', + '**Goal:** first alpha phase', + '', + '### Phase 2-02: Beta', + '**Goal:** beta phase', + ].join('\n')); + + const result = getRoadmapPhaseInternal(tmpDir, '2-01'); + assert.ok(result !== null); + assert.strictEqual(result.found, true); + assert.strictEqual(result.phase_name, 'Alpha'); + assert.strictEqual(result.goal, 'first alpha phase'); + }); + + test('decimal phase ID (e.g. 1.5)', () => { + writeRoadmap(tmpDir, [ + '## v1.0: Current', + '### Phase 1.5: Intermediate', + '**Goal:** interstitial step', + ].join('\n')); + + const result = getRoadmapPhaseInternal(tmpDir, '1.5'); + assert.ok(result !== null); + assert.strictEqual(result.phase_name, 'Intermediate'); + }); +}); + +// ─── getMilestoneInfo ───────────────────────────────────────────────────────── + +describe('roadmap-parser: getMilestoneInfo', () => { + let tmpDir; + + beforeEach(() => { tmpDir = createTempProject(); }); + afterEach(() => { cleanup(tmpDir); }); + + test('returns default when ROADMAP.md missing', () => { + const info = getMilestoneInfo(tmpDir); + assert.strictEqual(info.version, 'v1.0'); + assert.strictEqual(info.name, 'milestone'); + }); + + test('reads version from STATE.md and heading name', () => { + writeState(tmpDir, { milestone: 'v2.0' }); + writeRoadmap(tmpDir, '## v2.0: The Big Launch\n### Phase 1: Setup\n'); + const info = getMilestoneInfo(tmpDir); + assert.strictEqual(info.version, 'v2.0'); + assert.match(info.name, /Big Launch/); + }); + + test('falls back to 🚧 WIP marker when STATE.md has no milestone', () => { + writeRoadmap(tmpDir, '## 🚧 **v1.5 Work In Progress**\n### Phase 1: Do stuff\n'); + const info = getMilestoneInfo(tmpDir); + assert.strictEqual(info.version, 'v1.5'); + assert.match(info.name, /Work In Progress/i); + }); + + test('extracts from heading when no STATE.md and no WIP marker', () => { + writeRoadmap(tmpDir, [ + '## v3.0: Future Milestone', + '### Phase 1: Not started', + ].join('\n')); + const info = getMilestoneInfo(tmpDir); + assert.strictEqual(info.version, 'v3.0'); + assert.match(info.name, /Future Milestone/); + }); + + test('skips completed ✅ milestones', () => { + writeRoadmap(tmpDir, [ + '## ✅ v1.0: Shipped Already', + '## v2.0: Next Up', + ].join('\n')); + const info = getMilestoneInfo(tmpDir); + // Should not use the ✅-prefixed version as the current milestone + assert.strictEqual(info.version, 'v2.0'); + }); +}); + +// ─── getMilestonePhaseFilter ────────────────────────────────────────────────── + +describe('roadmap-parser: getMilestonePhaseFilter', () => { + let tmpDir; + + beforeEach(() => { tmpDir = createTempProject(); }); + afterEach(() => { cleanup(tmpDir); }); + + test('returns passAll (phaseCount=0) when ROADMAP.md missing', () => { + const filter = getMilestonePhaseFilter(tmpDir); + assert.strictEqual(filter.phaseCount, 0); + assert.strictEqual(filter('anything'), true); + }); + + test('basic milestone phase filter — matches dirs by phase number', () => { + writeRoadmap(tmpDir, [ + '## v1.0: Launch', + '### Phase 1: Setup', + '**Goal:** setup', + '', + '### Phase 2: Build', + '**Goal:** build', + ].join('\n')); + + const filter = getMilestonePhaseFilter(tmpDir); + assert.strictEqual(filter.phaseCount, 2); + assert.strictEqual(filter('01-setup'), true, '01-setup matches Phase 1'); + assert.strictEqual(filter('02-build'), true, '02-build matches Phase 2'); + assert.strictEqual(filter('03-deploy'), false, '03-deploy not in milestone'); + }); + + test('milestone-prefixed phase IDs (e.g. 2-01)', () => { + writeState(tmpDir, { milestone: 'v2.0' }); + writeRoadmap(tmpDir, [ + '## v2.0: Current', + '### Phase 2-01: Alpha', + '### Phase 2-02: Beta', + ].join('\n')); + + const filter = getMilestonePhaseFilter(tmpDir); + assert.strictEqual(filter('02-01-alpha'), true, '02-01 matches Phase 2-01'); + assert.strictEqual(filter('02-02-beta'), true, '02-02 matches Phase 2-02'); + assert.strictEqual(filter('02-03-other'), false, '02-03 not in milestone'); + }); + + test('versionOverride uses specified version slice', () => { + writeRoadmap(tmpDir, [ + '## v1.0: Old', + '### Phase 1: Old Phase', + '', + '## v2.0: Current', + '### Phase 2: New Phase', + ].join('\n')); + + const filter = getMilestonePhaseFilter(tmpDir, 'v2.0'); + assert.strictEqual(filter('02-new-phase'), true, 'phase 2 in v2.0 slice'); + assert.strictEqual(filter('01-old-phase'), false, 'phase 1 not in v2.0 slice'); + }); + + test('missingExplicitVersion set when version not found in versioned roadmap', () => { + writeRoadmap(tmpDir, [ + '## v1.0: Only Milestone', + '### Phase 1: Foo', + ].join('\n')); + + const filter = getMilestonePhaseFilter(tmpDir, 'v9.9'); + assert.strictEqual(filter.missingExplicitVersion, true, 'missingExplicitVersion should be true'); + assert.strictEqual(filter.phaseCount, 0); + }); + + test('zero-padded phase IDs match unpadded dirs and vice versa', () => { + writeRoadmap(tmpDir, [ + '## v1.0: Padded Test', + '### Phase 01: Setup', + '### Phase 02: Build', + ].join('\n')); + + const filter = getMilestonePhaseFilter(tmpDir); + assert.strictEqual(filter('1-setup'), true, 'unpadded dir matches padded Phase 01'); + assert.strictEqual(filter('02-build'), true, 'padded dir matches padded Phase 02'); + }); + + test('decimal phase IDs in ROADMAP filter correctly', () => { + writeRoadmap(tmpDir, [ + '## v1.0: Decimal Test', + '### Phase 1.5: Interstitial', + '### Phase 2: Normal', + ].join('\n')); + + const filter = getMilestonePhaseFilter(tmpDir); + assert.ok(filter.phaseCount >= 1, 'at least one phase found'); + // Decimal phase IDs are non-numeric so filter should handle them + assert.strictEqual(filter('1.5-interstitial'), true, 'decimal phase dir matches'); + }); + + test('repeated phase IDs — deduplication (no double count)', () => { + writeRoadmap(tmpDir, [ + '## v1.0: Repeated', + '### Phase 1: First', + '### Phase 1: Duplicate heading', + ].join('\n')); + + const filter = getMilestonePhaseFilter(tmpDir); + // Phase 1 appears twice but should only count once + assert.strictEqual(filter.phaseCount, 1, 'deduplication: only 1 unique phase'); + }); + + test('adversarial: characterizes current fence-blind behavior for backtick fence (pending #875)', () => { + writeRoadmap(tmpDir, [ + '## v1.0: Real', + '```', + '### Phase 999: Fake Phase Inside Fence', + '```', + '### Phase 1: Real Phase', + '**Goal:** real', + ].join('\n')); + + const filter = getMilestonePhaseFilter(tmpDir); + // KNOWN BUG #875: getMilestonePhaseFilter is fence-blind — phase headings inside + // fenced code blocks are incorrectly parsed as real phases. Flip to false when #875 is fixed. + assert.strictEqual(filter('01-real'), true, 'real phase matches'); + assert.strictEqual(filter('999-fake'), true, 'characterization: getMilestonePhaseFilter is currently fence-blind (KNOWN BUG #875) — flip to false when #875 is fixed'); + }); + + test('adversarial: unclosed fence block — does not crash', () => { + writeRoadmap(tmpDir, [ + '## v1.0: Unclosed', + '```', + '### Phase 1: Inside unclosed fence', + '**Goal:** unreachable', + // Intentionally no closing ``` — adversarial fixture + ].join('\n')); + + // Should not throw regardless of fence parsing behavior + let filter; + assert.doesNotThrow(() => { + filter = getMilestonePhaseFilter(tmpDir); + }, 'unclosed fence should not throw'); + assert.ok(typeof filter === 'function', 'filter is a function'); + }); + + test('adversarial: characterizes current fence-blind behavior for tilde fence (pending #875)', () => { + writeRoadmap(tmpDir, [ + '## v1.0: Tilde', + '~~~', + '### Phase 999: Fake', + '~~~', + '### Phase 1: Real', + ].join('\n')); + + const filter = getMilestonePhaseFilter(tmpDir); + // KNOWN BUG #875: getMilestonePhaseFilter is fence-blind — phase headings inside + // tilde-fenced code blocks are incorrectly parsed as real phases. Flip to false when #875 is fixed. + assert.strictEqual(filter('01-real'), true, 'real phase matches despite tilde fence'); + assert.strictEqual(filter('999-fake'), true, 'characterization: getMilestonePhaseFilter is currently fence-blind (KNOWN BUG #875) — flip to false when #875 is fixed'); + }); + + test('adversarial: CRLF line endings in roadmap', () => { + const crlf = '## v1.0: CRLF\r\n### Phase 1: Setup\r\n### Phase 2: Build\r\n'; + writeRoadmap(tmpDir, crlf); + let filter; + assert.doesNotThrow(() => { filter = getMilestonePhaseFilter(tmpDir); }); + assert.ok(filter.phaseCount >= 1, 'phases found despite CRLF'); + }); + + test('adversarial: mixed CRLF and LF in same file', () => { + const mixed = '## v1.0: Mixed\r\n### Phase 1: A\n### Phase 2: B\r\n### Phase 3: C\n'; + writeRoadmap(tmpDir, mixed); + let filter; + assert.doesNotThrow(() => { filter = getMilestonePhaseFilter(tmpDir); }); + assert.ok(filter.phaseCount >= 1, 'phases found in mixed CRLF/LF'); + }); + + test('adversarial: unicode headings', () => { + writeState(tmpDir, { milestone: 'v1.0' }); + writeRoadmap(tmpDir, [ + '## v1.0: 日本語マイルストーン', + '### Phase 1: Héros Réalité', + '### Phase 2: Тест', + ].join('\n')); + + let filter; + assert.doesNotThrow(() => { filter = getMilestonePhaseFilter(tmpDir); }); + assert.strictEqual(filter.phaseCount, 2, '2 unicode phases found'); + assert.strictEqual(filter('01-setup'), true, 'phase 1 dir matches'); + }); + + test('adversarial: bracket-prefixed phase heading ### [GSD] Phase 2-01:', () => { + writeState(tmpDir, { milestone: 'v2.0' }); + writeRoadmap(tmpDir, [ + '## v2.0: Bracket', + '### [GSD] Phase 2-01: Setup', + '### [GSD] Phase 2-02: Build', + ].join('\n')); + + const filter = getMilestonePhaseFilter(tmpDir); + assert.strictEqual(filter('02-01-setup'), true, 'bracket-prefixed phase 2-01 matched'); + assert.strictEqual(filter('02-02-build'), true, 'bracket-prefixed phase 2-02 matched'); + }); +});