@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,823 @@
1
+ /**
2
+ * The terminal report: violations as lines a developer can act on without
3
+ * opening anything else.
4
+ *
5
+ * The first line of every entry is `file:line:column`, unindented and with no
6
+ * prefix, because that is the shape a terminal turns into a link and an editor
7
+ * turns into a jump — it is also why the analysis record carries 1-based
8
+ * positions at all (`../analysis/contract.md`). Everything after it is
9
+ * indented, so a `grep` for `:` down the left margin lists exactly the sites.
10
+ *
11
+ * Four things are printed per violation and each has a reader in mind: the
12
+ * `messageId` (the same id ESLint would report, so a search finds upstream's
13
+ * documentation and a differential comparison has something to compare), the
14
+ * rendered message (what is wrong), the import and the project pair (which
15
+ * edge), and the constraint row that fired (WHY it is wrong — a message saying
16
+ * a project "can only depend on libs tagged with X" does not say which line of
17
+ * `module-boundaries.config.mjs` said so, and that is the line a fix has to
18
+ * agree with).
19
+ *
20
+ * This module decides nothing. A formatter that filtered would be a rule
21
+ * wearing a formatter's name, and it would disagree with the engine the first
22
+ * time either changed (`README.md` beside this file).
23
+ */
24
+ import { isWholeFileFailure } from "../analysis/source-util.mjs";
25
+
26
+ /** Two spaces of indent for a violation's detail lines, four for wrapped text. */
27
+ const DETAIL = " ";
28
+ const CONTINUED = " ";
29
+
30
+ /**
31
+ * The constraint row that fired, rendered from the row's own keys rather than
32
+ * from a list of the keys we expect. `@nx/enforce-module-boundaries` can grow a
33
+ * constraint field, and a renderer enumerating today's four would silently drop
34
+ * the new one from every report.
35
+ *
36
+ * A `decisionRef` is rendered specially: this is the one key whose value is a
37
+ * CLAIM ("this row is authorized by decision X") rather than a fact about the
38
+ * rule itself, and `resolveDecisionRef` (`../governance/adr-registry.mjs`) is
39
+ * the only thing that can check the claim — a pure renderer cannot, so the
40
+ * caller resolves every row's `decisionRef` once (against the workspace's ADR
41
+ * registry) and hands back the unresolved VALUES here. A ref this function
42
+ * cannot place in `unresolvedDecisionRefs` renders exactly as before —
43
+ * verbatim, the same as every other key — so a caller with nothing to check
44
+ * (no registry consulted) changes no byte of output.
45
+ *
46
+ * @param {object|null} constraint A `depConstraints` row, or `null`.
47
+ * @param {Set<string>} [unresolvedDecisionRefs] `decisionRef` values known not
48
+ * to resolve to any ADR, rule, or fitness record this run's registry knows.
49
+ * @returns {string}
50
+ */
51
+ export function formatConstraint(constraint, unresolvedDecisionRefs) {
52
+ if (!constraint) {
53
+ // Nine of the fifteen checks are decided before the constraint table is
54
+ // consulted — a relative path across projects, an import of an app, a
55
+ // cycle. Saying so beats printing nothing, which reads as a missing field.
56
+ return "not driven by a depConstraints row — this check fires before the table is read";
57
+ }
58
+ const source =
59
+ "allSourceTags" in constraint
60
+ ? `allSourceTags [${constraint.allSourceTags.join(", ")}]`
61
+ : `sourceTag ${constraint.sourceTag}`;
62
+ const rest = Object.entries(constraint)
63
+ .filter(([key]) => key !== "sourceTag" && key !== "allSourceTags")
64
+ .map(([key, value]) => {
65
+ if (key === "decisionRef" && unresolvedDecisionRefs?.has(value)) {
66
+ return `${key} [${value}] (UNRESOLVED — no matching ADR, rule, or fitness record)`;
67
+ }
68
+ return `${key} [${(Array.isArray(value) ? value : [value]).join(", ")}]`;
69
+ });
70
+ return [source, ...rest].join(" → ");
71
+ }
72
+
73
+ /** The project pair, with the two shapes a target can have spelled out. */
74
+ function formatEdge(violation) {
75
+ const source = violation.sourceProject ?? "(no project)";
76
+ const target = violation.targetProject ?? "(unresolved)";
77
+ return `${source} → ${target}`;
78
+ }
79
+
80
+ /**
81
+ * One violation as an entry: a clickable position line plus indented detail.
82
+ *
83
+ * Message templates are multi-line (a circular-dependency report carries the
84
+ * cycle and the file chain), so every line of the message is indented to the
85
+ * same column — an unindented continuation would read as a second violation at
86
+ * a file called whatever the wrapped text started with.
87
+ *
88
+ * @param {object} violation A `Violation` from `../rules/`.
89
+ * @param {Set<string>} [unresolvedDecisionRefs] Forwarded to `formatConstraint`.
90
+ * @returns {string}
91
+ */
92
+ export function formatViolation(violation, unresolvedDecisionRefs) {
93
+ const message = violation.message
94
+ .split("\n")
95
+ .map((line) => (line === "" ? "" : `${CONTINUED}${line}`))
96
+ .join("\n");
97
+ const lines = [
98
+ `${violation.sourceFile}:${violation.line}:${violation.column} ${violation.messageId}`,
99
+ message,
100
+ `${DETAIL}import ${JSON.stringify(violation.specifier)} (${violation.kind}) ${formatEdge(violation)}`,
101
+ `${DETAIL}constraint ${formatConstraint(violation.constraint, unresolvedDecisionRefs)}`,
102
+ ];
103
+ if (violation.constraint?.description) {
104
+ lines.push(`${DETAIL}rule ${violation.constraint.description}`);
105
+ }
106
+ if (violation.constraint?.remediation) {
107
+ lines.push(`${DETAIL}remediation ${violation.constraint.remediation}`);
108
+ }
109
+ if (violation.evidence !== undefined) {
110
+ // The one evidence a violation carries today: `"expired waiver"` — the row
111
+ // that used to accept it lapsed, and the boundary is live again. Rendered
112
+ // here so the re-assert is visible in the line a developer jumps to, not
113
+ // only in the JSON.
114
+ lines.push(`${DETAIL}evidence ${violation.evidence}`);
115
+ }
116
+ return lines.join("\n");
117
+ }
118
+
119
+ /** One failure as `file` or `file:line:column`, then its reason. */
120
+ const formatFailure = (failure) =>
121
+ `${DETAIL}${isWholeFileFailure(failure) ? failure.sourceFile : `${failure.sourceFile}:${failure.line}:${failure.column}`} ${failure.reason}`;
122
+
123
+ /**
124
+ * What analysis could not read, resolve, or parse — printed on every run,
125
+ * including a clean one, and never counted as a violation.
126
+ *
127
+ * Two sections, because the two things a failure can mean have opposite
128
+ * consequences and one heading for both hid that for as long as it existed.
129
+ *
130
+ * A SITE failure is a blind spot: the file was analyzed, and one specifier in
131
+ * it is not statically knowable — `import(url)` with a computed argument is
132
+ * the honest example, and so is a literal package import that names no
133
+ * declared project and cannot resolve (an uninstalled third-party dependency:
134
+ * a workspace with packages is a normal state, and failing the run on it would
135
+ * block merges over dependencies nobody crossed). Both are legitimately
136
+ * permanent, and the rest of the file still got a verdict.
137
+ *
138
+ * A WHOLE-FILE failure is a hole: nothing was read, parsed, or analyzed — or a
139
+ * literal import that names a DECLARED project could not be resolved, so the
140
+ * edge that workspace-internal dependency would have carried is missing — and
141
+ * this file contributed no verdict at all. The summary line above still counts
142
+ * imports and files, so a reader who sees "no boundary violations" is being
143
+ * told about coverage; a file in this section is coverage that is missing.
144
+ * `cli.mjs` exits non-zero on these for that reason, and the heading says so
145
+ * rather than leaving the exit code to be discovered.
146
+ *
147
+ * @param {object[]} failures `AnalysisFailure` records.
148
+ * @returns {string}
149
+ */
150
+ export function formatFailures(failures) {
151
+ if (failures.length === 0) return "";
152
+ const unchecked = failures.filter(isWholeFileFailure);
153
+ const blind = failures.filter((failure) => !isWholeFileFailure(failure));
154
+ const sections = [];
155
+
156
+ if (unchecked.length > 0) {
157
+ const files = new Set(unchecked.map((failure) => failure.sourceFile)).size;
158
+ sections.push(
159
+ [
160
+ `✖ ${files} file${files === 1 ? "" : "s"} could not be analyzed at all, so ${files === 1 ? "it is" : "they are"} ` +
161
+ `not covered by the verdict above and the run fails:`,
162
+ ...unchecked.map(formatFailure),
163
+ ].join("\n"),
164
+ );
165
+ }
166
+
167
+ if (blind.length > 0) {
168
+ sections.push(
169
+ [
170
+ `${blind.length} import${blind.length === 1 ? "" : "s"} could not be resolved. ` +
171
+ `These are blind spots inside files that were analyzed, not verdicts — the run does not fail on them:`,
172
+ ...blind.map(formatFailure),
173
+ ].join("\n"),
174
+ );
175
+ }
176
+ return sections.join("\n\n");
177
+ }
178
+
179
+ /**
180
+ * The go.work drift section — rendered only when the run HAS a go.work
181
+ * verdict, decided nowhere here.
182
+ *
183
+ * `goWork` is `null` (or absent) when the workspace has no tracked root
184
+ * go.work, and then this prints nothing: a workspace without the manifest pays
185
+ * nothing and hears nothing. When the check ran, even a clean result is a
186
+ * line, because "go.work agrees" is a claim about coverage the reader cannot
187
+ * otherwise tell apart from "nothing looked" — the same reason the summary
188
+ * line counts files (`../go-work.mjs` owns the findings' semantics).
189
+ *
190
+ * A finding with a position renders `go.work:line:column` like a violation; a
191
+ * missing-use finding is about an entry that does not exist, so it renders the
192
+ * file alone rather than a fabricated line 1.
193
+ *
194
+ * @param {{findings: object[], moduleProjects: number}|null|undefined} goWork
195
+ * @returns {string} Empty exactly when there is no go.work verdict to render.
196
+ */
197
+ export function formatGoWork(goWork) {
198
+ if (goWork == null) return "";
199
+ const { findings, moduleProjects } = goWork;
200
+ const modules = `${moduleProjects} Go module project${moduleProjects === 1 ? "" : "s"}`;
201
+ if (findings.length === 0) {
202
+ return `✔ go.work agrees with the project graph (${modules})`;
203
+ }
204
+ const entries = findings.map((finding) => {
205
+ const site =
206
+ finding.line === null ? finding.file : `${finding.file}:${finding.line}:${finding.column}`;
207
+ const message = finding.message
208
+ .split("\n")
209
+ .map((line) => (line === "" ? "" : `${CONTINUED}${line}`))
210
+ .join("\n");
211
+ return [`${site} ${finding.messageId}`, message].join("\n");
212
+ });
213
+ return [
214
+ entries.join("\n\n"),
215
+ `✖ go.work drifts from the project graph: ${findings.length} ` +
216
+ `finding${findings.length === 1 ? "" : "s"} (${modules}) — a developer's go build and ` +
217
+ `CI select different module sets, and the run fails`,
218
+ ].join("\n\n");
219
+ }
220
+
221
+ /**
222
+ * The declared-edge section — rendered only when the graph has at least one
223
+ * `implicit`-typed edge, decided nowhere here.
224
+ *
225
+ * `declaredEdges` is `null` when the graph has no `implicit` edges at all,
226
+ * and then this prints nothing: a workspace that never uses
227
+ * `implicitDependencies` pays nothing and hears nothing, the same bargain
228
+ * `formatGoWork` states. When at least one exists, even a clean result is a
229
+ * line that counts how many were judged, because "no declared-edge
230
+ * violations" is a claim about coverage the reader cannot otherwise tell
231
+ * apart from "nothing looked" — go.work's own reasoning.
232
+ *
233
+ * These findings have no import site by construction — an `implicit` edge is
234
+ * declared, not written as an import — so every entry renders the file that
235
+ * declared it alone, never a fabricated line 1, the same convention
236
+ * `formatGoWork`'s missing-use findings use.
237
+ *
238
+ * `declaration` is what the RUN's provider calls that field, handed in by
239
+ * `../../cli.mjs`'s `declaredEdgeField` rather than assumed here: `check`
240
+ * knows which provider answered and this renderer does not, and the summary
241
+ * sentence named `implicitDependencies` on every provider — a field a Moon
242
+ * workspace has no counterpart for, on a finding whose file is that
243
+ * workspace's `moon.yml`. Absent, it falls back to the Nx/native spelling,
244
+ * which is the only wording this function ever produced before the field
245
+ * existed; a wrong noun in one sentence is prose, not a verdict, and the
246
+ * finding list it summarises is identical either way.
247
+ *
248
+ * @param {{findings: object[], judged: number, declaration?: string}|null|undefined} declaredEdges
249
+ * @param {Set<string>} [unresolvedDecisionRefs] Forwarded to `formatConstraint`.
250
+ * @returns {string} Empty exactly when there is no declared-edge verdict to render.
251
+ */
252
+ export function formatDeclaredEdges(declaredEdges, unresolvedDecisionRefs) {
253
+ if (declaredEdges == null) return "";
254
+ const { findings, judged, declaration = "implicitDependencies" } = declaredEdges;
255
+ const label = `${judged} implicit edge${judged === 1 ? "" : "s"} judged`;
256
+ // The article follows the field name rather than being frozen into the
257
+ // sentence: "an implicitDependencies edge" is the wording every Nx and
258
+ // native run has always printed and must keep printing byte for byte, and
259
+ // "a dependsOn edge" is the one Moon needs. Spelling one of the two into
260
+ // the template makes the other ungrammatical on every run of that provider.
261
+ const article = /^[aeiou]/iu.test(declaration) ? "an" : "a";
262
+ if (findings.length === 0) {
263
+ return `✔ no declared-edge violations (${label})`;
264
+ }
265
+ const entries = findings.map((finding) => {
266
+ const message = finding.message
267
+ .split("\n")
268
+ .map((line) => (line === "" ? "" : `${CONTINUED}${line}`))
269
+ .join("\n");
270
+ return [
271
+ `${finding.file} ${finding.messageId}`,
272
+ message,
273
+ `${DETAIL}edge ${finding.source} → ${finding.target}`,
274
+ `${DETAIL}constraint ${formatConstraint(finding.constraint, unresolvedDecisionRefs)}`,
275
+ ].join("\n");
276
+ });
277
+ return [
278
+ entries.join("\n\n"),
279
+ `✖ declared-edge violations: ${findings.length} ` +
280
+ `finding${findings.length === 1 ? "" : "s"} (${label}) — ${article} ${declaration} edge ` +
281
+ `crosses a boundary depConstraints forbids, with no import site to remove; the ` +
282
+ `dependency itself needs removing or its tags reconciled, and the run fails`,
283
+ ].join("\n\n");
284
+ }
285
+
286
+ /**
287
+ * The tsconfig paths hygiene section — rendered only when the run HAS a paths
288
+ * verdict, decided nowhere here.
289
+ *
290
+ * `tsconfigPaths` is `null` (or absent) when the workspace has no tsconfig or
291
+ * its tsconfig declares no `paths`, and then this prints nothing: a workspace
292
+ * without the table pays nothing and hears nothing, the same bargain
293
+ * `formatGoWork` states. When the check ran, even a clean result is a line
294
+ * that counts the aliases judged — and separately the ones the check's rule
295
+ * cannot judge (`../tsconfig-paths.mjs` header) — because "no dead aliases"
296
+ * is a claim about coverage the reader cannot otherwise tell from silence.
297
+ *
298
+ * Findings are positionless by construction (the parsed options carry no
299
+ * source positions, and under `extends` the alias may be declared in another
300
+ * file), so every entry renders the file alone, never a fabricated line 1.
301
+ *
302
+ * @param {{tsConfig: string, findings: object[], aliases: number,
303
+ * unjudged: number}|null|undefined} tsconfigPaths
304
+ * @returns {string} Empty exactly when there is no paths verdict to render.
305
+ */
306
+ export function formatTsconfigPaths(tsconfigPaths) {
307
+ if (tsconfigPaths == null) return "";
308
+ const { tsConfig, findings, aliases, unjudged } = tsconfigPaths;
309
+ const judged =
310
+ `${aliases} alias${aliases === 1 ? "" : "es"} judged in ${tsConfig}` +
311
+ (unjudged > 0 ? `, ${unjudged} outside this check's rule and not judged` : "");
312
+ if (findings.length === 0) {
313
+ return `✔ no dead tsconfig path aliases (${judged})`;
314
+ }
315
+ const entries = findings.map((finding) => {
316
+ const message = finding.message
317
+ .split("\n")
318
+ .map((line) => (line === "" ? "" : `${CONTINUED}${line}`))
319
+ .join("\n");
320
+ return [`${finding.file} ${finding.messageId}`, message].join("\n");
321
+ });
322
+ return [
323
+ entries.join("\n\n"),
324
+ `✖ dead tsconfig path aliases: ${findings.length} ` +
325
+ `finding${findings.length === 1 ? "" : "s"} (${judged}) — no import matching ` +
326
+ `${findings.length === 1 ? "this alias" : "these aliases"} can resolve through the ` +
327
+ `paths table, and the run fails`,
328
+ ].join("\n\n");
329
+ }
330
+
331
+ /**
332
+ * The architecture-intent section — rendered only when the run HAS an intent
333
+ * verdict, decided nowhere here.
334
+ *
335
+ * `intent` is `null` (or absent) when the workspace has no tracked root
336
+ * `architecture-intent.json`, and then this prints nothing: a workspace
337
+ * without the declaration pays nothing and hears nothing, the same bargain
338
+ * `formatGoWork` and `formatTsconfigPaths` state. When the check ran, even a
339
+ * clean result is a line that counts the boundaries judged, because
340
+ * "architecture-intent agrees" is a claim about coverage the reader cannot
341
+ * otherwise tell apart from "nothing looked" — the same reason the go.work
342
+ * line counts modules.
343
+ *
344
+ * A no-verdict is a boundary (or a row side) that matched no observed project:
345
+ * the intent for that boundary cannot be verified, which is not a clean
346
+ * verdict. It renders as a warning line and the run fails (exit 3), the same
347
+ * posture a whole-file failure takes — an empty boundary must never read as a
348
+ * boundary that passed.
349
+ *
350
+ * @param {{verdict: "ok"|"findings"|"no-verdict", findings: object[],
351
+ * unresolved: object[], boundaries: object[],
352
+ * unresolvedDecisionRefs?: {kind: string, decisionRef: string}[]}|null|undefined} intent
353
+ * @returns {string} Empty exactly when there is no intent verdict to render.
354
+ */
355
+ export function formatIntentSection(intent) {
356
+ if (intent == null) return "";
357
+ const { verdict, findings, unresolved, boundaries, unresolvedDecisionRefs = [] } = intent;
358
+ const count = boundaries.length;
359
+ const label = `${count} boundar${count === 1 ? "y" : "ies"}`;
360
+ // An intent row whose `decisionRef` names no ADR, rule, or fitness record the
361
+ // registry knows — a citation the workspace claims as its authority but that
362
+ // does not exist (`check` folds these into its no-verdict lane; `drift` and
363
+ // `provenance` flag the identical row loudly). Rendered as an UNRESOLVED
364
+ // block so the gate's text face is not the one that stays silent.
365
+ const decisionRefSection =
366
+ unresolvedDecisionRefs.length > 0
367
+ ? unresolvedDecisionRefs
368
+ .map(
369
+ (entry) =>
370
+ `⚠ ${entry.kind} decisionRef [${entry.decisionRef}] (UNRESOLVED — no matching ADR, rule, or fitness record)`,
371
+ )
372
+ .join("\n")
373
+ : "";
374
+ if (verdict === "ok") {
375
+ return [decisionRefSection, `✔ architecture-intent agrees with the observed graph (${label})`]
376
+ .filter(Boolean)
377
+ .join("\n\n");
378
+ }
379
+ if (verdict === "no-verdict") {
380
+ const entries = unresolved.map((entry) => {
381
+ const message = entry.issue
382
+ .split("\n")
383
+ .map((line) => (line === "" ? "" : `${CONTINUED}${line}`))
384
+ .join("\n");
385
+ return `${entry.boundary} ${message}`;
386
+ });
387
+ return [
388
+ decisionRefSection,
389
+ entries.join("\n\n"),
390
+ `⚠ architecture-intent.json reached no verdict on ${unresolved.length} ` +
391
+ `boundar${unresolved.length === 1 ? "y" : "ies"} — the intent ` +
392
+ `${unresolved.length === 1 ? "this boundary names" : "these boundaries name"} ` +
393
+ `could not be verified, and the run fails`,
394
+ ]
395
+ .filter(Boolean)
396
+ .join("\n\n");
397
+ }
398
+ const entries = findings.map((finding) => {
399
+ const message = finding.message
400
+ .split("\n")
401
+ .map((line) => (line === "" ? "" : `${CONTINUED}${line}`))
402
+ .join("\n");
403
+ return `${finding.rule} ${message}`;
404
+ });
405
+ return [
406
+ decisionRefSection,
407
+ entries.join("\n\n"),
408
+ `✖ architecture-intent findings: ${findings.length} ` +
409
+ `finding${findings.length === 1 ? "" : "s"} (${label}) — the intended ` +
410
+ `architecture and the observed one disagree, and the run fails`,
411
+ ]
412
+ .filter(Boolean)
413
+ .join("\n\n");
414
+ }
415
+
416
+ /**
417
+ * One glyph per verdict state, shared by every verdict table below. Each
418
+ * section used to declare its own copy; two copies of a four-glyph table
419
+ * agree only until one gains a state, which is the drift the
420
+ * never-state-a-rule-twice bullet in `../../../../AGENTS.md` exists to end.
421
+ */
422
+ const VERDICT_GLYPH = Object.freeze({ pass: "✔", fail: "✖", unknown: "⚠", not_applicable: "◌" });
423
+
424
+ /**
425
+ * The fitness section — one line per declared fitness function's verdict,
426
+ * rendered only when the run's policy declared any (`fitness === undefined`).
427
+ *
428
+ * This is a verdict table, not a findings list: every declared function gets
429
+ * its row, so "no fitness failed" always reads as a claim about the specific
430
+ * functions that were judged. The overall verdict is a fourth line naming the
431
+ * run's posture — `fail` wins, then `unknown`, then `pass`, exactly
432
+ * `fitnessVerdictFor`'s ordering (`../governance/fitness-registry.mjs`) — and
433
+ * a declared function that could not apply to this run — a `match` that
434
+ * selected nothing, or a `coverage-minimum` row judged from a path-scoped run
435
+ * (`../governance/fitness-rules.mjs`'s `coverageMinimum`) — shows
436
+ * `not_applicable` with its own reason: loud, never absent. The overall label
437
+ * below stays reason-agnostic for exactly that reason: it must read true for
438
+ * either cause, not just the `match`-selected-nothing one it was first written
439
+ * for.
440
+ *
441
+ * @param {object[]} decisions Per-function verdict records from
442
+ * `evaluateFitness`.
443
+ * @param {{verdict: string}} overall The aggregate from `fitnessVerdictFor`.
444
+ * @returns {string} Empty exactly when the policy declared no fitness.
445
+ */
446
+ export function formatFitnessSection(decisions, overall) {
447
+ if (decisions.length === 0) return "";
448
+ const rows = decisions
449
+ .map((decision) => `${VERDICT_GLYPH[decision.verdict]} ${decision.name} ${decision.message}`)
450
+ .join("\n");
451
+ const overallLabel = {
452
+ pass: `✔ fitness: ${decisions.length} function${decisions.length === 1 ? "" : "s"} passed`,
453
+ fail: `✖ fitness: ${overall.verdict} — the build fails`,
454
+ unknown: `⚠ fitness: ${decisions.length} function${decisions.length === 1 ? "" : "s"} judged, some could not be determined — the run cannot claim pass`,
455
+ not_applicable: `◌ fitness: every declared function is not applicable to this run — nothing was judged`,
456
+ }[overall.verdict];
457
+ return `${rows}\n\n${overallLabel}`;
458
+ }
459
+
460
+ /**
461
+ * Where one custom-rule finding points, or `null` when it points nowhere.
462
+ *
463
+ * A rule may report a finding about the workspace as a whole, and it may
464
+ * report a file with no position inside it — both are legitimate, and both
465
+ * render without the part they do not have rather than with a fabricated line
466
+ * 1, the same convention `formatGoWork`'s missing-use findings and
467
+ * `formatTsconfigPaths` already keep. A `column` with no `line` has no
468
+ * clickable form at all (the file alone is what renders); the raw fields
469
+ * survive in the JSON envelope either way.
470
+ *
471
+ * @param {{sourceFile?: string, line?: number, column?: number}} finding
472
+ * @returns {string|null}
473
+ */
474
+ function customFindingSite(finding) {
475
+ if (finding.sourceFile === undefined) return null;
476
+ if (finding.line === undefined) return finding.sourceFile;
477
+ if (finding.column === undefined) return `${finding.sourceFile}:${finding.line}`;
478
+ return `${finding.sourceFile}:${finding.line}:${finding.column}`;
479
+ }
480
+
481
+ /**
482
+ * The custom-rules section — one entry per rule the policy declared, rendered
483
+ * only when it declared any.
484
+ *
485
+ * A verdict table like `formatFitnessSection`'s, and for the same reason:
486
+ * every declared rule gets its row, so "no custom rule failed" always reads as
487
+ * a claim about the specific rules that were judged rather than about silence.
488
+ * Each entry carries the rule's DECLARED reason — the workspace's answer to
489
+ * "why does this rule exist", which is the half a reader needs to decide
490
+ * whether a finding is worth fixing or the rule is worth deleting — and then
491
+ * its findings, each on a `file:line:column` line a terminal turns into a link
492
+ * (`formatViolation`'s opening argument, applied to a finding that has a
493
+ * position; a finding without one renders under its namespaced id alone).
494
+ *
495
+ * The summary counts all four verdicts rather than only the failing one: a
496
+ * rule that answered `not_applicable` did NOT look, and a reader who cannot
497
+ * tell that from a rule that looked and found nothing has been handed the one
498
+ * ambiguity this tool exists to end (`../../../../AGENTS.md`).
499
+ *
500
+ * @param {{decisions: object[], overall: {verdict: string}}|null|undefined} customRules
501
+ * @returns {string} Empty exactly when the policy declared no custom rules.
502
+ */
503
+ export function formatCustomRulesSection(customRules) {
504
+ if (customRules == null || customRules.decisions.length === 0) return "";
505
+ const { decisions, overall } = customRules;
506
+ const entries = decisions.map((decision) => {
507
+ const lines = [
508
+ `${VERDICT_GLYPH[decision.verdict]} ${decision.name} ${decision.message}`,
509
+ `${DETAIL}reason ${decision.reason}`,
510
+ ];
511
+ for (const finding of decision.findings ?? []) {
512
+ const site = customFindingSite(finding);
513
+ lines.push(`${DETAIL}${site === null ? finding.id : `${site} ${finding.id}`}`);
514
+ lines.push(
515
+ ...finding.message.split("\n").map((line) => (line === "" ? "" : `${CONTINUED}${line}`)),
516
+ );
517
+ }
518
+ return lines.join("\n");
519
+ });
520
+ const count = (verdict) => decisions.filter((decision) => decision.verdict === verdict).length;
521
+ const summary =
522
+ `${VERDICT_GLYPH[overall.verdict]} custom rules: ${count("pass")} passed, ` +
523
+ `${count("fail")} failed, ${count("unknown")} unknown, ` +
524
+ `${count("not_applicable")} not applicable`;
525
+ return `${entries.join("\n\n")}\n\n${summary}`;
526
+ }
527
+
528
+ /**
529
+ * The coverage-gap section — rendered only when this run knows of coverage it
530
+ * did not itself provide: polyglot edges the Nx graph is missing that the
531
+ * checker's own analysis did cover, or tracked analyzable files no project
532
+ * owns (`formatCoverageGap` below dispatches, one arm per kind).
533
+ *
534
+ * `coverageGaps` is `[]` (or absent) when there is no gap, and then this
535
+ * prints nothing — the same bargain `formatGoWork` and `formatTsconfigPaths`
536
+ * state: no fact, no claim. When the gap exists, it names the manifests and
537
+ * says what is missing: `nx affected` will not trace through these edges, and
538
+ * `@nx/enforce-module-boundaries` does not see them at all.
539
+ *
540
+ * This is not a finding (it does not change the exit code) and it is not a
541
+ * refusal (the checker still judged every import it found). It is a
542
+ * degraded-coverage note: the checker's verdict is valid, but the Nx graph
543
+ * those edges were meant for is incomplete, and anyone relying on `nx affected`
544
+ * or ESLint boundary enforcement for polyglot projects is under-covered.
545
+ *
546
+ * Every entry in `coverageGaps` renders — not only the first — so a second
547
+ * gap is never dropped from the report while still riding along in the JSON
548
+ * envelope (`cli.mjs`'s `coverage.coverageGaps`); the invariant this module is
549
+ * judged against (`../../../../AGENTS.md`) applies to a report section as much
550
+ * as to a verdict.
551
+ *
552
+ * @param {object[]} coverageGaps Each entry has a `kind` and the fields that
553
+ * kind carries — `manifests` for `"unregistered-plugin"`, `files`/
554
+ * `languages`/`provider` for `"unowned-files"`.
555
+ * @returns {string} Empty exactly when there is no coverage gap to render.
556
+ */
557
+ export function formatCoverageGaps(coverageGaps) {
558
+ if (coverageGaps.length === 0) return "";
559
+ return coverageGaps.map(formatCoverageGap).join("\n");
560
+ }
561
+
562
+ /**
563
+ * How many unowned-file paths this face prints before it stops listing and
564
+ * says how many are left. The count is the headline and is always exact; the
565
+ * paths are the sample that makes it actionable. A real Nx or Moon workspace
566
+ * carries tens of them (this repository's own tree: 50), and a report whose
567
+ * every other section is a handful of lines does not survive one section
568
+ * fifty lines long — the reader stops reading the report, which is the same
569
+ * outcome as not printing it. Nothing is dropped silently: the total is
570
+ * stated first and the remainder is named, with the surface that holds the
571
+ * complete list.
572
+ */
573
+ const UNOWNED_SAMPLE_LIMIT = 10;
574
+
575
+ /**
576
+ * One coverage gap entry, dispatched on `kind`.
577
+ *
578
+ * The unknown-kind arm is not decoration: a kind added on the producing side
579
+ * without an arm here would otherwise read a field it does not carry and
580
+ * throw mid-report, losing every section after it — a whole report lost to
581
+ * the newest, least-important line in it. Naming the kind is the loud
582
+ * version, and it mirrors what `./sarif.mjs`'s
583
+ * `sarifCoverageGapNotification` already does for the same input.
584
+ *
585
+ * @param {object} gap Has `kind`, and the fields that kind carries.
586
+ * @returns {string}
587
+ */
588
+ function formatCoverageGap(gap) {
589
+ if (gap.kind === "unregistered-plugin") return formatUnregisteredPluginGap(gap);
590
+ if (gap.kind === "unowned-files") return formatUnownedFilesGap(gap);
591
+ return `⚠ coverage gap "${gap.kind}" — part of this workspace is outside what this run covered`;
592
+ }
593
+
594
+ /**
595
+ * The unregistered-plugin gap: every manifest listed, because there are as
596
+ * many of them as there are polyglot projects and a reader acts on each one.
597
+ *
598
+ * @param {{manifests: string[]}} gap
599
+ * @returns {string}
600
+ */
601
+ function formatUnregisteredPluginGap(gap) {
602
+ const count = gap.manifests.length;
603
+ const label = `${count} polyglot manifest${count === 1 ? "" : "s"}`;
604
+ const paths = gap.manifests.map((manifest) => `${CONTINUED}${manifest}`).join("\n");
605
+ return (
606
+ `⚠ nx.json does not register this plugin but ${label} ` +
607
+ `found under project roots — nx affected and ` +
608
+ `@nx/enforce-module-boundaries will not cover these edges\n${paths}\n` +
609
+ `${DETAIL}register the plugin: "plugins": [{ "plugin": "@ecoma-io/archkeep/nx" }]`
610
+ );
611
+ }
612
+
613
+ /**
614
+ * The unowned-analyzable-files gap: tracked TypeScript, JavaScript or Vue
615
+ * files no project claims. They are skipped on purpose — Nx's own graph and
616
+ * `@nx/enforce-module-boundaries` already cover that language, which is why
617
+ * they are not failures and change no exit code
618
+ * (`../commands/context.mjs`'s `UNCLAIMED_CHECK_LANGUAGES`) — but "skipped"
619
+ * and "never mentioned" are different claims, and only the first one is
620
+ * true of this run.
621
+ *
622
+ * The count and the languages lead, because they are what a reader checks
623
+ * the clean line's own file count against; the paths follow, bounded.
624
+ *
625
+ * @param {{provider?: string, languages?: string[], files: string[]}} gap
626
+ * @returns {string}
627
+ */
628
+ function formatUnownedFilesGap(gap) {
629
+ // `files` is read as defensively as the SARIF face reads it. `./check.mjs`
630
+ // contributes this entry only when the list is non-empty, but
631
+ // `formatCoverageGaps` is exported and a caller holding an entry with no
632
+ // `files` would otherwise render a section announcing zero files — a
633
+ // heading with nothing under it reads as a truncation, not as "none".
634
+ const files = gap.files ?? [];
635
+ const count = files.length;
636
+ const languages = gap.languages ?? [];
637
+ const spans = languages.length > 0 ? ` (${languages.join(", ")})` : "";
638
+ const shown = files.slice(0, UNOWNED_SAMPLE_LIMIT);
639
+ const remaining = count - shown.length;
640
+ const lines = shown.map((file) => `${CONTINUED}${file}`);
641
+ if (remaining > 0) {
642
+ lines.push(`${CONTINUED}… and ${remaining} more — the full list is in --format json`);
643
+ }
644
+ // The one place this face needs to know which project model it is
645
+ // describing: a reader is being told to declare a project, and the file
646
+ // that declares one is not the same file on the two providers.
647
+ const them = count === 1 ? "it" : "them";
648
+ const manifest = gap.provider === "moon" ? "moon.yml" : "project.json";
649
+ return (
650
+ `⚠ ${count} tracked analyzable file${count === 1 ? "" : "s"}${spans} ` +
651
+ `owned by no project — skipped, so no boundary verdict here covers ${them}\n` +
652
+ `${lines.join("\n")}\n` +
653
+ `${DETAIL}declare a project that owns ${them} (a ${manifest} under a directory that ` +
654
+ `contains ${them}), or leave ${them} outside the boundary system knowingly`
655
+ );
656
+ }
657
+
658
+ /**
659
+ * The policy-identity line — which law this run enforced — rendered FIRST,
660
+ * ahead of every verdict below it: a reader has to know WHICH law produced a
661
+ * result before the result itself means anything. A violating tree under a
662
+ * weak policy and a clean tree under a strict one used to print the exact
663
+ * same "no boundary violations" sentence, with nothing anywhere in the report
664
+ * saying which law had run (P1-01, `../../../../AGENTS.md`'s empty-result
665
+ * invariant applied to the law itself, not only to the verdict).
666
+ *
667
+ * `policy` is `null`/absent only for a caller that has nothing to say about
668
+ * one — a unit test exercising some other section of this report. `cli.mjs`'s
669
+ * `check` always supplies it, because `check` always loads exactly one
670
+ * boundary law before it can judge anything; `profile` inside it is `null`,
671
+ * stated rather than omitted, on every run that is not profile-selected — the
672
+ * same "no fact, no claim" bargain `formatGoWork`/`formatTsconfigPaths` keep
673
+ * for a feature a workspace does not use either.
674
+ *
675
+ * @param {{profile: string|null, source: string, fingerprint: string}|null|undefined} policy
676
+ * @returns {string} Empty exactly when no policy identity was supplied.
677
+ */
678
+ export function formatPolicy(policy) {
679
+ if (policy == null) return "";
680
+ const { profile, source, fingerprint } = policy;
681
+ const law = profile === null ? source : `profile "${profile}" from ${source}`;
682
+ return `policy ${law} — fingerprint ${fingerprint}`;
683
+ }
684
+
685
+ /**
686
+ * The accepted-violations section: every violation an ACTIVE waiver covered,
687
+ * rendered with the waiver that accepts it and its expiry.
688
+ *
689
+ * A waived violation lives in `run.violations` (marked `waivedBy`) — it is
690
+ * still a violation, still counted, still exit-1 — and this formatter splits
691
+ * it out of the main list so a reader sees exactly what is being accepted and
692
+ * for how long. The evidence of the acceptance is the reason the waiver row
693
+ * carries.
694
+ *
695
+ * @param {object[]} waived Violations carrying `waivedBy`.
696
+ * @param {Set<string>} [unresolvedDecisionRefs] Forwarded to `formatViolation`.
697
+ * @returns {string}
698
+ */
699
+ export function formatAcceptedViolations(waived, unresolvedDecisionRefs) {
700
+ const rows = waived.map((violation) => {
701
+ const waiver = violation.waivedBy;
702
+ return [
703
+ formatViolation(violation, unresolvedDecisionRefs),
704
+ `${DETAIL}waiver accepted until ${waiver.expiresAt}` +
705
+ (waiver.origin ? ` (origin: ${waiver.origin})` : ""),
706
+ `${DETAIL}reason ${waiver.reason}`,
707
+ ].join("\n");
708
+ });
709
+ const count = waived.length;
710
+ return [
711
+ `⚠ accepted violations: ${count} boundary violation${count === 1 ? "" : "s"} waived until their ` +
712
+ `expiry — the boundary is still breached (the run stays non-zero), and each one below will ` +
713
+ `re-assert the moment its waiver lapses:`,
714
+ rows.join("\n\n"),
715
+ ].join("\n\n");
716
+ }
717
+
718
+ /**
719
+ * The whole report, violations first.
720
+ *
721
+ * The summary states what was inspected and not only what was found, because
722
+ * "no violations" is a claim about coverage as much as about correctness: a run
723
+ * that analyzed nothing and a clean tree print the same sentence otherwise, and
724
+ * that indistinguishability is the defect this whole tool exists to end
725
+ * (`../../AGENTS.md`).
726
+ *
727
+ * Waived violations are still `violations` (the engine marks them `waivedBy`,
728
+ * it never removes them), so an all-waived run still renders non-zero — the
729
+ * "waiving must not flip exit 1 → 0" invariant, in the report layer. The
730
+ * summary line above only says "no boundary violations" when there is nothing
731
+ * a waiver is covering either.
732
+ *
733
+ * @param {{violations: object[], failures: object[], analyzed: number, projects: number, imports: number, goWork?: object|null, tsconfigPaths?: object|null, declaredEdges?: object|null, intent?: object|null, fitness?: object|null, fitnessOverall?: {verdict: string}|null, customRules?: {decisions: object[], overall: {verdict: string}}|null, coverageGaps?: object[], notes?: string[], policy?: {profile: string|null, source: string, fingerprint: string}|null, unresolvedDecisionRefs?: Set<string>}} run
734
+ * @returns {string}
735
+ */
736
+ export function formatReport({
737
+ violations,
738
+ failures,
739
+ analyzed,
740
+ projects,
741
+ imports,
742
+ goWork,
743
+ tsconfigPaths,
744
+ declaredEdges,
745
+ intent,
746
+ fitness,
747
+ fitnessOverall,
748
+ customRules,
749
+ coverageGaps = [],
750
+ notes = [],
751
+ policy = null,
752
+ unresolvedDecisionRefs,
753
+ }) {
754
+ const inspected =
755
+ `${imports} import${imports === 1 ? "" : "s"} in ${analyzed} file${analyzed === 1 ? "" : "s"} ` +
756
+ `across ${projects} project${projects === 1 ? "" : "s"}` +
757
+ // `boundaryConfig`-dialect facts a reader needs alongside the count —
758
+ // today only the ESLint flat-config dialect ever populates this
759
+ // (`../eslint-config.mjs`'s `extractBoundaryRule`), e.g. which entry
760
+ // among several configuring the rule was binding.
761
+ (notes.length > 0 ? `; ${notes.join("; ")}` : "");
762
+ const sections = [];
763
+
764
+ const policySection = formatPolicy(policy);
765
+ if (policySection !== "") sections.push(policySection);
766
+
767
+ const waived = violations.filter((violation) => violation.waivedBy);
768
+ const live = violations.filter((violation) => !violation.waivedBy);
769
+
770
+ if (live.length > 0) {
771
+ sections.push(
772
+ live.map((violation) => formatViolation(violation, unresolvedDecisionRefs)).join("\n\n"),
773
+ );
774
+ const files = new Set(live.map((violation) => violation.sourceFile)).size;
775
+ sections.push(
776
+ `✖ ${live.length} boundary violation${live.length === 1 ? "" : "s"} ` +
777
+ `in ${files} file${files === 1 ? "" : "s"} (${inspected})`,
778
+ );
779
+ } else if (violations.length === 0) {
780
+ sections.push(`✔ no boundary violations (${inspected})`);
781
+ }
782
+
783
+ if (waived.length > 0) {
784
+ sections.push(formatAcceptedViolations(waived, unresolvedDecisionRefs));
785
+ // When every finding is waived the run is still NOT clean (exit 1), so the
786
+ // summary says so rather than letting the accepted section read as a clean
787
+ // tree. `waived` is the count word, deliberately not `!` — the finding is
788
+ // accepted, not an error a reader must chase.
789
+ if (live.length === 0) {
790
+ sections.push(
791
+ `✖ ${waived.length} boundary violation${waived.length === 1 ? "" : "s"} accepted (${inspected})`,
792
+ );
793
+ }
794
+ }
795
+
796
+ const goWorkSection = formatGoWork(goWork);
797
+ if (goWorkSection !== "") sections.push(goWorkSection);
798
+
799
+ const tsconfigPathsSection = formatTsconfigPaths(tsconfigPaths);
800
+ if (tsconfigPathsSection !== "") sections.push(tsconfigPathsSection);
801
+
802
+ const declaredEdgesSection = formatDeclaredEdges(declaredEdges, unresolvedDecisionRefs);
803
+ if (declaredEdgesSection !== "") sections.push(declaredEdgesSection);
804
+
805
+ const intentSection = formatIntentSection(intent);
806
+ if (intentSection !== "") sections.push(intentSection);
807
+
808
+ const fitnessSection = formatFitnessSection(fitness ?? [], fitnessOverall ?? { verdict: "pass" });
809
+ if (fitnessSection !== "") sections.push(fitnessSection);
810
+
811
+ // After fitness, the order `../../cli.mjs`'s `check` judges them in: both
812
+ // are policy-declared verdict tables, and the custom rules are the ones the
813
+ // workspace wrote itself.
814
+ const customRulesSection = formatCustomRulesSection(customRules);
815
+ if (customRulesSection !== "") sections.push(customRulesSection);
816
+
817
+ const coverageGapsSection = formatCoverageGaps(coverageGaps);
818
+ if (coverageGapsSection !== "") sections.push(coverageGapsSection);
819
+
820
+ const unresolved = formatFailures(failures);
821
+ if (unresolved !== "") sections.push(unresolved);
822
+ return sections.join("\n\n");
823
+ }