@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,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
|
+
}
|