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
38 changes: 35 additions & 3 deletions graphify/serve.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion tests/test_query_mcp_direction.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down
71 changes: 71 additions & 0 deletions tests/test_serve.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading