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