Skip to content

Commit c6f990f

Browse files
committed
feat(dataflow): stages 6–7 — function summaries and SDG assembly
Relational summaries (params/captures/read-globals → return/mutations/ written-globals) composed bottom-up over the Tarjan condensation DAG, monotone fixpoint within SCCs, callee global footprints injected at callsites and reaching definitions re-solved; HRB parameter structure (formal/actual in/out nodes in the owning function's id space after EXIT), CALL/PARAM_IN/PARAM_OUT edges, SUMMARY edges from composed flows, globals as extra formals, closure captures bound at definition sites; builder maps symbol-table signatures to AST by (file, line) and treats the call graph and Jedi callsite resolutions as frozen oracles. Gates: arity, no dangling endpoints, transitive-chain SUMMARY, cross- file global flow, deterministic double-run. (#67)
1 parent 789bd1c commit c6f990f

5 files changed

Lines changed: 1086 additions & 2 deletions

File tree

codeanalyzer/dataflow/builder.py

Lines changed: 255 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,255 @@
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+
"""The level-3 orchestrator: symbol table + call graph → program graphs.
18+
19+
``build_program_graphs`` is the single entry point ``Codeanalyzer.analyze``
20+
calls at ``-a 3``. It re-parses each module file with the stdlib ``ast`` (the
21+
same parser the symbol table used), maps every ``PyCallable`` to its def node
22+
by ``(file, start_line)`` — which is what guarantees graph nodes join back to
23+
symbol-table signatures — then runs the construction ladder:
24+
25+
per callable: CFG → dominance → facts (module-qualified globals)
26+
whole program: SCC condensation → summary fixpoint → SDG assembly
27+
28+
The call graph and Jedi-resolved callsites are frozen oracles: targets are
29+
looked up, never re-inferred. Callables whose AST cannot be recovered (file
30+
changed on disk, decorators moving line numbers, generated code) are skipped
31+
with a warning — their callers still treat them as external pass-through, so
32+
the result degrades gracefully instead of crashing (contract rule).
33+
"""
34+
35+
from __future__ import annotations
36+
37+
import ast
38+
from pathlib import Path
39+
from typing import Dict, List, Optional, Set, Tuple
40+
41+
from codeanalyzer.dataflow.access_paths import _PathExtractor, _calls_in
42+
from codeanalyzer.dataflow.alias import TypeBasedAliasOracle
43+
from codeanalyzer.dataflow.pdg import build_pdg
44+
from codeanalyzer.dataflow.sdg import ProgramGraphsIR, assemble_sdg
45+
from codeanalyzer.dataflow.summaries import CallSite, FunctionInfo, compute_summaries
46+
from codeanalyzer.schema.py_schema import PyApplication, PyCallable, PyClass, PyModule
47+
from codeanalyzer.utils import logger
48+
49+
DEFAULT_K_LIMIT = 3
50+
51+
52+
def _walk_callables(
53+
module: PyModule,
54+
) -> List[Tuple[PyCallable, Tuple[PyCallable, ...]]]:
55+
"""Every callable in the module with its chain of enclosing callables."""
56+
out: List[Tuple[PyCallable, Tuple[PyCallable, ...]]] = []
57+
58+
def from_callable(c: PyCallable, chain: Tuple[PyCallable, ...]) -> None:
59+
out.append((c, chain))
60+
for inner in (c.inner_callables or {}).values():
61+
from_callable(inner, chain + (c,))
62+
for cls in (c.inner_classes or {}).values():
63+
from_class(cls, chain + (c,))
64+
65+
def from_class(cls: PyClass, chain: Tuple[PyCallable, ...]) -> None:
66+
for m in (cls.methods or {}).values():
67+
from_callable(m, chain)
68+
for inner in (cls.inner_classes or {}).values():
69+
from_class(inner, chain)
70+
71+
for fn in (module.functions or {}).values():
72+
from_callable(fn, ())
73+
for cls in (module.classes or {}).values():
74+
from_class(cls, ())
75+
return out
76+
77+
78+
def _locals_of(func: ast.AST) -> Set[str]:
79+
from codeanalyzer.dataflow.access_paths import _assigned_names, _param_names
80+
81+
return set(_param_names(func)) | _assigned_names(func)
82+
83+
84+
def _base_types(c: PyCallable) -> Dict[str, Optional[str]]:
85+
types: Dict[str, Optional[str]] = {}
86+
for p in c.parameters or []:
87+
types[p.name] = p.type
88+
for v in c.local_variables or []:
89+
types.setdefault(v.name, v.type)
90+
return types
91+
92+
93+
def _class_index(app: PyApplication) -> Dict[str, PyClass]:
94+
from codeanalyzer.semantic_analysis.call_graph import iter_classes_in_symbol_table
95+
96+
return {c.signature: c for c in iter_classes_in_symbol_table(app.symbol_table)}
97+
98+
99+
def _callable_index(app: PyApplication) -> Dict[str, PyCallable]:
100+
from codeanalyzer.semantic_analysis.call_graph import iter_callables_in_symbol_table
101+
102+
return {c.signature: c for c in iter_callables_in_symbol_table(app.symbol_table)}
103+
104+
105+
def _match_args(
106+
call: ast.Call,
107+
callee: PyCallable,
108+
extractor: _PathExtractor,
109+
receiver_path: Optional[str],
110+
) -> Tuple[Tuple[str, Optional[str]], ...]:
111+
"""Positional/keyword-match actual access paths to callee param names.
112+
The receiver (or constructed object) binds the leading self/cls param."""
113+
params = [p.name for p in (callee.parameters or [])]
114+
pairs: List[Tuple[str, Optional[str]]] = []
115+
positional = list(params)
116+
if params and params[0] in ("self", "cls"):
117+
if receiver_path is not None:
118+
pairs.append((params[0], receiver_path))
119+
positional = params[1:]
120+
for name, arg in zip(positional, call.args):
121+
if isinstance(arg, ast.Starred):
122+
break
123+
pairs.append((name, extractor.path_of(arg)))
124+
for kw in call.keywords:
125+
if kw.arg and kw.arg in params:
126+
pairs.append((kw.arg, extractor.path_of(kw.value)))
127+
return tuple(pairs)
128+
129+
130+
def build_program_graphs(
131+
app: PyApplication,
132+
k: int = DEFAULT_K_LIMIT,
133+
) -> ProgramGraphsIR:
134+
"""Build CFG/PDG per callable and the whole-program SDG."""
135+
class_idx = _class_index(app)
136+
callable_idx = _callable_index(app)
137+
138+
infos: Dict[str, FunctionInfo] = {}
139+
func_asts: Dict[str, ast.AST] = {}
140+
141+
for file_key, module in sorted(app.symbol_table.items()):
142+
path = Path(module.file_path)
143+
try:
144+
tree = ast.parse(path.read_text())
145+
except (OSError, SyntaxError) as exc:
146+
logger.warning(f"level 3: skipping {path} (unparseable: {exc})")
147+
continue
148+
149+
def_index: Dict[int, ast.AST] = {}
150+
for node in ast.walk(tree):
151+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
152+
def_index[node.lineno] = node
153+
154+
for pycallable, chain in _walk_callables(module):
155+
func = def_index.get(pycallable.start_line)
156+
if func is None or func.name != pycallable.name:
157+
logger.warning(
158+
f"level 3: no AST match for {pycallable.signature} "
159+
f"({path}:{pycallable.start_line}); treated as external"
160+
)
161+
continue
162+
163+
enclosing_locals: Set[str] = set()
164+
for enclosing in chain:
165+
enclosing_ast = def_index.get(enclosing.start_line)
166+
if enclosing_ast is not None:
167+
enclosing_locals |= _locals_of(enclosing_ast)
168+
169+
oracle = TypeBasedAliasOracle(_base_types(pycallable))
170+
pdg = build_pdg(
171+
func,
172+
enclosing_locals=enclosing_locals,
173+
oracle=oracle,
174+
k=k,
175+
global_qualifier=module.module_name,
176+
)
177+
infos[pycallable.signature] = FunctionInfo(
178+
signature=pycallable.signature, pdg=pdg, oracle=oracle
179+
)
180+
func_asts[pycallable.signature] = func
181+
182+
# Callsites and nested defs, now that every signature is known.
183+
for sig, info in infos.items():
184+
pycallable = callable_idx[sig]
185+
func = func_asts[sig]
186+
extractor = _PathExtractor(info.pdg.scope, k)
187+
188+
calls_by_pos: Dict[Tuple[int, int], Tuple[int, ast.Call]] = {}
189+
calls_by_line: Dict[int, Tuple[int, ast.Call]] = {}
190+
for node in info.pdg.cfg.nodes:
191+
if node.ast_node is None:
192+
continue
193+
for call in _calls_in(node.ast_node):
194+
pos = (call.lineno, call.col_offset)
195+
calls_by_pos.setdefault(pos, (node.id, call))
196+
calls_by_line.setdefault(call.lineno, (node.id, call))
197+
198+
for site in pycallable.call_sites or []:
199+
target = site.callee_signature
200+
if not target:
201+
continue
202+
if target in class_idx and target not in infos:
203+
target = f"{target}.__init__" # constructor → its initializer
204+
if target not in infos:
205+
continue # external or unrecovered: pass-through posture
206+
207+
located = calls_by_pos.get((site.start_line, site.start_column))
208+
if located is None:
209+
located = calls_by_line.get(site.start_line)
210+
if located is None:
211+
continue
212+
node_id, call = located
213+
214+
receiver_path: Optional[str] = None
215+
if isinstance(call.func, ast.Attribute):
216+
receiver_path = extractor.path_of(call.func.value)
217+
elif site.is_constructor_call:
218+
# p = Box(...) binds the constructed object (self) to p.
219+
owner = info.pdg.cfg.node_by_id(node_id).ast_node
220+
if (
221+
isinstance(owner, ast.Assign)
222+
and len(owner.targets) == 1
223+
and isinstance(owner.targets[0], (ast.Name, ast.Attribute))
224+
):
225+
receiver_path = extractor.path_of(owner.targets[0])
226+
227+
info.call_sites.append(
228+
CallSite(
229+
node_id=node_id,
230+
targets=(target,),
231+
arg_paths=_match_args(call, callable_idx[target], extractor, receiver_path),
232+
line=site.start_line,
233+
)
234+
)
235+
236+
for node in info.pdg.cfg.nodes:
237+
if isinstance(node.ast_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
238+
nested_sig = f"{sig}.{node.ast_node.name}"
239+
if nested_sig in infos:
240+
info.nested_defs.append((node.id, nested_sig))
241+
242+
call_edges = [
243+
(e.source, e.target)
244+
for e in app.call_graph
245+
if e.source in infos and e.target in infos
246+
]
247+
# Callsite resolutions are part of the same oracle (they may include
248+
# constructor retargets the edge list lacks).
249+
for sig, info in infos.items():
250+
for cs in info.call_sites:
251+
for t in cs.targets:
252+
call_edges.append((sig, t))
253+
254+
summaries = compute_summaries(infos, sorted(set(call_edges)))
255+
return assemble_sdg(infos, summaries, k)

codeanalyzer/dataflow/defuse.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ def reaching_definitions(
6060
) -> Dict[int, Set[Tuple[str, int]]]:
6161
"""IN sets: ``{node: {(path, def_node), ...}}`` via worklist iteration."""
6262
preds = cfg.predecessors()
63+
succs = cfg.successors()
6364
node_ids = [n.id for n in cfg.nodes]
6465

6566
gen: Dict[int, Set[Tuple[str, int]]] = {}
@@ -80,8 +81,7 @@ def reaching_definitions(
8081
if new_in != in_sets[nid] or new_out != out_sets[nid]:
8182
in_sets[nid] = new_in
8283
out_sets[nid] = new_out
83-
succ = cfg.successors()[nid]
84-
for s, _ in succ:
84+
for s, _ in succs[nid]:
8585
if s not in worklist:
8686
worklist.append(s)
8787
return in_sets

0 commit comments

Comments
 (0)