Skip to content

Commit 43e0e69

Browse files
committed
feat(dataflow): stage 8a — two-phase context-sensitive backward slicing
Classic HRB traversal over the assembled SDG: phase 1 ascends and skips across callsites via SUMMARY edges (never PARAM_OUT), phase 2 descends (never PARAM_IN/CALL) — call–return matching without re-descent. Gate pins an exact hand-computed interprocedural slice (caller_of_mutate → mutate) plus cross-file global descent and no-reascend properties. (#67)
1 parent c6f990f commit 43e0e69

2 files changed

Lines changed: 203 additions & 0 deletions

File tree

codeanalyzer/dataflow/slicing.py

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
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 8 of the level-3 dataflow ladder: backward slicing as an SDG query.
18+
19+
The classic Horwitz–Reps–Binkley two-phase traversal, which is what makes the
20+
slice *context-sensitive* without re-descending into callees:
21+
22+
- **Phase 1** walks backward over every dependence edge **except PARAM_OUT**:
23+
it ascends from the criterion to callers (PARAM_IN/CALL reversed) and steps
24+
*across* callsites through SUMMARY edges, but never descends into a callee.
25+
- **Phase 2** starts from everything phase 1 reached and walks backward over
26+
every edge **except PARAM_IN and CALL**: it descends into callees
27+
(PARAM_OUT reversed) but never re-ascends — which is exactly what prevents
28+
infeasible call–return mismatches.
29+
30+
Slicing consumes the assembled :class:`~codeanalyzer.dataflow.sdg.
31+
ProgramGraphsIR`; taint is the same labeled traversal with a model pack and
32+
is deliberately left to the CLDK SDK (language-independent once the SDG is
33+
emitted — see #67).
34+
"""
35+
36+
from __future__ import annotations
37+
38+
from typing import Dict, List, Set, Tuple
39+
40+
from codeanalyzer.dataflow.sdg import ProgramGraphsIR
41+
42+
Node = Tuple[str, int] # (signature, node_id)
43+
44+
45+
def _reverse_adjacency(ir: ProgramGraphsIR) -> Dict[Node, List[Tuple[Node, str]]]:
46+
"""target → [(source, edge_type)] over intra- and inter-procedural edges."""
47+
radj: Dict[Node, List[Tuple[Node, str]]] = {}
48+
49+
def add(src: Node, tgt: Node, kind: str) -> None:
50+
radj.setdefault(tgt, []).append((src, kind))
51+
52+
for sig, fg in ir.functions.items():
53+
for e in fg.pdg.edges:
54+
if e.type == "CDG":
55+
add((sig, e.source), (sig, e.target), "CDG")
56+
for e in fg.ddg:
57+
add((sig, e.source), (sig, e.target), "DDG")
58+
for e in fg.extra_edges:
59+
add((sig, e.source), (sig, e.target), e.type)
60+
for e in ir.sdg_edges:
61+
add(
62+
(e.source_sig, e.source_node),
63+
(e.target_sig, e.target_node),
64+
e.type,
65+
)
66+
return radj
67+
68+
69+
def backward_slice(ir: ProgramGraphsIR, signature: str, node_id: int) -> Set[Node]:
70+
"""Context-sensitive backward slice of ``(signature, node_id)``."""
71+
if signature not in ir.functions:
72+
raise KeyError(f"unknown signature: {signature}")
73+
radj = _reverse_adjacency(ir)
74+
criterion: Node = (signature, node_id)
75+
76+
def sweep(seeds: Set[Node], skip: Set[str]) -> Set[Node]:
77+
seen: Set[Node] = set()
78+
stack = list(seeds)
79+
while stack:
80+
node = stack.pop()
81+
if node in seen:
82+
continue
83+
seen.add(node)
84+
for src, kind in radj.get(node, ()):
85+
if kind in skip:
86+
continue
87+
if src not in seen:
88+
stack.append(src)
89+
return seen
90+
91+
phase1 = sweep({criterion}, skip={"PARAM_OUT"})
92+
phase2 = sweep(phase1, skip={"PARAM_IN", "CALL"})
93+
return phase1 | phase2

test/test_dataflow_slicing.py

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
"""Stage-8 gate: the two-phase context-sensitive backward slice.
2+
3+
The client gate demands an *exact* hand-computed node set for a named
4+
criterion — this is the assertion that catches both missing dependence edges
5+
and context-insensitive over-reach.
6+
"""
7+
8+
from pathlib import Path
9+
10+
import pytest
11+
12+
from codeanalyzer.core import Codeanalyzer
13+
from codeanalyzer.dataflow.builder import build_program_graphs
14+
from codeanalyzer.dataflow.slicing import backward_slice
15+
from codeanalyzer.options import AnalysisOptions
16+
17+
FIXTURE = Path(__file__).parent / "fixtures" / "single_functionalities" / "dataflow"
18+
19+
20+
@pytest.fixture(scope="module")
21+
def ir(tmp_path_factory):
22+
cache = tmp_path_factory.mktemp("dataflow-slice-cache")
23+
options = AnalysisOptions(
24+
input=FIXTURE, analysis_level=1, no_venv=True, cache_dir=cache
25+
)
26+
with Codeanalyzer(options) as analyzer:
27+
return build_program_graphs(analyzer.analyze())
28+
29+
30+
def _sig(ir, suffix: str) -> str:
31+
matches = [s for s in ir.functions if s == suffix or s.endswith("." + suffix)]
32+
assert len(matches) == 1, f"suffix {suffix}: {matches}"
33+
return matches[0]
34+
35+
36+
def _cfg_id(ir, sig: str, line: int) -> int:
37+
fg = ir.functions[sig]
38+
return next(
39+
n.id for n in fg.pdg.cfg.nodes if n.start_line == line and n.kind != "entry"
40+
)
41+
42+
43+
def _param_id(ir, sig: str, kind: str, var: str, call_node=None) -> int:
44+
fg = ir.functions[sig]
45+
matches = [
46+
p.id
47+
for p in fg.param_nodes
48+
if p.kind == kind and p.var == var and (call_node is None or p.call_node == call_node)
49+
]
50+
assert len(matches) == 1, f"{sig} {kind} {var}: {matches}"
51+
return matches[0]
52+
53+
54+
def test_caller_of_mutate_slice_is_exactly_the_hand_computed_set(ir):
55+
caller = _sig(ir, "caller_of_mutate")
56+
mutate = _sig(ir, "mutate")
57+
criterion = _cfg_id(ir, caller, 61) # return xs
58+
59+
got = backward_slice(ir, caller, criterion)
60+
61+
call_node = _cfg_id(ir, caller, 60) # mutate(xs)
62+
expected = {
63+
# caller: ENTRY, xs = [], the callsite, the criterion,
64+
(caller, ir.functions[caller].pdg.cfg.entry_id),
65+
(caller, _cfg_id(ir, caller, 59)),
66+
(caller, call_node),
67+
(caller, criterion),
68+
# the module binding `mutate` read at the callsite,
69+
(caller, _param_id(ir, caller, "formal_in", "<global>:pipeline::mutate")),
70+
# the callsite's parameter structure,
71+
(caller, _param_id(ir, caller, "actual_in", "items", call_node)),
72+
(caller, _param_id(ir, caller, "actual_out", "<return>", call_node)),
73+
(caller, _param_id(ir, caller, "actual_out", "items", call_node)),
74+
# mutate (phase-2 descent): ENTRY, items.append(1), its formals.
75+
(mutate, ir.functions[mutate].pdg.cfg.entry_id),
76+
(mutate, _cfg_id(ir, mutate, 55)),
77+
(mutate, _param_id(ir, mutate, "formal_in", "items")),
78+
(mutate, _param_id(ir, mutate, "formal_out", "<return>")),
79+
(mutate, _param_id(ir, mutate, "formal_out", "items")),
80+
}
81+
assert got == expected
82+
83+
84+
def test_global_slice_descends_into_the_writing_function(ir):
85+
read_counter = _sig(ir, "read_counter")
86+
bump = _sig(ir, "bump")
87+
criterion = _cfg_id(ir, read_counter, 12) # return counter
88+
89+
got = backward_slice(ir, read_counter, criterion)
90+
91+
# The write `counter = counter + amount` (state.py line 8) must be in the
92+
# slice: read_counter ascends to drive's callsite, whose incoming global
93+
# def comes from bump's PARAM_OUT.
94+
assert (bump, _cfg_id(ir, bump, 8)) in got
95+
96+
97+
def test_slice_does_not_reascend_into_unrelated_callers(ir):
98+
# Criterion inside chain_c: its slice ascends to chain_b/chain_a/drive,
99+
# but must not pull in unrelated functions like alias_flow or gen.
100+
chain_c = _sig(ir, "chain_c")
101+
criterion = _cfg_id(ir, chain_c, 13) # return v - 3
102+
got = backward_slice(ir, chain_c, criterion)
103+
sigs = {s for s, _ in got}
104+
assert _sig(ir, "alias_flow") not in sigs
105+
assert _sig(ir, "looped") not in sigs
106+
107+
108+
def test_unknown_signature_raises(ir):
109+
with pytest.raises(KeyError):
110+
backward_slice(ir, "no.such.function", 0)

0 commit comments

Comments
 (0)