@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,668 @@
1
+ /**
2
+ * The built-in reusable fitness conditions — small pure functions over the
3
+ * observed snapshot, one per `condition.type`.
4
+ *
5
+ * Every function returns a decision `{verdict, evidence, message, rows?}`.
6
+ * The invariant (AGENTS.md) decides the failure path of each one: a condition
7
+ * that cannot determine its answer yields `unknown` with evidence saying which
8
+ * half of the snapshot was missing — never `pass`. `pass` is only reachable
9
+ * when the evidence the condition claims over was fully observed.
10
+ *
11
+ * Everything here is deterministic: sorted edges, sorted rows, plain `<`
12
+ * comparison, and no clock. Time-based fitness belongs behind the shared clock
13
+ * contract (E0), injected at the command boundary — see the registry.
14
+ */
15
+ import { resolveMembers } from "../architecture-intent/selectors.mjs";
16
+ import { buildReachability } from "../rules/reachability.mjs";
17
+ import { fitnessVerdict } from "./verdict.mjs";
18
+
19
+ /** `data.tags` on a node is the provider-neutral place tags live. */
20
+ function tagsOf(nodes, name) {
21
+ return nodes[name]?.data?.tags ?? [];
22
+ }
23
+
24
+ /** The direct `source → target` edges among the named projects, sorted. */
25
+ function edgesAmong(nodes, dependencies, names) {
26
+ const inSet = new Set(names);
27
+ const edges = [];
28
+ for (const [source, list] of Object.entries(dependencies ?? {})) {
29
+ if (!inSet.has(source)) continue;
30
+ for (const dependency of list ?? []) {
31
+ if (!inSet.has(dependency.target)) continue;
32
+ edges.push({ source, target: dependency.target });
33
+ }
34
+ }
35
+ return edges.sort((a, b) =>
36
+ a.source === b.source ? (a.target < b.target ? -1 : 1) : a.source < b.source ? -1 : 1,
37
+ );
38
+ }
39
+
40
+ /**
41
+ * Whether the subgraph induced on `names` contains a cycle. Only multi-project
42
+ * cycles are considered: every provider strips self-edges before the graph
43
+ * reaches the rules (`../providers/native/graph.mjs` and
44
+ * `../providers/moon.mjs` drop `source === target` edges, and the Nx graph
45
+ * cannot carry one), so a self-loop cannot occur here and is not a `cycle-free`
46
+ * finding.
47
+ *
48
+ * @param {{nodes: object, dependencies?: object}} graph
49
+ * @param {string[]} names
50
+ * @returns {string[]} The sorted names of projects on a cycle, `[]` when the
51
+ * subgraph is cycle-free.
52
+ */
53
+ export function cyclicProjects(graph, names) {
54
+ const { matrix } = buildReachability({
55
+ nodes: graph.nodes,
56
+ dependencies: graph.dependencies,
57
+ });
58
+ return names
59
+ .filter((source) =>
60
+ names.some(
61
+ (target) => source !== target && matrix[source]?.[target] && matrix[target]?.[source],
62
+ ),
63
+ )
64
+ .sort();
65
+ }
66
+
67
+ /** Direct edge data for a layer-dependency check, judged per condition. */
68
+ function layerEdges(nodes, dependencies, names, fromTag, toTag) {
69
+ return edgesAmong(nodes, dependencies, names).filter(
70
+ (edge) =>
71
+ tagsOf(nodes, edge.source).includes(fromTag) && tagsOf(nodes, edge.target).includes(toTag),
72
+ );
73
+ }
74
+
75
+ /** Whichever of `names` carry `tag` — the matched set, or every node. */
76
+ function taggedMembers(nodes, names, tag) {
77
+ return names.filter((name) => tagsOf(nodes, name).includes(tag));
78
+ }
79
+
80
+ /**
81
+ * `cycle-free` — no dependency cycle among the matched projects.
82
+ */
83
+ export function cycleFree(nodes, dependencies, names) {
84
+ const cycles = cyclicProjects({ nodes, dependencies }, names);
85
+ if (cycles.length === 0) {
86
+ return fitnessVerdict({
87
+ verdict: "pass",
88
+ name: "cycle-free",
89
+ evidence: { projects: names.length, cycles: 0 },
90
+ message: `${names.length} matched projects form no dependency cycle`,
91
+ rows: [],
92
+ });
93
+ }
94
+ return fitnessVerdict({
95
+ verdict: "fail",
96
+ name: "cycle-free",
97
+ evidence: { projects: names.length, cycles: cycles.length, cyclicProjects: cycles },
98
+ message:
99
+ `${names.length} matched projects contain a dependency cycle through ` +
100
+ `${cycles.join(", ")}`,
101
+ rows: cycles.map((project) => ({ source: project, target: project })),
102
+ });
103
+ }
104
+
105
+ /**
106
+ * `layer-dependency` — no dependency edge from any matched project carrying
107
+ * `from` to any carrying `to` (direction `"forbidden"`), or at least one such
108
+ * edge (direction `"required"`).
109
+ */
110
+ export function layerDependency(nodes, dependencies, names, { from, to, direction }) {
111
+ const fromMembers = taggedMembers(nodes, names, from);
112
+ const toMembers = taggedMembers(nodes, names, to);
113
+ const edges = layerEdges(nodes, dependencies, names, from, to);
114
+
115
+ // A condition whose source or target tag no matched project carries can
116
+ // never be satisfied or violated — reading that as either verdict would be
117
+ // the silent direction. `unknown` with the missing side named.
118
+ if (fromMembers.length === 0 || toMembers.length === 0) {
119
+ const missing = [
120
+ fromMembers.length === 0 ? `"${from}"` : null,
121
+ toMembers.length === 0 ? `"${to}"` : null,
122
+ ]
123
+ .filter(Boolean)
124
+ .join(" and ");
125
+ return fitnessVerdict({
126
+ verdict: "unknown",
127
+ name: `layer-dependency:${from}→${to}`,
128
+ evidence: {
129
+ projects: names.length,
130
+ fromMembers: fromMembers.length,
131
+ toMembers: toMembers.length,
132
+ },
133
+ message:
134
+ `cannot judge layer-dependency "${from}"→"${to}" — no matched project carries tag ${missing}, ` +
135
+ `so the condition could never be determined`,
136
+ rows: [],
137
+ });
138
+ }
139
+
140
+ if (direction === "required") {
141
+ if (edges.length > 0) {
142
+ return fitnessVerdict({
143
+ verdict: "pass",
144
+ name: `layer-dependency:${from}→${to}`,
145
+ evidence: {
146
+ projects: names.length,
147
+ fromMembers: fromMembers.length,
148
+ toMembers: toMembers.length,
149
+ edges: edges.length,
150
+ },
151
+ message: `${edges.length} edge${edges.length === 1 ? "" : "s"} carry "${from}" → "${to}" as required`,
152
+ rows: edges,
153
+ });
154
+ }
155
+ return fitnessVerdict({
156
+ verdict: "fail",
157
+ name: `layer-dependency:${from}→${to}`,
158
+ evidence: {
159
+ projects: names.length,
160
+ fromMembers: fromMembers.length,
161
+ toMembers: toMembers.length,
162
+ edges: 0,
163
+ },
164
+ message: `no observed dependency edge carries tag "${from}" → "${to}", but one is required`,
165
+ rows: [],
166
+ });
167
+ }
168
+
169
+ // direction "forbidden".
170
+ if (edges.length === 0) {
171
+ return fitnessVerdict({
172
+ verdict: "pass",
173
+ name: `layer-dependency:${from}→${to}`,
174
+ evidence: {
175
+ projects: names.length,
176
+ fromMembers: fromMembers.length,
177
+ toMembers: toMembers.length,
178
+ edges: 0,
179
+ },
180
+ message: `no dependency edge carries "${from}" → "${to}", as forbidden`,
181
+ rows: [],
182
+ });
183
+ }
184
+ return fitnessVerdict({
185
+ verdict: "fail",
186
+ name: `layer-dependency:${from}→${to}`,
187
+ evidence: {
188
+ projects: names.length,
189
+ fromMembers: fromMembers.length,
190
+ toMembers: toMembers.length,
191
+ edges: edges.length,
192
+ },
193
+ message: `${edges.length} edge${edges.length === 1 ? "" : "s"} carry "${from}" → "${to}" — forbidden`,
194
+ rows: edges,
195
+ });
196
+ }
197
+
198
+ /**
199
+ * `tag-conformance` — every direct edge LEAVING a matched project that carries
200
+ * `from` may only target (`toDependents: "only"`) or never target
201
+ * (`toDependents: "never"`) a project carrying `to`.
202
+ *
203
+ * ## `match` selects the SOURCES, and only the sources
204
+ *
205
+ * The edges are built with `edgesFrom` below, which constrains the source and
206
+ * nothing else — the same reading `tagAxisIsolation` gives `match`, through the
207
+ * same function. Requiring the TARGET to be matched too is what this used to
208
+ * do, and under `"only"` it deleted exactly the violations: an edge leaving the
209
+ * matched set is precisely the crossing the condition forbids, so a row scoped
210
+ * to the layer it governs (`match: ["tag:layer:app"]` — the natural way to
211
+ * write one) could never fail. That is the silent direction, and it is why the
212
+ * target side is not scoped by `match` here — only by being a project at all.
213
+ *
214
+ * `to` membership is therefore a fact about the whole graph rather than about
215
+ * the matched set, and so is the guard below: a `to` tag NO project carries
216
+ * anywhere makes the condition undeterminable under BOTH readings — a renamed
217
+ * layer or a typo answering `pass` is the same silent direction one step
218
+ * earlier. One rule, not one per `toDependents`.
219
+ */
220
+ export function tagConformance(nodes, dependencies, names, { from, to, toDependents }) {
221
+ const fromMembers = taggedMembers(nodes, names, from);
222
+ if (fromMembers.length === 0) {
223
+ return fitnessVerdict({
224
+ verdict: "unknown",
225
+ name: `tag-conformance:${from}`,
226
+ evidence: { projects: names.length, fromMembers: 0 },
227
+ message: `cannot judge tag-conformance "${from}" — no matched project carries tag "${from}"`,
228
+ rows: [],
229
+ });
230
+ }
231
+ const toMembers = taggedMembers(nodes, Object.keys(nodes), to);
232
+ if (toMembers.length === 0) {
233
+ return fitnessVerdict({
234
+ verdict: "unknown",
235
+ name: `tag-conformance:${from}`,
236
+ evidence: { projects: names.length, fromMembers: fromMembers.length, toMembers: 0 },
237
+ message:
238
+ `cannot judge tag-conformance "${from}" — no project in the workspace carries tag "${to}", ` +
239
+ `so the ${toDependents}-condition could not be determined`,
240
+ rows: [],
241
+ });
242
+ }
243
+ // A target that is not a project node is not this condition's business: an
244
+ // Nx graph's `dependencies` carry `npm:`-prefixed external targets verbatim
245
+ // (`../providers/nx.mjs` returns what `nx graph` emitted), and an external
246
+ // package's boundary is `bannedExternalImports`', judged by
247
+ // `../rules/index.mjs` against the specifier. Without this the `"only"`
248
+ // reading would report every npm import as a tag violation and the rule
249
+ // would be unusable on an Nx workspace. `tagAxisIsolation` below drops the
250
+ // same targets by construction, through its `targetValues.length > 0` test.
251
+ //
252
+ // `Object.hasOwn`, never `nodes[edge.target]`: `nodes` is a caller-supplied
253
+ // map and a project named `__proto__` or `toString` must not answer the
254
+ // membership question by inheritance — the same guard
255
+ // `../rules/reachability.mjs`'s `buildReachability` uses on this exact
256
+ // lookup.
257
+ const edges = edgesFrom(dependencies, fromMembers).filter((edge) =>
258
+ Object.hasOwn(nodes, edge.target),
259
+ );
260
+ const nonConforming = edges.filter((edge) =>
261
+ toDependents === "only" ? !toMembers.includes(edge.target) : toMembers.includes(edge.target),
262
+ );
263
+
264
+ if (nonConforming.length === 0) {
265
+ return fitnessVerdict({
266
+ verdict: "pass",
267
+ name: `tag-conformance:${from}`,
268
+ evidence: { projects: names.length, fromMembers: fromMembers.length, edges: edges.length },
269
+ message:
270
+ `${edges.length} edge${edges.length === 1 ? "" : "s"} from "${from}" ` +
271
+ (toDependents === "only"
272
+ ? `target only "${to}" projects as required`
273
+ : `target no "${to}" projects as required`),
274
+ rows: [],
275
+ });
276
+ }
277
+ return fitnessVerdict({
278
+ verdict: "fail",
279
+ name: `tag-conformance:${from}`,
280
+ evidence: {
281
+ projects: names.length,
282
+ fromMembers: fromMembers.length,
283
+ edges: edges.length,
284
+ violations: nonConforming.length,
285
+ },
286
+ message:
287
+ `${nonConforming.length} edge${nonConforming.length === 1 ? "" : "s"} from "${from}" ` +
288
+ (toDependents === "only"
289
+ ? `target ${nonConforming.map((e) => `"${e.target}"`).join(", ")} outside "${to}"`
290
+ : `target "${to}" projects, which is forbidden`),
291
+ rows: nonConforming,
292
+ });
293
+ }
294
+
295
+ /**
296
+ * The values a project carries on one tag axis — the part after the first `:`
297
+ * of every `axis:value` tag it has.
298
+ *
299
+ * Split on the FIRST colon, so a tag like `module:orders:v2` places the
300
+ * project in the partition `orders:v2` rather than in `orders`. That is the
301
+ * same reading `../rules/match.mjs` gives an `axis:value` tag, and the
302
+ * alternative — splitting on the last colon, or refusing the tag — would
303
+ * quietly move a project between partitions.
304
+ *
305
+ * @param {object} nodes
306
+ * @param {string} name
307
+ * @param {string} axis
308
+ * @returns {string[]} Sorted, deduplicated. Empty when the project carries no
309
+ * tag on this axis, which is what `tagAxisIsolation` reads as "belongs to no
310
+ * partition".
311
+ */
312
+ function axisValues(nodes, name, axis) {
313
+ const prefix = `${axis}:`;
314
+ const values = new Set();
315
+ for (const tag of tagsOf(nodes, name)) {
316
+ if (!tag.startsWith(prefix)) continue;
317
+ const value = tag.slice(prefix.length);
318
+ // A bare `module:` names an axis and no value. Reading it as the partition
319
+ // `""` would put every project carrying one into the SAME partition and
320
+ // pass every edge between them; leaving it out places the project nowhere,
321
+ // which is the `unknown` branch below and the loud direction.
322
+ if (value !== "") values.add(value);
323
+ }
324
+ return [...values].sort();
325
+ }
326
+
327
+ /**
328
+ * The direct edges leaving any of `sources`, wherever they land — deduplicated
329
+ * by the PAIR, and sorted.
330
+ *
331
+ * A provider deduplicates on `[source, target, type]`, so one `a → b`
332
+ * dependency can appear twice when it is both a manifest edge and an implicit
333
+ * one. Both callers judge the pair and never read `type`, so keeping both
334
+ * would report the same dependency twice and put a count of 2 on an evidence
335
+ * record describing one edge. That is the loud direction rather than the
336
+ * silent one, which is why it is a count bug and not a verdict bug — but a
337
+ * count in evidence is a claim about the graph, and this one would be wrong.
338
+ */
339
+ function edgesFrom(dependencies, sources) {
340
+ const inSet = new Set(sources);
341
+ const seen = new Map();
342
+ for (const [source, list] of Object.entries(dependencies ?? {})) {
343
+ if (!inSet.has(source)) continue;
344
+ for (const dependency of list ?? []) {
345
+ seen.set(`${source}\u0000${dependency.target}`, { source, target: dependency.target });
346
+ }
347
+ }
348
+ return [...seen.values()].sort((a, b) =>
349
+ a.source === b.source ? (a.target < b.target ? -1 : 1) : a.source < b.source ? -1 : 1,
350
+ );
351
+ }
352
+
353
+ /**
354
+ * `tag-axis-isolation` — the partition check: two projects sitting on
355
+ * different values of ONE tag axis may not depend on each other.
356
+ *
357
+ * ## Why this exists as a primitive
358
+ *
359
+ * Every other mechanism in this tool compares a project's tags against tag
360
+ * values written in the policy: `onlyDependOnLibsWithTags` names the target
361
+ * tags, `notDependOnLibsWithTags` names the forbidden ones,
362
+ * `layer-dependency` names both sides. None of them can say "the same value
363
+ * as the source", so a style whose boundary is a PARTITION — one module, one
364
+ * bounded context, one feature slice, one service — has to be written as one
365
+ * constraint row per partition, restated every time the tree grows a new one.
366
+ *
367
+ * That is not only verbose, it is wrong in both directions, and both were
368
+ * measured on the packs this package ships (`../../presets/`):
369
+ *
370
+ * - **False negative.** `modular-monolith`'s row lets `layer:module-internal`
371
+ * depend on `layer:module-internal`, because "its own module's internals"
372
+ * is not a thing a tag list can spell. One module's private implementation
373
+ * reaching another module's is permitted by the pack whose own row
374
+ * description forbids it.
375
+ * - **False positive.** `ddd-bounded-contexts-isolated`'s
376
+ * `notDependOnLibsWithTags: ["share:private"]` reports an import between
377
+ * two private projects of the SAME context, which is why that profile is
378
+ * documented as valid only where one project is one context.
379
+ *
380
+ * One axis-relative condition answers both, for every partitioned style, in
381
+ * one row that does not change when a partition is added.
382
+ *
383
+ * ## What is judged, and what is not
384
+ *
385
+ * `match` selects the SOURCES. Every direct edge leaving a matched project is
386
+ * judged when its target carries a value on `axis` and is not exempt; an edge
387
+ * whose target carries no value on the axis belongs to no partition and is
388
+ * not this condition's business (the shared kernel, a platform library, a
389
+ * project on another axis entirely). `exempt` is the one escape, and it names
390
+ * targets — a published contract, a module's public surface — so the pattern
391
+ * "cross-partition through the published surface only" is one selector rather
392
+ * than a second condition.
393
+ *
394
+ * Edges are DIRECT. A path laundered through a third project is a transitive
395
+ * question, which `notDependOnLibsWithTags` already answers and answers
396
+ * differently (it reports at the innocent hop). Naming the limit here is the
397
+ * honest half: this condition is a claim about direct coupling.
398
+ *
399
+ * ## The verdict order, and why `fail` outranks `unknown`
400
+ *
401
+ * A matched project carrying no value on the axis cannot be placed in a
402
+ * partition, so its edges cannot be judged. Reading that as "no violation"
403
+ * is the silent direction, so it can never produce `pass`. It does not
404
+ * suppress a crossing that WAS found, either: those are determined facts, and
405
+ * hiding them behind `unknown` would lose findings. So the order is the one
406
+ * `../../cli.mjs`'s `verdictFor` already uses for the run as a whole —
407
+ * findings first, could-not-look second, clean last — with the unplaced count
408
+ * carried in the evidence of whichever verdict is returned.
409
+ *
410
+ * @param {object} nodes
411
+ * @param {object} dependencies
412
+ * @param {string[]} names The matched projects — sources only.
413
+ * @param {{axis: string, exempt?: string[]}} params
414
+ * @returns {object}
415
+ */
416
+ export function tagAxisIsolation(nodes, dependencies, names, { axis, exempt = [] }) {
417
+ const ruleName = `tag-axis-isolation:${axis}`;
418
+ // `resolveMembers` seeds an implicit `*` whenever a list carries NO positive
419
+ // selector — not only when it is empty (`../architecture-intent/selectors.mjs`,
420
+ // where "everything except…" is what a boundary's `match` means). Here that
421
+ // reading would exempt the whole workspace and turn every verdict below into
422
+ // `pass`, so a list with no positive selector never reaches that function.
423
+ //
424
+ // `exemptSelectorViolations` (`./fitness-registry.mjs`) refuses such a list at
425
+ // load, by name, which is where an author is told about it. This is the
426
+ // backstop for a caller that assembled a row without validating it, and it
427
+ // errs toward exempting NOTHING — the stricter of the two answers, so the
428
+ // gap it covers can only ever over-report.
429
+ const exemptNames = new Set(
430
+ exempt.some((selector) => !selector.startsWith("!")) ? resolveMembers(exempt, nodes) : [],
431
+ );
432
+ const unplaced = names.filter((name) => axisValues(nodes, name, axis).length === 0).sort();
433
+
434
+ const crossings = edgesFrom(dependencies, names)
435
+ .filter((edge) => !exemptNames.has(edge.target))
436
+ .map((edge) => ({
437
+ ...edge,
438
+ sourceValues: axisValues(nodes, edge.source, axis),
439
+ targetValues: axisValues(nodes, edge.target, axis),
440
+ }))
441
+ .filter(
442
+ (edge) =>
443
+ edge.sourceValues.length > 0 &&
444
+ edge.targetValues.length > 0 &&
445
+ !edge.sourceValues.some((value) => edge.targetValues.includes(value)),
446
+ );
447
+
448
+ const evidence = {
449
+ projects: names.length,
450
+ axis,
451
+ exempt: exemptNames.size,
452
+ unplaced: unplaced.length,
453
+ crossings: crossings.length,
454
+ };
455
+
456
+ // Named once, appended to whichever verdict is returned. A `fail` that said
457
+ // nothing about the projects it could not place would report a partial look
458
+ // as a whole one: the reader acts on the crossings and never learns that part
459
+ // of the subject was never judged.
460
+ const unplacedNote =
461
+ unplaced.length === 0
462
+ ? ""
463
+ : ` ${unplaced.length} matched project${unplaced.length === 1 ? "" : "s"} ` +
464
+ `${unplaced.length === 1 ? "carries" : "carry"} no "${axis}:" tag, so ` +
465
+ `${unplaced.length === 1 ? "its" : "their"} dependencies could not be placed: ` +
466
+ `${unplaced.join(", ")}.`;
467
+
468
+ if (crossings.length > 0) {
469
+ return fitnessVerdict({
470
+ verdict: "fail",
471
+ name: ruleName,
472
+ evidence,
473
+ message:
474
+ `${crossings.length} dependency edge${crossings.length === 1 ? "" : "s"} cross${crossings.length === 1 ? "es" : ""} ` +
475
+ `a "${axis}:" boundary: ` +
476
+ crossings
477
+ .map(
478
+ (edge) =>
479
+ `${edge.source} (${edge.sourceValues.join("|")}) → ${edge.target} (${edge.targetValues.join("|")})`,
480
+ )
481
+ .join(", ") +
482
+ (unplacedNote === "" ? "" : `.${unplacedNote}`),
483
+ rows: crossings,
484
+ });
485
+ }
486
+
487
+ if (unplaced.length > 0) {
488
+ return fitnessVerdict({
489
+ verdict: "unknown",
490
+ name: ruleName,
491
+ evidence,
492
+ message: `cannot judge tag-axis-isolation on "${axis}:" —${unplacedNote}`,
493
+ rows: [],
494
+ });
495
+ }
496
+
497
+ return fitnessVerdict({
498
+ verdict: "pass",
499
+ name: ruleName,
500
+ evidence,
501
+ message: `${names.length} matched project${names.length === 1 ? "" : "s"} keep every dependency inside their own "${axis}:" partition`,
502
+ rows: [],
503
+ });
504
+ }
505
+
506
+ /**
507
+ * `coverage-minimum` — at least `statement` percent of the matched projects'
508
+ * owned files were actually analyzed by the workspace analysis. The observed
509
+ * side is `analysis`' per-project file count vs the files the workspace owns;
510
+ * a file that was owned but not analyzed counts as uncovered, never dropped —
511
+ * the silent direction.
512
+ */
513
+ export function coverageMinimum(analysis, names, { statement, scoped = false }) {
514
+ if (names.length === 0) {
515
+ return fitnessVerdict({
516
+ verdict: "not_applicable",
517
+ name: `coverage-minimum:${statement}%`,
518
+ evidence: { projects: 0, coverage: null },
519
+ notApplicableReason: "no matched projects, so coverage could not be claimed",
520
+ message: "declared but matches no observed project — coverage could not be claimed",
521
+ rows: [],
522
+ });
523
+ }
524
+ // A path-scoped run analyzed a subset of owned files, so coverage over the
525
+ // whole set is not determinable from it. Reading the partial number as a
526
+ // verdict would be the silent direction — but this is not a case of TRYING
527
+ // to judge and coming up short (`unknown`, I3's "could not look"): a scoped
528
+ // `check <path>` is a deliberately different, partial invocation, and
529
+ // coverage-minimum cannot be judged by ANY scoped run over ANY path, clean
530
+ // or not — the same "did not apply" shape `not_applicable` already has above
531
+ // for a `match` that selects zero projects, not a fresh meaning grafted onto
532
+ // it (P1-19: this used to answer `unknown` here, which made `check <path>`
533
+ // exit 3 unconditionally in any `coverage-minimum`-declaring workspace,
534
+ // regardless of what the scoped path contained or whether it was clean —
535
+ // this repository's own root `../../../../module-boundaries.config.mjs`
536
+ // declares exactly such a row). `not_applicable` still names the reason (I4,
537
+ // `./verdict.mjs`) and still renders as its own loud, distinct row
538
+ // (`../report/text.mjs`'s `formatFitnessSection`) — never silent, and never
539
+ // `pass` either, so a scoped run still cannot claim full coverage over files
540
+ // it never looked at — but unlike `unknown` it does not fold into `check`'s
541
+ // exit code (`../../cli.mjs`'s `fitnessUnknown`), so a scoped run over a
542
+ // genuinely clean subtree no longer exits 3 for a coverage question it was
543
+ // never in a position to answer.
544
+ if (scoped) {
545
+ return fitnessVerdict({
546
+ verdict: "not_applicable",
547
+ name: `coverage-minimum:${statement}%`,
548
+ evidence: { projects: names.length, scoped: true },
549
+ notApplicableReason:
550
+ "this run was scoped to specific paths — coverage-minimum needs a full, unscoped run",
551
+ message:
552
+ `coverage-minimum over ${names.length} matched projects does not apply to a ` +
553
+ `path-scoped run — it needs a full \`check\` with no paths to judge the whole tree`,
554
+ rows: [],
555
+ });
556
+ }
557
+ let owned = 0;
558
+ let analyzed = 0;
559
+ for (const name of names) {
560
+ owned += analysis.coverage?.[name]?.owned ?? 0;
561
+ analyzed += analysis.coverage?.[name]?.analyzed ?? 0;
562
+ }
563
+ if (owned === 0) {
564
+ return fitnessVerdict({
565
+ verdict: "unknown",
566
+ name: `coverage-minimum:${statement}%`,
567
+ evidence: { projects: names.length, owned: 0, analyzed: 0 },
568
+ message:
569
+ `cannot judge coverage-minimum over ${names.length} matched projects — the workspace ` +
570
+ `owns no tracked files for them, so a coverage claim would be a guess`,
571
+ rows: [],
572
+ });
573
+ }
574
+ const percent = (analyzed / owned) * 100;
575
+ if (percent >= statement) {
576
+ return fitnessVerdict({
577
+ verdict: "pass",
578
+ name: `coverage-minimum:${statement}%`,
579
+ evidence: {
580
+ projects: names.length,
581
+ owned,
582
+ analyzed,
583
+ percent: Math.round(percent * 100) / 100,
584
+ },
585
+ message:
586
+ `${analyzed}/${owned} files analyzed (${Math.round(percent * 100) / 100}%), ` +
587
+ `meets the ${statement}% minimum`,
588
+ rows: [],
589
+ });
590
+ }
591
+ return fitnessVerdict({
592
+ verdict: "fail",
593
+ name: `coverage-minimum:${statement}%`,
594
+ evidence: { projects: names.length, owned, analyzed, percent: Math.round(percent * 100) / 100 },
595
+ message:
596
+ `${analyzed}/${owned} files analyzed (${Math.round(percent * 100) / 100}%), ` +
597
+ `below the ${statement}% minimum`,
598
+ rows: [],
599
+ });
600
+ }
601
+
602
+ /**
603
+ * `boundary-suppression-count-within-threshold` — the number of accepted
604
+ * boundary suppressions in effect is at most `max`.
605
+ */
606
+ export function suppressionThreshold({ max }, suppressions) {
607
+ const count = suppressions.length;
608
+ if (count <= max) {
609
+ return fitnessVerdict({
610
+ verdict: "pass",
611
+ name: `boundary-suppressions:${max}`,
612
+ evidence: { suppressions: count, max },
613
+ message: `${count} accepted boundary suppressions is within the ${max} threshold`,
614
+ rows: [],
615
+ });
616
+ }
617
+ return fitnessVerdict({
618
+ verdict: "fail",
619
+ name: `boundary-suppressions:${max}`,
620
+ evidence: { suppressions: count, max },
621
+ message: `${count} accepted boundary suppressions exceeds the ${max} threshold`,
622
+ rows: [],
623
+ });
624
+ }
625
+
626
+ /**
627
+ * `drift-free` — the workspace's tracked `architecture-intent.json` (when one
628
+ * is present) judges clean against the observed graph. The intent judge's own
629
+ * no-verdict (an empty boundary, a row side matching nothing) is `unknown`,
630
+ * never `pass` — the same posture `check` renders it exit 3.
631
+ */
632
+ export function driftFree(intent) {
633
+ if (intent == null) {
634
+ return fitnessVerdict({
635
+ verdict: "unknown",
636
+ name: "drift-free",
637
+ evidence: { intent: null },
638
+ message: "cannot judge drift-free — no architecture-intent.json is declared",
639
+ rows: [],
640
+ });
641
+ }
642
+ if (intent.verdict === "ok") {
643
+ return fitnessVerdict({
644
+ verdict: "pass",
645
+ name: "drift-free",
646
+ evidence: { intent: "ok", findings: 0 },
647
+ message: "the declared architecture intent matches the observed graph",
648
+ rows: [],
649
+ });
650
+ }
651
+ if (intent.verdict === "no-verdict") {
652
+ return fitnessVerdict({
653
+ verdict: "unknown",
654
+ name: "drift-free",
655
+ evidence: { intent: "no-verdict", unresolved: intent.unresolved?.length ?? 0 },
656
+ message:
657
+ "cannot judge drift-free — the architecture intent reached no verdict on the observed graph",
658
+ rows: [],
659
+ });
660
+ }
661
+ return fitnessVerdict({
662
+ verdict: "fail",
663
+ name: "drift-free",
664
+ evidence: { intent: "findings", findings: intent.findings?.length ?? 0 },
665
+ message: `the declared architecture intent and the observed graph disagree (${intent.findings?.length ?? 0} finding${(intent.findings?.length ?? 0) === 1 ? "" : "s"})`,
666
+ rows: [],
667
+ });
668
+ }