fix(hooks): install a post-merge hook to heal symbols the union merge driver resurrects - #3556
ayushcodes10 wants to merge 12 commits into
Conversation
Toward issue 2418: the union merge driver can resurrect a symbol deleted on one side of a merge, and neither installed hook ever runs an unscoped rebuild to heal it afterward, so the phantom node persists indefinitely. This adds the start and end markers a new hook that runs after a merge will use, with no behavior change yet; the hook script and its install and uninstall and status wiring land in the next commits. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017qfdzgbA5KedGEjD1AayNh
Mirrors the checkout hook's structure and reuses its unscoped rebuild body (that body already runs with no changed_paths), since the whole point of this hook is that a merge can touch symbols in files the merge diff never names, so scoping the rebuild to changed files would miss the exact resurrection this hook exists to heal. Also carries the same rebase and merge state guard the commit and checkout hooks already have, and the same GRAPHIFY_SKIP_HOOK opt out, matching their defensive posture even though this hook only ever fires after a merge has already completed cleanly. Not wired into install, uninstall, or status yet; that lands next. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017qfdzgbA5KedGEjD1AayNh
Fixes issue 2418. install() now writes the merge hook alongside the commit and checkout hooks, using the same _install_hook path so idempotent re install and appending to an existing hook file both work exactly like the other two hooks. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017qfdzgbA5KedGEjD1AayNh
uninstall() now removes the merge hook alongside the commit and checkout hooks, using the same _uninstall_hook path so it is cleaned up correctly whether it was the only content in the file, appended to existing content, or already absent. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017qfdzgbA5KedGEjD1AayNh
status() now reports whether the merge hook is installed alongside the commit and checkout hooks, reusing the same viz limit staleness check the other two hooks already get. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017qfdzgbA5KedGEjD1AayNh
Keeps both listings accurate now that hook install writes three hooks instead of two. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017qfdzgbA5KedGEjD1AayNh
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017qfdzgbA5KedGEjD1AayNh
Mirrors the existing checkout hook test group: created with the right marker on install, executable, and cleanly removed on uninstall. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017qfdzgbA5KedGEjD1AayNh
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017qfdzgbA5KedGEjD1AayNh
Adding it to _HOOK_SCRIPTS extends 8 existing parametrized tests to the new hook for free: no nohup/setsid/disown, cross platform detach, Windows worker limiting, GIT_DIR reuse, GRAPHIFY_SKIP_HOOK handling, shell quote safety, valid Python payloads, and the linked worktree skip. All 8 pass, confirming the merge hook script was built from the same verified building blocks as the other two. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017qfdzgbA5KedGEjD1AayNh
This is the property the whole fix exists for, tested directly against _rebuild_code rather than the shell script: a node shaped like a genuine merge driver resurrection (same _origin and source_location fields a real prior AST extraction carries) survives a rebuild scoped to an unrelated changed file, exactly like the scoped commit hook would run, and is dropped by an unscoped rebuild, exactly what the new merge hook runs. Building the phantom without those fields looked like it reproduced the healing on the first try, but it was actually hitting a different path: a node with no _origin/source_location classifies as semantic tier rather than AST tier, and the AST only rebuild preserves semantic tier nodes unconditionally regardless of scope, so that shape healed for the wrong reason and would have passed even against a hook that did nothing. Shaping the phantom to match a real AST node closes that gap. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017qfdzgbA5KedGEjD1AayNh
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017qfdzgbA5KedGEjD1AayNh
There was a problem hiding this comment.
Graphify reviewed this change.
Looks safe to merge — no coupling regressions and no blocking issues, checked against the code graph (not a self-assessment).
Formal verification. 1 change(s) tested, no difference found (not proven).
Graphify review — findings
Adds a post-merge git hook that graphify hook install now writes (and uninstall/status manage) alongside the existing post-commit and post-checkout hooks. On a completed merge it launches a detached, unscoped code-only rebuild so a symbol resurrected by the union merge driver on one side gets re-derived and dropped rather than persisting until that file is touched again (#2418). It honors the same guards as the other hooks — only runs when graphify-out/ exists, bails out mid-rebase/cherry-pick or with GRAPHIFY_SKIP_HOOK=1, and pins PYTHONHASHSEED=0 plus single-worker on Windows/MSYS.
No blocking issues surfaced.
Analysis details — impact, health, verification
Impact & health
Graphify review
Impact — 983 functions depend on the 789 functions this change touches.
Health — this change adds coupling hotspots:
- new:
install()— 41 callers, 7 callees - new:
dispatch_command()— 2 callers, 124 callees - new:
dispatch_install_cli()— 2 callers, 31 callees - new:
uninstall()— 11 callers, 5 callees - new:
status()— 9 callers, 6 callees - new:
uninstall_all()— 2 callers, 13 callees - new:
test_poisoned_manifest_is_healed()— 0 callers, 6 callees
Verification — 983 functions in the blast radius were not formally verified this run (proofs are advisory here).
Gate & verification
graphify gate
PASS — objectively clean (no health regressions, tests not run — proofs not run this pass (advisory)). Grounded, not self-assessed.
Advisory (not blocking):
- verification_scope: 983 function(s) in the blast radius were not formally verified this run
Test selection
Test selection
275 of 275 test file(s) selected (100%) via static blast radius.
Escalated to a full run for safety — the selection is not trustworthy on its own (see below). CI should run the whole suite.
tests/test_affected_cli.py— impact, full-run-safetytests/test_affected_member_seed.py— full-run-safetytests/test_agents_platform.py— impact, full-run-safetytests/test_analyze.py— full-run-safetytests/test_anthropic_custom_endpoint.py— full-run-safetytests/test_antigravity_install.py— full-run-safetytests/test_apm_fallback_version.py— full-run-safetytests/test_architecture_doc.py— full-run-safetytests/test_astro_extraction.py— full-run-safetytests/test_astro_import_ids.py— full-run-safetytests/test_atomic_canvas_export.py— full-run-safetytests/test_atomic_version_stamp.py— full-run-safetytests/test_atomic_writes.py— full-run-safetytests/test_backend_env_isolation.py— full-run-safetytests/test_backend_extras.py— full-run-safetytests/test_benchmark.py— full-run-safetytests/test_benchmark_raw_graph.py— full-run-safetytests/test_build.py— full-run-safetytests/test_build_merge_dedup_scope.py— full-run-safetytests/test_build_merge_hyperedges_and_prune.py— full-run-safetytests/test_build_merge_shrink_guard.py— full-run-safetytests/test_builtin_global_type_refs.py— full-run-safetytests/test_cache.py— full-run-safetytests/test_callflow_html.py— full-run-safetytests/test_cargo_introspect.py— full-run-safetytests/test_carried_hyperedge_remap.py— full-run-safetytests/test_case_sensitive_resolution.py— full-run-safetytests/test_charmap_encoding.py— full-run-safetytests/test_chunking.py— full-run-safetytests/test_cjs_module_extension.py— full-run-safetytests/test_claude_cli_backend.py— full-run-safetytests/test_claude_md.py— full-run-safetytests/test_cli_broken_pipe.py— full-run-safetytests/test_cli_export.py— full-run-safetytests/test_cli_help.py— full-run-safetytests/test_cluster.py— full-run-safetytests/test_codebuddy.py— impact, full-run-safetytests/test_community_hub_labels.py— full-run-safetytests/test_community_labels_skill.py— full-run-safetytests/test_confidence.py— full-run-safetytests/test_corrupt_graph_json.py— full-run-safetytests/test_cpp_nested_and_cli.py— full-run-safetytests/test_cpp_objc_cross_file_calls.py— full-run-safetytests/test_cpp_preprocess.py— full-run-safetytests/test_cross_extension_reexport_self_cycle.py— full-run-safetytests/test_cross_language_call_resolution.py— full-run-safetytests/test_cross_repo_member_calls.py— full-run-safetytests/test_cross_repo_shared_types.py— full-run-safetytests/test_csharp_call_site_generic_args.py— full-run-safetytests/test_csharp_enum_members.py— full-run-safety- … and 225 more
non-code file(s) changed (
CHANGELOG.md,README.md) → running the full suite for safety (a code graph can't see config/fixture/data deps)
changed code file(s) with no mapped test (
CHANGELOG.md,README.md) — a coverage gap or a missing link — running the full suite rather than only the selected tests
Selection is safe under the controlled-regression assumption; always-run tests + a periodic full run are the backstops. Advisory — it never changes the check verdict.
Formal verification
Could not verify: Could not verify install.
The verifier did not have enough to check install, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: parameter `path` is annotated `Path` — outside the synthesizable primitive/collection set
Could not verify: Could not verify status.
The verifier did not have enough to check status, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: parameter `path` is annotated `Path` — outside the synthesizable primitive/collection set
Could not verify: Could not verify uninstall.
The verifier did not have enough to check uninstall, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: parameter `path` is annotated `Path` — outside the synthesizable primitive/collection set
No difference found (not proven): No behavior difference found in \_run\_cli (not a proof).
The verifier ran both versions of \_run\_cli on many inputs and saw identical behavior every time. Strong evidence the change is safe, but evidence, not a proof.
Guarantee: Empirical: differential testing (both versions run on many generated inputs). A divergence on an untested input remains possible, so this is 'no counterexample found', not 'proven equivalent'.
Note: An input the sampler did not try could still differ.
· 3 grounded finding(s) anchored inline below; 4 more finding(s) on lines outside this diff (see the check run).
| @@ -847,7 +903,7 @@ def _user_hooks_dir(hooks_dir: Path) -> Path: | |||
|
|
|||
|
|
|||
| def install(path: Path = Path(".")) -> str: | |||
There was a problem hiding this comment.
install()
fans out to 7 callees (efferent coupling); 41 callers depend on it (afferent coupling).
Grounded coupling-delta finding (deterministic), not an LLM guess.
| ) | ||
|
|
||
|
|
||
| def uninstall(path: Path = Path(".")) -> str: |
There was a problem hiding this comment.
uninstall()
11 callers depend on it (afferent coupling).
Grounded coupling-delta finding (deterministic), not an LLM guess.
| ) | ||
|
|
||
|
|
||
| def status(path: Path = Path(".")) -> str: |
There was a problem hiding this comment.
status()
fans out to 6 callees (efferent coupling); 9 callers depend on it (afferent coupling).
Grounded coupling-delta finding (deterministic), not an LLM guess.
Fixes #2418.
Summary
graphify hook installregisters a git merge driver forgraph.jsonthat union-merges both sides of a merge (nx.compose), which by design discards deletions made on either side — a reasonable trade to avoid conflict markers. The problem: nothing in the installed hook set ever runs the unscoped rebuild that would heal the resulting damage. The post-commit hook only rebuilds the files listed inGRAPHIFY_CHANGED, so a symbol resurrected inside a file that isn't touched again persists ingraph.jsonindefinitely, reported atEXTRACTEDconfidence with a realfile:line— indistinguishable from a real node inexplain/query/affectedoutput.The issue reporter traced this precisely (exact
hooks.pyline references) and proposed three fix options, explicitly calling one "the smallest change": install apost-mergehook (git's own hook for exactly this trigger point — fires after a merge completes cleanly) that runs an unscoped rebuild, matching the existingpost-checkoutpattern (branch switches already get a full, not scoped, rebuild for the same reason: arbitrary files can change with no single "changed_paths" list to scope to).Changes
graphify/hooks.py: new_MERGE_SCRIPT, built from the same shared building blocks as the other two hooks (_WORKTREE_GUARD,_PYTHON_DETECT,_detached_launch), reusing_REBUILD_BODY_CHECKOUTverbatim since it already does exactly what's needed — an unscoped_rebuild_code(_root, force=_force). Wired intoinstall(),uninstall(), andstatus()alongside the existing two hooks.README.md/graphify/__main__.py: updated the two places that listed which hookshook installwrites.tests/test_hooks.py: dedicated install/uninstall/status tests for the new hook, plus adding it to the shared_HOOK_SCRIPTSparametrized list, which extends 8 existing generic hook-script tests (no nohup, cross-platform detach, Windows worker limiting, GIT_DIR reuse,GRAPHIFY_SKIP_HOOKhandling, shell-quote safety, valid-Python payloads, linked-worktree skip) to it for free.tests/test_watch.py: an end-to-end test proving the actual property the fix exists for — a node shaped like a genuine merge-driver resurrection (matching a real AST node's_origin/source_locationfields) survives a rebuild scoped to an unrelated file (what the commit hook would run) and is dropped by an unscoped rebuild (what the new merge hook runs). Worth noting: an earlier draft of this test used a phantom node with no_origin/source_locationfields and appeared to pass on the first try, but for the wrong reason — such a node classifies as semantic-tier rather than AST-tier, and the AST-only rebuild preserves semantic-tier nodes unconditionally regardless of scope, so it would have "healed" even against a no-op hook. Shaping the phantom to match a real AST node closes that gap.CHANGELOG.md: entry under 0.9.61 (unreleased).Testing
python -m pytest tests/test_hooks.py tests/test_watch.py -q— all passpython -m pytest -q— 5498 passed, 68 skipped (pre-existing unrelated failures excluded:tests/test_ollama_retry_cap.pymissing the optionalopenaimodule, andtest_ts_normalizer_scales_linearly_on_large_files/test_hyperedge_convex_hull_js_is_geometrically_sound, both known environment-specific flakes unrelated to this change)python -m tools.skillgen --check— OK🤖 Generated with Claude Code
https://claude.ai/code/session_017qfdzgbA5KedGEjD1AayNh