Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
34 changes: 26 additions & 8 deletions codeanalyzer/dataflow/sdg.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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))

Expand Down Expand Up @@ -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)
)
Expand Down Expand Up @@ -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)
)
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -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 = [
Expand Down
36 changes: 36 additions & 0 deletions test/test_dataflow_sdg.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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":
Expand Down