codecortex 0.16.0__tar.gz → 0.17.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 (121) hide show
  1. {codecortex-0.16.0/src/codecortex.egg-info → codecortex-0.17.0}/PKG-INFO +4 -4
  2. {codecortex-0.16.0 → codecortex-0.17.0}/README.md +3 -3
  3. {codecortex-0.16.0 → codecortex-0.17.0/src/codecortex.egg-info}/PKG-INFO +4 -4
  4. {codecortex-0.16.0 → codecortex-0.17.0}/src/codecortex.egg-info/SOURCES.txt +3 -0
  5. codecortex-0.17.0/src/codeintel/__init__.py +1 -0
  6. {codecortex-0.16.0 → codecortex-0.17.0}/src/codeintel/__main__.py +7 -1
  7. codecortex-0.17.0/src/codeintel/commands/index.py +144 -0
  8. {codecortex-0.16.0 → codecortex-0.17.0}/src/codeintel/commands/reset.py +2 -2
  9. {codecortex-0.16.0 → codecortex-0.17.0}/src/codeintel/gateway.py +3 -3
  10. {codecortex-0.16.0 → codecortex-0.17.0}/src/codeintel/indexer.py +45 -2
  11. codecortex-0.17.0/src/codeintel/progress.py +78 -0
  12. {codecortex-0.16.0 → codecortex-0.17.0}/src/codeintel/reset.py +47 -1
  13. codecortex-0.17.0/src/codeintel/term.py +394 -0
  14. {codecortex-0.16.0 → codecortex-0.17.0}/tests/test_cli_commands.py +20 -2
  15. codecortex-0.17.0/tests/test_index_progress.py +263 -0
  16. codecortex-0.17.0/tests/test_indexer_binary.py +42 -0
  17. {codecortex-0.16.0 → codecortex-0.17.0}/tests/test_reset.py +62 -0
  18. codecortex-0.16.0/src/codeintel/__init__.py +0 -1
  19. codecortex-0.16.0/src/codeintel/commands/index.py +0 -76
  20. codecortex-0.16.0/src/codeintel/term.py +0 -161
  21. {codecortex-0.16.0 → codecortex-0.17.0}/LICENSE +0 -0
  22. {codecortex-0.16.0 → codecortex-0.17.0}/pyproject.toml +0 -0
  23. {codecortex-0.16.0 → codecortex-0.17.0}/setup.cfg +0 -0
  24. {codecortex-0.16.0 → codecortex-0.17.0}/src/codecortex.egg-info/dependency_links.txt +0 -0
  25. {codecortex-0.16.0 → codecortex-0.17.0}/src/codecortex.egg-info/entry_points.txt +0 -0
  26. {codecortex-0.16.0 → codecortex-0.17.0}/src/codecortex.egg-info/requires.txt +0 -0
  27. {codecortex-0.16.0 → codecortex-0.17.0}/src/codecortex.egg-info/top_level.txt +0 -0
  28. {codecortex-0.16.0 → codecortex-0.17.0}/src/codeintel/agent_prompt.py +0 -0
  29. {codecortex-0.16.0 → codecortex-0.17.0}/src/codeintel/auth.py +0 -0
  30. {codecortex-0.16.0 → codecortex-0.17.0}/src/codeintel/cache.py +0 -0
  31. {codecortex-0.16.0 → codecortex-0.17.0}/src/codeintel/commands/__init__.py +0 -0
  32. {codecortex-0.16.0 → codecortex-0.17.0}/src/codeintel/commands/_common.py +0 -0
  33. {codecortex-0.16.0 → codecortex-0.17.0}/src/codeintel/commands/doctor.py +0 -0
  34. {codecortex-0.16.0 → codecortex-0.17.0}/src/codeintel/commands/gen_token.py +0 -0
  35. {codecortex-0.16.0 → codecortex-0.17.0}/src/codeintel/commands/graph.py +0 -0
  36. {codecortex-0.16.0 → codecortex-0.17.0}/src/codeintel/commands/install.py +0 -0
  37. {codecortex-0.16.0 → codecortex-0.17.0}/src/codeintel/commands/map.py +0 -0
  38. {codecortex-0.16.0 → codecortex-0.17.0}/src/codeintel/commands/prompt.py +0 -0
  39. {codecortex-0.16.0 → codecortex-0.17.0}/src/codeintel/commands/query.py +0 -0
  40. {codecortex-0.16.0 → codecortex-0.17.0}/src/codeintel/commands/serve.py +0 -0
  41. {codecortex-0.16.0 → codecortex-0.17.0}/src/codeintel/commands/serve_http.py +0 -0
  42. {codecortex-0.16.0 → codecortex-0.17.0}/src/codeintel/commands/setup.py +0 -0
  43. {codecortex-0.16.0 → codecortex-0.17.0}/src/codeintel/commands/status.py +0 -0
  44. {codecortex-0.16.0 → codecortex-0.17.0}/src/codeintel/config.py +0 -0
  45. {codecortex-0.16.0 → codecortex-0.17.0}/src/codeintel/containment.py +0 -0
  46. {codecortex-0.16.0 → codecortex-0.17.0}/src/codeintel/doctor.py +0 -0
  47. {codecortex-0.16.0 → codecortex-0.17.0}/src/codeintel/graph_backend.py +0 -0
  48. {codecortex-0.16.0 → codecortex-0.17.0}/src/codeintel/graph_render.py +0 -0
  49. {codecortex-0.16.0 → codecortex-0.17.0}/src/codeintel/graph_resolution.py +0 -0
  50. {codecortex-0.16.0 → codecortex-0.17.0}/src/codeintel/grapher.py +0 -0
  51. {codecortex-0.16.0 → codecortex-0.17.0}/src/codeintel/http_server.py +0 -0
  52. {codecortex-0.16.0 → codecortex-0.17.0}/src/codeintel/injector.py +0 -0
  53. {codecortex-0.16.0 → codecortex-0.17.0}/src/codeintel/installer.py +0 -0
  54. {codecortex-0.16.0 → codecortex-0.17.0}/src/codeintel/loc.py +0 -0
  55. {codecortex-0.16.0 → codecortex-0.17.0}/src/codeintel/logconfig.py +0 -0
  56. {codecortex-0.16.0 → codecortex-0.17.0}/src/codeintel/mapper.py +0 -0
  57. {codecortex-0.16.0 → codecortex-0.17.0}/src/codeintel/metrics.py +0 -0
  58. {codecortex-0.16.0 → codecortex-0.17.0}/src/codeintel/onboarding.py +0 -0
  59. {codecortex-0.16.0 → codecortex-0.17.0}/src/codeintel/outcome.py +0 -0
  60. {codecortex-0.16.0 → codecortex-0.17.0}/src/codeintel/policy.py +0 -0
  61. {codecortex-0.16.0 → codecortex-0.17.0}/src/codeintel/provider.py +0 -0
  62. {codecortex-0.16.0 → codecortex-0.17.0}/src/codeintel/providers/__init__.py +0 -0
  63. {codecortex-0.16.0 → codecortex-0.17.0}/src/codeintel/providers/graph.py +0 -0
  64. {codecortex-0.16.0 → codecortex-0.17.0}/src/codeintel/providers/lsp.py +0 -0
  65. {codecortex-0.16.0 → codecortex-0.17.0}/src/codeintel/providers/none.py +0 -0
  66. {codecortex-0.16.0 → codecortex-0.17.0}/src/codeintel/providers/semantic.py +0 -0
  67. {codecortex-0.16.0 → codecortex-0.17.0}/src/codeintel/py.typed +0 -0
  68. {codecortex-0.16.0 → codecortex-0.17.0}/src/codeintel/redact.py +0 -0
  69. {codecortex-0.16.0 → codecortex-0.17.0}/src/codeintel/reindexer.py +0 -0
  70. {codecortex-0.16.0 → codecortex-0.17.0}/src/codeintel/searcher.py +0 -0
  71. {codecortex-0.16.0 → codecortex-0.17.0}/src/codeintel/semantic_db.py +0 -0
  72. {codecortex-0.16.0 → codecortex-0.17.0}/src/codeintel/server.py +0 -0
  73. {codecortex-0.16.0 → codecortex-0.17.0}/src/codeintel/source_kind.py +0 -0
  74. {codecortex-0.16.0 → codecortex-0.17.0}/src/codeintel/verify.py +0 -0
  75. {codecortex-0.16.0 → codecortex-0.17.0}/src/codeintel/viewer/__init__.py +0 -0
  76. {codecortex-0.16.0 → codecortex-0.17.0}/src/codeintel/viewer/graph_template.html +0 -0
  77. {codecortex-0.16.0 → codecortex-0.17.0}/tests/test_agent_prompt.py +0 -0
  78. {codecortex-0.16.0 → codecortex-0.17.0}/tests/test_backend_reaper.py +0 -0
  79. {codecortex-0.16.0 → codecortex-0.17.0}/tests/test_cache.py +0 -0
  80. {codecortex-0.16.0 → codecortex-0.17.0}/tests/test_chunking.py +0 -0
  81. {codecortex-0.16.0 → codecortex-0.17.0}/tests/test_cli_help.py +0 -0
  82. {codecortex-0.16.0 → codecortex-0.17.0}/tests/test_cold_process.py +0 -0
  83. {codecortex-0.16.0 → codecortex-0.17.0}/tests/test_config.py +0 -0
  84. {codecortex-0.16.0 → codecortex-0.17.0}/tests/test_corpus.py +0 -0
  85. {codecortex-0.16.0 → codecortex-0.17.0}/tests/test_docs_ci_claims.py +0 -0
  86. {codecortex-0.16.0 → codecortex-0.17.0}/tests/test_docs_deadcode_withdrawal.py +0 -0
  87. {codecortex-0.16.0 → codecortex-0.17.0}/tests/test_doctor.py +0 -0
  88. {codecortex-0.16.0 → codecortex-0.17.0}/tests/test_e2e.py +0 -0
  89. {codecortex-0.16.0 → codecortex-0.17.0}/tests/test_engine_adoption.py +0 -0
  90. {codecortex-0.16.0 → codecortex-0.17.0}/tests/test_enterprise.py +0 -0
  91. {codecortex-0.16.0 → codecortex-0.17.0}/tests/test_gateway.py +0 -0
  92. {codecortex-0.16.0 → codecortex-0.17.0}/tests/test_graph_failure_population.py +0 -0
  93. {codecortex-0.16.0 → codecortex-0.17.0}/tests/test_graph_provider.py +0 -0
  94. {codecortex-0.16.0 → codecortex-0.17.0}/tests/test_graph_real.py +0 -0
  95. {codecortex-0.16.0 → codecortex-0.17.0}/tests/test_graph_stdin.py +0 -0
  96. {codecortex-0.16.0 → codecortex-0.17.0}/tests/test_grapher.py +0 -0
  97. {codecortex-0.16.0 → codecortex-0.17.0}/tests/test_hardening.py +0 -0
  98. {codecortex-0.16.0 → codecortex-0.17.0}/tests/test_http_auth.py +0 -0
  99. {codecortex-0.16.0 → codecortex-0.17.0}/tests/test_http_server.py +0 -0
  100. {codecortex-0.16.0 → codecortex-0.17.0}/tests/test_incompleteness.py +0 -0
  101. {codecortex-0.16.0 → codecortex-0.17.0}/tests/test_installer.py +0 -0
  102. {codecortex-0.16.0 → codecortex-0.17.0}/tests/test_integration.py +0 -0
  103. {codecortex-0.16.0 → codecortex-0.17.0}/tests/test_loc_census.py +0 -0
  104. {codecortex-0.16.0 → codecortex-0.17.0}/tests/test_lsp_provider.py +0 -0
  105. {codecortex-0.16.0 → codecortex-0.17.0}/tests/test_lsp_real.py +0 -0
  106. {codecortex-0.16.0 → codecortex-0.17.0}/tests/test_mapper.py +0 -0
  107. {codecortex-0.16.0 → codecortex-0.17.0}/tests/test_mcp_handshake.py +0 -0
  108. {codecortex-0.16.0 → codecortex-0.17.0}/tests/test_mcp_server.py +0 -0
  109. {codecortex-0.16.0 → codecortex-0.17.0}/tests/test_model_dimension.py +0 -0
  110. {codecortex-0.16.0 → codecortex-0.17.0}/tests/test_never_raise.py +0 -0
  111. {codecortex-0.16.0 → codecortex-0.17.0}/tests/test_onboarding.py +0 -0
  112. {codecortex-0.16.0 → codecortex-0.17.0}/tests/test_rbac.py +0 -0
  113. {codecortex-0.16.0 → codecortex-0.17.0}/tests/test_reindexer.py +0 -0
  114. {codecortex-0.16.0 → codecortex-0.17.0}/tests/test_rerank.py +0 -0
  115. {codecortex-0.16.0 → codecortex-0.17.0}/tests/test_semantic_provider.py +0 -0
  116. {codecortex-0.16.0 → codecortex-0.17.0}/tests/test_source_kind.py +0 -0
  117. {codecortex-0.16.0 → codecortex-0.17.0}/tests/test_term.py +0 -0
  118. {codecortex-0.16.0 → codecortex-0.17.0}/tests/test_treesitter.py +0 -0
  119. {codecortex-0.16.0 → codecortex-0.17.0}/tests/test_verify.py +0 -0
  120. {codecortex-0.16.0 → codecortex-0.17.0}/tests/test_verify_call.py +0 -0
  121. {codecortex-0.16.0 → codecortex-0.17.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.16.0
3
+ Version: 0.17.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
@@ -279,7 +279,7 @@ Then it launches the exact command it registered and drives a real MCP handshake
279
279
  ```text
280
280
  v claude: registered at /Users/you/.claude.json
281
281
 
282
- v verified: codeintel 0.11.2 — 4 tools (code.query, code.status, code.doctor, code.map)
282
+ v verified: codeintel 0.16.0 — 4 tools (code.query, code.status, code.doctor, code.map)
283
283
  ```
284
284
 
285
285
  If the command is not on `PATH`, or the server fails to start, install says so and exits non-zero
@@ -407,7 +407,7 @@ Full system docs live in [`docs/`](docs/) — start with the index:
407
407
  | `codeintel reset [project_root] [--all] [--yes] [--json]` | Clear the semantic index (this repo, or `--all`) to recover from a corrupt/stale DB |
408
408
  | `codeintel gen-token` | Print a secure random bearer token (for `serve-http` / RBAC `auth.toml`) |
409
409
 
410
- Human-facing commands (`doctor`, `status`, `query`, `setup`, `reset`) honor `--no-color` / `NO_COLOR` and `--ascii`, and auto-degrade to plain text when piped.
410
+ Human-facing commands (`doctor`, `setup`, `prompt`, `reset`) honor `--no-color` / `NO_COLOR` and `--ascii`, and auto-degrade to plain text when piped.
411
411
 
412
412
  **Exit codes**, so a `make` target or CI step can gate on `$?`:
413
413
 
@@ -617,7 +617,7 @@ git clone https://github.com/hamilton-sky/codeintel.git
617
617
  cd codeintel
618
618
  pip install -e .[dev]
619
619
 
620
- pytest tests/ -q # ~620 tests, ~30s; fails under 83% coverage
620
+ pytest tests/ -q # ~740 tests, ~30s; fails under 83% coverage
621
621
  ruff check src tests # lint
622
622
  mypy # types (src/ only)
623
623
  ```
@@ -242,7 +242,7 @@ Then it launches the exact command it registered and drives a real MCP handshake
242
242
  ```text
243
243
  v claude: registered at /Users/you/.claude.json
244
244
 
245
- v verified: codeintel 0.11.2 — 4 tools (code.query, code.status, code.doctor, code.map)
245
+ v verified: codeintel 0.16.0 — 4 tools (code.query, code.status, code.doctor, code.map)
246
246
  ```
247
247
 
248
248
  If the command is not on `PATH`, or the server fails to start, install says so and exits non-zero
@@ -370,7 +370,7 @@ Full system docs live in [`docs/`](docs/) — start with the index:
370
370
  | `codeintel reset [project_root] [--all] [--yes] [--json]` | Clear the semantic index (this repo, or `--all`) to recover from a corrupt/stale DB |
371
371
  | `codeintel gen-token` | Print a secure random bearer token (for `serve-http` / RBAC `auth.toml`) |
372
372
 
373
- Human-facing commands (`doctor`, `status`, `query`, `setup`, `reset`) honor `--no-color` / `NO_COLOR` and `--ascii`, and auto-degrade to plain text when piped.
373
+ Human-facing commands (`doctor`, `setup`, `prompt`, `reset`) honor `--no-color` / `NO_COLOR` and `--ascii`, and auto-degrade to plain text when piped.
374
374
 
375
375
  **Exit codes**, so a `make` target or CI step can gate on `$?`:
376
376
 
@@ -580,7 +580,7 @@ git clone https://github.com/hamilton-sky/codeintel.git
580
580
  cd codeintel
581
581
  pip install -e .[dev]
582
582
 
583
- pytest tests/ -q # ~620 tests, ~30s; fails under 83% coverage
583
+ pytest tests/ -q # ~740 tests, ~30s; fails under 83% coverage
584
584
  ruff check src tests # lint
585
585
  mypy # types (src/ only)
586
586
  ```
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codecortex
3
- Version: 0.16.0
3
+ Version: 0.17.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
@@ -279,7 +279,7 @@ Then it launches the exact command it registered and drives a real MCP handshake
279
279
  ```text
280
280
  v claude: registered at /Users/you/.claude.json
281
281
 
282
- v verified: codeintel 0.11.2 — 4 tools (code.query, code.status, code.doctor, code.map)
282
+ v verified: codeintel 0.16.0 — 4 tools (code.query, code.status, code.doctor, code.map)
283
283
  ```
284
284
 
285
285
  If the command is not on `PATH`, or the server fails to start, install says so and exits non-zero
@@ -407,7 +407,7 @@ Full system docs live in [`docs/`](docs/) — start with the index:
407
407
  | `codeintel reset [project_root] [--all] [--yes] [--json]` | Clear the semantic index (this repo, or `--all`) to recover from a corrupt/stale DB |
408
408
  | `codeintel gen-token` | Print a secure random bearer token (for `serve-http` / RBAC `auth.toml`) |
409
409
 
410
- Human-facing commands (`doctor`, `status`, `query`, `setup`, `reset`) honor `--no-color` / `NO_COLOR` and `--ascii`, and auto-degrade to plain text when piped.
410
+ Human-facing commands (`doctor`, `setup`, `prompt`, `reset`) honor `--no-color` / `NO_COLOR` and `--ascii`, and auto-degrade to plain text when piped.
411
411
 
412
412
  **Exit codes**, so a `make` target or CI step can gate on `$?`:
413
413
 
@@ -617,7 +617,7 @@ git clone https://github.com/hamilton-sky/codeintel.git
617
617
  cd codeintel
618
618
  pip install -e .[dev]
619
619
 
620
- pytest tests/ -q # ~620 tests, ~30s; fails under 83% coverage
620
+ pytest tests/ -q # ~740 tests, ~30s; fails under 83% coverage
621
621
  ruff check src tests # lint
622
622
  mypy # types (src/ only)
623
623
  ```
@@ -31,6 +31,7 @@ src/codeintel/metrics.py
31
31
  src/codeintel/onboarding.py
32
32
  src/codeintel/outcome.py
33
33
  src/codeintel/policy.py
34
+ src/codeintel/progress.py
34
35
  src/codeintel/provider.py
35
36
  src/codeintel/py.typed
36
37
  src/codeintel/redact.py
@@ -89,6 +90,8 @@ tests/test_hardening.py
89
90
  tests/test_http_auth.py
90
91
  tests/test_http_server.py
91
92
  tests/test_incompleteness.py
93
+ tests/test_index_progress.py
94
+ tests/test_indexer_binary.py
92
95
  tests/test_installer.py
93
96
  tests/test_integration.py
94
97
  tests/test_loc_census.py
@@ -0,0 +1 @@
1
+ __version__ = "0.17.0"
@@ -131,13 +131,19 @@ def main() -> None:
131
131
  subparsers.add_parser("serve", help="Start the MCP server")
132
132
 
133
133
  # index subcommand
134
- index_parser = subparsers.add_parser("index", help="Index a project for semantic search")
134
+ index_parser = subparsers.add_parser(
135
+ "index", parents=[color_parent],
136
+ help="Index a project for semantic search")
135
137
  index_parser.add_argument(
136
138
  "project_root",
137
139
  nargs="?",
138
140
  default=None,
139
141
  help="Project root directory (default: cwd)",
140
142
  )
143
+ index_parser.add_argument(
144
+ "--quiet", "-q", action="store_true",
145
+ help="Suppress the live progress display and header; keep only the result line",
146
+ )
141
147
 
142
148
  # query subcommand
143
149
  query_parser = subparsers.add_parser("query", help="Query the code intelligence engine")
@@ -0,0 +1,144 @@
1
+ """`codeintel index` — build the semantic index, then best-effort refresh the graph and the map."""
2
+
3
+ import logging
4
+ import os
5
+ from typing import Any
6
+
7
+ from codeintel.commands._common import resolve_root
8
+
9
+
10
+ class _ProgressLogBridge(logging.Handler):
11
+ """Routes WARNING+ log records from the indexer to a live progress counter's ``notice()``, so a
12
+ per-file skip warning prints as a clean permanent line ABOVE the redrawing status line instead
13
+ of colliding with it (``0 chunksskipping …``). Installed only for a live (TTY) run and removed
14
+ the moment indexing returns — a non-TTY has no live line to protect, and the warnings keep their
15
+ normal stderr path there."""
16
+
17
+ def __init__(self, counter: Any) -> None:
18
+ super().__init__(level=logging.WARNING)
19
+ self._counter = counter
20
+
21
+ def emit(self, record: logging.LogRecord) -> None:
22
+ try:
23
+ self._counter.notice(record.getMessage())
24
+ except Exception:
25
+ pass
26
+
27
+
28
+ def run(args: Any) -> int:
29
+ import sys
30
+
31
+ import codeintel.term as term
32
+ from codeintel.config import load_config
33
+ from codeintel.indexer import Indexer
34
+ from codeintel.semantic_db import SemanticDb, default_db_path
35
+
36
+ project_root = resolve_root(args)
37
+ # Validate before doing anything. `codeintel index /typo/path` walked nothing, found nothing,
38
+ # and printed "Nothing new to index" at exit 0 — indistinguishable from a correct incremental
39
+ # run, which is the worst possible response to a mistyped path in a script.
40
+ if not os.path.isdir(project_root):
41
+ print(f"index failed: not a directory: {project_root}")
42
+ return 1
43
+
44
+ # A console bound to the CURRENT stdout (honouring --no-color/--ascii/NO_COLOR), so progress is
45
+ # captured under test and routed correctly whether or not main() already ran configure(). The
46
+ # LiveCounter auto-goes silent on a non-TTY; --quiet suppresses it (and the header) outright.
47
+ console = term.Console(
48
+ stream=sys.stdout,
49
+ no_color=getattr(args, "no_color", False),
50
+ ascii_mode=(True if getattr(args, "ascii", False) else None),
51
+ )
52
+ quiet = getattr(args, "quiet", False)
53
+ counter = None if quiet else term.LiveCounter(console)
54
+
55
+ failed = False
56
+ # Wrap the whole semantic pass so a setup failure (e.g. an unresolvable home dir →
57
+ # Path.home() raising) degrades with a message, like every other subcommand, not a traceback.
58
+ try:
59
+ cfg = load_config(project_root)
60
+ if not quiet:
61
+ # One dim header naming the model + chunk strategy: it says WHAT is running (and that it
62
+ # is all local), and on a cold cache it pre-explains the model-download stall.
63
+ strat = str(cfg.get("chunk_strategy", "syntax"))
64
+ strat_label = "def-aligned" if strat == "syntax" else "line-window"
65
+ model = str(cfg.get("model") or "BAAI/bge-small-en-v1.5")
66
+ sep = "." if console.ascii else "·"
67
+ print(console.header("index", project_root))
68
+ print(f" {console.dim(f'{model} {sep} {strat_label} chunks')}")
69
+ print()
70
+ db_path = default_db_path(str(cfg.get("model") or ""))
71
+ os.makedirs(os.path.dirname(db_path), exist_ok=True)
72
+ db = SemanticDb(db_path)
73
+ # While a live status line is on screen, the indexer's skip WARNINGs must not print raw to
74
+ # the terminal (they land mid-line). Route them through the counter's notice() for the span
75
+ # of the index pass only; a non-TTY needs no bridge (its status lines end in \n).
76
+ bridge = _ProgressLogBridge(counter) if (counter is not None and counter.live) else None
77
+ idx_logger = logging.getLogger("codeintel")
78
+ try:
79
+ db.init()
80
+ if bridge is not None:
81
+ idx_logger.addHandler(bridge)
82
+ try:
83
+ count = Indexer(
84
+ db,
85
+ model_name=str(cfg.get("model") or "BAAI/bge-small-en-v1.5"),
86
+ window=int(cfg.get("window", 20)),
87
+ stride=int(cfg.get("stride", 10)),
88
+ max_chunks=int(cfg.get("max_chunks", 500)),
89
+ max_total_chunks=int(cfg.get("max_total_chunks", 100000)),
90
+ chunk_strategy=str(cfg.get("chunk_strategy", "syntax")),
91
+ progress=counter,
92
+ ).index(project_root)
93
+ finally:
94
+ if bridge is not None:
95
+ idx_logger.removeHandler(bridge)
96
+ # Seal the live line before the plain summary: commit the ✓ row on real work, else erase
97
+ # it so "Nothing new" / the failure message stands on its own clean line.
98
+ if counter:
99
+ counter.finish(commit=count > 0)
100
+ if count > 0:
101
+ print(f"Indexed {count} chunks")
102
+ elif count < 0:
103
+ # Indexer.index() returns -1 for an unrecoverable failure. `> 0` sent that into
104
+ # the "Nothing new to index" branch, so a total failure read as a clean no-op.
105
+ print("index failed — the indexer could not complete (see the warnings above)")
106
+ failed = True
107
+ else:
108
+ print("Nothing new to index")
109
+ finally:
110
+ db.close()
111
+ except Exception as exc:
112
+ if counter:
113
+ counter.finish(commit=False)
114
+ print(f"index failed: {exc}")
115
+ failed = True
116
+
117
+ # best-effort graph reindex — an opaque subprocess, so a ticking elapsed heartbeat is the honest
118
+ # fidelity (no counts cross the boundary): the number keeps moving so a minute-long reindex never
119
+ # reads as a hang, but we never fabricate a percentage we can't see.
120
+ import shutil
121
+
122
+ if shutil.which("codebase-memory-mcp"):
123
+ beat = None if quiet else term.LiveHeartbeat(console, "graph reindex").start()
124
+ try:
125
+ from codeintel.reindexer import Reindexer
126
+
127
+ Reindexer()._graph_reindex(project_root)
128
+ if beat:
129
+ beat.stop("ok")
130
+ except Exception:
131
+ if beat:
132
+ beat.stop("warn", "skipped")
133
+
134
+ # best-effort map refresh after index (fast, kept silent — not worth a checklist row)
135
+ try:
136
+ from codeintel.mapper import MapGenerator
137
+ from codeintel.providers.graph import GraphProvider
138
+
139
+ gen = MapGenerator(GraphProvider())
140
+ gen.write(project_root, gen.generate(project_root))
141
+ except Exception:
142
+ pass
143
+
144
+ return 1 if failed else 0
@@ -1,4 +1,4 @@
1
- """`codeintel reset` — clear the semantic index (recover from a corrupt/stale DB)."""
1
+ """`codeintel reset` — clear a repo's index (semantic + graph), or the whole cache with --all."""
2
2
 
3
3
  import sys
4
4
  from typing import Any
@@ -18,7 +18,7 @@ def run(args: Any) -> int:
18
18
  return 1
19
19
  target = "ALL projects" if args.all else project_root
20
20
  count = preview.get("count", 0)
21
- ans = input(f"Reset semantic index for {target} ({count} entries)? [y/N] ").strip().lower()
21
+ ans = input(f"Reset index for {target} ({count} entries)? [y/N] ").strip().lower()
22
22
  if ans not in ("y", "yes"):
23
23
  print("aborted")
24
24
  return 0
@@ -24,7 +24,6 @@ _AUTO_ENGINE: dict[str, str] = {
24
24
  "overview": "graph",
25
25
  "changed": "graph",
26
26
  "changes": "graph",
27
- "deadcode": "graph",
28
27
  "hotspots": "graph",
29
28
  "symbol": "lsp",
30
29
  "search": "semantic",
@@ -33,8 +32,9 @@ _AUTO_ENGINE: dict[str, str] = {
33
32
 
34
33
  # Ops whose answer depends on live, unhashable state (the git worktree) rather than the indexed
35
34
  # content the cache key is built from — caching them would serve a stale answer within a freshness
36
- # generation. `changed` reads uncommitted edits; `deadcode`/`hotspots` are pure functions of the
37
- # index and stay cached (correctly keyed by the freshness generation).
35
+ # generation. `changed` reads uncommitted edits; `hotspots` is a pure function of the index and
36
+ # stays cached (correctly keyed by the freshness generation). A retired/unknown op falls through
37
+ # to the graph engine (the `_AUTO_ENGINE.get(op, "graph")` default), which safe-nulls it.
38
38
  _UNCACHED_OPS: frozenset[str] = frozenset({"changed", "changes"})
39
39
 
40
40
  # Engines whose slot can be filled after construction (see `Gateway.adopt_provider`).
@@ -10,6 +10,7 @@ from pathlib import Path
10
10
  from typing import TYPE_CHECKING
11
11
 
12
12
  from codeintel.containment import contained_path, real_root
13
+ from codeintel.progress import ProgressSink, _Guard
13
14
  from codeintel.source_kind import (
14
15
  CODE_EXTS,
15
16
  load_gitattributes_globs,
@@ -170,6 +171,20 @@ def _looks_binary(path) -> bool:
170
171
  return True # unreadable → treat as unindexable rather than crash
171
172
 
172
173
 
174
+ def _nul_byte_line(path) -> int | None:
175
+ """1-based line of the first NUL byte within the sniff window, or None. Turns the bare "looks
176
+ binary" skip into a message that points at the exact spot — a raw NUL in a source file is almost
177
+ always a deliberate separator (`.join('\\0')`, a composite key) saved as a byte instead of an
178
+ escape, and naming the line is the difference between a fixable warning and a puzzle."""
179
+ try:
180
+ with open(path, "rb") as fh:
181
+ head = fh.read(_BINARY_SNIFF_BYTES)
182
+ except OSError:
183
+ return None
184
+ i = head.find(b"\x00")
185
+ return None if i < 0 else head.count(b"\n", 0, i) + 1
186
+
187
+
173
188
  class Indexer:
174
189
  def __init__(
175
190
  self,
@@ -181,9 +196,14 @@ class Indexer:
181
196
  max_total_chunks: int = 100000,
182
197
  chunk_strategy: str = "syntax",
183
198
  max_chunk_lines: int | None = None,
199
+ progress: ProgressSink | None = None,
184
200
  ) -> None:
185
201
  self.db = db
186
202
  self.model_name = model_name
203
+ # Optional live-progress sink. Wrapped once in _Guard so every emit below is null-safe and
204
+ # never-raise: with the default None it is a no-op (the MCP server and Reindexer get silence
205
+ # and pay nothing), and a misbehaving sink can never abort a pass or change the count.
206
+ self._progress = _Guard(progress)
187
207
  # Coerce the numeric knobs defensively: the 4 production call sites pass config-validated
188
208
  # values, but a direct caller must not be able to set a stride/window that raises in
189
209
  # range() or silently drops regions (mirrors config._coerce's _POSITIVE_INTS clamp).
@@ -356,7 +376,13 @@ class Indexer:
356
376
  # garbage — 196KB of /dev/urandom produced 162 chunks — which then competed for
357
377
  # rank against real code in every search.
358
378
  if _looks_binary(candidate):
359
- logger.warning("skipping %s — looks binary despite its extension", candidate)
379
+ line = _nul_byte_line(candidate)
380
+ where = f" at line {line}" if line else ""
381
+ logger.warning(
382
+ "skipping %s — contains a null byte%s, so it reads as binary (the rule git "
383
+ "uses too). If that null is intentional (e.g. a key/join separator), write "
384
+ "it as the `\\0` escape instead of a raw byte and re-run index.",
385
+ candidate, where)
360
386
  continue
361
387
  # Last resort, and the only check that catches a bundle whose directory and
362
388
  # filename both look ordinary. One 6.7MB minified chunk contributes hundreds of
@@ -648,6 +674,7 @@ class Indexer:
648
674
  reconcile each fully-processed file (dropping rows for chunks it no longer produces)."""
649
675
  conn = self.db.conn()
650
676
  new_chunks: list[tuple[str, str, str, int, str]] = []
677
+ files_seen = 0
651
678
 
652
679
  for filepath in self._walk_files(root):
653
680
  if len(new_chunks) >= self.max_total_chunks:
@@ -657,6 +684,10 @@ class Indexer:
657
684
  self.max_total_chunks,
658
685
  )
659
686
  break
687
+ files_seen += 1
688
+ # A tick per file guarantees liveness even across a run of files that `continue` below
689
+ # (vanished / unreadable), which is exactly when a lone final tick would look stuck.
690
+ self._progress.scan(files_seen, len(new_chunks))
660
691
  try:
661
692
  with open(filepath, encoding="utf-8", errors="replace") as f:
662
693
  lines = f.readlines()
@@ -685,17 +716,25 @@ class Indexer:
685
716
  # out of scope for the indexer.
686
717
  self.db.delete_file_orphans(project_root_real, rel_path, keep_ids)
687
718
 
719
+ # One final tick with the true totals so the committed scan line is accurate (the per-file
720
+ # ticks lag by the current file's chunks). The renderer records this even when throttled.
721
+ self._progress.scan(files_seen, len(new_chunks))
688
722
  return new_chunks
689
723
 
690
724
  def _embed_and_write(
691
725
  self, new_chunks: list[tuple[str, str, str, int, str]], project_root_real: str
692
726
  ) -> int:
727
+ total = len(new_chunks)
728
+ # Signal the model-load BEFORE materialising the embedder: on a cold cache _get_embedder()
729
+ # downloads the model (minutes), a distinct stall the scan counter can't cover.
730
+ self._progress.load_model()
693
731
  embedder = self._get_embedder() # may raise → propagates to index() → returns -1
694
732
  conn = self.db.conn()
695
733
  embedded_count = 0
696
734
  batch_size = 32
735
+ self._progress.embed(0, total) # land on 0% the instant the model is ready
697
736
 
698
- for i in range(0, len(new_chunks), batch_size):
737
+ for i in range(0, total, batch_size):
699
738
  batch = new_chunks[i: i + batch_size]
700
739
  texts = [c[1] for c in batch]
701
740
 
@@ -744,6 +783,10 @@ class Indexer:
744
783
  except Exception as exc:
745
784
  logger.warning("commit failed after batch %d: %s", i // batch_size, exc)
746
785
 
786
+ # Report POSITION, not embedded_count: a skipped/failed batch still advanced the work,
787
+ # and a bar that stalls on a `continue` is a lie. Always lands on total at the last batch.
788
+ self._progress.embed(min(i + batch_size, total), total)
789
+
747
790
  return embedded_count
748
791
 
749
792
  def _index(self, project_root: str) -> int:
@@ -0,0 +1,78 @@
1
+ """The progress-reporting seam for indexing.
2
+
3
+ This is a deliberately tiny, dependency-free contract that lets the indexer say *how far it is*
4
+ without knowing anything about terminals, TTYs, or ANSI. The renderer that turns these calls into
5
+ a live line (``term.LiveCounter``) lives on the far side of this line: it never sees a chunk, and
6
+ the indexer never sees an escape byte. Neither imports the other — the renderer merely *matches*
7
+ ``ProgressSink``'s shape.
8
+
9
+ The never-raise guarantee for progress lives in exactly ONE place — ``_Guard``. The indexer calls
10
+ the guard's methods unguarded, so a broken, slow, or half-written sink can never abort an index
11
+ pass or change the count it returns. That single-choke-point is the whole reason this module exists
12
+ rather than the indexer poking a console directly.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ from typing import Protocol, runtime_checkable
17
+
18
+
19
+ @runtime_checkable
20
+ class ProgressSink(Protocol):
21
+ """What the indexer emits while it works. Three calls, because the phases genuinely differ:
22
+
23
+ * ``scan`` — the file walk. The file list is a generator, so there is no total; this is a
24
+ running count only (files seen, chunks collected so far).
25
+ * ``load_model`` — a bare marker fired just before the embedding model is materialised. On a
26
+ cold cache that step downloads hundreds of MB and is a *separate* multi-minute stall that
27
+ precedes the first batch; without its own signal it reads as a fresh hang the moment the scan
28
+ counter stops moving.
29
+ * ``embed`` — the batch loop. The total is known up front (``len(new_chunks)``), so this is a
30
+ real ``done``/``total`` and can drive a true percentage.
31
+
32
+ Implementations should not raise, but they are not trusted to honour that: every call the
33
+ indexer makes is routed through :class:`_Guard`.
34
+ """
35
+
36
+ def scan(self, files: int, chunks: int) -> None: ...
37
+ def load_model(self) -> None: ...
38
+ def embed(self, done: int, total: int) -> None: ...
39
+
40
+
41
+ class _Guard:
42
+ """Null-safe, never-raise adapter around an optional :class:`ProgressSink`.
43
+
44
+ ``Indexer`` holds one of these instead of the raw sink and calls it directly. When the sink is
45
+ ``None`` (the default — the MCP server, ``Reindexer``, and every test that doesn't opt in) each
46
+ method is a no-op; when it is present, any exception it throws is swallowed here. This is the
47
+ only spot the contract "progress can never break indexing" is enforced, so it is the only spot
48
+ that needs a test to prove it.
49
+ """
50
+
51
+ __slots__ = ("_sink",)
52
+
53
+ def __init__(self, sink: ProgressSink | None) -> None:
54
+ self._sink = sink
55
+
56
+ def scan(self, files: int, chunks: int) -> None:
57
+ if self._sink is None:
58
+ return
59
+ try:
60
+ self._sink.scan(files, chunks)
61
+ except Exception:
62
+ pass
63
+
64
+ def load_model(self) -> None:
65
+ if self._sink is None:
66
+ return
67
+ try:
68
+ self._sink.load_model()
69
+ except Exception:
70
+ pass
71
+
72
+ def embed(self, done: int, total: int) -> None:
73
+ if self._sink is None:
74
+ return
75
+ try:
76
+ self._sink.embed(done, total)
77
+ except Exception:
78
+ pass
@@ -8,6 +8,7 @@ from __future__ import annotations
8
8
  import glob
9
9
  import logging
10
10
  import os
11
+ import re
11
12
  import sqlite3
12
13
 
13
14
  import sqlite_vec
@@ -159,6 +160,44 @@ def _reset_graph_cache(apply: bool) -> dict:
159
160
  return {"count": count, "corrupt": corrupt}
160
161
 
161
162
 
163
+ def _graph_slug(canonical_root: str) -> str:
164
+ """The stem the graph backend gives a project's cache file: every run of non-alphanumeric
165
+ characters collapses to a single ``-``, with the ends trimmed. So ``/Users/me/aws-resource-tools``
166
+ → ``Users-me-aws-resource-tools`` and ``/tmp/-Weird/repo`` → ``tmp-Weird-repo`` (NOT
167
+ ``tmp--Weird-repo`` — a naive ``/``→``-`` replace leaves a double dash and misses the file,
168
+ silently leaking the graph index a scoped reset was meant to remove). Verified against real
169
+ on-disk caches. The collapse also flattens away every path separator, so the target can never
170
+ escape the cache directory."""
171
+ return re.sub(r"[^A-Za-z0-9]+", "-", canonical_root).strip("-")
172
+
173
+
174
+ def _reset_graph_project(project_root: str, apply: bool) -> dict:
175
+ """Remove ONE project's graph index — the per-project sqlite the backend names ``<slug>.db`` (see
176
+ :func:`_graph_slug`), plus its ``-wal``/``-shm``/``.corrupt`` siblings.
177
+
178
+ This is the graph half of a *scoped* reset. Without it, ``reset <repo>`` cleared only the
179
+ semantic side and left the repo listed and queryable in the graph engine — so "start as if never
180
+ indexed" was true only for ``--all``. ``list_projects`` enumerates these files (``_config.db`` is
181
+ key/value config, not a registry), so deleting them is exactly what makes one project vanish.
182
+ Pure file removal like ``_reset_graph_cache``: it works with the backend down and never hangs."""
183
+ directory = _graph_cache_dir()
184
+ slug = _graph_slug(os.path.realpath(str(project_root)))
185
+ found = 0
186
+ removed = 0
187
+ for suffix in (".db", ".db-wal", ".db-shm", ".db.corrupt"):
188
+ path = os.path.join(directory, slug + suffix)
189
+ if not os.path.isfile(path):
190
+ continue
191
+ found += 1
192
+ if apply:
193
+ try:
194
+ os.remove(path)
195
+ removed += 1
196
+ except Exception:
197
+ pass
198
+ return {"slug": slug, "found": found, "removed": removed}
199
+
200
+
162
201
  def _looks_unreadable(exc: Exception) -> bool:
163
202
  """Whether *exc* means "this file is not a usable sqlite cache" (as opposed to e.g. a lock)."""
164
203
  text = f"{type(exc).__name__}: {exc}".lower()
@@ -220,15 +259,22 @@ def run_reset(
220
259
  real = os.path.realpath(str(project_root))
221
260
  results = [_reset_scoped(project_root, p, apply) for p in files]
222
261
  count = sum(r["count"] for r in results)
262
+ # The graph half of a scoped reset — the piece that was missing, so `reset <repo>` now clears
263
+ # BOTH engines and the repo is genuinely "never indexed", matching what `--all` does globally.
264
+ graph = _reset_graph_project(project_root, apply)
223
265
  verb = "removed" if apply else "found"
224
266
  # Surface per-file trouble instead of summing counts and synthesizing a success line —
225
267
  # discarding these is what made a failed reset indistinguishable from a clean one.
226
268
  problems = [r["detail"] for r in results if r.get("error") or r.get("recovered") is not None]
227
- detail = f"{verb} {count} indexed chunk(s) for this project"
269
+ detail = f"{verb} {count} indexed chunk(s)"
270
+ if graph["found"]:
271
+ detail += " and the graph index" if apply else " and a graph index"
272
+ detail += " for this project"
228
273
  if problems:
229
274
  detail = "\n".join([*problems, detail])
230
275
  return {"ok": True, "mode": "scoped", "target": real, "count": count, "applied": bool(apply),
231
276
  "detail": detail,
277
+ "graph_found": graph["found"], "graph_removed": graph["removed"],
232
278
  "failed": any(r.get("error") for r in results)}
233
279
  except Exception as exc:
234
280
  return {"ok": True, "applied": apply, "detail": f"reset-error: {type(exc).__name__}: {exc}"}