Skip to content

Commit 3e82256

Browse files
committed
feat(dataflow): stage 8b — CPG projection through the Neo4j emitter
CFGNode label (merge key id = <signature>#<node_id>) carrying both CFG statements and HRB parameter nodes, plus the shared cross-language edge vocabulary HAS_CFG_NODE / CFG_NEXT / CDG / DDG / PARAM_IN / PARAM_OUT / SUMMARY (deliberately unprefixed — parity clause). Additive schema.neo4j.json bump to 1.2.0; sample app extended so the conformance tests exercise every new row family; count-parity and no-dangling gates on the real fixture at -a 3. CALL stays at the callable level (PY_CALLS twin). (#67)
1 parent 6479c04 commit 3e82256

5 files changed

Lines changed: 381 additions & 3 deletions

File tree

codeanalyzer/neo4j/project.py

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,9 +74,105 @@ def project(app: PyApplication, app_name: str) -> GraphRows:
7474
"PY_CALLS", src, tgt, _call_edge_props(e.weight, list(e.provenance or []))
7575
)
7676

77+
# Level-3 CPG overlay (present only at -a 3): the same program_graphs IR
78+
# projected as :CFGNode nodes and the shared cross-language edge types.
79+
if app.program_graphs is not None:
80+
_project_program_graphs(b, app)
81+
7782
return b.finish()
7883

7984

85+
# ----------------------------------------------------------------------------------------------
86+
# Level-3 CPG overlay
87+
# ----------------------------------------------------------------------------------------------
88+
89+
90+
def _signature_modules(app: PyApplication) -> dict:
91+
"""signature → owning module file_key, for CFGNode `_module` provenance."""
92+
from codeanalyzer.semantic_analysis.call_graph import _walk_module_callables
93+
94+
out: dict = {}
95+
for file_key, mod in app.symbol_table.items():
96+
for c in _walk_module_callables(mod):
97+
out[c.signature] = file_key
98+
return out
99+
100+
101+
def _cfg_node_ref(b: RowBuilder, sig: str, node_id: int) -> NodeRef:
102+
return NodeRef("CFGNode", "id", f"{sig}#{node_id}")
103+
104+
105+
def _project_program_graphs(b: RowBuilder, app: PyApplication) -> None:
106+
"""CFG/PDG/SDG rows: node label ``CFGNode`` (merge key ``id`` =
107+
``<signature>#<node_id>``) and edge types ``HAS_CFG_NODE`` / ``CFG_NEXT``
108+
(prop ``kind``) / ``CDG`` / ``DDG`` (prop ``var``) / ``PARAM_IN`` /
109+
``PARAM_OUT`` / ``SUMMARY`` — the shared cross-language vocabulary, so no
110+
``PY_`` prefix. Parameter nodes ride the same label with their HRB kinds
111+
plus ``var``/``call_node`` props (an additive, recorded extension)."""
112+
pg = app.program_graphs
113+
sig_module = _signature_modules(app)
114+
115+
for sig, fg in pg.functions.items():
116+
owner = _sym(sig)
117+
module = sig_module.get(sig)
118+
for n in (fg.cfg.nodes if fg.cfg else []):
119+
ref = b.node(
120+
["CFGNode"],
121+
"id",
122+
f"{sig}#{n.id}",
123+
prune(
124+
{
125+
"kind": n.kind,
126+
"start_line": n.start_line,
127+
"end_line": n.end_line,
128+
"_module": module,
129+
}
130+
),
131+
)
132+
b.edge("HAS_CFG_NODE", owner, ref)
133+
for p in fg.param_nodes or []:
134+
ref = b.node(
135+
["CFGNode"],
136+
"id",
137+
f"{sig}#{p.id}",
138+
prune(
139+
{
140+
"kind": p.kind,
141+
"var": p.var,
142+
"call_node": p.call_node,
143+
"start_line": p.start_line,
144+
"end_line": p.end_line,
145+
"_module": module,
146+
}
147+
),
148+
)
149+
b.edge("HAS_CFG_NODE", owner, ref)
150+
for e in (fg.cfg.edges if fg.cfg else []):
151+
b.edge(
152+
"CFG_NEXT",
153+
_cfg_node_ref(b, sig, e.source),
154+
_cfg_node_ref(b, sig, e.target),
155+
{"kind": e.kind},
156+
)
157+
for e in (fg.pdg.edges if fg.pdg else []):
158+
b.edge(
159+
e.type, # CDG | DDG
160+
_cfg_node_ref(b, sig, e.source),
161+
_cfg_node_ref(b, sig, e.target),
162+
prune({"var": e.var}),
163+
)
164+
165+
for e in pg.sdg_edges:
166+
if e.type == "CALL":
167+
continue # the callable-level PY_CALLS twin already carries calls
168+
b.edge(
169+
e.type, # PARAM_IN | PARAM_OUT | SUMMARY
170+
_cfg_node_ref(b, e.source.signature, e.source.node),
171+
_cfg_node_ref(b, e.target.signature, e.target.node),
172+
prune({"var": e.var}),
173+
)
174+
175+
80176
def _sym(signature: str) -> NodeRef:
81177
return NodeRef("PySymbol", "signature", signature)
82178

codeanalyzer/neo4j/schema.py

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@
3535
from dataclasses import dataclass, field
3636
from typing import Dict, List
3737

38-
SCHEMA_VERSION = "1.1.0"
38+
SCHEMA_VERSION = "1.2.0"
3939

4040
# PropType ∈ {"string", "integer", "float", "boolean", "string[]", "integer[]"}.
4141

@@ -176,6 +176,23 @@ class RelType:
176176
"_module": "string",
177177
},
178178
),
179+
# Level-3 CPG overlay (present only at -a 3). The label and edge types
180+
# below are the shared cross-language dataflow vocabulary — deliberately
181+
# NOT PY_-prefixed. `id` = "<signature>#<node_id>"; parameter-passing
182+
# nodes (formal/actual in/out) ride the same label with `var`/`call_node`.
183+
NodeLabel(
184+
"CFGNode",
185+
"CFGNode",
186+
"id",
187+
{
188+
"id": "string",
189+
"kind": "string",
190+
"var": "string",
191+
"call_node": "integer",
192+
**_SPAN,
193+
"_module": "string",
194+
},
195+
),
179196
]
180197

181198
_DECL_TARGETS = ["PyClass", "PyCallable"]
@@ -203,6 +220,14 @@ class RelType:
203220
{"imported_names": "string[]", "aliases": "string[]"},
204221
),
205222
RelType("PY_DECORATED_BY", ["PyCallable"], ["PyDecorator"]),
223+
# Level-3 CPG overlay (shared cross-language vocabulary, -a 3 only).
224+
RelType("HAS_CFG_NODE", ["PyCallable"], ["CFGNode"]),
225+
RelType("CFG_NEXT", ["CFGNode"], ["CFGNode"], {"kind": "string"}),
226+
RelType("CDG", ["CFGNode"], ["CFGNode"]),
227+
RelType("DDG", ["CFGNode"], ["CFGNode"], {"var": "string"}),
228+
RelType("PARAM_IN", ["CFGNode"], ["CFGNode"], {"var": "string"}),
229+
RelType("PARAM_OUT", ["CFGNode"], ["CFGNode"], {"var": "string"}),
230+
RelType("SUMMARY", ["CFGNode"], ["CFGNode"]),
206231
]
207232

208233

schema.neo4j.json

Lines changed: 95 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{
2-
"schema_version": "1.1.0",
2+
"schema_version": "1.2.0",
33
"generator": "codeanalyzer-python",
44
"marker_labels": [],
55
"node_labels": [
@@ -135,6 +135,20 @@
135135
"end_line": "integer",
136136
"_module": "string"
137137
}
138+
},
139+
{
140+
"label": "CFGNode",
141+
"merge_label": "CFGNode",
142+
"key": "id",
143+
"properties": {
144+
"id": "string",
145+
"kind": "string",
146+
"var": "string",
147+
"call_node": "integer",
148+
"start_line": "integer",
149+
"end_line": "integer",
150+
"_module": "string"
151+
}
138152
}
139153
],
140154
"relationship_types": [
@@ -260,6 +274,84 @@
260274
"PyDecorator"
261275
],
262276
"properties": {}
277+
},
278+
{
279+
"type": "HAS_CFG_NODE",
280+
"from": [
281+
"PyCallable"
282+
],
283+
"to": [
284+
"CFGNode"
285+
],
286+
"properties": {}
287+
},
288+
{
289+
"type": "CFG_NEXT",
290+
"from": [
291+
"CFGNode"
292+
],
293+
"to": [
294+
"CFGNode"
295+
],
296+
"properties": {
297+
"kind": "string"
298+
}
299+
},
300+
{
301+
"type": "CDG",
302+
"from": [
303+
"CFGNode"
304+
],
305+
"to": [
306+
"CFGNode"
307+
],
308+
"properties": {}
309+
},
310+
{
311+
"type": "DDG",
312+
"from": [
313+
"CFGNode"
314+
],
315+
"to": [
316+
"CFGNode"
317+
],
318+
"properties": {
319+
"var": "string"
320+
}
321+
},
322+
{
323+
"type": "PARAM_IN",
324+
"from": [
325+
"CFGNode"
326+
],
327+
"to": [
328+
"CFGNode"
329+
],
330+
"properties": {
331+
"var": "string"
332+
}
333+
},
334+
{
335+
"type": "PARAM_OUT",
336+
"from": [
337+
"CFGNode"
338+
],
339+
"to": [
340+
"CFGNode"
341+
],
342+
"properties": {
343+
"var": "string"
344+
}
345+
},
346+
{
347+
"type": "SUMMARY",
348+
"from": [
349+
"CFGNode"
350+
],
351+
"to": [
352+
"CFGNode"
353+
],
354+
"properties": {}
263355
}
264356
],
265357
"constraints": [
@@ -270,7 +362,8 @@
270362
"CREATE CONSTRAINT pydecorator_name IF NOT EXISTS FOR (x:PyDecorator) REQUIRE x.name IS UNIQUE",
271363
"CREATE CONSTRAINT pycallsite_id IF NOT EXISTS FOR (x:PyCallSite) REQUIRE x.id IS UNIQUE",
272364
"CREATE CONSTRAINT pyattribute_id IF NOT EXISTS FOR (x:PyAttribute) REQUIRE x.id IS UNIQUE",
273-
"CREATE CONSTRAINT pyvariable_id IF NOT EXISTS FOR (x:PyVariable) REQUIRE x.id IS UNIQUE"
365+
"CREATE CONSTRAINT pyvariable_id IF NOT EXISTS FOR (x:PyVariable) REQUIRE x.id IS UNIQUE",
366+
"CREATE CONSTRAINT cfgnode_id IF NOT EXISTS FOR (x:CFGNode) REQUIRE x.id IS UNIQUE"
274367
],
275368
"indexes": [
276369
"CREATE INDEX py_callable_name IF NOT EXISTS FOR (c:PyCallable) ON (c.name)",

test/sample_graph_app.py

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,22 @@
1111
from codeanalyzer.schema import (
1212
PyApplication,
1313
PyCallable,
14+
PyCFG,
15+
PyCFGEdge,
1416
PyClass,
1517
PyClassAttribute,
1618
PyComment,
1719
PyExternalSymbol,
20+
PyFunctionGraphs,
21+
PyGraphNode,
1822
PyImport,
1923
PyModule,
24+
PyParamNode,
25+
PyPDG,
26+
PyPDGEdge,
27+
PyProgramGraphs,
28+
PySDGEdge,
29+
PySDGEndpoint,
2030
PyVariableDeclaration,
2131
)
2232
from codeanalyzer.schema.py_schema import PyCallEdge, PyCallsite
@@ -147,10 +157,90 @@ def make_sample_app() -> PyApplication:
147157
),
148158
]
149159

160+
# A miniature level-3 section exercising every CPG row family:
161+
# helper's CFG (entry → callsite stmt → exit), a CDG/DDG pair, its HRB
162+
# parameter nodes, and PARAM_IN/PARAM_OUT/SUMMARY edges into announce.
163+
helper_graphs = PyFunctionGraphs(
164+
cfg=PyCFG(
165+
nodes=[
166+
PyGraphNode(id=0, kind="entry", start_line=17, end_line=17),
167+
PyGraphNode(id=1, kind="statement", start_line=18, end_line=18),
168+
PyGraphNode(id=2, kind="exit", start_line=20, end_line=20),
169+
],
170+
edges=[
171+
PyCFGEdge(source=0, target=1, kind="fallthrough"),
172+
PyCFGEdge(source=1, target=2, kind="return"),
173+
PyCFGEdge(source=1, target=2, kind="exception"),
174+
],
175+
),
176+
pdg=PyPDG(
177+
edges=[
178+
PyPDGEdge(source=0, target=1, type="CDG"),
179+
PyPDGEdge(source=0, target=1, type="DDG", var="url"),
180+
]
181+
),
182+
param_nodes=[
183+
PyParamNode(id=3, kind="formal_out", var="<return>", start_line=20, end_line=20),
184+
PyParamNode(id=4, kind="actual_in", var="self", call_node=1, start_line=18, end_line=18),
185+
PyParamNode(id=5, kind="actual_out", var="<return>", call_node=1, start_line=18, end_line=18),
186+
],
187+
)
188+
announce_graphs = PyFunctionGraphs(
189+
cfg=PyCFG(
190+
nodes=[
191+
PyGraphNode(id=0, kind="entry", start_line=10, end_line=10),
192+
PyGraphNode(id=1, kind="return", start_line=11, end_line=11),
193+
PyGraphNode(id=2, kind="exit", start_line=12, end_line=12),
194+
],
195+
edges=[
196+
PyCFGEdge(source=0, target=1, kind="fallthrough"),
197+
PyCFGEdge(source=1, target=2, kind="return"),
198+
],
199+
),
200+
pdg=PyPDG(edges=[PyPDGEdge(source=0, target=1, type="CDG")]),
201+
param_nodes=[
202+
PyParamNode(id=3, kind="formal_in", var="self", start_line=10, end_line=10),
203+
PyParamNode(id=4, kind="formal_out", var="<return>", start_line=12, end_line=12),
204+
],
205+
)
206+
program_graphs = PyProgramGraphs(
207+
schema_version="1.0.0",
208+
k_limit=3,
209+
functions={
210+
"src.service.helper": helper_graphs,
211+
"src.service.Service.announce": announce_graphs,
212+
},
213+
sdg_edges=[
214+
PySDGEdge(
215+
source=PySDGEndpoint(signature="src.service.helper", node=1),
216+
target=PySDGEndpoint(signature="src.service.Service.announce", node=0),
217+
type="CALL",
218+
),
219+
PySDGEdge(
220+
source=PySDGEndpoint(signature="src.service.helper", node=4),
221+
target=PySDGEndpoint(signature="src.service.Service.announce", node=3),
222+
type="PARAM_IN",
223+
var="self",
224+
),
225+
PySDGEdge(
226+
source=PySDGEndpoint(signature="src.service.Service.announce", node=4),
227+
target=PySDGEndpoint(signature="src.service.helper", node=5),
228+
type="PARAM_OUT",
229+
var="<return>",
230+
),
231+
PySDGEdge(
232+
source=PySDGEndpoint(signature="src.service.helper", node=4),
233+
target=PySDGEndpoint(signature="src.service.helper", node=5),
234+
type="SUMMARY",
235+
),
236+
],
237+
)
238+
150239
return PyApplication(
151240
symbol_table={"src/service.py": service_mod, "src/util.py": util_mod},
152241
call_graph=call_graph,
153242
# The ghost edge's target (requests.get) is a library member, recorded as a
154243
# first-class external symbol so the projection emits a :PyExternal for it.
155244
external_symbols={"requests.get": PyExternalSymbol(name="get", module="requests")},
245+
program_graphs=program_graphs,
156246
)

0 commit comments

Comments
 (0)