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:
129
tests/adr-612-collision-characterization.test.cjs
Normal file
129
tests/adr-612-collision-characterization.test.cjs
Normal 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 },
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user