@ecoma-io/archkeep 0.13.0 → 0.15.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.
Files changed (37) hide show
  1. package/README.md +9 -3
  2. package/cli.mjs +599 -55
  3. package/commands.mjs +51 -0
  4. package/package.json +3 -1
  5. package/src/analysis/typescript.mjs +2 -1
  6. package/src/commands/README.md +70 -1
  7. package/src/commands/change-intent.mjs +461 -0
  8. package/src/commands/change.mjs +612 -0
  9. package/src/commands/check.mjs +84 -17
  10. package/src/commands/context.mjs +92 -16
  11. package/src/commands/coverage-acceptance.mjs +113 -0
  12. package/src/commands/custom-rules.mjs +286 -2
  13. package/src/commands/delta-classify.mjs +664 -0
  14. package/src/commands/delta-snapshot.mjs +672 -0
  15. package/src/commands/delta.mjs +606 -0
  16. package/src/commands/diff.mjs +41 -13
  17. package/src/commands/evolution.mjs +473 -0
  18. package/src/commands/explain.mjs +39 -0
  19. package/src/commands/history.mjs +130 -103
  20. package/src/commands/policy.mjs +93 -1
  21. package/src/commands/trajectory.mjs +437 -0
  22. package/src/commands/waivers.mjs +53 -3
  23. package/src/config.mjs +129 -11
  24. package/src/lsp/boundary-config.mjs +9 -4
  25. package/src/path-util.mjs +40 -0
  26. package/src/providers/native/model.mjs +17 -0
  27. package/src/report/change-text.mjs +148 -0
  28. package/src/report/delta-text.mjs +264 -0
  29. package/src/report/evolution-text.mjs +83 -0
  30. package/src/report/explain-text.mjs +27 -0
  31. package/src/report/history-text.mjs +4 -114
  32. package/src/report/sarif.mjs +280 -0
  33. package/src/report/snapshot-text.mjs +123 -0
  34. package/src/report/text.mjs +36 -0
  35. package/src/report/trajectory-text.mjs +143 -0
  36. package/src/report/waivers-text.mjs +35 -2
  37. package/src/tsconfig-paths.mjs +3 -2
@@ -0,0 +1,672 @@
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
+ * ## The two OPTIONAL blocks, and why the version stays 1
23
+ *
24
+ * `customRules` (the declared rows: name, artifact, sha256, params) and
25
+ * `owned` (the workspace's file→project ownership map) are stored only when
26
+ * the capturing policy declares `customRules` — a workspace that declares
27
+ * none produces byte-identical snapshots before and after this addition, and
28
+ * a reader of version 1 that predates the blocks ignores keys it never asks
29
+ * for. Both are evidence in the same sense the records are: the rows are what
30
+ * lets a compare run say whether the custom LAW moved between capture and
31
+ * head (digest or params drift), and `owned` is what lets the base-side
32
+ * evidence bundle attribute each stored record — ownership is the workspace
33
+ * layer's answer (`../workspace.mjs`) and cannot be re-derived from a graph
34
+ * that may have changed since. A baseline WITHOUT the blocks is still legal
35
+ * (an old capture, or one whose policy declared no rules); downstream every
36
+ * custom finding then classifies `unknown` with a re-capture reason rather
37
+ * than the blocks' absence reading as "no custom rules existed at base".
38
+ *
39
+ * ## Purity seam
40
+ *
41
+ * Everything decidable is pure: `buildEvidenceSnapshot` takes already-resolved
42
+ * records as arguments, `serializeEvidenceSnapshot` takes the snapshot object,
43
+ * and `parseEvidenceSnapshot` takes text. Only `readEvidenceSnapshot` touches
44
+ * the filesystem, and its read function is injectable — the same separation
45
+ * `./history.mjs` draws between `readSnapshots` and `computeEvolution`
46
+ * (`../../../../AGENTS.md`: gate logic takes its facts as arguments).
47
+ */
48
+ import { readFileSync } from "node:fs";
49
+
50
+ import { buildDependencies, buildProjects } from "./graph.mjs";
51
+
52
+ /** The only snapshot schemaVersion this module writes and reads. */
53
+ export const EVIDENCE_SNAPSHOT_SCHEMA_VERSION = 1;
54
+
55
+ /**
56
+ * Builds the snapshot object from already-captured evidence.
57
+ *
58
+ * Every argument is a fact the caller already resolved — provenance from
59
+ * `./provenance.mjs`'s `resolveProvenance`, the fingerprint from
60
+ * `./graph.mjs`'s `computePolicyFingerprint`, the graph in Nx's shape, and the
61
+ * analysis envelope's `imports` array. Nothing here reads a file, spawns a
62
+ * process, or consults a clock, so a test drives capture without mocks.
63
+ *
64
+ * The graph is normalized through `buildProjects`/`buildDependencies` — the
65
+ * exact functions the `graph` command serializes with — so a snapshot's graph
66
+ * section cannot drift from what `graph --format json` publishes for the same
67
+ * tree. Three rule-relevant fields `buildProjects` strips for the public graph
68
+ * contract are re-attached here when the node declares them
69
+ * (`mfeRemote`, `entryPoints`, `declaredPackages`): `evaluate()` reads each,
70
+ * and a re-judge against a project row missing one would fabricate or silence
71
+ * verdicts — `declaredPackages` absent reads as "depends directly on nothing",
72
+ * which turns transitive-dependency violations on and off by omission. This
73
+ * snapshot exists to be re-judged from; it stores what judging reads.
74
+ *
75
+ * @param {object} input
76
+ * @param {{name: string, version: string}} input.tool The tool that captured
77
+ * the snapshot, named so a reader can tell which engine produced the bytes.
78
+ * @param {{commit: string, remote: string|null, dirty: boolean}|null}
79
+ * input.provenance From `resolveProvenance`; `null` is carried as an explicit
80
+ * "no origin claim" rather than dropped.
81
+ * @param {string} input.provider The project-model provider that built the
82
+ * graph ("nx", "native", "moon").
83
+ * @param {string} input.policyFingerprint From `computePolicyFingerprint`.
84
+ * @param {{complete: boolean, analyzedFiles: number, notAnalyzed: object[],
85
+ * blindSpots: object[]}} input.coverage The coverage summary, partitioned
86
+ * exactly as `./graph.mjs` partitions analysis failures.
87
+ * @param {{nodes: object, dependencies: object, workspaceLayout?: object,
88
+ * exemptedFiles?: string[]}} input.graph The project graph in Nx's shape.
89
+ * @param {object[]} input.records The raw import-site records — the analysis
90
+ * envelope's `imports` array verbatim (`../analysis/contract.md`), including
91
+ * the `resolved: null` rows.
92
+ * @param {{name: string, artifact: string, sha256: string,
93
+ * params?: Record<string, any>}[]} [input.customRules] The declared
94
+ * custom-rule rows, when the capturing policy declares any — see the header's
95
+ * optional-blocks section. Omitted means "the capturing policy declared no
96
+ * custom rules", and the snapshot carries no key at all.
97
+ * @param {{file: string, project: string}[]} [input.owned] The ownership map,
98
+ * required exactly when `customRules` is given: the base-side evidence
99
+ * bundle cannot attribute a record without it. Stored sorted by file.
100
+ * @returns {object} The snapshot, ready for `serializeEvidenceSnapshot`.
101
+ * @throws {Error} naming the first piece of required structure that is missing
102
+ * or malformed — a snapshot built over half-specified evidence would fail
103
+ * later anyway, and farther from the cause.
104
+ */
105
+ export function buildEvidenceSnapshot({
106
+ tool,
107
+ provenance,
108
+ provider,
109
+ policyFingerprint,
110
+ coverage,
111
+ graph,
112
+ records,
113
+ customRules,
114
+ owned,
115
+ }) {
116
+ if (!tool || typeof tool.name !== "string" || tool.name === "") {
117
+ throw new Error(
118
+ "archkeep: cannot build an evidence snapshot without a tool name — the snapshot must " +
119
+ "name the engine that captured it",
120
+ );
121
+ }
122
+ if (typeof tool.version !== "string" || tool.version === "") {
123
+ throw new Error(
124
+ "archkeep: cannot build an evidence snapshot without a tool version — a reader could not " +
125
+ "tell which engine revision produced the bytes",
126
+ );
127
+ }
128
+ if (typeof provider !== "string" || provider === "") {
129
+ throw new Error(
130
+ "archkeep: cannot build an evidence snapshot without a provider name — a delta run could " +
131
+ "not tell whether the baseline was read by the same project model it runs under",
132
+ );
133
+ }
134
+ if (typeof policyFingerprint !== "string" || policyFingerprint === "") {
135
+ throw new Error(
136
+ "archkeep: cannot build an evidence snapshot without a policy fingerprint — a delta run " +
137
+ "could not tell whether the boundary law changed between base and head",
138
+ );
139
+ }
140
+ const coverageProblems = describeCoverageProblems(coverage);
141
+ if (coverageProblems.length > 0) {
142
+ throw new Error(
143
+ "archkeep: cannot build an evidence snapshot — the coverage summary is malformed:\n " +
144
+ coverageProblems.join("\n "),
145
+ );
146
+ }
147
+ if (!graph || typeof graph !== "object" || typeof graph.nodes !== "object" || !graph.nodes) {
148
+ throw new Error(
149
+ "archkeep: cannot build an evidence snapshot without a project graph with a `nodes` map",
150
+ );
151
+ }
152
+ if (
153
+ typeof graph.dependencies !== "object" ||
154
+ graph.dependencies === null ||
155
+ Array.isArray(graph.dependencies)
156
+ ) {
157
+ throw new Error(
158
+ "archkeep: cannot build an evidence snapshot without the graph's `dependencies` map",
159
+ );
160
+ }
161
+ if (!Array.isArray(records)) {
162
+ throw new Error(
163
+ "archkeep: cannot build an evidence snapshot without the raw analysis records as an array — " +
164
+ "the records are what a delta run re-judges, and without them a baseline is a verdict " +
165
+ "that cannot be re-checked under changed law",
166
+ );
167
+ }
168
+ for (const [index, record] of records.entries()) {
169
+ if (record === null || typeof record !== "object" || Array.isArray(record)) {
170
+ throw new Error(
171
+ `archkeep: analysis record ${index} is ${describe(record)}, not an import-site object — ` +
172
+ "every record must carry the shape src/analysis/contract.md fixes",
173
+ );
174
+ }
175
+ }
176
+
177
+ if (customRules !== undefined) {
178
+ const customProblems = describeCustomRuleBlockProblems(customRules, owned);
179
+ if (customProblems.length > 0) {
180
+ throw new Error(
181
+ "archkeep: cannot build an evidence snapshot — the custom-rule evidence is malformed:\n " +
182
+ customProblems.join("\n "),
183
+ );
184
+ }
185
+ } else if (owned !== undefined) {
186
+ throw new Error(
187
+ "archkeep: cannot build an evidence snapshot with an `owned` map but no `customRules` " +
188
+ "rows — the map exists to attribute the base side of a custom-rule re-judgment, and " +
189
+ "storing it alone would claim custom-rule evidence the snapshot does not hold",
190
+ );
191
+ }
192
+
193
+ const projects = buildProjects(graph.nodes).map((project) => {
194
+ // Re-attach the three rule-relevant fields `buildProjects` strips for the
195
+ // public graph contract. Each is attached only when the node DECLARES it —
196
+ // an absent field stays absent, because `evaluate()` treats absence as
197
+ // "this workspace declares none here", and inventing an empty value would
198
+ // be a second copy of that answer.
199
+ const data = graph.nodes[project.name]?.data ?? {};
200
+ /** @type {Record<string, unknown>} */
201
+ const extras = {};
202
+ if (data.mfeRemote !== undefined) extras.mfeRemote = data.mfeRemote;
203
+ if (Array.isArray(data.entryPoints)) {
204
+ extras.entryPoints = data.entryPoints.slice().sort(cmpString);
205
+ }
206
+ if (Array.isArray(data.declaredPackages)) {
207
+ extras.declaredPackages = data.declaredPackages.slice().sort(cmpString);
208
+ }
209
+ return { ...project, ...extras };
210
+ });
211
+
212
+ /** @type {Record<string, unknown>} */
213
+ const storedGraph = { projects, dependencies: buildDependencies(graph.dependencies) };
214
+ if (
215
+ graph.workspaceLayout !== undefined &&
216
+ graph.workspaceLayout !== null &&
217
+ typeof graph.workspaceLayout === "object"
218
+ ) {
219
+ storedGraph.workspaceLayout = graph.workspaceLayout;
220
+ }
221
+ if (Array.isArray(graph.exemptedFiles)) {
222
+ storedGraph.exemptedFiles = graph.exemptedFiles.slice().sort(cmpString);
223
+ }
224
+
225
+ /** @type {Record<string, unknown>} */
226
+ const snapshot = {
227
+ schemaVersion: EVIDENCE_SNAPSHOT_SCHEMA_VERSION,
228
+ tool: { name: tool.name, version: tool.version },
229
+ provider,
230
+ provenance,
231
+ policyFingerprint,
232
+ coverage: {
233
+ complete: coverage.complete,
234
+ analyzedFiles: coverage.analyzedFiles,
235
+ notAnalyzed: coverage.notAnalyzed,
236
+ blindSpots: coverage.blindSpots,
237
+ },
238
+ graph: storedGraph,
239
+ records,
240
+ };
241
+ if (customRules !== undefined) {
242
+ // The declared rows, each reduced to the four fields a compare run reads:
243
+ // identity (name), the pinned law (artifact + sha256), and the parameters
244
+ // that ride inside the evidence bundle — params drift is law drift, the
245
+ // same as digest drift. `reason` and the governance block stay out: they
246
+ // explain the row to a human and change no judgment.
247
+ snapshot.customRules = customRules.map((row) => ({
248
+ name: row.name,
249
+ artifact: row.artifact,
250
+ sha256: row.sha256,
251
+ ...(row.params === undefined ? {} : { params: row.params }),
252
+ }));
253
+ // Sorted by file for byte-determinism; `createWorkspace` derives the map
254
+ // from a Set walk whose order is an accident of the file listing.
255
+ snapshot.owned = /** @type {{file: string, project: string}[]} */ (owned)
256
+ .map(({ file, project }) => ({ file, project }))
257
+ .sort((a, b) => cmpString(a.file, b.file));
258
+ }
259
+ return snapshot;
260
+ }
261
+
262
+ /**
263
+ * Renders the snapshot as deterministic JSON text.
264
+ *
265
+ * Deterministic because `buildEvidenceSnapshot` constructs every key in a
266
+ * fixed order and sorts every array whose source does not guarantee order;
267
+ * two captures over one unchanged tree produce byte-identical files, which is
268
+ * what makes a plain `diff` of two baselines meaningful.
269
+ *
270
+ * @param {object} snapshot From `buildEvidenceSnapshot`.
271
+ * @returns {string} The JSON text, newline-terminated.
272
+ */
273
+ export function serializeEvidenceSnapshot(snapshot) {
274
+ return `${JSON.stringify(snapshot, null, 2)}\n`;
275
+ }
276
+
277
+ /**
278
+ * Reads snapshot text from a path — the module's one filesystem seam, kept
279
+ * thin and injectable so tests and embedders drive validation without disk.
280
+ *
281
+ * @param {string} path Absolute path to the snapshot file.
282
+ * @param {{read?: (path: string) => string}} [io] Injectable read; defaults to
283
+ * a UTF-8 `readFileSync`.
284
+ * @returns {object} Whatever `parseEvidenceSnapshot` returns for the text.
285
+ * @throws {Error} when the file cannot be read, naming the path and the cause,
286
+ * and whatever `parseEvidenceSnapshot` throws.
287
+ */
288
+ export function readEvidenceSnapshot(path, io = {}) {
289
+ const read = io.read ?? ((p) => readFileSync(p, "utf8"));
290
+ let text;
291
+ try {
292
+ text = read(path);
293
+ } catch (cause) {
294
+ throw new Error(
295
+ `archkeep: cannot read the evidence snapshot '${path}': ${cause?.message ?? cause}`,
296
+ { cause },
297
+ );
298
+ }
299
+ return parseEvidenceSnapshot(text, path);
300
+ }
301
+
302
+ /**
303
+ * Parses and validates snapshot text.
304
+ *
305
+ * Pure: text in, validated snapshot out. Every refusal names what is wrong —
306
+ * these become exit-3 ("could not complete") upstream, and a delta run that
307
+ * consumed a malformed baseline silently would classify against nothing while
308
+ * reporting a verdict.
309
+ *
310
+ * Refusals, each loud:
311
+ * - unreadable/malformed JSON — named with the path and the parse error;
312
+ * - a `schemaVersion` that is not the integer this format uses — a FUTURE
313
+ * version refuses too: a reader that half-understood a newer format would
314
+ * classify over evidence it misread;
315
+ * - any missing or malformed required section, all named together;
316
+ * - a baseline whose coverage is not complete. An incomplete base did not look
317
+ * everywhere, so a head-only violation could exist unseen at base — classing
318
+ * it "introduced" would fabricate a change the code may not contain. The
319
+ * refusal names how many files went unanalyzed.
320
+ *
321
+ * What is deliberately NOT a refusal: dirty base provenance. A baseline from
322
+ * an uncommitted tree is weaker evidence, not unreadable evidence — the parsed
323
+ * snapshot exposes `provenance.dirty` so the renderer can say so loudly, and
324
+ * classification itself proceeds.
325
+ *
326
+ * @param {string} text The file contents.
327
+ * @param {string} path The path the text came from, for error messages.
328
+ * @returns {object} The validated snapshot.
329
+ * @throws {Error} on every condition above.
330
+ */
331
+ export function parseEvidenceSnapshot(text, path) {
332
+ let parsed;
333
+ try {
334
+ parsed = JSON.parse(text);
335
+ } catch (cause) {
336
+ throw new Error(
337
+ `archkeep: the evidence snapshot '${path}' is not valid JSON: ${cause?.message ?? cause}`,
338
+ { cause },
339
+ );
340
+ }
341
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
342
+ throw new Error(
343
+ `archkeep: the evidence snapshot '${path}' must be a JSON object, got ${describe(parsed)}`,
344
+ );
345
+ }
346
+
347
+ const problems = [];
348
+ if (!Number.isInteger(parsed.schemaVersion)) {
349
+ problems.push(
350
+ `schemaVersion: must be the integer ${EVIDENCE_SNAPSHOT_SCHEMA_VERSION}, got ` +
351
+ `${describe(parsed.schemaVersion)}`,
352
+ );
353
+ } else if (parsed.schemaVersion !== EVIDENCE_SNAPSHOT_SCHEMA_VERSION) {
354
+ // Version 1 is the first version this format ever had, so a larger integer
355
+ // is a future format and a smaller one was never written by any release —
356
+ // the two refusals name different ways out.
357
+ const disposition =
358
+ parsed.schemaVersion > EVIDENCE_SNAPSHOT_SCHEMA_VERSION
359
+ ? "The file was written by a newer version of Archkeep; upgrade to read it."
360
+ : "No release of Archkeep ever wrote that version; re-capture the baseline.";
361
+ throw new Error(
362
+ `archkeep: the evidence snapshot '${path}' has schemaVersion ` +
363
+ `${parsed.schemaVersion}, which this tool does not understand — it understands ` +
364
+ `${EVIDENCE_SNAPSHOT_SCHEMA_VERSION}. ${disposition}`,
365
+ );
366
+ }
367
+
368
+ if (!isPlainObject(parsed.tool)) {
369
+ problems.push("tool: must be an object naming the capturing engine");
370
+ } else {
371
+ if (typeof parsed.tool.name !== "string" || parsed.tool.name === "") {
372
+ problems.push("tool.name: must be a non-empty string");
373
+ }
374
+ if (typeof parsed.tool.version !== "string" || parsed.tool.version === "") {
375
+ problems.push("tool.version: must be a non-empty string");
376
+ }
377
+ }
378
+
379
+ if (typeof parsed.provider !== "string" || parsed.provider === "") {
380
+ problems.push("provider: must be a non-empty string naming the project-model provider");
381
+ }
382
+
383
+ if (parsed.provenance !== null && !isPlainObject(parsed.provenance)) {
384
+ problems.push("provenance: must be an object ({commit, remote, dirty}) or null");
385
+ } else if (isPlainObject(parsed.provenance) && typeof parsed.provenance.commit !== "string") {
386
+ problems.push("provenance.commit: must be a string when provenance is present");
387
+ }
388
+
389
+ if (typeof parsed.policyFingerprint !== "string" || parsed.policyFingerprint === "") {
390
+ problems.push(
391
+ "policyFingerprint: must be a non-empty string — without it a delta run cannot tell " +
392
+ "whether the boundary law moved between base and head",
393
+ );
394
+ }
395
+
396
+ if (!isPlainObject(parsed.coverage)) {
397
+ problems.push(
398
+ "coverage: must be an object with the capture's coverage summary — a reader could not " +
399
+ "tell how complete the look behind the records was",
400
+ );
401
+ } else {
402
+ // Each shape problem already carries its own `coverage.`-prefixed name;
403
+ // the completeness reason is named first because it decides usability on
404
+ // its own.
405
+ const incomplete = incompleteBaselineCoverageReason(parsed.coverage);
406
+ if (incomplete) problems.push(incomplete);
407
+ problems.push(...describeCoverageProblems(parsed.coverage));
408
+ }
409
+
410
+ if (!isPlainObject(parsed.graph)) {
411
+ problems.push("graph: must be an object carrying projects and dependencies");
412
+ } else {
413
+ if (!Array.isArray(parsed.graph.projects)) {
414
+ problems.push("graph.projects: must be an array of project entries");
415
+ } else {
416
+ parsed.graph.projects.forEach((project, index) => {
417
+ if (!isPlainObject(project)) {
418
+ problems.push(`graph.projects[${index}]: must be an object`);
419
+ return;
420
+ }
421
+ if (typeof project.name !== "string" || project.name === "") {
422
+ problems.push(`graph.projects[${index}].name: must be a non-empty string`);
423
+ }
424
+ if (typeof project.root !== "string") {
425
+ problems.push(`graph.projects[${index}].root: must be a string`);
426
+ }
427
+ });
428
+ }
429
+ if (!Array.isArray(parsed.graph.dependencies)) {
430
+ problems.push("graph.dependencies: must be an array of edges");
431
+ } else {
432
+ parsed.graph.dependencies.forEach((edge, index) => {
433
+ if (!isPlainObject(edge)) {
434
+ problems.push(`graph.dependencies[${index}]: must be an object`);
435
+ return;
436
+ }
437
+ for (const field of ["source", "target", "type"]) {
438
+ if (typeof edge[field] !== "string") {
439
+ problems.push(`graph.dependencies[${index}].${field}: must be a string`);
440
+ }
441
+ }
442
+ });
443
+ }
444
+ }
445
+
446
+ if (!Array.isArray(parsed.records)) {
447
+ problems.push(
448
+ "records: must be an array of raw import-site records — without them the baseline holds " +
449
+ "nothing a delta run can re-judge under the current law",
450
+ );
451
+ } else {
452
+ parsed.records.forEach((record, index) => {
453
+ if (!isPlainObject(record)) {
454
+ problems.push(`records[${index}]: must be an object per ../analysis/contract.md`);
455
+ return;
456
+ }
457
+ if (typeof record.sourceFile !== "string" || record.sourceFile === "") {
458
+ problems.push(`records[${index}].sourceFile: must be a non-empty string`);
459
+ }
460
+ if (typeof record.specifier !== "string" || record.specifier === "") {
461
+ problems.push(`records[${index}].specifier: must be a non-empty string`);
462
+ }
463
+ });
464
+ }
465
+
466
+ // The optional custom-rule pair: absence is a legal old-or-undeclared
467
+ // baseline, presence must be sound — a half-readable block consumed
468
+ // silently would attribute base records against a map that is not one.
469
+ if (parsed.customRules !== undefined) {
470
+ problems.push(...describeCustomRuleBlockProblems(parsed.customRules, parsed.owned));
471
+ } else if (parsed.owned !== undefined) {
472
+ problems.push(
473
+ "owned: present without customRules — the map only exists as custom-rule evidence, and " +
474
+ "half the pair is a snapshot no release ever wrote",
475
+ );
476
+ }
477
+
478
+ if (problems.length > 0) {
479
+ throw new Error(
480
+ `archkeep: the evidence snapshot '${path}' is not a usable baseline:\n ` +
481
+ problems.join("\n "),
482
+ );
483
+ }
484
+ return parsed;
485
+ }
486
+
487
+ /**
488
+ * Whether the baseline's provider mismatches the current run's, as a reason
489
+ * string — `null` when they agree.
490
+ *
491
+ * Exported rather than folded into `parseEvidenceSnapshot` because the loader
492
+ * sees only the baseline: it cannot know what provider the current run uses.
493
+ * The check takes two records and decides, so the caller wires it where both
494
+ * sides are in hand.
495
+ *
496
+ * A mismatch means the two graphs were built by different project models, so
497
+ * structural differences may be provider artefacts rather than real changes —
498
+ * the same reasoning `./snapshot-meta.mjs` applies between graph snapshots.
499
+ *
500
+ * @param {string} baselineProvider What the snapshot recorded.
501
+ * @param {string} currentProvider What the current run resolved.
502
+ * @returns {string|null} The reason they conflict, or `null`.
503
+ */
504
+ export function providerMismatch(baselineProvider, currentProvider) {
505
+ if (baselineProvider === currentProvider) return null;
506
+ return (
507
+ `the baseline was captured under the '${baselineProvider}' provider but this run is using ` +
508
+ `'${currentProvider}' — the two project models may attribute the same tree to different ` +
509
+ `projects, so structural differences may be provider artefacts rather than real changes`
510
+ );
511
+ }
512
+
513
+ /** Non-empty plain-object guard used across validation. */
514
+ function isPlainObject(value) {
515
+ return value !== null && typeof value === "object" && !Array.isArray(value);
516
+ }
517
+
518
+ /** Describes a value for error messages without dumping it. */
519
+ function describe(value) {
520
+ if (value === null) return "null";
521
+ if (Array.isArray(value)) return "an array";
522
+ return typeof value;
523
+ }
524
+
525
+ /** Plain lexicographic comparison — never localeCompare (byte-determinism). */
526
+ function cmpString(a, b) {
527
+ return a < b ? -1 : a > b ? 1 : 0;
528
+ }
529
+
530
+ /**
531
+ * Coverage-shape problems, collected rather than thrown, so capture-time
532
+ * construction and load-time parsing share ONE statement of what coverage is —
533
+ * capture throws on the first, the parser folds every problem into its single
534
+ * refusal. Written once so the two cannot disagree about what coverage is.
535
+ *
536
+ * @param {object} coverage
537
+ * @returns {string[]} One entry per problem, empty when the shape is sound.
538
+ */
539
+ function describeCoverageProblems(coverage) {
540
+ const problems = [];
541
+ if (!isPlainObject(coverage)) {
542
+ return [
543
+ "coverage: must be an object with the capture's coverage summary — a reader could not " +
544
+ "tell how complete the look behind the records was",
545
+ ];
546
+ }
547
+ if (typeof coverage.complete !== "boolean") {
548
+ problems.push(
549
+ "coverage.complete: must be a boolean — reading an unstated completeness as either " +
550
+ "answer would claim something the capture never said",
551
+ );
552
+ }
553
+ if (typeof coverage.analyzedFiles !== "number" || !Number.isFinite(coverage.analyzedFiles)) {
554
+ problems.push("coverage.analyzedFiles: must be a finite number");
555
+ }
556
+ if (!Array.isArray(coverage.notAnalyzed) || !Array.isArray(coverage.blindSpots)) {
557
+ problems.push(
558
+ "coverage.notAnalyzed and coverage.blindSpots: both must be arrays — both are always " +
559
+ "arrays in the analysis envelope, and a consumer iterates them without checking",
560
+ );
561
+ }
562
+ return problems;
563
+ }
564
+
565
+ /**
566
+ * Everything wrong with the optional custom-rule evidence pair, as messages —
567
+ * shared by capture-time construction and load-time parsing for the same
568
+ * reason `describeCoverageProblems` is: one statement of what the blocks are.
569
+ *
570
+ * Called only when `customRules` is PRESENT: absence is legal (an old
571
+ * baseline, or a policy that declares none) and is judged by the caller. When
572
+ * the rows are present the `owned` map must be too — a base-side re-judgment
573
+ * without attribution would hand every rule evidence it must refuse, and the
574
+ * time to say so is when the snapshot is built or read, not per rule at
575
+ * compare time.
576
+ *
577
+ * @param {unknown} customRules The stored (or to-be-stored) rule rows.
578
+ * @param {unknown} owned The stored (or to-be-stored) ownership map.
579
+ * @returns {string[]} One entry per problem, empty when both are sound.
580
+ */
581
+ function describeCustomRuleBlockProblems(customRules, owned) {
582
+ const problems = [];
583
+ if (!Array.isArray(customRules)) {
584
+ problems.push(
585
+ `customRules: must be an array of declared rule rows when present, got ` +
586
+ `${describe(customRules)}`,
587
+ );
588
+ } else {
589
+ /** @type {Map<string, number>} */
590
+ const firstIndexOfName = new Map();
591
+ customRules.forEach((row, index) => {
592
+ if (!isPlainObject(row)) {
593
+ problems.push(`customRules[${index}]: must be an object, got ${describe(row)}`);
594
+ return;
595
+ }
596
+ for (const field of ["name", "artifact", "sha256"]) {
597
+ if (typeof row[field] !== "string" || row[field] === "") {
598
+ problems.push(`customRules[${index}].${field}: must be a non-empty string`);
599
+ }
600
+ }
601
+ // A duplicate name is a loud refusal, not a last-one-wins: the compare
602
+ // side keys stored rows by name (`./custom-rules.mjs`'s
603
+ // `customRulesForDelta` builds a Map over them), so a second row under
604
+ // one name would silently shadow the first — and which law the delta
605
+ // then matched against would be an accident of row order.
606
+ if (typeof row.name === "string" && row.name !== "") {
607
+ const first = firstIndexOfName.get(row.name);
608
+ if (first !== undefined) {
609
+ problems.push(
610
+ `customRules[${index}].name: duplicates customRules[${first}].name ("${row.name}") — ` +
611
+ `rule names are the identity a delta matches base rows by, and two rows under one ` +
612
+ `name would silently shadow each other`,
613
+ );
614
+ } else {
615
+ firstIndexOfName.set(row.name, index);
616
+ }
617
+ }
618
+ if (row.params !== undefined && !isPlainObject(row.params)) {
619
+ problems.push(`customRules[${index}].params: must be a plain object when present`);
620
+ }
621
+ });
622
+ }
623
+ if (!Array.isArray(owned)) {
624
+ problems.push(
625
+ `owned: must be an array of {file, project} rows whenever customRules is stored — the ` +
626
+ `base side of a custom-rule re-judgment cannot attribute a record without it, got ` +
627
+ `${describe(owned)}`,
628
+ );
629
+ } else {
630
+ owned.forEach((row, index) => {
631
+ if (!isPlainObject(row)) {
632
+ problems.push(`owned[${index}]: must be an object, got ${describe(row)}`);
633
+ return;
634
+ }
635
+ for (const field of ["file", "project"]) {
636
+ if (typeof row[field] !== "string" || row[field] === "") {
637
+ problems.push(`owned[${index}].${field}: must be a non-empty string`);
638
+ }
639
+ }
640
+ });
641
+ }
642
+ return problems;
643
+ }
644
+
645
+ /**
646
+ * The reason a coverage summary cannot serve as a delta BASELINE, or `null`.
647
+ *
648
+ * Load-side only, deliberately: capture may honestly record an incomplete look
649
+ * (the evidence includes WHICH files went unanalyzed), but consuming one as a
650
+ * comparison base would fabricate classifications — a violation living in a
651
+ * file the base never looked at reads as newly introduced at head even if it
652
+ * predates the run. The refusal names how many files went unanalyzed.
653
+ *
654
+ * @param {object} coverage
655
+ * @returns {string|null}
656
+ */
657
+ function incompleteBaselineCoverageReason(coverage) {
658
+ if (
659
+ coverage &&
660
+ typeof coverage === "object" &&
661
+ !Array.isArray(coverage) &&
662
+ coverage.complete === false &&
663
+ Array.isArray(coverage.notAnalyzed)
664
+ ) {
665
+ return (
666
+ `coverage.complete: the baseline's coverage is not complete — ` +
667
+ `${coverage.notAnalyzed.length} file(s) could not be analyzed at capture time, so a ` +
668
+ `violation living there would be misread as newly introduced at head`
669
+ );
670
+ }
671
+ return null;
672
+ }