@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,962 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The rule engine: import sites in, violations out, one pure function.
|
|
3
|
+
*
|
|
4
|
+
* This reproduces `@nx/enforce-module-boundaries` over `ImportSite` records
|
|
5
|
+
* instead of an ESLint AST, so the same fifteen checks reach `.go`, `.rs` and
|
|
6
|
+
* `.py` — the languages ESLint cannot read at all, and where a layer-violating
|
|
7
|
+
* import passes `lint` today because the project's lint target runs `eslint
|
|
8
|
+
* project.json` and eslint answers "File ignored because no matching
|
|
9
|
+
* configuration was supplied" for a `.go` file.
|
|
10
|
+
*
|
|
11
|
+
* `.vue` is NOT one of them, though this header used to say so:
|
|
12
|
+
* `eslint.config.mjs` gives it `vue-eslint-parser` and the boundary rule block
|
|
13
|
+
* there carries no `files` filter, so upstream judges a single-file component
|
|
14
|
+
* and the two engines agree on it (`../conformance/README.md`).
|
|
15
|
+
*
|
|
16
|
+
* ## The order is the semantics
|
|
17
|
+
*
|
|
18
|
+
* Upstream's `run()` is a chain of `report(); return;` — most sites produce AT
|
|
19
|
+
* MOST ONE violation, and which one depends on the order the checks are written
|
|
20
|
+
* in. An engine that collected every applicable violation would report a
|
|
21
|
+
* different set for the same file. The order below is upstream's, and the two
|
|
22
|
+
* places that can emit more than one violation are marked where they occur:
|
|
23
|
+
* the npm branch (a transitive-dependency report does not stop the banned-import
|
|
24
|
+
* check) and the nested-banned check (one report per offending package).
|
|
25
|
+
*
|
|
26
|
+
* The chain has a second reader: the suppression table. A suppression must
|
|
27
|
+
* behave like a fix
|
|
28
|
+
* (`../../../../docs/reference/violations.md`, "The order matters") —
|
|
29
|
+
* suppressing the verdict a site reports has to reveal the next check down the
|
|
30
|
+
* order at the same line, exactly as editing the specifier would. So
|
|
31
|
+
* `candidateGroupsFor` yields, in order, every check's verdict the site could
|
|
32
|
+
* produce if each earlier one were fixed, and the evaluation picks the first
|
|
33
|
+
* group no suppression removes. With nothing suppressed, the first group is
|
|
34
|
+
* emitted untouched: one violation per site, byte-for-byte the verdict
|
|
35
|
+
* upstream's order defines. A group is yielded only where its own preconditions
|
|
36
|
+
* hold on the site — a tags verdict needs a resolved target project, the
|
|
37
|
+
* project-to-project block needs a project node — so a suppression can never
|
|
38
|
+
* surface a verdict the site could not actually produce.
|
|
39
|
+
*
|
|
40
|
+
* ## Where this engine is stricter than upstream, and why never the other way
|
|
41
|
+
*
|
|
42
|
+
* The dangerous failure for a boundary checker is a false NEGATIVE: reporting
|
|
43
|
+
* clean while a violation exists. That is strictly worse than today's state,
|
|
44
|
+
* where ESLint is right about JS/TS and silent elsewhere — silence you know
|
|
45
|
+
* about beats a green light you cannot trust. So every judgement call in this
|
|
46
|
+
* directory resolves toward reporting, and each one says so at its site. The
|
|
47
|
+
* ones that change a verdict are collected in `README.md` beside this file.
|
|
48
|
+
*
|
|
49
|
+
* ## What it may read
|
|
50
|
+
*
|
|
51
|
+
* Records and the loaded config. No filesystem, no git, no Nx — which is what
|
|
52
|
+
* lets both the CLI and the language server share one verdict, and what lets a
|
|
53
|
+
* test drive all fifteen rules from fixtures with no workspace at all.
|
|
54
|
+
*
|
|
55
|
+
* See ../analysis/contract.md for the `ImportSite` shape this consumes.
|
|
56
|
+
*/
|
|
57
|
+
import { findBoundaryConfigViolations, suppressionCovers } from "../config.mjs";
|
|
58
|
+
import { referenceTime } from "../governance/clock.mjs";
|
|
59
|
+
import { EXPIRED_WAIVER_EVIDENCE, suppressionFate } from "../governance/waiver.mjs";
|
|
60
|
+
|
|
61
|
+
import { matchImportWithWildcard, findMatchingProjects } from "./match.mjs";
|
|
62
|
+
import { renderMessage } from "./messages.mjs";
|
|
63
|
+
import {
|
|
64
|
+
buildReachability,
|
|
65
|
+
circularPathHasPair,
|
|
66
|
+
expandIgnoredCircularDependencies,
|
|
67
|
+
} from "./reachability.mjs";
|
|
68
|
+
import {
|
|
69
|
+
createProjectRootMappings,
|
|
70
|
+
DEFAULT_WORKSPACE_LAYOUT,
|
|
71
|
+
findProjectForPath,
|
|
72
|
+
findTransitiveExternalDependencies,
|
|
73
|
+
getPackageNameFromImportPath,
|
|
74
|
+
getTargetProjectBasedOnRelativeImport,
|
|
75
|
+
hasBannedDependencies,
|
|
76
|
+
hasBannedImport,
|
|
77
|
+
isAbsoluteImportIntoAnotherProject,
|
|
78
|
+
isBuiltinModuleImport,
|
|
79
|
+
} from "./specifiers.mjs";
|
|
80
|
+
import {
|
|
81
|
+
appIsMFERemote,
|
|
82
|
+
belongsToDifferentEntryPoint,
|
|
83
|
+
circularViolation,
|
|
84
|
+
createFileDependencyIndex,
|
|
85
|
+
hasBuildExecutor,
|
|
86
|
+
isDirectDependency,
|
|
87
|
+
lazyLoadedViolation,
|
|
88
|
+
} from "./topology.mjs";
|
|
89
|
+
import {
|
|
90
|
+
constraintSourceTagLabel,
|
|
91
|
+
emptyOnlyTagsViolation,
|
|
92
|
+
findConstraintsFor,
|
|
93
|
+
notTagsViolation,
|
|
94
|
+
onlyTagsViolation,
|
|
95
|
+
} from "./tags.mjs";
|
|
96
|
+
|
|
97
|
+
export { MESSAGE_IDS, MESSAGES, renderMessage } from "./messages.mjs";
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* One boundary violation, carrying what a terminal line and an LSP diagnostic
|
|
101
|
+
* both need — plus the constraint row that fired, so a report can explain the
|
|
102
|
+
* verdict instead of only stating it.
|
|
103
|
+
*
|
|
104
|
+
* `messageId` is upstream's id spelled exactly. That is what makes a
|
|
105
|
+
* differential comparison against ESLint possible at all: two tools that both
|
|
106
|
+
* say "error" agree on nothing until they name the same rule.
|
|
107
|
+
*
|
|
108
|
+
* @typedef {object} Violation
|
|
109
|
+
* @property {string} sourceFile Workspace-relative path of the importing file.
|
|
110
|
+
* @property {number} line 1-based.
|
|
111
|
+
* @property {number} column 1-based.
|
|
112
|
+
* @property {string} specifier The import as written.
|
|
113
|
+
* @property {string} kind The import kind from the analysis record.
|
|
114
|
+
* @property {string} messageId One of `MESSAGE_IDS`.
|
|
115
|
+
* @property {string} message The rendered message, as ESLint would print it.
|
|
116
|
+
* @property {string|null} sourceProject
|
|
117
|
+
* @property {string|null} targetProject Project name, `npm:<package>` for an
|
|
118
|
+
* external target, or `null` when the import resolved to neither.
|
|
119
|
+
* @property {object|null} constraint The `depConstraints` row that fired.
|
|
120
|
+
* @property {object} data The message's interpolation values, so a formatter
|
|
121
|
+
* can re-render without re-deriving them.
|
|
122
|
+
* @property {object} [waivedBy] The `boundarySuppressions` row that WAIVED
|
|
123
|
+
* this violation — present only on a violation an ACTIVE waiver accepted
|
|
124
|
+
* (`../governance/waiver.mjs`). A waived violation is still a violation:
|
|
125
|
+
* the run stays non-zero, and the row records why it is present.
|
|
126
|
+
* @property {string} [evidence] Why a violation is present in the report when
|
|
127
|
+
* the table would have removed it — `"expired waiver"` for a violation
|
|
128
|
+
* whose waiver lapsed and re-asserted.
|
|
129
|
+
*/
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* A project graph, in Nx's own shape so an adapter is a rename at most.
|
|
133
|
+
*
|
|
134
|
+
* Four fields are OPTIONAL and exist because upstream reads them off disk while
|
|
135
|
+
* a rule here may not. Each is documented at the check that uses it, and each
|
|
136
|
+
* fails closed when absent:
|
|
137
|
+
*
|
|
138
|
+
* `nodes[n].data.mfeRemote` — `noImportsOfApps` exemption
|
|
139
|
+
* `nodes[n].data.entryPoints` — secondary entry points
|
|
140
|
+
* `nodes[n].data.declaredPackages` — `noTransitiveDependencies`
|
|
141
|
+
* `workspaceLayout` — `{libsDir, appsDir}`, Nx's default when absent
|
|
142
|
+
* `exemptedFiles` — coverage-exempt files; absent means none
|
|
143
|
+
*
|
|
144
|
+
* @typedef {object} ProjectGraph
|
|
145
|
+
* @property {Record<string, object>} nodes Project nodes, `type` one of
|
|
146
|
+
* `app` | `lib` | `e2e`, `data.root` workspace-relative.
|
|
147
|
+
* @property {Record<string, object>} [externalNodes] npm nodes, keyed as Nx
|
|
148
|
+
* keys them (`npm:react`), each with `data.packageName`.
|
|
149
|
+
* @property {Record<string, {source: string, target: string, type?: string}[]>} [dependencies]
|
|
150
|
+
* @property {{libsDir: string, appsDir: string}} [workspaceLayout]
|
|
151
|
+
* @property {string[]} [exemptedFiles] Workspace-relative paths of the tracked,
|
|
152
|
+
* analyzable files a native workspace's `coverage.exempt` removed from
|
|
153
|
+
* coverage (`../providers/native/coverage.mjs`). CONCRETE paths, never the
|
|
154
|
+
* globs the rows are written with — the expansion happened once at
|
|
155
|
+
* discovery, over unowned files only, where a stale row is refused loudly.
|
|
156
|
+
*/
|
|
157
|
+
|
|
158
|
+
/** Nx's own node kinds. Anything else is an external node. */
|
|
159
|
+
const isProjectGraphProjectNode = (node) =>
|
|
160
|
+
node.type === "app" || node.type === "e2e" || node.type === "lib";
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* How the analyzer says this specifier is spelled, or a loud failure.
|
|
164
|
+
*
|
|
165
|
+
* This layer used to answer for itself, with one predicate shaped like
|
|
166
|
+
* JavaScript's `./x`. That is wrong in every language whose imports are names
|
|
167
|
+
* rather than paths, and it was measurably wrong: `use super::product_name` and
|
|
168
|
+
* a Rust binary calling its own package's library crate were both reported as
|
|
169
|
+
* `noSelfCircularDependencies` on an untouched tree. The fact is per-language,
|
|
170
|
+
* the analyzer knows the language and this layer does not, so the record
|
|
171
|
+
* carries it (`../analysis/contract.md`).
|
|
172
|
+
*
|
|
173
|
+
* A record that omits it **throws** rather than defaulting to the JavaScript
|
|
174
|
+
* shape. A default would let the next analyzer inherit this bug in silence,
|
|
175
|
+
* which is precisely how this one survived four languages.
|
|
176
|
+
*/
|
|
177
|
+
function spellingOf(site) {
|
|
178
|
+
const spelling = site.spelling;
|
|
179
|
+
if (typeof spelling?.path !== "boolean" || typeof spelling?.relative !== "boolean") {
|
|
180
|
+
throw new Error(
|
|
181
|
+
`archkeep: ${site.sourceFile}:${site.line}:${site.column} imports ` +
|
|
182
|
+
`'${site.specifier}' in a record carrying no \`spelling\` — the analysis contract ` +
|
|
183
|
+
`requires \`{ path, relative }\` on every import site, because whether a specifier ` +
|
|
184
|
+
`is a path and whether it stays inside its own project are per-language questions ` +
|
|
185
|
+
`only the analyzer can answer. See src/analysis/contract.md.`,
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
return spelling;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* A specifier that is a PATH rather than a package name: `.`, `..`, `./x`,
|
|
193
|
+
* `../x`, or an absolute `/x` in the JavaScript family, and nothing at all in
|
|
194
|
+
* Go, Rust or Python, whose specifiers are names.
|
|
195
|
+
*
|
|
196
|
+
* Named once because two places must ask the identical question — the external
|
|
197
|
+
* node lookup refuses exactly what the `!targetProject` branch reports as
|
|
198
|
+
* `noRelativeOrAbsoluteExternals`. Two spellings of one test could drift apart,
|
|
199
|
+
* and the gap between them would be a site that is given no target and then
|
|
200
|
+
* reported by nothing. Making the answer language-aware moved where
|
|
201
|
+
* it is computed, not how many places compute it.
|
|
202
|
+
*/
|
|
203
|
+
const isPathSpecifier = (site) => spellingOf(site).path;
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Everything the per-site evaluation needs, computed once for the whole run.
|
|
207
|
+
* Building it per site would recompute the reachability matrix for every
|
|
208
|
+
* import in the workspace.
|
|
209
|
+
*/
|
|
210
|
+
function createContext(importSites, graph, config) {
|
|
211
|
+
if (!graph || typeof graph !== "object" || typeof graph.nodes !== "object" || !graph.nodes) {
|
|
212
|
+
throw new Error("archkeep: evaluate() needs a project graph with a `nodes` map");
|
|
213
|
+
}
|
|
214
|
+
const violations = findBoundaryConfigViolations({
|
|
215
|
+
depConstraints: config?.depConstraints,
|
|
216
|
+
moduleBoundaryOptions: config?.options,
|
|
217
|
+
boundarySuppressions: config?.suppressions,
|
|
218
|
+
});
|
|
219
|
+
if (violations.length > 0) {
|
|
220
|
+
throw new Error(
|
|
221
|
+
`archkeep: the boundary config given to evaluate() is malformed:\n ` +
|
|
222
|
+
violations.join("\n "),
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Checked for the whole run before any verdict, not lazily per branch: a
|
|
227
|
+
// record whose analyzer never learned the question would otherwise be judged
|
|
228
|
+
// silently everywhere the check does not happen to fall.
|
|
229
|
+
for (const site of importSites) spellingOf(site);
|
|
230
|
+
|
|
231
|
+
// A `buildTargets` entry that matches NO project's declared targets is a
|
|
232
|
+
// silent no-op — `hasBuildExecutor` compares exactly, so the entry selects
|
|
233
|
+
// no project, and when `enforceBuildableLibDependency` is on the check
|
|
234
|
+
// reads as live while no project can possibly satisfy it. That is the
|
|
235
|
+
// exact silent direction this repository's invariant forbids, and it is
|
|
236
|
+
// only answerable here, where the graph is in the room: `config.mjs` has
|
|
237
|
+
// already refused entries carrying glob syntax at load (`targetPatternError`),
|
|
238
|
+
// so anything reaching this check is a plain NAME that happens not to be
|
|
239
|
+
// declared by any project — a typo, or a target the workspace renamed
|
|
240
|
+
// without updating the option. Both are reported by name, loudly, rather
|
|
241
|
+
// than judged into a rule that can never fire. When the flag is OFF the
|
|
242
|
+
// entries are never read (`docs/reference/policy-schema.md`, "moduleBoundaryOptions"),
|
|
243
|
+
// so there is nothing to check and no claim to make.
|
|
244
|
+
const optionsValue = /** @type {object} */ (config?.options);
|
|
245
|
+
if (optionsValue.enforceBuildableLibDependency === true && Object.keys(graph.nodes).length > 0) {
|
|
246
|
+
// `graph.nodes` being empty is a genuinely empty tree — no project exists
|
|
247
|
+
// for an entry to select, so there is no claim to make and no silent no-op
|
|
248
|
+
// (an option that judges nothing on no projects is not a trap; one that
|
|
249
|
+
// judges nothing while projects exist is). The check is only answerable
|
|
250
|
+
// against a non-empty graph, and an empty one skips it rather than
|
|
251
|
+
// refusing every command on a workspace that has nothing to judge.
|
|
252
|
+
const declaredTargets = new Set(
|
|
253
|
+
Object.values(graph.nodes).flatMap((node) => Object.keys(node.data?.targets ?? {}) ?? []),
|
|
254
|
+
);
|
|
255
|
+
for (const entry of /** @type {string[]} */ (optionsValue.buildTargets ?? [])) {
|
|
256
|
+
if (!declaredTargets.has(entry)) {
|
|
257
|
+
throw new Error(
|
|
258
|
+
`archkeep: buildTargets entry '${entry}' matches no target declared by any project ` +
|
|
259
|
+
`in the graph — under enforceBuildableLibDependency this entry is a silent no-op. ` +
|
|
260
|
+
`Declare a target named '${entry}' on some project, or remove the entry. ` +
|
|
261
|
+
`Declared targets: ${[...declaredTargets].join(", ") || "(none)"}`,
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const mappings = createProjectRootMappings(graph.nodes);
|
|
268
|
+
const reach = buildReachability(graph);
|
|
269
|
+
// The file index describes what was analyzed, not what exists — see
|
|
270
|
+
// `createFileDependencyIndex`. Built from the same records the rules judge so
|
|
271
|
+
// there is one view of the tree, not two that can disagree.
|
|
272
|
+
const fileIndex = createFileDependencyIndex(
|
|
273
|
+
importSites.map((site) => ({
|
|
274
|
+
sourceFile: site.sourceFile,
|
|
275
|
+
sourceProject: findProjectForPath(site.sourceFile, mappings),
|
|
276
|
+
targetProject: site.resolved?.target ?? null,
|
|
277
|
+
dynamic: site.kind === "dynamic",
|
|
278
|
+
})),
|
|
279
|
+
);
|
|
280
|
+
const externalByPackage = new Map();
|
|
281
|
+
for (const node of Object.values(graph.externalNodes ?? {})) {
|
|
282
|
+
if (node.data?.packageName) externalByPackage.set(node.data.packageName, node);
|
|
283
|
+
}
|
|
284
|
+
// Coverage exemptions ride the graph the way `workspaceLayout` does: an
|
|
285
|
+
// optional whole-graph fact the provider measured (`buildNativeGraph`, from
|
|
286
|
+
// `judgeCoverage`'s concrete list). Anything that is not a list of strings
|
|
287
|
+
// is read as NONE rather than guessed at — the fail-closed direction here is
|
|
288
|
+
// toward reporting, because an exemption that does not apply leaves the
|
|
289
|
+
// site's verdict exactly where it was before this field existed. The globs
|
|
290
|
+
// never reach this layer at all; see `exemptResolvedFile` for why that is
|
|
291
|
+
// the load-bearing half.
|
|
292
|
+
const exemptedFiles = new Set(
|
|
293
|
+
Array.isArray(graph.exemptedFiles)
|
|
294
|
+
? graph.exemptedFiles.filter((file) => typeof file === "string")
|
|
295
|
+
: [],
|
|
296
|
+
);
|
|
297
|
+
return {
|
|
298
|
+
graph,
|
|
299
|
+
depConstraints: config.depConstraints,
|
|
300
|
+
options: config.options,
|
|
301
|
+
suppressions: config.suppressions ?? [],
|
|
302
|
+
workspaceLayout: graph.workspaceLayout ?? DEFAULT_WORKSPACE_LAYOUT,
|
|
303
|
+
mappings,
|
|
304
|
+
reach,
|
|
305
|
+
fileIndex,
|
|
306
|
+
externalByPackage,
|
|
307
|
+
exemptedFiles,
|
|
308
|
+
synthesizedExternals: new Map(),
|
|
309
|
+
ignored: expandIgnoredCircularDependencies(
|
|
310
|
+
config.options.ignoredCircularDependencies,
|
|
311
|
+
graph,
|
|
312
|
+
findMatchingProjects,
|
|
313
|
+
),
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* The external node an external specifier points at, or `undefined` when the
|
|
319
|
+
* specifier is a path and so points at no package at all.
|
|
320
|
+
*
|
|
321
|
+
* Upstream looks the package up in `projectGraph.externalNodes` and BAILS when
|
|
322
|
+
* it is not there — no target, no check. This engine synthesises one instead,
|
|
323
|
+
* and that difference is deliberate: `src/graph/` does not register crates,
|
|
324
|
+
* PyPI distributions or Go modules as external nodes (`../../AGENTS.md` — only
|
|
325
|
+
* project↔project edges matter to `nx affected`), so bailing would mean
|
|
326
|
+
* `bannedExternalImports` silently never fires for any language but JavaScript.
|
|
327
|
+
* A ban that cannot fire is the false negative this tool exists to remove, so
|
|
328
|
+
* the analysis record's own answer — it resolved outside every project, and
|
|
329
|
+
* this is the package — is taken as sufficient.
|
|
330
|
+
*
|
|
331
|
+
* A PATH is where that stops, and it is not an exception to the mechanism but
|
|
332
|
+
* its precondition: a package name is what the mechanism needs, and a path
|
|
333
|
+
* never is one. Upstream is structurally the same — `TargetProjectLocator`'s
|
|
334
|
+
* `findProjectFromImport` opens with `isRelativePath` and then only ever
|
|
335
|
+
* resolves the path to a file, so a relative specifier never reaches its npm
|
|
336
|
+
* lookup at all. Deriving a name from a path here produced garbage that looked
|
|
337
|
+
* like a package (`".."` from `../../../outside/present`, `""` from
|
|
338
|
+
* `/outside/present`), and any target — however synthetic — makes the site
|
|
339
|
+
* skip the one branch that reports `noRelativeOrAbsoluteExternals`. Nothing is
|
|
340
|
+
* lost by refusing: what is refused here is exactly what that branch reports.
|
|
341
|
+
*/
|
|
342
|
+
function externalNodeFor(site, ctx) {
|
|
343
|
+
if (isPathSpecifier(site)) return undefined;
|
|
344
|
+
const packageName = site.resolved.packageName ?? getPackageNameFromImportPath(site.specifier);
|
|
345
|
+
const known = ctx.externalByPackage.get(packageName);
|
|
346
|
+
if (known) return known;
|
|
347
|
+
const synthesized = ctx.synthesizedExternals.get(packageName);
|
|
348
|
+
if (synthesized) return synthesized;
|
|
349
|
+
const node = { name: `npm:${packageName}`, type: "npm", data: { packageName } };
|
|
350
|
+
ctx.synthesizedExternals.set(packageName, node);
|
|
351
|
+
return node;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* The node an already-resolved record points at, or `undefined` when the record
|
|
356
|
+
* could not resolve it — which upstream treats the same way it treats an import
|
|
357
|
+
* its own locator could not place.
|
|
358
|
+
*
|
|
359
|
+
* A record naming a project the graph does not have is neither: it means the
|
|
360
|
+
* analysis and the graph were computed against different trees, and every
|
|
361
|
+
* verdict from that point on would be arbitrary. It throws.
|
|
362
|
+
*/
|
|
363
|
+
function resolveTargetNode(site, ctx) {
|
|
364
|
+
const resolved = site.resolved;
|
|
365
|
+
if (!resolved) return undefined;
|
|
366
|
+
if (resolved.target) {
|
|
367
|
+
const node = ctx.graph.nodes[resolved.target];
|
|
368
|
+
if (!node) {
|
|
369
|
+
throw new Error(
|
|
370
|
+
`archkeep: ${site.sourceFile}:${site.line}:${site.column} imports ` +
|
|
371
|
+
`'${site.specifier}', which analysis resolved to project '${resolved.target}' — ` +
|
|
372
|
+
`a project the graph does not contain. The graph and the analysis records ` +
|
|
373
|
+
`describe different trees; every verdict after this one would be guesswork.`,
|
|
374
|
+
);
|
|
375
|
+
}
|
|
376
|
+
return node;
|
|
377
|
+
}
|
|
378
|
+
if (resolved.external) return externalNodeFor(site, ctx);
|
|
379
|
+
return undefined;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* The coverage-exempt file an import resolved to, or `null`.
|
|
384
|
+
*
|
|
385
|
+
* A `coverage.exempt` row answers the coverage question ("this tracked,
|
|
386
|
+
* analyzable file legitimately belongs to no project") and, since #218, the
|
|
387
|
+
* boundary question too: importing such a file is neither a project-to-project
|
|
388
|
+
* edge nor an external one, so it is left unconstrained. The decision keys on
|
|
389
|
+
* the RESOLVED FILE, not on the specifier's spelling, which is what makes a
|
|
390
|
+
* relative `../x.js` and an alias pointing at the same file take one answer.
|
|
391
|
+
*
|
|
392
|
+
* ## Why this takes concrete paths and never re-globs
|
|
393
|
+
*
|
|
394
|
+
* This is the guard that keeps the exempt list from becoming a boundary-off
|
|
395
|
+
* switch. The globs a workspace writes are expanded exactly once — in
|
|
396
|
+
* `../providers/native/coverage.mjs`'s `judgeCoverage`, against the TRACKED,
|
|
397
|
+
* ANALYZABLE files NO PROJECT OWNS, where a row matching none of them is
|
|
398
|
+
* refused loudly as stale (`../providers/native/index.mjs`). What arrives here
|
|
399
|
+
* is that expansion's output. So:
|
|
400
|
+
*
|
|
401
|
+
* - even a broad row (`**`, a whole directory) can only ever name files
|
|
402
|
+
* outside every project — a project-owned file cannot enter the list, so no
|
|
403
|
+
* import into one is ever silenced by it;
|
|
404
|
+
* - membership is exact-path, so an import resolving to nothing real
|
|
405
|
+
* (`resolved.file` null), to a file outside the tree, or to an untracked
|
|
406
|
+
* file keeps the verdict it had before this mechanism existed.
|
|
407
|
+
*
|
|
408
|
+
* Exported because the run's own report must be able to say how many imports
|
|
409
|
+
* took the unconstrained road (`../../../cli.mjs`'s coverage notes) without a
|
|
410
|
+
* second copy of this predicate drifting from the engine's.
|
|
411
|
+
*
|
|
412
|
+
* @param {object} site An analysis record — see `../analysis/contract.md`.
|
|
413
|
+
* @param {Set<string>} exemptedFiles The graph's concrete exempt-file set.
|
|
414
|
+
* @returns {string|null} The exempt file the record resolved to.
|
|
415
|
+
*/
|
|
416
|
+
export function exemptResolvedFile(site, exemptedFiles) {
|
|
417
|
+
if (!exemptedFiles || exemptedFiles.size === 0) return null;
|
|
418
|
+
const file = site.resolved?.file;
|
|
419
|
+
return typeof file === "string" && exemptedFiles.has(file) ? file : null;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/** Builds one `Violation`. */
|
|
423
|
+
function violationOf(site, sourceProject, targetProject, messageId, data = {}, constraint = null) {
|
|
424
|
+
return {
|
|
425
|
+
sourceFile: site.sourceFile,
|
|
426
|
+
line: site.line,
|
|
427
|
+
column: site.column,
|
|
428
|
+
specifier: site.specifier,
|
|
429
|
+
kind: site.kind,
|
|
430
|
+
messageId,
|
|
431
|
+
message: renderMessage(messageId, data),
|
|
432
|
+
sourceProject: sourceProject?.name ?? null,
|
|
433
|
+
targetProject: targetProject?.name ?? null,
|
|
434
|
+
constraint,
|
|
435
|
+
data,
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* The tag block — upstream's last step, and the one with the two inversions
|
|
441
|
+
* that make or break a reimplementation.
|
|
442
|
+
*
|
|
443
|
+
* Yielded as groups in the order this block has always reported: the first
|
|
444
|
+
* firing check across all matching constraints is the first
|
|
445
|
+
* group, and each later constraint's verdict — reachable only by fixing or
|
|
446
|
+
* suppressing the one before it — is a later group. TRAP 2's AND semantics are
|
|
447
|
+
* unchanged: the FIRST group is decided exactly as the early return decided
|
|
448
|
+
* it, and nothing below it is judged into the verdict unless something removed
|
|
449
|
+
* the group above.
|
|
450
|
+
*
|
|
451
|
+
* @returns {Generator<Violation[]>}
|
|
452
|
+
*/
|
|
453
|
+
function* constraintGroupsFor(site, sourceProject, targetProject, ctx) {
|
|
454
|
+
const { depConstraints, options, graph, reach } = ctx;
|
|
455
|
+
if (depConstraints.length === 0) return;
|
|
456
|
+
|
|
457
|
+
const constraints = findConstraintsFor(depConstraints, sourceProject);
|
|
458
|
+
// TRAP 1 — no matching constraint is an ERROR, not a pass. Upstream's own
|
|
459
|
+
// comment: "when no constrains found => error. Force the user to provision
|
|
460
|
+
// them." Read it the natural way and every untagged or mis-tagged project
|
|
461
|
+
// escapes the boundary while the tool reports green. Nothing sits below the
|
|
462
|
+
// tag block, so this ends the chain.
|
|
463
|
+
if (constraints.length === 0) {
|
|
464
|
+
yield [
|
|
465
|
+
violationOf(
|
|
466
|
+
site,
|
|
467
|
+
sourceProject,
|
|
468
|
+
targetProject,
|
|
469
|
+
"projectWithoutTagsCannotHaveDependencies",
|
|
470
|
+
{},
|
|
471
|
+
),
|
|
472
|
+
];
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
const transitiveExternalDeps = options.checkNestedExternalImports
|
|
477
|
+
? findTransitiveExternalDependencies(graph, reach, targetProject)
|
|
478
|
+
: [];
|
|
479
|
+
|
|
480
|
+
// TRAP 2 — every matching constraint must be satisfied. `findConstraintsFor`
|
|
481
|
+
// returns an ARRAY and this loop is an AND: a project tagged `type:lib
|
|
482
|
+
// scope:shared layer:domain license:internal` is held to all four rows of a
|
|
483
|
+
// table carrying one row per axis. An OR here passes imports ESLint blocks.
|
|
484
|
+
for (const constraint of constraints) {
|
|
485
|
+
const tagVerdict =
|
|
486
|
+
onlyTagsViolation(constraint, targetProject) ??
|
|
487
|
+
emptyOnlyTagsViolation(constraint, targetProject) ??
|
|
488
|
+
notTagsViolation(constraint, targetProject, graph, reach);
|
|
489
|
+
if (tagVerdict) {
|
|
490
|
+
yield [
|
|
491
|
+
violationOf(
|
|
492
|
+
site,
|
|
493
|
+
sourceProject,
|
|
494
|
+
targetProject,
|
|
495
|
+
tagVerdict.messageId,
|
|
496
|
+
tagVerdict.data,
|
|
497
|
+
constraint,
|
|
498
|
+
),
|
|
499
|
+
];
|
|
500
|
+
continue;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
if (
|
|
504
|
+
options.checkNestedExternalImports &&
|
|
505
|
+
constraint.bannedExternalImports &&
|
|
506
|
+
constraint.bannedExternalImports.length
|
|
507
|
+
) {
|
|
508
|
+
const matches = hasBannedDependencies(
|
|
509
|
+
transitiveExternalDeps,
|
|
510
|
+
graph,
|
|
511
|
+
constraint,
|
|
512
|
+
site.specifier,
|
|
513
|
+
);
|
|
514
|
+
// One violation per offending package — the only check in the engine that
|
|
515
|
+
// reports more than once for a single import site.
|
|
516
|
+
if (matches.length > 0) {
|
|
517
|
+
yield matches.map(([, violatingSource, matchedConstraint]) =>
|
|
518
|
+
violationOf(
|
|
519
|
+
site,
|
|
520
|
+
sourceProject,
|
|
521
|
+
targetProject,
|
|
522
|
+
"nestedBannedExternalImportsViolation",
|
|
523
|
+
{
|
|
524
|
+
sourceTag: constraintSourceTagLabel(matchedConstraint),
|
|
525
|
+
childProjectName: violatingSource.name,
|
|
526
|
+
imp: site.specifier,
|
|
527
|
+
},
|
|
528
|
+
matchedConstraint,
|
|
529
|
+
),
|
|
530
|
+
);
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/**
|
|
537
|
+
* One import site's whole candidate chain, in upstream's order.
|
|
538
|
+
*
|
|
539
|
+
* Each yielded array is a GROUP of simultaneous violations — what the site
|
|
540
|
+
* reports at that point of the chain. Most checks yield one violation; two
|
|
541
|
+
* places yield several at once, unchanged from upstream: the npm branch (a
|
|
542
|
+
* transitive-dependency report does not stop the banned-import check) and the
|
|
543
|
+
* nested-banned check (one report per offending package). The evaluation picks
|
|
544
|
+
* the first group in which something survives the suppression table, so the
|
|
545
|
+
* chain is only ever walked past a group the table removed entirely — a
|
|
546
|
+
* suppression behaves like a fix, revealing the next check at the same line
|
|
547
|
+
* (`../../../../docs/reference/violations.md`, "The order matters"), never
|
|
548
|
+
* skipping a site or inventing a verdict whose preconditions do not hold.
|
|
549
|
+
*
|
|
550
|
+
* The places this generator RETURNS rather than yields-and-continues are the
|
|
551
|
+
* places nothing below is genuinely reachable:
|
|
552
|
+
*
|
|
553
|
+
* - `allow`, a file in no project — outside the boundary system entirely;
|
|
554
|
+
* - no resolved target — every check below needs one;
|
|
555
|
+
* - a self-project import (`source === target`) whose self-pair the ignore map
|
|
556
|
+
* does not excuse — fixing the barrel round-trip lands back inside the same
|
|
557
|
+
* project, where none of those checks judge. A self-pair EXCUSED by
|
|
558
|
+
* `ignoredCircularDependencies` falls through exactly as this engine has
|
|
559
|
+
* always fallen through, so its chain reaches the project-to-project block;
|
|
560
|
+
* - an npm target — upstream returns before the tag block, so no external
|
|
561
|
+
* import can produce a tags violation;
|
|
562
|
+
* - a non-project node.
|
|
563
|
+
*
|
|
564
|
+
* Everything else CONTINUES: fixing a cycle leaves the same import to be
|
|
565
|
+
* judged by the apps/e2e/buildable/lazy checks and the constraint table, which
|
|
566
|
+
* is exactly what a suppression standing in for that fix must reveal.
|
|
567
|
+
*
|
|
568
|
+
* @param {object} site Analysis record — see `../analysis/contract.md`.
|
|
569
|
+
* @param {object} ctx From `createContext`.
|
|
570
|
+
* @returns {Generator<Violation[]>}
|
|
571
|
+
*/
|
|
572
|
+
function* candidateGroupsFor(site, ctx) {
|
|
573
|
+
const { graph, options, mappings, reach, fileIndex, ignored, depConstraints } = ctx;
|
|
574
|
+
const imp = site.specifier;
|
|
575
|
+
|
|
576
|
+
// TRAP 3 — `allow` is matched against the RAW SPECIFIER with Nx's own
|
|
577
|
+
// wildcard matcher, whose fallback branch is an unanchored `new RegExp(...)`.
|
|
578
|
+
// Not the resolved file path, and not minimatch: swap in a glob library and
|
|
579
|
+
// every existing escape hatch quietly stops matching. Checked first, so an
|
|
580
|
+
// allowed specifier is exempt from all fifteen rules.
|
|
581
|
+
if (options.allow.some((allowed) => matchImportWithWildcard(allowed, imp))) return;
|
|
582
|
+
|
|
583
|
+
const sourceProject = graph.nodes[findProjectForPath(site.sourceFile, mappings)];
|
|
584
|
+
// A file in no project is outside the boundary system entirely.
|
|
585
|
+
if (!sourceProject) return;
|
|
586
|
+
|
|
587
|
+
// Relative and absolute paths are judged on their TEXT, before any resolution:
|
|
588
|
+
// the projects can be correct and the spelling still be the violation.
|
|
589
|
+
const absoluteIntoAnotherProject = isAbsoluteImportIntoAnotherProject(imp, ctx.workspaceLayout);
|
|
590
|
+
let targetProject = absoluteIntoAnotherProject
|
|
591
|
+
? graph.nodes[findProjectForPath(imp, mappings)]
|
|
592
|
+
: graph.nodes[getTargetProjectBasedOnRelativeImport(imp, site.sourceFile, mappings)];
|
|
593
|
+
|
|
594
|
+
if ((targetProject && sourceProject !== targetProject) || absoluteIntoAnotherProject) {
|
|
595
|
+
yield [
|
|
596
|
+
violationOf(site, sourceProject, targetProject, "noRelativeOrAbsoluteImportsAcrossLibraries"),
|
|
597
|
+
];
|
|
598
|
+
// The spelling was the violation, not the edge: with the specifier written
|
|
599
|
+
// through the project's public name — or with the spelling suppressed — the
|
|
600
|
+
// SAME target project reaches the checks below. `targetProject` is already
|
|
601
|
+
// resolved, so resolution is not run again.
|
|
602
|
+
} else {
|
|
603
|
+
// A coverage-exempt file is resolvable and unconstrained (#218): the record
|
|
604
|
+
// resolved to a real tracked workspace file that `coverage.exempt` declared
|
|
605
|
+
// to belong to no project, so this import is neither a project-to-project
|
|
606
|
+
// edge nor an external one. It sits BEFORE `resolveTargetNode` because that
|
|
607
|
+
// is what synthesises the external node: downstream of it a bare or aliased
|
|
608
|
+
// specifier resolving into an exempt file has already become
|
|
609
|
+
// `npm:<specifier>`, a package that does not exist, which is the false
|
|
610
|
+
// description this branch exists to stop. A file a project owns can never
|
|
611
|
+
// enter the exempt set (`exemptResolvedFile`), so this can only take a site
|
|
612
|
+
// whose target is outside every project; a relative path resolving outside
|
|
613
|
+
// every project still reaches `noRelativeOrAbsoluteExternals` below.
|
|
614
|
+
if (
|
|
615
|
+
!targetProject &&
|
|
616
|
+
site.resolved?.target == null &&
|
|
617
|
+
exemptResolvedFile(site, ctx.exemptedFiles) !== null
|
|
618
|
+
) {
|
|
619
|
+
return;
|
|
620
|
+
}
|
|
621
|
+
targetProject = targetProject ?? resolveTargetNode(site, ctx);
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
if (!targetProject) {
|
|
625
|
+
// A bare `.` or `..` counts as a path at this point though it did not count
|
|
626
|
+
// as one above — see `isPathSpecifier`, which `externalNodeFor` refuses on
|
|
627
|
+
// so that every path reaching here is reported rather than given a target.
|
|
628
|
+
if (isPathSpecifier(site)) {
|
|
629
|
+
yield [violationOf(site, sourceProject, null, "noRelativeOrAbsoluteExternals")];
|
|
630
|
+
return;
|
|
631
|
+
}
|
|
632
|
+
if (options.banTransitiveDependencies && !isBuiltinModuleImport(imp)) {
|
|
633
|
+
yield [violationOf(site, sourceProject, null, "noTransitiveDependencies")];
|
|
634
|
+
}
|
|
635
|
+
return;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
// A file reaching its own project through the project's public alias instead
|
|
639
|
+
// of a relative path: a cycle through the barrel, and invisible in an edge
|
|
640
|
+
// list because the edge starts and ends at the same node.
|
|
641
|
+
//
|
|
642
|
+
// `spelling.relative` is the counter-evidence, and it is the record's answer
|
|
643
|
+
// rather than this layer's: what counts as "instead of a relative path" is
|
|
644
|
+
// `./x` in JavaScript, `crate::`/`self::`/`super::` or a sibling crate target
|
|
645
|
+
// of the same Cargo package in Rust, a leading-dot import in Python, and in
|
|
646
|
+
// Go any import landing back in the source file's own project — Go has no
|
|
647
|
+
// relative import form at all, so treating one as evidence of a barrel cycle
|
|
648
|
+
// would demand syntax the language does not have, and its compiler already
|
|
649
|
+
// forbids the cycle this rule looks for.
|
|
650
|
+
// The early return keeps upstream's shape — with one exception carried over
|
|
651
|
+
// from the flat-list engine byte for byte: a self-pair the ignore map excuses
|
|
652
|
+
// (`ignoredCircularDependencies: [["p", "p"]]`) fell through to the
|
|
653
|
+
// project-to-project block below and still does. Restoring that fall-through
|
|
654
|
+
// is what keeps a workspace with no suppressions byte-identical: measured
|
|
655
|
+
// against this engine's previous revision, such an import reached the tag
|
|
656
|
+
// block and could report `onlyTagsConstraintViolation` or
|
|
657
|
+
// `noImportsOfApps` on the self-edge.
|
|
658
|
+
if (
|
|
659
|
+
sourceProject === targetProject &&
|
|
660
|
+
!circularPathHasPair([sourceProject, targetProject], ignored)
|
|
661
|
+
) {
|
|
662
|
+
if (
|
|
663
|
+
!options.allowCircularSelfDependency &&
|
|
664
|
+
!spellingOf(site).relative &&
|
|
665
|
+
!belongsToDifferentEntryPoint(site.resolved?.file ?? null, site.sourceFile, sourceProject)
|
|
666
|
+
) {
|
|
667
|
+
yield [
|
|
668
|
+
violationOf(site, sourceProject, targetProject, "noSelfCircularDependencies", { imp }),
|
|
669
|
+
];
|
|
670
|
+
}
|
|
671
|
+
return;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
if (targetProject.type === "npm") {
|
|
675
|
+
const found = [];
|
|
676
|
+
// Upstream does NOT return between these two, so an import can be both
|
|
677
|
+
// transitive and banned and be reported twice.
|
|
678
|
+
if (
|
|
679
|
+
options.banTransitiveDependencies &&
|
|
680
|
+
// The builtin exemption is upstream's, moved here because this engine
|
|
681
|
+
// synthesises external nodes for specifiers upstream would have left
|
|
682
|
+
// unresolved — without it, `import fs from "node:fs"` would be reported
|
|
683
|
+
// as a transitive dependency, which upstream never does.
|
|
684
|
+
!isBuiltinModuleImport(imp) &&
|
|
685
|
+
!isDirectDependency(sourceProject, targetProject)
|
|
686
|
+
) {
|
|
687
|
+
found.push(violationOf(site, sourceProject, targetProject, "noTransitiveDependencies"));
|
|
688
|
+
}
|
|
689
|
+
const constraint = hasBannedImport(sourceProject, targetProject, depConstraints, imp);
|
|
690
|
+
if (constraint) {
|
|
691
|
+
found.push(
|
|
692
|
+
violationOf(
|
|
693
|
+
site,
|
|
694
|
+
sourceProject,
|
|
695
|
+
targetProject,
|
|
696
|
+
"bannedExternalImportsViolation",
|
|
697
|
+
{ sourceTag: constraintSourceTagLabel(constraint), imp },
|
|
698
|
+
constraint,
|
|
699
|
+
),
|
|
700
|
+
);
|
|
701
|
+
}
|
|
702
|
+
// An npm target NEVER reaches the tag block below — so no external import
|
|
703
|
+
// can produce `projectWithoutTagsCannotHaveDependencies`, however untagged
|
|
704
|
+
// its source project is.
|
|
705
|
+
if (found.length > 0) yield found;
|
|
706
|
+
return;
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
if (!isProjectGraphProjectNode(targetProject)) return;
|
|
710
|
+
|
|
711
|
+
const circular = circularViolation({
|
|
712
|
+
reach,
|
|
713
|
+
graph,
|
|
714
|
+
sourceProject,
|
|
715
|
+
targetProject,
|
|
716
|
+
sourceFile: site.sourceFile,
|
|
717
|
+
fileIndex,
|
|
718
|
+
ignored,
|
|
719
|
+
});
|
|
720
|
+
if (circular) {
|
|
721
|
+
yield [violationOf(site, sourceProject, targetProject, circular.messageId, circular.data)];
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
if (targetProject.type === "app" && !appIsMFERemote(targetProject)) {
|
|
725
|
+
yield [violationOf(site, sourceProject, targetProject, "noImportsOfApps")];
|
|
726
|
+
} else if (targetProject.type === "e2e") {
|
|
727
|
+
yield [violationOf(site, sourceProject, targetProject, "noImportsOfE2e")];
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
if (
|
|
731
|
+
options.enforceBuildableLibDependency === true &&
|
|
732
|
+
sourceProject.type === "lib" &&
|
|
733
|
+
targetProject.type === "lib" &&
|
|
734
|
+
hasBuildExecutor(sourceProject, options.buildTargets) &&
|
|
735
|
+
!hasBuildExecutor(targetProject, options.buildTargets)
|
|
736
|
+
) {
|
|
737
|
+
yield [violationOf(site, sourceProject, targetProject, "noImportOfNonBuildableLibraries")];
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
// `kind === "static"` stands in for upstream's "an `import` declaration that
|
|
741
|
+
// is not type-only". See `lazyLoadedViolation` for the one case the analysis
|
|
742
|
+
// contract cannot separate — `require()` — and why it errs toward reporting.
|
|
743
|
+
if (
|
|
744
|
+
site.kind === "static" &&
|
|
745
|
+
!options.checkDynamicDependenciesExceptions.some((pattern) =>
|
|
746
|
+
matchImportWithWildcard(pattern, imp),
|
|
747
|
+
)
|
|
748
|
+
) {
|
|
749
|
+
const lazy = lazyLoadedViolation({
|
|
750
|
+
graph,
|
|
751
|
+
sourceProject,
|
|
752
|
+
targetProject,
|
|
753
|
+
resolvedFile: site.resolved?.file ?? null,
|
|
754
|
+
fileIndex,
|
|
755
|
+
});
|
|
756
|
+
if (lazy) {
|
|
757
|
+
yield [violationOf(site, sourceProject, targetProject, lazy.messageId, lazy.data)];
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
yield* constraintGroupsFor(site, sourceProject, targetProject, ctx);
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
/**
|
|
765
|
+
* Whether any SUPPRESSING row (fate `"suppress"` — a legacy row, no expiry)
|
|
766
|
+
* covers this violation. A waiver's fates (`"waive"`, `"reassert"`) never
|
|
767
|
+
* remove a violation, so they never decide which group a site reports — they
|
|
768
|
+
* only annotate it (`../governance/waiver.mjs`).
|
|
769
|
+
*
|
|
770
|
+
* @param {object[]} suppressions The validated `boundarySuppressions` table.
|
|
771
|
+
* @param {object} violation
|
|
772
|
+
* @param {string} now Reference instant (ISO-8601).
|
|
773
|
+
* @returns {boolean}
|
|
774
|
+
*/
|
|
775
|
+
function removedByTable(suppressions, violation, now) {
|
|
776
|
+
for (const entry of suppressions) {
|
|
777
|
+
if (!suppressionCovers(entry, violation)) continue;
|
|
778
|
+
if (suppressionFate(entry, now) === "suppress") return true;
|
|
779
|
+
}
|
|
780
|
+
return false;
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
/**
|
|
784
|
+
* The annotation the table puts on a violation that SURVIVES it: `waivedBy`
|
|
785
|
+
* for an active waiver's acceptance, `evidence` for one whose term lapsed.
|
|
786
|
+
* Copied rather than mutated so `evaluateRun`'s two results never share a
|
|
787
|
+
* marked object — the raw superset states what the law found, unannotated.
|
|
788
|
+
*
|
|
789
|
+
* @param {object[]} suppressions The validated `boundarySuppressions` table.
|
|
790
|
+
* @param {object} violation
|
|
791
|
+
* @param {string} now Reference instant (ISO-8601).
|
|
792
|
+
* @returns {object}
|
|
793
|
+
*/
|
|
794
|
+
function annotatedByTable(suppressions, violation, now) {
|
|
795
|
+
for (const entry of suppressions) {
|
|
796
|
+
if (!suppressionCovers(entry, violation)) continue;
|
|
797
|
+
if (suppressionFate(entry, now) === "waive") return { ...violation, waivedBy: entry };
|
|
798
|
+
return { ...violation, evidence: EXPIRED_WAIVER_EVIDENCE };
|
|
799
|
+
}
|
|
800
|
+
return violation;
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
/**
|
|
804
|
+
* One run of the engine over every import site: the judged verdict per site
|
|
805
|
+
* plus the raw superset it was picked from.
|
|
806
|
+
*
|
|
807
|
+
* Per site, `candidateGroupsFor` yields the site's candidate groups in
|
|
808
|
+
* upstream's order; this walk picks the FIRST group in which at least one
|
|
809
|
+
* violation survives the suppression table. Groups before it — every verdict
|
|
810
|
+
* the table removed entirely — are exactly what the raw superset carries above
|
|
811
|
+
* the verdict, which is the arithmetic the waiver surface is built on:
|
|
812
|
+
* `raw − evaluated = what the table hides`, per site, byte-for-byte. A group
|
|
813
|
+
* partially covered keeps its surviving members (an npm target reported as
|
|
814
|
+
* both transitive and banned, with only the transitive half suppressed, still
|
|
815
|
+
* reports the banned half), unchanged from when the table filtered flat lists.
|
|
816
|
+
*
|
|
817
|
+
* @param {object[]} importSites Analysis records — see `../analysis/contract.md`.
|
|
818
|
+
* @param {ProjectGraph} graph
|
|
819
|
+
* @param {{depConstraints: object[], options: object, suppressions?: object[], now?: string}} config
|
|
820
|
+
* As `loadBoundaryConfig` returns it, plus an optional `now` (ISO-8601
|
|
821
|
+
* reference instant) used only to decide waiver expiry; defaults to the
|
|
822
|
+
* shared governance clock.
|
|
823
|
+
* @returns {{violations: object[], rawViolations: object[]}} `violations` is
|
|
824
|
+
* the run's verdict — one group per site, waivers/expiry annotated;
|
|
825
|
+
* `rawViolations` is every candidate up to and including each site's selected
|
|
826
|
+
* group, unannotated. Exported for the one caller that needs both faces of a
|
|
827
|
+
* single walk (`cli.mjs`'s `check`, whose dead-suppression-row refusal
|
|
828
|
+
* measures each row against `rawViolations`) — everywhere else takes
|
|
829
|
+
* `evaluate` or `evaluateWithSuppressions`, which are this function's two
|
|
830
|
+
* fields under thinner names.
|
|
831
|
+
*/
|
|
832
|
+
export function evaluateRun(importSites, graph, config) {
|
|
833
|
+
const ctx = createContext(importSites, graph, config);
|
|
834
|
+
const now = config?.now ?? referenceTime();
|
|
835
|
+
/** @type {object[]} */
|
|
836
|
+
const violations = [];
|
|
837
|
+
/** @type {object[]} */
|
|
838
|
+
const rawViolations = [];
|
|
839
|
+
for (const site of importSites) {
|
|
840
|
+
/** @type {object[]} */
|
|
841
|
+
const hidden = [];
|
|
842
|
+
let selected = false;
|
|
843
|
+
for (const group of candidateGroupsFor(site, ctx)) {
|
|
844
|
+
const survivors =
|
|
845
|
+
ctx.suppressions.length === 0
|
|
846
|
+
? group
|
|
847
|
+
: group.filter((violation) => !removedByTable(ctx.suppressions, violation, now));
|
|
848
|
+
if (survivors.length === 0) {
|
|
849
|
+
hidden.push(...group);
|
|
850
|
+
continue;
|
|
851
|
+
}
|
|
852
|
+
// The first group something survived in IS the site's verdict; everything
|
|
853
|
+
// collected before it is what the table hid to get there.
|
|
854
|
+
violations.push(
|
|
855
|
+
...survivors.map((violation) => annotatedByTable(ctx.suppressions, violation, now)),
|
|
856
|
+
);
|
|
857
|
+
rawViolations.push(...hidden, ...group);
|
|
858
|
+
selected = true;
|
|
859
|
+
break;
|
|
860
|
+
}
|
|
861
|
+
if (!selected) rawViolations.push(...hidden);
|
|
862
|
+
}
|
|
863
|
+
return { violations, rawViolations };
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
/**
|
|
867
|
+
* The raw violations a site set produces BEFORE any suppression removes one,
|
|
868
|
+
* as `evaluate` would see them — every candidate up to each site's selected
|
|
869
|
+
* group, including the verdicts the table hides. `evaluate` itself reports the
|
|
870
|
+
* filtered verdict — the boundary as it stands after the workspace accepted its
|
|
871
|
+
* suppressions — while the waiver surface (how much of the law is currently
|
|
872
|
+
* waived) needs the count that WAS suppressed, which the filtered result cannot
|
|
873
|
+
* express. The two are a superset/subset: `waived = raw − evaluated`,
|
|
874
|
+
* byte-for-byte.
|
|
875
|
+
*
|
|
876
|
+
* @param {object[]} importSites Analysis records — see `../analysis/contract.md`.
|
|
877
|
+
* @param {ProjectGraph} graph
|
|
878
|
+
* @param {{depConstraints: object[], options: object, suppressions?: object[]}} config
|
|
879
|
+
* @returns {Violation[]} in the order the sites were given, nothing removed.
|
|
880
|
+
*/
|
|
881
|
+
export function evaluateWithSuppressions(importSites, graph, config) {
|
|
882
|
+
return evaluateRun(importSites, graph, config).rawViolations;
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
/**
|
|
886
|
+
* Judges every import site against the workspace's boundary law.
|
|
887
|
+
*
|
|
888
|
+
* Pure: the same three arguments always produce the same violations, and none
|
|
889
|
+
* of them is read from disk here.
|
|
890
|
+
*
|
|
891
|
+
* ## Suppressions act on VERDICTS, never on sites — and a suppressed verdict
|
|
892
|
+
* must behave like a fix
|
|
893
|
+
*
|
|
894
|
+
* `config.suppressions` decides what happens to violations the workspace
|
|
895
|
+
* accepted, each carrying the reason it was accepted (`../config.mjs`). Every
|
|
896
|
+
* site is judged before the table decides anything — the site's candidate
|
|
897
|
+
* chain is walked until a verdict survives it — and that ordering is
|
|
898
|
+
* load-bearing rather than an implementation detail: skipping a suppressed
|
|
899
|
+
* file up front would also skip the checks that make this function throw — a
|
|
900
|
+
* record naming a project the graph does not have, a malformed config — and a
|
|
901
|
+
* suppression must never be able to silence "I could not tell". A violation is
|
|
902
|
+
* a decision someone can accept; a failure is the absence of one, and
|
|
903
|
+
* accepting it would turn a blind spot into a green light.
|
|
904
|
+
*
|
|
905
|
+
* What the table removes is one VERDICT, never the checks below it. The first
|
|
906
|
+
* candidate group a suppressing row covers entirely is replaced by the next
|
|
907
|
+
* group down the documented order — suppressing
|
|
908
|
+
* `noRelativeOrAbsoluteImportsAcrossLibraries` on a cross-project import
|
|
909
|
+
* surfaces whatever the constraint table says about the same edge, exactly as
|
|
910
|
+
* rewriting the specifier would (`../../../../docs/reference/violations.md`,
|
|
911
|
+
* "The order matters"). With nothing suppressed, the first group is emitted
|
|
912
|
+
* untouched: one violation per site, byte-for-byte the verdict upstream's
|
|
913
|
+
* order defines. A later group exists only where its own preconditions hold on
|
|
914
|
+
* the site — a tags verdict needs a resolved target project — so fall-through
|
|
915
|
+
* invents nothing.
|
|
916
|
+
*
|
|
917
|
+
* The suppression vocabulary has no field that could name a failure either: an
|
|
918
|
+
* entry carries a path glob, an optional `messageId` out of `MESSAGE_IDS`, its
|
|
919
|
+
* reason, and — for a waiver — `expiresAt`. Analysis failures never reach this
|
|
920
|
+
* function at all — they travel beside the records in the analyzer's envelope
|
|
921
|
+
* (`../analysis/contract.md`). Because the table never touches a failure, a
|
|
922
|
+
* waiver over `unknown` is structurally impossible: a row can only match a
|
|
923
|
+
* verdict this engine reached, and a verdict it could not reach never enters
|
|
924
|
+
* this array. That same judge-before-suppress ordering is why a waiver cannot
|
|
925
|
+
* promote `unknown` → `pass`: a logical consequence, not a second mechanism.
|
|
926
|
+
*
|
|
927
|
+
* Rows WITH `expiresAt` are waivers and mark rather than remove
|
|
928
|
+
* (`../governance/waiver.mjs`): an ACTIVE waiver keeps the violation it covers
|
|
929
|
+
* in the findings, marked `waivedBy` — the run stays non-zero, because
|
|
930
|
+
* accepting a boundary breach for a fixed term is a tracked decision, not a
|
|
931
|
+
* fix — and an EXPIRED one re-asserts with `evidence: "expired waiver"`.
|
|
932
|
+
* Neither fate removes a verdict, so neither moves a site down its chain; the
|
|
933
|
+
* empty-result invariant (`../../../../AGENTS.md`) holds in the waiving
|
|
934
|
+
* direction too.
|
|
935
|
+
*
|
|
936
|
+
* `evaluateWithSuppressions` (above) is the raw superset this function folds
|
|
937
|
+
* down — every candidate up to each site's selected group — so a caller that
|
|
938
|
+
* needs to measure the waiver surface can see exactly what the table removed,
|
|
939
|
+
* which the filtered result cannot express: `raw − evaluated` is the set of
|
|
940
|
+
* verdicts the suppressions hid.
|
|
941
|
+
*
|
|
942
|
+
* @param {object[]} importSites Analysis records — see `../analysis/contract.md`.
|
|
943
|
+
* @param {ProjectGraph} graph
|
|
944
|
+
* @param {{depConstraints: object[], options: object, suppressions?: object[], now?: string}} config
|
|
945
|
+
* As `loadBoundaryConfig` returns it, plus an optional `now` (ISO-8601
|
|
946
|
+
* reference instant) used only to decide waiver expiry; defaults to the
|
|
947
|
+
* shared governance clock. An absent `suppressions` suppresses nothing, which
|
|
948
|
+
* is the direction that cannot hide a violation.
|
|
949
|
+
* @returns {Violation[]} per site, the first candidate group no suppression
|
|
950
|
+
* removed — one violation in the common case, several only where upstream
|
|
951
|
+
* reports more than once for one site (the npm branch; nested-banned, once
|
|
952
|
+
* per offending package). Violations an ACTIVE waiver covers are present,
|
|
953
|
+
* marked `waivedBy`; ones an EXPIRED waiver covered are present with
|
|
954
|
+
* `evidence: "expired waiver"`.
|
|
955
|
+
* @throws {Error} when the config is malformed, when the graph has no `nodes`,
|
|
956
|
+
* when a record carries no `spelling`, or when a record names a project the
|
|
957
|
+
* graph does not contain. Loud on purpose: an enforcer that starts on a
|
|
958
|
+
* broken input and reports nothing is indistinguishable from a clean tree.
|
|
959
|
+
*/
|
|
960
|
+
export function evaluate(importSites, graph, config) {
|
|
961
|
+
return evaluateRun(importSites, graph, config).violations;
|
|
962
|
+
}
|