diff --git a/.changeset/clever-rams-march.md b/.changeset/clever-rams-march.md new file mode 100644 index 000000000..3912be397 --- /dev/null +++ b/.changeset/clever-rams-march.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 1910 +--- +Fixed: probe-core's runProbeCli now fails closed on per-item adapter garbage inside a well-shaped report envelope, matching its documented 'fails closed on adapter garbage' contract. diff --git a/src/probe-core.cts b/src/probe-core.cts index 2ebd8a641..aca8b211f 100644 --- a/src/probe-core.cts +++ b/src/probe-core.cts @@ -96,16 +96,42 @@ function errMessage(e: unknown): string { return e instanceof Error ? e.message : String(e); } +/** + * Structural guard for ONE item of the report `items[]` — enforces the `Item` contract (#1907). + * `analyzeCoverage` always emits fully-populated, category/status-validated Items, so this never + * rejects legitimate output; it catches an adapter that bypasses the merge and hands back per-item + * garbage (e.g. `items:[{}]`) inside a well-shaped envelope — which the container-only guard let + * sail through as green output despite the fail-closed docstring below. + */ +function isValidItem(item: unknown): item is Item { + if (item == null || typeof item !== 'object') return false; + const i = item as { + requirement_id?: unknown; category?: unknown; status?: unknown; + verification?: unknown; resolution?: unknown; reason?: unknown; probe?: unknown; + }; + if (typeof i.requirement_id !== 'string' || !i.requirement_id.trim()) return false; + if (typeof i.category !== 'string' || !i.category.trim()) return false; + if (!VALID_STATUS.includes(i.status as Status)) return false; + if (typeof i.probe !== 'string') return false; + // The three nullable fields must be a string or null — never some other type. + if (i.verification !== null && typeof i.verification !== 'string') return false; + if (i.resolution !== null && typeof i.resolution !== 'string') return false; + if (i.reason !== null && typeof i.reason !== 'string') return false; + return true; +} + /** * Structural guard for the report an adapter's `analyze` returns. The scaffold types `analyze` * loosely (it runs over JSON-parsed input the adapter `as`-casts), so a future adapter (#644) * that forgets to validate inside its closure could hand back a malformed object. Rather than - * stringify garbage as green output, `runProbeCli` checks the report shape and fails closed. + * stringify garbage as green output, `runProbeCli` checks the report shape — container AND every + * item — and fails closed. */ function isValidReport(report: unknown): report is CoverageReport { if (report == null || typeof report !== 'object') return false; const r = report as { items?: unknown; coverage?: unknown }; if (!Array.isArray(r.items)) return false; + if (!r.items.every(isValidItem)) return false; const c = r.coverage as | { applicable?: unknown; resolved?: unknown; unresolved?: unknown; byVerification?: unknown } | undefined; diff --git a/tests/probe-core.test.cjs b/tests/probe-core.test.cjs index 75f6a2ebb..1331af825 100644 --- a/tests/probe-core.test.cjs +++ b/tests/probe-core.test.cjs @@ -332,6 +332,31 @@ describe('probe-core: runProbeCli (generic I/O scaffold, injected io)', () => { }); assert.deepEqual(JSON.parse(out), report); }); + test('per-item garbage inside a well-shaped envelope (items:[{}]) → exits 2, writes nothing (#1907 — matches the "fails closed on adapter garbage" docstring)', () => { + // The container is well-shaped (items is an array, coverage has the right scalars) but an item + // is an empty object. Before #1907 this sailed through and stringified as green output, despite + // the docstring promising it fails closed. A future adapter (#644-class) returning per-item + // garbage inside a good envelope must fail closed, not emit garbage as valid coverage. + let code; let out = ''; + pc.runProbeCli(() => ({ items: [{}], coverage: { applicable: 0, resolved: 0, unresolved: 0, byVerification: {} } }), { + usage: 'demo', argv: ['node', 'demo', '/req.json'], + readFile: () => '[]', write: (s) => { out += s; }, writeErr: () => {}, exit: (c) => { code = c; }, + }); + assert.equal(code, 2); + assert.equal(out, ''); + }); + test('a report with a fully-formed item still writes (per-item validation does not over-reject legitimate coverage)', () => { + let out = ''; + const good = { + items: [{ requirement_id: 'R1', category: 'empty', status: 'unresolved', verification: null, resolution: null, reason: null, probe: 'edge' }], + coverage: { applicable: 1, resolved: 0, unresolved: 1, byVerification: {} }, + }; + pc.runProbeCli(() => good, { + usage: 'demo', argv: ['node', 'demo', '/req.json'], + readFile: () => '[]', write: (s) => { out += s; }, exit: () => {}, + }); + assert.deepEqual(JSON.parse(out), good); + }); }); // ─── CHK-07 (#1278): descriptor-less backward-compat byte-stability ──────────────────────────────