From c26947808a971b85d6a447e2a35a76a0c5cdf3a4 Mon Sep 17 00:00:00 2001 From: Behruz Nassre Esfahani Date: Thu, 18 Jun 2026 18:59:10 -0700 Subject: [PATCH] feat(#1269): expand same-prefix numeric ID ranges in --phase-req-ids (#1419) * feat(#1269): expand same-prefix numeric ID ranges in --phase-req-ids normalizePhaseReqIds treated a range token like "SEL-01..SEL-03" as a single literal ID, so gap-analysis reported the range string as a missing requirement even when SEL-01/02/03 existed individually. Add a per-token range expander (run AFTER the existing split, preserving the string[] | null | undefined contract): a token matching -..- with identical prefixes, ascending bounds, and EQUAL digit width expands to the individual IDs preserving that width; anything ambiguous stays literal (fail-closed). Differing-width bounds stay literal so the expander never invents a zero-padding the author didn't type, and ranges beyond MAX_PHASE_REQ_RANGE (1000) stay literal as a DoS guard. Co-Authored-By: Claude Opus 4.8 (1M context) * chore(#1269): add changeset for --phase-req-ids range expansion Co-Authored-By: Claude Opus 4.8 (1M context) * chore(#1269): mark changeset docs-exempt (internal flag, no user docs surface) Co-Authored-By: Claude Opus 4.8 (1M context) * test(#1269): isolate the DoS-cap branch with a same-width range; doc nits Review fixes: the AC4 DoS test used REQ-1..REQ-100000, whose differing digit widths trip the width guard before the cap is reached. Use a same-width REQ-0001..REQ-1001 (span 1001 > 1000) so the test actually exercises the cap. Clarify the PHASE_REQ_RANGE_RE capture-group JSDoc and the property-test width assertion comment. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) Co-authored-by: Tom Boucher --- .changeset/wise-geese-roam.md | 7 + src/gap-checker.cts | 72 +++++- ...ug-447-gap-analysis-phase-req-ids.test.cjs | 150 ++++++++++++ tests/gap-checker.property.test.cjs | 220 ++++++++++++++++++ 4 files changed, 445 insertions(+), 4 deletions(-) create mode 100644 .changeset/wise-geese-roam.md create mode 100644 tests/gap-checker.property.test.cjs diff --git a/.changeset/wise-geese-roam.md b/.changeset/wise-geese-roam.md new file mode 100644 index 000000000..a52991619 --- /dev/null +++ b/.changeset/wise-geese-roam.md @@ -0,0 +1,7 @@ +--- +type: Added +pr: 1419 +--- +**`gap-analysis --phase-req-ids` now expands numeric ID ranges** — a same-prefix ascending equal-width range like `SEL-01..SEL-03` expands to `SEL-01, SEL-02, SEL-03` (zero-pad preserved) instead of being treated as one literal ID that gap-analysis then reports as missing. Ambiguous tokens (mismatched prefix, descending, differing width, non-numeric, >1000 span) stay literal. (#1269) + + diff --git a/src/gap-checker.cts b/src/gap-checker.cts index 64ae34493..4994e88fa 100644 --- a/src/gap-checker.cts +++ b/src/gap-checker.cts @@ -179,13 +179,75 @@ function readGate(cwd: string): boolean { return true; } +/** + * Same-prefix ascending numeric range, e.g. `SEL-01..SEL-03`. Both sides must + * share an identical prefix and a numeric suffix. Captures are: + * 1 low prefix, 2 low digits, 3 high prefix (compared to group 1 for equality), 4 high digits. + */ +const PHASE_REQ_RANGE_RE = /^(.+-)(\d+)\.\.(.+-)(\d+)$/; + +/** + * Maximum number of IDs a single range token may expand to. A range whose span + * exceeds this cap stays literal (fail-closed) rather than expanding, guarding + * against pathological input like `X-1..X-100000` ballooning the comparison set. + */ +const MAX_PHASE_REQ_RANGE = 1000; + +/** + * Expand a single `--phase-req-ids` token in place. If it is a valid ascending + * same-prefix numeric range (`-NN..-MM`, identical prefix both + * sides, numeric NN ≤ MM), return the individual IDs `-NN … -MM` + * preserving the bounds' zero-pad width. Anything that does NOT cleanly match a + * valid range stays literal (fail-closed) — returned as a single-element array. + * + * The two numeric bounds must share the same digit width; a range with + * differing widths (e.g. `SEL-9..SEL-11`) is ambiguous (padding to the wider + * width could invent IDs like `SEL-09` that never appear unpadded in + * REQUIREMENTS) and is left literal. A range spanning more than + * MAX_PHASE_REQ_RANGE IDs also stays literal. + */ +function expandPhaseReqIdToken(token: string): string[] { + const m = PHASE_REQ_RANGE_RE.exec(token); + if (!m) return [token]; + const [, prefixLow, lowDigits, prefixHigh, highDigits] = m; + // Fail closed unless the prefixes are identical. + if (prefixLow !== prefixHigh) return [token]; + // Fail closed unless the bounds share an identical digit width. Differing + // widths are ambiguous: padding to the wider width could invent IDs that + // never appear unpadded in REQUIREMENTS. + if (lowDigits.length !== highDigits.length) return [token]; + const low = Number(lowDigits); + const high = Number(highDigits); + // Fail closed on descending ranges (NN > MM). NN == MM is a valid single-element range. + if (!Number.isFinite(low) || !Number.isFinite(high) || low > high) return [token]; + // Fail closed (DoS guard) on ranges spanning more than the cap. + if (high - low + 1 > MAX_PHASE_REQ_RANGE) return [token]; + // Preserve the bounds' (shared) zero-pad width. + const width = lowDigits.length; + const out: string[] = []; + for (let n = low; n <= high; n++) { + out.push(`${prefixLow}${String(n).padStart(width, '0')}`); + } + return out; +} + /** * Normalize a raw `--phase-req-ids` argument into the scoping signal used by * runGapAnalysis (#447). Mirrors §13's null/TBD skip semantics. * - * undefined → flag absent: compare the whole REQUIREMENTS.md (back-compat) - * null | '' | TBD → no requirements mapped to this phase: skip the comparison - * "REQ-01,REQ-02" → restrict the comparison to these IDs + * undefined → flag absent: compare the whole REQUIREMENTS.md (back-compat) + * null | '' | TBD → no requirements mapped to this phase: skip the comparison + * "REQ-01,REQ-02" → restrict the comparison to these IDs + * "SEL-01..SEL-03" → range form: expands in place to SEL-01, SEL-02, SEL-03 (#1269) + * + * Range form (#1269): a list element of the shape `-NN..-MM` + * (identical prefix both sides, identical bound digit width, ascending numeric + * NN ≤ MM) is expanded in place to the individual IDs, preserving the bounds' + * zero-pad width; mixed lists expand in input order. Any element that does not + * cleanly match a valid ascending same-prefix numeric range (mismatched + * prefix, differing bound width, descending, non-numeric, missing bound, or + * spanning more than MAX_PHASE_REQ_RANGE IDs) stays literal — no partial + * expansion, no guessing. * * Tolerates JSON-array-ish input (`["REQ-01","REQ-02"]`) since callers may pass * the roadmap value through verbatim. @@ -199,7 +261,9 @@ function normalizePhaseReqIds(rawVal: unknown): string[] | null | undefined { // Tolerate comma-, space-, or newline-separated lists (callers may pass the // roadmap value verbatim, whose serialization is not guaranteed). const ids = v.split(/[\s,]+/).map(s => s.trim()).filter(Boolean); - return ids.length === 0 ? null : ids; + // Expand range tokens (#1269) per-token AFTER the split, preserving input order. + const expanded = ids.flatMap(expandPhaseReqIdToken); + return expanded.length === 0 ? null : expanded; } function runGapAnalysis(cwd: string, phaseDir: string, options: RunGapAnalysisOptions = {}): GapResult { diff --git a/tests/bug-447-gap-analysis-phase-req-ids.test.cjs b/tests/bug-447-gap-analysis-phase-req-ids.test.cjs index be5a59481..918bf9fc7 100644 --- a/tests/bug-447-gap-analysis-phase-req-ids.test.cjs +++ b/tests/bug-447-gap-analysis-phase-req-ids.test.cjs @@ -23,6 +23,7 @@ const assert = require('node:assert/strict'); const fs = require('fs'); const path = require('path'); const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs'); +const { normalizePhaseReqIds } = require('../gsd-core/bin/lib/gap-checker.cjs'); describe('gap-analysis --phase-req-ids scoping (#447)', () => { let tmpDir; @@ -224,3 +225,152 @@ describe('gap-analysis --phase-req-ids scoping (#447)', () => { 'an unmapped phase reports no requirement gaps (the original #447 bug)'); }); }); + +/** + * #1269: `--phase-req-ids` range syntax (`-NN..-MM`) was treated + * as a literal ID, so a mapped range was reported as a coverage gap even when the + * individual IDs existed. normalizePhaseReqIds now expands a valid ascending + * same-prefix numeric range in place (preserving zero-pad width), and leaves any + * ambiguous/invalid range literal (fail-closed). These unit fixtures are folded + * here (the owning home for --phase-req-ids behavior) rather than a new + * bug-NNNN-* file, per the regression-test-placement policy. + */ +describe('#1269 — normalizePhaseReqIds range expansion', () => { + // ── The core bug: a range token must expand, not stay literal ──────────────── + + test('AC1: range + single ID expands in input order (was the literal-token bug)', () => { + // Pre-fix this returned ['SEL-01..SEL-03','TEST-01'] — the unexpanded range. + assert.deepStrictEqual( + normalizePhaseReqIds('SEL-01..SEL-03,TEST-01'), + ['SEL-01', 'SEL-02', 'SEL-03', 'TEST-01'], + 'a same-prefix ascending range must expand in place, preserving list order'); + }); + + test('AC2: zero-pad width is preserved across the expansion', () => { + assert.deepStrictEqual( + normalizePhaseReqIds('PREFIX-001..PREFIX-003'), + ['PREFIX-001', 'PREFIX-002', 'PREFIX-003']); + }); + + // ── AC3: existing behavior is unchanged ────────────────────────────────────── + + test('AC3: single-ID, comma/space/newline, and JSON-array-ish inputs unchanged', () => { + assert.deepStrictEqual(normalizePhaseReqIds('REQ-01'), ['REQ-01']); + assert.deepStrictEqual(normalizePhaseReqIds('REQ-01,REQ-02'), ['REQ-01', 'REQ-02']); + assert.deepStrictEqual(normalizePhaseReqIds('REQ-01 REQ-02'), ['REQ-01', 'REQ-02']); + assert.deepStrictEqual(normalizePhaseReqIds('REQ-01\nREQ-02'), ['REQ-01', 'REQ-02']); + assert.deepStrictEqual(normalizePhaseReqIds(['REQ-01', 'REQ-02']), ['REQ-01', 'REQ-02']); + assert.strictEqual(normalizePhaseReqIds(undefined), undefined); + assert.strictEqual(normalizePhaseReqIds(null), null); + assert.strictEqual(normalizePhaseReqIds('TBD'), null); + assert.strictEqual(normalizePhaseReqIds(''), null); + }); + + // ── AC4: invalid/ambiguous ranges stay LITERAL (fail-closed) ───────────────── + + test('AC4: mismatched-prefix range stays literal (no partial expansion)', () => { + assert.deepStrictEqual(normalizePhaseReqIds('SEL-01..TEST-03'), ['SEL-01..TEST-03']); + }); + + test('AC4: descending range stays literal', () => { + assert.deepStrictEqual(normalizePhaseReqIds('SEL-03..SEL-01'), ['SEL-03..SEL-01']); + }); + + test('AC4: non-numeric bound stays literal', () => { + assert.deepStrictEqual(normalizePhaseReqIds('SEL-0A..SEL-0C'), ['SEL-0A..SEL-0C']); + }); + + test('AC4: missing bound stays literal', () => { + assert.deepStrictEqual(normalizePhaseReqIds('SEL-01..'), ['SEL-01..']); + assert.deepStrictEqual(normalizePhaseReqIds('..SEL-03'), ['..SEL-03']); + }); + + test('AC4: an invalid range inside a mixed list stays literal while valid ones expand', () => { + assert.deepStrictEqual( + normalizePhaseReqIds('SEL-01..SEL-03,BAD-3..BAD-1'), + ['SEL-01', 'SEL-02', 'SEL-03', 'BAD-3..BAD-1']); + }); + + // ── Boundary fixtures ──────────────────────────────────────────────────────── + + test('boundary: single-element range (NN == MM)', () => { + assert.deepStrictEqual(normalizePhaseReqIds('SEL-02..SEL-02'), ['SEL-02']); + }); + + test('boundary: two-element range (NN == MM-1)', () => { + assert.deepStrictEqual(normalizePhaseReqIds('SEL-01..SEL-02'), ['SEL-01', 'SEL-02']); + }); + + test('boundary: differing zero-pad widths stay literal (fail-closed)', () => { + // Bounds of differing digit width are ambiguous: padding 'SEL-9' to width 2 + // would invent 'SEL-09', which may never appear unpadded in REQUIREMENTS. + // Fail closed — leave the whole token literal rather than guess. + assert.deepStrictEqual( + normalizePhaseReqIds('SEL-9..SEL-11'), + ['SEL-9..SEL-11']); + }); + + test('AC4: a range exceeding MAX_PHASE_REQ_RANGE stays literal (DoS guard)', () => { + // Same-width bounds (both 4 digits) so the differing-width guard does NOT fire + // first; span = 1001 - 1 + 1 = 1001 > MAX_PHASE_REQ_RANGE (1000) → the DoS cap + // is what keeps this literal. Isolates the cap branch from the width check. + const token = 'REQ-0001..REQ-1001'; + assert.deepStrictEqual(normalizePhaseReqIds(token), [token]); + }); + + test('multi-segment prefix with digits is handled (prefix compared verbatim)', () => { + assert.deepStrictEqual( + normalizePhaseReqIds('REQ2-01..REQ2-03'), + ['REQ2-01', 'REQ2-02', 'REQ2-03']); + }); +}); + +/** + * #1269 integration (AC5): the gap-analysis CLI must not flag a mapped range — + * or the IDs it expands to — as missing when those IDs exist in REQUIREMENTS.md. + */ +describe('#1269 — gap-analysis --phase-req-ids range (integration)', () => { + let tmpDir; + let phaseDir; + + function writeRequirements(ids) { + const lines = ids.map((id, i) => `- [ ] **${id}** Requirement ${i + 1} description`); + fs.writeFileSync(path.join(tmpDir, '.planning', 'REQUIREMENTS.md'), + `# Requirements\n\n${lines.join('\n')}\n`); + } + function writePlan(name, body) { + fs.writeFileSync(path.join(phaseDir, `${name}-PLAN.md`), body); + } + function reqRows(out) { + return out.rows.filter(r => r.source === 'REQUIREMENTS.md').map(r => r.item); + } + + beforeEach(() => { + tmpDir = createTempProject(); + phaseDir = path.join(tmpDir, '.planning', 'phases', '01-test'); + fs.mkdirSync(phaseDir, { recursive: true }); + const r = runGsdTools('config-ensure-section', tmpDir); + assert.ok(r.success, `config-ensure-section failed: ${r.error}`); + }); + + afterEach(() => cleanup(tmpDir)); + + test('AC5: a mapped range is expanded and not flagged as missing when the IDs exist', () => { + writeRequirements(['SEL-01', 'SEL-02', 'SEL-03', 'TEST-01', 'OTHER-09']); + // The plan addresses each expanded SEL id and TEST-01. + writePlan('01', '# Plan\n\nImplements SEL-01, SEL-02, SEL-03, and TEST-01.\n'); + + const r = runGsdTools( + ['gap-analysis', '--phase-dir', phaseDir, '--phase-req-ids', 'SEL-01..SEL-03,TEST-01'], tmpDir); + assert.ok(r.success, r.error); + const out = JSON.parse(r.output); + + assert.deepStrictEqual(reqRows(out).sort(), ['SEL-01', 'SEL-02', 'SEL-03', 'TEST-01'], + 'the range expands to individual SEL IDs; the literal range token must NOT appear, and OTHER-09 (unmapped) is excluded'); + // The literal range token must never surface as a missing row. + assert.ok(!out.rows.some(x => x.item.includes('..')), + 'no range-literal row (e.g. "SEL-01..SEL-03") may be reported'); + assert.strictEqual(out.counts.uncovered, 0, + 'all expanded IDs exist in REQUIREMENTS.md and are covered — zero gaps'); + }); +}); diff --git a/tests/gap-checker.property.test.cjs b/tests/gap-checker.property.test.cjs new file mode 100644 index 000000000..4c07d7359 --- /dev/null +++ b/tests/gap-checker.property.test.cjs @@ -0,0 +1,220 @@ +'use strict'; + +/** + * Property-based tests for normalizePhaseReqIds range expansion (#1269). + * + * Module: gsd-core/bin/lib/gap-checker.cjs + * Exported: normalizePhaseReqIds(rawVal) + * + * Range form (#1269): a `--phase-req-ids` list element of the shape + * `-NN..-MM` (identical prefix both sides, identical bound digit + * width, ascending numeric NN ≤ MM) expands in place to the individual IDs, + * preserving the bounds' zero-pad width; ambiguous/invalid ranges stay literal + * (fail-closed). + * + * Properties tested: + * (a) valid ascending same-prefix, same-width range → length == MM-NN+1, all + * elements share the prefix, suffixes are strictly monotonic NN..MM, + * width preserved + * (b) NN == MM → single-element expansion equal to the (re-padded) bound + * (c) literal preservation: a non-range token round-trips unchanged + * (d) fail-closed: descending and mismatched-prefix ranges stay literal + * (d3) fail-closed: differing-width bounds stay literal + * (d4) fail-closed: non-numeric bounds stay literal + * (d5) fail-closed: missing left/right bound stays literal + * (d6) fail-closed: multi-dot tokens stay literal + * (e) never throws on arbitrary string input + * + * Lives in a sibling *.property.test.cjs file (the established property-test + * convention). Its effective prefix `gap-checker.property` does not match the + * `gap-checker` production prefix, so it does not count against the per-module + * test-file cap; the unit/integration fixtures are folded into + * bug-447-gap-analysis-phase-req-ids.test.cjs instead. + */ + +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const fc = require('./helpers/fast-check-setup.cjs'); + +const { normalizePhaseReqIds } = require('../gsd-core/bin/lib/gap-checker.cjs'); + +// A safe prefix that always ends in '-', contains no whitespace, commas, +// brackets, quotes, parens, or dots (those are stripped/split by the +// normalizer), and never collides with the null/TBD/none sentinels. +const prefixArb = fc + .stringMatching(/^[A-Za-z][A-Za-z0-9]{0,5}$/) + .filter(s => !/^(null|tbd|none)$/i.test(s)) + .map(s => `${s}-`); + +const widthArb = fc.integer({ min: 1, max: 4 }); + +function pad(n, width) { + return String(n).padStart(width, '0'); +} + +describe('#1269 normalizePhaseReqIds — range expansion properties', () => { + test('(a) valid ascending same-prefix, same-width range expands to MM-NN+1 monotonic same-prefix IDs', () => { + fc.assert(fc.property( + prefixArb, + fc.integer({ min: 0, max: 50 }), + fc.integer({ min: 0, max: 50 }), + widthArb, + (prefix, a, b, w) => { + const lo = Math.min(a, b); + const hi = Math.max(a, b); + // Both bounds share width w; choose w wide enough to hold hi so neither + // bound is truncated and both render at the SAME digit width. + const width = Math.max(w, String(hi).length); + const loStr = pad(lo, width); + const hiStr = pad(hi, width); + const token = `${prefix}${loStr}..${prefix}${hiStr}`; + + const result = normalizePhaseReqIds(token); + + // length == MM - NN + 1 + assert.strictEqual(result.length, hi - lo + 1, `length for ${token}`); + // all elements share the prefix + for (const id of result) { + assert.ok(id.startsWith(prefix), `${id} must start with ${prefix}`); + } + // suffixes are strictly monotonic NN..MM, each padded to the shared width + result.forEach((id, i) => { + const expectedNum = lo + i; + assert.strictEqual(id, `${prefix}${pad(expectedNum, width)}`, + `element ${i} of ${token}`); + }); + }, + )); + }); + + test('(d3) differing-width bounds stay literal (fail-closed)', () => { + fc.assert(fc.property( + prefixArb, + fc.integer({ min: 0, max: 50 }), + fc.integer({ min: 0, max: 50 }), + widthArb, + widthArb, + (prefix, a, b, wA, wB) => { + const lo = Math.min(a, b); + const hi = Math.max(a, b); + const loStr = pad(lo, wA); + const hiStr = pad(hi, wB); + // Only exercise the differing-width case here. + fc.pre(loStr.length !== hiStr.length); + const token = `${prefix}${loStr}..${prefix}${hiStr}`; + assert.deepStrictEqual(normalizePhaseReqIds(token), [token]); + }, + )); + }); + + test('(d4) non-numeric bounds stay literal (fail-closed)', () => { + fc.assert(fc.property( + prefixArb, + // A suffix containing at least one non-digit so the bound is non-numeric. + fc.stringMatching(/^[0-9]*[A-Za-z][0-9A-Za-z]*$/), + fc.stringMatching(/^[0-9]*[A-Za-z][0-9A-Za-z]*$/), + (prefix, sLo, sHi) => { + const token = `${prefix}${sLo}..${prefix}${sHi}`; + assert.deepStrictEqual(normalizePhaseReqIds(token), [token]); + }, + )); + }); + + test('(d5) missing left or right bound stays literal (fail-closed)', () => { + fc.assert(fc.property( + prefixArb, + fc.integer({ min: 0, max: 99 }), + widthArb, + fc.boolean(), + (prefix, n, w, dropLeft) => { + const bound = `${prefix}${pad(n, w)}`; + const token = dropLeft ? `..${bound}` : `${bound}..`; + assert.deepStrictEqual(normalizePhaseReqIds(token), [token]); + }, + )); + }); + + test('(d6) multi-dot tokens stay literal (fail-closed)', () => { + fc.assert(fc.property( + prefixArb, + fc.integer({ min: 0, max: 50 }), + fc.integer({ min: 0, max: 50 }), + fc.integer({ min: 0, max: 50 }), + widthArb, + (prefix, a, b, c, w) => { + const token = `${prefix}${pad(a, w)}..${prefix}${pad(b, w)}..${prefix}${pad(c, w)}`; + assert.deepStrictEqual(normalizePhaseReqIds(token), [token]); + }, + )); + }); + + test('(b) NN == MM expands to a single re-padded bound', () => { + fc.assert(fc.property( + prefixArb, + fc.integer({ min: 0, max: 99 }), + widthArb, + (prefix, n, w) => { + const nStr = pad(n, w); + const token = `${prefix}${nStr}..${prefix}${nStr}`; + const result = normalizePhaseReqIds(token); + // Expected width is nStr.length, not w: when n has more digits than w + // (e.g. n=99, w=1), pad() returns the un-truncated "99", so the emitted + // ID preserves the bound's actual width — which is what the range parser does. + assert.deepStrictEqual(result, [`${prefix}${pad(n, nStr.length)}`]); + }, + )); + }); + + test('(c) a non-range single token round-trips unchanged (literal preservation)', () => { + fc.assert(fc.property( + prefixArb, + fc.integer({ min: 0, max: 999 }), + widthArb, + (prefix, n, w) => { + const id = `${prefix}${pad(n, w)}`; // a plain ID, no '..' + assert.deepStrictEqual(normalizePhaseReqIds(id), [id]); + }, + )); + }); + + test('(d) descending range stays literal (fail-closed)', () => { + fc.assert(fc.property( + prefixArb, + fc.integer({ min: 1, max: 50 }), + fc.integer({ min: 1, max: 50 }), + widthArb, + (prefix, a, b, w) => { + fc.pre(a !== b); + const hi = Math.max(a, b); + const lo = Math.min(a, b); + // Deliberately put the larger bound first → descending → must stay literal. + const token = `${prefix}${pad(hi, w)}..${prefix}${pad(lo, w)}`; + assert.deepStrictEqual(normalizePhaseReqIds(token), [token]); + }, + )); + }); + + test('(d2) mismatched-prefix range stays literal (fail-closed)', () => { + fc.assert(fc.property( + prefixArb, + prefixArb, + fc.integer({ min: 0, max: 50 }), + fc.integer({ min: 0, max: 50 }), + widthArb, + (p1, p2, a, b, w) => { + fc.pre(p1 !== p2); + const lo = Math.min(a, b); + const hi = Math.max(a, b); + const token = `${p1}${pad(lo, w)}..${p2}${pad(hi, w)}`; + assert.deepStrictEqual(normalizePhaseReqIds(token), [token]); + }, + )); + }); + + test('(e) never throws on arbitrary string input', () => { + fc.assert(fc.property(fc.string(), (s) => { + // Either a valid normalized value or null — but never an exception. + assert.doesNotThrow(() => normalizePhaseReqIds(s)); + })); + }); +});