perf(resolution): skip receiver typing for JS/TS calls of a method no node is named (#2840)

A JS/TS call of a method that no node in the index is named, like
`list.push()`, `fs.writeFileSync()` or a test library's matcher, still
went through matchMethodCall's receiver-type inference, the TS binding
and import readers, and the class strategies. Every one of them ends in a
lookup by the method's name, so for such a name they can only answer
null. The one exception is the object-literal reader, which follows a
member to the binding the literal gives it: `const api = { alias:
renamed }` makes `api.alias()` a call of `renamed` (Issue #1932).

Root cause: matchMethodCall reads the calling file for the receiver's type
before anything tests the method's name.

Fix: the resolution context answers isAbsentName(name) from the set of
node names warmCaches already loads for the pre-filter. The answer is
exact, like getNodesByName, and false whenever the set isn't loaded. For a
JS-family call whose member is absent, matchMethodCall asks only the
object-literal reader, exactly as the full path would. When the reader
answers, the full path runs unchanged; otherwise the call returns null at
once. CodeGraph.indexFiles now drops the resolver's caches after it stores,
as indexAll and sync already do before they resolve, so a later resolution
never reads the name set from before the new files.

Validation:
- New __tests__/absent-member-calls.test.ts (6 cases). The skip reads
  nothing of the calling file (red on main). The name set answers only
  once loaded. Every path that links a member to a symbol of another name
  is pinned with the member asserted absent: a literal alias, a shorthand
  member bound to a renamed import, an `as const` literal, a holder in the
  same file and one imported, and a renamed re-export read through a
  namespace import. A method added through indexFiles after indexAll still
  links (main links it; the skip without the cache drop did not). A
  fixture indexes identically with and without the skip.
- Graphs byte-identical to main (scripts/dump-graph.mjs: nodes, edges with
  metadata, refs, files), kernel built per arm, on express,
  vue-element-admin, excalidraw and this repository (JS, TS, Vue, Svelte,
  Astro) and on the controls flask, gson, gin, ripgrep and slim (Python,
  Java, Go, Rust, PHP). Default mode (pooled on the two largest) for all
  nine, single-threaded for the JS four, and again after rebasing onto
  the current main. Main run twice is identical too. An incremental sync
  adding a file that declares formerly absent names (1,088 failed refs
  retried) is identical between the arms, with and without git, and
  matches a fresh index.
- Survey of main (instrumented build, nine repos, every language): no call
  whose member no node is named was resolved by matchMethodCall. The skip
  fires on exactly the calls the survey found (12,354 here).
- Profile counters, single-threaded: receiver inference runs 38,626 times
  instead of 50,980 on this repository, 6,460 instead of 9,306 on
  excalidraw, 2,488 instead of 3,586 on express. Outcome counts are equal.
- Cost, medians of 5 interleaved runs: the resolve loop on this repository
  4.54 to 4.41 s pooled and 16.85 to 16.29 s single-threaded, faster in 5
  of 5 paired rounds each; resolution phase -2.1% and -2.3%; whole index
  -1.3% and -2.2%. excalidraw's loop -1.6%, express's -5.0%; the five
  controls within noise (3 runs each).

Fixes #2829

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-10-23 12:48:07 +00:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 6500a3782b
commit d833827503
6 changed files with 304 additions and 1 deletions
+5 -1
View File
@@ -884,7 +884,11 @@ export class CodeGraph {
return { success: false, filesIndexed: 0, filesSkipped: 0, filesErrored: 0, nodesCreated: 0, edgesCreated: 0, errors: [{ message: 'Could not acquire file lock - another process may be indexing', severity: 'error' as const }], durationMs: 0 };
}
try {
return this.orchestrator.indexFiles(filePaths);
// The files' nodes are new to the resolver: a resolution after this
// reloads its caches, the set of node names included, as indexAll and
// sync do before they resolve, instead of reading the graph as it was
// before (#2829).
return this.orchestrator.indexFiles(filePaths).finally(() => this.resolver.clearCaches());
} finally {
this.fileLock.release();
}
+4
View File
@@ -774,6 +774,10 @@ export class ReferenceResolver {
return result;
},
// The set warmCaches loads (`SELECT DISTINCT name FROM nodes`, the exact
// match getNodesByName makes); unloaded, no name is absent.
isAbsentName: (name: string) => this.knownNames !== null && !this.knownNames.has(name),
getMethodMatches: (typeName: string, methodName: string, language: Language) => {
const key = `${language} ${typeName}::${methodName}`;
const cached = this.methodMatchCache.get(key);
+16
View File
@@ -19728,6 +19728,22 @@ export function matchMethodCall(
}
const [, objectOrClass, methodName] = match;
// A JS/TS call of a member no node in the index is named — `list.push()`,
// `fs.writeFileSync()`, a test library's matcher — is one only the
// object-literal reader below can link (#2829). It follows a member to the
// binding the literal gives it: `const api = { alias: renamed }` makes
// `api.alias()` a call of `renamed` (#1932). Every other strategy looks the
// member up by its own name and finds nothing, so reading the calling file
// for the receiver's type is wasted work. When the reader answers, the
// whole path below runs and decides, as before.
if (ref.referenceKind === 'calls' && JS_FAMILY.has(ref.language) && context.isAbsentName?.(methodName!)) {
const literal = nmTimedT('mc-absent', ref, () => dotMatch && !objectOrClass!.includes('.')
? resolveObjectPathMember(objectOrClass!, methodName!, ref, context, null)
: null);
if (!literal) return null;
}
// A simple `receiver.method` / `receiver:method` / `receiver$method` shape whose
// receiver type we can try to infer from its local declaration.
// A python attribute receiver (`self.conn`, `cfg.client`) is not one: the
+9
View File
@@ -101,6 +101,15 @@ export interface ResolutionContext {
getNodesInFileNamed?(filePath: string, name: string): Node[];
/** Get all nodes by name */
getNodesByName(name: string): Node[];
/**
* Whether no node in the index has exactly this name, answered from the
* resolver's set of every node name rather than a query (#2829). False
* whenever that set isn't loaded (before `warmCaches`, after
* `clearCaches`), so a caller only ever acts on a certain absence. A view
* that hides nodes keeps its base's answer, which stays certain: hiding
* nodes adds no name. Optional: a context without it never answers.
*/
isAbsentName?(name: string): boolean;
/** Get all nodes by qualified name */
getNodesByQualifiedName(qualifiedName: string): Node[];
/** Get all nodes of a kind */