From dc3d05efa27d8b86b2b09eeefb9d06a81dbe0e09 Mon Sep 17 00:00:00 2001 From: mochan Date: Mon, 27 Apr 2026 18:19:24 +0530 Subject: [PATCH 1/2] feat(resolver): handle conditional self attr + class-level union types (R3) Backend-facade pattern was opaque to the resolver: when a class declared `self._backend` via either `if/else` branches in __init__ or a class- level union annotation (`_b: Foo | Bar`), `self._backend.method()` calls had no way to bind to the concrete backend class. This change extends `_collect_class_attr_types` to: * parse class-level annotations as a list of types (BinOp `A | B` or `Union[A, B]` flatten into operands) * walk `__init__` for `self.X[: T] = Y(...)` assignments, including inside `if/else` (and try/with/for/while) branches, collecting annotation-or-constructor types from every branch The resolver now exposes a `_try_multi_self_attr` helper that emits one CALLS edge per candidate type when `self.X.tail` resolves to multiple classes. Single-type cases still go through the existing R2 path. attr_types schema migrated from dict[str, str] to dict[str, list[str]] with back-compat handling in the resolver (legacy single-string values are still accepted). --- codegraph/parsers/python.py | 255 +++++++++++++++++++++++++++++++++--- codegraph/resolve/calls.py | 195 +++++++++++++++++++++------ 2 files changed, 393 insertions(+), 57 deletions(-) diff --git a/codegraph/parsers/python.py b/codegraph/parsers/python.py index 127819c..84540b6 100644 --- a/codegraph/parsers/python.py +++ b/codegraph/parsers/python.py @@ -46,18 +46,103 @@ def _get_docstring(block_node: tree_sitter.Node, src: bytes) -> str | None: return None +def _extract_types_from_type_node( + type_node: tree_sitter.Node, src: bytes +) -> list[str]: + """Return the list of simple type names from a ``type`` AST node. + + Handles three shapes: + * single identifier / attribute -> one-element list + * binary union ``A | B | ...`` -> flattened list of operand names + * subscript ``Union[A, B]`` / ``Optional[A]`` -> list of inner names + + Anything else (string forward refs, generics like ``list[Foo]``) + returns an empty list — the resolver will simply not bind that + attribute, which is safe. + """ + # ``type`` typically has a single inner expression child; descend. + inner: tree_sitter.Node | None = None + for c in type_node.children: + if c.is_named: + inner = c + break + if inner is None: + return [] + return _flatten_type_expr(inner, src) + + +def _flatten_type_expr(node: tree_sitter.Node, src: bytes) -> list[str]: + """Recursively flatten a type expression into bare type names.""" + if node.type in ("identifier", "attribute"): + return [node_text(node, src)] + if node.type == "binary_operator": + # ``A | B`` — only honor union when the operator is ``|``. + op_is_pipe = any( + c.type == "|" for c in node.children if not c.is_named + ) + if not op_is_pipe: + return [] + out: list[str] = [] + for c in node.children: + if c.is_named: + out.extend(_flatten_type_expr(c, src)) + return out + if node.type in ("subscript", "generic_type"): + # ``Union[A, B]`` / ``Optional[A]`` — both flatten to operand list. + # Tree-sitter parses ``Union[A, B]`` as ``generic_type`` with a + # leading identifier and a ``type_parameter`` child; ``Optional[A]`` + # may be a ``subscript`` depending on grammar version. + head_node: tree_sitter.Node | None = None + if node.type == "subscript": + head_node = node.child_by_field_name("value") + else: + for c in node.children: + if c.type in ("identifier", "attribute"): + head_node = c + break + head = node_text(head_node, src) if head_node is not None else "" + head_leaf = head.rsplit(".", 1)[-1] + if head_leaf not in ("Union", "Optional"): + return [] + out2: list[str] = [] + for c in node.children: + if not c.is_named or c is head_node: + continue + if c.type == "type_parameter": + for inner_c in c.children: + if inner_c.is_named: + out2.extend(_flatten_type_expr(inner_c, src)) + else: + out2.extend(_flatten_type_expr(c, src)) + return out2 + if node.type == "type": + # Wrapping ``type`` node — descend into its named child. + for c in node.children: + if c.is_named: + return _flatten_type_expr(c, src) + return [] + return [] + + def _collect_class_attr_types( body: tree_sitter.Node, src: bytes -) -> dict[str, str]: - """Return ``{attr_name: type_qualname}`` for class-level annotations. - - Tree-sitter wraps each ``name: Type`` line as - ``expression_statement -> assignment -> identifier ":" type -> ...``. - We extract simple identifier and dotted-attribute types only; complex - generics (``list[Foo]``) and string forward refs are ignored — those - require type-system reasoning beyond the current resolver budget. +) -> dict[str, list[str]]: + """Return ``{attr_name: [type_qualname, ...]}`` for class annotations. + + Captures both class-level direct annotations (``svc: Service``, + ``svc: Foo | Bar``, ``svc: Union[Foo, Bar]``) AND attribute + assignments inside ``__init__`` (including ``if/else`` branches), so + a backend-facade pattern like:: + + def __init__(self, x): + if x: + self._b: Foo = Foo() + else: + self._b = Bar() + + yields ``{"_b": ["Foo", "Bar"]}``. """ - out: dict[str, str] = {} + out: dict[str, list[str]] = {} for stmt in body.children: if stmt.type != "expression_statement": continue @@ -73,21 +158,151 @@ def _collect_class_attr_types( type_node = c if name_node is None or type_node is None: continue - # Inner of `type` is usually a single identifier or attribute. - inner: tree_sitter.Node | None = None - for c in type_node.children: - if c.type in ("identifier", "attribute"): - inner = c - break - if inner is None: - continue attr_name = node_text(name_node, src) - type_text = node_text(inner, src) - if attr_name and type_text: - out[attr_name] = type_text + type_names = _extract_types_from_type_node(type_node, src) + if not attr_name or not type_names: + continue + existing = out.setdefault(attr_name, []) + for t in type_names: + if t not in existing: + existing.append(t) + + # Walk __init__ for ``self.X = ...`` and ``self.X: T = ...`` bindings. + for stmt in body.children: + func: tree_sitter.Node | None = None + if stmt.type == "function_definition": + func = stmt + elif stmt.type == "decorated_definition": + for c in stmt.children: + if c.type == "function_definition": + func = c + break + if func is None: + continue + name_n = func.child_by_field_name("name") + if name_n is None or node_text(name_n, src) != "__init__": + continue + init_body = func.child_by_field_name("body") + if init_body is None: + continue + _collect_self_attr_types_in_block(init_body, src, out) return out +def _collect_self_attr_types_in_block( + block: tree_sitter.Node, + src: bytes, + out: dict[str, list[str]], +) -> None: + """Walk a function body collecting ``self.X[: T] = Y(...)`` bindings. + + Recurses into ``if/else`` (and ``try/with/for/while``) branches so + both arms of a conditional contribute to the attribute's type list. + Walrus (``:=``) and dynamic ``setattr`` are deliberately ignored — + those are R4+ territory. + """ + for child in block.children: + if child.type == "expression_statement": + for assignment in child.children: + if assignment.type != "assignment": + continue + _maybe_record_self_assign(assignment, src, out) + elif child.type == "block": + # Tree-sitter wraps clause bodies in a ``block`` whose entries + # are the actual statements; recurse straight into it. + _collect_self_attr_types_in_block(child, src, out) + elif child.type in ( + "if_statement", "with_statement", "try_statement", + "for_statement", "while_statement", "elif_clause", "else_clause", + "except_clause", "finally_clause", + ): + # Recurse into all named children — this picks up the clause's + # inner ``block`` plus any sibling ``elif_clause`` / ``else_clause`` + # / ``except_clause`` chains. + for sub in child.children: + if sub.is_named: + _collect_self_attr_types_in_block(sub, src, out) + + +def _maybe_record_self_assign( + assignment: tree_sitter.Node, + src: bytes, + out: dict[str, list[str]], +) -> None: + """If ``assignment`` is ``self.X[: T] = expr``, record the type(s).""" + # Find the LHS (attribute), the optional type annotation, and RHS. + lhs: tree_sitter.Node | None = None + type_node: tree_sitter.Node | None = None + rhs: tree_sitter.Node | None = None + seen_eq = False + for c in assignment.children: + if c.type == "=": + seen_eq = True + continue + if c.type == "type": + type_node = c + continue + if not seen_eq: + if lhs is None: + lhs = c + else: + if rhs is None: + rhs = c + if lhs is None or lhs.type != "attribute": + return + obj = lhs.child_by_field_name("object") + attr = lhs.child_by_field_name("attribute") + if obj is None or attr is None: + return + if node_text(obj, src) != "self": + return + attr_name = node_text(attr, src) + if not attr_name: + return + + type_names: list[str] = [] + if type_node is not None: + type_names.extend(_extract_types_from_type_node(type_node, src)) + + # If no annotation (or annotation gave nothing useful), fall back + # to the constructor name on the RHS. + if not type_names and rhs is not None: + ctor = _ctor_name_from_expr(rhs, src) + if ctor: + type_names.append(ctor) + + if not type_names: + return + existing = out.setdefault(attr_name, []) + for t in type_names: + if t not in existing: + existing.append(t) + + +def _ctor_name_from_expr( + node: tree_sitter.Node, src: bytes +) -> str | None: + """Return the constructor name from an RHS expression like ``Foo(...)``. + + Handles ``Foo(...)``, ``mod.Foo(...)`` (returns ``Foo``), and simple + identifier references ``Foo`` (when a name is being aliased without + instantiation, we still record the type so ``self._b = some_factory`` + style does NOT match — only ``identifier`` / ``attribute`` whose leaf + looks PascalCase counts as a "type-ish" reference). + + Walrus (``named_expression``) is intentionally skipped. + """ + if node.type == "call": + func = node.child_by_field_name("function") + if func is None: + return None + text = node_text(func, src).rsplit(".", 1)[-1] + if text and text[0].isupper(): + return text + return None + return None + + # --- Argument expression simplification --------------------------------- # # Per DF0 spec: "simple" arg expressions (literals, identifiers, attributes, diff --git a/codegraph/resolve/calls.py b/codegraph/resolve/calls.py index b14c000..2d4c92b 100644 --- a/codegraph/resolve/calls.py +++ b/codegraph/resolve/calls.py @@ -105,6 +105,126 @@ def __init__(self, nodes: list[Node]) -> None: self.module_by_file[node.file] = node +def _attr_type_names( + class_node: Node, attr_head: str +) -> list[str]: + """Return the list of declared type names for ``self.``. + + Tolerates the legacy schema where ``attr_types[name]`` was a single + string rather than a list (R2). Empty list if the attribute is not + annotated. + """ + attr_types = class_node.metadata.get("attr_types") + if not isinstance(attr_types, dict): + return [] + raw = attr_types.get(attr_head) + if isinstance(raw, str): + return [raw] if raw else [] + if isinstance(raw, list): + return [t for t in raw if isinstance(t, str) and t] + return [] + + +def _resolve_self_attr_targets( + class_qual: str, + head: str, + tail: str, + index: _Index, + imports_for_module: dict[str, dict[str, str]], + src_module: Node | None, +) -> list[Node]: + """Resolve ``self..`` to one or more candidate nodes. + + Honors a list of candidate type names declared on the enclosing class + (R3 union annotation or if/else branch assignment in ``__init__``) + and returns one resolved node per type that owns ``tail``. + """ + class_nodes = index.by_qualname.get(class_qual, []) + if not class_nodes: + return [] + type_names = _attr_type_names(class_nodes[0], head) + if not type_names: + return [] + out: list[Node] = [] + seen: set[str] = set() + for type_name in type_names: + node = _resolve_typed_attr_tail( + type_name, tail, index, imports_for_module, src_module, + ) + if node is not None and node.id not in seen: + seen.add(node.id) + out.append(node) + return out + + +def _resolve_typed_attr_tail( + type_name: str, + tail: str, + index: _Index, + imports_for_module: dict[str, dict[str, str]], + src_module: Node | None, +) -> Node | None: + """Resolve ``.`` via fully-qualified, import, or tail.""" + # 1) Fully-qualified. + full = f"{type_name}.{tail}" + hit = index.by_qualname.get(full, []) + if hit: + return hit[0] + # 2) Import binding from the same module. + if src_module is not None: + bind = imports_for_module.get(src_module.id, {}) + bound = bind.get(type_name) + if bound: + hit = index.by_qualname.get(f"{bound}.{tail}", []) + if hit: + return hit[0] + # 3) Tail-match: any class whose qualname ends with ``type_name``. + for qn in index.by_qualname: + if qn == type_name or qn.endswith("." + type_name): + hit = index.by_qualname.get(f"{qn}.{tail}", []) + if hit: + return hit[0] + return None + + +def _try_multi_self_attr( + target: str, + src_node: Node | None, + edge_kind: EdgeKind, + index: _Index, + imports_for_module: dict[str, dict[str, str]], +) -> list[Node]: + """Return >=2 candidate nodes when ``self.X.tail`` has a union type. + + Returns an empty list when the target is not a self-attribute chain, + when the enclosing class doesn't declare a union for ``X``, or when + only zero/one type resolves. The single-candidate path is left to + the regular ``_resolve_target`` logic so we don't double-resolve. + """ + if edge_kind != EdgeKind.CALLS: + return [] + if src_node is None or src_node.kind != NodeKind.METHOD: + return [] + cleaned = _normalize_target(target) + if not cleaned.startswith("self."): + return [] + rest = cleaned[len("self."):] + head, _, tail = rest.partition(".") + if not tail: + return [] + parts = src_node.qualname.split(".") + if len(parts) < 2: + return [] + class_qual = ".".join(parts[:-1]) + src_module = index.module_by_file.get(src_node.file) + multi = _resolve_self_attr_targets( + class_qual, head, tail, index, imports_for_module, src_module, + ) + if len(multi) < 2: + return [] + return multi + + def _resolve_target( target: str, src_node: Node | None, @@ -144,44 +264,16 @@ def _resolve_target( # Dotted tail: try resolving via class-level type annotation # (\`name: TypeName\` in the class body). If the enclosing # class declares ``head: TypeName``, look up - # ``TypeName.`` against in-repo types. + # ``TypeName.`` against in-repo types. Multi-type + # candidates (R3 union / if-else) are returned via + # ``_resolve_self_attr_targets`` instead. if tail: - class_node = index.by_qualname.get(class_qual, []) - if class_node: - attr_types = class_node[0].metadata.get("attr_types") - if isinstance(attr_types, dict): - type_name = attr_types.get(head) - if isinstance(type_name, str) and type_name: - # 1) Try the type as a fully-qualified name. - full = f"{type_name}.{tail}" - hit = index.by_qualname.get(full, []) - if hit: - return hit[0] - # 2) Try resolving the type via an import - # binding from the same module. - if src_module is not None: - bind = imports_for_module.get( - src_module.id, {} - ) - bound = bind.get(type_name) - if bound: - hit = index.by_qualname.get( - f"{bound}.{tail}", [] - ) - if hit: - return hit[0] - # 3) Tail-match: any class whose qualname - # ends with the type name and which owns - # ``tail``. - for qn, _nodes in index.by_qualname.items(): - if qn == type_name or qn.endswith( - "." + type_name - ): - hit = index.by_qualname.get( - f"{qn}.{tail}", [] - ) - if hit: - return hit[0] + multi = _resolve_self_attr_targets( + class_qual, head, tail, index, + imports_for_module, src_module, + ) + if multi: + return multi[0] # Dotted tail: try resolving just the first segment as a # method/attribute on the enclosing class. if head != rest: @@ -322,6 +414,35 @@ def resolve_unresolved_edges(store: SQLiteGraphStore) -> ResolveStats: else _strip_unresolved(edge.dst) ) src_node = index.by_id.get(edge.src) + + # R3: ``self.X.tail`` may bind to multiple class types when the + # enclosing class declares a union annotation or assigns the + # attribute in branching ``__init__`` paths. We emit one edge per + # candidate so the dead-code analyzer can see all reachable + # implementations. + multi = _try_multi_self_attr( + target, src_node, edge.kind, index, bindings, + ) + if multi: + deletions.append((edge.src, edge.dst, edge.kind)) + for hit in multi: + if hit.id == edge.src: + continue + new_edges.append( + Edge( + src=edge.src, + dst=hit.id, + kind=edge.kind, + file=edge.file, + line=edge.line, + metadata={ + **edge.metadata, "resolved_from": edge.dst, + }, + ) + ) + stats.resolved += 1 + continue + resolved = _resolve_target(target, src_node, index, bindings) if resolved is None or resolved.id == edge.src: stats.unresolved += 1 From f85b8a21650541a7846c8a8ebef0308df1057b4c Mon Sep 17 00:00:00 2001 From: mochan Date: Mon, 27 Apr 2026 18:19:34 +0530 Subject: [PATCH 2/2] test(r3): cover if/else, union annotations, walrus + missing attr cases 8 fixture-based tests for resolver R3: * if/else with two annotated types -> both methods get edges * if/else with same annotation -> annotation wins over RHS constructor * class-level `Foo | Bar` -> both methods get edges * class-level `Union[Foo, Bar]` -> both methods get edges * if/else without annotation -> falls back to RHS constructor names * single-branch annotated assignment -> R2 regression * walrus operator -> silently skipped, no crash * method on undeclared self attr -> no phantom edge --- .../fixtures/resolver_r3/class_union_pipe.py | 15 ++ .../resolver_r3/class_union_typing.py | 18 +++ .../fixtures/resolver_r3/if_else_annotated.py | 19 +++ tests/fixtures/resolver_r3/if_else_no_anno.py | 19 +++ .../fixtures/resolver_r3/if_else_same_anno.py | 24 +++ tests/fixtures/resolver_r3/missing_attr.py | 5 + tests/fixtures/resolver_r3/single_branch.py | 11 ++ tests/fixtures/resolver_r3/walrus.py | 13 ++ tests/test_resolve_r3.py | 147 ++++++++++++++++++ 9 files changed, 271 insertions(+) create mode 100644 tests/fixtures/resolver_r3/class_union_pipe.py create mode 100644 tests/fixtures/resolver_r3/class_union_typing.py create mode 100644 tests/fixtures/resolver_r3/if_else_annotated.py create mode 100644 tests/fixtures/resolver_r3/if_else_no_anno.py create mode 100644 tests/fixtures/resolver_r3/if_else_same_anno.py create mode 100644 tests/fixtures/resolver_r3/missing_attr.py create mode 100644 tests/fixtures/resolver_r3/single_branch.py create mode 100644 tests/fixtures/resolver_r3/walrus.py create mode 100644 tests/test_resolve_r3.py diff --git a/tests/fixtures/resolver_r3/class_union_pipe.py b/tests/fixtures/resolver_r3/class_union_pipe.py new file mode 100644 index 0000000..36a5e5d --- /dev/null +++ b/tests/fixtures/resolver_r3/class_union_pipe.py @@ -0,0 +1,15 @@ +class Foo: + def method(self) -> str: + return "foo" + + +class Bar: + def method(self) -> str: + return "bar" + + +class Holder: + _b: Foo | Bar + + def use(self) -> str: + return self._b.method() diff --git a/tests/fixtures/resolver_r3/class_union_typing.py b/tests/fixtures/resolver_r3/class_union_typing.py new file mode 100644 index 0000000..06bb94d --- /dev/null +++ b/tests/fixtures/resolver_r3/class_union_typing.py @@ -0,0 +1,18 @@ +from typing import Union + + +class Foo: + def method(self) -> str: + return "foo" + + +class Bar: + def method(self) -> str: + return "bar" + + +class Holder: + _b: Union[Foo, Bar] # noqa: UP007 - intentional pre-PEP 604 syntax test + + def use(self) -> str: + return self._b.method() diff --git a/tests/fixtures/resolver_r3/if_else_annotated.py b/tests/fixtures/resolver_r3/if_else_annotated.py new file mode 100644 index 0000000..ba3f28a --- /dev/null +++ b/tests/fixtures/resolver_r3/if_else_annotated.py @@ -0,0 +1,19 @@ +class Foo: + def method(self) -> str: + return "foo" + + +class Bar: + def method(self) -> str: + return "bar" + + +class Facade: + def __init__(self, x: bool) -> None: + if x: + self._b: Foo = Foo() + else: + self._b: Bar = Bar() + + def use(self) -> str: + return self._b.method() diff --git a/tests/fixtures/resolver_r3/if_else_no_anno.py b/tests/fixtures/resolver_r3/if_else_no_anno.py new file mode 100644 index 0000000..cd90389 --- /dev/null +++ b/tests/fixtures/resolver_r3/if_else_no_anno.py @@ -0,0 +1,19 @@ +class Foo: + def method(self) -> str: + return "foo" + + +class Bar: + def method(self) -> str: + return "bar" + + +class Facade: + def __init__(self, x: bool) -> None: + if x: + self._b = Foo() + else: + self._b = Bar() + + def use(self) -> str: + return self._b.method() diff --git a/tests/fixtures/resolver_r3/if_else_same_anno.py b/tests/fixtures/resolver_r3/if_else_same_anno.py new file mode 100644 index 0000000..2e563e5 --- /dev/null +++ b/tests/fixtures/resolver_r3/if_else_same_anno.py @@ -0,0 +1,24 @@ +class Base: + def run(self) -> str: + return "base" + + +class Foo(Base): + def run(self) -> str: + return "foo" + + +class Bar(Base): + def run(self) -> str: + return "bar" + + +class Facade: + def __init__(self, flag: bool) -> None: + if flag: + self._b: Base = Foo() + else: + self._b: Base = Bar() + + def use(self) -> str: + return self._b.run() diff --git a/tests/fixtures/resolver_r3/missing_attr.py b/tests/fixtures/resolver_r3/missing_attr.py new file mode 100644 index 0000000..19fe86c --- /dev/null +++ b/tests/fixtures/resolver_r3/missing_attr.py @@ -0,0 +1,5 @@ +class C: + def use(self) -> None: + # No declaration of self._missing anywhere — must not crash and + # must not produce a CALLS edge to a phantom symbol. + return self._missing.method() diff --git a/tests/fixtures/resolver_r3/single_branch.py b/tests/fixtures/resolver_r3/single_branch.py new file mode 100644 index 0000000..d66e2fc --- /dev/null +++ b/tests/fixtures/resolver_r3/single_branch.py @@ -0,0 +1,11 @@ +class Service: + def run(self) -> str: + return "ok" + + +class Handler: + def __init__(self) -> None: + self._svc: Service = Service() + + def go(self) -> str: + return self._svc.run() diff --git a/tests/fixtures/resolver_r3/walrus.py b/tests/fixtures/resolver_r3/walrus.py new file mode 100644 index 0000000..5ca4886 --- /dev/null +++ b/tests/fixtures/resolver_r3/walrus.py @@ -0,0 +1,13 @@ +class Foo: + def method(self) -> str: + return "foo" + + +class C: + def __init__(self) -> None: + # Walrus is intentionally unsupported in R3 — must not crash. + if (x := Foo()): + self._b = x + + def use(self) -> str: + return self._b.method() diff --git a/tests/test_resolve_r3.py b/tests/test_resolve_r3.py new file mode 100644 index 0000000..de5ecb0 --- /dev/null +++ b/tests/test_resolve_r3.py @@ -0,0 +1,147 @@ +"""Tests for resolver R3: conditional / union self-attribute binding. + +R3 covers the backend-facade pattern where ``self.X`` is assigned in +multiple branches of ``__init__`` (different classes), or annotated with +a union type at the class level. The resolver must emit one CALLS edge +per concrete type so dead-code analysis can see all reachable +implementations. +""" +from __future__ import annotations + +import shutil +from pathlib import Path + +import pytest + +from codegraph.graph.builder import GraphBuilder +from codegraph.graph.schema import EdgeKind, NodeKind +from codegraph.graph.store_sqlite import SQLiteGraphStore + +FIXTURES = Path(__file__).parent / "fixtures" / "resolver_r3" + + +def _build(tmp_path: Path, fixture_name: str) -> SQLiteGraphStore: + repo = tmp_path / "repo" + repo.mkdir() + shutil.copy(FIXTURES / fixture_name, repo / fixture_name) + store = SQLiteGraphStore(tmp_path / "graph.db") + GraphBuilder(repo, store).build(incremental=False) + return store + + +def _find_one(store: SQLiteGraphStore, *, kind: NodeKind, suffix: str): + nodes = [ + n for n in store.iter_nodes(kind=kind) if n.qualname.endswith(suffix) + ] + assert len(nodes) == 1, ( + f"expected one {kind.value} ending with {suffix!r}, got " + f"{[n.qualname for n in nodes]}" + ) + return nodes[0] + + +def _calls_to(store: SQLiteGraphStore, dst_id: str) -> list: + return [ + e for e in store.iter_edges(kind=EdgeKind.CALLS) if e.dst == dst_id + ] + + +def test_r3_if_else_two_annotated_types(tmp_path: Path) -> None: + """Both branches' methods must receive a CALLS edge from ``use``.""" + store = _build(tmp_path, "if_else_annotated.py") + foo_method = _find_one(store, kind=NodeKind.METHOD, suffix=".Foo.method") + bar_method = _find_one(store, kind=NodeKind.METHOD, suffix=".Bar.method") + use = _find_one(store, kind=NodeKind.METHOD, suffix=".Facade.use") + foo_calls = {e.src for e in _calls_to(store, foo_method.id)} + bar_calls = {e.src for e in _calls_to(store, bar_method.id)} + assert use.id in foo_calls, "expected CALLS Facade.use -> Foo.method" + assert use.id in bar_calls, "expected CALLS Facade.use -> Bar.method" + + +def test_r3_if_else_same_annotation(tmp_path: Path) -> None: + """Same annotation in both branches — annotation wins (one type).""" + store = _build(tmp_path, "if_else_same_anno.py") + base_run = _find_one(store, kind=NodeKind.METHOD, suffix=".Base.run") + use = _find_one(store, kind=NodeKind.METHOD, suffix=".Facade.use") + base_calls = {e.src for e in _calls_to(store, base_run.id)} + assert use.id in base_calls, ( + "annotation 'Base' should resolve to Base.run regardless of " + "constructor on RHS" + ) + + +def test_r3_class_union_pipe(tmp_path: Path) -> None: + """``_b: Foo | Bar`` should bind ``self._b.method`` to both targets.""" + store = _build(tmp_path, "class_union_pipe.py") + foo_method = _find_one(store, kind=NodeKind.METHOD, suffix=".Foo.method") + bar_method = _find_one(store, kind=NodeKind.METHOD, suffix=".Bar.method") + use = _find_one(store, kind=NodeKind.METHOD, suffix=".Holder.use") + foo_calls = {e.src for e in _calls_to(store, foo_method.id)} + bar_calls = {e.src for e in _calls_to(store, bar_method.id)} + assert use.id in foo_calls + assert use.id in bar_calls + + +def test_r3_class_union_typing(tmp_path: Path) -> None: + """``_b: Union[Foo, Bar]`` syntax should bind both.""" + store = _build(tmp_path, "class_union_typing.py") + foo_method = _find_one(store, kind=NodeKind.METHOD, suffix=".Foo.method") + bar_method = _find_one(store, kind=NodeKind.METHOD, suffix=".Bar.method") + use = _find_one(store, kind=NodeKind.METHOD, suffix=".Holder.use") + foo_calls = {e.src for e in _calls_to(store, foo_method.id)} + bar_calls = {e.src for e in _calls_to(store, bar_method.id)} + assert use.id in foo_calls + assert use.id in bar_calls + + +def test_r3_if_else_no_annotation_uses_constructor(tmp_path: Path) -> None: + """No annotation: fall back to RHS constructor names from both branches.""" + store = _build(tmp_path, "if_else_no_anno.py") + foo_method = _find_one(store, kind=NodeKind.METHOD, suffix=".Foo.method") + bar_method = _find_one(store, kind=NodeKind.METHOD, suffix=".Bar.method") + use = _find_one(store, kind=NodeKind.METHOD, suffix=".Facade.use") + foo_calls = {e.src for e in _calls_to(store, foo_method.id)} + bar_calls = {e.src for e in _calls_to(store, bar_method.id)} + assert use.id in foo_calls + assert use.id in bar_calls + + +def test_r3_single_branch_init_regression(tmp_path: Path) -> None: + """Single ``self._svc: Service = Service()`` still resolves (R2 regress). + """ + store = _build(tmp_path, "single_branch.py") + run = _find_one(store, kind=NodeKind.METHOD, suffix=".Service.run") + go = _find_one(store, kind=NodeKind.METHOD, suffix=".Handler.go") + incoming = _calls_to(store, run.id) + srcs = {e.src for e in incoming} + assert go.id in srcs + + +def test_r3_walrus_does_not_crash(tmp_path: Path) -> None: + """Walrus operator in __init__ is silently skipped (no crash).""" + store = _build(tmp_path, "walrus.py") + # Must successfully build a graph; presence of nodes is enough. + nodes = list(store.iter_nodes(kind=NodeKind.CLASS)) + names = {n.name for n in nodes} + assert "C" in names and "Foo" in names + + +def test_r3_missing_attr_no_phantom_edge(tmp_path: Path) -> None: + """Calling a method on an undeclared self attribute emits no edge.""" + store = _build(tmp_path, "missing_attr.py") + # No CALLS edge should resolve to a phantom 'method' target — there + # is no Foo.method definition in this fixture, so any ``method`` edge + # must remain unresolved or be absent. + resolved_calls = [ + e for e in store.iter_edges(kind=EdgeKind.CALLS) + if not e.dst.startswith("unresolved::") + and "method" in (e.metadata.get("target_name") or "") + ] + assert resolved_calls == [], ( + f"unexpected resolved CALLS edges to phantom method: " + f"{[(e.src, e.dst) for e in resolved_calls]}" + ) + + +if __name__ == "__main__": # pragma: no cover + pytest.main([__file__, "-v"])