diff --git a/gitnexus/src/core/ingestion/import-resolvers/configs/swift.ts b/gitnexus/src/core/ingestion/import-resolvers/configs/swift.ts index 8235edf73..05e4b13be 100644 --- a/gitnexus/src/core/ingestion/import-resolvers/configs/swift.ts +++ b/gitnexus/src/core/ingestion/import-resolvers/configs/swift.ts @@ -39,6 +39,19 @@ interface SwiftTargetIndex { * stable reference and the index is built once — not once per import. A * fresh run produces a fresh array → a fresh index, so cross-run staleness * is impossible. + * + * DELIBERATELY NOT ON `import-resolvers/per-file-set.ts` (#2909 sweep): this is + * a TWO-input memo keyed on ONE of them. The index is a function of both + * `ctx` (`allFileList` + the index-aligned `normalizedFileList`) and `targets`, + * but the key is only `ctx.allFileList`, and `perFileSet`'s `build: (key) => T` + * hands the builder nothing but the key. It is sound here only because of an + * invariant OUTSIDE the memo — `targets` is `ctx.configs.swiftPackageConfig + * .targets`, so it shares `ctx`'s lifetime and cannot vary while + * `ctx.allFileList` is fixed — and `perFileSet` has no way to express "and this + * other input is pinned by the same lifetime". Re-keying on `ctx` to make + * `targets` derivable from the key would change what the cache is keyed on and + * force an unreachable null-config arm into the builder, so it is a behaviour + * change rather than a consolidation. Leave it hand-rolled. */ const SWIFT_TARGET_INDEX_CACHE = new WeakMap(); diff --git a/gitnexus/src/core/ingestion/import-resolvers/csharp.ts b/gitnexus/src/core/ingestion/import-resolvers/csharp.ts index 3f152e454..b262188f8 100644 --- a/gitnexus/src/core/ingestion/import-resolvers/csharp.ts +++ b/gitnexus/src/core/ingestion/import-resolvers/csharp.ts @@ -5,6 +5,7 @@ * This file contains shared helpers for namespace-based resolution. */ +import { perFileSet } from './per-file-set.js'; import type { SuffixIndex } from './utils.js'; import { suffixResolve } from './utils.js'; import type { CSharpProjectConfig, CSharpNamespaceEvidence } from '../language-config.js'; @@ -77,45 +78,40 @@ interface CsharpNamespaceDirIndex { * array to every import, so this build runs once. A caller that copies the * array per import silently restores the O(imports × files) cost. */ -const NAMESPACE_DIR_INDEX_CACHE = new WeakMap(); +const getCsharpNamespaceDirIndex = perFileSet( + (normalizedFileList: readonly string[]): CsharpNamespaceDirIndex => { + const dirsByLastSegment = new Map(); + const positionsByDir = new Map(); + const singleSegmentDirs: string[] = []; -function getCsharpNamespaceDirIndex(normalizedFileList: string[]): CsharpNamespaceDirIndex { - const cached = NAMESPACE_DIR_INDEX_CACHE.get(normalizedFileList); - if (cached !== undefined) return cached; + for (let i = 0; i < normalizedFileList.length; i++) { + const normalized = normalizedFileList[i]; + if (!normalized.endsWith('.cs')) continue; + const lastSlash = normalized.lastIndexOf('/'); + // A file with no directory can never match: the needle always ends with + // '/', so `indexOf` on a slash-free path is always -1. + if (lastSlash < 0) continue; - const dirsByLastSegment = new Map(); - const positionsByDir = new Map(); - const singleSegmentDirs: string[] = []; - - for (let i = 0; i < normalizedFileList.length; i++) { - const normalized = normalizedFileList[i]; - if (!normalized.endsWith('.cs')) continue; - const lastSlash = normalized.lastIndexOf('/'); - // A file with no directory can never match: the needle always ends with - // '/', so `indexOf` on a slash-free path is always -1. - if (lastSlash < 0) continue; - - const dir = normalized.slice(0, lastSlash); - let positions = positionsByDir.get(dir); - if (positions === undefined) { - positions = []; - positionsByDir.set(dir, positions); - const lastSegment = dir.slice(dir.lastIndexOf('/') + 1); - if (lastSegment === dir) singleSegmentDirs.push(dir); - let dirs = dirsByLastSegment.get(lastSegment); - if (dirs === undefined) { - dirs = []; - dirsByLastSegment.set(lastSegment, dirs); + const dir = normalized.slice(0, lastSlash); + let positions = positionsByDir.get(dir); + if (positions === undefined) { + positions = []; + positionsByDir.set(dir, positions); + const lastSegment = dir.slice(dir.lastIndexOf('/') + 1); + if (lastSegment === dir) singleSegmentDirs.push(dir); + let dirs = dirsByLastSegment.get(lastSegment); + if (dirs === undefined) { + dirs = []; + dirsByLastSegment.set(lastSegment, dirs); + } + dirs.push(dir); } - dirs.push(dir); + positions.push(i); } - positions.push(i); - } - const built: CsharpNamespaceDirIndex = { dirsByLastSegment, positionsByDir, singleSegmentDirs }; - NAMESPACE_DIR_INDEX_CACHE.set(normalizedFileList, built); - return built; -} + return { dirsByLastSegment, positionsByDir, singleSegmentDirs }; + }, +); /** * Every directory that could satisfy `dirPrefix`, as a superset — the exact diff --git a/gitnexus/src/core/ingestion/import-resolvers/pass-cache.ts b/gitnexus/src/core/ingestion/import-resolvers/pass-cache.ts new file mode 100644 index 000000000..f0e9bdee6 --- /dev/null +++ b/gitnexus/src/core/ingestion/import-resolvers/pass-cache.ts @@ -0,0 +1,53 @@ +import { buildSuffixIndex, type SuffixIndex } from './utils.js'; + +/** + * Everything the standard `resolveTsTarget` path derives from one workspace + * file set: the file list, the lower-cased file list, the suffix index and the + * per-pass `resolveCache`. + * + * Without this memoization the resolver re-derived `allFileList` and + * `normalizedFileList` (both O(N_files)), rebuilt the index and threw away the + * `resolveCache` on every import — O(N_files × N_imports) total work for what + * should be O(N_files + N_imports). + */ +export interface ImportPassCache { + readonly allFilePaths: Set; + readonly allFileList: readonly string[]; + readonly normalizedFileList: readonly string[]; + readonly index: SuffixIndex; + readonly resolveCache: Map; +} + +/** + * Build that state. Shared by the adapters whose resolution runs through + * `resolveTsTarget` (TypeScript, JavaScript, Vue) — three byte-identical copies + * before this existed. + * + * The BUILDER is shared; the MEMO deliberately is not. Each adapter wraps this + * in its own `perFileSet(...)`, so each gets its own `WeakMap`, its own index + * instance and — the one that would be a behaviour change — its own + * `resolveCache`. The languages disagree about what a specifier resolves to + * (`tsconfigPaths` is read from config for TypeScript and Vue, pinned to `null` + * for JavaScript, and the tried extension list differs), so one shared resolve + * cache across them would hand a language another language's answers. + * + * Sharing the builder is a code dedup and nothing more: it buys no runtime + * reuse, because there is none to buy. Each provider pass builds its own + * `allFilePaths` Set (`scope-resolution/pipeline/run.ts`, per provider), so + * TypeScript's set and JavaScript's set are different objects and therefore + * different `WeakMap` keys even where the two memos are the same code. + */ +export function buildImportPassCache(allFilePaths: ReadonlySet): ImportPassCache { + const allFileList = Array.from(allFilePaths); + const normalizedFileList = allFileList.map((f) => f.toLowerCase()); + return { + // Copied ONCE per file set, not once per import: `TsResolveContext` wants a + // mutable `Set` and the orchestrator hands us a `ReadonlySet`. The copy is + // not the #1918 hazard because the cache KEY is the caller's original Set. + allFilePaths: new Set(allFilePaths), + allFileList, + normalizedFileList, + index: buildSuffixIndex(normalizedFileList, allFileList), + resolveCache: new Map(), + }; +} diff --git a/gitnexus/src/core/ingestion/import-resolvers/per-file-set.ts b/gitnexus/src/core/ingestion/import-resolvers/per-file-set.ts new file mode 100644 index 000000000..ff29855ca --- /dev/null +++ b/gitnexus/src/core/ingestion/import-resolvers/per-file-set.ts @@ -0,0 +1,52 @@ +/** + * The one memo every per-file-set index in this pipeline is built on. + * + * The scope-resolution orchestrator builds ONE file-set object per provider + * pass and threads that same object through every `resolveImportTarget` call in + * the pass, so anything derived from it — a suffix index, a package-directory + * map, a basename bucket — can be built once and read by every import instead + * of rebuilt per import. Keying on the object's IDENTITY is what makes that + * work, and it is equally the contract callers must keep: the set is passed + * THROUGH, never copied. A defensive `new Set(allFilePaths)` at an adapter + * boundary hands a fresh key per import and silently restores + * O(imports × files) — the bug PR #1918 shipped and had to fix in review (P1). + * The guards are `test/integration/-import-index-reuse.test.ts` and, for + * every registered language at once, + * `test/unit/scope-resolution/import-target-index-reuse.contract.test.ts`. + * + * A `WeakMap` rather than a `Map`: the entry is reclaimed with the file set it + * was derived from, so a pass can never read a previous pass's index and memory + * does not grow across runs. There is no invalidation rule to get wrong because + * there is nothing to invalidate — a new file set is a new key. + * + * `K extends object` because the key is whatever object the pass keeps stable: + * usually the `ReadonlySet` of paths, sometimes a `readonly string[]` + * materialized from it once per run (`import-resolvers/csharp.ts`). Stability + * for the pass is the only property either needs. + * + * `T extends object` is deliberate, chosen over probing `has` before `get`. + * `WeakMap.get` returning `undefined` cannot distinguish "not built yet" from + * "built, and the value is `undefined`"; constraining the value to an object + * makes the second case unrepresentable rather than paying a second lookup on + * every import, and it needs no cast to type-check. Every index memoized here + * is a record, `Map` or `Set`, so the constraint costs nothing today — and a + * later caller wanting to memoize a `string | null` gets a compile error + * pointing at this line instead of a memo that silently rebuilds on every miss. + * + * A `build` that THROWS stores nothing, so the next call for that key runs it + * again: failures are not memoized, and a half-filled index is never published. + * Inert for the builders here — each is a pure, total pass over the file set — + * and the safer of the two behaviours if that ever stops being true. + */ +export function perFileSet( + build: (key: K) => T, +): (key: K) => T { + const cache = new WeakMap(); + return (key) => { + const cached = cache.get(key); + if (cached !== undefined) return cached; + const built = build(key); + cache.set(key, built); + return built; + }; +} diff --git a/gitnexus/src/core/ingestion/import-resolvers/workspace-file-index.ts b/gitnexus/src/core/ingestion/import-resolvers/workspace-file-index.ts index aac623cd5..5dcaacdc0 100644 --- a/gitnexus/src/core/ingestion/import-resolvers/workspace-file-index.ts +++ b/gitnexus/src/core/ingestion/import-resolvers/workspace-file-index.ts @@ -33,6 +33,7 @@ * INSIDE a resolver, by counting how many times the Set is iterated. */ +import { perFileSet } from './per-file-set.js'; import { buildSuffixIndex, type SuffixIndex } from './utils.js'; export interface WorkspaceFileIndex { @@ -52,27 +53,22 @@ export interface WorkspaceFileIndex { readonly normToRaw: Map; } -const WORKSPACE_FILE_INDEX_CACHE = new WeakMap, WorkspaceFileIndex>(); +export const getWorkspaceFileIndex = perFileSet( + (allFilePaths: ReadonlySet): WorkspaceFileIndex => { + const all = [...allFilePaths]; + const normalized = all.map((f) => f.replace(/\\/g, '/')); + const normToRaw = new Map(); + for (let i = 0; i < normalized.length; i++) { + // First wins, mirroring the `for (const raw of allFilePaths)` scans this + // replaces: they returned on the first match in iteration order. + if (!normToRaw.has(normalized[i])) normToRaw.set(normalized[i], all[i]); + } -export function getWorkspaceFileIndex(allFilePaths: ReadonlySet): WorkspaceFileIndex { - const cached = WORKSPACE_FILE_INDEX_CACHE.get(allFilePaths); - if (cached !== undefined) return cached; - - const all = [...allFilePaths]; - const normalized = all.map((f) => f.replace(/\\/g, '/')); - const normToRaw = new Map(); - for (let i = 0; i < normalized.length; i++) { - // First wins, mirroring the `for (const raw of allFilePaths)` scans this - // replaces: they returned on the first match in iteration order. - if (!normToRaw.has(normalized[i])) normToRaw.set(normalized[i], all[i]); - } - - const built: WorkspaceFileIndex = { - normalized, - all, - index: buildSuffixIndex(normalized, all), - normToRaw, - }; - WORKSPACE_FILE_INDEX_CACHE.set(allFilePaths, built); - return built; -} + return { + normalized, + all, + index: buildSuffixIndex(normalized, all), + normToRaw, + }; + }, +); diff --git a/gitnexus/src/core/ingestion/languages/c/import-target.ts b/gitnexus/src/core/ingestion/languages/c/import-target.ts index 495846030..5590bb2e6 100644 --- a/gitnexus/src/core/ingestion/languages/c/import-target.ts +++ b/gitnexus/src/core/ingestion/languages/c/import-target.ts @@ -1,4 +1,5 @@ import { dirname, join } from 'path'; +import { perFileSet } from '../../import-resolvers/per-file-set.js'; /** * A workspace file path pre-decomposed for the suffix-match fallback: @@ -28,26 +29,20 @@ interface CSuffixCandidate { * `WeakMap`-keyed so it is reclaimed with the pass (no cross-pass staleness). * Shared by C and C++ (`resolveCppImportTarget` delegates here). */ -const suffixIndexByPaths = new WeakMap, Map>(); - -function suffixIndex(allFilePaths: ReadonlySet): Map { - let index = suffixIndexByPaths.get(allFilePaths); - if (index === undefined) { - index = new Map(); - for (const original of allFilePaths) { - const normalized = original.replace(/\\/g, '/'); - const basename = normalized.slice(normalized.lastIndexOf('/') + 1); - let bucket = index.get(basename); - if (bucket === undefined) { - bucket = []; - index.set(basename, bucket); - } - bucket.push({ original, normalized, depth: normalized.split('/').length }); +const suffixIndex = perFileSet((allFilePaths: ReadonlySet) => { + const index = new Map(); + for (const original of allFilePaths) { + const normalized = original.replace(/\\/g, '/'); + const basename = normalized.slice(normalized.lastIndexOf('/') + 1); + let bucket = index.get(basename); + if (bucket === undefined) { + bucket = []; + index.set(basename, bucket); } - suffixIndexByPaths.set(allFilePaths, index); + bucket.push({ original, normalized, depth: normalized.split('/').length }); } return index; -} +}); /** * Resolve a C #include path to a file in the workspace. diff --git a/gitnexus/src/core/ingestion/languages/c/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/c/scope-resolver.ts index af31ec572..c3f9fb36f 100644 --- a/gitnexus/src/core/ingestion/languages/c/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/c/scope-resolver.ts @@ -8,6 +8,7 @@ import { cArityCompatibility, cMergeBindings, resolveCImportTarget } from './ind import { scanHeaderFiles } from './header-scan.js'; import { expandCWildcardNames, isStaticName, clearStaticNames } from './static-linkage.js'; import { applyCStaticLinkageSideChannel } from './capture-side-channel.js'; +import { perFileSet } from '../../import-resolvers/per-file-set.js'; /** * Per-pass memo of the augmented `#include`-resolution file set @@ -19,31 +20,26 @@ import { applyCStaticLinkageSideChannel } from './capture-side-channel.js'; * handing it a new set identity each time. Both `allFilePaths` (built once in * scope-resolution `run.ts`) and the header set (`loadResolutionConfig` * result) are stable per pass, so the union is built once and reused. - * `WeakMap`-keyed → reclaimed with the pass (no cross-pass staleness). + * Reclaimed with the pass (no cross-pass staleness). + * + * Two inputs, so two levels of `perFileSet` composed rather than a second + * primitive: the outer memo's value is the inner memo, and a function is an + * object, which is all `T extends object` asks for. + * + * The MEMO stays private to this file even though the C++ resolver's twin is + * byte-identical. The augmented set's IDENTITY is load-bearing downstream — + * C++ delegates to `resolveCImportTarget`, whose `suffixIndex` memo is keyed on + * exactly this set — so one memo shared across the two languages would hand + * each the other's index. Same builder-shared/memo-separate rule as + * `import-resolvers/pass-cache.ts`. */ -const augmentedPathsByPass = new WeakMap< - ReadonlySet, - WeakMap, ReadonlySet> ->(); - -function augmentedFilePaths( - allFilePaths: ReadonlySet, - headerPaths: ReadonlySet, -): ReadonlySet { - let byHeaders = augmentedPathsByPass.get(allFilePaths); - if (byHeaders === undefined) { - byHeaders = new WeakMap(); - augmentedPathsByPass.set(allFilePaths, byHeaders); - } - let augmented = byHeaders.get(headerPaths); - if (augmented === undefined) { +const augmentedFilePathsFor = perFileSet((allFilePaths: ReadonlySet) => + perFileSet((headerPaths: ReadonlySet): ReadonlySet => { const set = new Set(allFilePaths); for (const h of headerPaths) set.add(h); - augmented = set; - byHeaders.set(headerPaths, augmented); - } - return augmented; -} + return set; + }), +); /** * C `ScopeResolver` registered in `SCOPE_RESOLVERS` and consumed by @@ -94,7 +90,7 @@ export const cScopeResolver: ScopeResolver = { return resolveCImportTarget( targetRaw, fromFile, - augmentedFilePaths(allFilePaths, headerPaths), + augmentedFilePathsFor(allFilePaths)(headerPaths), ); } return resolveCImportTarget(targetRaw, fromFile, allFilePaths); diff --git a/gitnexus/src/core/ingestion/languages/c/static-linkage.ts b/gitnexus/src/core/ingestion/languages/c/static-linkage.ts index 2cc195205..a81398354 100644 --- a/gitnexus/src/core/ingestion/languages/c/static-linkage.ts +++ b/gitnexus/src/core/ingestion/languages/c/static-linkage.ts @@ -1,4 +1,5 @@ import type { ParsedFile, ScopeId, SymbolDefinition } from 'gitnexus-shared'; +import { perFileSet } from '../../import-resolvers/per-file-set.js'; /** * Per-file set of function names declared with `static` storage class. @@ -59,27 +60,23 @@ export function clearStaticNames(): void { * thousands of resolved includes) that is ~10^10+ comparisons on a single * thread — the dominant term in the scope-resolution finalize grind. * - * Building the lookup once collapses it to O(R_include + F). `WeakMap`-keyed - * on the array so the index is reclaimed with the pass — no cross-pass + * Building the lookup once collapses it to O(R_include + F). `perFileSet` keys + * on the array identity so the index is reclaimed with the pass — no cross-pass * staleness (mirrors the {@link clearStaticNames} discipline for server-mode * / multi-repo reuse), and a fresh array transparently rebuilds. */ -const moduleScopeIndexByPass = new WeakMap>(); - -function moduleScopeIndex(parsedFiles: readonly ParsedFile[]): Map { - let index = moduleScopeIndexByPass.get(parsedFiles); - if (index === undefined) { - index = new Map(); +const moduleScopeIndex = perFileSet( + (parsedFiles: readonly ParsedFile[]): Map => { + const index = new Map(); // First-wins to preserve `Array.find` semantics (returns the first match). // `moduleScope` is unique per file in practice, so collisions are absent; // the guard only formalises identical behaviour to the prior `.find`. for (const p of parsedFiles) { if (!index.has(p.moduleScope)) index.set(p.moduleScope, p); } - moduleScopeIndexByPass.set(parsedFiles, index); - } - return index; -} + return index; + }, +); /** * Return the names visible through a C wildcard import (`#include`). diff --git a/gitnexus/src/core/ingestion/languages/cobol/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/cobol/scope-resolver.ts index 57b1c03d6..cfea8f39e 100644 --- a/gitnexus/src/core/ingestion/languages/cobol/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/cobol/scope-resolver.ts @@ -12,6 +12,7 @@ import path from 'node:path'; import type { ParsedFile } from 'gitnexus-shared'; import { SupportedLanguages } from 'gitnexus-shared'; +import { perFileSet } from '../../import-resolvers/per-file-set.js'; import { populateClassOwnedMembers } from '../../scope-resolution/scope/walkers.js'; import type { ScopeResolver } from '../../scope-resolution/contract/scope-resolver.js'; import { cobolProvider } from '../cobol.js'; @@ -56,12 +57,7 @@ interface CobolCopyIndex { readonly sources: ReadonlyMap; } -const COBOL_COPY_INDEX_CACHE = new WeakMap, CobolCopyIndex>(); - -function getCobolCopyIndex(allFilePaths: ReadonlySet): CobolCopyIndex { - const cached = COBOL_COPY_INDEX_CACHE.get(allFilePaths); - if (cached !== undefined) return cached; - +const getCobolCopyIndex = perFileSet((allFilePaths: ReadonlySet): CobolCopyIndex => { const copybooks = new Map(); const sources = new Map(); // One pass builds both tiers: the two scans walked the same files and @@ -79,10 +75,8 @@ function getCobolCopyIndex(allFilePaths: ReadonlySet): CobolCopyIndex { if (!tier.has(basename)) tier.set(basename, fp); } - const built: CobolCopyIndex = { copybooks, sources }; - COBOL_COPY_INDEX_CACHE.set(allFilePaths, built); - return built; -} + return { copybooks, sources }; +}); const cobolScopeResolver: ScopeResolver = { language: SupportedLanguages.Cobol, diff --git a/gitnexus/src/core/ingestion/languages/cpp/file-local-linkage.ts b/gitnexus/src/core/ingestion/languages/cpp/file-local-linkage.ts index c6b383bf0..e578b20d4 100644 --- a/gitnexus/src/core/ingestion/languages/cpp/file-local-linkage.ts +++ b/gitnexus/src/core/ingestion/languages/cpp/file-local-linkage.ts @@ -1,4 +1,5 @@ import type { ParsedFile, Scope, ScopeId, SymbolDefinition } from 'gitnexus-shared'; +import { perFileSet } from '../../import-resolvers/per-file-set.js'; import { isCppInlineNamespaceScope } from './inline-namespaces.js'; /** @@ -283,24 +284,20 @@ export function isCppDefGloballyVisible(filePath: string, nodeId: string): boole * `parsedFiles` reference; the old `parsedFiles.find(...)` was therefore O(F) * per edge → O(R·F) overall (at kernel scale the ~25–30k `.h` headers are * classified C++, so this fires hard — the C twin in `c/static-linkage.ts`). - * Building the lookup once collapses it to O(R+F). `WeakMap`-keyed so it is - * reclaimed with the pass (no cross-pass staleness; mirrors - * {@link clearFileLocalNames}). + * Building the lookup once collapses it to O(R+F). `perFileSet` keys on the + * array identity so it is reclaimed with the pass (no cross-pass staleness; + * mirrors {@link clearFileLocalNames}). */ -const moduleScopeIndexByPass = new WeakMap>(); - -function moduleScopeIndex(parsedFiles: readonly ParsedFile[]): Map { - let index = moduleScopeIndexByPass.get(parsedFiles); - if (index === undefined) { - index = new Map(); +const moduleScopeIndex = perFileSet( + (parsedFiles: readonly ParsedFile[]): Map => { + const index = new Map(); // First-wins to preserve `Array.find` semantics (returns the first match). for (const p of parsedFiles) { if (!index.has(p.moduleScope)) index.set(p.moduleScope, p); } - moduleScopeIndexByPass.set(parsedFiles, index); - } - return index; -} + return index; + }, +); export function expandCppWildcardNames( targetModuleScope: ScopeId, diff --git a/gitnexus/src/core/ingestion/languages/cpp/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/cpp/scope-resolver.ts index 13f21183e..9421975ea 100644 --- a/gitnexus/src/core/ingestion/languages/cpp/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/cpp/scope-resolver.ts @@ -48,6 +48,7 @@ import { resolveCppReceiverMember, } from './member-lookup.js'; import { stripCppSpecifiers } from './interpret.js'; +import { perFileSet } from '../../import-resolvers/per-file-set.js'; /** A pointee worth binding: a bare identifier, not `T**`, `T[]`, `A::B` or a * template spelling. Hoisted — a literal here would mint a fresh RegExp on @@ -61,32 +62,25 @@ const CPP_SIMPLE_POINTEE_RE = /^[A-Za-z_]\w*$/; * a fresh ~F-entry `Set` on every call AND defeated the shared * `resolveCImportTarget` suffix-index memo (in `c/import-target.ts`) by handing * it a new set identity each time. Both inputs are stable per pass, so the - * union is built once and reused. `WeakMap`-keyed → reclaimed with the pass. - * (Twin of the C resolver's `augmentedFilePaths`.) + * union is built once and reused. Reclaimed with the pass. + * + * Two inputs, so two levels of `perFileSet` composed rather than a second + * primitive: the outer memo's value is the inner memo, and a function is an + * object, which is all `T extends object` asks for. + * + * (Twin of the C resolver's `augmentedFilePathsFor`.) The two memos stay + * SEPARATE deliberately. C++ delegates to `resolveCImportTarget`, whose + * `suffixIndex` memo is keyed on the augmented set, so a single memo shared + * with C would hand each language the other's index — same + * builder-shared/memo-separate rule as `import-resolvers/pass-cache.ts`. */ -const augmentedPathsByPass = new WeakMap< - ReadonlySet, - WeakMap, ReadonlySet> ->(); - -function augmentedFilePaths( - allFilePaths: ReadonlySet, - headerPaths: ReadonlySet, -): ReadonlySet { - let byHeaders = augmentedPathsByPass.get(allFilePaths); - if (byHeaders === undefined) { - byHeaders = new WeakMap(); - augmentedPathsByPass.set(allFilePaths, byHeaders); - } - let augmented = byHeaders.get(headerPaths); - if (augmented === undefined) { +const augmentedFilePathsFor = perFileSet((allFilePaths: ReadonlySet) => + perFileSet((headerPaths: ReadonlySet): ReadonlySet => { const set = new Set(allFilePaths); for (const h of headerPaths) set.add(h); - augmented = set; - byHeaders.set(headerPaths, augmented); - } - return augmented; -} + return set; + }), +); /** * C++ `ScopeResolver` registered in `SCOPE_RESOLVERS` and consumed by @@ -128,7 +122,7 @@ export const cppScopeResolver: ScopeResolver = { return resolveCppImportTarget( targetRaw, fromFile, - augmentedFilePaths(allFilePaths, headerPaths), + augmentedFilePathsFor(allFilePaths)(headerPaths), ); } return resolveCppImportTarget(targetRaw, fromFile, allFilePaths); diff --git a/gitnexus/src/core/ingestion/languages/csharp/import-target.ts b/gitnexus/src/core/ingestion/languages/csharp/import-target.ts index 18d406ba6..1632c87f8 100644 --- a/gitnexus/src/core/ingestion/languages/csharp/import-target.ts +++ b/gitnexus/src/core/ingestion/languages/csharp/import-target.ts @@ -31,6 +31,7 @@ import { firstFileDirectlyInPkgDir, type PackageDirIndex, } from '../../import-resolvers/package-dir-index.js'; +import { perFileSet } from '../../import-resolvers/per-file-set.js'; import { csharpSuffixFallbackAllowed } from '../../csharp-namespace-gate.js'; export interface CsharpResolveContext { @@ -46,15 +47,10 @@ export interface CsharpResolveContext { * `import-resolvers/package-dir-index.ts`), which the no-csproj path calls once * for the direct match and then up to once per stripped namespace prefix. */ -const csharpDirIndexCache = new WeakMap, PackageDirIndex>(); - -function getCsharpDirIndex(allFilePaths: ReadonlySet): PackageDirIndex { - const cached = csharpDirIndexCache.get(allFilePaths); - if (cached) return cached; - const built = buildPackageDirIndex(allFilePaths, (normalized) => normalized.endsWith('.cs')); - csharpDirIndexCache.set(allFilePaths, built); - return built; -} +const getCsharpDirIndex = perFileSet( + (allFilePaths: ReadonlySet): PackageDirIndex => + buildPackageDirIndex(allFilePaths, (normalized) => normalized.endsWith('.cs')), +); export function resolveCsharpImportTarget( parsedImport: ParsedImport, diff --git a/gitnexus/src/core/ingestion/languages/dart/import-target.ts b/gitnexus/src/core/ingestion/languages/dart/import-target.ts index 371ff1f11..fd6c5224a 100644 --- a/gitnexus/src/core/ingestion/languages/dart/import-target.ts +++ b/gitnexus/src/core/ingestion/languages/dart/import-target.ts @@ -13,6 +13,7 @@ * `targetRaw` arrives already quote-stripped from `interpretDartImport`. */ +import { perFileSet } from '../../import-resolvers/per-file-set.js'; import { DART_HERITAGE_PREFIX } from './interpret.js'; /** @@ -35,11 +36,7 @@ interface DartFileIndex { readonly byBasename: Map; } -const DART_FILE_INDEX_CACHE = new WeakMap, DartFileIndex>(); - -function getDartFileIndex(allFilePaths: ReadonlySet): DartFileIndex { - const cached = DART_FILE_INDEX_CACHE.get(allFilePaths); - if (cached !== undefined) return cached; +const getDartFileIndex = perFileSet((allFilePaths: ReadonlySet): DartFileIndex => { const byBasename = new Map(); for (const fp of allFilePaths) { const base = fp.slice(fp.lastIndexOf('/') + 1); @@ -50,10 +47,8 @@ function getDartFileIndex(allFilePaths: ReadonlySet): DartFileIndex { } bucket.push(fp); } - const built: DartFileIndex = { byBasename }; - DART_FILE_INDEX_CACHE.set(allFilePaths, built); - return built; -} + return { byBasename }; +}); /** First file (in Set-iteration order) that IS `candidate` or ends with * `/` — the exact predicate of the scans this replaces. */ diff --git a/gitnexus/src/core/ingestion/languages/go/import-target.ts b/gitnexus/src/core/ingestion/languages/go/import-target.ts index ceb3fa62b..847af0c09 100644 --- a/gitnexus/src/core/ingestion/languages/go/import-target.ts +++ b/gitnexus/src/core/ingestion/languages/go/import-target.ts @@ -5,6 +5,7 @@ import { sortedRootFiles, type PackageDirIndex, } from '../../import-resolvers/package-dir-index.js'; +import { perFileSet } from '../../import-resolvers/per-file-set.js'; /** * Resolve a Go import path to ALL .go files in the matching package directory. @@ -56,6 +57,11 @@ export function resolveGoImportTarget( return null; } +/** Go packages exclude `_test.go` files: they are a separate package. */ +function isGoPackageFile(normalized: string): boolean { + return normalized.endsWith('.go') && !normalized.endsWith('_test.go'); +} + /** * Package index over the file set, memoized on the Set's identity (#2877). * @@ -69,20 +75,10 @@ export function resolveGoImportTarget( * is built once per run. `resolveGoImportTarget` must therefore never copy the * Set before this point — see `import-resolvers/workspace-file-index.ts`. */ -const GO_PACKAGE_INDEX_CACHE = new WeakMap, PackageDirIndex>(); - -/** Go packages exclude `_test.go` files: they are a separate package. */ -function isGoPackageFile(normalized: string): boolean { - return normalized.endsWith('.go') && !normalized.endsWith('_test.go'); -} - -function getGoPackageIndex(allFilePaths: ReadonlySet): PackageDirIndex { - const cached = GO_PACKAGE_INDEX_CACHE.get(allFilePaths); - if (cached !== undefined) return cached; - const built = buildPackageDirIndex(allFilePaths, isGoPackageFile); - GO_PACKAGE_INDEX_CACHE.set(allFilePaths, built); - return built; -} +const getGoPackageIndex = perFileSet( + (allFilePaths: ReadonlySet): PackageDirIndex => + buildPackageDirIndex(allFilePaths, isGoPackageFile), +); function findRootPackageFiles(allFilePaths: ReadonlySet): string[] { return sortedRootFiles(getGoPackageIndex(allFilePaths)); diff --git a/gitnexus/src/core/ingestion/languages/java/import-target.ts b/gitnexus/src/core/ingestion/languages/java/import-target.ts index 3d2d6716b..74ba8182b 100644 --- a/gitnexus/src/core/ingestion/languages/java/import-target.ts +++ b/gitnexus/src/core/ingestion/languages/java/import-target.ts @@ -58,6 +58,7 @@ import { firstFileDirectlyInPkgDir, type PackageDirIndex, } from '../../import-resolvers/package-dir-index.js'; +import { perFileSet } from '../../import-resolvers/per-file-set.js'; export interface JavaResolveContext { readonly fromFile: string; @@ -69,15 +70,10 @@ export interface JavaResolveContext { * identity. Feeds `firstFileDirectlyInPkgDir`, which is called once for the * direct match and then up to once per stripped package prefix. */ -const javaDirIndexCache = new WeakMap, PackageDirIndex>(); - -function getJavaDirIndex(allFilePaths: ReadonlySet): PackageDirIndex { - const cached = javaDirIndexCache.get(allFilePaths); - if (cached !== undefined) return cached; - const built = buildPackageDirIndex(allFilePaths, (normalized) => normalized.endsWith('.java')); - javaDirIndexCache.set(allFilePaths, built); - return built; -} +const getJavaDirIndex = perFileSet( + (allFilePaths: ReadonlySet): PackageDirIndex => + buildPackageDirIndex(allFilePaths, (normalized) => normalized.endsWith('.java')), +); export function resolveJavaImportTarget( parsedImport: ParsedImport, diff --git a/gitnexus/src/core/ingestion/languages/javascript/import-target.ts b/gitnexus/src/core/ingestion/languages/javascript/import-target.ts index a26dc75c5..e2d0acedc 100644 --- a/gitnexus/src/core/ingestion/languages/javascript/import-target.ts +++ b/gitnexus/src/core/ingestion/languages/javascript/import-target.ts @@ -46,7 +46,8 @@ import { SupportedLanguages } from 'gitnexus-shared'; import { resolveTsTarget, type TsResolveContext } from '../typescript/import-target.js'; -import { buildSuffixIndex, type SuffixIndex } from '../../import-resolvers/utils.js'; +import { buildImportPassCache } from '../../import-resolvers/pass-cache.js'; +import { perFileSet } from '../../import-resolvers/per-file-set.js'; export type JsResolveContext = TsResolveContext; @@ -69,16 +70,7 @@ export type JsResolveContext = TsResolveContext; * traversals of the SET, and this scan walks the materialized array behind it. * See `test/integration/javascript-import-index-reuse.test.ts` for the guard * that can. - */ -interface PassCache { - readonly allFilePaths: Set; - readonly allFileList: readonly string[]; - readonly normalizedFileList: readonly string[]; - readonly index: SuffixIndex; - readonly resolveCache: Map; -} - -/** + * * Memoized on the `allFilePaths` Set identity, like every other language's * import index (`import-resolvers/workspace-file-index.ts` and friends). * @@ -94,27 +86,7 @@ interface PassCache { * `new Set(allFilePaths)` at the adapter boundary hands a fresh key per import * and restores the per-import rebuild (PR #1918 review P1). */ -const PASS_CACHE = new WeakMap, PassCache>(); - -function passCacheFor(allFilePaths: ReadonlySet): PassCache { - const cached = PASS_CACHE.get(allFilePaths); - if (cached !== undefined) return cached; - - const allFileList = Array.from(allFilePaths); - const normalizedFileList = allFileList.map((f) => f.toLowerCase()); - const built: PassCache = { - // Copied ONCE per file set, not once per import: `TsResolveContext` wants a - // mutable `Set` and the orchestrator hands us a `ReadonlySet`. The copy is - // not the #1918 hazard because the cache KEY is the caller's original Set. - allFilePaths: new Set(allFilePaths), - allFileList, - normalizedFileList, - index: buildSuffixIndex(normalizedFileList, allFileList), - resolveCache: new Map(), - }; - PASS_CACHE.set(allFilePaths, built); - return built; -} +const passCacheFor = perFileSet(buildImportPassCache); /** * Build a memoized `resolveImportTarget` adapter for JavaScript. diff --git a/gitnexus/src/core/ingestion/languages/kotlin/import-target.ts b/gitnexus/src/core/ingestion/languages/kotlin/import-target.ts index dbbee1172..6f20e5d6f 100644 --- a/gitnexus/src/core/ingestion/languages/kotlin/import-target.ts +++ b/gitnexus/src/core/ingestion/languages/kotlin/import-target.ts @@ -1,5 +1,6 @@ import type { ParsedImport, WorkspaceIndex } from 'gitnexus-shared'; import { KOTLIN_EXTENSIONS } from '../../import-resolvers/jvm.js'; +import { perFileSet } from '../../import-resolvers/per-file-set.js'; export interface KotlinResolveContext { readonly fromFile: string; @@ -179,13 +180,9 @@ interface KotlinFileIndex { readonly dirChildren: Map; } -const KOTLIN_FILE_INDEX_CACHE = new WeakMap, KotlinFileIndex>(); - -function getKotlinFileIndex(allFilePaths: ReadonlySet): KotlinFileIndex { - const cached = KOTLIN_FILE_INDEX_CACHE.get(allFilePaths); - if (cached !== undefined) return cached; - // Cache miss: materialize a fresh index. That it happens once per run and not - // once per import is asserted by counting traversals of the Set itself, in +const getKotlinFileIndex = perFileSet((allFilePaths: ReadonlySet): KotlinFileIndex => { + // Runs on a cache miss only. That it happens once per run and not once per + // import is asserted by counting traversals of the Set itself, in // `test/integration/kotlin-import-index-reuse.test.ts` (#2909). const exactByStem = new Map(); @@ -254,10 +251,8 @@ function getKotlinFileIndex(allFilePaths: ReadonlySet): KotlinFileIndex // future mutation is a loud TypeError instead of a silent edge move. for (const bucket of dirChildren.values()) Object.freeze(bucket); - const index: KotlinFileIndex = { exactByStem, suffixByStem, dirChildren }; - KOTLIN_FILE_INDEX_CACHE.set(allFilePaths, index); - return index; -} + return { exactByStem, suffixByStem, dirChildren }; +}); function addChild(dirChildren: Map, dir: string, raw: string): void { const bucket = dirChildren.get(dir); diff --git a/gitnexus/src/core/ingestion/languages/php/import-target.ts b/gitnexus/src/core/ingestion/languages/php/import-target.ts index 57948a890..6dc3090ed 100644 --- a/gitnexus/src/core/ingestion/languages/php/import-target.ts +++ b/gitnexus/src/core/ingestion/languages/php/import-target.ts @@ -19,6 +19,7 @@ import type { ParsedFile, ParsedImport, WorkspaceIndex } from 'gitnexus-shared'; import type { ImportResolutionContext } from '../../scope-resolution/contract/scope-resolver.js'; import { resolvePhpImportInternal } from '../../import-resolvers/php.js'; import type { SuffixIndex } from '../../import-resolvers/utils.js'; +import { perFileSet } from '../../import-resolvers/per-file-set.js'; import { getWorkspaceFileIndex } from '../../import-resolvers/workspace-file-index.js'; import type { ComposerConfig } from '../../language-config.js'; import { readFileSync } from 'node:fs'; @@ -74,12 +75,6 @@ function namespaceDirectories( return [...directories]; } -// A scope-resolution pass shares one stable parsedFiles array across imports. -const phpDirectoryIndexCache = new WeakMap< - readonly ParsedFile[], - ReadonlyMap ->(); - function parentDirectory(filePath: string): string { const normalizedPath = normalizePhpPath(filePath); const separator = normalizedPath.lastIndexOf('/'); @@ -100,23 +95,25 @@ function directoryAliases(filePath: string): string[] { return [...aliases]; } -function filesByDirectory( - parsedFiles: readonly ParsedFile[], -): ReadonlyMap { - const cached = phpDirectoryIndexCache.get(parsedFiles); - if (cached) return cached; - - const mutable = new Map(); - for (const parsed of parsedFiles) { - for (const directory of directoryAliases(parsed.filePath)) { - const files = mutable.get(directory) ?? []; - files.push(parsed); - mutable.set(directory, files); +/** + * Directory alias → the files under it, built once per pass. + * + * A scope-resolution pass shares one stable `parsedFiles` array across imports, + * so the array identity is the memo key — see `perFileSet`. + */ +const filesByDirectory = perFileSet( + (parsedFiles: readonly ParsedFile[]): ReadonlyMap => { + const mutable = new Map(); + for (const parsed of parsedFiles) { + for (const directory of directoryAliases(parsed.filePath)) { + const files = mutable.get(directory) ?? []; + files.push(parsed); + mutable.set(directory, files); + } } - } - phpDirectoryIndexCache.set(parsedFiles, mutable); - return mutable; -} + return mutable; + }, +); // ─── workspace index (#2901) ─────────────────────────────────────────────── @@ -185,9 +182,8 @@ interface PhpWorkspaceIndex { readonly suffixIndex: SuffixIndex; } -const PHP_WORKSPACE_INDEX_CACHE = new WeakMap, PhpWorkspaceIndex>(); - -function buildPhpWorkspaceIndex(allFilePaths: ReadonlySet): PhpWorkspaceIndex { +/** Memoized on the `allFilePaths` Set identity, like `getWorkspaceFileIndex`. */ +const getPhpWorkspaceIndex = perFileSet((allFilePaths: ReadonlySet): PhpWorkspaceIndex => { // The Set is passed THROUGH to the shared cache, never copied — a defensive // `new Set(...)` here or in `scope-resolver.ts` would hand both WeakMaps a // fresh key per import and silently restore O(imports × files) (#1918 P1). @@ -246,16 +242,7 @@ function buildPhpWorkspaceIndex(allFilePaths: ReadonlySet): PhpWorkspace }; return { normalized, all, suffixIndex }; -} - -/** Memoized on the `allFilePaths` Set identity, like `getWorkspaceFileIndex`. */ -function getPhpWorkspaceIndex(allFilePaths: ReadonlySet): PhpWorkspaceIndex { - const cached = PHP_WORKSPACE_INDEX_CACHE.get(allFilePaths); - if (cached !== undefined) return cached; - const built = buildPhpWorkspaceIndex(allFilePaths); - PHP_WORKSPACE_INDEX_CACHE.set(allFilePaths, built); - return built; -} +}); // ─── loadResolutionConfig ────────────────────────────────────────────────── diff --git a/gitnexus/src/core/ingestion/languages/python/import-target.ts b/gitnexus/src/core/ingestion/languages/python/import-target.ts index 692ee3216..d21997940 100644 --- a/gitnexus/src/core/ingestion/languages/python/import-target.ts +++ b/gitnexus/src/core/ingestion/languages/python/import-target.ts @@ -11,6 +11,7 @@ */ import type { ParsedFile, ParsedImport, WorkspaceIndex } from 'gitnexus-shared'; +import { perFileSet } from '../../import-resolvers/per-file-set.js'; import { resolvePythonImportInternal } from '../../import-resolvers/python.js'; export interface PythonResolveContext { @@ -347,13 +348,9 @@ interface PythonFileIndex { readonly dirPrefixes: Set; } -const PYTHON_FILE_INDEX_CACHE = new WeakMap, PythonFileIndex>(); - -function getPythonFileIndex(allFilePaths: ReadonlySet): PythonFileIndex { - const cached = PYTHON_FILE_INDEX_CACHE.get(allFilePaths); - if (cached !== undefined) return cached; - // Cache miss: materialize a fresh index. That it happens once per run and not - // once per import is asserted by counting traversals of the Set itself, in +const getPythonFileIndex = perFileSet((allFilePaths: ReadonlySet): PythonFileIndex => { + // Runs on a cache miss only. That it happens once per run and not once per + // import is asserted by counting traversals of the Set itself, in // `test/integration/python-import-index-reuse.test.ts` — the PR #1918 review // P1 guard (#2909). @@ -413,10 +410,8 @@ function getPythonFileIndex(allFilePaths: ReadonlySet): PythonFileIndex } } - const index: PythonFileIndex = { normSet, byBasename, byInitParent, dirPrefixes }; - PYTHON_FILE_INDEX_CACHE.set(allFilePaths, index); - return index; -} + return { normSet, byBasename, byInitParent, dirPrefixes }; +}); function pythonImportedSubmoduleTarget(parsedImport: ParsedImport): string | null { if (parsedImport.kind !== 'named' && parsedImport.kind !== 'alias') return null; diff --git a/gitnexus/src/core/ingestion/languages/rust/qualified-call.ts b/gitnexus/src/core/ingestion/languages/rust/qualified-call.ts index 65d1dd5f4..54047c6ff 100644 --- a/gitnexus/src/core/ingestion/languages/rust/qualified-call.ts +++ b/gitnexus/src/core/ingestion/languages/rust/qualified-call.ts @@ -33,6 +33,7 @@ */ import type { ParsedFile, Scope, ScopeId, SymbolDefinition } from 'gitnexus-shared'; +import { perFileSet } from '../../import-resolvers/per-file-set.js'; import { isOverloadableCallable } from '../../utils/callable-labels.js'; import { lookupBindingsAt } from '../../scope-resolution/scope/walkers.js'; import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js'; @@ -53,16 +54,9 @@ import { * The hook is invoked per call site; rebuilding the index each time would make * qualified-call resolution O(sites x files). */ -const MODULE_INDEX_CACHE = new WeakMap, RustModuleIndex>(); - -function moduleIndexFor(allFilePaths: ReadonlySet): RustModuleIndex { - let index = MODULE_INDEX_CACHE.get(allFilePaths); - if (index === undefined) { - index = buildRustModuleIndex(allFilePaths); - MODULE_INDEX_CACHE.set(allFilePaths, index); - } - return index; -} +const moduleIndexFor = perFileSet( + (allFilePaths: ReadonlySet): RustModuleIndex => buildRustModuleIndex(allFilePaths), +); export function resolveRustQualifiedFreeCall( site: { readonly name: string; readonly rawQualifiedName?: string; readonly inScope: ScopeId }, @@ -488,6 +482,15 @@ interface PassModuleIndex { readonly inlineModuleKeys: ReadonlySet; } +/** + * DELIBERATELY NOT ON `import-resolvers/per-file-set.ts` (#2909 sweep), unlike + * {@link moduleIndexFor} above. {@link passIndexFor} takes THREE inputs — + * `workspaceIndex`, `index` and `scopes` — and keys on the first alone; the + * builder reads `scopes.defs.byId` and `index`, neither of which is derivable + * from the key, and `perFileSet`'s `build: (key) => T` hands the builder + * nothing but the key. Sound here only because all three share the resolution + * pass's lifetime, which is an invariant the primitive cannot express. + */ const MODULE_SCOPE_CACHE = new WeakMap(); function moduleKey(module: RustModule): string { diff --git a/gitnexus/src/core/ingestion/languages/swift/import-target.ts b/gitnexus/src/core/ingestion/languages/swift/import-target.ts index 5c0c2f662..e0d5e8219 100644 --- a/gitnexus/src/core/ingestion/languages/swift/import-target.ts +++ b/gitnexus/src/core/ingestion/languages/swift/import-target.ts @@ -25,6 +25,7 @@ */ import type { ParsedImport, WorkspaceIndex } from 'gitnexus-shared'; +import { perFileSet } from '../../import-resolvers/per-file-set.js'; export interface SwiftResolveContext { readonly fromFile: string; @@ -39,12 +40,7 @@ interface SwiftModuleIndex { readonly byModule: Map; } -const SWIFT_MODULE_INDEX_CACHE = new WeakMap, SwiftModuleIndex>(); - -function getSwiftModuleIndex(allFilePaths: ReadonlySet): SwiftModuleIndex { - const cached = SWIFT_MODULE_INDEX_CACHE.get(allFilePaths); - if (cached !== undefined) return cached; - +const getSwiftModuleIndex = perFileSet((allFilePaths: ReadonlySet): SwiftModuleIndex => { const byModule = new Map(); for (const raw of allFilePaths) { const norm = raw.replace(/\\/g, '/'); @@ -66,10 +62,8 @@ function getSwiftModuleIndex(allFilePaths: ReadonlySet): SwiftModuleInde } } - const index: SwiftModuleIndex = { byModule }; - SWIFT_MODULE_INDEX_CACHE.set(allFilePaths, index); - return index; -} + return { byModule }; +}); export function resolveSwiftImportTarget( parsedImport: ParsedImport, diff --git a/gitnexus/src/core/ingestion/languages/typescript/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/typescript/scope-resolver.ts index 6b1dca190..bcaeffa51 100644 --- a/gitnexus/src/core/ingestion/languages/typescript/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/typescript/scope-resolver.ts @@ -22,7 +22,8 @@ import { simpleKey } from '../../scope-resolution/graph-bridge/node-lookup.js'; import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js'; import { typescriptProvider } from '../typescript.js'; import { loadTsconfigPaths, type TsconfigPaths } from '../../language-config.js'; -import { buildSuffixIndex, type SuffixIndex } from '../../import-resolvers/utils.js'; +import { buildImportPassCache } from '../../import-resolvers/pass-cache.js'; +import { perFileSet } from '../../import-resolvers/per-file-set.js'; import { indexOnlyElementType } from '../../type-extractors/shared.js'; import { typescriptArityCompatibility, @@ -54,24 +55,6 @@ const TYPESCRIPT_TYPE_ONLY_BINDING_TYPES = new Set([ 'Decorator', ]); -/** - * Everything `resolveTsTarget` derives from one workspace file set: the file - * list, the lower-cased file list, the suffix index and the per-pass - * `resolveCache`. - * - * Without this memoization `resolveTsTarget` re-derived `allFileList` and - * `normalizedFileList` (both O(N_files)), rebuilt the index and threw away the - * `resolveCache` on every import — O(N_files × N_imports) total work for what - * should be O(N_files + N_imports). - */ -interface TsPassCache { - readonly allFilePaths: Set; - readonly allFileList: readonly string[]; - readonly normalizedFileList: readonly string[]; - readonly index: SuffixIndex; - readonly resolveCache: Map; -} - /** * Memoized on the `allFilePaths` Set identity, like every other language's * import index (`import-resolvers/workspace-file-index.ts` and friends). @@ -89,27 +72,7 @@ interface TsPassCache { * `new Set(allFilePaths)` at the adapter boundary hands a fresh key per import * and restores the per-import rebuild (PR #1918 review P1). */ -const TS_PASS_CACHE = new WeakMap, TsPassCache>(); - -function tsPassCacheFor(allFilePaths: ReadonlySet): TsPassCache { - const cached = TS_PASS_CACHE.get(allFilePaths); - if (cached !== undefined) return cached; - - const allFileList = Array.from(allFilePaths); - const normalizedFileList = allFileList.map((f) => f.toLowerCase()); - const built: TsPassCache = { - // Copied ONCE per file set, not once per import: `TsResolveContext` wants a - // mutable `Set` and the orchestrator hands us a `ReadonlySet`. The copy is - // not the #1918 hazard because the cache KEY is the caller's original Set. - allFilePaths: new Set(allFilePaths), - allFileList, - normalizedFileList, - index: buildSuffixIndex(normalizedFileList, allFileList), - resolveCache: new Map(), - }; - TS_PASS_CACHE.set(allFilePaths, built); - return built; -} +const tsPassCacheFor = perFileSet(buildImportPassCache); /** * Build a `resolveImportTarget` adapter that reads the memoized per-file-set diff --git a/gitnexus/src/core/ingestion/languages/vue/import-target.ts b/gitnexus/src/core/ingestion/languages/vue/import-target.ts index da342f883..a50c4aa54 100644 --- a/gitnexus/src/core/ingestion/languages/vue/import-target.ts +++ b/gitnexus/src/core/ingestion/languages/vue/import-target.ts @@ -20,21 +20,14 @@ import { SupportedLanguages } from 'gitnexus-shared'; import { resolveTsTarget, type TsResolveContext } from '../typescript/import-target.js'; -import { buildSuffixIndex, type SuffixIndex } from '../../import-resolvers/utils.js'; +import { buildImportPassCache } from '../../import-resolvers/pass-cache.js'; +import { perFileSet } from '../../import-resolvers/per-file-set.js'; import type { TsconfigPaths } from '../../language-config.js'; interface VueResolutionConfig { readonly tsconfigPaths: TsconfigPaths | null; } -interface PassCache { - readonly allFilePaths: Set; - readonly allFileList: readonly string[]; - readonly normalizedFileList: readonly string[]; - readonly index: SuffixIndex; - readonly resolveCache: Map; -} - /** * Memoized on the `allFilePaths` Set identity, like every other language's * import index (`import-resolvers/workspace-file-index.ts` and friends). @@ -53,27 +46,7 @@ interface PassCache { * `new Set(allFilePaths)` at the adapter boundary hands a fresh key per import * and restores the per-import rebuild (PR #1918 review P1). */ -const PASS_CACHE = new WeakMap, PassCache>(); - -function passCacheFor(allFilePaths: ReadonlySet): PassCache { - const cached = PASS_CACHE.get(allFilePaths); - if (cached !== undefined) return cached; - - const allFileList = Array.from(allFilePaths); - const normalizedFileList = allFileList.map((f) => f.toLowerCase()); - const built: PassCache = { - // Copied ONCE per file set, not once per import: `TsResolveContext` wants a - // mutable `Set` and the orchestrator hands us a `ReadonlySet`. The copy is - // not the #1918 hazard because the cache KEY is the caller's original Set. - allFilePaths: new Set(allFilePaths), - allFileList, - normalizedFileList, - index: buildSuffixIndex(normalizedFileList, allFileList), - resolveCache: new Map(), - }; - PASS_CACHE.set(allFilePaths, built); - return built; -} +const passCacheFor = perFileSet(buildImportPassCache); /** * Build a memoized `resolveImportTarget` adapter for Vue SFCs.