@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.
- package/LICENSE +202 -0
- package/README.md +262 -0
- package/cli.mjs +2792 -0
- package/index.mjs +85 -0
- package/lsp.mjs +81 -0
- package/nx.mjs +24 -0
- package/package.json +81 -0
- package/presets/clean-architecture.json +78 -0
- package/presets/ddd-bounded-contexts.json +88 -0
- package/presets/hexagonal.json +68 -0
- package/presets/layered.json +92 -0
- package/presets/modular-monolith.json +85 -0
- package/presets/vertical-slice.json +68 -0
- package/src/analysis/analyze.mjs +218 -0
- package/src/analysis/contract.md +259 -0
- package/src/analysis/go.mjs +414 -0
- package/src/analysis/manifest-util.mjs +68 -0
- package/src/analysis/python.mjs +1266 -0
- package/src/analysis/registry.mjs +74 -0
- package/src/analysis/rust.mjs +674 -0
- package/src/analysis/source-util.mjs +230 -0
- package/src/analysis/typescript.mjs +1034 -0
- package/src/analysis/vue.mjs +156 -0
- package/src/architecture-intent/intent-fingerprint.mjs +29 -0
- package/src/architecture-intent/judge.mjs +539 -0
- package/src/architecture-intent/model.mjs +703 -0
- package/src/architecture-intent/selectors.mjs +170 -0
- package/src/canonical.mjs +48 -0
- package/src/commands/README.md +266 -0
- package/src/commands/adr.mjs +248 -0
- package/src/commands/check.mjs +989 -0
- package/src/commands/context-command.mjs +212 -0
- package/src/commands/context.mjs +790 -0
- package/src/commands/custom-rules.mjs +428 -0
- package/src/commands/debt.mjs +218 -0
- package/src/commands/diff.mjs +523 -0
- package/src/commands/discover.mjs +159 -0
- package/src/commands/drift.mjs +473 -0
- package/src/commands/edge-constraints.mjs +355 -0
- package/src/commands/explain.mjs +359 -0
- package/src/commands/fitness.mjs +226 -0
- package/src/commands/graph.mjs +297 -0
- package/src/commands/health.mjs +213 -0
- package/src/commands/history.mjs +614 -0
- package/src/commands/impact.mjs +226 -0
- package/src/commands/plan-context-command.mjs +496 -0
- package/src/commands/policy.mjs +138 -0
- package/src/commands/provenance-command.mjs +352 -0
- package/src/commands/provenance.mjs +159 -0
- package/src/commands/reconcile.mjs +219 -0
- package/src/commands/report.mjs +553 -0
- package/src/commands/snapshot-meta.mjs +107 -0
- package/src/commands/waivers.mjs +240 -0
- package/src/config.mjs +1308 -0
- package/src/containment.mjs +234 -0
- package/src/custom-rules/evidence.mjs +340 -0
- package/src/custom-rules/host.mjs +1023 -0
- package/src/custom-rules/values.mjs +43 -0
- package/src/entry-point.mjs +55 -0
- package/src/errors.mjs +36 -0
- package/src/eslint-config.mjs +542 -0
- package/src/go-work.mjs +394 -0
- package/src/governance/adr-registry.mjs +539 -0
- package/src/governance/clock.mjs +69 -0
- package/src/governance/debt-ledger.mjs +274 -0
- package/src/governance/discovery-proposal.mjs +423 -0
- package/src/governance/fitness-registry.mjs +504 -0
- package/src/governance/fitness-rules.mjs +668 -0
- package/src/governance/metrics.mjs +392 -0
- package/src/governance/preset-fingerprints.json +16 -0
- package/src/governance/profile-registry.mjs +366 -0
- package/src/governance/provenance-record.mjs +177 -0
- package/src/governance/reconcile-candidates.mjs +301 -0
- package/src/governance/reconcile-score.mjs +503 -0
- package/src/governance/row-schema.mjs +208 -0
- package/src/governance/verdict.mjs +127 -0
- package/src/governance/waiver.mjs +105 -0
- package/src/graph/create-dependencies.mjs +96 -0
- package/src/intent/intent-manifest.json +347 -0
- package/src/intent/mask-non-code.mjs +640 -0
- package/src/lsp/boundary-config.mjs +225 -0
- package/src/lsp/diagnose.mjs +202 -0
- package/src/lsp/diagnostics.mjs +241 -0
- package/src/lsp/protocol.mjs +215 -0
- package/src/lsp/server.mjs +922 -0
- package/src/lsp/workspace-index.mjs +891 -0
- package/src/nx-json.mjs +95 -0
- package/src/options.mjs +611 -0
- package/src/process.mjs +91 -0
- package/src/providers/moon.mjs +733 -0
- package/src/providers/native/README.md +204 -0
- package/src/providers/native/coverage.mjs +74 -0
- package/src/providers/native/differential.fixtures.mjs +1277 -0
- package/src/providers/native/discover.mjs +431 -0
- package/src/providers/native/graph.mjs +234 -0
- package/src/providers/native/index.mjs +152 -0
- package/src/providers/native/model.mjs +755 -0
- package/src/providers/nx.mjs +178 -0
- package/src/report/README.md +89 -0
- package/src/report/adr-text.mjs +129 -0
- package/src/report/context-text.mjs +109 -0
- package/src/report/debt-text.mjs +105 -0
- package/src/report/diff-text.mjs +219 -0
- package/src/report/discover-text.mjs +186 -0
- package/src/report/drift-text.mjs +194 -0
- package/src/report/envelope-shape.mjs +161 -0
- package/src/report/evidence.mjs +157 -0
- package/src/report/explain-text.mjs +159 -0
- package/src/report/graph-text.mjs +116 -0
- package/src/report/health-text.mjs +123 -0
- package/src/report/history-text.mjs +204 -0
- package/src/report/impact-text.mjs +128 -0
- package/src/report/json.mjs +173 -0
- package/src/report/plan-context-text.mjs +159 -0
- package/src/report/provenance-text.mjs +78 -0
- package/src/report/reconcile-text.mjs +159 -0
- package/src/report/report-text.mjs +264 -0
- package/src/report/sarif.mjs +953 -0
- package/src/report/text.mjs +823 -0
- package/src/report/waivers-text.mjs +100 -0
- package/src/rules/README.md +123 -0
- package/src/rules/index.mjs +962 -0
- package/src/rules/match.mjs +1708 -0
- package/src/rules/messages.mjs +73 -0
- package/src/rules/reachability.mjs +224 -0
- package/src/rules/specifiers.mjs +300 -0
- package/src/rules/tags.mjs +238 -0
- package/src/rules/topology.mjs +333 -0
- package/src/tsconfig-paths.mjs +237 -0
- package/src/verdict.mjs +145 -0
- 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
|
+
}
|