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.
623 lines
31 KiB
JavaScript
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');
|
|
});
|
|
});
|