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
8 changes: 7 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:** **1178 Automated Tests** (814 Pytest Backend + 314 Vitest Frontend + 50 Playwright E2E).
**Test Verification Baseline:** **1182 Automated Tests** (818 Pytest Backend + 314 Vitest Frontend + 50 Playwright E2E).

---

Expand Down Expand Up @@ -953,6 +953,12 @@ code results are enriched with signature/kind/ast_symbol_id from SQLite._
- `test_cobol_end_to_end_indexing_architecture_and_trace_path`
- `test_local_storage_cobol_persists_language_and_relationships` - _Uploading COBOL programs via LocalStorageService persists language='cobol' and ast_relationships for trace_path & diagnose_issue._

#### `tests/test_csharp_client_calls.py` (4 tests)
- `test_url_variable_with_conditional_gives_both_routes`
- `test_interpolated_literal_and_custom_client_name`
- `test_variables_without_a_url_are_ignored`
- `test_csharp_rules_do_not_apply_to_other_languages`

#### `tests/test_database_engine.py` (11 tests)
- `test_schema_metadata_contains_all_tables`
- `test_schema_api_keys_columns`
Expand Down
14 changes: 11 additions & 3 deletions app/services/chunking/api_client_calls.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
"""
HTTP client call-site extraction (frontend and service code) for find_api_callers and cross-repo trace_path.

Recognizes fetch/axios/requests/httpx/Go http/C# HttpClient calls, and project-specific JS/TS request
helpers (for example getJson(`/api/accounts/${id}`) or apiClient.post('/api/items', ...)).
Recognizes fetch/axios/requests/httpx/Go http/C# HttpClient calls (also HttpRequestMessage and URL
variables, see csharp_client_calls), and project-specific JS/TS request helpers (for example getJson(`/api/accounts/${id}`) or apiClient.post('/api/items', ...)).
"""

import re
Expand All @@ -11,7 +11,9 @@
from app.models.schemas import ApiClientCallRecord, CodeSymbol

_JS_LANGUAGES = ("javascript", "typescript", "tsx")
_FETCH_CALL = re.compile(r'\b(?:fetch|apiRequest|httpRequest|requestJson)\b[\s\S]*?\(\s*(["\'`]([^"\'`]+)["\'`])')
_CS_LANGUAGES = ("c_sharp", "csharp")
# The call itself only: `httpRequest.Headers.Add("x")` is not a request (optional generic: requestJson<T>(...)).
_FETCH_CALL = re.compile(r'\b(?:fetch|apiRequest|httpRequest|requestJson)\s*(?:<[^<>()]*>)?\s*\(\s*(["\'`]([^"\'`]+)["\'`])')
_AXIOS_CALL = re.compile(r'\baxios\.(get|post|put|delete|patch|options|head)\s*\(\s*(["\'`]([^"\'`]+)["\'`])', re.IGNORECASE)
_PY_CALL = re.compile(r'\b(?:requests|httpx|client)\.(get|post|put|delete|patch|options|head)\s*\(\s*f?["\']([^"\']+)["\']', re.IGNORECASE)
_GO_CALL = re.compile(r'\bhttp\.(Get|Post|Head)\s*\(\s*["\']([^"\']+)["\']')
Expand Down Expand Up @@ -84,6 +86,7 @@ def extract_client_calls(
) -> List[ApiClientCallRecord]:
"""Extracts HTTP client call sites line by line."""
from app.services.chunking.api_route_extractor import find_enclosing_symbol, normalize_path_pattern
from app.services.chunking.csharp_client_calls import extract_csharp_client_calls

calls: List[ApiClientCallRecord] = []

Expand Down Expand Up @@ -122,6 +125,11 @@ def add(method: Optional[str], url_raw: str, line_no: int) -> None:
add(method, m_cs_client.group(2), i)
continue

if language in _CS_LANGUAGES:
for method, url in extract_csharp_client_calls(line, lines, i):
add(method, url, i)
continue

if language in _JS_LANGUAGES:
helper = _js_helper_call(line)
if helper:
Expand Down
83 changes: 83 additions & 0 deletions app/services/chunking/csharp_client_calls.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
"""
C# HTTP client call sites whose URL is not a literal in the call.

Recognizes:
- `new HttpRequestMessage(HttpMethod.Post, url)` and `new HttpRequestMessage(new HttpMethod("PATCH"), "api/x")`
- `_apiClient.GetAsync(url)` / `PostAsJsonAsync(url, body)` with a variable or an interpolated string (`$"api/items/{id}"`)

A variable is resolved from its nearest assignment in the lines before the call (`string url = cond ? $"a" : $"b";`).
Each string literal in that statement gives one call (both branches of a conditional are callers).
"""

import re
from typing import List, Optional, Tuple

_LOOKBACK_LINES = 40
_STATEMENT_LINES = 6
_VERBS = ("GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS")

# A C# string literal: "x", $"x/{id}", @"x", $@"x". Group 1 is the content.
_CS_LITERAL = r'(?:\$@|@\$|\$|@)?"((?:[^"\\\n]|\\.)*)"'
_LITERAL_RE = re.compile(_CS_LITERAL + r'(\s*\+\s*[A-Za-z_][\w.]*(?:\(\))?)?')
_IDENTIFIER = r'[A-Za-z_][\w.]*'
_ARGUMENT = r'(' + _CS_LITERAL + r'(?:\s*\+\s*' + _IDENTIFIER + r'(?:\(\))?)?|' + _IDENTIFIER + r')'

_REQUEST_MESSAGE = re.compile(
r'new\s+HttpRequestMessage\s*\(\s*(?:HttpMethod\.(\w+)|new\s+HttpMethod\s*\(\s*"(\w+)"\s*\))\s*,\s*'
+ _ARGUMENT,
)
_CLIENT_METHOD = re.compile(
r'\b\w*client\.(Get|Post|Put|Delete|Patch)\w*Async\s*(?:<[^<>()]*>)?\(\s*' + _ARGUMENT + r'\s*[,)]',
re.IGNORECASE,
)


def _looks_like_path(text: str) -> bool:
return "/" in text and " " not in text.strip() and not text.startswith("//")


def _literal_urls(statement: str) -> List[str]:
"""URL-like string literals of a statement; `"api/items/" + id` becomes `api/items/{id}`."""
urls = []
for m in _LITERAL_RE.finditer(statement):
text = m.group(1)
if m.group(2):
text = text.rstrip("/") + "/{" + m.group(2).split("+", 1)[1].strip().split(".")[0].rstrip("()") + "}"
if _looks_like_path(text):
urls.append(text)
return urls


def _resolve_variable(name: str, lines: List[str], line_no: int) -> List[str]:
"""URL literals assigned to `name` in the nearest assignment above line `line_no` (1-based)."""
short = name.rsplit(".", 1)[-1]
assign = re.compile(r'\b' + re.escape(short) + r'\s*=(?![=>])')
first = max(0, line_no - 1 - _LOOKBACK_LINES)
for idx in range(line_no - 1, first - 1, -1):
m = assign.search(lines[idx])
if not m:
continue
statement = "\n".join(lines[idx:idx + _STATEMENT_LINES])[m.end():]
statement = statement.split(";", 1)[0]
return _literal_urls(statement)
return []


def _urls_for_argument(arg: str, lines: List[str], line_no: int) -> List[str]:
if '"' in arg:
return _literal_urls(arg)
return _resolve_variable(arg, lines, line_no)


def extract_csharp_client_calls(line: str, lines: List[str], line_no: int) -> List[Tuple[Optional[str], str]]:
"""(http_method, url) pairs for a C# line with an HttpRequestMessage or `*Client.<Verb>*Async(...)` call."""
m_req = _REQUEST_MESSAGE.search(line)
if m_req:
verb = (m_req.group(1) or m_req.group(2) or "").upper()
method = verb if verb in _VERBS else None
return [(method, url) for url in _urls_for_argument(m_req.group(3), lines, line_no)]
m_client = _CLIENT_METHOD.search(line)
if m_client:
method = m_client.group(1).upper()
return [(method, url) for url in _urls_for_argument(m_client.group(2), lines, line_no)]
return []
2 changes: 1 addition & 1 deletion docs/mcp-tools-and-resources.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ Paginated MCP resource templates (`files/{page}` and `routes/{page}`) use 1-inde
### HTTP Route and Client Discovery Tools

- **`find_routes`**: Searches declared backend endpoints (`api_routes`) by `query`, `method`, and `repo` while excluding unit test fixtures. Clamps `limit` to `1..200` (default `50`) with `offset`.
- **`find_api_callers`**: Matches frontend and service HTTP client calls (`api_client_calls`) against `path` (including `{id}` / `:id` parameter templates), `method`, and `repo`. Client calls are recognized for `fetch`, `axios`, Python `requests`/`httpx`, Go `http`, C# `HttpClient`, and JS/TS request helpers whose name suggests HTTP (for example ``getJson(`/api/items/${id}`)`` or `apiClient.post('/api/items', ...)`); the method comes from the helper name or a `method:` option. Clamps `limit` to `1..200` (default `50`) with `offset`.
- **`find_api_callers`**: Matches frontend and service HTTP client calls (`api_client_calls`) against `path` (including `{id}` / `:id` parameter templates), `method`, and `repo`. Client calls are recognized for `fetch`, `axios`, Python `requests`/`httpx`, Go `http`, C# `HttpClient` (also `new HttpRequestMessage(HttpMethod.Post, url)` and a URL in a variable that an earlier line sets, for example `string url = cond ? $"api/a/{id}" : $"api/b/{id}";`; each branch is one call), and JS/TS request helpers whose name suggests HTTP (for example ``getJson(`/api/items/${id}`)`` or `apiClient.post('/api/items', ...)`); the method comes from the helper name or a `method:` option. Clamps `limit` to `1..200` (default `50`) with `offset`.

### File Inspection and Pattern Matching Tools

Expand Down
62 changes: 62 additions & 0 deletions tests/test_csharp_client_calls.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
"""find_api_callers: C# client calls whose URL is an interpolated string or a variable set on an earlier line."""

from app.services.chunking.api_client_calls import extract_client_calls
from app.services.chunking.api_route_extractor import match_route_and_call

RUN_CLIENT = '''
public async Task<string> RunAssistantAsync(Guid assistantEntityId, Guid? assistantInstanceId)
{
var json = JsonSerializer.Serialize(request, JsonOptions);
using var content = new StringContent(json, Encoding.UTF8, "application/json");

string url = assistantInstanceId.HasValue
? $"api/v2/Assistants/run/instance/{assistantInstanceId.Value}"
: $"api/v2/Assistants/run/{assistantEntityId}";

using var httpRequest = new HttpRequestMessage(HttpMethod.Post, url) { Content = content };
httpRequest.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("text/event-stream"));
using var response = await _httpClient.SendAsync(httpRequest, cancellationToken);
}
'''


def _calls(source: str, language: str = "c_sharp"):
lines = source.splitlines()
return [(c.http_method, c.url_pattern, c.line_number) for c in extract_client_calls(lines, "client", "Client.cs", [], language)]


def test_url_variable_with_conditional_gives_both_routes():
calls = _calls(RUN_CLIENT)
assert [(m, u) for m, u, _ in calls] == [
("POST", "/api/v2/Assistants/run/instance/{assistantInstanceId.Value}"),
("POST", "/api/v2/Assistants/run/{assistantEntityId}"),
]
assert {line for _, _, line in calls} == {11}
assert match_route_and_call("api/v{version}/assistants/run/{eid}", calls[1][1])
assert match_route_and_call("api/v{version}/assistants/run/instance/{instanceId}", calls[0][1])


def test_interpolated_literal_and_custom_client_name():
source = '''
var item = await _apiClient.GetFromJsonAsync<ItemDto>($"api/items/{id}", ct);
await _apiClient.DeleteAsync("api/items/" + id);
var req = new HttpRequestMessage(new HttpMethod("PATCH"), $"api/items/{id}/state");
'''
assert [(m, u) for m, u, _ in _calls(source)] == [
("GET", "/api/items/{id}"),
("DELETE", "/api/items/{id}"),
("PATCH", "/api/items/{id}/state"),
]


def test_variables_without_a_url_are_ignored():
source = '''
string name = "report";
var request = new HttpRequestMessage(HttpMethod.Get, name);
var response = await _httpClient.PostAsync(requestUri, content);
'''
assert _calls(source) == []


def test_csharp_rules_do_not_apply_to_other_languages():
assert _calls(RUN_CLIENT, language="python") == []
Loading