chore(#2124): build canonical phase-id.cts surface (Phase 1 of #2121)

Add the ADR-2121-locked canonical functions to src/phase-id.cts. No consumer
behavior changes — Phases 2-4 migrate the divergent call sites against them.

- parsePhaseFromProse: anchored prose parser. A phase is returned only when the
  STATE.md "Phase:" field VALUE begins with a phase token, so
  "Milestone v0.5 complete" yields { phase: null } instead of "5" (the #2111
  root cause: the old unanchored \b(\d+..)\b mined the minor-version digit).
  Name extraction (parenthetical / em-dash tail, minus status words) unchanged.
- stripConfiguredProjectCodePrefix / isForeignPrefixedPhaseQuery: config-aware
  prefix policy. A foreign prefix (MEM-01 when the configured code is LKML) is
  preserved rather than collapsed to a bare numeric phase — the #2104 fix's
  canonical home (consumed later, outside this epic's critical path).
- roadmapPhaseLookupSources: moved from roadmap-parser.cts so phase-id.cts is
  the single owner of the exact -> numeric -> prefix-tolerant ordering.
  roadmap-parser.cts now imports it (behavior-identical); its two now-unused
  imports (phaseMarkdownRegexSourceExact, OPTIONAL_PROJECT_CODE_PREFIX_SOURCE)
  are dropped.

Tests: subject-named suites in tests/phase-id.test.cjs covering the ADR
boundary set (v0.5, v1.0, MEM-01, AB-29, bare 29, zero-padded 029) plus two
fast-check properties: the #2111 "Milestone vX.Y complete never yields a phase"
invariant and a parse/normalize property.

Extend-never-mutate: the 12 pre-existing phase-id.cts exports are unchanged.

Closes #2124
Refs #2121

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-07-09 16:13:06 -04:00
parent d8782f772a
commit bf25e0b435
3 changed files with 242 additions and 19 deletions

View File

@@ -22,10 +22,11 @@ import phaseIdModule = require('./phase-id.cjs');
const {
escapeRegex,
phaseMarkdownRegexSource,
phaseMarkdownRegexSourceExact,
stripProjectCodePrefix,
OPTIONAL_PROJECT_CODE_PREFIX_SOURCE,
OPTIONAL_PHASE_TAG_SOURCE,
// #2121: roadmapPhaseLookupSources now lives in phase-id.cjs (single owner of
// the lookup-source ordering); imported here rather than defined locally.
roadmapPhaseLookupSources,
} = phaseIdModule;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import planningWorkspace = require('./planning-workspace.cjs');
@@ -242,23 +243,6 @@ function findRoadmapPhaseInContent(content: string, phaseNum: unknown, phaseSour
};
}
function roadmapPhaseLookupSources(phaseNum: unknown): string[] {
const sources: string[] = [];
const exactSource = phaseMarkdownRegexSourceExact(phaseNum);
if (exactSource) sources.push(exactSource);
const numericSource = phaseMarkdownRegexSource(phaseNum);
// Source order matters: the bare numeric source is tried before the
// prefix-tolerant form so that a canonical bare heading ("Phase 117:") is
// preferred over a drifted prefixed heading ("Phase MANIFOLD-117:") when
// both exist in the same ROADMAP. The prefix-tolerant form is the fallback
// that handles the drifted-only case.
sources.push(numericSource);
sources.push(`${OPTIONAL_PROJECT_CODE_PREFIX_SOURCE}${numericSource}`);
return [...new Set(sources)];
}
function getRoadmapPhaseInternal(cwd: string, phaseNum: unknown): RoadmapPhaseResult | null {
if (!phaseNum) return null;
const normalizedPhase = stripProjectCodePrefix(phaseNum);