@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,183 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import { CodebaseIndexer, handleToolCall } from '../features/index-codebase.js';
3
+ import { EmbeddingsCache } from '../lib/cache.js';
4
+ import fs from 'fs/promises';
5
+
6
+ // Mock dependencies
7
+ vi.mock('fs/promises');
8
+ vi.mock('../lib/call-graph.js', () => ({
9
+ extractCallData: vi.fn().mockReturnValue({}),
10
+ }));
11
+ vi.mock('../lib/utils.js', async () => {
12
+ const actual = await vi.importActual('../lib/utils.js');
13
+ return {
14
+ ...actual,
15
+ hashContent: vi.fn().mockReturnValue('fixed-hash'),
16
+ smartChunk: actual.smartChunk,
17
+ };
18
+ });
19
+ vi.mock('worker_threads', async () => {
20
+ const { EventEmitter } = await import('events');
21
+ class Worker extends EventEmitter {
22
+ constructor() {
23
+ super();
24
+ setTimeout(() => this.emit('message', { type: 'ready' }), 1);
25
+ }
26
+ terminate() {
27
+ return Promise.resolve();
28
+ }
29
+ postMessage(msg) {
30
+ if (msg.type === 'process') {
31
+ this.emit('message', { type: 'results', results: [], batchId: msg.batchId });
32
+ }
33
+ }
34
+ }
35
+ return { Worker };
36
+ });
37
+
38
+ vi.mock('os', async () => {
39
+ return {
40
+ default: { cpus: () => [{}, {}, {}, {}] },
41
+ cpus: () => [{}, {}, {}, {}],
42
+ };
43
+ });
44
+
45
+ describe('Final Polish Coverage', () => {
46
+ let indexer;
47
+ let config;
48
+ let cache;
49
+ let embedder;
50
+
51
+ beforeEach(() => {
52
+ config = {
53
+ workerThreads: 2,
54
+ verbose: true,
55
+ embeddingModel: 'test-model',
56
+ searchDirectory: '/test',
57
+ maxFileSize: 100,
58
+ fileExtensions: ['js'],
59
+ excludePatterns: [],
60
+ callGraphEnabled: true,
61
+ };
62
+
63
+ cache = {
64
+ save: vi.fn(),
65
+ getVectorStore: vi.fn().mockReturnValue([]),
66
+ setVectorStore: vi.fn(),
67
+ fileHashes: new Map(),
68
+ fileCallData: new Map(),
69
+ getFileHash: vi.fn(),
70
+ setFileHash: vi.fn(),
71
+ removeFileFromStore: vi.fn(),
72
+ addToStore: vi.fn(),
73
+ setFileCallData: vi.fn(),
74
+ clearCallGraphData: vi.fn(),
75
+ pruneCallGraphData: vi.fn(),
76
+ rebuildCallGraph: vi.fn(),
77
+ ensureAnnIndex: vi.fn().mockResolvedValue(),
78
+ deleteFileHash: vi.fn(),
79
+ setLastIndexDuration: vi.fn(),
80
+ setLastIndexStats: vi.fn(),
81
+ setFileHashes: vi.fn((map) => { cache.fileHashes = map; }),
82
+ getFileHashKeys: vi.fn().mockImplementation(() => [...cache.fileHashes.keys()]),
83
+ getFileCallDataKeys: vi.fn().mockImplementation(() => [...cache.fileCallData.keys()]),
84
+ setFileCallDataEntries: vi.fn((map) => { cache.fileCallData = map; }),
85
+ clearFileCallData: vi.fn(() => { cache.fileCallData = new Map(); }),
86
+ getFileMeta: vi.fn(),
87
+ };
88
+
89
+ embedder = vi.fn().mockResolvedValue({ data: [] });
90
+
91
+ indexer = new CodebaseIndexer(embedder, cache, config);
92
+ indexer.discoverFiles = vi.fn().mockResolvedValue(['/test/file1.js']);
93
+ });
94
+
95
+ afterEach(() => {
96
+ vi.restoreAllMocks();
97
+ });
98
+
99
+ describe('lib/cache.js', () => {
100
+ it('handles invalid JSON in cache metadata (line 123)', async () => {
101
+ // While targeting 673, let's also cover metadata parsing failure if needed
102
+ const config = { enableCache: true, cacheDirectory: '/c' };
103
+ const cache = new EmbeddingsCache(config);
104
+ const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
105
+ vi.spyOn(fs, 'mkdir').mockResolvedValue();
106
+ // Return invalid JSON for meta
107
+ vi.spyOn(fs, 'readFile').mockImplementation(async (p) => {
108
+ if (p.endsWith('meta.json')) return '{ invalid';
109
+ return null;
110
+ });
111
+
112
+ await cache.load();
113
+ expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Invalid cache metadata'));
114
+ });
115
+
116
+ it('handles ANN metadata rebuilding (line 332)', async () => {
117
+ // Just extra coverage for ANN loading
118
+ const config = { enableCache: true, cacheDirectory: '/c', annEnabled: true };
119
+ const cache = new EmbeddingsCache(config);
120
+ cache.vectorStore = [{ vector: [1] }];
121
+
122
+ vi.spyOn(fs, 'readFile').mockResolvedValue('invalid-json');
123
+
124
+ const loaded = await cache.loadAnnIndexFromDisk({}, 1);
125
+ expect(loaded).toBe(false);
126
+ });
127
+ });
128
+
129
+ describe('features/index-codebase.js', () => {
130
+ it('handles stat errors in preFilterFiles (line 483)', async () => {
131
+ // Direct target for 515-516
132
+ vi.spyOn(fs, 'stat').mockRejectedValue(new Error('Stat Fail'));
133
+ const files = ['/test/bad.js'];
134
+
135
+ // Directly call the method
136
+ const results = await indexer.preFilterFiles(files);
137
+ expect(results).toEqual([]);
138
+ // We can't easily assert on `skippedCount` local var, but result length 0 implies it filtered.
139
+ });
140
+
141
+ it('triggers call-graph data re-indexing (line 578)', async () => {
142
+ // Target 662
143
+ const consoleSpy = vi.spyOn(console, 'info').mockImplementation(() => {});
144
+
145
+ // 1. All files unchanged initially
146
+ cache.getVectorStore.mockReturnValue([{ file: '/test/file1.js' }]);
147
+ cache.clearFileCallData(); // Empty!
148
+ cache.setFileHashes(new Map([['/test/file1.js', 'fixed-hash']]));
149
+ cache.getFileHash.mockReturnValue('fixed-hash');
150
+ cache.getFileMeta.mockReturnValue({ mtimeMs: 123, size: 50 });
151
+
152
+ // Mock fs to pass pre-check and processing
153
+ vi.spyOn(fs, 'stat').mockResolvedValue({ isDirectory: () => false, size: 50, mtimeMs: 123 });
154
+ vi.spyOn(fs, 'readFile').mockResolvedValue('content');
155
+
156
+ await indexer.indexAll(false);
157
+
158
+ expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('missing call graph data'));
159
+ expect(cache.setFileCallData).toHaveBeenCalled();
160
+ });
161
+
162
+ it('returns 0 on indexFile error (line 343)', async () => {
163
+ vi.spyOn(fs, 'stat').mockRejectedValue(new Error('Stat Fail'));
164
+ const result = await indexer.indexFile('/test/bad.js');
165
+ expect(result).toBe(0);
166
+ });
167
+
168
+ it('reports processed files in tool response (line 992)', async () => {
169
+ // Mock indexAll result
170
+ indexer.indexAll = vi.fn().mockResolvedValue({
171
+ filesProcessed: 5,
172
+ chunksCreated: 10,
173
+ totalFiles: 5,
174
+ totalChunks: 10,
175
+ });
176
+
177
+ const request = { params: { arguments: { force: true } } };
178
+ const result = await handleToolCall(request, indexer);
179
+
180
+ expect(result.content[0].text).toContain('Files processed this run: 5');
181
+ });
182
+ });
183
+ });
@@ -0,0 +1,95 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { extractDefinitions } from '../lib/call-graph.js';
3
+ import { ProjectDetector } from '../lib/project-detector.js';
4
+ import { EmbeddingsCache } from '../lib/cache.js';
5
+ import fs from 'fs/promises';
6
+
7
+ vi.mock('fs/promises');
8
+
9
+ describe('Final Coverage Gaps', () => {
10
+ describe('lib/call-graph.js fallback language', () => {
11
+ it('handles unknown extensions by falling back to javascript patterns', () => {
12
+ const content = 'function unknownExt() {}';
13
+ const definitions = extractDefinitions(content, 'test.unknown');
14
+ expect(definitions).toContain('unknownExt');
15
+ });
16
+ });
17
+
18
+ describe('lib/project-detector.js edge cases', () => {
19
+ it('hits depth limit and ignores deep directories', async () => {
20
+ const detector = new ProjectDetector('/root');
21
+ vi.mocked(fs.readdir).mockResolvedValue([{ name: 'dir1', isDirectory: () => true }]);
22
+
23
+ // We want to trigger depth > 2 return in checkDir
24
+ // initializeWorkers logic is recursive.
25
+ await detector.detectProjectTypes();
26
+ // Coverage should show line 16 hit
27
+ });
28
+
29
+ it('handles missing ignore pattern for detected type', () => {
30
+ const detector = new ProjectDetector('/root');
31
+ // Force a type that doesn't exist in IGNORE_PATTERNS
32
+ detector.detectedTypes.add('mystery-project');
33
+ const patterns = detector.getSmartIgnorePatterns();
34
+ expect(patterns).toBeDefined();
35
+ expect(patterns.length).toBeGreaterThan(0); // Should still have common patterns
36
+ });
37
+ });
38
+
39
+ describe('lib/cache.js edge cases', () => {
40
+ it('covers initHnswIndex deeper retry branches', () => {
41
+ const mockIndex = {
42
+ initIndex: vi
43
+ .fn()
44
+ .mockImplementationOnce(() => {
45
+ throw new Error('fail 1');
46
+ })
47
+ .mockImplementationOnce(() => {
48
+ throw new Error('fail 2');
49
+ })
50
+ .mockReturnValue(true),
51
+ };
52
+ // External functions aren't directly exportable, but we can hit them via buildAnnIndex
53
+ // or by importing the private functions if we were in a different setup.
54
+ // Since cache.js doesn't export initHnswIndex, we use buildAnnIndex.
55
+
56
+ const cache = new EmbeddingsCache({ annMetric: 'l2' });
57
+ cache.vectorStore = [{ vector: [1, 2] }];
58
+
59
+ // Use function instead of arrow for constructor
60
+ const HierarchicalNSW = vi.fn(function () {
61
+ return mockIndex;
62
+ });
63
+ cache.buildAnnIndex(HierarchicalNSW, 2);
64
+
65
+ expect(mockIndex.initIndex).toHaveBeenCalledTimes(3);
66
+ });
67
+
68
+ it('covers clearCallGraphData error logging', async () => {
69
+ const cache = new EmbeddingsCache({
70
+ enableCache: true,
71
+ cacheDirectory: '/path',
72
+ verbose: true,
73
+ });
74
+ vi.mocked(fs.rm).mockRejectedValue(new Error('unlink failed'));
75
+
76
+ vi.spyOn(console, 'warn').mockImplementation(() => {});
77
+ await cache.clearCallGraphData({ removeFile: true });
78
+
79
+ expect(console.warn).toHaveBeenCalledWith(
80
+ expect.stringContaining('Failed to remove call-graph cache')
81
+ );
82
+ });
83
+
84
+ it('covers getRelatedFiles missing graph path', async () => {
85
+ const cache = new EmbeddingsCache({ callGraphEnabled: true });
86
+ // Correct format for fileCallData: file -> { definitions: [], calls: [] }
87
+ cache.setFileCallData('f.js', { definitions: [], calls: [] });
88
+ // Avoid rebuildCallGraph which is async/lazy
89
+ cache.callGraph = null;
90
+
91
+ const result = await cache.getRelatedFiles(['sym']);
92
+ expect(result.size).toBe(0);
93
+ });
94
+ });
95
+ });
@@ -0,0 +1,191 @@
1
+ import path from 'path';
2
+ import { describe, expect, it, vi } from 'vitest';
3
+ import {
4
+ FindSimilarCode,
5
+ getToolDefinition,
6
+ handleToolCall,
7
+ } from '../features/find-similar-code.js';
8
+
9
+ const makeEmbedder = (vector) => vi.fn().mockResolvedValue({ data: Float32Array.from(vector) });
10
+
11
+ describe('FindSimilarCode', () => {
12
+ it('returns a helpful message when the index is empty', async () => {
13
+ const embedder = makeEmbedder([1, 0]);
14
+ const cache = { getVectorStore: () => [], ensureLoaded: vi.fn() };
15
+ const config = { searchDirectory: process.cwd() };
16
+ const tool = new FindSimilarCode(embedder, cache, config);
17
+
18
+ const result = await tool.execute({ code: 'const x = 1;' });
19
+
20
+ expect(result.results).toEqual([]);
21
+ expect(result.message).toMatch(/No code has been indexed/);
22
+ });
23
+
24
+ it('dedupes ANN candidates and falls back when too few remain', async () => {
25
+ const embedder = makeEmbedder([1, 0]);
26
+ const vectorStore = [
27
+ {
28
+ file: 'C:/repo/a.js',
29
+ startLine: 1,
30
+ endLine: 2,
31
+ content: 'alpha',
32
+ vector: [1, 0],
33
+ },
34
+ {
35
+ file: 'C:/repo/b.js',
36
+ startLine: 3,
37
+ endLine: 4,
38
+ content: 'beta',
39
+ vector: [0.5, 0.5],
40
+ },
41
+ ];
42
+ const cache = {
43
+ getVectorStore: () => vectorStore,
44
+ queryAnn: vi.fn().mockResolvedValue([0, 0]),
45
+ getChunkVector: (chunk) => chunk.vector,
46
+ getChunkContent: (chunk) => chunk.content,
47
+ ensureLoaded: vi.fn(),
48
+ };
49
+ const config = {
50
+ annEnabled: true,
51
+ annCandidateMultiplier: 1,
52
+ annMinCandidates: 0,
53
+ annMaxCandidates: 10,
54
+ searchDirectory: 'C:/repo',
55
+ };
56
+ const tool = new FindSimilarCode(embedder, cache, config);
57
+
58
+ const result = await tool.execute({
59
+ code: 'gamma',
60
+ maxResults: 2,
61
+ minSimilarity: 0,
62
+ });
63
+
64
+ expect(cache.queryAnn).toHaveBeenCalled();
65
+ expect(result.results.length).toBe(2);
66
+ });
67
+
68
+ it('skips exact text matches against the input', async () => {
69
+ const embedder = makeEmbedder([1, 0]);
70
+ const vectorStore = [
71
+ {
72
+ file: 'C:/repo/a.js',
73
+ startLine: 1,
74
+ endLine: 2,
75
+ content: 'same code',
76
+ vector: [1, 0],
77
+ },
78
+ {
79
+ file: 'C:/repo/b.js',
80
+ startLine: 3,
81
+ endLine: 4,
82
+ content: 'other code',
83
+ vector: [0, 1],
84
+ },
85
+ ];
86
+ const cache = {
87
+ getVectorStore: () => vectorStore,
88
+ queryAnn: vi.fn().mockResolvedValue([0, 1]),
89
+ getChunkVector: (chunk) => chunk.vector,
90
+ getChunkContent: (chunk) => chunk.content,
91
+ ensureLoaded: vi.fn(),
92
+ };
93
+ const config = { annEnabled: true, searchDirectory: 'C:/repo' };
94
+ const tool = new FindSimilarCode(embedder, cache, config);
95
+
96
+ const result = await tool.execute({
97
+ code: 'same code',
98
+ maxResults: 2,
99
+ minSimilarity: 0,
100
+ });
101
+
102
+ expect(result.results.length).toBe(1);
103
+ expect(result.results[0].content).toBe('other code');
104
+ });
105
+
106
+ it('handles ANN candidates that are below the minimum', async () => {
107
+ const embedder = makeEmbedder([1, 0]);
108
+ const vectorStore = [
109
+ {
110
+ file: 'C:/repo/a.js',
111
+ startLine: 1,
112
+ endLine: 2,
113
+ content: 'alpha',
114
+ vector: [1, 0],
115
+ },
116
+ {
117
+ file: 'C:/repo/b.js',
118
+ startLine: 3,
119
+ endLine: 4,
120
+ content: 'beta',
121
+ vector: [0, 1],
122
+ },
123
+ ];
124
+ const cache = {
125
+ getVectorStore: () => vectorStore,
126
+ queryAnn: vi.fn().mockResolvedValue([0]),
127
+ getChunkVector: (chunk) => chunk.vector,
128
+ getChunkContent: (chunk) => chunk.content,
129
+ ensureLoaded: vi.fn(),
130
+ };
131
+ const config = { annEnabled: true, searchDirectory: 'C:/repo' };
132
+ const tool = new FindSimilarCode(embedder, cache, config);
133
+
134
+ const result = await tool.execute({
135
+ code: 'gamma',
136
+ maxResults: 2,
137
+ minSimilarity: 0,
138
+ });
139
+
140
+ expect(result.results.length).toBe(2);
141
+ });
142
+
143
+ it('formats results with relative paths and code fences', async () => {
144
+ const embedder = makeEmbedder([1, 0]);
145
+ const cache = { getVectorStore: () => [], ensureLoaded: vi.fn() };
146
+ const config = { searchDirectory: 'C:/repo' };
147
+ const tool = new FindSimilarCode(embedder, cache, config);
148
+ const results = [
149
+ {
150
+ file: 'C:/repo/src/example.js',
151
+ startLine: 10,
152
+ endLine: 12,
153
+ content: 'const x = 1;',
154
+ similarity: 0.9,
155
+ },
156
+ ];
157
+
158
+ const formatted = await tool.formatResults(results);
159
+
160
+ expect(formatted).toContain(path.normalize('src/example.js'));
161
+ expect(formatted).toContain('```js');
162
+ });
163
+
164
+ it('returns a message when formatting empty results', async () => {
165
+ const embedder = makeEmbedder([1, 0]);
166
+ const cache = { getVectorStore: () => [], ensureLoaded: vi.fn() };
167
+ const config = { searchDirectory: 'C:/repo' };
168
+ const tool = new FindSimilarCode(embedder, cache, config);
169
+
170
+ await expect(tool.formatResults([])).resolves.toBe('No similar code patterns found in the codebase.');
171
+ });
172
+
173
+ it('handles tool calls with messages', async () => {
174
+ const embedder = makeEmbedder([1, 0]);
175
+ const cache = { getVectorStore: () => [], ensureLoaded: vi.fn() };
176
+ const config = { searchDirectory: 'C:/repo' };
177
+ const tool = new FindSimilarCode(embedder, cache, config);
178
+ const request = { params: { arguments: { code: 'x' } } };
179
+
180
+ const response = await handleToolCall(request, tool);
181
+
182
+ expect(response.content[0].text).toMatch(/No code has been indexed/);
183
+ });
184
+
185
+ it('exposes tool definition', () => {
186
+ const definition = getToolDefinition();
187
+
188
+ expect(definition.name).toBe('d_find_similar_code');
189
+ expect(definition.inputSchema.required).toContain('code');
190
+ });
191
+ });
package/test/helpers.js CHANGED
@@ -11,6 +11,8 @@ import { HybridSearch } from '../features/hybrid-search.js';
11
11
  import { pipeline } from '@xenova/transformers';
12
12
  import fs from 'fs/promises';
13
13
  import path from 'path';
14
+ import os from 'os';
15
+ import crypto from 'crypto';
14
16
 
15
17
  // Cached embedder instance (shared across tests for speed)
16
18
  let sharedEmbedder = null;
@@ -23,9 +25,13 @@ const DEFAULT_MOCK_DIMENSIONS = 64;
23
25
  */
24
26
  export async function getEmbedder(config) {
25
27
  if (!sharedEmbedder) {
26
- console.log('[TestHelper] Loading embedding model (first time)...');
28
+ if (config.verbose) {
29
+ console.error('[TestHelper] Loading embedding model (first time)...');
30
+ }
27
31
  sharedEmbedder = await pipeline('feature-extraction', config.embeddingModel);
28
- console.log('[TestHelper] Embedding model loaded');
32
+ if (config.verbose) {
33
+ console.error('[TestHelper] Embedding model loaded');
34
+ }
29
35
  }
30
36
  return sharedEmbedder;
31
37
  }
@@ -62,10 +68,10 @@ function normalizeVector(vector) {
62
68
  function createMockEmbedder({ dimensions = DEFAULT_MOCK_DIMENSIONS } = {}) {
63
69
  return async (text, options = {}) => {
64
70
  const vector = new Float32Array(dimensions);
65
- const tokens = String(text ?? "")
71
+ const tokens = String(text ?? '')
66
72
  .toLowerCase()
67
73
  .split(/[^a-z0-9_]+/g)
68
- .filter(token => token.length > 1);
74
+ .filter((token) => token.length > 1);
69
75
 
70
76
  for (const token of tokens) {
71
77
  const index = hashToken(token) % dimensions;
@@ -82,17 +88,43 @@ function createMockEmbedder({ dimensions = DEFAULT_MOCK_DIMENSIONS } = {}) {
82
88
  }
83
89
 
84
90
  /**
85
- * Create test fixtures with initialized components
91
+ * Create test fixtures with initialized components and isolated environment
86
92
  * @param {Object} options - Options for fixture creation
87
93
  * @returns {Object} Initialized components for testing
88
94
  */
89
95
  export async function createTestFixtures(options = {}) {
96
+ // Create a unique temporary directory for this test run
97
+ const sessionId = crypto.randomBytes(6).toString('hex');
98
+ const tempRootDir = path.join(os.tmpdir(), `heuristic-mcp-test-${sessionId}`);
99
+ const searchDir = path.join(tempRootDir, 'project');
100
+ const cacheDir = path.join(tempRootDir, 'cache');
101
+
102
+ await fs.mkdir(searchDir, { recursive: true });
103
+ await fs.mkdir(cacheDir, { recursive: true });
104
+
105
+ // Create some dummy files in the fixture directory
106
+ // This prevents tests from indexing the real heuristic-mcp codebase
107
+ await fs.writeFile(
108
+ path.join(searchDir, 'test.js'),
109
+ 'function hello() {\n console.info("hello world");\n}\n\n// embedder CodebaseIndexer test fixture\nmodule.exports = { hello };'
110
+ );
111
+ await fs.writeFile(
112
+ path.join(searchDir, 'utils.py'),
113
+ 'def add(a, b):\n """Adds two numbers"""\n return a + b\n\nif __name__ == "__main__":\n print(add(2, 3))'
114
+ );
115
+ await fs.writeFile(path.join(searchDir, 'README.md'), '# Test Project\n\nThis is a test.');
116
+
117
+ // Load baseline config
90
118
  const config = await loadConfig();
91
119
 
92
- // Override config for testing if needed
120
+ // Redirect to isolated test directories
121
+ config.searchDirectory = searchDir;
122
+ config.cacheDirectory = cacheDir;
123
+
124
+ // Override config for testing
93
125
  if (options.verbose !== undefined) config.verbose = options.verbose;
94
126
  if (options.workerThreads !== undefined) config.workerThreads = options.workerThreads;
95
- if (isVitest()) config.workerThreads = 1;
127
+ if (isVitest() && options.forceWorkers !== true) config.workerThreads = 0;
96
128
 
97
129
  const useRealEmbedder = options.useRealEmbedder === true;
98
130
  const embedder = useRealEmbedder
@@ -107,12 +139,15 @@ export async function createTestFixtures(options = {}) {
107
139
  const hybridSearch = new HybridSearch(embedder, cache, config);
108
140
 
109
141
  return {
142
+ tempRootDir,
143
+ searchDir,
144
+ cacheDir,
110
145
  config,
111
146
  embedder,
112
147
  cache,
113
148
  indexer,
114
149
  cacheClearer,
115
- hybridSearch
150
+ hybridSearch,
116
151
  };
117
152
  }
118
153
 
@@ -127,6 +162,15 @@ export async function cleanupFixtures(fixtures) {
127
162
  await fixtures.indexer.watcher.close();
128
163
  }
129
164
  }
165
+
166
+ // Remove temporary test directory
167
+ if (fixtures.tempRootDir) {
168
+ try {
169
+ await fs.rm(fixtures.tempRootDir, { recursive: true, force: true });
170
+ } catch (err) {
171
+ // Ignore cleanup errors
172
+ }
173
+ }
130
174
  }
131
175
 
132
176
  /**
@@ -151,9 +195,46 @@ export function createMockRequest(toolName, args = {}) {
151
195
  return {
152
196
  params: {
153
197
  name: toolName,
154
- arguments: args
155
- }
198
+ arguments: args,
199
+ },
200
+ };
201
+ }
202
+
203
+ /**
204
+ * Create a standard cache stub for HybridSearch tests.
205
+ * Provides the full required interface with sensible defaults.
206
+ */
207
+ export function createHybridSearchCacheStub({ vectorStore = [], ...overrides } = {}) {
208
+ let store = vectorStore;
209
+ const base = {
210
+ getVectorStore: () => store,
211
+ setVectorStore: (next) => {
212
+ store = Array.isArray(next) ? next : [];
213
+ },
214
+ getStoreSize: () => store.length,
215
+ getVector: (idx) => store[idx]?.vector ?? null,
216
+ getChunk: (idx) => store[idx] ?? null,
217
+ getChunkContent: async (chunkOrIndex) => {
218
+ if (typeof chunkOrIndex === 'number') {
219
+ return store[chunkOrIndex]?.content ?? null;
220
+ }
221
+ return chunkOrIndex?.content ?? null;
222
+ },
223
+ getChunkVector: (chunkOrIndex) => {
224
+ if (typeof chunkOrIndex === 'number') {
225
+ return store[chunkOrIndex]?.vector ?? null;
226
+ }
227
+ return chunkOrIndex?.vector ?? null;
228
+ },
229
+ queryAnn: async () => null,
230
+ getRelatedFiles: async () => new Map(),
231
+ getFileMeta: () => null,
232
+ startRead: () => {},
233
+ endRead: () => {},
234
+ waitForReaders: async () => {},
156
235
  };
236
+
237
+ return { ...base, ...overrides };
157
238
  }
158
239
 
159
240
  /**
@@ -167,7 +248,7 @@ export async function waitFor(condition, timeout = 5000, interval = 100) {
167
248
  const start = Date.now();
168
249
  while (Date.now() - start < timeout) {
169
250
  if (await condition()) return true;
170
- await new Promise(resolve => setTimeout(resolve, interval));
251
+ await new Promise((resolve) => setTimeout(resolve, interval));
171
252
  }
172
253
  return false;
173
254
  }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Additional coverage for test helpers
3
+ */
4
+
5
+ import { describe, it, expect, vi } from 'vitest';
6
+
7
+ vi.mock('@xenova/transformers', () => ({
8
+ pipeline: vi.fn(async () => async () => ({
9
+ data: new Float32Array([1, 0, 0]),
10
+ })),
11
+ }));
12
+
13
+ import fs from 'fs/promises';
14
+ import { pipeline } from '@xenova/transformers';
15
+ import { createTestFixtures, getEmbedder, clearTestCache, waitFor } from './helpers.js';
16
+
17
+ describe('Test helpers', () => {
18
+ it('caches embedder instance across calls', async () => {
19
+ const config = { embeddingModel: 'test-model' };
20
+ const first = await getEmbedder(config);
21
+ const second = await getEmbedder(config);
22
+
23
+ expect(first).toBe(second);
24
+ expect(pipeline).toHaveBeenCalledTimes(1);
25
+ });
26
+
27
+ it('creates fixtures with mock embedder and normalization', async () => {
28
+ const fixtures = await createTestFixtures({ useRealEmbedder: false });
29
+ const output = await fixtures.embedder('', { normalize: true });
30
+
31
+ expect(output.data.length).toBeGreaterThan(0);
32
+ expect(fixtures.cache).toBeDefined();
33
+ expect(fixtures.indexer).toBeDefined();
34
+ });
35
+
36
+ it('handles clearTestCache errors gracefully', async () => {
37
+ const rmSpy = vi.spyOn(fs, 'rm').mockRejectedValue(new Error('boom'));
38
+ await expect(clearTestCache({ cacheDirectory: 'missing' })).resolves.toBeUndefined();
39
+ rmSpy.mockRestore();
40
+ });
41
+
42
+ it('waitFor returns false when condition is never met', async () => {
43
+ const result = await waitFor(async () => false, 50, 10);
44
+ expect(result).toBe(false);
45
+ });
46
+ });