codecortex 0.20.0__tar.gz → 0.21.0__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.20.0/src/codecortex.egg-info → codecortex-0.21.0}/PKG-INFO +2 -2
- {codecortex-0.20.0 → codecortex-0.21.0}/README.md +1 -1
- {codecortex-0.20.0 → codecortex-0.21.0/src/codecortex.egg-info}/PKG-INFO +2 -2
- {codecortex-0.20.0 → codecortex-0.21.0}/src/codecortex.egg-info/SOURCES.txt +3 -1
- codecortex-0.21.0/src/codeintel/__init__.py +1 -0
- {codecortex-0.20.0 → codecortex-0.21.0}/src/codeintel/graph_backend.py +29 -17
- {codecortex-0.20.0 → codecortex-0.21.0}/src/codeintel/providers/graph.py +21 -15
- codecortex-0.21.0/src/codeintel/wire_text.py +362 -0
- {codecortex-0.20.0 → codecortex-0.21.0}/tests/test_graph_provider.py +33 -5
- {codecortex-0.20.0 → codecortex-0.21.0}/tests/test_graph_real.py +3 -2
- codecortex-0.21.0/tests/test_wire_text.py +203 -0
- codecortex-0.20.0/src/codeintel/__init__.py +0 -1
- {codecortex-0.20.0 → codecortex-0.21.0}/LICENSE +0 -0
- {codecortex-0.20.0 → codecortex-0.21.0}/pyproject.toml +0 -0
- {codecortex-0.20.0 → codecortex-0.21.0}/setup.cfg +0 -0
- {codecortex-0.20.0 → codecortex-0.21.0}/src/codecortex.egg-info/dependency_links.txt +0 -0
- {codecortex-0.20.0 → codecortex-0.21.0}/src/codecortex.egg-info/entry_points.txt +0 -0
- {codecortex-0.20.0 → codecortex-0.21.0}/src/codecortex.egg-info/requires.txt +0 -0
- {codecortex-0.20.0 → codecortex-0.21.0}/src/codecortex.egg-info/top_level.txt +0 -0
- {codecortex-0.20.0 → codecortex-0.21.0}/src/codeintel/__main__.py +0 -0
- {codecortex-0.20.0 → codecortex-0.21.0}/src/codeintel/agent_prompt.py +0 -0
- {codecortex-0.20.0 → codecortex-0.21.0}/src/codeintel/auth.py +0 -0
- {codecortex-0.20.0 → codecortex-0.21.0}/src/codeintel/c4.py +0 -0
- {codecortex-0.20.0 → codecortex-0.21.0}/src/codeintel/cache.py +0 -0
- {codecortex-0.20.0 → codecortex-0.21.0}/src/codeintel/commands/__init__.py +0 -0
- {codecortex-0.20.0 → codecortex-0.21.0}/src/codeintel/commands/_common.py +0 -0
- {codecortex-0.20.0 → codecortex-0.21.0}/src/codeintel/commands/c4.py +0 -0
- {codecortex-0.20.0 → codecortex-0.21.0}/src/codeintel/commands/doctor.py +0 -0
- {codecortex-0.20.0 → codecortex-0.21.0}/src/codeintel/commands/gen_token.py +0 -0
- {codecortex-0.20.0 → codecortex-0.21.0}/src/codeintel/commands/graph.py +0 -0
- {codecortex-0.20.0 → codecortex-0.21.0}/src/codeintel/commands/index.py +0 -0
- {codecortex-0.20.0 → codecortex-0.21.0}/src/codeintel/commands/install.py +0 -0
- {codecortex-0.20.0 → codecortex-0.21.0}/src/codeintel/commands/map.py +0 -0
- {codecortex-0.20.0 → codecortex-0.21.0}/src/codeintel/commands/prompt.py +0 -0
- {codecortex-0.20.0 → codecortex-0.21.0}/src/codeintel/commands/query.py +0 -0
- {codecortex-0.20.0 → codecortex-0.21.0}/src/codeintel/commands/reset.py +0 -0
- {codecortex-0.20.0 → codecortex-0.21.0}/src/codeintel/commands/serve.py +0 -0
- {codecortex-0.20.0 → codecortex-0.21.0}/src/codeintel/commands/serve_http.py +0 -0
- {codecortex-0.20.0 → codecortex-0.21.0}/src/codeintel/commands/setup.py +0 -0
- {codecortex-0.20.0 → codecortex-0.21.0}/src/codeintel/commands/status.py +0 -0
- {codecortex-0.20.0 → codecortex-0.21.0}/src/codeintel/config.py +0 -0
- {codecortex-0.20.0 → codecortex-0.21.0}/src/codeintel/containment.py +0 -0
- {codecortex-0.20.0 → codecortex-0.21.0}/src/codeintel/doctor.py +0 -0
- {codecortex-0.20.0 → codecortex-0.21.0}/src/codeintel/gateway.py +0 -0
- {codecortex-0.20.0 → codecortex-0.21.0}/src/codeintel/graph_render.py +0 -0
- {codecortex-0.20.0 → codecortex-0.21.0}/src/codeintel/graph_resolution.py +0 -0
- {codecortex-0.20.0 → codecortex-0.21.0}/src/codeintel/grapher.py +0 -0
- {codecortex-0.20.0 → codecortex-0.21.0}/src/codeintel/http_server.py +0 -0
- {codecortex-0.20.0 → codecortex-0.21.0}/src/codeintel/indexer.py +0 -0
- {codecortex-0.20.0 → codecortex-0.21.0}/src/codeintel/injector.py +0 -0
- {codecortex-0.20.0 → codecortex-0.21.0}/src/codeintel/installer.py +0 -0
- {codecortex-0.20.0 → codecortex-0.21.0}/src/codeintel/loc.py +0 -0
- {codecortex-0.20.0 → codecortex-0.21.0}/src/codeintel/logconfig.py +0 -0
- {codecortex-0.20.0 → codecortex-0.21.0}/src/codeintel/mapper.py +0 -0
- {codecortex-0.20.0 → codecortex-0.21.0}/src/codeintel/metrics.py +0 -0
- {codecortex-0.20.0 → codecortex-0.21.0}/src/codeintel/onboarding.py +0 -0
- {codecortex-0.20.0 → codecortex-0.21.0}/src/codeintel/outcome.py +0 -0
- {codecortex-0.20.0 → codecortex-0.21.0}/src/codeintel/paths.py +0 -0
- {codecortex-0.20.0 → codecortex-0.21.0}/src/codeintel/policy.py +0 -0
- {codecortex-0.20.0 → codecortex-0.21.0}/src/codeintel/progress.py +0 -0
- {codecortex-0.20.0 → codecortex-0.21.0}/src/codeintel/provider.py +0 -0
- {codecortex-0.20.0 → codecortex-0.21.0}/src/codeintel/providers/__init__.py +0 -0
- {codecortex-0.20.0 → codecortex-0.21.0}/src/codeintel/providers/lsp.py +0 -0
- {codecortex-0.20.0 → codecortex-0.21.0}/src/codeintel/providers/none.py +0 -0
- {codecortex-0.20.0 → codecortex-0.21.0}/src/codeintel/providers/semantic.py +0 -0
- {codecortex-0.20.0 → codecortex-0.21.0}/src/codeintel/py.typed +0 -0
- {codecortex-0.20.0 → codecortex-0.21.0}/src/codeintel/query_ops.py +0 -0
- {codecortex-0.20.0 → codecortex-0.21.0}/src/codeintel/redact.py +0 -0
- {codecortex-0.20.0 → codecortex-0.21.0}/src/codeintel/reindexer.py +0 -0
- {codecortex-0.20.0 → codecortex-0.21.0}/src/codeintel/reset.py +0 -0
- {codecortex-0.20.0 → codecortex-0.21.0}/src/codeintel/searcher.py +0 -0
- {codecortex-0.20.0 → codecortex-0.21.0}/src/codeintel/semantic_db.py +0 -0
- {codecortex-0.20.0 → codecortex-0.21.0}/src/codeintel/server.py +0 -0
- {codecortex-0.20.0 → codecortex-0.21.0}/src/codeintel/source_kind.py +0 -0
- {codecortex-0.20.0 → codecortex-0.21.0}/src/codeintel/term.py +0 -0
- {codecortex-0.20.0 → codecortex-0.21.0}/src/codeintel/verify.py +0 -0
- {codecortex-0.20.0 → codecortex-0.21.0}/src/codeintel/viewer/__init__.py +0 -0
- {codecortex-0.20.0 → codecortex-0.21.0}/src/codeintel/viewer/graph_template.html +0 -0
- {codecortex-0.20.0 → codecortex-0.21.0}/tests/test_agent_prompt.py +0 -0
- {codecortex-0.20.0 → codecortex-0.21.0}/tests/test_backend_reaper.py +0 -0
- {codecortex-0.20.0 → codecortex-0.21.0}/tests/test_c4.py +0 -0
- {codecortex-0.20.0 → codecortex-0.21.0}/tests/test_c4_model.py +0 -0
- {codecortex-0.20.0 → codecortex-0.21.0}/tests/test_c4_output.py +0 -0
- {codecortex-0.20.0 → codecortex-0.21.0}/tests/test_cache.py +0 -0
- {codecortex-0.20.0 → codecortex-0.21.0}/tests/test_chunking.py +0 -0
- {codecortex-0.20.0 → codecortex-0.21.0}/tests/test_cli_commands.py +0 -0
- {codecortex-0.20.0 → codecortex-0.21.0}/tests/test_cli_help.py +0 -0
- {codecortex-0.20.0 → codecortex-0.21.0}/tests/test_cli_install_dry_run.py +0 -0
- {codecortex-0.20.0 → codecortex-0.21.0}/tests/test_cli_install_offer_injection.py +0 -0
- {codecortex-0.20.0 → codecortex-0.21.0}/tests/test_cold_process.py +0 -0
- {codecortex-0.20.0 → codecortex-0.21.0}/tests/test_config.py +0 -0
- {codecortex-0.20.0 → codecortex-0.21.0}/tests/test_corpus.py +0 -0
- {codecortex-0.20.0 → codecortex-0.21.0}/tests/test_docs_ci_claims.py +0 -0
- {codecortex-0.20.0 → codecortex-0.21.0}/tests/test_docs_deadcode_withdrawal.py +0 -0
- {codecortex-0.20.0 → codecortex-0.21.0}/tests/test_doctor.py +0 -0
- {codecortex-0.20.0 → codecortex-0.21.0}/tests/test_e2e.py +0 -0
- {codecortex-0.20.0 → codecortex-0.21.0}/tests/test_edge_confidence.py +0 -0
- {codecortex-0.20.0 → codecortex-0.21.0}/tests/test_engine_adoption.py +0 -0
- {codecortex-0.20.0 → codecortex-0.21.0}/tests/test_enterprise.py +0 -0
- {codecortex-0.20.0 → codecortex-0.21.0}/tests/test_gateway.py +0 -0
- {codecortex-0.20.0 → codecortex-0.21.0}/tests/test_graph_failure_population.py +0 -0
- {codecortex-0.20.0 → codecortex-0.21.0}/tests/test_graph_stdin.py +0 -0
- {codecortex-0.20.0 → codecortex-0.21.0}/tests/test_grapher.py +0 -0
- {codecortex-0.20.0 → codecortex-0.21.0}/tests/test_hardening.py +0 -0
- {codecortex-0.20.0 → codecortex-0.21.0}/tests/test_http_auth.py +0 -0
- {codecortex-0.20.0 → codecortex-0.21.0}/tests/test_http_server.py +0 -0
- {codecortex-0.20.0 → codecortex-0.21.0}/tests/test_incompleteness.py +0 -0
- {codecortex-0.20.0 → codecortex-0.21.0}/tests/test_index_progress.py +0 -0
- {codecortex-0.20.0 → codecortex-0.21.0}/tests/test_indexer_binary.py +0 -0
- {codecortex-0.20.0 → codecortex-0.21.0}/tests/test_installer.py +0 -0
- {codecortex-0.20.0 → codecortex-0.21.0}/tests/test_integration.py +0 -0
- {codecortex-0.20.0 → codecortex-0.21.0}/tests/test_loc_census.py +0 -0
- {codecortex-0.20.0 → codecortex-0.21.0}/tests/test_lsp_language_coverage.py +0 -0
- {codecortex-0.20.0 → codecortex-0.21.0}/tests/test_lsp_provider.py +0 -0
- {codecortex-0.20.0 → codecortex-0.21.0}/tests/test_lsp_real.py +0 -0
- {codecortex-0.20.0 → codecortex-0.21.0}/tests/test_mapper.py +0 -0
- {codecortex-0.20.0 → codecortex-0.21.0}/tests/test_mcp_handshake.py +0 -0
- {codecortex-0.20.0 → codecortex-0.21.0}/tests/test_mcp_server.py +0 -0
- {codecortex-0.20.0 → codecortex-0.21.0}/tests/test_model_dimension.py +0 -0
- {codecortex-0.20.0 → codecortex-0.21.0}/tests/test_never_raise.py +0 -0
- {codecortex-0.20.0 → codecortex-0.21.0}/tests/test_null_reason_and_ripple.py +0 -0
- {codecortex-0.20.0 → codecortex-0.21.0}/tests/test_onboarding.py +0 -0
- {codecortex-0.20.0 → codecortex-0.21.0}/tests/test_rbac.py +0 -0
- {codecortex-0.20.0 → codecortex-0.21.0}/tests/test_redaction_boundary.py +0 -0
- {codecortex-0.20.0 → codecortex-0.21.0}/tests/test_reindexer.py +0 -0
- {codecortex-0.20.0 → codecortex-0.21.0}/tests/test_rerank.py +0 -0
- {codecortex-0.20.0 → codecortex-0.21.0}/tests/test_reset.py +0 -0
- {codecortex-0.20.0 → codecortex-0.21.0}/tests/test_semantic_provider.py +0 -0
- {codecortex-0.20.0 → codecortex-0.21.0}/tests/test_source_kind.py +0 -0
- {codecortex-0.20.0 → codecortex-0.21.0}/tests/test_staleness.py +0 -0
- {codecortex-0.20.0 → codecortex-0.21.0}/tests/test_symbol_context.py +0 -0
- {codecortex-0.20.0 → codecortex-0.21.0}/tests/test_term.py +0 -0
- {codecortex-0.20.0 → codecortex-0.21.0}/tests/test_treesitter.py +0 -0
- {codecortex-0.20.0 → codecortex-0.21.0}/tests/test_verify.py +0 -0
- {codecortex-0.20.0 → codecortex-0.21.0}/tests/test_verify_call.py +0 -0
- {codecortex-0.20.0 → codecortex-0.21.0}/tests/test_version_skew.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: codecortex
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.21.0
|
|
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
|
|
@@ -281,7 +281,7 @@ Then it launches the exact command it registered and drives a real MCP handshake
|
|
|
281
281
|
```text
|
|
282
282
|
v claude: registered at /Users/you/.claude.json
|
|
283
283
|
|
|
284
|
-
v verified: codeintel 0.
|
|
284
|
+
v verified: codeintel 0.21.0 — 4 tools (code.query, code.status, code.doctor, code.map)
|
|
285
285
|
```
|
|
286
286
|
|
|
287
287
|
If the command is not on `PATH`, or the server fails to start, install says so and exits non-zero
|
|
@@ -244,7 +244,7 @@ Then it launches the exact command it registered and drives a real MCP handshake
|
|
|
244
244
|
```text
|
|
245
245
|
v claude: registered at /Users/you/.claude.json
|
|
246
246
|
|
|
247
|
-
v verified: codeintel 0.
|
|
247
|
+
v verified: codeintel 0.21.0 — 4 tools (code.query, code.status, code.doctor, code.map)
|
|
248
248
|
```
|
|
249
249
|
|
|
250
250
|
If the command is not on `PATH`, or the server fails to start, install says so and exits non-zero
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: codecortex
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.21.0
|
|
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
|
|
@@ -281,7 +281,7 @@ Then it launches the exact command it registered and drives a real MCP handshake
|
|
|
281
281
|
```text
|
|
282
282
|
v claude: registered at /Users/you/.claude.json
|
|
283
283
|
|
|
284
|
-
v verified: codeintel 0.
|
|
284
|
+
v verified: codeintel 0.21.0 — 4 tools (code.query, code.status, code.doctor, code.map)
|
|
285
285
|
```
|
|
286
286
|
|
|
287
287
|
If the command is not on `PATH`, or the server fails to start, install says so and exits non-zero
|
|
@@ -46,6 +46,7 @@ src/codeintel/server.py
|
|
|
46
46
|
src/codeintel/source_kind.py
|
|
47
47
|
src/codeintel/term.py
|
|
48
48
|
src/codeintel/verify.py
|
|
49
|
+
src/codeintel/wire_text.py
|
|
49
50
|
src/codeintel/commands/__init__.py
|
|
50
51
|
src/codeintel/commands/_common.py
|
|
51
52
|
src/codeintel/commands/c4.py
|
|
@@ -128,4 +129,5 @@ tests/test_term.py
|
|
|
128
129
|
tests/test_treesitter.py
|
|
129
130
|
tests/test_verify.py
|
|
130
131
|
tests/test_verify_call.py
|
|
131
|
-
tests/test_version_skew.py
|
|
132
|
+
tests/test_version_skew.py
|
|
133
|
+
tests/test_wire_text.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.21.0"
|
|
@@ -27,6 +27,7 @@ import threading
|
|
|
27
27
|
import time
|
|
28
28
|
from typing import Any
|
|
29
29
|
|
|
30
|
+
from codeintel import wire_text
|
|
30
31
|
from codeintel.outcome import Missing
|
|
31
32
|
|
|
32
33
|
|
|
@@ -162,18 +163,7 @@ class BackendClient:
|
|
|
162
163
|
)
|
|
163
164
|
if result.returncode != 0:
|
|
164
165
|
return self._FAIL # unsupported / error → let the raw-JSON fallback try
|
|
165
|
-
|
|
166
|
-
return json.loads(result.stdout)
|
|
167
|
-
except ValueError:
|
|
168
|
-
# The backend RAN and exited 0 — it simply did not answer in JSON. That is a
|
|
169
|
-
# dialect mismatch, not a failure, and it must not be folded into the same `None`
|
|
170
|
-
# as a crash: `codebase-memory-mcp` 0.10.x replaced the `{columns, rows}` payload
|
|
171
|
-
# this provider parses with a compact human-readable text format, so every op that
|
|
172
|
-
# is not `list_projects` (still JSON) silently returned "not in graph index" — on a
|
|
173
|
-
# repository that was fully indexed. Distinguishing it is what lets the caller say
|
|
174
|
-
# so instead of sending the user to re-index for the third time.
|
|
175
|
-
self._saw_unparsable = True
|
|
176
|
-
return self._UNPARSABLE
|
|
166
|
+
return self._decode(method, result.stdout)
|
|
177
167
|
except Exception:
|
|
178
168
|
return self._FAIL
|
|
179
169
|
|
|
@@ -190,14 +180,36 @@ class BackendClient:
|
|
|
190
180
|
)
|
|
191
181
|
if result.returncode != 0:
|
|
192
182
|
return self._FAIL
|
|
193
|
-
|
|
194
|
-
return json.loads(result.stdout)
|
|
195
|
-
except ValueError:
|
|
196
|
-
self._saw_unparsable = True
|
|
197
|
-
return self._UNPARSABLE
|
|
183
|
+
return self._decode(method, result.stdout)
|
|
198
184
|
except Exception:
|
|
199
185
|
return self._FAIL
|
|
200
186
|
|
|
187
|
+
def _decode(self, method: str, stdout: bytes) -> Any:
|
|
188
|
+
"""One backend reply, in whichever dialect the installed backend speaks.
|
|
189
|
+
|
|
190
|
+
Two are supported. 0.9.x answers in JSON. 0.10.x answers all but `list_projects` in a
|
|
191
|
+
compact human-readable text layout — passing `--json` does not undo that, it only wraps the
|
|
192
|
+
same text in an MCP envelope — and `wire_text` translates it back into the 0.9.x-shaped
|
|
193
|
+
dict every caller above this line already parses. Neither the ops nor the renderers learn
|
|
194
|
+
that a second dialect exists.
|
|
195
|
+
|
|
196
|
+
The ordering matters: JSON is tried first, so a 0.9.x backend never pays for the text path
|
|
197
|
+
and never risks a misparse. And the text path must still be able to FAIL — a reply that is
|
|
198
|
+
no longer in a shape this release understands has to reach `_UNPARSABLE`, because the
|
|
199
|
+
"your backend and this release do not agree on a wire format" answer is the only thing that
|
|
200
|
+
made the 0.9→0.10 break diagnosable instead of looking like an unindexed repository. A
|
|
201
|
+
parser that guessed rather than refused would take that away.
|
|
202
|
+
"""
|
|
203
|
+
try:
|
|
204
|
+
return json.loads(stdout)
|
|
205
|
+
except ValueError:
|
|
206
|
+
pass
|
|
207
|
+
parsed = wire_text.parse(method, stdout.decode("utf-8", "replace"))
|
|
208
|
+
if parsed is not None:
|
|
209
|
+
return parsed
|
|
210
|
+
self._saw_unparsable = True
|
|
211
|
+
return self._UNPARSABLE
|
|
212
|
+
|
|
201
213
|
def _clear_failure(self) -> None:
|
|
202
214
|
"""Reset the per-query failure record.
|
|
203
215
|
|
|
@@ -258,13 +258,14 @@ def _collapse_repeats(label: str) -> str:
|
|
|
258
258
|
# with a compact human-readable text format; `list_projects` stayed JSON, so project resolution and
|
|
259
259
|
# `doctor` still work while EVERY other op silently returns nothing. That combination is the worst
|
|
260
260
|
# possible: the tool looks healthy and answers "not in the graph index" about a fully indexed repo.
|
|
261
|
-
_SUPPORTED_BACKEND = "0.9.x"
|
|
261
|
+
_SUPPORTED_BACKEND = "0.9.x and 0.10.x"
|
|
262
262
|
_INCOMPATIBLE_HINT = (
|
|
263
263
|
"the graph backend returned a response this release cannot parse — codebase-memory-mcp "
|
|
264
|
-
f"{_SUPPORTED_BACKEND} answers
|
|
265
|
-
"
|
|
266
|
-
"
|
|
267
|
-
"
|
|
264
|
+
f"{_SUPPORTED_BACKEND} are both understood (0.9.x answers in JSON rows, 0.10.x in a text "
|
|
265
|
+
"layout this release reads), so this is a THIRD shape: most likely a backend newer than this "
|
|
266
|
+
"codeintel. Check for a newer codeintel, or pin a known-good backend (pip/uv: "
|
|
267
|
+
"`pip install 'codebase-memory-mcp==0.10.*'`; standalone binary: re-install a 0.10.x build). "
|
|
268
|
+
"This is NOT a statement about whether your repository is indexed."
|
|
268
269
|
)
|
|
269
270
|
|
|
270
271
|
|
|
@@ -733,19 +734,18 @@ class GraphProvider:
|
|
|
733
734
|
if self._probe_wire_format(self._any_project_name(raw)) is False:
|
|
734
735
|
return {
|
|
735
736
|
"installed": True, "runnable": False, "repo_indexed": False, "project": None,
|
|
736
|
-
"detail": f"incompatible codebase-memory-mcp — this release
|
|
737
|
-
f"{_SUPPORTED_BACKEND},
|
|
738
|
-
f"
|
|
739
|
-
f"project resolution returns nothing",
|
|
737
|
+
"detail": f"incompatible codebase-memory-mcp — this release speaks "
|
|
738
|
+
f"{_SUPPORTED_BACKEND}, and the installed backend answers in neither, "
|
|
739
|
+
f"so every graph op except project resolution returns nothing",
|
|
740
740
|
# Two install shapes exist and only one takes a pip command: the PyPI launcher, and
|
|
741
741
|
# a standalone native binary that self-manages. Naming only pip left the binary
|
|
742
742
|
# users — including this project's own maintainer — with an instruction they could
|
|
743
743
|
# not run, which is the failure mode this whole check exists to avoid.
|
|
744
|
-
"remediation": "
|
|
745
|
-
"
|
|
746
|
-
"
|
|
747
|
-
"memory-mcp
|
|
748
|
-
"
|
|
744
|
+
"remediation": "upgrade codeintel first — a backend newer than this release is "
|
|
745
|
+
"the usual cause. If that does not resolve it, pin a known-good "
|
|
746
|
+
"backend: pip/uv installs `pip install "
|
|
747
|
+
"'codebase-memory-mcp==0.10.*'`; standalone binary: re-install a "
|
|
748
|
+
"0.10.x build for your platform.",
|
|
749
749
|
}
|
|
750
750
|
resolution = self._match_project(raw, project_root)
|
|
751
751
|
if resolution is None:
|
|
@@ -1824,7 +1824,13 @@ class GraphProvider:
|
|
|
1824
1824
|
saw_any = False
|
|
1825
1825
|
for label in ("Function", "Method"):
|
|
1826
1826
|
got = self._search_symbols(
|
|
1827
|
-
|
|
1827
|
+
# `fields` asks 0.10.x for the per-node metrics this op RANKS on. They are core
|
|
1828
|
+
# columns in 0.9.x and optional ones in 0.10.x, where omitting them yields rows
|
|
1829
|
+
# whose complexity is uniformly zero — a hotspots list sorted by nothing.
|
|
1830
|
+
# 0.9.x ignores the key, so one payload serves both.
|
|
1831
|
+
{"label": label, "min_degree": 1, "limit": 2000,
|
|
1832
|
+
"fields": ["complexity", "cognitive", "is_test"]},
|
|
1833
|
+
project, timeout_ms,
|
|
1828
1834
|
)
|
|
1829
1835
|
if got is None:
|
|
1830
1836
|
continue
|
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
"""Reader for codebase-memory-mcp 0.10.x's human-readable reply format.
|
|
2
|
+
|
|
3
|
+
0.9.x answered every CLI call in JSON. 0.10.x replaced that with a compact text layout for all but
|
|
4
|
+
`list_projects`, so a provider written against `{"columns": [...], "rows": [...]}` gets back
|
|
5
|
+
something it cannot read and — before this module — reported a fully indexed repository as "not in
|
|
6
|
+
the graph index". Passing `--json` does not undo it: that flag wraps the SAME text in an MCP
|
|
7
|
+
envelope (`{"content":[{"type":"text","text":"rows: 2 …"}]}`), so the structured rows are genuinely
|
|
8
|
+
gone rather than hidden behind a switch.
|
|
9
|
+
|
|
10
|
+
This module translates that text back into the 0.9.x-shaped dicts the rest of the provider already
|
|
11
|
+
parses, so exactly one layer knows two dialects exist and no op above the transport changes.
|
|
12
|
+
|
|
13
|
+
**The format is not a contract.** It is human-readable output, and it can change in a patch release
|
|
14
|
+
without anything announcing it. So every function here returns ``None`` rather than a half-filled
|
|
15
|
+
dict the moment the text stops matching what it expects, and the caller keeps its existing
|
|
16
|
+
"backend-incompatible" safe-null for that case. A wrong answer assembled from a format we no longer
|
|
17
|
+
understand is far worse than the honest refusal this replaces — that refusal is what made the
|
|
18
|
+
0.9→0.10 break diagnosable in the first place.
|
|
19
|
+
|
|
20
|
+
The grammar, pinned against real captures from 0.10.8:
|
|
21
|
+
|
|
22
|
+
scalar ``key: value``
|
|
23
|
+
list section ``key: N`` followed by two-space-indented bare lines
|
|
24
|
+
row section ``key: N (cols: c1 c2 …)`` — or ``(rows: …)``; the count may be absent
|
|
25
|
+
group line ``<qualified-prefix> (<file path>):`` introducing the rows beneath it, where
|
|
26
|
+
each row's qualified name is ``prefix + "." + name`` (the header says so)
|
|
27
|
+
row two-space indent, values separated by spaces, Go ``%q``-quoted when the value
|
|
28
|
+
contains a space or a quote, bare otherwise
|
|
29
|
+
null a single ``-``
|
|
30
|
+
|
|
31
|
+
The `-` for null is the one genuine ambiguity: a value that is *literally* a hyphen is
|
|
32
|
+
indistinguishable from an absent one. Nothing in the graph's own vocabulary (identifiers, paths,
|
|
33
|
+
labels, line ranges) is a bare hyphen, so this is accepted rather than worked around, and recorded
|
|
34
|
+
here so the next reader does not have to rediscover it.
|
|
35
|
+
"""
|
|
36
|
+
from __future__ import annotations
|
|
37
|
+
|
|
38
|
+
import re
|
|
39
|
+
from typing import Any
|
|
40
|
+
|
|
41
|
+
# `key: N (cols: a b c)` / `(rows: a b c; qn = group prefix + "." + name)`. The count is optional
|
|
42
|
+
# (`impacted_modules:` ships without one). Greedy to the LAST `)` on the line, because column names
|
|
43
|
+
# are Cypher expressions carrying their own parens — `labels(a)`, `type(c)` — and stopping at the
|
|
44
|
+
# first one truncated the name and silently dropped every column after it. Anything past the first
|
|
45
|
+
# `;` inside the parens is prose for a human and is discarded where the group is read.
|
|
46
|
+
_SECTION_RE = re.compile(r"^(?P<key>[a-z_]+):\s*(?P<count>\d+)?\s*\((?:cols|rows):\s*(?P<cols>.*)\)\s*$")
|
|
47
|
+
_SCALAR_RE = re.compile(r"^(?P<key>[a-z_]+):\s*(?P<value>.*)$")
|
|
48
|
+
# `<qualified.name> (<path>):` — a group header. The path may contain spaces, so anchor on the
|
|
49
|
+
# trailing `):` rather than splitting on whitespace.
|
|
50
|
+
_GROUP_RE = re.compile(r"^(?P<prefix>\S+)\s+\((?P<file>.+)\):$")
|
|
51
|
+
_INDENT = " "
|
|
52
|
+
|
|
53
|
+
_NULL = "-"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _unquote(tok: str) -> str:
|
|
57
|
+
"""A Go ``%q`` literal back to its value; a bare token unchanged."""
|
|
58
|
+
if len(tok) >= 2 and tok[0] == '"' and tok[-1] == '"':
|
|
59
|
+
body, out, i = tok[1:-1], [], 0
|
|
60
|
+
while i < len(body):
|
|
61
|
+
ch = body[i]
|
|
62
|
+
if ch == "\\" and i + 1 < len(body):
|
|
63
|
+
nxt = body[i + 1]
|
|
64
|
+
out.append({"n": "\n", "t": "\t", "r": "\r", '"': '"', "\\": "\\"}.get(nxt, nxt))
|
|
65
|
+
i += 2
|
|
66
|
+
continue
|
|
67
|
+
out.append(ch)
|
|
68
|
+
i += 1
|
|
69
|
+
return "".join(out)
|
|
70
|
+
return tok
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def split_row(line: str) -> list[str]:
|
|
74
|
+
"""Split one row into its values, honouring ``%q`` quoting.
|
|
75
|
+
|
|
76
|
+
Not `shlex`: that treats a single quote as an opener, and these payloads carry apostrophes
|
|
77
|
+
inside quoted prose (`"Prompts — all six are done"` is fine, but a docstring with `don't` is
|
|
78
|
+
not). Only the double-quote form the backend actually emits opens a token here.
|
|
79
|
+
"""
|
|
80
|
+
out: list[str] = []
|
|
81
|
+
i, n = 0, len(line)
|
|
82
|
+
while i < n:
|
|
83
|
+
while i < n and line[i] == " ":
|
|
84
|
+
i += 1
|
|
85
|
+
if i >= n:
|
|
86
|
+
break
|
|
87
|
+
if line[i] == '"':
|
|
88
|
+
j = i + 1
|
|
89
|
+
while j < n:
|
|
90
|
+
if line[j] == "\\":
|
|
91
|
+
j += 2
|
|
92
|
+
continue
|
|
93
|
+
if line[j] == '"':
|
|
94
|
+
break
|
|
95
|
+
j += 1
|
|
96
|
+
out.append(_unquote(line[i:min(j + 1, n)]))
|
|
97
|
+
i = j + 1
|
|
98
|
+
else:
|
|
99
|
+
j = line.index(" ", i) if " " in line[i:] else n
|
|
100
|
+
out.append(_unquote(line[i:j]))
|
|
101
|
+
i = j
|
|
102
|
+
return out
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _cell(value: str) -> Any:
|
|
106
|
+
"""One parsed value, with the backend's null marker mapped to ``None``."""
|
|
107
|
+
return None if value == _NULL else value
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def is_text_dialect(text: str) -> bool:
|
|
111
|
+
"""Whether *text* looks like a 0.10.x reply at all.
|
|
112
|
+
|
|
113
|
+
Deliberately weak — it only has to separate "the backend answered in the newer layout" from
|
|
114
|
+
"the backend produced something else entirely" (an error page, a usage banner, an empty
|
|
115
|
+
stream). Deciding whether the specific reply is USABLE is each parser's job below, and that
|
|
116
|
+
decision is the one allowed to fail.
|
|
117
|
+
"""
|
|
118
|
+
for line in text.splitlines():
|
|
119
|
+
line = line.strip()
|
|
120
|
+
if not line:
|
|
121
|
+
continue
|
|
122
|
+
return bool(_SCALAR_RE.match(line) or _SECTION_RE.match(line))
|
|
123
|
+
return False
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class _Doc:
|
|
127
|
+
"""One parsed reply: its top-level scalars, its list sections and its row sections."""
|
|
128
|
+
|
|
129
|
+
def __init__(self, text: str) -> None:
|
|
130
|
+
self.scalars: dict[str, str] = {}
|
|
131
|
+
self.lists: dict[str, list[str]] = {}
|
|
132
|
+
self.rows: dict[str, list[dict]] = {}
|
|
133
|
+
self._parse(text.splitlines())
|
|
134
|
+
|
|
135
|
+
def _parse(self, lines: list[str]) -> None:
|
|
136
|
+
i, n = 0, len(lines)
|
|
137
|
+
while i < n:
|
|
138
|
+
line = lines[i]
|
|
139
|
+
if not line.strip():
|
|
140
|
+
i += 1
|
|
141
|
+
continue
|
|
142
|
+
sec = _SECTION_RE.match(line)
|
|
143
|
+
if sec:
|
|
144
|
+
cols = sec.group("cols").split(";")[0].split()
|
|
145
|
+
i = self._read_rows(lines, i + 1, sec.group("key"), cols)
|
|
146
|
+
continue
|
|
147
|
+
scalar = _SCALAR_RE.match(line)
|
|
148
|
+
if scalar and not line.startswith(_INDENT):
|
|
149
|
+
key, value = scalar.group("key"), scalar.group("value").strip()
|
|
150
|
+
# `changed_files: 2` followed by indented bare lines is a LIST, not a scalar. The
|
|
151
|
+
# count alone is never what a caller wants, so the payload wins and the count is
|
|
152
|
+
# dropped — it is recoverable from the list, and keeping both invites them to
|
|
153
|
+
# disagree.
|
|
154
|
+
if i + 1 < n and lines[i + 1].startswith(_INDENT) and value.isdigit():
|
|
155
|
+
i = self._read_list(lines, i + 1, key)
|
|
156
|
+
continue
|
|
157
|
+
self.scalars[key] = _unquote(value)
|
|
158
|
+
i += 1
|
|
159
|
+
|
|
160
|
+
def _read_list(self, lines: list[str], i: int, key: str) -> int:
|
|
161
|
+
out: list[str] = []
|
|
162
|
+
while i < len(lines) and lines[i].startswith(_INDENT):
|
|
163
|
+
out.append(_unquote(lines[i].strip()))
|
|
164
|
+
i += 1
|
|
165
|
+
self.lists[key] = out
|
|
166
|
+
return i
|
|
167
|
+
|
|
168
|
+
def _read_rows(self, lines: list[str], i: int, key: str, cols: list[str]) -> int:
|
|
169
|
+
"""Rows under one section header, flattening any group lines into per-row fields.
|
|
170
|
+
|
|
171
|
+
A group line carries the qualified-name prefix and the file path for every row beneath it —
|
|
172
|
+
the header states the rule (`qn = group prefix + "." + name`) — so each row is given a
|
|
173
|
+
`_qn` and `_file` here rather than leaving every adapter below to re-derive it and one of
|
|
174
|
+
them to forget.
|
|
175
|
+
"""
|
|
176
|
+
out: list[dict] = []
|
|
177
|
+
prefix = file = ""
|
|
178
|
+
while i < len(lines):
|
|
179
|
+
line = lines[i]
|
|
180
|
+
if not line.strip():
|
|
181
|
+
i += 1
|
|
182
|
+
continue
|
|
183
|
+
if not line.startswith(_INDENT):
|
|
184
|
+
group = _GROUP_RE.match(line.strip())
|
|
185
|
+
if group:
|
|
186
|
+
prefix, file = group.group("prefix"), group.group("file")
|
|
187
|
+
i += 1
|
|
188
|
+
continue
|
|
189
|
+
break # a new top-level key ends this section
|
|
190
|
+
values = split_row(line.strip())
|
|
191
|
+
# NOT strict: a row with fewer values than columns is a format drift, and raising
|
|
192
|
+
# here would escape into a transport whose whole contract is that it never does. The
|
|
193
|
+
# short row simply yields fewer keys, and the adapter's required-key check decides.
|
|
194
|
+
row: dict[str, Any] = {c: _cell(v)
|
|
195
|
+
for c, v in zip(cols, values, strict=False)}
|
|
196
|
+
name = row.get(cols[0]) if cols else None
|
|
197
|
+
row["_qn"] = f"{prefix}.{name}" if prefix and name else (name or "")
|
|
198
|
+
row["_file"] = file
|
|
199
|
+
out.append(row)
|
|
200
|
+
i += 1
|
|
201
|
+
self.rows[key] = out
|
|
202
|
+
return i
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _int(value: Any, default: int = 0) -> int:
|
|
206
|
+
try:
|
|
207
|
+
return int(str(value))
|
|
208
|
+
except (TypeError, ValueError):
|
|
209
|
+
return default
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _span(value: Any) -> tuple[int, int]:
|
|
213
|
+
"""`"60-111"` → `(60, 111)`; a bare number → `(n, n)`; anything else → `(0, 0)`."""
|
|
214
|
+
text = str(value or "")
|
|
215
|
+
if "-" in text:
|
|
216
|
+
head, _, tail = text.partition("-")
|
|
217
|
+
return _int(head), _int(tail)
|
|
218
|
+
return _int(text), _int(text)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _query_graph(doc: _Doc, text: str) -> dict | None:
|
|
222
|
+
"""`rows: N (cols: …)` → the 0.9.x `{"columns": [...], "rows": [[…]]}` payload."""
|
|
223
|
+
rows = doc.rows.get("rows")
|
|
224
|
+
if rows is None:
|
|
225
|
+
return None
|
|
226
|
+
cols = [c for c in (rows[0].keys() if rows else []) if not c.startswith("_")]
|
|
227
|
+
if not cols:
|
|
228
|
+
# A zero-row answer still carries its column list in the header, and an empty result is a
|
|
229
|
+
# real answer — returning None here would turn "nothing matched" back into "backend
|
|
230
|
+
# unreadable", which is the whole confusion this module exists to end.
|
|
231
|
+
header = next((line for line in text.splitlines() if _SECTION_RE.match(line)), "")
|
|
232
|
+
match = _SECTION_RE.match(header)
|
|
233
|
+
cols = match.group("cols").split(";")[0].split() if match else []
|
|
234
|
+
return {"columns": cols,
|
|
235
|
+
"rows": [[r.get(c) for c in cols] for r in rows],
|
|
236
|
+
"total": _int(doc.scalars.get("total"), len(rows))}
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _search_graph(doc: _Doc) -> dict | None:
|
|
240
|
+
"""`results: N (rows: name label lines in out)` → `{"results": [ … ]}`."""
|
|
241
|
+
rows = doc.rows.get("results")
|
|
242
|
+
if rows is None:
|
|
243
|
+
return None
|
|
244
|
+
out = []
|
|
245
|
+
for r in rows:
|
|
246
|
+
start, end = _span(r.get("lines"))
|
|
247
|
+
out.append({
|
|
248
|
+
"name": r.get("name"), "qualified_name": r.get("_qn"),
|
|
249
|
+
"label": r.get("label"), "file_path": r.get("_file"),
|
|
250
|
+
"in_degree": _int(r.get("in")), "out_degree": _int(r.get("out")),
|
|
251
|
+
# `complexity`/`cognitive`/`is_test` are NOT core columns in 0.10.x — they arrive only
|
|
252
|
+
# when the caller asks for them through `fields`. `hotspots` does (it ranks on
|
|
253
|
+
# complexity, and `is_test` is how it drops spec files), so they are read by their real
|
|
254
|
+
# names here and default to 0/False when a caller did not request them.
|
|
255
|
+
"complexity": _int(r.get("complexity")), "cognitive": _int(r.get("cognitive")),
|
|
256
|
+
"is_test": r.get("is_test") == "true",
|
|
257
|
+
"lines": max(0, end - start + 1) if end >= start else 0,
|
|
258
|
+
})
|
|
259
|
+
return {"total": _int(doc.scalars.get("total"), len(out)), "results": out,
|
|
260
|
+
"has_more": doc.scalars.get("has_more") == "true"}
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _search_code(doc: _Doc) -> dict | None:
|
|
264
|
+
"""`results: N (cols: qn label file lines matches in out)` → `{"results": [ … ]}`."""
|
|
265
|
+
rows = doc.rows.get("results")
|
|
266
|
+
if rows is None:
|
|
267
|
+
return None
|
|
268
|
+
out = []
|
|
269
|
+
for r in rows:
|
|
270
|
+
start, end = _span(r.get("lines"))
|
|
271
|
+
qn = r.get("qn") or r.get("_qn") or ""
|
|
272
|
+
raw = str(r.get("matches") or "")
|
|
273
|
+
out.append({
|
|
274
|
+
"node": qn.rsplit(".", 1)[-1], "qualified_name": qn,
|
|
275
|
+
"label": r.get("label"), "file": r.get("file"),
|
|
276
|
+
"start_line": start, "end_line": end,
|
|
277
|
+
"match_lines": [_int(p) for p in raw.split(";") if p.strip()],
|
|
278
|
+
"in_degree": _int(r.get("in")), "out_degree": _int(r.get("out")),
|
|
279
|
+
})
|
|
280
|
+
return {"results": out, "total_results": _int(doc.scalars.get("total_results"), len(out))}
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _trace_path(doc: _Doc) -> dict | None:
|
|
284
|
+
"""`callees:`/`callers:` sections → `{"function", "callees": [...], "callers": [...]}`."""
|
|
285
|
+
if "callees" not in doc.rows and "callers" not in doc.rows:
|
|
286
|
+
return None
|
|
287
|
+
|
|
288
|
+
def hops(key: str) -> list[dict]:
|
|
289
|
+
out = []
|
|
290
|
+
for r in doc.rows.get(key, []):
|
|
291
|
+
qn = r.get("qn") or r.get("_qn") or ""
|
|
292
|
+
out.append({"name": qn.rsplit(".", 1)[-1], "qualified_name": qn,
|
|
293
|
+
"hop": _int(r.get("hop")), "risk": r.get("risk")})
|
|
294
|
+
return out
|
|
295
|
+
|
|
296
|
+
return {"function": doc.scalars.get("function", ""),
|
|
297
|
+
"direction": doc.scalars.get("direction", ""),
|
|
298
|
+
"mode": doc.scalars.get("mode", ""),
|
|
299
|
+
"callees": hops("callees"), "callers": hops("callers")}
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _get_architecture(doc: _Doc) -> dict | None:
|
|
303
|
+
"""Scalars plus the `node_labels` / `edge_types` / `languages` sections."""
|
|
304
|
+
if "total_nodes" not in doc.scalars and "node_labels" not in doc.rows:
|
|
305
|
+
return None
|
|
306
|
+
def pairs(key: str, name_key: str, count_key: str, out_name: str, out_count: str) -> list[dict]:
|
|
307
|
+
return [{out_name: r.get(name_key), out_count: _int(r.get(count_key))}
|
|
308
|
+
for r in doc.rows.get(key, [])]
|
|
309
|
+
return {
|
|
310
|
+
"project": doc.scalars.get("project", ""),
|
|
311
|
+
"total_nodes": _int(doc.scalars.get("total_nodes")),
|
|
312
|
+
"total_edges": _int(doc.scalars.get("total_edges")),
|
|
313
|
+
"node_labels": pairs("node_labels", "label", "count", "label", "count"),
|
|
314
|
+
"edge_types": pairs("edge_types", "type", "count", "type", "count"),
|
|
315
|
+
"languages": pairs("languages", "language", "files", "language", "file_count"),
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _detect_changes(doc: _Doc) -> dict | None:
|
|
320
|
+
"""`changed_files` list plus the grouped `impacted` rows.
|
|
321
|
+
|
|
322
|
+
0.10.x's `impacted` is already a transitive walk (it carries a `hop`), where 0.9.x returned only
|
|
323
|
+
the symbols defined in the changed files. Both are handed back under the key the provider
|
|
324
|
+
already reads; the extra reach is a straight improvement and needs no translation.
|
|
325
|
+
"""
|
|
326
|
+
if "changed_files" not in doc.lists and "changed_files" not in doc.scalars:
|
|
327
|
+
return None
|
|
328
|
+
impacted = [
|
|
329
|
+
{"name": r.get("name"), "qualified_name": r.get("_qn"), "file_path": r.get("_file"),
|
|
330
|
+
"hop": _int(r.get("hop"))}
|
|
331
|
+
for r in doc.rows.get("impacted", [])
|
|
332
|
+
]
|
|
333
|
+
files = doc.lists.get("changed_files", [])
|
|
334
|
+
return {"changed_files": files, "changed_count": len(files),
|
|
335
|
+
"impacted_symbols": impacted, "depth": _int(doc.scalars.get("depth"), 2)}
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
_PARSERS = {
|
|
339
|
+
"query_graph": lambda doc, text: _query_graph(doc, text),
|
|
340
|
+
"search_graph": lambda doc, text: _search_graph(doc),
|
|
341
|
+
"search_code": lambda doc, text: _search_code(doc),
|
|
342
|
+
"trace_path": lambda doc, text: _trace_path(doc),
|
|
343
|
+
"get_architecture": lambda doc, text: _get_architecture(doc),
|
|
344
|
+
"detect_changes": lambda doc, text: _detect_changes(doc),
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def parse(method: str, text: str) -> dict | None:
|
|
349
|
+
"""A 0.10.x text reply as the 0.9.x-shaped dict *method*'s caller expects.
|
|
350
|
+
|
|
351
|
+
``None`` for anything this module cannot vouch for — an unknown method, a reply that is not in
|
|
352
|
+
the text layout, a shape that no longer matches, or any exception at all. The caller treats
|
|
353
|
+
that exactly as it treated an unreadable reply before this module existed, which keeps the
|
|
354
|
+
honest "your backend and this release do not agree" answer available as the floor.
|
|
355
|
+
"""
|
|
356
|
+
try:
|
|
357
|
+
handler = _PARSERS.get(method)
|
|
358
|
+
if handler is None or not text or not is_text_dialect(text):
|
|
359
|
+
return None
|
|
360
|
+
return handler(_Doc(text), text)
|
|
361
|
+
except Exception:
|
|
362
|
+
return None
|
|
@@ -721,21 +721,49 @@ def _provider_speaking(stdout_bytes):
|
|
|
721
721
|
return gp, _fake
|
|
722
722
|
|
|
723
723
|
|
|
724
|
-
def
|
|
725
|
-
"""The exact 0.10.x
|
|
726
|
-
|
|
724
|
+
def test_a_text_speaking_backend_is_now_understood(monkeypatch):
|
|
725
|
+
"""The exact 0.10.x reply shape — exit 0, a human-readable body where JSON was promised.
|
|
726
|
+
|
|
727
|
+
This used to be the incompatibility case, and it is now a supported dialect: `wire_text`
|
|
728
|
+
translates the layout back into the rows every op above the transport already parses. The
|
|
729
|
+
assertions below are deliberately about the ANSWER, not about the parse: a translation that
|
|
730
|
+
produced a well-formed envelope with the wrong rows in it would be worse than the refusal it
|
|
731
|
+
replaced."""
|
|
732
|
+
text = (b'rows: 2 (cols: a.name a.qualified_name a.file_path labels(a) type(c))\n'
|
|
733
|
+
b' bar pkg.bar bar.py "[\\"Function\\"]" CALLS\n'
|
|
734
|
+
b' baz pkg.baz baz.py "[\\"Function\\"]" CALLS\n'
|
|
735
|
+
b'total: 2\n')
|
|
727
736
|
gp, fake = _provider_speaking(text)
|
|
728
737
|
monkeypatch.setattr("codeintel.providers.graph.subprocess.run", fake)
|
|
729
738
|
gp._lookup_project = lambda root: ProjectLookup( # type: ignore[method-assign]
|
|
730
739
|
ProjectResolution(name="proj", matched_root="/repo", scope="exact"), "ok")
|
|
731
740
|
|
|
741
|
+
res = gp.build_result("callers", "thing", [], 5000, "/repo")
|
|
742
|
+
assert res["ok"] is True
|
|
743
|
+
assert res["result"] is not None, res
|
|
744
|
+
assert res.get("reason") is None
|
|
745
|
+
assert "pkg.bar" in res["result"] and "pkg.baz" in res["result"]
|
|
746
|
+
assert "(2)" in res["result"]
|
|
747
|
+
assert gp._saw_unparsable is False
|
|
748
|
+
|
|
749
|
+
|
|
750
|
+
def test_a_third_dialect_is_still_reported_as_incompatible_not_as_missing_data(monkeypatch):
|
|
751
|
+
"""The protection the test above used to provide, kept for the shape nobody has parsed yet.
|
|
752
|
+
|
|
753
|
+
Supporting a second dialect must not cost the honest refusal for a third. A reply this release
|
|
754
|
+
cannot read has to stay distinguishable from "your symbol is not in the index" — that
|
|
755
|
+
distinction is the only reason the 0.9→0.10 break was diagnosable rather than looking like an
|
|
756
|
+
unindexed repository, and the next wire change will look exactly the same from here."""
|
|
757
|
+
gp, fake = _provider_speaking(b"<html><body>gateway timeout</body></html>")
|
|
758
|
+
monkeypatch.setattr("codeintel.providers.graph.subprocess.run", fake)
|
|
759
|
+
gp._lookup_project = lambda root: ProjectLookup( # type: ignore[method-assign]
|
|
760
|
+
ProjectResolution(name="proj", matched_root="/repo", scope="exact"), "ok")
|
|
761
|
+
|
|
732
762
|
res = gp.build_result("callers", "thing", [], 5000, "/repo")
|
|
733
763
|
assert res["ok"] is True
|
|
734
764
|
assert res["result"] is None
|
|
735
|
-
# The critical assertion: it must NOT claim the symbol is absent from the index.
|
|
736
765
|
assert res["reason"] == "backend-incompatible"
|
|
737
766
|
assert res["reason"] != "not-in-graph"
|
|
738
|
-
assert "0.9" in res["hint"]
|
|
739
767
|
assert "NOT a statement about whether your repository is indexed" in res["hint"]
|
|
740
768
|
|
|
741
769
|
|
|
@@ -482,8 +482,9 @@ def test_live_callers_of_safe_null_result():
|
|
|
482
482
|
# test_graph_provider.py and the doctor probe) — but it makes this end-to-end answer test
|
|
483
483
|
# meaningless, so name it rather than failing with a confusing "no callers".
|
|
484
484
|
if p._probe_wire_format(p._any_project_name(p._run("list_projects", {}, 30000))) is False:
|
|
485
|
-
pytest.skip("installed codebase-memory-mcp speaks
|
|
486
|
-
"
|
|
485
|
+
pytest.skip("the installed codebase-memory-mcp speaks a dialect this release cannot "
|
|
486
|
+
"parse — 0.9.x JSON and 0.10.x text are both supported, so this is a third "
|
|
487
|
+
"shape and an end-to-end answer test here would be meaningless")
|
|
487
488
|
if p._resolve_project(REPO_ROOT) is None:
|
|
488
489
|
pytest.skip("codeintel project not indexed in this environment")
|
|
489
490
|
|