codecortex 0.23.1__tar.gz → 0.23.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 (148) hide show
  1. {codecortex-0.23.1/src/codecortex.egg-info → codecortex-0.23.3}/PKG-INFO +1 -1
  2. {codecortex-0.23.1 → codecortex-0.23.3/src/codecortex.egg-info}/PKG-INFO +1 -1
  3. codecortex-0.23.3/src/codeintel/__init__.py +1 -0
  4. {codecortex-0.23.1 → codecortex-0.23.3}/src/codeintel/gateway.py +62 -34
  5. {codecortex-0.23.1 → codecortex-0.23.3}/src/codeintel/indexer.py +13 -1
  6. {codecortex-0.23.1 → codecortex-0.23.3}/src/codeintel/provider.py +17 -6
  7. {codecortex-0.23.1 → codecortex-0.23.3}/src/codeintel/providers/lsp.py +92 -1
  8. {codecortex-0.23.1 → codecortex-0.23.3}/tests/test_doctor.py +21 -0
  9. {codecortex-0.23.1 → codecortex-0.23.3}/tests/test_gateway.py +1 -0
  10. {codecortex-0.23.1 → codecortex-0.23.3}/tests/test_incompleteness.py +30 -0
  11. {codecortex-0.23.1 → codecortex-0.23.3}/tests/test_index_failure_reason.py +25 -0
  12. {codecortex-0.23.1 → codecortex-0.23.3}/tests/test_lsp_provider.py +86 -0
  13. {codecortex-0.23.1 → codecortex-0.23.3}/tests/test_null_reason_and_ripple.py +64 -0
  14. {codecortex-0.23.1 → codecortex-0.23.3}/tests/test_semantic_provider.py +1 -0
  15. codecortex-0.23.1/src/codeintel/__init__.py +0 -1
  16. {codecortex-0.23.1 → codecortex-0.23.3}/LICENSE +0 -0
  17. {codecortex-0.23.1 → codecortex-0.23.3}/README.md +0 -0
  18. {codecortex-0.23.1 → codecortex-0.23.3}/pyproject.toml +0 -0
  19. {codecortex-0.23.1 → codecortex-0.23.3}/setup.cfg +0 -0
  20. {codecortex-0.23.1 → codecortex-0.23.3}/src/codecortex.egg-info/SOURCES.txt +0 -0
  21. {codecortex-0.23.1 → codecortex-0.23.3}/src/codecortex.egg-info/dependency_links.txt +0 -0
  22. {codecortex-0.23.1 → codecortex-0.23.3}/src/codecortex.egg-info/entry_points.txt +0 -0
  23. {codecortex-0.23.1 → codecortex-0.23.3}/src/codecortex.egg-info/requires.txt +0 -0
  24. {codecortex-0.23.1 → codecortex-0.23.3}/src/codecortex.egg-info/top_level.txt +0 -0
  25. {codecortex-0.23.1 → codecortex-0.23.3}/src/codeintel/__main__.py +0 -0
  26. {codecortex-0.23.1 → codecortex-0.23.3}/src/codeintel/agent_prompt.py +0 -0
  27. {codecortex-0.23.1 → codecortex-0.23.3}/src/codeintel/auth.py +0 -0
  28. {codecortex-0.23.1 → codecortex-0.23.3}/src/codeintel/c4.py +0 -0
  29. {codecortex-0.23.1 → codecortex-0.23.3}/src/codeintel/c4_check.py +0 -0
  30. {codecortex-0.23.1 → codecortex-0.23.3}/src/codeintel/c4_layers.py +0 -0
  31. {codecortex-0.23.1 → codecortex-0.23.3}/src/codeintel/cache.py +0 -0
  32. {codecortex-0.23.1 → codecortex-0.23.3}/src/codeintel/commands/__init__.py +0 -0
  33. {codecortex-0.23.1 → codecortex-0.23.3}/src/codeintel/commands/_common.py +0 -0
  34. {codecortex-0.23.1 → codecortex-0.23.3}/src/codeintel/commands/c4.py +0 -0
  35. {codecortex-0.23.1 → codecortex-0.23.3}/src/codeintel/commands/doctor.py +0 -0
  36. {codecortex-0.23.1 → codecortex-0.23.3}/src/codeintel/commands/gen_token.py +0 -0
  37. {codecortex-0.23.1 → codecortex-0.23.3}/src/codeintel/commands/graph.py +0 -0
  38. {codecortex-0.23.1 → codecortex-0.23.3}/src/codeintel/commands/index.py +0 -0
  39. {codecortex-0.23.1 → codecortex-0.23.3}/src/codeintel/commands/install.py +0 -0
  40. {codecortex-0.23.1 → codecortex-0.23.3}/src/codeintel/commands/map.py +0 -0
  41. {codecortex-0.23.1 → codecortex-0.23.3}/src/codeintel/commands/prompt.py +0 -0
  42. {codecortex-0.23.1 → codecortex-0.23.3}/src/codeintel/commands/query.py +0 -0
  43. {codecortex-0.23.1 → codecortex-0.23.3}/src/codeintel/commands/reset.py +0 -0
  44. {codecortex-0.23.1 → codecortex-0.23.3}/src/codeintel/commands/serve.py +0 -0
  45. {codecortex-0.23.1 → codecortex-0.23.3}/src/codeintel/commands/serve_http.py +0 -0
  46. {codecortex-0.23.1 → codecortex-0.23.3}/src/codeintel/commands/setup.py +0 -0
  47. {codecortex-0.23.1 → codecortex-0.23.3}/src/codeintel/commands/status.py +0 -0
  48. {codecortex-0.23.1 → codecortex-0.23.3}/src/codeintel/config.py +0 -0
  49. {codecortex-0.23.1 → codecortex-0.23.3}/src/codeintel/containment.py +0 -0
  50. {codecortex-0.23.1 → codecortex-0.23.3}/src/codeintel/doctor.py +0 -0
  51. {codecortex-0.23.1 → codecortex-0.23.3}/src/codeintel/graph_backend.py +0 -0
  52. {codecortex-0.23.1 → codecortex-0.23.3}/src/codeintel/graph_render.py +0 -0
  53. {codecortex-0.23.1 → codecortex-0.23.3}/src/codeintel/graph_resolution.py +0 -0
  54. {codecortex-0.23.1 → codecortex-0.23.3}/src/codeintel/grapher.py +0 -0
  55. {codecortex-0.23.1 → codecortex-0.23.3}/src/codeintel/http_server.py +0 -0
  56. {codecortex-0.23.1 → codecortex-0.23.3}/src/codeintel/injector.py +0 -0
  57. {codecortex-0.23.1 → codecortex-0.23.3}/src/codeintel/installer.py +0 -0
  58. {codecortex-0.23.1 → codecortex-0.23.3}/src/codeintel/lang_config.py +0 -0
  59. {codecortex-0.23.1 → codecortex-0.23.3}/src/codeintel/loc.py +0 -0
  60. {codecortex-0.23.1 → codecortex-0.23.3}/src/codeintel/logconfig.py +0 -0
  61. {codecortex-0.23.1 → codecortex-0.23.3}/src/codeintel/mapper.py +0 -0
  62. {codecortex-0.23.1 → codecortex-0.23.3}/src/codeintel/metrics.py +0 -0
  63. {codecortex-0.23.1 → codecortex-0.23.3}/src/codeintel/onboarding.py +0 -0
  64. {codecortex-0.23.1 → codecortex-0.23.3}/src/codeintel/outcome.py +0 -0
  65. {codecortex-0.23.1 → codecortex-0.23.3}/src/codeintel/paths.py +0 -0
  66. {codecortex-0.23.1 → codecortex-0.23.3}/src/codeintel/policy.py +0 -0
  67. {codecortex-0.23.1 → codecortex-0.23.3}/src/codeintel/progress.py +0 -0
  68. {codecortex-0.23.1 → codecortex-0.23.3}/src/codeintel/providers/__init__.py +0 -0
  69. {codecortex-0.23.1 → codecortex-0.23.3}/src/codeintel/providers/graph.py +0 -0
  70. {codecortex-0.23.1 → codecortex-0.23.3}/src/codeintel/providers/none.py +0 -0
  71. {codecortex-0.23.1 → codecortex-0.23.3}/src/codeintel/providers/semantic.py +0 -0
  72. {codecortex-0.23.1 → codecortex-0.23.3}/src/codeintel/py.typed +0 -0
  73. {codecortex-0.23.1 → codecortex-0.23.3}/src/codeintel/query_ops.py +0 -0
  74. {codecortex-0.23.1 → codecortex-0.23.3}/src/codeintel/redact.py +0 -0
  75. {codecortex-0.23.1 → codecortex-0.23.3}/src/codeintel/reindexer.py +0 -0
  76. {codecortex-0.23.1 → codecortex-0.23.3}/src/codeintel/reset.py +0 -0
  77. {codecortex-0.23.1 → codecortex-0.23.3}/src/codeintel/searcher.py +0 -0
  78. {codecortex-0.23.1 → codecortex-0.23.3}/src/codeintel/semantic_db.py +0 -0
  79. {codecortex-0.23.1 → codecortex-0.23.3}/src/codeintel/server.py +0 -0
  80. {codecortex-0.23.1 → codecortex-0.23.3}/src/codeintel/source_kind.py +0 -0
  81. {codecortex-0.23.1 → codecortex-0.23.3}/src/codeintel/term.py +0 -0
  82. {codecortex-0.23.1 → codecortex-0.23.3}/src/codeintel/verify.py +0 -0
  83. {codecortex-0.23.1 → codecortex-0.23.3}/src/codeintel/viewer/__init__.py +0 -0
  84. {codecortex-0.23.1 → codecortex-0.23.3}/src/codeintel/viewer/graph_template.html +0 -0
  85. {codecortex-0.23.1 → codecortex-0.23.3}/src/codeintel/wire_text.py +0 -0
  86. {codecortex-0.23.1 → codecortex-0.23.3}/tests/test_agent_prompt.py +0 -0
  87. {codecortex-0.23.1 → codecortex-0.23.3}/tests/test_backend_reaper.py +0 -0
  88. {codecortex-0.23.1 → codecortex-0.23.3}/tests/test_background_index_failure.py +0 -0
  89. {codecortex-0.23.1 → codecortex-0.23.3}/tests/test_bench_oracle.py +0 -0
  90. {codecortex-0.23.1 → codecortex-0.23.3}/tests/test_bench_oracle_ts.py +0 -0
  91. {codecortex-0.23.1 → codecortex-0.23.3}/tests/test_c4.py +0 -0
  92. {codecortex-0.23.1 → codecortex-0.23.3}/tests/test_c4_check.py +0 -0
  93. {codecortex-0.23.1 → codecortex-0.23.3}/tests/test_c4_layers.py +0 -0
  94. {codecortex-0.23.1 → codecortex-0.23.3}/tests/test_c4_model.py +0 -0
  95. {codecortex-0.23.1 → codecortex-0.23.3}/tests/test_c4_output.py +0 -0
  96. {codecortex-0.23.1 → codecortex-0.23.3}/tests/test_cache.py +0 -0
  97. {codecortex-0.23.1 → codecortex-0.23.3}/tests/test_chunking.py +0 -0
  98. {codecortex-0.23.1 → codecortex-0.23.3}/tests/test_cli_commands.py +0 -0
  99. {codecortex-0.23.1 → codecortex-0.23.3}/tests/test_cli_help.py +0 -0
  100. {codecortex-0.23.1 → codecortex-0.23.3}/tests/test_cli_install_dry_run.py +0 -0
  101. {codecortex-0.23.1 → codecortex-0.23.3}/tests/test_cli_install_offer_injection.py +0 -0
  102. {codecortex-0.23.1 → codecortex-0.23.3}/tests/test_cold_process.py +0 -0
  103. {codecortex-0.23.1 → codecortex-0.23.3}/tests/test_config.py +0 -0
  104. {codecortex-0.23.1 → codecortex-0.23.3}/tests/test_corpus.py +0 -0
  105. {codecortex-0.23.1 → codecortex-0.23.3}/tests/test_could_not_ask_semantic.py +0 -0
  106. {codecortex-0.23.1 → codecortex-0.23.3}/tests/test_docs_ci_claims.py +0 -0
  107. {codecortex-0.23.1 → codecortex-0.23.3}/tests/test_docs_deadcode_withdrawal.py +0 -0
  108. {codecortex-0.23.1 → codecortex-0.23.3}/tests/test_e2e.py +0 -0
  109. {codecortex-0.23.1 → codecortex-0.23.3}/tests/test_edge_confidence.py +0 -0
  110. {codecortex-0.23.1 → codecortex-0.23.3}/tests/test_engine_adoption.py +0 -0
  111. {codecortex-0.23.1 → codecortex-0.23.3}/tests/test_enterprise.py +0 -0
  112. {codecortex-0.23.1 → codecortex-0.23.3}/tests/test_graph_failure_population.py +0 -0
  113. {codecortex-0.23.1 → codecortex-0.23.3}/tests/test_graph_provider.py +0 -0
  114. {codecortex-0.23.1 → codecortex-0.23.3}/tests/test_graph_real.py +0 -0
  115. {codecortex-0.23.1 → codecortex-0.23.3}/tests/test_graph_stdin.py +0 -0
  116. {codecortex-0.23.1 → codecortex-0.23.3}/tests/test_grapher.py +0 -0
  117. {codecortex-0.23.1 → codecortex-0.23.3}/tests/test_hard_exit.py +0 -0
  118. {codecortex-0.23.1 → codecortex-0.23.3}/tests/test_hardening.py +0 -0
  119. {codecortex-0.23.1 → codecortex-0.23.3}/tests/test_http_auth.py +0 -0
  120. {codecortex-0.23.1 → codecortex-0.23.3}/tests/test_http_server.py +0 -0
  121. {codecortex-0.23.1 → codecortex-0.23.3}/tests/test_index_progress.py +0 -0
  122. {codecortex-0.23.1 → codecortex-0.23.3}/tests/test_indexer_binary.py +0 -0
  123. {codecortex-0.23.1 → codecortex-0.23.3}/tests/test_installer.py +0 -0
  124. {codecortex-0.23.1 → codecortex-0.23.3}/tests/test_integration.py +0 -0
  125. {codecortex-0.23.1 → codecortex-0.23.3}/tests/test_lang_config.py +0 -0
  126. {codecortex-0.23.1 → codecortex-0.23.3}/tests/test_loc_census.py +0 -0
  127. {codecortex-0.23.1 → codecortex-0.23.3}/tests/test_lsp_language_coverage.py +0 -0
  128. {codecortex-0.23.1 → codecortex-0.23.3}/tests/test_lsp_real.py +0 -0
  129. {codecortex-0.23.1 → codecortex-0.23.3}/tests/test_mapper.py +0 -0
  130. {codecortex-0.23.1 → codecortex-0.23.3}/tests/test_mcp_handshake.py +0 -0
  131. {codecortex-0.23.1 → codecortex-0.23.3}/tests/test_mcp_server.py +0 -0
  132. {codecortex-0.23.1 → codecortex-0.23.3}/tests/test_model_dimension.py +0 -0
  133. {codecortex-0.23.1 → codecortex-0.23.3}/tests/test_never_raise.py +0 -0
  134. {codecortex-0.23.1 → codecortex-0.23.3}/tests/test_onboarding.py +0 -0
  135. {codecortex-0.23.1 → codecortex-0.23.3}/tests/test_rbac.py +0 -0
  136. {codecortex-0.23.1 → codecortex-0.23.3}/tests/test_redaction_boundary.py +0 -0
  137. {codecortex-0.23.1 → codecortex-0.23.3}/tests/test_reindexer.py +0 -0
  138. {codecortex-0.23.1 → codecortex-0.23.3}/tests/test_rerank.py +0 -0
  139. {codecortex-0.23.1 → codecortex-0.23.3}/tests/test_reset.py +0 -0
  140. {codecortex-0.23.1 → codecortex-0.23.3}/tests/test_source_kind.py +0 -0
  141. {codecortex-0.23.1 → codecortex-0.23.3}/tests/test_staleness.py +0 -0
  142. {codecortex-0.23.1 → codecortex-0.23.3}/tests/test_symbol_context.py +0 -0
  143. {codecortex-0.23.1 → codecortex-0.23.3}/tests/test_term.py +0 -0
  144. {codecortex-0.23.1 → codecortex-0.23.3}/tests/test_treesitter.py +0 -0
  145. {codecortex-0.23.1 → codecortex-0.23.3}/tests/test_verify.py +0 -0
  146. {codecortex-0.23.1 → codecortex-0.23.3}/tests/test_verify_call.py +0 -0
  147. {codecortex-0.23.1 → codecortex-0.23.3}/tests/test_version_skew.py +0 -0
  148. {codecortex-0.23.1 → codecortex-0.23.3}/tests/test_wire_text.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codecortex
3
- Version: 0.23.1
3
+ Version: 0.23.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.23.1
3
+ Version: 0.23.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.23.3"
@@ -174,7 +174,7 @@ class Gateway:
174
174
  self, result: Result, op: str, target: str, budget: Any,
175
175
  project_root: Any, was_auto: bool,
176
176
  ) -> Result:
177
- """Ask the LSP when the graph's whole answer was resolved by name rather than by import.
177
+ """Ask the LSP when the graph's caller answer carries a known binding failure.
178
178
 
179
179
  This closes the routing gap that let the worst failure through. `_AUTO_ENGINE` is a static
180
180
  op→engine map, not a cascade: `callers` goes to the graph and, uniquely among the ops, has
@@ -185,48 +185,71 @@ class Gateway:
185
185
  being confidently wrong is a bug; no second engine ever being consulted is the design that
186
186
  let it reach the caller.
187
187
 
188
- Deliberately narrow. It fires only when the graph itself raised `all-rows-name-resolved`
189
- every row a name guess, across enough rows for the pattern to mean something which is the
190
- collision signature and not the ordinary case of one unverified row among several. It
191
- APPENDS rather than replaces: the LSP answers a related but different question (references,
192
- not call edges), so presenting its list as the graph's would substitute one over-claim for
193
- another. And it never fires for an explicitly pinned `--engine graph`, where the caller has
194
- said which engine they want."""
188
+ Deliberately narrow. It fires when every graph row was name-resolved, or when a caller used
189
+ a file hint and the graph found same-named symbols but no edge for that exact definition.
190
+ The latter catches property calls that the TypeScript graph resolver failed to bind, while
191
+ avoiding an LSP call for ordinary healthy answers. It APPENDS rather than replaces: the LSP
192
+ answers a related but different question (references, not call edges), so presenting its
193
+ list as the graph's would substitute one over-claim for another. And it never fires for an
194
+ explicitly pinned `--engine graph`, where the caller has said which engine they want."""
195
195
  try:
196
196
  if not was_auto or op not in self._CROSS_CHECKED_OPS:
197
197
  return result
198
198
  if result.get("result") is None or self.lsp is None:
199
199
  return result
200
200
  gaps = result.get("gaps") or []
201
- if not any(g.get("kind") == "all-rows-name-resolved"
202
- for g in gaps if isinstance(g, dict)):
201
+ kinds = {
202
+ str(g.get("kind") or "") for g in gaps if isinstance(g, dict)
203
+ }
204
+ all_name_resolved = "all-rows-name-resolved" in kinds
205
+ exact_target_unbound = "target-hint-unmatched" in kinds
206
+ if not (all_name_resolved or exact_target_unbound):
203
207
  return result
208
+ graph_problem = (
209
+ "every graph row was resolved by name"
210
+ if all_name_resolved
211
+ else "the graph found no caller edge for the file-qualified symbol"
212
+ )
204
213
  probe = self._dispatch_single(
205
214
  self.lsp, "symbol", target, budget, project_root, "lsp")
206
215
  body = probe.get("result")
207
- if not body:
216
+ probe_gaps = probe.get("gaps") or []
217
+ reference_gap = next((
218
+ gap for gap in probe_gaps
219
+ if isinstance(gap, dict) and gap.get("section") == "references"
220
+ ), None)
221
+ if not body or reference_gap is not None:
208
222
  # Silence from the LSP is not agreement. Say which check did not happen — and
209
223
  # separate "not yet booted" from "had nothing", because only the first is fixed by
210
224
  # asking again. A one-shot CLI process meets a cold serena on every invocation; the
211
225
  # long-lived MCP server keeps the session warm and takes this branch once at most.
212
226
  # Waiting here is deliberately NOT done: it would hold back a graph answer that is
213
227
  # already complete, to append a section that is only advisory.
214
- warming = probe.get("reason") == "warming"
215
- why = ("the language server had not finished booting"
216
- if warming else "the LSP engine reported nothing for this symbol")
228
+ unavailable_reason = (
229
+ str(reference_gap.get("kind") or "not-asked")
230
+ if reference_gap is not None else str(probe.get("reason") or "no-result")
231
+ )
232
+ warming = unavailable_reason in {"warming", "timeout"}
233
+ why = (
234
+ "the language server had not finished booting"
235
+ if unavailable_reason == "warming"
236
+ else str(reference_gap.get("detail") or "the reference lookup did not answer")
237
+ if reference_gap is not None
238
+ else "the LSP engine reported nothing for this symbol"
239
+ )
217
240
  nxt = (" Ask again once it is warm and this section will be filled in."
218
241
  if warming else
219
242
  " Check it yourself with `--engine lsp --op symbol`.")
220
243
  return {**result, "gaps": [*gaps, {
221
244
  "section": op, "kind": "cross-check-unavailable",
222
245
  "engine": "lsp",
223
- "reason": probe.get("reason") or "no-result",
224
- "detail": f"every graph row was name-resolved and the LSP could not confirm "
225
- f"them ({why}), so they remain unverified"
246
+ "reason": unavailable_reason,
247
+ "detail": f"{graph_problem} and the LSP could not provide an independent "
248
+ f"reference check ({why}), so the graph answer remains unverified"
226
249
  + (" — retry" if warming else ""),
227
250
  **({"retry_after_s": 2} if warming else {}),
228
251
  }], "result": str(result["result"]) + (
229
- f"\n\n> Cross-check unavailable: every row above was resolved by name, and "
252
+ f"\n\n> Cross-check unavailable: {graph_problem}, and "
230
253
  f"{why}, so nothing here has been confirmed against a second engine.{nxt}"
231
254
  )}
232
255
  refs = self._reference_lines(str(body))
@@ -236,15 +259,15 @@ class Gateway:
236
259
  merged = (
237
260
  f"{result['result']}\n\n## Cross-check — LSP references to `{target}` "
238
261
  f"({len(refs)})\n"
239
- f"_The rows above were all resolved by NAME by the graph engine. These come from the "
240
- f"language server, which resolves through the file's imports. A caller listed above "
241
- f"but absent here is very likely a name collision; a location here but missing above "
242
- f"is a call the graph could not bind._\n" + listing + more
262
+ f"_The graph answer has a known binding gap: {graph_problem}. These locations come "
263
+ f"from the language server, which resolves the exact definition. A caller listed "
264
+ f"above but absent here is likely a name collision; a location here but missing "
265
+ f"above is a reference the graph could not bind._\n" + listing + more
243
266
  )
244
267
  return {**result, "result": merged, "gaps": [*gaps, {
245
268
  "section": op, "kind": "cross-checked-with-lsp",
246
269
  "engine": "lsp",
247
- "detail": f"every graph row was name-resolved, so the LSP was consulted "
270
+ "detail": f"{graph_problem}, so the LSP was consulted "
248
271
  f"independently and reported {len(refs)} reference location(s); the two "
249
272
  f"lists answer related but different questions and are shown separately",
250
273
  }]}
@@ -292,17 +315,22 @@ class Gateway:
292
315
  # codebase throws away the could-not-ask / asked-and-found-nothing distinction it is
293
316
  # otherwise careful to preserve per-provider.
294
317
  reasons = {eng: str(r.get("reason") or "no-result") for eng, r in results.items()}
295
- unreachable = {"engine-unavailable", "boot-failed", "warming", "project-not-indexed",
296
- "project-not-indexed-standalone", "error", "timeout",
297
- # An index pass that RAN and FAILED is a could-not-ask, not a
298
- # found-nothing. `no-index` deliberately stays out: it means the pass
299
- # completed and there was nothing to embed, which is an answer.
300
- "index-failed",
301
- # A query the embedder could not encode. No search ran, so the empty
302
- # result says nothing about the repository — the same reasoning as
303
- # `index-failed`, one step later in the pipeline.
304
- "query-failed"}
305
- all_unreachable = bool(reasons) and all(v in unreachable for v in reasons.values())
318
+ # Providers have already classified their reason into the public outcome taxonomy.
319
+ # Repeating a private reason allow-list here guaranteed drift: the first new reason,
320
+ # ``source-unreadable``, was correctly marked unavailable by LSP and then collapsed
321
+ # back to ``not_found`` by this merge. Failed and unavailable both mean no engine was
322
+ # able to answer; an actual miss is the explicit ``not_found`` outcome.
323
+ # Older/custom providers may not have adopted ``outcome`` yet. Route their reason
324
+ # through the SAME central classifier rather than reintroducing a local allow-list.
325
+ classified = [
326
+ r.get("outcome") or safe_null_result(
327
+ op_str, target_str, engine=eng, reason=reasons[eng]
328
+ ).get("outcome")
329
+ for eng, r in results.items()
330
+ ]
331
+ all_unreachable = bool(classified) and all(
332
+ outcome in ("unavailable", "failed") for outcome in classified
333
+ )
306
334
  summary = "engines-unavailable" if all_unreachable else "no-result"
307
335
  detail = ", ".join(f"{eng}: {why}" for eng, why in sorted(reasons.items()))
308
336
  return safe_null_result(
@@ -390,7 +390,19 @@ class Indexer:
390
390
  root_real = real_root(str(root))
391
391
  except Exception:
392
392
  root_real = str(root)
393
- for dirpath, dirnames, filenames in os.walk(root):
393
+ def fail_on_unreadable_tree(error: OSError) -> None:
394
+ """Do not turn an unreadable subtree into a successful empty index.
395
+
396
+ ``os.walk`` silently ignores directory-enumeration failures unless an ``onerror``
397
+ callback is supplied. On macOS this is particularly dangerous for folders protected
398
+ by Files & Folders / Full Disk Access: the root can pass ``isdir`` while every source
399
+ directory raises ``EPERM``, leaving the CLI to report "Nothing new to index" at exit
400
+ zero. Propagating the original error preserves its path and lets ``index()`` expose a
401
+ truthful, actionable failure.
402
+ """
403
+ raise error
404
+
405
+ for dirpath, dirnames, filenames in os.walk(root, onerror=fail_on_unreadable_tree):
394
406
  at_root = os.path.realpath(dirpath) == os.path.realpath(real_root_str)
395
407
  dirnames[:] = [
396
408
  d for d in dirnames
@@ -75,20 +75,31 @@ def safe_null_result(
75
75
  hint: str | None = None,
76
76
  ) -> Result:
77
77
  failed_reasons = {
78
- "backend-error", "backend-incompatible", "boot-failed", "error", "index-failed",
79
- "provider-error", "timeout", "unparsable",
78
+ "backend-error", "backend-incompatible", "boot-failed", "error", "gateway-error",
79
+ "handler-error", "index-failed", "provider-error", "query-failed", "timeout",
80
+ "unparsable",
80
81
  }
81
82
  unavailable_reasons = {
82
- "engine-unavailable", "engines-unavailable", "indexing-in-progress", "no-engine",
83
- "no-index", "no-project-root", "op-not-supported", "project-not-indexed",
84
- "project-not-indexed-standalone", "unsupported-op", "warming",
83
+ "backend-unreachable", "engine-unavailable", "engines-unavailable", "indexing-in-progress",
84
+ "index-stale", "no-engine", "no-project-root", "op-not-allowed-for-role",
85
+ "op-not-supported", "op-withdrawn", "project-not-indexed",
86
+ "project-not-indexed-standalone", "root-not-allowed-for-role", "source-unreadable",
87
+ "unknown-engine", "unsupported-op", "warming",
88
+ }
89
+ not_found_reasons = {
90
+ "below-floor", "no-edges", "no-index", "no-result", "not-found", "not-in-graph",
85
91
  }
86
92
  if reason in failed_reasons:
87
93
  outcome = "failed"
88
94
  elif reason in unavailable_reasons:
89
95
  outcome = "unavailable"
90
- else:
96
+ elif reason in not_found_reasons:
91
97
  outcome = "not_found"
98
+ else:
99
+ # New failure reasons must fail closed. Defaulting every unknown string to ``not_found``
100
+ # turned a newly introduced ``source-unreadable`` reason into an assertion that the symbol
101
+ # was absent — exactly the ambiguity the explicit outcome field was added to remove.
102
+ outcome = "failed"
92
103
  r: Result = {
93
104
  "ok": True,
94
105
  "op": str(op or ""),
@@ -67,6 +67,30 @@ _BACKEND_ERROR_MARKERS = (
67
67
  )
68
68
 
69
69
 
70
+ def _split_target_file_hint(target: str) -> tuple[str, str]:
71
+ """Return Serena's symbol pattern and an optional repo-relative file hint.
72
+
73
+ Graph operations accept ``name@path`` to disambiguate duplicate symbols. The gateway may pass
74
+ that exact target to the LSP for an independent reference cross-check, but Serena understands
75
+ only the name-path portion. Keep the syntax aligned here without coupling the LSP provider to
76
+ graph.py's private target parser.
77
+ """
78
+ raw = str(target or "").strip()
79
+ if "@" not in raw:
80
+ return raw, ""
81
+ head, _, tail = raw.rpartition("@")
82
+ if not head.strip() or not tail.strip():
83
+ return raw, ""
84
+ return head.strip(), tail.strip()
85
+
86
+
87
+ def _file_hint_matches(file_path: Any, hint: str) -> bool:
88
+ """Match a file hint on path-segment boundaries, consistently with the graph provider."""
89
+ have = str(file_path or "").replace("\\", "/").strip().strip("/").lower()
90
+ want = str(hint or "").replace("\\", "/").strip().strip("/").lower()
91
+ return bool(have and want and (have == want or have.endswith("/" + want)))
92
+
93
+
70
94
  def _looks_like_backend_error(text: str) -> bool:
71
95
  """Whether *text* is a backend failure message rather than a result.
72
96
 
@@ -127,6 +151,29 @@ def _serena_launch_args(cmd: str, project_root: str) -> list[str]:
127
151
  return [cmd, *common]
128
152
 
129
153
 
154
+ def _project_access_failure(project_root: str) -> tuple[str, str] | None:
155
+ """Return a safe diagnosis when the host cannot enumerate the project root.
156
+
157
+ Serena's stdio transport wraps an early subprocess exit in an ``ExceptionGroup``. When the
158
+ real cause is a macOS Files & Folders denial, reporting that wrapper as a network/bootstrap
159
+ failure sends the user in exactly the wrong direction. A one-entry scan is cheap, local, and
160
+ establishes the prerequisite Serena itself needs without forwarding any backend-controlled
161
+ prose to the calling agent.
162
+ """
163
+ try:
164
+ with os.scandir(project_root) as entries:
165
+ next(entries, None)
166
+ except OSError as exc:
167
+ location = str(getattr(exc, "filename", None) or project_root)
168
+ detail = f"repository root is not readable by codeintel ({type(exc).__name__}: {location})"
169
+ remediation = (
170
+ "grant the codeintel host read access to the repository, then retry; on macOS check "
171
+ "System Settings > Privacy & Security > Files and Folders (or Full Disk Access)"
172
+ )
173
+ return detail, remediation
174
+ return None
175
+
176
+
130
177
  class _State(enum.Enum):
131
178
  WARMING = "WARMING"
132
179
  READY = "READY"
@@ -433,6 +480,12 @@ class LspProvider:
433
480
  "detail": "serena session is warming for this repo", "remediation": None}
434
481
 
435
482
  # deep: boot (or reuse) a session and poll to a hard deadline — never hangs.
483
+ access_failure = _project_access_failure(project_root)
484
+ if access_failure is not None:
485
+ detail, remediation = access_failure
486
+ return {"installed": True, "runnable": False, "repo_indexed": None,
487
+ "detail": detail, "remediation": remediation}
488
+
436
489
  session = self._get_or_create_session(project_root)
437
490
  deadline = time.monotonic() + timeout_s
438
491
  while time.monotonic() < deadline:
@@ -502,6 +555,21 @@ class LspProvider:
502
555
  if not self.available:
503
556
  return safe_null_result(op_str, target_str, engine="lsp", reason="engine-unavailable")
504
557
 
558
+ # A protected macOS folder can still satisfy ``isdir`` while denying enumeration.
559
+ # Detect that locally before spawning Serena: otherwise its early exit is wrapped by
560
+ # the MCP transport as a generic boot failure and the first-query hint talks about a
561
+ # cold uvx cache instead of the permission the user actually needs to grant. Keep the
562
+ # ``isdir`` guard so synthetic/nonexistent roots used by embedders retain the existing
563
+ # never-raise backend behaviour.
564
+ if os.path.isdir(root_str):
565
+ access_failure = _project_access_failure(root_str)
566
+ if access_failure is not None:
567
+ detail, remediation = access_failure
568
+ return safe_null_result(
569
+ op_str, target_str, engine="lsp", reason="source-unreadable",
570
+ hint=f"{detail}; {remediation}",
571
+ )
572
+
505
573
  try:
506
574
  budget_ms = int(budget) if budget else 0
507
575
  except Exception:
@@ -729,10 +797,18 @@ class LspProvider:
729
797
  self, session: _LspSession, target: str, root: str, timeout_s: float
730
798
  ) -> str | None:
731
799
  try:
800
+ symbol_target, file_hint = _split_target_file_hint(target)
732
801
  def_out = self._call_tool(
733
802
  session,
734
803
  "find_symbol",
735
- {"name_path_pattern": target, "include_body": True, "max_matches": 5},
804
+ {
805
+ "name_path_pattern": symbol_target,
806
+ "include_body": True,
807
+ # A file hint exists specifically because the name is ambiguous. Retrieve
808
+ # enough definitions to find the requested file instead of silently selecting
809
+ # whichever five Serena happens to return first.
810
+ "max_matches": 50 if file_hint else 5,
811
+ },
736
812
  timeout_s,
737
813
  )
738
814
  if isinstance(def_out, Missing):
@@ -745,6 +821,21 @@ class LspProvider:
745
821
  def_raw = def_out.value
746
822
  def_text = self._extract_text(def_raw)
747
823
  matches = self._loads(def_text)
824
+ if isinstance(matches, list) and file_hint:
825
+ matches = [
826
+ match for match in matches
827
+ if isinstance(match, dict)
828
+ and _file_hint_matches(match.get("relative_path"), file_hint)
829
+ ]
830
+ if not matches:
831
+ # Do not fall through and render the original JSON containing definitions
832
+ # from other files. More importantly, the reference lookup below must remain
833
+ # "not asked", rather than turning an unresolved exact definition into a
834
+ # confident zero-reference answer.
835
+ def_text = (
836
+ f"> The language server found no definition for `{symbol_target}` in "
837
+ f"`{file_hint}`."
838
+ )
748
839
 
749
840
  first: dict | None = None
750
841
  if isinstance(matches, list) and matches:
@@ -316,6 +316,27 @@ def test_lsp_probe_deep_boot_failure_is_bounded(monkeypatch):
316
316
  assert r["runnable"] in (False, None) # failed to boot (or timed out) — never True
317
317
 
318
318
 
319
+ def test_lsp_probe_reports_repository_access_before_blaming_network(monkeypatch):
320
+ monkeypatch.setattr("codeintel.providers.lsp.shutil.which", lambda x: "/fake/uvx")
321
+ monkeypatch.setattr(
322
+ "codeintel.providers.lsp.os.scandir",
323
+ lambda root: (_ for _ in ()).throw(PermissionError(1, "denied", str(root))),
324
+ )
325
+ p = LspProvider()
326
+ monkeypatch.setattr(
327
+ p, "_get_or_create_session",
328
+ lambda root: pytest.fail("an unreadable repository must not launch serena"),
329
+ )
330
+
331
+ r = p.probe("/protected/repo", deep=True)
332
+
333
+ assert r["runnable"] is False
334
+ assert "PermissionError" in r["detail"]
335
+ assert "/protected/repo" in r["detail"]
336
+ assert "Privacy & Security" in r["remediation"]
337
+ assert "network" not in r["remediation"].lower()
338
+
339
+
319
340
  # --------------------------------------------------------------------------- #
320
341
  # handler + hint plumbing
321
342
  # --------------------------------------------------------------------------- #
@@ -548,6 +548,7 @@ def test_a_fanout_where_engines_ran_and_found_nothing_still_reports_no_result():
548
548
  lsp=_NullProvider("lsp", "not-found"))
549
549
  r = gw.query(op="context", target="nope", engine="both")
550
550
  assert r["reason"] == "no-result"
551
+ assert r["outcome"] == "not_found"
551
552
  assert "NOT evidence" not in (r.get("hint") or "")
552
553
 
553
554
 
@@ -371,8 +371,13 @@ def test_null_result_outcome_is_machine_readable():
371
371
  from codeintel.provider import safe_null_result
372
372
 
373
373
  assert safe_null_result("x", "y", reason="not-in-graph")["outcome"] == "not_found"
374
+ assert safe_null_result("x", "y", reason="not-found")["outcome"] == "not_found"
375
+ assert safe_null_result("x", "y", reason="no-index")["outcome"] == "not_found"
374
376
  assert safe_null_result("x", "y", reason="engine-unavailable")["outcome"] == "unavailable"
375
377
  assert safe_null_result("x", "y", reason="backend-error")["outcome"] == "failed"
378
+ assert safe_null_result("x", "y", reason="source-unreadable")["outcome"] == "unavailable"
379
+ assert safe_null_result("x", "y", reason="backend-unreachable")["outcome"] == "unavailable"
380
+ assert safe_null_result("x", "y", reason="new-unclassified-reason")["outcome"] == "failed"
376
381
 
377
382
 
378
383
  def test_a_fanned_out_answer_reports_the_engine_that_could_not_be_asked():
@@ -398,6 +403,31 @@ def test_a_fanned_out_answer_reports_the_engine_that_could_not_be_asked():
398
403
  assert "engine-unavailable" in kinds, kinds
399
404
 
400
405
 
406
+ def test_fan_out_preserves_source_unreadable_as_unavailable():
407
+ """The merge must consume the public outcome, not maintain another reason allow-list. If all
408
+ engines could not answer, a newly introduced reason must never collapse back to ``not_found``.
409
+ """
410
+ from codeintel.gateway import Gateway
411
+ from codeintel.provider import safe_null_result
412
+
413
+ merged = Gateway()._merge(
414
+ {
415
+ "lsp": safe_null_result(
416
+ "context", "createSession", engine="lsp", reason="source-unreadable"
417
+ ),
418
+ "graph": safe_null_result(
419
+ "context", "createSession", engine="graph", reason="project-not-indexed"
420
+ ),
421
+ },
422
+ "context", "createSession",
423
+ )
424
+
425
+ assert merged["result"] is None
426
+ assert merged["reason"] == "engines-unavailable"
427
+ assert merged["outcome"] == "unavailable"
428
+ assert "NOT evidence" in merged["hint"]
429
+
430
+
401
431
  def test_a_constituent_gap_survives_the_merge():
402
432
  from codeintel.gateway import Gateway
403
433
 
@@ -326,6 +326,31 @@ def test_other_failures_keep_their_type_prefix(monkeypatch, tmp_path):
326
326
  assert indexer.last_error == "PermissionError: read-only file system"
327
327
 
328
328
 
329
+ def test_an_unreadable_subtree_is_not_a_successful_empty_walk(monkeypatch, tmp_path):
330
+ """``os.walk`` ignores enumeration errors by default. A macOS-protected repository could
331
+ therefore scan as zero files and make the CLI print "Nothing new to index" at exit zero even
332
+ though no source was readable. The original permission error, including its path, must escape
333
+ the walk so ``Indexer.index`` can report it as an unrecoverable failure.
334
+ """
335
+ import codeintel.indexer as indexer_module
336
+ from codeintel.indexer import Indexer
337
+
338
+ protected = tmp_path / "protected-repo"
339
+
340
+ def denied_walk(root, *, onerror=None):
341
+ assert onerror is not None
342
+ onerror(PermissionError(1, "Operation not permitted", str(protected)))
343
+ return []
344
+
345
+ monkeypatch.setattr(indexer_module.os, "walk", denied_walk)
346
+ monkeypatch.setattr(indexer_module, "load_gitattributes_globs", lambda root: [])
347
+ indexer = Indexer.__new__(Indexer)
348
+ monkeypatch.setattr(indexer, "_load_gitignore", lambda root: set())
349
+
350
+ with pytest.raises(PermissionError, match="protected-repo"):
351
+ list(indexer._walk_files(tmp_path))
352
+
353
+
329
354
  def test_the_searcher_reports_it_too(monkeypatch):
330
355
  """A query is the other way a cold cache is discovered — stage-qualified, message verbatim."""
331
356
  from codeintel.searcher import Searcher
@@ -6,6 +6,8 @@ import time
6
6
  from typing import Any
7
7
  from unittest.mock import MagicMock
8
8
 
9
+ import pytest
10
+
9
11
  from codeintel.outcome import Missing, Ok
10
12
  from codeintel.providers.lsp import LspProvider, _State
11
13
  from codeintel.server import code_status_handler
@@ -161,6 +163,64 @@ def test_lsp_provider_ready_symbol(monkeypatch):
161
163
  assert "parse_result" in r["result"]
162
164
 
163
165
 
166
+ def test_file_qualified_symbol_selects_the_matching_definition(monkeypatch):
167
+ """A graph file hint must survive the LSP cross-check instead of selecting a same-name method."""
168
+ monkeypatch.setattr("codeintel.providers.lsp.shutil.which", lambda x: "/fake/uvx")
169
+ p = LspProvider()
170
+ p._sessions["/my/repo"] = _make_fake_session(_State.READY)
171
+ calls: list[tuple[str, dict]] = []
172
+
173
+ def _fake_call_tool(session, tool, args, timeout_s):
174
+ calls.append((tool, args))
175
+ if tool == "find_symbol":
176
+ return Ok('[{"name_path":"Other/createSession","kind":"Method",'
177
+ '"relative_path":"src/other.ts","body_location":{"start_line":1,'
178
+ '"end_line":2},"body":"wrong"},{"name_path":"WsSessionHandler/createSession",'
179
+ '"kind":"Method","relative_path":"backend/src/session.handler.ts",'
180
+ '"body_location":{"start_line":138,"end_line":177},"body":"right"}]')
181
+ if tool == "find_referencing_symbols":
182
+ assert args["name_path"] == "WsSessionHandler/createSession"
183
+ assert args["relative_path"] == "backend/src/session.handler.ts"
184
+ return Ok('{"backend/src/gateway.ts":{"Method":[{"name_path":"Gateway/routeMessage",'
185
+ '"content_around_reference":"> 612: await handler.createSession()"}]}}')
186
+ return Missing("backend-error", "unstubbed tool")
187
+
188
+ monkeypatch.setattr(p, "_call_tool", _fake_call_tool)
189
+ r = p.build_result(
190
+ "symbol", "createSession@backend/src/session.handler.ts", [], 30000, "/my/repo"
191
+ )
192
+
193
+ assert r["confidence"] == "complete"
194
+ assert "right" in r["result"] and "wrong" not in r["result"]
195
+ assert "backend/src/gateway.ts:613" in r["result"]
196
+ assert calls[0][1]["name_path_pattern"] == "createSession"
197
+ assert calls[0][1]["max_matches"] == 50
198
+
199
+
200
+ def test_file_qualified_symbol_does_not_use_a_definition_from_another_file(monkeypatch):
201
+ monkeypatch.setattr("codeintel.providers.lsp.shutil.which", lambda x: "/fake/uvx")
202
+ p = LspProvider()
203
+ p._sessions["/my/repo"] = _make_fake_session(_State.READY)
204
+ calls: list[str] = []
205
+
206
+ def _fake_call_tool(session, tool, args, timeout_s):
207
+ calls.append(tool)
208
+ return Ok('[{"name_path":"Other/createSession","kind":"Method",'
209
+ '"relative_path":"src/other.ts","body_location":{"start_line":1,'
210
+ '"end_line":2},"body":"wrong"}]')
211
+
212
+ monkeypatch.setattr(p, "_call_tool", _fake_call_tool)
213
+ r = p.build_result(
214
+ "symbol", "createSession@backend/src/session.handler.ts", [], 30000, "/my/repo"
215
+ )
216
+
217
+ assert r["confidence"] == "partial"
218
+ assert "found no definition" in r["result"] and "wrong" not in r["result"]
219
+ assert "References — not retrieved" in r["result"]
220
+ assert calls == ["find_symbol"]
221
+ assert any(g["section"] == "references" and g["kind"] == "not-asked" for g in r["gaps"])
222
+
223
+
164
224
  # ---------------------------------------------------------------------------
165
225
  # Group 8 — Unsupported op when READY
166
226
  # ---------------------------------------------------------------------------
@@ -462,6 +522,32 @@ def test_boot_failed_on_a_cold_uvx_first_attempt_reads_as_retry(monkeypatch):
462
522
  assert "download" in hint
463
523
 
464
524
 
525
+ def test_an_unreadable_repository_never_launches_serena(monkeypatch):
526
+ monkeypatch.setattr("codeintel.providers.lsp.shutil.which",
527
+ lambda x: "/fake/uvx" if x == "uvx" else None)
528
+ monkeypatch.setattr("codeintel.providers.lsp.os.path.isdir", lambda root: True)
529
+ monkeypatch.setattr(
530
+ "codeintel.providers.lsp._project_access_failure",
531
+ lambda root: (
532
+ f"repository root is not readable by codeintel (PermissionError: {root})",
533
+ "grant repository access",
534
+ ),
535
+ )
536
+ p = LspProvider()
537
+ monkeypatch.setattr(
538
+ p, "_get_or_create_session",
539
+ lambda root: pytest.fail("an unreadable repository must not launch serena"),
540
+ )
541
+
542
+ r = p.build_result("symbol", "createSession", [], 1000, "/protected/repo")
543
+
544
+ assert r["result"] is None
545
+ assert r["reason"] == "source-unreadable"
546
+ assert r["outcome"] == "unavailable"
547
+ assert "PermissionError" in r["hint"]
548
+ assert "grant repository access" in r["hint"]
549
+
550
+
465
551
  def test_boot_failed_on_a_respawn_does_not_blame_a_cold_cache(monkeypatch):
466
552
  """`uvx` populated its cache on the first attempt, so "still downloading" is only true once."""
467
553
  monkeypatch.setattr("codeintel.providers.lsp.shutil.which",