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:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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',
|
||||
|
||||
435
src/core.cts
435
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 <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 & 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<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;
|
||||
}
|
||||
// MilestoneInfo, MilestonePhaseFilter, getMilestoneInfo, getMilestonePhaseFilter
|
||||
// — all re-exported from roadmap-parser.cjs via roadmapParserModule above.
|
||||
|
||||
// ─── Phase file helpers ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
469
src/roadmap-parser.cts
Normal file
469
src/roadmap-parser.cts
Normal 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,
|
||||
};
|
||||
@@ -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');
|
||||
|
||||
562
tests/roadmap-parser.test.cjs
Normal file
562
tests/roadmap-parser.test.cjs
Normal 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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user