@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,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
|
+
}
|
package/src/commands/diff.mjs
CHANGED
|
@@ -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)` —
|
|
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
|
|
286
|
-
const baselineEdges = new Map(
|
|
287
|
-
|
|
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,
|
|
414
|
-
// baseline and head are all legitimate states a
|
|
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
|