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.
Files changed (53) hide show
  1. package/COMPATIBILITY.md +26 -0
  2. package/LICENSE +201 -0
  3. package/PROOF_SDK_CONTRACT.md +1255 -0
  4. package/README.md +60 -0
  5. package/cli/config.mjs +116 -0
  6. package/cli/engines/generate_proof_action_registry.mjs +271 -0
  7. package/cli/engines/parse_proof_schema.mjs +457 -0
  8. package/cli/engines/proof_coverage.mjs +871 -0
  9. package/cli/engines/proof_drift.mjs +980 -0
  10. package/cli/engines/proof_module_check.mjs +498 -0
  11. package/cli/engines/proof_mutation_check.mjs +1084 -0
  12. package/cli/engines/proof_mutation_inventory.mjs +508 -0
  13. package/cli/engines/proof_source_mutation.mjs +35 -0
  14. package/cli/engines/proof_verify.mjs +620 -0
  15. package/cli/engines/scan_module_meta.mjs +352 -0
  16. package/cli/engines/scan_proof_capabilities.mjs +359 -0
  17. package/cli/proof-harness.mjs +107 -0
  18. package/dist/action-types-C3OmHRyH.d.ts +26 -0
  19. package/dist/fixture-B-dmnG5G.d.ts +105 -0
  20. package/dist/mission-types-Dr5rBHYO.d.ts +299 -0
  21. package/dist/node.d.ts +79 -0
  22. package/dist/node.js +553 -0
  23. package/dist/node.js.map +1 -0
  24. package/dist/playwright.d.ts +433 -0
  25. package/dist/playwright.js +1824 -0
  26. package/dist/playwright.js.map +1 -0
  27. package/dist/portable-vocabulary.d.ts +86 -0
  28. package/dist/portable-vocabulary.js +43 -0
  29. package/dist/portable-vocabulary.js.map +1 -0
  30. package/dist/server.d.ts +55 -0
  31. package/dist/server.js +376 -0
  32. package/dist/server.js.map +1 -0
  33. package/dist/shared.d.ts +169 -0
  34. package/dist/shared.js +121 -0
  35. package/dist/shared.js.map +1 -0
  36. package/dist/trace-types-DkIzq-l9.d.ts +333 -0
  37. package/package.json +109 -0
  38. package/proof-consumer-fixtures/README.md +60 -0
  39. package/proof-consumer-fixtures/aggregates/M-CONFORMANCE-FAIL.json +112 -0
  40. package/proof-consumer-fixtures/aggregates/M-CONFORMANCE-PASS.json +101 -0
  41. package/proof-consumer-fixtures/baseline/failing/widgets-tenant-isolation.json +46 -0
  42. package/proof-consumer-fixtures/baseline/passing/widgets-tenant-isolation.json +45 -0
  43. package/proof-consumer-fixtures/corpus.json +102 -0
  44. package/proof-consumer-fixtures/duplicates/first/widgets-tenant-isolation.json +12 -0
  45. package/proof-consumer-fixtures/duplicates/second/widgets-tenant-isolation.json +12 -0
  46. package/proof-consumer-fixtures/inputs/capabilities.json +4 -0
  47. package/proof-consumer-fixtures/inputs/mission.json +20 -0
  48. package/proof-consumer-fixtures/inputs/schema.json +4 -0
  49. package/proof-consumer-fixtures/malformed/invalid-trace.json.invalid +4 -0
  50. package/proof-consumer-fixtures/malformed/wrong-manifest-version.json +11 -0
  51. package/proof-consumer-fixtures/malformed/wrong-trace-shape.json +8 -0
  52. package/proof-consumer-fixtures/mutations/MUT-WIDGETS-LEAK/mutation.json +17 -0
  53. package/proof-consumer-fixtures/mutations/MUT-WIDGETS-LEAK/widgets-tenant-isolation.json +50 -0
@@ -0,0 +1,457 @@
1
+ #!/usr/bin/env node
2
+ // ---------------------------------------------------------------------------
3
+ // parse_proof_schema.mjs
4
+ //
5
+ // v1 proof SDK migration parser.
6
+ //
7
+ // Reads every `.sql` file in `supabase/migrations/` (the aggregated source
8
+ // of truth that Supabase actually runs) and emits `.proof/schema.json` with:
9
+ //
10
+ // { schemaVersion: 1, tables: [
11
+ // { name, columns, policies, rls_classification, files } ] }
12
+ //
13
+ // Classification rules (order-sensitive — first match wins per policy,
14
+ // aggregated per table):
15
+ //
16
+ // 1. Policy body references `get_user_workspace_ids(`
17
+ // OR `workspace_id IN (SELECT workspace_id FROM workspace_members`
18
+ // → workspace_scoped
19
+ // 2. Policy body references `auth.uid() = user_id`, `user_id = auth.uid()`,
20
+ // `owner_id = auth.uid()`, or `auth.uid() = owner_id`
21
+ // → user_scoped
22
+ // 3. Policy TO clause is exactly `service_role` (no authenticated policies)
23
+ // → service_only
24
+ // 4. Policy is `FOR SELECT` with `USING (true)` to authenticated/public/anon
25
+ // and no workspace/user-scoped policies exist
26
+ // → public_read
27
+ // 5. No policies target authenticated at all (only service_role or none)
28
+ // → admin_only
29
+ // 6. Anything else → unclassified (surfaced for human review)
30
+ //
31
+ // Shallow SQL parsing is intentional: paren-balancing only, no AST, no
32
+ // semantic understanding. v2 can upgrade to a real PG parser. The output
33
+ // shape here is the stable contract.
34
+ //
35
+ // Deterministic output (sorted tables + columns + policies, no timestamp)
36
+ // so `git diff --exit-code` is a meaningful freshness gate in CI.
37
+ // ---------------------------------------------------------------------------
38
+
39
+ import fs from "node:fs";
40
+ import path from "node:path";
41
+ import { pathToFileURL } from "node:url";
42
+
43
+ import { loadProofConfig } from "../config.mjs";
44
+
45
+ const CONFIG = await loadProofConfig();
46
+ process.chdir(CONFIG.rootDir);
47
+
48
+ const MIGRATIONS_DIR = CONFIG.roots.migrations;
49
+ const OUTPUT_PATH = CONFIG.artifacts.schema;
50
+
51
+ // ---------------------------------------------------------------------------
52
+ // SQL preprocessing
53
+ // ---------------------------------------------------------------------------
54
+
55
+ export function stripSqlComments(sql) {
56
+ // Remove `/* block comments */` first, then `-- line` comments. Don't try
57
+ // to be clever about strings containing `--`; migrations avoid that.
58
+ let out = sql.replace(/\/\*[\s\S]*?\*\//g, "");
59
+ out = out.replace(/--[^\n]*/g, "");
60
+ return out;
61
+ }
62
+
63
+ function readAllMigrations() {
64
+ if (!fs.existsSync(MIGRATIONS_DIR)) return [];
65
+ const files = fs
66
+ .readdirSync(MIGRATIONS_DIR, { withFileTypes: true })
67
+ .filter((e) => e.isFile() && e.name.endsWith(".sql"))
68
+ .map((e) => path.join(MIGRATIONS_DIR, e.name))
69
+ .sort();
70
+ return files.map((file) => ({
71
+ file,
72
+ sql: stripSqlComments(fs.readFileSync(file, "utf8")),
73
+ }));
74
+ }
75
+
76
+ // ---------------------------------------------------------------------------
77
+ // Paren-balanced block extraction
78
+ // ---------------------------------------------------------------------------
79
+
80
+ /**
81
+ * Given a source string and an index pointing at an opening `(`, return
82
+ * the index of the matching closing `)` at the same depth, or -1 if the
83
+ * block is unbalanced. Ignores parens inside single-quoted strings.
84
+ */
85
+ function findMatchingParen(src, openIdx) {
86
+ if (src[openIdx] !== "(") return -1;
87
+ let depth = 0;
88
+ let inStr = false;
89
+ for (let i = openIdx; i < src.length; i++) {
90
+ const ch = src[i];
91
+ if (inStr) {
92
+ if (ch === "'") {
93
+ if (src[i + 1] === "'")
94
+ i++; // escaped single quote
95
+ else inStr = false;
96
+ }
97
+ continue;
98
+ }
99
+ if (ch === "'") {
100
+ inStr = true;
101
+ continue;
102
+ }
103
+ if (ch === "(") depth++;
104
+ else if (ch === ")") {
105
+ depth--;
106
+ if (depth === 0) return i;
107
+ }
108
+ }
109
+ return -1;
110
+ }
111
+
112
+ /**
113
+ * Split a paren-balanced SQL list on top-level commas only.
114
+ */
115
+ function splitTopLevelCommas(body) {
116
+ const parts = [];
117
+ let depth = 0;
118
+ let inStr = false;
119
+ let start = 0;
120
+ for (let i = 0; i < body.length; i++) {
121
+ const ch = body[i];
122
+ if (inStr) {
123
+ if (ch === "'") {
124
+ if (body[i + 1] === "'") i++;
125
+ else inStr = false;
126
+ }
127
+ continue;
128
+ }
129
+ if (ch === "'") {
130
+ inStr = true;
131
+ continue;
132
+ }
133
+ if (ch === "(") depth++;
134
+ else if (ch === ")") depth--;
135
+ else if (ch === "," && depth === 0) {
136
+ parts.push(body.slice(start, i));
137
+ start = i + 1;
138
+ }
139
+ }
140
+ parts.push(body.slice(start));
141
+ return parts.map((p) => p.trim()).filter(Boolean);
142
+ }
143
+
144
+ // ---------------------------------------------------------------------------
145
+ // CREATE TABLE parsing
146
+ // ---------------------------------------------------------------------------
147
+
148
+ export const CREATE_TABLE_RE =
149
+ /CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?(?:"?public"?\.)?"?(\w+)"?\s*\(/gi;
150
+
151
+ // Keywords that start a table-level constraint rather than a column definition.
152
+ const CONSTRAINT_KEYWORDS = new Set([
153
+ "PRIMARY",
154
+ "FOREIGN",
155
+ "UNIQUE",
156
+ "CHECK",
157
+ "CONSTRAINT",
158
+ "EXCLUDE",
159
+ "LIKE",
160
+ ]);
161
+
162
+ function parseColumns(body) {
163
+ const columns = [];
164
+ for (const seg of splitTopLevelCommas(body)) {
165
+ const firstWord = seg.split(/\s|\(/)[0].toUpperCase();
166
+ if (CONSTRAINT_KEYWORDS.has(firstWord)) continue;
167
+ const match = seg.match(/^"?(\w+)"?/);
168
+ if (!match) continue;
169
+ const name = match[1];
170
+ // Skip anything that still looks like a keyword (defensive).
171
+ if (CONSTRAINT_KEYWORDS.has(name.toUpperCase())) continue;
172
+ columns.push(name);
173
+ }
174
+ // Dedupe while preserving first-seen order.
175
+ return [...new Set(columns)];
176
+ }
177
+
178
+ /**
179
+ * Table names created by one `.sql` file.
180
+ *
181
+ * Exported so anything that needs to know which tables a module owns shares
182
+ * this parser rather than growing its own. A second `CREATE TABLE` regex is a
183
+ * second thing to get wrong, and the interesting failure is silent: a pattern
184
+ * that misses `create table "public"."x"` reports a module as owning no tables
185
+ * at all, which reads exactly like a module that genuinely owns none.
186
+ */
187
+ export function tableNamesIn(sql) {
188
+ const names = new Set();
189
+ const stripped = stripSqlComments(sql);
190
+ CREATE_TABLE_RE.lastIndex = 0;
191
+ let m;
192
+ while ((m = CREATE_TABLE_RE.exec(stripped)) !== null) names.add(m[1]);
193
+ return [...names].sort();
194
+ }
195
+
196
+ function extractTables(sql, sourceFile) {
197
+ const tables = [];
198
+ CREATE_TABLE_RE.lastIndex = 0;
199
+ let m;
200
+ while ((m = CREATE_TABLE_RE.exec(sql)) !== null) {
201
+ const name = m[1];
202
+ const openParen = sql.indexOf("(", m.index + m[0].length - 1);
203
+ if (openParen < 0) continue;
204
+ const closeParen = findMatchingParen(sql, openParen);
205
+ if (closeParen < 0) continue;
206
+ const body = sql.slice(openParen + 1, closeParen);
207
+ tables.push({
208
+ name,
209
+ columns: parseColumns(body),
210
+ sourceFile,
211
+ });
212
+ }
213
+ return tables;
214
+ }
215
+
216
+ // ---------------------------------------------------------------------------
217
+ // CREATE POLICY parsing
218
+ // ---------------------------------------------------------------------------
219
+
220
+ // Matches the policy header; body is captured separately by scanning forward.
221
+ const CREATE_POLICY_RE =
222
+ /CREATE\s+POLICY\s+(?:"([^"]+)"|(\w+))\s+ON\s+(?:"?public"?\.)?"?(\w+)"?\s+([\s\S]*?)(?=;)/gi;
223
+
224
+ function parsePolicies(sql, sourceFile) {
225
+ const policies = [];
226
+ CREATE_POLICY_RE.lastIndex = 0;
227
+ let m;
228
+ while ((m = CREATE_POLICY_RE.exec(sql)) !== null) {
229
+ const policyName = m[1] ?? m[2];
230
+ const tableName = m[3];
231
+ const rest = m[4];
232
+
233
+ // Extract FOR <command>
234
+ const forMatch = rest.match(/\bFOR\s+(ALL|SELECT|INSERT|UPDATE|DELETE)\b/i);
235
+ const command = forMatch ? forMatch[1].toUpperCase() : "ALL";
236
+
237
+ // Extract TO <roles...>, stopping at USING/WITH CHECK/end
238
+ const toMatch = rest.match(
239
+ /\bTO\s+([\w",\s]+?)(?=\s+(?:USING|WITH\s+CHECK|AS\s+(?:PERMISSIVE|RESTRICTIVE))\b|$)/i,
240
+ );
241
+ const roles = toMatch
242
+ ? toMatch[1]
243
+ .split(",")
244
+ .map((s) => s.trim().replace(/^"|"$/g, ""))
245
+ .filter(Boolean)
246
+ : [];
247
+
248
+ const usingMatch = rest.match(/\bUSING\s*\(/i);
249
+ let usingBody = "";
250
+ if (usingMatch) {
251
+ // Re-locate in original sql: simpler to paren-match from the match
252
+ // within `rest` directly.
253
+ const localOpen = usingMatch.index + usingMatch[0].length - 1;
254
+ const localClose = findMatchingParen(rest, localOpen);
255
+ if (localClose > localOpen)
256
+ usingBody = rest.slice(localOpen + 1, localClose);
257
+ }
258
+
259
+ const checkMatch = rest.match(/\bWITH\s+CHECK\s*\(/i);
260
+ let checkBody = "";
261
+ if (checkMatch) {
262
+ const localOpen = checkMatch.index + checkMatch[0].length - 1;
263
+ const localClose = findMatchingParen(rest, localOpen);
264
+ if (localClose > localOpen)
265
+ checkBody = rest.slice(localOpen + 1, localClose);
266
+ }
267
+
268
+ policies.push({
269
+ name: policyName,
270
+ table: tableName,
271
+ command,
272
+ roles,
273
+ using: usingBody.trim(),
274
+ check: checkBody.trim(),
275
+ sourceFile,
276
+ });
277
+ }
278
+ return policies;
279
+ }
280
+
281
+ // ---------------------------------------------------------------------------
282
+ // Classification
283
+ // ---------------------------------------------------------------------------
284
+
285
+ function policyIndicatesWorkspace(policy) {
286
+ const body = `${policy.using} ${policy.check}`.toLowerCase();
287
+ if (body.includes("get_user_workspace_ids(")) return true;
288
+ if (
289
+ /workspace_id\s+in\s*\(\s*select\s+workspace_id\s+from\s+workspace_members/i.test(
290
+ `${policy.using} ${policy.check}`,
291
+ )
292
+ ) {
293
+ return true;
294
+ }
295
+ return false;
296
+ }
297
+
298
+ function policyIndicatesUser(policy) {
299
+ const body = `${policy.using} ${policy.check}`;
300
+ // user_id = auth.uid() OR auth.uid() = user_id OR owner/created_by variants
301
+ const patterns = [
302
+ /\b(user_id|owner_id|created_by|invited_by)\s*=\s*\(?\s*(select\s+)?auth\.uid\(\)/i,
303
+ /\bauth\.uid\(\)\s*=\s*(user_id|owner_id|created_by|invited_by)\b/i,
304
+ /\(\s*select\s+auth\.uid\(\)\s*\)\s*=\s*(user_id|owner_id|created_by|invited_by)\b/i,
305
+ /\b(user_id|owner_id|created_by|invited_by)\s*=\s*\(\s*select\s+auth\.uid\(\)\s*\)/i,
306
+ ];
307
+ return patterns.some((re) => re.test(body));
308
+ }
309
+
310
+ function policyIsPublicRead(policy) {
311
+ if (policy.command !== "SELECT" && policy.command !== "ALL") return false;
312
+ const body = policy.using.trim().toLowerCase();
313
+ return body === "true";
314
+ }
315
+
316
+ function classifyTable(policies) {
317
+ // No policies at all → admin_only (assumes RLS-enabled Supabase table
318
+ // without authenticated access).
319
+ if (policies.length === 0) return "admin_only";
320
+
321
+ // A policy with no TO clause defaults to `TO public` in Postgres, so
322
+ // treat an empty `roles` list as implicit public (i.e. authenticated
323
+ // users are subject to it). This matches the common
324
+ // `CREATE POLICY foo ON t FOR SELECT USING (...)` pattern.
325
+ const isAuthFacing = (p) =>
326
+ p.roles.length === 0 ||
327
+ p.roles.some((r) => r === "authenticated" || r === "public");
328
+ const authPolicies = policies.filter(isAuthFacing);
329
+ const serviceOnly =
330
+ authPolicies.length === 0 &&
331
+ policies.length > 0 &&
332
+ policies.every(
333
+ (p) => p.roles.length === 1 && p.roles[0] === "service_role",
334
+ );
335
+
336
+ if (serviceOnly) return "service_only";
337
+
338
+ // Check workspace first (most specific).
339
+ if (authPolicies.some(policyIndicatesWorkspace)) return "workspace_scoped";
340
+ if (authPolicies.some(policyIndicatesUser)) return "user_scoped";
341
+
342
+ // public_read: every authenticated-accessible policy is SELECT with
343
+ // USING (true). (Don't classify as public_read if any write policy
344
+ // targets authenticated, that's a different pattern.)
345
+ const readPolicies = authPolicies.filter(
346
+ (p) => p.command === "SELECT" || p.command === "ALL",
347
+ );
348
+ if (
349
+ readPolicies.length > 0 &&
350
+ readPolicies.every(policyIsPublicRead) &&
351
+ authPolicies.every(
352
+ (p) => p.command === "SELECT" || p.command === "ALL" || p.check === "",
353
+ )
354
+ ) {
355
+ return "public_read";
356
+ }
357
+
358
+ if (authPolicies.length === 0) return "admin_only";
359
+
360
+ return "unclassified";
361
+ }
362
+
363
+ // ---------------------------------------------------------------------------
364
+ // Main
365
+ // ---------------------------------------------------------------------------
366
+
367
+ export function main() {
368
+ const sources = readAllMigrations();
369
+ const tablesByName = new Map();
370
+ const policiesByTable = new Map();
371
+
372
+ for (const { file, sql } of sources) {
373
+ for (const t of extractTables(sql, file)) {
374
+ const existing = tablesByName.get(t.name);
375
+ if (existing) {
376
+ // Merge columns from ALTER TABLE or re-declarations (future-proof).
377
+ existing.columns = [...new Set([...existing.columns, ...t.columns])];
378
+ if (!existing.files.includes(file)) existing.files.push(file);
379
+ } else {
380
+ tablesByName.set(t.name, {
381
+ name: t.name,
382
+ columns: t.columns,
383
+ files: [file],
384
+ });
385
+ }
386
+ }
387
+ for (const p of parsePolicies(sql, file)) {
388
+ if (!policiesByTable.has(p.table)) policiesByTable.set(p.table, []);
389
+ policiesByTable.get(p.table).push(p);
390
+ }
391
+ }
392
+
393
+ const tables = [];
394
+ const unclassifiedTables = [];
395
+
396
+ for (const [name, entry] of tablesByName) {
397
+ const policies = policiesByTable.get(name) ?? [];
398
+ const classification = classifyTable(policies);
399
+
400
+ const normalized = {
401
+ name,
402
+ columns: [...entry.columns].sort(),
403
+ rls_classification: classification,
404
+ policies: policies
405
+ .map((p) => ({
406
+ name: p.name,
407
+ command: p.command,
408
+ roles: [...p.roles].sort(),
409
+ }))
410
+ .sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)),
411
+ sourceFiles: [...entry.files]
412
+ .map((f) => f.split(path.sep).join("/"))
413
+ .sort(),
414
+ };
415
+ tables.push(normalized);
416
+ if (classification === "unclassified") {
417
+ unclassifiedTables.push({
418
+ table: name,
419
+ reason:
420
+ "policies present but none match the workspace/user/service/public classification patterns",
421
+ });
422
+ }
423
+ }
424
+
425
+ tables.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
426
+ unclassifiedTables.sort((a, b) =>
427
+ a.table < b.table ? -1 : a.table > b.table ? 1 : 0,
428
+ );
429
+
430
+ const output = {
431
+ schemaVersion: 1,
432
+ tables,
433
+ unclassified: unclassifiedTables,
434
+ };
435
+
436
+ fs.mkdirSync(path.dirname(OUTPUT_PATH), { recursive: true });
437
+ fs.writeFileSync(OUTPUT_PATH, JSON.stringify(output, null, 2) + "\n", "utf8");
438
+
439
+ console.log(
440
+ `[proof:parse] parsed ${sources.length} migration file(s); wrote ${tables.length} table(s), ${unclassifiedTables.length} unclassified to ${OUTPUT_PATH}`,
441
+ );
442
+ if (unclassifiedTables.length > 0) {
443
+ console.log(`[proof:parse] unclassified tables:`);
444
+ for (const u of unclassifiedTables)
445
+ console.log(` - ${u.table}: ${u.reason}`);
446
+ }
447
+ }
448
+
449
+ // Only run the CLI when executed directly, so importing this module for
450
+ // `tableNamesIn` / `CREATE_TABLE_RE` has no side effects.
451
+ const invokedDirectly =
452
+ process.argv[1] !== undefined &&
453
+ import.meta.url === pathToFileURL(process.argv[1]).href;
454
+
455
+ if (invokedDirectly) {
456
+ main();
457
+ }