codecortex 0.23.1__tar.gz → 0.23.2__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.2}/PKG-INFO +1 -1
  2. {codecortex-0.23.1 → codecortex-0.23.2/src/codecortex.egg-info}/PKG-INFO +1 -1
  3. codecortex-0.23.2/src/codeintel/__init__.py +1 -0
  4. {codecortex-0.23.1 → codecortex-0.23.2}/src/codeintel/gateway.py +16 -11
  5. {codecortex-0.23.1 → codecortex-0.23.2}/src/codeintel/indexer.py +13 -1
  6. {codecortex-0.23.1 → codecortex-0.23.2}/src/codeintel/provider.py +17 -6
  7. {codecortex-0.23.1 → codecortex-0.23.2}/src/codeintel/providers/lsp.py +44 -0
  8. {codecortex-0.23.1 → codecortex-0.23.2}/tests/test_doctor.py +21 -0
  9. {codecortex-0.23.1 → codecortex-0.23.2}/tests/test_gateway.py +1 -0
  10. {codecortex-0.23.1 → codecortex-0.23.2}/tests/test_incompleteness.py +30 -0
  11. {codecortex-0.23.1 → codecortex-0.23.2}/tests/test_index_failure_reason.py +25 -0
  12. {codecortex-0.23.1 → codecortex-0.23.2}/tests/test_lsp_provider.py +28 -0
  13. {codecortex-0.23.1 → codecortex-0.23.2}/tests/test_semantic_provider.py +1 -0
  14. codecortex-0.23.1/src/codeintel/__init__.py +0 -1
  15. {codecortex-0.23.1 → codecortex-0.23.2}/LICENSE +0 -0
  16. {codecortex-0.23.1 → codecortex-0.23.2}/README.md +0 -0
  17. {codecortex-0.23.1 → codecortex-0.23.2}/pyproject.toml +0 -0
  18. {codecortex-0.23.1 → codecortex-0.23.2}/setup.cfg +0 -0
  19. {codecortex-0.23.1 → codecortex-0.23.2}/src/codecortex.egg-info/SOURCES.txt +0 -0
  20. {codecortex-0.23.1 → codecortex-0.23.2}/src/codecortex.egg-info/dependency_links.txt +0 -0
  21. {codecortex-0.23.1 → codecortex-0.23.2}/src/codecortex.egg-info/entry_points.txt +0 -0
  22. {codecortex-0.23.1 → codecortex-0.23.2}/src/codecortex.egg-info/requires.txt +0 -0
  23. {codecortex-0.23.1 → codecortex-0.23.2}/src/codecortex.egg-info/top_level.txt +0 -0
  24. {codecortex-0.23.1 → codecortex-0.23.2}/src/codeintel/__main__.py +0 -0
  25. {codecortex-0.23.1 → codecortex-0.23.2}/src/codeintel/agent_prompt.py +0 -0
  26. {codecortex-0.23.1 → codecortex-0.23.2}/src/codeintel/auth.py +0 -0
  27. {codecortex-0.23.1 → codecortex-0.23.2}/src/codeintel/c4.py +0 -0
  28. {codecortex-0.23.1 → codecortex-0.23.2}/src/codeintel/c4_check.py +0 -0
  29. {codecortex-0.23.1 → codecortex-0.23.2}/src/codeintel/c4_layers.py +0 -0
  30. {codecortex-0.23.1 → codecortex-0.23.2}/src/codeintel/cache.py +0 -0
  31. {codecortex-0.23.1 → codecortex-0.23.2}/src/codeintel/commands/__init__.py +0 -0
  32. {codecortex-0.23.1 → codecortex-0.23.2}/src/codeintel/commands/_common.py +0 -0
  33. {codecortex-0.23.1 → codecortex-0.23.2}/src/codeintel/commands/c4.py +0 -0
  34. {codecortex-0.23.1 → codecortex-0.23.2}/src/codeintel/commands/doctor.py +0 -0
  35. {codecortex-0.23.1 → codecortex-0.23.2}/src/codeintel/commands/gen_token.py +0 -0
  36. {codecortex-0.23.1 → codecortex-0.23.2}/src/codeintel/commands/graph.py +0 -0
  37. {codecortex-0.23.1 → codecortex-0.23.2}/src/codeintel/commands/index.py +0 -0
  38. {codecortex-0.23.1 → codecortex-0.23.2}/src/codeintel/commands/install.py +0 -0
  39. {codecortex-0.23.1 → codecortex-0.23.2}/src/codeintel/commands/map.py +0 -0
  40. {codecortex-0.23.1 → codecortex-0.23.2}/src/codeintel/commands/prompt.py +0 -0
  41. {codecortex-0.23.1 → codecortex-0.23.2}/src/codeintel/commands/query.py +0 -0
  42. {codecortex-0.23.1 → codecortex-0.23.2}/src/codeintel/commands/reset.py +0 -0
  43. {codecortex-0.23.1 → codecortex-0.23.2}/src/codeintel/commands/serve.py +0 -0
  44. {codecortex-0.23.1 → codecortex-0.23.2}/src/codeintel/commands/serve_http.py +0 -0
  45. {codecortex-0.23.1 → codecortex-0.23.2}/src/codeintel/commands/setup.py +0 -0
  46. {codecortex-0.23.1 → codecortex-0.23.2}/src/codeintel/commands/status.py +0 -0
  47. {codecortex-0.23.1 → codecortex-0.23.2}/src/codeintel/config.py +0 -0
  48. {codecortex-0.23.1 → codecortex-0.23.2}/src/codeintel/containment.py +0 -0
  49. {codecortex-0.23.1 → codecortex-0.23.2}/src/codeintel/doctor.py +0 -0
  50. {codecortex-0.23.1 → codecortex-0.23.2}/src/codeintel/graph_backend.py +0 -0
  51. {codecortex-0.23.1 → codecortex-0.23.2}/src/codeintel/graph_render.py +0 -0
  52. {codecortex-0.23.1 → codecortex-0.23.2}/src/codeintel/graph_resolution.py +0 -0
  53. {codecortex-0.23.1 → codecortex-0.23.2}/src/codeintel/grapher.py +0 -0
  54. {codecortex-0.23.1 → codecortex-0.23.2}/src/codeintel/http_server.py +0 -0
  55. {codecortex-0.23.1 → codecortex-0.23.2}/src/codeintel/injector.py +0 -0
  56. {codecortex-0.23.1 → codecortex-0.23.2}/src/codeintel/installer.py +0 -0
  57. {codecortex-0.23.1 → codecortex-0.23.2}/src/codeintel/lang_config.py +0 -0
  58. {codecortex-0.23.1 → codecortex-0.23.2}/src/codeintel/loc.py +0 -0
  59. {codecortex-0.23.1 → codecortex-0.23.2}/src/codeintel/logconfig.py +0 -0
  60. {codecortex-0.23.1 → codecortex-0.23.2}/src/codeintel/mapper.py +0 -0
  61. {codecortex-0.23.1 → codecortex-0.23.2}/src/codeintel/metrics.py +0 -0
  62. {codecortex-0.23.1 → codecortex-0.23.2}/src/codeintel/onboarding.py +0 -0
  63. {codecortex-0.23.1 → codecortex-0.23.2}/src/codeintel/outcome.py +0 -0
  64. {codecortex-0.23.1 → codecortex-0.23.2}/src/codeintel/paths.py +0 -0
  65. {codecortex-0.23.1 → codecortex-0.23.2}/src/codeintel/policy.py +0 -0
  66. {codecortex-0.23.1 → codecortex-0.23.2}/src/codeintel/progress.py +0 -0
  67. {codecortex-0.23.1 → codecortex-0.23.2}/src/codeintel/providers/__init__.py +0 -0
  68. {codecortex-0.23.1 → codecortex-0.23.2}/src/codeintel/providers/graph.py +0 -0
  69. {codecortex-0.23.1 → codecortex-0.23.2}/src/codeintel/providers/none.py +0 -0
  70. {codecortex-0.23.1 → codecortex-0.23.2}/src/codeintel/providers/semantic.py +0 -0
  71. {codecortex-0.23.1 → codecortex-0.23.2}/src/codeintel/py.typed +0 -0
  72. {codecortex-0.23.1 → codecortex-0.23.2}/src/codeintel/query_ops.py +0 -0
  73. {codecortex-0.23.1 → codecortex-0.23.2}/src/codeintel/redact.py +0 -0
  74. {codecortex-0.23.1 → codecortex-0.23.2}/src/codeintel/reindexer.py +0 -0
  75. {codecortex-0.23.1 → codecortex-0.23.2}/src/codeintel/reset.py +0 -0
  76. {codecortex-0.23.1 → codecortex-0.23.2}/src/codeintel/searcher.py +0 -0
  77. {codecortex-0.23.1 → codecortex-0.23.2}/src/codeintel/semantic_db.py +0 -0
  78. {codecortex-0.23.1 → codecortex-0.23.2}/src/codeintel/server.py +0 -0
  79. {codecortex-0.23.1 → codecortex-0.23.2}/src/codeintel/source_kind.py +0 -0
  80. {codecortex-0.23.1 → codecortex-0.23.2}/src/codeintel/term.py +0 -0
  81. {codecortex-0.23.1 → codecortex-0.23.2}/src/codeintel/verify.py +0 -0
  82. {codecortex-0.23.1 → codecortex-0.23.2}/src/codeintel/viewer/__init__.py +0 -0
  83. {codecortex-0.23.1 → codecortex-0.23.2}/src/codeintel/viewer/graph_template.html +0 -0
  84. {codecortex-0.23.1 → codecortex-0.23.2}/src/codeintel/wire_text.py +0 -0
  85. {codecortex-0.23.1 → codecortex-0.23.2}/tests/test_agent_prompt.py +0 -0
  86. {codecortex-0.23.1 → codecortex-0.23.2}/tests/test_backend_reaper.py +0 -0
  87. {codecortex-0.23.1 → codecortex-0.23.2}/tests/test_background_index_failure.py +0 -0
  88. {codecortex-0.23.1 → codecortex-0.23.2}/tests/test_bench_oracle.py +0 -0
  89. {codecortex-0.23.1 → codecortex-0.23.2}/tests/test_bench_oracle_ts.py +0 -0
  90. {codecortex-0.23.1 → codecortex-0.23.2}/tests/test_c4.py +0 -0
  91. {codecortex-0.23.1 → codecortex-0.23.2}/tests/test_c4_check.py +0 -0
  92. {codecortex-0.23.1 → codecortex-0.23.2}/tests/test_c4_layers.py +0 -0
  93. {codecortex-0.23.1 → codecortex-0.23.2}/tests/test_c4_model.py +0 -0
  94. {codecortex-0.23.1 → codecortex-0.23.2}/tests/test_c4_output.py +0 -0
  95. {codecortex-0.23.1 → codecortex-0.23.2}/tests/test_cache.py +0 -0
  96. {codecortex-0.23.1 → codecortex-0.23.2}/tests/test_chunking.py +0 -0
  97. {codecortex-0.23.1 → codecortex-0.23.2}/tests/test_cli_commands.py +0 -0
  98. {codecortex-0.23.1 → codecortex-0.23.2}/tests/test_cli_help.py +0 -0
  99. {codecortex-0.23.1 → codecortex-0.23.2}/tests/test_cli_install_dry_run.py +0 -0
  100. {codecortex-0.23.1 → codecortex-0.23.2}/tests/test_cli_install_offer_injection.py +0 -0
  101. {codecortex-0.23.1 → codecortex-0.23.2}/tests/test_cold_process.py +0 -0
  102. {codecortex-0.23.1 → codecortex-0.23.2}/tests/test_config.py +0 -0
  103. {codecortex-0.23.1 → codecortex-0.23.2}/tests/test_corpus.py +0 -0
  104. {codecortex-0.23.1 → codecortex-0.23.2}/tests/test_could_not_ask_semantic.py +0 -0
  105. {codecortex-0.23.1 → codecortex-0.23.2}/tests/test_docs_ci_claims.py +0 -0
  106. {codecortex-0.23.1 → codecortex-0.23.2}/tests/test_docs_deadcode_withdrawal.py +0 -0
  107. {codecortex-0.23.1 → codecortex-0.23.2}/tests/test_e2e.py +0 -0
  108. {codecortex-0.23.1 → codecortex-0.23.2}/tests/test_edge_confidence.py +0 -0
  109. {codecortex-0.23.1 → codecortex-0.23.2}/tests/test_engine_adoption.py +0 -0
  110. {codecortex-0.23.1 → codecortex-0.23.2}/tests/test_enterprise.py +0 -0
  111. {codecortex-0.23.1 → codecortex-0.23.2}/tests/test_graph_failure_population.py +0 -0
  112. {codecortex-0.23.1 → codecortex-0.23.2}/tests/test_graph_provider.py +0 -0
  113. {codecortex-0.23.1 → codecortex-0.23.2}/tests/test_graph_real.py +0 -0
  114. {codecortex-0.23.1 → codecortex-0.23.2}/tests/test_graph_stdin.py +0 -0
  115. {codecortex-0.23.1 → codecortex-0.23.2}/tests/test_grapher.py +0 -0
  116. {codecortex-0.23.1 → codecortex-0.23.2}/tests/test_hard_exit.py +0 -0
  117. {codecortex-0.23.1 → codecortex-0.23.2}/tests/test_hardening.py +0 -0
  118. {codecortex-0.23.1 → codecortex-0.23.2}/tests/test_http_auth.py +0 -0
  119. {codecortex-0.23.1 → codecortex-0.23.2}/tests/test_http_server.py +0 -0
  120. {codecortex-0.23.1 → codecortex-0.23.2}/tests/test_index_progress.py +0 -0
  121. {codecortex-0.23.1 → codecortex-0.23.2}/tests/test_indexer_binary.py +0 -0
  122. {codecortex-0.23.1 → codecortex-0.23.2}/tests/test_installer.py +0 -0
  123. {codecortex-0.23.1 → codecortex-0.23.2}/tests/test_integration.py +0 -0
  124. {codecortex-0.23.1 → codecortex-0.23.2}/tests/test_lang_config.py +0 -0
  125. {codecortex-0.23.1 → codecortex-0.23.2}/tests/test_loc_census.py +0 -0
  126. {codecortex-0.23.1 → codecortex-0.23.2}/tests/test_lsp_language_coverage.py +0 -0
  127. {codecortex-0.23.1 → codecortex-0.23.2}/tests/test_lsp_real.py +0 -0
  128. {codecortex-0.23.1 → codecortex-0.23.2}/tests/test_mapper.py +0 -0
  129. {codecortex-0.23.1 → codecortex-0.23.2}/tests/test_mcp_handshake.py +0 -0
  130. {codecortex-0.23.1 → codecortex-0.23.2}/tests/test_mcp_server.py +0 -0
  131. {codecortex-0.23.1 → codecortex-0.23.2}/tests/test_model_dimension.py +0 -0
  132. {codecortex-0.23.1 → codecortex-0.23.2}/tests/test_never_raise.py +0 -0
  133. {codecortex-0.23.1 → codecortex-0.23.2}/tests/test_null_reason_and_ripple.py +0 -0
  134. {codecortex-0.23.1 → codecortex-0.23.2}/tests/test_onboarding.py +0 -0
  135. {codecortex-0.23.1 → codecortex-0.23.2}/tests/test_rbac.py +0 -0
  136. {codecortex-0.23.1 → codecortex-0.23.2}/tests/test_redaction_boundary.py +0 -0
  137. {codecortex-0.23.1 → codecortex-0.23.2}/tests/test_reindexer.py +0 -0
  138. {codecortex-0.23.1 → codecortex-0.23.2}/tests/test_rerank.py +0 -0
  139. {codecortex-0.23.1 → codecortex-0.23.2}/tests/test_reset.py +0 -0
  140. {codecortex-0.23.1 → codecortex-0.23.2}/tests/test_source_kind.py +0 -0
  141. {codecortex-0.23.1 → codecortex-0.23.2}/tests/test_staleness.py +0 -0
  142. {codecortex-0.23.1 → codecortex-0.23.2}/tests/test_symbol_context.py +0 -0
  143. {codecortex-0.23.1 → codecortex-0.23.2}/tests/test_term.py +0 -0
  144. {codecortex-0.23.1 → codecortex-0.23.2}/tests/test_treesitter.py +0 -0
  145. {codecortex-0.23.1 → codecortex-0.23.2}/tests/test_verify.py +0 -0
  146. {codecortex-0.23.1 → codecortex-0.23.2}/tests/test_verify_call.py +0 -0
  147. {codecortex-0.23.1 → codecortex-0.23.2}/tests/test_version_skew.py +0 -0
  148. {codecortex-0.23.1 → codecortex-0.23.2}/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.2
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.2
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.2"
@@ -292,17 +292,22 @@ class Gateway:
292
292
  # codebase throws away the could-not-ask / asked-and-found-nothing distinction it is
293
293
  # otherwise careful to preserve per-provider.
294
294
  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())
295
+ # Providers have already classified their reason into the public outcome taxonomy.
296
+ # Repeating a private reason allow-list here guaranteed drift: the first new reason,
297
+ # ``source-unreadable``, was correctly marked unavailable by LSP and then collapsed
298
+ # back to ``not_found`` by this merge. Failed and unavailable both mean no engine was
299
+ # able to answer; an actual miss is the explicit ``not_found`` outcome.
300
+ # Older/custom providers may not have adopted ``outcome`` yet. Route their reason
301
+ # through the SAME central classifier rather than reintroducing a local allow-list.
302
+ classified = [
303
+ r.get("outcome") or safe_null_result(
304
+ op_str, target_str, engine=eng, reason=reasons[eng]
305
+ ).get("outcome")
306
+ for eng, r in results.items()
307
+ ]
308
+ all_unreachable = bool(classified) and all(
309
+ outcome in ("unavailable", "failed") for outcome in classified
310
+ )
306
311
  summary = "engines-unavailable" if all_unreachable else "no-result"
307
312
  detail = ", ".join(f"{eng}: {why}" for eng, why in sorted(reasons.items()))
308
313
  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 ""),
@@ -127,6 +127,29 @@ def _serena_launch_args(cmd: str, project_root: str) -> list[str]:
127
127
  return [cmd, *common]
128
128
 
129
129
 
130
+ def _project_access_failure(project_root: str) -> tuple[str, str] | None:
131
+ """Return a safe diagnosis when the host cannot enumerate the project root.
132
+
133
+ Serena's stdio transport wraps an early subprocess exit in an ``ExceptionGroup``. When the
134
+ real cause is a macOS Files & Folders denial, reporting that wrapper as a network/bootstrap
135
+ failure sends the user in exactly the wrong direction. A one-entry scan is cheap, local, and
136
+ establishes the prerequisite Serena itself needs without forwarding any backend-controlled
137
+ prose to the calling agent.
138
+ """
139
+ try:
140
+ with os.scandir(project_root) as entries:
141
+ next(entries, None)
142
+ except OSError as exc:
143
+ location = str(getattr(exc, "filename", None) or project_root)
144
+ detail = f"repository root is not readable by codeintel ({type(exc).__name__}: {location})"
145
+ remediation = (
146
+ "grant the codeintel host read access to the repository, then retry; on macOS check "
147
+ "System Settings > Privacy & Security > Files and Folders (or Full Disk Access)"
148
+ )
149
+ return detail, remediation
150
+ return None
151
+
152
+
130
153
  class _State(enum.Enum):
131
154
  WARMING = "WARMING"
132
155
  READY = "READY"
@@ -433,6 +456,12 @@ class LspProvider:
433
456
  "detail": "serena session is warming for this repo", "remediation": None}
434
457
 
435
458
  # deep: boot (or reuse) a session and poll to a hard deadline — never hangs.
459
+ access_failure = _project_access_failure(project_root)
460
+ if access_failure is not None:
461
+ detail, remediation = access_failure
462
+ return {"installed": True, "runnable": False, "repo_indexed": None,
463
+ "detail": detail, "remediation": remediation}
464
+
436
465
  session = self._get_or_create_session(project_root)
437
466
  deadline = time.monotonic() + timeout_s
438
467
  while time.monotonic() < deadline:
@@ -502,6 +531,21 @@ class LspProvider:
502
531
  if not self.available:
503
532
  return safe_null_result(op_str, target_str, engine="lsp", reason="engine-unavailable")
504
533
 
534
+ # A protected macOS folder can still satisfy ``isdir`` while denying enumeration.
535
+ # Detect that locally before spawning Serena: otherwise its early exit is wrapped by
536
+ # the MCP transport as a generic boot failure and the first-query hint talks about a
537
+ # cold uvx cache instead of the permission the user actually needs to grant. Keep the
538
+ # ``isdir`` guard so synthetic/nonexistent roots used by embedders retain the existing
539
+ # never-raise backend behaviour.
540
+ if os.path.isdir(root_str):
541
+ access_failure = _project_access_failure(root_str)
542
+ if access_failure is not None:
543
+ detail, remediation = access_failure
544
+ return safe_null_result(
545
+ op_str, target_str, engine="lsp", reason="source-unreadable",
546
+ hint=f"{detail}; {remediation}",
547
+ )
548
+
505
549
  try:
506
550
  budget_ms = int(budget) if budget else 0
507
551
  except Exception:
@@ -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
@@ -462,6 +464,32 @@ def test_boot_failed_on_a_cold_uvx_first_attempt_reads_as_retry(monkeypatch):
462
464
  assert "download" in hint
463
465
 
464
466
 
467
+ def test_an_unreadable_repository_never_launches_serena(monkeypatch):
468
+ monkeypatch.setattr("codeintel.providers.lsp.shutil.which",
469
+ lambda x: "/fake/uvx" if x == "uvx" else None)
470
+ monkeypatch.setattr("codeintel.providers.lsp.os.path.isdir", lambda root: True)
471
+ monkeypatch.setattr(
472
+ "codeintel.providers.lsp._project_access_failure",
473
+ lambda root: (
474
+ f"repository root is not readable by codeintel (PermissionError: {root})",
475
+ "grant repository access",
476
+ ),
477
+ )
478
+ p = LspProvider()
479
+ monkeypatch.setattr(
480
+ p, "_get_or_create_session",
481
+ lambda root: pytest.fail("an unreadable repository must not launch serena"),
482
+ )
483
+
484
+ r = p.build_result("symbol", "createSession", [], 1000, "/protected/repo")
485
+
486
+ assert r["result"] is None
487
+ assert r["reason"] == "source-unreadable"
488
+ assert r["outcome"] == "unavailable"
489
+ assert "PermissionError" in r["hint"]
490
+ assert "grant repository access" in r["hint"]
491
+
492
+
465
493
  def test_boot_failed_on_a_respawn_does_not_blame_a_cold_cache(monkeypatch):
466
494
  """`uvx` populated its cache on the first attempt, so "still downloading" is only true once."""
467
495
  monkeypatch.setattr("codeintel.providers.lsp.shutil.which",
@@ -478,4 +478,5 @@ def test_a_fanout_where_the_repo_is_merely_unindexed_keeps_its_ordinary_summary(
478
478
  r = gw.query(op="context", target="make_widget", engine="all", project_root="/x")
479
479
 
480
480
  assert r["reason"] == "no-result"
481
+ assert r["outcome"] == "not_found"
481
482
  assert "NOT evidence" not in (r.get("hint") or "")
@@ -1 +0,0 @@
1
- __version__ = "0.23.1"
File without changes
File without changes
File without changes
File without changes