* test(#3639): pin bracket sentinel recognition in disk-side guards * fix(#3639): dir-aware sentinel recognition for the disk-side guards * chore(#3639): add changeset * fix(#3639): disclose the digit-continuation residual, join phases-clear, load-bearing over-suppression guard * test(#3639): match the token form W007 reports * chore(#3639): backfill changeset pr number --------- Co-authored-by: sim <sim@local>
837 lines
46 KiB
JavaScript
837 lines
46 KiB
JavaScript
'use strict';
|
||
|
||
/**
|
||
* PR-1 (#2249 / epic #612) — bracket phase-ID core grammar.
|
||
*
|
||
* Ratified contract: docs/adr/612-bracket-phase-id-convention.md, Decisions 1/4/6.
|
||
* One pure round-trippable model added INSIDE src/phase-id.cts (the ADR-2121
|
||
* single canonical owner): parsePhaseId / renderPhaseId / toDir sharing one
|
||
* PhaseId shape, alongside the existing M-NN helpers. READING-B: the milestone
|
||
* comes from the `[PROJECT.MM]` / `{CODE}.{MM}-` prefix, never the phase-token
|
||
* leading integer.
|
||
*
|
||
* Sibling of tests/adr-612-collision-characterization.test.cjs (the PR-0 anchor):
|
||
* that file locks the CURRENT M-NN collapse (`normalizePhaseName('2-01.02-01')
|
||
* === '02'`); this file locks the bracket grammar that resolves the same
|
||
* `(milestone, phase, subphase, plan)` identity to exactly one tuple on the
|
||
* gated bracket path.
|
||
*
|
||
* All assertions are BEHAVIORAL: call the exported function, assert its typed
|
||
* output. No source-grep. The example tables mirror ADR §3; the two fast-check
|
||
* blocks are the generative round-trip / disk-display bijection properties the
|
||
* #612 approval requires (ADR Decision 4).
|
||
*/
|
||
|
||
const { test, describe } = require('node:test');
|
||
const assert = require('node:assert/strict');
|
||
const fc = require('fast-check');
|
||
|
||
const core = require('../gsd-core/bin/lib/phase-id.cjs');
|
||
|
||
const p2 = (n) => String(n).padStart(2, '0');
|
||
|
||
// ─── ADR §3 round-trip example table (doc-parity) ───────────────────────────
|
||
const TABLE = [
|
||
{ display: '[GSD.02] 05.03-01', dir: 'GSD.02-05.03-feature' },
|
||
{ display: '[GSD.02] 05', dir: 'GSD.02-05-feature' },
|
||
{ display: '[CK.01] 12.04', dir: 'CK.01-12.04-feature' },
|
||
];
|
||
|
||
describe('bracket grammar: emit/render round-trip pair (ADR §3)', () => {
|
||
test('render(parse(display)) === display', () => {
|
||
for (const { display } of TABLE) {
|
||
assert.strictEqual(core.renderPhaseId(core.parsePhaseId(display)), display);
|
||
}
|
||
});
|
||
|
||
test('toDir(parse(display), slug) === dir', () => {
|
||
for (const { display, dir } of TABLE) {
|
||
assert.strictEqual(core.toDir(core.parsePhaseId(display), 'feature'), dir);
|
||
}
|
||
});
|
||
|
||
test('parse is idempotent across surfaces: parse(dir) and parse(display) agree on the tuple', () => {
|
||
for (const { display, dir } of TABLE) {
|
||
const a = core.parsePhaseId(display);
|
||
const b = core.parsePhaseId(dir);
|
||
assert.strictEqual(
|
||
`${b.project}.${b.milestone}-${b.phase}`,
|
||
`${a.project}.${a.milestone}-${a.phase}`,
|
||
);
|
||
}
|
||
});
|
||
});
|
||
|
||
// ─── ADR §1 collision-test acceptance (the full 5-tuple, post-fix) ───────────
|
||
describe('bracket grammar: full 5-tuple parse (ADR §1 acceptance)', () => {
|
||
test('parsePhaseId resolves a complete milestone/phase/subphase/plan identity', () => {
|
||
const parsed = core.parsePhaseId('GSD.02-05.03-01');
|
||
assert.deepStrictEqual(parsed, {
|
||
project: 'GSD',
|
||
milestone: '02', // from the bracket/dir prefix (READING-B), not the leading int
|
||
phase: '05',
|
||
subphase: '03',
|
||
plan: '01',
|
||
});
|
||
assert.strictEqual(core.renderPhaseId(parsed), '[GSD.02] 05.03-01');
|
||
assert.strictEqual(core.toDir(parsed, 'some-feature'), 'GSD.02-05.03-some-feature');
|
||
});
|
||
|
||
test('a plan without a subphase round-trips (display -[LL] with no .SS)', () => {
|
||
const id = core.parsePhaseId('[GSD.02] 05-01');
|
||
assert.strictEqual(id.subphase, undefined);
|
||
assert.strictEqual(id.plan, '01');
|
||
assert.strictEqual(core.renderPhaseId(id), '[GSD.02] 05-01');
|
||
});
|
||
|
||
test('a bare dir/token arg with no trailing segment parses (contract #2 "bare bracket arg")', () => {
|
||
// `GSD.02-05` — the on-disk/CLI token with neither sub-phase, plan, nor slug.
|
||
const id = core.parsePhaseId('GSD.02-05');
|
||
assert.deepStrictEqual(id, { project: 'GSD', milestone: '02', phase: '05' });
|
||
assert.strictEqual(core.renderPhaseId(id), '[GSD.02] 05');
|
||
});
|
||
});
|
||
|
||
// ─── 3+-digit numeric width (re-review Minor 1) ─────────────────────────────
|
||
// The fast-check generators below (numArb) span 1–999 so the round-trip and
|
||
// disk↔display properties genuinely exercise 3+-digit tokens; this concrete
|
||
// case pins the reviewer's hand-traced example deterministically. A 3+-digit
|
||
// milestone must survive pad2() un-truncated, carry no leading zero, and match
|
||
// CANONICAL_NUMERIC_RE's `[1-9]\d{2,}` branch inside toDir.
|
||
describe('bracket grammar: 3+-digit milestone width (re-review Minor 1)', () => {
|
||
test("'[GSD.100] 05' round-trips, renders, and toDirs without truncation", () => {
|
||
const id = core.parsePhaseId('[GSD.100] 05');
|
||
assert.deepStrictEqual(id, { project: 'GSD', milestone: '100', phase: '05' });
|
||
assert.strictEqual(core.renderPhaseId(id), '[GSD.100] 05');
|
||
assert.strictEqual(core.toDir(id, 'feature'), 'GSD.100-05-feature');
|
||
});
|
||
});
|
||
|
||
// ─── Strict-reject: parsePhaseId rejects non-canonical input (review B1) ────
|
||
// render(parse(x)) === x must hold for every WELL-FORMED display string
|
||
// (ADR-612 Decision 4). A permissive match that accepts unpadded numbers or
|
||
// multi-space separators falsifies that contract the moment the accepted
|
||
// string differs from what renderPhaseId would emit for the same tuple —
|
||
// parsePhaseId must reject rather than silently normalize.
|
||
describe('bracket grammar: parsePhaseId rejects non-canonical input (review B1)', () => {
|
||
test('unpadded / over-padded / multi-space display forms are rejected', () => {
|
||
assert.throws(() => core.parsePhaseId('[GSD.5] 5'), /parsePhaseId: not canonical/);
|
||
assert.throws(() => core.parsePhaseId('[GSD.005] 05'), /parsePhaseId: not canonical/);
|
||
assert.throws(() => core.parsePhaseId('[GSD.02] 05'), /parsePhaseId: not canonical/);
|
||
});
|
||
|
||
test('leading/trailing whitespace is rejected by the anchors (no .trim() tolerance)', () => {
|
||
assert.throws(() => core.parsePhaseId(' [GSD.02] 05'), /parsePhaseId: not a bracket phase id/);
|
||
assert.throws(() => core.parsePhaseId('[GSD.02] 05 '), /parsePhaseId: not a bracket phase id/);
|
||
});
|
||
|
||
test('unpadded dir/token forms are rejected', () => {
|
||
assert.throws(() => core.parsePhaseId('GSD.2-5'), /parsePhaseId: not canonical/);
|
||
assert.throws(() => core.parsePhaseId('GSD.02-05-1'), /parsePhaseId: not canonical/);
|
||
});
|
||
|
||
test('a canonical form still parses cleanly (no false-positive rejection)', () => {
|
||
assert.deepStrictEqual(core.parsePhaseId('[GSD.02] 05'), { project: 'GSD', milestone: '02', phase: '05' });
|
||
assert.deepStrictEqual(core.parsePhaseId('GSD.02-05'), { project: 'GSD', milestone: '02', phase: '05' });
|
||
});
|
||
});
|
||
|
||
// ─── Bare/ambiguous tokens are rejected — only the bracket parser throws ─────
|
||
describe('bracket grammar: parsePhaseId rejects ambiguous non-bracket tokens (ADR conservative default #8a)', () => {
|
||
test("bare '02-04' (the cross-subsystem ambiguity) is rejected by the bracket parser", () => {
|
||
// '02-04' has no bracket and no {CODE}.{MM}- dot-prefix, so it matches
|
||
// neither branch — the new bracket parser throws rather than guess a tuple.
|
||
// The rejection lives ONLY here; normalizePhaseName still returns it intact
|
||
// (see adr-612-collision-characterization.test.cjs), so no existing path
|
||
// gains a throw.
|
||
assert.throws(() => core.parsePhaseId('02-04'), /not a bracket phase id/);
|
||
// The legacy reader is untouched by this rejection.
|
||
assert.strictEqual(core.normalizePhaseName('02-04'), '02-04');
|
||
});
|
||
|
||
test('other non-bracket forms are rejected', () => {
|
||
assert.throws(() => core.parsePhaseId('05'), /not a bracket phase id/);
|
||
assert.throws(() => core.parsePhaseId('2-01'), /not a bracket phase id/);
|
||
assert.throws(() => core.parsePhaseId(''), /not a bracket phase id/);
|
||
});
|
||
});
|
||
|
||
// ─── READING-B milestone source (gated on 'bracket'; legacy paths intact) ────
|
||
describe('bracket grammar: getMilestoneFromPhaseId READING-B', () => {
|
||
test("milestone comes from the [PROJECT.MM] prefix, not the phase-token leading int", () => {
|
||
// 'GSD.02-05.03' → milestone 02 (v2.0), NOT phase 05 (v5.0). ADR Decision 6.
|
||
assert.strictEqual(core.getMilestoneFromPhaseId('GSD.02-05.03', 'bracket'), 'v2.0');
|
||
});
|
||
|
||
test('sentinel milestone ranges (0.x / 999.x) resolve to null', () => {
|
||
assert.strictEqual(core.getMilestoneFromPhaseId('GSD.00-01', 'bracket'), null);
|
||
assert.strictEqual(core.getMilestoneFromPhaseId('GSD.999-01', 'bracket'), null);
|
||
});
|
||
|
||
test('a bracket-convention call on a non-bracket string returns null (no throw)', () => {
|
||
// Negative branch: convention === 'bracket' but the string has no
|
||
// {CODE}.{MM} prefix → null, not an exception.
|
||
assert.strictEqual(core.getMilestoneFromPhaseId('2-01', 'bracket'), null);
|
||
});
|
||
|
||
test("legacy M-NN path is byte-unchanged when convention is absent / not 'bracket'", () => {
|
||
// READING-A leading-int rule, current behavior — must not regress.
|
||
assert.strictEqual(core.getMilestoneFromPhaseId('2-01'), 'v2.0');
|
||
assert.strictEqual(core.getMilestoneFromPhaseId('CK-2-01'), 'v2.0');
|
||
assert.strictEqual(core.getMilestoneFromPhaseId('2-01', 'milestone-prefixed'), 'v2.0');
|
||
// Sentinel + non-milestone forms preserved on the legacy path.
|
||
assert.strictEqual(core.getMilestoneFromPhaseId('0-01'), null);
|
||
assert.strictEqual(core.getMilestoneFromPhaseId('05'), null);
|
||
});
|
||
});
|
||
|
||
// ─── extractPhaseToken: bracket dir form (gated on convention) ───────────────
|
||
// The bracket dir `{CODE}.{MM}-{PP}` is string-indistinguishable from the legacy
|
||
// #2043 letter-prefixed-decimal family (`P0.3-2`) when the code ends in a digit,
|
||
// so the new bracket reader fires ONLY under an explicit `convention` arg — the
|
||
// same gating decision as getMilestoneFromPhaseId's READING-B. Convention-less
|
||
// callers keep the legacy reading byte-identical (pinned across the whole
|
||
// numeric-tail family in tests/phase-id.test.cjs).
|
||
describe('bracket grammar: extractPhaseToken (bracket path gated on convention)', () => {
|
||
test("extracts the phase token PP[.SS] from a bracket dir under convention 'bracket'", () => {
|
||
assert.strictEqual(core.extractPhaseToken('CK.02-02.01-slug', 'bracket'), '02.01');
|
||
assert.strictEqual(core.extractPhaseToken('GSD.02-05-feature', 'bracket'), '05');
|
||
assert.strictEqual(core.extractPhaseToken('GSD.02-05.03-01', 'bracket'), '05.03'); // plan is not part of the token
|
||
});
|
||
|
||
test('the bracket path is OFF by default: a convention-less call keeps the legacy reading', () => {
|
||
// Without the signal the bracket branch is skipped; `GSD.02` matches neither
|
||
// the leading-int nor the letter-prefix legacy rule, so the whole dir name is
|
||
// returned unchanged (prior behaviour) rather than parsed as a bracket dir.
|
||
assert.strictEqual(core.extractPhaseToken('GSD.02-05.03-01'), 'GSD.02-05.03-01');
|
||
});
|
||
|
||
test('legacy code-prefixed dirs still extract as before (no regression)', () => {
|
||
assert.strictEqual(core.extractPhaseToken('CK-01-foo'), 'CK-01');
|
||
assert.strictEqual(core.extractPhaseToken('02-04-some-slug'), '02-04');
|
||
});
|
||
});
|
||
|
||
// ─── comparator: existing comparePhaseNum orders extracted bracket tokens ─────
|
||
// ADR §Phases PR-1 row names "comparator". No NEW comparator code is required:
|
||
// bracket phase tokens flow through extractPhaseToken (which yields the
|
||
// dot-decimal `PP[.SS]` form), and comparePhaseNum's existing numeric+decimal
|
||
// branch already orders that form. Cross-MILESTONE ordering (a milestone-
|
||
// qualified key) is a resolution/lookup concern deferred to PR-2
|
||
// (bracketQualifiedKey), not core grammar — the last case below shows the
|
||
// boundary: two tokens from different milestones compare equal because the
|
||
// extracted token is milestone-blind by construction.
|
||
describe('bracket grammar: comparePhaseNum orders extracted bracket phase tokens', () => {
|
||
const tok = (d) => core.extractPhaseToken(d, 'bracket');
|
||
test('phase order: 05 < 12', () => {
|
||
assert.ok(core.comparePhaseNum(tok('GSD.02-05'), tok('GSD.02-12')) < 0);
|
||
assert.ok(core.comparePhaseNum(tok('GSD.02-12'), tok('GSD.02-05')) > 0);
|
||
});
|
||
|
||
test('sub-phase order: 05.03 < 05.10, and a bare phase sorts before its sub-phases', () => {
|
||
assert.ok(core.comparePhaseNum(tok('GSD.02-05.03'), tok('GSD.02-05.10')) < 0);
|
||
assert.ok(core.comparePhaseNum(tok('GSD.02-05'), tok('GSD.02-05.03')) < 0);
|
||
});
|
||
|
||
test('the extracted token is milestone-blind: same PP[.SS] across milestones compares equal (PR-2 owns qualified ordering)', () => {
|
||
assert.strictEqual(core.comparePhaseNum(tok('GSD.02-05.03'), tok('CK.09-05.03')), 0);
|
||
});
|
||
});
|
||
|
||
// ─── sentinel guard: isSentinelPhaseId / SENTINEL_RANGES ────────────────────
|
||
describe('bracket grammar: sentinel guard', () => {
|
||
test('SENTINEL_RANGES are the {0, 999} milestone ranges', () => {
|
||
assert.deepStrictEqual([...core.SENTINEL_RANGES], [0, 999]);
|
||
});
|
||
|
||
test('isSentinelPhaseId is true for milestone 0 / 999 across forms', () => {
|
||
// Bracket forms: milestone in the `{CODE}.{MM}` prefix (gated on convention).
|
||
assert.strictEqual(core.isSentinelPhaseId('GSD.999-01', 'bracket'), true);
|
||
assert.strictEqual(core.isSentinelPhaseId('GSD.00-01', 'bracket'), true);
|
||
// Legacy/bare leading-int forms need no convention.
|
||
assert.strictEqual(core.isSentinelPhaseId('999.1'), true);
|
||
assert.strictEqual(core.isSentinelPhaseId('0.1'), true);
|
||
});
|
||
|
||
test('isSentinelPhaseId is false for ordinary milestones and for tokens with no leading integer', () => {
|
||
assert.strictEqual(core.isSentinelPhaseId('GSD.02-05', 'bracket'), false);
|
||
assert.strictEqual(core.isSentinelPhaseId('2-01'), false);
|
||
// Negative branch: a string with no leading integer at all → false.
|
||
assert.strictEqual(core.isSentinelPhaseId('feature-branch'), false);
|
||
});
|
||
|
||
test('#3639: isSentinelPhaseDir reads the MILESTONE off a bracket dir name, convention-less', () => {
|
||
// The disk-side guards see raw DIRECTORY names and do not know the repo's
|
||
// convention. isSentinelPhaseDir recognizes the full bracket dir shape
|
||
// (code prefix, dot, MILESTONE digits, hyphen, PHASE digits) so sentinel
|
||
// milestone dirs are recognized without a convention argument.
|
||
assert.strictEqual(core.isSentinelPhaseDir('GSD.999-07-icebox'), true);
|
||
assert.strictEqual(core.isSentinelPhaseDir('GSD.00-01-backlog'), true);
|
||
assert.strictEqual(core.isSentinelPhaseDir('GSD.000-01-padded'), true);
|
||
// Ordinary bracket milestones: not sentinels.
|
||
assert.strictEqual(core.isSentinelPhaseDir('GSD.03-01-auth'), false);
|
||
assert.strictEqual(core.isSentinelPhaseDir('GSD.998-01-near-icebox'), false);
|
||
// Legacy sentinel dirs keep their recognition through the same helper.
|
||
assert.strictEqual(core.isSentinelPhaseDir('999-interim'), true);
|
||
assert.strictEqual(core.isSentinelPhaseDir('0-drafts'), true);
|
||
assert.strictEqual(core.isSentinelPhaseDir('M-999-07-icebox'), true);
|
||
});
|
||
|
||
test('#3639: isSentinelPhaseDir never re-reads #1324 letter-prefixed REAL dirs as bracket sentinels', () => {
|
||
// ADR-2121 indistinguishability: the bracket branch requires PHASE DIGITS
|
||
// after the milestone hyphen — `P0.0-foundation` (letters after the
|
||
// hyphen) never matches it, and the legacy branch already returns false
|
||
// for these (pinned in the suite above).
|
||
assert.strictEqual(core.isSentinelPhaseDir('P0.0-foundation'), false);
|
||
assert.strictEqual(core.isSentinelPhaseDir('P0.999-x'), false);
|
||
assert.strictEqual(core.isSentinelPhaseDir('P0.3-2'), false);
|
||
});
|
||
|
||
test('#3639 disclosed residual: #1324 digit-continuation dirs with a SENTINEL-valued first decimal read as bracket sentinels', () => {
|
||
// ADR-2121: `{code}.{0|999}-{digit}...` is string-indistinguishable from
|
||
// a bracket sentinel dir — no convention-free discriminator exists. The
|
||
// dir-aware recognizer takes the sentinel reading (conservative at the
|
||
// disk-guard sites: a suppressed warning, never deleted data), while the
|
||
// NON-sentinel first decimals stay ordinary. Pinned so a change to either
|
||
// reading is a deliberate act, not drift.
|
||
assert.strictEqual(core.isSentinelPhaseDir('P0.0-1-foundation'), true);
|
||
assert.strictEqual(core.isSentinelPhaseDir('P0.999-2-x'), true);
|
||
assert.strictEqual(core.isSentinelPhaseDir('P0.3-2'), false);
|
||
assert.strictEqual(core.isSentinelPhaseDir('P0.1-2-x'), false);
|
||
});
|
||
|
||
test('the bracket sentinel path is OFF by default: a convention-less #1324 dir is not a sentinel', () => {
|
||
// `P0.0-foundation` is a real #1324 letter-prefixed phase, NOT milestone-0
|
||
// sentinel. Auto-detecting the `P0`/`.0` prefix would be a false positive
|
||
// (the same root ambiguity gated in extractPhaseToken), so without the
|
||
// convention signal the legacy leading-int rule applies and returns false.
|
||
assert.strictEqual(core.isSentinelPhaseId('P0.0-foundation'), false);
|
||
assert.strictEqual(core.isSentinelPhaseId('P0.999-x'), false);
|
||
});
|
||
|
||
// SENTINEL_RANGES is the two DISCRETE values {0, 999} (an `.includes()`
|
||
// membership test), not an inclusive numeric range — so a milestone just
|
||
// inside either boundary (1, 998) and one just past the upper boundary
|
||
// (1000) are all ordinary, non-sentinel milestones. Locks that boundary
|
||
// shape across both the bracket and legacy reading paths.
|
||
test('milestones 1, 998, and 1000 are NOT sentinels (boundary probe on the {0, 999} discrete set)', () => {
|
||
assert.strictEqual(core.isSentinelPhaseId('GSD.01-01', 'bracket'), false);
|
||
assert.strictEqual(core.isSentinelPhaseId('GSD.998-01', 'bracket'), false);
|
||
assert.strictEqual(core.isSentinelPhaseId('GSD.1000-01', 'bracket'), false);
|
||
assert.strictEqual(core.isSentinelPhaseId('1-01'), false);
|
||
assert.strictEqual(core.isSentinelPhaseId('998-01'), false);
|
||
assert.strictEqual(core.isSentinelPhaseId('1000-01'), false);
|
||
});
|
||
|
||
test('getMilestoneFromPhaseId resolves 1, 998, and 1000 to real milestones (not the sentinel null)', () => {
|
||
assert.strictEqual(core.getMilestoneFromPhaseId('GSD.01-01', 'bracket'), 'v1.0');
|
||
assert.strictEqual(core.getMilestoneFromPhaseId('GSD.998-01', 'bracket'), 'v998.0');
|
||
assert.strictEqual(core.getMilestoneFromPhaseId('GSD.1000-01', 'bracket'), 'v1000.0');
|
||
assert.strictEqual(core.getMilestoneFromPhaseId('1-01'), 'v1.0');
|
||
assert.strictEqual(core.getMilestoneFromPhaseId('998-01'), 'v998.0');
|
||
assert.strictEqual(core.getMilestoneFromPhaseId('1000-01'), 'v1000.0');
|
||
});
|
||
});
|
||
|
||
// ─── slug guard: toDir never emits a path-traversal slug ────────────────────
|
||
describe('bracket grammar: toDir slug guard', () => {
|
||
test('a hostile slug is sanitized to a safe filesystem token', () => {
|
||
const id = core.parsePhaseId('[GSD.02] 05');
|
||
const dir = core.toDir(id, '../../etc/passwd');
|
||
assert.ok(!dir.includes('/'), `dir must not contain a path separator: ${dir}`);
|
||
assert.ok(!dir.includes('..'), `dir must not contain '..': ${dir}`);
|
||
assert.strictEqual(dir, 'GSD.02-05-etc-passwd');
|
||
});
|
||
|
||
test('a clean slug is preserved (round-trip unaffected)', () => {
|
||
assert.strictEqual(core.toDir(core.parsePhaseId('[GSD.02] 05.03-01'), 'feature'), 'GSD.02-05.03-feature');
|
||
});
|
||
});
|
||
|
||
// ─── toDir field validation: every interpolated PhaseId field is checked
|
||
// (review M1) ──────────────────────────────────────────────────────────────
|
||
// PhaseId is a structural (not nominal) type: nothing stops a caller from
|
||
// hand-building one and skipping parsePhaseId entirely. Only the slug was
|
||
// ever guarded, so a hand-built id with a hostile project/milestone/phase
|
||
// still reached the on-disk path unsanitized — a live traversal, not merely
|
||
// a theoretical one.
|
||
describe('bracket grammar: toDir validates every interpolated field (review M1)', () => {
|
||
test('a path-traversal project is rejected', () => {
|
||
const id = { project: '../../etc', milestone: '02', phase: '05' };
|
||
assert.throws(() => core.toDir(id, 'feature'), /toDir:.*project/);
|
||
});
|
||
|
||
test('a lowercase project is rejected (does not match the project_code grammar)', () => {
|
||
const id = { project: 'gsd', milestone: '02', phase: '05' };
|
||
assert.throws(() => core.toDir(id, 'feature'), /toDir:.*project/);
|
||
});
|
||
|
||
test('an unpadded milestone is rejected (not the canonical pad2 shape)', () => {
|
||
const id = { project: 'GSD', milestone: '5', phase: '05' };
|
||
assert.throws(() => core.toDir(id, 'feature'), /toDir:.*milestone/);
|
||
});
|
||
|
||
test('a non-numeric phase is rejected', () => {
|
||
const id = { project: 'GSD', milestone: '02', phase: '../etc' };
|
||
assert.throws(() => core.toDir(id, 'feature'), /toDir:.*phase/);
|
||
});
|
||
|
||
test('a non-numeric subphase is rejected when present', () => {
|
||
const id = { project: 'GSD', milestone: '02', phase: '05', subphase: '../etc' };
|
||
assert.throws(() => core.toDir(id, 'feature'), /toDir:.*subphase/);
|
||
});
|
||
});
|
||
|
||
// ─── toDir slug emptiness: no dangling trailing hyphen (review M2) ──────────
|
||
describe('bracket grammar: toDir rejects a slug that sanitizes to empty (review M2)', () => {
|
||
test('a slug of only punctuation is rejected rather than emitting a trailing hyphen', () => {
|
||
const id = core.parsePhaseId('[GSD.02] 05');
|
||
assert.throws(() => core.toDir(id, '!!!'), /toDir:.*slug/);
|
||
assert.throws(() => core.toDir(id, '...'), /toDir:.*slug/);
|
||
});
|
||
});
|
||
|
||
// ─── toDir all-digit slug: collides with the plan grammar (review M3) ───────
|
||
describe('bracket grammar: toDir rejects an all-digit slug (review M3)', () => {
|
||
test("a slug of '2026' is rejected — it would re-parse as a plan, not a slug", () => {
|
||
const id = core.parsePhaseId('[GSD.02] 05');
|
||
assert.throws(() => core.toDir(id, '2026'), /toDir:.*slug/);
|
||
});
|
||
});
|
||
|
||
// ─── toDir slug type: non-string slugs are rejected, not stringified (nit) ──
|
||
describe('bracket grammar: toDir rejects a non-string slug', () => {
|
||
test('undefined and null are rejected rather than coerced to the literal word', () => {
|
||
const id = core.parsePhaseId('[GSD.02] 05');
|
||
assert.throws(() => core.toDir(id, undefined), /toDir:.*slug/);
|
||
assert.throws(() => core.toDir(id, null), /toDir:.*slug/);
|
||
});
|
||
});
|
||
|
||
// ═══ m1: deterministic boundary coverage (re-review m1) ═════════════════════
|
||
// The fast-check domain (1–999, [a-z0-9] slugs) exercises the grammar's bounds
|
||
// only INCIDENTALLY. This section PINS them: the 2↔3-digit numeric-width
|
||
// boundary, a leading-zero 3-digit value, abusive slug content (null byte,
|
||
// control chars, unicode, absolute paths), whitespace-only, and very-long
|
||
// input. Every input below is a hand-written literal (never seeded from the
|
||
// renderer), and every expectation is the compiled lib's CURRENT behavior —
|
||
// this closes a proof gap, not a behavior gap. Placed against the toDir slug/
|
||
// field-validation cluster above so the absolute-path cases sit next to the
|
||
// `../../etc` traversal test they extend.
|
||
|
||
// ─── m1.1: numeric-width boundary 99/100/101 (identity grammar) ──────────────
|
||
// pad2() passes a ≥3-digit value through un-truncated and it carries no leading
|
||
// zero, so 99/100/101 are all canonical at the milestone/phase/subphase
|
||
// positions: parse→render round-trips and toDir emits them byte-for-byte
|
||
// (CANONICAL_NUMERIC_RE's `[1-9]\d{2,}` branch admits 100/101). The plan
|
||
// position is IDENTITY-symmetric too (parse/render accept all three) but is a
|
||
// filename-surface dimension only — toDir drops it from the dir string.
|
||
describe('bracket grammar: numeric-width boundary 99/100/101 (review m1)', () => {
|
||
test('milestone width 99/100/101 round-trips through display, dir, and toDir', () => {
|
||
for (const n of ['99', '100', '101']) {
|
||
assert.deepStrictEqual(core.parsePhaseId(`[GSD.${n}] 05`), { project: 'GSD', milestone: n, phase: '05' }, n);
|
||
assert.strictEqual(core.renderPhaseId(core.parsePhaseId(`[GSD.${n}] 05`)), `[GSD.${n}] 05`, n);
|
||
assert.strictEqual(core.parsePhaseId(`GSD.${n}-05`).milestone, n, n);
|
||
assert.strictEqual(core.toDir(core.parsePhaseId(`[GSD.${n}] 05`), 'feat'), `GSD.${n}-05-feat`, n);
|
||
}
|
||
});
|
||
|
||
test('phase width 99/100/101 round-trips through display, dir, and toDir', () => {
|
||
for (const n of ['99', '100', '101']) {
|
||
assert.deepStrictEqual(core.parsePhaseId(`[GSD.02] ${n}`), { project: 'GSD', milestone: '02', phase: n }, n);
|
||
assert.strictEqual(core.renderPhaseId(core.parsePhaseId(`[GSD.02] ${n}`)), `[GSD.02] ${n}`, n);
|
||
assert.strictEqual(core.parsePhaseId(`GSD.02-${n}`).phase, n, n);
|
||
assert.strictEqual(core.toDir(core.parsePhaseId(`[GSD.02] ${n}`), 'feat'), `GSD.02-${n}-feat`, n);
|
||
}
|
||
});
|
||
|
||
test('subphase width 99/100/101 round-trips through display, dir, and toDir', () => {
|
||
for (const n of ['99', '100', '101']) {
|
||
assert.strictEqual(core.parsePhaseId(`[GSD.02] 05.${n}`).subphase, n, n);
|
||
assert.strictEqual(core.renderPhaseId(core.parsePhaseId(`[GSD.02] 05.${n}`)), `[GSD.02] 05.${n}`, n);
|
||
assert.strictEqual(core.parsePhaseId(`GSD.02-05.${n}`).subphase, n, n);
|
||
assert.strictEqual(core.toDir(core.parsePhaseId(`[GSD.02] 05.${n}`), 'feat'), `GSD.02-05.${n}-feat`, n);
|
||
}
|
||
});
|
||
|
||
test('plan width 99/100/101: parse/render accept all three (identity-symmetric); toDir drops the plan', () => {
|
||
for (const n of ['99', '100', '101']) {
|
||
// Identity grammar is symmetric at the plan position — plan >=100 is
|
||
// accepted and round-trips (the plan-position cap lives ONLY in the
|
||
// read-token source, pinned in m1.2 below, NOT in parsePhaseId).
|
||
assert.strictEqual(core.parsePhaseId(`[GSD.02] 05-${n}`).plan, n, n);
|
||
assert.strictEqual(core.renderPhaseId(core.parsePhaseId(`[GSD.02] 05-${n}`)), `[GSD.02] 05-${n}`, n);
|
||
assert.strictEqual(core.parsePhaseId(`GSD.02-05-${n}`).plan, n, n);
|
||
// toDir emits the dir with NO plan segment (plan is filename-surface only),
|
||
// so all three widths collapse to the same slug-bearing dir.
|
||
assert.strictEqual(core.toDir(core.parsePhaseId(`[GSD.02] 05-${n}`), 'feat'), 'GSD.02-05-feat', n);
|
||
}
|
||
});
|
||
});
|
||
|
||
// ─── m1.2: read-token width is POSITIONAL (plan capped, others admit 3+) ──────
|
||
// BRACKET_PHASE_TOKEN_SOURCE is the PR-2 READ-tolerance source, applied after
|
||
// the `{CODE}.` prefix is stripped, so its run is MM-PP[.SS][-LL]. milestone
|
||
// (leading, unbounded), phase (dash-1) and subphase (dot) are delimiter-
|
||
// disambiguated and admit the canonical `[1-9]\d{2,}` width; the slug-adjacent
|
||
// plan (dash-2) consumes the single-owner #2232 continuation seam `\d{2}(?!\d)`,
|
||
// so a plan >=100 is DELIBERATELY out of the token grammar. This is the landed
|
||
// positional divergence (see the block at the foot of
|
||
// tests/continuation-grammar-parity.test.cjs), pinned here at the 99/100/101
|
||
// boundary — asymmetry expected, NOT symmetry with the other positions.
|
||
describe('bracket grammar: read-token width is positional at 99/100/101 (review m1)', () => {
|
||
const tok = (run) => run.match(new RegExp(`^${core.BRACKET_PHASE_TOKEN_SOURCE}`))?.[0];
|
||
|
||
test('milestone / phase / subphase absorb 99, 100, and 101', () => {
|
||
for (const n of ['99', '100', '101']) {
|
||
assert.strictEqual(tok(`${n}-05`), `${n}-05`, `milestone ${n} (leading, unbounded)`);
|
||
assert.strictEqual(tok(`02-${n}`), `02-${n}`, `phase ${n} (dash-1, delimiter-disambiguated)`);
|
||
assert.strictEqual(tok(`02-05.${n}`), `02-05.${n}`, `subphase ${n} (dot, delimiter-disambiguated)`);
|
||
}
|
||
});
|
||
|
||
test('plan (dash-2, slug-adjacent) absorbs 99 but NOT 100/101 — the #2232 cap holds', () => {
|
||
assert.strictEqual(tok('02-05-99'), '02-05-99', 'a canonical 2-digit plan is absorbed');
|
||
assert.strictEqual(tok('02-05-100'), '02-05', 'plan 100 is capped out of the token run');
|
||
assert.strictEqual(tok('02-05-101'), '02-05', 'plan 101 is capped out of the token run');
|
||
});
|
||
});
|
||
|
||
// ─── m1.3: leading-zero 3-digit value (007) is non-canonical everywhere ───────
|
||
// pad2('007') === '07' (parseInt drops the leading zeros), so the re-render /
|
||
// re-emit can never match the input — parsePhaseId rejects '007' as not-
|
||
// canonical at milestone/phase/subphase/plan, in BOTH the display and dir forms.
|
||
describe('bracket grammar: leading-zero 3-digit value (007) is rejected (review m1)', () => {
|
||
test('display form rejects 007 in milestone / phase / subphase / plan', () => {
|
||
for (const s of ['[GSD.007] 05', '[GSD.02] 007', '[GSD.02] 05.007', '[GSD.02] 05-007']) {
|
||
assert.throws(() => core.parsePhaseId(s), /parsePhaseId: not canonical/, s);
|
||
}
|
||
});
|
||
|
||
test('dir form rejects 007 in milestone / phase / subphase / plan', () => {
|
||
for (const s of ['GSD.007-05', 'GSD.02-007', 'GSD.02-05.007', 'GSD.02-05-007']) {
|
||
assert.throws(() => core.parsePhaseId(s), /parsePhaseId: not canonical/, s);
|
||
}
|
||
});
|
||
});
|
||
|
||
// ─── m1.4: slug content abuse — null byte / control / unicode ─────────────────
|
||
// Two layers, each pinned independently:
|
||
// READ (parsePhaseId): a dir trailing segment is a read-tolerant slug, DROPPED
|
||
// from the identity tuple. Any non-line-terminator content — null byte,
|
||
// control char, accented letter, emoji — is accepted and dropped (never
|
||
// stored, so it cannot smuggle a bad identity, and is never mis-read as a
|
||
// plan). A LINE TERMINATOR (\n / \r) is rejected outright because the dir
|
||
// regex's `.+` cannot cross it.
|
||
// EMIT (toDir): the allow-list sanitizer `.replace(/[^a-z0-9]+/g,'-')`
|
||
// collapses every non-[a-z0-9] run to a single hyphen (null / control / tab /
|
||
// newline / path separator alike) and drops non-ASCII bytes; content that
|
||
// sanitizes to nothing is rejected rather than emitting a dangling hyphen.
|
||
describe('bracket grammar: slug content abuse — null byte / control / unicode (review m1)', () => {
|
||
const EMOJI = '\u{1F4A5}'; // 💥
|
||
const ACCENTED = 'café'; // café
|
||
|
||
test('parsePhaseId drops an abusive (non-line-terminator) trailing slug, keeping a clean tuple', () => {
|
||
for (const bad of ['foo\x00bar', 'foo\x07bar', 'foo\tbar', ACCENTED, EMOJI]) {
|
||
const id = core.parsePhaseId(`GSD.02-05-${bad}`);
|
||
assert.deepStrictEqual(id, { project: 'GSD', milestone: '02', phase: '05' }, JSON.stringify(bad));
|
||
}
|
||
});
|
||
|
||
test('a line terminator (\\n / \\r) in the slug position is rejected by the anchors', () => {
|
||
assert.throws(() => core.parsePhaseId('GSD.02-05-foo\nbar'), /not a bracket phase id/);
|
||
assert.throws(() => core.parsePhaseId('GSD.02-05-foo\rbar'), /not a bracket phase id/);
|
||
});
|
||
|
||
test('toDir sanitizes abusive slug content to a safe [a-z0-9-] token', () => {
|
||
const id = core.parsePhaseId('[GSD.02] 05');
|
||
assert.strictEqual(core.toDir(id, 'foo\x00bar'), 'GSD.02-05-foo-bar', 'null byte → hyphen');
|
||
assert.strictEqual(core.toDir(id, 'foo\x07bar'), 'GSD.02-05-foo-bar', 'control char → hyphen');
|
||
assert.strictEqual(core.toDir(id, 'a\nb'), 'GSD.02-05-a-b', 'newline → hyphen (safe on emit, unlike read)');
|
||
assert.strictEqual(core.toDir(id, 'a\rb'), 'GSD.02-05-a-b', 'carriage return → hyphen');
|
||
assert.strictEqual(core.toDir(id, ACCENTED), 'GSD.02-05-caf', 'non-ASCII dropped, trailing hyphen stripped');
|
||
});
|
||
|
||
test('toDir rejects a slug that sanitizes to empty (emoji-only / null-only) rather than a dangling hyphen', () => {
|
||
const id = core.parsePhaseId('[GSD.02] 05');
|
||
assert.throws(() => core.toDir(id, EMOJI), /toDir:.*slug/);
|
||
assert.throws(() => core.toDir(id, '\x00'), /toDir:.*slug/);
|
||
});
|
||
});
|
||
|
||
// ─── m1.5: absolute-path slug or project ─────────────────────────────────────
|
||
// Sibling of the `../../etc/passwd` traversal test above (toDir slug guard) and
|
||
// the hand-built-id field-validation block (review M1): the absolute-path
|
||
// (leading `/`) shapes, pinned next to the traversal shapes they extend.
|
||
describe('bracket grammar: absolute-path slug or project (review m1)', () => {
|
||
test('an absolute-path slug is sanitized — leading slash and separators collapse away', () => {
|
||
const id = core.parsePhaseId('[GSD.02] 05');
|
||
const dir = core.toDir(id, '/etc/passwd');
|
||
assert.ok(!dir.includes('/'), `dir must not contain a path separator: ${dir}`);
|
||
assert.strictEqual(dir, 'GSD.02-05-etc-passwd');
|
||
});
|
||
|
||
test('an absolute-path project on a hand-built id is rejected by PROJECT_ID_RE', () => {
|
||
assert.throws(() => core.toDir({ project: '/etc/passwd', milestone: '02', phase: '05' }, 'feat'), /toDir:.*project/);
|
||
assert.throws(() => core.toDir({ project: '/etc', milestone: '02', phase: '05' }, 'feat'), /toDir:.*project/);
|
||
});
|
||
|
||
test('an absolute-path string is not a bracket phase id', () => {
|
||
assert.throws(() => core.parsePhaseId('/etc/passwd'), /not a bracket phase id/);
|
||
});
|
||
|
||
test('an absolute-path slug embedded in a dir string is dropped, leaving a clean tuple (no `/` in any field)', () => {
|
||
const id = core.parsePhaseId('GSD.02-05-/etc/passwd');
|
||
assert.deepStrictEqual(id, { project: 'GSD', milestone: '02', phase: '05' });
|
||
});
|
||
});
|
||
|
||
// ─── m1.6: whitespace-only input ─────────────────────────────────────────────
|
||
// (The empty string is already pinned above under the ambiguous-token block;
|
||
// these are the non-empty all-whitespace forms.)
|
||
describe('bracket grammar: whitespace-only input is rejected (review m1)', () => {
|
||
test("' ', ' ', '\\t', '\\n', and '\\t\\n' all reject as not-a-bracket-phase-id", () => {
|
||
for (const s of [' ', ' ', '\t', '\n', '\t\n']) {
|
||
assert.throws(() => core.parsePhaseId(s), /not a bracket phase id/, JSON.stringify(s));
|
||
}
|
||
});
|
||
});
|
||
|
||
// ─── m1.7: very-long input (ReDoS smoke) ─────────────────────────────────────
|
||
// Behavioral (not timing) assertions: the grammar's regexes are linear — no
|
||
// nested quantifier over an overlapping class — so a 10k-char input resolves
|
||
// promptly; a catastrophic-backtracking regression would fail the run by
|
||
// timeout rather than by assertion.
|
||
describe('bracket grammar: very-long input handled promptly (review m1)', () => {
|
||
const BIG = 'a'.repeat(10000);
|
||
|
||
test('a 10k-char garbage string rejects promptly', () => {
|
||
assert.throws(() => core.parsePhaseId(BIG), /not a bracket phase id/);
|
||
});
|
||
|
||
test('a partial-then-fail prefix (open bracket + 10k digits) rejects promptly', () => {
|
||
assert.throws(() => core.parsePhaseId(`[GSD.${'0'.repeat(10000)}`), /not a bracket phase id/);
|
||
});
|
||
|
||
test('a 10k-char slug in a dir string parses (slug dropped) without hanging', () => {
|
||
assert.deepStrictEqual(core.parsePhaseId(`GSD.02-05-${BIG}`), { project: 'GSD', milestone: '02', phase: '05' });
|
||
});
|
||
|
||
test('toDir sanitizes a 10k-char slug promptly to the expected token', () => {
|
||
const dir = core.toDir(core.parsePhaseId('[GSD.02] 05'), BIG);
|
||
assert.strictEqual(dir, `GSD.02-05-${BIG}`);
|
||
assert.strictEqual(dir.length, 'GSD.02-05-'.length + 10000);
|
||
});
|
||
});
|
||
|
||
// ─── canonical token/heading sources for the downstream read path (PR-2) ─────
|
||
describe('bracket grammar: exported canonical sources (drift-guard owner)', () => {
|
||
test('BRACKET_PHASE_TOKEN_SOURCE matches the bracket numeric run (dot-or-dash sub-separator)', () => {
|
||
const re = new RegExp(`^${core.BRACKET_PHASE_TOKEN_SOURCE}$`);
|
||
// The bracket dir/heading numeric run: MM-PP[.SS] (dash milestone↔phase, dot phase↔subphase).
|
||
assert.ok(re.test('02-05.03'), 'MM-PP.SS run');
|
||
assert.ok(re.test('02-05'), 'MM-PP run');
|
||
assert.ok(re.test('05.03'), 'PP.SS phase token');
|
||
assert.ok(re.test('05'), 'bare phase');
|
||
assert.ok(re.test('12A'), 'letter variant');
|
||
assert.ok(!re.test('slug'), 'non-numeric is not a token');
|
||
});
|
||
|
||
test('PHASE_HEADING_PREFIX_SRC matches a bracket-or-Phase heading intro, not a bare number', () => {
|
||
const re = new RegExp(`^${core.PHASE_HEADING_PREFIX_SRC}`);
|
||
assert.ok(re.test('[GSD.02] 05: Title'), 'bracket prefix');
|
||
assert.ok(re.test('Phase 5: Title'), 'Phase prefix');
|
||
assert.ok(re.test('[GSD.02] Phase 5: Title'), 'bracket + Phase prefix');
|
||
assert.ok(!re.test('05: Title'), 'a bare number is not a phase heading intro');
|
||
});
|
||
});
|
||
|
||
// ─── fast-check generative properties (ADR Decision 4) ──────────────────────
|
||
// A genuinely generative project code over the repo's [A-Z][A-Z0-9_]* grammar.
|
||
const projectArb = fc
|
||
.tuple(
|
||
fc.constantFrom(...'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('')),
|
||
fc.array(fc.constantFrom(...'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_'.split('')), { maxLength: 5 }),
|
||
)
|
||
.map(([head, rest]) => head + rest.join(''));
|
||
// A clean slug over [a-z0-9], filtered to guarantee at least one letter: the
|
||
// mixed digit+letter shape (review M3) exercises alphanumeric words like
|
||
// 'v2ui' without ever generating an all-digit slug, which toDir now rejects
|
||
// (an all-digit slug collides with the plan grammar). Restricted to
|
||
// lowercase-plus-digit input so the slug guard's toLowerCase()/replace() is a
|
||
// no-op — safeSlug === slug holds, keeping the disk↔display bijection's dir-
|
||
// string equality assertion exact.
|
||
const slugArb = fc
|
||
.array(fc.constantFrom(...'abcdefghijklmnopqrstuvwxyz0123456789'.split('')), { minLength: 1, maxLength: 8 })
|
||
.map((cs) => cs.join(''))
|
||
.filter((s) => /[a-z]/.test(s));
|
||
// Spans 1–999 so the property domain genuinely includes 3+-digit milestone/
|
||
// phase/subphase/plan tokens (re-review Minor 1). pad2() passes a ≥3-digit
|
||
// value through unchanged (no truncation) and it carries no leading zero, so
|
||
// both the render round-trip and CANONICAL_NUMERIC_RE's dedicated `[1-9]\d{2,}`
|
||
// branch (exercised via toDir in the bijection property) hold at that width.
|
||
const numArb = fc.integer({ min: 1, max: 999 });
|
||
const optNumArb = fc.option(numArb, { nil: undefined });
|
||
|
||
describe('bracket grammar — properties (fast-check)', () => {
|
||
test('round-trip: renderPhaseId(parsePhaseId(display)) === display for every well-formed display', () => {
|
||
fc.assert(
|
||
fc.property(projectArb, numArb, numArb, optNumArb, optNumArb, (proj, mm, pp, ss, ll) => {
|
||
let display = `[${proj}.${p2(mm)}] ${p2(pp)}`;
|
||
if (ss !== undefined) display += `.${p2(ss)}`;
|
||
if (ll !== undefined) display += `-${p2(ll)}`;
|
||
return core.renderPhaseId(core.parsePhaseId(display)) === display;
|
||
}),
|
||
);
|
||
});
|
||
|
||
test('disk↔display bijection: toDir(parse(display), slug) is the canonical dir and re-parses to the same identity', () => {
|
||
fc.assert(
|
||
fc.property(projectArb, numArb, numArb, optNumArb, slugArb, (proj, mm, pp, ss, slug) => {
|
||
let display = `[${proj}.${p2(mm)}] ${p2(pp)}`;
|
||
if (ss !== undefined) display += `.${p2(ss)}`;
|
||
const id = core.parsePhaseId(display);
|
||
const dir = core.toDir(id, slug);
|
||
const expectedDir = `${proj}.${p2(mm)}-${p2(pp)}${ss !== undefined ? '.' + p2(ss) : ''}-${slug}`;
|
||
if (dir !== expectedDir) return false;
|
||
const back = core.parsePhaseId(dir);
|
||
return (
|
||
back.project === id.project &&
|
||
back.milestone === id.milestone &&
|
||
back.phase === id.phase &&
|
||
back.subphase === id.subphase &&
|
||
// A valid (letter-bearing) slug must never be read back as a plan
|
||
// (review M3) — the bijection holds on the full identity tuple,
|
||
// not just the milestone/phase/subphase dimensions.
|
||
back.plan === undefined
|
||
);
|
||
}),
|
||
);
|
||
});
|
||
|
||
// Non-canonical property (review B1): the round-trip property above only
|
||
// ever feeds parsePhaseId a string produced by p2()-padding, so it cannot
|
||
// exercise the rejection path at all. This property starts from a KNOWN
|
||
// canonical display string and applies one structural mutation — stripping
|
||
// a pad (milestone, phase, OR subphase), doubling the bracket/phase separator
|
||
// space, over-padding a field, or adding stray whitespace — asserting
|
||
// parsePhaseId throws on every one. The milestone/phase/subphase integers are
|
||
// restricted to 1-9 here so `p2()` always actually pads (e.g. '05', not '42');
|
||
// otherwise the "unpad" mutation could regenerate the same canonical string,
|
||
// making the mutation a no-op instead of a genuine probe. `includeSub` (a
|
||
// generated boolean) decides whether the canonical carries a `.SS` subphase;
|
||
// the subphase-pad mutations (re-review Minor 2) force one in so there is
|
||
// always a `.SS` to mutate, while the non-subphase mutations keep their
|
||
// original no-subphase coverage whenever `includeSub` is false.
|
||
const singleDigitArb = fc.integer({ min: 1, max: 9 });
|
||
const mutationArb = fc.constantFrom(
|
||
'unpad-milestone',
|
||
'unpad-phase',
|
||
'unpad-subphase',
|
||
'overpad-milestone',
|
||
'overpad-phase',
|
||
'overpad-subphase',
|
||
'double-space',
|
||
'leading-space',
|
||
'trailing-space',
|
||
);
|
||
|
||
test('non-canonical mutations of a canonical display string are always rejected', () => {
|
||
fc.assert(
|
||
fc.property(
|
||
projectArb,
|
||
singleDigitArb,
|
||
singleDigitArb,
|
||
singleDigitArb,
|
||
fc.boolean(),
|
||
mutationArb,
|
||
(proj, mm, pp, ss, includeSub, mutation) => {
|
||
const mmP = p2(mm);
|
||
const ppP = p2(pp);
|
||
const ssP = p2(ss);
|
||
const isSubMutation = mutation === 'unpad-subphase' || mutation === 'overpad-subphase';
|
||
// A subphase-pad mutation needs a `.SS` to act on, so force one in for
|
||
// those cases; otherwise the generated boolean decides, preserving the
|
||
// original no-subphase mutation coverage.
|
||
const hasSub = includeSub || isSubMutation;
|
||
const subCanon = hasSub ? `.${ssP}` : '';
|
||
const canonical = `[${proj}.${mmP}] ${ppP}${subCanon}`;
|
||
let mutated;
|
||
switch (mutation) {
|
||
case 'unpad-milestone': mutated = `[${proj}.${mm}] ${ppP}${subCanon}`; break;
|
||
case 'unpad-phase': mutated = `[${proj}.${mmP}] ${pp}${subCanon}`; break;
|
||
case 'unpad-subphase': mutated = `[${proj}.${mmP}] ${ppP}.${ss}`; break;
|
||
case 'overpad-milestone': mutated = `[${proj}.0${mmP}] ${ppP}${subCanon}`; break;
|
||
case 'overpad-phase': mutated = `[${proj}.${mmP}] 0${ppP}${subCanon}`; break;
|
||
case 'overpad-subphase': mutated = `[${proj}.${mmP}] ${ppP}.0${ssP}`; break;
|
||
case 'double-space': mutated = `[${proj}.${mmP}] ${ppP}${subCanon}`; break;
|
||
case 'leading-space': mutated = ` ${canonical}`; break;
|
||
case 'trailing-space': mutated = `${canonical} `; break;
|
||
default: throw new Error(`unreachable mutation: ${mutation}`);
|
||
}
|
||
// Sanity: every mutation above must actually change the string, or
|
||
// the property would (correctly) fail to throw and falsely indict
|
||
// the implementation instead of the generator.
|
||
if (mutated === canonical) return false;
|
||
try {
|
||
core.parsePhaseId(mutated);
|
||
return false; // did not throw — the mutation slipped through
|
||
} catch {
|
||
return true;
|
||
}
|
||
},
|
||
),
|
||
);
|
||
});
|
||
});
|
||
|
||
// ─── the #2232 reconciliation, generatively ─────────────────────────────────
|
||
// BRACKET_PHASE_TOKEN_SOURCE is the READ side; toDir is the EMIT side. The
|
||
// example tables pin specific strings, but the contract that actually matters
|
||
// is metamorphic and spans the pair: for every id the emit path can produce,
|
||
// the read path must collect exactly that id's numeric run — no more (the
|
||
// #2232 over-collection) and no less (the under-collection a verbatim
|
||
// exactly-2 cap would cause at 3+-digit widths). Tying the two together means a
|
||
// future change to either side fails here rather than drifting silently, which
|
||
// is the whole point of consuming the single-owner seam.
|
||
describe('bracket grammar — read/emit agreement on the token run (#2232)', () => {
|
||
// A slug whose FIRST WORD is a >=3-digit number — the #2232 bug class itself
|
||
// (roadmap phase "2026 Photos & Performance" → slug "2026-photos-…"). The
|
||
// existing slugArb generates a single [a-z0-9] word and so can never produce
|
||
// this shape; the collision only exists when a digit-run sits at a segment
|
||
// boundary. Bounded at >=100 because a 2-digit first word is genuinely
|
||
// ambiguous against a canonical plan — the seam's known, accepted limit,
|
||
// identical on the M-NN path.
|
||
const numberLeadingSlugArb = fc
|
||
.tuple(
|
||
fc.integer({ min: 100, max: 9999 }),
|
||
fc.array(fc.constantFrom(...'abcdefghijklmnopqrstuvwxyz'.split('')), { minLength: 1, maxLength: 6 }),
|
||
)
|
||
.map(([lead, word]) => `${lead}-${word.join('')}`);
|
||
|
||
test('a number-leading slug never bleeds into the token run', () => {
|
||
fc.assert(
|
||
fc.property(projectArb, numArb, numArb, optNumArb, numberLeadingSlugArb, (proj, mm, pp, ss, slug) => {
|
||
const display = `[${proj}.${p2(mm)}] ${p2(pp)}${ss !== undefined ? '.' + p2(ss) : ''}`;
|
||
const id = core.parsePhaseId(display);
|
||
const dir = core.toDir(id, slug);
|
||
|
||
// The run the emit path actually wrote, independent of the read regex.
|
||
const expectedRun = `${p2(mm)}-${p2(pp)}${ss !== undefined ? '.' + p2(ss) : ''}`;
|
||
|
||
// The read path, applied the way a PR-2 reader would: strip the
|
||
// `{CODE}.` prefix, then collect the run with the exported source.
|
||
const runInput = dir.slice(`${proj}.`.length);
|
||
const collected = runInput.match(new RegExp(`^${core.BRACKET_PHASE_TOKEN_SOURCE}`))?.[0];
|
||
if (collected !== expectedRun) return false;
|
||
|
||
// And the strict parser agrees the slug is a slug, not a plan — the
|
||
// #2232 failure mode was the reader and the parser disagreeing about
|
||
// where the identity ends.
|
||
const back = core.parsePhaseId(dir);
|
||
return back.phase === p2(pp) && back.milestone === p2(mm) && back.plan === undefined;
|
||
}),
|
||
);
|
||
});
|
||
});
|