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,620 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// ---------------------------------------------------------------------------
|
|
3
|
+
// proof_verify.mjs
|
|
4
|
+
//
|
|
5
|
+
// The orchestrator CLI for the Proof SDK verification loop. Responsibilities:
|
|
6
|
+
// 1. Optionally run all Playwright proofs (e2e/proofs/*.proof.ts).
|
|
7
|
+
// 2. Read per-proof trace artifacts from `.proof/traces/*.json`.
|
|
8
|
+
// 3. Read `.proof/capabilities.json` + `.proof/schema.json` (produced by
|
|
9
|
+
// scan/parse; considered current).
|
|
10
|
+
// 4. Resolve a mission manifest (flags or `.proof/current-mission.json`).
|
|
11
|
+
// 5. In manifest mode: validate via `validate-mission.ts` and write an
|
|
12
|
+
// aggregated `.proof/traces/<missionId>.json`.
|
|
13
|
+
// 6. In no-manifest mode: print a human summary, exit 0 if all traces
|
|
14
|
+
// passed.
|
|
15
|
+
//
|
|
16
|
+
// Emits structured `[PROOF_FAIL] <category>: <message>` lines on failure
|
|
17
|
+
// so CI log readers / consumers can route issues without parsing prose.
|
|
18
|
+
// Exit code: 0 on success, 1 on any failure.
|
|
19
|
+
//
|
|
20
|
+
// Flags (all optional):
|
|
21
|
+
// --manifest <path> Path to mission manifest JSON (default: resolve).
|
|
22
|
+
// --mission <id> Look up manifest at `.proof/missions/<id>.json`.
|
|
23
|
+
// --no-run Skip running Playwright; aggregate existing traces.
|
|
24
|
+
// --traces-dir <path> Trace directory (default: `.proof/traces`).
|
|
25
|
+
// --help Print usage.
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
29
|
+
import fs from "node:fs";
|
|
30
|
+
import path from "node:path";
|
|
31
|
+
import { pathToFileURL } from "node:url";
|
|
32
|
+
import nextEnv from "@next/env";
|
|
33
|
+
|
|
34
|
+
import { validateMission } from "../../dist/node.js";
|
|
35
|
+
import { loadProofConfig } from "../config.mjs";
|
|
36
|
+
|
|
37
|
+
const CONFIG = await loadProofConfig();
|
|
38
|
+
process.chdir(CONFIG.rootDir);
|
|
39
|
+
|
|
40
|
+
const { loadEnvConfig } = nextEnv;
|
|
41
|
+
loadEnvConfig(CONFIG.rootDir);
|
|
42
|
+
|
|
43
|
+
const TRACES_DIR_DEFAULT = CONFIG.artifacts.traces;
|
|
44
|
+
const MISSIONS_DIR = CONFIG.mission.directory;
|
|
45
|
+
const DEFAULT_MANIFEST = CONFIG.mission.current;
|
|
46
|
+
const CAPABILITIES_PATH = CONFIG.artifacts.capabilities;
|
|
47
|
+
const SCHEMA_PATH = CONFIG.artifacts.schema;
|
|
48
|
+
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
// CLI parsing
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
function parseArgs(argv) {
|
|
54
|
+
const args = { noRun: false, help: false, tracesDir: TRACES_DIR_DEFAULT };
|
|
55
|
+
for (let i = 0; i < argv.length; i++) {
|
|
56
|
+
const v = argv[i];
|
|
57
|
+
if (v === "--help" || v === "-h") args.help = true;
|
|
58
|
+
else if (v === "--no-run") args.noRun = true;
|
|
59
|
+
else if (v === "--mission") args.mission = argv[++i];
|
|
60
|
+
else if (v === "--manifest") args.manifestPath = argv[++i];
|
|
61
|
+
else if (v === "--traces-dir") args.tracesDir = argv[++i];
|
|
62
|
+
else if (v.startsWith("--")) {
|
|
63
|
+
console.error(`[proof:verify] unknown flag: ${v}`);
|
|
64
|
+
process.exit(2);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return args;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function printHelp() {
|
|
71
|
+
console.log(`Usage: pnpm proof:verify [flags]
|
|
72
|
+
|
|
73
|
+
Flags:
|
|
74
|
+
--manifest <path> Path to mission manifest JSON.
|
|
75
|
+
--mission <id> Look up manifest at .proof/missions/<id>.json.
|
|
76
|
+
--no-run Skip running proofs; aggregate existing traces.
|
|
77
|
+
--traces-dir <path> Trace directory (default: .proof/traces).
|
|
78
|
+
--help Print this help.
|
|
79
|
+
|
|
80
|
+
If no manifest is found, runs in no-manifest mode: verifies that every
|
|
81
|
+
trace passed but skips requirement validation.`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
// Steps
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
function resolveManifest(args) {
|
|
89
|
+
if (args.manifestPath) {
|
|
90
|
+
if (!fs.existsSync(args.manifestPath)) {
|
|
91
|
+
console.error(
|
|
92
|
+
`[proof:verify] --manifest path not found: ${args.manifestPath}`,
|
|
93
|
+
);
|
|
94
|
+
process.exit(2);
|
|
95
|
+
}
|
|
96
|
+
return {
|
|
97
|
+
path: args.manifestPath,
|
|
98
|
+
manifest: JSON.parse(fs.readFileSync(args.manifestPath, "utf8")),
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
if (args.mission) {
|
|
102
|
+
const p = path.join(MISSIONS_DIR, `${args.mission}.json`);
|
|
103
|
+
if (!fs.existsSync(p)) {
|
|
104
|
+
console.error(
|
|
105
|
+
`[proof:verify] --mission ${args.mission} not found at ${p}`,
|
|
106
|
+
);
|
|
107
|
+
process.exit(2);
|
|
108
|
+
}
|
|
109
|
+
return { path: p, manifest: JSON.parse(fs.readFileSync(p, "utf8")) };
|
|
110
|
+
}
|
|
111
|
+
if (fs.existsSync(DEFAULT_MANIFEST)) {
|
|
112
|
+
return {
|
|
113
|
+
path: DEFAULT_MANIFEST,
|
|
114
|
+
manifest: JSON.parse(fs.readFileSync(DEFAULT_MANIFEST, "utf8")),
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
return { path: null, manifest: null };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Well-known Playwright error fragment when a browser binary is missing.
|
|
121
|
+
// Matches e.g. `browserType.launch: Executable doesn't exist at /…/chromium`.
|
|
122
|
+
const PLAYWRIGHT_BROWSER_MISSING_RE = /Executable doesn'?t exist/i;
|
|
123
|
+
|
|
124
|
+
async function existingProofServerIssue() {
|
|
125
|
+
const baseUrl = process.env.DEV_SERVER || "http://localhost:3000";
|
|
126
|
+
const secret = process.env.API_SECRET_KEY;
|
|
127
|
+
if (!secret) {
|
|
128
|
+
return "API_SECRET_KEY is not set after loading .env.local; the proof runner cannot authenticate its dev-only routes.";
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
try {
|
|
132
|
+
const response = await fetch(new URL("/api/proof/health", baseUrl), {
|
|
133
|
+
headers: {
|
|
134
|
+
"x-proof-secret": secret,
|
|
135
|
+
...(process.env.NEXT_PUBLIC_SUPABASE_URL
|
|
136
|
+
? {
|
|
137
|
+
"x-proof-supabase-url": process.env.NEXT_PUBLIC_SUPABASE_URL,
|
|
138
|
+
}
|
|
139
|
+
: {}),
|
|
140
|
+
},
|
|
141
|
+
signal: AbortSignal.timeout(5_000),
|
|
142
|
+
});
|
|
143
|
+
let body = null;
|
|
144
|
+
try {
|
|
145
|
+
body = await response.json();
|
|
146
|
+
} catch {
|
|
147
|
+
// An HTML/empty response means this is not a compatible proof server.
|
|
148
|
+
}
|
|
149
|
+
if (response.ok) {
|
|
150
|
+
if (body?.protocolVersion !== 1) {
|
|
151
|
+
return (
|
|
152
|
+
`an existing server at ${baseUrl} uses an incompatible proof health protocol ` +
|
|
153
|
+
`(expected 1, received ${body?.protocolVersion ?? "<missing>"})`
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
158
|
+
let detail = `status ${response.status}`;
|
|
159
|
+
if (body) {
|
|
160
|
+
detail = body.suggestion ?? body.error ?? detail;
|
|
161
|
+
}
|
|
162
|
+
return `an existing server at ${baseUrl} failed the proof compatibility check: ${detail}`;
|
|
163
|
+
} catch (error) {
|
|
164
|
+
const cause = error?.cause;
|
|
165
|
+
const code =
|
|
166
|
+
cause && typeof cause === "object" && "code" in cause
|
|
167
|
+
? cause.code
|
|
168
|
+
: undefined;
|
|
169
|
+
if (code === "ECONNREFUSED") return null;
|
|
170
|
+
if (error instanceof DOMException && error.name === "TimeoutError") {
|
|
171
|
+
return `an existing process at ${baseUrl} did not answer /api/proof/health within 5s`;
|
|
172
|
+
}
|
|
173
|
+
return `the proof server at ${baseUrl} could not be authenticated: ${
|
|
174
|
+
error instanceof Error ? error.message : String(error)
|
|
175
|
+
}`;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async function runProofs(provenance) {
|
|
180
|
+
console.log("[proof:verify] running Playwright proofs project…");
|
|
181
|
+
return new Promise((resolve) => {
|
|
182
|
+
const playwrightArgs = [
|
|
183
|
+
"exec",
|
|
184
|
+
"playwright",
|
|
185
|
+
"test",
|
|
186
|
+
"--project=proofs",
|
|
187
|
+
"--reporter=list",
|
|
188
|
+
];
|
|
189
|
+
if (process.env.CI) {
|
|
190
|
+
// GitHub's standard runner has two CPUs. Database fixtures use unique
|
|
191
|
+
// identifiers, so two workers shorten the green suite without sharing
|
|
192
|
+
// browser contexts or weakening any assertion.
|
|
193
|
+
playwrightArgs.push("--workers=2");
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const child = spawn("pnpm", playwrightArgs, {
|
|
197
|
+
env: {
|
|
198
|
+
...process.env,
|
|
199
|
+
CI: process.env.CI ?? "",
|
|
200
|
+
// Resolved once here and passed down so every trace in a run agrees
|
|
201
|
+
// about the code it observed. Left unset rather than blank when
|
|
202
|
+
// unknown, since the SDK reads absence as "ask git yourself".
|
|
203
|
+
...(provenance.commit ? { PROOF_COMMIT: provenance.commit } : {}),
|
|
204
|
+
...(provenance.dirty === undefined
|
|
205
|
+
? {}
|
|
206
|
+
: { PROOF_DIRTY: String(provenance.dirty) }),
|
|
207
|
+
},
|
|
208
|
+
});
|
|
209
|
+
// Tee both streams through to the user's terminal while capturing them, so
|
|
210
|
+
// we can recognise the "Executable doesn't exist" failure after exit and
|
|
211
|
+
// emit a structured, actionable hint. Playwright reports the missing-browser
|
|
212
|
+
// error on STDOUT via its test reporter, so watching stderr alone silently
|
|
213
|
+
// misses the most common first-run failure on a fresh machine.
|
|
214
|
+
let captured = "";
|
|
215
|
+
child.stdout.on("data", (chunk) => {
|
|
216
|
+
captured += chunk.toString();
|
|
217
|
+
process.stdout.write(chunk);
|
|
218
|
+
});
|
|
219
|
+
child.stderr.on("data", (chunk) => {
|
|
220
|
+
captured += chunk.toString();
|
|
221
|
+
process.stderr.write(chunk);
|
|
222
|
+
});
|
|
223
|
+
child.on("error", (err) => {
|
|
224
|
+
console.error(
|
|
225
|
+
`[proof:verify] failed to spawn playwright: ${err.message}`,
|
|
226
|
+
);
|
|
227
|
+
resolve(false);
|
|
228
|
+
});
|
|
229
|
+
child.on("close", (code) => {
|
|
230
|
+
if (code !== 0 && PLAYWRIGHT_BROWSER_MISSING_RE.test(captured)) {
|
|
231
|
+
console.error(
|
|
232
|
+
`[PROOF_FAIL] playwright_browser_missing: expected Chromium binary to be installed; found it missing from the Playwright cache.\n` +
|
|
233
|
+
` hint: Run \`pnpm exec playwright install chromium\` once per machine, then retry.\n` +
|
|
234
|
+
` command: proof-harness verify`,
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
resolve(code === 0);
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function readArtifactsOrFail() {
|
|
243
|
+
for (const p of [CAPABILITIES_PATH, SCHEMA_PATH]) {
|
|
244
|
+
if (!fs.existsSync(p)) {
|
|
245
|
+
console.error(
|
|
246
|
+
`[PROOF_FAIL] freshness: ${p} missing; run \`pnpm proof:scan && pnpm proof:parse\` first`,
|
|
247
|
+
);
|
|
248
|
+
process.exit(1);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
return {
|
|
252
|
+
capabilities: JSON.parse(fs.readFileSync(CAPABILITIES_PATH, "utf8")),
|
|
253
|
+
schema: JSON.parse(fs.readFileSync(SCHEMA_PATH, "utf8")),
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function readTraces(tracesDir) {
|
|
258
|
+
if (!fs.existsSync(tracesDir)) return [];
|
|
259
|
+
const files = fs
|
|
260
|
+
.readdirSync(tracesDir, { withFileTypes: true })
|
|
261
|
+
.filter((e) => e.isFile() && e.name.endsWith(".json"))
|
|
262
|
+
.map((e) => path.join(tracesDir, e.name))
|
|
263
|
+
.sort();
|
|
264
|
+
const traces = [];
|
|
265
|
+
for (const f of files) {
|
|
266
|
+
try {
|
|
267
|
+
const parsed = JSON.parse(fs.readFileSync(f, "utf8"));
|
|
268
|
+
// Skip aggregated mission traces; they have `proofs` not `steps`.
|
|
269
|
+
if (parsed && Array.isArray(parsed.steps)) traces.push(parsed);
|
|
270
|
+
} catch (err) {
|
|
271
|
+
console.error(
|
|
272
|
+
`[proof:verify] failed to parse trace ${f}: ${err.message}`,
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
return traces;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Remove only the top-level JSON artifacts this runner owns.
|
|
281
|
+
*
|
|
282
|
+
* `--traces-dir` is user-controlled, so recursively deleting the supplied
|
|
283
|
+
* directory would be unsafe. Per-proof and aggregated mission artifacts are
|
|
284
|
+
* top-level JSON files; clearing those is sufficient to make a run fresh while
|
|
285
|
+
* leaving unrelated files and nested directories untouched.
|
|
286
|
+
*/
|
|
287
|
+
function clearTraceArtifacts(tracesDir) {
|
|
288
|
+
if (!fs.existsSync(tracesDir)) return 0;
|
|
289
|
+
let removed = 0;
|
|
290
|
+
for (const entry of fs.readdirSync(tracesDir, { withFileTypes: true })) {
|
|
291
|
+
if (!entry.isFile() || !entry.name.endsWith(".json")) continue;
|
|
292
|
+
fs.unlinkSync(path.join(tracesDir, entry.name));
|
|
293
|
+
removed += 1;
|
|
294
|
+
}
|
|
295
|
+
return removed;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Best-effort provenance for the run as a whole.
|
|
300
|
+
*
|
|
301
|
+
* A trace that says "tenant isolation holds" is only worth something if you can
|
|
302
|
+
* tell WHICH code it was observed against. Every field is optional on purpose:
|
|
303
|
+
* shallow clones, exported tarballs and non-git checkouts must still produce a
|
|
304
|
+
* usable artifact, so a missing SHA degrades the evidence rather than failing
|
|
305
|
+
* the run.
|
|
306
|
+
*/
|
|
307
|
+
function runProvenance() {
|
|
308
|
+
const git = (args) => {
|
|
309
|
+
const res = spawnSync("git", args, { encoding: "utf8" });
|
|
310
|
+
if (res.status !== 0) return undefined;
|
|
311
|
+
return res.stdout.trim() || undefined;
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
const commit = git(["rev-parse", "HEAD"]);
|
|
315
|
+
const branch = git(["rev-parse", "--abbrev-ref", "HEAD"]);
|
|
316
|
+
const dirtyOutput = git(["status", "--porcelain"]);
|
|
317
|
+
|
|
318
|
+
return {
|
|
319
|
+
commit,
|
|
320
|
+
branch,
|
|
321
|
+
// Whether the working tree had uncommitted changes when the proof ran. A
|
|
322
|
+
// green proof from a dirty tree does not describe any reviewable commit.
|
|
323
|
+
dirty: dirtyOutput === undefined ? undefined : dirtyOutput.length > 0,
|
|
324
|
+
repository:
|
|
325
|
+
process.env.GITHUB_REPOSITORY ?? git(["remote", "get-url", "origin"]),
|
|
326
|
+
ci: Boolean(process.env.CI),
|
|
327
|
+
runId: process.env.GITHUB_RUN_ID,
|
|
328
|
+
command: `${path.basename(process.argv[0])} ${process.argv.slice(1).join(" ")}`,
|
|
329
|
+
nodeVersion: process.version,
|
|
330
|
+
platform: `${process.platform}-${process.arch}`,
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Tally a trace's assertions by status. `status` is absent on traces written
|
|
336
|
+
* before it existed, in which case it is derived from `passed` — the same rule
|
|
337
|
+
* the SDK's normalizeAssertionStatus applies.
|
|
338
|
+
*/
|
|
339
|
+
export function countAssertionStatuses(trace) {
|
|
340
|
+
const counts = { passed: 0, failed: 0, incomplete: 0, skipped: 0 };
|
|
341
|
+
for (const step of trace.steps ?? []) {
|
|
342
|
+
for (const assertion of step.assertions ?? []) {
|
|
343
|
+
const status =
|
|
344
|
+
assertion.status && assertion.status in counts
|
|
345
|
+
? assertion.status
|
|
346
|
+
: assertion.passed
|
|
347
|
+
? "passed"
|
|
348
|
+
: "failed";
|
|
349
|
+
counts[status] += 1;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
return counts;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function writeAggregatedTrace(
|
|
356
|
+
missionId,
|
|
357
|
+
bundle,
|
|
358
|
+
manifest,
|
|
359
|
+
outcome,
|
|
360
|
+
provenance,
|
|
361
|
+
) {
|
|
362
|
+
const aggregated = {
|
|
363
|
+
schemaVersion: 1,
|
|
364
|
+
missionId,
|
|
365
|
+
provenance,
|
|
366
|
+
// Top-level mission verdict. Consumers read this single boolean to decide
|
|
367
|
+
// pass/fail. True only when: (a) every proof spec in `bundle` passed AND
|
|
368
|
+
// (b) mission validation against the manifest produced zero issues.
|
|
369
|
+
passed: outcome.passed,
|
|
370
|
+
aggregatedAt: new Date().toISOString(),
|
|
371
|
+
manifestSummary: {
|
|
372
|
+
missionTitle: manifest.missionTitle,
|
|
373
|
+
capabilities_must_exist:
|
|
374
|
+
manifest.requirements.capabilities_must_exist.length,
|
|
375
|
+
schema_must_contain: manifest.requirements.schema_must_contain.length,
|
|
376
|
+
trace_must_prove: manifest.requirements.trace_must_prove.length,
|
|
377
|
+
},
|
|
378
|
+
// Present (possibly empty) on success; populated on failure with every
|
|
379
|
+
// [PROOF_FAIL] the validator emitted, in the same structured shape.
|
|
380
|
+
issues: outcome.issues ?? [],
|
|
381
|
+
// Which proof supplied the evidence for each trace requirement. Requirements
|
|
382
|
+
// match against every assertion in the run, so without this the attribution
|
|
383
|
+
// would be invisible and "0 issues" would have to be taken on faith.
|
|
384
|
+
requirementEvidence: outcome.evidence ?? [],
|
|
385
|
+
proofs: bundle.map((t) => ({
|
|
386
|
+
proofId: t.proofId,
|
|
387
|
+
missionId: t.missionId ?? null,
|
|
388
|
+
specFile: t.specFile ?? null,
|
|
389
|
+
specHash: t.specHash ?? null,
|
|
390
|
+
passed: t.passed,
|
|
391
|
+
durationMs: t.durationMs,
|
|
392
|
+
stepCount: t.steps?.length ?? 0,
|
|
393
|
+
assertionCount: (t.steps ?? []).reduce(
|
|
394
|
+
(acc, s) => acc + (s.assertions?.length ?? 0),
|
|
395
|
+
0,
|
|
396
|
+
),
|
|
397
|
+
// Broken out because a boolean cannot express "we did not find out". A
|
|
398
|
+
// proof can pass with a skipped direction, and a consumer that only reads
|
|
399
|
+
// `passed` would have no way to see it — the skip would look like one
|
|
400
|
+
// fewer assertion rather than one unanswered question.
|
|
401
|
+
assertionStatus: countAssertionStatuses(t),
|
|
402
|
+
})),
|
|
403
|
+
traces: bundle,
|
|
404
|
+
};
|
|
405
|
+
const outPath = path.join(TRACES_DIR_DEFAULT, `${missionId}.json`);
|
|
406
|
+
fs.mkdirSync(TRACES_DIR_DEFAULT, { recursive: true });
|
|
407
|
+
fs.writeFileSync(outPath, JSON.stringify(aggregated, null, 2) + "\n", "utf8");
|
|
408
|
+
return outPath;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function printFailureSummary(issues) {
|
|
412
|
+
for (const issue of issues) {
|
|
413
|
+
console.error(`[PROOF_FAIL] ${issue.category}: ${issue.message}`);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// ---------------------------------------------------------------------------
|
|
418
|
+
// Main
|
|
419
|
+
// ---------------------------------------------------------------------------
|
|
420
|
+
|
|
421
|
+
export async function main() {
|
|
422
|
+
const args = parseArgs(process.argv.slice(2));
|
|
423
|
+
if (args.help) {
|
|
424
|
+
printHelp();
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// Run-level failures that no amount of trace inspection can discover. These
|
|
429
|
+
// are tracked separately from validator issues and folded into the verdict
|
|
430
|
+
// below; a red Playwright run that happens to leave green traces behind must
|
|
431
|
+
// never be reported as a pass.
|
|
432
|
+
const runIssues = [];
|
|
433
|
+
|
|
434
|
+
// Read git once for the whole run: the specs are told what they are running
|
|
435
|
+
// against, and the aggregate reports the same thing. Asking twice invites a
|
|
436
|
+
// `dirty` flag that flips between the run and the summary.
|
|
437
|
+
const provenance = runProvenance();
|
|
438
|
+
|
|
439
|
+
if (!args.noRun) {
|
|
440
|
+
const serverIssue = await existingProofServerIssue();
|
|
441
|
+
if (serverIssue) {
|
|
442
|
+
runIssues.push({
|
|
443
|
+
category: "proof_server_stale",
|
|
444
|
+
message: `${serverIssue}. Restart the dev server after any .env.local change, then rerun proof:verify.`,
|
|
445
|
+
});
|
|
446
|
+
} else {
|
|
447
|
+
const removed = clearTraceArtifacts(args.tracesDir);
|
|
448
|
+
if (removed > 0) {
|
|
449
|
+
console.log(
|
|
450
|
+
`[proof:verify] cleared ${removed} previous trace artifact(s) before the run`,
|
|
451
|
+
);
|
|
452
|
+
}
|
|
453
|
+
const ok = await runProofs(provenance);
|
|
454
|
+
if (!ok) {
|
|
455
|
+
// Continue to aggregation so users see trace-level issues too, but
|
|
456
|
+
// remember that the run itself was red.
|
|
457
|
+
runIssues.push({
|
|
458
|
+
category: "proof_run",
|
|
459
|
+
message:
|
|
460
|
+
"one or more Playwright proofs failed; see test output above. " +
|
|
461
|
+
"Note that a proof which crashes before writing its trace leaves no artifact at all, " +
|
|
462
|
+
"so the trace summary below can look clean while the run was red.",
|
|
463
|
+
});
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
const { capabilities, schema } = readArtifactsOrFail();
|
|
469
|
+
const traces = readTraces(args.tracesDir);
|
|
470
|
+
const resolved = resolveManifest(args);
|
|
471
|
+
|
|
472
|
+
// Zero traces means zero evidence. Reporting that as "0 proof(s) passed" and
|
|
473
|
+
// exiting 0 is the single most dangerous failure mode this tool can have: a
|
|
474
|
+
// misconfigured runner, a bad --traces-dir, or a suite that died during
|
|
475
|
+
// startup all produce an empty directory, and a green check on no evidence
|
|
476
|
+
// gets trusted exactly like a green check on real evidence.
|
|
477
|
+
// `--no-run` deliberately validates artifacts already on disk rather than
|
|
478
|
+
// clearing them. Commit provenance catches cross-commit evidence; specFile
|
|
479
|
+
// existence catches renamed/deleted proof files even on the same dirty commit.
|
|
480
|
+
const orphaned = traces.filter(
|
|
481
|
+
(t) =>
|
|
482
|
+
typeof t.specFile === "string" &&
|
|
483
|
+
t.specFile.length > 0 &&
|
|
484
|
+
!fs.existsSync(path.resolve(t.specFile)),
|
|
485
|
+
);
|
|
486
|
+
if (orphaned.length > 0) {
|
|
487
|
+
runIssues.push({
|
|
488
|
+
category: "stale_trace",
|
|
489
|
+
message:
|
|
490
|
+
`${orphaned.length} trace(s) refer to proof specs that no longer exist: ` +
|
|
491
|
+
orphaned.map((t) => `${t.proofId} → ${t.specFile}`).join(", ") +
|
|
492
|
+
`. Deleted or renamed proofs cannot provide current evidence.`,
|
|
493
|
+
});
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
if (provenance.commit) {
|
|
497
|
+
const stale = traces.filter(
|
|
498
|
+
(t) => typeof t.commit === "string" && t.commit !== provenance.commit,
|
|
499
|
+
);
|
|
500
|
+
if (stale.length > 0) {
|
|
501
|
+
runIssues.push({
|
|
502
|
+
category: "stale_trace",
|
|
503
|
+
message:
|
|
504
|
+
`${stale.length} trace(s) were recorded against a different commit than this run: ` +
|
|
505
|
+
stale
|
|
506
|
+
.map((t) => `${t.proofId} @ ${t.commit.slice(0, 12)}`)
|
|
507
|
+
.join(", ") +
|
|
508
|
+
`. Expected ${provenance.commit.slice(0, 12)}. ` +
|
|
509
|
+
`Old evidence cannot speak for current code — clear the directory (rm -rf .proof/traces) and re-run.`,
|
|
510
|
+
});
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
if (traces.length === 0) {
|
|
515
|
+
runIssues.push({
|
|
516
|
+
category: "no_traces",
|
|
517
|
+
message:
|
|
518
|
+
`no trace artifacts found in ${args.tracesDir}; there is no evidence to verify. ` +
|
|
519
|
+
`Expected at least one <proofId>.json written by trace.proof(). ` +
|
|
520
|
+
`Check that e2e/proofs/*.proof.ts matched the Playwright "proofs" project and that the suite got past startup.`,
|
|
521
|
+
});
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
for (const issue of runIssues) {
|
|
525
|
+
console.error(`[PROOF_FAIL] ${issue.category}: ${issue.message}`);
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// Always surface proofs whose own step failed, even in no-manifest mode.
|
|
529
|
+
const failedProofs = traces.filter((t) => t.passed === false);
|
|
530
|
+
for (const t of failedProofs) {
|
|
531
|
+
for (const step of t.steps ?? []) {
|
|
532
|
+
if (step.passed === false) {
|
|
533
|
+
console.error(
|
|
534
|
+
`[PROOF_FAIL] ${step.kind}: proof "${t.proofId}" step "${step.intent}" failed on target "${step.target}"${step.error ? `; error: ${step.error}` : ""}`,
|
|
535
|
+
);
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
if (!resolved.manifest) {
|
|
541
|
+
if (failedProofs.length > 0 || runIssues.length > 0) {
|
|
542
|
+
const reasons = [];
|
|
543
|
+
if (failedProofs.length > 0) {
|
|
544
|
+
reasons.push(`${failedProofs.length}/${traces.length} proof(s) failed`);
|
|
545
|
+
}
|
|
546
|
+
for (const issue of runIssues) reasons.push(issue.category);
|
|
547
|
+
console.error(
|
|
548
|
+
`[proof:verify] no manifest (no-manifest mode); FAILED (${reasons.join(", ")})`,
|
|
549
|
+
);
|
|
550
|
+
process.exit(1);
|
|
551
|
+
}
|
|
552
|
+
console.log(
|
|
553
|
+
`[proof:verify] no manifest (no-manifest mode); ${traces.length} proof(s) passed`,
|
|
554
|
+
);
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
const manifest = resolved.manifest;
|
|
559
|
+
console.log(
|
|
560
|
+
`[proof:verify] validating against manifest ${manifest.missionId} (${resolved.path})`,
|
|
561
|
+
);
|
|
562
|
+
|
|
563
|
+
const result = validateMission({ manifest, capabilities, schema, traces });
|
|
564
|
+
|
|
565
|
+
// Mission verdict = the Playwright run exited clean AND evidence exists AND
|
|
566
|
+
// every proof in it passed AND zero manifest issues. We compute it up-front
|
|
567
|
+
// so the aggregated trace is ALWAYS written (green or red) with a consistent
|
|
568
|
+
// top-level `passed` for consumers to read.
|
|
569
|
+
const missionPassed =
|
|
570
|
+
result.ok && failedProofs.length === 0 && runIssues.length === 0;
|
|
571
|
+
const aggregatedPath = writeAggregatedTrace(
|
|
572
|
+
manifest.missionId,
|
|
573
|
+
traces,
|
|
574
|
+
manifest,
|
|
575
|
+
{
|
|
576
|
+
passed: missionPassed,
|
|
577
|
+
issues: [...runIssues, ...(result.issues ?? [])],
|
|
578
|
+
evidence: result.evidence ?? [],
|
|
579
|
+
},
|
|
580
|
+
provenance,
|
|
581
|
+
);
|
|
582
|
+
|
|
583
|
+
if (!result.ok) {
|
|
584
|
+
printFailureSummary(result.issues);
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
if (!missionPassed) {
|
|
588
|
+
const reasons = [];
|
|
589
|
+
if (result.issues.length > 0)
|
|
590
|
+
reasons.push(`${result.issues.length} manifest issue(s)`);
|
|
591
|
+
if (failedProofs.length > 0)
|
|
592
|
+
reasons.push(`${failedProofs.length} proof(s) failed`);
|
|
593
|
+
for (const issue of runIssues) reasons.push(issue.category);
|
|
594
|
+
console.error(
|
|
595
|
+
`[proof:verify] mission ${result.missionId} FAILED (${reasons.join(", ")}); aggregated trace → ${aggregatedPath}`,
|
|
596
|
+
);
|
|
597
|
+
process.exit(1);
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
console.log(
|
|
601
|
+
`[proof:verify] mission ${manifest.missionId} PASSED; aggregated trace → ${aggregatedPath}`,
|
|
602
|
+
);
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
// Re-exported for compatibility. The implementation is the package's compiled
|
|
606
|
+
// validator; the CLI contains no second validation ruleset.
|
|
607
|
+
export { validateMission };
|
|
608
|
+
|
|
609
|
+
// Only run the CLI when executed directly, so importing this module for tests
|
|
610
|
+
// has no side effects.
|
|
611
|
+
const invokedDirectly =
|
|
612
|
+
process.argv[1] !== undefined &&
|
|
613
|
+
import.meta.url === pathToFileURL(process.argv[1]).href;
|
|
614
|
+
|
|
615
|
+
if (invokedDirectly) {
|
|
616
|
+
main().catch((err) => {
|
|
617
|
+
console.error(`[proof:verify] unexpected error: ${err?.stack ?? err}`);
|
|
618
|
+
process.exit(2);
|
|
619
|
+
});
|
|
620
|
+
}
|