codecortex 0.23.3__tar.gz → 0.23.4__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {codecortex-0.23.3/src/codecortex.egg-info → codecortex-0.23.4}/PKG-INFO +1 -1
- {codecortex-0.23.3 → codecortex-0.23.4/src/codecortex.egg-info}/PKG-INFO +1 -1
- codecortex-0.23.4/src/codeintel/__init__.py +1 -0
- {codecortex-0.23.3 → codecortex-0.23.4}/src/codeintel/gateway.py +56 -13
- {codecortex-0.23.3 → codecortex-0.23.4}/src/codeintel/provider.py +1 -0
- {codecortex-0.23.3 → codecortex-0.23.4}/src/codeintel/providers/lsp.py +176 -19
- {codecortex-0.23.3 → codecortex-0.23.4}/tests/test_gateway.py +29 -0
- {codecortex-0.23.3 → codecortex-0.23.4}/tests/test_lsp_provider.py +342 -2
- {codecortex-0.23.3 → codecortex-0.23.4}/tests/test_null_reason_and_ripple.py +175 -0
- codecortex-0.23.3/src/codeintel/__init__.py +0 -1
- {codecortex-0.23.3 → codecortex-0.23.4}/LICENSE +0 -0
- {codecortex-0.23.3 → codecortex-0.23.4}/README.md +0 -0
- {codecortex-0.23.3 → codecortex-0.23.4}/pyproject.toml +0 -0
- {codecortex-0.23.3 → codecortex-0.23.4}/setup.cfg +0 -0
- {codecortex-0.23.3 → codecortex-0.23.4}/src/codecortex.egg-info/SOURCES.txt +0 -0
- {codecortex-0.23.3 → codecortex-0.23.4}/src/codecortex.egg-info/dependency_links.txt +0 -0
- {codecortex-0.23.3 → codecortex-0.23.4}/src/codecortex.egg-info/entry_points.txt +0 -0
- {codecortex-0.23.3 → codecortex-0.23.4}/src/codecortex.egg-info/requires.txt +0 -0
- {codecortex-0.23.3 → codecortex-0.23.4}/src/codecortex.egg-info/top_level.txt +0 -0
- {codecortex-0.23.3 → codecortex-0.23.4}/src/codeintel/__main__.py +0 -0
- {codecortex-0.23.3 → codecortex-0.23.4}/src/codeintel/agent_prompt.py +0 -0
- {codecortex-0.23.3 → codecortex-0.23.4}/src/codeintel/auth.py +0 -0
- {codecortex-0.23.3 → codecortex-0.23.4}/src/codeintel/c4.py +0 -0
- {codecortex-0.23.3 → codecortex-0.23.4}/src/codeintel/c4_check.py +0 -0
- {codecortex-0.23.3 → codecortex-0.23.4}/src/codeintel/c4_layers.py +0 -0
- {codecortex-0.23.3 → codecortex-0.23.4}/src/codeintel/cache.py +0 -0
- {codecortex-0.23.3 → codecortex-0.23.4}/src/codeintel/commands/__init__.py +0 -0
- {codecortex-0.23.3 → codecortex-0.23.4}/src/codeintel/commands/_common.py +0 -0
- {codecortex-0.23.3 → codecortex-0.23.4}/src/codeintel/commands/c4.py +0 -0
- {codecortex-0.23.3 → codecortex-0.23.4}/src/codeintel/commands/doctor.py +0 -0
- {codecortex-0.23.3 → codecortex-0.23.4}/src/codeintel/commands/gen_token.py +0 -0
- {codecortex-0.23.3 → codecortex-0.23.4}/src/codeintel/commands/graph.py +0 -0
- {codecortex-0.23.3 → codecortex-0.23.4}/src/codeintel/commands/index.py +0 -0
- {codecortex-0.23.3 → codecortex-0.23.4}/src/codeintel/commands/install.py +0 -0
- {codecortex-0.23.3 → codecortex-0.23.4}/src/codeintel/commands/map.py +0 -0
- {codecortex-0.23.3 → codecortex-0.23.4}/src/codeintel/commands/prompt.py +0 -0
- {codecortex-0.23.3 → codecortex-0.23.4}/src/codeintel/commands/query.py +0 -0
- {codecortex-0.23.3 → codecortex-0.23.4}/src/codeintel/commands/reset.py +0 -0
- {codecortex-0.23.3 → codecortex-0.23.4}/src/codeintel/commands/serve.py +0 -0
- {codecortex-0.23.3 → codecortex-0.23.4}/src/codeintel/commands/serve_http.py +0 -0
- {codecortex-0.23.3 → codecortex-0.23.4}/src/codeintel/commands/setup.py +0 -0
- {codecortex-0.23.3 → codecortex-0.23.4}/src/codeintel/commands/status.py +0 -0
- {codecortex-0.23.3 → codecortex-0.23.4}/src/codeintel/config.py +0 -0
- {codecortex-0.23.3 → codecortex-0.23.4}/src/codeintel/containment.py +0 -0
- {codecortex-0.23.3 → codecortex-0.23.4}/src/codeintel/doctor.py +0 -0
- {codecortex-0.23.3 → codecortex-0.23.4}/src/codeintel/graph_backend.py +0 -0
- {codecortex-0.23.3 → codecortex-0.23.4}/src/codeintel/graph_render.py +0 -0
- {codecortex-0.23.3 → codecortex-0.23.4}/src/codeintel/graph_resolution.py +0 -0
- {codecortex-0.23.3 → codecortex-0.23.4}/src/codeintel/grapher.py +0 -0
- {codecortex-0.23.3 → codecortex-0.23.4}/src/codeintel/http_server.py +0 -0
- {codecortex-0.23.3 → codecortex-0.23.4}/src/codeintel/indexer.py +0 -0
- {codecortex-0.23.3 → codecortex-0.23.4}/src/codeintel/injector.py +0 -0
- {codecortex-0.23.3 → codecortex-0.23.4}/src/codeintel/installer.py +0 -0
- {codecortex-0.23.3 → codecortex-0.23.4}/src/codeintel/lang_config.py +0 -0
- {codecortex-0.23.3 → codecortex-0.23.4}/src/codeintel/loc.py +0 -0
- {codecortex-0.23.3 → codecortex-0.23.4}/src/codeintel/logconfig.py +0 -0
- {codecortex-0.23.3 → codecortex-0.23.4}/src/codeintel/mapper.py +0 -0
- {codecortex-0.23.3 → codecortex-0.23.4}/src/codeintel/metrics.py +0 -0
- {codecortex-0.23.3 → codecortex-0.23.4}/src/codeintel/onboarding.py +0 -0
- {codecortex-0.23.3 → codecortex-0.23.4}/src/codeintel/outcome.py +0 -0
- {codecortex-0.23.3 → codecortex-0.23.4}/src/codeintel/paths.py +0 -0
- {codecortex-0.23.3 → codecortex-0.23.4}/src/codeintel/policy.py +0 -0
- {codecortex-0.23.3 → codecortex-0.23.4}/src/codeintel/progress.py +0 -0
- {codecortex-0.23.3 → codecortex-0.23.4}/src/codeintel/providers/__init__.py +0 -0
- {codecortex-0.23.3 → codecortex-0.23.4}/src/codeintel/providers/graph.py +0 -0
- {codecortex-0.23.3 → codecortex-0.23.4}/src/codeintel/providers/none.py +0 -0
- {codecortex-0.23.3 → codecortex-0.23.4}/src/codeintel/providers/semantic.py +0 -0
- {codecortex-0.23.3 → codecortex-0.23.4}/src/codeintel/py.typed +0 -0
- {codecortex-0.23.3 → codecortex-0.23.4}/src/codeintel/query_ops.py +0 -0
- {codecortex-0.23.3 → codecortex-0.23.4}/src/codeintel/redact.py +0 -0
- {codecortex-0.23.3 → codecortex-0.23.4}/src/codeintel/reindexer.py +0 -0
- {codecortex-0.23.3 → codecortex-0.23.4}/src/codeintel/reset.py +0 -0
- {codecortex-0.23.3 → codecortex-0.23.4}/src/codeintel/searcher.py +0 -0
- {codecortex-0.23.3 → codecortex-0.23.4}/src/codeintel/semantic_db.py +0 -0
- {codecortex-0.23.3 → codecortex-0.23.4}/src/codeintel/server.py +0 -0
- {codecortex-0.23.3 → codecortex-0.23.4}/src/codeintel/source_kind.py +0 -0
- {codecortex-0.23.3 → codecortex-0.23.4}/src/codeintel/term.py +0 -0
- {codecortex-0.23.3 → codecortex-0.23.4}/src/codeintel/verify.py +0 -0
- {codecortex-0.23.3 → codecortex-0.23.4}/src/codeintel/viewer/__init__.py +0 -0
- {codecortex-0.23.3 → codecortex-0.23.4}/src/codeintel/viewer/graph_template.html +0 -0
- {codecortex-0.23.3 → codecortex-0.23.4}/src/codeintel/wire_text.py +0 -0
- {codecortex-0.23.3 → codecortex-0.23.4}/tests/test_agent_prompt.py +0 -0
- {codecortex-0.23.3 → codecortex-0.23.4}/tests/test_backend_reaper.py +0 -0
- {codecortex-0.23.3 → codecortex-0.23.4}/tests/test_background_index_failure.py +0 -0
- {codecortex-0.23.3 → codecortex-0.23.4}/tests/test_bench_oracle.py +0 -0
- {codecortex-0.23.3 → codecortex-0.23.4}/tests/test_bench_oracle_ts.py +0 -0
- {codecortex-0.23.3 → codecortex-0.23.4}/tests/test_c4.py +0 -0
- {codecortex-0.23.3 → codecortex-0.23.4}/tests/test_c4_check.py +0 -0
- {codecortex-0.23.3 → codecortex-0.23.4}/tests/test_c4_layers.py +0 -0
- {codecortex-0.23.3 → codecortex-0.23.4}/tests/test_c4_model.py +0 -0
- {codecortex-0.23.3 → codecortex-0.23.4}/tests/test_c4_output.py +0 -0
- {codecortex-0.23.3 → codecortex-0.23.4}/tests/test_cache.py +0 -0
- {codecortex-0.23.3 → codecortex-0.23.4}/tests/test_chunking.py +0 -0
- {codecortex-0.23.3 → codecortex-0.23.4}/tests/test_cli_commands.py +0 -0
- {codecortex-0.23.3 → codecortex-0.23.4}/tests/test_cli_help.py +0 -0
- {codecortex-0.23.3 → codecortex-0.23.4}/tests/test_cli_install_dry_run.py +0 -0
- {codecortex-0.23.3 → codecortex-0.23.4}/tests/test_cli_install_offer_injection.py +0 -0
- {codecortex-0.23.3 → codecortex-0.23.4}/tests/test_cold_process.py +0 -0
- {codecortex-0.23.3 → codecortex-0.23.4}/tests/test_config.py +0 -0
- {codecortex-0.23.3 → codecortex-0.23.4}/tests/test_corpus.py +0 -0
- {codecortex-0.23.3 → codecortex-0.23.4}/tests/test_could_not_ask_semantic.py +0 -0
- {codecortex-0.23.3 → codecortex-0.23.4}/tests/test_docs_ci_claims.py +0 -0
- {codecortex-0.23.3 → codecortex-0.23.4}/tests/test_docs_deadcode_withdrawal.py +0 -0
- {codecortex-0.23.3 → codecortex-0.23.4}/tests/test_doctor.py +0 -0
- {codecortex-0.23.3 → codecortex-0.23.4}/tests/test_e2e.py +0 -0
- {codecortex-0.23.3 → codecortex-0.23.4}/tests/test_edge_confidence.py +0 -0
- {codecortex-0.23.3 → codecortex-0.23.4}/tests/test_engine_adoption.py +0 -0
- {codecortex-0.23.3 → codecortex-0.23.4}/tests/test_enterprise.py +0 -0
- {codecortex-0.23.3 → codecortex-0.23.4}/tests/test_graph_failure_population.py +0 -0
- {codecortex-0.23.3 → codecortex-0.23.4}/tests/test_graph_provider.py +0 -0
- {codecortex-0.23.3 → codecortex-0.23.4}/tests/test_graph_real.py +0 -0
- {codecortex-0.23.3 → codecortex-0.23.4}/tests/test_graph_stdin.py +0 -0
- {codecortex-0.23.3 → codecortex-0.23.4}/tests/test_grapher.py +0 -0
- {codecortex-0.23.3 → codecortex-0.23.4}/tests/test_hard_exit.py +0 -0
- {codecortex-0.23.3 → codecortex-0.23.4}/tests/test_hardening.py +0 -0
- {codecortex-0.23.3 → codecortex-0.23.4}/tests/test_http_auth.py +0 -0
- {codecortex-0.23.3 → codecortex-0.23.4}/tests/test_http_server.py +0 -0
- {codecortex-0.23.3 → codecortex-0.23.4}/tests/test_incompleteness.py +0 -0
- {codecortex-0.23.3 → codecortex-0.23.4}/tests/test_index_failure_reason.py +0 -0
- {codecortex-0.23.3 → codecortex-0.23.4}/tests/test_index_progress.py +0 -0
- {codecortex-0.23.3 → codecortex-0.23.4}/tests/test_indexer_binary.py +0 -0
- {codecortex-0.23.3 → codecortex-0.23.4}/tests/test_installer.py +0 -0
- {codecortex-0.23.3 → codecortex-0.23.4}/tests/test_integration.py +0 -0
- {codecortex-0.23.3 → codecortex-0.23.4}/tests/test_lang_config.py +0 -0
- {codecortex-0.23.3 → codecortex-0.23.4}/tests/test_loc_census.py +0 -0
- {codecortex-0.23.3 → codecortex-0.23.4}/tests/test_lsp_language_coverage.py +0 -0
- {codecortex-0.23.3 → codecortex-0.23.4}/tests/test_lsp_real.py +0 -0
- {codecortex-0.23.3 → codecortex-0.23.4}/tests/test_mapper.py +0 -0
- {codecortex-0.23.3 → codecortex-0.23.4}/tests/test_mcp_handshake.py +0 -0
- {codecortex-0.23.3 → codecortex-0.23.4}/tests/test_mcp_server.py +0 -0
- {codecortex-0.23.3 → codecortex-0.23.4}/tests/test_model_dimension.py +0 -0
- {codecortex-0.23.3 → codecortex-0.23.4}/tests/test_never_raise.py +0 -0
- {codecortex-0.23.3 → codecortex-0.23.4}/tests/test_onboarding.py +0 -0
- {codecortex-0.23.3 → codecortex-0.23.4}/tests/test_rbac.py +0 -0
- {codecortex-0.23.3 → codecortex-0.23.4}/tests/test_redaction_boundary.py +0 -0
- {codecortex-0.23.3 → codecortex-0.23.4}/tests/test_reindexer.py +0 -0
- {codecortex-0.23.3 → codecortex-0.23.4}/tests/test_rerank.py +0 -0
- {codecortex-0.23.3 → codecortex-0.23.4}/tests/test_reset.py +0 -0
- {codecortex-0.23.3 → codecortex-0.23.4}/tests/test_semantic_provider.py +0 -0
- {codecortex-0.23.3 → codecortex-0.23.4}/tests/test_source_kind.py +0 -0
- {codecortex-0.23.3 → codecortex-0.23.4}/tests/test_staleness.py +0 -0
- {codecortex-0.23.3 → codecortex-0.23.4}/tests/test_symbol_context.py +0 -0
- {codecortex-0.23.3 → codecortex-0.23.4}/tests/test_term.py +0 -0
- {codecortex-0.23.3 → codecortex-0.23.4}/tests/test_treesitter.py +0 -0
- {codecortex-0.23.3 → codecortex-0.23.4}/tests/test_verify.py +0 -0
- {codecortex-0.23.3 → codecortex-0.23.4}/tests/test_verify_call.py +0 -0
- {codecortex-0.23.3 → codecortex-0.23.4}/tests/test_version_skew.py +0 -0
- {codecortex-0.23.3 → codecortex-0.23.4}/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.
|
|
3
|
+
Version: 0.23.4
|
|
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.
|
|
3
|
+
Version: 0.23.4
|
|
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.4"
|
|
@@ -50,6 +50,13 @@ def _mark_reindexing(result: Result, reindexing: bool) -> Result:
|
|
|
50
50
|
"completed pass; re-ask shortly if you have just changed this code"}
|
|
51
51
|
|
|
52
52
|
|
|
53
|
+
def _is_retryable(result: Result) -> bool:
|
|
54
|
+
return bool(result.get("retry_after_s")) or any(
|
|
55
|
+
isinstance(gap, dict) and bool(gap.get("retry_after_s"))
|
|
56
|
+
for gap in (result.get("gaps") or [])
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
53
60
|
class Gateway:
|
|
54
61
|
def __init__(self, graph=None, lsp=None, semantic=None, policy: TieringPolicy | None = None,
|
|
55
62
|
reindexer: Reindexer | None = None, oneshot: bool = False):
|
|
@@ -198,17 +205,44 @@ class Gateway:
|
|
|
198
205
|
if result.get("result") is None or self.lsp is None:
|
|
199
206
|
return result
|
|
200
207
|
gaps = result.get("gaps") or []
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
208
|
+
all_name_resolved = any(
|
|
209
|
+
isinstance(gap, dict)
|
|
210
|
+
and gap.get("kind") == "all-rows-name-resolved"
|
|
211
|
+
and gap.get("section") == "callers"
|
|
212
|
+
for gap in gaps
|
|
213
|
+
)
|
|
214
|
+
exact_target_unbound = any(
|
|
215
|
+
isinstance(gap, dict)
|
|
216
|
+
and gap.get("kind") == "target-hint-unmatched"
|
|
217
|
+
and gap.get("section") == "callers"
|
|
218
|
+
for gap in gaps
|
|
219
|
+
)
|
|
220
|
+
# A file-qualified target is supposed to identify one definition. If its
|
|
221
|
+
# answer still contains low-confidence name matches, the backend has selected the
|
|
222
|
+
# destination node but has not proven the incoming property-call bindings. This is the
|
|
223
|
+
# mixed variant of ``all-rows-name-resolved``: a handful of structural rows can mask a
|
|
224
|
+
# much larger suffix-match population, so the old all-or-nothing signature missed it.
|
|
225
|
+
# Only auto mode reaches this method, and only an already-partial answer carrying an
|
|
226
|
+
# exact file hint pays for the LSP check; healthy graph answers still make no extra
|
|
227
|
+
# engine call. A dotted target without a file is intentionally not escalated because
|
|
228
|
+
# module identity is represented differently across Serena language servers.
|
|
229
|
+
caller_edges_unverified = any(
|
|
230
|
+
isinstance(gap, dict)
|
|
231
|
+
and gap.get("kind") == "low-confidence-edges"
|
|
232
|
+
and gap.get("section") == "callers"
|
|
233
|
+
for gap in gaps
|
|
234
|
+
)
|
|
235
|
+
_, has_at, hinted_path = target.rpartition("@")
|
|
236
|
+
exact_file_hint = bool(has_at and "/" in hinted_path.replace("\\", "/"))
|
|
237
|
+
narrowed_target_unverified = caller_edges_unverified and exact_file_hint
|
|
238
|
+
if not (all_name_resolved or exact_target_unbound or narrowed_target_unverified):
|
|
207
239
|
return result
|
|
208
240
|
graph_problem = (
|
|
209
241
|
"every graph row was resolved by name"
|
|
210
242
|
if all_name_resolved
|
|
211
243
|
else "the graph found no caller edge for the file-qualified symbol"
|
|
244
|
+
if exact_target_unbound
|
|
245
|
+
else "the qualified graph answer contains unverified name-resolved caller edges"
|
|
212
246
|
)
|
|
213
247
|
probe = self._dispatch_single(
|
|
214
248
|
self.lsp, "symbol", target, budget, project_root, "lsp")
|
|
@@ -229,7 +263,8 @@ class Gateway:
|
|
|
229
263
|
str(reference_gap.get("kind") or "not-asked")
|
|
230
264
|
if reference_gap is not None else str(probe.get("reason") or "no-result")
|
|
231
265
|
)
|
|
232
|
-
|
|
266
|
+
retry_after_s = probe.get("retry_after_s")
|
|
267
|
+
retryable = unavailable_reason in {"warming", "timeout"} or bool(retry_after_s)
|
|
233
268
|
why = (
|
|
234
269
|
"the language server had not finished booting"
|
|
235
270
|
if unavailable_reason == "warming"
|
|
@@ -238,7 +273,7 @@ class Gateway:
|
|
|
238
273
|
else "the LSP engine reported nothing for this symbol"
|
|
239
274
|
)
|
|
240
275
|
nxt = (" Ask again once it is warm and this section will be filled in."
|
|
241
|
-
if
|
|
276
|
+
if retryable else
|
|
242
277
|
" Check it yourself with `--engine lsp --op symbol`.")
|
|
243
278
|
return {**result, "gaps": [*gaps, {
|
|
244
279
|
"section": op, "kind": "cross-check-unavailable",
|
|
@@ -246,8 +281,8 @@ class Gateway:
|
|
|
246
281
|
"reason": unavailable_reason,
|
|
247
282
|
"detail": f"{graph_problem} and the LSP could not provide an independent "
|
|
248
283
|
f"reference check ({why}), so the graph answer remains unverified"
|
|
249
|
-
+ (" — retry" if
|
|
250
|
-
**({"retry_after_s": 2} if
|
|
284
|
+
+ (" — retry" if retryable else ""),
|
|
285
|
+
**({"retry_after_s": retry_after_s or 2} if retryable else {}),
|
|
251
286
|
}], "result": str(result["result"]) + (
|
|
252
287
|
f"\n\n> Cross-check unavailable: {graph_problem}, and "
|
|
253
288
|
f"{why}, so nothing here has been confirmed against a second engine.{nxt}"
|
|
@@ -333,12 +368,18 @@ class Gateway:
|
|
|
333
368
|
)
|
|
334
369
|
summary = "engines-unavailable" if all_unreachable else "no-result"
|
|
335
370
|
detail = ", ".join(f"{eng}: {why}" for eng, why in sorted(reasons.items()))
|
|
336
|
-
|
|
371
|
+
merged_null = safe_null_result(
|
|
337
372
|
op_str, target_str, engine=engine_str, reason=summary,
|
|
338
373
|
hint=(f"no engine produced an answer — {detail}"
|
|
339
374
|
+ ("; this is NOT evidence the target does not exist"
|
|
340
375
|
if all_unreachable else "")),
|
|
341
376
|
)
|
|
377
|
+
retry_after = max(
|
|
378
|
+
(float(r.get("retry_after_s") or 0) for r in results.values()), default=0
|
|
379
|
+
)
|
|
380
|
+
if retry_after:
|
|
381
|
+
merged_null["retry_after_s"] = retry_after
|
|
382
|
+
return merged_null
|
|
342
383
|
|
|
343
384
|
# A fan-out answer is only as whole as its parts. This used to hand-build a six-key envelope
|
|
344
385
|
# and drop both `confidence` and `gaps` on the floor — so a `context` request (the DEFAULT
|
|
@@ -357,6 +398,8 @@ class Gateway:
|
|
|
357
398
|
"detail": f"the {eng} engine contributed nothing to this answer "
|
|
358
399
|
f"({r.get('reason') or 'no-result'})",
|
|
359
400
|
"engine": eng,
|
|
401
|
+
**({"retry_after_s": r["retry_after_s"]}
|
|
402
|
+
if r.get("retry_after_s") else {}),
|
|
360
403
|
})
|
|
361
404
|
return attach_confidence({
|
|
362
405
|
"ok": True,
|
|
@@ -538,7 +581,7 @@ class Gateway:
|
|
|
538
581
|
engines = ["graph", "lsp"] if engine_str == "both" else ["graph", "lsp", "semantic"]
|
|
539
582
|
fan_results = self._fan_out(engines, op_str, target_str, budget, project_root)
|
|
540
583
|
result = self._merge(fan_results, op_str, target_str, engine_str)
|
|
541
|
-
if not uncacheable:
|
|
584
|
+
if not uncacheable and not _is_retryable(result):
|
|
542
585
|
self._cache.put(op_str, target_str, cache_engine, root_str, result, freshness)
|
|
543
586
|
return _mark_reindexing(result, reindexing)
|
|
544
587
|
|
|
@@ -577,7 +620,7 @@ class Gateway:
|
|
|
577
620
|
result = self._cross_check_name_resolved(
|
|
578
621
|
result, op_str, target_str, budget, project_root, was_auto)
|
|
579
622
|
|
|
580
|
-
if not uncacheable:
|
|
623
|
+
if not uncacheable and not _is_retryable(result):
|
|
581
624
|
self._cache.put(op_str, target_str, cache_engine, root_str, result, freshness)
|
|
582
625
|
return _mark_reindexing(result, reindexing)
|
|
583
626
|
|
|
@@ -36,6 +36,7 @@ class Result(TypedDict):
|
|
|
36
36
|
outcome: NotRequired[str]
|
|
37
37
|
reason: NotRequired[str]
|
|
38
38
|
hint: NotRequired[str]
|
|
39
|
+
retry_after_s: NotRequired[float]
|
|
39
40
|
# Set when the answer was served while a reindex for its project was still running, i.e. it
|
|
40
41
|
# reflects the last COMPLETED index rather than the current source. Optional, and the MCP
|
|
41
42
|
# tools deliberately return plain `dict` so this never becomes a required schema field.
|
|
@@ -19,6 +19,7 @@ from mcp.client.stdio import stdio_client
|
|
|
19
19
|
from codeintel.loc import loc, span
|
|
20
20
|
from codeintel.outcome import Missing, Ok, Outcome
|
|
21
21
|
from codeintel.provider import Result, attach_confidence, log_swallowed, safe_null_result
|
|
22
|
+
from codeintel.providers.graph import _FILE_EXTENSIONS
|
|
22
23
|
|
|
23
24
|
logger = logging.getLogger(__name__)
|
|
24
25
|
|
|
@@ -67,7 +68,7 @@ _BACKEND_ERROR_MARKERS = (
|
|
|
67
68
|
)
|
|
68
69
|
|
|
69
70
|
|
|
70
|
-
def _split_target_file_hint(target: str) -> tuple[str, str]:
|
|
71
|
+
def _split_target_file_hint(target: str) -> tuple[str, str, str]:
|
|
71
72
|
"""Return Serena's symbol pattern and an optional repo-relative file hint.
|
|
72
73
|
|
|
73
74
|
Graph operations accept ``name@path`` to disambiguate duplicate symbols. The gateway may pass
|
|
@@ -77,11 +78,45 @@ def _split_target_file_hint(target: str) -> tuple[str, str]:
|
|
|
77
78
|
"""
|
|
78
79
|
raw = str(target or "").strip()
|
|
79
80
|
if "@" not in raw:
|
|
80
|
-
return raw, ""
|
|
81
|
+
return raw, "", ""
|
|
81
82
|
head, _, tail = raw.rpartition("@")
|
|
82
83
|
if not head.strip() or not tail.strip():
|
|
83
|
-
return raw, ""
|
|
84
|
-
|
|
84
|
+
return raw, "", ""
|
|
85
|
+
symbol, file_hint = head.strip(), tail.strip()
|
|
86
|
+
# Query broadly by leaf and filter client-side. Serena splits module identity differently by
|
|
87
|
+
# language, while the file hint and complete symbol-path suffix together are stable.
|
|
88
|
+
suffix = symbol.rpartition(".")[2].lower()
|
|
89
|
+
qualified_hint = (
|
|
90
|
+
symbol.replace(".", "/")
|
|
91
|
+
if "." in symbol and suffix not in _FILE_EXTENSIONS
|
|
92
|
+
else ""
|
|
93
|
+
)
|
|
94
|
+
leaf = symbol.rpartition(".")[2] if qualified_hint else symbol
|
|
95
|
+
return leaf, file_hint, qualified_hint
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _name_path_suffix_score(name_path: Any, qualified_hint: str) -> int:
|
|
99
|
+
"""Number of trailing graph/Serena symbol segments that agree."""
|
|
100
|
+
have = str(name_path or "").strip("/").split("/")
|
|
101
|
+
want = str(qualified_hint or "").strip("/").split("/")
|
|
102
|
+
score = 0
|
|
103
|
+
for left, right in zip(reversed(have), reversed(want), strict=False):
|
|
104
|
+
if left != right:
|
|
105
|
+
break
|
|
106
|
+
score += 1
|
|
107
|
+
return score
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _qualified_definition_score(match: dict, qualified_hint: str) -> int:
|
|
111
|
+
"""Score a Serena symbol path within an already exact-file-filtered candidate set."""
|
|
112
|
+
symbol_score = _name_path_suffix_score(match.get("name_path"), qualified_hint)
|
|
113
|
+
name_parts = str(match.get("name_path") or "").strip("/").split("/")
|
|
114
|
+
# The whole Serena name path must be a suffix of the graph qualifier. An extra enclosing symbol
|
|
115
|
+
# is never a module and would make selection ambiguous. Require container + leaf: accepting a
|
|
116
|
+
# top-level ``resolve`` would leave ``MissingClass`` entirely unverified.
|
|
117
|
+
wanted_parts = str(qualified_hint or "").strip("/").split("/")
|
|
118
|
+
return (symbol_score if symbol_score == len(name_parts) == len(wanted_parts)
|
|
119
|
+
and symbol_score >= 2 else 0)
|
|
85
120
|
|
|
86
121
|
|
|
87
122
|
def _file_hint_matches(file_path: Any, hint: str) -> bool:
|
|
@@ -287,26 +322,54 @@ class LspProvider:
|
|
|
287
322
|
|
|
288
323
|
# Class-level default so a provider built via `__new__` (the test stubs do this) still has it
|
|
289
324
|
# rather than raising AttributeError inside the never-raise handler.
|
|
290
|
-
|
|
325
|
+
_backend_error_state = threading.local()
|
|
326
|
+
_backend_missing_state = threading.local()
|
|
291
327
|
# Sections of the current answer that are known to be short of an answer. An op appends here
|
|
292
328
|
# instead of quietly rendering an empty section, and `build_result` turns them into the
|
|
293
329
|
# envelope's `gaps` / `confidence`. Class-level default for the same __new__ reason as above.
|
|
294
|
-
|
|
330
|
+
_gap_state = threading.local()
|
|
295
331
|
# Same __new__ reason: `_boot_failed_hint` reads this, and a session injected straight into
|
|
296
332
|
# `_sessions` reaches it without `_detect_backend` having run.
|
|
297
333
|
_cmd: str | None = None
|
|
298
334
|
|
|
335
|
+
@property
|
|
336
|
+
def _last_backend_error(self) -> str | None:
|
|
337
|
+
value = getattr(self._backend_error_state, "value", None)
|
|
338
|
+
return value if isinstance(value, str) else None
|
|
339
|
+
|
|
340
|
+
@_last_backend_error.setter
|
|
341
|
+
def _last_backend_error(self, value: str | None) -> None:
|
|
342
|
+
self._backend_error_state.value = value
|
|
343
|
+
|
|
344
|
+
@property
|
|
345
|
+
def _pending_gaps(self) -> tuple[dict[str, Any], ...]:
|
|
346
|
+
value = getattr(self._gap_state, "value", ())
|
|
347
|
+
return value if isinstance(value, tuple) else ()
|
|
348
|
+
|
|
349
|
+
@_pending_gaps.setter
|
|
350
|
+
def _pending_gaps(self, value: tuple[dict[str, Any], ...]) -> None:
|
|
351
|
+
self._gap_state.value = value
|
|
352
|
+
|
|
299
353
|
def __init__(self) -> None:
|
|
300
354
|
self._sessions: dict[str, _LspSession] = {}
|
|
301
355
|
self._sessions_lock = threading.Lock()
|
|
302
356
|
self._last_backend_error = None
|
|
357
|
+
self._set_backend_missing(None)
|
|
303
358
|
self._pending_gaps = ()
|
|
304
359
|
self._detect_backend()
|
|
305
360
|
|
|
306
361
|
def _clear_backend_error(self) -> None:
|
|
307
362
|
self._last_backend_error = None
|
|
363
|
+
self._set_backend_missing(None)
|
|
308
364
|
self._pending_gaps = ()
|
|
309
365
|
|
|
366
|
+
def _set_backend_missing(self, missing: Missing | None) -> None:
|
|
367
|
+
self._backend_missing_state.value = missing
|
|
368
|
+
|
|
369
|
+
def _get_backend_missing(self) -> Missing | None:
|
|
370
|
+
value = getattr(self._backend_missing_state, "value", None)
|
|
371
|
+
return value if isinstance(value, Missing) else None
|
|
372
|
+
|
|
310
373
|
def _add_gap(self, section: str, missing: Missing) -> None:
|
|
311
374
|
"""Record that a named part of the answer could not be retrieved. The body text says so
|
|
312
375
|
too — this is the machine-readable half of the same statement."""
|
|
@@ -605,8 +668,13 @@ class LspProvider:
|
|
|
605
668
|
op_str, target_str, engine="lsp", reason="warming")
|
|
606
669
|
|
|
607
670
|
if state == _State.FAILED:
|
|
608
|
-
|
|
609
|
-
|
|
671
|
+
failed = safe_null_result(
|
|
672
|
+
op_str, target_str, engine="lsp", reason="boot-failed",
|
|
673
|
+
hint=self._boot_failed_hint(session),
|
|
674
|
+
)
|
|
675
|
+
if session.attempt <= 1 and self._cmd == "uvx":
|
|
676
|
+
failed["retry_after_s"] = 60
|
|
677
|
+
return failed
|
|
610
678
|
|
|
611
679
|
# READY
|
|
612
680
|
# Cleared through a method rather than a direct assignment: `_dispatch` sets this as a
|
|
@@ -618,12 +686,18 @@ class LspProvider:
|
|
|
618
686
|
# A backend failure is not an unsupported op. Reporting it as one sends the agent
|
|
619
687
|
# looking for a different tool when the language server simply did not start —
|
|
620
688
|
# the same misleading string the graph provider already had to stop emitting.
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
689
|
+
missing = self._get_backend_missing()
|
|
690
|
+
if self._last_backend_error or missing:
|
|
691
|
+
failed = safe_null_result(
|
|
692
|
+
op_str, target_str, engine="lsp",
|
|
693
|
+
reason=missing.kind if missing is not None else "backend-error",
|
|
694
|
+
hint=f"{missing.describe() if missing else self._last_backend_error} — "
|
|
695
|
+
f"run `codeintel doctor --deep` to boot-"
|
|
625
696
|
f"check serena; the full backend message is in the server log",
|
|
626
697
|
)
|
|
698
|
+
if missing is not None and missing.retry_after_s is not None:
|
|
699
|
+
failed["retry_after_s"] = missing.retry_after_s
|
|
700
|
+
return failed
|
|
627
701
|
return safe_null_result(op_str, target_str, engine="lsp", reason="unsupported-op")
|
|
628
702
|
|
|
629
703
|
envelope: Result = {
|
|
@@ -797,31 +871,46 @@ class LspProvider:
|
|
|
797
871
|
self, session: _LspSession, target: str, root: str, timeout_s: float
|
|
798
872
|
) -> str | None:
|
|
799
873
|
try:
|
|
800
|
-
|
|
874
|
+
deadline = time.monotonic() + timeout_s
|
|
875
|
+
|
|
876
|
+
def remaining() -> float:
|
|
877
|
+
return max(0.001, deadline - time.monotonic())
|
|
878
|
+
|
|
879
|
+
symbol_target, file_hint, qualified_hint = _split_target_file_hint(target)
|
|
801
880
|
def_out = self._call_tool(
|
|
802
881
|
session,
|
|
803
882
|
"find_symbol",
|
|
804
883
|
{
|
|
805
884
|
"name_path_pattern": symbol_target,
|
|
806
|
-
"include_body":
|
|
885
|
+
"include_body": not (file_hint or qualified_hint),
|
|
807
886
|
# A file hint exists specifically because the name is ambiguous. Retrieve
|
|
808
887
|
# enough definitions to find the requested file instead of silently selecting
|
|
809
888
|
# whichever five Serena happens to return first.
|
|
810
|
-
"max_matches":
|
|
889
|
+
"max_matches": 500 if (file_hint or qualified_hint) else 5,
|
|
811
890
|
},
|
|
812
|
-
|
|
891
|
+
remaining(),
|
|
813
892
|
)
|
|
814
893
|
if isinstance(def_out, Missing):
|
|
815
894
|
# The tool call itself failed or timed out. Rendering "(not found)" here — which is
|
|
816
895
|
# what this did — states that the symbol does not exist, on no evidence whatsoever.
|
|
817
896
|
# For an agent deciding whether to create something, "I could not ask" and "it is
|
|
818
897
|
# not there" are opposite answers.
|
|
898
|
+
self._set_backend_missing(def_out)
|
|
819
899
|
self._last_backend_error = def_out.describe()
|
|
820
900
|
return None
|
|
821
901
|
def_raw = def_out.value
|
|
822
902
|
def_text = self._extract_text(def_raw)
|
|
823
903
|
matches = self._loads(def_text)
|
|
824
|
-
|
|
904
|
+
candidate_page_capped = bool(
|
|
905
|
+
isinstance(matches, list) and (file_hint or qualified_hint) and len(matches) >= 500
|
|
906
|
+
)
|
|
907
|
+
if candidate_page_capped:
|
|
908
|
+
matches = []
|
|
909
|
+
def_text = (
|
|
910
|
+
f"> The language server returned the maximum 500 definitions for `{target}`; "
|
|
911
|
+
"the exact definition cannot be proven from a truncated candidate page."
|
|
912
|
+
)
|
|
913
|
+
if isinstance(matches, list) and file_hint and not candidate_page_capped:
|
|
825
914
|
matches = [
|
|
826
915
|
match for match in matches
|
|
827
916
|
if isinstance(match, dict)
|
|
@@ -836,7 +925,75 @@ class LspProvider:
|
|
|
836
925
|
f"> The language server found no definition for `{symbol_target}` in "
|
|
837
926
|
f"`{file_hint}`."
|
|
838
927
|
)
|
|
839
|
-
|
|
928
|
+
if isinstance(matches, list) and qualified_hint and not candidate_page_capped:
|
|
929
|
+
scored = [
|
|
930
|
+
(match, _qualified_definition_score(match, qualified_hint))
|
|
931
|
+
for match in matches if isinstance(match, dict)
|
|
932
|
+
]
|
|
933
|
+
best = max((score for _, score in scored), default=0)
|
|
934
|
+
# `_qualified_definition_score` admits a leaf-only symbol match only when every
|
|
935
|
+
# remaining qualifier segment was proven against the definition's relative path.
|
|
936
|
+
matches = [match for match, score in scored if score == best and score > 0]
|
|
937
|
+
if not matches:
|
|
938
|
+
def_text = (
|
|
939
|
+
f"> The language server found no definition matching the qualified symbol "
|
|
940
|
+
f"`{target}`."
|
|
941
|
+
)
|
|
942
|
+
if isinstance(matches, list) and matches and (file_hint or qualified_hint):
|
|
943
|
+
identities = {
|
|
944
|
+
(str(match.get("relative_path") or ""),
|
|
945
|
+
str(match.get("name_path") or ""),
|
|
946
|
+
json.dumps(match.get("body_location"), sort_keys=True))
|
|
947
|
+
for match in matches
|
|
948
|
+
}
|
|
949
|
+
if len(identities) != 1:
|
|
950
|
+
matches = []
|
|
951
|
+
def_text = (
|
|
952
|
+
f"> The language server found multiple definitions matching `{target}`; "
|
|
953
|
+
"the file hint is not specific enough to request exact references."
|
|
954
|
+
)
|
|
955
|
+
if isinstance(matches, list) and matches and (file_hint or qualified_hint):
|
|
956
|
+
selected = matches[0]
|
|
957
|
+
body_out = self._call_tool(
|
|
958
|
+
session,
|
|
959
|
+
"find_symbol",
|
|
960
|
+
{
|
|
961
|
+
"name_path_pattern": selected.get("name_path") or symbol_target,
|
|
962
|
+
"relative_path": selected.get("relative_path"),
|
|
963
|
+
"include_body": True,
|
|
964
|
+
"max_matches": 500,
|
|
965
|
+
},
|
|
966
|
+
remaining(),
|
|
967
|
+
)
|
|
968
|
+
if isinstance(body_out, Missing):
|
|
969
|
+
self._set_backend_missing(body_out)
|
|
970
|
+
self._last_backend_error = body_out.describe()
|
|
971
|
+
return None
|
|
972
|
+
body_text = self._extract_text(body_out.value)
|
|
973
|
+
body_matches = self._loads(body_text)
|
|
974
|
+
if isinstance(body_matches, list) and body_matches:
|
|
975
|
+
exact_body_matches = [
|
|
976
|
+
match for match in body_matches
|
|
977
|
+
if isinstance(match, dict)
|
|
978
|
+
and match.get("name_path") == selected.get("name_path")
|
|
979
|
+
and match.get("relative_path") == selected.get("relative_path")
|
|
980
|
+
and (
|
|
981
|
+
selected.get("body_location") is None
|
|
982
|
+
or match.get("body_location") == selected.get("body_location")
|
|
983
|
+
)
|
|
984
|
+
]
|
|
985
|
+
if exact_body_matches:
|
|
986
|
+
matches = exact_body_matches
|
|
987
|
+
else:
|
|
988
|
+
self._last_backend_error = (
|
|
989
|
+
"the exact definition body lookup returned no matching definition"
|
|
990
|
+
)
|
|
991
|
+
return None
|
|
992
|
+
else:
|
|
993
|
+
self._last_backend_error = (
|
|
994
|
+
"the exact definition body lookup returned no usable definition list"
|
|
995
|
+
)
|
|
996
|
+
return None
|
|
840
997
|
first: dict | None = None
|
|
841
998
|
if isinstance(matches, list) and matches:
|
|
842
999
|
def_section, first = self._format_matches(target, matches)
|
|
@@ -874,7 +1031,7 @@ class LspProvider:
|
|
|
874
1031
|
"name_path": first.get("name_path") or target,
|
|
875
1032
|
"relative_path": first.get("relative_path"),
|
|
876
1033
|
},
|
|
877
|
-
|
|
1034
|
+
remaining(),
|
|
878
1035
|
)
|
|
879
1036
|
miss: Missing | None = None
|
|
880
1037
|
parsed: object = None
|
|
@@ -112,6 +112,35 @@ def test_engine_both_graph_unavailable_returns_lsp_alone():
|
|
|
112
112
|
assert "graph-data" not in r["result"]
|
|
113
113
|
|
|
114
114
|
|
|
115
|
+
def test_retryable_fanout_failure_is_not_cached():
|
|
116
|
+
graph = _StubProvider("graph", "graph-data")
|
|
117
|
+
|
|
118
|
+
class _EventuallyReady(_StubProvider):
|
|
119
|
+
def build_result(self, op, target, files, budget, project_root) -> Result:
|
|
120
|
+
self.call_count += 1
|
|
121
|
+
if self.call_count == 1:
|
|
122
|
+
return {
|
|
123
|
+
"ok": True, "op": op, "target": target, "result": None,
|
|
124
|
+
"engine": "lsp", "cached": False, "outcome": "failed",
|
|
125
|
+
"reason": "boot-failed", "retry_after_s": 60,
|
|
126
|
+
}
|
|
127
|
+
return {
|
|
128
|
+
"ok": True, "op": op, "target": target, "result": "lsp-data",
|
|
129
|
+
"engine": "lsp", "cached": False,
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
lsp = _EventuallyReady("lsp", "lsp-data")
|
|
133
|
+
gw = Gateway(graph=graph, lsp=lsp)
|
|
134
|
+
|
|
135
|
+
first = gw.query(op="context", target="x", engine="both")
|
|
136
|
+
second = gw.query(op="context", target="x", engine="both")
|
|
137
|
+
|
|
138
|
+
assert any(gap.get("retry_after_s") == 60 for gap in first["gaps"])
|
|
139
|
+
assert "lsp-data" in second["result"]
|
|
140
|
+
assert graph.call_count == 2
|
|
141
|
+
assert lsp.call_count == 2
|
|
142
|
+
|
|
143
|
+
|
|
115
144
|
# ---------------------------------------------------------------------------
|
|
116
145
|
# Test 6: engine=all merges all three
|
|
117
146
|
# ---------------------------------------------------------------------------
|