@softerist/heuristic-mcp 2.0.0 → 2.1.1
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/ARCHITECTURE.md +9 -4
- package/CONTRIBUTING.md +6 -6
- package/README.md +37 -18
- package/config.json +12 -2
- package/features/ann-config.js +120 -0
- package/features/find-similar-code.js +40 -2
- package/features/hybrid-search.js +69 -5
- package/features/index-codebase.js +28 -4
- package/index.js +9 -1
- package/lib/cache.js +420 -10
- package/lib/call-graph.js +281 -0
- package/lib/config.js +141 -16
- package/lib/project-detector.js +49 -36
- package/package.json +5 -8
- package/test/ann-fallback.test.js +68 -0
- package/test/call-graph.test.js +142 -0
- package/test/clear-cache.test.js +3 -6
- package/test/helpers.js +64 -7
- package/test/hybrid-search.test.js +2 -2
- package/test/index-codebase.test.js +3 -10
- package/test/integration.test.js +3 -3
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { extractCallData, extractDefinitions, extractCalls, buildCallGraph, getRelatedFiles } from '../lib/call-graph.js';
|
|
3
|
+
|
|
4
|
+
describe('Call Graph Extractor', () => {
|
|
5
|
+
describe('extractDefinitions', () => {
|
|
6
|
+
it('should extract JavaScript function declarations', () => {
|
|
7
|
+
const content = `
|
|
8
|
+
function foo() {}
|
|
9
|
+
async function bar() {}
|
|
10
|
+
const baz = () => {};
|
|
11
|
+
let qux = async () => {};
|
|
12
|
+
`;
|
|
13
|
+
const defs = extractDefinitions(content, 'test.js');
|
|
14
|
+
expect(defs).toContain('foo');
|
|
15
|
+
expect(defs).toContain('bar');
|
|
16
|
+
expect(defs).toContain('baz');
|
|
17
|
+
expect(defs).toContain('qux');
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('should extract JavaScript class declarations', () => {
|
|
21
|
+
const content = `
|
|
22
|
+
class MyClass {
|
|
23
|
+
myMethod() {}
|
|
24
|
+
}
|
|
25
|
+
`;
|
|
26
|
+
const defs = extractDefinitions(content, 'test.js');
|
|
27
|
+
expect(defs).toContain('MyClass');
|
|
28
|
+
expect(defs).toContain('myMethod');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('should extract Python definitions', () => {
|
|
32
|
+
const content = `
|
|
33
|
+
def my_function():
|
|
34
|
+
pass
|
|
35
|
+
|
|
36
|
+
class MyClass:
|
|
37
|
+
def method(self):
|
|
38
|
+
pass
|
|
39
|
+
`;
|
|
40
|
+
const defs = extractDefinitions(content, 'test.py');
|
|
41
|
+
expect(defs).toContain('my_function');
|
|
42
|
+
expect(defs).toContain('MyClass');
|
|
43
|
+
expect(defs).toContain('method');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('should extract Go function declarations', () => {
|
|
47
|
+
const content = `
|
|
48
|
+
func main() {}
|
|
49
|
+
func (s *Server) Start() {}
|
|
50
|
+
`;
|
|
51
|
+
const defs = extractDefinitions(content, 'test.go');
|
|
52
|
+
expect(defs).toContain('main');
|
|
53
|
+
expect(defs).toContain('Start');
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe('extractCalls', () => {
|
|
58
|
+
it('should extract function calls', () => {
|
|
59
|
+
const content = `
|
|
60
|
+
function main() {
|
|
61
|
+
foo();
|
|
62
|
+
bar.baz();
|
|
63
|
+
await asyncFunc();
|
|
64
|
+
}
|
|
65
|
+
`;
|
|
66
|
+
const calls = extractCalls(content, 'test.js');
|
|
67
|
+
expect(calls).toContain('foo');
|
|
68
|
+
expect(calls).toContain('baz'); // bar.baz() -> extracts 'baz'
|
|
69
|
+
expect(calls).toContain('asyncFunc');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('should exclude built-in keywords', () => {
|
|
73
|
+
const content = `
|
|
74
|
+
if (condition) {}
|
|
75
|
+
for (let i = 0; i < 10; i++) {}
|
|
76
|
+
while (true) {}
|
|
77
|
+
`;
|
|
78
|
+
const calls = extractCalls(content, 'test.js');
|
|
79
|
+
expect(calls).not.toContain('if');
|
|
80
|
+
expect(calls).not.toContain('for');
|
|
81
|
+
expect(calls).not.toContain('while');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('should not extract calls from strings', () => {
|
|
85
|
+
const content = `
|
|
86
|
+
const str = "someFunction()";
|
|
87
|
+
const template = \`anotherFunction()\`;
|
|
88
|
+
`;
|
|
89
|
+
const calls = extractCalls(content, 'test.js');
|
|
90
|
+
expect(calls).not.toContain('someFunction');
|
|
91
|
+
expect(calls).not.toContain('anotherFunction');
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe('extractCallData', () => {
|
|
96
|
+
it('should separate definitions from external calls', () => {
|
|
97
|
+
const content = `
|
|
98
|
+
function localFunc() {
|
|
99
|
+
externalFunc();
|
|
100
|
+
localFunc(); // self-reference
|
|
101
|
+
}
|
|
102
|
+
`;
|
|
103
|
+
const result = extractCallData(content, 'test.js');
|
|
104
|
+
expect(result.definitions).toContain('localFunc');
|
|
105
|
+
expect(result.calls).toContain('externalFunc');
|
|
106
|
+
expect(result.calls).not.toContain('localFunc'); // Filtered as self-reference
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
describe('buildCallGraph', () => {
|
|
111
|
+
it('should build graph from file data', () => {
|
|
112
|
+
const fileData = new Map([
|
|
113
|
+
['/path/a.js', { definitions: ['funcA'], calls: ['funcB'] }],
|
|
114
|
+
['/path/b.js', { definitions: ['funcB'], calls: ['funcC'] }],
|
|
115
|
+
['/path/c.js', { definitions: ['funcC'], calls: [] }]
|
|
116
|
+
]);
|
|
117
|
+
|
|
118
|
+
const graph = buildCallGraph(fileData);
|
|
119
|
+
|
|
120
|
+
expect(graph.defines.get('funcA')).toContain('/path/a.js');
|
|
121
|
+
expect(graph.defines.get('funcB')).toContain('/path/b.js');
|
|
122
|
+
expect(graph.calledBy.get('funcB')).toContain('/path/a.js');
|
|
123
|
+
expect(graph.calledBy.get('funcC')).toContain('/path/b.js');
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
describe('getRelatedFiles', () => {
|
|
128
|
+
it('should find callers and callees', () => {
|
|
129
|
+
const fileData = new Map([
|
|
130
|
+
['/path/a.js', { definitions: ['funcA'], calls: ['funcB'] }],
|
|
131
|
+
['/path/b.js', { definitions: ['funcB'], calls: [] }]
|
|
132
|
+
]);
|
|
133
|
+
|
|
134
|
+
const graph = buildCallGraph(fileData);
|
|
135
|
+
const related = getRelatedFiles(graph, ['funcB'], 1);
|
|
136
|
+
|
|
137
|
+
// funcB is defined in b.js and called by a.js
|
|
138
|
+
expect(related.has('/path/a.js')).toBe(true);
|
|
139
|
+
expect(related.has('/path/b.js')).toBe(true);
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
});
|
package/test/clear-cache.test.js
CHANGED
|
@@ -25,7 +25,7 @@ describe('CacheClearer', () => {
|
|
|
25
25
|
let fixtures;
|
|
26
26
|
|
|
27
27
|
beforeAll(async () => {
|
|
28
|
-
fixtures = await createTestFixtures({ workerThreads:
|
|
28
|
+
fixtures = await createTestFixtures({ workerThreads: 1 });
|
|
29
29
|
});
|
|
30
30
|
|
|
31
31
|
afterAll(async () => {
|
|
@@ -90,9 +90,6 @@ describe('CacheClearer', () => {
|
|
|
90
90
|
fixtures.cache.fileHashes = new Map();
|
|
91
91
|
|
|
92
92
|
const indexPromise = fixtures.indexer.indexAll(true);
|
|
93
|
-
|
|
94
|
-
// Wait for indexing to start
|
|
95
|
-
await new Promise(resolve => setTimeout(resolve, 100));
|
|
96
93
|
expect(fixtures.indexer.isIndexing).toBe(true);
|
|
97
94
|
|
|
98
95
|
// Try to clear - should fail
|
|
@@ -207,7 +204,7 @@ describe('Clear Cache Tool Handler', () => {
|
|
|
207
204
|
let fixtures;
|
|
208
205
|
|
|
209
206
|
beforeAll(async () => {
|
|
210
|
-
fixtures = await createTestFixtures({ workerThreads:
|
|
207
|
+
fixtures = await createTestFixtures({ workerThreads: 1 });
|
|
211
208
|
});
|
|
212
209
|
|
|
213
210
|
afterAll(async () => {
|
|
@@ -250,7 +247,7 @@ describe('Clear Cache Tool Handler', () => {
|
|
|
250
247
|
fixtures.cache.fileHashes = new Map();
|
|
251
248
|
|
|
252
249
|
const indexPromise = fixtures.indexer.indexAll(true);
|
|
253
|
-
|
|
250
|
+
expect(fixtures.indexer.isIndexing).toBe(true);
|
|
254
251
|
|
|
255
252
|
const request = createMockRequest('c_clear_cache', {});
|
|
256
253
|
const result = await ClearCacheFeature.handleToolCall(request, fixtures.cacheClearer);
|
package/test/helpers.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Test helper utilities for
|
|
2
|
+
* Test helper utilities for Heuristic MCP tests
|
|
3
3
|
* Provides shared setup, teardown, and mock utilities
|
|
4
4
|
*/
|
|
5
5
|
|
|
@@ -15,6 +15,8 @@ import path from 'path';
|
|
|
15
15
|
// Cached embedder instance (shared across tests for speed)
|
|
16
16
|
let sharedEmbedder = null;
|
|
17
17
|
|
|
18
|
+
const DEFAULT_MOCK_DIMENSIONS = 64;
|
|
19
|
+
|
|
18
20
|
/**
|
|
19
21
|
* Get or initialize the shared embedder instance
|
|
20
22
|
* Loading the model once and reusing saves significant time
|
|
@@ -28,6 +30,57 @@ export async function getEmbedder(config) {
|
|
|
28
30
|
return sharedEmbedder;
|
|
29
31
|
}
|
|
30
32
|
|
|
33
|
+
function isVitest() {
|
|
34
|
+
return Boolean(process.env.VITEST || process.env.VITEST_WORKER_ID);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function hashToken(token) {
|
|
38
|
+
let hash = 2166136261;
|
|
39
|
+
for (let i = 0; i < token.length; i++) {
|
|
40
|
+
hash ^= token.charCodeAt(i);
|
|
41
|
+
hash += (hash << 1) + (hash << 4) + (hash << 7) + (hash << 8) + (hash << 24);
|
|
42
|
+
}
|
|
43
|
+
return hash >>> 0;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function normalizeVector(vector) {
|
|
47
|
+
let sumSquares = 0;
|
|
48
|
+
for (let i = 0; i < vector.length; i++) {
|
|
49
|
+
sumSquares += vector[i] * vector[i];
|
|
50
|
+
}
|
|
51
|
+
if (sumSquares === 0) {
|
|
52
|
+
vector[0] = 1;
|
|
53
|
+
return vector;
|
|
54
|
+
}
|
|
55
|
+
const norm = Math.sqrt(sumSquares);
|
|
56
|
+
for (let i = 0; i < vector.length; i++) {
|
|
57
|
+
vector[i] /= norm;
|
|
58
|
+
}
|
|
59
|
+
return vector;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function createMockEmbedder({ dimensions = DEFAULT_MOCK_DIMENSIONS } = {}) {
|
|
63
|
+
return async (text, options = {}) => {
|
|
64
|
+
const vector = new Float32Array(dimensions);
|
|
65
|
+
const tokens = String(text ?? "")
|
|
66
|
+
.toLowerCase()
|
|
67
|
+
.split(/[^a-z0-9_]+/g)
|
|
68
|
+
.filter(token => token.length > 1);
|
|
69
|
+
|
|
70
|
+
for (const token of tokens) {
|
|
71
|
+
const index = hashToken(token) % dimensions;
|
|
72
|
+
const weight = 1 + Math.min(4, Math.floor(token.length / 4));
|
|
73
|
+
vector[index] += weight;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (options.normalize) {
|
|
77
|
+
normalizeVector(vector);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return { data: vector };
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
31
84
|
/**
|
|
32
85
|
* Create test fixtures with initialized components
|
|
33
86
|
* @param {Object} options - Options for fixture creation
|
|
@@ -35,20 +88,24 @@ export async function getEmbedder(config) {
|
|
|
35
88
|
*/
|
|
36
89
|
export async function createTestFixtures(options = {}) {
|
|
37
90
|
const config = await loadConfig();
|
|
38
|
-
|
|
91
|
+
|
|
39
92
|
// Override config for testing if needed
|
|
40
93
|
if (options.verbose !== undefined) config.verbose = options.verbose;
|
|
41
94
|
if (options.workerThreads !== undefined) config.workerThreads = options.workerThreads;
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
95
|
+
if (isVitest()) config.workerThreads = 1;
|
|
96
|
+
|
|
97
|
+
const useRealEmbedder = options.useRealEmbedder === true;
|
|
98
|
+
const embedder = useRealEmbedder
|
|
99
|
+
? await getEmbedder(config)
|
|
100
|
+
: createMockEmbedder({ dimensions: options.embeddingDimensions });
|
|
101
|
+
|
|
45
102
|
const cache = new EmbeddingsCache(config);
|
|
46
103
|
await cache.load();
|
|
47
|
-
|
|
104
|
+
|
|
48
105
|
const indexer = new CodebaseIndexer(embedder, cache, config, null);
|
|
49
106
|
const cacheClearer = new CacheClearer(embedder, cache, config, indexer);
|
|
50
107
|
const hybridSearch = new HybridSearch(embedder, cache, config);
|
|
51
|
-
|
|
108
|
+
|
|
52
109
|
return {
|
|
53
110
|
config,
|
|
54
111
|
embedder,
|
|
@@ -23,7 +23,7 @@ describe('HybridSearch', () => {
|
|
|
23
23
|
let fixtures;
|
|
24
24
|
|
|
25
25
|
beforeAll(async () => {
|
|
26
|
-
fixtures = await createTestFixtures({ workerThreads:
|
|
26
|
+
fixtures = await createTestFixtures({ workerThreads: 1 });
|
|
27
27
|
|
|
28
28
|
// Ensure we have indexed content
|
|
29
29
|
await clearTestCache(fixtures.config);
|
|
@@ -164,7 +164,7 @@ describe('Hybrid Search Tool Handler', () => {
|
|
|
164
164
|
let fixtures;
|
|
165
165
|
|
|
166
166
|
beforeAll(async () => {
|
|
167
|
-
fixtures = await createTestFixtures({ workerThreads:
|
|
167
|
+
fixtures = await createTestFixtures({ workerThreads: 1 });
|
|
168
168
|
|
|
169
169
|
// Ensure indexed content
|
|
170
170
|
await fixtures.indexer.indexAll(false);
|
|
@@ -24,7 +24,7 @@ describe('CodebaseIndexer', () => {
|
|
|
24
24
|
let fixtures;
|
|
25
25
|
|
|
26
26
|
beforeAll(async () => {
|
|
27
|
-
fixtures = await createTestFixtures({ workerThreads:
|
|
27
|
+
fixtures = await createTestFixtures({ workerThreads: 1 });
|
|
28
28
|
});
|
|
29
29
|
|
|
30
30
|
afterAll(async () => {
|
|
@@ -91,9 +91,6 @@ describe('CodebaseIndexer', () => {
|
|
|
91
91
|
|
|
92
92
|
// Start first indexing
|
|
93
93
|
const promise1 = fixtures.indexer.indexAll(true);
|
|
94
|
-
|
|
95
|
-
// Wait for it to start
|
|
96
|
-
await new Promise(resolve => setTimeout(resolve, 100));
|
|
97
94
|
expect(fixtures.indexer.isIndexing).toBe(true);
|
|
98
95
|
|
|
99
96
|
// Second call should be skipped
|
|
@@ -114,9 +111,6 @@ describe('CodebaseIndexer', () => {
|
|
|
114
111
|
expect(fixtures.indexer.isIndexing).toBe(false);
|
|
115
112
|
|
|
116
113
|
const promise = fixtures.indexer.indexAll(true);
|
|
117
|
-
|
|
118
|
-
// Should be set during indexing
|
|
119
|
-
await new Promise(resolve => setTimeout(resolve, 100));
|
|
120
114
|
expect(fixtures.indexer.isIndexing).toBe(true);
|
|
121
115
|
|
|
122
116
|
await promise;
|
|
@@ -170,7 +164,7 @@ describe('Index Codebase Tool Handler', () => {
|
|
|
170
164
|
let fixtures;
|
|
171
165
|
|
|
172
166
|
beforeAll(async () => {
|
|
173
|
-
fixtures = await createTestFixtures({ workerThreads:
|
|
167
|
+
fixtures = await createTestFixtures({ workerThreads: 1 });
|
|
174
168
|
});
|
|
175
169
|
|
|
176
170
|
afterAll(async () => {
|
|
@@ -212,8 +206,7 @@ describe('Index Codebase Tool Handler', () => {
|
|
|
212
206
|
createMockRequest('b_index_codebase', { force: true }),
|
|
213
207
|
fixtures.indexer
|
|
214
208
|
);
|
|
215
|
-
|
|
216
|
-
await new Promise(resolve => setTimeout(resolve, 100));
|
|
209
|
+
expect(fixtures.indexer.isIndexing).toBe(true);
|
|
217
210
|
|
|
218
211
|
// Second concurrent call
|
|
219
212
|
const result2 = await IndexCodebaseFeature.handleToolCall(
|
package/test/integration.test.js
CHANGED
|
@@ -22,7 +22,7 @@ describe('Concurrent Indexing', () => {
|
|
|
22
22
|
let fixtures;
|
|
23
23
|
|
|
24
24
|
beforeAll(async () => {
|
|
25
|
-
fixtures = await createTestFixtures({ workerThreads:
|
|
25
|
+
fixtures = await createTestFixtures({ workerThreads: 1, useRealEmbedder: true });
|
|
26
26
|
});
|
|
27
27
|
|
|
28
28
|
afterAll(async () => {
|
|
@@ -110,7 +110,7 @@ describe('Clear Cache Operations', () => {
|
|
|
110
110
|
let fixtures;
|
|
111
111
|
|
|
112
112
|
beforeAll(async () => {
|
|
113
|
-
fixtures = await createTestFixtures({ workerThreads:
|
|
113
|
+
fixtures = await createTestFixtures({ workerThreads: 1, useRealEmbedder: true });
|
|
114
114
|
});
|
|
115
115
|
|
|
116
116
|
afterAll(async () => {
|
|
@@ -197,7 +197,7 @@ describe('Tool Handler Response Quality', () => {
|
|
|
197
197
|
let fixtures;
|
|
198
198
|
|
|
199
199
|
beforeAll(async () => {
|
|
200
|
-
fixtures = await createTestFixtures({ workerThreads:
|
|
200
|
+
fixtures = await createTestFixtures({ workerThreads: 1, useRealEmbedder: true });
|
|
201
201
|
});
|
|
202
202
|
|
|
203
203
|
afterAll(async () => {
|