@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,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The fifteen violation messages, and the renderer that fills them.
|
|
3
|
+
*
|
|
4
|
+
* Every string below is a verbatim copy of `meta.messages` in
|
|
5
|
+
* `@nx/eslint-plugin`'s `enforce-module-boundaries` rule, and every key is that
|
|
6
|
+
* rule's `messageId` spelled exactly. The ids are the contract: a differential
|
|
7
|
+
* test can put this engine's verdict beside ESLint's for the same import and
|
|
8
|
+
* compare ids, which is the only way to know the two agree rather than merely
|
|
9
|
+
* both being red. `src/rules/upstream.integration.test.mjs` reads the installed
|
|
10
|
+
* plugin's source and fails when a copy here drifts from it.
|
|
11
|
+
*
|
|
12
|
+
* Copied rather than imported, and rather than derived: this
|
|
13
|
+
* project may import Node built-ins and `typescript` only (`../../AGENTS.md`),
|
|
14
|
+
* and importing the plugin would pull `@nx/devkit` — a project graph read — into
|
|
15
|
+
* a layer whose whole point is being pure. The value is intrinsic to a fixed
|
|
16
|
+
* external contract, it lives in exactly this one place, and the integration
|
|
17
|
+
* test is what keeps the copy honest.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* `messageId` → the template ESLint would render, `{{placeholder}}`s intact.
|
|
22
|
+
*
|
|
23
|
+
* @type {Readonly<Record<string, string>>}
|
|
24
|
+
*/
|
|
25
|
+
export const MESSAGES = Object.freeze({
|
|
26
|
+
noRelativeOrAbsoluteImportsAcrossLibraries: `Projects cannot be imported by a relative or absolute path, and must begin with a npm scope`,
|
|
27
|
+
noRelativeOrAbsoluteExternals: `External resources cannot be imported using a relative or absolute path`,
|
|
28
|
+
noCircularDependencies: `Circular dependency between "{{sourceProjectName}}" and "{{targetProjectName}}" detected: {{path}}\n\nCircular file chain:\n{{filePaths}}`,
|
|
29
|
+
noSelfCircularDependencies: `Projects should use relative imports to import from other files within the same project. Use "./path/to/file" instead of import from "{{imp}}"`,
|
|
30
|
+
noImportsOfApps: "Imports of apps are forbidden",
|
|
31
|
+
noImportsOfE2e: "Imports of e2e projects are forbidden",
|
|
32
|
+
noImportOfNonBuildableLibraries:
|
|
33
|
+
"Buildable libraries cannot import or export from non-buildable libraries",
|
|
34
|
+
noImportsOfLazyLoadedLibraries: `Static imports of lazy-loaded libraries are forbidden.\n\nLibrary "{{targetProjectName}}" is lazy-loaded in these files:\n{{filePaths}}`,
|
|
35
|
+
projectWithoutTagsCannotHaveDependencies: `A project without tags matching at least one constraint cannot depend on any libraries`,
|
|
36
|
+
bannedExternalImportsViolation: `A project tagged with "{{sourceTag}}" is not allowed to import "{{imp}}"`,
|
|
37
|
+
nestedBannedExternalImportsViolation: `A project tagged with "{{sourceTag}}" is not allowed to import "{{imp}}". Nested import found at {{childProjectName}}`,
|
|
38
|
+
noTransitiveDependencies: `Only packages defined in the "package.json" can be imported. Transitive or unresolvable dependencies are not allowed.`,
|
|
39
|
+
onlyTagsConstraintViolation: `A project tagged with "{{sourceTag}}" can only depend on libs tagged with {{tags}}`,
|
|
40
|
+
emptyOnlyTagsConstraintViolation: `A project tagged with "{{sourceTag}}" cannot depend on any libs with tags`,
|
|
41
|
+
notTagsConstraintViolation: `A project tagged with "{{sourceTag}}" can not depend on libs tagged with {{tags}}\n\nViolation detected in:\n{{projects}}`,
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
/** Every `messageId` this engine can produce — the checklist, as data. */
|
|
45
|
+
export const MESSAGE_IDS = Object.freeze(Object.keys(MESSAGES));
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Renders a message the way ESLint's own reporter does: `{{key}}` (whitespace
|
|
49
|
+
* around the key tolerated) is replaced by `data[key]`, and a placeholder with
|
|
50
|
+
* no matching key is LEFT IN PLACE rather than blanked. That last part is
|
|
51
|
+
* deliberate upstream and worth keeping — a message reading `{{tags}}` tells a
|
|
52
|
+
* reader the rule forgot to pass data, where an empty string reads as a rule
|
|
53
|
+
* that found nothing to say.
|
|
54
|
+
*
|
|
55
|
+
* @param {string} messageId One of `MESSAGE_IDS`.
|
|
56
|
+
* @param {Record<string, string|number>} [data] Interpolation values.
|
|
57
|
+
* @returns {string}
|
|
58
|
+
* @throws {Error} for an unknown id — a typo in a rule must not ship a
|
|
59
|
+
* violation whose text is the id itself.
|
|
60
|
+
*/
|
|
61
|
+
export function renderMessage(messageId, data = {}) {
|
|
62
|
+
const template = MESSAGES[messageId];
|
|
63
|
+
if (template === undefined) {
|
|
64
|
+
throw new Error(
|
|
65
|
+
`archkeep: no message template for '${messageId}' — ` +
|
|
66
|
+
`ids must match @nx/enforce-module-boundaries exactly (one of ${MESSAGE_IDS.join(", ")})`,
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
return template.replace(/\{\{([^{}]+?)\}\}/gu, (placeholder, key) => {
|
|
70
|
+
const name = key.trim();
|
|
71
|
+
return name in data ? String(data[name]) : placeholder;
|
|
72
|
+
});
|
|
73
|
+
}
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Who can reach whom in the project graph — the traversal two different rules
|
|
3
|
+
* need, so it lives beside neither of them.
|
|
4
|
+
*
|
|
5
|
+
* `noCircularDependencies` asks whether a path leads from the import's target
|
|
6
|
+
* back to its source, and `notDependOnLibsWithTags` asks which tagged projects
|
|
7
|
+
* are reachable FROM the target (upstream checks the whole transitive closure,
|
|
8
|
+
* not just the project being imported — see `../rules/tags.mjs`). Both are the
|
|
9
|
+
* same reachability question, and a second copy of it in the second rule is how
|
|
10
|
+
* two answers to one question start disagreeing.
|
|
11
|
+
*
|
|
12
|
+
* Ported from `@nx/eslint-plugin`'s `utils/graph-utils`, including the exact
|
|
13
|
+
* traversal order of `getPath`: the path it returns is interpolated into the
|
|
14
|
+
* `noCircularDependencies` message, so a different-but-equally-valid path would
|
|
15
|
+
* make a differential comparison against ESLint fail on text alone.
|
|
16
|
+
*
|
|
17
|
+
* Upstream caches the matrix on a module-level `reach` object keyed by graph
|
|
18
|
+
* identity. This builds it once per `evaluate` call and hands it down instead:
|
|
19
|
+
* a module-level cache in a module advertised as pure functions is state a
|
|
20
|
+
* caller cannot see, and the tool's second surface (a language server holding
|
|
21
|
+
* several graph revisions) is exactly where that bites.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The adjacency list and full reachability matrix of a graph's PROJECT nodes.
|
|
26
|
+
* External (npm) nodes are excluded, as upstream excludes them: `adjList` only
|
|
27
|
+
* takes an edge whose target is a project node.
|
|
28
|
+
*
|
|
29
|
+
* @param {object} graph
|
|
30
|
+
* @returns {{ adjList: Record<string, string[]>, matrix: Record<string, Record<string, boolean>> }}
|
|
31
|
+
*/
|
|
32
|
+
export function buildReachability(graph) {
|
|
33
|
+
// Bound once so the membership test below asks the SAME map `nodes` was
|
|
34
|
+
// derived from, and so a graph that is only a shape (`buildReachability({})`)
|
|
35
|
+
// still answers instead of throwing.
|
|
36
|
+
const nodeMap = graph.nodes ?? {};
|
|
37
|
+
const nodes = Object.keys(nodeMap);
|
|
38
|
+
// Null-prototype for the same reason `../providers/native/graph.mjs` uses
|
|
39
|
+
// them: every key here is a project NAME, and project names come from the
|
|
40
|
+
// graph the caller built from attacker-controlled manifests — a plain `{}`
|
|
41
|
+
// would let a project literally named `__proto__` repoint the map's OWN
|
|
42
|
+
// prototype instead of adding an entry. The project then vanishes from the
|
|
43
|
+
// closure: `adjList["__proto__"]` reads the poisoned prototype (a Node
|
|
44
|
+
// object), `.filter`/`.push` are not functions, and the whole reachability
|
|
45
|
+
// walk throws `TypeError: adjList[current].filter is not a function` —
|
|
46
|
+
// exactly the diagnostic an LSP editor published in place of a boundary
|
|
47
|
+
// verdict. The matrix ROWS need the same protection as the outer maps: a
|
|
48
|
+
// row that is a plain `{}` reads `matrix[start][adj]` truthy whenever `adj`
|
|
49
|
+
// is `__proto__` (the inherited accessor answers `Object.prototype`), so the
|
|
50
|
+
// walk's `continue` skips the hop AND the write `matrix[start][__proto__]`
|
|
51
|
+
// is a no-op — one poisoned name silently cancels the whole walk through it.
|
|
52
|
+
/** @type {Record<string, string[]>} */
|
|
53
|
+
const adjList = Object.create(null);
|
|
54
|
+
/** @type {Record<string, Record<string, boolean>>} */
|
|
55
|
+
const matrix = Object.create(null);
|
|
56
|
+
for (const name of nodes) {
|
|
57
|
+
adjList[name] = [];
|
|
58
|
+
// Null-prototype rows: `matrix[name]` is keyed by project NAME too, and a
|
|
59
|
+
// project named `__proto__` must be a real, own, enumerable entry in it —
|
|
60
|
+
// the closure depends on both the read and the write behaving like any
|
|
61
|
+
// other project name (`Object.create(null)`'s absence of the inherited
|
|
62
|
+
// `__proto__` accessor is what makes the assignment add an entry instead
|
|
63
|
+
// of no-op on the prototype setter).
|
|
64
|
+
matrix[name] = Object.create(null);
|
|
65
|
+
}
|
|
66
|
+
for (const [source, dependencies] of Object.entries(graph.dependencies ?? {})) {
|
|
67
|
+
// Upstream indexes `adjList[dependency.source]` unguarded and throws on a
|
|
68
|
+
// dependency whose source is not a node. Skipping is the defensive
|
|
69
|
+
// difference: a graph half-built by a caller must not crash the enforcer
|
|
70
|
+
// into reporting nothing at all.
|
|
71
|
+
if (!adjList[source]) continue;
|
|
72
|
+
for (const dependency of dependencies ?? []) {
|
|
73
|
+
// `Object.hasOwn`, never `nodeMap[target]`: the caller's node map is
|
|
74
|
+
// usually a plain object (`JSON.parse` of `nx graph --file=`), so a
|
|
75
|
+
// truthiness test on it answers `constructor`, `toString`, `valueOf`,
|
|
76
|
+
// `hasOwnProperty` and `__proto__` from `Object.prototype` and admits an
|
|
77
|
+
// edge to a project that does not exist. That is not a quiet
|
|
78
|
+
// over-approximation here, it is a crash: `adjList` is null-prototype (by
|
|
79
|
+
// design, just above), so the phantom name gets pushed as a neighbour and
|
|
80
|
+
// then `adjList["constructor"]` reads back `undefined` on the very next
|
|
81
|
+
// hop — measured, `TypeError: adjList[current] is not iterable`, which
|
|
82
|
+
// takes down every rule that needs the closure and publishes a stack
|
|
83
|
+
// trace where a boundary verdict belonged. The `__proto__` sibling was
|
|
84
|
+
// already pinned; these four were not, because the prototype answers for
|
|
85
|
+
// them through inheritance rather than through the `__proto__` accessor.
|
|
86
|
+
if (Object.hasOwn(nodeMap, dependency.target)) adjList[source].push(dependency.target);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
// Upstream's `traverse` is recursive; this is the same depth-first closure
|
|
90
|
+
// with an explicit stack, so a deep graph cannot overflow the call stack.
|
|
91
|
+
for (const start of nodes) {
|
|
92
|
+
const stack = [start];
|
|
93
|
+
matrix[start][start] = true;
|
|
94
|
+
while (stack.length > 0) {
|
|
95
|
+
const current = stack.pop();
|
|
96
|
+
for (const adj of adjList[current]) {
|
|
97
|
+
if (matrix[start][adj]) continue;
|
|
98
|
+
matrix[start][adj] = true;
|
|
99
|
+
stack.push(adj);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return { adjList, matrix };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Is `target` reachable from `source`? A project always reaches itself, which
|
|
108
|
+
* is what makes `findDependenciesWithTags` include the imported project itself.
|
|
109
|
+
*
|
|
110
|
+
* @param {{matrix: Record<string, Record<string, boolean>>}} reach
|
|
111
|
+
* @param {string} sourceProjectName
|
|
112
|
+
* @param {string} targetProjectName
|
|
113
|
+
* @returns {boolean}
|
|
114
|
+
*/
|
|
115
|
+
export function pathExists(reach, sourceProjectName, targetProjectName) {
|
|
116
|
+
if (sourceProjectName === targetProjectName) return true;
|
|
117
|
+
return !!reach.matrix[sourceProjectName]?.[targetProjectName];
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* The nodes on a path from `sourceProjectName` to `targetProjectName`, or `[]`
|
|
122
|
+
* when none exists.
|
|
123
|
+
*
|
|
124
|
+
* Ported move for move from upstream, `queue.pop()` included — that turns the
|
|
125
|
+
* nominal breadth-first queue into a depth-first walk, and the resulting path
|
|
126
|
+
* is what the circular-dependency message prints.
|
|
127
|
+
*
|
|
128
|
+
* @returns {object[]} project nodes, source first.
|
|
129
|
+
*/
|
|
130
|
+
export function getPath(reach, graph, sourceProjectName, targetProjectName) {
|
|
131
|
+
if (sourceProjectName === targetProjectName) return [];
|
|
132
|
+
const { adjList, matrix } = reach;
|
|
133
|
+
let path = [];
|
|
134
|
+
const queue = [[sourceProjectName, path]];
|
|
135
|
+
const visited = [sourceProjectName];
|
|
136
|
+
|
|
137
|
+
while (queue.length > 0) {
|
|
138
|
+
const [current, p] = queue.pop();
|
|
139
|
+
path = [...p, current];
|
|
140
|
+
if (current === targetProjectName) break;
|
|
141
|
+
if (!adjList[current]) break;
|
|
142
|
+
adjList[current]
|
|
143
|
+
.filter((adj) => visited.indexOf(adj) === -1)
|
|
144
|
+
.filter((adj) => matrix[adj]?.[targetProjectName])
|
|
145
|
+
.forEach((adj) => {
|
|
146
|
+
visited.push(adj);
|
|
147
|
+
queue.push([adj, [...path]]);
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
return path.length > 1 ? path.map((n) => graph.nodes[n]) : [];
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* The cycle an import would close: a path from the TARGET back to the SOURCE.
|
|
155
|
+
* Empty when the import creates no cycle.
|
|
156
|
+
*
|
|
157
|
+
* The direction is the subtle half. The edge being judged is source → target;
|
|
158
|
+
* a cycle exists only if the graph already carries a way back, so upstream asks
|
|
159
|
+
* for the reverse path and never adds the new edge to the graph first.
|
|
160
|
+
*/
|
|
161
|
+
export function checkCircularPath(reach, graph, sourceProject, targetProject) {
|
|
162
|
+
// `Object.hasOwn` for the same reason `buildReachability` above uses it: this
|
|
163
|
+
// guard exists to refuse a target the graph does not contain, and a
|
|
164
|
+
// truthiness test on a plain node map answers `constructor`/`toString` and
|
|
165
|
+
// friends from `Object.prototype` — so the guard let exactly the names it was
|
|
166
|
+
// written to stop walk straight past it.
|
|
167
|
+
if (!Object.hasOwn(graph.nodes ?? {}, targetProject.name)) return [];
|
|
168
|
+
return getPath(reach, graph, targetProject.name, sourceProject.name);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Does any consecutive pair on this path appear in the ignore map? Checked
|
|
173
|
+
* pairwise rather than end to end, so ignoring one hop excuses every cycle
|
|
174
|
+
* running through it.
|
|
175
|
+
*
|
|
176
|
+
* @param {{name: string}[]} circularPath
|
|
177
|
+
* @param {Map<string, Set<string>>} ignored
|
|
178
|
+
* @returns {boolean}
|
|
179
|
+
*/
|
|
180
|
+
export function circularPathHasPair(circularPath, ignored) {
|
|
181
|
+
if (circularPath.length < 2) return false;
|
|
182
|
+
for (let i = 0; i < circularPath.length - 1; i++) {
|
|
183
|
+
if (ignored.get(circularPath[i].name)?.has(circularPath[i + 1].name)) return true;
|
|
184
|
+
}
|
|
185
|
+
return false;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Expands `ignoredCircularDependencies` pairs into the symmetric map the
|
|
190
|
+
* pairwise check reads. Both directions are recorded, because upstream records
|
|
191
|
+
* both: ignoring `[a, b]` excuses `a → b` and `b → a`.
|
|
192
|
+
*
|
|
193
|
+
* @param {[string, string][]} ignoredCircularDependencies
|
|
194
|
+
* @param {object} graph
|
|
195
|
+
* @param {(patterns: string[], nodes: object) => string[]} findMatchingProjects
|
|
196
|
+
* Injected rather than imported so this module keeps knowing only about
|
|
197
|
+
* graphs; `../config.mjs` has already rejected any pattern the matcher cannot
|
|
198
|
+
* reproduce, so a throw from here means validation was skipped.
|
|
199
|
+
* @returns {Map<string, Set<string>>}
|
|
200
|
+
*/
|
|
201
|
+
export function expandIgnoredCircularDependencies(
|
|
202
|
+
ignoredCircularDependencies,
|
|
203
|
+
graph,
|
|
204
|
+
findMatchingProjects,
|
|
205
|
+
) {
|
|
206
|
+
const allowed = new Map();
|
|
207
|
+
const add = (from, to) => {
|
|
208
|
+
if (!allowed.has(from)) allowed.set(from, new Set());
|
|
209
|
+
allowed.get(from).add(to);
|
|
210
|
+
};
|
|
211
|
+
for (const [a, b] of ignoredCircularDependencies) {
|
|
212
|
+
const setA = findMatchingProjects([a], graph.nodes);
|
|
213
|
+
const setB = findMatchingProjects([b], graph.nodes);
|
|
214
|
+
for (const projectA of setA) {
|
|
215
|
+
if (!allowed.has(projectA)) allowed.set(projectA, new Set());
|
|
216
|
+
for (const projectB of setB) add(projectA, projectB);
|
|
217
|
+
}
|
|
218
|
+
for (const projectB of setB) {
|
|
219
|
+
if (!allowed.has(projectB)) allowed.set(projectB, new Set());
|
|
220
|
+
for (const projectA of setA) add(projectB, projectA);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
return allowed;
|
|
224
|
+
}
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The rules decided on the raw import specifier — the text as written, before
|
|
3
|
+
* anything resolves it.
|
|
4
|
+
*
|
|
5
|
+
* Four of the fifteen violations live here, and they are the reason the
|
|
6
|
+
* analysis record keeps `specifier` verbatim instead of collapsing to a graph
|
|
7
|
+
* edge (`../analysis/contract.md`, "superset of a graph edge"): the projects
|
|
8
|
+
* involved can be entirely correct while the SPELLING is the violation.
|
|
9
|
+
*
|
|
10
|
+
* Upstream uses two relative-path predicates that differ by two characters, in
|
|
11
|
+
* different places, deliberately or otherwise:
|
|
12
|
+
*
|
|
13
|
+
* isRelative(s) './' or '../' — used to decide
|
|
14
|
+
* whether a specifier is a path worth resolving
|
|
15
|
+
* isRelativePath(s) '.', '..', './' or '../' — used to decide
|
|
16
|
+
* whether an unresolvable import is a path at all, and
|
|
17
|
+
* whether a self-import is spelled relatively
|
|
18
|
+
*
|
|
19
|
+
* A bare `.` or `..` is therefore a path to one and a package name to the
|
|
20
|
+
* other, and collapsing them changes which rule fires on `import x from ".."`.
|
|
21
|
+
* Only the first lives here. The second was a question about SPELLING, and
|
|
22
|
+
* spelling is per-language: `.`, `..`, `./`, `../` is JavaScript's shape, while
|
|
23
|
+
* Rust spells the same idea `crate::`/`self::`/`super::` and Python spells it
|
|
24
|
+
* `.mod`/`..pkg`. It moved to the layer that knows which language it is reading
|
|
25
|
+
* — every record now carries `spelling` (`../analysis/contract.md`), and
|
|
26
|
+
* `isRelativePath`'s text lives on as `specifierSpelling` in
|
|
27
|
+
* `../analysis/typescript.mjs`, applied to the family it was written for.
|
|
28
|
+
*
|
|
29
|
+
* `isRelative` stays because its caller below is path ARITHMETIC rather than a
|
|
30
|
+
* judgement about spelling: it joins the specifier onto the source file's
|
|
31
|
+
* directory, which only a filesystem path can survive.
|
|
32
|
+
*/
|
|
33
|
+
import { posix } from "node:path";
|
|
34
|
+
import { isBuiltin } from "node:module";
|
|
35
|
+
|
|
36
|
+
import { assertMatchableSpecifier, mapGlobToRegExp } from "./match.mjs";
|
|
37
|
+
import { findConstraintsFor } from "./tags.mjs";
|
|
38
|
+
import { pathExists } from "./reachability.mjs";
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Nx's default `workspaceLayout`. Applied when the graph does not carry one,
|
|
42
|
+
* because upstream applies exactly this default when `nx.json` omits the key —
|
|
43
|
+
* the value is intrinsic to the upstream contract, not a preference of ours, and
|
|
44
|
+
* getting it wrong changes which imports count as "absolute into another
|
|
45
|
+
* project".
|
|
46
|
+
*/
|
|
47
|
+
export const DEFAULT_WORKSPACE_LAYOUT = Object.freeze({ libsDir: "libs", appsDir: "apps" });
|
|
48
|
+
|
|
49
|
+
/** `./x` or `../x` — upstream's `isRelative`, from `runtime-lint-utils`. */
|
|
50
|
+
export function isRelative(s) {
|
|
51
|
+
return s.startsWith("./") || s.startsWith("../");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The package a specifier belongs to: `@scope/pkg` for `@scope/pkg/deep/path`,
|
|
56
|
+
* `pkg` for `pkg/deep/path`. Port of nx's `getPackageNameFromImportPath`.
|
|
57
|
+
*/
|
|
58
|
+
export function getPackageNameFromImportPath(importExpression) {
|
|
59
|
+
if (importExpression.startsWith("@")) {
|
|
60
|
+
return importExpression.split("/").slice(0, 2).join("/");
|
|
61
|
+
}
|
|
62
|
+
return importExpression.split("/")[0];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Node built-ins, including the experimental ones Nx lists explicitly.
|
|
67
|
+
*
|
|
68
|
+
* `node:module`'s `isBuiltin` is the same function Nx calls, so the answer
|
|
69
|
+
* agrees by construction rather than by a list we would have to maintain. It is
|
|
70
|
+
* a Node built-in itself, so importing it keeps this layer's "built-ins only"
|
|
71
|
+
* rule intact and adds no I/O — the check is a table lookup.
|
|
72
|
+
*
|
|
73
|
+
* It answers for NODE, and this engine also judges Go, Rust and Python. A Go
|
|
74
|
+
* import of `net/http` reduces to the package `net`, which Node also has, so it
|
|
75
|
+
* is treated as a built-in here. The only consequence is that
|
|
76
|
+
* `banTransitiveDependencies` does not fire on it — the direction that produces
|
|
77
|
+
* no false alarm, and `bannedExternalImports` still sees it (see `./index.mjs`,
|
|
78
|
+
* which synthesises an external node for an external record whose specifier
|
|
79
|
+
* names a package; a path names none and gets none).
|
|
80
|
+
*/
|
|
81
|
+
export function isBuiltinModuleImport(importExpr) {
|
|
82
|
+
const packageName = getPackageNameFromImportPath(importExpr);
|
|
83
|
+
return isBuiltin(packageName) || packageName === "node:sqlite";
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Nx's `normalizeProjectRoot`: `''` is the workspace root, trailing `/` goes. */
|
|
87
|
+
export function normalizeProjectRoot(root) {
|
|
88
|
+
const value = root === "" ? "." : root;
|
|
89
|
+
return value && value.endsWith("/") ? value.substring(0, value.length - 1) : value;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* `projectRoot → projectName`, the map every path lookup walks.
|
|
94
|
+
*
|
|
95
|
+
* @param {Record<string, {data: {root: string}}>} nodes
|
|
96
|
+
* @returns {Map<string, string>}
|
|
97
|
+
*/
|
|
98
|
+
export function createProjectRootMappings(nodes) {
|
|
99
|
+
const mappings = new Map();
|
|
100
|
+
for (const [name, node] of Object.entries(nodes)) {
|
|
101
|
+
mappings.set(normalizeProjectRoot(node.data.root), name);
|
|
102
|
+
}
|
|
103
|
+
return mappings;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* The project owning a workspace-relative path, by walking up its directories
|
|
108
|
+
* until one is a project root. Port of nx's `findProjectForPath`, with POSIX
|
|
109
|
+
* path semantics fixed in — the analysis contract states every path it emits is
|
|
110
|
+
* workspace-relative and `/`-separated, so there is no platform to detect.
|
|
111
|
+
*
|
|
112
|
+
* @returns {string|undefined} project name.
|
|
113
|
+
*/
|
|
114
|
+
export function findProjectForPath(filePath, projectRootMappings) {
|
|
115
|
+
let currentPath = filePath;
|
|
116
|
+
for (; currentPath !== posix.dirname(currentPath); currentPath = posix.dirname(currentPath)) {
|
|
117
|
+
const found = projectRootMappings.get(currentPath);
|
|
118
|
+
if (found) return found;
|
|
119
|
+
}
|
|
120
|
+
return projectRootMappings.get(currentPath);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Is this specifier an absolute path into another project — `libs/foo/bar`,
|
|
125
|
+
* `/apps/baz`? Port of `isAbsoluteImportIntoAnotherProject`.
|
|
126
|
+
*
|
|
127
|
+
* Note what it does NOT do: it never checks that the path lands in a different
|
|
128
|
+
* project than the source. Writing `libs/foo/x` from inside `libs/foo` is
|
|
129
|
+
* reported too, because the spelling is the violation.
|
|
130
|
+
*/
|
|
131
|
+
export function isAbsoluteImportIntoAnotherProject(
|
|
132
|
+
imp,
|
|
133
|
+
workspaceLayout = DEFAULT_WORKSPACE_LAYOUT,
|
|
134
|
+
) {
|
|
135
|
+
return (
|
|
136
|
+
imp.startsWith(`${workspaceLayout.libsDir}/`) ||
|
|
137
|
+
imp.startsWith(`/${workspaceLayout.libsDir}/`) ||
|
|
138
|
+
imp.startsWith(`${workspaceLayout.appsDir}/`) ||
|
|
139
|
+
imp.startsWith(`/${workspaceLayout.appsDir}/`)
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* The project a relative specifier points into, by path arithmetic alone — no
|
|
145
|
+
* extension probing, no `exports` conditions, exactly as upstream does it. A
|
|
146
|
+
* relative import is judged on where its text lands in the tree, so it needs no
|
|
147
|
+
* module resolver and this layer stays free of one.
|
|
148
|
+
*
|
|
149
|
+
* @returns {string|undefined} project name.
|
|
150
|
+
*/
|
|
151
|
+
export function getTargetProjectBasedOnRelativeImport(imp, sourceFile, projectRootMappings) {
|
|
152
|
+
if (!isRelative(imp)) return undefined;
|
|
153
|
+
const resolved = posix.normalize(posix.join(posix.dirname(sourceFile), imp));
|
|
154
|
+
// Upstream resolves against an absolute workspace root, so a specifier
|
|
155
|
+
// climbing out of the workspace clamps at `/` and then produces a nonsense
|
|
156
|
+
// relative path. Here it stays visible as a leading `..`, and nothing outside
|
|
157
|
+
// the workspace can be in a project — so there is no target, which is the
|
|
158
|
+
// same verdict by a route that cannot accidentally match a project.
|
|
159
|
+
if (resolved === ".." || resolved.startsWith("../")) return undefined;
|
|
160
|
+
return findProjectForPath(resolved, projectRootMappings);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Does this constraint ban this external import? Port of
|
|
165
|
+
* `isConstraintBanningProject`, whose three steps each hide something:
|
|
166
|
+
*
|
|
167
|
+
* 1. The constraint only speaks about imports OF THIS PACKAGE. `imp` must be
|
|
168
|
+
* the package name itself or a path under it, otherwise the row is silent —
|
|
169
|
+
* which is what makes `nestedBannedExternalImportsViolation` so hard to
|
|
170
|
+
* trigger (see `hasBannedDependencies`).
|
|
171
|
+
* 2. `bannedExternalImports` is matched with `mapGlobToRegExp` against the FULL
|
|
172
|
+
* specifier, so `@scope/pkg/*` bans the deep paths while leaving the entry
|
|
173
|
+
* point importable, and `@scope/pkg*` bans both.
|
|
174
|
+
* 3. `allowedExternalImports` is an allowlist evaluated with `.every()`: an
|
|
175
|
+
* import is banned when it matches NONE of the entries. Two consequences —
|
|
176
|
+
* an absent list bans nothing (`undefined?.every` short-circuits), and an
|
|
177
|
+
* EMPTY list `[]` bans every import of the package, because `[].every()` is
|
|
178
|
+
* `true`. The empty case reads like "no restrictions" and means the opposite.
|
|
179
|
+
*
|
|
180
|
+
* This is the door where a specifier meets a pattern the consumer wrote, and
|
|
181
|
+
* so where the specifier's LENGTH is bounded — `./match.mjs`'s
|
|
182
|
+
* `MAX_SPECIFIER_LENGTH` carries the measurement and
|
|
183
|
+
* `assertMatchableSpecifier` the reason it throws instead of returning
|
|
184
|
+
* `false`. The bound is checked before the package test rather than after,
|
|
185
|
+
* because the question it answers is not "does this row speak about this
|
|
186
|
+
* import" but "can this import be judged at all": a specifier this engine
|
|
187
|
+
* declines to match is unjudged wherever it appears, and an unjudged site
|
|
188
|
+
* reported as clean is the one outcome `../../../../AGENTS.md` ranks below a
|
|
189
|
+
* wrong answer.
|
|
190
|
+
*
|
|
191
|
+
* @returns {boolean}
|
|
192
|
+
*/
|
|
193
|
+
export function isConstraintBanningProject(externalProject, constraint, imp) {
|
|
194
|
+
assertMatchableSpecifier(imp, "import specifier judged against the constraint table");
|
|
195
|
+
const { allowedExternalImports, bannedExternalImports } = constraint;
|
|
196
|
+
const { packageName } = externalProject.data;
|
|
197
|
+
if (imp !== packageName && !imp.startsWith(`${packageName}/`)) return false;
|
|
198
|
+
if (bannedExternalImports?.some((definition) => mapGlobToRegExp(definition).test(imp))) {
|
|
199
|
+
return true;
|
|
200
|
+
}
|
|
201
|
+
return Boolean(
|
|
202
|
+
allowedExternalImports?.every(
|
|
203
|
+
(definition) => !imp.startsWith(packageName) || !mapGlobToRegExp(definition).test(imp),
|
|
204
|
+
),
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* The first constraint (matching the source project) that bans this external
|
|
210
|
+
* import, or `undefined`.
|
|
211
|
+
*
|
|
212
|
+
* Upstream re-derives the source-matching filter inline here; it is
|
|
213
|
+
* `findConstraintsFor` spelled a second way, so this calls the one
|
|
214
|
+
* implementation rather than keeping a second that could drift. `find`, not
|
|
215
|
+
* `filter`: one violation is reported, naming the first row that objects.
|
|
216
|
+
*/
|
|
217
|
+
export function hasBannedImport(sourceProject, targetProject, depConstraints, imp) {
|
|
218
|
+
return findConstraintsFor(depConstraints, sourceProject).find((constraint) =>
|
|
219
|
+
isConstraintBanningProject(targetProject, constraint, imp),
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Every external dependency reachable from `source`, including its own.
|
|
225
|
+
* Only computed when `checkNestedExternalImports` is on.
|
|
226
|
+
*
|
|
227
|
+
* @returns {{source: string, target: string, type?: string}[]}
|
|
228
|
+
*/
|
|
229
|
+
export function findTransitiveExternalDependencies(graph, reach, source) {
|
|
230
|
+
if (!graph.externalNodes) return [];
|
|
231
|
+
const externalDependencies = [];
|
|
232
|
+
// Both maps are keyed by NAME — project names in `dependencies`, package
|
|
233
|
+
// names in `externalNodes` — and both arrive as plain objects from
|
|
234
|
+
// `JSON.parse` of `nx graph --file=`, so every lookup below is an
|
|
235
|
+
// `Object.hasOwn` membership test rather than an index-and-hope. A project
|
|
236
|
+
// literally named `constructor`, `toString`, `valueOf`, `hasOwnProperty` or
|
|
237
|
+
// `__proto__` is otherwise answered by `Object.prototype`, and both reads
|
|
238
|
+
// break loudly and uselessly: `dependencies["constructor"]` yields the
|
|
239
|
+
// `Object` constructor FUNCTION, which `?? []` does not replace (a function
|
|
240
|
+
// is not nullish) and `for…of` then rejects — measured, `TypeError: function
|
|
241
|
+
// is not iterable` — while `externalNodes["toString"]` classifies a real
|
|
242
|
+
// internal project as an external package and hands it to
|
|
243
|
+
// `isConstraintBanningProject`, which destructures `.data` off
|
|
244
|
+
// `Function.prototype.toString` and throws. An enforcer that throws reports
|
|
245
|
+
// nothing at all, which `../../AGENTS.md` ranks below a wrong answer.
|
|
246
|
+
const dependencies = graph.dependencies ?? {};
|
|
247
|
+
for (const projectName of Object.keys(graph.nodes)) {
|
|
248
|
+
if (!pathExists(reach, source.name, projectName)) continue;
|
|
249
|
+
if (!Object.hasOwn(dependencies, projectName)) continue;
|
|
250
|
+
for (const dependency of dependencies[projectName] ?? []) {
|
|
251
|
+
if (Object.hasOwn(graph.externalNodes, dependency.target)) {
|
|
252
|
+
externalDependencies.push(dependency);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
return externalDependencies;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* The nested external dependencies this constraint bans, as
|
|
261
|
+
* `[externalNode, violatingSourceNode, constraint]` triples.
|
|
262
|
+
*
|
|
263
|
+
* **Read the `imp` argument carefully.** It is the specifier of the import
|
|
264
|
+
* being judged — which, at this point in the pipeline, resolves to a PROJECT,
|
|
265
|
+
* not to any of the external packages being scanned. `isConstraintBanningProject`
|
|
266
|
+
* returns false immediately unless that specifier is the nested package's name
|
|
267
|
+
* or a path under it, so this fires only where a project's import alias and a
|
|
268
|
+
* transitively-reachable package name coincide. That is upstream's behaviour in
|
|
269
|
+
* `@nx/eslint-plugin` 23.1.1, reproduced rather than corrected: this engine's
|
|
270
|
+
* contract is to agree with ESLint's verdict, and a "fixed" version here would
|
|
271
|
+
* report violations ESLint does not, breaking the parity that makes the two
|
|
272
|
+
* comparable. It is recorded as a finding instead.
|
|
273
|
+
*/
|
|
274
|
+
export function hasBannedDependencies(externalDependencies, graph, constraint, imp) {
|
|
275
|
+
// Exported, so it is reachable with a list this module did not build — the
|
|
276
|
+
// membership guard belongs here too, not only in
|
|
277
|
+
// `findTransitiveExternalDependencies` above. Same failure either way: an
|
|
278
|
+
// inherited `Object.prototype` member reaching `isConstraintBanningProject`
|
|
279
|
+
// is destructured for `.data` and throws, and a checker that throws reports
|
|
280
|
+
// nothing.
|
|
281
|
+
//
|
|
282
|
+
// BOTH maps, for the same reason and by the same test. `nodes` is read by
|
|
283
|
+
// `dependency.source` and its value becomes the violation's
|
|
284
|
+
// `childProjectName` (`./index.mjs`), so a source named `constructor` or
|
|
285
|
+
// `toString` yields a `Function` where a project node belongs and the report
|
|
286
|
+
// names `Object` — a project no workspace has — while an absent `nodes`
|
|
287
|
+
// throws on the index. A project genuinely named `constructor` is an OWN key
|
|
288
|
+
// and still answers here; only the inherited phantoms are dropped, and a
|
|
289
|
+
// phantom source names no project to report against.
|
|
290
|
+
const externalNodes = graph.externalNodes ?? {};
|
|
291
|
+
const nodes = graph.nodes ?? {};
|
|
292
|
+
return externalDependencies
|
|
293
|
+
.filter(
|
|
294
|
+
(dependency) =>
|
|
295
|
+
Object.hasOwn(externalNodes, dependency.target) &&
|
|
296
|
+
Object.hasOwn(nodes, dependency.source) &&
|
|
297
|
+
isConstraintBanningProject(externalNodes[dependency.target], constraint, imp),
|
|
298
|
+
)
|
|
299
|
+
.map((dep) => [externalNodes[dep.target], nodes[dep.source], constraint]);
|
|
300
|
+
}
|