@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,1277 @@
1
+ /**
2
+ * The shared machinery behind Oracle 1 (`differential.integration.test.mjs`):
3
+ * the six fixture-tree builders, the two-provider run, the diagnosable
4
+ * `diffGraphs` comparison, the ledger, and the breach checks that enforce
5
+ * `../../../../../AGENTS.md`'s invariant — "an empty result is a claim, not a
6
+ * shrug" — applied to two project-model providers instead of one rule path.
7
+ *
8
+ * This module carries **no vitest import and no `describe`/`it`/`expect`
9
+ * call** — it is loadable and runnable with `node -e "import(...)"` alone,
10
+ * spawning nothing at import time. That is a deliberate seam, not
11
+ * incidental: `differential.integration.test.mjs` imports this module to run
12
+ * its own suite, and a config-spelling differential (a future test covering
13
+ * this package's `boundaryConfig`/`tsConfig` option spelling) is meant to
14
+ * reuse the same Nx-marker and `archkeep.json`-marker fixture-tree builders
15
+ * rather than writing a third copy of either — reusing a file that also
16
+ * calls `describe()` at module scope would run this file's own 19 cases and
17
+ * 3 `nx graph` spawns as a side effect of importing it, and would throw
18
+ * outright in a plain Node process with no vitest runner around it.
19
+ *
20
+ * See `./README.md`'s "What proves this provider against a tree it was not
21
+ * tested on" for the axis list and the fixture-pair budget this module's
22
+ * builders exist to hold to.
23
+ */
24
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
25
+ import { join } from "node:path";
26
+
27
+ import { normalizeProjectRoot } from "../../rules/specifiers.mjs";
28
+ import { evaluate } from "../../rules/index.mjs";
29
+ import { loadBoundaryConfig } from "../../config.mjs";
30
+ import { analyzeWorkspace, createWorkspace, selectFiles } from "../../workspace.mjs";
31
+ import { runProcess } from "../../process.mjs";
32
+ import { readProjectGraph } from "../nx.mjs";
33
+ import { nativeProvider } from "./index.mjs";
34
+
35
+ // ---------------------------------------------------------------------------
36
+ // Tree-writing helpers
37
+ // ---------------------------------------------------------------------------
38
+
39
+ /**
40
+ * @param {(path: string, text: string) => void} write
41
+ * @param {Record<string, string>} files `{relativePath: text}`.
42
+ */
43
+ export function writeAll(write, files) {
44
+ for (const [path, text] of Object.entries(files)) write(path, text);
45
+ }
46
+
47
+ /** @param {string} root */
48
+ export function writeIn(root) {
49
+ return (relativePath, text) => {
50
+ mkdirSync(join(root, relativePath, ".."), { recursive: true });
51
+ writeFileSync(join(root, relativePath), text);
52
+ };
53
+ }
54
+
55
+ /** @param {string} root */
56
+ export function readFileFrom(root) {
57
+ return (path) => {
58
+ try {
59
+ return readFileSync(join(root, path), "utf8");
60
+ } catch {
61
+ return null;
62
+ }
63
+ };
64
+ }
65
+
66
+ /** Builds `{workspace, imports}` from an already-resolved graph and file list. */
67
+ function analyze(root, graph, files, tsConfig) {
68
+ const { workspace, owned } = createWorkspace({ root, graph, files, tsConfig });
69
+ const selected = selectFiles(
70
+ owned.map(({ file }) => file),
71
+ [],
72
+ { root, cwd: root },
73
+ );
74
+ return { workspace, ...analyzeWorkspace(workspace, selected) };
75
+ }
76
+
77
+ /** `{name → {root, type, tags}}`, comparable whichever provider produced it. */
78
+ export const nodeShape = (nodes) =>
79
+ Object.fromEntries(
80
+ Object.entries(nodes).map(([name, node]) => [
81
+ name,
82
+ {
83
+ root: normalizeProjectRoot(node.data.root),
84
+ type: node.type,
85
+ tags: [...(node.data.tags ?? [])].sort(),
86
+ },
87
+ ]),
88
+ );
89
+
90
+ /** `[JSON.stringify([source, target, type]), ...]`, sorted so build order
91
+ * cannot matter. Kept as a MULTISET (repeats survive), not deduplicated —
92
+ * `diffGraphs` below relies on that: a duplicate edge on one side and not the
93
+ * other is a real disagreement, and collapsing repeats here would erase it
94
+ * before `diffGraphs` ever saw it.
95
+ *
96
+ * Canonicalised with `JSON.stringify` rather than a space-joined
97
+ * `` `${source} ${target} ${type}` `` — a project name or file path
98
+ * containing a space would let two distinct edges collapse onto the same
99
+ * string (`source: "a b", target: "c"` and `source: "a", target: "b c"` both
100
+ * join to `"a b c static"`), which is exactly the silent shape
101
+ * `../../../../../AGENTS.md`'s invariant forbids: two real disagreements
102
+ * reading as one agreement. The provider this differential compares already
103
+ * dedups its own edges with a `JSON.stringify` key for the identical reason
104
+ * (`./graph.mjs`'s `buildDependencies`), so this keeps the differential's own
105
+ * canonicalisation consistent with the code under test rather than a weaker
106
+ * copy of it. */
107
+ export const dependencyShape = (dependencies) =>
108
+ Object.values(dependencies)
109
+ .flat()
110
+ .map(({ source, target, type }) => JSON.stringify([source, target, type]))
111
+ .sort();
112
+
113
+ /** `["messageId sourceFile:line:column", ...]`, sorted the same way. */
114
+ export const verdictShape = (violations) =>
115
+ violations.map((v) => `${v.messageId} ${v.sourceFile}:${v.line}:${v.column}`).sort();
116
+
117
+ /**
118
+ * `runProcess` with `NX_DAEMON=false` layered onto the environment for the
119
+ * one spawn it wraps — measured: without it, the first `nx graph` against one
120
+ * of this file's throwaway roots starts a background daemon that binds to
121
+ * that root's absolute path, and a later fixture pair's own spawn (or a
122
+ * second run of this suite) can hit a daemon still alive for a directory
123
+ * `afterAll` already deleted, rather than starting clean. Restores whatever
124
+ * `NX_DAEMON` was set to beforehand (or unsets it) once the spawn returns, so
125
+ * this file's own choice does not leak into anything spawned after it in the
126
+ * same process.
127
+ *
128
+ * @type {typeof runProcess}
129
+ */
130
+ export function runNxGraphSpawn(file, args, cwd) {
131
+ const previous = process.env.NX_DAEMON;
132
+ process.env.NX_DAEMON = "false";
133
+ try {
134
+ return runProcess(file, args, cwd);
135
+ } finally {
136
+ if (previous === undefined) delete process.env.NX_DAEMON;
137
+ else process.env.NX_DAEMON = previous;
138
+ }
139
+ }
140
+
141
+ /**
142
+ * Refuses to let `runBothProviders` build either provider's graph on top of a
143
+ * partial scan. `analyzeWorkspace` (`../../workspace.mjs`) records a failure
144
+ * rather than throwing when a file cannot be read or parsed — one bad file
145
+ * must not blank a whole analysis run — but that leaves the CALLER
146
+ * responsible for noticing one happened: a file either `readFile` cannot
147
+ * read yields a shorter import list on that side alone, and two shorter
148
+ * lists can still compare equal to each other by coincidence, which reads as
149
+ * "the providers agree" when what actually happened is "neither provider
150
+ * finished looking" — the exact silent shape `../../../../../AGENTS.md`'s
151
+ * invariant forbids, applied to this harness's own inputs rather than to the
152
+ * tool it drives. Called after both analyses and before either graph is
153
+ * built, so a fixture never gets far enough to diff two incomplete scans.
154
+ *
155
+ * @param {{sourceFile: string, reason: string}[]} nxFailures
156
+ * @param {{sourceFile: string, reason: string}[]} nativeFailures
157
+ * @throws {Error} naming every failing file, when either list is non-empty.
158
+ */
159
+ export function assertNoAnalysisFailures(nxFailures, nativeFailures) {
160
+ const named = [
161
+ ...nxFailures.map((f) => `nx:${f.sourceFile} (${f.reason})`),
162
+ ...nativeFailures.map((f) => `native:${f.sourceFile} (${f.reason})`),
163
+ ];
164
+ if (named.length > 0) {
165
+ throw new Error(
166
+ `runBothProviders refuses to compare two partial scans: ${named.length} file(s) failed to ` +
167
+ `read or parse before either provider's graph was built — ${named.join(", ")}`,
168
+ );
169
+ }
170
+ }
171
+
172
+ /**
173
+ * Runs both providers over one already-written pair of trees and returns
174
+ * everything a fixture's own assertions need: each side's graph (already put
175
+ * through `nodeShape`/`dependencyShape`), each side's raw violations (for
176
+ * `verdictShape` and for a count), and the loaded configs (for a fixture
177
+ * that wants to re-run `evaluate` itself, as the red-direction tests below
178
+ * do).
179
+ *
180
+ * @param {{
181
+ * nxRoot: string, nxFiles: string[],
182
+ * nativeRoot: string, nativeFiles: string[],
183
+ * boundaryConfig?: string,
184
+ * io?: {run?: typeof runProcess, resolveNx?: () => string},
185
+ * }} args `io.run` defaults to `runNxGraphSpawn` above, not the bare
186
+ * `runProcess` `../nx.mjs` itself defaults to — a caller that wants to
187
+ * count spawns (or otherwise observe them) wraps THIS default, so it
188
+ * inherits the `NX_DAEMON` handling rather than choosing between the two.
189
+ */
190
+ export async function runBothProviders({
191
+ nxRoot,
192
+ nxFiles,
193
+ nativeRoot,
194
+ nativeFiles,
195
+ boundaryConfig = "module-boundaries.config.mjs",
196
+ io = {},
197
+ }) {
198
+ const nxGraph = readProjectGraph(nxRoot, { run: runNxGraphSpawn, ...io });
199
+ const nxAnalysis = analyze(nxRoot, nxGraph, nxFiles, undefined);
200
+
201
+ const readFile = readFileFrom(nativeRoot);
202
+ const discovered = nativeProvider.discover({ root: nativeRoot, files: nativeFiles, readFile });
203
+ if (discovered.failures.length > 0) {
204
+ throw new Error(
205
+ `native discovery reported unclaimed-file failures the fixture did not expect: ` +
206
+ JSON.stringify(discovered.failures),
207
+ );
208
+ }
209
+ const preGraph = {
210
+ nodes: Object.fromEntries(
211
+ discovered.projects.map((project) => [
212
+ project.name,
213
+ { name: project.name, data: { root: project.root } },
214
+ ]),
215
+ ),
216
+ };
217
+ const nativeAnalysis = analyze(nativeRoot, preGraph, nativeFiles, discovered.model.tsConfig);
218
+ assertNoAnalysisFailures(nxAnalysis.failures, nativeAnalysis.failures);
219
+ const nativeGraph = nativeProvider.buildGraph({
220
+ discovered,
221
+ importSites: nativeAnalysis.imports,
222
+ });
223
+
224
+ const nxConfig = await loadBoundaryConfig(nxRoot, boundaryConfig);
225
+ const nativeConfig = await loadBoundaryConfig(nativeRoot, boundaryConfig);
226
+ const nxViolations = evaluate(nxAnalysis.imports, nxGraph, nxConfig);
227
+ const nativeViolations = evaluate(nativeAnalysis.imports, nativeGraph, nativeConfig);
228
+
229
+ return {
230
+ nx: {
231
+ nodes: nodeShape(nxGraph.nodes),
232
+ dependencies: dependencyShape(nxGraph.dependencies),
233
+ violations: nxViolations,
234
+ violationStrings: verdictShape(nxViolations),
235
+ },
236
+ native: {
237
+ nodes: nodeShape(nativeGraph.nodes),
238
+ dependencies: dependencyShape(nativeGraph.dependencies),
239
+ violations: nativeViolations,
240
+ violationStrings: verdictShape(nativeViolations),
241
+ },
242
+ };
243
+ }
244
+
245
+ // ---------------------------------------------------------------------------
246
+ // diffGraphs — a per-field diagnosable comparison
247
+ // ---------------------------------------------------------------------------
248
+
249
+ /**
250
+ * One row per disagreement between the two engines: `{kind, subject, field,
251
+ * nx, native}`. `kind` is `"node"`, `"dependency"` or `"verdict"`; `subject`
252
+ * is the project name, the `"source target type"` string, or (for verdicts,
253
+ * which vary in exact `line:column` per fixture edit) the `messageId` alone;
254
+ * `field` is which part of that subject disagreed.
255
+ *
256
+ * Deliberately not a single `expect(a).toEqual(b)` over two whole graphs —
257
+ * that prints two 200-line objects and names neither the project nor the
258
+ * field that differs.
259
+ *
260
+ * Two canonicalisations that must NOT throw away a real disagreement:
261
+ *
262
+ * - **Dependency edges compare as a MULTISET, not a `Set`.** A duplicate edge
263
+ * on one side (two import sites resolving to the same `source target type`
264
+ * triple) and a single copy on the other is a real difference — one side's
265
+ * analyzer over- or under-counting the same edge — and `new Set(...)`
266
+ * merging both down to one element each would make it invisible.
267
+ * - **Verdicts compare both the per-`messageId` COUNT and, when the counts
268
+ * already match, the sorted list of `sourceFile:line:column` locations.**
269
+ * Two engines that agree "3 violations of rule X" while pointing at three
270
+ * different places have not actually agreed on anything a developer could
271
+ * act on — `../../../../../AGENTS.md`'s invariant is about the verdict
272
+ * reaching the right place, not just the right count.
273
+ *
274
+ * @param {{nodes: Record<string, {root: string, type: string, tags: string[]}>,
275
+ * dependencies: string[],
276
+ * violations: {messageId: string, sourceFile?: string, line?: number, column?: number}[]}} nxSide
277
+ * @param {{nodes: Record<string, {root: string, type: string, tags: string[]}>,
278
+ * dependencies: string[],
279
+ * violations: {messageId: string, sourceFile?: string, line?: number, column?: number}[]}} nativeSide
280
+ * @returns {{kind: string, subject: string, field: string, nx: unknown, native: unknown}[]}
281
+ */
282
+ export function diffGraphs(nxSide, nativeSide) {
283
+ const rows = [];
284
+
285
+ const nxNames = new Set(Object.keys(nxSide.nodes));
286
+ const nativeNames = new Set(Object.keys(nativeSide.nodes));
287
+ for (const name of nxNames) {
288
+ if (!nativeNames.has(name)) {
289
+ rows.push({
290
+ kind: "node",
291
+ subject: name,
292
+ field: "presence",
293
+ nx: "present",
294
+ native: "absent",
295
+ });
296
+ }
297
+ }
298
+ for (const name of nativeNames) {
299
+ if (!nxNames.has(name)) {
300
+ rows.push({
301
+ kind: "node",
302
+ subject: name,
303
+ field: "presence",
304
+ nx: "absent",
305
+ native: "present",
306
+ });
307
+ }
308
+ }
309
+ for (const name of nxNames) {
310
+ if (!nativeNames.has(name)) continue;
311
+ const nxNode = nxSide.nodes[name];
312
+ const nativeNode = nativeSide.nodes[name];
313
+ for (const field of ["root", "type"]) {
314
+ if (nxNode[field] !== nativeNode[field]) {
315
+ rows.push({
316
+ kind: "node",
317
+ subject: name,
318
+ field,
319
+ nx: nxNode[field],
320
+ native: nativeNode[field],
321
+ });
322
+ }
323
+ }
324
+ // `JSON.stringify` of the sorted array, not a comma-joined string — a tag
325
+ // containing a comma (nothing in `./model.mjs`'s tag validation forbids
326
+ // one) would let two distinct tag lists collapse onto the same string:
327
+ // `["a,b"]` and `["a", "b"]` both join to `"a,b"`, and the second list's
328
+ // real difference from the first would vanish before this function ever
329
+ // returned a row for it.
330
+ const nxTags = JSON.stringify([...nxNode.tags].sort());
331
+ const nativeTags = JSON.stringify([...nativeNode.tags].sort());
332
+ if (nxTags !== nativeTags) {
333
+ rows.push({ kind: "node", subject: name, field: "tags", nx: nxTags, native: nativeTags });
334
+ }
335
+ }
336
+
337
+ /** @param {string[]} edges @returns {Map<string, number>} */
338
+ const countByEdge = (edges) => {
339
+ const counts = new Map();
340
+ for (const edge of edges) counts.set(edge, (counts.get(edge) ?? 0) + 1);
341
+ return counts;
342
+ };
343
+ const nxEdgeCounts = countByEdge(nxSide.dependencies);
344
+ const nativeEdgeCounts = countByEdge(nativeSide.dependencies);
345
+ for (const edge of new Set([...nxEdgeCounts.keys(), ...nativeEdgeCounts.keys()])) {
346
+ const nxCount = nxEdgeCounts.get(edge) ?? 0;
347
+ const nativeCount = nativeEdgeCounts.get(edge) ?? 0;
348
+ if (nxCount === 0) {
349
+ rows.push({
350
+ kind: "dependency",
351
+ subject: edge,
352
+ field: "presence",
353
+ nx: "absent",
354
+ native: "present",
355
+ });
356
+ } else if (nativeCount === 0) {
357
+ rows.push({
358
+ kind: "dependency",
359
+ subject: edge,
360
+ field: "presence",
361
+ nx: "present",
362
+ native: "absent",
363
+ });
364
+ } else if (nxCount !== nativeCount) {
365
+ rows.push({
366
+ kind: "dependency",
367
+ subject: edge,
368
+ field: "count",
369
+ nx: nxCount,
370
+ native: nativeCount,
371
+ });
372
+ }
373
+ }
374
+
375
+ /** @param {{messageId: string, sourceFile?: string, line?: number, column?: number}[]} violations
376
+ * @returns {Map<string, string[]>} messageId → sorted `sourceFile:line:column` strings. */
377
+ const locationsByMessage = (violations) => {
378
+ /** @type {Map<string, string[]>} */
379
+ const byMessage = new Map();
380
+ for (const v of violations) {
381
+ const location = `${v.sourceFile}:${v.line}:${v.column}`;
382
+ const list = byMessage.get(v.messageId) ?? [];
383
+ list.push(location);
384
+ byMessage.set(v.messageId, list);
385
+ }
386
+ for (const list of byMessage.values()) list.sort();
387
+ return byMessage;
388
+ };
389
+ const nxLocations = locationsByMessage(nxSide.violations);
390
+ const nativeLocations = locationsByMessage(nativeSide.violations);
391
+ for (const messageId of new Set([...nxLocations.keys(), ...nativeLocations.keys()])) {
392
+ const nxList = nxLocations.get(messageId) ?? [];
393
+ const nativeList = nativeLocations.get(messageId) ?? [];
394
+ if (nxList.length !== nativeList.length) {
395
+ rows.push({
396
+ kind: "verdict",
397
+ subject: messageId,
398
+ field: "count",
399
+ nx: nxList.length,
400
+ native: nativeList.length,
401
+ });
402
+ } else if (nxList.join("|") !== nativeList.join("|")) {
403
+ rows.push({
404
+ kind: "verdict",
405
+ subject: messageId,
406
+ field: "location",
407
+ nx: nxList.join(","),
408
+ native: nativeList.join(","),
409
+ });
410
+ }
411
+ }
412
+
413
+ return rows;
414
+ }
415
+
416
+ /** Prefixes every row's `subject` with `${label}:` so ledger rows (and stale-row
417
+ * detection) are scoped to one fixture pair and cannot silently absorb a
418
+ * disagreement in another. */
419
+ export function namespaced(rows, label) {
420
+ return rows.map((row) => ({ ...row, subject: `${label}:${row.subject}` }));
421
+ }
422
+
423
+ // ---------------------------------------------------------------------------
424
+ // The ledger — a decision that was made, never a difference that was hidden
425
+ // ---------------------------------------------------------------------------
426
+
427
+ /**
428
+ * The only direction a `LedgerRow` may ever excuse — see `LEDGER_DIRECTIONS`
429
+ * below for why the enum has exactly one member today and how a second one
430
+ * would be added.
431
+ * @typedef {"native-only"} LedgerDirection
432
+ */
433
+
434
+ /**
435
+ * @typedef {{subject: string, field: string, reason: string, issue: string,
436
+ * direction: LedgerDirection}} LedgerRow
437
+ * A row mirrors `scripts/differential-real-trees.mjs`'s `LEDGER` shape with
438
+ * two additions: `issue`, because every ledgered difference must carry a
439
+ * linked, trackable follow-up and not only a reason — a difference that is
440
+ * merely explained in prose can be re-explained forever, while one with an
441
+ * issue number has to eventually close — and `direction`, an explicit,
442
+ * enumerated claim about which side reported more. `direction` is not
443
+ * decorative: `classifyDifferences` below both validates it against
444
+ * `LEDGER_DIRECTIONS` and, independently of any ledger content, refuses to
445
+ * let a matching `subject`/`field` pair explain away a verdict-count row
446
+ * where Nx reports MORE than native — the one direction no row, however it
447
+ * spells its `reason`, may ever cover.
448
+ */
449
+
450
+ /**
451
+ * Every value `LedgerRow.direction` may take. One member today —
452
+ * `"native-only"`, the loud, self-correcting direction
453
+ * (`../../../../../AGENTS.md`'s invariant) — because a ledger row only ever
454
+ * exists to explain native reporting something Nx does not; the enum is
455
+ * still written as a list, not a literal type alias inlined into the
456
+ * `LedgerRow` typedef alone, so a second legitimate direction (should one
457
+ * ever exist) is one entry added here plus a matching rule in
458
+ * `classifyDifferences`, not a search for every place `"native-only"` was
459
+ * spelled as a bare string.
460
+ */
461
+ export const LEDGER_DIRECTIONS = Object.freeze(/** @type {const} */ (["native-only"]));
462
+
463
+ /**
464
+ * Every entry here is a **difference native reports and Nx does not** — the
465
+ * loud, self-correcting direction (`../../../../../AGENTS.md`'s invariant). An
466
+ * entry in the other direction is refused structurally: `classifyDifferences`
467
+ * throws on any verdict-count row where Nx's count exceeds native's, before
468
+ * it ever looks at whether a row here matches — no `reason`, however it is
469
+ * worded, can turn that row into an explained one.
470
+ *
471
+ * Empty today: the one row this ledger ever carried (`layout:
472
+ * noRelativeOrAbsoluteImportsAcrossLibraries`, native-only, issue #31) retired
473
+ * when `../nx.mjs`'s `readProjectGraph` started merging `nx.json`'s
474
+ * `workspaceLayout` back onto the graph it returns — see that function's own
475
+ * header, and `readWorkspaceLayout`/`requireCompleteWorkspaceLayout` in
476
+ * `../../options.mjs`. The `layout` fixture pair below is unchanged and now
477
+ * agrees on both engines instead of differing by one, which is what closes
478
+ * issue #31: the divergence this row explained no longer exists to explain.
479
+ *
480
+ * @type {readonly LedgerRow[]}
481
+ */
482
+ export const LEDGER = Object.freeze([]);
483
+
484
+ /**
485
+ * Every fixture-pair label this file actually runs a comparison over — the
486
+ * one list every `LEDGER` row's `subject` prefix must belong to, and the one
487
+ * list every `assertPairAgrees`/`pairProblems` call site draws its `label`
488
+ * argument from, so the two can never drift into naming different pairs.
489
+ */
490
+ export const PAIR_LABELS = Object.freeze(["simple", "composite", "layout"]);
491
+
492
+ /**
493
+ * The direction a `diffGraphs` row itself claims, in `LedgerRow.direction`'s
494
+ * own vocabulary plus its one unledgerable opposite: `"native-only"` when
495
+ * native reports something nx does not (a `presence` row with nx absent, or a
496
+ * `count` row where native's number is the larger one), `"nx-only"` for the
497
+ * mirror shape — the direction `LEDGER_DIRECTIONS` has no member for, because
498
+ * a `LedgerRow` may only ever excuse native reporting more — and `null` for
499
+ * every other field (`root`, `type`, `tags`, a verdict's `location`): those
500
+ * are two-sided value mismatches, not one side reporting more than the other,
501
+ * so they carry no direction a ledger row could ever share.
502
+ *
503
+ * @param {{field: string, nx?: unknown, native?: unknown}} difference
504
+ * @returns {"native-only" | "nx-only" | null}
505
+ */
506
+ function differenceDirection(difference) {
507
+ if (difference.field === "presence") {
508
+ if (difference.nx === "absent" && difference.native === "present") return "native-only";
509
+ if (difference.nx === "present" && difference.native === "absent") return "nx-only";
510
+ return null;
511
+ }
512
+ if (
513
+ difference.field === "count" &&
514
+ typeof difference.nx === "number" &&
515
+ typeof difference.native === "number"
516
+ ) {
517
+ if (difference.native > difference.nx) return "native-only";
518
+ if (difference.nx > difference.native) return "nx-only";
519
+ }
520
+ return null;
521
+ }
522
+
523
+ /**
524
+ * Splits `diffGraphs` rows into explained (a `ledger` row covers it),
525
+ * unexplained (nothing does — a finding), and stale ledger rows (they cover
526
+ * nothing that fired — also a finding, from the other side). Mirrors
527
+ * `scripts/differential-real-trees.mjs`'s `classifyDifferences`; the
528
+ * per-fixture `namespaced` prefix above is this function's substitute for
529
+ * that oracle's `treeName` filter.
530
+ *
531
+ * Before any matching happens, every ledger row is checked for the same
532
+ * three things a decision here always needs: a non-empty `reason`, a
533
+ * non-empty `issue`, and a `direction` drawn from `LEDGER_DIRECTIONS` — a row
534
+ * whose `direction` is missing or misspelled is as unusable as one with no
535
+ * `reason` at all, so it fails the same way, before matching.
536
+ *
537
+ * Then, independently of `ledger`'s contents: any `difference` of
538
+ * `kind: "verdict"`, `field: "count"` where `nx > native` is refused outright
539
+ * — `classifyDifferences` THROWS rather than returning it as "explained" or
540
+ * even "unexplained". Nx finding a violation native does not is the one
541
+ * silent shape `../../../../../AGENTS.md`'s invariant exists to rule out, and a
542
+ * `LedgerRow` matching that difference's `subject`/`field` used to be enough
543
+ * to explain it away regardless of what the row's `reason` said — this is
544
+ * the fix: no reason, however worded, gets a vote on that direction.
545
+ *
546
+ * A row's `subject`/`field` matching a difference is not enough on its own,
547
+ * either: `LEDGER` rows only ever declare `direction: "native-only"`, and
548
+ * matching by `subject`/`field` alone would let such a row explain away a
549
+ * difference running the opposite way — native missing something nx has,
550
+ * which is a native-provider regression, not the loud/self-correcting
551
+ * shape the ledger exists to record. `differenceDirection` above answers
552
+ * which way a given row actually runs; a row only fires when that answer
553
+ * equals `row.direction`, so a matching `subject`/`field` whose direction
554
+ * disagrees lands in `unexplained` instead.
555
+ *
556
+ * @param {{kind?: string, subject: string, field: string, nx?: unknown, native?: unknown}[]} differences
557
+ * @param {readonly LedgerRow[]} ledger
558
+ * @returns {{explained: object[], unexplained: object[], stale: LedgerRow[]}}
559
+ * @throws {Error} on a malformed ledger row, or a `nx > native` verdict-count
560
+ * difference no ledger row may ever cover.
561
+ */
562
+ export function classifyDifferences(differences, ledger) {
563
+ for (const row of ledger) {
564
+ if (!row.reason?.trim()) {
565
+ throw new Error(`ledger row for "${row.subject}"/"${row.field}" has an empty reason`);
566
+ }
567
+ if (!row.issue?.trim()) {
568
+ throw new Error(
569
+ `ledger row for "${row.subject}"/"${row.field}" has no linked issue — every ledgered ` +
570
+ `difference must carry a linked, trackable follow-up, not only a reason`,
571
+ );
572
+ }
573
+ if (!LEDGER_DIRECTIONS.includes(row.direction)) {
574
+ throw new Error(
575
+ `ledger row for "${row.subject}"/"${row.field}" has an invalid direction ` +
576
+ `${JSON.stringify(row.direction)} — must be one of ${LEDGER_DIRECTIONS.join(", ")}`,
577
+ );
578
+ }
579
+ }
580
+ const fired = new Set();
581
+ const explained = [];
582
+ const unexplained = [];
583
+ for (const difference of differences) {
584
+ if (
585
+ difference.kind === "verdict" &&
586
+ difference.field === "count" &&
587
+ typeof difference.nx === "number" &&
588
+ typeof difference.native === "number" &&
589
+ difference.nx > difference.native
590
+ ) {
591
+ throw new Error(
592
+ `classifyDifferences refuses to explain "${difference.subject}": nx reported ` +
593
+ `${difference.nx} and native reported only ${difference.native} — native ` +
594
+ `under-reporting relative to Nx is never ledgerable, at any count ` +
595
+ `(../../../../../AGENTS.md's invariant); this is an empty-verdict-shaped breach, not a ` +
596
+ `difference any ledger row — matching or not — may explain away.`,
597
+ );
598
+ }
599
+ const row = ledger.find(
600
+ (r) =>
601
+ r.subject === difference.subject &&
602
+ r.field === difference.field &&
603
+ r.direction === differenceDirection(difference),
604
+ );
605
+ if (row) {
606
+ fired.add(row);
607
+ explained.push({ difference, row });
608
+ } else {
609
+ unexplained.push(difference);
610
+ }
611
+ }
612
+ return { explained, unexplained, stale: ledger.filter((row) => !fired.has(row)) };
613
+ }
614
+
615
+ /**
616
+ * The aggregate half of the empty-verdict claim (`../../../../../AGENTS.md`'s
617
+ * invariant), applied to one fixture pair's TOTAL violation count per engine.
618
+ * On a pair the fixture designed to contain a violation, an engine answering
619
+ * zero has not found a clean tree — it has stopped looking. Deliberately
620
+ * takes no `ledger` argument: this direction is never ledgerable (see
621
+ * `classifyDifferences` above for the same refusal enforced structurally),
622
+ * and the surest way to keep it that way is a function with nowhere to pass
623
+ * one in.
624
+ *
625
+ * This only ever catches a TOTAL of zero on one side; `perMessageBreaches`
626
+ * below is the finer-grained sibling that catches native under-reporting
627
+ * relative to Nx on any one `messageId` even when neither total is literally
628
+ * zero.
629
+ *
630
+ * @param {string} label
631
+ * @param {{nx: number, native: number}} counts
632
+ * @returns {string[]}
633
+ */
634
+ export function emptyVerdictBreaches(label, counts) {
635
+ const breaches = [];
636
+ for (const [engine, count] of Object.entries(counts)) {
637
+ if (count === 0) {
638
+ breaches.push(
639
+ `${label}: ${engine} reported ZERO violations on a fixture built to contain one — ` +
640
+ "that is a silent engine, not a clean tree.",
641
+ );
642
+ }
643
+ }
644
+ return breaches;
645
+ }
646
+
647
+ /**
648
+ * The per-`messageId` sibling `emptyVerdictBreaches`'s own doc comment
649
+ * promises: native reporting FEWER of one `messageId` than Nx is a breach
650
+ * even when neither side's TOTAL is zero — `{nx: 9, native: 1}` on one rule
651
+ * is exactly as silent, for that rule, as `{nx: 1, native: 0}` is for the
652
+ * whole pair, because whichever finding native dropped is still gone.
653
+ * Deliberately takes no `ledger`, for the same reason `emptyVerdictBreaches`
654
+ * does not.
655
+ *
656
+ * @param {string} label
657
+ * @param {{messageId: string}[]} nxViolations
658
+ * @param {{messageId: string}[]} nativeViolations
659
+ * @returns {string[]}
660
+ */
661
+ export function perMessageBreaches(label, nxViolations, nativeViolations) {
662
+ /** @param {{messageId: string}[]} violations @returns {Map<string, number>} */
663
+ const countBy = (violations) => {
664
+ const counts = new Map();
665
+ for (const v of violations) counts.set(v.messageId, (counts.get(v.messageId) ?? 0) + 1);
666
+ return counts;
667
+ };
668
+ const nxCounts = countBy(nxViolations);
669
+ const nativeCounts = countBy(nativeViolations);
670
+ const breaches = [];
671
+ for (const [messageId, nxCount] of nxCounts) {
672
+ const nativeCount = nativeCounts.get(messageId) ?? 0;
673
+ if (nativeCount < nxCount) {
674
+ breaches.push(
675
+ `${label}: nx reported ${nxCount} × "${messageId}" and native reported only ` +
676
+ `${nativeCount} — native under-reporting relative to Nx on one messageId is a breach ` +
677
+ `even though neither engine's total is zero.`,
678
+ );
679
+ }
680
+ }
681
+ return breaches;
682
+ }
683
+
684
+ /**
685
+ * Every problem a fixture pair's own run surfaces, as human-readable
686
+ * strings, empty when the pair fully agrees: an aggregate empty-verdict
687
+ * breach, a per-`messageId` breach, an unexplained `diffGraphs` difference,
688
+ * or a ledger row scoped to this `label` that never fired. Pure — it returns
689
+ * problems rather than asserting them — so `assertPairAgrees` below is a
690
+ * thin, vitest-free wrapper and a test that wants the raw list (rather than a
691
+ * thrown `Error`) can call this directly.
692
+ *
693
+ * @param {string} label
694
+ * @param {Awaited<ReturnType<typeof runBothProviders>>} result
695
+ * @param {readonly LedgerRow[]} [ledger]
696
+ * @returns {string[]}
697
+ */
698
+ export function pairProblems(label, result, ledger = LEDGER) {
699
+ const problems = [
700
+ ...emptyVerdictBreaches(label, {
701
+ nx: result.nx.violations.length,
702
+ native: result.native.violations.length,
703
+ }),
704
+ ...perMessageBreaches(label, result.nx.violations, result.native.violations),
705
+ ];
706
+
707
+ const rows = namespaced(diffGraphs(result.nx, result.native), label);
708
+ const { unexplained, stale } = classifyDifferences(rows, ledger);
709
+ if (unexplained.length > 0) {
710
+ problems.push(`${label}: unexplained differences:\n${JSON.stringify(unexplained, null, 2)}`);
711
+ }
712
+ const staleForLabel = stale.filter((row) => row.subject.startsWith(`${label}:`));
713
+ if (staleForLabel.length > 0) {
714
+ problems.push(`${label}: stale ledger rows:\n${JSON.stringify(staleForLabel, null, 2)}`);
715
+ }
716
+ return problems;
717
+ }
718
+
719
+ /**
720
+ * Runs a fixture pair's providers, diffs the two graphs, classifies the
721
+ * differences against `LEDGER`, and throws (naming every problem at once)
722
+ * unless the pair fully agrees: no breach (aggregate or per-`messageId`,
723
+ * regardless of ledger), no unexplained difference, no stale ledger row
724
+ * scoped to this pair. One call per fixture pair in
725
+ * `differential.integration.test.mjs`.
726
+ *
727
+ * A thin wrapper over `pairProblems` — throwing rather than returning is what
728
+ * lets a caller write `assertPairAgrees("simple", result)` as one statement
729
+ * inside an `it()` and get vitest's own failure reporting for free, without
730
+ * this module importing `expect` (or anything else from `vitest`) to do it.
731
+ *
732
+ * @param {string} label
733
+ * @param {Awaited<ReturnType<typeof runBothProviders>>} result
734
+ * @param {readonly LedgerRow[]} [ledger]
735
+ */
736
+ export function assertPairAgrees(label, result, ledger = LEDGER) {
737
+ const problems = pairProblems(label, result, ledger);
738
+ if (problems.length > 0) {
739
+ throw new Error(`${label}: provider differential disagreement:\n ${problems.join("\n ")}`);
740
+ }
741
+ }
742
+
743
+ /**
744
+ * Rows whose `subject`'s label prefix (the text before the first `:`, which
745
+ * `namespaced` above always inserts) names no pair in `knownLabels`. A row
746
+ * like this is invisible to the stale-row check every real pair runs —
747
+ * `pairProblems`/`assertPairAgrees` only ever look at
748
+ * `stale.filter((row) => row.subject.startsWith(`${label}:`))` for the one
749
+ * `label` that pair ran under, so a row whose prefix matches NO real label is
750
+ * filtered out by every pair's own check and reported stale by none of
751
+ * them — a waiver that both explains nothing (nothing it could match ever
752
+ * fires) and is caught by nothing (its "no-op" stays invisible forever). The
753
+ * exact silent hole `../../../../../AGENTS.md`'s invariant forbids, applied to
754
+ * this file's own bookkeeping rather than to the tool under test.
755
+ *
756
+ * @param {readonly LedgerRow[]} ledger
757
+ * @param {readonly string[]} knownLabels
758
+ * @returns {LedgerRow[]}
759
+ */
760
+ export function unknownLabelRows(ledger, knownLabels) {
761
+ return ledger.filter((row) => !knownLabels.includes(row.subject.split(":")[0]));
762
+ }
763
+
764
+ // ---------------------------------------------------------------------------
765
+ // Fixture-tree builders
766
+ // ---------------------------------------------------------------------------
767
+
768
+ // Exported (not just module-private) so `../../config-spelling.integration.test.mjs`
769
+ // — the config-spelling differential — can build its own dialect spellings
770
+ // (`.mjs`, `.json`, an ESLint flat config, an inline `archkeep.json` object)
771
+ // of this EXACT law and prove they agree, rather than writing a byte-for-byte
772
+ // second copy of the same table: this module's own header already commits to
773
+ // staying importable and spawn-free at import time for exactly that reuse,
774
+ // and a second copy here is the drift `../../../../../AGENTS.md`'s "never
775
+ // state a rule twice" rule exists to catch.
776
+ export const SIMPLE_BOUNDARY_CONFIG = `export const depConstraints = [
777
+ { sourceTag: "layer:domain", onlyDependOnLibsWithTags: ["layer:domain"] },
778
+ { sourceTag: "layer:adapter", onlyDependOnLibsWithTags: ["layer:domain", "layer:adapter"] },
779
+ ];
780
+ export const moduleBoundaryOptions = {
781
+ allow: [],
782
+ buildTargets: ["build"],
783
+ enforceBuildableLibDependency: false,
784
+ allowCircularSelfDependency: false,
785
+ checkDynamicDependenciesExceptions: [],
786
+ ignoredCircularDependencies: [],
787
+ banTransitiveDependencies: false,
788
+ checkNestedExternalImports: false,
789
+ };
790
+ `;
791
+
792
+ // One physical shape, written byte-identical into both trees below: two Go
793
+ // modules, tagged opposite the layer axis, with one import that crosses it
794
+ // the wrong way (`domain` reaching into `adapter`). Exported for the same
795
+ // reuse reason as `SIMPLE_BOUNDARY_CONFIG` above.
796
+ export const SIMPLE_GO_FILES = {
797
+ "libs/domain/go.mod": "module example.com/domain\n\ngo 1.24\n",
798
+ "libs/adapter/go.mod": "module example.com/adapter\n\ngo 1.24\n",
799
+ "libs/adapter/adapter.go": 'package adapter\n\nvar Name = "adapter"\n',
800
+ "libs/domain/doc.go": `// Package domain is the layer everything else points at.
801
+ package domain
802
+
803
+ import (
804
+ "example.com/adapter"
805
+ )
806
+
807
+ var _ = adapter.Name
808
+ `,
809
+ };
810
+
811
+ /**
812
+ * Writes the Nx half of the `simple` pair: `nx.json` registering `../nx.mjs`
813
+ * as a local plugin, `project.json` per project, the boundary config, and
814
+ * the two Go modules. Returns the list of files written, for the analysis
815
+ * pipeline's `files` argument.
816
+ *
817
+ * @param {string} root An existing, empty directory.
818
+ * @param {{boundaryConfig?: string}} [options]
819
+ * @returns {string[]}
820
+ */
821
+ export function buildSimpleNxTree(root, { boundaryConfig = "module-boundaries.config.mjs" } = {}) {
822
+ const write = writeIn(root);
823
+ write(
824
+ "nx.json",
825
+ JSON.stringify({
826
+ plugins: [{ plugin: "@ecoma-io/archkeep/nx", options: { boundaryConfig } }],
827
+ }),
828
+ );
829
+ write(boundaryConfig, SIMPLE_BOUNDARY_CONFIG);
830
+ write("libs/domain/project.json", JSON.stringify({ name: "domain", tags: ["layer:domain"] }));
831
+ write("libs/adapter/project.json", JSON.stringify({ name: "adapter", tags: ["layer:adapter"] }));
832
+ writeAll(write, SIMPLE_GO_FILES);
833
+ return [
834
+ "nx.json",
835
+ boundaryConfig,
836
+ "libs/domain/project.json",
837
+ "libs/domain/go.mod",
838
+ "libs/domain/doc.go",
839
+ "libs/adapter/project.json",
840
+ "libs/adapter/go.mod",
841
+ "libs/adapter/adapter.go",
842
+ ];
843
+ }
844
+
845
+ /**
846
+ * Writes the native half of the `simple` pair: `archkeep.json` declaring the
847
+ * identical two projects by name and tag, no `nx.json`, no `project.json`,
848
+ * no `nx` reachable from here at all.
849
+ *
850
+ * @param {string} root An existing, empty directory.
851
+ * @param {{boundaryConfig?: string}} [options]
852
+ * @returns {string[]}
853
+ */
854
+ export function buildSimpleNativeTree(
855
+ root,
856
+ { boundaryConfig = "module-boundaries.config.mjs" } = {},
857
+ ) {
858
+ const write = writeIn(root);
859
+ write(
860
+ "archkeep.json",
861
+ JSON.stringify({
862
+ projects: {
863
+ declared: [
864
+ { root: "libs/domain", name: "domain", tags: ["layer:domain"] },
865
+ { root: "libs/adapter", name: "adapter", tags: ["layer:adapter"] },
866
+ ],
867
+ },
868
+ coverage: {
869
+ exempt: [
870
+ {
871
+ path: boundaryConfig,
872
+ reason: "workspace tooling config at the root, not itself a project",
873
+ },
874
+ ],
875
+ },
876
+ }),
877
+ );
878
+ write(boundaryConfig, SIMPLE_BOUNDARY_CONFIG);
879
+ writeAll(write, SIMPLE_GO_FILES);
880
+ return [
881
+ "archkeep.json",
882
+ boundaryConfig,
883
+ "libs/domain/go.mod",
884
+ "libs/domain/doc.go",
885
+ "libs/adapter/go.mod",
886
+ "libs/adapter/adapter.go",
887
+ ];
888
+ }
889
+
890
+ // --- composite: axes 1-6 in one tree -----------------------------------
891
+ //
892
+ // | project | axis(es) it carries |
893
+ // | ------------------ | ------------------------------------------------------------ |
894
+ // | `declared-only` | 1 — name from a native `declared` row, no manifest at all; 5 — a LITERAL implicit-dependency entry spelled on the declared row itself, not via project.json |
895
+ // | `pkg-named-project`| 1 — name from `package.json`; 4 — a tag Nx synthesises (`npm:private`), matched on the native side by a `projectRules` row (see `./README.md`'s "Declared limits") |
896
+ // | `basenamed` | 1 — name falls all the way to `basename(root)` |
897
+ // | `workspace-root` | 3 — a project declared at `root: ""` |
898
+ // | `e2eish-e2e` | 2 — `-e2e` suffix + `projectType: "application"` → type `"e2e"`; 5 — a `tag:`-pattern implicit dependency |
899
+ // | `parent` | 4 — tags from ALL THREE sources at once (declared row, `projectRules`, `project.json`) on the SAME project, the union axis 4 actually needs exercised; 5 — a literal-name implicit dependency; 6 — the outer half of a nested pair |
900
+ // | `nested-child` | 6 — nested inside `parent`'s own directory; also the source of the one real (and only) crossing import this fixture's boundary rule flags |
901
+ //
902
+ // Nx's own built-in js/package-json plugin merges into ANY directory holding
903
+ // a `package.json` — measured empirically before writing this fixture:
904
+ // unless an accompanying `project.json` states `"projectType": "library"`
905
+ // explicitly, that directory is typed `"application"` and gets an automatic
906
+ // `"npm:public"`/`"npm:private"` tag, regardless of npm/pnpm workspace glob
907
+ // coverage. `pkg-named-project`'s `project.json` states `"projectType":
908
+ // "library"` for exactly this reason. Its native-side `projectRules` row
909
+ // states the matching `npm:private` tag explicitly rather than inferring it
910
+ // from `package.json` — a deliberate design refusal, not a gap: see
911
+ // `./README.md`'s "Declared limits", item 6.
912
+ const COMPOSITE_BOUNDARY_CONFIG = `export const depConstraints = [
913
+ { sourceTag: "layer:parent", onlyDependOnLibsWithTags: ["layer:parent", "layer:child"] },
914
+ { sourceTag: "layer:child", onlyDependOnLibsWithTags: ["layer:child"] },
915
+ ];
916
+ export const moduleBoundaryOptions = {
917
+ allow: [],
918
+ buildTargets: ["build"],
919
+ enforceBuildableLibDependency: false,
920
+ allowCircularSelfDependency: false,
921
+ checkDynamicDependenciesExceptions: [],
922
+ ignoredCircularDependencies: [],
923
+ banTransitiveDependencies: false,
924
+ checkNestedExternalImports: false,
925
+ };
926
+ `;
927
+
928
+ // `nested-child` imports `parent` — a real Go import, and the one edge this
929
+ // fixture's boundary rule flags: `layer:child` may only depend on
930
+ // `layer:child`, so `nested-child` reaching into `parent` (`layer:parent`)
931
+ // is the fixture's single, deliberate violation. Nothing else here carries a
932
+ // real import, so both engines are expected to report exactly one verdict.
933
+ const COMPOSITE_GO_FILES = {
934
+ "libs/parent/go.mod": "module example.com/parent\n\ngo 1.24\n",
935
+ "libs/parent/parent.go": 'package parent\n\nvar Name = "parent"\n',
936
+ "libs/parent/nested-child/go.mod": "module example.com/nestedchild\n\ngo 1.24\n",
937
+ "libs/parent/nested-child/doc.go": `// Package nestedchild sits inside parent's own directory — axis 6.
938
+ package nestedchild
939
+
940
+ import (
941
+ "example.com/parent"
942
+ )
943
+
944
+ var _ = parent.Name
945
+ `,
946
+ };
947
+
948
+ /**
949
+ * @param {string} root An existing, empty directory.
950
+ * @param {{boundaryConfig?: string}} [options]
951
+ * @returns {string[]}
952
+ */
953
+ export function buildCompositeNxTree(
954
+ root,
955
+ { boundaryConfig = "module-boundaries.config.mjs" } = {},
956
+ ) {
957
+ const write = writeIn(root);
958
+ write(
959
+ "nx.json",
960
+ JSON.stringify({
961
+ plugins: [{ plugin: "@ecoma-io/archkeep/nx", options: { boundaryConfig } }],
962
+ }),
963
+ );
964
+ write(boundaryConfig, COMPOSITE_BOUNDARY_CONFIG);
965
+
966
+ write(
967
+ "libs/declared-only/project.json",
968
+ JSON.stringify({ name: "declared-only", implicitDependencies: ["parent"] }),
969
+ );
970
+ write("libs/declared-only/README.md", "# declared-only\n");
971
+
972
+ write("libs/pkgnamed/project.json", JSON.stringify({ projectType: "library" }));
973
+ write("libs/pkgnamed/package.json", JSON.stringify({ name: "pkg-named-project", private: true }));
974
+
975
+ write("libs/basenamed/project.json", "{}");
976
+
977
+ write("project.json", JSON.stringify({ name: "workspace-root", tags: ["scope:root"] }));
978
+
979
+ write(
980
+ "libs/e2eish-e2e/project.json",
981
+ JSON.stringify({
982
+ name: "e2eish-e2e",
983
+ projectType: "application",
984
+ implicitDependencies: ["tag:layer:child"],
985
+ }),
986
+ );
987
+
988
+ // Axis 4's union, mirrored: native draws `parent`'s tags from THREE
989
+ // sources (a `projects.declared` row, a `projectRules` row, and this
990
+ // `project.json`); Nx has no such three-way split, so its `project.json`
991
+ // states the union outright — the same final tag SET, spelled the one way
992
+ // Nx understands.
993
+ write(
994
+ "libs/parent/project.json",
995
+ JSON.stringify({
996
+ name: "parent",
997
+ tags: ["layer:parent", "union:declared", "union:projectRules"],
998
+ implicitDependencies: ["nested-child"],
999
+ }),
1000
+ );
1001
+ write(
1002
+ "libs/parent/nested-child/project.json",
1003
+ JSON.stringify({ name: "nested-child", tags: ["layer:child"] }),
1004
+ );
1005
+
1006
+ writeAll(write, COMPOSITE_GO_FILES);
1007
+
1008
+ return [
1009
+ "nx.json",
1010
+ boundaryConfig,
1011
+ "libs/declared-only/project.json",
1012
+ "libs/declared-only/README.md",
1013
+ "libs/pkgnamed/project.json",
1014
+ "libs/pkgnamed/package.json",
1015
+ "libs/basenamed/project.json",
1016
+ "project.json",
1017
+ "libs/e2eish-e2e/project.json",
1018
+ "libs/parent/project.json",
1019
+ "libs/parent/go.mod",
1020
+ "libs/parent/parent.go",
1021
+ "libs/parent/nested-child/project.json",
1022
+ "libs/parent/nested-child/go.mod",
1023
+ "libs/parent/nested-child/doc.go",
1024
+ ];
1025
+ }
1026
+
1027
+ /**
1028
+ * @param {string} root An existing, empty directory.
1029
+ * @param {{boundaryConfig?: string}} [options]
1030
+ * @returns {string[]}
1031
+ */
1032
+ export function buildCompositeNativeTree(
1033
+ root,
1034
+ { boundaryConfig = "module-boundaries.config.mjs" } = {},
1035
+ ) {
1036
+ const write = writeIn(root);
1037
+ write(
1038
+ "archkeep.json",
1039
+ JSON.stringify({
1040
+ projects: {
1041
+ declared: [
1042
+ {
1043
+ root: "libs/declared-only",
1044
+ name: "declared-only",
1045
+ tags: [],
1046
+ // Axis 5, driven through the DECLARED-ROW spelling rather than
1047
+ // (as `parent`'s below is) through an inferred `project.json` —
1048
+ // `declared-only` has no manifest at all, so this is the only
1049
+ // route this fixture has for a literal implicit-dependency entry
1050
+ // that never touches `project.json`'s own `implicitDependencies`
1051
+ // key.
1052
+ implicitDependencies: ["parent"],
1053
+ },
1054
+ // `parent` is declared here TOO, even though it also has a
1055
+ // `project.json` picked up by `infer` below — discovery allows a
1056
+ // root to be both; this is what gives it a declared-row tag
1057
+ // ALONGSIDE its `project.json` tag and its `projectRules` tag, the
1058
+ // three-source union axis 4 exists to prove. No `name` on this row:
1059
+ // name precedence still needs to resolve through `project.json`
1060
+ // (axis 1's own `declared?.name ?? manifest?.name ?? …` ladder,
1061
+ // `./discover.mjs`), which this row leaves untouched by omitting
1062
+ // the key rather than repeating "parent" a second way.
1063
+ { root: "libs/parent", tags: ["union:declared"] },
1064
+ ],
1065
+ // Every other project here (`pkgnamed`, `basenamed`, `workspace-root`,
1066
+ // `e2eish-e2e`, `parent`, `nested-child`) has to be reached by
1067
+ // inference from its own `project.json`/`package.json`, the same way
1068
+ // an Nx tree finds them with no `archkeep.json` at all — an absent
1069
+ // `projects.infer` key means "the declared list is exhaustive, no
1070
+ // inference" (`./model.mjs`'s own comment on the key), and this
1071
+ // fixture needs the opposite of that. `{}` takes every default:
1072
+ // `DEFAULT_MANIFEST_NAMES`, `include: ["**"]`, `exclude: []`.
1073
+ infer: {},
1074
+ },
1075
+ projectRules: [
1076
+ { match: "libs/pkgnamed", tags: ["npm:private"] },
1077
+ { match: "libs/parent", tags: ["union:projectRules"] },
1078
+ ],
1079
+ // No `coverage.exempt` needed here, unlike `simple`: `infer` above
1080
+ // finds `project.json` at the tree root and turns it into the
1081
+ // `workspace-root` project (axis 3), so `boundaryConfig` at the root is
1082
+ // claimed by that project rather than left unclaimed.
1083
+ }),
1084
+ );
1085
+ write(boundaryConfig, COMPOSITE_BOUNDARY_CONFIG);
1086
+
1087
+ write("libs/declared-only/README.md", "# declared-only\n");
1088
+
1089
+ write("libs/pkgnamed/project.json", JSON.stringify({ projectType: "library" }));
1090
+ write("libs/pkgnamed/package.json", JSON.stringify({ name: "pkg-named-project", private: true }));
1091
+
1092
+ write("libs/basenamed/project.json", "{}");
1093
+
1094
+ write("project.json", JSON.stringify({ name: "workspace-root", tags: ["scope:root"] }));
1095
+
1096
+ write(
1097
+ "libs/e2eish-e2e/project.json",
1098
+ JSON.stringify({
1099
+ name: "e2eish-e2e",
1100
+ projectType: "application",
1101
+ implicitDependencies: ["tag:layer:child"],
1102
+ }),
1103
+ );
1104
+
1105
+ write(
1106
+ "libs/parent/project.json",
1107
+ JSON.stringify({
1108
+ name: "parent",
1109
+ tags: ["layer:parent"],
1110
+ implicitDependencies: ["nested-child"],
1111
+ }),
1112
+ );
1113
+ write(
1114
+ "libs/parent/nested-child/project.json",
1115
+ JSON.stringify({ name: "nested-child", tags: ["layer:child"] }),
1116
+ );
1117
+
1118
+ writeAll(write, COMPOSITE_GO_FILES);
1119
+
1120
+ return [
1121
+ "archkeep.json",
1122
+ boundaryConfig,
1123
+ "libs/declared-only/README.md",
1124
+ "libs/pkgnamed/project.json",
1125
+ "libs/pkgnamed/package.json",
1126
+ "libs/basenamed/project.json",
1127
+ "project.json",
1128
+ "libs/e2eish-e2e/project.json",
1129
+ "libs/parent/project.json",
1130
+ "libs/parent/go.mod",
1131
+ "libs/parent/parent.go",
1132
+ "libs/parent/nested-child/project.json",
1133
+ "libs/parent/nested-child/go.mod",
1134
+ "libs/parent/nested-child/doc.go",
1135
+ ];
1136
+ }
1137
+
1138
+ // --- layout: axis 7 alone -------------------------------------------------
1139
+ //
1140
+ // `workspaceLayout` is workspace-global (`../../rules/index.mjs`'s
1141
+ // `DEFAULT_WORKSPACE_LAYOUT` fallback applies to the whole graph, not one
1142
+ // project), so it earns its own tree rather than folding into `composite`.
1143
+ //
1144
+ // Two projects, `thing` (`layer:thing`) and `blocked` (`layer:blocked`), with
1145
+ // `thing`'s source carrying TWO import statements:
1146
+ //
1147
+ // 1. A real, resolvable Go import of `blocked` — this is a plain layer-tag
1148
+ // violation, unrelated to `workspaceLayout`, and BOTH engines must find it
1149
+ // identically. It is the pair's control: without it, a fixture where Nx
1150
+ // legitimately reports zero violations would itself be the empty-verdict
1151
+ // breach `emptyVerdictBreaches` exists to catch (`../../../../../AGENTS.md`'s
1152
+ // invariant — a run must never look clean because it stopped looking, and
1153
+ // that applies to this file's own fixtures as much as to the tool).
1154
+ // 2. The literal specifier `"packages/elsewhere"` — text
1155
+ // `../../rules/specifiers.mjs`'s `isAbsoluteImportIntoAnotherProject`
1156
+ // flags unconditionally when `workspaceLayout.libsDir` is `"packages"`,
1157
+ // regardless of whether `elsewhere` resolves to a real project.
1158
+ // `archkeep.json` states that `libsDir`; `nx.json` states the identical
1159
+ // `workspaceLayout` key.
1160
+ //
1161
+ // The two engines now AGREE on import 2, which is the point of this fixture
1162
+ // existing: `../nx.mjs`'s `readProjectGraph` merges `nx.json`'s own
1163
+ // `workspaceLayout` back onto the graph it returns (that function's own
1164
+ // header — issue #31), so the Nx side sees the identical non-default
1165
+ // `libsDir` the native side always read from `archkeep.json` directly, and
1166
+ // both flag `"packages/"` as a triggering prefix. Before that fix landed, the
1167
+ // two disagreed by exactly one violation here — the `LEDGER` row (now
1168
+ // retired) that used to explain it, and the reason a dedicated `it` for this
1169
+ // pair exists in `differential.integration.test.mjs` at all rather than a
1170
+ // generic loop entry: it is the one fixture pair built to prove
1171
+ // `workspaceLayout` specifically, and it now asserts plain agreement through
1172
+ // `assertPairAgrees` like every other pair.
1173
+ const LAYOUT_BOUNDARY_CONFIG = `export const depConstraints = [
1174
+ { sourceTag: "layer:thing", onlyDependOnLibsWithTags: ["layer:thing"] },
1175
+ ];
1176
+ export const moduleBoundaryOptions = {
1177
+ allow: [],
1178
+ buildTargets: ["build"],
1179
+ enforceBuildableLibDependency: false,
1180
+ allowCircularSelfDependency: false,
1181
+ checkDynamicDependenciesExceptions: [],
1182
+ ignoredCircularDependencies: [],
1183
+ banTransitiveDependencies: false,
1184
+ checkNestedExternalImports: false,
1185
+ };
1186
+ `;
1187
+
1188
+ const LAYOUT_GO_FILES = {
1189
+ "packages/thing/go.mod": "module example.com/thing\n\ngo 1.24\n",
1190
+ "packages/thing/thing.go": `package thing
1191
+
1192
+ import (
1193
+ "example.com/blocked"
1194
+ "packages/elsewhere"
1195
+ )
1196
+
1197
+ var _ = blocked.Name
1198
+ var _ = elsewhere.Name
1199
+ `,
1200
+ "packages/blocked/go.mod": "module example.com/blocked\n\ngo 1.24\n",
1201
+ "packages/blocked/blocked.go": 'package blocked\n\nvar Name = "blocked"\n',
1202
+ };
1203
+
1204
+ /**
1205
+ * @param {string} root An existing, empty directory.
1206
+ * @param {{boundaryConfig?: string}} [options]
1207
+ * @returns {string[]}
1208
+ */
1209
+ export function buildLayoutNxTree(root, { boundaryConfig = "module-boundaries.config.mjs" } = {}) {
1210
+ const write = writeIn(root);
1211
+ write(
1212
+ "nx.json",
1213
+ JSON.stringify({
1214
+ plugins: [{ plugin: "@ecoma-io/archkeep/nx", options: { boundaryConfig } }],
1215
+ workspaceLayout: { libsDir: "packages", appsDir: "apps" },
1216
+ }),
1217
+ );
1218
+ write(boundaryConfig, LAYOUT_BOUNDARY_CONFIG);
1219
+ write("packages/thing/project.json", JSON.stringify({ name: "thing", tags: ["layer:thing"] }));
1220
+ write(
1221
+ "packages/blocked/project.json",
1222
+ JSON.stringify({ name: "blocked", tags: ["layer:blocked"] }),
1223
+ );
1224
+ writeAll(write, LAYOUT_GO_FILES);
1225
+ return [
1226
+ "nx.json",
1227
+ boundaryConfig,
1228
+ "packages/thing/project.json",
1229
+ "packages/thing/go.mod",
1230
+ "packages/thing/thing.go",
1231
+ "packages/blocked/project.json",
1232
+ "packages/blocked/go.mod",
1233
+ "packages/blocked/blocked.go",
1234
+ ];
1235
+ }
1236
+
1237
+ /**
1238
+ * @param {string} root An existing, empty directory.
1239
+ * @param {{boundaryConfig?: string}} [options]
1240
+ * @returns {string[]}
1241
+ */
1242
+ export function buildLayoutNativeTree(
1243
+ root,
1244
+ { boundaryConfig = "module-boundaries.config.mjs" } = {},
1245
+ ) {
1246
+ const write = writeIn(root);
1247
+ write(
1248
+ "archkeep.json",
1249
+ JSON.stringify({
1250
+ projects: {
1251
+ declared: [
1252
+ { root: "packages/thing", name: "thing", tags: ["layer:thing"] },
1253
+ { root: "packages/blocked", name: "blocked", tags: ["layer:blocked"] },
1254
+ ],
1255
+ },
1256
+ workspaceLayout: { libsDir: "packages", appsDir: "apps" },
1257
+ coverage: {
1258
+ exempt: [
1259
+ {
1260
+ path: boundaryConfig,
1261
+ reason: "workspace tooling config at the root, not itself a project",
1262
+ },
1263
+ ],
1264
+ },
1265
+ }),
1266
+ );
1267
+ write(boundaryConfig, LAYOUT_BOUNDARY_CONFIG);
1268
+ writeAll(write, LAYOUT_GO_FILES);
1269
+ return [
1270
+ "archkeep.json",
1271
+ boundaryConfig,
1272
+ "packages/thing/go.mod",
1273
+ "packages/thing/thing.go",
1274
+ "packages/blocked/go.mod",
1275
+ "packages/blocked/blocked.go",
1276
+ ];
1277
+ }