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.
Files changed (99) hide show
  1. {codecortex-0.15.2/src/codecortex.egg-info → codecortex-0.15.3}/PKG-INFO +1 -1
  2. {codecortex-0.15.2 → codecortex-0.15.3/src/codecortex.egg-info}/PKG-INFO +1 -1
  3. {codecortex-0.15.2 → codecortex-0.15.3}/src/codecortex.egg-info/SOURCES.txt +1 -0
  4. codecortex-0.15.3/src/codeintel/__init__.py +1 -0
  5. {codecortex-0.15.2 → codecortex-0.15.3}/src/codeintel/providers/lsp.py +111 -4
  6. codecortex-0.15.3/tests/test_corpus.py +335 -0
  7. {codecortex-0.15.2 → codecortex-0.15.3}/tests/test_lsp_provider.py +122 -0
  8. {codecortex-0.15.2 → codecortex-0.15.3}/tests/test_lsp_real.py +18 -4
  9. codecortex-0.15.2/src/codeintel/__init__.py +0 -1
  10. {codecortex-0.15.2 → codecortex-0.15.3}/LICENSE +0 -0
  11. {codecortex-0.15.2 → codecortex-0.15.3}/README.md +0 -0
  12. {codecortex-0.15.2 → codecortex-0.15.3}/pyproject.toml +0 -0
  13. {codecortex-0.15.2 → codecortex-0.15.3}/setup.cfg +0 -0
  14. {codecortex-0.15.2 → codecortex-0.15.3}/src/codecortex.egg-info/dependency_links.txt +0 -0
  15. {codecortex-0.15.2 → codecortex-0.15.3}/src/codecortex.egg-info/entry_points.txt +0 -0
  16. {codecortex-0.15.2 → codecortex-0.15.3}/src/codecortex.egg-info/requires.txt +0 -0
  17. {codecortex-0.15.2 → codecortex-0.15.3}/src/codecortex.egg-info/top_level.txt +0 -0
  18. {codecortex-0.15.2 → codecortex-0.15.3}/src/codeintel/__main__.py +0 -0
  19. {codecortex-0.15.2 → codecortex-0.15.3}/src/codeintel/auth.py +0 -0
  20. {codecortex-0.15.2 → codecortex-0.15.3}/src/codeintel/cache.py +0 -0
  21. {codecortex-0.15.2 → codecortex-0.15.3}/src/codeintel/commands/__init__.py +0 -0
  22. {codecortex-0.15.2 → codecortex-0.15.3}/src/codeintel/commands/_common.py +0 -0
  23. {codecortex-0.15.2 → codecortex-0.15.3}/src/codeintel/commands/doctor.py +0 -0
  24. {codecortex-0.15.2 → codecortex-0.15.3}/src/codeintel/commands/gen_token.py +0 -0
  25. {codecortex-0.15.2 → codecortex-0.15.3}/src/codeintel/commands/graph.py +0 -0
  26. {codecortex-0.15.2 → codecortex-0.15.3}/src/codeintel/commands/index.py +0 -0
  27. {codecortex-0.15.2 → codecortex-0.15.3}/src/codeintel/commands/install.py +0 -0
  28. {codecortex-0.15.2 → codecortex-0.15.3}/src/codeintel/commands/map.py +0 -0
  29. {codecortex-0.15.2 → codecortex-0.15.3}/src/codeintel/commands/query.py +0 -0
  30. {codecortex-0.15.2 → codecortex-0.15.3}/src/codeintel/commands/reset.py +0 -0
  31. {codecortex-0.15.2 → codecortex-0.15.3}/src/codeintel/commands/serve.py +0 -0
  32. {codecortex-0.15.2 → codecortex-0.15.3}/src/codeintel/commands/serve_http.py +0 -0
  33. {codecortex-0.15.2 → codecortex-0.15.3}/src/codeintel/commands/setup.py +0 -0
  34. {codecortex-0.15.2 → codecortex-0.15.3}/src/codeintel/commands/status.py +0 -0
  35. {codecortex-0.15.2 → codecortex-0.15.3}/src/codeintel/config.py +0 -0
  36. {codecortex-0.15.2 → codecortex-0.15.3}/src/codeintel/containment.py +0 -0
  37. {codecortex-0.15.2 → codecortex-0.15.3}/src/codeintel/doctor.py +0 -0
  38. {codecortex-0.15.2 → codecortex-0.15.3}/src/codeintel/gateway.py +0 -0
  39. {codecortex-0.15.2 → codecortex-0.15.3}/src/codeintel/grapher.py +0 -0
  40. {codecortex-0.15.2 → codecortex-0.15.3}/src/codeintel/http_server.py +0 -0
  41. {codecortex-0.15.2 → codecortex-0.15.3}/src/codeintel/indexer.py +0 -0
  42. {codecortex-0.15.2 → codecortex-0.15.3}/src/codeintel/injector.py +0 -0
  43. {codecortex-0.15.2 → codecortex-0.15.3}/src/codeintel/installer.py +0 -0
  44. {codecortex-0.15.2 → codecortex-0.15.3}/src/codeintel/logconfig.py +0 -0
  45. {codecortex-0.15.2 → codecortex-0.15.3}/src/codeintel/mapper.py +0 -0
  46. {codecortex-0.15.2 → codecortex-0.15.3}/src/codeintel/metrics.py +0 -0
  47. {codecortex-0.15.2 → codecortex-0.15.3}/src/codeintel/onboarding.py +0 -0
  48. {codecortex-0.15.2 → codecortex-0.15.3}/src/codeintel/policy.py +0 -0
  49. {codecortex-0.15.2 → codecortex-0.15.3}/src/codeintel/provider.py +0 -0
  50. {codecortex-0.15.2 → codecortex-0.15.3}/src/codeintel/providers/__init__.py +0 -0
  51. {codecortex-0.15.2 → codecortex-0.15.3}/src/codeintel/providers/graph.py +0 -0
  52. {codecortex-0.15.2 → codecortex-0.15.3}/src/codeintel/providers/none.py +0 -0
  53. {codecortex-0.15.2 → codecortex-0.15.3}/src/codeintel/providers/semantic.py +0 -0
  54. {codecortex-0.15.2 → codecortex-0.15.3}/src/codeintel/py.typed +0 -0
  55. {codecortex-0.15.2 → codecortex-0.15.3}/src/codeintel/reindexer.py +0 -0
  56. {codecortex-0.15.2 → codecortex-0.15.3}/src/codeintel/reset.py +0 -0
  57. {codecortex-0.15.2 → codecortex-0.15.3}/src/codeintel/searcher.py +0 -0
  58. {codecortex-0.15.2 → codecortex-0.15.3}/src/codeintel/semantic_db.py +0 -0
  59. {codecortex-0.15.2 → codecortex-0.15.3}/src/codeintel/server.py +0 -0
  60. {codecortex-0.15.2 → codecortex-0.15.3}/src/codeintel/source_kind.py +0 -0
  61. {codecortex-0.15.2 → codecortex-0.15.3}/src/codeintel/term.py +0 -0
  62. {codecortex-0.15.2 → codecortex-0.15.3}/src/codeintel/verify.py +0 -0
  63. {codecortex-0.15.2 → codecortex-0.15.3}/src/codeintel/viewer/__init__.py +0 -0
  64. {codecortex-0.15.2 → codecortex-0.15.3}/src/codeintel/viewer/graph_template.html +0 -0
  65. {codecortex-0.15.2 → codecortex-0.15.3}/tests/test_cache.py +0 -0
  66. {codecortex-0.15.2 → codecortex-0.15.3}/tests/test_chunking.py +0 -0
  67. {codecortex-0.15.2 → codecortex-0.15.3}/tests/test_cli_commands.py +0 -0
  68. {codecortex-0.15.2 → codecortex-0.15.3}/tests/test_cli_help.py +0 -0
  69. {codecortex-0.15.2 → codecortex-0.15.3}/tests/test_config.py +0 -0
  70. {codecortex-0.15.2 → codecortex-0.15.3}/tests/test_doctor.py +0 -0
  71. {codecortex-0.15.2 → codecortex-0.15.3}/tests/test_e2e.py +0 -0
  72. {codecortex-0.15.2 → codecortex-0.15.3}/tests/test_engine_adoption.py +0 -0
  73. {codecortex-0.15.2 → codecortex-0.15.3}/tests/test_enterprise.py +0 -0
  74. {codecortex-0.15.2 → codecortex-0.15.3}/tests/test_gateway.py +0 -0
  75. {codecortex-0.15.2 → codecortex-0.15.3}/tests/test_graph_provider.py +0 -0
  76. {codecortex-0.15.2 → codecortex-0.15.3}/tests/test_graph_real.py +0 -0
  77. {codecortex-0.15.2 → codecortex-0.15.3}/tests/test_graph_stdin.py +0 -0
  78. {codecortex-0.15.2 → codecortex-0.15.3}/tests/test_grapher.py +0 -0
  79. {codecortex-0.15.2 → codecortex-0.15.3}/tests/test_hardening.py +0 -0
  80. {codecortex-0.15.2 → codecortex-0.15.3}/tests/test_http_auth.py +0 -0
  81. {codecortex-0.15.2 → codecortex-0.15.3}/tests/test_http_server.py +0 -0
  82. {codecortex-0.15.2 → codecortex-0.15.3}/tests/test_installer.py +0 -0
  83. {codecortex-0.15.2 → codecortex-0.15.3}/tests/test_integration.py +0 -0
  84. {codecortex-0.15.2 → codecortex-0.15.3}/tests/test_mapper.py +0 -0
  85. {codecortex-0.15.2 → codecortex-0.15.3}/tests/test_mcp_handshake.py +0 -0
  86. {codecortex-0.15.2 → codecortex-0.15.3}/tests/test_mcp_server.py +0 -0
  87. {codecortex-0.15.2 → codecortex-0.15.3}/tests/test_model_dimension.py +0 -0
  88. {codecortex-0.15.2 → codecortex-0.15.3}/tests/test_never_raise.py +0 -0
  89. {codecortex-0.15.2 → codecortex-0.15.3}/tests/test_onboarding.py +0 -0
  90. {codecortex-0.15.2 → codecortex-0.15.3}/tests/test_rbac.py +0 -0
  91. {codecortex-0.15.2 → codecortex-0.15.3}/tests/test_reindexer.py +0 -0
  92. {codecortex-0.15.2 → codecortex-0.15.3}/tests/test_rerank.py +0 -0
  93. {codecortex-0.15.2 → codecortex-0.15.3}/tests/test_reset.py +0 -0
  94. {codecortex-0.15.2 → codecortex-0.15.3}/tests/test_semantic_provider.py +0 -0
  95. {codecortex-0.15.2 → codecortex-0.15.3}/tests/test_source_kind.py +0 -0
  96. {codecortex-0.15.2 → codecortex-0.15.3}/tests/test_term.py +0 -0
  97. {codecortex-0.15.2 → codecortex-0.15.3}/tests/test_treesitter.py +0 -0
  98. {codecortex-0.15.2 → codecortex-0.15.3}/tests/test_verify.py +0 -0
  99. {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.2
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.2
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
@@ -61,6 +61,7 @@ tests/test_chunking.py
61
61
  tests/test_cli_commands.py
62
62
  tests/test_cli_help.py
63
63
  tests/test_config.py
64
+ tests/test_corpus.py
64
65
  tests/test_doctor.py
65
66
  tests/test_e2e.py
66
67
  tests/test_engine_adoption.py
@@ -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
- # mcp CallToolResult has a .content list of TextContent
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 / degenerate response — surface whatever serena returned.
386
- def_section = f"## Symbol: {target}\n{def_text or '(not found)'}"
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
- # No JSON to parse definition falls back to "(not found)", references empty, no crash.
249
- assert r["result"] is not None
250
- assert "(not found)" in r["result"]
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