@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.
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 -76
  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,585 @@
1
+ import { vi } from 'vitest';
2
+ import os from 'os';
3
+
4
+ // Mock os.cpus
5
+ vi.mock('os', async () => {
6
+ const actual = await vi.importActual('os');
7
+ return {
8
+ ...actual,
9
+ cpus: vi.fn().mockReturnValue(Array(4).fill({})),
10
+ };
11
+ });
12
+
13
+ let workerMode = 'ready';
14
+
15
+ // Mock Worker
16
+ vi.mock('worker_threads', () => {
17
+ class MockWorker {
18
+ constructor() {
19
+ this.once = vi.fn((event, handler) => {
20
+ if (event === 'message') {
21
+ if (workerMode === 'ready') {
22
+ setImmediate(() => handler({ type: 'ready' }));
23
+ } else if (workerMode === 'error') {
24
+ setImmediate(() => handler({ type: 'error', error: 'boom' }));
25
+ }
26
+ }
27
+ if (event === 'error' && workerMode === 'crash') {
28
+ setImmediate(() => handler(new Error('crash')));
29
+ }
30
+ });
31
+ this.on = vi.fn();
32
+ this.off = vi.fn();
33
+ this.postMessage = vi.fn();
34
+ this.terminate = vi.fn().mockResolvedValue(undefined);
35
+ }
36
+ }
37
+ return { Worker: MockWorker };
38
+ });
39
+
40
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
41
+ import { CodebaseIndexer } from '../features/index-codebase.js';
42
+ import * as utils from '../lib/utils.js';
43
+ import fs from 'fs/promises';
44
+ import path from 'path';
45
+ import { Worker } from 'worker_threads';
46
+
47
+ // Store handlers
48
+ const handlers = {};
49
+ const mockWatcher = {
50
+ on: vi.fn((event, handler) => {
51
+ handlers[event] = handler;
52
+ return mockWatcher;
53
+ }),
54
+ close: vi.fn().mockResolvedValue(undefined),
55
+ };
56
+
57
+ // Mock dependencies
58
+ vi.mock('fs/promises');
59
+ vi.mock('chokidar', () => ({
60
+ default: {
61
+ watch: vi.fn(() => mockWatcher),
62
+ },
63
+ }));
64
+
65
+ describe('CodebaseIndexer Branch Coverage', () => {
66
+ let indexer;
67
+ let mockEmbedder;
68
+ let mockCache;
69
+ let mockConfig;
70
+ let mockServer;
71
+
72
+ beforeEach(() => {
73
+ // Ensure we start with real timers
74
+ vi.useRealTimers();
75
+ workerMode = 'ready';
76
+
77
+ for (const key in handlers) delete handlers[key];
78
+
79
+ mockEmbedder = vi.fn().mockResolvedValue({ data: [0.1, 0.2, 0.3] });
80
+ mockCache = {
81
+ getFileHash: vi.fn(),
82
+ setFileHash: vi.fn(),
83
+ removeFileFromStore: vi.fn(),
84
+ addToStore: vi.fn(),
85
+ deleteFileHash: vi.fn(),
86
+ save: vi.fn(),
87
+ clearCallGraphData: vi.fn(),
88
+ getVectorStore: vi.fn().mockReturnValue([]),
89
+ setVectorStore: vi.fn(),
90
+ ensureAnnIndex: vi.fn().mockResolvedValue(null),
91
+ pruneCallGraphData: vi.fn(),
92
+ fileCallData: new Map(),
93
+ fileHashes: new Map(),
94
+ rebuildCallGraph: vi.fn(),
95
+ setFileCallData: vi.fn(),
96
+ getAnnVector: vi.fn().mockReturnValue(new Float32Array([0.1])),
97
+ setLastIndexDuration: vi.fn(),
98
+ setLastIndexStats: vi.fn(),
99
+ getFileHashKeys: vi.fn().mockImplementation(() => [...mockCache.fileHashes.keys()]),
100
+ setFileHashes: vi.fn((map) => { mockCache.fileHashes = map; }),
101
+ getFileCallDataKeys: vi.fn().mockImplementation(() => [...mockCache.fileCallData.keys()]),
102
+ setFileCallDataEntries: vi.fn((map) => { mockCache.fileCallData = map; }),
103
+ clearFileCallData: vi.fn(() => { mockCache.fileCallData = new Map(); }),
104
+ };
105
+ mockConfig = {
106
+ searchDirectory: '/test',
107
+ fileExtensions: ['js'],
108
+ fileNames: ['.gitignore'],
109
+ excludePatterns: [],
110
+ maxFileSize: 1024 * 1024,
111
+ batchSize: 10,
112
+ verbose: true,
113
+ callGraphEnabled: false,
114
+ watchFiles: true,
115
+ workerThreads: 1,
116
+ embeddingModel: 'test-model',
117
+ };
118
+ mockServer = {
119
+ hybridSearch: {
120
+ clearFileModTime: vi.fn(),
121
+ },
122
+ sendNotification: vi.fn(),
123
+ };
124
+
125
+ indexer = new CodebaseIndexer(mockEmbedder, mockCache, mockConfig, mockServer);
126
+ vi.spyOn(console, 'warn').mockImplementation(() => {});
127
+ vi.spyOn(console, 'info').mockImplementation(() => {});
128
+ });
129
+
130
+ afterEach(() => {
131
+ vi.restoreAllMocks();
132
+ vi.useRealTimers();
133
+ });
134
+
135
+ it('re-initializes workers when thread configuration changes', async () => {
136
+ indexer.config.workerThreads = 'auto';
137
+ vi.spyOn(os, 'cpus').mockReturnValue([{}]);
138
+ await indexer.initializeWorkers();
139
+
140
+ indexer.config.workerThreads = 2;
141
+ await indexer.initializeWorkers();
142
+ });
143
+
144
+ it('covers initializeWorkers timeout branch', async () => {
145
+ vi.useFakeTimers();
146
+ workerMode = 'none';
147
+ indexer.config.workerThreads = 2;
148
+ const promise = indexer.initializeWorkers();
149
+
150
+ // Advance time to trigger timeout
151
+ vi.advanceTimersByTime(130000);
152
+
153
+ // The timeout callback rejects the promise, which is caught in initializeWorkers
154
+ await promise;
155
+ });
156
+
157
+ it('logs warning when worker initialization fails', async () => {
158
+ indexer.config.workerThreads = 2;
159
+ workerMode = 'error';
160
+ await indexer.initializeWorkers();
161
+ expect(console.warn).toHaveBeenCalledWith(
162
+ expect.stringContaining('Worker initialization failed')
163
+ );
164
+ });
165
+
166
+ it('handles various worker message types correctly', async () => {
167
+ const mockWorker = {
168
+ on: vi.fn(),
169
+ off: vi.fn(),
170
+ once: vi.fn(),
171
+ postMessage: vi.fn(),
172
+ };
173
+ indexer.workers = [mockWorker];
174
+
175
+ const chunks = [{ file: 'test.js', text: 'code' }];
176
+ const promise = indexer.processChunksWithWorkers(chunks);
177
+
178
+ const handler = mockWorker.on.mock.calls.find((call) => call[0] === 'message')[1];
179
+
180
+ handler({ batchId: 'wrong' }); // L249 false
181
+
182
+ const batchId = mockWorker.postMessage.mock.calls[0][0].batchId;
183
+ handler({ batchId, type: 'unknown' }); // L254 unknown
184
+ handler({ batchId, type: 'error', error: 'fail' }); // L254 error
185
+
186
+ const results = await promise;
187
+ expect(results).toHaveLength(1); // Fallback ran
188
+ });
189
+
190
+ it('falls back to single-threaded execution on worker error', async () => {
191
+ const mockWorker = {
192
+ on: vi.fn(),
193
+ off: vi.fn(),
194
+ once: vi.fn(),
195
+ postMessage: vi.fn(),
196
+ };
197
+ indexer.workers = [mockWorker];
198
+ const fallbackSpy = vi
199
+ .spyOn(indexer, 'processChunksSingleThreaded')
200
+ .mockResolvedValue([{ success: true }]);
201
+
202
+ const promise = indexer.processChunksWithWorkers([{ file: 'a.js', text: 'c' }]);
203
+ const handler = mockWorker.on.mock.calls.find((call) => call[0] === 'message')[1];
204
+ const batchId = mockWorker.postMessage.mock.calls[0][0].batchId;
205
+ handler({ batchId, type: 'error', error: 'boom' });
206
+
207
+ await promise;
208
+ expect(fallbackSpy).toHaveBeenCalled();
209
+ });
210
+
211
+ it('covers processChunksWithWorkers reset timeout and done=false branch', async () => {
212
+ vi.useFakeTimers();
213
+ const mockWorker = {
214
+ on: vi.fn(),
215
+ off: vi.fn(),
216
+ once: vi.fn(),
217
+ postMessage: vi.fn(),
218
+ };
219
+ indexer.workers = [mockWorker];
220
+ const fallbackSpy = vi
221
+ .spyOn(indexer, 'processChunksSingleThreaded')
222
+ .mockResolvedValue([{ success: true }]);
223
+
224
+ const promise = indexer.processChunksWithWorkers([{ file: 'a.js', text: 'c' }]);
225
+ const handler = mockWorker.on.mock.calls.find((call) => call[0] === 'message')[1];
226
+ const batchId = mockWorker.postMessage.mock.calls[0][0].batchId;
227
+
228
+ handler({
229
+ batchId,
230
+ type: 'results',
231
+ results: [{ success: true }],
232
+ done: false,
233
+ });
234
+
235
+ vi.advanceTimersByTime(1000);
236
+
237
+ await promise;
238
+ expect(fallbackSpy).toHaveBeenCalled();
239
+ });
240
+
241
+ it('covers processChunksWithWorkers postMessage failure', async () => {
242
+ const mockWorker = {
243
+ on: vi.fn(),
244
+ off: vi.fn(),
245
+ once: vi.fn(),
246
+ postMessage: vi.fn(() => {
247
+ throw new Error('post boom');
248
+ }),
249
+ };
250
+ indexer.workers = [mockWorker];
251
+ const fallbackSpy = vi
252
+ .spyOn(indexer, 'processChunksSingleThreaded')
253
+ .mockResolvedValue([{ success: true }]);
254
+
255
+ await indexer.processChunksWithWorkers([{ file: 'a.js', text: 'c' }]);
256
+
257
+ expect(console.warn).toHaveBeenCalledWith(
258
+ expect.stringContaining('postMessage failed')
259
+ );
260
+ expect(fallbackSpy).toHaveBeenCalled();
261
+ });
262
+
263
+ it('returns empty array when input chunks are empty', async () => {
264
+ const mockWorker = {
265
+ on: vi.fn(),
266
+ off: vi.fn(),
267
+ once: vi.fn(),
268
+ postMessage: vi.fn(),
269
+ };
270
+ indexer.workers = [mockWorker];
271
+ const results = await indexer.processChunksWithWorkers([]);
272
+ expect(results).toEqual([]);
273
+ });
274
+
275
+ it('covers processChunksWithWorkers worker crash and timeout', async () => {
276
+ vi.useFakeTimers();
277
+ const mockWorker = {
278
+ on: vi.fn(),
279
+ off: vi.fn(),
280
+ once: vi.fn(),
281
+ postMessage: vi.fn(),
282
+ };
283
+ indexer.workers = [mockWorker];
284
+
285
+ const promise = indexer.processChunksWithWorkers([{ file: 'a.js', text: 'c' }]);
286
+
287
+ // 1. Crash
288
+ const errorHandler = mockWorker.once.mock.calls.find((c) => c[0] === 'error')[1];
289
+ errorHandler(new Error('crash'));
290
+
291
+ // 2. Timeout
292
+ const promise2 = indexer.processChunksWithWorkers([{ file: 'b.js', text: 'c' }]);
293
+ vi.advanceTimersByTime(310000);
294
+
295
+ await promise;
296
+ await promise2;
297
+ });
298
+
299
+ it('covers missing call-graph stats guard (invalid stat)', async () => {
300
+ indexer.config.callGraphEnabled = true;
301
+ indexer.config.verbose = false;
302
+ indexer.discoverFiles = vi.fn().mockResolvedValue(['/test/a.js']);
303
+ indexer.preFilterFiles = vi.fn().mockResolvedValue([]);
304
+ mockCache.getVectorStore.mockReturnValue([{ file: '/test/a.js' }]);
305
+ mockCache.clearFileCallData();
306
+ vi.spyOn(fs, 'stat').mockResolvedValue({});
307
+
308
+ const result = await indexer.indexAll();
309
+
310
+ expect(result?.message).toBe('All files up to date');
311
+ expect(fs.stat).toHaveBeenCalledWith('/test/a.js');
312
+ });
313
+
314
+ it('logs warning when file indexing fails (verbose mode)', async () => {
315
+ indexer.config.verbose = true;
316
+ vi.spyOn(fs, 'stat').mockResolvedValue({ isDirectory: () => false, size: 100, mtimeMs: 123 });
317
+ vi.spyOn(fs, 'readFile').mockResolvedValue('content');
318
+ vi.spyOn(utils, 'hashContent').mockReturnValue('new-hash');
319
+ mockCache.getFileHash.mockReturnValue('old-hash');
320
+
321
+ vi.spyOn(utils, 'smartChunk').mockReturnValue([{ text: 'chunk1', startLine: 1, endLine: 2 }]);
322
+ mockEmbedder.mockRejectedValue(new Error('fail'));
323
+
324
+ await indexer.indexFile('file.js');
325
+ expect(console.warn).toHaveBeenCalledWith(expect.stringContaining('Skipped hash update'));
326
+ });
327
+
328
+ it('updates file hash registry after successful indexing', async () => {
329
+ indexer.config.verbose = false;
330
+ indexer.config.batchSize = 1;
331
+ indexer.config.workerThreads = 0;
332
+ indexer.config.allowSingleThreadFallback = true;
333
+
334
+ indexer.discoverFiles = vi.fn().mockResolvedValue(['/test/a.js']);
335
+ indexer.preFilterFiles = vi
336
+ .fn()
337
+ .mockResolvedValue([{ file: '/test/a.js', content: 'c', hash: 'h' }]);
338
+ vi.spyOn(utils, 'smartChunk').mockReturnValue([{ text: 'chunk', startLine: 1, endLine: 1 }]);
339
+ indexer.processChunksSingleThreaded = vi
340
+ .fn()
341
+ .mockResolvedValue([
342
+ { success: true, file: '/test/a.js', startLine: 1, endLine: 1, vector: [0.1] },
343
+ ]);
344
+
345
+ await indexer.indexAll();
346
+ expect(mockCache.setFileHash).toHaveBeenCalled();
347
+ });
348
+
349
+ it('logs warnings for hash skip and ANN failures', async () => {
350
+ indexer.config.verbose = true;
351
+ indexer.config.batchSize = 1;
352
+ indexer.config.workerThreads = 0;
353
+ indexer.config.allowSingleThreadFallback = true;
354
+ indexer.discoverFiles = vi.fn().mockResolvedValue(['/test/a.js']);
355
+ indexer.preFilterFiles = vi
356
+ .fn()
357
+ .mockResolvedValue([{ file: '/test/a.js', content: 'c', hash: 'h' }]);
358
+ vi.spyOn(utils, 'smartChunk').mockReturnValue([{ text: 'chunk', startLine: 1, endLine: 1 }]);
359
+ indexer.processChunksSingleThreaded = vi
360
+ .fn()
361
+ .mockResolvedValue([
362
+ { success: false, file: '/test/a.js', startLine: 1, endLine: 1, error: 'fail' },
363
+ ]);
364
+ mockCache.ensureAnnIndex.mockRejectedValue(new Error('ANN Boom'));
365
+
366
+ await indexer.indexAll();
367
+ await new Promise((resolve) => setTimeout(resolve, 0));
368
+
369
+ expect(console.warn).toHaveBeenCalledWith(expect.stringContaining('Skipped hash update'));
370
+ expect(console.warn).toHaveBeenCalledWith(
371
+ expect.stringContaining('Background ANN build failed')
372
+ );
373
+ });
374
+
375
+ it('logs skip warnings in verbose mode', async () => {
376
+ indexer.config.verbose = true;
377
+ indexer.config.batchSize = 1;
378
+ indexer.config.workerThreads = 0;
379
+ indexer.config.allowSingleThreadFallback = true;
380
+
381
+ indexer.discoverFiles = vi.fn().mockResolvedValue(['/test/a.js']);
382
+ indexer.preFilterFiles = vi
383
+ .fn()
384
+ .mockResolvedValue([{ file: '/test/a.js', content: 'c', hash: 'h' }]);
385
+
386
+ vi.spyOn(utils, 'smartChunk').mockReturnValue([{ text: 'chunk' }]);
387
+
388
+ indexer.processChunksSingleThreaded = vi
389
+ .fn()
390
+ .mockResolvedValue([{ success: false, file: '/test/a.js', error: 'fail' }]);
391
+
392
+ mockCache.ensureAnnIndex.mockRejectedValue(new Error('ANN Boom'));
393
+
394
+ await indexer.indexAll();
395
+
396
+ // Wait for background promise
397
+ await new Promise((resolve) => setImmediate(resolve));
398
+ expect(console.warn).toHaveBeenCalledWith(expect.stringContaining('Skipped hash update'));
399
+ expect(console.warn).toHaveBeenCalledWith(
400
+ expect.stringContaining('Background ANN build failed')
401
+ );
402
+ });
403
+
404
+ it('processes batches and tracks progress', async () => {
405
+ indexer.config.verbose = false;
406
+ indexer.config.batchSize = 1;
407
+
408
+ indexer.discoverFiles = vi.fn().mockResolvedValue(['a', 'b', 'c', 'd']);
409
+ indexer.preFilterFiles = vi.fn().mockResolvedValue([
410
+ { file: 'a', content: 'c', hash: 'h' },
411
+ { file: 'b', content: 'c', hash: 'h' },
412
+ { file: 'c', content: 'c', hash: 'h' },
413
+ { file: 'd', content: 'c', hash: 'h' },
414
+ ]);
415
+
416
+ indexer.processChunksSingleThreaded = vi.fn().mockImplementation(async (chunks) => {
417
+ return chunks.map((c) => ({ success: true, file: c.file, vector: [0.1] }));
418
+ });
419
+
420
+ await indexer.indexAll();
421
+ });
422
+
423
+ it('logs when queued watch events processing fails', async () => {
424
+ indexer.discoverFiles = vi.fn().mockResolvedValue([]);
425
+ indexer.processPendingWatchEvents = vi
426
+ .fn()
427
+ .mockRejectedValue(new Error('queue boom'));
428
+
429
+ await indexer.indexAll();
430
+
431
+ expect(console.warn).toHaveBeenCalledWith(
432
+ expect.stringContaining('Failed to apply queued file updates')
433
+ );
434
+ });
435
+
436
+ it('overwrites queued unlink when a new event arrives', () => {
437
+ const filePath = '/test/file.js';
438
+ indexer.pendingWatchEvents.set(filePath, 'unlink');
439
+
440
+ CodebaseIndexer.prototype.enqueueWatchEvent.call(indexer, 'change', filePath);
441
+
442
+ expect(indexer.pendingWatchEvents.get(filePath)).toBe('change');
443
+ });
444
+
445
+ it('covers setupFileWatcher branches', async () => {
446
+ indexer.config.watchFiles = true;
447
+ await indexer.setupFileWatcher();
448
+ await indexer.setupFileWatcher();
449
+
450
+ // TRUE branches
451
+ await handlers['add']('file.js');
452
+ await handlers['change']('file.js');
453
+ await handlers['unlink']('file.js');
454
+
455
+ // FALSE branches
456
+ indexer.server = null;
457
+ await handlers['add']('file.js');
458
+ });
459
+
460
+ it('skips files provided with content if too large', async () => {
461
+ indexer.discoverFiles = vi.fn().mockResolvedValue(['file-large-content.js']);
462
+ // Mock preFilterFiles to return an entry with content that exceeds maxFileSize
463
+ indexer.preFilterFiles = vi.fn().mockResolvedValue([
464
+ {
465
+ file: 'file-large-content.js',
466
+ content: 'x'.repeat(mockConfig.maxFileSize + 100),
467
+ hash: 'hash1',
468
+ force: false
469
+ }
470
+ ]);
471
+
472
+ await indexer.indexAll();
473
+
474
+ expect(console.warn).toHaveBeenCalledWith(
475
+ expect.stringContaining('Skipped file-large-content.js (too large:')
476
+ );
477
+ expect(mockEmbedder).not.toHaveBeenCalled();
478
+ });
479
+
480
+ it('skips files with invalid stat results', async () => {
481
+ indexer.discoverFiles = vi.fn().mockResolvedValue(['invalid-stat.js']);
482
+ indexer.preFilterFiles = vi.fn().mockResolvedValue([
483
+ { file: 'invalid-stat.js', force: false }
484
+ ]);
485
+
486
+ vi.spyOn(fs, 'stat').mockResolvedValue(null);
487
+
488
+ await indexer.indexAll();
489
+
490
+ expect(console.warn).toHaveBeenCalledWith(
491
+ expect.stringContaining('Invalid stat result for invalid-stat.js')
492
+ );
493
+ });
494
+
495
+ it('skips files that are too large via stat check', async () => {
496
+ indexer.discoverFiles = vi.fn().mockResolvedValue(['large-stat.js']);
497
+ indexer.preFilterFiles = vi.fn().mockResolvedValue([
498
+ { file: 'large-stat.js', force: false }
499
+ ]);
500
+
501
+ vi.spyOn(fs, 'stat').mockResolvedValue({
502
+ isDirectory: () => false,
503
+ size: mockConfig.maxFileSize + 100,
504
+ mtimeMs: 123
505
+ });
506
+
507
+ await indexer.indexAll();
508
+
509
+ expect(console.warn).toHaveBeenCalledWith(
510
+ expect.stringContaining('Skipped large-stat.js (too large:')
511
+ );
512
+ });
513
+
514
+ it('handles read file failures', async () => {
515
+ indexer.discoverFiles = vi.fn().mockResolvedValue(['read-error.js']);
516
+ indexer.preFilterFiles = vi.fn().mockResolvedValue([
517
+ { file: 'read-error.js', force: false }
518
+ ]);
519
+
520
+ vi.spyOn(fs, 'stat').mockResolvedValue({
521
+ isDirectory: () => false,
522
+ size: 50,
523
+ mtimeMs: 123
524
+ });
525
+ vi.spyOn(fs, 'readFile').mockRejectedValue(new Error('Read failed'));
526
+
527
+ await indexer.indexAll();
528
+
529
+ expect(console.warn).toHaveBeenCalledWith(
530
+ expect.stringContaining('Failed to read read-error.js: Read failed')
531
+ );
532
+ });
533
+
534
+ it('skips unchanged files when hash matches', async () => {
535
+ indexer.discoverFiles = vi.fn().mockResolvedValue(['unchanged.js']);
536
+ indexer.preFilterFiles = vi.fn().mockResolvedValue([
537
+ { file: 'unchanged.js', force: false }
538
+ ]);
539
+
540
+ vi.spyOn(fs, 'stat').mockResolvedValue({
541
+ isDirectory: () => false,
542
+ size: 50,
543
+ mtimeMs: 123
544
+ });
545
+ vi.spyOn(fs, 'readFile').mockResolvedValue('content');
546
+
547
+ vi.spyOn(utils, 'hashContent').mockReturnValue('same-hash');
548
+ mockCache.getFileHash.mockReturnValue('same-hash');
549
+
550
+ await indexer.indexAll();
551
+
552
+ expect(console.info).toHaveBeenCalledWith(
553
+ expect.stringContaining('Skipped unchanged.js (unchanged)')
554
+ );
555
+ expect(mockEmbedder).not.toHaveBeenCalled();
556
+ });
557
+
558
+ it('queues watch events when indexing is in progress', async () => {
559
+ await indexer.setupFileWatcher();
560
+
561
+ // Simulate indexing in progress
562
+ indexer.isIndexing = true;
563
+
564
+ // Trigger ADD event
565
+ await handlers['add']('added.js');
566
+ expect(indexer.pendingWatchEvents.get(path.join('/test', 'added.js'))).toBe('add');
567
+ expect(console.info).toHaveBeenCalledWith(
568
+ expect.stringContaining('Queued add event during indexing')
569
+ );
570
+
571
+ // Trigger CHANGE event
572
+ await handlers['change']('changed.js');
573
+ expect(indexer.pendingWatchEvents.get(path.join('/test', 'changed.js'))).toBe('change');
574
+ expect(console.info).toHaveBeenCalledWith(
575
+ expect.stringContaining('Queued change event during indexing')
576
+ );
577
+
578
+ // Trigger UNLINK event
579
+ await handlers['unlink']('deleted.js');
580
+ expect(indexer.pendingWatchEvents.get(path.join('/test', 'deleted.js'))).toBe('unlink');
581
+ expect(console.info).toHaveBeenCalledWith(
582
+ expect.stringContaining('Queued delete event during indexing')
583
+ );
584
+ });
585
+ });