Files
msd-core/tests/adr-612-bracket-grammar.test.cjs
BeeHiggs bf9fe4630d feat(#2249): bracket phase-id core grammar — parse/render/toDir round-trip pair (epic #612 PR-1) (#2258)
* feat(#2249): bracket phase-id core grammar — parse/render/toDir + READING-B + guards

PR-1 of epic #612 (ADR-612, in-tree at docs/adr/612-bracket-phase-id-convention.md).
Adds the bracket-convention grammar INSIDE src/phase-id.cts — the ADR-2121 single
canonical owner — as a pure, additive extension. The 17 locked exports and
PHASE_NUMBER_TOKEN_SOURCE are untouched, and normalizePhaseName is byte-identical,
so the PR-0 collision anchor (tests/adr-612-collision-characterization.test.cjs)
stays green.

New pure round-trippable model (ADR Decision 4):
- PhaseId { project, milestone, phase, subphase?, plan? }.
- parsePhaseId(input): accepts display `[GSD.02] 05.03-01`, dir/token
  `GSD.02-05.03-slug`, or bare `GSD.02-05`; rejects ambiguous non-bracket tokens
  (`02-04`, `05`) rather than guessing. The rejection lives ONLY in this new
  parser — normalizePhaseName and every legacy reader keep accepting those
  tokens unchanged (conservative default; no existing path gains a throw).
- renderPhaseId(id) -> `[GSD.02] 05.03-01`; toDir(id, slug) -> `GSD.02-05.03-slug`
  with a slug guard that sanitizes path-traversal input.
- getMilestoneFromPhaseId(phaseId, convention?): READING-B derives the milestone
  from the `[PROJECT.MM]` prefix, gated on convention === 'bracket' and returning
  the `vN.0` form (parity with READING-A). The optional parameter keeps the helper
  pure (no config read) and byte-compatible — every existing single-arg caller
  resolves to the unchanged READING-A body (ADR Decision 6).
- extractPhaseToken(dirName, convention?): bracket dir branch GATED on
  convention === 'bracket'. A bracket dir `{CODE}.{MM}-{PP}` is
  string-indistinguishable from the legacy #2043/#1324 letter-prefixed-decimal
  family (`P0.3-2`, `P0.12-34`) whenever the code ends in a digit, so no
  string-only discriminator is complete — an ungated auto-detect silently
  reinterpreted legacy reads on this CRITICAL 6-caller helper. The explicit
  convention signal keeps every existing convention-less call site byte-identical
  (pinned by a #2043 numeric-tail characterization in tests/phase-id.test.cjs).
- comparator: no new code — comparePhaseNum already orders the dot-decimal
  `PP[.SS]` tokens extractPhaseToken yields; milestone-qualified ordering is a
  PR-2 resolution concern (bracketQualifiedKey), not core grammar.
- SENTINEL_RANGES / isSentinelPhaseId(phaseId, convention?): {0, 999}
  non-milestone guard; the bracket-prefix reading is gated the same way (an
  ungated read called `P0.0-foundation` a sentinel), legacy leading-int form
  unchanged.
- BRACKET_PHASE_TOKEN_SOURCE (dot-or-dash `[.-]` sub-separator; deliberately
  more permissive than parsePhaseId — a read-tolerance source for PR-2, not the
  emit grammar) and PHASE_HEADING_PREFIX_SRC exported from the drift-guard-exempt
  owner so PR-2 builds every bracket read regex from the canonical source and
  check:phase-id-drift stays green stack-wide.

The bracket project code follows the repo's config-validated `[A-Z][A-Z0-9_]*`
grammar (not the ADR §1 illustration's `[A-Z]{1,6}`), so every project_code the
config permits parses. parsePhaseId has no live callers in PR-1, so this grammar
choice is forward-facing for PR-2 with zero PR-1 behavior impact.

Tests: tests/adr-612-bracket-grammar.test.cjs (28) — ADR §3 example round-trips,
full 5-tuple parse, READING-B (+ legacy-unchanged and sentinel cases),
extractPhaseToken bracket ON/OFF, comparator ordering of extracted tokens,
sentinel + slug guards, bare-token rejection, exported-source behavioral
assertions, and two generative fast-check properties: render∘parse identity over
well-formed displays, and the toDir/disk↔display bijection. Plus a #2043
numeric-tail characterization (single- AND multi-digit rows) in
tests/phase-id.test.cjs pinning the convention-less reading byte-identical.

The compiled gsd-core/bin/lib/phase-id.cjs is gitignored (ADR-457 build-at-publish)
and rebuilt by CI, so it is intentionally not committed.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(#2249): changeset fragment for PR #2258 (docs-exempt: internal grammar behind flag)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(#2249): reject non-canonical phase-id input + harden toDir (review B1/M1-M3)

PR-1 CHANGES_REQUESTED follow-up (epic #612, ADR-612 Decision 4).

B1 (blocker): parsePhaseId accepted non-canonical input (unpadded numbers,
over-padded numbers, multi-space separators, stray whitespace), so
render(parse(x)) === x did not hold for every well-formed x as ADR-612
Decision 4 requires. Both branches now enforce canonicality by construction:
parse permissively, rebuild the canonical string via the same emit path
(renderPhaseId for display, a hand-rebuilt token for dir/token), and throw
"parsePhaseId: not canonical" on any mismatch. The .trim() at the parser's
entry is removed — the match anchors now reject leading/trailing whitespace
outright, folding into the existing "not a bracket phase id" rejection.

M1 (major): toDir only ever guarded the slug; project/milestone/phase/
subphase were interpolated unsanitized, so a hand-built PhaseId (a
structural, not nominal, type) could smuggle a path-traversal segment onto
disk. Every field is now validated against the exact shape parsePhaseId
itself would produce before use.

M2 (major): a slug that sanitized to empty (e.g. '!!!') left a dangling
trailing hyphen in the emitted dir name. toDir now throws in that case.

M3 (major): an all-digit slug (e.g. '2026') was string-indistinguishable
from the dir-branch's plan tail, so it silently broke the disk<->identity
bijection on read-back. toDir now rejects all-digit slugs.

Nits: toDir now rejects a non-string slug instead of coercing it to the
literal token 'undefined'/'null'; sentinel boundary tests added for
milestones 1/998/1000 (SENTINEL_RANGES is the two discrete values {0, 999},
not an inclusive range — these were already correct, now locked by test).

Test-first: every new assertion (concrete examples + fast-check mutation
property for B1; concrete cases for M1-M3 and the nits) was written and
confirmed red before the implementation changes, per repo TDD convention.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(#2249): reformat changeset body to house convention (review Mi2)

The fragment added in ab26190a was a plain paragraph — no bold headline,
no trailing issue reference. Reformat to the repo's
`**Bold headline** — symptom/explanation. (#issue)` body shape (see e.g.
.changeset/agile-pandas-dance.md, .changeset/fierce-pumas-gather.md).

Uses (#2249), the issue every commit on this branch references, not the
PR number already carried in frontmatter (`pr: 2258`) — the changelog
serializer appends `(#{pr})` unconditionally, so a body also ending in
`(#2258)` would double-render as `(#2258) (#2258)`. Verified the rendered
bullet directly via parseFragment + serializeChangelog: it now reads
`... (#2249) (#2258)`, matching the dominant convention across the other
fragments (frontmatter pr = merged PR, body reference = originating issue).

Also moved the docs-exempt marker back before the paragraph -> after it
(matching the file's original order): the marker sits on its own line and
is stripped before the body is used, but placing it first left a leading
blank line in front of the bold headline once reformatted.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(#2249): widen property generators — 3+-digit numerics + subphase-pad mutation (re-review Minor 1/2)

PR-1 re-review follow-up (epic #612, ADR-612 Decision 4). Test-only: closes
two property-generator coverage gaps the reviewer flagged; no source change
(src/phase-id.cts and gsd-core/bin/lib/phase-id.cjs are byte-unchanged).

Minor 1 (3+-digit numerics never exercised): numArb capped at 99, so no
property fed a 3+-digit milestone/phase/subphase/plan through parse/render/
toDir despite CANONICAL_NUMERIC_RE's dedicated `[1-9]\d{2,}` branch. Widen
numArb to 1–999 so the round-trip and disk↔display bijection properties both
span 3-digit widths (pad2 passes ≥3-digit values through un-truncated with no
leading zero, so canonicality still holds). Add a concrete regression pinning
the reviewer's hand-traced example: '[GSD.100] 05' round-trips, renders, and
toDirs to 'GSD.100-05-feature' without truncation.

Minor 2 (no subphase-pad mutation): the B1 mutation-rejection property covered
milestone/phase pad + whitespace mutations but never a subphase pad. Add
unpad-subphase / overpad-subphase to the mutation set and a generated
`includeSub` boolean that decides whether the canonical carries a `.SS`
(forced in for the subphase mutations so there is always a `.SS` to mutate);
non-subphase mutations keep their original no-subphase coverage.

Non-vacuity verified against the compiled lib by temporarily probing each
widened/new property and confirming it fails: round-trip counterexample
["A",100,1,…] and bijection counterexample ["A",1,100,…,"a"] prove 3-digit
tokens are genuinely generated and reach the body; a no-op unpad-subphase
mutation trips the mutated===canonical guard (counterexample
["A",1,1,1,false,"unpad-subphase"]), proving the subphase branch is reached
with a subphase present. Probes reverted; numRuns unchanged.

Gates: tests/adr-612-bracket-grammar.test.cjs 44 pass / 0 fail;
`npm run test:unit` 1079 pass / 0 fail; `npm run lint:ci` exit 0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(#2249): consume the #2232 continuation seam at the bracket token's slug-adjacent position (review Major)

BRACKET_PHASE_TOKEN_SOURCE was a sixth continuation-recognition site that
re-derived the grammar as an unbounded `\d+` literal instead of consuming
PHASE_CONTINUATION_SEGMENT_SOURCE, re-opening the #2232 bug class on the bracket
path: a PR-2 reader interpolating it over dir `PROJ.01-14-2026-photos-…` (a slug
whose first word is a year) over-collected the token as `01-14-2026` instead of
`01-14`.

Interpolating the cap verbatim at every position was rejected on evidence: the
bracket run is `MM-PP[.SS][-LL]` and only the LAST position is slug-adjacent.
The exactly-2 cap at the others would under-collect ids toDir itself emits —
`PROJ.02-105-slug` (3-digit phase) reads as `02`, `[GSD.02] 05.100` (3-digit
sub-phase) as `05` — because CANONICAL_NUMERIC_RE admits `[1-9]\d{2,}` and
`[GSD.100] 05` is a pinned regression. Those positions are delimiter-
disambiguated (a required field separator; a dot a slug can never contain),
not heuristically recognized, so they have no year collision to defend against.
Upstream draws the same line for the same reason: core-utils/phase cap the
paired PLAN component while the leading phase component stays unbounded.

So the run is now positional rather than a free `(?:[.-]\d+)*` repetition, and
each position takes the width its delimiter affords: leading unbounded, dash-1
and dot canonical, and the slug-adjacent dash-2 interpolating the single-owner
seam. The accepted trade-off is #2232's policy verbatim: a PLAN ≥100 is out of
the token grammar.

Also derives CANONICAL_NUMERIC_RE from the new BRACKET_CANONICAL_NUMERIC_SOURCE
instead of re-spelling it as a literal, so the emit-side gate and the read-side
token source are one rule — the same single-owner discipline this fix is about.
Behaviour-identical (the anchors make the source's `(?!\d)` guard redundant).

Refs #2249

* test(#2249): pin the bracket/#2232 reconciliation — parity surface 6 + divergence gate + property (review Major)

The comment block alone cannot hold the divergence: src/phase-id.cts is exempt
from the #2128 drift guard by construction, so lint-phase-id-drift.cjs would not
catch the bracket token source drifting from the seam. Per the Generative Fix
Divergence rule, the divergence is pinned behaviorally instead.

Surface 6 joins the existing #2232 parity gate rather than starting a rival one:
the review named the bracket token source "a sixth continuation-recognition
site", and continuation-grammar-parity.test.cjs is already the invariant-named
home where the five #2043 sites agree with the owner on a shared width corpus.
Surface 6 asserts the same contract at the bracket run's slug-adjacent position
(`01-14-<seg>-photos-…`, mirroring surface 1 with the extra milestone level), so
the bracket path now fails the same gate the other five do.

A second block pins the DELIBERATE half — the wider canonical width at the
delimiter-disambiguated positions, plus the accepted bound (a plan >=100 is out
of the grammar). Without it, "unifying" bracket onto the exactly-2 cap would
look like a cleanup rather than a regression.

The generative property ties the READ side to the EMIT side metamorphically: for
every id toDir can produce, BRACKET_PHASE_TOKEN_SOURCE must collect exactly that
id's numeric run — no more, no less. It needed a new arbitrary: the existing
slugArb generates one [a-z0-9] word and so can never produce the number-leading
slug the collision requires.

Probe-falsified, both directions (probes reverted):
- reverting the source to the old unbounded `\d+` fails 8: the parity gate
  reports `"01-14-2026-photos-performance" collected "01-14-2026"` — the
  review's scenario verbatim — and the property shrinks to
  ["A",1,1,undefined,"100-a"].
- interpolating the seam at EVERY position (the rejected verbatim option) leaves
  the repro and parity green but fails the divergence gate `'02' !== '02-105'`
  and the property at ["A",1,1,100,"100-a"] (3-digit sub-phase), which is the
  evidence that a verbatim cap under-collects ids toDir emits.
Width 2 stays green under both probes — the corpus agrees with the owner exactly
where the old and new rules coincide, so the gate discriminates rather than
merely mirroring the regex.

Refs #2249

* docs(#2249): add the new phase-id exports to the CONTEXT.md glossary bullet (round-4 Major)

* test(#2249): pin deterministic grammar boundary cases (re-review m1)

PR-1 re-review follow-up (epic #612, ADR-612 Decision 4). Test-only: closes
the m1 proof gap — the grammar's bounds were exercised only incidentally
through the fast-check domain (1-999, [a-z0-9] slugs). No source change
(src/phase-id.cts and gsd-core/bin/lib/phase-id.cjs byte-unchanged).

Adds a deterministic boundary block (7 describe groups, +22 tests) pinning
the compiled lib's CURRENT behavior — a proof gap, not a behavior gap:

- m1.1 numeric-width 99/100/101 at milestone/phase/subphase/plan: parse
  (display + dir) -> render/toDir round-trip byte-equality. The plan
  position is identity-symmetric (parse/render accept 99/100/101) but toDir
  drops it (filename-surface dimension only).
- m1.2 read-token width is POSITIONAL: BRACKET_PHASE_TOKEN_SOURCE absorbs
  99/100/101 at milestone/phase/subphase (delimiter-disambiguated) but caps
  the slug-adjacent plan (dash-2) at exactly 2 digits — plan >=100 is out of
  the token grammar (#2232 seam). Pinned as asymmetry, NOT symmetry.
- m1.3 leading-zero 007 -> not-canonical rejection at every position/form.
- m1.4 slug abuse: parse DROPS a null-byte/control/unicode/emoji trailing
  slug (never stored, never mis-read as a plan) and rejects a line
  terminator; toDir's allow-list sanitizer collapses each to a safe
  [a-z0-9-] token or rejects sanitize-to-empty.
- m1.5 absolute-path slug sanitizes (next to the ../../etc traversal test);
  an absolute-path project on a hand-built id is rejected by PROJECT_ID_RE;
  an abs-path string is not a bracket id; an abs-path dir slug is dropped to
  a clean tuple.
- m1.6 whitespace-only -> not-a-bracket-phase-id.
- m1.7 very-long input (10k) resolves promptly (ReDoS smoke, behavioral):
  garbage/partial-prefix throw; a 10k-char slug parses (dropped)/sanitizes.

No accept-not-reject case is a src bug: parse never STORES an abusive slug
(dropped from the identity tuple) and toDir independently re-sanitizes on
emit, so the only slug reaching disk is allow-listed. Plan >=100 accepted by
parse is the documented positional design (toDir drops the plan; the
read-token caps it) — divergence pinned, not papered over.

Probe-falsify: corrupted one assertion in each of the 7 groups (m1.4 both
its parse-side and emit-side), ran -> 8 distinct named failures, reverted ->
66/66 green. Confirms every new group executes and can fail.

Gates: tests/adr-612-bracket-grammar.test.cjs 66 pass / 0 fail; grammar +
continuation-grammar-parity + collision-characterization + phase-id family
175 pass / 0 fail; `npm run lint:ci` exit 0. `npm run test:unit` is green
except one pre-existing, unrelated env failure (npm-integrity-gate: a live
npm-audit advisory in the production dep tree — reproduces with this change
stashed; no package.json/lock change here).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 12:49:53 -04:00

797 lines
43 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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('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;
}),
);
});
});