improve #1007 fix: use _norm_source_file with resolve() for symlink safety

Replace inlined path normalisation with _norm_source_file (the same function
that builds node source_file keys) so prune_set and node attrs are normalised
identically. resolve() on root handles symlinked scan roots. Keep both raw and
normalised forms in prune_set so nodes with absolute source_file also match.
Add edge pruning and Windows backslash path tests per Opus review.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Safi
2026-05-26 14:59:13 +01:00
parent a26f24ef7b
commit eef623a54a
2 changed files with 47 additions and 21 deletions
+15 -12
View File
@@ -337,18 +337,21 @@ def build_merge(
# Prune nodes and edges from deleted source files
if prune_sources:
prune_set = set(prune_sources)
# Manifest stores absolute paths; graph nodes store relative paths.
# Add relative variants so both formats match regardless of OS or
# whether paths were relativized at build time (#1007).
if root is not None:
import os as _os
_root = Path(root)
for p in list(prune_sources):
try:
prune_set.add(str(Path(p).relative_to(_root)).replace(_os.sep, "/"))
except ValueError:
pass
# Build a set containing both the raw form (matches nodes that kept
# absolute source_file) and the normalised relative form (matches nodes
# that were relativised by _norm_source_file at build time).
# .resolve() handles symlinked roots and redundant ".." / "./" segments
# so Path.relative_to() succeeds even when the scan root is a symlink.
# (#1007: manifest absolute paths vs graph relative source_file mismatch)
_root_str = str(Path(root).resolve()) if root is not None else None
prune_set: set[str] = set()
for p in prune_sources:
if not p:
continue
prune_set.add(p)
norm = _norm_source_file(p, _root_str)
if norm:
prune_set.add(norm)
to_remove = [
n for n, d in G.nodes(data=True)
if d.get("source_file") in prune_set
+32 -9
View File
@@ -348,9 +348,8 @@ def test_build_from_json_relative_source_file_unchanged(tmp_path):
def test_build_merge_prune_absolute_paths_match_relative_nodes(tmp_path):
"""#1007: manifest stores absolute paths, graph nodes store relative paths.
prune_sources with absolute paths must still remove the right nodes."""
import json
from graphify.export import to_json
prune_sources with absolute paths must still remove the right nodes and edges."""
import networkx as nx
root = tmp_path / "corpus"
root.mkdir()
@@ -360,20 +359,44 @@ def test_build_merge_prune_absolute_paths_match_relative_nodes(tmp_path):
chunk = {"nodes": [
{"id": "n1", "label": "login", "file_type": "code", "source_file": "module_a/auth.py"},
{"id": "n2", "label": "format_date", "file_type": "code", "source_file": "module_b/utils.py"},
], "edges": []}
], "edges": [
{"source": "n1", "target": "n2", "relation": "calls", "confidence": "EXTRACTED",
"source_file": "module_b/utils.py", "weight": 1.0},
]}
G0 = build([chunk], dedup=False)
import networkx as nx
graph_path.write_text(
json.dumps(nx.node_link_data(G0, edges="edges")), encoding="utf-8"
)
graph_path.write_text(json.dumps(nx.node_link_data(G0, edges="edges")), encoding="utf-8")
# prune_sources from manifest — absolute paths
# prune_sources from manifest — absolute paths (what detect_incremental emits)
deleted_abs = [str(root / "module_b" / "utils.py")]
G1 = build_merge([], graph_path, prune_sources=deleted_abs, dedup=False, root=root)
node_labels = {d["label"] for _, d in G1.nodes(data=True)}
assert "format_date" not in node_labels, "stale node from deleted file should be pruned"
assert "login" in node_labels, "unrelated node must survive"
# Edge from deleted file must also be gone
assert G1.number_of_edges() == 0, "edge from deleted source_file should be pruned"
def test_build_merge_prune_windows_backslash_paths(tmp_path):
"""#1007: prune_sources with Windows-style backslash absolute paths must still match."""
import networkx as nx
root = tmp_path / "corpus"
root.mkdir()
graph_path = tmp_path / "graph.json"
chunk = {"nodes": [
{"id": "n1", "label": "parse_date", "file_type": "code", "source_file": "module_b/utils.py"},
], "edges": []}
G0 = build([chunk], dedup=False)
graph_path.write_text(json.dumps(nx.node_link_data(G0, edges="edges")), encoding="utf-8")
# Simulate Windows manifest path with backslashes
win_path = str(root / "module_b" / "utils.py").replace("/", "\\")
G1 = build_merge([], graph_path, prune_sources=[win_path], dedup=False, root=root)
node_labels = {d["label"] for _, d in G1.nodes(data=True)}
assert "parse_date" not in node_labels, "node should be pruned even with backslash path"
def test_build_merge_rejects_oversized_existing_graph(monkeypatch, tmp_path):