Files
msd-core/tests/continuation-grammar-parity.test.cjs
Jakub Zych a9a7a328e6 refactor: hard-fork GSD -> MSD (Make Software Done)
Mechanical rename produced by scripts/msd-rename.cjs: gsd/Gsd/GSD -> msd/Msd/MSD
across contents and paths, upstream package/repo coordinates -> @golem15/msd-core
and golem15com/msd-core. Deep links into upstream history, sibling upstream
packages, the GSD-2 import feature, CHANGELOG.md and .changeset/ are kept as-is.

Hand edits on top: MSD block-letter banner and logos, LICENSE copyright line,
package/plugin identity, regenerated lockfile, install-tree fixtures, derived
registries and benchmark baseline; migration checksum baseline re-locked
(MSD keeps its own install state, so no install had applied the old sums);
sort-order and regex-escaped expectations in tests adjusted.
2026-10-06 01:47:40 +02:00

623 lines
31 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, 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-<seg>-slug…`
// puts <seg> 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-<digit>-…` 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-<seg>` 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');
});
});