fix(resolution): a call through a class name reaches that class, not one whose name contains it (#1935)

A call through a class name, `Logger.log()`, or through a receiver named
after one, `logger.log()`, now binds to that class itself: its own
method, else the nearest one a declared supertype has. Before, the
method was any one in the class's file whose qualified name contained
the class's name, so a `FileLogger` declared first took every
`Logger.log()`, and Kotlin's `Foo.create()` went to `FooBar`'s.

Reworked from the exact-owner rule alone, which lost inherited methods
(Django's `form.is_valid()` on `Form(BaseForm)`, Alamofire's protocol
methods). Also in this change:

- A class declared inside a function is named only there: a call from
  anywhere else no longer reaches a class a test declares in its body,
  through the class name, the receiver's name, or the method-name
  fallback. Those methods were never in the running, and the one method
  such a filter leaves is still only a guess by the receiver's name.
- Production code never names a test suite's class, as the method-name
  fallback already held: `manager.check()` in Django's
  `Model._check_managers` is no test app's `Manager` model.
- A Python base built by a class's factory, Django's
  `class Manager(BaseManager.from_queryset(QuerySet))`, names that class
  as the supertype, besides the factory's arguments.

A/B against main with fresh indexes, every changed edge triaged. Django:
67 links added (model instances reaching `Model.save`, inherited lookups
and expressions), 40 removed (links into test-local classes from outside
their function, and one polymorphic manager call), 263 retargeted:
about 170 to the right class (`user.has_perm` to `PermissionsMixin`
rather than `AnonymousUser`, `parser.parse` to the template `Parser`
rather than `MultiPartParser`, `field.get_lookup` to
`RegisterLookupMixin`), about 90 from one guess to another, and about
20 worse, where a receiver named after a model holds something else
(`session.save()` on a session store now guesses the `Session` model).
flask: one fix (`app.get` to `Scaffold.get`), 3 wrong links removed.
Alamofire: one fix and 3 protocol-typed locals that now guess the
`Adapter` class. zod, excalidraw and spring-petclinic: higher
confidence on the same targets. gin, cobra, express, eShopOnWeb and
jekyll are byte-identical. Full suite on the merged tree: 7,908 passed,
0 failed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Eric Minish
2026-10-20 19:55:52 +00:00
committed by GitHub
co-authored by Claude Opus 5.5
parent f76021db22
commit e043efbc84
3 changed files with 394 additions and 28 deletions
+67 -28
View File
@@ -11361,22 +11361,6 @@ function isMethodOwnerKind(n: Node): boolean {
(n.language === 'scala' && n.kind === 'module');
}
/**
* Is `n` the method `name` of the class `cls` a receiver is taken to name
* (the class-name and capitalized-receiver strategies of matchMethodCall)?
* In C and C++, only one of exactly that class: protobuf's `arena.get()`
* took the `get` of `Arena::UniquePtr`, a class nested in `Arena`, since its
* qualified name has `Arena` in it, as `ThreadSafeArena::get`'s does. In
* other languages, any method of the name whose qualified name has the
* class's name in it, as before.
*/
function isClassStrategyMethod(n: Node, cls: Node, name: string): boolean {
if (n.kind !== 'method' || n.name !== name) return false;
return cls.language === 'c' || cls.language === 'cpp'
? n.qualifiedName === `${cls.qualifiedName}::${name}`
: n.qualifiedName.includes(cls.name);
}
/**
* When a symbol name is ambiguous across files, prefer the candidate(s) declared
* in the call site's own file, keeping the rest in their original order (#1079).
@@ -17214,7 +17198,11 @@ function readDeclaredSupertypes(cls: Node, context: ResolutionContext): string[]
text ? [...text.matchAll(/([A-Za-z_][\w.:\\]*)/g)].map((m) => m[1]!.split(/::|\.|\\/).pop()!).filter((w) => !/^(?:metaclass|object)$/.test(w)) : [];
switch (cls.language) {
case 'pascal': return names(/=\s*class\s*\(([^)]*)\)/i.exec(head)?.[1]);
case 'python': return names(/\bclass\s+\w+\s*\(([^)]*)\)/.exec(head)?.[1]?.replace(/\w+\s*=\s*[\w.]+/g, ''));
// A base built by a class's factory — Django's `class Manager(
// BaseManager.from_queryset(QuerySet))` — subclasses that class: the
// qualifier is the supertype, not the method called on it.
case 'python': return names(/\bclass\s+\w+\s*\(([^)]*)\)/.exec(head)?.[1]?.replace(/\w+\s*=\s*[\w.]+/g, '')
.replace(/([A-Za-z_][\w.]*)\.\w+\s*\(/g, '$1 ('));
case 'ruby': return names(/\bclass\s+[\w:]+\s*<\s*([\w:]+)/.exec(head)?.[1]);
case 'php': return names(/\bextends\s+([\w.\\]+)/.exec(head)?.[1]);
case 'typescript': case 'tsx': case 'javascript': case 'jsx':
@@ -18417,9 +18405,17 @@ export function matchMethodCall(
// staxrip's `FrameServerFactory.Create(…)` went to its AutoCrop tool's copy.
if (ref.language === 'vbnet') classCandidates = preferVbProject(classCandidates, ref, context);
const productionCaller = !isTestPath(ref.filePath);
for (const classNode of classCandidates) {
// Skip cross-language class matches
if (classNode.language !== ref.language) continue;
// A class declared inside a function is named only in there: a
// `File.write(…)` elsewhere is not the `File` a Django test declares.
if (!isLexicallyReachable(classNode, ref, context)) continue;
// Production code never names a test suite's class: Django's
// `manager.check()` in `Model._check_managers` is no test app's
// `Manager` model (whose `check` it inherits from `Model`).
if (productionCaller && isTestPath(classNode.filePath)) continue;
// C++'s `Outer::Count()` is the member C++ looks up in `Outer`: its
// own, else one a base declares (cppMethodOf), never one of a class
@@ -18431,8 +18427,7 @@ export function matchMethodCall(
return { original: ref, targetNodeId: method.id, confidence: own ? 0.85 : 0.8, resolvedBy: 'qualified-name' };
}
const nodesInFile = context.getNodesInFile(classNode.filePath);
const methodNode = nodesInFile.find((n) => isClassStrategyMethod(n, classNode, methodName!));
const methodNode = classStrategyMethod(classNode, methodName!, context);
if (methodNode) {
return {
@@ -18500,7 +18495,7 @@ export function matchMethodCall(
}
// Strategy 2: Instance variable receiver - try capitalized form to find class
// e.g., "permissionEngine" → look for classes containing "PermissionEngine"
// e.g., "permissionEngine" → look for a class named "PermissionEngine"
const capitalizedReceiver = objectOrClass!.charAt(0).toUpperCase() + objectOrClass!.slice(1);
if (capitalizedReceiver !== objectOrClass) {
const strat2 = nmTimedT('mc-capital', ref, (): ResolvedRef | null => {
@@ -18508,16 +18503,16 @@ export function matchMethodCall(
withinCppTranslationUnit(context.getNodesByName(capitalizedReceiver).filter(isMethodOwnerKind), ref, context),
ref.filePath,
);
const productionCaller = !isTestPath(ref.filePath);
for (const classNode of fuzzyClassCandidates) {
// Skip cross-language class matches
if (classNode.language !== ref.language) continue;
// Nor a class declared inside another function, nor a test suite's
// class from production code (see Strategy 1).
if (!isLexicallyReachable(classNode, ref, context)) continue;
if (productionCaller && isTestPath(classNode.filePath)) continue;
const nodesInFile = context.getNodesInFile(classNode.filePath);
const methodNode = nodesInFile.find((n) => isClassStrategyMethod(n, classNode, methodName!) &&
// A Go standard method name is the type's own, not one of another
// type its name is part of: kubernetes' `status.String()` is no
// `RuntimeStatus::String` beside a `Status` type.
(!goStd || n.qualifiedName === `${classNode.name}::${methodName}`));
const methodNode = classStrategyMethod(classNode, methodName!, context);
if (methodNode) {
return {
@@ -18587,6 +18582,15 @@ export function matchMethodCall(
// rest_framework/renderers.py's `view.reverse_action(…)` is not a test's
// `DummyView`. The test's methods were never in the running.
if (!isTestPath(ref.filePath)) targetMethods = targetMethods.filter((m) => !isTestPath(m.filePath));
// Nor is it a method of a class another function declares: Django's
// `file.write(…)` in one test is not the `File` a serializer test
// declares in its body. The one method such a filter may leave is still
// only a guess by the receiver's name (the scoring below), never the
// only method of the name: `cache.aadd()` is `BaseCache`'s, while
// `value.split()` is no test model's `split`.
const reachable = targetMethods.filter((m) => isLexicallyReachable(m, ref, context));
const scopedOut = reachable.length !== targetMethods.length;
targetMethods = reachable;
// A Vue component's own method is reached as `this.m()` inside it —
// never as `e.preventDefault()` on an event, nor `this.editor.setValue()`
// on something the component holds. A template ref
@@ -18603,7 +18607,7 @@ export function matchMethodCall(
// (`web_push_request.legacy_encrypt` → WebPushRequest). rubocop's
// `node.loc` on a rubocop-ast node went to the one `loc` in the project
// 1,201 times; lobsters' `value.to_s` to a short-id class's.
if (targetMethods.length === 1 && !narrowed && targetMethods[0]!.language === ref.language &&
if (targetMethods.length === 1 && !narrowed && !scopedOut && targetMethods[0]!.language === ref.language &&
// A test double is only what a test names — as in the scoring below:
// allauth's `resp.json()` on a Django test response is not the one
// `json` of its `MockedResponse`.
@@ -18627,7 +18631,7 @@ export function matchMethodCall(
}
// Multiple methods: score by receiver name word overlap with class name
if (targetMethods.length > 1) {
if (targetMethods.length > 1 || (targetMethods.length === 1 && scopedOut && !narrowed)) {
// What the receiver is named after is its last link: `builder.tokeniser`
// is a Tokeniser, `table.Columns` no kind of table.
const receiverWords = splitCamelCase(receiverLink(objectOrClass!));
@@ -20883,6 +20887,41 @@ function tsFieldCall(
return resolveMethodOnType(typeName, methodName, ref, context, 0.85, 'instance-method', imported);
}
/**
* The `method` named `methodName` declared on `classNode`, among the nodes of
* the class's file: the class's exact qualified name first, then the
* owner-by-name form the resolver uses elsewhere (`Logger::log`,
* `ns::Logger::log`). Never a substring test: `FileLogger::log` contains
* `Logger`.
*
* Known limitation — same-named NESTED classes: the owner-by-name form also
* matches `Outer::Logger::log`, and every same-named class is a candidate in
* the caller's loop, so a nested `Outer::Logger` can take a top-level
* `Logger.log()` (declared first, or the only one with the method). Choosing
* among them needs each language's scope rules.
*/
function findOwnMethod(nodesInFile: Node[], classNode: Node, methodName: string): Node | undefined {
const methods = nodesInFile.filter((n) => n.kind === 'method' && n.name === methodName);
const exact = `${classNode.qualifiedName}::${methodName}`;
const byName = `${classNode.name}::${methodName}`;
return (
methods.find((n) => n.qualifiedName === exact) ??
methods.find((n) => n.qualifiedName === byName || n.qualifiedName.endsWith(`::${byName}`))
);
}
/**
* The method a class-name or capitalized-receiver call reaches on `classNode`:
* its own (findOwnMethod), else the nearest one a declared supertype has, as a
* call on an instance of the class finds it. Django's `form.is_valid()` on
* `Form` reaches `BaseForm::is_valid`, which the substring test this replaced
* only found because `BaseForm` contains `Form`.
*/
function classStrategyMethod(classNode: Node, methodName: string, context: ResolutionContext): Node | undefined {
return findOwnMethod(context.getNodesInFile(classNode.filePath), classNode, methodName) ??
inheritedClassMethod([classNode], methodName, context) ?? undefined;
}
/**
* The one fallback a TS/JS/Python call-receiver chain keeps (#1683): a STORE
* ACCESSOR. Zustand's `get()` inside the store factory and