@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.
- package/.agent/workflows/code-review.md +60 -0
- package/.prettierrc +7 -0
- package/ARCHITECTURE.md +105 -170
- package/CONTRIBUTING.md +32 -113
- package/GEMINI.md +73 -0
- package/LICENSE +21 -21
- package/README.md +161 -54
- package/config.json +876 -75
- package/debug-pids.js +27 -0
- package/eslint.config.js +36 -0
- package/features/ann-config.js +37 -26
- package/features/clear-cache.js +28 -19
- package/features/find-similar-code.js +142 -66
- package/features/hybrid-search.js +253 -93
- package/features/index-codebase.js +1455 -394
- package/features/lifecycle.js +813 -180
- package/features/register.js +58 -52
- package/index.js +450 -306
- package/lib/cache-ops.js +22 -0
- package/lib/cache-utils.js +68 -0
- package/lib/cache.js +1392 -587
- package/lib/call-graph.js +165 -50
- package/lib/cli.js +154 -0
- package/lib/config.js +462 -121
- package/lib/embedding-process.js +77 -0
- package/lib/embedding-worker.js +545 -30
- package/lib/ignore-patterns.js +61 -59
- package/lib/json-worker.js +14 -0
- package/lib/json-writer.js +344 -0
- package/lib/logging.js +88 -0
- package/lib/memory-logger.js +13 -0
- package/lib/project-detector.js +13 -17
- package/lib/server-lifecycle.js +38 -0
- package/lib/settings-editor.js +645 -0
- package/lib/tokenizer.js +207 -104
- package/lib/utils.js +273 -198
- package/lib/vector-store-binary.js +592 -0
- package/mcp_config.example.json +13 -0
- package/package.json +13 -2
- package/scripts/clear-cache.js +6 -17
- package/scripts/download-model.js +14 -9
- package/scripts/postinstall.js +5 -5
- package/search-configs.js +36 -0
- package/test/ann-config.test.js +179 -0
- package/test/ann-fallback.test.js +6 -6
- package/test/binary-store.test.js +69 -0
- package/test/cache-branches.test.js +120 -0
- package/test/cache-errors.test.js +264 -0
- package/test/cache-extra.test.js +300 -0
- package/test/cache-helpers.test.js +205 -0
- package/test/cache-hnsw-failure.test.js +40 -0
- package/test/cache-json-worker.test.js +190 -0
- package/test/cache-worker.test.js +102 -0
- package/test/cache.test.js +443 -0
- package/test/call-graph.test.js +103 -4
- package/test/clear-cache.test.js +69 -68
- package/test/code-review-workflow.test.js +50 -0
- package/test/config.test.js +418 -0
- package/test/coverage-gap.test.js +497 -0
- package/test/coverage-maximizer.test.js +236 -0
- package/test/debug-analysis.js +107 -0
- package/test/embedding-model.test.js +173 -103
- package/test/embedding-worker-extra.test.js +272 -0
- package/test/embedding-worker.test.js +158 -0
- package/test/features.test.js +139 -0
- package/test/final-boost.test.js +271 -0
- package/test/final-polish.test.js +183 -0
- package/test/final.test.js +95 -0
- package/test/find-similar-code.test.js +191 -0
- package/test/helpers.js +92 -11
- package/test/helpers.test.js +46 -0
- package/test/hybrid-search-basic.test.js +62 -0
- package/test/hybrid-search-branch.test.js +202 -0
- package/test/hybrid-search-callgraph.test.js +229 -0
- package/test/hybrid-search-extra.test.js +81 -0
- package/test/hybrid-search.test.js +484 -71
- package/test/index-cli.test.js +520 -0
- package/test/index-codebase-batch.test.js +119 -0
- package/test/index-codebase-branches.test.js +585 -0
- package/test/index-codebase-core.test.js +1032 -0
- package/test/index-codebase-edge-cases.test.js +254 -0
- package/test/index-codebase-errors.test.js +132 -0
- package/test/index-codebase-gap.test.js +239 -0
- package/test/index-codebase-lines.test.js +151 -0
- package/test/index-codebase-watcher.test.js +259 -0
- package/test/index-codebase-zone.test.js +259 -0
- package/test/index-codebase.test.js +371 -69
- package/test/index-memory.test.js +220 -0
- package/test/indexer-detailed.test.js +176 -0
- package/test/integration.test.js +148 -92
- package/test/json-worker.test.js +50 -0
- package/test/lifecycle.test.js +541 -0
- package/test/master.test.js +198 -0
- package/test/perfection.test.js +349 -0
- package/test/project-detector.test.js +65 -0
- package/test/register.test.js +262 -0
- package/test/tokenizer.test.js +55 -93
- package/test/ultra-maximizer.test.js +116 -0
- package/test/utils-branches.test.js +161 -0
- package/test/utils-extra.test.js +116 -0
- package/test/utils.test.js +131 -0
- package/test/verify_fixes.js +76 -0
- package/test/worker-errors.test.js +96 -0
- package/test/worker-init.test.js +102 -0
- package/test/worker_throttling.test.js +93 -0
- package/tools/scripts/benchmark-search.js +95 -0
- package/tools/scripts/cache-stats.js +71 -0
- package/tools/scripts/manual-search.js +34 -0
- package/vitest.config.js +19 -9
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import { EventEmitter } from 'events';
|
|
3
|
+
import fs from 'fs/promises';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
|
|
6
|
+
// Master mock for everything
|
|
7
|
+
vi.mock('fs/promises');
|
|
8
|
+
vi.mock('chokidar', () => ({
|
|
9
|
+
default: {
|
|
10
|
+
watch: vi.fn(() => ({
|
|
11
|
+
on: vi.fn().mockReturnThis(),
|
|
12
|
+
close: vi.fn().mockResolvedValue(true),
|
|
13
|
+
})),
|
|
14
|
+
},
|
|
15
|
+
}));
|
|
16
|
+
|
|
17
|
+
// Safe Mock Worker
|
|
18
|
+
class SafeMockWorker extends EventEmitter {
|
|
19
|
+
constructor() {
|
|
20
|
+
super();
|
|
21
|
+
this.postMessage = vi.fn();
|
|
22
|
+
this.terminate = vi.fn().mockResolvedValue(0);
|
|
23
|
+
this.threadId = Math.random();
|
|
24
|
+
}
|
|
25
|
+
off = this.removeListener;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe('Master Coverage Maximizer', () => {
|
|
29
|
+
describe('lib/cache.js', () => {
|
|
30
|
+
let EmbeddingsCache;
|
|
31
|
+
beforeEach(async () => {
|
|
32
|
+
vi.resetModules();
|
|
33
|
+
const mod = await import('../lib/cache.js');
|
|
34
|
+
EmbeddingsCache = mod.EmbeddingsCache;
|
|
35
|
+
vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('targets initHnswIndex retry logic via buildAnnIndex', async () => {
|
|
39
|
+
const mockIndex = {
|
|
40
|
+
initIndex: vi
|
|
41
|
+
.fn()
|
|
42
|
+
.mockImplementationOnce(() => {
|
|
43
|
+
throw new Error('1');
|
|
44
|
+
})
|
|
45
|
+
.mockImplementationOnce(() => {
|
|
46
|
+
throw new Error('2');
|
|
47
|
+
})
|
|
48
|
+
.mockReturnValue(true),
|
|
49
|
+
addPoint: vi.fn(),
|
|
50
|
+
setEf: vi.fn(),
|
|
51
|
+
};
|
|
52
|
+
const cache = new EmbeddingsCache({ annMetric: 'l2' });
|
|
53
|
+
cache.vectorStore = [{ vector: [0.1] }];
|
|
54
|
+
const Factory = vi.fn(function () {
|
|
55
|
+
return mockIndex;
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
await cache.buildAnnIndex(Factory, 1);
|
|
59
|
+
expect(mockIndex.initIndex).toHaveBeenCalledTimes(3);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('targets clearCallGraphData failure (line 520)', async () => {
|
|
63
|
+
const cache = new EmbeddingsCache({
|
|
64
|
+
enableCache: true,
|
|
65
|
+
cacheDirectory: '/x',
|
|
66
|
+
verbose: true,
|
|
67
|
+
});
|
|
68
|
+
vi.mocked(fs.rm).mockRejectedValue(new Error('fail'));
|
|
69
|
+
await cache.clearCallGraphData({ removeFile: true });
|
|
70
|
+
expect(console.warn).toHaveBeenCalledWith(
|
|
71
|
+
expect.stringContaining('Failed to remove call-graph cache')
|
|
72
|
+
);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('targets getRelatedFiles missing graph logic (line 602)', async () => {
|
|
76
|
+
const cache = new EmbeddingsCache({ callGraphEnabled: true });
|
|
77
|
+
cache.setFileCallData('a.js', { definitions: [], calls: [] });
|
|
78
|
+
cache.callGraph = null;
|
|
79
|
+
// Force dynamic import to fail or not set callGraph
|
|
80
|
+
const result = await cache.getRelatedFiles(['test']);
|
|
81
|
+
expect(result.size).toBe(0);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe('features/index-codebase.js', () => {
|
|
86
|
+
let CodebaseIndexer, handleToolCall;
|
|
87
|
+
|
|
88
|
+
beforeEach(async () => {
|
|
89
|
+
vi.resetModules();
|
|
90
|
+
// Mock os
|
|
91
|
+
vi.doMock('os', () => ({
|
|
92
|
+
cpus: () => [{}, {}],
|
|
93
|
+
default: { cpus: () => [{}, {}] },
|
|
94
|
+
}));
|
|
95
|
+
|
|
96
|
+
const mod = await import('../features/index-codebase.js');
|
|
97
|
+
CodebaseIndexer = mod.CodebaseIndexer;
|
|
98
|
+
handleToolCall = mod.handleToolCall;
|
|
99
|
+
vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('targets worker init timeout and error paths (lines 120-153)', async () => {
|
|
103
|
+
let workerCount = 0;
|
|
104
|
+
const workers = [];
|
|
105
|
+
vi.doMock('worker_threads', () => ({
|
|
106
|
+
Worker: vi.fn(function () {
|
|
107
|
+
const w = new SafeMockWorker();
|
|
108
|
+
workers.push(w);
|
|
109
|
+
workerCount++;
|
|
110
|
+
if (workerCount === 1) {
|
|
111
|
+
setTimeout(() => w.emit('message', { type: 'ready' }), 10);
|
|
112
|
+
} else {
|
|
113
|
+
setTimeout(() => w.emit('message', { type: 'error', error: 'boom' }), 10);
|
|
114
|
+
}
|
|
115
|
+
return w;
|
|
116
|
+
}),
|
|
117
|
+
}));
|
|
118
|
+
|
|
119
|
+
const { CodebaseIndexer: LocalIndexer } = await import('../features/index-codebase.js');
|
|
120
|
+
const indexer = new LocalIndexer(
|
|
121
|
+
vi.fn(),
|
|
122
|
+
{},
|
|
123
|
+
{
|
|
124
|
+
workerThreads: 2,
|
|
125
|
+
verbose: true,
|
|
126
|
+
embeddingModel: 'test',
|
|
127
|
+
}
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
await indexer.initializeWorkers();
|
|
131
|
+
expect(console.warn).toHaveBeenCalledWith(
|
|
132
|
+
expect.stringContaining('Worker initialization failed')
|
|
133
|
+
);
|
|
134
|
+
expect(indexer.workers.length).toBe(0);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('targets processChunksWithWorkers error/crash paths (lines 230-250)', async () => {
|
|
138
|
+
const worker = new SafeMockWorker();
|
|
139
|
+
const indexer = new CodebaseIndexer(vi.fn(), {}, { verbose: true });
|
|
140
|
+
indexer.workers = [worker];
|
|
141
|
+
|
|
142
|
+
// Setup postMessage to emit result for p1
|
|
143
|
+
worker.postMessage.mockImplementationOnce((msg) => {
|
|
144
|
+
setTimeout(() => {
|
|
145
|
+
worker.emit('message', {
|
|
146
|
+
type: 'error',
|
|
147
|
+
error: 'msg_fail',
|
|
148
|
+
batchId: msg.batchId,
|
|
149
|
+
});
|
|
150
|
+
}, 10);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
const p1 = indexer.processChunksWithWorkers([{ text: 'a' }]);
|
|
154
|
+
await p1;
|
|
155
|
+
expect(console.warn).toHaveBeenCalledWith(expect.stringContaining('Worker 0 error'));
|
|
156
|
+
|
|
157
|
+
// Setup for p2 (crash)
|
|
158
|
+
worker.postMessage.mockImplementationOnce(() => {
|
|
159
|
+
setTimeout(() => {
|
|
160
|
+
worker.emit('error', new Error('crashed'));
|
|
161
|
+
}, 10);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
const p2 = indexer.processChunksWithWorkers([{ text: 'b' }]);
|
|
165
|
+
await p2;
|
|
166
|
+
expect(console.warn).toHaveBeenCalledWith(expect.stringContaining('Worker 0 crashed'));
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('targets setupFileWatcher closing existing (line 780)', async () => {
|
|
170
|
+
const indexer = new CodebaseIndexer(
|
|
171
|
+
vi.fn(),
|
|
172
|
+
{},
|
|
173
|
+
{
|
|
174
|
+
watchFiles: true,
|
|
175
|
+
fileExtensions: ['js'],
|
|
176
|
+
}
|
|
177
|
+
);
|
|
178
|
+
const mockWatcher = { close: vi.fn().mockResolvedValue(true) };
|
|
179
|
+
indexer.watcher = mockWatcher;
|
|
180
|
+
await indexer.setupFileWatcher();
|
|
181
|
+
expect(mockWatcher.close).toHaveBeenCalled();
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it('targets preFilterFiles directory skip (line 465)', async () => {
|
|
185
|
+
const indexer = new CodebaseIndexer(
|
|
186
|
+
vi.fn(),
|
|
187
|
+
{ getFileHash: () => null },
|
|
188
|
+
{ maxFileSize: 100 }
|
|
189
|
+
);
|
|
190
|
+
vi.mocked(fs.stat).mockResolvedValue({
|
|
191
|
+
isDirectory: () => true,
|
|
192
|
+
size: 0,
|
|
193
|
+
});
|
|
194
|
+
const result = await indexer.preFilterFiles(['dir']);
|
|
195
|
+
expect(result.length).toBe(0);
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
});
|
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import fs from 'fs/promises';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import os from 'os';
|
|
5
|
+
import { EmbeddingsCache } from '../lib/cache.js';
|
|
6
|
+
|
|
7
|
+
const makeConfig = (cacheDir, overrides = {}) => ({
|
|
8
|
+
cacheDirectory: cacheDir,
|
|
9
|
+
searchDirectory: cacheDir,
|
|
10
|
+
enableCache: true,
|
|
11
|
+
callGraphEnabled: true,
|
|
12
|
+
embeddingModel: 'test-model',
|
|
13
|
+
fileExtensions: ['js'],
|
|
14
|
+
excludePatterns: [],
|
|
15
|
+
annEnabled: true,
|
|
16
|
+
annMinChunks: 1,
|
|
17
|
+
annMetric: 'cosine',
|
|
18
|
+
annM: 48,
|
|
19
|
+
annEfConstruction: 200,
|
|
20
|
+
annEfSearch: 10,
|
|
21
|
+
annIndexCache: true,
|
|
22
|
+
verbose: true,
|
|
23
|
+
...overrides,
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
async function withTempDir(testFn) {
|
|
27
|
+
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'heuristic-perfection-'));
|
|
28
|
+
try {
|
|
29
|
+
await testFn(dir);
|
|
30
|
+
} finally {
|
|
31
|
+
await fs.rm(dir, { recursive: true, force: true });
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
describe('EmbeddingsCache Perfection', () => {
|
|
36
|
+
let warnSpy;
|
|
37
|
+
let infoSpy;
|
|
38
|
+
|
|
39
|
+
beforeEach(() => {
|
|
40
|
+
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
41
|
+
infoSpy = vi.spyOn(console, 'info').mockImplementation(() => {});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
afterEach(() => {
|
|
45
|
+
warnSpy.mockRestore();
|
|
46
|
+
infoSpy.mockRestore();
|
|
47
|
+
vi.clearAllMocks();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('covers missing branches in ensureAnnIndex', async () => {
|
|
51
|
+
await withTempDir(async (dir) => {
|
|
52
|
+
const config = makeConfig(dir);
|
|
53
|
+
const cache = new EmbeddingsCache(config);
|
|
54
|
+
|
|
55
|
+
// Branch: annEnabled is false
|
|
56
|
+
cache.config.annEnabled = false;
|
|
57
|
+
expect(await cache.ensureAnnIndex()).toBeNull();
|
|
58
|
+
|
|
59
|
+
// Branch: vectorStore too small
|
|
60
|
+
cache.config.annEnabled = true;
|
|
61
|
+
cache.config.annMinChunks = 10;
|
|
62
|
+
cache.vectorStore = [{ vector: [1, 2, 3] }];
|
|
63
|
+
expect(await cache.ensureAnnIndex()).toBeNull();
|
|
64
|
+
|
|
65
|
+
// Branch: annIndex already exists and not dirty
|
|
66
|
+
cache.config.annMinChunks = 1; // Reset to allow index
|
|
67
|
+
const mockIndex = { setEf: vi.fn() };
|
|
68
|
+
cache.annIndex = mockIndex;
|
|
69
|
+
cache.annDirty = false;
|
|
70
|
+
expect(await cache.ensureAnnIndex()).toBe(mockIndex);
|
|
71
|
+
|
|
72
|
+
// Branch: annLoading already exists
|
|
73
|
+
cache.annIndex = null; // Clear index to reach loading branch
|
|
74
|
+
const loadingValue = { mock: 'loading' };
|
|
75
|
+
const loadingPromise = Promise.resolve(loadingValue);
|
|
76
|
+
cache.annLoading = loadingPromise;
|
|
77
|
+
expect(await cache.ensureAnnIndex()).toBe(loadingValue);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('covers filtered outdated cache entries in load', async () => {
|
|
82
|
+
await withTempDir(async (dir) => {
|
|
83
|
+
const config = makeConfig(dir, { fileExtensions: ['js'] });
|
|
84
|
+
const cache = new EmbeddingsCache(config);
|
|
85
|
+
|
|
86
|
+
const meta = { version: 1, embeddingModel: config.embeddingModel };
|
|
87
|
+
const cacheData = [
|
|
88
|
+
{ file: 'a.js', vector: [1, 2] },
|
|
89
|
+
{ file: 'a.txt', vector: [3, 4] }, // Will be filtered
|
|
90
|
+
];
|
|
91
|
+
const hashData = {
|
|
92
|
+
'a.js': 'hash1',
|
|
93
|
+
'a.txt': 'hash2',
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
await fs.writeFile(path.join(dir, 'meta.json'), JSON.stringify(meta));
|
|
97
|
+
await fs.writeFile(path.join(dir, 'embeddings.json'), JSON.stringify(cacheData));
|
|
98
|
+
await fs.writeFile(path.join(dir, 'file-hashes.json'), JSON.stringify(hashData));
|
|
99
|
+
|
|
100
|
+
await cache.load();
|
|
101
|
+
|
|
102
|
+
expect(cache.getVectorStore()).toHaveLength(1);
|
|
103
|
+
expect(cache.getFileHash('a.js')).toBe('hash1');
|
|
104
|
+
expect(cache.getFileHash('a.txt')).toBeUndefined();
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('covers call-graph file missing during load', async () => {
|
|
109
|
+
await withTempDir(async (dir) => {
|
|
110
|
+
const config = makeConfig(dir);
|
|
111
|
+
const cache = new EmbeddingsCache(config);
|
|
112
|
+
|
|
113
|
+
// Setup minimal meta to pass initial checks
|
|
114
|
+
const meta = { version: 1, embeddingModel: config.embeddingModel };
|
|
115
|
+
await fs.writeFile(path.join(dir, 'meta.json'), JSON.stringify(meta));
|
|
116
|
+
|
|
117
|
+
// No call-graph.json exists. This should hit the empty catch block at line 131.
|
|
118
|
+
await cache.load();
|
|
119
|
+
// No error should be logged for missing call-graph.json
|
|
120
|
+
expect(infoSpy).not.toHaveBeenCalledWith(expect.stringContaining('call-graph'));
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('covers clearCallGraphData branches', async () => {
|
|
125
|
+
await withTempDir(async (dir) => {
|
|
126
|
+
const config = makeConfig(dir, { enableCache: true });
|
|
127
|
+
const cache = new EmbeddingsCache(config);
|
|
128
|
+
|
|
129
|
+
// Branch: removeFile is true, enableCache is true
|
|
130
|
+
const callGraphFile = path.join(dir, 'call-graph.json');
|
|
131
|
+
await fs.writeFile(callGraphFile, '{}');
|
|
132
|
+
await cache.clearCallGraphData({ removeFile: true });
|
|
133
|
+
await expect(fs.access(callGraphFile)).rejects.toThrow();
|
|
134
|
+
|
|
135
|
+
// Branch: removeFile is true, enableCache is false
|
|
136
|
+
cache.config.enableCache = false;
|
|
137
|
+
await cache.clearCallGraphData({ removeFile: true });
|
|
138
|
+
// Should not attempt to remove anything (tested via coverage)
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('covers getRelatedFiles branches', async () => {
|
|
143
|
+
await withTempDir(async (dir) => {
|
|
144
|
+
const config = makeConfig(dir, { callGraphEnabled: false });
|
|
145
|
+
const cache = new EmbeddingsCache(config);
|
|
146
|
+
|
|
147
|
+
// Branch: callGraphEnabled is false
|
|
148
|
+
expect((await cache.getRelatedFiles(['sym'])).size).toBe(0);
|
|
149
|
+
|
|
150
|
+
// Branch: symbols empty
|
|
151
|
+
cache.config.callGraphEnabled = true;
|
|
152
|
+
expect((await cache.getRelatedFiles([])).size).toBe(0);
|
|
153
|
+
|
|
154
|
+
// Branch: callGraph is null and fileCallData is empty
|
|
155
|
+
cache.clearFileCallData();
|
|
156
|
+
cache.callGraph = null;
|
|
157
|
+
expect((await cache.getRelatedFiles(['sym'])).size).toBe(0);
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('covers setEfSearch error branch', async () => {
|
|
162
|
+
const cache = new EmbeddingsCache(makeConfig('dir'));
|
|
163
|
+
const result = cache.setEfSearch('not-a-number');
|
|
164
|
+
expect(result.success).toBe(false);
|
|
165
|
+
expect(result.error).toBeDefined();
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('covers load() early return when no files exist', async () => {
|
|
169
|
+
await withTempDir(async (dir) => {
|
|
170
|
+
const config = makeConfig(dir);
|
|
171
|
+
const cache = new EmbeddingsCache(config);
|
|
172
|
+
await cache.load();
|
|
173
|
+
expect(cache.getVectorStore()).toEqual([]);
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('covers readHnswIndex retries and failure', () => {
|
|
178
|
+
const cache = new EmbeddingsCache(makeConfig('dir'));
|
|
179
|
+
// Since readHnswIndex is not exported, it is tested via loadAnnIndexFromDisk
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('covers readHnswIndex retries and failure via loadAnnIndexFromDisk', async () => {
|
|
183
|
+
await withTempDir(async (dir) => {
|
|
184
|
+
const config = makeConfig(dir);
|
|
185
|
+
const cache = new EmbeddingsCache(config);
|
|
186
|
+
cache.vectorStore = [{ vector: [1] }];
|
|
187
|
+
|
|
188
|
+
const meta = {
|
|
189
|
+
version: 1,
|
|
190
|
+
embeddingModel: config.embeddingModel,
|
|
191
|
+
count: 1,
|
|
192
|
+
dim: 1,
|
|
193
|
+
metric: config.annMetric,
|
|
194
|
+
m: config.annM,
|
|
195
|
+
efConstruction: config.annEfConstruction,
|
|
196
|
+
};
|
|
197
|
+
await fs.writeFile(path.join(dir, 'ann-meta.json'), JSON.stringify(meta));
|
|
198
|
+
await fs.writeFile(path.join(dir, 'ann-index.bin'), '');
|
|
199
|
+
|
|
200
|
+
let calls = 0;
|
|
201
|
+
class MockIndex {
|
|
202
|
+
readIndexSync() {
|
|
203
|
+
calls++;
|
|
204
|
+
if (calls === 1) throw new Error('fail 1');
|
|
205
|
+
if (calls === 2) return true; // Succeed on second try
|
|
206
|
+
}
|
|
207
|
+
setEf() {}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
await cache.loadAnnIndexFromDisk(MockIndex, 1);
|
|
211
|
+
expect(calls).toBe(2);
|
|
212
|
+
|
|
213
|
+
// Failure case
|
|
214
|
+
calls = 0;
|
|
215
|
+
class FailIndex {
|
|
216
|
+
readIndexSync() {
|
|
217
|
+
calls++;
|
|
218
|
+
throw new Error('fail');
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
await cache.loadAnnIndexFromDisk(FailIndex, 1);
|
|
222
|
+
expect(calls).toBe(2);
|
|
223
|
+
expect(warnSpy).toHaveBeenCalledWith(
|
|
224
|
+
expect.stringContaining('Failed to load ANN index file')
|
|
225
|
+
);
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it('covers rebuildCallGraph error', async () => {
|
|
230
|
+
const cache = new EmbeddingsCache(makeConfig('dir', { verbose: true }));
|
|
231
|
+
cache.setFileCallData('a.js', {});
|
|
232
|
+
|
|
233
|
+
// We can't easily mock the dynamic import here to fail,
|
|
234
|
+
// but we can at least call it and hope it runs.
|
|
235
|
+
cache.rebuildCallGraph();
|
|
236
|
+
// Wait a bit for the promise
|
|
237
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it('covers load() missing metadata with hash present', async () => {
|
|
241
|
+
await withTempDir(async (dir) => {
|
|
242
|
+
const config = makeConfig(dir);
|
|
243
|
+
const cache = new EmbeddingsCache(config);
|
|
244
|
+
await fs.writeFile(path.join(dir, 'file-hashes.json'), '{}');
|
|
245
|
+
await cache.load();
|
|
246
|
+
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Missing cache metadata'));
|
|
247
|
+
});
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it('covers initHnswIndex fallbacks', async () => {
|
|
251
|
+
await withTempDir(async (dir) => {
|
|
252
|
+
const config = makeConfig(dir);
|
|
253
|
+
const cache = new EmbeddingsCache(config);
|
|
254
|
+
cache.vectorStore = [{ vector: [1] }];
|
|
255
|
+
|
|
256
|
+
let calls = 0;
|
|
257
|
+
class MockIndex {
|
|
258
|
+
initIndex() {
|
|
259
|
+
calls++;
|
|
260
|
+
if (calls === 1) throw new Error('1st fail');
|
|
261
|
+
if (calls === 2) throw new Error('2nd fail');
|
|
262
|
+
return; // 3rd succeeds
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
await cache.buildAnnIndex(MockIndex, 1);
|
|
267
|
+
expect(calls).toBe(3);
|
|
268
|
+
});
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it('covers annVectorCache length mismatch', () => {
|
|
272
|
+
const cache = new EmbeddingsCache(makeConfig('dir'));
|
|
273
|
+
cache.vectorStore = [{ vector: [1] }];
|
|
274
|
+
cache.annVectorCache = new Array(10); // Wrong length
|
|
275
|
+
const vec = cache.getAnnVector(0);
|
|
276
|
+
expect(cache.annVectorCache.length).toBe(1);
|
|
277
|
+
expect(vec).toBeInstanceOf(Float32Array);
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it('covers setEfSearch applied branch', () => {
|
|
281
|
+
const cache = new EmbeddingsCache(makeConfig('dir'));
|
|
282
|
+
cache.annIndex = { setEf: vi.fn() };
|
|
283
|
+
const result = cache.setEfSearch(20);
|
|
284
|
+
expect(result.applied).toBe(true);
|
|
285
|
+
expect(cache.annIndex.setEf).toHaveBeenCalledWith(20);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it('covers getCallGraphStats branches', () => {
|
|
289
|
+
const cache = new EmbeddingsCache(makeConfig('dir', { callGraphEnabled: true }));
|
|
290
|
+
cache.callGraph = { defines: new Set([1]), calledBy: new Set([2]) };
|
|
291
|
+
const stats = cache.getCallGraphStats();
|
|
292
|
+
expect(stats.definitions).toBe(1);
|
|
293
|
+
expect(stats.callTargets).toBe(1);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it('covers normalizeLabels via queryAnn', async () => {
|
|
297
|
+
const cache = new EmbeddingsCache(makeConfig('dir'));
|
|
298
|
+
cache.vectorStore = [{ vector: [1] }];
|
|
299
|
+
|
|
300
|
+
// Mock index to return different formats
|
|
301
|
+
const mockIndex = {
|
|
302
|
+
searchKnn: vi
|
|
303
|
+
.fn()
|
|
304
|
+
.mockReturnValueOnce({ neighbors: [0] })
|
|
305
|
+
.mockReturnValueOnce({ indices: [0] })
|
|
306
|
+
.mockReturnValueOnce({ unknown: [0] }),
|
|
307
|
+
};
|
|
308
|
+
cache.annIndex = mockIndex;
|
|
309
|
+
cache.annDirty = false;
|
|
310
|
+
|
|
311
|
+
expect(await cache.queryAnn([1], 1)).toEqual([0]); // neighbors
|
|
312
|
+
expect(await cache.queryAnn([1], 1)).toEqual([0]); // indices
|
|
313
|
+
expect(await cache.queryAnn([1], 1)).toEqual([]); // unknown/empty
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
it('covers setVectorStore and addToStore', () => {
|
|
317
|
+
const cache = new EmbeddingsCache(makeConfig('dir'));
|
|
318
|
+
cache.setVectorStore([{ file: 'test.js', vector: [1] }]);
|
|
319
|
+
expect(cache.getVectorStore().length).toBe(1);
|
|
320
|
+
expect(cache.annDirty).toBe(true);
|
|
321
|
+
|
|
322
|
+
cache.addToStore({ file: 'test2.js', vector: [2] });
|
|
323
|
+
expect(cache.getVectorStore().length).toBe(2);
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
it('covers clear()', async () => {
|
|
327
|
+
await withTempDir(async (dir) => {
|
|
328
|
+
const config = makeConfig(dir);
|
|
329
|
+
const cache = new EmbeddingsCache(config);
|
|
330
|
+
await fs.writeFile(path.join(dir, 'embeddings.json'), '[]');
|
|
331
|
+
await cache.clear();
|
|
332
|
+
expect(cache.getVectorStore()).toHaveLength(0);
|
|
333
|
+
expect(cache.getFileHashCount()).toBe(0);
|
|
334
|
+
await expect(fs.access(dir)).rejects.toThrow();
|
|
335
|
+
});
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
it('covers remaining getRelatedFiles branches', async () => {
|
|
339
|
+
await withTempDir(async (dir) => {
|
|
340
|
+
const config = makeConfig(dir, { callGraphEnabled: true });
|
|
341
|
+
const cache = new EmbeddingsCache(config);
|
|
342
|
+
|
|
343
|
+
// Branch: callGraph is null but fileCallData exists
|
|
344
|
+
cache.setFileCallData('a.js', { definitions: [], calls: [] });
|
|
345
|
+
const result = await cache.getRelatedFiles(['sym']);
|
|
346
|
+
expect(result).toBeDefined();
|
|
347
|
+
});
|
|
348
|
+
});
|
|
349
|
+
});
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for ProjectDetector
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, it, expect } from 'vitest';
|
|
6
|
+
import fs from 'fs/promises';
|
|
7
|
+
import path from 'path';
|
|
8
|
+
import os from 'os';
|
|
9
|
+
import { ProjectDetector } from '../lib/project-detector.js';
|
|
10
|
+
|
|
11
|
+
async function withTempDir(testFn) {
|
|
12
|
+
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'heuristic-detector-'));
|
|
13
|
+
try {
|
|
14
|
+
await testFn(dir);
|
|
15
|
+
} finally {
|
|
16
|
+
await fs.rm(dir, { recursive: true, force: true });
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe('ProjectDetector', () => {
|
|
21
|
+
it('returns empty when directory is missing', async () => {
|
|
22
|
+
const detector = new ProjectDetector(path.join(os.tmpdir(), 'does-not-exist'));
|
|
23
|
+
const types = await detector.detectProjectTypes();
|
|
24
|
+
expect(types).toEqual([]);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('detects wildcard markers like *.csproj', async () => {
|
|
28
|
+
await withTempDir(async (dir) => {
|
|
29
|
+
await fs.writeFile(path.join(dir, 'app.csproj'), '<Project />');
|
|
30
|
+
const detector = new ProjectDetector(dir);
|
|
31
|
+
const types = await detector.detectProjectTypes();
|
|
32
|
+
|
|
33
|
+
expect(types).toContain('dotnet');
|
|
34
|
+
|
|
35
|
+
const summary = detector.getSummary();
|
|
36
|
+
expect(summary.detectedTypes).toContain('dotnet');
|
|
37
|
+
expect(summary.patternCount).toBeGreaterThan(0);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('does not recurse past depth limit', async () => {
|
|
42
|
+
await withTempDir(async (dir) => {
|
|
43
|
+
const deepDir = path.join(dir, 'a', 'b', 'c');
|
|
44
|
+
await fs.mkdir(deepDir, { recursive: true });
|
|
45
|
+
await fs.writeFile(path.join(deepDir, 'package.json'), '{}');
|
|
46
|
+
|
|
47
|
+
const detector = new ProjectDetector(dir);
|
|
48
|
+
const types = await detector.detectProjectTypes();
|
|
49
|
+
|
|
50
|
+
expect(types).not.toContain('javascript');
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('short-circuits when startDepth exceeds maxDepth', async () => {
|
|
55
|
+
await withTempDir(async (dir) => {
|
|
56
|
+
const detector = new ProjectDetector(dir);
|
|
57
|
+
const types = await detector.detectProjectTypes({
|
|
58
|
+
startDepth: 3,
|
|
59
|
+
maxDepth: 2,
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
expect(types).toEqual([]);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
});
|