Skip to content

Commit 397a778

Browse files
HumanBean17claude
andauthored
fix: preserve dependent nodes in incremental graph rebuild (#305) (#311)
* fix: preserve dependent nodes in incremental graph rebuild (#305) _delete_file_scope deleted Symbol nodes for both changed and dependent files during incremental rebuild. Dependent nodes can have incoming CALLS edges from out-of-scope callers: the orchestrator expands dependents from changed nodes only, so callers OF dependent nodes are never pulled into scope, and source_file on an edge is the caller's file, so Phase 1's outgoing-edge delete left those incoming edges in place. The plain DELETE then crashed ('Node ... has connected edges in table CALLS in the bwd direction') and the rebuild fell back to full every time. Delete Symbol nodes only for changed files. Dependent nodes are kept and re-MERGEd in place on their deterministic symbol_id, so their incoming edges from out-of-scope callers survive (no crash, no silent edge loss). Phase 3 uses DETACH DELETE for changed nodes as a safety net. No schema or ontology change, so no re-index burden. Fixes #305 Co-Authored-By: Claude <noreply@anthropic.com> * extract _SYMBOL_TO_SYMBOL_EDGE_TYPES; document edge-list invariant Address PR #311 review feedback (non-blocking follow-ups): - Extract the `_SYMBOL_TO_SYMBOL_EDGE_TYPES` module constant and derive `_find_dependents` from it. The #305 fix's safety rests on `_find_dependents` enumerating EVERY Symbol->Symbol edge type (so every real caller of a changed node enters scope and Phase 1 removes its edge before the node delete). The named constant plus cross-references in the Phase 1 and Phase 3 comments make that invariant enforced/documented by construction, instead of two parallel hand-maintained lists (6 vs 12 types) silently needing to stay in sync. - Phase 2: comment why deleting UnresolvedCallSite children of preserved dependents is safe (scope files, dependents included, are reprocessed and re-emit them in `_scoped_write`). No behavior change; tests unchanged and still green. Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent b8c349b commit 397a778

2 files changed

Lines changed: 282 additions & 29 deletions

File tree

build_ast_graph.py

Lines changed: 79 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -588,15 +588,25 @@ def _load_existing_members(conn: ladybug.Connection, tables: GraphTables, exclud
588588
))
589589

590590

591+
# Every Symbol->Symbol REL TABLE type in the graph schema. A Symbol node can
592+
# only have an INCOMING edge of one of these types, so `_find_dependents` MUST
593+
# walk all of them: that completeness is what makes the changed-node DETACH
594+
# DELETE in `_delete_file_scope` Phase 3 safe (every real caller of a changed
595+
# node is pulled into scope, so Phase 1 removes the edge before the node delete).
596+
# If you add a new Symbol->Symbol edge type to the schema, add it here too —
597+
# otherwise changed-node deletion would silently drop its surviving edges.
598+
_SYMBOL_TO_SYMBOL_EDGE_TYPES = (
599+
"EXTENDS", "IMPLEMENTS", "INJECTS", "CALLS", "DECLARES", "OVERRIDES",
600+
)
601+
602+
591603
def _find_dependents(conn: ladybug.Connection, changed_node_ids: set[str]) -> set[str]:
592604
"""Find files whose nodes have edges pointing into changed nodes. Returns set of filenames."""
593605
dependent_files: set[str] = set()
594606

595-
# Query each Symbol-to-Symbol edge table for incoming edges
596-
edge_types = ["EXTENDS", "IMPLEMENTS", "INJECTS", "CALLS", "DECLARES", "OVERRIDES"]
597607
params = {"changed_ids": list(changed_node_ids)}
598608

599-
for edge_type in edge_types:
609+
for edge_type in _SYMBOL_TO_SYMBOL_EDGE_TYPES:
600610
query = f"""
601611
MATCH (src:Symbol)-[e:{edge_type}]->(dst:Symbol)
602612
WHERE dst.id IN $changed_ids
@@ -612,23 +622,52 @@ def _find_dependents(conn: ladybug.Connection, changed_node_ids: set[str]) -> se
612622
return dependent_files
613623

614624

615-
def _delete_file_scope(conn: ladybug.Connection, filenames: set[str]) -> None:
616-
"""Delete all nodes and edges originating from the given files.
617-
618-
Skip phantom nodes (filename=""). Deletes ALL edge types in Phase 1,
619-
then nodes in subsequent phases. Route/Client/Producer nodes use
620-
DETACH DELETE as a safety net for any edges missed in Phase 1.
621-
622-
Edges are deleted in batch across all filenames first to avoid LadybugDB
623-
"has connected edges" errors when edges from one file point to nodes
624-
in another file within the same scope.
625+
def _delete_file_scope(
626+
conn: ladybug.Connection,
627+
changed_files: set[str],
628+
dependent_files: set[str],
629+
) -> None:
630+
"""Delete nodes and edges for a scope split into changed vs dependent files.
631+
632+
``changed_files`` are files whose content actually changed (added/modified/
633+
removed): their Symbol nodes are deleted (and re-created by ``_scoped_write``).
634+
``dependent_files`` are files pulled in only to re-resolve their OUTGOING
635+
edges against the changed nodes; their node definitions did not change, so
636+
their nodes are deliberately PRESERVED (they re-MERGE in place on the same
637+
deterministic ``symbol_id``). Skipping phantom nodes (filename="").
638+
639+
Why dependents are preserved (issue #305): the orchestrator computes
640+
dependents from the *changed* nodes only, so a dependent file's node can
641+
have an incoming CALLS edge from an out-of-scope caller. The ``source_file``
642+
on every Symbol->Symbol edge is the CALLER's file (pinned by
643+
``test_source_file_value_matches_symbol_filename``), so Phase 1 below only
644+
deletes edges ORIGINATING in scope; incoming edges from out-of-scope callers
645+
survive. If we then tried to DELETE the dependent node, LadybugDB rejects it
646+
("Node ... has connected edges in table CALLS in the bwd direction, ...
647+
Please delete the edges first or try DETACH DELETE") and the rebuild falls
648+
back to a full rebuild. A naive fix (DETACH DELETE on dependents, or an
649+
extra incoming-edge pass) would silence the crash but permanently drop those
650+
out-of-scope edges, corrupting the graph. Preserving dependent nodes keeps
651+
both the nodes and their incoming edges intact.
652+
653+
Phase 1 deletes ALL edge types across the whole scope (changed + dependent)
654+
first to avoid LadybugDB "has connected edges" errors when edges from one
655+
file point to nodes in another file within the same scope. Route/Client/
656+
Producer nodes use DETACH DELETE as a safety net for any edges missed in
657+
Phase 1.
625658
"""
626-
filename_list = list(filenames)
627-
628-
# Phase 1: Delete ALL edges from ALL scope files at once.
629-
# This avoids ordering issues where file A has an edge from file B
630-
# pointing into it; if we delete A's nodes before B's edges, LadybugDB
631-
# raises "has connected edges" errors.
659+
scope_files = changed_files | dependent_files
660+
scope_list = list(scope_files)
661+
changed_list = list(changed_files)
662+
663+
# Phase 1: Delete ALL edges ORIGINATING from any scope file (changed +
664+
# dependent). Because `source_file` is the caller's file, this deletes edges
665+
# whose source is in scope (including dependents' outgoing edges to changed
666+
# nodes) while intentionally leaving incoming edges from out-of-scope callers
667+
# intact — those must survive so the dependent nodes below can be preserved.
668+
# This list is a superset of `_SYMBOL_TO_SYMBOL_EDGE_TYPES` (it also covers
669+
# Symbol->Route/Client/Producer/UCS and Client/Producer->Route edges); keep
670+
# both lists in sync with the schema.
632671
edge_tables = [
633672
"EXTENDS", "IMPLEMENTS", "INJECTS", "CALLS", "DECLARES", "OVERRIDES",
634673
"UNRESOLVED_AT", "EXPOSES", "DECLARES_CLIENT", "DECLARES_PRODUCER",
@@ -640,7 +679,7 @@ def _delete_file_scope(conn: ladybug.Connection, filenames: set[str]) -> None:
640679
WHERE e.source_file IN $filenames
641680
DELETE e
642681
"""
643-
conn.execute(query, {"filenames": filename_list})
682+
conn.execute(query, {"filenames": scope_list})
644683

645684
# Phase 2: Collect all Symbol node IDs for UnresolvedCallSite cleanup.
646685
symbol_ids: list[str] = []
@@ -649,12 +688,15 @@ def _delete_file_scope(conn: ladybug.Connection, filenames: set[str]) -> None:
649688
WHERE s.filename IN $filenames
650689
RETURN s.id
651690
"""
652-
result = conn.execute(symbol_ids_query, {"filenames": filename_list})
691+
result = conn.execute(symbol_ids_query, {"filenames": scope_list})
653692
while result.has_next():
654693
row = result.get_next()
655694
symbol_ids.append(row[0])
656695

657-
# Delete UnresolvedCallSite nodes whose caller_id is in the collected set
696+
# Delete UnresolvedCallSite nodes whose caller_id is in the collected set.
697+
# These are children of scope symbols (including preserved dependents);
698+
# deleting them is safe because every scope file — dependents included — is
699+
# reprocessed and re-emits its UnresolvedCallSite nodes in `_scoped_write`.
658700
if symbol_ids:
659701
unresolved_query = """
660702
MATCH (u:UnresolvedCallSite)
@@ -663,20 +705,30 @@ def _delete_file_scope(conn: ladybug.Connection, filenames: set[str]) -> None:
663705
"""
664706
conn.execute(unresolved_query, {"symbol_ids": symbol_ids})
665707

666-
# Phase 3: Delete Symbol nodes.
708+
# Phase 3: Delete Symbol nodes ONLY for changed files (not dependents).
709+
# Dependent-file nodes are deliberately PRESERVED so their incoming edges
710+
# from out-of-scope callers survive; the dependents are re-MERGEd in place
711+
# by `_scoped_write` on the same deterministic node id. A changed node's
712+
# real incoming edges all come from dependent files (callers pulled into
713+
# scope by `_find_dependents`, which walks every type in
714+
# `_SYMBOL_TO_SYMBOL_EDGE_TYPES`), so Phase 1 already removed them and the
715+
# dependents re-emit them when reprocessed. DETACH DELETE is only a safety
716+
# net for the rare surviving edge whose source was NOT pulled into scope
717+
# (e.g. a phantom caller with filename="", which `_find_dependents` skips);
718+
# such an edge is stale once the node is recreated, so dropping it is fine.
667719
delete_symbols_query = """
668720
MATCH (s:Symbol)
669721
WHERE s.filename IN $filenames
670-
DELETE s
722+
DETACH DELETE s
671723
"""
672-
conn.execute(delete_symbols_query, {"filenames": filename_list})
724+
conn.execute(delete_symbols_query, {"filenames": changed_list})
673725

674726
# Phase 4: Delete Route, Client, Producer nodes.
675727
# Use DETACH DELETE as a safety net in case any edges were missed in Phase 1.
676728
for label in ["Route", "Client", "Producer"]:
677729
conn.execute(
678730
f"MATCH (n:{label}) WHERE n.filename IN $filenames DETACH DELETE n",
679-
{"filenames": filename_list},
731+
{"filenames": scope_list},
680732
)
681733

682734

@@ -3524,7 +3576,7 @@ def incremental_rebuild(
35243576
# Step 4: Scoped deletion
35253577
if verbose:
35263578
_verbose_stderr_line("[increment] deleting outdated nodes and edges")
3527-
_delete_file_scope(conn, scope_files)
3579+
_delete_file_scope(conn, changed_files, dependent_files)
35283580

35293581
# Force deletion to be applied by running a dummy query
35303582
conn.execute("MATCH (s:Symbol) RETURN count(*)")

tests/test_incremental_graph.py

Lines changed: 203 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -738,7 +738,11 @@ def test_find_dependents_returns_incoming_edge_sources(self, tmp_path: Path) ->
738738
conn.close()
739739

740740
def test_delete_file_scope_removes_only_matching(self, tmp_path: Path) -> None:
741-
"""Delete scope for one file, verify other files' nodes/edges untouched."""
741+
"""Delete scope for one file (changed), verify other files' nodes/edges untouched.
742+
743+
Uses the new (changed_files, dependent_files) signature with an empty
744+
dependent set so behavior matches the legacy single-file case.
745+
"""
742746
from build_ast_graph import _delete_file_scope
743747

744748
source_root = tmp_path / "src"
@@ -761,7 +765,7 @@ def test_delete_file_scope_removes_only_matching(self, tmp_path: Path) -> None:
761765
conn.execute("MATCH (s:Symbol) RETURN count(*)")
762766

763767
# Delete only A.java's scope
764-
_delete_file_scope(conn, {"A.java"})
768+
_delete_file_scope(conn, changed_files={"A.java"}, dependent_files=set())
765769

766770
# Verify A's nodes are gone but B's remain
767771
a_result = conn.execute("MATCH (s:Symbol) WHERE s.fqn = 'pkg.A' RETURN count(*)")
@@ -778,3 +782,200 @@ def test_delete_file_scope_removes_only_matching(self, tmp_path: Path) -> None:
778782
assert b_count > 0
779783

780784
conn.close()
785+
786+
def test_delete_file_scope_preserves_dependent_nodes(self, tmp_path: Path) -> None:
787+
"""Direct unit test for the #305 fix.
788+
789+
Build a C -> B -> A call chain (only B is a dependent of changed A; C is
790+
out of scope because it has no edge into A). Then call
791+
_delete_file_scope(changed_files={A}, dependent_files={B}) and assert:
792+
no exception, A's node is gone, B and C nodes are preserved, and the
793+
out-of-scope C->B CALLS edge survives.
794+
"""
795+
from build_ast_graph import _delete_file_scope, write_ladybug
796+
797+
source_root = tmp_path / "src"
798+
source_root.mkdir()
799+
ladybug_path = tmp_path / "code_graph.lbug"
800+
801+
# C calls B.b; B calls A.a. (pass1-3 needed to produce CALLS edges.)
802+
(source_root / "A.java").write_text(
803+
"package pkg; class A { void a() {} }", encoding="utf-8"
804+
)
805+
(source_root / "B.java").write_text(
806+
"package pkg; class B {\n"
807+
" void b() {\n"
808+
" A a = new A();\n"
809+
" a.a();\n"
810+
" }\n"
811+
"}",
812+
encoding="utf-8",
813+
)
814+
(source_root / "C.java").write_text(
815+
"package pkg; class C {\n"
816+
" void c() {\n"
817+
" B b = new B();\n"
818+
" b.b();\n"
819+
" }\n"
820+
"}",
821+
encoding="utf-8",
822+
)
823+
824+
tables = GraphTables()
825+
asts = pass1_parse(source_root, tables, verbose=False)
826+
pass2_edges(tables, asts, verbose=False)
827+
from build_ast_graph import pass3_calls
828+
pass3_calls(tables, asts, verbose=False)
829+
write_ladybug(ladybug_path, tables, source_root=source_root, verbose=False)
830+
831+
db = ladybug.Database(str(ladybug_path))
832+
conn = ladybug.Connection(db)
833+
834+
# Sanity: the C->B CALLS edge must exist for this test to be meaningful.
835+
cb_result = conn.execute(
836+
"MATCH (src:Symbol {fqn: 'pkg.C#c()'})-[e:CALLS]->(dst:Symbol {fqn: 'pkg.B#b()'}) "
837+
"RETURN count(*)"
838+
)
839+
cb_count = 0
840+
if cb_result.has_next():
841+
cb_count = cb_result.get_next()[0]
842+
assert cb_count > 0, "seeded graph must contain a C->B CALLS edge"
843+
844+
# A is changed; B is its dependent; C is out of scope.
845+
_delete_file_scope(
846+
conn, changed_files={"A.java"}, dependent_files={"B.java"}
847+
)
848+
849+
# A's node must be gone.
850+
a_result = conn.execute("MATCH (s:Symbol) WHERE s.fqn = 'pkg.A' RETURN count(*)")
851+
a_count = 0
852+
if a_result.has_next():
853+
a_count = a_result.get_next()[0]
854+
assert a_count == 0
855+
856+
# B and C nodes must survive.
857+
b_result = conn.execute("MATCH (s:Symbol) WHERE s.fqn = 'pkg.B' RETURN count(*)")
858+
c_result = conn.execute("MATCH (s:Symbol) WHERE s.fqn = 'pkg.C' RETURN count(*)")
859+
b_count = 0
860+
c_count = 0
861+
if b_result.has_next():
862+
b_count = b_result.get_next()[0]
863+
if c_result.has_next():
864+
c_count = c_result.get_next()[0]
865+
assert b_count > 0
866+
assert c_count > 0
867+
868+
# The out-of-scope C->B CALLS edge must survive.
869+
cb_after_result = conn.execute(
870+
"MATCH (src:Symbol {fqn: 'pkg.C#c()'})-[e:CALLS]->(dst:Symbol {fqn: 'pkg.B#b()'}) "
871+
"RETURN count(*)"
872+
)
873+
cb_after_count = 0
874+
if cb_after_result.has_next():
875+
cb_after_count = cb_after_result.get_next()[0]
876+
assert cb_after_count > 0
877+
878+
conn.close()
879+
880+
def test_incremental_preserves_incoming_edges_to_dependent(self, tmp_path: Path) -> None:
881+
"""End-to-end repro for GitHub issue #305.
882+
883+
Topology C -> B -> A (C.c calls B.b, B.b calls A.a). Change A.java and run
884+
incremental_rebuild. On the unfixed code the dependent B is pulled into
885+
scope but its out-of-scope caller C is not; the surviving C->B CALLS edge
886+
crashes the dependent node delete and the rebuild falls back to full.
887+
888+
After the fix: mode is "incremental", B's node survives, and the C->B
889+
CALLS edge is preserved.
890+
"""
891+
from build_ast_graph import incremental_rebuild, write_ladybug
892+
893+
source_root = tmp_path / "src"
894+
source_root.mkdir()
895+
index_dir = tmp_path / "index"
896+
index_dir.mkdir()
897+
ladybug_path = index_dir / "code_graph.lbug"
898+
899+
(source_root / "A.java").write_text(
900+
"package pkg; class A { void a() {} }", encoding="utf-8"
901+
)
902+
(source_root / "B.java").write_text(
903+
"package pkg; class B {\n"
904+
" void b() {\n"
905+
" A a = new A();\n"
906+
" a.a();\n"
907+
" }\n"
908+
"}",
909+
encoding="utf-8",
910+
)
911+
(source_root / "C.java").write_text(
912+
"package pkg; class C {\n"
913+
" void c() {\n"
914+
" B b = new B();\n"
915+
" b.b();\n"
916+
" }\n"
917+
"}",
918+
encoding="utf-8",
919+
)
920+
921+
# Initial build (pass1-3 for CALLS edges).
922+
tables = GraphTables()
923+
asts = pass1_parse(source_root, tables, verbose=False)
924+
pass2_edges(tables, asts, verbose=False)
925+
from build_ast_graph import pass3_calls
926+
pass3_calls(tables, asts, verbose=False)
927+
write_ladybug(ladybug_path, tables, source_root=source_root, verbose=False)
928+
929+
# Sanity: C->B CALLS edge must exist in the seeded graph.
930+
db = ladybug.Database(str(ladybug_path))
931+
conn = ladybug.Connection(db)
932+
cb_result = conn.execute(
933+
"MATCH (src:Symbol {fqn: 'pkg.C#c()'})-[e:CALLS]->(dst:Symbol {fqn: 'pkg.B#b()'}) "
934+
"RETURN count(*)"
935+
)
936+
cb_count = 0
937+
if cb_result.has_next():
938+
cb_count = cb_result.get_next()[0]
939+
assert cb_count > 0, "seeded graph must contain a C->B CALLS edge"
940+
conn.close()
941+
942+
# Initialize hash tracker for all files.
943+
tracker = FileHashTracker(index_dir)
944+
ignore = LayeredIgnore(source_root, use_gitignore=False, builtin_patterns=[])
945+
tracker.detect_changes(source_root, ignore)
946+
for rel_path in ["A.java", "B.java", "C.java"]:
947+
tracker.update({rel_path}, source_root)
948+
tracker.save()
949+
950+
# Change A.java.
951+
(source_root / "A.java").write_text(
952+
"package pkg; class A { void a() {} void a2() {} }", encoding="utf-8"
953+
)
954+
955+
result = incremental_rebuild(source_root, ladybug_path, verbose=False)
956+
957+
assert result.mode == "incremental", (
958+
f"expected incremental, got {result.mode!r} (likely the bwd-edge crash)"
959+
)
960+
961+
# B's node and the C->B CALLS edge must survive.
962+
db = ladybug.Database(str(ladybug_path))
963+
conn = ladybug.Connection(db)
964+
b_result = conn.execute(
965+
"MATCH (s:Symbol) WHERE s.fqn = 'pkg.B' RETURN count(*)"
966+
)
967+
b_count = 0
968+
if b_result.has_next():
969+
b_count = b_result.get_next()[0]
970+
assert b_count > 0, "dependent node B must be preserved"
971+
972+
cb_after_result = conn.execute(
973+
"MATCH (src:Symbol {fqn: 'pkg.C#c()'})-[e:CALLS]->(dst:Symbol {fqn: 'pkg.B#b()'}) "
974+
"RETURN count(*)"
975+
)
976+
cb_after_count = 0
977+
if cb_after_result.has_next():
978+
cb_after_count = cb_after_result.get_next()[0]
979+
assert cb_after_count > 0, "out-of-scope C->B CALLS edge must be preserved"
980+
981+
conn.close()

0 commit comments

Comments
 (0)