fix(cli): disambiguate file labels on the --no-cluster extract path too (#2032)

The build_from_json label pass only covered the clustered/update paths; the raw
`extract --no-cluster` path writes the merged node list directly, so colliding
basenames stayed un-disambiguated there. Factor the logic into a shared
_file_label_reassignments core with a list-based variant
(disambiguate_file_labels_in_nodes) and apply it on the raw merged nodes.
Caught by the clean-venv edge-case battery.
This commit is contained in:
safishamsi
2026-07-20 16:35:14 +01:00
parent 8ce9f6b906
commit 5b05f0d9ab
3 changed files with 56 additions and 13 deletions
+33 -13
View File
@@ -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:
+5
View File
@@ -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"]}
+18
View File
@@ -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