Skip to content

Commit 18e8a1c

Browse files
authored
Merge pull request #105 from codellm-devkit/fix/issue-104-neo4j-code-property
fix(neo4j): derive the code property from module source spans
2 parents c173a5f + 41cb70a commit 18e8a1c

3 files changed

Lines changed: 76 additions & 14 deletions

File tree

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Fixed
11+
- **Neo4j `code` property was silently null** (#104): schema v2 removed the per-node `code`
12+
field (source lives once on `PyModule.source`, sliced by spans), but the Neo4j projection
13+
still read the old field — every `:PyClass`/`:PyCallable` node was written without `code`,
14+
deadening the `py_code_fts` fulltext index and the SDK's `RETURN c.code` queries. The
15+
projection now derives `code` at projection time by slicing the owning module's source
16+
with the node's utf-8 byte span. Regression gate:
17+
`test_projected_code_property_is_the_module_source_span_slice`.
18+
1019
## [1.0.1] - 2026-07-15
1120

1221
### Fixed

codeanalyzer/neo4j/project.py

Lines changed: 29 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -290,9 +290,11 @@ def _project_module_body(
290290
externals: dict, sig_to_id: dict, module_id_by_key: dict,
291291
) -> None:
292292
for fn in (mod.functions or {}).values():
293-
_project_callable(b, file_key, mod_ref, "PY_DECLARES", fn, externals, sig_to_id)
293+
_project_callable(b, file_key, mod_ref, "PY_DECLARES", fn, externals, sig_to_id,
294+
mod.source)
294295
for cl in (mod.types or {}).values():
295-
_project_class(b, file_key, mod_ref, "PY_DECLARES", cl, externals, sig_to_id)
296+
_project_class(b, file_key, mod_ref, "PY_DECLARES", cl, externals, sig_to_id,
297+
mod.source)
296298
for v in mod.variables or []:
297299
_project_variable(b, file_key, mod_ref, file_key, v)
298300
_project_imports(b, mod_ref, mod, module_id_by_key)
@@ -360,10 +362,10 @@ def _project_imports(b: RowBuilder, mod_ref: NodeRef, mod: PyModule,
360362

361363
def _project_class(
362364
b: RowBuilder, file_key: str, parent: NodeRef, parent_rel: str, cl: PyClass,
363-
externals: dict, sig_to_id: dict,
365+
externals: dict, sig_to_id: dict, source: str,
364366
) -> None:
365367
ref = b.node(
366-
["PySymbol", "PyClass"], "id", cl.id, _class_props(cl, file_key)
368+
["PySymbol", "PyClass"], "id", cl.id, _class_props(cl, file_key, source)
367369
)
368370
b.edge(parent_rel, parent, ref)
369371

@@ -372,22 +374,23 @@ def _project_class(
372374
b.edge_to_symbol("PY_EXTENDS", ref, _symbol_ref(base, externals, sig_to_id))
373375

374376
for m in (cl.callables or {}).values():
375-
_project_callable(b, file_key, ref, "PY_HAS_METHOD", m, externals, sig_to_id)
377+
_project_callable(b, file_key, ref, "PY_HAS_METHOD", m, externals, sig_to_id,
378+
source)
376379
for a in (cl.attributes or {}).values():
377380
_project_attribute(b, file_key, ref, cl.signature, a)
378381
for ic in (cl.types or {}).values():
379-
_project_class(b, file_key, ref, "PY_DECLARES", ic, externals, sig_to_id)
382+
_project_class(b, file_key, ref, "PY_DECLARES", ic, externals, sig_to_id, source)
380383

381384

382385
def _project_callable(
383386
b: RowBuilder, file_key: str, owner: NodeRef, owner_rel: str, c: PyCallable,
384-
externals: dict, sig_to_id: dict,
387+
externals: dict, sig_to_id: dict, source: str,
385388
) -> None:
386389
ref = b.node(
387390
["PySymbol", "PyCallable"],
388391
"id",
389392
c.id,
390-
_callable_props(c, file_key),
393+
_callable_props(c, file_key, source),
391394
)
392395
b.edge(owner_rel, owner, ref)
393396

@@ -410,9 +413,10 @@ def _project_callable(
410413
for v in c.local_variables or []:
411414
_project_variable(b, file_key, ref, c.signature, v)
412415
for ic in (c.callables or {}).values():
413-
_project_callable(b, file_key, ref, "PY_DECLARES", ic, externals, sig_to_id)
416+
_project_callable(b, file_key, ref, "PY_DECLARES", ic, externals, sig_to_id,
417+
source)
414418
for cl in (c.types or {}).values():
415-
_project_class(b, file_key, ref, "PY_DECLARES", cl, externals, sig_to_id)
419+
_project_class(b, file_key, ref, "PY_DECLARES", cl, externals, sig_to_id, source)
416420

417421

418422
def _project_attribute(
@@ -459,13 +463,24 @@ def _module_props(mod: PyModule, file_key: str) -> Props:
459463
)
460464

461465

462-
def _class_props(cl: PyClass, file_key: str) -> Props:
466+
def _span_code(source: str, span) -> str | None:
467+
"""A declaration's text: the owning module's ``source`` sliced by the node's
468+
utf-8 byte span. Schema v2 stores source once per module, so the graph's
469+
``code`` property (declared on :PyClass/:PyCallable and indexed by
470+
``py_code_fts``) is derived here at projection time (#104)."""
471+
if span is None or not source:
472+
return None
473+
lo, hi = span.bytes
474+
return source.encode("utf-8")[lo:hi].decode("utf-8")
475+
476+
477+
def _class_props(cl: PyClass, file_key: str, source: str) -> Props:
463478
return prune(
464479
{
465480
"id": cl.id,
466481
"signature": cl.signature,
467482
"name": cl.name,
468-
"code": getattr(cl, "code", None),
483+
"code": _span_code(source, cl.span),
469484
"base_classes": list(cl.base_classes or []),
470485
"docstring": _docstring_of(cl.comments),
471486
"start_line": cl.start_line,
@@ -475,7 +490,7 @@ def _class_props(cl: PyClass, file_key: str) -> Props:
475490
)
476491

477492

478-
def _callable_props(c: PyCallable, file_key: str) -> Props:
493+
def _callable_props(c: PyCallable, file_key: str, source: str) -> Props:
479494
return prune(
480495
{
481496
"id": c.id,
@@ -484,7 +499,7 @@ def _callable_props(c: PyCallable, file_key: str) -> Props:
484499
"path": c.path,
485500
"return_type": c.return_type,
486501
"cyclomatic_complexity": c.cyclomatic_complexity,
487-
"code": getattr(c, "code", None),
502+
"code": _span_code(source, c.span),
488503
"code_start_line": c.code_start_line,
489504
"start_line": c.start_line,
490505
"end_line": c.end_line,

test/test_v2_two_projection_agreement.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,3 +207,41 @@ def test_l4_param_summary_overlay_projects_onto_pycfgnode(tmp_path):
207207
assert any(
208208
e.type == "PY_DDG" and "prov" in e.props for e in rows.edges
209209
), "expected a PY_DDG edge carrying a prov prop at -a 4"
210+
211+
212+
# ----------------------------------------------------------------------------------------------
213+
# Regression for #104: schema v2 dropped the per-node `code` field (source lives once on
214+
# PyModule.source, sliced by spans), but the graph schema still declares `code` on
215+
# :PyClass / :PyCallable and indexes it (py_code_fts). The projection must therefore
216+
# derive `code` at projection time — module source sliced by the node's byte span —
217+
# or code search in the graph and the SDK's `RETURN c.code` go silently null.
218+
# ----------------------------------------------------------------------------------------------
219+
220+
221+
def test_projected_code_property_is_the_module_source_span_slice():
222+
from sample_graph_app import make_sample_app
223+
224+
app, sig_to_id = make_sample_app()
225+
rows = project(app, "sample-app", sig_to_id)
226+
by_id = {n.value: n for n in rows.nodes}
227+
228+
checked = 0
229+
for mod in app.symbol_table.values():
230+
src = mod.source.encode("utf-8")
231+
stack = list((mod.functions or {}).values()) + list((mod.types or {}).values())
232+
while stack:
233+
decl = stack.pop()
234+
stack += list((decl.callables or {}).values())
235+
stack += list((decl.types or {}).values())
236+
assert decl.span is not None, f"{decl.signature}: span missing at L1+"
237+
lo, hi = decl.span.bytes
238+
expected = src[lo:hi].decode("utf-8")
239+
got = by_id[decl.id].props.get("code")
240+
assert got == expected, (
241+
f"{decl.signature}: projected code must be the span slice of "
242+
f"module.source, got {got!r}"
243+
)
244+
checked += 1
245+
# the sample app has functions, methods, an inner class and a subclass —
246+
# if we checked fewer than that, the walk itself is broken.
247+
assert checked >= 6

0 commit comments

Comments
 (0)