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