@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,297 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `graph` command: the project graph as a deterministic, serialisable
|
|
3
|
+
* snapshot.
|
|
4
|
+
*
|
|
5
|
+
* `graph` reads the same project model every other command reads — Nx or
|
|
6
|
+
* native, resolved by `./context.mjs` — and returns it as two sorted arrays:
|
|
7
|
+
* one of projects and one of edges. It strips every internal field the rule
|
|
8
|
+
* engine uses but that is not a fact about the consumer's architecture
|
|
9
|
+
* (`../../AGENTS.md` documents that snapshots do not publish `mfeRemote`,
|
|
10
|
+
* `entryPoints`, or `declaredPackages`). It is descriptive: it never exits 1,
|
|
11
|
+
* because a snapshot of what is is never a finding.
|
|
12
|
+
*
|
|
13
|
+
* What it needs from its caller is a `CommandContext` — the preamble every
|
|
14
|
+
* command shares (`./context.mjs`). What it gives back is a `status`, the
|
|
15
|
+
* payload for both the text and the JSON renderers, and enough coverage
|
|
16
|
+
* information to build a correct envelope. It does not print, and it does not
|
|
17
|
+
* decide the process's exit code — `../../cli.mjs` owns those
|
|
18
|
+
* (`./README.md`).
|
|
19
|
+
*
|
|
20
|
+
* ## The unregistered-plugin refusal
|
|
21
|
+
*
|
|
22
|
+
* On an Nx workspace whose `nx.json` does not register this plugin but whose
|
|
23
|
+
* tracked files include polyglot manifests under project roots, `graph`
|
|
24
|
+
* refuses loudly rather than returning a snapshot whose edges silently
|
|
25
|
+
* under-represent the real architecture. The refusal is narrowed to that
|
|
26
|
+
* condition: a pure-TypeScript Nx workspace whose graph is complete without
|
|
27
|
+
* this plugin is never refused. No escape flag: an option that made analysis
|
|
28
|
+
* not run would turn an unknown result into an apparently valid snapshot.
|
|
29
|
+
*/
|
|
30
|
+
import { createHash } from "node:crypto";
|
|
31
|
+
|
|
32
|
+
import { isWholeFileFailure } from "../analysis/source-util.mjs";
|
|
33
|
+
import { canonicalizeJson } from "../canonical.mjs";
|
|
34
|
+
import { DEFAULT_WORKSPACE_LAYOUT } from "../rules/specifiers.mjs";
|
|
35
|
+
import { jsonEnvelope, renderJson } from "../report/json.mjs";
|
|
36
|
+
import { formatGraphReport } from "../report/graph-text.mjs";
|
|
37
|
+
import { resolveProvenance } from "./provenance.mjs";
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The fields stripped from every project node before it enters the snapshot.
|
|
41
|
+
* Each of these is a fact about how this tool reads upstream, not a fact about
|
|
42
|
+
* the consumer's architecture; publishing them in a versioned contract would
|
|
43
|
+
* freeze upstream's internal shape into ours.
|
|
44
|
+
*/
|
|
45
|
+
const INTERNAL_DATA_FIELDS = Object.freeze(["mfeRemote", "entryPoints", "declaredPackages"]);
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Builds the project list for the graph snapshot: one entry per node, sorted
|
|
49
|
+
* by name using plain string comparison (never `localeCompare` —
|
|
50
|
+
* two runs over an unchanged tree must produce byte-identical JSON, and
|
|
51
|
+
* `localeCompare` depends on locale and ICU data).
|
|
52
|
+
*
|
|
53
|
+
* `targets` is emitted as `Object.keys(node.data.targets)` only when the node
|
|
54
|
+
* declares any — the field is omitted, not `[]`, because a native project
|
|
55
|
+
* genuinely has no target table and an empty array would assert it has none
|
|
56
|
+
* declared when the concept does not apply. Iterate with `Object.hasOwn`
|
|
57
|
+
* guards because the native graph's null-prototype containers exist for
|
|
58
|
+
* `__proto__` safety and a naive `for…in` over a reconstructed object undoes
|
|
59
|
+
* it.
|
|
60
|
+
*
|
|
61
|
+
* @param {object} nodes The `graph.nodes` map from the project graph.
|
|
62
|
+
* @returns {object[]}
|
|
63
|
+
*/
|
|
64
|
+
export function buildProjects(nodes) {
|
|
65
|
+
return (
|
|
66
|
+
Object.values(nodes)
|
|
67
|
+
.map((node) => {
|
|
68
|
+
// Strip internal fields: they belong to this tool's rule engine, not to
|
|
69
|
+
// the consumer's architecture. The project's `data` object may carry
|
|
70
|
+
// `mfeRemote`, `entryPoints` and `declaredPackages` — computed by
|
|
71
|
+
// `../workspace.mjs`'s two annotators to reproduce upstream behaviour —
|
|
72
|
+
// but a versioned contract that published them would freeze upstream's
|
|
73
|
+
// internal shape into ours.
|
|
74
|
+
const data = { ...node.data };
|
|
75
|
+
for (const field of INTERNAL_DATA_FIELDS) {
|
|
76
|
+
delete data[field];
|
|
77
|
+
}
|
|
78
|
+
const project = {
|
|
79
|
+
name: node.name,
|
|
80
|
+
root: data.root,
|
|
81
|
+
type: node.type,
|
|
82
|
+
tags: (data.tags ?? []).slice().sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)),
|
|
83
|
+
};
|
|
84
|
+
// Emit `targets` only when the node declares any. A native project has
|
|
85
|
+
// no target table, and an empty array would falsely assert "zero
|
|
86
|
+
// targets declared" when the concept does not apply.
|
|
87
|
+
const targets =
|
|
88
|
+
data.targets && Object.hasOwn(data, "targets")
|
|
89
|
+
? Object.keys(data.targets).sort((a, b) => (a < b ? -1 : a > b ? 1 : 0))
|
|
90
|
+
: undefined;
|
|
91
|
+
if (targets !== undefined && targets.length > 0) {
|
|
92
|
+
project.targets = targets;
|
|
93
|
+
}
|
|
94
|
+
return project;
|
|
95
|
+
})
|
|
96
|
+
// Plain string comparison — never localeCompare. Determinism is part of
|
|
97
|
+
// the contract: two runs over an unchanged tree must produce byte-identical
|
|
98
|
+
// JSON, because `diff`'s whole premise is that a difference in the bytes
|
|
99
|
+
// means a difference in the architecture. localeCompare depends on the
|
|
100
|
+
// locale and the Node build's ICU data, so a snapshot taken on CI and
|
|
101
|
+
// diffed on a developer's machine could differ in order alone.
|
|
102
|
+
.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Builds the flat sorted edge array from the Nx source-keyed dependency map.
|
|
108
|
+
*
|
|
109
|
+
* Edge identity is `(source, target, type)` — keying on `(source, target)`
|
|
110
|
+
* alone would hide a `static`->`dynamic` change, a real architectural event.
|
|
111
|
+
*
|
|
112
|
+
* @param {object} dependencies The `graph.dependencies` map.
|
|
113
|
+
* @returns {{source: string, target: string, type: string}[]}
|
|
114
|
+
*/
|
|
115
|
+
export function buildDependencies(dependencies) {
|
|
116
|
+
const edges = [];
|
|
117
|
+
for (const source of Object.keys(dependencies)) {
|
|
118
|
+
if (!Object.hasOwn(dependencies, source)) continue;
|
|
119
|
+
const targets = dependencies[source];
|
|
120
|
+
for (const edge of targets) {
|
|
121
|
+
edges.push({
|
|
122
|
+
source,
|
|
123
|
+
target: edge.target,
|
|
124
|
+
type: edge.type,
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return edges.sort((a, b) => {
|
|
129
|
+
// Three-key lexicographic sort, plain string comparison throughout.
|
|
130
|
+
if (a.source < b.source) return -1;
|
|
131
|
+
if (a.source > b.source) return 1;
|
|
132
|
+
if (a.target < b.target) return -1;
|
|
133
|
+
if (a.target > b.target) return 1;
|
|
134
|
+
if (a.type < b.type) return -1;
|
|
135
|
+
if (a.type > b.type) return 1;
|
|
136
|
+
return 0;
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Computes a deterministic fingerprint for the boundary policy, so `diff`
|
|
142
|
+
* can warn when the policy changed between runs without re-implementing the
|
|
143
|
+
* config comparison logic.
|
|
144
|
+
*
|
|
145
|
+
* The fingerprint is SHA-256 of the canonicalized JSON for `depConstraints`,
|
|
146
|
+
* `options`, `suppressions` and — when the policy declares them — `fitness`
|
|
147
|
+
* and `customRules`. Those are every field of a loaded policy that states
|
|
148
|
+
* law: the first three decide which violations `evaluate` produces, the
|
|
149
|
+
* fourth decides which fitness functions `check` folds into the same exit
|
|
150
|
+
* code (`../governance/fitness-registry.mjs`), and the fifth names the rule
|
|
151
|
+
* artifacts a workspace declared, each pinned to the bytes its `sha256` claims
|
|
152
|
+
* (`../config.mjs`'s `customRuleRowViolations`) — swap one row's hash or its
|
|
153
|
+
* `params` and the policy says something different. A field that can fail a
|
|
154
|
+
* build and is not in the hash is a law that can be rewritten while `diff`
|
|
155
|
+
* reports the policy unchanged — the silent direction, and the reason the list
|
|
156
|
+
* here and `policyFrom`'s return shape (`../config.mjs`) are revisited
|
|
157
|
+
* together.
|
|
158
|
+
*
|
|
159
|
+
* `fitness` and `customRules` are included only when they are DECLARED, and
|
|
160
|
+
* the absent case contributes no key rather than an empty array. A policy that
|
|
161
|
+
* declares neither therefore fingerprints exactly as it did before those
|
|
162
|
+
* fields were covered, so extending the hash did not move every existing
|
|
163
|
+
* snapshot's value — only those whose law it was failing to describe.
|
|
164
|
+
*
|
|
165
|
+
* @param {object} config The loaded boundary config.
|
|
166
|
+
* @returns {string} A hex-encoded SHA-256 fingerprint.
|
|
167
|
+
*/
|
|
168
|
+
export function computePolicyFingerprint(config) {
|
|
169
|
+
const policy = {
|
|
170
|
+
depConstraints: config.depConstraints ?? [],
|
|
171
|
+
options: config.options ?? {},
|
|
172
|
+
suppressions: config.suppressions ?? [],
|
|
173
|
+
...(config.fitness === undefined ? {} : { fitness: config.fitness }),
|
|
174
|
+
...(config.customRules === undefined ? {} : { customRules: config.customRules }),
|
|
175
|
+
};
|
|
176
|
+
// Canonicalise: sort object keys at every depth so insertion order does not
|
|
177
|
+
// affect the hash. Semantic equality, not construction order, is the claim —
|
|
178
|
+
// the same canonicalizer the intent fingerprint uses
|
|
179
|
+
// (`../architecture-intent/intent-fingerprint.mjs`), so two fingerprints
|
|
180
|
+
// cannot drift from two serializations.
|
|
181
|
+
const canonical = canonicalizeJson(policy);
|
|
182
|
+
return createHash("sha256").update(canonical).digest("hex");
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Runs the `graph` command: resolves the command context, checks the
|
|
187
|
+
* unregistered-plugin condition, and returns the snapshot.
|
|
188
|
+
*
|
|
189
|
+
* @param {object} commandContext From `resolveCommandContext`.
|
|
190
|
+
* @param {{config?: object}} [io] Optional IO overrides. `config` is the loaded
|
|
191
|
+
* boundary config; when provided, a `policy` fingerprint is included in the
|
|
192
|
+
* snapshot so `diff` can warn when the policy changed between runs.
|
|
193
|
+
* @returns {{status: "ok"|"no-verdict", projects: object[], dependencies: object[],
|
|
194
|
+
* workspaceLayout: {appsDir: string, libsDir: string}, workspaceLayoutSource: string,
|
|
195
|
+
* policy: {fingerprint: string}|undefined,
|
|
196
|
+
* coverage: object, report: {text: string, json: string}}}
|
|
197
|
+
* @throws {Error} when an Nx workspace has polyglot manifests but the plugin
|
|
198
|
+
* is not registered — the graph would silently under-represent the real
|
|
199
|
+
* architecture.
|
|
200
|
+
*/
|
|
201
|
+
export function graphCommand(commandContext, { config = null } = {}) {
|
|
202
|
+
const { root, provider, marker, graph, pluginGap } = commandContext;
|
|
203
|
+
|
|
204
|
+
// Descriptive commands refuse when the graph is known to be incomplete.
|
|
205
|
+
// On an Nx workspace, if the plugin is not registered but polyglot manifests
|
|
206
|
+
// exist under project roots, the graph carries no polyglot edges and the
|
|
207
|
+
// snapshot would be a lie about the architecture — every "removed" edge in a
|
|
208
|
+
// later `diff` would be ambiguous between "gone" and "never seen".
|
|
209
|
+
if (provider === "nx" && !pluginGap.registered && pluginGap.manifests.length > 0) {
|
|
210
|
+
throw new Error(
|
|
211
|
+
`archkeep: refusing to build a graph snapshot for an Nx workspace where this plugin is ` +
|
|
212
|
+
`not registered but polyglot manifests exist under project roots ` +
|
|
213
|
+
`(${pluginGap.manifests.join(", ")}). The graph would carry no polyglot edges, ` +
|
|
214
|
+
`so the snapshot would silently under-represent the real architecture. ` +
|
|
215
|
+
`Register the plugin in nx.json: ` +
|
|
216
|
+
`"plugins": [{ "plugin": "@ecoma-io/archkeep/nx" }], or remove the polyglot manifests ` +
|
|
217
|
+
`if they are not in use.`,
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const notAnalyzed = commandContext.analysis.failures
|
|
222
|
+
.filter(isWholeFileFailure)
|
|
223
|
+
.map(({ sourceFile, reason }) => ({ file: sourceFile, reason }));
|
|
224
|
+
|
|
225
|
+
const complete = notAnalyzed.length === 0;
|
|
226
|
+
const status = complete ? "ok" : "no-verdict";
|
|
227
|
+
const exitCode = complete ? 0 : 3;
|
|
228
|
+
|
|
229
|
+
const projects = buildProjects(graph.nodes);
|
|
230
|
+
const dependencies = buildDependencies(graph.dependencies);
|
|
231
|
+
|
|
232
|
+
// `workspaceLayout` is carried on the graph object when the provider knows
|
|
233
|
+
// it. When absent, the engine's own default applies — imported from
|
|
234
|
+
// `../rules/specifiers.mjs` rather than written a second time here, because
|
|
235
|
+
// two copies of a default is how a report ends up describing a layout the
|
|
236
|
+
// engine did not use.
|
|
237
|
+
const workspaceLayout = Object.hasOwn(graph, "workspaceLayout")
|
|
238
|
+
? graph.workspaceLayout
|
|
239
|
+
: DEFAULT_WORKSPACE_LAYOUT;
|
|
240
|
+
const workspaceLayoutSource = Object.hasOwn(graph, "workspaceLayout") ? "declared" : "default";
|
|
241
|
+
|
|
242
|
+
const coverage = {
|
|
243
|
+
complete,
|
|
244
|
+
projects: projects.length,
|
|
245
|
+
analyzedFiles: commandContext.analysis.analyzed,
|
|
246
|
+
imports: commandContext.analysis.imports.length,
|
|
247
|
+
notAnalyzed,
|
|
248
|
+
blindSpots: commandContext.analysis.failures
|
|
249
|
+
.filter((f) => !isWholeFileFailure(f))
|
|
250
|
+
.map(({ sourceFile, line, column, reason }) => ({ file: sourceFile, line, column, reason })),
|
|
251
|
+
notes: [],
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
const context = { root, provider, marker, provenance: resolveProvenance(root) };
|
|
255
|
+
const result = {
|
|
256
|
+
projects,
|
|
257
|
+
dependencies,
|
|
258
|
+
workspaceLayout,
|
|
259
|
+
workspaceLayoutSource,
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
// When the boundary config is provided, include a policy fingerprint so
|
|
263
|
+
// `diff` can warn when the policy changed between runs. Without a config,
|
|
264
|
+
// the snapshot carries no policy identity — the consumer did not provide one.
|
|
265
|
+
if (config) {
|
|
266
|
+
result.policy = { fingerprint: computePolicyFingerprint(config) };
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const envelope = jsonEnvelope({
|
|
270
|
+
command: "graph",
|
|
271
|
+
context,
|
|
272
|
+
status,
|
|
273
|
+
exitCode,
|
|
274
|
+
coverage,
|
|
275
|
+
result,
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
return {
|
|
279
|
+
status,
|
|
280
|
+
projects,
|
|
281
|
+
dependencies,
|
|
282
|
+
workspaceLayout,
|
|
283
|
+
workspaceLayoutSource,
|
|
284
|
+
policy: result.policy,
|
|
285
|
+
coverage,
|
|
286
|
+
report: {
|
|
287
|
+
text: formatGraphReport({
|
|
288
|
+
projects,
|
|
289
|
+
dependencies,
|
|
290
|
+
workspaceLayout,
|
|
291
|
+
workspaceLayoutSource,
|
|
292
|
+
coverage,
|
|
293
|
+
}),
|
|
294
|
+
json: renderJson(envelope),
|
|
295
|
+
},
|
|
296
|
+
};
|
|
297
|
+
}
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `health` command: deterministic architecture-health metrics and trends
|
|
3
|
+
* for the current workspace — the numbers a maintainer acts on, computed
|
|
4
|
+
* read-only, with no service behind them.
|
|
5
|
+
*
|
|
6
|
+
* `health` measures the run's `{ projects, dependencies, analysis, coverage,
|
|
7
|
+
* intent }` and reports, per metric, a verdict in the canonical vocabulary the
|
|
8
|
+
* governance wave shares (`../governance/metrics.mjs`'s header owns the four
|
|
9
|
+
* states). It is **descriptive**, exactly like `graph`/`diff`/`impact`:
|
|
10
|
+
* it never exits 1, because a description of how healthy the architecture is
|
|
11
|
+
* is never itself a finding. Which verbs DO carry exit 1 is settled in
|
|
12
|
+
* `./README.md`, and it is not this one.
|
|
13
|
+
*
|
|
14
|
+
* ## What it reports
|
|
15
|
+
*
|
|
16
|
+
* - **Per-run metrics** — violation count, waiver surface, debt rows, coverage
|
|
17
|
+
* ratio, project/edge counts, cycle count, edge density, and the intent
|
|
18
|
+
* (fitness) verdict. Every number is re-derived from records the run already
|
|
19
|
+
* holds through the same functions `check`/`graph`/`drift` use, so health
|
|
20
|
+
* performs no new scans and cannot disagree with those commands about the
|
|
21
|
+
* same tree.
|
|
22
|
+
* - **Per-metric verdicts** — each metric is decided over complete evidence or
|
|
23
|
+
* says it could not look. A metric with no evidence reads `not_applicable`;
|
|
24
|
+
* a metric whose evidence is partial reads `unknown`. **A metric is never
|
|
25
|
+
* reported as a bare zero over evidence the run could not inspect** — that
|
|
26
|
+
* is the empty-result invariant applied at the metric level.
|
|
27
|
+
* - **Trends** — the same metrics over the snapshot directory `history` reads
|
|
28
|
+
* (`.archkeep/history/` by convention), so a maintainer sees how each metric
|
|
29
|
+
* moved between snapshots. Trends are limited to what a `graph` snapshot
|
|
30
|
+
* carries: structural metrics over the snapshots, with the disclosure that
|
|
31
|
+
* rule-impact cannot be re-derived from stored bytes (`../commands/history.mjs`
|
|
32
|
+
* states the same limit).
|
|
33
|
+
*
|
|
34
|
+
* ## The status contract
|
|
35
|
+
*
|
|
36
|
+
* `health` returns `status: "ok"` when every metric reached a verdict
|
|
37
|
+
* (`ok`, `findings`, or `not_applicable`) and `status: "no-verdict"` when any
|
|
38
|
+
* metric is `unknown` — a run that could not fully inspect its own evidence is
|
|
39
|
+
* not a healthy run, and a descriptive command's `no-verdict` is exit 3, the
|
|
40
|
+
* same code `graph`/`impact` use for incomplete coverage. It never changes
|
|
41
|
+
* the verdict or exit code of any other command: `health` is purely additive.
|
|
42
|
+
*
|
|
43
|
+
* It does not print, and it does not decide the process's exit code —
|
|
44
|
+
* `../../cli.mjs` owns those (`./README.md`).
|
|
45
|
+
*/
|
|
46
|
+
import { isWholeFileFailure } from "../analysis/source-util.mjs";
|
|
47
|
+
import { buildDependencies, buildProjects } from "./graph.mjs";
|
|
48
|
+
import { jsonEnvelope, renderJson } from "../report/json.mjs";
|
|
49
|
+
import { formatHealthReport } from "../report/health-text.mjs";
|
|
50
|
+
import { resolveProvenance } from "./provenance.mjs";
|
|
51
|
+
import { readSnapshots } from "./history.mjs";
|
|
52
|
+
import {
|
|
53
|
+
boundaryMetrics,
|
|
54
|
+
couplingMetrics,
|
|
55
|
+
debtMetric,
|
|
56
|
+
intentMetric,
|
|
57
|
+
structuralMetrics,
|
|
58
|
+
} from "../governance/metrics.mjs";
|
|
59
|
+
import { judgeIntent } from "../architecture-intent/judge.mjs";
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Computes the intent verdict the fitness metric reads — the same `judgeIntent`
|
|
63
|
+
* call `drift`/`check` make, so health and those commands cannot disagree about
|
|
64
|
+
* the same intent file.
|
|
65
|
+
*
|
|
66
|
+
* @param {object} commandContext From `resolveCommandContext`.
|
|
67
|
+
* @param {object|null} intent The loaded intent model, or `null` when the
|
|
68
|
+
* workspace has no intent file.
|
|
69
|
+
* @returns {{ok: boolean, findings: object[], unresolved: object[]}|null}
|
|
70
|
+
*/
|
|
71
|
+
function intentVerdict(commandContext, intent) {
|
|
72
|
+
if (intent === null) return null;
|
|
73
|
+
const judged = judgeIntent(intent, {
|
|
74
|
+
nodes: commandContext.graph.nodes,
|
|
75
|
+
dependencies: commandContext.graph.dependencies,
|
|
76
|
+
});
|
|
77
|
+
return {
|
|
78
|
+
ok: judged.findings.length === 0 && judged.unresolved.length === 0,
|
|
79
|
+
findings: judged.findings,
|
|
80
|
+
unresolved: judged.unresolved,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Runs the `health` command: derives the metrics from the resolved command
|
|
86
|
+
* context, reads the trend snapshots, and builds the report.
|
|
87
|
+
*
|
|
88
|
+
* @param {object} commandContext From `resolveCommandContext`.
|
|
89
|
+
* @param {{config?: object|null, intent?: object|null, trendDir?: string|null,
|
|
90
|
+
* readSnapshots?: Function}} [io]
|
|
91
|
+
* `config` is the loaded boundary law (or `null` when the workspace provides
|
|
92
|
+
* none — the same loading `check`/`graph` do, so `--config` in `cli.mjs`
|
|
93
|
+
* overrides the workspace's own). `intent` is the loaded intent model (or
|
|
94
|
+
* `null`). `trendDir` names the snapshot directory for trends, optional —
|
|
95
|
+
* health reports trends only when one is given. `readSnapshots` is
|
|
96
|
+
* injectable so a test drives trends without the filesystem.
|
|
97
|
+
* @returns {{status: "ok"|"no-verdict", metrics: object, trends: object|null,
|
|
98
|
+
* coverage: object, report: {text: string, json: string}}}
|
|
99
|
+
* @throws {Error} when the trend directory cannot be read (the same malformed
|
|
100
|
+
* snapshot dir condition `history` exits 3 on).
|
|
101
|
+
*/
|
|
102
|
+
export function healthCommand(commandContext, io = {}) {
|
|
103
|
+
const { root, provider, marker, graph, analysis, pluginGap } = commandContext;
|
|
104
|
+
const config = io.config === undefined ? null : io.config;
|
|
105
|
+
const intent = io.intent === undefined ? null : io.intent;
|
|
106
|
+
|
|
107
|
+
const projects = buildProjects(graph.nodes);
|
|
108
|
+
const edges = buildDependencies(graph.dependencies);
|
|
109
|
+
|
|
110
|
+
// The run's coverage facts, the same shape every command's envelope carries.
|
|
111
|
+
const fileComplete = analysis.failures.filter(isWholeFileFailure).length === 0;
|
|
112
|
+
// The graph is complete only when the files are AND the graph actually sees
|
|
113
|
+
// every polyglot edge — an Nx workspace with an unregistered plugin carries
|
|
114
|
+
// a graph with no Go/Rust/Python edges, which `graph`/`impact` refuse and
|
|
115
|
+
// health must report `unknown` rather than measure.
|
|
116
|
+
const graphComplete =
|
|
117
|
+
fileComplete && !(provider === "nx" && !pluginGap.registered && pluginGap.manifests.length > 0);
|
|
118
|
+
const coverage = {
|
|
119
|
+
complete: fileComplete,
|
|
120
|
+
projects: projects.length,
|
|
121
|
+
analyzedFiles: analysis.analyzed,
|
|
122
|
+
imports: analysis.imports.length,
|
|
123
|
+
notAnalyzed: analysis.failures
|
|
124
|
+
.filter(isWholeFileFailure)
|
|
125
|
+
.map(({ sourceFile, reason }) => ({ file: sourceFile, reason })),
|
|
126
|
+
blindSpots: analysis.failures
|
|
127
|
+
.filter((f) => !isWholeFileFailure(f))
|
|
128
|
+
.map(({ sourceFile, line, column, reason }) => ({ file: sourceFile, line, column, reason })),
|
|
129
|
+
notes: graphComplete
|
|
130
|
+
? []
|
|
131
|
+
: [
|
|
132
|
+
"the Nx plugin is not registered for polyglot manifests — the graph carries no " +
|
|
133
|
+
"Go/Rust/Python edges, so edge, cycle and boundary metrics read unknown",
|
|
134
|
+
],
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
const graphCoverage = { ...coverage, complete: graphComplete };
|
|
138
|
+
const structural = structuralMetrics({ projects, edges }, coverage, graphComplete);
|
|
139
|
+
const boundary = boundaryMetrics(analysis.imports, graph, config, graphCoverage);
|
|
140
|
+
const intentVerd = intentVerdict(commandContext, intent);
|
|
141
|
+
const fitness = intentMetric(intentVerd);
|
|
142
|
+
const coupling = couplingMetrics(projects, edges, graphCoverage);
|
|
143
|
+
const debt = debtMetric(config);
|
|
144
|
+
|
|
145
|
+
const metrics = {
|
|
146
|
+
projects: structural.projects,
|
|
147
|
+
edges: structural.edges,
|
|
148
|
+
coverage: structural.coverage,
|
|
149
|
+
violations: boundary.violations,
|
|
150
|
+
waiverSurface: boundary.waiverSurface,
|
|
151
|
+
cycles: coupling.cycles,
|
|
152
|
+
edgeDensity: coupling.edgeDensity,
|
|
153
|
+
debt: debt,
|
|
154
|
+
fitness: fitness,
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
// A run with any `unknown` metric is a no-verdict: it could not fully inspect
|
|
158
|
+
// its own evidence, so the healthy claim it would make is not one it can
|
|
159
|
+
// establish. `not_applicable` is fine (a metric with nothing to measure is
|
|
160
|
+
// not a gap); `unknown` is exactly the gap.
|
|
161
|
+
const hasUnknown = Object.values(metrics).some((m) => m.verdict === "unknown");
|
|
162
|
+
const status = hasUnknown ? "no-verdict" : "ok";
|
|
163
|
+
|
|
164
|
+
// Trends: the same structural metrics over the snapshots `history` reads.
|
|
165
|
+
// A trend is available only when a snapshot directory is named, and it is
|
|
166
|
+
// limited to what a `graph` snapshot carries — rule-impact cannot be
|
|
167
|
+
// re-derived from stored bytes.
|
|
168
|
+
let trends = null;
|
|
169
|
+
if (io.trendDir) {
|
|
170
|
+
const read = io.readSnapshots
|
|
171
|
+
? io.readSnapshots(io.trendDir)
|
|
172
|
+
: readSnapshots(io.trendDir, root);
|
|
173
|
+
trends = {
|
|
174
|
+
snapshots: read.files.map((f) => ({
|
|
175
|
+
name: f.name,
|
|
176
|
+
projects: f.envelope.result.projects.length,
|
|
177
|
+
dependencies: f.envelope.result.dependencies.length,
|
|
178
|
+
})),
|
|
179
|
+
notes: [
|
|
180
|
+
"rule-impact metrics (violations, waivers, debt, fitness) cannot be re-derived from " +
|
|
181
|
+
"stored snapshots — snapshots carry the graph and the policy fingerprint, not the " +
|
|
182
|
+
"constraint table or import sites. Run `check` at any commit for those.",
|
|
183
|
+
],
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const context = { root, provider, marker, provenance: resolveProvenance(root) };
|
|
188
|
+
const result = {
|
|
189
|
+
trendDir: io.trendDir ?? null,
|
|
190
|
+
metrics,
|
|
191
|
+
trends,
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
const envelope = jsonEnvelope({
|
|
195
|
+
command: "health",
|
|
196
|
+
context,
|
|
197
|
+
status,
|
|
198
|
+
exitCode: status === "ok" ? 0 : 3,
|
|
199
|
+
coverage,
|
|
200
|
+
result,
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
return {
|
|
204
|
+
status,
|
|
205
|
+
metrics,
|
|
206
|
+
trends,
|
|
207
|
+
coverage,
|
|
208
|
+
report: {
|
|
209
|
+
text: formatHealthReport({ metrics, trends, coverage }),
|
|
210
|
+
json: renderJson(envelope),
|
|
211
|
+
},
|
|
212
|
+
};
|
|
213
|
+
}
|