proof-harness 0.1.0-next.6 → 0.1.0-next.7
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 +70 -0
- package/PROOF_SDK_CONTRACT.md +13 -2
- package/README.md +14 -0
- package/cli/config.mjs +29 -0
- package/cli/engines/parse_proof_schema.mjs +40 -6
- package/cli/engines/proof_coverage.mjs +11 -17
- package/cli/engines/proof_drift.mjs +105 -93
- package/cli/engines/proof_mutation_check.mjs +189 -56
- package/cli/engines/proof_mutation_inventory.mjs +16 -21
- package/cli/engines/proof_verify.mjs +56 -37
- package/cli/engines/scan_proof_capabilities.mjs +309 -204
- package/dist/{mission-types-bdwBGf7O.d.ts → mission-types-CYXrtv5m.d.ts} +2 -2
- package/dist/node.d.ts +31 -3
- package/dist/node.js +320 -47
- package/dist/node.js.map +1 -1
- package/dist/playwright.d.ts +3 -1
- package/dist/playwright.js +577 -229
- package/dist/playwright.js.map +1 -1
- package/dist/server.js +42 -29
- package/dist/server.js.map +1 -1
- package/dist/shared.d.ts +2 -2
- package/dist/shared.js.map +1 -1
- package/dist/{trace-types-CbMb65OB.d.ts → trace-types-BIW53YPH.d.ts} +5 -0
- package/package.json +3 -3
- package/proof-consumer-fixtures/malformed/contradictory-status.json +45 -0
package/COMPATIBILITY.md
CHANGED
|
@@ -27,6 +27,76 @@ versions rather than guessing how to interpret them.
|
|
|
27
27
|
|
|
28
28
|
## Prerelease migration notes
|
|
29
29
|
|
|
30
|
+
### Unreleased audit hardening
|
|
31
|
+
|
|
32
|
+
Release candidate `0.1.0-next.7` tightens acceptance behavior; it has not been published. Trace
|
|
33
|
+
schema remains 2, with additive source/execution identity fields. Version 1
|
|
34
|
+
and unversioned trace shapes remain explicitly readable, but CLI acceptance
|
|
35
|
+
requires current provenance. Required consumer changes:
|
|
36
|
+
|
|
37
|
+
- Regenerate traces. `verify`, `coverage`, and mutation inventory share a
|
|
38
|
+
decoder: corrupt JSON, duplicate proof IDs, unknown vocabulary, and
|
|
39
|
+
contradictory verdicts fail. Only passing baseline evidence can satisfy
|
|
40
|
+
claims; mutation evidence is separate. Current spec contents must match
|
|
41
|
+
`specHash`; in Git worktrees both `commit` and `sourceHash` must match.
|
|
42
|
+
`sourceHash` includes tracked and untracked nonignored inputs and excludes
|
|
43
|
+
configured generated artifacts. Run generators before proofs. Non-Git
|
|
44
|
+
exports cannot establish whole-source freshness and only check spec hashes.
|
|
45
|
+
- Use a dedicated trace directory. The writer honors `artifacts.traces` and
|
|
46
|
+
the runner's `--traces-dir` override. IDs must use letters, digits, dots,
|
|
47
|
+
underscores, or hyphens, starting with a letter or digit. Duplicate IDs
|
|
48
|
+
cannot overwrite another proof; a later Playwright retry of the same test
|
|
49
|
+
may replace its earlier attempt. Standalone repeated runs need fresh output.
|
|
50
|
+
Mission aggregates now live at `<traces>/missions/<missionId>.json` to
|
|
51
|
+
prevent collisions with proof IDs. Cleanup refuses unknown JSON before
|
|
52
|
+
deleting any trace and rejects repository/ancestor directories, including
|
|
53
|
+
symlink aliases. Move legacy or corrupt files aside before rerunning.
|
|
54
|
+
- UPDATE denial probes require stable `identityColumns` (default `["id"]`),
|
|
55
|
+
present and non-null in every target row, excluded from the update payload.
|
|
56
|
+
The attempted update must change a value. Rereads use original identities,
|
|
57
|
+
even when a write changes a filter column. SQL errors other than `42501`
|
|
58
|
+
are incomplete evidence. INSERT probes require a payload absent before the
|
|
59
|
+
probe and valid for a service-role control insertion; that control is
|
|
60
|
+
cleaned up. HTTP expectations must contain a status or a nonempty matcher
|
|
61
|
+
list. Write requests are never automatically retried after transport errors.
|
|
62
|
+
- Capability discovery uses TypeScript syntax analysis for import aliases and
|
|
63
|
+
multiple actions. Unknown factory/client indirection, escaped service query
|
|
64
|
+
builders, dynamic table names, and ambiguous metadata block assessment.
|
|
65
|
+
Discovery still assumes the configured template action layout.
|
|
66
|
+
- The SQL parser is deliberately conservative, not a final PostgreSQL catalog
|
|
67
|
+
evaluator. Unsupported ALTER/DROP or dynamic SQL makes schema assessment
|
|
68
|
+
fail. RLS must be explicitly enabled for classification; public ALL/write
|
|
69
|
+
policies are never exempted as public-read. Policy expressions and mode
|
|
70
|
+
participate in drift. Resolve unsupported migrations with a catalog-backed
|
|
71
|
+
integration before using this parser as a gate; do not edit shipped SQL or
|
|
72
|
+
mark an unassessed artifact assessed to obtain a pass.
|
|
73
|
+
- Drift always regenerates both trees using the installed harness and each
|
|
74
|
+
tree's consumer configuration. It includes untracked actions, SQL, and
|
|
75
|
+
lockfiles. For ignored/generated migrations, configure `driftPrepare` as a
|
|
76
|
+
command argv array (for example `["node", "scripts/aggregate_migrations.mjs",
|
|
77
|
+
"--fresh"]`). That consumer-owned command runs in each tree before parsing;
|
|
78
|
+
failure prevents assessment. It must work in the archived tree without an
|
|
79
|
+
implicit dependency install. The old `driftSources` optimization is unused.
|
|
80
|
+
- Mutations require fresh mapped baseline claims and write isolated traces.
|
|
81
|
+
SQL applies run as one transaction; recovery is attempted even after a
|
|
82
|
+
failed apply. Policy snapshots preserve expressions, roles, command, and
|
|
83
|
+
permissive/restrictive mode. Generic privilege mutations support direct,
|
|
84
|
+
owner-issued grants without grant options; unsupported inherited/PUBLIC or
|
|
85
|
+
ownership cases fail before planting. Move old mutation outputs aside on
|
|
86
|
+
first use: nonempty output directories require a harness ownership marker.
|
|
87
|
+
An interrupted run leaves `mutation-recovery.json` beside the mutation output
|
|
88
|
+
directory. Run `proof-harness mutate --recover` against the same local
|
|
89
|
+
database to restore and verify the journaled subject. A timeout, signal, or
|
|
90
|
+
incomplete failed claim never counts as detection. Recovery covers the
|
|
91
|
+
catalog's declared subject and cleanup, not arbitrary undeclared side effects.
|
|
92
|
+
- Partial fixture setup is cleaned up, and cleanup errors now fail the proof.
|
|
93
|
+
|
|
94
|
+
These checks validate observed evidence. Protect the installed harness,
|
|
95
|
+
configuration, mission, policies, mutation catalog, and CI acceptance job from
|
|
96
|
+
executor edits. Trace metadata is not a signature or a sandbox: it cannot
|
|
97
|
+
establish honesty against an executor allowed to replace the gate or fabricate
|
|
98
|
+
its inputs. Independent product acceptance and a trusted runner remain needed.
|
|
99
|
+
|
|
30
100
|
### 0.1.0-next.6
|
|
31
101
|
|
|
32
102
|
**Removed: the module-descriptor system.** It served the consumer's planner
|
package/PROOF_SDK_CONTRACT.md
CHANGED
|
@@ -10,7 +10,7 @@ instead; this document is for anyone _consuming_ the template.
|
|
|
10
10
|
> **"Manifest" in this document always means the _mission_ manifest**
|
|
11
11
|
> (`.proof/current-mission.json`) — the file that pins what one PR must prove.
|
|
12
12
|
|
|
13
|
-
- **
|
|
13
|
+
- **Versions:** mission manifests 1; proof traces 2; proof health protocol 1.
|
|
14
14
|
- **Stability:** The shapes in this document are the contract. Additive
|
|
15
15
|
changes (new optional fields, new vocabulary entries) do not bump the
|
|
16
16
|
version; breaking changes bump `schemaVersion` and are documented here.
|
|
@@ -22,6 +22,17 @@ instead; this document is for anyone _consuming_ the template.
|
|
|
22
22
|
|
|
23
23
|
---
|
|
24
24
|
|
|
25
|
+
## Current acceptance semantics
|
|
26
|
+
|
|
27
|
+
The unreleased audit hardening in [COMPATIBILITY.md](COMPATIBILITY.md#unreleased-audit-hardening)
|
|
28
|
+
supersedes older template-specific command examples below. Trace consumers
|
|
29
|
+
must use `decodeTrace`/`decodeTraceBundle` for structural validation and
|
|
30
|
+
`readTraceDirectory` for CLI-equivalent freshness checks. Evidence counts only
|
|
31
|
+
when the proof and step passed, the assertion has a passing consistent verdict,
|
|
32
|
+
and the trace is a baseline rather than a planted mutation. Helper-origin
|
|
33
|
+
metadata is a convention enforced by the writer, not cryptographic attestation.
|
|
34
|
+
The consumer controls and protects the runner and its trust inputs.
|
|
35
|
+
|
|
25
36
|
## 1. The loop
|
|
26
37
|
|
|
27
38
|
```
|
|
@@ -47,7 +58,7 @@ instead; this document is for anyone _consuming_ the template.
|
|
|
47
58
|
│ │ fails cheaply BEFORE the database proofs run │
|
|
48
59
|
│ ├─ proof:verify │
|
|
49
60
|
│ ├─ runs every *.proof.ts spec │
|
|
50
|
-
│ ├─ aggregates .proof/traces/<missionId>.json │
|
|
61
|
+
│ ├─ aggregates .proof/traces/missions/<missionId>.json │
|
|
51
62
|
│ └─ validates manifest requirements against artifacts │
|
|
52
63
|
│ ├─ proof:coverage --strict │
|
|
53
64
|
│ └─ proof:inventory → requires mutation or acceptance │
|
package/README.md
CHANGED
|
@@ -91,3 +91,17 @@ Licensed under the [Apache License 2.0](./LICENSE).
|
|
|
91
91
|
The package is released from this public repository with npm provenance.
|
|
92
92
|
Prerelease consumers must pin an exact version rather than using `next`, `^`, or
|
|
93
93
|
`~`; see [COMPATIBILITY.md](./COMPATIBILITY.md) for the compatibility policy.
|
|
94
|
+
|
|
95
|
+
## Audit hardening (0.1.0-next.7, unreleased)
|
|
96
|
+
|
|
97
|
+
The working branch adds shared fail-closed trace ingestion, source freshness,
|
|
98
|
+
stronger authorization probes, isolated mutation evidence and interruption
|
|
99
|
+
recovery, conservative discovery, and package-owned drift regeneration.
|
|
100
|
+
See [required migration changes](COMPATIBILITY.md#unreleased-audit-hardening)
|
|
101
|
+
before upgrading a consumer. In particular, existing traces must be rerun and
|
|
102
|
+
unsupported SQL now blocks assessment.
|
|
103
|
+
|
|
104
|
+
The harness establishes specific tested claims. Acceptance still requires
|
|
105
|
+
consumer-owned requirements and a trusted CI job that controls the harness,
|
|
106
|
+
policies, and evidence. A passing trace is not a general proof of code quality
|
|
107
|
+
or a defense against an executor that can rewrite the acceptance gate.
|
package/cli/config.mjs
CHANGED
|
@@ -43,6 +43,9 @@ const DEFAULT_CONFIG = Object.freeze({
|
|
|
43
43
|
envExample: ".env.local.example",
|
|
44
44
|
supabaseConfig: "supabase/config.toml",
|
|
45
45
|
},
|
|
46
|
+
// Optional consumer-owned argv used to prepare generated migration inputs
|
|
47
|
+
// in both the working tree and the archived base before parsing.
|
|
48
|
+
driftPrepare: null,
|
|
46
49
|
driftSources: ["app", "package.json"],
|
|
47
50
|
mutationCatalog: null,
|
|
48
51
|
});
|
|
@@ -105,3 +108,29 @@ export async function loadMutationCatalog(config) {
|
|
|
105
108
|
}
|
|
106
109
|
|
|
107
110
|
export { DEFAULT_CONFIG };
|
|
111
|
+
|
|
112
|
+
/** Derived outputs are excluded from the source identity, never trust inputs. */
|
|
113
|
+
export function evidenceExclusions(config) {
|
|
114
|
+
return [
|
|
115
|
+
...Object.values(config.artifacts),
|
|
116
|
+
`${path.dirname(config.artifacts.mutations)}/mutation-recovery.json`,
|
|
117
|
+
].map((p) => path.resolve(config.rootDir, p));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Reject a trace/output directory that resolves to the repository or its parent. */
|
|
121
|
+
export function assertArtifactDirectory(directory, rootDir) {
|
|
122
|
+
function canonical(file) {
|
|
123
|
+
const absolute = path.resolve(file);
|
|
124
|
+
if (fs.existsSync(absolute)) return fs.realpathSync(absolute);
|
|
125
|
+
const parent = path.dirname(absolute);
|
|
126
|
+
return path.join(canonical(parent), path.basename(absolute));
|
|
127
|
+
}
|
|
128
|
+
const output = canonical(directory);
|
|
129
|
+
const root = canonical(rootDir);
|
|
130
|
+
const relative = path.relative(output, root);
|
|
131
|
+
if (!relative || (!relative.startsWith("..") && !path.isAbsolute(relative)))
|
|
132
|
+
throw new Error(
|
|
133
|
+
"[PROOF_FAIL] unsafe_artifact_directory: output must not be the repository or an ancestor",
|
|
134
|
+
);
|
|
135
|
+
return output;
|
|
136
|
+
}
|
|
@@ -269,6 +269,7 @@ function parsePolicies(sql, sourceFile) {
|
|
|
269
269
|
name: policyName,
|
|
270
270
|
table: tableName,
|
|
271
271
|
command,
|
|
272
|
+
mode: /\bAS\s+RESTRICTIVE\b/i.test(rest) ? "RESTRICTIVE" : "PERMISSIVE",
|
|
272
273
|
roles,
|
|
273
274
|
using: usingBody.trim(),
|
|
274
275
|
check: checkBody.trim(),
|
|
@@ -308,7 +309,7 @@ function policyIndicatesUser(policy) {
|
|
|
308
309
|
}
|
|
309
310
|
|
|
310
311
|
function policyIsPublicRead(policy) {
|
|
311
|
-
if (policy.command !== "SELECT"
|
|
312
|
+
if (policy.command !== "SELECT") return false;
|
|
312
313
|
const body = policy.using.trim().toLowerCase();
|
|
313
314
|
return body === "true";
|
|
314
315
|
}
|
|
@@ -348,9 +349,7 @@ function classifyTable(policies) {
|
|
|
348
349
|
if (
|
|
349
350
|
readPolicies.length > 0 &&
|
|
350
351
|
readPolicies.every(policyIsPublicRead) &&
|
|
351
|
-
authPolicies.every(
|
|
352
|
-
(p) => p.command === "SELECT" || p.command === "ALL" || p.check === "",
|
|
353
|
-
)
|
|
352
|
+
authPolicies.every((p) => p.command === "SELECT")
|
|
354
353
|
) {
|
|
355
354
|
return "public_read";
|
|
356
355
|
}
|
|
@@ -366,6 +365,26 @@ function classifyTable(policies) {
|
|
|
366
365
|
|
|
367
366
|
export function main() {
|
|
368
367
|
const sources = readAllMigrations();
|
|
368
|
+
const unsupported = [];
|
|
369
|
+
const enabledTables = new Set();
|
|
370
|
+
for (const { file, sql } of sources) {
|
|
371
|
+
// This parser is intentionally conservative. Never preserve historical CREATE
|
|
372
|
+
// facts as current evidence after an operation we cannot interpret.
|
|
373
|
+
const enable =
|
|
374
|
+
/ALTER\s+TABLE\s+(?:ONLY\s+)?(?:"?public"?\.)?"?(\w+)"?\s+ENABLE\s+ROW\s+LEVEL\s+SECURITY\s*;/gi;
|
|
375
|
+
for (const match of sql.matchAll(enable)) enabledTables.add(match[1]);
|
|
376
|
+
const remainder = sql.replace(enable, "");
|
|
377
|
+
if (
|
|
378
|
+
/\b(?:ALTER\s+(?:TABLE|POLICY)|DROP\s+(?:TABLE|POLICY)|DO\s+\$|EXECUTE\s+)/i.test(
|
|
379
|
+
remainder,
|
|
380
|
+
)
|
|
381
|
+
)
|
|
382
|
+
unsupported.push({
|
|
383
|
+
file,
|
|
384
|
+
reason:
|
|
385
|
+
"migration changes schema or policy state using unsupported SQL; final schema is unassessable",
|
|
386
|
+
});
|
|
387
|
+
}
|
|
369
388
|
const tablesByName = new Map();
|
|
370
389
|
const policiesByTable = new Map();
|
|
371
390
|
|
|
@@ -373,7 +392,10 @@ export function main() {
|
|
|
373
392
|
for (const t of extractTables(sql, file)) {
|
|
374
393
|
const existing = tablesByName.get(t.name);
|
|
375
394
|
if (existing) {
|
|
376
|
-
|
|
395
|
+
unsupported.push({
|
|
396
|
+
file,
|
|
397
|
+
reason: `repeated CREATE TABLE ${t.name} cannot establish final columns`,
|
|
398
|
+
});
|
|
377
399
|
existing.columns = [...new Set([...existing.columns, ...t.columns])];
|
|
378
400
|
if (!existing.files.includes(file)) existing.files.push(file);
|
|
379
401
|
} else {
|
|
@@ -395,17 +417,23 @@ export function main() {
|
|
|
395
417
|
|
|
396
418
|
for (const [name, entry] of tablesByName) {
|
|
397
419
|
const policies = policiesByTable.get(name) ?? [];
|
|
398
|
-
const classification =
|
|
420
|
+
const classification = enabledTables.has(name)
|
|
421
|
+
? classifyTable(policies)
|
|
422
|
+
: "unclassified";
|
|
399
423
|
|
|
400
424
|
const normalized = {
|
|
401
425
|
name,
|
|
402
426
|
columns: [...entry.columns].sort(),
|
|
403
427
|
rls_classification: classification,
|
|
428
|
+
rls_enabled: enabledTables.has(name),
|
|
404
429
|
policies: policies
|
|
405
430
|
.map((p) => ({
|
|
406
431
|
name: p.name,
|
|
407
432
|
command: p.command,
|
|
408
433
|
roles: [...p.roles].sort(),
|
|
434
|
+
using: p.using,
|
|
435
|
+
check: p.check,
|
|
436
|
+
mode: p.mode,
|
|
409
437
|
}))
|
|
410
438
|
.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)),
|
|
411
439
|
sourceFiles: [...entry.files]
|
|
@@ -429,6 +457,8 @@ export function main() {
|
|
|
429
457
|
|
|
430
458
|
const output = {
|
|
431
459
|
schemaVersion: 1,
|
|
460
|
+
assessed: unsupported.length === 0,
|
|
461
|
+
issues: unsupported,
|
|
432
462
|
tables,
|
|
433
463
|
unclassified: unclassifiedTables,
|
|
434
464
|
};
|
|
@@ -439,6 +469,10 @@ export function main() {
|
|
|
439
469
|
console.log(
|
|
440
470
|
`[proof:parse] parsed ${sources.length} migration file(s); wrote ${tables.length} table(s), ${unclassifiedTables.length} unclassified to ${OUTPUT_PATH}`,
|
|
441
471
|
);
|
|
472
|
+
if (unsupported.length)
|
|
473
|
+
throw new Error(
|
|
474
|
+
`[PROOF_FAIL] schema_unassessed: ${unsupported.map((p) => `${p.file}: ${p.reason}`).join("; ")}`,
|
|
475
|
+
);
|
|
442
476
|
if (unclassifiedTables.length > 0) {
|
|
443
477
|
console.log(`[proof:parse] unclassified tables:`);
|
|
444
478
|
for (const u of unclassifiedTables)
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import { readTraceDirectory, baselineAssertions } from "../../dist/node.js";
|
|
2
3
|
// ---------------------------------------------------------------------------
|
|
3
4
|
// proof_coverage.mjs
|
|
4
5
|
//
|
|
@@ -57,7 +58,7 @@ import {
|
|
|
57
58
|
TENANT_ISOLATION_ACTION_HELPERS,
|
|
58
59
|
TENANT_ISOLATION_TABLE_HELPERS,
|
|
59
60
|
} from "../../dist/portable-vocabulary.js";
|
|
60
|
-
import { loadProofConfig } from "../config.mjs";
|
|
61
|
+
import { loadProofConfig, evidenceExclusions } from "../config.mjs";
|
|
61
62
|
|
|
62
63
|
const CONFIG = await loadProofConfig();
|
|
63
64
|
process.chdir(CONFIG.rootDir);
|
|
@@ -121,22 +122,11 @@ function readJson(file, { required = true } = {}) {
|
|
|
121
122
|
|
|
122
123
|
/** Every assertion from every trace, flattened. Steps may nest assertions. */
|
|
123
124
|
function readAssertions() {
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
for (const artifact of Array.isArray(parsed) ? parsed : [parsed]) {
|
|
130
|
-
// The aggregate <missionId>.json embeds every per-spec trace; skip it so
|
|
131
|
-
// assertions are not counted twice.
|
|
132
|
-
if (Array.isArray(artifact?.traces)) continue;
|
|
133
|
-
const nested = (artifact?.steps ?? []).flatMap((s) => s.assertions ?? []);
|
|
134
|
-
for (const assertion of [...(artifact?.assertions ?? []), ...nested]) {
|
|
135
|
-
out.push({ ...assertion, proofId: artifact?.proofId ?? null });
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
return out;
|
|
125
|
+
return baselineAssertions(
|
|
126
|
+
readTraceDirectory(TRACES_DIR, {
|
|
127
|
+
excludedPaths: evidenceExclusions(CONFIG),
|
|
128
|
+
}),
|
|
129
|
+
);
|
|
140
130
|
}
|
|
141
131
|
|
|
142
132
|
// ---------------------------------------------------------------------------
|
|
@@ -202,6 +192,10 @@ function buildReport() {
|
|
|
202
192
|
const policy = readJson(POLICY_PATH, { required: false }) ?? {
|
|
203
193
|
acceptedGaps: [],
|
|
204
194
|
};
|
|
195
|
+
if (schema.assessed === false || capabilities.unclassified?.length)
|
|
196
|
+
throw new Error(
|
|
197
|
+
"[PROOF_FAIL] discovery_unassessed: schema or capability discovery is incomplete",
|
|
198
|
+
);
|
|
205
199
|
const assertions = readAssertions();
|
|
206
200
|
|
|
207
201
|
const tableNames = new Set((schema.tables ?? []).map((t) => t.name));
|
|
@@ -19,12 +19,11 @@
|
|
|
19
19
|
//
|
|
20
20
|
// against the same artifacts regenerated from the PR's merge base. The
|
|
21
21
|
// artifact generators are pure filesystem scripts, so the base side is rebuilt
|
|
22
|
-
// by extracting the base tree (`git archive`) and running
|
|
23
|
-
//
|
|
22
|
+
// by extracting the base tree (`git archive`) and running the installed harness
|
|
23
|
+
// with the archived consumer configuration. Optional driftPrepare builds inputs.
|
|
24
24
|
//
|
|
25
25
|
// WHAT THIS IS NOT. Drift sees exactly what the scanners see, and no more. It
|
|
26
|
-
// does NOT see:
|
|
27
|
-
// types, constraints, or defaults; SQL grants, triggers, or function bodies;
|
|
26
|
+
// does NOT see: column types, constraints, or defaults; SQL grants, triggers, or function bodies;
|
|
28
27
|
// transitive dependency contents; or ordinary product behavior. "No drift"
|
|
29
28
|
// means "no change on the proof-relevant derived surfaces", never "nothing
|
|
30
29
|
// else changed" — behavior inside declared scope still merits review or a
|
|
@@ -72,7 +71,7 @@ import { execFileSync } from "node:child_process";
|
|
|
72
71
|
import fs from "node:fs";
|
|
73
72
|
import os from "node:os";
|
|
74
73
|
import path from "node:path";
|
|
75
|
-
import { pathToFileURL } from "node:url";
|
|
74
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
76
75
|
|
|
77
76
|
// Single source of truth for the facet vocabularies, shared with both mission
|
|
78
77
|
// validators (which reject exactly what the standalone budget check below
|
|
@@ -91,10 +90,6 @@ const SCHEMA_PATH = CONFIG.artifacts.schema;
|
|
|
91
90
|
const MISSION_PATH = CONFIG.mission.current;
|
|
92
91
|
const OUTPUT_PATH = CONFIG.artifacts.drift;
|
|
93
92
|
|
|
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
93
|
// ---------------------------------------------------------------------------
|
|
99
94
|
// Diffing (pure — unit-tested directly)
|
|
100
95
|
// ---------------------------------------------------------------------------
|
|
@@ -282,7 +277,10 @@ export function diffSchema(baseArtifact, headArtifact) {
|
|
|
282
277
|
// Enforcement covers a table change only when EVERY facet is declared,
|
|
283
278
|
// so `columns_added` on a declared table never authorizes a policy edit.
|
|
284
279
|
const facets = [];
|
|
285
|
-
if (
|
|
280
|
+
if (
|
|
281
|
+
baseTable.rls_classification !== headTable.rls_classification ||
|
|
282
|
+
baseTable.rls_enabled !== headTable.rls_enabled
|
|
283
|
+
) {
|
|
286
284
|
facets.push("rls_classification_changed");
|
|
287
285
|
notes.push(
|
|
288
286
|
`rls_classification: ${baseTable.rls_classification} -> ${headTable.rls_classification}`,
|
|
@@ -301,7 +299,7 @@ export function diffSchema(baseArtifact, headArtifact) {
|
|
|
301
299
|
// `TO authenticated` widens who a policy reaches without renaming it, and
|
|
302
300
|
// may leave the coarse rls_classification untouched.
|
|
303
301
|
const policyKey = (p) =>
|
|
304
|
-
`${p.name} FOR ${p.command} TO ${[...(p.roles ?? [])].sort().join(",") || "PUBLIC"}`;
|
|
302
|
+
`${p.name} FOR ${p.command} TO ${[...(p.roles ?? [])].sort().join(",") || "PUBLIC"} ${p.mode ?? "PERMISSIVE"} USING ${p.using ?? ""} CHECK ${p.check ?? ""}`;
|
|
305
303
|
const policies = setDiff(
|
|
306
304
|
(baseTable.policies ?? []).map(policyKey),
|
|
307
305
|
(headTable.policies ?? []).map(policyKey),
|
|
@@ -564,13 +562,8 @@ export function resolveBase(explicit) {
|
|
|
564
562
|
return null;
|
|
565
563
|
}
|
|
566
564
|
|
|
567
|
-
/**
|
|
568
|
-
|
|
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) {
|
|
565
|
+
/** Materialize the base consumer and evaluate it with this installed harness. */
|
|
566
|
+
async function generateBaseArtifacts(baseSha, tmpDir) {
|
|
574
567
|
const tarPath = path.join(tmpDir, "base.tar");
|
|
575
568
|
execFileSync("git", ["archive", "--format=tar", "-o", tarPath, baseSha], {
|
|
576
569
|
stdio: ["ignore", "ignore", "pipe"],
|
|
@@ -581,31 +574,43 @@ function generateBaseArtifacts(baseSha, tmpDir) {
|
|
|
581
574
|
stdio: ["ignore", "ignore", "pipe"],
|
|
582
575
|
});
|
|
583
576
|
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
577
|
+
// Use the installed harness on both trees so generator-version differences
|
|
578
|
+
// cannot masquerade as product drift. Rebase every configured path into
|
|
579
|
+
// the archived consumer; never run removed template-owned scripts.
|
|
580
|
+
const baseConfigPath = CONFIG.configPath
|
|
581
|
+
? path.join(treeDir, path.relative(CONFIG.rootDir, CONFIG.configPath))
|
|
582
|
+
: undefined;
|
|
583
|
+
const baseConfig = await loadProofConfig({
|
|
584
|
+
cwd: treeDir,
|
|
585
|
+
configPath: baseConfigPath,
|
|
586
|
+
});
|
|
587
|
+
const relocated = { ...baseConfig, rootDir: treeDir };
|
|
588
|
+
for (const group of ["artifacts", "roots", "repository"]) {
|
|
589
|
+
for (const value of Object.values(relocated[group])) {
|
|
590
|
+
for (const item of Array.isArray(value) ? value : [value]) {
|
|
591
|
+
if (typeof item !== "string") continue;
|
|
592
|
+
const relative = path.relative(treeDir, path.resolve(treeDir, item));
|
|
593
|
+
if (relative.startsWith("..") || path.isAbsolute(relative))
|
|
594
|
+
throw new Error(`base ${group} path escapes archive: ${item}`);
|
|
595
|
+
}
|
|
592
596
|
}
|
|
593
|
-
execFileSync("node", [script, ...args], {
|
|
594
|
-
cwd: treeDir,
|
|
595
|
-
stdio: ["ignore", "ignore", "pipe"],
|
|
596
|
-
});
|
|
597
597
|
}
|
|
598
|
-
|
|
598
|
+
const configFile = path.join(tmpDir, "base-proof.config.mjs");
|
|
599
|
+
fs.writeFileSync(configFile, `export default ${JSON.stringify(relocated)};`);
|
|
600
|
+
generateArtifacts(treeDir, configFile, relocated);
|
|
599
601
|
return {
|
|
600
602
|
capabilities: JSON.parse(
|
|
601
|
-
fs.readFileSync(
|
|
603
|
+
fs.readFileSync(
|
|
604
|
+
path.join(treeDir, relocated.artifacts.capabilities),
|
|
605
|
+
"utf8",
|
|
606
|
+
),
|
|
602
607
|
),
|
|
603
608
|
schema: JSON.parse(
|
|
604
|
-
fs.readFileSync(path.join(treeDir,
|
|
609
|
+
fs.readFileSync(path.join(treeDir, relocated.artifacts.schema), "utf8"),
|
|
605
610
|
),
|
|
606
611
|
pkg: JSON.parse(
|
|
607
612
|
fs.readFileSync(
|
|
608
|
-
path.join(treeDir,
|
|
613
|
+
path.join(treeDir, relocated.repository.packageJson),
|
|
609
614
|
"utf8",
|
|
610
615
|
),
|
|
611
616
|
),
|
|
@@ -616,6 +621,44 @@ function generateBaseArtifacts(baseSha, tmpDir) {
|
|
|
616
621
|
// CLI
|
|
617
622
|
// ---------------------------------------------------------------------------
|
|
618
623
|
|
|
624
|
+
function generateArtifacts(rootDir, configPath, config) {
|
|
625
|
+
const env = { ...process.env };
|
|
626
|
+
delete env.PROOF_HARNESS_CONFIG;
|
|
627
|
+
if (configPath) env.PROOF_HARNESS_CONFIG = configPath;
|
|
628
|
+
if (config.driftPrepare !== null && config.driftPrepare !== undefined) {
|
|
629
|
+
if (
|
|
630
|
+
!Array.isArray(config.driftPrepare) ||
|
|
631
|
+
!config.driftPrepare.length ||
|
|
632
|
+
config.driftPrepare.some((v) => typeof v !== "string" || !v)
|
|
633
|
+
)
|
|
634
|
+
throw new Error("driftPrepare must be a nonempty command argv array");
|
|
635
|
+
const [command, ...args] = config.driftPrepare;
|
|
636
|
+
execFileSync(command, args, {
|
|
637
|
+
cwd: rootDir,
|
|
638
|
+
env,
|
|
639
|
+
stdio: ["ignore", "ignore", "pipe"],
|
|
640
|
+
timeout: 30_000,
|
|
641
|
+
});
|
|
642
|
+
} else if (
|
|
643
|
+
rootDir === CONFIG.rootDir &&
|
|
644
|
+
tryGit(["check-ignore", config.roots.migrations]) !== null
|
|
645
|
+
) {
|
|
646
|
+
throw new Error(
|
|
647
|
+
"ignored migration inputs need a consumer-owned driftPrepare command to rebuild the archived base",
|
|
648
|
+
);
|
|
649
|
+
}
|
|
650
|
+
for (const name of [
|
|
651
|
+
"scan_proof_capabilities.mjs",
|
|
652
|
+
"parse_proof_schema.mjs",
|
|
653
|
+
]) {
|
|
654
|
+
execFileSync(
|
|
655
|
+
process.execPath,
|
|
656
|
+
[fileURLToPath(new URL(name, import.meta.url))],
|
|
657
|
+
{ cwd: rootDir, env, stdio: ["ignore", "ignore", "pipe"] },
|
|
658
|
+
);
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
|
|
619
662
|
function readJson(file, { hint } = {}) {
|
|
620
663
|
if (!fs.existsSync(file)) {
|
|
621
664
|
console.error(
|
|
@@ -738,7 +781,7 @@ function printEntries(entries) {
|
|
|
738
781
|
for (const e of entries) row(e.area, e.key, e.change, e.severity, e.detail);
|
|
739
782
|
}
|
|
740
783
|
|
|
741
|
-
export function main() {
|
|
784
|
+
export async function main() {
|
|
742
785
|
const args = process.argv.slice(2);
|
|
743
786
|
const asJson = args.includes("--json");
|
|
744
787
|
const baseFlagIndex = args.indexOf("--base");
|
|
@@ -766,14 +809,6 @@ Without it, the report is informational. Writes ${OUTPUT_PATH}.`);
|
|
|
766
809
|
process.exit(2);
|
|
767
810
|
}
|
|
768
811
|
|
|
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
812
|
let base;
|
|
778
813
|
try {
|
|
779
814
|
base = resolveBase(explicitBase);
|
|
@@ -817,61 +852,35 @@ Without it, the report is informational. Writes ${OUTPUT_PATH}.`);
|
|
|
817
852
|
|
|
818
853
|
const headSha = tryGit(["rev-parse", "HEAD"]);
|
|
819
854
|
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
fs.
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
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;
|
|
855
|
+
const lockfilePath = path.relative(
|
|
856
|
+
CONFIG.rootDir,
|
|
857
|
+
path.resolve(CONFIG.repository.lockfile),
|
|
858
|
+
);
|
|
859
|
+
const baseLockfile = tryGit(["show", `${base.sha}:${lockfilePath}`]);
|
|
860
|
+
const headLockfile = fs.existsSync(CONFIG.repository.lockfile)
|
|
861
|
+
? fs.readFileSync(CONFIG.repository.lockfile, "utf8").trim()
|
|
862
|
+
: null;
|
|
863
|
+
const lockfileChanged = baseLockfile !== headLockfile;
|
|
864
|
+
|
|
865
|
+
// Always regenerate. Git diff alone omits untracked action and SQL files.
|
|
866
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "proof-drift-"));
|
|
867
|
+
let entries;
|
|
844
868
|
try {
|
|
845
|
-
|
|
846
|
-
|
|
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
|
-
}
|
|
869
|
+
generateArtifacts(CONFIG.rootDir, CONFIG.configPath, CONFIG);
|
|
870
|
+
const baseArtifacts = await generateBaseArtifacts(base.sha, tmpDir);
|
|
867
871
|
entries = buildDrift({
|
|
868
872
|
baseCapabilities: baseArtifacts.capabilities,
|
|
869
|
-
headCapabilities,
|
|
873
|
+
headCapabilities: readJson(CAPABILITIES_PATH),
|
|
870
874
|
baseSchema: baseArtifacts.schema,
|
|
871
|
-
headSchema,
|
|
875
|
+
headSchema: readJson(SCHEMA_PATH),
|
|
872
876
|
basePkg: baseArtifacts.pkg,
|
|
873
|
-
headPkg,
|
|
877
|
+
headPkg: readJson(CONFIG.repository.packageJson),
|
|
874
878
|
});
|
|
879
|
+
} catch (err) {
|
|
880
|
+
notAssessed(`could not regenerate proof surfaces: ${err.message}`);
|
|
881
|
+
return;
|
|
882
|
+
} finally {
|
|
883
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
875
884
|
}
|
|
876
885
|
|
|
877
886
|
if (lockfileChanged) {
|
|
@@ -976,5 +985,8 @@ const isDirectInvocation =
|
|
|
976
985
|
import.meta.url === pathToFileURL(process.argv[1]).href;
|
|
977
986
|
|
|
978
987
|
if (isDirectInvocation) {
|
|
979
|
-
main()
|
|
988
|
+
main().catch((error) => {
|
|
989
|
+
console.error(`[PROOF_FAIL] drift_unassessed: ${error.message}`);
|
|
990
|
+
process.exitCode = 1;
|
|
991
|
+
});
|
|
980
992
|
}
|