* 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:
@@ -188,8 +188,16 @@ function extractCanonicalPlanId(filename: string): string {
|
||||
// or a single-digit-plus-letter id ("3A"); a *bare* single digit is a slug word,
|
||||
// so "46-6-rs-…" is not paired into a "46-6" id while "3A-01" stays intact.
|
||||
const tokenRe = /^(?:\d{2,}[A-Z]?|\d[A-Z])(?:\.\d+)*$/i;
|
||||
// #2232: the PAIRED plan component is a zero-padded continuation segment
|
||||
// (exactly 2 digits), so a ≥3-digit slug word (a year) is not paired into a
|
||||
// bogus "14-2026" id. The leading phase component keeps tokenRe's unbounded
|
||||
// \d{2,} — phase numbers ≥100 are legitimate; only continuations are capped.
|
||||
const planTokenRe = new RegExp(
|
||||
`^(?:${phaseIdModule.PHASE_CONTINUATION_SEGMENT_SOURCE}[A-Z]?|\\d[A-Z])(?:\\.\\d+)*$`,
|
||||
'i',
|
||||
);
|
||||
const phaseIdx = parts.findIndex(p => tokenRe.test(p));
|
||||
if (phaseIdx >= 0 && phaseIdx + 1 < parts.length && tokenRe.test(parts[phaseIdx + 1])) {
|
||||
if (phaseIdx >= 0 && phaseIdx + 1 < parts.length && planTokenRe.test(parts[phaseIdx + 1])) {
|
||||
return `${parts[phaseIdx]}-${parts[phaseIdx + 1]}`;
|
||||
}
|
||||
return base;
|
||||
|
||||
@@ -53,6 +53,25 @@ const OPTIONAL_PHASE_TAG_SOURCE = '(?:\\s*\\([^)\\n]{0,200}\\))?';
|
||||
// introduced outside this module without a `// phase-id-owner:` justification.
|
||||
const PHASE_NUMBER_TOKEN_SOURCE = '\\d+[A-Z]?(?:\\.\\d+)*';
|
||||
|
||||
// #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 are width-capped. 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);
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -217,9 +236,11 @@ function extractPhaseToken(dirName: string): string {
|
||||
|
||||
const segments = rest.split('-');
|
||||
const tokenSegments: string[] = [];
|
||||
// #2043: distinguish a real (zero-padded, ≥2-digit) phase/sub-phase segment
|
||||
// from a single-digit slug word. A pure-numeric leading segment ("46") only
|
||||
// continues with ≥2-digit segments, so "46-6-rs-…" yields "46" (the "6" is the
|
||||
// #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
|
||||
@@ -239,7 +260,7 @@ function extractPhaseToken(dirName: string): string {
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
} else if (/^\d{2,}/.test(seg) || (firstLetterPrefixed && /^\d/.test(seg))) {
|
||||
} else if (isPhaseContinuationSegment(seg) || (firstLetterPrefixed && /^\d/.test(seg))) {
|
||||
tokenSegments.push(seg);
|
||||
} else {
|
||||
break;
|
||||
@@ -363,6 +384,8 @@ export = {
|
||||
OPTIONAL_PROJECT_CODE_PREFIX_SOURCE,
|
||||
OPTIONAL_PHASE_TAG_SOURCE,
|
||||
PHASE_NUMBER_TOKEN_SOURCE,
|
||||
PHASE_CONTINUATION_SEGMENT_SOURCE,
|
||||
isPhaseContinuationSegment,
|
||||
stripProjectCodePrefix,
|
||||
normalizePhaseName,
|
||||
getMilestoneFromPhaseId,
|
||||
|
||||
@@ -154,8 +154,16 @@ function extractCanonicalPlanId(filename: string): string {
|
||||
// or a single-digit-plus-letter id ("3A"); a *bare* single digit is a slug word,
|
||||
// so "46-6-rs-…" is not paired into a "46-6" id while "3A-01" stays intact.
|
||||
const tokenRe = /^(?:\d{2,}[A-Z]?|\d[A-Z])(?:\.\d+)*$/i;
|
||||
// #2232: the PAIRED plan component is a zero-padded continuation segment
|
||||
// (exactly 2 digits), so a ≥3-digit slug word (a year) is not paired into a
|
||||
// bogus "14-2026" id. The leading phase component keeps tokenRe's unbounded
|
||||
// \d{2,} — phase numbers ≥100 are legitimate; only continuations are capped.
|
||||
const planTokenRe = new RegExp(
|
||||
`^(?:${phaseIdMod.PHASE_CONTINUATION_SEGMENT_SOURCE}[A-Z]?|\\d[A-Z])(?:\\.\\d+)*$`,
|
||||
'i',
|
||||
);
|
||||
const phaseIdx = parts.findIndex((p) => tokenRe.test(p));
|
||||
if (phaseIdx >= 0 && phaseIdx + 1 < parts.length && tokenRe.test(parts[phaseIdx + 1])) {
|
||||
if (phaseIdx >= 0 && phaseIdx + 1 < parts.length && planTokenRe.test(parts[phaseIdx + 1])) {
|
||||
return `${parts[phaseIdx]}-${parts[phaseIdx + 1]}`;
|
||||
}
|
||||
return base;
|
||||
|
||||
@@ -588,12 +588,17 @@ function getMilestonePhaseFilter(cwd: string, versionOverride?: string | null, p
|
||||
}
|
||||
|
||||
const roadmapUsesHyphenedIds = [...normalized].some(n => n.includes('-'));
|
||||
// #2043: milestone-prefixed sub-phase components must be zero-padded (≥2 digits)
|
||||
// — "-\d{2,}" instead of "-0*\d+" — so a single-digit slug word after the phase
|
||||
// #2043: milestone-prefixed sub-phase components must be zero-padded — so a
|
||||
// single-digit slug word after the phase
|
||||
// number (e.g. dir "46-6-rs-…") captures "46" and is not silently excluded from
|
||||
// the milestone as a bogus "46-6" id.
|
||||
// the milestone as a bogus "46-6" id. #2232: the continuation width is exactly 2
|
||||
// (PHASE_CONTINUATION_SEGMENT_SOURCE), so a year-leading slug word (dir
|
||||
// "14-2026-photos-…") captures "14" and is not excluded as a bogus "14-2026" id.
|
||||
// Built via new RegExp (no /i — the [A-Za-z] letter class does real case handling).
|
||||
const numericRe = roadmapUsesHyphenedIds
|
||||
? /^0*(\d+(?:-\d{2,})*[A-Za-z]?(?:\.\d+)*)/
|
||||
? new RegExp(
|
||||
`^0*(\\d+(?:-${phaseIdModule.PHASE_CONTINUATION_SEGMENT_SOURCE})*[A-Za-z]?(?:\\.\\d+)*)`,
|
||||
)
|
||||
// phase-id-owner: the [A-Za-z] letter class does real case handling here — this regex carries NO /i flag; kept literal, not source-byte-equal to the canonical PHASE_NUMBER_TOKEN_SOURCE.
|
||||
: /^0*(\d+[A-Za-z]?(?:\.\d+)*)/;
|
||||
|
||||
|
||||
@@ -33,7 +33,11 @@
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import phaseIdMod = require('./phase-id.cjs');
|
||||
const { OPTIONAL_PROJECT_CODE_PREFIX_SOURCE, PHASE_NUMBER_TOKEN_SOURCE } = phaseIdMod;
|
||||
const {
|
||||
OPTIONAL_PROJECT_CODE_PREFIX_SOURCE,
|
||||
PHASE_NUMBER_TOKEN_SOURCE,
|
||||
PHASE_CONTINUATION_SEGMENT_SOURCE,
|
||||
} = phaseIdMod;
|
||||
|
||||
// ── Issue #26: regex constants (W005, W006-archived) ────────────────────────
|
||||
// Matches legacy numeric dirs (01-setup), milestone-prefixed dirs (02-01-setup),
|
||||
@@ -44,25 +48,31 @@ export const phaseDirNameRe = new RegExp(
|
||||
);
|
||||
// Extracts the full phase token from a directory name, including milestone-prefixed
|
||||
// multi-segment tokens like "02-01" from "02-01-setup" or "GSD-02-01-setup".
|
||||
// #2043: a *continuation* sub-phase segment must be zero-padded (≥2 digits), so a
|
||||
// #2043: a *continuation* sub-phase segment must be zero-padded, so a
|
||||
// single-digit slug word after a phase number (e.g. "46-6-rs-…", slug "6 Rs …") is
|
||||
// NOT absorbed — it captures "46", not "46-6". The first component stays "\d+"
|
||||
// NOT absorbed — it captures "46", not "46-6". #2232: the continuation width is
|
||||
// exactly 2 (PHASE_CONTINUATION_SEGMENT_SOURCE), so a ≥3-digit slug word (a year:
|
||||
// "14-2026-photos-…") is not absorbed either — it captures "14", not "14-2026".
|
||||
// The first component stays "\d+"
|
||||
// (with the "[A-Z]?" suffix) so single-digit letter-suffixed phase ids ("1A") and
|
||||
// milestone-prefixed single-digit sub-phases ("M1-2" → prefix "M1-" stripped, then
|
||||
// "2") still match. The trailing boundary "(?:-|$)" (was "(?:-[a-z]|$)") lets a slug
|
||||
// that starts with a digit terminate the token.
|
||||
export const PHASE_TOKEN_FROM_DIR_RE = new RegExp(
|
||||
`^${OPTIONAL_PROJECT_CODE_PREFIX_SOURCE}(\\d+(?:-\\d{2,})*[A-Z]?(?:\\.\\d+)*)(?:-|$)`,
|
||||
`^${OPTIONAL_PROJECT_CODE_PREFIX_SOURCE}(\\d+(?:-${PHASE_CONTINUATION_SEGMENT_SOURCE})*[A-Z]?(?:\\.\\d+)*)(?:-|$)`,
|
||||
'i',
|
||||
);
|
||||
export const MILESTONE_ARCHIVE_DIR_RE = /^v\d+.*-phases$/i;
|
||||
|
||||
// ── Issue #26: I001 canonicalization ────────────────────────────────────────
|
||||
export function canonicalPlanStem(stem: string): string {
|
||||
// #2043: the plan component (after the phase number) must be zero-padded
|
||||
// (≥2 digits), so a digit-leading slug word (e.g. "46-6-rs-…") is not mistaken
|
||||
// for a "46-6" phase/plan pair.
|
||||
const m = stem.match(new RegExp(`^(${PHASE_NUMBER_TOKEN_SOURCE}-\\d{2,})`, 'i'));
|
||||
// #2043: the plan component (after the phase number) must be zero-padded,
|
||||
// so a digit-leading slug word (e.g. "46-6-rs-…") is not mistaken
|
||||
// for a "46-6" phase/plan pair. #2232: exactly 2 digits, so a year-leading
|
||||
// slug ("14-2026-photos-…") is not mistaken for a "14-2026" pair either.
|
||||
const m = stem.match(
|
||||
new RegExp(`^(${PHASE_NUMBER_TOKEN_SOURCE}-${PHASE_CONTINUATION_SEGMENT_SOURCE})`, 'i'),
|
||||
);
|
||||
return m ? m[1] : stem;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user