mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-24 06:53:52 +08:00
Python calls through an attribute reach the method the code gives that attribute: `self.sink.stop()` follows `self.sink = Sink()`, a `sink: Sink` annotation, a typed `__init__` parameter, a factory's return type or what every `return` in it builds, and a base class's `__init__` in another file. Calls straight on a factory's result (`Client.from_env().send()`) resolve the same way, and a sync re-opens these answers when the factory or base they were read from changes. The native Python extractor now keeps a call's receiver text as the wasm one does, so `self.data.append(1)` is never a bare `append` that matches an unrelated project function. This includes all of PR 1704. Reworked from evidence-or-silence, which dropped about 3,000 correct Django links (`self.client.get()`, `self.engine.render_to_string()`). The reader now has three answers. A project class that has the method: that method. Evidence that rules project methods out: no edge, for a builtin, a class from outside the project built in a method or named by an annotation, an attribute only an outside base can give (Django's `TestCase` in an app), or a class without the method whose bases are all known. Nothing typed: the call is guessed by the name of its last link, read as written, as any receiver is, but only for an owner whose name holds the link's head noun (`self.session` for `SessionBase`, not `database.group_shopping_lists` for `GroupRepositoryAIProvider`) and never as the one method of a name; the guess records the files the reader looked at, so a sync re-opens it too. A class-body field an outside class builds (`author = models.ForeignKey(Author)`) may be a descriptor, so it is no evidence. A chain through an imported module (`os.path.exists()`) is the module's. In the shared scoring, a receiver that spells an owner's whole name (`client` for `Client`) outscores one that only shares its word (`RedisCacheClient`). A call through an imported value's attribute chain (`app.jinja_env.filters.keys()`) no longer links to the value's `jinja_env`. A/B against main with fresh indexes, every changed edge triaged, sites matched by position since the extractor now names these calls by their whole chain. Django: 3,317 calls linked that were not (`self.client.force_login()` to `ClientMixin`, `User.objects.create_user()` to `UserManager`, `self.stdout.write()` to `OutputWrapper`), 127 moved, nearly all to a more precise target (`self.site.register()` to `AdminSite.register` rather than the `register` decorator), and 134 removed: main's bare-name links on builtin values (`value.lower()` to the template filter, `.encode()` to a test password hasher) and calls on expressions such as `(q1 | q2).order_by()`, which now go unresolved as they do in the wasm engine. flask: 32 linked, 4 moved to `JSONProvider`. fastapi: 58 linked. mealie: 740 linked. Guesses still wrong, as main makes them for a plain receiver of the same name: fastapi's `request.headers.get()` to a telemetry `_HeadersGetter`, and about 20 of mealie's repository attributes to a controller. Go, C#, Ruby, Swift, Kotlin, Java and TS repositories are byte-identical, except excalidraw's 5 `props.onChange()` calls, which move between two unrelated `Props` interfaces. Indexing CPU: Django +7%, mealie +5%, fastapi flat; wall time unchanged within noise. Full suite on the merged tree: 7,999 passed, 0 failed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
495 lines
28 KiB
TypeScript
495 lines
28 KiB
TypeScript
/**
|
|
* A python call through an ATTRIBUTE CHAIN is a silent miss, never a guess
|
|
* (#66 follow-up).
|
|
*
|
|
* Since #66 the extractor keeps the receiver's text, so `self.data.append(1)`
|
|
* arrives as `self.data.append` rather than a bare `append`. The comment on that
|
|
* change claimed the qualifier confined it to import/module-member resolution.
|
|
* It did not: `matchMethodCall`'s dotMatch splits it into receiver `self.data` +
|
|
* method `append`, and the bare-name strategies below then bound it to any
|
|
* project method of that name.
|
|
*
|
|
* Go (#1276) and Rust (#1585) already treat a chained receiver as
|
|
* validated-inference-or-nothing, and PHP's `this->prop.method` likewise.
|
|
*
|
|
* Every negative test here uses a DISTRACTOR — a second project symbol with the
|
|
* same method name — because without one the old fallback found the right target
|
|
* by single-candidate luck and the test would pass on both arms, proving
|
|
* nothing. (That is exactly how an earlier version of this suite was vacuous.)
|
|
*/
|
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
import * as fs from 'fs';
|
|
import * as os from 'os';
|
|
import * as path from 'path';
|
|
import { CodeGraph } from '../src/index';
|
|
|
|
describe('python attribute-chain receivers', () => {
|
|
let tempDir: string;
|
|
let cg: CodeGraph | undefined;
|
|
|
|
beforeEach(() => {
|
|
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-py-chain-'));
|
|
});
|
|
afterEach(() => {
|
|
cg?.close();
|
|
cg = undefined;
|
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
});
|
|
|
|
const callsFrom = (kind: 'function' | 'method', name: string) => {
|
|
const src = cg!.getNodesByKind(kind).find((n) => n.name === name);
|
|
expect(src).toBeDefined();
|
|
return cg!
|
|
.getOutgoingEdges(src!.id)
|
|
.filter((e) => e.kind === 'calls')
|
|
.map((e) => cg!.getNode(e.target)!)
|
|
.map((n) => `${n.kind}:${n.name}@${n.filePath.replace(/\\/g, '/')}`);
|
|
};
|
|
|
|
it('does not bind a self-attribute chain to an unrelated same-named method', async () => {
|
|
fs.writeFileSync(
|
|
path.join(tempDir, 'sink.py'),
|
|
'class Sink:\n def append(self, x):\n return x\n\n def get(self, k):\n return k\n'
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(tempDir, 'box.py'),
|
|
'class Box:\n def __init__(self):\n self.data = []\n self.inner = {}\n\n'
|
|
+ ' def build(self, k):\n self.data.append(1)\n rows = {}\n rows[k] = []\n'
|
|
+ ' rows[k].append(2)\n return self.inner.get(k)\n'
|
|
);
|
|
|
|
cg = await CodeGraph.init(tempDir, { index: true });
|
|
|
|
// `self.data` is a list and `self.inner` a dict; nothing in the graph says
|
|
// so, which is exactly why guessing is wrong. Silence, not Sink.
|
|
expect(callsFrom('method', 'build')).toEqual([]);
|
|
});
|
|
|
|
it('does not bind a deep chain either', async () => {
|
|
fs.writeFileSync(
|
|
path.join(tempDir, 'leaf.py'),
|
|
'class Leaf:\n def go(self, x):\n return x\n'
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(tempDir, 'deep.py'),
|
|
'class Deep:\n def __init__(self, a):\n self.a = a\n\n'
|
|
+ ' def run(self, x):\n return self.a.b.go(x)\n'
|
|
);
|
|
|
|
cg = await CodeGraph.init(tempDir, { index: true });
|
|
expect(callsFrom('method', 'run')).toEqual([]);
|
|
});
|
|
|
|
it('guesses the class the last link spells whole before one that only shares its word', async () => {
|
|
// Django's test `Client` and the cache's `RedisCacheClient` both have a
|
|
// `get`; `self.client` is the one it is named after. `self.nodelist` is a
|
|
// `NodeList` though no single word of it matches.
|
|
writeTree({
|
|
'cache.py': 'class RedisCacheClient:\n def get(self, k):\n return k\n',
|
|
'client.py': 'class Client:\n def get(self, k):\n return k\n',
|
|
'nodes.py': 'class NodeList:\n def render(self, c):\n return c\n\n\nclass Other:\n def render(self, c):\n return c\n',
|
|
'tests.py': 'class Tests:\n def setUp(self, client, nodelist):\n self.client = client\n self.nodelist = nodelist\n\n'
|
|
+ ' def run(self):\n self.client.get(1)\n self.nodelist.render(2)\n',
|
|
});
|
|
cg = await CodeGraph.init(tempDir, { index: true });
|
|
expect(callsFrom('method', 'run').sort()).toEqual(['method:get@client.py', 'method:render@nodes.py']);
|
|
});
|
|
|
|
it('guesses an untyped chain only through its head noun', async () => {
|
|
// mealie's `database.group_shopping_lists` shares only `group` with the AI
|
|
// providers' repository; its head noun is `lists`. `group_ai_providers`
|
|
// names a provider, and `client` the mixin Django's test client gets
|
|
// `force_login` from. Django's `request.META` is a dict, not a `request`.
|
|
writeTree({
|
|
'repos.py': 'class GroupRepositoryAIProvider:\n def create(self):\n pass\n\n\nclass Other:\n def create(self):\n pass\n',
|
|
'client.py': 'class ClientMixin:\n def force_login(self, u):\n return u\n\n\nclass OtherMixin:\n def force_login(self, u):\n return u\n',
|
|
'factory.py': 'class RequestFactory:\n def get(self, path):\n return path\n',
|
|
'use.py': 'def lists(database):\n database.group_shopping_lists.create()\n\n\ndef providers(database):\n database.group_ai_providers.create()\n\n\n'
|
|
+ 'def login(case):\n case.client.force_login(1)\n\n\ndef meta(request):\n return request.META.get("HTTP_HOST")\n',
|
|
});
|
|
cg = await CodeGraph.init(tempDir, { index: true });
|
|
expect(callsFrom('function', 'lists')).toEqual([]);
|
|
expect(callsFrom('function', 'providers')).toEqual(['method:create@repos.py']);
|
|
expect(callsFrom('function', 'login')).toEqual(['method:force_login@client.py']);
|
|
expect(callsFrom('function', 'meta')).toEqual([]);
|
|
});
|
|
|
|
it('leaves a chain through a module to the module', async () => {
|
|
// `os.path.exists()` is the stdlib's, not a project storage's `exists`
|
|
// whose class shares the word `Path`.
|
|
writeTree({
|
|
'storage.py': 'class PathNotImplementedStorage:\n def exists(self, p):\n return p\n\n\nclass Storage:\n def exists(self, p):\n return p\n',
|
|
'use.py': 'import os\n\n\ndef has(p):\n return os.path.exists(p)\n',
|
|
});
|
|
cg = await CodeGraph.init(tempDir, { index: true });
|
|
expect(callsFrom('function', 'has')).toEqual([]);
|
|
});
|
|
|
|
it('guesses an untyped chain only by the name of its last link, as any receiver', async () => {
|
|
// Nothing types `cfg`, so the type reader has no answer. `cfg.client` is
|
|
// then guessed as a `Client` by its name — the guess every untyped
|
|
// receiver gets (`client.send()`), and how Django's `self.client.get()`
|
|
// reaches its test client — while `cfg.conn` names neither class: no
|
|
// edge, rather than one of the two `send`s.
|
|
fs.writeFileSync(path.join(tempDir, 'client.py'), 'class Client:\n def send(self, x):\n return x\n');
|
|
fs.writeFileSync(path.join(tempDir, 'other.py'), 'class Other:\n def send(self, x):\n return x\n');
|
|
fs.writeFileSync(
|
|
path.join(tempDir, 'use.py'),
|
|
'def run(cfg, x):\n return cfg.client.send(x)\n\n\ndef run_conn(cfg, x):\n return cfg.conn.send(x)\n'
|
|
);
|
|
|
|
cg = await CodeGraph.init(tempDir, { index: true });
|
|
expect(callsFrom('function', 'run')).toEqual(['method:send@client.py']);
|
|
expect(callsFrom('function', 'run_conn')).toEqual([]);
|
|
});
|
|
|
|
// --- boundaries the gate must NOT cross -----------------------------------
|
|
|
|
it('still resolves a single-segment receiver whose type is inferable', async () => {
|
|
fs.writeFileSync(
|
|
path.join(tempDir, 'client.py'),
|
|
'class Client:\n def send(self, payload):\n return payload\n'
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(tempDir, 'other.py'),
|
|
'class Other:\n def send(self, payload):\n return payload\n'
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(tempDir, 'svc.py'),
|
|
'from client import Client\n\n\ndef run(payload):\n c = Client()\n return c.send(payload)\n'
|
|
);
|
|
|
|
cg = await CodeGraph.init(tempDir, { index: true });
|
|
// With `Other.send` present, only the local-variable type inference can
|
|
// pick the right one — and it still runs, because `c` has no dot in it.
|
|
expect(callsFrom('function', 'run')).toEqual(['method:send@client.py']);
|
|
});
|
|
|
|
it('still resolves a module-qualified call, which is a different strategy', async () => {
|
|
// A guard, not a gate test: `ledger.record` has a single-segment receiver,
|
|
// so the gate never sees it. It is here because the gate sits in the same
|
|
// function as the module path and a careless widening would take it out.
|
|
fs.writeFileSync(path.join(tempDir, 'ledger.py'), 'def record(row):\n return row\n');
|
|
fs.writeFileSync(
|
|
path.join(tempDir, 'use.py'),
|
|
'from . import ledger\n\n\ndef save(row):\n return ledger.record(row)\n'
|
|
);
|
|
|
|
cg = await CodeGraph.init(tempDir, { index: true });
|
|
expect(callsFrom('function', 'save')).toContain('function:record@ledger.py');
|
|
});
|
|
|
|
// --- recall the qualifier must not cost ------------------------------------
|
|
//
|
|
// Before the receiver was kept, each of these arrived as a bare method name
|
|
// and resolved by name. The qualifier routes them to evidence instead: the
|
|
// module the import names, or the type the class body declares. Every case
|
|
// carries a same-named distractor so a name-only guess cannot pass.
|
|
|
|
const writeTree = (files: Record<string, string>) => {
|
|
for (const [rel, body] of Object.entries(files)) {
|
|
const abs = path.join(tempDir, rel);
|
|
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
fs.writeFileSync(abs, body);
|
|
}
|
|
};
|
|
|
|
it('resolves a call through a dotted module path', async () => {
|
|
writeTree({
|
|
'app/__init__.py': '',
|
|
'app/utils/__init__.py': '',
|
|
'app/utils/helpers.py': 'def do_x():\n return 1\n',
|
|
'app/other.py': 'def do_x():\n return 2\n',
|
|
'app/main.py':
|
|
'import app.utils.helpers\nfrom app import utils\n\n\n'
|
|
+ 'def full():\n return app.utils.helpers.do_x()\n\n\n'
|
|
+ 'def via_package():\n return utils.helpers.do_x()\n',
|
|
});
|
|
cg = await CodeGraph.init(tempDir, { index: true });
|
|
expect(callsFrom('function', 'full')).toEqual(['function:do_x@app/utils/helpers.py']);
|
|
expect(callsFrom('function', 'via_package')).toEqual(['function:do_x@app/utils/helpers.py']);
|
|
});
|
|
|
|
it('resolves a class-attribute receiver through the class or cls', async () => {
|
|
writeTree({
|
|
'sink.py': 'class Sink:\n def lookup(self, k):\n return k\n',
|
|
'main.py':
|
|
'class Registry:\n def lookup(self, k):\n return k\n\n\n'
|
|
+ 'class Svc:\n registry = Registry()\n\n'
|
|
+ ' def run(self):\n return Svc.registry.lookup("k")\n\n'
|
|
+ ' @classmethod\n def c(cls):\n return cls.registry.lookup("k")\n',
|
|
});
|
|
cg = await CodeGraph.init(tempDir, { index: true });
|
|
expect(callsFrom('method', 'run')).toEqual(['method:lookup@main.py']);
|
|
expect(callsFrom('method', 'c')).toEqual(['method:lookup@main.py']);
|
|
});
|
|
|
|
it('resolves a self attribute to the class the class body gives it', async () => {
|
|
writeTree({
|
|
'pkg/__init__.py': '',
|
|
'pkg/a.py': 'class Capture:\n def stop(self):\n pass\n',
|
|
'pkg/b.py': 'class Sink:\n def stop(self):\n pass\n',
|
|
'pkg/models.py': 'class User:\n def save(self):\n pass\n\n\nclass Other:\n def save(self):\n pass\n',
|
|
'pkg/main.py':
|
|
'from pkg.a import Capture\nfrom pkg.b import Sink\nfrom pkg import models\n\n\n'
|
|
+ 'class Svc:\n def __init__(self, cap: Capture):\n self.cap = cap\n'
|
|
+ ' self.sink = Sink()\n self.user = models.User()\n\n'
|
|
+ ' def by_param(self):\n self.cap.stop()\n\n'
|
|
+ ' def by_ctor(self):\n self.sink.stop()\n\n'
|
|
+ ' def by_module_type(self):\n self.user.save()\n',
|
|
});
|
|
cg = await CodeGraph.init(tempDir, { index: true });
|
|
expect(callsFrom('method', 'by_param')).toEqual(['method:stop@pkg/a.py']);
|
|
expect(callsFrom('method', 'by_ctor')).toEqual(['method:stop@pkg/b.py']);
|
|
expect(callsFrom('method', 'by_module_type')).toEqual(['method:save@pkg/models.py']);
|
|
});
|
|
|
|
it('follows the attribute type to a method its base class declares', async () => {
|
|
writeTree({
|
|
'app/__init__.py': '',
|
|
'app/capture.py': 'class Base:\n def stop(self):\n pass\n\n\nclass Capture(Base):\n def start(self):\n pass\n',
|
|
'app/sink.py': 'class Sink:\n def stop(self):\n pass\n\n def start(self):\n pass\n',
|
|
'app/main.py':
|
|
'from app.capture import Capture\n\n\n'
|
|
+ 'class Svc:\n def __init__(self):\n self.cap = Capture()\n\n'
|
|
+ ' def run(self):\n self.cap.start()\n self.cap.stop()\n',
|
|
});
|
|
cg = await CodeGraph.init(tempDir, { index: true });
|
|
expect(callsFrom('method', 'run').sort()).toEqual(['method:start@app/capture.py', 'method:stop@app/capture.py']);
|
|
});
|
|
|
|
it('resolves an attribute of a typed local or parameter', async () => {
|
|
writeTree({
|
|
'models.py':
|
|
'class Llm:\n def rebuild(self):\n pass\n\n\nclass Other:\n def rebuild(self):\n pass\n\n\n'
|
|
+ 'class Config:\n llm: Llm\n\n\nclass App:\n def __init__(self):\n self.config = Config()\n',
|
|
'use.py':
|
|
'from models import App, Config\n\n\n'
|
|
+ 'def by_param(config: Config):\n config.llm.rebuild()\n\n\n'
|
|
+ 'def by_local():\n app = App()\n app.config.llm.rebuild()\n cfg = app.config\n',
|
|
});
|
|
cg = await CodeGraph.init(tempDir, { index: true });
|
|
expect(callsFrom('function', 'by_param')).toEqual(['method:rebuild@models.py']);
|
|
// Two attribute hops is a deeper chain than the class body answers: it is
|
|
// guessed by the name of its last link, `llm`, as an untyped one is.
|
|
expect(callsFrom('function', 'by_local')).toEqual(['method:rebuild@models.py']);
|
|
});
|
|
|
|
it('leaves an attribute typed as an external class unresolved', async () => {
|
|
writeTree({
|
|
'session.py': 'class Session:\n def get(self, url):\n return url\n',
|
|
'svc.py':
|
|
'import requests\n\n\nclass Svc:\n def __init__(self):\n self.http = requests.Session()\n\n'
|
|
+ ' def fetch(self, url):\n return self.http.get(url)\n',
|
|
});
|
|
cg = await CodeGraph.init(tempDir, { index: true });
|
|
expect(callsFrom('method', 'fetch')).toEqual([]);
|
|
});
|
|
|
|
it('types an attribute only by a call that is the whole assigned value', async () => {
|
|
const lib =
|
|
'class Client:\n def send(self):\n pass\n\n def close(self):\n pass\n\n\n'
|
|
+ 'class Fake:\n def send(self):\n pass\n\n\n'
|
|
+ 'class Pool:\n def acquire(self):\n return Client()\n\n def close(self):\n pass\n';
|
|
writeTree({
|
|
'lib.py': lib,
|
|
'svc.py':
|
|
'from lib import Client, Fake, Pool\n\n\nclass Svc:\n def __init__(self, t):\n'
|
|
+ ' self.pooled = Pool().acquire()\n'
|
|
+ ' self.either = Client() if t else Fake()\n'
|
|
+ ' self.wrapped = Client() \\\n if t else Fake()\n'
|
|
+ ' self.closed = Client().close()\n'
|
|
+ ' self.same = Client(1) if t else Client(2)\n'
|
|
+ ' self.multi = Client(\n t,\n )\n\n'
|
|
+ ' def pooled_(self):\n self.pooled.close()\n\n'
|
|
+ ' def either_(self):\n self.either.send()\n\n'
|
|
+ ' def wrapped_(self):\n self.wrapped.send()\n\n'
|
|
+ ' def closed_(self):\n self.closed.send()\n\n'
|
|
+ ' def multi_(self):\n self.multi.send()\n\n'
|
|
+ ' def same_(self):\n self.same.send()\n',
|
|
});
|
|
cg = await CodeGraph.init(tempDir, { index: true });
|
|
// `Pool().acquire()` holds what `acquire` returns, not a Pool; a
|
|
// conditional whose arms differ holds either; `Client().close()` holds what `close`
|
|
// returns. A call spread over lines is still one whole call.
|
|
expect(callsFrom('method', 'pooled_')).toEqual([]);
|
|
expect(callsFrom('method', 'either_')).toEqual([]);
|
|
expect(callsFrom('method', 'wrapped_')).toEqual([]);
|
|
expect(callsFrom('method', 'closed_')).toEqual([]);
|
|
expect(callsFrom('method', 'multi_')).toEqual(['method:send@lib.py']);
|
|
// Both arms constructing the same class is that class.
|
|
expect(callsFrom('method', 'same_')).toEqual(['method:send@lib.py']);
|
|
});
|
|
|
|
it('never skips a statement after a value that does not close', async () => {
|
|
const lib = 'class Client:\n def send(self):\n pass\n\n\nclass Fake:\n def send(self):\n pass\n';
|
|
const run = ' def run(self):\n self.c.send()\n';
|
|
writeTree({
|
|
'lib.py': lib,
|
|
// A string that merely mentions `self.c =` and has an unmatched `(`.
|
|
'a.py': 'from lib import Client, Fake\n\n\nclass A:\n def __init__(self):\n self.c = Client()\n\n'
|
|
+ ' def check(self):\n raise ValueError("self.c = must be set (")\n\n'
|
|
+ ' def reset(self):\n self.c = Fake()\n\n' + run,
|
|
// A continuation line `c=1)` at class-body indent reads like `c = 1)`.
|
|
'b.py': 'from lib import Client, Fake\n\n\nclass B:\n def __init__(self):\n self.c = Client()\n\n'
|
|
+ ' opts = dict(\n c=1)\n\n def reset(self):\n self.c = Fake()\n\n' + run,
|
|
// A Python 3.12 f-string reusing its own quote inside the braces.
|
|
'c.py': 'from lib import Client, Fake\n\n\nclass C:\n def __init__(self):\n self.c = Client()\n\n'
|
|
+ ' def check(self):\n self.c = f"{\'"\'}"\n self.c = Fake()\n\n' + run,
|
|
});
|
|
cg = await CodeGraph.init(tempDir, { index: true });
|
|
// Each class also assigns `Fake()`, so the answer is contested: no edge.
|
|
const runs = cg.getNodesByKind('method').filter((n) => n.name === 'run');
|
|
expect(runs).toHaveLength(3);
|
|
for (const r of runs) {
|
|
expect(cg.getOutgoingEdges(r.id).filter((e) => e.kind === 'calls')).toEqual([]);
|
|
}
|
|
});
|
|
describe('review round 2026-09-30', () => {
|
|
const write = (rel: string, src: string) => {
|
|
fs.mkdirSync(path.dirname(path.join(tempDir, rel)), { recursive: true });
|
|
fs.writeFileSync(path.join(tempDir, rel), src);
|
|
};
|
|
|
|
it('an attribute assigned in the calling method still needs the whole value to be a call', async () => {
|
|
write('pool.py', 'class Pool:\n def acquire(self):\n return object()\n\n def send(self, x):\n return x\n');
|
|
write('svc.py', 'from pool import Pool\n\nclass Svc:\n def run(self):\n self.conn = Pool().acquire()\n return self.conn.send(1)\n');
|
|
cg = await CodeGraph.init(tempDir, { index: true });
|
|
expect(callsFrom('method', 'run')).not.toContain('method:send@pool.py');
|
|
});
|
|
|
|
it('an attribute assigned in the calling method from a rebound class name is not that class', async () => {
|
|
write('backend.py', 'class Backend:\n def run(self):\n return 1\n');
|
|
write('svc.py', 'from backend import Backend\n\ndef load():\n return object\n\nclass Svc:\n def go(self):\n Backend = load()\n self.b = Backend()\n return self.b.run()\n');
|
|
cg = await CodeGraph.init(tempDir, { index: true });
|
|
expect(callsFrom('method', 'go')).not.toContain('method:run@backend.py');
|
|
});
|
|
|
|
it('an attribute assigned in the calling method from an out-of-repo class is not a same-named project class', async () => {
|
|
write('app/local.py', 'class Session:\n def get(self, url):\n return url\n');
|
|
write('app/api.py', 'from requests import Session\n\nclass Api:\n def fetch(self):\n self.session = Session()\n return self.session.get("x")\n');
|
|
cg = await CodeGraph.init(tempDir, { index: true });
|
|
expect(callsFrom('method', 'fetch')).not.toContain('method:get@app/local.py');
|
|
});
|
|
|
|
it('a parameter rebound before the assignment no longer carries its annotation', async () => {
|
|
write('box.py', 'class Box:\n def run(self):\n return 1\n');
|
|
// `held`, named after no class, so no guess by name stands in for the
|
|
// annotation this must not carry.
|
|
write('svc.py', 'from box import Box\n\ndef wrap(b):\n return b\n\nclass Svc:\n def __init__(self, box: Box):\n box = wrap(box)\n self.held = box\n\n def go(self):\n return self.held.run()\n');
|
|
cg = await CodeGraph.init(tempDir, { index: true });
|
|
expect(callsFrom('method', 'go')).not.toContain('method:run@box.py');
|
|
});
|
|
|
|
it('descends a dotted module path from a relative package import', async () => {
|
|
write('app/__init__.py', '');
|
|
write('app/rel/__init__.py', '');
|
|
write('app/rel/utils/__init__.py', '');
|
|
write('app/rel/utils/helpers.py', 'def do_x():\n return 1\n');
|
|
write('app/rel/use.py', 'from . import utils\n\ndef rel_caller():\n return utils.helpers.do_x()\n');
|
|
cg = await CodeGraph.init(tempDir, { index: true });
|
|
expect(callsFrom('function', 'rel_caller')).toContain('function:do_x@app/rel/utils/helpers.py');
|
|
});
|
|
});
|
|
describe('review round 2026-10-01', () => {
|
|
const write = (rel: string, src: string) => {
|
|
fs.mkdirSync(path.dirname(path.join(tempDir, rel)), { recursive: true });
|
|
fs.writeFileSync(path.join(tempDir, rel), src);
|
|
};
|
|
const FOO = 'class Foo:\n def m(self):\n return 1\n';
|
|
const OTHER = 'class Other:\n def m(self):\n return 2\n';
|
|
const resolves = async (svc: string, fn = 'go') => {
|
|
write('foo.py', FOO);
|
|
write('other.py', OTHER);
|
|
write('svc.py', svc);
|
|
cg = await CodeGraph.init(tempDir, { index: true });
|
|
return callsFrom('method', fn).filter((c) => c.startsWith('method:m@'));
|
|
};
|
|
|
|
it('a keyword argument passing a parameter through is not a rebinding', async () => {
|
|
expect(await resolves('from foo import Foo\n\nclass Base:\n def __init__(self, client=None):\n pass\n\nclass Svc(Base):\n def __init__(self, client: Foo):\n super().__init__(\n client=client,\n )\n validate(client=client)\n self.client = client\n\n def go(self):\n return self.client.m()\n\ndef validate(client=None):\n return client\n'))
|
|
.toEqual(['method:m@foo.py']);
|
|
});
|
|
|
|
it('a keyword argument named like the class is not a rebinding', async () => {
|
|
expect(await resolves('from foo import Foo\n\ndef reg(**kw):\n return kw\n\nclass Svc:\n def __init__(self):\n reg(Foo=1)\n reg(\n Foo=2,\n )\n self.b = Foo()\n\n def go(self):\n return self.b.m()\n'))
|
|
.toEqual(['method:m@foo.py']);
|
|
});
|
|
|
|
it('a function-local import of the class is not a rebinding', async () => {
|
|
expect(await resolves('class Svc:\n def __init__(self):\n from foo import Foo\n self.b = Foo()\n\n def go(self):\n return self.b.m()\n'))
|
|
.toEqual(['method:m@foo.py']);
|
|
});
|
|
|
|
it('a nested def above the assignment does not rebind the outer name', async () => {
|
|
expect(await resolves('from foo import Foo\n\nclass Svc:\n def __init__(self):\n def cb(Foo=None):\n Foo = 1\n return Foo\n self.b = Foo()\n\n def go(self):\n return self.b.m()\n'))
|
|
.toEqual(['method:m@foo.py']);
|
|
});
|
|
|
|
it('text in a string is not a rebinding', async () => {
|
|
expect(await resolves('from foo import Foo\n\nclass Svc:\n def __init__(self):\n print("Foo = %s" % 1)\n self.b = Foo()\n\n def go(self):\n return self.b.m()\n'))
|
|
.toEqual(['method:m@foo.py']);
|
|
});
|
|
|
|
it('a walrus still rebinds', async () => {
|
|
expect(await resolves('from foo import Foo\n\ndef load():\n return object\n\nclass Svc:\n def __init__(self, k):\n if (Foo := load()):\n pass\n self.b = Foo()\n\n def go(self):\n return self.b.m()\n')).toEqual([]);
|
|
});
|
|
|
|
it('a one-line compound statement still rebinds', async () => {
|
|
expect(await resolves('from foo import Foo\n\ndef load():\n return object\n\nclass Svc:\n def __init__(self, k):\n if k: Foo = load()\n self.b = Foo()\n\n def go(self):\n return self.b.m()\n')).toEqual([]);
|
|
});
|
|
|
|
it('leaves a deeper attribute chain unresolved, even one the calling method assigned', async () => {
|
|
expect(await resolves('from foo import Foo\n\nclass Svc:\n def go(self):\n self.a.b = Foo()\n return self.a.b.m()\n')).toEqual([]);
|
|
});
|
|
|
|
it('leaves an attribute on an untyped parameter unresolved', async () => {
|
|
write('foo.py', FOO);
|
|
write('other.py', OTHER);
|
|
write('svc.py', 'from foo import Foo\n\ndef go(c):\n c.client = Foo()\n return c.client.m()\n');
|
|
cg = await CodeGraph.init(tempDir, { index: true });
|
|
expect(callsFrom('function', 'go').filter((c) => c.startsWith('method:m@'))).toEqual([]);
|
|
});
|
|
|
|
it('assignments that disagree anywhere in the class are no evidence, even in the calling method', async () => {
|
|
expect(await resolves('from foo import Foo\nfrom other import Other\n\nclass Svc:\n def reset(self):\n self.x = Other()\n\n def go(self):\n self.x = Foo()\n return self.x.m()\n')).toEqual([]);
|
|
});
|
|
});
|
|
describe('review round 2026-10-02', () => {
|
|
const write = (rel: string, src: string) => {
|
|
fs.mkdirSync(path.dirname(path.join(tempDir, rel)), { recursive: true });
|
|
fs.writeFileSync(path.join(tempDir, rel), src);
|
|
};
|
|
const resolves = async (svc: string) => {
|
|
write('foo.py', 'class Foo:\n def m(self):\n return 1\n');
|
|
write('other.py', 'class Other:\n def m(self):\n return 2\n');
|
|
write('svc.py', 'from foo import Foo\nfrom other import Other\n\n' + svc);
|
|
cg = await CodeGraph.init(tempDir, { index: true });
|
|
return callsFrom('method', 'go').filter((c) => c.startsWith('method:m@'));
|
|
};
|
|
|
|
it('storing into a container keyed by the class, or setting its attribute, is not a rebinding', async () => {
|
|
expect(await resolves('class Svc:\n def __init__(self, d, c):\n d[Foo] = 1\n self._h[Foo] = c\n Foo.attr = 1\n self.b = Foo()\n\n def go(self):\n return self.b.m()\n')).toEqual(['method:m@foo.py']);
|
|
});
|
|
|
|
it('branches of the calling method that disagree are no evidence', async () => {
|
|
expect(await resolves('class Svc:\n def go(self, a):\n if a:\n self.x = Foo()\n else:\n self.x = Other()\n return self.x.m()\n')).toEqual([]);
|
|
});
|
|
|
|
it('a branch in the calling method does not override the class body', async () => {
|
|
expect(await resolves('class Svc:\n def __init__(self):\n self.x = Other()\n\n def go(self, a):\n if a:\n self.x = Foo()\n return self.x.m()\n')).toEqual([]);
|
|
});
|
|
|
|
it('an assignment inside a nested def is not typed by the enclosing method', async () => {
|
|
expect(await resolves('class Svc:\n def __init__(self, c: Foo):\n def on_ready(c):\n self.x = c\n self.cb = on_ready\n\n def go(self):\n return self.x.m()\n')).toEqual([]);
|
|
cg!.close(); cg = undefined;
|
|
fs.rmSync(path.join(tempDir, '.codegraph'), { recursive: true, force: true });
|
|
expect(await resolves('def load():\n return object\n\nclass Svc:\n def __init__(self):\n def setup():\n Foo = load()\n self.x = Foo()\n setup()\n\n def go(self):\n return self.x.m()\n')).toEqual([]);
|
|
});
|
|
|
|
it('an augmented assignment is not typed by its right-hand side', async () => {
|
|
expect(await resolves('class Svc:\n def go(self):\n self.x += Foo()\n return self.x.m()\n')).toEqual([]);
|
|
});
|
|
});
|
|
});
|