proof-harness 0.1.0-next.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/COMPATIBILITY.md +26 -0
- package/LICENSE +201 -0
- package/PROOF_SDK_CONTRACT.md +1255 -0
- package/README.md +60 -0
- package/cli/config.mjs +116 -0
- package/cli/engines/generate_proof_action_registry.mjs +271 -0
- package/cli/engines/parse_proof_schema.mjs +457 -0
- package/cli/engines/proof_coverage.mjs +871 -0
- package/cli/engines/proof_drift.mjs +980 -0
- package/cli/engines/proof_module_check.mjs +498 -0
- package/cli/engines/proof_mutation_check.mjs +1084 -0
- package/cli/engines/proof_mutation_inventory.mjs +508 -0
- package/cli/engines/proof_source_mutation.mjs +35 -0
- package/cli/engines/proof_verify.mjs +620 -0
- package/cli/engines/scan_module_meta.mjs +352 -0
- package/cli/engines/scan_proof_capabilities.mjs +359 -0
- package/cli/proof-harness.mjs +107 -0
- package/dist/action-types-C3OmHRyH.d.ts +26 -0
- package/dist/fixture-B-dmnG5G.d.ts +105 -0
- package/dist/mission-types-Dr5rBHYO.d.ts +299 -0
- package/dist/node.d.ts +79 -0
- package/dist/node.js +553 -0
- package/dist/node.js.map +1 -0
- package/dist/playwright.d.ts +433 -0
- package/dist/playwright.js +1824 -0
- package/dist/playwright.js.map +1 -0
- package/dist/portable-vocabulary.d.ts +86 -0
- package/dist/portable-vocabulary.js +43 -0
- package/dist/portable-vocabulary.js.map +1 -0
- package/dist/server.d.ts +55 -0
- package/dist/server.js +376 -0
- package/dist/server.js.map +1 -0
- package/dist/shared.d.ts +169 -0
- package/dist/shared.js +121 -0
- package/dist/shared.js.map +1 -0
- package/dist/trace-types-DkIzq-l9.d.ts +333 -0
- package/package.json +109 -0
- package/proof-consumer-fixtures/README.md +60 -0
- package/proof-consumer-fixtures/aggregates/M-CONFORMANCE-FAIL.json +112 -0
- package/proof-consumer-fixtures/aggregates/M-CONFORMANCE-PASS.json +101 -0
- package/proof-consumer-fixtures/baseline/failing/widgets-tenant-isolation.json +46 -0
- package/proof-consumer-fixtures/baseline/passing/widgets-tenant-isolation.json +45 -0
- package/proof-consumer-fixtures/corpus.json +102 -0
- package/proof-consumer-fixtures/duplicates/first/widgets-tenant-isolation.json +12 -0
- package/proof-consumer-fixtures/duplicates/second/widgets-tenant-isolation.json +12 -0
- package/proof-consumer-fixtures/inputs/capabilities.json +4 -0
- package/proof-consumer-fixtures/inputs/mission.json +20 -0
- package/proof-consumer-fixtures/inputs/schema.json +4 -0
- package/proof-consumer-fixtures/malformed/invalid-trace.json.invalid +4 -0
- package/proof-consumer-fixtures/malformed/wrong-manifest-version.json +11 -0
- package/proof-consumer-fixtures/malformed/wrong-trace-shape.json +8 -0
- package/proof-consumer-fixtures/mutations/MUT-WIDGETS-LEAK/mutation.json +17 -0
- package/proof-consumer-fixtures/mutations/MUT-WIDGETS-LEAK/widgets-tenant-isolation.json +50 -0
|
@@ -0,0 +1,871 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// ---------------------------------------------------------------------------
|
|
3
|
+
// proof_coverage.mjs
|
|
4
|
+
//
|
|
5
|
+
// What is protected, and what do we merely believe?
|
|
6
|
+
//
|
|
7
|
+
// Every other check in this system is scoped to one mission: the manifest says
|
|
8
|
+
// what this PR must prove, and the validator grades the PR against its own
|
|
9
|
+
// homework. That leaves a blind spot with a specific shape — a mission adds a
|
|
10
|
+
// new scoped table, never asks for tenant isolation on it, CI goes green, and
|
|
11
|
+
// nothing in the pipeline is capable of noticing that a new route can leak
|
|
12
|
+
// across tenants.
|
|
13
|
+
//
|
|
14
|
+
// This script closes that by grading the whole schema instead of one mission.
|
|
15
|
+
// It joins three artifacts that are regenerated on every run:
|
|
16
|
+
//
|
|
17
|
+
// .proof/schema.json every table + its RLS classification
|
|
18
|
+
// .proof/capabilities.json every action + the invariants it declares
|
|
19
|
+
// .proof/traces/*.json every assertion a proof actually emitted
|
|
20
|
+
//
|
|
21
|
+
// and reports, per table, whether a proof exists for the invariant its
|
|
22
|
+
// classification implies — plus, per action, whether every invariant it declares
|
|
23
|
+
// via `withProof` is backed by an assertion. It also derives a mandatory
|
|
24
|
+
// action-layer requirement when a workspace-id-driven action mutates data
|
|
25
|
+
// through the service role: both a refusal and a working
|
|
26
|
+
// allowed-path control must be present, regardless of `withProof` metadata.
|
|
27
|
+
//
|
|
28
|
+
// IMPORTANT — what a gap does and does not mean:
|
|
29
|
+
// "No proof" is NOT "unprotected". These tables have RLS policies; the
|
|
30
|
+
// classification in schema.json was derived FROM those policies. A gap means
|
|
31
|
+
// the policy is asserted by a migration and never exercised by a test. The
|
|
32
|
+
// honest word is *unproven*, not unprotected.
|
|
33
|
+
//
|
|
34
|
+
// The verdict is computed evidence measured against authored intent:
|
|
35
|
+
// - evidence comes from the traces, and is never committed;
|
|
36
|
+
// - intent lives in .proof/coverage-policy.json, which IS committed, because
|
|
37
|
+
// an accepted gap is a decision a human made, not a fact about a run.
|
|
38
|
+
//
|
|
39
|
+
// With --strict, an unaccepted gap fails the build. That makes the policy file
|
|
40
|
+
// a ratchet: today's gaps are listed and pass, but the next scoped table added
|
|
41
|
+
// without a proof fails CI until someone either proves it or writes down, in a
|
|
42
|
+
// reviewable diff, why they chose not to.
|
|
43
|
+
//
|
|
44
|
+
// Usage:
|
|
45
|
+
// node scripts/proof_coverage.mjs # report
|
|
46
|
+
// node scripts/proof_coverage.mjs --strict # exit 1 on any unaccepted gap
|
|
47
|
+
// node scripts/proof_coverage.mjs --json # machine-readable report
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
import fs from "node:fs";
|
|
51
|
+
import path from "node:path";
|
|
52
|
+
import { fileURLToPath } from "node:url";
|
|
53
|
+
|
|
54
|
+
// Single source of truth for the helper-identity lists, shared with the
|
|
55
|
+
// mission validators; see portable-vocabulary.mjs for the rationale.
|
|
56
|
+
import {
|
|
57
|
+
TENANT_ISOLATION_ACTION_HELPERS,
|
|
58
|
+
TENANT_ISOLATION_TABLE_HELPERS,
|
|
59
|
+
} from "../../dist/portable-vocabulary.js";
|
|
60
|
+
import { loadProofConfig } from "../config.mjs";
|
|
61
|
+
|
|
62
|
+
const CONFIG = await loadProofConfig();
|
|
63
|
+
process.chdir(CONFIG.rootDir);
|
|
64
|
+
|
|
65
|
+
const SCHEMA_PATH = CONFIG.artifacts.schema;
|
|
66
|
+
const CAPABILITIES_PATH = CONFIG.artifacts.capabilities;
|
|
67
|
+
const TRACES_DIR = CONFIG.artifacts.traces;
|
|
68
|
+
const POLICY_PATH = CONFIG.policies.coverage;
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The invariant each classification implies, and why.
|
|
72
|
+
*
|
|
73
|
+
* Read as: "if the parser concluded a table is reachable this way, then this is
|
|
74
|
+
* the claim a proof has to make about it."
|
|
75
|
+
*/
|
|
76
|
+
const REQUIRED_BY_CLASSIFICATION = {
|
|
77
|
+
workspace_scoped: {
|
|
78
|
+
kind: "tenant_isolation",
|
|
79
|
+
because:
|
|
80
|
+
"reachable by members of a workspace, so another workspace must not read it",
|
|
81
|
+
},
|
|
82
|
+
user_scoped: {
|
|
83
|
+
kind: "tenant_isolation",
|
|
84
|
+
because: "reachable by its owning user, so another user must not read it",
|
|
85
|
+
},
|
|
86
|
+
admin_only: {
|
|
87
|
+
kind: "authorization",
|
|
88
|
+
because:
|
|
89
|
+
"no authenticated policy grants access, so a normal user must be refused",
|
|
90
|
+
},
|
|
91
|
+
service_only: {
|
|
92
|
+
kind: "authorization",
|
|
93
|
+
because:
|
|
94
|
+
"only the service role may touch it, so an authenticated client must be refused",
|
|
95
|
+
},
|
|
96
|
+
// Deliberately absent: `public_read` (readable by design) and `unclassified`
|
|
97
|
+
// (the parser could not determine reachability — reported separately, since a
|
|
98
|
+
// table we cannot classify is a blind spot rather than a gap).
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
// ---------------------------------------------------------------------------
|
|
102
|
+
// Inputs
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
|
|
105
|
+
function readJson(file, { required = true } = {}) {
|
|
106
|
+
if (!fs.existsSync(file)) {
|
|
107
|
+
if (!required) return null;
|
|
108
|
+
console.error(
|
|
109
|
+
`[proof:coverage] ${file} not found.\n` +
|
|
110
|
+
` suggestion: run \`pnpm proof:build\` (and \`pnpm proof:verify\` for traces).`,
|
|
111
|
+
);
|
|
112
|
+
process.exit(2);
|
|
113
|
+
}
|
|
114
|
+
try {
|
|
115
|
+
return JSON.parse(fs.readFileSync(file, "utf8"));
|
|
116
|
+
} catch (err) {
|
|
117
|
+
console.error(`[proof:coverage] ${file} is not valid JSON: ${err.message}`);
|
|
118
|
+
process.exit(2);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Every assertion from every trace, flattened. Steps may nest assertions. */
|
|
123
|
+
function readAssertions() {
|
|
124
|
+
if (!fs.existsSync(TRACES_DIR)) return [];
|
|
125
|
+
const out = [];
|
|
126
|
+
for (const file of fs.readdirSync(TRACES_DIR)) {
|
|
127
|
+
if (!file.endsWith(".json")) continue;
|
|
128
|
+
const parsed = readJson(path.join(TRACES_DIR, file));
|
|
129
|
+
for (const artifact of Array.isArray(parsed) ? parsed : [parsed]) {
|
|
130
|
+
// The aggregate <missionId>.json embeds every per-spec trace; skip it so
|
|
131
|
+
// assertions are not counted twice.
|
|
132
|
+
if (Array.isArray(artifact?.traces)) continue;
|
|
133
|
+
const nested = (artifact?.steps ?? []).flatMap((s) => s.assertions ?? []);
|
|
134
|
+
for (const assertion of [...(artifact?.assertions ?? []), ...nested]) {
|
|
135
|
+
out.push({ ...assertion, proofId: artifact?.proofId ?? null });
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return out;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ---------------------------------------------------------------------------
|
|
143
|
+
// Target resolution
|
|
144
|
+
//
|
|
145
|
+
// Assertion targets are free-form by design, and in practice they take several
|
|
146
|
+
// shapes: `workspace_members`, `workspace_members.insert`,
|
|
147
|
+
// `users.is_super_admin`, `/secret-admin`, `deduct_workspace_credits`,
|
|
148
|
+
// `workspace:removeMember`.
|
|
149
|
+
//
|
|
150
|
+
// Rather than impose a naming convention on every proof (and break targets that
|
|
151
|
+
// missions already reference), resolution is decided by the schema itself: the
|
|
152
|
+
// segment before the first `.` is a table only if schema.json says such a table
|
|
153
|
+
// exists. That makes the join unambiguous without a convention, and it fails in
|
|
154
|
+
// the safe direction — a typo resolves to nothing, so the table it was meant to
|
|
155
|
+
// cover still shows up as a gap rather than being silently marked proven.
|
|
156
|
+
//
|
|
157
|
+
// Anything that does not resolve is reported, so the join is never silent.
|
|
158
|
+
// ---------------------------------------------------------------------------
|
|
159
|
+
|
|
160
|
+
function resolveTable(target, tableNames) {
|
|
161
|
+
const head = String(target).split(".")[0];
|
|
162
|
+
return tableNames.has(head) ? head : null;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function normalizedRole(assertion) {
|
|
166
|
+
if (assertion.role === "control" || assertion.role === "positive_control") {
|
|
167
|
+
return "control";
|
|
168
|
+
}
|
|
169
|
+
return "primary";
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Whether an assertion's origin is trustworthy enough to count as coverage
|
|
174
|
+
* evidence for its kind.
|
|
175
|
+
*
|
|
176
|
+
* `tenant_isolation` claims must carry helper provenance (`emittedBy`), and
|
|
177
|
+
* the stamp is identity-checked, not merely present-checked:
|
|
178
|
+
* `assert.httpResponse` records whatever `kind` its caller passes, so its
|
|
179
|
+
* stamp on an isolation assertion would prove only that an HTTP probe ran.
|
|
180
|
+
* Isolation OF A TABLE is exactly what `assert.tenantIsolation` proves, so
|
|
181
|
+
* table targets accept only it; action-ref targets also accept
|
|
182
|
+
* `assert.authorization`, whose action probes record the cross-tenant
|
|
183
|
+
* invariant they establish. Other kinds accept spec-recorded assertions —
|
|
184
|
+
* bespoke probes (RPC grants, column guards) are legitimate there and remain
|
|
185
|
+
* backstopped by the mutation inventory.
|
|
186
|
+
*/
|
|
187
|
+
function verifiedOrigin(assertion, { isTable }) {
|
|
188
|
+
if (assertion.kind !== "tenant_isolation") return true;
|
|
189
|
+
const allowed = isTable
|
|
190
|
+
? TENANT_ISOLATION_TABLE_HELPERS
|
|
191
|
+
: TENANT_ISOLATION_ACTION_HELPERS;
|
|
192
|
+
return allowed.includes(assertion.emittedBy);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// ---------------------------------------------------------------------------
|
|
196
|
+
// Report
|
|
197
|
+
// ---------------------------------------------------------------------------
|
|
198
|
+
|
|
199
|
+
function buildReport() {
|
|
200
|
+
const schema = readJson(SCHEMA_PATH);
|
|
201
|
+
const capabilities = readJson(CAPABILITIES_PATH);
|
|
202
|
+
const policy = readJson(POLICY_PATH, { required: false }) ?? {
|
|
203
|
+
acceptedGaps: [],
|
|
204
|
+
};
|
|
205
|
+
const assertions = readAssertions();
|
|
206
|
+
|
|
207
|
+
const tableNames = new Set((schema.tables ?? []).map((t) => t.name));
|
|
208
|
+
|
|
209
|
+
// table -> Set(kind) of PASSING PRIMARY assertions. Controls and non-passing
|
|
210
|
+
// assertions are excluded on purpose: a control proves the fixture works, not
|
|
211
|
+
// the claim, and only a primary assertion is evidence of the invariant.
|
|
212
|
+
const provenKinds = new Map();
|
|
213
|
+
const unresolved = new Map();
|
|
214
|
+
// Passing primaries whose origin disqualifies them (see verifiedOrigin).
|
|
215
|
+
// Reported rather than dropped, so the gap they leave behind is explicable.
|
|
216
|
+
const unverifiedOrigin = [];
|
|
217
|
+
|
|
218
|
+
for (const a of assertions) {
|
|
219
|
+
if (normalizedRole(a) !== "primary") continue;
|
|
220
|
+
if (a.passed !== true) continue;
|
|
221
|
+
const resolved = resolveTable(a.target, tableNames);
|
|
222
|
+
if (!verifiedOrigin(a, { isTable: resolved !== null })) {
|
|
223
|
+
unverifiedOrigin.push({
|
|
224
|
+
kind: a.kind,
|
|
225
|
+
target: a.target,
|
|
226
|
+
emittedBy: a.emittedBy ?? null,
|
|
227
|
+
proofId: a.proofId ?? null,
|
|
228
|
+
});
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
const table = resolved;
|
|
232
|
+
if (!table) {
|
|
233
|
+
const key = `${a.kind}:${a.target}`;
|
|
234
|
+
unresolved.set(key, (unresolved.get(key) ?? 0) + 1);
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
if (!provenKinds.has(table)) provenKinds.set(table, new Set());
|
|
238
|
+
provenKinds.get(table).add(a.kind);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const accepted = new Map(
|
|
242
|
+
(policy.acceptedGaps ?? []).map((g) => [`${g.table}:${g.kind}`, g]),
|
|
243
|
+
);
|
|
244
|
+
const usedAcceptances = new Set();
|
|
245
|
+
const acceptedActionGaps = new Map(
|
|
246
|
+
(policy.acceptedActionGaps ?? []).map((gap) => [gap.action, gap]),
|
|
247
|
+
);
|
|
248
|
+
const usedActionAcceptances = new Set();
|
|
249
|
+
|
|
250
|
+
const reviewed = new Map(
|
|
251
|
+
(policy.reviewedUnclassified ?? []).map((r) => [r.table, r]),
|
|
252
|
+
);
|
|
253
|
+
const usedReviews = new Set();
|
|
254
|
+
|
|
255
|
+
const rows = [];
|
|
256
|
+
const unassessable = [];
|
|
257
|
+
|
|
258
|
+
for (const table of [...(schema.tables ?? [])].sort((a, b) =>
|
|
259
|
+
a.name.localeCompare(b.name),
|
|
260
|
+
)) {
|
|
261
|
+
const requirement = REQUIRED_BY_CLASSIFICATION[table.rls_classification];
|
|
262
|
+
if (!requirement) {
|
|
263
|
+
// A table the parser cannot classify is the one case where no requirement
|
|
264
|
+
// can be derived, which means it can never be reported as a gap. Left at
|
|
265
|
+
// that, an unparseable policy would be the cheapest way to disappear from
|
|
266
|
+
// this report entirely — so each one has to be acknowledged by hand.
|
|
267
|
+
if (table.rls_classification === "unclassified") {
|
|
268
|
+
const review = reviewed.get(table.name);
|
|
269
|
+
if (review) usedReviews.add(table.name);
|
|
270
|
+
unassessable.push({
|
|
271
|
+
table: table.name,
|
|
272
|
+
note: review?.note ?? null,
|
|
273
|
+
acknowledged: Boolean(review),
|
|
274
|
+
proof: [...(provenKinds.get(table.name) ?? [])].sort(),
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const proven = provenKinds.get(table.name)?.has(requirement.kind) ?? false;
|
|
281
|
+
const acceptance = accepted.get(`${table.name}:${requirement.kind}`);
|
|
282
|
+
if (!proven && acceptance)
|
|
283
|
+
usedAcceptances.add(`${table.name}:${requirement.kind}`);
|
|
284
|
+
|
|
285
|
+
rows.push({
|
|
286
|
+
table: table.name,
|
|
287
|
+
classification: table.rls_classification,
|
|
288
|
+
requires: requirement.kind,
|
|
289
|
+
because: requirement.because,
|
|
290
|
+
status: proven ? "proven" : acceptance ? "accepted_gap" : "gap",
|
|
291
|
+
acceptedReason: acceptance?.reason ?? null,
|
|
292
|
+
otherProof: [...(provenKinds.get(table.name) ?? [])].sort(),
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// An acceptance for a table that is now proven, or that no longer exists, is
|
|
297
|
+
// stale. Left unreported it would quietly license a future regression.
|
|
298
|
+
const staleAcceptances = (policy.acceptedGaps ?? [])
|
|
299
|
+
.filter((g) => !usedAcceptances.has(`${g.table}:${g.kind}`))
|
|
300
|
+
.map((g) => ({
|
|
301
|
+
...g,
|
|
302
|
+
why: tableNames.has(g.table)
|
|
303
|
+
? "the invariant is now proven — delete the acceptance"
|
|
304
|
+
: "no such table in schema.json — delete or fix the acceptance",
|
|
305
|
+
}));
|
|
306
|
+
|
|
307
|
+
const staleReviews = (policy.reviewedUnclassified ?? [])
|
|
308
|
+
.filter((r) => !usedReviews.has(r.table))
|
|
309
|
+
.map((r) => ({
|
|
310
|
+
...r,
|
|
311
|
+
why: tableNames.has(r.table)
|
|
312
|
+
? "the table is now classified, so a real requirement applies — delete the acknowledgement"
|
|
313
|
+
: "no such table in schema.json — delete or fix the acknowledgement",
|
|
314
|
+
}));
|
|
315
|
+
|
|
316
|
+
const declaredInvariants = (capabilities.capabilities ?? []).filter(
|
|
317
|
+
(c) => (c.invariants ?? []).length > 0,
|
|
318
|
+
);
|
|
319
|
+
|
|
320
|
+
// Action-level claims.
|
|
321
|
+
//
|
|
322
|
+
// `withProof` is voluntary, so it cannot be used to derive what an action
|
|
323
|
+
// OUGHT to prove — omitting it stays free. What it can do is make a
|
|
324
|
+
// declaration cost something: an action that claims an invariant must have a
|
|
325
|
+
// passing primary assertion against `<module>:<name>`, the same ref the
|
|
326
|
+
// scanner writes into capabilities.json.
|
|
327
|
+
//
|
|
328
|
+
// Deliberately no acceptance path here, unlike table gaps. A table's
|
|
329
|
+
// requirement is derived from its policies, so it exists whether anyone likes
|
|
330
|
+
// it or not and sometimes has to be waived. A declared claim is authored, so
|
|
331
|
+
// the honest escape hatch is deleting the claim rather than excusing it.
|
|
332
|
+
const actionRows = [];
|
|
333
|
+
for (const capability of [...declaredInvariants].sort((a, b) =>
|
|
334
|
+
`${a.module}:${a.name}`.localeCompare(`${b.module}:${b.name}`),
|
|
335
|
+
)) {
|
|
336
|
+
const ref = `${capability.module}:${capability.name}`;
|
|
337
|
+
for (const kind of capability.invariants ?? []) {
|
|
338
|
+
const key = `${kind}:${ref}`;
|
|
339
|
+
const proven = unresolved.has(key);
|
|
340
|
+
// Matched refs are removed so they stop being reported as targets that
|
|
341
|
+
// resolved to nothing — they resolved to an action.
|
|
342
|
+
if (proven) unresolved.delete(key);
|
|
343
|
+
actionRows.push({
|
|
344
|
+
action: ref,
|
|
345
|
+
requires: kind,
|
|
346
|
+
status: proven ? "proven" : "gap",
|
|
347
|
+
file: capability.file ?? null,
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// Mandatory service-role action boundaries.
|
|
353
|
+
//
|
|
354
|
+
// This requirement is derived from code + schema, not optional withProof
|
|
355
|
+
// metadata. The scanner reports concrete service-role mutations and whether
|
|
356
|
+
// the user-facing action takes a workspaceId. The workspaceId is the scope
|
|
357
|
+
// signal here: tenant-root tables such as `workspaces` are classified as
|
|
358
|
+
// user_scoped by their owner policy, but a service-role action accepting an
|
|
359
|
+
// arbitrary workspaceId still needs an action-layer cross-tenant probe.
|
|
360
|
+
//
|
|
361
|
+
// A denial alone is not enough. A broken/unregistered action also refuses
|
|
362
|
+
// everyone, so the same action target needs a passing allowed-path control.
|
|
363
|
+
const denialKinds = new Set(["authorization", "tenant_isolation"]);
|
|
364
|
+
const controlKinds = new Set([
|
|
365
|
+
"authorization",
|
|
366
|
+
"tenant_isolation",
|
|
367
|
+
"happy_path",
|
|
368
|
+
]);
|
|
369
|
+
const serviceRoleActionRows = [];
|
|
370
|
+
|
|
371
|
+
for (const capability of [...(capabilities.capabilities ?? [])].sort((a, b) =>
|
|
372
|
+
`${a.module}:${a.name}`.localeCompare(`${b.module}:${b.name}`),
|
|
373
|
+
)) {
|
|
374
|
+
if (
|
|
375
|
+
capability.acceptsWorkspaceId !== true ||
|
|
376
|
+
capability.internalOnly === true
|
|
377
|
+
) {
|
|
378
|
+
continue;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const workspaceMutations = capability.serviceRoleMutations ?? [];
|
|
382
|
+
if (workspaceMutations.length === 0) continue;
|
|
383
|
+
|
|
384
|
+
const action = `${capability.module}:${capability.name}`;
|
|
385
|
+
const denialEvidence = assertions.filter(
|
|
386
|
+
(assertion) =>
|
|
387
|
+
assertion.passed === true &&
|
|
388
|
+
assertion.target === action &&
|
|
389
|
+
normalizedRole(assertion) === "primary" &&
|
|
390
|
+
denialKinds.has(assertion.kind) &&
|
|
391
|
+
verifiedOrigin(assertion, { isTable: false }),
|
|
392
|
+
);
|
|
393
|
+
const controlEvidence = assertions.filter(
|
|
394
|
+
(assertion) =>
|
|
395
|
+
assertion.passed === true &&
|
|
396
|
+
assertion.target === action &&
|
|
397
|
+
normalizedRole(assertion) === "control" &&
|
|
398
|
+
controlKinds.has(assertion.kind),
|
|
399
|
+
);
|
|
400
|
+
const denialProven = denialEvidence.length > 0;
|
|
401
|
+
const controlProven = controlEvidence.length > 0;
|
|
402
|
+
const proven = denialProven && controlProven;
|
|
403
|
+
const acceptance = acceptedActionGaps.get(action);
|
|
404
|
+
if (!proven && acceptance) usedActionAcceptances.add(action);
|
|
405
|
+
|
|
406
|
+
// These targets are now resolved by a derived action requirement.
|
|
407
|
+
for (const kind of denialKinds) unresolved.delete(`${kind}:${action}`);
|
|
408
|
+
|
|
409
|
+
serviceRoleActionRows.push({
|
|
410
|
+
action,
|
|
411
|
+
tables: [...new Set(workspaceMutations.map((m) => m.table))].sort(),
|
|
412
|
+
mutations: workspaceMutations,
|
|
413
|
+
denial: denialProven ? "proven" : "gap",
|
|
414
|
+
control: controlProven ? "proven" : "gap",
|
|
415
|
+
status: proven ? "proven" : acceptance ? "accepted_gap" : "gap",
|
|
416
|
+
acceptedReason: acceptance?.reason ?? null,
|
|
417
|
+
denialProofs: [
|
|
418
|
+
...new Set(denialEvidence.map((a) => a.proofId).filter(Boolean)),
|
|
419
|
+
].sort(),
|
|
420
|
+
controlProofs: [
|
|
421
|
+
...new Set(controlEvidence.map((a) => a.proofId).filter(Boolean)),
|
|
422
|
+
].sort(),
|
|
423
|
+
file: capability.file ?? null,
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
const staleActionAcceptances = (policy.acceptedActionGaps ?? [])
|
|
428
|
+
.filter((gap) => !usedActionAcceptances.has(gap.action))
|
|
429
|
+
.map((gap) => ({
|
|
430
|
+
...gap,
|
|
431
|
+
why: serviceRoleActionRows.some((row) => row.action === gap.action)
|
|
432
|
+
? "the action boundary is now proven — delete the acceptance"
|
|
433
|
+
: "no derived service-role action requirement has this ref — delete or fix the acceptance",
|
|
434
|
+
}));
|
|
435
|
+
|
|
436
|
+
return {
|
|
437
|
+
rows,
|
|
438
|
+
actionRows,
|
|
439
|
+
serviceRoleActionRows,
|
|
440
|
+
unassessable,
|
|
441
|
+
unverifiedOrigin,
|
|
442
|
+
staleReviews,
|
|
443
|
+
unresolved: [...unresolved.entries()].map(([key, count]) => ({
|
|
444
|
+
key,
|
|
445
|
+
count,
|
|
446
|
+
})),
|
|
447
|
+
staleAcceptances,
|
|
448
|
+
staleActionAcceptances,
|
|
449
|
+
capabilityCount: (capabilities.capabilities ?? []).length,
|
|
450
|
+
declaredInvariants,
|
|
451
|
+
assertionCount: assertions.length,
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// ---------------------------------------------------------------------------
|
|
456
|
+
// Output
|
|
457
|
+
// ---------------------------------------------------------------------------
|
|
458
|
+
|
|
459
|
+
// The report is read by a human deciding whether to trust a build, so it is laid
|
|
460
|
+
// out in sections that can be skipped: a heading says what the section grades, a
|
|
461
|
+
// table carries the verdicts, and prose is wrapped and indented under the row it
|
|
462
|
+
// belongs to instead of running off the right edge.
|
|
463
|
+
//
|
|
464
|
+
// `[PROOF_FAIL]` lines are the exception. Those are the machine-readable part of
|
|
465
|
+
// the contract, so each stays on one line with any guidance on an indented
|
|
466
|
+
// `suggestion:` line, matching the format the rest of the SDK emits.
|
|
467
|
+
|
|
468
|
+
const WIDTH = 76;
|
|
469
|
+
|
|
470
|
+
function pad(value, width) {
|
|
471
|
+
return String(value).padEnd(width);
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/** Greedy word wrap. Long unbreakable tokens (paths, refs) are left intact. */
|
|
475
|
+
function wrap(text, indent = "") {
|
|
476
|
+
const width = Math.max(WIDTH - indent.length, 24);
|
|
477
|
+
const lines = [];
|
|
478
|
+
let current = "";
|
|
479
|
+
for (const word of String(text).split(/\s+/).filter(Boolean)) {
|
|
480
|
+
if (current === "") current = word;
|
|
481
|
+
else if (`${current} ${word}`.length <= width) current += ` ${word}`;
|
|
482
|
+
else {
|
|
483
|
+
lines.push(current);
|
|
484
|
+
current = word;
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
if (current !== "") lines.push(current);
|
|
488
|
+
return lines.map((line) => `${indent}${line}`);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
function heading(title, subtitle) {
|
|
492
|
+
console.log(`\n${title}`);
|
|
493
|
+
console.log("─".repeat(WIDTH));
|
|
494
|
+
if (subtitle) for (const line of wrap(subtitle)) console.log(line);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/** columns: [{ header, get }] — widths are derived from the widest cell. */
|
|
498
|
+
function printTable(columns, rows) {
|
|
499
|
+
const widths = columns.map((column) =>
|
|
500
|
+
Math.max(
|
|
501
|
+
column.header.length,
|
|
502
|
+
...rows.map((row) => String(column.get(row)).length),
|
|
503
|
+
),
|
|
504
|
+
);
|
|
505
|
+
const line = (cells) =>
|
|
506
|
+
console.log(
|
|
507
|
+
` ${cells.map((cell, i) => pad(cell, widths[i])).join(" ")}`.trimEnd(),
|
|
508
|
+
);
|
|
509
|
+
line(columns.map((c) => c.header));
|
|
510
|
+
line(widths.map((w) => "-".repeat(w)));
|
|
511
|
+
for (const row of rows) line(columns.map((c) => String(c.get(row))));
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
function printReport(report) {
|
|
515
|
+
const { rows } = report;
|
|
516
|
+
const proven = rows.filter((r) => r.status === "proven");
|
|
517
|
+
const acceptedGaps = rows.filter((r) => r.status === "accepted_gap");
|
|
518
|
+
const gaps = rows.filter((r) => r.status === "gap");
|
|
519
|
+
|
|
520
|
+
if (report.assertionCount === 0) {
|
|
521
|
+
console.log(
|
|
522
|
+
"[proof:coverage] no assertions found in .proof/traces — every table will read as a gap.\n" +
|
|
523
|
+
" suggestion: run `pnpm proof:verify` first so there is evidence to measure.",
|
|
524
|
+
);
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
heading(
|
|
528
|
+
"TABLE INVARIANTS",
|
|
529
|
+
"One requirement per table, derived from its RLS classification. A gap means unproven, not unprotected.",
|
|
530
|
+
);
|
|
531
|
+
console.log("");
|
|
532
|
+
printTable(
|
|
533
|
+
[
|
|
534
|
+
{ header: "table", get: (r) => r.table },
|
|
535
|
+
{ header: "classification", get: (r) => r.classification },
|
|
536
|
+
{ header: "requires", get: (r) => r.requires },
|
|
537
|
+
{ header: "status", get: (r) => r.status },
|
|
538
|
+
],
|
|
539
|
+
rows,
|
|
540
|
+
);
|
|
541
|
+
console.log(
|
|
542
|
+
`\n ${proven.length} proven · ${acceptedGaps.length} accepted gap(s) · ${gaps.length} unaccepted gap(s)`,
|
|
543
|
+
);
|
|
544
|
+
|
|
545
|
+
if (gaps.length > 0) {
|
|
546
|
+
console.log("");
|
|
547
|
+
for (const r of gaps) {
|
|
548
|
+
console.log(` ${r.table} needs ${r.requires}`);
|
|
549
|
+
for (const line of wrap(r.because, " ")) console.log(line);
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
if (acceptedGaps.length > 0) {
|
|
554
|
+
console.log(`\n Accepted in ${POLICY_PATH}:`);
|
|
555
|
+
for (const r of acceptedGaps) {
|
|
556
|
+
console.log(` ${r.table} · ${r.requires}`);
|
|
557
|
+
for (const line of wrap(
|
|
558
|
+
r.acceptedReason ?? "no reason given",
|
|
559
|
+
" ",
|
|
560
|
+
)) {
|
|
561
|
+
console.log(line);
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
if (report.actionRows.length > 0) {
|
|
567
|
+
const provenActions = report.actionRows.filter(
|
|
568
|
+
(r) => r.status === "proven",
|
|
569
|
+
);
|
|
570
|
+
heading(
|
|
571
|
+
"ACTION CLAIMS",
|
|
572
|
+
`Invariants declared via withProof, which must be backed by an assertion. ` +
|
|
573
|
+
`${report.declaredInvariants.length} of ${report.capabilityCount} capabilities declare one; ` +
|
|
574
|
+
`${provenActions.length} of ${report.actionRows.length} claim(s) proven.`,
|
|
575
|
+
);
|
|
576
|
+
console.log("");
|
|
577
|
+
printTable(
|
|
578
|
+
[
|
|
579
|
+
{ header: "action", get: (r) => r.action },
|
|
580
|
+
{ header: "requires", get: (r) => r.requires },
|
|
581
|
+
{ header: "status", get: (r) => r.status },
|
|
582
|
+
],
|
|
583
|
+
report.actionRows,
|
|
584
|
+
);
|
|
585
|
+
} else if (report.capabilityCount > 0) {
|
|
586
|
+
heading(
|
|
587
|
+
"ACTION CLAIMS",
|
|
588
|
+
`None: 0 of ${report.capabilityCount} capabilities declare an invariant via withProof, ` +
|
|
589
|
+
`so there is no action-level claim to check.`,
|
|
590
|
+
);
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
if (report.serviceRoleActionRows.length > 0) {
|
|
594
|
+
const provenBoundaries = report.serviceRoleActionRows.filter(
|
|
595
|
+
(r) => r.status === "proven",
|
|
596
|
+
);
|
|
597
|
+
const acceptedBoundaries = report.serviceRoleActionRows.filter(
|
|
598
|
+
(r) => r.status === "accepted_gap",
|
|
599
|
+
);
|
|
600
|
+
heading(
|
|
601
|
+
"SERVICE-ROLE ACTION BOUNDARIES",
|
|
602
|
+
"Derived from caller-controlled workspaceId inputs plus service-role mutations. Each action needs both an unauthorized/cross-tenant refusal and an allowed-path control; withProof metadata is not consulted.",
|
|
603
|
+
);
|
|
604
|
+
console.log("");
|
|
605
|
+
printTable(
|
|
606
|
+
[
|
|
607
|
+
{ header: "action", get: (r) => r.action },
|
|
608
|
+
{ header: "tables", get: (r) => r.tables.join(",") },
|
|
609
|
+
{ header: "denial", get: (r) => r.denial },
|
|
610
|
+
{ header: "control", get: (r) => r.control },
|
|
611
|
+
{ header: "status", get: (r) => r.status },
|
|
612
|
+
],
|
|
613
|
+
report.serviceRoleActionRows,
|
|
614
|
+
);
|
|
615
|
+
console.log(
|
|
616
|
+
`\n ${provenBoundaries.length} proven · ${acceptedBoundaries.length} accepted gap(s) · ` +
|
|
617
|
+
`${report.serviceRoleActionRows.length - provenBoundaries.length - acceptedBoundaries.length} unaccepted gap(s)`,
|
|
618
|
+
);
|
|
619
|
+
if (acceptedBoundaries.length > 0) {
|
|
620
|
+
console.log(`\n Accepted in ${POLICY_PATH}:`);
|
|
621
|
+
for (const row of acceptedBoundaries) {
|
|
622
|
+
console.log(` ${row.action}`);
|
|
623
|
+
for (const line of wrap(
|
|
624
|
+
row.acceptedReason ?? "no reason given",
|
|
625
|
+
" ",
|
|
626
|
+
)) {
|
|
627
|
+
console.log(line);
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
if (report.unassessable.length > 0) {
|
|
634
|
+
const unacknowledged = report.unassessable.filter((u) => !u.acknowledged);
|
|
635
|
+
heading(
|
|
636
|
+
"NOT ASSESSABLE",
|
|
637
|
+
`${report.unassessable.length} table(s) whose policies the parser could not classify, so no ` +
|
|
638
|
+
`requirement is derived. Each needs an entry in ${POLICY_PATH} — otherwise an unparseable ` +
|
|
639
|
+
`policy is the cheapest way off this report.`,
|
|
640
|
+
);
|
|
641
|
+
console.log("");
|
|
642
|
+
for (const u of report.unassessable) {
|
|
643
|
+
const evidence =
|
|
644
|
+
u.proof.length > 0
|
|
645
|
+
? `${u.proof.join(", ")} proven`
|
|
646
|
+
: "no primary proof";
|
|
647
|
+
console.log(
|
|
648
|
+
` ${u.table} · ${u.acknowledged ? "reviewed" : "NOT ACKNOWLEDGED"} · ${evidence}`,
|
|
649
|
+
);
|
|
650
|
+
const note =
|
|
651
|
+
u.note ??
|
|
652
|
+
"Add a reviewedUnclassified entry stating what the table is and how it is protected.";
|
|
653
|
+
for (const line of wrap(note, " ")) console.log(line);
|
|
654
|
+
}
|
|
655
|
+
if (unacknowledged.length > 0) {
|
|
656
|
+
console.log(`\n ${unacknowledged.length} unacknowledged`);
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
const staleEntries = [
|
|
661
|
+
...report.staleAcceptances.map((s) => ({
|
|
662
|
+
label: `accepted gap ${s.table}:${s.kind}`,
|
|
663
|
+
why: s.why,
|
|
664
|
+
})),
|
|
665
|
+
...report.staleReviews.map((s) => ({
|
|
666
|
+
label: `acknowledgement ${s.table}`,
|
|
667
|
+
why: s.why,
|
|
668
|
+
})),
|
|
669
|
+
...report.staleActionAcceptances.map((s) => ({
|
|
670
|
+
label: `accepted action gap ${s.action}`,
|
|
671
|
+
why: s.why,
|
|
672
|
+
})),
|
|
673
|
+
];
|
|
674
|
+
if (staleEntries.length > 0) {
|
|
675
|
+
heading(
|
|
676
|
+
"STALE POLICY ENTRIES",
|
|
677
|
+
`${staleEntries.length} entr(y/ies) in ${POLICY_PATH} that no longer apply. Left in place they ` +
|
|
678
|
+
`license a regression nobody decided to allow.`,
|
|
679
|
+
);
|
|
680
|
+
console.log("");
|
|
681
|
+
for (const stale of staleEntries) {
|
|
682
|
+
console.log(` ${stale.label}`);
|
|
683
|
+
for (const line of wrap(stale.why, " ")) console.log(line);
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
if (report.unverifiedOrigin.length > 0) {
|
|
688
|
+
heading(
|
|
689
|
+
"UNVERIFIED ORIGIN",
|
|
690
|
+
`${report.unverifiedOrigin.length} passing primary assertion(s) whose origin cannot vouch for the ` +
|
|
691
|
+
`claimed kind — recorded directly by spec code, or stamped by a helper that takes its kind from the ` +
|
|
692
|
+
`caller. They do NOT count as coverage evidence — emit the claim through the owning helper ` +
|
|
693
|
+
`(assert.tenantIsolation for table isolation) instead.`,
|
|
694
|
+
);
|
|
695
|
+
console.log("");
|
|
696
|
+
printTable(
|
|
697
|
+
[
|
|
698
|
+
{ header: "kind", get: (u) => u.kind },
|
|
699
|
+
{ header: "target", get: (u) => u.target },
|
|
700
|
+
{ header: "origin", get: (u) => u.emittedBy ?? "(spec-recorded)" },
|
|
701
|
+
{ header: "proof", get: (u) => u.proofId ?? "(unknown)" },
|
|
702
|
+
],
|
|
703
|
+
report.unverifiedOrigin,
|
|
704
|
+
);
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
if (report.unresolved.length > 0) {
|
|
708
|
+
heading(
|
|
709
|
+
"UNRESOLVED TARGETS",
|
|
710
|
+
`${report.unresolved.length} assertion target(s) that are neither a table nor a declared claim. ` +
|
|
711
|
+
`Routes and RPCs belong here; a typo would too, which is why they are listed.`,
|
|
712
|
+
);
|
|
713
|
+
console.log("");
|
|
714
|
+
printTable(
|
|
715
|
+
[
|
|
716
|
+
{ header: "target", get: (u) => u.key },
|
|
717
|
+
{ header: "assertions", get: (u) => u.count },
|
|
718
|
+
],
|
|
719
|
+
report.unresolved,
|
|
720
|
+
);
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
export function main() {
|
|
725
|
+
const args = process.argv.slice(2);
|
|
726
|
+
const strict = args.includes("--strict");
|
|
727
|
+
const asJson = args.includes("--json");
|
|
728
|
+
|
|
729
|
+
if (args.includes("--help") || args.includes("-h")) {
|
|
730
|
+
console.log(`Usage: node scripts/proof_coverage.mjs [--strict] [--json]
|
|
731
|
+
|
|
732
|
+
Reports which table-level invariants are proven by the trace evidence, which are
|
|
733
|
+
accepted gaps, and which are neither, plus whether every invariant an action
|
|
734
|
+
declares via withProof is backed by an assertion. Workspace-id-driven actions
|
|
735
|
+
that mutate through the service role additionally need
|
|
736
|
+
both a refusal and an allowed-path control. --strict exits 1 on an unaccepted
|
|
737
|
+
gap or an unproven action requirement.`);
|
|
738
|
+
return;
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
const unknown = args.filter((a) => !["--strict", "--json"].includes(a));
|
|
742
|
+
if (unknown.length > 0) {
|
|
743
|
+
console.error(`[proof:coverage] unknown flag: ${unknown.join(", ")}`);
|
|
744
|
+
process.exit(2);
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
const report = buildReport();
|
|
748
|
+
|
|
749
|
+
const gaps = report.rows.filter((r) => r.status === "gap");
|
|
750
|
+
const problems = [];
|
|
751
|
+
if (gaps.length > 0) {
|
|
752
|
+
problems.push({
|
|
753
|
+
line:
|
|
754
|
+
`[PROOF_FAIL] coverage_gap: ${gaps.length} table(s) require an invariant that no proof asserts: ` +
|
|
755
|
+
gaps.map((g) => `${g.table} (${g.requires})`).join(", "),
|
|
756
|
+
suggestion: `add a proof, or record the gap in ${POLICY_PATH} with a reason`,
|
|
757
|
+
});
|
|
758
|
+
}
|
|
759
|
+
const actionGaps = report.actionRows.filter((r) => r.status === "gap");
|
|
760
|
+
if (actionGaps.length > 0) {
|
|
761
|
+
problems.push({
|
|
762
|
+
line:
|
|
763
|
+
`[PROOF_FAIL] coverage_gap: ${actionGaps.length} action(s) declare an invariant via withProof that no proof asserts: ` +
|
|
764
|
+
actionGaps.map((g) => `${g.action} (${g.requires})`).join(", "),
|
|
765
|
+
suggestion:
|
|
766
|
+
`add a proof whose assertion kind is the invariant and whose target is the "<module>:<action>" ref, ` +
|
|
767
|
+
`or drop the declaration — a claim with no evidence is worse than no claim`,
|
|
768
|
+
});
|
|
769
|
+
}
|
|
770
|
+
const serviceRoleActionGaps = report.serviceRoleActionRows.filter(
|
|
771
|
+
(r) => r.status === "gap",
|
|
772
|
+
);
|
|
773
|
+
if (serviceRoleActionGaps.length > 0) {
|
|
774
|
+
problems.push({
|
|
775
|
+
line:
|
|
776
|
+
`[PROOF_FAIL] service_role_action_gap: ${serviceRoleActionGaps.length} workspace-targeted service-role action(s) lack mandatory action evidence: ` +
|
|
777
|
+
serviceRoleActionGaps
|
|
778
|
+
.map((g) => {
|
|
779
|
+
const missing = [
|
|
780
|
+
g.denial === "gap" ? "denial" : null,
|
|
781
|
+
g.control === "gap" ? "allowed control" : null,
|
|
782
|
+
].filter(Boolean);
|
|
783
|
+
return `${g.action} (${missing.join(" + ")})`;
|
|
784
|
+
})
|
|
785
|
+
.join(", "),
|
|
786
|
+
suggestion:
|
|
787
|
+
`add a passing primary authorization/tenant_isolation assertion for the unauthorized or cross-tenant call and ` +
|
|
788
|
+
`a passing control assertion for an allowed call, both targeting the exact "<module>:<action>" ref; ` +
|
|
789
|
+
`if the action cannot be exercised yet, record the debt in ${POLICY_PATH}.acceptedActionGaps with a durable reason`,
|
|
790
|
+
});
|
|
791
|
+
}
|
|
792
|
+
if (report.staleAcceptances.length > 0) {
|
|
793
|
+
problems.push({
|
|
794
|
+
line:
|
|
795
|
+
`[PROOF_FAIL] coverage_policy_stale: ${report.staleAcceptances.length} accepted gap(s) no longer apply: ` +
|
|
796
|
+
report.staleAcceptances.map((s) => `${s.table}:${s.kind}`).join(", "),
|
|
797
|
+
suggestion: `remove them from ${POLICY_PATH} so the policy keeps meaning what it says`,
|
|
798
|
+
});
|
|
799
|
+
}
|
|
800
|
+
const unacknowledged = report.unassessable.filter((u) => !u.acknowledged);
|
|
801
|
+
if (unacknowledged.length > 0) {
|
|
802
|
+
problems.push({
|
|
803
|
+
line:
|
|
804
|
+
`[PROOF_FAIL] coverage_blind_spot: ${unacknowledged.length} table(s) have policies the parser cannot classify and have not been reviewed: ` +
|
|
805
|
+
unacknowledged.map((u) => u.table).join(", "),
|
|
806
|
+
suggestion:
|
|
807
|
+
`no requirement can be derived for them, so add a reviewedUnclassified entry in ${POLICY_PATH} ` +
|
|
808
|
+
`stating what the table is and how it is protected`,
|
|
809
|
+
});
|
|
810
|
+
}
|
|
811
|
+
if (report.staleReviews.length > 0) {
|
|
812
|
+
problems.push({
|
|
813
|
+
line:
|
|
814
|
+
`[PROOF_FAIL] coverage_policy_stale: ${report.staleReviews.length} acknowledgement(s) no longer apply: ` +
|
|
815
|
+
report.staleReviews.map((s) => s.table).join(", "),
|
|
816
|
+
suggestion: `remove them from ${POLICY_PATH}`,
|
|
817
|
+
});
|
|
818
|
+
}
|
|
819
|
+
if (report.staleActionAcceptances.length > 0) {
|
|
820
|
+
problems.push({
|
|
821
|
+
line:
|
|
822
|
+
`[PROOF_FAIL] coverage_policy_stale: ${report.staleActionAcceptances.length} accepted action gap(s) no longer apply: ` +
|
|
823
|
+
report.staleActionAcceptances.map((gap) => gap.action).join(", "),
|
|
824
|
+
suggestion: `remove them from ${POLICY_PATH} so new regressions cannot inherit obsolete exceptions`,
|
|
825
|
+
});
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
if (asJson) {
|
|
829
|
+
console.log(JSON.stringify(report, null, 2));
|
|
830
|
+
if (problems.length > 0 && strict) process.exit(1);
|
|
831
|
+
return;
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
printReport(report);
|
|
835
|
+
|
|
836
|
+
if (problems.length === 0) {
|
|
837
|
+
console.log(
|
|
838
|
+
`\n[proof:coverage] PASS · ${report.rows.length - gaps.length} table invariant(s) and ` +
|
|
839
|
+
`${report.actionRows.length - actionGaps.length} declared claim(s), ` +
|
|
840
|
+
`${report.serviceRoleActionRows.length - serviceRoleActionGaps.length} service-role action boundary(ies) proven or accepted`,
|
|
841
|
+
);
|
|
842
|
+
return;
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
// Not strict: the sections above already show what is wrong, so summarise
|
|
846
|
+
// rather than emitting a failure line, which the consumer reads as a failed
|
|
847
|
+
// run. That is also why the summary below avoids the literal token — anything
|
|
848
|
+
// grepping this output should find it only when the build actually failed.
|
|
849
|
+
if (!strict) {
|
|
850
|
+
console.log(
|
|
851
|
+
`\n[proof:coverage] ${problems.length} problem(s) — re-run with --strict to fail the build on them`,
|
|
852
|
+
);
|
|
853
|
+
return;
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
console.error("");
|
|
857
|
+
for (const problem of problems) {
|
|
858
|
+
console.error(problem.line);
|
|
859
|
+
const [first, ...rest] = wrap(problem.suggestion, " ");
|
|
860
|
+
console.error(` suggestion: ${first.trim()}`);
|
|
861
|
+
for (const line of rest) console.error(line);
|
|
862
|
+
console.error("");
|
|
863
|
+
}
|
|
864
|
+
process.exit(1);
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
const invokedDirectly =
|
|
868
|
+
process.argv[1] !== undefined &&
|
|
869
|
+
path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
|
870
|
+
|
|
871
|
+
if (invokedDirectly) main();
|