diff --git a/CHANGELOG.md b/CHANGELOG.md index 4722c5fb64..f5cf1bad66 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -154,6 +154,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 ` items={…} />` or excalidraw's ` …>`, 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, `` 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 `` 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. +- Python calls through an attribute now reach the method your code gives that attribute. `self.sink.stop()` follows `self.sink = Sink()`, a `sink: Sink` annotation, or a typed `__init__` parameter to `Sink`'s `stop`, including a `stop` that `Sink` inherits, and an attribute of a typed local or parameter resolves the same way. An attribute whose type the code never names is left unresolved rather than guessed. Re-index Python projects after upgrading. (#1704) ## [1.6.2] - 2026-10-03 diff --git a/__tests__/fixtures/kernel-parity/torture.py b/__tests__/fixtures/kernel-parity/torture.py index 001d64f49d..af83300d35 100644 --- a/__tests__/fixtures/kernel-parity/torture.py +++ b/__tests__/fixtures/kernel-parity/torture.py @@ -61,3 +61,16 @@ def bucket_chains(d, k, v): make().run() (lambda: make)()().run() obj.make().run().again() + + +# --- non-call, non-identifier receivers (#66) --------------------------------- +def fabrication_shapes(rows_by_file, key): + # Attribute chain and subscript. Each must keep its receiver text as a + # qualifier — a bare `append`/`get` exact-matches an unrelated project + # function of that name. (The call-chain shape lives in bucket_chains + # above, which #1748 encodes as `().` instead.) + self_like = rows_by_file + self_like.rows.append({"x": 1}) + rows_by_file[key].append(2) + rows_by_file[key].get(key, None) + return rows_by_file diff --git a/__tests__/kernel-python-call-fabrication.test.ts b/__tests__/kernel-python-call-fabrication.test.ts new file mode 100644 index 0000000000..342310ed6a --- /dev/null +++ b/__tests__/kernel-python-call-fabrication.test.ts @@ -0,0 +1,98 @@ +/** + * Python non-identifier call receivers must not fabricate edges — ON THE ARM + * THAT SHIPS. + * + * `resolution.test.ts` already covers this end-to-end, but python is in the + * kernel's DEFAULT_ROUTED set, so in a from-source checkout (no staged .node) + * that test exercises the *wasm* TreeSitterExtractor while every published + * bundle extracts python with codegraph-kernel/src/python.rs. A fix applied to + * only one arm is a green test over code that never runs. + * + * This suite forces the kernel arm and asserts the same two properties: + * - `ledger.append(row)`, where `ledger` is an imported project module that + * exports `append`, RESOLVES (the false-negative half, #66). + * - `self.data.append(...)` / `rows[k].append(...)` — attribute-chain and + * subscript receivers — do NOT fabricate an edge to that same `append` + * (the false-positive half; `python.rs` used to emit a BARE `append`, + * which exact-matched the only project-wide symbol of that name). + * + * Skips when no kernel binary is staged, like the parity suites. + */ + +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'; + +const KERNEL_PATH = path.join( + __dirname, + '..', + 'codegraph-kernel', + 'prebuilds', + `${process.platform}-${process.arch}`, + 'codegraph-kernel.node' +); +const kernelBuilt = fs.existsSync(KERNEL_PATH); + +describe.skipIf(!kernelBuilt)('python call-receiver fabrication (kernel arm)', () => { + let tempDir: string; + let cg: CodeGraph | undefined; + let savedLangs: string | undefined; + + beforeEach(() => { + savedLangs = process.env.CODEGRAPH_KERNEL_LANGS; + process.env.CODEGRAPH_KERNEL_LANGS = 'python'; + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-py-fab-')); + }); + + afterEach(() => { + cg?.close(); + cg = undefined; + if (savedLangs === undefined) delete process.env.CODEGRAPH_KERNEL_LANGS; + else process.env.CODEGRAPH_KERNEL_LANGS = savedLangs; + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + it('resolves a module-qualified call and fabricates none from chained receivers (#66)', async () => { + fs.writeFileSync( + path.join(tempDir, 'ledger.py'), + 'def append(row):\n return True\n\n\ndef path():\n return "ledger.jsonl"\n' + ); + fs.writeFileSync( + path.join(tempDir, 'record.py'), + 'from . import ledger\n\n\ndef add_outcome(row):\n if not ledger.append(row):\n return None\n return ledger.path()\n' + ); + fs.writeFileSync( + path.join(tempDir, 'unrelated.py'), + 'class Box:\n def __init__(self):\n self.data = []\n\n def build_map(self):\n self.data.append({"x": 1})\n rows = {}\n rows["k"] = []\n rows["k"].append(2)\n return rows\n' + ); + + cg = await CodeGraph.init(tempDir, { index: true }); + + const ledgerAppend = cg + .getNodesByKind('function') + .find((n) => n.name === 'append' && n.filePath.replace(/\\/g, '/').endsWith('ledger.py')); + expect(ledgerAppend).toBeDefined(); + + const addOutcome = cg.getNodesByKind('function').find((n) => n.name === 'add_outcome'); + expect(addOutcome).toBeDefined(); + expect( + cg + .getOutgoingEdges(addOutcome!.id) + .filter((e) => e.kind === 'calls') + .map((e) => e.target) + ).toContain(ledgerAppend!.id); + + // Both non-identifier receivers live in build_map; neither may reach + // ledger.py's append. + const buildMap = cg.getNodesByKind('method').find((n) => n.name === 'build_map'); + expect(buildMap).toBeDefined(); + expect( + cg + .getOutgoingEdges(buildMap!.id) + .filter((e) => e.kind === 'calls') + .map((e) => e.target) + ).not.toContain(ledgerAppend!.id); + }); +}); diff --git a/__tests__/python-attr-chain.test.ts b/__tests__/python-attr-chain.test.ts new file mode 100644 index 0000000000..034f51f77b --- /dev/null +++ b/__tests__/python-attr-chain.test.ts @@ -0,0 +1,444 @@ +/** + * 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('does not bind a non-self chain to a same-named method', async () => { + // The distractor matters: `Other.send` exists too, so a resolution here + // could only ever be a guess between them. + fs.writeFileSync( + path.join(tempDir, 'kinds.py'), + 'class Client:\n def send(self, x):\n return x\n\n\nclass 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' + ); + + cg = await CodeGraph.init(tempDir, { index: true }); + expect(callsFrom('function', 'run')).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) => { + 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. + expect(callsFrom('function', 'by_local')).toEqual([]); + }); + + 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'); + 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.box = box\n\n def go(self):\n return self.box.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([]); + }); + }); +}); diff --git a/codegraph-kernel/src/python.rs b/codegraph-kernel/src/python.rs index 1fe325f050..bdec80f99b 100644 --- a/codegraph-kernel/src/python.rs +++ b/codegraph-kernel/src/python.rs @@ -731,6 +731,30 @@ impl<'t> Walker<'t> { // TreeSitterExtractor.extractCall. let Some(inner) = self.plain_inner_callee(r) else { return }; callee_name = format!("{inner}().{method_name}"); + } else if let Some(r) = receiver { + // Any receiver shape the arms above do not claim — attribute + // chain (`self.data.append`) and subscript (`d[k].append`). + // The call-chain shape (`rows.setdefault(k, []).append(x)`) + // is taken by the `().` arm above (#1748), + // which is matched first. + // These used to fall through to a BARE method_name. A bare name + // matching a common list/dict/str method (`append`, `get`, + // `update`, ...) exact-matches an unrelated top-level project + // function sharing that name, fabricating a call edge (#66, + // same class as #1230/#1276). Keep the receiver's source text + // as a qualifier so the ref can only resolve through + // import/module-member resolution, never the bare-name + // fallback; an unresolvable qualifier is a silent miss, never + // a wrong edge. Mirrors TreeSitterExtractor.extractCall's + // python branch — the whitespace collapse is JS `\s` + // semantics, not `char::is_whitespace`, so both arms emit the + // same bytes for the parity sweep. + let receiver_text = collapse_js_whitespace(self.text(r)); + callee_name = if receiver_text.is_empty() { + method_name.to_string() + } else { + format!("{receiver_text}.{method_name}") + }; } else { callee_name = method_name.to_string(); } @@ -1105,6 +1129,35 @@ fn is_stoplisted(name: &str) -> bool { ) } +/// `s.replace(/\s+/g, ' ').trim()` with JavaScript's `\s` class, so a receiver +/// qualifier is byte-identical to the one TreeSitterExtractor emits. Rust's +/// `char::is_whitespace` is NOT the same set: it counts U+0085 (NEL), which JS +/// `\s` does not, and omits U+FEFF (ZWNBSP), which JS `\s` includes. +fn collapse_js_whitespace(s: &str) -> String { + fn is_js_space(c: char) -> bool { + matches!( + c, + '\t' | '\n' | '\u{b}' | '\u{c}' | '\r' | ' ' | '\u{a0}' | '\u{feff}' + | '\u{2028}' | '\u{2029}' + | '\u{1680}' | '\u{2000}'..='\u{200a}' | '\u{202f}' | '\u{205f}' | '\u{3000}' + ) + } + let mut out = String::with_capacity(s.len()); + let mut in_space = false; + for c in s.chars() { + if is_js_space(c) { + in_space = true; + } else { + if in_space && !out.is_empty() { + out.push(' '); + } + in_space = false; + out.push(c); + } + } + out +} + /// LITERAL_RECEIVER_TYPES membership (shared table; python names among them). fn is_literal_receiver(kind: &str) -> bool { matches!( diff --git a/src/extraction/tree-sitter.ts b/src/extraction/tree-sitter.ts index eebcab5f0a..13c6a968da 100644 --- a/src/extraction/tree-sitter.ts +++ b/src/extraction/tree-sitter.ts @@ -5622,6 +5622,21 @@ export class TreeSitterExtractor { // Emit nothing: a silent miss, never a wrong edge. Mirrored in // the kernel's extract_call (tsjs/extractors.rs). return; + } else if (this.language === 'python' && receiver) { + // Any receiver shape the branches above do not claim — attribute + // chain (`self.data.append`) and subscript (`d[k].append`). The + // call-chain shape (`rows.setdefault(k, []).append(x)`) is taken + // by the `().` branch above (#1748), which runs + // first. These previously fell through to a bare methodName. A + // bare name matching a common + // list/dict/str method (`append`, `get`, `update`, ...) can exact-match + // an unrelated top-level project function sharing that name, fabricating + // a call edge (#66, same class as #1230/#1276). Keep the receiver's + // source text as a qualifier so the ref can only resolve through + // import/module-member resolution, never the bare-name fallback; an + // unresolvable qualifier is a silent miss, never a wrong edge. + const receiverText = getNodeText(receiver, this.source).replace(/\s+/g, ' ').trim(); + calleeName = receiverText ? `${receiverText}.${methodName}` : methodName; } else { calleeName = methodName; } diff --git a/src/resolution/import-resolver.ts b/src/resolution/import-resolver.ts index 9423eaebff..5e4dca4f41 100644 --- a/src/resolution/import-resolver.ts +++ b/src/resolution/import-resolver.ts @@ -2155,12 +2155,27 @@ function resolvePythonModuleMember( const dotIdx = ref.referenceName.indexOf('.'); if (dotIdx <= 0) return null; const receiver = ref.referenceName.substring(0, dotIdx); - // The immediate member of the module (first segment after the receiver). - const member = ref.referenceName.substring(dotIdx + 1).split('.')[0]; - if (!member) return null; + + const moduleFile = (modulePath: string, fromFile: string): string | null => { + // resolveImportPath only maps RELATIVE dotted paths (`.mod`, `..pkg.mod`); an + // ABSOLUTE package path (`pkg.module` from `from pkg import module`, or a bare + // `import pkg.mod`) resolves to null there, so fall back to the dotted-module + // file lookup — the same asymmetry resolveModuleImportToFile already handles + // for the file→file import edge. Without this, a `module.func()` call after + // `from pkg import module` dropped its `calls` edge even though the import + // edge resolved (#578). + return resolveImportPath(modulePath, fromFile, ref.language, context) + ?? findPythonModuleFile(modulePath, context, fromFile)?.filePath + ?? null; + }; for (const imp of imports) { - if (imp.localName !== receiver) continue; + // `import pkg.mod` binds `pkg`, so a call spells the whole path out — + // `pkg.mod.func()`. The mapping names the module by its last segment, which + // never matches that receiver; match the full dotted source instead. + const spelledOut = imp.isNamespace && imp.localName === imp.source.split('.').pop() + && imp.source.includes('.') && ref.referenceName.startsWith(`${imp.source}.`); + if (imp.localName !== receiver && !spelledOut) continue; // `import mod` / `import numpy as np` bind the module at `source` itself; // `from . import certs` / `from pkg import mod` bind a SUBMODULE whose @@ -2173,28 +2188,53 @@ function resolvePythonModuleMember( // form (where the two names coincide) worked (#1626). For an unaliased // import the two are identical, so this changes nothing there. const moduleName = imp.exportedName === '*' ? imp.localName : imp.exportedName; - const modulePath = imp.isNamespace + let modulePath = imp.isNamespace ? imp.source : imp.source.endsWith('.') ? imp.source + moduleName : imp.source + '.' + moduleName; + const rest = ref.referenceName + .substring(spelledOut ? imp.source.length + 1 : dotIdx + 1) + .split('.'); - // resolveImportPath only maps RELATIVE dotted paths (`.mod`, `..pkg.mod`); an - // ABSOLUTE package path (`pkg.module` from `from pkg import module`, or a bare - // `import pkg.mod`) resolves to null there, so fall back to the dotted-module - // file lookup — the same asymmetry resolveModuleImportToFile already handles - // for the file→file import edge. Without this, a `module.func()` call after - // `from pkg import module` dropped its `calls` edge even though the import - // edge resolved (#578). - let resolvedPath = resolveImportPath(modulePath, ref.filePath, ref.language, context); - if (!resolvedPath) { - resolvedPath = findPythonModuleFile(modulePath, context, ref.filePath)?.filePath ?? null; - } + let resolvedPath = moduleFile(modulePath, ref.filePath); if (!resolvedPath || resolvedPath === ref.filePath) continue; + // A CALL through a longer path — `utils.helpers.do_x()`, `mod.Klass.run()` + // — names submodules and then one symbol. Descend while the next segment + // is a module file; what is left must be a top-level callable, or a class + // and one of its methods. Anything else is not something this path can + // prove, so it is no edge — never the first segment's symbol, which for + // `mod.Klass.run()` would record a call to the class. Other reference + // kinds keep the single-member lookup below. + if (ref.referenceKind === 'calls' && rest.length > 1) { + while (rest.length > 1) { + // `modulePath` is spelled from the importer (`.utils.helpers`), so it + // resolves from there, not from the module file just reached. + const sub = moduleFile(`${modulePath}.${rest[0]}`, ref.filePath); + if (!sub) break; + modulePath = `${modulePath}.${rest.shift()}`; + resolvedPath = sub; + } + const inFile = context.getNodesInFile(resolvedPath); + let target: Node | undefined; + if (rest.length === 1) { + target = inFile.find((n) => n.name === rest[0] && (n.kind === 'function' || n.kind === 'class')); + } else if (rest.length === 2) { + const cls = inFile.find((n) => n.name === rest[0] && n.kind === 'class'); + target = cls && inFile.find((n) => + n.kind === 'method' && n.name === rest[1] && n.qualifiedName.endsWith(`${cls.name}::${rest[1]}`)); + } + if (target) { + return { original: ref, targetNodeId: target.id, confidence: 0.85, resolvedBy: 'import' }; + } + continue; + } + // Find the member as a top-level definition in the module file, or one it // re-exports (a package's `__init__.py`). Exclude `method` so `mod.foo` // never lands on a same-named class method. + const member = rest[0]!; const target = pythonModuleSymbol(resolvedPath, member, context, 0); if (target) { return { original: ref, targetNodeId: target.id, confidence: 0.85, resolvedBy: 'import' }; diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 02f3266170..d06185fb2b 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -13321,7 +13321,13 @@ export function matchMethodCall( const [, objectOrClass, methodName] = match; // A simple `receiver.method` / `receiver:method` / `receiver$method` shape whose // receiver type we can try to infer from its local declaration. - const inferableReceiver = dotMatch || luaColonMatch || rDollarMatch; + // A python attribute receiver (`self.conn`, `cfg.client`) is not one: the + // python branch below owns it, and the shared inferrer would answer first by + // the assignment's leading name — `self.conn = Pool().acquire()` read as a + // `Pool`, `Session` read as the project's class under `from requests import + // Session` — skipping that branch's whole-value, rebinding and import checks. + const pythonAttrReceiver = ref.language === 'python' && !!dotMatch && objectOrClass!.includes('.'); + const inferableReceiver = (dotMatch || luaColonMatch || rDollarMatch) && !pythonAttrReceiver; // A Go variable named like a standard-library package resolves by its // declaration alone (isGoProjectLocalCall let it past the package list). @@ -13565,6 +13571,19 @@ export function matchMethodCall( return matchTsThisFieldCall(objectOrClass!.slice('this.'.length), methodName!, ref, context); } + // Python call through an attribute — `self.data.append(x)`, + // `cls.registry.lookup(k)`, `cfg.client.send(x)` — which the extractor emits + // with its receiver (`self.data.append`) instead of a bare method name. + // EXCLUSIVE, for the same reason as the Go, Rust and TS branches above: + // everything past this point is name matching, and a bare-name match is how + // `self.data.append(1)` bound to an unrelated class's `append`. The evidence + // this path accepts is the type the class body gives the attribute. A dotted + // MODULE path (`pkg.mod.func()`) never gets here: import resolution claims it + // first, through the module the import names. + if (pythonAttrReceiver) { + return matchPythonAttrCall(objectOrClass!, methodName!, ref, context); + } + // Java/Kotlin: receiver may be a field whose name doesn't match the type by // Java naming convention (`userbo` → class `UserBO`, abbreviated). Look up // the field in the enclosing class to get its declared type, then resolve @@ -14450,6 +14469,605 @@ function goDotImportDirs(filePath: string, context: ResolutionContext): string[] return dirs; } +/** Python types whose methods are the runtime's, never a project symbol's. */ +const PYTHON_RUNTIME_TYPES: ReadonlySet = new Set([ + 'list', 'dict', 'set', 'frozenset', 'tuple', 'str', 'bytes', 'bytearray', + 'int', 'float', 'complex', 'bool', 'object', 'type', 'range', 'slice', + 'List', 'Dict', 'Set', 'FrozenSet', 'Tuple', 'Sequence', 'Mapping', + 'MutableMapping', 'MutableSequence', 'Iterable', 'Iterator', 'Callable', + 'deque', 'defaultdict', 'OrderedDict', 'Counter', + 'collections.deque', 'collections.defaultdict', 'collections.OrderedDict', 'collections.Counter', +]); + +/** What a python class body says an attribute holds. */ +type PythonAttrEvidence = { type: string } | 'runtime' | null; + +/** + * The type an annotation names — `Client`, `"Client"`, `Optional[Client]`, + * `Client | None`, `models.Client` — or 'runtime' for a builtin container, or + * null when it says nothing usable (`Any`, a generic of a project type). + */ +function pythonAnnotationEvidence(raw: string): PythonAttrEvidence { + let t = raw.trim().replace(/^['"]|['"]$/g, '').trim(); + for (let i = 0; i < 4 && t; i++) { + const opt = t.match(/^(?:typing\.)?Optional\s*\[\s*(.+)\s*\]$/) + ?? t.match(/^(?:typing\.)?Union\s*\[\s*([^,\]]+?)\s*,\s*None\s*\]$/) + ?? t.match(/^(.+?)\s*\|\s*None$/) + ?? t.match(/^None\s*\|\s*(.+)$/); + if (!opt) break; + t = opt[1]!.trim().replace(/^['"]|['"]$/g, '').trim(); + } + const head = t.split('[')[0]!.trim().replace(/^typing\./, ''); + if (PYTHON_RUNTIME_TYPES.has(head)) return 'runtime'; + if (t.includes('[') || !/^[A-Za-z_][\w.]*$/.test(t) || head === 'Any') return null; + return { type: t }; +} + +/** + * The top-level shape of a python expression: string literals collapse to + * `S` and everything inside brackets is dropped, so `Client(a, f(b))` reads + * `Client()`, `Pool().acquire()` stays `Pool().acquire()` and + * `Client() if t else Fake()` keeps its `if`. `open` says why there is no + * shape yet: a bracket or triple-quoted string still open, or a trailing `\` + * — the expression continues on the next line — versus a single-line string + * left open or a bracket closed that was never opened, which no further line + * can repair. + */ +function pythonExprScan(raw: string): { shape: string | null; open: 'none' | 'continues' | 'broken' } { + let out = ''; + let depth = 0; + for (let i = 0; i < raw.length; i++) { + const c = raw[i]!; + if (c === '"' || c === "'") { + const triple = raw.startsWith(c.repeat(3), i); + const close = triple ? c.repeat(3) : c; + let j = i + close.length; + while (j < raw.length && !raw.startsWith(close, j)) j += raw[j] === '\\' ? 2 : 1; + if (j >= raw.length) return { shape: null, open: triple ? 'continues' : 'broken' }; + if (depth === 0) out = out.replace(/[rbfuRBFU]{1,2}$/, '') + 'S'; + i = j + close.length - 1; + } else if (c === '(' || c === '[' || c === '{') { + if (depth === 0) out += c; + depth++; + } else if (c === ')' || c === ']' || c === '}') { + depth--; + if (depth < 0) return { shape: null, open: 'broken' }; + if (depth === 0) out += c; + } else if (depth === 0) { + out += c; + } + } + const shape = out.replace(/\s+/g, ' ').trim(); + if (depth > 0 || shape.endsWith('\\')) return { shape: null, open: 'continues' }; + return { shape, open: 'none' }; +} + +function pythonExprShape(raw: string): string | null { + return pythonExprScan(raw).shape; +} + +/** + * The two arms of a top-level conditional expression — `A if c else B` → + * `[A, B]` — or null when `raw` is not one. Keywords inside brackets or + * strings do not count. + */ +function pythonConditionalArms(raw: string): [string, string] | null { + let depth = 0; + let ifAt = -1; + for (let i = 0; i < raw.length; i++) { + const c = raw[i]!; + if (c === '"' || c === "'") { + const close = raw.startsWith(c.repeat(3), i) ? c.repeat(3) : c; + let j = i + close.length; + while (j < raw.length && !raw.startsWith(close, j)) j += raw[j] === '\\' ? 2 : 1; + i = j + close.length - 1; + } else if ('([{'.includes(c)) depth++; + else if (')]}'.includes(c)) depth--; + else if (depth === 0 && /\s/.test(raw[i - 1] ?? '')) { + if (ifAt < 0 && /^if\s/.test(raw.slice(i))) ifAt = i; + else if (ifAt >= 0 && /^else\s/.test(raw.slice(i))) { + return [raw.slice(0, ifAt).trim(), raw.slice(i + 4).trim()]; + } + } + } + return null; +} + +/** + * Same question for an assigned value: `Client(...)`, `[]`, `cap` (a + * parameter). A call counts only when it IS the whole value — `Pool().acquire()` + * holds what `acquire` returns, `Client() if t else Fake()` holds either, and + * neither is evidence of `Pool` or `Client`. + */ +function pythonValueEvidence(raw: string, signature: string | null): PythonAttrEvidence { + const v = raw.trim(); + const shape = pythonExprShape(v); + if (shape === null) return null; + // `A if cond else B` holds one arm or the other: evidence only when both + // arms give the same answer, as for two branches of an `if` statement. + const arms = pythonConditionalArms(v); + if (arms) { + const [a, b] = arms.map((arm) => pythonValueEvidence(arm, signature)); + if (!a || !b) return null; + if (a === 'runtime' || b === 'runtime') return a === b ? 'runtime' : null; + return a.type === b.type ? a : null; + } + if (/^(?:S(?: S)*|-?\d[\w.]*|True|False|\[\]|\{\}|\(\))$/.test(shape)) return 'runtime'; + const ctor = /^([A-Za-z_][\w.]*) ?\(\)$/.exec(shape); + if (ctor) { + if (PYTHON_RUNTIME_TYPES.has(ctor[1]!)) return 'runtime'; + // A capitalised callee constructs that class; `make_client()` returns + // something this file does not say. + return /^[A-Z]/.test(ctor[1]!.split('.').pop()!) ? { type: ctor[1]! } : null; + } + // `self.cap = cap` — the parameter's annotation in the enclosing `def`. + if (signature && /^[A-Za-z_]\w*$/.test(v)) { + const param = new RegExp(`[(,]\\s*\\*{0,2}${v}\\s*:\\s*([^,)=]+)`).exec(signature); + if (param) return pythonAnnotationEvidence(param[1]!); + } + return null; +} + +/** + * The value starting at `first` on line `ln`, joined with the lines that + * continue it — an open bracket or triple-quoted string, or a trailing `\` — + * up to a small cap. Returns the text and the last line it used. A value that + * does not close within the cap, or cannot (a single-line string left open, + * a stray closing bracket), is returned as its first line alone with no line + * consumed: the lines after it are statements of their own and must still be + * read. + */ +function pythonJoinedValue(lines: string[], ln: number, first: string, lastLine: number): { text: string; end: number } { + if (pythonExprScan(first).open !== 'continues') return { text: first, end: ln }; + let text = first; + for (let end = ln; end < lastLine && end - ln < 30; ) { + text = text.replace(/\\\s*$/, '') + ' ' + (lines[end] ?? '').trim(); + end++; + const { open } = pythonExprScan(text); + if (open === 'none') return { text, end }; + if (open === 'broken') break; + } + return { text: first, end: ln }; +} + +/** + * The names `value` calls as a whole expression or conditional arm — + * `Client(...)`, `make()` — whose local rebinding would make the evidence + * wrong. + */ +function pythonValueCallees(value: string): string[] { + const arms = pythonConditionalArms(value.trim()); + if (arms) return arms.flatMap(pythonValueCallees); + const shape = pythonExprShape(value.trim().replace(/^await\s+/, '')); + const call = shape && /^([A-Za-z_][\w.]*) ?\(\)$/.exec(shape); + return call ? [call[1]!] : []; +} + +/** + * `line` with the contents of its string literals blanked (quotes kept), so a + * string never reads as code: `log("a: return b")` holds no `return`, + * `print("Foo = %s")` no assignment. A quote left open blanks the rest of the + * line (a triple-quoted string continuing below). + */ +function pythonBlankStrings(line: string): string { + const out = line.split(''); + for (let i = 0; i < line.length; i++) { + const c = line[i]!; + if (c !== '"' && c !== "'") continue; + const q = line.startsWith(c.repeat(3), i) ? c.repeat(3) : c; + let j = i + q.length; + while (j < line.length && !line.startsWith(q, j)) j += line[j] === '\\' ? 2 : 1; + for (let k = i + q.length; k < Math.min(j, line.length); k++) out[k] = ' '; + i = Math.min(j + q.length, line.length) - 1; + } + return out.join(''); +} + +/** + * The assignment targets of one python statement (strings blanked): `a, (b, + * c) = …` → [`a, (b, c)`], `x: T = …` → [`x`], `x += …` → [`x`], `a = b = …` → + * [`a`, `b`], and the statement a one-line compound header guards (`if c: x = + * …`). A `=` inside brackets is a keyword argument or a default, `==` / `!=` / + * `<=` / `>=` compare, and a `lambda`'s defaults bind nothing here — none is a + * target. + */ +function pythonAssignTargets(statement: string): string[] { + const stmt = statement.replace(/^\s*(?:(?:el)?if|while|else|try|finally|except|with|for)\b[^:]*:(?!=)/, ''); + const targets: string[] = []; + let depth = 0; + let from = 0; + for (let i = 0; i < stmt.length; i++) { + const c = stmt[i]!; + if (c === '(' || c === '[' || c === '{') depth++; + else if (c === ')' || c === ']' || c === '}') depth = Math.max(0, depth - 1); + else if (c === '=' && depth === 0) { + if (stmt[i + 1] === '=') { i++; continue; } + const prev = stmt[i - 1] ?? ''; + if (prev === '!' || prev === ':') continue; // `!=`, walrus + if ((prev === '<' || prev === '>') && stmt[i - 2] !== prev) continue; // `<=`, `>=` + let seg = stmt.slice(from, i); + if (/\blambda\b/.test(seg)) break; + seg = seg.replace(/(?:\*\*|\/\/|>>|<<|[-+*/%&|^@])$/, ''); + targets.push(seg.split(':')[0]!.trim()); + from = i + 1; + } + } + return targets; +} + +/** + * The statement-level lines of the `def` on line `defLine`, after its header + * and before line `upto`: each with its line number and its statements + * (strings blanked, split on `;`). A line that continues a bracketed or + * `\`-continued statement is not one — `client=client,` inside a call is a + * keyword argument — and neither is a line inside a nested `def` or `class`, + * whose header is reported as `nested` (its name is bound here). + */ +function pythonDefStatements( + lines: string[], + defLine: number, + upto: number, +): { header: string; body: Array<{ line: number; statements: string[]; nested?: string }> } { + let header = ''; + let ln = defLine; + for (; ln <= upto && ln <= lines.length; ln++) { + header += ' ' + (lines[ln - 1] ?? '').trim(); + if (/\)\s*(?:->[^:]*)?:\s*$/.test(header)) break; + } + const body: Array<{ line: number; statements: string[]; nested?: string }> = []; + const indent = (l: string) => l.length - l.trimStart().length; + let depth = 0; + let continued = false; + let nestedIndent = -1; + for (let k = ln + 1; k < upto; k++) { + const raw = lines[k - 1] ?? ''; + if (!raw.trim()) continue; + const blanked = pythonBlankStrings(raw); + const inside = depth > 0 || continued; + for (const c of blanked) { + if (c === '(' || c === '[' || c === '{') depth++; + else if (c === ')' || c === ']' || c === '}') depth = Math.max(0, depth - 1); + } + continued = /\\\s*$/.test(blanked); + if (inside) continue; + if (nestedIndent >= 0) { + if (indent(raw) > nestedIndent) continue; + nestedIndent = -1; + } + const text = blanked.trim(); + const nested = /^(?:async\s+)?(?:def|class)\s+(\w+)/.exec(text); + if (nested) { + nestedIndent = indent(raw); + body.push({ line: k, statements: [], nested: nested[1]! }); + continue; + } + body.push({ line: k, statements: text.split(';').map((t) => t.trim()).filter(Boolean) }); + } + return { header, body }; +} + +/** + * Whether an assignment target binds `name` itself — `name`, `*name`, or an + * element of a tuple / list target (`a, (name, b)`, `[name, b]`). A target that + * only USES the name does not: `d[name] = 1` and `self._h[name] = c` store + * into a container, `name.attr = 1` sets an attribute. + */ +function pythonTargetBinds(target: string, name: string): boolean { + const n = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + for (const m of target.matchAll(new RegExp(`(? p.trim().replace(/^\*{1,2}/, '').split(/[:=]/)[0]!.trim()); + if (paramNames.includes(name)) return true; + } + const loop = new RegExp(`^(?:async\\s+)?for\\s+[\\w\\s,()\\[\\]]*(? pythonTargetBinds(t, name))) return true; + if (loop.test(st) || walrus.test(st)) return true; + if (!/^(?:from|import)\b/.test(st) && as.test(st)) return true; + } + } + return false; +} + +/** + * What `owner`'s body says `attr` holds: a class-level declaration + * (`registry = Registry()`, `conn: Client`) or an assignment through `self` / + * `cls` (`self.cap = Capture()`, `self.cap: Capture`, `self.cap = cap` with + * `cap: Capture` in the signature). Two statements that disagree are no + * evidence at all. + * + * Comments and docstrings are blanked before reading, and nested classes are + * skipped, so neither can supply the type: a regex over raw class lines took a + * type from a docstring once and turned a correct edge into a wrong one. + */ +function pythonAttrEvidence(owner: Node, attr: string, context: ResolutionContext): PythonAttrEvidence { + const source = context.readFile(owner.filePath); + if (!source) return null; + const lines = stripCommentsForRegex(source, 'python').split('\n'); + const nested = context + .getNodesInFile(owner.filePath) + .filter((n) => n.kind === 'class' && n.id !== owner.id && n.startLine > owner.startLine && n.endLine <= owner.endLine); + const indent = (l: string) => l.length - l.trimStart().length; + let bodyIndent = -1; + const a = attr.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const classLevel = new RegExp(`^${a}\\s*(?::\\s*([^=]+?))?\\s*(?:=\\s*(.+?))?\\s*$`); + const viaSelf = new RegExp(`\\b(?:self|cls)\\.${a}\\s*(?::\\s*([^=]+?))?\\s*(?:=(?!=)\\s*(.+?))?\\s*$`); + let signature: string | null = null; + let defStart = -1; + let nestedDefIndent = -1; + const found = new Set(); + + for (let ln = owner.startLine + 1; ln <= owner.endLine; ln++) { + const line = lines[ln - 1]; + if (!line || !line.trim()) continue; + if (nested.some((n) => ln >= n.startLine && ln <= n.endLine)) continue; + if (bodyIndent < 0) bodyIndent = indent(line); + const text = line.trim(); + if (/^(?:async\s+)?def\s/.test(text) && indent(line) === bodyIndent) { + // The enclosing method's signature, possibly over several lines. A def + // nested in a method is not one: its parameters and locals are its own. + defStart = ln; + nestedDefIndent = -1; + signature = text; + for (let k = ln; k < owner.endLine && !/\)\s*(?:->[^:]*)?:\s*$/.test(signature); k++) { + signature += ' ' + (lines[k] ?? '').trim(); + } + continue; + } + if (nestedDefIndent >= 0 && indent(line) <= nestedDefIndent) nestedDefIndent = -1; + if (nestedDefIndent < 0 && /^(?:async\s+)?def\s/.test(text) && indent(line) > bodyIndent) { + nestedDefIndent = indent(line); + continue; + } + const m = indent(line) === bodyIndent ? classLevel.exec(text) : viaSelf.exec(text); + if (!m || (!m[1] && !m[2])) continue; + // `self.x = c` inside a def nested in a method (a callback) reads that + // def's own parameters and locals, which the method's scope does not see: + // it says nothing usable here. + if (nestedDefIndent >= 0 && indent(line) > nestedDefIndent) continue; + const inDef = indent(line) !== bodyIndent && defStart > 0; + const at = ln; + let value = m[2]; + if (!m[1] && value) { + const joined = pythonJoinedValue(lines, ln, value, owner.endLine); + value = joined.text; + ln = joined.end; + } + // A value read through a name the method rebinds first — `Backend = + // load(); self.b = Backend()`, or `box = wrap(box); self.box = box` for a + // `box: Box` parameter — is not what the module's class or the annotation + // says. That statement is no evidence. + if (!m[1] && inDef) { + const v = value!.trim(); + const reads = pythonValueCallees(v).map((c) => c.split('.')[0]!); + if (reads.some((c) => pythonBoundInDef(lines, defStart, at, c))) continue; + if (/^[A-Za-z_]\w*$/.test(v) && pythonBoundInDef(lines, defStart, at, v, false)) continue; + } + const ev = m[1] + ? pythonAnnotationEvidence(m[1]) + : pythonValueEvidence(value!, inDef ? signature : null); + if (ev) found.add(ev === 'runtime' ? '' : ev.type); + } + if (found.size !== 1) return null; + const only = [...found][0]!; + return only === '' ? 'runtime' : { type: only }; +} + +/** + * The class `className` declared in the python module `modulePath` as seen + * from `fromFile` — `pkg.a` / `.a` / `..core` — or null. Relative paths are + * anchored at `fromFile`'s package; an absolute one may sit under a source root + * (`src/pkg/a.py`), so it matches by path suffix and must be unique. + */ +function pythonModuleClass(modulePath: string, className: string, fromFile: string, context: ResolutionContext): Node | null { + const dots = /^\.*/.exec(modulePath)![0].length; + let rel = modulePath.slice(dots).replace(/\./g, '/'); + if (dots > 0) { + const dir = fromFile.replace(/\\/g, '/').split('/').slice(0, -1); + for (let i = 1; i < dots; i++) { + if (dir.length === 0) return null; + dir.pop(); + } + rel = [...dir, ...(rel ? [rel] : [])].join('/'); + } + if (!rel) return null; + const wanted = [`${rel}.py`, `${rel}/__init__.py`, `${rel}.pyi`]; + const hits = context.getNodesByName(className).filter((n) => { + if (n.kind !== 'class' || n.language !== 'python') return false; + const fp = n.filePath.replace(/\\/g, '/'); + return wanted.some((w) => fp === w || (dots === 0 && fp.endsWith(`/${w}`))); + }); + return hits.length === 1 ? hits[0]! : null; +} + +/** + * The project class a python type name means in `fromFile`: the class in the + * module its import names, or the file's own declaration. An import that names + * a module outside the project (`requests.Session`) is null, never a same-named + * project class — that is the guess this whole path exists to stop. + */ +function pythonClassNamed(typeName: string, fromFile: string, context: ResolutionContext): Node | null { + const segs = typeName.split('.'); + const className = segs[segs.length - 1]!; + const imports = context.getImportMappings(fromFile, 'python'); + const candidates: Array = []; + for (const imp of imports) { + if (imp.isNamespace && segs.length > 1 && typeName.startsWith(`${imp.source}.`)) { + // `import pkg.models` then `pkg.models.User`. + const middle = typeName.slice(imp.source.length + 1).split('.').slice(0, -1); + candidates.push(pythonModuleClass([imp.source, ...middle].join('.'), className, fromFile, context)); + } else if (imp.localName === segs[0]) { + if (segs.length === 1) { + // `from pkg.a import Capture` / `... import Capture as C`. + candidates.push(imp.isNamespace ? null : pythonModuleClass(imp.source, imp.exportedName, fromFile, context)); + } else { + // `from pkg import models` then `models.User`; `import models as m` then `m.User`. + const base = imp.isNamespace + ? imp.source + : imp.source.endsWith('.') ? imp.source + imp.exportedName : `${imp.source}.${imp.exportedName}`; + candidates.push(pythonModuleClass([base, ...segs.slice(1, -1)].join('.'), className, fromFile, context)); + } + } + } + if (candidates.length > 0) { + // Every binding of the name must agree on one class. + const ids = new Set(candidates.map((c) => c?.id ?? '')); + return ids.size === 1 && candidates[0] ? candidates[0] : null; + } + if (segs.length > 1) return null; + const local = context + .getNodesByName(typeName) + .filter((n) => n.kind === 'class' && n.language === 'python' && n.filePath === fromFile); + return local.length === 1 ? local[0]! : null; +} + +/** + * `method` on class `cls`, or on a base its own `class` line names (resolved + * the same way, from `cls`'s file). Reading the bases off the header rather + * than the `extends` edges keeps this answerable in the first pass. + */ +function pythonMethodOnClass( + cls: Node, + methodName: string, + ref: UnresolvedRef, + context: ResolutionContext, + seen: Set = new Set(), +): ResolvedRef | null { + if (seen.has(cls.id) || seen.size >= 5) return null; + seen.add(cls.id); + const own = context + .getNodesByName(methodName) + .find((m) => + m.kind === 'method' && m.language === 'python' && m.filePath === cls.filePath && + (m.qualifiedName === `${cls.name}::${methodName}` || m.qualifiedName.endsWith(`::${cls.name}::${methodName}`))); + if (own) return { original: ref, targetNodeId: own.id, confidence: 0.85, resolvedBy: 'instance-method' }; + + const source = context.readFile(cls.filePath); + if (!source) return null; + const header = stripCommentsForRegex(source, 'python') + .split('\n') + .slice(cls.startLine - 1, cls.startLine + 4) + .join(' '); + const bases = new RegExp(`\\bclass\\s+${cls.name}\\s*\\(([^)]*)\\)`).exec(header)?.[1]; + for (const base of (bases ?? '').split(',').map((b) => b.trim())) { + if (!/^[A-Za-z_][\w.]*$/.test(base)) continue; // `metaclass=...`, generics + const baseCls = pythonClassNamed(base, cls.filePath, context); + const hit = baseCls && pythonMethodOnClass(baseCls, methodName, ref, context, seen); + if (hit) return { ...hit, confidence: 0.8 }; + } + return null; +} + +/** + * A base-typed attribute can hold a subclass-only method (#1820): the one + * descendant of `cls` that defines `methodName`, else nothing. Same rule as the + * method-value path in `matchMemberFunctionRef`; unrelated same-name methods + * never win. + */ +function pythonSubclassMethod( + cls: Node, + methodName: string, + ref: UnresolvedRef, + context: ResolutionContext, +): ResolvedRef | null { + const descendants = context.getNodesByName(methodName).filter((m) => { + if (m.kind !== 'method' || m.language !== 'python') return false; + const parent = context.getNodesInFile(m.filePath).find((c) => + c.kind === 'class' && m.qualifiedName === `${c.qualifiedName}::${methodName}`); + return !!parent && pythonDerivesFrom(parent, cls, ref, context); + }); + return descendants.length === 1 + ? { original: ref, targetNodeId: descendants[0]!.id, confidence: 0.8, resolvedBy: 'instance-method' } + : null; +} + +/** + * Python call through one attribute of a class — `self.cap.stop()`, + * `cls.registry.lookup()`, `Svc.registry.lookup()`, `config.llm.rebuild()`. + * The class is the one the call sits in (`self` / `cls`), the one the receiver + * names, or the declared type of a local; the attribute's type comes from that + * class's body; the method is looked up on that type. + * + * Anything the class body does not type — a builtin container, a factory + * function's result, an attribute declared on a base class, an untyped local, + * a deeper chain — resolves to nothing rather than to a same-named method + * somewhere else. + */ +function matchPythonAttrCall( + receiver: string, + methodName: string, + ref: UnresolvedRef, + context: ResolutionContext, +): ResolvedRef | null { + const segs = receiver.split('.'); + if (segs.length !== 2) return null; + const [head, attr] = segs as [string, string]; + + let owner: Node | null = null; + if (head === 'self' || head === 'cls') { + // The innermost class whose extent holds the call. + for (const n of context.getNodesInFile(ref.filePath)) { + if (n.kind !== 'class' || n.startLine > ref.line || n.endLine < ref.line) continue; + if (!owner || n.startLine > owner.startLine) owner = n; + } + } else if (/^[A-Z]/.test(head)) { + owner = pythonClassNamed(head, ref.filePath, context); + } else { + // A local or parameter whose own type the scope states — `config: Config`, + // `controller = Controller(...)` — then the attribute on that class. + const headType = inferLocalReceiverType(head, ref, context); + if (headType) owner = pythonClassNamed(headType, ref.filePath, context); + } + if (!owner) return null; + + const evidence = pythonAttrEvidence(owner, attr, context); + if (!evidence || evidence === 'runtime') return null; + const cls = pythonClassNamed(evidence.type, owner.filePath, context); + return cls ? pythonMethodOnClass(cls, methodName, ref, context) ?? pythonSubclassMethod(cls, methodName, ref, context) : null; +} + /** Go builtin/primitive field types that can never carry a project method. */ const GO_BUILTIN_FIELD_TYPES = new Set([ 'string', 'bool', 'byte', 'rune', 'error', 'any',