@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.
- package/README.md +343 -248
- package/dist/__tests__/config-resolver.test.js +153 -0
- package/dist/__tests__/daemon-rpc.test.js +100 -0
- package/dist/__tests__/observability.test.js +69 -0
- package/dist/__tests__/projects.test.js +141 -0
- package/dist/backends/daemon-client.js +87 -0
- package/dist/backends/in-process.js +104 -0
- package/dist/backends/index.js +29 -0
- package/dist/cli/index.js +231 -0
- package/dist/core/config.js +362 -49
- package/dist/core/env-config.js +45 -43
- package/dist/daemon/index.js +119 -0
- package/dist/daemon/rpc-server.js +99 -0
- package/dist/daemon/watcher.js +105 -0
- package/dist/index.js +7 -0
- package/dist/observability/logger.js +66 -0
- package/dist/observability/metrics.js +138 -0
- package/dist/projects/registry.js +146 -0
- package/dist/resources/resources.js +3 -3
- package/dist/server.js +6 -2
- package/dist/tools/graph.js +1 -1
- package/dist/tools/index-tools.js +1 -1
- package/dist/tools/profile.js +2 -2
- package/dist/tools/search.js +1 -1
- package/dist/transports/http-transport.js +38 -0
- package/package.json +31 -12
- package/scripts/smoke/run-smoke.sh +167 -0
- package/dist/core/ast-graph-builder.js +0 -800
- package/dist/core/chunker.js +0 -368
- package/dist/core/claude-runner.js +0 -173
- package/dist/core/cross-repo-detector.js +0 -887
- package/dist/core/embedder.js +0 -334
- package/dist/core/file-walker.js +0 -186
- package/dist/core/git-diff.js +0 -160
- package/dist/core/graph-metrics.js +0 -233
- package/dist/core/indexer.js +0 -552
- package/dist/core/project-graph-builder.js +0 -202
- package/dist/core/query-classifier.js +0 -144
- package/dist/core/query-router.js +0 -210
- package/dist/core/rag-evaluator.js +0 -213
- package/dist/core/repo-profiler.js +0 -383
- package/dist/core/reranker.js +0 -218
- package/dist/core/retriever.js +0 -242
- package/dist/core/semantic-edge-detector.js +0 -147
- package/dist/core/service-mesh-inferrer.js +0 -241
- package/dist/core/structural-hasher.js +0 -316
- package/dist/core/tree-sitter-parser.js +0 -539
- package/dist/core/vector-store.js +0 -259
- package/dist/core/workspace-detector.js +0 -481
- /package/dist/{core → backends}/types.js +0 -0
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { describe, it, beforeEach, afterEach } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { tmpdir, homedir } from "node:os";
|
|
6
|
+
import {
|
|
7
|
+
resolveCodeSearchConfig,
|
|
8
|
+
parseCliFlags,
|
|
9
|
+
toKnowledgeConfig,
|
|
10
|
+
redactSecrets,
|
|
11
|
+
DEFAULTS
|
|
12
|
+
} from "../core/config.js";
|
|
13
|
+
const ENV_KEYS = [
|
|
14
|
+
"CODE_SEARCH_EMBEDDING_PROVIDER",
|
|
15
|
+
"CODE_SEARCH_EMBEDDING_MODEL",
|
|
16
|
+
"CODE_SEARCH_EMBEDDING_DIMENSIONS",
|
|
17
|
+
"CODE_SEARCH_EMBEDDING_API_KEY",
|
|
18
|
+
"CODE_SEARCH_RERANKER_PROVIDER",
|
|
19
|
+
"CODE_SEARCH_PORT",
|
|
20
|
+
"CODE_SEARCH_AUTH_MODE",
|
|
21
|
+
"CODE_SEARCH_AUTH_API_KEYS",
|
|
22
|
+
"CODE_SEARCH_DATA_DIR",
|
|
23
|
+
"CODE_SEARCH_REINDEX_INTERVAL"
|
|
24
|
+
];
|
|
25
|
+
let tmp = "";
|
|
26
|
+
const STASH = {};
|
|
27
|
+
beforeEach(() => {
|
|
28
|
+
for (const k of ENV_KEYS) {
|
|
29
|
+
STASH[k] = process.env[k];
|
|
30
|
+
delete process.env[k];
|
|
31
|
+
}
|
|
32
|
+
tmp = mkdtempSync(join(tmpdir(), "cs-cfg-"));
|
|
33
|
+
});
|
|
34
|
+
afterEach(() => {
|
|
35
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
36
|
+
for (const k of ENV_KEYS) {
|
|
37
|
+
if (STASH[k] === void 0) delete process.env[k];
|
|
38
|
+
else process.env[k] = STASH[k];
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
describe("resolveCodeSearchConfig precedence (P3)", () => {
|
|
42
|
+
it("returns DEFAULTS when no file / env / CLI is present", () => {
|
|
43
|
+
const cfg = resolveCodeSearchConfig({ configPath: join(tmp, "missing.yaml") });
|
|
44
|
+
assert.equal(cfg.embedding.provider, "auto");
|
|
45
|
+
assert.equal(cfg.server.port, 3100);
|
|
46
|
+
assert.equal(cfg.indexing.autoIndex, true);
|
|
47
|
+
});
|
|
48
|
+
it("env overrides defaults", () => {
|
|
49
|
+
process.env.CODE_SEARCH_EMBEDDING_PROVIDER = "codestral";
|
|
50
|
+
process.env.CODE_SEARCH_EMBEDDING_MODEL = "codestral-embed-2505";
|
|
51
|
+
process.env.CODE_SEARCH_EMBEDDING_API_KEY = "sk-abc";
|
|
52
|
+
process.env.CODE_SEARCH_PORT = "4242";
|
|
53
|
+
const cfg = resolveCodeSearchConfig({ configPath: join(tmp, "missing.yaml") });
|
|
54
|
+
assert.equal(cfg.embedding.provider, "codestral");
|
|
55
|
+
assert.equal(cfg.embedding.model, "codestral-embed-2505");
|
|
56
|
+
assert.equal(cfg.embedding.apiKey, "sk-abc");
|
|
57
|
+
assert.equal(cfg.server.port, 4242);
|
|
58
|
+
});
|
|
59
|
+
it("YAML config file overrides defaults", () => {
|
|
60
|
+
const configPath = join(tmp, "config.yaml");
|
|
61
|
+
writeFileSync(
|
|
62
|
+
configPath,
|
|
63
|
+
`server:
|
|
64
|
+
port: 5500
|
|
65
|
+
embedding:
|
|
66
|
+
provider: voyage
|
|
67
|
+
model: voyage-code-3
|
|
68
|
+
`,
|
|
69
|
+
"utf-8"
|
|
70
|
+
);
|
|
71
|
+
const cfg = resolveCodeSearchConfig({ configPath });
|
|
72
|
+
assert.equal(cfg.server.port, 5500);
|
|
73
|
+
assert.equal(cfg.embedding.provider, "voyage");
|
|
74
|
+
assert.equal(cfg.embedding.model, "voyage-code-3");
|
|
75
|
+
});
|
|
76
|
+
it("CLI flags override env vars", () => {
|
|
77
|
+
process.env.CODE_SEARCH_EMBEDDING_PROVIDER = "voyage";
|
|
78
|
+
const { patch } = parseCliFlags(["--embedding.provider", "codestral", "--server.port", "6000"]);
|
|
79
|
+
const cfg = resolveCodeSearchConfig({ configPath: join(tmp, "missing.yaml"), cli: patch });
|
|
80
|
+
assert.equal(cfg.embedding.provider, "codestral");
|
|
81
|
+
assert.equal(cfg.server.port, 6e3);
|
|
82
|
+
});
|
|
83
|
+
it("per-workspace .code-search.yaml overrides ~/.code-search/config.yaml", () => {
|
|
84
|
+
const globalPath = join(tmp, "global.yaml");
|
|
85
|
+
writeFileSync(globalPath, `embedding:
|
|
86
|
+
provider: voyage
|
|
87
|
+
`, "utf-8");
|
|
88
|
+
const workspace = join(tmp, "workspace");
|
|
89
|
+
mkdirSync(workspace, { recursive: true });
|
|
90
|
+
writeFileSync(join(workspace, ".code-search.yaml"), `embedding:
|
|
91
|
+
provider: openai
|
|
92
|
+
`, "utf-8");
|
|
93
|
+
const cfg = resolveCodeSearchConfig({ configPath: globalPath, workspaceDir: workspace });
|
|
94
|
+
assert.equal(cfg.embedding.provider, "openai");
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
describe("toKnowledgeConfig (P3 adapter)", () => {
|
|
98
|
+
it("maps unified config to KnowledgeConfig shape", () => {
|
|
99
|
+
const cfg = resolveCodeSearchConfig({ configPath: join(tmp, "missing.yaml") });
|
|
100
|
+
const k = toKnowledgeConfig(cfg);
|
|
101
|
+
assert.equal(k.embedding.provider, cfg.embedding.provider);
|
|
102
|
+
assert.equal(k.retrieval.maxChunks, cfg.retrieval.maxChunks);
|
|
103
|
+
assert.deepEqual(k.chunking, cfg.indexing.chunking);
|
|
104
|
+
const r = typeof k.retrieval.reranker === "string" ? { provider: k.retrieval.reranker } : k.retrieval.reranker;
|
|
105
|
+
assert.equal(r.provider, cfg.reranker.provider);
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
describe("redactSecrets (P3 --print-config)", () => {
|
|
109
|
+
it("redacts apiKey/jwtSecret/token fields, preserves shape", () => {
|
|
110
|
+
const input = {
|
|
111
|
+
embedding: { apiKey: "sk-super-secret", model: "x" },
|
|
112
|
+
auth: { apiKeys: ["key-1", "key-2"], jwtSecret: "jwt-super-secret" },
|
|
113
|
+
github: { token: "gh-token" },
|
|
114
|
+
server: { port: 3100 }
|
|
115
|
+
};
|
|
116
|
+
const out = redactSecrets(input);
|
|
117
|
+
assert.match(out.embedding.apiKey, /redacted/);
|
|
118
|
+
assert.equal(out.embedding.model, "x");
|
|
119
|
+
assert.match(out.auth.apiKeys[0], /redacted/);
|
|
120
|
+
assert.match(out.auth.jwtSecret, /redacted/);
|
|
121
|
+
assert.match(out.github.token, /redacted/);
|
|
122
|
+
assert.equal(out.server.port, 3100);
|
|
123
|
+
});
|
|
124
|
+
it("keeps DEFAULTS shape redaction-safe (no secrets exposed)", () => {
|
|
125
|
+
const redacted = redactSecrets(DEFAULTS);
|
|
126
|
+
const json = JSON.stringify(redacted);
|
|
127
|
+
assert.equal(/sk-[a-zA-Z0-9]+/.test(json), false);
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
describe("parseCliFlags", () => {
|
|
131
|
+
it("handles dotted paths and value coercion", () => {
|
|
132
|
+
const { patch } = parseCliFlags([
|
|
133
|
+
"--embedding.provider",
|
|
134
|
+
"voyage",
|
|
135
|
+
"--retrieval.max-chunks",
|
|
136
|
+
"20",
|
|
137
|
+
"--telemetry.metrics-enabled",
|
|
138
|
+
"true"
|
|
139
|
+
]);
|
|
140
|
+
assert.equal(patch.embedding.provider, "voyage");
|
|
141
|
+
assert.equal(patch.retrieval.maxChunks, 20);
|
|
142
|
+
assert.equal(patch.telemetry.metricsEnabled, true);
|
|
143
|
+
});
|
|
144
|
+
it("treats reserved subcommand flags as rest", () => {
|
|
145
|
+
const { rest } = parseCliFlags(["--local", "/some/path", "--embedding.provider", "codestral"]);
|
|
146
|
+
assert.ok(rest.includes("--local"));
|
|
147
|
+
});
|
|
148
|
+
it("--no-x.y sets boolean false", () => {
|
|
149
|
+
const { patch } = parseCliFlags(["--no-indexing.auto-index"]);
|
|
150
|
+
assert.equal(patch.indexing.autoIndex, false);
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
void homedir;
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { describe, it, before, after } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { RpcServer } from "../daemon/rpc-server.js";
|
|
7
|
+
import { DaemonBackend } from "../backends/daemon-client.js";
|
|
8
|
+
const tmp = mkdtempSync(join(tmpdir(), "cs-rpc-"));
|
|
9
|
+
const socketPath = join(tmp, "test.sock");
|
|
10
|
+
let server;
|
|
11
|
+
before(async () => {
|
|
12
|
+
server = new RpcServer({
|
|
13
|
+
socketPath,
|
|
14
|
+
handlers: {
|
|
15
|
+
"search.code": async (p) => ({ query: p.query, totalTokens: 0, chunks: [] }),
|
|
16
|
+
"index.status": async () => ({
|
|
17
|
+
totalChunks: 0,
|
|
18
|
+
repos: [],
|
|
19
|
+
embeddingProvider: "stub",
|
|
20
|
+
lastIndexedAt: null,
|
|
21
|
+
watching: true,
|
|
22
|
+
queueDepth: 0,
|
|
23
|
+
uptimeSec: 1
|
|
24
|
+
}),
|
|
25
|
+
"index.force": async () => ({
|
|
26
|
+
totalChunks: 0,
|
|
27
|
+
repos: [],
|
|
28
|
+
embeddingProvider: "stub",
|
|
29
|
+
lastIndexedAt: null,
|
|
30
|
+
watching: true,
|
|
31
|
+
queueDepth: 0,
|
|
32
|
+
uptimeSec: 1
|
|
33
|
+
}),
|
|
34
|
+
"index.invalidate": async () => ({ ok: true }),
|
|
35
|
+
"health": () => ({ ok: true, uptime: 1 })
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
await server.start();
|
|
39
|
+
});
|
|
40
|
+
after(async () => {
|
|
41
|
+
await server.stop();
|
|
42
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
43
|
+
});
|
|
44
|
+
describe("daemon RPC roundtrip", { skip: process.platform === "win32" }, () => {
|
|
45
|
+
it("ping returns true for a live daemon", async () => {
|
|
46
|
+
const client = new DaemonBackend({
|
|
47
|
+
project: "test",
|
|
48
|
+
workspaceDir: null,
|
|
49
|
+
knowledge: {},
|
|
50
|
+
preferDaemon: true,
|
|
51
|
+
socketPath
|
|
52
|
+
});
|
|
53
|
+
assert.equal(await client.ping(), true);
|
|
54
|
+
await client.close();
|
|
55
|
+
});
|
|
56
|
+
it("search returns the typed payload", async () => {
|
|
57
|
+
const client = new DaemonBackend({
|
|
58
|
+
project: "test",
|
|
59
|
+
workspaceDir: null,
|
|
60
|
+
knowledge: {},
|
|
61
|
+
preferDaemon: true,
|
|
62
|
+
socketPath
|
|
63
|
+
});
|
|
64
|
+
const r = await client.search("hello", { mode: "hybrid", maxResults: 5 });
|
|
65
|
+
assert.equal(r.query, "hello");
|
|
66
|
+
assert.equal(Array.isArray(r.chunks), true);
|
|
67
|
+
await client.close();
|
|
68
|
+
});
|
|
69
|
+
it("status / invalidate / forceIndex roundtrip", async () => {
|
|
70
|
+
const client = new DaemonBackend({
|
|
71
|
+
project: "test",
|
|
72
|
+
workspaceDir: null,
|
|
73
|
+
knowledge: {},
|
|
74
|
+
preferDaemon: true,
|
|
75
|
+
socketPath
|
|
76
|
+
});
|
|
77
|
+
const s = await client.status();
|
|
78
|
+
assert.equal(s.embeddingProvider, "stub");
|
|
79
|
+
assert.equal(s.watching, true);
|
|
80
|
+
await client.invalidate(["/some/path.ts"]);
|
|
81
|
+
const after2 = await client.forceIndex({ force: true });
|
|
82
|
+
assert.equal(after2.embeddingProvider, "stub");
|
|
83
|
+
await client.close();
|
|
84
|
+
});
|
|
85
|
+
it("unknown method returns RPC error", async () => {
|
|
86
|
+
const client = new DaemonBackend({
|
|
87
|
+
project: "test",
|
|
88
|
+
workspaceDir: null,
|
|
89
|
+
knowledge: {},
|
|
90
|
+
preferDaemon: true,
|
|
91
|
+
socketPath
|
|
92
|
+
});
|
|
93
|
+
await assert.rejects(
|
|
94
|
+
// @ts-expect-error — calling a private method on purpose
|
|
95
|
+
() => client.rpc("notARealMethod", {}, 500),
|
|
96
|
+
/Method not found/
|
|
97
|
+
);
|
|
98
|
+
await client.close();
|
|
99
|
+
});
|
|
100
|
+
});
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { describe, it } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { Counter, Gauge, Histogram, registry, metrics } from "../observability/metrics.js";
|
|
4
|
+
import { Logger } from "../observability/logger.js";
|
|
5
|
+
describe("Prometheus collectors (P7)", () => {
|
|
6
|
+
it("Counter renders with labels", () => {
|
|
7
|
+
const c = new Counter("test_counter", "test counter");
|
|
8
|
+
c.inc({ kind: "a" });
|
|
9
|
+
c.inc({ kind: "a" });
|
|
10
|
+
c.inc({ kind: "b" });
|
|
11
|
+
const out = c.render();
|
|
12
|
+
assert.match(out, /# HELP test_counter test counter/);
|
|
13
|
+
assert.match(out, /# TYPE test_counter counter/);
|
|
14
|
+
assert.match(out, /test_counter\{kind="a"\} 2/);
|
|
15
|
+
assert.match(out, /test_counter\{kind="b"\} 1/);
|
|
16
|
+
});
|
|
17
|
+
it("Gauge updates last-write per label set", () => {
|
|
18
|
+
const g = new Gauge("test_gauge", "test gauge");
|
|
19
|
+
g.set(5, { repo: "a" });
|
|
20
|
+
g.set(7, { repo: "a" });
|
|
21
|
+
assert.match(g.render(), /test_gauge\{repo="a"\} 7/);
|
|
22
|
+
});
|
|
23
|
+
it("Histogram tracks buckets, sum, count", () => {
|
|
24
|
+
const h = new Histogram("test_hist", "test", [0.1, 0.5, 1]);
|
|
25
|
+
h.observe(0.05);
|
|
26
|
+
h.observe(0.3);
|
|
27
|
+
h.observe(0.9);
|
|
28
|
+
const out = h.render();
|
|
29
|
+
assert.match(out, /test_hist_count\{?\}? 3/);
|
|
30
|
+
assert.match(out, /test_hist_sum\{?\}? 1.25/);
|
|
31
|
+
assert.match(out, /test_hist_bucket\{le="0.1"\} 1/);
|
|
32
|
+
assert.match(out, /test_hist_bucket\{le="\+Inf"\} 3/);
|
|
33
|
+
});
|
|
34
|
+
it("registry renders the prelude for all pre-declared metrics", () => {
|
|
35
|
+
metrics.queriesTotal.inc({ mode: "hybrid" });
|
|
36
|
+
const out = registry.render();
|
|
37
|
+
assert.match(out, /code_search_queries_total/);
|
|
38
|
+
assert.match(out, /code_search_query_duration_seconds/);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
describe("Logger (P7)", () => {
|
|
42
|
+
it("emits structured JSON when enabled", () => {
|
|
43
|
+
const lines = [];
|
|
44
|
+
const log = new Logger({ structured: true, write: (l) => lines.push(l) });
|
|
45
|
+
log.info("hello", { query: "auth" });
|
|
46
|
+
assert.equal(lines.length, 1);
|
|
47
|
+
const parsed = JSON.parse(lines[0]);
|
|
48
|
+
assert.equal(parsed.msg, "hello");
|
|
49
|
+
assert.equal(parsed.query, "auth");
|
|
50
|
+
assert.equal(parsed.level, "info");
|
|
51
|
+
assert.ok(parsed.ts);
|
|
52
|
+
});
|
|
53
|
+
it("respects level threshold", () => {
|
|
54
|
+
const lines = [];
|
|
55
|
+
const log = new Logger({ structured: true, level: "warn", write: (l) => lines.push(l) });
|
|
56
|
+
log.info("skipped");
|
|
57
|
+
log.warn("kept");
|
|
58
|
+
assert.equal(lines.length, 1);
|
|
59
|
+
assert.match(lines[0], /"kept"/);
|
|
60
|
+
});
|
|
61
|
+
it("falls back to text mode when structured=false", () => {
|
|
62
|
+
const lines = [];
|
|
63
|
+
const log = new Logger({ structured: false, write: (l) => lines.push(l) });
|
|
64
|
+
log.error("boom");
|
|
65
|
+
assert.equal(lines.length, 1);
|
|
66
|
+
assert.match(lines[0], /ERROR/);
|
|
67
|
+
assert.match(lines[0], /boom/);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { describe, it, beforeEach, afterEach } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import {
|
|
7
|
+
ProjectRegistry,
|
|
8
|
+
projectAccessAllowed,
|
|
9
|
+
checkProjectQuota
|
|
10
|
+
} from "../projects/registry.js";
|
|
11
|
+
let dir = "";
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
dir = mkdtempSync(join(tmpdir(), "cs-projects-"));
|
|
14
|
+
});
|
|
15
|
+
afterEach(() => {
|
|
16
|
+
rmSync(dir, { recursive: true, force: true });
|
|
17
|
+
});
|
|
18
|
+
function writeProject(name, body) {
|
|
19
|
+
const d = join(dir, "projects", name);
|
|
20
|
+
mkdirSync(d, { recursive: true });
|
|
21
|
+
writeFileSync(join(d, "project.yaml"), body, "utf-8");
|
|
22
|
+
}
|
|
23
|
+
describe("ProjectRegistry", () => {
|
|
24
|
+
it("reload() discovers all projects with project.yaml", () => {
|
|
25
|
+
writeProject("alpha", `workspace: /tmp/alpha
|
|
26
|
+
repos: [a, b]
|
|
27
|
+
scopes: [team-x]
|
|
28
|
+
`);
|
|
29
|
+
writeProject("beta", `workspace: /tmp/beta
|
|
30
|
+
`);
|
|
31
|
+
mkdirSync(join(dir, "projects", "ignored"), { recursive: true });
|
|
32
|
+
const reg = new ProjectRegistry(dir);
|
|
33
|
+
const list = reg.reload();
|
|
34
|
+
assert.equal(list.length, 2);
|
|
35
|
+
const names = new Set(list.map((p) => p.name));
|
|
36
|
+
assert.ok(names.has("alpha"));
|
|
37
|
+
assert.ok(names.has("beta"));
|
|
38
|
+
const alpha = reg.get("alpha");
|
|
39
|
+
assert.equal(alpha.workspaceDir, "/tmp/alpha");
|
|
40
|
+
assert.deepEqual(alpha.repos, ["a", "b"]);
|
|
41
|
+
assert.deepEqual(alpha.scopes, ["team-x"]);
|
|
42
|
+
});
|
|
43
|
+
it("reload() applies quotas defaults when omitted", () => {
|
|
44
|
+
writeProject("plain", `workspace: /tmp/plain
|
|
45
|
+
`);
|
|
46
|
+
const reg = new ProjectRegistry(dir);
|
|
47
|
+
reg.reload();
|
|
48
|
+
const p = reg.get("plain");
|
|
49
|
+
assert.equal(p.quotas.maxQueriesPerMinute, 100);
|
|
50
|
+
});
|
|
51
|
+
it("reload() reads custom quotas", () => {
|
|
52
|
+
writeProject("tenant", `workspace: /tmp/tenant
|
|
53
|
+
quotas:
|
|
54
|
+
max_queries_per_minute: 5
|
|
55
|
+
max_embedding_cost_usd: 1
|
|
56
|
+
`);
|
|
57
|
+
const reg = new ProjectRegistry(dir);
|
|
58
|
+
reg.reload();
|
|
59
|
+
const p = reg.get("tenant");
|
|
60
|
+
assert.equal(p.quotas.maxQueriesPerMinute, 5);
|
|
61
|
+
assert.equal(p.quotas.maxEmbeddingCostUsd, 1);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
describe("projectAccessAllowed", () => {
|
|
65
|
+
it("public projects allow any identity", () => {
|
|
66
|
+
const p = {
|
|
67
|
+
name: "public",
|
|
68
|
+
workspaceDir: "/p",
|
|
69
|
+
repos: [],
|
|
70
|
+
scopes: [],
|
|
71
|
+
config: {},
|
|
72
|
+
quotas: { maxQueriesPerMinute: 1, maxEmbeddingCostUsd: 0, maxLlmCostUsd: 0 }
|
|
73
|
+
};
|
|
74
|
+
assert.equal(projectAccessAllowed(p, []), true);
|
|
75
|
+
assert.equal(projectAccessAllowed(p, ["some-scope"]), true);
|
|
76
|
+
});
|
|
77
|
+
it("scoped projects deny unmatched identities", () => {
|
|
78
|
+
const p = {
|
|
79
|
+
name: "secret",
|
|
80
|
+
workspaceDir: "/p",
|
|
81
|
+
repos: [],
|
|
82
|
+
scopes: ["team-x"],
|
|
83
|
+
config: {},
|
|
84
|
+
quotas: { maxQueriesPerMinute: 1, maxEmbeddingCostUsd: 0, maxLlmCostUsd: 0 }
|
|
85
|
+
};
|
|
86
|
+
assert.equal(projectAccessAllowed(p, ["team-y"]), false);
|
|
87
|
+
assert.equal(projectAccessAllowed(p, ["team-x"]), true);
|
|
88
|
+
});
|
|
89
|
+
it("admin scope * grants access regardless of project scopes", () => {
|
|
90
|
+
const p = {
|
|
91
|
+
name: "secret",
|
|
92
|
+
workspaceDir: "/p",
|
|
93
|
+
repos: [],
|
|
94
|
+
scopes: ["team-x"],
|
|
95
|
+
config: {},
|
|
96
|
+
quotas: { maxQueriesPerMinute: 1, maxEmbeddingCostUsd: 0, maxLlmCostUsd: 0 }
|
|
97
|
+
};
|
|
98
|
+
assert.equal(projectAccessAllowed(p, ["*"]), true);
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
describe("checkProjectQuota", () => {
|
|
102
|
+
it("blocks once burst exceeds the per-minute cap", () => {
|
|
103
|
+
const p = {
|
|
104
|
+
name: "q",
|
|
105
|
+
workspaceDir: "/p",
|
|
106
|
+
repos: [],
|
|
107
|
+
scopes: [],
|
|
108
|
+
config: {},
|
|
109
|
+
quotas: { maxQueriesPerMinute: 2, maxEmbeddingCostUsd: 0, maxLlmCostUsd: 0 }
|
|
110
|
+
};
|
|
111
|
+
assert.equal(checkProjectQuota(p, "user-1").ok, true);
|
|
112
|
+
assert.equal(checkProjectQuota(p, "user-1").ok, true);
|
|
113
|
+
const denied = checkProjectQuota(p, "user-1");
|
|
114
|
+
assert.equal(denied.ok, false);
|
|
115
|
+
if (denied.ok === false) assert.match(denied.reason, /quota exceeded/);
|
|
116
|
+
});
|
|
117
|
+
it("separate identities have independent buckets", () => {
|
|
118
|
+
const p = {
|
|
119
|
+
name: "q2",
|
|
120
|
+
workspaceDir: "/p",
|
|
121
|
+
repos: [],
|
|
122
|
+
scopes: [],
|
|
123
|
+
config: {},
|
|
124
|
+
quotas: { maxQueriesPerMinute: 1, maxEmbeddingCostUsd: 0, maxLlmCostUsd: 0 }
|
|
125
|
+
};
|
|
126
|
+
assert.equal(checkProjectQuota(p, "a").ok, true);
|
|
127
|
+
assert.equal(checkProjectQuota(p, "b").ok, true);
|
|
128
|
+
assert.equal(checkProjectQuota(p, "a").ok, false);
|
|
129
|
+
});
|
|
130
|
+
it("maxQueriesPerMinute=0 disables enforcement", () => {
|
|
131
|
+
const p = {
|
|
132
|
+
name: "q3",
|
|
133
|
+
workspaceDir: "/p",
|
|
134
|
+
repos: [],
|
|
135
|
+
scopes: [],
|
|
136
|
+
config: {},
|
|
137
|
+
quotas: { maxQueriesPerMinute: 0, maxEmbeddingCostUsd: 0, maxLlmCostUsd: 0 }
|
|
138
|
+
};
|
|
139
|
+
for (let i = 0; i < 1e3; i++) assert.equal(checkProjectQuota(p, "user").ok, true);
|
|
140
|
+
});
|
|
141
|
+
});
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { createConnection } from "node:net";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
let nextRpcId = 1;
|
|
4
|
+
class DaemonBackend {
|
|
5
|
+
kind = "daemon";
|
|
6
|
+
project;
|
|
7
|
+
socketPath;
|
|
8
|
+
startedAt = Date.now();
|
|
9
|
+
constructor(cfg) {
|
|
10
|
+
this.project = cfg.project;
|
|
11
|
+
this.socketPath = cfg.socketPath;
|
|
12
|
+
}
|
|
13
|
+
async search(query, opts) {
|
|
14
|
+
return this.rpc("search.code", { query, ...opts });
|
|
15
|
+
}
|
|
16
|
+
async status() {
|
|
17
|
+
return this.rpc("index.status", {});
|
|
18
|
+
}
|
|
19
|
+
async forceIndex(opts) {
|
|
20
|
+
return this.rpc("index.force", { force: opts?.force ?? false });
|
|
21
|
+
}
|
|
22
|
+
async invalidate(paths) {
|
|
23
|
+
await this.rpc("index.invalidate", { paths });
|
|
24
|
+
}
|
|
25
|
+
async close() {
|
|
26
|
+
}
|
|
27
|
+
/** Ping the daemon to check liveness. Returns true on success within ~100ms. */
|
|
28
|
+
async ping() {
|
|
29
|
+
try {
|
|
30
|
+
const r = await this.rpc("health", {}, 250);
|
|
31
|
+
return !!r.ok;
|
|
32
|
+
} catch {
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
rpc(method, params, timeoutMs = 3e4) {
|
|
37
|
+
return new Promise((resolveP, rejectP) => {
|
|
38
|
+
if (!existsSync(this.socketPath)) {
|
|
39
|
+
rejectP(new Error(`daemon socket not found at ${this.socketPath}`));
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
const id = nextRpcId++;
|
|
43
|
+
let buf = "";
|
|
44
|
+
let settled = false;
|
|
45
|
+
const sock = createConnection(this.socketPath);
|
|
46
|
+
const finish = (err, value) => {
|
|
47
|
+
if (settled) return;
|
|
48
|
+
settled = true;
|
|
49
|
+
sock.destroy();
|
|
50
|
+
clearTimeout(timer);
|
|
51
|
+
if (err) rejectP(err);
|
|
52
|
+
else resolveP(value);
|
|
53
|
+
};
|
|
54
|
+
const timer = setTimeout(() => finish(new Error(`daemon RPC timeout: ${method}`)), timeoutMs);
|
|
55
|
+
sock.setNoDelay(true);
|
|
56
|
+
sock.on("error", (err) => finish(err));
|
|
57
|
+
sock.on("data", (chunk) => {
|
|
58
|
+
buf += chunk.toString("utf-8");
|
|
59
|
+
let idx = buf.indexOf("\n");
|
|
60
|
+
while (idx >= 0) {
|
|
61
|
+
const line = buf.slice(0, idx);
|
|
62
|
+
buf = buf.slice(idx + 1);
|
|
63
|
+
try {
|
|
64
|
+
const msg = JSON.parse(line);
|
|
65
|
+
if (msg.id !== id) {
|
|
66
|
+
idx = buf.indexOf("\n");
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
if (msg.error) finish(new Error(`daemon RPC error ${msg.error.code}: ${msg.error.message}`));
|
|
70
|
+
else finish(null, msg.result);
|
|
71
|
+
return;
|
|
72
|
+
} catch (err) {
|
|
73
|
+
finish(err instanceof Error ? err : new Error(String(err)));
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
sock.on("connect", () => {
|
|
79
|
+
const req = JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n";
|
|
80
|
+
sock.write(req);
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
export {
|
|
86
|
+
DaemonBackend
|
|
87
|
+
};
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import {
|
|
4
|
+
getRetriever,
|
|
5
|
+
indexFromPath,
|
|
6
|
+
getKnowledgeBasePath,
|
|
7
|
+
KnowledgeIndexer,
|
|
8
|
+
VectorStore
|
|
9
|
+
} from "@esankhan3/anvil-knowledge-core";
|
|
10
|
+
import { metrics } from "../observability/metrics.js";
|
|
11
|
+
class InProcessBackend {
|
|
12
|
+
kind = "in-process";
|
|
13
|
+
project;
|
|
14
|
+
workspaceDir;
|
|
15
|
+
knowledge;
|
|
16
|
+
startedAt = Date.now();
|
|
17
|
+
constructor(cfg) {
|
|
18
|
+
this.project = cfg.project;
|
|
19
|
+
this.workspaceDir = cfg.workspaceDir;
|
|
20
|
+
this.knowledge = cfg.knowledge;
|
|
21
|
+
}
|
|
22
|
+
async search(query, opts) {
|
|
23
|
+
const started = Date.now();
|
|
24
|
+
let outcome = "ok";
|
|
25
|
+
try {
|
|
26
|
+
const retriever = await getRetriever(this.project, this.knowledge);
|
|
27
|
+
const modeMap = {
|
|
28
|
+
hybrid: "vector+bm25+graph",
|
|
29
|
+
vector: "vector",
|
|
30
|
+
bm25: "bm25"
|
|
31
|
+
};
|
|
32
|
+
const result = await retriever.retrieve(query, {
|
|
33
|
+
maxChunks: opts.maxResults ?? this.knowledge.retrieval.maxChunks,
|
|
34
|
+
repoFilter: opts.repos,
|
|
35
|
+
mode: modeMap[opts.mode]
|
|
36
|
+
});
|
|
37
|
+
return {
|
|
38
|
+
query: result.query,
|
|
39
|
+
totalTokens: result.totalTokens,
|
|
40
|
+
chunks: result.chunks.map((sc) => ({
|
|
41
|
+
filePath: sc.chunk.filePath,
|
|
42
|
+
startLine: sc.chunk.startLine,
|
|
43
|
+
endLine: sc.chunk.endLine,
|
|
44
|
+
language: sc.chunk.language,
|
|
45
|
+
repoName: sc.chunk.repoName,
|
|
46
|
+
score: sc.score,
|
|
47
|
+
source: sc.source,
|
|
48
|
+
content: sc.chunk.content
|
|
49
|
+
}))
|
|
50
|
+
};
|
|
51
|
+
} catch (err) {
|
|
52
|
+
outcome = "error";
|
|
53
|
+
metrics.errors.inc({ kind: "search" });
|
|
54
|
+
throw err;
|
|
55
|
+
} finally {
|
|
56
|
+
const elapsed = (Date.now() - started) / 1e3;
|
|
57
|
+
metrics.queriesTotal.inc({ mode: opts.mode, outcome });
|
|
58
|
+
metrics.queryDuration.observe(elapsed, { mode: opts.mode });
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
async status() {
|
|
62
|
+
const indexer = new KnowledgeIndexer();
|
|
63
|
+
const stats = await indexer.getStats(this.project);
|
|
64
|
+
return {
|
|
65
|
+
totalChunks: stats.totalChunks,
|
|
66
|
+
repos: stats.repos,
|
|
67
|
+
embeddingProvider: stats.embeddingProvider,
|
|
68
|
+
lastIndexedAt: stats.lastIndexed || null,
|
|
69
|
+
watching: false,
|
|
70
|
+
queueDepth: 0,
|
|
71
|
+
uptimeSec: Math.floor((Date.now() - this.startedAt) / 1e3)
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
async forceIndex(opts) {
|
|
75
|
+
if (!this.workspaceDir) {
|
|
76
|
+
throw new Error("InProcessBackend.forceIndex requires a workspaceDir");
|
|
77
|
+
}
|
|
78
|
+
await indexFromPath(this.project, this.workspaceDir, {
|
|
79
|
+
force: opts?.force,
|
|
80
|
+
config: this.knowledge
|
|
81
|
+
});
|
|
82
|
+
return this.status();
|
|
83
|
+
}
|
|
84
|
+
async invalidate(paths) {
|
|
85
|
+
if (paths.length === 0) return;
|
|
86
|
+
const basePath = getKnowledgeBasePath(this.project);
|
|
87
|
+
const dbPath = join(basePath, "lancedb");
|
|
88
|
+
if (!existsSync(dbPath)) return;
|
|
89
|
+
const store = new VectorStore(dbPath);
|
|
90
|
+
await store.init();
|
|
91
|
+
const stats = await store.getStats();
|
|
92
|
+
if (!stats || stats.rowCount === 0) return;
|
|
93
|
+
const indexer = new KnowledgeIndexer();
|
|
94
|
+
const summary = await indexer.getStats(this.project);
|
|
95
|
+
for (const r of summary.repos) {
|
|
96
|
+
await store.deleteFileChunks(this.project, r.name, paths);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
async close() {
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
export {
|
|
103
|
+
InProcessBackend
|
|
104
|
+
};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { InProcessBackend } from "./in-process.js";
|
|
4
|
+
import { DaemonBackend } from "./daemon-client.js";
|
|
5
|
+
export * from "./types.js";
|
|
6
|
+
import { InProcessBackend as InProcessBackend2 } from "./in-process.js";
|
|
7
|
+
import { DaemonBackend as DaemonBackend2 } from "./daemon-client.js";
|
|
8
|
+
function daemonSocketPath(dataDir, project) {
|
|
9
|
+
if (process.platform === "win32") {
|
|
10
|
+
return `\\\\.\\pipe\\code-search-${project}`;
|
|
11
|
+
}
|
|
12
|
+
return join(dataDir, "daemon", `${project}.sock`);
|
|
13
|
+
}
|
|
14
|
+
async function pickBackend(cfg) {
|
|
15
|
+
const socketPath = cfg.socketPath ?? daemonSocketPath(cfg.knowledge ? "" : "", cfg.project);
|
|
16
|
+
if (cfg.preferDaemon && socketPath && existsSync(socketPath)) {
|
|
17
|
+
const daemon = new DaemonBackend({ ...cfg, socketPath });
|
|
18
|
+
if (await daemon.ping()) return daemon;
|
|
19
|
+
process.stderr.write(`[code-search-mcp] daemon socket exists at ${socketPath} but ping failed; using in-process backend
|
|
20
|
+
`);
|
|
21
|
+
}
|
|
22
|
+
return new InProcessBackend(cfg);
|
|
23
|
+
}
|
|
24
|
+
export {
|
|
25
|
+
DaemonBackend2 as DaemonBackend,
|
|
26
|
+
InProcessBackend2 as InProcessBackend,
|
|
27
|
+
daemonSocketPath,
|
|
28
|
+
pickBackend
|
|
29
|
+
};
|