Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions graphify/affected.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
26 changes: 23 additions & 3 deletions graphify/serve.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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] = []
Expand All @@ -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)
Expand All @@ -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:
Expand Down
68 changes: 68 additions & 0 deletions tests/test_affected_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
26 changes: 26 additions & 0 deletions tests/test_explain_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
29 changes: 27 additions & 2 deletions tests/test_serve.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down Expand Up @@ -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)
Expand Down