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,498 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// ---------------------------------------------------------------------------
|
|
3
|
+
// proof_module_check.mjs
|
|
4
|
+
//
|
|
5
|
+
// Checks every claim a module descriptor makes against the code.
|
|
6
|
+
//
|
|
7
|
+
// A descriptor is a hand-written claim about a codebase, and hand-written
|
|
8
|
+
// claims about code go stale silently. That is the failure this whole repo
|
|
9
|
+
// keeps meeting — a wrong answer indistinguishable from a right one at the
|
|
10
|
+
// point it is produced — so the claims are checked rather than believed.
|
|
11
|
+
//
|
|
12
|
+
// Most of what the downstream prototype declared is now derived instead
|
|
13
|
+
// (tables, dependencies, actions), which deletes those checks rather than
|
|
14
|
+
// implementing them: you cannot mis-declare what you do not declare. What is
|
|
15
|
+
// left is the part no compiler knows, and each piece of it is checkable:
|
|
16
|
+
//
|
|
17
|
+
// module_missing every core/extension module HAS a descriptor. The one
|
|
18
|
+
// that matters most — without it, deleting a descriptor
|
|
19
|
+
// makes every other check on that module vanish and CI
|
|
20
|
+
// stays green.
|
|
21
|
+
// module_identity id matches its directory; kind matches its root
|
|
22
|
+
// module_shape the descriptor is actually the shape it claims
|
|
23
|
+
// module_question_shape ids unique and snake_case, options real and distinct,
|
|
24
|
+
// applied_by in the closed vocabulary
|
|
25
|
+
// module_seam_missing every place an answer lands still exists. This is what
|
|
26
|
+
// stops a moved file from quietly misleading the code
|
|
27
|
+
// agent that has to apply the answer.
|
|
28
|
+
// module_env_undocumented declared env vars are in .env.local.example
|
|
29
|
+
// module_env_undeclared env vars the module reads are declared
|
|
30
|
+
//
|
|
31
|
+
// Cycles in the derived dependency graph are REPORTED, not failed — the known
|
|
32
|
+
// one is being fixed elsewhere. Promote to a failure once it lands.
|
|
33
|
+
//
|
|
34
|
+
// Exits non-zero on any mismatch. A descriptor that can be wrong silently is a
|
|
35
|
+
// liability with a nice format.
|
|
36
|
+
//
|
|
37
|
+
// Usage: node scripts/proof_module_check.mjs [--json]
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
import fs from "node:fs";
|
|
41
|
+
import path from "node:path";
|
|
42
|
+
import { pathToFileURL } from "node:url";
|
|
43
|
+
|
|
44
|
+
import { loadProofConfig } from "../config.mjs";
|
|
45
|
+
|
|
46
|
+
const CONFIG = await loadProofConfig();
|
|
47
|
+
process.chdir(CONFIG.rootDir);
|
|
48
|
+
|
|
49
|
+
// See the same filter in scan_module_meta.mjs. It has to be installed before
|
|
50
|
+
// anything imports a `.ts` file, which is why the vocabulary below is imported
|
|
51
|
+
// dynamically inside main() rather than at the top: a static import would be
|
|
52
|
+
// hoisted above this and warn before the handler exists.
|
|
53
|
+
process.removeAllListeners("warning");
|
|
54
|
+
process.on("warning", (warning) => {
|
|
55
|
+
if (warning.code === "MODULE_TYPELESS_PACKAGE_JSON") return;
|
|
56
|
+
console.warn(warning.stack ?? String(warning));
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const MODULES_PATH = CONFIG.artifacts.modules;
|
|
60
|
+
const ENV_EXAMPLE = CONFIG.repository.envExample;
|
|
61
|
+
const DESCRIPTOR = "module.meta.ts";
|
|
62
|
+
|
|
63
|
+
/** Roots that must carry a descriptor, and the kind each implies. */
|
|
64
|
+
const REQUIRED_ROOTS = CONFIG.moduleKinds.required;
|
|
65
|
+
const OPTIONAL_ROOTS = CONFIG.moduleKinds.optional;
|
|
66
|
+
|
|
67
|
+
const ENV_READ_PATTERN = /(?:process\.)?env\.([A-Z][A-Z0-9_]+)/g;
|
|
68
|
+
/** Set by the runtime, not by whoever installs the product. */
|
|
69
|
+
const ENV_IGNORED = new Set(["NODE_ENV"]);
|
|
70
|
+
|
|
71
|
+
const SNAKE_CASE = /^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$/;
|
|
72
|
+
const PURPOSE_MAX = 400;
|
|
73
|
+
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
// Helpers
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
function directoriesIn(root) {
|
|
79
|
+
if (!fs.existsSync(root)) return [];
|
|
80
|
+
return fs
|
|
81
|
+
.readdirSync(root, { withFileTypes: true })
|
|
82
|
+
.filter((e) => e.isDirectory())
|
|
83
|
+
.map((e) => e.name)
|
|
84
|
+
.sort();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function documentedEnv() {
|
|
88
|
+
if (!fs.existsSync(ENV_EXAMPLE)) return new Set();
|
|
89
|
+
return new Set(
|
|
90
|
+
fs
|
|
91
|
+
.readFileSync(ENV_EXAMPLE, "utf8")
|
|
92
|
+
.split("\n")
|
|
93
|
+
.map((line) => line.match(/^([A-Z0-9_]+)=/)?.[1])
|
|
94
|
+
.filter(Boolean),
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function sourceFilesIn(dir) {
|
|
99
|
+
const out = [];
|
|
100
|
+
const walk = (d) => {
|
|
101
|
+
if (!fs.existsSync(d)) return;
|
|
102
|
+
for (const entry of fs.readdirSync(d, { withFileTypes: true })) {
|
|
103
|
+
const full = path.join(d, entry.name);
|
|
104
|
+
if (entry.isDirectory()) {
|
|
105
|
+
if (entry.name === "__tests" || entry.name === "node_modules") continue;
|
|
106
|
+
walk(full);
|
|
107
|
+
} else if (/\.tsx?$/.test(entry.name) && entry.name !== DESCRIPTOR) {
|
|
108
|
+
out.push(full);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
walk(path.join(dir, "src"));
|
|
113
|
+
return out;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Resolve one seam.
|
|
118
|
+
*
|
|
119
|
+
* `path/to/file.ts` must exist. `path/to/file.ts#SYMBOL` must exist AND contain
|
|
120
|
+
* that identifier — deliberately "appears in the file" rather than "is
|
|
121
|
+
* exported", because the seams that matter most are not always exports:
|
|
122
|
+
* `FOOTER_NAVIGATION_LINKS` is a local inside a component body, and it is still
|
|
123
|
+
* exactly where the answer lands.
|
|
124
|
+
*/
|
|
125
|
+
function seamProblem(seam) {
|
|
126
|
+
const [file, symbol] = seam.split("#");
|
|
127
|
+
if (!fs.existsSync(file)) {
|
|
128
|
+
return `expected a file at ${file}, found nothing`;
|
|
129
|
+
}
|
|
130
|
+
if (!symbol) return null;
|
|
131
|
+
if (fs.statSync(file).isDirectory()) {
|
|
132
|
+
return `expected ${file} to be a file so "${symbol}" could be looked up in it, found a directory`;
|
|
133
|
+
}
|
|
134
|
+
const source = fs.readFileSync(file, "utf8");
|
|
135
|
+
if (!source.includes(symbol)) {
|
|
136
|
+
return `expected "${symbol}" in ${file}, found no mention of it`;
|
|
137
|
+
}
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Mutually dependent clusters in the derived `requires` graph, via Tarjan.
|
|
143
|
+
*
|
|
144
|
+
* Components rather than cycles, and the difference is not cosmetic: walking
|
|
145
|
+
* every distinct cycle in this graph enumerates hundreds of permutations of the
|
|
146
|
+
* same handful of modules and buries the finding in its own output. "These five
|
|
147
|
+
* modules cannot be ordered relative to each other" is the fact worth having,
|
|
148
|
+
* and there is one of those per cluster.
|
|
149
|
+
*/
|
|
150
|
+
function findCycles(modules) {
|
|
151
|
+
const edges = new Map(modules.map((m) => [m.id, m.requires ?? []]));
|
|
152
|
+
const index = new Map();
|
|
153
|
+
const low = new Map();
|
|
154
|
+
const onStack = new Set();
|
|
155
|
+
const stack = [];
|
|
156
|
+
const components = [];
|
|
157
|
+
let next = 0;
|
|
158
|
+
|
|
159
|
+
const strongConnect = (id) => {
|
|
160
|
+
index.set(id, next);
|
|
161
|
+
low.set(id, next);
|
|
162
|
+
next += 1;
|
|
163
|
+
stack.push(id);
|
|
164
|
+
onStack.add(id);
|
|
165
|
+
|
|
166
|
+
for (const to of edges.get(id) ?? []) {
|
|
167
|
+
if (!edges.has(to)) continue;
|
|
168
|
+
if (!index.has(to)) {
|
|
169
|
+
strongConnect(to);
|
|
170
|
+
low.set(id, Math.min(low.get(id), low.get(to)));
|
|
171
|
+
} else if (onStack.has(to)) {
|
|
172
|
+
low.set(id, Math.min(low.get(id), index.get(to)));
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (low.get(id) !== index.get(id)) return;
|
|
177
|
+
const component = [];
|
|
178
|
+
let member;
|
|
179
|
+
do {
|
|
180
|
+
member = stack.pop();
|
|
181
|
+
onStack.delete(member);
|
|
182
|
+
component.push(member);
|
|
183
|
+
} while (member !== id);
|
|
184
|
+
|
|
185
|
+
const selfReferential = (edges.get(id) ?? []).includes(id);
|
|
186
|
+
if (component.length > 1 || selfReferential)
|
|
187
|
+
components.push(component.sort());
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
for (const m of modules) if (!index.has(m.id)) strongConnect(m.id);
|
|
191
|
+
return components.sort(
|
|
192
|
+
(a, b) => b.length - a.length || a[0].localeCompare(b[0]),
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// ---------------------------------------------------------------------------
|
|
197
|
+
// Checks
|
|
198
|
+
// ---------------------------------------------------------------------------
|
|
199
|
+
|
|
200
|
+
function checkCoverage(modules, problems) {
|
|
201
|
+
const described = new Set(modules.map((m) => m.id));
|
|
202
|
+
for (const { root, kind } of REQUIRED_ROOTS) {
|
|
203
|
+
for (const name of directoriesIn(root)) {
|
|
204
|
+
if (described.has(name)) continue;
|
|
205
|
+
problems.push({
|
|
206
|
+
category: "module_missing",
|
|
207
|
+
message: `expected ${root}/${name}/${DESCRIPTOR} to exist, found nothing. Without a descriptor this module is invisible to a planner, and every other check on it silently does not run.`,
|
|
208
|
+
suggestion: `Add ${root}/${name}/${DESCRIPTOR} declaring at least id, kind and purpose (kind: "${kind}").`,
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function checkIdentity(mod, problems) {
|
|
215
|
+
const where = `${mod.path}/${DESCRIPTOR}`;
|
|
216
|
+
|
|
217
|
+
if (!mod.path?.endsWith(`/${mod.id}`)) {
|
|
218
|
+
problems.push({
|
|
219
|
+
category: "module_identity",
|
|
220
|
+
message: `${where}: expected id to match the directory it sits in, found id "${mod.id}" at ${mod.path}.`,
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const roots = [...REQUIRED_ROOTS, ...OPTIONAL_ROOTS];
|
|
225
|
+
const expected = roots.find((r) => mod.path?.startsWith(`${r.root}/`))?.kind;
|
|
226
|
+
if (expected && mod.kind !== expected && mod.kind !== "generated") {
|
|
227
|
+
problems.push({
|
|
228
|
+
category: "module_identity",
|
|
229
|
+
message: `${where}: expected kind "${expected}" for a module under ${mod.path}, found "${mod.kind}". A planner prices core, extension and generated very differently.`,
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function checkShape(mod, MODULE_KINDS, problems) {
|
|
235
|
+
const where = `${mod.path}/${DESCRIPTOR}`;
|
|
236
|
+
const bad = (message) => problems.push({ category: "module_shape", message });
|
|
237
|
+
|
|
238
|
+
if (!MODULE_KINDS.includes(mod.kind)) {
|
|
239
|
+
bad(
|
|
240
|
+
`${where}: expected kind to be one of ${MODULE_KINDS.join(" | ")}, found ${JSON.stringify(mod.kind)}.`,
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
if (typeof mod.purpose !== "string" || mod.purpose.trim() === "") {
|
|
244
|
+
bad(
|
|
245
|
+
`${where}: expected a purpose a buyer would recognise, found ${JSON.stringify(mod.purpose)}. It is the field a planner leans on hardest and the only one nothing else can verify.`,
|
|
246
|
+
);
|
|
247
|
+
} else if (mod.purpose.length > PURPOSE_MAX) {
|
|
248
|
+
bad(
|
|
249
|
+
`${where}: expected purpose under ${PURPOSE_MAX} characters, found ${mod.purpose.length}. A planner reads every one of these; a paragraph here is a paragraph in every plan.`,
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
for (const field of ["env", "decisions", "inputs", "defaults"]) {
|
|
253
|
+
if (!Array.isArray(mod[field])) {
|
|
254
|
+
bad(
|
|
255
|
+
`${where}: expected ${field} to be an array, found ${JSON.stringify(mod[field])}. Declare it empty rather than omitting it — an empty list is an answer, a missing one is a gap.`,
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function checkQuestions(mod, APPLIED_BY, seenIds, problems) {
|
|
262
|
+
const where = `${mod.path}/${DESCRIPTOR}`;
|
|
263
|
+
const bad = (message, suggestion) =>
|
|
264
|
+
problems.push({ category: "module_question_shape", message, suggestion });
|
|
265
|
+
|
|
266
|
+
const claimId = (id, kind) => {
|
|
267
|
+
if (typeof id !== "string" || !SNAKE_CASE.test(id)) {
|
|
268
|
+
bad(
|
|
269
|
+
`${where}: expected a snake_case id on a ${kind}, found ${JSON.stringify(id)}.`,
|
|
270
|
+
);
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
if (seenIds.has(id)) {
|
|
274
|
+
bad(
|
|
275
|
+
`${where}: expected id "${id}" to be unique across every module, found it already used by ${seenIds.get(id)}. A planner keys the buyer's answers by this id, so a duplicate silently overwrites one of them.`,
|
|
276
|
+
);
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
seenIds.set(id, where);
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
for (const d of mod.decisions ?? []) {
|
|
283
|
+
claimId(d.id, "decision");
|
|
284
|
+
if (!APPLIED_BY.includes(d.applied_by)) {
|
|
285
|
+
bad(
|
|
286
|
+
`${where}: decision "${d.id}" expected applied_by to be one of ${APPLIED_BY.join(" | ")}, found ${JSON.stringify(d.applied_by)}. It is the planner's only cost signal.`,
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
const options = d.options ?? [];
|
|
290
|
+
if (options.length < 2) {
|
|
291
|
+
bad(
|
|
292
|
+
`${where}: decision "${d.id}" expected at least 2 options, found ${options.length}. A question with one answer is a default — move it to defaults[].`,
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
const values = options.map((o) => o.value);
|
|
296
|
+
if (new Set(values).size !== values.length) {
|
|
297
|
+
bad(
|
|
298
|
+
`${where}: decision "${d.id}" expected distinct option values, found duplicates in [${values.join(", ")}].`,
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
for (const o of options) {
|
|
302
|
+
if (typeof o.means !== "string" || o.means.trim() === "") {
|
|
303
|
+
bad(
|
|
304
|
+
`${where}: decision "${d.id}" option "${o.value}" expected a plain-language "means", found nothing. A buyer cannot pick between values they have no description of.`,
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
for (const field of ["ask", "because"]) {
|
|
309
|
+
if (typeof d[field] !== "string" || d[field].trim() === "") {
|
|
310
|
+
bad(`${where}: decision "${d.id}" expected a ${field}, found nothing.`);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
for (const i of mod.inputs ?? []) {
|
|
316
|
+
claimId(i.id, "input");
|
|
317
|
+
for (const field of ["ask", "because", "example"]) {
|
|
318
|
+
if (typeof i[field] !== "string" || i[field].trim() === "") {
|
|
319
|
+
bad(`${where}: input "${i.id}" expected a ${field}, found nothing.`);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
if (typeof i.required !== "boolean") {
|
|
323
|
+
bad(
|
|
324
|
+
`${where}: input "${i.id}" expected required to be a boolean, found ${JSON.stringify(i.required)}. It is how a planner knows whether it can start without an answer.`,
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
for (const f of mod.defaults ?? []) {
|
|
330
|
+
claimId(f.id, "default");
|
|
331
|
+
if (typeof f.is !== "string" || f.is.trim() === "") {
|
|
332
|
+
bad(
|
|
333
|
+
`${where}: default "${f.id}" expected an "is" describing what the template does, found nothing.`,
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function checkSeams(mod, problems) {
|
|
340
|
+
const where = `${mod.path}/${DESCRIPTOR}`;
|
|
341
|
+
const seams = [
|
|
342
|
+
...(mod.decisions ?? []).flatMap((d) =>
|
|
343
|
+
(d.applies ?? []).map((s) => [`decision "${d.id}"`, s]),
|
|
344
|
+
),
|
|
345
|
+
...(mod.inputs ?? []).flatMap((i) =>
|
|
346
|
+
(i.applies ?? []).map((s) => [`input "${i.id}"`, s]),
|
|
347
|
+
),
|
|
348
|
+
...(mod.defaults ?? [])
|
|
349
|
+
.filter((f) => f.where)
|
|
350
|
+
.map((f) => [`default "${f.id}"`, f.where]),
|
|
351
|
+
];
|
|
352
|
+
|
|
353
|
+
for (const [owner, seam] of seams) {
|
|
354
|
+
const problem = seamProblem(seam);
|
|
355
|
+
if (!problem) continue;
|
|
356
|
+
problems.push({
|
|
357
|
+
category: "module_seam_missing",
|
|
358
|
+
message: `${where}: ${owner} says its answer lands at ${seam}, but ${problem}.`,
|
|
359
|
+
suggestion:
|
|
360
|
+
"Either the file moved and the seam needs updating, or the answer no longer lands there. A seam nobody checks sends the agent applying the answer to the wrong place, and it will not notice.",
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function checkEnv(mod, documented, problems) {
|
|
366
|
+
const where = `${mod.path}/${DESCRIPTOR}`;
|
|
367
|
+
const declared = new Set(mod.env ?? []);
|
|
368
|
+
|
|
369
|
+
for (const key of declared) {
|
|
370
|
+
if (documented.has(key)) continue;
|
|
371
|
+
problems.push({
|
|
372
|
+
category: "module_env_undocumented",
|
|
373
|
+
message: `${where}: expected ${key} in ${ENV_EXAMPLE}, found nothing. Somebody installing this module has no way to learn it is needed until it fails at runtime.`,
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const read = new Set();
|
|
378
|
+
for (const file of sourceFilesIn(mod.path)) {
|
|
379
|
+
const source = fs.readFileSync(file, "utf8");
|
|
380
|
+
ENV_READ_PATTERN.lastIndex = 0;
|
|
381
|
+
let m;
|
|
382
|
+
while ((m = ENV_READ_PATTERN.exec(source)) !== null) {
|
|
383
|
+
if (!ENV_IGNORED.has(m[1])) read.add(m[1]);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
for (const key of [...read].sort()) {
|
|
387
|
+
if (declared.has(key)) continue;
|
|
388
|
+
problems.push({
|
|
389
|
+
category: "module_env_undeclared",
|
|
390
|
+
message: `${where}: expected ${key} to be declared in env, found it read in this module's source but not declared. This is the direction nobody checks: the module works on the machine that already has the variable set, and fails on every other one.`,
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// ---------------------------------------------------------------------------
|
|
396
|
+
// Main
|
|
397
|
+
// ---------------------------------------------------------------------------
|
|
398
|
+
|
|
399
|
+
async function main() {
|
|
400
|
+
const { APPLIED_BY, MODULE_KINDS } = await import("../../dist/shared.js");
|
|
401
|
+
const args = process.argv.slice(2);
|
|
402
|
+
const asJson = args.includes("--json");
|
|
403
|
+
const unknown = args.filter((a) => !["--json"].includes(a));
|
|
404
|
+
if (unknown.length > 0) {
|
|
405
|
+
console.error(`[proof:modules:check] unknown flag: ${unknown.join(", ")}`);
|
|
406
|
+
process.exit(2);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
if (!fs.existsSync(MODULES_PATH)) {
|
|
410
|
+
console.error(
|
|
411
|
+
`[PROOF_FAIL] module_missing: expected ${MODULES_PATH}, found nothing.\n` +
|
|
412
|
+
" suggestion: Run `pnpm proof:modules` first — it is a derived artifact and is not committed.\n",
|
|
413
|
+
);
|
|
414
|
+
process.exit(1);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
let artifact;
|
|
418
|
+
try {
|
|
419
|
+
artifact = JSON.parse(fs.readFileSync(MODULES_PATH, "utf8"));
|
|
420
|
+
} catch (err) {
|
|
421
|
+
console.error(
|
|
422
|
+
`[proof:modules:check] ${MODULES_PATH} is not valid JSON: ${err.message}`,
|
|
423
|
+
);
|
|
424
|
+
process.exit(2);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
const modules = artifact.modules ?? [];
|
|
428
|
+
const documented = documentedEnv();
|
|
429
|
+
const seenIds = new Map();
|
|
430
|
+
const problems = [];
|
|
431
|
+
|
|
432
|
+
checkCoverage(modules, problems);
|
|
433
|
+
for (const mod of modules) {
|
|
434
|
+
checkIdentity(mod, problems);
|
|
435
|
+
checkShape(mod, MODULE_KINDS, problems);
|
|
436
|
+
checkQuestions(mod, APPLIED_BY, seenIds, problems);
|
|
437
|
+
checkSeams(mod, problems);
|
|
438
|
+
checkEnv(mod, documented, problems);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
const cycles = findCycles(modules);
|
|
442
|
+
|
|
443
|
+
if (asJson) {
|
|
444
|
+
console.log(
|
|
445
|
+
JSON.stringify({ modules: modules.length, problems, cycles }, null, 2),
|
|
446
|
+
);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
if (problems.length > 0) {
|
|
450
|
+
if (!asJson) {
|
|
451
|
+
console.error(
|
|
452
|
+
`\n[proof:modules:check] ${problems.length} claim(s) the code does not support.\n`,
|
|
453
|
+
);
|
|
454
|
+
for (const p of problems) {
|
|
455
|
+
console.error(`[PROOF_FAIL] ${p.category}: ${p.message}`);
|
|
456
|
+
if (p.suggestion) console.error(` suggestion: ${p.suggestion}`);
|
|
457
|
+
console.error("");
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
process.exit(1);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
if (asJson) return;
|
|
464
|
+
|
|
465
|
+
const decisions = modules.reduce((n, m) => n + (m.decisions?.length ?? 0), 0);
|
|
466
|
+
const inputs = modules.reduce((n, m) => n + (m.inputs?.length ?? 0), 0);
|
|
467
|
+
const defaults = modules.reduce((n, m) => n + (m.defaults?.length ?? 0), 0);
|
|
468
|
+
|
|
469
|
+
// Reported, never failed, while the inversion is fixed elsewhere. Printed on
|
|
470
|
+
// stdout and without the failure token, so nothing greps this as a break.
|
|
471
|
+
for (const cluster of cycles) {
|
|
472
|
+
console.log(
|
|
473
|
+
`[proof:modules:check] note: ${cluster.length} modules depend on each other ` +
|
|
474
|
+
`and cannot be ordered relative to one another — ${cluster.join(", ")}.`,
|
|
475
|
+
);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
console.log(
|
|
479
|
+
`[proof:modules:check] ${modules.length} module descriptor(s) verified against source: ` +
|
|
480
|
+
`${decisions} decision(s), ${inputs} input(s), ${defaults} default(s), ` +
|
|
481
|
+
`${artifact.unowned_tables?.length ?? 0} unowned table(s).`,
|
|
482
|
+
);
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
const invokedDirectly =
|
|
486
|
+
process.argv[1] !== undefined &&
|
|
487
|
+
import.meta.url === pathToFileURL(process.argv[1]).href;
|
|
488
|
+
|
|
489
|
+
if (invokedDirectly) {
|
|
490
|
+
main().catch((err) => {
|
|
491
|
+
console.error(
|
|
492
|
+
`[proof:modules:check] unexpected error: ${err?.stack ?? err}`,
|
|
493
|
+
);
|
|
494
|
+
process.exit(2);
|
|
495
|
+
});
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
export { main, seamProblem, findCycles };
|