Skip to content

Deepen: one binding-and-use model behind a single interface, instead of six visitors and three copies of the same knowledge #166

Open

Description

@jonbaldie

Recommendation strength: Strong

Modules

src/messpy/cli.py — _record_pattern_binding, _named_binding_targets, _FunctionLocalBindings, _ScopeBindingCollector, _ScopedNameUseVisitor, _ModuleBindingCollector, _ScopeRebindingCollector, _NamingRoleCollector, _target_names, _assigned_names, _direct_bindings.

Problem

"What does this Python construct bind, and where is that name used?" is the single most load-bearing fact in messpy, and it currently has no module. It is re-derived independently in six AST visitors plus a handful of free functions, so the knowledge has no locality: each answer is only as correct as the visitor that happens to be asking.

The duplication is verbatim. Pattern-and-handler binding is spelled three times. Once as a recorder:

def _record_pattern_binding(names: set[str], node: ast.AST) -> None:
    if isinstance(node, (ast.MatchAs, ast.MatchStar, ast.ExceptHandler)):
        if node.name is not None:
            names.add(node.name)
    elif isinstance(node, ast.MatchMapping) and node.rest is not None:
        names.add(node.rest)

Once as a naming-target collector over the same four node types:

def _named_binding_targets(tree: ast.Module) -> list[NamingTarget]:
    targets: list[NamingTarget] = []
    for node in ast.walk(tree):
        if isinstance(node, ast.ExceptHandler) and node.name is not None:
            targets.append(NamingTarget(node.name, node.lineno, "variable"))
        elif isinstance(node, (ast.MatchAs, ast.MatchStar)) and node.name is not None:
            targets.append(NamingTarget(node.name, node.lineno, "variable"))
        elif isinstance(node, ast.MatchMapping) and node.rest is not None:
            targets.append(NamingTarget(node.rest, node.lineno, "variable"))
    return targets

And once more as four visit_* methods on _FunctionLocalBindings. Target destructuring is spelled twice, identically but for the return type:

def _target_names(node: ast.AST) -> list[ast.Name]:
    if isinstance(node, ast.Name):
        return [node]
    if isinstance(node, ast.Starred):
        return _target_names(node.value)
    if isinstance(node, (ast.Tuple, ast.List)):
        return [name for element in node.elts for name in _target_names(element)]
    return []
def _assigned_names(target: ast.AST) -> list[str]:
    if isinstance(target, ast.Name):
        return [target.id]
    if isinstance(target, ast.Starred):
        return _assigned_names(target.value)
    if isinstance(target, (ast.Tuple, ast.List)):
        return [name for element in target.elts for name in _assigned_names(element)]
    return []

The cost is measurable in this repository's own history: roughly 17 of the closed bug issues are binding-, use-, or scope-resolution defects. The recurring shape is a construct handled in one visitor and missed in another, which is exactly what you would predict from three independent implementations of the same rule. Adding support for a new binding form today means finding all six places and hoping.

Proposed deepening

Extract a bindings module whose interface is small and whose implementation absorbs every construct-specific isinstance chain now scattered through cli.py. Something in the shape of:

  • bindings(tree) -> BindingTable, where a BindingTable answers "which names does this scope bind", "what kind of binding is each" (parameter, local, comprehension, handler, pattern, module-level, class field), "where is each bound", and "is this name used inside this scope".

Callers stop walking the AST for names. UnusedLocalVariable, UnusedFormalParameter, GlobalVariable, ShortVariable/LongVariable/CamelCaseVariableName, and the shadowing-sensitive rules (ExitExpression, DevelopmentCodeFragment) all consume the same table.

Deletion test: deleting this module does not move the complexity, it restores it to six places. That is the signal we want.

Benefits

Locality. New Python binding syntax is taught once. The next match-statement or comprehension-scope defect is fixed once, and the fix lands in every rule that reads names — instead of being fixed in _FunctionLocalBindings and quietly left broken in _named_binding_targets.

Leverage. Rule authors learn one interface (BindingTable) rather than choosing correctly between six near-identical visitors. A rule that needs name information goes from "write an ast.NodeVisitor" to "ask the table", so the per-rule cost of getting scoping right drops to near zero.

Test impact

Today, every one of these ~17 historical defects could only be expressed as an end-to-end CLI test: write a temp .py file containing the construct, run the whole analyzer under some ruleset that happens to include a name-sensitive rule, and string-match the rendered text report. That is why tests/test_cli.py reaches behind the seam at all — its only two non-run() tests exist precisely because this concern has no interface:

from messpy.cli import run, _direct_bindings, _is_protocol, _protocol_base_names

With a bindings module the construct-coverage matrix becomes a table test over (source snippet) -> BindingTable, asserting the bound names directly. That is still a real acceptance test in the sense CODING_STANDARDS.md requires — real source, real parse, no mocks, no injected fakes — just observed at a narrower and more honest seam. The end-to-end tests that should remain end-to-end (exit codes, report composition) are unaffected, and _direct_bindings stops being a test backdoor because it becomes part of a published interface.

Activity

  1. jonbaldie commented on Sep 20, 2026

    @jonbaldie
    ContributorAuthor

    This was generated by AI during triage.

    Agent Brief

    Category: enhancement
    Summary: Extract a unified binding and scope-use model behind a single interface

    Current behavior:
    AST construct binding and name usage are re-derived independently across six AST visitors (_FunctionLocalBindings, _ScopeBindingCollector, _ScopedNameUseVisitor, _ModuleBindingCollector, _ScopeRebindingCollector, _NamingRoleCollector) and multiple helper functions (_record_pattern_binding, _named_binding_targets, _target_names, _assigned_names, _direct_bindings). Target destructuring and pattern binding are duplicated across multiple places, leading to inconsistent scope handling and recurring bugs when new Python syntax or binding forms are encountered.

    Desired behavior:
    Introduce a cohesive binding model (e.g. BindingTable) behind a clean interface that answers for any given scope:

    • Which names are bound in the scope
    • The binding role/kind for each name (parameter, local variable, comprehension variable, exception handler name, pattern match variable, module-level binding, class field)
    • The source location where each name is bound
    • Whether a bound name is referenced or used within the scope

    Downstream rules (UnusedLocalVariable, UnusedFormalParameter, GlobalVariable, ShortVariable, LongVariable, CamelCaseVariableName, ExitExpression, DevelopmentCodeFragment) must query this unified model rather than implementing independent AST traversal logic.

    Key interfaces:

    • BindingTable: encapsulates scope bindings, binding kinds, source locations, and usage queries.
    • bindings(tree: ast.AST) / constructor function: parses and populates the BindingTable.

    Acceptance criteria:

    • A dedicated bindings module/interface unifies name-binding extraction and scope usage across function, comprehension, class, handler, and module scopes.
    • Rules relying on binding and name usage (UnusedLocalVariable, UnusedFormalParameter, GlobalVariable, naming rules, shadowing rules) consume the unified binding model instead of duplicating AST traversal.
    • Direct table-driven acceptance tests verify bound names and usage for Python constructs (match statements, comprehensions, starred assignments, exception handlers, parameters).
    • Existing rule behavior and CLI detection results remain unchanged.
    • All existing test suites pass and clean self-analysis is maintained.

    Out of scope:

    • Changing the detection criteria, thresholds, or priorities of naming or unused-code rules.
    • Introducing third-party AST or static analysis dependencies (must remain stdlib ast only).
  2. added
    ready-for-agentFully specified, ready for an AFK agent
    and removed
    needs-triageMaintainer needs to evaluate this issue
    on Sep 20, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or requestready-for-agentFully specified, ready for an AFK agent

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions