@mhalder/qdrant-mcp-server 3.1.2 → 3.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/CHANGELOG.md +11 -0
- package/CONTRIBUTING.md +28 -130
- package/README.md +51 -30
- package/build/tools/federated.d.ts +96 -0
- package/build/tools/federated.d.ts.map +1 -0
- package/build/tools/federated.js +375 -0
- package/build/tools/federated.js.map +1 -0
- package/build/tools/federated.test.d.ts +2 -0
- package/build/tools/federated.test.d.ts.map +1 -0
- package/build/tools/federated.test.js +592 -0
- package/build/tools/federated.test.js.map +1 -0
- package/build/tools/index.d.ts.map +1 -1
- package/build/tools/index.js +5 -0
- package/build/tools/index.js.map +1 -1
- package/build/tools/schemas.d.ts +13 -0
- package/build/tools/schemas.d.ts.map +1 -1
- package/build/tools/schemas.js +35 -0
- package/build/tools/schemas.js.map +1 -1
- package/examples/README.md +32 -0
- package/examples/advanced-search/README.md +348 -0
- package/package.json +1 -1
- package/prompts.example.json +102 -0
- package/src/tools/federated.test.ts +752 -0
- package/src/tools/federated.ts +569 -0
- package/src/tools/index.ts +6 -0
- package/src/tools/schemas.ts +39 -0
|
@@ -0,0 +1,752 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
import type { CodeSearchResult } from "../code/types.js";
|
|
3
|
+
import type { GitSearchResult } from "../git/types.js";
|
|
4
|
+
import {
|
|
5
|
+
buildCorrelations,
|
|
6
|
+
normalizeScores,
|
|
7
|
+
calculateRRFScore,
|
|
8
|
+
pathsMatch,
|
|
9
|
+
} from "./federated.js";
|
|
10
|
+
|
|
11
|
+
// ============================================================================
|
|
12
|
+
// Unit Tests for Helper Functions
|
|
13
|
+
// ============================================================================
|
|
14
|
+
|
|
15
|
+
describe("normalizeScores", () => {
|
|
16
|
+
it("should return empty array for empty input", () => {
|
|
17
|
+
const result = normalizeScores([]);
|
|
18
|
+
expect(result).toEqual([]);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("should normalize single result to score of 1", () => {
|
|
22
|
+
const input = [{ score: 0.5, id: "a" }];
|
|
23
|
+
const result = normalizeScores(input);
|
|
24
|
+
|
|
25
|
+
expect(result).toHaveLength(1);
|
|
26
|
+
expect(result[0].score).toBe(1);
|
|
27
|
+
expect(result[0].id).toBe("a");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("should normalize scores to [0, 1] range", () => {
|
|
31
|
+
const input = [
|
|
32
|
+
{ score: 10, id: "a" },
|
|
33
|
+
{ score: 5, id: "b" },
|
|
34
|
+
{ score: 0, id: "c" },
|
|
35
|
+
];
|
|
36
|
+
const result = normalizeScores(input);
|
|
37
|
+
|
|
38
|
+
expect(result).toHaveLength(3);
|
|
39
|
+
expect(result.find((r) => r.id === "a")?.score).toBe(1); // highest
|
|
40
|
+
expect(result.find((r) => r.id === "b")?.score).toBe(0.5); // middle
|
|
41
|
+
expect(result.find((r) => r.id === "c")?.score).toBe(0); // lowest
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("should handle identical scores by normalizing all to 1", () => {
|
|
45
|
+
const input = [
|
|
46
|
+
{ score: 0.7, id: "a" },
|
|
47
|
+
{ score: 0.7, id: "b" },
|
|
48
|
+
{ score: 0.7, id: "c" },
|
|
49
|
+
];
|
|
50
|
+
const result = normalizeScores(input);
|
|
51
|
+
|
|
52
|
+
expect(result.every((r) => r.score === 1)).toBe(true);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("should handle negative scores", () => {
|
|
56
|
+
const input = [
|
|
57
|
+
{ score: -5, id: "a" },
|
|
58
|
+
{ score: 5, id: "b" },
|
|
59
|
+
];
|
|
60
|
+
const result = normalizeScores(input);
|
|
61
|
+
|
|
62
|
+
expect(result.find((r) => r.id === "a")?.score).toBe(0);
|
|
63
|
+
expect(result.find((r) => r.id === "b")?.score).toBe(1);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("should preserve other properties", () => {
|
|
67
|
+
const input = [
|
|
68
|
+
{ score: 1, id: "a", extra: "data1" },
|
|
69
|
+
{ score: 0, id: "b", extra: "data2" },
|
|
70
|
+
];
|
|
71
|
+
const result = normalizeScores(input);
|
|
72
|
+
|
|
73
|
+
expect(result[0].extra).toBeDefined();
|
|
74
|
+
expect(result[1].extra).toBeDefined();
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe("calculateRRFScore", () => {
|
|
79
|
+
it("should calculate RRF for single rank", () => {
|
|
80
|
+
// RRF with k=60: 1/(60+1) = 0.01639...
|
|
81
|
+
const score = calculateRRFScore([1]);
|
|
82
|
+
expect(score).toBeCloseTo(1 / 61, 5);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("should calculate RRF for multiple ranks", () => {
|
|
86
|
+
// RRF with k=60: 1/(60+1) + 1/(60+2) = 0.01639... + 0.01613...
|
|
87
|
+
const score = calculateRRFScore([1, 2]);
|
|
88
|
+
expect(score).toBeCloseTo(1 / 61 + 1 / 62, 5);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("should return 0 for empty ranks", () => {
|
|
92
|
+
const score = calculateRRFScore([]);
|
|
93
|
+
expect(score).toBe(0);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("should give higher score to lower ranks (better positions)", () => {
|
|
97
|
+
const scoreRank1 = calculateRRFScore([1]);
|
|
98
|
+
const scoreRank10 = calculateRRFScore([10]);
|
|
99
|
+
|
|
100
|
+
expect(scoreRank1).toBeGreaterThan(scoreRank10);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("should handle high ranks without overflow", () => {
|
|
104
|
+
const score = calculateRRFScore([1000]);
|
|
105
|
+
expect(score).toBeCloseTo(1 / 1060, 5);
|
|
106
|
+
expect(Number.isFinite(score)).toBe(true);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
describe("pathsMatch", () => {
|
|
111
|
+
it("should match identical paths", () => {
|
|
112
|
+
expect(pathsMatch("src/auth/user.ts", "src/auth/user.ts")).toBe(true);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("should match when shorter path is suffix of longer path", () => {
|
|
116
|
+
expect(pathsMatch("app/models/user.ts", "models/user.ts")).toBe(true);
|
|
117
|
+
expect(pathsMatch("models/user.ts", "app/models/user.ts")).toBe(true);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("should match filename only against full path", () => {
|
|
121
|
+
expect(pathsMatch("src/components/Button.tsx", "Button.tsx")).toBe(true);
|
|
122
|
+
expect(pathsMatch("Button.tsx", "src/components/Button.tsx")).toBe(true);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("should NOT match paths with different parent directories", () => {
|
|
126
|
+
// This was the false positive case
|
|
127
|
+
expect(pathsMatch("app/models/user.ts", "lib/user.ts")).toBe(false);
|
|
128
|
+
expect(pathsMatch("src/auth/middleware.ts", "other/middleware.ts")).toBe(
|
|
129
|
+
false,
|
|
130
|
+
);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("should NOT match completely different filenames", () => {
|
|
134
|
+
expect(pathsMatch("src/auth.ts", "src/user.ts")).toBe(false);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("should handle Windows-style backslashes", () => {
|
|
138
|
+
expect(pathsMatch("src\\auth\\user.ts", "auth/user.ts")).toBe(true);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("should be case-insensitive", () => {
|
|
142
|
+
expect(pathsMatch("src/Auth/User.ts", "auth/user.ts")).toBe(true);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("should return false for empty paths", () => {
|
|
146
|
+
expect(pathsMatch("", "src/file.ts")).toBe(false);
|
|
147
|
+
expect(pathsMatch("src/file.ts", "")).toBe(false);
|
|
148
|
+
expect(pathsMatch("", "")).toBe(false);
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
describe("buildCorrelations", () => {
|
|
153
|
+
const createCodeResult = (
|
|
154
|
+
overrides: Partial<CodeSearchResult> = {},
|
|
155
|
+
): CodeSearchResult => ({
|
|
156
|
+
content: "function test() {}",
|
|
157
|
+
filePath: "src/utils/helper.ts",
|
|
158
|
+
startLine: 10,
|
|
159
|
+
endLine: 20,
|
|
160
|
+
language: "typescript",
|
|
161
|
+
score: 0.85,
|
|
162
|
+
fileExtension: ".ts",
|
|
163
|
+
...overrides,
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
const createGitResult = (
|
|
167
|
+
overrides: Partial<GitSearchResult> = {},
|
|
168
|
+
): GitSearchResult => ({
|
|
169
|
+
content: "Commit content",
|
|
170
|
+
commitHash: "abc123def456",
|
|
171
|
+
shortHash: "abc123d",
|
|
172
|
+
author: "John Doe",
|
|
173
|
+
date: "2024-01-15T10:30:00Z",
|
|
174
|
+
subject: "feat: add helper function",
|
|
175
|
+
commitType: "feat",
|
|
176
|
+
files: ["src/utils/helper.ts", "src/index.ts"],
|
|
177
|
+
score: 0.9,
|
|
178
|
+
...overrides,
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("should find correlations when file paths match", () => {
|
|
182
|
+
const codeResults = [createCodeResult({ filePath: "src/utils/helper.ts" })];
|
|
183
|
+
const gitResults = [
|
|
184
|
+
createGitResult({ files: ["src/utils/helper.ts", "src/index.ts"] }),
|
|
185
|
+
];
|
|
186
|
+
|
|
187
|
+
const correlations = buildCorrelations(codeResults, gitResults);
|
|
188
|
+
|
|
189
|
+
expect(correlations).toHaveLength(1);
|
|
190
|
+
expect(correlations[0].codeResult.filePath).toBe("src/utils/helper.ts");
|
|
191
|
+
expect(correlations[0].relatedCommits).toHaveLength(1);
|
|
192
|
+
expect(correlations[0].relatedCommits[0].shortHash).toBe("abc123d");
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("should match partial paths (relative vs absolute)", () => {
|
|
196
|
+
const codeResults = [
|
|
197
|
+
createCodeResult({ filePath: "/home/user/project/src/utils/helper.ts" }),
|
|
198
|
+
];
|
|
199
|
+
const gitResults = [createGitResult({ files: ["src/utils/helper.ts"] })];
|
|
200
|
+
|
|
201
|
+
const correlations = buildCorrelations(codeResults, gitResults);
|
|
202
|
+
|
|
203
|
+
expect(correlations).toHaveLength(1);
|
|
204
|
+
expect(correlations[0].relatedCommits).toHaveLength(1);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("should return empty array when no matches found", () => {
|
|
208
|
+
const codeResults = [createCodeResult({ filePath: "src/other/file.ts" })];
|
|
209
|
+
const gitResults = [createGitResult({ files: ["src/different/path.ts"] })];
|
|
210
|
+
|
|
211
|
+
const correlations = buildCorrelations(codeResults, gitResults);
|
|
212
|
+
|
|
213
|
+
expect(correlations).toHaveLength(0);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it("should find multiple related commits for one file", () => {
|
|
217
|
+
const codeResults = [createCodeResult({ filePath: "src/utils/helper.ts" })];
|
|
218
|
+
const gitResults = [
|
|
219
|
+
createGitResult({
|
|
220
|
+
shortHash: "abc123d",
|
|
221
|
+
files: ["src/utils/helper.ts"],
|
|
222
|
+
}),
|
|
223
|
+
createGitResult({
|
|
224
|
+
shortHash: "xyz789a",
|
|
225
|
+
files: ["src/utils/helper.ts", "src/other.ts"],
|
|
226
|
+
}),
|
|
227
|
+
];
|
|
228
|
+
|
|
229
|
+
const correlations = buildCorrelations(codeResults, gitResults);
|
|
230
|
+
|
|
231
|
+
expect(correlations).toHaveLength(1);
|
|
232
|
+
expect(correlations[0].relatedCommits).toHaveLength(2);
|
|
233
|
+
expect(correlations[0].relatedCommits.map((c) => c.shortHash)).toContain(
|
|
234
|
+
"abc123d",
|
|
235
|
+
);
|
|
236
|
+
expect(correlations[0].relatedCommits.map((c) => c.shortHash)).toContain(
|
|
237
|
+
"xyz789a",
|
|
238
|
+
);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it("should handle multiple code results with different correlations", () => {
|
|
242
|
+
const codeResults = [
|
|
243
|
+
createCodeResult({ filePath: "src/utils/helper.ts" }),
|
|
244
|
+
createCodeResult({ filePath: "src/services/api.ts" }),
|
|
245
|
+
];
|
|
246
|
+
const gitResults = [
|
|
247
|
+
createGitResult({
|
|
248
|
+
shortHash: "abc123d",
|
|
249
|
+
files: ["src/utils/helper.ts"],
|
|
250
|
+
}),
|
|
251
|
+
createGitResult({
|
|
252
|
+
shortHash: "xyz789a",
|
|
253
|
+
files: ["src/services/api.ts"],
|
|
254
|
+
}),
|
|
255
|
+
];
|
|
256
|
+
|
|
257
|
+
const correlations = buildCorrelations(codeResults, gitResults);
|
|
258
|
+
|
|
259
|
+
expect(correlations).toHaveLength(2);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it("should handle empty code results", () => {
|
|
263
|
+
const gitResults = [createGitResult()];
|
|
264
|
+
|
|
265
|
+
const correlations = buildCorrelations([], gitResults);
|
|
266
|
+
|
|
267
|
+
expect(correlations).toHaveLength(0);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it("should handle empty git results", () => {
|
|
271
|
+
const codeResults = [createCodeResult()];
|
|
272
|
+
|
|
273
|
+
const correlations = buildCorrelations(codeResults, []);
|
|
274
|
+
|
|
275
|
+
expect(correlations).toHaveLength(0);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it("should handle case-insensitive path matching", () => {
|
|
279
|
+
const codeResults = [createCodeResult({ filePath: "SRC/Utils/Helper.ts" })];
|
|
280
|
+
const gitResults = [createGitResult({ files: ["src/utils/helper.ts"] })];
|
|
281
|
+
|
|
282
|
+
const correlations = buildCorrelations(codeResults, gitResults);
|
|
283
|
+
|
|
284
|
+
expect(correlations).toHaveLength(1);
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it("should normalize Windows-style paths", () => {
|
|
288
|
+
const codeResults = [
|
|
289
|
+
createCodeResult({ filePath: "src\\utils\\helper.ts" }),
|
|
290
|
+
];
|
|
291
|
+
const gitResults = [createGitResult({ files: ["src/utils/helper.ts"] })];
|
|
292
|
+
|
|
293
|
+
const correlations = buildCorrelations(codeResults, gitResults);
|
|
294
|
+
|
|
295
|
+
expect(correlations).toHaveLength(1);
|
|
296
|
+
});
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
// ============================================================================
|
|
300
|
+
// Integration Tests for Tool Implementations
|
|
301
|
+
// ============================================================================
|
|
302
|
+
|
|
303
|
+
describe("contextual_search integration", () => {
|
|
304
|
+
// Mock indexers
|
|
305
|
+
const mockCodeIndexer = {
|
|
306
|
+
getIndexStatus: vi.fn(),
|
|
307
|
+
searchCode: vi.fn(),
|
|
308
|
+
indexCodebase: vi.fn(),
|
|
309
|
+
reindexChanges: vi.fn(),
|
|
310
|
+
clearIndex: vi.fn(),
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
const mockGitHistoryIndexer = {
|
|
314
|
+
getIndexStatus: vi.fn(),
|
|
315
|
+
searchHistory: vi.fn(),
|
|
316
|
+
indexHistory: vi.fn(),
|
|
317
|
+
indexNewCommits: vi.fn(),
|
|
318
|
+
clearIndex: vi.fn(),
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
beforeEach(() => {
|
|
322
|
+
vi.resetAllMocks();
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
it("should execute code and git searches in parallel", async () => {
|
|
326
|
+
mockCodeIndexer.getIndexStatus.mockResolvedValue({ status: "indexed" });
|
|
327
|
+
mockGitHistoryIndexer.getIndexStatus.mockResolvedValue({
|
|
328
|
+
status: "indexed",
|
|
329
|
+
});
|
|
330
|
+
mockCodeIndexer.searchCode.mockResolvedValue([
|
|
331
|
+
{
|
|
332
|
+
content: "function test() {}",
|
|
333
|
+
filePath: "src/test.ts",
|
|
334
|
+
startLine: 1,
|
|
335
|
+
endLine: 5,
|
|
336
|
+
language: "typescript",
|
|
337
|
+
score: 0.9,
|
|
338
|
+
fileExtension: ".ts",
|
|
339
|
+
},
|
|
340
|
+
]);
|
|
341
|
+
mockGitHistoryIndexer.searchHistory.mockResolvedValue([
|
|
342
|
+
{
|
|
343
|
+
content: "Commit content",
|
|
344
|
+
commitHash: "abc123",
|
|
345
|
+
shortHash: "abc123d",
|
|
346
|
+
author: "Test Author",
|
|
347
|
+
date: "2024-01-15",
|
|
348
|
+
subject: "feat: add test",
|
|
349
|
+
commitType: "feat",
|
|
350
|
+
files: ["src/test.ts"],
|
|
351
|
+
score: 0.85,
|
|
352
|
+
},
|
|
353
|
+
]);
|
|
354
|
+
|
|
355
|
+
// Import and call performContextualSearch directly
|
|
356
|
+
const { registerFederatedTools } = await import("./federated.js");
|
|
357
|
+
|
|
358
|
+
// Create a mock server
|
|
359
|
+
const mockServer = {
|
|
360
|
+
registerTool: vi.fn(),
|
|
361
|
+
};
|
|
362
|
+
|
|
363
|
+
registerFederatedTools(mockServer as any, {
|
|
364
|
+
codeIndexer: mockCodeIndexer as any,
|
|
365
|
+
gitHistoryIndexer: mockGitHistoryIndexer as any,
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
// Get the handler for contextual_search
|
|
369
|
+
const contextualSearchCall = mockServer.registerTool.mock.calls.find(
|
|
370
|
+
(call) => call[0] === "contextual_search",
|
|
371
|
+
);
|
|
372
|
+
expect(contextualSearchCall).toBeDefined();
|
|
373
|
+
|
|
374
|
+
const handler = contextualSearchCall![2];
|
|
375
|
+
const result = await handler({
|
|
376
|
+
path: "/test/repo",
|
|
377
|
+
query: "test function",
|
|
378
|
+
codeLimit: 5,
|
|
379
|
+
gitLimit: 5,
|
|
380
|
+
correlate: true,
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
expect(mockCodeIndexer.searchCode).toHaveBeenCalledWith(
|
|
384
|
+
"/test/repo",
|
|
385
|
+
"test function",
|
|
386
|
+
{ limit: 5 },
|
|
387
|
+
);
|
|
388
|
+
expect(mockGitHistoryIndexer.searchHistory).toHaveBeenCalledWith(
|
|
389
|
+
"/test/repo",
|
|
390
|
+
"test function",
|
|
391
|
+
{ limit: 5 },
|
|
392
|
+
);
|
|
393
|
+
expect(result.content[0].text).toContain("Code Results");
|
|
394
|
+
expect(result.content[0].text).toContain("Git History Results");
|
|
395
|
+
expect(result.content[0].text).toContain("Correlations");
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
it("should return error when code index not found", async () => {
|
|
399
|
+
mockCodeIndexer.getIndexStatus.mockResolvedValue({ status: "not_indexed" });
|
|
400
|
+
mockGitHistoryIndexer.getIndexStatus.mockResolvedValue({
|
|
401
|
+
status: "indexed",
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
const { registerFederatedTools } = await import("./federated.js");
|
|
405
|
+
const mockServer = { registerTool: vi.fn() };
|
|
406
|
+
|
|
407
|
+
registerFederatedTools(mockServer as any, {
|
|
408
|
+
codeIndexer: mockCodeIndexer as any,
|
|
409
|
+
gitHistoryIndexer: mockGitHistoryIndexer as any,
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
const contextualSearchCall = mockServer.registerTool.mock.calls.find(
|
|
413
|
+
(call) => call[0] === "contextual_search",
|
|
414
|
+
);
|
|
415
|
+
const handler = contextualSearchCall![2];
|
|
416
|
+
const result = await handler({
|
|
417
|
+
path: "/test/repo",
|
|
418
|
+
query: "test",
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
expect(result.isError).toBe(true);
|
|
422
|
+
expect(result.content[0].text).toContain("Code index not found");
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
it("should return error when git index not found", async () => {
|
|
426
|
+
mockCodeIndexer.getIndexStatus.mockResolvedValue({ status: "indexed" });
|
|
427
|
+
mockGitHistoryIndexer.getIndexStatus.mockResolvedValue({
|
|
428
|
+
status: "not_indexed",
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
const { registerFederatedTools } = await import("./federated.js");
|
|
432
|
+
const mockServer = { registerTool: vi.fn() };
|
|
433
|
+
|
|
434
|
+
registerFederatedTools(mockServer as any, {
|
|
435
|
+
codeIndexer: mockCodeIndexer as any,
|
|
436
|
+
gitHistoryIndexer: mockGitHistoryIndexer as any,
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
const contextualSearchCall = mockServer.registerTool.mock.calls.find(
|
|
440
|
+
(call) => call[0] === "contextual_search",
|
|
441
|
+
);
|
|
442
|
+
const handler = contextualSearchCall![2];
|
|
443
|
+
const result = await handler({
|
|
444
|
+
path: "/test/repo",
|
|
445
|
+
query: "test",
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
expect(result.isError).toBe(true);
|
|
449
|
+
expect(result.content[0].text).toContain("Git history index not found");
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
it("should skip correlations when correlate=false", async () => {
|
|
453
|
+
mockCodeIndexer.getIndexStatus.mockResolvedValue({ status: "indexed" });
|
|
454
|
+
mockGitHistoryIndexer.getIndexStatus.mockResolvedValue({
|
|
455
|
+
status: "indexed",
|
|
456
|
+
});
|
|
457
|
+
mockCodeIndexer.searchCode.mockResolvedValue([
|
|
458
|
+
{
|
|
459
|
+
content: "code",
|
|
460
|
+
filePath: "src/test.ts",
|
|
461
|
+
startLine: 1,
|
|
462
|
+
endLine: 5,
|
|
463
|
+
language: "typescript",
|
|
464
|
+
score: 0.9,
|
|
465
|
+
fileExtension: ".ts",
|
|
466
|
+
},
|
|
467
|
+
]);
|
|
468
|
+
mockGitHistoryIndexer.searchHistory.mockResolvedValue([
|
|
469
|
+
{
|
|
470
|
+
content: "commit",
|
|
471
|
+
commitHash: "abc123",
|
|
472
|
+
shortHash: "abc123d",
|
|
473
|
+
author: "Author",
|
|
474
|
+
date: "2024-01-15",
|
|
475
|
+
subject: "feat: test",
|
|
476
|
+
commitType: "feat",
|
|
477
|
+
files: ["src/test.ts"],
|
|
478
|
+
score: 0.85,
|
|
479
|
+
},
|
|
480
|
+
]);
|
|
481
|
+
|
|
482
|
+
const { registerFederatedTools } = await import("./federated.js");
|
|
483
|
+
const mockServer = { registerTool: vi.fn() };
|
|
484
|
+
|
|
485
|
+
registerFederatedTools(mockServer as any, {
|
|
486
|
+
codeIndexer: mockCodeIndexer as any,
|
|
487
|
+
gitHistoryIndexer: mockGitHistoryIndexer as any,
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
const contextualSearchCall = mockServer.registerTool.mock.calls.find(
|
|
491
|
+
(call) => call[0] === "contextual_search",
|
|
492
|
+
);
|
|
493
|
+
const handler = contextualSearchCall![2];
|
|
494
|
+
const result = await handler({
|
|
495
|
+
path: "/test/repo",
|
|
496
|
+
query: "test",
|
|
497
|
+
correlate: false,
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
expect(result.content[0].text).not.toContain("Correlations (Code");
|
|
501
|
+
expect(result.content[0].text).toContain("0 correlation(s)");
|
|
502
|
+
});
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
describe("federated_search integration", () => {
|
|
506
|
+
const mockCodeIndexer = {
|
|
507
|
+
getIndexStatus: vi.fn(),
|
|
508
|
+
searchCode: vi.fn(),
|
|
509
|
+
indexCodebase: vi.fn(),
|
|
510
|
+
reindexChanges: vi.fn(),
|
|
511
|
+
clearIndex: vi.fn(),
|
|
512
|
+
};
|
|
513
|
+
|
|
514
|
+
const mockGitHistoryIndexer = {
|
|
515
|
+
getIndexStatus: vi.fn(),
|
|
516
|
+
searchHistory: vi.fn(),
|
|
517
|
+
indexHistory: vi.fn(),
|
|
518
|
+
indexNewCommits: vi.fn(),
|
|
519
|
+
clearIndex: vi.fn(),
|
|
520
|
+
};
|
|
521
|
+
|
|
522
|
+
beforeEach(() => {
|
|
523
|
+
vi.resetAllMocks();
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
it("should search across multiple repositories", async () => {
|
|
527
|
+
mockCodeIndexer.getIndexStatus.mockResolvedValue({ status: "indexed" });
|
|
528
|
+
mockGitHistoryIndexer.getIndexStatus.mockResolvedValue({
|
|
529
|
+
status: "indexed",
|
|
530
|
+
});
|
|
531
|
+
mockCodeIndexer.searchCode.mockResolvedValue([
|
|
532
|
+
{
|
|
533
|
+
content: "function test() {}",
|
|
534
|
+
filePath: "src/test.ts",
|
|
535
|
+
startLine: 1,
|
|
536
|
+
endLine: 5,
|
|
537
|
+
language: "typescript",
|
|
538
|
+
score: 0.9,
|
|
539
|
+
fileExtension: ".ts",
|
|
540
|
+
},
|
|
541
|
+
]);
|
|
542
|
+
mockGitHistoryIndexer.searchHistory.mockResolvedValue([
|
|
543
|
+
{
|
|
544
|
+
content: "Commit",
|
|
545
|
+
commitHash: "abc123",
|
|
546
|
+
shortHash: "abc123d",
|
|
547
|
+
author: "Author",
|
|
548
|
+
date: "2024-01-15",
|
|
549
|
+
subject: "feat: add",
|
|
550
|
+
commitType: "feat",
|
|
551
|
+
files: ["src/test.ts"],
|
|
552
|
+
score: 0.85,
|
|
553
|
+
},
|
|
554
|
+
]);
|
|
555
|
+
|
|
556
|
+
const { registerFederatedTools } = await import("./federated.js");
|
|
557
|
+
const mockServer = { registerTool: vi.fn() };
|
|
558
|
+
|
|
559
|
+
registerFederatedTools(mockServer as any, {
|
|
560
|
+
codeIndexer: mockCodeIndexer as any,
|
|
561
|
+
gitHistoryIndexer: mockGitHistoryIndexer as any,
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
const federatedSearchCall = mockServer.registerTool.mock.calls.find(
|
|
565
|
+
(call) => call[0] === "federated_search",
|
|
566
|
+
);
|
|
567
|
+
const handler = federatedSearchCall![2];
|
|
568
|
+
const result = await handler({
|
|
569
|
+
paths: ["/repo1", "/repo2"],
|
|
570
|
+
query: "test function",
|
|
571
|
+
searchType: "both",
|
|
572
|
+
limit: 20,
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
// Each repo gets searched for code and git
|
|
576
|
+
expect(mockCodeIndexer.searchCode).toHaveBeenCalledTimes(2);
|
|
577
|
+
expect(mockGitHistoryIndexer.searchHistory).toHaveBeenCalledTimes(2);
|
|
578
|
+
expect(result.content[0].text).toContain("Federated Search Results");
|
|
579
|
+
expect(result.content[0].text).toContain("Repositories: 2");
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
it("should fail fast when any repository is not indexed", async () => {
|
|
583
|
+
mockCodeIndexer.getIndexStatus
|
|
584
|
+
.mockResolvedValueOnce({ status: "indexed" })
|
|
585
|
+
.mockResolvedValueOnce({ status: "not_indexed" });
|
|
586
|
+
mockGitHistoryIndexer.getIndexStatus.mockResolvedValue({
|
|
587
|
+
status: "indexed",
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
const { registerFederatedTools } = await import("./federated.js");
|
|
591
|
+
const mockServer = { registerTool: vi.fn() };
|
|
592
|
+
|
|
593
|
+
registerFederatedTools(mockServer as any, {
|
|
594
|
+
codeIndexer: mockCodeIndexer as any,
|
|
595
|
+
gitHistoryIndexer: mockGitHistoryIndexer as any,
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
const federatedSearchCall = mockServer.registerTool.mock.calls.find(
|
|
599
|
+
(call) => call[0] === "federated_search",
|
|
600
|
+
);
|
|
601
|
+
const handler = federatedSearchCall![2];
|
|
602
|
+
const result = await handler({
|
|
603
|
+
paths: ["/repo1", "/repo2"],
|
|
604
|
+
query: "test",
|
|
605
|
+
});
|
|
606
|
+
|
|
607
|
+
expect(result.isError).toBe(true);
|
|
608
|
+
expect(result.content[0].text).toContain("Index validation failed");
|
|
609
|
+
expect(result.content[0].text).toContain("Code index not found");
|
|
610
|
+
// Should not perform searches when validation fails
|
|
611
|
+
expect(mockCodeIndexer.searchCode).not.toHaveBeenCalled();
|
|
612
|
+
});
|
|
613
|
+
|
|
614
|
+
it("should respect searchType=code and only search code", async () => {
|
|
615
|
+
mockCodeIndexer.getIndexStatus.mockResolvedValue({ status: "indexed" });
|
|
616
|
+
mockCodeIndexer.searchCode.mockResolvedValue([]);
|
|
617
|
+
|
|
618
|
+
const { registerFederatedTools } = await import("./federated.js");
|
|
619
|
+
const mockServer = { registerTool: vi.fn() };
|
|
620
|
+
|
|
621
|
+
registerFederatedTools(mockServer as any, {
|
|
622
|
+
codeIndexer: mockCodeIndexer as any,
|
|
623
|
+
gitHistoryIndexer: mockGitHistoryIndexer as any,
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
const federatedSearchCall = mockServer.registerTool.mock.calls.find(
|
|
627
|
+
(call) => call[0] === "federated_search",
|
|
628
|
+
);
|
|
629
|
+
const handler = federatedSearchCall![2];
|
|
630
|
+
await handler({
|
|
631
|
+
paths: ["/repo1"],
|
|
632
|
+
query: "test",
|
|
633
|
+
searchType: "code",
|
|
634
|
+
});
|
|
635
|
+
|
|
636
|
+
expect(mockCodeIndexer.searchCode).toHaveBeenCalled();
|
|
637
|
+
expect(mockGitHistoryIndexer.searchHistory).not.toHaveBeenCalled();
|
|
638
|
+
});
|
|
639
|
+
|
|
640
|
+
it("should respect searchType=git and only search git history", async () => {
|
|
641
|
+
mockGitHistoryIndexer.getIndexStatus.mockResolvedValue({
|
|
642
|
+
status: "indexed",
|
|
643
|
+
});
|
|
644
|
+
mockGitHistoryIndexer.searchHistory.mockResolvedValue([]);
|
|
645
|
+
|
|
646
|
+
const { registerFederatedTools } = await import("./federated.js");
|
|
647
|
+
const mockServer = { registerTool: vi.fn() };
|
|
648
|
+
|
|
649
|
+
registerFederatedTools(mockServer as any, {
|
|
650
|
+
codeIndexer: mockCodeIndexer as any,
|
|
651
|
+
gitHistoryIndexer: mockGitHistoryIndexer as any,
|
|
652
|
+
});
|
|
653
|
+
|
|
654
|
+
const federatedSearchCall = mockServer.registerTool.mock.calls.find(
|
|
655
|
+
(call) => call[0] === "federated_search",
|
|
656
|
+
);
|
|
657
|
+
const handler = federatedSearchCall![2];
|
|
658
|
+
await handler({
|
|
659
|
+
paths: ["/repo1"],
|
|
660
|
+
query: "test",
|
|
661
|
+
searchType: "git",
|
|
662
|
+
});
|
|
663
|
+
|
|
664
|
+
expect(mockCodeIndexer.searchCode).not.toHaveBeenCalled();
|
|
665
|
+
expect(mockGitHistoryIndexer.searchHistory).toHaveBeenCalled();
|
|
666
|
+
});
|
|
667
|
+
|
|
668
|
+
it("should apply limit to combined results", async () => {
|
|
669
|
+
mockCodeIndexer.getIndexStatus.mockResolvedValue({ status: "indexed" });
|
|
670
|
+
mockGitHistoryIndexer.getIndexStatus.mockResolvedValue({
|
|
671
|
+
status: "indexed",
|
|
672
|
+
});
|
|
673
|
+
|
|
674
|
+
// Return many results from each search
|
|
675
|
+
const codeResults = Array(10)
|
|
676
|
+
.fill(null)
|
|
677
|
+
.map((_, i) => ({
|
|
678
|
+
content: `code ${i}`,
|
|
679
|
+
filePath: `src/file${i}.ts`,
|
|
680
|
+
startLine: 1,
|
|
681
|
+
endLine: 5,
|
|
682
|
+
language: "typescript",
|
|
683
|
+
score: 0.9 - i * 0.05,
|
|
684
|
+
fileExtension: ".ts",
|
|
685
|
+
}));
|
|
686
|
+
const gitResults = Array(10)
|
|
687
|
+
.fill(null)
|
|
688
|
+
.map((_, i) => ({
|
|
689
|
+
content: `commit ${i}`,
|
|
690
|
+
commitHash: `hash${i}`,
|
|
691
|
+
shortHash: `hash${i}`,
|
|
692
|
+
author: "Author",
|
|
693
|
+
date: "2024-01-15",
|
|
694
|
+
subject: `feat: commit ${i}`,
|
|
695
|
+
commitType: "feat",
|
|
696
|
+
files: [`src/file${i}.ts`],
|
|
697
|
+
score: 0.85 - i * 0.05,
|
|
698
|
+
}));
|
|
699
|
+
|
|
700
|
+
mockCodeIndexer.searchCode.mockResolvedValue(codeResults);
|
|
701
|
+
mockGitHistoryIndexer.searchHistory.mockResolvedValue(gitResults);
|
|
702
|
+
|
|
703
|
+
const { registerFederatedTools } = await import("./federated.js");
|
|
704
|
+
const mockServer = { registerTool: vi.fn() };
|
|
705
|
+
|
|
706
|
+
registerFederatedTools(mockServer as any, {
|
|
707
|
+
codeIndexer: mockCodeIndexer as any,
|
|
708
|
+
gitHistoryIndexer: mockGitHistoryIndexer as any,
|
|
709
|
+
});
|
|
710
|
+
|
|
711
|
+
const federatedSearchCall = mockServer.registerTool.mock.calls.find(
|
|
712
|
+
(call) => call[0] === "federated_search",
|
|
713
|
+
);
|
|
714
|
+
const handler = federatedSearchCall![2];
|
|
715
|
+
const result = await handler({
|
|
716
|
+
paths: ["/repo1"],
|
|
717
|
+
query: "test",
|
|
718
|
+
limit: 5,
|
|
719
|
+
});
|
|
720
|
+
|
|
721
|
+
// Should limit total results
|
|
722
|
+
expect(result.content[0].text).toContain("Total: 5 result(s)");
|
|
723
|
+
});
|
|
724
|
+
|
|
725
|
+
it("should return message when no results found", async () => {
|
|
726
|
+
mockCodeIndexer.getIndexStatus.mockResolvedValue({ status: "indexed" });
|
|
727
|
+
mockGitHistoryIndexer.getIndexStatus.mockResolvedValue({
|
|
728
|
+
status: "indexed",
|
|
729
|
+
});
|
|
730
|
+
mockCodeIndexer.searchCode.mockResolvedValue([]);
|
|
731
|
+
mockGitHistoryIndexer.searchHistory.mockResolvedValue([]);
|
|
732
|
+
|
|
733
|
+
const { registerFederatedTools } = await import("./federated.js");
|
|
734
|
+
const mockServer = { registerTool: vi.fn() };
|
|
735
|
+
|
|
736
|
+
registerFederatedTools(mockServer as any, {
|
|
737
|
+
codeIndexer: mockCodeIndexer as any,
|
|
738
|
+
gitHistoryIndexer: mockGitHistoryIndexer as any,
|
|
739
|
+
});
|
|
740
|
+
|
|
741
|
+
const federatedSearchCall = mockServer.registerTool.mock.calls.find(
|
|
742
|
+
(call) => call[0] === "federated_search",
|
|
743
|
+
);
|
|
744
|
+
const handler = federatedSearchCall![2];
|
|
745
|
+
const result = await handler({
|
|
746
|
+
paths: ["/repo1"],
|
|
747
|
+
query: "nonexistent query",
|
|
748
|
+
});
|
|
749
|
+
|
|
750
|
+
expect(result.content[0].text).toContain("No results found");
|
|
751
|
+
});
|
|
752
|
+
});
|