refactor(#870): extract ROADMAP.md parsing into roadmap-parser.cts (#876)

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 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-06-08 12:34:17 -04:00
committed by GitHub
parent a480510f54
commit fa1118afa7
10 changed files with 1053 additions and 428 deletions

1
.gitignore vendored
View File

@@ -129,6 +129,7 @@ build/
/gsd-core/bin/lib/core.cjs /gsd-core/bin/lib/core.cjs
/gsd-core/bin/lib/io.cjs /gsd-core/bin/lib/io.cjs
/gsd-core/bin/lib/phase-id.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/drift.cjs
/gsd-core/bin/lib/cjs-command-router-adapter.cjs /gsd-core/bin/lib/cjs-command-router-adapter.cjs
/gsd-core/bin/lib/phase-command-router.cjs /gsd-core/bin/lib/phase-command-router.cjs

View File

@@ -112,6 +112,9 @@ Primary installer for all runtimes. Single production file: `bin/install.js` (ge
### I/O Module ### 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`). 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] ### 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. 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.

View File

@@ -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 | | `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 | | `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) | | `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`) | | `planning-workspace.cjs` | Planning seam (`planningDir`, `planningPaths`, active workstream routing, `.planning/.lock`) |
| `state.cjs` | STATE.md parsing, updating, progression, metrics | | `state.cjs` | STATE.md parsing, updating, progression, metrics |
| `phase.cjs` | Phase directory operations, decimal numbering, plan indexing | | `phase.cjs` | Phase directory operations, decimal numbering, plan indexing |

View File

@@ -324,6 +324,7 @@
"research-store.cjs", "research-store.cjs",
"review-reviewer-selection.cjs", "review-reviewer-selection.cjs",
"roadmap-command-router.cjs", "roadmap-command-router.cjs",
"roadmap-parser.cjs",
"roadmap-upgrade.cjs", "roadmap-upgrade.cjs",
"roadmap.cjs", "roadmap.cjs",
"runtime-artifact-layout.cjs", "runtime-artifact-layout.cjs",

View File

@@ -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`. 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 | | `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 | | `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-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-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 | | `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) | | `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) |

View File

@@ -91,6 +91,7 @@ export default tseslint.config(
'gsd-core/bin/lib/core.cjs', 'gsd-core/bin/lib/core.cjs',
'gsd-core/bin/lib/io.cjs', 'gsd-core/bin/lib/io.cjs',
'gsd-core/bin/lib/phase-id.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/drift.cjs',
'gsd-core/bin/lib/cjs-command-router-adapter.cjs', 'gsd-core/bin/lib/cjs-command-router-adapter.cjs',
'gsd-core/bin/lib/phase-command-router.cjs', 'gsd-core/bin/lib/phase-command-router.cjs',

View File

@@ -17,6 +17,9 @@ const { output, error, ERROR_REASON, setJsonErrorMode, getJsonErrorMode, GSD_TEM
import phaseIdModule = require('./phase-id.cjs'); import phaseIdModule = require('./phase-id.cjs');
const { escapeRegex, normalizePhaseName, getMilestoneFromPhaseId, getPhaseDirFromPhaseId, phaseMarkdownRegexSource, phaseMarkdownRegexSourceExact, comparePhaseNum, extractPhaseToken, phaseTokenMatches } = phaseIdModule; const { escapeRegex, normalizePhaseName, getMilestoneFromPhaseId, getPhaseDirFromPhaseId, phaseMarkdownRegexSource, phaseMarkdownRegexSourceExact, comparePhaseNum, extractPhaseToken, phaseTokenMatches } = phaseIdModule;
// eslint-disable-next-line @typescript-eslint/no-require-imports // 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'); 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; 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'; 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; return results;
} }
// ─── Roadmap milestone scoping ─────────────────────────────────────────────── // ─── Roadmap milestone scoping (re-exported from roadmap-parser.cjs) ──────────
// stripShippedMilestones, extractCurrentMilestone, replaceInCurrentMilestone,
/** // getRoadmapPhaseInternal, getMilestoneInfo, getMilestonePhaseFilter
* Strip shipped milestone content wrapped in <details> blocks. // — all imported via `roadmapParserModule` above; internal callers use the destructured bindings.
*/
function stripShippedMilestones(content: string): string {
return content.replace(/<details>[\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(
`<summary[^>]*>([^<]*${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('<details');
if (detailsOpenIdx !== -1) {
const afterDetails = content.slice(detailsOpenIdx);
const closingMatch = afterDetails.match(/<\/details>/i);
const detailsEnd = closingMatch
? detailsOpenIdx + (closingMatch.index ?? 0) + '</details>'.length
: content.length;
const anyMilestoneOrDetails = /^#{1,3}\s+(?!Phase\s+\S)(?:.*v\d+\.\d+|✅|📋|🚧|🔄)|<details/im;
const firstMilestoneMatch = content.match(anyMilestoneOrDetails);
const preambleCutoff = firstMilestoneMatch ? firstMilestoneMatch.index! : detailsOpenIdx;
const preamble = content.slice(0, preambleCutoff)
.replace(/<details>[\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(/<details>[\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('</details>');
if (lastDetailsClose === -1) {
return content.replace(pattern, replacement);
}
const offset = lastDetailsClose + '</details>'.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;
}
}
// ─── Agent installation validation (#1371) ─────────────────────────────────── // ─── 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); return text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').substring(0, 60);
} }
interface MilestoneInfo { // MilestoneInfo, MilestonePhaseFilter, getMilestoneInfo, getMilestonePhaseFilter
version: string; // — all re-exported from roadmap-parser.cjs via roadmapParserModule above.
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<string>();
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(`<summary[^>]*>[^<]*${escapedVersion}[^<]*<\\/summary>`, 'i');
const summaryHit = roadmapContent.match(summaryPat);
if (summaryHit) {
const beforeSummary = roadmapContent.slice(0, summaryHit.index);
const detailsIdx = beforeSummary.lastIndexOf('<details');
if (detailsIdx !== -1) {
sectionMatch = null;
}
}
}
if (!sectionMatch) {
const hasVersionedMilestones = /^#{1,3}\s+(?!Phase\s+\S).*v\d+\.\d+/mi.test(roadmapContent);
const versionInSummary = new RegExp(`<summary[^>]*>[^<]*${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;
}
// ─── Phase file helpers ────────────────────────────────────────────────────── // ─── Phase file helpers ──────────────────────────────────────────────────────

469
src/roadmap-parser.cts Normal file
View File

@@ -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 <details> blocks.
*/
function stripShippedMilestones(content: string): string {
return content.replace(/<details>[\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(
`<summary[^>]*>([^<]*${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('<details');
if (detailsOpenIdx !== -1) {
const afterDetails = content.slice(detailsOpenIdx);
const closingMatch = afterDetails.match(/<\/details>/i);
const detailsEnd = closingMatch
? detailsOpenIdx + (closingMatch.index ?? 0) + '</details>'.length
: content.length;
const anyMilestoneOrDetails = /^#{1,3}\s+(?!Phase\s+\S)(?:.*v\d+\.\d+|✅|📋|🚧|🔄)|<details/im;
const firstMilestoneMatch = content.match(anyMilestoneOrDetails);
const preambleCutoff = firstMilestoneMatch ? firstMilestoneMatch.index! : detailsOpenIdx;
const preamble = content.slice(0, preambleCutoff)
.replace(/<details>[\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(/<details>[\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('</details>');
if (lastDetailsClose === -1) {
return content.replace(pattern, replacement);
}
const offset = lastDetailsClose + '</details>'.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<string>();
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(`<summary[^>]*>[^<]*${escapedVersion}[^<]*<\\/summary>`, 'i');
const summaryHit = roadmapContent.match(summaryPat);
if (summaryHit) {
const beforeSummary = roadmapContent.slice(0, summaryHit.index);
const detailsIdx = beforeSummary.lastIndexOf('<details');
if (detailsIdx !== -1) {
sectionMatch = null;
}
}
}
if (!sectionMatch) {
const hasVersionedMilestones = /^#{1,3}\s+(?!Phase\s+\S).*v\d+\.\d+/mi.test(roadmapContent);
const versionInSummary = new RegExp(`<summary[^>]*>[^<]*${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,
};

View File

@@ -10,7 +10,10 @@ import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
// eslint-disable-next-line @typescript-eslint/no-require-imports // eslint-disable-next-line @typescript-eslint/no-require-imports
import core = require('./core.cjs'); 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'; import { platformWriteSync } from './shell-command-projection.cjs';
// eslint-disable-next-line @typescript-eslint/no-require-imports // eslint-disable-next-line @typescript-eslint/no-require-imports
import planningWorkspace = require('./planning-workspace.cjs'); import planningWorkspace = require('./planning-workspace.cjs');

View File

@@ -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 <details> block', () => {
const input = 'before\n<details>\nsome shipped content\n</details>\nafter';
const result = stripShippedMilestones(input);
assert.ok(!result.includes('<details>'), '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 <details> blocks', () => {
const input = '<details>\nA\n</details>\nmiddle\n<details>\nB\n</details>\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('<details>'), 'all details tags removed');
});
test('returns unchanged string when no <details> blocks', () => {
const input = '## v1.0: Launch\n### Phase 1: Setup\n**Goal:** init\n';
assert.strictEqual(stripShippedMilestones(input), input);
});
test('handles case-insensitive <DETAILS> tags', () => {
const input = '<DETAILS>\nclosed content\n</DETAILS>\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 <details> only', () => {
const input = '<details>\nshipped\n</details>\n## v2.0: Next\n### Phase 1: Setup\n';
const result = extractCurrentMilestone(input);
assert.ok(!result.includes('<details>'), '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 = [
'<details>',
'<summary>v1.0</summary>',
'### Phase 1: Old',
'</details>',
'## 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 = [
'<details>',
'<summary>v1.0 done</summary>',
'### Phase 1: Done',
'</details>',
'## v2.0: Next (no WIP marker)',
'### Phase 2: Future',
].join('\n');
const result = extractCurrentMilestone(content);
assert.ok(!result.includes('<details>'), '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 </details> when present', () => {
const content = '<details>\nold\n</details>\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 </details>');
assert.ok(result.includes('<details>'), 'details block untouched');
});
test('replaces anywhere when no </details> 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 = '<details>\n**Plans:** 0/1 plans\n</details>\n## v2.0\n**Plans:** 0/1 plans';
const result = replaceInCurrentMilestone(content, /0\/1 plans/, '1/1 plans complete');
// Only the SECOND occurrence (after </details>) should be replaced
assert.ok(result.includes('<details>\n**Plans:** 0/1 plans\n</details>'), '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');
});
});