@ecoma-io/archkeep 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (131) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +262 -0
  3. package/cli.mjs +2792 -0
  4. package/index.mjs +85 -0
  5. package/lsp.mjs +81 -0
  6. package/nx.mjs +24 -0
  7. package/package.json +81 -0
  8. package/presets/clean-architecture.json +78 -0
  9. package/presets/ddd-bounded-contexts.json +88 -0
  10. package/presets/hexagonal.json +68 -0
  11. package/presets/layered.json +92 -0
  12. package/presets/modular-monolith.json +85 -0
  13. package/presets/vertical-slice.json +68 -0
  14. package/src/analysis/analyze.mjs +218 -0
  15. package/src/analysis/contract.md +259 -0
  16. package/src/analysis/go.mjs +414 -0
  17. package/src/analysis/manifest-util.mjs +68 -0
  18. package/src/analysis/python.mjs +1266 -0
  19. package/src/analysis/registry.mjs +74 -0
  20. package/src/analysis/rust.mjs +674 -0
  21. package/src/analysis/source-util.mjs +230 -0
  22. package/src/analysis/typescript.mjs +1034 -0
  23. package/src/analysis/vue.mjs +156 -0
  24. package/src/architecture-intent/intent-fingerprint.mjs +29 -0
  25. package/src/architecture-intent/judge.mjs +539 -0
  26. package/src/architecture-intent/model.mjs +703 -0
  27. package/src/architecture-intent/selectors.mjs +170 -0
  28. package/src/canonical.mjs +48 -0
  29. package/src/commands/README.md +266 -0
  30. package/src/commands/adr.mjs +248 -0
  31. package/src/commands/check.mjs +989 -0
  32. package/src/commands/context-command.mjs +212 -0
  33. package/src/commands/context.mjs +790 -0
  34. package/src/commands/custom-rules.mjs +428 -0
  35. package/src/commands/debt.mjs +218 -0
  36. package/src/commands/diff.mjs +523 -0
  37. package/src/commands/discover.mjs +159 -0
  38. package/src/commands/drift.mjs +473 -0
  39. package/src/commands/edge-constraints.mjs +355 -0
  40. package/src/commands/explain.mjs +359 -0
  41. package/src/commands/fitness.mjs +226 -0
  42. package/src/commands/graph.mjs +297 -0
  43. package/src/commands/health.mjs +213 -0
  44. package/src/commands/history.mjs +614 -0
  45. package/src/commands/impact.mjs +226 -0
  46. package/src/commands/plan-context-command.mjs +496 -0
  47. package/src/commands/policy.mjs +138 -0
  48. package/src/commands/provenance-command.mjs +352 -0
  49. package/src/commands/provenance.mjs +159 -0
  50. package/src/commands/reconcile.mjs +219 -0
  51. package/src/commands/report.mjs +553 -0
  52. package/src/commands/snapshot-meta.mjs +107 -0
  53. package/src/commands/waivers.mjs +240 -0
  54. package/src/config.mjs +1308 -0
  55. package/src/containment.mjs +234 -0
  56. package/src/custom-rules/evidence.mjs +340 -0
  57. package/src/custom-rules/host.mjs +1023 -0
  58. package/src/custom-rules/values.mjs +43 -0
  59. package/src/entry-point.mjs +55 -0
  60. package/src/errors.mjs +36 -0
  61. package/src/eslint-config.mjs +542 -0
  62. package/src/go-work.mjs +394 -0
  63. package/src/governance/adr-registry.mjs +539 -0
  64. package/src/governance/clock.mjs +69 -0
  65. package/src/governance/debt-ledger.mjs +274 -0
  66. package/src/governance/discovery-proposal.mjs +423 -0
  67. package/src/governance/fitness-registry.mjs +504 -0
  68. package/src/governance/fitness-rules.mjs +668 -0
  69. package/src/governance/metrics.mjs +392 -0
  70. package/src/governance/preset-fingerprints.json +16 -0
  71. package/src/governance/profile-registry.mjs +366 -0
  72. package/src/governance/provenance-record.mjs +177 -0
  73. package/src/governance/reconcile-candidates.mjs +301 -0
  74. package/src/governance/reconcile-score.mjs +503 -0
  75. package/src/governance/row-schema.mjs +208 -0
  76. package/src/governance/verdict.mjs +127 -0
  77. package/src/governance/waiver.mjs +105 -0
  78. package/src/graph/create-dependencies.mjs +96 -0
  79. package/src/intent/intent-manifest.json +347 -0
  80. package/src/intent/mask-non-code.mjs +640 -0
  81. package/src/lsp/boundary-config.mjs +225 -0
  82. package/src/lsp/diagnose.mjs +202 -0
  83. package/src/lsp/diagnostics.mjs +241 -0
  84. package/src/lsp/protocol.mjs +215 -0
  85. package/src/lsp/server.mjs +922 -0
  86. package/src/lsp/workspace-index.mjs +891 -0
  87. package/src/nx-json.mjs +95 -0
  88. package/src/options.mjs +611 -0
  89. package/src/process.mjs +91 -0
  90. package/src/providers/moon.mjs +733 -0
  91. package/src/providers/native/README.md +204 -0
  92. package/src/providers/native/coverage.mjs +74 -0
  93. package/src/providers/native/differential.fixtures.mjs +1277 -0
  94. package/src/providers/native/discover.mjs +431 -0
  95. package/src/providers/native/graph.mjs +234 -0
  96. package/src/providers/native/index.mjs +152 -0
  97. package/src/providers/native/model.mjs +755 -0
  98. package/src/providers/nx.mjs +178 -0
  99. package/src/report/README.md +89 -0
  100. package/src/report/adr-text.mjs +129 -0
  101. package/src/report/context-text.mjs +109 -0
  102. package/src/report/debt-text.mjs +105 -0
  103. package/src/report/diff-text.mjs +219 -0
  104. package/src/report/discover-text.mjs +186 -0
  105. package/src/report/drift-text.mjs +194 -0
  106. package/src/report/envelope-shape.mjs +161 -0
  107. package/src/report/evidence.mjs +157 -0
  108. package/src/report/explain-text.mjs +159 -0
  109. package/src/report/graph-text.mjs +116 -0
  110. package/src/report/health-text.mjs +123 -0
  111. package/src/report/history-text.mjs +204 -0
  112. package/src/report/impact-text.mjs +128 -0
  113. package/src/report/json.mjs +173 -0
  114. package/src/report/plan-context-text.mjs +159 -0
  115. package/src/report/provenance-text.mjs +78 -0
  116. package/src/report/reconcile-text.mjs +159 -0
  117. package/src/report/report-text.mjs +264 -0
  118. package/src/report/sarif.mjs +953 -0
  119. package/src/report/text.mjs +823 -0
  120. package/src/report/waivers-text.mjs +100 -0
  121. package/src/rules/README.md +123 -0
  122. package/src/rules/index.mjs +962 -0
  123. package/src/rules/match.mjs +1708 -0
  124. package/src/rules/messages.mjs +73 -0
  125. package/src/rules/reachability.mjs +224 -0
  126. package/src/rules/specifiers.mjs +300 -0
  127. package/src/rules/tags.mjs +238 -0
  128. package/src/rules/topology.mjs +333 -0
  129. package/src/tsconfig-paths.mjs +237 -0
  130. package/src/verdict.mjs +145 -0
  131. package/src/workspace.mjs +580 -0
@@ -0,0 +1,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
+ }