diff --git a/graphify/build.py b/graphify/build.py index a03524d8..8375eecd 100644 --- a/graphify/build.py +++ b/graphify/build.py @@ -205,26 +205,46 @@ def _shortest_unique_suffix(sf: str, all_sfs: "set[str]") -> str: return "/".join(parts) -def _disambiguate_file_node_labels(G: "nx.Graph") -> None: - """Give file nodes that share a basename a directory-qualified label so - `explain`/discovery can tell them apart (#2032). Repos where every basename - is unique are untouched (labels stay bare). Ids/edges are never changed — - only display labels. Idempotent: labels derive from source_file, not the - current (possibly already-qualified) label.""" +def _file_label_reassignments(items: "list[tuple]") -> dict: + """Given (key, label, source_file) triples, return {key: new_label} for file + nodes whose basename collides with another's — the shortest unique + directory-qualified suffix (#2032). Keys of non-colliding/basename-unique file + nodes are omitted (their label stays bare).""" from collections import defaultdict - groups: dict[str, list[tuple[str, str]]] = defaultdict(list) - for nid, attrs in G.nodes(data=True): - sf = attrs.get("source_file") - label = attrs.get("label") + groups: dict[str, list[tuple]] = defaultdict(list) + for key, label, sf in items: if sf and label and _is_file_node_label(str(label), str(sf)): basename = str(sf).replace("\\", "/").rsplit("/", 1)[-1] - groups[basename].append((nid, str(sf))) + groups[basename].append((key, str(sf))) + out: dict = {} for members in groups.values(): distinct = {sf for _, sf in members} if len(distinct) < 2: continue # no collision — leave the bare basename label - for nid, sf in members: - G.nodes[nid]["label"] = _shortest_unique_suffix(sf, distinct) + for key, sf in members: + out[key] = _shortest_unique_suffix(sf, distinct) + return out + + +def _disambiguate_file_node_labels(G: "nx.Graph") -> None: + """Relabel colliding-basename file nodes on a graph (#2032). Ids/edges are + never changed — only display labels. Idempotent (labels derive from + source_file, not the current possibly-qualified label).""" + items = [(nid, a.get("label"), a.get("source_file")) for nid, a in G.nodes(data=True)] + for nid, new_label in _file_label_reassignments(items).items(): + G.nodes[nid]["label"] = new_label + + +def disambiguate_file_labels_in_nodes(nodes: "list") -> None: + """Relabel colliding-basename file nodes on a raw node-dict list, in place + (#2032). Used by the extract --no-cluster path, which writes the merged + extraction directly without going through build_from_json.""" + items = [ + (i, n.get("label"), n.get("source_file")) + for i, n in enumerate(nodes) if isinstance(n, dict) + ] + for i, new_label in _file_label_reassignments(items).items(): + nodes[i]["label"] = new_label def _infer_merge_root(graph_path: Path) -> str | None: diff --git a/graphify/cli.py b/graphify/cli.py index fe219a6c..fb4d04ae 100644 --- a/graphify/cli.py +++ b/graphify/cli.py @@ -3178,6 +3178,11 @@ def dispatch_command(cmd: str) -> None: merged["nodes"] = _dedupe_nodes(merged["nodes"]) merged["edges"] = _dedupe_edges(merged["edges"]) + # Disambiguate colliding-basename file-node labels (#2032). This raw + # --no-cluster path bypasses build_from_json (where the clustered path + # gets this), so apply it directly on the merged node list. + from graphify.build import disambiguate_file_labels_in_nodes as _disamb_labels + _disamb_labels(merged["nodes"]) # Backfill source_file from endpoint nodes — this raw path bypasses # build_from_json's backfill, and semantic edges sometimes omit it (#1279). _node_sf = {n.get("id"): n.get("source_file") for n in merged["nodes"]} diff --git a/tests/test_file_label_disambiguation.py b/tests/test_file_label_disambiguation.py index bc474fae..ccefbb5a 100644 --- a/tests/test_file_label_disambiguation.py +++ b/tests/test_file_label_disambiguation.py @@ -14,9 +14,27 @@ from graphify.build import ( _disambiguate_file_node_labels, _is_file_node_label, _shortest_unique_suffix, + disambiguate_file_labels_in_nodes, ) +def test_disambiguate_raw_node_list_for_no_cluster_path(): + """The extract --no-cluster path writes the merged node dicts directly + (bypassing build_from_json), so the list-based variant must relabel too.""" + nodes = [ + {"id": "po", "label": "index.ts", "file_type": "code", "source_file": "fn/process-order/index.ts"}, + {"id": "sr", "label": "index.ts", "file_type": "code", "source_file": "fn/send-receipt/index.ts"}, + {"id": "m", "label": "main.ts", "file_type": "code", "source_file": "main.ts"}, + {"id": "sym", "label": "handler", "file_type": "code", "source_file": "fn/process-order/index.ts"}, + ] + disambiguate_file_labels_in_nodes(nodes) + by_id = {n["id"]: n["label"] for n in nodes} + assert by_id["po"] == "process-order/index.ts" + assert by_id["sr"] == "send-receipt/index.ts" + assert by_id["m"] == "main.ts" + assert by_id["sym"] == "handler" + + def test_is_file_node_label_and_suffix_helpers(): assert _is_file_node_label("index.ts", "a/b/index.ts") assert _is_file_node_label("b/index.ts", "a/b/index.ts") # qualified form