Skip to content

fix(hooks): install a post-merge hook to heal symbols the union merge driver resurrects - #3556

Open
ayushcodes10 wants to merge 12 commits into
Graphify-Labs:v8from
ayushcodes10:fix-2418-post-merge-hook
Open

ayushcodes10 wants to merge 12 commits into
Graphify-Labs:v8from
ayushcodes10:fix-2418-post-merge-hook

Conversation

@ayushcodes10

Copy link
Copy Markdown
Contributor

Fixes #2418.

Summary

graphify hook install registers a git merge driver for graph.json that 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 in GRAPHIFY_CHANGED, so a symbol resurrected inside a file that isn't touched again persists in graph.json indefinitely, reported at EXTRACTED confidence with a real file:line — indistinguishable from a real node in explain/query/affected output.

The issue reporter traced this precisely (exact hooks.py line references) and proposed three fix options, explicitly calling one "the smallest change": install a post-merge hook (git's own hook for exactly this trigger point — fires after a merge completes cleanly) that runs an unscoped rebuild, matching the existing post-checkout pattern (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_CHECKOUT verbatim since it already does exactly what's needed — an unscoped _rebuild_code(_root, force=_force). Wired into install(), uninstall(), and status() alongside the existing two hooks.
  • README.md / graphify/__main__.py: updated the two places that listed which hooks hook install writes.
  • tests/test_hooks.py: dedicated install/uninstall/status tests for the new hook, plus adding it to the shared _HOOK_SCRIPTS parametrized list, which extends 8 existing generic hook-script tests (no nohup, cross-platform detach, Windows worker limiting, GIT_DIR reuse, GRAPHIFY_SKIP_HOOK handling, 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_location fields) 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_location fields 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 pass
  • Full suite: python -m pytest -q — 5498 passed, 68 skipped (pre-existing unrelated failures excluded: tests/test_ollama_retry_cap.py missing the optional openai module, and test_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

ayushcodes10 and others added 12 commits September 14, 2026 16:59
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

@graphify-labs graphify-labs Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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-safety
  • tests/test_affected_member_seed.py — full-run-safety
  • tests/test_agents_platform.py — impact, full-run-safety
  • tests/test_analyze.py — full-run-safety
  • tests/test_anthropic_custom_endpoint.py — full-run-safety
  • tests/test_antigravity_install.py — full-run-safety
  • tests/test_apm_fallback_version.py — full-run-safety
  • tests/test_architecture_doc.py — full-run-safety
  • tests/test_astro_extraction.py — full-run-safety
  • tests/test_astro_import_ids.py — full-run-safety
  • tests/test_atomic_canvas_export.py — full-run-safety
  • tests/test_atomic_version_stamp.py — full-run-safety
  • tests/test_atomic_writes.py — full-run-safety
  • tests/test_backend_env_isolation.py — full-run-safety
  • tests/test_backend_extras.py — full-run-safety
  • tests/test_benchmark.py — full-run-safety
  • tests/test_benchmark_raw_graph.py — full-run-safety
  • tests/test_build.py — full-run-safety
  • tests/test_build_merge_dedup_scope.py — full-run-safety
  • tests/test_build_merge_hyperedges_and_prune.py — full-run-safety
  • tests/test_build_merge_shrink_guard.py — full-run-safety
  • tests/test_builtin_global_type_refs.py — full-run-safety
  • tests/test_cache.py — full-run-safety
  • tests/test_callflow_html.py — full-run-safety
  • tests/test_cargo_introspect.py — full-run-safety
  • tests/test_carried_hyperedge_remap.py — full-run-safety
  • tests/test_case_sensitive_resolution.py — full-run-safety
  • tests/test_charmap_encoding.py — full-run-safety
  • tests/test_chunking.py — full-run-safety
  • tests/test_cjs_module_extension.py — full-run-safety
  • tests/test_claude_cli_backend.py — full-run-safety
  • tests/test_claude_md.py — full-run-safety
  • tests/test_cli_broken_pipe.py — full-run-safety
  • tests/test_cli_export.py — full-run-safety
  • tests/test_cli_help.py — full-run-safety
  • tests/test_cluster.py — full-run-safety
  • tests/test_codebuddy.py — impact, full-run-safety
  • tests/test_community_hub_labels.py — full-run-safety
  • tests/test_community_labels_skill.py — full-run-safety
  • tests/test_confidence.py — full-run-safety
  • tests/test_corrupt_graph_json.py — full-run-safety
  • tests/test_cpp_nested_and_cli.py — full-run-safety
  • tests/test_cpp_objc_cross_file_calls.py — full-run-safety
  • tests/test_cpp_preprocess.py — full-run-safety
  • tests/test_cross_extension_reexport_self_cycle.py — full-run-safety
  • tests/test_cross_language_call_resolution.py — full-run-safety
  • tests/test_cross_repo_member_calls.py — full-run-safety
  • tests/test_cross_repo_shared_types.py — full-run-safety
  • tests/test_csharp_call_site_generic_args.py — full-run-safety
  • tests/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).

Comment thread graphify/hooks.py
@@ -847,7 +903,7 @@ def _user_hooks_dir(hooks_dir: Path) -> Path:


def install(path: Path = Path(".")) -> str:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Health regressioninstall()

fans out to 7 callees (efferent coupling); 41 callers depend on it (afferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

Comment thread graphify/hooks.py
)


def uninstall(path: Path = Path(".")) -> str:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Health regressionuninstall()

11 callers depend on it (afferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

Comment thread graphify/hooks.py
)


def status(path: Path = Path(".")) -> str:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Health regressionstatus()

fans out to 6 callees (efferent coupling); 9 callers depend on it (afferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Post-merge phantom nodes are never healed: union merge-driver resurrects deleted symbols, and the scoped post-commit rebuild cannot prune them

1 participant