@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,542 @@
1
+ /**
2
+ * Reads the workspace's module-boundary law out of an ESLint flat config, for
3
+ * the `boundaryConfig` dialect selected on basename — a name matching
4
+ * `eslint.config.*` — by `./config.mjs`'s own dispatch (that module's header
5
+ * carries the dispatch as a whole; this one only reads what it hands off to).
6
+ *
7
+ * A TypeScript workspace already keeps its module-boundary law in
8
+ * `@nx/enforce-module-boundaries`'s own rule entry, because that is what
9
+ * ESLint reads. This dialect lets such a workspace point `boundaryConfig`
10
+ * straight at that file instead of maintaining a second, `.mjs` copy of the
11
+ * same table — the two tables disagreeing the day one changed is exactly the
12
+ * drift a single source of truth is meant to prevent.
13
+ *
14
+ * `extractBoundaryRule` moved here from `scripts/differential-real-trees.mjs`
15
+ * (which now re-exports it): the differential and this reader parse the
16
+ * identical shape — a flat-config array's `@nx/enforce-module-boundaries`
17
+ * entry, bound the way ESLint itself binds it, last matching entry wins — and
18
+ * a second copy of that parser is exactly the drift `AGENTS.md`'s "never
19
+ * state a rule twice" rule exists to catch.
20
+ *
21
+ * Every option `@nx/enforce-module-boundaries` accepts but this ESLint entry
22
+ * does not state is defaulted from the workspace's own installed
23
+ * `@nx/eslint-plugin` — never from a value copied into this package, which
24
+ * would drift the day upstream changed a default. `depConstraints` has no
25
+ * such default read: an entry that does not state it at all is refused rather
26
+ * than read as an empty table (see `parseRuleValue`) — the eslint dialect
27
+ * makes the same "an unstated table is not an empty one" claim the `.mjs`
28
+ * dialect makes, just for a different reason: there, an unstated
29
+ * `depConstraints` key never happens because the exported object is this
30
+ * tool's own shape; here, a workspace's real ESLint entry can genuinely
31
+ * configure the rule on with only, say, `buildTargets` stated, and reading
32
+ * that as "no constraints" would report a clean tree over a table nobody
33
+ * wrote.
34
+ *
35
+ * Everything this file reports is loud: an ESLint config this reader cannot
36
+ * map to a constraint table throws, naming why, so a workspace mid-migration
37
+ * to this dialect gets one clear error rather than a boundary check that
38
+ * quietly ran with no constraints (`AGENTS.md`'s invariant). Four classes of
39
+ * "cannot map" beyond the ones already documented on `extractBoundaryRule`
40
+ * and `parseRuleValue`:
41
+ *
42
+ * - A flat-config array element that is not a plain object — a nested array
43
+ * (flat config permits composing configs by nesting arrays, flattened by
44
+ * ESLint's own loader before a rule ever runs), a function, or anything
45
+ * else this reader was not built to read — is refused BY INDEX rather than
46
+ * skipped. Skipping it silently would let an earlier, already-matched entry
47
+ * keep binding with no sign that a later entry — possibly the one that
48
+ * actually configures the rule — was never read at all: the confident-wrong
49
+ * answer this whole file exists to avoid.
50
+ * - An element carrying an `extends` key is refused the same way: this reader
51
+ * walks the array ESLint's loader has already flattened, not a config
52
+ * ESLint has resolved through an `extends` chain (see "What the extraction
53
+ * structurally cannot see" below).
54
+ * - A `files`-scoped entry is refused UNLESS every glob in its `files` is a
55
+ * bare source-extension pattern over the whole tree, with no directory
56
+ * component — see `BARE_EXTENSION_GLOB`. That is the shape `nx g
57
+ * @nx/eslint` itself emits, and it states which languages ESLint parses,
58
+ * never which part of the tree the law covers, so archkeep applies the
59
+ * table tree-wide and records the fact as a note rather than refusing a
60
+ * config this dialect is meant to read with no rewriting at all.
61
+ * - An entry carrying a non-empty `ignores` is refused outright, with no
62
+ * bare-extension exception: unlike `files`, an `ignores` list is a set of
63
+ * paths by construction — there is no shape of it that states languages
64
+ * rather than territory — so it is folded into the same refusal a
65
+ * directory-component `files` glob gets, never applied tree-wide with the
66
+ * exclusion silently dropped. Both scope refusals hold under
67
+ * `extractBoundaryRule`'s default; the one caller allowed to bind such an
68
+ * entry tree-wide anyway, and why that is not a silent drop there, is
69
+ * argued on `extractBoundaryRule` itself (`pathScoped: "bind-tree-wide"`).
70
+ */
71
+ import { createRequire } from "node:module";
72
+ import { pathToFileURL } from "node:url";
73
+
74
+ /** The severities ESLint itself recognises for a rule entry. */
75
+ const KNOWN_SEVERITIES = new Set(["off", "warn", "error", 0, 1, 2]);
76
+ const OFF_SEVERITIES = new Set(["off", 0]);
77
+
78
+ // The shape `files` takes in the canonical config `nx g @nx/eslint` emits —
79
+ // two asterisks, a slash, an asterisk, a dot, then a bare extension, nothing
80
+ // before it and nothing after: `**` + `/*.ts`, `**` + `/*.tsx`, `**` + `/*.js`,
81
+ // `**` + `/*.jsx`. Both pinned real trees `scripts/differential-real-trees.mjs`
82
+ // drives carry exactly that array. A glob with a directory component
83
+ // (`apps/**` + `/*.ts`) states WHICH PART of the tree the law covers and is
84
+ // refused (see `extractBoundaryRule`); a bare extension glob states only
85
+ // WHICH LANGUAGES ESLint parses, which is not a scoping decision this reader
86
+ // has to honour to stay correct — every file archkeep analyzes is already one
87
+ // of the four languages it knows, TS/JS included.
88
+ //
89
+ // Brace expansion (`**` + `/*.{ts,tsx}`) is deliberately NOT treated as the
90
+ // same shape: expanding brace syntax means reimplementing a slice of ESLint's
91
+ // own glob engine here, the same reason `./config.mjs`'s `suppressionCovers`
92
+ // reaches for `node:path`'s `matchesGlob` instead of hand-rolling one. A
93
+ // brace-form `files` entry is refused as scoped, same as a directory-scoped
94
+ // one — a stricter refusal than strictly necessary, not a silent guess.
95
+ const BARE_EXTENSION_GLOB = /^\*\*\/\*\.[A-Za-z0-9]+$/u;
96
+
97
+ /** @type {(value: unknown) => value is Record<string, unknown>} */
98
+ function isPlainObject(value) {
99
+ return typeof value === "object" && value !== null && !Array.isArray(value);
100
+ }
101
+
102
+ /** A rule entry's severity — the bare value itself, or the `[severity, …]` pair's first element. */
103
+ function severityOf(value) {
104
+ return Array.isArray(value) ? value[0] : value;
105
+ }
106
+
107
+ /**
108
+ * Whether a rule entry states an options element at all (`[severity, options]`,
109
+ * as opposed to a bare severity or a one-element `[severity]` array). Measured
110
+ * against eslint 10.8.0's own `FlatConfigArray`: a later flat-config entry
111
+ * that states only a severity does not clear the options an earlier entry
112
+ * stated for the same rule — ESLint keeps that options object and merges only
113
+ * the severity forward. `extractBoundaryRule` mirrors that merge instead of
114
+ * reading a severity-only winning entry as "no depConstraints key stated" for
115
+ * a config ESLint is actively enforcing under an earlier table.
116
+ */
117
+ function statesOptions(value) {
118
+ return Array.isArray(value) && value.length > 1;
119
+ }
120
+
121
+ /** A flat-config array element's shape, for a message naming what was found instead of a plain object. */
122
+ function describeElement(value) {
123
+ if (Array.isArray(value)) return "an array";
124
+ if (typeof value === "function") return "a function";
125
+ if (value === null) return "null";
126
+ return `a ${typeof value}`;
127
+ }
128
+
129
+ /**
130
+ * One flat-config entry's `@nx/enforce-module-boundaries` value, parsed into
131
+ * its options — or a thrown, named refusal when the severity is unrecognised,
132
+ * switched off, the options are not an object, or the entry states no
133
+ * `depConstraints` key at all.
134
+ *
135
+ * Called exactly once by `extractBoundaryRule`, on the entry that actually
136
+ * BINDS — never mapped over every matching entry. An earlier entry that
137
+ * happens to be off, or malformed, never configured anything ESLint would
138
+ * have run with, and must not be able to refuse a run over an entry that
139
+ * itself is perfectly valid and wins.
140
+ *
141
+ * @param {unknown} value The rule's configured value — a bare severity, or an
142
+ * `[severity, options]` pair.
143
+ * @param {number} index The entry's position in the flat-config array, so the
144
+ * message points at the offending entry rather than at the rule in general.
145
+ * @returns {Record<string, unknown>} The entry's options object, with
146
+ * `depConstraints` present (the caller strips it back out).
147
+ * @throws {Error} when the severity is not one ESLint recognises, the
148
+ * severity is `off`/`0`, the options are stated but not an object, or the
149
+ * options carry no `depConstraints` key — reading that last case as an
150
+ * empty table would report a clean tree over an entry that is on but never
151
+ * said what it enforces.
152
+ */
153
+ function parseRuleValue(value, index) {
154
+ const isArray = Array.isArray(value);
155
+ const severity = isArray ? value[0] : value;
156
+ if (!KNOWN_SEVERITIES.has(/** @type {any} */ (severity))) {
157
+ throw new Error(
158
+ `archkeep: flatConfig[${index}]'s @nx/enforce-module-boundaries severity is ` +
159
+ `${JSON.stringify(severity)} — expected one of off, warn, error, 0, 1, 2. Anything else ` +
160
+ "is refused rather than read as on, because there is no rule for what it means.",
161
+ );
162
+ }
163
+ if (OFF_SEVERITIES.has(/** @type {any} */ (severity))) {
164
+ throw new Error(
165
+ `archkeep: flatConfig[${index}] configures @nx/enforce-module-boundaries as ` +
166
+ `${JSON.stringify(value)} — switched off, there is no constraint table to compare against.`,
167
+ );
168
+ }
169
+ const rawOptions = isArray ? value[1] : undefined;
170
+ if (rawOptions !== undefined && !isPlainObject(rawOptions)) {
171
+ throw new Error(
172
+ `archkeep: flatConfig[${index}]'s @nx/enforce-module-boundaries options must be an object, ` +
173
+ `got ${JSON.stringify(rawOptions)}`,
174
+ );
175
+ }
176
+ const options = rawOptions ?? {};
177
+ if (!("depConstraints" in options)) {
178
+ throw new Error(
179
+ `archkeep: flatConfig[${index}] configures @nx/enforce-module-boundaries as ` +
180
+ `${JSON.stringify(value)} — on, but with no depConstraints key stated. Reading an unstated ` +
181
+ "table as an empty one would report a clean tree over an entry that never said what it " +
182
+ "enforces; state depConstraints explicitly — [] if the workspace really means no " +
183
+ "constraints — so an unstated table and a deliberately empty one are not the same shape on " +
184
+ "disk.",
185
+ );
186
+ }
187
+ return options;
188
+ }
189
+
190
+ /**
191
+ * Reads the `@nx/enforce-module-boundaries` entry off a flat-config array,
192
+ * exactly as ESLint would bind it: the LAST unscoped-or-accepted entry that
193
+ * configures the rule wins. Pure — the caller has already imported the config
194
+ * and handed the array in.
195
+ *
196
+ * @param {unknown} flatConfig The config module's default export, expected to
197
+ * be a flat-config array.
198
+ * @param {{pathScoped?: "refuse"|"bind-tree-wide"}} [readerOptions] What to do
199
+ * with an entry that scopes the rule to part of the tree — a
200
+ * directory-component `files` glob, or a non-empty `ignores`. The default,
201
+ * `"refuse"`, is the only correct answer for an ENFORCER: archkeep reads one
202
+ * global constraint table, and binding a scoped entry's table tree-wide
203
+ * would enforce a law over files the workspace deliberately excluded from
204
+ * it. `"bind-tree-wide"` exists for exactly one caller shape — a
205
+ * DIFFERENTIAL that feeds the extracted table identically to every engine
206
+ * it compares (`../../../scripts/differential-real-trees-child.mjs`): there
207
+ * the subject is rule-engine agreement on identical inputs, not fidelity to
208
+ * the tree's own lint scope, so applying one table to every file is
209
+ * symmetric across engines and cannot manufacture or hide a difference
210
+ * between them. The drop is still never silent: binding a path-scoped entry
211
+ * under this mode always contributes a `note` naming the entry and the
212
+ * scope it stated, so the run that used it says so.
213
+ * @returns {{depConstraints: object[], options: Record<string, unknown>,
214
+ * note?: string}} The constraint table, the entry's own stated options
215
+ * (`depConstraints` stripped out), and — only when there is something worth
216
+ * telling a reader about which entry bound — a note recording it. Four
217
+ * independent facts can each contribute a sentence: several unscoped (or
218
+ * accepted files-scoped) entries configuring the rule differently, the
219
+ * winning entry itself being files-scoped under the accepted shape, the
220
+ * winning entry stating only a severity so its options were read off an
221
+ * earlier entry instead (see `statesOptions`), and — under
222
+ * `pathScoped: "bind-tree-wide"` only — the winning entry being
223
+ * path-scoped with its scope dropped tree-wide. None of the four is a
224
+ * refusal: several overrides layered across a monorepo's `eslint.config.mjs`
225
+ * composing other configs is a normal, common shape, and ESLint's own
226
+ * binding rule already says unambiguously which one wins; a bare
227
+ * source-extension `files` entry states languages, not territory; a
228
+ * severity-only override is exactly how ESLint expects a later config to
229
+ * dial a rule up or down without restating its table; and the fourth fires
230
+ * only under an explicit opt-in whose argument sits above.
231
+ * @throws {Error} when `flatConfig` is not an array, an element is not a
232
+ * plain object or carries an `extends` key, no entry configures the rule,
233
+ * `readerOptions.pathScoped` is a value this reader does not define, an
234
+ * entry scopes the rule under a `files` glob with a directory component
235
+ * or carries a non-empty `ignores` (a per-glob law this reader cannot
236
+ * express — see below) while `pathScoped` is `"refuse"`, or the winning
237
+ * entry's severity/options are malformed (`parseRuleValue`) once any
238
+ * severity-only fallback has been applied.
239
+ */
240
+ export function extractBoundaryRule(flatConfig, readerOptions = {}) {
241
+ const pathScopedMode = readerOptions.pathScoped ?? "refuse";
242
+ if (pathScopedMode !== "refuse" && pathScopedMode !== "bind-tree-wide") {
243
+ throw new Error(
244
+ `archkeep: extractBoundaryRule was called with pathScoped: ` +
245
+ `${JSON.stringify(readerOptions.pathScoped)} — the only values this reader defines are ` +
246
+ `"refuse" and "bind-tree-wide". An unrecognised mode is refused rather than read as the ` +
247
+ "default, because the caller plainly meant something and this reader cannot know what.",
248
+ );
249
+ }
250
+ if (!Array.isArray(flatConfig)) {
251
+ throw new Error(
252
+ "archkeep: the ESLint config's default export is not a flat-config array, so the module " +
253
+ "boundary law cannot be read from it.",
254
+ );
255
+ }
256
+
257
+ /** @type {{index: number, files: unknown, ignores: unknown, value: unknown, scope: "unscoped"|"extension"|"path"}[]} */
258
+ const matches = [];
259
+ flatConfig.forEach((item, index) => {
260
+ if (!isPlainObject(item)) {
261
+ throw new Error(
262
+ `archkeep: flatConfig[${index}] is ${describeElement(item)}, not a plain config object — ` +
263
+ "this reader walks a flat-config array of plain objects only; a nested array, a " +
264
+ "function, or any other shape it cannot read is refused rather than skipped, because " +
265
+ "skipping it silently would let an earlier, already-matched entry keep binding with no " +
266
+ "sign that a later entry was never read.",
267
+ );
268
+ }
269
+ if ("extends" in item) {
270
+ throw new Error(
271
+ `archkeep: flatConfig[${index}] carries an 'extends' key — this reader walks the array ` +
272
+ "ESLint's own loader has already flattened, not a chain it resolves itself (see this " +
273
+ 'module\'s header, "What the extraction structurally cannot see"). Export the ' +
274
+ "already-composed array instead, or move the @nx/enforce-module-boundaries entry to an " +
275
+ "element with no 'extends'.",
276
+ );
277
+ }
278
+ const value = /** @type {any} */ (item.rules)?.["@nx/enforce-module-boundaries"];
279
+ if (value === undefined) return;
280
+ const files = item.files;
281
+ const ignores = item.ignores;
282
+ // An `ignores` array on the same entry as `files`/`rules` excludes part of
283
+ // the tree from what that entry configures — a scoping decision no less
284
+ // territorial than a directory-component `files` glob (the `pathScoped`
285
+ // check below), and unlike `files` there is no bare-extension shape that
286
+ // reads as "which languages", never "which files": an ignore list is a
287
+ // set of paths by construction. Reading past it — the bug this branch
288
+ // closes — bound the table tree-wide with no note at all, silently
289
+ // dropping the very exclusion the workspace wrote. Folding it into the
290
+ // same `"path"` scope reuses the existing named refusal rather than
291
+ // inventing a second one for the same class of problem.
292
+ const hasIgnores = Array.isArray(ignores) && ignores.length > 0;
293
+ const scope = hasIgnores
294
+ ? "path"
295
+ : files === undefined
296
+ ? "unscoped"
297
+ : Array.isArray(files) &&
298
+ files.every((glob) => typeof glob === "string" && BARE_EXTENSION_GLOB.test(glob))
299
+ ? "extension"
300
+ : "path";
301
+ matches.push({ index, files, ignores, value, scope });
302
+ });
303
+
304
+ if (matches.length === 0) {
305
+ throw new Error(
306
+ "archkeep: no @nx/enforce-module-boundaries entry in this ESLint config — there is no " +
307
+ "constraint table to read.",
308
+ );
309
+ }
310
+
311
+ // A `files`-scoped entry configures the rule for a glob, not for the whole
312
+ // tree. ESLint's own last-wins binding is unscoped: it does not know or care
313
+ // which entry's `files` a given source file matched, because it never has
314
+ // to — ESLint evaluates the rule per source file, scoped correctly, while
315
+ // this reader has to pick ONE table for the entire workspace up front. A
316
+ // scope with a directory component (`apps/**`, `libs/foo/**`) really does
317
+ // say "only here", and taking the last entry regardless would silently
318
+ // apply the wrong table to files outside its glob — refused by name. A
319
+ // scope whose every glob is a bare source-extension pattern (`scope ===
320
+ // "extension"`, see `BARE_EXTENSION_GLOB`) says no such thing and is left
321
+ // in the pool below.
322
+ const firstPathScoped = matches.find((match) => match.scope === "path");
323
+ if (firstPathScoped !== undefined && pathScopedMode === "refuse") {
324
+ const hasIgnores = Array.isArray(firstPathScoped.ignores) && firstPathScoped.ignores.length > 0;
325
+ throw new Error(
326
+ hasIgnores
327
+ ? `archkeep: flatConfig[${firstPathScoped.index}] configures @nx/enforce-module-boundaries under ` +
328
+ `ignores: ${JSON.stringify(firstPathScoped.ignores)} — archkeep reads one global constraint ` +
329
+ "table and has no way to express a law that excludes part of the tree. State the rule " +
330
+ "in an entry with no ignores instead, or move ignores to a separate entry that carries " +
331
+ "no @nx/enforce-module-boundaries key."
332
+ : `archkeep: flatConfig[${firstPathScoped.index}] configures @nx/enforce-module-boundaries under ` +
333
+ `files: ${JSON.stringify(firstPathScoped.files)} — archkeep reads one global constraint table ` +
334
+ "and has no way to express a law that differs per file glob. A files entry whose every " +
335
+ "glob is a bare source-extension pattern over the whole tree (no directory component) is " +
336
+ "accepted instead and applied tree-wide, because that shape states which languages ESLint " +
337
+ "parses rather than which part of the tree the law covers.",
338
+ );
339
+ }
340
+
341
+ // Only the entry that actually BINDS is parsed — an earlier entry that is
342
+ // off, malformed, or missing depConstraints never configured anything and
343
+ // must not be able to refuse a run over the entry ESLint itself would run
344
+ // with.
345
+ const last = matches[matches.length - 1];
346
+
347
+ // A winning entry that states only a severity (`"warn"`, not
348
+ // `["warn", {...}]`) does not, under ESLint's own merge, clear whatever
349
+ // options an earlier entry stated for this rule — see `statesOptions`.
350
+ // Reading it as "on, with no depConstraints key" would refuse a config
351
+ // ESLint is actively enforcing under the most recent earlier table; fall
352
+ // back to that table instead, keeping the winning entry's own severity.
353
+ let priorWithOptions;
354
+ let effectiveValue = last.value;
355
+ if (!statesOptions(effectiveValue)) {
356
+ // `Array.prototype.findLast` needs an ES2023 lib target this package
357
+ // does not carry (`tsconfig.json` targets es2022) — a plain backward
358
+ // loop instead of widening the lib for one call site.
359
+ for (let i = matches.length - 2; i >= 0; i--) {
360
+ if (statesOptions(matches[i].value)) {
361
+ priorWithOptions = matches[i];
362
+ break;
363
+ }
364
+ }
365
+ if (priorWithOptions !== undefined) {
366
+ effectiveValue = [severityOf(last.value), priorWithOptions.value[1]];
367
+ }
368
+ }
369
+
370
+ const { depConstraints: rawDepConstraints, ...options } = parseRuleValue(
371
+ effectiveValue,
372
+ last.index,
373
+ );
374
+ const depConstraints = /** @type {object[]} */ (rawDepConstraints);
375
+
376
+ const noteParts = [];
377
+ if (priorWithOptions !== undefined) {
378
+ noteParts.push(
379
+ `flatConfig[${last.index}] states only a severity (${JSON.stringify(last.value)}) for ` +
380
+ "@nx/enforce-module-boundaries — ESLint's own merge keeps the options an earlier entry " +
381
+ `stated for the rule rather than clearing them, so archkeep read the constraint table off ` +
382
+ `flatConfig[${priorWithOptions.index}], the most recent entry that stated one.`,
383
+ );
384
+ }
385
+ if (matches.length > 1) {
386
+ const differs = matches.some(
387
+ (entry) => JSON.stringify(entry.value) !== JSON.stringify(last.value),
388
+ );
389
+ if (differs) {
390
+ const indices = matches.map((entry) => entry.index).join(", ");
391
+ noteParts.push(
392
+ `flatConfig[${last.index}] is the entry archkeep bound for @nx/enforce-module-boundaries ` +
393
+ `— the last of ${matches.length} entries setting the rule (at index ${indices}), per ` +
394
+ "ESLint's own last-wins binding order.",
395
+ );
396
+ }
397
+ }
398
+ if (last.scope === "extension") {
399
+ noteParts.push(
400
+ `flatConfig[${last.index}] scopes @nx/enforce-module-boundaries under files: ` +
401
+ `${JSON.stringify(last.files)} — every glob there is a bare source-extension pattern with ` +
402
+ "no directory component, so archkeep applied the table tree-wide rather than refusing it " +
403
+ "as a per-directory law.",
404
+ );
405
+ }
406
+ if (last.scope === "path") {
407
+ // Reachable only under `pathScoped: "bind-tree-wide"` — the refuse mode
408
+ // threw above on the first path-scoped match. The note is not optional
409
+ // here: the whole argument for the opt-in (see the `readerOptions` doc)
410
+ // rests on the dropped scope being stated by the run that dropped it.
411
+ const scopeFacts = [
412
+ ...(last.files !== undefined ? [`files: ${JSON.stringify(last.files)}`] : []),
413
+ ...(Array.isArray(last.ignores) && last.ignores.length > 0
414
+ ? [`ignores: ${JSON.stringify(last.ignores)}`]
415
+ : []),
416
+ ].join(" and ");
417
+ noteParts.push(
418
+ `flatConfig[${last.index}] scopes @nx/enforce-module-boundaries under ${scopeFacts} — bound ` +
419
+ `tree-wide at the caller's explicit request (pathScoped: "bind-tree-wide"): the caller ` +
420
+ "applies this one table identically to every engine it compares, so the dropped scope is " +
421
+ "symmetric across them, and this note is the record of the drop.",
422
+ );
423
+ }
424
+
425
+ return {
426
+ depConstraints,
427
+ options,
428
+ ...(noteParts.length > 0 ? { note: noteParts.join(" ") } : {}),
429
+ };
430
+ }
431
+
432
+ /**
433
+ * The eight `@nx/enforce-module-boundaries` option defaults, read from the
434
+ * workspace's own installed `@nx/eslint-plugin` rather than duplicated here —
435
+ * a copy here would drift the day upstream changed a default. Resolved from
436
+ * `configPath` (the flat config's own location), never from this package, so
437
+ * a workspace whose plugin is hoisted differently than this package's own
438
+ * dependency tree still resolves the copy it actually lints with
439
+ * (`../AGENTS.md`: "never assume any workspace's project names, areas, or tag
440
+ * values" — the same posture applies to which copy of a peer plugin answers).
441
+ *
442
+ * Resolution and loading are two separate steps with two separate refusals:
443
+ * `require.resolve` alone answers "is the package there at all", with no side
444
+ * effects, before anything of the plugin's own code runs; a package that
445
+ * resolves but throws while its own entry point executes — an incompatible
446
+ * dependency of the plugin's, say — is a different problem than one that is
447
+ * simply not installed, and the two must not read as the same message. The
448
+ * plugin's own `package.json` "exports" map exposes no subpath narrower than
449
+ * the whole package (measured: requiring
450
+ * `@nx/eslint-plugin/dist/src/rules/enforce-module-boundaries` directly
451
+ * throws `ERR_PACKAGE_PATH_NOT_EXPORTED`), so loading the whole resolved
452
+ * entry point is the least this reader can load and still reach the one
453
+ * rule's defaults.
454
+ *
455
+ * @param {string} configPath Absolute path of the workspace's ESLint config.
456
+ * @returns {Record<string, unknown>} The rule's own `defaultOptions[0]`, with
457
+ * `depConstraints` stripped — that default is always `[]` and is never what
458
+ * this reader wants read from it: the workspace's own stated table always
459
+ * wins, and an unstated one means empty, not upstream's fallback.
460
+ * @throws {Error} naming the workspace's config path, when the plugin does
461
+ * not resolve from it, resolves but throws while loading, or loads but
462
+ * exposes no default options to ground the unstated ones against — any of
463
+ * the three leaves this reader nothing to fill the un-stated options with,
464
+ * and filling them with a guess would be exactly the silent default
465
+ * `../options.mjs` refuses for its own two keys.
466
+ */
467
+ function resolveEslintPluginDefaults(configPath) {
468
+ const require = createRequire(configPath);
469
+ let resolvedPath;
470
+ try {
471
+ resolvedPath = require.resolve("@nx/eslint-plugin");
472
+ } catch (cause) {
473
+ throw new Error(
474
+ `archkeep: @nx/eslint-plugin does not resolve from ${configPath} — the ESLint ` +
475
+ "boundaryConfig dialect reads its unstated option defaults off the workspace's own " +
476
+ "installed plugin, and a workspace naming this dialect without that plugin installed has " +
477
+ "no defaults to read.",
478
+ { cause },
479
+ );
480
+ }
481
+ let plugin;
482
+ try {
483
+ // The literal specifier again, not `resolvedPath` — `require` and
484
+ // `require.resolve` are the same bound function, so resolving then
485
+ // requiring the same specifier reaches the identical file `resolvedPath`
486
+ // already names (Node resolves and caches by the same algorithm both
487
+ // times), and a literal keeps this load visible to the conformance walk
488
+ // (`src/conformance/boundary.test.mjs`), which cannot follow a `require`
489
+ // call built from a variable — that is opacity the walk is built to
490
+ // refuse, not a shape it is meant to approximate.
491
+ plugin = require("@nx/eslint-plugin");
492
+ } catch (cause) {
493
+ throw new Error(
494
+ `archkeep: @nx/eslint-plugin resolved from ${resolvedPath} but could not be loaded: ` +
495
+ `${cause?.message ?? cause} — a plugin that resolves but fails while its own entry point ` +
496
+ "runs is a different problem than one that is simply not installed, and must not read as " +
497
+ "the same refusal.",
498
+ { cause },
499
+ );
500
+ }
501
+ const rule = /** @type {any} */ (plugin)?.rules?.["enforce-module-boundaries"];
502
+ const defaults = rule?.defaultOptions?.[0];
503
+ if (!isPlainObject(defaults)) {
504
+ throw new Error(
505
+ `archkeep: @nx/eslint-plugin resolved from ${resolvedPath} but its enforce-module-boundaries ` +
506
+ "rule exposes no default options to ground the unstated ones against.",
507
+ );
508
+ }
509
+ const { depConstraints: _pluginDepConstraintsDefault, ...optionDefaults } = defaults;
510
+ return optionDefaults;
511
+ }
512
+
513
+ /**
514
+ * Loads and reads the boundary law out of an ESLint flat config at `path`.
515
+ *
516
+ * Handles exactly one default-export shape: a plain array, the flat-config
517
+ * form ESLint itself treats as already-resolved. Every other shape ESLint
518
+ * also accepts — a Promise (the async-config form), a function (a config
519
+ * factory), a single bare object (auto-wrapped by ESLint's own loader) — is
520
+ * refused by name rather than coerced: coercing a bare object into a
521
+ * one-element array, or awaiting a Promise, would mean reimplementing
522
+ * ESLint's own config-resolution pipeline here, and answering for a table
523
+ * ESLint's real pipeline might resolve differently.
524
+ *
525
+ * @param {string} path Absolute path of the ESLint config file.
526
+ * @returns {Promise<{depConstraints: object[], options: Record<string, unknown>, note?: string}>}
527
+ * @throws {Error} when the file cannot be imported, its default export is not
528
+ * a flat-config array, `extractBoundaryRule` refuses the rule entry it
529
+ * contains, or `@nx/eslint-plugin` cannot supply the unstated defaults.
530
+ */
531
+ export async function loadEslintBoundaryConfig(path) {
532
+ let loaded;
533
+ try {
534
+ loaded = await import(pathToFileURL(path).href);
535
+ } catch (cause) {
536
+ throw new Error(`archkeep: cannot load ${path}: ${cause?.message ?? cause}`, { cause });
537
+ }
538
+
539
+ const { depConstraints, options, note } = extractBoundaryRule(loaded.default);
540
+ const optionDefaults = resolveEslintPluginDefaults(path);
541
+ return { depConstraints, options: { ...optionDefaults, ...options }, note };
542
+ }