From 612fcb00f79eb69443f9d7deed9234fe2e5cc3a8 Mon Sep 17 00:00:00 2001 From: Cody Anderson <70287898+arakasi1@users.noreply.github.com> Date: Wed, 15 Jul 2026 13:33:58 -0600 Subject: [PATCH] fix(#2232): cap phase-token continuation segments at exactly 2 digits (all sites) (#2254) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- .changeset/gentle-tigers-greet.md | 5 + src/core-utils.cts | 10 +- src/phase-id.cts | 31 +++- src/phase.cts | 10 +- src/roadmap-parser.cts | 13 +- src/validate.cts | 26 ++-- tests/continuation-grammar-parity.test.cjs | 160 +++++++++++++++++++++ tests/core-utils.test.cjs | 14 ++ tests/health-validation.test.cjs | 25 ++++ tests/phase-id.test.cjs | 99 +++++++++++++ tests/phase.test.cjs | 18 +++ tests/roadmap-parser.test.cjs | 26 ++++ 12 files changed, 419 insertions(+), 18 deletions(-) create mode 100644 .changeset/gentle-tigers-greet.md create mode 100644 tests/continuation-grammar-parity.test.cjs diff --git a/.changeset/gentle-tigers-greet.md b/.changeset/gentle-tigers-greet.md new file mode 100644 index 000000000..d7d88129b --- /dev/null +++ b/.changeset/gentle-tigers-greet.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 2254 +--- +**Phase dirs whose slug leads with a multi-digit number (e.g. a year) resolve again** — a phase like `14-2026-photos-performance` (roadmap name "2026 Photos & Performance") had its phase token over-collected as `14-2026`, so `init.plan-phase`, `init.execute-phase`, `phase-plan-index`, `state.planned-phase`, and `roadmap.annotate-dependencies` reported `phase_dir=null` / `plan_count=0` while the directory existed. Continuation segments of a phase token are now capped at the exactly-2-digit zero-padded form the write side emits, via a single shared grammar source consumed by all five parsing sites (the residual case from #2043). (#2232) diff --git a/src/core-utils.cts b/src/core-utils.cts index ea96ed380..cc7f77291 100644 --- a/src/core-utils.cts +++ b/src/core-utils.cts @@ -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; diff --git a/src/phase-id.cts b/src/phase-id.cts index ead7a3b27..d9f256f4c 100644 --- a/src/phase-id.cts +++ b/src/phase-id.cts @@ -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, diff --git a/src/phase.cts b/src/phase.cts index e1f089068..e22334ec7 100644 --- a/src/phase.cts +++ b/src/phase.cts @@ -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; diff --git a/src/roadmap-parser.cts b/src/roadmap-parser.cts index d1bfc98fc..95585e6b5 100644 --- a/src/roadmap-parser.cts +++ b/src/roadmap-parser.cts @@ -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+)*)/; diff --git a/src/validate.cts b/src/validate.cts index edffe68bf..48fe3eeb7 100644 --- a/src/validate.cts +++ b/src/validate.cts @@ -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; } diff --git a/tests/continuation-grammar-parity.test.cjs b/tests/continuation-grammar-parity.test.cjs new file mode 100644 index 000000000..1dc7f1c54 --- /dev/null +++ b/tests/continuation-grammar-parity.test.cjs @@ -0,0 +1,160 @@ +'use strict'; +/** + * continuation-grammar-parity.test.cjs — DEFECT.GENERATIVE-FIX parity gate (#2232) + * + * Proves that the phase-token CONTINUATION-segment grammar has a single owner + * (`phase-id.cjs: PHASE_CONTINUATION_SEGMENT_SOURCE` / `isPhaseContinuationSegment`) + * and that every consuming surface agrees with it on a shared digit-width corpus. + * + * Why this gate exists: #2043 fixed the same class of bug by hand-editing five + * independent `/^\d{2,}/` copies; #2232 is the residual that survived because a + * later reader could not tell the five copies were one rule. The rule is now + * single-sourced, but a regex literal is easy to re-introduce and + * `scripts/lint-phase-id-drift.cjs` only guards the OTHER constant + * (`PHASE_NUMBER_TOKEN_SOURCE`) — a bare `\d{2,}` re-derivation would pass lint + * and CI silently. This test is the behavioral backstop: it fails the moment any + * consuming surface disagrees with the owner about which continuation widths are + * absorbed. + * + * Contract: for every digit-width in the corpus, each surface's notion of + * "is this segment absorbed as a continuation?" MUST equal + * `isPhaseContinuationSegment(segment)`. + * + * Surfaces covered (the five #2043 sites): + * 1. phase-id.cjs extractPhaseToken + * 2. validate.cjs PHASE_TOKEN_FROM_DIR_RE + * 3. validate.cjs canonicalPlanStem + * 4. core-utils.cjs extractCanonicalPlanId (paired plan component) + * 5. roadmap-parser.cjs getMilestonePhaseFilter → isDirInMilestone (hyphenated mode) + */ + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const phaseId = require('../gsd-core/bin/lib/phase-id.cjs'); +const validate = require('../gsd-core/bin/lib/validate.cjs'); +const coreUtils = require('../gsd-core/bin/lib/core-utils.cjs'); +const { getMilestonePhaseFilter } = require('../gsd-core/bin/lib/roadmap-parser.cjs'); +const { createTempProject, cleanup } = require('./helpers.cjs'); + +// The shared digit-width corpus. `absorbed` is stated independently of the +// implementation (it is the LOCKED POLICY, not a mirror of the regex): a +// continuation is exactly the 2-digit zero-padded form getPhaseDirFromPhaseId +// emits. 1-digit is a slug word (#2043); ≥3-digit is a slug word (#2232 — a +// year/count/version). +const WIDTH_CORPUS = [ + { width: 1, seg: '6', absorbed: false, note: '#2043 single-digit slug word' }, + { width: 2, seg: '02', absorbed: true, note: 'the zero-padded sub-phase — the cap' }, + { width: 3, seg: '100', absorbed: false, note: '#2232 limit+1 (policy: ≥100 out of grammar)' }, + { width: 4, seg: '2026', absorbed: false, note: '#2232 the reported case (a year)' }, + { width: 5, seg: '12345', absorbed: false, note: '#2232 far side of the cap' }, +]; + +describe('#2232 continuation-grammar parity — owner vs. corpus', () => { + test('the owner (isPhaseContinuationSegment) matches the locked policy', () => { + for (const { seg, absorbed, note } of WIDTH_CORPUS) { + assert.strictEqual( + phaseId.isPhaseContinuationSegment(seg), + absorbed, + `isPhaseContinuationSegment(${JSON.stringify(seg)}) must be ${absorbed} — ${note}`, + ); + } + }); + + test('PHASE_CONTINUATION_SEGMENT_SOURCE is exported and is the exactly-2 grammar', () => { + assert.strictEqual(typeof phaseId.PHASE_CONTINUATION_SEGMENT_SOURCE, 'string'); + // Anchored at both ends so a consuming site can embed it verbatim. + const re = new RegExp(`^${phaseId.PHASE_CONTINUATION_SEGMENT_SOURCE}$`); + assert.ok(re.test('02'), 'the 2-digit form must match'); + assert.ok(!re.test('2026'), 'a 4-digit run must not match'); + assert.ok(!re.test('6'), 'a 1-digit run must not match'); + }); +}); + +describe('#2232 continuation-grammar parity — every consuming surface agrees', () => { + for (const { seg, absorbed, note } of WIDTH_CORPUS) { + test(`width ${seg.length} (${JSON.stringify(seg)}): all surfaces agree absorbed=${absorbed} — ${note}`, () => { + const owner = phaseId.isPhaseContinuationSegment(seg); + assert.strictEqual(owner, absorbed, 'precondition: owner matches policy'); + + // ── Surface 1: extractPhaseToken ──────────────────────────────────── + const dir = `14-${seg}-photos-performance`; + assert.strictEqual( + phaseId.extractPhaseToken(dir) === `14-${seg}`, + owner, + `extractPhaseToken(${JSON.stringify(dir)}) diverged from the owner`, + ); + + // ── Surface 2: validate PHASE_TOKEN_FROM_DIR_RE ───────────────────── + const reToken = validate.PHASE_TOKEN_FROM_DIR_RE.exec(dir)?.[1]; + assert.strictEqual( + reToken === `14-${seg}`, + owner, + `PHASE_TOKEN_FROM_DIR_RE on ${JSON.stringify(dir)} gave ${JSON.stringify(reToken)} — diverged from the owner`, + ); + + // ── Surface 3: validate canonicalPlanStem ─────────────────────────── + const stem = `14-${seg}-photos-performance`; + assert.strictEqual( + validate.canonicalPlanStem(stem) === `14-${seg}`, + owner, + `canonicalPlanStem(${JSON.stringify(stem)}) diverged from the owner`, + ); + + // ── Surface 4: core-utils extractCanonicalPlanId (paired component) ── + const planFile = `14-${seg}-photos-performance-PLAN.md`; + assert.strictEqual( + coreUtils.extractCanonicalPlanId(planFile) === `14-${seg}`, + owner, + `extractCanonicalPlanId(${JSON.stringify(planFile)}) diverged from the owner`, + ); + }); + } +}); + +// Surface 5 needs a real ROADMAP/STATE on disk, so it gets its own block. +describe('#2232 continuation-grammar parity — roadmap isDirInMilestone (hyphenated mode)', () => { + let tmpDir; + + function writeProject(roadmapLines) { + tmpDir = createTempProject(); + const planning = path.join(tmpDir, '.planning'); + fs.mkdirSync(planning, { recursive: true }); + fs.writeFileSync(path.join(planning, 'STATE.md'), '---\nmilestone: v1.0\n---\n'); + fs.writeFileSync(path.join(planning, 'ROADMAP.md'), roadmapLines.join('\n')); + return tmpDir; + } + + for (const { seg, absorbed, note } of WIDTH_CORPUS) { + test(`width ${seg.length} (${JSON.stringify(seg)}): isDirInMilestone agrees — ${note}`, () => { + // A hyphenated phase id in the roadmap switches the filter into the + // hyphenated-mode regex — the branch #2043/#2232 both live in. + writeProject([ + '## v1.0: Current', + '### Phase 2-01: Alpha', + '**Goal:** first alpha phase', + '', + '### Phase 14: 2026 Photos And Performance', + '**Goal:** the year-leading slug case', + ]); + const filter = getMilestonePhaseFilter(tmpDir); + + // When the segment is NOT absorbed, the dir's token is "14" → matches + // roadmap Phase 14. When it IS absorbed (width 2), the token is "14-02", + // which the roadmap does not list → correctly excluded. + assert.strictEqual( + filter(`14-${seg}-photos-performance`), + !absorbed, + `isDirInMilestone("14-${seg}-photos-performance") diverged from the owner ` + + `(absorbed=${absorbed} → token ${absorbed ? `"14-${seg}" (not in roadmap)` : '"14" (Phase 14)'})`, + ); + + // Control: the genuine milestone-prefixed dir always matches. + assert.strictEqual(filter('02-01-alpha'), true, '02-01-alpha must match Phase 2-01'); + cleanup(tmpDir); + tmpDir = null; + }); + } +}); diff --git a/tests/core-utils.test.cjs b/tests/core-utils.test.cjs index c994e0cdf..e2f7dbe40 100644 --- a/tests/core-utils.test.cjs +++ b/tests/core-utils.test.cjs @@ -548,6 +548,20 @@ describe('extractCanonicalPlanId', () => { assert.strictEqual(coreUtils.extractCanonicalPlanId('01-02-PLAN.md'), '01-02'); assert.strictEqual(coreUtils.extractCanonicalPlanId('3A-01-feature-PLAN.md'), '3A-01'); }); + + test('does not pair a ≥3-digit slug word as a plan component (#2232)', () => { + // A year-leading slug word ("14-2026-photos-…") is not a plan component — + // must not collapse to the bogus "14-2026". + assert.notStrictEqual( + coreUtils.extractCanonicalPlanId('14-2026-photos-performance-SUMMARY.md'), + '14-2026', + ); + assert.notStrictEqual(coreUtils.extractCanonicalPlanId('05-100-slug-PLAN.md'), '05-100'); + // The LEADING phase component stays unbounded (\d{2,}) — only the paired + // continuation is width-capped, so phase ≥100 plan files still pair. + assert.strictEqual(coreUtils.extractCanonicalPlanId('100-01-extra-slug-PLAN.md'), '100-01'); + assert.strictEqual(coreUtils.extractCanonicalPlanId('01-02-PLAN.md'), '01-02'); + }); }); // ─── countMatchedSummaries (#1988) ─────────────────────────────────────────── diff --git a/tests/health-validation.test.cjs b/tests/health-validation.test.cjs index 94a7841f6..6a5d0d9bb 100644 --- a/tests/health-validation.test.cjs +++ b/tests/health-validation.test.cjs @@ -1215,6 +1215,31 @@ describe('Drift item I001 — canonicalPlanStem: long PLAN stem matches short SU assert.strictEqual(gen.canonicalPlanStem('68-01-scaffolding'), '68-01'); assert.strictEqual(gen.canonicalPlanStem('3A-01-feature'), '3A-01'); }); + + test('PHASE_TOKEN_FROM_DIR_RE rejects a ≥3-digit slug word after a phase number (#2232)', () => { + const gen = require('../gsd-core/bin/lib/validate.cjs'); + const re = gen.PHASE_TOKEN_FROM_DIR_RE; + // Dir "14-2026-photos-performance" (roadmap phase name "2026 Photos & + // Performance") must extract token "14", not "14-2026" — the year is the + // slug's first word. Boundary by continuation-segment digit width: + assert.strictEqual(re.exec('14-2026-photos-performance')?.[1], '14'); // 4-digit: slug + assert.strictEqual(re.exec('05-100-slug')?.[1], '05'); // 3-digit: slug (policy) + assert.strictEqual(re.exec('02-01-setup')?.[1], '02-01'); // 2-digit: sub-phase + assert.strictEqual(re.exec('46-6-rs')?.[1], '46'); // 1-digit: slug (#2043) + }); + + test('canonicalPlanStem does not pair a ≥3-digit slug word (#2232)', () => { + const gen = require('../gsd-core/bin/lib/validate.cjs'); + // A year-leading slug is not a plan component: the stem is returned + // unchanged rather than the bogus "14-2026". + assert.strictEqual( + gen.canonicalPlanStem('14-2026-photos-performance'), + '14-2026-photos-performance', + ); + assert.strictEqual(gen.canonicalPlanStem('05-100-slug'), '05-100-slug'); + // Legit zero-padded plan components still canonicalize. + assert.strictEqual(gen.canonicalPlanStem('68-01-scaffolding'), '68-01'); + }); }); }); } diff --git a/tests/phase-id.test.cjs b/tests/phase-id.test.cjs index 286cf7a0c..c084a2524 100644 --- a/tests/phase-id.test.cjs +++ b/tests/phase-id.test.cjs @@ -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}`); + }, + ), + ); + }); +}); diff --git a/tests/phase.test.cjs b/tests/phase.test.cjs index fb884528f..376338f61 100644 --- a/tests/phase.test.cjs +++ b/tests/phase.test.cjs @@ -555,6 +555,24 @@ describe('phase-plan-index command', () => { assert.ok(output.warning === undefined, 'truly empty dir must not emit a warning'); }); + test('phase dir whose slug leads with a year still resolves and indexes plans (#2232)', () => { + // Roadmap phase name "2026 Photos & Performance" → dir + // "14-2026-photos-performance". extractPhaseToken over-collected the year + // into the token ("14-2026"), so phase-plan-index reported plans: [] while + // the plans existed on disk. + const phaseDir = path.join(tmpDir, '.planning', 'phases', '14-2026-photos-performance'); + fs.mkdirSync(phaseDir, { recursive: true }); + fs.writeFileSync(path.join(phaseDir, '14-01-PLAN.md'), '---\nwave: 1\n---\n'); + fs.writeFileSync(path.join(phaseDir, '14-02-PLAN.md'), '---\nwave: 1\n---\n'); + + const result = runGsdTools('phase-plan-index 14', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.strictEqual(output.plans.length, 2, 'plans found despite year-leading slug'); + assert.ok(output.warning === undefined, `canonical plans must not warn, got: ${output.warning}`); + }); + // #2893 — when the planner produces filenames that don't match the canonical // `{padded_phase}-{NN}-PLAN.md` contract, the executor used to silently see // plan_count: 0 with no signal. Now the response must include a `warning` diff --git a/tests/roadmap-parser.test.cjs b/tests/roadmap-parser.test.cjs index 9fb864bf2..d5be4f408 100644 --- a/tests/roadmap-parser.test.cjs +++ b/tests/roadmap-parser.test.cjs @@ -552,6 +552,32 @@ describe('roadmap-parser: getMilestonePhaseFilter', () => { assert.strictEqual(filter('02-01-alpha'), true, '02-01-alpha matches Phase 2-01'); }); + test('year-leading slug word after a phase number is not wrongly excluded (#2232)', () => { + // Same hyphenated-mode collision as #2043 but with a ≥3-digit slug word: + // phase 14's roadmap name "2026 Photos & Performance" slugifies to a dir + // starting with a year ("14-2026-photos-…"). The ≥2-digit continuation + // gate over-collected the year into the phase token ("14-2026"), which + // never matched the roadmap's "14", so the dir was wrongly excluded. + writeState(tmpDir, { milestone: 'v1.0' }); + writeRoadmap(tmpDir, [ + '## v1.0: Current', + '### Phase 2-01: Alpha', + '**Goal:** first alpha phase', + '', + '### Phase 14: 2026 Photos & Performance', + '**Goal:** ship the photos and performance work', + ].join('\n')); + + const filter = getMilestonePhaseFilter(tmpDir); + assert.strictEqual( + filter('14-2026-photos-performance'), + true, + '14-2026-photos-performance (phase 14, year-leading slug) must match Phase 14', + ); + // Legit milestone-prefixed dir still matches as before. + assert.strictEqual(filter('02-01-alpha'), true, '02-01-alpha matches Phase 2-01'); + }); + test('versionOverride uses specified version slice', () => { writeRoadmap(tmpDir, [ '## v1.0: Old',