@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,539 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Architecture Intent — judgment against the observed graph.
|
|
3
|
+
*
|
|
4
|
+
* The intent model (`./model.mjs`) is the contract; this module decides what
|
|
5
|
+
* the observed architecture does about it. Pure: takes a provider-neutral
|
|
6
|
+
* `{nodes, dependencies}` graph (the same shape `../../src/commands/context.mjs`
|
|
7
|
+
* hands every command) and returns a verdict plus the records a report renders.
|
|
8
|
+
* No I/O, no provider import — it reads the graph, not the workspace.
|
|
9
|
+
*
|
|
10
|
+
* The empty-result invariant (`../../../../AGENTS.md`) decides every branch:
|
|
11
|
+
* a clean verdict is only ever returned when every row and every boundary was
|
|
12
|
+
* actually judged. Three non-obvious choices follow from it:
|
|
13
|
+
*
|
|
14
|
+
* - **A forbidden relationship is violated by ANY path** (direct or
|
|
15
|
+
* transitive), matching `notDependOnLibsWithTags` in `../../src/rules/tags.mjs`
|
|
16
|
+
* which already judges the transitive closure — intent and the boundary
|
|
17
|
+
* policy must not disagree about the same boundary. The witness path is
|
|
18
|
+
* reported for determinism.
|
|
19
|
+
* - **Allowing is observed, not assumed.** An `allowed` row declares an
|
|
20
|
+
* architecture statement the team intends to build; an `allowed` dep that
|
|
21
|
+
* is not observed is drift, and drift is the payoff of intent. It is a
|
|
22
|
+
* finding (exit 1) unless the row is `"optional": true`, which demotes its
|
|
23
|
+
* absence to a coverage note.
|
|
24
|
+
* - **A boundary (or a row side) that matches no observed project is not a
|
|
25
|
+
* clean verdict — it is a no-verdict.** Whether a selector names something
|
|
26
|
+
* real needs the graph, so it is decided here, not at load
|
|
27
|
+
* (`./model.mjs` stays nodes-free); "cannot verify this boundary" reads as
|
|
28
|
+
* no-verdict (exit 3), never as "intent passes".
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { resolveMembers } from "./selectors.mjs";
|
|
32
|
+
import { buildReachability, getPath, pathExists } from "../../src/rules/reachability.mjs";
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* What a finding means — one entry per `messageId`, the arrangement
|
|
36
|
+
* `../../src/report/sarif.mjs` derives its rule descriptors from, the same as
|
|
37
|
+
* `../../src/go-work.mjs` and `../../src/tsconfig-paths.mjs`.
|
|
38
|
+
*/
|
|
39
|
+
export const INTENT_MESSAGES = Object.freeze({
|
|
40
|
+
intentForbiddenEdge:
|
|
41
|
+
"A dependency this workspace's architecture-intent.json forbids appears in the observed " +
|
|
42
|
+
"project graph — the architecture that is being built contradicts the one that was intended.",
|
|
43
|
+
intentAllowedMissing:
|
|
44
|
+
"A dependency this workspace's architecture-intent.json allows is not observed — the " +
|
|
45
|
+
"intended architecture is not being built.",
|
|
46
|
+
projectMissing:
|
|
47
|
+
"the intent requires a project to exist, but the observed architecture has no project of that name",
|
|
48
|
+
projectPresent: "the intent forbids a project, but the observed architecture contains it",
|
|
49
|
+
projectTagMissing: "a required project lacks a required tag",
|
|
50
|
+
dependencyForbidden: "a dependency the intent forbids exists in the observed architecture",
|
|
51
|
+
dependencyNotAllowed: "an observed dependency is not among the dependencies the intent allows",
|
|
52
|
+
tagDependencyForbidden:
|
|
53
|
+
"a dependency the intent forbids between two tags exists in the observed architecture",
|
|
54
|
+
intentUnknownProject: "an intent row names a project the observed architecture does not have",
|
|
55
|
+
intentUnknownTag: "a tag rule names a tag no observed project carries",
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
export const INTENT_MESSAGE_IDS = Object.freeze(Object.keys(INTENT_MESSAGES));
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* How a `from`/`to` side resolves: a declared boundary name wins (deterministic
|
|
62
|
+
* — names can never contain `:` so they cannot collide with a selector), else
|
|
63
|
+
* the side is an inline selector resolved against the graph.
|
|
64
|
+
*/
|
|
65
|
+
function sidePatterns(intent, side) {
|
|
66
|
+
const declared = intent.boundaries.find((b) => b.name === side);
|
|
67
|
+
if (declared) return { boundaryName: side, patterns: declared.match };
|
|
68
|
+
// Not a declared name — an inline selector (`name:x`, `tag:x`, `directory:x`,
|
|
69
|
+
// `*`, or a bare project name).
|
|
70
|
+
return { boundaryName: null, patterns: [side] };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* A collision-free key for one (source, target) project pair. `JSON.stringify`
|
|
75
|
+
* of the pair is unambiguous for any strings a graph can name, unlike a
|
|
76
|
+
* delimiter join, which any delimiter could appear inside.
|
|
77
|
+
*
|
|
78
|
+
* @param {string} source
|
|
79
|
+
* @param {string} target
|
|
80
|
+
* @returns {string}
|
|
81
|
+
*/
|
|
82
|
+
function edgeKey(source, target) {
|
|
83
|
+
return `${JSON.stringify(source)}>${JSON.stringify(target)}`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* All the observed DIRECT edges, as (source, target) keys — built once, not
|
|
88
|
+
* per row.
|
|
89
|
+
*
|
|
90
|
+
* @param {{nodes: object, dependencies?: object}} graph
|
|
91
|
+
* @returns {Set<string>}
|
|
92
|
+
*/
|
|
93
|
+
function directEdges(graph) {
|
|
94
|
+
const edges = new Set();
|
|
95
|
+
for (const [source, dependencies] of Object.entries(graph.dependencies ?? {})) {
|
|
96
|
+
for (const dependency of dependencies ?? []) {
|
|
97
|
+
// `Object.hasOwn`, never `graph.nodes[target] !== undefined`: the node map
|
|
98
|
+
// arrives from a provider and is usually a plain object (`JSON.parse` of
|
|
99
|
+
// `nx graph --file=`), so a truthiness/`!== undefined` test on it answers
|
|
100
|
+
// `constructor`, `toString`, `valueOf`, `hasOwnProperty` and `__proto__`
|
|
101
|
+
// from `Object.prototype` — an edge pointing at a project of that name
|
|
102
|
+
// would be counted as OBSERVED on a workspace that has no such project.
|
|
103
|
+
// Both directions are wrong and both are quiet: an `allowed` row anchored
|
|
104
|
+
// on the phantom reads as satisfied (drift that is never reported), and a
|
|
105
|
+
// `forbidden` one reads as violated by an edge that does not exist.
|
|
106
|
+
// `./selectors.mjs`'s own name lookup carries the same guard for the same
|
|
107
|
+
// reason.
|
|
108
|
+
if (Object.hasOwn(graph.nodes, dependency.target)) {
|
|
109
|
+
edges.add(edgeKey(source, dependency.target));
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return edges;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* `graph.dependencies`, minus every `implicit`-typed edge — a build-ordering
|
|
118
|
+
* declaration (`implicitDependencies` in Nx's `project.json`, Moon's own
|
|
119
|
+
* `source: "implicit"` marker, or `archkeep.json`'s native equivalent), not a
|
|
120
|
+
* dependency derived from code. This is the exact exclusion
|
|
121
|
+
* `../../src/commands/drift.mjs`'s `buildObserved` already applies to what a
|
|
122
|
+
* drift report COUNTS ("N implicit edges excluded"); every edge this judge
|
|
123
|
+
* reads must come from the same filtered set, or the report's claim and the
|
|
124
|
+
* judge's verdict can disagree on the same run — a `forbidden`/`allowed` row,
|
|
125
|
+
* a `dependencies.forbidden`/`dependencies.allowed` row, or a `forbiddenTags`
|
|
126
|
+
* row firing (or, for `allowed`, silently NOT firing) on an edge the report
|
|
127
|
+
* just said it excluded. Applied once, here, rather than at each of
|
|
128
|
+
* `directEdges`/`observedEdgePairs`/`buildReachability`'s call sites below —
|
|
129
|
+
* three copies of the same filter is how one of them drifts.
|
|
130
|
+
*
|
|
131
|
+
* `../../src/rules/reachability.mjs`'s `buildReachability` itself stays
|
|
132
|
+
* type-agnostic on purpose: `../../src/commands/edge-constraints.mjs`'s
|
|
133
|
+
* `declaredEdgeViolationsForCheck` and `../../src/rules/index.mjs`'s
|
|
134
|
+
* `evaluate()` both deliberately hand it the UNFILTERED graph, because
|
|
135
|
+
* `depConstraints`' `notDependOnLibsWithTags` is a different, tag-based
|
|
136
|
+
* question that intentionally treats a declared build-ordering edge as a real
|
|
137
|
+
* coupling. Architecture intent's `allowed`/`forbidden` rows ask a narrower
|
|
138
|
+
* question — "is this actually being built in code" — and a build-ordering
|
|
139
|
+
* declaration is precisely not an answer to it.
|
|
140
|
+
*
|
|
141
|
+
* @param {{dependencies?: object}} graph
|
|
142
|
+
* @returns {object}
|
|
143
|
+
*/
|
|
144
|
+
function codeDependencies(graph) {
|
|
145
|
+
// Null-prototype, for the reason `../../src/providers/native/graph.mjs`'s
|
|
146
|
+
// `buildDependencies` uses one: every key here is a source project NAME, and
|
|
147
|
+
// that provider's own dependency map is already null-prototype, so a project
|
|
148
|
+
// literally named `__proto__` reaches this loop as a real entry. Writing it
|
|
149
|
+
// onto a plain `{}` invokes `Object.prototype`'s inherited `__proto__` setter
|
|
150
|
+
// and REPOINTS this object instead of adding a key — measured: the whole
|
|
151
|
+
// filtered map's prototype became the edge array, `Object.entries(filtered)`
|
|
152
|
+
// no longer listed the project, and `judgeIntent` returned verdict `ok` with
|
|
153
|
+
// zero findings for a `forbidden` row that the observed graph plainly
|
|
154
|
+
// violated. Every edge leaving that project vanished from `directEdges`,
|
|
155
|
+
// `observedEdgePairs` and `buildReachability` at once, which is the silent
|
|
156
|
+
// direction `../../AGENTS.md` refuses: byte-identical to a clean workspace.
|
|
157
|
+
/** @type {Record<string, object[]>} */
|
|
158
|
+
const filtered = Object.create(null);
|
|
159
|
+
for (const [source, dependencies] of Object.entries(graph.dependencies ?? {})) {
|
|
160
|
+
filtered[source] = (dependencies ?? []).filter((dependency) => dependency.type !== "implicit");
|
|
161
|
+
}
|
|
162
|
+
return filtered;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Judge an intent model against a graph.
|
|
167
|
+
*
|
|
168
|
+
* @param {object} intent The normalized model from `./model.mjs`.
|
|
169
|
+
* @param {{nodes: object, dependencies?: object}} graph
|
|
170
|
+
* @returns {{verdict: "ok"|"findings"|"no-verdict",
|
|
171
|
+
* findings: object[], unresolved: object[], boundaries: object[], notes: string[]}}
|
|
172
|
+
* `findings` are `{source, target, rule, boundaryFrom, boundaryTo, message}`;
|
|
173
|
+
* `unresolved` are `{boundary, issue}` for every empty side or empty
|
|
174
|
+
* boundary; `boundaries` are `{name, projects[]}` (sorted members); `notes`
|
|
175
|
+
* are coverage notes that change no verdict — today only an
|
|
176
|
+
* `"optional": true` `allowed` row whose statement is not yet built.
|
|
177
|
+
*/
|
|
178
|
+
export function judgeIntent(intent, graph) {
|
|
179
|
+
const nodes = graph.nodes ?? {};
|
|
180
|
+
// Every edge this judge reads goes through `dependencies`, never
|
|
181
|
+
// `graph.dependencies` directly — see `codeDependencies` above for why.
|
|
182
|
+
const dependencies = codeDependencies(graph);
|
|
183
|
+
const boundaries = intent.boundaries.map((b) => ({
|
|
184
|
+
name: b.name,
|
|
185
|
+
projects: resolveMembers(b.match, nodes),
|
|
186
|
+
}));
|
|
187
|
+
const byName = new Map(boundaries.map((b) => [b.name, b.projects]));
|
|
188
|
+
const reach = buildReachability({ nodes, dependencies });
|
|
189
|
+
const edges = directEdges({ nodes, dependencies });
|
|
190
|
+
|
|
191
|
+
const findings = [];
|
|
192
|
+
const unresolved = [];
|
|
193
|
+
const notes = [];
|
|
194
|
+
|
|
195
|
+
for (const boundary of boundaries) {
|
|
196
|
+
if (boundary.projects.length === 0) {
|
|
197
|
+
unresolved.push({
|
|
198
|
+
boundary: boundary.name,
|
|
199
|
+
issue: `matches no observed project — the intent for this boundary cannot be verified`,
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const judgeRow = (row, listName) => {
|
|
205
|
+
const from = sidePatterns(intent, row.from);
|
|
206
|
+
const to = sidePatterns(intent, row.to);
|
|
207
|
+
const fromMembers = byName.get(from.boundaryName) ?? resolveMembers(from.patterns, nodes);
|
|
208
|
+
const toMembers = byName.get(to.boundaryName) ?? resolveMembers(to.patterns, nodes);
|
|
209
|
+
|
|
210
|
+
if (fromMembers.length === 0 || toMembers.length === 0) {
|
|
211
|
+
unresolved.push({
|
|
212
|
+
boundary: from.boundaryName ?? row.from,
|
|
213
|
+
issue: `the ${listName} row between "${row.from}" and "${row.to}" has a side with no observed projects — its intent cannot be verified`,
|
|
214
|
+
});
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (listName === "forbidden") {
|
|
219
|
+
// A single-project self-ban in disguise: both sides resolve to the same
|
|
220
|
+
// ONE project, so no cross-pair can ever exist and the row can never
|
|
221
|
+
// fire. Reading that as "clean — the ban holds" is the silent direction:
|
|
222
|
+
// a ban that cannot fire and one that held are byte-identical. The
|
|
223
|
+
// judge has the graph, so it decides here — a no-verdict, never clean.
|
|
224
|
+
// The load-provable spellings (`name:x` vs `name:x`) are already
|
|
225
|
+
// rejected at load (`../model.mjs`); this catches the ones only the
|
|
226
|
+
// graph can prove (`{from: "packages", to: "name:x"}` where `x` is the
|
|
227
|
+
// boundary's only member). A same multi-member set is NOT this case:
|
|
228
|
+
// `*`→`*` and `packages`→`tag:type-package` both have real cross-pairs
|
|
229
|
+
// and judge normally.
|
|
230
|
+
if (fromMembers.length === 1 && toMembers.length === 1 && fromMembers[0] === toMembers[0]) {
|
|
231
|
+
unresolved.push({
|
|
232
|
+
boundary: from.boundaryName ?? row.from,
|
|
233
|
+
issue:
|
|
234
|
+
`the forbidden row between "${row.from}" and "${row.to}" resolves both ` +
|
|
235
|
+
`sides to the single project "${fromMembers[0]}" — a self-ban that can ` +
|
|
236
|
+
`never fire, and reading it as holding would be the silent direction`,
|
|
237
|
+
});
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
// Every cross-pair. Self-pairs (source === target) are excluded: a
|
|
241
|
+
// project reaching itself is not a dependency, and `{from: "*", to: "*"}`
|
|
242
|
+
// must not report every project for self-reach.
|
|
243
|
+
const pairs = [];
|
|
244
|
+
for (const source of fromMembers) {
|
|
245
|
+
for (const target of toMembers) {
|
|
246
|
+
if (source === target) continue;
|
|
247
|
+
if (pathExists(reach, source, target)) pairs.push([source, target]);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
if (pairs.length === 0) return; // a ban that holds is a clean verdict
|
|
251
|
+
pairs.sort((a, b) => (a[0] === b[0] ? (a[1] < b[1] ? -1 : 1) : a[0] < b[0] ? -1 : 1));
|
|
252
|
+
const [source, target] = pairs[0];
|
|
253
|
+
const path = getPath(reach, { nodes }, source, target).map((n) => n.name);
|
|
254
|
+
const witness = path.length > 1 ? path.join(" → ") : `${source} → ${target}`;
|
|
255
|
+
findings.push({
|
|
256
|
+
source,
|
|
257
|
+
target,
|
|
258
|
+
rule: "intentForbiddenEdge",
|
|
259
|
+
boundaryFrom: from.boundaryName ?? row.from,
|
|
260
|
+
boundaryTo: to.boundaryName ?? row.to,
|
|
261
|
+
message:
|
|
262
|
+
`${witness} — architecture-intent.json forbids "${row.from}" reaching "${row.to}"` +
|
|
263
|
+
(from.boundaryName ? ` (boundary ${from.boundaryName})` : "") +
|
|
264
|
+
(to.boundaryName ? ` to ${to.boundaryName}` : ""),
|
|
265
|
+
});
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// allowed.
|
|
270
|
+
// A row satisfied when ANY distinct cross-pair edge is observed: the intent
|
|
271
|
+
// statement "X may depend on Y" holds the moment one such dependency is
|
|
272
|
+
// being built. `from` and `to` resolve to the same single-member set
|
|
273
|
+
// (`{from: "module", to: "module"}` on a one-project boundary) yield no
|
|
274
|
+
// distinct pair: nothing to be built, so nothing to miss — vacuously held.
|
|
275
|
+
let held = false;
|
|
276
|
+
for (const source of fromMembers) {
|
|
277
|
+
for (const target of toMembers) {
|
|
278
|
+
if (source === target) continue;
|
|
279
|
+
if (edges.has(edgeKey(source, target))) {
|
|
280
|
+
held = true;
|
|
281
|
+
break;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
if (held) break;
|
|
285
|
+
}
|
|
286
|
+
if (held) return;
|
|
287
|
+
// No distinct cross-pair exists — the statement is vacuous, not missing.
|
|
288
|
+
if (fromMembers.length === 1 && toMembers.length === 1 && fromMembers[0] === toMembers[0])
|
|
289
|
+
return;
|
|
290
|
+
if (row.optional) {
|
|
291
|
+
// Absence tolerated — aspirational, not drift — but it is still a
|
|
292
|
+
// coverage note and the caller threads it into the report's coverage
|
|
293
|
+
// notes, so a reader can tell "optional and absent" from "never checked".
|
|
294
|
+
notes.push(
|
|
295
|
+
`optional allowed intent "${row.from}" → "${row.to}" is not yet observed — aspirational, not drift`,
|
|
296
|
+
);
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
const pairs = [];
|
|
300
|
+
for (const source of fromMembers) {
|
|
301
|
+
for (const target of toMembers) {
|
|
302
|
+
if (source !== target) pairs.push([source, target]);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
pairs.sort((a, b) => (a[0] === b[0] ? (a[1] < b[1] ? -1 : 1) : a[0] < b[0] ? -1 : 1));
|
|
306
|
+
const [source, target] = pairs[0] ?? [fromMembers[0], toMembers[0]];
|
|
307
|
+
findings.push({
|
|
308
|
+
source,
|
|
309
|
+
target,
|
|
310
|
+
rule: "intentAllowedMissing",
|
|
311
|
+
boundaryFrom: from.boundaryName ?? row.from,
|
|
312
|
+
boundaryTo: to.boundaryName ?? row.to,
|
|
313
|
+
message:
|
|
314
|
+
`architecture-intent.json allows "${row.from}" reaching "${row.to}", but no observed ` +
|
|
315
|
+
`dependency between projects of the two sides satisfies it` +
|
|
316
|
+
(from.boundaryName ? ` (boundary ${from.boundaryName})` : "") +
|
|
317
|
+
(to.boundaryName ? ` and ${to.boundaryName}` : ""),
|
|
318
|
+
});
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
for (const row of intent.forbidden) judgeRow(row, "forbidden");
|
|
322
|
+
for (const row of intent.allowed) judgeRow(row, "allowed");
|
|
323
|
+
|
|
324
|
+
// ── Drift sections (projects / dependencies / forbiddenTags) ─────────────
|
|
325
|
+
// These judge existence facts by *name* and *tag* rather than by boundary
|
|
326
|
+
// selector. `from`/`to` on a dependency row and `name` on a project row are
|
|
327
|
+
// exact project names, never selectors — a typo'd name must be a loud
|
|
328
|
+
// `intentUnknownProject`, not a boundary that silently matches nothing. Tag
|
|
329
|
+
// rows reference tag values, judged against the union of every observed
|
|
330
|
+
// project's tags.
|
|
331
|
+
/** @type {Map<string, {name: string, tags: string[]}>} */
|
|
332
|
+
const projectsByName = new Map();
|
|
333
|
+
for (const [entryName, entry] of Object.entries(nodes)) {
|
|
334
|
+
// Tags live under `data.tags` (the provider-neutral node shape the selector
|
|
335
|
+
// engine reads) — read from there, never from a bare `tags` field.
|
|
336
|
+
projectsByName.set(entryName, { name: entryName, tags: entry?.data?.tags ?? [] });
|
|
337
|
+
}
|
|
338
|
+
const known = (name) => projectsByName.has(name);
|
|
339
|
+
const tagsOf = (name) => {
|
|
340
|
+
const project = projectsByName.get(name);
|
|
341
|
+
return project?.tags ?? [];
|
|
342
|
+
};
|
|
343
|
+
const tagVocabulary = new Set();
|
|
344
|
+
for (const project of projectsByName.values())
|
|
345
|
+
for (const tag of project.tags) tagVocabulary.add(tag);
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* `alreadyNamed` lets a drift rule skip a (source,target) pair a boundary or
|
|
349
|
+
* project rule already reported, so no edge is ever two findings.
|
|
350
|
+
*/
|
|
351
|
+
const alreadyNamed = (list, source, target) =>
|
|
352
|
+
list.some((f) => f.source === source && f.target === target);
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* The observed direct edges as `[source, target]` pairs, built with the same
|
|
356
|
+
* traversal `directEdges` above uses — the drift sections read the pair list
|
|
357
|
+
* rather than parse `edgeKey` strings back apart. Order is irrelevant: the
|
|
358
|
+
* final sort below is total. Reads the same `dependencies` — implicit edges
|
|
359
|
+
* already filtered — as `directEdges`/`buildReachability` above, never
|
|
360
|
+
* `graph.dependencies` directly.
|
|
361
|
+
*/
|
|
362
|
+
const observedEdgePairs = [];
|
|
363
|
+
for (const [source, sourceDependencies] of Object.entries(dependencies)) {
|
|
364
|
+
for (const dependency of sourceDependencies) {
|
|
365
|
+
// `Object.hasOwn` for the same reason `directEdges` above uses it — an
|
|
366
|
+
// inherited `Object.prototype` member is not a project, and counting one
|
|
367
|
+
// as an observed edge feeds a phantom pair to every drift section below.
|
|
368
|
+
if (Object.hasOwn(nodes, dependency.target)) {
|
|
369
|
+
observedEdgePairs.push([source, dependency.target]);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
for (const row of intent.dependencies?.forbidden ?? []) {
|
|
375
|
+
const { source, target } = row;
|
|
376
|
+
// Transitive, like the `forbidden` row above and `notDependOnLibsWithTags`
|
|
377
|
+
// in `../../src/rules/tags.mjs` — a dependency reached through an
|
|
378
|
+
// intermediate project (A imports B imports the forbidden C) is still the
|
|
379
|
+
// forbidden dependency; checking DIRECT edges only was the silent
|
|
380
|
+
// direction this file's header is about. Self-pairs are excluded, matching
|
|
381
|
+
// the row-based check above: `pathExists` treats every project as reaching
|
|
382
|
+
// itself, and a project cannot depend on itself.
|
|
383
|
+
if (source !== target && pathExists(reach, source, target)) {
|
|
384
|
+
const path = getPath(reach, { nodes }, source, target).map((n) => n.name);
|
|
385
|
+
const witness = path.length > 1 ? path.join(" → ") : `${source} → ${target}`;
|
|
386
|
+
findings.push({
|
|
387
|
+
source,
|
|
388
|
+
target,
|
|
389
|
+
rule: "dependencyForbidden",
|
|
390
|
+
boundaryFrom: null,
|
|
391
|
+
boundaryTo: null,
|
|
392
|
+
message: `${witness} — architecture-intent.json forbids this dependency, but the observed graph contains it`,
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
const hasDependencyAllowlist = (intent.dependencies?.allowed?.length ?? 0) > 0;
|
|
397
|
+
if (hasDependencyAllowlist) {
|
|
398
|
+
const allowedSet = new Set(
|
|
399
|
+
(intent.dependencies.allowed ?? []).map((r) => `${r.source}${r.target}`),
|
|
400
|
+
);
|
|
401
|
+
for (const [source, target] of observedEdgePairs) {
|
|
402
|
+
if (alreadyNamed(findings, source, target) || allowedSet.has(`${source}${target}`)) continue;
|
|
403
|
+
findings.push({
|
|
404
|
+
source,
|
|
405
|
+
target,
|
|
406
|
+
rule: "dependencyNotAllowed",
|
|
407
|
+
boundaryFrom: null,
|
|
408
|
+
boundaryTo: null,
|
|
409
|
+
message: `${source} → ${target} — architecture-intent.json allows only the listed dependencies, and this one is not listed`,
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
for (const row of intent.forbiddenTags ?? []) {
|
|
414
|
+
const { from, to } = row;
|
|
415
|
+
const fromMissing = !tagVocabulary.has(from);
|
|
416
|
+
const toMissing = !tagVocabulary.has(to);
|
|
417
|
+
if (fromMissing || toMissing) {
|
|
418
|
+
const missing = [fromMissing ? `"${from}"` : null, toMissing ? `"${to}"` : null]
|
|
419
|
+
.filter(Boolean)
|
|
420
|
+
.join(" and ");
|
|
421
|
+
findings.push({
|
|
422
|
+
source: null,
|
|
423
|
+
target: null,
|
|
424
|
+
rule: "intentUnknownTag",
|
|
425
|
+
boundaryFrom: null,
|
|
426
|
+
boundaryTo: null,
|
|
427
|
+
message: `architecture-intent.json forbids a dependency from tag "${from}" to tag "${to}", but no observed project carries ${missing}`,
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
// Transitive, for the same reason as `dependencies.forbidden` above: a
|
|
431
|
+
// project carrying `from` that reaches (directly or transitively) a
|
|
432
|
+
// project carrying `to` is still the forbidden relationship. Self-pairs
|
|
433
|
+
// are excluded — one project may legitimately carry both tags, and
|
|
434
|
+
// `pathExists` treats every project as reaching itself.
|
|
435
|
+
const fromProjects = [...projectsByName.values()]
|
|
436
|
+
.filter((project) => project.tags.includes(from))
|
|
437
|
+
.map((project) => project.name);
|
|
438
|
+
const toProjects = [...projectsByName.values()]
|
|
439
|
+
.filter((project) => project.tags.includes(to))
|
|
440
|
+
.map((project) => project.name);
|
|
441
|
+
for (const source of fromProjects) {
|
|
442
|
+
for (const target of toProjects) {
|
|
443
|
+
if (source === target || !pathExists(reach, source, target)) continue;
|
|
444
|
+
const path = getPath(reach, { nodes }, source, target).map((n) => n.name);
|
|
445
|
+
const witness = path.length > 1 ? path.join(" → ") : `${source} → ${target}`;
|
|
446
|
+
findings.push({
|
|
447
|
+
source,
|
|
448
|
+
target,
|
|
449
|
+
rule: "tagDependencyForbidden",
|
|
450
|
+
boundaryFrom: null,
|
|
451
|
+
boundaryTo: null,
|
|
452
|
+
message: `${witness} — architecture-intent.json forbids a dependency from any project carrying tag "${from}" to any project carrying tag "${to}"`,
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
// Referenced-but-unknown project names in the dependency sections — a typo'd
|
|
458
|
+
// name can never fire and must be loud, never silent.
|
|
459
|
+
const referenced = new Set();
|
|
460
|
+
for (const row of [
|
|
461
|
+
...(intent.dependencies?.allowed ?? []),
|
|
462
|
+
...(intent.dependencies?.forbidden ?? []),
|
|
463
|
+
]) {
|
|
464
|
+
if (typeof row.source === "string") referenced.add(row.source);
|
|
465
|
+
if (typeof row.target === "string") referenced.add(row.target);
|
|
466
|
+
}
|
|
467
|
+
for (const name of [...referenced].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0))) {
|
|
468
|
+
if (!known(name)) {
|
|
469
|
+
findings.push({
|
|
470
|
+
source: null,
|
|
471
|
+
target: null,
|
|
472
|
+
rule: "intentUnknownProject",
|
|
473
|
+
boundaryFrom: null,
|
|
474
|
+
boundaryTo: null,
|
|
475
|
+
message: `architecture-intent.json names project "${name}", but the observed architecture has no project of that name`,
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
if (intent.projects?.required) {
|
|
480
|
+
for (const row of intent.projects.required) {
|
|
481
|
+
const requiredTags = row.tags ?? [];
|
|
482
|
+
if (!known(row.name)) {
|
|
483
|
+
findings.push({
|
|
484
|
+
source: null,
|
|
485
|
+
target: null,
|
|
486
|
+
rule: "projectMissing",
|
|
487
|
+
boundaryFrom: null,
|
|
488
|
+
boundaryTo: null,
|
|
489
|
+
message: `architecture-intent.json requires project "${row.name}" to exist, but the observed architecture has no project of that name`,
|
|
490
|
+
});
|
|
491
|
+
}
|
|
492
|
+
for (const tag of requiredTags) {
|
|
493
|
+
if (!tagsOf(row.name).includes(tag)) {
|
|
494
|
+
findings.push({
|
|
495
|
+
source: null,
|
|
496
|
+
target: null,
|
|
497
|
+
rule: "projectTagMissing",
|
|
498
|
+
boundaryFrom: null,
|
|
499
|
+
boundaryTo: null,
|
|
500
|
+
message: `architecture-intent.json requires project "${row.name}" to carry tag "${tag}", but it does not`,
|
|
501
|
+
});
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
if (intent.projects?.forbidden) {
|
|
507
|
+
for (const row of intent.projects.forbidden) {
|
|
508
|
+
if (known(row.name)) {
|
|
509
|
+
findings.push({
|
|
510
|
+
source: null,
|
|
511
|
+
target: null,
|
|
512
|
+
rule: "projectPresent",
|
|
513
|
+
boundaryFrom: null,
|
|
514
|
+
boundaryTo: null,
|
|
515
|
+
message: `architecture-intent.json forbids project "${row.name}", but the observed architecture contains it`,
|
|
516
|
+
});
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// Determinism: findings by (source, target), unresolved by boundary, both
|
|
522
|
+
// with plain `<` comparison (never localeCompare). The comparator is a total
|
|
523
|
+
// order: `null`/`undefined` sides (presence and tag findings) sort before
|
|
524
|
+
// any string, and equal keys return 0 — a comparator that returned 1 for an
|
|
525
|
+
// equal key would hand the sort an inconsistent order and the byte-identical
|
|
526
|
+
// promise with it.
|
|
527
|
+
const compareKey = (a, b) => {
|
|
528
|
+
if (a === b) return 0;
|
|
529
|
+
if (a === null || a === undefined) return -1;
|
|
530
|
+
if (b === null || b === undefined) return 1;
|
|
531
|
+
return a < b ? -1 : 1;
|
|
532
|
+
};
|
|
533
|
+
findings.sort((a, b) => compareKey(a.source, b.source) || compareKey(a.target, b.target) || 0);
|
|
534
|
+
unresolved.sort((a, b) => (a.boundary < b.boundary ? -1 : a.boundary > b.boundary ? 1 : 0));
|
|
535
|
+
|
|
536
|
+
const verdict = findings.length > 0 ? "findings" : unresolved.length > 0 ? "no-verdict" : "ok";
|
|
537
|
+
|
|
538
|
+
return { verdict, findings, unresolved, boundaries, notes };
|
|
539
|
+
}
|