diff --git a/REQUIREMENTS.md b/REQUIREMENTS.md index a5828a9..82bd423 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:** **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). --- @@ -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` diff --git a/app/services/chunking/api_client_calls.py b/app/services/chunking/api_client_calls.py index 3bb7365..b603b03 100644 --- a/app/services/chunking/api_client_calls.py +++ b/app/services/chunking/api_client_calls.py @@ -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 @@ -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(...)). +_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*["\']([^"\']+)["\']') @@ -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] = [] @@ -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: diff --git a/app/services/chunking/csharp_client_calls.py b/app/services/chunking/csharp_client_calls.py new file mode 100644 index 0000000..f09f290 --- /dev/null +++ b/app/services/chunking/csharp_client_calls.py @@ -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.*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 [] diff --git a/docs/mcp-tools-and-resources.md b/docs/mcp-tools-and-resources.md index 46ff651..08112ec 100644 --- a/docs/mcp-tools-and-resources.md +++ b/docs/mcp-tools-and-resources.md @@ -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 diff --git a/tests/test_csharp_client_calls.py b/tests/test_csharp_client_calls.py new file mode 100644 index 0000000..3278465 --- /dev/null +++ b/tests/test_csharp_client_calls.py @@ -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 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($"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") == []