mirror of
https://github.com/safishamsi/graphify.git
synced 2026-08-22 14:26:32 +00:00
fix(build): disambiguate colliding file-node labels for discovery (#2032)
In directory-per-entrypoint repos (Supabase Edge Functions, Next.js page.tsx, Rust mod.rs, Python __init__.py) many files share a basename, so basename-only file-node labels collided and `explain`/free-text discovery couldn't resolve them — exactly the highest-value files. build_from_json now runs a final pass that gives colliding-basename file nodes the shortest unique directory-qualified label (`process-order/index.ts`); unique basenames stay bare, and node ids/edges are never touched. The pass runs after the alias-competition (which still needs bare basenames), is idempotent (labels derive from source_file), and the downstream file-node predicates (analyze god-nodes, tree_html, serve lookup) recognize the qualified form via a shared _is_file_node_label helper.
This commit is contained in:
+4
-3
@@ -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("()"):
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
+9
-7
@@ -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]]
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user