@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,606 @@
1
+ /**
2
+ * The `delta` command: two evidence sets — a captured baseline and the current
3
+ * tree — re-judged under ONE boundary config and ONE shared reference instant,
4
+ * then classified `introduced` | `resolved` | `unchanged` | `unknown`.
5
+ *
6
+ * Two modes, one module:
7
+ *
8
+ * - **capture** (`captureDelta`) — run at a base checkout, writes the evidence
9
+ * snapshot `./delta-snapshot.mjs` defines: raw import-site records, the
10
+ * graph they were collected against, coverage, provenance, and the policy
11
+ * fingerprint. Evidence, never verdicts — the header of that module owns
12
+ * the argument.
13
+ * - **compare** (`deltaCommand`) — run at head, loads the baseline and
14
+ * re-judges BOTH sides through `../rules/index.mjs`'s engine under the
15
+ * CURRENT config, so a policy edit between capture and now cannot fabricate
16
+ * an introduced/resolved pair; only the code can move a classification
17
+ * (`./delta-classify.mjs`).
18
+ *
19
+ * Unlike `diff` — which compares two GRAPH snapshots edge by edge and never
20
+ * exits 1 — `delta` is a gate: a non-waived introduced violation is a finding
21
+ * (exit 1), which is the whole point of carrying re-judgeable evidence rather
22
+ * than a graph. That made `delta` the third verb whose verdict carries
23
+ * exit 1, beside `check` and `fitness`; `./change.mjs` later became the
24
+ * fourth, over a different question — declared intent versus observed delta.
25
+ *
26
+ * Refusals (each a throw, exit 3 upstream — a delta that could not honestly
27
+ * classify must never read as "no change"):
28
+ * - a baseline that cannot be read, parsed, or holds a foreign schemaVersion
29
+ * (`./delta-snapshot.mjs`'s loader owns those);
30
+ * - a provider mismatch between baseline and this run (`providerMismatch`) —
31
+ * a THROW here where `diff` settles for a note, because violation IDENTITY
32
+ * across two different project models is not trustworthy: the same tree
33
+ * attributed to different projects would classify a rename as an
34
+ * introduced/resolved pair the code does not contain;
35
+ * - incomplete CURRENT coverage — a delta over a half-analyzed head is not a
36
+ * verdict, the same posture `check` takes on `unchecked` files;
37
+ * - an Nx workspace with polyglot manifests but no plugin registration — the
38
+ * same silently-under-representing graph `graph`/`diff` refuse.
39
+ *
40
+ * What is deliberately NOT a refusal: a policy-fingerprint mismatch between
41
+ * baseline and current. Both sides are re-judged under the current law — that
42
+ * is the design's point — so the mismatch becomes a loud coverage note
43
+ * instead. Dirty base provenance and a dirty head are notes too: weaker
44
+ * evidence, not unreadable evidence.
45
+ *
46
+ * This module computes and returns; `../../cli.mjs`'s `runDelta` owns argv,
47
+ * output destination and the process exit code (`./README.md`).
48
+ */
49
+ import { createRequire } from "node:module";
50
+
51
+ import { isWholeFileFailure } from "../analysis/source-util.mjs";
52
+ import { stripTrailingSlashes } from "../path-util.mjs";
53
+ import { referenceTime } from "../governance/clock.mjs";
54
+ import { jsonEnvelope, renderJson } from "../report/json.mjs";
55
+ import { buildDecision } from "../report/evidence.mjs";
56
+ import { formatDeltaReport } from "../report/delta-text.mjs";
57
+ import { formatDeltaSarif } from "../report/sarif.mjs";
58
+ import { evaluateRun } from "../rules/index.mjs";
59
+ import { customRulesForDelta, declaresCustomRules } from "./custom-rules.mjs";
60
+ import { classifyCustomFindings, classifyDelta } from "./delta-classify.mjs";
61
+ import {
62
+ buildEvidenceSnapshot,
63
+ providerMismatch,
64
+ readEvidenceSnapshot,
65
+ serializeEvidenceSnapshot,
66
+ } from "./delta-snapshot.mjs";
67
+ import { computePolicyFingerprint } from "./graph.mjs";
68
+ import { resolveProvenance } from "./provenance.mjs";
69
+ import { compareSnapshotMetadata } from "./snapshot-meta.mjs";
70
+
71
+ const require = createRequire(import.meta.url);
72
+ /** @type {{name: string, version: string}} */
73
+ const { name: TOOL_NAME, version: TOOL_VERSION } = require("../../package.json");
74
+
75
+ /**
76
+ * Refuses the two head states no delta side may be built over, shared by both
77
+ * modes: the unregistered-plugin graph and incomplete analysis coverage.
78
+ *
79
+ * Exported since `change` arrived because that command builds its comparison
80
+ * over the same two head states — a graph that under-represents the tree and
81
+ * an analysis with holes would reconcile a declaration against architecture
82
+ * nobody observed — and a second copy of the refusal is where the two
83
+ * commands would drift into answering "may this head be judged?" differently.
84
+ *
85
+ * @param {object} commandContext From `resolveCommandContext`.
86
+ * @param {string} activity Which mode is refusing, for the message.
87
+ * @throws {Error} on either condition.
88
+ */
89
+ export function refuseUnjudgeableHead(commandContext, activity) {
90
+ const { provider, pluginGap } = commandContext;
91
+ if (provider === "nx" && !pluginGap.registered && pluginGap.manifests.length > 0) {
92
+ throw new Error(
93
+ `archkeep: refusing to ${activity} for an Nx workspace where this plugin is not ` +
94
+ `registered but polyglot manifests exist under project roots ` +
95
+ `(${pluginGap.manifests.join(", ")}). The graph would carry no polyglot edges, so the ` +
96
+ `evidence would silently under-represent the real architecture. Register the plugin in ` +
97
+ `nx.json: "plugins": [{ "plugin": "@ecoma-io/archkeep/nx" }], or remove the polyglot ` +
98
+ `manifests if they are not in use.`,
99
+ );
100
+ }
101
+ const notAnalyzed = commandContext.analysis.failures.filter(isWholeFileFailure);
102
+ if (notAnalyzed.length > 0) {
103
+ throw new Error(
104
+ `archkeep: cannot ${activity} — ${notAnalyzed.length} file` +
105
+ `${notAnalyzed.length === 1 ? "" : "s"} could not be analyzed, so the evidence would ` +
106
+ `miss violations living there and a later classification would misread the gap as a ` +
107
+ `code change. Fix the unanalyzed files and re-run.`,
108
+ );
109
+ }
110
+ }
111
+
112
+ /**
113
+ * Captures the current tree as a delta baseline: the evidence snapshot
114
+ * serialized, ready for a future `delta <base.json>` run to consume.
115
+ *
116
+ * @param {object} commandContext From `resolveCommandContext`.
117
+ * @param {{config: object|null}} io The resolved boundary config — required,
118
+ * because the snapshot's policy fingerprint is what lets a later run say
119
+ * loudly that the law moved.
120
+ * @returns {{snapshot: object, text: string}}
121
+ * @throws {Error} on an unjudgeable head (above) or a run with no boundary
122
+ * law — a baseline with no policy identity could never disclose a law
123
+ * change, which is the silent direction.
124
+ */
125
+ export function captureDelta(commandContext, { config }) {
126
+ refuseUnjudgeableHead(commandContext, "capture a delta baseline");
127
+ if (!config) {
128
+ throw new Error(
129
+ "archkeep: cannot capture a delta baseline without a boundary config — the snapshot " +
130
+ "records the policy fingerprint so a later delta run can say loudly when the law moved, " +
131
+ "and a workspace that resolves no law leaves that claim unmakeable.",
132
+ );
133
+ }
134
+ const { root, provider, graph, analysis } = commandContext;
135
+ const snapshot = buildEvidenceSnapshot({
136
+ // The two optional custom-rule blocks, stored exactly when the capturing
137
+ // policy declares rules — an undeclaring workspace's snapshot stays
138
+ // byte-identical (`./delta-snapshot.mjs`, the optional-blocks section).
139
+ ...(declaresCustomRules(config)
140
+ ? { customRules: config.customRules, owned: commandContext.owned }
141
+ : {}),
142
+ tool: { name: TOOL_NAME, version: TOOL_VERSION },
143
+ provenance: resolveProvenance(root),
144
+ provider,
145
+ policyFingerprint: computePolicyFingerprint(config),
146
+ coverage: {
147
+ // `refuseUnjudgeableHead` already threw on any whole-file failure, so
148
+ // the capture-side claim is honestly complete.
149
+ complete: true,
150
+ analyzedFiles: analysis.analyzed,
151
+ notAnalyzed: [],
152
+ blindSpots: analysis.failures
153
+ .filter((failure) => !isWholeFileFailure(failure))
154
+ .map(({ sourceFile, line, column, reason }) => ({
155
+ file: sourceFile,
156
+ line,
157
+ column,
158
+ reason,
159
+ })),
160
+ },
161
+ graph,
162
+ records: analysis.imports,
163
+ });
164
+ return { snapshot, text: serializeEvidenceSnapshot(snapshot) };
165
+ }
166
+
167
+ /**
168
+ * Rebuilds an engine-consumable `ProjectGraph` from a snapshot's stored graph.
169
+ *
170
+ * The snapshot stores what `graph --format json` publishes — a `projects`
171
+ * ARRAY and a flat `dependencies` array — while `../rules/index.mjs`'s
172
+ * `evaluate()` consumes Nx's shape: a `nodes` MAP keyed by name (each with
173
+ * `type` and `data.{root, tags, targets?}`) plus a source-keyed `dependencies`
174
+ * map. The conversion is exact where the snapshot kept the fact:
175
+ *
176
+ * - `name`/`root`/`type`/`tags` map straight back onto `data`;
177
+ * - the three rule-relevant extras the snapshot re-attached (`mfeRemote`,
178
+ * `entryPoints`, `declaredPackages`) go back onto `data` only when present —
179
+ * absence stays absence, because `evaluate()` treats an absent field as
180
+ * "declares none" and inventing an empty value would be a second copy of
181
+ * that answer (`./delta-snapshot.mjs`);
182
+ * - `targets` was stored as the NAMES alone, so each becomes `{}` in the
183
+ * rebuilt `data.targets` map. That preserves both reads the engine makes of
184
+ * it — `Object.keys` in the buildTargets guard, and
185
+ * `../rules/topology.mjs`'s `hasBuildExecutor`, whose
186
+ * `targets[t].executor !== ""` is true for `{}` — so a declared target
187
+ * stays a declared target; the executor STRING itself is the one fact the
188
+ * snapshot never held;
189
+ * - `workspaceLayout` and `exemptedFiles` ride the graph object exactly as
190
+ * the provider carried them, because `createContext` reads both off it.
191
+ *
192
+ * Every project name gets a `dependencies` entry — an empty array for a
193
+ * project with no outgoing edge — matching the shape every provider emits.
194
+ *
195
+ * A mis-shaped rebuild here is the silent direction in miniature: a base
196
+ * graph the engine reads as empty yields zero base violations, which
197
+ * classifies every standing violation as freshly introduced (loud but wrong)
198
+ * or — with the sides swapped — masks base violations entirely. The test
199
+ * beside this module holds the non-empty base-side re-judgment.
200
+ *
201
+ * @param {{projects: object[], dependencies: {source: string, target: string,
202
+ * type: string}[], workspaceLayout?: object, exemptedFiles?: string[]}} storedGraph
203
+ * A validated snapshot's `graph` section (`parseEvidenceSnapshot`).
204
+ * @returns {object} A graph `evaluate()` consumes.
205
+ */
206
+ export function evidenceGraphToProjectGraph(storedGraph) {
207
+ /** @type {Record<string, object>} */
208
+ const nodes = {};
209
+ /** @type {Record<string, object[]>} */
210
+ const dependencies = {};
211
+ for (const project of storedGraph.projects) {
212
+ /** @type {Record<string, unknown>} */
213
+ const data = { root: project.root, tags: project.tags ?? [] };
214
+ if (Array.isArray(project.targets)) {
215
+ data.targets = Object.fromEntries(project.targets.map((target) => [target, {}]));
216
+ }
217
+ if (project.mfeRemote !== undefined) data.mfeRemote = project.mfeRemote;
218
+ if (Array.isArray(project.entryPoints)) data.entryPoints = project.entryPoints;
219
+ if (Array.isArray(project.declaredPackages)) data.declaredPackages = project.declaredPackages;
220
+ nodes[project.name] = { name: project.name, type: project.type, data };
221
+ dependencies[project.name] = [];
222
+ }
223
+ for (const edge of storedGraph.dependencies) {
224
+ if (!Array.isArray(dependencies[edge.source])) dependencies[edge.source] = [];
225
+ dependencies[edge.source].push({ source: edge.source, target: edge.target, type: edge.type });
226
+ }
227
+ /** @type {Record<string, unknown>} */
228
+ const graph = { nodes, dependencies };
229
+ if (storedGraph.workspaceLayout !== undefined)
230
+ graph.workspaceLayout = storedGraph.workspaceLayout;
231
+ if (Array.isArray(storedGraph.exemptedFiles)) graph.exemptedFiles = storedGraph.exemptedFiles;
232
+ return graph;
233
+ }
234
+
235
+ /**
236
+ * A longest-root-prefix attributor for `classifyUnresolvableRecords`: the
237
+ * record's file is matched against project roots, head's first (the current
238
+ * model is the one both sides are judged under), then any baseline root the
239
+ * head does not already claim — so a base-side record living in a directory
240
+ * the head no longer has still attributes to the project that owned it.
241
+ *
242
+ * Exported for `./change.mjs`, which classifies the same two evidence sets
243
+ * through `./delta-classify.mjs` and must attribute unresolvable records the
244
+ * same way a delta does — a second attribution rule beside this one is how
245
+ * the two commands would disagree about which project carried a site.
246
+ *
247
+ * @param {object} headGraph The current run's graph (`nodes` map).
248
+ * @param {object[]} baselineProjects The snapshot's stored project rows.
249
+ * @returns {(record: object) => string|null}
250
+ */
251
+ export function sourceProjectAttributor(headGraph, baselineProjects) {
252
+ /** @type {Map<string, string>} root → project name, head winning ties. */
253
+ const byRoot = new Map();
254
+ for (const node of Object.values(headGraph.nodes ?? {})) {
255
+ const root = typeof node?.data?.root === "string" ? node.data.root : null;
256
+ if (root !== null && !byRoot.has(root)) byRoot.set(root, node.name);
257
+ }
258
+ for (const project of baselineProjects) {
259
+ if (typeof project.root === "string" && !byRoot.has(project.root)) {
260
+ byRoot.set(project.root, project.name);
261
+ }
262
+ }
263
+ const entries = [...byRoot.entries()]
264
+ .map(([root, name]) => [stripTrailingSlashes(root), name])
265
+ .sort((a, b) => b[0].length - a[0].length);
266
+ return (record) => {
267
+ const file = record?.sourceFile;
268
+ if (typeof file !== "string") return null;
269
+ for (const [root, name] of entries) {
270
+ if (root === "" || root === "." || file === root || file.startsWith(`${root}/`)) {
271
+ return /** @type {string} */ (name);
272
+ }
273
+ }
274
+ return null;
275
+ };
276
+ }
277
+
278
+ /** First eight hex characters of a fingerprint, for prose that names one. */
279
+ const short = (fingerprint) =>
280
+ typeof fingerprint === "string" ? fingerprint.slice(0, 8) : String(fingerprint);
281
+
282
+ /**
283
+ * Runs the `delta` compare mode: loads the baseline, re-judges both sides
284
+ * under the current law and one shared instant, classifies, and folds the
285
+ * classification into the verdict.
286
+ *
287
+ * The exit fold — the whole point of the command:
288
+ * - any `introduced` violation NOT covered by the current waiver table →
289
+ * `findings` (exit 1);
290
+ * - else any `unknown` entry, in either the violations or the unresolvable
291
+ * buckets → `no-verdict` (exit 3): an item the classifier could not place
292
+ * is a question this run could not answer, never a clean delta;
293
+ * - else `ok` (exit 0). Waived-introduced entries are REPORTED — waiving is
294
+ * a tracked acceptance, not a fix — but do not fail the gate, which is what
295
+ * a waiver is for.
296
+ *
297
+ * Custom-rule (wasm) findings join the classification when either side
298
+ * declares them: `./custom-rules.mjs`'s `customRulesForDelta` judges every
299
+ * head-declared rule over both evidence sets, `./delta-classify.mjs`'s
300
+ * `classifyCustomFindings` buckets the findings, and the result rides the
301
+ * envelope as `result.customRules` — a block that is ABSENT (never `null`)
302
+ * when neither side declares any, so an undeclaring workspace's envelope
303
+ * stays byte-identical. An introduced custom finding gates exactly as an
304
+ * introduced violation does, with no waiver lane by construction
305
+ * (suppressions key on a `messageId` custom findings do not have); an
306
+ * unclassifiable one is a no-verdict. This is also why the function is async:
307
+ * the wasm host is.
308
+ *
309
+ * @param {string} baselinePath Absolute path to the evidence snapshot.
310
+ * @param {object} commandContext From `resolveCommandContext`.
311
+ * @param {{config: object|null, readBaseline?: (path: string) => object,
312
+ * now?: string, readArtifact?: (artifact: string) => Uint8Array|null,
313
+ * timeoutMs?: number}} io The resolved boundary config (required
314
+ * — both sides are re-judged under it), an injectable baseline reader, the
315
+ * one shared reference instant (defaults to the shared governance clock),
316
+ * and the custom-rule host's two injectable seams, passed through to
317
+ * `customRulesForDelta`.
318
+ * @returns {Promise<{status: "ok"|"findings"|"no-verdict", delta: object,
319
+ * coverage: object, report: {text: string, json: string, sarif: string}}>}
320
+ * @throws {Error} on every refusal the module header lists, and on a
321
+ * custom-rule LOAD failure (`./custom-rules.mjs` argues the split).
322
+ */
323
+ export async function deltaCommand(
324
+ baselinePath,
325
+ commandContext,
326
+ { config, readBaseline = readEvidenceSnapshot, now = referenceTime(), ...customRuleIo },
327
+ ) {
328
+ const { root, provider, marker, graph, analysis } = commandContext;
329
+
330
+ refuseUnjudgeableHead(commandContext, "compute a delta");
331
+ if (!config) {
332
+ throw new Error(
333
+ "archkeep: cannot compute a delta without a boundary config — both sides are re-judged " +
334
+ "under the current law, and a run that resolves no law has nothing to judge either side " +
335
+ "against.",
336
+ );
337
+ }
338
+
339
+ const baseline = readBaseline(baselinePath);
340
+
341
+ // Provider mismatch is a REFUSAL here, deliberately stricter than `diff`'s
342
+ // note: `diff` describes structural difference, where a provider artefact is
343
+ // a caveat; `delta` asserts violation identity across the two sides, and an
344
+ // identity computed over two different project models is not evidence.
345
+ const mismatch = providerMismatch(baseline.provider, provider);
346
+ if (mismatch !== null) {
347
+ throw new Error(
348
+ `archkeep: refusing to compute a delta — ${mismatch}. Re-capture the baseline under ` +
349
+ `this run's provider.`,
350
+ );
351
+ }
352
+
353
+ const baseGraph = evidenceGraphToProjectGraph(baseline.graph);
354
+ const configWithNow = { ...config, now };
355
+ // Both sides RAW (pre-suppression), through the same walk `waivers` reads:
356
+ // suppression must annotate the classification, never shrink either side —
357
+ // a suppressed-then-regressed violation has to stay visible
358
+ // (`./delta-classify.mjs`).
359
+ const baseViolations = evaluateRun(baseline.records, baseGraph, configWithNow).rawViolations;
360
+ const headViolations = evaluateRun(analysis.imports, graph, configWithNow).rawViolations;
361
+
362
+ const classification = classifyDelta({
363
+ baseViolations,
364
+ headViolations,
365
+ baseRecords: baseline.records,
366
+ headRecords: analysis.imports,
367
+ suppressions: config.suppressions ?? [],
368
+ now,
369
+ sourceProjectOf: sourceProjectAttributor(graph, baseline.graph.projects),
370
+ });
371
+
372
+ const headProvenance = resolveProvenance(root);
373
+ const headFingerprint = computePolicyFingerprint(config);
374
+ const meta = compareSnapshotMetadata({
375
+ baselineProvider: baseline.provider,
376
+ headProvider: provider,
377
+ baselineProvenance: baseline.provenance,
378
+ headProvenance,
379
+ baselineFingerprint: baseline.policyFingerprint,
380
+ headFingerprint,
381
+ });
382
+
383
+ const notes = [];
384
+ if (meta.policyChanged === true) {
385
+ notes.push(
386
+ `the boundary law changed since capture (baseline ${short(baseline.policyFingerprint)}…, ` +
387
+ `current ${short(headFingerprint)}…) — classifications reflect the current law applied ` +
388
+ `to both sides, so a violation a policy edit created or retired classifies as unchanged, ` +
389
+ `not as introduced or resolved`,
390
+ );
391
+ }
392
+ if (meta.crossRepo) {
393
+ notes.push(
394
+ `baseline provenance remote (${baseline.provenance.remote}) differs from head provenance ` +
395
+ `remote (${headProvenance?.remote}) — the delta may be across unrelated repositories ` +
396
+ `rather than two revisions of the same one`,
397
+ );
398
+ } else if (meta.provenanceOneSided) {
399
+ const side = baseline.provenance ? "head" : "baseline";
400
+ notes.push(
401
+ `the ${side} carries no provenance — the delta cannot verify it compares two revisions ` +
402
+ `of the same repository`,
403
+ );
404
+ }
405
+ if (meta.dirtyBaseline) {
406
+ notes.push(
407
+ "the baseline was captured from a dirty working tree — its evidence is not a reproducible " +
408
+ "claim about the commit it names",
409
+ );
410
+ }
411
+ if (meta.dirtyHead) {
412
+ notes.push(
413
+ "this run's working tree is dirty — the head side describes uncommitted state, not the " +
414
+ "commit HEAD names",
415
+ );
416
+ }
417
+
418
+ // The custom-rule half, present exactly when a side declares rules: judged
419
+ // two-sided where the law is identical, `unknown` with a mandatory reason
420
+ // everywhere else (`./custom-rules.mjs`'s `customRulesForDelta` owns the
421
+ // routes). `null` when NEITHER side declares any — the envelope block and
422
+ // the summary key are then absent, and an undeclaring workspace's envelope
423
+ // stays byte-identical (`../../../../AGENTS.md`, "a change to what is
424
+ // reported on an unchanged workspace is a breaking change").
425
+ /** @type {{judged: object[], skipped: object[], removed: string[],
426
+ * findings: {introduced: object[], resolved: object[], unchanged: object[],
427
+ * unknown: object[]}}|null} */
428
+ let custom = null;
429
+ // The head-declared finding catalogue, held for the SARIF face alone: the
430
+ // envelope deliberately does not carry it (the JSON contract predates the
431
+ // SARIF face and must stay byte-identical), while `sarifRules` needs it so
432
+ // an introduced custom finding's `ruleId` resolves to a descriptor.
433
+ /** @type {{ruleId: string, rule: string, findingId: string, message: string}[]} */
434
+ let customCatalogue = [];
435
+ if (declaresCustomRules(config)) {
436
+ const twoSided = await customRulesForDelta(commandContext, {
437
+ rows: config.customRules,
438
+ policy: config,
439
+ baseline,
440
+ ...customRuleIo,
441
+ });
442
+ customCatalogue = twoSided.catalogue;
443
+ custom = {
444
+ judged: twoSided.judged.map(({ name, sha256, notes: ruleNotes }) => ({
445
+ name,
446
+ sha256,
447
+ ...(ruleNotes === undefined ? {} : { notes: ruleNotes }),
448
+ })),
449
+ skipped: twoSided.unknownRules,
450
+ removed: twoSided.removedRules,
451
+ findings: classifyCustomFindings({
452
+ judged: twoSided.judged,
453
+ unknownRules: twoSided.unknownRules,
454
+ }),
455
+ };
456
+ } else if (baseline.customRules !== undefined) {
457
+ // The head declares nothing, so there is no law to judge either side
458
+ // under — every baseline rule is a removal, disclosed rather than judged.
459
+ custom = {
460
+ judged: [],
461
+ skipped: [],
462
+ removed: baseline.customRules.map((row) => row.name),
463
+ findings: { introduced: [], resolved: [], unchanged: [], unknown: [] },
464
+ };
465
+ }
466
+ if (custom !== null) {
467
+ for (const skipped of custom.skipped) {
468
+ notes.push(`custom rule "${skipped.name}" was not classified — ${skipped.reason}`);
469
+ }
470
+ for (const name of custom.removed) {
471
+ notes.push(
472
+ `custom rule "${name}" is declared in the baseline but not by the current policy — ` +
473
+ `nothing was judged for it, so its base-side findings are not classified as resolved`,
474
+ );
475
+ }
476
+ for (const rule of custom.judged) {
477
+ for (const note of rule.notes ?? []) {
478
+ notes.push(`custom rule "${rule.name}": ${note}`);
479
+ }
480
+ }
481
+ }
482
+
483
+ const { violations, unresolvable } = classification;
484
+ const introducedWaived = violations.introduced.filter((entry) => entry.waived === true).length;
485
+ const introducedNotWaived = violations.introduced.length - introducedWaived;
486
+ // Custom findings have no waiver lane (`./delta-classify.mjs`'s
487
+ // `classifyCustomFindings` argues the by-construction absence), so every
488
+ // introduced one gates.
489
+ const customIntroduced = custom === null ? 0 : custom.findings.introduced.length;
490
+ const customUnknown = custom === null ? 0 : custom.findings.unknown.length;
491
+ const unknownCount = violations.unknown.length + unresolvable.unknown.length + customUnknown;
492
+
493
+ /** @type {"ok"|"findings"|"no-verdict"} */
494
+ let status;
495
+ /** @type {0|1|3} */
496
+ let exitCode;
497
+ let decision;
498
+ if (introducedNotWaived + customIntroduced > 0) {
499
+ status = "findings";
500
+ exitCode = 1;
501
+ decision = buildDecision({
502
+ status,
503
+ coverageComplete: true,
504
+ findings: introducedNotWaived + customIntroduced,
505
+ });
506
+ } else if (unknownCount > 0) {
507
+ status = "no-verdict";
508
+ exitCode = 3;
509
+ decision = buildDecision({
510
+ status,
511
+ coverageComplete: true,
512
+ findings: 0,
513
+ reason:
514
+ `${unknownCount} delta item${unknownCount === 1 ? "" : "s"} could not be classified — ` +
515
+ (customUnknown > 0
516
+ ? `${customUnknown} of them custom-rule item${customUnknown === 1 ? "" : "s"} — `
517
+ : "") +
518
+ `an item whose identity cannot be stated is never guessed into a bucket`,
519
+ });
520
+ } else {
521
+ status = "ok";
522
+ exitCode = 0;
523
+ decision = buildDecision({ status, coverageComplete: true, findings: 0 });
524
+ }
525
+
526
+ const coverage = {
527
+ complete: true,
528
+ projects: Object.keys(graph.nodes).length,
529
+ analyzedFiles: analysis.analyzed,
530
+ imports: analysis.imports.length,
531
+ notAnalyzed: [],
532
+ blindSpots: analysis.failures
533
+ .filter((failure) => !isWholeFileFailure(failure))
534
+ .map(({ sourceFile, line, column, reason }) => ({ file: sourceFile, line, column, reason })),
535
+ notes,
536
+ };
537
+
538
+ const result = {
539
+ baseline: {
540
+ path: baselinePath,
541
+ tool: baseline.tool,
542
+ provider: baseline.provider,
543
+ provenance: baseline.provenance,
544
+ policyFingerprint: baseline.policyFingerprint,
545
+ records: baseline.records.length,
546
+ projects: baseline.graph.projects.length,
547
+ },
548
+ head: {
549
+ provenance: headProvenance,
550
+ policyFingerprint: headFingerprint,
551
+ records: analysis.imports.length,
552
+ projects: Object.keys(graph.nodes).length,
553
+ },
554
+ policyChanged: meta.policyChanged,
555
+ summary: {
556
+ introduced: violations.introduced.length,
557
+ introducedWaived,
558
+ resolved: violations.resolved.length,
559
+ unchanged: violations.unchanged.length,
560
+ unknown: violations.unknown.length,
561
+ unresolvable: {
562
+ introduced: unresolvable.introduced.length,
563
+ resolved: unresolvable.resolved.length,
564
+ unchanged: unresolvable.unchanged.length,
565
+ unknown: unresolvable.unknown.length,
566
+ },
567
+ ...(custom === null
568
+ ? {}
569
+ : {
570
+ customFindings: {
571
+ introduced: custom.findings.introduced.length,
572
+ resolved: custom.findings.resolved.length,
573
+ unchanged: custom.findings.unchanged.length,
574
+ unknown: custom.findings.unknown.length,
575
+ },
576
+ }),
577
+ },
578
+ violations,
579
+ unresolvable,
580
+ ...(custom === null ? {} : { customRules: custom }),
581
+ };
582
+
583
+ const envelope = jsonEnvelope({
584
+ command: "delta",
585
+ context: { root, provider, marker, provenance: headProvenance },
586
+ status,
587
+ exitCode,
588
+ coverage,
589
+ result,
590
+ decision,
591
+ });
592
+
593
+ return {
594
+ status,
595
+ delta: result,
596
+ coverage,
597
+ report: {
598
+ text: formatDeltaReport({ delta: result, coverage }),
599
+ json: renderJson(envelope),
600
+ // Eager beside the other two faces: the render is pure and cheap, and a
601
+ // lazy face is one a caller can forget to build — the SARIF is the same
602
+ // verdict, ready whichever face `--format` selects.
603
+ sarif: formatDeltaSarif({ delta: result, coverage, customCatalogue }),
604
+ },
605
+ };
606
+ }
@@ -216,13 +216,29 @@ function buildHeadSnapshot(commandContext) {
216
216
  };
217
217
  }
218
218
 
219
+ /**
220
+ * The identity key of one dependency record: the `(source, target, type)`
221
+ * triple joined with `\0`. Extracted from `computeDiff`'s index maps so a
222
+ * second consumer (`./trajectory.mjs`'s persistence sets) keys edges the SAME
223
+ * way the diff does — a second spelling of this string would be a second
224
+ * definition of "same edge", and two definitions drift.
225
+ *
226
+ * A `static` edge becoming `dynamic` is therefore a removed edge under the
227
+ * old type and an added edge under the new one, which is exactly what a
228
+ * consumer wants to see: it is a real architectural event, not an
229
+ * implementation detail.
230
+ *
231
+ * @param {{source: string, target: string, type: string}} edge
232
+ * @returns {string}
233
+ */
234
+ export function edgeIdentityKey({ source, target, type }) {
235
+ return `${source}\0${target}\0${type}`;
236
+ }
237
+
219
238
  /**
220
239
  * Computes the diff between two graph snapshots.
221
240
  *
222
- * Edge identity is `(source, target, type)` — a `static` edge becoming
223
- * `dynamic` is an added edge under the new type and a removed edge under the
224
- * old one, which is exactly what a consumer wants to see: it is a real
225
- * architectural event, not an implementation detail.
241
+ * Edge identity is `(source, target, type)` — see `edgeIdentityKey` above.
226
242
  *
227
243
  * @param {{projects: object[], dependencies: object[]}} baseline
228
244
  * @param {{projects: object[], dependencies: object[]}} head
@@ -282,13 +298,9 @@ export function computeDiff(baseline, head) {
282
298
  removedProjects.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
283
299
  changedProjects.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
284
300
 
285
- // Index edges by the (source, target, type) triple the identity key.
286
- const baselineEdges = new Map(
287
- baseline.dependencies.map((e) => [`${e.source}\0${e.target}\0${e.type}`, e]),
288
- );
289
- const headEdges = new Map(
290
- head.dependencies.map((e) => [`${e.source}\0${e.target}\0${e.type}`, e]),
291
- );
301
+ // Index edges by the identity key `edgeIdentityKey` above owns the triple.
302
+ const baselineEdges = new Map(baseline.dependencies.map((e) => [edgeIdentityKey(e), e]));
303
+ const headEdges = new Map(head.dependencies.map((e) => [edgeIdentityKey(e), e]));
292
304
 
293
305
  const addedEdges = [];
294
306
  const removedEdges = [];
@@ -410,8 +422,11 @@ export function diffCommand(
410
422
  // classifies as changes, compared here through the shared
411
423
  // `./snapshot-meta.mjs` so the two commands cannot disagree about them.
412
424
  // Each mismatch becomes a `coverage.notes` warning rather than a refusal:
413
- // a provider migration, a cross-repository diff, or a policy change between
414
- // baseline and head are all legitimate states a consumer must be told about.
425
+ // a provider migration, a cross-repository diff, a dirty-tree side, or a
426
+ // policy change between baseline and head are all legitimate states a
427
+ // consumer must be told about. The dirty-tree wording is the one `delta` and
428
+ // `change` emit over the same metadata — three commands, one sentence each,
429
+ // so a consumer reading any report reads the same caveat.
415
430
  const headProvenance = resolveProvenance(root);
416
431
  const headFingerprint = config ? computePolicyFingerprint(config) : null;
417
432
  const meta = compareSnapshotMetadata({
@@ -445,6 +460,19 @@ export function diffCommand(
445
460
  );
446
461
  }
447
462
 
463
+ if (meta.dirtyBaseline) {
464
+ coverage.notes.push(
465
+ "the baseline was captured from a dirty working tree — its evidence is not a reproducible " +
466
+ "claim about the commit it names",
467
+ );
468
+ }
469
+ if (meta.dirtyHead) {
470
+ coverage.notes.push(
471
+ "this run's working tree is dirty — the head side describes uncommitted state, not the " +
472
+ "commit HEAD names",
473
+ );
474
+ }
475
+
448
476
  if (meta.policyChanged === true) {
449
477
  // A policy change between baseline and head means every "introduced" or
450
478
  // "resolved" violation in the rule-impact analysis may be an artefact of