Load existing graph.json, merge new chunks into it, and save back. 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_ch
(
new_chunks: list[dict],
graph_path: str | Path | None = None,
prune_sources: list[str] | None = None,
*,
directed: bool = False,
dedup: bool = True,
dedup_llm_backend: str | None = None,
root: str | Path | None = None,
)
| 733 | |
| 734 | |
| 735 | def build_merge( |
| 736 | new_chunks: list[dict], |
| 737 | graph_path: str | Path | None = None, |
| 738 | prune_sources: list[str] | None = None, |
| 739 | *, |
| 740 | directed: bool = False, |
| 741 | dedup: bool = True, |
| 742 | dedup_llm_backend: str | None = None, |
| 743 | root: str | Path | None = None, |
| 744 | ) -> nx.Graph: |
| 745 | """Load existing graph.json, merge new chunks into it, and save back. |
| 746 | |
| 747 | Re-extracted files REPLACE their prior contribution: any source_file present |
| 748 | in new_chunks is dropped from the loaded graph before merging, so a changed |
| 749 | file's stale nodes/edges don't accumulate. Files absent from new_chunks are |
| 750 | preserved unchanged; deleted files are removed via prune_sources. |
| 751 | Safe to call repeatedly. |
| 752 | root: if given, absolute source_file paths in new_chunks are made relative (#932). |
| 753 | """ |
| 754 | graph_path = Path(graph_path if graph_path is not None else _default_graph_json()) |
| 755 | if graph_path.exists(): |
| 756 | # Read JSON directly instead of going through node_link_graph(). |
| 757 | # The latter rebuilds an undirected nx.Graph and then enumerating |
| 758 | # edges() yields endpoints based on node insertion order, which |
| 759 | # silently flips directional edges (e.g. `calls`) when the callee |
| 760 | # was inserted before the caller. The _src/_tgt direction-preserving |
| 761 | # attrs are popped before saving in export.py, so going through the |
| 762 | # NetworkX round-trip loses direction permanently (#760). |
| 763 | from graphify.security import check_graph_file_size_cap |
| 764 | check_graph_file_size_cap(graph_path) |
| 765 | try: |
| 766 | data = json.loads(graph_path.read_text(encoding="utf-8")) |
| 767 | except (json.JSONDecodeError, OSError) as exc: |
| 768 | raise RuntimeError( |
| 769 | f"Cannot read {graph_path} for incremental merge: {exc}. " |
| 770 | "Delete the file and run a full rebuild." |
| 771 | ) from exc |
| 772 | links_key = "links" if "links" in data else "edges" |
| 773 | existing_nodes = list(data.get("nodes", [])) |
| 774 | existing_edges = list(data.get(links_key, [])) |
| 775 | existing_hyperedges = list(data.get("hyperedges", [])) |
| 776 | had_graph = True |
| 777 | else: |
| 778 | existing_nodes = [] |
| 779 | existing_edges = [] |
| 780 | existing_hyperedges = [] |
| 781 | had_graph = False |
| 782 | |
| 783 | # Effective root for relativizing absolute source_file / prune paths back to the |
| 784 | # stored relative source_file keys. When the caller passes root we use it; |
| 785 | # otherwise fall back to the graph's recorded scan root, so absolute |
| 786 | # prune_sources and new-chunk paths still match even when a caller omits root |
| 787 | # (#1571 — the skill's --update runbook calls build_merge without root, so |
| 788 | # absolute deleted-file paths never matched the relative node keys and their |
| 789 | # nodes survived as ghosts). |
| 790 | _eff_root = ( |
| 791 | str(Path(root).resolve()) if root is not None |
| 792 | else _infer_merge_root(graph_path) |