mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-25 03:49:59 +08:00
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>
327 lines
10 KiB
TypeScript
327 lines
10 KiB
TypeScript
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');
|
|
});
|
|
});
|