@ecoma-io/archkeep 0.15.0 → 0.16.1

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 (46) hide show
  1. package/README.md +3 -3
  2. package/cli.mjs +126 -4
  3. package/commands.mjs +6 -0
  4. package/lsp.mjs +15 -2
  5. package/package.json +6 -2
  6. package/src/analysis/analyze.mjs +15 -0
  7. package/src/analysis/contract.md +36 -18
  8. package/src/analysis/csharp.mjs +514 -0
  9. package/src/analysis/dotnet/csproj.mjs +380 -0
  10. package/src/analysis/dotnet/mask.mjs +178 -0
  11. package/src/analysis/dotnet/namespaces.mjs +172 -0
  12. package/src/analysis/dotnet/resolve.mjs +89 -0
  13. package/src/analysis/go.mjs +303 -5
  14. package/src/analysis/java.mjs +329 -0
  15. package/src/analysis/jvm/gradle.mjs +545 -0
  16. package/src/analysis/jvm/mask.mjs +170 -0
  17. package/src/analysis/jvm/maven.mjs +612 -0
  18. package/src/analysis/jvm/packages.mjs +209 -0
  19. package/src/analysis/jvm/resolve.mjs +139 -0
  20. package/src/analysis/kotlin.mjs +210 -0
  21. package/src/analysis/manifest-util.mjs +30 -0
  22. package/src/analysis/python.mjs +3 -2
  23. package/src/analysis/registry.mjs +11 -0
  24. package/src/analysis/rust.mjs +171 -17
  25. package/src/analysis/source-util.mjs +155 -6
  26. package/src/analysis/typescript.mjs +9 -2
  27. package/src/commands/context.mjs +84 -14
  28. package/src/commands/provenance.mjs +7 -44
  29. package/src/commands/rules.mjs +775 -0
  30. package/src/governance/profile-registry.mjs +0 -1
  31. package/src/graph/create-dependencies.mjs +138 -15
  32. package/src/lsp/diagnose.mjs +1 -1
  33. package/src/lsp/server.mjs +97 -1
  34. package/src/lsp/workspace-index.mjs +106 -15
  35. package/src/options.mjs +30 -7
  36. package/src/process.mjs +10 -1
  37. package/src/providers/moon.mjs +287 -36
  38. package/src/providers/native/differential.fixtures.mjs +32 -6
  39. package/src/providers/native/discover.mjs +83 -4
  40. package/src/providers/native/graph.mjs +58 -0
  41. package/src/providers/native/model.mjs +59 -1
  42. package/src/rules/index.mjs +21 -6
  43. package/src/rules/reachability.mjs +2 -0
  44. package/src/rules/tags.mjs +7 -5
  45. package/src/rules/topology.mjs +5 -3
  46. package/src/workspace.mjs +115 -23
@@ -44,6 +44,16 @@
44
44
  * and a `;` inside a comment or string can let a `use` written there be
45
45
  * read — both are the accepted spurious-record trade (text the file really
46
46
  * contains), never a missed project.
47
+ * - **A `use` no `;` ever terminates is a whole-file failure (#419).** A file
48
+ * truncated inside a `use` — a failed write, a merge marker left mid-import —
49
+ * used to parse as importing nothing, byte-for-byte identical to a file that
50
+ * imports nothing, with a clean verdict over the hole. The scan already meets
51
+ * the condition when it breaks (no `;` after an opener), so it reports the
52
+ * opener's offset and `analyzeRust` records the whole-file failure from it.
53
+ * A `;` from unrelated code below — a later statement, a comment — still
54
+ * terminates the scan the way it always has, and the path text the two
55
+ * together bridge is the documented spurious-record trade above, not a new
56
+ * failure.
47
57
  * - **A `use` whose path opens with a brace group** — `use {a::b, c::d};` — is
48
58
  * a LIST of paths and is read as one: each arm names its own crate at its
49
59
  * head, so the statement means exactly `use a::b; use c::d;` and produces one
@@ -79,6 +89,17 @@
79
89
  * entry it comes from — same `path`/`workspace = true` handling — so a `use`
80
90
  * naming the rename lands on the same project the graph edge already points
81
91
  * at, never a second, disagreeing answer.
92
+ *
93
+ * **An unreadable Cargo.toml refuses, it does not skip (#405).** Every reader
94
+ * here — the edge resolver, `crateNamesOf`, `renamedDepsOf` — distinguishes a
95
+ * manifest that is absent from one it cannot read (`trackedManifests` and the
96
+ * per-project `filesOf` check settle absence first), and the unreadable state
97
+ * is a whole-file failure naming the manifest plus a `refuseUnreadTree` throw
98
+ * in the graph resolver, the same two faces the Maven, Gradle and .csproj
99
+ * readers have (`./source-util.mjs`). The empty parse it used to be read as
100
+ * would drop the project's own identity from every map while the run stayed
101
+ * clean. `rustManifestFailures` is the analysis-side funnel
102
+ * `src/commands/context.mjs` spreads, so `check` exits 3 rather than 0.
82
103
  */
83
104
  import { normalizePath, parseManifest } from "./manifest-util.mjs";
84
105
  import {
@@ -88,6 +109,7 @@ import {
88
109
  perWorkspace,
89
110
  positionAt,
90
111
  projectOwning,
112
+ refuseUnreadTree,
91
113
  trackedManifests,
92
114
  } from "./source-util.mjs";
93
115
 
@@ -123,14 +145,40 @@ function findWorkspaceManifest(startDir, readFile) {
123
145
  /**
124
146
  * Static edges between Rust projects. Same contract as the Go resolver:
125
147
  * `projects` [{ name, root }], `filesOf(name)`, `readFile(path)` → raw deps.
148
+ *
149
+ * A Cargo.toml the tree tracks but the read cannot produce is not parsed as
150
+ * the empty string (#405): the empty parse names no crate, and the project
151
+ * would leave `projectByRoot` in silence — every path dependency pointing at
152
+ * it would resolve to no target, and the edge would be dropped as if none was
153
+ * declared. The read failure is recorded and the whole graph is refused
154
+ * (`refuseUnreadTree`, #364's posture — `../graph/create-dependencies.mjs`'s
155
+ * header owns the boundary): a manifest is an identity anchor, so one
156
+ * unreadable entry corrupts resolution for every project that names it. A
157
+ * manifest that READS but declares no `[package]` is different — the content
158
+ * is known and genuinely names no crate (a workspace-only manifest), so that
159
+ * project still contributes nothing, as before.
160
+ *
161
+ * @throws {Error} when any tracked root Cargo.toml could not be read, naming
162
+ * each one.
126
163
  */
127
164
  export function resolveRustDependencies(projects, filesOf, readFile) {
128
165
  const projectByRoot = new Map();
129
166
  const crates = [];
167
+ const unreadable = [];
130
168
  for (const project of projects) {
131
169
  const manifestPath = normalizePath(project.root, "Cargo.toml");
132
170
  if (!filesOf(project.name).includes(manifestPath)) continue;
133
- const manifest = parseManifest(readFile(manifestPath) ?? "");
171
+ const text = readFile(manifestPath);
172
+ if (text === null) {
173
+ unreadable.push(
174
+ fileFailure(
175
+ manifestPath,
176
+ "could not be read — the project's crate name is unknown, so imports reaching it cannot be judged",
177
+ ),
178
+ );
179
+ continue;
180
+ }
181
+ const manifest = parseManifest(text);
134
182
  if (!manifest?.package?.name) continue; // workspace-only manifests are not crates
135
183
  // Normalized, not raw: Nx spells the workspace-root project's `root` as
136
184
  // `"."`, which `normalizePath` collapses to `""` — the same value a
@@ -139,6 +187,7 @@ export function resolveRustDependencies(projects, filesOf, readFile) {
139
187
  projectByRoot.set(normalizePath(project.root, ""), project.name);
140
188
  crates.push({ project, manifest, manifestPath });
141
189
  }
190
+ refuseUnreadTree("the Rust crate map", unreadable);
142
191
 
143
192
  const dependencies = [];
144
193
  for (const { project, manifest, manifestPath } of crates) {
@@ -211,16 +260,34 @@ export function crateImportName(manifest) {
211
260
  * why analysis is broader here than the edge resolver above, and for the one
212
261
  * project in this workspace that needs it. A workspace-only manifest declares
213
262
  * no `[package]`, so the repo-root `Cargo.toml` contributes nothing.
263
+ *
264
+ * A tracked manifest the read cannot produce is recorded as a whole-file
265
+ * failure naming it rather than parsed as the empty string (#405): the empty
266
+ * parse names no crate, and the project would leave the crate map in silence —
267
+ * every `use` of it then resolved `external: true`, indistinguishable from a
268
+ * registry import. The failure is workspace-scoped, the way the Python and JVM
269
+ * manifest readers record theirs.
214
270
  */
215
271
  const crateNamesOf = perWorkspace((workspace) => {
216
272
  const byCrate = new Map();
273
+ const failures = [];
217
274
  for (const project of workspace.projects) {
218
275
  for (const manifestPath of trackedManifests(workspace, project.name, "Cargo.toml")) {
219
- const crate = crateImportName(parseManifest(workspace.readFile(manifestPath) ?? ""));
276
+ const text = workspace.readFile(manifestPath);
277
+ if (text === null) {
278
+ failures.push(
279
+ fileFailure(
280
+ manifestPath,
281
+ "could not be read — the project's crate name is unknown, so imports reaching it cannot be judged",
282
+ ),
283
+ );
284
+ continue;
285
+ }
286
+ const crate = crateImportName(parseManifest(text));
220
287
  if (crate) byCrate.set(crate, project.name);
221
288
  }
222
289
  }
223
- return byCrate;
290
+ return { byCrate, failures };
224
291
  });
225
292
 
226
293
  /**
@@ -241,8 +308,10 @@ const crateNamesOf = perWorkspace((workspace) => {
241
308
  * Tauri `src-tauri/` shape) draws no graph edge to resolve a rename against
242
309
  * in the first place, so there is no target here to be consistent with.
243
310
  *
244
- * @returns {Map<string, Map<string, string>>} project name -> (the identifier
245
- * a `.rs` file spells -> the project the rename actually reaches).
311
+ * @returns {{byProject: Map<string, Map<string, string>>, failures: object[]}}
312
+ * `byProject`: project name -> (the identifier a `.rs` file spells -> the
313
+ * project the rename actually reaches). `failures`: one whole-file failure
314
+ * per tracked root manifest the read could not produce (#405).
246
315
  */
247
316
  const renamedDepsOf = perWorkspace((workspace) => {
248
317
  const projectByRoot = new Map();
@@ -255,10 +324,21 @@ const renamedDepsOf = perWorkspace((workspace) => {
255
324
  }
256
325
 
257
326
  const byProject = new Map();
327
+ const failures = [];
258
328
  for (const project of workspace.projects) {
259
329
  const manifestPath = normalizePath(project.root, "Cargo.toml");
260
330
  if (!workspace.filesOf(project.name).includes(manifestPath)) continue;
261
- const manifest = parseManifest(workspace.readFile(manifestPath) ?? "");
331
+ const text = workspace.readFile(manifestPath);
332
+ if (text === null) {
333
+ failures.push(
334
+ fileFailure(
335
+ manifestPath,
336
+ "could not be read — the project's crate name is unknown, so imports reaching it cannot be judged",
337
+ ),
338
+ );
339
+ continue;
340
+ }
341
+ const manifest = parseManifest(text);
262
342
  if (!manifest) continue;
263
343
 
264
344
  const aliases = new Map();
@@ -304,9 +384,33 @@ const renamedDepsOf = perWorkspace((workspace) => {
304
384
  }
305
385
  if (aliases.size > 0) byProject.set(project.name, aliases);
306
386
  }
307
- return byProject;
387
+ return { byProject, failures };
308
388
  });
309
389
 
390
+ /**
391
+ * The unreadable-manifest failures of this workspace's Rust readers, as the
392
+ * run's failure list wants them (`../contract.md`). Both builders above read
393
+ * the same root manifests — the root project's `Cargo.toml` is walked by
394
+ * `crateNamesOf`'s `trackedManifests` and by `renamedDepsOf`'s root-only read
395
+ * — so the same unreadable file would be recorded twice without the dedupe.
396
+ *
397
+ * @param {object} workspace
398
+ * @returns {object[]} `fileFailure` shapes (`../analysis/source-util.mjs`).
399
+ */
400
+ export function rustManifestFailures(workspace) {
401
+ const seen = new Set();
402
+ const failures = [];
403
+ for (const failure of [
404
+ ...crateNamesOf(workspace).failures,
405
+ ...renamedDepsOf(workspace).failures,
406
+ ]) {
407
+ if (seen.has(failure.sourceFile)) continue;
408
+ seen.add(failure.sourceFile);
409
+ failures.push(failure);
410
+ }
411
+ return failures;
412
+ }
413
+
310
414
  /** Path prefixes that name the crate being compiled rather than another one. */
311
415
  const OWN_CRATE_ROOTS = new Set(["crate", "self", "super"]);
312
416
 
@@ -330,7 +434,8 @@ const OWN_CRATE_ROOTS = new Set(["crate", "self", "super"]);
330
434
  * `noSelfCircularDependencies` names.
331
435
  *
332
436
  * Nothing here is a filesystem path: a `use` path names items inside a module
333
- * tree, so `spelling.path` is always false for Rust.
437
+ * tree, so `spelling.path` is always false for Rust and `spelling.namesOnly`
438
+ * always true — a crate name is a name, never a path into a project (#376).
334
439
  *
335
440
  * @param {string|null} root The `use` path's first segment; `null` for a brace group.
336
441
  * @param {{name: string}|null} owner The project owning the source file.
@@ -442,15 +547,31 @@ export function useRootSegment(path) {
442
547
  *
443
548
  * @param {string} rustText
444
549
  * @param {Set<string>} [knownCrates] Crate identifiers the workspace declares.
445
- * @returns {{ specifier: string, root: string|null, kind: string, offset: number }[]}
550
+ * @param {{ returnMetrics?: boolean }} [options] `returnMetrics: true` adds
551
+ * `binarySearchIterations` \u2014 how many comparisons the claimed-range lookups
552
+ * below made \u2014 so a regression test can budget in operations rather than
553
+ * wall-clock time (#359). Production callers omit it and pay one increment
554
+ * per comparison for the counter, nothing else.
555
+ * @returns {{ sites: { specifier: string, root: string|null, kind: string, offset: number }[], unterminatedUseAt: number|null, binarySearchIterations?: number }}
556
+ * `unterminatedUseAt` is the offset of the first `use` opener no `;` ever
557
+ * terminates \u2014 the shape a truncated file takes (#419) \u2014 or `null` when
558
+ * every opener reached one. `analyzeRust` turns a non-null offset into a
559
+ * whole-file failure, which is what keeps the empty `sites` that shape
560
+ * produces from reading as a clean file.
446
561
  */
447
- export function parseRustUseSites(rustText, knownCrates = new Set()) {
562
+ export function parseRustUseSites(rustText, knownCrates = new Set(), options = {}) {
563
+ const { returnMetrics = false } = options;
564
+ /** The offset of the first `use` opener no `;` terminates, `null` before one is seen. */
565
+ let unterminatedUseAt = null;
448
566
  // A UTF-8 BOM is blanked, not stripped (see the header's byte-tolerance
449
567
  // bullet): same length, so every offset below stays an offset into the
450
568
  // original, and `^`-anchored forms see a line that starts like any other.
451
569
  const source = rustText.replace(/^\uFEFF/, " ");
452
570
  const sites = [];
453
571
  const claimed = [];
572
+ // Counted per comparison in `unclaimed` below; reported only under
573
+ // `returnMetrics`, never read otherwise.
574
+ let binarySearchIterations = 0;
454
575
 
455
576
  // `use` opens at a line start, after a same-line `;`/`{`/`}` statement
456
577
  // boundary, or after a same-line `#[…]` attribute block — every position
@@ -491,8 +612,15 @@ export function parseRustUseSites(rustText, knownCrates = new Set()) {
491
612
  // either — every later candidate starts further along the same text — so
492
613
  // the old pattern's remaining attempts were all going to fail too. It is
493
614
  // the same verdict (no site), reached without re-scanning the tail once
494
- // per candidate.
495
- if (terminator === -1) break;
615
+ // per candidate. The offset is what keeps the verdict from reading as a
616
+ // claim: a `use` the file truncates before its `;` is the shape a failed
617
+ // write or a merge marker takes (#419), and `analyzeRust` records it as a
618
+ // whole-file failure so `check` reports the run incomplete instead of
619
+ // calling a file that imports nothing clean.
620
+ if (terminator === -1) {
621
+ unterminatedUseAt = m.index;
622
+ break;
623
+ }
496
624
  // Everything between the `use`'s whitespace and that `;`, which is what
497
625
  // `[^;]*` matched: the terminator is the first `;`, so no `;` is inside.
498
626
  const path = source.slice(pathOffset, terminator);
@@ -568,6 +696,7 @@ export function parseRustUseSites(rustText, knownCrates = new Set()) {
568
696
  let high = claimed.length - 1;
569
697
  let last = -1; // the last range starting at or before `offset`
570
698
  while (low <= high) {
699
+ binarySearchIterations++;
571
700
  const mid = (low + high) >> 1;
572
701
  if (claimed[mid][0] <= offset) {
573
702
  last = mid;
@@ -593,7 +722,12 @@ export function parseRustUseSites(rustText, knownCrates = new Set()) {
593
722
  }
594
723
  }
595
724
 
596
- return sites.sort((a, b) => a.offset - b.offset);
725
+ const sorted = sites.sort((a, b) => a.offset - b.offset);
726
+ const result = { sites: sorted, unterminatedUseAt };
727
+ if (returnMetrics) {
728
+ result.binarySearchIterations = binarySearchIterations;
729
+ }
730
+ return result;
597
731
  }
598
732
 
599
733
  /**
@@ -612,11 +746,11 @@ export function parseRustUseSites(rustText, knownCrates = new Set()) {
612
746
  export function analyzeRust({ sourceFile, text, workspace }) {
613
747
  const result = emptyResult();
614
748
  try {
615
- const byCrate = crateNamesOf(workspace);
749
+ const { byCrate } = crateNamesOf(workspace);
616
750
  const owner = projectOwning(workspace.projects, sourceFile);
617
751
  // A rename is legible only inside the project whose OWN manifest declares
618
752
  // it — see the header's "A renamed dependency IS followed".
619
- const ownAliases = owner ? renamedDepsOf(workspace).get(owner.name) : undefined;
753
+ const ownAliases = owner ? renamedDepsOf(workspace).byProject.get(owner.name) : undefined;
620
754
  const knownCrates = ownAliases
621
755
  ? new Set([...byCrate.keys(), ...ownAliases.keys()])
622
756
  : new Set(byCrate.keys());
@@ -625,7 +759,23 @@ export function analyzeRust({ sourceFile, text, workspace }) {
625
759
  // site: a `.rs` file with thousands of `use` statements otherwise pays a
626
760
  // rescan of the file per site (`source-util.mjs`'s `lineStartsOf`).
627
761
  const lineStarts = lineStartsOf(text);
628
- for (const site of parseRustUseSites(text, knownCrates)) {
762
+ const useSites = parseRustUseSites(text, knownCrates);
763
+ const sites = useSites.sites;
764
+ // A `use` the file truncates before its `;` used to parse as importing
765
+ // nothing, with no failure beside the empty result — the clean verdict
766
+ // over it was the bug (#419). The whole-file shape is what turns the
767
+ // verdict loud: `check` counts the file toward `unchecked` and refuses to
768
+ // call the run complete, instead of reporting a hole as a clean file.
769
+ if (useSites.unterminatedUseAt !== null) {
770
+ result.failures.push(
771
+ fileFailure(
772
+ sourceFile,
773
+ "a `use` statement opens and never terminates — the file is truncated or malformed, " +
774
+ `so its imports cannot be read (line ${positionAt(text, useSites.unterminatedUseAt, lineStarts).line})`,
775
+ ),
776
+ );
777
+ }
778
+ for (const site of sites) {
629
779
  const { line, column } = positionAt(text, site.offset, lineStarts);
630
780
  let resolved = null;
631
781
  if (site.root === null) {
@@ -661,7 +811,11 @@ export function analyzeRust({ sourceFile, text, workspace }) {
661
811
  column,
662
812
  specifier: site.specifier,
663
813
  kind: site.kind,
664
- spelling: { path: false, relative: isOwnProjectPath(site.root, owner, byCrate) },
814
+ spelling: {
815
+ path: false,
816
+ relative: isOwnProjectPath(site.root, owner, byCrate),
817
+ namesOnly: true,
818
+ },
665
819
  resolved,
666
820
  });
667
821
  }
@@ -106,6 +106,81 @@ export function positionAt(text, offset, lineStarts = lineStartsOf(text)) {
106
106
  return { line: low + 1, column: clamped - lineStarts[low] + 1 };
107
107
  }
108
108
 
109
+ /**
110
+ * The store behind `ownershipIndexOf`, which owns its keying contract.
111
+ *
112
+ * @type {WeakMap<{ name: string, root: string }[], { roots: string[], entries: { name: string, root: string }[] }>}
113
+ */
114
+ const ownershipIndexes = new WeakMap();
115
+
116
+ /**
117
+ * The projects of `projects` sorted by their roots ascending, beside those
118
+ * normalized roots — the structure `projectOwning` binary-searches.
119
+ *
120
+ * Built once per projects ARRAY and keyed on its identity, for the same reason
121
+ * `perWorkspace` below keys on the workspace object: every caller holds one
122
+ * array steady for a whole run (`workspace.projects`, or the normalized copies
123
+ * `../go-work.mjs` and `./python.mjs` assemble before their loops)
124
+ * and asks it about every file, so one sort serves the run. A caller that
125
+ * builds a fresh array per call gets no reuse rather than a stale answer — and
126
+ * the array must not be mutated after a lookup, because a project pushed later
127
+ * would be invisible to every later answer, which is the silent direction.
128
+ *
129
+ * The sort is stable and `firstRootAtOrAfter` is a lower bound, so of two
130
+ * projects spelling one root the FIRST in the array is the one found — the tie
131
+ * the linear scan this replaces broke with a strict `>`.
132
+ *
133
+ * @param {{ name: string, root: string }[]} projects
134
+ * @returns {{ roots: string[], entries: { name: string, root: string }[] }}
135
+ */
136
+ function ownershipIndexOf(projects) {
137
+ let index = ownershipIndexes.get(projects);
138
+ if (index === undefined) {
139
+ // `root ?? ""` here is the one spelling decision the scan also made: a
140
+ // project without a root is the workspace-root project, never a project
141
+ // that owns nothing.
142
+ const entries = [...projects].sort((a, b) => {
143
+ const left = a.root ?? "";
144
+ const right = b.root ?? "";
145
+ return left < right ? -1 : left > right ? 1 : 0;
146
+ });
147
+ index = { roots: entries.map((project) => project.root ?? ""), entries };
148
+ ownershipIndexes.set(projects, index);
149
+ }
150
+ return index;
151
+ }
152
+
153
+ /**
154
+ * Root comparisons `projectOwning` has performed since the module loaded.
155
+ *
156
+ * Nothing in production reads it. It exists so the complexity test counts
157
+ * deterministic operations instead of milliseconds — the wall-clock this
158
+ * repository does not trust in a test (cf. #359, #369). Every comparison the
159
+ * lookup makes is counted: one per binary-search step, one per equality probe.
160
+ */
161
+ let rootComparisons = 0;
162
+ export const ownershipRootComparisons = () => rootComparisons;
163
+
164
+ /**
165
+ * The first index in `roots` (sorted ascending) whose value is at or after
166
+ * `probe` — the lower bound `projectOwning` walks ancestors with.
167
+ *
168
+ * @param {string[]} roots
169
+ * @param {string} probe
170
+ * @returns {number}
171
+ */
172
+ function firstRootAtOrAfter(roots, probe) {
173
+ let low = 0;
174
+ let high = roots.length;
175
+ while (low < high) {
176
+ const mid = (low + high) >> 1;
177
+ rootComparisons++;
178
+ if (roots[mid] < probe) low = mid + 1;
179
+ else high = mid;
180
+ }
181
+ return low;
182
+ }
183
+
109
184
  /**
110
185
  * The project owning `path`, by **longest**-prefix match on project roots.
111
186
  *
@@ -118,18 +193,35 @@ export function positionAt(text, offset, lineStarts = lineStartsOf(text)) {
118
193
  * A project whose root is `""` (a workspace-root project) matches everything,
119
194
  * which is correct and still loses to any longer root.
120
195
  *
196
+ * The answer is found by walking `path`'s ancestor prefixes from the longest —
197
+ * `path` itself, then each prefix ending at one of its `/` boundaries, then
198
+ * `""` — and asking the sorted roots for each: every root that can own `path`
199
+ * IS one of those ancestors (`path === root`, `path.startsWith(root + "/")`,
200
+ * or `root === ""`), ancestors strictly shorten as the walk strips segments,
201
+ * and a longer ancestor sorts after a shorter one, so the first ancestor the
202
+ * roots contain is the longest match. That turns the per-file, per-context
203
+ * linear scan over every project into one sort per run plus a walk of
204
+ * `log(projects)` comparisons per ancestor — the O(files × projects) term that
205
+ * dominated very large monorepos (cf. #369): on the issue's shape, 5,000
206
+ * projects and 200,000 files, the scan performed 1,000,000,000 root tests
207
+ * where the walk performs about 8,000,000 comparisons, its one-time sort
208
+ * included.
209
+ *
121
210
  * @param {{ name: string, root: string }[]} projects
122
211
  * @param {string} path Workspace-relative.
123
212
  * @returns {{ name: string, root: string }|null}
124
213
  */
125
214
  export function projectOwning(projects, path) {
126
- let owner = null;
127
- for (const project of projects) {
128
- const root = project.root ?? "";
129
- if (root !== "" && path !== root && !path.startsWith(`${root}/`)) continue;
130
- if (owner === null || root.length > owner.root.length) owner = project;
215
+ const { roots, entries } = ownershipIndexOf(projects);
216
+ let candidate = path;
217
+ for (;;) {
218
+ const i = firstRootAtOrAfter(roots, candidate);
219
+ rootComparisons++;
220
+ if (i < roots.length && roots[i] === candidate) return entries[i];
221
+ if (candidate === "") return null;
222
+ const cut = candidate.lastIndexOf("/");
223
+ candidate = cut === -1 ? "" : candidate.slice(0, cut);
131
224
  }
132
- return owner;
133
225
  }
134
226
 
135
227
  /**
@@ -208,6 +300,36 @@ export const fileFailure = (sourceFile, reason) => ({
208
300
  reason,
209
301
  });
210
302
 
303
+ /**
304
+ * The hook-boundary posture every manifest reader and name index holds (#364):
305
+ * a graph resolver whose model reports could-not-complete failures throws
306
+ * rather than returning the edges it managed to draw. The CLI funnel turns the
307
+ * same failure list into exit 3; the Nx hook has exactly one loud output — a
308
+ * throw, which Nx wraps and turns into a failed graph computation — so the
309
+ * throw is what keeps `nx affected` from under-selecting on a broken reactor.
310
+ * The rule and its boundary (manifests and indexes throw; per-source import
311
+ * reads keep the null-read posture) are argued once, in
312
+ * `../../graph/create-dependencies.mjs`'s header.
313
+ *
314
+ * @param {string} reader The reader the failures came from, for the message's
315
+ * first sentence ("the Maven model", "the JVM package index", …).
316
+ * @param {{ sourceFile: string, reason: string }[]} failures Whole-reader
317
+ * could-not-complete failures, as the models and indexes record them.
318
+ * @returns {void} Nothing when the list is empty — a clean reader never calls
319
+ * attention to itself.
320
+ * @throws {Error} naming every failing file and its reason.
321
+ */
322
+ export const refuseUnreadTree = (reader, failures) => {
323
+ if (failures.length === 0) return;
324
+ const listed = failures.map(({ sourceFile, reason }) => `${sourceFile} (${reason})`);
325
+ throw new Error(
326
+ `archkeep: ${reader} could not fully read this tree — refusing to compute a ` +
327
+ `graph over it: ${listed.join("; ")}. Fix or remove the files above: an edge ` +
328
+ `quietly omitted for an unreadable manifest is the under-selection this ` +
329
+ `plugin exists to close.`,
330
+ );
331
+ };
332
+
211
333
  /**
212
334
  * Whether a failure means the file has NO verdict at all, rather than one
213
335
  * import site inside it having none.
@@ -228,3 +350,30 @@ export const fileFailure = (sourceFile, reason) => ({
228
350
  * @returns {boolean}
229
351
  */
230
352
  export const isWholeFileFailure = (failure) => failure.line === null;
353
+
354
+ /**
355
+ * One whole-file failure per source file, first reason kept.
356
+ *
357
+ * The funnel that merges every failure source (`../commands/context.mjs`)
358
+ * can legitimately hear about the same unreadable file twice — the language
359
+ * analyzer's own read failure AND the package/namespace index's row for the
360
+ * same file (`.NET`'s `dotnetIndexFailures`, the JVM's `jvmIndexFailures`).
361
+ * The two rows state one fact — "this file could not be analyzed" — and a
362
+ * consumer counting rows would be told "2 files" when one file failed, which
363
+ * is exactly the kind of wrong number a report must not carry. Positioned
364
+ * failures pass through untouched: several blind spots in one file are
365
+ * several distinct facts, one per import site.
366
+ *
367
+ * @param {{ sourceFile: string, line: number|null }[]} failures
368
+ * @returns {{ sourceFile: string, line: number|null }[]} Same order, whole-
369
+ * file rows deduplicated by `sourceFile`.
370
+ */
371
+ export const dedupeWholeFileFailures = (failures) => {
372
+ const seen = new Set();
373
+ return failures.filter((failure) => {
374
+ if (!isWholeFileFailure(failure)) return true;
375
+ if (seen.has(failure.sourceFile)) return false;
376
+ seen.add(failure.sourceFile);
377
+ return true;
378
+ });
379
+ };
@@ -307,8 +307,15 @@ const SCRIPT_KIND_BY_LANG = Object.freeze({
307
307
  * (`` `./${dir}/x` ``), which starts with a backtick or a quote and so is
308
308
  * neither — the honest answer for a specifier nobody can read.
309
309
  *
310
+ * `namesOnly` is per-LANGUAGE, so constant on every record this family
311
+ * produces: `false`, because this family can spell a filesystem path at all.
312
+ * That is the bit `isAbsoluteImportIntoAnotherProject` is gated on — a bare
313
+ * `libs/x` here is a deep import and a `/libs/x` an absolute path, while the
314
+ * same text in a language whose only spelling is the name is just a name
315
+ * whose first segments are those words (#376).
316
+ *
310
317
  * @param {string} specifier The raw string as written.
311
- * @returns {{ path: boolean, relative: boolean }}
318
+ * @returns {{ path: boolean, relative: boolean, namesOnly: boolean }}
312
319
  */
313
320
  export function specifierSpelling(specifier) {
314
321
  const relative =
@@ -316,7 +323,7 @@ export function specifierSpelling(specifier) {
316
323
  specifier === ".." ||
317
324
  specifier.startsWith("./") ||
318
325
  specifier.startsWith("../");
319
- return { path: relative || specifier.startsWith("/"), relative };
326
+ return { path: relative || specifier.startsWith("/"), relative, namesOnly: false };
320
327
  }
321
328
 
322
329
  /**