@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.
- package/README.md +9 -3
- package/cli.mjs +599 -55
- package/commands.mjs +51 -0
- package/package.json +3 -1
- package/src/analysis/typescript.mjs +2 -1
- package/src/commands/README.md +70 -1
- package/src/commands/change-intent.mjs +461 -0
- package/src/commands/change.mjs +612 -0
- package/src/commands/check.mjs +84 -17
- package/src/commands/context.mjs +92 -16
- package/src/commands/coverage-acceptance.mjs +113 -0
- package/src/commands/custom-rules.mjs +286 -2
- package/src/commands/delta-classify.mjs +664 -0
- package/src/commands/delta-snapshot.mjs +672 -0
- package/src/commands/delta.mjs +606 -0
- package/src/commands/diff.mjs +41 -13
- package/src/commands/evolution.mjs +473 -0
- package/src/commands/explain.mjs +39 -0
- package/src/commands/history.mjs +130 -103
- package/src/commands/policy.mjs +93 -1
- package/src/commands/trajectory.mjs +437 -0
- package/src/commands/waivers.mjs +53 -3
- package/src/config.mjs +129 -11
- package/src/lsp/boundary-config.mjs +9 -4
- package/src/path-util.mjs +40 -0
- package/src/providers/native/model.mjs +17 -0
- package/src/report/change-text.mjs +148 -0
- package/src/report/delta-text.mjs +264 -0
- package/src/report/evolution-text.mjs +83 -0
- package/src/report/explain-text.mjs +27 -0
- package/src/report/history-text.mjs +4 -114
- package/src/report/sarif.mjs +280 -0
- package/src/report/snapshot-text.mjs +123 -0
- package/src/report/text.mjs +36 -0
- package/src/report/trajectory-text.mjs +143 -0
- package/src/report/waivers-text.mjs +35 -2
- 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
|
+
}
|