@ecoma-io/archkeep 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (131) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +262 -0
  3. package/cli.mjs +2792 -0
  4. package/index.mjs +85 -0
  5. package/lsp.mjs +81 -0
  6. package/nx.mjs +24 -0
  7. package/package.json +81 -0
  8. package/presets/clean-architecture.json +78 -0
  9. package/presets/ddd-bounded-contexts.json +88 -0
  10. package/presets/hexagonal.json +68 -0
  11. package/presets/layered.json +92 -0
  12. package/presets/modular-monolith.json +85 -0
  13. package/presets/vertical-slice.json +68 -0
  14. package/src/analysis/analyze.mjs +218 -0
  15. package/src/analysis/contract.md +259 -0
  16. package/src/analysis/go.mjs +414 -0
  17. package/src/analysis/manifest-util.mjs +68 -0
  18. package/src/analysis/python.mjs +1266 -0
  19. package/src/analysis/registry.mjs +74 -0
  20. package/src/analysis/rust.mjs +674 -0
  21. package/src/analysis/source-util.mjs +230 -0
  22. package/src/analysis/typescript.mjs +1034 -0
  23. package/src/analysis/vue.mjs +156 -0
  24. package/src/architecture-intent/intent-fingerprint.mjs +29 -0
  25. package/src/architecture-intent/judge.mjs +539 -0
  26. package/src/architecture-intent/model.mjs +703 -0
  27. package/src/architecture-intent/selectors.mjs +170 -0
  28. package/src/canonical.mjs +48 -0
  29. package/src/commands/README.md +266 -0
  30. package/src/commands/adr.mjs +248 -0
  31. package/src/commands/check.mjs +989 -0
  32. package/src/commands/context-command.mjs +212 -0
  33. package/src/commands/context.mjs +790 -0
  34. package/src/commands/custom-rules.mjs +428 -0
  35. package/src/commands/debt.mjs +218 -0
  36. package/src/commands/diff.mjs +523 -0
  37. package/src/commands/discover.mjs +159 -0
  38. package/src/commands/drift.mjs +473 -0
  39. package/src/commands/edge-constraints.mjs +355 -0
  40. package/src/commands/explain.mjs +359 -0
  41. package/src/commands/fitness.mjs +226 -0
  42. package/src/commands/graph.mjs +297 -0
  43. package/src/commands/health.mjs +213 -0
  44. package/src/commands/history.mjs +614 -0
  45. package/src/commands/impact.mjs +226 -0
  46. package/src/commands/plan-context-command.mjs +496 -0
  47. package/src/commands/policy.mjs +138 -0
  48. package/src/commands/provenance-command.mjs +352 -0
  49. package/src/commands/provenance.mjs +159 -0
  50. package/src/commands/reconcile.mjs +219 -0
  51. package/src/commands/report.mjs +553 -0
  52. package/src/commands/snapshot-meta.mjs +107 -0
  53. package/src/commands/waivers.mjs +240 -0
  54. package/src/config.mjs +1308 -0
  55. package/src/containment.mjs +234 -0
  56. package/src/custom-rules/evidence.mjs +340 -0
  57. package/src/custom-rules/host.mjs +1023 -0
  58. package/src/custom-rules/values.mjs +43 -0
  59. package/src/entry-point.mjs +55 -0
  60. package/src/errors.mjs +36 -0
  61. package/src/eslint-config.mjs +542 -0
  62. package/src/go-work.mjs +394 -0
  63. package/src/governance/adr-registry.mjs +539 -0
  64. package/src/governance/clock.mjs +69 -0
  65. package/src/governance/debt-ledger.mjs +274 -0
  66. package/src/governance/discovery-proposal.mjs +423 -0
  67. package/src/governance/fitness-registry.mjs +504 -0
  68. package/src/governance/fitness-rules.mjs +668 -0
  69. package/src/governance/metrics.mjs +392 -0
  70. package/src/governance/preset-fingerprints.json +16 -0
  71. package/src/governance/profile-registry.mjs +366 -0
  72. package/src/governance/provenance-record.mjs +177 -0
  73. package/src/governance/reconcile-candidates.mjs +301 -0
  74. package/src/governance/reconcile-score.mjs +503 -0
  75. package/src/governance/row-schema.mjs +208 -0
  76. package/src/governance/verdict.mjs +127 -0
  77. package/src/governance/waiver.mjs +105 -0
  78. package/src/graph/create-dependencies.mjs +96 -0
  79. package/src/intent/intent-manifest.json +347 -0
  80. package/src/intent/mask-non-code.mjs +640 -0
  81. package/src/lsp/boundary-config.mjs +225 -0
  82. package/src/lsp/diagnose.mjs +202 -0
  83. package/src/lsp/diagnostics.mjs +241 -0
  84. package/src/lsp/protocol.mjs +215 -0
  85. package/src/lsp/server.mjs +922 -0
  86. package/src/lsp/workspace-index.mjs +891 -0
  87. package/src/nx-json.mjs +95 -0
  88. package/src/options.mjs +611 -0
  89. package/src/process.mjs +91 -0
  90. package/src/providers/moon.mjs +733 -0
  91. package/src/providers/native/README.md +204 -0
  92. package/src/providers/native/coverage.mjs +74 -0
  93. package/src/providers/native/differential.fixtures.mjs +1277 -0
  94. package/src/providers/native/discover.mjs +431 -0
  95. package/src/providers/native/graph.mjs +234 -0
  96. package/src/providers/native/index.mjs +152 -0
  97. package/src/providers/native/model.mjs +755 -0
  98. package/src/providers/nx.mjs +178 -0
  99. package/src/report/README.md +89 -0
  100. package/src/report/adr-text.mjs +129 -0
  101. package/src/report/context-text.mjs +109 -0
  102. package/src/report/debt-text.mjs +105 -0
  103. package/src/report/diff-text.mjs +219 -0
  104. package/src/report/discover-text.mjs +186 -0
  105. package/src/report/drift-text.mjs +194 -0
  106. package/src/report/envelope-shape.mjs +161 -0
  107. package/src/report/evidence.mjs +157 -0
  108. package/src/report/explain-text.mjs +159 -0
  109. package/src/report/graph-text.mjs +116 -0
  110. package/src/report/health-text.mjs +123 -0
  111. package/src/report/history-text.mjs +204 -0
  112. package/src/report/impact-text.mjs +128 -0
  113. package/src/report/json.mjs +173 -0
  114. package/src/report/plan-context-text.mjs +159 -0
  115. package/src/report/provenance-text.mjs +78 -0
  116. package/src/report/reconcile-text.mjs +159 -0
  117. package/src/report/report-text.mjs +264 -0
  118. package/src/report/sarif.mjs +953 -0
  119. package/src/report/text.mjs +823 -0
  120. package/src/report/waivers-text.mjs +100 -0
  121. package/src/rules/README.md +123 -0
  122. package/src/rules/index.mjs +962 -0
  123. package/src/rules/match.mjs +1708 -0
  124. package/src/rules/messages.mjs +73 -0
  125. package/src/rules/reachability.mjs +224 -0
  126. package/src/rules/specifiers.mjs +300 -0
  127. package/src/rules/tags.mjs +238 -0
  128. package/src/rules/topology.mjs +333 -0
  129. package/src/tsconfig-paths.mjs +237 -0
  130. package/src/verdict.mjs +145 -0
  131. package/src/workspace.mjs +580 -0
@@ -0,0 +1,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
+ }