@softerist/heuristic-mcp 2.1.47 → 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.
Files changed (109) hide show
  1. package/.agent/workflows/code-review.md +60 -0
  2. package/.prettierrc +7 -0
  3. package/ARCHITECTURE.md +105 -170
  4. package/CONTRIBUTING.md +32 -113
  5. package/GEMINI.md +73 -0
  6. package/LICENSE +21 -21
  7. package/README.md +161 -54
  8. package/config.json +876 -75
  9. package/debug-pids.js +27 -0
  10. package/eslint.config.js +36 -0
  11. package/features/ann-config.js +37 -26
  12. package/features/clear-cache.js +28 -19
  13. package/features/find-similar-code.js +142 -66
  14. package/features/hybrid-search.js +253 -93
  15. package/features/index-codebase.js +1455 -394
  16. package/features/lifecycle.js +813 -180
  17. package/features/register.js +58 -52
  18. package/index.js +450 -306
  19. package/lib/cache-ops.js +22 -0
  20. package/lib/cache-utils.js +68 -0
  21. package/lib/cache.js +1392 -587
  22. package/lib/call-graph.js +165 -50
  23. package/lib/cli.js +154 -0
  24. package/lib/config.js +462 -121
  25. package/lib/embedding-process.js +77 -0
  26. package/lib/embedding-worker.js +545 -30
  27. package/lib/ignore-patterns.js +61 -59
  28. package/lib/json-worker.js +14 -0
  29. package/lib/json-writer.js +344 -0
  30. package/lib/logging.js +88 -0
  31. package/lib/memory-logger.js +13 -0
  32. package/lib/project-detector.js +13 -17
  33. package/lib/server-lifecycle.js +38 -0
  34. package/lib/settings-editor.js +645 -0
  35. package/lib/tokenizer.js +207 -104
  36. package/lib/utils.js +273 -198
  37. package/lib/vector-store-binary.js +592 -0
  38. package/mcp_config.example.json +13 -0
  39. package/package.json +13 -2
  40. package/scripts/clear-cache.js +6 -17
  41. package/scripts/download-model.js +14 -9
  42. package/scripts/postinstall.js +5 -5
  43. package/search-configs.js +36 -0
  44. package/test/ann-config.test.js +179 -0
  45. package/test/ann-fallback.test.js +6 -6
  46. package/test/binary-store.test.js +69 -0
  47. package/test/cache-branches.test.js +120 -0
  48. package/test/cache-errors.test.js +264 -0
  49. package/test/cache-extra.test.js +300 -0
  50. package/test/cache-helpers.test.js +205 -0
  51. package/test/cache-hnsw-failure.test.js +40 -0
  52. package/test/cache-json-worker.test.js +190 -0
  53. package/test/cache-worker.test.js +102 -0
  54. package/test/cache.test.js +443 -0
  55. package/test/call-graph.test.js +103 -4
  56. package/test/clear-cache.test.js +69 -68
  57. package/test/code-review-workflow.test.js +50 -0
  58. package/test/config.test.js +418 -0
  59. package/test/coverage-gap.test.js +497 -0
  60. package/test/coverage-maximizer.test.js +236 -0
  61. package/test/debug-analysis.js +107 -0
  62. package/test/embedding-model.test.js +173 -103
  63. package/test/embedding-worker-extra.test.js +272 -0
  64. package/test/embedding-worker.test.js +158 -0
  65. package/test/features.test.js +139 -0
  66. package/test/final-boost.test.js +271 -0
  67. package/test/final-polish.test.js +183 -0
  68. package/test/final.test.js +95 -0
  69. package/test/find-similar-code.test.js +191 -0
  70. package/test/helpers.js +92 -11
  71. package/test/helpers.test.js +46 -0
  72. package/test/hybrid-search-basic.test.js +62 -0
  73. package/test/hybrid-search-branch.test.js +202 -0
  74. package/test/hybrid-search-callgraph.test.js +229 -0
  75. package/test/hybrid-search-extra.test.js +81 -0
  76. package/test/hybrid-search.test.js +484 -71
  77. package/test/index-cli.test.js +520 -0
  78. package/test/index-codebase-batch.test.js +119 -0
  79. package/test/index-codebase-branches.test.js +585 -0
  80. package/test/index-codebase-core.test.js +1032 -0
  81. package/test/index-codebase-edge-cases.test.js +254 -0
  82. package/test/index-codebase-errors.test.js +132 -0
  83. package/test/index-codebase-gap.test.js +239 -0
  84. package/test/index-codebase-lines.test.js +151 -0
  85. package/test/index-codebase-watcher.test.js +259 -0
  86. package/test/index-codebase-zone.test.js +259 -0
  87. package/test/index-codebase.test.js +371 -69
  88. package/test/index-memory.test.js +220 -0
  89. package/test/indexer-detailed.test.js +176 -0
  90. package/test/integration.test.js +148 -92
  91. package/test/json-worker.test.js +50 -0
  92. package/test/lifecycle.test.js +541 -0
  93. package/test/master.test.js +198 -0
  94. package/test/perfection.test.js +349 -0
  95. package/test/project-detector.test.js +65 -0
  96. package/test/register.test.js +262 -0
  97. package/test/tokenizer.test.js +55 -93
  98. package/test/ultra-maximizer.test.js +116 -0
  99. package/test/utils-branches.test.js +161 -0
  100. package/test/utils-extra.test.js +116 -0
  101. package/test/utils.test.js +131 -0
  102. package/test/verify_fixes.js +76 -0
  103. package/test/worker-errors.test.js +96 -0
  104. package/test/worker-init.test.js +102 -0
  105. package/test/worker_throttling.test.js +93 -0
  106. package/tools/scripts/benchmark-search.js +95 -0
  107. package/tools/scripts/cache-stats.js +71 -0
  108. package/tools/scripts/manual-search.js +34 -0
  109. package/vitest.config.js +19 -9
@@ -0,0 +1,520 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+
3
+ let lastIndexer = null;
4
+ let lastCache = null;
5
+ let lastServer = null;
6
+ let indexAllMock;
7
+ let setupFileWatcherMock;
8
+ let hybridHandleToolCall;
9
+ let indexHandleToolCall;
10
+ let clearHandleToolCall;
11
+ let findHandleToolCall;
12
+ let annHandleToolCall;
13
+ let callSchema;
14
+ let listSchema;
15
+ let logsMock;
16
+
17
+ const configMock = {
18
+ loadConfig: vi.fn(),
19
+ getGlobalCacheDir: vi.fn(),
20
+ };
21
+ const fsMock = {
22
+ access: vi.fn(),
23
+ };
24
+ const pipelineMock = vi.fn();
25
+ const registerMock = vi.fn();
26
+ const stopMock = vi.fn();
27
+ const startMock = vi.fn();
28
+ const statusMock = vi.fn();
29
+
30
+ vi.mock('@modelcontextprotocol/sdk/server/index.js', () => ({
31
+ Server: class {
32
+ constructor() {
33
+ this.handlers = new Map();
34
+ lastServer = this;
35
+ }
36
+ setRequestHandler(schema, handler) {
37
+ this.handlers.set(schema, handler);
38
+ }
39
+ async connect() {}
40
+ },
41
+ }));
42
+ vi.mock('@modelcontextprotocol/sdk/server/stdio.js', () => ({
43
+ StdioServerTransport: class {},
44
+ }));
45
+ vi.mock('@modelcontextprotocol/sdk/types.js', () => {
46
+ callSchema = Symbol('call');
47
+ listSchema = Symbol('list');
48
+ return {
49
+ CallToolRequestSchema: callSchema,
50
+ ListToolsRequestSchema: listSchema,
51
+ };
52
+ });
53
+ vi.mock('@xenova/transformers', () => ({
54
+ pipeline: (...args) => pipelineMock(...args),
55
+ env: {
56
+ backends: {
57
+ onnx: {
58
+ numThreads: 1,
59
+ wasm: { numThreads: 1 },
60
+ },
61
+ },
62
+ },
63
+ }));
64
+ vi.mock('fs/promises', () => fsMock);
65
+ vi.mock('../lib/config.js', () => configMock);
66
+ vi.mock('../lib/cache.js', () => ({
67
+ EmbeddingsCache: class {
68
+ constructor(config) {
69
+ this.config = config;
70
+ this.load = vi.fn().mockResolvedValue(undefined);
71
+ this.save = vi.fn().mockResolvedValue(undefined);
72
+ lastCache = this;
73
+ }
74
+ },
75
+ }));
76
+ vi.mock('../features/index-codebase.js', () => ({
77
+ CodebaseIndexer: class {
78
+ constructor() {
79
+ this.watcher = { close: vi.fn().mockResolvedValue(undefined) };
80
+ this.terminateWorkers = vi.fn().mockResolvedValue(undefined);
81
+ this.indexAll = indexAllMock;
82
+ this.setupFileWatcher = setupFileWatcherMock;
83
+ lastIndexer = this;
84
+ }
85
+ },
86
+ getToolDefinition: vi.fn(() => ({ name: 'index-codebase' })),
87
+ handleToolCall: (...args) => indexHandleToolCall(...args),
88
+ }));
89
+ vi.mock('../features/hybrid-search.js', () => ({
90
+ HybridSearch: class {},
91
+ getToolDefinition: vi.fn(() => ({ name: 'semantic_search' })),
92
+ handleToolCall: (...args) => hybridHandleToolCall(...args),
93
+ }));
94
+ vi.mock('../features/clear-cache.js', () => ({
95
+ CacheClearer: class {},
96
+ getToolDefinition: vi.fn(() => ({ name: 'clear_cache' })),
97
+ handleToolCall: (...args) => clearHandleToolCall(...args),
98
+ }));
99
+ vi.mock('../features/find-similar-code.js', () => ({
100
+ FindSimilarCode: class {},
101
+ getToolDefinition: vi.fn(() => ({ name: 'find_similar_code' })),
102
+ handleToolCall: (...args) => findHandleToolCall(...args),
103
+ }));
104
+ vi.mock('../features/ann-config.js', () => ({
105
+ AnnConfigTool: class {},
106
+ getToolDefinition: vi.fn(() => ({ name: 'ann_config' })),
107
+ handleToolCall: (...args) => annHandleToolCall(...args),
108
+ }));
109
+ vi.mock('../features/register.js', () => ({
110
+ register: (...args) => registerMock(...args),
111
+ }));
112
+ vi.mock('../features/lifecycle.js', () => ({
113
+ stop: (...args) => stopMock(...args),
114
+ start: (...args) => startMock(...args),
115
+ status: (...args) => statusMock(...args),
116
+ logs: (...args) => logsMock(...args),
117
+ }));
118
+
119
+ const baseConfig = {
120
+ searchDirectory: 'C:\\work',
121
+ embeddingModel: 'test-model',
122
+ cacheDirectory: 'C:\\cache',
123
+ watchFiles: false,
124
+ };
125
+
126
+ describe('index.js CLI coverage', () => {
127
+ let originalArgv;
128
+ let onSpy;
129
+ let exitSpy;
130
+ let errorSpy;
131
+ let infoSpy;
132
+ let listeners;
133
+
134
+ beforeEach(() => {
135
+ vi.resetModules();
136
+ vi.resetAllMocks();
137
+ originalArgv = process.argv;
138
+ listeners = {};
139
+ lastIndexer = null;
140
+ lastCache = null;
141
+ lastServer = null;
142
+ indexAllMock = vi.fn().mockResolvedValue(undefined);
143
+ setupFileWatcherMock = vi.fn();
144
+ hybridHandleToolCall = vi.fn();
145
+ indexHandleToolCall = vi.fn();
146
+ clearHandleToolCall = vi.fn();
147
+ findHandleToolCall = vi.fn();
148
+ annHandleToolCall = vi.fn();
149
+ logsMock = vi.fn();
150
+ registerMock.mockReset();
151
+ stopMock.mockReset();
152
+ startMock.mockReset();
153
+ statusMock.mockReset();
154
+ pipelineMock.mockReset();
155
+ configMock.loadConfig.mockReset();
156
+ configMock.getGlobalCacheDir.mockReset();
157
+ fsMock.access.mockReset();
158
+ onSpy = vi.spyOn(process, 'on').mockImplementation((event, handler) => {
159
+ listeners[event] = handler;
160
+ return process;
161
+ });
162
+ exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => {});
163
+ errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
164
+ infoSpy = vi.spyOn(console, 'info').mockImplementation(() => {});
165
+ });
166
+
167
+ afterEach(() => {
168
+ process.argv = originalArgv;
169
+ onSpy.mockRestore();
170
+ exitSpy.mockRestore();
171
+ errorSpy.mockRestore();
172
+ infoSpy.mockRestore();
173
+ vi.useRealTimers();
174
+ });
175
+
176
+ it('registers with filter and exits', async () => {
177
+ process.argv = ['node', 'index.js', '--register', 'antigravity'];
178
+ registerMock.mockResolvedValue(undefined);
179
+ const exitError = new Error('exit');
180
+ exitSpy.mockImplementation(() => {
181
+ throw exitError;
182
+ });
183
+
184
+ try {
185
+ const { main } = await import('../index.js');
186
+ await main();
187
+ } catch (err) {
188
+ expect(err).toBe(exitError);
189
+ }
190
+
191
+ expect(registerMock).toHaveBeenCalledWith('antigravity');
192
+ expect(exitSpy).toHaveBeenCalledWith(0);
193
+ });
194
+
195
+ it('registers without filter when argument is missing', async () => {
196
+ process.argv = ['node', 'index.js', '--register'];
197
+ registerMock.mockResolvedValue(undefined);
198
+ const exitError = new Error('exit');
199
+ exitSpy.mockImplementation(() => {
200
+ throw exitError;
201
+ });
202
+
203
+ try {
204
+ const { main } = await import('../index.js');
205
+ await main();
206
+ } catch (err) {
207
+ expect(err).toBe(exitError);
208
+ }
209
+
210
+ expect(registerMock).toHaveBeenCalledWith(null);
211
+ expect(exitSpy).toHaveBeenCalledWith(0);
212
+ });
213
+
214
+ it('prints version and exits', async () => {
215
+ process.argv = ['node', 'index.js', '--version'];
216
+ const exitError = new Error('exit');
217
+ exitSpy.mockImplementation(() => {
218
+ throw exitError;
219
+ });
220
+
221
+ try {
222
+ const { main } = await import('../index.js');
223
+ await main();
224
+ } catch (err) {
225
+ expect(err).toBe(exitError);
226
+ }
227
+
228
+ const versionMessages = [...infoSpy.mock.calls, ...errorSpy.mock.calls].map((call) => call[0]);
229
+ const hasVersion = versionMessages.some(
230
+ (message) => typeof message === 'string' && /\d+\.\d+\.\d+/.test(message)
231
+ );
232
+ expect(hasVersion).toBe(true);
233
+ expect(exitSpy).toHaveBeenCalledWith(0);
234
+ });
235
+
236
+ it('enables logs flag and strips args', async () => {
237
+ process.argv = ['node', 'index.js', '--logs'];
238
+ configMock.getGlobalCacheDir.mockReturnValue('C:\\cache-root');
239
+ configMock.loadConfig.mockResolvedValue(baseConfig);
240
+ fsMock.access.mockResolvedValue(undefined);
241
+ pipelineMock.mockResolvedValue(() => ({}));
242
+
243
+ const { main } = await import('../index.js');
244
+ await main();
245
+
246
+ expect(process.env.SMART_CODING_VERBOSE).toBe('true');
247
+ expect(logsMock).toHaveBeenCalled();
248
+ });
249
+
250
+ it('logs memory stats when verbose is enabled', async () => {
251
+ process.argv = ['node', 'index.js'];
252
+ configMock.getGlobalCacheDir.mockReturnValue('C:\\cache-root');
253
+ configMock.loadConfig.mockResolvedValue({
254
+ ...baseConfig,
255
+ verbose: true,
256
+ });
257
+ let accessResolve;
258
+ const accessPromise = new Promise((resolve) => {
259
+ accessResolve = resolve;
260
+ });
261
+ fsMock.access.mockReturnValue(accessPromise);
262
+ pipelineMock.mockResolvedValue(() => ({}));
263
+ vi.useFakeTimers();
264
+ const clearSpy = vi.spyOn(global, 'clearInterval');
265
+
266
+ const { main } = await import('../index.js');
267
+ const importPromise = main();
268
+ await Promise.resolve();
269
+ await vi.advanceTimersByTimeAsync(15000);
270
+ accessResolve();
271
+ await importPromise;
272
+
273
+ const messages = infoSpy.mock.calls.map((call) => call[0]);
274
+ const hasStartup = messages.some(
275
+ (message) => typeof message === 'string' && message.includes('[Server] Memory (startup)')
276
+ );
277
+ const hasModelLoad = messages.some(
278
+ (message) =>
279
+ typeof message === 'string' && message.includes('[Server] Memory (after model load)')
280
+ );
281
+ const hasCacheLoad = messages.some(
282
+ (message) =>
283
+ typeof message === 'string' && message.includes('[Server] Memory (after cache load)')
284
+ );
285
+ expect(hasStartup).toBe(true);
286
+ expect(hasModelLoad).toBe(true);
287
+ expect(hasCacheLoad).toBe(true);
288
+ expect(clearSpy).toHaveBeenCalled();
289
+
290
+ clearSpy.mockRestore();
291
+ });
292
+
293
+ it('handles lifecycle stop/start/status flags', async () => {
294
+ const exitError = new Error('exit');
295
+ exitSpy.mockImplementation(() => {
296
+ throw exitError;
297
+ });
298
+
299
+ process.argv = ['node', 'index.js', '--stop'];
300
+ stopMock.mockResolvedValue(undefined);
301
+ try {
302
+ const { main } = await import('../index.js');
303
+ await main();
304
+ } catch { /* ignore */ }
305
+ expect(stopMock).toHaveBeenCalled();
306
+
307
+ vi.resetModules();
308
+ process.argv = ['node', 'index.js', '--start'];
309
+ startMock.mockResolvedValue(undefined);
310
+ try {
311
+ const { main } = await import('../index.js');
312
+ await main();
313
+ } catch { /* ignore */ }
314
+ expect(startMock).toHaveBeenCalled();
315
+
316
+ vi.resetModules();
317
+ process.argv = ['node', 'index.js', '--status'];
318
+ statusMock.mockResolvedValue(undefined);
319
+ try {
320
+ const { main } = await import('../index.js');
321
+ await main();
322
+ } catch { /* ignore */ }
323
+ expect(statusMock).toHaveBeenCalled();
324
+ });
325
+
326
+ it('falls back when workspace variables are unexpanded', async () => {
327
+ process.argv = ['node', 'index.js', '--workspace', '${workspaceFolder}'];
328
+ configMock.getGlobalCacheDir.mockReturnValue('C:\\cache-root');
329
+ configMock.loadConfig.mockResolvedValue(baseConfig);
330
+ fsMock.access.mockResolvedValue(undefined);
331
+ pipelineMock.mockResolvedValue(() => ({}));
332
+
333
+ const { main } = await import('../index.js');
334
+ await main();
335
+
336
+ const errors = errorSpy.mock.calls.map((call) => call[0]);
337
+ const hasFallback = errors.some(
338
+ (message) => typeof message === 'string' && message.includes('IDE variable not expanded')
339
+ );
340
+ expect(hasFallback).toBe(true);
341
+ });
342
+
343
+ it('parses workspace args with equals and starts watcher', async () => {
344
+ vi.useFakeTimers();
345
+ process.argv = ['node', 'index.js', '--workspace=C:\\work'];
346
+ configMock.getGlobalCacheDir.mockReturnValue('C:\\cache-root');
347
+ configMock.loadConfig.mockResolvedValue({
348
+ ...baseConfig,
349
+ watchFiles: true,
350
+ });
351
+ fsMock.access.mockResolvedValue(undefined);
352
+ pipelineMock.mockResolvedValue(() => ({}));
353
+
354
+ const { main } = await import('../index.js');
355
+ await main();
356
+
357
+ // Trigger the background initialization timeout
358
+ await vi.runAllTimersAsync();
359
+ await Promise.resolve();
360
+ await Promise.resolve();
361
+
362
+ expect(setupFileWatcherMock).toHaveBeenCalled();
363
+ });
364
+
365
+ it('ignores workspace flag without a value', async () => {
366
+ process.argv = ['node', 'index.js', '--workspace'];
367
+ configMock.getGlobalCacheDir.mockReturnValue('C:\\cache-root');
368
+ configMock.loadConfig.mockResolvedValue(baseConfig);
369
+ fsMock.access.mockResolvedValue(undefined);
370
+ pipelineMock.mockResolvedValue(() => ({}));
371
+
372
+ const { main } = await import('../index.js');
373
+ await main();
374
+
375
+ const errors = errorSpy.mock.calls.map((call) => call[0]);
376
+ const hasWorkspace = errors.some(
377
+ (message) => typeof message === 'string' && message.includes('Workspace mode')
378
+ );
379
+ expect(hasWorkspace).toBe(false);
380
+ });
381
+
382
+ it('logs background indexing errors', async () => {
383
+ vi.useFakeTimers();
384
+ process.argv = ['node', 'index.js', '--workspace', 'C:\\work'];
385
+ configMock.getGlobalCacheDir.mockReturnValue('C:\\cache-root');
386
+ configMock.loadConfig.mockResolvedValue(baseConfig);
387
+ fsMock.access.mockResolvedValue(undefined);
388
+ pipelineMock.mockResolvedValue(() => ({}));
389
+ indexAllMock.mockRejectedValue(new Error('index fail'));
390
+
391
+ const { main } = await import('../index.js');
392
+ await main();
393
+
394
+ // Trigger the background initialization timeout
395
+ await vi.runAllTimersAsync();
396
+ await Promise.resolve();
397
+ await Promise.resolve();
398
+
399
+ const errors = errorSpy.mock.calls.map((call) => call[0]);
400
+ const hasError = errors.some(
401
+ (message) => typeof message === 'string' && message.includes('Background indexing error:')
402
+ );
403
+ expect(hasError).toBe(true);
404
+ });
405
+
406
+ it('parses workspace args and handles shutdown cleanup', async () => {
407
+ process.argv = ['node', 'index.js', '--workspace', 'C:\\work'];
408
+ configMock.getGlobalCacheDir.mockReturnValue('C:\\cache-root');
409
+ configMock.loadConfig.mockResolvedValue(baseConfig);
410
+ fsMock.access.mockResolvedValue(undefined);
411
+ pipelineMock.mockResolvedValue(() => ({}));
412
+
413
+ const { main } = await import('../index.js');
414
+ await main();
415
+
416
+ const info = [...infoSpy.mock.calls, ...errorSpy.mock.calls].map((call) => call[0]);
417
+ const hasWorkspace = info.some(
418
+ (message) => typeof message === 'string' && message.includes('Workspace mode')
419
+ );
420
+ expect(hasWorkspace).toBe(true);
421
+ expect(lastIndexer).toBeTruthy();
422
+ expect(lastCache).toBeTruthy();
423
+
424
+ lastIndexer.terminateWorkers
425
+ .mockResolvedValueOnce(undefined)
426
+ .mockRejectedValueOnce(new Error('sigint-fail'))
427
+ .mockResolvedValueOnce(undefined)
428
+ .mockRejectedValueOnce(new Error('sigterm-fail'));
429
+
430
+ vi.useFakeTimers();
431
+ const runHandler = async (handler) => {
432
+ const promise = handler();
433
+ await vi.runAllTimersAsync();
434
+ await promise;
435
+ };
436
+
437
+ await runHandler(listeners.SIGINT);
438
+ await runHandler(listeners.SIGINT);
439
+ await runHandler(listeners.SIGTERM);
440
+ await runHandler(listeners.SIGTERM);
441
+
442
+ expect(lastIndexer.watcher.close).toHaveBeenCalled();
443
+ expect(lastCache.save).toHaveBeenCalled();
444
+ });
445
+
446
+ it('handles shutdown when no watcher, workers, or cache exist', async () => {
447
+ process.argv = ['node', 'index.js', '--workspace', 'C:\\work'];
448
+ configMock.getGlobalCacheDir.mockReturnValue('C:\\cache-root');
449
+ configMock.loadConfig.mockResolvedValue(baseConfig);
450
+ fsMock.access.mockResolvedValue(undefined);
451
+ pipelineMock.mockResolvedValue(() => ({}));
452
+
453
+ const { main } = await import('../index.js');
454
+ await main();
455
+
456
+ lastIndexer.watcher = null;
457
+ lastIndexer.terminateWorkers = null;
458
+ lastCache = null;
459
+
460
+ vi.useFakeTimers();
461
+ exitSpy.mockClear();
462
+ const runHandler = async (handler) => {
463
+ const promise = handler();
464
+ await vi.runAllTimersAsync();
465
+ await promise;
466
+ };
467
+
468
+ await runHandler(listeners.SIGINT);
469
+ await runHandler(listeners.SIGTERM);
470
+
471
+ expect(exitSpy).toHaveBeenCalledWith(0);
472
+ });
473
+
474
+ it('lists tools and routes tool calls', async () => {
475
+ process.argv = ['node', 'index.js', '--workspace', 'C:\\work'];
476
+ configMock.getGlobalCacheDir.mockReturnValue('C:\\cache-root');
477
+ configMock.loadConfig.mockResolvedValue(baseConfig);
478
+ fsMock.access.mockResolvedValue(undefined);
479
+ pipelineMock.mockResolvedValue(() => ({}));
480
+
481
+ const { main } = await import('../index.js');
482
+ await main();
483
+
484
+ const listHandler = lastServer.handlers.get(listSchema);
485
+ const callHandler = lastServer.handlers.get(callSchema);
486
+
487
+ const listResponse = await listHandler();
488
+ expect(listResponse.tools).toHaveLength(5);
489
+
490
+ hybridHandleToolCall.mockResolvedValue({ ok: true });
491
+ const callResponse = await callHandler({
492
+ params: { name: 'semantic_search' },
493
+ });
494
+ expect(hybridHandleToolCall).toHaveBeenCalled();
495
+ expect(callResponse).toEqual({ ok: true });
496
+
497
+ const unknown = await callHandler({ params: { name: 'unknown_tool' } });
498
+ expect(unknown.content[0].text).toContain('Unknown tool');
499
+ });
500
+
501
+ it('handles shutdown when cache is not yet initialized', async () => {
502
+ configMock.loadConfig.mockRejectedValue(new Error('pre-cache fail'));
503
+
504
+ try {
505
+ const { main } = await import('../index.js');
506
+ await main();
507
+ } catch (err) {
508
+ // Expected failure
509
+ }
510
+
511
+ await listeners.SIGINT();
512
+ await listeners.SIGTERM();
513
+
514
+ const errors = errorSpy.mock.calls.map((call) => call[0]);
515
+ const hasCacheSaved = errors.some(
516
+ (message) => typeof message === 'string' && message.includes('Cache saved')
517
+ );
518
+ expect(hasCacheSaved).toBe(false);
519
+ });
520
+ });
@@ -0,0 +1,119 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import fs from 'fs/promises';
3
+ import { smartChunk, hashContent } from '../lib/utils.js';
4
+
5
+ vi.mock('os', () => ({
6
+ default: { cpus: () => [{}] },
7
+ cpus: () => [{}],
8
+ }));
9
+ vi.mock('fs/promises');
10
+ vi.mock('../lib/utils.js', () => ({
11
+ smartChunk: vi.fn(),
12
+ hashContent: vi.fn(),
13
+ }));
14
+
15
+ const createCache = () => ({
16
+ removeFileFromStore: vi.fn(),
17
+ deleteFileHash: vi.fn(),
18
+ setFileHash: vi.fn(),
19
+ addToStore: vi.fn(),
20
+ setVectorStore: vi.fn(),
21
+ getVectorStore: vi.fn().mockReturnValue([]),
22
+ pruneCallGraphData: vi.fn().mockReturnValue(0),
23
+ clearCallGraphData: vi.fn(),
24
+ save: vi.fn().mockResolvedValue(undefined),
25
+ rebuildCallGraph: vi.fn(),
26
+ ensureAnnIndex: vi.fn().mockResolvedValue(null),
27
+ fileHashes: new Map(),
28
+ fileCallData: new Map(),
29
+ getFileHashKeys() {
30
+ return Array.from(this.fileHashes.keys());
31
+ },
32
+ getFileHashCount() {
33
+ return this.fileHashes.size;
34
+ },
35
+ clearFileHashes() {
36
+ this.fileHashes.clear();
37
+ },
38
+ getFileCallDataKeys() {
39
+ return Array.from(this.fileCallData.keys());
40
+ },
41
+ getFileCallDataCount() {
42
+ return this.fileCallData.size;
43
+ },
44
+ clearFileCallData() {
45
+ this.fileCallData.clear();
46
+ },
47
+ setFileCallData: vi.fn(),
48
+ });
49
+
50
+ describe('CodebaseIndexer batch processing presets', () => {
51
+ beforeEach(() => {
52
+ vi.clearAllMocks();
53
+ });
54
+
55
+ it('coerces preset content and skips oversized batches', async () => {
56
+ const { CodebaseIndexer } = await import('../features/index-codebase.js');
57
+ const cache = createCache();
58
+ const config = {
59
+ searchDirectory: '/root',
60
+ excludePatterns: [],
61
+ fileExtensions: ['js'],
62
+ fileNames: [],
63
+ batchSize: 1,
64
+ maxFileSize: 1,
65
+ callGraphEnabled: false,
66
+ verbose: true,
67
+ };
68
+ const indexer = new CodebaseIndexer(vi.fn(), cache, config);
69
+ indexer.discoverFiles = vi.fn().mockResolvedValue(['/root/a.js']);
70
+ indexer.preFilterFiles = vi
71
+ .fn()
72
+ .mockResolvedValue([{ file: '/root/a.js', content: 12345 }]);
73
+
74
+ hashContent.mockReturnValueOnce('hash');
75
+
76
+ const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
77
+
78
+ await indexer.indexAll(false);
79
+
80
+ expect(hashContent).toHaveBeenCalledWith('12345');
81
+ const hasSkip = consoleSpy.mock.calls.some(
82
+ (call) => typeof call[0] === 'string' && call[0].includes('Skipped a.js (too large')
83
+ );
84
+ expect(hasSkip).toBe(true);
85
+
86
+ consoleSpy.mockRestore();
87
+ });
88
+
89
+ it('logs stat errors when preset content is missing', async () => {
90
+ const { CodebaseIndexer } = await import('../features/index-codebase.js');
91
+ const cache = createCache();
92
+ const config = {
93
+ searchDirectory: '/root',
94
+ excludePatterns: [],
95
+ fileExtensions: ['js'],
96
+ fileNames: [],
97
+ batchSize: 1,
98
+ maxFileSize: 100,
99
+ callGraphEnabled: false,
100
+ verbose: true,
101
+ };
102
+ const indexer = new CodebaseIndexer(vi.fn(), cache, config);
103
+ indexer.discoverFiles = vi.fn().mockResolvedValue(['/root/b.js']);
104
+ indexer.preFilterFiles = vi.fn().mockResolvedValue([{ file: '/root/b.js' }]);
105
+
106
+ fs.stat.mockRejectedValueOnce(new Error('stat fail'));
107
+
108
+ const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
109
+
110
+ await indexer.indexAll(false);
111
+
112
+ const hasStatError = consoleSpy.mock.calls.some(
113
+ (call) => typeof call[0] === 'string' && call[0].includes('Failed to stat')
114
+ );
115
+ expect(hasStatError).toBe(true);
116
+
117
+ consoleSpy.mockRestore();
118
+ });
119
+ });