@esankhan3/code-search-mcp 0.0.2 → 0.2.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 (50) hide show
  1. package/README.md +343 -248
  2. package/dist/__tests__/config-resolver.test.js +153 -0
  3. package/dist/__tests__/daemon-rpc.test.js +100 -0
  4. package/dist/__tests__/observability.test.js +69 -0
  5. package/dist/__tests__/projects.test.js +141 -0
  6. package/dist/backends/daemon-client.js +87 -0
  7. package/dist/backends/in-process.js +104 -0
  8. package/dist/backends/index.js +29 -0
  9. package/dist/cli/index.js +231 -0
  10. package/dist/core/config.js +362 -49
  11. package/dist/core/env-config.js +45 -43
  12. package/dist/daemon/index.js +119 -0
  13. package/dist/daemon/rpc-server.js +99 -0
  14. package/dist/daemon/watcher.js +105 -0
  15. package/dist/index.js +7 -0
  16. package/dist/observability/logger.js +66 -0
  17. package/dist/observability/metrics.js +138 -0
  18. package/dist/projects/registry.js +146 -0
  19. package/dist/resources/resources.js +3 -3
  20. package/dist/server.js +6 -2
  21. package/dist/tools/graph.js +1 -1
  22. package/dist/tools/index-tools.js +1 -1
  23. package/dist/tools/profile.js +2 -2
  24. package/dist/tools/search.js +1 -1
  25. package/dist/transports/http-transport.js +38 -0
  26. package/package.json +31 -12
  27. package/scripts/smoke/run-smoke.sh +167 -0
  28. package/dist/core/ast-graph-builder.js +0 -800
  29. package/dist/core/chunker.js +0 -368
  30. package/dist/core/claude-runner.js +0 -173
  31. package/dist/core/cross-repo-detector.js +0 -887
  32. package/dist/core/embedder.js +0 -334
  33. package/dist/core/file-walker.js +0 -186
  34. package/dist/core/git-diff.js +0 -160
  35. package/dist/core/graph-metrics.js +0 -233
  36. package/dist/core/indexer.js +0 -552
  37. package/dist/core/project-graph-builder.js +0 -202
  38. package/dist/core/query-classifier.js +0 -144
  39. package/dist/core/query-router.js +0 -210
  40. package/dist/core/rag-evaluator.js +0 -213
  41. package/dist/core/repo-profiler.js +0 -383
  42. package/dist/core/reranker.js +0 -218
  43. package/dist/core/retriever.js +0 -242
  44. package/dist/core/semantic-edge-detector.js +0 -147
  45. package/dist/core/service-mesh-inferrer.js +0 -241
  46. package/dist/core/structural-hasher.js +0 -316
  47. package/dist/core/tree-sitter-parser.js +0 -539
  48. package/dist/core/vector-store.js +0 -259
  49. package/dist/core/workspace-detector.js +0 -481
  50. /package/dist/{core → backends}/types.js +0 -0
@@ -0,0 +1,146 @@
1
+ import { readdirSync, existsSync, statSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import {
4
+ resolveCodeSearchConfig
5
+ } from "../core/config.js";
6
+ const DEFAULT_QUOTAS = {
7
+ maxQueriesPerMinute: 100,
8
+ maxEmbeddingCostUsd: 0,
9
+ maxLlmCostUsd: 0
10
+ };
11
+ function parseSimpleYaml(src) {
12
+ const obj = {};
13
+ const lines = src.split(/\r?\n/);
14
+ let i = 0;
15
+ while (i < lines.length) {
16
+ const line = lines[i].replace(/#.*$/, "").replace(/\s+$/, "");
17
+ if (!line.trim()) {
18
+ i++;
19
+ continue;
20
+ }
21
+ const top = /^(\w+):\s*(.*)$/.exec(line);
22
+ if (!top) {
23
+ i++;
24
+ continue;
25
+ }
26
+ const key = top[1];
27
+ const valRaw = top[2];
28
+ if (!valRaw) {
29
+ const sub = {};
30
+ i++;
31
+ while (i < lines.length) {
32
+ const sline = lines[i].replace(/#.*$/, "").replace(/\s+$/, "");
33
+ if (!sline) {
34
+ i++;
35
+ continue;
36
+ }
37
+ const indent = sline.length - sline.trimStart().length;
38
+ if (indent < 2) break;
39
+ const m = /^\s*(\w+):\s*(.*)$/.exec(sline);
40
+ if (!m) {
41
+ i++;
42
+ continue;
43
+ }
44
+ sub[m[1]] = coerce(m[2]);
45
+ i++;
46
+ }
47
+ obj[key] = sub;
48
+ } else {
49
+ obj[key] = coerce(valRaw);
50
+ i++;
51
+ }
52
+ }
53
+ return obj;
54
+ }
55
+ function coerce(raw) {
56
+ let v = raw.trim();
57
+ if (v.startsWith('"') && v.endsWith('"') || v.startsWith("'") && v.endsWith("'")) return v.slice(1, -1);
58
+ if (v === "true") return true;
59
+ if (v === "false") return false;
60
+ if (/^-?\d+$/.test(v)) return parseInt(v, 10);
61
+ if (/^-?\d+\.\d+$/.test(v)) return parseFloat(v);
62
+ if (v.startsWith("[") && v.endsWith("]")) return v.slice(1, -1).split(",").map((s) => coerce(s.trim()));
63
+ return v;
64
+ }
65
+ function readProjectYaml(path) {
66
+ if (!existsSync(path)) return {};
67
+ try {
68
+ return parseSimpleYaml(readFileSync(path, "utf-8"));
69
+ } catch {
70
+ return {};
71
+ }
72
+ }
73
+ class ProjectRegistry {
74
+ constructor(dataDir) {
75
+ this.dataDir = dataDir;
76
+ }
77
+ projects = /* @__PURE__ */ new Map();
78
+ /** Re-read the projects/ directory. */
79
+ reload() {
80
+ this.projects.clear();
81
+ const projectsDir = join(this.dataDir, "projects");
82
+ if (!existsSync(projectsDir)) return [];
83
+ for (const name of readdirSync(projectsDir)) {
84
+ const dir = join(projectsDir, name);
85
+ try {
86
+ if (!statSync(dir).isDirectory()) continue;
87
+ } catch {
88
+ continue;
89
+ }
90
+ const yamlPath = join(dir, "project.yaml");
91
+ const meta = readProjectYaml(yamlPath);
92
+ if (!meta.workspace) continue;
93
+ const workspaceDir = String(meta.workspace);
94
+ const quotas = {
95
+ maxQueriesPerMinute: meta.quotas?.max_queries_per_minute ?? meta.quotas?.maxQueriesPerMinute ?? DEFAULT_QUOTAS.maxQueriesPerMinute,
96
+ maxEmbeddingCostUsd: meta.quotas?.max_embedding_cost_usd ?? meta.quotas?.maxEmbeddingCostUsd ?? DEFAULT_QUOTAS.maxEmbeddingCostUsd,
97
+ maxLlmCostUsd: meta.quotas?.max_llm_cost_usd ?? meta.quotas?.maxLlmCostUsd ?? DEFAULT_QUOTAS.maxLlmCostUsd
98
+ };
99
+ const config = resolveCodeSearchConfig({ workspaceDir });
100
+ this.projects.set(name, {
101
+ name,
102
+ workspaceDir,
103
+ repos: Array.isArray(meta.repos) ? meta.repos : [],
104
+ scopes: Array.isArray(meta.scopes) ? meta.scopes : [],
105
+ config,
106
+ quotas
107
+ });
108
+ }
109
+ return [...this.projects.values()];
110
+ }
111
+ list() {
112
+ return [...this.projects.values()];
113
+ }
114
+ get(name) {
115
+ return this.projects.get(name);
116
+ }
117
+ }
118
+ function projectAccessAllowed(project, identityScopes) {
119
+ if (project.scopes.length === 0) return true;
120
+ if (identityScopes.includes("*")) return true;
121
+ for (const s of identityScopes) {
122
+ if (project.scopes.includes(s)) return true;
123
+ }
124
+ return false;
125
+ }
126
+ const projectRateBuckets = /* @__PURE__ */ new Map();
127
+ function checkProjectQuota(project, identity) {
128
+ if (project.quotas.maxQueriesPerMinute <= 0) return { ok: true };
129
+ const key = `${identity}|${project.name}`;
130
+ const now = Date.now();
131
+ const bucket = projectRateBuckets.get(key);
132
+ if (!bucket || now > bucket.resetAt) {
133
+ projectRateBuckets.set(key, { count: 1, resetAt: now + 6e4 });
134
+ return { ok: true };
135
+ }
136
+ if (bucket.count >= project.quotas.maxQueriesPerMinute) {
137
+ return { ok: false, reason: `quota exceeded for project="${project.name}": ${project.quotas.maxQueriesPerMinute}/min` };
138
+ }
139
+ bucket.count++;
140
+ return { ok: true };
141
+ }
142
+ export {
143
+ ProjectRegistry,
144
+ checkProjectQuota,
145
+ projectAccessAllowed
146
+ };
@@ -1,8 +1,8 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
- import { getKnowledgeBasePath } from "../core/config.js";
4
- import { loadAllProfiles } from "../core/repo-profiler.js";
5
- import { loadProfile } from "../core/repo-profiler.js";
3
+ import { getKnowledgeBasePath } from "@esankhan3/anvil-knowledge-core";
4
+ import { loadAllProfiles } from "@esankhan3/anvil-knowledge-core";
5
+ import { loadProfile } from "@esankhan3/anvil-knowledge-core";
6
6
  function registerResources(ctx) {
7
7
  return [
8
8
  {
package/dist/server.js CHANGED
@@ -13,9 +13,10 @@ import { registerGraphTools, handleGraphTool } from "./tools/graph.js";
13
13
  import { registerProfileTools, handleProfileTool } from "./tools/profile.js";
14
14
  import { registerIndexTools, handleIndexTool } from "./tools/index-tools.js";
15
15
  import { registerResources, handleResource } from "./resources/resources.js";
16
- import { getKnowledgeBasePath } from "./core/config.js";
17
- import { indexFromPath } from "./core/indexer.js";
16
+ import { getKnowledgeBasePath } from "@esankhan3/anvil-knowledge-core";
17
+ import { indexFromPath } from "@esankhan3/anvil-knowledge-core";
18
18
  import { loadServerConfig } from "./core/env-config.js";
19
+ import { toKnowledgeConfig } from "./core/config.js";
19
20
  import { startHttpTransport } from "./transports/http-transport.js";
20
21
  function createMcpServerInstance(ctx) {
21
22
  const server = new Server(
@@ -185,9 +186,12 @@ async function trackedIndex(ctx, project, dirPath, opts) {
185
186
  ctx.indexing.startedAt = Date.now();
186
187
  ctx.indexing.error = null;
187
188
  pushHistory(ctx, "start", `${label}: started for "${project}" at ${dirPath}`);
189
+ const unified = loadServerConfig().__unified;
190
+ const knowledgeConfig = toKnowledgeConfig(unified);
188
191
  try {
189
192
  const stats = await indexFromPath(project, dirPath, {
190
193
  force: opts?.force,
194
+ config: knowledgeConfig,
191
195
  onProgress: (m) => {
192
196
  ctx.indexing.message = m;
193
197
  console.error(`[${label}] ${m}`);
@@ -1,6 +1,6 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
- import { getKnowledgeBasePath } from "../core/config.js";
3
+ import { getKnowledgeBasePath } from "@esankhan3/anvil-knowledge-core";
4
4
  function registerGraphTools() {
5
5
  return [
6
6
  {
@@ -1,4 +1,4 @@
1
- import { KnowledgeIndexer } from "../core/indexer.js";
1
+ import { KnowledgeIndexer } from "@esankhan3/anvil-knowledge-core";
2
2
  const TOOL_NAMES = ["index_status"];
3
3
  function registerIndexTools() {
4
4
  return [
@@ -1,5 +1,5 @@
1
- import { loadAllProfiles, loadProfile } from "../core/repo-profiler.js";
2
- import { discoverRepos } from "../core/indexer.js";
1
+ import { loadAllProfiles, loadProfile } from "@esankhan3/anvil-knowledge-core";
2
+ import { discoverRepos } from "@esankhan3/anvil-knowledge-core";
3
3
  function registerProfileTools() {
4
4
  return [
5
5
  {
@@ -1,4 +1,4 @@
1
- import { getRetriever } from "../core/indexer.js";
1
+ import { getRetriever } from "@esankhan3/anvil-knowledge-core";
2
2
  function registerSearchTools() {
3
3
  return [
4
4
  {
@@ -2,9 +2,11 @@ import { createServer } from "node:http";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
4
4
  import { createAuthMiddleware } from "../middleware/auth.js";
5
+ import { registry, metrics } from "../observability/metrics.js";
5
6
  async function startHttpTransport(opts) {
6
7
  const { config, createMcpServer, onReady, getHealth } = opts;
7
8
  const authenticate = createAuthMiddleware(config);
9
+ const startedAt = Date.now();
8
10
  const sessions = /* @__PURE__ */ new Map();
9
11
  const SESSION_TTL_MS = 30 * 60 * 1e3;
10
12
  const MAX_SESSIONS = 100;
@@ -37,6 +39,42 @@ async function startHttpTransport(opts) {
37
39
  res.end(JSON.stringify(status, null, 2));
38
40
  return;
39
41
  }
42
+ if (path === "/version" && req.method === "GET") {
43
+ res.writeHead(200, { "Content-Type": "application/json" });
44
+ res.end(JSON.stringify({
45
+ name: "code-search-mcp",
46
+ version: "0.1.0",
47
+ node: process.version,
48
+ platform: process.platform
49
+ }));
50
+ return;
51
+ }
52
+ if (path === "/ready" && req.method === "GET") {
53
+ const health = getHealth?.() ?? {};
54
+ const ready = health.indexReady === true;
55
+ res.writeHead(ready ? 200 : 503, { "Content-Type": "application/json" });
56
+ res.end(JSON.stringify({ ready, ...health }));
57
+ return;
58
+ }
59
+ if (path === "/metrics" && req.method === "GET") {
60
+ const status = opts.getStatus?.() ?? {};
61
+ const indexing = status.indexing;
62
+ metrics.indexAge.set(0, { phase: indexing?.phase ?? "idle" });
63
+ res.writeHead(200, { "Content-Type": "text/plain; version=0.0.4" });
64
+ res.end(registry.render());
65
+ return;
66
+ }
67
+ if (path === "/admin/api/status" && req.method === "GET") {
68
+ const status = opts.getStatus?.() ?? {};
69
+ res.writeHead(200, { "Content-Type": "application/json" });
70
+ res.end(JSON.stringify({
71
+ version: "0.1.0",
72
+ uptime: Math.floor((Date.now() - startedAt) / 1e3),
73
+ sessions: sessions.size,
74
+ ...status
75
+ }, null, 2));
76
+ return;
77
+ }
40
78
  if (path === "/index" && req.method === "POST") {
41
79
  if (config.authEnabled) {
42
80
  const identity = authenticate(req, res);
package/package.json CHANGED
@@ -1,33 +1,52 @@
1
1
  {
2
2
  "name": "@esankhan3/code-search-mcp",
3
- "version": "0.0.2",
3
+ "version": "0.2.0",
4
4
  "description": "MCP server for multi-repo code search — GitHub org or local path, zero config",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "bin": {
8
- "code-search-mcp": "./dist/index.js"
8
+ "code-search-mcp": "./dist/index.js",
9
+ "code-search": "./dist/cli/index.js",
10
+ "code-search-daemon": "./dist/daemon/index.js"
9
11
  },
10
12
  "exports": {
11
13
  ".": "./dist/index.js"
12
14
  },
13
15
  "files": [
14
- "dist"
16
+ "dist",
17
+ "scripts/smoke"
15
18
  ],
16
19
  "scripts": {
17
20
  "build": "node build.mjs",
18
- "dev": "tsc -b --watch"
21
+ "dev": "tsc -b --watch",
22
+ "test": "node --test dist/__tests__/*.test.js",
23
+ "smoke": "bash scripts/smoke/run-smoke.sh"
19
24
  },
20
25
  "dependencies": {
21
- "@lancedb/lancedb": "0.27.2",
22
- "@modelcontextprotocol/sdk": "^1.29.0",
23
- "graphology": "0.26.0",
24
- "graphology-communities-louvain": "2.0.2",
25
- "graphology-metrics": "2.4.0",
26
- "graphology-types": "0.24.8",
27
- "tree-sitter-wasms": "0.1.13",
28
- "web-tree-sitter": "0.26.8"
26
+ "@esankhan3/anvil-agent-core": "^0.2.0",
27
+ "@esankhan3/anvil-knowledge-core": "^0.2.0",
28
+ "@modelcontextprotocol/sdk": "^1.29.0"
29
+ },
30
+ "optionalDependencies": {
31
+ "@lancedb/lancedb-darwin-arm64": "0.27.2",
32
+ "@lancedb/lancedb-darwin-x64": "0.27.2",
33
+ "@lancedb/lancedb-linux-x64-gnu": "0.27.2",
34
+ "@lancedb/lancedb-linux-arm64-gnu": "0.27.2",
35
+ "@lancedb/lancedb-win32-x64-msvc": "0.27.2"
29
36
  },
30
37
  "devDependencies": {
31
38
  "@types/node": "^22.15.2"
39
+ },
40
+ "repository": {
41
+ "type": "git",
42
+ "url": "https://github.com/esanmohammad/Anvil.git",
43
+ "directory": "packages/code-search-mcp"
44
+ },
45
+ "homepage": "https://github.com/esanmohammad/Anvil/tree/main/packages/code-search-mcp#readme",
46
+ "bugs": {
47
+ "url": "https://github.com/esanmohammad/Anvil/issues"
48
+ },
49
+ "publishConfig": {
50
+ "access": "public"
32
51
  }
33
52
  }
@@ -0,0 +1,167 @@
1
+ #!/usr/bin/env bash
2
+ # code-search-mcp end-to-end smoke (T1–T8 from the standalone product plan).
3
+ #
4
+ # Runs the same eight checks we used while landing the P0–P8 work. Requires:
5
+ # - Ollama running on $OLLAMA_HOST (default http://localhost:11434) with
6
+ # `bge-m3` and `qwen2.5-coder:7b` pulled (re-rank step).
7
+ # - The bundle built (`npm -w @esankhan3/code-search-mcp run build`).
8
+ #
9
+ # Optional envs picked up by individual cases:
10
+ # - OPENAI_API_KEY → T5 OpenAI embedding path
11
+ # - GEMINI_OAUTH_CREDS=~/.gemini/oauth_creds.json → T5b Gemini path
12
+ #
13
+ # Exit non-zero on the first failure. The script is idempotent: every case
14
+ # writes under $TMP/ and cleans up its own state at the start.
15
+
16
+ set -u
17
+ cd "$(dirname "$0")/../.." # → packages/code-search-mcp
18
+
19
+ TMP="${TMP:-/tmp/cs-smoke}"
20
+ DIST_CLI="$PWD/dist/cli/index.js"
21
+ DIST_MCP="$PWD/dist/index.js"
22
+ DIST_DAEMON="$PWD/dist/daemon/index.js"
23
+
24
+ if [[ ! -f "$DIST_CLI" || ! -f "$DIST_MCP" || ! -f "$DIST_DAEMON" ]]; then
25
+ echo "FATAL: dist/ missing — run \`npm -w @esankhan3/code-search-mcp run build\` first" >&2
26
+ exit 64
27
+ fi
28
+
29
+ PASS=0
30
+ FAIL=0
31
+ SKIP=0
32
+
33
+ pass() { echo " ✓ $*"; PASS=$((PASS+1)); }
34
+ fail() { echo " ✗ $*" >&2; FAIL=$((FAIL+1)); }
35
+ skip() { echo " - $* (skipped)"; SKIP=$((SKIP+1)); }
36
+ hdr() { echo; echo "── $* ──"; }
37
+
38
+ # ── Setup: minimal pet-shop repo ─────────────────────────────────────────────
39
+ hdr "Setup"
40
+ rm -rf "$TMP"
41
+ mkdir -p "$TMP/pet-shop/src" "$TMP/index"
42
+ cat > "$TMP/pet-shop/src/auth.ts" <<'EOF'
43
+ export function verifyToken(token: string, secret: string): boolean { return token.length > 0 && secret.length > 0; }
44
+ export function requireScope(scope: string, scopes: string[]): void { if (!scopes.includes(scope)) throw new Error('forbidden'); }
45
+ EOF
46
+ cat > "$TMP/pet-shop/src/pets.ts" <<'EOF'
47
+ export interface Pet { id: string; name: string; available: boolean }
48
+ const pets = new Map<string, Pet>();
49
+ export function listAvailablePets(): Pet[] { return [...pets.values()].filter((p) => p.available); }
50
+ export function adoptPet(id: string): Pet { const p = pets.get(id); if (!p) throw new Error('not found'); p.available = false; return p; }
51
+ EOF
52
+ ( cd "$TMP/pet-shop" && git init -q && git config user.email t@t.t && git config user.name t && git add -A && git commit -q -m initial ) || { fail "git init"; exit 1; }
53
+ pass "pet-shop fixture committed"
54
+
55
+ # Common envs for the rest of the smoke.
56
+ export CODE_SEARCH_DATA_DIR="$TMP/index"
57
+ export CODE_SEARCH_EMBEDDING_PROVIDER=ollama
58
+ export CODE_SEARCH_EMBEDDING_MODEL=bge-m3
59
+ export CODE_SEARCH_EMBEDDING_DIMENSIONS=1024
60
+ export CODE_SEARCH_RERANKER_PROVIDER=none
61
+ export CODE_SEARCH_LLM_MODE=none
62
+ export CODE_SEARCH_DAEMON_DISABLED=1
63
+
64
+ # ── T1: --print-config ───────────────────────────────────────────────────────
65
+ hdr "T1: --print-config"
66
+ OUT="$(CODE_SEARCH_EMBEDDING_PROVIDER=ollama node "$DIST_CLI" --print-config --retrieval.max-chunks 12 2>&1)" || { fail "exited non-zero"; }
67
+ echo "$OUT" | grep -q '"provider": "ollama"' && pass "env layer applied" || fail "embedding.provider != ollama"
68
+ echo "$OUT" | grep -q '"maxChunks": 12' && pass "CLI flag applied" || fail "retrieval.maxChunks != 12 (CLI flag override)"
69
+
70
+ # ── T2: code-search index against real Ollama ───────────────────────────────
71
+ hdr "T2: index pet-shop (Ollama bge-m3)"
72
+ OUT="$(node "$DIST_CLI" index "$TMP/pet-shop" --project petshop 2>&1)" || { fail "indexer exited non-zero"; echo "$OUT" >&2; }
73
+ echo "$OUT" | grep -q '"chunks":' && pass "indexer printed chunks summary" || fail "indexer summary missing"
74
+ echo "$OUT" | grep -q 'DEPRECATED: CODE_SEARCH_LLM_MODE' && fail "F1 regression — stale ANVIL_LLM_MODE warning fired" || pass "F1: no stale legacy-env warning"
75
+
76
+ # ── T3: query (vector + bm25 baseline; reranker disabled) ───────────────────
77
+ hdr "T3: code-search query"
78
+ OUT="$(node "$DIST_CLI" query "adopt pet" --project petshop --mode vector --top-k 1 --format json 2>&1)" || { fail "vector query failed"; }
79
+ echo "$OUT" | grep -q '"filePath": "src/pets.ts"' && pass "vector query surfaced pets.ts" || fail "vector query did not surface pets.ts"
80
+ OUT="$(node "$DIST_CLI" query "adoptPet" --project petshop --mode bm25 --top-k 1 --format json 2>&1)" || { fail "bm25 query failed"; }
81
+ echo "$OUT" | grep -q '"filePath": "src/pets.ts"' && pass "bm25 query surfaced pets.ts" || fail "bm25 query did not surface pets.ts"
82
+
83
+ # ── T4: status reports the index ────────────────────────────────────────────
84
+ hdr "T4: status"
85
+ OUT="$(node "$DIST_CLI" status --project petshop 2>&1)"
86
+ echo "$OUT" | grep -q '"embeddingProvider": "ollama"' && pass "embeddingProvider=ollama" || fail "status embeddingProvider mismatch"
87
+ echo "$OUT" | grep -q '"totalChunks":' && pass "totalChunks reported" || fail "totalChunks missing"
88
+
89
+ # ── T5: issue #6 — env vars reach the embedder (provider switch) ────────────
90
+ hdr "T5: env-vars-reach-embedder (issue #6 reproduction)"
91
+ if [[ -n "${OPENAI_API_KEY:-}" ]]; then
92
+ OUT="$(CODE_SEARCH_DATA_DIR="$TMP/index-openai" CODE_SEARCH_EMBEDDING_PROVIDER=openai CODE_SEARCH_EMBEDDING_MODEL=text-embedding-3-small CODE_SEARCH_EMBEDDING_DIMENSIONS=512 CODE_SEARCH_EMBEDDING_API_KEY="$OPENAI_API_KEY" node "$DIST_CLI" index "$TMP/pet-shop" --project petshop-openai 2>&1)"
93
+ # Either embedding succeeds (real key) OR we get OpenAI's auth error
94
+ # (placeholder key). Both prove the env var reached the embedder.
95
+ if echo "$OUT" | grep -qE '("chunks":|api.openai.com|OpenAI embedding request)'; then
96
+ pass "OpenAI embedder constructed + called (env vars threaded through)"
97
+ else
98
+ fail "OpenAI embedder didn't run"; echo "$OUT" | tail -5 >&2
99
+ fi
100
+ else
101
+ skip "T5 OpenAI path — set OPENAI_API_KEY to verify"
102
+ fi
103
+
104
+ # ── T6: vector-space mismatch guard ─────────────────────────────────────────
105
+ hdr "T6: vector-space mismatch guard"
106
+ OUT="$(CODE_SEARCH_EMBEDDING_PROVIDER=openai CODE_SEARCH_EMBEDDING_API_KEY=stub-key node "$DIST_CLI" query "adopt pet" --project petshop --mode vector --top-k 1 --format json 2>&1)" || true
107
+ echo "$OUT" | grep -q 'Vector-space mismatch' && pass "guard refused the cross-provider query" || fail "guard did not fire on provider mismatch"
108
+
109
+ # ── T7: daemon round-trip ───────────────────────────────────────────────────
110
+ hdr "T7: daemon round-trip"
111
+ unset CODE_SEARCH_DAEMON_DISABLED
112
+ DAEMON_LOG="$TMP/daemon.log"
113
+ node "$DIST_DAEMON" --workspace "$TMP/pet-shop" > "$DAEMON_LOG" 2>&1 &
114
+ DAEMON_PID=$!
115
+ sleep 6
116
+ if kill -0 "$DAEMON_PID" 2>/dev/null; then
117
+ pass "daemon process alive (pid $DAEMON_PID)"
118
+ SOCKET="$TMP/index/daemon/pet-shop.sock"
119
+ test -S "$SOCKET" && pass "UDS socket present" || fail "socket missing at $SOCKET"
120
+ OUT="$(node "$DIST_CLI" query "adopt pet" --project pet-shop --mode vector --top-k 1 --format json 2>&1)"
121
+ echo "$OUT" | grep -q '"filePath": "src/pets.ts"' && pass "query through daemon succeeded" || fail "daemon-backed query failed"
122
+ echo "// touched $(date)" >> "$TMP/pet-shop/src/pets.ts"
123
+ sleep 3
124
+ grep -q 'reindex queued' "$DAEMON_LOG" && pass "watcher debounce-reindexed on file touch" || fail "watcher didn't fire"
125
+ kill "$DAEMON_PID" 2>/dev/null
126
+ for _ in 1 2 3 4 5; do
127
+ kill -0 "$DAEMON_PID" 2>/dev/null || break
128
+ sleep 1
129
+ done
130
+ kill -9 "$DAEMON_PID" 2>/dev/null || true
131
+ wait "$DAEMON_PID" 2>/dev/null || true
132
+ else
133
+ fail "daemon exited prematurely"; tail -10 "$DAEMON_LOG" >&2
134
+ fi
135
+ export CODE_SEARCH_DAEMON_DISABLED=1
136
+
137
+ # ── T8: HTTP server endpoints ───────────────────────────────────────────────
138
+ hdr "T8: HTTP serve"
139
+ PORT=$(( ( RANDOM % 1000 ) + 4100 ))
140
+ HTTP_LOG="$TMP/http.log"
141
+ CODE_SEARCH_PORT="$PORT" CODE_SEARCH_HOST=127.0.0.1 CODE_SEARCH_TRANSPORT=streamable-http node "$DIST_MCP" --serve --project petshop > "$HTTP_LOG" 2>&1 &
142
+ HTTP_PID=$!
143
+ sleep 5
144
+ if kill -0 "$HTTP_PID" 2>/dev/null; then
145
+ for ep in health ready version metrics admin/api/status; do
146
+ code="$(curl -s -o /dev/null -w '%{http_code}' "http://127.0.0.1:${PORT}/${ep}")"
147
+ if [[ "$code" == "200" ]]; then pass "GET /$ep → 200"; else fail "GET /$ep → $code"; fi
148
+ done
149
+ curl -s "http://127.0.0.1:${PORT}/metrics" | grep -q '^# HELP code_search_queries_total' \
150
+ && pass "/metrics emits Prom text format" || fail "/metrics missing prelude"
151
+ kill "$HTTP_PID" 2>/dev/null
152
+ for _ in 1 2 3 4 5; do
153
+ kill -0 "$HTTP_PID" 2>/dev/null || break
154
+ sleep 1
155
+ done
156
+ kill -9 "$HTTP_PID" 2>/dev/null || true
157
+ wait "$HTTP_PID" 2>/dev/null || true
158
+ else
159
+ fail "HTTP server failed to start"; tail -10 "$HTTP_LOG" >&2
160
+ fi
161
+
162
+ # ── Summary ─────────────────────────────────────────────────────────────────
163
+ echo
164
+ echo "──────────────────────────────────────────"
165
+ echo "PASS: $PASS FAIL: $FAIL SKIP: $SKIP"
166
+ echo "──────────────────────────────────────────"
167
+ exit $(( FAIL == 0 ? 0 : 1 ))