gitnexus 1.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/README.md +181 -0
- package/dist/cli/ai-context.d.ts +21 -0
- package/dist/cli/ai-context.js +219 -0
- package/dist/cli/analyze.d.ts +10 -0
- package/dist/cli/analyze.js +118 -0
- package/dist/cli/clean.d.ts +8 -0
- package/dist/cli/clean.js +29 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +42 -0
- package/dist/cli/list.d.ts +6 -0
- package/dist/cli/list.js +27 -0
- package/dist/cli/mcp.d.ts +7 -0
- package/dist/cli/mcp.js +85 -0
- package/dist/cli/serve.d.ts +3 -0
- package/dist/cli/serve.js +5 -0
- package/dist/cli/status.d.ts +6 -0
- package/dist/cli/status.js +27 -0
- package/dist/config/ignore-service.d.ts +1 -0
- package/dist/config/ignore-service.js +208 -0
- package/dist/config/supported-languages.d.ts +11 -0
- package/dist/config/supported-languages.js +15 -0
- package/dist/core/embeddings/embedder.d.ts +60 -0
- package/dist/core/embeddings/embedder.js +205 -0
- package/dist/core/embeddings/embedding-pipeline.d.ts +50 -0
- package/dist/core/embeddings/embedding-pipeline.js +321 -0
- package/dist/core/embeddings/index.d.ts +9 -0
- package/dist/core/embeddings/index.js +9 -0
- package/dist/core/embeddings/text-generator.d.ts +24 -0
- package/dist/core/embeddings/text-generator.js +182 -0
- package/dist/core/embeddings/types.d.ts +87 -0
- package/dist/core/embeddings/types.js +32 -0
- package/dist/core/graph/graph.d.ts +2 -0
- package/dist/core/graph/graph.js +61 -0
- package/dist/core/graph/types.d.ts +50 -0
- package/dist/core/graph/types.js +1 -0
- package/dist/core/ingestion/ast-cache.d.ts +11 -0
- package/dist/core/ingestion/ast-cache.js +34 -0
- package/dist/core/ingestion/call-processor.d.ts +8 -0
- package/dist/core/ingestion/call-processor.js +269 -0
- package/dist/core/ingestion/cluster-enricher.d.ts +38 -0
- package/dist/core/ingestion/cluster-enricher.js +170 -0
- package/dist/core/ingestion/community-processor.d.ts +39 -0
- package/dist/core/ingestion/community-processor.js +269 -0
- package/dist/core/ingestion/entry-point-scoring.d.ts +39 -0
- package/dist/core/ingestion/entry-point-scoring.js +235 -0
- package/dist/core/ingestion/filesystem-walker.d.ts +5 -0
- package/dist/core/ingestion/filesystem-walker.js +26 -0
- package/dist/core/ingestion/framework-detection.d.ts +38 -0
- package/dist/core/ingestion/framework-detection.js +183 -0
- package/dist/core/ingestion/heritage-processor.d.ts +14 -0
- package/dist/core/ingestion/heritage-processor.js +134 -0
- package/dist/core/ingestion/import-processor.d.ts +8 -0
- package/dist/core/ingestion/import-processor.js +490 -0
- package/dist/core/ingestion/parsing-processor.d.ts +8 -0
- package/dist/core/ingestion/parsing-processor.js +249 -0
- package/dist/core/ingestion/pipeline.d.ts +2 -0
- package/dist/core/ingestion/pipeline.js +228 -0
- package/dist/core/ingestion/process-processor.d.ts +51 -0
- package/dist/core/ingestion/process-processor.js +278 -0
- package/dist/core/ingestion/structure-processor.d.ts +2 -0
- package/dist/core/ingestion/structure-processor.js +36 -0
- package/dist/core/ingestion/symbol-table.d.ts +33 -0
- package/dist/core/ingestion/symbol-table.js +38 -0
- package/dist/core/ingestion/tree-sitter-queries.d.ts +11 -0
- package/dist/core/ingestion/tree-sitter-queries.js +319 -0
- package/dist/core/ingestion/utils.d.ts +10 -0
- package/dist/core/ingestion/utils.js +44 -0
- package/dist/core/kuzu/csv-generator.d.ts +22 -0
- package/dist/core/kuzu/csv-generator.js +272 -0
- package/dist/core/kuzu/kuzu-adapter.d.ts +81 -0
- package/dist/core/kuzu/kuzu-adapter.js +568 -0
- package/dist/core/kuzu/schema.d.ts +53 -0
- package/dist/core/kuzu/schema.js +380 -0
- package/dist/core/search/bm25-index.d.ts +22 -0
- package/dist/core/search/bm25-index.js +52 -0
- package/dist/core/search/hybrid-search.d.ts +49 -0
- package/dist/core/search/hybrid-search.js +118 -0
- package/dist/core/tree-sitter/parser-loader.d.ts +4 -0
- package/dist/core/tree-sitter/parser-loader.js +42 -0
- package/dist/lib/utils.d.ts +1 -0
- package/dist/lib/utils.js +3 -0
- package/dist/mcp/core/embedder.d.ts +27 -0
- package/dist/mcp/core/embedder.js +93 -0
- package/dist/mcp/core/kuzu-adapter.d.ts +23 -0
- package/dist/mcp/core/kuzu-adapter.js +62 -0
- package/dist/mcp/local/local-backend.d.ts +73 -0
- package/dist/mcp/local/local-backend.js +752 -0
- package/dist/mcp/resources.d.ts +31 -0
- package/dist/mcp/resources.js +279 -0
- package/dist/mcp/server.d.ts +12 -0
- package/dist/mcp/server.js +130 -0
- package/dist/mcp/staleness.d.ts +15 -0
- package/dist/mcp/staleness.js +29 -0
- package/dist/mcp/tools.d.ts +24 -0
- package/dist/mcp/tools.js +160 -0
- package/dist/server/api.d.ts +6 -0
- package/dist/server/api.js +156 -0
- package/dist/storage/git.d.ts +7 -0
- package/dist/storage/git.js +39 -0
- package/dist/storage/repo-manager.d.ts +61 -0
- package/dist/storage/repo-manager.js +106 -0
- package/dist/types/pipeline.d.ts +28 -0
- package/dist/types/pipeline.js +16 -0
- package/package.json +80 -0
- package/skills/debugging.md +104 -0
- package/skills/exploring.md +112 -0
- package/skills/impact-analysis.md +114 -0
- package/skills/refactoring.md +119 -0
- package/vendor/leiden/index.cjs +355 -0
- package/vendor/leiden/utils.cjs +392 -0
|
@@ -0,0 +1,752 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local Backend
|
|
3
|
+
*
|
|
4
|
+
* Provides tool implementations using local .gitnexus/ index.
|
|
5
|
+
* This enables MCP to work without the browser.
|
|
6
|
+
*/
|
|
7
|
+
import fs from 'fs/promises';
|
|
8
|
+
import path from 'path';
|
|
9
|
+
import { initKuzu, executeQuery, closeKuzu, isKuzuReady } from '../core/kuzu-adapter.js';
|
|
10
|
+
import { embedQuery, getEmbeddingDims, disposeEmbedder } from '../core/embedder.js';
|
|
11
|
+
import { isGitRepo, getCurrentCommit, getGitRoot } from '../../storage/git.js';
|
|
12
|
+
import { getStoragePaths as getRepoStoragePaths, saveMeta as saveRepoMeta, loadMeta as loadRepoMeta, addToGitignore, } from '../../storage/repo-manager.js';
|
|
13
|
+
import { generateAIContextFiles } from '../../cli/ai-context.js';
|
|
14
|
+
/**
|
|
15
|
+
* Quick test-file detection for filtering impact results.
|
|
16
|
+
* Matches common test file patterns across all supported languages.
|
|
17
|
+
*/
|
|
18
|
+
function isTestFilePath(filePath) {
|
|
19
|
+
const p = filePath.toLowerCase().replace(/\\/g, '/');
|
|
20
|
+
return (p.includes('.test.') || p.includes('.spec.') ||
|
|
21
|
+
p.includes('__tests__/') || p.includes('__mocks__/') ||
|
|
22
|
+
p.includes('/test/') || p.includes('/tests/') ||
|
|
23
|
+
p.includes('/testing/') || p.includes('/fixtures/') ||
|
|
24
|
+
p.endsWith('_test.go') || p.endsWith('_test.py') ||
|
|
25
|
+
p.includes('/test_') || p.includes('/conftest.'));
|
|
26
|
+
}
|
|
27
|
+
const GITNEXUS_DIR = '.gitnexus';
|
|
28
|
+
function getStoragePaths(repoPath) {
|
|
29
|
+
const storagePath = path.join(path.resolve(repoPath), GITNEXUS_DIR);
|
|
30
|
+
return {
|
|
31
|
+
storagePath,
|
|
32
|
+
kuzuPath: path.join(storagePath, 'kuzu'),
|
|
33
|
+
metaPath: path.join(storagePath, 'meta.json'),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
async function loadMeta(storagePath) {
|
|
37
|
+
try {
|
|
38
|
+
// Verify both meta.json and kuzu exist for a valid index
|
|
39
|
+
const metaPath = path.join(storagePath, 'meta.json');
|
|
40
|
+
const kuzuPath = path.join(storagePath, 'kuzu');
|
|
41
|
+
// Check kuzu exists (can be file or directory depending on how it was saved)
|
|
42
|
+
try {
|
|
43
|
+
await fs.stat(kuzuPath);
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return null; // kuzu doesn't exist
|
|
47
|
+
}
|
|
48
|
+
// Load and parse meta.json
|
|
49
|
+
const raw = await fs.readFile(metaPath, 'utf-8');
|
|
50
|
+
return JSON.parse(raw);
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
async function loadRepo(repoPath) {
|
|
57
|
+
const paths = getStoragePaths(repoPath);
|
|
58
|
+
const meta = await loadMeta(paths.storagePath);
|
|
59
|
+
if (!meta)
|
|
60
|
+
return null;
|
|
61
|
+
return {
|
|
62
|
+
repoPath: path.resolve(repoPath),
|
|
63
|
+
...paths,
|
|
64
|
+
meta,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
export async function findRepo(startPath) {
|
|
68
|
+
let current = path.resolve(startPath);
|
|
69
|
+
const root = path.parse(current).root;
|
|
70
|
+
while (current !== root) {
|
|
71
|
+
const repo = await loadRepo(current);
|
|
72
|
+
if (repo)
|
|
73
|
+
return repo;
|
|
74
|
+
current = path.dirname(current);
|
|
75
|
+
}
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
export class LocalBackend {
|
|
79
|
+
repo = null;
|
|
80
|
+
_context = null;
|
|
81
|
+
initialized = false;
|
|
82
|
+
async init(cwd) {
|
|
83
|
+
this.repo = await findRepo(cwd);
|
|
84
|
+
if (!this.repo)
|
|
85
|
+
return false;
|
|
86
|
+
const stats = this.repo.meta.stats || {};
|
|
87
|
+
this._context = {
|
|
88
|
+
projectName: path.basename(this.repo.repoPath),
|
|
89
|
+
stats: {
|
|
90
|
+
fileCount: stats.files || 0,
|
|
91
|
+
functionCount: stats.nodes || 0,
|
|
92
|
+
classCount: 0,
|
|
93
|
+
interfaceCount: 0,
|
|
94
|
+
methodCount: 0,
|
|
95
|
+
communityCount: stats.communities || 0,
|
|
96
|
+
processCount: stats.processes || 0,
|
|
97
|
+
},
|
|
98
|
+
hotspots: [],
|
|
99
|
+
folderTree: '',
|
|
100
|
+
};
|
|
101
|
+
return true;
|
|
102
|
+
}
|
|
103
|
+
async ensureInitialized() {
|
|
104
|
+
if (this.initialized || !this.repo)
|
|
105
|
+
return;
|
|
106
|
+
await initKuzu(this.repo.kuzuPath);
|
|
107
|
+
this.initialized = true;
|
|
108
|
+
}
|
|
109
|
+
get context() {
|
|
110
|
+
return this._context;
|
|
111
|
+
}
|
|
112
|
+
get isReady() {
|
|
113
|
+
return this.repo !== null;
|
|
114
|
+
}
|
|
115
|
+
get repoPath() {
|
|
116
|
+
return this.repo?.repoPath || null;
|
|
117
|
+
}
|
|
118
|
+
get storagePath() {
|
|
119
|
+
return this.repo?.storagePath || null;
|
|
120
|
+
}
|
|
121
|
+
get meta() {
|
|
122
|
+
return this.repo?.meta || null;
|
|
123
|
+
}
|
|
124
|
+
async callTool(method, params) {
|
|
125
|
+
if (!this.repo) {
|
|
126
|
+
throw new Error('Repository not indexed. Run: gitnexus analyze');
|
|
127
|
+
}
|
|
128
|
+
switch (method) {
|
|
129
|
+
case 'search':
|
|
130
|
+
return this.search(params);
|
|
131
|
+
case 'cypher':
|
|
132
|
+
return this.cypher(params);
|
|
133
|
+
case 'overview':
|
|
134
|
+
return this.overview(params);
|
|
135
|
+
case 'explore':
|
|
136
|
+
return this.explore(params);
|
|
137
|
+
case 'impact':
|
|
138
|
+
return this.impact(params);
|
|
139
|
+
case 'analyze':
|
|
140
|
+
return this.analyze(params);
|
|
141
|
+
default:
|
|
142
|
+
throw new Error(`Unknown tool: ${method}`);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
async search(params) {
|
|
146
|
+
await this.ensureInitialized();
|
|
147
|
+
const limit = params.limit || 10;
|
|
148
|
+
const query = params.query;
|
|
149
|
+
const depth = params.depth || 'definitions';
|
|
150
|
+
// Run BM25 and semantic search in parallel
|
|
151
|
+
const [bm25Results, semanticResults] = await Promise.all([
|
|
152
|
+
this.bm25Search(query, limit * 2),
|
|
153
|
+
this.semanticSearch(query, limit * 2),
|
|
154
|
+
]);
|
|
155
|
+
// Merge and deduplicate results using reciprocal rank fusion
|
|
156
|
+
const scoreMap = new Map();
|
|
157
|
+
// BM25 results
|
|
158
|
+
for (let i = 0; i < bm25Results.length; i++) {
|
|
159
|
+
const result = bm25Results[i];
|
|
160
|
+
const key = result.filePath;
|
|
161
|
+
const rrfScore = 1 / (60 + i); // RRF formula with k=60
|
|
162
|
+
const existing = scoreMap.get(key);
|
|
163
|
+
if (existing) {
|
|
164
|
+
existing.score += rrfScore;
|
|
165
|
+
existing.source = 'hybrid';
|
|
166
|
+
}
|
|
167
|
+
else {
|
|
168
|
+
scoreMap.set(key, { score: rrfScore, source: 'bm25', data: result });
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
// Semantic results
|
|
172
|
+
for (let i = 0; i < semanticResults.length; i++) {
|
|
173
|
+
const result = semanticResults[i];
|
|
174
|
+
const key = result.filePath;
|
|
175
|
+
const rrfScore = 1 / (60 + i);
|
|
176
|
+
const existing = scoreMap.get(key);
|
|
177
|
+
if (existing) {
|
|
178
|
+
existing.score += rrfScore;
|
|
179
|
+
existing.source = 'hybrid';
|
|
180
|
+
}
|
|
181
|
+
else {
|
|
182
|
+
scoreMap.set(key, { score: rrfScore, source: 'semantic', data: result });
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
// Sort by fused score and take top results
|
|
186
|
+
const merged = Array.from(scoreMap.entries())
|
|
187
|
+
.sort((a, b) => b[1].score - a[1].score)
|
|
188
|
+
.slice(0, limit);
|
|
189
|
+
// Enrich with graph data
|
|
190
|
+
const results = [];
|
|
191
|
+
for (const [_, item] of merged) {
|
|
192
|
+
const result = item.data;
|
|
193
|
+
result.searchSource = item.source;
|
|
194
|
+
result.fusedScore = item.score;
|
|
195
|
+
// Add cluster membership context for each result with a nodeId
|
|
196
|
+
if (result.nodeId) {
|
|
197
|
+
try {
|
|
198
|
+
const clusterQuery = `
|
|
199
|
+
MATCH (n {id: '${result.nodeId.replace(/'/g, "''")}'})-[:CodeRelation {type: 'MEMBER_OF'}]->(c:Community)
|
|
200
|
+
RETURN c.label AS label, c.heuristicLabel AS heuristicLabel
|
|
201
|
+
LIMIT 1
|
|
202
|
+
`;
|
|
203
|
+
const clusters = await executeQuery(clusterQuery);
|
|
204
|
+
if (clusters.length > 0) {
|
|
205
|
+
result.cluster = {
|
|
206
|
+
label: clusters[0].label || clusters[0][0],
|
|
207
|
+
heuristicLabel: clusters[0].heuristicLabel || clusters[0][1],
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
catch {
|
|
212
|
+
// Cluster lookup failed - continue without it
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
// Add relationships if depth is 'full' and we have a node ID
|
|
216
|
+
if (depth === 'full' && result.nodeId) {
|
|
217
|
+
try {
|
|
218
|
+
const relQuery = `
|
|
219
|
+
MATCH (n {id: '${result.nodeId.replace(/'/g, "''")}'})-[r:CodeRelation]->(m)
|
|
220
|
+
RETURN r.type AS type, m.name AS targetName, m.filePath AS targetPath
|
|
221
|
+
LIMIT 5
|
|
222
|
+
`;
|
|
223
|
+
const rels = await executeQuery(relQuery);
|
|
224
|
+
result.connections = rels.map((rel) => ({
|
|
225
|
+
type: rel.type || rel[0],
|
|
226
|
+
name: rel.targetName || rel[1],
|
|
227
|
+
path: rel.targetPath || rel[2],
|
|
228
|
+
}));
|
|
229
|
+
}
|
|
230
|
+
catch {
|
|
231
|
+
result.connections = [];
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
results.push(result);
|
|
235
|
+
}
|
|
236
|
+
return results;
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* BM25 keyword search helper - uses KuzuDB FTS for always-fresh results
|
|
240
|
+
*/
|
|
241
|
+
async bm25Search(query, limit) {
|
|
242
|
+
// Import dynamically to avoid circular dependency
|
|
243
|
+
const { searchFTSFromKuzu } = await import('../../core/search/bm25-index.js');
|
|
244
|
+
const bm25Results = await searchFTSFromKuzu(query, limit);
|
|
245
|
+
const results = [];
|
|
246
|
+
for (const bm25Result of bm25Results) {
|
|
247
|
+
const fileName = bm25Result.filePath.split('/').pop() || bm25Result.filePath;
|
|
248
|
+
try {
|
|
249
|
+
const symbolQuery = `
|
|
250
|
+
MATCH (n)
|
|
251
|
+
WHERE n.filePath CONTAINS '${fileName.replace(/'/g, "''")}'
|
|
252
|
+
RETURN n.id AS id, n.name AS name, labels(n)[0] AS type, n.filePath AS filePath, n.startLine AS startLine, n.endLine AS endLine
|
|
253
|
+
LIMIT 3
|
|
254
|
+
`;
|
|
255
|
+
const symbols = await executeQuery(symbolQuery);
|
|
256
|
+
if (symbols.length > 0) {
|
|
257
|
+
for (const sym of symbols) {
|
|
258
|
+
results.push({
|
|
259
|
+
nodeId: sym.id || sym[0],
|
|
260
|
+
name: sym.name || sym[1],
|
|
261
|
+
type: sym.type || sym[2],
|
|
262
|
+
filePath: sym.filePath || sym[3],
|
|
263
|
+
startLine: sym.startLine || sym[4],
|
|
264
|
+
endLine: sym.endLine || sym[5],
|
|
265
|
+
bm25Score: bm25Result.score,
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
else {
|
|
270
|
+
results.push({
|
|
271
|
+
name: fileName,
|
|
272
|
+
type: 'File',
|
|
273
|
+
filePath: bm25Result.filePath,
|
|
274
|
+
bm25Score: bm25Result.score,
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
catch {
|
|
279
|
+
results.push({
|
|
280
|
+
name: fileName,
|
|
281
|
+
type: 'File',
|
|
282
|
+
filePath: bm25Result.filePath,
|
|
283
|
+
bm25Score: bm25Result.score,
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
return results;
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
290
|
+
* Semantic vector search helper
|
|
291
|
+
*/
|
|
292
|
+
async semanticSearch(query, limit) {
|
|
293
|
+
try {
|
|
294
|
+
// Embed the query
|
|
295
|
+
const queryVec = await embedQuery(query);
|
|
296
|
+
const dims = getEmbeddingDims();
|
|
297
|
+
const queryVecStr = `[${queryVec.join(',')}]`;
|
|
298
|
+
// Query vector index
|
|
299
|
+
const vectorQuery = `
|
|
300
|
+
CALL QUERY_VECTOR_INDEX('CodeEmbedding', 'code_embedding_idx',
|
|
301
|
+
CAST(${queryVecStr} AS FLOAT[${dims}]), ${limit})
|
|
302
|
+
YIELD node AS emb, distance
|
|
303
|
+
WITH emb, distance
|
|
304
|
+
WHERE distance < 0.6
|
|
305
|
+
RETURN emb.nodeId AS nodeId, distance
|
|
306
|
+
ORDER BY distance
|
|
307
|
+
`;
|
|
308
|
+
const embResults = await executeQuery(vectorQuery);
|
|
309
|
+
if (embResults.length === 0)
|
|
310
|
+
return [];
|
|
311
|
+
// Get metadata for each result
|
|
312
|
+
const results = [];
|
|
313
|
+
for (const embRow of embResults) {
|
|
314
|
+
const nodeId = embRow.nodeId ?? embRow[0];
|
|
315
|
+
const distance = embRow.distance ?? embRow[1];
|
|
316
|
+
// Extract label from node ID
|
|
317
|
+
const labelEndIdx = nodeId.indexOf(':');
|
|
318
|
+
const label = labelEndIdx > 0 ? nodeId.substring(0, labelEndIdx) : 'Unknown';
|
|
319
|
+
try {
|
|
320
|
+
const nodeQuery = label === 'File'
|
|
321
|
+
? `MATCH (n:File {id: '${nodeId.replace(/'/g, "''")}'}) RETURN n.name AS name, n.filePath AS filePath`
|
|
322
|
+
: `MATCH (n:${label} {id: '${nodeId.replace(/'/g, "''")}'}) RETURN n.name AS name, n.filePath AS filePath, n.startLine AS startLine, n.endLine AS endLine`;
|
|
323
|
+
const nodeRows = await executeQuery(nodeQuery);
|
|
324
|
+
if (nodeRows.length > 0) {
|
|
325
|
+
const nodeRow = nodeRows[0];
|
|
326
|
+
results.push({
|
|
327
|
+
nodeId,
|
|
328
|
+
name: nodeRow.name ?? nodeRow[0] ?? '',
|
|
329
|
+
type: label,
|
|
330
|
+
filePath: nodeRow.filePath ?? nodeRow[1] ?? '',
|
|
331
|
+
distance,
|
|
332
|
+
startLine: label !== 'File' ? (nodeRow.startLine ?? nodeRow[2]) : undefined,
|
|
333
|
+
endLine: label !== 'File' ? (nodeRow.endLine ?? nodeRow[3]) : undefined,
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
catch { }
|
|
338
|
+
}
|
|
339
|
+
return results;
|
|
340
|
+
}
|
|
341
|
+
catch (err) {
|
|
342
|
+
// Semantic search unavailable (no embeddings or model not loaded)
|
|
343
|
+
console.error('GitNexus: Semantic search unavailable -', err.message);
|
|
344
|
+
return [];
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
async cypher(params) {
|
|
348
|
+
await this.ensureInitialized();
|
|
349
|
+
if (!isKuzuReady()) {
|
|
350
|
+
return { error: 'KuzuDB not ready. Index may be corrupted.' };
|
|
351
|
+
}
|
|
352
|
+
try {
|
|
353
|
+
const result = await executeQuery(params.query);
|
|
354
|
+
return result;
|
|
355
|
+
}
|
|
356
|
+
catch (err) {
|
|
357
|
+
return { error: err.message || 'Query failed' };
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
async overview(params) {
|
|
361
|
+
await this.ensureInitialized();
|
|
362
|
+
const limit = params.limit || 20;
|
|
363
|
+
const result = {
|
|
364
|
+
repoPath: this.repo.repoPath,
|
|
365
|
+
stats: this.repo.meta.stats,
|
|
366
|
+
indexedAt: this.repo.meta.indexedAt,
|
|
367
|
+
lastCommit: this.repo.meta.lastCommit,
|
|
368
|
+
};
|
|
369
|
+
if (params.showClusters !== false) {
|
|
370
|
+
try {
|
|
371
|
+
const clusters = await executeQuery(`
|
|
372
|
+
MATCH (c:Community)
|
|
373
|
+
RETURN c.id AS id, c.label AS label, c.heuristicLabel AS heuristicLabel, c.cohesion AS cohesion, c.symbolCount AS symbolCount
|
|
374
|
+
ORDER BY c.symbolCount DESC
|
|
375
|
+
LIMIT ${limit}
|
|
376
|
+
`);
|
|
377
|
+
result.clusters = clusters.map((c) => ({
|
|
378
|
+
id: c.id || c[0],
|
|
379
|
+
label: c.label || c[1],
|
|
380
|
+
heuristicLabel: c.heuristicLabel || c[2],
|
|
381
|
+
cohesion: c.cohesion || c[3],
|
|
382
|
+
symbolCount: c.symbolCount || c[4],
|
|
383
|
+
}));
|
|
384
|
+
}
|
|
385
|
+
catch {
|
|
386
|
+
result.clusters = [];
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
if (params.showProcesses !== false) {
|
|
390
|
+
try {
|
|
391
|
+
const processes = await executeQuery(`
|
|
392
|
+
MATCH (p:Process)
|
|
393
|
+
RETURN p.id AS id, p.label AS label, p.heuristicLabel AS heuristicLabel, p.processType AS processType, p.stepCount AS stepCount
|
|
394
|
+
ORDER BY p.stepCount DESC
|
|
395
|
+
LIMIT ${limit}
|
|
396
|
+
`);
|
|
397
|
+
result.processes = processes.map((p) => ({
|
|
398
|
+
id: p.id || p[0],
|
|
399
|
+
label: p.label || p[1],
|
|
400
|
+
heuristicLabel: p.heuristicLabel || p[2],
|
|
401
|
+
processType: p.processType || p[3],
|
|
402
|
+
stepCount: p.stepCount || p[4],
|
|
403
|
+
}));
|
|
404
|
+
}
|
|
405
|
+
catch {
|
|
406
|
+
result.processes = [];
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
return result;
|
|
410
|
+
}
|
|
411
|
+
async explore(params) {
|
|
412
|
+
await this.ensureInitialized();
|
|
413
|
+
const { name, type } = params;
|
|
414
|
+
if (type === 'symbol') {
|
|
415
|
+
// Find symbol and its context
|
|
416
|
+
const symbolQuery = `
|
|
417
|
+
MATCH (n)
|
|
418
|
+
WHERE n.name = '${name.replace(/'/g, "''")}'
|
|
419
|
+
RETURN n.id AS id, n.name AS name, labels(n)[0] AS type, n.filePath AS filePath, n.startLine AS startLine, n.endLine AS endLine
|
|
420
|
+
LIMIT 1
|
|
421
|
+
`;
|
|
422
|
+
const symbols = await executeQuery(symbolQuery);
|
|
423
|
+
if (symbols.length === 0)
|
|
424
|
+
return { error: `Symbol '${name}' not found` };
|
|
425
|
+
const sym = symbols[0];
|
|
426
|
+
const symId = sym.id || sym[0];
|
|
427
|
+
// Get callers
|
|
428
|
+
const callersQuery = `
|
|
429
|
+
MATCH (caller)-[:CodeRelation {type: 'CALLS'}]->(n {id: '${symId}'})
|
|
430
|
+
RETURN caller.name AS name, caller.filePath AS filePath
|
|
431
|
+
LIMIT 10
|
|
432
|
+
`;
|
|
433
|
+
const callers = await executeQuery(callersQuery);
|
|
434
|
+
// Get callees
|
|
435
|
+
const calleesQuery = `
|
|
436
|
+
MATCH (n {id: '${symId}'})-[:CodeRelation {type: 'CALLS'}]->(callee)
|
|
437
|
+
RETURN callee.name AS name, callee.filePath AS filePath
|
|
438
|
+
LIMIT 10
|
|
439
|
+
`;
|
|
440
|
+
const callees = await executeQuery(calleesQuery);
|
|
441
|
+
// Get community
|
|
442
|
+
const communityQuery = `
|
|
443
|
+
MATCH (n {id: '${symId}'})-[:CodeRelation {type: 'MEMBER_OF'}]->(c:Community)
|
|
444
|
+
RETURN c.label AS label, c.heuristicLabel AS heuristicLabel
|
|
445
|
+
LIMIT 1
|
|
446
|
+
`;
|
|
447
|
+
const communities = await executeQuery(communityQuery);
|
|
448
|
+
return {
|
|
449
|
+
symbol: {
|
|
450
|
+
id: symId,
|
|
451
|
+
name: sym.name || sym[1],
|
|
452
|
+
type: sym.type || sym[2],
|
|
453
|
+
filePath: sym.filePath || sym[3],
|
|
454
|
+
startLine: sym.startLine || sym[4],
|
|
455
|
+
endLine: sym.endLine || sym[5],
|
|
456
|
+
},
|
|
457
|
+
callers: callers.map((c) => ({ name: c.name || c[0], filePath: c.filePath || c[1] })),
|
|
458
|
+
callees: callees.map((c) => ({ name: c.name || c[0], filePath: c.filePath || c[1] })),
|
|
459
|
+
community: communities.length > 0 ? {
|
|
460
|
+
label: communities[0].label || communities[0][0],
|
|
461
|
+
heuristicLabel: communities[0].heuristicLabel || communities[0][1],
|
|
462
|
+
} : null,
|
|
463
|
+
};
|
|
464
|
+
}
|
|
465
|
+
if (type === 'cluster') {
|
|
466
|
+
const clusterQuery = `
|
|
467
|
+
MATCH (c:Community)
|
|
468
|
+
WHERE c.label = '${name.replace(/'/g, "''")}' OR c.heuristicLabel = '${name.replace(/'/g, "''")}'
|
|
469
|
+
RETURN c.id AS id, c.label AS label, c.heuristicLabel AS heuristicLabel, c.cohesion AS cohesion, c.symbolCount AS symbolCount
|
|
470
|
+
LIMIT 1
|
|
471
|
+
`;
|
|
472
|
+
const clusters = await executeQuery(clusterQuery);
|
|
473
|
+
if (clusters.length === 0)
|
|
474
|
+
return { error: `Cluster '${name}' not found` };
|
|
475
|
+
const cluster = clusters[0];
|
|
476
|
+
const clusterId = cluster.id || cluster[0];
|
|
477
|
+
const membersQuery = `
|
|
478
|
+
MATCH (n)-[:CodeRelation {type: 'MEMBER_OF'}]->(c {id: '${clusterId}'})
|
|
479
|
+
RETURN n.name AS name, labels(n)[0] AS type, n.filePath AS filePath
|
|
480
|
+
LIMIT 20
|
|
481
|
+
`;
|
|
482
|
+
const members = await executeQuery(membersQuery);
|
|
483
|
+
return {
|
|
484
|
+
cluster: {
|
|
485
|
+
id: clusterId,
|
|
486
|
+
label: cluster.label || cluster[1],
|
|
487
|
+
heuristicLabel: cluster.heuristicLabel || cluster[2],
|
|
488
|
+
cohesion: cluster.cohesion || cluster[3],
|
|
489
|
+
symbolCount: cluster.symbolCount || cluster[4],
|
|
490
|
+
},
|
|
491
|
+
members: members.map((m) => ({
|
|
492
|
+
name: m.name || m[0],
|
|
493
|
+
type: m.type || m[1],
|
|
494
|
+
filePath: m.filePath || m[2],
|
|
495
|
+
})),
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
if (type === 'process') {
|
|
499
|
+
const processQuery = `
|
|
500
|
+
MATCH (p:Process)
|
|
501
|
+
WHERE p.label = '${name.replace(/'/g, "''")}' OR p.heuristicLabel = '${name.replace(/'/g, "''")}'
|
|
502
|
+
RETURN p.id AS id, p.label AS label, p.heuristicLabel AS heuristicLabel, p.processType AS processType, p.stepCount AS stepCount, p.entryPointId AS entryPointId, p.terminalId AS terminalId
|
|
503
|
+
LIMIT 1
|
|
504
|
+
`;
|
|
505
|
+
const processes = await executeQuery(processQuery);
|
|
506
|
+
if (processes.length === 0)
|
|
507
|
+
return { error: `Process '${name}' not found` };
|
|
508
|
+
const proc = processes[0];
|
|
509
|
+
const procId = proc.id || proc[0];
|
|
510
|
+
const stepsQuery = `
|
|
511
|
+
MATCH (n)-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p {id: '${procId}'})
|
|
512
|
+
RETURN n.name AS name, labels(n)[0] AS type, n.filePath AS filePath, r.step AS step
|
|
513
|
+
ORDER BY r.step
|
|
514
|
+
`;
|
|
515
|
+
const steps = await executeQuery(stepsQuery);
|
|
516
|
+
return {
|
|
517
|
+
process: {
|
|
518
|
+
id: procId,
|
|
519
|
+
label: proc.label || proc[1],
|
|
520
|
+
heuristicLabel: proc.heuristicLabel || proc[2],
|
|
521
|
+
processType: proc.processType || proc[3],
|
|
522
|
+
stepCount: proc.stepCount || proc[4],
|
|
523
|
+
},
|
|
524
|
+
steps: steps.map((s) => ({
|
|
525
|
+
step: s.step || s[3],
|
|
526
|
+
name: s.name || s[0],
|
|
527
|
+
type: s.type || s[1],
|
|
528
|
+
filePath: s.filePath || s[2],
|
|
529
|
+
})),
|
|
530
|
+
};
|
|
531
|
+
}
|
|
532
|
+
return { error: 'Invalid type. Use: symbol, cluster, or process' };
|
|
533
|
+
}
|
|
534
|
+
async impact(params) {
|
|
535
|
+
await this.ensureInitialized();
|
|
536
|
+
const { target, direction } = params;
|
|
537
|
+
const maxDepth = params.maxDepth || 3;
|
|
538
|
+
const relationTypes = params.relationTypes && params.relationTypes.length > 0
|
|
539
|
+
? params.relationTypes
|
|
540
|
+
: ['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS'];
|
|
541
|
+
const includeTests = params.includeTests ?? false;
|
|
542
|
+
const minConfidence = params.minConfidence ?? 0;
|
|
543
|
+
// Build the relation type filter for Cypher
|
|
544
|
+
const relTypeFilter = relationTypes.map(t => `'${t}'`).join(', ');
|
|
545
|
+
const confidenceFilter = minConfidence > 0 ? ` AND r.confidence >= ${minConfidence}` : '';
|
|
546
|
+
// Find target symbol
|
|
547
|
+
const targetQuery = `
|
|
548
|
+
MATCH (n)
|
|
549
|
+
WHERE n.name = '${target.replace(/'/g, "''")}'
|
|
550
|
+
RETURN n.id AS id, n.name AS name, labels(n)[0] AS type, n.filePath AS filePath
|
|
551
|
+
LIMIT 1
|
|
552
|
+
`;
|
|
553
|
+
const targets = await executeQuery(targetQuery);
|
|
554
|
+
if (targets.length === 0)
|
|
555
|
+
return { error: `Target '${target}' not found` };
|
|
556
|
+
const sym = targets[0];
|
|
557
|
+
const symId = sym.id || sym[0];
|
|
558
|
+
// BFS to find impacted nodes
|
|
559
|
+
const impacted = [];
|
|
560
|
+
const visited = new Set([symId]);
|
|
561
|
+
let frontier = [symId];
|
|
562
|
+
for (let depth = 1; depth <= maxDepth && frontier.length > 0; depth++) {
|
|
563
|
+
const nextFrontier = [];
|
|
564
|
+
for (const nodeId of frontier) {
|
|
565
|
+
const query = direction === 'upstream'
|
|
566
|
+
? `MATCH (caller)-[r:CodeRelation]->(n {id: '${nodeId}'}) WHERE r.type IN [${relTypeFilter}]${confidenceFilter} RETURN caller.id AS id, caller.name AS name, labels(caller)[0] AS type, caller.filePath AS filePath, r.type AS relType, r.confidence AS confidence`
|
|
567
|
+
: `MATCH (n {id: '${nodeId}'})-[r:CodeRelation]->(callee) WHERE r.type IN [${relTypeFilter}]${confidenceFilter} RETURN callee.id AS id, callee.name AS name, labels(callee)[0] AS type, callee.filePath AS filePath, r.type AS relType, r.confidence AS confidence`;
|
|
568
|
+
const related = await executeQuery(query);
|
|
569
|
+
for (const rel of related) {
|
|
570
|
+
const relId = rel.id || rel[0];
|
|
571
|
+
const filePath = rel.filePath || rel[3] || '';
|
|
572
|
+
// Skip test files unless explicitly included
|
|
573
|
+
if (!includeTests && isTestFilePath(filePath))
|
|
574
|
+
continue;
|
|
575
|
+
if (!visited.has(relId)) {
|
|
576
|
+
visited.add(relId);
|
|
577
|
+
nextFrontier.push(relId);
|
|
578
|
+
impacted.push({
|
|
579
|
+
depth,
|
|
580
|
+
id: relId,
|
|
581
|
+
name: rel.name || rel[1],
|
|
582
|
+
type: rel.type || rel[2],
|
|
583
|
+
filePath,
|
|
584
|
+
relationType: rel.relType || rel[4],
|
|
585
|
+
confidence: rel.confidence || rel[5] || 1.0,
|
|
586
|
+
});
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
frontier = nextFrontier;
|
|
591
|
+
}
|
|
592
|
+
// Group by depth
|
|
593
|
+
const grouped = {};
|
|
594
|
+
for (const item of impacted) {
|
|
595
|
+
if (!grouped[item.depth])
|
|
596
|
+
grouped[item.depth] = [];
|
|
597
|
+
grouped[item.depth].push(item);
|
|
598
|
+
}
|
|
599
|
+
return {
|
|
600
|
+
target: {
|
|
601
|
+
id: symId,
|
|
602
|
+
name: sym.name || sym[1],
|
|
603
|
+
type: sym.type || sym[2],
|
|
604
|
+
filePath: sym.filePath || sym[3],
|
|
605
|
+
},
|
|
606
|
+
direction,
|
|
607
|
+
impactedCount: impacted.length,
|
|
608
|
+
byDepth: grouped,
|
|
609
|
+
};
|
|
610
|
+
}
|
|
611
|
+
async analyze(params) {
|
|
612
|
+
// Determine target repo path
|
|
613
|
+
let repoPath;
|
|
614
|
+
if (params.path) {
|
|
615
|
+
repoPath = path.resolve(params.path);
|
|
616
|
+
}
|
|
617
|
+
else if (this.repo) {
|
|
618
|
+
repoPath = this.repo.repoPath;
|
|
619
|
+
}
|
|
620
|
+
else {
|
|
621
|
+
const gitRoot = getGitRoot(process.cwd());
|
|
622
|
+
if (!gitRoot) {
|
|
623
|
+
return { error: 'Not inside a git repository' };
|
|
624
|
+
}
|
|
625
|
+
repoPath = gitRoot;
|
|
626
|
+
}
|
|
627
|
+
if (!isGitRepo(repoPath)) {
|
|
628
|
+
return { error: 'Not a git repository' };
|
|
629
|
+
}
|
|
630
|
+
const { storagePath, kuzuPath } = getRepoStoragePaths(repoPath);
|
|
631
|
+
const currentCommit = getCurrentCommit(repoPath);
|
|
632
|
+
const existingMeta = await loadRepoMeta(storagePath);
|
|
633
|
+
// Skip if already indexed at same commit (unless force)
|
|
634
|
+
if (existingMeta && !params.force && existingMeta.lastCommit === currentCommit) {
|
|
635
|
+
return { status: 'up_to_date', message: 'Repository already up to date.' };
|
|
636
|
+
}
|
|
637
|
+
// Close MCP's persistent connection before pipeline takes over
|
|
638
|
+
await closeKuzu();
|
|
639
|
+
this.initialized = false;
|
|
640
|
+
try {
|
|
641
|
+
// Import pipeline modules dynamically to avoid circular deps
|
|
642
|
+
const { runPipelineFromRepo } = await import('../../core/ingestion/pipeline.js');
|
|
643
|
+
const coreKuzu = await import('../../core/kuzu/kuzu-adapter.js');
|
|
644
|
+
// Run ingestion pipeline
|
|
645
|
+
console.error('GitNexus: Running indexing pipeline...');
|
|
646
|
+
const pipelineResult = await runPipelineFromRepo(repoPath, (progress) => {
|
|
647
|
+
if (progress.percent % 20 === 0) {
|
|
648
|
+
console.error(`GitNexus: ${progress.phase} ${progress.percent}%`);
|
|
649
|
+
}
|
|
650
|
+
});
|
|
651
|
+
// Load graph into KuzuDB
|
|
652
|
+
console.error('GitNexus: Loading graph into KuzuDB...');
|
|
653
|
+
await coreKuzu.initKuzu(kuzuPath);
|
|
654
|
+
await coreKuzu.loadGraphToKuzu(pipelineResult.graph, pipelineResult.fileContents, storagePath);
|
|
655
|
+
// Create FTS indexes
|
|
656
|
+
console.error('GitNexus: Creating FTS indexes...');
|
|
657
|
+
try {
|
|
658
|
+
await coreKuzu.createFTSIndex('File', 'file_fts', ['name', 'content']);
|
|
659
|
+
await coreKuzu.createFTSIndex('Function', 'function_fts', ['name', 'content']);
|
|
660
|
+
await coreKuzu.createFTSIndex('Class', 'class_fts', ['name', 'content']);
|
|
661
|
+
await coreKuzu.createFTSIndex('Method', 'method_fts', ['name', 'content']);
|
|
662
|
+
}
|
|
663
|
+
catch (e) {
|
|
664
|
+
console.error('GitNexus: Some FTS indexes may not have been created:', e.message);
|
|
665
|
+
}
|
|
666
|
+
// Generate embeddings (unless skipped)
|
|
667
|
+
if (!params.skipEmbeddings) {
|
|
668
|
+
try {
|
|
669
|
+
console.error('GitNexus: Generating embeddings...');
|
|
670
|
+
const { runEmbeddingPipeline } = await import('../../core/embeddings/embedding-pipeline.js');
|
|
671
|
+
await runEmbeddingPipeline(coreKuzu.executeQuery, coreKuzu.executeWithReusedStatement, (progress) => {
|
|
672
|
+
if (progress.percent % 25 === 0) {
|
|
673
|
+
console.error(`GitNexus: Embeddings ${progress.percent}%`);
|
|
674
|
+
}
|
|
675
|
+
});
|
|
676
|
+
}
|
|
677
|
+
catch (e) {
|
|
678
|
+
console.error('GitNexus: Embedding generation failed (non-fatal):', e.message);
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
// Save metadata
|
|
682
|
+
const stats = await coreKuzu.getKuzuStats();
|
|
683
|
+
await saveRepoMeta(storagePath, {
|
|
684
|
+
repoPath,
|
|
685
|
+
lastCommit: currentCommit,
|
|
686
|
+
indexedAt: new Date().toISOString(),
|
|
687
|
+
stats: {
|
|
688
|
+
files: pipelineResult.fileContents.size,
|
|
689
|
+
nodes: stats.nodes,
|
|
690
|
+
edges: stats.edges,
|
|
691
|
+
communities: pipelineResult.communityResult?.stats.totalCommunities,
|
|
692
|
+
processes: pipelineResult.processResult?.stats.totalProcesses,
|
|
693
|
+
},
|
|
694
|
+
});
|
|
695
|
+
// Add .gitnexus to .gitignore
|
|
696
|
+
await addToGitignore(repoPath);
|
|
697
|
+
// Generate AI context files
|
|
698
|
+
const projectName = path.basename(repoPath);
|
|
699
|
+
await generateAIContextFiles(repoPath, storagePath, projectName, {
|
|
700
|
+
files: pipelineResult.fileContents.size,
|
|
701
|
+
nodes: stats.nodes,
|
|
702
|
+
edges: stats.edges,
|
|
703
|
+
communities: pipelineResult.communityResult?.stats.totalCommunities,
|
|
704
|
+
processes: pipelineResult.processResult?.stats.totalProcesses,
|
|
705
|
+
});
|
|
706
|
+
// Close core kuzu connection (pipeline is done)
|
|
707
|
+
await coreKuzu.closeKuzu();
|
|
708
|
+
// Re-init MCP state so next tool call reconnects
|
|
709
|
+
this.repo = await loadRepo(repoPath);
|
|
710
|
+
if (this.repo) {
|
|
711
|
+
const repoStats = this.repo.meta.stats || {};
|
|
712
|
+
this._context = {
|
|
713
|
+
projectName: path.basename(this.repo.repoPath),
|
|
714
|
+
stats: {
|
|
715
|
+
fileCount: repoStats.files || 0,
|
|
716
|
+
functionCount: repoStats.nodes || 0,
|
|
717
|
+
classCount: 0,
|
|
718
|
+
interfaceCount: 0,
|
|
719
|
+
methodCount: 0,
|
|
720
|
+
communityCount: repoStats.communities || 0,
|
|
721
|
+
processCount: repoStats.processes || 0,
|
|
722
|
+
},
|
|
723
|
+
hotspots: [],
|
|
724
|
+
folderTree: '',
|
|
725
|
+
};
|
|
726
|
+
}
|
|
727
|
+
console.error('GitNexus: Indexing complete!');
|
|
728
|
+
return {
|
|
729
|
+
status: 'success',
|
|
730
|
+
message: `Repository indexed successfully.`,
|
|
731
|
+
stats: {
|
|
732
|
+
files: pipelineResult.fileContents.size,
|
|
733
|
+
nodes: stats.nodes,
|
|
734
|
+
edges: stats.edges,
|
|
735
|
+
communities: pipelineResult.communityResult?.stats.totalCommunities,
|
|
736
|
+
processes: pipelineResult.processResult?.stats.totalProcesses,
|
|
737
|
+
},
|
|
738
|
+
};
|
|
739
|
+
}
|
|
740
|
+
catch (e) {
|
|
741
|
+
console.error('GitNexus: Indexing failed:', e.message);
|
|
742
|
+
return { error: `Indexing failed: ${e.message}` };
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
async disconnect() {
|
|
746
|
+
closeKuzu();
|
|
747
|
+
await disposeEmbedder();
|
|
748
|
+
this.repo = null;
|
|
749
|
+
this._context = null;
|
|
750
|
+
this.initialized = false;
|
|
751
|
+
}
|
|
752
|
+
}
|