diff --git a/graphify/serve.py b/graphify/serve.py index c39872091a..6f2b6cb706 100644 --- a/graphify/serve.py +++ b/graphify/serve.py @@ -1102,8 +1102,28 @@ def _seed_label_key(nid: str) -> str: "modules": "import", "exports": "export", "exported": "export", + "inherit": "inherits", + "inherited": "inherits", + "inheritance": "inherits", + "subclass": "inherits", + "subclasses": "inherits", + "superclass": "inherits", + "implements": "implements", + "implemented": "implements", + "interface": "implements", + "interfaces": "implements", + "extends": "extends", } +# Heritage relations, which a context filter matches by relation name as well as +# by context. They are the one structural family the extractors disagree on: +# TypeScript tags them `context="type"`, while C#, Java and Python emit them +# with no context at all, so a filter that only read `context` could never +# reach inheritance in those languages (#5178). `type` deliberately stays out +# of this set — it means the TypeScript context and nothing more, or a +# TypeScript graph would start losing edges from its other contexts. +_HERITAGE_RELATIONS = frozenset({"inherits", "implements", "extends"}) + def _normalize_context_filters(filters: list[str] | None) -> list[str]: if not filters: @@ -1147,15 +1167,22 @@ def _filter_graph_by_context(G: nx.Graph, context_filters: list[str] | None) -> filters = set(_normalize_context_filters(context_filters)) if not filters: return G + relations = filters & _HERITAGE_RELATIONS + + def keep(data: dict) -> bool: + if data.get("context") in filters: + return True + return data.get("relation") in relations + H = G.__class__() H.add_nodes_from(G.nodes(data=True)) if isinstance(G, (nx.MultiGraph, nx.MultiDiGraph)): for u, v, key, data in G.edges(keys=True, data=True): - if data.get("context") in filters: + if keep(data): H.add_edge(u, v, key=key, **data) else: for u, v, data in G.edges(data=True): - if data.get("context") in filters: + if keep(data): H.add_edge(u, v, **data) return H @@ -1714,7 +1741,12 @@ def _render_query( header_parts.insert(0, f"Graph: {_display_graph_path(graph_path)} " f"({G.number_of_nodes()} nodes)") if resolved_filters: - header_parts.append(f"Context: {', '.join(resolved_filters)} ({filter_source})") + # A filter that selects nothing used to answer with the seed alone and + # no hint, which reads as "these symbols are unrelated" (#5178). + label = f"Context: {', '.join(resolved_filters)} ({filter_source}" + if not traversal_graph.number_of_edges(): + label += ", matched 0 edges" + header_parts.append(label + ")") header_parts.append(f"{len(nodes)} nodes found") header = " | ".join(header_parts) + "\n\n" # Pass the seeds so the queried symbol renders first and survives truncation diff --git a/tests/test_query_mcp_direction.py b/tests/test_query_mcp_direction.py index 1b2bf36e92..fcad8acff8 100644 --- a/tests/test_query_mcp_direction.py +++ b/tests/test_query_mcp_direction.py @@ -52,7 +52,10 @@ def test_mcp_query_explicit_context_filter_still_applies(tmp_path): kept = _query_graph_text(G, "callee_fn", depth=2, context_filters=["call"]) assert "Context: call (explicit)" in kept and "NODE caller_fn" in kept dropped = _query_graph_text(G, "callee_fn", depth=2, context_filters=["import"]) - assert "Context: import (explicit)" in dropped and "NODE caller_fn" not in dropped + # No `import` edge here, so the header says the filter matched nothing + # instead of leaving an empty answer to be read as "unrelated" (#5178). + assert "Context: import (explicit, matched 0 edges)" in dropped + assert "NODE caller_fn" not in dropped def test_traversal_view_keeps_multigraph_parallel_and_mutual_edges(): diff --git a/tests/test_serve.py b/tests/test_serve.py index 31a4ca13ee..c0e9a8e1b7 100644 --- a/tests/test_serve.py +++ b/tests/test_serve.py @@ -2250,3 +2250,74 @@ def test_qualified_tail_split(): assert _qualified_tail("HTTPClient") is None assert _qualified_tail("sweep_widget_state") is None assert _qualified_tail("") is None + + +def _make_heritage_graph() -> nx.Graph: + """Square inherits Base and implements IShape. + + Mirrors what the C#/Java/Python extractors emit: heritage edges with no + `context`. One TypeScript-style edge carries `context="type"`. + """ + G = nx.Graph() + labels = {"square": "Square", "base": "Base", "ishape": "IShape", "helper": "Helper"} + for nid, label in labels.items(): + G.add_node(nid, label=label, source_file="shapes.py", source_location="L1", community=0) + G.add_edge("square", "base", relation="inherits", confidence="EXTRACTED") + G.add_edge("square", "ishape", relation="implements", confidence="EXTRACTED") + G.add_edge("square", "helper", relation="calls", confidence="EXTRACTED", context="call") + G.add_edge("base", "helper", relation="imports", confidence="EXTRACTED", context="import") + return G + + +def test_context_filter_inheritance_aliases_resolve(): + from graphify.serve import _normalize_context_filters + + assert _normalize_context_filters(["inheritance"]) == ["inherits"] + assert _normalize_context_filters(["superclass"]) == ["inherits"] + assert _normalize_context_filters(["implements"]) == ["implements"] + assert _normalize_context_filters(["interface"]) == ["implements"] + + +def test_context_filter_reaches_heritage_edges_without_a_context(): + G = _make_heritage_graph() + filtered = _filter_graph_by_context(G, ["inherits"]) + assert set(filtered.edges()) == {("square", "base")} + # All nodes survive the filter, as before. + assert set(filtered.nodes()) == set(G.nodes()) + + +def test_context_filter_implements_is_not_folded_into_inherits(): + G = _make_heritage_graph() + assert set(_filter_graph_by_context(G, ["implements"]).edges()) == {("square", "ishape")} + + +def test_context_filter_type_still_means_the_typescript_context(): + G = _make_heritage_graph() + assert _filter_graph_by_context(G, ["type"]).number_of_edges() == 0 + G.add_edge("square", "base", relation="inherits", confidence="EXTRACTED", context="type") + assert _filter_graph_by_context(G, ["type"]).number_of_edges() == 1 + + +def test_context_filter_still_ignores_edges_without_a_context(): + G = _make_heritage_graph() + G.add_edge("helper", "square", relation="uses") + assert set(_filter_graph_by_context(G, ["call"]).edges()) == {("square", "helper")} + + +def test_query_graph_text_reports_a_filter_that_matches_no_edge(): + G = _make_heritage_graph() + text = _query_graph_text( + G, "Square", mode="bfs", depth=2, token_budget=2000, context_filters=["return_type"] + ) + assert "Context: return_type (explicit, matched 0 edges)" in text + assert "1 nodes found" in text + + +def test_query_graph_text_inheritance_filter_walks_the_hierarchy(): + G = _make_heritage_graph() + text = _query_graph_text( + G, "Square", mode="bfs", depth=2, token_budget=2000, context_filters=["inherits"] + ) + assert "Context: inherits (explicit)" in text + assert "2 nodes found" in text + assert "helper" not in text