Files
codegraph/__tests__/python-receiver-guessing.test.ts
Colby MchenryandClaude Opus 5.5 99209b6f3d fix(resolution): a Python call reaches a method through its receiver, never by name alone (#2125)
Found by the README-wide sweep — Django/Flask/FastAPI apps' most-depended-on
lists were test and serializer methods: netbox `UserConfig.all` (3,610 from
`X.objects.all()`), `PortSerializer.create` (2,093 from `.objects.create`),
a test-local `FakeQuerySet.count` (1,910); healthchecks `AuthTestCase.get`
(880 from `Channel.objects.get()`), `ProfileAdmin.login` (702 from
`self.client.login()`); mealie `_FakeHTTPResponse.json`.

Python keeps a call through a receiver it cannot name (`User.objects.get`,
`self.client.login`, `request.POST.get`) as the bare `get` / `login`, and
exact-name matching then took any class's method by proximity. The call's
shape is now read at its column (the call's start):
- `bare` (`get(1)`): not a method — Python has no implicit self — and not a
  project symbol when the file takes that name `from` a module the project
  doesn't contain (`from django.shortcuts import render`);
- `chained`: only a member of what the chain names last — a method of a
  class of that name (case/underscores ignored: `self.store` → `Store`,
  `self.user_service` → `UserService`) or a function/class in a module of
  that name (`app.helpers.slugify()` → helpers.py);
- `self.x()` / `cls.x()`, and chains split across lines: unchanged.
Fuzzy matching is case-exact for Python (`dir(…)` ≠ class `Dir`).
Lexical reachability now walks every enclosing scope, so a method of a
class declared inside a function is only reachable in there (memoized per
candidate).

A/B vs main (edge diffs, every change classified): netbox −18,091/+73
(indexes 8s → 5s), healthchecks −3,354/+361, pytest −2,950/+105,
mealie −1,802/+6, allauth −1,086/+57, DRF −943/+50, flask −167/+17,
httpx −147/+24. Gains are precise member links (`self.channel.notify` →
Channel.notify, `item.stash.get` → Stash.get, `config.cache.get` →
Cache.get, `self.provider.verify_token`); the one deliberate loss class is
calls through an instance whose type needs inference (`flask.g.setdefault`
in flask's own repo). excalidraw, typeorm, gson, okio, AutoMapper, gin and
hono byte-identical.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 05:52:25 +00:00

90 lines
3.1 KiB
TypeScript

/**
* A Python call whose receiver the extractor could not keep — `User.objects.get(…)`,
* `self.client.login(…)` reach the resolver as `get`, `login` — is not a project
* method picked by name. netbox bound 3,610 `.all()` calls to one `UserConfig.all`,
* healthchecks 880 `objects.get` to a test case's `get`. A bare `get(1)` cannot be a
* method either (no implicit self); `self.helper()` still resolves to the class's own.
*/
import { describe, it, expect, afterAll } from 'vitest';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { CodeGraph } from '../src';
const roots: string[] = [];
afterAll(() => {
for (const r of roots.splice(0)) fs.rmSync(r, { recursive: true, force: true });
});
const FILES: Record<string, string> = {
'app/tests.py': `class AuthTestCase:
def get(self, url):
return url
def login(self, name):
return name
`,
'app/curl.py': `def get(url):
return url
`,
'app/helpers.py': `def slugify(text):
return text
`,
'app/tags.py': `def render(context):
return context
`,
'app/pages.py': `import app.helpers
from django.shortcuts import render
def page(request):
kind = request.POST.get("kind")
slug = app.helpers.slugify(kind)
return render(request, "page.html", {"slug": slug})
`,
'app/views.py': `from django.contrib.auth.models import User
class Backend:
def authenticate(self, username):
user = User.objects.get(email=username)
self.client.login(username="a")
self.helper()
self.assertEqual(self.filterset(params, self.queryset).qs.get(), 2)
get(1)
return user
def helper(self):
return 1
`,
};
describe('Python: a method is reached through a receiver', () => {
it('not by its name alone', async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-py-receiver-'));
roots.push(root);
for (const [rel, content] of Object.entries(FILES)) {
fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true });
fs.writeFileSync(path.join(root, rel), content);
}
const cg = await CodeGraph.init(root, { index: true });
try {
const authenticate = cg.getNodesByName('authenticate')[0]!;
const targets = cg
.getOutgoingEdgesFrom([authenticate.id], ['calls'])
.map((e) => cg.getNode(e.target)!.qualifiedName);
expect(targets).not.toContain('AuthTestCase::get');
expect(targets).not.toContain('AuthTestCase::login');
expect(targets).toContain('Backend::helper');
const page = cg.getNodesByName('page')[0]!;
const fromPage = cg.getOutgoingEdgesFrom([page.id], ['calls']).map((e) => cg.getNode(e.target)!);
const pageTargets = fromPage.map((n) => `${n.filePath}:${n.name}`);
// \`request.POST.get\` names no project module; \`render\` is Django's.
expect(pageTargets).not.toContain('app/curl.py:get');
expect(pageTargets).not.toContain('app/tags.py:render');
// A module path names the module's function.
expect(pageTargets).toContain('app/helpers.py:slugify');
} finally {
cg.close();
}
});
});