codecortex 0.13.0__tar.gz → 0.14.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {codecortex-0.13.0/src/codecortex.egg-info → codecortex-0.14.0}/PKG-INFO +32 -4
- {codecortex-0.13.0 → codecortex-0.14.0}/README.md +31 -3
- {codecortex-0.13.0 → codecortex-0.14.0/src/codecortex.egg-info}/PKG-INFO +32 -4
- codecortex-0.14.0/src/codeintel/__init__.py +1 -0
- {codecortex-0.13.0 → codecortex-0.14.0}/src/codeintel/auth.py +35 -4
- {codecortex-0.13.0 → codecortex-0.14.0}/src/codeintel/cache.py +12 -1
- {codecortex-0.13.0 → codecortex-0.14.0}/src/codeintel/commands/graph.py +3 -1
- {codecortex-0.13.0 → codecortex-0.14.0}/src/codeintel/commands/index.py +14 -1
- {codecortex-0.13.0 → codecortex-0.14.0}/src/codeintel/commands/map.py +3 -1
- {codecortex-0.13.0 → codecortex-0.14.0}/src/codeintel/gateway.py +37 -12
- {codecortex-0.13.0 → codecortex-0.14.0}/src/codeintel/http_server.py +7 -3
- {codecortex-0.13.0 → codecortex-0.14.0}/src/codeintel/indexer.py +56 -2
- {codecortex-0.13.0 → codecortex-0.14.0}/src/codeintel/injector.py +36 -7
- {codecortex-0.13.0 → codecortex-0.14.0}/src/codeintel/installer.py +30 -3
- codecortex-0.14.0/src/codeintel/policy.py +93 -0
- {codecortex-0.13.0 → codecortex-0.14.0}/src/codeintel/providers/graph.py +73 -11
- {codecortex-0.13.0 → codecortex-0.14.0}/src/codeintel/reindexer.py +44 -6
- {codecortex-0.13.0 → codecortex-0.14.0}/src/codeintel/reset.py +48 -3
- {codecortex-0.13.0 → codecortex-0.14.0}/src/codeintel/server.py +14 -0
- codecortex-0.14.0/tests/test_cache.py +109 -0
- {codecortex-0.13.0 → codecortex-0.14.0}/tests/test_cli_commands.py +24 -3
- {codecortex-0.13.0 → codecortex-0.14.0}/tests/test_gateway.py +100 -0
- codecortex-0.14.0/tests/test_graph_provider.py +310 -0
- {codecortex-0.13.0 → codecortex-0.14.0}/tests/test_graph_stdin.py +33 -2
- {codecortex-0.13.0 → codecortex-0.14.0}/tests/test_installer.py +47 -0
- {codecortex-0.13.0 → codecortex-0.14.0}/tests/test_mapper.py +56 -0
- codecortex-0.14.0/tests/test_rbac.py +438 -0
- codecortex-0.14.0/tests/test_reindexer.py +262 -0
- {codecortex-0.13.0 → codecortex-0.14.0}/tests/test_reset.py +44 -0
- codecortex-0.13.0/src/codeintel/__init__.py +0 -1
- codecortex-0.13.0/src/codeintel/policy.py +0 -28
- codecortex-0.13.0/tests/test_cache.py +0 -61
- codecortex-0.13.0/tests/test_graph_provider.py +0 -170
- codecortex-0.13.0/tests/test_rbac.py +0 -178
- codecortex-0.13.0/tests/test_reindexer.py +0 -128
- {codecortex-0.13.0 → codecortex-0.14.0}/LICENSE +0 -0
- {codecortex-0.13.0 → codecortex-0.14.0}/pyproject.toml +0 -0
- {codecortex-0.13.0 → codecortex-0.14.0}/setup.cfg +0 -0
- {codecortex-0.13.0 → codecortex-0.14.0}/src/codecortex.egg-info/SOURCES.txt +0 -0
- {codecortex-0.13.0 → codecortex-0.14.0}/src/codecortex.egg-info/dependency_links.txt +0 -0
- {codecortex-0.13.0 → codecortex-0.14.0}/src/codecortex.egg-info/entry_points.txt +0 -0
- {codecortex-0.13.0 → codecortex-0.14.0}/src/codecortex.egg-info/requires.txt +0 -0
- {codecortex-0.13.0 → codecortex-0.14.0}/src/codecortex.egg-info/top_level.txt +0 -0
- {codecortex-0.13.0 → codecortex-0.14.0}/src/codeintel/__main__.py +0 -0
- {codecortex-0.13.0 → codecortex-0.14.0}/src/codeintel/commands/__init__.py +0 -0
- {codecortex-0.13.0 → codecortex-0.14.0}/src/codeintel/commands/_common.py +0 -0
- {codecortex-0.13.0 → codecortex-0.14.0}/src/codeintel/commands/doctor.py +0 -0
- {codecortex-0.13.0 → codecortex-0.14.0}/src/codeintel/commands/gen_token.py +0 -0
- {codecortex-0.13.0 → codecortex-0.14.0}/src/codeintel/commands/install.py +0 -0
- {codecortex-0.13.0 → codecortex-0.14.0}/src/codeintel/commands/query.py +0 -0
- {codecortex-0.13.0 → codecortex-0.14.0}/src/codeintel/commands/reset.py +0 -0
- {codecortex-0.13.0 → codecortex-0.14.0}/src/codeintel/commands/serve.py +0 -0
- {codecortex-0.13.0 → codecortex-0.14.0}/src/codeintel/commands/serve_http.py +0 -0
- {codecortex-0.13.0 → codecortex-0.14.0}/src/codeintel/commands/setup.py +0 -0
- {codecortex-0.13.0 → codecortex-0.14.0}/src/codeintel/commands/status.py +0 -0
- {codecortex-0.13.0 → codecortex-0.14.0}/src/codeintel/config.py +0 -0
- {codecortex-0.13.0 → codecortex-0.14.0}/src/codeintel/doctor.py +0 -0
- {codecortex-0.13.0 → codecortex-0.14.0}/src/codeintel/grapher.py +0 -0
- {codecortex-0.13.0 → codecortex-0.14.0}/src/codeintel/logconfig.py +0 -0
- {codecortex-0.13.0 → codecortex-0.14.0}/src/codeintel/mapper.py +0 -0
- {codecortex-0.13.0 → codecortex-0.14.0}/src/codeintel/metrics.py +0 -0
- {codecortex-0.13.0 → codecortex-0.14.0}/src/codeintel/onboarding.py +0 -0
- {codecortex-0.13.0 → codecortex-0.14.0}/src/codeintel/provider.py +0 -0
- {codecortex-0.13.0 → codecortex-0.14.0}/src/codeintel/providers/__init__.py +0 -0
- {codecortex-0.13.0 → codecortex-0.14.0}/src/codeintel/providers/lsp.py +0 -0
- {codecortex-0.13.0 → codecortex-0.14.0}/src/codeintel/providers/none.py +0 -0
- {codecortex-0.13.0 → codecortex-0.14.0}/src/codeintel/providers/semantic.py +0 -0
- {codecortex-0.13.0 → codecortex-0.14.0}/src/codeintel/py.typed +0 -0
- {codecortex-0.13.0 → codecortex-0.14.0}/src/codeintel/searcher.py +0 -0
- {codecortex-0.13.0 → codecortex-0.14.0}/src/codeintel/semantic_db.py +0 -0
- {codecortex-0.13.0 → codecortex-0.14.0}/src/codeintel/term.py +0 -0
- {codecortex-0.13.0 → codecortex-0.14.0}/src/codeintel/verify.py +0 -0
- {codecortex-0.13.0 → codecortex-0.14.0}/src/codeintel/viewer/__init__.py +0 -0
- {codecortex-0.13.0 → codecortex-0.14.0}/src/codeintel/viewer/graph_template.html +0 -0
- {codecortex-0.13.0 → codecortex-0.14.0}/tests/test_chunking.py +0 -0
- {codecortex-0.13.0 → codecortex-0.14.0}/tests/test_cli_help.py +0 -0
- {codecortex-0.13.0 → codecortex-0.14.0}/tests/test_config.py +0 -0
- {codecortex-0.13.0 → codecortex-0.14.0}/tests/test_doctor.py +0 -0
- {codecortex-0.13.0 → codecortex-0.14.0}/tests/test_e2e.py +0 -0
- {codecortex-0.13.0 → codecortex-0.14.0}/tests/test_engine_adoption.py +0 -0
- {codecortex-0.13.0 → codecortex-0.14.0}/tests/test_enterprise.py +0 -0
- {codecortex-0.13.0 → codecortex-0.14.0}/tests/test_graph_real.py +0 -0
- {codecortex-0.13.0 → codecortex-0.14.0}/tests/test_grapher.py +0 -0
- {codecortex-0.13.0 → codecortex-0.14.0}/tests/test_hardening.py +0 -0
- {codecortex-0.13.0 → codecortex-0.14.0}/tests/test_http_auth.py +0 -0
- {codecortex-0.13.0 → codecortex-0.14.0}/tests/test_http_server.py +0 -0
- {codecortex-0.13.0 → codecortex-0.14.0}/tests/test_integration.py +0 -0
- {codecortex-0.13.0 → codecortex-0.14.0}/tests/test_lsp_provider.py +0 -0
- {codecortex-0.13.0 → codecortex-0.14.0}/tests/test_lsp_real.py +0 -0
- {codecortex-0.13.0 → codecortex-0.14.0}/tests/test_mcp_handshake.py +0 -0
- {codecortex-0.13.0 → codecortex-0.14.0}/tests/test_mcp_server.py +0 -0
- {codecortex-0.13.0 → codecortex-0.14.0}/tests/test_model_dimension.py +0 -0
- {codecortex-0.13.0 → codecortex-0.14.0}/tests/test_never_raise.py +0 -0
- {codecortex-0.13.0 → codecortex-0.14.0}/tests/test_onboarding.py +0 -0
- {codecortex-0.13.0 → codecortex-0.14.0}/tests/test_rerank.py +0 -0
- {codecortex-0.13.0 → codecortex-0.14.0}/tests/test_semantic_provider.py +0 -0
- {codecortex-0.13.0 → codecortex-0.14.0}/tests/test_term.py +0 -0
- {codecortex-0.13.0 → codecortex-0.14.0}/tests/test_treesitter.py +0 -0
- {codecortex-0.13.0 → codecortex-0.14.0}/tests/test_verify.py +0 -0
- {codecortex-0.13.0 → codecortex-0.14.0}/tests/test_verify_call.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: codecortex
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.14.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
|
|
@@ -111,7 +111,7 @@ The agent hands `result` straight to the model. If the graph backend isn't insta
|
|
|
111
111
|
- **It never throws.** Every call returns the same JSON envelope; a missing or broken backend degrades to `null` *with a reason*. No exceptions, no 500s, no malformed output for the agent to trip over — so you never wrap `code.query` in a `try`.
|
|
112
112
|
- **One tool, not three.** Register a single MCP server and it auto-routes each question to graph, LSP, or semantic — instead of wiring up three backends with three response shapes and three failure modes.
|
|
113
113
|
- **Degrades instead of breaking.** No graph backend installed? That engine returns `null` and the agent falls back to grep. The semantic engine needs nothing external, so codeintel is useful the moment it's installed and only gets sharper as you add backends.
|
|
114
|
-
- **Fast on repeat, never
|
|
114
|
+
- **Fast on repeat, and the cache never lies.** A content-hash cache returns instantly for unchanged code and self-invalidates when a background reindex advances the index, so you never read a cached answer for code that moved on. The cache is bounded (LRU), so a long-running server holds steady memory. (The *cache* is always consistent with the index; how current the index itself is depends on the engine — see [Keeping answers fresh](#keeping-answers-fresh).)
|
|
115
115
|
- **Concurrency-safe.** The HTTP transport handles requests on threads, so one slow query (an LSP session warming, a first-time index) can't block every other agent.
|
|
116
116
|
- **Honest about its own health.** `codeintel doctor` answers three separate questions per engine — *installed?* *runnable?* *is this repo indexed?* — with the single command to fix each gap, so "installed" is never mistaken for "working". And a readiness claim is one a query can actually honor: install a missing backend mid-session and the running server picks it up on the next call, rather than reporting the engine healthy while quietly routing around it until you restart the host.
|
|
117
117
|
|
|
@@ -274,6 +274,34 @@ Every `Gateway.query()` call returns a dict with exactly these keys:
|
|
|
274
274
|
|
|
275
275
|
Run `codeintel doctor` at any time to see which engines are actually ready for a repo and how to fix the ones that aren't.
|
|
276
276
|
|
|
277
|
+
### Keeping answers fresh
|
|
278
|
+
|
|
279
|
+
The three engines have genuinely different freshness models, and it's worth knowing which you're
|
|
280
|
+
reading:
|
|
281
|
+
|
|
282
|
+
| Engine | Freshness |
|
|
283
|
+
|---|---|
|
|
284
|
+
| `lsp` | **Live.** Reads your files at query time — always current, no refresh needed. |
|
|
285
|
+
| `semantic` | **Incremental.** A background reindex re-embeds only what changed; `codeintel status` shows the index age. |
|
|
286
|
+
| `graph` | **Snapshot.** Built by `codeintel index` and stale until the next one. |
|
|
287
|
+
|
|
288
|
+
So a `callers`/`impact`/`hotspots` answer is only as current as your last index. If a result
|
|
289
|
+
describes code you just changed — or a symbol you just added comes back
|
|
290
|
+
`reason: "not-in-graph"` — that's the signal to re-run:
|
|
291
|
+
|
|
292
|
+
```bash
|
|
293
|
+
codeintel index /path/to/repo
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
The reply names the fix when it can: a missing symbol now returns a `hint` with the exact command
|
|
297
|
+
rather than a bare reason.
|
|
298
|
+
|
|
299
|
+
One more honest caveat. For targets that are **symbol names or free text** (most of them —
|
|
300
|
+
`callers`, `impact`, `hotspots`, `search`), there is no file whose content hash could change, so a
|
|
301
|
+
cached answer is invalidated only when a background reindex completes, and those are debounced
|
|
302
|
+
(~30s). An edit followed immediately by the same query can therefore return the pre-edit answer.
|
|
303
|
+
Targets that are real file paths are content-hashed and refresh as soon as the bytes change.
|
|
304
|
+
|
|
277
305
|
Pass `--engine auto` (the default) and codeintel chooses the best engine per operation. Pass `--engine both` or `--engine all` to fan out to multiple engines and merge results.
|
|
278
306
|
|
|
279
307
|
## Documentation
|
|
@@ -297,12 +325,12 @@ Full system docs live in [`docs/`](docs/) — start with the index:
|
|
|
297
325
|
| `codeintel index [project_root]` | Index a project for semantic search |
|
|
298
326
|
| `codeintel serve` | Start the MCP server (stdio transport) |
|
|
299
327
|
| `codeintel serve-http [--host HOST] [--port 8766] [--allow-remote] [--token TOKEN]` | Start the HTTP transport (loopback-only unless `--allow-remote`; `--token` requires a bearer token on every request) |
|
|
300
|
-
| `codeintel query --op OP --target TARGET [--engine auto]` | Run a single query and print the result |
|
|
328
|
+
| `codeintel query --op OP --target TARGET [--engine auto] [--project-root DIR]` | Run a single query and print the result |
|
|
301
329
|
| `codeintel status [project_root]` | Show engine availability and index age |
|
|
302
330
|
| `codeintel doctor [project_root] [--deep] [--json]` | Diagnose per-engine health + repo index status, with a fix for each gap |
|
|
303
331
|
| `codeintel map [project_root]` | Generate the `CODE_INTEL.md` orientation file |
|
|
304
332
|
| `codeintel graph [project_root] [--html] [--out FILE] [--limit N]` | Emit the call graph as `{nodes,edges}` JSON, or `--html` a self-contained interactive viewer — see [docs/graph-viewer.md](docs/graph-viewer.md) |
|
|
305
|
-
| `codeintel reset [project_root] [--all] [--yes]` | Clear the semantic index (this repo, or `--all`) to recover from a corrupt/stale DB |
|
|
333
|
+
| `codeintel reset [project_root] [--all] [--yes] [--json]` | Clear the semantic index (this repo, or `--all`) to recover from a corrupt/stale DB |
|
|
306
334
|
| `codeintel gen-token` | Print a secure random bearer token (for `serve-http` / RBAC `auth.toml`) |
|
|
307
335
|
|
|
308
336
|
Human-facing commands (`doctor`, `status`, `query`, `setup`, `reset`) honor `--no-color` / `NO_COLOR` and `--ascii`, and auto-degrade to plain text when piped.
|
|
@@ -74,7 +74,7 @@ The agent hands `result` straight to the model. If the graph backend isn't insta
|
|
|
74
74
|
- **It never throws.** Every call returns the same JSON envelope; a missing or broken backend degrades to `null` *with a reason*. No exceptions, no 500s, no malformed output for the agent to trip over — so you never wrap `code.query` in a `try`.
|
|
75
75
|
- **One tool, not three.** Register a single MCP server and it auto-routes each question to graph, LSP, or semantic — instead of wiring up three backends with three response shapes and three failure modes.
|
|
76
76
|
- **Degrades instead of breaking.** No graph backend installed? That engine returns `null` and the agent falls back to grep. The semantic engine needs nothing external, so codeintel is useful the moment it's installed and only gets sharper as you add backends.
|
|
77
|
-
- **Fast on repeat, never
|
|
77
|
+
- **Fast on repeat, and the cache never lies.** A content-hash cache returns instantly for unchanged code and self-invalidates when a background reindex advances the index, so you never read a cached answer for code that moved on. The cache is bounded (LRU), so a long-running server holds steady memory. (The *cache* is always consistent with the index; how current the index itself is depends on the engine — see [Keeping answers fresh](#keeping-answers-fresh).)
|
|
78
78
|
- **Concurrency-safe.** The HTTP transport handles requests on threads, so one slow query (an LSP session warming, a first-time index) can't block every other agent.
|
|
79
79
|
- **Honest about its own health.** `codeintel doctor` answers three separate questions per engine — *installed?* *runnable?* *is this repo indexed?* — with the single command to fix each gap, so "installed" is never mistaken for "working". And a readiness claim is one a query can actually honor: install a missing backend mid-session and the running server picks it up on the next call, rather than reporting the engine healthy while quietly routing around it until you restart the host.
|
|
80
80
|
|
|
@@ -237,6 +237,34 @@ Every `Gateway.query()` call returns a dict with exactly these keys:
|
|
|
237
237
|
|
|
238
238
|
Run `codeintel doctor` at any time to see which engines are actually ready for a repo and how to fix the ones that aren't.
|
|
239
239
|
|
|
240
|
+
### Keeping answers fresh
|
|
241
|
+
|
|
242
|
+
The three engines have genuinely different freshness models, and it's worth knowing which you're
|
|
243
|
+
reading:
|
|
244
|
+
|
|
245
|
+
| Engine | Freshness |
|
|
246
|
+
|---|---|
|
|
247
|
+
| `lsp` | **Live.** Reads your files at query time — always current, no refresh needed. |
|
|
248
|
+
| `semantic` | **Incremental.** A background reindex re-embeds only what changed; `codeintel status` shows the index age. |
|
|
249
|
+
| `graph` | **Snapshot.** Built by `codeintel index` and stale until the next one. |
|
|
250
|
+
|
|
251
|
+
So a `callers`/`impact`/`hotspots` answer is only as current as your last index. If a result
|
|
252
|
+
describes code you just changed — or a symbol you just added comes back
|
|
253
|
+
`reason: "not-in-graph"` — that's the signal to re-run:
|
|
254
|
+
|
|
255
|
+
```bash
|
|
256
|
+
codeintel index /path/to/repo
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
The reply names the fix when it can: a missing symbol now returns a `hint` with the exact command
|
|
260
|
+
rather than a bare reason.
|
|
261
|
+
|
|
262
|
+
One more honest caveat. For targets that are **symbol names or free text** (most of them —
|
|
263
|
+
`callers`, `impact`, `hotspots`, `search`), there is no file whose content hash could change, so a
|
|
264
|
+
cached answer is invalidated only when a background reindex completes, and those are debounced
|
|
265
|
+
(~30s). An edit followed immediately by the same query can therefore return the pre-edit answer.
|
|
266
|
+
Targets that are real file paths are content-hashed and refresh as soon as the bytes change.
|
|
267
|
+
|
|
240
268
|
Pass `--engine auto` (the default) and codeintel chooses the best engine per operation. Pass `--engine both` or `--engine all` to fan out to multiple engines and merge results.
|
|
241
269
|
|
|
242
270
|
## Documentation
|
|
@@ -260,12 +288,12 @@ Full system docs live in [`docs/`](docs/) — start with the index:
|
|
|
260
288
|
| `codeintel index [project_root]` | Index a project for semantic search |
|
|
261
289
|
| `codeintel serve` | Start the MCP server (stdio transport) |
|
|
262
290
|
| `codeintel serve-http [--host HOST] [--port 8766] [--allow-remote] [--token TOKEN]` | Start the HTTP transport (loopback-only unless `--allow-remote`; `--token` requires a bearer token on every request) |
|
|
263
|
-
| `codeintel query --op OP --target TARGET [--engine auto]` | Run a single query and print the result |
|
|
291
|
+
| `codeintel query --op OP --target TARGET [--engine auto] [--project-root DIR]` | Run a single query and print the result |
|
|
264
292
|
| `codeintel status [project_root]` | Show engine availability and index age |
|
|
265
293
|
| `codeintel doctor [project_root] [--deep] [--json]` | Diagnose per-engine health + repo index status, with a fix for each gap |
|
|
266
294
|
| `codeintel map [project_root]` | Generate the `CODE_INTEL.md` orientation file |
|
|
267
295
|
| `codeintel graph [project_root] [--html] [--out FILE] [--limit N]` | Emit the call graph as `{nodes,edges}` JSON, or `--html` a self-contained interactive viewer — see [docs/graph-viewer.md](docs/graph-viewer.md) |
|
|
268
|
-
| `codeintel reset [project_root] [--all] [--yes]` | Clear the semantic index (this repo, or `--all`) to recover from a corrupt/stale DB |
|
|
296
|
+
| `codeintel reset [project_root] [--all] [--yes] [--json]` | Clear the semantic index (this repo, or `--all`) to recover from a corrupt/stale DB |
|
|
269
297
|
| `codeintel gen-token` | Print a secure random bearer token (for `serve-http` / RBAC `auth.toml`) |
|
|
270
298
|
|
|
271
299
|
Human-facing commands (`doctor`, `status`, `query`, `setup`, `reset`) honor `--no-color` / `NO_COLOR` and `--ascii`, and auto-degrade to plain text when piped.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: codecortex
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.14.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
|
|
@@ -111,7 +111,7 @@ The agent hands `result` straight to the model. If the graph backend isn't insta
|
|
|
111
111
|
- **It never throws.** Every call returns the same JSON envelope; a missing or broken backend degrades to `null` *with a reason*. No exceptions, no 500s, no malformed output for the agent to trip over — so you never wrap `code.query` in a `try`.
|
|
112
112
|
- **One tool, not three.** Register a single MCP server and it auto-routes each question to graph, LSP, or semantic — instead of wiring up three backends with three response shapes and three failure modes.
|
|
113
113
|
- **Degrades instead of breaking.** No graph backend installed? That engine returns `null` and the agent falls back to grep. The semantic engine needs nothing external, so codeintel is useful the moment it's installed and only gets sharper as you add backends.
|
|
114
|
-
- **Fast on repeat, never
|
|
114
|
+
- **Fast on repeat, and the cache never lies.** A content-hash cache returns instantly for unchanged code and self-invalidates when a background reindex advances the index, so you never read a cached answer for code that moved on. The cache is bounded (LRU), so a long-running server holds steady memory. (The *cache* is always consistent with the index; how current the index itself is depends on the engine — see [Keeping answers fresh](#keeping-answers-fresh).)
|
|
115
115
|
- **Concurrency-safe.** The HTTP transport handles requests on threads, so one slow query (an LSP session warming, a first-time index) can't block every other agent.
|
|
116
116
|
- **Honest about its own health.** `codeintel doctor` answers three separate questions per engine — *installed?* *runnable?* *is this repo indexed?* — with the single command to fix each gap, so "installed" is never mistaken for "working". And a readiness claim is one a query can actually honor: install a missing backend mid-session and the running server picks it up on the next call, rather than reporting the engine healthy while quietly routing around it until you restart the host.
|
|
117
117
|
|
|
@@ -274,6 +274,34 @@ Every `Gateway.query()` call returns a dict with exactly these keys:
|
|
|
274
274
|
|
|
275
275
|
Run `codeintel doctor` at any time to see which engines are actually ready for a repo and how to fix the ones that aren't.
|
|
276
276
|
|
|
277
|
+
### Keeping answers fresh
|
|
278
|
+
|
|
279
|
+
The three engines have genuinely different freshness models, and it's worth knowing which you're
|
|
280
|
+
reading:
|
|
281
|
+
|
|
282
|
+
| Engine | Freshness |
|
|
283
|
+
|---|---|
|
|
284
|
+
| `lsp` | **Live.** Reads your files at query time — always current, no refresh needed. |
|
|
285
|
+
| `semantic` | **Incremental.** A background reindex re-embeds only what changed; `codeintel status` shows the index age. |
|
|
286
|
+
| `graph` | **Snapshot.** Built by `codeintel index` and stale until the next one. |
|
|
287
|
+
|
|
288
|
+
So a `callers`/`impact`/`hotspots` answer is only as current as your last index. If a result
|
|
289
|
+
describes code you just changed — or a symbol you just added comes back
|
|
290
|
+
`reason: "not-in-graph"` — that's the signal to re-run:
|
|
291
|
+
|
|
292
|
+
```bash
|
|
293
|
+
codeintel index /path/to/repo
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
The reply names the fix when it can: a missing symbol now returns a `hint` with the exact command
|
|
297
|
+
rather than a bare reason.
|
|
298
|
+
|
|
299
|
+
One more honest caveat. For targets that are **symbol names or free text** (most of them —
|
|
300
|
+
`callers`, `impact`, `hotspots`, `search`), there is no file whose content hash could change, so a
|
|
301
|
+
cached answer is invalidated only when a background reindex completes, and those are debounced
|
|
302
|
+
(~30s). An edit followed immediately by the same query can therefore return the pre-edit answer.
|
|
303
|
+
Targets that are real file paths are content-hashed and refresh as soon as the bytes change.
|
|
304
|
+
|
|
277
305
|
Pass `--engine auto` (the default) and codeintel chooses the best engine per operation. Pass `--engine both` or `--engine all` to fan out to multiple engines and merge results.
|
|
278
306
|
|
|
279
307
|
## Documentation
|
|
@@ -297,12 +325,12 @@ Full system docs live in [`docs/`](docs/) — start with the index:
|
|
|
297
325
|
| `codeintel index [project_root]` | Index a project for semantic search |
|
|
298
326
|
| `codeintel serve` | Start the MCP server (stdio transport) |
|
|
299
327
|
| `codeintel serve-http [--host HOST] [--port 8766] [--allow-remote] [--token TOKEN]` | Start the HTTP transport (loopback-only unless `--allow-remote`; `--token` requires a bearer token on every request) |
|
|
300
|
-
| `codeintel query --op OP --target TARGET [--engine auto]` | Run a single query and print the result |
|
|
328
|
+
| `codeintel query --op OP --target TARGET [--engine auto] [--project-root DIR]` | Run a single query and print the result |
|
|
301
329
|
| `codeintel status [project_root]` | Show engine availability and index age |
|
|
302
330
|
| `codeintel doctor [project_root] [--deep] [--json]` | Diagnose per-engine health + repo index status, with a fix for each gap |
|
|
303
331
|
| `codeintel map [project_root]` | Generate the `CODE_INTEL.md` orientation file |
|
|
304
332
|
| `codeintel graph [project_root] [--html] [--out FILE] [--limit N]` | Emit the call graph as `{nodes,edges}` JSON, or `--html` a self-contained interactive viewer — see [docs/graph-viewer.md](docs/graph-viewer.md) |
|
|
305
|
-
| `codeintel reset [project_root] [--all] [--yes]` | Clear the semantic index (this repo, or `--all`) to recover from a corrupt/stale DB |
|
|
333
|
+
| `codeintel reset [project_root] [--all] [--yes] [--json]` | Clear the semantic index (this repo, or `--all`) to recover from a corrupt/stale DB |
|
|
306
334
|
| `codeintel gen-token` | Print a secure random bearer token (for `serve-http` / RBAC `auth.toml`) |
|
|
307
335
|
|
|
308
336
|
Human-facing commands (`doctor`, `status`, `query`, `setup`, `reset`) honor `--no-color` / `NO_COLOR` and `--ascii`, and auto-degrade to plain text when piped.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.14.0"
|
|
@@ -38,9 +38,11 @@ class TokenAuth:
|
|
|
38
38
|
"""Resolves a bearer token to a role and builds the matching op policy. Tokens are held as
|
|
39
39
|
sha256 hashes; a presented token is hashed and looked up in O(1). Immutable; thread-safe."""
|
|
40
40
|
|
|
41
|
-
def __init__(self, token_hash_to_role: dict[str, str], role_ops: dict[str, list[str]]
|
|
41
|
+
def __init__(self, token_hash_to_role: dict[str, str], role_ops: dict[str, list[str]],
|
|
42
|
+
role_roots: dict[str, list[str]] | None = None) -> None:
|
|
42
43
|
self._tokens = token_hash_to_role
|
|
43
44
|
self._role_ops = role_ops
|
|
45
|
+
self._role_roots = role_roots or {}
|
|
44
46
|
|
|
45
47
|
@property
|
|
46
48
|
def enabled(self) -> bool:
|
|
@@ -56,13 +58,19 @@ class TokenAuth:
|
|
|
56
58
|
def build_policy(self) -> TieringPolicy:
|
|
57
59
|
"""A role with ops ``["*"]`` (or none) is unrestricted → omit it from the rules, since
|
|
58
60
|
TieringPolicy treats a role absent from its rules as full-access. Every other role maps to
|
|
59
|
-
its explicit op allowlist (an empty list = deny all ops, a fail-safe default).
|
|
61
|
+
its explicit op allowlist (an empty list = deny all ops, a fail-safe default).
|
|
62
|
+
|
|
63
|
+
``enabled`` is True whenever RBAC is configured at all — NOT merely when some role has a
|
|
64
|
+
restricted op list. It used to be ``bool(rules)``, which meant a config whose roles were
|
|
65
|
+
all ``["*"]`` produced a disabled policy, and a disabled policy enforces no root scoping
|
|
66
|
+
either. Op behavior is unchanged (a role absent from rules is still unrestricted); this
|
|
67
|
+
only ensures the root allowlist is actually consulted."""
|
|
60
68
|
rules: dict[str, list[str]] = {}
|
|
61
69
|
for role, ops in self._role_ops.items():
|
|
62
70
|
if _ALL in ops:
|
|
63
71
|
continue # unrestricted
|
|
64
72
|
rules[role] = list(ops)
|
|
65
|
-
return TieringPolicy(enabled=
|
|
73
|
+
return TieringPolicy(enabled=True, rules=rules, roots=dict(self._role_roots))
|
|
66
74
|
|
|
67
75
|
|
|
68
76
|
def _auth_config_path() -> pathlib.Path | None:
|
|
@@ -99,6 +107,19 @@ def load_auth() -> TokenAuth:
|
|
|
99
107
|
logger.warning("auth: role %r ops must be a list (got %r) — denying all ops for it", role, ops)
|
|
100
108
|
role_ops[str(role)] = []
|
|
101
109
|
|
|
110
|
+
roots_section = data.get("roots")
|
|
111
|
+
raw_roots: dict = roots_section if isinstance(roots_section, dict) else {}
|
|
112
|
+
role_roots: dict[str, list[str]] = {}
|
|
113
|
+
for role, paths in raw_roots.items():
|
|
114
|
+
if isinstance(paths, list):
|
|
115
|
+
role_roots[str(role)] = [str(r) for r in paths]
|
|
116
|
+
else:
|
|
117
|
+
# Same fail-CLOSED rule as ops: a bare string (`reader = "/srv/repo"`) is a typo, and
|
|
118
|
+
# guessing it meant a one-element list would hand out access the operator never wrote.
|
|
119
|
+
logger.warning("auth: role %r roots must be a list (got %r) — denying all paths for it",
|
|
120
|
+
role, paths)
|
|
121
|
+
role_roots[str(role)] = []
|
|
122
|
+
|
|
102
123
|
tokens_section = data.get("tokens")
|
|
103
124
|
raw_tokens: dict = tokens_section if isinstance(tokens_section, dict) else {}
|
|
104
125
|
token_hash_to_role: dict[str, str] = {}
|
|
@@ -107,6 +128,7 @@ def load_auth() -> TokenAuth:
|
|
|
107
128
|
# A token mapped to an undefined role fails safe: define it as deny-all (empty op list).
|
|
108
129
|
if role not in role_ops:
|
|
109
130
|
role_ops[role] = []
|
|
131
|
+
role_roots.setdefault(role, [])
|
|
110
132
|
tok = str(tok)
|
|
111
133
|
if tok[:len(_SHA_PREFIX)].lower() == _SHA_PREFIX: # case-insensitive `sha256:` prefix
|
|
112
134
|
h = tok[len(_SHA_PREFIX):].strip().lower()
|
|
@@ -118,4 +140,13 @@ def load_auth() -> TokenAuth:
|
|
|
118
140
|
if not token_hash_to_role:
|
|
119
141
|
logger.warning("auth: %s defines no usable [tokens] — RBAC is OFF (no token→role mapping)", path)
|
|
120
142
|
|
|
121
|
-
|
|
143
|
+
# A configured-but-unscoped RBAC deployment is the exact shape of the hole this closes, so say
|
|
144
|
+
# so at load time rather than letting the operator discover it as a wall of 403s.
|
|
145
|
+
unscoped = sorted(r for r in role_ops if not role_roots.get(r))
|
|
146
|
+
if token_hash_to_role and unscoped:
|
|
147
|
+
logger.warning(
|
|
148
|
+
"auth: roles %s have no [roots] entry and may target NO project — add a [roots] table "
|
|
149
|
+
"(e.g. `%s = [\"/srv/repos/team-a\"]`, or `= [\"*\"]` for unrestricted)",
|
|
150
|
+
", ".join(unscoped), unscoped[0])
|
|
151
|
+
|
|
152
|
+
return TokenAuth(token_hash_to_role, role_ops, role_roots)
|
|
@@ -9,9 +9,20 @@ from codeintel.provider import Result
|
|
|
9
9
|
|
|
10
10
|
|
|
11
11
|
def _compute_hash(target: str, project_root: str) -> str:
|
|
12
|
+
"""The cache key's content component: a file target hashes its BYTES, so an edit invalidates.
|
|
13
|
+
|
|
14
|
+
A relative target must be resolved against ``project_root``, not the process cwd. It used to
|
|
15
|
+
use ``os.path.realpath(target)`` alone — and the ops whose target is a path take it
|
|
16
|
+
repo-relative (`overview` passes it straight through as serena's ``relative_path``). The
|
|
17
|
+
server is a long-lived singleton answering for whichever ``project_root`` a caller names, so
|
|
18
|
+
its cwd matches at most one repo and usually none: `inside_root` was false, and every such
|
|
19
|
+
entry silently fell back to hashing the *string*, which never changes when the file does.
|
|
20
|
+
That is exactly the "an edit forces a refresh" guarantee docs/architecture.md advertises."""
|
|
12
21
|
try:
|
|
13
22
|
root = os.path.realpath(project_root) if project_root else ""
|
|
14
|
-
|
|
23
|
+
# Relative targets resolve inside the project; absolute ones are already anchored.
|
|
24
|
+
raw = target if os.path.isabs(target) or not root else os.path.join(root, target)
|
|
25
|
+
path = os.path.realpath(raw)
|
|
15
26
|
inside_root = (root and path.startswith(root + os.sep)) or path == root
|
|
16
27
|
if inside_root and os.path.isfile(path):
|
|
17
28
|
with open(path, "rb") as fh:
|
|
@@ -6,7 +6,9 @@ from typing import Any
|
|
|
6
6
|
from codeintel.commands._common import never_raise, 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
|
|
10
|
+
# reports success to any `make`/CI step gating on $? while nothing was produced.
|
|
11
|
+
@never_raise("graph failed: {exc}", code=1)
|
|
10
12
|
def run(args: Any) -> int:
|
|
11
13
|
from codeintel import grapher
|
|
12
14
|
|
|
@@ -12,6 +12,13 @@ def run(args: Any) -> int:
|
|
|
12
12
|
from codeintel.semantic_db import SemanticDb, default_db_path
|
|
13
13
|
|
|
14
14
|
project_root = resolve_root(args)
|
|
15
|
+
# Validate before doing anything. `codeintel index /typo/path` walked nothing, found nothing,
|
|
16
|
+
# and printed "Nothing new to index" at exit 0 — indistinguishable from a correct incremental
|
|
17
|
+
# run, which is the worst possible response to a mistyped path in a script.
|
|
18
|
+
if not os.path.isdir(project_root):
|
|
19
|
+
print(f"index failed: not a directory: {project_root}")
|
|
20
|
+
return 1
|
|
21
|
+
failed = False
|
|
15
22
|
# Wrap the whole semantic pass so a setup failure (e.g. an unresolvable home dir →
|
|
16
23
|
# Path.home() raising) degrades with a message, like every other subcommand, not a traceback.
|
|
17
24
|
try:
|
|
@@ -32,12 +39,18 @@ def run(args: Any) -> int:
|
|
|
32
39
|
).index(project_root)
|
|
33
40
|
if count > 0:
|
|
34
41
|
print(f"Indexed {count} chunks")
|
|
42
|
+
elif count < 0:
|
|
43
|
+
# Indexer.index() returns -1 for an unrecoverable failure. `> 0` sent that into
|
|
44
|
+
# the "Nothing new to index" branch, so a total failure read as a clean no-op.
|
|
45
|
+
print("index failed — the indexer could not complete (see the warnings above)")
|
|
46
|
+
failed = True
|
|
35
47
|
else:
|
|
36
48
|
print("Nothing new to index")
|
|
37
49
|
finally:
|
|
38
50
|
db.close()
|
|
39
51
|
except Exception as exc:
|
|
40
52
|
print(f"index failed: {exc}")
|
|
53
|
+
failed = True
|
|
41
54
|
|
|
42
55
|
# best-effort graph reindex
|
|
43
56
|
import shutil
|
|
@@ -60,4 +73,4 @@ def run(args: Any) -> int:
|
|
|
60
73
|
except Exception:
|
|
61
74
|
pass
|
|
62
75
|
|
|
63
|
-
return 0
|
|
76
|
+
return 1 if failed else 0
|
|
@@ -6,7 +6,9 @@ from codeintel.commands._common import never_raise, resolve_root
|
|
|
6
6
|
|
|
7
7
|
|
|
8
8
|
# Never-raise parity with the MCP code.map handler — degrade, don't crash.
|
|
9
|
-
|
|
9
|
+
# code=1: this command's job is to WRITE A FILE. Exiting 0 after failing to write it
|
|
10
|
+
# reports success to any `make`/CI step gating on $? while nothing was produced.
|
|
11
|
+
@never_raise("map failed: {exc}", code=1)
|
|
10
12
|
def run(args: Any) -> int:
|
|
11
13
|
from codeintel.injector import Injector
|
|
12
14
|
from codeintel.mapper import MapGenerator
|
|
@@ -191,6 +191,15 @@ class Gateway:
|
|
|
191
191
|
except Exception:
|
|
192
192
|
return True
|
|
193
193
|
|
|
194
|
+
def allows_root(self, role: str, project_root: str) -> bool:
|
|
195
|
+
"""Whether *role* may target *project_root*. The companion to ``allows`` — an op gate alone
|
|
196
|
+
leaves the TARGET unbounded, which is how `doctor` and `status` could still be pointed at
|
|
197
|
+
any readable directory after `query` had been scoped."""
|
|
198
|
+
try:
|
|
199
|
+
return self._policy is None or self._policy.is_root_allowed(role, project_root)
|
|
200
|
+
except Exception:
|
|
201
|
+
return True
|
|
202
|
+
|
|
194
203
|
def query(
|
|
195
204
|
self,
|
|
196
205
|
op=None,
|
|
@@ -206,14 +215,21 @@ class Gateway:
|
|
|
206
215
|
engine_str = str(engine or "").strip() or "auto"
|
|
207
216
|
was_auto = engine_str == "auto"
|
|
208
217
|
|
|
209
|
-
# Policy check FIRST — a role denied
|
|
210
|
-
# no
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
218
|
+
# Policy check FIRST — a role denied here does NO work (no reindex, no dispatch, no
|
|
219
|
+
# cache lookup, and critically no on-demand indexing walk). Applies to the modern
|
|
220
|
+
# provider path; the legacy list path has none.
|
|
221
|
+
if self._legacy_providers is None and self._policy is not None:
|
|
222
|
+
if not self._policy.is_allowed(role, op_str):
|
|
223
|
+
return safe_null_result(op_str, target_str, reason="op-not-allowed-for-role")
|
|
224
|
+
# `project_root` arrives in the request body. Without this check any role able to
|
|
225
|
+
# call `search` could name ANY directory the server process can read, and the
|
|
226
|
+
# semantic provider would walk, index, and return its contents — an op allowlist
|
|
227
|
+
# never sees the target. Denied before maybe_reindex, so a rejected path is not
|
|
228
|
+
# even touched.
|
|
229
|
+
if not self._policy.is_root_allowed(role, str(project_root or "")):
|
|
230
|
+
return safe_null_result(op_str, target_str, reason="root-not-allowed-for-role",
|
|
231
|
+
hint="this token's role is not scoped to that "
|
|
232
|
+
"project_root (see the [roots] table in auth.toml)")
|
|
217
233
|
|
|
218
234
|
try:
|
|
219
235
|
self._reindexer.maybe_reindex(str(project_root or ""))
|
|
@@ -240,6 +256,15 @@ class Gateway:
|
|
|
240
256
|
if engine_str == "auto":
|
|
241
257
|
engine_str = _AUTO_ENGINE.get(op_str, "graph")
|
|
242
258
|
|
|
259
|
+
# Cache under what was ASKED, not what auto resolved to. `auto` and an explicit
|
|
260
|
+
# `graph` both resolved to "graph" and so shared one key — but they are different
|
|
261
|
+
# questions: `auto` accepts the overview LSP fallback below, an explicit `graph`
|
|
262
|
+
# does not. One `auto` miss therefore parked an LSP answer under the graph key, and
|
|
263
|
+
# the next explicit `engine=graph` request got it back with `cached: true` and an
|
|
264
|
+
# `engine: "lsp"` field contradicting its own request. Reachable on any cold start,
|
|
265
|
+
# since "graph not indexed yet" is the normal first-query state.
|
|
266
|
+
cache_engine = "auto" if was_auto else engine_str
|
|
267
|
+
|
|
243
268
|
root_str = project_root or ""
|
|
244
269
|
|
|
245
270
|
# Freshness token — bumps when a background reindex completes, so a cached
|
|
@@ -259,7 +284,7 @@ class Gateway:
|
|
|
259
284
|
if engine_str in _FANOUT_ENGINES:
|
|
260
285
|
cached_result = (
|
|
261
286
|
None if uncacheable
|
|
262
|
-
else self._cache.get(op_str, target_str,
|
|
287
|
+
else self._cache.get(op_str, target_str, cache_engine, root_str, freshness)
|
|
263
288
|
)
|
|
264
289
|
if cached_result is not None:
|
|
265
290
|
return {**cached_result, "cached": True}
|
|
@@ -268,13 +293,13 @@ class Gateway:
|
|
|
268
293
|
fan_results = self._fan_out(engines, op_str, target_str, budget, project_root)
|
|
269
294
|
result = self._merge(fan_results, op_str, target_str, engine_str)
|
|
270
295
|
if not uncacheable:
|
|
271
|
-
self._cache.put(op_str, target_str,
|
|
296
|
+
self._cache.put(op_str, target_str, cache_engine, root_str, result, freshness)
|
|
272
297
|
return result
|
|
273
298
|
|
|
274
299
|
# Single-engine dispatch (`uncacheable`, computed above, also guards this path).
|
|
275
300
|
cached_result = (
|
|
276
301
|
None if uncacheable
|
|
277
|
-
else self._cache.get(op_str, target_str,
|
|
302
|
+
else self._cache.get(op_str, target_str, cache_engine, root_str, freshness)
|
|
278
303
|
)
|
|
279
304
|
if cached_result is not None:
|
|
280
305
|
return {**cached_result, "cached": True}
|
|
@@ -298,7 +323,7 @@ class Gateway:
|
|
|
298
323
|
result = lsp_result
|
|
299
324
|
|
|
300
325
|
if not uncacheable:
|
|
301
|
-
self._cache.put(op_str, target_str,
|
|
326
|
+
self._cache.put(op_str, target_str, cache_engine, root_str, result, freshness)
|
|
302
327
|
return result
|
|
303
328
|
|
|
304
329
|
except Exception as exc:
|
|
@@ -168,8 +168,10 @@ class _Handler(BaseHTTPRequestHandler):
|
|
|
168
168
|
return
|
|
169
169
|
|
|
170
170
|
# Everything else is auth-gated when auth is configured (metrics can reveal usage patterns;
|
|
171
|
-
# status reveals engine/index state).
|
|
172
|
-
|
|
171
|
+
# status reveals engine/index state). The role is kept, not discarded: /code/status takes a
|
|
172
|
+
# project_root, and answering it for any path regardless of the role's [roots] was the same
|
|
173
|
+
# cross-tenant disclosure that scoping /code/query was meant to close.
|
|
174
|
+
ok, role = self._resolve_role()
|
|
173
175
|
if not ok:
|
|
174
176
|
self._send_json(401, {"error": "unauthorized"})
|
|
175
177
|
return
|
|
@@ -185,7 +187,9 @@ class _Handler(BaseHTTPRequestHandler):
|
|
|
185
187
|
return
|
|
186
188
|
if path == "/code/status":
|
|
187
189
|
project_root = (parse_qs(parsed.query).get("project_root") or [""])[0]
|
|
188
|
-
|
|
190
|
+
# role is server-authoritative, exactly as on the POST path — never read from input.
|
|
191
|
+
self._send_json(200, code_status_handler(
|
|
192
|
+
{"project_root": project_root, "role": role}))
|
|
189
193
|
return
|
|
190
194
|
self._send_json(404, {"error": "not-found"})
|
|
191
195
|
|
|
@@ -13,6 +13,11 @@ if TYPE_CHECKING:
|
|
|
13
13
|
|
|
14
14
|
logger = logging.getLogger(__name__)
|
|
15
15
|
|
|
16
|
+
# Hard ceiling on the characters embedded for ONE chunk. Line-based splitting cannot bound a
|
|
17
|
+
# minified or generated single-line file, and the embedder's memory use scales with input size.
|
|
18
|
+
# Generous enough that no hand-written function is affected.
|
|
19
|
+
_MAX_CHUNK_CHARS = 200_000
|
|
20
|
+
|
|
16
21
|
_INDEXED_EXTS = frozenset({
|
|
17
22
|
".py", ".md",
|
|
18
23
|
".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", # TS/JS variants
|
|
@@ -206,7 +211,21 @@ class Indexer:
|
|
|
206
211
|
logger.warning("Cleanup pass failed: %s", exc)
|
|
207
212
|
|
|
208
213
|
def _walk_files(self, root: Path):
|
|
214
|
+
"""Indexable files inside *root* — and strictly inside it.
|
|
215
|
+
|
|
216
|
+
``os.walk`` defaults to ``followlinks=False``, which stops recursion into symlinked
|
|
217
|
+
DIRECTORIES but says nothing about symlinked FILES: those still appear in ``filenames``,
|
|
218
|
+
and the later ``open()`` follows them transparently. That made a planted symlink a
|
|
219
|
+
complete bypass of RBAC project scoping — a tenant able to write inside their own allowed
|
|
220
|
+
root could link to any file the server process can read and have it indexed, embedded, and
|
|
221
|
+
returned as a search snippet. Demonstrated before this guard existed.
|
|
222
|
+
|
|
223
|
+
So every candidate is resolved and required to land back under the resolved root."""
|
|
209
224
|
ignores = set(_SKIP_DIRS) | set(_DEFAULT_IGNORES) | self._load_gitignore(root)
|
|
225
|
+
try:
|
|
226
|
+
real_root = os.path.realpath(root)
|
|
227
|
+
except Exception:
|
|
228
|
+
real_root = str(root)
|
|
210
229
|
for dirpath, dirnames, filenames in os.walk(root):
|
|
211
230
|
dirnames[:] = [
|
|
212
231
|
d for d in dirnames
|
|
@@ -215,8 +234,32 @@ class Indexer:
|
|
|
215
234
|
for fname in filenames:
|
|
216
235
|
if fname in ignores:
|
|
217
236
|
continue
|
|
218
|
-
if Path(fname).suffix.lower() in _INDEXED_EXTS:
|
|
219
|
-
|
|
237
|
+
if Path(fname).suffix.lower() not in _INDEXED_EXTS:
|
|
238
|
+
continue
|
|
239
|
+
candidate = Path(dirpath) / fname
|
|
240
|
+
try:
|
|
241
|
+
real = os.path.realpath(candidate)
|
|
242
|
+
except Exception:
|
|
243
|
+
continue
|
|
244
|
+
if real != real_root and not real.startswith(real_root + os.sep):
|
|
245
|
+
logger.warning("skipping %s — it resolves outside the indexed root", candidate)
|
|
246
|
+
continue
|
|
247
|
+
# A HARDLINK is a second directory entry for the same inode. It is physically
|
|
248
|
+
# inside the root, so its realpath is inside the root and the check above passes
|
|
249
|
+
# — `realpath` cannot see it. That reopened exactly the hole the symlink guard
|
|
250
|
+
# closes: a tenant able to write in their own root could `ln` another tenant's
|
|
251
|
+
# file in and have it indexed. There is no way to ask "does this inode also live
|
|
252
|
+
# outside?", so treat extra links as disqualifying. Measured at 0 occurrences
|
|
253
|
+
# across 3213 source files of a real repo, so the false-positive cost is nil.
|
|
254
|
+
try:
|
|
255
|
+
links = os.stat(real).st_nlink
|
|
256
|
+
except OSError:
|
|
257
|
+
continue
|
|
258
|
+
if links > 1:
|
|
259
|
+
logger.warning("skipping %s — %d hard links, so its content may also live "
|
|
260
|
+
"outside the indexed root", candidate, links)
|
|
261
|
+
continue
|
|
262
|
+
yield candidate
|
|
220
263
|
|
|
221
264
|
# ---- chunk-span computation ------------------------------------------------------------
|
|
222
265
|
# A file is turned into a list of 0-based, half-open ``(start, end)`` line spans; every
|
|
@@ -451,6 +494,17 @@ class Indexer:
|
|
|
451
494
|
if not chunk_lines:
|
|
452
495
|
continue
|
|
453
496
|
chunk_text = "".join(chunk_lines)
|
|
497
|
+
# Cap chunk BYTES, not just lines. `_maybe_split` splits on line boundaries, so a
|
|
498
|
+
# minified bundle or a generated one-liner is a single unsplittable chunk however
|
|
499
|
+
# large: a 20MB one-line .py peaked at 3.4GB RSS through the embedder, and a 40MB
|
|
500
|
+
# one extrapolates past 8GB. That runs on the reindexer's daemon thread inside the
|
|
501
|
+
# long-lived MCP server, so it can take the agent host down, not just a CLI run.
|
|
502
|
+
# Truncation is the right trade — the head of a chunk carries its identifying
|
|
503
|
+
# content, and an over-long minified line has no retrieval value past that anyway.
|
|
504
|
+
if len(chunk_text) > _MAX_CHUNK_CHARS:
|
|
505
|
+
logger.warning("truncating an oversized chunk in %s (%d chars) to %d",
|
|
506
|
+
rel_path, len(chunk_text), _MAX_CHUNK_CHARS)
|
|
507
|
+
chunk_text = chunk_text[:_MAX_CHUNK_CHARS]
|
|
454
508
|
if not chunk_text.strip():
|
|
455
509
|
# EC3.4: never embed empty/whitespace-only chunks (zero vectors pollute results).
|
|
456
510
|
chunk_count += 1
|
|
@@ -49,7 +49,9 @@ def _find_context_file(project_root: str) -> str | None:
|
|
|
49
49
|
|
|
50
50
|
def _read_file(path: str) -> str | None:
|
|
51
51
|
try:
|
|
52
|
-
|
|
52
|
+
# newline="" keeps the file's own line endings intact. Universal-newline mode silently
|
|
53
|
+
# converted a CRLF file to LF on write, producing a whole-file diff on every --inject.
|
|
54
|
+
with open(path, encoding="utf-8", errors="strict", newline="") as f:
|
|
53
55
|
return f.read()
|
|
54
56
|
except Exception as exc:
|
|
55
57
|
_logger.warning("Injector: could not read %s: %s", path, exc)
|
|
@@ -59,10 +61,26 @@ def _read_file(path: str) -> str | None:
|
|
|
59
61
|
def _write_file(path: str, content: str) -> None:
|
|
60
62
|
# Atomic: write a sibling temp file then os.replace it into place, so an interrupted
|
|
61
63
|
# write can never leave the user's CLAUDE.md/AGENTS.md truncated or half-written.
|
|
62
|
-
|
|
63
|
-
|
|
64
|
+
#
|
|
65
|
+
# os.replace swaps in a NEW inode, so without care it also (a) drops the original's
|
|
66
|
+
# permissions in favour of whatever the umask gives, and (b) replaces a symlinked
|
|
67
|
+
# CLAUDE.md — a dotfile-manager or shared-team-rules setup — with a regular file, orphaning
|
|
68
|
+
# the source. Resolve the link and carry the mode across.
|
|
69
|
+
target = os.path.realpath(path)
|
|
70
|
+
try:
|
|
71
|
+
mode: int | None = os.stat(target).st_mode & 0o7777
|
|
72
|
+
except OSError:
|
|
73
|
+
mode = None
|
|
74
|
+
|
|
75
|
+
tmp = target + ".codeintel.tmp"
|
|
76
|
+
with open(tmp, "w", encoding="utf-8", newline="") as f:
|
|
64
77
|
f.write(content)
|
|
65
|
-
|
|
78
|
+
if mode is not None:
|
|
79
|
+
try:
|
|
80
|
+
os.chmod(tmp, mode)
|
|
81
|
+
except OSError:
|
|
82
|
+
pass
|
|
83
|
+
os.replace(tmp, target)
|
|
66
84
|
|
|
67
85
|
|
|
68
86
|
def _update_block(content: str) -> tuple[str, str]:
|
|
@@ -73,9 +91,20 @@ def _update_block(content: str) -> tuple[str, str]:
|
|
|
73
91
|
|
|
74
92
|
if has_start and has_end:
|
|
75
93
|
start_idx = content.index(_START_MARKER)
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
94
|
+
# The END marker must be searched for AFTER the start. `content.index(_END_MARKER)` finds
|
|
95
|
+
# the FIRST one, which can sit before start_idx — a hand-edit, a bad merge, or this
|
|
96
|
+
# function's own append branch below, which happily creates that ordering. The slice
|
|
97
|
+
# `content[:start_idx] + block + content[end_idx:]` then RE-EMITS everything between the
|
|
98
|
+
# stray marker and the block, duplicating the user's own instructions once per run and
|
|
99
|
+
# growing without bound. CLAUDE.md is prompt context, so this silently degrades the agent
|
|
100
|
+
# it is supposed to help.
|
|
101
|
+
end_rel = content.find(_END_MARKER, start_idx)
|
|
102
|
+
if end_rel != -1:
|
|
103
|
+
end_idx = end_rel + len(_END_MARKER)
|
|
104
|
+
return (content[:start_idx] + block + content[end_idx:], "updated")
|
|
105
|
+
# Start with no end after it: the block is truncated. Replace from the start marker to
|
|
106
|
+
# the end of the file rather than splicing around a marker that precedes it.
|
|
107
|
+
return (content[:start_idx] + block, "repaired")
|
|
79
108
|
|
|
80
109
|
# Missing or corrupted (only one marker): append a fresh block
|
|
81
110
|
separator = "\n\n" if not content.endswith("\n\n") else ""
|