fix(resolution): resolve method values on untyped Python module globals (#2074) - #2075
JosefAschauer wants to merge 5 commits into
Conversation
…ls (colbymchenry#1820) A method value whose receiver is a module global without a static type (`thread_pool_exec(settings.conn.fetch, ...)`, where `conn = None` is rebound by `global conn; conn = Backend()`) resolved to a `variable` node and produced no edge. The global's type is now the set of classes its module assigns to it, at module scope or in a function that declares it `global`. One class resolves to its own method; several bind to the nearest declaration every candidate inherits, as a base-typed receiver does. No edge (a wrong callback edge is worse than none) when: - any binding of the global in its module is not a whole constructor call: a factory, a conditional, a tuple target, `for`/`with`/import, a star import, or an annotation the constructor contradicts; - the caller or an enclosing function binds the name itself: parameter, assignment (any target position), loop, comprehension, `as`, `case` pattern, lambda parameter; - the receiver is a deeper chain (`settings.conn.pool.fetch`); - the importing file rebinds the name, or imports it from two sources. Statements are read with their continuation lines, and only statement lines (bracket depth 0) can assign. Not seen: rebinding the global from another module (`settings.conn = X`) or through `globals()`. `pkg.mod.Cls` now resolves through `import pkg.mod`, unless two namespace imports share the last segment. Python import mappings also read parenthesized from-imports (`from x import (\n A,\n B,\n)`), which previously yielded the single name `(` and hid every class imported that way from receiver and inheritance resolution. Comments and docstrings are stripped first. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018CQubRS1bXvaoApgPyLf66
…her files (colbymchenry#2074) The module-global receiver type was read from the global's own module only, so `settings.conn = Decoy()` in another module, or `globals()["conn"] = X`, left a stale type and a wrong edge. - A production write `<module>.<name> = Cls(...)` from another file joins the type set (resolved in the writing file); any other production write (another value, a tuple target, `setattr`/`patch.object`) makes the type unknown. The module is matched through each file's imports, including aliases and relative imports. - Test files install doubles (`settings.conn = MagicMock()`, `monkeypatch.setattr(settings, "conn", ...)`): they do not change the production type, but a ref inside such a test resolves nothing. - `globals()[...] = ...` with the global's name or a computed key, and `globals().update(...)`, in the global's module make the type unknown. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018CQubRS1bXvaoApgPyLf66
…suffix Review follow-up to the cross-module write scan. - A write now lands on the file its import names: relative imports resolve exactly, absolute ones by path suffix; when a spelling could name another file sharing the tail (`x/settings.py` and `y/settings.py` for `import settings`), a write through it makes the type unknown instead of feeding or clearing the wrong module. - `import a.b` binds `a`: the module is spelled `a.b`; the mapping's last-segment name is not treated as an alias of it. - Test doubles are recognised by the narrow test-suite set plus any `conftest.py`; examples, fixtures and benchmarks are production writers. - Writes inside `if __name__ == "__main__":` are script code, not module state, in the global's module and elsewhere. - Namespace-dict writes: `globals()`, `vars()` and `sys.modules[__name__]` are read per statement (continuations joined, strings ignored); any use other than a literal-key read or `.get`, or a literal-key write of the name, makes the type unknown. `<module>.__dict__[...]` / `.update(` from another file does too. - `settings.conn: Store = Store()` and `settings.conn = (Store())` are plain constructor writes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018CQubRS1bXvaoApgPyLf66
…coped vars() Review follow-up. - `import pkg.settings as settings` binds `settings` even though it equals the last segment; the import mapping cannot tell it from a plain `import pkg.settings`, so the source line decides. - `if __name__ == "__main__": stmt` on one line is script code, and a `globals()` write inside a `__main__` block no longer counts. - `vars()` is the module dict only at module scope; inside a function it is the locals, so `return vars()` there no longer makes the type unknown. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018CQubRS1bXvaoApgPyLf66
…de strings Review follow-up: `import other.pkg.settings as settings` or a string `"import pkg.settings as settings"` no longer marks a plain `import pkg.settings` as explicitly aliased, which attached a write to the wrong module. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018CQubRS1bXvaoApgPyLf66
|
Pushed follow-up commits that close the gap listed under "Not addressed": writes to the global from other files, and dynamic writes.
Validation: 34 more rows in the "never binds…" table, each mutation-checked. On RAGFlow the 181 edges are unchanged, none of them from test files, and no new edges appear. Known limitation: a body-only edit to a file that writes the global doesn't re-resolve consumers on The override half of #2074 (callers of an override missing base-typed calls) is a separate PR: #2076. |
|
Thank you, and for the thorough adversarial tests! This landed in #2291: your commits carried onto current |
Problem
A method value whose receiver is an untyped Python module global produced no
callers/impactedge. An example isthread_pool_exec(settings.docStoreConn.delete_payload_fields, …), wheredocStoreConn = Noneis later rebound withglobal docStoreConn; docStoreConn = Backend(). #2034 resolves receivers through type and import scope. This receiver resolves to avariablenode, not a class, so the member was never looked up.While verifying this on a real project, I found a second gap it depends on:
extractPythonImportsreadfrom x import (\n A,\n B,\n)as the single name(. So a class imported that way was invisible to receiver and inheritance resolution, including #2034's typed receivers andpythonBases.Fix
Module-global receivers (
matchMemberFunctionRef, Python):<import>.<global>, a bare imported global, or a same-file global.global. Annotations count.Noneis ignored.for/with/import, a star import, and an annotation the constructor contradicts.as, acasepattern, or a lambda parameter.settings.conn.pool.fetch).clearNameMatcherMemos, sosyncre-resolves them.settings.conn = X) or throughglobals()is not seen.Dotted class references:
pkg.mod.Clsnow resolves throughimport pkg.mod. It is refused when two namespace imports share the last segment.Python import mappings: parenthesized from-imports are parsed, after comments and docstrings are stripped.
Resolution only. There is no extraction or kernel change, so no extraction version bump.
Validation
__tests__/function-ref.test.tsadds 5 cases:syncre-resolution test;__tests__/resolution.test.tsadds a case for parenthesized imports with a comment and a docstring.npx tsc --noEmit: clean.npx vitest run __tests__/{function-ref,kernel-tsjs-parity,resolution,call-receiver-no-fabrication,python-quoted-annotation,python-module-scope-collection-methods,extraction}.test.ts: 1022 passed.CODEGRAPH_KERNEL=0function-ref.test.ts: 35 passed.bundle-launcher×2: nozipbinary on this machine.cli-sync×2: the host runs Node 26, which prints the unsupported-Node banner.docStoreConnhas 7 backend classes):DocStoreConnectiondeclarations:search,delete,insert,update,getanddelete_payload_fields.client,dbName) stay unresolved.mainfound 0 removed edges without a replacement. The retargets I checked were all corrections, e.g. a Python ref previously bound to a same-named Go symbol now binds to the Python definition it imports. The added edges I spot-checked were correct.Not addressed
callersof an override (e.g.QdrantConnection.delete_payload_fields) still don't include calls that bind to the base declaration. That's a query-time dispatch question, and I've noted it in the issue.Fixes #2074
🤖 Generated with Claude Code