@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,151 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { CodebaseIndexer } from '../features/index-codebase.js';
3
+ import path from 'path';
4
+
5
+ // Helper to access the private/internal function if it was exported,
6
+ // but since it's not, we have to test it through public methods that use it.
7
+ // buildExcludeMatchers uses globToRegExp.
8
+ // isExcluded uses matchesExcludePatterns which uses buildExcludeMatchers.
9
+
10
+ describe('CodebaseIndexer Glob Coverage', () => {
11
+ it('should handle single star glob patterns correctly', () => {
12
+ // This targets the branch in globToRegExp:
13
+ // if (char === "*") { ... } else { regex += "[^/]*"; ... }
14
+
15
+ // We need a pattern with a single '*' that is NOT followed by '*'
16
+ // e.g., "*.log" or "src/*.js"
17
+
18
+ const config = {
19
+ excludePatterns: ['*.log', 'src/*.js'],
20
+ fileExtensions: ['js'],
21
+ searchDirectory: '/test',
22
+ verbose: true,
23
+ };
24
+
25
+ // Mock dependencies
26
+ const embedder = vi.fn();
27
+ const cache = { load: vi.fn() };
28
+
29
+ const indexer = new CodebaseIndexer(embedder, cache, config);
30
+
31
+ // These calls should trigger the regex generation and matching logic
32
+ expect(indexer.isExcluded('error.log')).toBe(true);
33
+ expect(indexer.isExcluded('src/utils.js')).toBe(true);
34
+ expect(indexer.isExcluded('src/utils.test.js')).toBe(true);
35
+ expect(indexer.isExcluded('src/sub/utils.js')).toBe(false); // Single * shouldn't match across dirs usually if it's not **
36
+ expect(indexer.isExcluded('other.js')).toBe(false);
37
+ });
38
+
39
+ it('should handle question mark glob patterns', () => {
40
+ // Targets: if (char === "?")
41
+
42
+ const config = {
43
+ excludePatterns: ['test?.js'],
44
+
45
+ fileExtensions: ['js'],
46
+
47
+ searchDirectory: '/test',
48
+ };
49
+
50
+ const indexer = new CodebaseIndexer(vi.fn(), {}, config);
51
+
52
+ expect(indexer.isExcluded('test1.js')).toBe(true);
53
+
54
+ expect(indexer.isExcluded('testA.js')).toBe(true);
55
+
56
+ expect(indexer.isExcluded('test10.js')).toBe(false);
57
+ });
58
+
59
+ it('should handle double star not followed by slash', () => {
60
+ // Targets: if (pattern[i + 1] === "*") ... else { regex += ".*"; ... }
61
+
62
+ // Use a pattern with "/" to ensure matchBase is false, testing the full regex against path
63
+
64
+ const config = {
65
+ excludePatterns: ['dir/foo**bar'],
66
+
67
+ fileExtensions: ['js'],
68
+
69
+ searchDirectory: '/test',
70
+ };
71
+
72
+ const indexer = new CodebaseIndexer(vi.fn(), {}, config);
73
+
74
+ // Pattern "dir/foo**bar" becomes "^dir/foo.*bar$"
75
+
76
+ expect(indexer.isExcluded('dir/fooxyzbar')).toBe(true);
77
+
78
+ expect(indexer.isExcluded('dir/foobar')).toBe(true);
79
+
80
+ // ".*" matches "/" so this should match
81
+
82
+ expect(indexer.isExcluded('dir/foo/nested/bar')).toBe(true);
83
+ });
84
+ });
85
+
86
+ describe('CodebaseIndexer Worker Chunking', () => {
87
+ it('should handle fewer chunks than workers (Line 222 coverage)', async () => {
88
+ // config.workerThreads = 2
89
+
90
+ // chunks = [1]
91
+
92
+ // Worker 0 gets 1, Worker 1 gets 0 -> continues
93
+
94
+ // Mock os.cpus() first
95
+
96
+ vi.mock('os', async () => {
97
+ const actual = await vi.importActual('os');
98
+
99
+ return {
100
+ ...actual,
101
+
102
+ cpus: () => [{}, {}, {}, {}],
103
+ };
104
+ });
105
+
106
+ // Mock worker_threads
107
+
108
+ vi.mock('worker_threads', async () => {
109
+ const { EventEmitter } = await import('events');
110
+
111
+ class Worker extends EventEmitter {
112
+ constructor() {
113
+ super();
114
+ setTimeout(() => this.emit('message', { type: 'ready' }), 1);
115
+ }
116
+
117
+ terminate() {
118
+ return Promise.resolve();
119
+ }
120
+
121
+ postMessage(msg) {
122
+ if (msg.type === 'process') {
123
+ this.emit('message', { type: 'results', results: [], batchId: msg.batchId });
124
+ }
125
+ }
126
+ }
127
+
128
+ return { Worker };
129
+ });
130
+
131
+ const { CodebaseIndexer } = await import('../features/index-codebase.js');
132
+
133
+ const config = { workerThreads: 2, verbose: true }; // Verbose to hit logging branches too
134
+
135
+ const indexer = new CodebaseIndexer(
136
+ vi.fn(),
137
+ { save: vi.fn(), getVectorStore: () => [] },
138
+ config
139
+ );
140
+
141
+ await indexer.initializeWorkers();
142
+
143
+ // 1 chunk, 2 workers
144
+
145
+ await indexer.processChunksWithWorkers([{ text: 'abc', file: 'f.js' }]);
146
+
147
+ await indexer.terminateWorkers();
148
+
149
+ vi.restoreAllMocks();
150
+ });
151
+ });
@@ -0,0 +1,259 @@
1
+ /**
2
+ * Tests for CodebaseIndexer file watcher behavior
3
+ */
4
+
5
+ import { describe, it, expect, vi } from 'vitest';
6
+ import path from 'path';
7
+ import fs from 'fs/promises';
8
+ import os from 'os';
9
+
10
+ vi.mock('chokidar', () => {
11
+ const { EventEmitter } = require('events');
12
+ const watch = vi.fn(() => {
13
+ const emitter = new EventEmitter();
14
+ emitter.close = vi.fn().mockResolvedValue();
15
+ const on = emitter.on.bind(emitter);
16
+ emitter.on = (event, handler) => {
17
+ on(event, handler);
18
+ return emitter;
19
+ };
20
+ globalThis.__heuristicWatcher = emitter;
21
+ return emitter;
22
+ });
23
+
24
+ return {
25
+ __esModule: true,
26
+ default: {
27
+ watch,
28
+ },
29
+ };
30
+ });
31
+
32
+ import { CodebaseIndexer } from '../features/index-codebase.js';
33
+
34
+ async function withTempDir(testFn) {
35
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'heuristic-watcher-'));
36
+ try {
37
+ await testFn(dir);
38
+ } finally {
39
+ await fs.rm(dir, { recursive: true, force: true });
40
+ }
41
+ }
42
+
43
+ function flushPromises() {
44
+ return new Promise((resolve) => setImmediate(resolve));
45
+ }
46
+
47
+ describe('CodebaseIndexer watcher', () => {
48
+ it('wires watcher events to indexing and cache updates', async () => {
49
+ await withTempDir(async (dir) => {
50
+ const config = {
51
+ fileExtensions: ['js'],
52
+ fileNames: [],
53
+ searchDirectory: dir,
54
+ excludePatterns: [],
55
+ watchFiles: true,
56
+ enableCache: true,
57
+ callGraphEnabled: false,
58
+ embeddingModel: 'test',
59
+ verbose: false,
60
+ };
61
+
62
+ const cache = {
63
+ save: vi.fn().mockResolvedValue(),
64
+ removeFileFromStore: vi.fn(),
65
+ deleteFileHash: vi.fn(),
66
+ };
67
+
68
+ const server = {
69
+ hybridSearch: {
70
+ clearFileModTime: vi.fn(),
71
+ },
72
+ };
73
+
74
+ const indexer = new CodebaseIndexer(async () => ({ data: [] }), cache, config, server);
75
+ indexer.indexFile = vi.fn().mockResolvedValue();
76
+ indexer.isIndexing = false;
77
+ indexer.processingWatchEvents = false;
78
+
79
+ await indexer.setupFileWatcher();
80
+
81
+ const relPath = path.join('src', 'file.js');
82
+ indexer.watcher.emit('add', relPath);
83
+ await flushPromises();
84
+
85
+ expect(indexer.indexFile).toHaveBeenCalledWith(path.join(dir, relPath));
86
+ expect(cache.save).toHaveBeenCalled();
87
+ expect(server.hybridSearch.clearFileModTime).toHaveBeenCalledWith(path.join(dir, relPath));
88
+
89
+ indexer.watcher.emit('change', relPath);
90
+ await flushPromises();
91
+ expect(indexer.indexFile).toHaveBeenCalledTimes(2);
92
+
93
+ indexer.watcher.emit('unlink', relPath);
94
+ await flushPromises();
95
+ expect(cache.removeFileFromStore).toHaveBeenCalledWith(path.join(dir, relPath));
96
+ expect(cache.deleteFileHash).toHaveBeenCalledWith(path.join(dir, relPath));
97
+ });
98
+ });
99
+
100
+ it('closes existing watcher before reinitializing', async () => {
101
+ await withTempDir(async (dir) => {
102
+ const config = {
103
+ fileExtensions: ['js'],
104
+ fileNames: [],
105
+ searchDirectory: dir,
106
+ excludePatterns: [],
107
+ watchFiles: true,
108
+ enableCache: true,
109
+ callGraphEnabled: false,
110
+ embeddingModel: 'test',
111
+ verbose: false,
112
+ };
113
+
114
+ const cache = {
115
+ save: vi.fn().mockResolvedValue(),
116
+ removeFileFromStore: vi.fn(),
117
+ deleteFileHash: vi.fn(),
118
+ };
119
+
120
+ const indexer = new CodebaseIndexer(async () => ({ data: [] }), cache, config, null);
121
+ indexer.indexFile = vi.fn().mockResolvedValue();
122
+
123
+ await indexer.setupFileWatcher();
124
+ const firstWatcher = globalThis.__heuristicWatcher;
125
+
126
+ await indexer.setupFileWatcher();
127
+
128
+ expect(firstWatcher.close).toHaveBeenCalledTimes(1);
129
+ expect(globalThis.__heuristicWatcher).not.toBe(firstWatcher);
130
+ });
131
+ });
132
+
133
+ it('queues change and unlink events during active indexing', async () => {
134
+ await withTempDir(async (dir) => {
135
+ const config = {
136
+ fileExtensions: ['js'],
137
+ fileNames: [],
138
+ searchDirectory: dir,
139
+ excludePatterns: [],
140
+ watchFiles: true,
141
+ enableCache: true,
142
+ callGraphEnabled: false,
143
+ embeddingModel: 'test',
144
+ verbose: true,
145
+ };
146
+
147
+ const cache = {
148
+ save: vi.fn().mockResolvedValue(),
149
+ removeFileFromStore: vi.fn(),
150
+ deleteFileHash: vi.fn(),
151
+ };
152
+
153
+ const indexer = new CodebaseIndexer(async () => ({ data: [] }), cache, config, null);
154
+ indexer.indexFile = vi.fn().mockResolvedValue();
155
+ indexer.isIndexing = true;
156
+
157
+ const enqueueSpy = vi.spyOn(indexer, 'enqueueWatchEvent');
158
+
159
+ await indexer.setupFileWatcher();
160
+
161
+ const relPath = path.join('src', 'file.js');
162
+ globalThis.__heuristicWatcher.emit('add', relPath);
163
+ globalThis.__heuristicWatcher.emit('change', relPath);
164
+ globalThis.__heuristicWatcher.emit('unlink', relPath);
165
+ await flushPromises();
166
+
167
+ expect(enqueueSpy).toHaveBeenCalledWith('add', path.join(dir, relPath));
168
+ expect(enqueueSpy).toHaveBeenCalledWith('change', path.join(dir, relPath));
169
+ expect(enqueueSpy).toHaveBeenCalledWith('unlink', path.join(dir, relPath));
170
+ expect(indexer.indexFile).not.toHaveBeenCalled();
171
+ expect(cache.save).not.toHaveBeenCalled();
172
+ });
173
+ });
174
+
175
+ it('queues events without verbose logging when indexing', async () => {
176
+ await withTempDir(async (dir) => {
177
+ const config = {
178
+ fileExtensions: ['js'],
179
+ fileNames: [],
180
+ searchDirectory: dir,
181
+ excludePatterns: [],
182
+ watchFiles: true,
183
+ enableCache: true,
184
+ callGraphEnabled: false,
185
+ embeddingModel: 'test',
186
+ verbose: false,
187
+ };
188
+
189
+ const cache = {
190
+ save: vi.fn().mockResolvedValue(),
191
+ removeFileFromStore: vi.fn(),
192
+ deleteFileHash: vi.fn(),
193
+ };
194
+
195
+ const indexer = new CodebaseIndexer(async () => ({ data: [] }), cache, config, null);
196
+ indexer.indexFile = vi.fn().mockResolvedValue();
197
+ indexer.isIndexing = true;
198
+
199
+ const enqueueSpy = vi.spyOn(indexer, 'enqueueWatchEvent');
200
+
201
+ await indexer.setupFileWatcher();
202
+
203
+ const relPath = path.join('src', 'file.js');
204
+ globalThis.__heuristicWatcher.emit('add', relPath);
205
+ globalThis.__heuristicWatcher.emit('change', relPath);
206
+ globalThis.__heuristicWatcher.emit('unlink', relPath);
207
+ await flushPromises();
208
+
209
+ expect(enqueueSpy).toHaveBeenCalledWith('add', path.join(dir, relPath));
210
+ expect(enqueueSpy).toHaveBeenCalledWith('change', path.join(dir, relPath));
211
+ expect(enqueueSpy).toHaveBeenCalledWith('unlink', path.join(dir, relPath));
212
+ });
213
+ });
214
+
215
+ it('processes pending watch events and clears hybrid search cache', async () => {
216
+ await withTempDir(async (dir) => {
217
+ const config = {
218
+ fileExtensions: ['js'],
219
+ fileNames: [],
220
+ searchDirectory: dir,
221
+ excludePatterns: [],
222
+ watchFiles: true,
223
+ enableCache: true,
224
+ callGraphEnabled: false,
225
+ embeddingModel: 'test',
226
+ verbose: false,
227
+ };
228
+
229
+ const cache = {
230
+ save: vi.fn().mockResolvedValue(),
231
+ removeFileFromStore: vi.fn(),
232
+ deleteFileHash: vi.fn(),
233
+ };
234
+
235
+ const server = {
236
+ hybridSearch: {
237
+ clearFileModTime: vi.fn(),
238
+ },
239
+ };
240
+
241
+ const indexer = new CodebaseIndexer(async () => ({ data: [] }), cache, config, server);
242
+ indexer.indexFile = vi.fn().mockResolvedValue();
243
+
244
+ const changePath = path.join(dir, 'change.js');
245
+ const unlinkPath = path.join(dir, 'unlink.js');
246
+ indexer.pendingWatchEvents.set(changePath, 'change');
247
+ indexer.pendingWatchEvents.set(unlinkPath, 'unlink');
248
+
249
+ await indexer.processPendingWatchEvents();
250
+
251
+ expect(server.hybridSearch.clearFileModTime).toHaveBeenCalledWith(changePath);
252
+ expect(server.hybridSearch.clearFileModTime).toHaveBeenCalledWith(unlinkPath);
253
+ expect(indexer.indexFile).toHaveBeenCalledWith(changePath);
254
+ expect(cache.removeFileFromStore).toHaveBeenCalledWith(unlinkPath);
255
+ expect(cache.deleteFileHash).toHaveBeenCalledWith(unlinkPath);
256
+ expect(cache.save).toHaveBeenCalled();
257
+ });
258
+ });
259
+ });
@@ -0,0 +1,259 @@
1
+
2
+ import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach, vi } from 'vitest';
3
+ import {
4
+ createTestFixtures,
5
+ cleanupFixtures,
6
+ clearTestCache,
7
+ } from './helpers.js';
8
+ import fs from 'fs/promises';
9
+ import path from 'path';
10
+
11
+ describe('CodebaseIndexer Phase 2 Coverage', () => {
12
+ let fixtures;
13
+
14
+ beforeAll(async () => {
15
+ fixtures = await createTestFixtures({ workerThreads: 1 });
16
+ });
17
+
18
+ afterAll(async () => {
19
+ await cleanupFixtures(fixtures);
20
+ });
21
+
22
+ beforeEach(async () => {
23
+ fixtures.indexer.isIndexing = false;
24
+ await fixtures.indexer.terminateWorkers();
25
+ await clearTestCache(fixtures.config);
26
+ fixtures.cache.setVectorStore([]);
27
+ fixtures.cache.clearFileHashes();
28
+ fixtures.config.verbose = true;
29
+ });
30
+
31
+ afterEach(() => {
32
+ vi.restoreAllMocks();
33
+ });
34
+
35
+ it('should handle read errors in pre-filter batch processing (lines 553-554)', async () => {
36
+ const subDir = path.join(fixtures.config.searchDirectory, 'p2_read_data');
37
+ await fs.mkdir(subDir, { recursive: true });
38
+
39
+ const fileGood = path.join(subDir, 'good.js');
40
+ const fileBad = path.join(subDir, 'bad.js');
41
+
42
+ await fs.writeFile(fileGood, 'good content');
43
+ await fs.writeFile(fileBad, 'bad content');
44
+
45
+ const realStat = fs.stat;
46
+ const statSpy = vi.spyOn(fs, 'stat').mockImplementation(async (filePath) => {
47
+ if (filePath.toString().includes('bad.js')) {
48
+ throw new Error('Simulated stat error');
49
+ }
50
+ return realStat.call(fs, filePath);
51
+ });
52
+
53
+ try {
54
+ // Call preFilterFiles directly to bypass discovery issues
55
+ const result = await fixtures.indexer.preFilterFiles([fileGood, fileBad]);
56
+
57
+ console.error('PreFilter Result:', result);
58
+
59
+ // Good file should be included
60
+ const hasGood = result.some(r => r.file.includes('good.js'));
61
+ // Bad file should be excluded (due to error)
62
+ const hasBad = result.some(r => r.file.includes('bad.js'));
63
+
64
+ expect(hasGood).toBe(true);
65
+ expect(hasBad).toBe(false);
66
+
67
+ } finally {
68
+ statSpy.mockRestore();
69
+ await fs.rm(subDir, { recursive: true, force: true });
70
+ }
71
+ });
72
+
73
+ it('should flush read batch when size limit exceeded (lines 571-573)', async () => {
74
+ const subDir = path.join(fixtures.config.searchDirectory, 'p2_flush_data');
75
+ await fs.mkdir(subDir, { recursive: true });
76
+
77
+ const file1 = path.join(subDir, 'f1.js');
78
+ const file2 = path.join(subDir, 'f2.js');
79
+ const file3 = path.join(subDir, 'f3.js');
80
+
81
+ await fs.writeFile(file1, 'c1');
82
+ await fs.writeFile(file2, 'c2');
83
+ await fs.writeFile(file3, 'c3');
84
+
85
+ const realStat = fs.stat;
86
+ const statSpy = vi.spyOn(fs, 'stat').mockImplementation(async (filePath) => {
87
+ if (filePath.toString().includes('p2_flush_data')) {
88
+ const s = await realStat.call(fs, filePath);
89
+ // Modify directly
90
+ s.size = 20 * 1024 * 1024; // 20MB
91
+ return s;
92
+ }
93
+ return realStat.call(fs, filePath);
94
+ });
95
+
96
+ const oldMax = fixtures.config.maxFileSize;
97
+ fixtures.config.maxFileSize = 100 * 1024 * 1024; // 100MB
98
+
99
+ try {
100
+ // Pass 3 files. Total 60MB. Batch limit 50MB.
101
+ // Should trigger intermediate flush.
102
+ const result = await fixtures.indexer.preFilterFiles([file1, file2, file3]);
103
+
104
+ console.error('Batch flush result:', result);
105
+
106
+ expect(result.some(r => r.file.includes('f1.js'))).toBe(true);
107
+ expect(result.some(r => r.file.includes('f2.js'))).toBe(true);
108
+ expect(result.some(r => r.file.includes('f3.js'))).toBe(true);
109
+
110
+ } finally {
111
+ statSpy.mockRestore();
112
+ fixtures.config.maxFileSize = oldMax;
113
+ await fs.rm(subDir, { recursive: true, force: true });
114
+ }
115
+ });
116
+
117
+ it('should handle invalid stats in call graph recovery (line 701)', async () => {
118
+ const subDir = path.join(fixtures.config.searchDirectory, 'p2_recovery_data');
119
+ await fs.mkdir(subDir, { recursive: true });
120
+
121
+ const fileRecover = path.join(subDir, 'recover.js');
122
+ await fs.writeFile(fileRecover, 'content');
123
+
124
+ fixtures.cache.addToStore({
125
+ file: fileRecover,
126
+ startLine: 1, endLine: 1, content: 'content', vector: []
127
+ });
128
+
129
+ const realStat = fs.stat;
130
+ const statSpy = vi.spyOn(fs, 'stat').mockImplementation(async (filePath) => {
131
+ if (filePath.toString().includes('recover.js')) {
132
+ return { isDirectory: 'not-a-function' };
133
+ }
134
+ return realStat.call(fs, filePath);
135
+ });
136
+
137
+ fixtures.config.callGraphEnabled = true;
138
+
139
+ try {
140
+ await fixtures.indexer.indexAll(false);
141
+ } finally {
142
+ statSpy.mockRestore();
143
+ await fs.rm(subDir, { recursive: true, force: true });
144
+ }
145
+ });
146
+
147
+ it('should skip large files provided with content (L825)', async () => {
148
+ const largeContent = 'x'.repeat(fixtures.config.maxFileSize + 1024);
149
+ fixtures.indexer.discoverFiles = vi.fn().mockResolvedValue(['large.js']);
150
+ fixtures.indexer.preFilterFiles = vi
151
+ .fn()
152
+ .mockResolvedValue([{ file: 'large.js', content: largeContent, hash: 'h', force: true }]);
153
+
154
+ await fixtures.indexer.indexAll();
155
+ });
156
+
157
+ it('coerces non-string content and skips when too large (L825)', async () => {
158
+ fixtures.indexer.discoverFiles = vi.fn().mockResolvedValue(['large2.js']);
159
+ fixtures.indexer.preFilterFiles = vi
160
+ .fn()
161
+ .mockResolvedValue([{ file: 'large2.js', content: 42, hash: null, force: true }]);
162
+ const oldMax = fixtures.config.maxFileSize;
163
+ fixtures.config.maxFileSize = 1;
164
+
165
+ try {
166
+ await fixtures.indexer.indexAll();
167
+ } finally {
168
+ fixtures.config.maxFileSize = oldMax;
169
+ }
170
+ });
171
+
172
+ it('should log error when stat fails (L837)', async () => {
173
+ fixtures.indexer.discoverFiles = vi.fn().mockResolvedValue(['stat-error.js']);
174
+ fixtures.indexer.preFilterFiles = vi
175
+ .fn()
176
+ .mockResolvedValue([{ file: 'stat-error.js', hash: 'h', force: true }]);
177
+
178
+ vi.spyOn(fs, 'stat').mockRejectedValue(new Error('Stat failed'));
179
+
180
+ await fixtures.indexer.indexAll();
181
+ });
182
+
183
+ it('should handle read failures in main loop (L870)', async () => {
184
+ fixtures.indexer.discoverFiles = vi.fn().mockResolvedValue(['fail.js']);
185
+ fixtures.indexer.preFilterFiles = vi
186
+ .fn()
187
+ .mockResolvedValue([{ file: 'fail.js', hash: 'h', force: true }]);
188
+
189
+ vi.spyOn(fs, 'stat').mockResolvedValue({ isDirectory: () => false, size: 100 });
190
+ vi.spyOn(fs, 'readFile').mockRejectedValue(new Error('Read fail'));
191
+
192
+ await fixtures.indexer.indexAll();
193
+ });
194
+
195
+ it('should skip unchanged files in loop (L882)', async () => {
196
+ fixtures.indexer.discoverFiles = vi.fn().mockResolvedValue(['same.js']);
197
+ fixtures.indexer.preFilterFiles = vi
198
+ .fn()
199
+ .mockResolvedValue([{ file: 'same.js', hash: 'h', force: false }]);
200
+
201
+ vi.spyOn(fs, 'stat').mockResolvedValue({ isDirectory: () => false, size: 100 });
202
+ vi.spyOn(fs, 'readFile').mockResolvedValue('content');
203
+ vi.spyOn(fixtures.indexer.cache, 'getFileHash').mockReturnValue('h');
204
+
205
+ await fixtures.indexer.indexAll();
206
+ });
207
+
208
+ it('handles invalid stats in main loop (L846)', async () => {
209
+ fixtures.indexer.discoverFiles = vi.fn().mockResolvedValue(['invalid.js']);
210
+ fixtures.indexer.preFilterFiles = vi
211
+ .fn()
212
+ .mockResolvedValue([{ file: 'invalid.js', hash: 'h', force: true }]);
213
+
214
+ vi.spyOn(fs, 'stat').mockResolvedValue({});
215
+
216
+ await fixtures.indexer.indexAll();
217
+ });
218
+
219
+ it('skips large file during stat pass (L859)', async () => {
220
+ fixtures.indexer.discoverFiles = vi.fn().mockResolvedValue(['big.js']);
221
+ fixtures.indexer.preFilterFiles = vi
222
+ .fn()
223
+ .mockResolvedValue([{ file: 'big.js', hash: 'h', force: true }]);
224
+
225
+ vi.spyOn(fs, 'stat').mockResolvedValue({
226
+ isDirectory: () => false,
227
+ size: fixtures.config.maxFileSize + 1,
228
+ });
229
+
230
+ await fixtures.indexer.indexAll();
231
+ });
232
+
233
+ it('should queue watcher events during indexing (L1106, L1126, L1146)', async () => {
234
+ // 1. Setup watcher
235
+ fixtures.config.watchFiles = true;
236
+ await fixtures.indexer.setupFileWatcher();
237
+
238
+ // 2. Set indexing flag
239
+ fixtures.indexer.isIndexing = true;
240
+
241
+ // 3. Emit events
242
+ const watcher = fixtures.indexer.watcher;
243
+ if (watcher) {
244
+ watcher.emit('add', 'new.js');
245
+ watcher.emit('change', 'changed.js');
246
+ watcher.emit('unlink', 'deleted.js');
247
+ }
248
+
249
+ // Check queue
250
+ expect(fixtures.indexer.pendingWatchEvents.has(path.join(fixtures.config.searchDirectory, 'new.js'))).toBe(true);
251
+ expect(fixtures.indexer.pendingWatchEvents.has(path.join(fixtures.config.searchDirectory, 'changed.js'))).toBe(true);
252
+ // unlink might use absolute path logic
253
+ const delPath = path.join(fixtures.config.searchDirectory, 'deleted.js');
254
+ expect(fixtures.indexer.pendingWatchEvents.get(delPath)).toBe('unlink');
255
+
256
+ // Reset
257
+ fixtures.indexer.isIndexing = false;
258
+ });
259
+ });