* fix(#1907): validate per-item shape in isValidReport so adapter garbage fails closed isValidReport checked only the container (items is-array, coverage scalars), so a report like {items:[{}]} sailed through runProbeCli and stringified as green output — despite the docstring promising it 'fails closed on adapter garbage'. Add a per-item Item-contract guard (requirement_id/category/status + typed nullable fields) so a future adapter that bypasses the analyzeCoverage merge and returns per-item garbage inside a well-shaped envelope fails closed (exit 2) instead of emitting it as valid coverage. analyzeCoverage always emits fully-populated, validated Items, so the 2 shipped adapters are unaffected (verified across the edge/prohibition suites). Refs #1907, epic #1904. * chore(changeset): Fixed fragment for #1910 (isValidReport per-item)
This commit is contained in:
5
.changeset/clever-rams-march.md
Normal file
5
.changeset/clever-rams-march.md
Normal file
@@ -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.
|
||||
@@ -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;
|
||||
|
||||
@@ -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 ──────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user