Skip to content
Merged
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
12 changes: 11 additions & 1 deletion REQUIREMENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

---

Expand Down Expand Up @@ -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`
Expand Down
152 changes: 152 additions & 0 deletions app/services/diagnostic_route_path.py
Original file line number Diff line number Diff line change
@@ -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
117 changes: 79 additions & 38 deletions app/services/diagnostics.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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


Expand All @@ -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,
),
}

Expand Down
2 changes: 1 addition & 1 deletion docs/mcp-tools-and-resources.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

---

Expand Down
2 changes: 1 addition & 1 deletion docs/reference/mcp-tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

---

Expand Down
Loading
Loading