Files
msd-core/src/roadmap.cts
Tom Boucher 77dcdda534 enhance(#4014): an unreadable directory must not report as an empty one (#4163)
* test(#4014): add failing-first coverage for unreadable-vs-empty directory scope (epic #3473 B4)

* fix(#4014): an unreadable directory must not report as an empty one (epic #3473 B4)

* test(#4014): update hardcoded generateSlugInternal closing-brace line after import shift

src/core-utils.cts's new #4014 import block shifted every subsequent line by
6, moving generateSlugInternal's real closing brace from line 193 to 199.
tests/slug-derivation-drift-guard.test.cjs's MAJOR-1 fixture hardcodes that
line number to plant a synthetic violation immediately after the function's
real body; the guard script itself locates the boundary dynamically via
brace-matching and needed no change.

* docs(#4014): document the unreadable-directory scope signal and add changeset

* docs(#4014): backfill changeset PR number to #4163

* test(#4014): kill pre-existing core-utils.cjs mutation-score gap, unrelated to this issue's diff

---------

Co-authored-by: sim <sim@local>
2026-09-02 08:12:57 -04:00

1561 lines
79 KiB
TypeScript

/**
* Roadmap — Roadmap parsing and update operations
*
* ADR-457 build-at-publish: the hand-written bin/lib/roadmap.cjs collapsed
* to a TypeScript source of truth. Behaviour is preserved byte-for-behaviour
* from the prior hand-written .cjs; only strict types are added.
*/
import fs from 'node:fs';
import path from 'node:path';
import { realClock } from './clock.cjs';
import { escapeRegex } from './pattern.cjs';
import { splitLines, detectEol, joinLines } from './text-lines.cjs';
// eslint-disable-next-line @typescript-eslint/no-require-imports
import ioMod = require('./io.cjs');
const { output, error, formatDiagnosticToken, declineNoOp } = ioMod;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import phaseIdMod = require('./phase-id.cjs');
const { normalizePhaseName, phaseMarkdownRegexSource, matchPhaseDirs, stripProjectCodePrefix, OPTIONAL_PHASE_TAG_SOURCE, roadmapPhaseLookupSources, phaseHeadingPrefixSrcFor, PHASE_HEADING_BASELINE, isSentinelPhaseId, scopeToPhase, bracketQualifiedKey, foldBracketId } = phaseIdMod;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import phaseLocatorMod = require('./phase-locator.cjs');
const { findPhaseInternal, listMilestonePhaseDirs, listAllPhaseDirs } = phaseLocatorMod;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import planningScopeMod = require('./planning-scope.cjs');
const { SCOPE } = planningScopeMod;
type Scope = planningScopeMod.Scope;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import roadmapParserModule = require('./roadmap-parser.cjs');
const { stripShippedMilestones, extractCurrentMilestone, extractCurrentMilestoneScoped, replaceInCurrentMilestone, listMilestoneHeadings, scanMilestonePhaseIds, collectTablePhaseRows } = roadmapParserModule;
import { tokenizeHeadings } from './markdown-sectionizer.cjs';
import { updateTableCell } from './markdown-table.cjs';
import { clampPercent } from './phase-lifecycle.cjs';
import { platformWriteSync } from './shell-command-projection.cjs';
// eslint-disable-next-line @typescript-eslint/no-require-imports
import planningWorkspace = require('./planning-workspace.cjs');
const { planningPaths, withPlanningLock, findContextMdIn, resolvePhaseIdConvention } = planningWorkspace;
// #3641: milestone-scope's convention resolution reads the project config
// (no cycle — config-loader does not import this module).
// eslint-disable-next-line @typescript-eslint/no-require-imports
import configLoaderForScope = require('./config-loader.cjs');
const { loadConfig: loadConfigForScope } = configLoaderForScope;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import scanPhasePlans = require('./plan-scan.cjs');
// eslint-disable-next-line @typescript-eslint/no-require-imports
import coreUtils = require('./core-utils.cjs');
const { countMatchedSummaries, findUnsummarizedPlans } = coreUtils;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import frontmatter = require('./frontmatter.cjs');
const { extractFrontmatter, parseMustHavesBlock } = frontmatter;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import verificationMod = require('./verification.cjs');
const { isPhaseComplete } = verificationMod;
// ─── Types ────────────────────────────────────────────────────────────────────
interface PhasePlansAndSummaries {
planCount: number;
summaryCount: number;
hasContext: boolean;
hasResearch: boolean;
/**
* #3885 (ADR-3473 §8.5): null when the phase directory's readdirSync
* succeeded OR was genuinely absent (ENOENT — a real "not built yet"
* answer, not an error). A message naming the phase directory when
* readdirSync failed for any other reason (EACCES/EIO/...), so an
* unreadable directory is never silently reported the same as one that was
* successfully read and genuinely has no CONTEXT.md.
*
* #4014 (epic #3473 B4): kept — `AnalyzePhase.context_read_error` (the
* shipped, tested `roadmap analyze` JSON field this feeds) is an existing
* consumer, so this field stays additive rather than being retired. `scope`
* below is the new, typed sibling signal; this field is now derived from
* it rather than owning its own readdirSync.
*/
contextReadError: string | null;
/** #4014 (epic #3473 B4): the `SCOPE` this phase dir's listing resolved to
* — `SCOPE.UNREADABLE` distinguishes a real read failure from a
* genuinely empty/absent phase dir (`SCOPE.COMPLETE`), which
* `contextReadError`/`hasContext` alone cannot. */
scope: Scope;
}
interface PhaseSearchResult {
found: boolean;
phase_number: string;
phase_name: string;
goal?: string | null;
mode?: string | null;
success_criteria?: string[];
section?: string;
error?: string;
message?: string;
}
interface TruthValue {
count: number;
text: string;
}
// ─── coerceTruthToString ──────────────────────────────────────────────────────
/**
* Coerce an arbitrary YAML scalar/object into a string for cross-cutting
* truth aggregation. Handles:
* - strings (passthrough)
* - numbers / booleans (String() coercion — issue #2770: bare YAML ints
* like `- 3` must be surfaced, not silently skipped)
* - kv-shaped objects from parseMustHavesBlock continuation kv (issue
* #2757) — extract the first meaningful string field
*
* Returns the empty string when no usable text can be derived; callers should
* skip empty results.
*/
function coerceTruthToString(t: unknown): string {
if (t === null || t === undefined) return '';
if (typeof t === 'string') return t;
if (typeof t === 'number' || typeof t === 'boolean' || typeof t === 'bigint') {
return String(t);
}
if (typeof t === 'object') {
// Prefer common title-bearing keys produced by parseMustHavesBlock. `statement` is the canonical
// truth/prohibition payload field — and the carrier of #1154's object-form backstop truth
// `{ statement, verification: backstop }`, so it leads (a non-inferable truth must be coerced by
// its statement, never dropped — the Hyrum backward-compat guard for the new marker).
for (const k of ['statement', 'title', 'text', 'name', 'rule', 'path', 'provides']) {
const v = (t as Record<string, unknown>)[k];
if (typeof v === 'string' && v.trim()) return v;
if (typeof v === 'number' || typeof v === 'boolean') return String(v);
}
}
return '';
}
// ─── countPhasePlansAndSummaries ──────────────────────────────────────────────
function countPhasePlansAndSummaries(phaseDir: string): PhasePlansAndSummaries {
const { planCount, summaryCount } = scanPhasePlans(phaseDir);
// hasContext and hasResearch are not plan-scan concerns — read the directory
// once and share the listing for all non-plan metadata that cmdRoadmapAnalyze needs.
//
// #4014 (epic #3473 B4): the listing + unreadable-vs-empty discrimination
// is now owned by findContextMdIn's directory-string form, retiring this
// function's own readdirSync try/catch (mirrors core-utils.cts's
// getPhaseFileStats / phase-locator.cts's listMilestonePhaseDirs
// SCOPE.UNREADABLE discriminator).
const { files: phaseFiles, scope } = findContextMdIn(phaseDir);
// #3885 (ADR-3473 §8.5): `contextReadError` stays additive for the shipped
// `AnalyzePhase.context_read_error` JSON field — derived from `scope`
// rather than from its own caught error, since findContextMdIn's
// directory-string form reports SCOPE, not the raw errno message.
const contextReadError = scope === SCOPE.UNREADABLE
? `Could not read phase directory ${formatDiagnosticToken(phaseDir)}`
: null;
// #3511: scope the raw listing to this phase dir before the
// phase-numbered-artifact predicates (hasContext/hasResearch) — planCount/
// summaryCount above stay on scanPhasePlans's own unscoped listing since a
// PLAN/SUMMARY leading number is a plan sequence number, not a phase
// number. Mirrors core-utils.cts's getPhaseFileStats.
const scopedFiles = scopeToPhase(phaseFiles, path.basename(phaseDir));
return {
planCount,
summaryCount,
hasContext: findContextMdIn(scopedFiles) !== null,
hasResearch: scopedFiles.some(f => f.endsWith('-RESEARCH.md') || f === 'RESEARCH.md'),
contextReadError,
scope,
};
}
// `phaseMarkdownRegexSource` lives in phase-id.cjs (#3537) and is imported above.
// ─── searchPhaseInContent ─────────────────────────────────────────────────────
/**
* Build the phase-heading regex used by `searchPhaseInContent` for a given
* pre-escaped phase source. Extracted (#3412) so tests can assert against the
* exact production pattern instead of hand-duplicating it.
* #1729: OPTIONAL_PHASE_TAG_SOURCE after the number tolerates a pre-colon ( ) tag.
*/
function buildPhaseHeadingRegex(escapedPhase: string, convention?: string | null): RegExp {
return new RegExp(
`^${phaseHeadingPrefixSrcFor(PHASE_HEADING_BASELINE.ANY_BRACKET, convention)}${escapedPhase}${OPTIONAL_PHASE_TAG_SOURCE}:\\s*(.+)$`,
'i'
);
}
/**
* Search for a phase header (and its section) within the given content string.
* Returns a result object if found (either a full match or a malformed_roadmap
* checklist-only match), or null if the phase is not present at all.
*/
function searchPhaseInContent(content: string, escapedPhase: string, phaseNum: string, convention?: string | null): PhaseSearchResult | null {
const headingPattern = buildPhaseHeadingRegex(escapedPhase, convention);
const headings = tokenizeHeadings(content);
const headingIndex = headings.findIndex((heading) => headingPattern.test(heading.text));
const headerMatch = headingIndex === -1 ? null : headings[headingIndex].text.match(headingPattern);
if (!headerMatch) {
// Fallback: check if phase exists in summary list but missing detail section
// A BARE `Phase\s+` at base — takes the label-only baseline, so a bracket
// repo gains the bracket-ID form and nothing else.
const checklistPattern = new RegExp(
`-\\s*\\[[ x]\\]\\s*\\*\\*${phaseHeadingPrefixSrcFor(PHASE_HEADING_BASELINE.LABEL_ONLY, convention)}${escapedPhase}${OPTIONAL_PHASE_TAG_SOURCE}:\\s*([^*]+)\\*\\*`,
'i'
);
const checklistMatch = content.match(checklistPattern);
if (checklistMatch) {
return {
found: false,
phase_number: phaseNum,
phase_name: checklistMatch[1].trim(),
error: 'malformed_roadmap',
message: `Phase ${phaseNum} exists in summary list but missing "### Phase ${phaseNum}:" detail section. ROADMAP.md needs both formats.`
};
}
return null;
}
const phaseName = headerMatch[1].trim();
const headerIndex = headings[headingIndex].offset;
const currentHeading = headings[headingIndex];
const nextHeading = headings
.slice(headingIndex + 1)
.find((candidate) => candidate.level <= currentHeading.level);
const sectionEnd = nextHeading ? nextHeading.offset : content.length;
const section = content.slice(headerIndex, sectionEnd).trim();
// Extract goal if present (supports both **Goal:** and **Goal**: formats)
const goalMatch = section.match(/\*\*Goal(?::\*\*|\*\*:)\s*([^\n]+)/i);
const goal = goalMatch ? goalMatch[1].trim() : null;
// Mode: vertical-MVP slice mode flag. Lowercased + trimmed for canonical
// comparison; unrecognized values are preserved verbatim for forward-compat.
const modeMatch = section.match(/\*\*Mode(?::\*\*|\*\*:)\s*([^\n]+)/i);
const mode = modeMatch ? modeMatch[1].trim().toLowerCase() : null;
// Extract success criteria as structured array. A criterion may wrap onto extra
// indented lines (no `N.` prefix); those continuations must fold INTO their
// criterion, not end the run (#2522 — the old `(?:\s*\d+\.\s*[^\n]+)+` broke on a
// wrapped line, truncating it and silently dropping every criterion below it).
// `\n*` before each numbered line keeps blank-line-separated criteria working.
const criteriaMatch = section.match(
/\*\*Success Criteria\*\*[^\n]*:\s*\n((?:\n*[ \t]*\d+\.[^\n]*\n?(?:[ \t]+(?!\d+\.)[^\n]*\n?)*)+)/i);
const success_criteria = criteriaMatch
? criteriaMatch[1].trim().split(/\n+(?=[ \t]*\d+\.)/)
.map(entry => entry.replace(/^\s*\d+\.\s*/, '').replace(/\s*\n\s*/g, ' ').trim())
.filter(Boolean)
: [];
return {
found: true,
phase_number: phaseNum,
phase_name: phaseName,
goal,
mode,
success_criteria,
section,
};
}
// ─── getRoadmapPhaseWithFallback ──────────────────────────────────────────────
/**
* Two-pass phase lookup that mirrors cmdRoadmapGetPhase's resolution strategy.
*
* Pass 1: current-milestone slice (extractCurrentMilestone).
* Pass 2: full roadmap content (stripShippedMilestones) — covers cross-milestone
* and older frontend phases that are no longer in the current milestone slice.
*
* Returns the phase section string if found, null if ROADMAP.md is missing,
* or throws if ROADMAP.md read fails.
*
* Used by check-command-router (computeUiPlanGate) so ui-plan-gate uses the SAME
* phase resolution as `roadmap.get-phase` — not a milestone-only subset.
*/
function getRoadmapPhaseWithFallback(cwd: string, phaseNum: string): string | null {
// #3185: canonical sentinel predicate (SENTINEL_RANGES [0,999]) — this was a local 999-only literal that admitted Phase 0.
if (isSentinelPhaseId(stripProjectCodePrefix(phaseNum))) return null;
const roadmapPath = planningPaths(cwd).roadmap;
// Read directly rather than gating on fs.existsSync: existsSync returns false
// on EACCES/EIO too, which would mask an UNREADABLE roadmap as "missing" and
// let a blocking gate certify empty scope (#2365 review). Honor the documented
// contract — null only when genuinely absent (ENOENT), otherwise throw.
let rawContent: string;
try {
rawContent = fs.readFileSync(roadmapPath, 'utf-8');
} catch (err) {
if ((err as NodeJS.ErrnoException | undefined)?.code === 'ENOENT') return null;
throw err;
}
const milestoneContent = extractCurrentMilestone(rawContent, cwd);
const fullContent = stripShippedMilestones(rawContent);
// #2121/#2114: iterate the shared lookup-source list (exact → numeric →
// prefix-tolerant) so this resolver matches getRoadmapPhaseInternal and a
// bare-number query resolves a drifted project-code-prefixed heading.
const convention = resolvePhaseIdConvention(cwd);
for (const source of roadmapPhaseLookupSources(phaseNum)) {
const milestoneResult = searchPhaseInContent(milestoneContent, source, phaseNum, convention);
if (milestoneResult && !milestoneResult.error) return milestoneResult.section ?? null;
const fullResult = searchPhaseInContent(fullContent, source, phaseNum, convention);
if (fullResult && !fullResult.error) return fullResult.section ?? null;
}
return null;
}
// ─── cmdRoadmapGetPhase ───────────────────────────────────────────────────────
function cmdRoadmapGetPhase(cwd: string, phaseNum: string, raw: boolean): void {
// #3185: canonical sentinel predicate (SENTINEL_RANGES [0,999]) — this was a local 999-only literal that admitted Phase 0.
if (isSentinelPhaseId(stripProjectCodePrefix(phaseNum))) {
output({ found: false, phase_number: phaseNum }, raw, '');
return;
}
const roadmapPath = planningPaths(cwd).roadmap;
if (!fs.existsSync(roadmapPath)) {
output({ found: false, error: 'ROADMAP.md not found' }, raw, '');
return;
}
try {
const rawContent = fs.readFileSync(roadmapPath, 'utf-8');
const milestoneContent = extractCurrentMilestone(rawContent, cwd);
const fullContent = stripShippedMilestones(rawContent);
const convention = resolvePhaseIdConvention(cwd);
// #2121/#2114: iterate the shared lookup-source list (exact → numeric →
// prefix-tolerant) so all three roadmap resolvers share one contract and a
// bare-number query resolves a drifted `### Phase AB-29:` heading. This
// preserves the #3599 exact-prefix-first and #3537 padding-tolerant behavior
// (both now encoded in roadmapPhaseLookupSources' ordering). A clean match
// (milestone or full, any source) wins immediately; a malformed_roadmap
// (checklist-only) candidate is surfaced only if no source finds a real
// heading — so a milestone checklist never blocks a full-roadmap header.
let malformed: PhaseSearchResult | null = null;
for (const source of roadmapPhaseLookupSources(phaseNum)) {
const milestoneResult = searchPhaseInContent(milestoneContent, source, phaseNum, convention);
if (milestoneResult && !milestoneResult.error) {
output(milestoneResult, raw, milestoneResult.section);
return;
}
const fullResult = searchPhaseInContent(fullContent, source, phaseNum, convention);
if (fullResult && !fullResult.error) {
output(fullResult, raw, fullResult.section);
return;
}
if (!malformed) malformed = (milestoneResult?.error ? milestoneResult : (fullResult?.error ? fullResult : null));
}
// #3577: no heading or checklist entry matched — fall back to a
// markdown-table row declaration (the same last-resort tier
// getRoadmapPhaseInternal gained). Zero-pad-tolerant id compare (#3572
// lesson: the declared form may be padded).
const stripPad = (s: string) => s.replace(/^0+(?=.)/, '');
const tableHit = collectTablePhaseRows(milestoneContent).find((tr) => stripPad(tr.id) === stripPad(phaseNum))
?? collectTablePhaseRows(fullContent).find((tr) => stripPad(tr.id) === stripPad(phaseNum));
if (tableHit) {
output(
{ found: true, phase_number: phaseNum, phase_name: tableHit.name ?? `Phase ${tableHit.id}`, goal: null, section: tableHit.row.trim() },
raw,
tableHit.row.trim(),
);
return;
}
if (malformed) {
output(malformed, raw, '');
return;
}
output({ found: false, phase_number: phaseNum }, raw, '');
} catch (e) {
error('Failed to read ROADMAP.md: ' + (e as Error).message);
}
}
// ─── cmdRoadmapAnalyze ────────────────────────────────────────────────────────
/**
* #3165: a single phase-detail heading enriched with its on-disk status, as
* `cmdRoadmapAnalyze` reports it. Extracted so the SAME enrichment runs on both
* the scoped milestone window and, when that window is suspect (non-COMPLETE
* scope, zero phases, phase dirs on disk), the shipped-milestone-stripped
* fallback document.
*/
type AnalyzePhase = {
number: string;
name: string;
goal: string | null;
mode: string | null;
depends_on: string | null;
plan_count: number;
summary_count: number;
has_context: boolean;
has_research: boolean;
disk_status: string;
roadmap_complete: boolean;
/** #3885 (ADR-3473 §8.5): see PhasePlansAndSummaries.contextReadError. */
context_read_error: string | null;
/** #4014 (epic #3473 B4): see PhasePlansAndSummaries.scope. Additive
* sibling of context_read_error — SCOPE.UNREADABLE for the same read
* failure context_read_error names, SCOPE.COMPLETE otherwise (including a
* genuinely absent/no_directory phase). */
context_scope: Scope;
};
type AnalyzePhaseCollection = {
phases: AnalyzePhase[];
detailKeys: Set<string>;
};
// #612 composes the convention-qualified sentinel reading with upstream's
// canonical legacy sentinel owner. A reserved bracket milestone OR a reserved
// phase token excludes the occurrence.
const isSentinelPhase = (num: string, bracketId?: string): boolean => {
if (bracketId && isSentinelPhaseId(`${bracketId}-${num}`, 'bracket')) return true;
return isSentinelPhaseId(num);
};
// #2761 M1: missing-detail identity is milestone-qualified under bracket.
// Prefer the canonical qualified-key owner, which case-folds accepted ids, so
// `[gsd.02] 01` and `[GSD.02] 01` are one occurrence. It is intentionally not
// padding-tolerant: the milestone grammar has one canonical spelling (pad2
// below 100, no leading zero above), so `[GSD.2]` is malformed rather than an
// alternate spelling of `[GSD.02]`. Hyphenated tokens and other shapes the
// qualified-key owner refuses retain a folded composite, keeping distinct
// bracket/token pairs from collapsing onto one missing-detail verdict.
const occurrenceKey = (num: string, bracketId?: string): string => {
if (!bracketId) return num;
const qualified = num.includes('-')
? null
: bracketQualifiedKey(`${bracketId}-${num}`, 'bracket');
return qualified ?? `${foldBracketId(bracketId)}|${num}`;
};
/**
* #3165: scan `content` for phase-detail headings (`##/###/#### Phase N: Name`)
* and enrich each with its on-disk plan/summary/completion status and ROADMAP
* checkbox. Pure extraction over `content` + the pre-built `phaseDirNames`
* lookup index — no milestone windowing of its own; the caller chooses the
* content (scoped window or fallback). Extracted verbatim from
* `cmdRoadmapAnalyze`'s former inline loop so the fallback re-runs the EXACT
* same enrichment, not a second derivation.
*/
function collectAnalyzePhases(
content: string,
phasesDir: string,
phaseDirNames: string[],
convention?: string | null,
): AnalyzePhaseCollection {
// Extract all phase headings: ## Phase N: Name or ### Phase N: Name
// #1729: `(?:\s*\([^)\n]{0,200}\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
// #612: CAPTURING intro under the bracket convention — group 1 is the
// `[CODE.MM]` bracket id (undefined otherwise), group 2 the token, group 3 the
// name. The bracket id is what the sentinel filter needs: READING-B puts the
// sentinel milestone in the bracket, not in the token.
// phase-id-owner: uses the [.-] (dot-or-dash) separator variant, not the canonical dot-only token; a swap to PHASE_NUMBER_TOKEN_SOURCE would drop hyphenated phase-id matches.
// #3036: widen the id capture to accept non-numeric-leading ids (e.g. B7, P0.3-2)
// that get-phase/execute-phase already resolve. An optional leading letter prefix
// ([A-Za-z]?) covers letter-prefixed ids without breaking numeric-leading ones.
// phase-id-owner: uses the [.-] (dot-or-dash) separator variant, not the canonical dot-only token; a swap to PHASE_NUMBER_TOKEN_SOURCE would drop hyphenated phase-id matches.
const phasePattern = new RegExp(`#{2,4}\\s*${phaseHeadingPrefixSrcFor(PHASE_HEADING_BASELINE.ANY_BRACKET, convention, true)}([A-Za-z]?\\d+[A-Z]?(?:[.-]\\d+)*)(?:\\s*\\([^)\\n]{0,200}\\))?\\s*:\\s*([^\\n]+)`, 'gi');
// The capturing intro inserts the bracket id at group 1 only under the
// bracket convention; the token and name shift by the same offset.
const G = convention === 'bracket' ? 1 : 0;
const phases: AnalyzePhase[] = [];
let match: RegExpExecArray | null;
// The caller needs the exact occurrence identities from the same scan that
// built `phases`; returning them together also keeps fallback rescans atomic.
const detailKeys = new Set<string>();
while ((match = phasePattern.exec(content)) !== null) {
const bracketId = G ? match[1] : undefined;
const phaseNum = match[1 + G];
if (isSentinelPhase(phaseNum, bracketId)) continue;
detailKeys.add(occurrenceKey(phaseNum, bracketId));
const phaseName = match[2 + G].replace(/\(INSERTED\)/i, '').trim();
// Extract goal from the section
const sectionStart = match.index;
const restOfContent = content.slice(sectionStart);
// #3691: `\d` → `\d[\d.]*` so decimal phase headings (e.g. `### Phase 02.3:`) are
// recognised as section boundaries. #3036: `[A-Za-z]?\d` so non-numeric-leading ids
// (e.g. B7) are also recognised.
const nextHeader = restOfContent.match(new RegExp(`\\n#{2,4}\\s+${phaseHeadingPrefixSrcFor(PHASE_HEADING_BASELINE.ANY_BRACKET, convention)}[A-Za-z]?\\d[\\d.-]*`, 'i'));
const sectionEnd = nextHeader ? sectionStart + nextHeader.index! : content.length;
const section = content.slice(sectionStart, sectionEnd);
const goalMatch = section.match(/\*\*Goal(?::\*\*|\*\*:)\s*([^\n]+)/i);
const goal = goalMatch ? goalMatch[1].trim() : null;
const modeMatch = section.match(/\*\*Mode(?::\*\*|\*\*:)\s*([^\n]+)/i);
const mode = modeMatch ? modeMatch[1].trim().toLowerCase() : null;
const dependsMatch = section.match(/\*\*Depends on(?::\*\*|\*\*:)\s*([^\n]+)/i);
const depends_on = dependsMatch ? dependsMatch[1].trim() : null;
// Check completion on disk
const normalized = normalizePhaseName(phaseNum);
let diskStatus = 'no_directory';
let planCount = 0;
let summaryCount = 0;
let hasContext = false;
let hasResearch = false;
// #3885 (ADR-3473 §8.5): null unless dirMatch resolves and its readdirSync
// hit a non-ENOENT error — no directory at all is `disk_status:
// 'no_directory'`, a real (if uninteresting) answer, not a read error.
let contextReadError: string | null = null;
// #4014 (epic #3473 B4): additive sibling — SCOPE.COMPLETE by default
// (no directory at all is a genuine, not-unreadable answer), overwritten
// below only when dirMatch resolves.
let contextScope: Scope = SCOPE.COMPLETE;
// DEAD catch removed (#2245 audit): matchPhaseDirs(...) is a pure
// array lookup on an already-resolved string array, and
// countPhasePlansAndSummaries is itself fully defensive (its own
// readdirSync is self-guarded, and it delegates to scanPhasePlans, which
// never throws) — nothing in this block can throw, so the try/catch could
// never be triggered.
// #612: the DIRECTORY read is selected by the same `convention` the four
// heading/checklist patterns above already thread. Left two-argument, this
// one call reported EVERY canonical `{CODE}.{MM}-{PP}-slug` directory as
// `disk_status: "no_directory"` with `plan_count`/`summary_count` 0 —
// `extractPhaseToken('GSD.02-01-one')` with no convention returns the whole
// dir name — while the same build resolved those same directories correctly
// in three other places on the same repo (W006/W007 through their shared
// directory matcher, `state json` via the milestone filter, and the W026
// milestone-complete read through the same convention-aware owner). It
// failed ONLY for the directory shape the convention exists to name: a
// mid-migration bracket repo carrying legacy `01-one` dirs resolved fine.
// That is verbatim the asymmetry the note above the W026 rule says this PR
// closed — the directory read widens with the heading read, or every bracket
// phase resolves to nothing.
// Upstream centralized this choice in `matchPhaseDirs`; thread the same
// convention into that owner rather than reviving the primitive `.find()`.
const dirMatch = matchPhaseDirs(phaseDirNames, normalized, convention).matches[0];
if (dirMatch) {
const counts = countPhasePlansAndSummaries(path.join(phasesDir, dirMatch));
planCount = counts.planCount;
summaryCount = counts.summaryCount;
hasContext = counts.hasContext;
hasResearch = counts.hasResearch;
contextReadError = counts.contextReadError;
contextScope = counts.scope;
// ADR-3180 §7.4 (issue #3186, disk-strict, #3168 fix): route "is this
// phase complete" through the canonical owner (`isPhaseComplete`),
// which calls readVerificationStatus UNCONDITIONALLY — plan count is
// NOT a precondition, so a zero-plan phase with a passing
// `*-VERIFICATION.md` reports complete here too, not just via
// `phase.complete`.
const completionResult = isPhaseComplete(path.join(phasesDir, dirMatch));
if (completionResult.value.complete) diskStatus = 'complete';
else if (summaryCount > 0) diskStatus = 'partial';
else if (planCount > 0) diskStatus = 'planned';
else if (hasResearch) diskStatus = 'researched';
else if (hasContext) diskStatus = 'discussed';
else diskStatus = 'empty';
}
// Check ROADMAP checkbox status. #3537: padding-tolerant fragment — the
// heading discovered above may use a different padding than the
// summary-bullet checkbox below it (mixed padding inside one ROADMAP is
// legal and seen in real projects).
//
// ADR-3180 §7.4 (disk-strict, #2957, maintainer decision 2026-08-08):
// `roadmapComplete` is reported below as metadata ONLY — it carries NO
// machine authority over `diskStatus`. The override that used to trust a
// ticked checkbox over disk file structure is DELETED, not generalized
// (#2957: "a ticked ROADMAP checkbox is a human annotation with no
// machine authority"). A phase marked complete solely by a ticked
// checkbox — no passing `*-VERIFICATION.md`, plans outstanding — now
// reports incomplete; this is the deliberate Tier-2 break (ADR-3180 §7.4
// Decision 3).
const checkboxPattern = new RegExp(`-\\s*\\[(x| )\\]\\s*.*${phaseHeadingPrefixSrcFor(PHASE_HEADING_BASELINE.LABEL_ONLY, convention)}${phaseMarkdownRegexSource(phaseNum)}${OPTIONAL_PHASE_TAG_SOURCE}[:\\s]`, 'i');
const checkboxMatch = content.match(checkboxPattern);
const roadmapComplete = checkboxMatch ? checkboxMatch[1] === 'x' : false;
phases.push({
number: phaseNum,
name: phaseName,
goal,
mode,
depends_on,
plan_count: planCount,
summary_count: summaryCount,
has_context: hasContext,
has_research: hasResearch,
disk_status: diskStatus,
roadmap_complete: roadmapComplete,
context_read_error: contextReadError,
context_scope: contextScope,
});
}
// #3577: markdown-table row declarations join the enumeration — same
// enrichment contract as headings (disk counts when the directory exists),
// zero-pad-tolerant duplicate guard so an id declared in BOTH a heading and
// a table counts once.
const stripPadA = (s: string) => s.replace(/^0+(?=.)/, '');
const seen = new Set(phases.map((ph) => stripPadA(ph.number)));
for (const tr of collectTablePhaseRows(content)) {
// #3577 table declarations were part of the pre-existing detail set.
// Preserve that behavior while heading occurrences gain bracket identity.
detailKeys.add(occurrenceKey(tr.id));
if (seen.has(stripPadA(tr.id))) continue;
const dirMatchA = matchPhaseDirs(phaseDirNames, normalizePhaseName(tr.id)).matches[0];
let tPlanCount = 0;
let tSummaryCount = 0;
let tHasContext = false;
let tHasResearch = false;
let tContextReadError: string | null = null;
// #4014 (epic #3473 B4): additive sibling, same default rule as the
// heading-declared branch above.
let tContextScope: Scope = SCOPE.COMPLETE;
if (dirMatchA) {
const counts = countPhasePlansAndSummaries(path.join(phasesDir, dirMatchA));
tPlanCount = counts.planCount;
tSummaryCount = counts.summaryCount;
tHasContext = fs.existsSync(path.join(phasesDir, dirMatchA, 'CONTEXT.md'));
tHasResearch = fs.existsSync(path.join(phasesDir, dirMatchA, 'RESEARCH.md'));
// #3885 (ADR-3473 §8.5): reuse the SAME countPhasePlansAndSummaries call's
// discriminator — this row's hasContext/hasResearch are read via a direct
// existsSync (which cannot itself distinguish EACCES from absent), but
// an unreadable phase directory is still surfaced via the sibling call.
tContextReadError = counts.contextReadError;
tContextScope = counts.scope;
}
phases.push({
number: tr.id,
name: tr.name ?? `Phase ${tr.id}`,
goal: null,
mode: null,
depends_on: null,
plan_count: tPlanCount,
summary_count: tSummaryCount,
has_context: tHasContext,
has_research: tHasResearch,
disk_status: dirMatchA ? 'ok' : 'no_directory',
roadmap_complete: false,
context_read_error: tContextReadError,
context_scope: tContextScope,
});
}
return { phases, detailKeys };
}
function cmdRoadmapAnalyze(cwd: string, raw: boolean): void {
const roadmapPath = planningPaths(cwd).roadmap;
if (!fs.existsSync(roadmapPath)) {
output({ error: 'ROADMAP.md not found', milestones: [], phases: [], current_phase: null }, raw, undefined);
return;
}
const rawContent = fs.readFileSync(roadmapPath, 'utf-8');
// #3184/#3165: use the scoped variant so a truncated window is a
// distinguishable signal in the output instead of a silent `phase_count: 0`
// indistinguishable from a genuinely empty milestone.
const { value: content, scope } = extractCurrentMilestoneScoped(rawContent, cwd);
const phasesDir = planningPaths(cwd).phases;
// #612: resolve once per command and thread the same reading through both
// the scoped scan and any fallback scan.
const convention = resolvePhaseIdConvention(cwd);
const G = convention === 'bracket' ? 1 : 0;
// Build phase directory lookup once (O(1) readdir instead of O(N) per phase)
// #3185 exemption reason (ADR-3180 Decision 4a): this is a heading->directory
// LOOKUP INDEX, not a milestone enumeration. It must see the PHYSICAL set so
// a heading already scoped by extractCurrentMilestoneScoped above can find
// its directory; filtering it through listMilestonePhaseDirs would scope
// the same set twice. #3882 (ADR-3473 §8.2): routed through the named
// "physical set, sentinels included" axis instead of a hand-rolled
// readdirSync — every heading matched below already excludes sentinel
// phase numbers via isSentinelPhaseId before it ever consults this list
// (collectAnalyzePhases), so a sentinel directory's presence here is
// output-invariant; this only removes the re-derivation, not the reason.
const _phaseDirNames = listAllPhaseDirs(phasesDir, { includeSentinels: true }).value;
// Scan the scoped milestone window for phase-detail headings and enrich each
// with its on-disk status. Extracted into `collectAnalyzePhases` (#3165) so
// the SAME enrichment re-runs on the fallback below — not a second copy.
let collected = collectAnalyzePhases(content, phasesDir, _phaseDirNames, convention);
let phases = collected.phases;
let detailKeys = collected.detailKeys;
// `effectiveContent` is what the downstream checklist scan (missing_details)
// iterates. Defaults to the scoped window; switched to the fallback document
// when the recovery path below fires, so a phase found via fallback is not
// falsely reported as "in checklist but missing a detail section."
let effectiveContent = content;
// #3165: recover phase_count when the scoped window came back empty. A
// CLOSED milestone heading sitting between the active milestone heading and
// its own phase-detail sections closes `extractCurrentMilestoneScoped`'s
// window over prose only — `phases` is empty, and the consuming resume gate
// (`workflows/next.md` Route 0) iterates `.phases[]` so a safety invariant
// silently never runs. When the window is suspect (non-COMPLETE scope), the
// scoped scan found nothing, AND phase directories exist on disk (real
// evidence phases exist), re-scan the shipped-milestone-stripped document so
// the phase list reflects the real phases instead of a silent zero. The
// `scope` field retains its non-COMPLETE value downstream so consumers can
// still tell this is a best-effort count, not a cleanly scoped one. Position
// alone cannot attribute phases to the active vs the intervening closed
// milestone, so this never claims COMPLETE — it converts silence into a
// populated, flagged result.
if (phases.length === 0 && scope !== SCOPE.COMPLETE && _phaseDirNames.length > 0) {
const fallbackContent = stripShippedMilestones(rawContent);
const fallbackCollection = collectAnalyzePhases(fallbackContent, phasesDir, _phaseDirNames, convention);
if (fallbackCollection.phases.length > 0) {
collected = fallbackCollection;
phases = collected.phases;
detailKeys = collected.detailKeys;
effectiveContent = fallbackContent;
}
}
// Extract milestone info. #3216: routed through the canonical
// `listMilestoneHeadings` owner (deleted the inline `##…` regex, which
// truncated names at a parenthetical and had no phase-heading exclusion)
// rather than re-deriving the enumeration here.
const milestones: Array<{ heading: string; version: string }> = listMilestoneHeadings(content).map((m) => ({
heading: m.heading,
version: m.version,
}));
// Find current and next phase
const currentPhase = phases.find(p => p.disk_status === 'planned' || p.disk_status === 'partial') || null;
const nextPhase = phases.find(p => p.disk_status === 'empty' || p.disk_status === 'no_directory' || p.disk_status === 'discussed' || p.disk_status === 'researched') || null;
// Aggregated stats
const totalPlans = phases.reduce((sum, p) => sum + p.plan_count, 0);
const totalSummaries = phases.reduce((sum, p) => sum + p.summary_count, 0);
const completedPhases = phases.filter(p => p.disk_status === 'complete').length;
// Detect phases in summary list without detail sections (malformed ROADMAP).
// The char class must allow `-` (not just `.`) so dash-separated milestone-prefixed
// IDs (e.g. `1-01`) match the detail-heading scanner above; otherwise they truncate
// at the dash (`1-01` -> `1`) and every such phase reports a phantom missing detail.
// #612: CAPTURING label-only intro — the bracket id rides along so the
// sentinel filter below is not blind to `- [ ] **[GSD.999] 01: Icebox**`.
// phase-id-owner: uses the [.-] (dot-or-dash) separator variant, not the canonical dot-only token; a swap to PHASE_NUMBER_TOKEN_SOURCE would drop hyphenated phase-id matches.
// #3036: widen to accept non-numeric-leading ids (same widening as the detail-heading pattern above).
// phase-id-owner: uses the [.-] (dot-or-dash) separator variant, not the canonical dot-only token; a swap to PHASE_NUMBER_TOKEN_SOURCE would drop hyphenated phase-id matches.
const checklistPattern = new RegExp(`-\\s*\\[[ x]\\]\\s*\\*\\*${phaseHeadingPrefixSrcFor(PHASE_HEADING_BASELINE.LABEL_ONLY, convention, true)}([A-Za-z]?\\d+[A-Z]?(?:[.-]\\d+)*)`, 'gi');
// #2761 M1: an OCCURRENCE list keyed by `occurrenceKey`, not a token->bracket
// map. The map was first-wins on the bare token, so of two checklist entries
// sharing a token across brackets the FIRST one's bracket id classified BOTH:
// `- [ ] **[GSD.999] 01: Icebox**` written above `- [ ] **[GSD.02] 01: …**`
// made the real phase inherit the icebox's sentinel verdict and vanish from
// `missing_phase_details`; written below it, the same document reported it.
// Dedupe still happens — it is now per PHASE rather than per token, which is
// what makes the classification order-independent.
const checklistOccurrences: Array<{ token: string; bracketId?: string }> = [];
const seenChecklistKeys = new Set<string>();
let checklistMatch: RegExpExecArray | null;
while ((checklistMatch = checklistPattern.exec(effectiveContent)) !== null) {
const token = checklistMatch[1 + G];
const bracketId = G ? checklistMatch[1] : undefined;
const key = occurrenceKey(token, bracketId);
if (seenChecklistKeys.has(key)) continue;
seenChecklistKeys.add(key);
checklistOccurrences.push({ token, bracketId });
}
// The EMITTED value stays the bare token, unchanged: `phases[].number` is a
// token under every convention, and `missing_phase_details` is read against
// it. Only the classification moved to the qualified key — so two different
// brackets' `01` both missing report `01` once, rather than one of them
// silently covering for the other.
const missingDetails = [...new Set(
checklistOccurrences
.filter(o => !detailKeys.has(occurrenceKey(o.token, o.bracketId))
&& !isSentinelPhase(o.token, o.bracketId))
.map(o => o.token),
)];
// #3217 (ADR-3180 §7.6 rules 3-4): `progress_percent` used to accumulate
// `totalPlans`/`totalSummaries` above — a heading-matched enumeration
// (`phasePattern` over the milestone-windowed `content`) paired against
// `_phaseDirNames`, a DELIBERATELY unscoped physical directory listing
// (see its own comment above: it is a heading->directory lookup index,
// not a milestone enumeration). That set is not the same set
// `listMilestonePhaseDirs` scopes for `query progress` / `stats` (#3185
// Phase 3), so `progress_percent` could silently diverge from both siblings
// on the same project (rule 3). Route `progress_percent`'s own
// numerator/denominator through the single scoped owner instead — mirrors
// cmdProgressRender/cmdStats's own aggregation — and withhold the
// percentage entirely when THAT scope is not COMPLETE (rule 4), never
// returning `0` for "could not compute". This does not touch `total_plans`
// / `total_summaries` / `phases` / `completed_phases` above — those stay
// the heading-matched detail view; only `progress_percent`'s own inputs
// move onto the scoped owner.
let scopedTotalPlans = 0;
let scopedTotalSummaries = 0;
let progressScope: Scope = SCOPE.UNREADABLE;
try {
const { value: progressDirs, scope: scopedResult } = listMilestonePhaseDirs(phasesDir, { cwd });
progressScope = scopedResult;
for (const dir of progressDirs) {
const scan = scanPhasePlans(path.join(phasesDir, dir));
scopedTotalPlans += scan.planCount;
scopedTotalSummaries += scan.summaryCount;
}
} catch { /* progressScope stays the pessimistic SCOPE.UNREADABLE default */ }
const progressPercent = progressScope === SCOPE.COMPLETE
? clampPercent(scopedTotalSummaries, scopedTotalPlans)
: null;
const result = {
milestones,
phases,
phase_count: phases.length,
completed_phases: completedPhases,
total_plans: totalPlans,
total_summaries: totalSummaries,
progress_percent: progressPercent,
// #3217 finding 2: `progress_percent` is gated by a SECOND, independently
// computed `listMilestonePhaseDirs` scope (`progressScope` above) — not
// by the top-level `scope` field, which describes the heading-windowing
// identity `phases`/`total_plans`/`total_summaries`/`completed_phases`
// were built from. Those two scopes can legitimately disagree (e.g.
// `scope: "complete"` alongside a genuinely unreadable phases directory),
// and per the documented contract "scope tells you whether the counts
// are trustworthy", a consumer seeing `progress_percent: null` needs a
// field to tell WHY without reading source. Exposing `progress_scope`
// (rather than reconciling the two scopes into one, or re-deriving
// `total_plans`/`phases`/etc. from the scoped set) preserves the
// deliberate, already-documented choice a few lines up: `phases`/
// `total_plans`/`total_summaries`/`completed_phases` stay the
// heading-matched detail view (`_phaseDirNames` is a lookup index, not a
// milestone enumeration — see its comment); only `progress_percent`'s own
// inputs move onto the scoped owner.
progress_scope: progressScope,
current_phase: currentPhase ? currentPhase.number : null,
next_phase: nextPhase ? nextPhase.number : null,
missing_phase_details: missingDetails.length > 0 ? missingDetails : null,
// #3184/#3165: distinguishes a genuinely empty milestone (`scope:
// "complete"`, `phase_count: 0`) from a window that could not be fully
// resolved (`"truncated"` / `"unscoped"` / `"unreadable"`) — those cases
// were previously output-identical.
scope,
};
output(result, raw, undefined);
}
// ─── cmdRoadmapMilestoneScope ────────────────────────────────────────────────
/**
* #3262 (write-time milestone-scope guard): read-only probe emitting the
* current milestone window's IDENTITY — its scope classification and the
* phase ids it declares — so the edit-phase workflow can capture it before
* its in-place section write, re-derive it after, and roll back on any
* change. This is the milestone-scope sibling of the workflow's existing
* `depends_on` gate, expressed as a command because the workflow's write is
* assistant-driven free-text surgery, not a code path.
*
* Deliberately NOT `cmdRoadmapAnalyze`: analyze's #3165 recovery re-populates
* `phases` from the shipped-milestone-stripped document when the scoped
* window is suspect, which is right for a human-facing progress report and
* wrong for a before/after equality probe — the refill would mask exactly
* the narrowing this guard exists to detect. This probe reports the RAW
* window (`extractCurrentMilestoneScoped` + `scanMilestonePhaseIds`), no
* fallback, so a narrowed window is always visible as a changed phase set.
*/
function cmdRoadmapMilestoneScope(cwd: string, raw: boolean): void {
const roadmapPath = planningPaths(cwd).roadmap;
if (!fs.existsSync(roadmapPath)) {
output({ error: 'ROADMAP.md not found', scope: SCOPE.UNREADABLE, phases: [], phase_count: 0 }, raw, undefined);
return;
}
const rawContent = fs.readFileSync(roadmapPath, 'utf-8');
// #3641: resolve phase_id_convention and thread it into the scope axis, so
// this probe and `roadmap validate`'s V005 answer the SAME question the
// SAME way for a bracket-convention project — a window the classifier
// calls TRUNCATED in validate must never read COMPLETE here (the #3262
// capture/compare guard consumes this scope). Resolution mirrors the
// validate router's: .planning/config.json first, ROADMAP.md frontmatter
// as fallback.
let phaseIdConvention: string | undefined | null;
try {
const cfg = loadConfigForScope(cwd);
phaseIdConvention = cfg['phase_id_convention'] as string | undefined | null;
} catch {
phaseIdConvention = undefined;
}
if (phaseIdConvention === undefined || phaseIdConvention === null) {
// Bounded per local/no-unbounded-quantifier (#2128): frontmatter is a
// short header block — 4KB is orders of magnitude beyond any real one.
const fmMatch = rawContent.match(/^---\r?\n([\s\S]{0,4000}?)\r?\n---/);
if (fmMatch) {
const kvMatch = fmMatch[1].match(/^phase_id_convention:\s*(.*)$/m);
if (kvMatch) {
const val = kvMatch[1].trim();
if (val !== 'null' && val !== '') {
phaseIdConvention = val.replace(/^["']|["']$/g, '');
}
}
}
}
const { value: window, scope } = extractCurrentMilestoneScoped(rawContent, cwd, undefined, phaseIdConvention);
// Document order (Set insertion order) — deterministic for a given document.
const phases = [...scanMilestonePhaseIds(window, phaseIdConvention)];
output({ scope, phases, phase_count: phases.length }, raw, undefined);
}
// ─── cmdRoadmapUpdatePlanProgress ─────────────────────────────────────────────
/**
* Scope a ROADMAP.md content string down to its "Progress table" writable
* slice, run `edit` against just that slice, then splice the result back into
* the original content (ADR-2143 §7). Layered scoping:
* 1. Milestone scope — everything after the LAST `</details>` close tag
* (mirrors `replaceInCurrentMilestone`), so a same-numbered phase row in
* an archived milestone is never touched.
* 2. Heading scope — within that milestone slice, the `## Progress` heading
* section (up to the next `#`/`##` heading) when present, else the whole
* milestone slice (mirrors phase-lifecycle.cjs's `deriveProgressFromRoadmap`
* read-side scoping, #2012 decoy avoidance — a differently-headed table
* sharing the same column names must not be picked up instead).
* `edit` always returns a string and never fails — a no-op edit (table/row not
* found within the scoped slice) simply returns its input unchanged, mirroring
* the prior regex `.replace()`'s no-match-is-a-no-op semantics.
*/
function editProgressTableSlice(content: string, edit: (scoped: string) => string): string {
const lastDetailsClose = content.lastIndexOf('</details>');
const milestoneOffset = lastDetailsClose === -1 ? 0 : lastDetailsClose + '</details>'.length;
const before = content.slice(0, milestoneOffset);
const milestoneSlice = content.slice(milestoneOffset);
const progressMatch = milestoneSlice.match(/^##[ \t]+Progress\b/im);
if (!progressMatch || progressMatch.index === undefined) {
return before + edit(milestoneSlice);
}
const headingOffset = progressMatch.index;
const beforeHeading = milestoneSlice.slice(0, headingOffset);
const fromHeading = milestoneSlice.slice(headingOffset);
const nextHeading = fromHeading.search(/\n#{1,2}[ \t]/);
const scoped = nextHeading >= 0 ? fromHeading.slice(0, nextHeading) : fromHeading;
const after = nextHeading >= 0 ? fromHeading.slice(nextHeading) : '';
return before + beforeHeading + edit(scoped) + after;
}
function cmdRoadmapUpdatePlanProgress(cwd: string, phaseNum: string | null | undefined, raw: boolean): void {
if (!phaseNum) {
error('phase number required for roadmap update-plan-progress');
}
const roadmapPath = planningPaths(cwd).roadmap;
const phaseInfo = findPhaseInternal(cwd, phaseNum);
if (!phaseInfo) {
error(`Phase ${phaseNum} not found`);
}
const planCount = phaseInfo!.plans.length;
// Count only summaries that pair with a real plan (#1988): stray non-plan
// summaries (30-FIX-CR02-SUMMARY.md, 30-GAPCLOSURE-SUMMARY.md, …) must not
// inflate summary_count and silently flip the phase to Complete.
const summaryCount = countMatchedSummaries(phaseInfo!.plans, phaseInfo!.summaries);
if (planCount === 0) {
declineNoOp(
raw,
'updated',
'No plans found',
'roadmap update-plan-progress skipped — no plans found for this phase. ROADMAP.md was left unchanged.',
{ plan_count: 0, summary_count: 0 },
);
return;
}
// Verification gate (#2022): do NOT check the phase checkbox or stamp a
// completion date until the phase's verification status is 'passed', matching
// cmdPhaseComplete's gate (phase.cts:1436). Previously the checkbox fired the
// moment the last plan summary landed — before gsd-verifier had verified.
//
// ADR-3180 §7.4 (issue #3186, disk-strict): routed through the canonical
// owner (`isPhaseComplete`) instead of hand-rolling `summaryCount >=
// planCount && verificationPassed` locally — the owner calls
// readVerificationStatus UNCONDITIONALLY, so `isComplete` here always
// agrees with `roadmap analyze` / `init manager` / `phase complete` for
// the same phase (ADR-3180 §7.4's headline: one predicate for the read
// path and the write path).
const phaseDir = path.join(cwd, phaseInfo!.directory);
const completionResult = isPhaseComplete(phaseDir);
const verificationResult = completionResult.value.verification;
// #2648 precedent, applied at this write site (ADR-3180 §7.4 / #3186):
// `isPhaseComplete` deliberately carries NO plan-count precondition — the
// owner's `complete` is exactly `verification.status === 'passed'`, and
// that must stay true (disk-strict: a zero-plan phase with a passing
// `*-VERIFICATION.md` IS complete, #3168). But `readVerificationStatus`'s
// staleness check only compares SUMMARY mtimes against the verification
// file — it has no idea a NEW plan was added after the file was written,
// so a still-fresh `passed` verification says nothing about a plan added
// afterward. This command WRITES a checkbox and a completion date into
// ROADMAP.md, a materially stronger claim than "verification passed" —
// mirroring cmdPhaseComplete's own fail-closed plan-coverage gate
// (phase.cts:~1995, #2648: "a coverage gate that passes when it cannot
// read the plans is no gate at all"), composed explicitly here rather than
// folded into the predicate: complete AND all plans executed.
const coverageScan = scanPhasePlans(phaseDir);
const unsummarizedPlans = findUnsummarizedPlans(coverageScan.planFiles, coverageScan.summaryFiles);
const isComplete = completionResult.value.complete && unsummarizedPlans.length === 0;
// #3057 B3: routing above is unchanged (an indeterminate staleness check
// still routes as if nothing were stale) — this only makes the fact visible
// to whatever reads this command's JSON output.
const verificationStaleCheckIndeterminate = verificationResult.staleCheckIndeterminate === true;
const status = isComplete ? 'Complete' : summaryCount > 0 ? 'In Progress' : 'Planned';
const today = realClock.localToday();
if (!fs.existsSync(roadmapPath)) {
declineNoOp(
raw,
'updated',
'ROADMAP.md not found',
'roadmap update-plan-progress skipped — ROADMAP.md not found.',
{ plan_count: planCount, summary_count: summaryCount },
);
return;
}
// Wrap entire read-modify-write in lock to prevent concurrent corruption
let updated = false;
withPlanningLock(cwd, () => {
// #3957 (B9.4): captured BEFORE any transform runs, so the write/report
// decision below reflects whether the transforms actually changed
// anything — not just that they ran. Every transform below still runs
// unconditionally exactly as before; only the final write-and-report
// step becomes conditional on `roadmapContent !== originalContent`.
const originalContent = fs.readFileSync(roadmapPath, 'utf-8');
let roadmapContent = originalContent;
const phasePattern = phaseMarkdownRegexSource(phaseNum);
// Progress table row: update Plans Complete/Status/Completed columns BY
// COLUMN NAME (handles 4- or 5-column RoadmapProgress tables regardless of
// Milestone-column presence) via the markdown-table seam (ADR-2143 §7) —
// supersedes the prior ordinal cells[]-index regex. Scoped to the current
// milestone's `## Progress` table (editProgressTableSlice above).
// #2245 Blocker 4: optional dot must be followed by whitespace-or-end, not
// dot-OR-whitespace-OR-end as alternatives — the prior form let a bare "."
// satisfy the whole lookahead, so completing phase "2" over-matched a
// decimal sub-phase row like "2.5 Extra". Matches "2", "2.", "2 Alpha";
// rejects "2.5 Extra" (replicates OLD's `\.?\s` intent on the now-TRIMMED
// cell value, where end-of-string is the trimmed equivalent of "no more
// characters after the optional dot").
const phaseCellRe = new RegExp(`^${phasePattern}\\.?(?:\\s|$)`, 'i');
const rowMatch = (row: Record<string, string>): boolean => phaseCellRe.test((row['Phase'] ?? '').trim());
const dateShape = /^\d{4}-\d{2}-\d{2}$/;
roadmapContent = editProgressTableSlice(roadmapContent, (scoped) => {
let text = scoped;
const plansResult = updateTableCell(text, rowMatch, 'Plans Complete', ` ${summaryCount}/${planCount} `);
if (plansResult.ok) text = plansResult.value;
const statusResult = updateTableCell(text, rowMatch, 'Status', ` ${status.padEnd(11)}`);
if (statusResult.ok) text = statusResult.value;
// Preserve only a valid ISO date (#1161: idempotent; self-heal garbage).
// Ragged-tolerant (#2245 Blocker 2): probe the CURRENT Completed cell via
// a no-op updateTableCell write (its own tolerant row scan) rather than
// findTableWithColumns (which requires the WHOLE table to parse — a
// ragged SIBLING row elsewhere used to silently no-op this row's date
// stamp/clear too). The decision (write vs no-op) is folded into the
// newValue callback so a single updateTableCell call both reads and
// writes.
const completedResult = updateTableCell(text, rowMatch, 'Completed', (current) => {
if (isComplete) {
return dateShape.test(current.trim()) ? current : ` ${today} `;
}
return ' ';
});
if (completedResult.ok) text = completedResult.value;
return text;
});
// Update plan count in phase detail section.
// Three recognised forms (all tolerated; canonical template uses the first):
// `**Plans**: N plans` — bold word + outer colon (gsd-core/templates/roadmap.md)
// `**Plans:** N plans` — bold "Plans:" (colon inside bold)
// `Plans: N plans` — plain text header
//
// #2853 / #3584: the verb owns the count token ONLY — it must not destroy
// hand-written prose a human placed on the line. Group $1 = phase header →
// `Plans:` label + trailing whitespace (unchanged). Group $2 = the existing
// count token to replace: matches `N/N plans complete`, `N/N plans executed`,
// or the bare template `N plan(s)` form — singular is part of the tool's OWN
// grammar (gsd-core/templates/roadmap.md:62 ships `**Plans**: 1 plan` as the
// documented one-plan-phase shape), so the `s` is optional there (bug #3584
// Finding B; pre-fix a bare `1 plan` fell into the drop-everything path and
// was accidentally overwritten with the correct count — post-fix it must be
// recognised as a token in its own right or it freezes stale forever). Group
// $3 = whatever else is on the line (`[^\r\n]*`, so a CRLF `\r` is never part
// of the match and rides along untouched in the unmatched remainder of the
// string — never stranded, never duplicated).
//
// Three arms, in order:
// 1. $2 present (a real count token) → rewrite the token, preserve $3
// verbatim (an annotation a human wrote after a real count; #2853).
// 2. $2 absent AND $3, trimmed, is the fresh-template PLACEHOLDER shipped
// by gsd-core/templates/roadmap.md — either
// `[Number of plans, e.g., "3 plans" or "TBD"]` (line 37) or
// `[Number of plans]` (lines 51/75/88) → replace it with the computed
// count. Detected POSITIVELY on the distinctive `Number of plans`
// wording (anchored, case-insensitive), NEVER on "wholly bracketed" —
// a bracketed HUMAN annotation such as `[Deferred pending re-scope]`
// is structurally identical but must be arm-3 preserved (bug #3584
// Finding A).
// 3. Anything else (freeform prose, `TBD` / `TBD — annotation`, a
// bracketed human note, the first line of a wrapped sentence, an
// empty value) → leave the whole matched line untouched by returning
// `_match` unchanged. An untouched first line cannot orphan its own
// continuation on the next line, since the pattern never spans past
// `\n` in the first place.
const planCountPattern = new RegExp(
`(#{2,4}\\s*Phase\\s+${phasePattern}${OPTIONAL_PHASE_TAG_SOURCE}(?=[:\\s])(?:(?!\\n#{1,4}\\s)[\\s\\S])*?(?:\\*\\*Plans\\*\\*:|\\*\\*Plans:\\*\\*|(?:^|\\n)Plans:)\\s*)(\\d+\\s*\\/\\s*\\d+\\s+plans(?:\\s+(?:complete|executed))?|\\d+\\s+plans?)?([^\\r\\n]*)`,
'i'
);
const planCountText = isComplete
? `${summaryCount}/${planCount} plans complete`
: `${summaryCount}/${planCount} plans executed`;
// Positive detector for the fresh-template placeholder ONLY (bug #3584
// Finding A). Anchored to the distinctive `Number of plans` wording that
// gsd-core/templates/roadmap.md actually ships, not to "anything in
// brackets" — a bracketed human annotation like `[Deferred pending
// re-scope]` is structurally bracketed too but carries none of this
// wording, so it correctly falls through to arm 3 untouched.
const isTemplatePlaceholder = (value: string): boolean => {
const trimmed = value.trim();
return /^\[\s*Number of plans\b[\s\S]*\]$/i.test(trimmed);
};
roadmapContent = replaceInCurrentMilestone(roadmapContent, planCountPattern, (_match, label, existingCount, trailing) => {
if (existingCount) {
// Arm 1: real count token — rewrite it, preserve the trailing annotation.
return `${label}${planCountText}${trailing}`;
}
if (isTemplatePlaceholder(trailing)) {
// Arm 2: fresh-template placeholder — replace with the count.
return `${label}${planCountText}`;
}
// Arm 3: freeform prose, TBD, a bracketed human annotation, a wrapped
// sentence's first line, or an empty value — leave the line exactly as
// it was.
return _match;
});
// If complete: check checkbox
if (isComplete) {
const checkboxPattern = new RegExp(
`(-\\s*\\[)[ ](\\]\\s*.*Phase\\s+${phasePattern}${OPTIONAL_PHASE_TAG_SOURCE}[:\\s][^\\n]*)`,
'i'
);
roadmapContent = replaceInCurrentMilestone(roadmapContent, checkboxPattern, `$1x$2 (completed ${today})`);
}
// Mark completed plan checkboxes (e.g. "- [ ] 50-01-PLAN.md", "- [ ] 50-01:", or "- [ ] **50-01**")
for (const summaryFile of phaseInfo!.summaries) {
const planId = summaryFile.replace('-SUMMARY.md', '').replace('SUMMARY.md', '');
if (!planId) continue;
const planEscaped = escapeRegex(planId);
const planCheckboxPattern = new RegExp(
`(-\\s*\\[) (\\]\\s*(?:\\*\\*)?${planEscaped}(?:\\*\\*)?)`,
'i'
);
roadmapContent = roadmapContent.replace(planCheckboxPattern, '$1x$2');
}
// Compute the active (post-</details>) region offset ONCE. Both the
// missing-plan DETECTION and the row INSERTION must use the same active
// region string so that a plan row that exists only in an archived <details>
// block is not counted as "already present" in the active milestone section.
// (Finding 1 code-review: detection was previously running against the full
// roadmapContent, causing archived rows to suppress active-section inserts.)
const lastDetailsClose = roadmapContent.lastIndexOf('</details>');
const activeRegion = lastDetailsClose === -1
? roadmapContent
: roadmapContent.slice(lastDetailsClose + '</details>'.length);
// Compute which plan files are MISSING a checkbox row in the ACTIVE region.
// This handles three cases:
// (a) Fresh template — no rows at all: all plans are missing.
// (b) Partial gap — some rows exist, others don't: only the absent ones.
// (c) All rows present — nothing to insert (idempotent).
//
// Detection is scoped to the active region so a plan that appears in an
// archived <details> block is still correctly detected as missing from the
// active milestone section.
const missingPlans = phaseInfo!.plans.filter((planFile) => {
const planEscaped = escapeRegex(planFile);
return !new RegExp(`-\\s*\\[[x ]\\]\\s*(?:\\*\\*)?${planEscaped}`, 'i').test(activeRegion);
});
if (missingPlans.length > 0) {
// Insert missing plan checklist rows (#1163). We prefer to anchor to the
// bare `Plans:` checklist header (canonical template form) and fall back to
// the bold `**Plans**:`/`**Plans:**` summary line only when no bare header
// is present. Using two separate patterns avoids the lazy-quantifier trap
// where a single alternation would stop at the first matching alternative
// (the bold summary) before reaching the checklist header.
//
// Canonical template (gsd-core/templates/roadmap.md) uses BOTH lines:
// **Plans**: N plans ← summary (colon outside bold)
// Plans: ← checklist header (PREFERRED insertion anchor)
// Rows must land after `Plans:`, not between the summary and the header.
//
// Pattern A: anchor to bare `Plans:` header (preferred).
// Pattern B: fallback to bold summary when no bare header exists.
const insertRowsPatternA = new RegExp(
`(#{2,4}\\s*Phase\\s+${phasePattern}${OPTIONAL_PHASE_TAG_SOURCE}(?=[:\\s])(?:(?!\\n#{1,4}\\s)[\\s\\S])*?(?:^|\\n)(?:Plans:)[^\\n]*)`,
'i'
);
const insertRowsPatternB = new RegExp(
`(#{2,4}\\s*Phase\\s+${phasePattern}${OPTIONAL_PHASE_TAG_SOURCE}(?=[:\\s])(?:(?!\\n#{1,4}\\s)[\\s\\S])*?(?:\\*\\*Plans\\*\\*:|\\*\\*Plans:\\*\\*)[^\\n]*)`,
'i'
);
const sortedMissing = [...missingPlans].sort();
const newRows = sortedMissing.map(p => `- [ ] ${p}`).join('\n');
const inserter = (match: string) => `${match}\n${newRows}`;
// Scope insertion to the active (post-</details>) milestone region to
// prevent duplicate phase headings in archived sections from receiving rows.
// replaceInCurrentMilestone only accepts a string replacement, so we
// perform the scoped replace manually here (same strategy as that helper).
// Note: lastDetailsClose was computed above (shared with detection).
const scopedReplace = (src: string, pat: RegExp) => src.replace(pat, inserter);
let withRows: string;
if (lastDetailsClose === -1) {
// activeRegion === roadmapContent when there are no </details> blocks.
const regionA = scopedReplace(activeRegion, insertRowsPatternA);
withRows = regionA !== activeRegion ? regionA : scopedReplace(activeRegion, insertRowsPatternB);
} else {
const beforeDetails = roadmapContent.slice(0, lastDetailsClose + '</details>'.length);
const regionA = scopedReplace(activeRegion, insertRowsPatternA);
const afterWithRows = regionA !== activeRegion ? regionA : scopedReplace(activeRegion, insertRowsPatternB);
withRows = beforeDetails + afterWithRows;
}
if (withRows !== roadmapContent) {
roadmapContent = withRows;
// Mark any newly-inserted rows that already have summaries as complete
for (const summaryFile of phaseInfo!.summaries) {
const planId = summaryFile.replace('-SUMMARY.md', '').replace('SUMMARY.md', '');
if (!planId) continue;
const planEscaped = escapeRegex(planId);
const planCheckboxPattern = new RegExp(
`(-\\s*\\[) (\\]\\s*(?:\\*\\*)?${planEscaped}(?:\\*\\*)?)`,
'i'
);
roadmapContent = roadmapContent.replace(planCheckboxPattern, '$1x$2');
}
}
}
// #3957 (B9.4): write and report an update only when the transforms
// above actually produced different bytes — mirroring the sibling
// `cmdRoadmapAnnotateDependencies`'s existing `nextContent !== content`
// gate. Previously this wrote and reported `updated: true`
// unconditionally, even on an idempotent re-run that changed nothing.
if (roadmapContent !== originalContent) {
platformWriteSync(roadmapPath, roadmapContent);
updated = true;
}
});
const computed = {
phase: phaseNum,
plan_count: planCount,
summary_count: summaryCount,
status,
complete: isComplete,
verification_stale_check_indeterminate: verificationStaleCheckIndeterminate,
};
if (updated) {
output({ updated: true, ...computed }, raw, `${summaryCount}/${planCount} ${status}`);
} else {
declineNoOp(
raw,
'updated',
"no changes were needed — ROADMAP.md already reflects this phase's plan/summary counts and status",
"roadmap update-plan-progress skipped — no changes were needed; ROADMAP.md already reflects this phase's plan/summary counts and status.",
computed,
);
}
}
// ─── cmdRoadmapAnnotateDependencies ───────────────────────────────────────────
/**
* Annotate the ROADMAP.md plan list for a phase with wave dependency notes
* and a cross-cutting constraints subsection derived from PLAN frontmatter.
*
* Wave dependency notes: "Wave 2 — blocked on Wave 1 completion" inserted as
* bold headers before each wave group in the plan checklist.
*
* Cross-cutting constraints: must_haves.truths strings that appear in 2+ plans
* are surfaced in a "Cross-cutting constraints" subsection below the plan list.
*
* The operation is idempotent: if wave headers already exist in the section
* the function returns without modifying the file.
*/
function cmdRoadmapAnnotateDependencies(cwd: string, phaseNum: string | null | undefined, raw: boolean): void {
if (!phaseNum) {
error('phase number required for roadmap annotate-dependencies');
}
const roadmapPath = planningPaths(cwd).roadmap;
if (!fs.existsSync(roadmapPath)) {
declineNoOp(raw, 'updated', 'ROADMAP.md not found', 'roadmap annotate-dependencies skipped — ROADMAP.md not found.');
return;
}
const phaseInfo = findPhaseInternal(cwd, phaseNum);
// #3957 (B9.1): distinguish "phase does not resolve at all" from "phase
// resolves but has zero plans" — previously both collapsed into the same
// 'no plans found for phase' reason, which is simply false for the first
// case (there IS no such phase to have plans).
if (!phaseInfo) {
declineNoOp(
raw,
'updated',
`phase ${phaseNum} not found`,
`roadmap annotate-dependencies skipped — phase ${formatDiagnosticToken(String(phaseNum))} not found.`,
{ phase: phaseNum },
);
return;
}
if (phaseInfo.plans.length === 0) {
declineNoOp(
raw,
'updated',
`phase ${phaseNum} has no plans`,
`roadmap annotate-dependencies skipped — phase ${formatDiagnosticToken(String(phaseNum))} has no plans.`,
{ phase: phaseNum },
);
return;
}
// Read each PLAN.md and extract wave + must_haves.truths
const planData: Array<{ planFile: string; planId: string; wave: number; truths: unknown[] }> = [];
for (const planFile of phaseInfo.plans) {
const planPath = path.join(path.resolve(cwd, phaseInfo.directory), planFile);
try {
const content = fs.readFileSync(planPath, 'utf-8');
const fm = extractFrontmatter(content, planPath);
const wave = parseInt(fm.wave as string, 10) || 1;
const planId = planFile.replace(/-PLAN\.md$/i, '').replace(/PLAN\.md$/i, '');
const truths = parseMustHavesBlock(content, 'truths') || [];
planData.push({ planFile, planId, wave, truths });
} catch { /* skip unreadable plans */ }
}
if (planData.length === 0) {
declineNoOp(
raw,
'updated',
'could not read plan frontmatter',
'roadmap annotate-dependencies skipped — could not read plan frontmatter for any plan in this phase.',
);
return;
}
// Group plans by wave (sorted)
const waveGroups = new Map<number, typeof planData>();
for (const p of planData) {
if (!waveGroups.has(p.wave)) waveGroups.set(p.wave, []);
waveGroups.get(p.wave)!.push(p);
}
const waves = [...waveGroups.keys()].sort((a, b) => a - b);
// Find cross-cutting truths: appear in 2+ plans (de-duplicated, case-insensitive).
//
// Issue #2770: must **coerce, not skip**. A previous guard
// `if (typeof t !== 'string') continue` silently dropped numeric scalars
// (YAML ints like `- 3`) and kv-shaped truths (`- title: X`), so the
// cross-cutting analysis lost real constraints rather than crashing on
// `t.trim()`. We coerce primitives via `String(t)` and extract a sensible
// string field from object-shaped items produced by parseMustHavesBlock's
// continuation-kv path (issue #2757 produces those shapes for nested keys).
const truthCounts = new Map<string, TruthValue>();
for (const { truths } of planData) {
const seen = new Set<string>();
for (const t of truths) {
const text = coerceTruthToString(t);
if (!text) continue;
const trimmed = text.trim();
const key = trimmed.toLowerCase();
if (!key || seen.has(key)) continue;
seen.add(key);
if (!truthCounts.has(key)) truthCounts.set(key, { count: 0, text: trimmed });
truthCounts.get(key)!.count++;
}
}
const crossCuttingTruths = [...truthCounts.values()]
.filter(v => v.count >= 2)
.map(v => v.text);
// Patch ROADMAP.md
let updated = false;
withPlanningLock(cwd, () => {
const content = fs.readFileSync(roadmapPath, 'utf-8');
// #3413: preserve the file's own EOL style when the checklist block below
// is rebuilt and spliced back in — splitLines() cleans each captured line
// of any dangling \r, so rejoining with a bare '\n' would silently
// downgrade a CRLF ROADMAP.md's rewritten block to LF only.
const eol = detectEol(content);
// Find the phase section.
// #3537: padding-tolerant fragment so the caller's resolved padded id
// matches un-padded ROADMAP headings.
const phaseEscaped = phaseMarkdownRegexSource(phaseNum);
const phaseHeaderPattern = new RegExp(`(#{2,4}\\s*Phase\\s+${phaseEscaped}${OPTIONAL_PHASE_TAG_SOURCE}:[^\\n]*)`, 'i');
const phaseMatch = content.match(phaseHeaderPattern);
if (!phaseMatch) return;
const phaseStart = phaseMatch.index!;
const restAfterHeader = content.slice(phaseStart);
const nextPhaseOffset = restAfterHeader.slice(1).search(/\n#{2,4}\s+Phase\s+\d/i);
const phaseEnd = nextPhaseOffset >= 0 ? phaseStart + 1 + nextPhaseOffset : content.length;
const phaseSection = content.slice(phaseStart, phaseEnd);
// Idempotency: skip if annotation markers already present
if (
/\*\*Wave\s+\d+/i.test(phaseSection) ||
/\*\*Cross-cutting constraints:\*\*/i.test(phaseSection)
) return;
// Find the Plans: section within the phase section.
// #3691 Bug 1: `Plans:\s*\n` required no text after the colon, missing variants like
// `Plans: 3 plans across 2 waves\n` or `**Plans:** 3 plans\n` (bold-wrapped).
// `\*{0,2}Plans\*{0,2}:[^\n]*\n` accepts any text (or none) after the colon
// and tolerates optional `**` markdown bold wrappers on either side.
// The checklist group uses `+` (not `*`) so that a bold `**Plans:**` description
// line with no immediately-following checklist items (e.g. a summary line above a
// separate bare `Plans:` block) does not consume the match and prevent the actual
// list from being found.
// Review fix (F2): `(?:^|\n)` anchors the match to start-of-line so mid-line
// occurrences like `***Plans:***` embedded in a sentence or `OpenPlans: foo`
// do not trigger a false match. Groups 1 and 2 retain the same semantics.
// #3415: empirically verified linear-time to 10.9MB / 320,000 lines of adversarial
// checklist input (0.31ms@1000 lines -> 8.4ms@320,000 lines). The outer `+` group has
// no trailing constraint after it in the pattern, so a successful greedy pass never
// needs to explore alternate `\r?\n?` boundary partitions to satisfy something later —
// it accepts the first complete parse and stops, which rules out the #2128-class
// ambiguous-boundary blowup despite the nested-quantifier shape. Non-global match on
// already phase-sliced content, not the whole file.
// eslint-disable-next-line local/no-unbounded-quantifier -- outer `+` has no trailing constraint to force re-partitioning; measured linear to 10.9MB
const plansBlockMatch = phaseSection.match(/(?:^|\r?\n)(\*{0,2}Plans\*{0,2}:[^\r\n]*\r?\n)((?:\s*-\s*\[[ x]\][^\r\n]*\r?\n?)+)/i);
if (!plansBlockMatch) return;
const plansHeader = plansBlockMatch[1];
const existingList = plansBlockMatch[2];
const listLines = splitLines(existingList).filter(l => /^\s*-\s*\[/.test(l));
if (listLines.length === 0) return;
// #314 perf: build a first-wins Map so per-line lookup is O(1) instead of O(plans).
// First-wins mirrors .find() semantics: if the same planId appears more than once
// in planData, the earlier entry wins — identical to what .find() returned before.
const planById = new Map<string, typeof planData[number]>();
for (const p of planData) {
if (!planById.has(p.planId)) planById.set(p.planId, p);
}
// Build wave-annotated plan list
const linesByWave = new Map<number, string[]>();
for (const line of listLines) {
// Match plan ID from line: "- [ ] 01-01-PLAN.md — ..." or "- [ ] 01-01: ..."
// #3691 Bug 3: `[\w-]+?` excluded `.`, so decimal IDs like `02.3-01` were captured
// as `02` only and never matched planData entries. `[\w.-]+?` preserves the
// terminating alternation (`-PLAN.md|.md|:|\s—`) as the boundary anchor.
const idMatch = line.match(/\[\s*[x ]\s*\]\s*([\w.-]+?)(?:-PLAN\.md|\.md|:|\s—)/i);
const planId = idMatch ? idMatch[1] : null;
// Review fix (F3): reject malformed IDs that start with `.`, contain consecutive
// dots, or otherwise violate the `^\w[\w.-]*$` contract. A leading-dot ID
// (e.g. `.invalid-PLAN.md`) would silently default to wave 1 — defensively
// skip the line instead so corrupted ROADMAP entries don't corrupt wave layout.
if (planId && !/^\w[\w.-]*$/.test(planId)) continue;
const planEntry = planId ? (planById.get(planId) || null) : null;
const wave = planEntry ? planEntry.wave : 1;
if (!linesByWave.has(wave)) linesByWave.set(wave, []);
linesByWave.get(wave)!.push(line);
}
const annotatedLines: string[] = [];
const sortedWaves = [...linesByWave.keys()].sort((a, b) => a - b);
for (let i = 0; i < sortedWaves.length; i++) {
const w = sortedWaves[i];
const waveLines = linesByWave.get(w)!;
if (sortedWaves.length > 1) {
const dep = i > 0 ? ` *(blocked on Wave ${sortedWaves[i - 1]} completion)*` : '';
annotatedLines.push(`**Wave ${w}**${dep}`);
}
annotatedLines.push(...waveLines);
if (i < sortedWaves.length - 1) annotatedLines.push('');
}
// Append cross-cutting constraints subsection if any found
if (crossCuttingTruths.length > 0) {
annotatedLines.push('');
annotatedLines.push('**Cross-cutting constraints:**');
for (const t of crossCuttingTruths) {
annotatedLines.push(`- ${t}`);
}
}
const newListBlock = joinLines(annotatedLines, eol) + eol;
// #1103: when `(?:^|\r?\n)` consumed a leading terminator (mid-string
// match), re-emit it verbatim so the line preceding the Plans: header is
// not fused onto it. #3413: the widened `(?:^|\r?\n)` can now consume a
// 2-char `\r\n` — re-emit whatever was actually captured (`''`, `'\n'`,
// or `'\r\n'`), not a hardcoded `'\n'`, or a CRLF file loses its `\r`.
const leadingMatch = /^\r?\n/.exec(plansBlockMatch[0]);
const leadingNewline = leadingMatch ? leadingMatch[0] : '';
// Review fix (#3413 security): use the FUNCTION-replacement form. The
// string-replacement form expands String#replace's special patterns
// (`$&`, `` $` ``, `$'`, `$$`, `$1`-`$9`) inside the replacement — and
// newListBlock is built from author-controlled truths/plan-file content,
// so a line containing a literal `` $` `` (etc.) would splice unrelated
// surrounding phaseSection text into the result. A function replacer is
// never pattern-interpreted.
const newPhaseSection = phaseSection.replace(
plansBlockMatch[0],
() => leadingNewline + plansHeader + newListBlock
);
const nextContent = content.slice(0, phaseStart) + newPhaseSection + content.slice(phaseEnd);
if (nextContent === content) return;
platformWriteSync(roadmapPath, nextContent);
updated = true;
});
output({
updated,
phase: phaseNum,
waves: waves.length,
cross_cutting_constraints: crossCuttingTruths.length,
}, raw, updated ? `annotated ${waves.length} wave(s), ${crossCuttingTruths.length} constraint(s)` : 'skipped (already annotated or no plan list)');
}
export = {
cmdRoadmapGetPhase,
getRoadmapPhaseWithFallback,
cmdRoadmapAnalyze,
cmdRoadmapMilestoneScope,
cmdRoadmapUpdatePlanProgress,
cmdRoadmapAnnotateDependencies,
buildPhaseHeadingRegex,
};