From f0345da28b35768607abd7b98a438d3cba5c683d Mon Sep 17 00:00:00 2001 From: safishamsi Date: Sun, 16 Aug 2026 19:22:06 +0100 Subject: [PATCH] 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) --- CHANGELOG.md | 1 + graphify/watch.py | 8 +++++- tests/test_watch.py | 65 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 73 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c47d93f..c029b472 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ Full release notes with details on each version: [GitHub Releases](https://githu ## 0.9.45 (unreleased) - Fix: `graphify install ` 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). diff --git a/graphify/watch.py b/graphify/watch.py index fafb4bb5..69986ea8 100644 --- a/graphify/watch.py +++ b/graphify/watch.py @@ -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 diff --git a/tests/test_watch.py b/tests/test_watch.py index 8106b00a..82526e4a 100644 --- a/tests/test_watch.py +++ b/tests/test_watch.py @@ -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]}" + )