fix(build_merge): replace re-extracted files instead of accumulating stale edges (#1344)

build_merge: prune a re-extracted file's stale nodes/edges before merge instead of accumulating (fixes #1283, #1285). Validated: full suite 2107 passed. Thanks @RelywOo.
This commit is contained in:
RelywOo
2026-06-17 10:46:15 +01:00
committed by GitHub
parent f117aaccc6
commit fd463deb03
2 changed files with 89 additions and 4 deletions
+37 -4
View File
@@ -443,8 +443,11 @@ def build_merge(
) -> nx.Graph:
"""Load existing graph.json, merge new chunks into it, and save back.
Never replaces - only grows (or prunes deleted-file nodes via prune_sources).
Safe to call repeatedly: existing nodes and edges are preserved.
Re-extracted files REPLACE their prior contribution: any source_file present
in new_chunks is dropped from the loaded graph before merging, so a changed
file's stale nodes/edges don't accumulate. Files absent from new_chunks are
preserved unchanged; deleted files are removed via prune_sources.
Safe to call repeatedly.
root: if given, absolute source_file paths in new_chunks are made relative (#932).
"""
graph_path = Path(graph_path)
@@ -462,10 +465,40 @@ def build_merge(
links_key = "links" if "links" in data else "edges"
existing_nodes = list(data.get("nodes", []))
existing_edges = list(data.get(links_key, []))
base = [{"nodes": existing_nodes, "edges": existing_edges}]
had_graph = True
else:
existing_nodes = []
base = []
existing_edges = []
had_graph = False
# Re-extracted files REPLACE their prior contribution. Every source_file
# present in new_chunks is dropped from the loaded base before merging, so a
# CHANGED file's stale nodes/edges don't accumulate across incremental
# updates. Without this, build() merges old+new for the same file and only
# exact-duplicate edges collapse — edges/nodes that disappeared from the new
# version survive forever. Brand-new files aren't in base, so this is a no-op
# for them; genuinely deleted files are still handled via prune_sources.
# Matched in both raw and _norm_source_file form because new_chunks may carry
# absolute win32 paths while the stored graph keeps relative posix (#1007).
_replace_root = str(Path(root).resolve()) if root is not None else None
new_sources: set[str] = set()
for ch in new_chunks:
for n in ch.get("nodes", []):
sf = n.get("source_file")
if not sf:
continue
new_sources.add(sf)
norm = _norm_source_file(sf, _replace_root)
if norm:
new_sources.add(norm)
if new_sources:
def _kept(item: dict) -> bool:
sf = item.get("source_file")
return sf not in new_sources and _norm_source_file(sf, _replace_root) not in new_sources
existing_nodes = [n for n in existing_nodes if _kept(n)]
existing_edges = [e for e in existing_edges if _kept(e)]
base = [{"nodes": existing_nodes, "edges": existing_edges}] if had_graph else []
all_chunks = base + list(new_chunks)
G = build(all_chunks, directed=directed, dedup=dedup, dedup_llm_backend=dedup_llm_backend, root=root)