Skip to content

Commit 8496d11

Browse files
committed
fix(dataflow): key L3 body + intra-callable edges by local id (canonical); keep global_id for neo4j
1 parent 5600542 commit 8496d11

3 files changed

Lines changed: 77 additions & 46 deletions

File tree

codeanalyzer/dataflow/builder.py

Lines changed: 22 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -204,14 +204,14 @@ def emit_l3_body(
204204
:func:`build_function_pdgs` (syntactic oracle), this writes onto the
205205
matching ``PyCallable`` in ``app``'s symbol table:
206206
207-
* ``body`` — one node per CFG node, keyed by its ordinal id
208-
(``<can:// id>@entry``/``@exit`` for the synthetic bookends,
209-
``<can:// id>@line:col`` for real statements). A statement position an
210-
L1 pass already materialized as a ``call`` node keeps its ``call`` kind
211-
and L2-resolved ``callee``; it is only re-keyed onto its ordinal id (so
212-
the edge lists resolve to it and it is not duplicated) and given the
213-
byte-offset ``span`` L1 could not compute.
214-
* ``cfg`` — one ``CfgEdge`` per CFG edge, endpoints as ordinal ids.
207+
* ``body`` — one node per CFG node, keyed by its LOCAL id (``"@entry"``/
208+
``"@exit"`` for the synthetic bookends, ``"line:col"`` for real
209+
statements — the same key format L1 uses). A statement position an L1
210+
pass already materialized as a ``call`` node lands on the SAME local key,
211+
so it keeps its ``call`` kind and L2-resolved ``callee`` in place (no
212+
re-keying, no duplication) and is only given the byte-offset ``span`` L1
213+
could not compute.
214+
* ``cfg`` — one ``CfgEdge`` per CFG edge, endpoints as local ids.
215215
* ``cdg`` — the PDG's control-dependence edges.
216216
* ``ddg`` — the PDG's syntactic def-use edges, each with ``prov=["ssa"]``
217217
(no points-to provenance at L3; that is the L4 delta).
@@ -261,50 +261,45 @@ def _span_of(source: str, node) -> Optional["Span"]:
261261
im = IdentityMap.for_function(callable_id, pdg)
262262

263263
for node in pdg.cfg.nodes:
264-
ordinal = im.ordinal(node.id)
264+
local = im.local(node.id)
265265
if node.id == pdg.cfg.entry_id:
266-
pycallable.body[ordinal] = BodyNode(kind="entry")
266+
pycallable.body[local] = BodyNode(kind="entry")
267267
continue
268268
if node.id == pdg.cfg.exit_id:
269-
pycallable.body[ordinal] = BodyNode(kind="exit")
269+
pycallable.body[local] = BodyNode(kind="exit")
270270
continue
271271
span = _span_of(source, node)
272-
# An L1 `call` node was keyed by its "line:col"; if this CFG node
273-
# sits at the same position, keep that node's `call` kind and
274-
# resolved `callee` and merely re-key it onto the ordinal id
275-
# (dedup + endpoint resolution), filling any missing span.
276-
existing = pycallable.body.get(ordinal)
277-
if existing is None:
278-
existing = pycallable.body.pop(
279-
f"{node.start_line}:{node.start_column}", None
280-
)
272+
# An L1 `call` node was keyed by its LOCAL "line:col"; this CFG
273+
# node at the same position lands on the SAME key, so keep the
274+
# node's `call` kind and L2-resolved `callee` in place and just
275+
# fill any missing span — never re-key or duplicate it.
276+
existing = pycallable.body.get(local)
281277
if existing is not None:
282278
if existing.span is None and span is not None:
283279
existing.span = span
284-
pycallable.body[ordinal] = existing
285280
continue
286-
pycallable.body[ordinal] = BodyNode(kind=node.kind, span=span)
281+
pycallable.body[local] = BodyNode(kind=node.kind, span=span)
287282

288283
if want_cfg:
289284
pycallable.cfg = [
290285
CfgEdge(
291-
src=im.ordinal(e.source),
292-
dst=im.ordinal(e.target),
286+
src=im.local(e.source),
287+
dst=im.local(e.target),
293288
kind=e.kind,
294289
)
295290
for e in pdg.cfg.edges
296291
]
297292
if want_pdg:
298293
pycallable.cdg = [
299-
CdgEdge(src=im.ordinal(e.source), dst=im.ordinal(e.target))
294+
CdgEdge(src=im.local(e.source), dst=im.local(e.target))
300295
for e in pdg.edges
301296
if e.type == "CDG"
302297
]
303298
if want_ddg:
304299
pycallable.ddg = [
305300
DdgEdge(
306-
src=im.ordinal(e.source),
307-
dst=im.ordinal(e.target),
301+
src=im.local(e.source),
302+
dst=im.local(e.target),
308303
var=e.var,
309304
prov=["ssa"],
310305
)

codeanalyzer/dataflow/identity.py

Lines changed: 25 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,46 @@
1-
"""Bijection between internal IR node ids (ints, per function) and canonical
2-
ordinal ids `<callable can:// id>@<tag>` — `@entry`/`@exit` for the synthetic
3-
CFG bookends, `@line:col` for real statements. Both emitters consume this so
4-
JSON body-node ids and Neo4j PyCFGNode keys are identical."""
1+
"""Bijection between internal IR node ids (ints, per function) and their
2+
canonical ids.
3+
4+
Two forms per node:
5+
6+
* **local** — the intra-callable id used as the ``body`` map key and as every
7+
``cfg``/``cdg``/``ddg`` edge endpoint: ``"@entry"``/``"@exit"`` for the
8+
synthetic CFG bookends, ``"line:col"`` for real statements. This matches the
9+
key format L1 already uses for ``call`` nodes (see ``schema/l1_body.py``), so
10+
an L1 body node and its coinciding CFG node land on the same key and L1 ⊆ L3
11+
holds.
12+
* **global** — ``"<callable can:// id>@<local>"``, the fully addressable id for
13+
cross-callable references and the Neo4j PyCFGNode keys (a later task).
14+
"""
515
from __future__ import annotations
616
from typing import Dict, Iterable
717

818

919
class IdentityMap:
10-
def __init__(self, callable_id: str, id_to_ordinal: Dict[int, str]):
20+
def __init__(self, callable_id: str, id_to_local: Dict[int, str]):
1121
self._callable_id = callable_id
12-
self._map = id_to_ordinal
22+
self._map = id_to_local
1323

1424
@classmethod
1525
def for_function(cls, callable_id: str, pdg) -> "IdentityMap":
1626
cfg = pdg.cfg
1727
m: Dict[int, str] = {}
1828
for n in cfg.nodes:
1929
if n.id == cfg.entry_id:
20-
m[n.id] = f"{callable_id}@entry"
30+
m[n.id] = "@entry"
2131
elif n.id == cfg.exit_id:
22-
m[n.id] = f"{callable_id}@exit"
32+
m[n.id] = "@exit"
2333
else:
24-
m[n.id] = f"{callable_id}@{n.start_line}:{n.start_column}"
34+
m[n.id] = f"{n.start_line}:{n.start_column}"
2535
return cls(callable_id, m)
2636

27-
def ordinal(self, node_id: int) -> str:
37+
def local(self, node_id: int) -> str:
38+
"""Intra-callable id: ``"@entry"``/``"@exit"`` or ``"line:col"``."""
2839
return self._map[node_id]
2940

41+
def global_id(self, node_id: int) -> str:
42+
"""Fully addressable id: ``"<callable-id>@<local>"``."""
43+
return f"{self._callable_id}@{self._map[node_id]}"
44+
3045
def node_ids(self) -> Iterable[int]:
3146
return self._map.keys()

test/test_v2_l3.py

Lines changed: 30 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from codeanalyzer.dataflow.identity import IdentityMap
66
from codeanalyzer.dataflow.syntactic import SyntacticOracle
77
from codeanalyzer.schema.assign_ids import assign_ids
8+
from codeanalyzer.schema.l1_body import populate_l1_body
89
from codeanalyzer.schema.py_schema import PyApplication
910
from codeanalyzer.syntactic_analysis.symbol_table_builder import SymbolTableBuilder
1011

@@ -21,13 +22,16 @@ def node_by_id(self, i): return self._n[i]
2122
class _PDG:
2223
def __init__(self, cfg): self.cfg = cfg
2324

24-
def test_ordinal_ids_for_entry_exit_and_statements():
25+
def test_local_and_global_ids_for_entry_exit_and_statements():
2526
nodes = [_Node(0, 1, 0, "entry"), _Node(1, 2, 4, "statement"), _Node(2, 3, 4, "exit")]
2627
pdg = _PDG(_CFG(nodes, entry_id=0, exit_id=2))
2728
im = IdentityMap.for_function("can://python/app/m.py/f()", pdg)
28-
assert im.ordinal(0) == "can://python/app/m.py/f()@entry"
29-
assert im.ordinal(1) == "can://python/app/m.py/f()@2:4"
30-
assert im.ordinal(2) == "can://python/app/m.py/f()@exit"
29+
# LOCAL ids: intra-callable keys (match l1_body's "line:col" format).
30+
assert im.local(0) == "@entry"
31+
assert im.local(1) == "2:4"
32+
assert im.local(2) == "@exit"
33+
# GLOBAL id: fully addressable form for Neo4j / cross-callable use.
34+
assert im.global_id(1) == "can://python/app/m.py/f()@2:4"
3135
assert set(im.node_ids()) == {0, 1, 2}
3236

3337

@@ -40,19 +44,36 @@ def test_syntactic_oracle_only_identity_aliases():
4044

4145
def test_emit_l3_populates_body_and_cfg(tmp_path: Path):
4246
f = tmp_path / "m.py"
43-
f.write_text("def f(a):\n b = a\n return b\n", encoding="utf-8")
47+
f.write_text("def f(a):\n b = a\n g(b)\n return b\n", encoding="utf-8")
4448
mod = SymbolTableBuilder(tmp_path, None).build_pymodule_from_file(f)
4549
app = PyApplication(symbol_table={"m.py": mod})
4650
sig_to_id = assign_ids(app, "app")
51+
# L1 materializes the `g(b)` call as a LOCAL "line:col" body node; simulate
52+
# the L2 callee refinement so we can prove L3 preserves it (no re-key).
53+
populate_l1_body(app)
54+
fn = next(iter(mod.functions.values()))
55+
call_key = "3:4"
56+
assert fn.body[call_key].kind == "call"
57+
fn.body[call_key].callee = "m.g"
58+
4759
infos, _func_asts = build_function_pdgs(
4860
app, k=3, oracle_factory=lambda c: SyntacticOracle()
4961
)
5062
emit_l3_body(app, infos, sig_to_id, graphs={"cfg", "dfg", "pdg"})
51-
fn = next(iter(mod.functions.values()))
52-
assert any(k.endswith("@entry") for k in fn.body)
53-
assert any(k.endswith("@exit") for k in fn.body)
63+
64+
# body keys are LOCAL: "@entry"/"@exit" bookends + bare "line:col" stmts,
65+
# never the full "<callable-id>@..." form.
66+
assert "@entry" in fn.body
67+
assert "@exit" in fn.body
68+
assert any(k not in ("@entry", "@exit") and ":" in k for k in fn.body)
69+
assert not any(k.startswith("can://") for k in fn.body)
70+
# the L1 call node is PRESERVED under its local key (not duplicated, not
71+
# re-keyed): still kind=="call" with its L2-resolved callee.
72+
assert fn.body[call_key].kind == "call"
73+
assert fn.body[call_key].callee == "m.g"
74+
5475
assert len(fn.cfg) > 0
55-
# every cfg endpoint resolves to a body node id
76+
# every cfg endpoint resolves to a (local) body node id
5677
body_ids = set(fn.body)
5778
for e in fn.cfg:
5879
assert e.src in body_ids and e.dst in body_ids

0 commit comments

Comments
 (0)