@esankhan3/code-search-mcp 1.0.6 → 1.1.1
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__/graph-traversal.test.js +262 -0
- 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 +448 -165
- package/dist/tools/index-tools.js +6 -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 +2 -2
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { describe, it, before, after } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { createConnection } from "node:net";
|
|
4
|
+
import { existsSync, rmSync, unlinkSync } from "node:fs";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { RpcServer } from "../daemon/rpc-server.js";
|
|
7
|
+
import { tmp } from "./_harness.js";
|
|
8
|
+
let dir;
|
|
9
|
+
let server;
|
|
10
|
+
let socketPath;
|
|
11
|
+
let healthCalls = 0;
|
|
12
|
+
const handlers = {
|
|
13
|
+
"search.code": async (p) => ({ query: p.query, totalTokens: 0, chunks: [] }),
|
|
14
|
+
"index.status": async () => ({}),
|
|
15
|
+
"index.force": async () => ({}),
|
|
16
|
+
"index.invalidate": async () => ({ ok: true }),
|
|
17
|
+
"health": () => {
|
|
18
|
+
healthCalls++;
|
|
19
|
+
return { ok: true, uptime: 1 };
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
before(async () => {
|
|
23
|
+
dir = tmp("cs-rpcp-");
|
|
24
|
+
socketPath = join(dir, "s.sock");
|
|
25
|
+
server = new RpcServer({ socketPath, handlers });
|
|
26
|
+
await server.start();
|
|
27
|
+
});
|
|
28
|
+
after(async () => {
|
|
29
|
+
await server.stop();
|
|
30
|
+
rmSync(dir, { recursive: true, force: true });
|
|
31
|
+
});
|
|
32
|
+
function exchange(lines, expect) {
|
|
33
|
+
return new Promise((resolve, reject) => {
|
|
34
|
+
const sock = createConnection(socketPath);
|
|
35
|
+
let buf = "";
|
|
36
|
+
const replies = [];
|
|
37
|
+
const done = () => {
|
|
38
|
+
sock.destroy();
|
|
39
|
+
resolve(replies);
|
|
40
|
+
};
|
|
41
|
+
let idle = setTimeout(done, 500);
|
|
42
|
+
sock.on("connect", () => sock.write(lines.map((l) => l + "\n").join("")));
|
|
43
|
+
sock.on("data", (c) => {
|
|
44
|
+
buf += c.toString();
|
|
45
|
+
let i;
|
|
46
|
+
while ((i = buf.indexOf("\n")) >= 0) {
|
|
47
|
+
replies.push(JSON.parse(buf.slice(0, i)));
|
|
48
|
+
buf = buf.slice(i + 1);
|
|
49
|
+
}
|
|
50
|
+
clearTimeout(idle);
|
|
51
|
+
if (replies.length >= expect) setTimeout(done, 100);
|
|
52
|
+
else idle = setTimeout(done, 500);
|
|
53
|
+
});
|
|
54
|
+
sock.on("error", reject);
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
describe("daemon RPC protocol", { skip: process.platform === "win32" }, () => {
|
|
58
|
+
it("does not dispatch prototype method names", async () => {
|
|
59
|
+
for (const method of ["constructor", "toString", "__proto__", "hasOwnProperty"]) {
|
|
60
|
+
const [r] = await exchange([JSON.stringify({ jsonrpc: "2.0", id: 1, method, params: {} })], 1);
|
|
61
|
+
assert.equal(r?.error?.code, -32601, `${method}: ${JSON.stringify(r)}`);
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
it("runs notifications without answering them", async () => {
|
|
65
|
+
const before2 = healthCalls;
|
|
66
|
+
const replies = await exchange([
|
|
67
|
+
JSON.stringify({ jsonrpc: "2.0", method: "health", params: {} }),
|
|
68
|
+
JSON.stringify({ jsonrpc: "2.0", method: "nope", params: {} }),
|
|
69
|
+
JSON.stringify({ jsonrpc: "2.0", id: 7, method: "health", params: {} })
|
|
70
|
+
], 2);
|
|
71
|
+
assert.deepEqual(replies.map((r) => r.id), [7]);
|
|
72
|
+
assert.equal(healthCalls - before2, 2);
|
|
73
|
+
});
|
|
74
|
+
it("answers a batch with a single invalid-request error", async () => {
|
|
75
|
+
const replies = await exchange([JSON.stringify([
|
|
76
|
+
{ jsonrpc: "2.0", id: 1, method: "health", params: {} },
|
|
77
|
+
{ jsonrpc: "2.0", id: 2, method: "health", params: {} }
|
|
78
|
+
])], 2);
|
|
79
|
+
assert.equal(replies.length, 1);
|
|
80
|
+
assert.equal(replies[0].id, null);
|
|
81
|
+
assert.equal(replies[0].error.code, -32600);
|
|
82
|
+
});
|
|
83
|
+
it("stop() leaves a socket file another server created", async () => {
|
|
84
|
+
const path = join(dir, "own.sock");
|
|
85
|
+
const a = new RpcServer({ socketPath: path, handlers });
|
|
86
|
+
await a.start();
|
|
87
|
+
unlinkSync(path);
|
|
88
|
+
const b = new RpcServer({ socketPath: path, handlers });
|
|
89
|
+
await b.start();
|
|
90
|
+
await a.stop();
|
|
91
|
+
assert.equal(existsSync(path), true);
|
|
92
|
+
await b.stop();
|
|
93
|
+
assert.equal(existsSync(path), false);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { describe, it, before, after } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { DaemonBackend } from "../backends/daemon-client.js";
|
|
6
|
+
import {
|
|
7
|
+
BIN,
|
|
8
|
+
tmp,
|
|
9
|
+
makeWorkspace,
|
|
10
|
+
baseEnv,
|
|
11
|
+
spawnProc,
|
|
12
|
+
run,
|
|
13
|
+
waitFor,
|
|
14
|
+
exitWithin,
|
|
15
|
+
startFakeEmbedder
|
|
16
|
+
} from "./_harness.js";
|
|
17
|
+
let root;
|
|
18
|
+
let ws;
|
|
19
|
+
let dataDir;
|
|
20
|
+
let embedder;
|
|
21
|
+
let env;
|
|
22
|
+
let wrapper;
|
|
23
|
+
const sock = () => join(dataDir, "daemon", "ws.sock");
|
|
24
|
+
const pidFile = () => join(dataDir, "daemon", "ws.pid");
|
|
25
|
+
const client = () => new DaemonBackend({ project: "ws", workspaceDir: null, knowledge: {}, preferDaemon: true, socketPath: sock() });
|
|
26
|
+
before(async () => {
|
|
27
|
+
root = tmp("cs-d-");
|
|
28
|
+
ws = makeWorkspace(root);
|
|
29
|
+
dataDir = join(root, "d");
|
|
30
|
+
embedder = await startFakeEmbedder(47394);
|
|
31
|
+
env = baseEnv({ home: join(root, "h"), dataDir, embedderUrl: embedder.url });
|
|
32
|
+
});
|
|
33
|
+
after(async () => {
|
|
34
|
+
wrapper?.kill();
|
|
35
|
+
try {
|
|
36
|
+
process.kill(Number(readFileSync(pidFile(), "utf-8")), "SIGKILL");
|
|
37
|
+
} catch {
|
|
38
|
+
}
|
|
39
|
+
await embedder.close();
|
|
40
|
+
rmSync(root, { recursive: true, force: true });
|
|
41
|
+
});
|
|
42
|
+
describe("daemon", { skip: process.platform === "win32" }, () => {
|
|
43
|
+
it("starts via the wrapper with --workspace . and names the project after the directory", async () => {
|
|
44
|
+
wrapper = spawnProc(BIN.cli, ["daemon", "--workspace", "."], env, ws);
|
|
45
|
+
await wrapper.waitForStderr(/initial index ready/, 6e4);
|
|
46
|
+
assert.equal(existsSync(sock()), true, "socket at <dataDir>/daemon/ws.sock");
|
|
47
|
+
const pid = Number(readFileSync(pidFile(), "utf-8"));
|
|
48
|
+
assert.ok(pid > 0 && pid !== wrapper.child.pid, "pid file holds the daemon (child) pid");
|
|
49
|
+
const status = JSON.parse((await run(BIN.cli, ["status", "--project", "ws"], env)).stdout);
|
|
50
|
+
assert.equal(status.daemonAlive, true);
|
|
51
|
+
});
|
|
52
|
+
it("refuses a second daemon for the same project", async () => {
|
|
53
|
+
const r = await run(BIN.daemon, ["--workspace", ws], env);
|
|
54
|
+
assert.equal(r.code, 1);
|
|
55
|
+
assert.match(r.stderr, /already running/);
|
|
56
|
+
assert.equal(await client().ping(), true, "the first daemon keeps its socket");
|
|
57
|
+
});
|
|
58
|
+
it("serves fresh results right after its own reindex, one run per watcher batch", async () => {
|
|
59
|
+
const before2 = await client().search("zebraQuasar", { mode: "bm25", maxResults: 20 });
|
|
60
|
+
assert.ok(!before2.chunks.some((c) => c.content.includes("zebraQuasar")));
|
|
61
|
+
const genFile = join(dataDir, "ws", "index_generation");
|
|
62
|
+
const gens = /* @__PURE__ */ new Set();
|
|
63
|
+
const poll = setInterval(() => {
|
|
64
|
+
try {
|
|
65
|
+
gens.add(readFileSync(genFile, "utf-8"));
|
|
66
|
+
} catch {
|
|
67
|
+
}
|
|
68
|
+
}, 5);
|
|
69
|
+
const startGen = readFileSync(genFile, "utf-8");
|
|
70
|
+
try {
|
|
71
|
+
const mark = wrapper.stderr().length;
|
|
72
|
+
appendFileSync(join(ws, "orders", "src", "orders.ts"), '\nexport function zebraQuasar() {\n return "zebra quasar";\n}\n');
|
|
73
|
+
await waitFor(() => /reindex finished \(watcher\)/.test(wrapper.stderr().slice(mark)), 6e4, `watcher reindex
|
|
74
|
+
${wrapper.stderr()}`);
|
|
75
|
+
const after2 = await client().search("zebraQuasar", { mode: "bm25", maxResults: 20 });
|
|
76
|
+
assert.ok(after2.chunks.some((c) => c.content.includes("zebraQuasar")), "the daemon must not serve its pre-reindex retriever");
|
|
77
|
+
await new Promise((r) => setTimeout(r, 2e3));
|
|
78
|
+
gens.delete(startGen);
|
|
79
|
+
assert.equal(gens.size, 2, `one index run (graph + embed phases) per batch, saw ${gens.size} generation bumps`);
|
|
80
|
+
assert.equal((wrapper.stderr().slice(mark).match(/reindex started \(watcher\)/g) ?? []).length, 1);
|
|
81
|
+
} finally {
|
|
82
|
+
clearInterval(poll);
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
it("drops invalidated rows from results immediately", async () => {
|
|
86
|
+
const hasBilling = async () => (await client().search("chargeOrder", { mode: "vector", maxResults: 50 })).chunks.some((c) => c.filePath === "src/billing.ts");
|
|
87
|
+
assert.equal(await hasBilling(), true);
|
|
88
|
+
await client().invalidate(["src/billing.ts"]);
|
|
89
|
+
assert.equal(await hasBilling(), false);
|
|
90
|
+
});
|
|
91
|
+
it("honours --no-daemon", async () => {
|
|
92
|
+
const args = ["query", "--project", "ws", "--mode", "vector", "--format", "json", "--embedding.base-url", "http://127.0.0.1:9", "zebra"];
|
|
93
|
+
assert.equal((await run(BIN.cli, args, env)).code, 0, "routed to the daemon");
|
|
94
|
+
assert.notEqual((await run(BIN.cli, [...args, "--no-daemon"], env)).code, 0, "searched in-process");
|
|
95
|
+
});
|
|
96
|
+
it("the wrapper forwards SIGTERM; the daemon removes its own socket + pid file", async () => {
|
|
97
|
+
wrapper.child.kill("SIGTERM");
|
|
98
|
+
const exit = await exitWithin(wrapper, 1e4);
|
|
99
|
+
assert.notEqual(exit, "timeout", "wrapper must exit on SIGTERM");
|
|
100
|
+
assert.equal(existsSync(sock()), false);
|
|
101
|
+
assert.equal(existsSync(pidFile()), false);
|
|
102
|
+
});
|
|
103
|
+
it("status sees a SIGKILLed daemon as dead and a new daemon replaces its stale socket", async () => {
|
|
104
|
+
const d = spawnProc(BIN.daemon, ["--workspace", ws], env);
|
|
105
|
+
try {
|
|
106
|
+
await d.waitForStderr(/initial index ready/, 6e4);
|
|
107
|
+
d.child.kill("SIGKILL");
|
|
108
|
+
await d.exited;
|
|
109
|
+
assert.equal(existsSync(sock()), true, "stale socket left behind");
|
|
110
|
+
const status = JSON.parse((await run(BIN.cli, ["status", "--project", "ws"], env)).stdout);
|
|
111
|
+
assert.equal(status.daemonAlive, false);
|
|
112
|
+
const d2 = spawnProc(BIN.daemon, ["--workspace", ws], env);
|
|
113
|
+
try {
|
|
114
|
+
await d2.waitForStderr(/RPC listening/, 3e4);
|
|
115
|
+
await waitFor(() => client().ping(), 1e4, "second daemon answering");
|
|
116
|
+
} finally {
|
|
117
|
+
d2.child.kill("SIGTERM");
|
|
118
|
+
await exitWithin(d2, 1e4);
|
|
119
|
+
}
|
|
120
|
+
} finally {
|
|
121
|
+
d.kill();
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
describe("reset", () => {
|
|
126
|
+
it("rejects names that escape or collide inside the data dir", async () => {
|
|
127
|
+
const dd = join(root, "rp", "inner", "dd");
|
|
128
|
+
mkdirSync(join(dd, "daemon"), { recursive: true });
|
|
129
|
+
mkdirSync(join(dd, "projects"), { recursive: true });
|
|
130
|
+
writeFileSync(join(root, "rp", "keep.txt"), "x");
|
|
131
|
+
const e = { ...env, CODE_SEARCH_DATA_DIR: dd };
|
|
132
|
+
for (const bad of ["..", ".", "daemon", "projects", "a/b", "../inner"]) {
|
|
133
|
+
const r = await run(BIN.cli, ["reset", "--project", bad], e);
|
|
134
|
+
assert.equal(r.code, 2, `${bad}: ${r.stderr}`);
|
|
135
|
+
}
|
|
136
|
+
assert.equal(existsSync(join(root, "rp", "keep.txt")), true);
|
|
137
|
+
assert.equal(existsSync(join(dd, "daemon")), true);
|
|
138
|
+
assert.equal(existsSync(join(dd, "projects")), true);
|
|
139
|
+
mkdirSync(join(dd, "p1", "lancedb"), { recursive: true });
|
|
140
|
+
const ok = await run(BIN.cli, ["reset", "--project", "p1"], e);
|
|
141
|
+
assert.equal(ok.code, 0, ok.stderr);
|
|
142
|
+
assert.equal(existsSync(join(dd, "p1")), false);
|
|
143
|
+
assert.equal(existsSync(dd), true);
|
|
144
|
+
});
|
|
145
|
+
});
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
import { describe, it, before, after } 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 { execFileSync } from "node:child_process";
|
|
7
|
+
import {
|
|
8
|
+
createSystemGraphSqliteWriter,
|
|
9
|
+
invalidateRetriever
|
|
10
|
+
} from "@esankhan3/anvil-knowledge-core";
|
|
11
|
+
import { handleGraphTool } from "../tools/graph.js";
|
|
12
|
+
const PROJECT = "gts";
|
|
13
|
+
let tmp = "";
|
|
14
|
+
let ws = "";
|
|
15
|
+
let headSha = "";
|
|
16
|
+
let stashDataDir;
|
|
17
|
+
function ctx(directoryPath = null) {
|
|
18
|
+
return {
|
|
19
|
+
projectName: PROJECT,
|
|
20
|
+
directoryPath,
|
|
21
|
+
indexReady: true,
|
|
22
|
+
startedAt: 0,
|
|
23
|
+
indexing: {
|
|
24
|
+
status: "idle",
|
|
25
|
+
phase: null,
|
|
26
|
+
message: null,
|
|
27
|
+
percent: 0,
|
|
28
|
+
startedAt: null,
|
|
29
|
+
error: null,
|
|
30
|
+
lastSuccess: null,
|
|
31
|
+
lastDurationMs: 0,
|
|
32
|
+
history: []
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
const text = (r) => r?.content?.[0]?.text ?? "";
|
|
37
|
+
const call = (name, args, c = ctx()) => handleGraphTool(name, args, c);
|
|
38
|
+
const SERVER_TS = [
|
|
39
|
+
"import express from 'express';",
|
|
40
|
+
"const app = express();",
|
|
41
|
+
"export async function getOrderById(req: any, res: any) {",
|
|
42
|
+
" res.json({ id: req.params.id });",
|
|
43
|
+
"}",
|
|
44
|
+
"export function listOrders(req: any, res: any) {",
|
|
45
|
+
" res.json([]);",
|
|
46
|
+
"}",
|
|
47
|
+
"export function publishOrderCreated(order: any) {",
|
|
48
|
+
" return order;",
|
|
49
|
+
"}",
|
|
50
|
+
"app.get('/api/orders/:id', getOrderById);",
|
|
51
|
+
"app.get('/api/orders', listOrders);"
|
|
52
|
+
].join("\n") + "\n";
|
|
53
|
+
const REFUNDS_TS = "export function refund(id: string) {\n return id;\n}\n";
|
|
54
|
+
const mod = (file) => ({ id: file, label: file.split("/").pop(), type: "module", file });
|
|
55
|
+
const ent = (file, name, type = "function") => ({ id: `${file}::${name}`, label: name, type, file });
|
|
56
|
+
const contains = (file, name) => ({ source: file, target: `${file}::${name}`, type: "contains", confidence: 1 });
|
|
57
|
+
const rel = (s, t, type, confidence = 0.9) => ({ source: s, target: t, type, confidence });
|
|
58
|
+
const ORDERS = {
|
|
59
|
+
nodes: [
|
|
60
|
+
mod("src/server.ts"),
|
|
61
|
+
ent("src/server.ts", "getOrderById"),
|
|
62
|
+
ent("src/server.ts", "listOrders"),
|
|
63
|
+
ent("src/server.ts", "publishOrderCreated"),
|
|
64
|
+
mod("src/models.ts"),
|
|
65
|
+
ent("src/models.ts", "LineItem", "interface"),
|
|
66
|
+
ent("src/models.ts", "Order", "class"),
|
|
67
|
+
ent("src/models.ts", "total", "method"),
|
|
68
|
+
mod("src/checkout.ts"),
|
|
69
|
+
ent("src/checkout.ts", "checkoutHandler"),
|
|
70
|
+
mod("src/cart.ts"),
|
|
71
|
+
ent("src/cart.ts", "cartTotal"),
|
|
72
|
+
mod("src/refunds.ts"),
|
|
73
|
+
ent("src/refunds.ts", "refund"),
|
|
74
|
+
mod("src/pipeline/chainA.ts"),
|
|
75
|
+
ent("src/pipeline/chainA.ts", "startPipeline"),
|
|
76
|
+
mod("src/pipeline/chainB.ts"),
|
|
77
|
+
ent("src/pipeline/chainB.ts", "stepTwo"),
|
|
78
|
+
mod("src/pipeline/stepThree.ts"),
|
|
79
|
+
ent("src/pipeline/stepThree.ts", "stepThree")
|
|
80
|
+
],
|
|
81
|
+
links: [
|
|
82
|
+
contains("src/server.ts", "getOrderById"),
|
|
83
|
+
contains("src/server.ts", "listOrders"),
|
|
84
|
+
contains("src/server.ts", "publishOrderCreated"),
|
|
85
|
+
contains("src/models.ts", "LineItem"),
|
|
86
|
+
contains("src/models.ts", "Order"),
|
|
87
|
+
{ source: "src/models.ts::Order", target: "src/models.ts::total", type: "contains", confidence: 1 },
|
|
88
|
+
contains("src/checkout.ts", "checkoutHandler"),
|
|
89
|
+
contains("src/cart.ts", "cartTotal"),
|
|
90
|
+
contains("src/refunds.ts", "refund"),
|
|
91
|
+
contains("src/pipeline/chainA.ts", "startPipeline"),
|
|
92
|
+
contains("src/pipeline/chainB.ts", "stepTwo"),
|
|
93
|
+
contains("src/pipeline/stepThree.ts", "stepThree"),
|
|
94
|
+
rel("src/pipeline/chainA.ts::startPipeline", "src/pipeline/chainB.ts::stepTwo", "calls"),
|
|
95
|
+
rel("src/pipeline/chainB.ts::stepTwo", "src/pipeline/stepThree.ts::stepThree", "calls"),
|
|
96
|
+
rel("src/pipeline/chainA.ts", "src/pipeline/chainB.ts", "imports", 1),
|
|
97
|
+
rel("src/pipeline/chainA.ts", "src/pipeline/chainB.ts::stepTwo", "uses", 0.95),
|
|
98
|
+
rel("src/server.ts::getOrderById", "src/models.ts::LineItem", "type-ref", 0.85),
|
|
99
|
+
rel("src/cart.ts::cartTotal", "src/checkout.ts::checkoutHandler", "calls"),
|
|
100
|
+
rel("src/checkout.ts::checkoutHandler", "src/server.ts::getOrderById", "calls"),
|
|
101
|
+
rel("src/checkout.ts::checkoutHandler", "src/refunds.ts::refund", "calls"),
|
|
102
|
+
rel("src/checkout.ts", "src/refunds.ts", "imports", 1)
|
|
103
|
+
]
|
|
104
|
+
};
|
|
105
|
+
const WEB = { nodes: [mod("src/api/orders.ts"), ent("src/api/orders.ts", "fetchOrder")], links: [contains("src/api/orders.ts", "fetchOrder")] };
|
|
106
|
+
const ANALYTICS = { nodes: [mod("consumer.py"), ent("consumer.py", "consume")], links: [contains("consumer.py", "consume")] };
|
|
107
|
+
const MONO = {
|
|
108
|
+
nodes: [{ id: "pkg::@m/a", label: "@m/a", type: "package", file: "packages/a" }, { id: "pkg::@m/b", label: "@m/b", type: "package", file: "packages/b" }],
|
|
109
|
+
links: []
|
|
110
|
+
};
|
|
111
|
+
const CROSS = [
|
|
112
|
+
{
|
|
113
|
+
sourceRepo: "web-app",
|
|
114
|
+
sourceNode: "src/api/orders.ts::/api/orders/:param",
|
|
115
|
+
targetRepo: "orders-svc",
|
|
116
|
+
targetNode: "src/server.ts::/api/orders/:param",
|
|
117
|
+
edgeType: "http",
|
|
118
|
+
evidence: "API endpoint: /api/orders/:param",
|
|
119
|
+
confidence: 0.8
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
sourceRepo: "analytics-py",
|
|
123
|
+
sourceNode: "consumer.py::order-created",
|
|
124
|
+
targetRepo: "orders-svc",
|
|
125
|
+
targetNode: "src/server.ts::order-created",
|
|
126
|
+
edgeType: "event-schema",
|
|
127
|
+
evidence: "Shared event/topic: order-created",
|
|
128
|
+
confidence: 0.85
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
sourceRepo: "mono",
|
|
132
|
+
sourceNode: "pkg::@m/a",
|
|
133
|
+
targetRepo: "mono",
|
|
134
|
+
targetNode: "pkg::@m/b",
|
|
135
|
+
edgeType: "workspace-dep",
|
|
136
|
+
evidence: "@m/a depends on @m/b (packages/a/package.json)",
|
|
137
|
+
confidence: 1
|
|
138
|
+
}
|
|
139
|
+
];
|
|
140
|
+
function chunk(filePath, startLine, endLine, entityName, content) {
|
|
141
|
+
return { id: `${filePath}:${startLine}`, repoName: "orders-svc", filePath, startLine, endLine, entityName, content, language: "typescript" };
|
|
142
|
+
}
|
|
143
|
+
const serverLines = SERVER_TS.split("\n");
|
|
144
|
+
const lines = (a, b) => serverLines.slice(a - 1, b).join("\n");
|
|
145
|
+
const CHUNKS = [
|
|
146
|
+
chunk("src/server.ts", 1, 2, void 0, lines(1, 2)),
|
|
147
|
+
chunk("src/server.ts", 3, 5, "getOrderById", lines(3, 5)),
|
|
148
|
+
chunk("src/server.ts", 6, 8, "listOrders", lines(6, 8)),
|
|
149
|
+
chunk("src/server.ts", 9, 13, "publishOrderCreated", lines(9, 13)),
|
|
150
|
+
chunk("src/refunds.ts", 1, 3, "refund", REFUNDS_TS.trimEnd())
|
|
151
|
+
];
|
|
152
|
+
const git = (cwd, ...a) => execFileSync("git", ["-c", "user.email=t@t", "-c", "user.name=t", "-c", "commit.gpgsign=false", ...a], { cwd, stdio: ["pipe", "pipe", "pipe"] }).toString().trim();
|
|
153
|
+
before(async () => {
|
|
154
|
+
stashDataDir = process.env.CODE_SEARCH_DATA_DIR;
|
|
155
|
+
tmp = mkdtempSync(join(tmpdir(), "cs-gts-"));
|
|
156
|
+
process.env.CODE_SEARCH_DATA_DIR = tmp;
|
|
157
|
+
const kb = join(tmp, PROJECT);
|
|
158
|
+
mkdirSync(join(kb, "orders-svc"), { recursive: true });
|
|
159
|
+
const w = await createSystemGraphSqliteWriter(kb);
|
|
160
|
+
assert.ok(w, "no sqlite driver");
|
|
161
|
+
for (const [name, g] of [["orders-svc", ORDERS], ["web-app", WEB], ["analytics-py", ANALYTICS], ["mono", MONO]]) w.addRepoGraph(name, g);
|
|
162
|
+
w.addCrossRepoEdges(CROSS);
|
|
163
|
+
w.finalize();
|
|
164
|
+
writeFileSync(join(kb, "orders-svc", "graph.json"), JSON.stringify(ORDERS));
|
|
165
|
+
writeFileSync(join(kb, "chunks.json"), CHUNKS.map((c) => JSON.stringify(c)).join("\n") + "\n");
|
|
166
|
+
ws = mkdtempSync(join(tmpdir(), "cs-gts-ws-"));
|
|
167
|
+
const repo = join(ws, "orders-svc");
|
|
168
|
+
mkdirSync(join(repo, "src"), { recursive: true });
|
|
169
|
+
writeFileSync(join(repo, "src", "server.ts"), SERVER_TS);
|
|
170
|
+
writeFileSync(join(repo, "src", "refunds.ts"), REFUNDS_TS);
|
|
171
|
+
git(repo, "init", "-q");
|
|
172
|
+
git(repo, "add", "-A");
|
|
173
|
+
git(repo, "commit", "-qm", "init");
|
|
174
|
+
headSha = git(repo, "rev-parse", "HEAD");
|
|
175
|
+
});
|
|
176
|
+
after(async () => {
|
|
177
|
+
await invalidateRetriever();
|
|
178
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
179
|
+
rmSync(ws, { recursive: true, force: true });
|
|
180
|
+
if (stashDataDir === void 0) delete process.env.CODE_SEARCH_DATA_DIR;
|
|
181
|
+
else process.env.CODE_SEARCH_DATA_DIR = stashDataDir;
|
|
182
|
+
});
|
|
183
|
+
describe("get_cross_repo_edges", () => {
|
|
184
|
+
it("names the evidence behind each edge", async () => {
|
|
185
|
+
const t = text(await call("get_cross_repo_edges", { repo: "web-app" }));
|
|
186
|
+
assert.match(t, /web-app → orders-svc \(http\).*API endpoint: \/api\/orders\/:param/);
|
|
187
|
+
});
|
|
188
|
+
it("lists same-repo workspace-dep edges in their own section", async () => {
|
|
189
|
+
const t = text(await call("get_cross_repo_edges", {}));
|
|
190
|
+
assert.match(t, /## Within a repo/);
|
|
191
|
+
assert.match(t, /mono: @m\/a → @m\/b \(workspace-dep\).*@m\/a depends on @m\/b/);
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
describe("impact_analysis", () => {
|
|
195
|
+
it("counts the other side of a symmetric edge as a dependent", async () => {
|
|
196
|
+
const t = text(await call("impact_analysis", { repo: "analytics-py", file: "consumer.py" }));
|
|
197
|
+
assert.match(t, /Affected repos: orders-svc/);
|
|
198
|
+
});
|
|
199
|
+
it("entity scope: file-level cross-repo dependents + multi-hop callers", async () => {
|
|
200
|
+
const t = text(await call("impact_analysis", { repo: "orders-svc", file: "src/server.ts", entity: "getOrderById" }));
|
|
201
|
+
assert.match(t, /checkoutHandler/);
|
|
202
|
+
assert.match(t, /cartTotal/);
|
|
203
|
+
assert.match(t, /web-app.*file-level/);
|
|
204
|
+
assert.match(t, /Affected repos: .*web-app/);
|
|
205
|
+
const shallow = text(await call("impact_analysis", { repo: "orders-svc", file: "src/server.ts", entity: "getOrderById", depth: 1 }));
|
|
206
|
+
assert.doesNotMatch(shallow, /cartTotal/);
|
|
207
|
+
});
|
|
208
|
+
it("accepts the full id as entity", async () => {
|
|
209
|
+
const t = text(await call("impact_analysis", { repo: "orders-svc", file: "src/server.ts", entity: "orders-svc::src/server.ts::getOrderById" }));
|
|
210
|
+
assert.match(t, /checkoutHandler/);
|
|
211
|
+
});
|
|
212
|
+
it("a file with no graph nodes is an error, not an empty report", async () => {
|
|
213
|
+
const r = await call("impact_analysis", { repo: "orders-svc", file: "src/nope.ts" });
|
|
214
|
+
assert.equal(r.isError, true);
|
|
215
|
+
assert.match(text(r), /No graph nodes/);
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
describe("symbol resolution", () => {
|
|
219
|
+
it("trace_path accepts a full repo::file::entity id", async () => {
|
|
220
|
+
const t = text(await call("trace_path", { from: "orders-svc::src/pipeline/chainA.ts::startPipeline", to: "stepThree" }));
|
|
221
|
+
assert.match(t, /2 hops/);
|
|
222
|
+
});
|
|
223
|
+
it('exact names are literal: "%" matches nothing', async () => {
|
|
224
|
+
assert.match(text(await call("find_callers", { function: "%" })), /No entity found/);
|
|
225
|
+
});
|
|
226
|
+
it("fuzzy trace_path matches symbol names, never a 0-hop file match", async () => {
|
|
227
|
+
const t = text(await call("trace_path", { from: "Pipeline", to: "Three", fuzzy: true }));
|
|
228
|
+
assert.match(t, /2 hops/);
|
|
229
|
+
assert.doesNotMatch(t, /0 hops/);
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
describe("relation-typed traversal", () => {
|
|
233
|
+
it("find_callers returns call edges only by default", async () => {
|
|
234
|
+
const t = text(await call("find_callers", { function: "stepTwo" }));
|
|
235
|
+
assert.match(t, /startPipeline/);
|
|
236
|
+
assert.doesNotMatch(t, /chainA\.ts`/);
|
|
237
|
+
});
|
|
238
|
+
it("other relations are named, and listed on request", async () => {
|
|
239
|
+
const t = text(await call("find_callers", { function: "LineItem" }));
|
|
240
|
+
assert.match(t, /no callers/i);
|
|
241
|
+
assert.match(t, /type-ref/);
|
|
242
|
+
const typed = text(await call("find_callers", { function: "LineItem", relations: ["type-ref"] }));
|
|
243
|
+
assert.match(typed, /## type-ref \(1\)[\s\S]*getOrderById/);
|
|
244
|
+
});
|
|
245
|
+
it("find_dependencies defaults to calls", async () => {
|
|
246
|
+
const t = text(await call("find_dependencies", { function: "checkoutHandler" }));
|
|
247
|
+
assert.match(t, /getOrderById/);
|
|
248
|
+
assert.match(t, /refund/);
|
|
249
|
+
});
|
|
250
|
+
});
|
|
251
|
+
describe("search_graph", () => {
|
|
252
|
+
it("name is a regex over symbol names", async () => {
|
|
253
|
+
const t = text(await call("search_graph", { name: "^step.*$" }));
|
|
254
|
+
assert.match(t, /stepTwo/);
|
|
255
|
+
assert.match(t, /stepThree/);
|
|
256
|
+
assert.doesNotMatch(t, /\(module/);
|
|
257
|
+
});
|
|
258
|
+
it("type filter matches any node type the graph has", async () => {
|
|
259
|
+
assert.match(text(await call("search_graph", { type: "method" })), /`total`/);
|
|
260
|
+
});
|
|
261
|
+
it("rejects an invalid regex", async () => {
|
|
262
|
+
const r = await call("search_graph", { name: "(" });
|
|
263
|
+
assert.equal(r.isError, true);
|
|
264
|
+
assert.match(text(r), /Invalid regex/);
|
|
265
|
+
});
|
|
266
|
+
});
|
|
267
|
+
describe("get_repo_graph", () => {
|
|
268
|
+
it("lists edges, bounded by limit", async () => {
|
|
269
|
+
const t = text(await call("get_repo_graph", { repo: "orders-svc", limit: 2 }));
|
|
270
|
+
assert.match(t, /## Edges/);
|
|
271
|
+
assert.match(t, /startPipeline` → `src\/pipeline\/chainB\.ts::stepTwo` \(calls\)/);
|
|
272
|
+
assert.equal((t.split("## Edges")[1].match(/^- /gm) ?? []).length, 2);
|
|
273
|
+
});
|
|
274
|
+
it("validates repo", async () => {
|
|
275
|
+
const missing = await call("get_repo_graph", {});
|
|
276
|
+
assert.equal(missing.isError, true);
|
|
277
|
+
const unknown = await call("get_repo_graph", { repo: "nope" });
|
|
278
|
+
assert.equal(unknown.isError, true);
|
|
279
|
+
});
|
|
280
|
+
});
|
|
281
|
+
describe("find_dead_code", () => {
|
|
282
|
+
it("does not flag a function referenced elsewhere in its file", async () => {
|
|
283
|
+
const t = text(await call("find_dead_code", { repo: "orders-svc" }));
|
|
284
|
+
assert.match(t, /publishOrderCreated/);
|
|
285
|
+
assert.doesNotMatch(t, /listOrders/);
|
|
286
|
+
});
|
|
287
|
+
});
|
|
288
|
+
describe("detect_changes", () => {
|
|
289
|
+
it("reports only the entities whose lines changed", async () => {
|
|
290
|
+
const repo = join(ws, "orders-svc");
|
|
291
|
+
writeFileSync(join(repo, "src", "server.ts"), SERVER_TS.replace("res.json({ id: req.params.id });", "res.json({ id: Number(req.params.id) });"));
|
|
292
|
+
try {
|
|
293
|
+
const t = text(await call("detect_changes", { repo: "orders-svc", baseSha: headSha }, ctx(ws)));
|
|
294
|
+
const affected = t.split("## Affected entities")[1].split("##")[0];
|
|
295
|
+
assert.match(affected, /getOrderById/);
|
|
296
|
+
assert.doesNotMatch(affected, /listOrders|publishOrderCreated/);
|
|
297
|
+
assert.match(t, /checkoutHandler/);
|
|
298
|
+
} finally {
|
|
299
|
+
git(repo, "checkout", "--", ".");
|
|
300
|
+
}
|
|
301
|
+
});
|
|
302
|
+
it("reports deleted files, their entities and dependents", async () => {
|
|
303
|
+
const repo = join(ws, "orders-svc");
|
|
304
|
+
git(repo, "rm", "-q", "src/refunds.ts");
|
|
305
|
+
try {
|
|
306
|
+
const t = text(await call("detect_changes", { repo: "orders-svc", baseSha: headSha }, ctx(ws)));
|
|
307
|
+
assert.match(t, /Deleted files: 1[\s\S]*src\/refunds\.ts/);
|
|
308
|
+
assert.match(t.split("## Affected entities")[1], /refund/);
|
|
309
|
+
assert.match(t, /checkoutHandler` → `orders-svc::src\/refunds\.ts::refund/);
|
|
310
|
+
} finally {
|
|
311
|
+
git(repo, "reset", "-q", "--hard");
|
|
312
|
+
}
|
|
313
|
+
});
|
|
314
|
+
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, it, beforeEach, afterEach } from "node:test";
|
|
2
2
|
import assert from "node:assert/strict";
|
|
3
|
-
import { mkdtempSync, mkdirSync, rmSync, writeFileSync
|
|
3
|
+
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { tmpdir } from "node:os";
|
|
6
6
|
import { execSync } from "node:child_process";
|
|
@@ -57,7 +57,9 @@ const REPO_GRAPH = {
|
|
|
57
57
|
]
|
|
58
58
|
};
|
|
59
59
|
const CHUNKS = [
|
|
60
|
-
{ id: "c1", repoName: "app", filePath: "a.ts", entityName: "handleRequest", startLine: 1, endLine: 3, content: "function handleRequest() { return 1; }", language: "typescript" }
|
|
60
|
+
{ id: "c1", repoName: "app", filePath: "a.ts", entityName: "handleRequest", startLine: 1, endLine: 3, content: "function handleRequest() { return 1; }", language: "typescript" },
|
|
61
|
+
{ id: "c2", repoName: "app", filePath: "ui.ts", entityName: "render", parentEntity: "Board", startLine: 2, endLine: 2, content: 'render() { return "board"; }', language: "typescript" },
|
|
62
|
+
{ id: "c3", repoName: "app", filePath: "ui.ts", entityName: "render", parentEntity: "Card", startLine: 5, endLine: 5, content: 'render() { return "card"; }', language: "typescript" }
|
|
61
63
|
];
|
|
62
64
|
const PROJECT_GRAPH = {
|
|
63
65
|
architectureSummary: "Two services talk over HTTP.",
|
|
@@ -141,6 +143,11 @@ describe("get_code_snippet", () => {
|
|
|
141
143
|
const r = await handleSearchTool("get_code_snippet", { id: "app::a.ts::handleRequest" }, ctx());
|
|
142
144
|
assert.match(text(r), /function handleRequest/);
|
|
143
145
|
});
|
|
146
|
+
it("resolves a class-qualified method id to that class's method", async () => {
|
|
147
|
+
const t = text(await handleSearchTool("get_code_snippet", { id: "app::ui.ts::Card.render" }, ctx()));
|
|
148
|
+
assert.match(t, /"card"/);
|
|
149
|
+
assert.doesNotMatch(t, /"board"/);
|
|
150
|
+
});
|
|
144
151
|
it("reports when nothing matches", async () => {
|
|
145
152
|
const r = await handleSearchTool("get_code_snippet", { id: "app::a.ts::nope" }, ctx());
|
|
146
153
|
assert.match(text(r), /No snippet found/);
|
|
@@ -187,11 +194,11 @@ describe("detect_changes", () => {
|
|
|
187
194
|
git("config user.name test");
|
|
188
195
|
git("config commit.gpgsign false");
|
|
189
196
|
writeFileSync(join(repoDir, "a.ts"), "export function handleRequest() {}\n");
|
|
190
|
-
writeFileSync(join(repoDir, "b.ts"), "export function dbQuery() {}\n");
|
|
197
|
+
writeFileSync(join(repoDir, "b.ts"), "export function dbQuery() {\n return 1;\n}\n");
|
|
191
198
|
git("add -A");
|
|
192
199
|
git("commit -q -m init");
|
|
193
200
|
baseSha = execSync("git rev-parse HEAD", { cwd: repoDir }).toString().trim();
|
|
194
|
-
|
|
201
|
+
writeFileSync(join(repoDir, "b.ts"), "export function dbQuery() {\n return 2;\n}\n");
|
|
195
202
|
} catch {
|
|
196
203
|
rmSync(ws, { recursive: true, force: true });
|
|
197
204
|
return;
|