Skip to content

Commit f85b8a2

Browse files
committed
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
1 parent dc3d05e commit f85b8a2

9 files changed

Lines changed: 271 additions & 0 deletions

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
class Foo:
2+
def method(self) -> str:
3+
return "foo"
4+
5+
6+
class Bar:
7+
def method(self) -> str:
8+
return "bar"
9+
10+
11+
class Holder:
12+
_b: Foo | Bar
13+
14+
def use(self) -> str:
15+
return self._b.method()
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
from typing import Union
2+
3+
4+
class Foo:
5+
def method(self) -> str:
6+
return "foo"
7+
8+
9+
class Bar:
10+
def method(self) -> str:
11+
return "bar"
12+
13+
14+
class Holder:
15+
_b: Union[Foo, Bar] # noqa: UP007 - intentional pre-PEP 604 syntax test
16+
17+
def use(self) -> str:
18+
return self._b.method()
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
class Foo:
2+
def method(self) -> str:
3+
return "foo"
4+
5+
6+
class Bar:
7+
def method(self) -> str:
8+
return "bar"
9+
10+
11+
class Facade:
12+
def __init__(self, x: bool) -> None:
13+
if x:
14+
self._b: Foo = Foo()
15+
else:
16+
self._b: Bar = Bar()
17+
18+
def use(self) -> str:
19+
return self._b.method()
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
class Foo:
2+
def method(self) -> str:
3+
return "foo"
4+
5+
6+
class Bar:
7+
def method(self) -> str:
8+
return "bar"
9+
10+
11+
class Facade:
12+
def __init__(self, x: bool) -> None:
13+
if x:
14+
self._b = Foo()
15+
else:
16+
self._b = Bar()
17+
18+
def use(self) -> str:
19+
return self._b.method()
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
class Base:
2+
def run(self) -> str:
3+
return "base"
4+
5+
6+
class Foo(Base):
7+
def run(self) -> str:
8+
return "foo"
9+
10+
11+
class Bar(Base):
12+
def run(self) -> str:
13+
return "bar"
14+
15+
16+
class Facade:
17+
def __init__(self, flag: bool) -> None:
18+
if flag:
19+
self._b: Base = Foo()
20+
else:
21+
self._b: Base = Bar()
22+
23+
def use(self) -> str:
24+
return self._b.run()
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
class C:
2+
def use(self) -> None:
3+
# No declaration of self._missing anywhere — must not crash and
4+
# must not produce a CALLS edge to a phantom symbol.
5+
return self._missing.method()
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
class Service:
2+
def run(self) -> str:
3+
return "ok"
4+
5+
6+
class Handler:
7+
def __init__(self) -> None:
8+
self._svc: Service = Service()
9+
10+
def go(self) -> str:
11+
return self._svc.run()
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
class Foo:
2+
def method(self) -> str:
3+
return "foo"
4+
5+
6+
class C:
7+
def __init__(self) -> None:
8+
# Walrus is intentionally unsupported in R3 — must not crash.
9+
if (x := Foo()):
10+
self._b = x
11+
12+
def use(self) -> str:
13+
return self._b.method()

tests/test_resolve_r3.py

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
"""Tests for resolver R3: conditional / union self-attribute binding.
2+
3+
R3 covers the backend-facade pattern where ``self.X`` is assigned in
4+
multiple branches of ``__init__`` (different classes), or annotated with
5+
a union type at the class level. The resolver must emit one CALLS edge
6+
per concrete type so dead-code analysis can see all reachable
7+
implementations.
8+
"""
9+
from __future__ import annotations
10+
11+
import shutil
12+
from pathlib import Path
13+
14+
import pytest
15+
16+
from codegraph.graph.builder import GraphBuilder
17+
from codegraph.graph.schema import EdgeKind, NodeKind
18+
from codegraph.graph.store_sqlite import SQLiteGraphStore
19+
20+
FIXTURES = Path(__file__).parent / "fixtures" / "resolver_r3"
21+
22+
23+
def _build(tmp_path: Path, fixture_name: str) -> SQLiteGraphStore:
24+
repo = tmp_path / "repo"
25+
repo.mkdir()
26+
shutil.copy(FIXTURES / fixture_name, repo / fixture_name)
27+
store = SQLiteGraphStore(tmp_path / "graph.db")
28+
GraphBuilder(repo, store).build(incremental=False)
29+
return store
30+
31+
32+
def _find_one(store: SQLiteGraphStore, *, kind: NodeKind, suffix: str):
33+
nodes = [
34+
n for n in store.iter_nodes(kind=kind) if n.qualname.endswith(suffix)
35+
]
36+
assert len(nodes) == 1, (
37+
f"expected one {kind.value} ending with {suffix!r}, got "
38+
f"{[n.qualname for n in nodes]}"
39+
)
40+
return nodes[0]
41+
42+
43+
def _calls_to(store: SQLiteGraphStore, dst_id: str) -> list:
44+
return [
45+
e for e in store.iter_edges(kind=EdgeKind.CALLS) if e.dst == dst_id
46+
]
47+
48+
49+
def test_r3_if_else_two_annotated_types(tmp_path: Path) -> None:
50+
"""Both branches' methods must receive a CALLS edge from ``use``."""
51+
store = _build(tmp_path, "if_else_annotated.py")
52+
foo_method = _find_one(store, kind=NodeKind.METHOD, suffix=".Foo.method")
53+
bar_method = _find_one(store, kind=NodeKind.METHOD, suffix=".Bar.method")
54+
use = _find_one(store, kind=NodeKind.METHOD, suffix=".Facade.use")
55+
foo_calls = {e.src for e in _calls_to(store, foo_method.id)}
56+
bar_calls = {e.src for e in _calls_to(store, bar_method.id)}
57+
assert use.id in foo_calls, "expected CALLS Facade.use -> Foo.method"
58+
assert use.id in bar_calls, "expected CALLS Facade.use -> Bar.method"
59+
60+
61+
def test_r3_if_else_same_annotation(tmp_path: Path) -> None:
62+
"""Same annotation in both branches — annotation wins (one type)."""
63+
store = _build(tmp_path, "if_else_same_anno.py")
64+
base_run = _find_one(store, kind=NodeKind.METHOD, suffix=".Base.run")
65+
use = _find_one(store, kind=NodeKind.METHOD, suffix=".Facade.use")
66+
base_calls = {e.src for e in _calls_to(store, base_run.id)}
67+
assert use.id in base_calls, (
68+
"annotation 'Base' should resolve to Base.run regardless of "
69+
"constructor on RHS"
70+
)
71+
72+
73+
def test_r3_class_union_pipe(tmp_path: Path) -> None:
74+
"""``_b: Foo | Bar`` should bind ``self._b.method`` to both targets."""
75+
store = _build(tmp_path, "class_union_pipe.py")
76+
foo_method = _find_one(store, kind=NodeKind.METHOD, suffix=".Foo.method")
77+
bar_method = _find_one(store, kind=NodeKind.METHOD, suffix=".Bar.method")
78+
use = _find_one(store, kind=NodeKind.METHOD, suffix=".Holder.use")
79+
foo_calls = {e.src for e in _calls_to(store, foo_method.id)}
80+
bar_calls = {e.src for e in _calls_to(store, bar_method.id)}
81+
assert use.id in foo_calls
82+
assert use.id in bar_calls
83+
84+
85+
def test_r3_class_union_typing(tmp_path: Path) -> None:
86+
"""``_b: Union[Foo, Bar]`` syntax should bind both."""
87+
store = _build(tmp_path, "class_union_typing.py")
88+
foo_method = _find_one(store, kind=NodeKind.METHOD, suffix=".Foo.method")
89+
bar_method = _find_one(store, kind=NodeKind.METHOD, suffix=".Bar.method")
90+
use = _find_one(store, kind=NodeKind.METHOD, suffix=".Holder.use")
91+
foo_calls = {e.src for e in _calls_to(store, foo_method.id)}
92+
bar_calls = {e.src for e in _calls_to(store, bar_method.id)}
93+
assert use.id in foo_calls
94+
assert use.id in bar_calls
95+
96+
97+
def test_r3_if_else_no_annotation_uses_constructor(tmp_path: Path) -> None:
98+
"""No annotation: fall back to RHS constructor names from both branches."""
99+
store = _build(tmp_path, "if_else_no_anno.py")
100+
foo_method = _find_one(store, kind=NodeKind.METHOD, suffix=".Foo.method")
101+
bar_method = _find_one(store, kind=NodeKind.METHOD, suffix=".Bar.method")
102+
use = _find_one(store, kind=NodeKind.METHOD, suffix=".Facade.use")
103+
foo_calls = {e.src for e in _calls_to(store, foo_method.id)}
104+
bar_calls = {e.src for e in _calls_to(store, bar_method.id)}
105+
assert use.id in foo_calls
106+
assert use.id in bar_calls
107+
108+
109+
def test_r3_single_branch_init_regression(tmp_path: Path) -> None:
110+
"""Single ``self._svc: Service = Service()`` still resolves (R2 regress).
111+
"""
112+
store = _build(tmp_path, "single_branch.py")
113+
run = _find_one(store, kind=NodeKind.METHOD, suffix=".Service.run")
114+
go = _find_one(store, kind=NodeKind.METHOD, suffix=".Handler.go")
115+
incoming = _calls_to(store, run.id)
116+
srcs = {e.src for e in incoming}
117+
assert go.id in srcs
118+
119+
120+
def test_r3_walrus_does_not_crash(tmp_path: Path) -> None:
121+
"""Walrus operator in __init__ is silently skipped (no crash)."""
122+
store = _build(tmp_path, "walrus.py")
123+
# Must successfully build a graph; presence of nodes is enough.
124+
nodes = list(store.iter_nodes(kind=NodeKind.CLASS))
125+
names = {n.name for n in nodes}
126+
assert "C" in names and "Foo" in names
127+
128+
129+
def test_r3_missing_attr_no_phantom_edge(tmp_path: Path) -> None:
130+
"""Calling a method on an undeclared self attribute emits no edge."""
131+
store = _build(tmp_path, "missing_attr.py")
132+
# No CALLS edge should resolve to a phantom 'method' target — there
133+
# is no Foo.method definition in this fixture, so any ``method`` edge
134+
# must remain unresolved or be absent.
135+
resolved_calls = [
136+
e for e in store.iter_edges(kind=EdgeKind.CALLS)
137+
if not e.dst.startswith("unresolved::")
138+
and "method" in (e.metadata.get("target_name") or "")
139+
]
140+
assert resolved_calls == [], (
141+
f"unexpected resolved CALLS edges to phantom method: "
142+
f"{[(e.src, e.dst) for e in resolved_calls]}"
143+
)
144+
145+
146+
if __name__ == "__main__": # pragma: no cover
147+
pytest.main([__file__, "-v"])

0 commit comments

Comments
 (0)