gitnexus 1.6.10-rc.183 → 1.6.10-rc.185

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 (49) hide show
  1. package/dist/core/ingestion/import-resolvers/configs/csharp.js +5 -1
  2. package/dist/core/ingestion/import-resolvers/configs/swift.js +13 -0
  3. package/dist/core/ingestion/import-resolvers/csharp.d.ts +9 -1
  4. package/dist/core/ingestion/import-resolvers/csharp.js +176 -19
  5. package/dist/core/ingestion/import-resolvers/go.d.ts +1 -1
  6. package/dist/core/ingestion/import-resolvers/jvm.d.ts +2 -2
  7. package/dist/core/ingestion/import-resolvers/pass-cache.d.ts +50 -0
  8. package/dist/core/ingestion/import-resolvers/pass-cache.js +60 -0
  9. package/dist/core/ingestion/import-resolvers/per-file-set.d.ts +71 -0
  10. package/dist/core/ingestion/import-resolvers/per-file-set.js +11 -0
  11. package/dist/core/ingestion/import-resolvers/php.d.ts +1 -1
  12. package/dist/core/ingestion/import-resolvers/php.js +27 -8
  13. package/dist/core/ingestion/import-resolvers/python-file-index.d.ts +201 -0
  14. package/dist/core/ingestion/import-resolvers/python-file-index.js +302 -0
  15. package/dist/core/ingestion/import-resolvers/python.js +25 -6
  16. package/dist/core/ingestion/import-resolvers/ruby.d.ts +1 -1
  17. package/dist/core/ingestion/import-resolvers/standard.d.ts +1 -1
  18. package/dist/core/ingestion/import-resolvers/utils.d.ts +56 -6
  19. package/dist/core/ingestion/import-resolvers/utils.js +215 -36
  20. package/dist/core/ingestion/import-resolvers/workspace-file-index.d.ts +38 -12
  21. package/dist/core/ingestion/import-resolvers/workspace-file-index.js +24 -18
  22. package/dist/core/ingestion/languages/c/import-target.js +12 -16
  23. package/dist/core/ingestion/languages/c/scope-resolver.js +20 -19
  24. package/dist/core/ingestion/languages/c/static-linkage.js +12 -16
  25. package/dist/core/ingestion/languages/cobol/scope-resolver.js +27 -20
  26. package/dist/core/ingestion/languages/cpp/file-local-linkage.js +11 -15
  27. package/dist/core/ingestion/languages/cpp/scope-resolver.js +19 -20
  28. package/dist/core/ingestion/languages/csharp/import-target.js +4 -11
  29. package/dist/core/ingestion/languages/dart/import-target.js +4 -9
  30. package/dist/core/ingestion/languages/go/import-target.js +6 -13
  31. package/dist/core/ingestion/languages/java/captures.js +19 -28
  32. package/dist/core/ingestion/languages/java/import-target.d.ts +38 -0
  33. package/dist/core/ingestion/languages/java/import-target.js +113 -66
  34. package/dist/core/ingestion/languages/javascript/import-target.d.ts +26 -2
  35. package/dist/core/ingestion/languages/javascript/import-target.js +67 -13
  36. package/dist/core/ingestion/languages/kotlin/import-target.js +7 -13
  37. package/dist/core/ingestion/languages/php/import-target.js +128 -14
  38. package/dist/core/ingestion/languages/python/import-target.js +111 -96
  39. package/dist/core/ingestion/languages/rust/qualified-call.js +11 -9
  40. package/dist/core/ingestion/languages/swift/import-target.js +4 -9
  41. package/dist/core/ingestion/languages/typescript/scope-resolver.js +22 -24
  42. package/dist/core/ingestion/languages/vue/import-target.d.ts +2 -2
  43. package/dist/core/ingestion/languages/vue/import-target.js +24 -16
  44. package/dist/core/ingestion/scope-resolution/passes/receiver-bound-calls.js +21 -13
  45. package/package.json +1 -1
  46. package/dist/core/ingestion/languages/kotlin/index-stats.d.ts +0 -18
  47. package/dist/core/ingestion/languages/kotlin/index-stats.js +0 -25
  48. package/dist/core/ingestion/languages/python/index-stats.d.ts +0 -18
  49. package/dist/core/ingestion/languages/python/index-stats.js +0 -25
@@ -24,7 +24,11 @@ export const csharpNamespaceStrategy = (rawImportPath, _filePath, ctx) => {
24
24
  }
25
25
  return null;
26
26
  }
27
- const resolvedFiles = resolveCSharpImportInternal(rawImportPath, csharpConfigs, ctx.normalizedFileList, ctx.allFileList, ctx.index, evidence);
27
+ const resolvedFiles = resolveCSharpImportInternal(rawImportPath, csharpConfigs,
28
+ // The Set, not `ctx.normalizedFileList`/`ctx.allFileList`: the resolver
29
+ // derives both from it through the same per-pass memo the ctx's own arrays
30
+ // come from, so this is the identical pair by a shorter route.
31
+ ctx.allFilePaths, ctx.index, evidence);
28
32
  if (resolvedFiles.length > 1) {
29
33
  const dirSuffix = resolveCSharpNamespaceDir(rawImportPath, csharpConfigs);
30
34
  if (dirSuffix) {
@@ -31,6 +31,19 @@ import { SupportedLanguages } from '../../../../_shared/index.js';
31
31
  * stable reference and the index is built once — not once per import. A
32
32
  * fresh run produces a fresh array → a fresh index, so cross-run staleness
33
33
  * is impossible.
34
+ *
35
+ * DELIBERATELY NOT ON `import-resolvers/per-file-set.ts` (#2909 sweep): this is
36
+ * a TWO-input memo keyed on ONE of them. The index is a function of both
37
+ * `ctx` (`allFileList` + the index-aligned `normalizedFileList`) and `targets`,
38
+ * but the key is only `ctx.allFileList`, and `perFileSet`'s `build: (key) => T`
39
+ * hands the builder nothing but the key. It is sound here only because of an
40
+ * invariant OUTSIDE the memo — `targets` is `ctx.configs.swiftPackageConfig
41
+ * .targets`, so it shares `ctx`'s lifetime and cannot vary while
42
+ * `ctx.allFileList` is fixed — and `perFileSet` has no way to express "and this
43
+ * other input is pinned by the same lifetime". Re-keying on `ctx` to make
44
+ * `targets` derivable from the key would change what the cache is keyed on and
45
+ * force an unreachable null-config arm into the builder, so it is a behaviour
46
+ * change rather than a consolidation. Leave it hand-rolled.
34
47
  */
35
48
  const SWIFT_TARGET_INDEX_CACHE = new WeakMap();
36
49
  function getSwiftTargetIndex(ctx, targets) {
@@ -13,8 +13,16 @@ import type { CSharpProjectConfig, CSharpNamespaceEvidence } from '../language-c
13
13
  * The final unanchored suffix fallback is gated on `evidence` so BCL usings
14
14
  * (e.g. `System.Threading.Tasks`) can't match a coincidentally-named local
15
15
  * file (#1881). When `evidence` is omitted the fallback stays permissive.
16
+ *
17
+ * Takes the file SET, not the two materialized lists it used to take: both are
18
+ * derived here from the per-pass `getWorkspaceFileIndex` memo, which is where
19
+ * every caller already got them. That leaves one key shape for the indexes
20
+ * below and makes the `normalized`/`all` pairing structural rather than a
21
+ * contract the caller has to honour. `index` stays a parameter — the parity
22
+ * harness drives this resolver with and without one, and the no-index legs are
23
+ * a tested dimension, not a degenerate case.
16
24
  */
17
- export declare function resolveCSharpImportInternal(importPath: string, csharpConfigs: CSharpProjectConfig[], normalizedFileList: string[], allFileList: string[], index?: SuffixIndex, evidence?: CSharpNamespaceEvidence): string[];
25
+ export declare function resolveCSharpImportInternal(importPath: string, csharpConfigs: CSharpProjectConfig[], allFilePaths: ReadonlySet<string>, index?: SuffixIndex, evidence?: CSharpNamespaceEvidence): string[];
18
26
  /**
19
27
  * Compute the directory suffix for a C# namespace import (for PackageMap).
20
28
  * Returns a suffix like "/ProjectDir/Models/" or null if no config matches.
@@ -4,8 +4,154 @@
4
4
  * Strategy lives in configs/csharp.ts.
5
5
  * This file contains shared helpers for namespace-based resolution.
6
6
  */
7
+ import { perFileSet } from './per-file-set.js';
8
+ import { getWorkspaceFileIndex } from './workspace-file-index.js';
7
9
  import { suffixResolve } from './utils.js';
8
10
  import { csharpSuffixFallbackAllowed } from '../csharp-namespace-gate.js';
11
+ /**
12
+ * Memoized on the file SET's identity, the same key every other per-file-set
13
+ * index in this pipeline uses: the orchestrator builds one Set per pass and
14
+ * threads it through every import, so this build runs once.
15
+ *
16
+ * It used to key on the `normalizedFileList` ARRAY, which was a second key
17
+ * shape and — more to the point — one no guard could instrument. Copying an
18
+ * array mints a fresh `WeakMap` key while traversing the SET zero extra times,
19
+ * so a `[...normalized]` copy at the adapter boundary rebuilt this index once
20
+ * per `using` while every scan-counting guard stayed green and only the timing
21
+ * bench noticed (#2911 review). Taking the array from
22
+ * `getWorkspaceFileIndex(allFilePaths)` inside the builder retires that shape:
23
+ * the only way to defeat the memo now is to copy the Set, which is exactly what
24
+ * `CountingSet` counts.
25
+ *
26
+ * It also retires a precondition. The cached positions index `normalized` while
27
+ * the emitted value is read from `all`; both now come from the same
28
+ * `getWorkspaceFileIndex` object, so the caller can no longer pair a position
29
+ * list against a differently-ordered array.
30
+ */
31
+ const getCsharpNamespaceDirIndex = perFileSet((allFilePaths) => {
32
+ const { normalized: normalizedFileList } = getWorkspaceFileIndex(allFilePaths);
33
+ const dirsByLastSegment = new Map();
34
+ const positionsByDir = new Map();
35
+ const singleSegmentDirs = [];
36
+ for (let i = 0; i < normalizedFileList.length; i++) {
37
+ const normalized = normalizedFileList[i];
38
+ if (!normalized.endsWith('.cs'))
39
+ continue;
40
+ const lastSlash = normalized.lastIndexOf('/');
41
+ // A file with no directory can never match: the needle always ends with
42
+ // '/', so `indexOf` on a slash-free path is always -1.
43
+ if (lastSlash < 0)
44
+ continue;
45
+ const dir = normalized.slice(0, lastSlash);
46
+ let positions = positionsByDir.get(dir);
47
+ if (positions === undefined) {
48
+ positions = [];
49
+ positionsByDir.set(dir, positions);
50
+ const lastSegment = dir.slice(dir.lastIndexOf('/') + 1);
51
+ if (lastSegment === dir)
52
+ singleSegmentDirs.push(dir);
53
+ let dirs = dirsByLastSegment.get(lastSegment);
54
+ if (dirs === undefined) {
55
+ dirs = [];
56
+ dirsByLastSegment.set(lastSegment, dirs);
57
+ }
58
+ dirs.push(dir);
59
+ }
60
+ positions.push(i);
61
+ }
62
+ return { dirsByLastSegment, positionsByDir, singleSegmentDirs };
63
+ });
64
+ /**
65
+ * Every directory that could satisfy `dirPrefix`, as a superset — the exact
66
+ * test runs in `matchingDirPositions`.
67
+ *
68
+ * When `dirPrefix` contains a '/', its own slash forces a segment boundary in
69
+ * any matching directory: `H` ending with `…/<lastSeg>/` means `D` ends with
70
+ * `/<lastSeg>`, so `D`'s last segment IS `lastSeg` and the exact bucket is
71
+ * complete. Without a '/', `D`'s last segment only has to END with `dirPrefix`
72
+ * (`SubModels` for `Models`), which no single bucket holds, so the last-segment
73
+ * KEYS are swept. That is the one term here that is not O(matches), and it is
74
+ * O(distinct last segments), not O(directories): C# repos reuse `Models`,
75
+ * `Services`, `Controllers` under every project, so the sweep collapses on the
76
+ * layouts that actually occur. Measured at 200 000 `.cs` files, 25 000
77
+ * directories: 456 µs per import when every directory name is unique, 7.9 µs
78
+ * on a `SrcN/Models` layout. Closing the unique-name case needs a character-
79
+ * suffix map over the segments, which is the O(files × depth) memory shape
80
+ * `package-dir-index.ts` cites #2649 to avoid — a design change, not a tune.
81
+ *
82
+ * An empty `dirPrefix` would sweep every key and keep every directory, so it is
83
+ * answered from `singleSegmentDirs` instead: its needle is a bare '/', which
84
+ * only a slash-free directory can carry as its LAST slash.
85
+ */
86
+ function* candidateDirs(index, dirPrefix) {
87
+ if (dirPrefix === '') {
88
+ yield* index.singleSegmentDirs;
89
+ return;
90
+ }
91
+ const lastSlash = dirPrefix.lastIndexOf('/');
92
+ if (lastSlash >= 0) {
93
+ const bucket = index.dirsByLastSegment.get(dirPrefix.slice(lastSlash + 1));
94
+ if (bucket !== undefined)
95
+ yield* bucket;
96
+ return;
97
+ }
98
+ for (const [lastSegment, dirs] of index.dirsByLastSegment) {
99
+ if (!lastSegment.endsWith(dirPrefix))
100
+ continue;
101
+ yield* dirs;
102
+ }
103
+ }
104
+ /** Positions of the `.cs` files in each directory matching `dirPrefix`. */
105
+ function* matchingDirPositions(index, dirPrefix) {
106
+ const needle = dirPrefix + '/';
107
+ for (const dir of candidateDirs(index, dirPrefix)) {
108
+ const haystack = dir + '/';
109
+ // The length guard is not redundant: for a shorter `haystack`, `indexOf`
110
+ // returns -1 and `haystack.length - needle.length` can also be -1, which
111
+ // would report a bogus match.
112
+ if (haystack.length < needle.length)
113
+ continue;
114
+ if (haystack.indexOf(needle) !== haystack.length - needle.length)
115
+ continue;
116
+ const positions = index.positionsByDir.get(dir);
117
+ if (positions !== undefined)
118
+ yield positions;
119
+ }
120
+ }
121
+ /**
122
+ * Append every `.cs` file directly inside a directory matching `dirPrefix`, in
123
+ * `normalizedFileList` order — the order the single-pass scan emitted, which
124
+ * this function's callers return as the whole edge target list.
125
+ */
126
+ function pushFilesDirectlyInNamespaceDir(index, dirPrefix, allFileList, results) {
127
+ // One matching directory is the overwhelmingly common case, and its positions
128
+ // are already ascending, so the first bucket is held by reference. A second
129
+ // one promotes it to a real accumulator that is appended to from then on —
130
+ // never re-spread per directory, which would cost O(files × dirs²) copies in
131
+ // a monorepo carrying the same namespace directory under many projects.
132
+ let first = null;
133
+ let merged = null;
134
+ for (const positions of matchingDirPositions(index, dirPrefix)) {
135
+ if (first === null) {
136
+ first = positions;
137
+ continue;
138
+ }
139
+ if (merged === null)
140
+ merged = [...first];
141
+ for (const position of positions)
142
+ merged.push(position);
143
+ }
144
+ if (first === null)
145
+ return;
146
+ if (merged === null) {
147
+ for (const position of first)
148
+ results.push(allFileList[position]);
149
+ return;
150
+ }
151
+ merged.sort((a, b) => a - b);
152
+ for (const position of merged)
153
+ results.push(allFileList[position]);
154
+ }
9
155
  /**
10
156
  * Resolve a C# using-directive import path to matching .cs files (low-level helper).
11
157
  * Tries single-file match first, then directory match for namespace imports.
@@ -13,8 +159,17 @@ import { csharpSuffixFallbackAllowed } from '../csharp-namespace-gate.js';
13
159
  * The final unanchored suffix fallback is gated on `evidence` so BCL usings
14
160
  * (e.g. `System.Threading.Tasks`) can't match a coincidentally-named local
15
161
  * file (#1881). When `evidence` is omitted the fallback stays permissive.
162
+ *
163
+ * Takes the file SET, not the two materialized lists it used to take: both are
164
+ * derived here from the per-pass `getWorkspaceFileIndex` memo, which is where
165
+ * every caller already got them. That leaves one key shape for the indexes
166
+ * below and makes the `normalized`/`all` pairing structural rather than a
167
+ * contract the caller has to honour. `index` stays a parameter — the parity
168
+ * harness drives this resolver with and without one, and the no-index legs are
169
+ * a tested dimension, not a degenerate case.
16
170
  */
17
- export function resolveCSharpImportInternal(importPath, csharpConfigs, normalizedFileList, allFileList, index, evidence) {
171
+ export function resolveCSharpImportInternal(importPath, csharpConfigs, allFilePaths, index, evidence) {
172
+ const { normalized: normalizedFileList, all: allFileList } = getWorkspaceFileIndex(allFilePaths);
18
173
  const namespacePath = importPath.replace(/\./g, '/');
19
174
  const results = [];
20
175
  for (const config of csharpConfigs) {
@@ -65,24 +220,26 @@ export function resolveCSharpImportInternal(importPath, csharpConfigs, normalize
65
220
  if (results.length > 0)
66
221
  return results;
67
222
  }
68
- // 3. Linear scan fallback for directory matching
69
- if (results.length === 0) {
70
- const dirTrail = dirPrefix + '/';
71
- for (let i = 0; i < normalizedFileList.length; i++) {
72
- const normalized = normalizedFileList[i];
73
- if (!normalized.endsWith('.cs'))
74
- continue;
75
- const prefixIdx = normalized.indexOf(dirTrail);
76
- if (prefixIdx < 0)
77
- continue;
78
- const afterDir = normalized.substring(prefixIdx + dirTrail.length);
79
- if (!afterDir.includes('/')) {
80
- results.push(allFileList[i]);
81
- }
82
- }
83
- if (results.length > 0)
84
- return results;
85
- }
223
+ // 3. Directory matching, UNANCHORED.
224
+ //
225
+ // Not redundant with step 2, and not skippable when `index` is present:
226
+ // `getFilesInDir` is keyed on SEGMENT suffixes of a directory, while this
227
+ // leg's predicate is an unanchored substring one, so it additionally
228
+ // answers `Models` with `src/SubModels/` and `src/Models` with
229
+ // `vendor/mysrc/Models/`. It is also the only leg that answers an empty
230
+ // `dirPrefix` — the `relative = ''` branch above (the import IS the root
231
+ // namespace) with no `projectDir` to stand in for it — because
232
+ // `buildSuffixIndex` emits an empty directory suffix only for a path that
233
+ // BEGINS with '/', so over repo-relative paths `getFilesInDir('', '.cs')`
234
+ // is always empty. See `CsharpNamespaceDirIndex` above for the index that
235
+ // replaced the per-import Θ(files) scan this used to be (#2902).
236
+ //
237
+ // `results` is provably empty here: step 2 returns as soon as it pushes
238
+ // anything, and so does this leg, so every iteration of the config loop
239
+ // starts empty.
240
+ pushFilesDirectlyInNamespaceDir(getCsharpNamespaceDirIndex(allFilePaths), dirPrefix, allFileList, results);
241
+ if (results.length > 0)
242
+ return results;
86
243
  }
87
244
  // Fallback: suffix matching without namespace stripping (single file).
88
245
  // Gated on in-repo declared-namespace evidence (#1881).
@@ -14,4 +14,4 @@ export declare function resolveGoPackageDir(importPath: string, goModule: GoModu
14
14
  * Resolve a Go internal package import to all .go files in the package directory.
15
15
  * Returns an array of file paths.
16
16
  */
17
- export declare function resolveGoPackage(importPath: string, goModule: GoModuleConfig, normalizedFileList: string[], allFileList: string[]): string[];
17
+ export declare function resolveGoPackage(importPath: string, goModule: GoModuleConfig, normalizedFileList: readonly string[], allFileList: readonly string[]): string[];
@@ -18,10 +18,10 @@ export declare const appendKotlinWildcard: (importPath: string, importNode: Synt
18
18
  * Resolve a JVM wildcard import (com.example.*) to all matching files.
19
19
  * Works for both Java (.java) and Kotlin (.kt, .kts).
20
20
  */
21
- export declare function resolveJvmWildcard(importPath: string, normalizedFileList: string[], allFileList: string[], extensions: readonly string[], index?: SuffixIndex): string[];
21
+ export declare function resolveJvmWildcard(importPath: string, normalizedFileList: readonly string[], allFileList: readonly string[], extensions: readonly string[], index?: SuffixIndex): string[];
22
22
  /**
23
23
  * Try to resolve a JVM member/static import by stripping the member name.
24
24
  * Java: "com.example.Constants.VALUE" -> resolve "com.example.Constants"
25
25
  * Kotlin: "com.example.Constants.VALUE" -> resolve "com.example.Constants"
26
26
  */
27
- export declare function resolveJvmMemberImport(importPath: string, normalizedFileList: string[], allFileList: string[], extensions: readonly string[], index?: SuffixIndex): string | null;
27
+ export declare function resolveJvmMemberImport(importPath: string, normalizedFileList: readonly string[], allFileList: readonly string[], extensions: readonly string[], index?: SuffixIndex): string | null;
@@ -0,0 +1,50 @@
1
+ import { type SuffixIndex } from './utils.js';
2
+ /**
3
+ * Everything the standard `resolveTsTarget` path derives from one workspace
4
+ * file set: the file list, the lower-cased file list, the suffix index and the
5
+ * per-pass `resolveCache`.
6
+ *
7
+ * Without this memoization the resolver re-derived `allFileList` and
8
+ * `normalizedFileList` (both O(N_files)), rebuilt the index and threw away the
9
+ * `resolveCache` on every import — O(N_files × N_imports) total work for what
10
+ * should be O(N_files + N_imports).
11
+ */
12
+ export interface ImportPassCache {
13
+ readonly allFilePaths: Set<string>;
14
+ readonly allFileList: readonly string[];
15
+ readonly normalizedFileList: readonly string[];
16
+ readonly index: SuffixIndex;
17
+ readonly resolveCache: Map<string, string | null>;
18
+ }
19
+ /**
20
+ * Build that state. Shared by every adapter whose resolution runs through
21
+ * `resolveTsTarget`.
22
+ *
23
+ * Not a dedup of identical copies, and the difference is the point. At
24
+ * 49c5b7d81 each of those adapters carried this record inline and they did NOT
25
+ * agree: `languages/typescript/scope-resolver.ts` and
26
+ * `languages/vue/import-target.ts` held six byte-identical fields built around
27
+ * `index: buildSuffixIndex(normalizedFileList, allFileList)`, while
28
+ * `languages/javascript/import-target.ts` held five and never called
29
+ * `buildSuffixIndex` at all. That one missing field IS the O(imports × files)
30
+ * defect PR #2911 fixed — `resolveTsTarget` fell back to `suffixResolve`'s
31
+ * linear scan for every JavaScript import — and the header of
32
+ * `languages/javascript/import-target.ts` carries the measurements. Hoisting
33
+ * the builder is what makes a fourth adapter unable to omit it again: `index`
34
+ * is not optional on `ImportPassCache`.
35
+ *
36
+ * The BUILDER is shared; the MEMO deliberately is not. Each adapter wraps this
37
+ * in its own `perFileSet(...)`, so each gets its own `WeakMap`, its own index
38
+ * instance and — the one that would be a behaviour change — its own
39
+ * `resolveCache`. The languages disagree about what a specifier resolves to
40
+ * (`tsconfigPaths` is read from config for TypeScript and Vue, pinned to `null`
41
+ * for JavaScript, and the tried extension list differs), so one shared resolve
42
+ * cache across them would hand a language another language's answers.
43
+ *
44
+ * Sharing the builder is a code dedup and nothing more: it buys no runtime
45
+ * reuse, because there is none to buy. Each provider pass builds its own
46
+ * `allFilePaths` Set (`scope-resolution/pipeline/run.ts`, per provider), so
47
+ * TypeScript's set and JavaScript's set are different objects and therefore
48
+ * different `WeakMap` keys even where the two memos are the same code.
49
+ */
50
+ export declare function buildImportPassCache(allFilePaths: ReadonlySet<string>): ImportPassCache;
@@ -0,0 +1,60 @@
1
+ import { buildSuffixIndex } from './utils.js';
2
+ /**
3
+ * Build that state. Shared by every adapter whose resolution runs through
4
+ * `resolveTsTarget`.
5
+ *
6
+ * Not a dedup of identical copies, and the difference is the point. At
7
+ * 49c5b7d81 each of those adapters carried this record inline and they did NOT
8
+ * agree: `languages/typescript/scope-resolver.ts` and
9
+ * `languages/vue/import-target.ts` held six byte-identical fields built around
10
+ * `index: buildSuffixIndex(normalizedFileList, allFileList)`, while
11
+ * `languages/javascript/import-target.ts` held five and never called
12
+ * `buildSuffixIndex` at all. That one missing field IS the O(imports × files)
13
+ * defect PR #2911 fixed — `resolveTsTarget` fell back to `suffixResolve`'s
14
+ * linear scan for every JavaScript import — and the header of
15
+ * `languages/javascript/import-target.ts` carries the measurements. Hoisting
16
+ * the builder is what makes a fourth adapter unable to omit it again: `index`
17
+ * is not optional on `ImportPassCache`.
18
+ *
19
+ * The BUILDER is shared; the MEMO deliberately is not. Each adapter wraps this
20
+ * in its own `perFileSet(...)`, so each gets its own `WeakMap`, its own index
21
+ * instance and — the one that would be a behaviour change — its own
22
+ * `resolveCache`. The languages disagree about what a specifier resolves to
23
+ * (`tsconfigPaths` is read from config for TypeScript and Vue, pinned to `null`
24
+ * for JavaScript, and the tried extension list differs), so one shared resolve
25
+ * cache across them would hand a language another language's answers.
26
+ *
27
+ * Sharing the builder is a code dedup and nothing more: it buys no runtime
28
+ * reuse, because there is none to buy. Each provider pass builds its own
29
+ * `allFilePaths` Set (`scope-resolution/pipeline/run.ts`, per provider), so
30
+ * TypeScript's set and JavaScript's set are different objects and therefore
31
+ * different `WeakMap` keys even where the two memos are the same code.
32
+ */
33
+ export function buildImportPassCache(allFilePaths) {
34
+ const allFileList = Array.from(allFilePaths);
35
+ // LOWERCASED, not slash-normalized — unlike every other caller of
36
+ // `buildSuffixIndex`. That is what `alreadyLowercased` below records.
37
+ const normalizedFileList = allFileList.map((f) => f.toLowerCase());
38
+ return {
39
+ // Copied ONCE per file set, not once per import: `TsResolveContext` wants a
40
+ // mutable `Set` and the orchestrator hands us a `ReadonlySet`. The copy is
41
+ // not the #1918 hazard because the cache KEY is the caller's original Set.
42
+ allFilePaths: new Set(allFilePaths),
43
+ allFileList,
44
+ normalizedFileList,
45
+ // Every suffix of an all-lowercase path is itself lowercase, so the index's
46
+ // case-folded map came out a byte-for-byte copy of its exact map — same
47
+ // keys, same values, same insertion order — one per `ImportPassCache`, so
48
+ // once per adapter per pass. Measured 14.00 MiB at 32 000 paths, 29.8% of
49
+ // the retained `ImportPassCache`. The flag drops the copy; it does not change
50
+ // what `getInsensitive` answers, because the copy was the identity (see
51
+ // `SuffixIndexOptions`). Checked, not assumed: over 474 524 probes on four
52
+ // mixed-case corpora — Vue PascalCase plus alias specifiers, case-colliding
53
+ // twins, a 600-file deep monorepo, and Unicode paths carrying final sigma,
54
+ // dotted-I and sharp-S — the two maps came out byte-identical, the exact
55
+ // map was the sole answerer 0 times, and `get(s) || getInsensitive(s)`
56
+ // returned the same file 474 524 times out of 474 524.
57
+ index: buildSuffixIndex(normalizedFileList, allFileList, { alreadyLowercased: true }),
58
+ resolveCache: new Map(),
59
+ };
60
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * The one memo every per-file-set index in this pipeline is built on.
3
+ *
4
+ * The scope-resolution orchestrator builds ONE file-set object per provider
5
+ * pass and threads that same object through every `resolveImportTarget` call in
6
+ * the pass, so anything derived from it — a suffix index, a package-directory
7
+ * map, a basename bucket — can be built once and read by every import instead
8
+ * of rebuilt per import. Keying on the object's IDENTITY is what makes that
9
+ * work, and it is equally the contract callers must keep: the set is passed
10
+ * THROUGH, never copied. A defensive `new Set(allFilePaths)` at an adapter
11
+ * boundary hands a fresh key per import and silently restores
12
+ * O(imports × files) — the bug PR #1918 shipped and had to fix in review (P1).
13
+ * The guards are `test/integration/<lang>-import-index-reuse.test.ts` and, for
14
+ * every registered language at once,
15
+ * `test/unit/scope-resolution/import-target-index-reuse.contract.test.ts`,
16
+ * whose inventory arm fails when an entry of `SCOPE_RESOLVERS` has no fixture.
17
+ * That arm is why no language is named here: the registry is the census, and a
18
+ * hand-copied list of languages goes stale the release after it is written.
19
+ *
20
+ * A `WeakMap` rather than a `Map`: the entry is reclaimed with the file set it
21
+ * was derived from, so a pass can never read a previous pass's index and memory
22
+ * does not grow across runs. There is no invalidation rule to get wrong because
23
+ * there is nothing to invalidate — a new file set is a new key.
24
+ *
25
+ * The KEY TYPE is constrained rather than described, because which object is
26
+ * the key decides whether the guards above can see the memo fail, and a prose
27
+ * list of call sites is the thing this file elsewhere tells you not to write.
28
+ * `K` admits exactly the two shapes the orchestrator keeps stable for a pass:
29
+ *
30
+ * - `ReadonlySet<string>`, the pass's file set — every index derived from it,
31
+ * including the derived header-closure sets that `languages/{c,cpp}/
32
+ * scope-resolver.ts` memoize inside an outer per-file-set memo. Defeating
33
+ * one of these means copying the SET, which re-traverses it, which the
34
+ * `CountingSet` instrument (`test/helpers/counting-file-set.ts`) reads as a
35
+ * scan count rising with the import count.
36
+ * - `readonly ParsedFile[]`, the pass's parsed-file array. Not derived from
37
+ * the file set at all, so the file-set guards do not reach them; these key
38
+ * on the array the orchestrator already threads through the pass, and their
39
+ * contract is that same pass-through discipline. The instrument that CAN see
40
+ * them counts element reads on that array — `countedParsedFiles`, beside
41
+ * `CountingSet`, driven by the contract test's `minimumParsedFileReads`.
42
+ *
43
+ * A THIRD shape — an array materialized from the file set — is what the type
44
+ * exists to reject. `import-resolvers/csharp.ts` used one until #2911, and it
45
+ * is worth a compile error rather than a rule: copying an array mints a fresh
46
+ * `WeakMap` key while traversing the Set zero extra times, so every
47
+ * scan-counting guard stays green at its correct value while the index rebuilds
48
+ * once per import. That failure is invisible to the whole instrument family
49
+ * above and was caught only by a timing ratio in `bench/import-target/`. Derive
50
+ * the array inside the builder from `getWorkspaceFileIndex(allFilePaths)`
51
+ * instead. `string[]` is not assignable to `K`, so the shape cannot come back
52
+ * silently — `configs/swift.ts` keeps the one hand-rolled `WeakMap` on
53
+ * `ctx.allFileList` in the tree, deliberately and with its reasons written
54
+ * down, and it is deliberately NOT on this primitive.
55
+ *
56
+ * `T extends object` is deliberate, chosen over probing `has` before `get`.
57
+ * `WeakMap.get` returning `undefined` cannot distinguish "not built yet" from
58
+ * "built, and the value is `undefined`"; constraining the value to an object
59
+ * makes the second case unrepresentable rather than paying a second lookup on
60
+ * every import, and it needs no cast to type-check. Every index memoized here
61
+ * is a record, `Map` or `Set`, so the constraint costs nothing today — and a
62
+ * later caller wanting to memoize a `string | null` gets a compile error
63
+ * pointing at this line instead of a memo that silently rebuilds on every miss.
64
+ *
65
+ * A `build` that THROWS stores nothing, so the next call for that key runs it
66
+ * again: failures are not memoized, and a half-filled index is never published.
67
+ * Inert for the builders here — each is a pure, total pass over the file set —
68
+ * and the safer of the two behaviours if that ever stops being true.
69
+ */
70
+ import type { ParsedFile } from '../../../_shared/index.js';
71
+ export declare function perFileSet<K extends ReadonlySet<string> | readonly ParsedFile[], T extends object>(build: (key: K) => T): (key: K) => T;
@@ -0,0 +1,11 @@
1
+ export function perFileSet(build) {
2
+ const cache = new WeakMap();
3
+ return (key) => {
4
+ const cached = cache.get(key);
5
+ if (cached !== undefined)
6
+ return cached;
7
+ const built = build(key);
8
+ cache.set(key, built);
9
+ return built;
10
+ };
11
+ }
@@ -21,4 +21,4 @@ import type { ComposerConfig } from '../language-config.js';
21
21
  * a known limitation — PHP function imports cannot be resolved to a specific file
22
22
  * without parsing all candidate files.
23
23
  */
24
- export declare function resolvePhpImportInternal(importPath: string, composerConfig: ComposerConfig | null, allFiles: Set<string>, normalizedFileList: string[], allFileList: string[], index?: SuffixIndex): string | null;
24
+ export declare function resolvePhpImportInternal(importPath: string, composerConfig: ComposerConfig | null, allFiles: Set<string>, normalizedFileList: readonly string[], allFileList: readonly string[], index?: SuffixIndex): string | null;
@@ -53,19 +53,38 @@ export function resolvePhpImportInternal(importPath, composerConfig, allFiles, n
53
53
  // e.g. App\Models\getUser → directory app/Models/, find first .php file in that dir.
54
54
  const lastSlash = remainder.lastIndexOf('/');
55
55
  const nsDir = lastSlash >= 0 ? dirPrefix + '/' + remainder.slice(0, lastSlash) : dirPrefix;
56
- // Prefer SuffixIndex directory lookup (O(log n + matches)) over linear scan
56
+ // Prefer SuffixIndex directory lookup (O(log n + matches)) over linear scan.
57
+ //
58
+ // An EMPTY bucket is a final answer, not a miss to retry with the scan
59
+ // below — which is what the `else` restores, and what this comment
60
+ // always claimed. Re-scanning on empty was the last per-import
61
+ // workspace traversal left in PHP resolution after #2901: any `use`
62
+ // matching a PSR-4 prefix whose directory holds no direct `.php` child
63
+ // (`App\Legacy\Ghost`) paid a full pass, measured at 201 traversals for
64
+ // 200 imports.
65
+ //
66
+ // The bucket is a superset of what the scan can find, for BOTH index
67
+ // shapes that reach here. A root-anchored direct child `nsDir/<x>.php`
68
+ // has its directory exactly equal to `nsDir`, and `nsDir` is always one
69
+ // of that directory's own suffixes — so the shared `dirMap` (keyed on
70
+ // every directory suffix) necessarily contains it, as does the
71
+ // root-anchored parity index `languages/php/import-target.ts` builds.
72
+ // Empty superset therefore implies empty scan, and control falls
73
+ // through to the next PSR-4 prefix exactly as before.
57
74
  if (index) {
58
75
  const candidates = index.getFilesInDir(nsDir, '.php');
59
76
  if (candidates.length > 0)
60
77
  return candidates[0];
61
78
  }
62
- // Fallback: linear scan (only when SuffixIndex unavailable)
63
- const nsDirPrefix = nsDir.endsWith('/') ? nsDir : nsDir + '/';
64
- for (const f of allFiles) {
65
- if (f.startsWith(nsDirPrefix) &&
66
- f.endsWith('.php') &&
67
- !f.slice(nsDirPrefix.length).includes('/')) {
68
- return f;
79
+ else {
80
+ // Linear scan, only when a SuffixIndex is genuinely unavailable.
81
+ const nsDirPrefix = nsDir.endsWith('/') ? nsDir : nsDir + '/';
82
+ for (const f of allFiles) {
83
+ if (f.startsWith(nsDirPrefix) &&
84
+ f.endsWith('.php') &&
85
+ !f.slice(nsDirPrefix.length).includes('/')) {
86
+ return f;
87
+ }
69
88
  }
70
89
  }
71
90
  }