'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, plus the #612 bracket read path): * 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) * 6. phase-id.cjs BRACKET_PHASE_TOKEN_SOURCE (slug-adjacent position only — * see the divergence block at the foot of this file) * 7. validate.cjs buildRoadmapPhaseVariants (#2761 bracket heading read) * 8. validate.cjs phaseTokenFromDir vs phase-id.cjs extractPhaseToken * (#2761 bracket DIRECTORY read — the two readers that * resolve a phase directory on the `validate health` path) */ const { test, describe } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); const fc = require('fast-check'); const phaseId = require('../msd-core/bin/lib/phase-id.cjs'); const validate = require('../msd-core/bin/lib/validate.cjs'); const coreUtils = require('../msd-core/bin/lib/core-utils.cjs'); const { getMilestonePhaseFilter } = require('../msd-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'); assert.ok(!re.test('10x'), 'a digit-plus-letter slug word 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 6: #612 BRACKET_PHASE_TOKEN_SOURCE (slug-adjacent position) ── // The bracket run is MM-PP[.SS][-LL]; `-LL` is the only position a slug // word can collide with, so it is the position #2232 owns. Same shape as // surface 1 with the bracket's extra milestone level: `01-14--slug…` // puts at dash-2, exactly where a year over-collected before. const bracketDir = `01-14-${seg}-photos-performance`; const bracketToken = bracketDir.match(new RegExp(phaseId.BRACKET_PHASE_TOKEN_SOURCE))?.[0]; assert.strictEqual( bracketToken === `01-14-${seg}`, owner, `BRACKET_PHASE_TOKEN_SOURCE on ${JSON.stringify(bracketDir)} collected ` + `${JSON.stringify(bracketToken)} — diverged from the owner at the slug-adjacent position`, ); }); } test('#2528: regex token extraction agrees on the literal reading at slug boundaries', () => { const cases = [ // The reported shape and its indistinguishable twin read IDENTICALLY: no // surface may guess which of the two a `NN-NN--…` name is, because // nothing in the name says. Phase 10 named "24/7 Autonomy" is reached by // the resolution-layer fallback (see phase-id.test.cjs), NOT by the // tokenizer re-reading its name. ['10-24-7-autonomy', '10-24'], ['10-24-7-zip', '10-24'], ['05-80-20-25abc', '05-80-20'], ['14-06-2026-photos-and-performance', '14-06'], ['14-10x-growth', '14'], ]; for (const [dir, expected] of cases) { assert.strictEqual(phaseId.extractPhaseToken(dir), expected); assert.strictEqual( validate.PHASE_TOKEN_FROM_DIR_RE.exec(dir)?.[1], expected, `PHASE_TOKEN_FROM_DIR_RE diverged from extractPhaseToken for ${dir}`, ); } }); test('#2528: a one-digit terminator does not re-tokenize the name on any non-I/O surface', () => { // Every surface below is QUERY-LESS — it sees a name and nothing else — so // none of them may resolve the "24 is a sub-phase" / "24 is a slug word" // ambiguity. They agree on the literal reading, and the disambiguation is // left to matchPhaseDirs, which does have a query. for (const dir of ['10-24-7-autonomy', '10-24-7-zip']) { assert.strictEqual(phaseId.extractPhaseToken(dir), '10-24'); assert.strictEqual(validate.PHASE_TOKEN_FROM_DIR_RE.exec(dir)?.[1], '10-24'); assert.strictEqual(validate.canonicalPlanStem(dir), '10-24'); assert.strictEqual( coreUtils.extractCanonicalPlanId(`${dir}-PLAN.md`), '10-24', ); } const bracketDir = '01-10-24-7-autonomy'; assert.strictEqual( bracketDir.match(new RegExp(phaseId.BRACKET_PHASE_TOKEN_SOURCE))?.[0], '01-10-24', ); }); test('letter-suffixed plan components and dotted sub-phases keep their established grammar', () => { assert.strictEqual(phaseId.isPhaseContinuationSegment('01A'), true); assert.strictEqual(validate.PHASE_TOKEN_FROM_DIR_RE.exec('10-01A-auth')?.[1], '10-01A'); assert.strictEqual(validate.canonicalPlanStem('10-01A-auth-setup'), '10-01'); assert.strictEqual( coreUtils.extractCanonicalPlanId('10-01A-auth-setup-PLAN.md'), '10-01A', ); assert.strictEqual(phaseId.extractPhaseToken('10-01.2-auth'), '10-01.2'); assert.strictEqual( phaseId.phaseTokenMatches('10-01.2-auth', phaseId.normalizePhaseName('10')), false, ); }); test('#2528: digit-plus-letter slug words preserve owner/regex parity', () => { fc.assert( fc.property( fc.integer({ min: 1, max: 99 }), fc.integer({ min: 10, max: 99 }), fc.string({ unit: fc.constantFrom(...'abcdefghijklmnopqrstuvwxyz'), minLength: 1, maxLength: 8, }), (phase, digits, letters) => { const expected = String(phase).padStart(2, '0'); const dir = `${expected}-${digits}${letters}-growth`; assert.strictEqual(phaseId.extractPhaseToken(dir), expected); assert.strictEqual( validate.PHASE_TOKEN_FROM_DIR_RE.exec(dir)?.[1], expected, `PHASE_TOKEN_FROM_DIR_RE diverged from extractPhaseToken for ${dir}`, ); }, ), ); }); test('property: prefixed deep tokens stay identical across imperative and regex readers', () => { const prefixArb = fc.constantFrom('', 'CK-', 'M1-', 'v2-', 'APP1-', 'APP_1-', 'phase-'); const phaseArb = fc.integer({ min: 0, max: 999 }).map(String); const continuationArb = fc.array( fc.integer({ min: 0, max: 99 }).map((n) => String(n).padStart(2, '0')), { minLength: 2, maxLength: 5 }, ); fc.assert( fc.property(prefixArb, phaseArb, continuationArb, (prefix, phase, continuations) => { const token = `${prefix}${phase}-${continuations.join('-')}`; const dir = `${token}-feature`; assert.strictEqual( validate.PHASE_TOKEN_FROM_DIR_RE.exec(dir)?.[1], phaseId.extractPhaseToken(dir), `prefixed/deep grammar diverged for ${dir}`, ); assert.strictEqual(phaseId.extractPhaseToken(dir), token); }), ); }); // #2528 re-review: the boundary the earlier revision of this fix had no // coverage for. `minLength: 1` is the case that matters — EXACTLY one genuine // sub-phase level followed by a slug that starts with a bare digit ("10-24-7-zip", // sub-phase 10.24 named "7-Zip Integration"). A tokenizer that treats a // one-digit terminator as evidence that the preceding continuation was a slug // word cannot see the difference between that and "10-24-7-autonomy" (phase 10 // named "24/7 Autonomy") — so it silently makes the well-formed sub-phase // unresolvable by its own id. The token therefore keeps EVERY absorbed // continuation regardless of what terminates the scan, at one level and at five. test('property: a digit-leading slug never shortens the absorbed continuation run', () => { const prefixArb = fc.constantFrom('', 'CK-', 'M1-', 'v2-', 'APP1-', 'APP_1-', 'phase-'); const phaseArb = fc.integer({ min: 0, max: 999 }).map(String); const continuationArb = fc.array( fc.integer({ min: 0, max: 99 }).map((n) => String(n).padStart(2, '0')), { minLength: 1, maxLength: 5 }, ); const terminatorArb = fc.integer({ min: 0, max: 9 }).map(String); fc.assert( fc.property( prefixArb, phaseArb, continuationArb, terminatorArb, (prefix, phase, continuations, terminator) => { const expected = `${prefix}${phase}-${continuations.join('-')}`; const dir = `${prefix}${phase}-${continuations.join('-')}-${terminator}-feature`; assert.strictEqual(phaseId.extractPhaseToken(dir), expected); assert.strictEqual( validate.PHASE_TOKEN_FROM_DIR_RE.exec(dir)?.[1], expected, `deep continuation grammar diverged for ${dir}`, ); // …and the directory stays reachable by that very token. assert.deepStrictEqual( phaseId.matchPhaseDirs([dir], expected).matches, [dir], `${dir} became unresolvable by its own id ${expected}`, ); }, ), ); }); }); // 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; }); } // #2528 RESIDUAL, pinned rather than left to prose. This filter is one of the // query-less surfaces: it compares a directory's own token against the roadmap // set, and the #2232 contract above already fixes what happens when that token // is an absorbed continuation the roadmap does not list — the dir is excluded. // A phase named "24/7 Autonomy" produces exactly that shape, so it is excluded // for the same reason and by the same rule as the width-2 case above, and // identically to the "05-80-20-cleanup" shape this fix documents. Widening the // filter would contradict the #2232 pin one screen up; the bare-integer // fallback lives where a query exists (matchPhaseDirs), and every phase-verb // path that takes a phase number resolves this directory correctly — see // tests/phase-resolution-parity.test.cjs. test('#2528 residual: a digit-leading phase NAME is scoped by its literal token', () => { writeProject([ '## v1.0: Current', '### Phase 2-01: Alpha', '**Goal:** force hyphenated mode', '', '### Phase 10: Autonomy', '**Goal:** the 24/7 name', ]); const filter = getMilestonePhaseFilter(tmpDir); // Token "10-24" — not a roadmap id, so out of milestone scope… assert.strictEqual(filter('10-24-7-autonomy'), false); // …exactly like the other member of the family, and unlike the plain form. assert.strictEqual(filter('05-80-20-cleanup'), false); assert.strictEqual(filter('10-autonomy'), true); cleanup(tmpDir); tmpDir = null; }); }); // ─── #612: the DELIBERATE divergence, pinned ──────────────────────────────── // Surface 6 consumes the owner at the slug-adjacent position (above), but is // deliberately WIDER at the other positions. That is a divergence, so per the // Generative Fix Divergence rule it gets pinned here rather than left to a // comment: if someone later "unifies" the bracket run onto the exactly-2 cap, // or re-widens the slug-adjacent position back to `\d+`, one of these fails and // points them at the rationale in phase-id.cts. // // The policy is stated independently of the regex: bracket's non-slug-adjacent // positions are DELIMITER-disambiguated (a grammar-required field separator; a // dot no slug can contain), not heuristically recognized, so they carry the // canonical width toDir emits — while #2232's cap defends the one position that // sits against a slug. // ── Surface 7: the heading read agrees with the dir read about WHICH phase ── describe('#2761 surface 7 — heading read and dir read name the same phase', () => { for (const { seg, absorbed, note } of WIDTH_CORPUS) { test(`width ${seg.length} (${JSON.stringify(seg)}): absorbed=${absorbed} — ${note}`, () => { const owner = phaseId.isPhaseContinuationSegment(seg); const headingToken = `14-${seg}`; const dir = `14-${seg}-photos-performance`; // A heading token carries no slug, so its grammar is the letter-tolerant // one, NOT the continuation grammar. What must hold is that the two agree // about which phase a `MM-` pair names — otherwise a phase named in // the ROADMAP resolves to the wrong directory, or to none. const { roadmapPhases } = validate.buildRoadmapPhaseVariants(`### Phase ${headingToken}: Photos`); assert.ok(roadmapPhases.has(headingToken), `heading token dropped: ${headingToken}`); assert.strictEqual( phaseId.extractPhaseToken(dir) === headingToken, owner, `heading/dir disagreement on ${JSON.stringify(headingToken)}`, ); // The BRACKET spelling of the same heading must yield the same phase set: // the widened intro changes which SPELLINGS are seen, never which TOKEN a // heading yields. const bracket = validate.buildRoadmapPhaseVariants( `### [MSD.01] ${headingToken}: Photos`, 'bracket'); assert.deepEqual([...bracket.roadmapPhases], [...roadmapPhases], 'bracket and legacy spellings of one heading must yield the same phase set'); }); } }); // ── Surface 8: the two bracket DIRECTORY readers, both directions ─────────── // `validate health` resolves a bracket phase directory twice in one run: W005 / // W006 / W007 through validate.phaseTokenFromDir, and the W021 // milestone-complete check through phaseTokenMatches -> extractPhaseToken. A // disagreement makes the run contradict itself — W007 resolving a directory that // W021 simultaneously reports as an unstarted phase. describe('#2761 surface 8 — one bracket directory token rule, two call paths', () => { const ACCEPTED = [ 'MSD.02-05-feature', 'MSD.02-05.03-feature', 'MSD.02-05', 'CK.01-12.04-feature', 'MSD_X2.100-05-feature', 'MSD.02-05-2026-photos', 'MSD.999-01-icebox', // DISCLOSED: string-indistinguishable from a padded bracket dir, so a repo // that has opted into bracket reads it as one. Listed here because the point // of this surface is that BOTH readers do the same thing with it. 'P0.34-56-name', ]; // Shapes outside the emit grammar (CANONICAL_NUMERIC_RE is digits-only with at // most one sub-phase), plus legacy and ambiguous forms. const REJECTED = [ 'MSD.02-12A-hotfix', 'MSD.02-05.03.07-x', 'MSD.2-05-x', 'MSD.02', '02-01-setup', 'MSD-02-01-setup', 'not-a-phase', 'P0.3-2-tenant', 'P0.16-gate', ]; for (const dir of ACCEPTED) { test(`accepted: ${dir} — both readers agree`, () => { assert.ok(validate.BRACKET_PHASE_DIR_RE.test(dir), 'precondition: recognized'); assert.strictEqual( validate.phaseTokenFromDir(dir, 'bracket'), phaseId.extractPhaseToken(dir, 'bracket'), ); }); } for (const dir of REJECTED) { test(`rejected: ${dir} — the owner does not bracket-resolve it either`, () => { assert.strictEqual(validate.BRACKET_PHASE_DIR_RE.test(dir), false, 'precondition: rejected'); // The half that was previously unpinned: agreement on REJECTED input. The // owner must fall through to its legacy reading rather than produce a // bracket token the recognizer refuses. assert.strictEqual( phaseId.extractPhaseToken(dir, 'bracket'), phaseId.extractPhaseToken(dir), 'owner bracket-resolved a directory the recognizer rejects', ); }); } }); describe('#612 bracket divergence — wider only where the delimiter disambiguates', () => { const tokenOf = (s) => s.match(new RegExp(phaseId.BRACKET_PHASE_TOKEN_SOURCE))?.[0]; test('the #2232 repro cannot reopen on the bracket path', () => { // The review's scenario: roadmap phase "2026 Photos & Performance" at // phase 14 → slug leads with a year. The token is the phase, not the year. assert.strictEqual(tokenOf('01-14-2026-photos-performance'), '01-14'); assert.strictEqual(tokenOf('01-14.03-2026-photos-performance'), '01-14.03'); }); test('3+-digit phase and sub-phase — widths toDir emits — stay recognized', () => { // Both are rejected by a verbatim exactly-2 cap; both are canonical per // CANONICAL_NUMERIC_RE, so under-collecting them would break the read path // against ids the emit path produces. assert.strictEqual(tokenOf('02-105-slug'), '02-105', '3-digit phase (dash-1)'); assert.strictEqual(tokenOf('05.100'), '05.100', '3-digit sub-phase (dot)'); assert.strictEqual(tokenOf('01-2026-photos'), '01-2026', 'a 4-digit phase is unambiguous at dash-1'); }); test('the divergence is bounded: a PLAN >=100 is out of the grammar (#2232 policy verbatim)', () => { // The accepted trade-off. Stated as a test so it is a decision on record, // not an accident: the slug-adjacent position cannot be widened without // reopening the year collision. assert.strictEqual(tokenOf('02-05-100'), '02-05', 'a 3-digit plan is not absorbed'); assert.strictEqual(tokenOf('02-05-01'), '02-05-01', 'a canonical 2-digit plan is absorbed'); }); test('an over-padded field is not canonical, so it is not collected', () => { // `014` matches neither canonical branch (leading zero + 3 digits), which is // what parsePhaseId rejects too — the read side under-collects rather than // inventing a field the parser would refuse. assert.strictEqual(tokenOf('01-014-slug'), '01'); }); // The `(?=-|$)` terminator this PR adds is what keeps surface 6 in step with // the others: without it the run would stop mid-field and report a prefix. // It also costs something, and the cost is pinned rather than left implicit — // a token followed by any OTHER delimiter no longer tokenizes at all. No // production caller reads this constant (its consumers are this file and // adr-612-bracket-grammar.test.cjs), so the loss is confined to the display // shapes below. Anyone restoring them must widen the terminator class // deliberately, not by deleting the lookahead. test('the terminator is dash-or-end, and display punctuation is not in it', () => { assert.strictEqual(tokenOf('05.03-slug'), '05.03', 'dash terminates'); assert.strictEqual(tokenOf('05.03'), '05.03', 'end-of-string terminates'); assert.strictEqual(tokenOf('05.03: Title'), undefined, 'a colon does not'); assert.strictEqual(tokenOf('12A: X'), undefined, 'nor after a letter suffix'); assert.strictEqual(tokenOf('05.03]'), undefined, 'nor a closing bracket'); }); }); // ─── #2528: two more grammar edges this PR moves, pinned ──────────────────── // Neither has a production consumer today, so neither can break a caller — they // are pinned so the change is a decision on record rather than a silent drift a // future reader has to reconstruct from a diff. describe('#2528 grammar edges without production consumers', () => { test('canonicalPlanStem only strips an UPPERCASE plan suffix', () => { // The `i` flag is gone and the lookahead is uppercase-only, so a lowercase // suffix — and a dotted sub-plan — now fall through unchanged instead of // being reduced to the stem. Uppercase, the shape `toDir` actually emits, // is unaffected. If a production caller ever appears, this is the line to // revisit. assert.strictEqual(validate.canonicalPlanStem('10-01A-auth'), '10-01', 'uppercase: stripped'); assert.strictEqual(validate.canonicalPlanStem('10-01a-auth'), '10-01a-auth', 'lowercase: unchanged'); assert.strictEqual(validate.canonicalPlanStem('10-01.2-auth'), '10-01.2-auth', 'dotted sub-plan: unchanged'); }); test('a letter-suffixed phase with a sub-phase windows like its plain-numeric twin', () => { // Before this PR `12A-01-foo` yielded `12A` while `12-01-foo` yielded // `12-01` — the letter suffix was the only reason a sub-phase directory // folded into its PARENT phase's milestone. That asymmetry is the defect; // the two shapes now agree. The visible consequence is that a milestone // declaring `12A` no longer absorbs `12A-01-foo`, exactly as one declaring // `12` has never absorbed `12-01-foo`. const tmpDir = createTempProject(); try { 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'), [ '## v1.0: Current', '### Phase 2-01: Alpha', '**Goal:** puts the filter in hyphenated mode', '', '### Phase 12A: Letter Suffixed', '**Goal:** the shape under test', ].join('\n')); const filter = getMilestonePhaseFilter(tmpDir); assert.strictEqual(filter('12-01-foo'), false, 'plain numeric: unchanged'); assert.strictEqual(filter('12A-01-foo'), false, 'letter-suffixed: now agrees'); assert.strictEqual(filter('12A-foo'), true, 'the phase itself still windows'); } finally { cleanup(tmpDir); } }); }); // ─── #2761 M3: the BRACKET MILESTONE INTRO grammar — one owner, two shapes ── // // trek-e's finding: this grammar was re-typed in roadmap-parser (the // bracket-fallback selector), state (`isMilestoneBounded`) and verify // (`checkBracketCoherence`), which #2761's own gate forbids, and // `check:phase-id-drift` could not see it. All three now consume // `phase-id.cjs`, so the LITERAL divergence is closed and // tests/phase-id-drift-guard.test.cjs proves the guard now fails on each // shipped copy. // // This is the behavioral half: the owner exports the intro in TWO shapes — // PINNED to one milestone, and CAPTURING over any — and nothing structurally // forces them to agree about what a milestone intro IS. They are two doors onto // one rule, so a corpus drives both and requires the same verdict. Widening // either one alone (the `0*N` acceptance that reopened the unscoped-milestone // defect) fails here. describe('#2761 bracket milestone intro — the pinned and capturing shapes agree', () => { // Stated as POLICY, independently of either regex: the canonical spelling is // exactly what toDir emits — pad2 for 0-99, no leading zero beyond that. // Anything else is malformed and scopes nothing. const INTRO_CORPUS = [ { text: '[MSD.00] Zero', milestone: 0, canonical: true, note: 'pad2 lower bound' }, { text: '[MSD.02] Foundation', milestone: 2, canonical: true, note: 'the ADR example' }, { text: '[MSD.99] Late', milestone: 99, canonical: true, note: 'pad2 upper bound' }, { text: '[MSD.100] Later', milestone: 100, canonical: true, note: '3-digit, no leading zero' }, { text: '[MSD.999] Icebox', milestone: 999, canonical: true, note: 'the backlog sentinel' }, { text: '[A_B9.02] Underscored', milestone: 2, canonical: true, note: 'the full code class' }, { text: '[msd.02] Lowercased', milestone: 2, canonical: true, note: 'readers compile /i' }, { text: '[MSD.2] Unpadded', milestone: 2, canonical: false, note: 'unpadded scopes nothing' }, { text: '[MSD.002] Overpadded', milestone: 2, canonical: false, note: 'over-padded is malformed' }, { text: '[9SD.02] Digit-led code', milestone: 2, canonical: false, note: 'a code starts with a letter' }, { text: '[MSD-02] Hyphenated', milestone: 2, canonical: false, note: 'the separator is a dot' }, { text: '[MSD.] Empty milestone', milestone: 0, canonical: false, note: 'the milestone field is required' }, ]; const capturing = new RegExp(`^${phaseId.BRACKET_MILESTONE_INTRO_CAPTURING_SRC}`, 'i'); for (const { text, milestone, canonical, note } of INTRO_CORPUS) { test(`${JSON.stringify(text)}: both shapes say canonical=${canonical} — ${note}`, () => { const pinned = new RegExp(`^${phaseId.bracketMilestoneIntroSrcFor(milestone)}`, 'i'); const byPinned = pinned.test(text); const byCapturing = capturing.test(text); assert.strictEqual( byPinned, byCapturing, `the pinned and capturing shapes disagreed on ${JSON.stringify(text)} — ` + 'they are two doors onto one rule and must never diverge', ); assert.strictEqual(byPinned, canonical, `verdict must match the locked policy — ${note}`); }); } test('the capturing shape reports the milestone the pinned shape was built for', () => { // Beyond agreeing on accept/reject: when both accept, they must be talking // about the SAME milestone. A capture-group or padding slip shows up here. for (const { text, milestone, canonical } of INTRO_CORPUS) { if (!canonical) continue; assert.strictEqual( parseInt(text.match(capturing)[1], 10), milestone, `${text}: the captured milestone must be ${milestone}`, ); } }); test('a milestone intro is not confused with a phase id sharing its code', () => { // `[MSD.02]` is a MILESTONE intro; `MSD.02-01` is a phase DIRECTORY. Both // are built from BRACKET_PROJECT_CODE_SRC, so this pins that sharing one // code class does not collapse the two readings. const pinned = new RegExp(`^${phaseId.bracketMilestoneIntroSrcFor(2)}`, 'i'); assert.ok(!pinned.test('MSD.02-01-setup'), 'a directory name is not a bracket intro'); assert.ok(pinned.test('[MSD.02] 01: Setup'), 'a bracket PHASE heading still carries the intro'); assert.strictEqual(phaseId.bracketQualifiedKey('MSD.02-01', 'bracket'), 'MSD.2-1'); }); });