From 41cb70a795ad81f784902013f24f76a319541523 Mon Sep 17 00:00:00 2001 From: Rahul Krishna Date: Thu, 16 Jul 2026 22:23:23 -0400 Subject: [PATCH] fix(neo4j): derive the code property from module source spans (#104) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Schema v2 removed the per-node code field (module source is stored once, sliced by spans), but the Neo4j projection still read it via getattr(..., 'code', None) — so every :PyClass and :PyCallable node was written without code, deadening the py_code_fts fulltext index and the python-sdk's RETURN c.code queries. Thread the owning module's source through the declaration walk and slice it by each node's utf-8 byte span at projection time, restoring the declared graph contract without touching analysis.json. --- CHANGELOG.md | 9 +++++ codeanalyzer/neo4j/project.py | 43 ++++++++++++++++-------- test/test_v2_two_projection_agreement.py | 38 +++++++++++++++++++++ 3 files changed, 76 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c2ab57..a80e3f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/codeanalyzer/neo4j/project.py b/codeanalyzer/neo4j/project.py index e44f948..672ee80 100644 --- a/codeanalyzer/neo4j/project.py +++ b/codeanalyzer/neo4j/project.py @@ -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) @@ -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) @@ -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) @@ -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( @@ -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, @@ -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, @@ -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, diff --git a/test/test_v2_two_projection_agreement.py b/test/test_v2_two_projection_agreement.py index 7d8ffd5..332e35c 100644 --- a/test/test_v2_two_projection_agreement.py +++ b/test/test_v2_two_projection_agreement.py @@ -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