mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-23 06:50:54 +08:00
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:
co-authored by
Claude Opus 5.5
parent
f76021db22
commit
e043efbc84
@@ -204,6 +204,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
- In React with TypeScript, a component that renders a generic component and passes it type arguments, like outline's `<PaginatedList<Document> items={…} />` or excalidraw's `<DropdownMenuItemContentRadio<"contain" | "overlap"> …>`, now links to the component it renders. Before, a tag followed by type arguments was skipped, so the generic component looked unused by the screens that render it, and `codegraph_explore`'s call paths, callers and impact stopped short of it. Re-index React projects after upgrading.
|
||||
- In React, a component that a file declares itself as a value is now what that file's JSX renders, whatever else in the repository shares its name. A lazily loaded page, like `const RegisterPage = Loadable(lazy(() => import('./pages/Register')))`, a `dynamic(() => import(…))` component or one loaded only in development (`IS_DEV ? lazy(…) : null`), links to the component its module exports, including the one its loader picks and one an `export default observer(Login)` wraps; a wrapped component like `const Settings = observer(function Settings() {…})`, `React.memo(Rows, areEqual)` or `forwardRef(ComboboxInner) as {…}` links to the function it wraps; and an alias like `const DialogPortal = DialogPrimitive.Portal` links to its own declaration. Before, `<RegisterPage />` was linked to any lone component of that name elsewhere, often in another app of the same repository, so `codegraph_explore`, callers and impact followed it into the wrong app while the page it really renders had no caller. React Router layouts declared this way follow the same rule, and a TSX type named like a value the file declares now stays in that file. Re-index React projects after upgrading.
|
||||
- In React, a JSX tag imported from one of your own modules now links to the component that module exports under that name, read the way a lazily loaded page's module already is. That includes going through an `index` file or barrel that passes the component on, like outline's `Sidebar/index.ts` with `import Sidebar from './App'; export default Sidebar` or its `Avatar/index.ts` with `const Avatar = AvatarWithHoverCard`, and past a wrapper such as `export default observer(AppSidebar)`, `React.memo(StaticCanvas, areEqual)` or mastodon's `const MemoizedBlurhash = memo(Blurhash)`. Before, a tag was matched by its name and the import only broke ties, so outline's `<Sidebar>` linked to an unrelated styled `Sidebar` in a sibling file, a default import under another name linked to whatever other component its module exported, and an import through a barrel could land on another app's component of the same name. A tag whose module exports an unnamed component, like `export default () => …`, now links to nothing instead of to a namesake elsewhere. A lazily loaded React Router page exported as such a value or wrapper links to the component it holds too, the one with the markup. `codegraph sync` and the file watcher redraw these links when only the file that passes the component on changes. Re-index React projects after upgrading.
|
||||
- A call through a class name, like `Logger.log()`, or through a variable named after one, like `logger.log()`, now links to that class's own method or the one it inherits, such as Django's `form.is_valid()` reaching `BaseForm`, instead of the same-named method of a class whose name merely contains it, such as `FileLogger`. These calls also no longer link to a class a test declares inside one of its functions, or, from your own code, to a class in a test suite. Re-index to update an existing project. Thanks @inth3shadows.
|
||||
- Python imports now resolve the way Python finds modules: from your project's package roots — the repository root, `src/` layouts, each service with its own `pyproject.toml`, `setup.cfg` or `setup.py`, and the package directories those declare — so `from shop.cart import total` reaches `src/shop/cart.py`, `from pkg import name` reaches what the package's `__init__.py` defines or re-exports (respecting `__all__`), and `import json` or `import requests` no longer links to a same-named file nested in a test fixture, an example, a vendored copy or another package. After a module, package or `__init__.py` is added or removed, a package's `__init__.py` is edited, or a build config is edited, the next sync brings Python imports back in line with a fresh index; the file watcher doesn't react to a build-config edit by itself, so run `codegraph sync` after one. Re-index Python projects after upgrading. (#1704)
|
||||
- An Express route keeps its named handler when a middleware before it contains an arrow function. `rateLimit({ keyGenerator: (req) => req.ip })` or `(req, res, next) => next()` no longer hides the handler at the end of the route (re-index to update an existing project).
|
||||
- A TypeScript type re-exported through a barrel (`export type { Foo } from`, `export { type Foo } from`, `export type * from`) now links to its real declaration instead of a same-named type elsewhere in the project, and import lists with comments, string names, or a JSDoc `@import` are read correctly. Re-index to update an existing project.
|
||||
|
||||
@@ -0,0 +1,326 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { CodeGraph } from '../src';
|
||||
|
||||
// `Logger.log()` must bind to Logger's own `log`, not to the first method in
|
||||
// the file whose owner's name merely CONTAINS "Logger" (`FileLogger::log`).
|
||||
// The method lookup matched the class name as a substring of the qualified
|
||||
// name, so a class declared earlier in the same file with a longer name
|
||||
// ending in the receiver's won every call.
|
||||
|
||||
let dir: string;
|
||||
let cg: CodeGraph;
|
||||
|
||||
beforeAll(async () => {
|
||||
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-owner-class-'));
|
||||
fs.writeFileSync(path.join(dir, 'log.py'), `class FileLogger:
|
||||
def log(self):
|
||||
pass
|
||||
|
||||
class Logger:
|
||||
def log(self):
|
||||
pass
|
||||
|
||||
def via_class():
|
||||
Logger.log()
|
||||
|
||||
def via_instance(logger):
|
||||
logger.log()
|
||||
`);
|
||||
fs.writeFileSync(path.join(dir, 'log.ts'), `export class FileLogger {
|
||||
static log(): void {}
|
||||
}
|
||||
|
||||
export class Logger {
|
||||
static log(): void {}
|
||||
}
|
||||
|
||||
export function viaStatic(): void {
|
||||
Logger.log();
|
||||
}
|
||||
`);
|
||||
// A same-named NESTED class must not take a call that names the top-level
|
||||
// class — whether it is declared first, or is the only one with the method.
|
||||
fs.writeFileSync(path.join(dir, 'nested.py'), `class Outer:
|
||||
class Logger:
|
||||
def log(self):
|
||||
pass
|
||||
|
||||
class Logger:
|
||||
def log(self):
|
||||
pass
|
||||
|
||||
def nested_first():
|
||||
Logger.log()
|
||||
`);
|
||||
fs.writeFileSync(path.join(dir, 'nested_only.py'), `class Plain:
|
||||
pass
|
||||
|
||||
class Box:
|
||||
class Plain:
|
||||
def log(self):
|
||||
pass
|
||||
|
||||
def top_has_none():
|
||||
Plain.log()
|
||||
`);
|
||||
// A class that declares no method of the name reaches the one it inherits,
|
||||
// as Django's `form.is_valid()` on `Form(BaseForm)` does — not a method of
|
||||
// another class whose name merely contains the receiver's (`AdminForm`,
|
||||
// declared first so a name test would pick it).
|
||||
fs.writeFileSync(path.join(dir, 'forms.py'), `class AdminForm:
|
||||
def is_valid(self):
|
||||
pass
|
||||
|
||||
class BaseForm:
|
||||
def is_valid(self):
|
||||
pass
|
||||
|
||||
class Form(BaseForm):
|
||||
pass
|
||||
|
||||
def submit(form):
|
||||
form.is_valid()
|
||||
|
||||
def submit_class():
|
||||
Form.is_valid()
|
||||
`);
|
||||
fs.writeFileSync(path.join(dir, 'stores.ts'), `export class AdminStore {
|
||||
static create(): void {}
|
||||
}
|
||||
|
||||
export class BaseStore {
|
||||
static create(): void {}
|
||||
}
|
||||
|
||||
export class Store extends BaseStore {}
|
||||
|
||||
export function makeStore(): void {
|
||||
Store.create();
|
||||
}
|
||||
`);
|
||||
// A class declared inside a function is named only in there (Django's
|
||||
// serializer test declares a `File` with a `write`): a call elsewhere is
|
||||
// not on it, through the class name or a receiver named after it.
|
||||
fs.writeFileSync(path.join(dir, 'streams.py'), `class SerializerTests:
|
||||
def test_stream(self):
|
||||
class File:
|
||||
def write(self, line):
|
||||
pass
|
||||
f = File()
|
||||
File.write(f, "x")
|
||||
`);
|
||||
fs.writeFileSync(path.join(dir, 'storage.py'), `class File:
|
||||
pass
|
||||
|
||||
def save(file):
|
||||
file.write("data")
|
||||
|
||||
def save_class(f):
|
||||
File.write(f, "data")
|
||||
`);
|
||||
// Production code never names a test suite's class: a test app's `Manager`
|
||||
// model (which inherits `check`) is not what `manager.check()` in the
|
||||
// library means. The library's own `Manager` is built by a factory on
|
||||
// `BaseManager` — Django's `BaseManager.from_queryset(QuerySet)` — so it
|
||||
// has BaseManager's methods, and the QuerySet's the factory copies on.
|
||||
fs.mkdirSync(path.join(dir, 'tests'));
|
||||
fs.writeFileSync(path.join(dir, 'tests', 'models.py'), `class Model:
|
||||
def check(self):
|
||||
pass
|
||||
|
||||
class Manager(Model):
|
||||
pass
|
||||
`);
|
||||
fs.writeFileSync(path.join(dir, 'managers.py'), `class QuerySet:
|
||||
def filter(self):
|
||||
pass
|
||||
|
||||
class CurrentSiteManager:
|
||||
def get_queryset(self):
|
||||
pass
|
||||
|
||||
class BaseManager:
|
||||
def check(self):
|
||||
pass
|
||||
|
||||
def get_queryset(self):
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
def from_queryset(cls, queryset_class):
|
||||
return cls
|
||||
|
||||
class Manager(BaseManager.from_queryset(QuerySet)):
|
||||
pass
|
||||
|
||||
def check_managers(manager):
|
||||
manager.check()
|
||||
manager.get_queryset()
|
||||
manager.filter()
|
||||
`);
|
||||
// A method of a class another function declares is out of reach, but what
|
||||
// it leaves is still a guess by the receiver's name: `cache.aadd()` is
|
||||
// BaseCache's, and `value.split()` is not the one other `split` there is.
|
||||
fs.writeFileSync(path.join(dir, 'cachelib.py'), `class BaseCache:
|
||||
def aadd(self):
|
||||
pass
|
||||
|
||||
class BadModelWithSplit:
|
||||
def split(self):
|
||||
pass
|
||||
`);
|
||||
fs.writeFileSync(path.join(dir, 'local_fakes.py'), `def make_fakes():
|
||||
class Fake:
|
||||
def aadd(self):
|
||||
pass
|
||||
|
||||
def split(self):
|
||||
pass
|
||||
return Fake()
|
||||
`);
|
||||
fs.writeFileSync(path.join(dir, 'cache_use.py'), `def use_cache(cache, value):
|
||||
cache.aadd()
|
||||
value.split()
|
||||
`);
|
||||
// A Kotlin companion object's members are the class's own: `Foo.create()`
|
||||
// is Foo's, not that of `FooBar`, declared first.
|
||||
fs.writeFileSync(path.join(dir, 'Factory.kt'), `package app
|
||||
|
||||
class FooBar {
|
||||
companion object {
|
||||
fun create(): FooBar = FooBar()
|
||||
}
|
||||
}
|
||||
|
||||
class Foo {
|
||||
companion object {
|
||||
fun create(): Foo = Foo()
|
||||
}
|
||||
}
|
||||
|
||||
fun build() {
|
||||
Foo.create()
|
||||
}
|
||||
`);
|
||||
// R allows dots in a class name: the owner is `Bank.Account`, whole.
|
||||
fs.writeFileSync(path.join(dir, 'account.R'), `Bank.Account <- R6::R6Class("Bank.Account",
|
||||
public = list(
|
||||
deposit = function(x) { x }
|
||||
)
|
||||
)
|
||||
`);
|
||||
fs.writeFileSync(path.join(dir, 'main.R'), `BankAccount <- R6::R6Class("BankAccount",
|
||||
public = list(
|
||||
deposit = function(x) { x }
|
||||
)
|
||||
)
|
||||
|
||||
run_it <- function() {
|
||||
Bank.Account$deposit(1)
|
||||
}
|
||||
`);
|
||||
cg = await CodeGraph.init(dir, { index: true });
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
cg?.destroy();
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
/** `file#qualifiedName` of every callee — the file matters: several fixtures share names. */
|
||||
function calleesOf(name: string, file: string): string[] {
|
||||
const fn = cg.getNodesByName(name).find((n) => (n.kind === 'function' || n.kind === 'method') && n.filePath === file);
|
||||
expect(fn).toBeDefined();
|
||||
return cg.getCallees(fn!.id).map(({ node }) => `${node.filePath}#${node.qualifiedName}`);
|
||||
}
|
||||
|
||||
describe('method call binds to the receiver class itself, not a class whose name contains it', () => {
|
||||
it('Python: Logger.log() → Logger::log', () => {
|
||||
const callees = calleesOf('via_class', 'log.py');
|
||||
expect(callees).toContain('log.py#Logger::log');
|
||||
expect(callees).not.toContain('log.py#FileLogger::log');
|
||||
});
|
||||
|
||||
it('Python: logger.log() (capitalized receiver) → Logger::log', () => {
|
||||
const callees = calleesOf('via_instance', 'log.py');
|
||||
expect(callees).toContain('log.py#Logger::log');
|
||||
expect(callees).not.toContain('log.py#FileLogger::log');
|
||||
});
|
||||
|
||||
it('TypeScript: Logger.log() → Logger::log', () => {
|
||||
const callees = calleesOf('viaStatic', 'log.ts');
|
||||
expect(callees).toContain('log.ts#Logger::log');
|
||||
expect(callees).not.toContain('log.ts#FileLogger::log');
|
||||
});
|
||||
|
||||
it('Python: a method the class inherits is reached through its base, not a namesake class', () => {
|
||||
for (const fn of ['submit', 'submit_class']) {
|
||||
const callees = calleesOf(fn, 'forms.py');
|
||||
expect(callees).toContain('forms.py#BaseForm::is_valid');
|
||||
expect(callees).not.toContain('forms.py#AdminForm::is_valid');
|
||||
}
|
||||
});
|
||||
|
||||
it('TypeScript: a static method the class inherits is reached through its base', () => {
|
||||
const callees = calleesOf('makeStore', 'stores.ts');
|
||||
expect(callees).toContain('stores.ts#BaseStore::create');
|
||||
expect(callees).not.toContain('stores.ts#AdminStore::create');
|
||||
});
|
||||
|
||||
it('Python: a class another function declares takes no call from outside it', () => {
|
||||
expect(calleesOf('save', 'storage.py')).not.toContain('streams.py#SerializerTests::test_stream::File::write');
|
||||
expect(calleesOf('save_class', 'storage.py')).not.toContain('streams.py#SerializerTests::test_stream::File::write');
|
||||
// Inside the function that declares it, the name is that class.
|
||||
expect(calleesOf('test_stream', 'streams.py')).toContain('streams.py#SerializerTests::test_stream::File::write');
|
||||
});
|
||||
|
||||
it("Python: production code reaches the library's class, not a test suite's namesake", () => {
|
||||
const callees = calleesOf('check_managers', 'managers.py');
|
||||
expect(callees).toContain('managers.py#BaseManager::check');
|
||||
expect(callees).not.toContain('tests/models.py#Model::check');
|
||||
});
|
||||
|
||||
it("Python: a base built by a class's factory gives that class's methods, and the factory's argument's", () => {
|
||||
const callees = calleesOf('check_managers', 'managers.py');
|
||||
expect(callees).toContain('managers.py#BaseManager::get_queryset');
|
||||
expect(callees).toContain('managers.py#QuerySet::filter');
|
||||
});
|
||||
|
||||
it('Python: with a local class out of reach, the method left is still a guess by the receiver name', () => {
|
||||
const callees = calleesOf('use_cache', 'cache_use.py');
|
||||
expect(callees).toContain('cachelib.py#BaseCache::aadd');
|
||||
expect(callees).not.toContain('cachelib.py#BadModelWithSplit::split');
|
||||
});
|
||||
|
||||
it("Kotlin: a companion object's method is its own class's", () => {
|
||||
const callees = calleesOf('build', 'Factory.kt');
|
||||
expect(callees).toContain('Factory.kt#app::Foo::create');
|
||||
expect(callees).not.toContain('Factory.kt#app::FooBar::create');
|
||||
});
|
||||
|
||||
it('R: a dotted class name is the owner as a whole', () => {
|
||||
const callees = calleesOf('run_it', 'main.R');
|
||||
expect(callees).toContain('account.R#Bank.Account::deposit');
|
||||
expect(callees).not.toContain('main.R#BankAccount::deposit');
|
||||
});
|
||||
|
||||
// Known limitation (see findOwnMethod): a same-named NESTED class can take a
|
||||
// call that names the top-level class. Pinned with it.fails so a fix flips
|
||||
// them; the "fixture indexed" checks keep a broken fixture from passing as
|
||||
// the expected failure.
|
||||
it('Python nested-class fixtures are indexed and resolved', () => {
|
||||
expect(calleesOf('top_has_none', 'nested_only.py').length).toBeGreaterThan(0);
|
||||
expect(calleesOf('nested_first', 'nested.py').length).toBeGreaterThan(0);
|
||||
});
|
||||
it.fails("Python: a nested class's method is not the top-level class's own", () => {
|
||||
expect(calleesOf('top_has_none', 'nested_only.py')).not.toContain('nested_only.py#Box::Plain::log');
|
||||
});
|
||||
it.fails('Python: a nested class declared first does not take a top-level call', () => {
|
||||
const callees = calleesOf('nested_first', 'nested.py');
|
||||
expect(callees).toContain('nested.py#Logger::log');
|
||||
expect(callees).not.toContain('nested.py#Outer::Logger::log');
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user