Skip to content

Commit ddae36c

Browse files
committed
fix(neo4j): namespace the CPG overlay per language (PyCFGNode, PY_* edges)
Unprefixed CFGNode/CFG_NEXT/CDG/DDG/PARAM_IN/PARAM_OUT/SUMMARY would mingle analyzers' dependence edges in a Neo4j database holding more than one language's graph — SDK backends scope queries by label/type prefix. The vocabulary stays cross-language in shape (same suffixes, props, semantics) but is PY_-namespaced in the projection like every other row family; the JSON program_graphs section keeps the unprefixed contract since each analysis.json is its own namespace. Decision recorded in .claude/SCHEMA_DECISIONS.md. (#67)
1 parent ac7acb3 commit ddae36c

7 files changed

Lines changed: 91 additions & 72 deletions

File tree

.claude/SCHEMA_DECISIONS.md

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -48,14 +48,24 @@ additions, all additive:
4848

4949
## Level-3 CPG (Neo4j) — schema.neo4j.json 1.2.0 (additive)
5050

51-
- New label `CFGNode` (merge key `id` = `<signature>#<node_id>`; props
51+
- New label `PyCFGNode` (merge key `id` = `<signature>#<node_id>`; props
5252
`kind`, `var`, `call_node`, `start_line`, `end_line`, `_module`). Both CFG
5353
statements and parameter nodes ride this one label, distinguished by
5454
`kind` — the parity clause's label set stays minimal.
55-
- New edge types `HAS_CFG_NODE` (PyCallable → CFGNode), `CFG_NEXT` (prop
56-
`kind`), `CDG`, `DDG` (prop `var`), `PARAM_IN`, `PARAM_OUT`, `SUMMARY`
57-
deliberately **not** `PY_`-prefixed: this vocabulary is the shared
58-
cross-language CPG contract.
55+
- New edge types `PY_HAS_CFG_NODE` (PyCallable → PyCFGNode), `PY_CFG_NEXT`
56+
(prop `kind`), `PY_CDG`, `PY_DDG` (prop `var`), `PY_PARAM_IN`,
57+
`PY_PARAM_OUT`, `PY_SUMMARY`.
58+
- **Namespacing decision (maintainer, 2026-07-02):** the CPG vocabulary is
59+
cross-language in *shape* (same suffix names, properties, semantics) but
60+
**per-language-prefixed in the Neo4j projection**, like every other row
61+
family (`PySymbol`, `PY_CALLS`, …). Rationale: SDK Neo4j backends scope
62+
queries by label/type prefix; unprefixed `DDG`/`CFGNode` in a database
63+
holding multiple languages' graphs would mingle analyzers' dependence
64+
edges with no way to separate them. Each analyzer uses its language tag
65+
(`TS_`/`TSCFGNode` for TypeScript, etc.). The **JSON** `program_graphs`
66+
section keeps the unprefixed shared vocabulary — it lives inside each
67+
analyzer's own `analysis.json`, so there is no shared namespace to
68+
collide in; the SDK strips/adds the prefix at the projection boundary.
5969
- `CALL` SDG edges are not projected: the callable-level `PY_CALLS` twin
6070
already carries calls; callsite-statement granularity is recoverable via
6171
`PY_HAS_CALLSITE`/`PY_RESOLVES_TO`.

CHANGELOG.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2020
- **Context-sensitive backward slicing** as an SDG query (`codeanalyzer.dataflow.slicing`, HRB
2121
two-phase traversal). Taint is deliberately left to the CLDK SDK — post-SDG it is
2222
language-independent labeled reachability.
23-
- **CPG overlay in the Neo4j projection** at level 3: `CFGNode` nodes plus the shared
24-
cross-language `HAS_CFG_NODE`/`CFG_NEXT`/`CDG`/`DDG`/`PARAM_IN`/`PARAM_OUT`/`SUMMARY` edge
25-
vocabulary. `schema.neo4j.json` bumped additively to **1.2.0**.
23+
- **CPG overlay in the Neo4j projection** at level 3: `PyCFGNode` nodes plus the
24+
`PY_HAS_CFG_NODE`/`PY_CFG_NEXT`/`PY_CDG`/`PY_DDG`/`PY_PARAM_IN`/`PY_PARAM_OUT`/`PY_SUMMARY`
25+
edge vocabulary — cross-language in shape, PY_-namespaced like every other row family so a
26+
multi-language database never mingles analyzers' dependence edges. `schema.neo4j.json`
27+
bumped additively to **1.2.0**.
2628
- **New flags**: `--graphs cfg,dfg,pdg,sdg` (scopes the emitted sections; strict validation —
2729
unknown values or use below `-a 3` exit non-zero) and `--graph-field-depth` (access-path
2830
k-limit, default 3 — the bound that guarantees the interprocedural fixpoint terminates).

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -381,7 +381,9 @@ $ canpy --help
381381
canpy --input ./my-python-project -a 3 --graph-field-depth 2 # tighter access-path k-limit
382382
```
383383
Level 3 also enriches the Neo4j projection (`--emit neo4j`) with the CPG overlay
384-
(`:CFGNode` nodes and `CFG_NEXT`/`CDG`/`DDG`/`PARAM_IN`/`PARAM_OUT`/`SUMMARY` edges).
384+
(`:PyCFGNode` nodes and `PY_CFG_NEXT`/`PY_CDG`/`PY_DDG`/`PY_PARAM_IN`/`PY_PARAM_OUT`/
385+
`PY_SUMMARY` edges — the cross-language dataflow vocabulary, PY_-namespaced like every
386+
other row family so multi-language databases never mingle analyzers' edges).
385387

386388
## Analysis levels
387389

codeanalyzer/neo4j/project.py

Lines changed: 17 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ def project(app: PyApplication, app_name: str) -> GraphRows:
7575
)
7676

7777
# 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.
78+
# projected as :PyCFGNode nodes and PY_-namespaced dependence edge types.
7979
if app.program_graphs is not None:
8080
_project_program_graphs(b, app)
8181

@@ -99,16 +99,18 @@ def _signature_modules(app: PyApplication) -> dict:
9999

100100

101101
def _cfg_node_ref(b: RowBuilder, sig: str, node_id: int) -> NodeRef:
102-
return NodeRef("CFGNode", "id", f"{sig}#{node_id}")
102+
return NodeRef("PyCFGNode", "id", f"{sig}#{node_id}")
103103

104104

105105
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)."""
106+
"""CFG/PDG/SDG rows: node label ``PyCFGNode`` (merge key ``id`` =
107+
``<signature>#<node_id>``) and edge types ``PY_HAS_CFG_NODE`` /
108+
``PY_CFG_NEXT`` (prop ``kind``) / ``PY_CDG`` / ``PY_DDG`` (prop ``var``) /
109+
``PY_PARAM_IN`` / ``PY_PARAM_OUT`` / ``PY_SUMMARY``. The vocabulary is
110+
cross-language in shape but PY_-namespaced like every other row family, so
111+
a multi-language database never mingles analyzers' dependence edges.
112+
Parameter nodes ride the same label with their HRB kinds plus
113+
``var``/``call_node`` props (an additive, recorded extension)."""
112114
pg = app.program_graphs
113115
sig_module = _signature_modules(app)
114116

@@ -117,7 +119,7 @@ def _project_program_graphs(b: RowBuilder, app: PyApplication) -> None:
117119
module = sig_module.get(sig)
118120
for n in (fg.cfg.nodes if fg.cfg else []):
119121
ref = b.node(
120-
["CFGNode"],
122+
["PyCFGNode"],
121123
"id",
122124
f"{sig}#{n.id}",
123125
prune(
@@ -129,10 +131,10 @@ def _project_program_graphs(b: RowBuilder, app: PyApplication) -> None:
129131
}
130132
),
131133
)
132-
b.edge("HAS_CFG_NODE", owner, ref)
134+
b.edge("PY_HAS_CFG_NODE", owner, ref)
133135
for p in fg.param_nodes or []:
134136
ref = b.node(
135-
["CFGNode"],
137+
["PyCFGNode"],
136138
"id",
137139
f"{sig}#{p.id}",
138140
prune(
@@ -146,17 +148,17 @@ def _project_program_graphs(b: RowBuilder, app: PyApplication) -> None:
146148
}
147149
),
148150
)
149-
b.edge("HAS_CFG_NODE", owner, ref)
151+
b.edge("PY_HAS_CFG_NODE", owner, ref)
150152
for e in (fg.cfg.edges if fg.cfg else []):
151153
b.edge(
152-
"CFG_NEXT",
154+
"PY_CFG_NEXT",
153155
_cfg_node_ref(b, sig, e.source),
154156
_cfg_node_ref(b, sig, e.target),
155157
{"kind": e.kind},
156158
)
157159
for e in (fg.pdg.edges if fg.pdg else []):
158160
b.edge(
159-
e.type, # CDG | DDG
161+
f"PY_{e.type}", # PY_CDG | PY_DDG
160162
_cfg_node_ref(b, sig, e.source),
161163
_cfg_node_ref(b, sig, e.target),
162164
prune({"var": e.var}),
@@ -166,7 +168,7 @@ def _project_program_graphs(b: RowBuilder, app: PyApplication) -> None:
166168
if e.type == "CALL":
167169
continue # the callable-level PY_CALLS twin already carries calls
168170
b.edge(
169-
e.type, # PARAM_IN | PARAM_OUT | SUMMARY
171+
f"PY_{e.type}", # PY_PARAM_IN | PY_PARAM_OUT | PY_SUMMARY
170172
_cfg_node_ref(b, e.source.signature, e.source.node),
171173
_cfg_node_ref(b, e.target.signature, e.target.node),
172174
prune({"var": e.var}),

codeanalyzer/neo4j/schema.py

Lines changed: 17 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -176,13 +176,15 @@ 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`.
179+
# Level-3 CPG overlay (present only at -a 3). The dataflow vocabulary is
180+
# shared cross-language in *shape* (same suffixes, props, semantics) but
181+
# namespaced per language like every other row family — a multi-language
182+
# Neo4j database must never mingle one analyzer's dependence edges with
183+
# another's. `id` = "<signature>#<node_id>"; parameter-passing nodes
184+
# (formal/actual in/out) ride the same label with `var`/`call_node`.
183185
NodeLabel(
184-
"CFGNode",
185-
"CFGNode",
186+
"PyCFGNode",
187+
"PyCFGNode",
186188
"id",
187189
{
188190
"id": "string",
@@ -220,14 +222,15 @@ class RelType:
220222
{"imported_names": "string[]", "aliases": "string[]"},
221223
),
222224
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"]),
225+
# Level-3 CPG overlay (-a 3 only): the cross-language dataflow vocabulary,
226+
# PY_-namespaced so per-language SDK backends can scope their queries.
227+
RelType("PY_HAS_CFG_NODE", ["PyCallable"], ["PyCFGNode"]),
228+
RelType("PY_CFG_NEXT", ["PyCFGNode"], ["PyCFGNode"], {"kind": "string"}),
229+
RelType("PY_CDG", ["PyCFGNode"], ["PyCFGNode"]),
230+
RelType("PY_DDG", ["PyCFGNode"], ["PyCFGNode"], {"var": "string"}),
231+
RelType("PY_PARAM_IN", ["PyCFGNode"], ["PyCFGNode"], {"var": "string"}),
232+
RelType("PY_PARAM_OUT", ["PyCFGNode"], ["PyCFGNode"], {"var": "string"}),
233+
RelType("PY_SUMMARY", ["PyCFGNode"], ["PyCFGNode"]),
231234
]
232235

233236

schema.neo4j.json

Lines changed: 23 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -137,8 +137,8 @@
137137
}
138138
},
139139
{
140-
"label": "CFGNode",
141-
"merge_label": "CFGNode",
140+
"label": "PyCFGNode",
141+
"merge_label": "PyCFGNode",
142142
"key": "id",
143143
"properties": {
144144
"id": "string",
@@ -276,80 +276,80 @@
276276
"properties": {}
277277
},
278278
{
279-
"type": "HAS_CFG_NODE",
279+
"type": "PY_HAS_CFG_NODE",
280280
"from": [
281281
"PyCallable"
282282
],
283283
"to": [
284-
"CFGNode"
284+
"PyCFGNode"
285285
],
286286
"properties": {}
287287
},
288288
{
289-
"type": "CFG_NEXT",
289+
"type": "PY_CFG_NEXT",
290290
"from": [
291-
"CFGNode"
291+
"PyCFGNode"
292292
],
293293
"to": [
294-
"CFGNode"
294+
"PyCFGNode"
295295
],
296296
"properties": {
297297
"kind": "string"
298298
}
299299
},
300300
{
301-
"type": "CDG",
301+
"type": "PY_CDG",
302302
"from": [
303-
"CFGNode"
303+
"PyCFGNode"
304304
],
305305
"to": [
306-
"CFGNode"
306+
"PyCFGNode"
307307
],
308308
"properties": {}
309309
},
310310
{
311-
"type": "DDG",
311+
"type": "PY_DDG",
312312
"from": [
313-
"CFGNode"
313+
"PyCFGNode"
314314
],
315315
"to": [
316-
"CFGNode"
316+
"PyCFGNode"
317317
],
318318
"properties": {
319319
"var": "string"
320320
}
321321
},
322322
{
323-
"type": "PARAM_IN",
323+
"type": "PY_PARAM_IN",
324324
"from": [
325-
"CFGNode"
325+
"PyCFGNode"
326326
],
327327
"to": [
328-
"CFGNode"
328+
"PyCFGNode"
329329
],
330330
"properties": {
331331
"var": "string"
332332
}
333333
},
334334
{
335-
"type": "PARAM_OUT",
335+
"type": "PY_PARAM_OUT",
336336
"from": [
337-
"CFGNode"
337+
"PyCFGNode"
338338
],
339339
"to": [
340-
"CFGNode"
340+
"PyCFGNode"
341341
],
342342
"properties": {
343343
"var": "string"
344344
}
345345
},
346346
{
347-
"type": "SUMMARY",
347+
"type": "PY_SUMMARY",
348348
"from": [
349-
"CFGNode"
349+
"PyCFGNode"
350350
],
351351
"to": [
352-
"CFGNode"
352+
"PyCFGNode"
353353
],
354354
"properties": {}
355355
}
@@ -363,7 +363,7 @@
363363
"CREATE CONSTRAINT pycallsite_id IF NOT EXISTS FOR (x:PyCallSite) REQUIRE x.id IS UNIQUE",
364364
"CREATE CONSTRAINT pyattribute_id IF NOT EXISTS FOR (x:PyAttribute) REQUIRE x.id IS UNIQUE",
365365
"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"
366+
"CREATE CONSTRAINT pycfgnode_id IF NOT EXISTS FOR (x:PyCFGNode) REQUIRE x.id IS UNIQUE"
367367
],
368368
"indexes": [
369369
"CREATE INDEX py_callable_name IF NOT EXISTS FOR (c:PyCallable) ON (c.name)",

test/test_dataflow_cpg.py

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
"""Stage-8b gate: the CPG projection of the level-3 graphs.
22
3-
- CFGNode row count equals the JSON section's node count (CFG + parameter
3+
- PyCFGNode row count equals the JSON section's node count (CFG + parameter
44
nodes) — the contract's count-parity assertion;
5-
- every CFG_NEXT/CDG/DDG/PARAM_IN/PARAM_OUT/SUMMARY edge endpoint references
6-
an emitted CFGNode id (deferred-edge/no-dangling gate);
5+
- every PY_CFG_NEXT/PY_CDG/PY_DDG/PY_PARAM_IN/PY_PARAM_OUT/PY_SUMMARY edge
6+
endpoint references an emitted PyCFGNode id (deferred-edge/no-dangling gate);
77
- the Cypher snapshot renders and contains the overlay's vocabulary.
88
99
Loading into a live Neo4j is exercised by the (container-gated) bolt tests;
@@ -21,7 +21,7 @@
2121

2222
FIXTURE = Path(__file__).parent / "fixtures" / "single_functionalities" / "dataflow"
2323

24-
CPG_EDGE_TYPES = {"CFG_NEXT", "CDG", "DDG", "PARAM_IN", "PARAM_OUT", "SUMMARY"}
24+
CPG_EDGE_TYPES = {"PY_CFG_NEXT", "PY_CDG", "PY_DDG", "PY_PARAM_IN", "PY_PARAM_OUT", "PY_SUMMARY"}
2525

2626

2727
@pytest.fixture(scope="module")
@@ -44,31 +44,31 @@ def test_cfg_node_count_matches_the_json_section(level3_app, rows):
4444
len(fg.cfg.nodes if fg.cfg else []) + len(fg.param_nodes or [])
4545
for fg in level3_app.program_graphs.functions.values()
4646
)
47-
emitted = [n for n in rows.nodes if "CFGNode" in n.labels]
47+
emitted = [n for n in rows.nodes if "PyCFGNode" in n.labels]
4848
assert expected > 0
4949
assert len(emitted) == expected
5050

5151

5252
def test_no_dangling_cpg_edge_endpoints(rows):
53-
cfg_ids = {n.value for n in rows.nodes if "CFGNode" in n.labels}
53+
cfg_ids = {n.value for n in rows.nodes if "PyCFGNode" in n.labels}
5454
cpg_edges = [e for e in rows.edges if e.type in CPG_EDGE_TYPES]
5555
assert cpg_edges, "no CPG edges projected"
5656
for e in cpg_edges:
57-
if e.from_ref.label == "CFGNode":
57+
if e.from_ref.label == "PyCFGNode":
5858
assert e.from_ref.value in cfg_ids, e
59-
if e.to_ref.label == "CFGNode":
59+
if e.to_ref.label == "PyCFGNode":
6060
assert e.to_ref.value in cfg_ids, e
6161

6262

6363
def test_every_callable_with_graphs_owns_its_cfg_nodes(level3_app, rows):
64-
has_edges = [e for e in rows.edges if e.type == "HAS_CFG_NODE"]
64+
has_edges = [e for e in rows.edges if e.type == "PY_HAS_CFG_NODE"]
6565
owned = {e.to_ref.value for e in has_edges}
66-
cfg_ids = {n.value for n in rows.nodes if "CFGNode" in n.labels}
66+
cfg_ids = {n.value for n in rows.nodes if "PyCFGNode" in n.labels}
6767
assert owned == cfg_ids, "every CFGNode must be owned by its callable"
6868

6969

7070
def test_cypher_snapshot_renders_the_overlay(level3_app, rows):
7171
cypher = render_cypher(rows, "dataflow-fixture")
72-
assert ":CFGNode" in cypher
72+
assert ":PyCFGNode" in cypher
7373
for t in CPG_EDGE_TYPES:
7474
assert t in cypher, f"{t} missing from the snapshot"

0 commit comments

Comments
 (0)