|
| 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