@ecoma-io/archkeep 0.15.0 → 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/cli.mjs +126 -4
- package/commands.mjs +6 -0
- package/lsp.mjs +15 -2
- package/package.json +6 -2
- package/src/analysis/analyze.mjs +15 -0
- package/src/analysis/contract.md +36 -18
- package/src/analysis/csharp.mjs +485 -0
- package/src/analysis/dotnet/csproj.mjs +380 -0
- package/src/analysis/dotnet/mask.mjs +178 -0
- package/src/analysis/dotnet/namespaces.mjs +172 -0
- package/src/analysis/dotnet/resolve.mjs +89 -0
- package/src/analysis/go.mjs +289 -5
- package/src/analysis/java.mjs +329 -0
- package/src/analysis/jvm/gradle.mjs +545 -0
- package/src/analysis/jvm/mask.mjs +170 -0
- package/src/analysis/jvm/maven.mjs +612 -0
- package/src/analysis/jvm/packages.mjs +209 -0
- package/src/analysis/jvm/resolve.mjs +139 -0
- package/src/analysis/kotlin.mjs +210 -0
- package/src/analysis/manifest-util.mjs +30 -0
- package/src/analysis/python.mjs +3 -2
- package/src/analysis/registry.mjs +11 -0
- package/src/analysis/rust.mjs +171 -17
- package/src/analysis/source-util.mjs +155 -6
- package/src/analysis/typescript.mjs +9 -2
- package/src/commands/context.mjs +84 -14
- package/src/commands/provenance.mjs +7 -44
- package/src/commands/rules.mjs +775 -0
- package/src/governance/profile-registry.mjs +0 -1
- package/src/graph/create-dependencies.mjs +138 -15
- package/src/lsp/diagnose.mjs +1 -1
- package/src/lsp/server.mjs +97 -1
- package/src/lsp/workspace-index.mjs +106 -15
- package/src/options.mjs +30 -7
- package/src/process.mjs +10 -1
- package/src/providers/moon.mjs +287 -36
- package/src/providers/native/differential.fixtures.mjs +32 -6
- package/src/providers/native/discover.mjs +83 -4
- package/src/providers/native/graph.mjs +58 -0
- package/src/providers/native/model.mjs +59 -1
- package/src/rules/index.mjs +21 -6
- package/src/rules/reachability.mjs +2 -0
- package/src/rules/tags.mjs +7 -5
- package/src/rules/topology.mjs +5 -3
- package/src/workspace.mjs +115 -23
package/src/analysis/rust.mjs
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
245
|
-
* a `.rs` file spells -> the
|
|
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
|
|
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
|
-
* @
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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: {
|
|
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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
/**
|