/** * 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; // #4906 Phase 2 (#4917/ADR-4910): the PlanningDoc parse -> mutate -> serialize // seam, mirroring phase.cts's already-migrated `writePlansField` site. import { parsePlanningDoc, findField, readNode, setFieldValue, serialize } from './planning-document.cjs'; // ─── 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)[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, convention?: string | null): 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. // #612: `convention` threaded from the one caller (which already threads it // into matchPhaseDirs) so a bracket dir scopes by its real token. const scopedFiles = scopeToPhase(phaseFiles, path.basename(phaseDir), convention); 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). // #4731: multiline-aware — hard-wrapped Goals read past the line break. const goal = roadmapParserModule.extractPhaseFieldMultiline(section, 'Goal'); // 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; }; // #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 // `[msd.02] 01` and `[MSD.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 `[MSD.2]` is malformed rather than an // alternate spelling of `[MSD.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. // #4478: line-anchored (`^ {0,3}`, `/m`) — unanchored, `#{2,4}` matched a // `### Phase N:`-shaped mention ANYWHERE `exec()`'s scan reached: mid-sentence // prose, inside a blockquote, inside an inline code span (backtick-quoted on // the same line, not a fenced block `tokenizeHeadings` would exclude). Any // such line minted a phantom phase entry, inflating phase_count and able to // collide on a phase NUMBER with a real heading nearby. `{0,3}` leading // spaces mirrors `tokenizeHeadings`'s own CommonMark ATX-heading tolerance // (src/markdown-sectionizer.cts:453) so a legitimately-indented heading that // matched before this fix still matches after it. // 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(`^ {0,3}#{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]+)`, 'gim'); // 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(); 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. // #4478 follow-up (independent code review on this same fix): ` {0,3}` after // the literal `\n` mirrors phasePattern's own new leading-space tolerance // above -- without it, a legitimately-indented (1-3 space) NEXT phase // heading was invisible to this boundary lookup, letting the PRIOR phase's // goal/mode/depends_on extraction bleed across the section boundary into // the next phase's own body. const nextHeader = restOfContent.match(new RegExp(`\\n {0,3}#{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 goal = roadmapParserModule.extractPhaseFieldMultiline(section, 'Goal'); 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('MSD.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), convention); 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`. // #612: `convention` (a parameter of this function, same thread as // matchPhaseDirs above) rides into completion so a bracket phase dir // resolves and scopes its verification report like its legacy twin. const completionResult = isPhaseComplete(path.join(phasesDir, dirMatch), { convention }); 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 `- [ ] **[MSD.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: // `- [ ] **[MSD.999] 01: Icebox**` written above `- [ ] **[MSD.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(); 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 `` 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(''); const milestoneOffset = lastDetailsClose === -1 ? 0 : lastDetailsClose + ''.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 msd-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); // ADR-3180 §7.4 read/write-path symmetry with the threaded site at ~583: // thread convention here too, so this write path's completion reading // agrees with the read path's under the bracket convention. const convention = resolvePhaseIdConvention(cwd); const completionResult = isPhaseComplete(phaseDir, { convention }); 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; // #4247: the refusal flag. The write/report decision below must be keyed to // "a writable roadmap representation of THIS phase was found", never to "any // byte moved". On a checklist-form ROADMAP (`- [ ] **Phase N: …**`, the // roadmapper's own summary-checklist form) every phase-targeted grammar // below requires an ATX `#{2,4} Phase N` heading and therefore finds // nothing; the Progress-table row is the only other writable target, and // when its Phase cell does not match `phaseCellRe` (e.g. a word-prefixed // `Phase 68` cell — deliberately unrecognized on the read side too, // `deriveProgressFromRoadmap`'s `/^\d/` data-row filter) the command used // to fall through to unrelated byte deltas (an UN-scoped plan-checkbox mark // anywhere in the document) and report `updated: true` while the phase's // own row stayed untouched — with the file-global write then letting the // platform write seam's markdown normalization inject blank lines around // other phases' bullets, splitting hand-wrapped sentences mid-entry. The // refusal below declines with the analyzer's own `missing_phase_details` // vocabulary and leaves ROADMAP.md byte-identical. let missingPhaseDetails = 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); // #4247: ONE local source for the ATX phase-heading anchor that every // section-scoped writer below (`planSectionPattern`, // `insertRowsPatternA|B`) starts with — extracted so the target-detection // gate below reads the SAME grammar the writers anchor on, and a future // edit to one cannot drift from the other three copies. const phaseHeadingAnchor = `#{2,4}\\s*Phase\\s+${phasePattern}${OPTIONAL_PHASE_TAG_SOURCE}(?=[:\\s])`; // #4247: target detection runs against the ORIGINAL content's active // (post-) region — the same milestone scoping every writer // below applies — so the gate asks "does the file carry a writable phase // representation" rather than "did some regex fire mid-transform". const gateDetailsClose = originalContent.lastIndexOf(''); const gateActiveRegion = gateDetailsClose === -1 ? originalContent : originalContent.slice(gateDetailsClose + ''.length); // Heading target: the exact grammar `planSectionPattern` / // `insertRowsPatternA|B` anchor on (an ATX phase heading for this phase). const headingTargetFound = new RegExp(phaseHeadingAnchor, 'i').test(gateActiveRegion); // Checklist target: when the phase is complete, its own checklist bullet // (`- [ ] **Phase N: …**`) IS a writable phase row — the completion // checkbox stamp below updates it. Same grammar as that writer, widened // one notch to `[ x]` so an ALREADY-checked bullet still counts as a // found target: an idempotent re-run then takes the honest // "no changes were needed" decline instead of this refusal. const checklistTargetFound = isComplete && new RegExp( `-\\s*\\[[ x]\\]\\s*.*Phase\\s+${phasePattern}${OPTIONAL_PHASE_TAG_SOURCE}[:\\s]`, 'i', ).test(gateActiveRegion); // Table-row target: set by the row-scoped cell updates below. let tableRowFound = false; // 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): 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; tableRowFound = true; } const statusResult = updateTableCell(text, rowMatch, 'Status', ` ${status.padEnd(11)}`); if (statusResult.ok) { text = statusResult.value; tableRowFound = true; } // 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; tableRowFound = true; } 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 (msd-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 (msd-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 msd-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. // #4906 Phase 2 (#4917/ADR-4910): migrated off the one-capture-group // regex that replaced to end of line onto the PlanningDoc `boldField` // write seam. `planSectionPattern` scopes the match to phase N's OWN // detail section (heading + body up to the next heading) — the same // window `phaseHeadingAnchor`'s siblings anchor on — and the callback // below runs parse -> classify -> (maybe) mutate -> serialize entirely // within that section text, mirroring phase.cts's `writePlansField`. const planSectionPattern = new RegExp( `${phaseHeadingAnchor}(?:(?!\\n#{1,4}\\s)[\\s\\S])*`, '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 // msd-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. Kept as a // caller-side classifier (NOT seam grammar) reused below against the // `boldField` node's own parsed `value`. const isTemplatePlaceholder = (value: string): boolean => { const trimmed = value.trim(); return /^\[\s*Number of plans\b[\s\S]*\]$/i.test(trimmed); }; // Positive detectors for the two "real count token" shapes (#2853 / // #3584 Finding B): a fraction count (`N/M plans complete|executed`) or a // bare singular/plural count (`N plan`/`N plans`, the fresh single-plan // template shape at msd-core/templates/roadmap.md:62). Either one is an // existing count token to overwrite (arm 1), never template placeholder // (arm 2) or freeform prose (arm 3). // #4906 regression fix: PREFIX match (not full-string) — a real count // token may have a glued-on annotation with no ` — ` separator (e.g. // `0/1 plans executed (11-16 are gap closure from VERIFICATION)`), which // `parseBoldFieldLine`'s em-dash-only trailing-content split leaves // entirely inside `value` (TRAILING_SEPARATOR_RE in planning-document.cts // is unchanged and correct — this is a caller-side classification fix, // not a seam fix). Returns the matched prefix length, or -1 if no match. const fractionCountPrefixLength = (value: string): number => { const m = value.match(/^\d+\s*\/\s*\d+\s+plans(?:\s+(?:complete|executed))?/i); return m ? m[0].length : -1; }; const bareCountPrefixLength = (value: string): number => { const m = value.match(/^\d+\s+plans?/i); return m ? m[0].length : -1; }; roadmapContent = replaceInCurrentMilestone(roadmapContent, planSectionPattern, (sectionText: string): string => { const parsed = parsePlanningDoc(sectionText, 'ROADMAP.md'); if (!parsed.ok) { // Unreadable section (e.g. an unterminated frontmatter fence) — // leave it byte-identical rather than throwing. return sectionText; } const fieldId = findField(parsed.value, 'Plans'); if (!fieldId) { // #4906 regression (#1163, caught by msd-test): a hand-edited or // pre-template ROADMAP.md may carry a PLAIN (non-bold) `Plans:` line // rather than the canonical `**Plans**:`/`**Plans:**` bold field. // BOLD_FIELD_RE is deliberately bold-only (widening it to any bare // `Label:` would register ordinary prose like "Note: see below" as a // spurious field seam-wide) — this is domain knowledge about ONE // field's legacy tolerated shape, the same class of thing // `isTemplatePlaceholder` above already keeps caller-side rather // than seam grammar, so the fallback lives here, not in // planning-document.cts. const plainMatch = sectionText.match(/^([ \t]*)Plans:([ \t]*)([^\r\n]*)$/m); if (!plainMatch) { // No `**Plans:**`/`**Plans**:`/plain `Plans:` line in this phase's // own section — nothing to write; not a failure (mirrors the old // regex's silent no-match no-op). return sectionText; } const [whole, indent, spacing, plainValue] = plainMatch; const plainFractionLen = fractionCountPrefixLength(plainValue); const plainBareLen = bareCountPrefixLength(plainValue); const plainCountPrefixLen = plainFractionLen >= 0 ? plainFractionLen : plainBareLen; if (plainCountPrefixLen < 0 && !isTemplatePlaceholder(plainValue)) { // Arm 3: freeform prose, TBD, a bracketed human annotation, or an // empty value — leave the section exactly as it was. return sectionText; } const plainSuffix = plainCountPrefixLen >= 0 ? plainValue.slice(plainCountPrefixLen) : ''; const newPlainLine = `${indent}Plans:${spacing}${planCountText}${plainSuffix}`; const start = plainMatch.index ?? sectionText.indexOf(whole); return sectionText.slice(0, start) + newPlainLine + sectionText.slice(start + whole.length); } const current = readNode(parsed.value, fieldId); if (!current.ok) { return sectionText; } const currentValue = current.value; const fractionPrefixLen = fractionCountPrefixLength(currentValue); const barePrefixLen = bareCountPrefixLength(currentValue); const countPrefixLen = fractionPrefixLen >= 0 ? fractionPrefixLen : barePrefixLen; if (countPrefixLen < 0 && !isTemplatePlaceholder(currentValue)) { // Arm 3: freeform prose, TBD, a bracketed human annotation, or an // empty value — leave the section exactly as it was. return sectionText; } // Arm 1 (real count token, possibly with a glued-on no-separator // annotation re-attached verbatim as `suffix`) or arm 2 (fresh-template // placeholder, whole value replaced): write the computed count. // `setFieldValue`'s valueSpan/trailingSpan split additionally preserves // any EM-DASH-separated trailing annotation (#2853) automatically — no // separate "preserve trailing" branch needed for that shape. const suffix = countPrefixLen >= 0 ? currentValue.slice(countPrefixLen) : ''; const newValueToWrite = planCountText + suffix; const staged = setFieldValue(parsed.value, fieldId, newValueToWrite); if (!staged.ok) { return sectionText; } const out = serialize(staged.value); if (!out.ok) { // `hasUnreadableNodes` refusal (ADR-4910 amendment) — a ragged // SIBLING node elsewhere in this same section refuses the whole // splice. Never throw; leave the section unchanged. return sectionText; } return out.value; }); // 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**") // #4741: tick only plans the phase's own COUNT still counts. `plans` is the // superseded-filtered set (#2349 via scanPhasePlans) while `summaries` is // the raw *-SUMMARY.md listing — a superseded plan can carry a SUMMARY // (e.g. `status: halted`), and ticking it read as "executed" right under a // count line that excludes it. The prefix match mirrors the checkbox regex // below (rows match by planId prefix, which the PLAN-01.md naming shape // relies on), so non-superseded plans tick exactly as before. const tickableSummaries = phaseInfo!.summaries .map((summaryFile) => ({ summaryFile, planId: summaryFile.replace('-SUMMARY.md', '').replace('SUMMARY.md', '') })) .filter(({ planId }) => planId !== '' && phaseInfo!.plans.some((planFile) => planFile.startsWith(planId))); for (const { planId } of tickableSummaries) { const planEscaped = escapeRegex(planId); const planCheckboxPattern = new RegExp( `(-\\s*\\[) (\\]\\s*(?:\\*\\*)?${planEscaped}(?:\\*\\*)?)`, 'i' ); roadmapContent = roadmapContent.replace(planCheckboxPattern, '$1x$2'); } // Compute the active (post-) 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
// 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('
'); const activeRegion = lastDetailsClose === -1 ? roadmapContent : roadmapContent.slice(lastDetailsClose + ''.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
block is still correctly detected as missing from the // active milestone section. // // #4786: a plan row may be written WITH the `-PLAN.md` suffix (canonical // template form) or WITHOUT it (hand-written form: `- [x] 659-01 — desc`). // The tick loop above keys on the bare planId stem with a PREFIX match // (pre-existing, out of scope here), while detection required the full // `-PLAN.md` filename — so every suffix-less row was counted missing and // the insertion fired BESIDE the recognized list — 32 checkbox lines for // 16 plans, exit 0. The stem arm below accepts the bare id only up to a // boundary (whitespace / `:` / dashes / `-PLAN.md` / `.md` / `**` / `)`), // so `5-011` never satisfies `5-01`. Deliberately NOT in the boundary set: // `.` — a dotted sub-id (`5-01.5`) is a real distinct plan, and counting // its row as 5-01's presence would suppress a genuine insertion. const missingPlans = phaseInfo!.plans.filter((planFile) => { const planEscaped = escapeRegex(planFile); if (new RegExp(`-\\s*\\[[x ]\\]\\s*(?:\\*\\*)?${planEscaped}`, 'i').test(activeRegion)) return false; const stem = planFile.replace(/-PLAN\.md$/i, ''); const stemEscaped = escapeRegex(stem); const stemPresent = new RegExp( `-\\s*\\[[x ]\\]\\s*(?:\\*\\*)?${stemEscaped}(?=$|\\s|:|—|–|-PLAN\\.md|\\.md|\\*\\*|\\))`, 'i' ).test(activeRegion); return !stemPresent; }); 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 (msd-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( `(${phaseHeadingAnchor}(?:(?!\\n#{1,4}\\s)[\\s\\S])*?(?:^|\\n)(?:Plans:)[^\\n]*)`, 'i' ); const insertRowsPatternB = new RegExp( `(${phaseHeadingAnchor}(?:(?!\\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-
) 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 blocks. const regionA = scopedReplace(activeRegion, insertRowsPatternA); withRows = regionA !== activeRegion ? regionA : scopedReplace(activeRegion, insertRowsPatternB); } else { const beforeDetails = roadmapContent.slice(0, lastDetailsClose + ''.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 // (#4741: same superseded-filtered tick list as the loop above — a // pre-existing superseded row must stay unchecked on this path too). for (const { planId } of tickableSummaries) { 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. // // #4247: ...but ONLY when a writable representation of THIS phase was // found. Without the target gate, a byte delta from an unrelated // transform (the un-scoped plan-checkbox mark) satisfied the #3957 gate // and produced a success-shaped `updated: true` while the phase's own // row stayed untouched — and the file-global write let the platform // write seam's markdown normalization reflow unrelated entries. When no // target exists the command refuses: no write at all, so ROADMAP.md is // left byte-identical, and the caller gets a typed // `missing_phase_details` decline instead of a false green. const phaseRepresentationFound = tableRowFound || headingTargetFound || checklistTargetFound; if (phaseRepresentationFound && roadmapContent !== originalContent) { platformWriteSync(roadmapPath, roadmapContent); updated = true; } if (!phaseRepresentationFound) { missingPhaseDetails = 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 if (missingPhaseDetails) { // #4247: honest refusal — the reason names the real condition (the // analyzer's `missing_phase_details` vocabulary), never "already // reflects", which was false: the ROADMAP was never able to record this // phase's progress in the first place. declineNoOp( raw, 'updated', 'missing_phase_details', `roadmap update-plan-progress skipped — ROADMAP.md has no writable entry for phase ${formatDiagnosticToken(String(phaseNum))} (no matching Progress-table row, no phase detail section, and no checklist entry this command can update). ROADMAP.md was left unchanged.`, computed, ); } 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(); 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(); for (const { truths } of planData) { const seen = new Set(); 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(); for (const p of planData) { if (!planById.has(p.planId)) planById.set(p.planId, p); } // Build wave-annotated plan list const linesByWave = new Map(); 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, };