proof-harness 0.1.0-next.13 → 0.1.0-next.14

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.
@@ -4,11 +4,8 @@ import {
4
4
  validateTableAcl,
5
5
  restoreTableAclSql,
6
6
  } from "./proof_table_acl.mjs";
7
- import {
8
- validateControlBaseline,
9
- assertionSelectorsTurnedRed,
10
- } from "./proof_control_sensitivity.mjs";
11
- import { readTraceDirectory } from "../../dist/node.js";
7
+ import { assertionSelectorsTurnedRed } from "./proof_control_sensitivity.mjs";
8
+ import { readTraceDirectory, sourceHash } from "../../dist/node.js";
12
9
  // ---------------------------------------------------------------------------
13
10
  // proof_mutation_check.mjs
14
11
  //
@@ -47,11 +44,7 @@ import path from "node:path";
47
44
  import { createRequire } from "node:module";
48
45
  import { fileURLToPath } from "node:url";
49
46
  import nextEnv from "@next/env";
50
- import {
51
- deriveAutomaticRlsMutations,
52
- evaluateMutationClaimCoverage,
53
- mutationCoversClaim,
54
- } from "./proof_mutation_inventory.mjs";
47
+ import { buildMutationInventory } from "./proof_inventory_build.mjs";
55
48
  import {
56
49
  assessPlantedSubject,
57
50
  assessSubjectAfterProof,
@@ -77,10 +70,8 @@ loadEnvConfig(CONFIG.rootDir);
77
70
  const require = createRequire(import.meta.url);
78
71
 
79
72
  const CONFIG_TOML = CONFIG.repository.supabaseConfig;
80
- const TRACE_DIR = CONFIG.artifacts.traces;
81
- const SCHEMA_PATH = CONFIG.artifacts.schema;
82
- const MUTATION_POLICY_PATH = CONFIG.policies.mutation;
83
- const MUTATION_ARTIFACT_DIR = CONTROL_SENSITIVITY
73
+ let TRACE_DIR = CONFIG.artifacts.traces;
74
+ let MUTATION_ARTIFACT_DIR = CONTROL_SENSITIVITY
84
75
  ? CONFIG.artifacts.controlSensitivity
85
76
  : CONFIG.artifacts.mutations;
86
77
  const RECOVERY_FILE = path.join(
@@ -400,113 +391,12 @@ const PROOFS_DIR = CONFIG.roots.proofs;
400
391
  function listSpecs() {
401
392
  if (!fs.existsSync(PROOFS_DIR)) return [];
402
393
  return fs
403
- .readdirSync(PROOFS_DIR)
394
+ .readdirSync(PROOFS_DIR, { recursive: true })
404
395
  .filter((f) => f.endsWith(".proof.ts"))
405
396
  .map((f) => path.join(PROOFS_DIR, f))
406
397
  .sort();
407
398
  }
408
399
 
409
- function readMutationPolicy() {
410
- try {
411
- const policy = JSON.parse(fs.readFileSync(MUTATION_POLICY_PATH, "utf8"));
412
- if (policy.schemaVersion !== 1) {
413
- return {
414
- policy: { acceptedClaims: [] },
415
- problems: [
416
- `${MUTATION_POLICY_PATH} has unsupported schemaVersion ${policy.schemaVersion ?? "<missing>"}; expected 1`,
417
- ],
418
- };
419
- }
420
- return { policy, problems: [] };
421
- } catch (error) {
422
- return {
423
- policy: { acceptedClaims: [] },
424
- problems: [
425
- `cannot read ${MUTATION_POLICY_PATH}: ${error instanceof Error ? error.message : String(error)}`,
426
- ],
427
- };
428
- }
429
- }
430
-
431
- function buildMutationInventory() {
432
- if (CONTROL_SENSITIVITY) {
433
- if (!CONFIG.controlSensitivityCatalog)
434
- throw new Error(
435
- "[PROOF_FAIL] control_contract: configure a separate controlSensitivityCatalog",
436
- );
437
- const traces = readTraceDirectory(TRACE_DIR, {
438
- rootDir: CONFIG.rootDir,
439
- excludedPaths: evidenceExclusions(CONFIG),
440
- });
441
- const ids = new Set();
442
- const mutations = MUTATIONS.map((m) => {
443
- if (ids.has(m.id)) throw new Error("duplicate control-sensitivity id");
444
- ids.add(m.id);
445
- return { ...m, resolvedControls: validateControlBaseline(m, traces) };
446
- });
447
- return {
448
- mutations,
449
- problems: [],
450
- claims: [],
451
- acceptedClaims: [],
452
- uncoveredClaims: [],
453
- uncoveredActionClaims: [],
454
- };
455
- }
456
- const derived = deriveAutomaticRlsMutations({
457
- tracesDir: TRACE_DIR,
458
- excludedPaths: evidenceExclusions(CONFIG),
459
- schemaPath: SCHEMA_PATH,
460
- explicitMutations: MUTATIONS,
461
- });
462
- const mutations = [...MUTATIONS, ...derived.mutations];
463
- const ids = new Set();
464
- const problems = [...derived.problems];
465
-
466
- for (const mutation of mutations) {
467
- if (ids.has(mutation.id)) {
468
- problems.push(`duplicate mutation id "${mutation.id}"`);
469
- }
470
- ids.add(mutation.id);
471
- }
472
-
473
- const loadedPolicy = readMutationPolicy();
474
- const claimCoverage = evaluateMutationClaimCoverage({
475
- claims: derived.claims,
476
- evidence: derived.evidence,
477
- mutations,
478
- policy: loadedPolicy.policy,
479
- });
480
- problems.push(...loadedPolicy.problems, ...claimCoverage.problems);
481
- for (const claim of claimCoverage.uncoveredClaims) {
482
- problems.push(
483
- `${claim.spec} has no mutation or accepted policy entry for ${claim.kind}:${claim.target}:${claim.operation}`,
484
- );
485
- }
486
-
487
- const mutationsWithClaims = mutations.map((mutation) => ({
488
- ...mutation,
489
- resolvedClaims: derived.claims
490
- .filter((claim) => mutationCoversClaim(mutation, claim))
491
- .map((claim) => ({
492
- kind: claim.kind,
493
- target: claim.target,
494
- operation: claim.operation,
495
- })),
496
- }));
497
-
498
- return {
499
- mutations: mutationsWithClaims,
500
- problems,
501
- claims: derived.claims,
502
- acceptedClaims: claimCoverage.acceptedClaims,
503
- uncoveredClaims: claimCoverage.uncoveredClaims,
504
- uncoveredActionClaims: claimCoverage.uncoveredClaims.filter((claim) =>
505
- /^[A-Za-z][\w-]*:[A-Za-z][\w-]*$/.test(claim.target),
506
- ),
507
- };
508
- }
509
-
510
400
  /**
511
401
  * Returns inventory problems plus mutations pointing at specs that no longer
512
402
  * exist. Claim-level mutation/policy coverage is computed in buildMutationInventory.
@@ -649,6 +539,10 @@ function waitForExit(child, timeoutMs) {
649
539
  */
650
540
  async function sharedDevServer() {
651
541
  if (await serverIsReachable(DEV_SERVER_URL)) {
542
+ if (process.env.PROOF_REQUIRE_OWNED_SERVER === "true")
543
+ throw new Error(
544
+ "parallel mutations require a fresh runner-owned server; stop the existing process first",
545
+ );
652
546
  const issue = await proofServerIssue(DEV_SERVER_URL);
653
547
  if (issue) {
654
548
  throw new Error(
@@ -844,7 +738,7 @@ function writeMutationSummary(results, inventory) {
844
738
  );
845
739
  }
846
740
 
847
- function runProof(spec, mutationId, traceDirectory) {
741
+ function runProof(spec, mutationId, traceDirectory, executionSourceHash) {
848
742
  return new Promise((resolve) => {
849
743
  const playwrightCli = require.resolve("@playwright/test/cli");
850
744
  const child = spawn(
@@ -853,8 +747,8 @@ function runProof(spec, mutationId, traceDirectory) {
853
747
  playwrightCli,
854
748
  "test",
855
749
  "--project=proofs",
856
- spec,
857
- "--reporter=line",
750
+ `${spec.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`,
751
+ `--reporter=line,${fileURLToPath(new URL("./proof_execution_reporter.mjs", import.meta.url))}`,
858
752
  // A planted defect is expected to turn the proof red. Retrying that
859
753
  // deliberate failure only reruns the same proof against the same
860
754
  // broken database, multiplying mutation time without adding evidence.
@@ -868,7 +762,15 @@ function runProof(spec, mutationId, traceDirectory) {
868
762
  CI: process.env.CI ?? "",
869
763
  PLAYWRIGHT_REUSE_EXISTING_SERVER: "true",
870
764
  PROOF_MUTATION_ID: mutationId,
765
+ ...(executionSourceHash
766
+ ? { PROOF_SOURCE_HASH: executionSourceHash }
767
+ : {}),
871
768
  PROOF_TRACES_DIR: path.resolve(traceDirectory),
769
+ PROOF_EXECUTION_REPORT: path.resolve(
770
+ traceDirectory,
771
+ "../",
772
+ `${mutationId}-execution.json`,
773
+ ),
872
774
  },
873
775
  },
874
776
  );
@@ -895,14 +797,22 @@ function runProof(spec, mutationId, traceDirectory) {
895
797
  // ---------------------------------------------------------------------------
896
798
 
897
799
  function parseArgs(argv) {
898
- const args = { inventory: false, list: false };
800
+ const args = { inventory: false, list: false, specs: [] };
899
801
  for (let i = 0; i < argv.length; i++) {
900
802
  const v = argv[i];
803
+ if (
804
+ ["--only", "--spec", "--baseline-dir", "--output-dir"].includes(v) &&
805
+ (!argv[i + 1] || argv[i + 1].startsWith("--"))
806
+ )
807
+ throw new Error(`${v} requires a value`);
901
808
  if (v === "--control-sensitivity") continue;
902
809
  if (v === "--inventory") args.inventory = true;
903
810
  else if (v === "--recover") args.recover = true;
904
811
  else if (v === "--list") args.list = true;
905
812
  else if (v === "--only") args.only = argv[++i];
813
+ else if (v === "--spec") args.specs.push(argv[++i]);
814
+ else if (v === "--baseline-dir") args.baselineDir = argv[++i];
815
+ else if (v === "--output-dir") args.outputDir = argv[++i];
906
816
  else if (v === "--help" || v === "-h") args.help = true;
907
817
  else {
908
818
  console.error(`[mutation] unknown flag: ${v}`);
@@ -943,7 +853,33 @@ Both verify planting, run the proof, restore, and fail on incomplete evidence.`)
943
853
  return;
944
854
  }
945
855
 
946
- const inventory = buildMutationInventory();
856
+ if (args.baselineDir) TRACE_DIR = path.resolve(args.baselineDir);
857
+ if (args.outputDir) MUTATION_ARTIFACT_DIR = path.resolve(args.outputDir);
858
+ if (args.specs.length && !args.baselineDir)
859
+ throw new Error("--spec requires a fresh --baseline-dir");
860
+ if (args.specs.some((s) => !listSpecs().includes(s)))
861
+ throw new Error("unknown proof spec");
862
+ if (args.specs.length) {
863
+ const traces = readTraceDirectory(TRACE_DIR, {
864
+ rootDir: CONFIG.rootDir,
865
+ excludedPaths: evidenceExclusions(CONFIG),
866
+ });
867
+ if (
868
+ traces.some(
869
+ (t) => t.mutation || !args.specs.includes(t.specFile) || !t.passed,
870
+ )
871
+ )
872
+ throw new Error("scoped baseline contains unexpected or failed traces");
873
+ if (args.specs.some((s) => !traces.some((t) => t.specFile === s)))
874
+ throw new Error("scoped baseline is incomplete");
875
+ }
876
+ const inventory = buildMutationInventory({
877
+ config: CONFIG,
878
+ mutations: MUTATIONS,
879
+ controlSensitivity: CONTROL_SENSITIVITY,
880
+ tracesDir: TRACE_DIR,
881
+ specs: args.specs.length ? args.specs : null,
882
+ });
947
883
  const mutations = inventory.mutations;
948
884
 
949
885
  if (args.inventory) {
@@ -1018,6 +954,15 @@ Both verify planting, run the proof, restore, and fail on incomplete evidence.`)
1018
954
  ...requested.filter((mutation) => isSourceMutation(mutation)),
1019
955
  ];
1020
956
 
957
+ if (
958
+ selected.length === 0 &&
959
+ args.specs.length &&
960
+ !args.only &&
961
+ coverageProblems.length === 0
962
+ ) {
963
+ writeMutationSummary([], inventory);
964
+ return;
965
+ }
1021
966
  if (selected.length === 0) {
1022
967
  console.error(
1023
968
  `[mutation] no mutation with id "${args.only}"; run --list to see them`,
@@ -1094,6 +1039,23 @@ Both verify planting, run the proof, restore, and fail on incomplete evidence.`)
1094
1039
  let snapshot = null;
1095
1040
  let applied = false;
1096
1041
  let sourceServer = null;
1042
+ const result = {
1043
+ id: m.id,
1044
+ spec: m.spec,
1045
+ outcome: "setup_failure",
1046
+ restoration: "not_needed",
1047
+ cleanupErrors: [],
1048
+ timings: {},
1049
+ };
1050
+ results.push(result);
1051
+ let phase = "snapshot";
1052
+ let phaseStart = performance.now();
1053
+ const stage = (name) => {
1054
+ result.timings[phase] =
1055
+ (result.timings[phase] ?? 0) + performance.now() - phaseStart;
1056
+ phase = name;
1057
+ phaseStart = performance.now();
1058
+ };
1097
1059
  try {
1098
1060
  handler.preflight?.(container, m.subject);
1099
1061
  snapshot = handler.snapshot(container, m.subject);
@@ -1121,11 +1083,14 @@ Both verify planting, run the proof, restore, and fail on incomplete evidence.`)
1121
1083
  { mode: 0o600, flag: "wx" },
1122
1084
  );
1123
1085
  applied = true;
1086
+ result.restoration = "unknown";
1087
+ stage("plant");
1124
1088
  applyMutation(container, m, snapshot);
1125
1089
 
1126
1090
  // A defect that is not demonstrably live proves nothing: running the
1127
1091
  // proof anyway would blame the proof ("MISSED") for a plant that never
1128
1092
  // happened. Read the subject back and refuse to continue on mismatch.
1093
+ stage("plantReadback");
1129
1094
  const plantedSnapshot = handler.snapshot(container, m.subject);
1130
1095
  const plantProblem = assessPlantedSubject({
1131
1096
  id: m.id,
@@ -1135,6 +1100,8 @@ Both verify planting, run the proof, restore, and fail on incomplete evidence.`)
1135
1100
  applyDoesNotChangeSubject: m.applyDoesNotChangeSubject === true,
1136
1101
  });
1137
1102
  if (plantProblem) throw new Error(plantProblem);
1103
+ result.plantVerified = true;
1104
+ stage("readiness");
1138
1105
 
1139
1106
  if (isSourceMutation(m)) {
1140
1107
  if (devServer) {
@@ -1147,16 +1114,32 @@ Both verify planting, run the proof, restore, and fail on incomplete evidence.`)
1147
1114
  }
1148
1115
 
1149
1116
  const traceDirectory = path.join(MUTATION_ARTIFACT_DIR, ".runs", m.id);
1117
+ stage("execution");
1118
+ result.executionSourceHash = sourceHash(CONFIG.rootDir, [
1119
+ ...evidenceExclusions(CONFIG),
1120
+ MUTATION_ARTIFACT_DIR,
1121
+ TRACE_DIR,
1122
+ ]);
1150
1123
  const { code, output, timedOut } = await runProof(
1151
1124
  m.spec,
1152
1125
  m.id,
1153
1126
  traceDirectory,
1127
+ result.executionSourceHash,
1154
1128
  );
1129
+ fs.writeFileSync(
1130
+ path.join(MUTATION_ARTIFACT_DIR, ".runs", `${m.id}-output.txt`),
1131
+ output,
1132
+ );
1133
+ result.playwrightFailed = code !== 0;
1134
+ result.exitCode = code;
1135
+ result.timedOut = timedOut === true;
1155
1136
  if (timedOut)
1156
1137
  throw new Error(
1157
1138
  "mutation proof timed out; no detection verdict can be issued",
1158
1139
  );
1159
1140
  if (interrupted) throw new Error("mutation run interrupted");
1141
+ if (!Number.isInteger(code))
1142
+ throw new Error("proof process terminated without an exit status");
1160
1143
  const playwrightFailed = code !== 0;
1161
1144
 
1162
1145
  // The defect must still be live now that the proof has finished. If
@@ -1164,6 +1147,7 @@ Both verify planting, run the proof, restore, and fail on incomplete evidence.`)
1164
1147
  // re-granting a revoked privilege), the proof's verdict — green or
1165
1148
  // red — says nothing about the proof, and archiving it as MISSED
1166
1149
  // would misdirect the operator toward a working proof.
1150
+ stage("finalPlantVerification");
1167
1151
  const afterProofSnapshot = handler.snapshot(container, m.subject);
1168
1152
  const liveProblem = assessSubjectAfterProof({
1169
1153
  id: m.id,
@@ -1174,6 +1158,18 @@ Both verify planting, run the proof, restore, and fail on incomplete evidence.`)
1174
1158
  applyDoesNotChangeSubject: m.applyDoesNotChangeSubject === true,
1175
1159
  });
1176
1160
  if (liveProblem) throw new Error(liveProblem);
1161
+ if (
1162
+ result.executionSourceHash &&
1163
+ result.executionSourceHash !==
1164
+ sourceHash(CONFIG.rootDir, [
1165
+ ...evidenceExclusions(CONFIG),
1166
+ MUTATION_ARTIFACT_DIR,
1167
+ TRACE_DIR,
1168
+ ])
1169
+ )
1170
+ throw new Error("source changed during mutation execution");
1171
+ result.liveVerified = true;
1172
+ stage("evidence");
1177
1173
 
1178
1174
  // A proof can fail for the wrong reason (a crash, a timeout, a missing
1179
1175
  // browser). Where the reason matters — as with the deny-all mutation that
@@ -1190,7 +1186,14 @@ Both verify planting, run the proof, restore, and fail on incomplete evidence.`)
1190
1186
  ? archived.controlRejected
1191
1187
  : archived.detected;
1192
1188
 
1193
- results.push({
1189
+ Object.assign(result, {
1190
+ outcome: detected
1191
+ ? reasonOk
1192
+ ? "detected"
1193
+ : "wrong_reason"
1194
+ : playwrightFailed
1195
+ ? "wrong_claim"
1196
+ : "survived",
1194
1197
  id: m.id,
1195
1198
  detected: archived.detected,
1196
1199
  controlRejected: archived.controlRejected,
@@ -1217,6 +1220,7 @@ Both verify planting, run the proof, restore, and fail on incomplete evidence.`)
1217
1220
  ` ✗ WRONG REASON — the proof failed, but not via "${m.expectFailureContains}".`,
1218
1221
  );
1219
1222
  } else if (
1223
+ playwrightFailed &&
1220
1224
  !(CONTROL_SENSITIVITY
1221
1225
  ? archived.controlsTurnedRed
1222
1226
  : archived.claimsTurnedRed)
@@ -1230,24 +1234,35 @@ Both verify planting, run the proof, restore, and fail on incomplete evidence.`)
1230
1234
  );
1231
1235
  }
1232
1236
  } catch (err) {
1233
- results.push({ id: m.id, error: String(err?.message ?? err) });
1237
+ result.error ??= String(err?.message ?? err);
1238
+ result.outcome = result.timedOut
1239
+ ? "timeout"
1240
+ : ["plant", "plantReadback", "finalPlantVerification"].includes(phase)
1241
+ ? "plant_failure"
1242
+ : phase === "evidence"
1243
+ ? "missing_or_invalid_evidence"
1244
+ : "setup_failure";
1234
1245
  console.log(` ! ERROR — ${err?.message ?? err}`);
1235
1246
  } finally {
1247
+ stage("cleanup");
1236
1248
  let stopError = null;
1237
1249
  if (sourceServer) {
1238
1250
  try {
1239
1251
  await sourceServer.stop();
1240
1252
  } catch (error) {
1241
1253
  stopError = error;
1254
+ result.cleanupErrors.push(String(error?.message ?? error));
1242
1255
  }
1243
1256
  }
1244
1257
  if (applied) {
1258
+ stage("restoration");
1245
1259
  try {
1246
1260
  // Trust nothing: restore from the exact snapshot and read back.
1247
1261
  // Source mutations preserve dirty working-tree contents byte-for-byte;
1248
1262
  // database mutations preserve the live object's catalog definition.
1249
1263
  restoreMutation(container, m, handler, snapshot);
1250
1264
  fs.unlinkSync(RECOVERY_FILE);
1265
+ result.restoration = "verified";
1251
1266
  console.log(
1252
1267
  ` reverted (${handler.describe(m.subject)} verified)\n`,
1253
1268
  );
@@ -1259,16 +1274,30 @@ Both verify planting, run the proof, restore, and fail on incomplete evidence.`)
1259
1274
  `\n[mutation] FAILED TO REVERT "${m.id}". The local checkout is still vulnerable. ` +
1260
1275
  `${recovery}\n${err?.message ?? err}\n`,
1261
1276
  );
1262
- process.exit(3);
1277
+ result.restoration = "failed";
1278
+ result.cleanupErrors.push(String(err?.message ?? err));
1279
+ result.error ??= String(err?.message ?? err);
1263
1280
  }
1264
1281
  }
1282
+ stage("complete");
1283
+ writeMutationSummary(results, inventory);
1284
+ if (result.restoration === "failed")
1285
+ throw new Error("restoration failed; recovery journal retained");
1265
1286
  if (stopError) throw stopError;
1266
1287
  }
1267
1288
  }
1268
1289
  } finally {
1269
1290
  process.removeListener("SIGINT", onSignal);
1270
1291
  process.removeListener("SIGTERM", onSignal);
1271
- if (devServer) await devServer.stop();
1292
+ try {
1293
+ if (devServer) await devServer.stop();
1294
+ } catch (error) {
1295
+ if (results.length)
1296
+ results.at(-1).cleanupErrors.push(String(error?.message ?? error));
1297
+ throw error;
1298
+ } finally {
1299
+ writeMutationSummary(results, inventory);
1300
+ }
1272
1301
  }
1273
1302
 
1274
1303
  writeMutationSummary(results, inventory);
@@ -1276,6 +1305,8 @@ Both verify planting, run the proof, restore, and fail on incomplete evidence.`)
1276
1305
  const missed = results.filter(
1277
1306
  (r) =>
1278
1307
  r.error ||
1308
+ r.restoration !== "verified" ||
1309
+ r.cleanupErrors.length > 0 ||
1279
1310
  !(CONTROL_SENSITIVITY ? r.controlRejected : r.detected) ||
1280
1311
  !r.reasonOk,
1281
1312
  );
@@ -1285,28 +1316,16 @@ Both verify planting, run the proof, restore, and fail on incomplete evidence.`)
1285
1316
 
1286
1317
  console.log("── summary ─────────────────────────────────────────");
1287
1318
  for (const r of results) {
1288
- const verdict = CONTROL_SENSITIVITY
1289
- ? r.error
1290
- ? `ERROR (${r.error})`
1291
- : r.controlRejected && r.reasonOk
1292
- ? "CONTROL REJECTED"
1293
- : "CONTROL NOT REJECTED"
1294
- : r.error
1295
- ? `ERROR (${r.error.split("\n")[0]})`
1296
- : r.claimsTurnedRed === false
1297
- ? "WRONG CLAIM"
1298
- : !r.detected
1299
- ? "MISSED"
1300
- : !r.reasonOk
1301
- ? `WRONG REASON (expected ${r.expectedReason})`
1302
- : "detected";
1319
+ const verdict = r.error
1320
+ ? `${r.outcome}: ${r.error.split("\n")[0]}`
1321
+ : r.outcome;
1303
1322
  console.log(` ${r.id}: ${verdict}`);
1304
1323
  }
1305
1324
 
1306
1325
  if (missed.length > 0) {
1307
1326
  console.error(
1308
1327
  `\n[PROOF_FAIL] ${CONTROL_SENSITIVITY ? "control_sensitivity_check" : "mutation_check"}: ${missed.length} of ${results.length} planted defect(s) were not properly detected. ` +
1309
- `The affected proofs cannot fail, so their green status means nothing.`,
1328
+ `See individual outcomes: unrelated failures and missing evidence do not demonstrate detection.`,
1310
1329
  );
1311
1330
  process.exit(1);
1312
1331
  }