Skip to content

fix(resolution): resolve method values on untyped Python module globals (#2074) - #2075

Closed
JosefAschauer wants to merge 5 commits into
colbymchenry:mainfrom
JosefAschauer:fix/python-module-global-method-values
Closed

JosefAschauer wants to merge 5 commits into
colbymchenry:mainfrom
JosefAschauer:fix/python-module-global-method-values

Conversation

@JosefAschauer

Copy link
Copy Markdown
Contributor

Problem

A method value whose receiver is an untyped Python module global produced no callers/impact edge. An example is thread_pool_exec(settings.docStoreConn.delete_payload_fields, …), where docStoreConn = None is later rebound with global docStoreConn; docStoreConn = Backend(). #2034 resolves receivers through type and import scope. This receiver resolves to a variable node, 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: extractPythonImports read from 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 and pythonBases.

Fix

Module-global receivers (matchMemberFunctionRef, Python):

  • The receiver must be <import>.<global>, a bare imported global, or a same-file global.
  • The global's type is the set of classes its module assigns to it: at module scope, or in a function that declares it global. Annotations count. None is ignored.
  • With one class, the edge goes to that class's method. With several, it goes to the nearest declaration every candidate class inherits, which is where a base-typed receiver already binds. If there is no single such declaration, the ref gets no edge.
  • Following "a wrong callback edge is worse than none", these cases produce no edge:
    • Any binding of the global in its module other than a whole constructor call. That includes a factory, a conditional, a tuple target, for/with/import, a star import, and an annotation the constructor contradicts.
    • The caller or an enclosing function binding the name itself: a parameter, an assignment in any target position, a loop or comprehension, as, a case pattern, or a lambda parameter.
    • A deeper chain (settings.conn.pool.fetch).
    • An importing file that rebinds the name or imports it from two sources.
  • Only statement lines (bracket depth 0) can assign. Each statement is read together with its continuation lines, so a multi-line constructor is read to its closing parenthesis.
  • The per-file scans are memoized and cleared in clearNameMatcherMemos, so sync re-resolves them.
  • Known limitations: a rebinding from another module (settings.conn = X) or through globals() is not seen.

Dotted class references: pkg.mod.Cls now resolves through import 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.ts adds 5 cases:
  • __tests__/resolution.test.ts adds a case for parenthesized imports with a comment and a docstring.
  • Each new rule was mutation-checked: I disabled it and confirmed that a new test failed.
  • 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=0 function-ref.test.ts: 35 passed.
  • Full suite on Linux: 5369 passed, 4 failed. The failures are environmental:
    • bundle-launcher ×2: no zip binary on this machine.
    • cli-sync ×2: the host runs Node 26, which prints the unsupported-Node banner.
  • Real project (RAGFlow, 5.5k files; docStoreConn has 7 backend classes):
    • 181 previously missing callbacks now resolve to the DocStoreConnection declarations: search, delete, insert, update, get and delete_payload_fields.
    • The non-callable attributes (client, dbName) stay unresolved.
    • An edge diff against main found 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.
    • Indexing time is about the same.
  • Two rounds of independent review (gpt-5.6-terra) plus a verification round, using 45 adversarial probe projects. All findings are fixed and pinned in the tests above.

Not addressed

callers of 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

JosefAschauer and others added 5 commits September 28, 2026 08:26
…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
@JosefAschauer

Copy link
Copy Markdown
Contributor Author

Pushed follow-up commits that close the gap listed under "Not addressed": writes to the global from other files, and dynamic writes.

  • Production writes from other files: <module>.<name> = Cls(...) from another production file adds Cls to the type set, resolved using the imports of the file that wrote it. Any other production write makes the type unknown: another value, a tuple target, setattr / patch.object, <module>.__dict__[...] or .update(.
  • Which file a write lands on: a write counts for the file its import actually names. Relative imports resolve exactly. Absolute ones match a path suffix; when a spelling could name two files sharing the tail (x/settings.py, y/settings.py), the type becomes unknown. import a.b binds a, and import a.b as b is recognised as explicit from the source line.
  • Test doubles: writes in test suites (plus any conftest.py) don't change the production type, e.g. settings.conn = MagicMock() or monkeypatch.setattr(settings, "conn", …). A ref inside such a test resolves nothing through the global. RAGFlow's tests do this in 20 places. Treating those writes as production would have removed every real edge.
  • globals() and __main__:
    • globals() / sys.modules[__name__] used for anything but a literal-key read or .get, or a literal-key write of the name, makes the type unknown. So does vars() at module scope.
    • Writes inside if __name__ == "__main__": are script code and are ignored.

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 sync. That's the existing CG-33 trade-off: an empty definitionDelta means no cross-file rebind. A full index converges.

The override half of #2074 (callers of an override missing base-typed calls) is a separate PR: #2076.

@colbymchenry

Copy link
Copy Markdown
Owner

Thank you, and for the thorough adversarial tests! This landed in #2291: your commits carried onto current main with their authorship kept. The edge A/B on netbox, mealie, DRF, allauth and ragflow showed only corrections. It also surfaced an older bug your import fix made more visible (self.x() binding to an imported x), which was fixed first in #2290.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Method values on untyped Python module globals still produce no callers edge (follow-up to #1820)

2 participants