@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,238 @@
1
+ /**
2
+ * The tag axis: which constraints a source project is held to, and the three
3
+ * verdicts those constraints can produce.
4
+ *
5
+ * Two semantics here are the opposite of the obvious implementation, and both
6
+ * are load-bearing:
7
+ *
8
+ * **No matching constraint is an ERROR, not a pass.** `findConstraintsFor`
9
+ * returning nothing means the source project's tags match no row in the table,
10
+ * and upstream reports `projectWithoutTagsCannotHaveDependencies` — its own
11
+ * comment reads "when no constrains found => error. Force the user to provision
12
+ * them." The natural reading ("no rule said no, so it's fine") inverts it, and
13
+ * every mis-tagged or untagged project then escapes the boundary silently. That
14
+ * is the exact hole this tool exists to close, so the check is spelled out at
15
+ * the call site in `./index.mjs` where the ordering is visible.
16
+ *
17
+ * **Several matching constraints are AND, not OR.** `findConstraintsFor`
18
+ * returns an ARRAY and upstream loops over all of it. A project tagged
19
+ * `type:lib scope:shared layer:domain license:internal` matches four rows of a
20
+ * table carrying one row per axis, and the dependency must satisfy every one.
21
+ * Implement OR and this engine passes imports ESLint blocks — the direction
22
+ * that turns a boundary check into decoration.
23
+ *
24
+ * The third semantic worth stating: `notDependOnLibsWithTags` is TRANSITIVE.
25
+ * It does not ask whether the imported project carries a forbidden tag, it asks
26
+ * whether any project reachable from it does — the imported project included.
27
+ */
28
+ import { tagMatches } from "./match.mjs";
29
+ import { getPath, pathExists } from "./reachability.mjs";
30
+
31
+ /** `"a", "b"` — how upstream renders a tag list inside a message. */
32
+ export function stringifyTags(tags) {
33
+ return tags.map((t) => `"${t}"`).join(", ");
34
+ }
35
+
36
+ /** A row keyed on `allSourceTags` rather than a single `sourceTag`. */
37
+ export function isComboDepConstraint(depConstraint) {
38
+ return !!depConstraint.allSourceTags;
39
+ }
40
+
41
+ /**
42
+ * The `{{sourceTag}}` a message shows for a row. A combo row prints its tags
43
+ * joined by `" and "`, which reads as `"a" and "b"` once the template's own
44
+ * quotes are around it.
45
+ */
46
+ export function constraintSourceTagLabel(constraint) {
47
+ return isComboDepConstraint(constraint)
48
+ ? constraint.allSourceTags.join('" and "')
49
+ : constraint.sourceTag;
50
+ }
51
+
52
+ /** Does this project carry `tag`, in any of the four tag dialects? */
53
+ export function hasTag(project, tag) {
54
+ return tagMatches(project.data?.tags || [], tag);
55
+ }
56
+
57
+ /** True when the project carries NONE of these tags — upstream's spelling. */
58
+ export function hasNoneOfTheseTags(project, tags) {
59
+ return tags.filter((tag) => hasTag(project, tag)).length === 0;
60
+ }
61
+
62
+ /**
63
+ * Every constraint row whose source matches this project. A combo row matches
64
+ * only when the project carries ALL of its `allSourceTags`.
65
+ *
66
+ * The result is a list on purpose — see this file's header. Callers iterate it
67
+ * and an empty list is an error, never a pass.
68
+ *
69
+ * @returns {object[]}
70
+ */
71
+ export function findConstraintsFor(depConstraints, sourceProject) {
72
+ return depConstraints.filter((constraint) =>
73
+ isComboDepConstraint(constraint)
74
+ ? constraint.allSourceTags.every((tag) => hasTag(sourceProject, tag))
75
+ : hasTag(sourceProject, constraint.sourceTag),
76
+ );
77
+ }
78
+
79
+ /**
80
+ * The `depConstraints` rows that match NO project in the graph as their
81
+ * source — the one direction a constraint can be dead in. A row whose source
82
+ * selector (`sourceTag`, or every tag of `allSourceTags` on one project)
83
+ * selects nothing never applies to any import, so everything on its axis
84
+ * passes while the config reads as enforced: the exact "a constraint matching
85
+ * nothing does not error, it approves" mode the file loading this table
86
+ * refuses shapes for but cannot see, because whether a tag is CARRIED is a
87
+ * fact about the graph, which the loader deliberately never holds.
88
+ *
89
+ * Only the SOURCE side is asked, deliberately. A row whose
90
+ * `onlyDependOnLibsWithTags` names a tag no project carries is not dead — it
91
+ * is maximally strict, since `onlyTagsViolation` fires when the target carries
92
+ * none of the permitted list, so an empty carrier set violates every
93
+ * dependency. Loud, self-correcting, and none of this function's business.
94
+ * Rows answered here go through `findConstraintsFor` itself — the same
95
+ * matcher the per-site evaluation runs — so all four tag dialects
96
+ * (`*`, `/regex/`, glob, exact) are judged by the one opinion, and a row over
97
+ * a malformed selector (refused at load before any caller reaches here) is
98
+ * skipped rather than crashed into.
99
+ *
100
+ * @param {object[]} depConstraints The validated constraint table.
101
+ * @param {object} graph The project graph `{nodes}`.
102
+ * @returns {{index: number, row: object}[]} In table order.
103
+ */
104
+ export function unmatchedConstraintRows(depConstraints, graph) {
105
+ const projects = Object.values(graph?.nodes ?? {});
106
+ return depConstraints
107
+ .map((row, index) => ({ row, index }))
108
+ .filter(({ row }) => {
109
+ if (!isPlainRow(row)) return false;
110
+ return !projects.some((project) => findConstraintsFor([row], project).length > 0);
111
+ });
112
+ }
113
+
114
+ /** @param {object} row @returns {boolean} */
115
+ function isPlainRow(row) {
116
+ return (
117
+ typeof row === "object" &&
118
+ row !== null &&
119
+ (typeof row.sourceTag === "string" ||
120
+ (Array.isArray(row.allSourceTags) && row.allSourceTags.every((t) => typeof t === "string")))
121
+ );
122
+ }
123
+
124
+ /**
125
+ * The `notDependOnLibsWithTags` entries naming a tag no project carries — the
126
+ * other direction a constraint can die in. A forbidden-target list whose every
127
+ * value names nothing bans nothing, so the row reads as enforcing an axis it
128
+ * has stopped guarding: a ban that evaporated is the silent direction itself.
129
+ * (`onlyDependOnLibsWithTags` is deliberately absent here: a permitted list
130
+ * naming nothing makes the row maximally STRICT — `onlyTagsViolation` fires
131
+ * when the target carries none of the permitted list, so an empty carrier set
132
+ * violates every dependency — which is loud, self-correcting, and none of this
133
+ * function's business.)
134
+ *
135
+ * Each entry is asked through `hasTag` — the same matcher the transitive
136
+ * verdict judges with — so all four tag dialects (`*`, `/regex/`, glob,
137
+ * exact) are answered by the one opinion.
138
+ *
139
+ * @param {object[]} depConstraints The validated constraint table.
140
+ * @param {object} graph The project graph `{nodes}`.
141
+ * @returns {{index: number, position: number, tag: string}[]} In table order.
142
+ */
143
+ export function orphanedNotDependOnTags(depConstraints, graph) {
144
+ const projects = Object.values(graph?.nodes ?? {});
145
+ const carried = (tag) => projects.some((project) => hasTag(project, tag));
146
+ /** @type {{index: number, position: number, tag: string}[]} */
147
+ const orphans = [];
148
+ depConstraints.forEach((row, index) => {
149
+ if (!isPlainRow(row)) return;
150
+ const list = row.notDependOnLibsWithTags;
151
+ if (!Array.isArray(list)) return;
152
+ list.forEach((tag, position) => {
153
+ if (typeof tag === "string" && !carried(tag)) orphans.push({ index, position, tag });
154
+ });
155
+ });
156
+ return orphans;
157
+ }
158
+
159
+ /**
160
+ * Paths from `targetProject` to every project reachable from it that carries
161
+ * one of `tags` — the target itself included, as a one-element path.
162
+ *
163
+ * This is why `notDependOnLibsWithTags` is transitive: importing a clean lib
164
+ * that itself depends on a forbidden one is a violation, and the returned paths
165
+ * are what the message prints so a reader can see the hop that did it.
166
+ */
167
+ export function findDependenciesWithTags(targetProject, tags, graph, reach) {
168
+ const reachable = Object.keys(graph.nodes).filter(
169
+ (projectName) =>
170
+ pathExists(reach, targetProject.name, projectName) &&
171
+ tags.some((tag) => hasTag(graph.nodes[projectName], tag)),
172
+ );
173
+ return reachable.map((project) =>
174
+ targetProject.name === project
175
+ ? [targetProject]
176
+ : getPath(reach, graph, targetProject.name, project),
177
+ );
178
+ }
179
+
180
+ /**
181
+ * `onlyDependOnLibsWithTags` with entries: the target must carry at least one
182
+ * of them.
183
+ *
184
+ * @returns {{messageId: string, data: object}|null}
185
+ */
186
+ export function onlyTagsViolation(constraint, targetProject) {
187
+ const tags = constraint.onlyDependOnLibsWithTags;
188
+ if (!tags || tags.length === 0) return null;
189
+ if (!hasNoneOfTheseTags(targetProject, tags)) return null;
190
+ return {
191
+ messageId: "onlyTagsConstraintViolation",
192
+ data: { sourceTag: constraintSourceTagLabel(constraint), tags: stringifyTags(tags) },
193
+ };
194
+ }
195
+
196
+ /**
197
+ * `onlyDependOnLibsWithTags: []` — an empty list, which is a rule of its own
198
+ * and not the same as having no constraint at all: it says "may depend on
199
+ * nothing that carries tags".
200
+ *
201
+ * The near-miss that must NOT fire is a target with no tags. Upstream requires
202
+ * `targetProject.data.tags.length !== 0`, so an untagged dependency is
203
+ * permitted by an empty-only row — which is consistent, since the row bans
204
+ * tagged libs specifically.
205
+ *
206
+ * @returns {{messageId: string, data: object}|null}
207
+ */
208
+ export function emptyOnlyTagsViolation(constraint, targetProject) {
209
+ const tags = constraint.onlyDependOnLibsWithTags;
210
+ if (!tags || tags.length !== 0) return null;
211
+ if ((targetProject.data?.tags || []).length === 0) return null;
212
+ return {
213
+ messageId: "emptyOnlyTagsConstraintViolation",
214
+ data: { sourceTag: constraintSourceTagLabel(constraint) },
215
+ };
216
+ }
217
+
218
+ /**
219
+ * `notDependOnLibsWithTags` — transitive, per this file's header.
220
+ *
221
+ * @returns {{messageId: string, data: object}|null}
222
+ */
223
+ export function notTagsViolation(constraint, targetProject, graph, reach) {
224
+ const tags = constraint.notDependOnLibsWithTags;
225
+ if (!tags || tags.length === 0) return null;
226
+ const projectPaths = findDependenciesWithTags(targetProject, tags, graph, reach);
227
+ if (projectPaths.length === 0) return null;
228
+ return {
229
+ messageId: "notTagsConstraintViolation",
230
+ data: {
231
+ sourceTag: constraintSourceTagLabel(constraint),
232
+ tags: stringifyTags(tags),
233
+ projects: projectPaths
234
+ .map((projectPath) => `- ${projectPath.map((p) => p.name).join(" -> ")}`)
235
+ .join("\n"),
236
+ },
237
+ };
238
+ }
@@ -0,0 +1,333 @@
1
+ /**
2
+ * The rules decided on the shape of the graph rather than on the text of the
3
+ * import: cycles, self-cycles, apps, e2e suites, buildability, lazy loading,
4
+ * and transitive package use.
5
+ *
6
+ * Three of these need a fact `@nx/enforce-module-boundaries` reads off the
7
+ * filesystem — a `module-federation.config.js`, a project's `package.json`
8
+ * `exports`, the packages a manifest declares. A rule here reads records and
9
+ * nothing else (`../rules/README.md`), so each such fact is an OPTIONAL field
10
+ * on the graph node, and **its absence fails closed**: the enforcer assumes the
11
+ * exemption does not apply and reports. A false alarm a maintainer can see and
12
+ * fix is recoverable; a boundary that quietly stops being enforced is the
13
+ * failure this tool exists to end. Each site below names which field would
14
+ * change its answer.
15
+ */
16
+ import { posix } from "node:path";
17
+
18
+ import { checkCircularPath, circularPathHasPair } from "./reachability.mjs";
19
+
20
+ /** Nx's `DependencyType.dynamic`, the edge kind a lazy-loaded lib arrives by. */
21
+ const DYNAMIC = "dynamic";
22
+
23
+ /**
24
+ * Does this project have a build target? Port of `hasBuildExecutor`, including
25
+ * the `executor !== ''` test — a target declared with an empty executor string
26
+ * does not make a library buildable.
27
+ */
28
+ export function hasBuildExecutor(project, buildTargets = ["build"]) {
29
+ const targets = project.data?.targets;
30
+ return Boolean(
31
+ targets && buildTargets.some((target) => targets[target] && targets[target].executor !== ""),
32
+ );
33
+ }
34
+
35
+ /**
36
+ * Is this app a Module Federation remote, and therefore importable?
37
+ *
38
+ * Upstream reads `module-federation.config.{js,ts}` beside the project and
39
+ * looks for an `exposes:` key. Here it is `data.mfeRemote`, supplied by whoever
40
+ * builds the graph. **Absent means false**, so an unmarked app is reported —
41
+ * the closed direction, since the alternative is exempting every app from
42
+ * `noImportsOfApps` on a fact we never checked.
43
+ */
44
+ export function appIsMFERemote(project) {
45
+ return project.data?.mfeRemote === true;
46
+ }
47
+
48
+ /**
49
+ * The entry point a file belongs to, or `undefined`. Port of upstream's
50
+ * `getEntryPoint`, over `data.entryPoints` (`{path, file}` pairs, the shape its
51
+ * `parseExports` builds from a `package.json` `exports` map) instead of over
52
+ * the filesystem.
53
+ *
54
+ * Not reproduced: upstream's `ng-package.json` fallback, which reads a file per
55
+ * directory walked. A project with no entry-point data answers `undefined`
56
+ * here, which is exactly what upstream answers for a library with no `exports`
57
+ * — the common case, and the one that keeps `noSelfCircularDependencies` armed.
58
+ */
59
+ export function entryPointOf(file, projectRoot, entryPoints) {
60
+ if (!entryPoints || entryPoints.length === 0) return undefined;
61
+ const fileEntryPoint = entryPoints.find((entry) => entry.file === file);
62
+ if (fileEntryPoint) return fileEntryPoint.file;
63
+
64
+ let parent = posix.join(file, "../");
65
+ // Upstream's loop is `while (parent !== projectRoot + '/')` with no floor: a
66
+ // file outside the project root walks past `.` and grows a `../` per turn,
67
+ // forever. The `startsWith` exit is ours — a hung enforcer reports nothing at
68
+ // all, which is the one outcome worse than a wrong answer.
69
+ while (parent !== `${projectRoot}/` && parent.startsWith(`${projectRoot}/`)) {
70
+ // `parent` always carries a trailing slash (`join(file, '../')` keeps it)
71
+ // and an entry point's `path` never does (upstream builds it with
72
+ // `joinPathFragments(projectRoot, basePath)`), so for entry points shaped
73
+ // the way upstream shapes them this comparison cannot match and the walk
74
+ // finds nothing. Reproduced rather than repaired: "no entry point" is what
75
+ // keeps `noSelfCircularDependencies` and the lazy-load check armed, so
76
+ // repairing it would open a hole rather than close one.
77
+ const entryPoint = entryPoints.find((entry) => entry.path === parent);
78
+ if (entryPoint) return entryPoint.file;
79
+ parent = posix.join(parent, "../");
80
+ }
81
+ return undefined;
82
+ }
83
+
84
+ /**
85
+ * The entry point an import resolved into, or `undefined` when it did not
86
+ * resolve to a file or the target declares no entry points. Port of
87
+ * `getSecondaryEntryPointPath`, with the analysis record's `resolved.file`
88
+ * standing in for upstream's module resolution — the resolver already ran; this
89
+ * layer never runs a second one.
90
+ */
91
+ export function secondaryEntryPointPath(resolvedFile, targetProject) {
92
+ if (!resolvedFile) return undefined;
93
+ return entryPointOf(resolvedFile, targetProject.data.root, targetProject.data?.entryPoints);
94
+ }
95
+
96
+ /**
97
+ * Is the importing file in a DIFFERENT entry point of its own project than the
98
+ * one it imported? Port of `belongsToDifferentEntryPoint` — the escape hatch
99
+ * that lets a secondary entry point import its own package by alias.
100
+ *
101
+ * With no entry-point data both sides are `undefined`, so they are equal, so
102
+ * this is false and the self-circular rule fires. Closed by default.
103
+ */
104
+ export function belongsToDifferentEntryPoint(resolvedFile, sourceFile, sourceProject) {
105
+ const importEntryPoint = secondaryEntryPointPath(resolvedFile, sourceProject);
106
+ const sourceEntryPoint = entryPointOf(
107
+ sourceFile,
108
+ sourceProject.data.root,
109
+ sourceProject.data?.entryPoints,
110
+ );
111
+ return importEntryPoint !== sourceEntryPoint;
112
+ }
113
+
114
+ /**
115
+ * The FIRST path of DYNAMIC edges from `source` to `target`, as
116
+ * `[source, ..., target]`, or `null` when none exists. The walk behind
117
+ * `hasDynamicImport`, made to return the route it took so a verdict and its
118
+ * evidence can come from the same traversal: the old predicate answered
119
+ * "yes" over a transitive chain while the message went looking for files on
120
+ * the DIRECT pair alone and printed an empty list (`noImportsOfLazyLoadedLibraries`
121
+ * saying nothing is issue #281's shape).
122
+ *
123
+ * Traversal order and cycle guard are exactly the predicate's original ones —
124
+ * edges in array order, first hit wins, `visited.indexOf` at entry with
125
+ * `[...visited, source]` handed down — because this function is that code,
126
+ * not a reimplementation of it.
127
+ *
128
+ * @returns {string[]|null}
129
+ */
130
+ export function findDynamicImportPath(graph, sourceProjectName, targetProjectName, visited = []) {
131
+ if (visited.indexOf(sourceProjectName) > -1) return null;
132
+ for (const dependency of graph.dependencies?.[sourceProjectName] ?? []) {
133
+ if (dependency.type !== DYNAMIC) continue;
134
+ if (dependency.target === targetProjectName) {
135
+ return [...visited, sourceProjectName, dependency.target];
136
+ }
137
+ const rest = findDynamicImportPath(graph, dependency.target, targetProjectName, [
138
+ ...visited,
139
+ sourceProjectName,
140
+ ]);
141
+ if (rest !== null) return rest;
142
+ }
143
+ return null;
144
+ }
145
+
146
+ /**
147
+ * Does `source` reach `target` through any chain of DYNAMIC edges? Port of
148
+ * upstream's `hasDynamicImport`, recursion and visited-list included — kept as
149
+ * its own export because the lazy-load rule reads as a boolean at every call
150
+ * site, but it now delegates to `findDynamicImportPath` rather than walking
151
+ * separately: two traversals of one graph could disagree about which chain
152
+ * fired, and then the verdict would name evidence for a route nobody walked.
153
+ */
154
+ export function hasDynamicImport(graph, sourceProjectName, targetProjectName) {
155
+ return findDynamicImportPath(graph, sourceProjectName, targetProjectName) !== null;
156
+ }
157
+
158
+ /**
159
+ * Is this external package declared as a direct dependency of the importing
160
+ * project?
161
+ *
162
+ * Upstream reads the workspace root's `package.json` and the source project's,
163
+ * checking `dependencies`, `devDependencies` and `peerDependencies`. Here it is
164
+ * `data.declaredPackages` on the source node — the union of both manifests,
165
+ * assembled by whoever builds the graph. **Absent means "cannot prove it",
166
+ * which counts as not direct**, so with `banTransitiveDependencies` on and no
167
+ * such data every external import is reported. That is deliberately noisy: the
168
+ * option is off by default, and a wall of violations sends a maintainer to
169
+ * populate the field, where a silent pass would leave the option looking
170
+ * enforced while enforcing nothing.
171
+ */
172
+ export function isDirectDependency(sourceProject, externalProject) {
173
+ const declared = sourceProject.data?.declaredPackages;
174
+ if (!declared) return false;
175
+ return declared.includes(externalProject.data.packageName);
176
+ }
177
+
178
+ /** `source → target` as one string, the key both file indexes are built on. */
179
+ const edgeKey = (source, target) => `${source}\0${target}`;
180
+
181
+ /**
182
+ * An index of which FILES carry which project-to-project edge, used only to
183
+ * render the two messages that name files.
184
+ *
185
+ * Upstream reads Nx's cached `projectFileMap`. This is derived from the import
186
+ * sites the engine was handed, so it describes exactly what was analyzed: hand
187
+ * the engine one file and the chain it prints names one file. Detection never
188
+ * depends on it — a cycle is found in the graph, not in this index — so a
189
+ * partial index shortens a message and can never hide a violation.
190
+ *
191
+ * @param {{sourceFile: string, sourceProject: string, targetProject: string|null, dynamic: boolean}[]} edges
192
+ * @returns {{any: Map<string, string[]>, dynamic: Map<string, string[]>}}
193
+ */
194
+ export function createFileDependencyIndex(edges) {
195
+ const index = { any: new Map(), dynamic: new Map() };
196
+ const push = (map, key, file) => {
197
+ const files = map.get(key);
198
+ if (!files) map.set(key, [file]);
199
+ else if (!files.includes(file)) files.push(file);
200
+ };
201
+ for (const edge of edges) {
202
+ if (!edge.targetProject || edge.targetProject === edge.sourceProject) continue;
203
+ const key = edgeKey(edge.sourceProject, edge.targetProject);
204
+ push(index.any, key, edge.sourceFile);
205
+ if (edge.dynamic) push(index.dynamic, key, edge.sourceFile);
206
+ }
207
+ return index;
208
+ }
209
+
210
+ /** The files carrying each hop of a path — one entry per consecutive pair. */
211
+ export function findFilesInCircularPath(fileIndex, circularPath) {
212
+ const chain = [];
213
+ for (let i = 0; i < circularPath.length - 1; i++) {
214
+ chain.push(fileIndex.any.get(edgeKey(circularPath[i].name, circularPath[i + 1].name)) ?? []);
215
+ }
216
+ return chain;
217
+ }
218
+
219
+ /** The files that lazy-load `target` from inside `source`. */
220
+ export function findFilesWithDynamicImports(fileIndex, sourceProjectName, targetProjectName) {
221
+ return fileIndex.dynamic.get(edgeKey(sourceProjectName, targetProjectName)) ?? [];
222
+ }
223
+
224
+ /**
225
+ * The cycle this import would close, and the message data for it — or `null`
226
+ * when there is no cycle, or when one of its hops is excused by
227
+ * `ignoredCircularDependencies`.
228
+ *
229
+ * @returns {{messageId: string, data: object}|null}
230
+ */
231
+ export function circularViolation({
232
+ reach,
233
+ graph,
234
+ sourceProject,
235
+ targetProject,
236
+ sourceFile,
237
+ fileIndex,
238
+ ignored,
239
+ }) {
240
+ // The path runs from the TARGET back to the SOURCE — see `checkCircularPath`,
241
+ // which owns that direction so this rule and the reachability layer cannot
242
+ // end up disagreeing about which way a cycle is walked.
243
+ const circularPath = checkCircularPath(reach, graph, sourceProject, targetProject);
244
+ if (circularPath.length === 0 || circularPathHasPair(circularPath, ignored)) return null;
245
+
246
+ const circularFilePath = findFilesInCircularPath(fileIndex, circularPath);
247
+ // Upstream's own spacer: indirect hops with several files print one file per
248
+ // line so a terminal does not receive one enormous line.
249
+ const spacer = " ";
250
+ return {
251
+ messageId: "noCircularDependencies",
252
+ data: {
253
+ sourceProjectName: sourceProject.name,
254
+ targetProjectName: targetProject.name,
255
+ path: circularPath.reduce((acc, v) => `${acc} -> ${v.name}`, sourceProject.name),
256
+ filePaths: circularFilePath
257
+ .map((files) =>
258
+ files.length > 1
259
+ ? `[${files.map((f) => `\n${spacer}${spacer}${f}`).join(",")}\n${spacer}]`
260
+ : // Upstream indexes `files[0]` unguarded, printing `undefined` for a
261
+ // hop with no file to blame. Ours can legitimately have none, since
262
+ // the index only knows the files it was handed.
263
+ (files[0] ?? ""),
264
+ )
265
+ .reduce((acc, files) => `${acc}\n- ${files}`, `- ${sourceFile}`),
266
+ },
267
+ };
268
+ }
269
+
270
+ /**
271
+ * `noImportsOfLazyLoadedLibraries` — a static import of a library that is also
272
+ * lazy-loaded somewhere, which would defeat the lazy loading.
273
+ *
274
+ * Only a static import can trigger it, and upstream is precise about which:
275
+ * `node.type === ImportDeclaration && importKind !== 'type'`. A `require()`
276
+ * call, a dynamic `import()`, a re-export and a type-only import are all
277
+ * exempt. Of those the analysis contract can distinguish everything except
278
+ * `require()`, which it records as `kind: "static"` like an `import` statement
279
+ * — so a `require()` of a lazy-loaded library is reported here where ESLint
280
+ * would stay silent. Failing closed, and named as a divergence rather than
281
+ * discovered later as a mismatch.
282
+ *
283
+ * Detection and evidence are two different facts and this function keeps them
284
+ * on one walk: the GRAPH decides that the target is lazy-loaded (an edge may
285
+ * come from manifest metadata no analyzer ever read), while the file index only
286
+ * describes what this run analyzed. So every outcome names something — the
287
+ * files of each hop when they are known, annotated with the chain when it took
288
+ * more than one hop, or an explicit sentence saying the graph carries the chain
289
+ * but no analyzed `import()` backs any hop of it. The empty string the old code
290
+ * rendered for a transitive hit printed "lazy-loaded in these files:" over
291
+ * nothing — indistinguishable from a clean run, which is exactly the silent
292
+ * direction this package exists to end (#281). A direct pair renders byte for
293
+ * byte as before; only the cases that used to print nothing changed.
294
+ *
295
+ * @returns {{messageId: string, data: object}|null}
296
+ */
297
+ export function lazyLoadedViolation({
298
+ graph,
299
+ sourceProject,
300
+ targetProject,
301
+ resolvedFile,
302
+ fileIndex,
303
+ }) {
304
+ const path = findDynamicImportPath(graph, sourceProject.name, targetProject.name);
305
+ if (path === null) return null;
306
+ if (secondaryEntryPointPath(resolvedFile, targetProject)) return null;
307
+ // One file list per consecutive hop, deduped in first-seen order — a file
308
+ // that carries several hops of the chain is still one file to open.
309
+ const chain = path.join(" -> ");
310
+ const seen = new Set();
311
+ const files = [];
312
+ for (let i = 0; i < path.length - 1; i++) {
313
+ for (const file of findFilesWithDynamicImports(fileIndex, path[i], path[i + 1])) {
314
+ if (!seen.has(file)) {
315
+ seen.add(file);
316
+ files.push(file);
317
+ }
318
+ }
319
+ }
320
+ const filePaths =
321
+ files.length > 0
322
+ ? path.length > 2
323
+ ? files.map((file) => `- ${file} (${chain})`).join("\n")
324
+ : files.map((file) => `- ${file}`).join("\n")
325
+ : `(the project graph carries dynamic edges along ${chain}, but no analyzed import() names any file on that path — the evidence index only knows the files this run analyzed)`;
326
+ return {
327
+ messageId: "noImportsOfLazyLoadedLibraries",
328
+ data: {
329
+ targetProjectName: targetProject.name,
330
+ filePaths,
331
+ },
332
+ };
333
+ }