Skip to content

fix(watch): validate .graphify_root before anchoring stored sources on it (#2603) - #2778

Open
catpotd wants to merge 1 commit into
Graphify-Labs:v8from
catpotd:fix/2603-root-marker-anchor
Open

fix(watch): validate .graphify_root before anchoring stored sources on it (#2603)#2778
catpotd wants to merge 1 commit into
Graphify-Labs:v8from
catpotd:fix/2603-root-marker-anchor

Conversation

@catpotd

@catpotd catpotd commented Aug 16, 2026

Copy link
Copy Markdown

Summary

Addresses the silent graph collapse in #2603 — the part its reporter "could not
fully isolate". A graph built on a subfolder loses almost every unchanged node
on the first incremental (hook or update) rebuild.

Scope: this PR fixes the mass eviction / collapse. The other quirk described in
#2603 — the rebuild output landing under the scan root rather than next to the
existing graphify-out/ — is a separate, smaller issue and is not changed here,
so this intentionally does not say "Fixes".

Root cause

_StoredSourcePaths trusts an absolute .graphify_root unconditionally as the anchor for stored relative source_file values (watch.py:373). But the two are written by different parties with different bases:

  • the skill / a repo-root build stores source_file relative to the build cwd (src/mod.py)
  • the skill's Step 1 writes .graphify_root as an absolute subfolder path (/repo/src)

The incremental hook path then resolves src/mod.py against /repo/src/repo/src/src/mod.py. The doubled path exists for no file, so every unchanged source is judged deleted and evicted. The graph collapses to roughly the changed files' nodes.

Reproduction

Minimal (6-file Python corpus, scan scoped to src/, output at repo root): one changed file collapses 48 nodes → 2. On larger corpora the collapse is partial — most nodes are silently lost while the rebuild still reports success, which makes it easy to miss.

The trigger is binary: with the absolute marker present, preserved=0; delete the marker file and rerun, preserved=48. A/B verified on 0.9.44.

Approach

Before adopting the marker as the anchor, verify that stored relative paths actually resolve under it (_anchors_stored_sources, first-hit sampling over 25 entries). If they don't, try project_root, then the invocation cwd. If nothing anchors, keep the marker — a fully-deleted corpus must still evict, and behavior stays identical to today's. This mirrors what remap_communities_to_previous already does for community IDs: prefer the interpretation with actual overlap over a stored claim.

Known limit (documented in the docstring): a commit deleting 25+ files whose nodes sort first would reject the correct anchor and fall back to the marker — i.e. the pre-fix behavior, no worse.

Verification

  • New end-to-end test test_subfolder_root_marker_preserves_unchanged_nodes pins the scenario; it fails on unpatched v8 and passes with the fix
  • Full suite (uv sync --all-extras --frozen, same commands as CI): 4517 passed / 0 failed on both Python 3.10 and 3.12; the five skillgen checks all pass
  • Real-corpus A/B (a larger real-world project): unchanged-node loss goes to 0 with the fix; a deletion scenario still evicts the deleted sources' nodes; a no-change rebuild still short-circuits ("No code-graph topology changes detected")

Happy to adjust sampling size or fallback order if you prefer a different trade-off.

…n it (Graphify-Labs#2603)

An absolute .graphify_root holds the SCAN root, but stored source_file
values are relative to the BUILD's cwd (the skill builds from the repo
root scoped to a subfolder). Trusting the marker blindly re-anchors
'src/mod.py' under src/, doubling the path; every unchanged source is
then judged deleted and evicted, collapsing the graph.

Adopt an anchor only when stored paths actually resolve under it;
otherwise try project_root, then the invocation cwd. When nothing
anchors, keep the marker so a fully-deleted corpus still evicts.

@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. No changes could be formally verified in this run.


Graphify review — findings

This PR modifies the _StoredSourcePaths logic in graphify/watch.py for how the .graphify_root marker is used to anchor stored source paths during incremental rebuilds. Instead of trusting an absolute marker path directly, it adds a new _anchors_stored_sources helper that samples stored relative source_file entries to check whether they actually resolve under a candidate root, falling back through project_root, cwd, and finally the marker value. A new end-to-end test is added in tests/test_watch.py covering the subfolder-scoped build scenario referenced by issue #2603, and various unrelated test/rationale symbols in the same files appear in the change set.

No blocking issues surfaced. 3 lower-confidence candidates did not survive cross-model review.

Analysis details — impact, health, verification

Impact & health

Graphify review

Impact — 484 functions depend on the 331 functions this change touches.

Health — this change adds coupling hotspots:

  • new: _rebuild_code() — 96 callers, 51 callees
  • new: dispatch_command() — 2 callers, 117 callees
  • new: watch() — 5 callers, 7 callees
  • new: _reconcile_existing_graph() — 1 callers, 7 callees
  • new: test_poisoned_manifest_is_healed() — 0 callers, 6 callees

Verification — 484 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: 342 function(s) in the blast radius were not formally verified this run

Formal verification

Could not verify: Could not verify \_\_init\_\_.

The verifier did not have enough to check \_\_init\_\_, 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: method — first parameter is `self`, which needs a constructed instance (not synthesizable)

· 5 more finding(s) on lines outside this diff (see the check run).

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.

1 participant