build_merge backs `graphify --update`. Two incremental-update data bugs:
#1574 — it read only nodes+edges from the existing graph.json, never
hyperedges, and build() only sees the new chunks' hyperedges. So every
--update collapsed the graph's hyperedge set (the highest-signal semantic
groupings) down to just the re-extracted files'. Now existing hyperedges are
carried forward via attach_hyperedges (id-dedup): re-extracted files' prior
hyperedges are replaced by their new version (by source_file), deleted files'
are dropped via the prune set, and unchanged/global ones are preserved. This
mirrors what watch.py already did.
#1571 — when a caller omits `root`, absolute prune_sources (from
detect_incremental) never relativized to the stored relative source_file
keys, so deleted files' nodes survived as ghosts and accumulated across runs.
Added _infer_merge_root: fall back to the committed graphify-out/.graphify_root
marker, else the output dir's parent. This root now drives BOTH the prune set
and the replace-per-source normalization, so both work without an explicit
root. The CLI --update path and all shipped runbooks already pass root; this
hardens the library for any other caller.
5 tests: hyperedge preservation (unchanged/global kept, re-extracted replaced,
with and without root), deleted-file hyperedge prune, and root-less prune via
both the grandparent and .graphify_root-marker fallbacks. Full suite 2742.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>