Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 46 additions & 2 deletions graphify/watch.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
from typing import Callable

# Single source of truth in graphify.paths (#1423); re-exported as _GRAPHIFY_OUT.
from graphify.paths import GRAPHIFY_OUT as _GRAPHIFY_OUT
from graphify.paths import GRAPHIFY_OUT as _GRAPHIFY_OUT, is_absolute_any_platform
_PENDING_FILENAME = ".pending_changes"
_PENDING_DRAIN_MAX_PASSES = 20

Expand Down Expand Up @@ -371,7 +371,26 @@ def __init__(
try:
saved_root = Path(root_marker.read_text(encoding="utf-8").strip())
if saved_root.is_absolute():
self.existing_source_root = saved_root.resolve()
# #2603: the marker 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. Only adopt an anchor the stored
# paths actually resolve under; when none does, keep the
# marker (previous behavior) so a fully-deleted corpus
# still evicts.
resolved = saved_root.resolve()
if self._anchors_stored_sources(existing, resolved):
self.existing_source_root = resolved
else:
for candidate in (self.project_root, Path.cwd().resolve()):
if self._anchors_stored_sources(existing, candidate):
self.existing_source_root = candidate
break
else:
self.existing_source_root = resolved
else:
invocation_root = Path.cwd().resolve()
if (invocation_root / saved_root).resolve() == watch_root:
Expand Down Expand Up @@ -399,6 +418,31 @@ def __init__(
break
self.legacy_watch_relative = not has_project_relative_source

def _anchors_stored_sources(self, existing: dict, root: Path, sample: int = 25) -> bool:
"""Whether stored relative source_file paths resolve under ``root``.

Samples the first ``sample`` relative entries: the first hit accepts
the anchor; ``sample`` consecutive misses reject it. A graph with no
relative sources returns True (any anchor is harmless there). The
bound keeps the check O(sample) on large graphs; its known limit is a
commit that deletes ``sample``-plus files whose nodes happen to sort
first — the anchor then falls back to the marker, matching the
pre-fix behavior (no worse).
"""
checked = 0
for bucket in ("nodes", "links", "edges"):
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
if not stored or is_absolute_any_platform(stored):
continue
checked += 1
if (root / Path(posixpath.normpath(stored))).exists():
return True
if checked >= sample:
return False
return checked == 0

def normalize(self, source_file: str | None) -> str | None:
normalized = self._normalize_source(source_file, str(self.project_root))
return posixpath.normpath(normalized) if normalized else normalized
Expand Down
59 changes: 59 additions & 0 deletions tests/test_watch.py
Original file line number Diff line number Diff line change
Expand Up @@ -3516,3 +3516,62 @@ def test_incremental_indirect_call_parity_and_idempotency(tmp_path):

fresh = _2438_seed(tmp_path / "fresh", caller_prefix=" x = 1\n")
assert sorted(_2438_indirects(_2406_graph(fresh))) == sorted(incremental)


# --- #2603: absolute .graphify_root must not re-anchor cwd-relative sources ---

def test_subfolder_root_marker_preserves_unchanged_nodes(tmp_path, monkeypatch):
"""End-to-end pin for #2603: a graph built from the repo root scoped to a
subfolder stores source_file relative to the repo root ("src/mod0.py"),
while the skill writes an ABSOLUTE subfolder path into .graphify_root.
_StoredSourcePaths then anchored the stored paths to the subfolder,
doubling them (src/src/...), judging every unchanged source deleted, and
collapsing the graph. The marker must be validated against the stored
paths before it is trusted as their anchor."""
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)

# Build from the repo root scoped to the subfolder (the skill's shape):
# stored source_file values come out relative to the repo root.
assert _rebuild_code(Path("src"), acquire_lock=False) is True
out = src / "graphify-out"
graph_path = out / "graph.json"
baseline = json.loads(graph_path.read_text(encoding="utf-8"))
baseline_ids = {n["id"] for n in baseline["nodes"]}
assert any(
(n.get("source_file") or "").startswith("src/") for n in baseline["nodes"]
), "precondition: stored sources are repo-root-relative"

# The skill's Step 1 marker: an absolute path to the SUBFOLDER. The build
# above wrote the safe relative form; overwrite with the absolute form
# that reproduces #2603.
(out / ".graphify_root").write_text(str(src.resolve()), encoding="utf-8")

# Incremental rebuild the way the post-commit hook calls it: absolute
# watch_path read from the marker, one changed file.
(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

after_ids = {
n["id"] for n in json.loads(graph_path.read_text(encoding="utf-8"))["nodes"]
}
# mod0's nodes are re-minted by the re-extraction; nodes of the UNCHANGED
# files must all survive.
unchanged_lost = {i for i in baseline_ids - after_ids if "mod0" not in i}
assert not unchanged_lost, (
f"unchanged sources lost {len(unchanged_lost)} node(s) to marker "
f"re-anchoring: {sorted(unchanged_lost)[:5]}"
)