#!/usr/bin/env python3 # SPDX-License-Identifier: AGPL-3.0-only # Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. """Scope-aware check for dangling aliases and silent rename clashes in import hoists (_b -> b).""" from __future__ import annotations import argparse import ast from pathlib import PurePosixPath import builtins import re as _re_mod import subprocess import sys from dataclasses import dataclass, field _BUILTINS = set(dir(builtins)) | { "__file__", "__name__", "__doc__", "__package__", "__spec__", "__loader__", "__builtins__", "__class__", "__annotations__", "__dict__", "__qualname__", "__module__", "__path__", "__debug__", "__import__", "NotImplemented", "Ellipsis", "copyright", "credits", "license", "help", "exit", "quit", "__build_class__", "__cached__", "reveal_type", "reveal_locals", } @dataclass class Binding: kind: str # 'import' | 'importfrom' | 'def' | 'class' | 'other' target: str | None = None @dataclass class Scope: kind: str # 'module' | 'function' | 'class' | 'lambda' | 'comp' qualname: str parent: "Scope | None" bindings: dict[str, list[Binding]] = field(default_factory = dict) globals: set[str] = field(default_factory = set) nonlocals: set[str] = field(default_factory = set) star_import: bool = False def add(self, name: str, b: Binding) -> None: self.bindings.setdefault(name, []).append(b) def _import_target(node: ast.AST, alias: ast.alias) -> tuple[str, str]: """Return (bound_name, canonical_target_id) for one import alias.""" if isinstance(node, ast.Import): bound = alias.asname or alias.name.split(".")[0] return bound, f"import:{alias.name}" bound = alias.asname or alias.name mod = ("." * (node.level or 0)) + (node.module or "") return bound, f"from:{mod}:{alias.name}" def _is_literal_ref(node: ast.AST) -> bool: """Whether `node` names `Literal`, however it was imported (`Literal`, `t.Literal`).""" if isinstance(node, ast.Name): return node.id == "Literal" return isinstance(node, ast.Attribute) and node.attr == "Literal" class _Builder(ast.NodeVisitor): """Builds the scope tree + bindings, and records every (scope, Name-load).""" def __init__(self): self.module = Scope("module", "", None) self.uses: list[tuple[Scope, str, int]] = [] # Annotations count as used but never unresolved (forward refs). self.soft_uses: list[tuple[Scope, str, int]] = [] # `Optional["Dict[str, 'T']"]` is two deep; nothing real goes further. _FORWARD_REF_DEPTH = 3 def _visit_annotation( self, node, scope: Scope, _depth: int = 0, _lineno: int = 0, ) -> None: """Record annotation names as SOFT uses: an import used only in an annotation counts as used, but a forward-ref name is never 'unresolved'. A QUOTED annotation is one too: `Optional["T"]` keeps the name in an ast.Constant, invisible to a Name walk, so a TYPE_CHECKING import reached only that way read as unused and blocked correct code. Parse the string and walk what it denotes. """ if node is None: return # Literal[...] holds values, not type names; crediting them would hide a real finding. if isinstance(node, ast.Subscript) and _is_literal_ref(node.value): self._visit_annotation(node.value, scope, _depth, _lineno) return if isinstance(node, ast.Constant): if not isinstance(node.value, str) or _depth >= self._FORWARD_REF_DEPTH: return try: inner = ast.parse(node.value.strip(), mode = "eval").body except (SyntaxError, ValueError): return self._visit_annotation(inner, scope, _depth + 1, _lineno or node.lineno) return if isinstance(node, ast.Name) and isinstance(node.ctx, ast.Load): self.soft_uses.append((scope, node.id, _lineno or node.lineno)) for child in ast.iter_child_nodes(node): self._visit_annotation(child, scope, _depth, _lineno) def _bind_targets(self, scope: Scope, target: ast.AST) -> None: for n in ast.walk(target): if isinstance(n, ast.Name) and isinstance(n.ctx, (ast.Store, ast.Del)): self._bind_name(scope, n.id, Binding("other")) elif isinstance(n, ast.Starred): pass def _bind_name(self, scope: Scope, name: str, b: Binding) -> None: if name in scope.globals: self.module.add(name, b) elif name in scope.nonlocals: p = scope.parent while p is not None and p.kind not in ("function", "lambda"): p = p.parent (p or self.module).add(name, b) else: scope.add(name, b) def _visit_body(self, stmts, scope: Scope) -> None: for s in stmts: self._visit_stmt(s, scope) def _visit_stmt(self, node: ast.AST, scope: Scope) -> None: if isinstance(node, (ast.Import, ast.ImportFrom)): star = isinstance(node, ast.ImportFrom) and any(a.name == "*" for a in node.names) if star: scope.star_import = True for alias in node.names: if alias.name == "*": continue bound, target = _import_target(node, alias) kind = "import" if isinstance(node, ast.Import) else "importfrom" self._bind_name(scope, bound, Binding(kind, target)) return if isinstance(node, ast.Global): scope.globals.update(node.names) return if isinstance(node, ast.Nonlocal): scope.nonlocals.update(node.names) return if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): self._bind_name(scope, node.name, Binding("def")) # Decorators and defaults evaluate in the enclosing scope. for d in node.decorator_list: self._visit_expr(d, scope) self._visit_arg_defaults(node.args, scope) child = Scope("function", f"{scope.qualname}.{node.name}", scope) self._bind_type_params(node, child) self._bind_args(node.args, child) for a in self._all_args(node.args): self._visit_annotation(a.annotation, child) self._visit_annotation(getattr(node, "returns", None), child) self._visit_body(node.body, child) return if isinstance(node, ast.ClassDef): self._bind_name(scope, node.name, Binding("class")) for d in node.decorator_list: self._visit_expr(d, scope) for b in node.bases: self._visit_expr(b, scope) for kw in node.keywords: self._visit_expr(kw.value, scope) child = Scope("class", f"{scope.qualname}.{node.name}", scope) self._bind_type_params(node, child) self._visit_body(node.body, child) return if isinstance(node, ast.Match): self._visit_expr(node.subject, scope) for case in node.cases: self._bind_pattern(case.pattern, scope) if case.guard is not None: self._visit_expr(case.guard, scope) self._visit_body(case.body, scope) return if isinstance(node, getattr(ast, "TryStar", ())): self._visit_body(node.body, scope) for h in node.handlers: if h.type is not None: self._visit_expr(h.type, scope) if h.name: self._bind_name(scope, h.name, Binding("other")) self._visit_body(h.body, scope) self._visit_body(node.orelse, scope) self._visit_body(node.finalbody, scope) return if isinstance(node, getattr(ast, "TypeAlias", ())): if isinstance(node.name, ast.Name): self._bind_name(scope, node.name.id, Binding("other")) self._visit_annotation(node.value, scope) return if isinstance(node, (ast.Assign, ast.AnnAssign, ast.AugAssign)): targets = node.targets if isinstance(node, ast.Assign) else [node.target] val = node.value if val is not None: self._visit_expr(val, scope) if isinstance(node, ast.AnnAssign) and node.annotation is not None: self._visit_annotation(node.annotation, scope) for t in targets: self._bind_targets(scope, t) if isinstance(node, ast.AugAssign): self._record_loads(t, scope) else: # `overrides[dependency] = fn` loads both names. if not isinstance(t, ast.Name): self._record_loads(t, scope) return if isinstance(node, (ast.For, ast.AsyncFor)): self._visit_expr(node.iter, scope) self._bind_targets(scope, node.target) self._visit_body(node.body, scope) self._visit_body(node.orelse, scope) return if isinstance(node, (ast.With, ast.AsyncWith)): for item in node.items: self._visit_expr(item.context_expr, scope) if item.optional_vars is not None: self._bind_targets(scope, item.optional_vars) self._visit_body(node.body, scope) return if isinstance(node, ast.Try): self._visit_body(node.body, scope) for h in node.handlers: if h.type is not None: self._visit_expr(h.type, scope) if h.name: self._bind_name(scope, h.name, Binding("other")) self._visit_body(h.body, scope) self._visit_body(node.orelse, scope) self._visit_body(node.finalbody, scope) return for child in ast.iter_child_nodes(node): if isinstance(child, ast.stmt): self._visit_stmt(child, scope) else: self._visit_expr(child, scope) def _visit_arg_defaults(self, args: ast.arguments, scope: Scope) -> None: for d in list(args.defaults) + [d for d in args.kw_defaults if d is not None]: self._visit_expr(d, scope) def _all_args(self, args: ast.arguments) -> list[ast.arg]: out = list(args.posonlyargs) + list(args.args) + list(args.kwonlyargs) if args.vararg: out.append(args.vararg) if args.kwarg: out.append(args.kwarg) return out def _bind_args(self, args: ast.arguments, scope: Scope) -> None: for a in self._all_args(args): scope.add(a.arg, Binding("other")) def _bind_type_params(self, node, scope: Scope) -> None: for tp in getattr(node, "type_params", []) or []: name = getattr(tp, "name", None) if isinstance(name, str): scope.add(name, Binding("other")) self._visit_annotation(getattr(tp, "bound", None), scope) self._visit_annotation(getattr(tp, "default_value", None), scope) def _bind_pattern(self, pat, scope: Scope) -> None: if pat is None: return if isinstance(pat, ast.MatchValue): self._visit_expr(pat.value, scope) elif isinstance(pat, ast.MatchSingleton): pass elif isinstance(pat, ast.MatchSequence): for p in pat.patterns: self._bind_pattern(p, scope) elif isinstance(pat, ast.MatchStar): if pat.name: self._bind_name(scope, pat.name, Binding("other")) elif isinstance(pat, ast.MatchMapping): for k in pat.keys: self._visit_expr(k, scope) for p in pat.patterns: self._bind_pattern(p, scope) if pat.rest: self._bind_name(scope, pat.rest, Binding("other")) elif isinstance(pat, ast.MatchClass): self._visit_expr(pat.cls, scope) for p in pat.patterns: self._bind_pattern(p, scope) for p in pat.kwd_patterns: self._bind_pattern(p, scope) elif isinstance(pat, ast.MatchAs): self._bind_pattern(pat.pattern, scope) if pat.name: self._bind_name(scope, pat.name, Binding("other")) elif isinstance(pat, ast.MatchOr): for p in pat.patterns: self._bind_pattern(p, scope) def _record_loads(self, node: ast.AST, scope: Scope) -> None: for n in ast.walk(node): if isinstance(n, ast.Name) and isinstance(n.ctx, ast.Load): self.uses.append((scope, n.id, n.lineno)) def _visit_expr(self, node: ast.AST, scope: Scope) -> None: if isinstance(node, ast.Name): if isinstance(node.ctx, ast.Load): self.uses.append((scope, node.id, node.lineno)) elif isinstance(node.ctx, (ast.Store, ast.Del)): self._bind_name(scope, node.id, Binding("other")) return if isinstance(node, ast.Lambda): self._visit_arg_defaults(node.args, scope) child = Scope("lambda", f"{scope.qualname}.", scope) self._bind_args(node.args, child) self._visit_expr(node.body, child) return if isinstance(node, (ast.ListComp, ast.SetComp, ast.GeneratorExp, ast.DictComp)): child = Scope("comp", f"{scope.qualname}.", scope) for i, gen in enumerate(node.generators): # First iterable evaluates in the enclosing scope. self._visit_expr(gen.iter, scope if i == 0 else child) self._bind_targets(child, gen.target) for cond in gen.ifs: self._visit_expr(cond, child) if isinstance(node, ast.DictComp): self._visit_expr(node.key, child) self._visit_expr(node.value, child) else: self._visit_expr(node.elt, child) return if isinstance(node, ast.NamedExpr): # walrus binds in enclosing scope self._visit_expr(node.value, scope) if isinstance(node.target, ast.Name): self._bind_name(scope, node.target.id, Binding("other")) return for child in ast.iter_child_nodes(node): if isinstance(child, ast.stmt): self._visit_stmt(child, scope) else: self._visit_expr(child, scope) def run(self, tree: ast.Module) -> None: self._visit_body(tree.body, self.module) # ---------------------------------------------------------------- resolution def _any_star(scope: Scope) -> bool: c = scope while c is not None: if c.star_import: return True c = c.parent return False def _resolve(scope: Scope, name: str): """LEGB resolution. Returns (status, bindings); status in {'local','import','other','builtin','star','unresolved'}.""" start = scope if name in scope.globals: chain = [_module_of(scope)] elif name in scope.nonlocals: chain = _enclosing_functions(scope) else: chain = _legb_chain(scope) for i, sc in enumerate(chain): if sc is None: continue if name in sc.bindings: binds = sc.bindings[name] if any(b.kind in ("import", "importfrom") for b in binds): return "import", binds return "other", binds if name in _BUILTINS: return "builtin", [] if _any_star(start): return "star", [] return "unresolved", [] def _module_of(scope: Scope) -> Scope: while scope.parent is not None: scope = scope.parent return scope def _enclosing_functions(scope: Scope) -> list[Scope]: out = [] p = scope.parent while p is not None: if p.kind in ("function", "lambda"): out.append(p) p = p.parent out.append(_module_of(scope)) return out def _legb_chain(scope: Scope) -> list[Scope]: """Immediate scope, then enclosing scopes skipping class scopes, then module.""" chain = [scope] p = scope.parent while p is not None: if p.kind != "class" or p.parent is None: if p.kind != "class": chain.append(p) p = p.parent return chain # ---------------------------------------------------------------- analysis def _analyze(src: str): tree = ast.parse(src) b = _Builder() b.run(tree) unresolved: dict[str, set[str]] = {} targets_by_scope: dict[str, set[str]] = {} target_by_use: dict[tuple[str, str], set[str]] = {} for scope, name, _ln in b.uses: status, binds = _resolve(scope, name) if status == "unresolved": unresolved.setdefault(scope.qualname, set()).add(name) elif status == "import": tids = {bd.target for bd in binds if bd.target} targets_by_scope.setdefault(scope.qualname, set()).update(tids) target_by_use.setdefault((scope.qualname, name), set()).update(tids) for scope, name, _ln in b.soft_uses: status, binds = _resolve(scope, name) if status == "import": tids = {bd.target for bd in binds if bd.target} targets_by_scope.setdefault(scope.qualname, set()).update(tids) module = b.module module_imports = { n: bs for n, bs in module.bindings.items() if any(x.kind in ("import", "importfrom") for x in bs) } module_dup = { n for n, bs in module.bindings.items() if any(x.kind in ("import", "importfrom") for x in bs) and any(x.kind not in ("import", "importfrom") for x in bs) } ambiguous: dict[str, set[str]] = {} def walk_scopes(scope: Scope): for n, bs in scope.bindings.items(): if any(x.kind in ("import", "importfrom") for x in bs) and any( x.kind not in ("import", "importfrom") for x in bs ): ambiguous.setdefault(scope.qualname, set()).add(n) walk_scopes(module) return { "unresolved": unresolved, "targets_by_scope": targets_by_scope, "target_by_use": target_by_use, "module_import_targets": { n: {x.target for x in bs if x.target} for n, bs in module_imports.items() }, "module_dup": module_dup, "ambiguous": ambiguous, } def _git_show(ref: str, path: str) -> str | None: try: return subprocess.run( ["git", "show", f"{ref}:{path}"], capture_output = True, text = True, check = True ).stdout except subprocess.CalledProcessError: return None def _is_package_init(path: str) -> bool: """True for a package __init__.py, where __all__ means "public re-export".""" return PurePosixPath(str(path).replace("\\", "/")).name == "__init__.py" def _from_parts(target: str) -> tuple[str, str] | None: """("module", "name") for a `from:module:name` target id, else None.""" if not target.startswith("from:"): return None mod, _, name = target[len("from:") :].rpartition(":") return (mod, name) if mod and name else None def _is_definition_move(lost: set[str], gained: set[str], moved) -> bool: """Every lost `from:old:N` pairs with one gained `from:new:N` that `moved` confirms. A function-local `from old import N` re-pointed to `from new import N` after N itself moved to `new` binds the same object; the module-level relocation check above cannot see that because neither import is at module level. """ if moved is None or not lost or len(lost) != len(gained): return False gained_by_name: dict[str, list[str]] = {} for target in gained: parts = _from_parts(target) if parts is None: return False gained_by_name.setdefault(parts[1], []).append(target) used: set[str] = set() for target in lost: parts = _from_parts(target) candidates = gained_by_name.get(parts[1], []) if parts else [] if len(candidates) != 1 or candidates[0] in used or not moved(target, candidates[0]): return False used.add(candidates[0]) # One-to-one: a gained target no lost one paired with is an unexplained re-point. return used == set(gained) def compare( before_src: str, after_src: str, path: str, moved = None, ) -> list[tuple[str, str]]: """Return list of (severity, message). severity in BLOCKER/WARN/INFO. Blocker signals (precise, no relocation false-positives): UNRESOLVED-NEW - a load became undefined (dangling alias / removed import). NEW-UNUSED-HOIST - a module-level import added by this change is resolved by NO load (un-normalized alias or wrong rename target). TARGET-CHANGED - same (scope, name) load resolves to a different import target before vs after (a same-name re-point). """ a = _analyze(before_src) b = _analyze(after_src) findings: list[tuple[str, str]] = [] after_exported = _dunder_all_names(after_src) def used_targets(analysis) -> set[str]: out: set[str] = set() for tids in analysis["targets_by_scope"].values(): out |= tids return out before_used = used_targets(a) after_used = used_targets(b) before_module_targets: set[str] = set() for tids in a["module_import_targets"].values(): before_module_targets |= tids after_module_targets: set[str] = set() for tids in b["module_import_targets"].values(): after_module_targets |= tids added_module_targets = after_module_targets - before_module_targets for scope, names in b["unresolved"].items(): new = names - a["unresolved"].get(scope, set()) for n in sorted(new): findings.append( ( "BLOCKER", f"{path}: UNRESOLVED-NEW '{n}' in scope {scope} " f"(undefined after change -> dangling alias / removed import)", ) ) # Module-level import no load resolves to, that is new or was used before. for n, tids in b["module_import_targets"].items(): if tids & after_used: continue # __future__ imports are compiler directives and never loaded. if all(t.startswith("from:__future__:") for t in tids): continue # __all__ re-exports are exempt only in package __init__.py; broader would hide rename clashes. if n in after_exported and _is_package_init(path): continue newly_added = bool(tids - before_module_targets) was_used_before = bool(tids & before_used) if newly_added or was_used_before: why = ( "added but unused" if newly_added else "was used before, now unused (references re-pointed)" ) findings.append( ( "BLOCKER", f"{path}: HOISTED-IMPORT-UNUSED '{n}' ({sorted(tids)}) " f"{why} -> un-normalized alias or wrong rename target?", ) ) # Only a swap is dangerous; superset growth and an in-diff relocation are benign. removed_module_targets = before_module_targets - after_module_targets for key, tafter in b["target_by_use"].items(): tbefore = a["target_by_use"].get(key) if tbefore and tbefore != tafter and (tbefore - tafter): lost = tbefore - tafter gained = tafter - tbefore relocated = lost <= removed_module_targets and gained <= added_module_targets if relocated: continue if _is_definition_move(lost, gained, moved): findings.append( ( "INFO", f"{path}: MOVED name '{key[1]}' in {key[0]} " f"{sorted(tbefore)} -> {sorted(tafter)} (definition moved to the new module)", ) ) continue findings.append( ( "BLOCKER", f"{path}: TARGET-CHANGED name '{key[1]}' in {key[0]} " f"{sorted(tbefore)} -> {sorted(tafter)} (rename re-points module)", ) ) for n in sorted(b["module_dup"] - a["module_dup"]): findings.append( ( "WARN", f"{path}: MODULE-DUP-IMPORT '{n}' bound by import AND non-import " f"at module level (possible clash)", ) ) for scope, names in b["ambiguous"].items(): new = names - a["ambiguous"].get(scope, set()) for n in sorted(new): findings.append(("WARN", f"{path}: AMBIGUOUS-BIND '{n}' import+non-import in {scope}")) # Informational: remaining cases are relocated code. for scope, tbefore in a["targets_by_scope"].items(): tafter = b["targets_by_scope"].get(scope, set()) for t in sorted(tbefore - tafter): relocated = ( "" if t in added_module_targets else " [target not re-added here -> likely relocated/deleted]" ) findings.append(("INFO", f"{path}: TARGET-MISSING {t} in scope {scope}{relocated}")) return findings # ---------------------------------------------------------------- self-test _SELF_TESTS = { "dangling_alias": ( "import os\ndef f():\n import glob as _b\n return _b.glob('*')\n", "import os\nimport glob\ndef f():\n return _b.glob('*')\n", "BLOCKER", ), "rename_clash": ( "import re as _b\nb = 123\ndef f():\n return _b.compile('x'), b\n", "import re\nb = 123\ndef f():\n return b.compile('x'), b\n", "BLOCKER", ), "clean_rename": ( "def f():\n import glob as _g\n return _g.glob('*')\n", "import glob\ndef f():\n return glob.glob('*')\n", None, ), "clean_dedup_redundant": ( "import sys\ndef f():\n import sys\n return sys.argv\n", "import sys\ndef f():\n return sys.argv\n", None, ), "from_import_dangling": ( "def f():\n from importlib.metadata import version as _v\n return _v('x')\n", "from importlib.metadata import version\ndef f():\n return _v('x')\n", "BLOCKER", ), "local_var_clash": ( "def f(b):\n import re as _b\n return _b.compile(b)\n", "import re\ndef f(b):\n return b.compile(b)\n", "BLOCKER", ), "substring_safe": ( "def f(config):\n" " import copy as _copy\n" " config_copy = _copy.deepcopy(config)\n" " return config_copy\n", "import copy\n" "def f(config):\n" " config_copy = copy.deepcopy(config)\n" " return config_copy\n", None, ), "attr_access_not_a_use": ( "import os\ndef f(x):\n import sys as _b\n return x._b + _b.argv[0]\n", "import os\nimport sys\ndef f(x):\n return x._b + sys.argv[0]\n", None, ), "reexport_in_package_init_is_allowed": ( 'from .a import A\n__all__ = ["A"]\n', 'from .a import A\nfrom .b import B\n__all__ = ["A", "B"]\n', None, "pkg/__init__.py", ), "reexport_in_ordinary_module_is_still_blocked": ( 'from .a import A\n__all__ = ["A"]\n', 'from .a import A\nfrom .b import B\n__all__ = ["A", "B"]\n', "BLOCKER", "pkg/helpers.py", ), "unexported_new_import_in_init_is_still_blocked": ( 'from .a import A\n__all__ = ["A"]\n', 'from .a import A\nfrom .b import B\n__all__ = ["A"]\n', "BLOCKER", "pkg/__init__.py", ), "forward_ref_string_annotation_counts_as_a_use": ( "from typing import TYPE_CHECKING, Optional\ndef f(x) -> Optional[int]:\n return x\n", "from typing import TYPE_CHECKING, Optional\n" "if TYPE_CHECKING:\n" " from .m import T\n" 'def f(x) -> Optional["T"]:\n' " return x\n", None, ), "nested_forward_ref_counts_as_a_use": ( "from typing import TYPE_CHECKING, Optional\ndef f(x) -> Optional[int]:\n return x\n", "from typing import TYPE_CHECKING, Optional\n" "if TYPE_CHECKING:\n" " from .m import T\n" "def f(x) -> Optional[\"Optional['T']\"]:\n" " return x\n", None, ), "a_subscript_key_on_the_left_hand_side_is_a_use": ( "app = {}\n", "from .deps import dependency\napp = {}\napp[dependency] = 1\n", None, ), "a_subscripted_object_on_the_left_hand_side_is_a_use": ( "def f(k, v):\n return k, v\n", "from .deps import registry\ndef f(k, v):\n registry[k] = v\n", None, ), "an_attribute_target_loads_the_object": ( "def f(v):\n return v\n", "from .deps import settings\ndef f(v):\n settings.value = v\n", None, ), "a_literal_value_is_not_a_use_of_that_name": ( "from typing import TYPE_CHECKING, Literal\ndef f(x) -> Literal['a']:\n return x\n", "from typing import TYPE_CHECKING, Literal\n" "if TYPE_CHECKING:\n" " from .m import T\n" "def f(x) -> Literal['T']:\n" " return x\n", "BLOCKER", ), "unparseable_annotation_string_is_ignored": ( "from typing import Annotated\ndef f(x: Annotated[int, 'ok']) -> int:\n return x\n", "from typing import Annotated\n" "def f(x: Annotated[int, 'not a type at all']) -> int:\n" " return x\n", None, ), # A function-local import re-pointed after the definition itself moved binds the same object. "local_import_follows_a_moved_definition": ( "def f():\n from pkg.old import helper\n return helper()\n", "def f():\n from pkg.new import helper\n return helper()\n", None, "", lambda old, new: (old, new) == ("from:pkg.old:helper", "from:pkg.new:helper"), ), # The same re-point when the definition did not move is still a clash. "local_import_re_pointed_without_a_move_is_blocked": ( "def f():\n from pkg.old import helper\n return helper()\n", "def f():\n from pkg.new import helper\n return helper()\n", "BLOCKER", "", lambda old, new: False, ), # A rename (helper -> other_helper's module binding under a new name) is never a move. "local_import_renamed_across_modules_is_blocked": ( "def f():\n from pkg.old import helper\n return helper()\n", "def f():\n from pkg.new import other as helper\n return helper()\n", "BLOCKER", "", lambda old, new: True, ), "two_lost_imports_cannot_share_one_gained_target": ( "def f(x):\n if x:\n from pkg.old1 import helper\n else:\n from pkg.old2 import helper\n return helper()\n", "def f(x):\n if x:\n from pkg.shared import helper\n else:\n from pkg.other import other as helper\n return helper()\n", "BLOCKER", "", lambda old, new: new == "from:pkg.shared:helper", ), } def _self_test() -> int: ok = True for name, case in _SELF_TESTS.items(): before, after, expect = case[0], case[1], case[2] path = case[3] if len(case) > 3 else f"<{name}>" moved = case[4] if len(case) > 4 else None findings = compare(before, after, path, moved = moved) blockers = [m for sev, m in findings if sev == "BLOCKER"] got = "BLOCKER" if blockers else None passed = got == expect ok = ok and passed print(f"[{'PASS' if passed else 'FAIL'}] {name}: expect={expect} got={got}") for sev, m in findings: print(f" ({sev}) {m}") print("\nSELF-TEST:", "ALL PASS" if ok else "FAILURES") return 0 if ok else 1 def _pyflakes_undefined(path: str) -> set[str] | None: """Return the set of names pyflakes reports as 'undefined name' for `path`, or None if pyflakes failed to run/parse the file.""" try: proc = subprocess.run( [sys.executable, "-m", "pyflakes", path], capture_output = True, text = True ) except Exception: return None if "syntax error" in (proc.stdout + proc.stderr).lower(): return None names = set() for line in proc.stdout.splitlines(): m = _re_mod.search(r"undefined name '([^']+)'", line) if m: names.add(m.group(1)) return names def audit_files(paths: list[str]) -> int: """Single-version robustness audit: confirm the analyzer doesn't crash, then cross-check its 'unresolved' names against pyflakes. A name the resolver flags that pyflakes accepts is a tool false positive.""" n_files = n_err = n_fp = n_syntax = 0 fp_detail: dict[str, set[str]] = {} err_detail: dict[str, str] = {} for path in paths: n_files += 1 try: src = open(path, encoding = "utf-8").read() except Exception as e: n_err += 1 err_detail[path] = f"read: {e}" continue try: res = _analyze(src) except SyntaxError: n_syntax += 1 continue except Exception as e: n_err += 1 err_detail[path] = f"{type(e).__name__}: {e}" continue tool_unresolved = set() for names in res["unresolved"].values(): tool_unresolved |= names if not tool_unresolved: continue pf = _pyflakes_undefined(path) if pf is None: continue false_pos = tool_unresolved - pf if false_pos: n_fp += 1 fp_detail[path] = false_pos print(f"audited files : {n_files}") print(f"syntax-skipped : {n_syntax}") print(f"analyzer errors : {n_err}") for p, e in sorted(err_detail.items()): print(f" ERROR {p}: {e}") print(f"false-positive files: {n_fp} (resolver flagged a name pyflakes accepts)") for p, names in sorted(fp_detail.items()): print(f" FP {p}: {sorted(names)}") ok = n_err == 0 and n_fp == 0 print( "\nAUDIT:", "ROBUST (no crashes, no false positives vs pyflakes)" if ok else "NEEDS WORK (see above)", ) return 0 if ok else 1 def _dunder_all_names(src: str) -> set[str]: """Names a module publishes via __all__, i.e. deliberate re-exports.""" out: set[str] = set() try: tree = ast.parse(src) except SyntaxError: return out for node in tree.body: targets = [] if isinstance(node, ast.Assign): targets = node.targets elif isinstance(node, ast.AnnAssign): targets = [node.target] elif isinstance(node, ast.AugAssign): targets = [node.target] if not any(isinstance(t, ast.Name) and t.id == "__all__" for t in targets): continue value = getattr(node, "value", None) if isinstance(value, (ast.List, ast.Tuple, ast.Set)): for elt in value.elts: if isinstance(elt, ast.Constant) and isinstance(elt.value, str): out.add(elt.value) return out def _top_level_binding(src: str | None, name: str): """How a module binds `name` at top level: "def", ("import", module, original_name), None, or "ambiguous" when it is bound more than once (a later binding can shadow the definition) or by a plain `import`.""" if src is None: return None try: tree = ast.parse(src) except SyntaxError: return None found = [] def targets(node) -> list[str]: if isinstance(node, ast.Name): return [node.id] if isinstance(node, (ast.Tuple, ast.List)): return [n for elt in node.elts for n in targets(elt)] if isinstance(node, ast.Starred): return targets(node.value) return [] def visit(body) -> None: for node in body: if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): if node.name == name: found.append("def") continue if isinstance(node, ast.Assign): found.extend("def" for t in node.targets if name in targets(t)) elif isinstance(node, ast.AnnAssign): # A bare annotation (`helper: int`) declares without binding. if node.value is not None and name in targets(node.target): found.append("def") elif isinstance(node, ast.AugAssign): if name in targets(node.target): found.append("ambiguous") elif isinstance(node, ast.Delete): if any(name in targets(t) for t in node.targets): found.append("ambiguous") elif isinstance(node, ast.ImportFrom): for alias in node.names: # `import *` may rebind any name; its exports are not resolved here. if alias.name == "*": found.append("ambiguous") elif (alias.asname or alias.name) == name: found.append( ("import", "." * (node.level or 0) + (node.module or ""), alias.name) ) elif isinstance(node, ast.Import): found.extend( "ambiguous" for a in node.names if (a.asname or a.name.split(".")[0]) == name ) elif isinstance(node, (ast.For, ast.AsyncFor, ast.With, ast.AsyncWith)): for item in getattr(node, "items", []): if item.optional_vars is not None and name in targets(item.optional_vars): found.append("ambiguous") if isinstance(node, (ast.For, ast.AsyncFor)) and name in targets(node.target): found.append("ambiguous") # Conditional and guarded blocks (`if TYPE_CHECKING:`, `try:`) may not bind at runtime. try_like = isinstance(node, ast.Try) or isinstance(node, getattr(ast, "TryStar", ())) if try_like or isinstance( node, (ast.If, ast.For, ast.AsyncFor, ast.While, ast.With, ast.AsyncWith) ): for field in ("body", "orelse", "finalbody"): guarded(getattr(node, field, []) or []) if try_like: for handler in node.handlers: guarded(handler.body) if isinstance(node, getattr(ast, "Match", ())): for case in node.cases: guarded(case.body) def guarded(body) -> None: start = len(found) visit(body) found[start:] = ["ambiguous"] * (len(found) - start) visit(tree.body) if not found: return None # The scan above classifies the common shapes; any second binding site, in whatever form, makes it ambiguous. if len(found) > 1 or _module_binding_sites(tree, name) > 1: return "ambiguous" return found[0] def _module_binding_sites(tree: ast.Module, name: str) -> int: """Every place that binds or unbinds `name` in module scope: assignment / del / for / with / walrus targets, `except ... as`, match captures, imports (and `import *`), def / class, plus `global name` anywhere.""" count = 0 def visit(node) -> None: nonlocal count for child in ast.iter_child_nodes(node): if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): count += child.name == name # Decorators, defaults, bases and annotations run in module scope; the body does not. for sub in ast.iter_child_nodes(child): if sub not in child.body: visit_one(sub) count += sum(isinstance(n, ast.Global) and name in n.names for n in ast.walk(child)) continue if isinstance(child, ast.Lambda): # Defaults are evaluated where the lambda is; only its body is a new scope. visit_one(child.args) continue visit_one(child) def visit_one(node) -> None: nonlocal count if ( isinstance(node, ast.Name) and node.id == name and isinstance(node.ctx, (ast.Store, ast.Del)) ): count += 1 elif isinstance(node, ast.ExceptHandler) and node.name == name: count += 1 elif isinstance(node, (ast.ImportFrom, ast.Import)): for alias in node.names: bound = alias.asname or ( alias.name if isinstance(node, ast.ImportFrom) else alias.name.split(".")[0] ) count += alias.name == "*" or bound == name elif ( type(node).__name__ in ("MatchAs", "MatchStar") and getattr(node, "name", None) == name ): count += 1 elif type(node).__name__ == "MatchMapping" and getattr(node, "rest", None) == name: count += 1 visit(node) visit(tree) return count def _module_paths(module: str, importer: str) -> list[str]: """Candidate repo paths for `module` as imported from the file `importer`.""" importer_dir = PurePosixPath(str(importer).replace("\\", "/")).parent if module.startswith("."): level = len(module) - len(module.lstrip(".")) base = importer_dir for _ in range(level - 1): base = base.parent # The repo root is not a package: a relative import that reaches it (or climbs past it) cannot resolve. if str(base) in ("", "."): return [] rest = module.lstrip(".") stem = base / rest.replace(".", "/") if rest else base return [f"{stem}.py", f"{stem}/__init__.py"] rel = module.replace(".", "/") roots = [importer_dir, *importer_dir.parents] out = [] for root in roots: prefix = "" if str(root) in ("", ".") else f"{root}/" out += [f"{prefix}{rel}.py", f"{prefix}{rel}/__init__.py"] return out def _git_moved(before_ref: str, after_ref: str, importer: str): """moved(old_target, new_target): the name's definition left `old` for `new` in this diff.""" cache: dict[tuple[str, str], str | None] = {} def show(ref: str, path: str) -> str | None: if (ref, path) not in cache: cache[(ref, path)] = _git_show(ref, path) return cache[(ref, path)] def locate(module: str, ref: str, near: str) -> str | None: # Only a unique match: which root an absolute import reaches depends on sys.path, not on nearness. found = [p for p in _module_paths(module, near) if show(ref, p) is not None] return found[0] if len(found) == 1 else None def origin( ref: str, module: str, name: str, near: str, hops: int = 0, ): """(path, name) of the definition `from module import name` reaches at `ref`, following re-exports.""" path = locate(module, ref, near) binding = _top_level_binding(show(ref, path), name) if path else None if binding == "def": return (path, name) if isinstance(binding, tuple) and hops < 8: return origin(ref, binding[1], binding[2], path, hops + 1) return None def moved(old_target: str, new_target: str) -> bool: old, new = _from_parts(old_target), _from_parts(new_target) if old is None or new is None or old[1] != new[1]: return False name = old[1] # Both imports reach one definition (the old module and the new one re-export it). before_origin = origin(before_ref, old[0], name, importer) if before_origin is not None and before_origin == origin(after_ref, new[0], name, importer): return True old_path = locate(old[0], before_ref, importer) new_path = locate(new[0], after_ref, importer) if not old_path or not new_path or old_path == new_path: return False # Defined in the new module now, and was defined in the old one before. if _top_level_binding(show(after_ref, new_path), name) != "def": return False if _top_level_binding(show(before_ref, old_path), name) != "def": return False # Arrived with this change: a destination that already had its own `name` is a different object. if _top_level_binding(show(before_ref, new_path), name) is not None: return False # The old module no longer defines it; at most it re-imports it from the new module. left = _top_level_binding(show(after_ref, old_path), name) if left is None: return True if not isinstance(left, tuple): return False _, module, original = left return original == name and locate(module, after_ref, old_path) == new_path return moved def main() -> int: ap = argparse.ArgumentParser() ap.add_argument("--before", default = "origin/main") ap.add_argument("--after", default = "HEAD") ap.add_argument("--self-test", action = "store_true") ap.add_argument( "--audit", action = "store_true", help = "single-version robustness audit on filesystem paths", ) ap.add_argument("files", nargs = "*") args = ap.parse_args() if args.self_test: return _self_test() if args.audit: return audit_files(args.files) any_blocker = False for path in args.files: before = _git_show(args.before, path) after = _git_show(args.after, path) if after is None: print(f"SKIP {path}: not found at {args.after}") continue if before is None: before = "" findings = compare(before, after, path, moved = _git_moved(args.before, args.after, path)) blockers = [f for f in findings if f[0] == "BLOCKER"] warns = [f for f in findings if f[0] == "WARN"] infos = [f for f in findings if f[0] == "INFO"] status = "CLEAN" if not blockers and not warns else ("BLOCKERS" if blockers else "WARNINGS") print(f"\n=== {path}: {status} ===") for sev, m in blockers + warns + infos: print(f" [{sev}] {m}") any_blocker = any_blocker or bool(blockers) print("\nOVERALL:", "FAIL (blockers found)" if any_blocker else "PASS (no blockers)") return 1 if any_blocker else 0 if __name__ == "__main__": sys.exit(main())