From f15566e1334c90b8d715b2f80293d2f830b9845d Mon Sep 17 00:00:00 2001 From: Steve Pelech Date: Thu, 12 Nov 2026 15:36:13 -0600 Subject: [PATCH] fix(diagnose): respect the repo filter and rank candidates from the matched route Co-Authored-By: Claude Opus 5.5 --- REQUIREMENTS.md | 12 +- app/services/diagnostic_route_path.py | 152 ++++++++++++++++ app/services/diagnostics.py | 117 ++++++++---- docs/mcp-tools-and-resources.md | 2 +- docs/reference/mcp-tools.md | 2 +- ...t_diagnose_repo_filter_route_candidates.py | 167 ++++++++++++++++++ 6 files changed, 411 insertions(+), 41 deletions(-) create mode 100644 app/services/diagnostic_route_path.py create mode 100644 tests/test_diagnose_repo_filter_route_candidates.py diff --git a/REQUIREMENTS.md b/REQUIREMENTS.md index ce17591..207bc63 100644 --- a/REQUIREMENTS.md +++ b/REQUIREMENTS.md @@ -2,7 +2,7 @@ > **Note:** This document is automatically generated and verified against the live test suite by `scripts/generate_requirements.py` and `tests/backend/test_requirements_sync.py`. -**Test Verification Baseline:** **1199 Automated Tests** (835 Pytest Backend + 314 Vitest Frontend + 50 Playwright E2E). +**Test Verification Baseline:** **1207 Automated Tests** (843 Pytest Backend + 314 Vitest Frontend + 50 Playwright E2E). --- @@ -985,6 +985,16 @@ code results are enriched with signature/kind/ast_symbol_id from SQLite._ - `test_wait_for_db_retries_and_success` - `test_wait_for_db_exhausts_retries` +#### `tests/test_diagnose_repo_filter_route_candidates.py` (8 tests) +- `test_external_callers_respect_repo` +- `test_external_callers_cross_repo_without_filter` +- `test_stack_frame_does_not_fall_back_to_other_repo` +- `test_route_call_path_ranks_first_and_noise_is_filtered` +- `test_route_call_path_fills_limit_and_skips_search` +- `test_semantic_hits_without_route_still_filter_repo` +- `test_language_family` +- `test_keep_semantic_hit_rules` + #### `tests/test_diagnostics_and_services_multilang.py` (7 tests) - `test_parse_csharp_posix_and_windows_frames` - `test_parse_ts_react_vite_and_bundler_frames` diff --git a/app/services/diagnostic_route_path.py b/app/services/diagnostic_route_path.py new file mode 100644 index 0000000..4145158 --- /dev/null +++ b/app/services/diagnostic_route_path.py @@ -0,0 +1,152 @@ +"""Route-path candidates and relevance filters for `diagnose_issue` semantic candidates. + +When an HTTP route in the issue text matches an indexed route, the route handler and the +methods it calls (the call path below the handler) are the most likely places for the fault. +These candidates come before free-text semantic hits. +""" + +import json +import logging +import os +from typing import Any, Dict, List, Optional, Set + +from app.services.graph_traversal import trace_symbol_path +from app.services.stack_trace_parser import _is_test_path + +logger = logging.getLogger("contextcortex.diagnostics") + +ROUTE_CALLEE_DEPTH = 3 +MAX_ROUTE_TRACE_NODES = 40 + +_LANGUAGE_ALIASES = { + "c_sharp": "csharp", "cs": "csharp", "c#": "csharp", + "py": "python", + "ts": "typescript", "tsx": "typescript", + "js": "javascript", "jsx": "javascript", "mjs": "javascript", "cjs": "javascript", + "cbl": "cobol", "cob": "cobol", "cpy": "cobol", + "golang": "go", "rs": "rust", + "cpp": "cpp", "cc": "cpp", "cxx": "cpp", "hpp": "cpp", "h": "cpp", "c": "cpp", +} +_CODE_EXTENSIONS = {"cs", "py", "ts", "tsx", "js", "jsx", "mjs", "cjs", "cbl", "cob", "cpy", "go", "rs", + "java", "cpp", "cc", "cxx", "hpp", "h", "c"} + + +def language_family(language: Optional[str], path: Optional[str] = None) -> Optional[str]: + """Returns a normalized language name from a language tag, or from the file extension.""" + lang = (language or "").strip().lower() + if lang and lang not in ("text", "unknown"): + return _LANGUAGE_ALIASES.get(lang, lang) + ext = os.path.splitext((path or "").lower())[1].lstrip(".") + if ext in _CODE_EXTENSIONS: + return _LANGUAGE_ALIASES.get(ext, ext) + return None + + +def _walk_callees(nodes: List[Dict[str, Any]], depth: int, out: List[Dict[str, Any]]) -> None: + """Flattens a trace_path callees tree in breadth-first order (nearest callees first).""" + level = [(n, depth) for n in nodes] + while level: + nxt = [] + for node, d in level: + out.append({"symbol": node.get("symbol"), "repo": node.get("repo"), "depth": d}) + nxt.extend((child, d + 1) for child in node.get("callees") or []) + level = nxt + + +def _find_definition(conn, symbol: str, repo: Optional[str]) -> Optional[Dict[str, Any]]: + """Finds the indexed definition of a symbol in the given repo (production code only).""" + if not symbol: + return None + sql = ( + "SELECT repo, filepath, name, full_symbol, kind, start_line, end_line, signature, language " + "FROM ast_symbols WHERE (full_symbol = ?" + ) + params: List[Any] = [symbol] + if "." not in symbol: + sql += " OR name = ?" + params.append(symbol) + sql += ")" + if repo: + sql += " AND repo = ?" + params.append(repo) + rows = [dict(r) for r in conn.execute(sql + " LIMIT 10", params).fetchall()] + rows = [r for r in rows if not _is_test_path(r["filepath"])] + if not rows: + return None + rows.sort(key=lambda r: (0 if r["full_symbol"] == symbol else 1, int(r["end_line"]) - int(r["start_line"]))) + return rows[0] + + +def collect_route_path_symbols( + conn, + matched_routes: List[Dict[str, Any]], + repo: Optional[str] = None, + limit: int = 5, +) -> List[Dict[str, Any]]: + """Returns indexed definitions for matched route handlers and their callees, nearest first. + + Each item is an ast_symbols row with `source` ("route_handler" or "route_callee") and `depth` + (0 for the handler). Calls that do not resolve to an indexed definition (framework or library + calls) are skipped. + """ + results: List[Dict[str, Any]] = [] + seen: Set[tuple] = set() + + def _add(defn: Optional[Dict[str, Any]], source: str, depth: int) -> None: + if not defn or len(results) >= limit: + return + key = (defn["repo"], defn["full_symbol"] or defn["name"], defn["filepath"], defn["start_line"]) + if key in seen: + return + seen.add(key) + results.append({**defn, "source": source, "depth": depth}) + + handlers = [] + for route in matched_routes or []: + handler = route.get("handler") or {} + h_repo, h_sym = handler.get("repo"), handler.get("symbol") + if not h_sym or (repo and h_repo != repo): + continue + defn = _find_definition(conn, h_sym, h_repo) + _add(defn, "route_handler", 0) + handlers.append((h_sym, h_repo, defn)) + + for h_sym, h_repo, defn in handlers: + if len(results) >= limit: + break + trace_sym = (defn["full_symbol"] or defn["name"]) if defn else h_sym + try: + trace = json.loads(trace_symbol_path( + symbol=trace_sym, repo=h_repo, direction="callees", + depth=ROUTE_CALLEE_DEPTH, limit=MAX_ROUTE_TRACE_NODES, + )) + except Exception as e: + logger.debug(f"Could not trace callees of route handler {trace_sym}: {e}") + continue + flat: List[Dict[str, Any]] = [] + _walk_callees(trace.get("callees") or [], 1, flat) + for node in flat: + node_repo = node.get("repo") or h_repo + if repo and node_repo != repo: + continue + _add(_find_definition(conn, node.get("symbol") or "", node_repo), "route_callee", node["depth"]) + return results + + +def keep_semantic_hit( + payload: Dict[str, Any], + repo: Optional[str], + route_languages: Set[str], +) -> bool: + """Drops semantic hits from other repos, test files, and (when a route matched) other languages.""" + hit_repo = payload.get("repo") + if repo and hit_repo and hit_repo != repo: + return False + hit_path = payload.get("rel_path") or payload.get("path") or "" + if hit_path and _is_test_path(hit_path): + return False + if route_languages: + hit_lang = language_family(payload.get("language"), hit_path) + if hit_lang and hit_lang not in route_languages: + return False + return True diff --git a/app/services/diagnostics.py b/app/services/diagnostics.py index 6a5752c..18bc2a2 100644 --- a/app/services/diagnostics.py +++ b/app/services/diagnostics.py @@ -4,6 +4,7 @@ from app.services.chunking import match_route_and_call, normalize_path_pattern from app.services.database import get_db_connection +from app.services.diagnostic_route_path import collect_route_path_symbols, keep_semantic_hit, language_family from app.services.file_reader import get_file_reader_service from app.services.git_manager import format_git_permalink from app.services.search import execute_hybrid_search @@ -164,7 +165,8 @@ def _query_candidates(repo_filter: Optional[str]) -> List[Any]: q_params.append(repo_filter) return conn.execute(q_sql + " LIMIT 30", q_params).fetchall() - rows = _query_candidates(repo) or (_query_candidates(None) if repo else []) + # With an explicit repo, never fall back to files from other repos. + rows = _query_candidates(repo) if not rows: return None @@ -265,6 +267,8 @@ def match_routes_and_callers( ).fetchall() all_calls = conn.execute( "SELECT id, repo, filepath, http_method, url_pattern, caller_symbol, line_number FROM api_client_calls" + + (" WHERE repo = ?" if repo else ""), + ([repo] if repo else []), ).fetchall() for method, path in extracted_routes: @@ -345,60 +349,95 @@ def match_symbols_and_exceptions( return matched +def _build_candidate(conn, git_repos, item: Dict[str, Any]) -> Dict[str, Any]: + """Adds a permalink and 1-hop callers/callees to a candidate (route-path or semantic).""" + target_repo, file_path, sym = item["repo"], item["file"], item["symbol"] + start_l, end_l = item["lines"] + permalink = item.pop("permalink", None) + if permalink and "\\" in permalink: + permalink = permalink.replace("\\", "/") + if not permalink and target_repo in git_repos: + g_info = git_repos[target_repo] + permalink = format_git_permalink( + g_info["url"], g_info["commit_sha"], file_path, start_l, end_l, provider=g_info.get("provider") + ) + callers, callees = [], [] + if sym: + c_list, ce_list = _get_callers_and_callees( + conn, target_repo, sym, enclosing_filepath=file_path, enclosing_start_line=start_l, enclosing_end_line=end_l + ) + callers = [c["symbol"] for c in c_list if c.get("symbol")] + callees = [ce["symbol"] for ce in ce_list if ce.get("symbol")] + return {**item, "permalink": permalink, "callers": callers, "callees": callees} + + +def _read_symbol_code(raw_path: str, repo: str, start_l: int, end_l: int) -> str: + try: + res = get_file_reader_service().read_file(path=raw_path, repo=repo, start_line=start_l, end_line=end_l) + return (res.get("content") or "")[:MAX_CANDIDATE_CHARS] + except Exception as e: + logger.debug(f"Could not read route-path candidate code for {raw_path}:{start_l}: {e}") + return "" + + def find_semantic_candidates( issue_text: str, repo: Optional[str] = None, limit: int = 5, signals: Optional[Dict[str, Any]] = None, frames: Optional[List[Dict[str, Any]]] = None, + matched_routes: Optional[List[Dict[str, Any]]] = None, ) -> List[Dict[str, Any]]: - """Executes hybrid semantic+lexical search and enriches candidates with 1-hop callers/callees.""" - candidates = [] + """Returns ranked code candidates: the matched route's call path first, then filtered hybrid search hits.""" + candidates: List[Dict[str, Any]] = [] if not issue_text or not issue_text.strip(): return candidates - query_str = _build_semantic_query(issue_text, signals or {}, frames or []) if (signals or frames) else issue_text[:2000] - try: - hits = execute_hybrid_search(query_text=query_str, doc_type="code", repo=repo, limit=limit) - except Exception as e: - logger.warning(f"Hybrid search failed in diagnostics: {e}") - hits = [] with get_db_connection() as conn: git_repos = { r["name"]: dict(r) for r in conn.execute("SELECT name, url, commit_sha, provider FROM git_repositories").fetchall() } - for h in hits[:limit]: + route_syms = collect_route_path_symbols(conn, matched_routes or [], repo=repo, limit=limit) + route_languages = {lang for s in route_syms if (lang := language_family(s.get("language"), s["filepath"]))} + seen = set() + for s in route_syms: + file_path = _clean_diag_path(s["filepath"]) + sym = s["full_symbol"] or s["name"] + seen.add((s["repo"], file_path, sym)) + candidates.append(_build_candidate(conn, git_repos, { + "score": None, "source": s["source"], "depth": s["depth"], "repo": s["repo"], "file": file_path, + "lines": [s["start_line"], s["end_line"]], "symbol": sym, "kind": s["kind"], "signature": s["signature"], + "content": _read_symbol_code(s["filepath"], s["repo"], s["start_line"], s["end_line"]), + })) + + remaining = limit - len(candidates) + hits = [] + if remaining > 0: + query_str = _build_semantic_query(issue_text, signals or {}, frames or []) if (signals or frames) else issue_text[:2000] + try: + hits = execute_hybrid_search(query_text=query_str, doc_type="code", repo=repo, limit=limit) + except Exception as e: + logger.warning(f"Hybrid search failed in diagnostics: {e}") + for h in hits: + if len(candidates) >= limit: + break p = h.payload - target_repo = p.get("repo") or repo or "unknown" + if not keep_semantic_hit(p, repo, route_languages): + continue file_path = _clean_diag_path(p.get("rel_path") or p.get("path") or "") - start_l, end_l = p.get("start_line", 1), p.get("end_line", 1) sym = p.get("full_symbol") or p.get("symbol") - - permalink = p.get("permalink_url") or p.get("github_url") - if permalink and "\\" in permalink: - permalink = permalink.replace("\\", "/") - if not permalink and target_repo in git_repos: - g_info = git_repos[target_repo] - permalink = format_git_permalink( - g_info["url"], g_info["commit_sha"], file_path, start_l, end_l, provider=g_info.get("provider") - ) - - callers, callees = [], [] - if sym: - c_list, ce_list = _get_callers_and_callees( - conn, target_repo, sym, enclosing_filepath=file_path, enclosing_start_line=start_l, enclosing_end_line=end_l - ) - callers = [c["symbol"] for c in c_list if c.get("symbol")] - callees = [ce["symbol"] for ce in ce_list if ce.get("symbol")] - + target_repo = p.get("repo") or repo or "unknown" + if (target_repo, file_path, sym) in seen: + continue score_val = float(h.score) if isinstance(getattr(h, "score", None), (int, float)) else 0.0 - candidates.append({ - "score": round(score_val, 4), "repo": target_repo, "file": file_path, - "lines": [start_l, end_l], "symbol": sym, "kind": p.get("kind"), - "signature": p.get("signature"), "content": (p.get("content") or "")[:MAX_CANDIDATE_CHARS], - "permalink": permalink, "callers": callers, "callees": callees, - }) + candidates.append(_build_candidate(conn, git_repos, { + "score": round(score_val, 4), "source": "semantic", "depth": None, "repo": target_repo, + "file": file_path, "lines": [p.get("start_line", 1), p.get("end_line", 1)], "symbol": sym, + "kind": p.get("kind"), "signature": p.get("signature"), + "content": (p.get("content") or "")[:MAX_CANDIDATE_CHARS], + "permalink": p.get("permalink_url") or p.get("github_url"), + })) return candidates @@ -424,14 +463,16 @@ def diagnose_issue( frame_symbols.append(f["program"]) merged_candidate_symbols = list(OrderedDict.fromkeys(signals.get("symbols", []) + frame_symbols)) + matched_routes = match_routes_and_callers(signals["routes"], repo=repo) return { "resolved_frames": resolve_stack_frames(parsed_frames, repo=repo, context_lines=clamped_context), - "matched_routes": match_routes_and_callers(signals["routes"], repo=repo), + "matched_routes": matched_routes, "matched_symbols": match_symbols_and_exceptions( signals["exceptions"], signals["quoted_errors"], repo=repo, candidate_symbols=merged_candidate_symbols ), "semantic_candidates": find_semantic_candidates( - issue_text=safe_text, repo=repo, limit=clamped_limit, signals=signals, frames=parsed_frames + issue_text=safe_text, repo=repo, limit=clamped_limit, signals=signals, frames=parsed_frames, + matched_routes=matched_routes, ), } diff --git a/docs/mcp-tools-and-resources.md b/docs/mcp-tools-and-resources.md index 8fa0642..29a7ba4 100644 --- a/docs/mcp-tools-and-resources.md +++ b/docs/mcp-tools-and-resources.md @@ -103,7 +103,7 @@ Paginated MCP resource templates (`files/{page}` and `routes/{page}`) use 1-inde - **`manage_local_file`**: Supports `upload`, `replace`, `delete` (`editor` role) and `read`, `preview` (`viewer` role) inside `LOCAL_STORAGE_PATH` with immediate vector and AST indexing. - **`what_is_ingested`**: Inspects `all`, `git`, `monitored_path`, or `local_storage` sources. Set `include_files=True` (or `detail_level='detailed'`) with `limit` (`1..500`, default `100`) and `offset` (`>= 0`) to paginate file paths per source (`total_files`, `offset`, `limit`, `has_more`, `next_offset`) without dumping thousands of paths. -- **`diagnose_issue`**: Resolves stack traces, error string literals, HTTP routes, and semantic code candidates across Python, TypeScript/JS/TSX, C#, COBOL, Go, Rust, Java, and C/C++. Clamps `context_lines` (`1..15`, default `5`) and `limit` (`1..20`, default `5`). +- **`diagnose_issue`**: Resolves stack traces, error string literals, HTTP routes, and semantic code candidates across Python, TypeScript/JS/TSX, C#, COBOL, Go, Rust, Java, and C/C++. Clamps `context_lines` (`1..15`, default `5`) and `limit` (`1..20`, default `5`). When `repo` is set, every result section (stack frames, route handlers, external callers, symbols, and candidates) stays in that repo. When an HTTP route matches, `semantic_candidates` starts with the route handler and its callees (`source`: `route_handler` / `route_callee`, `depth` from the handler, `score`: `null`). Search hits (`source`: `semantic`) fill the remaining slots; hits from test files or from a different language than the route handler are dropped. --- diff --git a/docs/reference/mcp-tools.md b/docs/reference/mcp-tools.md index 10bd6ba..7019fe5 100644 --- a/docs/reference/mcp-tools.md +++ b/docs/reference/mcp-tools.md @@ -82,7 +82,7 @@ Forensic triage tool that accepts stack traces, error logs, or user bug reports - `repo` (*string, optional*): Optional repository identifier to scope diagnostics and resolution. - `context_lines` (*integer, default: 5*): Number of surrounding code lines to include for resolved stack frames (`1` to `15`). - `limit` (*integer, default: 5*): Maximum number of semantic code candidate blocks to retrieve (`1` to `20`). -- **Returns**: Structured JSON payload containing `resolved_frames` (stack frames mapped to indexed files, line numbers, enclosing AST symbols, and code context), `matched_routes` (HTTP routes referenced in the error log), `matched_symbols`, `literal_matches` (exact error strings found in source code), and `semantic_candidates`. +- **Returns**: Structured JSON payload containing `resolved_frames` (stack frames mapped to indexed files, line numbers, enclosing AST symbols, and code context), `matched_routes` (HTTP routes referenced in the error log), `matched_symbols`, `literal_matches` (exact error strings found in source code), and `semantic_candidates`. With `repo` set, all sections stay in that repo. When an HTTP route matches, `semantic_candidates` lists the route handler and its callees first (`source`: `route_handler` or `route_callee`, `depth`, `score`: `null`), then search hits (`source`: `semantic`) that are not test code and use the same language as the handler. --- diff --git a/tests/test_diagnose_repo_filter_route_candidates.py b/tests/test_diagnose_repo_filter_route_candidates.py new file mode 100644 index 0000000..379a40e --- /dev/null +++ b/tests/test_diagnose_repo_filter_route_candidates.py @@ -0,0 +1,167 @@ +"""diagnose_issue must respect `repo` everywhere and rank the matched route's call path first.""" + +from unittest.mock import MagicMock, patch + +import pytest + +from app.services.database import get_db_connection +from app.services.database.engine import get_db_engine, init_db +from app.services.diagnostic_route_path import keep_semantic_hit, language_family +from app.services.diagnostics import diagnose_issue, match_routes_and_callers, resolve_stack_frames + +REPO = "hub-prefix" +OTHER = "assistant-hub" +ISSUE = ( + "Publishing a tool from the UI fails. `POST /api/v2/Tools/{id}/publish` returns HTTP 500. " + "The API log shows: System.InvalidOperationException: ExecuteReader requires the command to have a " + "transaction when the connection assigned to the command is in a pending local transaction." +) +CLASSES = { + "ToolsController": ("Controllers/ToolsController.cs", "ToolsController(IToolService toolService)"), + "ToolService": ("Services/ToolService.cs", "ToolService(IToolRepository toolRepository)"), + "ToolRepository": ("Data/ToolRepository.cs", "ToolRepository(IDbConnection connection)"), +} +IMPLEMENTS = {"ToolService": "IToolService", "ToolRepository": "IToolRepository"} + + +def _add_symbol(conn, repo, fp, name, full, kind, start, end, sig, lang="c_sharp"): + return conn.execute( + "INSERT INTO ast_symbols (repo, filepath, name, full_symbol, kind, start_line, end_line, signature, language) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", + (repo, f"{repo}://{fp}", name, full, kind, start, end, sig, lang), + ).lastrowid + + +@pytest.fixture +def hub_db(tmp_path, monkeypatch): + db_file = tmp_path / "diag_repo_filter.db" + db_url = f"sqlite:///{db_file}" + monkeypatch.setenv("DATABASE_URL", db_url) + monkeypatch.setattr("app.services.database.connection.CACHE_DB_PATH", str(db_file)) + monkeypatch.setattr("app.services.database.CACHE_DB_PATH", str(db_file), raising=False) + init_db(engine=get_db_engine(db_url, reset=True)) + + with get_db_connection() as conn: + ids = {} + for repo in (REPO, OTHER): + for cls, (fp, ctor_sig) in CLASSES.items(): + _add_symbol(conn, repo, fp, cls, cls, "class_declaration", 1, 100, f"public class {cls}") + _add_symbol(conn, repo, fp, cls, f"{cls}.{cls}", "constructor_declaration", 5, 9, ctor_sig) + ids[(repo, cls)] = _add_symbol( + conn, repo, fp, "PublishInstance", f"{cls}.PublishInstance", "method_declaration", 20, 40, + "PublishInstance(Guid id)", + ) + for cls, iface in IMPLEMENTS.items(): + _add_symbol(conn, repo, f"Interfaces/{iface}.cs", iface, iface, "interface_declaration", 1, 10, iface) + _add_symbol(conn, repo, f"Interfaces/{iface}.cs", "PublishInstance", f"{iface}.PublishInstance", + "method_declaration", 3, 3, "PublishInstance(Guid id)") + conn.execute( + "INSERT INTO ast_relationships (repo, source_filepath, source_symbol, target_symbol, relationship_type, line_number) " + "VALUES (?, ?, ?, ?, 'IMPLEMENTS', 1)", + (repo, f"{repo}://{CLASSES[cls][0]}", cls, iface), + ) + # Controller -> service -> repository; the repository calls a framework method (not indexed). + for cls, target, line in (("ToolsController", "PublishInstance", 30), ("ToolService", "PublishInstance", 30), + ("ToolRepository", "ExecuteReader", 32)): + conn.execute( + "INSERT INTO ast_relationships (repo, source_symbol_id, source_filepath, source_symbol, target_symbol, relationship_type, line_number) " + "VALUES (?, ?, ?, 'PublishInstance', ?, 'CALLS', ?)", + (repo, ids[(repo, cls)], f"{repo}://{CLASSES[cls][0]}", target, line), + ) + conn.execute( + "INSERT INTO api_routes (repo, filepath, framework, http_method, path_pattern, handler_symbol, start_line, end_line) " + "VALUES (?, ?, 'aspnet', 'POST', '/api/v2/tools/{id}/publish', 'ToolsController.PublishInstance', 20, 40)", + (repo, f"{repo}://Controllers/ToolsController.cs"), + ) + conn.execute( + "INSERT INTO api_client_calls (repo, filepath, http_method, url_pattern, caller_symbol, line_number) " + "VALUES (?, ?, 'POST', '/api/v2/tools/{id}/publish', ?, 12)", + (repo, f"{repo}://ClientApp/src/api/tools.ts", f"publishTool_{repo}"), + ) + conn.execute( + "INSERT INTO indexed_files (filepath, repo, doc_type, language) VALUES (?, ?, 'code', 'python')", + (f"{OTHER}://scripts/only_here.py", OTHER), + ) + conn.commit() + + +def _hit(repo, path, symbol, score, language): + hit = MagicMock() + hit.score = score + hit.payload = {"repo": repo, "rel_path": path, "start_line": 1, "end_line": 9, "symbol": symbol, + "full_symbol": symbol, "kind": "method", "language": language, "content": "..."} + return hit + + +NOISE_HITS = [ + _hit(OTHER, "Models/SystemToolId.cs", "SystemToolId", 0.61, "c_sharp"), + _hit(REPO, "scripts/static_analysis.py", "main", 0.58, "python"), + _hit(REPO, "Models/SystemToolNames.cs", "SystemToolNames", 0.55, "c_sharp"), + _hit(REPO, "Tests/ToolTests.cs", "ToolTests.Publish", 0.54, "c_sharp"), +] + + +def test_external_callers_respect_repo(hub_db): + routes = match_routes_and_callers([("POST", "/api/v2/Tools/{id}/publish")], repo=REPO) + assert [r["handler"]["repo"] for r in routes] == [REPO] + assert [c["repo"] for c in routes[0]["external_callers"]] == [REPO] + + +def test_external_callers_cross_repo_without_filter(hub_db): + routes = match_routes_and_callers([("POST", "/api/v2/Tools/{id}/publish")]) + callers = {c["repo"] for r in routes for c in r["external_callers"]} + assert callers == {REPO, OTHER} + + +def test_stack_frame_does_not_fall_back_to_other_repo(hub_db): + frames = [{"path": "scripts/only_here.py", "line": 3, "function": None}] + assert resolve_stack_frames(frames, repo=REPO)[0]["repo"] == REPO + assert resolve_stack_frames(frames)[0]["repo"] == OTHER + + +def test_route_call_path_ranks_first_and_noise_is_filtered(hub_db): + with patch("app.services.diagnostics.execute_hybrid_search", return_value=NOISE_HITS): + result = diagnose_issue(ISSUE, repo=REPO, limit=5) + + assert result["resolved_frames"] == [] + assert [r["handler"]["symbol"] for r in result["matched_routes"]] == ["ToolsController.PublishInstance"] + cands = result["semantic_candidates"] + assert {c["repo"] for c in cands} == {REPO} + assert [(c["symbol"], c["source"], c["depth"]) for c in cands] == [ + ("ToolsController.PublishInstance", "route_handler", 0), + ("ToolService.PublishInstance", "route_callee", 1), + ("ToolRepository.PublishInstance", "route_callee", 2), + ("SystemToolNames", "semantic", None), + ] + assert cands[0]["score"] is None and cands[3]["score"] == 0.55 + assert cands[0]["file"].endswith("Controllers/ToolsController.cs") + assert cands[0]["lines"] == [20, 40] + + +def test_route_call_path_fills_limit_and_skips_search(hub_db): + with patch("app.services.diagnostics.execute_hybrid_search", return_value=NOISE_HITS) as search: + cands = diagnose_issue(ISSUE, repo=REPO, limit=2)["semantic_candidates"] + assert [c["symbol"] for c in cands] == ["ToolsController.PublishInstance", "ToolService.PublishInstance"] + search.assert_not_called() + + +def test_semantic_hits_without_route_still_filter_repo(hub_db): + with patch("app.services.diagnostics.execute_hybrid_search", return_value=NOISE_HITS): + cands = diagnose_issue("Publishing fails with an InvalidOperationException", repo=REPO)["semantic_candidates"] + # No route matched: no language filter, but other repos and test files are dropped. + assert [c["symbol"] for c in cands] == ["main", "SystemToolNames"] + + +@pytest.mark.parametrize("language, path, expected", [ + ("c_sharp", "a.cs", "csharp"), (None, "x/Foo.cs", "csharp"), ("python", "", "python"), + ("text", "README.md", None), (None, "lib/app.tsx", "typescript"), +]) +def test_language_family(language, path, expected): + assert language_family(language, path) == expected + + +def test_keep_semantic_hit_rules(): + assert keep_semantic_hit({"repo": REPO, "rel_path": "a.cs", "language": "c_sharp"}, REPO, {"csharp"}) + assert not keep_semantic_hit({"repo": OTHER, "rel_path": "a.cs"}, REPO, set()) + assert not keep_semantic_hit({"repo": REPO, "rel_path": "a.py", "language": "python"}, REPO, {"csharp"}) + assert keep_semantic_hit({"repo": REPO, "rel_path": "notes"}, REPO, {"csharp"})