Files
msd-core/src/phase-id.cts
Tom Boucher dc3c81e93d chore(#3212): src/pattern.cts is the sole owner of runtime-value regex construction — Phase 1 (#3416)
* test(#3412): failing-first suite for the pattern-construction seam

Phase 1 of epic #3212 (ADR-3212 §1/§2/§7). Tests only — src/pattern.cts
and eslint-rules/no-adhoc-regex-escape.cjs do not exist yet, so both
suites fail with MODULE_NOT_FOUND, which is the intended RED.

Locks the measured behavior rather than the assumed behavior:
RegExp.escape hex-escapes the leading character of nearly every string
("abc" -> "\x61bc"), so the suite asserts match-equivalence against an
inlined historical oracle (the implementation being deleted) rather
than byte-equivalence of pattern text — 200 seeded fast-check runs plus
a fixed corpus, 0 mismatches. Also locks the latent character-class
range bug this phase fixes as a side effect: a hyphen-bearing value
interpolated into [...] currently forms a real range and matches an
unintended character; post-migration it must not.

* chore(#3412): src/pattern.cts owns runtime-value regex construction

Phase 1 of epic #3212 (ADR-3212 §1/§2/§6/§7). Adds the pattern seam
delegating to the built-in RegExp.escape, deletes every hand-rolled
copy, and raises the Node floor to the Active LTS line.

The census was low, three times over. ADR-3212 counted 10 copies; a
graph query found 12; the new lint rule — once live — found 27 more.
The difference is that the census counted named helper FUNCTIONS while
the rule counts the escape SHAPE, so inline .replace(<class>, '\$&')
copies were never in scope. ADR §1's actual requirement is that no
module outside the seam escapes a value for regex use, so all of them
are, and CLAUDE.md's no-defer rule makes them this change's work.
Fourth consecutive epic here whose copy count was low — the argument
for ADR-3180 Amendment 3's "state N found by the guard" rule.

Also corrected mid-implementation: the survey reported phase-id.cts's
escapeRegex had 0 external importers. It had 8 production importers,
making its removal a public-surface change to an ADR-2121-owned module
and requiring an update to that ADR's locked-surface test. Blast
radius revised Medium-High -> High.

RegExp.escape is match-equivalent but NOT text-equivalent: it
hex-escapes the leading char of nearly every string ("abc" ->
"\x61bc"). Equivalence is proven by a seeded fast-check property test
against the deleted implementation as oracle. It also fixes a latent
bug: a hyphen-bearing value interpolated into a character class
previously formed a real range and matched an unintended character.

Node floor 22 -> 24 (RegExp.escape is Node 24+), across engines,
.nvmrc, package-lock, 9 CI matrix entries, and 5 docs. The aggregate
`required-tests` context is unchanged and no job was added or removed,
so branch protection cannot be orphaned by the dropped lanes.

Enforced by eslint-rules/no-adhoc-regex-escape.cjs (shape-matched, with
structural provenance for reviewed pattern-fragment constants rather
than a name heuristic) plus a whole-tree companion guard covering the
directories ESLint's globs miss.

* fix(#3412): close the _SOURCE guard evasion, correct two false claims

Three findings from the orthogonal review pass, all fixed.

1. The ESLint rule's `_SOURCE` provenance fallback was pure identifier-
   name matching with no binding check, so `new RegExp(userInput_SOURCE)`
   — a function parameter — sailed past the guard. That is the same
   rename-evasion class issue #3410 documents, reopened by the very
   fallback meant to complement the structural check. Now bound to the
   identifier's actual binding kind: import, require-derived const, or
   module-scope const; parameters, `let`/`var`, and unresolvable
   bindings fail closed. Four RuleTester cases cover the evasion and
   prove the legitimate cross-module case still passes.

2. src/pattern.cts's own header carried the stale pre-correction counts
   (12 copies / 17 call sites) while CONTEXT.md and the design doc
   carried the corrected ones (~39 / ~44) — a self-contradiction inside
   the PR whose entire purpose is deleting divergent copies. Rewritten,
   preserving the durable lesson: a named-function census cannot see
   inline copies; only a shape-matching guard can.

3. The claim that all deleted copies threw TypeError on non-string was
   false. phase-id.cts's copy — the one with 8 external importers — did
   String(value).replace(...) and never threw. The seam's locked
   signature does not coerce, so this is a real, now-disclosed behavior
   change rather than the pure preservation the tests asserted. Audited
   all 32 invocations across the 8 importers and 6 in-file callers:
   every one is safe by construction (upstream truthy guard or a
   string-producing derivation), verified by runtime probe against the
   compiled modules rather than by TS compilation, which cannot see a
   runtime undefined. Corrected the false claim in both the test comment
   and the design doc, and added it to Known limits.

* docs(#3412): add Changed changeset for the Node 24 floor

The only user-visible break in this phase. The escape-behavior change
is internal and match-equivalent, so it carries no user-facing note.

* fix(#3412): resolve the seam's require graph in script fixtures and packaging

Checkpoint 2 came back red with 90 failures on the node24 lane. Three
distinct defects, all introduced by routing scripts/ through the new
pattern seam, none reproducible by any local gate:

1. ~82 failures — tests/adr-index-gate.test.cjs and
   tests/removed-but-needed-lint.test.cjs copy a scripts/*.cjs into an
   mkdtemp fixture and spawn it there (necessary: those scripts resolve
   their scan root from __dirname/.., so running the real script would
   scan the real repo). Each harness hand-listed the dependencies to
   copy alongside. Adding require('../gsd-core/bin/lib/pattern.cjs') to
   gen-adr-index.cjs made both lists silently incomplete ->
   MODULE_NOT_FOUND, plus 17 downstream 'did not emit parseable JSON'
   failures from the same crash.

   Fixed as a class, not an instance: new tests/helpers/copy-script-
   fixture.cjs walks a script's transitive static relative-require graph
   and copies it, so dependencies are derived and never re-declared. It
   throws (naming the unbuilt artifact) instead of letting the child die
   with a bare MODULE_NOT_FOUND. Verified for all four seam-consuming
   scripts: gen-adr-index, lint-removed-but-needed, gen-loop-host-
   contract, sync-runtime-launcher.

2. 2 failures — scripts/ ships wholesale but eslint-rules/ does not, so
   the new scripts/lint-no-adhoc-regex-escape.cjs would be
   MODULE_NOT_FOUND in a published install (#2858 guard). Excluded from
   the tarball, matching the existing precedent for gen-emitted-
   baseline.cjs, which is excluded for the identical reason, and locked
   with a test modeled on that one. Confirmed against a real npm pack:
   890 files, 0 from eslint-rules/, and gsd-core/bin/lib/pattern.cjs
   present (so the other four scripts' requires are legitimate).

3. 6 failures — tests/phase-id.test.cjs asserted the literal escaped
   source text ('0*29', 'PROJ-42'). RegExp.escape is match-equivalent to
   the retired hand-rolled escaper but NOT text-equivalent: it hex-
   escapes the leading character and all hyphens ('0*\x329',
   '\x50ROJ\x2d42'). Verified NOT a behavior change — 576 match
   decisions across all three real interpolation prefixes, zero
   divergence. Those tests now compile each source into the same heading
   regex src/roadmap.cts's searchPhaseInContent builds and assert what
   matches and what does not, including the 'i'-flag canonicalization
   the hex escape has to preserve. Re-pinning the new literals would
   have rebuilt the same brittleness one layer down. Adds a test for the
   property the escape exists for: a dot in '1.2' must not act as a
   wildcard.

Also shares one definition of 'a require' between the packaging guard
and the fixture copier, so the two cannot disagree about what they scan.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(#3412): refuse to copy a fixture dependency outside the fixture root

copyScriptWithDeps resolved each relative require and joined the
repo-relative result onto fixtureRoot. A require resolving OUTSIDE the
repo yields a '../'-prefixed relative path, so path.join climbed out of
the fixture and wrote into the surrounding temp dir (verified:
repoRoot=/repo + depAbs=/etc/passwd wrote /tmp/etc/passwd).

No script in the tree does this today, so this closes an available
escape rather than an active one. Refuses via the existing unresolved-
require path so the failure names the offending specifier. Covered by a
negative proof that the guard fires and that nothing lands outside the
fixture.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(#3412): parse requires instead of pattern-matching them; restore the foreign-prefix contract

Applies all findings from the second orthogonal review round, re-run
because real code changed after round 1.

HIGH (security) — extractRequires stripped BLOCK comments before LINE
comments, so a '//' comment containing '/*' opened a phantom block
comment, and a '//' inside a string literal truncated the line. Both
hid real requires: 'const u="http://x"; require("./real.cjs")'
returned [], and four real requires in gsd-core/bin/gsd-tools.cjs were
invisible. Replaced with a real AST parse via espree.

This is ADR-3212's own Decision 4 — tokenizer-first for stateful
grammars — applied to the case it describes; comment/string/regex
nesting is exactly such a grammar, which is why the regex version was
wrong. The function was moved byte-identical out of the #2858 packaging
guard, so the bug PRE-DATES this branch and has been a live blind spot
there: a shipped script could have required an unshipped path
undetected. Fixing it makes that guard strictly stronger than on next.

espree is promoted from a transitive eslint dependency to an explicit
devDependency rather than relying on hoisting. The script parse attempt
sets ecmaFeatures.globalReturn because Node wraps CommonJS bodies in a
function, making a top-level return legal — scripts/check-coverage-gate
.cjs relies on it, and without the flag the guard throws on a file it
is supposed to scan. Verified 0 unparseable across all 324 .cjs/.js
under scripts/, bin/, and gsd-core/bin/, and 0 new violations against a
real npm pack, so the exact extractor does not newly fail the guard.

MEDIUM (security) — the repo-containment check guarded dependencies but
not the entry path. One escapesContainment predicate now guards both.

LOW (security) — containment was lexical while fs follows symlinks, and
a directory symlink could mint a fresh dedupe key per level. realpath
now resolves both repoRoot and each dependency before the decision, and
the realpath-derived path is the dedupe key. Destination layout still
uses the original repo-relative path, so copied trees are unchanged.

MAJOR (standards) — the round-1 behavioral rewrite of phase-id tests
lost the foreign-prefix contract: every assertion was satisfied by an
impl returning [A-Z]+\x2d42, i.e. ANY project code — the exact #3599
bug class the exact-source prevents. The literal assertions it replaced
were catching this. Now asserts the compiled regex REJECTS a different
prefix with the same number.

MAJOR (standards) — the test hand-duplicated production's heading regex
with no parity guard (CLAUDE.md's 'Generative Fix Divergence'). Removed
the parallel surface instead of policing it: src/roadmap.cts exports
buildPhaseHeadingRegex, searchPhaseInContent calls it, the test imports
it. Byte-identical .source and .flags verified for both escaped forms.

MINOR — '..foo' no longer false-flagged as an escape; the inverted
spurious-vs-missing doc claim corrected; the dead allow-test-rule
header removed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore(#3412): backfill changeset pr number to 3416

* fix(#3412): make the escape guard's own regex linear, reword an injection-scan collision

Two CI failures on PR #3416, both in code this branch added.

CodeQL js/redos (high) — REPLACE_CALL_RE's outer alternation let a
bracket run be consumed EITHER by the character-class branch OR one
character at a time by the trailing catch-all, so a failing match
explored both parses of every pair. Measured on the real regex:
n=26 -> 204ms, n=28 -> 791ms, n=30 -> 3475ms, a clean 2^n. This script
scans repo source, so a file with a long bracket run after '.replace(/'
would hang CI outright — a guard against undisciplined pattern
construction was itself the worst pattern in the diff.

Fixed the way ADR-3212 already prescribes: the catch-all branch now
excludes '[' and ']' so a bracket can only be consumed by the class
branch (this is what makes it linear), and every quantifier is bounded
(the locked bounded-quantifiers decision) as a second line of defense.
Now 0ms at n=2000. Disclosed coverage tradeoff, recorded at the
constant: a regex literal with a BARE unescaped ']' outside a class is
no longer matched by this backstop. No census shape has that form, and
the AST rule remains the primary detector.

Verified the guard did not go blind doing it: a real census-shape
violation is still reported, and an allow-adhoc-regex-escape
suppression comment is still honored.

Regression test drives the exported findViolations on a
2000-repetition adversarial input and asserts the RESULT. It makes no
wall-clock assertion — elapsed-time tests are forbidden — so a
regression surfaces as a harness timeout, which is the correct signal.

Prompt injection scan — 'must not act as a regex wildcard' in a test
comment matched the scanner's jailbreak pattern act\s+as\s+(a|an|if|
my). Reworded to 'behave as'. Deliberately NOT allowlisted: silencing a
whole test file over one phrase would blunt the scanner permanently,
and the comment has nothing to do with injection.

Neither failure was reachable from the remote runner — CodeQL and the
injection scan are not in that matrix, so the sha it passed was green
and still wrong.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: sim <sim@local>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 16:19:57 -04:00

965 lines
49 KiB
TypeScript

/**
* 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;
}
/**
* 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 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;
}
}
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('-');
}
/**
* 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-<slug>` — 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 `<CODE>-` shape), this strips the
* leading `<CODE>-` ONLY when `<CODE>` 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,
phaseTokenMatches,
matchPhaseDirs,
phaseNumberForMatch,
phaseKeyFromToken,
phaseKeyFromDir,
phaseKeyFromProse,
parentPhaseKey,
parsePhaseFromProse,
stripConfiguredProjectCodePrefix,
isForeignPrefixedPhaseQuery,
roadmapPhaseLookupSources,
};