mirror of
https://github.com/safishamsi/graphify.git
synced 2026-07-28 18:22:06 +00:00
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:
+33
-13
@@ -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:
|
||||
|
||||
@@ -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"]}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user