/** * Pure phase-id parsing/matching helpers — normalize, token match, * milestone/phase-dir id parsing, phase-markdown regex builders. * * Extracted from core.cts (ADR-857 rollout phase 2a / issue #865). * The hand-written bodies are preserved byte-for-behaviour; only the module * boundary moved. The core.cjs re-export spine was retired in epic #1267; * callers import phase-id helpers from phase-id.cjs directly. * * Dependencies: * - ./pattern.cjs (escapeRegex — #3212 Phase 1 seam; this module is no * longer the owner of pattern-escaping, only a consumer) */ import { escapeRegex } from './pattern.cjs'; // ─── Phase-id helpers ───────────────────────────────────────────────────────── // project_code values start with an uppercase letter (e.g. PROJ, APP_CODE); // leading underscores are not valid project codes per .planning/config.json. const PROJECT_CODE_PREFIX_STRIP_RE = /^[A-Z][A-Z0-9_]*-(?=\d)/; const PROJECT_CODE_PREFIX_STRIP_RE_I = /^[A-Z][A-Z0-9_]*-(?=\d)/i; const PROJECT_CODE_PREFIX_CAPTURE_RE_I = /^([A-Z][A-Z0-9_]*)-(\d.*)/i; const OPTIONAL_PROJECT_CODE_PREFIX_SOURCE = '(?:[A-Z][A-Z0-9_]*-)?'; // #1729: phase headers may carry a parenthetical tag between the number and the // colon, e.g. `### Phase 26 (Cluster B): Title`. This optional, non-capturing // fragment is injected at every phase-header regex call site (immediately after // the phase-number token, before the colon/space delimiter) so the resolver // tolerates the tag — mirroring how `[...]` is already tolerated before `Phase`. // `[^)\n]*` keeps the match single-line (headers are one line) to avoid // over-consuming across a malformed multi-line document. Injected at the call // site (not baked into phaseMarkdownRegexSource) so it applies uniformly to // both the numeric and project-code-exact escaped sources, and so the decimal // sub-phase patterns can place it after the `.N` segment. // // Enumeration/parse call sites that read phase headers from a regex *literal* // (rather than a `new RegExp` built from an interpolated phase number) cannot // reference this constant; they inline its literal-regex mirror instead — // `(?:\s*\([^)\n]{0,200}\))?` — kept character-for-character equivalent to this // source. Both forms must change together; see the #1729 regression test. const OPTIONAL_PHASE_TAG_SOURCE = '(?:\\s*\\([^)\\n]{0,200}\\))?'; // #2128: the canonical phase-NUMBER-TOKEN grammar — a phase number with an // optional single-letter variant suffix and optional dotted sub-phases // (1, 01, 12A, 12.1, 3.2.1). This is the ENUMERATION/scan counterpart to // phaseMarkdownRegexSource: use phaseMarkdownRegexSource(n) to build a source // for ONE KNOWN number; reference this constant when a call site must match ANY // phase and capture its token. Enumeration/parse sites inline this into a // `new RegExp(...)` instead of re-deriving the grammar as a literal, so every // phase-token producer shares one owner. The anti-divergence guard // (scripts/lint-phase-id-drift.cjs) fails CI if a literal re-derivation is // introduced outside this module without a `// phase-id-owner:` justification. const PHASE_NUMBER_TOKEN_SOURCE = '\\d+[A-Z]?(?:\\.\\d+)*'; // #2528 review: the CASE-FLEXIBLE renderings of the two sources above, for call // sites that scan directory names (where a project code or a variant suffix may // legitimately be lowercase) and therefore cannot use a case-sensitive class. // // They live HERE, beside the sources they widen, because the alternative in use // was `SOURCE.replaceAll('A-Z', 'A-Za-z')` at the consuming site — a derivation // that depends on the owner rendering that exact literal. It passes // lint-phase-id-drift.cjs (no literal copy of the grammar), but the day this // module expresses the same class any other way (`[[:upper:]]`, a named // fragment, an escaped range) the replaceAll silently no-ops and the consumer // quietly narrows to uppercase-only — the failure is a NON-match, so nothing // throws and no test that only feeds uppercase input notices. Deriving it once, // where the source is defined, makes that impossible: a rename here is a // compile-visible change, not a silent behavior change three modules away. const CASE_FLEXIBLE_PROJECT_CODE_PREFIX_SOURCE = OPTIONAL_PROJECT_CODE_PREFIX_SOURCE.replaceAll('A-Z', 'A-Za-z'); const CASE_FLEXIBLE_PHASE_NUMBER_TOKEN_SOURCE = PHASE_NUMBER_TOKEN_SOURCE.replaceAll('A-Z', 'A-Za-z'); // #2232: the canonical CONTINUATION-segment grammar — a dash-separated segment // that extends a phase token (a zero-padded sub-phase or plan number, e.g. the // "01" in "02-01-setup"). getPhaseDirFromPhaseId writes these zero-padded to // exactly 2 digits, so the digit RUN of a genuine continuation is exactly 2: // #2043's `\d{2,}` (2-or-more) over-collected a slug word that merely leads // with ≥2 digits (a year: "14-2026-photos-…" yielded token "14-2026", so every // phase-locating verb reported the phase as missing). The `(?!\d)` guard caps // the run at 2 without anchoring what may follow, so call sites keep their own // trailing grammar (letter suffixes, dotted sub-phases, segment boundaries). // POLICY (locked by boundary tests): sub-phase/plan numbers ≥100 are out of the // dir-token grammar — the LEADING phase number stays unbounded (`\d+`), only // continuation segments begin with a two-digit run; consuming sites retain // their established suffix and boundary grammar. Shared from here so the five #2043 // call sites cannot drift independently (see scripts/lint-phase-id-drift.cjs). const PHASE_CONTINUATION_SEGMENT_SOURCE = '\\d{2}(?!\\d)'; const PHASE_CONTINUATION_SEGMENT_PREFIX_RE = new RegExp(`^${PHASE_CONTINUATION_SEGMENT_SOURCE}`); function isPhaseContinuationSegment(seg: string): boolean { return PHASE_CONTINUATION_SEGMENT_PREFIX_RE.test(seg); } // #612 (PR-1): bracket-convention token/heading sources, kept next to the M-NN // PHASE_NUMBER_TOKEN_SOURCE so this owner file stays the single origin of every // phase-token grammar. `src/phase-id.cts` is exempt from the #2128 drift guard // (scripts/lint-phase-id-drift.cjs) by construction, and that guard fails any // literal re-derivation of the token grammar elsewhere — so the downstream // bracket readers (PR-2: roadmap/validate/verify) must build their regexes by // interpolating these exports, never by copying the literal. // // The canonical numeric WIDTH of a bracket identity field, mirroring pad2()'s // output: exactly 2 digits, or 3+ with no leading zero. Owned here as a SOURCE // so the read side (BRACKET_PHASE_TOKEN_SOURCE, below) and the emit-side // validator (CANONICAL_NUMERIC_RE, which toDir enforces) are one rule rather // than two literals that agree today and drift tomorrow. const BRACKET_CANONICAL_NUMERIC_SOURCE = '(?:[1-9]\\d{2,}|\\d{2})'; // BRACKET_PHASE_TOKEN_SOURCE differs from PHASE_NUMBER_TOKEN_SOURCE by a // dot-OR-dash sub-separator: a bracket dir/heading numeric run is `MM-PP[.SS]` // (a hyphen joins milestone↔phase, a dot joins phase↔sub-phase), whereas M-NN // sub-phases are dot-only. // // The run is POSITIONAL, not a free repetition — `MM-PP[.SS][-LL]` — and each // position gets the width its DELIMITER can actually afford: // // MM leading unbounded — delimited by the `{CODE}.` prefix // -PP dash-1 canonical — the grammar REQUIRES this dash, so it is a field // separator, not a continuation heuristic // .SS dot canonical — a slug carries no dot (toDir sanitizes them // away), so this position cannot collide // -LL dash-2 #2232 cap — the ONLY slug-adjacent position, and therefore // the only one a slug word can collide with // // #2232 reconciliation: the slug-adjacent position interpolates the single-owner // PHASE_CONTINUATION_SEGMENT_SOURCE, so the #2232 bug class cannot reopen on the // bracket path — dir `PROJ.01-14-2026-photos-…` (a slug leading with a year) // yields `01-14`, never `01-14-2026`. // // DELIBERATE DIVERGENCE from the M-NN dir-token path (pinned by the parity gate // in tests/continuation-grammar-parity.test.cjs, which fails if these two rules // drift for a reason nobody intended): the non-slug-adjacent positions stay // WIDER than #2232's cap. Bracket admits 3+-digit milestone/phase/sub-phase // (CANONICAL_NUMERIC_RE — `[GSD.100] 05` is a pinned regression), and unlike the // M-NN continuations those positions are delimiter-disambiguated rather than // heuristically recognized, so there is no year collision to defend against. // Interpolating the cap verbatim at every position would only under-collect ids // that toDir itself emits: `PROJ.02-105-slug` (3-digit phase) would read as // `02`, and `[GSD.02] 05.100` (3-digit sub-phase) as `05`. Upstream draws this // same line for the same reason — core-utils/phase cap the paired PLAN component // while the leading phase component stays unbounded (phase numbers ≥100 are // legitimate). The trade-off this accepts is #2232's policy verbatim: a PLAN // ≥100 is out of the token grammar. // // Still deliberately MORE PERMISSIVE than parsePhaseId's strict grammar (it // admits a letter-suffixed and unpadded leading token that the parser rejects): // this is a READ-TOLERANCE source for the PR-2 readers, which must recognize a // bracket-shaped token before deciding what to do with it — it is not the // emit/identity grammar. parsePhaseId stays the arbiter of well-formedness. const BRACKET_PHASE_TOKEN_SOURCE = `\\d+[A-Z]?` + `(?:-${BRACKET_CANONICAL_NUMERIC_SOURCE}(?!\\d))?` + `(?:\\.${BRACKET_CANONICAL_NUMERIC_SOURCE}(?!\\d))?` + `(?:-${PHASE_CONTINUATION_SEGMENT_SOURCE})?` + `(?=-|$)`; // A phase HEADING intro under bracket is either a `[...]` bracket (optionally // followed by a `Phase ` label) or a bare `Phase ` label; a bare number is NOT // a phase-heading intro. The `[^\]]{1,200}` bound mirrors the existing // roadmap-parser heading regexes (ReDoS-safe: a header is one short line). const PHASE_HEADING_PREFIX_SRC = '(?:\\[[^\\]]{1,200}\\]\\s*(?:Phase\\s+)?|Phase\\s+)'; function stripProjectCodePrefix(value: unknown, caseInsensitive = true): string { const input = String(value); const re = caseInsensitive ? PROJECT_CODE_PREFIX_STRIP_RE_I : PROJECT_CODE_PREFIX_STRIP_RE; return input.replace(re, ''); } function hasProjectCodePrefix(value: unknown): boolean { return PROJECT_CODE_PREFIX_STRIP_RE_I.test(String(value)); } function normalizePhaseName(phase: unknown): string { const str = String(phase); // Strip optional project_code prefix (e.g., 'CK-01' → '01') const stripped = stripProjectCodePrefix(str, false); // Milestone-prefixed phase IDs: M-NN or M-N-N (deep decomposition). const milestoneMatch = stripped.match(/^(\d+)((?:-\d+)+)([A-Z]?(?:\.\d+)*)$/i); if (milestoneMatch) { const major = milestoneMatch[1].padStart(2, '0'); const subSegments = milestoneMatch[2].slice(1).split('-').map(s => s.padStart(2, '0')); const suffix = milestoneMatch[3] || ''; return `${major}-${subSegments.join('-')}${suffix}`; } // Standard numeric phases: 1, 01, 12A, 12.1 const match = stripped.match(/^(\d+)([A-Z])?((?:\.\d+)*)/i); if (match) { const padded = match[1].padStart(2, '0'); // Preserve original case of letter suffix (#1962). const letter = match[2] || ''; const decimal = match[3] || ''; return padded + letter + decimal; } // Custom phase IDs (e.g. PROJ-42, AUTH-101): return as-is return str; } function getMilestoneFromPhaseId(phaseId: unknown, convention?: string): string | null { // READING-B (#612): under the bracket convention the milestone comes from the // `[PROJECT.MM]` / `{CODE}.{MM}-` prefix, never the phase-token leading // integer (ADR-612 Decision 6). Gated on 'bracket' so the `null` and // 'milestone-prefixed' (M-NN) paths keep the legacy leading-int rule // (READING-A) below, byte-untouched. The optional parameter keeps this helper // pure (no config read) and backward-compatible: every existing single-arg // caller resolves to the unchanged READING-A body. if (convention === 'bracket') { const b = String(phaseId).match(/^([A-Z][A-Z0-9_]*)\.(\d+)/); if (!b) return null; const mm = parseInt(b[2], 10); if (SENTINEL_RANGES.includes(mm)) return null; // sentinel milestones have no real milestone return `v${mm}.0`; } const stripped = stripProjectCodePrefix(phaseId); const m = stripped.match(/^0*(\d+)-\d/); if (!m) return null; const major = parseInt(m[1], 10); if (major === 0 || major === 999) return null; return `v${major}.0`; } function getPhaseDirFromPhaseId(phaseId: unknown, phaseName: string | null | undefined, projectCode: string | null | undefined): string | null { const stripped = stripProjectCodePrefix(phaseId); const m = stripped.match(/^0*(\d+)-(0*(\d+(?:-\d+)*))$/); if (!m) return null; const milestone = String(parseInt(m[1], 10)).padStart(2, '0'); const subParts = m[2].split('-').map(p => String(parseInt(p, 10)).padStart(2, '0')); const sub = subParts.join('-'); const slug = phaseName ? phaseName.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') : ''; const parts = [milestone, sub, slug].filter(Boolean); const base = parts.join('-'); return projectCode ? `${projectCode}-${base}` : base; } // ─── Bracket phase-ID grammar (#612, PR-1) ────────────────────────────────── // One pure round-trippable model (ADR-612 §3 / Decision 4). parsePhaseId // accepts the display form `[PROJECT.MM] PP[.SS][-LL]` or the on-disk/token form // `{PROJECT}.{MM}-{PP}[.{SS}][-{LL|slug}]`; renderPhaseId / toDir are its two // emitters. READING-B: the milestone lives in the `[PROJECT.MM]` prefix, so no // token dimension is ever overloaded (the M-NN collapse pinned in // tests/adr-612-collision-characterization.test.cjs cannot occur on this path). // `plan` is a filename-surface dimension only — renderPhaseId emits it; toDir // drops it (directories carry a slug, not a plan). The project code follows the // repo's established `[A-Z][A-Z0-9_]*` grammar (the config-validated // project_code shape shared with OPTIONAL_PROJECT_CODE_PREFIX_SOURCE), not the // ADR §1 illustration's `[A-Z]{1,6}`, so that every project_code the config // permits (digits / underscore / >6 chars) parses. // // Strict-reject posture (ADR-612 Decision 4's `render(parse(x)) === x` // contract, held exactly): parsePhaseId accepts ONLY the canonical form of // each branch — unpadded numbers, over-padded numbers, and multi-space or // stray leading/trailing whitespace are all rejected rather than silently // normalized, so two distinct input strings can never parse to the same // tuple while one of them fails to round-trip. toDir mirrors this on the // write side: every interpolated PhaseId field is validated (PhaseId is a // structural type — nothing forces callers through parsePhaseId, so a hand- // built id must not be able to smuggle a path-traversal segment onto disk), // and the slug must sanitize to a non-empty, non-all-digit token (an empty // slug would leave a dangling trailing hyphen; an all-digit slug is // string-indistinguishable from the plan grammar's trailing tail and would // silently break the disk↔identity bijection on read-back). type PhaseId = { project: string; // 'GSD' milestone: string; // '02' (zero-padded, from the bracket/dir prefix) phase: string; // '05' (zero-padded) subphase?: string; // '03' (optional) plan?: string; // '01' (filename surface only) }; const pad2 = (n: string): string => String(parseInt(n, 10)).padStart(2, '0'); function parsePhaseId(input: string): PhaseId { // No .trim(): the match anchors (`^`...`$`) then reject leading/trailing // whitespace outright, folding that case into the same "not a bracket // phase id" rejection below rather than needing its own check. const str = String(input); // Display form: [PROJECT.MM] PP[.SS][-LL]. The match itself stays // permissive on purpose (it will happily match an unpadded number or a // multi-space run) — canonicality is enforced UNIFORMLY below via the // render round-trip (ADR-612 Decision 4) rather than by hand-tuning every // numeric / whitespace sub-pattern, so a field added later inherits the // check for free instead of needing its own regex micro-surgery. const disp = str.match(/^\[([A-Z][A-Z0-9_]*)\.(\d+)\]\s+(\d+)(?:\.(\d+))?(?:-(\d+))?$/); if (disp) { const id: PhaseId = { project: disp[1], milestone: pad2(disp[2]), phase: pad2(disp[3]) }; if (disp[4] !== undefined) id.subphase = pad2(disp[4]); if (disp[5] !== undefined) id.plan = pad2(disp[5]); // Canonicality by construction: re-render the parsed id and require // byte-equality with the input. This rejects unpadded ('[GSD.5] 5'), // over-padded ('[GSD.005] 05'), and multi-space-separated ('[GSD.02] 05') // variants uniformly, without special-casing any one of them — the emit // path (renderPhaseId) is the single source of truth for "canonical". if (renderPhaseId(id) !== str) { throw new Error(`parsePhaseId: not canonical: ${JSON.stringify(input)}`); } return id; } // Dir / token form: {PROJECT}.{MM}-{PP}[.{SS}][-{plan|slug}] const dir = str.match(/^([A-Z][A-Z0-9_]*)\.(\d+)-(\d+)(?:\.(\d+))?(?:-(.+))?$/); if (dir) { const id: PhaseId = { project: dir[1], milestone: pad2(dir[2]), phase: pad2(dir[3]) }; if (dir[4] !== undefined) id.subphase = pad2(dir[4]); // Trailing segment: a pure-integer tail is the plan; anything else is a // slug (dropped from the tuple — it is not an identity dimension). The // plan tail participates in the canonicality check below; the slug tail // is read-tolerant pass-through (a slug is not an identity dimension) and // is exempt from it. const tail = dir[5]; const tailIsPlan = tail !== undefined && /^\d+$/.test(tail); if (tailIsPlan) id.plan = pad2(tail); // Canonicality by construction, mirroring the display branch: rebuild the // exact dir/token string this id would emit and require it match the // input verbatim. Rejects unpadded milestone/phase ('GSD.2-5') and // unpadded plan tails ('GSD.02-05-1') without special-casing either. const sub = id.subphase ? `.${id.subphase}` : ''; const tailOut = tail === undefined ? '' : tailIsPlan ? `-${pad2(tail)}` : `-${tail}`; const canonical = `${id.project}.${id.milestone}-${id.phase}${sub}${tailOut}`; if (canonical !== str) { throw new Error(`parsePhaseId: not canonical: ${JSON.stringify(input)}`); } return id; } // Ambiguous / bare tokens (e.g. `02-04`, `05`, `2-01`) match neither branch, // as does a display/dir form carrying leading/trailing whitespace (the // anchors never match it): reject rather than guess a tuple (ADR-612 // conservative default). The rejection lives ONLY in this new parser — // normalizePhaseName and every other legacy reader keep accepting those // tokens unchanged. throw new Error(`parsePhaseId: not a bracket phase id: ${JSON.stringify(input)}`); } function renderPhaseId(id: PhaseId): string { const sub = id.subphase ? `.${id.subphase}` : ''; const plan = id.plan ? `-${id.plan}` : ''; return `[${id.project}.${id.milestone}] ${id.phase}${sub}${plan}`; } // PhaseId is a structural type: nothing forces a caller through parsePhaseId, // so toDir cannot trust project/milestone/phase/subphase are already // canonical — each is validated below against the exact shape parsePhaseId // itself would ever produce, closing off a hand-built id as a path-traversal // vector. PROJECT_ID_RE mirrors the parser's `[A-Z][A-Z0-9_]*` grammar; // CANONICAL_NUMERIC_RE mirrors pad2()'s output shape — exactly 2 digits, or // 3+ digits with no leading zero. It is BUILT from // BRACKET_CANONICAL_NUMERIC_SOURCE rather than re-spelled as a literal, so this // emit-side gate and the read-side token source cannot disagree about what // "canonical width" means (the anchors here make the source's trailing `(?!\d)` // guard, which the unanchored read side needs, redundant). const PROJECT_ID_RE = /^[A-Z][A-Z0-9_]*$/; const CANONICAL_NUMERIC_RE = new RegExp(`^${BRACKET_CANONICAL_NUMERIC_SOURCE}$`); function toDir(id: PhaseId, slug: string): string { if (!PROJECT_ID_RE.test(id.project)) { throw new Error(`toDir: invalid project: ${JSON.stringify(id.project)}`); } if (!CANONICAL_NUMERIC_RE.test(id.milestone)) { throw new Error(`toDir: invalid milestone: ${JSON.stringify(id.milestone)}`); } if (!CANONICAL_NUMERIC_RE.test(id.phase)) { throw new Error(`toDir: invalid phase: ${JSON.stringify(id.phase)}`); } if (id.subphase !== undefined && !CANONICAL_NUMERIC_RE.test(id.subphase)) { throw new Error(`toDir: invalid subphase: ${JSON.stringify(id.subphase)}`); } // A non-string slug (e.g. an omitted second argument) must not be silently // coerced by String(...) into the literal token 'undefined'/'null' on disk. if (typeof slug !== 'string') { throw new Error(`toDir: slug must be a string: ${JSON.stringify(slug)}`); } const sub = id.subphase ? `.${id.subphase}` : ''; // Slug guard: the slug becomes an on-disk path segment, so collapse it to a // safe lowercase token — never a path separator or `..` traversal. const safeSlug = slug.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, ''); // A slug that sanitizes to nothing (e.g. '!!!') would otherwise emit a // dangling trailing hyphen. if (!safeSlug) { throw new Error(`toDir: slug sanitizes to empty: ${JSON.stringify(slug)}`); } // An all-digit slug (e.g. '2026') is string-indistinguishable from the // parsePhaseId dir branch's plan tail, so it would re-parse as a plan, not // a slug — silently breaking the disk↔identity bijection on read-back. if (/^\d+$/.test(safeSlug)) { throw new Error(`toDir: slug must not be all-digit: ${JSON.stringify(slug)}`); } return `${id.project}.${id.milestone}-${id.phase}${sub}-${safeSlug}`; } // Milestone integers reserved as non-milestone sentinels (0.x backlog / 999.x // icebox); a phase id in these ranges has no real milestone. const SENTINEL_RANGES: readonly number[] = Object.freeze([0, 999]); function isSentinelPhaseId(phaseId: unknown, convention?: string): boolean { const s = String(phaseId); // Bracket milestone lives in the `{CODE}.{MM}` prefix. GATED on // convention === 'bracket' for the same reason as extractPhaseToken below and // getMilestoneFromPhaseId above: that prefix is string-indistinguishable from // the legacy #1324 letter-prefixed-decimal family (`P0.0-foundation` is a real // phase, NOT sentinel milestone 0) whenever the code ends in a digit. A // convention-less caller uses the legacy/bare leading-int rule below, so no // existing reader gains a false positive; the bracket reading is opt-in. if (convention === 'bracket') { const bracket = s.match(/^[A-Z][A-Z0-9_]*\.(\d+)/); // bracket: milestone in the prefix if (bracket) return SENTINEL_RANGES.includes(parseInt(bracket[1], 10)); } const legacy = stripProjectCodePrefix(s).match(/^0*(\d+)/); // legacy/bare: leading int if (!legacy) return false; return SENTINEL_RANGES.includes(parseInt(legacy[1], 10)); } /** * Render a regex source fragment matching a phase number against ROADMAP/STATE * prose regardless of zero-padding on either side. */ function phaseMarkdownRegexSource(phaseNum: unknown): string { const stripped = stripProjectCodePrefix(phaseNum); // Milestone-prefixed IDs: M-NN or M-N-N (deep). const milestoneSegments = stripped.match(/^(\d+)((?:-\d+)*)([A-Z]?(?:\.\d+)*)$/i); if (milestoneSegments && milestoneSegments[2]) { const majorUnpadded = milestoneSegments[1].replace(/^0+/, '') || '0'; const subParts = milestoneSegments[2].slice(1).split('-'); const subFragments = subParts.map(s => { const unpadded = s.replace(/^0+/, '') || '0'; return `0*${escapeRegex(unpadded)}`; }); const suffix = milestoneSegments[3] || ''; const suffixFragment = suffix ? escapeRegex(suffix) : ''; return `0*${escapeRegex(majorUnpadded)}-${subFragments.join('-')}${suffixFragment}`; } // Plain numeric phase: 1, 01, 12A, 12.1 const match = stripped.match(/^0*(\d+)([A-Z])?((?:\.\d+)*)$/i); // #3212: escapeRegex now requires a string (the seam owns coercion policy, // not this module) — String(...) here preserves this function's own // pre-existing `unknown` acceptance for callers that pass a non-string // phaseNum through to this fallback branch. if (!match) return escapeRegex(String(phaseNum)); const integer = match[1].replace(/^0+/, '') || '0'; const letter = match[2] ? escapeRegex(match[2]) : ''; const decimal = match[3] ? escapeRegex(match[3]) : ''; return `0*${escapeRegex(integer)}${letter}${decimal}`; } /** * #3599: when the caller passed a project-code-prefixed ID like `PROJ-42`, * return the exact-escaped form. */ function phaseMarkdownRegexSourceExact(phaseNum: unknown): string | null { const raw = String(phaseNum); if (!hasProjectCodePrefix(raw)) return null; return escapeRegex(raw); } function comparePhaseNum(a: unknown, b: unknown): number { // Strip optional project_code prefix before comparing const sa = stripProjectCodePrefix(a); const sb = stripProjectCodePrefix(b); const milestoneA = sa.match(/^(\d+)((?:-\d+)+)([A-Z]?(?:\.\d+)*)$/i); const milestoneB = sb.match(/^(\d+)((?:-\d+)+)([A-Z]?(?:\.\d+)*)$/i); if (milestoneA && milestoneB) { const segsA = [parseInt(milestoneA[1], 10), ...milestoneA[2].slice(1).split('-').map(s => parseInt(s, 10))]; const segsB = [parseInt(milestoneB[1], 10), ...milestoneB[2].slice(1).split('-').map(s => parseInt(s, 10))]; const maxSegs = Math.max(segsA.length, segsB.length); for (let i = 0; i < maxSegs; i++) { const av = segsA[i] !== undefined ? segsA[i] : 0; const bv = segsB[i] !== undefined ? segsB[i] : 0; if (av !== bv) return av - bv; } const sufA = milestoneA[3] || ''; const sufB = milestoneB[3] || ''; if (sufA !== sufB) return sufA < sufB ? -1 : 1; return 0; } if (milestoneA || milestoneB) return String(a).localeCompare(String(b)); const pa = sa.match(/^(\d+)([A-Z])?((?:\.\d+)*)/i); const pb = sb.match(/^(\d+)([A-Z])?((?:\.\d+)*)/i); if (!pa || !pb) return String(a).localeCompare(String(b)); const intDiff = parseInt(pa[1], 10) - parseInt(pb[1], 10); if (intDiff !== 0) return intDiff; const la = (pa[2] || '').toUpperCase(); const lb = (pb[2] || '').toUpperCase(); if (la !== lb) { if (!la) return -1; if (!lb) return 1; return la < lb ? -1 : 1; } const aDecParts = pa[3] ? pa[3].slice(1).split('.').map(p => parseInt(p, 10)) : []; const bDecParts = pb[3] ? pb[3].slice(1).split('.').map(p => parseInt(p, 10)) : []; const maxLen = Math.max(aDecParts.length, bDecParts.length); if (aDecParts.length === 0 && bDecParts.length > 0) return -1; if (bDecParts.length === 0 && aDecParts.length > 0) return 1; for (let i = 0; i < maxLen; i++) { const av = Number.isFinite(aDecParts[i]) ? aDecParts[i] : 0; const bv = Number.isFinite(bDecParts[i]) ? bDecParts[i] : 0; if (av !== bv) return av - bv; } return 0; } /** * Segmentation core shared by `extractPhaseToken` (the token VALUE) and * `isPhaseArtifact` (the DERIVABILITY check — #3511). Factored out so the two * questions ("what is this dir's token" and "did a real token exist at all") * can never diverge — see CLAUDE.md's "Generative Fix Divergence" note: this * is exactly a shared parser between two parallel surfaces. * * Returns `tokenSegments.length === 0` iff dirName's own leading segment * carries no phase-number token (the `extractPhaseToken` dirName-unchanged * fallback) — i.e. the directory name itself does not start with a digit or a * short letter+digit prefix, so no reliable phase token can be read from it. */ function derivePhaseTokenSegments( dirName: string, ): { prefix: string; tokenSegments: string[]; firstLetterPrefixed: boolean } { const codePrefixMatch = dirName.match(PROJECT_CODE_PREFIX_CAPTURE_RE_I); let prefix = ''; let rest = dirName; if (codePrefixMatch) { prefix = codePrefixMatch[1] + '-'; rest = codePrefixMatch[2]; } const segments = rest.split('-'); const tokenSegments: string[] = []; // #2043: distinguish a real (zero-padded) phase/sub-phase segment from a // single-digit slug word. A pure-numeric leading segment ("46") only // continues with exactly-2-digit segments (#2232: a ≥3-digit run is a slug // word such as a year — "14-2026-photos-…" yields "14", not "14-2026"), so // "46-6-rs-…" yields "46" (the "6" is the // slug's first word), not "46-6". Milestone-prefixed ids like "M1-2" reach here // with "M1-" already stripped as a project-code prefix (see // PROJECT_CODE_PREFIX_CAPTURE_RE_I), so "2" is the leading segment and the same // pure-numeric rule applies (M1-46-6-rs → "M1-46"). The firstLetterPrefixed // carve-out covers letter+digit leading segments that survive prefix stripping // because of punctuation (e.g. "P0.3-2"), whose single-digit continuation is // intentionally preserved (unchanged from prior behaviour). let firstLetterPrefixed = false; for (let i = 0; i < segments.length; i++) { const seg = segments[i]; if (i === 0) { if (/^\d/.test(seg)) { tokenSegments.push(seg); } else if (/^[A-Za-z]{1,3}\d/.test(seg)) { tokenSegments.push(seg); firstLetterPrefixed = true; } else { break; } } else if ( (firstLetterPrefixed && /^\d/.test(seg)) || (!firstLetterPrefixed && isPhaseContinuationSegment(seg)) ) { tokenSegments.push(seg); } else { break; } } return { prefix, tokenSegments, firstLetterPrefixed }; } /** * Extract the phase token from a directory name. */ function extractPhaseToken(dirName: string, convention?: string): string { // #612 bracket dir form `{CODE}.{MM}-{PP}[.{SS}]-slug` → phase token `PP[.SS]`. // GATED on convention === 'bracket' (mirrors getMilestoneFromPhaseId's READING-B // decision above). A bracket dir `{CODE}.{MM}-{PP}` is string-INDISTINGUISHABLE // from the legacy #2043/#1324 letter-prefixed-decimal family (`P0.3-2`, // `P0.12-34`) whenever the project code ends in a digit, so NO string-only // discriminator can separate the two conventions — auto-detecting here silently // reinterpreted `P0.3-2` → `2` (was `P0.3-2`), a byte-identical-read regression // on this CRITICAL 6-caller helper (ADR-2121). Requiring an explicit convention // signal keeps every existing (convention-less) call site byte-identical to // prior behaviour — see the #2043 numeric-tail characterization in // tests/phase-id.test.cjs — while keeping the helper pure (optional param, no // config read). The captured token is dot-only (`PP[.SS]`); the milestone↔phase // hyphen and any trailing plan/slug are excluded. if (convention === 'bracket') { const bracketDir = dirName.match(/^[A-Z][A-Z0-9_]*\.\d+-(\d+(?:\.\d+)?)/); if (bracketDir) return bracketDir[1]; } const { prefix, tokenSegments, firstLetterPrefixed } = derivePhaseTokenSegments(dirName); if (tokenSegments.length === 0) { return dirName; } // #2528 (re-review): the tokenizer deliberately does NOT try to tell a 2-digit // slug word ("24" of "24/7 Autonomy") from a genuine zero-padded continuation // ("24" of sub-phase 10.24) — by width alone they are the same string, the gap // between #2043's 1-digit and #2232's ≥3-digit guards, and no LOCAL signal // separates them. An earlier revision of this fix rewound the token when the // segment that stopped the scan was a 1-digit word, which reads // "10-24-7-autonomy" correctly but silently re-tokenizes the equally real // "10-24-7-zip" (sub-phase 10.24 named "7-Zip …") from "10-24" to "10" — it // trades the reported ambiguity for the symmetric one a level down, on a // CRITICAL 15-caller chokepoint whose output feeds query-less derivations // (STATE.md phase counts, W007, the #2562 key surface). // // So the token stays the LITERAL reading of the name, and disambiguation lives // ONE layer up, in matchPhaseDirs, where a QUERY exists to disambiguate // against: a bare-integer lookup falls back to the directory's leading digit // run and resolves "10-24-7-autonomy" for "10" without touching what the // directory's own token is. That is the same bounded mechanism the // "05-80-20-cleanup" shape already uses — one rule for the whole // digit-leading-slug family instead of two overlapping ones. // // A generated slug is lowercase. If the owner admitted a two-digit prefix // from a digit+letter slug segment ("10x", "25abc"), remove only that final // segment. Uppercase suffixes remain available to the established plan-ID // grammar, and dotted continuations remain intact. if ( !firstLetterPrefixed && tokenSegments.length > 1 && /^\d{2}[a-z][a-z0-9]*$/.test(tokenSegments[tokenSegments.length - 1]) ) { tokenSegments.pop(); } return prefix + tokenSegments.join('-'); } /** * #3511 (reworked — adversarial review found the membership rule wrong in * approach, not just detail): predicate for AGGREGATE phase-directory scans * (every matching file contributes, e.g. * uat-predicate/phase.cts/state.cts/uat.cts/audit.cts's `*-UAT.md` / * `*-VERIFICATION.md` scans) — answers "does fileName belong to THIS phase" * so a stray, cross-phase, or ad-hoc file (`04-VERIFICATION.md` sitting in * phase 03's directory) cannot contribute its status to phase 03. This is * deliberately NOT `resolveVerificationFile` (`src/verification.cts`, * #3357/#3492) — that resolver answers a SINGLE-PICK question ("which one * candidate is THE report") for a phase dir already known to hold one; this * answers a per-file membership question for a scan that must fold in EVERY * match. See "Reconciliation" below — the two do NOT fully agree. * * THE ORIGINAL BUG: files are named by `normalizePhaseName` * (`cmdScaffold`, `src/commands.cts` — PADDED, project-code-STRIPPED), while * this predicate read the directory's OWN token via the literal, unpadded, * project-code-CARRYING `extractPhaseToken(phaseDirName)`. Two different * normalizations of the same phase number, so a literal * `startsWith(token + '-')` excluded a phase's own artifacts whenever they * disagreed: `CK-01-foundation` (token `CK-01`, file `01-VERIFICATION.md`), * `1-unpadded` (token `1`, file `01-VERIFICATION.md`), and the #2528 * digit-leading-slug family `05-80-20-cleanup` / `10-24-7-autonomy` (token * over-absorbs past the digit run `cmdScaffold` actually writes: `05-80-20` * vs the real `05-UAT.md`). * * THE FIX: build the set of every phase-number READING this directory could * plausibly resolve to elsewhere in the module, then check fileName against * ALL of them — reusing the exact readings `matchPhaseDirs` / * `phaseNumberForMatch` (#2528, below) already carry for directory * RESOLUTION, so this membership check can never diverge from what "the * directory for phase N" means elsewhere in the module (no third * normalization — CLAUDE.md's Generative Fix Divergence class): * 1. the literal token (`extractPhaseToken(phaseDirName)` — still correct * for the common case and for genuine decimal / letter-suffixed * sub-phase dirs); * 2. the same token read off the project-code-STRIPPED name * (`stripProjectCodePrefix` — the exact fallback `phaseTokenMatches` * already applies, #612/#1324); * 3. the directory's own LEADING DIGIT RUN on the stripped name * (`LEADING_DIGIT_RUN_RE` — the #2528 bare-integer-fallback reading, * the one that actually matches what `cmdScaffold` writes for the * digit-leading-slug family); and * 4. each of (1)-(3) additionally passed through `normalizePhaseName`, * since files always carry the PADDED form and directories often do * not (`1-unpadded` vs `01-...`). * A file belongs when it starts with any candidate + `-` OR any candidate + * `.`, compared case-insensitively (matching `phaseTokenMatches`' own rule — * review item 8: `03A-VERIFICATION.md` vs `03a-foo`). This is a PREFIX check, * not a full-token equality — `03-01-SUMMARY.md` (phase 03, plan 01) must * still match dir `03-foo` on candidate `03`, even though * `extractPhaseToken('03-01-SUMMARY.md')` would (wrongly, for this purpose) * read `03-01` as a mis-absorbed 2-digit continuation. * * DOTTED SUB-PHASE CONTINUATION: the dot arm of the check exists because this * module's own token grammar (`PHASE_NUMBER_TOKEN_SOURCE`) admits a dotted * sub-phase continuation (`(?:\.\d+)*`) alongside dash-continuations — a * sub-phase artifact `01.1-CONTEXT.md` is `01`'s own file, written into `01`'s * directory, not a stray from a different phase. A dash-only prefix check * excluded it (`01.1-` does not start with `01-`), which is over-exclusion: * the dangerous direction for an aggregate scan whose fail-safes above all * default to inclusion when membership is unclear. Widening dash-only to * dash-OR-dot only ever ADDS a match a candidate already earned; it cannot * newly admit a file whose leading digits differ from `candidate`, so it * cannot resolve a genuinely different phase's artifact (`02.1-...` still * fails every `01`-rooted candidate). * * BRACKET CONVENTION (review item 7): a letter-prefixed-decimal dir * (`P0.3-2-slug`) is string-INDISTINGUISHABLE from a bracket-dir token * (`extractPhaseToken` above, gated on `convention === 'bracket'`) without an * explicit convention signal — and this predicate is never given one: none * of its 9 call sites thread `convention`/config through today. Rather than * guess a reading it cannot know is active and risk excluding the phase's OWN * artifact (the exact defect class this rework exists to fix), this family * (`firstLetterPrefixed` dirs) falls into the same include-everything * fail-safe as the zero-segment case below — a documented, deliberate * widening (it also stops excluding a genuine stray from a DIFFERENT * letter-prefixed-decimal phase, narrowly) accepted in trade for never * dropping the phase's own report. Convention-aware scoping for this family * is deferred to whenever a call site actually threads `convention` through. * * FAIL-SAFE (#3511, unchanged): when dirName's own leading segment carries no * phase-number token at all (`derivePhaseTokenSegments` finds zero segments — * the same condition `extractPhaseToken` treats as "return dirName * unchanged"), no reliable token exists to scope against. Excluding on an * unreliable token would make an aggregate gate silently PERMISSIVE in the * wrong direction — dropping the phase's own real blockers — which is worse * than the cross-phase-contamination bug this predicate exists to fix. * Instead every file is treated as belonging to the phase (returns `true` * unconditionally), matching pre-fix (unscoped) behaviour for that directory. * * FIX 2 — bare `VERIFICATION.md` / `UAT.md` (no dash, no token of its own): * `derivePhaseTokenSegments(fileName)` also finds zero segments for these — * the file carries no phase number to compare against anything. Directory * containment is the only signal available for a token-less file, and it is * sufficient: every call site passes `fs.readdirSync` results for ONE * specific phase dir, so a token-less candidate already reaching this * predicate (past each call site's own verification/UAT suffix pre-filter) * is, by construction, that phase's own * listing. Returns `true` unconditionally, same as the dir-side fail-safe. * * RECONCILIATION WITH resolveVerificationFile (#3357/#3492/#3511) — the two * surfaces now AGREE. `resolveVerificationFile`'s fallback (`verification.cts`, * "Fallback" step in its own docblock) filters its dashed candidates through * THIS predicate — `isPhaseArtifact(f, phaseDirName)` — before picking * alphabetically-first, via a new `phaseDirName` option every call site * threads in (the same basename each already derives for `phaseToken`). So a * stray cross-phase file can no longer win the single-pick fallback either: * it is excluded there for the identical reason it is excluded from the * aggregate scans here — membership, not canonical shape. The fail-safes stay * aligned too: when this predicate cannot determine membership for a * directory (returns `true` unconditionally — see FAIL-SAFE above), * `resolveVerificationFile`'s filter is a no-op and its fallback degrades to * the original pre-#3357 "alphabetically first of ALL dashed candidates" * behavior, exactly as it always did for that directory shape. */ function isPhaseArtifact(fileName: string, phaseDirName: string): boolean { const { tokenSegments, firstLetterPrefixed } = derivePhaseTokenSegments(phaseDirName); if (tokenSegments.length === 0) return true; const literalToken = extractPhaseToken(phaseDirName); const strippedDir = stripProjectCodePrefix(phaseDirName); const strippedToken = strippedDir !== phaseDirName ? extractPhaseToken(strippedDir) : literalToken; const leadingRunMatch = strippedDir.match(LEADING_DIGIT_RUN_RE); const rawCandidates = [literalToken, strippedToken, leadingRunMatch?.[1]].filter( (t): t is string => Boolean(t), ); // Each reading is compared in BOTH its padded and de-padded form: files are // written padded by `normalizePhaseName` (`cmdScaffold`) while directories // are often not (`1-unpadded`), and legacy trees carry the reverse pairing. // De-padding is numeric-only — a token with a letter suffix or a dotted // sub-phase (`03A`, `03.1`) has no meaningful de-padded form and is left // alone, so this only ever ADDS a reading and can never drop one. const depad = (t: string): string => (/^\d+$/.test(t) ? String(Number(t)) : t); const candidates = new Set( rawCandidates .flatMap(t => [t, normalizePhaseName(t), depad(t)]) .map(t => t.toUpperCase()), ); const fileUpper = fileName.toUpperCase(); for (const candidate of candidates) { // A dotted sub-phase segment (e.g. `01.1-CONTEXT.md`) is a legitimate // continuation of `candidate` per this module's own token grammar // (PHASE_NUMBER_TOKEN_SOURCE admits `(?:\.\d+)*`), so it belongs to // `candidate`'s own directory just as a dash-continuation does. Inclusion // is the safe direction for these aggregate scans (see FAIL-SAFE above) — // widening a dash-only check to dash-OR-dot never drops a genuine match, // it only stops wrongly excluding one. // // Accepted separator class after a matched candidate: `-`, `.`, or `_`. // The underscore was added for state.cts's `cmdStateValidate` S006/S007 // scan, whose own pre-filter is deliberately broader than the dashed // grammar every other call site uses (`.includes('VERIFICATION')`, no // dash required — see the WARNING-4 comment there), so it admits names // like `03_VERIFICATION.md`. Before this predicate accepted `_` as a // boundary, such a file failed the `-`/`.`-only check here even though // its digits matched `candidate` exactly, and `scopeToPhase` dropped it — // a real same-phase verification report reported as absent. Widening the // separator class only ever EXTENDS a candidate whose digits already // match exactly; it cannot admit a genuinely different phase's file, // since the candidate comparison itself is unchanged. if ( fileUpper.startsWith(`${candidate}-`) || fileUpper.startsWith(`${candidate}.`) || fileUpper.startsWith(`${candidate}_`) ) return true; } // FIX 2: token-less filename (bare "VERIFICATION.md"/"UAT.md") — containment // in this phase's own directory listing is sufficient. if (derivePhaseTokenSegments(fileName).tokenSegments.length === 0) return true; // Bracket-convention ambiguity fail-safe — see docblock above. if (firstLetterPrefixed) return true; return false; } /** * #3511: scope `fileNames` to the subset that passes * `isPhaseArtifact(fileName, phaseDirName)`. The single seam every * phase-directory scan routes through, so the membership rule has ONE owner. * * AN EMPTY RESULT IS A REAL ANSWER — deliberately, and this is the hard-won * part. An earlier revision of this helper carried an extra rule ("scoping * must never turn a non-empty set into an empty one": if the filter removed * every file, return the unfiltered input). It was added to rescue a * directory whose basename merely PARSES phase-shaped — * `gsd-651-broad-grep-a1b2`, an `mkdtemp`-style fixture name that * `extractPhaseToken` reads as project code `gsd` + phase `651` (the capture * regex is case-INSENSITIVE) — holding only `01-bg-VERIFICATION.md`, which * the filter then dropped, yielding an empty set indistinguishable from "no * report exists". * * That rescue was wrong, and no local rule can make it right: a directory * whose own name says phase 651 holding only a file that says phase 01 is * STRING-INDISTINGUISHABLE from `03-foo/` holding only `04-VERIFICATION.md` * — the exact cross-phase stray #3511 exists to exclude. Keeping the rule * meant a real phase directory holding only a MISFILED report would resolve * to it and publish another phase's `passed` as its own: the reported bug, in * its single most damaging form. `missing` is the correct answer when a * phase's own report is genuinely absent, and every caller already has a * `missing`/`null` branch for it. * * The over-exclusion that rule was reaching for is instead handled where it * is actually determinable, inside `isPhaseArtifact`: the zero-token dir * fail-safe, the `firstLetterPrefixed` bracket-ambiguity fail-safe, the * token-less-filename rule, and the multi-reading candidate set (literal / * project-code-stripped / leading-digit-run, each also padded AND de-padded) * that covers every normalization a phase directory and its files can * legitimately disagree on. A file excluded after all of those genuinely * names a different phase. * * SITE DISCIPLINE: every aggregate-scan call site (`uat.cts`, * `uat-predicate.cts`, `phase.cts`, `audit.cts`, `state.cts`, * `core-utils.cts`'s `getPhaseFileStats` — #3511 BLOCKER-2 — and * `init.cts`'s two phase-info-projection sites — #3511 BLOCKER-3, both of * which scope the raw listing once up front and reuse it for every bare * `.find()`/`.some()` artifact predicate: context/research/UAT/reviews/ * patterns) and `resolveVerificationFile`'s single-pick fallback * (`verification.cts`) MUST route through this helper rather than calling * `isPhaseArtifact` in a filter position directly, so the rule cannot be * re-derived per site (CLAUDE.md's Generative Fix Divergence class). * `isPhaseArtifact` stays exported for single-item membership questions and * its own unit tests. */ function scopeToPhase(fileNames: string[], phaseDirName: string): string[] { return fileNames.filter((f) => isPhaseArtifact(f, phaseDirName)); } /** * Check if a directory name's phase token matches the normalized phase exactly. */ function phaseTokenMatches(dirName: string, normalized: string): boolean { const token = extractPhaseToken(dirName); if (token.toUpperCase() === normalized.toUpperCase()) return true; const stripped = stripProjectCodePrefix(dirName); if (stripped !== dirName) { const strippedToken = extractPhaseToken(stripped); if (strippedToken.toUpperCase() === normalized.toUpperCase()) return true; } return false; } /** * #2528: the LEADING DIGIT RUN of a directory name — the fragment the * bare-integer fallback selects on, and the one `phaseNumberForMatch` then * displays. Named (per this module's convention of naming grammar fragments * rather than inlining them) because the two sites must not drift: selecting on * one run and displaying another would resolve a directory and then label it * with a number that never matched. * * `LEADING_DIGIT_RUN_RE` anchors a trailing `-`-or-end so the run is a whole * segment; `_PREFIX` is the same run without that boundary, for reading the run * back off a name already known to match. */ const LEADING_DIGIT_RUN_SOURCE = '\\d+'; const LEADING_DIGIT_RUN_RE = new RegExp(`^(${LEADING_DIGIT_RUN_SOURCE})(?:-|$)`); const LEADING_DIGIT_RUN_PREFIX_RE = new RegExp(`^${LEADING_DIGIT_RUN_SOURCE}`); const BARE_INTEGER_RE = new RegExp(`^${LEADING_DIGIT_RUN_SOURCE}$`); /** Strip leading zeros for numeric-equality compare, keeping a lone "0". */ const unpad = (digits: string): string => digits.replace(/^0+(?=\d)/, ''); /** * #2528: the CANONICAL phase-directory match selection — the one rule every * directory-resolution path (the shared locator plus the `find-phase` and * `phase-plan-index` command scans) applies to a candidate dir list. Extracted * here because the surrounding scan/ambiguity/shaping code exists per site and * had already diverged; the selection itself must not. * * Two passes: * 1. PRIMARY — exact token match (`phaseTokenMatches`), unchanged behavior. * 2. BARE-INTEGER FALLBACK — only when the primary pass matched NOTHING and * the query is a bare integer, re-filter by each directory's own LEADING * digit run (zero-padded compare). This catches digit-leading slug shapes * the tokenizer cannot disambiguate from genuine sub-phase segments * (e.g. "05-80-20-cleanup", phase 5 named "80/20 Cleanup", whose token * "05-80-20" is byte-identical in shape to a real deep-decomposition dir). * The fallback can only turn a silent not-found into a resolution or into * a surfaced ambiguity (callers keep their #2237 multi-match guards) — * never override a primary match. * * SCOPE, precisely (#2528 re-review). Non-bare QUERIES ("46-6", "12A", * "PROJ-42") never enter the fallback, so nothing changes about how a * deep-decomposition or letter-suffix lookup is asked. What DOES change is the * DIRECTORY side: a bare query now reaches directories the tokenizer classified * as multi-segment, and a genuine sub-phase directory has exactly that shape. * So `5` against a lone `05-01-auth` resolves (phase_number "05", phase_name * "01-auth") where it previously found nothing. * * That widening is DELIBERATE and it is irreducible from directory names alone. * `05-01-auth` (sub-phase 5.1) and `30-12-factor-refactor` (phase 30 named * "12-Factor Refactor") are the same string shape — `NN-NN-` — and the * discriminator that would separate them, "is the second segment a valid decimal * sub-phase", accepts both (`5.1` and `30.12` are equally well-formed). Any rule * strong enough to exclude `05-01-auth` also excludes `30-12-factor-refactor`, * which is the defect #2528 exists to fix. The tie is therefore broken in favour * of resolving, and the consequence is bounded on the side that matters: when * BOTH readings have a directory (`05-01-auth` + `05-02-api`) the result is two * matches. `tests/phase-resolution-parity.test.cjs` pins both directions: the * lone-directory resolution and the two-directory refusal. * * WHAT IS SHARED IS SELECTION, NOT AMBIGUITY POLICY. This function is the one * owner of "which directories does this query name". What a caller does with * two of them stays the caller's own decision, and the callers split in two * tiers on purpose: * * REFUSE on `matches.length > 1` — `searchPhaseInDir`, `cmdFindPhase`, * `cmdPhasePlanIndex`, `cmdPhaseRemove`. These either act destructively or * answer "which phase is this", so guessing is worse than reporting the * candidates (#2237). * * TAKE `matches[0]` — `cmdPhasesList`, `cmdInitManager`, `cmdRoadmapAnalyze`, * `cmdVerifySchemaDrift`, `detectVerifyFailed`. Each read a directory to * DECORATE a row they are already emitting; each used `.find()` before this * PR, so first-match is their prior behavior preserved verbatim, and each is * order-stable because the directory list is sorted and this function filters * without reordering. * * The honest caveat on that second tier: the bare-number fallback makes * multi-match newly REACHABLE for inputs that previously found nothing, so those * five can now silently pick one of several candidates where they used to report * not-found. That is a widening of an existing first-match rule, not a new rule * — but it is a widening, and promoting any of them to refusal is a UX decision * about their own output, not a change to selection, so it does not belong here. * * `usedBareFallback` tells callers to derive the displayed phase number from * the directory's leading digit run instead of `extractPhaseToken` (whose * token for these dirs is the mis-absorbed multi-segment form). */ function matchPhaseDirs(dirs: string[], normalized: string): { matches: string[]; usedBareFallback: boolean } { const primary = dirs.filter(d => phaseTokenMatches(d, normalized)); if (primary.length > 0) return { matches: primary, usedBareFallback: false }; const bare = String(normalized); if (!BARE_INTEGER_RE.test(bare)) return { matches: primary, usedBareFallback: false }; const want = unpad(bare); const fallback = dirs.filter(d => { const m = stripProjectCodePrefix(d).match(LEADING_DIGIT_RUN_RE); return m !== null && unpad(m[1]) === want; }); return { matches: fallback, usedBareFallback: fallback.length > 0 }; } /** * #2528: the display phase number for a directory selected by matchPhaseDirs. * Primary matches keep the extracted token; bare-fallback matches use the * directory's leading digit run (the whole point of the fallback is that the * extracted token is wrong for these dirs). */ function phaseNumberForMatch(dirName: string, usedBareFallback: boolean): string { if (!usedBareFallback) return extractPhaseToken(dirName); const stripped = stripProjectCodePrefix(dirName); const prefix = dirName.slice(0, dirName.length - stripped.length); const m = stripped.match(LEADING_DIGIT_RUN_PREFIX_RE); return m ? prefix + m[0] : extractPhaseToken(dirName); } // ─── Canonical phase KEY surface (#2562) ───────────────────────────────────── // // A phase "key" is the padding-, case- and project-code-insensitive identity of // a phase, for use as a Map/Set key when two independently-derived phase // references (a ROADMAP table cell and a phase directory name, say) must be // compared. Promoted here from a local pair in state.cts (#2445) so every // consumer derives BOTH sides of a comparison from the SAME function — deriving // one side with a bespoke regex is the #2562 defect class (a `01` table cell // never matching a `1-slug` directory, silently zeroing a rollup). /** * Canonical key for an already-extracted phase TOKEN (`"5"`, `"05"`, `"005"`, * `"12A"`, `"30.1"`, `"PROJ-05"`). Padding- and case-insensitive: every * spelling of a number collapses to one key. * * Leading zeros are stripped per hyphen-separated segment BEFORE * `normalizePhaseName` pads to the 2-digit convention. Padding alone is not a * normalisation — `padStart(2)` is a no-op once the input is already ≥2 * characters, so `5` yielded `05` while `005` stayed `005` and the two never * compared equal. The strip is deliberately confined to this key surface: * `normalizePhaseName` itself is a RENDERING function whose verbatim treatment * of wide IDs (`001.10`) is relied on by plan-ID capture and wave assignment. * Arithmetic is avoided (`parseInt` would lose precision on a long digit run). */ function phaseKeyFromToken(token: unknown): string { const stripped = String(token) .split('-') .map(segment => segment.replace(/^0+(?=\d)/, '')) .join('-'); return normalizePhaseName(stripped).toUpperCase(); } /** * Canonical key for a phase DIRECTORY name (`"05-schedule-8"` → `"05"`, * `"PROJ-5-x"` → `"05"`, `"30.1-follow-up"` → `"30.1"`). */ function phaseKeyFromDir(dirName: string): string { return phaseKeyFromToken(extractPhaseToken(dirName)); } /** * Canonical key for a phase referenced in PROSE — a ROADMAP `## Progress` table * cell (`"30. Schedule 8 rollout"`, `"**05.1 Follow-up**"`) or a STATE.md * `Phase:` value. Markdown emphasis is stripped first so a bolded cell is not * mistaken for a non-phase. Returns null when the value does not BEGIN with a * phase token (`parsePhaseFromProse` anchoring, #2111). */ function phaseKeyFromProse(value: string | null | undefined): string | null { if (value == null) return null; const { phase } = parsePhaseFromProse(String(value).replace(/[*_`~]/g, '')); return phase === null ? null : phaseKeyFromToken(phase); } /** * The PARENT phase key of a sub-phase key (`"30.1"` → `"30"`), or null for a * top-level phase. A sub-phase directory inserted mid-milestone frequently has * no ROADMAP row of its own and inherits its parent's milestone (#2562). */ function parentPhaseKey(key: string): string | null { const dot = key.indexOf('.'); return dot === -1 ? null : key.slice(0, dot); } // ─── #2121 canonical surface (ADR-2121) ────────────────────────────────────── /** * Parse a phase identifier from a STATE.md `Phase:` prose field VALUE — the text * after the `Phase:` label (e.g. `"3 of 4 (Delta)"`, `"3A — Delta (executing)"`, * or `"Milestone v0.5 complete"`). * * The token is anchored to the START of the value (after an optional literal * `Phase ` label and an optional project-code prefix) so a phase is only * returned when the value actually begins with one. This is the #2111 fix: the * prior unanchored `/\b(\d+[A-Z]?(?:\.\d+)*)\b/i` mined the first numeral * anywhere, so `"Milestone v0.5 complete"` collapsed to `"5"` (the minor-version * digit) and `"v1.0"` to `"0"` (a reserved sentinel). Here both yield * `{ phase: null }` because they do not begin with a phase token. The name * extraction (parenthetical or em-dash tail, minus status words) is unchanged. */ function parsePhaseFromProse(value: string | null): { phase: string | null; name: string | null } { if (!value) return { phase: null, name: null }; // Coerce defensively so a non-string caller cannot throw on this canonical // surface (mirrors the sibling #2121 functions' String(...) handling). const str = String(value); const phaseMatch = str.match(/^\s*(?:Phase\s+)?(?:[A-Z][A-Z0-9_]*-)?(\d+[A-Z]?(?:\.\d+)*)\b/i); // The name-extraction quantifiers are length-bounded so a crafted long // unterminated run (many `(` or `—`) in an untrusted STATE.md field value // cannot drive O(n^2) regex backtracking (CPU-exhaustion DoS). A real phase // name is far shorter than the cap. const parenName = str.match(/\(([^)]{1,200})\)/); // #2736 (the #1695 AC #3 residual): status-keyword-aware precedence. The // first-party writer shapes are `N — Name (aside)` (completePhaseCore), // `N (Name) — EXECUTING` (beginPhaseCore), `N — COMPLETE`, and the // gsd2-import `N (slug) — Milestone: Title`. A blind paren-first read // harvests the aside as the name on the first shape; a blind dash-first // read harvests the status keyword on the others. Prefer the em-dash name // when it is a genuine name, else fall back to the parenthetical. Still // lossy for names that themselves contain a parenthetical — transitions // that hold the exact name bypass this parser entirely via the // syncStateFrontmatter authoritative override. // // The em-dash separator is searched on a paren-stripped copy, so an em-dash // INSIDE a parenthetical name (`16 (Native — Global Hotkey) — EXECUTING`) // can never be mistaken for the name separator. const strNoParens = str.replace(/\([^)\n]{0,200}\)/g, ' '); const dashName = strNoParens.match(/—\s*([^(\n]{1,200}?)\s*$/); // The precedence-decision vocabulary is deliberately broader than the final // name-nulling filter below: a dash tail that merely LOOKS like a status // annotation should lose to a parenthetical name, without changing which // extracted names are nulled (that set stays the long-standing three). const STATUS_WORD_RE = /^(?:complete|executing|not started)$/i; const STATUSY_TAIL_RE = /^(?:completed?|executing|not started|planning|planned|ready(?:\s+to\s+\S.{0,50})?|done|in progress|blocked|paused|verifying)$/i; const dashRaw = dashName?.[1]?.trim() ?? null; const dashIsName = dashRaw !== null && dashRaw.length > 0 && !STATUSY_TAIL_RE.test(dashRaw) && !/^milestone\s*:/i.test(dashRaw) // A lone ALL-CAPS token after the dash reads as a status marker whenever a // parenthetical name exists to prefer (the beginPhase writer's systematic // `(Name) — STATUS` shape); with no parenthetical it stays the best guess. && !(parenName && /^[A-Z][A-Z0-9_-]*$/.test(dashRaw)); const rawName = dashIsName ? dashRaw : (parenName?.[1] ?? dashRaw ?? null); const name = rawName && !STATUS_WORD_RE.test(rawName.trim()) ? rawName.trim() : null; return { phase: phaseMatch ? phaseMatch[1] : null, name, }; } /** * Config-AWARE project-code prefix strip. Unlike the config-blind * `stripProjectCodePrefix` (which strips ANY `-` shape), this strips the * leading `-` ONLY when `` case-insensitively equals the configured * `projectCode`. A foreign prefix (`MEM-01` when the configured code is `LKML`) * or an absent/empty `projectCode` is preserved verbatim — this is the #2104 * fix: a foreign-prefixed id must not collapse to a bare numeric phase and * collide with a real one. */ function stripConfiguredProjectCodePrefix(value: unknown, projectCode: string | null | undefined): string { const input = String(value); const configured = typeof projectCode === 'string' ? projectCode.trim() : ''; if (!configured) return input; const m = input.match(PROJECT_CODE_PREFIX_CAPTURE_RE_I); if (!m) return input; if (m[1].toUpperCase() !== configured.toUpperCase()) return input; return m[2]; } /** * True when `phase` carries a project-code prefix that is NOT the configured * `projectCode` (or when no `projectCode` is configured). The canonical * predicate the init-command foreign-prefix guard (#2056 / PR #2105) delegates * to, so every call site shares one foreign-prefix rule. */ function isForeignPrefixedPhaseQuery(phase: unknown, projectCode: unknown): boolean { const m = String(phase).match(PROJECT_CODE_PREFIX_CAPTURE_RE_I); if (!m) return false; const configured = typeof projectCode === 'string' ? projectCode.trim() : ''; return !configured || m[1].toUpperCase() !== configured.toUpperCase(); } /** * Canonical ROADMAP heading lookup-source list (moved here from * roadmap-parser.cts so phase-id.cts is the single owner of the ordering). * Sources are tried in a fixed, deduplicated order: exact (only when the query * itself is project-code-prefixed) → bare numeric / padding-tolerant → * prefix-tolerant fallback. The bare numeric source precedes the prefix-tolerant * form so a canonical heading (`### Phase 117:`) is preferred over a drifted * prefixed one (`### Phase MANIFOLD-117:`) when both exist in one ROADMAP. */ function roadmapPhaseLookupSources(phaseNum: unknown): string[] { const sources: string[] = []; const exactSource = phaseMarkdownRegexSourceExact(phaseNum); if (exactSource) sources.push(exactSource); const numericSource = phaseMarkdownRegexSource(phaseNum); sources.push(numericSource); sources.push(`${OPTIONAL_PROJECT_CODE_PREFIX_SOURCE}${numericSource}`); return [...new Set(sources)]; } export = { OPTIONAL_PROJECT_CODE_PREFIX_SOURCE, OPTIONAL_PHASE_TAG_SOURCE, PHASE_NUMBER_TOKEN_SOURCE, CASE_FLEXIBLE_PROJECT_CODE_PREFIX_SOURCE, CASE_FLEXIBLE_PHASE_NUMBER_TOKEN_SOURCE, PHASE_CONTINUATION_SEGMENT_SOURCE, isPhaseContinuationSegment, BRACKET_PHASE_TOKEN_SOURCE, PHASE_HEADING_PREFIX_SRC, stripProjectCodePrefix, normalizePhaseName, getMilestoneFromPhaseId, getPhaseDirFromPhaseId, parsePhaseId, renderPhaseId, toDir, SENTINEL_RANGES, isSentinelPhaseId, phaseMarkdownRegexSource, phaseMarkdownRegexSourceExact, comparePhaseNum, extractPhaseToken, isPhaseArtifact, scopeToPhase, phaseTokenMatches, matchPhaseDirs, phaseNumberForMatch, phaseKeyFromToken, phaseKeyFromDir, phaseKeyFromProse, parentPhaseKey, parsePhaseFromProse, stripConfiguredProjectCodePrefix, isForeignPrefixedPhaseQuery, roadmapPhaseLookupSources, };