@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,989 @@
1
+ /**
2
+ * The `check` command: every import site judged against the boundary rules,
3
+ * folded together with every other finding class one verdict counts.
4
+ *
5
+ * `../../cli.mjs`'s `runCheck` owns argv, the output destination and the
6
+ * process's exit code; this module owns the computation and hands back the
7
+ * report and the counts — `./README.md`'s rule applied to the one command
8
+ * that predates it. `../../cli.mjs` re-exports `check` under its own name, so
9
+ * every importer that already reads it from there keeps working.
10
+ */
11
+
12
+ import { statSync } from "node:fs";
13
+ import { join } from "node:path";
14
+
15
+ import { fileFailure, isWholeFileFailure } from "../analysis/source-util.mjs";
16
+ import { tsconfigPathsFacts } from "../analysis/typescript.mjs";
17
+ import { suppressionCovers } from "../config.mjs";
18
+ import { referenceTime } from "../governance/clock.mjs";
19
+ import { suppressionFate } from "../governance/waiver.mjs";
20
+ import { resolveCommandContext, unownedGapWithoutRunConfiguration } from "./context.mjs";
21
+ import { readAdrContext } from "./adr.mjs";
22
+ import { declaredFitnessNames, unresolvedDecisionRefRows } from "../governance/adr-registry.mjs";
23
+ import { declaredEdgeViolationsForCheck } from "./edge-constraints.mjs";
24
+ import { customRulesForCheck, declaresCustomRules } from "./custom-rules.mjs";
25
+ import { driftForCheck } from "./drift.mjs";
26
+ import { fitnessForCheck } from "./fitness.mjs";
27
+ import { computePolicyFingerprint } from "./graph.mjs";
28
+ import { resolvePolicy } from "./policy.mjs";
29
+ import { resolveProvenance } from "./provenance.mjs";
30
+ import { INTENT_FILE, loadIntent } from "../architecture-intent/model.mjs";
31
+ import { compareGoWork, parseGoWorkUse } from "../go-work.mjs";
32
+ import { jsonEnvelope, renderJson } from "../report/json.mjs";
33
+ import { formatSarif } from "../report/sarif.mjs";
34
+ import { formatReport } from "../report/text.mjs";
35
+ import { ARCHKEEP_MODEL_FILE } from "../providers/native/model.mjs";
36
+ import { evaluateRun, exemptResolvedFile } from "../rules/index.mjs";
37
+ import { orphanedNotDependOnTags, unmatchedConstraintRows } from "../rules/tags.mjs";
38
+ import { judgeTsconfigPaths } from "../tsconfig-paths.mjs";
39
+ import { verdictFor } from "../verdict.mjs";
40
+ import { listTrackedFiles } from "../workspace.mjs";
41
+
42
+ /**
43
+ * A total order over violations, so a report's byte sequence is an invariant
44
+ * rather than an accident of git's index order.
45
+ *
46
+ * `listTrackedFiles` returns `git ls-files` output verbatim, and the analysis
47
+ * and rule layers preserve exactly that order through `evaluate()` — which
48
+ * makes byte-identity hold today only because git's index happens to sort
49
+ * paths. A filesystem or git version that stopped guaranteeing that order
50
+ * would silently reorder every report while the determinism contract
51
+ * (`docs/reference/json-output.md`) still claimed byte-identity. Sorting here —
52
+ * at the boundary between the engine and its reports — makes `(sourceFile,
53
+ * line, column, messageId)` the one place a reorder could come from, and
54
+ * plain `<` comparison means the ordering never varies with the invoking
55
+ * environment's collation (`../../../../AGENTS.md`, the localeCompare invariant).
56
+ *
57
+ * @param {object[]} violations `Violation` records from `../rules/`.
58
+ * @returns {object[]} A new array, sorted; the input is not mutated.
59
+ */
60
+ export function sortViolations(violations) {
61
+ return [...violations].sort((a, b) => {
62
+ if (a.sourceFile !== b.sourceFile) return a.sourceFile < b.sourceFile ? -1 : 1;
63
+ if (a.line !== b.line) return a.line < b.line ? -1 : 1;
64
+ if (a.column !== b.column) return a.column < b.column ? -1 : 1;
65
+ if (a.messageId !== b.messageId) return a.messageId < b.messageId ? -1 : 1;
66
+ return 0;
67
+ });
68
+ }
69
+ /** Renderers for the two formats whose output is a report, not an envelope. */
70
+ const FORMATS = Object.freeze({ text: formatReport, sarif: formatSarif });
71
+ /**
72
+ * The per-project file each provider declares an edge in, and the field it
73
+ * spells the declaration with — the two facts a declared-edge finding needs to
74
+ * point somewhere real despite having no import site.
75
+ *
76
+ * One table rather than two lookups, because the file and the field are the
77
+ * same provider's vocabulary and a run that got one from Moon and the other
78
+ * from Nx would render a `moon.yml` blamed for an `implicitDependencies` it
79
+ * has no field for.
80
+ *
81
+ * - **Nx** declares it per-project, in that project's own `project.json`,
82
+ * under `implicitDependencies`.
83
+ * - **Moon** declares it per-project too, in that project's own `moon.yml`,
84
+ * under `dependsOn`. Moon calls such a dependency `explicit` — its own
85
+ * inverse of this package's word — and `../providers/moon.mjs`'s
86
+ * `edgeTypeFromScope` is where the two vocabularies are mapped onto each
87
+ * other; `docs/integrations/moon.md`'s "explicit → implicit" row and
88
+ * `../../e2e/moon.e2e.mjs` both drive the same `moon.yml`.
89
+ * - **native** carries no per-project file of its own: `archkeep.json`
90
+ * validates the row regardless of whether it sits in that file's
91
+ * `projects.declared` or in a tracked `project.json`
92
+ * (`../providers/native/discover.mjs`'s union of the two), so
93
+ * `archkeep.json` is the workspace's single source of truth that opted
94
+ * either one in — the same reasoning `coverage.exempt`'s attribution above
95
+ * uses. `perProject: false` is what says so.
96
+ */
97
+ const DECLARED_EDGE_SITE = Object.freeze({
98
+ nx: { file: "project.json", field: "implicitDependencies", perProject: true },
99
+ moon: { file: "moon.yml", field: "dependsOn", perProject: true },
100
+ native: { file: ARCHKEEP_MODEL_FILE, field: "implicitDependencies", perProject: false },
101
+ });
102
+
103
+ /**
104
+ * Which field name the run's provider calls a declared edge, for the one
105
+ * sentence the report writes about it (`../report/text.mjs`'s
106
+ * `formatDeclaredEdges`).
107
+ *
108
+ * A provider this table does not know falls back to the native row rather
109
+ * than to `undefined`: every provider that exists resolves here, and a name
110
+ * is only ever read as prose, so an unknown one is a wrong noun rather than a
111
+ * missing verdict. The verdict itself — the finding list — is unaffected.
112
+ *
113
+ * @param {string} provider
114
+ * @returns {string}
115
+ */
116
+ function declaredEdgeField(provider) {
117
+ return (DECLARED_EDGE_SITE[provider] ?? DECLARED_EDGE_SITE.native).field;
118
+ }
119
+
120
+ /**
121
+ * Where a declared-edge finding's declaration lives, so the finding can point
122
+ * somewhere real despite having no import site.
123
+ *
124
+ * **The path has to be one the reader's checkout actually contains.** A
125
+ * non-Nx provider used to get `archkeep.json` unconditionally, which on a Moon
126
+ * workspace names a file that provably cannot be there: a Moon tree carrying
127
+ * `archkeep.json` is refused outright, exit 3, before any rule runs
128
+ * (`./context.mjs`'s `refusal(moonMarker, ARCHKEEP_MODEL_FILE)`).
129
+ * That is not only a confusing text line — GitHub's code scanning silently
130
+ * DROPS a SARIF result whose `uri` is not a real repository-relative path,
131
+ * which is exactly the failure `../custom-rules/host.mjs`'s
132
+ * `isWorkspaceRelative` refuses for a wasm rule's own findings, so a
133
+ * declared-edge violation reported through `--format sarif` disappeared with
134
+ * no error anywhere. Moon reaches this function on every hand-written
135
+ * `dependsOn` since #262 inverted the Moon `explicit`/`implicit` mapping (the
136
+ * comment here previously claimed it could not reach it at all), so that path
137
+ * is now the common one rather than a corner.
138
+ *
139
+ * The per-project half is built from the graph node's own `root` — the
140
+ * finding carries a project NAME, and the node is the only place its
141
+ * directory is known — the same derivation the Nx arm has always used.
142
+ *
143
+ * @param {{provider: string, graph: {nodes: object}}} commandContext
144
+ * @param {string} sourceProject
145
+ * @returns {string} A workspace-relative path.
146
+ */
147
+ function declaredEdgeManifest({ provider, graph }, sourceProject) {
148
+ const site = DECLARED_EDGE_SITE[provider] ?? DECLARED_EDGE_SITE.native;
149
+ if (!site.perProject) return site.file;
150
+ const root = graph.nodes[sourceProject]?.data?.root;
151
+ // A root of `.` (or `""`, or a trailing slash) is the workspace root
152
+ // itself — Moon spells a root-level project's `source` exactly that way
153
+ // (`../providers/moon.mjs`'s `inferWorkspaceLayout` names the same
154
+ // spelling) — and `./project.json` is a different string from
155
+ // `project.json` to every consumer that compares paths, this file's own
156
+ // SARIF `uri` included.
157
+ const scoped = typeof root === "string" ? root.replace(/\/+$/u, "") : "";
158
+ return scoped === "" || scoped === "." ? site.file : `${scoped}/${site.file}`;
159
+ }
160
+ /**
161
+ * Runs one `check`: workspace, analysis, rules, report.
162
+ *
163
+ * Returns the report and the counts rather than printing, so the caller owns
164
+ * both the destination and the exit code — and so a test can read the verdict
165
+ * without a subprocess.
166
+ *
167
+ * The workspace/provider/analysis preamble is `./context.mjs`'s
168
+ * `resolveCommandContext` — this function's whole body used to BE that
169
+ * preamble, before a second command existed to need it too. What is still
170
+ * this function's own: loading the boundary policy, the go.work drift check,
171
+ * the tsconfig paths hygiene check, judging the rules, and rendering the
172
+ * report in whichever format was asked for.
173
+ *
174
+ * `readGraph` and `listFiles` are the two seams that reach outside this
175
+ * process — Nx and git — threaded straight through to `resolveCommandContext`.
176
+ * Injectable for the same reason every resolver in this project takes its
177
+ * readers: a test drives the real analysis, the real rules and the real
178
+ * report over a fixture tree, and pins the exact `file:line:column` a
179
+ * developer would act on, without an Nx installation or a git repository.
180
+ *
181
+ * @param {{format: string, config: string|null, paths: string[],
182
+ * evidenceOut?: string|null}} options
183
+ * @param {{cwd: string, readGraph?: Function, listFiles?: Function}} context
184
+ * @returns {Promise<{report: string, violations: number, declaredEdgeFindings: number,
185
+ * goWorkDrift: number, tsconfigPathsDead: number, intentFindings: number,
186
+ * intentUnresolved: number, intentUnresolvedDecisionRefs: number, fitnessFail: number,
187
+ * fitnessUnknown: number, customRuleFail: number, customRuleUnknown: number,
188
+ * customRuleEvidence: {rule: string, bytes: Uint8Array}[], customRulesDeclared: boolean,
189
+ * analyzed: number, unchecked: number, waived?: number}>}
190
+ */
191
+ export async function check(options, { cwd, readGraph, listFiles = listTrackedFiles }) {
192
+ const commandContext = resolveCommandContext(
193
+ { cwd, paths: options.paths },
194
+ { readGraph, listFiles },
195
+ );
196
+ const { root, graph, workspace, tracked } = commandContext;
197
+ const { imports, exemptedFiles } = commandContext.analysis;
198
+ const failures = [...commandContext.analysis.failures];
199
+ const analyzed = commandContext.analysis.analyzed;
200
+
201
+ // The config's location is a separate fact from the workspace root, which is
202
+ // why `--config` does not move the root: pointed at a consumer's tree, the
203
+ // tool and the law it enforces are in different trees, and the tree being
204
+ // judged is still the consumer's. Loaded before the three workspace-level
205
+ // checks below (go.work drift, dead tsconfig path aliases, and the declared
206
+ // `implicitDependencies` edges), the same order `check` has always used — a
207
+ // malformed `--config` stops the run before any of them runs at all.
208
+ // `resolvePolicy` owns the profile/file/inline priority — see its own doc for the order and
209
+ // why a `profiles` registry, when named, takes `--config`/`boundaryConfig`
210
+ // over as a profile NAME rather than a filename. `check` is the one caller
211
+ // of the eleven that also needs to know WHICH profile/file resolved it —
212
+ // `profile`/`source` — so its report can name the law that governed the run
213
+ // (P1-01): a violating tree under a weak policy and a clean tree under a
214
+ // strict one used to produce byte-identical output, with nothing anywhere in
215
+ // the report saying which law had run. `profile` is `null` on every branch
216
+ // but the profile one — stated, not omitted, the same "no fact, no claim"
217
+ // bargain `goWork`/`tsconfigPaths` keep below for a feature a workspace does
218
+ // not use either. `source` is always workspace-relative, the convention
219
+ // every other file reference in this report already keeps (`sourceFile`,
220
+ // `tsConfig`, intent's `file`).
221
+ const {
222
+ config,
223
+ profile: policyProfile,
224
+ source: policySource,
225
+ } = await resolvePolicy(options, commandContext, cwd);
226
+ // D-10: provenance is resolved once, up front — BEFORE any verdict — and
227
+ // reused in the JSON envelope, so a commitless repository (an unborn HEAD,
228
+ // which makes `git ls-files` report zero files) is a loud exit-3 could-not-
229
+ // look in BOTH report formats instead of a quiet "0 imports in 0 files"
230
+ // claim. `resolveProvenance` throws for that state; `null` here means "git
231
+ // not available or not a repository at all", a legitimate no-origin-claim.
232
+ const provenance = resolveProvenance(root);
233
+ // The policy's own fingerprint, alongside its source — the SHA-256 of the
234
+ // canonicalized policy (`depConstraints`/`options`/`suppressions`) that
235
+ // `graph`/`diff`/`history` already share (`computePolicyFingerprint`,
236
+ // `./graph.mjs`), reused here rather than recomputed by hand so
237
+ // two runs under the identical effective policy always agree, whichever of
238
+ // `resolvePolicy`'s branches produced it. Unlike `graph`'s own optional
239
+ // `result.policy` — absent when no config was given — `check` always loads
240
+ // exactly one policy before it can judge anything, so `policy` is `null`
241
+ // only on the one defensive arm `resolvePolicy` itself documents as
242
+ // unreachable by any current provider.
243
+ const policy = config
244
+ ? {
245
+ profile: policyProfile,
246
+ source: policySource,
247
+ fingerprint: computePolicyFingerprint(config),
248
+ }
249
+ : null;
250
+
251
+ // The go.work drift check, keyed off the manifest's presence the way every
252
+ // resolver keys off its language's manifest: no tracked root go.work, no
253
+ // check and no mention. It ignores `options.paths` on purpose — two
254
+ // workspace facts are compared, not files analyzed — and a go.work the
255
+ // parser cannot read becomes a whole-file failure (exit 3) rather than a
256
+ // truncated use list, because a use list cut short at the malformed line
257
+ // would hide every stale entry below it while inventing missing-use
258
+ // findings above it — a verdict about a file that was never read
259
+ // (`../go-work.mjs`).
260
+ let goWork = null;
261
+ if (tracked.includes("go.work")) {
262
+ try {
263
+ const goWorkText = workspace.readFile("go.work");
264
+ if (goWorkText === null) throw new Error("go.work could not be read");
265
+ goWork = compareGoWork({
266
+ uses: parseGoWorkUse(goWorkText),
267
+ workspaceRoot: root,
268
+ projects: workspace.projects,
269
+ files: tracked,
270
+ });
271
+ } catch (cause) {
272
+ failures.push(
273
+ fileFailure(
274
+ "go.work",
275
+ `${cause?.message ?? cause} — a go.work this tool cannot read is a coverage hole, ` +
276
+ `not an empty use list, so the drift check reached no verdict`,
277
+ ),
278
+ );
279
+ }
280
+ }
281
+
282
+ // The tsconfig paths hygiene check, keyed the same way: no `paths` table in
283
+ // the workspace tsconfig — or no tsconfig at all — means no check and no
284
+ // mention. The table, its base and the failure posture all come from the
285
+ // resolver's own parsed context (`tsconfigPathsFacts`), so the file judged
286
+ // here is provably the file `ts.resolveModuleName` reads, and a tsconfig
287
+ // that failed to load is a whole-file failure (exit 3) here exactly as it is
288
+ // at every TypeScript import site — never an absent table. Only existence is
289
+ // asked of the filesystem, because the judgement is about directories on
290
+ // disk, the same disk the resolver probes (`../tsconfig-paths.mjs` owns the
291
+ // rule and its limits). Like go.work, `options.paths` is ignored on purpose:
292
+ // a workspace fact is judged, not files analyzed.
293
+ let tsconfigPaths = null;
294
+ {
295
+ const facts = tsconfigPathsFacts(workspace);
296
+ if (facts.configFailure !== null) {
297
+ failures.push(
298
+ fileFailure(
299
+ facts.tsConfig,
300
+ `${facts.configFailure} — and the paths hygiene check reached no verdict, because a ` +
301
+ `tsconfig this tool cannot load is a coverage hole, not an empty alias table`,
302
+ ),
303
+ );
304
+ } else if (facts.paths !== undefined) {
305
+ tsconfigPaths = judgeTsconfigPaths({
306
+ paths: facts.paths,
307
+ base: facts.base,
308
+ workspaceRoot: root,
309
+ tsConfig: facts.tsConfig,
310
+ directoryExists: (dir) => {
311
+ try {
312
+ return statSync(join(root, dir)).isDirectory();
313
+ } catch {
314
+ return false;
315
+ }
316
+ },
317
+ });
318
+ for (const { reason } of tsconfigPaths.malformed) {
319
+ failures.push(fileFailure(facts.tsConfig, reason));
320
+ }
321
+ }
322
+ }
323
+
324
+ // The architecture-intent check, keyed the same way as go.work and tsconfig:
325
+ // a tracked root `architecture-intent.json` means the workspace declared the
326
+ // architecture it intends, and the observed graph is judged against it. An
327
+ // intent file that fails to parse or validate is a no-verdict (exit 3) on
328
+ // the intent axis rather than "no intent" — a declaration this tool cannot
329
+ // establish must never read as one that was verified. It is NOT folded into
330
+ // `failures`: that bucket means "a source file the analysis could not read",
331
+ // and counting `architecture-intent.json` there would flip
332
+ // `coverage.complete` and list it among `notAnalyzed` source holes, which a
333
+ // reader would misread as a coverage problem with source files (the design
334
+ // contract `docs/reference/architecture-intent.md` states: a malformed
335
+ // intent renders as a distinct line, never as "N files could not be
336
+ // analyzed"). It rides `intentUnresolved` instead, the same no-verdict lane
337
+ // a zero-member boundary takes.
338
+ /** @type {{verdict: "ok"|"findings"|"no-verdict", findings: object[], unresolved: object[], boundaries: object[], notes: object[], unresolvedDecisionRefs?: {kind: string, decisionRef: string}[]}|null} */
339
+ let intent = null;
340
+ // The intent rows that carry a `decisionRef`, for `check`'s own citation
341
+ // pass — empty when no intent is tracked, and populated by the fold below
342
+ // (F01: an intent row citing nothing the registry knows must be surfaced
343
+ // the way `drift`/`provenance` surface it, not left silent).
344
+ let intentDecisionRefRows = [];
345
+ if (tracked.includes(INTENT_FILE)) {
346
+ try {
347
+ // The drift fold runs the same refuse-incomplete-graph guard the `drift`
348
+ // command does: an Nx workspace whose polyglot manifests are invisible to
349
+ // the graph must not have its intent judged against a graph that cannot
350
+ // see them. `driftForCheck` is what `drift` and `check` share.
351
+ const drift = await driftForCheck(commandContext, {
352
+ loadIntentOverride: (root) => loadIntent(root, { tracked }),
353
+ });
354
+ intent = {
355
+ verdict:
356
+ drift.findings.length > 0
357
+ ? "findings"
358
+ : drift.unresolved.length > 0
359
+ ? "no-verdict"
360
+ : "ok",
361
+ boundaries: drift.boundaries,
362
+ findings: drift.findings,
363
+ unresolved: drift.unresolved,
364
+ notes: drift.notes,
365
+ };
366
+ // The fold's intent rows carrying a `decisionRef` — the rows `drift`/
367
+ // `provenance` judge loudly, and which `check` must not leave silent
368
+ // (F01). Captured here, outside the `intent` object, so the citation
369
+ // pass below can judge them through the same registry as the boundary
370
+ // rows.
371
+ intentDecisionRefRows = drift.decisionRefRows;
372
+ } catch (cause) {
373
+ const reason =
374
+ `${cause?.message ?? cause} — architecture-intent.json could not be ` +
375
+ `established, so the intent check reached no verdict`;
376
+ intent = {
377
+ verdict: "no-verdict",
378
+ findings: [],
379
+ unresolved: [{ boundary: INTENT_FILE, issue: reason }],
380
+ boundaries: [],
381
+ notes: [],
382
+ };
383
+ }
384
+ }
385
+
386
+ // Both faces of one walk: the run's verdict, and the raw superset it was
387
+ // picked from — every candidate up to each site's surviving group, including
388
+ // the verdicts the suppression table removed to get there. `evaluate` alone
389
+ // cannot answer whether a row is dead, because a row that removes everything
390
+ // leaves no trace in the verdict; the raw side of this walk is the trace.
391
+ // The reference instant is fixed once here and threaded into the walk, so
392
+ // the gate's waiver-expiry judgement below and the engine's are the same
393
+ // judgement, not two reads of the clock a boundary instant could split.
394
+ const now = referenceTime();
395
+ const { violations: judged, rawViolations } = evaluateRun(imports, graph, { ...config, now });
396
+ const violations = sortViolations(judged);
397
+ // An ACTIVE waiver keeps the violation it accepts in the findings list,
398
+ // marked `waivedBy` — the run is still non-zero (waiving does not flip
399
+ // exit 1 → 0), and this count is the additive "accepted violations" number
400
+ // the report surfaces. Expired waivers re-assert in full (evidence
401
+ // "expired waiver"), so they are ordinary violations here, never waived.
402
+ const waived = violations.filter((violation) => violation.waivedBy).length;
403
+
404
+ // `evaluate()` above judges only import sites, by design (`../rules/README.md`:
405
+ // "analysis records and the loaded config, nothing else"). An `implicit`-typed
406
+ // graph edge — `implicitDependencies` in a `project.json`/`archkeep.json` row —
407
+ // has no import site behind it and so never reaches that loop, which used to
408
+ // mean `check` reported nothing about it while `context`/`impact` (walking
409
+ // `graph.dependencies` directly through the same `judgeEdge`) showed it as a
410
+ // tag violation: an empty result that was not actually a clean verdict, the
411
+ // exact defect this project's invariant forbids (`../../../../AGENTS.md`). Reusing
412
+ // `judgeEdge` here — rather than a second implementation — is what guarantees
413
+ // `check` can never disagree with what those commands already display for the
414
+ // identical edge.
415
+ const implicitEdges = Object.values(graph.dependencies ?? {})
416
+ .flat()
417
+ .filter((edge) => edge.type === "implicit").length;
418
+ const declaredEdgeViolations = declaredEdgeViolationsForCheck(graph, config.depConstraints).map(
419
+ (violation) => ({ ...violation, file: declaredEdgeManifest(commandContext, violation.source) }),
420
+ );
421
+ const declaredEdgeFindings = declaredEdgeViolations.length;
422
+ // `null` — not `{checked: true, findings: []}` — when the graph has no
423
+ // `implicit` edges at all: the same "no fact, no claim" bargain go.work and
424
+ // tsconfig-paths state for a workspace that never uses the feature either,
425
+ // so a workspace with no `implicitDependencies` anywhere pays nothing and
426
+ // hears nothing, rather than a near-universal "0 implicit edges" line.
427
+ // `declaration` names the field the RUN's provider spells a declared edge
428
+ // with, so the report's own sentence about it can too — a Moon workspace has
429
+ // no `implicitDependencies` field for the text to blame, and naming one sends
430
+ // a reader looking for a key `moon.yml` does not accept. Report-only: it
431
+ // rides beside the verdict, never into it, and the JSON envelope below still
432
+ // publishes exactly `judged` and `findings`.
433
+ const declaredEdges =
434
+ implicitEdges === 0
435
+ ? null
436
+ : {
437
+ findings: declaredEdgeViolations,
438
+ judged: implicitEdges,
439
+ declaration: declaredEdgeField(commandContext.provider),
440
+ };
441
+
442
+ // A `depConstraints` row's `decisionRef` names the ADR (or rule/fitness id)
443
+ // that supposedly authorizes it — but nothing verified that citation before
444
+ // it reached a report: `rowSchemaViolations` (`../governance/row-schema.mjs`)
445
+ // has always had a resolution half gated on an injected `io.resolve`, and
446
+ // `config.mjs` has never supplied one, so `resolveDecisionRef`
447
+ // (`../governance/adr-registry.mjs`) had zero production callers. A rule
448
+ // bound to a nonexistent ADR id fired exactly as designed and the report
449
+ // quoted the citation as if it were confirmed. Checked only when a row
450
+ // actually carries one — the common case (no `docs/adr/` adopted yet) pays
451
+ // no extra read. Report-only: an unresolved citation is a fact about the
452
+ // rule's DOCUMENTATION, not about whether the boundary holds, so it changes
453
+ // no byte of `verdictFor`'s inputs below — the same posture `provenance`
454
+ // already takes for a row with no `origin` (`./provenance-command.mjs`).
455
+ //
456
+ // The intent's rows are judged through the SAME registry, in the SAME pass,
457
+ // so `check` cannot disagree with `drift`/`provenance` about which intent
458
+ // citations resolve. Unlike the boundary rows, an intent row with an
459
+ // unresolved `decisionRef` folds into the no-verdict lane (exit 3) WHEN THE
460
+ // INTENT IS APPLIED — a workspace that declared an intended architecture
461
+ // whose governing decision does not exist cannot claim `ok` on that axis
462
+ // (`drift`/`provenance` already flag the identical row loudly). That is the
463
+ // F01 parity the audit names: `drift` and `provenance` both surface it, and
464
+ // the gate CI runs must not be the one face that stays silent.
465
+ const unresolvedDecisionRefs = new Set();
466
+ const intentUnresolvedDecisionRefRows = [];
467
+ {
468
+ // Intent rows while the intent is actually tracked — the same gate the
469
+ // fold above uses, so a workspace with no intent pays nothing and hears
470
+ // nothing.
471
+ const decisionRefRows = [
472
+ ...config.depConstraints
473
+ .map((row, index) => ({ kind: `depConstraints[${index}]`, row }))
474
+ .filter(({ row }) => typeof row?.decisionRef === "string" && row.decisionRef.trim() !== ""),
475
+ ...intentDecisionRefRows,
476
+ ];
477
+ if (decisionRefRows.length > 0) {
478
+ const adrContext = readAdrContext(root, { tracked });
479
+ // F04: the fitness half resolves against the ids the loaded policy
480
+ // DECLARES (`declaredFitnessNames(config)`), never the ADRs' own
481
+ // `bindings` — a citation cannot resolve itself.
482
+ for (const row of unresolvedDecisionRefRows(
483
+ decisionRefRows,
484
+ adrContext.byId,
485
+ declaredFitnessNames(config),
486
+ )) {
487
+ unresolvedDecisionRefs.add(row.decisionRef);
488
+ if (row.kind.startsWith("depConstraints")) continue;
489
+ intentUnresolvedDecisionRefRows.push({ kind: row.kind, decisionRef: row.decisionRef });
490
+ }
491
+ }
492
+ // The unresolved intent citations ride the intent object so the text
493
+ // report can render them inline (the same UNRESOLVED note the constraint
494
+ // rows get), and so the JSON intent block names WHICH rows cited what.
495
+ if (intent !== null && intentUnresolvedDecisionRefRows.length > 0) {
496
+ intent.unresolvedDecisionRefs = intentUnresolvedDecisionRefRows;
497
+ }
498
+ }
499
+
500
+ const goWorkDrift = goWork === null ? 0 : goWork.findings.length;
501
+ const tsconfigPathsDead = tsconfigPaths === null ? 0 : tsconfigPaths.findings.length;
502
+ const intentFindings = intent === null ? 0 : intent.findings.length;
503
+ const intentUnresolved = intent === null ? 0 : intent.unresolved.length;
504
+
505
+ // The fitness fold, keyed the same two ways every governance axis is: a
506
+ // policy that declares fitness (the `fitness` export on the boundary
507
+ // config) is judged against the run's own facts — the same graph, the same
508
+ // analysis, the same intent verdict, the same suppressions `check` already
509
+ // holds. Absence is a workspace decision (the key is omitted, never
510
+ // `null`); presence without a number of declared rows is impossible (the
511
+ // validator refuses an empty list). A declared function whose match selects
512
+ // no project is `skipped` — loud, never folded into `pass`; a function the
513
+ // run could not determine is `unknown`, which rides `fitnessUnknown`, the
514
+ // same no-verdict lane a zero-member boundary takes. `scoped` marks the
515
+ // path-scoped case (`check <path>`), where coverage over a matched
516
+ // project's whole file set is not determinable — the registry answers that
517
+ // `not_applicable`, never a partial-number verdict and never `unknown`
518
+ // either (P1-19): `coverage-minimum` cannot be judged by ANY scoped run,
519
+ // which is a fact about the run mode, not evidence of a coverage hole, so it
520
+ // joins `skipped` in NOT riding `fitnessFail`/`fitnessUnknown` below — a
521
+ // scoped run over an otherwise-clean subtree in a workspace that declares
522
+ // `coverage-minimum` no longer exits 3 for a question this run was never in
523
+ // a position to answer.
524
+ let fitness = null;
525
+ if (config.fitness !== undefined) {
526
+ const { decisions, overall } = fitnessForCheck(commandContext, {
527
+ rows: config.fitness,
528
+ intent,
529
+ suppressions: config.suppressions,
530
+ scoped: options.paths.length > 0,
531
+ });
532
+ fitness = { decisions, overall };
533
+ }
534
+ const fitnessFail = fitness === null ? 0 : fitness.overall.verdict === "fail" ? 1 : 0;
535
+ const fitnessUnknown = fitness === null ? 0 : fitness.overall.verdict === "unknown" ? 1 : 0;
536
+
537
+ // The custom-rules fold, keyed by presence exactly as the fitness fold above
538
+ // is, and judged after it over the same observed facts — the same graph, the
539
+ // same analysis, the same policy. A workspace that declares no `customRules`
540
+ // reaches nothing here and hears nothing anywhere: no section, no envelope
541
+ // key, no SARIF descriptor, byte-for-byte the report it already got
542
+ // (`../../../../AGENTS.md`, "a change to what is reported on an unchanged
543
+ // workspace is a breaking change").
544
+ //
545
+ // Every load-class failure THROWS out of here rather than becoming a verdict
546
+ // — the declared law could not be read, so the run refuses the way it
547
+ // refuses a malformed boundary config, and `runCheck` below turns that into
548
+ // the same exit 3 (`./custom-rules.mjs` argues the split). The
549
+ // counts below then carry only what a loaded law decided: a `fail` is a
550
+ // finding (exit 1) and an `unknown` is a could-not-determine (exit 3), the
551
+ // identical two lanes fitness rides, per RULE rather than per aggregate
552
+ // because each declared rule is its own law and a reader acts on the one
553
+ // that failed.
554
+ let customRules = null;
555
+ if (declaresCustomRules(config)) {
556
+ customRules = await customRulesForCheck(commandContext, {
557
+ rows: config.customRules,
558
+ policy: config,
559
+ scoped: options.paths.length > 0,
560
+ // Off unless `--evidence-out` asked: the bundle carries every import
561
+ // site in the tree, and a run nobody asked to inspect should not hold
562
+ // one per rule in memory.
563
+ collectEvidence: Boolean(options.evidenceOut),
564
+ });
565
+ }
566
+ const customRuleDecisions = customRules === null ? [] : customRules.decisions;
567
+ const customRuleFail = customRuleDecisions.filter((rule) => rule.verdict === "fail").length;
568
+ const customRuleUnknown = customRuleDecisions.filter((rule) => rule.verdict === "unknown").length;
569
+
570
+ // Files the run produced no verdict about, counted here rather than
571
+ // recomputed by the caller: the exit code, the text report and the JSON
572
+ // envelope must all agree about which failures mean "not covered", and one
573
+ // predicate is how they do.
574
+ const unchecked = new Set(
575
+ failures.filter(isWholeFileFailure).map((failure) => failure.sourceFile),
576
+ ).size;
577
+
578
+ // A row of the boundary law that covers nothing is a boundary that stopped
579
+ // being enforced, and — unlike a missing `reason`, which only a human can
580
+ // judge — it is machine-detectable. Two tables can be dead, and both are
581
+ // refused here with the same exit and the same sentence shape the stale
582
+ // `coverage.exempt` row has always gotten (`../providers/native/index.mjs`):
583
+ //
584
+ // - a `boundarySuppressions` row covering no candidate violation, measured
585
+ // against `rawViolations` above (the candidates up to each site's
586
+ // surviving group, so a row covering a verdict ANOTHER row removed first
587
+ // still counts as alive). Waiver rows still in force are excluded: their
588
+ // lifecycle is the `waivers` command's informational surface, which has
589
+ // always reported a term-bearing row that currently covers nothing as
590
+ // stale rather than fatal — a fixed violation leaves its waiver idle until
591
+ // expiry, and that resting state is the feature working, not a defect. An
592
+ // EXPIRED waiver (`fate === "reassert"`) is the opposite state: its term
593
+ // has lapsed, it can never come back into force without an edit, and it
594
+ // sits in the table forever — exactly as dead as a permanent row, refused
595
+ // here with it, named with its lapse date.
596
+ // - a `depConstraints` row selecting no project as its source
597
+ // (`unmatchedConstraintRows`), which approves everything on its axis while
598
+ // reading as enforced. This half applies to law the workspace WROTE — a
599
+ // filename at its root, an inline policy, a registry it keeps in the tree.
600
+ // A profile resolved from inside a dependency install (`node_modules`) is
601
+ // exempt: a shipped policy pack is data adopted wholesale
602
+ // (`docs/usage/presets.md` — "a pack saves you the blank page"), written
603
+ // for trees that instantiate its style at their own pace, so holding its
604
+ // rows to this tree's tag vocabulary would refuse every partial adoption —
605
+ // the false-positive direction. A rename under an adopted pack is still
606
+ // loud for the reason that feature already ships: the renamed projects
607
+ // stop matching any row, and the no-matching-constraint-is-an-error rule
608
+ // reports them on their first import (`../rules/tags.mjs`'s header).
609
+ //
610
+ // Both verdicts fire only where they are KNOWABLE — a whole-workspace run
611
+ // over a tree this run fully analyzed. A path-scoped run judges part of the
612
+ // tree, and files whose analysis failed contribute no candidates at all;
613
+ // either way a row covering nothing in what this run saw may cover plenty in
614
+ // what it did not (a scoped run over a half-adopted style pack being the
615
+ // everyday case for the constraint half), and refusing there would be the
616
+ // false-positive direction. The language server's per-file path never
617
+ // reaches this function, so it needs no guard of its own. The constraint
618
+ // verdict needs only the graph, which scoping never narrows, but it rides
619
+ // the same gate rather than a second one — one dialect, one condition pair,
620
+ // stated once (`../../../../AGENTS.md`, "Never state a rule twice").
621
+ if (
622
+ config !== null &&
623
+ options.paths.length === 0 &&
624
+ failures.length === 0 &&
625
+ (config.suppressions.length > 0 || config.depConstraints.length > 0)
626
+ ) {
627
+ const deadRows = [];
628
+ config.suppressions.forEach((row, index) => {
629
+ const fate = suppressionFate(row, now);
630
+ if (fate === "waive") return;
631
+ if (rawViolations.some((violation) => suppressionCovers(row, violation))) return;
632
+ const expired = fate === "reassert" ? ` (expired ${row.expiresAt})` : "";
633
+ deadRows.push(
634
+ `boundarySuppressions[${index}]: '${row.path}'${expired} matches no violation this run ` +
635
+ `judged — either the code it accepted is gone, or the path was never right`,
636
+ );
637
+ });
638
+ const authoredLaw =
639
+ policySource === null || !policySource.split(/[\\/]/u).includes("node_modules");
640
+ if (authoredLaw && Object.keys(graph.nodes).length > 0) {
641
+ for (const { row, index } of unmatchedConstraintRows(config.depConstraints, graph)) {
642
+ const selector = Array.isArray(row.allSourceTags)
643
+ ? `allSourceTags (${row.allSourceTags.join(", ")})`
644
+ : `sourceTag '${row.sourceTag}'`;
645
+ deadRows.push(
646
+ `depConstraints[${index}]: ${selector} matches no project in the graph — the row ` +
647
+ `selects no source, and a constraint matching nothing does not error, it approves. ` +
648
+ `Either its tags were renamed out from under it or they were never right`,
649
+ );
650
+ }
651
+ for (const { index, position, tag } of orphanedNotDependOnTags(
652
+ config.depConstraints,
653
+ graph,
654
+ )) {
655
+ deadRows.push(
656
+ `depConstraints[${index}].notDependOnLibsWithTags[${position}]: '${tag}' is carried by ` +
657
+ `no project in the graph — the ban names nothing that can exist, so this axis of the ` +
658
+ `row forbids nothing while reading as enforced. Either the tag was renamed out from ` +
659
+ `under it or it was never right`,
660
+ );
661
+ }
662
+ }
663
+ if (deadRows.length > 0) {
664
+ throw new Error(
665
+ `archkeep: ${policySource ?? "the boundary config"} describes a workspace that does not ` +
666
+ `match the tree:\n ${deadRows.join("\n ")}`,
667
+ );
668
+ }
669
+ }
670
+
671
+ // A `coverage.exempt` row removes a file from `unclaimed` before this run
672
+ // ever sees it — legitimately, for vendored or generated code — but nothing
673
+ // that removal produces was ever named in any report: an exempted file and
674
+ // a genuinely covered one read identically in every surface `check`
675
+ // produces. Stated as a note for the same reason the polyglot coverage gap
676
+ // below is: the exit code and verdict are unchanged (this is what
677
+ // `coverage.exempt` is FOR), but the silent direction — a reader unable to
678
+ // tell that coverage narrowed at all — is closed.
679
+ const exemptionNote =
680
+ exemptedFiles.length > 0
681
+ ? `${exemptedFiles.length} file${exemptedFiles.length === 1 ? "" : "s"} exempted from ` +
682
+ `coverage by ${ARCHKEEP_MODEL_FILE}'s coverage.exempt`
683
+ : null;
684
+
685
+ // The other half of the same fact (#218): an import whose record resolved
686
+ // INTO one of those files is judged unconstrained — neither a project edge
687
+ // nor an external import (`../rules/index.mjs`'s `exemptResolvedFile`,
688
+ // the same predicate the rule engine decided the site with, not a second
689
+ // membership test that could drift from it). Without this count, "the run
690
+ // chose not to constrain these imports" and "these imports never existed"
691
+ // would render identically — the silent direction again. Stated only when
692
+ // nonzero, so a workspace with exempt files but no imports of them keeps
693
+ // byte-identical output; and always directly after `exemptionNote`, which
694
+ // is what "those files" below points at.
695
+ const exemptedSet = new Set(exemptedFiles);
696
+ const unconstrainedExemptImports = imports.filter(
697
+ (site) => exemptResolvedFile(site, exemptedSet) !== null,
698
+ ).length;
699
+ const unconstrainedImportNote =
700
+ unconstrainedExemptImports > 0
701
+ ? `${unconstrainedExemptImports} import${unconstrainedExemptImports === 1 ? "" : "s"} ` +
702
+ `resolve${unconstrainedExemptImports === 1 ? "s" : ""} into those files and ` +
703
+ `${unconstrainedExemptImports === 1 ? "is" : "are"} left unconstrained — neither project edges nor external imports`
704
+ : null;
705
+
706
+ // Ready-to-ship policy facts have always been sourced from `boundaryConfig`
707
+ // via `../config.mjs`'s `notes`; the intent check's own coverage notes ride
708
+ // the same seam so both surfaces (text and JSON) thread them identically —
709
+ // today only an `"optional": true` allowed row whose statement is absent.
710
+ const notes = [
711
+ ...(config.notes ?? []),
712
+ ...(intent === null ? [] : intent.notes),
713
+ ...(exemptionNote === null ? [] : [exemptionNote]),
714
+ ...(unconstrainedImportNote === null ? [] : [unconstrainedImportNote]),
715
+ ];
716
+
717
+ // A polyglot coverage gap: the Nx graph carries no polyglot edges because
718
+ // the plugin is not registered, but polyglot manifests exist under project
719
+ // roots. The checker still judged every import it found — this is not a
720
+ // finding and not a refusal — but `nx affected` and `@nx/enforce-module-boundaries`
721
+ // are blind to Go, Rust and Python dependencies. Surfaced as a
722
+ // degraded-coverage note so the silent direction (exit 0 with no mention) is
723
+ // closed, without changing the exit code or verdict. The condition mirrors
724
+ // `./graph.mjs`'s refusal, which throws for the same state
725
+ // because a descriptive command's output is the graph itself — here the
726
+ // checker's own analysis covers what the graph does not, so a note is the
727
+ // right level. cf. #38
728
+ //
729
+ // The second kind rides the same channel for the same reason: tracked
730
+ // analyzable files no project owns, in the languages `./context.mjs`'s
731
+ // `UNCLAIMED_CHECK_LANGUAGES` deliberately does NOT fail on. Skipping them
732
+ // is the documented, unchanged decision (`../../../../docs/reference/violations.md`,
733
+ // "The order matters" step 2); leaving no trace of the skip anywhere in the
734
+ // report was not — the run printed byte-identical output whether fifty
735
+ // files sat outside every project or none did (#263). Appended AFTER the
736
+ // plugin gap, and contributed only when the list is non-empty, so a
737
+ // workspace with no unowned analyzable file reports exactly the bytes it
738
+ // reported before. Like the gap above it: no exit code, no verdict, and
739
+ // `coverage.complete` untouched — those belong to `unchecked`, and moving
740
+ // this state into them would turn `check` red on trees whose only sin is a
741
+ // root-level tooling script.
742
+ // The law that actually governed THIS run, not the one the workspace
743
+ // declared: `policySource` already carries the `--config` override and the
744
+ // resolved profile, workspace-relative. Subtracting it here rather than
745
+ // inside `resolveCommandContext` is forced — `resolvePolicy` takes the
746
+ // context as an argument, so it cannot run before it. `tsConfig` joins it
747
+ // because it is configuration by the same test, though every spelling of it
748
+ // is `.json` today and so never reaches the list.
749
+ const unownedGap = unownedGapWithoutRunConfiguration(commandContext.unownedGap, [
750
+ policySource,
751
+ commandContext.options.tsConfig,
752
+ ]);
753
+
754
+ const coverageGaps = [
755
+ ...(commandContext.provider === "nx" &&
756
+ !commandContext.pluginGap.registered &&
757
+ commandContext.pluginGap.manifests.length > 0
758
+ ? [{ kind: "unregistered-plugin", manifests: commandContext.pluginGap.manifests }]
759
+ : []),
760
+ ...(unownedGap.files.length > 0
761
+ ? [
762
+ {
763
+ kind: "unowned-files",
764
+ // Which project model a reader has to declare the files in — the
765
+ // remediation differs by provider (`project.json` against
766
+ // `moon.yml`), and the faces that render this carry no other way
767
+ // to know which tree they are describing.
768
+ provider: commandContext.provider,
769
+ languages: unownedGap.languages,
770
+ files: unownedGap.files,
771
+ },
772
+ ]
773
+ : []),
774
+ ];
775
+
776
+ const report =
777
+ options.format === "json"
778
+ ? renderJson(
779
+ jsonEnvelope({
780
+ command: "check",
781
+ context: {
782
+ root,
783
+ provider: commandContext.provider,
784
+ marker: commandContext.marker,
785
+ // D-10: provenance rides the SAME-envelope shape every other
786
+ // command resolves through the one `resolveProvenance` — a check
787
+ // report is byte-identifiable to the git HEAD it was run on, so
788
+ // a dirty or un-stamped CI run cannot present a claim about a
789
+ // different tree state. Resolved once at the top of `check` for
790
+ // the reason that comment states.
791
+ provenance,
792
+ },
793
+ // `verdictFor` returns `status`, `exitCode`, and the canonical
794
+ // `decision` — the four-state verb of the same counts — so the
795
+ // envelope's `decision.verdict` and its `status` are built from
796
+ // exactly one computation and can never disagree.
797
+ ...verdictFor({
798
+ violations: violations.length,
799
+ declaredEdgeFindings,
800
+ goWorkDrift,
801
+ tsconfigPathsDead,
802
+ intentFindings,
803
+ intentUnresolved,
804
+ intentUnresolvedDecisionRefs: intentUnresolvedDecisionRefRows.length,
805
+ unchecked,
806
+ fitnessFail,
807
+ fitnessUnknown,
808
+ customRuleFail,
809
+ customRuleUnknown,
810
+ }),
811
+ coverage: {
812
+ complete: unchecked === 0,
813
+ projects: Object.keys(graph.nodes).length,
814
+ analyzedFiles: analyzed,
815
+ imports: imports.length,
816
+ notAnalyzed: failures
817
+ .filter(isWholeFileFailure)
818
+ .map(({ sourceFile, reason }) => ({ file: sourceFile, reason })),
819
+ blindSpots: failures
820
+ .filter((failure) => !isWholeFileFailure(failure))
821
+ .map(({ sourceFile, line, column, reason }) => ({
822
+ file: sourceFile,
823
+ line,
824
+ column,
825
+ reason,
826
+ })),
827
+ notes,
828
+ coverageGaps,
829
+ },
830
+ result: {
831
+ // Named first: which law produced everything below it (P1-01).
832
+ // Always present — `check` cannot judge anything without
833
+ // loading exactly one policy — unlike `graph`'s own `policy`,
834
+ // which is absent when no config was given to a purely
835
+ // descriptive run.
836
+ policy,
837
+ violations,
838
+ // Additive and optional: absent when the tree has no active
839
+ // waivers, so an unchanged tree's JSON is unchanged. Never `!` —
840
+ // the accepted count is a tracked decision, not a new error kind.
841
+ ...(waived > 0 ? { waived } : {}),
842
+ // Additive and optional, the same bargain: absent when every
843
+ // depConstraints row's decisionRef resolves (or none carries
844
+ // one) — an unchanged tree's JSON is unchanged. A row that fired
845
+ // stays in `violations` with its raw `constraint.decisionRef`
846
+ // untouched (byte-identical to today); this list is the
847
+ // separate, resolved fact a consumer cross-checks it against,
848
+ // never a mutation of the row itself.
849
+ ...(unresolvedDecisionRefs.size > 0
850
+ ? {
851
+ unresolvedDecisionRefs: [...unresolvedDecisionRefs].sort((a, b) =>
852
+ a < b ? -1 : a > b ? 1 : 0,
853
+ ),
854
+ }
855
+ : {}),
856
+ goWork: goWork === null ? null : { checked: true, findings: goWork.findings },
857
+ tsconfigPaths:
858
+ tsconfigPaths === null ? null : { checked: true, findings: tsconfigPaths.findings },
859
+ // `declaredEdges` is `null` under the same "no fact, no claim"
860
+ // bargain as goWork/tsconfigPaths above — computed once, right
861
+ // where `implicitEdges` is counted.
862
+ declaredEdges:
863
+ declaredEdges === null
864
+ ? null
865
+ : {
866
+ checked: true,
867
+ judged: declaredEdges.judged,
868
+ findings: declaredEdges.findings,
869
+ },
870
+ // Intent is a governance DECLARATION, absent when the workspace
871
+ // chose not to make one: the key is omitted, never written as
872
+ // null — the design contract `docs/reference/json-output.md` will
873
+ // state ("never serialized as `null` — absent key, not null
874
+ // value"). goWork/tsconfig leave a named null because those are
875
+ // checks the tool can always run (they just found nothing);
876
+ // intent absence is a workspace decision, not a finding of zero.
877
+ ...(intent === null
878
+ ? {}
879
+ : {
880
+ intent: {
881
+ checked: true,
882
+ file: INTENT_FILE,
883
+ verdict: intent.verdict,
884
+ findings: intent.findings,
885
+ unresolved: intent.unresolved,
886
+ boundaries: intent.boundaries,
887
+ // The intent rows whose `decisionRef` does not resolve —
888
+ // the same citations `result.unresolvedDecisionRefs` lists
889
+ // values for, named here with their row so the JSON
890
+ // intent block is self-contained.
891
+ ...(intent.unresolvedDecisionRefs !== undefined
892
+ ? { unresolvedDecisionRefs: intent.unresolvedDecisionRefs }
893
+ : {}),
894
+ },
895
+ }),
896
+ // Fitness is a policy DECLARATION too, absent when the workspace
897
+ // chose not to declare functions — the same omitted-key-not-
898
+ // null discipline the intent block above states.
899
+ ...(fitness === null
900
+ ? {}
901
+ : {
902
+ fitness: {
903
+ checked: true,
904
+ verdict: fitness.overall.verdict,
905
+ functions: fitness.decisions,
906
+ },
907
+ }),
908
+ // Custom rules are a policy DECLARATION as well, and take the
909
+ // same omitted-key-not-null discipline: a workspace that
910
+ // declares none has no `customRules` key at all, so its envelope
911
+ // is byte-identical to the one it got before this section
912
+ // existed — the additive-only half of the stability promise
913
+ // `docs/reference/json-output.md` publishes. `rules` (not
914
+ // `functions`) because each entry is a whole declared law rather
915
+ // than a named gate, and it carries the rule's own `findings`,
916
+ // which no fitness decision has.
917
+ ...(customRules === null
918
+ ? {}
919
+ : {
920
+ customRules: {
921
+ checked: true,
922
+ verdict: customRules.overall.verdict,
923
+ rules: customRules.decisions,
924
+ },
925
+ }),
926
+ },
927
+ }),
928
+ )
929
+ : FORMATS[options.format]({
930
+ policy,
931
+ violations,
932
+ failures,
933
+ analyzed,
934
+ imports: imports.length,
935
+ projects: Object.keys(graph.nodes).length,
936
+ goWork,
937
+ tsconfigPaths,
938
+ declaredEdges,
939
+ intent,
940
+ fitness: fitness?.decisions,
941
+ fitnessOverall: fitness?.overall,
942
+ // One object rather than the decisions/overall pair fitness passes:
943
+ // the SARIF face also needs the finding CATALOGUE (its
944
+ // reportingDescriptor set), and splitting three fields across the
945
+ // two formatters would let a face be handed one without the others.
946
+ customRules,
947
+ // Only the ESLint boundaryConfig dialect ever produces one (see
948
+ // `../eslint-config.mjs`'s `extractBoundaryRule`) — which entry it
949
+ // bound when more than one configured the rule, or that the winning
950
+ // entry was files-scoped under the accepted TS/JS shape. Computing it
951
+ // and never showing it would be the silent direction with extra
952
+ // steps, so it rides the same coverage line every other "what was
953
+ // inspected" fact does (`../report/text.mjs`'s `formatReport`).
954
+ notes,
955
+ coverageGaps,
956
+ // `formatReport` (text) reads this to annotate an unresolved
957
+ // decisionRef inline; `formatSarif` files each one as a warning
958
+ // notification. Both faces of one run name the same citations, in
959
+ // the same order — the SARIF face sorts with the comparator this
960
+ // envelope already uses, so a reader comparing them cannot find
961
+ // them disagreeing.
962
+ unresolvedDecisionRefs,
963
+ });
964
+
965
+ return {
966
+ report,
967
+ violations: violations.length,
968
+ waived,
969
+ declaredEdgeFindings,
970
+ goWorkDrift,
971
+ tsconfigPathsDead,
972
+ intentFindings,
973
+ intentUnresolved,
974
+ intentUnresolvedDecisionRefs: intentUnresolvedDecisionRefRows.length,
975
+ fitnessFail,
976
+ fitnessUnknown,
977
+ customRuleFail,
978
+ customRuleUnknown,
979
+ // Empty on every run that did not ask for it, and — deliberately — also
980
+ // on a scoped run and on a policy that declares no custom rule. `runCheck`
981
+ // tells those three apart before it writes anything, because a
982
+ // `--evidence-out` that quietly produced no file would be the silent
983
+ // direction wearing a debugging flag's name.
984
+ customRuleEvidence: customRules?.evidence ?? [],
985
+ customRulesDeclared: customRules !== null,
986
+ analyzed,
987
+ unchecked,
988
+ };
989
+ }