@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,541 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+
3
+ let execPromiseMock;
4
+ const fsMock = {};
5
+ const osMock = {};
6
+ let registerMock;
7
+
8
+ vi.mock('child_process', () => ({ exec: vi.fn() }));
9
+ vi.mock('util', () => ({
10
+ default: { promisify: () => execPromiseMock },
11
+ promisify: () => execPromiseMock,
12
+ }));
13
+ vi.mock('fs/promises', () => ({ default: fsMock }));
14
+ vi.mock('os', () => ({ default: osMock }));
15
+ vi.mock('../features/register.js', () => ({
16
+ register: (...args) => registerMock(...args),
17
+ }));
18
+
19
+ const setPlatform = (value) => {
20
+ Object.defineProperty(process, 'platform', { value, configurable: true });
21
+ };
22
+
23
+ describe('lifecycle', () => {
24
+ const originalPlatform = process.platform;
25
+ const originalPid = process.pid;
26
+ let consoleLog;
27
+ let consoleInfo;
28
+ let consoleWarn;
29
+ let consoleError;
30
+ let killSpy;
31
+
32
+ beforeEach(() => {
33
+ execPromiseMock = vi.fn();
34
+ fsMock.readFile = vi.fn();
35
+ fsMock.unlink = vi.fn().mockResolvedValue();
36
+ fsMock.readdir = vi.fn();
37
+ fsMock.stat = vi.fn();
38
+ fsMock.access = vi.fn().mockRejectedValue(new Error('missing'));
39
+ fsMock.writeFile = vi.fn().mockResolvedValue();
40
+ osMock.homedir = () => 'C:/Users/test';
41
+ registerMock = vi.fn();
42
+ consoleLog = vi.spyOn(console, 'log').mockImplementation(() => {});
43
+ consoleInfo = vi.spyOn(console, 'info').mockImplementation(() => {});
44
+ consoleWarn = vi.spyOn(console, 'warn').mockImplementation(() => {});
45
+ consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
46
+ killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true);
47
+ });
48
+
49
+ afterEach(() => {
50
+ setPlatform(originalPlatform);
51
+ consoleLog.mockRestore();
52
+ consoleInfo.mockRestore();
53
+ consoleWarn.mockRestore();
54
+ consoleError.mockRestore();
55
+ killSpy.mockRestore();
56
+ vi.resetModules();
57
+ });
58
+
59
+ it('stops cleanly when no win32 processes are found', async () => {
60
+ setPlatform('win32');
61
+ execPromiseMock.mockResolvedValue({ stdout: '' });
62
+ const { stop } = await import('../features/lifecycle.js');
63
+
64
+ await stop();
65
+
66
+ expect(consoleInfo).toHaveBeenCalledWith(
67
+ '[Lifecycle] No running instances found (already stopped).'
68
+ );
69
+ });
70
+
71
+ it('stops win32 processes and warns on kill failures', async () => {
72
+ setPlatform('win32');
73
+ fsMock.readFile.mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' }));
74
+ execPromiseMock.mockImplementation(async (cmd) => {
75
+ if (String(cmd).startsWith('wmic process')) {
76
+ return { stdout: 'ProcessId=1234\nProcessId=5678\n' };
77
+ }
78
+ if (
79
+ String(cmd).startsWith(
80
+ 'powershell -NoProfile -Command "Get-CimInstance Win32_Process | Where-Object'
81
+ )
82
+ ) {
83
+ return { stdout: '1234\n5678\n' };
84
+ }
85
+ if (String(cmd).startsWith('taskkill') && String(cmd).includes('/PID 5678')) {
86
+ const err = new Error('Denied');
87
+ throw err;
88
+ }
89
+ return { stdout: '' };
90
+ });
91
+ const { stop } = await import('../features/lifecycle.js');
92
+
93
+ await stop();
94
+
95
+ expect(consoleWarn).toHaveBeenCalled();
96
+ });
97
+
98
+ it('handles pgrep exit code 1 on non-win32', async () => {
99
+ setPlatform('linux');
100
+ execPromiseMock.mockRejectedValue({ code: 1 });
101
+ const { stop } = await import('../features/lifecycle.js');
102
+
103
+ await stop();
104
+
105
+ expect(consoleInfo).toHaveBeenCalledWith(
106
+ '[Lifecycle] No running instances found (already stopped).'
107
+ );
108
+ });
109
+
110
+ it('stops non-win32 processes discovered via pgrep', async () => {
111
+ setPlatform('linux');
112
+ execPromiseMock.mockResolvedValue({ stdout: '1234 5678' });
113
+ const { stop } = await import('../features/lifecycle.js');
114
+
115
+ await stop();
116
+
117
+ expect(killSpy).toHaveBeenCalledWith(1234, 0);
118
+ expect(killSpy).toHaveBeenCalledWith(5678, 0);
119
+ expect(killSpy).toHaveBeenCalledWith(1234, 'SIGTERM');
120
+ expect(killSpy).toHaveBeenCalledWith(5678, 'SIGTERM');
121
+ });
122
+
123
+ it('warns when non-win32 stop fails to kill a PID', async () => {
124
+ setPlatform('linux');
125
+ execPromiseMock.mockResolvedValue({ stdout: '1234' });
126
+ killSpy.mockImplementation((pid, signal) => {
127
+ if (signal === 'SIGTERM') {
128
+ const err = new Error('Denied');
129
+ err.code = 'EPERM';
130
+ throw err;
131
+ }
132
+ return true;
133
+ });
134
+ const { stop } = await import('../features/lifecycle.js');
135
+
136
+ await stop();
137
+
138
+ expect(consoleWarn).toHaveBeenCalledWith(expect.stringContaining('Failed to kill PID 1234'));
139
+ });
140
+
141
+ it('warns on unexpected pgrep errors', async () => {
142
+ setPlatform('linux');
143
+ execPromiseMock.mockRejectedValue({ code: 2, message: 'boom' });
144
+ const { stop } = await import('../features/lifecycle.js');
145
+
146
+ await stop();
147
+
148
+ expect(consoleWarn).toHaveBeenCalledWith(
149
+ expect.stringContaining('Stop command encountered an error')
150
+ );
151
+ });
152
+
153
+ it('skips current pid when stopping non-win32 processes', async () => {
154
+ setPlatform('linux');
155
+ execPromiseMock.mockResolvedValue({ stdout: `${originalPid} 1234` });
156
+ const { stop } = await import('../features/lifecycle.js');
157
+
158
+ await stop();
159
+
160
+ expect(killSpy).not.toHaveBeenCalledWith(originalPid, 0);
161
+ expect(killSpy).toHaveBeenCalledWith(1234, 0);
162
+ });
163
+
164
+ it('starts and reports register errors', async () => {
165
+ registerMock.mockRejectedValue(new Error('boom'));
166
+ const { start } = await import('../features/lifecycle.js');
167
+
168
+ await start();
169
+
170
+ expect(consoleError).toHaveBeenCalled();
171
+ });
172
+
173
+ it('starts and logs success output', async () => {
174
+ registerMock.mockResolvedValue();
175
+ const { start } = await import('../features/lifecycle.js');
176
+
177
+ await start();
178
+
179
+ expect(consoleInfo).toHaveBeenCalledWith('[Lifecycle] ✅ Configuration checked.');
180
+ expect(consoleInfo).toHaveBeenCalledWith(
181
+ '[Lifecycle] To start the server, please reload your IDE window or restart the IDE.'
182
+ );
183
+ });
184
+
185
+ it('reports status with cache details on non-win32', async () => {
186
+ setPlatform('linux');
187
+ fsMock.readFile.mockImplementation(async (filePath) => {
188
+ if (String(filePath).endsWith('.heuristic-mcp.pid')) {
189
+ return '2222';
190
+ }
191
+ if (String(filePath).endsWith('meta.json')) {
192
+ return JSON.stringify({
193
+ workspace: 'repo',
194
+ filesIndexed: 2,
195
+ chunksStored: 3,
196
+ lastSaveTime: new Date().toISOString(),
197
+ });
198
+ }
199
+ throw Object.assign(new Error('missing'), { code: 'ENOENT' });
200
+ });
201
+ fsMock.readdir.mockResolvedValue(['cacheA', 'cacheB']);
202
+ fsMock.stat.mockResolvedValue({
203
+ mtime: new Date(Date.now() - 11 * 60 * 1000),
204
+ });
205
+ fsMock.access.mockRejectedValue(new Error('nope'));
206
+ execPromiseMock.mockImplementation(async (cmd) => {
207
+ if (cmd === 'ps aux') {
208
+ return { stdout: 'user 3333 0.0 0.1 heuristic-mcp/index.js' };
209
+ }
210
+ if (cmd === 'npm config get prefix') {
211
+ return { stdout: '/usr/local' };
212
+ }
213
+ return { stdout: '' };
214
+ });
215
+ killSpy.mockImplementation(() => {
216
+ const err = new Error('stale');
217
+ err.code = 'ESRCH';
218
+ throw err;
219
+ });
220
+ const { status } = await import('../features/lifecycle.js');
221
+
222
+ await status();
223
+
224
+ expect(fsMock.readdir).toHaveBeenCalled();
225
+ expect(fsMock.unlink).toHaveBeenCalled();
226
+ });
227
+
228
+ it('skips invalid PID file entries', async () => {
229
+ setPlatform('linux');
230
+ fsMock.readFile.mockResolvedValue('not-a-number');
231
+ fsMock.readdir.mockResolvedValue([]);
232
+ fsMock.access.mockRejectedValue(new Error('missing'));
233
+ execPromiseMock.mockImplementation(async (cmd) => {
234
+ if (cmd === 'ps aux') {
235
+ return { stdout: '' };
236
+ }
237
+ if (cmd === 'npm config get prefix') {
238
+ return { stdout: '/usr/local' };
239
+ }
240
+ return { stdout: '' };
241
+ });
242
+ const { status } = await import('../features/lifecycle.js');
243
+
244
+ await status();
245
+
246
+ const stopped = consoleInfo.mock.calls.some(
247
+ (call) => typeof call[0] === 'string' && call[0].includes('Server is STOPPED')
248
+ );
249
+ expect(stopped).toBe(true);
250
+ });
251
+
252
+ it('reports running status when PID file points to live process', async () => {
253
+ setPlatform('linux');
254
+ fsMock.readFile.mockImplementation(async (filePath) => {
255
+ if (String(filePath).endsWith('.heuristic-mcp.pid')) {
256
+ return '4444';
257
+ }
258
+ if (String(filePath).endsWith('meta.json')) {
259
+ return JSON.stringify({ filesIndexed: 1, chunksStored: 1 });
260
+ }
261
+ throw Object.assign(new Error('missing'), { code: 'ENOENT' });
262
+ });
263
+ fsMock.readdir.mockResolvedValue(['cacheOnly']);
264
+ fsMock.access.mockRejectedValue(new Error('missing'));
265
+ execPromiseMock.mockImplementation(async (cmd) => {
266
+ if (cmd === 'npm config get prefix') {
267
+ return { stdout: '/usr/local' };
268
+ }
269
+ return { stdout: '' };
270
+ });
271
+ killSpy.mockReturnValue(true);
272
+ const { status } = await import('../features/lifecycle.js');
273
+
274
+ await status();
275
+
276
+ expect(killSpy).toHaveBeenCalledWith(4444, 0);
277
+ expect(consoleInfo).toHaveBeenCalledWith(expect.stringContaining('Server is RUNNING'));
278
+ expect(consoleInfo).toHaveBeenCalledWith(expect.stringContaining('cache directory'));
279
+ });
280
+
281
+ it('reports stopped status and empty cache dirs on win32', async () => {
282
+ setPlatform('win32');
283
+ process.env.LOCALAPPDATA = 'C:/LocalApp';
284
+ process.env.APPDATA = 'C:/Roaming';
285
+ fsMock.readFile.mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' }));
286
+ fsMock.readdir.mockResolvedValue([]);
287
+ fsMock.access.mockRejectedValue(new Error('missing'));
288
+ const { status } = await import('../features/lifecycle.js');
289
+
290
+ await status();
291
+
292
+ expect(consoleInfo).toHaveBeenCalledWith('[Lifecycle] ⚪ Server is STOPPED.');
293
+ expect(consoleInfo).toHaveBeenCalledWith('[Status] No cache directories found.');
294
+ expect(consoleInfo).toHaveBeenCalledWith(
295
+ expect.stringContaining('Expected location: C:\\LocalApp\\heuristic-mcp')
296
+ );
297
+ expect(consoleInfo).toHaveBeenCalledWith(expect.stringContaining('Cursor\\User\\settings.json'));
298
+ });
299
+
300
+ it('uses win32 cache root fallback when LOCALAPPDATA is unset', async () => {
301
+ setPlatform('win32');
302
+ delete process.env.LOCALAPPDATA;
303
+ process.env.APPDATA = 'C:/Roaming';
304
+ fsMock.readFile.mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' }));
305
+ fsMock.readdir.mockResolvedValue([]);
306
+ fsMock.access.mockRejectedValue(new Error('missing'));
307
+ const { status } = await import('../features/lifecycle.js');
308
+
309
+ await status();
310
+
311
+ expect(consoleInfo).toHaveBeenCalledWith(expect.stringContaining('Expected location:'));
312
+ expect(consoleInfo).toHaveBeenCalledWith(
313
+ expect.stringContaining('AppData\\Local\\heuristic-mcp')
314
+ );
315
+ });
316
+
317
+ it('uses win32 Cursor config path fallback when APPDATA is unset', async () => {
318
+ setPlatform('win32');
319
+ delete process.env.APPDATA;
320
+ fsMock.readFile.mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' }));
321
+ fsMock.readdir.mockResolvedValue([]);
322
+ fsMock.access.mockRejectedValue(new Error('missing'));
323
+ const { status } = await import('../features/lifecycle.js');
324
+
325
+ await status();
326
+
327
+ expect(consoleInfo).toHaveBeenCalledWith(expect.stringContaining('Cursor\\User\\settings.json'));
328
+ });
329
+
330
+ it('reports indexing status for empty and incomplete caches on darwin', async () => {
331
+ setPlatform('darwin');
332
+ fsMock.readFile.mockImplementation(async (filePath) => {
333
+ if (String(filePath).endsWith('.heuristic-mcp.pid')) {
334
+ throw Object.assign(new Error('missing'), { code: 'ENOENT' });
335
+ }
336
+ if (String(filePath).includes('cacheA') && String(filePath).endsWith('meta.json')) {
337
+ return JSON.stringify({ filesIndexed: 0, chunksStored: 0 });
338
+ }
339
+ if (String(filePath).includes('cacheB') && String(filePath).endsWith('meta.json')) {
340
+ return JSON.stringify({ workspace: 'repo' });
341
+ }
342
+ throw Object.assign(new Error('missing'), { code: 'ENOENT' });
343
+ });
344
+ fsMock.readdir.mockResolvedValue(['cacheA', 'cacheB']);
345
+ fsMock.access.mockRejectedValue(new Error('missing'));
346
+ execPromiseMock.mockResolvedValue({ stdout: '' });
347
+ const { status } = await import('../features/lifecycle.js');
348
+
349
+ await status();
350
+
351
+ expect(consoleInfo).toHaveBeenCalledWith(
352
+ expect.stringContaining('Cached index: ⚠️ NO FILES')
353
+ );
354
+ expect(consoleInfo).toHaveBeenCalledWith(
355
+ expect.stringContaining('Cached index: ⚠️ INCOMPLETE')
356
+ );
357
+ expect(consoleInfo).toHaveBeenCalledWith(
358
+ expect.stringContaining('Library\\Application Support\\Cursor\\User\\settings.json')
359
+ );
360
+ });
361
+
362
+ it('merges valid PIDs from process list when no PID file exists', async () => {
363
+ setPlatform('linux');
364
+ fsMock.readFile.mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' }));
365
+ fsMock.readdir.mockResolvedValue([]);
366
+ fsMock.access.mockRejectedValue(new Error('missing'));
367
+ execPromiseMock.mockImplementation(async (cmd) => {
368
+ if (cmd === 'ps aux') {
369
+ return { stdout: 'user 5555 0.0 0.1 heuristic-mcp/index.js' };
370
+ }
371
+ if (cmd === 'npm config get prefix') {
372
+ return { stdout: '/usr/local' };
373
+ }
374
+ return { stdout: '' };
375
+ });
376
+ const { status } = await import('../features/lifecycle.js');
377
+
378
+ await status();
379
+
380
+ expect(consoleInfo).toHaveBeenCalledWith(
381
+ expect.stringContaining('Server is RUNNING. PID(s): 5555')
382
+ );
383
+ });
384
+
385
+ it('skips grep lines and duplicate PIDs in process list', async () => {
386
+ setPlatform('linux');
387
+ fsMock.readFile.mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' }));
388
+ fsMock.readdir.mockResolvedValue([]);
389
+ fsMock.access.mockRejectedValue(new Error('missing'));
390
+ execPromiseMock.mockImplementation(async (cmd) => {
391
+ if (cmd === 'ps aux') {
392
+ return {
393
+ stdout: [
394
+ 'user 6666 0.0 0.1 heuristic-mcp/index.js',
395
+ 'user 6666 0.0 0.1 heuristic-mcp/index.js',
396
+ 'user 7777 0.0 0.1 heuristic-mcp/index.js grep heuristic-mcp',
397
+ ].join('\n'),
398
+ };
399
+ }
400
+ if (cmd === 'npm config get prefix') {
401
+ return { stdout: '/usr/local' };
402
+ }
403
+ return { stdout: '' };
404
+ });
405
+ const { status } = await import('../features/lifecycle.js');
406
+
407
+ await status();
408
+
409
+ expect(consoleInfo).toHaveBeenCalledWith(expect.stringContaining('PID(s): 6666'));
410
+ expect(consoleInfo).not.toHaveBeenCalledWith(expect.stringContaining('7777'));
411
+ });
412
+
413
+ it('handles missing meta files and corrupted caches', async () => {
414
+ setPlatform('linux');
415
+ fsMock.readFile.mockImplementation(async (filePath) => {
416
+ if (String(filePath).endsWith('.heuristic-mcp.pid')) {
417
+ throw Object.assign(new Error('missing'), { code: 'ENOENT' });
418
+ }
419
+ if (String(filePath).includes('corruptCache') && String(filePath).endsWith('meta.json')) {
420
+ const err = new Error('denied');
421
+ err.code = 'EACCES';
422
+ throw err;
423
+ }
424
+ const err = new Error('missing');
425
+ err.code = 'ENOENT';
426
+ throw err;
427
+ });
428
+ fsMock.readdir.mockResolvedValue(['newCache', 'oldCache', 'badCache', 'corruptCache']);
429
+ fsMock.stat.mockImplementation(async (cacheDir) => {
430
+ if (String(cacheDir).includes('newCache')) {
431
+ return { mtime: new Date() };
432
+ }
433
+ if (String(cacheDir).includes('oldCache')) {
434
+ return { mtime: new Date(Date.now() - 11 * 60 * 1000) };
435
+ }
436
+ throw new Error('stat failed');
437
+ });
438
+ fsMock.access.mockRejectedValue(new Error('missing'));
439
+ execPromiseMock.mockResolvedValue({ stdout: '' });
440
+ const { status } = await import('../features/lifecycle.js');
441
+
442
+ await status();
443
+
444
+ expect(consoleInfo).toHaveBeenCalledWith(
445
+ expect.stringContaining('Initializing / Indexing in progress')
446
+ );
447
+ expect(consoleInfo).toHaveBeenCalledWith(expect.stringContaining('Incomplete cache (stale)'));
448
+ expect(consoleInfo).toHaveBeenCalledWith(expect.stringContaining('Invalid cache directory'));
449
+ expect(consoleInfo).toHaveBeenCalledWith(expect.stringContaining('Invalid or corrupted'));
450
+ });
451
+
452
+ it('reports fatal status errors', async () => {
453
+ osMock.homedir = () => {
454
+ throw new Error('boom');
455
+ };
456
+ const { status } = await import('../features/lifecycle.js');
457
+
458
+ await status();
459
+
460
+ expect(consoleError).toHaveBeenCalledWith(expect.stringContaining('Failed to check status'));
461
+ });
462
+
463
+ it('reports empty cache dirs when readdir fails', async () => {
464
+ setPlatform('linux');
465
+ fsMock.readFile.mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' }));
466
+ fsMock.readdir.mockRejectedValue(new Error('bad read'));
467
+ fsMock.access.mockRejectedValue(new Error('missing'));
468
+ execPromiseMock.mockImplementation(async (cmd) => {
469
+ if (cmd === 'ps aux') {
470
+ return { stdout: '' };
471
+ }
472
+ if (cmd === 'npm config get prefix') {
473
+ return { stdout: '/usr/local' };
474
+ }
475
+ return { stdout: '' };
476
+ });
477
+ const { status } = await import('../features/lifecycle.js');
478
+
479
+ await status();
480
+
481
+ expect(consoleInfo).toHaveBeenCalledWith('[Status] No cache directories found.');
482
+ });
483
+
484
+ it('marks config paths as existing when access succeeds', async () => {
485
+ setPlatform('linux');
486
+ fsMock.readFile.mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' }));
487
+ fsMock.readdir.mockResolvedValue([]);
488
+ fsMock.access.mockResolvedValue();
489
+ execPromiseMock.mockImplementation(async (cmd) => {
490
+ if (cmd === 'ps aux') {
491
+ return { stdout: '' };
492
+ }
493
+ if (cmd === 'npm config get prefix') {
494
+ return { stdout: '/usr/local' };
495
+ }
496
+ return { stdout: '' };
497
+ });
498
+ const { status } = await import('../features/lifecycle.js');
499
+
500
+ await status();
501
+
502
+ expect(consoleInfo).toHaveBeenCalledWith(expect.stringContaining('(exists)'));
503
+ });
504
+
505
+ it('handles ESRCH error when killing a process', async () => {
506
+ setPlatform('linux');
507
+ execPromiseMock.mockResolvedValue({ stdout: '1234' });
508
+ killSpy.mockImplementation(() => {
509
+ const err = new Error('Already dead');
510
+ err.code = 'ESRCH';
511
+ throw err;
512
+ });
513
+ const { stop } = await import('../features/lifecycle.js');
514
+
515
+ await stop();
516
+
517
+ expect(killSpy).toHaveBeenCalled();
518
+ expect(consoleWarn).not.toHaveBeenCalledWith(expect.stringContaining('Failed to kill PID'));
519
+ });
520
+
521
+ it('handles error when unlinking stale PID file', async () => {
522
+ setPlatform('linux');
523
+ fsMock.readFile.mockResolvedValue('9999');
524
+ killSpy.mockImplementation(() => {
525
+ throw new Error('dead');
526
+ });
527
+ fsMock.unlink.mockRejectedValue(new Error('unlink failed'));
528
+ fsMock.readdir.mockResolvedValue([]);
529
+ fsMock.access.mockRejectedValue(new Error('missing'));
530
+ execPromiseMock.mockResolvedValue({ stdout: '' });
531
+ const { status } = await import('../features/lifecycle.js');
532
+
533
+ await status();
534
+
535
+ expect(fsMock.unlink).toHaveBeenCalledWith(expect.stringContaining('.heuristic-mcp.pid'));
536
+ const fatalError = consoleError.mock.calls.some(
537
+ (call) => typeof call[0] === 'string' && call[0].includes('Failed to check status')
538
+ );
539
+ expect(fatalError).toBe(false);
540
+ });
541
+ });