@esankhan3/code-search-mcp 1.0.5 → 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.
Files changed (41) hide show
  1. package/README.md +114 -54
  2. package/dist/__tests__/_harness.js +194 -0
  3. package/dist/__tests__/config-cli.test.js +110 -0
  4. package/dist/__tests__/config-resolver.test.js +1 -1
  5. package/dist/__tests__/daemon-rpc-protocol.test.js +95 -0
  6. package/dist/__tests__/daemon-runtime.test.js +145 -0
  7. package/dist/__tests__/graph-tools-store.test.js +314 -0
  8. package/dist/__tests__/graph-tools.test.js +11 -4
  9. package/dist/__tests__/http-auth.test.js +180 -0
  10. package/dist/__tests__/http-index.test.js +125 -0
  11. package/dist/__tests__/metrics-collect.test.js +48 -0
  12. package/dist/__tests__/observability.test.js +3 -4
  13. package/dist/__tests__/remote-proxy.test.js +105 -0
  14. package/dist/__tests__/server-cli-args.test.js +146 -0
  15. package/dist/__tests__/server-lifecycle.test.js +108 -0
  16. package/dist/__tests__/tool-inputs.test.js +178 -0
  17. package/dist/backends/in-process.js +5 -2
  18. package/dist/backends/index.js +2 -2
  19. package/dist/cli/index.js +143 -76
  20. package/dist/core/config.js +106 -35
  21. package/dist/core/env-config.js +3 -5
  22. package/dist/core/project-name.js +12 -0
  23. package/dist/core/version.js +5 -0
  24. package/dist/daemon/index.js +106 -65
  25. package/dist/daemon/rpc-server.js +38 -18
  26. package/dist/index.js +113 -89
  27. package/dist/middleware/auth.js +34 -66
  28. package/dist/middleware/rate-limit.js +37 -0
  29. package/dist/observability/logger.js +10 -2
  30. package/dist/observability/metrics.js +7 -6
  31. package/dist/projects/registry.js +13 -15
  32. package/dist/resources/resources.js +17 -0
  33. package/dist/server.js +216 -129
  34. package/dist/tools/graph.js +353 -133
  35. package/dist/tools/index-tools.js +3 -2
  36. package/dist/tools/profile.js +14 -8
  37. package/dist/tools/search.js +61 -51
  38. package/dist/tools/validate.js +48 -0
  39. package/dist/transports/http-transport.js +94 -55
  40. package/dist/transports/remote-proxy.js +58 -97
  41. 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. Listens on a UDS socket at
72
- `<dataDir>/daemon/<project>.sock` (named pipe on Windows). The CLI
73
- and MCP server automatically prefer the daemon when it's alive,
74
- falling back to in-process when it isn't.
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 transport, SSE optional)
102
- with `/mcp`, `/health`, `/ready`, `/version`, `/status`,
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`. Use this to host one index for a whole team — every
105
- dev points their client at the same URL.
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
- Eleven tools across four categories. Every one of them maps to a
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-only (identifiers, error codes) |
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 (Kafka, HTTP, gRPC, shared types, …) |
128
- | `find_callers` | Who calls this function (exact by default; `fuzzy:true` for substring) |
129
- | `find_dependencies` | What this function calls |
130
- | `impact_analysis` | What breaks if this changes |
131
- | `trace_path` | Multi-hop call chain — shortest path between two functions, or the reachable call tree |
132
- | `search_graph` | Structural query — filter entities by name/type/file, ranked by connectivity |
133
- | `find_dead_code` | Entities with no callers (heuristic) |
134
- | `detect_changes` | Map a git diff to affected entities + their dependents |
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` | Last indexed SHA, chunk count, embedding provider |
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. `find_callers` works *across
182
- repos*. So does `impact_analysis`.
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` only after first index completes |
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 (queries, latency histogram, chunks, embedding/LLM calls, errors) |
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 `CODE_SEARCH_TELEMETRY_RECORD_QUERIES=1` to opt
202
- into recording query text in spans (off by default — queries are
203
- PII-shaped).
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
- `ProjectRegistry` reads them at boot. `projectAccessAllowed`
220
- gates by scope (`*` is admin). `checkProjectQuota` enforces a
221
- sliding 1-minute window per `(identity, project)`.
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 + `exp` + `iss` validated). Public binding without auth
231
- logs a warning, because that's what should happen.
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
- In-memory sliding 1-minute window keyed by identity subject. Tune
235
- per-deployment.
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
- runs an incremental re-index in the background. Skips itself if a
240
- manual index is already running. The timer is `unref`'d so it
241
- doesn't keep the process alive.
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 `{ project, dirPath?, opts? }` triggers a
256
- fresh index. Gated against concurrent runs. Auth-required when
257
- auth is on.
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
- ~/.anvil/knowledge-base/<project>/
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` | Override the index storage root |
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` / `DAEMON_AUTO_SPAWN` | Daemon backend control |
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 without auth
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
  });