Skip to content

Commit 789bd1c

Browse files
committed
feat(dataflow): stage 5 — alias oracle wiring, Tarjan SCC, global qualification
Iterative Tarjan SCC condensation of the frozen call-graph oracle (reverse topological schedule for bottom-up summaries); call mutations become suffixed weak defs so caller-visible mutation is distinguishable from local rebinding; global bases gain module::name qualification for the interprocedural build. (#67)
1 parent d21fc0a commit 789bd1c

4 files changed

Lines changed: 159 additions & 5 deletions

File tree

codeanalyzer/dataflow/access_paths.py

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -372,16 +372,19 @@ def target_reads(self, target: ast.expr) -> Set[str]:
372372
# -- call mutation (documented over-approximation) -----------------------
373373

374374
def mutation_defs(self, expr: ast.expr) -> Set[str]:
375+
"""Weak defs of the *contents* of receiver/argument objects (``xs.*``
376+
— suffixed, so a call mutation is never confused with a local
377+
rebinding, which is not caller-visible)."""
375378
defs: Set[str] = set()
376379
for call in _calls_in(expr):
377380
if isinstance(call.func, ast.Attribute):
378381
receiver = self.path_of(call.func.value)
379382
if receiver is not None:
380-
defs.add(receiver)
383+
defs.add(k_limit(receiver + ".*", self.k))
381384
for arg in list(call.args) + [kw.value for kw in call.keywords]:
382385
p = self.path_of(arg)
383386
if p is not None:
384-
defs.add(p)
387+
defs.add(k_limit(p + ".*", self.k))
385388
return defs
386389

387390
def receiver_uses(self, expr: ast.expr) -> Set[str]:
@@ -419,12 +422,38 @@ def _calls_in(expr: ast.expr) -> List[ast.Call]:
419422
return calls
420423

421424

425+
def qualify_globals(paths: Set[str], scope: FunctionScope, qualifier: str) -> Set[str]:
426+
"""Rewrite global bases to their module-qualified form ``module::name``
427+
(``::`` keeps the qualifier out of the field-path grammar). Builtins stay
428+
bare — they carry no cross-module dataflow worth modeling."""
429+
import builtins as _builtins
430+
431+
out: Set[str] = set()
432+
for p in paths:
433+
b = base_of(p)
434+
if (
435+
"::" not in b
436+
and b != RETURN_PATH
437+
and scope.kind_of(b) == "global"
438+
and not hasattr(_builtins, b)
439+
):
440+
out.add(f"{qualifier}::{b}" + p[len(b):])
441+
else:
442+
out.add(p)
443+
return out
444+
445+
422446
def statement_facts(
423-
cfg: ControlFlowGraph, func: ast.AST, scope: FunctionScope, k: int
447+
cfg: ControlFlowGraph,
448+
func: ast.AST,
449+
scope: FunctionScope,
450+
k: int,
451+
global_qualifier: Optional[str] = None,
424452
) -> Dict[int, StatementFacts]:
425453
"""Defs/uses per CFG node id. Compound statements contribute only their
426454
header expressions; ENTRY defines every param/self/global/capture base
427-
the function touches (the incoming state)."""
455+
the function touches (the incoming state). With ``global_qualifier`` set
456+
(the interprocedural build), global bases become ``module::name``."""
428457
ex = _PathExtractor(scope, k)
429458
facts: Dict[int, StatementFacts] = {}
430459

@@ -526,6 +555,9 @@ def call_fx(expr: ast.expr) -> None:
526555

527556
f.defs = {k_limit(p, k) for p in f.defs}
528557
f.uses = {k_limit(p, k) for p in f.uses}
558+
if global_qualifier is not None:
559+
f.defs = qualify_globals(f.defs, scope, global_qualifier)
560+
f.uses = qualify_globals(f.uses, scope, global_qualifier)
529561
facts[node.id] = f
530562

531563
return facts

codeanalyzer/dataflow/pdg.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,11 +65,12 @@ def build_pdg(
6565
enclosing_locals: Set[str],
6666
oracle: TypeBasedAliasOracle,
6767
k: int = 3,
68+
global_qualifier: Optional[str] = None,
6869
) -> FunctionPDG:
6970
"""CFG → dominance → def-use → PDG for one callable."""
7071
cfg = build_cfg(func)
7172
scope = build_scope(func, enclosing_locals)
72-
facts = statement_facts(cfg, func, scope, k)
73+
facts = statement_facts(cfg, func, scope, k, global_qualifier)
7374

7475
edges: List[PDGEdge] = [
7576
PDGEdge(source=a, target=b, type="CDG") for a, b in control_dependence(cfg)

codeanalyzer/dataflow/scc.py

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
################################################################################
2+
# Copyright IBM Corporation 2025
3+
#
4+
# Licensed under the Apache License, Version 2.0 (the "License");
5+
# you may not use this file except in compliance with the License.
6+
# You may obtain a copy of the License at
7+
#
8+
# http://www.apache.org/licenses/LICENSE-2.0
9+
#
10+
# Unless required by applicable law or agreed to in writing, software
11+
# distributed under the License is distributed on an "AS IS" BASIS,
12+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
# See the License for the specific language governing permissions and
14+
# limitations under the License.
15+
################################################################################
16+
17+
"""Stage 5b of the level-3 dataflow ladder: SCC condensation of the call graph.
18+
19+
The call graph is a frozen oracle (level-1 Jedi edges, provenance-merged with
20+
level-2 PyCG when enabled); Tarjan condenses it into strongly connected
21+
components, and the condensation DAG in reverse topological order is the
22+
bottom-up processing schedule for summary composition — callees before
23+
callers, one monotone fixpoint per SCC (mutual recursion).
24+
25+
Iterative Tarjan (no recursion — real projects overflow Python's stack), with
26+
sorted tie-breaking so the schedule is deterministic.
27+
"""
28+
29+
from __future__ import annotations
30+
31+
from typing import Dict, List, Set, Tuple
32+
33+
34+
def strongly_connected_components(
35+
nodes: List[str], edges: List[Tuple[str, str]]
36+
) -> List[List[str]]:
37+
"""Tarjan SCCs in reverse topological order (callees before callers).
38+
Deterministic: nodes are visited in sorted order and members sorted."""
39+
adj: Dict[str, List[str]] = {n: [] for n in nodes}
40+
for s, t in sorted(set(edges)):
41+
if s in adj and t in adj:
42+
adj[s].append(t)
43+
44+
index_of: Dict[str, int] = {}
45+
lowlink: Dict[str, int] = {}
46+
on_stack: Set[str] = set()
47+
stack: List[str] = []
48+
sccs: List[List[str]] = []
49+
counter = [0]
50+
51+
for root in sorted(adj):
52+
if root in index_of:
53+
continue
54+
# Iterative DFS: (node, iterator position over successors).
55+
work: List[Tuple[str, int]] = [(root, 0)]
56+
while work:
57+
node, i = work.pop()
58+
if i == 0:
59+
index_of[node] = lowlink[node] = counter[0]
60+
counter[0] += 1
61+
stack.append(node)
62+
on_stack.add(node)
63+
recurse = False
64+
successors = adj[node]
65+
while i < len(successors):
66+
succ = successors[i]
67+
i += 1
68+
if succ not in index_of:
69+
work.append((node, i))
70+
work.append((succ, 0))
71+
recurse = True
72+
break
73+
if succ in on_stack:
74+
lowlink[node] = min(lowlink[node], index_of[succ])
75+
if recurse:
76+
continue
77+
if lowlink[node] == index_of[node]:
78+
component: List[str] = []
79+
while True:
80+
member = stack.pop()
81+
on_stack.discard(member)
82+
component.append(member)
83+
if member == node:
84+
break
85+
sccs.append(sorted(component))
86+
if work:
87+
parent = work[-1][0]
88+
lowlink[parent] = min(lowlink[parent], lowlink[node])
89+
90+
# Tarjan emits SCCs in reverse topological order already.
91+
return sccs

test/test_dataflow_scc.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
"""Stage-5 gate: SCC condensation of the call-graph oracle."""
2+
3+
from codeanalyzer.dataflow.scc import strongly_connected_components
4+
5+
6+
def test_mutual_recursion_forms_one_scc():
7+
nodes = ["main", "even", "odd", "leaf"]
8+
edges = [("main", "even"), ("even", "odd"), ("odd", "even"), ("even", "leaf")]
9+
sccs = strongly_connected_components(nodes, edges)
10+
assert ["even", "odd"] in sccs
11+
assert ["leaf"] in sccs and ["main"] in sccs
12+
13+
14+
def test_reverse_topological_order_callees_first():
15+
nodes = ["a", "b", "c"]
16+
edges = [("a", "b"), ("b", "c")]
17+
sccs = strongly_connected_components(nodes, edges)
18+
pos = {tuple(s): i for i, s in enumerate(sccs)}
19+
assert pos[("c",)] < pos[("b",)] < pos[("a",)]
20+
21+
22+
def test_deterministic_across_runs():
23+
nodes = ["m", "x", "y", "z"]
24+
edges = [("m", "x"), ("x", "y"), ("y", "x"), ("y", "z"), ("z", "y")]
25+
assert strongly_connected_components(nodes, edges) == strongly_connected_components(
26+
nodes, edges
27+
)
28+
# x-y-z all collapse into one SCC (x↔y, y↔z), members sorted.
29+
sccs = strongly_connected_components(nodes, edges)
30+
assert ["x", "y", "z"] in sccs

0 commit comments

Comments
 (0)