* 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>
161 lines
7.4 KiB
JavaScript
161 lines
7.4 KiB
JavaScript
'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;
|
|
});
|
|
}
|
|
});
|