@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,62 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { HybridSearch } from '../features/hybrid-search.js';
|
|
3
|
+
import { createHybridSearchCacheStub } from './helpers.js';
|
|
4
|
+
|
|
5
|
+
describe('HybridSearch coverage', () => {
|
|
6
|
+
it('skips duplicate chunks during exact match fallback (line 113 coverage)', async () => {
|
|
7
|
+
const vectorStore = [
|
|
8
|
+
{
|
|
9
|
+
file: 'duplicate.js',
|
|
10
|
+
content: 'exact match',
|
|
11
|
+
startLine: 1,
|
|
12
|
+
endLine: 2,
|
|
13
|
+
vector: [1, 0]
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
file: 'new.js',
|
|
17
|
+
content: 'exact match',
|
|
18
|
+
startLine: 1,
|
|
19
|
+
endLine: 2,
|
|
20
|
+
vector: [0, 1]
|
|
21
|
+
}
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
const cache = createHybridSearchCacheStub({
|
|
25
|
+
vectorStore,
|
|
26
|
+
// ANN returns only the first chunk
|
|
27
|
+
queryAnn: async () => [0],
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const config = {
|
|
31
|
+
annEnabled: true,
|
|
32
|
+
// Ensure we get into the ANN path
|
|
33
|
+
annMinCandidates: 0,
|
|
34
|
+
annMaxCandidates: 10,
|
|
35
|
+
annCandidateMultiplier: 1,
|
|
36
|
+
// Need maxResults > exactMatchCount (which will be 1)
|
|
37
|
+
maxResults: 5,
|
|
38
|
+
semanticWeight: 1,
|
|
39
|
+
exactMatchBoost: 1,
|
|
40
|
+
recencyBoost: 0,
|
|
41
|
+
callGraphEnabled: false,
|
|
42
|
+
searchDirectory: '/test'
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const embedder = async () => ({ data: new Float32Array([1, 0]) });
|
|
46
|
+
|
|
47
|
+
const hybridSearch = new HybridSearch(embedder, cache, config);
|
|
48
|
+
|
|
49
|
+
// Search for "exact match"
|
|
50
|
+
// 1. ANN returns Chunk 0.
|
|
51
|
+
// 2. exactMatchCount = 1 (Chunk 0 has "exact match").
|
|
52
|
+
// 3. maxResults = 5. 1 < 5.
|
|
53
|
+
// 4. Fallback loop starts.
|
|
54
|
+
// 5. Checks Chunk 0. content matches. Key is in seen set. Line 113 -> continue.
|
|
55
|
+
// 6. Checks Chunk 1. content matches. Key not in seen set. Added.
|
|
56
|
+
|
|
57
|
+
const { results } = await hybridSearch.search('exact match', 5);
|
|
58
|
+
|
|
59
|
+
expect(results).toHaveLength(2);
|
|
60
|
+
expect(results.map(r => r.file).sort()).toEqual(['duplicate.js', 'new.js']);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
|
|
2
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
3
|
+
import { HybridSearch } from '../features/hybrid-search.js';
|
|
4
|
+
import { createHybridSearchCacheStub } from './helpers.js';
|
|
5
|
+
import fs from 'fs/promises';
|
|
6
|
+
|
|
7
|
+
describe('HybridSearch Branch Coverage', () => {
|
|
8
|
+
it('should handle fs.stat errors in populateFileModTimes', async () => {
|
|
9
|
+
const config = {
|
|
10
|
+
annEnabled: false,
|
|
11
|
+
semanticWeight: 1,
|
|
12
|
+
exactMatchBoost: 0,
|
|
13
|
+
recencyBoost: 0.5,
|
|
14
|
+
recencyDecayDays: 30,
|
|
15
|
+
callGraphEnabled: false,
|
|
16
|
+
callGraphBoost: 0,
|
|
17
|
+
searchDirectory: '/mock',
|
|
18
|
+
};
|
|
19
|
+
const embedder = vi.fn();
|
|
20
|
+
const cache = createHybridSearchCacheStub({
|
|
21
|
+
vectorStore: [],
|
|
22
|
+
getFileMeta: () => null,
|
|
23
|
+
});
|
|
24
|
+
const hybrid = new HybridSearch(embedder, cache, config);
|
|
25
|
+
|
|
26
|
+
// Mock fs.stat to fail for one file and succeed for another
|
|
27
|
+
const statSpy = vi.spyOn(fs, 'stat').mockImplementation(async (path) => {
|
|
28
|
+
if (path === 'fail.js') {
|
|
29
|
+
throw new Error('stat failed');
|
|
30
|
+
}
|
|
31
|
+
return { mtimeMs: 1000 };
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
await hybrid.populateFileModTimes(['success.js', 'fail.js']);
|
|
35
|
+
|
|
36
|
+
expect(hybrid.fileModTimes.get('success.js')).toBe(1000);
|
|
37
|
+
expect(hybrid.fileModTimes.get('fail.js')).toBeNull();
|
|
38
|
+
|
|
39
|
+
statSpy.mockRestore();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('should return early from populateFileModTimes if no missing files', async () => {
|
|
43
|
+
const hybrid = new HybridSearch({}, createHybridSearchCacheStub(), {});
|
|
44
|
+
hybrid.fileModTimes.set('a.js', 100);
|
|
45
|
+
|
|
46
|
+
const statSpy = vi.spyOn(fs, 'stat');
|
|
47
|
+
await hybrid.populateFileModTimes(['a.js']);
|
|
48
|
+
|
|
49
|
+
expect(statSpy).not.toHaveBeenCalled();
|
|
50
|
+
statSpy.mockRestore();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('should handle null mtime in scoring', async () => {
|
|
54
|
+
const vectorStore = [
|
|
55
|
+
{
|
|
56
|
+
file: 'null-mtime.js',
|
|
57
|
+
content: 'content',
|
|
58
|
+
vector: [1, 0],
|
|
59
|
+
startLine: 1,
|
|
60
|
+
endLine: 1,
|
|
61
|
+
},
|
|
62
|
+
];
|
|
63
|
+
const cache = createHybridSearchCacheStub({
|
|
64
|
+
vectorStore,
|
|
65
|
+
queryAnn: async () => null,
|
|
66
|
+
});
|
|
67
|
+
const config = {
|
|
68
|
+
annEnabled: false,
|
|
69
|
+
semanticWeight: 1,
|
|
70
|
+
exactMatchBoost: 0,
|
|
71
|
+
recencyBoost: 0.5,
|
|
72
|
+
recencyDecayDays: 30,
|
|
73
|
+
callGraphEnabled: false,
|
|
74
|
+
callGraphBoost: 0,
|
|
75
|
+
searchDirectory: '/mock',
|
|
76
|
+
};
|
|
77
|
+
const embedder = async () => ({ data: new Float32Array([1, 0]) });
|
|
78
|
+
const hybrid = new HybridSearch(embedder, cache, config);
|
|
79
|
+
|
|
80
|
+
// Explicitly set null mtime
|
|
81
|
+
hybrid.fileModTimes.set('null-mtime.js', null);
|
|
82
|
+
|
|
83
|
+
const { results } = await hybrid.search('query', 1);
|
|
84
|
+
expect(results[0].score).toBe(1); // Only semantic weight
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('should skip call graph boost if no symbols from top results', async () => {
|
|
88
|
+
const vectorStore = [
|
|
89
|
+
{
|
|
90
|
+
file: 'no-symbols.js',
|
|
91
|
+
content: '', // Empty content -> no symbols
|
|
92
|
+
vector: [1, 0],
|
|
93
|
+
startLine: 1,
|
|
94
|
+
endLine: 1,
|
|
95
|
+
},
|
|
96
|
+
];
|
|
97
|
+
const cache = createHybridSearchCacheStub({
|
|
98
|
+
vectorStore,
|
|
99
|
+
queryAnn: async () => null,
|
|
100
|
+
getRelatedFiles: vi.fn(),
|
|
101
|
+
});
|
|
102
|
+
const config = {
|
|
103
|
+
annEnabled: false,
|
|
104
|
+
semanticWeight: 1,
|
|
105
|
+
exactMatchBoost: 0,
|
|
106
|
+
recencyBoost: 0,
|
|
107
|
+
callGraphEnabled: true,
|
|
108
|
+
callGraphBoost: 0.5,
|
|
109
|
+
searchDirectory: '/mock',
|
|
110
|
+
};
|
|
111
|
+
const embedder = async () => ({ data: new Float32Array([1, 0]) });
|
|
112
|
+
const hybrid = new HybridSearch(embedder, cache, config);
|
|
113
|
+
|
|
114
|
+
await hybrid.search('query', 1);
|
|
115
|
+
expect(cache.getRelatedFiles).not.toHaveBeenCalled();
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('should skip chunks without content in exact match fallback (line 113)', async () => {
|
|
119
|
+
const vectorStore = [
|
|
120
|
+
{
|
|
121
|
+
file: 'no-content.js',
|
|
122
|
+
content: null,
|
|
123
|
+
vector: [1, 0],
|
|
124
|
+
startLine: 1,
|
|
125
|
+
endLine: 1,
|
|
126
|
+
},
|
|
127
|
+
];
|
|
128
|
+
const cache = createHybridSearchCacheStub({
|
|
129
|
+
vectorStore,
|
|
130
|
+
queryAnn: async () => [0],
|
|
131
|
+
});
|
|
132
|
+
const config = {
|
|
133
|
+
annEnabled: true,
|
|
134
|
+
annMinCandidates: 0,
|
|
135
|
+
annMaxCandidates: 10,
|
|
136
|
+
annCandidateMultiplier: 1,
|
|
137
|
+
semanticWeight: 1,
|
|
138
|
+
exactMatchBoost: 1,
|
|
139
|
+
recencyBoost: 0,
|
|
140
|
+
callGraphEnabled: false,
|
|
141
|
+
callGraphBoost: 0,
|
|
142
|
+
searchDirectory: '/mock',
|
|
143
|
+
};
|
|
144
|
+
const embedder = async () => ({ data: new Float32Array([1, 0]) });
|
|
145
|
+
const hybrid = new HybridSearch(embedder, cache, config);
|
|
146
|
+
|
|
147
|
+
// We need exactMatchCount < maxResults to trigger the fallback block
|
|
148
|
+
// ANN returns usedAnn = true.
|
|
149
|
+
// candidates = [chunk0].
|
|
150
|
+
// exactMatchCount = 0 (chunk0 has no content).
|
|
151
|
+
// exactMatchCount < maxResults (2).
|
|
152
|
+
// Fallback block is entered.
|
|
153
|
+
// Iterates vectorStore. chunk0 is skipped because chunk.content is null.
|
|
154
|
+
const { results } = await hybrid.search('target', 2);
|
|
155
|
+
expect(results).toHaveLength(1);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('should cover line 113: skip redundant chunk during exact match fallback', async () => {
|
|
159
|
+
const vectorStore = [
|
|
160
|
+
{
|
|
161
|
+
file: 'match.js',
|
|
162
|
+
content: 'target match',
|
|
163
|
+
vector: [1, 0],
|
|
164
|
+
startLine: 1,
|
|
165
|
+
endLine: 1,
|
|
166
|
+
}
|
|
167
|
+
];
|
|
168
|
+
const cache = createHybridSearchCacheStub({
|
|
169
|
+
vectorStore,
|
|
170
|
+
queryAnn: async () => [0],
|
|
171
|
+
});
|
|
172
|
+
const config = {
|
|
173
|
+
annEnabled: true,
|
|
174
|
+
annMinCandidates: 0,
|
|
175
|
+
annMaxCandidates: 10,
|
|
176
|
+
annCandidateMultiplier: 1,
|
|
177
|
+
semanticWeight: 1,
|
|
178
|
+
exactMatchBoost: 1,
|
|
179
|
+
recencyBoost: 0,
|
|
180
|
+
callGraphEnabled: false,
|
|
181
|
+
callGraphBoost: 0,
|
|
182
|
+
searchDirectory: '/mock',
|
|
183
|
+
};
|
|
184
|
+
const embedder = async () => ({ data: new Float32Array([1, 0]) });
|
|
185
|
+
const hybrid = new HybridSearch(embedder, cache, config);
|
|
186
|
+
|
|
187
|
+
// Flow:
|
|
188
|
+
// 1. usedAnn = true.
|
|
189
|
+
// 2. candidates = [chunk0].
|
|
190
|
+
// 3. exactMatchCount = 1.
|
|
191
|
+
// 4. maxResults = 2.
|
|
192
|
+
// 5. exactMatchCount < maxResults -> Fallback entered (line 110).
|
|
193
|
+
// 6. seen = Set(['match.js:1:1']).
|
|
194
|
+
// 7. Loop vectorStore:
|
|
195
|
+
// - chunk0: content matches 'target'.
|
|
196
|
+
// - key = 'match.js:1:1'.
|
|
197
|
+
// - seen.has(key) is TRUE -> continues (line 113 COVERAGE).
|
|
198
|
+
const { results } = await hybrid.search('target', 2);
|
|
199
|
+
expect(results).toHaveLength(1);
|
|
200
|
+
expect(results[0].file).toBe('match.js');
|
|
201
|
+
});
|
|
202
|
+
});
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
|
|
2
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
3
|
+
import { HybridSearch } from '../features/hybrid-search.js';
|
|
4
|
+
import { createHybridSearchCacheStub } from './helpers.js';
|
|
5
|
+
import * as CallGraph from '../lib/call-graph.js';
|
|
6
|
+
|
|
7
|
+
describe('HybridSearch Final Coverage', () => {
|
|
8
|
+
describe('Partial Match Logic', () => {
|
|
9
|
+
it('should handle short words and missing words in partial matching', async () => {
|
|
10
|
+
const vectorStore = [
|
|
11
|
+
{
|
|
12
|
+
file: 'partial.js',
|
|
13
|
+
content: 'some longcontent here',
|
|
14
|
+
vector: [1, 0],
|
|
15
|
+
startLine: 1,
|
|
16
|
+
endLine: 1,
|
|
17
|
+
}
|
|
18
|
+
];
|
|
19
|
+
const cache = createHybridSearchCacheStub({
|
|
20
|
+
vectorStore,
|
|
21
|
+
queryAnn: async () => null,
|
|
22
|
+
});
|
|
23
|
+
const config = {
|
|
24
|
+
annEnabled: false,
|
|
25
|
+
semanticWeight: 0, // Disable semantic score to isolate exact/partial match
|
|
26
|
+
exactMatchBoost: 10,
|
|
27
|
+
recencyBoost: 0,
|
|
28
|
+
callGraphEnabled: false,
|
|
29
|
+
searchDirectory: '/mock',
|
|
30
|
+
};
|
|
31
|
+
const embedder = async () => ({ data: new Float32Array([0, 0]) });
|
|
32
|
+
const hybrid = new HybridSearch(embedder, cache, config);
|
|
33
|
+
|
|
34
|
+
// Query: "is missingword"
|
|
35
|
+
// "is" -> length 2 (skipped)
|
|
36
|
+
// "missingword" -> length > 2 (checked, but not in content)
|
|
37
|
+
// matchedWords should be 0. Score should be 0 (since semanticWeight is 0).
|
|
38
|
+
|
|
39
|
+
const { results } = await hybrid.search('is missingword', 1);
|
|
40
|
+
expect(results[0].score).toBe(0);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('should match words longer than 2 characters', async () => {
|
|
44
|
+
const vectorStore = [
|
|
45
|
+
{
|
|
46
|
+
file: 'partial.js',
|
|
47
|
+
content: 'some longcontent here',
|
|
48
|
+
vector: [1, 0],
|
|
49
|
+
startLine: 1,
|
|
50
|
+
endLine: 1,
|
|
51
|
+
}
|
|
52
|
+
];
|
|
53
|
+
const cache = createHybridSearchCacheStub({
|
|
54
|
+
vectorStore,
|
|
55
|
+
queryAnn: async () => null,
|
|
56
|
+
});
|
|
57
|
+
const config = {
|
|
58
|
+
annEnabled: false,
|
|
59
|
+
semanticWeight: 0,
|
|
60
|
+
exactMatchBoost: 10,
|
|
61
|
+
recencyBoost: 0,
|
|
62
|
+
callGraphEnabled: false,
|
|
63
|
+
searchDirectory: '/mock',
|
|
64
|
+
};
|
|
65
|
+
const embedder = async () => ({ data: new Float32Array([0, 0]) });
|
|
66
|
+
const hybrid = new HybridSearch(embedder, cache, config);
|
|
67
|
+
|
|
68
|
+
// Query: "longcontent missing"
|
|
69
|
+
// Full string "longcontent missing" NOT in content.
|
|
70
|
+
// "longcontent" -> length > 2 (checked, found)
|
|
71
|
+
// "missing" -> length > 2 (checked, not found)
|
|
72
|
+
// matchedWords = 1. Total words = 2.
|
|
73
|
+
// Score = (1/2) * 0.3 = 0.15
|
|
74
|
+
|
|
75
|
+
const { results } = await hybrid.search('longcontent missing', 1);
|
|
76
|
+
expect(results[0].score).toBeCloseTo(0.15);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
describe('Call Graph Proximity', () => {
|
|
81
|
+
it('should apply boost only when proximity exists', async () => {
|
|
82
|
+
const vectorStore = [
|
|
83
|
+
{
|
|
84
|
+
file: 'source.js',
|
|
85
|
+
content: 'function source() {}',
|
|
86
|
+
vector: [1, 0],
|
|
87
|
+
startLine: 1,
|
|
88
|
+
endLine: 1,
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
file: 'related.js',
|
|
92
|
+
content: 'related content',
|
|
93
|
+
vector: [0.9, 0],
|
|
94
|
+
startLine: 1,
|
|
95
|
+
endLine: 1,
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
file: 'unrelated.js',
|
|
99
|
+
content: 'unrelated content',
|
|
100
|
+
vector: [0.8, 0],
|
|
101
|
+
startLine: 1,
|
|
102
|
+
endLine: 1,
|
|
103
|
+
}
|
|
104
|
+
];
|
|
105
|
+
|
|
106
|
+
const relatedMap = new Map();
|
|
107
|
+
relatedMap.set('related.js', 1); // Boost of 1
|
|
108
|
+
// unrelated.js is not in the map
|
|
109
|
+
|
|
110
|
+
const cache = createHybridSearchCacheStub({
|
|
111
|
+
vectorStore,
|
|
112
|
+
queryAnn: async () => null,
|
|
113
|
+
getRelatedFiles: async () => relatedMap,
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
const config = {
|
|
117
|
+
annEnabled: false,
|
|
118
|
+
semanticWeight: 1,
|
|
119
|
+
exactMatchBoost: 0,
|
|
120
|
+
recencyBoost: 0,
|
|
121
|
+
callGraphEnabled: true,
|
|
122
|
+
callGraphBoost: 10, // Large boost to make it obvious
|
|
123
|
+
searchDirectory: '/mock',
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const embedder = async () => ({ data: new Float32Array([1, 0]) });
|
|
127
|
+
const hybrid = new HybridSearch(embedder, cache, config);
|
|
128
|
+
|
|
129
|
+
// Mock extractSymbolsFromContent to return something so we trigger getRelatedFiles
|
|
130
|
+
vi.spyOn(CallGraph, 'extractSymbolsFromContent').mockReturnValue(['source']);
|
|
131
|
+
|
|
132
|
+
const { results } = await hybrid.search('query', 3);
|
|
133
|
+
|
|
134
|
+
// related.js should have a massive score due to boost
|
|
135
|
+
// unrelated.js should have normal score
|
|
136
|
+
|
|
137
|
+
const related = results.find(r => r.file === 'related.js');
|
|
138
|
+
const unrelated = results.find(r => r.file === 'unrelated.js');
|
|
139
|
+
|
|
140
|
+
// Base score is semantic similarity.
|
|
141
|
+
// related: 0.9 * 1 = 0.9. Boost: 1 * 10 = 10. Total 10.9
|
|
142
|
+
// unrelated: 0.8 * 1 = 0.8. Boost: 0. Total 0.8
|
|
143
|
+
|
|
144
|
+
expect(related.score).toBeGreaterThan(10);
|
|
145
|
+
expect(unrelated.score).toBeLessThan(1);
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
describe('Line 113 Coverage', () => {
|
|
150
|
+
it('should explicitly hit line 113 (redundant chunk check)', async () => {
|
|
151
|
+
const chunk = {
|
|
152
|
+
file: 'hit.js',
|
|
153
|
+
content: 'hit me',
|
|
154
|
+
vector: [1],
|
|
155
|
+
startLine: 1,
|
|
156
|
+
endLine: 1
|
|
157
|
+
};
|
|
158
|
+
const vectorStore = [chunk];
|
|
159
|
+
|
|
160
|
+
const cache = createHybridSearchCacheStub({
|
|
161
|
+
vectorStore,
|
|
162
|
+
queryAnn: async () => [0],
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
const config = {
|
|
166
|
+
annEnabled: true,
|
|
167
|
+
annMinCandidates: 0,
|
|
168
|
+
annMaxCandidates: 10,
|
|
169
|
+
annCandidateMultiplier: 1,
|
|
170
|
+
maxResults: 10,
|
|
171
|
+
semanticWeight: 1,
|
|
172
|
+
exactMatchBoost: 1,
|
|
173
|
+
recencyBoost: 0,
|
|
174
|
+
callGraphEnabled: false,
|
|
175
|
+
searchDirectory: '/mock',
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
const embedder = async () => ({ data: new Float32Array([1]) });
|
|
179
|
+
const hybrid = new HybridSearch(embedder, cache, config);
|
|
180
|
+
|
|
181
|
+
await hybrid.search('hit', 10);
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
describe('Edge Case Search Parameters', () => {
|
|
186
|
+
it('should skip exact match fallback if query is too short', async () => {
|
|
187
|
+
const vectorStore = [{ file: 'a.js', content: 'a', vector: [1], startLine: 1, endLine: 1 }];
|
|
188
|
+
const cache = createHybridSearchCacheStub({
|
|
189
|
+
vectorStore,
|
|
190
|
+
queryAnn: async () => [0],
|
|
191
|
+
});
|
|
192
|
+
const config = {
|
|
193
|
+
annEnabled: true,
|
|
194
|
+
maxResults: 2,
|
|
195
|
+
semanticWeight: 1,
|
|
196
|
+
exactMatchBoost: 1,
|
|
197
|
+
recencyBoost: 0,
|
|
198
|
+
callGraphEnabled: false,
|
|
199
|
+
searchDirectory: '/mock',
|
|
200
|
+
};
|
|
201
|
+
const embedder = async () => ({ data: new Float32Array([1]) });
|
|
202
|
+
const hybrid = new HybridSearch(embedder, cache, config);
|
|
203
|
+
|
|
204
|
+
// Query 'a' -> length 1. should skip the fallback logic.
|
|
205
|
+
await hybrid.search('a', 2);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it('should skip exact match fallback if exactMatchCount >= maxResults', async () => {
|
|
209
|
+
const vectorStore = [{ file: 'match.js', content: 'target', vector: [1], startLine: 1, endLine: 1 }];
|
|
210
|
+
const cache = createHybridSearchCacheStub({
|
|
211
|
+
vectorStore,
|
|
212
|
+
queryAnn: async () => [0],
|
|
213
|
+
});
|
|
214
|
+
const config = {
|
|
215
|
+
annEnabled: true,
|
|
216
|
+
maxResults: 1, // maxResults is 1, exactMatchCount will be 1
|
|
217
|
+
semanticWeight: 1,
|
|
218
|
+
exactMatchBoost: 1,
|
|
219
|
+
recencyBoost: 0,
|
|
220
|
+
callGraphEnabled: false,
|
|
221
|
+
searchDirectory: '/mock',
|
|
222
|
+
};
|
|
223
|
+
const embedder = async () => ({ data: new Float32Array([1]) });
|
|
224
|
+
const hybrid = new HybridSearch(embedder, cache, config);
|
|
225
|
+
|
|
226
|
+
await hybrid.search('target', 1);
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
});
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { HybridSearch } from '../features/hybrid-search.js';
|
|
3
|
+
import { createHybridSearchCacheStub } from './helpers.js';
|
|
4
|
+
|
|
5
|
+
describe('HybridSearch extra coverage', () => {
|
|
6
|
+
it('handles missing chunk content in ANN fallback loop', async () => {
|
|
7
|
+
const vectorStore = [
|
|
8
|
+
{
|
|
9
|
+
file: 'a.js',
|
|
10
|
+
content: undefined,
|
|
11
|
+
startLine: 1,
|
|
12
|
+
endLine: 2,
|
|
13
|
+
vector: [1, 0],
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
file: 'b.js',
|
|
17
|
+
content: 'no match here',
|
|
18
|
+
startLine: 3,
|
|
19
|
+
endLine: 4,
|
|
20
|
+
vector: [0, 1],
|
|
21
|
+
},
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
const cache = createHybridSearchCacheStub({
|
|
25
|
+
vectorStore,
|
|
26
|
+
queryAnn: async () => [0, 1],
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
const config = {
|
|
30
|
+
annEnabled: true,
|
|
31
|
+
annMinCandidates: 0,
|
|
32
|
+
annMaxCandidates: 10,
|
|
33
|
+
annCandidateMultiplier: 1,
|
|
34
|
+
maxResults: 2,
|
|
35
|
+
semanticWeight: 1,
|
|
36
|
+
exactMatchBoost: 1,
|
|
37
|
+
recencyBoost: 0,
|
|
38
|
+
callGraphEnabled: false,
|
|
39
|
+
searchDirectory: '/test',
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const embedder = async () => ({ data: new Float32Array([1, 0]) });
|
|
43
|
+
const hybridSearch = new HybridSearch(embedder, cache, config);
|
|
44
|
+
|
|
45
|
+
const { results } = await hybridSearch.search('ab', 2);
|
|
46
|
+
|
|
47
|
+
expect(results).toHaveLength(2);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('falls back with empty chunk content string', async () => {
|
|
51
|
+
const vectorStore = [
|
|
52
|
+
{ file: 'x.js', content: undefined, startLine: 1, endLine: 1, vector: [1, 0] },
|
|
53
|
+
{ file: 'y.js', content: 'ab', startLine: 2, endLine: 2, vector: [0, 1] },
|
|
54
|
+
];
|
|
55
|
+
|
|
56
|
+
const cache = createHybridSearchCacheStub({
|
|
57
|
+
vectorStore,
|
|
58
|
+
queryAnn: async () => [0, 1],
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const config = {
|
|
62
|
+
annEnabled: true,
|
|
63
|
+
annMinCandidates: 0,
|
|
64
|
+
annMaxCandidates: 10,
|
|
65
|
+
annCandidateMultiplier: 1,
|
|
66
|
+
maxResults: 2,
|
|
67
|
+
semanticWeight: 1,
|
|
68
|
+
exactMatchBoost: 1,
|
|
69
|
+
recencyBoost: 0,
|
|
70
|
+
callGraphEnabled: false,
|
|
71
|
+
searchDirectory: '/test',
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const embedder = async () => ({ data: new Float32Array([1, 0]) });
|
|
75
|
+
const hybridSearch = new HybridSearch(embedder, cache, config);
|
|
76
|
+
|
|
77
|
+
const { results } = await hybridSearch.search('ab', 2);
|
|
78
|
+
|
|
79
|
+
expect(results).toHaveLength(2);
|
|
80
|
+
});
|
|
81
|
+
});
|