@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,674 @@
1
+ /**
2
+ * Rust resolver — reads Cargo manifests with a real TOML parser (smol-toml),
3
+ * no `cargo` binary required.
4
+ *
5
+ * Model: one crate per Nx project (`<projectRoot>/Cargo.toml`). Edges come
6
+ * from dependency entries that resolve to another project's directory:
7
+ * - `{ path = "…" }` entries, relative to the declaring manifest;
8
+ * - `{ workspace = true }` entries, resolved through the nearest ancestor
9
+ * manifest carrying `[workspace]` (its `[workspace.dependencies]` entry
10
+ * must itself be a `path` dependency to point at a project).
11
+ * Registry (crates.io) dependencies are ignored — external nodes are out of
12
+ * scope for this plugin; only project↔project edges matter to `nx affected`.
13
+ *
14
+ * ## Source level, alongside the manifests
15
+ *
16
+ * `analyzeRust` reads `.rs` sources for what the manifests cannot show: WHICH
17
+ * file reaches another crate, on which line, through which path. A Cargo
18
+ * manifest says a crate may be used; it never says a boundary was crossed, and
19
+ * `Cargo.toml:1` is not a location a reader can act on.
20
+ *
21
+ * The crate a `use` names is the **import** name, which is not always the
22
+ * package name. Two spellings have to be reconciled, and both occur in real
23
+ * trees: Cargo replaces `-` with `_` for the identifier (`engine-core`
24
+ * is `use engine_core::…`), and a `[lib] name` overrides the package name
25
+ * outright — a Tauri desktop package declaring `[lib] name = "app_lib"` makes
26
+ * that the only spelling its own `main.rs` can use. Both sides are normalised to
27
+ * underscores before matching, and `[lib] name` wins when it is present.
28
+ *
29
+ * Known parse limits, deliberate and pinned by tests. The worst case of each
30
+ * is a spurious record naming text the file really contains — never a missed
31
+ * project, which is the standard the Go header sets:
32
+ *
33
+ * - **`use` is matched at a line start, after a `;`/`{`/`}`, or after a
34
+ * same-line attribute block**, read up to but NOT consuming the `;` that
35
+ * closes it — the path ends AT that `;` and the scan resumes there, so the
36
+ * same `;` is still available to open the next match's `(?:^|[{;}])` when a
37
+ * second `use` shares the line. Every position Rust allows a `use` statement
38
+ * starts one of those ways, and the closing `;` is never swallowed, so two
39
+ * or more `use` statements sharing one line are each read, not just the
40
+ * first. A same-line attribute's bracketed
41
+ * content is read past a balanced quoted string rather than stopping at the
42
+ * first `]`, so `#[doc = "see [x]"] use a::b;` still reaches its `use`. A
43
+ * `use` inside a raw string literal that starts its own line would be read,
44
+ * and a `;` inside a comment or string can let a `use` written there be
45
+ * read — both are the accepted spurious-record trade (text the file really
46
+ * contains), never a missed project.
47
+ * - **A `use` whose path opens with a brace group** — `use {a::b, c::d};` — is
48
+ * a LIST of paths and is read as one: each arm names its own crate at its
49
+ * head, so the statement means exactly `use a::b; use c::d;` and produces one
50
+ * record per arm, at the arm's own position. Nothing is guessed, because
51
+ * nothing is ambiguous. Only text that is not a well-formed group — braces
52
+ * that do not balance, or anything after the group's close — keeps the older
53
+ * answer: one record with `resolved: null` and a failure beside it, never a
54
+ * dropped record.
55
+ * - **Uniform paths are ambiguous and resolved toward the crate.** Since Rust
56
+ * 2018, `use foo::Bar` can name either an extern crate `foo` or a local
57
+ * `mod foo`. A first segment matching another project's crate name is read
58
+ * as that crate. A local module deliberately named after a sibling crate
59
+ * would produce a spurious record.
60
+ * - **`mod` is not an import.** A `mod` declaration names a file inside the
61
+ * same crate, so it crosses no project boundary and is never recorded.
62
+ * - **A UTF-8 BOM before a first-line statement is tolerated** (`contract.md`,
63
+ * byte tolerance): every anchor here is `^` or a character class the BOM
64
+ * fails, so an editor-written `\uFEFF` used to drop a first-line `use` and
65
+ * a first-line `extern crate` — records gone with no failure beside them.
66
+ * The BOM is blanked to a space, not stripped, so every offset stays an
67
+ * offset into the file as it sits on disk; the bare-path and fully-qualified
68
+ * forms needed nothing, their `(^|[^…])` prefix already reads the BOM as
69
+ * ordinary preceding text.
70
+ *
71
+ * **A renamed dependency IS followed, scoped to the project that renamed
72
+ * it.** `dep = { package = "real", path = "../real" }` in a project's own
73
+ * Cargo.toml makes `real`'s crate reachable from THAT project's `.rs` sources
74
+ * only under the identifier `dep` — Rust builds a crate's `extern` prelude
75
+ * from its own manifest alone, so a different project renaming something else
76
+ * to `dep`, or `dep` happening to be nobody's rename at all, has no bearing on
77
+ * what THIS project's `use dep::…` means. `renamedDepsOf` below resolves the
78
+ * rename exactly the way `resolveRustDependencies` resolves the manifest
79
+ * entry it comes from — same `path`/`workspace = true` handling — so a `use`
80
+ * naming the rename lands on the same project the graph edge already points
81
+ * at, never a second, disagreeing answer.
82
+ */
83
+ import { normalizePath, parseManifest } from "./manifest-util.mjs";
84
+ import {
85
+ emptyResult,
86
+ fileFailure,
87
+ lineStartsOf,
88
+ perWorkspace,
89
+ positionAt,
90
+ projectOwning,
91
+ trackedManifests,
92
+ } from "./source-util.mjs";
93
+
94
+ const DEP_SECTIONS = ["dependencies", "dev-dependencies", "build-dependencies"];
95
+
96
+ /** All dependency tables in a manifest: top-level plus per-target ones. */
97
+ function* depTables(manifest) {
98
+ for (const section of DEP_SECTIONS) {
99
+ if (manifest[section]) yield manifest[section];
100
+ }
101
+ for (const targetCfg of Object.values(manifest.target ?? {})) {
102
+ for (const section of DEP_SECTIONS) {
103
+ if (targetCfg?.[section]) yield targetCfg[section];
104
+ }
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Nearest ancestor dir (starting at the parent of `startDir`) whose
110
+ * Cargo.toml declares `[workspace]`; `{ dir, manifest }` or null.
111
+ */
112
+ function findWorkspaceManifest(startDir, readFile) {
113
+ let dir = startDir;
114
+ while (dir.includes("/")) {
115
+ dir = dir.slice(0, dir.lastIndexOf("/"));
116
+ const manifest = parseManifest(readFile(`${dir}/Cargo.toml`) ?? "");
117
+ if (manifest?.workspace) return { dir, manifest };
118
+ }
119
+ const manifest = parseManifest(readFile("Cargo.toml") ?? "");
120
+ return manifest?.workspace ? { dir: "", manifest } : null;
121
+ }
122
+
123
+ /**
124
+ * Static edges between Rust projects. Same contract as the Go resolver:
125
+ * `projects` [{ name, root }], `filesOf(name)`, `readFile(path)` → raw deps.
126
+ */
127
+ export function resolveRustDependencies(projects, filesOf, readFile) {
128
+ const projectByRoot = new Map();
129
+ const crates = [];
130
+ for (const project of projects) {
131
+ const manifestPath = normalizePath(project.root, "Cargo.toml");
132
+ if (!filesOf(project.name).includes(manifestPath)) continue;
133
+ const manifest = parseManifest(readFile(manifestPath) ?? "");
134
+ if (!manifest?.package?.name) continue; // workspace-only manifests are not crates
135
+ // Normalized, not raw: Nx spells the workspace-root project's `root` as
136
+ // `"."`, which `normalizePath` collapses to `""` — the same value a
137
+ // dependency pointing AT that project resolves `pathDir` to below. Keying
138
+ // on the raw `root` would leave `"."` in the map and miss every lookup.
139
+ projectByRoot.set(normalizePath(project.root, ""), project.name);
140
+ crates.push({ project, manifest, manifestPath });
141
+ }
142
+
143
+ const dependencies = [];
144
+ for (const { project, manifest, manifestPath } of crates) {
145
+ const workspace = { resolved: false, value: null }; // lazy per-crate lookup
146
+ for (const table of depTables(manifest)) {
147
+ for (const [depName, spec] of Object.entries(table)) {
148
+ if (typeof spec !== "object" || spec === null) continue;
149
+ let pathDir = null;
150
+ if (typeof spec.path === "string") {
151
+ pathDir = normalizePath(project.root, spec.path);
152
+ } else if (spec.workspace === true) {
153
+ if (!workspace.resolved) {
154
+ workspace.resolved = true;
155
+ workspace.value = findWorkspaceManifest(project.root, readFile);
156
+ }
157
+ // `[workspace.dependencies]` is keyed by the member's LOCAL name,
158
+ // the key Cargo inherits from (measured against cargo 1.96: a member
159
+ // `foo = { package = "bar", workspace = true }` fails to parse). A
160
+ // rename on an inherited dep lives in the workspace spec's own
161
+ // `package`, and `wsSpec.path` still resolves the crate.
162
+ const wsSpec = workspace.value?.manifest.workspace?.dependencies?.[depName];
163
+ if (typeof wsSpec?.path === "string") {
164
+ pathDir = normalizePath(workspace.value.dir, wsSpec.path);
165
+ }
166
+ }
167
+ // `pathDir` is `""` (falsy, but a real answer) when a dependency
168
+ // points AT the workspace-root project — `normalizePath` resolves an
169
+ // in-tree path that climbs all the way to the root as the empty
170
+ // string. `projectByRoot` is keyed the same way (normalized, not
171
+ // raw), so this matches that project regardless of whether its own
172
+ // `root` is spelled `""` or Nx's `"."`. Only `null` (no
173
+ // `path`/`workspace = true` resolved at all) means "no target".
174
+ if (pathDir === null) continue;
175
+ const target = projectByRoot.get(pathDir);
176
+ if (target && target !== project.name) {
177
+ dependencies.push({
178
+ source: project.name,
179
+ target,
180
+ sourceFile: manifestPath,
181
+ type: "static",
182
+ });
183
+ }
184
+ }
185
+ }
186
+ }
187
+ return dependencies;
188
+ }
189
+
190
+ /** Cargo's identifier spelling of a crate name: `-` and `.` become `_`. */
191
+ export function crateIdentifier(name) {
192
+ return name.replace(/[-.]/g, "_");
193
+ }
194
+
195
+ /**
196
+ * The name every `.rs` source must spell to reach a crate: `[lib] name` when
197
+ * the manifest overrides it, the package name otherwise, always as an
198
+ * identifier.
199
+ *
200
+ * @param {object} manifest A parsed Cargo.toml.
201
+ * @returns {string|null}
202
+ */
203
+ export function crateImportName(manifest) {
204
+ const declared = manifest?.lib?.name ?? manifest?.package?.name;
205
+ return typeof declared === "string" && declared !== "" ? crateIdentifier(declared) : null;
206
+ }
207
+
208
+ /**
209
+ * Crate import name → project name, over every tracked `Cargo.toml` in each
210
+ * project rather than only the one at its root — see `trackedManifests` for
211
+ * why analysis is broader here than the edge resolver above, and for the one
212
+ * project in this workspace that needs it. A workspace-only manifest declares
213
+ * no `[package]`, so the repo-root `Cargo.toml` contributes nothing.
214
+ */
215
+ const crateNamesOf = perWorkspace((workspace) => {
216
+ const byCrate = new Map();
217
+ for (const project of workspace.projects) {
218
+ for (const manifestPath of trackedManifests(workspace, project.name, "Cargo.toml")) {
219
+ const crate = crateImportName(parseManifest(workspace.readFile(manifestPath) ?? ""));
220
+ if (crate) byCrate.set(crate, project.name);
221
+ }
222
+ }
223
+ return byCrate;
224
+ });
225
+
226
+ /**
227
+ * Per-project renamed-dependency aliases — see the header's "A renamed
228
+ * dependency IS followed". Reads each project's OWN Cargo.toml dependency
229
+ * tables (the same `depTables` the edge resolver walks) for an entry carrying
230
+ * `package = "…"`, and resolves its `path`/`workspace = true` spec exactly
231
+ * the way `resolveRustDependencies` does, so the two can never disagree about
232
+ * which project a rename reaches.
233
+ *
234
+ * Scoped per project rather than folded into the single global `crateNamesOf`
235
+ * map: the alias is a fact about the IMPORTER's manifest, not the imported
236
+ * crate, and a sibling project could rename the very same dependency to a
237
+ * different local name, or not rename it at all.
238
+ *
239
+ * Only at the project root, matching `resolveRustDependencies` rather than
240
+ * `crateNamesOf`'s broader `trackedManifests` walk — a nested manifest (the
241
+ * Tauri `src-tauri/` shape) draws no graph edge to resolve a rename against
242
+ * in the first place, so there is no target here to be consistent with.
243
+ *
244
+ * @returns {Map<string, Map<string, string>>} project name -> (the identifier
245
+ * a `.rs` file spells -> the project the rename actually reaches).
246
+ */
247
+ const renamedDepsOf = perWorkspace((workspace) => {
248
+ const projectByRoot = new Map();
249
+ // Normalized, not raw — see the identical comment in
250
+ // `resolveRustDependencies` above: Nx's `"."` root spelling and `""` both
251
+ // have to land on the same map key for a rename pointing at the
252
+ // workspace-root project to resolve.
253
+ for (const project of workspace.projects) {
254
+ projectByRoot.set(normalizePath(project.root, ""), project.name);
255
+ }
256
+
257
+ const byProject = new Map();
258
+ for (const project of workspace.projects) {
259
+ const manifestPath = normalizePath(project.root, "Cargo.toml");
260
+ if (!workspace.filesOf(project.name).includes(manifestPath)) continue;
261
+ const manifest = parseManifest(workspace.readFile(manifestPath) ?? "");
262
+ if (!manifest) continue;
263
+
264
+ const aliases = new Map();
265
+ const ws = { resolved: false, value: null }; // lazy per-crate lookup, as the edge resolver
266
+ for (const table of depTables(manifest)) {
267
+ for (const [depName, spec] of Object.entries(table)) {
268
+ if (typeof spec !== "object" || spec === null) continue;
269
+ let renamed = false;
270
+ let pathDir = null;
271
+ if (typeof spec.path === "string") {
272
+ pathDir = normalizePath(project.root, spec.path);
273
+ renamed = typeof spec.package === "string";
274
+ } else if (spec.workspace === true) {
275
+ if (!ws.resolved) {
276
+ ws.resolved = true;
277
+ ws.value = findWorkspaceManifest(project.root, workspace.readFile);
278
+ }
279
+ // `[workspace.dependencies]` is keyed by the member's LOCAL name
280
+ // (`depName`), which is the key Cargo inherits from — measured
281
+ // against cargo 1.96, a member `foo = { package = "bar",
282
+ // workspace = true }` fails to parse. A rename on an inherited dep
283
+ // lives in the WORKSPACE spec (`as_real = { path = …, package =
284
+ // "real" }`), never on the member entry, which may carry no
285
+ // `package` at all.
286
+ const wsSpec = ws.value?.manifest.workspace?.dependencies?.[depName];
287
+ if (typeof wsSpec?.path === "string") {
288
+ pathDir = normalizePath(ws.value.dir, wsSpec.path);
289
+ renamed = typeof spec.package === "string" || typeof wsSpec.package === "string";
290
+ }
291
+ }
292
+ // A plain `use real::…` resolves through `crateNamesOf`; the alias
293
+ // map is only for names the local spelling does not match, so an
294
+ // entry that is not a rename contributes nothing here. `pathDir` is
295
+ // `""` (falsy, but a real answer) when the dependency points AT the
296
+ // workspace-root project — see the identical guard and its comment in
297
+ // `resolveRustDependencies` above — so the "no target" check and the
298
+ // "not a rename" check must stay two separate conditions.
299
+ if (pathDir === null) continue;
300
+ if (!renamed) continue;
301
+ const target = projectByRoot.get(pathDir);
302
+ if (target && target !== project.name) aliases.set(crateIdentifier(depName), target);
303
+ }
304
+ }
305
+ if (aliases.size > 0) byProject.set(project.name, aliases);
306
+ }
307
+ return byProject;
308
+ });
309
+
310
+ /** Path prefixes that name the crate being compiled rather than another one. */
311
+ const OWN_CRATE_ROOTS = new Set(["crate", "self", "super"]);
312
+
313
+ /**
314
+ * Is this `use` path spelled as a reference inside the file's own project —
315
+ * the `spelling.relative` bit of the analysis record (`contract.md`)?
316
+ *
317
+ * Two spellings qualify, and the second is the one a JavaScript-shaped
318
+ * predicate cannot see.
319
+ *
320
+ * 1. **`crate::`, `self::`, `super::`** — Rust's relative forms. They are what
321
+ * `./x` and `../x` are to JavaScript, and a `.rs` file that uses them has
322
+ * not left its crate at all.
323
+ * 2. **A crate name this file's OWN project declares.** One Cargo package
324
+ * compiles several crates — a `[lib]`, a `[[bin]]`, tests, examples — and a
325
+ * binary reaches its package's library by naming it (`rba_desktop_lib::run`,
326
+ * which `[lib] name` may rename outright). Nx models the package as one
327
+ * project, so source and target land on the same node; Cargo offers no other
328
+ * spelling for it, and its crate graph cannot cycle, so this is never the
329
+ * round trip out through a public alias and back in that
330
+ * `noSelfCircularDependencies` names.
331
+ *
332
+ * Nothing here is a filesystem path: a `use` path names items inside a module
333
+ * tree, so `spelling.path` is always false for Rust.
334
+ *
335
+ * @param {string|null} root The `use` path's first segment; `null` for a brace group.
336
+ * @param {{name: string}|null} owner The project owning the source file.
337
+ * @param {Map<string, string>} byCrate Crate import name → project name.
338
+ * @returns {boolean}
339
+ */
340
+ function isOwnProjectPath(root, owner, byCrate) {
341
+ if (root === null) return false;
342
+ if (OWN_CRATE_ROOTS.has(root)) return true;
343
+ return owner !== null && byCrate.get(crateIdentifier(root)) === owner.name;
344
+ }
345
+
346
+ /**
347
+ * The arms of a `use` path that opens with a brace group, each with its offset
348
+ * inside `path`.
349
+ *
350
+ * `use {a::b, c::d};` is not ambiguous and never was: the group is a list, and
351
+ * every arm is a complete path naming its own crate at its head — the
352
+ * statement means exactly `use a::b; use c::d;`. Reading it as "names no crate"
353
+ * cost every arm its record, which is what made 29 files of a real Rust
354
+ * repository report dependencies nothing could see (`scripts/coverage-real-trees.mjs`
355
+ * pins the count that found it).
356
+ *
357
+ * Splitting is done by hand rather than by a regex because commas nest:
358
+ * `{a::{b, c}, d::e}` has two top-level arms, and a comma-split would produce
359
+ * three. Depth is counted over `{}` only — a `use` path holds no other
360
+ * bracket.
361
+ *
362
+ * Returns `null` for a group whose braces do not balance, which keeps the
363
+ * loud path for text that is not a well-formed group: a half-read group is
364
+ * exactly the guess this analyzer refuses.
365
+ *
366
+ * @param {string} path The `use` path, from the first non-space to the `;`.
367
+ * @returns {{text: string, offset: number}[] | null}
368
+ */
369
+ export function braceGroupArms(path) {
370
+ const open = path.indexOf("{");
371
+ if (open === -1 || path.slice(0, open).trim() !== "") return null;
372
+ /** @type {{text: string, offset: number}[]} */
373
+ const arms = [];
374
+ let depth = 0;
375
+ let start = -1;
376
+ for (let index = open; index < path.length; index += 1) {
377
+ const character = path[index];
378
+ if (character === "{") {
379
+ depth += 1;
380
+ if (depth === 1) start = index + 1;
381
+ continue;
382
+ }
383
+ if (character === "}") {
384
+ depth -= 1;
385
+ if (depth === 0) {
386
+ arms.push({ text: path.slice(start, index), offset: start });
387
+ // Anything after the group's close is not a path this reader knows how
388
+ // to split — `use {a}::b;` is not Rust — so it is left to the caller's
389
+ // loud path rather than half-read.
390
+ if (path.slice(index + 1).trim() !== "") return null;
391
+ start = -1;
392
+ }
393
+ if (depth < 0) return null;
394
+ continue;
395
+ }
396
+ if (character === "," && depth === 1) {
397
+ arms.push({ text: path.slice(start, index), offset: start });
398
+ start = index + 1;
399
+ }
400
+ }
401
+ if (depth !== 0 || start !== -1) return null;
402
+ return arms
403
+ .map((arm) => {
404
+ // Each arm carries its own leading whitespace, so the offset moves with
405
+ // the trim: a record's column must point at the arm, not at the comma
406
+ // before it.
407
+ const lead = arm.text.length - arm.text.trimStart().length;
408
+ return { text: arm.text.trim().replace(/\s+/gu, " "), offset: arm.offset + lead };
409
+ })
410
+ .filter((arm) => arm.text !== "");
411
+ }
412
+
413
+ /**
414
+ * The crate segment a `use` path starts with, or `null` when the path opens
415
+ * with a brace group and names none.
416
+ */
417
+ export function useRootSegment(path) {
418
+ const match = /^\s*(?:::\s*)?([A-Za-z_]\w*)/.exec(path);
419
+ return match ? match[1] : null;
420
+ }
421
+
422
+ /**
423
+ * Every source-level crate reference in a `.rs` file, in source order.
424
+ *
425
+ * Four forms, matched separately and then merged: a `use` declaration, an
426
+ * `extern crate`, a fully-qualified `::crate::` path, and — only for crates
427
+ * named in `knownCrates` — a bare `crate_name::item` path written inline with
428
+ * no `use` at all. The forms overlap (`use ::serde::de;` is two of them), so a
429
+ * match inside a range already claimed by a `use` or `extern crate` is dropped
430
+ * rather than recorded twice.
431
+ *
432
+ * **Why the bare form needs the crate list.** Since Rust 2018 a crate can be
433
+ * used with no `use` line anywhere — this workspace's own `main.rs` is exactly
434
+ * that, calling `rba_desktop_lib::run()` and importing nothing. But a bare
435
+ * `Name::item` is far more often a type (`String::from`), an enum
436
+ * (`Ordering::Less`), or a local module, and telling those apart needs the
437
+ * resolver this file exists without. Matching only against crate names the
438
+ * workspace actually declares is what makes the form decidable: the crossing
439
+ * that matters is caught, and no identifier is guessed at. A crate outside the
440
+ * workspace referenced only by a bare path is therefore not recorded here —
441
+ * `resolveRustDependencies` above still reads it from `Cargo.toml`.
442
+ *
443
+ * @param {string} rustText
444
+ * @param {Set<string>} [knownCrates] Crate identifiers the workspace declares.
445
+ * @returns {{ specifier: string, root: string|null, kind: string, offset: number }[]}
446
+ */
447
+ export function parseRustUseSites(rustText, knownCrates = new Set()) {
448
+ // A UTF-8 BOM is blanked, not stripped (see the header's byte-tolerance
449
+ // bullet): same length, so every offset below stays an offset into the
450
+ // original, and `^`-anchored forms see a line that starts like any other.
451
+ const source = rustText.replace(/^\uFEFF/, " ");
452
+ const sites = [];
453
+ const claimed = [];
454
+
455
+ // `use` opens at a line start, after a same-line `;`/`{`/`}` statement
456
+ // boundary, or after a same-line `#[…]` attribute block — every position
457
+ // Rust allows one. `[{;}]` inside a string or comment can open a spurious
458
+ // match (text the file really contains), never a missed one; the `#[…]`
459
+ // token's content may itself hold a quoted string containing `]`
460
+ // (`#[doc = "see [x]"]`), so it is read past a balanced quoted string
461
+ // rather than stopping at the first `]`. The two alternatives inside that
462
+ // repeated group — a quoted string, or a single non-`]` character — are
463
+ // kept DISJOINT on `"` (the fallback is `[^"\]]`, not `[^\]]`): letting
464
+ // both branches match `"` gives the regex engine two ways to reach the
465
+ // same position for every quote character, and a `.rs` file with many `"`
466
+ // and no closing `]` then backtracks exponentially over that ambiguity
467
+ // (measured: ~285ms at 30 quote characters, seconds at ~50, `.rs` content
468
+ // is attacker-supplied per SECURITY.md). Excluding `"` from the fallback
469
+ // makes the split unambiguous — linear in input length — while still
470
+ // reading the same attribute text: `"` can now only ever be consumed by
471
+ // starting the string branch.
472
+ //
473
+ // The pattern stops at the `use`'s whitespace; the path and its terminating
474
+ // `;` are taken by `indexOf` below rather than by a `([^;]*)(?=;)` tail,
475
+ // and that is the SAME defect class as the one above rather than a
476
+ // different one. `[^;]*` cannot cross a `;`, so it is cheap while one is
477
+ // coming — but a `.rs` file with no `;` after a `use` makes it run to
478
+ // end-of-file and then backtrack one character at a time testing the
479
+ // lookahead, once per `use` start, which is quadratic in file size
480
+ // (measured on the pre-fix pattern: 22KB→16ms, 44KB→65ms, 89KB→256ms,
481
+ // 179KB→1141ms — four times the time for twice the bytes; one crafted 1MB
482
+ // file cost 17.9 SECONDS). `indexOf` finds the same terminator with no
483
+ // backtracking, and the scan windows of successive matches do not overlap,
484
+ // so the whole pass stays linear.
485
+ const usePattern =
486
+ /(?:^|[{;}])[ \t]*(?:(?:#\[(?:"(?:[^"\\]|\\.)*"|[^"\]])*\][ \t]*)+)?(pub(?:\s*\([^)]*\))?[ \t]+)?use[ \t\r\n]+/gm;
487
+ for (let m = usePattern.exec(source); m !== null; m = usePattern.exec(source)) {
488
+ const pathOffset = m.index + m[0].length;
489
+ const terminator = source.indexOf(";", pathOffset);
490
+ // No `;` anywhere after this `use` means no later `use` can be terminated
491
+ // either — every later candidate starts further along the same text — so
492
+ // the old pattern's remaining attempts were all going to fail too. It is
493
+ // the same verdict (no site), reached without re-scanning the tail once
494
+ // per candidate.
495
+ if (terminator === -1) break;
496
+ // Everything between the `use`'s whitespace and that `;`, which is what
497
+ // `[^;]*` matched: the terminator is the first `;`, so no `;` is inside.
498
+ const path = source.slice(pathOffset, terminator);
499
+ // Resume exactly where the lookahead used to leave `matchAll`: AT the
500
+ // `;`, never past it, so it is still there — unclaimed — for a second
501
+ // `use` sharing the same line to open its own match against.
502
+ usePattern.lastIndex = terminator;
503
+ const lead = path.length - path.trimStart().length;
504
+ // A use path may wrap across lines inside a brace group. The record is
505
+ // printed in `file:line:column: specifier` reports, so line breaks are
506
+ // collapsed — Rust has no single-token module specifier to keep verbatim.
507
+ const specifier = path.trim().replace(/\s+/g, " ");
508
+ const kind = m[1] ? "re-export" : "static";
509
+ // A path opening with a brace group is a LIST of paths, and each arm names
510
+ // its own crate — see `braceGroupArms`. One site per arm, at the arm's own
511
+ // position, so a report sends a reader to the import they have to change.
512
+ // `null` back from the splitter means the text is not a well-formed group,
513
+ // and the single `root: null` site below keeps that loud.
514
+ const arms = braceGroupArms(path);
515
+ if (arms !== null) {
516
+ for (const arm of arms) {
517
+ sites.push({
518
+ specifier: arm.text,
519
+ root: useRootSegment(arm.text),
520
+ kind,
521
+ offset: pathOffset + arm.offset,
522
+ });
523
+ }
524
+ claimed.push([m.index, terminator]);
525
+ continue;
526
+ }
527
+ sites.push({
528
+ specifier,
529
+ root: useRootSegment(path),
530
+ kind,
531
+ offset: pathOffset + lead,
532
+ });
533
+ claimed.push([m.index, terminator]);
534
+ }
535
+
536
+ for (const m of source.matchAll(/^[ \t]*(?:pub[ \t]+)?extern[ \t]+crate[ \t]+([A-Za-z_]\w*)/gm)) {
537
+ sites.push({
538
+ specifier: m[1],
539
+ root: m[1],
540
+ kind: "static",
541
+ offset: m.index + m[0].lastIndexOf(m[1]),
542
+ });
543
+ claimed.push([m.index, m.index + m[0].length]);
544
+ }
545
+
546
+ // Is `offset` outside every range a `use` or an `extern crate` already
547
+ // claimed? Answered by binary search over the ranges sorted by start, not by
548
+ // testing all of them: a `.rs` file pairing N `use` statements with N
549
+ // fully-qualified `::crate::` calls — ordinary Rust, not a crafted file —
550
+ // otherwise pays N*N range tests, measured at 11.4ms for 55KB, 32ms for
551
+ // 110KB and 123ms for 220KB, the same four-times-for-twice-the-bytes shape
552
+ // the `use` scan above was fixed for.
553
+ //
554
+ // The two passes' ranges are each ascending and disjoint, but a range from
555
+ // one CAN contain a range from the other (an `extern crate` line sitting
556
+ // inside a `use` group that wraps across lines), so containment is decided
557
+ // against a running maximum of the ends rather than against the end of the
558
+ // last range that starts early enough. That is exactly what the `some()`
559
+ // asked: some range starts at or before the offset AND reaches past it.
560
+ claimed.sort((a, b) => a[0] - b[0]);
561
+ /** `reach[i]` — the furthest end among `claimed[0..i]`. */
562
+ const reach = [];
563
+ for (let i = 0; i < claimed.length; i++) {
564
+ reach.push(i === 0 ? claimed[i][1] : Math.max(reach[i - 1], claimed[i][1]));
565
+ }
566
+ const unclaimed = (offset) => {
567
+ let low = 0;
568
+ let high = claimed.length - 1;
569
+ let last = -1; // the last range starting at or before `offset`
570
+ while (low <= high) {
571
+ const mid = (low + high) >> 1;
572
+ if (claimed[mid][0] <= offset) {
573
+ last = mid;
574
+ low = mid + 1;
575
+ } else {
576
+ high = mid - 1;
577
+ }
578
+ }
579
+ return last === -1 || reach[last] <= offset;
580
+ };
581
+
582
+ for (const m of source.matchAll(/(^|[^:\w])::([A-Za-z_]\w*)::/gm)) {
583
+ const offset = m.index + m[1].length;
584
+ if (!unclaimed(offset)) continue;
585
+ sites.push({ specifier: `::${m[2]}::`, root: m[2], kind: "static", offset });
586
+ }
587
+
588
+ if (knownCrates.size > 0) {
589
+ for (const m of source.matchAll(/(^|[^:\w.])([A-Za-z_]\w*)::/gm)) {
590
+ const offset = m.index + m[1].length;
591
+ if (!knownCrates.has(crateIdentifier(m[2])) || !unclaimed(offset)) continue;
592
+ sites.push({ specifier: `${m[2]}::`, root: m[2], kind: "static", offset });
593
+ }
594
+ }
595
+
596
+ return sites.sort((a, b) => a.offset - b.offset);
597
+ }
598
+
599
+ /**
600
+ * Analyzes one `.rs` file.
601
+ *
602
+ * `file` is always `null`: a Rust path names an item inside a crate, and which
603
+ * `.rs` file defines it is a question only the compiler's module tree can
604
+ * answer. `crate::`/`self::`/`super::` resolve to the file's own project —
605
+ * intra-project imports are recorded, not dropped (`contract.md`) — and
606
+ * `isOwnProjectPath` above states which spellings reach the file's own project
607
+ * without leaving it, which is the fact the self-circular rule reads.
608
+ *
609
+ * @param {{ sourceFile: string, text: string, workspace: object }} request
610
+ * @returns {{ imports: object[], failures: object[] }}
611
+ */
612
+ export function analyzeRust({ sourceFile, text, workspace }) {
613
+ const result = emptyResult();
614
+ try {
615
+ const byCrate = crateNamesOf(workspace);
616
+ const owner = projectOwning(workspace.projects, sourceFile);
617
+ // A rename is legible only inside the project whose OWN manifest declares
618
+ // it — see the header's "A renamed dependency IS followed".
619
+ const ownAliases = owner ? renamedDepsOf(workspace).get(owner.name) : undefined;
620
+ const knownCrates = ownAliases
621
+ ? new Set([...byCrate.keys(), ...ownAliases.keys()])
622
+ : new Set(byCrate.keys());
623
+
624
+ // One line-start index for the whole file, built here and handed to every
625
+ // site: a `.rs` file with thousands of `use` statements otherwise pays a
626
+ // rescan of the file per site (`source-util.mjs`'s `lineStartsOf`).
627
+ const lineStarts = lineStartsOf(text);
628
+ for (const site of parseRustUseSites(text, knownCrates)) {
629
+ const { line, column } = positionAt(text, site.offset, lineStarts);
630
+ let resolved = null;
631
+ if (site.root === null) {
632
+ result.failures.push({
633
+ sourceFile,
634
+ line,
635
+ column,
636
+ reason: `'use ${site.specifier}' opens with a brace group, so it names no crate to resolve`,
637
+ });
638
+ } else if (OWN_CRATE_ROOTS.has(site.root)) {
639
+ resolved = {
640
+ target: owner?.name ?? null,
641
+ file: null,
642
+ external: owner === null,
643
+ packageName: null,
644
+ };
645
+ } else {
646
+ const identifier = crateIdentifier(site.root);
647
+ const target = byCrate.get(identifier) ?? ownAliases?.get(identifier) ?? null;
648
+ resolved = {
649
+ target,
650
+ file: null,
651
+ external: target === null,
652
+ // The identifier spelling, which is the only one a source states.
653
+ // A Cargo package name that hyphenates cannot be recovered from it,
654
+ // so a `bannedExternalImports` glob is written against this form.
655
+ packageName: target === null ? site.root : null,
656
+ };
657
+ }
658
+ result.imports.push({
659
+ sourceFile,
660
+ line,
661
+ column,
662
+ specifier: site.specifier,
663
+ kind: site.kind,
664
+ spelling: { path: false, relative: isOwnProjectPath(site.root, owner, byCrate) },
665
+ resolved,
666
+ });
667
+ }
668
+ } catch (cause) {
669
+ result.failures.push(
670
+ fileFailure(sourceFile, `Rust analysis failed: ${cause?.message ?? cause}`),
671
+ );
672
+ }
673
+ return result;
674
+ }