Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

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

## [1.0.1] - 2026-07-15

### Fixed
Expand Down
43 changes: 29 additions & 14 deletions codeanalyzer/neo4j/project.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -290,9 +290,11 @@ def _project_module_body(
externals: dict, sig_to_id: dict, module_id_by_key: dict,
) -> None:
for fn in (mod.functions or {}).values():
_project_callable(b, file_key, mod_ref, "PY_DECLARES", fn, externals, sig_to_id)
_project_callable(b, file_key, mod_ref, "PY_DECLARES", fn, externals, sig_to_id,
mod.source)
for cl in (mod.types or {}).values():
_project_class(b, file_key, mod_ref, "PY_DECLARES", cl, externals, sig_to_id)
_project_class(b, file_key, mod_ref, "PY_DECLARES", cl, externals, sig_to_id,
mod.source)
for v in mod.variables or []:
_project_variable(b, file_key, mod_ref, file_key, v)
_project_imports(b, mod_ref, mod, module_id_by_key)
Expand DownExpand Up@@ -360,10 +362,10 @@ def _project_imports(b: RowBuilder, mod_ref: NodeRef, mod: PyModule,

def _project_class(
b: RowBuilder, file_key: str, parent: NodeRef, parent_rel: str, cl: PyClass,
externals: dict, sig_to_id: dict,
externals: dict, sig_to_id: dict, source: str,
) -> None:
ref = b.node(
["PySymbol", "PyClass"], "id", cl.id, _class_props(cl, file_key)
["PySymbol", "PyClass"], "id", cl.id, _class_props(cl, file_key, source)
)
b.edge(parent_rel, parent, ref)

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

for m in (cl.callables or {}).values():
_project_callable(b, file_key, ref, "PY_HAS_METHOD", m, externals, sig_to_id)
_project_callable(b, file_key, ref, "PY_HAS_METHOD", m, externals, sig_to_id,
source)
for a in (cl.attributes or {}).values():
_project_attribute(b, file_key, ref, cl.signature, a)
for ic in (cl.types or {}).values():
_project_class(b, file_key, ref, "PY_DECLARES", ic, externals, sig_to_id)
_project_class(b, file_key, ref, "PY_DECLARES", ic, externals, sig_to_id, source)


def _project_callable(
b: RowBuilder, file_key: str, owner: NodeRef, owner_rel: str, c: PyCallable,
externals: dict, sig_to_id: dict,
externals: dict, sig_to_id: dict, source: str,
) -> None:
ref = b.node(
["PySymbol", "PyCallable"],
"id",
c.id,
_callable_props(c, file_key),
_callable_props(c, file_key, source),
)
b.edge(owner_rel, owner, ref)

Expand All@@ -410,9 +413,10 @@ def _project_callable(
for v in c.local_variables or []:
_project_variable(b, file_key, ref, c.signature, v)
for ic in (c.callables or {}).values():
_project_callable(b, file_key, ref, "PY_DECLARES", ic, externals, sig_to_id)
_project_callable(b, file_key, ref, "PY_DECLARES", ic, externals, sig_to_id,
source)
for cl in (c.types or {}).values():
_project_class(b, file_key, ref, "PY_DECLARES", cl, externals, sig_to_id)
_project_class(b, file_key, ref, "PY_DECLARES", cl, externals, sig_to_id, source)


def _project_attribute(
Expand DownExpand Up@@ -459,13 +463,24 @@ def _module_props(mod: PyModule, file_key: str) -> Props:
)


def _class_props(cl: PyClass, file_key: str) -> Props:
def _span_code(source: str, span) -> str | None:
"""A declaration's text: the owning module's ``source`` sliced by the node's
utf-8 byte span. Schema v2 stores source once per module, so the graph's
``code`` property (declared on :PyClass/:PyCallable and indexed by
``py_code_fts``) is derived here at projection time (#104)."""
if span is None or not source:
return None
lo, hi = span.bytes
return source.encode("utf-8")[lo:hi].decode("utf-8")


def _class_props(cl: PyClass, file_key: str, source: str) -> Props:
return prune(
{
"id": cl.id,
"signature": cl.signature,
"name": cl.name,
"code": getattr(cl, "code", None),
"code": _span_code(source, cl.span),
"base_classes": list(cl.base_classes or []),
"docstring": _docstring_of(cl.comments),
"start_line": cl.start_line,
Expand All@@ -475,7 +490,7 @@ def _class_props(cl: PyClass, file_key: str) -> Props:
)


def _callable_props(c: PyCallable, file_key: str) -> Props:
def _callable_props(c: PyCallable, file_key: str, source: str) -> Props:
return prune(
{
"id": c.id,
Expand All@@ -484,7 +499,7 @@ def _callable_props(c: PyCallable, file_key: str) -> Props:
"path": c.path,
"return_type": c.return_type,
"cyclomatic_complexity": c.cyclomatic_complexity,
"code": getattr(c, "code", None),
"code": _span_code(source, c.span),
"code_start_line": c.code_start_line,
"start_line": c.start_line,
"end_line": c.end_line,
Expand Down
38 changes: 38 additions & 0 deletions test/test_v2_two_projection_agreement.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -207,3 +207,41 @@ def test_l4_param_summary_overlay_projects_onto_pycfgnode(tmp_path):
assert any(
e.type == "PY_DDG" and "prov" in e.props for e in rows.edges
), "expected a PY_DDG edge carrying a prov prop at -a 4"


# ----------------------------------------------------------------------------------------------
# Regression for #104: schema v2 dropped the per-node `code` field (source lives once on
# PyModule.source, sliced by spans), but the graph schema still declares `code` on
# :PyClass / :PyCallable and indexes it (py_code_fts). The projection must therefore
# derive `code` at projection time — module source sliced by the node's byte span —
# or code search in the graph and the SDK's `RETURN c.code` go silently null.
# ----------------------------------------------------------------------------------------------


def test_projected_code_property_is_the_module_source_span_slice():
from sample_graph_app import make_sample_app

app, sig_to_id = make_sample_app()
rows = project(app, "sample-app", sig_to_id)
by_id = {n.value: n for n in rows.nodes}

checked = 0
for mod in app.symbol_table.values():
src = mod.source.encode("utf-8")
stack = list((mod.functions or {}).values()) + list((mod.types or {}).values())
while stack:
decl = stack.pop()
stack += list((decl.callables or {}).values())
stack += list((decl.types or {}).values())
assert decl.span is not None, f"{decl.signature}: span missing at L1+"
lo, hi = decl.span.bytes
expected = src[lo:hi].decode("utf-8")
got = by_id[decl.id].props.get("code")
assert got == expected, (
f"{decl.signature}: projected code must be the span slice of "
f"module.source, got {got!r}"
)
checked += 1
# the sample app has functions, methods, an inner class and a subclass —
# if we checked fewer than that, the walk itself is broken.
assert checked >= 6