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.
Files changed (76) hide show
  1. package/README.md +50 -59
  2. package/dist/cli/ai-context.js +9 -9
  3. package/dist/cli/analyze.js +139 -47
  4. package/dist/cli/augment.d.ts +13 -0
  5. package/dist/cli/augment.js +33 -0
  6. package/dist/cli/claude-hooks.d.ts +22 -0
  7. package/dist/cli/claude-hooks.js +97 -0
  8. package/dist/cli/eval-server.d.ts +30 -0
  9. package/dist/cli/eval-server.js +372 -0
  10. package/dist/cli/index.js +56 -1
  11. package/dist/cli/mcp.js +9 -0
  12. package/dist/cli/setup.js +184 -5
  13. package/dist/cli/tool.d.ts +37 -0
  14. package/dist/cli/tool.js +91 -0
  15. package/dist/cli/wiki.d.ts +13 -0
  16. package/dist/cli/wiki.js +199 -0
  17. package/dist/core/augmentation/engine.d.ts +26 -0
  18. package/dist/core/augmentation/engine.js +213 -0
  19. package/dist/core/embeddings/embedder.d.ts +2 -2
  20. package/dist/core/embeddings/embedder.js +11 -11
  21. package/dist/core/embeddings/embedding-pipeline.d.ts +2 -1
  22. package/dist/core/embeddings/embedding-pipeline.js +13 -5
  23. package/dist/core/embeddings/types.d.ts +2 -2
  24. package/dist/core/ingestion/call-processor.d.ts +7 -0
  25. package/dist/core/ingestion/call-processor.js +61 -23
  26. package/dist/core/ingestion/community-processor.js +34 -26
  27. package/dist/core/ingestion/filesystem-walker.js +15 -10
  28. package/dist/core/ingestion/heritage-processor.d.ts +6 -0
  29. package/dist/core/ingestion/heritage-processor.js +68 -5
  30. package/dist/core/ingestion/import-processor.d.ts +22 -0
  31. package/dist/core/ingestion/import-processor.js +215 -20
  32. package/dist/core/ingestion/parsing-processor.d.ts +8 -1
  33. package/dist/core/ingestion/parsing-processor.js +66 -25
  34. package/dist/core/ingestion/pipeline.js +104 -40
  35. package/dist/core/ingestion/process-processor.js +1 -1
  36. package/dist/core/ingestion/workers/parse-worker.d.ts +58 -0
  37. package/dist/core/ingestion/workers/parse-worker.js +451 -0
  38. package/dist/core/ingestion/workers/worker-pool.d.ts +22 -0
  39. package/dist/core/ingestion/workers/worker-pool.js +65 -0
  40. package/dist/core/kuzu/kuzu-adapter.d.ts +15 -1
  41. package/dist/core/kuzu/kuzu-adapter.js +177 -63
  42. package/dist/core/kuzu/schema.d.ts +1 -1
  43. package/dist/core/kuzu/schema.js +3 -0
  44. package/dist/core/search/bm25-index.js +13 -15
  45. package/dist/core/wiki/generator.d.ts +96 -0
  46. package/dist/core/wiki/generator.js +674 -0
  47. package/dist/core/wiki/graph-queries.d.ts +80 -0
  48. package/dist/core/wiki/graph-queries.js +238 -0
  49. package/dist/core/wiki/html-viewer.d.ts +10 -0
  50. package/dist/core/wiki/html-viewer.js +297 -0
  51. package/dist/core/wiki/llm-client.d.ts +36 -0
  52. package/dist/core/wiki/llm-client.js +111 -0
  53. package/dist/core/wiki/prompts.d.ts +53 -0
  54. package/dist/core/wiki/prompts.js +174 -0
  55. package/dist/mcp/core/embedder.js +4 -2
  56. package/dist/mcp/core/kuzu-adapter.d.ts +2 -1
  57. package/dist/mcp/core/kuzu-adapter.js +35 -15
  58. package/dist/mcp/local/local-backend.d.ts +54 -1
  59. package/dist/mcp/local/local-backend.js +716 -171
  60. package/dist/mcp/resources.d.ts +1 -1
  61. package/dist/mcp/resources.js +111 -73
  62. package/dist/mcp/server.d.ts +1 -1
  63. package/dist/mcp/server.js +91 -22
  64. package/dist/mcp/tools.js +80 -61
  65. package/dist/storage/git.d.ts +0 -1
  66. package/dist/storage/git.js +1 -8
  67. package/dist/storage/repo-manager.d.ts +17 -0
  68. package/dist/storage/repo-manager.js +26 -0
  69. package/hooks/claude/gitnexus-hook.cjs +135 -0
  70. package/hooks/claude/pre-tool-use.sh +78 -0
  71. package/hooks/claude/session-start.sh +42 -0
  72. package/package.json +4 -2
  73. package/skills/debugging.md +24 -22
  74. package/skills/exploring.md +26 -24
  75. package/skills/impact-analysis.md +19 -13
  76. 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
- await initKuzu(repoId, handle.kuzuPath);
135
- this.initializedRepos.add(repoId);
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 'search':
171
- return this.search(repo, params);
185
+ case 'query':
186
+ return this.query(repo, params);
172
187
  case 'cypher':
173
188
  return this.cypher(repo, params);
174
- case 'overview':
175
- return this.overview(repo, params);
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
- async search(repo, params) {
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 limit = params.limit || 10;
188
- const query = params.query;
189
- const depth = params.depth || 'definitions';
190
- // Run BM25 and semantic search in parallel
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, query, limit * 2),
193
- this.semanticSearch(repo, query, limit * 2),
229
+ this.bm25Search(repo, searchQuery, searchLimit),
230
+ this.semanticSearch(repo, searchQuery, searchLimit),
194
231
  ]);
195
- // Merge and deduplicate results using reciprocal rank fusion
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, source: 'bm25', data: result });
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, source: 'semantic', data: result });
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, limit);
231
- // Enrich with graph data
232
- const results = [];
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 result = item.data;
235
- result.searchSource = item.source;
236
- result.fusedScore = item.score;
237
- // Add cluster membership context for each result with a nodeId
238
- if (result.nodeId) {
239
- try {
240
- const clusterQuery = `
241
- MATCH (n {id: '${result.nodeId.replace(/'/g, "''")}'})-[:CodeRelation {type: 'MEMBER_OF'}]->(c:Community)
242
- RETURN c.label AS label, c.heuristicLabel AS heuristicLabel
243
- LIMIT 1
244
- `;
245
- const clusters = await executeQuery(repo.id, clusterQuery);
246
- if (clusters.length > 0) {
247
- result.cluster = {
248
- label: clusters[0].label || clusters[0][0],
249
- heuristicLabel: clusters[0].heuristicLabel || clusters[0][1],
250
- };
251
- }
252
- }
253
- catch {
254
- // Cluster lookup failed - continue without it
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
- // Add relationships if depth is 'full' and we have a node ID
258
- // Only include connections with actual name/path data (skip MEMBER_OF, STEP_IN_PROCESS noise)
259
- if (depth === 'full' && result.nodeId) {
297
+ catch { /* no cluster info */ }
298
+ // Optionally fetch content
299
+ let content;
300
+ if (includeContent) {
260
301
  try {
261
- const relQuery = `
262
- MATCH (n {id: '${result.nodeId.replace(/'/g, "''")}'})-[r:CodeRelation]->(m)
263
- WHERE r.type IN ['CALLS', 'IMPORTS', 'DEFINES', 'EXTENDS', 'IMPLEMENTS']
264
- RETURN r.type AS type, m.name AS targetName, m.filePath AS targetPath
265
- LIMIT 5
266
- `;
267
- const rels = await executeQuery(repo.id, relQuery);
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
- result.connections = [];
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
- return results;
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
- const bm25Results = await searchFTSFromKuzu(query, limit, repo.id);
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: '${nodeId.replace(/'/g, "''")}'}) RETURN n.name AS name, n.filePath AS filePath`
363
- : `MATCH (n:${label} {id: '${nodeId.replace(/'/g, "''")}'}) RETURN n.name AS name, n.filePath AS filePath, n.startLine AS startLine, n.endLine AS endLine`;
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
- async explore(repo, params) {
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, type } = params;
494
- if (type === 'symbol') {
495
- // If name contains a path separator or ':', treat it as a qualified lookup
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
- const symbolQuery = isQualified
498
- ? `MATCH (n) WHERE n.id = '${name.replace(/'/g, "''")}' OR (n.name = '${name.replace(/'/g, "''")}')
499
- 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
500
- LIMIT 5`
501
- : `MATCH (n) WHERE n.name = '${name.replace(/'/g, "''")}'
502
- 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
503
- LIMIT 5`;
504
- const symbols = await executeQuery(repo.id, symbolQuery);
505
- if (symbols.length === 0)
506
- return { error: `Symbol '${name}' not found` };
507
- // Use the first match for detailed exploration
508
- const sym = symbols[0];
509
- const symId = sym.id || sym[0];
510
- const callersQuery = `
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
- const callees = await executeQuery(repo.id, calleesQuery);
522
- const communityQuery = `
523
- MATCH (n {id: '${symId}'})-[:CodeRelation {type: 'MEMBER_OF'}]->(c:Community)
524
- RETURN c.label AS label, c.heuristicLabel AS heuristicLabel
525
- LIMIT 1
526
- `;
527
- const communities = await executeQuery(repo.id, communityQuery);
528
- const result = {
529
- symbol: {
530
- id: symId,
531
- name: sym.name || sym[1],
532
- type: sym.type || sym[2],
533
- filePath: sym.filePath || sym[3],
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
- result.hint = `Multiple symbols named '${name}' found. Showing details for ${result.symbol.filePath}. Use the full node ID to explore a specific alternative.`;
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 result;
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
- label: c.label || c[1],
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
- // Aggregate: sum symbols, weighted-average cohesion across sub-communities
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
- // Fetch members from ALL matching sub-communities (DISTINCT to avoid dupes)
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 processQuery = `
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, p.entryPointId AS entryPointId, p.terminalId AS terminalId
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 stepsQuery = `
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
- label: proc.label || proc[1],
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
- for (const nodeId of frontier) {
669
- const query = direction === 'upstream'
670
- ? `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`
671
- : `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`;
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[0];
675
- const filePath = rel.filePath || rel[3] || '';
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[1],
685
- type: rel.type || rel[2],
1092
+ name: rel.name || rel[2],
1093
+ type: rel.type || rel[3],
686
1094
  filePath,
687
- relationType: rel.relType || rel[4],
688
- confidence: rel.confidence || rel[5] || 1.0,
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();