Skip to content

Commit 6d07098

Browse files
committed
feat(callgraph): whole-program type propagation in the defuse linker
Implements #150. The one-round oracle becomes a capped monotone fixpoint (progress-driven, at most 8 rounds — later rounds consume earlier resolutions' parameter votes), and three transfer families join it: - Chained return summaries: `return self.build_response(...)` resolves through the callee's own returns — memoized, cycle-safe, depth-capped. - Returned callables: `return inner` gives `f = factory(); f()` an arrow to the inner def (the closure-target class the reference comparison left open). The bare-name fan is owner-filtered in the same change: a bare name can never invoke a method at runtime, so receiverless fans list module-level functions only. - Container element types: `self.adapters[k] = adapter` is typed by the writer method's parameter votes, and a loop variable drawn from `self.adapters.items()`/`.values()` carries the element type into return summaries — closing the get_adapter/send chain. Measured on odoo-slim-19 (2,364 modules, tests included): the audited Joern residual falls 292 -> 243 (0.9%), the closure-target bucket to zero; graph size stays flat at +0.02% (fan edges replaced by precise arrows offset the new coverage). Determinism gates: requests byte-identical A/B; flask differs by the single canonical #146 edge; odoo paired samples flap 0.088%/0.118% with the downstream amplification ratio constant (~60%) — same composition, no new flap class, #146 remains the sole source. Full suite: 291 passed, 6 skipped. Linker unit tests: 25, including one asserting the full mount -> element -> loop-var -> receiver chain. Closes #150.
1 parent 2ee845b commit 6d07098

3 files changed

Lines changed: 281 additions & 19 deletions

File tree

codeanalyzer/semantic_analysis/defuse_linker.py

Lines changed: 204 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ class _Scope:
7676
__slots__ = (
7777
"parent", "funcs", "bindings", "imports", "mod_imports", "blocked",
7878
"literal_types", "instance_types", "call_assigns", "return_ctors",
79+
"return_calls", "loopvar_sources",
7980
)
8081

8182
def __init__(self, parent: Optional["_Scope"]) -> None:
@@ -100,6 +101,12 @@ def __init__(self, parent: Optional["_Scope"]) -> None:
100101
self.call_assigns: Dict[str, ast.expr] = {}
101102
# bare class names this scope's `return C(...)` statements construct
102103
self.return_ctors: set = set()
104+
# func expressions of every `return <call>(...)` in this scope, for
105+
# chained return summaries (`return self.build_response(...)`)
106+
self.return_calls: List[ast.expr] = []
107+
# loop variables drawn from a self-attribute container:
108+
# `for k, v in self.adapters.items():` -> v: ("elem", "adapters")
109+
self.loopvar_sources: Dict[str, Tuple[str, str]] = {}
103110

104111

105112
def _module_qual(file_key: str) -> str:
@@ -276,12 +283,23 @@ def _record_stmt(stmt: ast.AST, scope: _Scope) -> None:
276283
if isinstance(stmt.value.func, ast.Name):
277284
scope.instance_types[tgt.id] = stmt.value.func.id
278285
scope.call_assigns[tgt.id] = stmt.value.func
286+
elif isinstance(stmt, (ast.For, ast.AsyncFor)):
287+
attr = _self_container_of(stmt.iter)
288+
if attr is not None:
289+
targets = (
290+
stmt.target.elts if isinstance(stmt.target, ast.Tuple) else [stmt.target]
291+
)
292+
# the value position: last element of a tuple target (items()),
293+
# or the single target (values()/direct iteration)
294+
val = targets[-1]
295+
if isinstance(val, ast.Name):
296+
scope.loopvar_sources[val.id] = ("elem", attr)
279297
elif isinstance(stmt, ast.Return):
280298
if stmt.value is not None:
281-
if isinstance(stmt.value, ast.Call) and isinstance(
282-
stmt.value.func, ast.Name
283-
):
284-
scope.return_ctors.add(stmt.value.func.id)
299+
if isinstance(stmt.value, ast.Call):
300+
scope.return_calls.append(stmt.value.func)
301+
if isinstance(stmt.value.func, ast.Name):
302+
scope.return_ctors.add(stmt.value.func.id)
285303
elif isinstance(stmt.value, ast.Name):
286304
# `cj = RequestsCookieJar(); ...; return cj` — resolved when
287305
# the summary is read, against this scope's ctor-typed locals.
@@ -293,6 +311,22 @@ def _record_stmt(stmt: ast.AST, scope: _Scope) -> None:
293311
scope.blocked.add(stmt.target.id)
294312

295313

314+
def _self_container_of(expr: ast.expr) -> Optional[str]:
315+
"""``self.X`` / ``self.X.items()`` / ``self.X.values()`` -> ``"X"``."""
316+
node = expr
317+
if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute):
318+
if node.func.attr not in ("items", "values"):
319+
return None
320+
node = node.func.value
321+
if (
322+
isinstance(node, ast.Attribute)
323+
and isinstance(node.value, ast.Name)
324+
and node.value.id == "self"
325+
):
326+
return node.attr
327+
return None
328+
329+
296330
def _record_import_from(node: ast.ImportFrom, scope: _Scope) -> None:
297331
"""``from m import f [as g]``; relative spellings resolve at lookup time.
298332
@@ -811,6 +845,10 @@ def __init__(self) -> None:
811845
self.self_attr: Dict[Tuple[str, str], Tuple[str, str]] = {}
812846
self.return_class: Dict[str, Optional[str]] = {}
813847
self.module_classes: Dict[str, Dict[str, PyClass]] = {}
848+
self.owner_by_sig: Dict[str, Optional[PyClass]] = {}
849+
# (class sig, attr) -> [(writer method name, value var name)] for
850+
# `self.attr[key] = value` container writes
851+
self.elem_writes: Dict[Tuple[str, str], List[Tuple[str, str]]] = {}
814852

815853
# -- construction ------------------------------------------------------
816854
def add_module(self, qual: str, mod: PyModule, tree: ast.Module,
@@ -820,6 +858,7 @@ def add_module(self, qual: str, mod: PyModule, tree: ast.Module,
820858
self.classes_global.setdefault(name, []).append(cls)
821859
for caller, _owner in _iter_callables(mod):
822860
self.func_by_sig[caller.signature] = caller
861+
self.owner_by_sig[caller.signature] = _owner
823862
self.by_name.setdefault(caller.name, []).append(caller.signature)
824863
names = [p.name for p in caller.parameters or []]
825864
self.param_names[caller.signature] = names
@@ -838,6 +877,26 @@ def _collect_self_attrs(self, qual: str, tree: ast.Module,
838877
cls = classes.get(node.name)
839878
if cls is None:
840879
continue
880+
method_stack: Dict[int, str] = {}
881+
for m in ast.walk(node):
882+
if isinstance(m, (ast.FunctionDef, ast.AsyncFunctionDef)):
883+
for sub in ast.walk(m):
884+
if not isinstance(sub, ast.Assign):
885+
continue
886+
for tgt in sub.targets:
887+
if (
888+
isinstance(tgt, ast.Subscript)
889+
and isinstance(tgt.value, ast.Attribute)
890+
and isinstance(tgt.value.value, ast.Name)
891+
and tgt.value.value.id == "self"
892+
and isinstance(sub.value, ast.Name)
893+
):
894+
# self.X[key] = value_name — the writer method
895+
# and value name; the value's type resolves
896+
# lazily (parameter votes land later)
897+
self.elem_writes.setdefault(
898+
(cls.signature, tgt.value.attr), []
899+
).append((m.name, sub.value.id))
841900
for sub in ast.walk(node):
842901
if not isinstance(sub, ast.Assign):
843902
continue
@@ -1029,10 +1088,9 @@ def bump(src: str, dst: str) -> None:
10291088
global_classes=oracle.classes_global,
10301089
)
10311090
if sig is None:
1032-
if site.receiver_expr:
1033-
pending.append(
1034-
(caller, owner, site, scope, mod, qual, classes)
1035-
)
1091+
pending.append(
1092+
(caller, owner, site, scope, mod, qual, classes)
1093+
)
10361094
continue
10371095
oracle.vote(sig, site)
10381096
bump(caller.signature, sig)
@@ -1141,27 +1199,99 @@ def bump(src: str, dst: str) -> None:
11411199
# ---- interprocedural round (#148 extension): type the receivers the
11421200
# local pass could not, in a strict deterministic order, then resolve
11431201
# the method on the typed class. One round, no fixpoint.
1144-
def _returned_ctor_class(callee, qual):
1145-
"""Unique `return C(...)` inside *callee*, resolved to a class."""
1202+
def _container_elem_type(owner, attr, qual):
1203+
"""`self.attr[k] = value` writers vote the container's element type."""
1204+
if owner is None:
1205+
return None
1206+
cands = set()
1207+
for method_name, value_name in oracle.elem_writes.get(
1208+
(owner.signature, attr), []
1209+
):
1210+
writer = (owner.callables or {}).get(method_name)
1211+
if writer is None:
1212+
continue
1213+
t = oracle.param_type(writer, value_name, qual)
1214+
if t is not None and t[0] == "class":
1215+
cands.add(t[1].name)
1216+
return next(iter(cands)) if len(cands) == 1 else None
1217+
1218+
_ret_memo: Dict[str, Optional[Tuple]] = {}
1219+
1220+
def _returned_summary(callee, depth=0):
1221+
"""What does *callee* return? -> ("class", PyClass) |
1222+
("callable", sig) | None. Memoized, cycle-safe, depth-capped —
1223+
chains through `return self.m(...)` / `return f(...)`.
1224+
"""
1225+
if callee is None or depth > 4:
1226+
return None
1227+
key = callee.signature
1228+
if key in _ret_memo:
1229+
return _ret_memo[key]
1230+
_ret_memo[key] = None # cycle guard
1231+
result = None
11461232
for home_qual, (hmod, hfacts, hclasses) in sorted(module_ctx.items()):
1147-
if not callee.signature.startswith(home_qual + "."):
1233+
if not key.startswith(home_qual + "."):
11481234
continue
11491235
cscope = _scope_for_callable(callee, hfacts.by_def, hfacts.module_scope)
11501236
if cscope is hfacts.module_scope:
1151-
continue
1237+
break
11521238
names = set()
1239+
fn_paths = set()
11531240
for c in sorted(cscope.return_ctors):
11541241
if c.startswith("~"):
1155-
it = cscope.instance_types.get(c[1:])
1242+
nm = c[1:]
1243+
if nm in cscope.funcs:
1244+
fn_paths.add(cscope.funcs[nm])
1245+
continue
1246+
it = cscope.instance_types.get(nm)
1247+
if it is None and nm in cscope.loopvar_sources:
1248+
# loop var drawn from a self container:
1249+
# `for k, v in self.adapters.items(): ... return v`
1250+
_, attr = cscope.loopvar_sources[nm]
1251+
own = oracle.owner_by_sig.get(key)
1252+
it = _container_elem_type(own, attr, home_qual)
11561253
if it is not None:
11571254
names.add(it)
11581255
else:
11591256
names.add(c)
11601257
hits = sorted({n for n in names if n in hclasses})
1161-
if len(hits) == 1 and len(names) == 1:
1162-
return hclasses[hits[0]]
1163-
return None
1164-
return None
1258+
if len(hits) == 1 and len(names) == 1 and not fn_paths:
1259+
result = ("class", hclasses[hits[0]])
1260+
break
1261+
if len(fn_paths) == 1 and not names:
1262+
sig = _signature_for_path(hmod, next(iter(fn_paths)))
1263+
if sig:
1264+
result = ("callable", sig)
1265+
break
1266+
if len(cscope.return_calls) == 1 and not names and not fn_paths:
1267+
fexpr = cscope.return_calls[0]
1268+
nxt_sig = None
1269+
if (
1270+
isinstance(fexpr, ast.Attribute)
1271+
and isinstance(fexpr.value, ast.Name)
1272+
and fexpr.value.id in ("self", "cls")
1273+
):
1274+
own = oracle.owner_by_sig.get(key)
1275+
if own is not None:
1276+
nxt_sig = _resolve_self_call(
1277+
fexpr.attr, own, hclasses,
1278+
hfacts.module_scope, hmod, home_qual, by_qual,
1279+
global_classes=oracle.classes_global,
1280+
)
1281+
else:
1282+
nxt_sig = _resolve_expr(
1283+
fexpr, cscope, hmod, home_qual, by_qual
1284+
)
1285+
nxt = oracle.func_by_sig.get(nxt_sig) if nxt_sig else None
1286+
if nxt is not None:
1287+
result = _returned_summary(nxt, depth + 1)
1288+
break
1289+
_ret_memo[key] = result
1290+
return result
1291+
1292+
def _returned_ctor_class(callee, qual):
1293+
r = _returned_summary(callee)
1294+
return r[1] if r is not None and r[0] == "class" else None
11651295

11661296
def _typed_receiver(caller, owner, name, scope, mod, qual, classes):
11671297
t = oracle.param_type(caller, name, qual)
@@ -1224,9 +1354,56 @@ def _method_on(t, method, qual):
12241354
)
12251355

12261356
remaining = pending
1227-
for _round in (1, 2):
1357+
_MAX_ROUNDS = 8 # monotone: resolutions only grow; cap is a safety net
1358+
for _round in range(_MAX_ROUNDS):
1359+
made_progress = False
12281360
still: List[Tuple] = []
12291361
for caller, owner, site, scope, mod, qual, classes in remaining:
1362+
if not (site.receiver_expr or ""):
1363+
# bare call of a variable holding a returned closure:
1364+
# `compute = make_compute(...); compute(...)`
1365+
sig = None
1366+
s_ = scope
1367+
while s_ is not None:
1368+
if site.method_name in s_.call_assigns:
1369+
fexpr = s_.call_assigns[site.method_name]
1370+
fsig = None
1371+
if (
1372+
isinstance(fexpr, ast.Attribute)
1373+
and isinstance(fexpr.value, ast.Name)
1374+
and fexpr.value.id in ("self", "cls")
1375+
and owner is not None
1376+
):
1377+
fsig = _resolve_self_call(
1378+
fexpr.attr, owner, classes,
1379+
module_ctx[qual][1].module_scope, mod, qual,
1380+
by_qual, global_classes=oracle.classes_global,
1381+
)
1382+
else:
1383+
fsig = _resolve_expr(fexpr, s_, mod, qual, by_qual)
1384+
summ = _returned_summary(oracle.func_by_sig.get(fsig)) if fsig else None
1385+
if summ is not None and summ[0] == "callable":
1386+
sig = summ[1]
1387+
break
1388+
if (
1389+
site.method_name in s_.blocked
1390+
or site.method_name in s_.bindings
1391+
or site.method_name in s_.funcs
1392+
or site.method_name in s_.imports
1393+
or site.method_name in s_.mod_imports
1394+
):
1395+
break
1396+
s_ = s_.parent
1397+
if sig is not None:
1398+
oracle.vote(sig, site)
1399+
bump(caller.signature, sig)
1400+
resolutions[
1401+
(caller.signature, f"{site.start_line}:{site.start_column}")
1402+
] = sig
1403+
made_progress = True
1404+
else:
1405+
still.append((caller, owner, site, scope, mod, qual, classes))
1406+
continue
12301407
if (site.receiver_expr or "") in ("self", "cls") and owner is not None:
12311408
sig = _resolve_self_call(
12321409
site.method_name, owner, classes,
@@ -1277,7 +1454,10 @@ def _method_on(t, method, qual):
12771454
resolutions[
12781455
(caller.signature, f"{site.start_line}:{site.start_column}")
12791456
] = sig
1457+
made_progress = True
12801458
remaining = still
1459+
if not made_progress:
1460+
break
12811461

12821462
iter_still: List[Tuple] = []
12831463
for caller, owner, name, scope, mod, qual, classes in pending_iter:
@@ -1298,7 +1478,12 @@ def _method_on(t, method, qual):
12981478
# bounded per site so a common name cannot explode the graph.
12991479
_FAN_CAP = 1024 # pathology guard only; Joern's widest observed fan is 222
13001480
for caller, owner, site, scope, mod, qual, classes in remaining:
1301-
for sig in (oracle.by_name.get(site.method_name) or [])[:_FAN_CAP]:
1481+
cands = oracle.by_name.get(site.method_name) or []
1482+
if not (site.receiver_expr or ""):
1483+
# a bare name can never invoke a method (no receiver at runtime)
1484+
# — only module-level functions are legal targets
1485+
cands = [c for c in cands if oracle.owner_by_sig.get(c) is None]
1486+
for sig in cands[:_FAN_CAP]:
13021487
if sig != caller.signature:
13031488
bump(caller.signature, sig)
13041489
for caller, mname in iter_still:

docs/design/specs/2026-08-25-defuse-linker-call-graph-design.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,24 @@ descriptor-protocol resolutions (`builtins.classmethod.__get__` on
142142
`cls.helper()` sites) and cross-namespace stamps (`self._warn` resolved to
143143
stdlib `_warnings.warn` — the declared-method edge is now added alongside).
144144

145+
**Whole-program propagation (#150, implemented 2026-08-26).** The one-round
146+
oracle became a capped monotone fixpoint (progress-driven, ≤8 rounds), and
147+
three transfer families joined it: **chained return summaries** (`return
148+
self.build_response(...)` resolves through the callee's own returns,
149+
memoized, cycle-safe, depth-capped), **returned callables** (`return inner`
150+
lets `f = factory(); f()` point at the inner def — bare-name fan is
151+
owner-filtered at the same time, since a bare name can never invoke a
152+
method), and **container element types** (`self.adapters[k] = adapter` typed
153+
by the writer's parameter votes; a loop variable drawn from
154+
`self.adapters.items()` carries the element type into return summaries — the
155+
`get_adapter`/`send` chain). Measured on odoo-slim-19: the audited Joern
156+
residual fell 292 → 243 (0.9%), the closure-target bucket to zero, with the
157+
graph size flat (+0.02% — fan replacement offset by the new precise arrows).
158+
Determinism: requests byte-identical; flask differs by the single canonical
159+
#146 edge; odoo paired samples flap 0.088% / 0.118% with the
160+
downstream-amplification ratio constant at ~60% — same composition, no new
161+
flap class, #146 remains the sole source.
162+
145163
Still out of scope: Scalpel copy-closure alias widening.
146164

147165
## Reference validation (2026-08-25/26)

0 commit comments

Comments
 (0)