Skip to content

Commit d21fc0a

Browse files
committed
feat(dataflow): stage 4 — PDG assembly and exact backward-slice gate
PDG = CDG ∪ DDG per callable over the same node ids; intraprocedural backward slice as reverse reachability. Gate pins hand-computed exact slices: the early-return arm is excluded from the other arm's slice, loop slices close over the loop-carried dependency. (#67)
1 parent 7377dc3 commit d21fc0a

2 files changed

Lines changed: 167 additions & 0 deletions

File tree

codeanalyzer/dataflow/pdg.py

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
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 4 of the level-3 dataflow ladder: PDG assembly.
18+
19+
Per callable, the PDG is the union of the stage-2 control-dependence edges
20+
(``CDG``) and the stage-3 def-use edges (``DDG``), over the same
21+
``(signature, node_id)`` nodes. Nothing new is computed here — this module is
22+
bookkeeping plus the intraprocedural backward slice that gates it: reverse
23+
reachability over CDG ∪ DDG from a criterion node, expected to match a
24+
hand-computed node set exactly on the fixture.
25+
"""
26+
27+
from __future__ import annotations
28+
29+
import ast
30+
from dataclasses import dataclass, field
31+
from typing import Dict, List, Optional, Set
32+
33+
from codeanalyzer.dataflow.access_paths import (
34+
FunctionScope,
35+
StatementFacts,
36+
build_scope,
37+
statement_facts,
38+
)
39+
from codeanalyzer.dataflow.alias import TypeBasedAliasOracle
40+
from codeanalyzer.dataflow.cfg import ControlFlowGraph, build_cfg
41+
from codeanalyzer.dataflow.defuse import ddg_edges
42+
from codeanalyzer.dataflow.dominance import control_dependence
43+
44+
45+
@dataclass(frozen=True)
46+
class PDGEdge:
47+
source: int
48+
target: int
49+
type: str # "CDG" | "DDG"
50+
var: Optional[str] = None # access path on DDG edges
51+
52+
53+
@dataclass
54+
class FunctionPDG:
55+
"""One callable's intraprocedural graphs, keyed externally by signature."""
56+
57+
cfg: ControlFlowGraph
58+
edges: List[PDGEdge]
59+
scope: FunctionScope
60+
facts: Dict[int, StatementFacts] = field(default_factory=dict)
61+
62+
63+
def build_pdg(
64+
func: ast.AST,
65+
enclosing_locals: Set[str],
66+
oracle: TypeBasedAliasOracle,
67+
k: int = 3,
68+
) -> FunctionPDG:
69+
"""CFG → dominance → def-use → PDG for one callable."""
70+
cfg = build_cfg(func)
71+
scope = build_scope(func, enclosing_locals)
72+
facts = statement_facts(cfg, func, scope, k)
73+
74+
edges: List[PDGEdge] = [
75+
PDGEdge(source=a, target=b, type="CDG") for a, b in control_dependence(cfg)
76+
]
77+
edges.extend(
78+
PDGEdge(source=e.source, target=e.target, type="DDG", var=e.var)
79+
for e in ddg_edges(cfg, facts, oracle)
80+
)
81+
edges.sort(key=lambda e: (e.source, e.target, e.type, e.var or ""))
82+
return FunctionPDG(cfg=cfg, edges=edges, scope=scope, facts=facts)
83+
84+
85+
def intraprocedural_backward_slice(pdg: FunctionPDG, criterion: int) -> Set[int]:
86+
"""Reverse reachability over CDG ∪ DDG from the criterion node (the
87+
criterion itself is in the slice). The stage-4 gate."""
88+
reverse: Dict[int, List[int]] = {}
89+
for e in pdg.edges:
90+
reverse.setdefault(e.target, []).append(e.source)
91+
seen: Set[int] = set()
92+
stack = [criterion]
93+
while stack:
94+
n = stack.pop()
95+
if n in seen:
96+
continue
97+
seen.add(n)
98+
stack.extend(reverse.get(n, []))
99+
return seen

test/test_dataflow_pdg.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
"""Stage-4 gate: PDG assembly and the exact intraprocedural backward slice.
2+
3+
The highest-value test of the intraprocedural half: the backward slice of a
4+
named variable at a named line equals a hand-computed node set — exactly.
5+
It catches both missing control dependences and missing def-use edges.
6+
"""
7+
8+
import ast
9+
from pathlib import Path
10+
11+
from codeanalyzer.dataflow.alias import TypeBasedAliasOracle
12+
from codeanalyzer.dataflow.pdg import build_pdg, intraprocedural_backward_slice
13+
14+
FIXTURE = Path(__file__).parent / "fixtures" / "single_functionalities" / "dataflow"
15+
16+
17+
def _pdg_of(file_name: str, func_name: str):
18+
tree = ast.parse((FIXTURE / file_name).read_text())
19+
for node in ast.walk(tree):
20+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name == func_name:
21+
return build_pdg(node, enclosing_locals=set(), oracle=TypeBasedAliasOracle())
22+
raise AssertionError(f"{func_name} not found")
23+
24+
25+
def _id_at_line(pdg, line: int) -> int:
26+
return next(n.id for n in pdg.cfg.nodes if n.start_line == line and n.kind != "entry")
27+
28+
29+
def _lines(pdg, ids) -> set:
30+
return {
31+
pdg.cfg.node_by_id(i).start_line
32+
for i in ids
33+
if pdg.cfg.node_by_id(i).kind not in ("entry", "exit")
34+
}
35+
36+
37+
def test_pdg_edges_use_only_cdg_and_ddg_types():
38+
for func in ("branchy", "looped", "early_exit", "handles"):
39+
pdg = _pdg_of("main.py", func)
40+
assert {e.type for e in pdg.edges} <= {"CDG", "DDG"}
41+
for e in pdg.edges:
42+
assert (e.var is not None) == (e.type == "DDG")
43+
44+
45+
def test_early_exit_slice_excludes_the_other_arm():
46+
pdg = _pdg_of("main.py", "early_exit")
47+
criterion = _id_at_line(pdg, 32) # return y
48+
slice_ids = intraprocedural_backward_slice(pdg, criterion)
49+
# Hand-computed: ENTRY, the branch header (29), y = n * 2 (31), and the
50+
# criterion itself. `return -1` (30) is control-dependent on the same
51+
# branch but contributes nothing to y — it must NOT appear.
52+
assert _lines(pdg, slice_ids) == {29, 31, 32}
53+
assert pdg.cfg.entry_id in slice_ids
54+
assert _id_at_line(pdg, 30) not in slice_ids
55+
56+
57+
def test_branchy_slice_includes_both_arms_and_the_branch():
58+
pdg = _pdg_of("main.py", "branchy")
59+
criterion = _id_at_line(pdg, 16) # return x
60+
slice_ids = intraprocedural_backward_slice(pdg, criterion)
61+
assert _lines(pdg, slice_ids) == {12, 13, 15, 16}
62+
63+
64+
def test_looped_slice_of_return_total_is_the_whole_loop():
65+
pdg = _pdg_of("main.py", "looped")
66+
criterion = _id_at_line(pdg, 25) # return total
67+
slice_ids = intraprocedural_backward_slice(pdg, criterion)
68+
assert _lines(pdg, slice_ids) == {20, 21, 22, 23, 24, 25}

0 commit comments

Comments
 (0)