@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,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
|
+
}
|