Phase 0 of the #2121 epic — an ADR-only PR that LOCKS the contract Phases 1-4 execute against. No production code lands here. Locks: - phase-id.cts as the single canonical owner of phase-identifier parsing. - New pure exports Phase 1 adds: parsePhaseFromProse (anchored; fixes the #2111 "Milestone v0.5 complete -> 5" class), stripConfiguredProjectCodePrefix / isForeignPrefixedPhaseQuery (config-aware; the #2104 fix's home), and roadmapPhaseLookupSources moved in as sole owner of the 3-source ordering (fixes the #2114 2-vs-3-source divergence). - Extend-never-mutate on the 12 existing exports (normalizePhaseName has a CRITICAL 84-symbol / 20-caller blast radius) — Hyrum's Law. - The exact exact->numeric->prefix-tolerant lookup ordering. - A behavioral anti-divergence contract: reference-identity guard + scripts/lint-phase-id-drift.cjs scanner, modeled on the repo's proven capability-precedence-parity / package-identity-drift patterns. #2104 remains blocked on PR #2105 and off this epic's critical path. Adds the docs/adr/README.md index row. Docs-only; no changeset required (no-changelog). Closes #2121 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
24 KiB
ADR-2121: Phase-Identifier Parsing Consolidation
- Status: Accepted (Phase 0 — ADR only; locks the contract Phases 1–4 execute against. No production code lands in this PR.)
- Date: 2026-07-09
- Issue: #2121 — epic (tech-debt / root-cause consolidation,
type: chore+approved-enhancement) - Supersedes: nothing
- Relationship to prior work: completes #1455 (which introduced the prefix-tolerant lookup source but only in
roadmap-parser.cts); it is the parser-layer analog of thepackage-identity.cjssingle-source seam (ADR-referenced byscripts/lint-package-identity-drift.cjs).
Context
Phase-identifier parsing — turning a phase reference (3, 03, 12A, 2.7, 2-01, CK-01, AB-29, Milestone v0.5 complete) into a normalized identity, a ROADMAP heading match, or a resolved phase — is implemented independently in at least six modules. src/phase-id.cts exists and is meant to be the canonical normalizer, but the surrounding modules each roll their own regex instead of delegating. A fix or invariant lands on one surface and its siblings silently diverge, so the same defect keeps re-surfacing under new issue numbers.
This is exactly the class CLAUDE.md warns about under Generative Fix Divergence:
When sharing constants/arrays/parsers between parallel surfaces, add a parity assertion test that fails if they diverge.
The guard rule exists in the standards, but it is not applied to phase-ID parsing. Three confirmed bugs are the direct consequence:
| Symptom issue | Site | Divergent behavior | Blocked? |
|---|---|---|---|
| #2111 | state.cts:parseProsePhaseField (state.cts:1118-1131) |
/\b(\d+[A-Z]?(?:\.\d+)*)\b/i mines the first numeral in a prose Phase: line; Milestone v0.5 complete → 5, v1.0 → 0 (a reserved sentinel). milestone complete v0.5 writes current_phase: 5 instead of the real last phase. |
No |
| #2114 | roadmap.cts:cmdRoadmapGetPhase (roadmap.cts:238-303) + getRoadmapPhaseWithFallback (roadmap.cts:209-234) |
Both hand-roll a 2-source lookup (exact → numeric). getRoadmapPhaseInternal (roadmap-parser.cts:262-287) loops a 3-source pass (exact → numeric → prefix-tolerant) via roadmapPhaseLookupSources. roadmap get-phase 29 returns empty for ### Phase AB-29: while init.phase-op 29 resolves it. |
No |
| #2104 | phase-id.cts:normalizePhaseName / stripProjectCodePrefix (phase-id.cts:44-77) |
PROJECT_CODE_PREFIX_STRIP_RE_I = /^[A-Z][A-Z0-9_]*-(?=\d)/i strips any prefix-shaped token with no check against the configured project_code; MEM-01 collapses to bare 01 even when the project code is LKML. The #2056 guard was added to cmdInitPlanPhase only; the three sibling init commands still collapse foreign prefixes. |
Yes — sequenced after PR #2105 (#2056) |
Why #1455 did not close the loop
git show 2dedbdd11 (fix(#1455)) touched phase-id.cts, phase.cts, roadmap-parser.cts, roadmap-upgrade.cts, validate.cts — not roadmap.cts. It added OPTIONAL_PROJECT_CODE_PREFIX_SOURCE (phase-id.cts:24) and the third lookup source inside roadmapPhaseLookupSources (roadmap-parser.cts:245-260), but roadmap.cts never imported the constant. The mechanical root of #2114 is an import-list asymmetry: roadmap.cts:16-17 destructures seven names from phase-id.cjs but omits OPTIONAL_PROJECT_CODE_PREFIX_SOURCE, so it structurally cannot build the prefix-tolerant source today. A point fix on roadmap.cts would leave the divergence itself — one seam missing, N call sites free to re-diverge — fully intact.
The full divergent surface (broader than the three modules the issue names)
Memtrace blast-radius analysis (get_impact normalizePhaseName → risk CRITICAL, 84 affected symbols, 20 direct callers across 19 files) and a symbol sweep surfaced the complete surface. This matters because it bounds both the back-compat risk and the guard's scope:
phase-id.cts(the partial canonical seam, 272 lines, pure — "no Node built-ins"). Already owns:escapeRegex(:15),OPTIONAL_PROJECT_CODE_PREFIX_SOURCE(:24),OPTIONAL_PHASE_TAG_SOURCE(:42),stripProjectCodePrefix(:44),normalizePhaseName(:54),getMilestoneFromPhaseId(:79),getPhaseDirFromPhaseId(:88),phaseMarkdownRegexSource(:107),phaseMarkdownRegexSourceExact(:138),comparePhaseNum(:144),extractPhaseToken(:197),phaseTokenMatches(:247). It does not parse-from-prose, and its regex-source builders are consumed by callers, not applied here.state.cts— five independently-maintained phase-token regex shapes:parseProsePhaseField(:1120, the\b…\bminer), its unanchored twinsresolvePhaseIdForCompletePhase(:2722) andcmdStateCompletePhase(:2750), thePhase-anchoredextractRetiredPhaseNumbers(:1319), the strip/pad idiom incmdStateValidate(:2291,:2294), and the digits-only dir shape inphaseInventoryProvider(:2642). OnlyphaseKeyFromToken/phaseKeyFromDir(:1283-1288) delegate tophase-id.cjs.roadmap.cts— four regex-construction sites (:410,:528,:787, plus the two named CLI functions). The three standalone sites already use the canonicalphaseMarkdownRegexSourcebuilder; only the two named functions diverge (no prefix-tolerant source).roadmap-parser.cts—roadmapPhaseLookupSources(:245, the canonical 3-source ordering),getRoadmapPhaseInternal(:262, the one impure resolver),findRoadmapPhaseInContent(:214).init.cts— onnexttoday, init resolves a phase query through the config-blindstripProjectCodePrefix/normalizePhaseNamepath (init.cts:76,:1184), so its three sibling commands (cmdInitExecutePhase,cmdInitVerifyWork,cmdInitPhaseOp) collapse foreign-prefixed IDs (the #2104 symptom). The config-aware guard family (parsePhasePrefix/isForeignPrefixedPhaseQuery/roadmapPhaseMatchesExactPrefix, with/^([A-Z][A-Z0-9_]*)-(?=\d)/i) is being introduced by #2056 on the unmerged PR #2105 (fix/2056-plan-phase-foreign-prefix) and is not yet onnext— it is Cluster 1 / #2104 domain, and its line numbers are omitted here deliberately because they will drift when #2105 lands.validate.cts—buildNotStartedPhaseVariantswith its ownPhase\s+([\w][\w.-]*)regex;phaseVariants.- Two distinct ROADMAP content matchers:
searchPhaseInContent(roadmap.cts:126, usesOPTIONAL_PHASE_TAG_SOURCE+ a checklist fallback +tokenizeHeadings) vsfindRoadmapPhaseInContent(roadmap-parser.cts:214). escapeRegexis duplicated inphase-id.cts:15andstate-document.cts:11.
Per CONTRIBUTING.md:
One issue = one ADR-or-PRD = one PR. Do not batch multiple decisions into one file or one PR.
This ADR is that one file. It decides and locks the target seam; it ships no production code. Phases 1–4 execute against it as separate PRs.
Decision
Make src/phase-id.cts the single canonical owner of every phase-identifier operation, migrate the divergent consumers to delegate to it, and add a machine-enforced anti-divergence guard so no future module can re-implement phase-ID parsing without failing CI. Seven decisions, locked below.
1. phase-id.cts is the sole owner of phase-identifier parsing
"Phase-identifier parsing" is defined as the closed set of operations: normalize, compare, project-code prefix policy, parse-from-prose, parse-from-heading (regex-source construction + lookup-source ordering), parse-from-dir-name (token extraction + match predicate), and parse-from-CLI-query. Every function in that set lives in phase-id.cts (or, for the one operation that must touch the filesystem, in the single resolver named in Decision 5). Consumers require/import the canonical functions; no consumer defines a phase-identifier regex locally.
Rejected: (B) a new phase-resolver.cts module — rejected because phase-id.cts already holds twelve of these functions and 20 direct callers; a new module would create a second seam and worsen the divergence it aims to fix. (C) leave parsing distributed but add a lint that all sites match a golden regex — rejected because it enforces textual sameness, not single-ownership, and cannot cover the semantic divergences (\b…\b vs unanchored vs Phase-anchored are all "valid" regex).
2. Extend, never mutate — the backward-compatibility guarantee (Hyrum's Law)
normalizePhaseName has a CRITICAL blast radius (84 affected symbols, 20 direct callers, 19 files). Its observable behavior — zero-padding, unconditional prefix stripping, letter-case preservation (#1962), milestone-form decomposition — is depended upon everywhere. Locked: Phases 1–4 may only add exported functions to phase-id.cts; they may not change the observable behavior of any of the twelve existing exports. Any behavior change to an existing export (including "fixing" the config-blind strip in place) is out of scope for this epic and requires its own ADR. The #2104 fix is delivered as a new, config-aware function (Decision 4), leaving the existing config-blind path untouched for its 20 callers.
3. The locked canonical surface (the exports Phase 1 adds)
Phase 1 adds exactly these pure functions to phase-id.cts. Signatures and contracts are locked; Phases 2–4 consume them verbatim.
parsePhaseFromProse(value: string | null): { phase: string | null; name: string | null }
The anchored replacement for state.cts:parseProsePhaseField. It extracts a phase identifier only from a genuine phase reference — the literal token Phase <id> (optionally Phase <id>: <name>, Phase <id> — <name>, or Phase <id> of <M>). Invariants this seam pins:
- A milestone-completion string carries no phase:
parsePhaseFromProse('Milestone v0.5 complete')→{ phase: null, name: null }(fixes #2111). Likewisev1.0,v2.10, and anyMilestone v…form. - A real reference parses:
'Phase 3A — Delta (executing)'→{ phase: '3A', name: 'Delta' }. - It never mines a stray numeral from surrounding prose; absence of a
Phaseanchor yields{ phase: null }, not a guessed number.
stripConfiguredProjectCodePrefix(value: unknown, projectCode: string | null | undefined): string
The config-aware prefix stripper. Strips a leading <CODE>- only when <CODE> case-insensitively equals projectCode; a foreign prefix (MEM- when the code is LKML) or an absent/empty projectCode leaves the value verbatim. This is the canonical home for the #2104 fix. It complements — does not replace — the existing config-blind stripProjectCodePrefix (Decision 2).
isForeignPrefixedPhaseQuery(phase: unknown, projectCode: unknown): boolean
The canonical predicate that #2056's guard family — arriving on the unmerged PR #2105, not yet on next — will delegate to once it lands: true when phase carries a prefix that is not the configured projectCode. Locking it here means #2105's cmdInitPlanPhase guard and the three #2104 sibling commands share one foreign-prefix rule instead of the divergent copies they would otherwise seed.
roadmapPhaseLookupSources(phaseNum: unknown): string[] (moved from roadmap-parser.cts:245-260)
The canonical heading lookup-source builder becomes an owned export of phase-id.cts (it is already pure — it only composes regex-source strings). All three roadmap call sites consume it, so the ordering (Decision 5) has exactly one definition.
Parse-from-CLI-query — no new function (locked). A CLI-supplied phase argument (gsd-tools … <phase>) is resolved by composing existing locked primitives, not a new parser: extractPhaseToken / normalizePhaseName (token + normalize, Decision 2) → isForeignPrefixedPhaseQuery / stripConfiguredProjectCodePrefix (config-aware prefix policy, Decision 4) → phaseTokenMatches for dir-name resolution or roadmapPhaseLookupSources → getRoadmapPhaseInternal for heading resolution. This is deliberately not a distinct parseCliQuery function: callers already know they hold a CLI arg, and a discriminated god-parser would re-widen the accept surface (Postel's Law). The lock is that CLI-query resolution routes through these primitives only — no consumer re-derives a phase from a CLI arg with its own regex.
Rejected: (B) fixing parseProsePhaseField in place with a tighter regex but leaving it in state.cts — rejected because the fix would not be reusable by the other prose sites and would re-seed the divergence. (C) a single mega-parser parsePhaseId(input, kind) with a kind discriminator — rejected (Postel's Law / interface clarity): callers already know whether they hold prose, a heading, a dir name, or a CLI arg; a discriminated god-function hides that and widens the accept surface.
4. Project-code prefix policy — config-aware stripping is the resolution path
Locked policy: a project-code prefix is a display prefix. For identity/normalization where config is unavailable, the config-blind stripProjectCodePrefix remains (back-compat). For resolution of a caller-supplied query (init commands, roadmap lookup) the config-aware stripConfiguredProjectCodePrefix / isForeignPrefixedPhaseQuery are the path: a query whose prefix is not this project's code must not collapse to a bare number and match a foreign phase. This tightens an over-liberal accept surface (Postel's Law) without touching the 20 callers of the blind stripper.
5. Lookup-source ordering — the locked invariant
The canonical resolution tries sources in this exact, de-duplicated order (as roadmapPhaseLookupSources implements today at roadmap-parser.cts:251-259):
- Exact —
phaseMarkdownRegexSourceExact(phaseNum)— non-null only when the query itself carries a prefix; matches### Phase PROJ-42:verbatim. - Numeric / padding-tolerant —
phaseMarkdownRegexSource(phaseNum)— the canonical bare heading (### Phase 42:), padding-tolerant (0*N). - Prefix-tolerant —
`${OPTIONAL_PROJECT_CODE_PREFIX_SOURCE}${numericSource}`— the drifted-only fallback (### Phase MANIFOLD-117:for a bare117query), de-duplicated via[...new Set(sources)].
Order matters and is locked: bare-numeric is tried before prefix-tolerant so a canonical heading wins over a drifted one when both exist. The single impure ROADMAP resolver is getRoadmapPhaseInternal (roadmap-parser.cts:262) — it reads ROADMAP.md and loops these sources. roadmap.cts's CLI siblings (cmdRoadmapGetPhase, getRoadmapPhaseWithFallback) delegate to it rather than re-scanning content, collapsing the searchPhaseInContent vs findRoadmapPhaseInContent duplication onto one resolution path — this is precisely the delegation #2114 requests.
6. Migration order & backward-compatibility guarantees
Each phase is its own small PR, opened under a fresh chore(#2121): … — Phase N sub-issue, and lands in order — Phase N+1 does not begin until Phase N merges. No phase changes any observable CLI output except the corrected resolution for the cited symptom cases.
| Phase | Scope | Drives green | Sub-issue |
|---|---|---|---|
| 0 | This ADR — lock the contract. No production code. | — | Closes #2121 |
| 1 | Add the Decision-3 functions to phase-id.cts; move roadmapPhaseLookupSources in. Exhaustive unit tests + boundary cases (v0.5, v1.0, MEM-01, AB-29, bare 29, zero-padded 029) + ≥1 fast-check property test for the parse↔normalize contract. No consumer changes. |
— | new |
| 2 | Migrate state.cts prose/number sites (parseProsePhaseField → parsePhaseFromProse; the unanchored twins resolvePhaseIdForCompletePhase, cmdStateCompletePhase; align the dir/pad shapes on extractPhaseToken/normalizePhaseName). Regression-first: assert current_phase survives a milestone complete v0.5 close unchanged. |
#2111 | new |
| 3 | Delegate roadmap.cts:cmdRoadmapGetPhase + getRoadmapPhaseWithFallback to getRoadmapPhaseInternal / roadmapPhaseLookupSources. Regression-first: roadmap get-phase <bare-N> resolves ### Phase AB-N:, and both CLI siblings route through the same lookup sources as the internal resolver. |
#2114 | new |
| 4 | Add the Decision-7 anti-divergence guard; inventory-sweep the remaining sites. | closes the recurrence loop | new |
#2104 disposition (locked): Phase 1 builds the config-aware prefix API (Decision 4) so #2104's fix has a canonical home, but #2104's own migration (applying the guard to cmdInitExecutePhase / cmdInitVerifyWork / cmdInitPhaseOp) is blocked on PR #2105 (#2056) — the guard helpers it must reuse do not exist on next yet — and stays tracked on #2104, outside this epic's critical path. Phases 1–4 must not block on #2104, and #2104's init sites are allowlisted by the Phase-4 guard (Decision 7) until #2105 lands.
7. The anti-divergence contract (the parity guard)
Locked mechanism, modeled on the repo's two proven single-source patterns — tests/capability-precedence-parity.test.cjs (identity guard) and scripts/lint-package-identity-drift.cjs + tests/issue-498-identity-drift-lint.test.cjs (drift scanner):
- Identity guard test — for every consumer that re-exports a canonical phase-ID function, assert reference identity:
assert.strictEqual(consumer.fn, phaseId.fn). A pasted re-implementation is a different function object and fails instantly (the mechanismcapability-precedence-parity.test.cjs:44-51uses). - Drift scanner —
scripts/lint-phase-id-drift.cjsexports a purefindPhaseIdRegexDrift(text, opts)that flags phase-ID-shaped regex literals (\d+[A-Z]?(?:\.\d+)*,[A-Z][A-Z0-9_]*-, andPhase\s+…:heading builders) defined in anysrc/*.ctsother thanphase-id.cts. It is wired tonpm run check:phase-id-driftand asserted zero via ascanRepo(ROOT)integration test. A narrow allowlist keyed by an explicit// phase-id-owner: <reason>comment covers sanctioned exceptions (e.g. Cluster-1 / #2104 init sites,// phase-id-owner: cluster-1-#2104) until they migrate.
Locked constraints on the guard's own implementation (so it does not become new tech debt):
- It must be behavioral, not a
readFileSync(path).includes(...)inside atests/**/*.test.cjsfile — that tripseslint-rules/no-source-grep.cjs(boundlocal/no-source-grep,errorin tests). Text-scanning lives in thescripts/pure function; the testrequire()s it and calls it with inline string literals, pertests/issue-498-identity-drift-lint.test.cjs:21-25. - It must not be modeled on
tests/package-name-single-source.test.cjs, which only appears to satisfyno-source-grepbecause the rule's taint-tracking loses the variable after.split()— an evasion, not an exemption.
Rejected: (B) an ESLint no-restricted-syntax rule — the repo has exactly one such rule (test-timing hygiene, eslint.config.mjs:363) and no single-ownership lint precedent; a node:test behavioral contract is the established, proven pattern. (C) outcome-parity only (run two paths, diff outputs, as tests/phase.test.cjs:6881 expectParity does for #3537) — necessary but insufficient: it proves two paths agree today, not that only one implementation exists, so it cannot catch a third divergent site added tomorrow.
Consequences
Positive:
- The recurring #2111/#2114-class bug is root-caused, not point-fixed: one seam owns the parsing, and the Phase-4 guard makes re-divergence a CI failure rather than a future issue number.
- #2114's
roadmap get-phase/ui-plan-gatesplit is closed by delegation, and the two ROADMAP content matchers collapse to one resolver. - The config-aware prefix API gives #2104 (and its siblings #1836, #2056) a single correct home the moment #2105 lands.
- Callers gain named, tested parsing functions in place of inline
\b…\bcleverness that is hard to read and harder to debug (Kernighan's Law).
Negative:
- Touching
normalizePhaseName's neighborhood is high-risk (84 affected symbols). Decision 2 (extend-never-mutate) contains the risk but constrains the design — the fixes must be new functions, not tighter versions of the old ones. - Four sequential PRs plus sub-issues is more process overhead than a single "fix the three bugs" PR — accepted, because a batched fix would re-seed the very divergence this epic removes and violates one-issue-one-PR.
- The Phase-4 guard adds an allowlist that must be curated as #2104/#2105 land; a stale allowlist entry is a small, visible debt rather than a silent gap.
Neutral:
phase-id.ctsgrows from a normalizer into the full phase-ID surface; it stays pure (no Node built-ins), so the FS-touching resolver deliberately remainsgetRoadmapPhaseInternalinroadmap-parser.cts.#2104remains open and independently tracked; this epic neither closes nor blocks on it.
Alternatives considered
- Point-fix each of the three bugs in place. Rejected: leaves the seam absent, so surface #4 (init.cts, validate.cts) re-diverges under the next issue number — the exact history from #905 → #2111, #1455 → #2114, #2056 → #2104.
- One big PR consolidating everything at once. Rejected: violates
CONTRIBUTING.md"One issue = one … = one PR"; unreviewable across a CRITICAL-blast-radius symbol; no fail-first regression discipline per bug. - Golden-regex lint (all sites must textually match one pattern). Rejected: enforces textual sameness, not single ownership, and cannot express the semantic divergence (anchored vs unanchored vs
Phase-anchored are all syntactically valid). - Second module (
phase-resolver.cts). Rejected: a new seam alongside the existingphase-id.ctsseam deepens, rather than removes, the divergence.
Software laws applied
Cross-referenced via /skills-from-the-artificer; the laws that materially shaped the decisions:
- Hyrum's Law —
normalizePhaseName's 20 callers depend on its observable behavior ⇒ extend, never mutate (Decision 2). - Postel's Law — the current parsers are too liberal (mine any numeral; strip any prefix) ⇒ tighten acceptance to the anchored / config-aware forms (Decisions 3–4).
- Gall's Law —
phase-id.ctsis a working simple system; grow it incrementally through a phased epic rather than a big-bang rewrite (Decisions 1, 6). - DRY / single-source-of-truth (Generative Fix Divergence) — one seam, guarded, is the whole point (Decisions 1, 7).
- Kernighan's Law — inline
\b…\bone-liners are hard to debug; naming + centralizing them lowers the debugging cost the bugs were paying (Decision 3).
Cross-references
- Symptom issues: #2111, #2114, #2104 (blocked on #2105/#2056).
- Prior art: #1455 (
OPTIONAL_PROJECT_CODE_PREFIX_SOURCE,roadmap-parser.cts);CLAUDE.md→ "Generative Fix Divergence";scripts/lint-package-identity-drift.cjs+tests/capability-precedence-parity.test.cjs(the guard models). - Owner seam:
src/phase-id.cts. Impure resolver:src/roadmap-parser.cts:getRoadmapPhaseInternal.
Amendments
(none yet — append-only; amendments extend this ADR with a dated ### #<issue> — <topic> section rather than rewriting the body above.)