@ecoma-io/archkeep 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (131) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +262 -0
  3. package/cli.mjs +2792 -0
  4. package/index.mjs +85 -0
  5. package/lsp.mjs +81 -0
  6. package/nx.mjs +24 -0
  7. package/package.json +81 -0
  8. package/presets/clean-architecture.json +78 -0
  9. package/presets/ddd-bounded-contexts.json +88 -0
  10. package/presets/hexagonal.json +68 -0
  11. package/presets/layered.json +92 -0
  12. package/presets/modular-monolith.json +85 -0
  13. package/presets/vertical-slice.json +68 -0
  14. package/src/analysis/analyze.mjs +218 -0
  15. package/src/analysis/contract.md +259 -0
  16. package/src/analysis/go.mjs +414 -0
  17. package/src/analysis/manifest-util.mjs +68 -0
  18. package/src/analysis/python.mjs +1266 -0
  19. package/src/analysis/registry.mjs +74 -0
  20. package/src/analysis/rust.mjs +674 -0
  21. package/src/analysis/source-util.mjs +230 -0
  22. package/src/analysis/typescript.mjs +1034 -0
  23. package/src/analysis/vue.mjs +156 -0
  24. package/src/architecture-intent/intent-fingerprint.mjs +29 -0
  25. package/src/architecture-intent/judge.mjs +539 -0
  26. package/src/architecture-intent/model.mjs +703 -0
  27. package/src/architecture-intent/selectors.mjs +170 -0
  28. package/src/canonical.mjs +48 -0
  29. package/src/commands/README.md +266 -0
  30. package/src/commands/adr.mjs +248 -0
  31. package/src/commands/check.mjs +989 -0
  32. package/src/commands/context-command.mjs +212 -0
  33. package/src/commands/context.mjs +790 -0
  34. package/src/commands/custom-rules.mjs +428 -0
  35. package/src/commands/debt.mjs +218 -0
  36. package/src/commands/diff.mjs +523 -0
  37. package/src/commands/discover.mjs +159 -0
  38. package/src/commands/drift.mjs +473 -0
  39. package/src/commands/edge-constraints.mjs +355 -0
  40. package/src/commands/explain.mjs +359 -0
  41. package/src/commands/fitness.mjs +226 -0
  42. package/src/commands/graph.mjs +297 -0
  43. package/src/commands/health.mjs +213 -0
  44. package/src/commands/history.mjs +614 -0
  45. package/src/commands/impact.mjs +226 -0
  46. package/src/commands/plan-context-command.mjs +496 -0
  47. package/src/commands/policy.mjs +138 -0
  48. package/src/commands/provenance-command.mjs +352 -0
  49. package/src/commands/provenance.mjs +159 -0
  50. package/src/commands/reconcile.mjs +219 -0
  51. package/src/commands/report.mjs +553 -0
  52. package/src/commands/snapshot-meta.mjs +107 -0
  53. package/src/commands/waivers.mjs +240 -0
  54. package/src/config.mjs +1308 -0
  55. package/src/containment.mjs +234 -0
  56. package/src/custom-rules/evidence.mjs +340 -0
  57. package/src/custom-rules/host.mjs +1023 -0
  58. package/src/custom-rules/values.mjs +43 -0
  59. package/src/entry-point.mjs +55 -0
  60. package/src/errors.mjs +36 -0
  61. package/src/eslint-config.mjs +542 -0
  62. package/src/go-work.mjs +394 -0
  63. package/src/governance/adr-registry.mjs +539 -0
  64. package/src/governance/clock.mjs +69 -0
  65. package/src/governance/debt-ledger.mjs +274 -0
  66. package/src/governance/discovery-proposal.mjs +423 -0
  67. package/src/governance/fitness-registry.mjs +504 -0
  68. package/src/governance/fitness-rules.mjs +668 -0
  69. package/src/governance/metrics.mjs +392 -0
  70. package/src/governance/preset-fingerprints.json +16 -0
  71. package/src/governance/profile-registry.mjs +366 -0
  72. package/src/governance/provenance-record.mjs +177 -0
  73. package/src/governance/reconcile-candidates.mjs +301 -0
  74. package/src/governance/reconcile-score.mjs +503 -0
  75. package/src/governance/row-schema.mjs +208 -0
  76. package/src/governance/verdict.mjs +127 -0
  77. package/src/governance/waiver.mjs +105 -0
  78. package/src/graph/create-dependencies.mjs +96 -0
  79. package/src/intent/intent-manifest.json +347 -0
  80. package/src/intent/mask-non-code.mjs +640 -0
  81. package/src/lsp/boundary-config.mjs +225 -0
  82. package/src/lsp/diagnose.mjs +202 -0
  83. package/src/lsp/diagnostics.mjs +241 -0
  84. package/src/lsp/protocol.mjs +215 -0
  85. package/src/lsp/server.mjs +922 -0
  86. package/src/lsp/workspace-index.mjs +891 -0
  87. package/src/nx-json.mjs +95 -0
  88. package/src/options.mjs +611 -0
  89. package/src/process.mjs +91 -0
  90. package/src/providers/moon.mjs +733 -0
  91. package/src/providers/native/README.md +204 -0
  92. package/src/providers/native/coverage.mjs +74 -0
  93. package/src/providers/native/differential.fixtures.mjs +1277 -0
  94. package/src/providers/native/discover.mjs +431 -0
  95. package/src/providers/native/graph.mjs +234 -0
  96. package/src/providers/native/index.mjs +152 -0
  97. package/src/providers/native/model.mjs +755 -0
  98. package/src/providers/nx.mjs +178 -0
  99. package/src/report/README.md +89 -0
  100. package/src/report/adr-text.mjs +129 -0
  101. package/src/report/context-text.mjs +109 -0
  102. package/src/report/debt-text.mjs +105 -0
  103. package/src/report/diff-text.mjs +219 -0
  104. package/src/report/discover-text.mjs +186 -0
  105. package/src/report/drift-text.mjs +194 -0
  106. package/src/report/envelope-shape.mjs +161 -0
  107. package/src/report/evidence.mjs +157 -0
  108. package/src/report/explain-text.mjs +159 -0
  109. package/src/report/graph-text.mjs +116 -0
  110. package/src/report/health-text.mjs +123 -0
  111. package/src/report/history-text.mjs +204 -0
  112. package/src/report/impact-text.mjs +128 -0
  113. package/src/report/json.mjs +173 -0
  114. package/src/report/plan-context-text.mjs +159 -0
  115. package/src/report/provenance-text.mjs +78 -0
  116. package/src/report/reconcile-text.mjs +159 -0
  117. package/src/report/report-text.mjs +264 -0
  118. package/src/report/sarif.mjs +953 -0
  119. package/src/report/text.mjs +823 -0
  120. package/src/report/waivers-text.mjs +100 -0
  121. package/src/rules/README.md +123 -0
  122. package/src/rules/index.mjs +962 -0
  123. package/src/rules/match.mjs +1708 -0
  124. package/src/rules/messages.mjs +73 -0
  125. package/src/rules/reachability.mjs +224 -0
  126. package/src/rules/specifiers.mjs +300 -0
  127. package/src/rules/tags.mjs +238 -0
  128. package/src/rules/topology.mjs +333 -0
  129. package/src/tsconfig-paths.mjs +237 -0
  130. package/src/verdict.mjs +145 -0
  131. package/src/workspace.mjs +580 -0
@@ -0,0 +1,473 @@
1
+ /**
2
+ * The `drift` command: the observed architecture compared against the declared
3
+ * intended one.
4
+ *
5
+ * Drift is a verdict, not a prediction: every finding names the intent row and
6
+ * the observed fact that violates it. The observed side is the same project
7
+ * graph `graph`/`diff`/`check` read, from any of the three providers — this
8
+ * module reads only the resolved `CommandContext`, never a provider, so the
9
+ * same intent produces the same verdict under Nx, Moon, or native. The
10
+ * intended side is the one canonical contract the workspace declares:
11
+ * `architecture-intent.json` at its root, the same file the `check` command
12
+ * loads and judges (`../architecture-intent/model.mjs` and `judge.mjs`). There
13
+ * is no parallel intent grammar and no `intentConfig` option — one contract,
14
+ * one judge, one fingerprint.
15
+ *
16
+ * ## Two faces, one verdict
17
+ *
18
+ * `drift` is descriptive, exactly like `diff`: it prints the intent, the
19
+ * findings, and the intent fingerprint, and it never exits 1. Only `check`
20
+ * exits 1, and `check` folds drift in by presence — when an intent file is
21
+ * present, `check` loads it and counts intent findings into its verdict (exit
22
+ * 1 on findings, 3 on a malformed intent, exactly like go.work drift). There
23
+ * is no `--drift` flag: an opt-in flag would make a forgotten flag
24
+ * byte-identical to "no drift checked", which is the silent direction this
25
+ * whole tool exists to end.
26
+ *
27
+ * ## Fail-closed
28
+ *
29
+ * This command refuses loudly on every path that cannot reach a verdict,
30
+ * mirroring `diff`'s refusals:
31
+ *
32
+ * - the intent file cannot be read or parsed (strict JSON, validated) → throw
33
+ * → exit 3;
34
+ * - the observed side is incomplete (`notAnalyzed` non-empty) → exit 3, the
35
+ * same reasoning as `diff` — every "project missing" would be ambiguous
36
+ * between "gone" and "never seen";
37
+ * - an Nx workspace has polyglot manifests but the plugin is not registered →
38
+ * exit 3, the same refusal `graph`/`diff` make;
39
+ * - a boundary or row side matched no observed project → exit 3, the same
40
+ * no-verdict `check` renders for the same state — "cannot verify" must never
41
+ * read as "no drift".
42
+ *
43
+ * An empty finding list must mean exactly "the observed architecture matches
44
+ * the intended one".
45
+ *
46
+ * ## decisionRef resolution is a separate, non-verdict axis
47
+ *
48
+ * An intent row's `decisionRef` names the ADR (or rule/fitness id) that
49
+ * supposedly authorizes it — unverified until now, because `resolveDecisionRef`
50
+ * (`../governance/adr-registry.mjs`) had no production caller anywhere in this
51
+ * package. `driftCommand` checks every row that carries one against the
52
+ * workspace's ADR registry (`readAdrContext`, `./adr.mjs`) and lists what does
53
+ * not resolve. This is a fact about the row's documentation, not about the
54
+ * architecture: it never becomes a finding and never changes the exit code —
55
+ * the same posture `./provenance-command.mjs` states for the identical axis,
56
+ * for the identical reason `hasOrigin` never does either.
57
+ *
58
+ * This axis is also the ONLY thing in `drift` that reads the boundary law, and
59
+ * it reads only the fitness ids that law DECLARES (`declaredFitnessNames`, F04)
60
+ * — so the dependence is conditional on an intent row actually carrying a
61
+ * `decisionRef`. `cli.mjs`'s `runDrift` therefore hands the policy load's
62
+ * failure over as `io.configError` rather than throwing it where it happens:
63
+ * a workspace with an intent and no boundary config used to exit 3 from a law
64
+ * `drift` would never have opened, which is a refusal the four above do not
65
+ * make and `../../../../docs/usage/drift.md` never documented. Deferred, not
66
+ * dropped — when a row DOES carry a `decisionRef` the error is thrown here and
67
+ * the exit-3 refusal is byte-identical to what it always was, because resolving
68
+ * citations against an empty declared-fitness set would report rows unresolved
69
+ * on the strength of a law nobody read.
70
+ *
71
+ * ## Determinism
72
+ *
73
+ * Findings are sorted by the judge's total key — plain string comparison
74
+ * everywhere, never `localeCompare` — so two runs over an unchanged tree and
75
+ * intent produce byte-identical text and JSON.
76
+ */
77
+ import { isWholeFileFailure } from "../analysis/source-util.mjs";
78
+ import { buildDependencies, buildProjects } from "./graph.mjs";
79
+ import { jsonEnvelope, renderJson } from "../report/json.mjs";
80
+ import { resolveProvenance } from "./provenance.mjs";
81
+ import { judgeIntent } from "../architecture-intent/judge.mjs";
82
+ import { computeIntentFingerprint } from "../architecture-intent/intent-fingerprint.mjs";
83
+ import { INTENT_FILE, loadIntent } from "../architecture-intent/model.mjs";
84
+ import { formatDriftReport } from "../report/drift-text.mjs";
85
+ import { readAdrContext } from "./adr.mjs";
86
+ import { intentRows as governanceIntentRows } from "./provenance-command.mjs";
87
+ import { declaredFitnessNames, unresolvedDecisionRefRows } from "../governance/adr-registry.mjs";
88
+
89
+ /**
90
+ * The observed side of the comparison: the same project model `graph` builds.
91
+ * Shared with `check` (which folds drift into its verdict by the same
92
+ * `buildProjects`/`buildDependencies` pair `graph` and `diff` use), so the
93
+ * descriptive command and the checker always compare the same observed facts.
94
+ *
95
+ * Edges whose target is not a project in the model are dropped, the same
96
+ * filter the native provider's `buildDependencies` applies
97
+ * (`./providers/native/graph.mjs`): an Nx graph can carry external edges
98
+ * (`app → npm:lodash`) whose targets live outside the project set, and drift
99
+ * judges the *architecture of the workspace's own projects* — an external
100
+ * package is not a project an intent row can ever name. Without the filter the
101
+ * two providers would disagree on the same tree, and content like an Nx
102
+ * consumer with an allowlist would report every external dependency as
103
+ * `dependencyNotAllowed`.
104
+ *
105
+ * `implicit` edges (build-ordering declarations, not code dependencies) are
106
+ * dropped the same way and counted separately — the report states what it
107
+ * excluded, and an empty finding list still means exactly "no drift among
108
+ * code-dependency edges" (`@nx/enforce-module-boundaries` ignores implicit
109
+ * edges too).
110
+ *
111
+ * @param {object} commandContext From `resolveCommandContext`.
112
+ * @returns {{projects: object[], edges: object[], implicitEdges: number}}
113
+ */
114
+ export function buildObserved(commandContext) {
115
+ const { graph } = commandContext;
116
+ const projects = buildProjects(graph.nodes);
117
+ const projectNames = new Set(projects.map((project) => project.name));
118
+ const edges = [];
119
+ let implicitEdges = 0;
120
+ for (const edge of buildDependencies(graph.dependencies)) {
121
+ if (edge.type === "implicit") {
122
+ implicitEdges += 1;
123
+ continue;
124
+ }
125
+ if (projectNames.has(edge.source) && projectNames.has(edge.target)) {
126
+ edges.push(edge);
127
+ }
128
+ }
129
+ return { projects, edges, implicitEdges };
130
+ }
131
+
132
+ /**
133
+ * Refuses a drift verdict over a graph known to be incomplete, the same
134
+ * fail-closed condition `graph`/`diff`/`impact`/`explain` share. On an Nx
135
+ * workspace whose `nx.json` does not register this plugin but whose tracked
136
+ * files include polyglot manifests under project roots, the graph carries no
137
+ * polyglot edges — every "project missing" and every absent forbidden edge
138
+ * would be ambiguous between "the architecture changed" and "never seen".
139
+ *
140
+ * @param {object} commandContext From `resolveCommandContext`.
141
+ * @param {string} [what] What the caller was doing, named in the refusal — the
142
+ * default "judge drift" reads true for every caller that reaches the shared
143
+ * guard (`graph`, `diff`, `impact`, `explain`, `fitness`, and now `waivers`),
144
+ * so each command's refusal names its own question rather than every one of
145
+ * them claiming to be a drift verdict.
146
+ * @throws {Error} on the unregistered-plugin over polyglot manifests.
147
+ */
148
+ export function refuseIncompleteGraph(commandContext, what = "judge drift") {
149
+ const { provider, pluginGap } = commandContext;
150
+ if (provider === "nx" && !pluginGap.registered && pluginGap.manifests.length > 0) {
151
+ throw new Error(
152
+ `archkeep: refusing to ${what} for an Nx workspace where this plugin is not ` +
153
+ `registered but polyglot manifests exist under project roots ` +
154
+ `(${pluginGap.manifests.join(", ")}). The graph would carry no polyglot edges, so a ${what} ` +
155
+ `verdict would silently under-represent the real architecture. Register the plugin in ` +
156
+ `nx.json: "plugins": [{ "plugin": "@ecoma-io/archkeep/nx" }], or remove the polyglot ` +
157
+ `manifests if they are not in use.`,
158
+ );
159
+ }
160
+ }
161
+
162
+ /**
163
+ * The drift verdict for `check`'s fold — the intent loaded, the observed side
164
+ * compared, the findings counted. `check` and `../commands/plan-context-command.mjs`
165
+ * call this only when the workspace actually HAS an intent file, but that is
166
+ * their own choice, not a precondition this function requires: absence of an
167
+ * intent is a workspace choice, not a finding (`../../../../AGENTS.md`: an
168
+ * empty result must mean exactly "no drift"), so `intent === undefined` (no
169
+ * tracked architecture-intent.json) returns a quiet result — `intent:
170
+ * undefined` on the return, every list empty — instead of judging one, rather
171
+ * than reaching `judgeIntent`, which assumes a normalized model and has no
172
+ * absent case of its own. That is what lets `../commands/fitness.mjs`'s
173
+ * `fitnessCommand` call this UNCONDITIONALLY: it must still reach the
174
+ * `refuseIncompleteGraph` guard below whether or not intent is declared, so an
175
+ * Nx workspace with an unregistered plugin over polyglot manifests refuses
176
+ * loudly regardless. Every OTHER fail-closed condition — that same
177
+ * unregistered-plugin refusal, an unreadable or invalid intent — still throws,
178
+ * so `check` turns a malformed intent into exit 3 exactly like a malformed
179
+ * go.work.
180
+ *
181
+ * @param {object} commandContext From `resolveCommandContext`.
182
+ * @param {{loadIntentOverride?: (root: string) => Promise<object>}} [io]
183
+ * @returns {Promise<{intent: {file: string, fingerprint: string, rows: number}|undefined,
184
+ * observed: {projects: number, edges: number, implicitEdges: number},
185
+ * findings: object[], unresolved: object[], boundaries: object[],
186
+ * notes: string[], decisionRefRows: {kind: string, row: object}[]}>} `intent`
187
+ * is `undefined` exactly when no architecture-intent.json is tracked — a
188
+ * caller building its own verdict-shaped intent reads that as "no intent
189
+ * declared", never as "declared and clean". `decisionRefRows` lists every
190
+ * intent row carrying a `decisionRef`, for `check`'s own citation pass.
191
+ */
192
+ export async function driftForCheck(commandContext, io = {}) {
193
+ refuseIncompleteGraph(commandContext);
194
+ const intent = await (io.loadIntentOverride ?? loadIntent)(commandContext.root, {
195
+ tracked: commandContext.tracked,
196
+ });
197
+ const observed = buildObserved(commandContext);
198
+ const observedSummary = {
199
+ projects: observed.projects.length,
200
+ edges: observed.edges.length,
201
+ implicitEdges: observed.implicitEdges,
202
+ };
203
+ if (intent === undefined) {
204
+ return {
205
+ intent: undefined,
206
+ observed: observedSummary,
207
+ findings: [],
208
+ unresolved: [],
209
+ boundaries: [],
210
+ notes: [],
211
+ decisionRefRows: [],
212
+ };
213
+ }
214
+ const verdict = judgeIntent(intent, {
215
+ nodes: commandContext.graph.nodes,
216
+ dependencies: commandContext.graph.dependencies,
217
+ });
218
+ return {
219
+ intent: {
220
+ file: INTENT_FILE,
221
+ fingerprint: computeIntentFingerprint(intent),
222
+ rows: intentRows(intent),
223
+ },
224
+ observed: observedSummary,
225
+ findings: verdict.findings,
226
+ unresolved: verdict.unresolved,
227
+ boundaries: verdict.boundaries,
228
+ notes: verdict.notes,
229
+ // The intent rows that carry a `decisionRef` — surfaced for `check`'s own
230
+ // citation pass (F01) so the gate judges intent citations through the SAME
231
+ // registry as `depConstraints` citations, instead of a second ADR read
232
+ // with a second opinion about the same rows.
233
+ decisionRefRows: intentDecisionRefRows(intent),
234
+ };
235
+ }
236
+
237
+ /**
238
+ * Every intent row that carries a `decisionRef` — the subset `check` folds
239
+ * into its own citation check. The intent model itself stays inside
240
+ * `driftForCheck` (the fold needs only the judge's verdict), so the rows that
241
+ * would have been visible to a resolution pass are surfaced here, additively:
242
+ * absent when the workspace declared no intent, empty when no row carries a
243
+ * `decisionRef`. This is what lets `check` judge intent citations through the
244
+ * SAME registry pass as `depConstraints` citations instead of a second ADR
245
+ * read with a second opinion about the same rows.
246
+ *
247
+ * @param {object} intent The normalized intent model.
248
+ * @returns {{kind: string, row: object}[]}
249
+ */
250
+ export function intentDecisionRefRows(intent) {
251
+ return governanceIntentRows(intent).filter(
252
+ ({ row }) => typeof row?.decisionRef === "string" && row.decisionRef.trim() !== "",
253
+ );
254
+ }
255
+
256
+ /**
257
+ * The number of intent rows the judge's verdict is a claim about — boundaries,
258
+ * allowed/forbidden boundary rows, project requirements, dependency rows, and
259
+ * tag rows. `check` and the descriptive command both report it so "no drift"
260
+ * always reads as a claim about a specific contract.
261
+ *
262
+ * @param {object} intent The normalized intent model.
263
+ * @returns {number}
264
+ */
265
+ function intentRows(intent) {
266
+ return (
267
+ (intent.boundaries?.length ?? 0) +
268
+ (intent.allowed?.length ?? 0) +
269
+ (intent.forbidden?.length ?? 0) +
270
+ (intent.projects?.required?.length ?? 0) +
271
+ (intent.projects?.forbidden?.length ?? 0) +
272
+ (intent.dependencies?.allowed?.length ?? 0) +
273
+ (intent.dependencies?.forbidden?.length ?? 0) +
274
+ (intent.forbiddenTags?.length ?? 0)
275
+ );
276
+ }
277
+
278
+ /**
279
+ * Runs the `drift` command: loads the intent, refuses incomplete coverage,
280
+ * and computes the verdict.
281
+ *
282
+ * @param {object} commandContext From `resolveCommandContext`.
283
+ * @param {{loadIntentOverride?: (root: string) => Promise<object>,
284
+ * config?: object|null, configError?: Error|null,
285
+ * readAdrContextOverride?: typeof import("./adr.mjs").readAdrContext,
286
+ * loadAdrRegistryOverride?: typeof import("../governance/adr-registry.mjs").loadAdrRegistry}} [io]
287
+ * Injectable intent loader for tests. `readAdrContextOverride` (or the
288
+ * narrower `loadAdrRegistryOverride`, forwarded to the real
289
+ * `readAdrContext`) stands in for the ADR registry read that resolves each
290
+ * row's `decisionRef`. `configError` carries a boundary-policy load failure
291
+ * the caller chose not to throw at the load site — rethrown here, unchanged,
292
+ * only if an intent row actually cites something.
293
+ * @returns {Promise<{status: "ok", drift: object, coverage: object,
294
+ * report: {text: string, json: string}}>}
295
+ * @throws {Error} on every condition the header lists, all exit-3 class, plus
296
+ * a malformed ADR registry — the same loud refusal `provenance` makes for
297
+ * the identical read.
298
+ */
299
+ export async function driftCommand(commandContext, io = {}) {
300
+ const { root, provider, marker, analysis } = commandContext;
301
+
302
+ refuseIncompleteGraph(commandContext);
303
+
304
+ // A drift verdict cannot be established over a tree it could not fully read.
305
+ const notAnalyzed = analysis.failures
306
+ .filter(isWholeFileFailure)
307
+ .map(({ sourceFile, reason }) => ({ file: sourceFile, reason }));
308
+
309
+ if (notAnalyzed.length > 0) {
310
+ throw new Error(
311
+ `archkeep: drift has incomplete coverage — ${notAnalyzed.length} file` +
312
+ `${notAnalyzed.length === 1 ? "" : "s"} could not be analyzed, so every "project missing" ` +
313
+ `would be ambiguous between "gone" and "never seen". Fix the unanalyzed files and re-run.`,
314
+ );
315
+ }
316
+
317
+ const intent = await (io.loadIntentOverride ?? loadIntent)(root, {
318
+ tracked: commandContext.tracked,
319
+ });
320
+ if (intent === undefined) {
321
+ throw new Error(
322
+ `archkeep: drift requires a tracked ${INTENT_FILE} at the workspace root, but none ` +
323
+ `is present — a workspace without an intended architecture cannot be judged for drift`,
324
+ );
325
+ }
326
+ const observed = buildObserved(commandContext);
327
+ const verdict = judgeIntent(intent, {
328
+ nodes: commandContext.graph.nodes,
329
+ dependencies: commandContext.graph.dependencies,
330
+ });
331
+
332
+ // An intent whose boundary or row side matched no observed project reached no
333
+ // verdict on that row — "no drift" must never mean "cannot verify". `check`
334
+ // renders the same state exit 3; the descriptive command must refuse loudly
335
+ // rather than print "✔ no drift".
336
+ if (verdict.unresolved.length > 0) {
337
+ throw new Error(
338
+ `archkeep: cannot compare the observed architecture to ${INTENT_FILE} — ` +
339
+ verdict.unresolved
340
+ .map(({ boundary, issue }) => `boundary/row ${boundary}: ${issue}`)
341
+ .join("; ") +
342
+ `. An intent that cannot be verified is not a clean one; fix the intent or the graph and re-run.`,
343
+ );
344
+ }
345
+
346
+ // A row's `decisionRef` names the ADR (or rule/fitness id) that supposedly
347
+ // authorizes it — unverified until now, the same gap `provenance` closes
348
+ // for the identical rows through the identical `readAdrContext`/
349
+ // `unresolvedDecisionRefRows` (`../governance/adr-registry.mjs`). This is a
350
+ // documentation fact about the row, not about the architecture: it changes
351
+ // no finding and no exit code, the same "descriptive, never a verdict"
352
+ // posture `provenance` states for the exact same axis. Only the rows that
353
+ // actually carry one are checked; a workspace that never uses the field
354
+ // pays no extra read.
355
+ const decisionRefRows = governanceIntentRows(intent).filter(
356
+ ({ row }) => typeof row?.decisionRef === "string" && row.decisionRef.trim() !== "",
357
+ );
358
+ let unresolvedDecisionRefs = [];
359
+ if (decisionRefRows.length > 0) {
360
+ // The deferred policy-load failure, thrown at the one place the policy is
361
+ // actually read (see the header's decisionRef section). `knownFitness`
362
+ // below would otherwise be derived from a law that failed to load, and
363
+ // every fitness-id citation would report unresolved on that basis — a
364
+ // loud wrong answer standing in for a refusal.
365
+ if (io.configError) throw io.configError;
366
+ const adrContext = (io.readAdrContextOverride ?? readAdrContext)(root, {
367
+ tracked: commandContext.tracked,
368
+ loadAdrRegistryOverride: io.loadAdrRegistryOverride,
369
+ });
370
+ // F04: the fitness half resolves against the ids the workspace's policy
371
+ // DECLARES (`declaredFitnessNames`, the `io.config` the caller holds),
372
+ // never the ADRs' own `bindings` — a citation cannot resolve itself.
373
+ unresolvedDecisionRefs = unresolvedDecisionRefRows(
374
+ decisionRefRows,
375
+ adrContext.byId,
376
+ declaredFitnessNames(io.config),
377
+ ).map(({ kind, decisionRef }) => ({ kind, decisionRef }));
378
+ }
379
+
380
+ // A deferred policy failure that never had to be thrown is still a thing this
381
+ // run noticed and would otherwise say nothing about. It changes no finding —
382
+ // no row cited anything, so the law was never consulted — but "noticed and
383
+ // silent" is the posture this tool exists to refuse, so it rides the same
384
+ // coverage notes an `optional: true` row does, in both faces.
385
+ const notes = [...verdict.notes];
386
+ if (io.configError && decisionRefRows.length === 0) {
387
+ notes.push(
388
+ `the workspace's boundary law could not be loaded (${io.configError.message}) — no intent ` +
389
+ `row carries a decisionRef, so nothing in this verdict was judged against it`,
390
+ );
391
+ }
392
+
393
+ const coverage = {
394
+ complete: true,
395
+ projects: observed.projects.length,
396
+ analyzedFiles: analysis.analyzed,
397
+ imports: analysis.imports.length,
398
+ notAnalyzed: [],
399
+ // Drift reads only the graph — provider failures are the same blind spots
400
+ // every other command reports, and a blind spot never prevents a verdict.
401
+ blindSpots: analysis.failures
402
+ .filter((failure) => !isWholeFileFailure(failure))
403
+ .map(({ sourceFile, line, column, reason }) => ({ file: sourceFile, line, column, reason })),
404
+ // Coverage notes (e.g. an `optional: true` allowed row the team has not
405
+ // built yet) ride here so "optional and absent" never reads as "never
406
+ // checked".
407
+ notes,
408
+ };
409
+
410
+ const context = { root, provider, marker, provenance: resolveProvenance(root) };
411
+ const result = {
412
+ intent: {
413
+ file: INTENT_FILE,
414
+ fingerprint: computeIntentFingerprint(intent),
415
+ rows: intentRows(intent),
416
+ },
417
+ observed: {
418
+ projects: observed.projects.length,
419
+ edges: observed.edges.length,
420
+ implicitEdges: observed.implicitEdges,
421
+ },
422
+ findings: verdict.findings,
423
+ // Additive and optional: absent when every intent row's decisionRef
424
+ // resolves (or none carries one) — a workspace that never uses the field
425
+ // reads exactly as it did before this axis existed. A documentation fact
426
+ // about the rows, never a drift finding: `verdict.findings` above is the
427
+ // only thing this envelope's `status`/`exitCode` are a claim about.
428
+ ...(unresolvedDecisionRefs.length > 0 ? { unresolvedDecisionRefs } : {}),
429
+ };
430
+
431
+ // Drift is descriptive — always status "ok" when it completes, never
432
+ // "findings". A description of what drifts is not itself a finding; only
433
+ // `check` exits 1.
434
+ const status = "ok";
435
+ const exitCode = 0;
436
+
437
+ const envelope = jsonEnvelope({
438
+ command: "drift",
439
+ context,
440
+ status,
441
+ exitCode,
442
+ coverage,
443
+ result,
444
+ });
445
+
446
+ return {
447
+ status,
448
+ drift: result,
449
+ coverage,
450
+ report: {
451
+ text: formatDriftReport({
452
+ findings: verdict.findings,
453
+ intent: { fingerprint: result.intent.fingerprint, rows: result.intent.rows },
454
+ unresolvedDecisionRefs,
455
+ decisionRefsChecked: decisionRefRows.length,
456
+ observed: {
457
+ projects: result.observed.projects,
458
+ edges: result.observed.edges,
459
+ implicitEdges: result.observed.implicitEdges,
460
+ },
461
+ // Coverage notes (e.g. an `optional: true` allowed row not yet
462
+ // built) already ride `coverage.notes` above for the JSON envelope —
463
+ // this is the SAME list reaching the text face too, so a warning that
464
+ // exists in the coverage object does not stop at the reader who only
465
+ // sees the terminal report. It is `notes`, not `verdict.notes`,
466
+ // precisely so the deferred-policy note above cannot reach one face
467
+ // and not the other.
468
+ notes,
469
+ }),
470
+ json: renderJson(envelope),
471
+ },
472
+ };
473
+ }