From 35912734c730f5aa13d7d80cf3acb44eb0902f8e Mon Sep 17 00:00:00 2001 From: Rahul Krishna Date: Wed, 16 Sep 2026 01:11:54 -0400 Subject: [PATCH] fix(sdg): wire actual_in ports from formal_in, not from CFG ENTRY (#220) The SDG edges were correct: param_in carried actual_in -> formal_in and the summary edges were present. The DDG feeding them was wired wrong on the way in. Reaching-def analysis names the synthetic CFG ENTRY node as the definition site of every parameter, capture and read global. build_formals already remapped that source onto the matching formal_in vertex. build_actuals runs after it, allocates the actual_in ports, and wired them straight from _defs_reaching_call_matching, which returns the raw source. For a parameter that source is ENTRY, and nothing remapped it, so the graph got ENTRY -> actual_in:N where it needed formal_in:N -> actual_in:N. The return direction was already correct, and that asymmetry is the tell. Because resolve_value returns the formal_in vertex, a forward walk started at a vertex with no path to the argument port, never crossed PARAM_IN, and stopped inside the caller. slice_forward stayed in one function, flows_to_call and flows_to_argument answered False, paths_between returned nothing, and taint() reported a clean exhausted with complete=True and an empty ledger, which is a refutation for a flow visible in three lines of source. Extract the ENTRY-to-formal_in lookup into formal_for(var) on the assembler and use it from both build_formals and build_actuals, for argument ports and global-read ports. Fall back to the original source when the variable is not one of the callable's formals, so no edge is lost. On the reproducer in #220 a two-boundary flow now resolves end to end and taint returns the witness. Regression test asserts an actual_in port carrying a parameter is fed from formal_in and never from ENTRY; it fails on 1.5.3. Full suite: 520 passed, 6 skipped. Closes #220 --- CHANGELOG.md | 32 ++++++++++++++++++++++++++++++++ codeanalyzer/dataflow/sdg.py | 34 ++++++++++++++++++++++++++-------- pyproject.toml | 2 +- test/test_dataflow_sdg.py | 36 ++++++++++++++++++++++++++++++++++++ 4 files changed, 95 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3983b15..0961937 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,38 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.5.4] - 2026-09-16 + +### Fixed + +- **A parameter passed as an argument now links across the call boundary** (#220). The SDG + edges were always correct -- `param_in` carried `actual_in -> formal_in` and the summary + edges were present -- but the DDG that feeds them was wired wrong on the way in. + Reaching-def analysis names the synthetic CFG ENTRY node as the definition site of every + parameter, capture and read global. `build_formals` already remapped that source onto the + matching `formal_in` vertex, which is what puts a parameter's definition on its port. + `build_actuals` runs after it, allocates the `actual_in` ports, and wired them straight + from `_defs_reaching_call_matching`, which returns the raw source. For a parameter that + source is ENTRY, and nothing remapped it, so the graph got `ENTRY -> actual_in:N` where it + needed `formal_in:N -> actual_in:N`. The return direction was wired correctly, and that + asymmetry is the tell. + + The consequence reached every interprocedural value query. `resolve_value` returns the + `formal_in` vertex, so a forward walk began at a vertex with no path to the argument port, + never crossed `PARAM_IN`, and stopped inside the caller. `slice_forward` stayed in one + function, `flows_to_call` and `flows_to_argument` answered `False`, `paths_between` returned + nothing, and `taint()` reported a clean `exhausted` with `complete: True` and an empty + ledger -- a refutation for a flow visible in three lines of source. Absence of evidence was + being published as evidence of absence. + + The ENTRY-to-`formal_in` lookup is now a `formal_for(var)` helper on the assembler, used by + both `build_formals` and `build_actuals`, for argument ports and global-read ports alike. It + falls back to the original source when the variable is not one of the callable's formals, so + no edge is lost. On the reproducer in #220 a two-boundary flow now resolves end to end, and + `taint` returns the witness instead of a refutation. A regression test in + `test/test_dataflow_sdg.py` asserts that an `actual_in` port carrying a parameter is fed from + `formal_in` and never from ENTRY; it fails on 1.5.3. + ## [1.5.3] - 2026-09-14 ### Fixed diff --git a/codeanalyzer/dataflow/sdg.py b/codeanalyzer/dataflow/sdg.py index 276943f..34711a9 100644 --- a/codeanalyzer/dataflow/sdg.py +++ b/codeanalyzer/dataflow/sdg.py @@ -142,6 +142,26 @@ def _alloc(self, kind: str, var: str, span, call_node=None) -> int: # ---------------------------------------------------------------- formals + def formal_for(self, var: str) -> Optional[int]: + """The ``formal_in`` vertex that defines ``var`` in this callable. + + A parameter, a capture and a read global are all defined at a + ``formal_in`` port, not at the synthetic CFG ENTRY node. Reaching-def + analysis reports ENTRY as the source for all three, so every place that + consumes a raw def source has to remap it here, or the SDG loses the + hop from the parameter to whatever uses it. Returns ``None`` when + ``var`` is not one of this callable's formals, so a caller can keep the + original source. + """ + b = base_of(var) + if b in self.formal_in: + return self.formal_in[b] + if CAPTURE_PREFIX + b in self.formal_in: + return self.formal_in[CAPTURE_PREFIX + b] + if "::" in b and GLOBAL_PREFIX + b in self.formal_in: + return self.formal_in[GLOBAL_PREFIX + b] + return None + def build_formals(self) -> None: scope, summary = self.scope, self.summary params = list(scope.params) @@ -168,14 +188,8 @@ def build_formals(self) -> None: for e in self.ddg: if e.source != entry: continue - b = base_of(e.var) - if b in self.formal_in: - fid = self.formal_in[b] - elif CAPTURE_PREFIX + b in self.formal_in: - fid = self.formal_in[CAPTURE_PREFIX + b] - elif "::" in b and GLOBAL_PREFIX + b in self.formal_in: - fid = self.formal_in[GLOBAL_PREFIX + b] - else: + fid = self.formal_for(e.var) + if fid is None: continue self.extra.append(PDGEdge(source=fid, target=e.target, type="DDG", var=e.var)) @@ -268,6 +282,8 @@ def build_actuals( for src, var in self._defs_reaching_call_matching( cs.node_id, path ): + if src == self.cfg.entry_id: + src = self.formal_for(var) or src self.extra.append( PDGEdge(source=src, target=aid, type="DDG", var=var) ) @@ -296,6 +312,8 @@ def build_actuals( for src, var in self._defs_reaching_call_matching( cs.node_id, g ): + if src == self.cfg.entry_id: + src = self.formal_for(var) or src self.extra.append( PDGEdge(source=src, target=aid, type="DDG", var=var) ) diff --git a/pyproject.toml b/pyproject.toml index 99db4ef..54de02a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "codeanalyzer-python" -version = "1.5.3" +version = "1.5.4" description = "Static analysis for Python — canonical schema v2 (symbol table, call graph, and native CFG/PDG/SDG dataflow) as analysis.json or a Neo4j property graph." readme = "README.md" authors = [ diff --git a/test/test_dataflow_sdg.py b/test/test_dataflow_sdg.py index 126aca2..8c77363 100644 --- a/test/test_dataflow_sdg.py +++ b/test/test_dataflow_sdg.py @@ -16,6 +16,7 @@ from pathlib import Path from codeanalyzer.dataflow.builder import _callable_index, build_program_graphs +from codeanalyzer.dataflow.access_paths import base_of from codeanalyzer.dataflow.sdg import CAPTURE_PREFIX, GLOBAL_PREFIX from codeanalyzer.options import AnalysisOptions from codeanalyzer.core import Codeanalyzer @@ -110,6 +111,41 @@ def test_param_in_arity_matches_callee_formals(ir): assert formal.var == e.var +def test_actual_in_is_fed_from_formal_in_not_entry(ir): + """A parameter passed through as an argument reaches the call site's + ``actual_in`` port from ``formal_in``, never from the synthetic CFG ENTRY. + + Reaching-def analysis names ENTRY as the definition site of every + parameter, so ``build_actuals`` has to remap that source onto the + ``formal_in`` vertex. Without the remap the argument port hangs off ENTRY, + a forward walk from the parameter never reaches it, and every + interprocedural value path breaks at the first call boundary. + """ + sig = _sig(ir, "use_adder") # def use_adder(n): ... return add5(n) + fg = ir.functions[sig] + entry_id = fg.pdg.cfg.entry_id + + formal_n = { + p.id for p in fg.param_nodes if p.kind == "formal_in" and p.var == "n" + } + assert formal_n, "no formal_in vertex for parameter n" + + actual_ids = {p.id for p in fg.param_nodes if p.kind == "actual_in"} + assert actual_ids, "no actual_in vertex at the add5(n) call site" + + feeding = { + e.source + for e in fg.extra_edges + if e.type == "DDG" and e.target in actual_ids and base_of(e.var or "") == "n" + } + assert feeding, "no DDG edge carries n into an actual_in port" + assert entry_id not in feeding, ( + "actual_in for n is fed from the CFG ENTRY node; it must be fed from " + "formal_in(n), or interprocedural value flow breaks at this call" + ) + assert feeding & formal_n, "actual_in for n is not fed from formal_in(n)" + + def test_param_out_sources_are_formal_outs(ir): for e in ir.sdg_edges: if e.type != "PARAM_OUT":