harden(watch): document cwd anchor fallback, align bucket tuple, add tests (#2603)

Two small hardening tweaks on top of #2778: comment that in the hook path
Path.cwd() is the load-bearing anchor rescue (project_root == watch_root ==
the bad marker), and add hyperedges to the _anchors_stored_sources bucket
tuple to match the sibling loop. Adds a deletion-still-evicts test (the anchor
validation must not over-preserve) and an incremental==cold id-parity test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
safishamsi
2026-08-16 19:22:06 +01:00
co-authored by Claude Opus 4.8
parent 6f05172521
commit f0345da28b
3 changed files with 73 additions and 1 deletions
+1
View File
@@ -5,6 +5,7 @@ Full release notes with details on each version: [GitHub Releases](https://githu
## 0.9.45 (unreleased)
- Fix: `graphify install <platform>` now advances the `.graphify_version` stamp only for the platform it actually (re)writes, instead of stamping every installed platform as current; a platform whose skill content was left untouched keeps its old stamp so its staleness warning stays truthful (#2694, thanks @ousamabenyounes). This completes #2694 (the CLAUDE_CONFIG_DIR half shipped in 0.9.44).
- Fix: an incremental rebuild no longer collapses the whole graph when the `.graphify_root` marker records a subfolder while stored `source_file` paths are relative to the repo root; the marker is validated against the stored paths before it is trusted as their anchor, so a mismatched marker can't make every unchanged source look deleted (#2603, thanks @catpotd). A genuinely deleted source is still evicted, and incremental ids stay identical to a cold build.
- Fix: a Go file that declares both an exported and an unexported symbol differing only by case (e.g. `Run` and `run`, which are distinct in Go's case-sensitive visibility rules) no longer collapses them onto one node id and drops one; the exported symbol keeps its stable id and the unexported one is disambiguated, so an intra-file call to the unexported symbol resolves locally instead of phantoming to another package (#2779, thanks @catpotd). Only the Go extractor's id assignment is affected; the shared id normalization is unchanged, so no other language's ids move.
- Fix: loading a `graph.json` that contains a hyperedge with no `id` field (the semantic extractor emits them and they persist verbatim) no longer crashes the incremental re-extract with `KeyError: 'id'`; id-less hyperedges are tolerated and retained (#2775, thanks @ousamabenyounes).
+7 -1
View File
@@ -385,6 +385,12 @@ class _StoredSourcePaths:
if self._anchors_stored_sources(existing, resolved):
self.existing_source_root = resolved
else:
# In the #2603 hook path watch_path is the absolute
# marker, so project_root == watch_root == the bad
# marker and the first candidate also fails to anchor;
# Path.cwd() (the repo root a post-commit hook runs from)
# is the load-bearing rescue candidate here. Keep both so
# a relative-watch_path invocation is also covered.
for candidate in (self.project_root, Path.cwd().resolve()):
if self._anchors_stored_sources(existing, candidate):
self.existing_source_root = candidate
@@ -430,7 +436,7 @@ class _StoredSourcePaths:
pre-fix behavior (no worse).
"""
checked = 0
for bucket in ("nodes", "links", "edges"):
for bucket in ("nodes", "links", "edges", "hyperedges"):
for item in existing.get(bucket, []):
raw = item.get("source_file") if isinstance(item, dict) else None
stored = self._normalize_source(raw) if raw else None
+65
View File
@@ -3575,3 +3575,68 @@ def test_subfolder_root_marker_preserves_unchanged_nodes(tmp_path, monkeypatch):
f"unchanged sources lost {len(unchanged_lost)} node(s) to marker "
f"re-anchoring: {sorted(unchanged_lost)[:5]}"
)
def test_subfolder_marker_still_evicts_a_deleted_file(tmp_path, monkeypatch):
"""The anchor validation must not over-preserve (#2603): once the correct
anchor is chosen, a genuinely deleted source is still evicted."""
from graphify.watch import _rebuild_code
repo = tmp_path / "repo"
src = repo / "src"
src.mkdir(parents=True)
for i in range(3):
(src / f"mod{i}.py").write_text(
f"class Thing{i}:\n def run(self):\n return {i}\n", encoding="utf-8"
)
monkeypatch.chdir(repo)
assert _rebuild_code(Path("src"), acquire_lock=False) is True
out = src / "graphify-out"
graph_path = out / "graph.json"
(out / ".graphify_root").write_text(str(src.resolve()), encoding="utf-8")
(src / "mod1.py").unlink() # a genuine deletion
assert _rebuild_code(
src.resolve(), changed_paths=[Path("src/mod1.py")], acquire_lock=False
) is True
after = json.loads(graph_path.read_text(encoding="utf-8"))["nodes"]
assert not any("mod1" in n["id"] for n in after), "deleted file's nodes must be evicted"
assert any("mod2" in n["id"] for n in after), "unchanged file must survive"
def test_subfolder_marker_incremental_matches_cold_build(tmp_path, monkeypatch):
"""Incremental rebuild with the validated anchor produces the same node-id
set as a cold rebuild of the identical on-disk state (id parity, #2603)."""
from graphify.watch import _rebuild_code
repo = tmp_path / "repo"
src = repo / "src"
src.mkdir(parents=True)
for i in range(3):
(src / f"mod{i}.py").write_text(
f"class Thing{i}:\n def run(self):\n return {i}\n", encoding="utf-8"
)
monkeypatch.chdir(repo)
assert _rebuild_code(Path("src"), acquire_lock=False) is True
out = src / "graphify-out"
graph_path = out / "graph.json"
(out / ".graphify_root").write_text(str(src.resolve()), encoding="utf-8")
(src / "mod0.py").write_text(
"class Thing0:\n def run(self):\n return 100\n", encoding="utf-8"
)
assert _rebuild_code(
src.resolve(), changed_paths=[Path("src/mod0.py")], acquire_lock=False
) is True
incremental_ids = {n["id"] for n in json.loads(graph_path.read_text(encoding="utf-8"))["nodes"]}
import shutil
shutil.rmtree(out)
assert _rebuild_code(Path("src"), acquire_lock=False) is True
cold_ids = {n["id"] for n in json.loads(graph_path.read_text(encoding="utf-8"))["nodes"]}
assert incremental_ids == cold_ids, (
f"incremental vs cold id drift: only-incremental={sorted(incremental_ids - cold_ids)[:5]}, "
f"only-cold={sorted(cold_ids - incremental_ids)[:5]}"
)