Skip to content

Commit 8d104f8

Browse files
songoowclaude
andcommitted
fix(semantics): let a module-scope import alias take the name it rebinds
``action = "run"`` followed by ``import os as action`` left the scan reporting ``values=['run'], unresolved=false`` while CPython bound the module object. The gate that reads this treats a resolved set as stronger evidence than unknown, so the shape produced false certainty rather than a missed value. The cause was one set answering two questions. At module scope an import is how a producer names the owner module it qualifies against, so an import there must not shadow that qualified binding -- which is why ``imported`` was left empty for ``<module>``. The same set was then reused for ``rebound_by_other_forms``, the check that asks whether a later binding takes the name away from a plain assignment. Skipping module scope for the first question silently skipped it for the second, and the superseded literal survived as a complete set. The two questions are now asked of two sets. ``import_bound`` collects the names every import binds, in every scope, and feeds the rebinding check; ``imported`` stays empty at module scope and keeps feeding the shadow set, so owner-module qualification is untouched. A name has to be both plainly assigned and import-bound in the same scope for this to change anything, which is exactly the counterexample and nothing else. ``MODULE_ASSIGNED_THEN_IMPORTED`` adds the module-scope variant to the executable property test, which runs the snippet under CPython and compares what it observes against what the scan reports. It fails on the previous code with the module repr the scan omitted, and passes here. Nothing on the tracked tree carries the shape: the drift smoke reports the same unresolved_producer_sites=40 and the same blocker distribution (annotation_only=5, argument_name_only=10, attribute_read=7, call_result=14, other=1, unstable_local=3) as before the fix. ``tests/architecture`` 684 passed. Ruff reports the same four findings as main at lines this change does not touch, and ``git diff --check`` is clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: song <22676124+songoow@users.noreply.github.com>
1 parent 1751f98 commit 8d104f8

2 files changed

Lines changed: 30 additions & 6 deletions

File tree

‎loopx/semantics/python_production.py‎

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -470,11 +470,19 @@ def collect(node: ast.AST, loops: frozenset[int] = frozenset()) -> None:
470470
if scope == '<module>':
471471
nested_names -= local_owner_names | {owner.split('::')[1] for owner in call_arguments
472472
if owner.split('::')[0] == source.path}
473-
imported = set()
474-
if scope != '<module>':
475-
for node in nodes:
476-
if isinstance(node, (ast.Import, ast.ImportFrom)):
477-
imported.update(alias.asname or alias.name.split('.')[0] for alias in node.names)
473+
import_bound = set()
474+
for node in nodes:
475+
if isinstance(node, (ast.Import, ast.ImportFrom)):
476+
import_bound.update(alias.asname or alias.name.split('.')[0] for alias in node.names)
477+
# Two different questions are asked about the same import. At module
478+
# scope an import is how a producer names the owner module it qualifies
479+
# against, so it must not shadow that qualified binding -- which is why
480+
# ``imported`` stays empty here. Whether it takes the name away from a
481+
# plain assignment to that same name is asked separately, through
482+
# ``rebound_by_other_forms``, and the answer there is yes in every
483+
# scope: ``action = "run"`` followed by ``import os as action`` leaves
484+
# the module object bound, not the literal.
485+
imported = set() if scope == '<module>' else import_bound
478486
exception_targets = {n.name for n in nodes if isinstance(n, ast.ExceptHandler) and n.name}
479487
deleted = {n.id for n in nodes if isinstance(n, ast.Name) and isinstance(n.ctx, ast.Del)}
480488
shadows = set(assigned) | parameters | nested_names | imported | exception_targets | deleted
@@ -509,7 +517,7 @@ def collect(node: ast.AST, loops: frozenset[int] = frozenset()) -> None:
509517
# A later ``import as``, ``except as`` or nested ``def``/``class`` takes
510518
# the name away from the value the plain assignment gave it, so the
511519
# initializer is no longer the whole story for this scope.
512-
rebound_by_other_forms = imported | exception_targets | nested_names
520+
rebound_by_other_forms = import_bound | exception_targets | nested_names
513521
definitions = {name: values for name, values in plain.items()
514522
if assigned[name] == len(values) and name not in parameters
515523
and name not in declared and name not in deleted

‎tests/architecture/test_semantic_producer_soundness_regressions.py‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,21 @@ def emit():
9797
return {"effective_action": str(action)}
9898
'''
9999

100+
# The same rebinding at module scope, where the scan has a second reason to
101+
# look at imports: an import is how a producer names the owner module it
102+
# qualifies against, so an import may not shadow that qualified binding. It
103+
# must still take the name away from a plain assignment to the same name, and
104+
# for a while it did not -- the scan reported ``["run"]`` as a complete set
105+
# while CPython bound the module object.
106+
MODULE_ASSIGNED_THEN_IMPORTED = '''
107+
action = "run"
108+
import os as action
109+
RESULT = {"effective_action": str(action)}
110+
111+
def emit():
112+
return RESULT
113+
'''
114+
100115
# ``except ... as action`` rebinds the name to the exception and unbinds it at
101116
# block exit, so the initializer above is not what the field can carry.
102117
ASSIGNED_THEN_CAUGHT = '''
@@ -155,6 +170,7 @@ def emit():
155170
pytest.param(MATCH_STAR, [["a"]], id="match-star-rebinds"),
156171
pytest.param(MATCH_MAPPING_REST, [{"k": 1, "z": 2}], id="match-mapping-rest-rebinds"),
157172
pytest.param(ASSIGNED_THEN_IMPORTED, [_NO_ARGUMENT], id="import-takes-the-name"),
173+
pytest.param(MODULE_ASSIGNED_THEN_IMPORTED, [_NO_ARGUMENT], id="module-import-takes-the-name"),
158174
pytest.param(ASSIGNED_THEN_CAUGHT, [True], id="except-takes-the-name"),
159175
pytest.param(NESTED_NONLOCAL_REBIND, ["drop"], id="nested-scope-rebinds"),
160176
pytest.param(CONTAINER_ALIAS_WRITE, ["drop"], id="alias-writes-the-container"),

0 commit comments

Comments
 (0)