@ecoma-io/archkeep 0.13.0 → 0.14.0

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.
@@ -0,0 +1,517 @@
1
+ /**
2
+ * The evidence snapshot: what a future `delta <base.json>` run consumes as its
3
+ * baseline.
4
+ *
5
+ * The snapshot stores EVIDENCE, not verdicts. A baseline that stored "which
6
+ * violations existed at base" would be judged under the law and the clock of
7
+ * the moment it was captured, so a policy edit or a waiver expiring between
8
+ * base and head would fabricate classifications: a violation introduced by a
9
+ * policy tightening would read as "introduced by the code", and one whose
10
+ * waiver lapsed would read as "resolved" when it is live again. So the
11
+ * snapshot carries the raw import-site records (`../analysis/contract.md`),
12
+ * the graph they were collected against, and the coverage facts that say how
13
+ * complete the look was — everything `../rules/index.mjs`'s
14
+ * `evaluate(sites, graph, config)` needs to re-judge the base under the CURRENT
15
+ * config at delta time. Both sides are then judged under ONE law and ONE
16
+ * shared reference instant, and only the code can move a classification.
17
+ *
18
+ * The format has its own `schemaVersion`, independent of the report envelope's:
19
+ * this file is read back by `parseEvidenceSnapshot` alone, never by the report
20
+ * renderers, and the two formats will evolve on different clocks.
21
+ *
22
+ * ## Purity seam
23
+ *
24
+ * Everything decidable is pure: `buildEvidenceSnapshot` takes already-resolved
25
+ * records as arguments, `serializeEvidenceSnapshot` takes the snapshot object,
26
+ * and `parseEvidenceSnapshot` takes text. Only `readEvidenceSnapshot` touches
27
+ * the filesystem, and its read function is injectable — the same separation
28
+ * `./history.mjs` draws between `readSnapshots` and `computeEvolution`
29
+ * (`../../../../AGENTS.md`: gate logic takes its facts as arguments).
30
+ */
31
+ import { readFileSync } from "node:fs";
32
+
33
+ import { buildDependencies, buildProjects } from "./graph.mjs";
34
+
35
+ /** The only snapshot schemaVersion this module writes and reads. */
36
+ export const EVIDENCE_SNAPSHOT_SCHEMA_VERSION = 1;
37
+
38
+ /**
39
+ * Builds the snapshot object from already-captured evidence.
40
+ *
41
+ * Every argument is a fact the caller already resolved — provenance from
42
+ * `./provenance.mjs`'s `resolveProvenance`, the fingerprint from
43
+ * `./graph.mjs`'s `computePolicyFingerprint`, the graph in Nx's shape, and the
44
+ * analysis envelope's `imports` array. Nothing here reads a file, spawns a
45
+ * process, or consults a clock, so a test drives capture without mocks.
46
+ *
47
+ * The graph is normalized through `buildProjects`/`buildDependencies` — the
48
+ * exact functions the `graph` command serializes with — so a snapshot's graph
49
+ * section cannot drift from what `graph --format json` publishes for the same
50
+ * tree. Three rule-relevant fields `buildProjects` strips for the public graph
51
+ * contract are re-attached here when the node declares them
52
+ * (`mfeRemote`, `entryPoints`, `declaredPackages`): `evaluate()` reads each,
53
+ * and a re-judge against a project row missing one would fabricate or silence
54
+ * verdicts — `declaredPackages` absent reads as "depends directly on nothing",
55
+ * which turns transitive-dependency violations on and off by omission. This
56
+ * snapshot exists to be re-judged from; it stores what judging reads.
57
+ *
58
+ * @param {object} input
59
+ * @param {{name: string, version: string}} input.tool The tool that captured
60
+ * the snapshot, named so a reader can tell which engine produced the bytes.
61
+ * @param {{commit: string, remote: string|null, dirty: boolean}|null}
62
+ * input.provenance From `resolveProvenance`; `null` is carried as an explicit
63
+ * "no origin claim" rather than dropped.
64
+ * @param {string} input.provider The project-model provider that built the
65
+ * graph ("nx", "native", "moon").
66
+ * @param {string} input.policyFingerprint From `computePolicyFingerprint`.
67
+ * @param {{complete: boolean, analyzedFiles: number, notAnalyzed: object[],
68
+ * blindSpots: object[]}} input.coverage The coverage summary, partitioned
69
+ * exactly as `./graph.mjs` partitions analysis failures.
70
+ * @param {{nodes: object, dependencies: object, workspaceLayout?: object,
71
+ * exemptedFiles?: string[]}} input.graph The project graph in Nx's shape.
72
+ * @param {object[]} input.records The raw import-site records — the analysis
73
+ * envelope's `imports` array verbatim (`../analysis/contract.md`), including
74
+ * the `resolved: null` rows.
75
+ * @returns {object} The snapshot, ready for `serializeEvidenceSnapshot`.
76
+ * @throws {Error} naming the first piece of required structure that is missing
77
+ * or malformed — a snapshot built over half-specified evidence would fail
78
+ * later anyway, and farther from the cause.
79
+ */
80
+ export function buildEvidenceSnapshot({
81
+ tool,
82
+ provenance,
83
+ provider,
84
+ policyFingerprint,
85
+ coverage,
86
+ graph,
87
+ records,
88
+ }) {
89
+ if (!tool || typeof tool.name !== "string" || tool.name === "") {
90
+ throw new Error(
91
+ "archkeep: cannot build an evidence snapshot without a tool name — the snapshot must " +
92
+ "name the engine that captured it",
93
+ );
94
+ }
95
+ if (typeof tool.version !== "string" || tool.version === "") {
96
+ throw new Error(
97
+ "archkeep: cannot build an evidence snapshot without a tool version — a reader could not " +
98
+ "tell which engine revision produced the bytes",
99
+ );
100
+ }
101
+ if (typeof provider !== "string" || provider === "") {
102
+ throw new Error(
103
+ "archkeep: cannot build an evidence snapshot without a provider name — a delta run could " +
104
+ "not tell whether the baseline was read by the same project model it runs under",
105
+ );
106
+ }
107
+ if (typeof policyFingerprint !== "string" || policyFingerprint === "") {
108
+ throw new Error(
109
+ "archkeep: cannot build an evidence snapshot without a policy fingerprint — a delta run " +
110
+ "could not tell whether the boundary law changed between base and head",
111
+ );
112
+ }
113
+ const coverageProblems = describeCoverageProblems(coverage);
114
+ if (coverageProblems.length > 0) {
115
+ throw new Error(
116
+ "archkeep: cannot build an evidence snapshot — the coverage summary is malformed:\n " +
117
+ coverageProblems.join("\n "),
118
+ );
119
+ }
120
+ if (!graph || typeof graph !== "object" || typeof graph.nodes !== "object" || !graph.nodes) {
121
+ throw new Error(
122
+ "archkeep: cannot build an evidence snapshot without a project graph with a `nodes` map",
123
+ );
124
+ }
125
+ if (
126
+ typeof graph.dependencies !== "object" ||
127
+ graph.dependencies === null ||
128
+ Array.isArray(graph.dependencies)
129
+ ) {
130
+ throw new Error(
131
+ "archkeep: cannot build an evidence snapshot without the graph's `dependencies` map",
132
+ );
133
+ }
134
+ if (!Array.isArray(records)) {
135
+ throw new Error(
136
+ "archkeep: cannot build an evidence snapshot without the raw analysis records as an array — " +
137
+ "the records are what a delta run re-judges, and without them a baseline is a verdict " +
138
+ "that cannot be re-checked under changed law",
139
+ );
140
+ }
141
+ for (const [index, record] of records.entries()) {
142
+ if (record === null || typeof record !== "object" || Array.isArray(record)) {
143
+ throw new Error(
144
+ `archkeep: analysis record ${index} is ${describe(record)}, not an import-site object — ` +
145
+ "every record must carry the shape src/analysis/contract.md fixes",
146
+ );
147
+ }
148
+ }
149
+
150
+ const projects = buildProjects(graph.nodes).map((project) => {
151
+ // Re-attach the three rule-relevant fields `buildProjects` strips for the
152
+ // public graph contract. Each is attached only when the node DECLARES it —
153
+ // an absent field stays absent, because `evaluate()` treats absence as
154
+ // "this workspace declares none here", and inventing an empty value would
155
+ // be a second copy of that answer.
156
+ const data = graph.nodes[project.name]?.data ?? {};
157
+ /** @type {Record<string, unknown>} */
158
+ const extras = {};
159
+ if (data.mfeRemote !== undefined) extras.mfeRemote = data.mfeRemote;
160
+ if (Array.isArray(data.entryPoints)) {
161
+ extras.entryPoints = data.entryPoints.slice().sort(cmpString);
162
+ }
163
+ if (Array.isArray(data.declaredPackages)) {
164
+ extras.declaredPackages = data.declaredPackages.slice().sort(cmpString);
165
+ }
166
+ return { ...project, ...extras };
167
+ });
168
+
169
+ /** @type {Record<string, unknown>} */
170
+ const storedGraph = { projects, dependencies: buildDependencies(graph.dependencies) };
171
+ if (
172
+ graph.workspaceLayout !== undefined &&
173
+ graph.workspaceLayout !== null &&
174
+ typeof graph.workspaceLayout === "object"
175
+ ) {
176
+ storedGraph.workspaceLayout = graph.workspaceLayout;
177
+ }
178
+ if (Array.isArray(graph.exemptedFiles)) {
179
+ storedGraph.exemptedFiles = graph.exemptedFiles.slice().sort(cmpString);
180
+ }
181
+
182
+ return {
183
+ schemaVersion: EVIDENCE_SNAPSHOT_SCHEMA_VERSION,
184
+ tool: { name: tool.name, version: tool.version },
185
+ provider,
186
+ provenance,
187
+ policyFingerprint,
188
+ coverage: {
189
+ complete: coverage.complete,
190
+ analyzedFiles: coverage.analyzedFiles,
191
+ notAnalyzed: coverage.notAnalyzed,
192
+ blindSpots: coverage.blindSpots,
193
+ },
194
+ graph: storedGraph,
195
+ records,
196
+ };
197
+ }
198
+
199
+ /**
200
+ * Renders the snapshot as deterministic JSON text.
201
+ *
202
+ * Deterministic because `buildEvidenceSnapshot` constructs every key in a
203
+ * fixed order and sorts every array whose source does not guarantee order;
204
+ * two captures over one unchanged tree produce byte-identical files, which is
205
+ * what makes a plain `diff` of two baselines meaningful.
206
+ *
207
+ * @param {object} snapshot From `buildEvidenceSnapshot`.
208
+ * @returns {string} The JSON text, newline-terminated.
209
+ */
210
+ export function serializeEvidenceSnapshot(snapshot) {
211
+ return `${JSON.stringify(snapshot, null, 2)}\n`;
212
+ }
213
+
214
+ /**
215
+ * Reads snapshot text from a path — the module's one filesystem seam, kept
216
+ * thin and injectable so tests and embedders drive validation without disk.
217
+ *
218
+ * @param {string} path Absolute path to the snapshot file.
219
+ * @param {{read?: (path: string) => string}} [io] Injectable read; defaults to
220
+ * a UTF-8 `readFileSync`.
221
+ * @returns {object} Whatever `parseEvidenceSnapshot` returns for the text.
222
+ * @throws {Error} when the file cannot be read, naming the path and the cause,
223
+ * and whatever `parseEvidenceSnapshot` throws.
224
+ */
225
+ export function readEvidenceSnapshot(path, io = {}) {
226
+ const read = io.read ?? ((p) => readFileSync(p, "utf8"));
227
+ let text;
228
+ try {
229
+ text = read(path);
230
+ } catch (cause) {
231
+ throw new Error(
232
+ `archkeep: cannot read the evidence snapshot '${path}': ${cause?.message ?? cause}`,
233
+ { cause },
234
+ );
235
+ }
236
+ return parseEvidenceSnapshot(text, path);
237
+ }
238
+
239
+ /**
240
+ * Parses and validates snapshot text.
241
+ *
242
+ * Pure: text in, validated snapshot out. Every refusal names what is wrong —
243
+ * these become exit-3 ("could not complete") upstream, and a delta run that
244
+ * consumed a malformed baseline silently would classify against nothing while
245
+ * reporting a verdict.
246
+ *
247
+ * Refusals, each loud:
248
+ * - unreadable/malformed JSON — named with the path and the parse error;
249
+ * - a `schemaVersion` that is not the integer this format uses — a FUTURE
250
+ * version refuses too: a reader that half-understood a newer format would
251
+ * classify over evidence it misread;
252
+ * - any missing or malformed required section, all named together;
253
+ * - a baseline whose coverage is not complete. An incomplete base did not look
254
+ * everywhere, so a head-only violation could exist unseen at base — classing
255
+ * it "introduced" would fabricate a change the code may not contain. The
256
+ * refusal names how many files went unanalyzed.
257
+ *
258
+ * What is deliberately NOT a refusal: dirty base provenance. A baseline from
259
+ * an uncommitted tree is weaker evidence, not unreadable evidence — the parsed
260
+ * snapshot exposes `provenance.dirty` so the renderer can say so loudly, and
261
+ * classification itself proceeds.
262
+ *
263
+ * @param {string} text The file contents.
264
+ * @param {string} path The path the text came from, for error messages.
265
+ * @returns {object} The validated snapshot.
266
+ * @throws {Error} on every condition above.
267
+ */
268
+ export function parseEvidenceSnapshot(text, path) {
269
+ let parsed;
270
+ try {
271
+ parsed = JSON.parse(text);
272
+ } catch (cause) {
273
+ throw new Error(
274
+ `archkeep: the evidence snapshot '${path}' is not valid JSON: ${cause?.message ?? cause}`,
275
+ { cause },
276
+ );
277
+ }
278
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
279
+ throw new Error(
280
+ `archkeep: the evidence snapshot '${path}' must be a JSON object, got ${describe(parsed)}`,
281
+ );
282
+ }
283
+
284
+ const problems = [];
285
+ if (!Number.isInteger(parsed.schemaVersion)) {
286
+ problems.push(
287
+ `schemaVersion: must be the integer ${EVIDENCE_SNAPSHOT_SCHEMA_VERSION}, got ` +
288
+ `${describe(parsed.schemaVersion)}`,
289
+ );
290
+ } else if (parsed.schemaVersion !== EVIDENCE_SNAPSHOT_SCHEMA_VERSION) {
291
+ // Version 1 is the first version this format ever had, so a larger integer
292
+ // is a future format and a smaller one was never written by any release —
293
+ // the two refusals name different ways out.
294
+ const disposition =
295
+ parsed.schemaVersion > EVIDENCE_SNAPSHOT_SCHEMA_VERSION
296
+ ? "The file was written by a newer version of Archkeep; upgrade to read it."
297
+ : "No release of Archkeep ever wrote that version; re-capture the baseline.";
298
+ throw new Error(
299
+ `archkeep: the evidence snapshot '${path}' has schemaVersion ` +
300
+ `${parsed.schemaVersion}, which this tool does not understand — it understands ` +
301
+ `${EVIDENCE_SNAPSHOT_SCHEMA_VERSION}. ${disposition}`,
302
+ );
303
+ }
304
+
305
+ if (!isPlainObject(parsed.tool)) {
306
+ problems.push("tool: must be an object naming the capturing engine");
307
+ } else {
308
+ if (typeof parsed.tool.name !== "string" || parsed.tool.name === "") {
309
+ problems.push("tool.name: must be a non-empty string");
310
+ }
311
+ if (typeof parsed.tool.version !== "string" || parsed.tool.version === "") {
312
+ problems.push("tool.version: must be a non-empty string");
313
+ }
314
+ }
315
+
316
+ if (typeof parsed.provider !== "string" || parsed.provider === "") {
317
+ problems.push("provider: must be a non-empty string naming the project-model provider");
318
+ }
319
+
320
+ if (parsed.provenance !== null && !isPlainObject(parsed.provenance)) {
321
+ problems.push("provenance: must be an object ({commit, remote, dirty}) or null");
322
+ } else if (isPlainObject(parsed.provenance) && typeof parsed.provenance.commit !== "string") {
323
+ problems.push("provenance.commit: must be a string when provenance is present");
324
+ }
325
+
326
+ if (typeof parsed.policyFingerprint !== "string" || parsed.policyFingerprint === "") {
327
+ problems.push(
328
+ "policyFingerprint: must be a non-empty string — without it a delta run cannot tell " +
329
+ "whether the boundary law moved between base and head",
330
+ );
331
+ }
332
+
333
+ if (!isPlainObject(parsed.coverage)) {
334
+ problems.push(
335
+ "coverage: must be an object with the capture's coverage summary — a reader could not " +
336
+ "tell how complete the look behind the records was",
337
+ );
338
+ } else {
339
+ // Each shape problem already carries its own `coverage.`-prefixed name;
340
+ // the completeness reason is named first because it decides usability on
341
+ // its own.
342
+ const incomplete = incompleteBaselineCoverageReason(parsed.coverage);
343
+ if (incomplete) problems.push(incomplete);
344
+ problems.push(...describeCoverageProblems(parsed.coverage));
345
+ }
346
+
347
+ if (!isPlainObject(parsed.graph)) {
348
+ problems.push("graph: must be an object carrying projects and dependencies");
349
+ } else {
350
+ if (!Array.isArray(parsed.graph.projects)) {
351
+ problems.push("graph.projects: must be an array of project entries");
352
+ } else {
353
+ parsed.graph.projects.forEach((project, index) => {
354
+ if (!isPlainObject(project)) {
355
+ problems.push(`graph.projects[${index}]: must be an object`);
356
+ return;
357
+ }
358
+ if (typeof project.name !== "string" || project.name === "") {
359
+ problems.push(`graph.projects[${index}].name: must be a non-empty string`);
360
+ }
361
+ if (typeof project.root !== "string") {
362
+ problems.push(`graph.projects[${index}].root: must be a string`);
363
+ }
364
+ });
365
+ }
366
+ if (!Array.isArray(parsed.graph.dependencies)) {
367
+ problems.push("graph.dependencies: must be an array of edges");
368
+ } else {
369
+ parsed.graph.dependencies.forEach((edge, index) => {
370
+ if (!isPlainObject(edge)) {
371
+ problems.push(`graph.dependencies[${index}]: must be an object`);
372
+ return;
373
+ }
374
+ for (const field of ["source", "target", "type"]) {
375
+ if (typeof edge[field] !== "string") {
376
+ problems.push(`graph.dependencies[${index}].${field}: must be a string`);
377
+ }
378
+ }
379
+ });
380
+ }
381
+ }
382
+
383
+ if (!Array.isArray(parsed.records)) {
384
+ problems.push(
385
+ "records: must be an array of raw import-site records — without them the baseline holds " +
386
+ "nothing a delta run can re-judge under the current law",
387
+ );
388
+ } else {
389
+ parsed.records.forEach((record, index) => {
390
+ if (!isPlainObject(record)) {
391
+ problems.push(`records[${index}]: must be an object per ../analysis/contract.md`);
392
+ return;
393
+ }
394
+ if (typeof record.sourceFile !== "string" || record.sourceFile === "") {
395
+ problems.push(`records[${index}].sourceFile: must be a non-empty string`);
396
+ }
397
+ if (typeof record.specifier !== "string" || record.specifier === "") {
398
+ problems.push(`records[${index}].specifier: must be a non-empty string`);
399
+ }
400
+ });
401
+ }
402
+
403
+ if (problems.length > 0) {
404
+ throw new Error(
405
+ `archkeep: the evidence snapshot '${path}' is not a usable baseline:\n ` +
406
+ problems.join("\n "),
407
+ );
408
+ }
409
+ return parsed;
410
+ }
411
+
412
+ /**
413
+ * Whether the baseline's provider mismatches the current run's, as a reason
414
+ * string — `null` when they agree.
415
+ *
416
+ * Exported rather than folded into `parseEvidenceSnapshot` because the loader
417
+ * sees only the baseline: it cannot know what provider the current run uses.
418
+ * The check takes two records and decides, so the caller wires it where both
419
+ * sides are in hand.
420
+ *
421
+ * A mismatch means the two graphs were built by different project models, so
422
+ * structural differences may be provider artefacts rather than real changes —
423
+ * the same reasoning `./snapshot-meta.mjs` applies between graph snapshots.
424
+ *
425
+ * @param {string} baselineProvider What the snapshot recorded.
426
+ * @param {string} currentProvider What the current run resolved.
427
+ * @returns {string|null} The reason they conflict, or `null`.
428
+ */
429
+ export function providerMismatch(baselineProvider, currentProvider) {
430
+ if (baselineProvider === currentProvider) return null;
431
+ return (
432
+ `the baseline was captured under the '${baselineProvider}' provider but this run is using ` +
433
+ `'${currentProvider}' — the two project models may attribute the same tree to different ` +
434
+ `projects, so structural differences may be provider artefacts rather than real changes`
435
+ );
436
+ }
437
+
438
+ /** Non-empty plain-object guard used across validation. */
439
+ function isPlainObject(value) {
440
+ return value !== null && typeof value === "object" && !Array.isArray(value);
441
+ }
442
+
443
+ /** Describes a value for error messages without dumping it. */
444
+ function describe(value) {
445
+ if (value === null) return "null";
446
+ if (Array.isArray(value)) return "an array";
447
+ return typeof value;
448
+ }
449
+
450
+ /** Plain lexicographic comparison — never localeCompare (byte-determinism). */
451
+ function cmpString(a, b) {
452
+ return a < b ? -1 : a > b ? 1 : 0;
453
+ }
454
+
455
+ /**
456
+ * Coverage-shape problems, collected rather than thrown, so capture-time
457
+ * construction and load-time parsing share ONE statement of what coverage is —
458
+ * capture throws on the first, the parser folds every problem into its single
459
+ * refusal. Written once so the two cannot disagree about what coverage is.
460
+ *
461
+ * @param {object} coverage
462
+ * @returns {string[]} One entry per problem, empty when the shape is sound.
463
+ */
464
+ function describeCoverageProblems(coverage) {
465
+ const problems = [];
466
+ if (!isPlainObject(coverage)) {
467
+ return [
468
+ "coverage: must be an object with the capture's coverage summary — a reader could not " +
469
+ "tell how complete the look behind the records was",
470
+ ];
471
+ }
472
+ if (typeof coverage.complete !== "boolean") {
473
+ problems.push(
474
+ "coverage.complete: must be a boolean — reading an unstated completeness as either " +
475
+ "answer would claim something the capture never said",
476
+ );
477
+ }
478
+ if (typeof coverage.analyzedFiles !== "number" || !Number.isFinite(coverage.analyzedFiles)) {
479
+ problems.push("coverage.analyzedFiles: must be a finite number");
480
+ }
481
+ if (!Array.isArray(coverage.notAnalyzed) || !Array.isArray(coverage.blindSpots)) {
482
+ problems.push(
483
+ "coverage.notAnalyzed and coverage.blindSpots: both must be arrays — both are always " +
484
+ "arrays in the analysis envelope, and a consumer iterates them without checking",
485
+ );
486
+ }
487
+ return problems;
488
+ }
489
+
490
+ /**
491
+ * The reason a coverage summary cannot serve as a delta BASELINE, or `null`.
492
+ *
493
+ * Load-side only, deliberately: capture may honestly record an incomplete look
494
+ * (the evidence includes WHICH files went unanalyzed), but consuming one as a
495
+ * comparison base would fabricate classifications — a violation living in a
496
+ * file the base never looked at reads as newly introduced at head even if it
497
+ * predates the run. The refusal names how many files went unanalyzed.
498
+ *
499
+ * @param {object} coverage
500
+ * @returns {string|null}
501
+ */
502
+ function incompleteBaselineCoverageReason(coverage) {
503
+ if (
504
+ coverage &&
505
+ typeof coverage === "object" &&
506
+ !Array.isArray(coverage) &&
507
+ coverage.complete === false &&
508
+ Array.isArray(coverage.notAnalyzed)
509
+ ) {
510
+ return (
511
+ `coverage.complete: the baseline's coverage is not complete — ` +
512
+ `${coverage.notAnalyzed.length} file(s) could not be analyzed at capture time, so a ` +
513
+ `violation living there would be misread as newly introduced at head`
514
+ );
515
+ }
516
+ return null;
517
+ }