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,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
|
+
}
|