@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.
- package/LICENSE +202 -0
- package/README.md +262 -0
- package/cli.mjs +2792 -0
- package/index.mjs +85 -0
- package/lsp.mjs +81 -0
- package/nx.mjs +24 -0
- package/package.json +81 -0
- package/presets/clean-architecture.json +78 -0
- package/presets/ddd-bounded-contexts.json +88 -0
- package/presets/hexagonal.json +68 -0
- package/presets/layered.json +92 -0
- package/presets/modular-monolith.json +85 -0
- package/presets/vertical-slice.json +68 -0
- package/src/analysis/analyze.mjs +218 -0
- package/src/analysis/contract.md +259 -0
- package/src/analysis/go.mjs +414 -0
- package/src/analysis/manifest-util.mjs +68 -0
- package/src/analysis/python.mjs +1266 -0
- package/src/analysis/registry.mjs +74 -0
- package/src/analysis/rust.mjs +674 -0
- package/src/analysis/source-util.mjs +230 -0
- package/src/analysis/typescript.mjs +1034 -0
- package/src/analysis/vue.mjs +156 -0
- package/src/architecture-intent/intent-fingerprint.mjs +29 -0
- package/src/architecture-intent/judge.mjs +539 -0
- package/src/architecture-intent/model.mjs +703 -0
- package/src/architecture-intent/selectors.mjs +170 -0
- package/src/canonical.mjs +48 -0
- package/src/commands/README.md +266 -0
- package/src/commands/adr.mjs +248 -0
- package/src/commands/check.mjs +989 -0
- package/src/commands/context-command.mjs +212 -0
- package/src/commands/context.mjs +790 -0
- package/src/commands/custom-rules.mjs +428 -0
- package/src/commands/debt.mjs +218 -0
- package/src/commands/diff.mjs +523 -0
- package/src/commands/discover.mjs +159 -0
- package/src/commands/drift.mjs +473 -0
- package/src/commands/edge-constraints.mjs +355 -0
- package/src/commands/explain.mjs +359 -0
- package/src/commands/fitness.mjs +226 -0
- package/src/commands/graph.mjs +297 -0
- package/src/commands/health.mjs +213 -0
- package/src/commands/history.mjs +614 -0
- package/src/commands/impact.mjs +226 -0
- package/src/commands/plan-context-command.mjs +496 -0
- package/src/commands/policy.mjs +138 -0
- package/src/commands/provenance-command.mjs +352 -0
- package/src/commands/provenance.mjs +159 -0
- package/src/commands/reconcile.mjs +219 -0
- package/src/commands/report.mjs +553 -0
- package/src/commands/snapshot-meta.mjs +107 -0
- package/src/commands/waivers.mjs +240 -0
- package/src/config.mjs +1308 -0
- package/src/containment.mjs +234 -0
- package/src/custom-rules/evidence.mjs +340 -0
- package/src/custom-rules/host.mjs +1023 -0
- package/src/custom-rules/values.mjs +43 -0
- package/src/entry-point.mjs +55 -0
- package/src/errors.mjs +36 -0
- package/src/eslint-config.mjs +542 -0
- package/src/go-work.mjs +394 -0
- package/src/governance/adr-registry.mjs +539 -0
- package/src/governance/clock.mjs +69 -0
- package/src/governance/debt-ledger.mjs +274 -0
- package/src/governance/discovery-proposal.mjs +423 -0
- package/src/governance/fitness-registry.mjs +504 -0
- package/src/governance/fitness-rules.mjs +668 -0
- package/src/governance/metrics.mjs +392 -0
- package/src/governance/preset-fingerprints.json +16 -0
- package/src/governance/profile-registry.mjs +366 -0
- package/src/governance/provenance-record.mjs +177 -0
- package/src/governance/reconcile-candidates.mjs +301 -0
- package/src/governance/reconcile-score.mjs +503 -0
- package/src/governance/row-schema.mjs +208 -0
- package/src/governance/verdict.mjs +127 -0
- package/src/governance/waiver.mjs +105 -0
- package/src/graph/create-dependencies.mjs +96 -0
- package/src/intent/intent-manifest.json +347 -0
- package/src/intent/mask-non-code.mjs +640 -0
- package/src/lsp/boundary-config.mjs +225 -0
- package/src/lsp/diagnose.mjs +202 -0
- package/src/lsp/diagnostics.mjs +241 -0
- package/src/lsp/protocol.mjs +215 -0
- package/src/lsp/server.mjs +922 -0
- package/src/lsp/workspace-index.mjs +891 -0
- package/src/nx-json.mjs +95 -0
- package/src/options.mjs +611 -0
- package/src/process.mjs +91 -0
- package/src/providers/moon.mjs +733 -0
- package/src/providers/native/README.md +204 -0
- package/src/providers/native/coverage.mjs +74 -0
- package/src/providers/native/differential.fixtures.mjs +1277 -0
- package/src/providers/native/discover.mjs +431 -0
- package/src/providers/native/graph.mjs +234 -0
- package/src/providers/native/index.mjs +152 -0
- package/src/providers/native/model.mjs +755 -0
- package/src/providers/nx.mjs +178 -0
- package/src/report/README.md +89 -0
- package/src/report/adr-text.mjs +129 -0
- package/src/report/context-text.mjs +109 -0
- package/src/report/debt-text.mjs +105 -0
- package/src/report/diff-text.mjs +219 -0
- package/src/report/discover-text.mjs +186 -0
- package/src/report/drift-text.mjs +194 -0
- package/src/report/envelope-shape.mjs +161 -0
- package/src/report/evidence.mjs +157 -0
- package/src/report/explain-text.mjs +159 -0
- package/src/report/graph-text.mjs +116 -0
- package/src/report/health-text.mjs +123 -0
- package/src/report/history-text.mjs +204 -0
- package/src/report/impact-text.mjs +128 -0
- package/src/report/json.mjs +173 -0
- package/src/report/plan-context-text.mjs +159 -0
- package/src/report/provenance-text.mjs +78 -0
- package/src/report/reconcile-text.mjs +159 -0
- package/src/report/report-text.mjs +264 -0
- package/src/report/sarif.mjs +953 -0
- package/src/report/text.mjs +823 -0
- package/src/report/waivers-text.mjs +100 -0
- package/src/rules/README.md +123 -0
- package/src/rules/index.mjs +962 -0
- package/src/rules/match.mjs +1708 -0
- package/src/rules/messages.mjs +73 -0
- package/src/rules/reachability.mjs +224 -0
- package/src/rules/specifiers.mjs +300 -0
- package/src/rules/tags.mjs +238 -0
- package/src/rules/topology.mjs +333 -0
- package/src/tsconfig-paths.mjs +237 -0
- package/src/verdict.mjs +145 -0
- 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 };
|