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.
Files changed (136) hide show
  1. {codecortex-0.20.0/src/codecortex.egg-info → codecortex-0.21.0}/PKG-INFO +2 -2
  2. {codecortex-0.20.0 → codecortex-0.21.0}/README.md +1 -1
  3. {codecortex-0.20.0 → codecortex-0.21.0/src/codecortex.egg-info}/PKG-INFO +2 -2
  4. {codecortex-0.20.0 → codecortex-0.21.0}/src/codecortex.egg-info/SOURCES.txt +3 -1
  5. codecortex-0.21.0/src/codeintel/__init__.py +1 -0
  6. {codecortex-0.20.0 → codecortex-0.21.0}/src/codeintel/graph_backend.py +29 -17
  7. {codecortex-0.20.0 → codecortex-0.21.0}/src/codeintel/providers/graph.py +21 -15
  8. codecortex-0.21.0/src/codeintel/wire_text.py +362 -0
  9. {codecortex-0.20.0 → codecortex-0.21.0}/tests/test_graph_provider.py +33 -5
  10. {codecortex-0.20.0 → codecortex-0.21.0}/tests/test_graph_real.py +3 -2
  11. codecortex-0.21.0/tests/test_wire_text.py +203 -0
  12. codecortex-0.20.0/src/codeintel/__init__.py +0 -1
  13. {codecortex-0.20.0 → codecortex-0.21.0}/LICENSE +0 -0
  14. {codecortex-0.20.0 → codecortex-0.21.0}/pyproject.toml +0 -0
  15. {codecortex-0.20.0 → codecortex-0.21.0}/setup.cfg +0 -0
  16. {codecortex-0.20.0 → codecortex-0.21.0}/src/codecortex.egg-info/dependency_links.txt +0 -0
  17. {codecortex-0.20.0 → codecortex-0.21.0}/src/codecortex.egg-info/entry_points.txt +0 -0
  18. {codecortex-0.20.0 → codecortex-0.21.0}/src/codecortex.egg-info/requires.txt +0 -0
  19. {codecortex-0.20.0 → codecortex-0.21.0}/src/codecortex.egg-info/top_level.txt +0 -0
  20. {codecortex-0.20.0 → codecortex-0.21.0}/src/codeintel/__main__.py +0 -0
  21. {codecortex-0.20.0 → codecortex-0.21.0}/src/codeintel/agent_prompt.py +0 -0
  22. {codecortex-0.20.0 → codecortex-0.21.0}/src/codeintel/auth.py +0 -0
  23. {codecortex-0.20.0 → codecortex-0.21.0}/src/codeintel/c4.py +0 -0
  24. {codecortex-0.20.0 → codecortex-0.21.0}/src/codeintel/cache.py +0 -0
  25. {codecortex-0.20.0 → codecortex-0.21.0}/src/codeintel/commands/__init__.py +0 -0
  26. {codecortex-0.20.0 → codecortex-0.21.0}/src/codeintel/commands/_common.py +0 -0
  27. {codecortex-0.20.0 → codecortex-0.21.0}/src/codeintel/commands/c4.py +0 -0
  28. {codecortex-0.20.0 → codecortex-0.21.0}/src/codeintel/commands/doctor.py +0 -0
  29. {codecortex-0.20.0 → codecortex-0.21.0}/src/codeintel/commands/gen_token.py +0 -0
  30. {codecortex-0.20.0 → codecortex-0.21.0}/src/codeintel/commands/graph.py +0 -0
  31. {codecortex-0.20.0 → codecortex-0.21.0}/src/codeintel/commands/index.py +0 -0
  32. {codecortex-0.20.0 → codecortex-0.21.0}/src/codeintel/commands/install.py +0 -0
  33. {codecortex-0.20.0 → codecortex-0.21.0}/src/codeintel/commands/map.py +0 -0
  34. {codecortex-0.20.0 → codecortex-0.21.0}/src/codeintel/commands/prompt.py +0 -0
  35. {codecortex-0.20.0 → codecortex-0.21.0}/src/codeintel/commands/query.py +0 -0
  36. {codecortex-0.20.0 → codecortex-0.21.0}/src/codeintel/commands/reset.py +0 -0
  37. {codecortex-0.20.0 → codecortex-0.21.0}/src/codeintel/commands/serve.py +0 -0
  38. {codecortex-0.20.0 → codecortex-0.21.0}/src/codeintel/commands/serve_http.py +0 -0
  39. {codecortex-0.20.0 → codecortex-0.21.0}/src/codeintel/commands/setup.py +0 -0
  40. {codecortex-0.20.0 → codecortex-0.21.0}/src/codeintel/commands/status.py +0 -0
  41. {codecortex-0.20.0 → codecortex-0.21.0}/src/codeintel/config.py +0 -0
  42. {codecortex-0.20.0 → codecortex-0.21.0}/src/codeintel/containment.py +0 -0
  43. {codecortex-0.20.0 → codecortex-0.21.0}/src/codeintel/doctor.py +0 -0
  44. {codecortex-0.20.0 → codecortex-0.21.0}/src/codeintel/gateway.py +0 -0
  45. {codecortex-0.20.0 → codecortex-0.21.0}/src/codeintel/graph_render.py +0 -0
  46. {codecortex-0.20.0 → codecortex-0.21.0}/src/codeintel/graph_resolution.py +0 -0
  47. {codecortex-0.20.0 → codecortex-0.21.0}/src/codeintel/grapher.py +0 -0
  48. {codecortex-0.20.0 → codecortex-0.21.0}/src/codeintel/http_server.py +0 -0
  49. {codecortex-0.20.0 → codecortex-0.21.0}/src/codeintel/indexer.py +0 -0
  50. {codecortex-0.20.0 → codecortex-0.21.0}/src/codeintel/injector.py +0 -0
  51. {codecortex-0.20.0 → codecortex-0.21.0}/src/codeintel/installer.py +0 -0
  52. {codecortex-0.20.0 → codecortex-0.21.0}/src/codeintel/loc.py +0 -0
  53. {codecortex-0.20.0 → codecortex-0.21.0}/src/codeintel/logconfig.py +0 -0
  54. {codecortex-0.20.0 → codecortex-0.21.0}/src/codeintel/mapper.py +0 -0
  55. {codecortex-0.20.0 → codecortex-0.21.0}/src/codeintel/metrics.py +0 -0
  56. {codecortex-0.20.0 → codecortex-0.21.0}/src/codeintel/onboarding.py +0 -0
  57. {codecortex-0.20.0 → codecortex-0.21.0}/src/codeintel/outcome.py +0 -0
  58. {codecortex-0.20.0 → codecortex-0.21.0}/src/codeintel/paths.py +0 -0
  59. {codecortex-0.20.0 → codecortex-0.21.0}/src/codeintel/policy.py +0 -0
  60. {codecortex-0.20.0 → codecortex-0.21.0}/src/codeintel/progress.py +0 -0
  61. {codecortex-0.20.0 → codecortex-0.21.0}/src/codeintel/provider.py +0 -0
  62. {codecortex-0.20.0 → codecortex-0.21.0}/src/codeintel/providers/__init__.py +0 -0
  63. {codecortex-0.20.0 → codecortex-0.21.0}/src/codeintel/providers/lsp.py +0 -0
  64. {codecortex-0.20.0 → codecortex-0.21.0}/src/codeintel/providers/none.py +0 -0
  65. {codecortex-0.20.0 → codecortex-0.21.0}/src/codeintel/providers/semantic.py +0 -0
  66. {codecortex-0.20.0 → codecortex-0.21.0}/src/codeintel/py.typed +0 -0
  67. {codecortex-0.20.0 → codecortex-0.21.0}/src/codeintel/query_ops.py +0 -0
  68. {codecortex-0.20.0 → codecortex-0.21.0}/src/codeintel/redact.py +0 -0
  69. {codecortex-0.20.0 → codecortex-0.21.0}/src/codeintel/reindexer.py +0 -0
  70. {codecortex-0.20.0 → codecortex-0.21.0}/src/codeintel/reset.py +0 -0
  71. {codecortex-0.20.0 → codecortex-0.21.0}/src/codeintel/searcher.py +0 -0
  72. {codecortex-0.20.0 → codecortex-0.21.0}/src/codeintel/semantic_db.py +0 -0
  73. {codecortex-0.20.0 → codecortex-0.21.0}/src/codeintel/server.py +0 -0
  74. {codecortex-0.20.0 → codecortex-0.21.0}/src/codeintel/source_kind.py +0 -0
  75. {codecortex-0.20.0 → codecortex-0.21.0}/src/codeintel/term.py +0 -0
  76. {codecortex-0.20.0 → codecortex-0.21.0}/src/codeintel/verify.py +0 -0
  77. {codecortex-0.20.0 → codecortex-0.21.0}/src/codeintel/viewer/__init__.py +0 -0
  78. {codecortex-0.20.0 → codecortex-0.21.0}/src/codeintel/viewer/graph_template.html +0 -0
  79. {codecortex-0.20.0 → codecortex-0.21.0}/tests/test_agent_prompt.py +0 -0
  80. {codecortex-0.20.0 → codecortex-0.21.0}/tests/test_backend_reaper.py +0 -0
  81. {codecortex-0.20.0 → codecortex-0.21.0}/tests/test_c4.py +0 -0
  82. {codecortex-0.20.0 → codecortex-0.21.0}/tests/test_c4_model.py +0 -0
  83. {codecortex-0.20.0 → codecortex-0.21.0}/tests/test_c4_output.py +0 -0
  84. {codecortex-0.20.0 → codecortex-0.21.0}/tests/test_cache.py +0 -0
  85. {codecortex-0.20.0 → codecortex-0.21.0}/tests/test_chunking.py +0 -0
  86. {codecortex-0.20.0 → codecortex-0.21.0}/tests/test_cli_commands.py +0 -0
  87. {codecortex-0.20.0 → codecortex-0.21.0}/tests/test_cli_help.py +0 -0
  88. {codecortex-0.20.0 → codecortex-0.21.0}/tests/test_cli_install_dry_run.py +0 -0
  89. {codecortex-0.20.0 → codecortex-0.21.0}/tests/test_cli_install_offer_injection.py +0 -0
  90. {codecortex-0.20.0 → codecortex-0.21.0}/tests/test_cold_process.py +0 -0
  91. {codecortex-0.20.0 → codecortex-0.21.0}/tests/test_config.py +0 -0
  92. {codecortex-0.20.0 → codecortex-0.21.0}/tests/test_corpus.py +0 -0
  93. {codecortex-0.20.0 → codecortex-0.21.0}/tests/test_docs_ci_claims.py +0 -0
  94. {codecortex-0.20.0 → codecortex-0.21.0}/tests/test_docs_deadcode_withdrawal.py +0 -0
  95. {codecortex-0.20.0 → codecortex-0.21.0}/tests/test_doctor.py +0 -0
  96. {codecortex-0.20.0 → codecortex-0.21.0}/tests/test_e2e.py +0 -0
  97. {codecortex-0.20.0 → codecortex-0.21.0}/tests/test_edge_confidence.py +0 -0
  98. {codecortex-0.20.0 → codecortex-0.21.0}/tests/test_engine_adoption.py +0 -0
  99. {codecortex-0.20.0 → codecortex-0.21.0}/tests/test_enterprise.py +0 -0
  100. {codecortex-0.20.0 → codecortex-0.21.0}/tests/test_gateway.py +0 -0
  101. {codecortex-0.20.0 → codecortex-0.21.0}/tests/test_graph_failure_population.py +0 -0
  102. {codecortex-0.20.0 → codecortex-0.21.0}/tests/test_graph_stdin.py +0 -0
  103. {codecortex-0.20.0 → codecortex-0.21.0}/tests/test_grapher.py +0 -0
  104. {codecortex-0.20.0 → codecortex-0.21.0}/tests/test_hardening.py +0 -0
  105. {codecortex-0.20.0 → codecortex-0.21.0}/tests/test_http_auth.py +0 -0
  106. {codecortex-0.20.0 → codecortex-0.21.0}/tests/test_http_server.py +0 -0
  107. {codecortex-0.20.0 → codecortex-0.21.0}/tests/test_incompleteness.py +0 -0
  108. {codecortex-0.20.0 → codecortex-0.21.0}/tests/test_index_progress.py +0 -0
  109. {codecortex-0.20.0 → codecortex-0.21.0}/tests/test_indexer_binary.py +0 -0
  110. {codecortex-0.20.0 → codecortex-0.21.0}/tests/test_installer.py +0 -0
  111. {codecortex-0.20.0 → codecortex-0.21.0}/tests/test_integration.py +0 -0
  112. {codecortex-0.20.0 → codecortex-0.21.0}/tests/test_loc_census.py +0 -0
  113. {codecortex-0.20.0 → codecortex-0.21.0}/tests/test_lsp_language_coverage.py +0 -0
  114. {codecortex-0.20.0 → codecortex-0.21.0}/tests/test_lsp_provider.py +0 -0
  115. {codecortex-0.20.0 → codecortex-0.21.0}/tests/test_lsp_real.py +0 -0
  116. {codecortex-0.20.0 → codecortex-0.21.0}/tests/test_mapper.py +0 -0
  117. {codecortex-0.20.0 → codecortex-0.21.0}/tests/test_mcp_handshake.py +0 -0
  118. {codecortex-0.20.0 → codecortex-0.21.0}/tests/test_mcp_server.py +0 -0
  119. {codecortex-0.20.0 → codecortex-0.21.0}/tests/test_model_dimension.py +0 -0
  120. {codecortex-0.20.0 → codecortex-0.21.0}/tests/test_never_raise.py +0 -0
  121. {codecortex-0.20.0 → codecortex-0.21.0}/tests/test_null_reason_and_ripple.py +0 -0
  122. {codecortex-0.20.0 → codecortex-0.21.0}/tests/test_onboarding.py +0 -0
  123. {codecortex-0.20.0 → codecortex-0.21.0}/tests/test_rbac.py +0 -0
  124. {codecortex-0.20.0 → codecortex-0.21.0}/tests/test_redaction_boundary.py +0 -0
  125. {codecortex-0.20.0 → codecortex-0.21.0}/tests/test_reindexer.py +0 -0
  126. {codecortex-0.20.0 → codecortex-0.21.0}/tests/test_rerank.py +0 -0
  127. {codecortex-0.20.0 → codecortex-0.21.0}/tests/test_reset.py +0 -0
  128. {codecortex-0.20.0 → codecortex-0.21.0}/tests/test_semantic_provider.py +0 -0
  129. {codecortex-0.20.0 → codecortex-0.21.0}/tests/test_source_kind.py +0 -0
  130. {codecortex-0.20.0 → codecortex-0.21.0}/tests/test_staleness.py +0 -0
  131. {codecortex-0.20.0 → codecortex-0.21.0}/tests/test_symbol_context.py +0 -0
  132. {codecortex-0.20.0 → codecortex-0.21.0}/tests/test_term.py +0 -0
  133. {codecortex-0.20.0 → codecortex-0.21.0}/tests/test_treesitter.py +0 -0
  134. {codecortex-0.20.0 → codecortex-0.21.0}/tests/test_verify.py +0 -0
  135. {codecortex-0.20.0 → codecortex-0.21.0}/tests/test_verify_call.py +0 -0
  136. {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.20.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.20.0 — 4 tools (code.query, code.status, code.doctor, code.map)
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.20.0 — 4 tools (code.query, code.status, code.doctor, code.map)
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.20.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.20.0 — 4 tools (code.query, code.status, code.doctor, code.map)
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
- try:
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
- try:
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 with JSON rows, and 0.10.x replaced that with a text format. "
265
- "Pin the supported backend (pip/uv: `pip install 'codebase-memory-mcp==0.9.*'`; standalone "
266
- "binary: re-install the 0.9.x build) or check for a newer "
267
- "codeintel. This is NOT a statement about whether your repository is indexed."
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 needs "
737
- f"{_SUPPORTED_BACKEND}, which answers queries with JSON rows; the "
738
- f"installed backend replies in a text format, so every graph op except "
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": "downgrade the backend to 0.9.x pip/uv installs: "
745
- "`pip install 'codebase-memory-mcp==0.9.*'`; standalone binary: "
746
- "re-install the 0.9.x build for your platform (note `codebase-"
747
- "memory-mcp update` self-updates back to 0.10.x). Or check for a "
748
- "newer codeintel that speaks the new format.",
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
- {"label": label, "min_degree": 1, "limit": 2000}, project, timeout_ms,
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 test_a_text_speaking_backend_is_reported_as_incompatible_not_as_missing_data(monkeypatch):
725
- """The exact 0.10.x behaviour: exit 0, and a human-readable body where JSON was promised."""
726
- text = b'rows: 2 (cols: a.name)\n "codeintel"\n "codeintel.viewer"\ntotal: 2\n'
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 the 0.10.x text format, which this "
486
- "release cannot parse — pin 0.9.x to run the live graph tests")
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