@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,504 @@
1
+ /**
2
+ * Fitness Functions — parse, validate, and evaluate the workspace's declared
3
+ * fitness functions against the observed architecture snapshot.
4
+ *
5
+ * A fitness function is a named, machine-checkable quality gate: "the graph
6
+ * stays cycle-free", "no `layer:adapter` may reach `layer:domain`", "at least
7
+ * 90% of statements are analyzed". It is declared in the workspace's ONE
8
+ * executable policy file — `module-boundaries.config.mjs`'s `fitness` export
9
+ * (or a native workspace's inline `boundaryConfig` object) — and evaluated
10
+ * deterministically against the same observed facts every other check reads:
11
+ * the project graph, the workspace analysis, the architecture intent, and the
12
+ * boundary policy itself. NO LLM, no network, no clock: a fitness function is
13
+ * a verdict a pipeline can reproduce, not a belief a reviewer holds.
14
+ *
15
+ * This module mirrors `../config.mjs`'s and `../architecture-intent/model.mjs`'s
16
+ * split: a pure `(raw) -> string[]` validator (shared by the config loader, so
17
+ * a malformed row fails where it is read) and a pure judge over an assembled
18
+ * snapshot. The judge is the schema-driven evaluation base
19
+ * `../architecture-intent/judge.mjs` provides — this module reuses
20
+ * `resolveMembers` for `match` resolution and hands each condition the facts
21
+ * it needs; it does NOT duplicate a judge.
22
+ *
23
+ * ## The invariant, per function
24
+ *
25
+ * The empty-result invariant (AGENTS.md) decides every branch. A function's
26
+ * verdict is one of E0's four (`../governance/verdict.mjs`):
27
+ *
28
+ * - `pass` — every requirement was evaluated and held. Only reachable when
29
+ * the evidence the condition needs was fully observed.
30
+ * - `fail` — a requirement was evaluated and broken.
31
+ * - `unknown` — the run could not determine the answer. A fitness it cannot
32
+ * determine MUST yield `unknown`, never `pass`. Two classes reach here:
33
+ * the condition's own evidence is missing (a `layer-dependency` tag no
34
+ * matched project carries; a `coverage-minimum` over zero owned
35
+ * statements; `drift-free` over no intent), and the function's `match`
36
+ * itself could not be judged against the observed graph.
37
+ * - `not_applicable` — a declared function whose `match` selects zero
38
+ * projects, so it could not be judged. Reported loudly — "declared but
39
+ * matches nothing" — never folded into `pass`. Invariant I4
40
+ * (`../governance/verdict.mjs`) requires it to name a
41
+ * `notApplicableReason`.
42
+ *
43
+ * ## Determinism
44
+ *
45
+ * Everything is sorted with plain `<` comparison, never `localeCompare`, and
46
+ * evidence is serialized through `canonicalizeJson` for any fingerprint or
47
+ * test that needs byte-identical output. Running the same snapshot through
48
+ * the registry twice produces identical evidence.
49
+ */
50
+ import { isValidSelector, resolveMembers } from "../architecture-intent/selectors.mjs";
51
+ import { languageOf } from "../analysis/registry.mjs";
52
+ import { canonicalizeJson } from "../canonical.mjs";
53
+ import { GOVERNANCE_ROW_KEYS, rowSchemaViolations } from "./row-schema.mjs";
54
+ import { fitnessVerdict, isVerdict } from "./verdict.mjs";
55
+ import {
56
+ coverageMinimum,
57
+ cycleFree,
58
+ driftFree,
59
+ layerDependency,
60
+ suppressionThreshold,
61
+ tagAxisIsolation,
62
+ tagConformance,
63
+ } from "./fitness-rules.mjs";
64
+
65
+ /** The one `fitness` list key in the boundary config. */
66
+ export const FITNESS_KEY = "fitness";
67
+
68
+ /** The condition types the registry can evaluate. */
69
+ export const CONDITION_TYPES = Object.freeze([
70
+ "cycle-free",
71
+ "layer-dependency",
72
+ "tag-conformance",
73
+ "coverage-minimum",
74
+ "boundary-suppression-count-within-threshold",
75
+ "drift-free",
76
+ "tag-axis-isolation",
77
+ ]);
78
+
79
+ /** The keys a fitness row may carry — the governance block rides additively. */
80
+ const ROW_KEYS = Object.freeze(["name", "match", "condition", "reason"]);
81
+
82
+ /** A fitness `name`, matched exactly — names can never contain `:`, so a name can never collide with a selector label. */
83
+ const NAME_PATTERN = /^[a-zA-Z0-9_-]+$/u;
84
+
85
+ /** The one `direction` a `layer-dependency` row may carry. */
86
+ const LAYER_DIRECTIONS = Object.freeze(["forbidden", "required"]);
87
+ /** The one `toDependents` a `tag-conformance` row may carry. */
88
+ const TAG_DEPENDENT_DIRECTIONS = Object.freeze(["only", "never"]);
89
+
90
+ /** @type {(value: unknown) => value is Record<string, unknown>} */
91
+ const isPlainObject = (value) =>
92
+ value !== null && typeof value === "object" && !Array.isArray(value);
93
+
94
+ /** A value's type, for an error message that shows what was actually there. */
95
+ function describe(value) {
96
+ if (Array.isArray(value)) return `an array (${JSON.stringify(value)})`;
97
+ if (value === null) return "null";
98
+ return `${typeof value} (${JSON.stringify(value) ?? String(value)})`;
99
+ }
100
+
101
+ function unknownKeys(obj, allowed) {
102
+ return Object.keys(obj).filter((key) => !allowed.includes(key));
103
+ }
104
+
105
+ /**
106
+ * Everything wrong with a `fitness` list, as messages; empty when it is
107
+ * well-formed. Pure and nodes-free — membership is the judge's question.
108
+ *
109
+ * @param {unknown} list The parsed `fitness` value.
110
+ * @returns {string[]}
111
+ */
112
+ export function findFitnessViolations(list, io = {}) {
113
+ if (list === undefined) return [];
114
+ if (!Array.isArray(list)) {
115
+ return [`fitness: must be an array of fitness rows, got ${describe(list)}`];
116
+ }
117
+ if (list.length === 0) {
118
+ return [
119
+ "fitness: must not be empty — a list present but empty reads as policy while deciding nothing",
120
+ ];
121
+ }
122
+
123
+ const violations = [];
124
+ const names = new Set();
125
+ list.forEach((row, index) => {
126
+ const at = `fitness[${index}]`;
127
+ if (!isPlainObject(row)) {
128
+ violations.push(`${at}: must be an object, got ${describe(row)}`);
129
+ return;
130
+ }
131
+ for (const key of unknownKeys(row, [...ROW_KEYS, ...GOVERNANCE_ROW_KEYS])) {
132
+ violations.push(
133
+ `${at}.${key}: unknown key — a fitness row may carry only ` +
134
+ `${ROW_KEYS.join(", ")}, plus the governance block keys ` +
135
+ `${GOVERNANCE_ROW_KEYS.join(", ")}`,
136
+ );
137
+ }
138
+ // The shared governance block (Contract 2): a fitness row is a policy
139
+ // decision like any other row, so the same `origin`/`rationale`/
140
+ // `decisionRef`/`fitnessBindings` shape — and, when a caller has a
141
+ // registry, the same resolution half — applies. Additive: a legacy row
142
+ // without the block stays valid byte-identical.
143
+ violations.push(...rowSchemaViolations(row, at, io));
144
+ if (typeof row.name !== "string" || !NAME_PATTERN.test(row.name)) {
145
+ violations.push(
146
+ `${at}.name: must be a non-empty string of letters, digits, "-" or "_" (no ":"), got ${describe(row.name)}`,
147
+ );
148
+ } else {
149
+ if (names.has(row.name)) {
150
+ violations.push(
151
+ `${at}.name: "${row.name}" is declared more than once — every fitness name must be unique`,
152
+ );
153
+ }
154
+ names.add(row.name);
155
+ }
156
+ const match = row.match;
157
+ if (!Array.isArray(match) || match.length === 0) {
158
+ violations.push(
159
+ `${at}.match: must be a non-empty array of project selectors, got ${describe(match)}`,
160
+ );
161
+ } else {
162
+ match.forEach((selector, selIndex) => {
163
+ if (!isValidSelector(selector)) {
164
+ violations.push(
165
+ `${at}.match[${selIndex}]: must be a valid project selector ` +
166
+ `(name:x, tag:x, directory:x, "*", or "!"-prefixed), got ${describe(selector)}`,
167
+ );
168
+ }
169
+ });
170
+ }
171
+ if (typeof row.reason !== "string" || row.reason.trim() === "") {
172
+ violations.push(
173
+ `${at}.reason: must be a non-empty string — a fitness function is a policy decision, and one with no reason written down is indistinguishable from a policy that quietly stopped applying`,
174
+ );
175
+ }
176
+ violations.push(...conditionViolations(row.condition, at));
177
+ });
178
+ return violations;
179
+ }
180
+
181
+ /** One condition's problems, prefixed with its row's index. */
182
+ function conditionViolations(condition, at) {
183
+ if (!isPlainObject(condition)) {
184
+ return [`${at}.condition: must be an object, got ${describe(condition)}`];
185
+ }
186
+ const violations = [];
187
+ const keys = Object.keys(condition);
188
+ const type = condition.type;
189
+ if (typeof type !== "string" || !CONDITION_TYPES.includes(type)) {
190
+ violations.push(
191
+ `${at}.condition.type: must be one of ${CONDITION_TYPES.join(", ")}, got ${describe(type)}`,
192
+ );
193
+ return violations;
194
+ }
195
+ const allowed = {
196
+ "cycle-free": ["type"],
197
+ "drift-free": ["type"],
198
+ "layer-dependency": ["type", "from", "to", "direction"],
199
+ "tag-conformance": ["type", "from", "to", "toDependents"],
200
+ "coverage-minimum": ["type", "statement"],
201
+ "boundary-suppression-count-within-threshold": ["type", "max"],
202
+ "tag-axis-isolation": ["type", "axis", "exempt"],
203
+ };
204
+ for (const key of keys) {
205
+ if (!allowed[type].includes(key)) {
206
+ violations.push(
207
+ `${at}.condition.${key}: not a field of condition type "${type}" — expected ${allowed[type].join(", ")}`,
208
+ );
209
+ }
210
+ }
211
+ if (type === "layer-dependency" || type === "tag-conformance") {
212
+ for (const side of ["from", "to"]) {
213
+ if (typeof condition[side] !== "string" || condition[side].trim() === "") {
214
+ violations.push(
215
+ `${at}.condition.${side}: must be a non-empty tag value, got ${describe(condition[side])}`,
216
+ );
217
+ }
218
+ }
219
+ }
220
+ if (
221
+ type === "layer-dependency" &&
222
+ (typeof condition.direction !== "string" || !LAYER_DIRECTIONS.includes(condition.direction))
223
+ ) {
224
+ violations.push(
225
+ `${at}.condition.direction: must be one of ${LAYER_DIRECTIONS.join(", ")}, got ${describe(condition.direction)}`,
226
+ );
227
+ }
228
+ if (
229
+ type === "tag-conformance" &&
230
+ (typeof condition.toDependents !== "string" ||
231
+ !TAG_DEPENDENT_DIRECTIONS.includes(condition.toDependents))
232
+ ) {
233
+ violations.push(
234
+ `${at}.condition.toDependents: must be one of ${TAG_DEPENDENT_DIRECTIONS.join(", ")}, got ${describe(condition.toDependents)}`,
235
+ );
236
+ }
237
+ if (
238
+ type === "coverage-minimum" &&
239
+ (typeof condition.statement !== "number" ||
240
+ Number.isNaN(condition.statement) ||
241
+ condition.statement < 0 ||
242
+ condition.statement > 100)
243
+ ) {
244
+ violations.push(
245
+ `${at}.condition.statement: must be a percentage between 0 and 100, got ${describe(condition.statement)}`,
246
+ );
247
+ }
248
+ if (
249
+ type === "boundary-suppression-count-within-threshold" &&
250
+ (typeof condition.max !== "number" || !Number.isInteger(condition.max) || condition.max < 0)
251
+ ) {
252
+ violations.push(
253
+ `${at}.condition.max: must be a non-negative integer, got ${describe(condition.max)}`,
254
+ );
255
+ }
256
+ if (type === "tag-axis-isolation") {
257
+ violations.push(...tagAxisIsolationViolations(condition, at));
258
+ }
259
+ return violations;
260
+ }
261
+
262
+ /**
263
+ * `tag-axis-isolation`'s two fields.
264
+ *
265
+ * `axis` is the tag prefix a partition is read off, so it must not itself
266
+ * contain the separator: `module:orders` names the axis `module`, and an
267
+ * `axis` of `"module:orders"` would ask for the partition value after the
268
+ * SECOND colon — a row that matches nothing while reading as policy. It is
269
+ * refused by name rather than allowed to select an empty partition set.
270
+ *
271
+ * `exempt` is a list of project selectors naming targets an edge may point at
272
+ * across a partition boundary. It is validated with the same
273
+ * `isValidSelector` every `match` list uses, so a `tagz:x` typo is a load
274
+ * error here for the reason it is one there — an exemption nobody understands
275
+ * is an exemption that silently applies to nothing, which in THIS field is
276
+ * the loud direction and in a `match` list is the silent one. Refusing both
277
+ * keeps one rule for one grammar.
278
+ */
279
+ function tagAxisIsolationViolations(condition, at) {
280
+ const violations = [];
281
+ const { axis, exempt } = condition;
282
+ if (typeof axis !== "string" || axis.trim() === "") {
283
+ violations.push(`${at}.condition.axis: must be a non-empty tag axis, got ${describe(axis)}`);
284
+ } else if (axis.includes(":")) {
285
+ violations.push(
286
+ `${at}.condition.axis: must name a tag axis without its separator — ` +
287
+ `"${axis}" contains ':', so it would read the partition value off the wrong half of a tag ` +
288
+ `(write "${axis.split(":")[0]}" to partition on ${JSON.stringify(axis.split(":")[0])})`,
289
+ );
290
+ }
291
+ if (exempt !== undefined) {
292
+ if (!Array.isArray(exempt)) {
293
+ violations.push(
294
+ `${at}.condition.exempt: must be an array of project selectors when present, got ${describe(exempt)}`,
295
+ );
296
+ } else {
297
+ exempt.forEach((selector, index) => {
298
+ if (!isValidSelector(selector)) {
299
+ violations.push(
300
+ `${at}.condition.exempt[${index}]: must be a valid project selector ` +
301
+ `(name:x, tag:x, directory:x, "*", or "!"-prefixed), got ${describe(selector)}`,
302
+ );
303
+ }
304
+ });
305
+ // A `match` list of only `!` selectors means "everything except those"
306
+ // — `resolveMembers` seeds an implicit `*` for it
307
+ // (`../architecture-intent/selectors.mjs`). Read the same way here, an
308
+ // `exempt` of `["!name:legacy"]` exempts the whole workspace and turns
309
+ // every verdict this condition can reach into `pass`: a policy that
310
+ // reads as "do not exempt legacy" and enforces nothing at all. Refused
311
+ // by name rather than reinterpreted, because BOTH readings are
312
+ // defensible and a reader cannot tell which one a silent engine chose.
313
+ if (exempt.length > 0 && exempt.every((selector) => String(selector).startsWith("!"))) {
314
+ violations.push(
315
+ `${at}.condition.exempt: names only "!" selectors, which would exempt every project ` +
316
+ `except those — and so exempt the whole workspace, making this function pass on any ` +
317
+ `tree. Write the projects to exempt positively, or "*" with the exclusions after it ` +
318
+ `if exempting nearly everything is really meant.`,
319
+ );
320
+ }
321
+ }
322
+ }
323
+ return violations;
324
+ }
325
+
326
+ /**
327
+ * The per-function decision, evaluated against the assembled snapshot.
328
+ *
329
+ * @param {object} row A validated fitness row.
330
+ * @param {{nodes: object, dependencies?: object}} graph
331
+ * @param {{coverage?: Record<string, {owned?: number, analyzed?: number}>,
332
+ * analyzed?: number, owned?: number, scoped?: boolean}} analysis Never fully
333
+ * absent — the registry always passes a snapshot's assembled analysis — but
334
+ * only `coverage-minimum` reads it, so the fields ride optional for callers
335
+ * that do not carry a coverage fact (a `rules`-unit test driving another
336
+ * condition).
337
+ * @param {object|null} intent The intent judge's verdict, or `null` when no
338
+ * intent is declared.
339
+ * @param {object[]} suppressions The accepted boundary suppressions in effect.
340
+ * @returns {object} A verdict record from `fitnessVerdict`.
341
+ */
342
+ export function judgeFitnessRow(row, graph, analysis, intent, suppressions) {
343
+ const names = resolveMembers(row.match, graph.nodes);
344
+ if (names.length === 0) {
345
+ return fitnessVerdict({
346
+ verdict: "not_applicable",
347
+ name: row.name,
348
+ evidence: { projects: 0 },
349
+ notApplicableReason: `match [${row.match.join(", ")}] selects no observed project`,
350
+ message: `"${row.name}" is declared but matches nothing — no observed project, so it could not be judged`,
351
+ rows: [],
352
+ });
353
+ }
354
+
355
+ const { type, ...params } = row.condition;
356
+ // The rule's own verdict names the RULE (`layer-dependency:from→to`); the
357
+ // declared function's name is what every consumer — the report's verdict
358
+ // table, the JSON envelope, `check`'s exit-code lane — must read, so the
359
+ // registry stamps it over the rule's internal name. The rule's name is
360
+ // DISCARDED, not relocated: this comment used to say it survived in
361
+ // `evidence.condition`, and no condition has ever written that key. Where a
362
+ // condition's parameters matter to a reader, the condition itself puts them
363
+ // in `evidence` (`tag-axis-isolation` carries `axis` there).
364
+ let decision;
365
+ switch (type) {
366
+ case "cycle-free":
367
+ decision = cycleFree(graph.nodes, graph.dependencies, names);
368
+ break;
369
+ case "layer-dependency":
370
+ decision = layerDependency(graph.nodes, graph.dependencies, names, params);
371
+ break;
372
+ case "tag-axis-isolation":
373
+ decision = tagAxisIsolation(graph.nodes, graph.dependencies, names, params);
374
+ break;
375
+ case "tag-conformance":
376
+ decision = tagConformance(graph.nodes, graph.dependencies, names, params);
377
+ break;
378
+ case "coverage-minimum":
379
+ decision = coverageMinimum(analysis, names, {
380
+ statement: params.statement,
381
+ scoped: analysis.scoped ?? false,
382
+ });
383
+ break;
384
+ case "boundary-suppression-count-within-threshold":
385
+ decision = suppressionThreshold({ max: params.max }, suppressions);
386
+ break;
387
+ case "drift-free":
388
+ decision = driftFree(intent);
389
+ break;
390
+ default:
391
+ throw new Error(
392
+ `archkeep: fitness function "${row.name}" names an unevaluable condition type ${JSON.stringify(type)}`,
393
+ );
394
+ }
395
+ return { ...decision, name: row.name };
396
+ }
397
+
398
+ /**
399
+ * Assemble the fitness snapshot the registry judges against, from the facts
400
+ * `check` and the `fitness` command already hold.
401
+ *
402
+ * Coverage is FILE coverage, not import-site coverage: each owned ANALYZABLE
403
+ * tracked file contributes to its owning project's `owned` bucket, each file
404
+ * the analysis actually analyzed to `analyzed`. "Analyzable" is exactly the
405
+ * files `analyzeWorkspace` reads — the extension→language map filters out
406
+ * Markdown, JSON and images before any analyzer runs (`languageOf`) — and
407
+ * "analyzed" is the same list whose length `check` states beside its verdict
408
+ * (`commandContext.analysis.analyzedFiles`), so a `coverage-minimum` over a
409
+ * project is a claim about the SAME files the rest of the run inspected. A
410
+ * file that is owned but not analyzed (a whole-file failure, or a path-scoped
411
+ * run cutting the analysis short) counts as covered by nothing, never dropped
412
+ * from the denominator — the silent direction.
413
+ *
414
+ * A path-scoped run (`archkeep check <path>`) analyzes a subset of owned files,
415
+ * so coverage over those projects' whole file sets is not determinable from it;
416
+ * the snapshot marks that with `scoped`, and `coverage-minimum` answers
417
+ * `not_applicable` there (`./fitness-rules.mjs`), never a
418
+ * low-looking number that is really "we only looked at part of the tree" —
419
+ * and, unlike `unknown`, `not_applicable` does not by itself fail `check`
420
+ * (P1-19: it used to answer `unknown`, which folded into `check`'s exit code
421
+ * the same as a real coverage hole, so a scoped run exited 3 in ANY
422
+ * `coverage-minimum`-declaring workspace regardless of what the scoped path
423
+ * held).
424
+ *
425
+ * @param {object} commandContext From `resolveCommandContext`.
426
+ * @param {{analysis?: object, intent?: object|null, suppressions?: object[],
427
+ * scoped?: boolean}} [extra] `analysis`/`intent`/`suppressions` override the
428
+ * command's own facts (the `fitness` command re-reads intent and suppresses
429
+ * the whole-tree analysis); `scoped` is set by `check` when `paths` scoped
430
+ * the run.
431
+ * @returns {{graph: object, analysis: {coverage: object, analyzed: number,
432
+ * owned: number, scoped: boolean}, intent: object|null, suppressions: object[]}}
433
+ */
434
+ export function fitnessSnapshot(commandContext, extra = {}) {
435
+ const graph = {
436
+ nodes: commandContext.graph.nodes,
437
+ dependencies: commandContext.graph.dependencies,
438
+ };
439
+ const analyzedFiles = extra.analysis?.analyzedFiles ?? commandContext.analysis.analyzedFiles;
440
+ const ownedFiles = extra.analysis?.ownedFiles ?? commandContext.owned;
441
+ const coverage = {};
442
+ const analyzedSet = new Set(analyzedFiles ?? []);
443
+ for (const { file, project } of ownedFiles ?? []) {
444
+ // The denominator is the analyzable owned files only — the same files
445
+ // `analyzeWorkspace` reads (`languageOf` filters the rest out before any
446
+ // analyzer runs), so a Markdown or JSON file can neither raise the claim
447
+ // nor lower it.
448
+ if (languageOf(file) === null) continue;
449
+ if (coverage[project] === undefined) coverage[project] = { owned: 0, analyzed: 0 };
450
+ coverage[project].owned += 1;
451
+ if (analyzedSet.has(file)) coverage[project].analyzed += 1;
452
+ }
453
+ return {
454
+ graph,
455
+ analysis: {
456
+ coverage,
457
+ owned: Object.values(coverage).reduce((sum, c) => sum + c.owned, 0),
458
+ analyzed: Object.values(coverage).reduce((sum, c) => sum + c.analyzed, 0),
459
+ // `scoped` rides IN the analysis object because `judgeFitnessRow` reads
460
+ // `analysis.scoped` — a path-scoped run analyzed a subset, so coverage is
461
+ // not determinable from it. A top-level `scoped` never reached the rules.
462
+ scoped: extra.scoped ?? false,
463
+ },
464
+ intent: extra.intent ?? null,
465
+ suppressions: extra.suppressions ?? [],
466
+ };
467
+ }
468
+
469
+ /**
470
+ * Judge every declared fitness function against a snapshot, deterministically.
471
+ *
472
+ * Rows are judged in declaration order (row order is semantic, same as a
473
+ * boundary policy's `depConstraints`), each returning its own verdict record.
474
+ *
475
+ * @param {object[]} rows Validated fitness rows.
476
+ * @param {object} snapshot From `fitnessSnapshot`.
477
+ * @returns {object[]} The per-function verdict records.
478
+ */
479
+ export function evaluateFitness(rows, snapshot) {
480
+ return rows.map((row) =>
481
+ judgeFitnessRow(row, snapshot.graph, snapshot.analysis, snapshot.intent, snapshot.suppressions),
482
+ );
483
+ }
484
+
485
+ /**
486
+ * The overall verdict the `check` fold and the `fitness` command share: any
487
+ * `fail` wins, then any `unknown`, then `pass` — and `not_applicable` alone
488
+ * never turns a run red, because a declared-but-matching-nothing function is a
489
+ * loud report row, not a broken requirement.
490
+ *
491
+ * @param {object[]} decisions From `evaluateFitness`.
492
+ * @returns {{verdict: "pass"|"fail"|"unknown"|"not_applicable", decisions: object[]}}
493
+ */
494
+ export function fitnessVerdictFor(decisions) {
495
+ if (decisions.some((d) => d.verdict === "fail")) return { verdict: "fail", decisions };
496
+ if (decisions.some((d) => d.verdict === "unknown")) return { verdict: "unknown", decisions };
497
+ if (decisions.length === 0 || decisions.every((d) => d.verdict === "not_applicable")) {
498
+ return { verdict: "not_applicable", decisions };
499
+ }
500
+ return { verdict: "pass", decisions };
501
+ }
502
+
503
+ /** Re-exported so `fitnessVerdict` and `isVerdict` live one import away. */
504
+ export { canonicalizeJson, fitnessVerdict, isVerdict };