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,980 @@
1
+ #!/usr/bin/env node
2
+ // ---------------------------------------------------------------------------
3
+ // proof_drift.mjs — PROOF-SURFACE drift.
4
+ //
5
+ // What else did this PR do — on the surfaces the proof system can see?
6
+ //
7
+ // Every other proof check grades presence: the mission says what must exist
8
+ // and hold, and the validator confirms it does. Nothing in that loop notices
9
+ // the change the mission never asked for — an extra action in an unrelated
10
+ // module, a table quietly reshaped, a new runtime dependency, an auth guard
11
+ // dropped from an existing action. This script closes that gap by diffing the
12
+ // DERIVED artifacts, not the source:
13
+ //
14
+ // .proof/capabilities.json every action + its scanner-derived facts
15
+ // .proof/schema.json every table: columns, RLS class, policies
16
+ // (name, command, roles)
17
+ // package.json root dependency surface
18
+ // pnpm-lock.yaml changed-or-not (resolution drift signal)
19
+ //
20
+ // against the same artifacts regenerated from the PR's merge base. The
21
+ // artifact generators are pure filesystem scripts, so the base side is rebuilt
22
+ // by extracting the base tree (`git archive`) and running THAT checkout's own
23
+ // generators — no database, no install, a few seconds.
24
+ //
25
+ // WHAT THIS IS NOT. Drift sees exactly what the scanners see, and no more. It
26
+ // does NOT see: RLS policy predicates (USING / WITH CHECK bodies), column
27
+ // types, constraints, or defaults; SQL grants, triggers, or function bodies;
28
+ // transitive dependency contents; or ordinary product behavior. "No drift"
29
+ // means "no change on the proof-relevant derived surfaces", never "nothing
30
+ // else changed" — behavior inside declared scope still merits review or a
31
+ // trace requirement.
32
+ //
33
+ // Enforcement is opt-in and planner-owned. When `.proof/current-mission.json`
34
+ // declares an `expectedChanges` budget, every HIGH-severity delta outside it
35
+ // fails as:
36
+ //
37
+ // [PROOF_FAIL] drift_undeclared: <what changed and why it matters>
38
+ //
39
+ // and a declared budget that cannot be assessed (base unavailable, base
40
+ // artifacts unbuildable) FAILS CLOSED as `drift_unassessed` instead of
41
+ // exiting 0. Without the budget (or without a mission), the report is
42
+ // informational: the full delta is printed and written to `.proof/drift.json`
43
+ // (gitignored) so a consumer can read one small artifact instead of the diff.
44
+ //
45
+ // The budget is narrow by default (see evaluateDrift): `modules` covers only
46
+ // ADDED actions; existing-action changes need exact refs in `actions`; table
47
+ // declarations may be narrowed to change facets; every runtime dependency
48
+ // delta (add, remove, version change) is high-severity; lockfile-only
49
+ // resolution changes need an explicit `lockfile: true`.
50
+ //
51
+ // Severity:
52
+ // high — action added/removed/changed; table added/removed/reshaped
53
+ // (columns, RLS classification, policies incl. role lists); any
54
+ // runtime dependency delta; lockfile-only resolution change.
55
+ // info — action file moved; devDependency added; lockfile change that
56
+ // accompanies package.json dependency changes.
57
+ //
58
+ // Usage:
59
+ // node scripts/proof_drift.mjs # base = merge-base with main
60
+ // node scripts/proof_drift.mjs --base <ref> # explicit base (CI passes
61
+ // # the PR base sha)
62
+ // node scripts/proof_drift.mjs --json # machine-readable report
63
+ //
64
+ // The base may also be supplied via PROOF_DRIFT_BASE. An explicitly supplied
65
+ // base that does not resolve is an error (a misconfigured gate must be loud).
66
+ // An undetectable base (no main ref, shallow history) downgrades to "not
67
+ // assessed" with exit 0 ONLY in report-only mode; with a declared budget it
68
+ // fails closed as drift_unassessed.
69
+ // ---------------------------------------------------------------------------
70
+
71
+ import { execFileSync } from "node:child_process";
72
+ import fs from "node:fs";
73
+ import os from "node:os";
74
+ import path from "node:path";
75
+ import { pathToFileURL } from "node:url";
76
+
77
+ // Single source of truth for the facet vocabularies, shared with both mission
78
+ // validators (which reject exactly what the standalone budget check below
79
+ // rejects) and with the TS types derived from the same file.
80
+ import {
81
+ ACTION_CHANGE_KINDS,
82
+ TABLE_CHANGE_KINDS,
83
+ } from "../../dist/portable-vocabulary.js";
84
+ import { loadProofConfig } from "../config.mjs";
85
+
86
+ const CONFIG = await loadProofConfig();
87
+ process.chdir(CONFIG.rootDir);
88
+
89
+ const CAPABILITIES_PATH = CONFIG.artifacts.capabilities;
90
+ const SCHEMA_PATH = CONFIG.artifacts.schema;
91
+ const MISSION_PATH = CONFIG.mission.current;
92
+ const OUTPUT_PATH = CONFIG.artifacts.drift;
93
+
94
+ // Paths whose contents feed the derived artifacts. If none of them differ from
95
+ // the base, there is nothing to regenerate or diff.
96
+ const SOURCE_PATHS = CONFIG.driftSources;
97
+
98
+ // ---------------------------------------------------------------------------
99
+ // Diffing (pure — unit-tested directly)
100
+ // ---------------------------------------------------------------------------
101
+
102
+ function byKey(list, keyOf) {
103
+ const map = new Map();
104
+ for (const item of list ?? []) map.set(keyOf(item), item);
105
+ return map;
106
+ }
107
+
108
+ function setDiff(baseList, headList) {
109
+ const base = new Set(baseList ?? []);
110
+ const head = new Set(headList ?? []);
111
+ return {
112
+ added: [...head].filter((v) => !base.has(v)).sort(),
113
+ removed: [...base].filter((v) => !head.has(v)).sort(),
114
+ };
115
+ }
116
+
117
+ /**
118
+ * Field-level comparison of one action's security-relevant facts. Returns
119
+ * human-readable notes; empty means "same action" (a moved file is reported
120
+ * separately as info).
121
+ */
122
+ function actionChanges(base, head) {
123
+ const notes = [];
124
+ // Facets mirror the table decomposition: enforcement covers a modified
125
+ // action only when EVERY facet is declared, so a mission expecting an
126
+ // invariant declaration to change never quietly authorizes an RBAC/auth
127
+ // middleware change on the same action.
128
+ const facets = [];
129
+
130
+ const metadataNotes = [];
131
+ for (const field of [
132
+ "verb",
133
+ "object",
134
+ "acceptsWorkspaceId",
135
+ "internalOnly",
136
+ "usesDirectUpdateTag",
137
+ ]) {
138
+ const before = base[field] ?? null;
139
+ const after = head[field] ?? null;
140
+ if (before !== after) metadataNotes.push(`${field}: ${before} -> ${after}`);
141
+ }
142
+ if (metadataNotes.length > 0) {
143
+ facets.push("metadata_changed");
144
+ notes.push(...metadataNotes);
145
+ }
146
+
147
+ const invariants = setDiff(base.invariants, head.invariants);
148
+ if (invariants.added.length > 0 || invariants.removed.length > 0) {
149
+ facets.push("invariants_changed");
150
+ if (invariants.added.length > 0)
151
+ notes.push(`invariants added: ${invariants.added.join(", ")}`);
152
+ if (invariants.removed.length > 0)
153
+ notes.push(`invariants removed: ${invariants.removed.join(", ")}`);
154
+ }
155
+
156
+ const middlewareNotes = [];
157
+ for (const guard of ["auth", "tenantIsolation", "rbac"]) {
158
+ const before = base.middleware?.[guard] ?? null;
159
+ const after = head.middleware?.[guard] ?? null;
160
+ if (before !== after)
161
+ middlewareNotes.push(`middleware.${guard}: ${before} -> ${after}`);
162
+ }
163
+ if (middlewareNotes.length > 0) {
164
+ facets.push("middleware_changed");
165
+ notes.push(...middlewareNotes);
166
+ }
167
+
168
+ const mutationKey = (m) => `${m.table}:${m.operation}`;
169
+ const mutations = setDiff(
170
+ (base.serviceRoleMutations ?? []).map(mutationKey),
171
+ (head.serviceRoleMutations ?? []).map(mutationKey),
172
+ );
173
+ if (mutations.added.length > 0 || mutations.removed.length > 0) {
174
+ facets.push("service_role_mutations_changed");
175
+ if (mutations.added.length > 0)
176
+ notes.push(`serviceRoleMutations added: ${mutations.added.join(", ")}`);
177
+ if (mutations.removed.length > 0)
178
+ notes.push(
179
+ `serviceRoleMutations removed: ${mutations.removed.join(", ")}`,
180
+ );
181
+ }
182
+
183
+ return { facets, notes };
184
+ }
185
+
186
+ /** Diff two capabilities.json artifacts into drift entries. */
187
+ export function diffCapabilities(baseArtifact, headArtifact) {
188
+ const keyOf = (c) => `${c.module}:${c.name}`;
189
+ const base = byKey(baseArtifact?.capabilities, keyOf);
190
+ const head = byKey(headArtifact?.capabilities, keyOf);
191
+ const entries = [];
192
+
193
+ for (const [key, capability] of head) {
194
+ if (!base.has(key)) {
195
+ entries.push({
196
+ area: "action",
197
+ key,
198
+ module: capability.module,
199
+ change: "added",
200
+ facets: ["added"],
201
+ severity: "high",
202
+ detail: `new action in ${capability.file ?? "unknown file"}`,
203
+ });
204
+ }
205
+ }
206
+ for (const [key, capability] of base) {
207
+ if (!head.has(key)) {
208
+ entries.push({
209
+ area: "action",
210
+ key,
211
+ module: capability.module,
212
+ change: "removed",
213
+ facets: ["removed"],
214
+ severity: "high",
215
+ detail: `action no longer found (was ${capability.file ?? "unknown file"})`,
216
+ });
217
+ }
218
+ }
219
+ for (const [key, headCapability] of head) {
220
+ const baseCapability = base.get(key);
221
+ if (!baseCapability) continue;
222
+ const { facets, notes } = actionChanges(baseCapability, headCapability);
223
+ if (facets.length > 0) {
224
+ entries.push({
225
+ area: "action",
226
+ key,
227
+ module: headCapability.module,
228
+ change: "modified",
229
+ facets,
230
+ severity: "high",
231
+ detail: notes.join("; "),
232
+ });
233
+ } else if (baseCapability.file !== headCapability.file) {
234
+ entries.push({
235
+ area: "action",
236
+ key,
237
+ module: headCapability.module,
238
+ change: "moved",
239
+ severity: "info",
240
+ detail: `${baseCapability.file} -> ${headCapability.file}`,
241
+ });
242
+ }
243
+ }
244
+ return entries;
245
+ }
246
+
247
+ /** Diff two schema.json artifacts into drift entries. */
248
+ export function diffSchema(baseArtifact, headArtifact) {
249
+ const base = byKey(baseArtifact?.tables, (t) => t.name);
250
+ const head = byKey(headArtifact?.tables, (t) => t.name);
251
+ const entries = [];
252
+
253
+ for (const [name, table] of head) {
254
+ if (!base.has(name)) {
255
+ entries.push({
256
+ area: "table",
257
+ key: name,
258
+ change: "added",
259
+ facets: ["added"],
260
+ severity: "high",
261
+ detail: `new table (${table.rls_classification}, ${table.columns?.length ?? 0} column(s))`,
262
+ });
263
+ }
264
+ }
265
+ for (const [name] of base) {
266
+ if (!head.has(name)) {
267
+ entries.push({
268
+ area: "table",
269
+ key: name,
270
+ change: "removed",
271
+ facets: ["removed"],
272
+ severity: "high",
273
+ detail: "table no longer present in migrations",
274
+ });
275
+ }
276
+ }
277
+ for (const [name, headTable] of head) {
278
+ const baseTable = base.get(name);
279
+ if (!baseTable) continue;
280
+ const notes = [];
281
+ // Facets are the machine-readable decomposition of a "modified" entry.
282
+ // Enforcement covers a table change only when EVERY facet is declared,
283
+ // so `columns_added` on a declared table never authorizes a policy edit.
284
+ const facets = [];
285
+ if (baseTable.rls_classification !== headTable.rls_classification) {
286
+ facets.push("rls_classification_changed");
287
+ notes.push(
288
+ `rls_classification: ${baseTable.rls_classification} -> ${headTable.rls_classification}`,
289
+ );
290
+ }
291
+ const columns = setDiff(baseTable.columns, headTable.columns);
292
+ if (columns.added.length > 0) {
293
+ facets.push("columns_added");
294
+ notes.push(`columns added: ${columns.added.join(", ")}`);
295
+ }
296
+ if (columns.removed.length > 0) {
297
+ facets.push("columns_removed");
298
+ notes.push(`columns removed: ${columns.removed.join(", ")}`);
299
+ }
300
+ // Roles are part of the identity: rewriting `TO service_role` as
301
+ // `TO authenticated` widens who a policy reaches without renaming it, and
302
+ // may leave the coarse rls_classification untouched.
303
+ const policyKey = (p) =>
304
+ `${p.name} FOR ${p.command} TO ${[...(p.roles ?? [])].sort().join(",") || "PUBLIC"}`;
305
+ const policies = setDiff(
306
+ (baseTable.policies ?? []).map(policyKey),
307
+ (headTable.policies ?? []).map(policyKey),
308
+ );
309
+ if (policies.added.length > 0 || policies.removed.length > 0) {
310
+ facets.push("policies_changed");
311
+ if (policies.added.length > 0)
312
+ notes.push(`policies added: ${policies.added.join("; ")}`);
313
+ if (policies.removed.length > 0)
314
+ notes.push(`policies removed: ${policies.removed.join("; ")}`);
315
+ }
316
+ if (facets.length > 0) {
317
+ entries.push({
318
+ area: "table",
319
+ key: name,
320
+ change: "modified",
321
+ facets,
322
+ severity: "high",
323
+ detail: notes.join("; "),
324
+ });
325
+ }
326
+ }
327
+ return entries;
328
+ }
329
+
330
+ /** Diff two package.json files into drift entries. */
331
+ export function diffDependencies(basePkg, headPkg) {
332
+ const entries = [];
333
+ const runtime = {
334
+ base: basePkg?.dependencies ?? {},
335
+ head: headPkg?.dependencies ?? {},
336
+ };
337
+ for (const name of Object.keys(runtime.head).sort()) {
338
+ if (!(name in runtime.base)) {
339
+ entries.push({
340
+ area: "dependency",
341
+ key: name,
342
+ change: "added",
343
+ severity: "high",
344
+ detail: `new runtime dependency ${name}@${runtime.head[name]}`,
345
+ });
346
+ } else if (runtime.base[name] !== runtime.head[name]) {
347
+ // High, like additions: a swap or downgrade changes what ships as
348
+ // surely as a new package does.
349
+ entries.push({
350
+ area: "dependency",
351
+ key: name,
352
+ change: "version_changed",
353
+ severity: "high",
354
+ detail: `${runtime.base[name]} -> ${runtime.head[name]}`,
355
+ });
356
+ }
357
+ }
358
+ for (const name of Object.keys(runtime.base).sort()) {
359
+ if (!(name in runtime.head)) {
360
+ entries.push({
361
+ area: "dependency",
362
+ key: name,
363
+ change: "removed",
364
+ severity: "high",
365
+ detail: `runtime dependency removed (was ${runtime.base[name]})`,
366
+ });
367
+ }
368
+ }
369
+ const dev = {
370
+ base: basePkg?.devDependencies ?? {},
371
+ head: headPkg?.devDependencies ?? {},
372
+ };
373
+ for (const name of Object.keys(dev.head).sort()) {
374
+ if (!(name in dev.base)) {
375
+ entries.push({
376
+ area: "dev-dependency",
377
+ key: name,
378
+ change: "added",
379
+ severity: "info",
380
+ detail: `new devDependency ${name}@${dev.head[name]}`,
381
+ });
382
+ }
383
+ }
384
+ return entries;
385
+ }
386
+
387
+ /** Full drift between base and head artifact sets, deterministically ordered. */
388
+ export function buildDrift({
389
+ baseCapabilities,
390
+ headCapabilities,
391
+ baseSchema,
392
+ headSchema,
393
+ basePkg,
394
+ headPkg,
395
+ }) {
396
+ const entries = [
397
+ ...diffCapabilities(baseCapabilities, headCapabilities),
398
+ ...diffSchema(baseSchema, headSchema),
399
+ ...diffDependencies(basePkg, headPkg),
400
+ ];
401
+ entries.sort(
402
+ (a, b) => a.area.localeCompare(b.area) || a.key.localeCompare(b.key),
403
+ );
404
+ return entries;
405
+ }
406
+
407
+ /**
408
+ * Grade the drift against a mission's `expectedChanges` budget.
409
+ *
410
+ * `expectedChanges` absent (or no mission at all) means report-only: nothing
411
+ * is undeclared because nothing was bounded. When present, every
412
+ * high-severity entry must fall inside the declared budget, and the budget is
413
+ * deliberately narrow:
414
+ *
415
+ * action entries -> `actions` names the exact `module:name` ref
416
+ * (string = any change; { ref, changes } narrows to
417
+ * facets: added, removed, middleware_changed,
418
+ * invariants_changed, service_role_mutations_changed,
419
+ * metadata_changed — a modified action is covered
420
+ * only when EVERY facet is declared).
421
+ * `modules` covers ONLY "added" — a module
422
+ * declaration never authorizes modifying or removing
423
+ * an existing action.
424
+ * table entries -> `tables` names the table (string = any change;
425
+ * { name, changes } narrows). A "modified" entry is
426
+ * covered only when EVERY facet is declared.
427
+ * dependency entries -> `dependencies` names the package; covers added,
428
+ * removed, and version_changed alike.
429
+ * lockfile entry -> `lockfile: true` (only a lockfile-only resolution
430
+ * change is high-severity; one accompanying declared
431
+ * package.json changes is informational).
432
+ *
433
+ * Declared budget that saw no change is reported (not failed): a planner may
434
+ * budget for work the executor solved differently, and the mission's positive
435
+ * requirements already fail if a required thing is missing.
436
+ */
437
+ export function evaluateDrift(entries, expectedChanges) {
438
+ if (!expectedChanges || typeof expectedChanges !== "object") {
439
+ return { enforced: false, undeclared: [], unusedDeclarations: [] };
440
+ }
441
+ const modules = new Set(expectedChanges.modules ?? []);
442
+ const dependencies = new Set(expectedChanges.dependencies ?? []);
443
+ // string shorthand -> null (any change); object -> Set of allowed kinds.
444
+ const normalize = (list, nameField) =>
445
+ new Map(
446
+ (list ?? []).map((entry) =>
447
+ typeof entry === "string"
448
+ ? [entry, null]
449
+ : [entry[nameField], entry.changes ? new Set(entry.changes) : null],
450
+ ),
451
+ );
452
+ const actions = normalize(expectedChanges.actions, "ref");
453
+ const tables = normalize(expectedChanges.tables, "name");
454
+
455
+ const declared = (entry) => {
456
+ if (entry.area === "action") {
457
+ if (actions.has(entry.key)) {
458
+ const allowed = actions.get(entry.key);
459
+ if (allowed === null) return true;
460
+ return (entry.facets ?? [entry.change]).every((facet) =>
461
+ allowed.has(facet),
462
+ );
463
+ }
464
+ return entry.change === "added" && modules.has(entry.module);
465
+ }
466
+ if (entry.area === "table") {
467
+ if (!tables.has(entry.key)) return false;
468
+ const allowed = tables.get(entry.key);
469
+ if (allowed === null) return true;
470
+ return (entry.facets ?? [entry.change]).every((facet) =>
471
+ allowed.has(facet),
472
+ );
473
+ }
474
+ if (entry.area === "dependency") return dependencies.has(entry.key);
475
+ if (entry.area === "lockfile") return expectedChanges.lockfile === true;
476
+ return true;
477
+ };
478
+
479
+ const undeclared = entries.filter(
480
+ (entry) => entry.severity === "high" && !declared(entry),
481
+ );
482
+
483
+ const touchedModules = new Set(
484
+ entries
485
+ .filter((e) => e.area === "action" && e.change === "added")
486
+ .map((e) => e.module),
487
+ );
488
+ const touchedActions = new Set(
489
+ entries.filter((e) => e.area === "action").map((e) => e.key),
490
+ );
491
+ const touchedTables = new Set(
492
+ entries.filter((e) => e.area === "table").map((e) => e.key),
493
+ );
494
+ const touchedDependencies = new Set(
495
+ entries.filter((e) => e.area === "dependency").map((e) => e.key),
496
+ );
497
+ const unusedDeclarations = [
498
+ ...[...modules]
499
+ .filter((m) => !touchedModules.has(m))
500
+ .map((m) => ({ scope: "modules", name: m })),
501
+ ...[...actions.keys()]
502
+ .filter((ref) => !touchedActions.has(ref))
503
+ .map((ref) => ({ scope: "actions", name: ref })),
504
+ ...[...tables.keys()]
505
+ .filter((t) => !touchedTables.has(t))
506
+ .map((t) => ({ scope: "tables", name: t })),
507
+ ...[...dependencies]
508
+ .filter((d) => !touchedDependencies.has(d))
509
+ .map((d) => ({ scope: "dependencies", name: d })),
510
+ ...(expectedChanges.lockfile === true &&
511
+ !entries.some((e) => e.area === "lockfile")
512
+ ? [{ scope: "lockfile", name: CONFIG.repository.lockfile }]
513
+ : []),
514
+ ];
515
+
516
+ return { enforced: true, undeclared, unusedDeclarations };
517
+ }
518
+
519
+ // ---------------------------------------------------------------------------
520
+ // Base resolution + artifact regeneration
521
+ // ---------------------------------------------------------------------------
522
+
523
+ function git(args, options = {}) {
524
+ return execFileSync("git", args, {
525
+ encoding: "utf8",
526
+ stdio: ["ignore", "pipe", "pipe"],
527
+ ...options,
528
+ }).trim();
529
+ }
530
+
531
+ function tryGit(args) {
532
+ try {
533
+ return git(args);
534
+ } catch {
535
+ return null;
536
+ }
537
+ }
538
+
539
+ /**
540
+ * Resolve the base commit to diff against.
541
+ *
542
+ * Explicit (`--base` / PROOF_DRIFT_BASE) must resolve or the whole run fails:
543
+ * CI passing a sha that is not fetched is a configuration bug, and a gate that
544
+ * silently skips on misconfiguration is not a gate. Auto-detection falls back
545
+ * through origin/main and main to a merge-base, and reports `null` (drift not
546
+ * assessed) when the repo has no usable base.
547
+ */
548
+ export function resolveBase(explicit) {
549
+ if (explicit) {
550
+ const sha = tryGit(["rev-parse", "--verify", `${explicit}^{commit}`]);
551
+ if (!sha) {
552
+ throw new Error(
553
+ `base "${explicit}" does not resolve to a commit — fetch it (e.g. \`git fetch origin <sha>\`) or fix the --base/PROOF_DRIFT_BASE value`,
554
+ );
555
+ }
556
+ const mergeBase = tryGit(["merge-base", sha, "HEAD"]);
557
+ return { sha: mergeBase ?? sha, ref: explicit };
558
+ }
559
+ for (const ref of ["origin/main", "main"]) {
560
+ if (tryGit(["rev-parse", "--verify", `${ref}^{commit}`]) === null) continue;
561
+ const mergeBase = tryGit(["merge-base", ref, "HEAD"]);
562
+ if (mergeBase) return { sha: mergeBase, ref };
563
+ }
564
+ return null;
565
+ }
566
+
567
+ /**
568
+ * Materialize the base tree and regenerate its proof artifacts using the base
569
+ * checkout's OWN generator scripts (they are pure filesystem scripts with no
570
+ * node_modules imports, so no install is needed). Returns the artifact set or
571
+ * throws with a reason.
572
+ */
573
+ function generateBaseArtifacts(baseSha, tmpDir) {
574
+ const tarPath = path.join(tmpDir, "base.tar");
575
+ execFileSync("git", ["archive", "--format=tar", "-o", tarPath, baseSha], {
576
+ stdio: ["ignore", "ignore", "pipe"],
577
+ });
578
+ const treeDir = path.join(tmpDir, "tree");
579
+ fs.mkdirSync(treeDir, { recursive: true });
580
+ execFileSync("tar", ["-xf", tarPath, "-C", treeDir], {
581
+ stdio: ["ignore", "ignore", "pipe"],
582
+ });
583
+
584
+ const generators = [
585
+ ["scripts/scan_proof_capabilities.mjs"],
586
+ ["scripts/aggregate_migrations.mjs", "--fresh"],
587
+ ["scripts/parse_proof_schema.mjs"],
588
+ ];
589
+ for (const [script, ...args] of generators) {
590
+ if (!fs.existsSync(path.join(treeDir, script))) {
591
+ throw new Error(`base commit has no ${script} — too old to diff against`);
592
+ }
593
+ execFileSync("node", [script, ...args], {
594
+ cwd: treeDir,
595
+ stdio: ["ignore", "ignore", "pipe"],
596
+ });
597
+ }
598
+
599
+ return {
600
+ capabilities: JSON.parse(
601
+ fs.readFileSync(path.join(treeDir, CAPABILITIES_PATH), "utf8"),
602
+ ),
603
+ schema: JSON.parse(
604
+ fs.readFileSync(path.join(treeDir, SCHEMA_PATH), "utf8"),
605
+ ),
606
+ pkg: JSON.parse(
607
+ fs.readFileSync(
608
+ path.join(treeDir, CONFIG.repository.packageJson),
609
+ "utf8",
610
+ ),
611
+ ),
612
+ };
613
+ }
614
+
615
+ // ---------------------------------------------------------------------------
616
+ // CLI
617
+ // ---------------------------------------------------------------------------
618
+
619
+ function readJson(file, { hint } = {}) {
620
+ if (!fs.existsSync(file)) {
621
+ console.error(
622
+ `[proof:drift] ${file} not found.${hint ? `\n suggestion: ${hint}` : ""}`,
623
+ );
624
+ process.exit(2);
625
+ }
626
+ try {
627
+ return JSON.parse(fs.readFileSync(file, "utf8"));
628
+ } catch (err) {
629
+ console.error(`[proof:drift] ${file} is not valid JSON: ${err.message}`);
630
+ process.exit(2);
631
+ }
632
+ }
633
+
634
+ function loadExpectedChanges() {
635
+ if (!fs.existsSync(MISSION_PATH)) return { mission: null, expected: null };
636
+ let mission;
637
+ try {
638
+ mission = JSON.parse(fs.readFileSync(MISSION_PATH, "utf8"));
639
+ } catch (err) {
640
+ // An unparseable mission might contain a budget this run cannot see.
641
+ // Downgrading to report-only would fail open on exactly the standalone
642
+ // invocation that skipped proof:verify, so drift fails it itself.
643
+ console.error(
644
+ `[PROOF_FAIL] manifest_shape: ${MISSION_PATH} is not valid JSON (${err.message}); drift cannot tell whether it declares an expectedChanges budget`,
645
+ );
646
+ console.error(
647
+ ` suggestion: fix the manifest — proof:verify reports the same error as manifest_shape`,
648
+ );
649
+ process.exit(1);
650
+ }
651
+ const expected = mission?.expectedChanges;
652
+ if (expected === undefined) return { mission, expected: null };
653
+ // Shape errors are proof:verify's to fail; here a malformed block must not
654
+ // half-enforce, so it is ignored with a warning. The checks mirror
655
+ // expectedChangesShapeIssues in validate-mission.ts, loosely: string lists
656
+ // for modules/dependencies, string-or-object entries for actions/tables,
657
+ // boolean lockfile.
658
+ const stringList = (list) =>
659
+ list === undefined ||
660
+ (Array.isArray(list) &&
661
+ list.every((v) => typeof v === "string" && v.length > 0));
662
+ const entryList = (list, nameField, kinds) =>
663
+ list === undefined ||
664
+ (Array.isArray(list) &&
665
+ list.every(
666
+ (entry) =>
667
+ (typeof entry === "string" && entry.length > 0) ||
668
+ (entry &&
669
+ typeof entry === "object" &&
670
+ typeof entry[nameField] === "string" &&
671
+ entry[nameField].length > 0 &&
672
+ (entry.changes === undefined ||
673
+ (Array.isArray(entry.changes) &&
674
+ entry.changes.length > 0 &&
675
+ entry.changes.every((c) => kinds.includes(c))))),
676
+ ));
677
+ const valid =
678
+ expected !== null &&
679
+ typeof expected === "object" &&
680
+ !Array.isArray(expected) &&
681
+ stringList(expected.modules) &&
682
+ stringList(expected.dependencies) &&
683
+ entryList(expected.actions, "ref", ACTION_CHANGE_KINDS) &&
684
+ entryList(expected.tables, "name", TABLE_CHANGE_KINDS) &&
685
+ (expected.lockfile === undefined || typeof expected.lockfile === "boolean");
686
+ if (!valid) {
687
+ // A planner declared a budget; a malformed one must never silently
688
+ // disable the enforcement it names — not even when proof:drift is run
689
+ // standalone, outside the proof:check chain where proof:verify would
690
+ // have failed it first.
691
+ console.error(
692
+ `[PROOF_FAIL] manifest_shape: ${MISSION_PATH} expectedChanges is malformed; drift will not run report-only with a broken budget`,
693
+ );
694
+ console.error(
695
+ ` suggestion: fix the block — proof:verify names the exact field errors as manifest_shape`,
696
+ );
697
+ process.exit(1);
698
+ }
699
+ return { mission, expected };
700
+ }
701
+
702
+ function writeReport(report) {
703
+ fs.mkdirSync(path.dirname(OUTPUT_PATH), { recursive: true });
704
+ fs.writeFileSync(OUTPUT_PATH, JSON.stringify(report, null, 2) + "\n", "utf8");
705
+ }
706
+
707
+ function printEntries(entries) {
708
+ const width = (get) =>
709
+ Math.max(...entries.map((e) => String(get(e)).length), 1);
710
+ const areaW = Math.max(
711
+ width((e) => e.area),
712
+ "area".length,
713
+ );
714
+ const keyW = Math.max(
715
+ width((e) => e.key),
716
+ "key".length,
717
+ );
718
+ const changeW = Math.max(
719
+ width((e) => e.change),
720
+ "change".length,
721
+ );
722
+ const sevW = Math.max(
723
+ width((e) => e.severity),
724
+ "severity".length,
725
+ );
726
+ const row = (a, k, c, s, d) =>
727
+ console.log(
728
+ ` ${String(a).padEnd(areaW)} ${String(k).padEnd(keyW)} ${String(c).padEnd(changeW)} ${String(s).padEnd(sevW)} ${d}`,
729
+ );
730
+ row("area", "key", "change", "severity", "detail");
731
+ row(
732
+ "-".repeat(areaW),
733
+ "-".repeat(keyW),
734
+ "-".repeat(changeW),
735
+ "-".repeat(sevW),
736
+ "------",
737
+ );
738
+ for (const e of entries) row(e.area, e.key, e.change, e.severity, e.detail);
739
+ }
740
+
741
+ export function main() {
742
+ const args = process.argv.slice(2);
743
+ const asJson = args.includes("--json");
744
+ const baseFlagIndex = args.indexOf("--base");
745
+ const explicitBase =
746
+ (baseFlagIndex !== -1 ? args[baseFlagIndex + 1] : undefined) ??
747
+ (process.env.PROOF_DRIFT_BASE?.trim() || undefined);
748
+
749
+ if (args.includes("--help") || args.includes("-h")) {
750
+ console.log(`Usage: node scripts/proof_drift.mjs [--base <ref>] [--json]
751
+
752
+ Diffs the derived proof artifacts (capabilities.json, schema.json) and
753
+ package.json against the PR's merge base and reports every action, table, and
754
+ dependency delta. When .proof/current-mission.json declares expectedChanges,
755
+ high-severity deltas outside that scope fail as [PROOF_FAIL] drift_undeclared.
756
+ Without it, the report is informational. Writes ${OUTPUT_PATH}.`);
757
+ return;
758
+ }
759
+ const known = new Set(["--json", "--base"]);
760
+ const unknown = args.filter(
761
+ (a, i) =>
762
+ !known.has(a) && !(i === baseFlagIndex + 1 && baseFlagIndex !== -1),
763
+ );
764
+ if (unknown.length > 0) {
765
+ console.error(`[proof:drift] unknown flag: ${unknown.join(", ")}`);
766
+ process.exit(2);
767
+ }
768
+
769
+ const headCapabilities = readJson(CAPABILITIES_PATH, {
770
+ hint: "run `pnpm proof:build` first — drift diffs the regenerated artifacts",
771
+ });
772
+ const headSchema = readJson(SCHEMA_PATH, {
773
+ hint: "run `pnpm proof:build` first — drift diffs the regenerated artifacts",
774
+ });
775
+ const headPkg = readJson(CONFIG.repository.packageJson, {});
776
+
777
+ let base;
778
+ try {
779
+ base = resolveBase(explicitBase);
780
+ } catch (err) {
781
+ console.error(`[proof:drift] ${err.message}`);
782
+ process.exit(2);
783
+ }
784
+
785
+ // The budget is loaded before any base work: whether drift may silently
786
+ // downgrade to "not assessed" depends on whether a planner bounded this PR.
787
+ const { expected } = loadExpectedChanges();
788
+
789
+ // Fail closed when a planner declared a budget: an unassessable drift check
790
+ // that exits 0 would let exactly the change the budget forbids through.
791
+ // Without a budget the check is informational and stays permissive.
792
+ const notAssessed = (reason) => {
793
+ writeReport({
794
+ schemaVersion: 1,
795
+ assessed: false,
796
+ reason,
797
+ generatedAt: new Date().toISOString(),
798
+ });
799
+ if (expected) {
800
+ console.error(
801
+ `[PROOF_FAIL] drift_unassessed: the mission declares expectedChanges but drift could not be assessed: ${reason}`,
802
+ );
803
+ console.error(
804
+ ` suggestion: fix the base resolution (fetch history, set PROOF_DRIFT_BASE) — enforced drift never passes unassessed`,
805
+ );
806
+ process.exit(1);
807
+ }
808
+ console.error(`[proof:drift] drift not assessed: ${reason}`);
809
+ };
810
+
811
+ if (!base) {
812
+ notAssessed(
813
+ "no base ref found (origin/main or main) — pass --base or set PROOF_DRIFT_BASE",
814
+ );
815
+ return;
816
+ }
817
+
818
+ const headSha = tryGit(["rev-parse", "HEAD"]);
819
+
820
+ // Lockfile drift is tracked separately from the package.json surface: a
821
+ // lockfile that changes while the dependency surface does not means the
822
+ // RESOLUTION changed (a swapped tarball, a widened range realized), which
823
+ // no package.json diff can see.
824
+ let lockfileChanged = false;
825
+ if (
826
+ fs.existsSync(CONFIG.repository.lockfile) ||
827
+ tryGit(["cat-file", "-e", `${base.sha}:${CONFIG.repository.lockfile}`]) !==
828
+ null
829
+ ) {
830
+ try {
831
+ execFileSync(
832
+ "git",
833
+ ["diff", "--quiet", base.sha, "--", CONFIG.repository.lockfile],
834
+ { stdio: "ignore" },
835
+ );
836
+ } catch {
837
+ lockfileChanged = true;
838
+ }
839
+ }
840
+
841
+ // Fast path: when nothing feeding the artifacts differs from the base
842
+ // (committed or not), there is no drift and no need to rebuild anything.
843
+ let sourceUnchanged = false;
844
+ try {
845
+ execFileSync("git", ["diff", "--quiet", base.sha, "--", ...SOURCE_PATHS], {
846
+ stdio: "ignore",
847
+ });
848
+ sourceUnchanged = true;
849
+ } catch {
850
+ sourceUnchanged = false;
851
+ }
852
+
853
+ let entries = [];
854
+ if (!sourceUnchanged) {
855
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "proof-drift-"));
856
+ let baseArtifacts;
857
+ try {
858
+ baseArtifacts = generateBaseArtifacts(base.sha, tmpDir);
859
+ } catch (err) {
860
+ notAssessed(
861
+ `could not rebuild base artifacts at ${base.sha.slice(0, 12)}: ${err.message}`,
862
+ );
863
+ return;
864
+ } finally {
865
+ fs.rmSync(tmpDir, { recursive: true, force: true });
866
+ }
867
+ entries = buildDrift({
868
+ baseCapabilities: baseArtifacts.capabilities,
869
+ headCapabilities,
870
+ baseSchema: baseArtifacts.schema,
871
+ headSchema,
872
+ basePkg: baseArtifacts.pkg,
873
+ headPkg,
874
+ });
875
+ }
876
+
877
+ if (lockfileChanged) {
878
+ const dependenciesAlsoChanged = entries.some(
879
+ (e) => e.area === "dependency" || e.area === "dev-dependency",
880
+ );
881
+ entries.push({
882
+ area: "lockfile",
883
+ key: CONFIG.repository.lockfile,
884
+ change: "modified",
885
+ // Alongside package.json dependency changes a lockfile update is the
886
+ // expected companion; alone, it is a resolution change nobody declared.
887
+ severity: dependenciesAlsoChanged ? "info" : "high",
888
+ detail: dependenciesAlsoChanged
889
+ ? "lockfile updated alongside package.json dependency changes"
890
+ : "lockfile changed while the package.json dependency surface did not — dependency RESOLUTION changed",
891
+ });
892
+ }
893
+
894
+ const evaluation = evaluateDrift(entries, expected);
895
+
896
+ const report = {
897
+ schemaVersion: 1,
898
+ assessed: true,
899
+ base: { sha: base.sha, ref: base.ref ?? null },
900
+ head: { sha: headSha },
901
+ generatedAt: new Date().toISOString(),
902
+ entries,
903
+ enforcement: evaluation,
904
+ };
905
+ writeReport(report);
906
+
907
+ if (asJson) {
908
+ console.log(JSON.stringify(report, null, 2));
909
+ if (evaluation.undeclared.length > 0) process.exit(1);
910
+ return;
911
+ }
912
+
913
+ const baseLabel = `${base.sha.slice(0, 12)}${base.ref ? ` (${base.ref})` : ""}`;
914
+ if (entries.length === 0) {
915
+ console.log(
916
+ `[proof:drift] PASS · no action/table/dependency drift vs ${baseLabel}`,
917
+ );
918
+ return;
919
+ }
920
+
921
+ console.log(`\nDRIFT vs ${baseLabel}`);
922
+ console.log("─".repeat(76));
923
+ console.log("");
924
+ printEntries(entries);
925
+ const high = entries.filter((e) => e.severity === "high");
926
+ console.log(`\n ${entries.length} change(s) · ${high.length} high-severity`);
927
+
928
+ if (!evaluation.enforced) {
929
+ console.log(
930
+ `\n[proof:drift] report-only: no expectedChanges budget in ${MISSION_PATH} — ` +
931
+ `declare { modules, actions, tables, dependencies, lockfile } there to fail on undeclared high-severity drift`,
932
+ );
933
+ return;
934
+ }
935
+
936
+ if (evaluation.unusedDeclarations.length > 0) {
937
+ console.log(
938
+ `\n declared but unchanged: ` +
939
+ evaluation.unusedDeclarations
940
+ .map((u) => `${u.scope}:${u.name}`)
941
+ .join(", "),
942
+ );
943
+ }
944
+
945
+ if (evaluation.undeclared.length === 0) {
946
+ console.log(
947
+ `\n[proof:drift] PASS · every high-severity change is inside the mission's declared scope`,
948
+ );
949
+ return;
950
+ }
951
+
952
+ console.error("");
953
+ for (const entry of evaluation.undeclared) {
954
+ const scope =
955
+ entry.area === "action"
956
+ ? entry.change === "added"
957
+ ? `neither expectedChanges.actions ("${entry.key}") nor expectedChanges.modules ("${entry.module}") covers it`
958
+ : `expectedChanges.actions does not cover "${entry.key}" for: ${(entry.facets ?? [entry.change]).join(", ")} — a module declaration only covers ADDED actions`
959
+ : entry.area === "table"
960
+ ? `expectedChanges.tables does not cover "${entry.key}" for: ${(entry.facets ?? [entry.change]).join(", ")}`
961
+ : entry.area === "lockfile"
962
+ ? `a lockfile-only resolution change requires expectedChanges.lockfile: true`
963
+ : `dependency "${entry.key}" is not in expectedChanges.dependencies`;
964
+ console.error(
965
+ `[PROOF_FAIL] drift_undeclared: ${entry.area} ${entry.key} ${entry.change} (${entry.detail}) — ${scope}`,
966
+ );
967
+ console.error(
968
+ ` suggestion: revert the change if it was unplanned, or have the mission owner widen expectedChanges in ${MISSION_PATH}`,
969
+ );
970
+ }
971
+ process.exit(1);
972
+ }
973
+
974
+ const isDirectInvocation =
975
+ process.argv[1] !== undefined &&
976
+ import.meta.url === pathToFileURL(process.argv[1]).href;
977
+
978
+ if (isDirectInvocation) {
979
+ main();
980
+ }