mirror of
https://github.com/safishamsi/graphify.git
synced 2026-08-27 00:36:39 +00:00
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:
co-authored by
Claude Opus 4.8
parent
6f05172521
commit
f0345da28b
@@ -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
@@ -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
|
||||
|
||||
@@ -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]}"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user