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