@mhalder/qdrant-mcp-server 1.4.0 → 1.6.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/.codecov.yml +16 -0
- package/.github/workflows/claude-code-review.yml +6 -5
- package/.releaserc.json +8 -1
- package/CHANGELOG.md +34 -0
- package/README.md +259 -9
- package/build/code/chunker/base.d.ts +19 -0
- package/build/code/chunker/base.d.ts.map +1 -0
- package/build/code/chunker/base.js +5 -0
- package/build/code/chunker/base.js.map +1 -0
- package/build/code/chunker/character-chunker.d.ts +22 -0
- package/build/code/chunker/character-chunker.d.ts.map +1 -0
- package/build/code/chunker/character-chunker.js +111 -0
- package/build/code/chunker/character-chunker.js.map +1 -0
- package/build/code/chunker/tree-sitter-chunker.d.ts +29 -0
- package/build/code/chunker/tree-sitter-chunker.d.ts.map +1 -0
- package/build/code/chunker/tree-sitter-chunker.js +213 -0
- package/build/code/chunker/tree-sitter-chunker.js.map +1 -0
- package/build/code/config.d.ts +11 -0
- package/build/code/config.d.ts.map +1 -0
- package/build/code/config.js +145 -0
- package/build/code/config.js.map +1 -0
- package/build/code/indexer.d.ts +42 -0
- package/build/code/indexer.d.ts.map +1 -0
- package/build/code/indexer.js +508 -0
- package/build/code/indexer.js.map +1 -0
- package/build/code/metadata.d.ts +32 -0
- package/build/code/metadata.d.ts.map +1 -0
- package/build/code/metadata.js +128 -0
- package/build/code/metadata.js.map +1 -0
- package/build/code/scanner.d.ts +35 -0
- package/build/code/scanner.d.ts.map +1 -0
- package/build/code/scanner.js +108 -0
- package/build/code/scanner.js.map +1 -0
- package/build/code/sync/merkle.d.ts +45 -0
- package/build/code/sync/merkle.d.ts.map +1 -0
- package/build/code/sync/merkle.js +116 -0
- package/build/code/sync/merkle.js.map +1 -0
- package/build/code/sync/snapshot.d.ts +41 -0
- package/build/code/sync/snapshot.d.ts.map +1 -0
- package/build/code/sync/snapshot.js +91 -0
- package/build/code/sync/snapshot.js.map +1 -0
- package/build/code/sync/synchronizer.d.ts +53 -0
- package/build/code/sync/synchronizer.d.ts.map +1 -0
- package/build/code/sync/synchronizer.js +132 -0
- package/build/code/sync/synchronizer.js.map +1 -0
- package/build/code/types.d.ts +98 -0
- package/build/code/types.d.ts.map +1 -0
- package/build/code/types.js +5 -0
- package/build/code/types.js.map +1 -0
- package/build/index.js +252 -1
- package/build/index.js.map +1 -1
- package/build/qdrant/client.d.ts +1 -1
- package/build/qdrant/client.d.ts.map +1 -1
- package/build/qdrant/client.js +2 -2
- package/build/qdrant/client.js.map +1 -1
- package/build/qdrant/client.test.js +16 -0
- package/build/qdrant/client.test.js.map +1 -1
- package/examples/code-search/README.md +271 -0
- package/package.json +15 -2
- package/src/code/chunker/base.ts +22 -0
- package/src/code/chunker/character-chunker.ts +131 -0
- package/src/code/chunker/tree-sitter-chunker.ts +250 -0
- package/src/code/config.ts +156 -0
- package/src/code/indexer.ts +613 -0
- package/src/code/metadata.ts +153 -0
- package/src/code/scanner.ts +124 -0
- package/src/code/sync/merkle.ts +136 -0
- package/src/code/sync/snapshot.ts +110 -0
- package/src/code/sync/synchronizer.ts +154 -0
- package/src/code/types.ts +117 -0
- package/src/index.ts +298 -1
- package/src/qdrant/client.test.ts +20 -0
- package/src/qdrant/client.ts +2 -2
- package/tests/code/chunker/character-chunker.test.ts +141 -0
- package/tests/code/chunker/tree-sitter-chunker.test.ts +275 -0
- package/tests/code/fixtures/sample-py/calculator.py +32 -0
- package/tests/code/fixtures/sample-ts/async-operations.ts +120 -0
- package/tests/code/fixtures/sample-ts/auth.ts +31 -0
- package/tests/code/fixtures/sample-ts/config.ts +52 -0
- package/tests/code/fixtures/sample-ts/database.ts +50 -0
- package/tests/code/fixtures/sample-ts/index.ts +39 -0
- package/tests/code/fixtures/sample-ts/types-advanced.ts +132 -0
- package/tests/code/fixtures/sample-ts/utils.ts +105 -0
- package/tests/code/fixtures/sample-ts/validator.ts +169 -0
- package/tests/code/indexer.test.ts +828 -0
- package/tests/code/integration.test.ts +708 -0
- package/tests/code/metadata.test.ts +457 -0
- package/tests/code/scanner.test.ts +131 -0
- package/tests/code/sync/merkle.test.ts +406 -0
- package/tests/code/sync/snapshot.test.ts +360 -0
- package/tests/code/sync/synchronizer.test.ts +501 -0
- package/vitest.config.ts +1 -0
|
@@ -0,0 +1,828 @@
|
|
|
1
|
+
import { promises as fs } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import { CodeIndexer } from "../../src/code/indexer.js";
|
|
6
|
+
import type { CodeConfig } from "../../src/code/types.js";
|
|
7
|
+
import type { EmbeddingProvider } from "../../src/embeddings/base.js";
|
|
8
|
+
import type { QdrantManager } from "../../src/qdrant/client.js";
|
|
9
|
+
|
|
10
|
+
// Mock implementations
|
|
11
|
+
class MockQdrantManager implements Partial<QdrantManager> {
|
|
12
|
+
private collections = new Map<string, any>();
|
|
13
|
+
private points = new Map<string, any[]>();
|
|
14
|
+
|
|
15
|
+
async collectionExists(name: string): Promise<boolean> {
|
|
16
|
+
return this.collections.has(name);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async createCollection(
|
|
20
|
+
name: string,
|
|
21
|
+
_vectorSize: number,
|
|
22
|
+
_distance: string,
|
|
23
|
+
_enableHybrid?: boolean
|
|
24
|
+
): Promise<void> {
|
|
25
|
+
this.collections.set(name, {
|
|
26
|
+
vectorSize: _vectorSize,
|
|
27
|
+
hybridEnabled: _enableHybrid || false,
|
|
28
|
+
});
|
|
29
|
+
this.points.set(name, []);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async deleteCollection(name: string): Promise<void> {
|
|
33
|
+
this.collections.delete(name);
|
|
34
|
+
this.points.delete(name);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async addPoints(collectionName: string, points: any[]): Promise<void> {
|
|
38
|
+
const existing = this.points.get(collectionName) || [];
|
|
39
|
+
this.points.set(collectionName, [...existing, ...points]);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async addPointsWithSparse(collectionName: string, points: any[]): Promise<void> {
|
|
43
|
+
await this.addPoints(collectionName, points);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async search(
|
|
47
|
+
collectionName: string,
|
|
48
|
+
_vector: number[],
|
|
49
|
+
limit: number,
|
|
50
|
+
_filter?: any
|
|
51
|
+
): Promise<any[]> {
|
|
52
|
+
const points = this.points.get(collectionName) || [];
|
|
53
|
+
return points.slice(0, limit).map((p, idx) => ({
|
|
54
|
+
id: p.id,
|
|
55
|
+
score: 0.9 - idx * 0.1,
|
|
56
|
+
payload: p.payload,
|
|
57
|
+
}));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async hybridSearch(
|
|
61
|
+
collectionName: string,
|
|
62
|
+
_vector: number[],
|
|
63
|
+
_sparseVector: any,
|
|
64
|
+
limit: number,
|
|
65
|
+
_filter?: any
|
|
66
|
+
): Promise<any[]> {
|
|
67
|
+
return this.search(collectionName, _vector, limit, _filter);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async getCollectionInfo(name: string): Promise<any> {
|
|
71
|
+
const collection = this.collections.get(name);
|
|
72
|
+
const points = this.points.get(name) || [];
|
|
73
|
+
return {
|
|
74
|
+
pointsCount: points.length,
|
|
75
|
+
hybridEnabled: collection?.hybridEnabled || false,
|
|
76
|
+
vectorSize: collection?.vectorSize || 384,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
class MockEmbeddingProvider implements EmbeddingProvider {
|
|
82
|
+
getDimensions(): number {
|
|
83
|
+
return 384;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async embed(_text: string): Promise<{ embedding: number[] }> {
|
|
87
|
+
return { embedding: new Array(384).fill(0.1) };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async embedBatch(texts: string[]): Promise<Array<{ embedding: number[] }>> {
|
|
91
|
+
return texts.map(() => ({ embedding: new Array(384).fill(0.1) }));
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
describe("CodeIndexer", () => {
|
|
96
|
+
let indexer: CodeIndexer;
|
|
97
|
+
let qdrant: MockQdrantManager;
|
|
98
|
+
let embeddings: MockEmbeddingProvider;
|
|
99
|
+
let config: CodeConfig;
|
|
100
|
+
let tempDir: string;
|
|
101
|
+
let codebaseDir: string;
|
|
102
|
+
|
|
103
|
+
beforeEach(async () => {
|
|
104
|
+
// Create temporary test directory
|
|
105
|
+
tempDir = join(
|
|
106
|
+
tmpdir(),
|
|
107
|
+
`qdrant-mcp-test-${Date.now()}-${Math.random().toString(36).substring(7)}`
|
|
108
|
+
);
|
|
109
|
+
codebaseDir = join(tempDir, "codebase");
|
|
110
|
+
await fs.mkdir(codebaseDir, { recursive: true });
|
|
111
|
+
|
|
112
|
+
// Initialize mocks and config
|
|
113
|
+
qdrant = new MockQdrantManager() as any;
|
|
114
|
+
embeddings = new MockEmbeddingProvider();
|
|
115
|
+
config = {
|
|
116
|
+
chunkSize: 500,
|
|
117
|
+
chunkOverlap: 50,
|
|
118
|
+
enableASTChunking: true,
|
|
119
|
+
supportedExtensions: [".ts", ".js", ".py"],
|
|
120
|
+
ignorePatterns: ["node_modules/**", "dist/**"],
|
|
121
|
+
batchSize: 10,
|
|
122
|
+
defaultSearchLimit: 5,
|
|
123
|
+
enableHybridSearch: false,
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
indexer = new CodeIndexer(qdrant as any, embeddings, config);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
afterEach(async () => {
|
|
130
|
+
try {
|
|
131
|
+
await fs.rm(tempDir, { recursive: true, force: true });
|
|
132
|
+
} catch (_error) {
|
|
133
|
+
// Ignore cleanup errors
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
describe("indexCodebase", () => {
|
|
138
|
+
it("should index a simple codebase", async () => {
|
|
139
|
+
await createTestFile(
|
|
140
|
+
codebaseDir,
|
|
141
|
+
"hello.ts",
|
|
142
|
+
'export function hello(name: string): string {\n console.log("Greeting user");\n return `Hello, ${name}!`;\n}'
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
const stats = await indexer.indexCodebase(codebaseDir);
|
|
146
|
+
|
|
147
|
+
expect(stats.filesScanned).toBe(1);
|
|
148
|
+
expect(stats.filesIndexed).toBe(1);
|
|
149
|
+
expect(stats.chunksCreated).toBeGreaterThan(0);
|
|
150
|
+
expect(stats.status).toBe("completed");
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("should handle empty directory", async () => {
|
|
154
|
+
const stats = await indexer.indexCodebase(codebaseDir);
|
|
155
|
+
|
|
156
|
+
expect(stats.filesScanned).toBe(0);
|
|
157
|
+
expect(stats.chunksCreated).toBe(0);
|
|
158
|
+
expect(stats.status).toBe("completed");
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("should index multiple files", async () => {
|
|
162
|
+
await createTestFile(codebaseDir, "file1.ts", "function test1() {}");
|
|
163
|
+
await createTestFile(codebaseDir, "file2.js", "function test2() {}");
|
|
164
|
+
await createTestFile(codebaseDir, "file3.py", "def test3(): pass");
|
|
165
|
+
|
|
166
|
+
const stats = await indexer.indexCodebase(codebaseDir);
|
|
167
|
+
|
|
168
|
+
expect(stats.filesScanned).toBe(3);
|
|
169
|
+
expect(stats.filesIndexed).toBe(3);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("should create collection with correct settings", async () => {
|
|
173
|
+
await createTestFile(
|
|
174
|
+
codebaseDir,
|
|
175
|
+
"test.ts",
|
|
176
|
+
"export const configuration = {\n apiKey: process.env.API_KEY,\n timeout: 5000\n};"
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
const createCollectionSpy = vi.spyOn(qdrant, "createCollection");
|
|
180
|
+
|
|
181
|
+
await indexer.indexCodebase(codebaseDir);
|
|
182
|
+
|
|
183
|
+
expect(createCollectionSpy).toHaveBeenCalledWith(
|
|
184
|
+
expect.stringContaining("code_"),
|
|
185
|
+
384,
|
|
186
|
+
"Cosine",
|
|
187
|
+
false
|
|
188
|
+
);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it("should force re-index when option is set", async () => {
|
|
192
|
+
await createTestFile(
|
|
193
|
+
codebaseDir,
|
|
194
|
+
"test.ts",
|
|
195
|
+
"export function calculateTotal(items: number[]): number {\n return items.reduce((sum, item) => sum + item, 0);\n}"
|
|
196
|
+
);
|
|
197
|
+
|
|
198
|
+
await indexer.indexCodebase(codebaseDir);
|
|
199
|
+
const deleteCollectionSpy = vi.spyOn(qdrant, "deleteCollection");
|
|
200
|
+
|
|
201
|
+
await indexer.indexCodebase(codebaseDir, { forceReindex: true });
|
|
202
|
+
|
|
203
|
+
expect(deleteCollectionSpy).toHaveBeenCalled();
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("should call progress callback", async () => {
|
|
207
|
+
await createTestFile(
|
|
208
|
+
codebaseDir,
|
|
209
|
+
"test.ts",
|
|
210
|
+
"export interface User {\n id: string;\n name: string;\n email: string;\n}"
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
const progressCallback = vi.fn();
|
|
214
|
+
|
|
215
|
+
await indexer.indexCodebase(codebaseDir, undefined, progressCallback);
|
|
216
|
+
|
|
217
|
+
expect(progressCallback).toHaveBeenCalled();
|
|
218
|
+
expect(progressCallback.mock.calls.some((call) => call[0].phase === "scanning")).toBe(true);
|
|
219
|
+
expect(progressCallback.mock.calls.some((call) => call[0].phase === "chunking")).toBe(true);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it("should respect custom extensions", async () => {
|
|
223
|
+
await createTestFile(codebaseDir, "test.ts", "const x = 1;");
|
|
224
|
+
await createTestFile(codebaseDir, "test.md", "# Documentation");
|
|
225
|
+
|
|
226
|
+
const stats = await indexer.indexCodebase(codebaseDir, {
|
|
227
|
+
extensions: [".md"],
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
expect(stats.filesScanned).toBe(1);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it("should handle files with secrets gracefully", async () => {
|
|
234
|
+
await createTestFile(
|
|
235
|
+
codebaseDir,
|
|
236
|
+
"secrets.ts",
|
|
237
|
+
'const apiKey = "sk_test_FAKE_KEY_FOR_TESTING_ONLY_NOT_REAL";'
|
|
238
|
+
);
|
|
239
|
+
|
|
240
|
+
const stats = await indexer.indexCodebase(codebaseDir);
|
|
241
|
+
|
|
242
|
+
expect(stats.errors).toBeDefined();
|
|
243
|
+
expect(stats.errors?.some((e) => e.includes("secrets"))).toBe(true);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it("should enable hybrid search when configured", async () => {
|
|
247
|
+
const hybridConfig = { ...config, enableHybridSearch: true };
|
|
248
|
+
const hybridIndexer = new CodeIndexer(qdrant as any, embeddings, hybridConfig);
|
|
249
|
+
|
|
250
|
+
await createTestFile(
|
|
251
|
+
codebaseDir,
|
|
252
|
+
"test.ts",
|
|
253
|
+
"export class DataService {\n async fetchData(): Promise<any[]> {\n return [];\n }\n}"
|
|
254
|
+
);
|
|
255
|
+
|
|
256
|
+
const createCollectionSpy = vi.spyOn(qdrant, "createCollection");
|
|
257
|
+
|
|
258
|
+
await hybridIndexer.indexCodebase(codebaseDir);
|
|
259
|
+
|
|
260
|
+
expect(createCollectionSpy).toHaveBeenCalledWith(
|
|
261
|
+
expect.stringContaining("code_"),
|
|
262
|
+
384,
|
|
263
|
+
"Cosine",
|
|
264
|
+
true
|
|
265
|
+
);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it("should handle file read errors", async () => {
|
|
269
|
+
await createTestFile(codebaseDir, "test.ts", "content");
|
|
270
|
+
|
|
271
|
+
// Mock fs.readFile to throw error for this specific file
|
|
272
|
+
const originalReadFile = fs.readFile;
|
|
273
|
+
vi.spyOn(fs, "readFile").mockImplementation(async (path: any, ...args: any[]) => {
|
|
274
|
+
if (path.includes("test.ts")) {
|
|
275
|
+
throw new Error("Permission denied");
|
|
276
|
+
}
|
|
277
|
+
return originalReadFile(path, ...args);
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
const stats = await indexer.indexCodebase(codebaseDir);
|
|
281
|
+
|
|
282
|
+
expect(stats.errors?.some((e) => e.includes("Permission denied"))).toBe(true);
|
|
283
|
+
|
|
284
|
+
vi.restoreAllMocks();
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it("should batch embed operations", async () => {
|
|
288
|
+
// Create multiple files to trigger batching
|
|
289
|
+
for (let i = 0; i < 5; i++) {
|
|
290
|
+
await createTestFile(
|
|
291
|
+
codebaseDir,
|
|
292
|
+
`file${i}.ts`,
|
|
293
|
+
`export function test${i}() {\n console.log('Test function ${i}');\n return ${i};\n}`
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const embedBatchSpy = vi.spyOn(embeddings, "embedBatch");
|
|
298
|
+
|
|
299
|
+
await indexer.indexCodebase(codebaseDir);
|
|
300
|
+
|
|
301
|
+
expect(embedBatchSpy).toHaveBeenCalled();
|
|
302
|
+
});
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
describe("searchCode", () => {
|
|
306
|
+
beforeEach(async () => {
|
|
307
|
+
await createTestFile(
|
|
308
|
+
codebaseDir,
|
|
309
|
+
"test.ts",
|
|
310
|
+
"export function hello(name: string): string {\n const greeting = `Hello, ${name}!`;\n return greeting;\n}"
|
|
311
|
+
);
|
|
312
|
+
await indexer.indexCodebase(codebaseDir);
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it("should search indexed codebase", async () => {
|
|
316
|
+
const results = await indexer.searchCode(codebaseDir, "hello function");
|
|
317
|
+
|
|
318
|
+
expect(Array.isArray(results)).toBe(true);
|
|
319
|
+
expect(results.length).toBeGreaterThan(0);
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
it("should throw error for non-indexed codebase", async () => {
|
|
323
|
+
const nonIndexedDir = join(tempDir, "non-indexed");
|
|
324
|
+
await fs.mkdir(nonIndexedDir, { recursive: true });
|
|
325
|
+
|
|
326
|
+
await expect(indexer.searchCode(nonIndexedDir, "test")).rejects.toThrow("not indexed");
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it("should respect limit option", async () => {
|
|
330
|
+
const results = await indexer.searchCode(codebaseDir, "test", { limit: 2 });
|
|
331
|
+
|
|
332
|
+
expect(results.length).toBeLessThanOrEqual(2);
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
it("should apply score threshold", async () => {
|
|
336
|
+
const results = await indexer.searchCode(codebaseDir, "test", {
|
|
337
|
+
scoreThreshold: 0.95,
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
results.forEach((r) => {
|
|
341
|
+
expect(r.score).toBeGreaterThanOrEqual(0.95);
|
|
342
|
+
});
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
it("should filter by file types", async () => {
|
|
346
|
+
await createTestFile(codebaseDir, "test.py", "def test(): pass");
|
|
347
|
+
await indexer.indexCodebase(codebaseDir, { forceReindex: true });
|
|
348
|
+
|
|
349
|
+
const results = await indexer.searchCode(codebaseDir, "test", {
|
|
350
|
+
fileTypes: [".py"],
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
// Filter should be applied (even if mock doesn't filter properly)
|
|
354
|
+
expect(Array.isArray(results)).toBe(true);
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
it("should use hybrid search when enabled", async () => {
|
|
358
|
+
const hybridConfig = { ...config, enableHybridSearch: true };
|
|
359
|
+
const hybridIndexer = new CodeIndexer(qdrant as any, embeddings, hybridConfig);
|
|
360
|
+
|
|
361
|
+
await createTestFile(
|
|
362
|
+
codebaseDir,
|
|
363
|
+
"test.ts",
|
|
364
|
+
`export function testFunction(): boolean {
|
|
365
|
+
console.log('Running comprehensive test suite');
|
|
366
|
+
const result = performValidation();
|
|
367
|
+
const status = checkStatus();
|
|
368
|
+
return result && status;
|
|
369
|
+
}
|
|
370
|
+
function performValidation(): boolean {
|
|
371
|
+
console.log('Validating data');
|
|
372
|
+
return true;
|
|
373
|
+
}
|
|
374
|
+
function checkStatus(): boolean {
|
|
375
|
+
return true;
|
|
376
|
+
}`
|
|
377
|
+
);
|
|
378
|
+
// Force reindex to recreate collection with hybrid search enabled
|
|
379
|
+
await hybridIndexer.indexCodebase(codebaseDir, { forceReindex: true });
|
|
380
|
+
|
|
381
|
+
const hybridSearchSpy = vi.spyOn(qdrant, "hybridSearch");
|
|
382
|
+
|
|
383
|
+
await hybridIndexer.searchCode(codebaseDir, "test");
|
|
384
|
+
|
|
385
|
+
expect(hybridSearchSpy).toHaveBeenCalled();
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
it("should format results correctly", async () => {
|
|
389
|
+
const results = await indexer.searchCode(codebaseDir, "hello");
|
|
390
|
+
|
|
391
|
+
results.forEach((result) => {
|
|
392
|
+
expect(result).toHaveProperty("content");
|
|
393
|
+
expect(result).toHaveProperty("filePath");
|
|
394
|
+
expect(result).toHaveProperty("startLine");
|
|
395
|
+
expect(result).toHaveProperty("endLine");
|
|
396
|
+
expect(result).toHaveProperty("language");
|
|
397
|
+
expect(result).toHaveProperty("score");
|
|
398
|
+
});
|
|
399
|
+
});
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
describe("getIndexStatus", () => {
|
|
403
|
+
it("should return not indexed for new codebase", async () => {
|
|
404
|
+
const status = await indexer.getIndexStatus(codebaseDir);
|
|
405
|
+
|
|
406
|
+
expect(status.isIndexed).toBe(false);
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
it("should return indexed status after indexing", async () => {
|
|
410
|
+
await createTestFile(
|
|
411
|
+
codebaseDir,
|
|
412
|
+
"test.ts",
|
|
413
|
+
"export const APP_CONFIG = {\n port: 3000,\n host: 'localhost',\n debug: true,\n apiUrl: 'https://api.example.com',\n timeout: 5000\n};\nconsole.log('Config loaded');"
|
|
414
|
+
);
|
|
415
|
+
await indexer.indexCodebase(codebaseDir);
|
|
416
|
+
|
|
417
|
+
const status = await indexer.getIndexStatus(codebaseDir);
|
|
418
|
+
|
|
419
|
+
expect(status.isIndexed).toBe(true);
|
|
420
|
+
expect(status.collectionName).toBeDefined();
|
|
421
|
+
expect(status.chunksCount).toBeGreaterThan(0);
|
|
422
|
+
});
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
describe("reindexChanges", () => {
|
|
426
|
+
it("should throw error if not previously indexed", async () => {
|
|
427
|
+
await expect(indexer.reindexChanges(codebaseDir)).rejects.toThrow("not indexed");
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
it("should detect and index new files", async () => {
|
|
431
|
+
await createTestFile(
|
|
432
|
+
codebaseDir,
|
|
433
|
+
"file1.ts",
|
|
434
|
+
`export const initialValue = 1;
|
|
435
|
+
console.log('Initial file created');
|
|
436
|
+
function helper(param: string): boolean {
|
|
437
|
+
console.log('Processing:', param);
|
|
438
|
+
return true;
|
|
439
|
+
}`
|
|
440
|
+
);
|
|
441
|
+
await indexer.indexCodebase(codebaseDir);
|
|
442
|
+
|
|
443
|
+
await createTestFile(
|
|
444
|
+
codebaseDir,
|
|
445
|
+
"file2.ts",
|
|
446
|
+
[
|
|
447
|
+
"export function process(data: number): number {",
|
|
448
|
+
" console.log('Processing data with value:', data);",
|
|
449
|
+
" const multiplier = 42;",
|
|
450
|
+
" const result = data * multiplier;",
|
|
451
|
+
" console.log('Computed result:', result);",
|
|
452
|
+
" if (result > 100) {",
|
|
453
|
+
" console.log('Result is large');",
|
|
454
|
+
" }",
|
|
455
|
+
" return result;",
|
|
456
|
+
"}",
|
|
457
|
+
"",
|
|
458
|
+
"export function validate(input: string): boolean {",
|
|
459
|
+
" if (!input || input.length === 0) {",
|
|
460
|
+
" console.log('Invalid input');",
|
|
461
|
+
" return false;",
|
|
462
|
+
" }",
|
|
463
|
+
" console.log('Valid input');",
|
|
464
|
+
" return input.length > 5;",
|
|
465
|
+
"}",
|
|
466
|
+
].join("\n")
|
|
467
|
+
);
|
|
468
|
+
|
|
469
|
+
const stats = await indexer.reindexChanges(codebaseDir);
|
|
470
|
+
|
|
471
|
+
expect(stats.filesAdded).toBe(1);
|
|
472
|
+
expect(stats.chunksAdded).toBeGreaterThan(0);
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
it("should detect modified files", async () => {
|
|
476
|
+
await createTestFile(
|
|
477
|
+
codebaseDir,
|
|
478
|
+
"test.ts",
|
|
479
|
+
"export const originalValue = 1;\nconsole.log('Original');"
|
|
480
|
+
);
|
|
481
|
+
await indexer.indexCodebase(codebaseDir);
|
|
482
|
+
|
|
483
|
+
await createTestFile(
|
|
484
|
+
codebaseDir,
|
|
485
|
+
"test.ts",
|
|
486
|
+
"export const updatedValue = 2;\nconsole.log('Updated');"
|
|
487
|
+
);
|
|
488
|
+
|
|
489
|
+
const stats = await indexer.reindexChanges(codebaseDir);
|
|
490
|
+
|
|
491
|
+
expect(stats.filesModified).toBe(1);
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
it("should detect deleted files", async () => {
|
|
495
|
+
await createTestFile(
|
|
496
|
+
codebaseDir,
|
|
497
|
+
"test.ts",
|
|
498
|
+
"export const toBeDeleted = 1;\nconsole.log('Will be deleted');"
|
|
499
|
+
);
|
|
500
|
+
await indexer.indexCodebase(codebaseDir);
|
|
501
|
+
|
|
502
|
+
await fs.unlink(join(codebaseDir, "test.ts"));
|
|
503
|
+
|
|
504
|
+
const stats = await indexer.reindexChanges(codebaseDir);
|
|
505
|
+
|
|
506
|
+
expect(stats.filesDeleted).toBe(1);
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
it("should handle no changes", async () => {
|
|
510
|
+
await createTestFile(
|
|
511
|
+
codebaseDir,
|
|
512
|
+
"test.ts",
|
|
513
|
+
"export const unchangedValue = 1;\nconsole.log('No changes');"
|
|
514
|
+
);
|
|
515
|
+
await indexer.indexCodebase(codebaseDir);
|
|
516
|
+
|
|
517
|
+
const stats = await indexer.reindexChanges(codebaseDir);
|
|
518
|
+
|
|
519
|
+
expect(stats.filesAdded).toBe(0);
|
|
520
|
+
expect(stats.filesModified).toBe(0);
|
|
521
|
+
expect(stats.filesDeleted).toBe(0);
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
it("should call progress callback during reindexing", async () => {
|
|
525
|
+
await createTestFile(
|
|
526
|
+
codebaseDir,
|
|
527
|
+
"test.ts",
|
|
528
|
+
"export const existingValue = 1;\nconsole.log('Existing');"
|
|
529
|
+
);
|
|
530
|
+
await indexer.indexCodebase(codebaseDir);
|
|
531
|
+
|
|
532
|
+
await createTestFile(
|
|
533
|
+
codebaseDir,
|
|
534
|
+
"new.ts",
|
|
535
|
+
"export const newValue = 2;\nconsole.log('New file');"
|
|
536
|
+
);
|
|
537
|
+
|
|
538
|
+
const progressCallback = vi.fn();
|
|
539
|
+
await indexer.reindexChanges(codebaseDir, progressCallback);
|
|
540
|
+
|
|
541
|
+
expect(progressCallback).toHaveBeenCalled();
|
|
542
|
+
});
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
describe("path validation", () => {
|
|
546
|
+
it("should handle non-existent paths gracefully", async () => {
|
|
547
|
+
// Create a path that doesn't exist yet
|
|
548
|
+
const nonExistentDir = join(codebaseDir, "non-existent-dir");
|
|
549
|
+
|
|
550
|
+
// Should not throw error, validatePath falls back to absolute path
|
|
551
|
+
// and scanner finds 0 files
|
|
552
|
+
const stats = await indexer.indexCodebase(nonExistentDir);
|
|
553
|
+
expect(stats.filesScanned).toBe(0);
|
|
554
|
+
expect(stats.status).toBe("completed");
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
it("should resolve real paths for existing directories", async () => {
|
|
558
|
+
await createTestFile(codebaseDir, "test.ts", "export const test = true;");
|
|
559
|
+
|
|
560
|
+
// Should successfully index with real path
|
|
561
|
+
const stats = await indexer.indexCodebase(codebaseDir);
|
|
562
|
+
expect(stats.filesScanned).toBeGreaterThan(0);
|
|
563
|
+
});
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
describe("clearIndex", () => {
|
|
567
|
+
it("should clear indexed codebase", async () => {
|
|
568
|
+
await createTestFile(
|
|
569
|
+
codebaseDir,
|
|
570
|
+
"test.ts",
|
|
571
|
+
"export const configValue = 1;\nconsole.log('Config loaded');"
|
|
572
|
+
);
|
|
573
|
+
await indexer.indexCodebase(codebaseDir);
|
|
574
|
+
|
|
575
|
+
await indexer.clearIndex(codebaseDir);
|
|
576
|
+
|
|
577
|
+
const status = await indexer.getIndexStatus(codebaseDir);
|
|
578
|
+
expect(status.isIndexed).toBe(false);
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
it("should handle clearing non-indexed codebase", async () => {
|
|
582
|
+
await expect(indexer.clearIndex(codebaseDir)).resolves.not.toThrow();
|
|
583
|
+
});
|
|
584
|
+
|
|
585
|
+
it("should allow re-indexing after clearing", async () => {
|
|
586
|
+
await createTestFile(
|
|
587
|
+
codebaseDir,
|
|
588
|
+
"test.ts",
|
|
589
|
+
"export const reindexValue = 1;\nconsole.log('Reindexing');"
|
|
590
|
+
);
|
|
591
|
+
await indexer.indexCodebase(codebaseDir);
|
|
592
|
+
|
|
593
|
+
await indexer.clearIndex(codebaseDir);
|
|
594
|
+
|
|
595
|
+
const stats = await indexer.indexCodebase(codebaseDir);
|
|
596
|
+
expect(stats.status).toBe("completed");
|
|
597
|
+
});
|
|
598
|
+
});
|
|
599
|
+
|
|
600
|
+
describe("edge cases", () => {
|
|
601
|
+
it("should handle nested directory structures", async () => {
|
|
602
|
+
await fs.mkdir(join(codebaseDir, "src", "components"), { recursive: true });
|
|
603
|
+
await createTestFile(
|
|
604
|
+
codebaseDir,
|
|
605
|
+
"src/components/Button.ts",
|
|
606
|
+
`export const Button = () => {
|
|
607
|
+
console.log('Button component rendering');
|
|
608
|
+
const handleClick = () => {
|
|
609
|
+
console.log('Button clicked');
|
|
610
|
+
};
|
|
611
|
+
return '<button>Click me</button>';
|
|
612
|
+
}`
|
|
613
|
+
);
|
|
614
|
+
|
|
615
|
+
const stats = await indexer.indexCodebase(codebaseDir);
|
|
616
|
+
|
|
617
|
+
expect(stats.filesIndexed).toBe(1);
|
|
618
|
+
});
|
|
619
|
+
|
|
620
|
+
it("should handle files with unicode content", async () => {
|
|
621
|
+
await createTestFile(codebaseDir, "test.ts", "const greeting = '你好世界';");
|
|
622
|
+
|
|
623
|
+
const stats = await indexer.indexCodebase(codebaseDir);
|
|
624
|
+
|
|
625
|
+
expect(stats.status).toBe("completed");
|
|
626
|
+
});
|
|
627
|
+
|
|
628
|
+
it("should handle very large files", async () => {
|
|
629
|
+
const largeContent = "function test() {}\n".repeat(1000);
|
|
630
|
+
await createTestFile(codebaseDir, "large.ts", largeContent);
|
|
631
|
+
|
|
632
|
+
const stats = await indexer.indexCodebase(codebaseDir);
|
|
633
|
+
|
|
634
|
+
expect(stats.chunksCreated).toBeGreaterThan(1);
|
|
635
|
+
});
|
|
636
|
+
|
|
637
|
+
it("should generate consistent collection names", async () => {
|
|
638
|
+
await createTestFile(codebaseDir, "test.ts", "const x = 1;");
|
|
639
|
+
|
|
640
|
+
await indexer.indexCodebase(codebaseDir);
|
|
641
|
+
const status1 = await indexer.getIndexStatus(codebaseDir);
|
|
642
|
+
|
|
643
|
+
await indexer.clearIndex(codebaseDir);
|
|
644
|
+
|
|
645
|
+
await indexer.indexCodebase(codebaseDir);
|
|
646
|
+
const status2 = await indexer.getIndexStatus(codebaseDir);
|
|
647
|
+
|
|
648
|
+
expect(status1.collectionName).toBe(status2.collectionName);
|
|
649
|
+
});
|
|
650
|
+
|
|
651
|
+
it("should handle concurrent operations gracefully", async () => {
|
|
652
|
+
await createTestFile(codebaseDir, "test.ts", "const x = 1;");
|
|
653
|
+
|
|
654
|
+
await indexer.indexCodebase(codebaseDir);
|
|
655
|
+
|
|
656
|
+
const searchPromises = [
|
|
657
|
+
indexer.searchCode(codebaseDir, "test"),
|
|
658
|
+
indexer.searchCode(codebaseDir, "const"),
|
|
659
|
+
indexer.getIndexStatus(codebaseDir),
|
|
660
|
+
];
|
|
661
|
+
|
|
662
|
+
const results = await Promise.all(searchPromises);
|
|
663
|
+
|
|
664
|
+
expect(results).toHaveLength(3);
|
|
665
|
+
});
|
|
666
|
+
});
|
|
667
|
+
|
|
668
|
+
describe("Chunk limiting configuration", () => {
|
|
669
|
+
it("should respect maxChunksPerFile limit", async () => {
|
|
670
|
+
const limitedConfig = {
|
|
671
|
+
...config,
|
|
672
|
+
maxChunksPerFile: 2,
|
|
673
|
+
};
|
|
674
|
+
const limitedIndexer = new CodeIndexer(qdrant as any, embeddings, limitedConfig);
|
|
675
|
+
|
|
676
|
+
// Create a large file that would generate many chunks
|
|
677
|
+
const largeContent = Array(50)
|
|
678
|
+
.fill(null)
|
|
679
|
+
.map((_, i) => `function test${i}() { console.log('test ${i}'); return ${i}; }`)
|
|
680
|
+
.join("\n\n");
|
|
681
|
+
|
|
682
|
+
await createTestFile(codebaseDir, "large.ts", largeContent);
|
|
683
|
+
|
|
684
|
+
const stats = await limitedIndexer.indexCodebase(codebaseDir);
|
|
685
|
+
|
|
686
|
+
// Should limit chunks per file to 2
|
|
687
|
+
expect(stats.chunksCreated).toBeLessThanOrEqual(2);
|
|
688
|
+
expect(stats.filesIndexed).toBe(1);
|
|
689
|
+
});
|
|
690
|
+
|
|
691
|
+
it("should respect maxTotalChunks limit", async () => {
|
|
692
|
+
const limitedConfig = {
|
|
693
|
+
...config,
|
|
694
|
+
maxTotalChunks: 3,
|
|
695
|
+
};
|
|
696
|
+
const limitedIndexer = new CodeIndexer(qdrant as any, embeddings, limitedConfig);
|
|
697
|
+
|
|
698
|
+
// Create multiple files
|
|
699
|
+
for (let i = 0; i < 10; i++) {
|
|
700
|
+
await createTestFile(
|
|
701
|
+
codebaseDir,
|
|
702
|
+
`file${i}.ts`,
|
|
703
|
+
`export function func${i}() { console.log('function ${i}'); return ${i}; }`
|
|
704
|
+
);
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
const stats = await limitedIndexer.indexCodebase(codebaseDir);
|
|
708
|
+
|
|
709
|
+
// Should stop after reaching max total chunks
|
|
710
|
+
expect(stats.chunksCreated).toBeLessThanOrEqual(3);
|
|
711
|
+
expect(stats.filesIndexed).toBeGreaterThan(0);
|
|
712
|
+
});
|
|
713
|
+
|
|
714
|
+
it("should handle maxTotalChunks during chunk iteration", async () => {
|
|
715
|
+
const limitedConfig = {
|
|
716
|
+
...config,
|
|
717
|
+
maxTotalChunks: 1,
|
|
718
|
+
};
|
|
719
|
+
const limitedIndexer = new CodeIndexer(qdrant as any, embeddings, limitedConfig);
|
|
720
|
+
|
|
721
|
+
// Create a file with multiple chunks
|
|
722
|
+
const content = `
|
|
723
|
+
function first() {
|
|
724
|
+
console.log('first function');
|
|
725
|
+
return 1;
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
function second() {
|
|
729
|
+
console.log('second function');
|
|
730
|
+
return 2;
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
function third() {
|
|
734
|
+
console.log('third function');
|
|
735
|
+
return 3;
|
|
736
|
+
}
|
|
737
|
+
`;
|
|
738
|
+
|
|
739
|
+
await createTestFile(codebaseDir, "multi.ts", content);
|
|
740
|
+
|
|
741
|
+
const stats = await limitedIndexer.indexCodebase(codebaseDir);
|
|
742
|
+
|
|
743
|
+
// Should stop after first chunk
|
|
744
|
+
expect(stats.chunksCreated).toBe(1);
|
|
745
|
+
});
|
|
746
|
+
});
|
|
747
|
+
|
|
748
|
+
describe("Progress callback coverage", () => {
|
|
749
|
+
it("should call progress callback during reindexChanges", async () => {
|
|
750
|
+
// Initial indexing
|
|
751
|
+
await createTestFile(
|
|
752
|
+
codebaseDir,
|
|
753
|
+
"file1.ts",
|
|
754
|
+
"export const initial = 1;\nconsole.log('Initial');"
|
|
755
|
+
);
|
|
756
|
+
await indexer.indexCodebase(codebaseDir);
|
|
757
|
+
|
|
758
|
+
// Add new file
|
|
759
|
+
await createTestFile(
|
|
760
|
+
codebaseDir,
|
|
761
|
+
"file2.ts",
|
|
762
|
+
`export const added = 2;
|
|
763
|
+
console.log('Added file');
|
|
764
|
+
|
|
765
|
+
export function process() {
|
|
766
|
+
console.log('Processing');
|
|
767
|
+
return true;
|
|
768
|
+
}`
|
|
769
|
+
);
|
|
770
|
+
|
|
771
|
+
const progressUpdates: string[] = [];
|
|
772
|
+
const progressCallback = (progress: any) => {
|
|
773
|
+
progressUpdates.push(progress.phase);
|
|
774
|
+
};
|
|
775
|
+
|
|
776
|
+
await indexer.reindexChanges(codebaseDir, progressCallback);
|
|
777
|
+
|
|
778
|
+
// Should have called progress callback with various phases
|
|
779
|
+
expect(progressUpdates.length).toBeGreaterThan(0);
|
|
780
|
+
expect(progressUpdates).toContain("scanning");
|
|
781
|
+
});
|
|
782
|
+
});
|
|
783
|
+
|
|
784
|
+
describe("Error handling edge cases", () => {
|
|
785
|
+
it("should handle non-Error exceptions", async () => {
|
|
786
|
+
// This tests the `error instanceof Error ? error.message : String(error)` branch
|
|
787
|
+
await createTestFile(codebaseDir, "test.ts", "const x = 1;");
|
|
788
|
+
|
|
789
|
+
// Mock fs.readFile to throw a non-Error object
|
|
790
|
+
const originalReadFile = fs.readFile;
|
|
791
|
+
let callCount = 0;
|
|
792
|
+
|
|
793
|
+
// @ts-expect-error - Mocking for test
|
|
794
|
+
fs.readFile = async (path: any, encoding: any) => {
|
|
795
|
+
callCount++;
|
|
796
|
+
if (callCount === 1 && typeof path === "string" && path.endsWith("test.ts")) {
|
|
797
|
+
// Throw a non-Error object
|
|
798
|
+
throw "String error";
|
|
799
|
+
}
|
|
800
|
+
return originalReadFile(path, encoding);
|
|
801
|
+
};
|
|
802
|
+
|
|
803
|
+
try {
|
|
804
|
+
const stats = await indexer.indexCodebase(codebaseDir);
|
|
805
|
+
|
|
806
|
+
// Should handle the error gracefully
|
|
807
|
+
expect(stats.status).toBe("completed");
|
|
808
|
+
expect(stats.errors?.some((e) => e.includes("String error"))).toBe(true);
|
|
809
|
+
} finally {
|
|
810
|
+
// Restore original function
|
|
811
|
+
// @ts-expect-error
|
|
812
|
+
fs.readFile = originalReadFile;
|
|
813
|
+
}
|
|
814
|
+
});
|
|
815
|
+
});
|
|
816
|
+
});
|
|
817
|
+
|
|
818
|
+
// Helper function to create test files
|
|
819
|
+
async function createTestFile(
|
|
820
|
+
baseDir: string,
|
|
821
|
+
relativePath: string,
|
|
822
|
+
content: string
|
|
823
|
+
): Promise<void> {
|
|
824
|
+
const fullPath = join(baseDir, relativePath);
|
|
825
|
+
const dir = join(fullPath, "..");
|
|
826
|
+
await fs.mkdir(dir, { recursive: true });
|
|
827
|
+
await fs.writeFile(fullPath, content, "utf-8");
|
|
828
|
+
}
|