Skip to content

fix(parsing): emit CALLS edges and code blocks; repair Neo4j 5 schema DDL - #50

Merged
lexasub merged 2 commits into
mainfrom
fix/call-graph-and-block-extraction
Aug 1, 2026
Merged

fix(parsing): emit CALLS edges and code blocks; repair Neo4j 5 schema DDL#50
lexasub merged 2 commits into
mainfrom
fix/call-graph-and-block-extraction

Conversation

@r0h1tb

@r0h1tb r0h1tb commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator

Fixes #49

Problem

The call-graph and block features returned nothing in every language, and no Neo4j 5 constraint or index was ever created. All of it failed silently — ast-rag callers printed No callers found., indexing printed 0 blocks and Done, nothing exited non-zero.

Four independent defects, each covered in detail in #49.

Root cause and fix

1. edge_extractor.py:260 — candidate callers built from an empty list

method_nodes = [n for n in [] if n.kind in (NodeKind.METHOD, ...)]

method_nodes was unconditionally [], so _find_enclosing_callable always returned None and every call site hit continue. extract_edges already receives nodes; it just wasn't passed down (line 84). _extract_containment_edges at line 145 has the identical comprehension written correctly, so this fix restores the intended shape rather than inventing one.

2. block_extractor.py — walk started at the root it then pruned

_manual_extract_blocks began at tree.root_node while rejecting any node outside the target function's byte range. The root spans the whole file, so it returned on the first call and never descended. Now anchors on the function's own subtree via descendant_for_byte_range, which also removes the need for the range check inside the walk. Nesting depth is counted from the function, so a top-level block in a function is depth 1.

3. schema_manager.py — invalid Neo4j 5 DDL

CREATE CONSTRAINT / CREATE INDEX emitted the name after IF NOT EXISTS, which Neo4j 5 rejects with Invalid input '<name>': expected 'FOR' or 'ON'. Name now precedes it, in both helpers.

4. ast_rag_api.py — traversals matched relationship types that are never written

batch_upsert_edges writes (a)-[:EDGE {kind: ...}]->(b), but the call traversals matched :CALLS|VIRTUAL_CALL|LAMBDA_CALL|CROSS_FILE_CALL. Those now match :EDGE and filter on kind via a new CALL_EDGE_KINDS constant. Without this, fixing (1) alone still yields no callers.

Plus two packaging fixes that block the documented install:

  • requirements.txt pinned tree-sitter>=0.22,<0.24 while pyproject.toml requires >=0.24 — mutually exclusive. Aligned to >=0.24.
  • services/__init__.py imported watcher_service eagerly, which imports watchdog at module scope. watchdog ships only in the mcp/dev extras, so the README's pip install -e . produced a CLI that died on --help. The import is now lazy via module __getattr__, which keeps the dependency optional as the extras clearly intend — rather than promoting it to a base dependency.

Scope — deliberately left out

  • Cross-file call resolution. name_to_id is per-file, so only same-file calls resolve. On this repo that's 507 intra-file vs 2521 cross-file call relationships, i.e. most of the call graph is still missing. That's a design change, not a bug fix.
  • The other 18 typed-relationship query sites (INHERITS, OVERRIDES, TYPES, CONTAINS_BLOCK, RELATES, CAPTURES) have the same :EDGE mismatch. I only touched the call path because it's the one I could verify end to end. Aligning the rest vs. changing the writer to emit typed relationships is your call — happy to do either.
  • The ~585-line duplicated block in edge_extractor.py (a second copy of ten methods nested inside _add_type_relation_edges, including a second _extract_call_edges with the same bug). It's unreachable, so I left it rather than bury this fix in a large reformat. Worth deleting separately.
  • The benchmark harness (Call graph, block extraction and Neo4j 5 schema creation all silently produce nothing #49, items 5–6).

Tests

Three new files, plus one marker removal.

tests/test_python_parsing.py::TestPythonCallEdges::test_method_call_edges_extracted already encoded this bug as @pytest.mark.xfail(strict=True) — "known gap: ... CALLS ... not extracted yet". It now XPASSes, so the marker is removed. That test was written against the symptom; the new tests pin the cause.

On this branch with the source changes stashed but the tests kept, 9 tests fail:

FAILED tests/test_call_edges.py::test_call_edges_are_extracted[sample.py-...-python]
FAILED tests/test_call_edges.py::test_call_edges_are_extracted[Sample.java-...-java]
FAILED tests/test_call_edges.py::test_python_blocks_are_extracted
FAILED tests/test_schema_cypher.py::test_create_constraint_places_name_before_if_not_exists
FAILED tests/test_schema_cypher.py::test_create_index_places_name_before_if_not_exists
FAILED tests/test_schema_cypher.py::test_constraint_name_not_immediately_after_if_not_exists
FAILED tests/test_optional_watchdog.py::test_cli_import_does_not_pull_in_watchdog
FAILED tests/test_optional_watchdog.py::test_services_package_imports_without_watchdog
FAILED tests/test_python_parsing.py::TestPythonCallEdges::test_method_call_edges_extracted
9 failed, 20 passed, 1 xfailed

with the literal message on the first being:

AssertionError: no CALLS edges extracted from sample.py; got kinds
['EdgeKind.CONTAINS_FUNCTION', 'EdgeKind.CONTAINS_METHOD', 'EdgeKind.CONTAINS_CLASS']

The watchdog tests were also run with watchdog actually uninstalled, not just mocked — ast-rag --help works, and services.WorkspaceWatcher raises a clear ModuleNotFoundError only when accessed.

Verification

Full suite:

failed passed xfailed
baseline (main, dev extras installed) 3 174 2
this branch 3 184 1

Same three failures before and after, all pre-existing and unrelated:

  • test_update_project_dry_run.py::test_update_project_dry_run and ::test_search_by_signature_formatModuleNotFoundError: No module named 'mcp.server.fastmcp' (FastMCP moved in newer mcp releases)
  • test_unsupported_language.py::test_supported_extensions_grouped_by_language — expects typescript -> ['.ts', '.tsx'], gets ['.ts']; a leftover from cd75313 moving .tsx to a dedicated grammar

The xfail count drops 2 → 1 because the previously-xfailing call-edge test now passes.

End to end, re-indexing this repo against Neo4j 5 + Qdrant:

before after
CALLS edges in graph 0 498
blocks extracted 0 1360
edges extracted 1646 2387
constraint/index creation errors many 0
constraints / indexes present 0 / 0 2 / 10

For retrieval quality I built an independent oracle from Python's stdlib ast — a different parser from tree-sitter — so the system is not graded against its own output, which is the flaw in benchmarks/create_ground_truth.py. Scoring intra-file caller lookup over the 15 most-called single-definition symbols:

symbol                      expected   raged      P      R     F1
----------------------------------------------------------------
_make_sentinel_tree               37      34   0.97   0.89   0.93
_parse                            21      21   1.00   1.00   1.00
_load_config                      18      18   1.00   1.00   1.00
_get_api                          16      16   1.00   1.00   1.00
_write                            15      15   1.00   1.00   1.00
_tmp_db                           11      11   0.82   0.82   0.82
...
----------------------------------------------------------------
MEAN                                           0.99   0.98   0.98

Before the fix this is not 0.98 vs some lower number — there were no CALLS edges in the database at all, so the feature had no output to score.

ruff check is clean on the files I touched (the repo has ~563 pre-existing findings elsewhere, which I left alone), and ruff format is clean on all changed files.

One thing that looks odd, flagged deliberately

I removed a @pytest.mark.xfail(strict=True) from an existing test. That is normally a smell — it can hide a regression. Here the marker's own reason states the gap this PR closes, and strict=True means the suite fails while the marker stays, so it has to go. The behaviour it asserts is additionally covered by the new tests/test_call_edges.py, in both Python and Java.

Happy to split this into separate PRs per defect, or to drop the packaging changes into their own, if you'd prefer smaller reviews.

Four independent defects meant the call graph and block features produced
nothing, silently, in every language.

1. edge_extractor: `_extract_call_edges` built its candidate-caller list from
   an empty list literal (`for n in []`) rather than the `nodes` its caller
   already had, so `_find_enclosing_callable` never resolved a caller and no
   CALLS edge was ever emitted. The sibling `_extract_containment_edges`
   shows the intended pattern verbatim. Threads `nodes` through.

2. block_extractor: `_manual_extract_blocks` started its walk at the tree
   root while pruning anything outside the target function's byte range. The
   root spans the whole file, so it returned on the first call and no block
   was ever extracted. Anchors the walk on the function's own subtree.

3. schema_manager: `CREATE CONSTRAINT` / `CREATE INDEX` emitted the name
   after `IF NOT EXISTS`. Neo4j 5 rejects this with
   `Invalid input '<name>': expected 'FOR' or 'ON'`, so every constraint and
   index silently failed to be created while indexing reported success.

4. api: call traversals matched typed `:CALLS|VIRTUAL_CALL|...` relationships,
   but `batch_upsert_edges` writes generic `:EDGE` carrying a `kind` property.
   Even with extraction fixed, `find_callers`/`find_callees` returned nothing.
   Matches `:EDGE` and filters on `kind`.

Measured on this repo (76 Python files), before -> after:
  CALLS edges in graph        0 -> 498
  blocks extracted            0 -> 1360
  constraint creation errors  many -> 0

Intra-file caller lookup scored against an independent oracle built from
Python's stdlib `ast` (a different parser from tree-sitter): precision 0.99,
recall 0.98, F1 0.98 over 15 symbols. Cross-file call resolution remains
unimplemented -- `name_to_id` is built per file -- and is out of scope here.

tests/test_python_parsing.py carried this bug as a strict xfail
("known gap: ... CALLS ... not extracted yet"); it now XPASSes, so the marker
is removed.

Also aligns requirements.txt (`tree-sitter>=0.22,<0.24`) with pyproject
(`>=0.24`), which were mutually exclusive, and makes the `watchdog` import in
services/__init__ lazy so that the documented `pip install -e .` yields a
working CLI instead of ModuleNotFoundError.

Suite: 3 failed, 184 passed (baseline on main: 3 failed, 174 passed).
The 3 failures are pre-existing and unrelated.
@r0h1tb

r0h1tb commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator Author

CI on this PR is red at the Lint with ruff step, which is pre-existing and unrelated to these changes — main fails identically.

Ruff is unpinned and [tool.ruff] never set select, so a newer ruff widened its default rule set and the tree went from clean to 496 findings with no code change. Running today's ruff against 16ae8bd — the last commit whose CI run was green — also reports 496.

Because lint runs before pytest, the Run tests step is skipped, so CI hasn't actually executed the suite since 2026-07-27.

#51 fixes that and is passing. Once it lands, this PR should go green on a re-run; I've verified all three CI steps locally in the meantime.

lexasub pushed a commit that referenced this pull request Aug 1, 2026
Adds tree-sitter-go and a GO_QUERIES module covering structs, interfaces,
functions, methods, struct fields, imports and calls.

Go models types as `type_declaration -> type_spec` with the concrete shape on
the `type` field, so structs and interfaces are matched on the type_spec
rather than on a dedicated node. Methods carry a `receiver` and are a separate
node type (method_declaration) from plain functions, so Area() lands as METHOD
while describe()/main() land as FUNCTION.

The calls query handles both shapes Go uses -- a bare identifier (describe())
and a selector (fmt.Println()) -- capturing the method name for the latter.
Query names reuse the existing generic mapping in node_extractor, so no
per-language dispatch was needed.

Verified on a sample with an interface, struct, method, two functions and both
call shapes:

  INTERFACE Shape / STRUCT Rect / FUNCTION describe, main / METHOD Area
  FIELD W, H / IMPORTS 1

Call edges additionally require the _extract_call_edges fix from #50; with that
applied locally this sample yields exactly the two intra-file edges
(describe -> Area, main -> describe) and correctly excludes external calls such
as fmt.Println. This PR does not include that fix.

test_unsupported_language.py used .go as its example of an unsupported
extension, which is no longer true -- switched to .rb.

Suite: 3 failed, 188 passed (baseline on main: 3 failed, 174 passed) -- same
three pre-existing failures, fixed separately in #51.
@lexasub

lexasub commented Aug 1, 2026

Copy link
Copy Markdown
Owner

@r0h1tb for others from The other 18 typed-relationship query sites (INHERITS, OVERRIDES, TYPES, CONTAINS_BLOCK, RELATES, CAPTURES) have the same :EDGE mismatch. I only touched the call path because it's the one I could verify end to end. Aligning the rest vs. changing the writer to emit typed relationships is your call — happy to do either. pls, create 1 issue

@lexasub

lexasub commented Aug 1, 2026

Copy link
Copy Markdown
Owner

Good pr!

@lexasub
lexasub merged commit 6f71a9b into main Aug 1, 2026
1 check failed
@github-project-automation github-project-automation Bot moved this from Backlog to Done in raged kanban Aug 1, 2026
@r0h1tb

r0h1tb commented Aug 2, 2026

Copy link
Copy Markdown
Collaborator Author

@lexasub done — filed as #61.

I re-verified it against main before writing it up rather than restating the note from this PR, and the count is 8 read sites, not the 18 I estimated here. :CONTAINS_BLOCK and :RELATES turned out to have their own writers (graph_updater_service.py:857, neo4j_repository.py:329/360), so those queries work — the genuinely dead ones are :INHERITS|EXTENDS|IMPLEMENTS, :OVERRIDES, :TYPES and :CAPTURES.

The issue lays out the two options you'd have to choose between (align readers to [r:EDGE] + WHERE r.kind, vs. make the writer emit typed relationships) with the trade-off on each. I lean toward aligning the readers since it needs no re-index, but it's your call on the graph schema — tell me which and I'll send the PR.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

Call graph, block extraction and Neo4j 5 schema creation all silently produce nothing

2 participants