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

@@ -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)

View File

@@ -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;

View File

@@ -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,

View File

@@ -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;

View File

@@ -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+)*)/;

View File

@@ -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;
}

View File

@@ -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;
});
}
});

View File

@@ -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) ───────────────────────────────────────────

View File

@@ -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');
});
});
});
}

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}`);
},
),
);
});
});

View File

@@ -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`

View File

@@ -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',