@softerist/heuristic-mcp 2.1.46 → 3.0.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/.agent/workflows/code-review.md +60 -0
- package/.prettierrc +7 -0
- package/ARCHITECTURE.md +105 -170
- package/CONTRIBUTING.md +32 -113
- package/GEMINI.md +73 -0
- package/LICENSE +21 -21
- package/README.md +161 -54
- package/config.json +876 -76
- package/debug-pids.js +27 -0
- package/eslint.config.js +36 -0
- package/features/ann-config.js +37 -26
- package/features/clear-cache.js +28 -19
- package/features/find-similar-code.js +142 -66
- package/features/hybrid-search.js +253 -93
- package/features/index-codebase.js +1455 -394
- package/features/lifecycle.js +813 -180
- package/features/register.js +58 -52
- package/index.js +450 -306
- package/lib/cache-ops.js +22 -0
- package/lib/cache-utils.js +68 -0
- package/lib/cache.js +1392 -587
- package/lib/call-graph.js +165 -50
- package/lib/cli.js +154 -0
- package/lib/config.js +462 -121
- package/lib/embedding-process.js +77 -0
- package/lib/embedding-worker.js +545 -30
- package/lib/ignore-patterns.js +61 -59
- package/lib/json-worker.js +14 -0
- package/lib/json-writer.js +344 -0
- package/lib/logging.js +88 -0
- package/lib/memory-logger.js +13 -0
- package/lib/project-detector.js +13 -17
- package/lib/server-lifecycle.js +38 -0
- package/lib/settings-editor.js +645 -0
- package/lib/tokenizer.js +207 -104
- package/lib/utils.js +273 -198
- package/lib/vector-store-binary.js +592 -0
- package/mcp_config.example.json +13 -0
- package/package.json +13 -2
- package/scripts/clear-cache.js +6 -17
- package/scripts/download-model.js +14 -9
- package/scripts/postinstall.js +5 -5
- package/search-configs.js +36 -0
- package/test/ann-config.test.js +179 -0
- package/test/ann-fallback.test.js +6 -6
- package/test/binary-store.test.js +69 -0
- package/test/cache-branches.test.js +120 -0
- package/test/cache-errors.test.js +264 -0
- package/test/cache-extra.test.js +300 -0
- package/test/cache-helpers.test.js +205 -0
- package/test/cache-hnsw-failure.test.js +40 -0
- package/test/cache-json-worker.test.js +190 -0
- package/test/cache-worker.test.js +102 -0
- package/test/cache.test.js +443 -0
- package/test/call-graph.test.js +103 -4
- package/test/clear-cache.test.js +69 -68
- package/test/code-review-workflow.test.js +50 -0
- package/test/config.test.js +418 -0
- package/test/coverage-gap.test.js +497 -0
- package/test/coverage-maximizer.test.js +236 -0
- package/test/debug-analysis.js +107 -0
- package/test/embedding-model.test.js +173 -103
- package/test/embedding-worker-extra.test.js +272 -0
- package/test/embedding-worker.test.js +158 -0
- package/test/features.test.js +139 -0
- package/test/final-boost.test.js +271 -0
- package/test/final-polish.test.js +183 -0
- package/test/final.test.js +95 -0
- package/test/find-similar-code.test.js +191 -0
- package/test/helpers.js +92 -11
- package/test/helpers.test.js +46 -0
- package/test/hybrid-search-basic.test.js +62 -0
- package/test/hybrid-search-branch.test.js +202 -0
- package/test/hybrid-search-callgraph.test.js +229 -0
- package/test/hybrid-search-extra.test.js +81 -0
- package/test/hybrid-search.test.js +484 -71
- package/test/index-cli.test.js +520 -0
- package/test/index-codebase-batch.test.js +119 -0
- package/test/index-codebase-branches.test.js +585 -0
- package/test/index-codebase-core.test.js +1032 -0
- package/test/index-codebase-edge-cases.test.js +254 -0
- package/test/index-codebase-errors.test.js +132 -0
- package/test/index-codebase-gap.test.js +239 -0
- package/test/index-codebase-lines.test.js +151 -0
- package/test/index-codebase-watcher.test.js +259 -0
- package/test/index-codebase-zone.test.js +259 -0
- package/test/index-codebase.test.js +371 -69
- package/test/index-memory.test.js +220 -0
- package/test/indexer-detailed.test.js +176 -0
- package/test/integration.test.js +148 -92
- package/test/json-worker.test.js +50 -0
- package/test/lifecycle.test.js +541 -0
- package/test/master.test.js +198 -0
- package/test/perfection.test.js +349 -0
- package/test/project-detector.test.js +65 -0
- package/test/register.test.js +262 -0
- package/test/tokenizer.test.js +55 -93
- package/test/ultra-maximizer.test.js +116 -0
- package/test/utils-branches.test.js +161 -0
- package/test/utils-extra.test.js +116 -0
- package/test/utils.test.js +131 -0
- package/test/verify_fixes.js +76 -0
- package/test/worker-errors.test.js +96 -0
- package/test/worker-init.test.js +102 -0
- package/test/worker_throttling.test.js +93 -0
- package/tools/scripts/benchmark-search.js +95 -0
- package/tools/scripts/cache-stats.js +71 -0
- package/tools/scripts/manual-search.js +34 -0
- package/vitest.config.js +19 -9
|
@@ -0,0 +1,443 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import fs from 'fs/promises';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import os from 'os';
|
|
5
|
+
import { EmbeddingsCache } from '../lib/cache.js';
|
|
6
|
+
import { loadConfig } from '../lib/config.js';
|
|
7
|
+
|
|
8
|
+
let lastHnswInstance = null;
|
|
9
|
+
class FakeHnsw {
|
|
10
|
+
constructor(metric, dim) {
|
|
11
|
+
this.metric = metric;
|
|
12
|
+
this.dim = dim;
|
|
13
|
+
lastHnswInstance = this;
|
|
14
|
+
}
|
|
15
|
+
initIndex() {}
|
|
16
|
+
addPoint() {}
|
|
17
|
+
writeIndexSync() {}
|
|
18
|
+
readIndexSync() {}
|
|
19
|
+
setEf(value) {
|
|
20
|
+
this.ef = value;
|
|
21
|
+
}
|
|
22
|
+
searchKnn() {
|
|
23
|
+
return { labels: [0] };
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
vi.mock('hnswlib-node', () => ({
|
|
28
|
+
HierarchicalNSW: FakeHnsw,
|
|
29
|
+
}));
|
|
30
|
+
|
|
31
|
+
async function withTempDir(testFn) {
|
|
32
|
+
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'heuristic-cache-'));
|
|
33
|
+
try {
|
|
34
|
+
await testFn(dir);
|
|
35
|
+
} finally {
|
|
36
|
+
await fs.rm(dir, { recursive: true, force: true });
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function createConfig(cacheDir) {
|
|
41
|
+
const config = await loadConfig();
|
|
42
|
+
config.cacheDirectory = cacheDir;
|
|
43
|
+
config.searchDirectory = cacheDir;
|
|
44
|
+
config.enableCache = true;
|
|
45
|
+
config.callGraphEnabled = true;
|
|
46
|
+
config.embeddingModel = 'test-model';
|
|
47
|
+
config.fileExtensions = ['js'];
|
|
48
|
+
config.excludePatterns = [];
|
|
49
|
+
config.annEnabled = true;
|
|
50
|
+
config.annMinChunks = 1;
|
|
51
|
+
return config;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
describe('EmbeddingsCache', () => {
|
|
55
|
+
beforeEach(() => {
|
|
56
|
+
vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
afterEach(() => {
|
|
60
|
+
vi.clearAllMocks();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('should cover all lines', async () => {
|
|
64
|
+
await withTempDir(async (dir) => {
|
|
65
|
+
const config = await createConfig(dir);
|
|
66
|
+
const cache = new EmbeddingsCache(config);
|
|
67
|
+
|
|
68
|
+
// Line 176
|
|
69
|
+
const mkdirSpy = vi
|
|
70
|
+
.spyOn(fs, 'mkdir')
|
|
71
|
+
.mockRejectedValue(new Error('Failed to create directory'));
|
|
72
|
+
await cache.load();
|
|
73
|
+
expect(console.warn).toHaveBeenCalledWith(
|
|
74
|
+
'[Cache] Failed to load cache:',
|
|
75
|
+
'Failed to create directory'
|
|
76
|
+
);
|
|
77
|
+
mkdirSpy.mockRestore();
|
|
78
|
+
|
|
79
|
+
// Line 219
|
|
80
|
+
await cache.save();
|
|
81
|
+
expect(cache.isSaving).toBe(false);
|
|
82
|
+
|
|
83
|
+
// Line 246
|
|
84
|
+
cache.setFileCallData('a.js', { defs: [], calls: [] });
|
|
85
|
+
cache.removeFileFromStore('a.js');
|
|
86
|
+
expect(cache.getFileCallData('a.js')).toBeUndefined();
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('loads cache data and filters extensions', async () => {
|
|
91
|
+
await withTempDir(async (dir) => {
|
|
92
|
+
const config = await createConfig(dir);
|
|
93
|
+
config.fileExtensions = ['js'];
|
|
94
|
+
const cache = new EmbeddingsCache(config);
|
|
95
|
+
|
|
96
|
+
const meta = { version: 1, embeddingModel: config.embeddingModel };
|
|
97
|
+
const cacheData = [
|
|
98
|
+
{ file: path.join(dir, 'a.js'), vector: [1, 2] },
|
|
99
|
+
{ file: path.join(dir, 'a.txt'), vector: [3, 4] },
|
|
100
|
+
];
|
|
101
|
+
const hashData = {
|
|
102
|
+
[path.join(dir, 'a.js')]: 'hash1',
|
|
103
|
+
[path.join(dir, 'a.txt')]: 'hash2',
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
await fs.writeFile(path.join(dir, 'meta.json'), JSON.stringify(meta));
|
|
107
|
+
await fs.writeFile(path.join(dir, 'embeddings.json'), JSON.stringify(cacheData));
|
|
108
|
+
await fs.writeFile(path.join(dir, 'file-hashes.json'), JSON.stringify(hashData));
|
|
109
|
+
|
|
110
|
+
await cache.load();
|
|
111
|
+
|
|
112
|
+
expect(cache.getVectorStore()).toHaveLength(1);
|
|
113
|
+
expect(cache.getFileHash(path.join(dir, 'a.js'))).toBe('hash1');
|
|
114
|
+
expect(cache.getFileHash(path.join(dir, 'a.txt'))).toBeUndefined();
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('persists file hash metadata and reloads', async () => {
|
|
119
|
+
await withTempDir(async (dir) => {
|
|
120
|
+
const config = await createConfig(dir);
|
|
121
|
+
const cache = new EmbeddingsCache(config);
|
|
122
|
+
const filePath = path.join(dir, 'a.js');
|
|
123
|
+
|
|
124
|
+
cache.setFileHash(filePath, 'hash-meta', { mtimeMs: 1234, size: 4567 });
|
|
125
|
+
await cache.save();
|
|
126
|
+
|
|
127
|
+
const hashFile = path.join(dir, 'file-hashes.json');
|
|
128
|
+
const raw = JSON.parse(await fs.readFile(hashFile, 'utf-8'));
|
|
129
|
+
expect(raw[filePath]).toEqual({ hash: 'hash-meta', mtimeMs: 1234, size: 4567 });
|
|
130
|
+
|
|
131
|
+
const reloaded = new EmbeddingsCache(config);
|
|
132
|
+
await reloaded.load();
|
|
133
|
+
expect(reloaded.getFileHash(filePath)).toBe('hash-meta');
|
|
134
|
+
expect(reloaded.getFileMeta(filePath)).toEqual(
|
|
135
|
+
expect.objectContaining({ hash: 'hash-meta', mtimeMs: 1234, size: 4567 })
|
|
136
|
+
);
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('writes and loads binary vector store with content lookup', async () => {
|
|
141
|
+
await withTempDir(async (dir) => {
|
|
142
|
+
const config = await createConfig(dir);
|
|
143
|
+
config.vectorStoreFormat = 'binary';
|
|
144
|
+
config.vectorStoreContentMode = 'external';
|
|
145
|
+
config.contentCacheEntries = 2;
|
|
146
|
+
const cache = new EmbeddingsCache(config);
|
|
147
|
+
const filePath = path.join(dir, 'b.js');
|
|
148
|
+
|
|
149
|
+
cache.vectorStore = [
|
|
150
|
+
{
|
|
151
|
+
file: filePath,
|
|
152
|
+
startLine: 1,
|
|
153
|
+
endLine: 2,
|
|
154
|
+
content: 'console.log("hi")',
|
|
155
|
+
vector: new Float32Array([0.1, 0.2]),
|
|
156
|
+
},
|
|
157
|
+
];
|
|
158
|
+
cache.setFileHash(filePath, 'hash-binary', { mtimeMs: 10, size: 20 });
|
|
159
|
+
|
|
160
|
+
await cache.save();
|
|
161
|
+
|
|
162
|
+
const reloaded = new EmbeddingsCache(config);
|
|
163
|
+
await reloaded.load();
|
|
164
|
+
|
|
165
|
+
const store = reloaded.getVectorStore();
|
|
166
|
+
expect(store.length).toBe(1);
|
|
167
|
+
await expect(reloaded.getChunkContent(store[0])).resolves.toBe('console.log("hi")');
|
|
168
|
+
expect(reloaded.getChunkVector(store[0])).toBeInstanceOf(Float32Array);
|
|
169
|
+
|
|
170
|
+
await reloaded.close();
|
|
171
|
+
await cache.close();
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('loads binary vector store in disk mode without inline vectors', async () => {
|
|
176
|
+
await withTempDir(async (dir) => {
|
|
177
|
+
const config = await createConfig(dir);
|
|
178
|
+
config.vectorStoreFormat = 'binary';
|
|
179
|
+
config.vectorStoreContentMode = 'external';
|
|
180
|
+
config.vectorStoreLoadMode = 'disk';
|
|
181
|
+
config.vectorCacheEntries = 1;
|
|
182
|
+
const cache = new EmbeddingsCache(config);
|
|
183
|
+
const filePath = path.join(dir, 'disk.js');
|
|
184
|
+
|
|
185
|
+
cache.vectorStore = [
|
|
186
|
+
{
|
|
187
|
+
file: filePath,
|
|
188
|
+
startLine: 1,
|
|
189
|
+
endLine: 2,
|
|
190
|
+
content: 'console.log("disk")',
|
|
191
|
+
vector: new Float32Array([0.3, 0.6]),
|
|
192
|
+
},
|
|
193
|
+
];
|
|
194
|
+
cache.setFileHash(filePath, 'hash-disk', { mtimeMs: 10, size: 20 });
|
|
195
|
+
|
|
196
|
+
await cache.save();
|
|
197
|
+
|
|
198
|
+
const reloaded = new EmbeddingsCache(config);
|
|
199
|
+
await reloaded.load();
|
|
200
|
+
|
|
201
|
+
const store = reloaded.getVectorStore();
|
|
202
|
+
expect(store.length).toBe(1);
|
|
203
|
+
expect(store[0].vector).toBeUndefined();
|
|
204
|
+
expect(reloaded.getChunkVector(store[0])).toBeInstanceOf(Float32Array);
|
|
205
|
+
|
|
206
|
+
await reloaded.close();
|
|
207
|
+
await cache.close();
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it('migrates from JSON cache to binary store on save', async () => {
|
|
212
|
+
await withTempDir(async (dir) => {
|
|
213
|
+
const config = await createConfig(dir);
|
|
214
|
+
config.vectorStoreFormat = 'binary';
|
|
215
|
+
config.vectorStoreContentMode = 'external';
|
|
216
|
+
|
|
217
|
+
const filePath = path.join(dir, 'migrate.js');
|
|
218
|
+
const meta = { version: 1, embeddingModel: config.embeddingModel };
|
|
219
|
+
const cacheData = [
|
|
220
|
+
{
|
|
221
|
+
file: filePath,
|
|
222
|
+
startLine: 1,
|
|
223
|
+
endLine: 2,
|
|
224
|
+
content: 'export const x = 1;',
|
|
225
|
+
vector: [0.3, 0.4],
|
|
226
|
+
},
|
|
227
|
+
];
|
|
228
|
+
const hashData = { [filePath]: 'hash-migrate' };
|
|
229
|
+
|
|
230
|
+
await fs.writeFile(path.join(dir, 'meta.json'), JSON.stringify(meta));
|
|
231
|
+
await fs.writeFile(path.join(dir, 'embeddings.json'), JSON.stringify(cacheData));
|
|
232
|
+
await fs.writeFile(path.join(dir, 'file-hashes.json'), JSON.stringify(hashData));
|
|
233
|
+
|
|
234
|
+
const cache = new EmbeddingsCache(config);
|
|
235
|
+
await cache.load();
|
|
236
|
+
expect(cache.getVectorStore()).toHaveLength(1);
|
|
237
|
+
|
|
238
|
+
await cache.save();
|
|
239
|
+
|
|
240
|
+
const vectorsPath = path.join(dir, 'vectors.bin');
|
|
241
|
+
const recordsPath = path.join(dir, 'records.bin');
|
|
242
|
+
const contentPath = path.join(dir, 'content.bin');
|
|
243
|
+
const filesPath = path.join(dir, 'files.json');
|
|
244
|
+
|
|
245
|
+
await expect(fs.readFile(vectorsPath)).resolves.toBeDefined();
|
|
246
|
+
await expect(fs.readFile(recordsPath)).resolves.toBeDefined();
|
|
247
|
+
await expect(fs.readFile(contentPath)).resolves.toBeDefined();
|
|
248
|
+
await expect(fs.readFile(filesPath)).resolves.toBeDefined();
|
|
249
|
+
|
|
250
|
+
await cache.close();
|
|
251
|
+
});
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it('reuses cached ANN vectors', async () => {
|
|
255
|
+
await withTempDir(async (dir) => {
|
|
256
|
+
const config = await createConfig(dir);
|
|
257
|
+
const cache = new EmbeddingsCache(config);
|
|
258
|
+
|
|
259
|
+
cache.vectorStore = [{ file: 'a.js', vector: [1, 2, 3] }];
|
|
260
|
+
const first = cache.getAnnVector(0);
|
|
261
|
+
const second = cache.getAnnVector(0);
|
|
262
|
+
|
|
263
|
+
expect(first).toBeInstanceOf(Float32Array);
|
|
264
|
+
expect(second).toBe(first);
|
|
265
|
+
});
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it('loads ANN index from disk and applies efSearch', async () => {
|
|
269
|
+
await withTempDir(async (dir) => {
|
|
270
|
+
const config = await createConfig(dir);
|
|
271
|
+
config.annEfSearch = 32;
|
|
272
|
+
const cache = new EmbeddingsCache(config);
|
|
273
|
+
cache.vectorStore = [{ file: 'a.js', vector: [1, 2] }];
|
|
274
|
+
|
|
275
|
+
const meta = {
|
|
276
|
+
version: 1,
|
|
277
|
+
embeddingModel: config.embeddingModel,
|
|
278
|
+
metric: config.annMetric,
|
|
279
|
+
dim: 2,
|
|
280
|
+
count: 1,
|
|
281
|
+
m: config.annM,
|
|
282
|
+
efConstruction: config.annEfConstruction,
|
|
283
|
+
};
|
|
284
|
+
await fs.writeFile(path.join(dir, 'ann-meta.json'), JSON.stringify(meta));
|
|
285
|
+
await fs.writeFile(path.join(dir, 'ann-index.bin'), '');
|
|
286
|
+
|
|
287
|
+
const loaded = await cache.loadAnnIndexFromDisk(FakeHnsw, 2);
|
|
288
|
+
|
|
289
|
+
expect(loaded).toBe(true);
|
|
290
|
+
expect(cache.annIndex).toBeTruthy();
|
|
291
|
+
expect(lastHnswInstance.ef).toBe(32);
|
|
292
|
+
});
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it('builds ANN index and saves when cache is enabled', async () => {
|
|
296
|
+
await withTempDir(async (dir) => {
|
|
297
|
+
const config = await createConfig(dir);
|
|
298
|
+
config.annIndexCache = true;
|
|
299
|
+
const cache = new EmbeddingsCache(config);
|
|
300
|
+
cache.vectorStore = [{ file: 'a.js', vector: [1, 2] }];
|
|
301
|
+
|
|
302
|
+
const index = await cache.buildAnnIndex(FakeHnsw, 2);
|
|
303
|
+
|
|
304
|
+
expect(index).toBeTruthy();
|
|
305
|
+
expect(cache.annDirty).toBe(false);
|
|
306
|
+
const metaFile = path.join(dir, 'ann-meta.json');
|
|
307
|
+
const metaExists = await fs.readFile(metaFile, 'utf-8');
|
|
308
|
+
expect(JSON.parse(metaExists).count).toBe(1);
|
|
309
|
+
});
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
it('reuses hnswlib promise across index builds', async () => {
|
|
313
|
+
await withTempDir(async (dir) => {
|
|
314
|
+
const config = await createConfig(dir);
|
|
315
|
+
config.annIndexCache = false;
|
|
316
|
+
const cache = new EmbeddingsCache(config);
|
|
317
|
+
cache.vectorStore = [{ file: 'a.js', vector: [1, 2] }];
|
|
318
|
+
|
|
319
|
+
const first = await cache.ensureAnnIndex();
|
|
320
|
+
expect(first).toBeTruthy();
|
|
321
|
+
|
|
322
|
+
cache.annIndex = null;
|
|
323
|
+
cache.annDirty = true;
|
|
324
|
+
const second = await cache.ensureAnnIndex();
|
|
325
|
+
expect(second).toBeTruthy();
|
|
326
|
+
});
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it('returns null when ANN labels are invalid', async () => {
|
|
330
|
+
await withTempDir(async (dir) => {
|
|
331
|
+
const config = await createConfig(dir);
|
|
332
|
+
const cache = new EmbeddingsCache(config);
|
|
333
|
+
cache.vectorStore = [{ file: 'a.js', vector: [1, 2] }];
|
|
334
|
+
|
|
335
|
+
const mockIndex = {
|
|
336
|
+
searchKnn: () => ({ labels: [-1, 10] }),
|
|
337
|
+
};
|
|
338
|
+
vi.spyOn(cache, 'ensureAnnIndex').mockResolvedValue(mockIndex);
|
|
339
|
+
|
|
340
|
+
const result = await cache.queryAnn([1, 2], 2);
|
|
341
|
+
expect(result).toEqual([]);
|
|
342
|
+
});
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
it('uses default values in ANN and call-graph stats', async () => {
|
|
346
|
+
await withTempDir(async (dir) => {
|
|
347
|
+
const config = await createConfig(dir);
|
|
348
|
+
delete config.annEnabled;
|
|
349
|
+
delete config.callGraphEnabled;
|
|
350
|
+
const cache = new EmbeddingsCache(config);
|
|
351
|
+
|
|
352
|
+
const annStats = cache.getAnnStats();
|
|
353
|
+
const callStats = cache.getCallGraphStats();
|
|
354
|
+
|
|
355
|
+
expect(annStats.enabled).toBe(false);
|
|
356
|
+
expect(callStats.enabled).toBe(false);
|
|
357
|
+
});
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
it('logs when call-graph cache removal fails', async () => {
|
|
361
|
+
await withTempDir(async (dir) => {
|
|
362
|
+
const config = await createConfig(dir);
|
|
363
|
+
config.verbose = true;
|
|
364
|
+
const cache = new EmbeddingsCache(config);
|
|
365
|
+
const rmSpy = vi.spyOn(fs, 'rm').mockRejectedValue(new Error('rm failed'));
|
|
366
|
+
|
|
367
|
+
await cache.clearCallGraphData({ removeFile: true });
|
|
368
|
+
|
|
369
|
+
const called = console.warn.mock.calls.some(
|
|
370
|
+
(call) =>
|
|
371
|
+
typeof call[0] === 'string' && call[0].includes('Failed to remove call-graph cache')
|
|
372
|
+
);
|
|
373
|
+
expect(called).toBe(true);
|
|
374
|
+
rmSpy.mockRestore();
|
|
375
|
+
});
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
it('handles missing cacheData or hashData in load', async () => {
|
|
379
|
+
await withTempDir(async (dir) => {
|
|
380
|
+
const config = await createConfig(dir);
|
|
381
|
+
const cache = new EmbeddingsCache(config);
|
|
382
|
+
const meta = { version: 1, embeddingModel: config.embeddingModel };
|
|
383
|
+
await fs.writeFile(path.join(dir, 'meta.json'), JSON.stringify(meta));
|
|
384
|
+
// embeddings.json and file-hashes.json are missing
|
|
385
|
+
await cache.load();
|
|
386
|
+
expect(cache.getVectorStore()).toEqual([]);
|
|
387
|
+
});
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
it('handles index without setEf', async () => {
|
|
391
|
+
await withTempDir(async (dir) => {
|
|
392
|
+
const config = await createConfig(dir);
|
|
393
|
+
const cache = new EmbeddingsCache(config);
|
|
394
|
+
cache.vectorStore = [{ file: 'a.js', vector: [1, 2] }];
|
|
395
|
+
|
|
396
|
+
class NoEfIndex {
|
|
397
|
+
constructor() {}
|
|
398
|
+
initIndex() {}
|
|
399
|
+
addPoint() {}
|
|
400
|
+
readIndexSync() {
|
|
401
|
+
return true;
|
|
402
|
+
}
|
|
403
|
+
writeIndexSync() {}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
const meta = {
|
|
407
|
+
version: 1,
|
|
408
|
+
embeddingModel: config.embeddingModel,
|
|
409
|
+
metric: config.annMetric,
|
|
410
|
+
dim: 2,
|
|
411
|
+
count: 1,
|
|
412
|
+
m: config.annM,
|
|
413
|
+
efConstruction: config.annEfConstruction,
|
|
414
|
+
};
|
|
415
|
+
await fs.writeFile(path.join(dir, 'ann-meta.json'), JSON.stringify(meta));
|
|
416
|
+
await fs.writeFile(path.join(dir, 'ann-index.bin'), '');
|
|
417
|
+
|
|
418
|
+
await cache.loadAnnIndexFromDisk(NoEfIndex, 2);
|
|
419
|
+
expect(cache.annIndex).toBeInstanceOf(NoEfIndex);
|
|
420
|
+
|
|
421
|
+
cache.annIndex = null;
|
|
422
|
+
cache.annDirty = true;
|
|
423
|
+
await cache.buildAnnIndex(NoEfIndex, 2);
|
|
424
|
+
expect(cache.annIndex).toBeInstanceOf(NoEfIndex);
|
|
425
|
+
});
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
it('handles verbose false in clearCallGraphData failure', async () => {
|
|
429
|
+
await withTempDir(async (dir) => {
|
|
430
|
+
const config = await createConfig(dir);
|
|
431
|
+
config.verbose = false;
|
|
432
|
+
const cache = new EmbeddingsCache(config);
|
|
433
|
+
const rmSpy = vi.spyOn(fs, 'rm').mockRejectedValue(new Error('rm failed'));
|
|
434
|
+
|
|
435
|
+
await cache.clearCallGraphData({ removeFile: true });
|
|
436
|
+
|
|
437
|
+
expect(console.warn).not.toHaveBeenCalledWith(
|
|
438
|
+
expect.stringContaining('Failed to remove call-graph cache')
|
|
439
|
+
);
|
|
440
|
+
rmSpy.mockRestore();
|
|
441
|
+
});
|
|
442
|
+
});
|
|
443
|
+
});
|
package/test/call-graph.test.js
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
extractCallData,
|
|
4
|
+
extractDefinitions,
|
|
5
|
+
extractCalls,
|
|
6
|
+
buildCallGraph,
|
|
7
|
+
getRelatedFiles,
|
|
8
|
+
extractSymbolsFromContent,
|
|
9
|
+
} from '../lib/call-graph.js';
|
|
3
10
|
|
|
4
11
|
describe('Call Graph Extractor', () => {
|
|
5
12
|
describe('extractDefinitions', () => {
|
|
@@ -52,6 +59,48 @@ describe('Call Graph Extractor', () => {
|
|
|
52
59
|
expect(defs).toContain('main');
|
|
53
60
|
expect(defs).toContain('Start');
|
|
54
61
|
});
|
|
62
|
+
|
|
63
|
+
it('should extract Rust function declarations', () => {
|
|
64
|
+
const content = `
|
|
65
|
+
fn main() {}
|
|
66
|
+
impl Server {
|
|
67
|
+
fn start() {}
|
|
68
|
+
}
|
|
69
|
+
`;
|
|
70
|
+
const defs = extractDefinitions(content, 'test.rs');
|
|
71
|
+
expect(defs).toContain('main');
|
|
72
|
+
expect(defs).toContain('Server');
|
|
73
|
+
expect(defs).toContain('start');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('should extract Java function declarations', () => {
|
|
77
|
+
const content = `
|
|
78
|
+
class Main {
|
|
79
|
+
public static void main(String[] args) {}
|
|
80
|
+
}
|
|
81
|
+
`;
|
|
82
|
+
const defs = extractDefinitions(content, 'test.java');
|
|
83
|
+
expect(defs).toContain('Main');
|
|
84
|
+
expect(defs).toContain('main');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('should extract JavaScript function declarations from jsx', () => {
|
|
88
|
+
const content = `
|
|
89
|
+
function foo() {}
|
|
90
|
+
const bar = () => {};
|
|
91
|
+
`;
|
|
92
|
+
const defs = extractDefinitions(content, 'test.jsx');
|
|
93
|
+
expect(defs).toContain('foo');
|
|
94
|
+
expect(defs).toContain('bar');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('should default to JavaScript patterns for unknown extensions', () => {
|
|
98
|
+
const content = `
|
|
99
|
+
function defaulted() {}
|
|
100
|
+
`;
|
|
101
|
+
const defs = extractDefinitions(content, 'notes.txt');
|
|
102
|
+
expect(defs).toContain('defaulted');
|
|
103
|
+
});
|
|
55
104
|
});
|
|
56
105
|
|
|
57
106
|
describe('extractCalls', () => {
|
|
@@ -84,12 +133,26 @@ describe('Call Graph Extractor', () => {
|
|
|
84
133
|
it('should not extract calls from strings', () => {
|
|
85
134
|
const content = `
|
|
86
135
|
const str = "someFunction()";
|
|
87
|
-
const template =
|
|
136
|
+
const template =
|
|
88
137
|
`;
|
|
89
138
|
const calls = extractCalls(content, 'test.js');
|
|
90
139
|
expect(calls).not.toContain('someFunction');
|
|
91
140
|
expect(calls).not.toContain('anotherFunction');
|
|
92
141
|
});
|
|
142
|
+
|
|
143
|
+
it('should ignore Python comments and docstrings', () => {
|
|
144
|
+
const content = `
|
|
145
|
+
def foo():
|
|
146
|
+
"""docstring call inDocstring()"""
|
|
147
|
+
bar()
|
|
148
|
+
# commentCall()
|
|
149
|
+
return
|
|
150
|
+
`;
|
|
151
|
+
const calls = extractCalls(content, 'test.py');
|
|
152
|
+
expect(calls).toContain('bar');
|
|
153
|
+
expect(calls).not.toContain('inDocstring');
|
|
154
|
+
expect(calls).not.toContain('commentCall');
|
|
155
|
+
});
|
|
93
156
|
});
|
|
94
157
|
|
|
95
158
|
describe('extractCallData', () => {
|
|
@@ -112,7 +175,7 @@ describe('Call Graph Extractor', () => {
|
|
|
112
175
|
const fileData = new Map([
|
|
113
176
|
['/path/a.js', { definitions: ['funcA'], calls: ['funcB'] }],
|
|
114
177
|
['/path/b.js', { definitions: ['funcB'], calls: ['funcC'] }],
|
|
115
|
-
['/path/c.js', { definitions: ['funcC'], calls: [] }]
|
|
178
|
+
['/path/c.js', { definitions: ['funcC'], calls: [] }],
|
|
116
179
|
]);
|
|
117
180
|
|
|
118
181
|
const graph = buildCallGraph(fileData);
|
|
@@ -128,7 +191,7 @@ describe('Call Graph Extractor', () => {
|
|
|
128
191
|
it('should find callers and callees', () => {
|
|
129
192
|
const fileData = new Map([
|
|
130
193
|
['/path/a.js', { definitions: ['funcA'], calls: ['funcB'] }],
|
|
131
|
-
['/path/b.js', { definitions: ['funcB'], calls: [] }]
|
|
194
|
+
['/path/b.js', { definitions: ['funcB'], calls: [] }],
|
|
132
195
|
]);
|
|
133
196
|
|
|
134
197
|
const graph = buildCallGraph(fileData);
|
|
@@ -138,5 +201,41 @@ describe('Call Graph Extractor', () => {
|
|
|
138
201
|
expect(related.has('/path/a.js')).toBe(true);
|
|
139
202
|
expect(related.has('/path/b.js')).toBe(true);
|
|
140
203
|
});
|
|
204
|
+
|
|
205
|
+
it('should stop immediately when maxHops is negative', () => {
|
|
206
|
+
const graph = buildCallGraph(
|
|
207
|
+
new Map([['/path/a.js', { definitions: ['funcA'], calls: ['funcB'] }]])
|
|
208
|
+
);
|
|
209
|
+
|
|
210
|
+
const related = getRelatedFiles(graph, ['funcB'], -1);
|
|
211
|
+
|
|
212
|
+
expect(related.size).toBe(0);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it('should handle missing fileCalls entries on deeper hops', () => {
|
|
216
|
+
const defines = new Map([['funcA', ['/path/a.js']]]);
|
|
217
|
+
const calledBy = new Map([['funcA', ['/path/b.js']]]);
|
|
218
|
+
const fileCalls = new Map([['/path/a.js', ['funcA']]]);
|
|
219
|
+
const graph = { defines, calledBy, fileCalls };
|
|
220
|
+
|
|
221
|
+
const related = getRelatedFiles(graph, ['funcA'], 2);
|
|
222
|
+
|
|
223
|
+
expect(related.has('/path/a.js')).toBe(true);
|
|
224
|
+
expect(related.has('/path/b.js')).toBe(true);
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
describe('extractSymbolsFromContent', () => {
|
|
229
|
+
it('should extract symbols from result snippets', () => {
|
|
230
|
+
const content = `
|
|
231
|
+
function alpha() {}
|
|
232
|
+
class Beta {}
|
|
233
|
+
const gamma = () => {};
|
|
234
|
+
`;
|
|
235
|
+
const symbols = extractSymbolsFromContent(content);
|
|
236
|
+
expect(symbols).toContain('alpha');
|
|
237
|
+
expect(symbols).toContain('Beta');
|
|
238
|
+
expect(symbols).toContain('gamma');
|
|
239
|
+
});
|
|
141
240
|
});
|
|
142
241
|
});
|