test(#612): M-NN phase-id collision characterization (PR-0 anchor)

The "prove the defect first" artifact the #612 approval requires. Green
current-behavior characterization (zero behavior change) asserting that the
milestone-prefixed grammar collapses a fully-specified 4-tuple token to a bare
integer:

  normalizePhaseName('2-01.02-01') === '02'   (the anchor)
  normalizePhaseName('10-02.03-04') === '10'  (same failure class)

plus the defect boundary (collapse appears only when milestone+phase+subphase+
plan coexist) and the cross-subsystem '02-04' semantic ambiguity (asserted as a
non-collapsing current output, a distinct class from the parse collapse).

Behavioral only, no source-grep. Locks the current known-imperfect behavior as
documentation of the ambiguity; to be superseded when PR-1 lands the bracket
grammar behind phase_id_convention: 'bracket'. Named adr-612-* (maps to no prod
module) so it clears the lint-test-file-count 2-file cap on the phase-id module
and the lint-regression-test-names bug-* ban.

See docs/adr/612-bracket-phase-id-convention.md, Decision 3.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Brandon Higgins
2026-07-11 09:57:50 -06:00
parent 8cfbdf167f
commit b0fe4d8aa6

View File

@@ -0,0 +1,129 @@
'use strict';
/**
* Characterization test for the M-NN phase-ID collision — the PR-0 anchor
* requested in the #612 (bracket phase-ID convention) approval.
*
* See docs/adr/612-bracket-phase-id-convention.md, Decision 3.
*
* This test captures the CURRENT behavior of `normalizePhaseName`
* (src/phase-id.cts:66 → gsd-core/bin/lib/phase-id.cjs). It is NOT a bug the
* PR fixes — PR-0 is zero behavior change. It "proves the defect first":
* a fully-specified milestone/phase/subphase/plan token silently collapses to
* a bare two-digit integer under the milestone-prefixed (M-NN) grammar, because
* the hyphen is overloaded (milestone↔phase in one position, phase↔plan in
* another) and the anchored milestone regex cannot consume a trailing plan
* hyphen that follows a subphase dot.
*
* Mechanism (both regexes are in normalizePhaseName):
* - Anchored milestone regex (src/phase-id.cts:71):
* /^(\d+)((?:-\d+)+)([A-Z]?(?:\.\d+)*)$/i
* On "2-01.02-01" it captures 2, then "-01", then ".02"; the trailing "-01"
* (the plan) is not expressible as (?:\.\d+)* so the `$` anchor fails and
* the match is rejected.
* - Unanchored numeric fallback (src/phase-id.cts:79):
* /^(\d+)([A-Z])?((?:\.\d+)*)/i
* With no `$`, it matches only the leading "2" and zero-pads to "02";
* everything after the leading integer is silently dropped.
*
* These assertions are the byte-identical current-behavior contract. They are
* expected to be REPLACED — not merely to keep passing — when PR-1 lands the
* bracket grammar (parsePhaseId/renderPhaseId/toDir) behind
* `phase_id_convention: 'bracket'`, at which point a full bracket token
* (`GSD.02-05.03-01`) parses to exactly one tuple and the collapse no longer
* occurs on the bracket path. Until then, this file documents the ambiguity.
*
* All assertions are BEHAVIORAL (call the exported function, assert its typed
* output). No source-grep.
*/
const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
const phaseId = require('../gsd-core/bin/lib/phase-id.cjs');
const { normalizePhaseName } = phaseId;
const fc = require('fast-check');
describe('ADR-612 PR-0 — M-NN phase-ID collision (current-behavior characterization)', () => {
test('the full 4-tuple token "2-01.02-01" collapses to bare "02" (the anchor defect)', () => {
// Intended meaning: milestone 2, phase 01, subphase 02, plan 01.
// The anchored milestone regex rejects the trailing plan hyphen after ".02",
// so control falls to the unanchored fallback, which keeps only the leading
// "2" and zero-pads it. A fully-specified identity collapses to bare "02".
assert.equal(normalizePhaseName('2-01.02-01'), '02');
// It did NOT round-trip the input: there is no single deterministic 4-tuple
// a parser can return under M-NN without out-of-band convention metadata.
assert.notEqual(normalizePhaseName('2-01.02-01'), '02-01.02-01');
});
test('the collapse is a general class, not a single case: "10-02.03-04" collapses to "10"', () => {
// Same failure mode: the trailing "-04" (plan) after ".03" (subphase) kills
// the anchored milestone match; the fallback keeps only "10".
assert.equal(normalizePhaseName('10-02.03-04'), '10');
});
test('the collapse appears ONLY when all four dimensions coexist (defect boundary)', () => {
// milestone + phase — anchored milestone regex matches, no collapse.
assert.equal(normalizePhaseName('2-01'), '02-01');
// + subphase (dot tail) — anchored regex still matches, no collapse.
assert.equal(normalizePhaseName('2-01.02'), '02-01.02');
// + plan (a hyphen after the subphase dot) — anchor fails, token collapses.
assert.equal(normalizePhaseName('2-01.02-01'), '02');
});
test('"02-04" is a cross-subsystem semantic ambiguity, not a parse collapse', () => {
// normalizePhaseName reads "02-04" as milestone 02 / phase 04 and returns it
// intact — this token does NOT collapse.
assert.equal(normalizePhaseName('02-04'), '02-04');
// The SAME string is the phase-02 / plan-04 token in plan-file notation
// ({padded_phase}-{NN}-PLAN.md). Both readings are valid productions; the
// token alone cannot tell a resolver which subsystem is asking. This is a
// genuine ambiguity but a different class than the collapse above.
});
});
/**
* Property generalization of the collapse (requested by the PR-2181 review:
* "covers the collapse with literal examples only; no fast-check property test
* generalizing the collision class"). The literal cases above assert two points
* on the class; this proves the collapse is the CLASS behavior, not cherry-
* picked examples: for EVERY fully-specified 4-dimension M-NN token
* `${m}-${pp}.${ss}-${ll}` (m: 1-99 integer, no leading zeros; pp, ss, ll:
* zero-padded 2-digit 01-99), normalizePhaseName collapses it to the bare
* zero-padded milestone `String(m).padStart(2, '0')`.
*
* Still GREEN-ONLY: it characterizes CURRENT behavior exactly (same contract as
* the literal cases). It is expected to be REPLACED, not merely kept passing,
* when PR-1 lands the bracket grammar (parsePhaseId/renderPhaseId/toDir) behind
* `phase_id_convention: 'bracket'`, at which point a full bracket token parses
* to exactly one tuple and the collapse no longer occurs on the bracket path.
*
* numRuns: the house default for the fast-check block in tests/phase-id.test.cjs
* is the implicit 100; this sets 1000 for denser sampling of the ~96M-combination
* class (99^4). The property was additionally verified EXHAUSTIVELY offline over
* all 96,059,601 tokens of the domain (0 failures) — it holds totally, not just
* on the sampled draws, so no domain narrowing was needed.
*/
describe('ADR-612 PR-0 — M-NN collapse is the class, not the examples (fast-check property)', () => {
const pad2 = (n) => String(n).padStart(2, '0');
test('every 4-tuple `${m}-${pp}.${ss}-${ll}` collapses to bare `String(m).padStart(2,"0")`', () => {
fc.assert(
fc.property(
fc.integer({ min: 1, max: 99 }), // milestone m — leading integer, no leading zeros
fc.integer({ min: 1, max: 99 }), // phase pp — rendered zero-padded 2-digit
fc.integer({ min: 1, max: 99 }), // subphase ss — rendered zero-padded 2-digit
fc.integer({ min: 1, max: 99 }), // plan ll — rendered zero-padded 2-digit
(m, pp, ss, ll) => {
// The trailing plan hyphen after the subphase dot kills the anchored
// milestone regex (src/phase-id.cts:71); the unanchored fallback
// (src/phase-id.cts:79) keeps only the leading integer, zero-padded.
const token = `${m}-${pad2(pp)}.${pad2(ss)}-${pad2(ll)}`;
return normalizePhaseName(token) === String(m).padStart(2, '0');
},
),
{ numRuns: 1000 },
);
});
});