@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.
- package/README.md +1 -1
- package/cli.mjs +158 -2
- package/package.json +1 -1
- package/src/commands/README.md +19 -1
- package/src/commands/check.mjs +82 -16
- package/src/commands/context.mjs +52 -14
- package/src/commands/coverage-acceptance.mjs +113 -0
- package/src/commands/delta-classify.mjs +502 -0
- package/src/commands/delta-snapshot.mjs +517 -0
- package/src/commands/delta.mjs +481 -0
- package/src/commands/explain.mjs +39 -0
- package/src/commands/policy.mjs +36 -1
- package/src/commands/waivers.mjs +53 -3
- package/src/config.mjs +129 -11
- package/src/lsp/boundary-config.mjs +9 -4
- package/src/providers/native/model.mjs +17 -0
- package/src/report/delta-text.mjs +183 -0
- package/src/report/explain-text.mjs +27 -0
- package/src/report/sarif.mjs +25 -0
- package/src/report/text.mjs +36 -0
- package/src/report/waivers-text.mjs +35 -2
|
@@ -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
|
+
}
|