diff --git a/graphify/__main__.py b/graphify/__main__.py index 924ae986d..7160a6b72 100644 --- a/graphify/__main__.py +++ b/graphify/__main__.py @@ -556,6 +556,8 @@ def _run_cli() -> None: print(" query \"\" 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 to graph.json (default graphify-out/graph.json)") print(" affected \"X\" reverse traversal to find nodes impacted by X") diff --git a/graphify/cli.py b/graphify/cli.py index 56c32140a..ccd051e16 100644 --- a/graphify/cli.py +++ b/graphify/cli.py @@ -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 \"\" [--dfs] [--context C] [--budget N] [--graph path]", file=sys.stderr) + print("Usage: graphify query \"\" [--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 @@ -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): @@ -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 @@ -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", diff --git a/graphify/serve.py b/graphify/serve.py index 5684b01eb..f9965dd72 100644 --- a/graphify/serve.py +++ b/graphify/serve.py @@ -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] @@ -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( - 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. @@ -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 @@ -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 @@ -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) @@ -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"], }, @@ -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, @@ -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", diff --git a/tests/test_query_cli.py b/tests/test_query_cli.py index 0db4e6fa8..ec50401fe 100644 --- a/tests/test_query_cli.py +++ b/tests/test_query_cli.py @@ -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, diff --git a/tests/test_serve.py b/tests/test_serve.py index fdaa2753c..3d30e322b 100644 --- a/tests/test_serve.py +++ b/tests/test_serve.py @@ -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, @@ -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"] diff --git a/tests/test_serve_http.py b/tests/test_serve_http.py index 7893a0f4e..1a60599f5 100644 --- a/tests/test_serve_http.py +++ b/tests/test_serve_http.py @@ -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: