Skip to content

Commit fd63ce5

Browse files
committed
fix(neo4j): resolve PY_EXTENDS/PY_RESOLVES_TO targets to can:// ids (restore dropped edges)
1 parent 0967ee1 commit fd63ce5

3 files changed

Lines changed: 63 additions & 29 deletions

File tree

codeanalyzer/neo4j/project.py

Lines changed: 36 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -57,15 +57,18 @@ def project(app: PyApplication, app_name: str, sig_to_id: dict) -> GraphRows:
5757
["PyApplication"], "name", app_name, {"schema_version": SCHEMA_VERSION}
5858
)
5959

60+
# Endpoints listed in app.external_symbols become :PyExternal ghost nodes; the
61+
# rest are declared :PySymbol nodes emitted here (keyed by their can:// id,
62+
# resolved through ``sig_to_id``). Both the module-body projection (for
63+
# PY_EXTENDS / PY_RESOLVES_TO) and the PY_CALLS twin below share this split.
64+
externals = app.external_symbols or {}
65+
6066
for file_key, mod in app.symbol_table.items():
6167
mod_ref = b.node(["PyModule"], "id", mod.id, _module_props(mod, file_key))
6268
b.edge("PY_HAS_MODULE", app_ref, mod_ref)
63-
_project_module_body(b, file_key, mod_ref, mod)
69+
_project_module_body(b, file_key, mod_ref, mod, externals, sig_to_id)
6470

65-
# The aggregated :PY_CALLS twin. Endpoints listed in app.external_symbols become
66-
# :PyExternal ghost nodes; the rest are declared :PySymbol nodes already emitted
67-
# (keyed by their can:// id, resolved through ``sig_to_id``).
68-
externals = app.external_symbols or {}
71+
# The aggregated :PY_CALLS twin.
6972
for e in app.call_graph:
7073
src = _call_endpoint(b, e.source, externals, sig_to_id)
7174
tgt = _call_endpoint(b, e.target, externals, sig_to_id)
@@ -175,6 +178,16 @@ def _sym(can_id: str) -> NodeRef:
175178
return NodeRef("PySymbol", "id", can_id)
176179

177180

181+
def _symbol_ref(signature: str, externals: dict, sig_to_id: dict) -> NodeRef:
182+
"""Resolve a call/inheritance target to the NodeRef under which it was (or
183+
will be) emitted: a declared symbol by its can:// id, otherwise a
184+
signature-keyed :PySymbol (external ghost)."""
185+
can_id = sig_to_id.get(signature)
186+
if can_id is not None:
187+
return NodeRef("PySymbol", "id", can_id)
188+
return NodeRef("PySymbol", "signature", signature)
189+
190+
178191
def _call_endpoint(
179192
b: RowBuilder, signature: str, externals: dict, sig_to_id: dict
180193
) -> NodeRef:
@@ -212,12 +225,13 @@ def _call_endpoint(
212225

213226

214227
def _project_module_body(
215-
b: RowBuilder, file_key: str, mod_ref: NodeRef, mod: PyModule
228+
b: RowBuilder, file_key: str, mod_ref: NodeRef, mod: PyModule,
229+
externals: dict, sig_to_id: dict,
216230
) -> None:
217231
for fn in (mod.functions or {}).values():
218-
_project_callable(b, file_key, mod_ref, "PY_DECLARES", fn)
232+
_project_callable(b, file_key, mod_ref, "PY_DECLARES", fn, externals, sig_to_id)
219233
for cl in (mod.classes or {}).values():
220-
_project_class(b, file_key, mod_ref, "PY_DECLARES", cl)
234+
_project_class(b, file_key, mod_ref, "PY_DECLARES", cl, externals, sig_to_id)
221235
for v in mod.variables or []:
222236
_project_variable(b, file_key, mod_ref, file_key, v)
223237
_project_imports(b, mod_ref, mod)
@@ -256,26 +270,29 @@ def _project_imports(b: RowBuilder, mod_ref: NodeRef, mod: PyModule) -> None:
256270

257271

258272
def _project_class(
259-
b: RowBuilder, file_key: str, parent: NodeRef, parent_rel: str, cl: PyClass
273+
b: RowBuilder, file_key: str, parent: NodeRef, parent_rel: str, cl: PyClass,
274+
externals: dict, sig_to_id: dict,
260275
) -> None:
261276
ref = b.node(
262277
["PySymbol", "PyClass"], "id", cl.id, _class_props(cl, file_key)
263278
)
264279
b.edge(parent_rel, parent, ref)
265280

266281
for base in cl.base_classes or []:
267-
b.edge_to_symbol("PY_EXTENDS", ref, base)
282+
if base:
283+
b.edge_to_symbol("PY_EXTENDS", ref, _symbol_ref(base, externals, sig_to_id))
268284

269285
for m in (cl.methods or {}).values():
270-
_project_callable(b, file_key, ref, "PY_HAS_METHOD", m)
286+
_project_callable(b, file_key, ref, "PY_HAS_METHOD", m, externals, sig_to_id)
271287
for a in (cl.attributes or {}).values():
272288
_project_attribute(b, file_key, ref, cl.signature, a)
273289
for ic in (cl.inner_classes or {}).values():
274-
_project_class(b, file_key, ref, "PY_DECLARES", ic)
290+
_project_class(b, file_key, ref, "PY_DECLARES", ic, externals, sig_to_id)
275291

276292

277293
def _project_callable(
278-
b: RowBuilder, file_key: str, owner: NodeRef, owner_rel: str, c: PyCallable
294+
b: RowBuilder, file_key: str, owner: NodeRef, owner_rel: str, c: PyCallable,
295+
externals: dict, sig_to_id: dict,
279296
) -> None:
280297
ref = b.node(
281298
["PySymbol", "PyCallable"],
@@ -296,14 +313,17 @@ def _project_callable(
296313
cs = b.node(["PyCallSite"], "id", cs_id, _call_site_props(s, file_key))
297314
b.edge("PY_HAS_CALLSITE", ref, cs)
298315
if s.callee_signature:
299-
b.edge_to_symbol("PY_RESOLVES_TO", cs, s.callee_signature)
316+
b.edge_to_symbol(
317+
"PY_RESOLVES_TO", cs,
318+
_symbol_ref(s.callee_signature, externals, sig_to_id),
319+
)
300320

301321
for v in c.local_variables or []:
302322
_project_variable(b, file_key, ref, c.signature, v)
303323
for ic in (c.inner_callables or {}).values():
304-
_project_callable(b, file_key, ref, "PY_DECLARES", ic)
324+
_project_callable(b, file_key, ref, "PY_DECLARES", ic, externals, sig_to_id)
305325
for cl in (c.inner_classes or {}).values():
306-
_project_class(b, file_key, ref, "PY_DECLARES", cl)
326+
_project_class(b, file_key, ref, "PY_DECLARES", cl, externals, sig_to_id)
307327

308328

309329
def _project_attribute(

codeanalyzer/neo4j/rows.py

Lines changed: 7 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -110,20 +110,15 @@ def edge(self, type_: str, from_ref: NodeRef, to_ref: NodeRef, props: Optional[P
110110
self._edges.append(EdgeRow(type_, from_ref, to_ref, dict(props or {})))
111111

112112
def edge_to_symbol(
113-
self, type_: str, from_ref: NodeRef, target_signature: str, props: Optional[Props] = None
113+
self, type_: str, from_ref: NodeRef, target_ref: NodeRef, props: Optional[Props] = None
114114
) -> None:
115115
"""An edge to a ``:PySymbol`` target that may be external/library code not
116-
present in the graph. Deferred and kept only if the target signature was
117-
actually emitted as a node — so PY_EXTENDS / PY_RESOLVES_TO never dangle (the
118-
string fallback lives on the source node's props)."""
119-
self._deferred.append(
120-
EdgeRow(
121-
type_,
122-
from_ref,
123-
NodeRef("PySymbol", "signature", target_signature),
124-
dict(props or {}),
125-
)
126-
)
116+
present in the graph. The target is an already-resolved :class:`NodeRef`
117+
(a declared symbol by its can:// id, or a signature-keyed external ghost).
118+
Deferred and kept only if that ``(label, value)`` was actually emitted as a
119+
node — so PY_EXTENDS / PY_RESOLVES_TO never dangle (the string fallback lives
120+
on the source node's props)."""
121+
self._deferred.append(EdgeRow(type_, from_ref, target_ref, dict(props or {})))
127122

128123
def has_key(self, label: str, value: str) -> bool:
129124
"""Whether a node with this ``(merge_label, value)`` identity was emitted."""

test/test_v2_two_projection_agreement.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
from codeanalyzer.schema.assign_ids import assign_ids
22
from codeanalyzer.neo4j.project import project
3-
from codeanalyzer.schema.py_schema import PyApplication, PyModule, PyCallable
3+
from codeanalyzer.schema.py_schema import (
4+
PyApplication, PyModule, PyClass, PyCallable, PyCallsite,
5+
)
46

57

68
def test_neo4j_callable_key_equals_json_id():
@@ -13,3 +15,20 @@ def test_neo4j_callable_key_equals_json_id():
1315
keys = {n.value for n in rows.nodes}
1416
assert fn.id in keys # the callable node is keyed by its can:// id
1517
assert app.symbol_table["m.py"].id in keys
18+
19+
20+
def test_py_resolves_to_edge_targets_declared_callee_by_can_id():
21+
callee = PyCallable(name="g", path="m.py", signature="m.g", parameters=[])
22+
cs = PyCallsite(method_name="g", start_line=2, start_column=4, end_line=2,
23+
end_column=7, callee_signature="m.g")
24+
caller = PyCallable(name="f", path="m.py", signature="m.f", parameters=[],
25+
call_sites=[cs])
26+
mod = PyModule(file_path="m.py", module_name="m", source="def f():\n g()\n",
27+
functions={"f": caller, "g": callee})
28+
app = PyApplication(symbol_table={"m.py": mod})
29+
sig_to_id = assign_ids(app, "myapp")
30+
rows = project(app, "myapp", sig_to_id)
31+
resolves = [e for e in rows.edges if e.type == "PY_RESOLVES_TO"]
32+
# the callsite must resolve to g's can:// id — edge kept, not dropped
33+
assert any(e.to_ref.value == sig_to_id["m.g"] for e in resolves), \
34+
"PY_RESOLVES_TO must target the declared callee by can:// id"

0 commit comments

Comments
 (0)