From 17fc2a6405e005b3eb4d0e78c237cd91832bc206 Mon Sep 17 00:00:00 2001 From: Houyun Sun <18763177732@139.com> Date: Thu, 12 Nov 2026 21:28:42 +0800 Subject: [PATCH] fix(query): let context_filter reach heritage edges without a context (#5178) `context_filter` only matched `edge["context"]`, and the C#, Java and Python extractors emit `inherits`/`implements` with no context at all (only the TypeScript one tags them `type`). So no filter value could select inheritance in those languages: `["type"]` worked for TS alone, `["inherits"]` matched nothing anywhere, and the traversal came back with the seeds and zero edges. - map the heritage words (inherit(s)/inheritance/subclass/superclass, implements/interface(s), extends) onto the edge relations they name, and let a heritage filter match the edge's `relation` as well as its `context`; - `type` keeps meaning exactly `context == "type"` -- widening it would drop `implements` edges from a TypeScript graph; - an explicit filter that selects no edge is now reported in the header as `Context: (explicit, matched 0 edges)`, so an empty answer is not read as "these symbols are unrelated". Verified on a four-language corpus (same Square : Base, IShape hierarchy in C#, TS, Java, Python): `--context inherits` now reaches the C#/Java/Python edges, and `--context implements` the interface ones, while `type` still returns only what TS tagged. --- graphify/serve.py | 38 +++++++++++++++-- tests/test_query_mcp_direction.py | 5 ++- tests/test_serve.py | 71 +++++++++++++++++++++++++++++++ 3 files changed, 110 insertions(+), 4 deletions(-) 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