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