@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,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
+ }