fix(build): relativize source_file across a symlinked root (#1571 follow-up)

Rigorous smoke testing surfaced an edge case the canonical-tmp unit tests
couldn't reach: when the scan root is under a symlink (macOS /var ->
/private/var, a symlinked home or git worktree), the absolute prune path and
the resolved root differ by prefix, so _norm_source_file's lexical
relative_to fails and the prune/replace match silently misses — deleted
files' ghost nodes survive. Latent in the pre-existing #1007 path too, now
that build_merge resolves the root.

Fix: when lexical relative_to fails, retry with both sides fully resolved.
Only the failure path resolves, so the common lexical match stays
filesystem-free (no per-node stat on the hot replace-per-source loop).

Adds a symlinked-root prune regression test (POSIX-only). Full suite 2768,
and the full end-to-end smoke battery (indirect_call all contexts, JS,
Ruby/Groovy inherits, hyperedge preservation, symlinked-root ghost prune,
corrupt-json errors, dedup collision warning) is green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
safishamsi
2026-07-01 11:14:48 +01:00
co-authored by Claude Opus 4.8
parent 6eb7c014f4
commit de7d362537
2 changed files with 35 additions and 1 deletions
+10 -1
View File
@@ -117,7 +117,16 @@ def _norm_source_file(p: str | None, root: str | None = None) -> str | None:
try:
p = Path(p).relative_to(root).as_posix()
except ValueError:
pass
# Lexical relative_to failed. Retry with both sides fully resolved:
# a symlinked scan root (macOS /var -> /private/var, or a symlinked
# home/worktree) makes the raw prefixes differ even though they point
# at the same dir, which otherwise silently defeats prune/replace
# matching. Only the slow path resolves, so the common lexical match
# stays filesystem-free.
try:
p = Path(p).resolve().relative_to(Path(root).resolve()).as_posix()
except (ValueError, OSError):
pass
return p
@@ -13,8 +13,11 @@ build_merge backs `graphify --update`. Two regressions covered here:
from __future__ import annotations
import json
import os
from pathlib import Path
import pytest
from graphify.build import build_merge, _infer_merge_root
@@ -124,3 +127,25 @@ def test_prune_without_root_uses_graphify_root_marker(tmp_path):
assert _infer_merge_root(graph_path) == str(real_root.resolve())
G = build_merge([], graph_path, prune_sources=[str(real_root / "HANDOFF.md")], dedup=False)
assert "handoff" not in {d["label"] for _, d in G.nodes(data=True)}
@pytest.mark.skipif(os.name == "nt", reason="POSIX symlink semantics")
def test_prune_matches_across_symlinked_root(tmp_path):
"""A symlinked scan root (macOS /var -> /private/var, symlinked home/worktree)
makes the absolute prune path and the resolved root differ by prefix. The prune
must still match lexical relative_to fails, so normalization resolves both
sides. Regression for the edge case a canonical-tmp unit test can't reach."""
real = tmp_path / "real"
(real / "graphify-out").mkdir(parents=True)
link = tmp_path / "link"
os.symlink(real, link)
graph_path = real / "graphify-out" / "graph.json"
_write_graph(graph_path, [
{"id": "h1", "label": "handoff", "file_type": "document", "source_file": "HANDOFF.md"},
{"id": "k1", "label": "keep", "file_type": "document", "source_file": "KEEP.md"},
], [], [])
# prune path addressed via the SYMLINK, root resolved to the real dir
G = build_merge([], graph_path=graph_path,
prune_sources=[str(link / "HANDOFF.md")], root=str(real), dedup=False)
labels = {d["label"] for _, d in G.nodes(data=True)}
assert "handoff" not in labels and "keep" in labels