diff --git a/gitnexus/src/core/ingestion/languages/r/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/r/scope-resolver.ts index 48c389e5d..0d32be7e6 100644 --- a/gitnexus/src/core/ingestion/languages/r/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/r/scope-resolver.ts @@ -34,6 +34,7 @@ import type { GraphNodeLookup } from '../../scope-resolution/graph-bridge/node-l import type { KnowledgeGraph } from '../../../graph/types.js'; import { generateId } from '../../../../lib/utils.js'; import { loadRPackageConfig } from './package-config.js'; +import { perFileSet } from '../../import-resolvers/per-file-set.js'; import { resolveRImportTarget } from '../../import-resolvers/r.js'; import { isRGlobalNameFallbackPlausible, @@ -103,6 +104,23 @@ function emitRVirtualHeritageEdges( } } +/** + * `moduleScope` → its `ParsedFile`, built once per pass and keyed on the identity of the pass's + * `parsedFiles` array, which finalize passes unchanged to every `expandsWildcardTo` call. The + * lookup used to be `parsedFiles.find`, one scan of all files per `library()` edge: 450,000 edges + * over 3,000 files took 5.9 s. As in the C, C++ and Python providers, the first file wins on a + * repeated scope, as `find` did. + */ +const parsedFileByModuleScope = perFileSet( + (parsedFiles: readonly ParsedFile[]): Map => { + const index = new Map(); + for (const parsed of parsedFiles) { + if (!index.has(parsed.moduleScope)) index.set(parsed.moduleScope, parsed); + } + return index; + }, +); + /** * Enumerate all top-level names exported from a target module scope's file * (module-scope function and class bindings; functions nested in another function are not). @@ -126,7 +144,7 @@ export function expandRWildcardNames( targetModuleScope: ScopeId, parsedFiles: readonly ParsedFile[], ): readonly string[] { - const target = parsedFiles.find((p) => p.moduleScope === targetModuleScope); + const target = parsedFileByModuleScope(parsedFiles).get(targetModuleScope); if (target === undefined) return []; const { names: topLevel, dottedTails } = rFileTopLevel(target); const seen = new Set(); diff --git a/gitnexus/test/unit/scope-resolution/r/r-dotted-s3-hooks.test.ts b/gitnexus/test/unit/scope-resolution/r/r-dotted-s3-hooks.test.ts index 3df1da79d..67938d658 100644 --- a/gitnexus/test/unit/scope-resolution/r/r-dotted-s3-hooks.test.ts +++ b/gitnexus/test/unit/scope-resolution/r/r-dotted-s3-hooks.test.ts @@ -13,6 +13,7 @@ import { rIsCallableVisibleFromCaller, rScopeResolver, } from '../../../../src/core/ingestion/languages/r/scope-resolver.js'; +import { countedParsedFiles } from '../../../helpers/counting-file-set.js'; function parse(src: string, filePath: string): ParsedFile { const parsed = extractParsedFile(rProvider, src, filePath); @@ -146,3 +147,21 @@ describe('expandRWildcardNames', () => { expect(expandRWildcardNames('scope:missing' as never, [a])).toEqual([]); }); }); + +describe('expandRWildcardNames: the module-scope lookup is built once per pass', () => { + it('reads each parsed file once however many wildcard edges ask', () => { + // `parsedFiles.find` per edge read the files up to the match every time. Counted element + // reads, not wall time: the figure depends on the file count and the number of passes only. + const paths = Array.from({ length: 200 }, (_, i) => `pkg/R/f${i}.R`); + const { parsedFiles, reads } = countedParsedFiles(paths); + for (let edge = 0; edge < 1_000; edge++) { + expandRWildcardNames(`module:${paths[(edge * 7) % paths.length]}`, parsedFiles); + } + expect(reads()).toBe(paths.length); + }); + + it('answers a scope no file owns with nothing', () => { + const { parsedFiles } = countedParsedFiles(['pkg/R/a.R']); + expect(expandRWildcardNames('module:absent' as never, parsedFiles)).toEqual([]); + }); +});