* 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>
225 lines
11 KiB
JavaScript
225 lines
11 KiB
JavaScript
'use strict';
|
|
/**
|
|
* continuation-grammar-parity.test.cjs — DEFECT.GENERATIVE-FIX parity gate (#2232)
|
|
*
|
|
* Proves that the phase-token CONTINUATION-segment grammar has a single owner
|
|
* (`phase-id.cjs: PHASE_CONTINUATION_SEGMENT_SOURCE` / `isPhaseContinuationSegment`)
|
|
* and that every consuming surface agrees with it on a shared digit-width corpus.
|
|
*
|
|
* Why this gate exists: #2043 fixed the same class of bug by hand-editing five
|
|
* independent `/^\d{2,}/` copies; #2232 is the residual that survived because a
|
|
* later reader could not tell the five copies were one rule. The rule is now
|
|
* single-sourced, but a regex literal is easy to re-introduce and
|
|
* `scripts/lint-phase-id-drift.cjs` only guards the OTHER constant
|
|
* (`PHASE_NUMBER_TOKEN_SOURCE`) — a bare `\d{2,}` re-derivation would pass lint
|
|
* and CI silently. This test is the behavioral backstop: it fails the moment any
|
|
* consuming surface disagrees with the owner about which continuation widths are
|
|
* absorbed.
|
|
*
|
|
* Contract: for every digit-width in the corpus, each surface's notion of
|
|
* "is this segment absorbed as a continuation?" MUST equal
|
|
* `isPhaseContinuationSegment(segment)`.
|
|
*
|
|
* Surfaces covered (the five #2043 sites, plus the #612 bracket read path):
|
|
* 1. phase-id.cjs extractPhaseToken
|
|
* 2. validate.cjs PHASE_TOKEN_FROM_DIR_RE
|
|
* 3. validate.cjs canonicalPlanStem
|
|
* 4. core-utils.cjs extractCanonicalPlanId (paired plan component)
|
|
* 5. roadmap-parser.cjs getMilestonePhaseFilter → isDirInMilestone (hyphenated mode)
|
|
* 6. phase-id.cjs BRACKET_PHASE_TOKEN_SOURCE (slug-adjacent position only —
|
|
* see the divergence block at the foot of this file)
|
|
*/
|
|
|
|
const { test, describe } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
|
|
const phaseId = require('../gsd-core/bin/lib/phase-id.cjs');
|
|
const validate = require('../gsd-core/bin/lib/validate.cjs');
|
|
const coreUtils = require('../gsd-core/bin/lib/core-utils.cjs');
|
|
const { getMilestonePhaseFilter } = require('../gsd-core/bin/lib/roadmap-parser.cjs');
|
|
const { createTempProject, cleanup } = require('./helpers.cjs');
|
|
|
|
// The shared digit-width corpus. `absorbed` is stated independently of the
|
|
// implementation (it is the LOCKED POLICY, not a mirror of the regex): a
|
|
// continuation is exactly the 2-digit zero-padded form getPhaseDirFromPhaseId
|
|
// emits. 1-digit is a slug word (#2043); ≥3-digit is a slug word (#2232 — a
|
|
// year/count/version).
|
|
const WIDTH_CORPUS = [
|
|
{ width: 1, seg: '6', absorbed: false, note: '#2043 single-digit slug word' },
|
|
{ width: 2, seg: '02', absorbed: true, note: 'the zero-padded sub-phase — the cap' },
|
|
{ width: 3, seg: '100', absorbed: false, note: '#2232 limit+1 (policy: ≥100 out of grammar)' },
|
|
{ width: 4, seg: '2026', absorbed: false, note: '#2232 the reported case (a year)' },
|
|
{ width: 5, seg: '12345', absorbed: false, note: '#2232 far side of the cap' },
|
|
];
|
|
|
|
describe('#2232 continuation-grammar parity — owner vs. corpus', () => {
|
|
test('the owner (isPhaseContinuationSegment) matches the locked policy', () => {
|
|
for (const { seg, absorbed, note } of WIDTH_CORPUS) {
|
|
assert.strictEqual(
|
|
phaseId.isPhaseContinuationSegment(seg),
|
|
absorbed,
|
|
`isPhaseContinuationSegment(${JSON.stringify(seg)}) must be ${absorbed} — ${note}`,
|
|
);
|
|
}
|
|
});
|
|
|
|
test('PHASE_CONTINUATION_SEGMENT_SOURCE is exported and is the exactly-2 grammar', () => {
|
|
assert.strictEqual(typeof phaseId.PHASE_CONTINUATION_SEGMENT_SOURCE, 'string');
|
|
// Anchored at both ends so a consuming site can embed it verbatim.
|
|
const re = new RegExp(`^${phaseId.PHASE_CONTINUATION_SEGMENT_SOURCE}$`);
|
|
assert.ok(re.test('02'), 'the 2-digit form must match');
|
|
assert.ok(!re.test('2026'), 'a 4-digit run must not match');
|
|
assert.ok(!re.test('6'), 'a 1-digit run must not match');
|
|
});
|
|
});
|
|
|
|
describe('#2232 continuation-grammar parity — every consuming surface agrees', () => {
|
|
for (const { seg, absorbed, note } of WIDTH_CORPUS) {
|
|
test(`width ${seg.length} (${JSON.stringify(seg)}): all surfaces agree absorbed=${absorbed} — ${note}`, () => {
|
|
const owner = phaseId.isPhaseContinuationSegment(seg);
|
|
assert.strictEqual(owner, absorbed, 'precondition: owner matches policy');
|
|
|
|
// ── Surface 1: extractPhaseToken ────────────────────────────────────
|
|
const dir = `14-${seg}-photos-performance`;
|
|
assert.strictEqual(
|
|
phaseId.extractPhaseToken(dir) === `14-${seg}`,
|
|
owner,
|
|
`extractPhaseToken(${JSON.stringify(dir)}) diverged from the owner`,
|
|
);
|
|
|
|
// ── Surface 2: validate PHASE_TOKEN_FROM_DIR_RE ─────────────────────
|
|
const reToken = validate.PHASE_TOKEN_FROM_DIR_RE.exec(dir)?.[1];
|
|
assert.strictEqual(
|
|
reToken === `14-${seg}`,
|
|
owner,
|
|
`PHASE_TOKEN_FROM_DIR_RE on ${JSON.stringify(dir)} gave ${JSON.stringify(reToken)} — diverged from the owner`,
|
|
);
|
|
|
|
// ── Surface 3: validate canonicalPlanStem ───────────────────────────
|
|
const stem = `14-${seg}-photos-performance`;
|
|
assert.strictEqual(
|
|
validate.canonicalPlanStem(stem) === `14-${seg}`,
|
|
owner,
|
|
`canonicalPlanStem(${JSON.stringify(stem)}) diverged from the owner`,
|
|
);
|
|
|
|
// ── Surface 4: core-utils extractCanonicalPlanId (paired component) ──
|
|
const planFile = `14-${seg}-photos-performance-PLAN.md`;
|
|
assert.strictEqual(
|
|
coreUtils.extractCanonicalPlanId(planFile) === `14-${seg}`,
|
|
owner,
|
|
`extractCanonicalPlanId(${JSON.stringify(planFile)}) diverged from the owner`,
|
|
);
|
|
|
|
// ── Surface 6: #612 BRACKET_PHASE_TOKEN_SOURCE (slug-adjacent position) ──
|
|
// The bracket run is MM-PP[.SS][-LL]; `-LL` is the only position a slug
|
|
// word can collide with, so it is the position #2232 owns. Same shape as
|
|
// surface 1 with the bracket's extra milestone level: `01-14-<seg>-slug…`
|
|
// puts <seg> at dash-2, exactly where a year over-collected before.
|
|
const bracketDir = `01-14-${seg}-photos-performance`;
|
|
const bracketToken = bracketDir.match(new RegExp(phaseId.BRACKET_PHASE_TOKEN_SOURCE))?.[0];
|
|
assert.strictEqual(
|
|
bracketToken === `01-14-${seg}`,
|
|
owner,
|
|
`BRACKET_PHASE_TOKEN_SOURCE on ${JSON.stringify(bracketDir)} collected ` +
|
|
`${JSON.stringify(bracketToken)} — diverged from the owner at the slug-adjacent position`,
|
|
);
|
|
});
|
|
}
|
|
});
|
|
|
|
// Surface 5 needs a real ROADMAP/STATE on disk, so it gets its own block.
|
|
describe('#2232 continuation-grammar parity — roadmap isDirInMilestone (hyphenated mode)', () => {
|
|
let tmpDir;
|
|
|
|
function writeProject(roadmapLines) {
|
|
tmpDir = createTempProject();
|
|
const planning = path.join(tmpDir, '.planning');
|
|
fs.mkdirSync(planning, { recursive: true });
|
|
fs.writeFileSync(path.join(planning, 'STATE.md'), '---\nmilestone: v1.0\n---\n');
|
|
fs.writeFileSync(path.join(planning, 'ROADMAP.md'), roadmapLines.join('\n'));
|
|
return tmpDir;
|
|
}
|
|
|
|
for (const { seg, absorbed, note } of WIDTH_CORPUS) {
|
|
test(`width ${seg.length} (${JSON.stringify(seg)}): isDirInMilestone agrees — ${note}`, () => {
|
|
// A hyphenated phase id in the roadmap switches the filter into the
|
|
// hyphenated-mode regex — the branch #2043/#2232 both live in.
|
|
writeProject([
|
|
'## v1.0: Current',
|
|
'### Phase 2-01: Alpha',
|
|
'**Goal:** first alpha phase',
|
|
'',
|
|
'### Phase 14: 2026 Photos And Performance',
|
|
'**Goal:** the year-leading slug case',
|
|
]);
|
|
const filter = getMilestonePhaseFilter(tmpDir);
|
|
|
|
// When the segment is NOT absorbed, the dir's token is "14" → matches
|
|
// roadmap Phase 14. When it IS absorbed (width 2), the token is "14-02",
|
|
// which the roadmap does not list → correctly excluded.
|
|
assert.strictEqual(
|
|
filter(`14-${seg}-photos-performance`),
|
|
!absorbed,
|
|
`isDirInMilestone("14-${seg}-photos-performance") diverged from the owner ` +
|
|
`(absorbed=${absorbed} → token ${absorbed ? `"14-${seg}" (not in roadmap)` : '"14" (Phase 14)'})`,
|
|
);
|
|
|
|
// Control: the genuine milestone-prefixed dir always matches.
|
|
assert.strictEqual(filter('02-01-alpha'), true, '02-01-alpha must match Phase 2-01');
|
|
cleanup(tmpDir);
|
|
tmpDir = null;
|
|
});
|
|
}
|
|
});
|
|
|
|
// ─── #612: the DELIBERATE divergence, pinned ────────────────────────────────
|
|
// Surface 6 consumes the owner at the slug-adjacent position (above), but is
|
|
// deliberately WIDER at the other positions. That is a divergence, so per the
|
|
// Generative Fix Divergence rule it gets pinned here rather than left to a
|
|
// comment: if someone later "unifies" the bracket run onto the exactly-2 cap,
|
|
// or re-widens the slug-adjacent position back to `\d+`, one of these fails and
|
|
// points them at the rationale in phase-id.cts.
|
|
//
|
|
// The policy is stated independently of the regex: bracket's non-slug-adjacent
|
|
// positions are DELIMITER-disambiguated (a grammar-required field separator; a
|
|
// dot no slug can contain), not heuristically recognized, so they carry the
|
|
// canonical width toDir emits — while #2232's cap defends the one position that
|
|
// sits against a slug.
|
|
describe('#612 bracket divergence — wider only where the delimiter disambiguates', () => {
|
|
const tokenOf = (s) => s.match(new RegExp(phaseId.BRACKET_PHASE_TOKEN_SOURCE))?.[0];
|
|
|
|
test('the #2232 repro cannot reopen on the bracket path', () => {
|
|
// The review's scenario: roadmap phase "2026 Photos & Performance" at
|
|
// phase 14 → slug leads with a year. The token is the phase, not the year.
|
|
assert.strictEqual(tokenOf('01-14-2026-photos-performance'), '01-14');
|
|
assert.strictEqual(tokenOf('01-14.03-2026-photos-performance'), '01-14.03');
|
|
});
|
|
|
|
test('3+-digit phase and sub-phase — widths toDir emits — stay recognized', () => {
|
|
// Both are rejected by a verbatim exactly-2 cap; both are canonical per
|
|
// CANONICAL_NUMERIC_RE, so under-collecting them would break the read path
|
|
// against ids the emit path produces.
|
|
assert.strictEqual(tokenOf('02-105-slug'), '02-105', '3-digit phase (dash-1)');
|
|
assert.strictEqual(tokenOf('05.100'), '05.100', '3-digit sub-phase (dot)');
|
|
assert.strictEqual(tokenOf('01-2026-photos'), '01-2026', 'a 4-digit phase is unambiguous at dash-1');
|
|
});
|
|
|
|
test('the divergence is bounded: a PLAN >=100 is out of the grammar (#2232 policy verbatim)', () => {
|
|
// The accepted trade-off. Stated as a test so it is a decision on record,
|
|
// not an accident: the slug-adjacent position cannot be widened without
|
|
// reopening the year collision.
|
|
assert.strictEqual(tokenOf('02-05-100'), '02-05', 'a 3-digit plan is not absorbed');
|
|
assert.strictEqual(tokenOf('02-05-01'), '02-05-01', 'a canonical 2-digit plan is absorbed');
|
|
});
|
|
|
|
test('an over-padded field is not canonical, so it is not collected', () => {
|
|
// `014` matches neither canonical branch (leading zero + 3 digits), which is
|
|
// what parsePhaseId rejects too — the read side under-collects rather than
|
|
// inventing a field the parser would refuse.
|
|
assert.strictEqual(tokenOf('01-014-slug'), '01');
|
|
});
|
|
});
|