Skip to content
Open
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
2 changes: 2 additions & 0 deletions graphify/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -556,6 +556,8 @@ def _run_cli() -> None:
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(" --seed-ignore P exclude matching paths from initial seeds (repeatable)")
print(" defaults to tests/; GRAPHIFY_QUERY_IGNORE_PATTERNS overrides (empty disables)")
print(" --budget N cap output at N tokens (default 2000)")
print(" --graph <path> path to graph.json (default graphify-out/graph.json)")
print(" affected \"X\" reverse traversal to find nodes impacted by X")
Expand Down
14 changes: 13 additions & 1 deletion graphify/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -851,7 +851,7 @@ def dispatch_command(cmd: str) -> None:
sys.exit(1)
elif cmd == "query":
if len(sys.argv) < 3:
print("Usage: graphify query \"<question>\" [--dfs] [--context C] [--budget N] [--graph path]", file=sys.stderr)
print("Usage: graphify query \"<question>\" [--dfs] [--context C] [--seed-ignore P] [--budget N] [--graph path]", file=sys.stderr)
sys.exit(1)
from graphify.serve import _query_graph_text
from graphify.security import sanitize_label
Expand All @@ -863,6 +863,7 @@ def dispatch_command(cmd: str) -> None:
budget = 2000
graph_path = _default_graph_path()
context_filters: list[str] = []
seed_ignore_patterns: list[str] | None = None
args = sys.argv[3:]
i = 0
while i < len(args):
Expand All @@ -886,6 +887,16 @@ def dispatch_command(cmd: str) -> None:
elif args[i].startswith("--context="):
context_filters.append(args[i].split("=", 1)[1])
i += 1
elif args[i] == "--seed-ignore" and i + 1 < len(args):
if seed_ignore_patterns is None:
seed_ignore_patterns = []
seed_ignore_patterns.append(args[i + 1])
i += 2
elif args[i].startswith("--seed-ignore="):
if seed_ignore_patterns is None:
seed_ignore_patterns = []
seed_ignore_patterns.append(args[i].split("=", 1)[1])
i += 1
elif args[i] == "--graph" and i + 1 < len(args):
graph_path = args[i + 1]
i += 2
Expand Down Expand Up @@ -956,6 +967,7 @@ def dispatch_command(cmd: str) -> None:
depth=2,
token_budget=budget,
context_filters=context_filters,
seed_ignore_patterns=seed_ignore_patterns,
)
querylog.log_query(
kind="query",
Expand Down
93 changes: 91 additions & 2 deletions graphify/serve.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
from graphify.security import sanitize_label, check_graph_file_size_cap
from graphify.build import edge_data, edge_datas
from graphify.paths import default_graph_json as _default_graph_json
from graphify.detect import _match_anchored_ignore_pattern

try:
import jieba as _jieba # type: ignore[import-untyped]
Expand Down Expand Up @@ -430,8 +431,75 @@ def _score_nodes(G: nx.Graph, terms: list[str]) -> list[tuple[float, str]]:
return _score_query(G, terms, collect_per_term_seeds=False).ranked


_DEFAULT_SEED_IGNORE_PATTERNS = ("tests/", "**/tests/**")


def _normalize_seed_ignore_patterns(patterns: object) -> list[str]:
"""Normalize query seed exclusion globs without changing their order."""
if patterns is None:
return []
if isinstance(patterns, str):
raw_patterns = [patterns]
elif isinstance(patterns, list):
raw_patterns = patterns
else:
return []

normalized: list[str] = []
for raw in raw_patterns:
pattern = str(raw).strip().replace("\\", "/")
negated = pattern.startswith("!")
if negated:
pattern = pattern[1:].lstrip("/")
else:
pattern = pattern.lstrip("/")
if pattern:
normalized.append(("!" if negated else "") + pattern)
return normalized


def _resolve_seed_ignore_patterns(explicit_patterns: list[str] | None = None) -> list[str]:
"""Resolve query-only seed exclusions: CLI/MCP, then env, then defaults."""
if explicit_patterns is not None:
return _normalize_seed_ignore_patterns(explicit_patterns)
env_patterns = os.environ.get("GRAPHIFY_QUERY_IGNORE_PATTERNS")
if env_patterns is not None:
return _normalize_seed_ignore_patterns(env_patterns.split(","))
return list(_DEFAULT_SEED_IGNORE_PATTERNS)


def _source_is_seed_ignored(source_file: object, patterns: list[str]) -> bool:
"""Match root-relative node paths with the discovery ignore glob matcher."""
if not patterns or not isinstance(source_file, str) or not source_file:
return False
source = source_file.replace("\\", "/").strip("/")
if not source:
return False

ignored = False
for raw_pattern in patterns:
negated = raw_pattern.startswith("!")
pattern = raw_pattern[1:] if negated else raw_pattern
# A directory pattern applies to everything beneath it. Bare patterns
# keep gitignore's any-directory behavior while patterns with a slash
# are evaluated root-relative.
directory_only = pattern.endswith("/")
pattern = pattern.rstrip("/")
if "/" not in pattern:
pattern = f"**/{pattern}"
if directory_only:
pattern = f"{pattern}/**"
if _match_anchored_ignore_pattern(source, pattern):
ignored = not negated
return ignored


def _score_query(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Health regression_score_query()

14 callers depend on it (afferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Health regression_score_query()

14 callers depend on it (afferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Health regression_score_query()

14 callers depend on it (afferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

G: nx.Graph, terms: list[str], *, collect_per_term_seeds: bool
G: nx.Graph,
terms: list[str],
*,
collect_per_term_seeds: bool,
seed_ignore_patterns: list[str] | None = None,
) -> _QueryScores:
"""Single-pass combined scorer that optionally also records the best seed
for each normalized query token.
Expand Down Expand Up @@ -491,6 +559,8 @@ def _score_query(
{} if collect_per_term_seeds else None
)
for nid, data in node_iter:
if _source_is_seed_ignored(data.get("source_file"), seed_ignore_patterns or []):
continue
norm_label = data.get("norm_label") or _strip_diacritics(data.get("label") or "").lower()
bare_label = norm_label.rstrip("()")
# Tokenized form of the label (punctuation stripped, same transform as the
Expand Down Expand Up @@ -1090,6 +1160,7 @@ def _query_graph_text(
depth: int = 3,
token_budget: int = 2000,
context_filters: list[str] | None = None,
seed_ignore_patterns: list[str] | None = None,
) -> str:
terms = _query_terms(question)
# One graph scoring pass produces both the combined ranking (used to drive
Expand All @@ -1098,8 +1169,19 @@ def _query_graph_text(
# — one combined + one per query token — re-walking the whole graph each
# time; on a 100k-node, three-term benchmark ~71% of scoring time was
# spent in those redundant per-term passes.
qs = _score_query(G, terms, collect_per_term_seeds=True)
resolved_seed_ignore = _resolve_seed_ignore_patterns(seed_ignore_patterns)
qs = _score_query(
G,
terms,
collect_per_term_seeds=True,
seed_ignore_patterns=resolved_seed_ignore,
)
start_nodes = _pick_seeds(qs.ranked, G=G, best_seed_by_term=qs.best_seed_by_term)
# A test-focused query can legitimately match only test nodes. Fall back to
# the unfiltered scorer in that case while keeping exclusions seed-only.
if not start_nodes and resolved_seed_ignore:
qs = _score_query(G, terms, collect_per_term_seeds=True)
start_nodes = _pick_seeds(qs.ranked, G=G, best_seed_by_term=qs.best_seed_by_term)
if not start_nodes:
return "No matching nodes found."
resolved_filters, filter_source = _resolve_context_filters(question, context_filters)
Expand Down Expand Up @@ -1361,6 +1443,11 @@ async def list_tools() -> list[types.Tool]:
"items": {"type": "string"},
"description": "Optional explicit edge-context filter, e.g. ['call', 'field']",
},
"seed_ignore_patterns": {
"type": "array",
"items": {"type": "string"},
"description": "Optional root-relative globs excluded from initial query seeds",
},
},
"required": ["question"],
},
Expand Down Expand Up @@ -1497,6 +1584,7 @@ def _tool_query_graph(arguments: dict) -> str:
depth = min(int(arguments.get("depth", 3)), 6)
budget = int(arguments.get("token_budget", 2000))
context_filter = arguments.get("context_filter")
seed_ignore_patterns = arguments.get("seed_ignore_patterns")
_t0 = _time.perf_counter()
result = _query_graph_text(
G,
Expand All @@ -1505,6 +1593,7 @@ def _tool_query_graph(arguments: dict) -> str:
depth=depth,
token_budget=budget,
context_filters=context_filter,
seed_ignore_patterns=seed_ignore_patterns,
)
querylog.log_query(
kind="mcp_query",
Expand Down
49 changes: 49 additions & 0 deletions tests/test_query_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,55 @@ def test_query_cli_heuristic_context_filter(monkeypatch, tmp_path, capsys):
assert "build" not in out


def test_query_cli_seed_ignore_pattern(monkeypatch, tmp_path, capsys):
G = nx.Graph()
G.add_node("production", label="CrawlEngine", source_file="src/crawler.py", source_location="L1")
G.add_node("generated", label="Engine", source_file="generated/noise.py", source_location="L1")
graph_path = tmp_path / "graph.json"
graph_path.write_text(json.dumps(json_graph.node_link_data(G, edges="links")))

monkeypatch.setattr(mainmod, "_check_skill_version", lambda _: None)
monkeypatch.setattr(
mainmod.sys,
"argv",
[
"graphify",
"query",
"engine",
"--seed-ignore",
"generated/**",
"--graph",
str(graph_path),
],
)
mainmod.main()
out = capsys.readouterr().out

assert "Start: ['CrawlEngine']" in out
assert "NODE CrawlEngine" in out
assert "NODE Engine" not in out


def test_query_cli_empty_seed_ignore_disables_default(monkeypatch, tmp_path, capsys):
G = nx.Graph()
G.add_node("production", label="CrawlEngine", source_file="src/crawler.py", source_location="L1")
G.add_node("test_noise", label="Engine", source_file="tests/test_crawler.py", source_location="L1")
graph_path = tmp_path / "graph.json"
graph_path.write_text(json.dumps(json_graph.node_link_data(G, edges="links")))

monkeypatch.setattr(mainmod, "_check_skill_version", lambda _: None)
monkeypatch.setattr(
mainmod.sys,
"argv",
["graphify", "query", "engine", "--seed-ignore=", "--graph", str(graph_path)],
)
mainmod.main()
out = capsys.readouterr().out

assert "Start: ['Engine']" in out
assert "NODE Engine" in out


def _write_calls_graph(tmp_path):
"""A single directed `calls` edge on an (on-disk) undirected graph.json,

Expand Down
87 changes: 87 additions & 0 deletions tests/test_serve.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@
_query_terms,
_query_graph_text,
_resolve_context_filters,
_normalize_seed_ignore_patterns,
_resolve_seed_ignore_patterns,
_source_is_seed_ignored,
_subgraph_to_text,
_cut_lines_to_budget,
_load_graph,
Expand Down Expand Up @@ -431,6 +434,90 @@ def test_query_graph_text_keeps_short_non_english_terms():
assert "NODE 前端" in text


def _seed_ignore_graph():
G = nx.Graph()
G.add_node("production", label="CrawlEngine", source_file="src/crawler.py", source_location="L1")
G.add_node("test_noise", label="Engine", source_file="mcp-svc/tests/test_crawler.py", source_location="L1")
return G


def test_default_seed_ignore_patterns_match_root_and_nested_tests(monkeypatch):
monkeypatch.delenv("GRAPHIFY_QUERY_IGNORE_PATTERNS", raising=False)
patterns = _resolve_seed_ignore_patterns()

assert _source_is_seed_ignored("tests/test_crawler.py", patterns)
assert _source_is_seed_ignored("mcp-svc/tests/test_crawler.py", patterns)
assert _source_is_seed_ignored("nested/generated/noise.py", ["generated/"])
assert not _source_is_seed_ignored("docs/testing.md", patterns)


def test_seed_ignore_env_replaces_defaults_and_supports_negation(monkeypatch):
monkeypatch.setenv(
"GRAPHIFY_QUERY_IGNORE_PATTERNS",
"generated/**,!generated/keep/**",
)
patterns = _resolve_seed_ignore_patterns()

assert _source_is_seed_ignored("generated/noise.py", patterns)
assert not _source_is_seed_ignored("generated/keep/example.py", patterns)
assert not _source_is_seed_ignored("tests/test_crawler.py", patterns)
assert _resolve_seed_ignore_patterns([]) == []


def test_seed_ignore_normalization_handles_untrusted_mcp_values():
assert _normalize_seed_ignore_patterns("generated/**") == ["generated/**"]
assert _normalize_seed_ignore_patterns({"generated": "**"}) == []


def test_query_default_seed_ignore_keeps_production_symbol_ahead_of_test_noise(monkeypatch):
monkeypatch.delenv("GRAPHIFY_QUERY_IGNORE_PATTERNS", raising=False)
text = _query_graph_text(_seed_ignore_graph(), "engine", depth=0)

assert "Start: ['CrawlEngine']" in text
assert "NODE CrawlEngine" in text
assert "NODE Engine" not in text


def test_seed_ignore_is_query_only_and_keeps_reachable_test_context(monkeypatch):
monkeypatch.delenv("GRAPHIFY_QUERY_IGNORE_PATTERNS", raising=False)
G = _seed_ignore_graph()
G.add_edge("production", "test_noise", relation="calls", confidence="EXTRACTED")

# explain/path score through _score_nodes, which must retain prior behavior.
assert _score_nodes(G, ["engine"])[0][1] == "test_noise"

text = _query_graph_text(G, "engine", depth=1)
assert "Start: ['CrawlEngine']" in text
assert "NODE Engine" in text


def test_query_empty_seed_ignore_patterns_restores_test_seed(monkeypatch):
monkeypatch.delenv("GRAPHIFY_QUERY_IGNORE_PATTERNS", raising=False)
text = _query_graph_text(_seed_ignore_graph(), "engine", depth=0, seed_ignore_patterns=[])

assert "Start: ['Engine']" in text
assert "NODE Engine" in text


def test_empty_seed_ignore_environment_restores_test_seed(monkeypatch):
monkeypatch.setenv("GRAPHIFY_QUERY_IGNORE_PATTERNS", "")
text = _query_graph_text(_seed_ignore_graph(), "engine", depth=0)

assert "Start: ['Engine']" in text
assert "NODE Engine" in text


def test_query_seed_ignore_falls_back_when_only_test_nodes_match(monkeypatch):
monkeypatch.delenv("GRAPHIFY_QUERY_IGNORE_PATTERNS", raising=False)
G = nx.Graph()
G.add_node("test_engine", label="Engine", source_file="tests/test_engine.py", source_location="L1")

text = _query_graph_text(G, "engine", depth=0)

assert "Start: ['Engine']" in text
assert "NODE Engine" in text


def test_infer_context_filters_for_calls_question():
assert _infer_context_filters("who calls extract") == ["call"]

Expand Down
33 changes: 32 additions & 1 deletion tests/test_serve_http.py
Original file line number Diff line number Diff line change
Expand Up @@ -166,8 +166,39 @@ def test_tools_list_over_http(tmp_path):
json={"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {}},
)
assert resp.status_code == 200
names = {t["name"] for t in resp.json()["result"]["tools"]}
tools = resp.json()["result"]["tools"]
names = {t["name"] for t in tools}
assert {"query_graph", "get_node", "graph_stats"} <= names
query_graph = next(tool for tool in tools if tool["name"] == "query_graph")
assert "seed_ignore_patterns" in query_graph["inputSchema"]["properties"]


def test_query_graph_mcp_honors_seed_ignore_patterns(tmp_path):
graph = {
"directed": True,
"nodes": [
{"id": "production", "label": "CrawlEngine", "source_file": "src/crawler.py"},
{"id": "generated", "label": "Engine", "source_file": "generated/noise.py"},
],
"edges": [],
}
graph_path = tmp_path / "graph.json"
graph_path.write_text(json.dumps(graph), encoding="utf-8")
app = serve_mod._build_http_app(str(graph_path), json_response=True)

with _client(app) as client:
headers = _init_session(client)
result = _call_tool(
client,
headers,
"query_graph",
{"question": "engine", "seed_ignore_patterns": ["generated/**"]},
rid=2,
)

assert "Start: ['CrawlEngine']" in result
assert "NODE CrawlEngine" in result
assert "NODE Engine" not in result


def _project_with_graph(tmp_path, node_count: int, name: str = "proj") -> str:
Expand Down