diff --git a/graphify/build.py b/graphify/build.py index 579d7a35..f180419d 100644 --- a/graphify/build.py +++ b/graphify/build.py @@ -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 diff --git a/tests/test_build.py b/tests/test_build.py index 7552aa6a..065d7f41 100644 --- a/tests/test_build.py +++ b/tests/test_build.py @@ -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):