From cdeecff7b8581e496d2ecc62c6bc76b6594a5a75 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Nov 2026 19:29:16 +0000 Subject: [PATCH] Index attribute references by name in ModuleNodeIndex MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit references() walked every AST node per module per operation — O(total nodes × ops). Now ModuleNodeIndex.attributes_by_attr maps each .attr name to its ast.Attribute nodes, built once in the existing single walk. references() does a dict lookup instead of ast.walk. The dynamic access check also uses the indexed calls list instead of a full walk. On 842 modules, this turns a 14-op batch from 14 × O(nodes) walks into 14 × O(1) lookups on the same cached index. Co-Authored-By: Claude Opus 4.6 (1M context) Claude-Session: https://claude.ai/code/session_01RnH6dR7JdknxC8XJJtW8Uh --- nominal_refactor_advisor/module_node_index.py | 10 +++++++++- .../refactors/answered_method.py | 19 +++++++++++-------- 2 files changed, 20 insertions(+), 9 deletions(-) diff --git a/nominal_refactor_advisor/module_node_index.py b/nominal_refactor_advisor/module_node_index.py index cae15044..57e023b7 100644 --- a/nominal_refactor_advisor/module_node_index.py +++ b/nominal_refactor_advisor/module_node_index.py @@ -56,6 +56,7 @@ class ModuleNodeIndex(ParsedModuleFact): parents: AstParentIndex = field(repr=False) loads: Counter[str] = field(repr=False) attribute_bases: frozenset[str] + attributes_by_attr: dict[str, tuple[ast.Attribute, ...]] = field(repr=False) bound_name_counts: Counter[str] = field(repr=False) binding_sites: dict[str, tuple[ast.AST, ...]] = field(repr=False) identifier_text: str = field(repr=False) @@ -69,6 +70,7 @@ def derive(cls, parsed_module: ParsedModule) -> ModuleNodeIndex: parents = parsed_module.syntax_index.parent_index loads: Counter[str] = Counter() attribute_bases: set[str] = set() + attributes_by_attr: dict[str, list[ast.Attribute]] = {} bound: Counter[str] = Counter() package_heads: set[str] = set() sites: dict[str, list[ast.AST]] = {} @@ -116,8 +118,11 @@ def derive(cls, parsed_module: ParsedModule) -> ModuleNodeIndex: else: bound[name] += 1 sites.setdefault(name, []).append(node) - case ast.Attribute(value=ast.Name(id=name)): + case ast.Attribute(attr=attr_name, value=ast.Name(id=name)): attribute_bases.add(name) + attributes_by_attr.setdefault(attr_name, []).append(node) + case ast.Attribute(attr=attr_name) if not isinstance(node.value, ast.Name): + attributes_by_attr.setdefault(attr_name, []).append(node) bound.update(package_heads) return cls( module=parsed_module.module, @@ -128,6 +133,9 @@ def derive(cls, parsed_module: ParsedModule) -> ModuleNodeIndex: parents=parents, loads=loads, attribute_bases=frozenset(attribute_bases), + attributes_by_attr={ + name: tuple(nodes) for name, nodes in attributes_by_attr.items() + }, bound_name_counts=bound, binding_sites={name: tuple(nodes) for name, nodes in sites.items()}, identifier_text=( diff --git a/nominal_refactor_advisor/refactors/answered_method.py b/nominal_refactor_advisor/refactors/answered_method.py index a9a5b14f..1c566f4a 100644 --- a/nominal_refactor_advisor/refactors/answered_method.py +++ b/nominal_refactor_advisor/refactors/answered_method.py @@ -207,15 +207,18 @@ def references(self) -> list[TypedReceiver]: for parsed in snapshot.parsed_modules: if self.name not in parsed.source: continue - parents = parsed.fact(ModuleNodeIndex).parents - for node in ast.walk(parsed.module): - where = f"{parsed.file_path}:{getattr(node, 'lineno', 0)}" - if _dynamic_access(node, self.name): - raise ValueError(f"{where} reaches {self.name!r} by a string") - if node is self.method or not ( - isinstance(node, ast.Attribute) and node.attr == self.name - ): + index = parsed.fact(ModuleNodeIndex) + parents = index.parents + for call in index.calls: + if _dynamic_access(call, self.name): + raise ValueError( + f"{parsed.file_path}:{call.lineno} " + f"reaches {self.name!r} by a string" + ) + for node in index.attributes_by_attr.get(self.name, ()): + if node is self.method: continue + where = f"{parsed.file_path}:{node.lineno}" parent = parents.get(node) site = MethodReference( parsed.file_path, node,