From de7d362537da7a8e63ceb731472136b7dc22dc1b Mon Sep 17 00:00:00 2001 From: safishamsi Date: Wed, 1 Jul 2026 11:14:48 +0100 Subject: [PATCH] fix(build): relativize source_file across a symlinked root (#1571 follow-up) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- graphify/build.py | 11 +++++++- .../test_build_merge_hyperedges_and_prune.py | 25 +++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/graphify/build.py b/graphify/build.py index 6fcf241b..48bf2b6d 100644 --- a/graphify/build.py +++ b/graphify/build.py @@ -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 diff --git a/tests/test_build_merge_hyperedges_and_prune.py b/tests/test_build_merge_hyperedges_and_prune.py index dba0d681..ff4c7e8f 100644 --- a/tests/test_build_merge_hyperedges_and_prune.py +++ b/tests/test_build_merge_hyperedges_and_prune.py @@ -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