From 1b7bdda137a1b976200f45f88b3289e5d4897321 Mon Sep 17 00:00:00 2001 From: kaziu007 Date: Sun, 28 Jun 2026 00:16:24 +0200 Subject: [PATCH] Fix source_file path lookup --- graphify/affected.py | 39 ++++++++++++++++++++++ graphify/serve.py | 26 +++++++++++++-- tests/test_affected_cli.py | 68 ++++++++++++++++++++++++++++++++++++++ tests/test_explain_cli.py | 26 +++++++++++++++ tests/test_serve.py | 29 ++++++++++++++-- 5 files changed, 183 insertions(+), 5 deletions(-) diff --git a/graphify/affected.py b/graphify/affected.py index 8bddce653..5c85b5c6c 100644 --- a/graphify/affected.py +++ b/graphify/affected.py @@ -54,6 +54,41 @@ def _normalize_label(label: str) -> str: return unicodedata.normalize("NFC", label).casefold() +def _prefer_file_node( + graph: nx.Graph, + node_ids: list[str], + query: str, +) -> str | None: + """Return the file-level node when a source_file query matches many nodes.""" + query_basename = _normalize_label(Path(query).name) + exact_file_nodes = [ + node_id + for node_id in node_ids + if str(graph.nodes[node_id].get("source_location", "")) == "L1" + and _normalize_label(str(graph.nodes[node_id].get("label", ""))) == query_basename + ] + if len(exact_file_nodes) == 1: + return exact_file_nodes[0] + + l1_nodes = [ + node_id + for node_id in node_ids + if str(graph.nodes[node_id].get("source_location", "")) == "L1" + ] + if len(l1_nodes) == 1: + return l1_nodes[0] + + basename_nodes = [ + node_id + for node_id in node_ids + if _normalize_label(str(graph.nodes[node_id].get("label", ""))) == query_basename + ] + if len(basename_nodes) == 1: + return basename_nodes[0] + + return None + + def resolve_seed(graph: nx.Graph, query: str) -> str | None: if query in graph: return query @@ -83,6 +118,10 @@ def resolve_seed(graph: nx.Graph, query: str) -> str | None: ] if len(exact_source_matches) == 1: return exact_source_matches[0] + if exact_source_matches: + preferred_file_node = _prefer_file_node(graph, exact_source_matches, query) + if preferred_file_node is not None: + return preferred_file_node contains_matches = [ str(node_id) for node_id, data in graph.nodes(data=True) diff --git a/graphify/serve.py b/graphify/serve.py index 117e70af4..1145cf152 100644 --- a/graphify/serve.py +++ b/graphify/serve.py @@ -152,6 +152,8 @@ def _node_search_text(data: dict, nid: str) -> str: `term in label_tokens` branch, where a multi-word `term` can span a token boundary that punctuation hides in `norm_label` (e.g. query "foo bar" matches label "foo.bar" only via its tokenized form). + - `source_tokens` feeds _find_node's exact source-file path lookup, where a + query like "app/api/example/route.ts" tokenizes to "app api example route ts". - `nid` feeds the whole-query `joined == nid_lower` tier. NUL separators stop a trigram from spanning two fields (a query never contains @@ -160,7 +162,8 @@ def _node_search_text(data: dict, nid: str) -> str: norm_label = data.get("norm_label") or _strip_diacritics(data.get("label") or "").lower() label_tokens = " ".join(_search_tokens(data.get("label") or "")) source = (data.get("source_file") or "").lower() - return "\x00".join((norm_label, label_tokens, str(nid).lower(), source)) + source_tokens = " ".join(_search_tokens(data.get("source_file") or "")) + return "\x00".join((norm_label, label_tokens, str(nid).lower(), source, source_tokens)) def _get_trigram_index(G: nx.Graph) -> dict: @@ -572,6 +575,7 @@ def _find_node(G: nx.Graph, label: str) -> list[str]: term = " ".join(_search_tokens(label)) if not term: return [] + source_exact: list[str] = [] exact: list[str] = [] prefix: list[str] = [] substring: list[str] = [] @@ -586,8 +590,11 @@ def _find_node(G: nx.Graph, label: str) -> list[str]: norm_label = d.get("norm_label") or _strip_diacritics(d.get("label") or "").lower() bare_label = norm_label.rstrip("()") label_tokens = " ".join(_search_tokens(d.get("label") or "")) + source_tokens = " ".join(_search_tokens(d.get("source_file") or "")) nid_lower = nid.lower() - if term == norm_label or term == bare_label or term == label_tokens or term == nid_lower: + if term == source_tokens: + source_exact.append(nid) + elif term == norm_label or term == bare_label or term == label_tokens or term == nid_lower: exact.append(nid) elif ( norm_label.startswith(term) @@ -598,7 +605,20 @@ def _find_node(G: nx.Graph, label: str) -> list[str]: prefix.append(nid) elif term in norm_label or term in label_tokens: substring.append(nid) - return exact + prefix + substring + + 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 + ] + if len(preferred) == 1: + source_exact = preferred + [nid for nid in source_exact if nid != preferred[0]] + + return source_exact + exact + prefix + substring def _filter_blank_stdin() -> None: diff --git a/tests/test_affected_cli.py b/tests/test_affected_cli.py index c7835b866..0b5ece483 100644 --- a/tests/test_affected_cli.py +++ b/tests/test_affected_cli.py @@ -174,3 +174,71 @@ def test_resolve_seed_bare_name_tie_still_returns_none(): graph.add_node("b", label="dup()", source_file="pkg/two.py") assert resolve_seed(graph, "dup") is None + + +def test_resolve_seed_source_file_path_prefers_file_level_node(): + from graphify.affected import resolve_seed + + graph = nx.DiGraph() + source_file = "app/api/example/route.ts" + graph.add_node( + "example_route_get", + label="GET()", + source_file=source_file, + source_location="L42", + ) + graph.add_node( + "example_route", + label="route.ts", + source_file=source_file, + source_location="L1", + ) + + assert resolve_seed(graph, source_file) == "example_route" + + +def test_affected_cli_source_file_path_uses_file_level_node(monkeypatch, tmp_path, capsys): + graph = nx.DiGraph() + source_file = "app/api/example/route.ts" + graph.add_node( + "example_route_get", + label="GET()", + source_file=source_file, + source_location="L42", + ) + graph.add_node( + "example_route", + label="route.ts", + source_file=source_file, + source_location="L1", + ) + graph.add_node( + "consumer", + label="consumer.ts", + source_file="app/consumer.ts", + source_location="L1", + ) + graph.add_edge( + "consumer", + "example_route", + relation="imports_from", + context="import", + confidence="EXTRACTED", + ) + graph_path = tmp_path / "graph.json" + graph_path.write_text(json.dumps(json_graph.node_link_data(graph, edges="links")), encoding="utf-8") + + monkeypatch.setattr(mainmod, "_check_skill_version", lambda _: None) + monkeypatch.setattr( + mainmod.sys, + "argv", + ["graphify", "affected", source_file, "--graph", str(graph_path)], + ) + + mainmod.main() + + out = capsys.readouterr().out + assert "Affected nodes for route.ts" in out + assert "consumer.ts" in out + assert "imports_from" in out + assert "No unique node matched" not in out diff --git a/tests/test_explain_cli.py b/tests/test_explain_cli.py index 1d00955f0..a77ac3eca 100644 --- a/tests/test_explain_cli.py +++ b/tests/test_explain_cli.py @@ -54,3 +54,29 @@ def test_caller_shows_callee_as_outbound(monkeypatch, tmp_path, capsys): out = _run(monkeypatch, p, "createPatchHandler", capsys) assert "--> validateSanitySession() [calls]" in out assert "<-- " not in out + + +def test_explain_source_file_path_prefers_file_level_node(monkeypatch, tmp_path, capsys): + source_file = "app/api/example/route.ts" + graph_data = { + "directed": False, "multigraph": False, "graph": {}, + "nodes": [ + {"id": "example_route_get", "label": "GET()", + "source_file": source_file, "source_location": "L42", "community": 0}, + {"id": "example_route", "label": "route.ts", + "source_file": source_file, "source_location": "L1", "community": 0}, + ], + "links": [ + {"source": "example_route", "target": "example_route_get", + "relation": "contains", "confidence": "EXTRACTED"}, + ], + } + p = tmp_path / "graph.json" + p.write_text(json.dumps(graph_data)) + + out = _run(monkeypatch, p, source_file, capsys) + + assert "Node: route.ts" in out + assert "ID: example_route" in out + assert f"Source: {source_file} L1" in out + assert "Node: GET()" not in out diff --git a/tests/test_serve.py b/tests/test_serve.py index 4ffa9aedb..4cd477d65 100644 --- a/tests/test_serve.py +++ b/tests/test_serve.py @@ -165,13 +165,14 @@ def test_trigrams_basic(): def test_node_search_text_includes_all_matched_fields(): G = _make_big_graph() text = _node_search_text(G.nodes["punct"], "punct") - # norm_label, the tokenized label (label_tokens), nid, and source are all present, - # NUL-separated so trigrams can't span fields. + # norm_label, tokenized label, nid, raw source, and tokenized source are all + # present, NUL-separated so trigrams can't span fields. parts = text.split("\x00") assert parts[0] == "foo.bar:baz" # norm_label (punctuation kept) assert parts[1] == "foo bar baz" # label_tokens (tokenized) assert parts[2] == "punct" # nid assert parts[3] == "pkg/foobar.py" # source_file + assert parts[4] == "pkg foobar py" # source_file tokens def test_trigram_candidates_fast_path_fires_for_rare_term(): @@ -227,6 +228,30 @@ def test_find_node_label_tokens_branch_covered_by_index(): assert _find_node(G, "Foo Bar Baz") == ["punct"] +def test_find_node_source_file_path_prefers_file_level_node(): + G = _make_big_graph() + source_file = "app/api/example/route.ts" + # Insert the function node first to prove source-file lookup reorders the + # file-level node ahead of other nodes from the same file. + G.add_node( + "example_route_get", + label="GET()", + source_file=source_file, + source_location="L42", + ) + G.add_node( + "example_route", + label="route.ts", + source_file=source_file, + source_location="L1", + ) + + matches = _find_node(G, source_file) + + assert matches[0] == "example_route" + assert "example_route_get" in matches + + def test_trigram_index_cached_and_rebuilt_per_graph(): G = _make_big_graph() idx1 = _get_trigram_index(G)