merge PR #573: cross-language edge context filters in MCP query tool

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Safi
2026-05-02 16:49:22 +01:00
co-authored by Claude Sonnet 4.6
8 changed files with 567 additions and 62 deletions
+20 -10
View File
@@ -1016,6 +1016,7 @@ def main() -> None:
print(" --no-viz skip graph.html generation (useful for >5000 node graphs / CI)")
print(" query \"<question>\" BFS traversal of graph.json for a question")
print(" --dfs use depth-first instead of breadth-first")
print(" --context C explicit edge-context filter (repeatable)")
print(" --budget N cap output at N tokens (default 2000)")
print(" --graph <path> path to graph.json (default graphify-out/graph.json)")
print(" save-result save a Q&A result to graphify-out/memory/ for graph feedback loop")
@@ -1209,15 +1210,16 @@ def main() -> None:
sys.exit(1)
elif cmd == "query":
if len(sys.argv) < 3:
print("Usage: graphify query \"<question>\" [--dfs] [--budget N] [--graph path]", file=sys.stderr)
print("Usage: graphify query \"<question>\" [--dfs] [--context C] [--budget N] [--graph path]", file=sys.stderr)
sys.exit(1)
from graphify.serve import _score_nodes, _bfs, _dfs, _subgraph_to_text
from graphify.serve import _query_graph_text
from graphify.security import sanitize_label
from networkx.readwrite import json_graph
question = sys.argv[2]
use_dfs = "--dfs" in sys.argv
budget = 2000
graph_path = "graphify-out/graph.json"
context_filters: list[str] = []
args = sys.argv[3:]
i = 0
while i < len(args):
@@ -1235,6 +1237,12 @@ def main() -> None:
print(f"error: --budget must be an integer", file=sys.stderr)
sys.exit(1)
i += 1
elif args[i] == "--context" and i + 1 < len(args):
context_filters.append(args[i + 1])
i += 2
elif args[i].startswith("--context="):
context_filters.append(args[i].split("=", 1)[1])
i += 1
elif args[i] == "--graph" and i + 1 < len(args):
graph_path = args[i + 1]; i += 2
else:
@@ -1257,14 +1265,16 @@ def main() -> None:
except Exception as exc:
print(f"error: could not load graph: {exc}", file=sys.stderr)
sys.exit(1)
terms = [t.lower() for t in question.split() if len(t) > 2]
scored = _score_nodes(G, terms)
if not scored:
print("No matching nodes found.")
sys.exit(0)
start = [nid for _, nid in scored[:5]]
nodes, edges = (_dfs if use_dfs else _bfs)(G, start, depth=2)
print(_subgraph_to_text(G, nodes, edges, token_budget=budget))
print(
_query_graph_text(
G,
question,
mode="dfs" if use_dfs else "bfs",
depth=2,
token_budget=budget,
context_filters=context_filters,
)
)
elif cmd == "save-result":
# graphify save-result --question Q --answer A --type T [--nodes N1 N2 ...]
import argparse as _ap
+137 -41
View File
@@ -150,6 +150,7 @@ def _import_python(node, source: bytes, file_nid: str, stem: str, edges: list, s
"source": file_nid,
"target": tgt_nid,
"relation": "imports",
"context": "import",
"confidence": "EXTRACTED",
"source_file": str_path,
"source_location": f"L{node.start_point[0] + 1}",
@@ -174,6 +175,7 @@ def _import_python(node, source: bytes, file_nid: str, stem: str, edges: list, s
"source": file_nid,
"target": tgt_nid,
"relation": "imports_from",
"context": "import",
"confidence": "EXTRACTED",
"source_file": str_path,
"source_location": f"L{node.start_point[0] + 1}",
@@ -221,6 +223,7 @@ def _import_js(node, source: bytes, file_nid: str, stem: str, edges: list, str_p
"source": file_nid,
"target": tgt_nid,
"relation": "imports_from",
"context": "import",
"confidence": "EXTRACTED",
"source_file": str_path,
"source_location": f"L{node.start_point[0] + 1}",
@@ -285,6 +288,7 @@ def _import_java(node, source: bytes, file_nid: str, stem: str, edges: list, str
"source": file_nid,
"target": tgt_nid,
"relation": "imports",
"context": "import",
"confidence": "EXTRACTED",
"source_file": str_path,
"source_location": f"L{node.start_point[0] + 1}",
@@ -304,6 +308,7 @@ def _import_c(node, source: bytes, file_nid: str, stem: str, edges: list, str_pa
"source": file_nid,
"target": tgt_nid,
"relation": "imports",
"context": "import",
"confidence": "EXTRACTED",
"source_file": str_path,
"source_location": f"L{node.start_point[0] + 1}",
@@ -323,6 +328,7 @@ def _import_csharp(node, source: bytes, file_nid: str, stem: str, edges: list, s
"source": file_nid,
"target": tgt_nid,
"relation": "imports",
"context": "import",
"confidence": "EXTRACTED",
"source_file": str_path,
"source_location": f"L{node.start_point[0] + 1}",
@@ -342,6 +348,7 @@ def _import_kotlin(node, source: bytes, file_nid: str, stem: str, edges: list, s
"source": file_nid,
"target": tgt_nid,
"relation": "imports",
"context": "import",
"confidence": "EXTRACTED",
"source_file": str_path,
"source_location": f"L{node.start_point[0] + 1}",
@@ -357,6 +364,7 @@ def _import_kotlin(node, source: bytes, file_nid: str, stem: str, edges: list, s
"source": file_nid,
"target": tgt_nid,
"relation": "imports",
"context": "import",
"confidence": "EXTRACTED",
"source_file": str_path,
"source_location": f"L{node.start_point[0] + 1}",
@@ -376,6 +384,7 @@ def _import_scala(node, source: bytes, file_nid: str, stem: str, edges: list, st
"source": file_nid,
"target": tgt_nid,
"relation": "imports",
"context": "import",
"confidence": "EXTRACTED",
"source_file": str_path,
"source_location": f"L{node.start_point[0] + 1}",
@@ -395,6 +404,7 @@ def _import_php(node, source: bytes, file_nid: str, stem: str, edges: list, str_
"source": file_nid,
"target": tgt_nid,
"relation": "imports",
"context": "import",
"confidence": "EXTRACTED",
"source_file": str_path,
"source_location": f"L{node.start_point[0] + 1}",
@@ -696,6 +706,7 @@ def _import_lua(node, source: bytes, file_nid: str, stem: str, edges: list, str_
"source": file_nid,
"target": module_name,
"relation": "imports",
"context": "import",
"confidence": "EXTRACTED",
"confidence_score": 1.0,
"source_file": str_path,
@@ -730,6 +741,7 @@ def _import_swift(node, source: bytes, file_nid: str, stem: str, edges: list, st
"source": file_nid,
"target": tgt_nid,
"relation": "imports",
"context": "import",
"confidence": "EXTRACTED",
"source_file": str_path,
"source_location": f"L{node.start_point[0] + 1}",
@@ -756,6 +768,27 @@ def _import_vbnet(node, source: bytes, file_nid: str, stem: str, edges: list, st
})
def _read_csharp_type_name(node, source: bytes) -> str | None:
"""Resolve a readable C# type name from a field/type node."""
if node is None:
return None
if node.type in ("identifier", "predefined_type"):
return _read_text(node, source)
if node.type == "qualified_name":
return _read_text(node, source).split(".")[-1]
if node.type == "generic_name":
name_node = node.child_by_field_name("name")
if name_node is not None:
return _read_text(name_node, source)
for child in node.children:
if not child.is_named:
continue
name = _read_csharp_type_name(child, source)
if name:
return name
return None
_SWIFT_CONFIG = LanguageConfig(
ts_module="tree_sitter_swift",
class_types=frozenset({"class_declaration", "protocol_declaration"}),
@@ -833,8 +866,9 @@ def _extract_generic(path: Path, config: LanguageConfig) -> dict:
})
def add_edge(src: str, tgt: str, relation: str, line: int,
confidence: str = "EXTRACTED", weight: float = 1.0) -> None:
edges.append({
confidence: str = "EXTRACTED", weight: float = 1.0,
context: str | None = None) -> None:
edge = {
"source": src,
"target": tgt,
"relation": relation,
@@ -842,7 +876,19 @@ def _extract_generic(path: Path, config: LanguageConfig) -> dict:
"source_file": str_path,
"source_location": f"L{line}",
"weight": weight,
})
}
if context:
edge["context"] = context
edges.append(edge)
def ensure_named_node(name: str, line: int) -> str:
nid = _make_id(stem, name)
if nid in seen_ids:
return nid
nid = _make_id(name)
if nid not in seen_ids:
add_node(nid, name, line)
return nid
file_nid = _make_id(str(path))
add_node(file_nid, path.name, 1)
@@ -1078,6 +1124,23 @@ def _extract_generic(path: Path, config: LanguageConfig) -> dict:
break
return
if (config.ts_module == "tree_sitter_c_sharp"
and t == "field_declaration"
and parent_class_nid):
type_node = node.child_by_field_name("type")
if type_node is None:
for child in node.children:
if child.type == "variable_declaration":
type_node = child.child_by_field_name("type")
if type_node is not None:
break
type_name = _read_csharp_type_name(type_node, source)
if type_name:
line = node.start_point[0] + 1
add_edge(parent_class_nid, ensure_named_node(type_name, line),
"references", line, context="field")
return
# Function types
if t in config.function_types:
# Swift deinit/subscript have no name field — resolve before generic fallback
@@ -1304,6 +1367,7 @@ def _extract_generic(path: Path, config: LanguageConfig) -> dict:
"source": caller_nid,
"target": tgt_nid,
"relation": "calls",
"context": "call",
"confidence": "EXTRACTED",
"source_file": str_path,
"source_location": f"L{line}",
@@ -2032,8 +2096,9 @@ def extract_julia(path: Path) -> dict:
})
def add_edge(src: str, tgt: str, relation: str, line: int,
confidence: str = "EXTRACTED", weight: float = 1.0) -> None:
edges.append({
confidence: str = "EXTRACTED", weight: float = 1.0,
context: str | None = None) -> None:
edge = {
"source": src,
"target": tgt,
"relation": relation,
@@ -2041,7 +2106,10 @@ def extract_julia(path: Path) -> dict:
"source_file": str_path,
"source_location": f"L{line}",
"weight": weight,
})
}
if context:
edge["context"] = context
edges.append(edge)
file_nid = _make_id(str(path))
add_node(file_nid, path.name, 1)
@@ -2068,14 +2136,14 @@ def extract_julia(path: Path) -> dict:
callee_name = _read_text(callee, source)
target_nid = _make_id(stem, callee_name)
add_edge(func_nid, target_nid, "calls", body_node.start_point[0] + 1,
confidence="EXTRACTED")
confidence="EXTRACTED", context="call")
# Method call: obj.method(...)
elif callee.type == "field_expression" and len(callee.children) >= 3:
method_node = callee.children[-1]
method_name = _read_text(method_node, source)
target_nid = _make_id(stem, method_name)
add_edge(func_nid, target_nid, "calls", body_node.start_point[0] + 1,
confidence="EXTRACTED")
confidence="EXTRACTED", context="call")
for child in body_node.children:
walk_calls(child, func_nid)
@@ -2176,14 +2244,14 @@ def extract_julia(path: Path) -> dict:
mod_name = _read_text(child, source)
imp_nid = _make_id(mod_name)
add_node(imp_nid, mod_name, line)
add_edge(scope_nid, imp_nid, "imports", line)
add_edge(scope_nid, imp_nid, "imports", line, context="import")
elif child.type == "selected_import":
identifiers = [c for c in child.children if c.type == "identifier"]
if identifiers:
pkg_name = _read_text(identifiers[0], source)
pkg_nid = _make_id(pkg_name)
add_node(pkg_nid, pkg_name, line)
add_edge(scope_nid, pkg_nid, "imports", line)
add_edge(scope_nid, pkg_nid, "imports", line, context="import")
return
for child in node.children:
@@ -2248,8 +2316,9 @@ def extract_go(path: Path) -> dict:
})
def add_edge(src: str, tgt: str, relation: str, line: int,
confidence: str = "EXTRACTED", weight: float = 1.0) -> None:
edges.append({
confidence: str = "EXTRACTED", weight: float = 1.0,
context: str | None = None) -> None:
edge = {
"source": src,
"target": tgt,
"relation": relation,
@@ -2257,7 +2326,10 @@ def extract_go(path: Path) -> dict:
"source_file": str_path,
"source_location": f"L{line}",
"weight": weight,
})
}
if context:
edge["context"] = context
edges.append(edge)
file_nid = _make_id(str(path))
add_node(file_nid, path.name, 1)
@@ -2331,7 +2403,7 @@ def extract_go(path: Path) -> dict:
# Prefix with go_pkg_ so stdlib names (e.g. "context")
# don't collide with local files of the same basename.
tgt_nid = _make_id("go", "pkg", raw)
add_edge(file_nid, tgt_nid, "imports_from", spec.start_point[0] + 1)
add_edge(file_nid, tgt_nid, "imports_from", spec.start_point[0] + 1, context="import")
# Track local name (alias or last path segment)
alias = spec.child_by_field_name("name")
local_name = _read_text(alias, source) if alias else raw.split("/")[-1]
@@ -2342,7 +2414,7 @@ def extract_go(path: Path) -> dict:
if path_node:
raw = _read_text(path_node, source).strip('"')
tgt_nid = _make_id("go", "pkg", raw)
add_edge(file_nid, tgt_nid, "imports_from", child.start_point[0] + 1)
add_edge(file_nid, tgt_nid, "imports_from", child.start_point[0] + 1, context="import")
alias = child.child_by_field_name("name")
local_name = _read_text(alias, source) if alias else raw.split("/")[-1]
if local_name and local_name != "_" and local_name != ".":
@@ -2393,6 +2465,7 @@ def extract_go(path: Path) -> dict:
"source": caller_nid,
"target": tgt_nid,
"relation": "calls",
"context": "call",
"confidence": "EXTRACTED",
"source_file": str_path,
"source_location": f"L{line}",
@@ -2460,8 +2533,9 @@ def extract_rust(path: Path) -> dict:
})
def add_edge(src: str, tgt: str, relation: str, line: int,
confidence: str = "EXTRACTED", weight: float = 1.0) -> None:
edges.append({
confidence: str = "EXTRACTED", weight: float = 1.0,
context: str | None = None) -> None:
edge = {
"source": src,
"target": tgt,
"relation": relation,
@@ -2469,7 +2543,10 @@ def extract_rust(path: Path) -> dict:
"source_file": str_path,
"source_location": f"L{line}",
"weight": weight,
})
}
if context:
edge["context"] = context
edges.append(edge)
file_nid = _make_id(str(path))
add_node(file_nid, path.name, 1)
@@ -2526,7 +2603,7 @@ def extract_rust(path: Path) -> dict:
module_name = clean.split("::")[-1].strip()
if module_name:
tgt_nid = _make_id(module_name)
add_edge(file_nid, tgt_nid, "imports_from", node.start_point[0] + 1)
add_edge(file_nid, tgt_nid, "imports_from", node.start_point[0] + 1, context="import")
return
for child in node.children:
@@ -2573,6 +2650,7 @@ def extract_rust(path: Path) -> dict:
"source": caller_nid,
"target": tgt_nid,
"relation": "calls",
"context": "call",
"confidence": "EXTRACTED",
"source_file": str_path,
"source_location": f"L{line}",
@@ -2635,10 +2713,14 @@ def extract_zig(path: Path) -> dict:
"source_file": str_path, "source_location": f"L{line}"})
def add_edge(src: str, tgt: str, relation: str, line: int,
confidence: str = "EXTRACTED", weight: float = 1.0) -> None:
edges.append({"source": src, "target": tgt, "relation": relation,
"confidence": confidence, "source_file": str_path,
"source_location": f"L{line}", "weight": weight})
confidence: str = "EXTRACTED", weight: float = 1.0,
context: str | None = None) -> None:
edge = {"source": src, "target": tgt, "relation": relation,
"confidence": confidence, "source_file": str_path,
"source_location": f"L{line}", "weight": weight}
if context:
edge["context"] = context
edges.append(edge)
file_nid = _make_id(str(path))
add_node(file_nid, path.name, 1)
@@ -2801,10 +2883,14 @@ def extract_powershell(path: Path) -> dict:
"source_file": str_path, "source_location": f"L{line}"})
def add_edge(src: str, tgt: str, relation: str, line: int,
confidence: str = "EXTRACTED", weight: float = 1.0) -> None:
edges.append({"source": src, "target": tgt, "relation": relation,
"confidence": confidence, "source_file": str_path,
"source_location": f"L{line}", "weight": weight})
confidence: str = "EXTRACTED", weight: float = 1.0,
context: str | None = None) -> None:
edge = {"source": src, "target": tgt, "relation": relation,
"confidence": confidence, "source_file": str_path,
"source_location": f"L{line}", "weight": weight}
if context:
edge["context"] = context
edges.append(edge)
file_nid = _make_id(str(path))
add_node(file_nid, path.name, 1)
@@ -3196,10 +3282,14 @@ def extract_objc(path: Path) -> dict:
"source_file": str_path, "source_location": f"L{line}"})
def add_edge(src: str, tgt: str, relation: str, line: int,
confidence: str = "EXTRACTED", weight: float = 1.0) -> None:
edges.append({"source": src, "target": tgt, "relation": relation,
"confidence": confidence, "source_file": str_path,
"source_location": f"L{line}", "weight": weight})
confidence: str = "EXTRACTED", weight: float = 1.0,
context: str | None = None) -> None:
edge = {"source": src, "target": tgt, "relation": relation,
"confidence": confidence, "source_file": str_path,
"source_location": f"L{line}", "weight": weight}
if context:
edge["context"] = context
edges.append(edge)
file_nid = _make_id(str(path))
add_node(file_nid, path.name, 1)
@@ -3223,7 +3313,7 @@ def extract_objc(path: Path) -> dict:
module = raw.split("/")[-1].replace(".h", "")
if module:
tgt_nid = _make_id(module)
add_edge(file_nid, tgt_nid, "imports", line)
add_edge(file_nid, tgt_nid, "imports", line, context="import")
elif child.type == "string_literal":
# recurse into string_literal to find string_content
for sub in child.children:
@@ -3232,7 +3322,7 @@ def extract_objc(path: Path) -> dict:
module = raw.split("/")[-1].replace(".h", "")
if module:
tgt_nid = _make_id(module)
add_edge(file_nid, tgt_nid, "imports", line)
add_edge(file_nid, tgt_nid, "imports", line, context="import")
return
if t == "class_interface":
@@ -3263,7 +3353,7 @@ def extract_objc(path: Path) -> dict:
for s in sub.children:
if s.type == "type_identifier":
proto_nid = _make_id(_read(s))
add_edge(cls_nid, proto_nid, "imports", line)
add_edge(cls_nid, proto_nid, "imports", line, context="import")
elif child.type == "method_declaration":
walk(child, cls_nid)
return
@@ -3355,7 +3445,7 @@ def extract_objc(path: Path) -> dict:
if pair not in seen_calls and caller_nid != candidate:
seen_calls.add(pair)
add_edge(caller_nid, candidate, "calls", body_node.start_point[0] + 1,
confidence="EXTRACTED", weight=1.0)
confidence="EXTRACTED", weight=1.0, context="call")
for child in n.children:
walk_calls(child)
walk_calls(body_node)
@@ -3394,10 +3484,14 @@ def extract_elixir(path: Path) -> dict:
"source_file": str_path, "source_location": f"L{line}"})
def add_edge(src: str, tgt: str, relation: str, line: int,
confidence: str = "EXTRACTED", weight: float = 1.0) -> None:
edges.append({"source": src, "target": tgt, "relation": relation,
"confidence": confidence, "source_file": str_path,
"source_location": f"L{line}", "weight": weight})
confidence: str = "EXTRACTED", weight: float = 1.0,
context: str | None = None) -> None:
edge = {"source": src, "target": tgt, "relation": relation,
"confidence": confidence, "source_file": str_path,
"source_location": f"L{line}", "weight": weight}
if context:
edge["context"] = context
edges.append(edge)
file_nid = _make_id(str(path))
add_node(file_nid, path.name, 1)
@@ -3476,7 +3570,7 @@ def extract_elixir(path: Path) -> dict:
module_name = _get_alias_text(arguments_node)
if module_name:
tgt_nid = _make_id(module_name)
add_edge(file_nid, tgt_nid, "imports", line)
add_edge(file_nid, tgt_nid, "imports", line, context="import")
return
for child in node.children:
@@ -3531,7 +3625,8 @@ def extract_elixir(path: Path) -> dict:
if pair not in seen_call_pairs:
seen_call_pairs.add(pair)
add_edge(caller_nid, tgt_nid, "calls",
node.start_point[0] + 1, confidence="EXTRACTED", weight=1.0)
node.start_point[0] + 1, confidence="EXTRACTED", weight=1.0,
context="call")
else:
raw_calls.append({
"caller_nid": caller_nid,
@@ -3763,6 +3858,7 @@ def extract(paths: list[Path], cache_root: Path | None = None) -> dict:
"source": caller,
"target": tgt,
"relation": "calls",
"context": "call",
"confidence": "INFERRED",
"confidence_score": 0.8,
"source_file": rc.get("source_file", ""),
+111 -9
View File
@@ -63,6 +63,68 @@ def _score_nodes(G: nx.Graph, terms: list[str]) -> list[tuple[float, str]]:
return sorted(scored, reverse=True)
_CONTEXT_HINTS: tuple[tuple[str, tuple[str, ...]], ...] = (
("call", ("call", "calls", "called", "invoke", "invokes", "invoked")),
("import", ("import", "imports", "imported", "module", "modules")),
("field", ("field", "fields", "member", "members", "property", "properties")),
("parameter_type", ("parameter", "parameters", "param", "params", "argument", "arguments")),
("return_type", ("return", "returns", "returned")),
("generic_arg", ("generic", "generics", "template", "templates")),
)
def _normalize_context_filters(filters: list[str] | None) -> list[str]:
if not filters:
return []
normalized: list[str] = []
seen: set[str] = set()
for value in filters:
key = _strip_diacritics(str(value)).strip().lower()
if key and key not in seen:
seen.add(key)
normalized.append(key)
return normalized
def _infer_context_filters(question: str) -> list[str]:
lowered = {
_strip_diacritics(token).lower()
for token in question.replace("?", " ").replace(",", " ").split()
}
inferred: list[str] = []
for context, hints in _CONTEXT_HINTS:
if any(hint in lowered for hint in hints):
inferred.append(context)
return inferred
def _resolve_context_filters(question: str, explicit_filters: list[str] | None = None) -> tuple[list[str], str | None]:
normalized = _normalize_context_filters(explicit_filters)
if normalized:
return normalized, "explicit"
inferred = _infer_context_filters(question)
if inferred:
return inferred, "heuristic"
return [], None
def _filter_graph_by_context(G: nx.Graph, context_filters: list[str] | None) -> nx.Graph:
filters = set(_normalize_context_filters(context_filters))
if not filters:
return G
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:
H.add_edge(u, v, key=key, **data)
else:
for u, v, data in G.edges(data=True):
if data.get("context") in filters:
H.add_edge(u, v, **data)
return H
def _bfs(G: nx.Graph, start_nodes: list[str], depth: int) -> tuple[set[str], list[tuple]]:
visited: set[str] = set(start_nodes)
frontier = set(start_nodes)
@@ -114,7 +176,13 @@ def _subgraph_to_text(G: nx.Graph, nodes: set[str], edges: list[tuple], token_bu
if u in nodes and v in nodes:
raw = G[u][v]
d = next(iter(raw.values()), {}) if isinstance(G, (nx.MultiGraph, nx.MultiDiGraph)) else raw
line = f"EDGE {sanitize_label(G.nodes[u].get('label', u))} --{d.get('relation', '')} [{d.get('confidence', '')}]--> {sanitize_label(G.nodes[v].get('label', v))}"
context = d.get("context")
context_suffix = f" context={context}" if context else ""
line = (
f"EDGE {sanitize_label(G.nodes[u].get('label', u))} "
f"--{d.get('relation', '')} [{d.get('confidence', '')}{context_suffix}]--> "
f"{sanitize_label(G.nodes[v].get('label', v))}"
)
lines.append(line)
output = "\n".join(lines)
if len(output) > char_budget:
@@ -122,6 +190,34 @@ def _subgraph_to_text(G: nx.Graph, nodes: set[str], edges: list[tuple], token_bu
return output
def _query_graph_text(
G: nx.Graph,
question: str,
*,
mode: str = "bfs",
depth: int = 3,
token_budget: int = 2000,
context_filters: list[str] | None = None,
) -> str:
terms = [t.lower() for t in question.split() if len(t) > 2]
scored = _score_nodes(G, terms)
start_nodes = [nid for _, nid in scored[:3]]
if not start_nodes:
return "No matching nodes found."
resolved_filters, filter_source = _resolve_context_filters(question, context_filters)
traversal_graph = _filter_graph_by_context(G, resolved_filters)
nodes, edges = _dfs(traversal_graph, start_nodes, depth) if mode == "dfs" else _bfs(traversal_graph, start_nodes, depth)
header_parts = [
f"Traversal: {mode.upper()} depth={depth}",
f"Start: {[G.nodes[n].get('label', n) for n in start_nodes]}",
]
if resolved_filters:
header_parts.append(f"Context: {', '.join(resolved_filters)} ({filter_source})")
header_parts.append(f"{len(nodes)} nodes found")
header = " | ".join(header_parts) + "\n\n"
return header + _subgraph_to_text(traversal_graph, nodes, edges, token_budget)
def _find_node(G: nx.Graph, label: str) -> list[str]:
"""Return node IDs whose label or ID matches the search term (diacritic-insensitive)."""
term = _strip_diacritics(label).lower()
@@ -188,6 +284,11 @@ def serve(graph_path: str = "graphify-out/graph.json") -> None:
"description": "bfs=broad context, dfs=trace a specific path"},
"depth": {"type": "integer", "default": 3, "description": "Traversal depth (1-6)"},
"token_budget": {"type": "integer", "default": 2000, "description": "Max output tokens"},
"context_filter": {
"type": "array",
"items": {"type": "string"},
"description": "Optional explicit edge-context filter, e.g. ['call', 'field']",
},
},
"required": ["question"],
},
@@ -252,14 +353,15 @@ def serve(graph_path: str = "graphify-out/graph.json") -> None:
mode = arguments.get("mode", "bfs")
depth = min(int(arguments.get("depth", 3)), 6)
budget = int(arguments.get("token_budget", 2000))
terms = [t.lower() for t in question.split() if len(t) > 2]
scored = _score_nodes(G, terms)
start_nodes = [nid for _, nid in scored[:3]]
if not start_nodes:
return "No matching nodes found."
nodes, edges = _dfs(G, start_nodes, depth) if mode == "dfs" else _bfs(G, start_nodes, depth)
header = f"Traversal: {mode.upper()} depth={depth} | Start: {[G.nodes[n].get('label', n) for n in start_nodes]} | {len(nodes)} nodes found\n\n"
return header + _subgraph_to_text(G, nodes, edges, budget, seeds=start_nodes)
context_filter = arguments.get("context_filter")
return _query_graph_text(
G,
question,
mode=mode,
depth=depth,
token_budget=budget,
context_filters=context_filter,
)
def _tool_get_node(arguments: dict) -> str:
label = arguments["label"].lower()
+7
View File
@@ -124,6 +124,13 @@ def test_calls_edges_are_extracted():
assert edge["weight"] == 1.0
def test_python_call_edges_have_call_context():
result = extract_python(FIXTURES / "sample_calls.py")
call_edges = [e for e in result["edges"] if e["relation"] == "calls"]
assert call_edges
assert all(e.get("context") == "call" for e in call_edges)
def test_calls_no_self_loops():
result = extract_python(FIXTURES / "sample_calls.py")
for edge in result["edges"]:
+148
View File
@@ -25,6 +25,22 @@ def _calls(r):
}
def _references(r):
node_by_id = {n["id"]: n["label"] for n in r["nodes"]}
return [
(
node_by_id.get(e["source"], e["source"]),
node_by_id.get(e["target"], e["target"]),
e,
)
for e in r["edges"] if e["relation"] == "references"
]
def _edges_with_relation(r, *relations):
return [e for e in r["edges"] if e["relation"] in relations]
# ── Java ──────────────────────────────────────────────────────────────────────
def test_java_no_error():
@@ -49,6 +65,13 @@ def test_java_finds_imports():
r = extract_java(FIXTURES / "sample.java")
assert "imports" in _relations(r)
def test_java_import_edges_have_import_context():
r = extract_java(FIXTURES / "sample.java")
import_edges = _edges_with_relation(r, "imports", "imports_from")
assert import_edges
assert all(e.get("context") == "import" for e in import_edges)
def test_java_no_dangling_edges():
r = extract_java(FIXTURES / "sample.java")
node_ids = {n["id"] for n in r["nodes"]}
@@ -83,6 +106,20 @@ def test_c_calls_are_extracted():
assert e["confidence"] == "EXTRACTED"
def test_c_import_edges_have_import_context():
r = extract_c(FIXTURES / "sample.c")
import_edges = _edges_with_relation(r, "imports", "imports_from")
assert import_edges
assert all(e.get("context") == "import" for e in import_edges)
def test_c_call_edges_have_call_context():
r = extract_c(FIXTURES / "sample.c")
call_edges = _edges_with_relation(r, "calls")
assert call_edges
assert all(e.get("context") == "call" for e in call_edges)
# ── C++ ───────────────────────────────────────────────────────────────────────
def test_cpp_no_error():
@@ -104,6 +141,13 @@ def test_cpp_finds_includes():
assert "imports" in _relations(r)
def test_cpp_import_edges_have_import_context():
r = extract_cpp(FIXTURES / "sample.cpp")
import_edges = _edges_with_relation(r, "imports", "imports_from")
assert import_edges
assert all(e.get("context") == "import" for e in import_edges)
# ── Ruby ─────────────────────────────────────────────────────────────────────
def test_ruby_no_error():
@@ -164,6 +208,33 @@ def test_csharp_inherits_iprocessor():
assert found, "DataProcessor should have inherits edge to IProcessor"
def test_csharp_field_type_references_have_field_context():
r = extract_csharp(FIXTURES / "sample.cs")
refs = _references(r)
assert any(
"DataProcessor" in src and "HttpClient" in tgt and edge.get("context") == "field"
for src, tgt, edge in refs
), "DataProcessor field declarations should reference HttpClient with field context"
def test_csharp_call_edges_have_call_context():
r = extract_csharp(FIXTURES / "sample.cs")
node_by_id = {n["id"]: n["label"] for n in r["nodes"]}
assert any(
"Process" in node_by_id.get(e["source"], "")
and "Validate" in node_by_id.get(e["target"], "")
and e.get("context") == "call"
for e in r["edges"] if e["relation"] == "calls"
), "C# call edges should retain call context"
def test_csharp_import_edges_have_import_context():
r = extract_csharp(FIXTURES / "sample.cs")
import_edges = [e for e in r["edges"] if e["relation"] == "imports"]
assert import_edges
assert all(e.get("context") == "import" for e in import_edges)
# ── Kotlin ───────────────────────────────────────────────────────────────────
def test_kotlin_no_error():
@@ -222,6 +293,20 @@ def test_scala_finds_methods():
assert any("post" in l for l in labels)
def test_scala_import_edges_have_import_context():
r = extract_scala(FIXTURES / "sample.scala")
import_edges = _edges_with_relation(r, "imports", "imports_from")
assert import_edges
assert all(e.get("context") == "import" for e in import_edges)
def test_scala_call_edges_have_call_context():
r = extract_scala(FIXTURES / "sample.scala")
call_edges = _edges_with_relation(r, "calls")
assert call_edges
assert all(e.get("context") == "call" for e in call_edges)
# ── PHP ───────────────────────────────────────────────────────────────────────
def test_php_no_error():
@@ -246,6 +331,20 @@ def test_php_finds_imports():
r = extract_php(FIXTURES / "sample.php")
assert "imports" in _relations(r)
def test_php_import_edges_have_import_context():
r = extract_php(FIXTURES / "sample.php")
import_edges = _edges_with_relation(r, "imports", "imports_from")
assert import_edges
assert all(e.get("context") == "import" for e in import_edges)
def test_php_call_edges_have_call_context():
r = extract_php(FIXTURES / "sample.php")
call_edges = _edges_with_relation(r, "calls")
assert call_edges
assert all(e.get("context") == "call" for e in call_edges)
def test_php_finds_static_property_access():
r = extract_php(FIXTURES / "sample_php_static_prop.php")
assert "uses_static_prop" in _relations(r)
@@ -331,6 +430,13 @@ def test_swift_finds_imports():
r = extract_swift(FIXTURES / "sample.swift")
assert "imports" in _relations(r)
def test_swift_import_edges_have_import_context():
r = extract_swift(FIXTURES / "sample.swift")
import_edges = _edges_with_relation(r, "imports", "imports_from")
assert import_edges
assert all(e.get("context") == "import" for e in import_edges)
def test_swift_no_dangling_edges():
r = extract_swift(FIXTURES / "sample.swift")
node_ids = {n["id"] for n in r["nodes"]}
@@ -489,6 +595,13 @@ def test_vbnet_no_dangling_edges():
for e in r["edges"]:
assert e["source"] in node_ids
def test_swift_call_edges_have_call_context():
r = extract_swift(FIXTURES / "sample.swift")
call_edges = _edges_with_relation(r, "calls")
assert call_edges
assert all(e.get("context") == "call" for e in call_edges)
# ── Elixir ────────────────────────────────────────────────────────────────────
from graphify.extract import extract_elixir
@@ -511,12 +624,26 @@ def test_elixir_finds_imports():
import_edges = [e for e in r["edges"] if e["relation"] == "imports"]
assert len(import_edges) >= 2
def test_elixir_import_edges_have_import_context():
r = extract_elixir(FIXTURES / "sample.ex")
import_edges = _edges_with_relation(r, "imports", "imports_from")
assert import_edges
assert all(e.get("context") == "import" for e in import_edges)
def test_elixir_finds_calls():
r = extract_elixir(FIXTURES / "sample.ex")
calls = {(e["source"], e["target"]) for e in r["edges"] if e["relation"] == "calls"}
labels = {n["id"]: n["label"] for n in r["nodes"]}
assert any("create" in labels.get(src, "") and "validate" in labels.get(tgt, "") for src, tgt in calls)
def test_elixir_call_edges_have_call_context():
r = extract_elixir(FIXTURES / "sample.ex")
call_edges = _edges_with_relation(r, "calls")
assert call_edges
assert all(e.get("context") == "call" for e in call_edges)
def test_elixir_method_edges():
r = extract_elixir(FIXTURES / "sample.ex")
methods = [e for e in r["edges"] if e["relation"] == "method"]
@@ -551,6 +678,13 @@ def test_objc_finds_imports():
assert len(import_edges) >= 1
def test_objc_import_edges_have_import_context():
r = extract_objc(FIXTURES / "sample.m")
import_edges = _edges_with_relation(r, "imports", "imports_from")
assert import_edges
assert all(e.get("context") == "import" for e in import_edges)
def test_objc_inherits_edge():
r = extract_objc(FIXTURES / "sample.m")
inherits = [e for e in r["edges"] if e["relation"] == "inherits"]
@@ -626,6 +760,13 @@ def test_julia_finds_imports():
assert len(import_edges) >= 1
def test_julia_import_edges_have_import_context():
r = extract_julia(FIXTURES / "sample.jl")
import_edges = _edges_with_relation(r, "imports", "imports_from")
assert import_edges
assert all(e.get("context") == "import" for e in import_edges)
def test_julia_finds_inherits():
r = extract_julia(FIXTURES / "sample.jl")
inherits = [e for e in r["edges"] if e["relation"] == "inherits"]
@@ -638,6 +779,13 @@ def test_julia_finds_calls():
assert len(call_edges) >= 1
def test_julia_call_edges_have_call_context():
r = extract_julia(FIXTURES / "sample.jl")
call_edges = _edges_with_relation(r, "calls")
assert call_edges
assert all(e.get("context") == "call" for e in call_edges)
def test_julia_no_dangling_edges():
r = extract_julia(FIXTURES / "sample.jl")
node_ids = {n["id"] for n in r["nodes"]}
+46
View File
@@ -24,6 +24,10 @@ def _confidences(result):
return {e["confidence"] for e in result["edges"]}
def _edges_with_relation(result, *relations):
return [e for e in result["edges"] if e["relation"] in relations]
# ── TypeScript ────────────────────────────────────────────────────────────────
def test_ts_finds_class():
@@ -53,6 +57,20 @@ def test_ts_calls_are_extracted():
if e["relation"] == "calls":
assert e["confidence"] == "EXTRACTED"
def test_ts_import_edges_have_import_context():
r = extract_js(FIXTURES / "sample.ts")
import_edges = _edges_with_relation(r, "imports", "imports_from")
assert import_edges
assert all(e.get("context") == "import" for e in import_edges)
def test_ts_call_edges_have_call_context():
r = extract_js(FIXTURES / "sample.ts")
call_edges = _edges_with_relation(r, "calls")
assert call_edges
assert all(e.get("context") == "call" for e in call_edges)
def test_ts_no_dangling_edges():
r = extract_js(FIXTURES / "sample.ts")
node_ids = {n["id"] for n in r["nodes"]}
@@ -87,6 +105,20 @@ def test_go_has_extracted_calls():
r = extract_go(FIXTURES / "sample.go")
assert "EXTRACTED" in _confidences(r)
def test_go_import_edges_have_import_context():
r = extract_go(FIXTURES / "sample.go")
import_edges = _edges_with_relation(r, "imports", "imports_from")
assert import_edges
assert all(e.get("context") == "import" for e in import_edges)
def test_go_call_edges_have_call_context():
r = extract_go(FIXTURES / "sample.go")
call_edges = _edges_with_relation(r, "calls")
assert call_edges
assert all(e.get("context") == "call" for e in call_edges)
def test_go_no_dangling_edges():
r = extract_go(FIXTURES / "sample.go")
node_ids = {n["id"] for n in r["nodes"]}
@@ -123,6 +155,20 @@ def test_rust_calls_are_extracted():
if e["relation"] == "calls":
assert e["confidence"] == "EXTRACTED"
def test_rust_import_edges_have_import_context():
r = extract_rust(FIXTURES / "sample.rs")
import_edges = _edges_with_relation(r, "imports", "imports_from")
assert import_edges
assert all(e.get("context") == "import" for e in import_edges)
def test_rust_call_edges_have_call_context():
r = extract_rust(FIXTURES / "sample.rs")
call_edges = _edges_with_relation(r, "calls")
assert call_edges
assert all(e.get("context") == "call" for e in call_edges)
def test_rust_no_dangling_edges():
r = extract_rust(FIXTURES / "sample.rs")
node_ids = {n["id"] for n in r["nodes"]}
+51
View File
@@ -0,0 +1,51 @@
"""Tests for graphify query CLI context filtering."""
from __future__ import annotations
import json
import networkx as nx
from networkx.readwrite import json_graph
import graphify.__main__ as mainmod
def _write_graph(tmp_path):
G = nx.Graph()
G.add_node("n1", label="extract", source_file="extract.py", source_location="L10", community=0)
G.add_node("n2", label="cluster", source_file="cluster.py", source_location="L5", community=0)
G.add_node("n3", label="build", source_file="build.py", source_location="L1", community=1)
G.add_edge("n1", "n2", relation="calls", confidence="EXTRACTED", context="call")
G.add_edge("n2", "n3", relation="imports", confidence="EXTRACTED", context="import")
graph_path = tmp_path / "graph.json"
graph_path.write_text(json.dumps(json_graph.node_link_data(G, edges="links")))
return graph_path
def test_query_cli_explicit_context_filter(monkeypatch, tmp_path, capsys):
graph_path = _write_graph(tmp_path)
monkeypatch.setattr(mainmod, "_check_skill_version", lambda _: None)
monkeypatch.setattr(
mainmod.sys,
"argv",
["graphify", "query", "extract", "--context", "call", "--graph", str(graph_path)],
)
mainmod.main()
out = capsys.readouterr().out
assert "Context: call (explicit)" in out
assert "cluster" in out
assert "build" not in out
def test_query_cli_heuristic_context_filter(monkeypatch, tmp_path, capsys):
graph_path = _write_graph(tmp_path)
monkeypatch.setattr(mainmod, "_check_skill_version", lambda _: None)
monkeypatch.setattr(
mainmod.sys,
"argv",
["graphify", "query", "who calls extract", "--graph", str(graph_path)],
)
mainmod.main()
out = capsys.readouterr().out
assert "Context: call (heuristic)" in out
assert "cluster" in out
assert "build" not in out
+47 -2
View File
@@ -9,6 +9,10 @@ from graphify.serve import (
_score_nodes,
_bfs,
_dfs,
_filter_graph_by_context,
_infer_context_filters,
_query_graph_text,
_resolve_context_filters,
_subgraph_to_text,
_load_graph,
)
@@ -21,8 +25,8 @@ def _make_graph() -> nx.Graph:
G.add_node("n3", label="build", source_file="build.py", source_location="L1", community=1)
G.add_node("n4", label="report", source_file="report.py", source_location="L1", community=1)
G.add_node("n5", label="isolated", source_file="other.py", source_location="L1", community=2)
G.add_edge("n1", "n2", relation="calls", confidence="INFERRED")
G.add_edge("n2", "n3", relation="imports", confidence="EXTRACTED")
G.add_edge("n1", "n2", relation="calls", confidence="INFERRED", context="call")
G.add_edge("n2", "n3", relation="imports", confidence="EXTRACTED", context="import")
G.add_edge("n3", "n4", relation="uses", confidence="EXTRACTED")
return G
@@ -73,6 +77,16 @@ def test_score_nodes_source_file_partial():
assert "n2" in nids
def test_infer_context_filters_for_calls_question():
assert _infer_context_filters("who calls extract") == ["call"]
def test_resolve_context_filters_explicit_overrides_heuristic():
filters, source = _resolve_context_filters("who calls extract", ["field"])
assert filters == ["field"]
assert source == "explicit"
# --- _bfs ---
def test_bfs_depth_1():
@@ -99,6 +113,15 @@ def test_bfs_returns_edges():
assert any(u == "n1" or v == "n1" for u, v in edges)
def test_filter_graph_by_context_limits_traversal():
G = _make_graph()
filtered = _filter_graph_by_context(G, ["call"])
visited, edges = _bfs(filtered, ["n1"], depth=2)
assert "n2" in visited
assert "n3" not in visited
assert edges == [("n1", "n2")]
# --- _dfs ---
def test_dfs_depth_1():
@@ -135,6 +158,28 @@ def test_subgraph_to_text_edge_included():
assert "calls" in text
def test_subgraph_to_text_includes_edge_context():
G = _make_graph()
text = _subgraph_to_text(G, {"n1", "n2"}, [("n1", "n2")])
assert "context=call" in text
def test_query_graph_text_explicit_context_filter_changes_traversal():
G = _make_graph()
text = _query_graph_text(G, "extract", mode="bfs", depth=2, token_budget=2000, context_filters=["call"])
assert "Context: call (explicit)" in text
assert "cluster" in text
assert "build" not in text
def test_query_graph_text_heuristic_context_filter_changes_traversal():
G = _make_graph()
text = _query_graph_text(G, "who calls extract", mode="bfs", depth=2, token_budget=2000)
assert "Context: call (heuristic)" in text
assert "cluster" in text
assert "build" not in text
# --- _load_graph ---
def test_load_graph_roundtrip(tmp_path):