Skip to content

fix(python): a parameter or local named like an imported module is not that module - #3046

Merged
colbymchenry merged 1 commit into
mainfrom
claude/fix-python-rebinding
Oct 30, 2026
Merged

colbymchenry merged 1 commit into
mainfrom
claude/fix-python-rebinding

Conversation

@colbymchenry

Copy link
Copy Markdown
Owner

Summary

In Python, a call through a name the file imports as a module now reads that module only where nothing closer binds the name. A parameter, a local, a with … as / except … as / loop target, a walrus, del, a nested def or class, a case capture, or a lambda parameter or comprehension variable around the call is that value instead, so def by_param(other): other.update(…) no longer links into the module the file imports as other. A function-local from pkg import third as other now links that function's other.get() to pkg.third, and no longer every other function's.

Reimplements #2632 by @BGamboa13. That PR's headline change (absolute imports of project modules) landed separately with #1921; this is its rebinding guard, written fresh on top of the existing scope readers.

Root cause

resolveViaImport read the file's import mappings alone. Its only scope check was #2600's pythonFunctionModuleAlias, which handles a function-local import a.b as X and nothing else, so for other.update(…) with from pkg import other at the top of the file:

  • resolvePythonModuleMember linked the call to pkg/other.py's update whatever other held in the calling function;
  • isPythonProjectModule, which lets a builtin-named method (update, get, write) past the builtin filter when the receiver is a project module, asked the same file-wide question, so other = {}; other.update(…) was waved through as a module call.

A related leak: the mappings list from X import Y statements at any depth (indented ones included), ahead of every plain import. A function-local from pkg import third as other was therefore the first mapping for other in the whole file, and with import pkg.other as other at module level every other function's other.get() linked to pkg.third.

Fix

  • pythonReceiverBinding (name-matcher) answers how the code around a reference binds a name: the reference's own statement (a lambda parameter list or a comprehension for target the reference sits inside, which bind only there), then each enclosing function innermost first (a closure reads its enclosing function's locals). Per function it reads the parameters and the statements of its own body (continuation lines joined, strings blanked, nested def/class bodies skipped but their names counted): assignment targets (pythonAssignTargets / pythonTargetBinds, so a keyword argument, x.attr = … and d[x] = … bind nothing), annotations, for, as targets, walrus, del, case captures (not a value pattern other.MODE, a class pattern other(…) or a keyword pattern's attribute Point(other=1)), and imports. global name leaves the module's binding in place. Results are memoized per function and name; a per-file line index finds the enclosing functions.
  • pythonImportsInScope (import-resolver) gives a dotted Python reference the mappings its receiver means there: none of the receiver's when something else binds it; for a function-local import, only the mappings binding the same thing (a local from pkg import other still matches the module-level one, a local import pkg.sub keeps the module-level import pkg); and where nothing nearer binds it, only module-level mappings when the receiver's mappings disagree (the leak above), falling back to all of them if no module-level one is found. resolveViaImport's Python branch and isPythonProjectModule both use it, and isPythonProjectModule now also honours Python: import a.b as X inside a function still gives no call edge (rest of #1626) #2600's local import a.b as X, as resolvePythonModuleMember already did.

Resolution only: no extraction change, so no kernel change.

Validation

  • New __tests__/python-receiver-rebinding.test.ts, 80 tests over the default and wasm paths, LF and CRLF: on the base main 48 fail (the ten rebinding shapes, the function-local from … import … as, the cross-function leak) and 32 pass (the look-alikes: keyword argument, text in a string and a comment, a comprehension over the module, a lambda beside the call, global, a keyword pattern, a same-module local import); all 80 pass with the fix.

  • The triage fixtures (pr2632-shadow, -shadow2, -localrebind, -scoped, pr2632, -flat): every rebound call (parameter, with … as, assignment, for, except … as, lambda parameter, case other:) is unlinked; local_from.py now reaches third.py:get; -scoped's six look-alikes, case Point(other=1), case = 1, top(), the same-module local import and the local import pkg.third as other keep their links; the src-layout and flat headline fixtures are unchanged. A 25-site probe (closures, walrus, annotation-only, augmented, nested class, tuple as, async with, *args/**kw, one-line defs, mapping/sequence captures, later assignment) came out right at every site.

  • A/B, base main vs fix (resolution only, so django ran on the kernel path and the rest on wasm; repo commits in parentheses):

    Repo Changed Triage
    django's django/ package (817df1c4, 930 files, kernel path) 0 edges; 2 confidence 0.85 → 0.8, same targets right: _check_lazy_references(apps, …)'s nested function calls through the parameter apps, and lazy_related_operation through apps = model._meta.apps, not the module-level from django.apps import apps
    flask (d086db85) −1 edge; 4 confidence 0.85 → 0.7, same targets test_appgroup_app_context defines def cli() (decorated @click.group(cls=AppGroup)), so cli is local there; main had typed cli.group() / cli.command() through from flask.cli import cli written inside other test functions. cli.group() is now unlinked (group is also a re.Match method name), the 3 cli.command() keep AppGroup.command at 0.7. client(app)'s pytest fixture parameter app keeps Flask.test_client at 0.7.
    requests (3c7baaa0), click (2247b35e), mealie mealie/+tests/ (581ac022), fastapi fastapi/+tests/ (03002a00) byte-identical —
    express (1c6f1b0f), gin (0f09c3a9) byte-identical controls
  • Index time, django's package on the kernel path, median of 3 interleaved: 11.3 s main, 10.7 s fix (noise).

  • Python-named suites plus frameworks-integration, pr19-improvements and function-ref (26 files): 357 passed, 1 skipped, 0 failed.

  • Full suite on this exact commit (base main c12a7b6, kernel built, CODEGRAPH_KERNEL_EXPECT=1, CODEGRAPH_NO_DAEMON unset, 4 workers): 795 files, 9,461 passed, 0 failed, 85 skipped.

Left out

  • A bare call through a shadowed import (def f(helper): helper() after from pkg import helper) goes through name matching's own Python local-binding check, which by design defers to an imported name; it is a different path and is not changed here.
  • A module-level rebinding (other = other.sub, try: import x as other / except ImportError: other = None) is out of scope: the guard reads functions and the reference's own statement.
  • Typing a decorated nested def by its decorator (@click.group(cls=AppGroup) def cli()): flask's test now leaves one cli.group() unlinked, which main had linked through another function's import (see the triage).

🤖 Generated with Claude Code

…t that module

A Python call through a name the file imports as a module, like
other.update(...) after `from pkg import other`, linked to the module's
function whatever the calling function bound `other` to: a parameter, a
local, a with/except/loop target, a walrus, a case capture, a lambda or
comprehension variable. resolveViaImport read the file's import mappings
alone (its only scope check, pythonFunctionModuleAlias from PR #2600,
covers a function-local `import a.b as X`), and isPythonProjectModule,
which lets a builtin-named call like update/get/write past the builtin
filter for a project module, asked the same file-wide question. The
mappings also list from-imports at any depth ahead of plain imports, so a
function-local `from pkg import third as other` became every function's
`other`.

pythonReceiverBinding reads how the code around a reference binds the
name: the reference's own statement (lambda parameters, comprehension
targets), then each enclosing function, innermost first, through its
parameters and own statements (assignment, annotation, for, as, walrus,
del, nested def/class, case captures, imports; global keeps the module's
binding), memoized per function. pythonImportsInScope gives
resolveViaImport's Python branch and isPythonProjectModule only the
receiver's mappings that apply there.

Validation:
- __tests__/python-receiver-rebinding.test.ts, 80 tests (default and
  wasm, LF and CRLF): 48 fail on the base main (ten rebinding shapes,
  the local from-import, the cross-function leak), 32 pass (look-alikes:
  keyword argument, string, comment, comprehension over the module, lambda
  beside the call, global, keyword pattern, same-module local import);
  all pass with the fix. The 26 Python-related test files pass.
- A/B, main vs fix: django's own package (930 files, kernel path) changes
  2 edges' confidence (0.85 to 0.8, same targets: a closure over a
  parameter `apps` and a local `apps = model._meta.apps`); flask drops
  one cli.group() call and lowers 4 to 0.7 with the same targets (a
  nested `def cli` and a pytest fixture parameter `app` named like
  imports); requests, click, mealie and fastapi (package plus tests), and
  the express and gin controls are byte-identical.
- django package index time, median of 3 interleaved: 11.3 s main,
  10.7 s fix.

Reimplements PR #2632 by BGamboa13.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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.

1 participant