@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,237 @@
1
+ /**
2
+ * tsconfig paths hygiene — the workspace-level check that the `paths` alias
3
+ * table still points at code that exists.
4
+ *
5
+ * A `paths` alias whose every target rotted away is a silent lie with two
6
+ * faces. An import of it stops resolving through the table — TypeScript falls
7
+ * back to a node_modules lookup (measured: a dead mapping with an installed
8
+ * package of the same name resolves to the package, `isExternalLibraryImport`
9
+ * true) or reports the import unresolvable — so either the build breaks or,
10
+ * worse, every boundary decision that keys off the alias quietly reads the
11
+ * import as external instead of as the workspace source the alias promised.
12
+ * Nothing else reports either state: the table is only ever consulted, never
13
+ * judged. This module judges it.
14
+ *
15
+ * **This check judges the alias table itself and never resolves a specifier.**
16
+ * `../analysis/typescript.mjs` delegates every real import to
17
+ * `ts.resolveModuleName` precisely so this package never grows a second
18
+ * resolver (docs/reference/languages.md § TypeScript), and a hygiene check that
19
+ * probed candidate files per resolution mode would be that second resolver
20
+ * wearing a janitor's coat. What it may honestly decide instead follows from
21
+ * one measured fact about how TypeScript forms candidates from a target
22
+ * (typescript 5.9.3, probed under node10, nodenext and bundler resolution):
23
+ * every candidate derived from a target — extension substitution and
24
+ * appending, `index.*` and `package.json` lookups below a directory target,
25
+ * and the specifier text substituted at the target's `*` — lives at or below
26
+ * the directory of the target's static prefix (the text before the first `*`;
27
+ * the whole target when it has none).
28
+ *
29
+ * ## The rule, exactly
30
+ *
31
+ * Targets resolve against `baseUrl` when the tsconfig sets one, else against
32
+ * the directory of the config file that declared `paths` (TypeScript's own
33
+ * `pathsBasePath`; both measured). A target is **unreachable** when the
34
+ * directory of its static prefix does not exist — no candidate TypeScript can
35
+ * form from it can then name an existing file. An alias is **dead**, and a
36
+ * finding, when its target list is empty or every target is unreachable.
37
+ *
38
+ * ## Limits, each the honest side of a line this package refuses to cross
39
+ *
40
+ * - **A dead mapping whose prefix directory still exists is not reported.**
41
+ * A target `libs/a/src/index.ts` whose file is gone while `libs/a/src/`
42
+ * remains may still resolve: measured, TypeScript then probes `index.tsx`,
43
+ * `index.d.ts`, `index.js`, even `index.ts.ts` and an `index.ts/` directory,
44
+ * and the probe set differs by resolution mode. Deciding it would mean
45
+ * reproducing that per-mode candidate set — the second resolver. No record.
46
+ * - **A target whose prefix directory falls outside the workspace root is not
47
+ * judged** (a `../` escape, an absolute path elsewhere): this tool's view of
48
+ * the world ends at the root it was given. Counted, not reported.
49
+ * - **A pattern with more than one `*` is not judged**: measured, TypeScript
50
+ * ignores such an alias entirely — zero candidate probes even for a
51
+ * specifier shaped to match — so its targets decide nothing. Counted.
52
+ * - **A target with more than one `*` is judged**: only the first `*` is
53
+ * substituted (measured; later stars stay literal characters in the
54
+ * candidate), so every candidate still lives under the first star's prefix.
55
+ * - **A `paths` value that is not an array of strings is a malformed table,
56
+ * not an absent one.** `ts.parseConfigFileTextToJson` and
57
+ * `ts.parseJsonConfigFileContent` both accept these shapes without a
58
+ * diagnostic (measured), and resolution then misbehaves — a bare-string
59
+ * value is iterated character by character, a non-string element throws
60
+ * mid-resolve. Read as "no aliases" they would be the silent direction, so
61
+ * they come back in `malformed` and `../cli.mjs` turns each into a
62
+ * whole-file failure (exit 3), the same posture the analyzer takes for a
63
+ * tsconfig that does not parse at all.
64
+ * - **A substituted specifier containing `..` can escape the prefix
65
+ * directory.** The reachability model assumes the matched text stays inside
66
+ * it; an import written to traverse out of a mapping is outside this model,
67
+ * and the worst case is a finding naming an alias such an import still
68
+ * reaches — loud, never silent.
69
+ *
70
+ * **This check runs on the CLI surface only, not in the language server**, for
71
+ * the reason `./go-work.mjs` states: it describes the workspace's table, not
72
+ * any document being edited, and a workspace-level finding pinned to whichever
73
+ * file is open would put the report where its fix is not. The table itself
74
+ * comes from `../analysis/typescript.mjs`'s own parsed context — the same
75
+ * file, the same parse, the same `extends` handling the resolver uses — so the
76
+ * check and the resolver cannot disagree about what the workspace's tsconfig
77
+ * says (`tsconfigPathsFacts` there).
78
+ */
79
+ import { posix } from "node:path";
80
+
81
+ /**
82
+ * What a hygiene finding means — one entry per `messageId`, the arrangement
83
+ * `../report/sarif.mjs` derives its rule descriptors from, the same as
84
+ * `./go-work.mjs`, so the id cannot be nameless in a code-scanning upload.
85
+ */
86
+ export const TSCONFIG_PATHS_MESSAGES = Object.freeze({
87
+ tsconfigDeadPathAlias:
88
+ "A tsconfig paths alias maps only to targets whose directories do not exist: no import of it " +
89
+ "can resolve through the alias table, so the build breaks — or silently resolves to an " +
90
+ "installed package of the same name instead of the workspace source the alias promised.",
91
+ });
92
+
93
+ export const TSCONFIG_PATHS_MESSAGE_IDS = Object.freeze(Object.keys(TSCONFIG_PATHS_MESSAGES));
94
+
95
+ /** A workspace-relative directory for display, `""` being the root. */
96
+ const displayDir = (dir) => (dir === "" ? "the workspace root" : `${dir}/`);
97
+
98
+ /**
99
+ * The workspace-relative directory every candidate formed from `target` must
100
+ * live under, or `null` when that directory falls outside the workspace and
101
+ * so cannot be judged from here.
102
+ *
103
+ * @param {string} target A `paths` target as written.
104
+ * @param {string} base Absolute: `baseUrl` if set, else `pathsBasePath`.
105
+ * @param {string} root Absolute workspace root, no trailing slash.
106
+ * @returns {string|null} `""` for the root itself.
107
+ */
108
+ function probeDirectory(target, base, root) {
109
+ const starIndex = target.indexOf("*");
110
+ const prefix = starIndex === -1 ? target : target.slice(0, starIndex);
111
+ const joined = posix.isAbsolute(prefix)
112
+ ? posix.normalize(prefix)
113
+ : posix.normalize(posix.join(base, prefix));
114
+ // A prefix ending at a separator names its directory itself; anything else
115
+ // ends mid-segment (a partial name before `*`, or a whole target), and the
116
+ // candidates live in its parent.
117
+ const dir =
118
+ prefix === "" || prefix.endsWith("/")
119
+ ? joined.replace(/\/+$/u, "") || "/"
120
+ : posix.dirname(joined);
121
+ if (dir === root) return "";
122
+ return dir.startsWith(`${root}/`) ? dir.slice(root.length + 1) : null;
123
+ }
124
+
125
+ /**
126
+ * The dead aliases in a `paths` table — pure, facts as arguments, so the tests
127
+ * need no filesystem: the table and its base come from the resolver's own
128
+ * parsed context, and existence arrives as a predicate.
129
+ *
130
+ * @param {{ paths: Record<string, unknown>,
131
+ * base: string,
132
+ * workspaceRoot: string,
133
+ * tsConfig: string,
134
+ * directoryExists: (dir: string) => boolean }} facts `base` is absolute
135
+ * (`baseUrl` if the config sets one, else TypeScript's `pathsBasePath`);
136
+ * `directoryExists` takes a workspace-relative directory, `""` for the root.
137
+ * @returns {{ tsConfig: string,
138
+ * findings: { messageId: string, file: string, line: null, column: null,
139
+ * alias: string, targets: string[], message: string }[],
140
+ * aliases: number, unjudged: number,
141
+ * malformed: { alias: string, reason: string }[] }} `aliases` counts the
142
+ * aliases that reached a dead-or-alive verdict and `unjudged` the ones the
143
+ * header's limits exclude, so the report can state coverage beside the
144
+ * verdict; `malformed` is for `../cli.mjs` to refuse loudly, never to skip.
145
+ */
146
+ export function judgeTsconfigPaths({ paths, base, workspaceRoot, tsConfig, directoryExists }) {
147
+ const root = workspaceRoot.replace(/\/+$/u, "");
148
+ const findings = [];
149
+ const malformed = [];
150
+ let aliases = 0;
151
+ let unjudged = 0;
152
+
153
+ for (const [alias, targets] of Object.entries(paths)) {
154
+ if (alias.indexOf("*") !== alias.lastIndexOf("*")) {
155
+ // TypeScript ignores a multi-star pattern outright (header), so its
156
+ // targets decide nothing — dead or alive would both be guesses.
157
+ unjudged += 1;
158
+ continue;
159
+ }
160
+ if (!Array.isArray(targets)) {
161
+ malformed.push({
162
+ alias,
163
+ reason:
164
+ `${tsConfig} maps "${alias}" to ${JSON.stringify(targets)}, not to an array of ` +
165
+ `target paths, so the paths hygiene check reached no verdict about it — and ` +
166
+ `TypeScript itself only diagnoses this shape when a full Program is built, which ` +
167
+ `this tool never does`,
168
+ });
169
+ continue;
170
+ }
171
+ if (targets.some((target) => typeof target !== "string")) {
172
+ malformed.push({
173
+ alias,
174
+ reason:
175
+ `${tsConfig} maps "${alias}" to a target list with a non-string entry, so the ` +
176
+ `paths hygiene check reached no verdict about it`,
177
+ });
178
+ continue;
179
+ }
180
+
181
+ const dirs = targets.map((target) => probeDirectory(target, base, root));
182
+ if (dirs.includes(null)) {
183
+ // At least one target lives outside the workspace root; whether it hits
184
+ // a file is not knowable from inside this tree (header).
185
+ unjudged += 1;
186
+ continue;
187
+ }
188
+ aliases += 1;
189
+
190
+ if (targets.length === 0) {
191
+ findings.push(deadAlias(tsConfig, alias, targets, null));
192
+ continue;
193
+ }
194
+ const missing = [...new Set(dirs)].filter((dir) => !directoryExists(dir));
195
+ if (missing.length === new Set(dirs).size) {
196
+ findings.push(deadAlias(tsConfig, alias, targets, missing));
197
+ }
198
+ }
199
+
200
+ return { tsConfig, findings, aliases, unjudged, malformed };
201
+ }
202
+
203
+ /**
204
+ * One dead-alias finding. Positionless on purpose: the parsed compiler options
205
+ * carry no source positions, and under `extends` the alias may not even be
206
+ * declared in the file the workspace names — the finding cites the table the
207
+ * resolver actually reads, and a fabricated line 1 would mark text the alias
208
+ * is not on (`../report/sarif.mjs` gives the same reasoning for go.work).
209
+ *
210
+ * @param {string} tsConfig The workspace tsconfig, workspace-relative.
211
+ * @param {string} alias The pattern as written.
212
+ * @param {string[]} targets The targets as written.
213
+ * @param {string[]|null} missing The unreachable directories, `null` for an
214
+ * alias with no targets at all.
215
+ */
216
+ function deadAlias(tsConfig, alias, targets, missing) {
217
+ const message =
218
+ missing === null
219
+ ? `${tsConfig} maps "${alias}" to an empty target list — no import matching it can ever ` +
220
+ `resolve through this alias. Give it a target, or delete it.`
221
+ : `${tsConfig} maps "${alias}" only to ${targets.map((t) => `"${t}"`).join(", ")}, and ` +
222
+ `every candidate those targets can name lives under ` +
223
+ `${missing.map(displayDir).join(", ")} — which ${missing.length === 1 ? "does" : "do"} ` +
224
+ `not exist. No import matching "${alias}" resolves through this alias: the build ` +
225
+ `breaks on it, or it silently resolves to an installed package of the same name and ` +
226
+ `every boundary decision reads the import as external. Point the alias at the moved ` +
227
+ `source, or delete it.`;
228
+ return {
229
+ messageId: "tsconfigDeadPathAlias",
230
+ file: tsConfig,
231
+ line: null,
232
+ column: null,
233
+ alias,
234
+ targets: [...targets],
235
+ message,
236
+ };
237
+ }
@@ -0,0 +1,145 @@
1
+ /**
2
+ * The process's exit-code contract, and the one function that turns a run's
3
+ * counts into the verdict every format agrees on.
4
+ *
5
+ * Both sit here rather than in `../cli.mjs` because two callers need them and
6
+ * only one of the two is the CLI shell: `./commands/check.mjs` words its own
7
+ * `--format json` envelope from `verdictFor`, and `../cli.mjs`'s `runCheck`
8
+ * takes the process's exit code from the same call. `../cli.mjs` re-exports
9
+ * `EXIT` under its own name, so every importer that already reads it from
10
+ * there keeps working.
11
+ */
12
+
13
+ import { buildDecision } from "./report/evidence.mjs";
14
+
15
+ export const EXIT = Object.freeze({
16
+ ok: 0,
17
+ violations: 1,
18
+ usage: 2,
19
+ error: 3,
20
+ });
21
+ /**
22
+ * The one place that turns a run's counts into the verdict every format
23
+ * agrees on. `runCheck` uses it for the process's exit code; `check` uses the
24
+ * same function to word its own `--format json` envelope's `status` and
25
+ * `exitCode` fields — called once each, from the same counts, so the two can
26
+ * never disagree about a run neither of them re-derives from the other.
27
+ *
28
+ * Findings first — boundary violations, go.work drift, dead tsconfig path
29
+ * aliases and architecture-intent findings alike are verdicts, and a caller
30
+ * that gets `findings` knows the tree is dirty whatever else the run could not
31
+ * reach; the report lists the unreached files either way. A clean run with a
32
+ * file nobody could analyze — or an architecture-intent boundary nobody could
33
+ * verify — is the case that must not read `ok`, because `ok` is read as
34
+ * "checked, and fine".
35
+ *
36
+ * The `decision` is the canonical 4-state verb of the same verdict
37
+ * (`./report/evidence.mjs`), built from the same counts so the envelope's
38
+ * `status` and its `decision.verdict` cannot disagree: `ok`→`pass`,
39
+ * `findings`→`fail`, `no-verdict`→`unknown`. `buildDecision` throws on any
40
+ * invariant the counts violate (a `pass` over incomplete coverage, a `fail`
41
+ * with no findings), which makes a regression in this mapping a loud error
42
+ * rather than a silent one.
43
+ *
44
+ * @param {{violations: number, declaredEdgeFindings: number, goWorkDrift: number, tsconfigPathsDead: number, intentFindings: number, intentUnresolved: number, intentUnresolvedDecisionRefs?: number, unchecked: number, fitnessFail?: number, fitnessUnknown?: number, customRuleFail?: number, customRuleUnknown?: number}} counts
45
+ * @returns {{status: "ok"|"findings"|"no-verdict", exitCode: 0|1|3, decision: object}}
46
+ */
47
+ export function verdictFor({
48
+ violations,
49
+ declaredEdgeFindings,
50
+ goWorkDrift,
51
+ tsconfigPathsDead,
52
+ intentFindings,
53
+ intentUnresolved,
54
+ intentUnresolvedDecisionRefs = 0,
55
+ unchecked,
56
+ fitnessFail = 0,
57
+ fitnessUnknown = 0,
58
+ customRuleFail = 0,
59
+ customRuleUnknown = 0,
60
+ }) {
61
+ if (
62
+ violations > 0 ||
63
+ declaredEdgeFindings > 0 ||
64
+ goWorkDrift > 0 ||
65
+ tsconfigPathsDead > 0 ||
66
+ intentFindings > 0 ||
67
+ fitnessFail > 0 ||
68
+ // A `fail`-verdict custom rule is a finding by the same argument a failing
69
+ // fitness function is one (D-09): the workspace declared the law, the law
70
+ // judged, and the law says no. It rides this lane rather than a new exit
71
+ // code, so a consumer's CI branches on the same 0/1/3 it already does.
72
+ customRuleFail > 0
73
+ ) {
74
+ return {
75
+ status: "findings",
76
+ exitCode: EXIT.violations,
77
+ decision: buildDecision({
78
+ status: "findings",
79
+ coverageComplete: unchecked === 0,
80
+ findings:
81
+ violations +
82
+ declaredEdgeFindings +
83
+ goWorkDrift +
84
+ tsconfigPathsDead +
85
+ intentFindings +
86
+ fitnessFail +
87
+ customRuleFail,
88
+ }),
89
+ };
90
+ }
91
+ if (
92
+ unchecked > 0 ||
93
+ intentUnresolved > 0 ||
94
+ intentUnresolvedDecisionRefs > 0 ||
95
+ fitnessUnknown > 0 ||
96
+ customRuleUnknown > 0
97
+ ) {
98
+ return {
99
+ status: "no-verdict",
100
+ exitCode: EXIT.error,
101
+ decision: buildDecision({
102
+ status: "no-verdict",
103
+ coverageComplete: unchecked === 0,
104
+ findings: 0,
105
+ // The could-not-look condition, named so a reader knows WHICH half of
106
+ // the run did not reach a verdict (I3). When read-only coverage and
107
+ // intent both failed, name both — a reason naming only the file count
108
+ // would hide the unresolved intent boundary from a reader acting on
109
+ // the reason alone (it stays visible in result.intent.unresolved, and
110
+ // status is still no-verdict, so nothing is silent). Each clause below
111
+ // is independent of the others — none is gated on a sibling clause
112
+ // being zero — so a tree that fails on several axes at once names
113
+ // every one of them, not just the first the array happens to check.
114
+ reason: [
115
+ unchecked > 0
116
+ ? `${unchecked} file${unchecked === 1 ? "" : "s"} could not be analyzed — coverage incomplete`
117
+ : null,
118
+ intentUnresolved > 0
119
+ ? `${intentUnresolved} architecture-intent boundary or row${intentUnresolved === 1 ? "" : "s"} could not be established`
120
+ : null,
121
+ intentUnresolvedDecisionRefs > 0
122
+ ? `${intentUnresolvedDecisionRefs} intent row${intentUnresolvedDecisionRefs === 1 ? "" : "s"} ${intentUnresolvedDecisionRefs === 1 ? "cites" : "cite"} a decisionRef that does not resolve`
123
+ : null,
124
+ fitnessUnknown > 0
125
+ ? `${fitnessUnknown} fitness function${fitnessUnknown === 1 ? "" : "s"} could not be determined`
126
+ : null,
127
+ customRuleUnknown > 0
128
+ ? `${customRuleUnknown} custom rule${customRuleUnknown === 1 ? "" : "s"} could not be judged`
129
+ : null,
130
+ ]
131
+ .filter(Boolean)
132
+ .join("; "),
133
+ }),
134
+ };
135
+ }
136
+ return {
137
+ status: "ok",
138
+ exitCode: EXIT.ok,
139
+ decision: buildDecision({
140
+ status: "ok",
141
+ coverageComplete: true,
142
+ findings: 0,
143
+ }),
144
+ };
145
+ }