@esankhan3/code-search-mcp 1.0.6 → 1.1.0
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.
- package/README.md +114 -54
- package/dist/__tests__/_harness.js +194 -0
- package/dist/__tests__/config-cli.test.js +110 -0
- package/dist/__tests__/config-resolver.test.js +1 -1
- package/dist/__tests__/daemon-rpc-protocol.test.js +95 -0
- package/dist/__tests__/daemon-runtime.test.js +145 -0
- package/dist/__tests__/graph-tools-store.test.js +314 -0
- package/dist/__tests__/graph-tools.test.js +11 -4
- package/dist/__tests__/http-auth.test.js +180 -0
- package/dist/__tests__/http-index.test.js +125 -0
- package/dist/__tests__/metrics-collect.test.js +48 -0
- package/dist/__tests__/observability.test.js +3 -4
- package/dist/__tests__/remote-proxy.test.js +105 -0
- package/dist/__tests__/server-cli-args.test.js +146 -0
- package/dist/__tests__/server-lifecycle.test.js +108 -0
- package/dist/__tests__/tool-inputs.test.js +178 -0
- package/dist/backends/in-process.js +5 -2
- package/dist/backends/index.js +2 -2
- package/dist/cli/index.js +143 -76
- package/dist/core/config.js +106 -35
- package/dist/core/env-config.js +3 -5
- package/dist/core/project-name.js +12 -0
- package/dist/core/version.js +5 -0
- package/dist/daemon/index.js +106 -65
- package/dist/daemon/rpc-server.js +38 -18
- package/dist/index.js +113 -89
- package/dist/middleware/auth.js +34 -66
- package/dist/middleware/rate-limit.js +37 -0
- package/dist/observability/logger.js +10 -2
- package/dist/observability/metrics.js +7 -6
- package/dist/projects/registry.js +13 -15
- package/dist/resources/resources.js +17 -0
- package/dist/server.js +216 -129
- package/dist/tools/graph.js +353 -133
- package/dist/tools/index-tools.js +3 -2
- package/dist/tools/profile.js +14 -8
- package/dist/tools/search.js +61 -51
- package/dist/tools/validate.js +48 -0
- package/dist/transports/http-transport.js +94 -55
- package/dist/transports/remote-proxy.js +58 -97
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -55,23 +55,38 @@ job — none of them need a separate install.
|
|
|
55
55
|
code-search index . # index current dir
|
|
56
56
|
code-search query "where do we verify JWTs" # hybrid by default
|
|
57
57
|
code-search query "createPet" --mode bm25 --top-k 5 # exact-identifier mode
|
|
58
|
+
code-search query --project petshop --format json "adopt pet" # flags in any order
|
|
58
59
|
code-search status --project petshop # JSON report
|
|
59
60
|
code-search reset --project petshop # drop the index
|
|
60
61
|
code-search --print-config # resolved config (redacted)
|
|
61
62
|
```
|
|
62
63
|
Every `CodeSearchConfig` leaf is settable as `--<dotted-path>` —
|
|
63
64
|
e.g. `--embedding.provider codestral`, `--retrieval.max-chunks 12`,
|
|
64
|
-
`--no-indexing.auto-index`.
|
|
65
|
+
`--no-indexing.auto-index`. An unknown flag (or an invalid `--mode` /
|
|
66
|
+
`--format` / `--top-k`) exits with status 2 instead of being ignored.
|
|
67
|
+
`query`, `status` and `--print-config` read the cwd's
|
|
68
|
+
`.code-search.yaml`; `index <path>` reads the one in `<path>`.
|
|
69
|
+
`status` reports where the KB lives (`dataDir`, `kbPath`), whether the
|
|
70
|
+
project is indexed, and whether a daemon answers on its socket.
|
|
71
|
+
`reset` only removes `<dataDir>/<project>` and rejects project names that
|
|
72
|
+
aren't a single path segment (or are the reserved `daemon` / `projects`).
|
|
65
73
|
|
|
66
74
|
### Daemon mode (long-running indexer)
|
|
67
75
|
```sh
|
|
68
76
|
code-search-daemon --workspace /path/to/repos
|
|
77
|
+
code-search daemon --workspace . # same, via the CLI wrapper
|
|
69
78
|
```
|
|
70
79
|
Builds the index once, then watches the workspace and debounce-
|
|
71
|
-
reindexes on file events
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
80
|
+
reindexes on file events (one index run per batch; results are fresh as
|
|
81
|
+
soon as a run finishes). The project is named after the workspace
|
|
82
|
+
directory. Listens on a UDS socket at `<dataDir>/daemon/<project>.sock`
|
|
83
|
+
(named pipe on Windows) and writes `<project>.pid` next to it; a second
|
|
84
|
+
daemon for the same project refuses to start, and a stopped daemon
|
|
85
|
+
removes only its own socket + pid file. `code-search query` prefers the
|
|
86
|
+
daemon when it answers a ping (`--no-daemon` to skip it), falling back
|
|
87
|
+
to in-process when it doesn't. The MCP server (`--local` / `--serve`)
|
|
88
|
+
does **not** use the daemon — it indexes and searches in-process, so
|
|
89
|
+
don't run both on the same project.
|
|
75
90
|
|
|
76
91
|
### MCP — three modes (`code-search-mcp` argv)
|
|
77
92
|
|
|
@@ -96,19 +111,26 @@ your embedder + reranker are local (Ollama).
|
|
|
96
111
|
|
|
97
112
|
**Serve**
|
|
98
113
|
```sh
|
|
99
|
-
code-search-mcp --serve --port 4000 --auth api-key
|
|
114
|
+
code-search-mcp --serve /path/to/repos --port 4000 --auth api-key
|
|
100
115
|
```
|
|
101
|
-
Boots an HTTP server (Streamable HTTP
|
|
102
|
-
with `/
|
|
116
|
+
Boots an HTTP server (Streamable HTTP on `/mcp`; there is no SSE
|
|
117
|
+
transport) with `/health`, `/ready`, `/version`, `/status`,
|
|
103
118
|
`/metrics` (Prometheus), `/admin/api/status`, and an admin
|
|
104
|
-
`POST /index`.
|
|
105
|
-
|
|
119
|
+
`POST /index`. The port is bound before the startup index runs, so
|
|
120
|
+
probes answer from the start (`/ready` is 503 until the index is
|
|
121
|
+
servable). `--serve <path>`, `--serve --local <path>` and
|
|
122
|
+
`--local <path> --serve` are equivalent; the project is `default`
|
|
123
|
+
unless `--project` says otherwise. Use this to host one index for a
|
|
124
|
+
whole team — every dev points their client at the same URL.
|
|
125
|
+
|
|
126
|
+
`code-search mcp [path]` is the stdio local mode over the cwd (or
|
|
127
|
+
`path`); `code-search mcp --remote URL` is the remote proxy.
|
|
106
128
|
|
|
107
129
|
---
|
|
108
130
|
|
|
109
131
|
## Tool surface
|
|
110
132
|
|
|
111
|
-
|
|
133
|
+
Seventeen tools across four categories. Every one of them maps to a
|
|
112
134
|
function in `@anvil/knowledge-core` — you're getting the same
|
|
113
135
|
retrieval pipeline that powers the Anvil dashboard.
|
|
114
136
|
|
|
@@ -117,23 +139,28 @@ retrieval pipeline that powers the Anvil dashboard.
|
|
|
117
139
|
|---|---|
|
|
118
140
|
| `search_code` | Hybrid retrieval — vector + BM25 + graph + rerank |
|
|
119
141
|
| `search_semantic` | Vector-only (paraphrases, intent) |
|
|
120
|
-
| `search_exact` | BM25-
|
|
142
|
+
| `search_exact` | BM25, led by exact symbol-name / phrase hits (identifiers, error codes); works without the embedder |
|
|
121
143
|
| `get_code_snippet` | Fetch the source for one entity by qualified name (cheaper than reading the file) |
|
|
122
144
|
|
|
123
145
|
### Graph
|
|
124
146
|
| Tool | What it does |
|
|
125
147
|
|---|---|
|
|
126
|
-
| `get_repo_graph` | Single-repo AST graph |
|
|
127
|
-
| `get_cross_repo_edges` | Inter-repo edges (
|
|
128
|
-
| `find_callers` | Who calls this
|
|
129
|
-
| `find_dependencies` | What this
|
|
130
|
-
| `impact_analysis` | What breaks if
|
|
131
|
-
| `trace_path` | Multi-hop call chain — shortest path between two
|
|
132
|
-
| `search_graph` | Structural query — filter
|
|
133
|
-
| `find_dead_code` | Entities with no
|
|
134
|
-
| `detect_changes` | Map a git diff to
|
|
148
|
+
| `get_repo_graph` | Single-repo AST graph — entities and edges (`limit` caps each list) |
|
|
149
|
+
| `get_cross_repo_edges` | Inter-repo edges (HTTP, gRPC, topics, tables, shared types, …), each with its evidence; same-repo workspace-package deps in their own section |
|
|
150
|
+
| `find_callers` | Who calls this symbol — `calls` edges only by default; `relations` adds imports / uses / type-ref / inherits, listed per relation |
|
|
151
|
+
| `find_dependencies` | What this symbol calls (same `relations` option) |
|
|
152
|
+
| `impact_analysis` | What breaks if a file or entity changes — dependents up to `depth` hops (default 2, max 5), cross-repo included |
|
|
153
|
+
| `trace_path` | Multi-hop call chain — shortest path (unweighted BFS) between two symbols, or what is reachable; `calls` edges by default |
|
|
154
|
+
| `search_graph` | Structural query — filter nodes by name (regex over symbol names), type, file, repo; ranked by connectivity |
|
|
155
|
+
| `find_dead_code` | Entities with no incoming edge and no other mention in their file (heuristic) |
|
|
156
|
+
| `detect_changes` | Map a git diff to the entities whose lines changed (whole file for added / deleted files) + their dependents |
|
|
135
157
|
| `get_architecture` | Project overview — repo roles, key flows, connections |
|
|
136
158
|
|
|
159
|
+
Symbol arguments take a name (exact, case-sensitive) or a full
|
|
160
|
+
`repo::file::entity` id; `fuzzy: true` matches a case-insensitive
|
|
161
|
+
substring of symbol names. Every tool validates its arguments against
|
|
162
|
+
its input schema — a bad argument returns `isError: true` and the reason.
|
|
163
|
+
|
|
137
164
|
### Profiles
|
|
138
165
|
| Tool | What it does |
|
|
139
166
|
|---|---|
|
|
@@ -143,7 +170,7 @@ retrieval pipeline that powers the Anvil dashboard.
|
|
|
143
170
|
### Index
|
|
144
171
|
| Tool | What it does |
|
|
145
172
|
|---|---|
|
|
146
|
-
| `index_status` |
|
|
173
|
+
| `index_status` | Chunk count, embedding provider, last indexed time, vector-index health, chunks per repo |
|
|
147
174
|
|
|
148
175
|
Plus four MCP resources via `code-search://`:
|
|
149
176
|
`repos`, `system-graph`, `repo/{name}/profile`, `repo/{name}/graph`.
|
|
@@ -178,8 +205,10 @@ exist between web and api?
|
|
|
178
205
|
Fourteen cross-repo edge strategies cover shared types, Kafka,
|
|
179
206
|
HTTP, gRPC, databases, env vars, npm/workspace deps, k8s,
|
|
180
207
|
docker-compose, proto, Redis, S3, and shared constants. Plus an
|
|
181
|
-
LLM-inferred semantic edge layer. `
|
|
182
|
-
|
|
208
|
+
LLM-inferred semantic edge layer. `get_cross_repo_edges`,
|
|
209
|
+
`impact_analysis` and `detect_changes` work *across repos* (shared
|
|
210
|
+
topics, tables and the like count as dependencies both ways);
|
|
211
|
+
`find_callers` and `trace_path` do too with `relations: ["all"]`.
|
|
183
212
|
|
|
184
213
|
---
|
|
185
214
|
|
|
@@ -191,16 +220,17 @@ no scrape config or APM agent required.
|
|
|
191
220
|
| Endpoint | Returns |
|
|
192
221
|
|---|---|
|
|
193
222
|
| `GET /health` | Liveness ping with project + uptime |
|
|
194
|
-
| `GET /ready` | `200`
|
|
195
|
-
| `GET /version` | Package + Node + platform |
|
|
223
|
+
| `GET /ready` | `200` once the index is servable, `503` before (incl. during the startup index) |
|
|
224
|
+
| `GET /version` | Package version + Node + platform |
|
|
196
225
|
| `GET /status` | Current indexing phase + last-success history |
|
|
197
|
-
| `GET /metrics` | Prometheus text format
|
|
198
|
-
| `GET /admin/api/status` | JSON status dashboard for the admin UI |
|
|
226
|
+
| `GET /metrics` | Prometheus text format: queries, latency histogram, errors, seconds since the last successful index, chunks per repo, vector-index health |
|
|
227
|
+
| `GET /admin/api/status` | JSON status dashboard for the admin UI (requires auth when auth is on) |
|
|
199
228
|
|
|
200
229
|
Set `CODE_SEARCH_STRUCTURED_LOGS=1` for one-JSON-line-per-event
|
|
201
|
-
stderr logs, and
|
|
202
|
-
|
|
203
|
-
|
|
230
|
+
stderr logs from the server, daemon and CLI (lines knowledge-core
|
|
231
|
+
prints itself stay plain), and `CODE_SEARCH_TELEMETRY_RECORD_QUERIES=1`
|
|
232
|
+
to opt into recording query text in spans (off by default — queries
|
|
233
|
+
are PII-shaped).
|
|
204
234
|
|
|
205
235
|
---
|
|
206
236
|
|
|
@@ -216,9 +246,15 @@ quotas:
|
|
|
216
246
|
max_embedding_cost_usd: 5
|
|
217
247
|
```
|
|
218
248
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
249
|
+
The HTTP server reads the entry of the project it serves once at
|
|
250
|
+
boot and enforces it on every `/mcp` request: the caller's scopes
|
|
251
|
+
must match `scopes` (`*` is admin; API keys are admin; a JWT's
|
|
252
|
+
scopes come from its `scope` claim) or it gets 403, and
|
|
253
|
+
`max_queries_per_minute` caps requests per `(identity, project)` in
|
|
254
|
+
a sliding 1-minute window (429 + `Retry-After`). Not enforced:
|
|
255
|
+
`repos`, `max_embedding_cost_usd` / `max_llm_cost_usd`, and any
|
|
256
|
+
`config:` block (per-project config comes from the workspace's
|
|
257
|
+
`.code-search.yaml`).
|
|
222
258
|
|
|
223
259
|
---
|
|
224
260
|
|
|
@@ -226,19 +262,25 @@ sliding 1-minute window per `(identity, project)`.
|
|
|
226
262
|
|
|
227
263
|
### Auth, three flavors
|
|
228
264
|
`none` (local-only, default 127.0.0.1 binding), `api-key`
|
|
229
|
-
(`Authorization: Bearer ...`, timing-safe compare), `jwt` (HS256
|
|
230
|
-
signature
|
|
231
|
-
|
|
265
|
+
(`Authorization: Bearer ...`, timing-safe compare), `jwt` (HS256;
|
|
266
|
+
signature, `iss`, a required `exp` and, when present, `nbf`
|
|
267
|
+
validated; the server refuses to start in `jwt` mode without
|
|
268
|
+
`CODE_SEARCH_AUTH_JWT_SECRET`). Public binding without auth logs a
|
|
269
|
+
warning, because that's what should happen.
|
|
232
270
|
|
|
233
271
|
### Rate limiting
|
|
234
|
-
|
|
235
|
-
|
|
272
|
+
`CODE_SEARCH_RATE_LIMIT_PER_MINUTE` (default 100) requests per
|
|
273
|
+
identity in a sliding 1-minute window (each API key counts on its
|
|
274
|
+
own); 429 responses carry `Retry-After`. In cluster mode the limit
|
|
275
|
+
applies to the whole server, not per worker.
|
|
236
276
|
|
|
237
277
|
### Auto-reindex
|
|
238
|
-
`CODE_SEARCH_REINDEX_INTERVAL=30m` (or `1h`, `6h`, `0` to disable
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
278
|
+
`CODE_SEARCH_REINDEX_INTERVAL=30m` (or `1h`, `6h`, `0` to disable;
|
|
279
|
+
also `indexing.reindex_interval_ms` in YAML or
|
|
280
|
+
`--indexing.reindex-interval-ms`) runs an incremental re-index in
|
|
281
|
+
the background. Anything else (e.g. `90s`) is reported as invalid and
|
|
282
|
+
disables it. Skips itself if a manual index is already running. The
|
|
283
|
+
timer is `unref`'d so it doesn't keep the process alive.
|
|
242
284
|
|
|
243
285
|
### Status tracking
|
|
244
286
|
Every index call lands in a 50-entry FIFO history with start
|
|
@@ -252,9 +294,21 @@ instance: modern (2026-07-28) clients negotiate via
|
|
|
252
294
|
per-request legacy fallback. No session map, no TTL.
|
|
253
295
|
|
|
254
296
|
### Admin index endpoint
|
|
255
|
-
`POST /index` with `{
|
|
256
|
-
|
|
257
|
-
|
|
297
|
+
`POST /index` with `{ "path": "/repos", "force"?: true }` (re)indexes
|
|
298
|
+
the served project from `path`, which then becomes its directory for
|
|
299
|
+
scheduled reindexes. `project`, if given, must be the served project
|
|
300
|
+
(a server never switches projects — 400 otherwise). Invalid JSON or a
|
|
301
|
+
missing `path` → 400, a body over 64 KiB → 413, an index already
|
|
302
|
+
running → 409. Auth-required when auth is on. The response reports
|
|
303
|
+
chunks, repos and cross-repo edges.
|
|
304
|
+
|
|
305
|
+
### Cluster mode
|
|
306
|
+
`CODE_SEARCH_WORKERS=N` (2–16) forks N HTTP workers sharing the port.
|
|
307
|
+
The primary is the only writer (startup index, `POST /index` forwarded
|
|
308
|
+
over IPC, scheduled reindex) and pushes its indexing state to the
|
|
309
|
+
workers, so `/status`, `/ready` and `/admin/api/status` look the same
|
|
310
|
+
on every worker; rate limits and quotas are counted once for the whole
|
|
311
|
+
server.
|
|
258
312
|
|
|
259
313
|
---
|
|
260
314
|
|
|
@@ -284,7 +338,8 @@ auth is on.
|
|
|
284
338
|
└────────────┬────────────┘
|
|
285
339
|
│ on disk
|
|
286
340
|
▼
|
|
287
|
-
|
|
341
|
+
<dataDir>/<project>/
|
|
342
|
+
(default ~/.anvil/knowledge-base/)
|
|
288
343
|
```
|
|
289
344
|
|
|
290
345
|
Three different fronts, one knowledge stack:
|
|
@@ -314,16 +369,21 @@ Single resolver in `src/core/config.ts`. Five precedence layers
|
|
|
314
369
|
5. CLI flags via `--<dotted-path>` (e.g. `--embedding.provider codestral`)
|
|
315
370
|
|
|
316
371
|
Run `code-search --print-config` or `code-search-mcp --print-config`
|
|
317
|
-
any time to see the resolved struct (secrets redacted
|
|
372
|
+
any time to see the resolved struct (secrets redacted — including
|
|
373
|
+
passwords in URIs and OTEL header values). `--port`, `--host`,
|
|
374
|
+
`--transport` and `--auth` are shorthands for `server.port`,
|
|
375
|
+
`server.host`, `server.transport` and `auth.mode`.
|
|
318
376
|
|
|
319
377
|
| Var (`CODE_SEARCH_*`) | Effect |
|
|
320
378
|
|---|---|
|
|
321
379
|
| `SERVER` | Remote URL (proxy mode) |
|
|
322
380
|
| `API_KEY` | API key for proxy or serve modes |
|
|
323
|
-
| `DATA_DIR` |
|
|
381
|
+
| `DATA_DIR` | Index storage root (default `~/.anvil/knowledge-base`; also `storage.data_dir` / `--storage.data-dir`). Daemon sockets live under `<dataDir>/daemon/` |
|
|
324
382
|
| `PORT` / `HOST` / `TRANSPORT` | HTTP server bind |
|
|
325
383
|
| `AUTH_MODE` / `AUTH_API_KEYS` / `AUTH_JWT_SECRET` | Server auth |
|
|
326
|
-
| `REINDEX_INTERVAL` | `30m` / `1h` / `6h` / `0
|
|
384
|
+
| `REINDEX_INTERVAL` | `30m` / `1h` / `6h` / `0`; invalid → disabled |
|
|
385
|
+
| `RATE_LIMIT_PER_MINUTE` | Per-identity request limit (sliding window) |
|
|
386
|
+
| `WORKERS` | `>1` → cluster mode |
|
|
327
387
|
| `EMBEDDING_PROVIDER` | `auto` / `codestral` / `voyage` / `openai` / `ollama` / `openai-compatible` / `custom` / `gemini-oauth` |
|
|
328
388
|
| `EMBEDDING_MODEL` / `EMBEDDING_DIMENSIONS` | Model + dims |
|
|
329
389
|
| `EMBEDDING_API_KEY` / `EMBEDDING_BASE_URL` | Credentials |
|
|
@@ -332,8 +392,8 @@ any time to see the resolved struct (secrets redacted).
|
|
|
332
392
|
| `RERANKER_MODEL` / `RERANKER_API_KEY` / `RERANKER_BASE_URL` | Reranker config |
|
|
333
393
|
| `RETRIEVAL_MAX_CHUNKS` / `RETRIEVAL_MAX_TOKENS` | Retrieval tuning |
|
|
334
394
|
| `AUTO_INDEX` | `false` to disable startup auto-index |
|
|
335
|
-
| `DAEMON_DISABLED`
|
|
336
|
-
| `LLM_MODE` / `LLM_PROVIDER` / `LLM_MODEL` / `LLM_API_KEY` / `LLM_BASE_URL` | Profiling + service-mesh LLM. Pre-promoted to `ANVIL_LLM_*` so the agent-core integration is silent. |
|
|
395
|
+
| `DAEMON_DISABLED` | `code-search query` never uses a daemon |
|
|
396
|
+
| `LLM_MODE` / `LLM_PROVIDER` / `LLM_MODEL` / `LLM_API_KEY` / `LLM_BASE_URL` | Profiling + service-mesh LLM (`LLM_MODE` defaults to `none`). Pre-promoted to `ANVIL_LLM_*` so the agent-core integration is silent. |
|
|
337
397
|
| `INDEXING_DEBOUNCE_MS` / `CHUNKING_MAX_TOKENS` | Indexer knobs |
|
|
338
398
|
| `METRICS_ENABLED` / `STRUCTURED_LOGS` / `TELEMETRY_RECORD_QUERIES` | Observability |
|
|
339
399
|
|
|
@@ -381,8 +441,8 @@ proxies (cluster workers) are free.
|
|
|
381
441
|
|
|
382
442
|
**Security defaults that don't bite.** Default bind is `127.0.0.1`
|
|
383
443
|
when auth is `none`. API keys compared timing-safe. JWT
|
|
384
|
-
signature + exp + iss validated. Public binding
|
|
385
|
-
logs a warning instead of pretending it's fine.
|
|
444
|
+
signature + exp (required) + nbf + iss validated. Public binding
|
|
445
|
+
without auth logs a warning instead of pretending it's fine.
|
|
386
446
|
|
|
387
447
|
---
|
|
388
448
|
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { spawn, execFileSync } from "node:child_process";
|
|
2
|
+
import { createServer } from "node:http";
|
|
3
|
+
import { mkdirSync, mkdtempSync, realpathSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { dirname, join } from "node:path";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
const DIST = fileURLToPath(new URL("..", import.meta.url));
|
|
8
|
+
const BIN = {
|
|
9
|
+
mcp: join(DIST, "index.js"),
|
|
10
|
+
cli: join(DIST, "cli", "index.js"),
|
|
11
|
+
daemon: join(DIST, "daemon", "index.js")
|
|
12
|
+
};
|
|
13
|
+
function tmp(prefix = "cs-") {
|
|
14
|
+
return realpathSync(mkdtempSync(join(tmpdir(), prefix)));
|
|
15
|
+
}
|
|
16
|
+
async function startFakeEmbedder(port, opts = {}) {
|
|
17
|
+
const dims = opts.dims ?? 16;
|
|
18
|
+
let calls = 0;
|
|
19
|
+
const server = createServer((req, res) => {
|
|
20
|
+
let body = "";
|
|
21
|
+
req.on("data", (c) => {
|
|
22
|
+
body += c.toString();
|
|
23
|
+
});
|
|
24
|
+
req.on("end", () => {
|
|
25
|
+
calls++;
|
|
26
|
+
const input = (() => {
|
|
27
|
+
try {
|
|
28
|
+
return JSON.parse(body).input ?? [];
|
|
29
|
+
} catch {
|
|
30
|
+
return [];
|
|
31
|
+
}
|
|
32
|
+
})();
|
|
33
|
+
const data = input.map((text, index) => {
|
|
34
|
+
const v = new Array(dims).fill(0).map((_, i) => {
|
|
35
|
+
let h = 0;
|
|
36
|
+
for (let j = i; j < text.length; j += dims) h = (h * 31 + text.charCodeAt(j)) % 1e3;
|
|
37
|
+
return h / 1e3 + 1e-3;
|
|
38
|
+
});
|
|
39
|
+
return { object: "embedding", index, embedding: v };
|
|
40
|
+
});
|
|
41
|
+
setTimeout(() => {
|
|
42
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
43
|
+
res.end(JSON.stringify({ object: "list", data, model: "fake" }));
|
|
44
|
+
}, opts.delayMs ?? 0);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
await new Promise((r) => server.listen(port, "127.0.0.1", () => r()));
|
|
48
|
+
return {
|
|
49
|
+
url: `http://127.0.0.1:${port}`,
|
|
50
|
+
calls: () => calls,
|
|
51
|
+
close: () => new Promise((r) => {
|
|
52
|
+
server.closeAllConnections();
|
|
53
|
+
server.close(() => r());
|
|
54
|
+
})
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
function makeRepo(root, name, files) {
|
|
58
|
+
const dir = join(root, name);
|
|
59
|
+
for (const [rel, content] of Object.entries(files)) {
|
|
60
|
+
mkdirSync(dirname(join(dir, rel)), { recursive: true });
|
|
61
|
+
writeFileSync(join(dir, rel), content);
|
|
62
|
+
}
|
|
63
|
+
const git = (...a) => execFileSync("git", ["-c", "user.email=t@t", "-c", "user.name=t", ...a], { cwd: dir, stdio: "pipe" });
|
|
64
|
+
git("init", "-q");
|
|
65
|
+
git("add", "-A");
|
|
66
|
+
git("commit", "-q", "-m", "init");
|
|
67
|
+
return dir;
|
|
68
|
+
}
|
|
69
|
+
function makeWorkspace(root) {
|
|
70
|
+
const ws = join(root, "ws");
|
|
71
|
+
makeRepo(ws, "orders", {
|
|
72
|
+
"package.json": '{"name":"orders","version":"1.0.0"}',
|
|
73
|
+
"src/orders.ts": [
|
|
74
|
+
"export const topic = 'orders.created';",
|
|
75
|
+
"export function createOrder(id: string) {",
|
|
76
|
+
' return { id, status: "new" };',
|
|
77
|
+
"}",
|
|
78
|
+
"export function welcomeEmail(user: string) {",
|
|
79
|
+
" return `welcome email for ${user}`;",
|
|
80
|
+
"}"
|
|
81
|
+
].join("\n")
|
|
82
|
+
});
|
|
83
|
+
makeRepo(ws, "billing", {
|
|
84
|
+
"package.json": '{"name":"billing","version":"1.0.0"}',
|
|
85
|
+
"src/billing.ts": [
|
|
86
|
+
"const topic = 'orders.created';",
|
|
87
|
+
"export function chargeOrder(orderId: string, cents: number) {",
|
|
88
|
+
" return { orderId, cents, charged: true };",
|
|
89
|
+
"}"
|
|
90
|
+
].join("\n")
|
|
91
|
+
});
|
|
92
|
+
return ws;
|
|
93
|
+
}
|
|
94
|
+
function baseEnv(opts) {
|
|
95
|
+
const env = {};
|
|
96
|
+
for (const [k, v] of Object.entries(process.env)) {
|
|
97
|
+
if (/^(CODE_SEARCH_|ANVIL_|OPENAI_|ANTHROPIC_|MISTRAL_|VOYAGE_|COHERE_|OLLAMA_|OTEL_|GITHUB_TOKEN|CLAUDE_BIN)/.test(k)) continue;
|
|
98
|
+
env[k] = v;
|
|
99
|
+
}
|
|
100
|
+
env.HOME = opts.home;
|
|
101
|
+
env.CODE_SEARCH_LLM_MODE = "none";
|
|
102
|
+
env.CODE_SEARCH_RERANKER_PROVIDER = "none";
|
|
103
|
+
if (opts.dataDir) env.CODE_SEARCH_DATA_DIR = opts.dataDir;
|
|
104
|
+
if (opts.embedderUrl) {
|
|
105
|
+
env.CODE_SEARCH_EMBEDDING_PROVIDER = "custom";
|
|
106
|
+
env.CODE_SEARCH_EMBEDDING_BASE_URL = opts.embedderUrl;
|
|
107
|
+
env.CODE_SEARCH_EMBEDDING_MODEL = "fake";
|
|
108
|
+
env.CODE_SEARCH_EMBEDDING_DIMENSIONS = "16";
|
|
109
|
+
}
|
|
110
|
+
return { ...env, ...opts.extra };
|
|
111
|
+
}
|
|
112
|
+
function spawnProc(bin, args, env, cwd) {
|
|
113
|
+
const child = spawn(process.execPath, [bin, ...args], { env, cwd, stdio: ["pipe", "pipe", "pipe"] });
|
|
114
|
+
let out = "";
|
|
115
|
+
let err = "";
|
|
116
|
+
child.stdout.on("data", (d) => {
|
|
117
|
+
out += d.toString();
|
|
118
|
+
});
|
|
119
|
+
child.stderr.on("data", (d) => {
|
|
120
|
+
err += d.toString();
|
|
121
|
+
});
|
|
122
|
+
const exited = new Promise((r) => {
|
|
123
|
+
child.on("exit", (code, signal) => r({ code, signal }));
|
|
124
|
+
});
|
|
125
|
+
return {
|
|
126
|
+
child,
|
|
127
|
+
stdout: () => out,
|
|
128
|
+
stderr: () => err,
|
|
129
|
+
exited,
|
|
130
|
+
waitForStderr: (re, ms = 2e4) => waitFor(() => re.test(err), ms, `stderr ${re}
|
|
131
|
+
--- stderr ---
|
|
132
|
+
${err}`),
|
|
133
|
+
kill: () => {
|
|
134
|
+
try {
|
|
135
|
+
child.kill("SIGKILL");
|
|
136
|
+
} catch {
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
async function run(bin, args, env, cwd, timeoutMs = 6e4) {
|
|
142
|
+
const p = spawnProc(bin, args, env, cwd);
|
|
143
|
+
p.child.stdin.end();
|
|
144
|
+
const timer = setTimeout(() => p.kill(), timeoutMs);
|
|
145
|
+
const { code } = await p.exited;
|
|
146
|
+
clearTimeout(timer);
|
|
147
|
+
return { code, stdout: p.stdout(), stderr: p.stderr() };
|
|
148
|
+
}
|
|
149
|
+
async function waitFor(fn, ms = 2e4, what = "condition") {
|
|
150
|
+
const deadline = Date.now() + ms;
|
|
151
|
+
while (Date.now() < deadline) {
|
|
152
|
+
try {
|
|
153
|
+
if (await fn()) return;
|
|
154
|
+
} catch {
|
|
155
|
+
}
|
|
156
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
157
|
+
}
|
|
158
|
+
throw new Error(`timed out waiting for ${what}`);
|
|
159
|
+
}
|
|
160
|
+
async function exitWithin(p, ms) {
|
|
161
|
+
let timer;
|
|
162
|
+
const r = await Promise.race([
|
|
163
|
+
p.exited,
|
|
164
|
+
new Promise((res) => {
|
|
165
|
+
timer = setTimeout(() => res("timeout"), ms);
|
|
166
|
+
})
|
|
167
|
+
]);
|
|
168
|
+
clearTimeout(timer);
|
|
169
|
+
if (r === "timeout") p.kill();
|
|
170
|
+
return r;
|
|
171
|
+
}
|
|
172
|
+
async function getJson(url, init) {
|
|
173
|
+
const res = await fetch(url, init);
|
|
174
|
+
const text = await res.text();
|
|
175
|
+
let body = text;
|
|
176
|
+
try {
|
|
177
|
+
body = JSON.parse(text);
|
|
178
|
+
} catch {
|
|
179
|
+
}
|
|
180
|
+
return { status: res.status, body, headers: res.headers };
|
|
181
|
+
}
|
|
182
|
+
export {
|
|
183
|
+
BIN,
|
|
184
|
+
baseEnv,
|
|
185
|
+
exitWithin,
|
|
186
|
+
getJson,
|
|
187
|
+
makeRepo,
|
|
188
|
+
makeWorkspace,
|
|
189
|
+
run,
|
|
190
|
+
spawnProc,
|
|
191
|
+
startFakeEmbedder,
|
|
192
|
+
tmp,
|
|
193
|
+
waitFor
|
|
194
|
+
};
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { describe, it, beforeEach, afterEach } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { parseCliFlags, resolveCodeSearchConfig, printConfig, DEFAULTS } from "../core/config.js";
|
|
7
|
+
import { loadServerConfig, resetServerConfig } from "../core/env-config.js";
|
|
8
|
+
import { validateProjectName } from "../core/project-name.js";
|
|
9
|
+
const saved = { ...process.env };
|
|
10
|
+
let noConfig;
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
for (const k of Object.keys(process.env)) {
|
|
13
|
+
if (k.startsWith("CODE_SEARCH_") || k.startsWith("ANVIL_") || k === "OPENAI_API_KEY" || k === "ANTHROPIC_API_KEY") delete process.env[k];
|
|
14
|
+
}
|
|
15
|
+
const home = mkdtempSync(join(tmpdir(), "cs-cfg-"));
|
|
16
|
+
process.env.HOME = home;
|
|
17
|
+
noConfig = join(home, "none.yaml");
|
|
18
|
+
resetServerConfig();
|
|
19
|
+
});
|
|
20
|
+
afterEach(() => {
|
|
21
|
+
process.env = { ...saved };
|
|
22
|
+
resetServerConfig();
|
|
23
|
+
});
|
|
24
|
+
describe("parseCliFlags", () => {
|
|
25
|
+
it("maps the root aliases into the config tree (no junk top-level keys)", () => {
|
|
26
|
+
const { patch, unknown } = parseCliFlags(["--port", "3200", "--host", "0.0.0.0", "--auth", "jwt", "--transport", "streamable-http"]);
|
|
27
|
+
assert.deepEqual(unknown, []);
|
|
28
|
+
assert.equal(patch.server?.port, 3200);
|
|
29
|
+
assert.equal(patch.server?.host, "0.0.0.0");
|
|
30
|
+
assert.equal(patch.server?.transport, "streamable-http");
|
|
31
|
+
assert.equal(patch.auth?.mode, "jwt");
|
|
32
|
+
assert.equal(patch.port, void 0);
|
|
33
|
+
});
|
|
34
|
+
it("accepts dotted config paths and reports unknown ones", () => {
|
|
35
|
+
const ok = parseCliFlags(["--retrieval.max-chunks", "12", "--no-indexing.auto-index", "--storage.backends.blob.backend", "fs"]);
|
|
36
|
+
assert.deepEqual(ok.unknown, []);
|
|
37
|
+
assert.equal(ok.patch.retrieval?.maxChunks, 12);
|
|
38
|
+
assert.equal(ok.patch.indexing?.autoIndex, false);
|
|
39
|
+
const bad = parseCliFlags(["--retrieval.max-chunkz", "3", "--daemon.auto-spawn", "--language", "ts", "--config", "x.yaml"]);
|
|
40
|
+
assert.deepEqual(bad.unknown, ["--retrieval.max-chunkz", "--daemon.auto-spawn", "--language", "--config"]);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
describe("--print-config redaction", () => {
|
|
44
|
+
it("redacts credentials in URIs, OTEL headers and secret-named keys", () => {
|
|
45
|
+
process.env.CODE_SEARCH_STORAGE_MONGO_URI = "mongodb://admin:PASSWORD1@db:27017/x";
|
|
46
|
+
process.env.CODE_SEARCH_STORAGE_MONGO_DB = "kb";
|
|
47
|
+
process.env.CODE_SEARCH_OTEL_HEADERS = "Authorization=Bearer OTELTOKEN,x-honeycomb-team=HONEYKEY";
|
|
48
|
+
process.env.CODE_SEARCH_EMBEDDING_BASE_URL = "https://user:URLPASS@embed.example.com/v1";
|
|
49
|
+
process.env.CODE_SEARCH_AUTH_JWT_SECRET = "JWTSECRET";
|
|
50
|
+
const cfg = resolveCodeSearchConfig({ configPath: noConfig });
|
|
51
|
+
cfg.storage.extra = { password: "PW2", clientSecret: "CS2", accessToken: "AT2", authorization: "Basic AU2" };
|
|
52
|
+
const out = printConfig(cfg);
|
|
53
|
+
for (const secret of ["PASSWORD1", "OTELTOKEN", "HONEYKEY", "URLPASS", "JWTSECRET", "PW2", "CS2", "AT2", "AU2"]) {
|
|
54
|
+
assert.ok(!out.includes(secret), `${secret} leaked:
|
|
55
|
+
${out}`);
|
|
56
|
+
}
|
|
57
|
+
assert.match(out, /mongodb:\/\/admin:\*\*\*@db:27017\/x/);
|
|
58
|
+
assert.match(out, /https:\/\/user:\*\*\*@embed\.example\.com\/v1/);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
describe("reindex interval", () => {
|
|
62
|
+
it("normalises env / CLI values the same way the server applies them", () => {
|
|
63
|
+
const at = (raw, cli = false) => {
|
|
64
|
+
delete process.env.CODE_SEARCH_REINDEX_INTERVAL;
|
|
65
|
+
if (!cli) process.env.CODE_SEARCH_REINDEX_INTERVAL = raw;
|
|
66
|
+
const patch = cli ? parseCliFlags(["--indexing.reindex-interval-ms", raw]).patch : void 0;
|
|
67
|
+
return resolveCodeSearchConfig({ configPath: noConfig, cli: patch }).indexing.reindexIntervalMs;
|
|
68
|
+
};
|
|
69
|
+
assert.equal(at("30m"), 30 * 6e4);
|
|
70
|
+
assert.equal(at("2h"), 2 * 36e5);
|
|
71
|
+
assert.equal(at("0"), 0);
|
|
72
|
+
assert.equal(at("none"), 0);
|
|
73
|
+
assert.equal(at("90s"), 0, "unsupported unit \u2192 disabled, not 90 ms");
|
|
74
|
+
assert.equal(at("abc"), 0);
|
|
75
|
+
assert.equal(at("30m", true), 30 * 6e4);
|
|
76
|
+
assert.equal(at("3600000", true), 36e5);
|
|
77
|
+
assert.equal(at("5", true), 0, "sub-minute interval \u2192 disabled");
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
describe("server config", () => {
|
|
81
|
+
it("honours an explicit LLM mode none even with an API key around", () => {
|
|
82
|
+
process.env.CODE_SEARCH_LLM_MODE = "none";
|
|
83
|
+
process.env.OPENAI_API_KEY = "sk-test";
|
|
84
|
+
assert.equal(loadServerConfig().llmMode, "none");
|
|
85
|
+
});
|
|
86
|
+
it("binds 0.0.0.0 by default when auth is enabled from any layer", () => {
|
|
87
|
+
const cfg = resolveCodeSearchConfig({ configPath: noConfig, cli: parseCliFlags(["--auth", "api-key"]).patch });
|
|
88
|
+
assert.equal(cfg.server.host, "0.0.0.0");
|
|
89
|
+
const explicit = resolveCodeSearchConfig({ configPath: noConfig, cli: parseCliFlags(["--auth", "api-key", "--host", "127.0.0.1"]).patch });
|
|
90
|
+
assert.equal(explicit.server.host, "127.0.0.1");
|
|
91
|
+
assert.equal(resolveCodeSearchConfig({ configPath: noConfig }).server.host, DEFAULTS.server.host);
|
|
92
|
+
});
|
|
93
|
+
it("applies the workspace .code-search.yaml layer", () => {
|
|
94
|
+
const ws = mkdtempSync(join(tmpdir(), "cs-ws-"));
|
|
95
|
+
writeFileSync(join(ws, ".code-search.yaml"), "retrieval:\n max_chunks: 21\n");
|
|
96
|
+
try {
|
|
97
|
+
assert.equal(resolveCodeSearchConfig({ configPath: noConfig, workspaceDir: ws }).retrieval.maxChunks, 21);
|
|
98
|
+
} finally {
|
|
99
|
+
rmSync(ws, { recursive: true, force: true });
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
describe("validateProjectName", () => {
|
|
104
|
+
it("rejects names that would escape or collide inside the data dir", () => {
|
|
105
|
+
for (const bad of ["", ".", "..", "a/b", "..\\x", "daemon", "projects", "x\0y"]) {
|
|
106
|
+
assert.throws(() => validateProjectName(bad), /project name/i, JSON.stringify(bad));
|
|
107
|
+
}
|
|
108
|
+
for (const good of ["ws", "my-project", "org_1.2"]) validateProjectName(good);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
@@ -100,7 +100,7 @@ describe("toKnowledgeConfig (P3 adapter)", () => {
|
|
|
100
100
|
const k = toKnowledgeConfig(cfg);
|
|
101
101
|
assert.equal(k.embedding.provider, cfg.embedding.provider);
|
|
102
102
|
assert.equal(k.retrieval.maxChunks, cfg.retrieval.maxChunks);
|
|
103
|
-
assert.deepEqual(k.chunking, cfg.indexing.chunking);
|
|
103
|
+
assert.deepEqual(k.chunking, { ...cfg.indexing.chunking, ignorePatterns: cfg.indexing.ignorePatterns });
|
|
104
104
|
const r = typeof k.retrieval.reranker === "string" ? { provider: k.retrieval.reranker } : k.retrieval.reranker;
|
|
105
105
|
assert.equal(r.provider, cfg.reranker.provider);
|
|
106
106
|
});
|