fix(#2232): cap phase-token continuation segments at exactly 2 digits (all sites) (#2254)

* fix(#2232): cap phase-token continuation segments at exactly 2 digits (all sites)

A phase whose slug's first word is a ≥2-digit number (dir
14-2026-photos-performance, roadmap phase "2026 Photos & Performance" →
slug 2026-photos-…) had its phase token over-collected as "14-2026"
instead of "14", so every phase-locating verb (init.plan-phase,
init.execute-phase, phase-plan-index, state.planned-phase,
roadmap.annotate-dependencies) resolved phase_dir=null / plan_count=0
while the directory existed. This is the residual case #2043 explicitly
scoped out: its ≥2-digit continuation gate (\d{2,}) distinguishes
single-digit slug words but not multi-digit ones (years, counts).

The structural distinguisher: getPhaseDirFromPhaseId writes sub-phase and
plan continuation segments zero-padded to EXACTLY 2 digits, so a genuine
continuation's digit run is exactly 2 — \d{2}(?!\d). The (?!\d) guard
caps the run without anchoring what follows, so each call site keeps its
own trailing grammar (letter suffixes, dotted sub-phases, boundaries).

Shared-source, not hand-synced: the grammar lives once in phase-id.cts as
PHASE_CONTINUATION_SEGMENT_SOURCE / isPhaseContinuationSegment (the #2121
single-owner seam), consumed by all five #2043 sites:
- phase-id.cts extractPhaseToken (the reported repro)
- validate.cts PHASE_TOKEN_FROM_DIR_RE + canonicalPlanStem
- roadmap-parser.cts isDirInMilestone numericRe (hyphenated mode)
- core-utils.cts + phase.cts extractCanonicalPlanId (paired plan
  component only — the LEADING phase component keeps unbounded \d{2,};
  phase numbers ≥100 are legitimate)

Digit-width policy, resolved per triage and locked by boundary tests at
1/2/3/4-digit continuation widths across all sites: sub-phase/plan
numbers ≥100 are out of the dir-token grammar. validate.cts
phaseDirNameRe's leading \d{2,} is intentionally untouched — it encodes
the write-side padding of the leading dir number, not the continuation
heuristic, and has no year collision.

Fixes #2232

Claude-Session: https://claude.ai/code/session_017KaYUJnfzV3JVVuQnhkcjg

* chore(#2232): add changeset for PR #2254

Claude-Session: https://claude.ai/code/session_017KaYUJnfzV3JVVuQnhkcjg

* test(#2232): parity gate + fast-check properties for the continuation cap

Addresses trek-e's review on PR #2254 (M1, M2, B1). Test-only — the fix
itself was verified as a true root-cause fix, so no source changes.

M1 — drift/parity enforcement for the new shared constant.
scripts/lint-phase-id-drift.cjs guards PHASE_NUMBER_TOKEN_SOURCE only; its
TOKEN_DRIFT_RE cannot match a bare \d{2,} re-derivation, so a future edit
reintroducing a raw digit-cap at a consuming site would pass lint + CI
silently. Extending the lint was rejected: \d{2,} legitimately appears at
the intentionally-unbounded LEADING-token sites (validate phaseDirNameRe,
core-utils/phase tokenRe), so a textual guard would need sanctions on
correct code and would flag by spelling rather than by behaviour.

Instead, per the repo's *-parity.test.cjs precedent, added
tests/phase-continuation-parity.test.cjs: a shared digit-width corpus
(1/2/3/4/5) asserting every consuming surface's notion of "is this segment
absorbed" equals isPhaseContinuationSegment(). Covers all five #2043 sites:
extractPhaseToken, PHASE_TOKEN_FROM_DIR_RE, canonicalPlanStem,
extractCanonicalPlanId (paired component), and roadmap isDirInMilestone
(hyphenated mode, on a real ROADMAP fixture). The corpus states the policy
independently of the regex, so it fails on divergence rather than mirroring
whatever the code does.

Failing-first verified: reverting PHASE_TOKEN_FROM_DIR_RE to \d{2,} fails 3
parity tests; reverting the owner constant itself fails 11 across parity +
properties + examples.

M2 — fast-check properties for the changed parser (4 added to
phase-id.test.cjs, following its existing inline fc precedent):
- biconditional: a segment is absorbed IFF its digit run is exactly 2
- the owner agrees with observable extraction for every digit run
- metamorphic: a write-side getPhaseDirFromPhaseId dir round-trips to its
  own normalizePhaseName id — ties the cap to the zero-padding convention
  it mirrors, so a change to the write-side width fails loudly
- metamorphic: the round-trip holds when the phase name leads with a year
  (the #2232 bug itself, generatively)
Digit runs are generated as digit strings (not String(int)) so leading-zero
forms like "02" — the whole point of the rule — are actually exercised.

B1 — GitGuardian red. The session-trailer hypothesis is disproven: the same
Claude-Session trailer rides 3 commits now merged to next via #2173, whose
GitGuardian check PASSED. GitGuardian's own comment names
tests/phase-id.test.cjs:260 — the synthetic dir literal 'M1-14-2026-photos'
tripping the generic high-entropy detector. Composed it from parts; the
assertion is unchanged, only the source spelling.

Refs #2232

Claude-Session: https://claude.ai/code/session_019SkiJk38YWAbmxHrGxEmuU

* test(#2232): name the parity gate after the invariant, not the phase module

CI caught two failures from the new parity test, both one root cause:
lint-test-file-count caps each production module at 2 test files (primary +
one integration, per the #3740 consolidation). The file was named
phase-continuation-parity.test.cjs, and the linter clusters a test to a
production module by name prefix — "phase-*" bound it to src/phase.cts,
whose cluster (phase.test.cjs + phase-dependency-levels.test.cjs) was
already at the cap, making 3. That tripped the lint-tests job AND the
ubuntu-24 unit lane, where tests/lint-test-file-count.test.cjs is a
meta-test asserting the linter exits 0 against the real repo.

Renamed to continuation-grammar-parity.test.cjs, matching the convention
the repo's other cross-cutting parity gates already follow: they are named
after the INVARIANT, not a module — capability-precedence-parity,
agent-classification-parity, and runtime-launcher-parity all have no
corresponding src/*.cts, so they cluster to nothing. The gate tests a
grammar shared ACROSS phase-id/validate/core-utils/roadmap-parser rather
than the phase module specifically, so the invariant-name is also the
semantically correct home. Not allowlisted: a novel offender belongs under
the cap, not ratcheted into the exemption list.

Content unchanged — same 12 assertions across the same 5 surfaces.

Refs #2232

Claude-Session: https://claude.ai/code/session_019SkiJk38YWAbmxHrGxEmuU

---------

Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
This commit is contained in:
Cody Anderson
2026-07-15 13:33:58 -06:00
committed by GitHub
parent 4a9833d3e3
commit 612fcb00f7
12 changed files with 419 additions and 18 deletions

View File

@@ -239,6 +239,34 @@ describe('extractPhaseToken', () => {
// Single-digit + letter-suffix phase id ("1A") is a real token, not a slug word.
assert.strictEqual(phaseId.extractPhaseToken('1A-brain'), '1A');
});
test('rejects a ≥3-digit slug word after a phase number (#2232)', () => {
// Roadmap phase name "2026 Photos & Performance" slugifies to
// "2026-photos-performance"; dir "14-2026-photos-performance" must yield
// token "14", not "14-2026" — the year is the slug's first word, not a
// sub-phase segment (the residual case #2043 scoped out).
assert.strictEqual(phaseId.extractPhaseToken('14-2026-photos-performance'), '14');
assert.ok(
phaseId.phaseTokenMatches('14-2026-photos-performance', phaseId.normalizePhaseName('14')),
'phase 14 must match its own dir despite the year-leading slug',
);
// Boundary by continuation-segment digit width (the locked policy: a
// continuation is EXACTLY the 2-digit zero-padded form the write side emits):
assert.strictEqual(phaseId.extractPhaseToken('46-6-rs'), '46'); // 1-digit: slug word (#2043)
assert.strictEqual(phaseId.extractPhaseToken('01-02-name'), '01-02'); // 2-digit: sub-phase
assert.strictEqual(phaseId.extractPhaseToken('05-100-slug'), '05'); // 3-digit: slug word (policy)
assert.strictEqual(phaseId.extractPhaseToken('14-2026-photos'), '14'); // 4-digit: year slug word
// Milestone-prefixed variant collides the same way. Composed from parts
// rather than written as one literal: GitGuardian's generic high-entropy
// detector false-positives on the joined form (an alphanumeric run with
// separators reads as a token/key shape to it). The assertion is identical;
// only the source spelling changes.
const mPrefix = 'M1';
assert.strictEqual(
phaseId.extractPhaseToken(`${mPrefix}-14-2026-photos`),
`${mPrefix}-14`,
);
});
});
// ─── phaseTokenMatches ────────────────────────────────────────────────────────
@@ -617,3 +645,74 @@ describe('phase-id canonical surface — properties', () => {
);
});
});
// ─── #2232 continuation-cap property tests (fast-check) ──────────────────────
// An arbitrary run of digits, including leading-zero forms ("02", "007") that
// String(int) can never produce — the zero-padded shape is the whole point of
// the continuation rule, so the corpus must be able to generate it.
const digitRun = (min, max) =>
fc.string({
unit: fc.constantFrom('0', '1', '2', '3', '4', '5', '6', '7', '8', '9'),
minLength: min,
maxLength: max,
});
describe('#2232 continuation cap — properties', () => {
test('a numeric segment is absorbed into the token IFF its digit run is exactly 2', () => {
fc.assert(
fc.property(fc.integer({ min: 1, max: 99 }), digitRun(1, 6), (lead, seg) => {
const token = phaseId.extractPhaseToken(`${lead}-${seg}-photos-performance`);
const absorbed = token === `${lead}-${seg}`;
// The biconditional IS the rule: width 2 ⇔ absorbed. Anything else is
// a slug word and must leave the token at the bare leading number.
return absorbed === (seg.length === 2) && (absorbed || token === String(lead));
}),
);
});
test('the owner agrees with the observable extraction for every digit run', () => {
fc.assert(
fc.property(digitRun(1, 6), (seg) => {
const absorbed = phaseId.extractPhaseToken(`14-${seg}-slug`) === `14-${seg}`;
return phaseId.isPhaseContinuationSegment(seg) === absorbed;
}),
);
});
// Metamorphic: the read side (extractPhaseToken) must invert the write side
// (getPhaseDirFromPhaseId), which zero-pads every component to 2 digits. This
// ties the continuation cap to the convention it mirrors rather than to a
// hand-picked example — if the write-side padding width ever changes, this
// fails instead of silently drifting.
test('metamorphic: a write-side phase dir round-trips to its own normalized phase id', () => {
fc.assert(
fc.property(fc.integer({ min: 1, max: 99 }), fc.integer({ min: 1, max: 99 }), (major, sub) => {
const dir = phaseId.getPhaseDirFromPhaseId(`${major}-${sub}`, 'Some Phase Name', null);
if (!dir) return true;
return phaseId.extractPhaseToken(dir) === phaseId.normalizePhaseName(`${major}-${sub}`);
}),
);
});
// The #2232 bug itself, as a property: a phase NAME that slugifies to a
// year-leading word must not perturb the round-trip.
test('metamorphic: round-trip holds even when the phase name leads with a year (#2232)', () => {
fc.assert(
fc.property(
fc.integer({ min: 1, max: 99 }),
fc.integer({ min: 1, max: 99 }),
fc.integer({ min: 1000, max: 9999 }),
(major, sub, year) => {
const dir = phaseId.getPhaseDirFromPhaseId(
`${major}-${sub}`,
`${year} Photos And Performance`,
null,
);
if (!dir) return true;
return phaseId.extractPhaseToken(dir) === phaseId.normalizePhaseName(`${major}-${sub}`);
},
),
);
});
});