@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.
@@ -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
+ }
@@ -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({
@@ -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);