Skip to content

Commit 5600542

Browse files
committed
feat(dataflow): emit L3 body + cfg/cdg/ddg(ssa) onto the v2 tree
1 parent 3e4d416 commit 5600542

3 files changed

Lines changed: 160 additions & 2 deletions

File tree

codeanalyzer/core.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -468,7 +468,20 @@ def analyze(self) -> Analysis:
468468
backfill_callees(app, sig_to_id)
469469
reidentify_call_graph(app, sig_to_id)
470470

471-
# L3/L4 dataflow emission rebuilt on the v2 tree in Stage 3+
471+
# L3: intraprocedural dataflow (CFG/CDG/DDG) emitted onto the v2 tree.
472+
if self.analysis_level >= 3:
473+
from codeanalyzer.dataflow.builder import (
474+
build_function_pdgs,
475+
emit_l3_body,
476+
)
477+
from codeanalyzer.dataflow.syntactic import SyntacticOracle
478+
479+
infos, _func_asts = build_function_pdgs(
480+
app,
481+
k=self.options.graph_field_depth,
482+
oracle_factory=lambda c: SyntacticOracle(),
483+
)
484+
emit_l3_body(app, infos, sig_to_id, set(self.options.graphs.split(",")))
472485

473486
# Build the v2 envelope, then persist it (the cache stores the full
474487
# ``Analysis`` envelope so a reused cache round-trips schema_version).

codeanalyzer/dataflow/builder.py

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,127 @@ def build_function_pdgs(
192192
return infos, func_asts
193193

194194

195+
def emit_l3_body(
196+
app: PyApplication,
197+
infos: Dict[str, FunctionInfo],
198+
sig_to_id: Dict[str, str],
199+
graphs: Set[str],
200+
) -> None:
201+
"""Project each callable's syntactic PDG onto the v2 tree at L3.
202+
203+
For every callable that produced a ``FunctionInfo`` in
204+
:func:`build_function_pdgs` (syntactic oracle), this writes onto the
205+
matching ``PyCallable`` in ``app``'s symbol table:
206+
207+
* ``body`` — one node per CFG node, keyed by its ordinal id
208+
(``<can:// id>@entry``/``@exit`` for the synthetic bookends,
209+
``<can:// id>@line:col`` for real statements). A statement position an
210+
L1 pass already materialized as a ``call`` node keeps its ``call`` kind
211+
and L2-resolved ``callee``; it is only re-keyed onto its ordinal id (so
212+
the edge lists resolve to it and it is not duplicated) and given the
213+
byte-offset ``span`` L1 could not compute.
214+
* ``cfg`` — one ``CfgEdge`` per CFG edge, endpoints as ordinal ids.
215+
* ``cdg`` — the PDG's control-dependence edges.
216+
* ``ddg`` — the PDG's syntactic def-use edges, each with ``prov=["ssa"]``
217+
(no points-to provenance at L3; that is the L4 delta).
218+
219+
``graphs`` scopes the edge lists exactly as the dormant
220+
:func:`to_program_graphs` does: ``cfg`` needs ``"cfg"``; ``cdg`` needs
221+
``"pdg"``/``"sdg"``; ``ddg`` needs those or ``"dfg"``. ``body`` is always
222+
populated. Callables absent from ``infos`` (unrecovered AST) are skipped.
223+
"""
224+
from codeanalyzer.dataflow.identity import IdentityMap
225+
from codeanalyzer.schema.py_schema import (
226+
BodyNode,
227+
CdgEdge,
228+
CfgEdge,
229+
DdgEdge,
230+
Span,
231+
byte_offsets,
232+
)
233+
234+
want_pdg = bool({"pdg", "sdg"} & graphs)
235+
want_cfg = "cfg" in graphs
236+
want_ddg = want_pdg or "dfg" in graphs
237+
238+
def _span_of(source: str, node) -> Optional["Span"]:
239+
if not source or node.start_line < 1:
240+
return None
241+
return Span(
242+
start=(node.start_line, node.start_column),
243+
end=(node.end_line, node.end_column),
244+
bytes=byte_offsets(
245+
source,
246+
node.start_line,
247+
node.start_column,
248+
node.end_line,
249+
node.end_column,
250+
),
251+
)
252+
253+
for module in app.symbol_table.values():
254+
source = module.source
255+
for pycallable, _chain in _walk_callables(module):
256+
info = infos.get(pycallable.signature)
257+
if info is None:
258+
continue
259+
pdg = info.pdg
260+
callable_id = sig_to_id.get(pycallable.signature) or pycallable.id
261+
im = IdentityMap.for_function(callable_id, pdg)
262+
263+
for node in pdg.cfg.nodes:
264+
ordinal = im.ordinal(node.id)
265+
if node.id == pdg.cfg.entry_id:
266+
pycallable.body[ordinal] = BodyNode(kind="entry")
267+
continue
268+
if node.id == pdg.cfg.exit_id:
269+
pycallable.body[ordinal] = BodyNode(kind="exit")
270+
continue
271+
span = _span_of(source, node)
272+
# An L1 `call` node was keyed by its "line:col"; if this CFG node
273+
# sits at the same position, keep that node's `call` kind and
274+
# resolved `callee` and merely re-key it onto the ordinal id
275+
# (dedup + endpoint resolution), filling any missing span.
276+
existing = pycallable.body.get(ordinal)
277+
if existing is None:
278+
existing = pycallable.body.pop(
279+
f"{node.start_line}:{node.start_column}", None
280+
)
281+
if existing is not None:
282+
if existing.span is None and span is not None:
283+
existing.span = span
284+
pycallable.body[ordinal] = existing
285+
continue
286+
pycallable.body[ordinal] = BodyNode(kind=node.kind, span=span)
287+
288+
if want_cfg:
289+
pycallable.cfg = [
290+
CfgEdge(
291+
src=im.ordinal(e.source),
292+
dst=im.ordinal(e.target),
293+
kind=e.kind,
294+
)
295+
for e in pdg.cfg.edges
296+
]
297+
if want_pdg:
298+
pycallable.cdg = [
299+
CdgEdge(src=im.ordinal(e.source), dst=im.ordinal(e.target))
300+
for e in pdg.edges
301+
if e.type == "CDG"
302+
]
303+
if want_ddg:
304+
pycallable.ddg = [
305+
DdgEdge(
306+
src=im.ordinal(e.source),
307+
dst=im.ordinal(e.target),
308+
var=e.var,
309+
prov=["ssa"],
310+
)
311+
for e in pdg.edges
312+
if e.type == "DDG"
313+
]
314+
315+
195316
def build_program_graphs(
196317
app: PyApplication,
197318
k: int = DEFAULT_K_LIMIT,

test/test_v2_l3.py

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import textwrap
22
from pathlib import Path
33

4-
from codeanalyzer.dataflow.builder import build_function_pdgs
4+
from codeanalyzer.dataflow.builder import build_function_pdgs, emit_l3_body
55
from codeanalyzer.dataflow.identity import IdentityMap
66
from codeanalyzer.dataflow.syntactic import SyntacticOracle
7+
from codeanalyzer.schema.assign_ids import assign_ids
78
from codeanalyzer.schema.py_schema import PyApplication
89
from codeanalyzer.syntactic_analysis.symbol_table_builder import SymbolTableBuilder
910

@@ -37,6 +38,29 @@ def test_syntactic_oracle_only_identity_aliases():
3738
assert o.may_alias("a", "b") is False
3839

3940

41+
def test_emit_l3_populates_body_and_cfg(tmp_path: Path):
42+
f = tmp_path / "m.py"
43+
f.write_text("def f(a):\n b = a\n return b\n", encoding="utf-8")
44+
mod = SymbolTableBuilder(tmp_path, None).build_pymodule_from_file(f)
45+
app = PyApplication(symbol_table={"m.py": mod})
46+
sig_to_id = assign_ids(app, "app")
47+
infos, _func_asts = build_function_pdgs(
48+
app, k=3, oracle_factory=lambda c: SyntacticOracle()
49+
)
50+
emit_l3_body(app, infos, sig_to_id, graphs={"cfg", "dfg", "pdg"})
51+
fn = next(iter(mod.functions.values()))
52+
assert any(k.endswith("@entry") for k in fn.body)
53+
assert any(k.endswith("@exit") for k in fn.body)
54+
assert len(fn.cfg) > 0
55+
# every cfg endpoint resolves to a body node id
56+
body_ids = set(fn.body)
57+
for e in fn.cfg:
58+
assert e.src in body_ids and e.dst in body_ids
59+
# ddg (if any) carries ssa provenance
60+
for e in fn.ddg:
61+
assert e.prov == ["ssa"]
62+
63+
4064
def test_build_function_pdgs_returns_pdg_per_callable(tmp_path: Path):
4165
f = tmp_path / "m.py"
4266
f.write_text(textwrap.dedent("def f(a):\n b = a\n return b\n"), encoding="utf-8")

0 commit comments

Comments
 (0)