diff --git a/graphify/analyze.py b/graphify/analyze.py index cb6c7648..ec1e61a9 100644 --- a/graphify/analyze.py +++ b/graphify/analyze.py @@ -64,11 +64,12 @@ def _is_file_node(G: nx.Graph, node_id: str) -> bool: label = attrs.get("label", "") if not label: return False - # File-level hub: label matches the actual source filename (not just any label ending in .py) + # File-level hub: label matches the actual source filename — bare basename OR + # the directory-qualified form the #2032 disambiguation pass may assign. source_file = attrs.get("source_file", "") if source_file: - from pathlib import Path as _Path - if label == _Path(source_file).name: + from graphify.build import _is_file_node_label + if _is_file_node_label(label, source_file): return True # Method stub: AST extractor labels methods as '.method_name()' if label.startswith(".") and label.endswith("()"): diff --git a/graphify/build.py b/graphify/build.py index 6ce5e42d..a03524d8 100644 --- a/graphify/build.py +++ b/graphify/build.py @@ -174,6 +174,59 @@ def _abs_identity(p: str | None, root: str | None = None) -> str | None: return pp.as_posix() +def _is_file_node_label(label: "str | None", source_file: "str | None") -> bool: + """Whether *label* is a file node's label for *source_file* — the bare + basename, OR a directory-qualified suffix produced by the disambiguation pass + below (#2032). Used both to recognize file nodes when relabeling and by the + downstream file-node predicates (analyze/tree/serve).""" + if not label or not source_file: + return False + sf = str(source_file).replace("\\", "/") + lbl = str(label) + if lbl == sf.rsplit("/", 1)[-1]: + return True + return "/" in lbl and (sf == lbl or sf.endswith("/" + lbl)) + + +def _shortest_unique_suffix(sf: str, all_sfs: "set[str]") -> str: + """Shortest trailing path suffix (basename + k parent dirs) of *sf* that is + unique among *all_sfs*. `a/b/index.ts` vs `c/b/index.ts` -> `a/b/index.ts`; + `x/index.ts` vs `y/index.ts` -> `x/index.ts`. Derived from the path (never the + current label) so relabeling is idempotent across incremental rebuilds.""" + parts = [p for p in sf.replace("\\", "/").split("/") if p] + others = [ + [p for p in o.replace("\\", "/").split("/") if p] + for o in all_sfs if o != sf + ] + for k in range(1, len(parts) + 1): + suffix = parts[-k:] + if all(o[-k:] != suffix for o in others): + return "/".join(suffix) + 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.""" + 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") + 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))) + 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) + + def _infer_merge_root(graph_path: Path) -> str | None: """Best-effort scan root for relativizing paths in build_merge when the caller passes no ``root`` (#1571). @@ -860,6 +913,11 @@ def build_from_json(extraction: dict, *, directed: bool = False, root: str | Pat kept_hyperedges.append(he) if kept_hyperedges: G.graph["hyperedges"] = kept_hyperedges + # Runs LAST, after the alias-competition above (which relies on file-node + # labels still being bare basenames): give colliding-basename file nodes a + # directory-qualified display label so lookup/discovery can disambiguate + # them (#2032). Labels only — ids and edges are untouched. + _disambiguate_file_node_labels(G) return G diff --git a/graphify/serve.py b/graphify/serve.py index 457171e5..eab34f39 100644 --- a/graphify/serve.py +++ b/graphify/serve.py @@ -947,13 +947,15 @@ def _find_node(G: nx.Graph, label: str) -> list[str]: if source_exact: query_basename = _strip_diacritics(Path(label).name).lower() - preferred = [ - nid - for nid in source_exact - if str(G.nodes[nid].get("source_location", "")) == "L1" - and _strip_diacritics(str(G.nodes[nid].get("label") or "")).lower() - == query_basename - ] + preferred = [] + for nid in source_exact: + if str(G.nodes[nid].get("source_location", "")) != "L1": + continue + # File-node label is the bare basename OR a directory-qualified form + # from the #2032 disambiguation pass (e.g. "process-order/index.ts"). + lbl = _strip_diacritics(str(G.nodes[nid].get("label") or "")).lower() + if lbl == query_basename or lbl.endswith("/" + query_basename): + preferred.append(nid) if len(preferred) == 1: source_exact = preferred + [nid for nid in source_exact if nid != preferred[0]] diff --git a/graphify/tree_html.py b/graphify/tree_html.py index 94b10df4..1dc658ff 100644 --- a/graphify/tree_html.py +++ b/graphify/tree_html.py @@ -125,9 +125,12 @@ def build_tree( sym_children: List[Dict[str, Any]] = [] for n in syms: label = n.get("label", n.get("id", "?")) - # Skip the redundant file-name node graphify emits. - if label == src_path.name and n.get("file_type") == "code": - continue + # Skip the redundant file-name node graphify emits (bare basename or + # the directory-qualified form from the #2032 disambiguation pass). + if n.get("file_type") == "code": + from graphify.build import _is_file_node_label + if _is_file_node_label(label, src_file): + continue sym_children.append({ "name": label, "total_count": 1, diff --git a/tests/test_file_label_disambiguation.py b/tests/test_file_label_disambiguation.py new file mode 100644 index 00000000..bc474fae --- /dev/null +++ b/tests/test_file_label_disambiguation.py @@ -0,0 +1,109 @@ +"""File-node labels are disambiguated when basenames collide (#2032). + +In directory-per-entrypoint repos (Supabase Edge Functions, Next.js pages, +Rust mod.rs, Python __init__.py) many files share a basename, so basename-only +file-node labels collide and `explain`/discovery can't tell them apart. When a +basename collides, file nodes get a shortest-unique directory-qualified label; +unique basenames are left bare. Ids/edges are never changed — only labels. +""" +from __future__ import annotations + +import networkx as nx + +from graphify.build import ( + _disambiguate_file_node_labels, + _is_file_node_label, + _shortest_unique_suffix, +) + + +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 + assert not _is_file_node_label("index", "a/b/index.ts") # a symbol, not the file + assert not _is_file_node_label("helper()", "a/b/index.ts") + all_sfs = {"supabase/functions/process-order/index.ts", + "supabase/functions/send-receipt/index.ts"} + assert _shortest_unique_suffix("supabase/functions/process-order/index.ts", all_sfs) == "process-order/index.ts" + # root-level file colliding with a nested one keeps the bare basename + assert _shortest_unique_suffix("index.ts", {"index.ts", "a/index.ts"}) == "index.ts" + + +def _file_node(nid, sf): + return (nid, {"label": sf.rsplit("/", 1)[-1], "file_type": "code", "source_file": sf}) + + +def test_colliding_file_labels_are_qualified_uniques_left_bare(): + G = nx.DiGraph() + for nid, sf in [ + ("po", "supabase/functions/process-order/index.ts"), + ("sr", "supabase/functions/send-receipt/index.ts"), + ("main", "src/main.ts"), + ]: + G.add_node(nid, **_file_node(nid, sf)[1]) + # a symbol inside one of the files must NOT be relabeled + G.add_node("sym", label="handler", file_type="code", source_file="supabase/functions/process-order/index.ts") + + _disambiguate_file_node_labels(G) + + assert G.nodes["po"]["label"] == "process-order/index.ts" + assert G.nodes["sr"]["label"] == "send-receipt/index.ts" + assert G.nodes["main"]["label"] == "main.ts", "unique basename must stay bare" + assert G.nodes["sym"]["label"] == "handler", "symbol nodes must be untouched" + + +def test_disambiguation_is_idempotent(): + G = nx.DiGraph() + G.add_node("a", label="index.ts", file_type="code", source_file="x/a/index.ts") + G.add_node("b", label="index.ts", file_type="code", source_file="x/b/index.ts") + _disambiguate_file_node_labels(G) + first = {n: G.nodes[n]["label"] for n in G} + # Re-run over already-qualified labels: must be stable (derived from path). + _disambiguate_file_node_labels(G) + assert {n: G.nodes[n]["label"] for n in G} == first + assert first == {"a": "a/index.ts", "b": "b/index.ts"} + + +def test_three_way_collision_grows_suffix_until_unique(): + G = nx.DiGraph() + G.add_node("a", label="index.ts", file_type="code", source_file="a/x/index.ts") + G.add_node("b", label="index.ts", file_type="code", source_file="b/x/index.ts") + _disambiguate_file_node_labels(G) + # basename + one dir ("x/index.ts") still collides, so grow to two dirs. + assert G.nodes["a"]["label"] == "a/x/index.ts" + assert G.nodes["b"]["label"] == "b/x/index.ts" + + +def test_end_to_end_build_and_lookup(tmp_path): + """Full pipeline: two entry-point index.ts files get distinguishable labels + and both resolve via serve._find_node.""" + from graphify.extract import extract + from graphify.build import build_from_json + from graphify.serve import _find_node + + fns = tmp_path / "supabase" / "functions" + (fns / "process-order").mkdir(parents=True) + (fns / "send-receipt").mkdir(parents=True) + (fns / "process-order" / "index.ts").write_text("export function processOrder() { return 1; }\n") + (fns / "send-receipt" / "index.ts").write_text("export function sendReceipt() { return 2; }\n") + (tmp_path / "main.ts").write_text("export function main() { return 0; }\n") + + result = extract( + [fns / "process-order" / "index.ts", fns / "send-receipt" / "index.ts", tmp_path / "main.ts"], + cache_root=tmp_path / "cache", parallel=False, + ) + G = build_from_json(result, root=str(tmp_path)) + + file_labels = { + d["label"] for _, d in G.nodes(data=True) + if _is_file_node_label(d.get("label"), d.get("source_file")) + } + assert "process-order/index.ts" in file_labels + assert "send-receipt/index.ts" in file_labels + assert "main.ts" in file_labels # unique basename stays bare + + # Discovery works for both the directory name and the qualified path. + assert _find_node(G, "process-order") + assert _find_node(G, "process-order/index.ts") + po = _find_node(G, "process-order/index.ts")[0] + assert G.nodes[po]["label"] == "process-order/index.ts"