codecortex 0.23.2__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.2/src/codecortex.egg-info → codecortex-0.23.4}/PKG-INFO +1 -1
- {codecortex-0.23.2 → 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.2 → codecortex-0.23.4}/src/codeintel/gateway.py +95 -29
- {codecortex-0.23.2 → codecortex-0.23.4}/src/codeintel/provider.py +1 -0
- {codecortex-0.23.2 → codecortex-0.23.4}/src/codeintel/providers/lsp.py +216 -12
- {codecortex-0.23.2 → codecortex-0.23.4}/tests/test_gateway.py +29 -0
- {codecortex-0.23.2 → codecortex-0.23.4}/tests/test_lsp_provider.py +399 -1
- {codecortex-0.23.2 → codecortex-0.23.4}/tests/test_null_reason_and_ripple.py +239 -0
- codecortex-0.23.2/src/codeintel/__init__.py +0 -1
- {codecortex-0.23.2 → codecortex-0.23.4}/LICENSE +0 -0
- {codecortex-0.23.2 → codecortex-0.23.4}/README.md +0 -0
- {codecortex-0.23.2 → codecortex-0.23.4}/pyproject.toml +0 -0
- {codecortex-0.23.2 → codecortex-0.23.4}/setup.cfg +0 -0
- {codecortex-0.23.2 → codecortex-0.23.4}/src/codecortex.egg-info/SOURCES.txt +0 -0
- {codecortex-0.23.2 → codecortex-0.23.4}/src/codecortex.egg-info/dependency_links.txt +0 -0
- {codecortex-0.23.2 → codecortex-0.23.4}/src/codecortex.egg-info/entry_points.txt +0 -0
- {codecortex-0.23.2 → codecortex-0.23.4}/src/codecortex.egg-info/requires.txt +0 -0
- {codecortex-0.23.2 → codecortex-0.23.4}/src/codecortex.egg-info/top_level.txt +0 -0
- {codecortex-0.23.2 → codecortex-0.23.4}/src/codeintel/__main__.py +0 -0
- {codecortex-0.23.2 → codecortex-0.23.4}/src/codeintel/agent_prompt.py +0 -0
- {codecortex-0.23.2 → codecortex-0.23.4}/src/codeintel/auth.py +0 -0
- {codecortex-0.23.2 → codecortex-0.23.4}/src/codeintel/c4.py +0 -0
- {codecortex-0.23.2 → codecortex-0.23.4}/src/codeintel/c4_check.py +0 -0
- {codecortex-0.23.2 → codecortex-0.23.4}/src/codeintel/c4_layers.py +0 -0
- {codecortex-0.23.2 → codecortex-0.23.4}/src/codeintel/cache.py +0 -0
- {codecortex-0.23.2 → codecortex-0.23.4}/src/codeintel/commands/__init__.py +0 -0
- {codecortex-0.23.2 → codecortex-0.23.4}/src/codeintel/commands/_common.py +0 -0
- {codecortex-0.23.2 → codecortex-0.23.4}/src/codeintel/commands/c4.py +0 -0
- {codecortex-0.23.2 → codecortex-0.23.4}/src/codeintel/commands/doctor.py +0 -0
- {codecortex-0.23.2 → codecortex-0.23.4}/src/codeintel/commands/gen_token.py +0 -0
- {codecortex-0.23.2 → codecortex-0.23.4}/src/codeintel/commands/graph.py +0 -0
- {codecortex-0.23.2 → codecortex-0.23.4}/src/codeintel/commands/index.py +0 -0
- {codecortex-0.23.2 → codecortex-0.23.4}/src/codeintel/commands/install.py +0 -0
- {codecortex-0.23.2 → codecortex-0.23.4}/src/codeintel/commands/map.py +0 -0
- {codecortex-0.23.2 → codecortex-0.23.4}/src/codeintel/commands/prompt.py +0 -0
- {codecortex-0.23.2 → codecortex-0.23.4}/src/codeintel/commands/query.py +0 -0
- {codecortex-0.23.2 → codecortex-0.23.4}/src/codeintel/commands/reset.py +0 -0
- {codecortex-0.23.2 → codecortex-0.23.4}/src/codeintel/commands/serve.py +0 -0
- {codecortex-0.23.2 → codecortex-0.23.4}/src/codeintel/commands/serve_http.py +0 -0
- {codecortex-0.23.2 → codecortex-0.23.4}/src/codeintel/commands/setup.py +0 -0
- {codecortex-0.23.2 → codecortex-0.23.4}/src/codeintel/commands/status.py +0 -0
- {codecortex-0.23.2 → codecortex-0.23.4}/src/codeintel/config.py +0 -0
- {codecortex-0.23.2 → codecortex-0.23.4}/src/codeintel/containment.py +0 -0
- {codecortex-0.23.2 → codecortex-0.23.4}/src/codeintel/doctor.py +0 -0
- {codecortex-0.23.2 → codecortex-0.23.4}/src/codeintel/graph_backend.py +0 -0
- {codecortex-0.23.2 → codecortex-0.23.4}/src/codeintel/graph_render.py +0 -0
- {codecortex-0.23.2 → codecortex-0.23.4}/src/codeintel/graph_resolution.py +0 -0
- {codecortex-0.23.2 → codecortex-0.23.4}/src/codeintel/grapher.py +0 -0
- {codecortex-0.23.2 → codecortex-0.23.4}/src/codeintel/http_server.py +0 -0
- {codecortex-0.23.2 → codecortex-0.23.4}/src/codeintel/indexer.py +0 -0
- {codecortex-0.23.2 → codecortex-0.23.4}/src/codeintel/injector.py +0 -0
- {codecortex-0.23.2 → codecortex-0.23.4}/src/codeintel/installer.py +0 -0
- {codecortex-0.23.2 → codecortex-0.23.4}/src/codeintel/lang_config.py +0 -0
- {codecortex-0.23.2 → codecortex-0.23.4}/src/codeintel/loc.py +0 -0
- {codecortex-0.23.2 → codecortex-0.23.4}/src/codeintel/logconfig.py +0 -0
- {codecortex-0.23.2 → codecortex-0.23.4}/src/codeintel/mapper.py +0 -0
- {codecortex-0.23.2 → codecortex-0.23.4}/src/codeintel/metrics.py +0 -0
- {codecortex-0.23.2 → codecortex-0.23.4}/src/codeintel/onboarding.py +0 -0
- {codecortex-0.23.2 → codecortex-0.23.4}/src/codeintel/outcome.py +0 -0
- {codecortex-0.23.2 → codecortex-0.23.4}/src/codeintel/paths.py +0 -0
- {codecortex-0.23.2 → codecortex-0.23.4}/src/codeintel/policy.py +0 -0
- {codecortex-0.23.2 → codecortex-0.23.4}/src/codeintel/progress.py +0 -0
- {codecortex-0.23.2 → codecortex-0.23.4}/src/codeintel/providers/__init__.py +0 -0
- {codecortex-0.23.2 → codecortex-0.23.4}/src/codeintel/providers/graph.py +0 -0
- {codecortex-0.23.2 → codecortex-0.23.4}/src/codeintel/providers/none.py +0 -0
- {codecortex-0.23.2 → codecortex-0.23.4}/src/codeintel/providers/semantic.py +0 -0
- {codecortex-0.23.2 → codecortex-0.23.4}/src/codeintel/py.typed +0 -0
- {codecortex-0.23.2 → codecortex-0.23.4}/src/codeintel/query_ops.py +0 -0
- {codecortex-0.23.2 → codecortex-0.23.4}/src/codeintel/redact.py +0 -0
- {codecortex-0.23.2 → codecortex-0.23.4}/src/codeintel/reindexer.py +0 -0
- {codecortex-0.23.2 → codecortex-0.23.4}/src/codeintel/reset.py +0 -0
- {codecortex-0.23.2 → codecortex-0.23.4}/src/codeintel/searcher.py +0 -0
- {codecortex-0.23.2 → codecortex-0.23.4}/src/codeintel/semantic_db.py +0 -0
- {codecortex-0.23.2 → codecortex-0.23.4}/src/codeintel/server.py +0 -0
- {codecortex-0.23.2 → codecortex-0.23.4}/src/codeintel/source_kind.py +0 -0
- {codecortex-0.23.2 → codecortex-0.23.4}/src/codeintel/term.py +0 -0
- {codecortex-0.23.2 → codecortex-0.23.4}/src/codeintel/verify.py +0 -0
- {codecortex-0.23.2 → codecortex-0.23.4}/src/codeintel/viewer/__init__.py +0 -0
- {codecortex-0.23.2 → codecortex-0.23.4}/src/codeintel/viewer/graph_template.html +0 -0
- {codecortex-0.23.2 → codecortex-0.23.4}/src/codeintel/wire_text.py +0 -0
- {codecortex-0.23.2 → codecortex-0.23.4}/tests/test_agent_prompt.py +0 -0
- {codecortex-0.23.2 → codecortex-0.23.4}/tests/test_backend_reaper.py +0 -0
- {codecortex-0.23.2 → codecortex-0.23.4}/tests/test_background_index_failure.py +0 -0
- {codecortex-0.23.2 → codecortex-0.23.4}/tests/test_bench_oracle.py +0 -0
- {codecortex-0.23.2 → codecortex-0.23.4}/tests/test_bench_oracle_ts.py +0 -0
- {codecortex-0.23.2 → codecortex-0.23.4}/tests/test_c4.py +0 -0
- {codecortex-0.23.2 → codecortex-0.23.4}/tests/test_c4_check.py +0 -0
- {codecortex-0.23.2 → codecortex-0.23.4}/tests/test_c4_layers.py +0 -0
- {codecortex-0.23.2 → codecortex-0.23.4}/tests/test_c4_model.py +0 -0
- {codecortex-0.23.2 → codecortex-0.23.4}/tests/test_c4_output.py +0 -0
- {codecortex-0.23.2 → codecortex-0.23.4}/tests/test_cache.py +0 -0
- {codecortex-0.23.2 → codecortex-0.23.4}/tests/test_chunking.py +0 -0
- {codecortex-0.23.2 → codecortex-0.23.4}/tests/test_cli_commands.py +0 -0
- {codecortex-0.23.2 → codecortex-0.23.4}/tests/test_cli_help.py +0 -0
- {codecortex-0.23.2 → codecortex-0.23.4}/tests/test_cli_install_dry_run.py +0 -0
- {codecortex-0.23.2 → codecortex-0.23.4}/tests/test_cli_install_offer_injection.py +0 -0
- {codecortex-0.23.2 → codecortex-0.23.4}/tests/test_cold_process.py +0 -0
- {codecortex-0.23.2 → codecortex-0.23.4}/tests/test_config.py +0 -0
- {codecortex-0.23.2 → codecortex-0.23.4}/tests/test_corpus.py +0 -0
- {codecortex-0.23.2 → codecortex-0.23.4}/tests/test_could_not_ask_semantic.py +0 -0
- {codecortex-0.23.2 → codecortex-0.23.4}/tests/test_docs_ci_claims.py +0 -0
- {codecortex-0.23.2 → codecortex-0.23.4}/tests/test_docs_deadcode_withdrawal.py +0 -0
- {codecortex-0.23.2 → codecortex-0.23.4}/tests/test_doctor.py +0 -0
- {codecortex-0.23.2 → codecortex-0.23.4}/tests/test_e2e.py +0 -0
- {codecortex-0.23.2 → codecortex-0.23.4}/tests/test_edge_confidence.py +0 -0
- {codecortex-0.23.2 → codecortex-0.23.4}/tests/test_engine_adoption.py +0 -0
- {codecortex-0.23.2 → codecortex-0.23.4}/tests/test_enterprise.py +0 -0
- {codecortex-0.23.2 → codecortex-0.23.4}/tests/test_graph_failure_population.py +0 -0
- {codecortex-0.23.2 → codecortex-0.23.4}/tests/test_graph_provider.py +0 -0
- {codecortex-0.23.2 → codecortex-0.23.4}/tests/test_graph_real.py +0 -0
- {codecortex-0.23.2 → codecortex-0.23.4}/tests/test_graph_stdin.py +0 -0
- {codecortex-0.23.2 → codecortex-0.23.4}/tests/test_grapher.py +0 -0
- {codecortex-0.23.2 → codecortex-0.23.4}/tests/test_hard_exit.py +0 -0
- {codecortex-0.23.2 → codecortex-0.23.4}/tests/test_hardening.py +0 -0
- {codecortex-0.23.2 → codecortex-0.23.4}/tests/test_http_auth.py +0 -0
- {codecortex-0.23.2 → codecortex-0.23.4}/tests/test_http_server.py +0 -0
- {codecortex-0.23.2 → codecortex-0.23.4}/tests/test_incompleteness.py +0 -0
- {codecortex-0.23.2 → codecortex-0.23.4}/tests/test_index_failure_reason.py +0 -0
- {codecortex-0.23.2 → codecortex-0.23.4}/tests/test_index_progress.py +0 -0
- {codecortex-0.23.2 → codecortex-0.23.4}/tests/test_indexer_binary.py +0 -0
- {codecortex-0.23.2 → codecortex-0.23.4}/tests/test_installer.py +0 -0
- {codecortex-0.23.2 → codecortex-0.23.4}/tests/test_integration.py +0 -0
- {codecortex-0.23.2 → codecortex-0.23.4}/tests/test_lang_config.py +0 -0
- {codecortex-0.23.2 → codecortex-0.23.4}/tests/test_loc_census.py +0 -0
- {codecortex-0.23.2 → codecortex-0.23.4}/tests/test_lsp_language_coverage.py +0 -0
- {codecortex-0.23.2 → codecortex-0.23.4}/tests/test_lsp_real.py +0 -0
- {codecortex-0.23.2 → codecortex-0.23.4}/tests/test_mapper.py +0 -0
- {codecortex-0.23.2 → codecortex-0.23.4}/tests/test_mcp_handshake.py +0 -0
- {codecortex-0.23.2 → codecortex-0.23.4}/tests/test_mcp_server.py +0 -0
- {codecortex-0.23.2 → codecortex-0.23.4}/tests/test_model_dimension.py +0 -0
- {codecortex-0.23.2 → codecortex-0.23.4}/tests/test_never_raise.py +0 -0
- {codecortex-0.23.2 → codecortex-0.23.4}/tests/test_onboarding.py +0 -0
- {codecortex-0.23.2 → codecortex-0.23.4}/tests/test_rbac.py +0 -0
- {codecortex-0.23.2 → codecortex-0.23.4}/tests/test_redaction_boundary.py +0 -0
- {codecortex-0.23.2 → codecortex-0.23.4}/tests/test_reindexer.py +0 -0
- {codecortex-0.23.2 → codecortex-0.23.4}/tests/test_rerank.py +0 -0
- {codecortex-0.23.2 → codecortex-0.23.4}/tests/test_reset.py +0 -0
- {codecortex-0.23.2 → codecortex-0.23.4}/tests/test_semantic_provider.py +0 -0
- {codecortex-0.23.2 → codecortex-0.23.4}/tests/test_source_kind.py +0 -0
- {codecortex-0.23.2 → codecortex-0.23.4}/tests/test_staleness.py +0 -0
- {codecortex-0.23.2 → codecortex-0.23.4}/tests/test_symbol_context.py +0 -0
- {codecortex-0.23.2 → codecortex-0.23.4}/tests/test_term.py +0 -0
- {codecortex-0.23.2 → codecortex-0.23.4}/tests/test_treesitter.py +0 -0
- {codecortex-0.23.2 → codecortex-0.23.4}/tests/test_verify.py +0 -0
- {codecortex-0.23.2 → codecortex-0.23.4}/tests/test_verify_call.py +0 -0
- {codecortex-0.23.2 → codecortex-0.23.4}/tests/test_version_skew.py +0 -0
- {codecortex-0.23.2 → 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):
|
|
@@ -174,7 +181,7 @@ class Gateway:
|
|
|
174
181
|
self, result: Result, op: str, target: str, budget: Any,
|
|
175
182
|
project_root: Any, was_auto: bool,
|
|
176
183
|
) -> Result:
|
|
177
|
-
"""Ask the LSP when the graph's
|
|
184
|
+
"""Ask the LSP when the graph's caller answer carries a known binding failure.
|
|
178
185
|
|
|
179
186
|
This closes the routing gap that let the worst failure through. `_AUTO_ENGINE` is a static
|
|
180
187
|
op→engine map, not a cascade: `callers` goes to the graph and, uniquely among the ops, has
|
|
@@ -185,48 +192,99 @@ class Gateway:
|
|
|
185
192
|
being confidently wrong is a bug; no second engine ever being consulted is the design that
|
|
186
193
|
let it reach the caller.
|
|
187
194
|
|
|
188
|
-
Deliberately narrow. It fires
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
not call edges), so presenting its
|
|
193
|
-
another. And it never fires for an
|
|
194
|
-
said which engine they want."""
|
|
195
|
+
Deliberately narrow. It fires when every graph row was name-resolved, or when a caller used
|
|
196
|
+
a file hint and the graph found same-named symbols but no edge for that exact definition.
|
|
197
|
+
The latter catches property calls that the TypeScript graph resolver failed to bind, while
|
|
198
|
+
avoiding an LSP call for ordinary healthy answers. It APPENDS rather than replaces: the LSP
|
|
199
|
+
answers a related but different question (references, not call edges), so presenting its
|
|
200
|
+
list as the graph's would substitute one over-claim for another. And it never fires for an
|
|
201
|
+
explicitly pinned `--engine graph`, where the caller has said which engine they want."""
|
|
195
202
|
try:
|
|
196
203
|
if not was_auto or op not in self._CROSS_CHECKED_OPS:
|
|
197
204
|
return result
|
|
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
|
-
|
|
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):
|
|
203
239
|
return result
|
|
240
|
+
graph_problem = (
|
|
241
|
+
"every graph row was resolved by name"
|
|
242
|
+
if all_name_resolved
|
|
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"
|
|
246
|
+
)
|
|
204
247
|
probe = self._dispatch_single(
|
|
205
248
|
self.lsp, "symbol", target, budget, project_root, "lsp")
|
|
206
249
|
body = probe.get("result")
|
|
207
|
-
|
|
250
|
+
probe_gaps = probe.get("gaps") or []
|
|
251
|
+
reference_gap = next((
|
|
252
|
+
gap for gap in probe_gaps
|
|
253
|
+
if isinstance(gap, dict) and gap.get("section") == "references"
|
|
254
|
+
), None)
|
|
255
|
+
if not body or reference_gap is not None:
|
|
208
256
|
# Silence from the LSP is not agreement. Say which check did not happen — and
|
|
209
257
|
# separate "not yet booted" from "had nothing", because only the first is fixed by
|
|
210
258
|
# asking again. A one-shot CLI process meets a cold serena on every invocation; the
|
|
211
259
|
# long-lived MCP server keeps the session warm and takes this branch once at most.
|
|
212
260
|
# Waiting here is deliberately NOT done: it would hold back a graph answer that is
|
|
213
261
|
# already complete, to append a section that is only advisory.
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
262
|
+
unavailable_reason = (
|
|
263
|
+
str(reference_gap.get("kind") or "not-asked")
|
|
264
|
+
if reference_gap is not None else str(probe.get("reason") or "no-result")
|
|
265
|
+
)
|
|
266
|
+
retry_after_s = probe.get("retry_after_s")
|
|
267
|
+
retryable = unavailable_reason in {"warming", "timeout"} or bool(retry_after_s)
|
|
268
|
+
why = (
|
|
269
|
+
"the language server had not finished booting"
|
|
270
|
+
if unavailable_reason == "warming"
|
|
271
|
+
else str(reference_gap.get("detail") or "the reference lookup did not answer")
|
|
272
|
+
if reference_gap is not None
|
|
273
|
+
else "the LSP engine reported nothing for this symbol"
|
|
274
|
+
)
|
|
217
275
|
nxt = (" Ask again once it is warm and this section will be filled in."
|
|
218
|
-
if
|
|
276
|
+
if retryable else
|
|
219
277
|
" Check it yourself with `--engine lsp --op symbol`.")
|
|
220
278
|
return {**result, "gaps": [*gaps, {
|
|
221
279
|
"section": op, "kind": "cross-check-unavailable",
|
|
222
280
|
"engine": "lsp",
|
|
223
|
-
"reason":
|
|
224
|
-
"detail": f"
|
|
225
|
-
f"
|
|
226
|
-
+ (" — retry" if
|
|
227
|
-
**({"retry_after_s": 2} if
|
|
281
|
+
"reason": unavailable_reason,
|
|
282
|
+
"detail": f"{graph_problem} and the LSP could not provide an independent "
|
|
283
|
+
f"reference check ({why}), so the graph answer remains unverified"
|
|
284
|
+
+ (" — retry" if retryable else ""),
|
|
285
|
+
**({"retry_after_s": retry_after_s or 2} if retryable else {}),
|
|
228
286
|
}], "result": str(result["result"]) + (
|
|
229
|
-
f"\n\n> Cross-check unavailable:
|
|
287
|
+
f"\n\n> Cross-check unavailable: {graph_problem}, and "
|
|
230
288
|
f"{why}, so nothing here has been confirmed against a second engine.{nxt}"
|
|
231
289
|
)}
|
|
232
290
|
refs = self._reference_lines(str(body))
|
|
@@ -236,15 +294,15 @@ class Gateway:
|
|
|
236
294
|
merged = (
|
|
237
295
|
f"{result['result']}\n\n## Cross-check — LSP references to `{target}` "
|
|
238
296
|
f"({len(refs)})\n"
|
|
239
|
-
f"_The
|
|
240
|
-
f"language server, which resolves
|
|
241
|
-
f"but absent here is
|
|
242
|
-
f"is a
|
|
297
|
+
f"_The graph answer has a known binding gap: {graph_problem}. These locations come "
|
|
298
|
+
f"from the language server, which resolves the exact definition. A caller listed "
|
|
299
|
+
f"above but absent here is likely a name collision; a location here but missing "
|
|
300
|
+
f"above is a reference the graph could not bind._\n" + listing + more
|
|
243
301
|
)
|
|
244
302
|
return {**result, "result": merged, "gaps": [*gaps, {
|
|
245
303
|
"section": op, "kind": "cross-checked-with-lsp",
|
|
246
304
|
"engine": "lsp",
|
|
247
|
-
"detail": f"
|
|
305
|
+
"detail": f"{graph_problem}, so the LSP was consulted "
|
|
248
306
|
f"independently and reported {len(refs)} reference location(s); the two "
|
|
249
307
|
f"lists answer related but different questions and are shown separately",
|
|
250
308
|
}]}
|
|
@@ -310,12 +368,18 @@ class Gateway:
|
|
|
310
368
|
)
|
|
311
369
|
summary = "engines-unavailable" if all_unreachable else "no-result"
|
|
312
370
|
detail = ", ".join(f"{eng}: {why}" for eng, why in sorted(reasons.items()))
|
|
313
|
-
|
|
371
|
+
merged_null = safe_null_result(
|
|
314
372
|
op_str, target_str, engine=engine_str, reason=summary,
|
|
315
373
|
hint=(f"no engine produced an answer — {detail}"
|
|
316
374
|
+ ("; this is NOT evidence the target does not exist"
|
|
317
375
|
if all_unreachable else "")),
|
|
318
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
|
|
319
383
|
|
|
320
384
|
# A fan-out answer is only as whole as its parts. This used to hand-build a six-key envelope
|
|
321
385
|
# and drop both `confidence` and `gaps` on the floor — so a `context` request (the DEFAULT
|
|
@@ -334,6 +398,8 @@ class Gateway:
|
|
|
334
398
|
"detail": f"the {eng} engine contributed nothing to this answer "
|
|
335
399
|
f"({r.get('reason') or 'no-result'})",
|
|
336
400
|
"engine": eng,
|
|
401
|
+
**({"retry_after_s": r["retry_after_s"]}
|
|
402
|
+
if r.get("retry_after_s") else {}),
|
|
337
403
|
})
|
|
338
404
|
return attach_confidence({
|
|
339
405
|
"ok": True,
|
|
@@ -515,7 +581,7 @@ class Gateway:
|
|
|
515
581
|
engines = ["graph", "lsp"] if engine_str == "both" else ["graph", "lsp", "semantic"]
|
|
516
582
|
fan_results = self._fan_out(engines, op_str, target_str, budget, project_root)
|
|
517
583
|
result = self._merge(fan_results, op_str, target_str, engine_str)
|
|
518
|
-
if not uncacheable:
|
|
584
|
+
if not uncacheable and not _is_retryable(result):
|
|
519
585
|
self._cache.put(op_str, target_str, cache_engine, root_str, result, freshness)
|
|
520
586
|
return _mark_reindexing(result, reindexing)
|
|
521
587
|
|
|
@@ -554,7 +620,7 @@ class Gateway:
|
|
|
554
620
|
result = self._cross_check_name_resolved(
|
|
555
621
|
result, op_str, target_str, budget, project_root, was_auto)
|
|
556
622
|
|
|
557
|
-
if not uncacheable:
|
|
623
|
+
if not uncacheable and not _is_retryable(result):
|
|
558
624
|
self._cache.put(op_str, target_str, cache_engine, root_str, result, freshness)
|
|
559
625
|
return _mark_reindexing(result, reindexing)
|
|
560
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,6 +68,64 @@ _BACKEND_ERROR_MARKERS = (
|
|
|
67
68
|
)
|
|
68
69
|
|
|
69
70
|
|
|
71
|
+
def _split_target_file_hint(target: str) -> tuple[str, str, str]:
|
|
72
|
+
"""Return Serena's symbol pattern and an optional repo-relative file hint.
|
|
73
|
+
|
|
74
|
+
Graph operations accept ``name@path`` to disambiguate duplicate symbols. The gateway may pass
|
|
75
|
+
that exact target to the LSP for an independent reference cross-check, but Serena understands
|
|
76
|
+
only the name-path portion. Keep the syntax aligned here without coupling the LSP provider to
|
|
77
|
+
graph.py's private target parser.
|
|
78
|
+
"""
|
|
79
|
+
raw = str(target or "").strip()
|
|
80
|
+
if "@" not in raw:
|
|
81
|
+
return raw, "", ""
|
|
82
|
+
head, _, tail = raw.rpartition("@")
|
|
83
|
+
if not head.strip() or not tail.strip():
|
|
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)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _file_hint_matches(file_path: Any, hint: str) -> bool:
|
|
123
|
+
"""Match a file hint on path-segment boundaries, consistently with the graph provider."""
|
|
124
|
+
have = str(file_path or "").replace("\\", "/").strip().strip("/").lower()
|
|
125
|
+
want = str(hint or "").replace("\\", "/").strip().strip("/").lower()
|
|
126
|
+
return bool(have and want and (have == want or have.endswith("/" + want)))
|
|
127
|
+
|
|
128
|
+
|
|
70
129
|
def _looks_like_backend_error(text: str) -> bool:
|
|
71
130
|
"""Whether *text* is a backend failure message rather than a result.
|
|
72
131
|
|
|
@@ -263,26 +322,54 @@ class LspProvider:
|
|
|
263
322
|
|
|
264
323
|
# Class-level default so a provider built via `__new__` (the test stubs do this) still has it
|
|
265
324
|
# rather than raising AttributeError inside the never-raise handler.
|
|
266
|
-
|
|
325
|
+
_backend_error_state = threading.local()
|
|
326
|
+
_backend_missing_state = threading.local()
|
|
267
327
|
# Sections of the current answer that are known to be short of an answer. An op appends here
|
|
268
328
|
# instead of quietly rendering an empty section, and `build_result` turns them into the
|
|
269
329
|
# envelope's `gaps` / `confidence`. Class-level default for the same __new__ reason as above.
|
|
270
|
-
|
|
330
|
+
_gap_state = threading.local()
|
|
271
331
|
# Same __new__ reason: `_boot_failed_hint` reads this, and a session injected straight into
|
|
272
332
|
# `_sessions` reaches it without `_detect_backend` having run.
|
|
273
333
|
_cmd: str | None = None
|
|
274
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
|
+
|
|
275
353
|
def __init__(self) -> None:
|
|
276
354
|
self._sessions: dict[str, _LspSession] = {}
|
|
277
355
|
self._sessions_lock = threading.Lock()
|
|
278
356
|
self._last_backend_error = None
|
|
357
|
+
self._set_backend_missing(None)
|
|
279
358
|
self._pending_gaps = ()
|
|
280
359
|
self._detect_backend()
|
|
281
360
|
|
|
282
361
|
def _clear_backend_error(self) -> None:
|
|
283
362
|
self._last_backend_error = None
|
|
363
|
+
self._set_backend_missing(None)
|
|
284
364
|
self._pending_gaps = ()
|
|
285
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
|
+
|
|
286
373
|
def _add_gap(self, section: str, missing: Missing) -> None:
|
|
287
374
|
"""Record that a named part of the answer could not be retrieved. The body text says so
|
|
288
375
|
too — this is the machine-readable half of the same statement."""
|
|
@@ -581,8 +668,13 @@ class LspProvider:
|
|
|
581
668
|
op_str, target_str, engine="lsp", reason="warming")
|
|
582
669
|
|
|
583
670
|
if state == _State.FAILED:
|
|
584
|
-
|
|
585
|
-
|
|
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
|
|
586
678
|
|
|
587
679
|
# READY
|
|
588
680
|
# Cleared through a method rather than a direct assignment: `_dispatch` sets this as a
|
|
@@ -594,12 +686,18 @@ class LspProvider:
|
|
|
594
686
|
# A backend failure is not an unsupported op. Reporting it as one sends the agent
|
|
595
687
|
# looking for a different tool when the language server simply did not start —
|
|
596
688
|
# the same misleading string the graph provider already had to stop emitting.
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
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-"
|
|
601
696
|
f"check serena; the full backend message is in the server log",
|
|
602
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
|
|
603
701
|
return safe_null_result(op_str, target_str, engine="lsp", reason="unsupported-op")
|
|
604
702
|
|
|
605
703
|
envelope: Result = {
|
|
@@ -773,23 +871,129 @@ class LspProvider:
|
|
|
773
871
|
self, session: _LspSession, target: str, root: str, timeout_s: float
|
|
774
872
|
) -> str | None:
|
|
775
873
|
try:
|
|
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)
|
|
776
880
|
def_out = self._call_tool(
|
|
777
881
|
session,
|
|
778
882
|
"find_symbol",
|
|
779
|
-
{
|
|
780
|
-
|
|
883
|
+
{
|
|
884
|
+
"name_path_pattern": symbol_target,
|
|
885
|
+
"include_body": not (file_hint or qualified_hint),
|
|
886
|
+
# A file hint exists specifically because the name is ambiguous. Retrieve
|
|
887
|
+
# enough definitions to find the requested file instead of silently selecting
|
|
888
|
+
# whichever five Serena happens to return first.
|
|
889
|
+
"max_matches": 500 if (file_hint or qualified_hint) else 5,
|
|
890
|
+
},
|
|
891
|
+
remaining(),
|
|
781
892
|
)
|
|
782
893
|
if isinstance(def_out, Missing):
|
|
783
894
|
# The tool call itself failed or timed out. Rendering "(not found)" here — which is
|
|
784
895
|
# what this did — states that the symbol does not exist, on no evidence whatsoever.
|
|
785
896
|
# For an agent deciding whether to create something, "I could not ask" and "it is
|
|
786
897
|
# not there" are opposite answers.
|
|
898
|
+
self._set_backend_missing(def_out)
|
|
787
899
|
self._last_backend_error = def_out.describe()
|
|
788
900
|
return None
|
|
789
901
|
def_raw = def_out.value
|
|
790
902
|
def_text = self._extract_text(def_raw)
|
|
791
903
|
matches = self._loads(def_text)
|
|
792
|
-
|
|
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:
|
|
914
|
+
matches = [
|
|
915
|
+
match for match in matches
|
|
916
|
+
if isinstance(match, dict)
|
|
917
|
+
and _file_hint_matches(match.get("relative_path"), file_hint)
|
|
918
|
+
]
|
|
919
|
+
if not matches:
|
|
920
|
+
# Do not fall through and render the original JSON containing definitions
|
|
921
|
+
# from other files. More importantly, the reference lookup below must remain
|
|
922
|
+
# "not asked", rather than turning an unresolved exact definition into a
|
|
923
|
+
# confident zero-reference answer.
|
|
924
|
+
def_text = (
|
|
925
|
+
f"> The language server found no definition for `{symbol_target}` in "
|
|
926
|
+
f"`{file_hint}`."
|
|
927
|
+
)
|
|
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
|
|
793
997
|
first: dict | None = None
|
|
794
998
|
if isinstance(matches, list) and matches:
|
|
795
999
|
def_section, first = self._format_matches(target, matches)
|
|
@@ -827,7 +1031,7 @@ class LspProvider:
|
|
|
827
1031
|
"name_path": first.get("name_path") or target,
|
|
828
1032
|
"relative_path": first.get("relative_path"),
|
|
829
1033
|
},
|
|
830
|
-
|
|
1034
|
+
remaining(),
|
|
831
1035
|
)
|
|
832
1036
|
miss: Missing | None = None
|
|
833
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
|
# ---------------------------------------------------------------------------
|