codecortex 0.14.0__tar.gz → 0.14.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. {codecortex-0.14.0/src/codecortex.egg-info → codecortex-0.14.2}/PKG-INFO +18 -1
  2. {codecortex-0.14.0 → codecortex-0.14.2}/README.md +17 -0
  3. {codecortex-0.14.0 → codecortex-0.14.2/src/codecortex.egg-info}/PKG-INFO +18 -1
  4. codecortex-0.14.2/src/codeintel/__init__.py +1 -0
  5. {codecortex-0.14.0 → codecortex-0.14.2}/src/codeintel/commands/_common.py +11 -0
  6. {codecortex-0.14.0 → codecortex-0.14.2}/src/codeintel/commands/graph.py +7 -2
  7. {codecortex-0.14.0 → codecortex-0.14.2}/src/codeintel/commands/map.py +5 -1
  8. {codecortex-0.14.0 → codecortex-0.14.2}/src/codeintel/commands/setup.py +7 -2
  9. {codecortex-0.14.0 → codecortex-0.14.2}/src/codeintel/commands/status.py +5 -1
  10. {codecortex-0.14.0 → codecortex-0.14.2}/src/codeintel/gateway.py +20 -1
  11. {codecortex-0.14.0 → codecortex-0.14.2}/src/codeintel/indexer.py +24 -0
  12. {codecortex-0.14.0 → codecortex-0.14.2}/src/codeintel/installer.py +33 -1
  13. {codecortex-0.14.0 → codecortex-0.14.2}/src/codeintel/provider.py +4 -0
  14. {codecortex-0.14.0 → codecortex-0.14.2}/src/codeintel/providers/graph.py +23 -1
  15. {codecortex-0.14.0 → codecortex-0.14.2}/src/codeintel/reindexer.py +24 -0
  16. {codecortex-0.14.0 → codecortex-0.14.2}/src/codeintel/semantic_db.py +5 -1
  17. {codecortex-0.14.0 → codecortex-0.14.2}/tests/test_chunking.py +36 -0
  18. {codecortex-0.14.0 → codecortex-0.14.2}/tests/test_cli_commands.py +18 -0
  19. {codecortex-0.14.0 → codecortex-0.14.2}/tests/test_gateway.py +93 -0
  20. {codecortex-0.14.0 → codecortex-0.14.2}/tests/test_graph_provider.py +22 -0
  21. {codecortex-0.14.0 → codecortex-0.14.2}/tests/test_installer.py +35 -0
  22. {codecortex-0.14.0 → codecortex-0.14.2}/tests/test_reindexer.py +20 -0
  23. codecortex-0.14.0/src/codeintel/__init__.py +0 -1
  24. {codecortex-0.14.0 → codecortex-0.14.2}/LICENSE +0 -0
  25. {codecortex-0.14.0 → codecortex-0.14.2}/pyproject.toml +0 -0
  26. {codecortex-0.14.0 → codecortex-0.14.2}/setup.cfg +0 -0
  27. {codecortex-0.14.0 → codecortex-0.14.2}/src/codecortex.egg-info/SOURCES.txt +0 -0
  28. {codecortex-0.14.0 → codecortex-0.14.2}/src/codecortex.egg-info/dependency_links.txt +0 -0
  29. {codecortex-0.14.0 → codecortex-0.14.2}/src/codecortex.egg-info/entry_points.txt +0 -0
  30. {codecortex-0.14.0 → codecortex-0.14.2}/src/codecortex.egg-info/requires.txt +0 -0
  31. {codecortex-0.14.0 → codecortex-0.14.2}/src/codecortex.egg-info/top_level.txt +0 -0
  32. {codecortex-0.14.0 → codecortex-0.14.2}/src/codeintel/__main__.py +0 -0
  33. {codecortex-0.14.0 → codecortex-0.14.2}/src/codeintel/auth.py +0 -0
  34. {codecortex-0.14.0 → codecortex-0.14.2}/src/codeintel/cache.py +0 -0
  35. {codecortex-0.14.0 → codecortex-0.14.2}/src/codeintel/commands/__init__.py +0 -0
  36. {codecortex-0.14.0 → codecortex-0.14.2}/src/codeintel/commands/doctor.py +0 -0
  37. {codecortex-0.14.0 → codecortex-0.14.2}/src/codeintel/commands/gen_token.py +0 -0
  38. {codecortex-0.14.0 → codecortex-0.14.2}/src/codeintel/commands/index.py +0 -0
  39. {codecortex-0.14.0 → codecortex-0.14.2}/src/codeintel/commands/install.py +0 -0
  40. {codecortex-0.14.0 → codecortex-0.14.2}/src/codeintel/commands/query.py +0 -0
  41. {codecortex-0.14.0 → codecortex-0.14.2}/src/codeintel/commands/reset.py +0 -0
  42. {codecortex-0.14.0 → codecortex-0.14.2}/src/codeintel/commands/serve.py +0 -0
  43. {codecortex-0.14.0 → codecortex-0.14.2}/src/codeintel/commands/serve_http.py +0 -0
  44. {codecortex-0.14.0 → codecortex-0.14.2}/src/codeintel/config.py +0 -0
  45. {codecortex-0.14.0 → codecortex-0.14.2}/src/codeintel/doctor.py +0 -0
  46. {codecortex-0.14.0 → codecortex-0.14.2}/src/codeintel/grapher.py +0 -0
  47. {codecortex-0.14.0 → codecortex-0.14.2}/src/codeintel/http_server.py +0 -0
  48. {codecortex-0.14.0 → codecortex-0.14.2}/src/codeintel/injector.py +0 -0
  49. {codecortex-0.14.0 → codecortex-0.14.2}/src/codeintel/logconfig.py +0 -0
  50. {codecortex-0.14.0 → codecortex-0.14.2}/src/codeintel/mapper.py +0 -0
  51. {codecortex-0.14.0 → codecortex-0.14.2}/src/codeintel/metrics.py +0 -0
  52. {codecortex-0.14.0 → codecortex-0.14.2}/src/codeintel/onboarding.py +0 -0
  53. {codecortex-0.14.0 → codecortex-0.14.2}/src/codeintel/policy.py +0 -0
  54. {codecortex-0.14.0 → codecortex-0.14.2}/src/codeintel/providers/__init__.py +0 -0
  55. {codecortex-0.14.0 → codecortex-0.14.2}/src/codeintel/providers/lsp.py +0 -0
  56. {codecortex-0.14.0 → codecortex-0.14.2}/src/codeintel/providers/none.py +0 -0
  57. {codecortex-0.14.0 → codecortex-0.14.2}/src/codeintel/providers/semantic.py +0 -0
  58. {codecortex-0.14.0 → codecortex-0.14.2}/src/codeintel/py.typed +0 -0
  59. {codecortex-0.14.0 → codecortex-0.14.2}/src/codeintel/reset.py +0 -0
  60. {codecortex-0.14.0 → codecortex-0.14.2}/src/codeintel/searcher.py +0 -0
  61. {codecortex-0.14.0 → codecortex-0.14.2}/src/codeintel/server.py +0 -0
  62. {codecortex-0.14.0 → codecortex-0.14.2}/src/codeintel/term.py +0 -0
  63. {codecortex-0.14.0 → codecortex-0.14.2}/src/codeintel/verify.py +0 -0
  64. {codecortex-0.14.0 → codecortex-0.14.2}/src/codeintel/viewer/__init__.py +0 -0
  65. {codecortex-0.14.0 → codecortex-0.14.2}/src/codeintel/viewer/graph_template.html +0 -0
  66. {codecortex-0.14.0 → codecortex-0.14.2}/tests/test_cache.py +0 -0
  67. {codecortex-0.14.0 → codecortex-0.14.2}/tests/test_cli_help.py +0 -0
  68. {codecortex-0.14.0 → codecortex-0.14.2}/tests/test_config.py +0 -0
  69. {codecortex-0.14.0 → codecortex-0.14.2}/tests/test_doctor.py +0 -0
  70. {codecortex-0.14.0 → codecortex-0.14.2}/tests/test_e2e.py +0 -0
  71. {codecortex-0.14.0 → codecortex-0.14.2}/tests/test_engine_adoption.py +0 -0
  72. {codecortex-0.14.0 → codecortex-0.14.2}/tests/test_enterprise.py +0 -0
  73. {codecortex-0.14.0 → codecortex-0.14.2}/tests/test_graph_real.py +0 -0
  74. {codecortex-0.14.0 → codecortex-0.14.2}/tests/test_graph_stdin.py +0 -0
  75. {codecortex-0.14.0 → codecortex-0.14.2}/tests/test_grapher.py +0 -0
  76. {codecortex-0.14.0 → codecortex-0.14.2}/tests/test_hardening.py +0 -0
  77. {codecortex-0.14.0 → codecortex-0.14.2}/tests/test_http_auth.py +0 -0
  78. {codecortex-0.14.0 → codecortex-0.14.2}/tests/test_http_server.py +0 -0
  79. {codecortex-0.14.0 → codecortex-0.14.2}/tests/test_integration.py +0 -0
  80. {codecortex-0.14.0 → codecortex-0.14.2}/tests/test_lsp_provider.py +0 -0
  81. {codecortex-0.14.0 → codecortex-0.14.2}/tests/test_lsp_real.py +0 -0
  82. {codecortex-0.14.0 → codecortex-0.14.2}/tests/test_mapper.py +0 -0
  83. {codecortex-0.14.0 → codecortex-0.14.2}/tests/test_mcp_handshake.py +0 -0
  84. {codecortex-0.14.0 → codecortex-0.14.2}/tests/test_mcp_server.py +0 -0
  85. {codecortex-0.14.0 → codecortex-0.14.2}/tests/test_model_dimension.py +0 -0
  86. {codecortex-0.14.0 → codecortex-0.14.2}/tests/test_never_raise.py +0 -0
  87. {codecortex-0.14.0 → codecortex-0.14.2}/tests/test_onboarding.py +0 -0
  88. {codecortex-0.14.0 → codecortex-0.14.2}/tests/test_rbac.py +0 -0
  89. {codecortex-0.14.0 → codecortex-0.14.2}/tests/test_rerank.py +0 -0
  90. {codecortex-0.14.0 → codecortex-0.14.2}/tests/test_reset.py +0 -0
  91. {codecortex-0.14.0 → codecortex-0.14.2}/tests/test_semantic_provider.py +0 -0
  92. {codecortex-0.14.0 → codecortex-0.14.2}/tests/test_term.py +0 -0
  93. {codecortex-0.14.0 → codecortex-0.14.2}/tests/test_treesitter.py +0 -0
  94. {codecortex-0.14.0 → codecortex-0.14.2}/tests/test_verify.py +0 -0
  95. {codecortex-0.14.0 → codecortex-0.14.2}/tests/test_verify_call.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codecortex
3
- Version: 0.14.0
3
+ Version: 0.14.2
4
4
  Summary: Local-first, MCP-native code-intelligence server — graph, LSP, and semantic search behind one safe code.query tool for coding agents.
5
5
  Author: Shammai Hamilton
6
6
  License-Expression: MIT
@@ -439,6 +439,23 @@ docker build -t codeintel . && docker run -p 127.0.0.1:8766:8766 \
439
439
  -e CODEINTEL_HTTP_TOKEN="$(openssl rand -hex 32)" codeintel
440
440
  ```
441
441
 
442
+ ## Reporting a problem
443
+
444
+ `codeintel doctor --json` prints a complete, machine-readable picture of what's installed, what's
445
+ runnable, and whether this repo is indexed — per engine, with the remediation for each gap. Paste
446
+ it into an issue and the report is actionable immediately instead of needing a round trip:
447
+
448
+ ```bash
449
+ codeintel doctor --json
450
+ ```
451
+
452
+ It reports only local engine and index state. Over the HTTP transport the `registrations` field —
453
+ which names agent config files on the machine running the server — is deliberately omitted.
454
+
455
+ If a *result* looks wrong rather than a command failing, include the exact `code.query` call and
456
+ its full envelope. `reason`, `hint`, `engine`, `cached`, and `reindexing` between them explain
457
+ which engine answered and how current its index was, which is usually the whole diagnosis.
458
+
442
459
  ## Development
443
460
 
444
461
  ```bash
@@ -402,6 +402,23 @@ docker build -t codeintel . && docker run -p 127.0.0.1:8766:8766 \
402
402
  -e CODEINTEL_HTTP_TOKEN="$(openssl rand -hex 32)" codeintel
403
403
  ```
404
404
 
405
+ ## Reporting a problem
406
+
407
+ `codeintel doctor --json` prints a complete, machine-readable picture of what's installed, what's
408
+ runnable, and whether this repo is indexed — per engine, with the remediation for each gap. Paste
409
+ it into an issue and the report is actionable immediately instead of needing a round trip:
410
+
411
+ ```bash
412
+ codeintel doctor --json
413
+ ```
414
+
415
+ It reports only local engine and index state. Over the HTTP transport the `registrations` field —
416
+ which names agent config files on the machine running the server — is deliberately omitted.
417
+
418
+ If a *result* looks wrong rather than a command failing, include the exact `code.query` call and
419
+ its full envelope. `reason`, `hint`, `engine`, `cached`, and `reindexing` between them explain
420
+ which engine answered and how current its index was, which is usually the whole diagnosis.
421
+
405
422
  ## Development
406
423
 
407
424
  ```bash
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codecortex
3
- Version: 0.14.0
3
+ Version: 0.14.2
4
4
  Summary: Local-first, MCP-native code-intelligence server — graph, LSP, and semantic search behind one safe code.query tool for coding agents.
5
5
  Author: Shammai Hamilton
6
6
  License-Expression: MIT
@@ -439,6 +439,23 @@ docker build -t codeintel . && docker run -p 127.0.0.1:8766:8766 \
439
439
  -e CODEINTEL_HTTP_TOKEN="$(openssl rand -hex 32)" codeintel
440
440
  ```
441
441
 
442
+ ## Reporting a problem
443
+
444
+ `codeintel doctor --json` prints a complete, machine-readable picture of what's installed, what's
445
+ runnable, and whether this repo is indexed — per engine, with the remediation for each gap. Paste
446
+ it into an issue and the report is actionable immediately instead of needing a round trip:
447
+
448
+ ```bash
449
+ codeintel doctor --json
450
+ ```
451
+
452
+ It reports only local engine and index state. Over the HTTP transport the `registrations` field —
453
+ which names agent config files on the machine running the server — is deliberately omitted.
454
+
455
+ If a *result* looks wrong rather than a command failing, include the exact `code.query` call and
456
+ its full envelope. `reason`, `hint`, `engine`, `cached`, and `reindexing` between them explain
457
+ which engine answered and how current its index was, which is usually the whole diagnosis.
458
+
442
459
  ## Development
443
460
 
444
461
  ```bash
@@ -0,0 +1 @@
1
+ __version__ = "0.14.2"
@@ -18,6 +18,17 @@ def resolve_root(args: Any) -> str:
18
18
  return getattr(args, "project_root", None) or os.getcwd()
19
19
 
20
20
 
21
+ def require_dir(project_root: str, command: str) -> str | None:
22
+ """An error line when *project_root* is not a directory, else None.
23
+
24
+ A mistyped path used to produce confident, well-formed output about a directory that does not
25
+ exist — `setup /typo` rendered a full three-engine health table for it — which is the worst
26
+ possible response to a typo in a script, because it is indistinguishable from success."""
27
+ if os.path.isdir(project_root):
28
+ return None
29
+ return f"{command} failed: not a directory: {project_root}"
30
+
31
+
21
32
  def emit(report: dict, *, as_json: bool, render: Callable[[dict], str]) -> None:
22
33
  """Print a report as structured JSON (--json) or as its human-facing text rendering."""
23
34
  if as_json:
@@ -3,7 +3,7 @@
3
3
  import json
4
4
  from typing import Any
5
5
 
6
- from codeintel.commands._common import never_raise, resolve_root
6
+ from codeintel.commands._common import never_raise, require_dir, resolve_root
7
7
 
8
8
 
9
9
  # code=1: this command's job is to WRITE A FILE. Exiting 0 after failing to write it
@@ -12,7 +12,12 @@ from codeintel.commands._common import never_raise, resolve_root
12
12
  def run(args: Any) -> int:
13
13
  from codeintel import grapher
14
14
 
15
- payload = grapher.build_graph_payload(resolve_root(args), limit=args.limit)
15
+ project_root = resolve_root(args)
16
+ problem = require_dir(project_root, "graph")
17
+ if problem:
18
+ print(problem)
19
+ return 1
20
+ payload = grapher.build_graph_payload(project_root, limit=args.limit)
16
21
  nodes, edges = len(payload.get("nodes", [])), len(payload.get("edges", []))
17
22
 
18
23
  if not args.html:
@@ -2,7 +2,7 @@
2
2
 
3
3
  from typing import Any
4
4
 
5
- from codeintel.commands._common import never_raise, resolve_root
5
+ from codeintel.commands._common import never_raise, require_dir, resolve_root
6
6
 
7
7
 
8
8
  # Never-raise parity with the MCP code.map handler — degrade, don't crash.
@@ -15,6 +15,10 @@ def run(args: Any) -> int:
15
15
  from codeintel.providers.graph import GraphProvider
16
16
 
17
17
  project_root = resolve_root(args)
18
+ problem = require_dir(project_root, "map")
19
+ if problem:
20
+ print(problem)
21
+ return 1
18
22
  try:
19
23
  provider = GraphProvider()
20
24
  except Exception:
@@ -2,16 +2,21 @@
2
2
 
3
3
  from typing import Any
4
4
 
5
- from codeintel.commands._common import emit, never_raise, resolve_root
5
+ from codeintel.commands._common import emit, never_raise, require_dir, resolve_root
6
6
 
7
7
 
8
8
  @never_raise("setup unavailable: {exc}")
9
9
  def run(args: Any) -> int:
10
10
  from codeintel import onboarding
11
11
 
12
+ project_root = resolve_root(args)
13
+ problem = require_dir(project_root, "setup")
14
+ if problem:
15
+ print(problem)
16
+ return 1
12
17
  all_steps = getattr(args, "all_steps", False) # --all implies every automatable step
13
18
  report = onboarding.run_setup(
14
- resolve_root(args),
19
+ project_root,
15
20
  install_uv=args.install_uv or all_steps,
16
21
  install_deps=args.install_deps or all_steps,
17
22
  do_index=args.index or all_steps,
@@ -4,7 +4,7 @@ import datetime
4
4
  import os
5
5
  from typing import Any
6
6
 
7
- from codeintel.commands._common import never_raise, resolve_root
7
+ from codeintel.commands._common import never_raise, require_dir, resolve_root
8
8
 
9
9
  # "available" alone was the misleading word: it meant "a binary is on PATH", which is not the same
10
10
  # as runnable, and not the same as usable on THIS repo. Say which.
@@ -16,6 +16,10 @@ def run(args: Any) -> int:
16
16
  from codeintel import server
17
17
 
18
18
  project_root = resolve_root(args)
19
+ problem = require_dir(project_root, "status")
20
+ if problem:
21
+ print(problem)
22
+ return 1
19
23
  status = server.code_status_handler({"project_root": project_root})
20
24
 
21
25
  readiness = status.get("readiness") or {}
@@ -40,6 +40,15 @@ _UNCACHED_OPS: frozenset[str] = frozenset({"changed", "changes"})
40
40
  _ADOPTABLE_ENGINES: frozenset[str] = frozenset({"graph", "lsp", "semantic"})
41
41
 
42
42
 
43
+ def _mark_reindexing(result: Result, reindexing: bool) -> Result:
44
+ """Flag an answer served while a reindex for its project is still running."""
45
+ if not reindexing or result.get("result") is None:
46
+ return result
47
+ return {**result, "reindexing": True,
48
+ "hint": "a reindex is in progress — this answer reflects the index as of the last "
49
+ "completed pass; re-ask shortly if you have just changed this code"}
50
+
51
+
43
52
  class Gateway:
44
53
  def __init__(self, graph=None, lsp=None, semantic=None, policy: TieringPolicy | None = None,
45
54
  reindexer: Reindexer | None = None):
@@ -236,6 +245,16 @@ class Gateway:
236
245
  except Exception:
237
246
  pass
238
247
 
248
+ # If a reindex is running, this answer comes from the PREVIOUS index. Structural
249
+ # answers (callers/impact/hotspots) hash a symbol name, not file bytes, so nothing
250
+ # else in the envelope can reveal that — and an agent that just edited and asked
251
+ # "what did I break?" lands precisely here. Busting the cache would not help: the
252
+ # index itself is behind, so re-asking refetches the same stale data.
253
+ try:
254
+ reindexing = self._reindexer.reindex_pending(str(project_root or ""))
255
+ except Exception:
256
+ reindexing = False
257
+
239
258
  # Legacy list-based path (backward compat with pre-Phase-2 tests)
240
259
  if self._legacy_providers is not None:
241
260
  for p in self._legacy_providers:
@@ -324,7 +343,7 @@ class Gateway:
324
343
 
325
344
  if not uncacheable:
326
345
  self._cache.put(op_str, target_str, cache_engine, root_str, result, freshness)
327
- return result
346
+ return _mark_reindexing(result, reindexing)
328
347
 
329
348
  except Exception as exc:
330
349
  log_swallowed("Gateway.query", exc)
@@ -18,6 +18,10 @@ logger = logging.getLogger(__name__)
18
18
  # Generous enough that no hand-written function is affected.
19
19
  _MAX_CHUNK_CHARS = 200_000
20
20
 
21
+ # Bytes examined when deciding whether a file is binary. A NUL in the first block is the classic
22
+ # signal and is what `git` itself uses.
23
+ _BINARY_SNIFF_BYTES = 8192
24
+
21
25
  _INDEXED_EXTS = frozenset({
22
26
  ".py", ".md",
23
27
  ".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", # TS/JS variants
@@ -108,6 +112,15 @@ def _ts_decl_is_function(node) -> bool:
108
112
  return False
109
113
 
110
114
 
115
+ def _looks_binary(path) -> bool:
116
+ """Whether *path* is binary, by the same rule git uses: a NUL byte in the opening block."""
117
+ try:
118
+ with open(path, "rb") as fh:
119
+ return b"\x00" in fh.read(_BINARY_SNIFF_BYTES)
120
+ except OSError:
121
+ return True # unreadable → treat as unindexable rather than crash
122
+
123
+
111
124
  class Indexer:
112
125
  def __init__(
113
126
  self,
@@ -210,6 +223,10 @@ class Indexer:
210
223
  except Exception as exc:
211
224
  logger.warning("Cleanup pass failed: %s", exc)
212
225
 
226
+ @staticmethod
227
+ def _binary_check(path) -> bool:
228
+ return _looks_binary(path)
229
+
213
230
  def _walk_files(self, root: Path):
214
231
  """Indexable files inside *root* — and strictly inside it.
215
232
 
@@ -259,6 +276,13 @@ class Indexer:
259
276
  logger.warning("skipping %s — %d hard links, so its content may also live "
260
277
  "outside the indexed root", candidate, links)
261
278
  continue
279
+ # A source extension is not a promise of source. A compiled artifact or blob named
280
+ # `.py` was read with errors="replace" and embedded as replacement-character
281
+ # garbage — 196KB of /dev/urandom produced 162 chunks — which then competed for
282
+ # rank against real code in every search.
283
+ if _looks_binary(candidate):
284
+ logger.warning("skipping %s — looks binary despite its extension", candidate)
285
+ continue
262
286
  yield candidate
263
287
 
264
288
  # ---- chunk-span computation ------------------------------------------------------------
@@ -3,6 +3,7 @@ from __future__ import annotations
3
3
  import json
4
4
  import os
5
5
  import pathlib
6
+ import re
6
7
  import shutil
7
8
 
8
9
  _AGENTS = ["claude", "codex", "gemini", "zed"]
@@ -308,10 +309,41 @@ class Installer:
308
309
  return None
309
310
  return None
310
311
 
312
+ @staticmethod
313
+ def _looks_like_jsonc(text: str) -> bool:
314
+ """Whether *text* is JSON-with-comments — valid for Zed, invalid for `json.loads`."""
315
+ stripped = re.sub(r"/\*.*?\*/", "", text, flags=re.S)
316
+ stripped = re.sub(r"(?m)^\s*//.*$", "", stripped)
317
+ stripped = re.sub(r",(\s*[}\]])", r"\1", stripped)
318
+ try:
319
+ json.loads(stripped)
320
+ except Exception:
321
+ return False
322
+ return True
323
+
311
324
  def _register_json(self, agent: str, config_path: pathlib.Path, spec: dict,
312
325
  command: str) -> dict:
313
326
  if config_path.exists():
314
- data = json.loads(config_path.read_text(encoding="utf-8"))
327
+ raw = config_path.read_text(encoding="utf-8")
328
+ try:
329
+ data = json.loads(raw)
330
+ except json.JSONDecodeError:
331
+ # Zed ships settings.json as JSONC — comments and trailing commas — so strict
332
+ # parsing fails and every Zed user was blocked with an opaque "Expecting value".
333
+ # Parsing the JSONC and writing back with json.dumps would be WORSE: it would
334
+ # silently delete the user's comments, which in Zed's default config is most of
335
+ # the file. Until this can insert textually (the way the Codex TOML path does),
336
+ # hand the user the exact block instead of damaging their editor config.
337
+ if self._looks_like_jsonc(raw):
338
+ entry = json.dumps({spec["key"][-1] if spec.get("key") else "codeintel":
339
+ launch_value(command)}, indent=2)
340
+ raise ValueError(
341
+ f"{config_path} is JSONC (it has comments or trailing commas), which "
342
+ f"codeintel will not rewrite because doing so would discard your "
343
+ f"comments. Add this to \"{'.'.join(spec['key'][:-1]) or 'the root'}\" "
344
+ f"by hand:\n{entry}"
345
+ ) from None
346
+ raise
315
347
  if not isinstance(data, dict):
316
348
  # Every other malformed-input path here fails safe (invalid JSON → failed, no
317
349
  # write). Silently substituting {} made this the one branch that DESTROYED the
@@ -33,6 +33,10 @@ class Result(TypedDict):
33
33
  cached: bool
34
34
  reason: NotRequired[str]
35
35
  hint: NotRequired[str]
36
+ # Set when the answer was served while a reindex for its project was still running, i.e. it
37
+ # reflects the last COMPLETED index rather than the current source. Optional, and the MCP
38
+ # tools deliberately return plain `dict` so this never becomes a required schema field.
39
+ reindexing: NotRequired[bool]
36
40
 
37
41
 
38
42
  @runtime_checkable
@@ -26,6 +26,28 @@ _GRAPH_OPS = frozenset({
26
26
  })
27
27
 
28
28
 
29
+ def _strip_project_prefix(qualified_name: str) -> str:
30
+ """Drop the backend's project id from the head of a qualified name.
31
+
32
+ The backend prefixes every qualified name with its own project id, which for a path-slug
33
+ registration is the flattened absolute path — so each result line began
34
+ `Users-alice-Documents-project-myrepo.src.pkg.fn`. That is the author's home directory
35
+ layout repeated on every row: noise for a human, wasted tokens for the agent this tool
36
+ exists to serve, on results that can run to a hundred lines.
37
+
38
+ Only a leading path-slug-looking segment is removed. A qualified name that starts with a
39
+ real module (`src.codeintel.gateway.query`) is left exactly as it is.
40
+ """
41
+ head, sep, rest = qualified_name.partition(".")
42
+ if not sep:
43
+ return qualified_name
44
+ # A slug: no spaces, and hyphenated (the backend joins path components with "-"). A genuine
45
+ # Python package name cannot contain a hyphen, so this cannot eat a real module.
46
+ if "-" in head and " " not in head:
47
+ return rest
48
+ return qualified_name
49
+
50
+
29
51
  def _repo_display_name(root: str) -> str:
30
52
  """The repo's own directory name, for headings a human will read.
31
53
 
@@ -276,7 +298,7 @@ class GraphProvider:
276
298
  @staticmethod
277
299
  def _display(row: dict, name_key: str, qn_key: str, file_key: str) -> str:
278
300
  name = str(row.get(name_key) or "?")
279
- qn = str(row.get(qn_key) or "")
301
+ qn = _strip_project_prefix(str(row.get(qn_key) or ""))
280
302
  file = str(row.get(file_key) or "")
281
303
  edge = str(row.get("type(c)") or "").strip()
282
304
  label = qn or name
@@ -52,12 +52,27 @@ class Reindexer:
52
52
  # folds it into the cache key so a structural answer is invalidated once the
53
53
  # index actually moves (a symbol/free-text target has no file content to hash).
54
54
  self._generation: dict[str, int] = {}
55
+ # Roots with a reindex submitted but not yet finished. The index is KNOWN to be behind
56
+ # while this holds, and no amount of cache invalidation fixes that — re-asking just
57
+ # refetches the same stale index, more expensively. The only honest response is to say so.
58
+ self._in_flight: set[str] = set()
55
59
  self._executor = _DaemonPool(max_workers=2)
56
60
 
57
61
  def generation(self, project_root: str) -> int:
58
62
  with self._lock:
59
63
  return self._generation.get(project_root, 0)
60
64
 
65
+ def reindex_pending(self, project_root: str) -> bool:
66
+ """Whether this root has a reindex running right now.
67
+
68
+ Lets a caller distinguish "this is the current structure" from "this is the structure as
69
+ of the last completed index, and a newer one is being built". An agent's loop is edit →
70
+ ask what I broke, which lands exactly in that window."""
71
+ if not project_root:
72
+ return False
73
+ with self._lock:
74
+ return project_root in self._in_flight
75
+
61
76
  def maybe_reindex(self, project_root: str) -> None:
62
77
  if not self._enabled:
63
78
  return
@@ -66,6 +81,12 @@ class Reindexer:
66
81
 
67
82
  now = time.monotonic()
68
83
  with self._lock:
84
+ # Already running: skip. The debounce timestamp is set when a pass is SUBMITTED, not
85
+ # when it finishes, so on a repo whose reindex outlasts the window every later query
86
+ # stacked another concurrent pass — overlapping writers against one SQLite file and
87
+ # one graph subprocess, for no benefit.
88
+ if project_root in self._in_flight:
89
+ return
69
90
  last = self._last_fired.get(project_root, 0.0)
70
91
  if now - last < self._debounce_seconds:
71
92
  return
@@ -77,6 +98,8 @@ class Reindexer:
77
98
  if self._reindex_disabled(project_root):
78
99
  return
79
100
 
101
+ with self._lock:
102
+ self._in_flight.add(project_root)
80
103
  self._executor.submit(self._do_reindex, project_root)
81
104
 
82
105
  def _reindex_disabled(self, project_root: str) -> bool:
@@ -113,6 +136,7 @@ class Reindexer:
113
136
  finally:
114
137
  with self._lock:
115
138
  self._generation[project_root] = self._generation.get(project_root, 0) + 1
139
+ self._in_flight.discard(project_root)
116
140
 
117
141
  def _semantic_reindex(self, project_root: str) -> None:
118
142
  import pathlib
@@ -149,7 +149,11 @@ class SemanticDb:
149
149
  if self.dimension is not None:
150
150
  logger.warning(
151
151
  "embedding dimension %d != cache dimension %d — skipping write; run "
152
- "`codeintel reset` to rebuild the semantic index for the new model",
152
+ # `reset` alone cannot fix this: the vec0 table's dimension is fixed at
153
+ # creation and the table is SHARED across every project in this cache file,
154
+ # so a project-scoped reset (which only DELETEs that project's rows) leaves
155
+ # it in place and the warning repeats forever. `--all` drops the file.
156
+ "`codeintel reset --all` to rebuild the semantic index for the new model",
153
157
  dim, self.dimension,
154
158
  )
155
159
  return None
@@ -363,3 +363,39 @@ def test_switching_strategy_reconciles_stale_line_chunks(tmp_path):
363
363
 
364
364
  # 25-line def > max_chunk_lines(40)? no — 25 <= 40, so it becomes ONE def chunk at line 0.
365
365
  assert syntax_starts == {0}
366
+
367
+
368
+ def test_a_binary_file_with_a_source_extension_is_not_indexed(tmp_path):
369
+ """A source extension is not a promise of source. A compiled artifact named `.py` was read
370
+ with errors="replace" and embedded as replacement-character garbage — 196KB of random bytes
371
+ produced 162 chunks — which then competed for rank against real code in every search."""
372
+ from codeintel.indexer import Indexer
373
+ from codeintel.semantic_db import SemanticDb
374
+
375
+ (tmp_path / "real.py").write_text("def hello():\n return 1\n")
376
+ (tmp_path / "blob.py").write_bytes(b"\x7fELF\x00\x00\x00" + os.urandom(4096))
377
+
378
+ db = SemanticDb(str(tmp_path / "db.sqlite"))
379
+ db.init()
380
+ try:
381
+ names = sorted(f.name for f in Indexer(db)._walk_files(tmp_path))
382
+ finally:
383
+ db.close()
384
+
385
+ assert names == ["real.py"], f"a binary file was queued for embedding: {names}"
386
+
387
+
388
+ def test_utf8_source_with_unusual_characters_is_still_indexed(tmp_path):
389
+ """Guard the guard: the NUL sniff must not reject legitimate non-ASCII source."""
390
+ from codeintel.indexer import Indexer
391
+ from codeintel.semantic_db import SemanticDb
392
+
393
+ (tmp_path / "unicode.py").write_text("# ünïcödé ✓ 日本語\ndef f():\n return '→'\n",
394
+ encoding="utf-8")
395
+ db = SemanticDb(str(tmp_path / "db.sqlite"))
396
+ db.init()
397
+ try:
398
+ names = sorted(f.name for f in Indexer(db)._walk_files(tmp_path))
399
+ finally:
400
+ db.close()
401
+ assert names == ["unicode.py"]
@@ -792,3 +792,21 @@ def test_index_reports_an_unrecoverable_indexer_failure(monkeypatch, tmp_path, c
792
792
  assert import_module("codeintel.commands.index").run(_args(project_root=str(tmp_path))) == 1
793
793
  out = capsys.readouterr().out
794
794
  assert "index failed" in out and "Nothing new to index" not in out
795
+
796
+
797
+ # --------------------------------------------------------------------------- root validation
798
+
799
+ @pytest.mark.parametrize("command,args_", [
800
+ ("setup", {"project_root": None, "all_steps": False, "install_uv": False,
801
+ "install_deps": False, "index": False, "warm": False, "json": False}),
802
+ ("status", {"project_root": None}),
803
+ ("map", {"project_root": None, "inject": False, "budget": 32768}),
804
+ ("graph", {"project_root": None, "html": False, "out": None, "limit": 220}),
805
+ ])
806
+ def test_a_nonexistent_project_root_fails_loudly(command, args_, tmp_path, capsys):
807
+ """A mistyped path produced confident, well-formed output about a directory that does not
808
+ exist — `setup /typo` rendered a full three-engine health table for it. In a script that is
809
+ indistinguishable from success, which is the worst possible response to a typo."""
810
+ args_ = {**args_, "project_root": str(tmp_path / "does-not-exist")}
811
+ assert import_module(f"codeintel.commands.{command}").run(_args(args_)) == 1
812
+ assert "not a directory" in capsys.readouterr().out
@@ -409,3 +409,96 @@ def test_cache_get_and_put_always_use_the_same_key():
409
409
 
410
410
  keys = {engine_arg(c) for c in gets} | {engine_arg(c) for c in puts}
411
411
  assert keys == {"cache_engine"}, f"cache key components disagree: {keys}"
412
+
413
+
414
+ # --------------------------------------------------------------------------- staleness signalling
415
+
416
+ class _Answering:
417
+ available = True
418
+
419
+ def build_result(self, op, target, files, budget, project_root):
420
+ return {"ok": True, "op": op, "target": target, "result": "ANSWER",
421
+ "engine": "graph", "cached": False}
422
+
423
+
424
+ def test_an_answer_served_during_a_reindex_says_so():
425
+ """Structural answers hash a symbol NAME, not file bytes, so nothing else in the envelope can
426
+ reveal that the index is behind. An agent's loop is edit -> ask what I broke, which lands
427
+ exactly in that window. Invalidating the cache would not help: the index itself is stale, so
428
+ re-asking just refetches the same data more expensively. Saying so is the only honest fix."""
429
+ from codeintel.gateway import Gateway
430
+ from codeintel.reindexer import Reindexer
431
+
432
+ rx = Reindexer()
433
+ gw = Gateway(graph=_Answering(), lsp=None, semantic=None, reindexer=rx)
434
+ rx._in_flight.add("/repo")
435
+
436
+ res = gw.query(op="callers", target="f", role="", project_root="/repo")
437
+ assert res["result"] == "ANSWER" # still answers — this is a caveat, not a error
438
+ assert res["reindexing"] is True
439
+ assert "re-ask" in res["hint"]
440
+
441
+
442
+ def test_a_settled_index_does_not_flag_an_answer():
443
+ """The signal must be rare enough to mean something — flagging every answer would train a
444
+ reader to ignore it. Steady state is: a reindex ran, the debounce window has not elapsed, so
445
+ no new pass fires and answers come back unqualified. (The FIRST query for a root does fire one
446
+ and is legitimately flagged while it runs — the index really is being rebuilt underneath.)"""
447
+ import time
448
+
449
+ from codeintel.gateway import Gateway
450
+ from codeintel.reindexer import Reindexer
451
+
452
+ rx = Reindexer()
453
+ rx._last_fired["/repo"] = time.monotonic() # inside the debounce window: nothing re-fires
454
+ gw = Gateway(graph=_Answering(), lsp=None, semantic=None, reindexer=rx)
455
+
456
+ res = gw.query(op="callers", target="f", role="", project_root="/repo")
457
+ assert res["result"] == "ANSWER"
458
+ assert res.get("reindexing") is None
459
+
460
+
461
+ def test_an_empty_result_is_not_flagged_as_reindexing():
462
+ """`reindexing` qualifies an answer. On a safe-null there is no answer to qualify, and the
463
+ `reason`/`hint` already carry the explanation."""
464
+ from codeintel.gateway import Gateway
465
+ from codeintel.provider import safe_null_result
466
+ from codeintel.reindexer import Reindexer
467
+
468
+ class _Empty(_Answering):
469
+ def build_result(self, op, target, files, budget, project_root):
470
+ return safe_null_result(op, target, engine="graph", reason="not-in-graph")
471
+
472
+ rx = Reindexer()
473
+ gw = Gateway(graph=_Empty(), lsp=None, semantic=None, reindexer=rx)
474
+ rx._in_flight.add("/repo")
475
+
476
+ res = gw.query(op="callers", target="f", role="", project_root="/repo")
477
+ assert res.get("reindexing") is None
478
+ assert res["reason"] == "not-in-graph"
479
+
480
+
481
+ def test_reindex_pending_clears_once_the_pass_completes(monkeypatch):
482
+ from codeintel.reindexer import Reindexer
483
+
484
+ rx = Reindexer()
485
+ monkeypatch.setattr(rx, "_semantic_reindex", lambda root: None)
486
+ monkeypatch.setattr(rx, "_graph_reindex", lambda root: None)
487
+
488
+ rx._in_flight.add("/repo")
489
+ assert rx.reindex_pending("/repo") is True
490
+ rx._do_reindex("/repo")
491
+ assert rx.reindex_pending("/repo") is False, "a completed reindex must clear the flag"
492
+
493
+
494
+ def test_reindex_pending_clears_even_when_both_passes_fail(monkeypatch):
495
+ """A stuck flag would mark every later answer stale and train the reader to ignore it."""
496
+ from codeintel.reindexer import Reindexer
497
+
498
+ rx = Reindexer()
499
+ for name in ("_semantic_reindex", "_graph_reindex"):
500
+ monkeypatch.setattr(rx, name, lambda root: (_ for _ in ()).throw(RuntimeError("down")))
501
+
502
+ rx._in_flight.add("/repo")
503
+ rx._do_reindex("/repo")
504
+ assert rx.reindex_pending("/repo") is False
@@ -308,3 +308,25 @@ def test_overview_falls_back_to_the_backend_name_without_a_root(monkeypatch):
308
308
  "project": "backend-id", "total_nodes": 5, "total_edges": 4})
309
309
 
310
310
  assert gp._op_overview("", "backend-id", 1000, "").splitlines()[0] == "## Architecture: backend-id"
311
+
312
+
313
+ def test_result_lines_do_not_carry_the_backends_project_id(tmp_path):
314
+ """The backend prefixes every qualified name with its project id — for a path-slug
315
+ registration, the flattened ABSOLUTE PATH. Every result line therefore began
316
+ `Users-alice-Documents-project-myrepo.src.pkg.fn`: the author's home directory repeated per
317
+ row, on results that run to a hundred lines. Noise for a human, wasted tokens for the agent
318
+ this tool exists to serve."""
319
+ from codeintel.providers.graph import _strip_project_prefix
320
+
321
+ assert _strip_project_prefix(
322
+ "Users-alice-Documents-project-myrepo.src.pkg.fn") == "src.pkg.fn"
323
+ assert _strip_project_prefix("my-repo.src.pkg.fn") == "src.pkg.fn"
324
+
325
+
326
+ def test_stripping_never_eats_a_real_module_path():
327
+ """A hyphen cannot appear in a Python package name, which is what makes the slug detectable —
328
+ so a genuine dotted module path must pass through untouched."""
329
+ from codeintel.providers.graph import _strip_project_prefix
330
+
331
+ for qn in ("src.codeintel.gateway.query", "codeintel.server.run", "main", "", "pkg.mod"):
332
+ assert _strip_project_prefix(qn) == qn
@@ -561,3 +561,38 @@ def test_install_refuses_to_overwrite_a_non_object_config(tmp_path, monkeypatch)
561
561
 
562
562
  assert result["ok"] is False
563
563
  assert cfg.read_text() == "[1, 2, 3]\n", "the user's file was overwritten"
564
+
565
+
566
+ def test_zed_jsonc_config_is_not_rewritten_and_the_error_is_actionable(tmp_path, monkeypatch):
567
+ """Zed ships settings.json as JSONC — comments and trailing commas — so strict parsing failed
568
+ and every Zed user was blocked by an opaque "Expecting value". Parsing the JSONC and writing
569
+ back with json.dumps would be WORSE: it would silently delete the user's comments, which in
570
+ Zed's default config is most of the file. Fail, keep the file intact, and hand over the block.
571
+ """
572
+ monkeypatch.setenv("HOME", str(tmp_path))
573
+ monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "xdg"))
574
+ cfg = tmp_path / "xdg" / "zed" / "settings.json"
575
+ cfg.parent.mkdir(parents=True)
576
+ original = '{\n // Font settings\n "buffer_font_size": 14,\n "theme": "One Dark",\n}\n'
577
+ cfg.write_text(original)
578
+
579
+ res = Installer().register("zed")
580
+
581
+ assert res["ok"] is False
582
+ assert cfg.read_text() == original, "the user's editor config was modified"
583
+ assert "JSONC" in res["reason"] and "context_servers" in res["reason"]
584
+ assert "codeintel" in res["reason"] # the block to paste is in the message
585
+
586
+
587
+ def test_plain_invalid_json_still_reports_the_parse_error(tmp_path, monkeypatch):
588
+ """Only genuine JSONC gets the special path — a truly corrupt file must not be misdiagnosed."""
589
+ monkeypatch.setenv("HOME", str(tmp_path))
590
+ monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(tmp_path))
591
+ cfg = tmp_path / ".claude.json"
592
+ cfg.write_text("{ this is not json at all ")
593
+
594
+ res = Installer().register("claude")
595
+
596
+ assert res["ok"] is False
597
+ assert "JSONC" not in res["reason"]
598
+ assert cfg.read_text() == "{ this is not json at all "
@@ -260,3 +260,23 @@ def test_repeated_failures_keep_advancing_rather_than_pinning_at_zero(monkeypatc
260
260
  for _ in range(3):
261
261
  r._do_reindex("/repo")
262
262
  assert r.generation("/repo") == 3
263
+
264
+
265
+ def test_a_reindex_already_running_is_not_submitted_again(monkeypatch):
266
+ """The debounce timestamp is set when a pass is SUBMITTED, not when it finishes, so on a repo
267
+ whose reindex outlasts the window every later query stacked another concurrent pass —
268
+ overlapping writers against one SQLite file and one graph subprocess, for no benefit."""
269
+ r = Reindexer(debounce_seconds=0)
270
+ submitted = []
271
+ monkeypatch.setattr(r._executor, "submit", lambda fn, root: submitted.append(root))
272
+
273
+ r.maybe_reindex("/repo")
274
+ assert submitted == ["/repo"]
275
+
276
+ r.maybe_reindex("/repo") # still in flight (nothing cleared it)
277
+ r.maybe_reindex("/repo")
278
+ assert submitted == ["/repo"], "a second pass was stacked on a running one"
279
+
280
+ r._in_flight.discard("/repo") # the running pass completes
281
+ r.maybe_reindex("/repo")
282
+ assert submitted == ["/repo", "/repo"]
@@ -1 +0,0 @@
1
- __version__ = "0.14.0"
File without changes
File without changes
File without changes