gitnexus 1.1.8 → 1.2.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 +50 -59
- package/dist/cli/ai-context.js +9 -9
- package/dist/cli/analyze.js +139 -47
- package/dist/cli/augment.d.ts +13 -0
- package/dist/cli/augment.js +33 -0
- package/dist/cli/claude-hooks.d.ts +22 -0
- package/dist/cli/claude-hooks.js +97 -0
- package/dist/cli/eval-server.d.ts +30 -0
- package/dist/cli/eval-server.js +372 -0
- package/dist/cli/index.js +56 -1
- package/dist/cli/mcp.js +9 -0
- package/dist/cli/setup.js +184 -5
- package/dist/cli/tool.d.ts +37 -0
- package/dist/cli/tool.js +91 -0
- package/dist/cli/wiki.d.ts +13 -0
- package/dist/cli/wiki.js +199 -0
- package/dist/core/augmentation/engine.d.ts +26 -0
- package/dist/core/augmentation/engine.js +213 -0
- package/dist/core/embeddings/embedder.d.ts +2 -2
- package/dist/core/embeddings/embedder.js +11 -11
- package/dist/core/embeddings/embedding-pipeline.d.ts +2 -1
- package/dist/core/embeddings/embedding-pipeline.js +13 -5
- package/dist/core/embeddings/types.d.ts +2 -2
- package/dist/core/ingestion/call-processor.d.ts +7 -0
- package/dist/core/ingestion/call-processor.js +61 -23
- package/dist/core/ingestion/community-processor.js +34 -26
- package/dist/core/ingestion/filesystem-walker.js +15 -10
- package/dist/core/ingestion/heritage-processor.d.ts +6 -0
- package/dist/core/ingestion/heritage-processor.js +68 -5
- package/dist/core/ingestion/import-processor.d.ts +22 -0
- package/dist/core/ingestion/import-processor.js +215 -20
- package/dist/core/ingestion/parsing-processor.d.ts +8 -1
- package/dist/core/ingestion/parsing-processor.js +66 -25
- package/dist/core/ingestion/pipeline.js +104 -40
- package/dist/core/ingestion/process-processor.js +1 -1
- package/dist/core/ingestion/workers/parse-worker.d.ts +58 -0
- package/dist/core/ingestion/workers/parse-worker.js +451 -0
- package/dist/core/ingestion/workers/worker-pool.d.ts +22 -0
- package/dist/core/ingestion/workers/worker-pool.js +65 -0
- package/dist/core/kuzu/kuzu-adapter.d.ts +15 -1
- package/dist/core/kuzu/kuzu-adapter.js +177 -63
- package/dist/core/kuzu/schema.d.ts +1 -1
- package/dist/core/kuzu/schema.js +3 -0
- package/dist/core/search/bm25-index.js +13 -15
- package/dist/core/wiki/generator.d.ts +96 -0
- package/dist/core/wiki/generator.js +674 -0
- package/dist/core/wiki/graph-queries.d.ts +80 -0
- package/dist/core/wiki/graph-queries.js +238 -0
- package/dist/core/wiki/html-viewer.d.ts +10 -0
- package/dist/core/wiki/html-viewer.js +297 -0
- package/dist/core/wiki/llm-client.d.ts +36 -0
- package/dist/core/wiki/llm-client.js +111 -0
- package/dist/core/wiki/prompts.d.ts +53 -0
- package/dist/core/wiki/prompts.js +174 -0
- package/dist/mcp/core/embedder.js +4 -2
- package/dist/mcp/core/kuzu-adapter.d.ts +2 -1
- package/dist/mcp/core/kuzu-adapter.js +35 -15
- package/dist/mcp/local/local-backend.d.ts +54 -1
- package/dist/mcp/local/local-backend.js +716 -171
- package/dist/mcp/resources.d.ts +1 -1
- package/dist/mcp/resources.js +111 -73
- package/dist/mcp/server.d.ts +1 -1
- package/dist/mcp/server.js +91 -22
- package/dist/mcp/tools.js +80 -61
- package/dist/storage/git.d.ts +0 -1
- package/dist/storage/git.js +1 -8
- package/dist/storage/repo-manager.d.ts +17 -0
- package/dist/storage/repo-manager.js +26 -0
- package/hooks/claude/gitnexus-hook.cjs +135 -0
- package/hooks/claude/pre-tool-use.sh +78 -0
- package/hooks/claude/session-start.sh +42 -0
- package/package.json +4 -2
- package/skills/debugging.md +24 -22
- package/skills/exploring.md +26 -24
- package/skills/impact-analysis.md +19 -13
- package/skills/refactoring.md +37 -26
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* Supports multiple indexed repositories via a global registry.
|
|
6
6
|
* KuzuDB connections are opened lazily per repo on first query.
|
|
7
7
|
*/
|
|
8
|
+
import fs from 'fs/promises';
|
|
8
9
|
import path from 'path';
|
|
9
10
|
import { initKuzu, executeQuery, closeKuzu, isKuzuReady } from '../core/kuzu-adapter.js';
|
|
10
11
|
import { embedQuery, getEmbeddingDims, disposeEmbedder } from '../core/embedder.js';
|
|
@@ -26,6 +27,13 @@ function isTestFilePath(filePath) {
|
|
|
26
27
|
p.endsWith('_test.go') || p.endsWith('_test.py') ||
|
|
27
28
|
p.includes('/test_') || p.includes('/conftest.'));
|
|
28
29
|
}
|
|
30
|
+
/** Valid KuzuDB node labels for safe Cypher query construction */
|
|
31
|
+
const VALID_NODE_LABELS = new Set([
|
|
32
|
+
'File', 'Folder', 'Function', 'Class', 'Interface', 'Method', 'CodeElement',
|
|
33
|
+
'Community', 'Process', 'Struct', 'Enum', 'Macro', 'Typedef', 'Union',
|
|
34
|
+
'Namespace', 'Trait', 'Impl', 'TypeAlias', 'Const', 'Static', 'Property',
|
|
35
|
+
'Record', 'Delegate', 'Annotation', 'Constructor', 'Template', 'Module',
|
|
36
|
+
]);
|
|
29
37
|
export class LocalBackend {
|
|
30
38
|
repos = new Map();
|
|
31
39
|
contextCache = new Map();
|
|
@@ -131,8 +139,15 @@ export class LocalBackend {
|
|
|
131
139
|
const handle = this.repos.get(repoId);
|
|
132
140
|
if (!handle)
|
|
133
141
|
throw new Error(`Unknown repo: ${repoId}`);
|
|
134
|
-
|
|
135
|
-
|
|
142
|
+
try {
|
|
143
|
+
await initKuzu(repoId, handle.kuzuPath);
|
|
144
|
+
this.initializedRepos.add(repoId);
|
|
145
|
+
}
|
|
146
|
+
catch (err) {
|
|
147
|
+
// If lock error, mark as not initialized so next call retries
|
|
148
|
+
this.initializedRepos.delete(repoId);
|
|
149
|
+
throw err;
|
|
150
|
+
}
|
|
136
151
|
}
|
|
137
152
|
// ─── Public Getters ──────────────────────────────────────────────
|
|
138
153
|
/**
|
|
@@ -167,36 +182,55 @@ export class LocalBackend {
|
|
|
167
182
|
// Resolve repo from optional param
|
|
168
183
|
const repo = this.resolveRepo(params?.repo);
|
|
169
184
|
switch (method) {
|
|
170
|
-
case '
|
|
171
|
-
return this.
|
|
185
|
+
case 'query':
|
|
186
|
+
return this.query(repo, params);
|
|
172
187
|
case 'cypher':
|
|
173
188
|
return this.cypher(repo, params);
|
|
174
|
-
case '
|
|
175
|
-
return this.
|
|
176
|
-
case 'explore':
|
|
177
|
-
return this.explore(repo, params);
|
|
189
|
+
case 'context':
|
|
190
|
+
return this.context(repo, params);
|
|
178
191
|
case 'impact':
|
|
179
192
|
return this.impact(repo, params);
|
|
193
|
+
case 'detect_changes':
|
|
194
|
+
return this.detectChanges(repo, params);
|
|
195
|
+
case 'rename':
|
|
196
|
+
return this.rename(repo, params);
|
|
197
|
+
// Legacy aliases for backwards compatibility
|
|
198
|
+
case 'search':
|
|
199
|
+
return this.query(repo, params);
|
|
200
|
+
case 'explore':
|
|
201
|
+
return this.context(repo, { name: params?.name, ...params });
|
|
202
|
+
case 'overview':
|
|
203
|
+
return this.overview(repo, params);
|
|
180
204
|
default:
|
|
181
205
|
throw new Error(`Unknown tool: ${method}`);
|
|
182
206
|
}
|
|
183
207
|
}
|
|
184
208
|
// ─── Tool Implementations ────────────────────────────────────────
|
|
185
|
-
|
|
209
|
+
/**
|
|
210
|
+
* Query tool — process-grouped search.
|
|
211
|
+
*
|
|
212
|
+
* 1. Hybrid search (BM25 + semantic) to find matching symbols
|
|
213
|
+
* 2. Trace each match to its process(es) via STEP_IN_PROCESS
|
|
214
|
+
* 3. Group by process, rank by aggregate relevance + internal cluster cohesion
|
|
215
|
+
* 4. Return: { processes, process_symbols, definitions }
|
|
216
|
+
*/
|
|
217
|
+
async query(repo, params) {
|
|
218
|
+
if (!params.query?.trim()) {
|
|
219
|
+
return { error: 'query parameter is required and cannot be empty.' };
|
|
220
|
+
}
|
|
186
221
|
await this.ensureInitialized(repo.id);
|
|
187
|
-
const
|
|
188
|
-
const
|
|
189
|
-
const
|
|
190
|
-
|
|
222
|
+
const processLimit = params.limit || 5;
|
|
223
|
+
const maxSymbolsPerProcess = params.max_symbols || 10;
|
|
224
|
+
const includeContent = params.include_content ?? false;
|
|
225
|
+
const searchQuery = params.query.trim();
|
|
226
|
+
// Step 1: Run hybrid search to get matching symbols
|
|
227
|
+
const searchLimit = processLimit * maxSymbolsPerProcess; // fetch enough raw results
|
|
191
228
|
const [bm25Results, semanticResults] = await Promise.all([
|
|
192
|
-
this.bm25Search(repo,
|
|
193
|
-
this.semanticSearch(repo,
|
|
229
|
+
this.bm25Search(repo, searchQuery, searchLimit),
|
|
230
|
+
this.semanticSearch(repo, searchQuery, searchLimit),
|
|
194
231
|
]);
|
|
195
|
-
// Merge
|
|
196
|
-
// Key by nodeId (symbol-level) so semantic precision is preserved.
|
|
197
|
-
// Fall back to filePath for File-level results that lack a nodeId.
|
|
232
|
+
// Merge via reciprocal rank fusion
|
|
198
233
|
const scoreMap = new Map();
|
|
199
|
-
// BM25 results
|
|
200
234
|
for (let i = 0; i < bm25Results.length; i++) {
|
|
201
235
|
const result = bm25Results[i];
|
|
202
236
|
const key = result.nodeId || result.filePath;
|
|
@@ -204,13 +238,11 @@ export class LocalBackend {
|
|
|
204
238
|
const existing = scoreMap.get(key);
|
|
205
239
|
if (existing) {
|
|
206
240
|
existing.score += rrfScore;
|
|
207
|
-
existing.source = 'hybrid';
|
|
208
241
|
}
|
|
209
242
|
else {
|
|
210
|
-
scoreMap.set(key, { score: rrfScore,
|
|
243
|
+
scoreMap.set(key, { score: rrfScore, data: result });
|
|
211
244
|
}
|
|
212
245
|
}
|
|
213
|
-
// Semantic results
|
|
214
246
|
for (let i = 0; i < semanticResults.length; i++) {
|
|
215
247
|
const result = semanticResults[i];
|
|
216
248
|
const key = result.nodeId || result.filePath;
|
|
@@ -218,73 +250,158 @@ export class LocalBackend {
|
|
|
218
250
|
const existing = scoreMap.get(key);
|
|
219
251
|
if (existing) {
|
|
220
252
|
existing.score += rrfScore;
|
|
221
|
-
existing.source = 'hybrid';
|
|
222
253
|
}
|
|
223
254
|
else {
|
|
224
|
-
scoreMap.set(key, { score: rrfScore,
|
|
255
|
+
scoreMap.set(key, { score: rrfScore, data: result });
|
|
225
256
|
}
|
|
226
257
|
}
|
|
227
|
-
// Sort by fused score and take top results
|
|
228
258
|
const merged = Array.from(scoreMap.entries())
|
|
229
259
|
.sort((a, b) => b[1].score - a[1].score)
|
|
230
|
-
.slice(0,
|
|
231
|
-
//
|
|
232
|
-
const
|
|
260
|
+
.slice(0, searchLimit);
|
|
261
|
+
// Step 2: For each match with a nodeId, trace to process(es)
|
|
262
|
+
const processMap = new Map();
|
|
263
|
+
const definitions = []; // standalone symbols not in any process
|
|
233
264
|
for (const [_, item] of merged) {
|
|
234
|
-
const
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
265
|
+
const sym = item.data;
|
|
266
|
+
if (!sym.nodeId) {
|
|
267
|
+
// File-level results go to definitions
|
|
268
|
+
definitions.push({
|
|
269
|
+
name: sym.name,
|
|
270
|
+
type: sym.type || 'File',
|
|
271
|
+
filePath: sym.filePath,
|
|
272
|
+
});
|
|
273
|
+
continue;
|
|
274
|
+
}
|
|
275
|
+
const escaped = sym.nodeId.replace(/'/g, "''");
|
|
276
|
+
// Find processes this symbol participates in
|
|
277
|
+
let processRows = [];
|
|
278
|
+
try {
|
|
279
|
+
processRows = await executeQuery(repo.id, `
|
|
280
|
+
MATCH (n {id: '${escaped}'})-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p:Process)
|
|
281
|
+
RETURN p.id AS pid, p.label AS label, p.heuristicLabel AS heuristicLabel, p.processType AS processType, p.stepCount AS stepCount, r.step AS step
|
|
282
|
+
`);
|
|
283
|
+
}
|
|
284
|
+
catch { /* symbol might not be in any process */ }
|
|
285
|
+
// Get cluster cohesion as internal ranking signal (never exposed)
|
|
286
|
+
let cohesion = 0;
|
|
287
|
+
try {
|
|
288
|
+
const cohesionRows = await executeQuery(repo.id, `
|
|
289
|
+
MATCH (n {id: '${escaped}'})-[:CodeRelation {type: 'MEMBER_OF'}]->(c:Community)
|
|
290
|
+
RETURN c.cohesion AS cohesion
|
|
291
|
+
LIMIT 1
|
|
292
|
+
`);
|
|
293
|
+
if (cohesionRows.length > 0) {
|
|
294
|
+
cohesion = (cohesionRows[0].cohesion ?? cohesionRows[0][0]) || 0;
|
|
255
295
|
}
|
|
256
296
|
}
|
|
257
|
-
|
|
258
|
-
//
|
|
259
|
-
|
|
297
|
+
catch { /* no cluster info */ }
|
|
298
|
+
// Optionally fetch content
|
|
299
|
+
let content;
|
|
300
|
+
if (includeContent) {
|
|
260
301
|
try {
|
|
261
|
-
const
|
|
262
|
-
MATCH (n {id: '${
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
result.connections = rels.map((rel) => ({
|
|
269
|
-
type: rel.type || rel[0],
|
|
270
|
-
name: rel.targetName || rel[1],
|
|
271
|
-
path: rel.targetPath || rel[2],
|
|
272
|
-
}));
|
|
302
|
+
const contentRows = await executeQuery(repo.id, `
|
|
303
|
+
MATCH (n {id: '${escaped}'})
|
|
304
|
+
RETURN n.content AS content
|
|
305
|
+
`);
|
|
306
|
+
if (contentRows.length > 0) {
|
|
307
|
+
content = contentRows[0].content ?? contentRows[0][0];
|
|
308
|
+
}
|
|
273
309
|
}
|
|
274
|
-
catch {
|
|
275
|
-
|
|
310
|
+
catch { /* skip */ }
|
|
311
|
+
}
|
|
312
|
+
const symbolEntry = {
|
|
313
|
+
id: sym.nodeId,
|
|
314
|
+
name: sym.name,
|
|
315
|
+
type: sym.type,
|
|
316
|
+
filePath: sym.filePath,
|
|
317
|
+
startLine: sym.startLine,
|
|
318
|
+
endLine: sym.endLine,
|
|
319
|
+
...(includeContent && content ? { content } : {}),
|
|
320
|
+
};
|
|
321
|
+
if (processRows.length === 0) {
|
|
322
|
+
// Symbol not in any process — goes to definitions
|
|
323
|
+
definitions.push(symbolEntry);
|
|
324
|
+
}
|
|
325
|
+
else {
|
|
326
|
+
// Add to each process it belongs to
|
|
327
|
+
for (const row of processRows) {
|
|
328
|
+
const pid = row.pid ?? row[0];
|
|
329
|
+
const label = row.label ?? row[1];
|
|
330
|
+
const hLabel = row.heuristicLabel ?? row[2];
|
|
331
|
+
const pType = row.processType ?? row[3];
|
|
332
|
+
const stepCount = row.stepCount ?? row[4];
|
|
333
|
+
const step = row.step ?? row[5];
|
|
334
|
+
if (!processMap.has(pid)) {
|
|
335
|
+
processMap.set(pid, {
|
|
336
|
+
id: pid,
|
|
337
|
+
label,
|
|
338
|
+
heuristicLabel: hLabel,
|
|
339
|
+
processType: pType,
|
|
340
|
+
stepCount,
|
|
341
|
+
totalScore: 0,
|
|
342
|
+
cohesionBoost: 0,
|
|
343
|
+
symbols: [],
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
const proc = processMap.get(pid);
|
|
347
|
+
proc.totalScore += item.score;
|
|
348
|
+
proc.cohesionBoost = Math.max(proc.cohesionBoost, cohesion);
|
|
349
|
+
proc.symbols.push({
|
|
350
|
+
...symbolEntry,
|
|
351
|
+
process_id: pid,
|
|
352
|
+
step_index: step,
|
|
353
|
+
});
|
|
276
354
|
}
|
|
277
355
|
}
|
|
278
|
-
results.push(result);
|
|
279
356
|
}
|
|
280
|
-
|
|
357
|
+
// Step 3: Rank processes by aggregate score + internal cohesion boost
|
|
358
|
+
const rankedProcesses = Array.from(processMap.values())
|
|
359
|
+
.map(p => ({
|
|
360
|
+
...p,
|
|
361
|
+
priority: p.totalScore + (p.cohesionBoost * 0.1), // cohesion as subtle ranking signal
|
|
362
|
+
}))
|
|
363
|
+
.sort((a, b) => b.priority - a.priority)
|
|
364
|
+
.slice(0, processLimit);
|
|
365
|
+
// Step 4: Build response
|
|
366
|
+
const processes = rankedProcesses.map(p => ({
|
|
367
|
+
id: p.id,
|
|
368
|
+
summary: p.heuristicLabel || p.label,
|
|
369
|
+
priority: Math.round(p.priority * 1000) / 1000,
|
|
370
|
+
symbol_count: p.symbols.length,
|
|
371
|
+
process_type: p.processType,
|
|
372
|
+
step_count: p.stepCount,
|
|
373
|
+
}));
|
|
374
|
+
const processSymbols = rankedProcesses.flatMap(p => p.symbols.slice(0, maxSymbolsPerProcess).map(s => ({
|
|
375
|
+
...s,
|
|
376
|
+
// remove internal fields
|
|
377
|
+
})));
|
|
378
|
+
// Deduplicate process_symbols by id
|
|
379
|
+
const seen = new Set();
|
|
380
|
+
const dedupedSymbols = processSymbols.filter(s => {
|
|
381
|
+
if (seen.has(s.id))
|
|
382
|
+
return false;
|
|
383
|
+
seen.add(s.id);
|
|
384
|
+
return true;
|
|
385
|
+
});
|
|
386
|
+
return {
|
|
387
|
+
processes,
|
|
388
|
+
process_symbols: dedupedSymbols,
|
|
389
|
+
definitions: definitions.slice(0, 20), // cap standalone definitions
|
|
390
|
+
};
|
|
281
391
|
}
|
|
282
392
|
/**
|
|
283
393
|
* BM25 keyword search helper - uses KuzuDB FTS for always-fresh results
|
|
284
394
|
*/
|
|
285
395
|
async bm25Search(repo, query, limit) {
|
|
286
396
|
const { searchFTSFromKuzu } = await import('../../core/search/bm25-index.js');
|
|
287
|
-
|
|
397
|
+
let bm25Results;
|
|
398
|
+
try {
|
|
399
|
+
bm25Results = await searchFTSFromKuzu(query, limit, repo.id);
|
|
400
|
+
}
|
|
401
|
+
catch (err) {
|
|
402
|
+
console.error('GitNexus: BM25/FTS search failed (FTS indexes may not exist) -', err.message);
|
|
403
|
+
return [];
|
|
404
|
+
}
|
|
288
405
|
const results = [];
|
|
289
406
|
for (const bm25Result of bm25Results) {
|
|
290
407
|
const fullPath = bm25Result.filePath;
|
|
@@ -357,10 +474,14 @@ export class LocalBackend {
|
|
|
357
474
|
const distance = embRow.distance ?? embRow[1];
|
|
358
475
|
const labelEndIdx = nodeId.indexOf(':');
|
|
359
476
|
const label = labelEndIdx > 0 ? nodeId.substring(0, labelEndIdx) : 'Unknown';
|
|
477
|
+
// Validate label against known node types to prevent Cypher injection
|
|
478
|
+
if (!VALID_NODE_LABELS.has(label))
|
|
479
|
+
continue;
|
|
360
480
|
try {
|
|
481
|
+
const escapedId = nodeId.replace(/'/g, "''");
|
|
361
482
|
const nodeQuery = label === 'File'
|
|
362
|
-
? `MATCH (n:File {id: '${
|
|
363
|
-
: `MATCH (n
|
|
483
|
+
? `MATCH (n:File {id: '${escapedId}'}) RETURN n.name AS name, n.filePath AS filePath`
|
|
484
|
+
: `MATCH (n:\`${label}\` {id: '${escapedId}'}) RETURN n.name AS name, n.filePath AS filePath, n.startLine AS startLine, n.endLine AS endLine`;
|
|
364
485
|
const nodeRows = await executeQuery(repo.id, nodeQuery);
|
|
365
486
|
if (nodeRows.length > 0) {
|
|
366
487
|
const nodeRow = nodeRows[0];
|
|
@@ -488,73 +609,140 @@ export class LocalBackend {
|
|
|
488
609
|
}
|
|
489
610
|
return result;
|
|
490
611
|
}
|
|
491
|
-
|
|
612
|
+
/**
|
|
613
|
+
* Context tool — 360-degree symbol view with categorized refs.
|
|
614
|
+
* Disambiguation when multiple symbols share a name.
|
|
615
|
+
* UID-based direct lookup. No cluster in output.
|
|
616
|
+
*/
|
|
617
|
+
async context(repo, params) {
|
|
492
618
|
await this.ensureInitialized(repo.id);
|
|
493
|
-
const { name,
|
|
494
|
-
if (
|
|
495
|
-
|
|
619
|
+
const { name, uid, file_path, include_content } = params;
|
|
620
|
+
if (!name && !uid) {
|
|
621
|
+
return { error: 'Either "name" or "uid" parameter is required.' };
|
|
622
|
+
}
|
|
623
|
+
// Step 1: Find the symbol
|
|
624
|
+
let symbols;
|
|
625
|
+
if (uid) {
|
|
626
|
+
const escaped = uid.replace(/'/g, "''");
|
|
627
|
+
symbols = await executeQuery(repo.id, `
|
|
628
|
+
MATCH (n {id: '${escaped}'})
|
|
629
|
+
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${include_content ? ', n.content AS content' : ''}
|
|
630
|
+
LIMIT 1
|
|
631
|
+
`);
|
|
632
|
+
}
|
|
633
|
+
else {
|
|
634
|
+
const escaped = name.replace(/'/g, "''");
|
|
496
635
|
const isQualified = name.includes('/') || name.includes(':');
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
MATCH (caller)-[:CodeRelation {type: 'CALLS'}]->(n {id: '${symId}'})
|
|
512
|
-
RETURN caller.name AS name, caller.filePath AS filePath
|
|
513
|
-
LIMIT 10
|
|
514
|
-
`;
|
|
515
|
-
const callers = await executeQuery(repo.id, callersQuery);
|
|
516
|
-
const calleesQuery = `
|
|
517
|
-
MATCH (n {id: '${symId}'})-[:CodeRelation {type: 'CALLS'}]->(callee)
|
|
518
|
-
RETURN callee.name AS name, callee.filePath AS filePath
|
|
636
|
+
let whereClause;
|
|
637
|
+
if (file_path) {
|
|
638
|
+
const fpEscaped = file_path.replace(/'/g, "''");
|
|
639
|
+
whereClause = `WHERE n.name = '${escaped}' AND n.filePath CONTAINS '${fpEscaped}'`;
|
|
640
|
+
}
|
|
641
|
+
else if (isQualified) {
|
|
642
|
+
whereClause = `WHERE n.id = '${escaped}' OR n.name = '${escaped}'`;
|
|
643
|
+
}
|
|
644
|
+
else {
|
|
645
|
+
whereClause = `WHERE n.name = '${escaped}'`;
|
|
646
|
+
}
|
|
647
|
+
symbols = await executeQuery(repo.id, `
|
|
648
|
+
MATCH (n) ${whereClause}
|
|
649
|
+
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${include_content ? ', n.content AS content' : ''}
|
|
519
650
|
LIMIT 10
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
startLine: sym.startLine || sym[4],
|
|
535
|
-
endLine: sym.endLine || sym[5],
|
|
536
|
-
},
|
|
537
|
-
callers: callers.map((c) => ({ name: c.name || c[0], filePath: c.filePath || c[1] })),
|
|
538
|
-
callees: callees.map((c) => ({ name: c.name || c[0], filePath: c.filePath || c[1] })),
|
|
539
|
-
community: communities.length > 0 ? {
|
|
540
|
-
label: communities[0].label || communities[0][0],
|
|
541
|
-
heuristicLabel: communities[0].heuristicLabel || communities[0][1],
|
|
542
|
-
} : null,
|
|
543
|
-
};
|
|
544
|
-
// If multiple symbols share the same name, show alternatives so the agent can disambiguate
|
|
545
|
-
if (symbols.length > 1) {
|
|
546
|
-
result.alternatives = symbols.slice(1).map((s) => ({
|
|
547
|
-
id: s.id || s[0],
|
|
548
|
-
type: s.type || s[2],
|
|
651
|
+
`);
|
|
652
|
+
}
|
|
653
|
+
if (symbols.length === 0) {
|
|
654
|
+
return { error: `Symbol '${name || uid}' not found` };
|
|
655
|
+
}
|
|
656
|
+
// Step 2: Disambiguation
|
|
657
|
+
if (symbols.length > 1 && !uid) {
|
|
658
|
+
return {
|
|
659
|
+
status: 'ambiguous',
|
|
660
|
+
message: `Found ${symbols.length} symbols matching '${name}'. Use uid or file_path to disambiguate.`,
|
|
661
|
+
candidates: symbols.map((s) => ({
|
|
662
|
+
uid: s.id || s[0],
|
|
663
|
+
name: s.name || s[1],
|
|
664
|
+
kind: s.type || s[2],
|
|
549
665
|
filePath: s.filePath || s[3],
|
|
550
|
-
|
|
551
|
-
|
|
666
|
+
line: s.startLine || s[4],
|
|
667
|
+
})),
|
|
668
|
+
};
|
|
669
|
+
}
|
|
670
|
+
// Step 3: Build full context
|
|
671
|
+
const sym = symbols[0];
|
|
672
|
+
const symId = (sym.id || sym[0]).replace(/'/g, "''");
|
|
673
|
+
// Categorized incoming refs
|
|
674
|
+
const incomingRows = await executeQuery(repo.id, `
|
|
675
|
+
MATCH (caller)-[r:CodeRelation]->(n {id: '${symId}'})
|
|
676
|
+
WHERE r.type IN ['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS']
|
|
677
|
+
RETURN r.type AS relType, caller.id AS uid, caller.name AS name, caller.filePath AS filePath, labels(caller)[0] AS kind
|
|
678
|
+
LIMIT 30
|
|
679
|
+
`);
|
|
680
|
+
// Categorized outgoing refs
|
|
681
|
+
const outgoingRows = await executeQuery(repo.id, `
|
|
682
|
+
MATCH (n {id: '${symId}'})-[r:CodeRelation]->(target)
|
|
683
|
+
WHERE r.type IN ['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS']
|
|
684
|
+
RETURN r.type AS relType, target.id AS uid, target.name AS name, target.filePath AS filePath, labels(target)[0] AS kind
|
|
685
|
+
LIMIT 30
|
|
686
|
+
`);
|
|
687
|
+
// Process participation
|
|
688
|
+
let processRows = [];
|
|
689
|
+
try {
|
|
690
|
+
processRows = await executeQuery(repo.id, `
|
|
691
|
+
MATCH (n {id: '${symId}'})-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p:Process)
|
|
692
|
+
RETURN p.id AS pid, p.heuristicLabel AS label, r.step AS step, p.stepCount AS stepCount
|
|
693
|
+
`);
|
|
694
|
+
}
|
|
695
|
+
catch { /* no process info */ }
|
|
696
|
+
// Helper to categorize refs
|
|
697
|
+
const categorize = (rows) => {
|
|
698
|
+
const cats = {};
|
|
699
|
+
for (const row of rows) {
|
|
700
|
+
const relType = (row.relType || row[0] || '').toLowerCase();
|
|
701
|
+
const entry = {
|
|
702
|
+
uid: row.uid || row[1],
|
|
703
|
+
name: row.name || row[2],
|
|
704
|
+
filePath: row.filePath || row[3],
|
|
705
|
+
kind: row.kind || row[4],
|
|
706
|
+
};
|
|
707
|
+
if (!cats[relType])
|
|
708
|
+
cats[relType] = [];
|
|
709
|
+
cats[relType].push(entry);
|
|
552
710
|
}
|
|
553
|
-
return
|
|
711
|
+
return cats;
|
|
712
|
+
};
|
|
713
|
+
return {
|
|
714
|
+
status: 'found',
|
|
715
|
+
symbol: {
|
|
716
|
+
uid: sym.id || sym[0],
|
|
717
|
+
name: sym.name || sym[1],
|
|
718
|
+
kind: sym.type || sym[2],
|
|
719
|
+
filePath: sym.filePath || sym[3],
|
|
720
|
+
startLine: sym.startLine || sym[4],
|
|
721
|
+
endLine: sym.endLine || sym[5],
|
|
722
|
+
...(include_content && (sym.content || sym[6]) ? { content: sym.content || sym[6] } : {}),
|
|
723
|
+
},
|
|
724
|
+
incoming: categorize(incomingRows),
|
|
725
|
+
outgoing: categorize(outgoingRows),
|
|
726
|
+
processes: processRows.map((r) => ({
|
|
727
|
+
id: r.pid || r[0],
|
|
728
|
+
name: r.label || r[1],
|
|
729
|
+
step_index: r.step || r[2],
|
|
730
|
+
step_count: r.stepCount || r[3],
|
|
731
|
+
})),
|
|
732
|
+
};
|
|
733
|
+
}
|
|
734
|
+
/**
|
|
735
|
+
* Legacy explore — kept for backwards compatibility with resources.ts.
|
|
736
|
+
* Routes cluster/process types to direct graph queries.
|
|
737
|
+
*/
|
|
738
|
+
async explore(repo, params) {
|
|
739
|
+
await this.ensureInitialized(repo.id);
|
|
740
|
+
const { name, type } = params;
|
|
741
|
+
if (type === 'symbol') {
|
|
742
|
+
return this.context(repo, { name });
|
|
554
743
|
}
|
|
555
744
|
if (type === 'cluster') {
|
|
556
745
|
const escaped = name.replace(/'/g, "''");
|
|
557
|
-
// Find ALL communities with this label (not just one)
|
|
558
746
|
const clusterQuery = `
|
|
559
747
|
MATCH (c:Community)
|
|
560
748
|
WHERE c.label = '${escaped}' OR c.heuristicLabel = '${escaped}'
|
|
@@ -564,28 +752,21 @@ export class LocalBackend {
|
|
|
564
752
|
if (clusters.length === 0)
|
|
565
753
|
return { error: `Cluster '${name}' not found` };
|
|
566
754
|
const rawClusters = clusters.map((c) => ({
|
|
567
|
-
id: c.id || c[0],
|
|
568
|
-
|
|
569
|
-
heuristicLabel: c.heuristicLabel || c[2],
|
|
570
|
-
cohesion: c.cohesion || c[3],
|
|
571
|
-
symbolCount: c.symbolCount || c[4],
|
|
755
|
+
id: c.id || c[0], label: c.label || c[1], heuristicLabel: c.heuristicLabel || c[2],
|
|
756
|
+
cohesion: c.cohesion || c[3], symbolCount: c.symbolCount || c[4],
|
|
572
757
|
}));
|
|
573
|
-
|
|
574
|
-
let totalSymbols = 0;
|
|
575
|
-
let weightedCohesion = 0;
|
|
758
|
+
let totalSymbols = 0, weightedCohesion = 0;
|
|
576
759
|
for (const c of rawClusters) {
|
|
577
760
|
const s = c.symbolCount || 0;
|
|
578
761
|
totalSymbols += s;
|
|
579
762
|
weightedCohesion += (c.cohesion || 0) * s;
|
|
580
763
|
}
|
|
581
|
-
|
|
582
|
-
const membersQuery = `
|
|
764
|
+
const members = await executeQuery(repo.id, `
|
|
583
765
|
MATCH (n)-[:CodeRelation {type: 'MEMBER_OF'}]->(c:Community)
|
|
584
766
|
WHERE c.label = '${escaped}' OR c.heuristicLabel = '${escaped}'
|
|
585
767
|
RETURN DISTINCT n.name AS name, labels(n)[0] AS type, n.filePath AS filePath
|
|
586
768
|
LIMIT 30
|
|
587
|
-
|
|
588
|
-
const members = await executeQuery(repo.id, membersQuery);
|
|
769
|
+
`);
|
|
589
770
|
return {
|
|
590
771
|
cluster: {
|
|
591
772
|
id: rawClusters[0].id,
|
|
@@ -596,48 +777,273 @@ export class LocalBackend {
|
|
|
596
777
|
subCommunities: rawClusters.length,
|
|
597
778
|
},
|
|
598
779
|
members: members.map((m) => ({
|
|
599
|
-
name: m.name || m[0],
|
|
600
|
-
type: m.type || m[1],
|
|
601
|
-
filePath: m.filePath || m[2],
|
|
780
|
+
name: m.name || m[0], type: m.type || m[1], filePath: m.filePath || m[2],
|
|
602
781
|
})),
|
|
603
782
|
};
|
|
604
783
|
}
|
|
605
784
|
if (type === 'process') {
|
|
606
|
-
const
|
|
785
|
+
const processes = await executeQuery(repo.id, `
|
|
607
786
|
MATCH (p:Process)
|
|
608
787
|
WHERE p.label = '${name.replace(/'/g, "''")}' OR p.heuristicLabel = '${name.replace(/'/g, "''")}'
|
|
609
|
-
RETURN p.id AS id, p.label AS label, p.heuristicLabel AS heuristicLabel, p.processType AS processType, p.stepCount AS stepCount
|
|
788
|
+
RETURN p.id AS id, p.label AS label, p.heuristicLabel AS heuristicLabel, p.processType AS processType, p.stepCount AS stepCount
|
|
610
789
|
LIMIT 1
|
|
611
|
-
|
|
612
|
-
const processes = await executeQuery(repo.id, processQuery);
|
|
790
|
+
`);
|
|
613
791
|
if (processes.length === 0)
|
|
614
792
|
return { error: `Process '${name}' not found` };
|
|
615
793
|
const proc = processes[0];
|
|
616
794
|
const procId = proc.id || proc[0];
|
|
617
|
-
const
|
|
795
|
+
const steps = await executeQuery(repo.id, `
|
|
618
796
|
MATCH (n)-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p {id: '${procId}'})
|
|
619
797
|
RETURN n.name AS name, labels(n)[0] AS type, n.filePath AS filePath, r.step AS step
|
|
620
798
|
ORDER BY r.step
|
|
621
|
-
|
|
622
|
-
const steps = await executeQuery(repo.id, stepsQuery);
|
|
799
|
+
`);
|
|
623
800
|
return {
|
|
624
801
|
process: {
|
|
625
|
-
id: procId,
|
|
626
|
-
|
|
627
|
-
heuristicLabel: proc.heuristicLabel || proc[2],
|
|
628
|
-
processType: proc.processType || proc[3],
|
|
629
|
-
stepCount: proc.stepCount || proc[4],
|
|
802
|
+
id: procId, label: proc.label || proc[1], heuristicLabel: proc.heuristicLabel || proc[2],
|
|
803
|
+
processType: proc.processType || proc[3], stepCount: proc.stepCount || proc[4],
|
|
630
804
|
},
|
|
631
805
|
steps: steps.map((s) => ({
|
|
632
|
-
step: s.step || s[3],
|
|
633
|
-
name: s.name || s[0],
|
|
634
|
-
type: s.type || s[1],
|
|
635
|
-
filePath: s.filePath || s[2],
|
|
806
|
+
step: s.step || s[3], name: s.name || s[0], type: s.type || s[1], filePath: s.filePath || s[2],
|
|
636
807
|
})),
|
|
637
808
|
};
|
|
638
809
|
}
|
|
639
810
|
return { error: 'Invalid type. Use: symbol, cluster, or process' };
|
|
640
811
|
}
|
|
812
|
+
/**
|
|
813
|
+
* Detect changes — git-diff based impact analysis.
|
|
814
|
+
* Maps changed lines to indexed symbols, then finds affected processes.
|
|
815
|
+
*/
|
|
816
|
+
async detectChanges(repo, params) {
|
|
817
|
+
await this.ensureInitialized(repo.id);
|
|
818
|
+
const scope = params.scope || 'unstaged';
|
|
819
|
+
const { execSync } = await import('child_process');
|
|
820
|
+
// Build git diff command based on scope
|
|
821
|
+
let diffCmd;
|
|
822
|
+
switch (scope) {
|
|
823
|
+
case 'staged':
|
|
824
|
+
diffCmd = 'git diff --staged --name-only';
|
|
825
|
+
break;
|
|
826
|
+
case 'all':
|
|
827
|
+
diffCmd = 'git diff HEAD --name-only';
|
|
828
|
+
break;
|
|
829
|
+
case 'compare':
|
|
830
|
+
if (!params.base_ref)
|
|
831
|
+
return { error: 'base_ref is required for "compare" scope' };
|
|
832
|
+
diffCmd = `git diff ${params.base_ref} --name-only`;
|
|
833
|
+
break;
|
|
834
|
+
case 'unstaged':
|
|
835
|
+
default:
|
|
836
|
+
diffCmd = 'git diff --name-only';
|
|
837
|
+
break;
|
|
838
|
+
}
|
|
839
|
+
let changedFiles;
|
|
840
|
+
try {
|
|
841
|
+
const output = execSync(diffCmd, { cwd: repo.repoPath, encoding: 'utf-8' });
|
|
842
|
+
changedFiles = output.trim().split('\n').filter(f => f.length > 0);
|
|
843
|
+
}
|
|
844
|
+
catch (err) {
|
|
845
|
+
return { error: `Git diff failed: ${err.message}` };
|
|
846
|
+
}
|
|
847
|
+
if (changedFiles.length === 0) {
|
|
848
|
+
return {
|
|
849
|
+
summary: { changed_count: 0, affected_count: 0, risk_level: 'none', message: 'No changes detected.' },
|
|
850
|
+
changed_symbols: [],
|
|
851
|
+
affected_processes: [],
|
|
852
|
+
};
|
|
853
|
+
}
|
|
854
|
+
// Map changed files to indexed symbols
|
|
855
|
+
const changedSymbols = [];
|
|
856
|
+
for (const file of changedFiles) {
|
|
857
|
+
const escaped = file.replace(/\\/g, '/').replace(/'/g, "''");
|
|
858
|
+
try {
|
|
859
|
+
const symbols = await executeQuery(repo.id, `
|
|
860
|
+
MATCH (n) WHERE n.filePath CONTAINS '${escaped}'
|
|
861
|
+
RETURN n.id AS id, n.name AS name, labels(n)[0] AS type, n.filePath AS filePath
|
|
862
|
+
LIMIT 20
|
|
863
|
+
`);
|
|
864
|
+
for (const sym of symbols) {
|
|
865
|
+
changedSymbols.push({
|
|
866
|
+
id: sym.id || sym[0],
|
|
867
|
+
name: sym.name || sym[1],
|
|
868
|
+
type: sym.type || sym[2],
|
|
869
|
+
filePath: sym.filePath || sym[3],
|
|
870
|
+
change_type: 'Modified',
|
|
871
|
+
});
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
catch { /* skip */ }
|
|
875
|
+
}
|
|
876
|
+
// Find affected processes
|
|
877
|
+
const affectedProcesses = new Map();
|
|
878
|
+
for (const sym of changedSymbols) {
|
|
879
|
+
const escaped = sym.id.replace(/'/g, "''");
|
|
880
|
+
try {
|
|
881
|
+
const procs = await executeQuery(repo.id, `
|
|
882
|
+
MATCH (n {id: '${escaped}'})-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p:Process)
|
|
883
|
+
RETURN p.id AS pid, p.heuristicLabel AS label, p.processType AS processType, p.stepCount AS stepCount, r.step AS step
|
|
884
|
+
`);
|
|
885
|
+
for (const proc of procs) {
|
|
886
|
+
const pid = proc.pid || proc[0];
|
|
887
|
+
if (!affectedProcesses.has(pid)) {
|
|
888
|
+
affectedProcesses.set(pid, {
|
|
889
|
+
id: pid,
|
|
890
|
+
name: proc.label || proc[1],
|
|
891
|
+
process_type: proc.processType || proc[2],
|
|
892
|
+
step_count: proc.stepCount || proc[3],
|
|
893
|
+
changed_steps: [],
|
|
894
|
+
});
|
|
895
|
+
}
|
|
896
|
+
affectedProcesses.get(pid).changed_steps.push({
|
|
897
|
+
symbol: sym.name,
|
|
898
|
+
step: proc.step || proc[4],
|
|
899
|
+
});
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
catch { /* skip */ }
|
|
903
|
+
}
|
|
904
|
+
const processCount = affectedProcesses.size;
|
|
905
|
+
const risk = processCount === 0 ? 'low' : processCount <= 5 ? 'medium' : processCount <= 15 ? 'high' : 'critical';
|
|
906
|
+
return {
|
|
907
|
+
summary: {
|
|
908
|
+
changed_count: changedSymbols.length,
|
|
909
|
+
affected_count: processCount,
|
|
910
|
+
changed_files: changedFiles.length,
|
|
911
|
+
risk_level: risk,
|
|
912
|
+
},
|
|
913
|
+
changed_symbols: changedSymbols,
|
|
914
|
+
affected_processes: Array.from(affectedProcesses.values()),
|
|
915
|
+
};
|
|
916
|
+
}
|
|
917
|
+
/**
|
|
918
|
+
* Rename tool — multi-file coordinated rename using graph + text search.
|
|
919
|
+
* Graph refs are tagged "graph" (high confidence).
|
|
920
|
+
* Additional refs found via text search are tagged "text_search" (lower confidence).
|
|
921
|
+
*/
|
|
922
|
+
async rename(repo, params) {
|
|
923
|
+
await this.ensureInitialized(repo.id);
|
|
924
|
+
const { new_name, file_path } = params;
|
|
925
|
+
const dry_run = params.dry_run ?? true;
|
|
926
|
+
if (!params.symbol_name && !params.symbol_uid) {
|
|
927
|
+
return { error: 'Either symbol_name or symbol_uid is required.' };
|
|
928
|
+
}
|
|
929
|
+
// Step 1: Find the target symbol (reuse context's lookup)
|
|
930
|
+
const lookupResult = await this.context(repo, {
|
|
931
|
+
name: params.symbol_name,
|
|
932
|
+
uid: params.symbol_uid,
|
|
933
|
+
file_path,
|
|
934
|
+
});
|
|
935
|
+
if (lookupResult.status === 'ambiguous') {
|
|
936
|
+
return lookupResult; // pass disambiguation through
|
|
937
|
+
}
|
|
938
|
+
if (lookupResult.error) {
|
|
939
|
+
return lookupResult;
|
|
940
|
+
}
|
|
941
|
+
const sym = lookupResult.symbol;
|
|
942
|
+
const oldName = sym.name;
|
|
943
|
+
if (oldName === new_name) {
|
|
944
|
+
return { error: 'New name is the same as the current name.' };
|
|
945
|
+
}
|
|
946
|
+
// Step 2: Collect edits from graph (high confidence)
|
|
947
|
+
const changes = new Map();
|
|
948
|
+
const addEdit = (filePath, line, oldText, newText, confidence) => {
|
|
949
|
+
if (!changes.has(filePath)) {
|
|
950
|
+
changes.set(filePath, { file_path: filePath, edits: [] });
|
|
951
|
+
}
|
|
952
|
+
changes.get(filePath).edits.push({ line, old_text: oldText, new_text: newText, confidence });
|
|
953
|
+
};
|
|
954
|
+
// The definition itself
|
|
955
|
+
if (sym.filePath && sym.startLine) {
|
|
956
|
+
try {
|
|
957
|
+
const content = await fs.readFile(path.join(repo.repoPath, sym.filePath), 'utf-8');
|
|
958
|
+
const lines = content.split('\n');
|
|
959
|
+
const lineIdx = sym.startLine - 1;
|
|
960
|
+
if (lineIdx >= 0 && lineIdx < lines.length && lines[lineIdx].includes(oldName)) {
|
|
961
|
+
addEdit(sym.filePath, sym.startLine, lines[lineIdx].trim(), lines[lineIdx].replace(oldName, new_name).trim(), 'graph');
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
catch { /* skip */ }
|
|
965
|
+
}
|
|
966
|
+
// All incoming refs from graph (callers, importers, etc.)
|
|
967
|
+
const allIncoming = [
|
|
968
|
+
...(lookupResult.incoming.calls || []),
|
|
969
|
+
...(lookupResult.incoming.imports || []),
|
|
970
|
+
...(lookupResult.incoming.extends || []),
|
|
971
|
+
...(lookupResult.incoming.implements || []),
|
|
972
|
+
];
|
|
973
|
+
let graphEdits = changes.size > 0 ? 1 : 0; // count definition edit
|
|
974
|
+
for (const ref of allIncoming) {
|
|
975
|
+
if (!ref.filePath)
|
|
976
|
+
continue;
|
|
977
|
+
try {
|
|
978
|
+
const content = await fs.readFile(path.join(repo.repoPath, ref.filePath), 'utf-8');
|
|
979
|
+
const lines = content.split('\n');
|
|
980
|
+
for (let i = 0; i < lines.length; i++) {
|
|
981
|
+
if (lines[i].includes(oldName)) {
|
|
982
|
+
addEdit(ref.filePath, i + 1, lines[i].trim(), lines[i].replace(new RegExp(`\\b${oldName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'g'), new_name).trim(), 'graph');
|
|
983
|
+
graphEdits++;
|
|
984
|
+
break; // one edit per file from graph refs
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
catch { /* skip */ }
|
|
989
|
+
}
|
|
990
|
+
// Step 3: Text search for refs the graph might have missed
|
|
991
|
+
let astSearchEdits = 0;
|
|
992
|
+
const graphFiles = new Set([sym.filePath, ...allIncoming.map(r => r.filePath)].filter(Boolean));
|
|
993
|
+
// Simple text search across the repo for the old name (in files not already covered by graph)
|
|
994
|
+
try {
|
|
995
|
+
const { execSync } = await import('child_process');
|
|
996
|
+
const rgCmd = `rg -l --type-add "code:*.{ts,tsx,js,jsx,py,go,rs,java}" -t code "\\b${oldName}\\b" .`;
|
|
997
|
+
const output = execSync(rgCmd, { cwd: repo.repoPath, encoding: 'utf-8', timeout: 5000 });
|
|
998
|
+
const files = output.trim().split('\n').filter(f => f.length > 0);
|
|
999
|
+
for (const file of files) {
|
|
1000
|
+
const normalizedFile = file.replace(/\\/g, '/').replace(/^\.\//, '');
|
|
1001
|
+
if (graphFiles.has(normalizedFile))
|
|
1002
|
+
continue; // already covered by graph
|
|
1003
|
+
try {
|
|
1004
|
+
const content = await fs.readFile(path.join(repo.repoPath, normalizedFile), 'utf-8');
|
|
1005
|
+
const lines = content.split('\n');
|
|
1006
|
+
const regex = new RegExp(`\\b${oldName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'g');
|
|
1007
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1008
|
+
if (regex.test(lines[i])) {
|
|
1009
|
+
addEdit(normalizedFile, i + 1, lines[i].trim(), lines[i].replace(regex, new_name).trim(), 'text_search');
|
|
1010
|
+
astSearchEdits++;
|
|
1011
|
+
regex.lastIndex = 0; // reset regex
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
catch { /* skip */ }
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
catch { /* rg not available or no additional matches */ }
|
|
1019
|
+
// Step 4: Apply or preview
|
|
1020
|
+
const allChanges = Array.from(changes.values());
|
|
1021
|
+
const totalEdits = allChanges.reduce((sum, c) => sum + c.edits.length, 0);
|
|
1022
|
+
if (!dry_run) {
|
|
1023
|
+
// Apply edits to files
|
|
1024
|
+
for (const change of allChanges) {
|
|
1025
|
+
try {
|
|
1026
|
+
const fullPath = path.join(repo.repoPath, change.file_path);
|
|
1027
|
+
let content = await fs.readFile(fullPath, 'utf-8');
|
|
1028
|
+
const regex = new RegExp(`\\b${oldName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'g');
|
|
1029
|
+
content = content.replace(regex, new_name);
|
|
1030
|
+
await fs.writeFile(fullPath, content, 'utf-8');
|
|
1031
|
+
}
|
|
1032
|
+
catch { /* skip failed files */ }
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
return {
|
|
1036
|
+
status: 'success',
|
|
1037
|
+
old_name: oldName,
|
|
1038
|
+
new_name,
|
|
1039
|
+
files_affected: allChanges.length,
|
|
1040
|
+
total_edits: totalEdits,
|
|
1041
|
+
graph_edits: graphEdits,
|
|
1042
|
+
text_search_edits: astSearchEdits,
|
|
1043
|
+
changes: allChanges,
|
|
1044
|
+
applied: !dry_run,
|
|
1045
|
+
};
|
|
1046
|
+
}
|
|
641
1047
|
async impact(repo, params) {
|
|
642
1048
|
await this.ensureInitialized(repo.id);
|
|
643
1049
|
const { target, direction } = params;
|
|
@@ -665,14 +1071,16 @@ export class LocalBackend {
|
|
|
665
1071
|
let frontier = [symId];
|
|
666
1072
|
for (let depth = 1; depth <= maxDepth && frontier.length > 0; depth++) {
|
|
667
1073
|
const nextFrontier = [];
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
1074
|
+
// Batch frontier nodes into a single Cypher query per depth level
|
|
1075
|
+
const idList = frontier.map(id => `'${id.replace(/'/g, "''")}'`).join(', ');
|
|
1076
|
+
const query = direction === 'upstream'
|
|
1077
|
+
? `MATCH (caller)-[r:CodeRelation]->(n) WHERE n.id IN [${idList}] AND r.type IN [${relTypeFilter}]${confidenceFilter} RETURN n.id AS sourceId, 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`
|
|
1078
|
+
: `MATCH (n)-[r:CodeRelation]->(callee) WHERE n.id IN [${idList}] AND r.type IN [${relTypeFilter}]${confidenceFilter} RETURN n.id AS sourceId, 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`;
|
|
1079
|
+
try {
|
|
672
1080
|
const related = await executeQuery(repo.id, query);
|
|
673
1081
|
for (const rel of related) {
|
|
674
|
-
const relId = rel.id || rel[
|
|
675
|
-
const filePath = rel.filePath || rel[
|
|
1082
|
+
const relId = rel.id || rel[1];
|
|
1083
|
+
const filePath = rel.filePath || rel[4] || '';
|
|
676
1084
|
if (!includeTests && isTestFilePath(filePath))
|
|
677
1085
|
continue;
|
|
678
1086
|
if (!visited.has(relId)) {
|
|
@@ -681,15 +1089,16 @@ export class LocalBackend {
|
|
|
681
1089
|
impacted.push({
|
|
682
1090
|
depth,
|
|
683
1091
|
id: relId,
|
|
684
|
-
name: rel.name || rel[
|
|
685
|
-
type: rel.type || rel[
|
|
1092
|
+
name: rel.name || rel[2],
|
|
1093
|
+
type: rel.type || rel[3],
|
|
686
1094
|
filePath,
|
|
687
|
-
relationType: rel.relType || rel[
|
|
688
|
-
confidence: rel.confidence || rel[
|
|
1095
|
+
relationType: rel.relType || rel[5],
|
|
1096
|
+
confidence: rel.confidence || rel[6] || 1.0,
|
|
689
1097
|
});
|
|
690
1098
|
}
|
|
691
1099
|
}
|
|
692
1100
|
}
|
|
1101
|
+
catch { /* query failed for this depth level */ }
|
|
693
1102
|
frontier = nextFrontier;
|
|
694
1103
|
}
|
|
695
1104
|
const grouped = {};
|
|
@@ -710,6 +1119,142 @@ export class LocalBackend {
|
|
|
710
1119
|
byDepth: grouped,
|
|
711
1120
|
};
|
|
712
1121
|
}
|
|
1122
|
+
// ─── Direct Graph Queries (for resources.ts) ────────────────────
|
|
1123
|
+
/**
|
|
1124
|
+
* Query clusters (communities) directly from graph.
|
|
1125
|
+
* Used by getClustersResource — avoids legacy overview() dispatch.
|
|
1126
|
+
*/
|
|
1127
|
+
async queryClusters(repoName, limit = 100) {
|
|
1128
|
+
const repo = this.resolveRepo(repoName);
|
|
1129
|
+
await this.ensureInitialized(repo.id);
|
|
1130
|
+
try {
|
|
1131
|
+
const rawLimit = Math.max(limit * 5, 200);
|
|
1132
|
+
const clusters = await executeQuery(repo.id, `
|
|
1133
|
+
MATCH (c:Community)
|
|
1134
|
+
RETURN c.id AS id, c.label AS label, c.heuristicLabel AS heuristicLabel, c.cohesion AS cohesion, c.symbolCount AS symbolCount
|
|
1135
|
+
ORDER BY c.symbolCount DESC
|
|
1136
|
+
LIMIT ${rawLimit}
|
|
1137
|
+
`);
|
|
1138
|
+
const rawClusters = clusters.map((c) => ({
|
|
1139
|
+
id: c.id || c[0],
|
|
1140
|
+
label: c.label || c[1],
|
|
1141
|
+
heuristicLabel: c.heuristicLabel || c[2],
|
|
1142
|
+
cohesion: c.cohesion || c[3],
|
|
1143
|
+
symbolCount: c.symbolCount || c[4],
|
|
1144
|
+
}));
|
|
1145
|
+
return { clusters: this.aggregateClusters(rawClusters).slice(0, limit) };
|
|
1146
|
+
}
|
|
1147
|
+
catch {
|
|
1148
|
+
return { clusters: [] };
|
|
1149
|
+
}
|
|
1150
|
+
}
|
|
1151
|
+
/**
|
|
1152
|
+
* Query processes directly from graph.
|
|
1153
|
+
* Used by getProcessesResource — avoids legacy overview() dispatch.
|
|
1154
|
+
*/
|
|
1155
|
+
async queryProcesses(repoName, limit = 50) {
|
|
1156
|
+
const repo = this.resolveRepo(repoName);
|
|
1157
|
+
await this.ensureInitialized(repo.id);
|
|
1158
|
+
try {
|
|
1159
|
+
const processes = await executeQuery(repo.id, `
|
|
1160
|
+
MATCH (p:Process)
|
|
1161
|
+
RETURN p.id AS id, p.label AS label, p.heuristicLabel AS heuristicLabel, p.processType AS processType, p.stepCount AS stepCount
|
|
1162
|
+
ORDER BY p.stepCount DESC
|
|
1163
|
+
LIMIT ${limit}
|
|
1164
|
+
`);
|
|
1165
|
+
return {
|
|
1166
|
+
processes: processes.map((p) => ({
|
|
1167
|
+
id: p.id || p[0],
|
|
1168
|
+
label: p.label || p[1],
|
|
1169
|
+
heuristicLabel: p.heuristicLabel || p[2],
|
|
1170
|
+
processType: p.processType || p[3],
|
|
1171
|
+
stepCount: p.stepCount || p[4],
|
|
1172
|
+
})),
|
|
1173
|
+
};
|
|
1174
|
+
}
|
|
1175
|
+
catch {
|
|
1176
|
+
return { processes: [] };
|
|
1177
|
+
}
|
|
1178
|
+
}
|
|
1179
|
+
/**
|
|
1180
|
+
* Query cluster detail (members) directly from graph.
|
|
1181
|
+
* Used by getClusterDetailResource.
|
|
1182
|
+
*/
|
|
1183
|
+
async queryClusterDetail(name, repoName) {
|
|
1184
|
+
const repo = this.resolveRepo(repoName);
|
|
1185
|
+
await this.ensureInitialized(repo.id);
|
|
1186
|
+
const escaped = name.replace(/'/g, "''");
|
|
1187
|
+
const clusterQuery = `
|
|
1188
|
+
MATCH (c:Community)
|
|
1189
|
+
WHERE c.label = '${escaped}' OR c.heuristicLabel = '${escaped}'
|
|
1190
|
+
RETURN c.id AS id, c.label AS label, c.heuristicLabel AS heuristicLabel, c.cohesion AS cohesion, c.symbolCount AS symbolCount
|
|
1191
|
+
`;
|
|
1192
|
+
const clusters = await executeQuery(repo.id, clusterQuery);
|
|
1193
|
+
if (clusters.length === 0)
|
|
1194
|
+
return { error: `Cluster '${name}' not found` };
|
|
1195
|
+
const rawClusters = clusters.map((c) => ({
|
|
1196
|
+
id: c.id || c[0], label: c.label || c[1], heuristicLabel: c.heuristicLabel || c[2],
|
|
1197
|
+
cohesion: c.cohesion || c[3], symbolCount: c.symbolCount || c[4],
|
|
1198
|
+
}));
|
|
1199
|
+
let totalSymbols = 0, weightedCohesion = 0;
|
|
1200
|
+
for (const c of rawClusters) {
|
|
1201
|
+
const s = c.symbolCount || 0;
|
|
1202
|
+
totalSymbols += s;
|
|
1203
|
+
weightedCohesion += (c.cohesion || 0) * s;
|
|
1204
|
+
}
|
|
1205
|
+
const members = await executeQuery(repo.id, `
|
|
1206
|
+
MATCH (n)-[:CodeRelation {type: 'MEMBER_OF'}]->(c:Community)
|
|
1207
|
+
WHERE c.label = '${escaped}' OR c.heuristicLabel = '${escaped}'
|
|
1208
|
+
RETURN DISTINCT n.name AS name, labels(n)[0] AS type, n.filePath AS filePath
|
|
1209
|
+
LIMIT 30
|
|
1210
|
+
`);
|
|
1211
|
+
return {
|
|
1212
|
+
cluster: {
|
|
1213
|
+
id: rawClusters[0].id,
|
|
1214
|
+
label: rawClusters[0].heuristicLabel || rawClusters[0].label,
|
|
1215
|
+
heuristicLabel: rawClusters[0].heuristicLabel || rawClusters[0].label,
|
|
1216
|
+
cohesion: totalSymbols > 0 ? weightedCohesion / totalSymbols : 0,
|
|
1217
|
+
symbolCount: totalSymbols,
|
|
1218
|
+
subCommunities: rawClusters.length,
|
|
1219
|
+
},
|
|
1220
|
+
members: members.map((m) => ({
|
|
1221
|
+
name: m.name || m[0], type: m.type || m[1], filePath: m.filePath || m[2],
|
|
1222
|
+
})),
|
|
1223
|
+
};
|
|
1224
|
+
}
|
|
1225
|
+
/**
|
|
1226
|
+
* Query process detail (steps) directly from graph.
|
|
1227
|
+
* Used by getProcessDetailResource.
|
|
1228
|
+
*/
|
|
1229
|
+
async queryProcessDetail(name, repoName) {
|
|
1230
|
+
const repo = this.resolveRepo(repoName);
|
|
1231
|
+
await this.ensureInitialized(repo.id);
|
|
1232
|
+
const escaped = name.replace(/'/g, "''");
|
|
1233
|
+
const processes = await executeQuery(repo.id, `
|
|
1234
|
+
MATCH (p:Process)
|
|
1235
|
+
WHERE p.label = '${escaped}' OR p.heuristicLabel = '${escaped}'
|
|
1236
|
+
RETURN p.id AS id, p.label AS label, p.heuristicLabel AS heuristicLabel, p.processType AS processType, p.stepCount AS stepCount
|
|
1237
|
+
LIMIT 1
|
|
1238
|
+
`);
|
|
1239
|
+
if (processes.length === 0)
|
|
1240
|
+
return { error: `Process '${name}' not found` };
|
|
1241
|
+
const proc = processes[0];
|
|
1242
|
+
const procId = proc.id || proc[0];
|
|
1243
|
+
const steps = await executeQuery(repo.id, `
|
|
1244
|
+
MATCH (n)-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p {id: '${procId}'})
|
|
1245
|
+
RETURN n.name AS name, labels(n)[0] AS type, n.filePath AS filePath, r.step AS step
|
|
1246
|
+
ORDER BY r.step
|
|
1247
|
+
`);
|
|
1248
|
+
return {
|
|
1249
|
+
process: {
|
|
1250
|
+
id: procId, label: proc.label || proc[1], heuristicLabel: proc.heuristicLabel || proc[2],
|
|
1251
|
+
processType: proc.processType || proc[3], stepCount: proc.stepCount || proc[4],
|
|
1252
|
+
},
|
|
1253
|
+
steps: steps.map((s) => ({
|
|
1254
|
+
step: s.step || s[3], name: s.name || s[0], type: s.type || s[1], filePath: s.filePath || s[2],
|
|
1255
|
+
})),
|
|
1256
|
+
};
|
|
1257
|
+
}
|
|
713
1258
|
async disconnect() {
|
|
714
1259
|
await closeKuzu(); // close all connections
|
|
715
1260
|
await disposeEmbedder();
|