codecortex 0.15.2__tar.gz → 0.15.3__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {codecortex-0.15.2/src/codecortex.egg-info → codecortex-0.15.3}/PKG-INFO +1 -1
- {codecortex-0.15.2 → codecortex-0.15.3/src/codecortex.egg-info}/PKG-INFO +1 -1
- {codecortex-0.15.2 → codecortex-0.15.3}/src/codecortex.egg-info/SOURCES.txt +1 -0
- codecortex-0.15.3/src/codeintel/__init__.py +1 -0
- {codecortex-0.15.2 → codecortex-0.15.3}/src/codeintel/providers/lsp.py +111 -4
- codecortex-0.15.3/tests/test_corpus.py +335 -0
- {codecortex-0.15.2 → codecortex-0.15.3}/tests/test_lsp_provider.py +122 -0
- {codecortex-0.15.2 → codecortex-0.15.3}/tests/test_lsp_real.py +18 -4
- codecortex-0.15.2/src/codeintel/__init__.py +0 -1
- {codecortex-0.15.2 → codecortex-0.15.3}/LICENSE +0 -0
- {codecortex-0.15.2 → codecortex-0.15.3}/README.md +0 -0
- {codecortex-0.15.2 → codecortex-0.15.3}/pyproject.toml +0 -0
- {codecortex-0.15.2 → codecortex-0.15.3}/setup.cfg +0 -0
- {codecortex-0.15.2 → codecortex-0.15.3}/src/codecortex.egg-info/dependency_links.txt +0 -0
- {codecortex-0.15.2 → codecortex-0.15.3}/src/codecortex.egg-info/entry_points.txt +0 -0
- {codecortex-0.15.2 → codecortex-0.15.3}/src/codecortex.egg-info/requires.txt +0 -0
- {codecortex-0.15.2 → codecortex-0.15.3}/src/codecortex.egg-info/top_level.txt +0 -0
- {codecortex-0.15.2 → codecortex-0.15.3}/src/codeintel/__main__.py +0 -0
- {codecortex-0.15.2 → codecortex-0.15.3}/src/codeintel/auth.py +0 -0
- {codecortex-0.15.2 → codecortex-0.15.3}/src/codeintel/cache.py +0 -0
- {codecortex-0.15.2 → codecortex-0.15.3}/src/codeintel/commands/__init__.py +0 -0
- {codecortex-0.15.2 → codecortex-0.15.3}/src/codeintel/commands/_common.py +0 -0
- {codecortex-0.15.2 → codecortex-0.15.3}/src/codeintel/commands/doctor.py +0 -0
- {codecortex-0.15.2 → codecortex-0.15.3}/src/codeintel/commands/gen_token.py +0 -0
- {codecortex-0.15.2 → codecortex-0.15.3}/src/codeintel/commands/graph.py +0 -0
- {codecortex-0.15.2 → codecortex-0.15.3}/src/codeintel/commands/index.py +0 -0
- {codecortex-0.15.2 → codecortex-0.15.3}/src/codeintel/commands/install.py +0 -0
- {codecortex-0.15.2 → codecortex-0.15.3}/src/codeintel/commands/map.py +0 -0
- {codecortex-0.15.2 → codecortex-0.15.3}/src/codeintel/commands/query.py +0 -0
- {codecortex-0.15.2 → codecortex-0.15.3}/src/codeintel/commands/reset.py +0 -0
- {codecortex-0.15.2 → codecortex-0.15.3}/src/codeintel/commands/serve.py +0 -0
- {codecortex-0.15.2 → codecortex-0.15.3}/src/codeintel/commands/serve_http.py +0 -0
- {codecortex-0.15.2 → codecortex-0.15.3}/src/codeintel/commands/setup.py +0 -0
- {codecortex-0.15.2 → codecortex-0.15.3}/src/codeintel/commands/status.py +0 -0
- {codecortex-0.15.2 → codecortex-0.15.3}/src/codeintel/config.py +0 -0
- {codecortex-0.15.2 → codecortex-0.15.3}/src/codeintel/containment.py +0 -0
- {codecortex-0.15.2 → codecortex-0.15.3}/src/codeintel/doctor.py +0 -0
- {codecortex-0.15.2 → codecortex-0.15.3}/src/codeintel/gateway.py +0 -0
- {codecortex-0.15.2 → codecortex-0.15.3}/src/codeintel/grapher.py +0 -0
- {codecortex-0.15.2 → codecortex-0.15.3}/src/codeintel/http_server.py +0 -0
- {codecortex-0.15.2 → codecortex-0.15.3}/src/codeintel/indexer.py +0 -0
- {codecortex-0.15.2 → codecortex-0.15.3}/src/codeintel/injector.py +0 -0
- {codecortex-0.15.2 → codecortex-0.15.3}/src/codeintel/installer.py +0 -0
- {codecortex-0.15.2 → codecortex-0.15.3}/src/codeintel/logconfig.py +0 -0
- {codecortex-0.15.2 → codecortex-0.15.3}/src/codeintel/mapper.py +0 -0
- {codecortex-0.15.2 → codecortex-0.15.3}/src/codeintel/metrics.py +0 -0
- {codecortex-0.15.2 → codecortex-0.15.3}/src/codeintel/onboarding.py +0 -0
- {codecortex-0.15.2 → codecortex-0.15.3}/src/codeintel/policy.py +0 -0
- {codecortex-0.15.2 → codecortex-0.15.3}/src/codeintel/provider.py +0 -0
- {codecortex-0.15.2 → codecortex-0.15.3}/src/codeintel/providers/__init__.py +0 -0
- {codecortex-0.15.2 → codecortex-0.15.3}/src/codeintel/providers/graph.py +0 -0
- {codecortex-0.15.2 → codecortex-0.15.3}/src/codeintel/providers/none.py +0 -0
- {codecortex-0.15.2 → codecortex-0.15.3}/src/codeintel/providers/semantic.py +0 -0
- {codecortex-0.15.2 → codecortex-0.15.3}/src/codeintel/py.typed +0 -0
- {codecortex-0.15.2 → codecortex-0.15.3}/src/codeintel/reindexer.py +0 -0
- {codecortex-0.15.2 → codecortex-0.15.3}/src/codeintel/reset.py +0 -0
- {codecortex-0.15.2 → codecortex-0.15.3}/src/codeintel/searcher.py +0 -0
- {codecortex-0.15.2 → codecortex-0.15.3}/src/codeintel/semantic_db.py +0 -0
- {codecortex-0.15.2 → codecortex-0.15.3}/src/codeintel/server.py +0 -0
- {codecortex-0.15.2 → codecortex-0.15.3}/src/codeintel/source_kind.py +0 -0
- {codecortex-0.15.2 → codecortex-0.15.3}/src/codeintel/term.py +0 -0
- {codecortex-0.15.2 → codecortex-0.15.3}/src/codeintel/verify.py +0 -0
- {codecortex-0.15.2 → codecortex-0.15.3}/src/codeintel/viewer/__init__.py +0 -0
- {codecortex-0.15.2 → codecortex-0.15.3}/src/codeintel/viewer/graph_template.html +0 -0
- {codecortex-0.15.2 → codecortex-0.15.3}/tests/test_cache.py +0 -0
- {codecortex-0.15.2 → codecortex-0.15.3}/tests/test_chunking.py +0 -0
- {codecortex-0.15.2 → codecortex-0.15.3}/tests/test_cli_commands.py +0 -0
- {codecortex-0.15.2 → codecortex-0.15.3}/tests/test_cli_help.py +0 -0
- {codecortex-0.15.2 → codecortex-0.15.3}/tests/test_config.py +0 -0
- {codecortex-0.15.2 → codecortex-0.15.3}/tests/test_doctor.py +0 -0
- {codecortex-0.15.2 → codecortex-0.15.3}/tests/test_e2e.py +0 -0
- {codecortex-0.15.2 → codecortex-0.15.3}/tests/test_engine_adoption.py +0 -0
- {codecortex-0.15.2 → codecortex-0.15.3}/tests/test_enterprise.py +0 -0
- {codecortex-0.15.2 → codecortex-0.15.3}/tests/test_gateway.py +0 -0
- {codecortex-0.15.2 → codecortex-0.15.3}/tests/test_graph_provider.py +0 -0
- {codecortex-0.15.2 → codecortex-0.15.3}/tests/test_graph_real.py +0 -0
- {codecortex-0.15.2 → codecortex-0.15.3}/tests/test_graph_stdin.py +0 -0
- {codecortex-0.15.2 → codecortex-0.15.3}/tests/test_grapher.py +0 -0
- {codecortex-0.15.2 → codecortex-0.15.3}/tests/test_hardening.py +0 -0
- {codecortex-0.15.2 → codecortex-0.15.3}/tests/test_http_auth.py +0 -0
- {codecortex-0.15.2 → codecortex-0.15.3}/tests/test_http_server.py +0 -0
- {codecortex-0.15.2 → codecortex-0.15.3}/tests/test_installer.py +0 -0
- {codecortex-0.15.2 → codecortex-0.15.3}/tests/test_integration.py +0 -0
- {codecortex-0.15.2 → codecortex-0.15.3}/tests/test_mapper.py +0 -0
- {codecortex-0.15.2 → codecortex-0.15.3}/tests/test_mcp_handshake.py +0 -0
- {codecortex-0.15.2 → codecortex-0.15.3}/tests/test_mcp_server.py +0 -0
- {codecortex-0.15.2 → codecortex-0.15.3}/tests/test_model_dimension.py +0 -0
- {codecortex-0.15.2 → codecortex-0.15.3}/tests/test_never_raise.py +0 -0
- {codecortex-0.15.2 → codecortex-0.15.3}/tests/test_onboarding.py +0 -0
- {codecortex-0.15.2 → codecortex-0.15.3}/tests/test_rbac.py +0 -0
- {codecortex-0.15.2 → codecortex-0.15.3}/tests/test_reindexer.py +0 -0
- {codecortex-0.15.2 → codecortex-0.15.3}/tests/test_rerank.py +0 -0
- {codecortex-0.15.2 → codecortex-0.15.3}/tests/test_reset.py +0 -0
- {codecortex-0.15.2 → codecortex-0.15.3}/tests/test_semantic_provider.py +0 -0
- {codecortex-0.15.2 → codecortex-0.15.3}/tests/test_source_kind.py +0 -0
- {codecortex-0.15.2 → codecortex-0.15.3}/tests/test_term.py +0 -0
- {codecortex-0.15.2 → codecortex-0.15.3}/tests/test_treesitter.py +0 -0
- {codecortex-0.15.2 → codecortex-0.15.3}/tests/test_verify.py +0 -0
- {codecortex-0.15.2 → codecortex-0.15.3}/tests/test_verify_call.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: codecortex
|
|
3
|
-
Version: 0.15.
|
|
3
|
+
Version: 0.15.3
|
|
4
4
|
Summary: Local-first, MCP-native code-intelligence server — graph, LSP, and semantic search behind one safe code.query tool for coding agents.
|
|
5
5
|
Author: Shammai Hamilton
|
|
6
6
|
License-Expression: MIT
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: codecortex
|
|
3
|
-
Version: 0.15.
|
|
3
|
+
Version: 0.15.3
|
|
4
4
|
Summary: Local-first, MCP-native code-intelligence server — graph, LSP, and semantic search behind one safe code.query tool for coding agents.
|
|
5
5
|
Author: Shammai Hamilton
|
|
6
6
|
License-Expression: MIT
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.15.3"
|
|
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|
|
3
3
|
import asyncio
|
|
4
4
|
import enum
|
|
5
5
|
import json
|
|
6
|
+
import logging
|
|
6
7
|
import os
|
|
7
8
|
import re
|
|
8
9
|
import shutil
|
|
@@ -16,6 +17,8 @@ from mcp.client.stdio import stdio_client
|
|
|
16
17
|
|
|
17
18
|
from codeintel.provider import Result, log_swallowed, safe_null_result
|
|
18
19
|
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
19
22
|
_COOLDOWN_SECONDS = 60
|
|
20
23
|
_DEFAULT_TIMEOUT_S = 5.0
|
|
21
24
|
|
|
@@ -26,6 +29,49 @@ _DEFAULT_TIMEOUT_S = 5.0
|
|
|
26
29
|
_SERENA_GIT = "git+https://github.com/oraios/serena"
|
|
27
30
|
|
|
28
31
|
|
|
32
|
+
# Prefixes serena uses when a tool call fails. Checked in addition to the MCP `isError` flag,
|
|
33
|
+
# which is not set by every server or version — and the cost of missing one is that a failure is
|
|
34
|
+
# served to an agent as an answer.
|
|
35
|
+
_BACKEND_ERROR_MARKERS = (
|
|
36
|
+
"error executing tool",
|
|
37
|
+
"exception:",
|
|
38
|
+
"traceback (most recent call last)",
|
|
39
|
+
"the language server manager is not initialized",
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _looks_like_backend_error(text: str) -> bool:
|
|
44
|
+
"""Whether *text* is a backend failure message rather than a result.
|
|
45
|
+
|
|
46
|
+
Anchored to the START of the payload, and only after ruling out JSON. Both guards are load
|
|
47
|
+
bearing, and the second was added because the first was not enough: a `symbol` lookup quotes
|
|
48
|
+
real source back, so a perfectly good JSON response whose body contained
|
|
49
|
+
`raise RuntimeError('Exception: bad input')` matched a substring search inside its first few
|
|
50
|
+
hundred characters. Hiding real answers to catch errors would just trade one silent wrong
|
|
51
|
+
answer for another — serena's failures are plain prose beginning with a known phrase, and a
|
|
52
|
+
successful response is JSON, so the two never overlap.
|
|
53
|
+
"""
|
|
54
|
+
head = text.lstrip()
|
|
55
|
+
if head[:1] in ("[", "{"):
|
|
56
|
+
return False # a structured response, whatever it happens to quote
|
|
57
|
+
return head.lower().startswith(_BACKEND_ERROR_MARKERS)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _summarize_backend_error(text: str | None) -> str:
|
|
61
|
+
"""A short, SAFE description of a backend failure — never the backend's own prose.
|
|
62
|
+
|
|
63
|
+
The raw text is not forwarded anywhere a caller can see it. serena's failure messages contain
|
|
64
|
+
instructions addressed to a language model ("do not attempt workarounds. Inform the user and
|
|
65
|
+
wait for further instructions before you continue!") plus a dump of LSP initialisation
|
|
66
|
+
parameters. Passing that through would hand a backend's error path a direct line to the
|
|
67
|
+
calling agent's instructions, and leak internals in the same breath. The full text is logged
|
|
68
|
+
for the operator instead; the caller gets a fixed, boring summary.
|
|
69
|
+
"""
|
|
70
|
+
if text:
|
|
71
|
+
logger.warning("serena returned an error result: %s", text[:2000])
|
|
72
|
+
return "the language server reported an error for this query"
|
|
73
|
+
|
|
74
|
+
|
|
29
75
|
def _open_errlog():
|
|
30
76
|
"""Where serena's own stderr goes. Serena logs ~30 lines of INFO on every boot; inherited,
|
|
31
77
|
that noise lands on top of `codeintel doctor --deep`'s report and any CLI query that warms
|
|
@@ -125,11 +171,19 @@ class LspProvider:
|
|
|
125
171
|
Finding references therefore needs two steps: locate the symbol, then query with its path.
|
|
126
172
|
"""
|
|
127
173
|
|
|
174
|
+
# Class-level default so a provider built via `__new__` (the test stubs do this) still has it
|
|
175
|
+
# rather than raising AttributeError inside the never-raise handler.
|
|
176
|
+
_last_backend_error: str | None = None
|
|
177
|
+
|
|
128
178
|
def __init__(self) -> None:
|
|
129
179
|
self._sessions: dict[str, _LspSession] = {}
|
|
130
180
|
self._sessions_lock = threading.Lock()
|
|
181
|
+
self._last_backend_error = None
|
|
131
182
|
self._detect_backend()
|
|
132
183
|
|
|
184
|
+
def _clear_backend_error(self) -> None:
|
|
185
|
+
self._last_backend_error = None
|
|
186
|
+
|
|
133
187
|
def _detect_backend(self) -> None:
|
|
134
188
|
# Prefer a directly-installed serena; otherwise drive it through uvx.
|
|
135
189
|
if shutil.which("serena"):
|
|
@@ -245,8 +299,21 @@ class LspProvider:
|
|
|
245
299
|
return safe_null_result(op_str, target_str, engine="lsp", reason="boot-failed")
|
|
246
300
|
|
|
247
301
|
# READY
|
|
302
|
+
# Cleared through a method rather than a direct assignment: `_dispatch` sets this as a
|
|
303
|
+
# side effect, which a type checker cannot see, so an inline `= None` narrows the
|
|
304
|
+
# attribute to None and makes the branch below look unreachable.
|
|
305
|
+
self._clear_backend_error()
|
|
248
306
|
result_text = self._dispatch(session, op_str, target_str, root_str, timeout_s)
|
|
249
307
|
if result_text is None:
|
|
308
|
+
# A backend failure is not an unsupported op. Reporting it as one sends the agent
|
|
309
|
+
# looking for a different tool when the language server simply did not start —
|
|
310
|
+
# the same misleading string the graph provider already had to stop emitting.
|
|
311
|
+
if self._last_backend_error:
|
|
312
|
+
return safe_null_result(
|
|
313
|
+
op_str, target_str, engine="lsp", reason="backend-error",
|
|
314
|
+
hint=f"{self._last_backend_error} — run `codeintel doctor --deep` to boot-"
|
|
315
|
+
f"check serena; the full backend message is in the server log",
|
|
316
|
+
)
|
|
250
317
|
return safe_null_result(op_str, target_str, engine="lsp", reason="unsupported-op")
|
|
251
318
|
|
|
252
319
|
return {
|
|
@@ -294,11 +361,38 @@ class LspProvider:
|
|
|
294
361
|
return None
|
|
295
362
|
|
|
296
363
|
def _extract_text(self, raw: Any) -> str | None:
|
|
364
|
+
"""The text payload of a tool result, or None — including when the result is an ERROR.
|
|
365
|
+
|
|
366
|
+
An MCP `CallToolResult` carries `isError`, and this read straight past it: serena's failure
|
|
367
|
+
text was harvested like any other content and handed back as the answer. What an agent then
|
|
368
|
+
received for "where is this symbol defined?" was `ok: true`, no `reason`, and a body reading
|
|
369
|
+
|
|
370
|
+
Error executing tool find_symbol: Exception: The language server manager is not
|
|
371
|
+
initialized … do not attempt workarounds. Inform the user and wait for further
|
|
372
|
+
instructions before you continue!
|
|
373
|
+
|
|
374
|
+
followed by a dump of the LSP initialisation params. Three separate problems in one string:
|
|
375
|
+
it is a failure presented as a result, it leaks internals, and — worst — it carries
|
|
376
|
+
imperative instructions aimed at a language model into a field an agent reads as data. A
|
|
377
|
+
backend's error path must never become a channel for telling the caller's agent what to do.
|
|
378
|
+
"""
|
|
297
379
|
if raw is None:
|
|
298
380
|
return None
|
|
299
381
|
if isinstance(raw, str):
|
|
300
|
-
return raw
|
|
301
|
-
|
|
382
|
+
return None if _looks_like_backend_error(raw) else raw
|
|
383
|
+
if getattr(raw, "isError", False):
|
|
384
|
+
self._last_backend_error = _summarize_backend_error(self._raw_text(raw))
|
|
385
|
+
return None
|
|
386
|
+
text = self._raw_text(raw)
|
|
387
|
+
# `isError` is not always set by every server/version, so the text shape is a second gate.
|
|
388
|
+
if text is not None and _looks_like_backend_error(text):
|
|
389
|
+
self._last_backend_error = _summarize_backend_error(text)
|
|
390
|
+
return None
|
|
391
|
+
return text
|
|
392
|
+
|
|
393
|
+
@staticmethod
|
|
394
|
+
def _raw_text(raw: Any) -> str | None:
|
|
395
|
+
"""Concatenated text of an MCP result's content blocks, with no error interpretation."""
|
|
302
396
|
try:
|
|
303
397
|
parts = [item.text for item in raw.content if hasattr(item, "text")]
|
|
304
398
|
return "\n".join(parts) if parts else None
|
|
@@ -375,15 +469,28 @@ class LspProvider:
|
|
|
375
469
|
{"name_path_pattern": target, "include_body": True, "max_matches": 5},
|
|
376
470
|
timeout_s,
|
|
377
471
|
)
|
|
472
|
+
if def_raw is None:
|
|
473
|
+
# The tool call itself failed or timed out. Rendering "(not found)" here — which is
|
|
474
|
+
# what this did — states that the symbol does not exist, on no evidence whatsoever.
|
|
475
|
+
# For an agent deciding whether to create something, "I could not ask" and "it is
|
|
476
|
+
# not there" are opposite answers.
|
|
477
|
+
self._last_backend_error = "the language server did not respond to this query"
|
|
478
|
+
return None
|
|
378
479
|
def_text = self._extract_text(def_raw)
|
|
379
480
|
matches = self._loads(def_text)
|
|
380
481
|
|
|
381
482
|
first: dict | None = None
|
|
382
483
|
if isinstance(matches, list) and matches:
|
|
383
484
|
def_section, first = self._format_matches(target, matches)
|
|
485
|
+
elif def_text is None:
|
|
486
|
+
# `_extract_text` returns None for an error result, and there is nothing to render
|
|
487
|
+
# from a failure. Returning None here routes to a safe-null carrying a real reason
|
|
488
|
+
# rather than dressing the failure up as "## Symbol: x" with the error underneath.
|
|
489
|
+
return None
|
|
384
490
|
else:
|
|
385
|
-
# Non-JSON
|
|
386
|
-
|
|
491
|
+
# Non-JSON but not an error — surface what serena returned, which is how a
|
|
492
|
+
# degenerate-but-real response still reaches the caller.
|
|
493
|
+
def_section = f"## Symbol: {target}\n{def_text}"
|
|
387
494
|
|
|
388
495
|
# References require the located symbol's own path (two-step contract).
|
|
389
496
|
ref_section = "## References\n(none)"
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
"""Corpus harness — run the engines against REAL repositories and assert invariants.
|
|
2
|
+
|
|
3
|
+
Every other fixture in this suite is a synthetic two-to-five-file micro-repo, including the release
|
|
4
|
+
canary's. That is precisely the world in which none of this project's real bugs are visible: they
|
|
5
|
+
were all properties of scale and mess — a vendored tree, a minified bundle, a repo nested inside
|
|
6
|
+
another, a language server that fails to start — and a hand-built fixture cannot produce those by
|
|
7
|
+
construction. Every one of them was found by a person deciding to point the tool at an unfamiliar
|
|
8
|
+
codebase, and the recurring lesson of this project is that the technique works and nobody had
|
|
9
|
+
automated it.
|
|
10
|
+
|
|
11
|
+
So this file automates it, with three deliberate design choices:
|
|
12
|
+
|
|
13
|
+
* **Real repositories, pinned by commit SHA.** Not vendored (too large), not floating on a branch
|
|
14
|
+
(an upstream commit must never turn CI red for reasons unrelated to this project).
|
|
15
|
+
* **Invariants, not golden answers.** Asserting exact output over a real repo means the assertions
|
|
16
|
+
churn on every backend update and get rubber-stamped. Each check below states something that must
|
|
17
|
+
hold on ANY repository, so it keeps its meaning when the corpus grows.
|
|
18
|
+
* **Planted adversarial artifacts.** Real repos supply structure and scale; a controlled canary
|
|
19
|
+
supplies a known right answer. Both are needed — "no result escaped the root" is only meaningful
|
|
20
|
+
when something worth finding sits outside it.
|
|
21
|
+
|
|
22
|
+
Opt-in, because it clones over the network and takes minutes:
|
|
23
|
+
|
|
24
|
+
CODEINTEL_CORPUS=1 pytest tests/test_corpus.py -v
|
|
25
|
+
|
|
26
|
+
Individual checks skip when the engine they need is unavailable, so this is useful with any subset
|
|
27
|
+
of the backends installed. A skip is reported, never silently passed.
|
|
28
|
+
"""
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import json
|
|
32
|
+
import os
|
|
33
|
+
import pathlib
|
|
34
|
+
import shutil
|
|
35
|
+
import subprocess
|
|
36
|
+
import sys
|
|
37
|
+
|
|
38
|
+
import pytest
|
|
39
|
+
|
|
40
|
+
pytestmark = pytest.mark.skipif(
|
|
41
|
+
os.environ.get("CODEINTEL_CORPUS", "").strip() not in ("1", "true", "on", "yes"),
|
|
42
|
+
reason="corpus harness is opt-in: set CODEINTEL_CORPUS=1 (clones real repositories)",
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
# Pinned by SHA. Chosen for being small enough to clone quickly while still being real code with
|
|
46
|
+
# real structure — packages, tests, docs, examples — which is the property the synthetic fixtures
|
|
47
|
+
# lack. Add to this list rather than replacing: breadth is the whole point.
|
|
48
|
+
CORPUS = [
|
|
49
|
+
{
|
|
50
|
+
"name": "click",
|
|
51
|
+
"url": "https://github.com/pallets/click.git",
|
|
52
|
+
"sha": "cbd7a4109da16ce58f54c2a618b4c986e3041fcf",
|
|
53
|
+
},
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
_CACHE = os.environ.get("CODEINTEL_CORPUS_CACHE") or "/tmp/codeintel-corpus"
|
|
57
|
+
|
|
58
|
+
# Content planted OUTSIDE every indexed root. If this string ever appears in a result, something
|
|
59
|
+
# read past the boundary — the concrete form of the containment bug that shipped.
|
|
60
|
+
CANARY = "sk-live-CORPUS-CANARY-MUST-NEVER-APPEAR"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _clone(spec: dict) -> str:
|
|
64
|
+
"""Clone (or reuse) a pinned corpus repo. Skips the test when the network is unavailable —
|
|
65
|
+
an unreachable GitHub is not a defect in this project."""
|
|
66
|
+
dest = os.path.join(_CACHE, spec["name"])
|
|
67
|
+
if os.path.isdir(os.path.join(dest, ".git")):
|
|
68
|
+
head = subprocess.run(["git", "-C", dest, "rev-parse", "HEAD"],
|
|
69
|
+
capture_output=True, text=True).stdout.strip()
|
|
70
|
+
if head == spec["sha"]:
|
|
71
|
+
return dest
|
|
72
|
+
shutil.rmtree(dest, ignore_errors=True)
|
|
73
|
+
os.makedirs(_CACHE, exist_ok=True)
|
|
74
|
+
try:
|
|
75
|
+
subprocess.run(["git", "clone", "--quiet", spec["url"], dest],
|
|
76
|
+
check=True, capture_output=True, timeout=600)
|
|
77
|
+
subprocess.run(["git", "-C", dest, "checkout", "--quiet", spec["sha"]],
|
|
78
|
+
check=True, capture_output=True, timeout=120)
|
|
79
|
+
except (OSError, subprocess.SubprocessError) as exc:
|
|
80
|
+
pytest.skip(f"could not fetch corpus repo {spec['name']}: {exc}")
|
|
81
|
+
return dest
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@pytest.fixture(scope="session", params=CORPUS, ids=lambda s: s["name"])
|
|
85
|
+
def corpus_repo(request):
|
|
86
|
+
"""A pinned real repository, with adversarial artifacts planted around and inside it."""
|
|
87
|
+
repo = _clone(request.param)
|
|
88
|
+
|
|
89
|
+
# A secret OUTSIDE the repo, and a symlink inside pointing at it. Containment is enforced at
|
|
90
|
+
# index time and at read time; this is the shape that defeated the index-time-only version.
|
|
91
|
+
outside = os.path.join(_CACHE, "_outside")
|
|
92
|
+
os.makedirs(outside, exist_ok=True)
|
|
93
|
+
secret = os.path.join(outside, "secret.py")
|
|
94
|
+
with open(secret, "w", encoding="utf-8") as fh:
|
|
95
|
+
fh.write(f'SECRET_TOKEN = "{CANARY}"\n')
|
|
96
|
+
link = os.path.join(repo, "_planted_link.py")
|
|
97
|
+
if not os.path.islink(link):
|
|
98
|
+
try:
|
|
99
|
+
os.symlink(secret, link)
|
|
100
|
+
except OSError:
|
|
101
|
+
pass
|
|
102
|
+
|
|
103
|
+
# A minified bundle in a directory on no skip list anywhere — the case a name-based rule
|
|
104
|
+
# structurally cannot catch, and the one that put a webpack chunk in a repo's top hotspots.
|
|
105
|
+
assets = os.path.join(repo, "assets")
|
|
106
|
+
os.makedirs(assets, exist_ok=True)
|
|
107
|
+
with open(os.path.join(assets, "chunk.js"), "w", encoding="utf-8") as fh:
|
|
108
|
+
fh.write("!function(e,t){" + ("a=1;" * 4000) + "}();")
|
|
109
|
+
|
|
110
|
+
return repo
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
# --------------------------------------------------------------------------- helpers
|
|
114
|
+
|
|
115
|
+
def _graph():
|
|
116
|
+
from codeintel.providers.graph import GraphProvider
|
|
117
|
+
|
|
118
|
+
p = GraphProvider()
|
|
119
|
+
if not p.available:
|
|
120
|
+
pytest.skip("codebase-memory-mcp not installed")
|
|
121
|
+
return p
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _indexed_graph(repo: str):
|
|
125
|
+
"""A graph provider with *repo* indexed, or a skip explaining why not."""
|
|
126
|
+
p = _graph()
|
|
127
|
+
p._run("index_repository", {"repo_path": repo}, 300_000)
|
|
128
|
+
lookup = p._lookup_project(repo)
|
|
129
|
+
if lookup.reason == "backend-unreachable":
|
|
130
|
+
pytest.skip("graph backend did not respond")
|
|
131
|
+
if lookup.resolution is None:
|
|
132
|
+
pytest.skip("graph backend did not register the corpus repo")
|
|
133
|
+
return p
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _all_text(result: dict) -> str:
|
|
137
|
+
"""Everything in an envelope a caller can read."""
|
|
138
|
+
return " ".join(str(result.get(k) or "") for k in ("result", "reason", "hint"))
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _paths_in(text: str) -> list[str]:
|
|
142
|
+
"""Every path-looking token in a rendered result."""
|
|
143
|
+
import re
|
|
144
|
+
|
|
145
|
+
return re.findall(r"[\w./-]+\.(?:py|js|ts|tsx|jsx|go|rs|java|c|h|cpp|md)", text or "")
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
GRAPH_OPS = ["hotspots", "deadcode", "overview", "changed"]
|
|
149
|
+
SYMBOL_OPS = ["callers", "callees", "impact", "chain"]
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
# --------------------------------------------------------------------------- invariants
|
|
153
|
+
|
|
154
|
+
def test_no_op_ever_raises_on_a_real_repository(corpus_repo):
|
|
155
|
+
"""The never-raise contract, against real input rather than a three-file fixture."""
|
|
156
|
+
p = _indexed_graph(corpus_repo)
|
|
157
|
+
for op in GRAPH_OPS + SYMBOL_OPS + ["pattern"]:
|
|
158
|
+
r = p.build_result(op, "Command", [], 30000, corpus_repo)
|
|
159
|
+
assert r["ok"] is True, f"{op} broke the never-raise contract"
|
|
160
|
+
assert set(r).issuperset({"ok", "op", "target", "result", "engine", "cached"})
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def test_no_result_ever_carries_content_from_outside_the_root(corpus_repo):
|
|
164
|
+
"""The containment invariant, with something real to find: a secret outside the repo, reachable
|
|
165
|
+
through a symlink planted inside it."""
|
|
166
|
+
p = _indexed_graph(corpus_repo)
|
|
167
|
+
for op in GRAPH_OPS + SYMBOL_OPS:
|
|
168
|
+
r = p.build_result(op, "Command", [], 30000, corpus_repo)
|
|
169
|
+
assert CANARY not in _all_text(r), f"{op} returned content from outside the indexed root"
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def test_no_result_cites_a_generated_or_ignored_file(corpus_repo):
|
|
173
|
+
"""Generated content must not appear in a ranking. Checked against two oracles the project did
|
|
174
|
+
not previously consult — its own shape heuristic, and `git check-ignore`, which is the
|
|
175
|
+
repository's own statement about what is not source."""
|
|
176
|
+
from codeintel.source_kind import looks_generated_path
|
|
177
|
+
|
|
178
|
+
p = _indexed_graph(corpus_repo)
|
|
179
|
+
offenders: list[str] = []
|
|
180
|
+
for op in GRAPH_OPS:
|
|
181
|
+
r = p.build_result(op, "", [], 30000, corpus_repo)
|
|
182
|
+
for path in _paths_in(str(r.get("result") or "")):
|
|
183
|
+
if looks_generated_path(path):
|
|
184
|
+
offenders.append(f"{op}: {path} (generated by shape)")
|
|
185
|
+
continue
|
|
186
|
+
full = os.path.join(corpus_repo, path)
|
|
187
|
+
if os.path.exists(full):
|
|
188
|
+
ignored = subprocess.run(
|
|
189
|
+
["git", "-C", corpus_repo, "check-ignore", "-q", path],
|
|
190
|
+
capture_output=True,
|
|
191
|
+
)
|
|
192
|
+
if ignored.returncode == 0:
|
|
193
|
+
offenders.append(f"{op}: {path} (git-ignored)")
|
|
194
|
+
assert not offenders, "generated/ignored files surfaced as results:\n " + "\n ".join(offenders)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def test_the_graph_actually_answered(corpus_repo):
|
|
198
|
+
"""Non-vacuity guard, and it must come first.
|
|
199
|
+
|
|
200
|
+
Every invariant below is of the form "X never appears in a result". All of them pass trivially
|
|
201
|
+
against an engine that returns nothing — which is exactly the state this project shipped in for
|
|
202
|
+
a whole release. A corpus harness that goes green on a dead backend would be worse than no
|
|
203
|
+
harness, because it would certify the outage. So: assert the engine produced substantial output
|
|
204
|
+
before believing anything it did not produce.
|
|
205
|
+
"""
|
|
206
|
+
p = _indexed_graph(corpus_repo)
|
|
207
|
+
hotspots = p.build_result("hotspots", "", [], 60000, corpus_repo)
|
|
208
|
+
overview = p.build_result("overview", "", [], 60000, corpus_repo)
|
|
209
|
+
assert hotspots.get("result"), "hotspots returned nothing — the invariants below prove nothing"
|
|
210
|
+
assert overview.get("result"), "overview returned nothing"
|
|
211
|
+
assert len(str(hotspots["result"]).splitlines()) > 10, "implausibly few hotspots for a real repo"
|
|
212
|
+
assert "nodes" in str(overview["result"])
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def test_the_planted_bundle_never_ranks_as_a_hotspot(corpus_repo):
|
|
216
|
+
"""A minified bundle is by far the most "complex" function in any tree containing one, and
|
|
217
|
+
`assets/` is on no skip list in this codebase.
|
|
218
|
+
|
|
219
|
+
Note this is a REGRESSION guard rather than a live check today: the graph backend does its own
|
|
220
|
+
file selection and never offers the `.js` to codeintel at all, so the assertion is currently
|
|
221
|
+
satisfied upstream. It earns its place by failing if that ever changes — but the corresponding
|
|
222
|
+
live check for codeintel's OWN filtering is `test_generated_content_stays_out_of_the_corpus`
|
|
223
|
+
below, which exercises the indexer directly.
|
|
224
|
+
"""
|
|
225
|
+
p = _indexed_graph(corpus_repo)
|
|
226
|
+
r = p.build_result("hotspots", "", [], 60000, corpus_repo)
|
|
227
|
+
assert "chunk.js" not in str(r.get("result") or "")
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def test_generated_content_stays_out_of_the_corpus(corpus_repo):
|
|
231
|
+
"""codeintel's own file selection, over a real repository.
|
|
232
|
+
|
|
233
|
+
This is the check the graph one cannot be: it drives `Indexer._walk_files`, which is where this
|
|
234
|
+
project decides what is hand-written source, and needs neither backend nor embedding model. The
|
|
235
|
+
planted bundle sits in `assets/` — a name on no skip list — so only the content-shape heuristic
|
|
236
|
+
can exclude it, and the symlink resolves outside the root so only containment can.
|
|
237
|
+
"""
|
|
238
|
+
from codeintel.indexer import Indexer
|
|
239
|
+
from codeintel.semantic_db import SemanticDb
|
|
240
|
+
|
|
241
|
+
db = SemanticDb(os.path.join(_CACHE, "walk.sqlite"))
|
|
242
|
+
db.init()
|
|
243
|
+
try:
|
|
244
|
+
walked = {os.path.relpath(str(f), corpus_repo)
|
|
245
|
+
for f in Indexer(db)._walk_files(pathlib.Path(corpus_repo))}
|
|
246
|
+
finally:
|
|
247
|
+
db.close()
|
|
248
|
+
|
|
249
|
+
# Non-vacuity first, again: an empty walk would satisfy every exclusion below.
|
|
250
|
+
assert len(walked) > 40, f"implausibly few files walked for a real repo: {len(walked)}"
|
|
251
|
+
assert any(p.endswith(".py") and p.startswith("src/") for p in walked), "no real source walked"
|
|
252
|
+
|
|
253
|
+
assert "assets/chunk.js" not in walked, "a minified bundle entered the corpus"
|
|
254
|
+
assert "_planted_link.py" not in walked, "a symlink out of the root entered the corpus"
|
|
255
|
+
|
|
256
|
+
# And nothing the repository itself declares as non-source.
|
|
257
|
+
ignored = [p for p in walked
|
|
258
|
+
if subprocess.run(["git", "-C", corpus_repo, "check-ignore", "-q", p],
|
|
259
|
+
capture_output=True).returncode == 0]
|
|
260
|
+
assert not ignored, f"git-ignored files entered the corpus: {ignored[:10]}"
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def test_deadcode_hits_have_no_textual_reference_in_the_tree(corpus_repo):
|
|
264
|
+
"""`deadcode` names symbols an agent may delete, so its output gets checked against the source
|
|
265
|
+
rather than trusted. A name that appears anywhere beyond its own definition is not dead."""
|
|
266
|
+
import re
|
|
267
|
+
|
|
268
|
+
p = _indexed_graph(corpus_repo)
|
|
269
|
+
r = p.build_result("deadcode", "", [], 60000, corpus_repo)
|
|
270
|
+
text = str(r.get("result") or "")
|
|
271
|
+
if not text or "(0)" in text:
|
|
272
|
+
pytest.skip("no deadcode candidates reported for this repo")
|
|
273
|
+
|
|
274
|
+
names = re.findall(r"^- ([\w.]+)", text, re.MULTILINE)[:10]
|
|
275
|
+
live: list[str] = []
|
|
276
|
+
for qualified in names:
|
|
277
|
+
name = qualified.rsplit(".", 1)[-1]
|
|
278
|
+
if len(name) < 4: # too short to grep meaningfully
|
|
279
|
+
continue
|
|
280
|
+
hits = subprocess.run(
|
|
281
|
+
["git", "-C", corpus_repo, "grep", "-c", "-w", name],
|
|
282
|
+
capture_output=True, text=True,
|
|
283
|
+
).stdout.strip().splitlines()
|
|
284
|
+
total = sum(int(line.rsplit(":", 1)[-1]) for line in hits if ":" in line)
|
|
285
|
+
if total > 1: # more than its own definition
|
|
286
|
+
live.append(f"{qualified}: {total} textual references")
|
|
287
|
+
assert not live, "deadcode named symbols that are referenced in the tree:\n " + "\n ".join(live)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def test_a_nonsense_target_is_reported_as_absent_not_as_a_failure(corpus_repo):
|
|
291
|
+
"""Reason fidelity. With the backend present and working, a target that genuinely does not
|
|
292
|
+
exist must land in the asked-and-found-nothing family — never in the could-not-ask family,
|
|
293
|
+
which would tell an agent the engine is broken when the answer is simply "no"."""
|
|
294
|
+
p = _indexed_graph(corpus_repo)
|
|
295
|
+
r = p.build_result("callers", "zzz_no_such_symbol_anywhere_zzz", [], 30000, corpus_repo)
|
|
296
|
+
assert r["result"] is None
|
|
297
|
+
assert r.get("reason") in ("not-in-graph", "no-result"), r.get("reason")
|
|
298
|
+
assert r.get("reason") not in (
|
|
299
|
+
"engine-unavailable", "backend-unreachable", "backend-incompatible",
|
|
300
|
+
"project-not-indexed", "project-not-indexed-standalone", "error",
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def test_no_result_leaks_an_absolute_host_path(corpus_repo):
|
|
305
|
+
"""The home-path disclosure class. Renderers were swept for it once and a third and fourth site
|
|
306
|
+
turned up later; this asserts the property over real output instead of enumerating sites."""
|
|
307
|
+
p = _indexed_graph(corpus_repo)
|
|
308
|
+
home = os.path.expanduser("~")
|
|
309
|
+
for op in GRAPH_OPS + SYMBOL_OPS:
|
|
310
|
+
r = p.build_result(op, "Command", [], 30000, corpus_repo)
|
|
311
|
+
text = _all_text(r)
|
|
312
|
+
assert home not in text, f"{op} leaked the host home directory"
|
|
313
|
+
# The backend's path-slug project id is the flattened absolute path; it must not appear.
|
|
314
|
+
assert corpus_repo.strip("/").replace("/", "-") not in text, f"{op} leaked the project id"
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def test_the_same_query_gives_the_same_answer_in_a_fresh_process(corpus_repo):
|
|
318
|
+
"""Determinism. A ranking that reorders between runs cannot be reviewed, and a cached answer
|
|
319
|
+
that differs from an uncached one is a silent staleness bug."""
|
|
320
|
+
_indexed_graph(corpus_repo) # ensure indexed before the subprocesses run
|
|
321
|
+
prog = (
|
|
322
|
+
"import json,sys;"
|
|
323
|
+
"from codeintel.providers.graph import GraphProvider;"
|
|
324
|
+
"p=GraphProvider();"
|
|
325
|
+
f"r=p.build_result('hotspots','',[],60000,{corpus_repo!r});"
|
|
326
|
+
"print(json.dumps(r.get('result')))"
|
|
327
|
+
)
|
|
328
|
+
runs = []
|
|
329
|
+
for _ in range(2):
|
|
330
|
+
out = subprocess.run([sys.executable, "-c", prog], capture_output=True, text=True,
|
|
331
|
+
timeout=300)
|
|
332
|
+
if out.returncode != 0:
|
|
333
|
+
pytest.skip(f"subprocess run failed: {out.stderr[-300:]}")
|
|
334
|
+
runs.append(json.loads(out.stdout or "null"))
|
|
335
|
+
assert runs[0] == runs[1], "the same query returned different answers in two fresh processes"
|
|
@@ -187,3 +187,125 @@ def test_code_status_without_lsp(monkeypatch):
|
|
|
187
187
|
r = code_status_handler({})
|
|
188
188
|
assert r["ok"] is True
|
|
189
189
|
assert "lsp" not in r["engines"]
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
# --------------------------------------------------------------------------- #
|
|
193
|
+
# A backend ERROR is not an answer
|
|
194
|
+
#
|
|
195
|
+
# `_extract_text` harvested `.text` from every MCP content block and ignored `isError`, so serena's
|
|
196
|
+
# failure text was returned as the result. An agent asking "where is this symbol defined?" got
|
|
197
|
+
# `ok: true`, no `reason`, and a body that was an error message — carrying a dump of the LSP
|
|
198
|
+
# initialisation params and, worse, imperative instructions addressed to a language model:
|
|
199
|
+
#
|
|
200
|
+
# Error executing tool find_symbol: Exception: The language server manager is not initialized …
|
|
201
|
+
# do not attempt workarounds. Inform the user and wait for further instructions before you
|
|
202
|
+
# continue!
|
|
203
|
+
#
|
|
204
|
+
# Found by running the live test, which no CI job has ever executed.
|
|
205
|
+
# --------------------------------------------------------------------------- #
|
|
206
|
+
|
|
207
|
+
SERENA_ERROR = (
|
|
208
|
+
"Error executing tool find_symbol: Exception: The language server manager is not "
|
|
209
|
+
"initialized, indicating a problem during project initialisation.\n"
|
|
210
|
+
"Failed to start 1 language server(s):\n"
|
|
211
|
+
"python: Error processing request initialize with params:\n"
|
|
212
|
+
"{'initializationOptions': {'exclude': ['**/__pycache__', '**/.venv']}}\n"
|
|
213
|
+
"do not attempt workarounds. Inform the user and wait for further instructions "
|
|
214
|
+
"before you continue!"
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
class _Block:
|
|
219
|
+
def __init__(self, text): self.text = text
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
class _Result:
|
|
223
|
+
def __init__(self, text, is_error=False):
|
|
224
|
+
self.content = [_Block(text)]
|
|
225
|
+
self.isError = is_error
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _provider_returning(raw):
|
|
229
|
+
"""An LspProvider whose tool calls return *raw*, with the session seam stubbed out."""
|
|
230
|
+
p = LspProvider.__new__(LspProvider)
|
|
231
|
+
p.available = True # type: ignore[attr-defined]
|
|
232
|
+
p._cmd = "serena" # type: ignore[attr-defined]
|
|
233
|
+
p._last_backend_error = None # type: ignore[attr-defined]
|
|
234
|
+
# build_result asks for a session and reads its state, so the stub must be READY — otherwise
|
|
235
|
+
# every call short-circuits to `warming` and the branch under test is never reached.
|
|
236
|
+
import threading as _th
|
|
237
|
+
|
|
238
|
+
from codeintel.providers.lsp import _State
|
|
239
|
+
|
|
240
|
+
class _ReadySession:
|
|
241
|
+
state = _State.READY
|
|
242
|
+
_lock = _th.Lock()
|
|
243
|
+
|
|
244
|
+
p._get_or_create_session = lambda root: _ReadySession() # type: ignore[method-assign]
|
|
245
|
+
p._call_tool = lambda *a, **k: raw # type: ignore[method-assign]
|
|
246
|
+
return p
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _assert_no_backend_prose(res):
|
|
250
|
+
"""Neither the result nor the hint may carry the backend's own error text."""
|
|
251
|
+
blob = f"{res.get('result')} {res.get('hint')}"
|
|
252
|
+
for leak in ("Inform the user", "wait for further instructions", "initializationOptions",
|
|
253
|
+
"Error executing tool", "language server manager is not initialized"):
|
|
254
|
+
assert leak not in blob, f"backend error prose leaked to the caller: {leak!r}"
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def test_an_error_result_is_not_served_as_an_answer(monkeypatch):
|
|
258
|
+
monkeypatch.setattr("codeintel.providers.lsp.shutil.which", lambda x: "/fake/serena")
|
|
259
|
+
p = _provider_returning(_Result(SERENA_ERROR, is_error=True))
|
|
260
|
+
res = p.build_result("symbol", "safe_null_result", [], 0, "/repo")
|
|
261
|
+
|
|
262
|
+
assert res["ok"] is True
|
|
263
|
+
assert res["result"] is None, "a backend failure must not be returned as the answer"
|
|
264
|
+
assert res["reason"] == "backend-error"
|
|
265
|
+
# Not `unsupported-op`: that sends the agent looking for a different tool when the language
|
|
266
|
+
# server simply did not start.
|
|
267
|
+
assert res["reason"] != "unsupported-op"
|
|
268
|
+
_assert_no_backend_prose(res)
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def test_an_error_shaped_response_without_the_flag_is_still_caught(monkeypatch):
|
|
272
|
+
"""`isError` is not set by every server or version, and the cost of missing one is that a
|
|
273
|
+
failure reaches an agent as data. The text shape is a second gate."""
|
|
274
|
+
monkeypatch.setattr("codeintel.providers.lsp.shutil.which", lambda x: "/fake/serena")
|
|
275
|
+
p = _provider_returning(_Result(SERENA_ERROR, is_error=False))
|
|
276
|
+
res = p.build_result("symbol", "safe_null_result", [], 0, "/repo")
|
|
277
|
+
|
|
278
|
+
assert res["result"] is None
|
|
279
|
+
assert res["reason"] == "backend-error"
|
|
280
|
+
_assert_no_backend_prose(res)
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def test_real_source_that_merely_mentions_exceptions_is_not_mistaken_for_an_error(monkeypatch):
|
|
284
|
+
"""The guard against over-detection: a `symbol` lookup quotes real code back, and plenty of
|
|
285
|
+
real functions contain the word "Exception:". Hiding those would trade one silent wrong answer
|
|
286
|
+
for another."""
|
|
287
|
+
from codeintel.providers.lsp import _looks_like_backend_error
|
|
288
|
+
|
|
289
|
+
body = (
|
|
290
|
+
'[{"name_path": "handle", "relative_path": "src/app.py", '
|
|
291
|
+
'"body": "def handle():\\n raise RuntimeError(\'Exception: bad input\')\\n"}]'
|
|
292
|
+
)
|
|
293
|
+
assert _looks_like_backend_error(body) is False
|
|
294
|
+
|
|
295
|
+
monkeypatch.setattr("codeintel.providers.lsp.shutil.which", lambda x: "/fake/serena")
|
|
296
|
+
p = _provider_returning(_Result(body))
|
|
297
|
+
res = p.build_result("symbol", "handle", [], 0, "/repo")
|
|
298
|
+
assert res["result"] is not None
|
|
299
|
+
assert res.get("reason") is None
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def test_the_error_summary_never_quotes_the_backend(monkeypatch):
|
|
303
|
+
"""The summary handed to a caller is fixed text. The backend's prose is logged for the
|
|
304
|
+
operator and goes nowhere an agent can read it — an error path must not become a channel for
|
|
305
|
+
instructing the caller's model."""
|
|
306
|
+
from codeintel.providers.lsp import _summarize_backend_error
|
|
307
|
+
|
|
308
|
+
summary = _summarize_backend_error(SERENA_ERROR)
|
|
309
|
+
assert "Inform the user" not in summary
|
|
310
|
+
assert "initializationOptions" not in summary
|
|
311
|
+
assert summary == "the language server reported an error for this query"
|
|
@@ -245,9 +245,12 @@ def test_symbol_tool_returns_none_degrades(monkeypatch):
|
|
|
245
245
|
monkeypatch.setattr(p, "_call_tool", lambda *a, **k: None) # every tool call fails
|
|
246
246
|
r = p.build_result("symbol", "safe_null_result", [], 0, "/repo")
|
|
247
247
|
assert r["ok"] is True
|
|
248
|
-
#
|
|
249
|
-
|
|
250
|
-
|
|
248
|
+
# This used to render "## Symbol: x\n(not found)" — a claim that the symbol does not exist,
|
|
249
|
+
# made when every tool call had FAILED. For an agent deciding whether to create something,
|
|
250
|
+
# "I could not ask" and "it is not there" are opposite answers, so a dead backend degrades to
|
|
251
|
+
# a safe-null carrying a reason rather than to a confident negative.
|
|
252
|
+
assert r["result"] is None
|
|
253
|
+
assert r["reason"] == "backend-error"
|
|
251
254
|
|
|
252
255
|
|
|
253
256
|
def test_symbol_tool_raising_is_caught(monkeypatch):
|
|
@@ -282,9 +285,20 @@ def test_live_symbol_returns_definition_and_references():
|
|
|
282
285
|
if r["result"] is not None:
|
|
283
286
|
result = r["result"]
|
|
284
287
|
break
|
|
285
|
-
assert r["reason"] in ("warming", "boot-failed")
|
|
288
|
+
assert r["reason"] in ("warming", "boot-failed", "backend-error")
|
|
286
289
|
if r["reason"] == "boot-failed":
|
|
287
290
|
pytest.skip("serena failed to boot in this environment")
|
|
291
|
+
if r["reason"] == "backend-error":
|
|
292
|
+
# serena started but its language server did not (commonly a missing runtime, or no
|
|
293
|
+
# network to fetch one). The ANSWER cannot be checked here — but the invariant that
|
|
294
|
+
# made this test worth running still can, and it is the one that was broken: a backend
|
|
295
|
+
# failure must not reach the caller dressed as data. Assert that, then skip.
|
|
296
|
+
assert r["result"] is None
|
|
297
|
+
blob = f"{r.get('result')} {r.get('hint')}"
|
|
298
|
+
for leak in ("Inform the user", "initializationOptions", "Error executing tool"):
|
|
299
|
+
assert leak not in blob, f"backend error prose reached the caller: {leak!r}"
|
|
300
|
+
pytest.skip("serena's language server did not start here — error correctly reported, "
|
|
301
|
+
"but the symbol answer cannot be verified in this environment")
|
|
288
302
|
time.sleep(0.5)
|
|
289
303
|
|
|
290
304
|
assert result is not None, "serena never returned a definition"
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.15.2"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|