@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,481 @@
|
|
|
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 makes `delta` the third verb whose verdict carries
|
|
23
|
+
* exit 1, beside `check` and `fitness`.
|
|
24
|
+
*
|
|
25
|
+
* Refusals (each a throw, exit 3 upstream — a delta that could not honestly
|
|
26
|
+
* classify must never read as "no change"):
|
|
27
|
+
* - a baseline that cannot be read, parsed, or holds a foreign schemaVersion
|
|
28
|
+
* (`./delta-snapshot.mjs`'s loader owns those);
|
|
29
|
+
* - a provider mismatch between baseline and this run (`providerMismatch`) —
|
|
30
|
+
* a THROW here where `diff` settles for a note, because violation IDENTITY
|
|
31
|
+
* across two different project models is not trustworthy: the same tree
|
|
32
|
+
* attributed to different projects would classify a rename as an
|
|
33
|
+
* introduced/resolved pair the code does not contain;
|
|
34
|
+
* - incomplete CURRENT coverage — a delta over a half-analyzed head is not a
|
|
35
|
+
* verdict, the same posture `check` takes on `unchecked` files;
|
|
36
|
+
* - an Nx workspace with polyglot manifests but no plugin registration — the
|
|
37
|
+
* same silently-under-representing graph `graph`/`diff` refuse.
|
|
38
|
+
*
|
|
39
|
+
* What is deliberately NOT a refusal: a policy-fingerprint mismatch between
|
|
40
|
+
* baseline and current. Both sides are re-judged under the current law — that
|
|
41
|
+
* is the design's point — so the mismatch becomes a loud coverage note
|
|
42
|
+
* instead. Dirty base provenance and a dirty head are notes too: weaker
|
|
43
|
+
* evidence, not unreadable evidence.
|
|
44
|
+
*
|
|
45
|
+
* This module computes and returns; `../../cli.mjs`'s `runDelta` owns argv,
|
|
46
|
+
* output destination and the process exit code (`./README.md`).
|
|
47
|
+
*/
|
|
48
|
+
import { createRequire } from "node:module";
|
|
49
|
+
|
|
50
|
+
import { isWholeFileFailure } from "../analysis/source-util.mjs";
|
|
51
|
+
import { referenceTime } from "../governance/clock.mjs";
|
|
52
|
+
import { jsonEnvelope, renderJson } from "../report/json.mjs";
|
|
53
|
+
import { buildDecision } from "../report/evidence.mjs";
|
|
54
|
+
import { formatDeltaReport } from "../report/delta-text.mjs";
|
|
55
|
+
import { evaluateRun } from "../rules/index.mjs";
|
|
56
|
+
import { classifyDelta } from "./delta-classify.mjs";
|
|
57
|
+
import {
|
|
58
|
+
buildEvidenceSnapshot,
|
|
59
|
+
providerMismatch,
|
|
60
|
+
readEvidenceSnapshot,
|
|
61
|
+
serializeEvidenceSnapshot,
|
|
62
|
+
} from "./delta-snapshot.mjs";
|
|
63
|
+
import { computePolicyFingerprint } from "./graph.mjs";
|
|
64
|
+
import { resolveProvenance } from "./provenance.mjs";
|
|
65
|
+
import { compareSnapshotMetadata } from "./snapshot-meta.mjs";
|
|
66
|
+
|
|
67
|
+
const require = createRequire(import.meta.url);
|
|
68
|
+
/** @type {{name: string, version: string}} */
|
|
69
|
+
const { name: TOOL_NAME, version: TOOL_VERSION } = require("../../package.json");
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Refuses the two head states no delta side may be built over, shared by both
|
|
73
|
+
* modes: the unregistered-plugin graph and incomplete analysis coverage.
|
|
74
|
+
*
|
|
75
|
+
* @param {object} commandContext From `resolveCommandContext`.
|
|
76
|
+
* @param {string} activity Which mode is refusing, for the message.
|
|
77
|
+
* @throws {Error} on either condition.
|
|
78
|
+
*/
|
|
79
|
+
function refuseUnjudgeableHead(commandContext, activity) {
|
|
80
|
+
const { provider, pluginGap } = commandContext;
|
|
81
|
+
if (provider === "nx" && !pluginGap.registered && pluginGap.manifests.length > 0) {
|
|
82
|
+
throw new Error(
|
|
83
|
+
`archkeep: refusing to ${activity} for an Nx workspace where this plugin is not ` +
|
|
84
|
+
`registered but polyglot manifests exist under project roots ` +
|
|
85
|
+
`(${pluginGap.manifests.join(", ")}). The graph would carry no polyglot edges, so the ` +
|
|
86
|
+
`evidence would silently under-represent the real architecture. Register the plugin in ` +
|
|
87
|
+
`nx.json: "plugins": [{ "plugin": "@ecoma-io/archkeep/nx" }], or remove the polyglot ` +
|
|
88
|
+
`manifests if they are not in use.`,
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
const notAnalyzed = commandContext.analysis.failures.filter(isWholeFileFailure);
|
|
92
|
+
if (notAnalyzed.length > 0) {
|
|
93
|
+
throw new Error(
|
|
94
|
+
`archkeep: cannot ${activity} — ${notAnalyzed.length} file` +
|
|
95
|
+
`${notAnalyzed.length === 1 ? "" : "s"} could not be analyzed, so the evidence would ` +
|
|
96
|
+
`miss violations living there and a later classification would misread the gap as a ` +
|
|
97
|
+
`code change. Fix the unanalyzed files and re-run.`,
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Captures the current tree as a delta baseline: the evidence snapshot
|
|
104
|
+
* serialized, ready for a future `delta <base.json>` run to consume.
|
|
105
|
+
*
|
|
106
|
+
* @param {object} commandContext From `resolveCommandContext`.
|
|
107
|
+
* @param {{config: object|null}} io The resolved boundary config — required,
|
|
108
|
+
* because the snapshot's policy fingerprint is what lets a later run say
|
|
109
|
+
* loudly that the law moved.
|
|
110
|
+
* @returns {{snapshot: object, text: string}}
|
|
111
|
+
* @throws {Error} on an unjudgeable head (above) or a run with no boundary
|
|
112
|
+
* law — a baseline with no policy identity could never disclose a law
|
|
113
|
+
* change, which is the silent direction.
|
|
114
|
+
*/
|
|
115
|
+
export function captureDelta(commandContext, { config }) {
|
|
116
|
+
refuseUnjudgeableHead(commandContext, "capture a delta baseline");
|
|
117
|
+
if (!config) {
|
|
118
|
+
throw new Error(
|
|
119
|
+
"archkeep: cannot capture a delta baseline without a boundary config — the snapshot " +
|
|
120
|
+
"records the policy fingerprint so a later delta run can say loudly when the law moved, " +
|
|
121
|
+
"and a workspace that resolves no law leaves that claim unmakeable.",
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
const { root, provider, graph, analysis } = commandContext;
|
|
125
|
+
const snapshot = buildEvidenceSnapshot({
|
|
126
|
+
tool: { name: TOOL_NAME, version: TOOL_VERSION },
|
|
127
|
+
provenance: resolveProvenance(root),
|
|
128
|
+
provider,
|
|
129
|
+
policyFingerprint: computePolicyFingerprint(config),
|
|
130
|
+
coverage: {
|
|
131
|
+
// `refuseUnjudgeableHead` already threw on any whole-file failure, so
|
|
132
|
+
// the capture-side claim is honestly complete.
|
|
133
|
+
complete: true,
|
|
134
|
+
analyzedFiles: analysis.analyzed,
|
|
135
|
+
notAnalyzed: [],
|
|
136
|
+
blindSpots: analysis.failures
|
|
137
|
+
.filter((failure) => !isWholeFileFailure(failure))
|
|
138
|
+
.map(({ sourceFile, line, column, reason }) => ({
|
|
139
|
+
file: sourceFile,
|
|
140
|
+
line,
|
|
141
|
+
column,
|
|
142
|
+
reason,
|
|
143
|
+
})),
|
|
144
|
+
},
|
|
145
|
+
graph,
|
|
146
|
+
records: analysis.imports,
|
|
147
|
+
});
|
|
148
|
+
return { snapshot, text: serializeEvidenceSnapshot(snapshot) };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Rebuilds an engine-consumable `ProjectGraph` from a snapshot's stored graph.
|
|
153
|
+
*
|
|
154
|
+
* The snapshot stores what `graph --format json` publishes — a `projects`
|
|
155
|
+
* ARRAY and a flat `dependencies` array — while `../rules/index.mjs`'s
|
|
156
|
+
* `evaluate()` consumes Nx's shape: a `nodes` MAP keyed by name (each with
|
|
157
|
+
* `type` and `data.{root, tags, targets?}`) plus a source-keyed `dependencies`
|
|
158
|
+
* map. The conversion is exact where the snapshot kept the fact:
|
|
159
|
+
*
|
|
160
|
+
* - `name`/`root`/`type`/`tags` map straight back onto `data`;
|
|
161
|
+
* - the three rule-relevant extras the snapshot re-attached (`mfeRemote`,
|
|
162
|
+
* `entryPoints`, `declaredPackages`) go back onto `data` only when present —
|
|
163
|
+
* absence stays absence, because `evaluate()` treats an absent field as
|
|
164
|
+
* "declares none" and inventing an empty value would be a second copy of
|
|
165
|
+
* that answer (`./delta-snapshot.mjs`);
|
|
166
|
+
* - `targets` was stored as the NAMES alone, so each becomes `{}` in the
|
|
167
|
+
* rebuilt `data.targets` map. That preserves both reads the engine makes of
|
|
168
|
+
* it — `Object.keys` in the buildTargets guard, and
|
|
169
|
+
* `../rules/topology.mjs`'s `hasBuildExecutor`, whose
|
|
170
|
+
* `targets[t].executor !== ""` is true for `{}` — so a declared target
|
|
171
|
+
* stays a declared target; the executor STRING itself is the one fact the
|
|
172
|
+
* snapshot never held;
|
|
173
|
+
* - `workspaceLayout` and `exemptedFiles` ride the graph object exactly as
|
|
174
|
+
* the provider carried them, because `createContext` reads both off it.
|
|
175
|
+
*
|
|
176
|
+
* Every project name gets a `dependencies` entry — an empty array for a
|
|
177
|
+
* project with no outgoing edge — matching the shape every provider emits.
|
|
178
|
+
*
|
|
179
|
+
* A mis-shaped rebuild here is the silent direction in miniature: a base
|
|
180
|
+
* graph the engine reads as empty yields zero base violations, which
|
|
181
|
+
* classifies every standing violation as freshly introduced (loud but wrong)
|
|
182
|
+
* or — with the sides swapped — masks base violations entirely. The test
|
|
183
|
+
* beside this module holds the non-empty base-side re-judgment.
|
|
184
|
+
*
|
|
185
|
+
* @param {{projects: object[], dependencies: {source: string, target: string,
|
|
186
|
+
* type: string}[], workspaceLayout?: object, exemptedFiles?: string[]}} storedGraph
|
|
187
|
+
* A validated snapshot's `graph` section (`parseEvidenceSnapshot`).
|
|
188
|
+
* @returns {object} A graph `evaluate()` consumes.
|
|
189
|
+
*/
|
|
190
|
+
export function evidenceGraphToProjectGraph(storedGraph) {
|
|
191
|
+
/** @type {Record<string, object>} */
|
|
192
|
+
const nodes = {};
|
|
193
|
+
/** @type {Record<string, object[]>} */
|
|
194
|
+
const dependencies = {};
|
|
195
|
+
for (const project of storedGraph.projects) {
|
|
196
|
+
/** @type {Record<string, unknown>} */
|
|
197
|
+
const data = { root: project.root, tags: project.tags ?? [] };
|
|
198
|
+
if (Array.isArray(project.targets)) {
|
|
199
|
+
data.targets = Object.fromEntries(project.targets.map((target) => [target, {}]));
|
|
200
|
+
}
|
|
201
|
+
if (project.mfeRemote !== undefined) data.mfeRemote = project.mfeRemote;
|
|
202
|
+
if (Array.isArray(project.entryPoints)) data.entryPoints = project.entryPoints;
|
|
203
|
+
if (Array.isArray(project.declaredPackages)) data.declaredPackages = project.declaredPackages;
|
|
204
|
+
nodes[project.name] = { name: project.name, type: project.type, data };
|
|
205
|
+
dependencies[project.name] = [];
|
|
206
|
+
}
|
|
207
|
+
for (const edge of storedGraph.dependencies) {
|
|
208
|
+
if (!Array.isArray(dependencies[edge.source])) dependencies[edge.source] = [];
|
|
209
|
+
dependencies[edge.source].push({ source: edge.source, target: edge.target, type: edge.type });
|
|
210
|
+
}
|
|
211
|
+
/** @type {Record<string, unknown>} */
|
|
212
|
+
const graph = { nodes, dependencies };
|
|
213
|
+
if (storedGraph.workspaceLayout !== undefined)
|
|
214
|
+
graph.workspaceLayout = storedGraph.workspaceLayout;
|
|
215
|
+
if (Array.isArray(storedGraph.exemptedFiles)) graph.exemptedFiles = storedGraph.exemptedFiles;
|
|
216
|
+
return graph;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* A longest-root-prefix attributor for `classifyUnresolvableRecords`: the
|
|
221
|
+
* record's file is matched against project roots, head's first (the current
|
|
222
|
+
* model is the one both sides are judged under), then any baseline root the
|
|
223
|
+
* head does not already claim — so a base-side record living in a directory
|
|
224
|
+
* the head no longer has still attributes to the project that owned it.
|
|
225
|
+
*
|
|
226
|
+
* @param {object} headGraph The current run's graph (`nodes` map).
|
|
227
|
+
* @param {object[]} baselineProjects The snapshot's stored project rows.
|
|
228
|
+
* @returns {(record: object) => string|null}
|
|
229
|
+
*/
|
|
230
|
+
function sourceProjectAttributor(headGraph, baselineProjects) {
|
|
231
|
+
/** @type {Map<string, string>} root → project name, head winning ties. */
|
|
232
|
+
const byRoot = new Map();
|
|
233
|
+
for (const node of Object.values(headGraph.nodes ?? {})) {
|
|
234
|
+
const root = typeof node?.data?.root === "string" ? node.data.root : null;
|
|
235
|
+
if (root !== null && !byRoot.has(root)) byRoot.set(root, node.name);
|
|
236
|
+
}
|
|
237
|
+
for (const project of baselineProjects) {
|
|
238
|
+
if (typeof project.root === "string" && !byRoot.has(project.root)) {
|
|
239
|
+
byRoot.set(project.root, project.name);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
const entries = [...byRoot.entries()]
|
|
243
|
+
.map(([root, name]) => [root.replace(/\/+$/u, ""), name])
|
|
244
|
+
.sort((a, b) => b[0].length - a[0].length);
|
|
245
|
+
return (record) => {
|
|
246
|
+
const file = record?.sourceFile;
|
|
247
|
+
if (typeof file !== "string") return null;
|
|
248
|
+
for (const [root, name] of entries) {
|
|
249
|
+
if (root === "" || root === "." || file === root || file.startsWith(`${root}/`)) {
|
|
250
|
+
return /** @type {string} */ (name);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
return null;
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** First eight hex characters of a fingerprint, for prose that names one. */
|
|
258
|
+
const short = (fingerprint) =>
|
|
259
|
+
typeof fingerprint === "string" ? fingerprint.slice(0, 8) : String(fingerprint);
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Runs the `delta` compare mode: loads the baseline, re-judges both sides
|
|
263
|
+
* under the current law and one shared instant, classifies, and folds the
|
|
264
|
+
* classification into the verdict.
|
|
265
|
+
*
|
|
266
|
+
* The exit fold — the whole point of the command:
|
|
267
|
+
* - any `introduced` violation NOT covered by the current waiver table →
|
|
268
|
+
* `findings` (exit 1);
|
|
269
|
+
* - else any `unknown` entry, in either the violations or the unresolvable
|
|
270
|
+
* buckets → `no-verdict` (exit 3): an item the classifier could not place
|
|
271
|
+
* is a question this run could not answer, never a clean delta;
|
|
272
|
+
* - else `ok` (exit 0). Waived-introduced entries are REPORTED — waiving is
|
|
273
|
+
* a tracked acceptance, not a fix — but do not fail the gate, which is what
|
|
274
|
+
* a waiver is for.
|
|
275
|
+
*
|
|
276
|
+
* @param {string} baselinePath Absolute path to the evidence snapshot.
|
|
277
|
+
* @param {object} commandContext From `resolveCommandContext`.
|
|
278
|
+
* @param {{config: object|null, readBaseline?: (path: string) => object,
|
|
279
|
+
* now?: string}} io The resolved boundary config (required — both sides
|
|
280
|
+
* are re-judged under it), an injectable baseline reader, and the one
|
|
281
|
+
* shared reference instant (defaults to the shared governance clock).
|
|
282
|
+
* @returns {{status: "ok"|"findings"|"no-verdict", delta: object,
|
|
283
|
+
* coverage: object, report: {text: string, json: string}}}
|
|
284
|
+
* @throws {Error} on every refusal the module header lists.
|
|
285
|
+
*/
|
|
286
|
+
export function deltaCommand(
|
|
287
|
+
baselinePath,
|
|
288
|
+
commandContext,
|
|
289
|
+
{ config, readBaseline = readEvidenceSnapshot, now = referenceTime() },
|
|
290
|
+
) {
|
|
291
|
+
const { root, provider, marker, graph, analysis } = commandContext;
|
|
292
|
+
|
|
293
|
+
refuseUnjudgeableHead(commandContext, "compute a delta");
|
|
294
|
+
if (!config) {
|
|
295
|
+
throw new Error(
|
|
296
|
+
"archkeep: cannot compute a delta without a boundary config — both sides are re-judged " +
|
|
297
|
+
"under the current law, and a run that resolves no law has nothing to judge either side " +
|
|
298
|
+
"against.",
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const baseline = readBaseline(baselinePath);
|
|
303
|
+
|
|
304
|
+
// Provider mismatch is a REFUSAL here, deliberately stricter than `diff`'s
|
|
305
|
+
// note: `diff` describes structural difference, where a provider artefact is
|
|
306
|
+
// a caveat; `delta` asserts violation identity across the two sides, and an
|
|
307
|
+
// identity computed over two different project models is not evidence.
|
|
308
|
+
const mismatch = providerMismatch(baseline.provider, provider);
|
|
309
|
+
if (mismatch !== null) {
|
|
310
|
+
throw new Error(
|
|
311
|
+
`archkeep: refusing to compute a delta — ${mismatch}. Re-capture the baseline under ` +
|
|
312
|
+
`this run's provider.`,
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const baseGraph = evidenceGraphToProjectGraph(baseline.graph);
|
|
317
|
+
const configWithNow = { ...config, now };
|
|
318
|
+
// Both sides RAW (pre-suppression), through the same walk `waivers` reads:
|
|
319
|
+
// suppression must annotate the classification, never shrink either side —
|
|
320
|
+
// a suppressed-then-regressed violation has to stay visible
|
|
321
|
+
// (`./delta-classify.mjs`).
|
|
322
|
+
const baseViolations = evaluateRun(baseline.records, baseGraph, configWithNow).rawViolations;
|
|
323
|
+
const headViolations = evaluateRun(analysis.imports, graph, configWithNow).rawViolations;
|
|
324
|
+
|
|
325
|
+
const classification = classifyDelta({
|
|
326
|
+
baseViolations,
|
|
327
|
+
headViolations,
|
|
328
|
+
baseRecords: baseline.records,
|
|
329
|
+
headRecords: analysis.imports,
|
|
330
|
+
suppressions: config.suppressions ?? [],
|
|
331
|
+
now,
|
|
332
|
+
sourceProjectOf: sourceProjectAttributor(graph, baseline.graph.projects),
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
const headProvenance = resolveProvenance(root);
|
|
336
|
+
const headFingerprint = computePolicyFingerprint(config);
|
|
337
|
+
const meta = compareSnapshotMetadata({
|
|
338
|
+
baselineProvider: baseline.provider,
|
|
339
|
+
headProvider: provider,
|
|
340
|
+
baselineProvenance: baseline.provenance,
|
|
341
|
+
headProvenance,
|
|
342
|
+
baselineFingerprint: baseline.policyFingerprint,
|
|
343
|
+
headFingerprint,
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
const notes = [];
|
|
347
|
+
if (meta.policyChanged === true) {
|
|
348
|
+
notes.push(
|
|
349
|
+
`the boundary law changed since capture (baseline ${short(baseline.policyFingerprint)}…, ` +
|
|
350
|
+
`current ${short(headFingerprint)}…) — classifications reflect the current law applied ` +
|
|
351
|
+
`to both sides, so a violation a policy edit created or retired classifies as unchanged, ` +
|
|
352
|
+
`not as introduced or resolved`,
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
if (meta.crossRepo) {
|
|
356
|
+
notes.push(
|
|
357
|
+
`baseline provenance remote (${baseline.provenance.remote}) differs from head provenance ` +
|
|
358
|
+
`remote (${headProvenance?.remote}) — the delta may be across unrelated repositories ` +
|
|
359
|
+
`rather than two revisions of the same one`,
|
|
360
|
+
);
|
|
361
|
+
} else if (meta.provenanceOneSided) {
|
|
362
|
+
const side = baseline.provenance ? "head" : "baseline";
|
|
363
|
+
notes.push(
|
|
364
|
+
`the ${side} carries no provenance — the delta cannot verify it compares two revisions ` +
|
|
365
|
+
`of the same repository`,
|
|
366
|
+
);
|
|
367
|
+
}
|
|
368
|
+
if (meta.dirtyBaseline) {
|
|
369
|
+
notes.push(
|
|
370
|
+
"the baseline was captured from a dirty working tree — its evidence is not a reproducible " +
|
|
371
|
+
"claim about the commit it names",
|
|
372
|
+
);
|
|
373
|
+
}
|
|
374
|
+
if (meta.dirtyHead) {
|
|
375
|
+
notes.push(
|
|
376
|
+
"this run's working tree is dirty — the head side describes uncommitted state, not the " +
|
|
377
|
+
"commit HEAD names",
|
|
378
|
+
);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const { violations, unresolvable } = classification;
|
|
382
|
+
const introducedWaived = violations.introduced.filter((entry) => entry.waived === true).length;
|
|
383
|
+
const introducedNotWaived = violations.introduced.length - introducedWaived;
|
|
384
|
+
const unknownCount = violations.unknown.length + unresolvable.unknown.length;
|
|
385
|
+
|
|
386
|
+
/** @type {"ok"|"findings"|"no-verdict"} */
|
|
387
|
+
let status;
|
|
388
|
+
/** @type {0|1|3} */
|
|
389
|
+
let exitCode;
|
|
390
|
+
let decision;
|
|
391
|
+
if (introducedNotWaived > 0) {
|
|
392
|
+
status = "findings";
|
|
393
|
+
exitCode = 1;
|
|
394
|
+
decision = buildDecision({
|
|
395
|
+
status,
|
|
396
|
+
coverageComplete: true,
|
|
397
|
+
findings: introducedNotWaived,
|
|
398
|
+
});
|
|
399
|
+
} else if (unknownCount > 0) {
|
|
400
|
+
status = "no-verdict";
|
|
401
|
+
exitCode = 3;
|
|
402
|
+
decision = buildDecision({
|
|
403
|
+
status,
|
|
404
|
+
coverageComplete: true,
|
|
405
|
+
findings: 0,
|
|
406
|
+
reason:
|
|
407
|
+
`${unknownCount} delta item${unknownCount === 1 ? "" : "s"} could not be classified — ` +
|
|
408
|
+
`an item whose identity cannot be stated is never guessed into a bucket`,
|
|
409
|
+
});
|
|
410
|
+
} else {
|
|
411
|
+
status = "ok";
|
|
412
|
+
exitCode = 0;
|
|
413
|
+
decision = buildDecision({ status, coverageComplete: true, findings: 0 });
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
const coverage = {
|
|
417
|
+
complete: true,
|
|
418
|
+
projects: Object.keys(graph.nodes).length,
|
|
419
|
+
analyzedFiles: analysis.analyzed,
|
|
420
|
+
imports: analysis.imports.length,
|
|
421
|
+
notAnalyzed: [],
|
|
422
|
+
blindSpots: analysis.failures
|
|
423
|
+
.filter((failure) => !isWholeFileFailure(failure))
|
|
424
|
+
.map(({ sourceFile, line, column, reason }) => ({ file: sourceFile, line, column, reason })),
|
|
425
|
+
notes,
|
|
426
|
+
};
|
|
427
|
+
|
|
428
|
+
const result = {
|
|
429
|
+
baseline: {
|
|
430
|
+
path: baselinePath,
|
|
431
|
+
tool: baseline.tool,
|
|
432
|
+
provider: baseline.provider,
|
|
433
|
+
provenance: baseline.provenance,
|
|
434
|
+
policyFingerprint: baseline.policyFingerprint,
|
|
435
|
+
records: baseline.records.length,
|
|
436
|
+
projects: baseline.graph.projects.length,
|
|
437
|
+
},
|
|
438
|
+
head: {
|
|
439
|
+
provenance: headProvenance,
|
|
440
|
+
policyFingerprint: headFingerprint,
|
|
441
|
+
records: analysis.imports.length,
|
|
442
|
+
projects: Object.keys(graph.nodes).length,
|
|
443
|
+
},
|
|
444
|
+
policyChanged: meta.policyChanged,
|
|
445
|
+
summary: {
|
|
446
|
+
introduced: violations.introduced.length,
|
|
447
|
+
introducedWaived,
|
|
448
|
+
resolved: violations.resolved.length,
|
|
449
|
+
unchanged: violations.unchanged.length,
|
|
450
|
+
unknown: violations.unknown.length,
|
|
451
|
+
unresolvable: {
|
|
452
|
+
introduced: unresolvable.introduced.length,
|
|
453
|
+
resolved: unresolvable.resolved.length,
|
|
454
|
+
unchanged: unresolvable.unchanged.length,
|
|
455
|
+
unknown: unresolvable.unknown.length,
|
|
456
|
+
},
|
|
457
|
+
},
|
|
458
|
+
violations,
|
|
459
|
+
unresolvable,
|
|
460
|
+
};
|
|
461
|
+
|
|
462
|
+
const envelope = jsonEnvelope({
|
|
463
|
+
command: "delta",
|
|
464
|
+
context: { root, provider, marker, provenance: headProvenance },
|
|
465
|
+
status,
|
|
466
|
+
exitCode,
|
|
467
|
+
coverage,
|
|
468
|
+
result,
|
|
469
|
+
decision,
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
return {
|
|
473
|
+
status,
|
|
474
|
+
delta: result,
|
|
475
|
+
coverage,
|
|
476
|
+
report: {
|
|
477
|
+
text: formatDeltaReport({ delta: result, coverage }),
|
|
478
|
+
json: renderJson(envelope),
|
|
479
|
+
},
|
|
480
|
+
};
|
|
481
|
+
}
|
package/src/commands/explain.mjs
CHANGED
|
@@ -15,6 +15,29 @@
|
|
|
15
15
|
* build a correct envelope. It does not print, and it does not decide the
|
|
16
16
|
* process's exit code — `../../cli.mjs` owns those (`./README.md`).
|
|
17
17
|
*
|
|
18
|
+
* ## The site verdict, and the two guaranteed violation keys
|
|
19
|
+
*
|
|
20
|
+
* `result.verdict` names the judgment for the ONE site being explained —
|
|
21
|
+
* `"violation"`, `"clean"`, or `"unknown"` for an unresolvable site. It lives
|
|
22
|
+
* inside `result`, never as an envelope-level `decision`: the envelope's
|
|
23
|
+
* decision block must agree with `status`/`exitCode` (`../report/json.mjs`),
|
|
24
|
+
* and explain is descriptive — a violating site is still exit 0, so a
|
|
25
|
+
* site-level verdict is a different tier of fact from a run-level one.
|
|
26
|
+
*
|
|
27
|
+
* Every violation entry carries two guaranteed keys beside
|
|
28
|
+
* `messageId`/`message`/`constraint`:
|
|
29
|
+
*
|
|
30
|
+
* - `remediation` — the author-declared `remediation` string from the
|
|
31
|
+
* governing constraint row, verbatim, or `null` when the workspace declared
|
|
32
|
+
* none. NEVER text this engine composed: Archkeep supplies evidence and the
|
|
33
|
+
* consumer decides (`../../../../docs/doctrine/architecture-authority.md`).
|
|
34
|
+
* - `allowed` — the governing row's own `onlyDependOnLibsWithTags` list,
|
|
35
|
+
* verbatim from the law, or `null` when the row states no allowed list
|
|
36
|
+
* (a `notDependOnLibsWithTags` row, or a check no row drives). A complement
|
|
37
|
+
* computed from a ban list would be the engine inventing a direction the
|
|
38
|
+
* law never stated, so `null` plus the `constraint` row itself is the
|
|
39
|
+
* honest answer there.
|
|
40
|
+
*
|
|
18
41
|
* ## The unregistered-plugin refusal
|
|
19
42
|
*
|
|
20
43
|
* Same as `graph`: on an Nx workspace whose `nx.json` does not register this
|
|
@@ -195,6 +218,7 @@ export function explainCommand(site, commandContext, config) {
|
|
|
195
218
|
targetTags: [],
|
|
196
219
|
matchedConstraints: [],
|
|
197
220
|
violations: null,
|
|
221
|
+
verdict: "unknown",
|
|
198
222
|
unresolvable: true,
|
|
199
223
|
reason: siteFailure.reason,
|
|
200
224
|
};
|
|
@@ -219,6 +243,7 @@ export function explainCommand(site, commandContext, config) {
|
|
|
219
243
|
|
|
220
244
|
const result = {
|
|
221
245
|
site: explanation.site,
|
|
246
|
+
verdict: "unknown",
|
|
222
247
|
unresolvable: true,
|
|
223
248
|
reason: siteFailure.reason,
|
|
224
249
|
};
|
|
@@ -291,13 +316,25 @@ export function explainCommand(site, commandContext, config) {
|
|
|
291
316
|
// and `noTransitiveDependencies` can fire together. An agent seeing only
|
|
292
317
|
// the first might fix it and be confused when `check` still fails. Return
|
|
293
318
|
// all of them so the consumer sees the complete picture.
|
|
319
|
+
//
|
|
320
|
+
// `remediation` and `allowed` are guaranteed keys (this file's header):
|
|
321
|
+
// both are read verbatim off the governing constraint row, and both are
|
|
322
|
+
// an explicit `null` — never absent — when the row does not state them,
|
|
323
|
+
// so a consumer can tell "no declared remediation" from a field that
|
|
324
|
+
// does not exist yet.
|
|
294
325
|
violations = siteViolations.map((v) => ({
|
|
295
326
|
messageId: v.messageId,
|
|
296
327
|
message: v.message,
|
|
297
328
|
constraint: v.constraint,
|
|
329
|
+
remediation: typeof v.constraint?.remediation === "string" ? v.constraint.remediation : null,
|
|
330
|
+
allowed: Array.isArray(v.constraint?.onlyDependOnLibsWithTags)
|
|
331
|
+
? v.constraint.onlyDependOnLibsWithTags
|
|
332
|
+
: null,
|
|
298
333
|
}));
|
|
299
334
|
}
|
|
300
335
|
|
|
336
|
+
const verdict = violations === null ? "clean" : "violation";
|
|
337
|
+
|
|
301
338
|
const explanation = {
|
|
302
339
|
site: { file: parsed.sourceFile, line: parsed.line, column: parsed.column },
|
|
303
340
|
import: {
|
|
@@ -312,6 +349,7 @@ export function explainCommand(site, commandContext, config) {
|
|
|
312
349
|
targetTags,
|
|
313
350
|
matchedConstraints,
|
|
314
351
|
violations,
|
|
352
|
+
verdict,
|
|
315
353
|
unresolvable: false,
|
|
316
354
|
reason: null,
|
|
317
355
|
};
|
|
@@ -336,6 +374,7 @@ export function explainCommand(site, commandContext, config) {
|
|
|
336
374
|
targetTags,
|
|
337
375
|
matchedConstraints,
|
|
338
376
|
violations,
|
|
377
|
+
verdict,
|
|
339
378
|
};
|
|
340
379
|
|
|
341
380
|
const envelope = jsonEnvelope({
|
package/src/commands/policy.mjs
CHANGED
|
@@ -85,7 +85,7 @@ export function hasProfiles(options) {
|
|
|
85
85
|
* @param {string} cwd The process's working directory a relative `--config`
|
|
86
86
|
* resolves against — kept separate from the workspace root for the reason
|
|
87
87
|
* above.
|
|
88
|
-
* @returns {Promise<{config: {depConstraints: object[], options: object, suppressions: object[], fitness?: object[], customRules?: object[], notes?: string[]}|null, profile: string|null, source: string|null}>}
|
|
88
|
+
* @returns {Promise<{config: {depConstraints: object[], options: object, suppressions: object[], fitness?: object[], customRules?: object[], coverage?: object, notes?: string[]}|null, profile: string|null, source: string|null}>}
|
|
89
89
|
* `fitness` and `customRules` are present only when the resolved policy
|
|
90
90
|
* declares them — an absent key is the workspace's decision not to declare
|
|
91
91
|
* that law, never an empty one (`../config.mjs`'s `policyFrom`).
|
|
@@ -94,6 +94,41 @@ export function hasProfiles(options) {
|
|
|
94
94
|
* mode, unchanged by the extraction.
|
|
95
95
|
*/
|
|
96
96
|
export async function resolvePolicy(options, commandContext, cwd) {
|
|
97
|
+
const resolved = await resolvePolicyArm(options, commandContext, cwd);
|
|
98
|
+
// The policy's `coverage` key (unowned-file acceptances,
|
|
99
|
+
// `../config.mjs`'s `findCoverageViolations`) is an Nx/Moon channel: a
|
|
100
|
+
// native tree already records the identical decision on `archkeep.json`'s
|
|
101
|
+
// own `coverage.exempt` (`../providers/native/coverage.mjs`), and two
|
|
102
|
+
// channels for one decision on one tree is how copies drift. The inline
|
|
103
|
+
// spelling is refused at model load
|
|
104
|
+
// (`../providers/native/model.mjs`'s `findNativeModelViolations`); this is
|
|
105
|
+
// the file-dialect spelling of the same refusal, held here because the
|
|
106
|
+
// ladder below is the first point that knows both the provider and the
|
|
107
|
+
// loaded policy — and held for every command that reads a policy, so the
|
|
108
|
+
// editor-adjacent commands cannot accept a law `check` refuses.
|
|
109
|
+
if (resolved.config?.coverage !== undefined && commandContext.provider === "native") {
|
|
110
|
+
throw new Error(
|
|
111
|
+
`archkeep: ${resolved.source ?? "the boundary config"} declares 'coverage', but this ` +
|
|
112
|
+
`workspace's project model is ${ARCHKEEP_MODEL_FILE} — a native tree records ` +
|
|
113
|
+
`unowned-file acceptances on ${ARCHKEEP_MODEL_FILE}'s own coverage.exempt, and a second ` +
|
|
114
|
+
`channel for the same decision is a copy that will drift. Move the rows there and ` +
|
|
115
|
+
`delete the policy key.`,
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
return resolved;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* The four arms of the ladder, unguarded — `resolvePolicy` above wraps this
|
|
123
|
+
* with the one post-resolution refusal that needs both the provider and the
|
|
124
|
+
* loaded policy in hand.
|
|
125
|
+
*
|
|
126
|
+
* @param {{config: string|null}} options
|
|
127
|
+
* @param {object} commandContext
|
|
128
|
+
* @param {string} cwd
|
|
129
|
+
* @returns {Promise<{config: object|null, profile: string|null, source: string|null}>}
|
|
130
|
+
*/
|
|
131
|
+
async function resolvePolicyArm(options, commandContext, cwd) {
|
|
97
132
|
const { root } = commandContext;
|
|
98
133
|
if (hasProfiles(commandContext.options)) {
|
|
99
134
|
const profileName = String(options.config ?? commandContext.options.boundaryConfig);
|