Skip to content

Commit cc86883

Browse files
committed
feat(neo4j): re-key L1 nodes onto can:// ids (two-projection agreement)
Key PyModule/PyClass/PyCallable Neo4j nodes on their canonical can:// id (stamped by assign_ids) instead of the file_key / dotted signature, so the JSON and Neo4j projections agree on node identity. Call-graph endpoints for declared symbols resolve through the signature->id map; externals keep their signature-keyed PyExternal identity. Update the declarative schema catalog's merge keys to id (signature/file_key kept as regular props) and thread the id map through emit_neo4j.
1 parent 22e0ca5 commit cc86883

4 files changed

Lines changed: 59 additions & 25 deletions

File tree

codeanalyzer/neo4j/emit.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
from codeanalyzer.neo4j.project import project
3434
from codeanalyzer.options import AnalysisOptions
3535
from codeanalyzer.schema import Analysis
36+
from codeanalyzer.schema.assign_ids import assign_ids
3637
from codeanalyzer.utils import logger
3738

3839

@@ -53,7 +54,11 @@ def emit_neo4j(analysis: Analysis, options: AnalysisOptions) -> None:
5354
"""Project the analysis to a graph and write it: a live Bolt push when
5455
``--neo4j-uri`` is set, otherwise a self-contained ``graph.cypher`` snapshot."""
5556
app_name = options.app_name or Path(options.input).resolve().name
56-
rows = project(analysis.application, app_name)
57+
# ``assign_ids`` is idempotent: it stamps every module/class/callable with its
58+
# canonical ``can://`` id and returns the ``signature -> id`` map the projection
59+
# keys nodes on, so the JSON and Neo4j projections agree.
60+
sig_to_id = assign_ids(analysis.application, app_name)
61+
rows = project(analysis.application, app_name, sig_to_id)
5762

5863
if options.neo4j_uri:
5964
cfg = BoltConfig(

codeanalyzer/neo4j/project.py

Lines changed: 32 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -50,26 +50,25 @@
5050
from codeanalyzer.schema.py_schema import PyCallsite
5151

5252

53-
def project(app: PyApplication, app_name: str) -> GraphRows:
53+
def project(app: PyApplication, app_name: str, sig_to_id: dict) -> GraphRows:
5454
b = RowBuilder()
5555

5656
app_ref = b.node(
5757
["PyApplication"], "name", app_name, {"schema_version": SCHEMA_VERSION}
5858
)
5959

6060
for file_key, mod in app.symbol_table.items():
61-
mod_ref = b.node(
62-
["PyModule"], "file_key", file_key, _module_props(mod, file_key)
63-
)
61+
mod_ref = b.node(["PyModule"], "id", mod.id, _module_props(mod, file_key))
6462
b.edge("PY_HAS_MODULE", app_ref, mod_ref)
6563
_project_module_body(b, file_key, mod_ref, mod)
6664

6765
# The aggregated :PY_CALLS twin. Endpoints listed in app.external_symbols become
68-
# :PyExternal ghost nodes; the rest are declared :PySymbol nodes already emitted.
66+
# :PyExternal ghost nodes; the rest are declared :PySymbol nodes already emitted
67+
# (keyed by their can:// id, resolved through ``sig_to_id``).
6968
externals = app.external_symbols or {}
7069
for e in app.call_graph:
71-
src = _call_endpoint(b, e.source, externals)
72-
tgt = _call_endpoint(b, e.target, externals)
70+
src = _call_endpoint(b, e.source, externals, sig_to_id)
71+
tgt = _call_endpoint(b, e.target, externals, sig_to_id)
7372
b.edge(
7473
"PY_CALLS", src, tgt, _call_edge_props(e.weight, list(e.provenance or []))
7574
)
@@ -172,21 +171,27 @@ def _project_program_graphs(b: RowBuilder, app: PyApplication) -> None:
172171
)
173172

174173

175-
def _sym(signature: str) -> NodeRef:
176-
return NodeRef("PySymbol", "signature", signature)
174+
def _sym(can_id: str) -> NodeRef:
175+
return NodeRef("PySymbol", "id", can_id)
177176

178177

179-
def _call_endpoint(b: RowBuilder, signature: str, externals: dict) -> NodeRef:
180-
"""A call-graph endpoint: a declared callable already emitted, or an external
181-
symbol (imported library / builtin member) materialized as a :PyExternal ghost.
178+
def _call_endpoint(
179+
b: RowBuilder, signature: str, externals: dict, sig_to_id: dict
180+
) -> NodeRef:
181+
"""A call-graph endpoint: a declared callable already emitted (keyed by its
182+
canonical ``can://`` id, resolved through ``sig_to_id``), or an external symbol
183+
(imported library / builtin member) materialized as a :PyExternal ghost.
182184
183185
Classification is authoritative -- it comes from ``app.external_symbols``, not a
184186
"present in the graph" heuristic -- so an imported module name (which exists only
185-
as a :PyPackage) can never shadow the call target. A small fallback still
186-
materializes an external for any endpoint that is neither declared nor listed."""
187+
as a :PyPackage) can never shadow the call target. A declared endpoint resolves to
188+
its ``can://`` id; anything neither declared nor listed falls back to a
189+
signature-keyed :PyExternal ghost rather than raising."""
187190
ext = externals.get(signature)
188-
if ext is None and b.has_key("PySymbol", signature):
189-
return _sym(signature)
191+
if ext is None:
192+
can_id = sig_to_id.get(signature)
193+
if can_id is not None:
194+
return _sym(can_id)
190195
name = (
191196
ext.name
192197
if ext is not None
@@ -254,7 +259,7 @@ def _project_class(
254259
b: RowBuilder, file_key: str, parent: NodeRef, parent_rel: str, cl: PyClass
255260
) -> None:
256261
ref = b.node(
257-
["PySymbol", "PyClass"], "signature", cl.signature, _class_props(cl, file_key)
262+
["PySymbol", "PyClass"], "id", cl.id, _class_props(cl, file_key)
258263
)
259264
b.edge(parent_rel, parent, ref)
260265

@@ -274,8 +279,8 @@ def _project_callable(
274279
) -> None:
275280
ref = b.node(
276281
["PySymbol", "PyCallable"],
277-
"signature",
278-
c.signature,
282+
"id",
283+
c.id,
279284
_callable_props(c, file_key),
280285
)
281286
b.edge(owner_rel, owner, ref)
@@ -334,6 +339,8 @@ def _project_decorator(b: RowBuilder, on: NodeRef, decorator: str) -> None:
334339
def _module_props(mod: PyModule, file_key: str) -> Props:
335340
return prune(
336341
{
342+
"id": mod.id,
343+
"file_key": file_key,
337344
"module_name": mod.module_name,
338345
"content_hash": mod.content_hash,
339346
"last_modified": mod.last_modified,
@@ -346,8 +353,10 @@ def _module_props(mod: PyModule, file_key: str) -> Props:
346353
def _class_props(cl: PyClass, file_key: str) -> Props:
347354
return prune(
348355
{
356+
"id": cl.id,
357+
"signature": cl.signature,
349358
"name": cl.name,
350-
"code": cl.code,
359+
"code": getattr(cl, "code", None),
351360
"base_classes": list(cl.base_classes or []),
352361
"docstring": _docstring_of(cl.comments),
353362
"start_line": cl.start_line,
@@ -360,11 +369,13 @@ def _class_props(cl: PyClass, file_key: str) -> Props:
360369
def _callable_props(c: PyCallable, file_key: str) -> Props:
361370
return prune(
362371
{
372+
"id": c.id,
373+
"signature": c.signature,
363374
"name": c.name,
364375
"path": c.path,
365376
"return_type": c.return_type,
366377
"cyclomatic_complexity": c.cyclomatic_complexity,
367-
"code": c.code,
378+
"code": getattr(c, "code", None),
368379
"code_start_line": c.code_start_line,
369380
"start_line": c.start_line,
370381
"end_line": c.end_line,

codeanalyzer/neo4j/schema.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -72,8 +72,9 @@ class RelType:
7272
NodeLabel(
7373
"PyModule",
7474
"PyModule",
75-
"file_key",
75+
"id",
7676
{
77+
"id": "string",
7778
"file_key": "string",
7879
"module_name": "string",
7980
"content_hash": "string",
@@ -85,8 +86,9 @@ class RelType:
8586
NodeLabel(
8687
"PyClass",
8788
"PySymbol",
88-
"signature",
89+
"id",
8990
{
91+
"id": "string",
9092
"signature": "string",
9193
"name": "string",
9294
"code": "string",
@@ -99,8 +101,9 @@ class RelType:
99101
NodeLabel(
100102
"PyCallable",
101103
"PySymbol",
102-
"signature",
104+
"id",
103105
{
106+
"id": "string",
104107
"signature": "string",
105108
"name": "string",
106109
"path": "string",
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
from codeanalyzer.schema.assign_ids import assign_ids
2+
from codeanalyzer.neo4j.project import project
3+
from codeanalyzer.schema.py_schema import PyApplication, PyModule, PyCallable
4+
5+
6+
def test_neo4j_callable_key_equals_json_id():
7+
fn = PyCallable(name="f", path="m.py", signature="m.f", parameters=[])
8+
mod = PyModule(file_path="m.py", module_name="m", source="def f():\n pass\n",
9+
functions={"f": fn})
10+
app = PyApplication(symbol_table={"m.py": mod})
11+
sig_to_id = assign_ids(app, "myapp")
12+
rows = project(app, "myapp", sig_to_id)
13+
keys = {n.value for n in rows.nodes}
14+
assert fn.id in keys # the callable node is keyed by its can:// id
15+
assert app.symbol_table["m.py"].id in keys

0 commit comments

Comments
 (0)