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