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,359 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// ---------------------------------------------------------------------------
|
|
3
|
+
// scan_proof_capabilities.mjs
|
|
4
|
+
//
|
|
5
|
+
// v1 proof SDK capability scanner.
|
|
6
|
+
//
|
|
7
|
+
// Walks every server-action source file under:
|
|
8
|
+
// - app/__core/<module>/src/actions/**/*.ts
|
|
9
|
+
// - app/__business-logic/<module>/src/actions/**/*.ts
|
|
10
|
+
// - app/__extensions/<module>/src/actions/**/*.ts
|
|
11
|
+
//
|
|
12
|
+
// For each file it tries to extract:
|
|
13
|
+
// - name — the literal string passed as `createAction({ functionName })`
|
|
14
|
+
// - module — the <module> segment from the file path
|
|
15
|
+
// - verb — literal `verb` on `.use(withProof({ ... }))` if present
|
|
16
|
+
// - object — literal `object` on withProof if present
|
|
17
|
+
// - invariants — literal string array on withProof if present
|
|
18
|
+
// - acceptsWorkspaceId — whether the action input declares/uses workspaceId
|
|
19
|
+
// - internalOnly — BOT/server-only plumbing rather than a user action
|
|
20
|
+
// - usesDirectUpdateTag — incompatible with proof Route Handler invocation
|
|
21
|
+
// - serviceRoleMutations — tables mutated through a service-role client
|
|
22
|
+
//
|
|
23
|
+
// Writes `.proof/capabilities.json`. Output is deterministic (sorted,
|
|
24
|
+
// no timestamps) so `git diff --exit-code` is a meaningful freshness gate
|
|
25
|
+
// in CI; it only fires when the inputs actually changed.
|
|
26
|
+
//
|
|
27
|
+
// This is the v1 scanner: regex + glob, zero AST. It intentionally ignores:
|
|
28
|
+
// - computed function names (template literals, variable references)
|
|
29
|
+
// - `withProof` declarations outside the same file as `createAction`
|
|
30
|
+
// - chained middleware where the literal spans multiple lines awkwardly
|
|
31
|
+
// Any file with `createAction(` that the regex can't parse lands in
|
|
32
|
+
// `unclassified[]` with a reason so the author sees it instead of a
|
|
33
|
+
// silently-missing capability.
|
|
34
|
+
//
|
|
35
|
+
// v2 will swap this for a ts-morph / TypeScript compiler API scanner that
|
|
36
|
+
// resolves variables, follows re-exports, and handles dynamic shapes. The
|
|
37
|
+
// output shape here is the stable contract — v2 just widens coverage.
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
import fs from "node:fs";
|
|
41
|
+
import path from "node:path";
|
|
42
|
+
import { fileURLToPath } from "node:url";
|
|
43
|
+
|
|
44
|
+
import { loadProofConfig } from "../config.mjs";
|
|
45
|
+
|
|
46
|
+
const CONFIG = await loadProofConfig();
|
|
47
|
+
process.chdir(CONFIG.rootDir);
|
|
48
|
+
|
|
49
|
+
const ROOTS = CONFIG.roots.actions;
|
|
50
|
+
const OUTPUT_PATH = CONFIG.artifacts.capabilities;
|
|
51
|
+
|
|
52
|
+
function listActionFiles() {
|
|
53
|
+
const files = [];
|
|
54
|
+
for (const root of ROOTS) {
|
|
55
|
+
if (!fs.existsSync(root)) continue;
|
|
56
|
+
for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
|
|
57
|
+
if (!entry.isDirectory()) continue;
|
|
58
|
+
const actionsDir = path.join(root, entry.name, "src", "actions");
|
|
59
|
+
if (fs.existsSync(actionsDir)) {
|
|
60
|
+
walkTs(actionsDir, files);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return files;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function walkTs(dir, acc) {
|
|
68
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
69
|
+
const full = path.join(dir, entry.name);
|
|
70
|
+
if (entry.isDirectory()) {
|
|
71
|
+
if (entry.name === "__tests" || entry.name === "node_modules") continue;
|
|
72
|
+
walkTs(full, acc);
|
|
73
|
+
} else if (
|
|
74
|
+
entry.isFile() &&
|
|
75
|
+
entry.name.endsWith(".ts") &&
|
|
76
|
+
!entry.name.endsWith(".test.ts") &&
|
|
77
|
+
!entry.name.endsWith(".d.ts")
|
|
78
|
+
) {
|
|
79
|
+
acc.push(full);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function extractModule(file) {
|
|
85
|
+
for (const root of ROOTS) {
|
|
86
|
+
const relative = path.relative(root, file);
|
|
87
|
+
if (
|
|
88
|
+
relative !== "" &&
|
|
89
|
+
!relative.startsWith(`..${path.sep}`) &&
|
|
90
|
+
!path.isAbsolute(relative)
|
|
91
|
+
) {
|
|
92
|
+
return relative.split(path.sep)[0] ?? null;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
const parts = file.split(path.sep);
|
|
96
|
+
const rootIdx = parts.findIndex(
|
|
97
|
+
(p) => p === "__core" || p === "__business-logic" || p === "__extensions",
|
|
98
|
+
);
|
|
99
|
+
if (rootIdx < 0 || rootIdx + 1 >= parts.length) return null;
|
|
100
|
+
return parts[rootIdx + 1];
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Resolve the TABLE_NAME_* constants used by the repository's query builders.
|
|
105
|
+
*
|
|
106
|
+
* The scanner remains deliberately shallow, but resolving these constants is
|
|
107
|
+
* important: generated actions conventionally call `.from(TABLE_NAME_WIDGETS)`
|
|
108
|
+
* rather than spelling the table as a string literal. Ambiguous identifiers
|
|
109
|
+
* are dropped so a same-named constant in two modules cannot be resolved to the
|
|
110
|
+
* wrong table.
|
|
111
|
+
*/
|
|
112
|
+
function readTableConstants() {
|
|
113
|
+
const values = new Map();
|
|
114
|
+
const ambiguous = new Set();
|
|
115
|
+
|
|
116
|
+
function walk(dir) {
|
|
117
|
+
if (!fs.existsSync(dir)) return;
|
|
118
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
119
|
+
const full = path.join(dir, entry.name);
|
|
120
|
+
if (entry.isDirectory()) {
|
|
121
|
+
if (entry.name === "node_modules" || entry.name === ".next") continue;
|
|
122
|
+
walk(full);
|
|
123
|
+
} else if (entry.isFile() && entry.name === "queries.ts") {
|
|
124
|
+
const source = stripComments(fs.readFileSync(full, "utf8"));
|
|
125
|
+
for (const match of source.matchAll(
|
|
126
|
+
/\bexport\s+const\s+([A-Za-z_$][\w$]*)\s*=\s*["']([^"']+)["']/g,
|
|
127
|
+
)) {
|
|
128
|
+
const [, identifier, table] = match;
|
|
129
|
+
const existing = values.get(identifier);
|
|
130
|
+
if (existing && existing !== table) ambiguous.add(identifier);
|
|
131
|
+
else values.set(identifier, table);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
walk(CONFIG.roots.source);
|
|
138
|
+
for (const identifier of ambiguous) values.delete(identifier);
|
|
139
|
+
return values;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// `[\s\S]` rather than `.` with /s flag: tolerates any content (nested
|
|
143
|
+
// objects, comments) until we hit `functionName`. The non-greedy `*?`
|
|
144
|
+
// keeps the match tight.
|
|
145
|
+
const CREATE_ACTION_RE = /createAction\s*\(\s*\{([\s\S]*?)\}\s*\)/;
|
|
146
|
+
const FUNCTION_NAME_RE = /\bfunctionName\s*:\s*["']([^"']+)["']/;
|
|
147
|
+
const WITH_PROOF_RE = /\bwithProof\s*\(\s*\{([\s\S]*?)\}\s*\)/;
|
|
148
|
+
const VERB_RE = /\bverb\s*:\s*["']([^"']+)["']/;
|
|
149
|
+
const OBJECT_RE = /\bobject\s*:\s*["']([^"']+)["']/;
|
|
150
|
+
const INVARIANTS_RE = /\binvariants\s*:\s*\[([\s\S]*?)\]/;
|
|
151
|
+
const STRING_LITERALS_RE = /["']([^"'\\]+)["']/g;
|
|
152
|
+
const SERVICE_CLIENT_ASSIGNMENT_RE =
|
|
153
|
+
/\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*createSupabaseServiceClient\s*\(\s*\)/g;
|
|
154
|
+
const EXPORTED_FUNCTION_RE =
|
|
155
|
+
/\bexport\s+(?:default\s+)?(?:async\s+)?function\s+([A-Za-z_$][\w$]*)\s*\(/;
|
|
156
|
+
|
|
157
|
+
// Strip `// line` comments and `/* block */` comments before regex matching
|
|
158
|
+
// so commented-out examples in JSDoc don't produce false positives. This is
|
|
159
|
+
// naive (doesn't handle strings containing `//`) but sufficient for the
|
|
160
|
+
// repo's code style. False positives fail loudly when the capabilities.json
|
|
161
|
+
// diff lights up — that's an acceptable signal.
|
|
162
|
+
function stripComments(src) {
|
|
163
|
+
return src
|
|
164
|
+
.replace(/\/\*[\s\S]*?\*\//g, "")
|
|
165
|
+
.replace(/^\s*\/\/.*$/gm, "")
|
|
166
|
+
.replace(/[ \t]+\/\/.*$/gm, "");
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function extractStringArrayLiterals(body) {
|
|
170
|
+
if (!body) return [];
|
|
171
|
+
const out = [];
|
|
172
|
+
for (const m of body.matchAll(STRING_LITERALS_RE)) out.push(m[1]);
|
|
173
|
+
return out;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function escapeRegExp(value) {
|
|
177
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function resolveTableExpression(expression, tableConstants) {
|
|
181
|
+
const literal = expression.match(/^["']([^"']+)["']$/);
|
|
182
|
+
if (literal) return literal[1];
|
|
183
|
+
return tableConstants.get(expression) ?? null;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function extractServiceRoleMutations(src, tableConstants) {
|
|
187
|
+
const clientNames = [...src.matchAll(SERVICE_CLIENT_ASSIGNMENT_RE)].map(
|
|
188
|
+
(match) => match[1],
|
|
189
|
+
);
|
|
190
|
+
const mutations = new Map();
|
|
191
|
+
|
|
192
|
+
for (const clientName of new Set(clientNames)) {
|
|
193
|
+
const escapedClient = escapeRegExp(clientName);
|
|
194
|
+
// Stop before the next `.from()` call on this client so a SELECT followed
|
|
195
|
+
// by a later UPDATE is not misclassified as one mutating query chain.
|
|
196
|
+
const mutationRe = new RegExp(
|
|
197
|
+
`\\b${escapedClient}\\s*\\.\\s*from\\s*\\(\\s*(` +
|
|
198
|
+
`["'][^"']+["']|[A-Za-z_$][\\w$]*` +
|
|
199
|
+
`)\\s*\\)(?:(?!\\b${escapedClient}\\s*\\.\\s*from\\s*\\()[\\s\\S])*?` +
|
|
200
|
+
`\\.\\s*(insert|upsert|update|delete)\\s*\\(`,
|
|
201
|
+
"g",
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
for (const match of src.matchAll(mutationRe)) {
|
|
205
|
+
const table = resolveTableExpression(match[1], tableConstants);
|
|
206
|
+
if (!table) continue;
|
|
207
|
+
const operation = match[2];
|
|
208
|
+
mutations.set(`${table}:${operation}`, { table, operation });
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return [...mutations.values()].sort((a, b) =>
|
|
213
|
+
`${a.table}:${a.operation}`.localeCompare(`${b.table}:${b.operation}`),
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function scanFile(file, tableConstants) {
|
|
218
|
+
const raw = fs.readFileSync(file, "utf8");
|
|
219
|
+
const src = stripComments(raw);
|
|
220
|
+
const relFile = path.relative(process.cwd(), file);
|
|
221
|
+
|
|
222
|
+
// Only process files that actually invoke createAction at runtime. The
|
|
223
|
+
// middleware/utils files that *define* createAction are excluded by the
|
|
224
|
+
// action-dir glob, but this is an extra guard.
|
|
225
|
+
if (!/\bcreateAction\s*\(/.test(src)) return null;
|
|
226
|
+
|
|
227
|
+
const caMatch = src.match(CREATE_ACTION_RE);
|
|
228
|
+
if (!caMatch) {
|
|
229
|
+
return {
|
|
230
|
+
kind: "unclassified",
|
|
231
|
+
file: relFile,
|
|
232
|
+
reason:
|
|
233
|
+
"createAction call present but the `createAction({ ... })` literal could not be matched by the v1 regex; check for multi-line or computed syntax",
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const fnMatch = caMatch[1].match(FUNCTION_NAME_RE);
|
|
238
|
+
if (!fnMatch) {
|
|
239
|
+
return {
|
|
240
|
+
kind: "unclassified",
|
|
241
|
+
file: relFile,
|
|
242
|
+
reason:
|
|
243
|
+
"createAction({ ... }) present but `functionName` is missing or computed; scanner only reads string literals in v1",
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const name = fnMatch[1];
|
|
248
|
+
const exportName = src.match(EXPORTED_FUNCTION_RE)?.[1] ?? null;
|
|
249
|
+
const moduleName = extractModule(file);
|
|
250
|
+
if (!moduleName) {
|
|
251
|
+
return {
|
|
252
|
+
kind: "unclassified",
|
|
253
|
+
file: relFile,
|
|
254
|
+
reason:
|
|
255
|
+
"could not determine module from path (expected `app/__<kind>/<module>/src/actions/...`)",
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
let verb = null;
|
|
260
|
+
let object = null;
|
|
261
|
+
let invariants = [];
|
|
262
|
+
// Require a caller-controlled workspaceId signal. Looking only before
|
|
263
|
+
// createAction misses the template's common `formData.workspaceId` shape,
|
|
264
|
+
// while searching for any `workspaceId` would misclassify acceptInvitation,
|
|
265
|
+
// which derives the workspace from a trusted token after validation.
|
|
266
|
+
const acceptsWorkspaceId =
|
|
267
|
+
/\bworkspace_?id\b/i.test(src.slice(0, caMatch.index)) ||
|
|
268
|
+
/\b(?:inputParams|formData)\s*\.\s*workspaceId\b/.test(src);
|
|
269
|
+
const internalOnly =
|
|
270
|
+
file.endsWith("_BOT.ts") ||
|
|
271
|
+
/^\s*import\s+["']server-only["']\s*;?/m.test(src);
|
|
272
|
+
const usesDirectUpdateTag =
|
|
273
|
+
/\bimport\s*\{[^}]*\bupdateTag\b[^}]*\}\s*from\s*["']next\/cache["']/.test(
|
|
274
|
+
src,
|
|
275
|
+
);
|
|
276
|
+
const serviceRoleMutations = extractServiceRoleMutations(src, tableConstants);
|
|
277
|
+
const middleware = {
|
|
278
|
+
auth: /\bwithAuth\s*\(/.test(src),
|
|
279
|
+
tenantIsolation: /\bwithTenantIsolation\s*\(/.test(src),
|
|
280
|
+
rbac: /\bwithRBAC\s*\(/.test(src),
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
const pfMatch = src.match(WITH_PROOF_RE);
|
|
284
|
+
if (pfMatch) {
|
|
285
|
+
const body = pfMatch[1];
|
|
286
|
+
verb = body.match(VERB_RE)?.[1] ?? null;
|
|
287
|
+
object = body.match(OBJECT_RE)?.[1] ?? null;
|
|
288
|
+
const invBody = body.match(INVARIANTS_RE)?.[1];
|
|
289
|
+
invariants = extractStringArrayLiterals(invBody);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
return {
|
|
293
|
+
kind: "capability",
|
|
294
|
+
capability: {
|
|
295
|
+
name,
|
|
296
|
+
module: moduleName,
|
|
297
|
+
exportName,
|
|
298
|
+
verb,
|
|
299
|
+
object,
|
|
300
|
+
invariants,
|
|
301
|
+
acceptsWorkspaceId,
|
|
302
|
+
internalOnly,
|
|
303
|
+
usesDirectUpdateTag,
|
|
304
|
+
serviceRoleMutations,
|
|
305
|
+
middleware,
|
|
306
|
+
file: relFile.split(path.sep).join("/"),
|
|
307
|
+
},
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
export function main() {
|
|
312
|
+
const files = listActionFiles();
|
|
313
|
+
const tableConstants = readTableConstants();
|
|
314
|
+
const capabilities = [];
|
|
315
|
+
const unclassified = [];
|
|
316
|
+
|
|
317
|
+
for (const file of files) {
|
|
318
|
+
const result = scanFile(file, tableConstants);
|
|
319
|
+
if (!result) continue;
|
|
320
|
+
if (result.kind === "capability") {
|
|
321
|
+
capabilities.push(result.capability);
|
|
322
|
+
} else {
|
|
323
|
+
unclassified.push({ file: result.file, reason: result.reason });
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
capabilities.sort((a, b) => {
|
|
328
|
+
const ka = `${a.module}:${a.name}`;
|
|
329
|
+
const kb = `${b.module}:${b.name}`;
|
|
330
|
+
return ka < kb ? -1 : ka > kb ? 1 : 0;
|
|
331
|
+
});
|
|
332
|
+
unclassified.sort((a, b) => (a.file < b.file ? -1 : a.file > b.file ? 1 : 0));
|
|
333
|
+
|
|
334
|
+
// Deterministic output: no generatedAt timestamp. The freshness check is
|
|
335
|
+
// `git diff --exit-code`; a timestamp would trip it on every run.
|
|
336
|
+
const output = {
|
|
337
|
+
schemaVersion: 1,
|
|
338
|
+
capabilities,
|
|
339
|
+
unclassified,
|
|
340
|
+
};
|
|
341
|
+
|
|
342
|
+
fs.mkdirSync(path.dirname(OUTPUT_PATH), { recursive: true });
|
|
343
|
+
fs.writeFileSync(OUTPUT_PATH, JSON.stringify(output, null, 2) + "\n", "utf8");
|
|
344
|
+
|
|
345
|
+
const total = capabilities.length + unclassified.length;
|
|
346
|
+
console.log(
|
|
347
|
+
`[proof:scan] scanned ${files.length} action file(s); wrote ${capabilities.length} capabilities, ${unclassified.length} unclassified (total ${total}) to ${OUTPUT_PATH}`,
|
|
348
|
+
);
|
|
349
|
+
if (unclassified.length > 0) {
|
|
350
|
+
console.log(`[proof:scan] unclassified entries:`);
|
|
351
|
+
for (const u of unclassified) console.log(` - ${u.file}: ${u.reason}`);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const invokedDirectly =
|
|
356
|
+
process.argv[1] !== undefined &&
|
|
357
|
+
path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
|
358
|
+
|
|
359
|
+
if (invokedDirectly) main();
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { spawnSync } from "node:child_process";
|
|
4
|
+
import fs from "node:fs";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
|
|
8
|
+
import { loadProofConfig } from "./config.mjs";
|
|
9
|
+
|
|
10
|
+
const CLI_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
11
|
+
const ENGINES = {
|
|
12
|
+
scan: "scan_proof_capabilities.mjs",
|
|
13
|
+
parse: "parse_proof_schema.mjs",
|
|
14
|
+
registry: "generate_proof_action_registry.mjs",
|
|
15
|
+
verify: "proof_verify.mjs",
|
|
16
|
+
coverage: "proof_coverage.mjs",
|
|
17
|
+
drift: "proof_drift.mjs",
|
|
18
|
+
modules: "scan_module_meta.mjs",
|
|
19
|
+
"modules-check": "proof_module_check.mjs",
|
|
20
|
+
mutate: "proof_mutation_check.mjs",
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
function printHelp() {
|
|
24
|
+
console.log(`Usage: proof-harness [--config <path>] <command> [flags]
|
|
25
|
+
|
|
26
|
+
Commands:
|
|
27
|
+
scan, parse, registry, build, verify, coverage, inventory, drift,
|
|
28
|
+
modules, modules-check, mutate
|
|
29
|
+
|
|
30
|
+
Repository paths and the protected mutation catalog are read from
|
|
31
|
+
proof.config.mjs. Missing config falls back to a conventional application layout.`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function runEngine(command, args, config) {
|
|
35
|
+
const engine = ENGINES[command];
|
|
36
|
+
if (!engine) throw new Error(`unknown proof command: ${command}`);
|
|
37
|
+
const result = spawnSync(
|
|
38
|
+
process.execPath,
|
|
39
|
+
[path.join(CLI_DIR, "engines", engine), ...args],
|
|
40
|
+
{
|
|
41
|
+
cwd: config.rootDir,
|
|
42
|
+
env: {
|
|
43
|
+
...process.env,
|
|
44
|
+
...(config.configPath
|
|
45
|
+
? { PROOF_HARNESS_CONFIG: config.configPath }
|
|
46
|
+
: {}),
|
|
47
|
+
},
|
|
48
|
+
stdio: "inherit",
|
|
49
|
+
},
|
|
50
|
+
);
|
|
51
|
+
if (result.error) throw result.error;
|
|
52
|
+
return result.status ?? 1;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function runCli(argv = process.argv.slice(2)) {
|
|
56
|
+
const args = [...argv];
|
|
57
|
+
const configIndex = args.indexOf("--config");
|
|
58
|
+
let configPath;
|
|
59
|
+
if (configIndex >= 0) {
|
|
60
|
+
configPath = args[configIndex + 1];
|
|
61
|
+
if (!configPath) throw new Error("--config requires a path");
|
|
62
|
+
args.splice(configIndex, 2);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const command = args.shift();
|
|
66
|
+
if (!command || command === "--help" || command === "-h") {
|
|
67
|
+
printHelp();
|
|
68
|
+
return 0;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const config = await loadProofConfig({ configPath });
|
|
72
|
+
if (command === "build") {
|
|
73
|
+
for (const [step, stepArgs] of [
|
|
74
|
+
["scan", []],
|
|
75
|
+
["parse", []],
|
|
76
|
+
["registry", ["--check"]],
|
|
77
|
+
["modules", []],
|
|
78
|
+
]) {
|
|
79
|
+
const status = runEngine(step, stepArgs, config);
|
|
80
|
+
if (status !== 0) return status;
|
|
81
|
+
}
|
|
82
|
+
return 0;
|
|
83
|
+
}
|
|
84
|
+
if (command === "inventory") {
|
|
85
|
+
return runEngine("mutate", ["--inventory", ...args], config);
|
|
86
|
+
}
|
|
87
|
+
return runEngine(command, args, config);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const invokedDirectly =
|
|
91
|
+
process.argv[1] !== undefined &&
|
|
92
|
+
fs.realpathSync(path.resolve(process.argv[1])) ===
|
|
93
|
+
fs.realpathSync(fileURLToPath(import.meta.url));
|
|
94
|
+
|
|
95
|
+
if (invokedDirectly) {
|
|
96
|
+
runCli().then(
|
|
97
|
+
(status) => {
|
|
98
|
+
process.exitCode = status;
|
|
99
|
+
},
|
|
100
|
+
(error) => {
|
|
101
|
+
console.error(
|
|
102
|
+
`[proof-harness] ${error instanceof Error ? error.message : String(error)}`,
|
|
103
|
+
);
|
|
104
|
+
process.exitCode = 2;
|
|
105
|
+
},
|
|
106
|
+
);
|
|
107
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Discriminated union returned by every server action that uses the
|
|
3
|
+
* `createAction` pipeline. Identical in shape to `ActionResult<T>` in
|
|
4
|
+
* `@shared/middleware`.
|
|
5
|
+
*/
|
|
6
|
+
type ActionResult<T = unknown> = {
|
|
7
|
+
success: true;
|
|
8
|
+
data: T;
|
|
9
|
+
} | {
|
|
10
|
+
success: false;
|
|
11
|
+
error: string;
|
|
12
|
+
};
|
|
13
|
+
/**
|
|
14
|
+
* Body accepted by `POST /api/proof/invoke-action`.
|
|
15
|
+
*
|
|
16
|
+
* `module` + `action` together form the registry key (`"module:action"`).
|
|
17
|
+
* `inputParams` is passed through as-is; each action validates its own shape
|
|
18
|
+
* with Zod at runtime.
|
|
19
|
+
*/
|
|
20
|
+
interface InvokeActionRequest {
|
|
21
|
+
module: string;
|
|
22
|
+
action: string;
|
|
23
|
+
inputParams: Record<string, unknown>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type { ActionResult as A, InvokeActionRequest as I };
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { SupabaseClient } from '@supabase/supabase-js';
|
|
2
|
+
|
|
3
|
+
interface SeedWorkspace {
|
|
4
|
+
id: string;
|
|
5
|
+
name: string;
|
|
6
|
+
ownerId: string | null;
|
|
7
|
+
}
|
|
8
|
+
interface SeedUser {
|
|
9
|
+
id: string;
|
|
10
|
+
email: string;
|
|
11
|
+
password: string;
|
|
12
|
+
workspaceId: string;
|
|
13
|
+
role: "owner" | "admin" | "member" | "viewer";
|
|
14
|
+
/**
|
|
15
|
+
* `workspace_members.id` for this user's membership row in `workspaceId`.
|
|
16
|
+
* Needed by proofs that invoke actions keyed on the membership (e.g.
|
|
17
|
+
* `removeMember` takes `memberId = workspace_members.id`, not user_id).
|
|
18
|
+
*/
|
|
19
|
+
membershipId: string;
|
|
20
|
+
}
|
|
21
|
+
declare const seed: {
|
|
22
|
+
/**
|
|
23
|
+
* Create a workspace with no owner. Use `seed.user({ workspace, role: "owner" })`
|
|
24
|
+
* afterwards to attach an owner. Returns the inserted row.
|
|
25
|
+
*
|
|
26
|
+
* Bypasses RLS via the service-role client, so it works even when no user
|
|
27
|
+
* is authenticated.
|
|
28
|
+
*/
|
|
29
|
+
workspace(name: string): Promise<SeedWorkspace>;
|
|
30
|
+
/**
|
|
31
|
+
* Create an auth user with the given credentials and attach them to the
|
|
32
|
+
* provided workspace with the given role. Relies on the `handle_new_user`
|
|
33
|
+
* trigger to mirror the row into `public.users` automatically.
|
|
34
|
+
*/
|
|
35
|
+
user(opts: {
|
|
36
|
+
email: string;
|
|
37
|
+
password: string;
|
|
38
|
+
workspace: SeedWorkspace;
|
|
39
|
+
role: "owner" | "admin" | "member" | "viewer";
|
|
40
|
+
}): Promise<SeedUser>;
|
|
41
|
+
/**
|
|
42
|
+
* Delete a workspace and cascade-clean its members. Used by helpers for
|
|
43
|
+
* teardown. Safe to call on a workspace that no longer exists.
|
|
44
|
+
*/
|
|
45
|
+
deleteWorkspace(workspaceId: string): Promise<void>;
|
|
46
|
+
/**
|
|
47
|
+
* Delete an auth user (cascades to public.users and workspace_members via
|
|
48
|
+
* the schema's ON DELETE CASCADE). Safe to call if the user no longer
|
|
49
|
+
* exists.
|
|
50
|
+
*/
|
|
51
|
+
deleteUser(userId: string): Promise<void>;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Seed handles available to a tenant-isolation fixture factory.
|
|
56
|
+
*
|
|
57
|
+
* A factory normally inserts one row for each side so the helper can prove both
|
|
58
|
+
* isolation directions. It may use fewer handles when the table is user-scoped
|
|
59
|
+
* or when an existing seeded workspace/user row is itself the fixture.
|
|
60
|
+
*/
|
|
61
|
+
interface ProofFixtureContext {
|
|
62
|
+
orgA: SeedWorkspace;
|
|
63
|
+
orgB: SeedWorkspace;
|
|
64
|
+
userA: SeedUser;
|
|
65
|
+
userB: SeedUser;
|
|
66
|
+
sb: SupabaseClient;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Explicit, table-owned setup contract for a Proof fixture.
|
|
70
|
+
*
|
|
71
|
+
* Keep factories under `e2e/fixtures/<table>.ts`. The table name is repeated at
|
|
72
|
+
* runtime so a copied factory cannot silently seed one table while a proof
|
|
73
|
+
* probes another.
|
|
74
|
+
*/
|
|
75
|
+
interface ProofFixtureFactory<TTable extends string = string> {
|
|
76
|
+
readonly table: TTable;
|
|
77
|
+
create(context: ProofFixtureContext): Promise<void>;
|
|
78
|
+
}
|
|
79
|
+
interface DefineProofFixtureOptions<TTable extends string> {
|
|
80
|
+
table: TTable;
|
|
81
|
+
create(context: ProofFixtureContext): Promise<void>;
|
|
82
|
+
}
|
|
83
|
+
/** Define a completed, explicit fixture factory. */
|
|
84
|
+
declare function defineProofFixture<const TTable extends string>(options: DefineProofFixtureOptions<TTable>): ProofFixtureFactory<TTable>;
|
|
85
|
+
/**
|
|
86
|
+
* Error used when a proof exists before its table's valid fixture shape is
|
|
87
|
+
* known. `assert.tenantIsolation` recognizes it and records an incomplete
|
|
88
|
+
* assertion rather than allowing the missing setup to look green.
|
|
89
|
+
*/
|
|
90
|
+
declare class ProofFixturePendingError extends Error {
|
|
91
|
+
readonly table: string;
|
|
92
|
+
readonly reason: string;
|
|
93
|
+
readonly code = "fixture_factory_required";
|
|
94
|
+
constructor(table: string, reason: string);
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Declare the intentional pre-schema state of a fixture.
|
|
98
|
+
*
|
|
99
|
+
* The builder replaces this with `defineProofFixture(...)` after migrations
|
|
100
|
+
* settle. Until then the proof remains explicitly incomplete.
|
|
101
|
+
*/
|
|
102
|
+
declare function pendingProofFixture<const TTable extends string>(table: TTable, reason?: string): ProofFixtureFactory<TTable>;
|
|
103
|
+
declare function isProofFixturePendingError(error: unknown): error is ProofFixturePendingError;
|
|
104
|
+
|
|
105
|
+
export { type DefineProofFixtureOptions as D, type ProofFixtureContext as P, type SeedUser as S, type ProofFixtureFactory as a, ProofFixturePendingError as b, type SeedWorkspace as c, defineProofFixture as d, isProofFixturePendingError as i, pendingProofFixture as p, seed as s };
|