@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,359 @@
1
+ /**
2
+ * The `explain` command: the judgment for one import site, explained.
3
+ *
4
+ * `explain` takes a `file:line:column` site, finds the matching import
5
+ * record, and explains the judgment: which constraint row matched, which tags
6
+ * applied, whether it is a violation and why. It is descriptive: it never
7
+ * exits 1, because an explanation of what the rules decided is never a finding
8
+ * .
9
+ *
10
+ * What it needs from its caller is a site string, a `CommandContext`, and the
11
+ * loaded boundary config — the preamble every command shares
12
+ * (`./context.mjs`) plus the law the judgment was made under
13
+ * (`../config.mjs`). What it gives back is a `status`, the explanation payload
14
+ * for both the text and the JSON renderers, and enough coverage information to
15
+ * build a correct envelope. It does not print, and it does not decide the
16
+ * process's exit code — `../../cli.mjs` owns those (`./README.md`).
17
+ *
18
+ * ## The unregistered-plugin refusal
19
+ *
20
+ * Same as `graph`: on an Nx workspace whose `nx.json` does not register this
21
+ * plugin but whose tracked files include polyglot manifests under project
22
+ * roots, `explain` refuses loudly rather than explaining a judgment from a
23
+ * graph whose edges silently under-represent the real architecture.
24
+ */
25
+ import { isWholeFileFailure } from "../analysis/source-util.mjs";
26
+ import { UsageError } from "../errors.mjs";
27
+ import { evaluate } from "../rules/index.mjs";
28
+ import { findConstraintsFor } from "../rules/tags.mjs";
29
+ import { findProjectForPath, createProjectRootMappings } from "../rules/specifiers.mjs";
30
+ import { jsonEnvelope, renderJson } from "../report/json.mjs";
31
+ import { formatExplainReport } from "../report/explain-text.mjs";
32
+ import { resolveProvenance } from "./provenance.mjs";
33
+
34
+ /**
35
+ * Parses a `file:line:column` site string into its components.
36
+ *
37
+ * All three parts are required and must be positive integers. The file part
38
+ * may contain any characters except the two colons that delimit the segments.
39
+ *
40
+ * @param {string} site A `file:line:column` string.
41
+ * @returns {{ sourceFile: string, line: number, column: number }}
42
+ * @throws {UsageError} when the site string is malformed.
43
+ */
44
+ export function parseSite(site) {
45
+ const lastColon = site.lastIndexOf(":");
46
+ if (lastColon === -1 || lastColon === 0) {
47
+ throw new UsageError(
48
+ `archkeep: '${site}' is not a valid site — expected <file>:<line>:<column>`,
49
+ );
50
+ }
51
+ const secondLastColon = site.lastIndexOf(":", lastColon - 1);
52
+ if (secondLastColon === -1) {
53
+ throw new UsageError(
54
+ `archkeep: '${site}' is not a valid site — expected <file>:<line>:<column>`,
55
+ );
56
+ }
57
+ const sourceFile = site.slice(0, secondLastColon);
58
+ const lineStr = site.slice(secondLastColon + 1, lastColon);
59
+ const columnStr = site.slice(lastColon + 1);
60
+
61
+ if (!sourceFile) {
62
+ throw new UsageError(`archkeep: '${site}' is not a valid site — the file part is empty`);
63
+ }
64
+
65
+ const line = Number(lineStr);
66
+ const column = Number(columnStr);
67
+ if (!Number.isInteger(line) || line < 1) {
68
+ throw new UsageError(
69
+ `archkeep: '${site}' is not a valid site — line must be a positive integer, got '${lineStr}'`,
70
+ );
71
+ }
72
+ if (!Number.isInteger(column) || column < 1) {
73
+ throw new UsageError(
74
+ `archkeep: '${site}' is not a valid site — column must be a positive integer, got '${columnStr}'`,
75
+ );
76
+ }
77
+ return { sourceFile, line, column };
78
+ }
79
+
80
+ /**
81
+ * Finds the import record matching a parsed site.
82
+ *
83
+ * An import record matches when its `sourceFile`, `line`, and `column` all
84
+ * agree. At most one record should match a given site.
85
+ *
86
+ * @param {{sourceFile: string, line: number, column: number}} parsed
87
+ * @param {object[]} imports The analysis import records.
88
+ * @returns {object|null} The matching record, or `null`.
89
+ */
90
+ export function findSite(parsed, imports) {
91
+ return (
92
+ imports.find(
93
+ (site) =>
94
+ site.sourceFile === parsed.sourceFile &&
95
+ site.line === parsed.line &&
96
+ site.column === parsed.column,
97
+ ) ?? null
98
+ );
99
+ }
100
+
101
+ /**
102
+ * Finds the constraint rows that match a source project's tags.
103
+ *
104
+ * This is the "allowed" counterpart to `evaluate`: `evaluate` returns
105
+ * violations, and this returns the constraints that WERE satisfied. A project
106
+ * with no matching constraints would have been reported as
107
+ * `projectWithoutTagsCannotHaveDependencies` by `evaluate`, so reaching this
108
+ * function with no matches means the site was judged before the constraint
109
+ * table was consulted (e.g. it was `allow`ed, or it reached an app).
110
+ *
111
+ * @param {object[]} depConstraints The config's constraint table.
112
+ * @param {object} sourceProjectNode The graph node for the source project.
113
+ * @returns {object[]}
114
+ */
115
+ function findMatchingConstraints(depConstraints, sourceProjectNode) {
116
+ return findConstraintsFor(depConstraints, sourceProjectNode);
117
+ }
118
+
119
+ /**
120
+ * Runs the `explain` command: resolves the command context, finds the import
121
+ * site, evaluates the rules, and returns the explanation.
122
+ *
123
+ * @param {string} site A `file:line:column` string.
124
+ * @param {object} commandContext From `resolveCommandContext`.
125
+ * @param {object} config The loaded boundary config (from `loadBoundaryConfig`).
126
+ * @returns {{status: "ok"|"no-verdict", explanation: object, coverage: object,
127
+ * report: {text: string, json: string}}}
128
+ * @throws {Error} when the plugin is unregistered on a polyglot Nx workspace,
129
+ * when the site string is malformed, or when the site cannot be found.
130
+ */
131
+ export function explainCommand(site, commandContext, config) {
132
+ const { root, provider, marker, graph } = commandContext;
133
+
134
+ // Descriptive commands refuse when the graph is known to be incomplete.
135
+ if (
136
+ provider === "nx" &&
137
+ !commandContext.pluginGap.registered &&
138
+ commandContext.pluginGap.manifests.length > 0
139
+ ) {
140
+ throw new Error(
141
+ `archkeep: refusing to explain a judgment for an Nx workspace where this plugin is ` +
142
+ `not registered but polyglot manifests exist under project roots ` +
143
+ `(${commandContext.pluginGap.manifests.join(", ")}). The graph would carry no polyglot edges, ` +
144
+ `so the judgment would be against an incomplete graph. ` +
145
+ `Register the plugin in nx.json: ` +
146
+ `"plugins": [{ "plugin": "@ecoma-io/archkeep/nx" }], or remove the polyglot manifests ` +
147
+ `if they are not in use.`,
148
+ );
149
+ }
150
+
151
+ const parsed = parseSite(site);
152
+
153
+ const notAnalyzed = commandContext.analysis.failures
154
+ .filter(isWholeFileFailure)
155
+ .map(({ sourceFile, reason }) => ({ file: sourceFile, reason }));
156
+
157
+ const complete = notAnalyzed.length === 0;
158
+ const status = complete ? "ok" : "no-verdict";
159
+
160
+ // Find the import record at this site.
161
+ const record = findSite(parsed, commandContext.analysis.imports);
162
+
163
+ if (record === null) {
164
+ // Check whether the site's file had a whole-file failure.
165
+ const wholeFileFailure = commandContext.analysis.failures.find(
166
+ (f) => isWholeFileFailure(f) && f.sourceFile === parsed.sourceFile,
167
+ );
168
+ if (wholeFileFailure) {
169
+ throw new Error(
170
+ `archkeep: ${parsed.sourceFile} could not be analyzed at all ` +
171
+ `(${wholeFileFailure.reason}), so no import site at ` +
172
+ `${parsed.sourceFile}:${parsed.line}:${parsed.column} exists to explain`,
173
+ );
174
+ }
175
+
176
+ // Check whether this specific site had a site-level failure.
177
+ const siteFailure = commandContext.analysis.failures.find(
178
+ (f) =>
179
+ !isWholeFileFailure(f) &&
180
+ f.sourceFile === parsed.sourceFile &&
181
+ f.line === parsed.line &&
182
+ f.column === parsed.column,
183
+ );
184
+
185
+ if (siteFailure) {
186
+ // Site-level failure: the file was analyzed but this import site is
187
+ // unresolvable. Report it as such — the file was judged, one position
188
+ // in it has no answer.
189
+ const explanation = {
190
+ site: { file: parsed.sourceFile, line: parsed.line, column: parsed.column },
191
+ import: null,
192
+ sourceProject: null,
193
+ targetProject: null,
194
+ sourceTags: [],
195
+ targetTags: [],
196
+ matchedConstraints: [],
197
+ violations: null,
198
+ unresolvable: true,
199
+ reason: siteFailure.reason,
200
+ };
201
+
202
+ const context = { root, provider, marker, provenance: resolveProvenance(root) };
203
+ const coverage = {
204
+ complete,
205
+ projects: Object.keys(graph.nodes).length,
206
+ analyzedFiles: commandContext.analysis.analyzed,
207
+ imports: commandContext.analysis.imports.length,
208
+ notAnalyzed,
209
+ blindSpots: commandContext.analysis.failures
210
+ .filter((f) => !isWholeFileFailure(f))
211
+ .map(({ sourceFile, line, column, reason }) => ({
212
+ file: sourceFile,
213
+ line,
214
+ column,
215
+ reason,
216
+ })),
217
+ notes: [],
218
+ };
219
+
220
+ const result = {
221
+ site: explanation.site,
222
+ unresolvable: true,
223
+ reason: siteFailure.reason,
224
+ };
225
+
226
+ const envelope = jsonEnvelope({
227
+ command: "explain",
228
+ context,
229
+ status,
230
+ exitCode: complete ? 0 : 3,
231
+ coverage,
232
+ result,
233
+ });
234
+
235
+ return {
236
+ status,
237
+ explanation,
238
+ coverage,
239
+ report: {
240
+ text: formatExplainReport({ explanation, coverage }),
241
+ json: renderJson(envelope),
242
+ },
243
+ };
244
+ }
245
+
246
+ // No record and no failure at this site — the position does not exist.
247
+ throw new Error(
248
+ `archkeep: no import site at ${parsed.sourceFile}:${parsed.line}:${parsed.column} — ` +
249
+ `that position does not correspond to any import this tool found. ` +
250
+ `Check the file, line and column; remember that line and column are 1-based.`,
251
+ );
252
+ }
253
+
254
+ // Evaluate the rules to find violations for ALL sites, then filter to this
255
+ // one. We run the full evaluation rather than a single-site evaluation
256
+ // because `evaluate` is pure and some rules (circular dependencies, lazy
257
+ // loading) depend on the whole file graph — they cannot be computed on one
258
+ // site in isolation.
259
+ const allViolations = evaluate(commandContext.analysis.imports, graph, config);
260
+ const siteViolations = allViolations.filter(
261
+ (v) =>
262
+ v.sourceFile === parsed.sourceFile && v.line === parsed.line && v.column === parsed.column,
263
+ );
264
+
265
+ // Derive source and target projects from the graph.
266
+ // `sourceProject` is NOT on the import record — the analysis contract
267
+ // (`../analysis/contract.md`) keeps no project name on the record. It is
268
+ // derived the same way `evaluate` derives it: by walking up the source
269
+ // file's directory path against the project root mappings. `target` comes
270
+ // from the record's resolved field, which the analysis resolver provides.
271
+ const mappings = createProjectRootMappings(graph.nodes);
272
+ const sourceProjectName = findProjectForPath(record.sourceFile, mappings) ?? null;
273
+ const targetProjectName = record.resolved?.target ?? null;
274
+
275
+ const sourceProjectNode = sourceProjectName ? (graph.nodes[sourceProjectName] ?? null) : null;
276
+ const targetProjectNode = targetProjectName ? (graph.nodes[targetProjectName] ?? null) : null;
277
+
278
+ const sourceTags = sourceProjectNode ? (sourceProjectNode.data?.tags ?? []) : [];
279
+ const targetTags = targetProjectNode ? (targetProjectNode.data?.tags ?? []) : [];
280
+
281
+ // Find which constraint rows match the source project's tags — this is the
282
+ // "allowed" explanation. A project with no matching constraints would have
283
+ // been flagged by `evaluate` as `projectWithoutTagsCannotHaveDependencies`.
284
+ const matchedConstraints = sourceProjectNode
285
+ ? findMatchingConstraints(config.depConstraints, sourceProjectNode)
286
+ : [];
287
+
288
+ let violations = null;
289
+ if (siteViolations.length > 0) {
290
+ // A site can produce multiple violations — e.g. `bannedExternalImports`
291
+ // and `noTransitiveDependencies` can fire together. An agent seeing only
292
+ // the first might fix it and be confused when `check` still fails. Return
293
+ // all of them so the consumer sees the complete picture.
294
+ violations = siteViolations.map((v) => ({
295
+ messageId: v.messageId,
296
+ message: v.message,
297
+ constraint: v.constraint,
298
+ }));
299
+ }
300
+
301
+ const explanation = {
302
+ site: { file: parsed.sourceFile, line: parsed.line, column: parsed.column },
303
+ import: {
304
+ specifier: record.specifier,
305
+ kind: record.kind,
306
+ sourceProject: sourceProjectName,
307
+ targetProject: targetProjectName,
308
+ },
309
+ sourceProject: sourceProjectName,
310
+ targetProject: targetProjectName,
311
+ sourceTags,
312
+ targetTags,
313
+ matchedConstraints,
314
+ violations,
315
+ unresolvable: false,
316
+ reason: null,
317
+ };
318
+
319
+ const context = { root, provider, marker, provenance: resolveProvenance(root) };
320
+ const coverage = {
321
+ complete,
322
+ projects: Object.keys(graph.nodes).length,
323
+ analyzedFiles: commandContext.analysis.analyzed,
324
+ imports: commandContext.analysis.imports.length,
325
+ notAnalyzed,
326
+ blindSpots: commandContext.analysis.failures
327
+ .filter((f) => !isWholeFileFailure(f))
328
+ .map(({ sourceFile, line, column, reason }) => ({ file: sourceFile, line, column, reason })),
329
+ notes: [],
330
+ };
331
+
332
+ const result = {
333
+ site: explanation.site,
334
+ import: explanation.import,
335
+ sourceTags,
336
+ targetTags,
337
+ matchedConstraints,
338
+ violations,
339
+ };
340
+
341
+ const envelope = jsonEnvelope({
342
+ command: "explain",
343
+ context,
344
+ status,
345
+ exitCode: complete ? 0 : 3,
346
+ coverage,
347
+ result,
348
+ });
349
+
350
+ return {
351
+ status,
352
+ explanation,
353
+ coverage,
354
+ report: {
355
+ text: formatExplainReport({ explanation, coverage }),
356
+ json: renderJson(envelope),
357
+ },
358
+ };
359
+ }
@@ -0,0 +1,226 @@
1
+ /**
2
+ * The `fitness` command: every declared fitness function judged against the
3
+ * observed workspace, as a verdict table.
4
+ *
5
+ * Fitness functions are the workspace's executable quality gates
6
+ * (`module-boundaries.config.mjs`'s `fitness` export, validated by
7
+ * `../config.mjs`): "the graph stays cycle-free", "no adapter reaches the
8
+ * domain", "at least 90% of files are analyzed", "no more than N boundary
9
+ * suppressions". Each is judged deterministically against the SAME observed
10
+ * facts `check` reads — the project graph, the workspace analysis, the
11
+ * architecture intent, and the boundary suppressions — through the registry
12
+ * `../governance/fitness-registry.mjs` (which reuses `resolveMembers` and the
13
+ * E0 verdict envelope, never duplicating a judge).
14
+ *
15
+ * ## Posture
16
+ *
17
+ * `fitness` prints a verdict table, and the verdict carries the exit code: a
18
+ * failing function is a finding (exit 1) and an undetermined one is a
19
+ * could-not-determine (exit 3) — the same two lanes `check` uses, argued at
20
+ * the status↔exitCode mapping below (D-09). `check` also folds fitness in by
21
+ * presence — a workspace whose policy declares fitness gets its per-function
22
+ * verdicts counted into the same verdict machinery (exit 1 for any `fail`,
23
+ * exit 3 for any `unknown`, never a new exit code). There is no `--fitness`
24
+ * flag: an opt-in flag would make a forgotten flag byte-identical to "no
25
+ * fitness checked", the silent direction this tool exists to end.
26
+ *
27
+ * ## Fail-closed
28
+ *
29
+ * A declared function that cannot be determined yields `unknown` — never
30
+ * `pass`. The mirror of `drift`'s refusals: a `layer-dependency` tag no
31
+ * matched project carries, a `coverage-minimum` over zero owned files, and a
32
+ * `drift-free` over no architecture-intent.json all answer `unknown` with the
33
+ * missing fact named. A function whose `match` selects no project is
34
+ * `skipped` — loud ("declared but matches nothing"), never folded into
35
+ * `pass`; a `coverage-minimum` row judged from a path-scoped run joins it
36
+ * there rather than `unknown` (`../governance/fitness-rules.mjs`), because a
37
+ * scoped `check <path>` structurally cannot answer a whole-tree coverage
38
+ * question — that is not evidence of a hole, and folding it into `unknown`
39
+ * used to make `check <path>` exit 3 unconditionally in any workspace
40
+ * declaring `coverage-minimum` (P1-19).
41
+ *
42
+ * ## Determinism
43
+ *
44
+ * Rows are judged in declaration order; edges and evidence are plain-`<`
45
+ * sorted; JSON rides `canonicalizeJson`. Two runs over an unchanged tree and
46
+ * policy produce byte-identical text and JSON.
47
+ */
48
+ import { isWholeFileFailure } from "../analysis/source-util.mjs";
49
+ import { jsonEnvelope, renderJson } from "../report/json.mjs";
50
+ import { formatFitnessSection } from "../report/text.mjs";
51
+ import { resolveProvenance } from "./provenance.mjs";
52
+ import { driftForCheck } from "./drift.mjs";
53
+ import {
54
+ evaluateFitness,
55
+ fitnessSnapshot,
56
+ fitnessVerdictFor,
57
+ } from "../governance/fitness-registry.mjs";
58
+
59
+ /**
60
+ * The `fitness` command's own text report: the same verdict table `check`'s
61
+ * fold renders (`formatFitnessSection`), so the two faces can never disagree
62
+ * about a function's verdict.
63
+ *
64
+ * @param {object[]} decisions Per-function verdict records.
65
+ * @param {{verdict: string}} overall From `fitnessVerdictFor`.
66
+ * @returns {string}
67
+ */
68
+ function formatFitnessReport(decisions, overall) {
69
+ return formatFitnessSection(decisions, overall);
70
+ }
71
+
72
+ /**
73
+ * The fitness verdicts for `check`'s fold — the policy's rows evaluated
74
+ * against the run's own facts (`check` needs no override: it already holds the
75
+ * intent verdict it judged and the suppressions it enforced).
76
+ *
77
+ * @param {object} commandContext From `resolveCommandContext`.
78
+ * @param {{rows: object[], intent: object|null, suppressions: object[],
79
+ * scoped: boolean}} policy The validated fitness rows plus the run's own
80
+ * intent verdict and suppressions, scoped by `paths`.
81
+ * @returns {{decisions: object[], overall: {verdict: string}}}
82
+ */
83
+ export function fitnessForCheck(commandContext, { rows, intent, suppressions, scoped }) {
84
+ const snapshot = fitnessSnapshot(commandContext, { intent, suppressions, scoped });
85
+ const decisions = evaluateFitness(rows, snapshot);
86
+ return { decisions, overall: fitnessVerdictFor(decisions) };
87
+ }
88
+
89
+ /**
90
+ * Whether a policy declares fitness functions at all — the one condition that
91
+ * separates "this workspace has no quality gates to judge" from "the gates
92
+ * could not be judged". `fitnessCommand` refuses (exit 3) when it is false,
93
+ * because a `fitness` run asked for a table that does not exist; a composing
94
+ * report reads the same predicate to render `not_applicable` instead, which is
95
+ * the correct verdict for a workspace that declared none
96
+ * (`../governance/metrics.mjs`'s header owns that distinction). Exported so the
97
+ * two callers cannot come to disagree about what "declares fitness" means.
98
+ *
99
+ * @param {{fitness?: unknown}|null|undefined} config The loaded boundary policy.
100
+ * @returns {boolean}
101
+ */
102
+ export function declaresFitness(config) {
103
+ return config !== null && config !== undefined && config.fitness !== undefined;
104
+ }
105
+
106
+ /**
107
+ * Runs the `fitness` command: loads the boundary policy, evaluates every
108
+ * declared function against the workspace's facts, and renders the verdict
109
+ * table.
110
+ *
111
+ * The command re-reads the intent itself (its own `architecture-intent.json`
112
+ * load) and the suppressions from the policy, so its verdict is the same facts
113
+ * `check` folds — one registry, one snapshot shape, two faces.
114
+ *
115
+ * @param {object} commandContext From `resolveCommandContext`.
116
+ * @param {{config?: object|null}} [io] The loaded policy, injectable for tests.
117
+ * @returns {Promise<{status: "ok"|"findings"|"no-verdict", fitness: object, coverage: object,
118
+ * report: {text: string, json: string}}>}
119
+ * @throws {Error} on every condition the header lists, all exit-3 class.
120
+ */
121
+ export async function fitnessCommand(commandContext, io = {}) {
122
+ const { root, provider, marker, analysis } = commandContext;
123
+
124
+ const config = io.config ?? null;
125
+ if (!declaresFitness(config)) {
126
+ throw new Error(
127
+ `archkeep: fitness requires a policy that declares fitness functions — ` +
128
+ `module-boundary config has no \`fitness\` export, so there is nothing to judge`,
129
+ );
130
+ }
131
+
132
+ // A verdict over a tree it could not fully read is a guess. Same refusal
133
+ // `drift`/`graph`/`diff` make for the same condition.
134
+ const notAnalyzed = analysis.failures
135
+ .filter(isWholeFileFailure)
136
+ .map(({ sourceFile, reason }) => ({ file: sourceFile, reason }));
137
+ if (notAnalyzed.length > 0) {
138
+ throw new Error(
139
+ `archkeep: fitness has incomplete coverage — ${notAnalyzed.length} file` +
140
+ `${notAnalyzed.length === 1 ? "" : "s"} could not be analyzed, so every coverage ` +
141
+ `and graph claim would be ambiguous between "clean" and "never seen". Fix the ` +
142
+ `unanalyzed files and re-run.`,
143
+ );
144
+ }
145
+
146
+ // `drift-free` judges the SAME verdict-shaped intent `check`'s fold builds —
147
+ // `driftForCheck` (not raw `loadIntent`) — so the two faces can never
148
+ // disagree about whether the declared intent matches the observed graph. A
149
+ // drift comparison that cannot be completed surfaces as an `unknown` verdict
150
+ // on the function, never a `fail` reading "0 findings" over an intent the
151
+ // command never actually judged. `driftForCheck` is called UNCONDITIONALLY —
152
+ // never gated on `tracked` the way `check`/`plan-context-command.mjs` gate
153
+ // their own calls — because this command must still reach
154
+ // `driftForCheck`'s `refuseIncompleteGraph` guard whether or not intent is
155
+ // declared: an Nx workspace with an unregistered plugin over polyglot
156
+ // manifests must refuse loudly regardless of intent. `driftForCheck` itself
157
+ // resolves an absent intent (`drift.intent === undefined`) to a quiet
158
+ // result rather than judging one, which is what makes this unconditional
159
+ // call safe; that signal becomes `intent: null` here, the same value
160
+ // `check`'s fold passes when no intent file is tracked at all, and
161
+ // `drift-free` below reads it as `unknown`. `driftForCheck` is NOT caught
162
+ // here: every OTHER fail-closed condition — that same unregistered-plugin
163
+ // refusal, an unreadable or invalid intent — must exit 3 exactly as they do
164
+ // for `drift`/`graph`/`impact`/`explain`, not fold into a verdict-bearing
165
+ // run over a graph that cannot see the workspace.
166
+ const drift = await driftForCheck(commandContext);
167
+ const intent =
168
+ drift.intent === undefined
169
+ ? null
170
+ : {
171
+ verdict:
172
+ drift.findings.length > 0
173
+ ? "findings"
174
+ : drift.unresolved.length > 0
175
+ ? "no-verdict"
176
+ : "ok",
177
+ boundaries: drift.boundaries,
178
+ findings: drift.findings,
179
+ unresolved: drift.unresolved,
180
+ notes: drift.notes,
181
+ };
182
+ const snapshot = fitnessSnapshot(commandContext, {
183
+ intent,
184
+ suppressions: config.suppressions,
185
+ });
186
+ const decisions = evaluateFitness(config.fitness, snapshot);
187
+ const overall = fitnessVerdictFor(decisions);
188
+
189
+ const coverage = {
190
+ complete: true,
191
+ projects: Object.keys(commandContext.graph.nodes).length,
192
+ analyzedFiles: analysis.analyzed,
193
+ imports: analysis.imports.length,
194
+ notAnalyzed: [],
195
+ blindSpots: analysis.failures
196
+ .filter((failure) => !isWholeFileFailure(failure))
197
+ .map(({ sourceFile, line, column, reason }) => ({ file: sourceFile, line, column, reason })),
198
+ notes: [],
199
+ };
200
+
201
+ const context = { root, provider, marker, provenance: resolveProvenance(root) };
202
+ // D-09: a `fitness` run is a verdict, not a print job. `fail` is a finding
203
+ // (exit 1) and `unknown` is a could-not-determine (exit 3) — the same two
204
+ // lanes `check` uses, so a CI that gates on `archkeep fitness` cannot be
205
+ // green over a function the run could not determine. `pass` and a run whose
206
+ // every function is `not_applicable` are both `ok`: nothing failed and
207
+ // nothing stayed undetermined. The status↔exitCode pair is asserted by
208
+ // `jsonEnvelope` (3-on-no-verdict), so a wrong mapping here cannot ship.
209
+ /** @type {{status: "ok"|"findings"|"no-verdict", exitCode: 0|1|3}} */
210
+ const { status, exitCode } =
211
+ overall.verdict === "fail"
212
+ ? { status: "findings", exitCode: 1 }
213
+ : overall.verdict === "unknown"
214
+ ? { status: "no-verdict", exitCode: 3 }
215
+ : { status: "ok", exitCode: 0 };
216
+ const result = { verdict: overall.verdict, functions: decisions };
217
+
218
+ const report = {
219
+ text: formatFitnessReport(decisions, overall),
220
+ json: renderJson(
221
+ jsonEnvelope({ command: "fitness", context, status, exitCode, coverage, result }),
222
+ ),
223
+ };
224
+
225
+ return { status, fitness: result, coverage, report };
226
+ }