@veewo/gitnexus 1.3.11 → 1.4.7-rc

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 (181) hide show
  1. package/README.md +37 -80
  2. package/dist/benchmark/agent-context/tool-runner.js +2 -2
  3. package/dist/benchmark/neonspark-candidates.js +3 -3
  4. package/dist/benchmark/tool-runner.js +2 -2
  5. package/dist/cli/ai-context.d.ts +2 -1
  6. package/dist/cli/ai-context.js +16 -12
  7. package/dist/cli/analyze.d.ts +2 -0
  8. package/dist/cli/analyze.js +68 -48
  9. package/dist/cli/augment.js +1 -1
  10. package/dist/cli/eval-server.d.ts +8 -1
  11. package/dist/cli/eval-server.js +30 -13
  12. package/dist/cli/index.js +28 -82
  13. package/dist/cli/lazy-action.d.ts +6 -0
  14. package/dist/cli/lazy-action.js +18 -0
  15. package/dist/cli/mcp.js +3 -1
  16. package/dist/cli/setup.js +87 -48
  17. package/dist/cli/setup.test.js +18 -13
  18. package/dist/cli/skill-gen.d.ts +26 -0
  19. package/dist/cli/skill-gen.js +549 -0
  20. package/dist/cli/status.js +13 -4
  21. package/dist/cli/tool.d.ts +3 -2
  22. package/dist/cli/tool.js +50 -16
  23. package/dist/cli/wiki.js +8 -4
  24. package/dist/config/ignore-service.d.ts +25 -0
  25. package/dist/config/ignore-service.js +76 -0
  26. package/dist/config/supported-languages.d.ts +4 -1
  27. package/dist/config/supported-languages.js +3 -2
  28. package/dist/core/augmentation/engine.js +94 -67
  29. package/dist/core/embeddings/embedder.d.ts +1 -1
  30. package/dist/core/embeddings/embedder.js +1 -1
  31. package/dist/core/embeddings/embedding-pipeline.d.ts +3 -3
  32. package/dist/core/embeddings/embedding-pipeline.js +52 -25
  33. package/dist/core/embeddings/types.d.ts +1 -1
  34. package/dist/core/graph/types.d.ts +7 -2
  35. package/dist/core/ingestion/ast-cache.js +3 -2
  36. package/dist/core/ingestion/call-processor.d.ts +8 -6
  37. package/dist/core/ingestion/call-processor.js +468 -206
  38. package/dist/core/ingestion/call-routing.d.ts +53 -0
  39. package/dist/core/ingestion/call-routing.js +108 -0
  40. package/dist/core/ingestion/constants.d.ts +16 -0
  41. package/dist/core/ingestion/constants.js +16 -0
  42. package/dist/core/ingestion/entry-point-scoring.d.ts +2 -1
  43. package/dist/core/ingestion/entry-point-scoring.js +116 -23
  44. package/dist/core/ingestion/export-detection.d.ts +18 -0
  45. package/dist/core/ingestion/export-detection.js +231 -0
  46. package/dist/core/ingestion/filesystem-walker.js +4 -3
  47. package/dist/core/ingestion/framework-detection.d.ts +19 -4
  48. package/dist/core/ingestion/framework-detection.js +182 -6
  49. package/dist/core/ingestion/heritage-processor.d.ts +13 -5
  50. package/dist/core/ingestion/heritage-processor.js +109 -55
  51. package/dist/core/ingestion/import-processor.d.ts +16 -20
  52. package/dist/core/ingestion/import-processor.js +199 -579
  53. package/dist/core/ingestion/language-config.d.ts +46 -0
  54. package/dist/core/ingestion/language-config.js +167 -0
  55. package/dist/core/ingestion/mro-processor.d.ts +45 -0
  56. package/dist/core/ingestion/mro-processor.js +369 -0
  57. package/dist/core/ingestion/named-binding-extraction.d.ts +61 -0
  58. package/dist/core/ingestion/named-binding-extraction.js +363 -0
  59. package/dist/core/ingestion/parsing-processor.d.ts +4 -1
  60. package/dist/core/ingestion/parsing-processor.js +107 -109
  61. package/dist/core/ingestion/pipeline.d.ts +6 -3
  62. package/dist/core/ingestion/pipeline.js +208 -114
  63. package/dist/core/ingestion/process-processor.js +8 -2
  64. package/dist/core/ingestion/resolution-context.d.ts +53 -0
  65. package/dist/core/ingestion/resolution-context.js +132 -0
  66. package/dist/core/ingestion/resolvers/csharp.d.ts +22 -0
  67. package/dist/core/ingestion/resolvers/csharp.js +109 -0
  68. package/dist/core/ingestion/resolvers/go.d.ts +19 -0
  69. package/dist/core/ingestion/resolvers/go.js +42 -0
  70. package/dist/core/ingestion/resolvers/index.d.ts +18 -0
  71. package/dist/core/ingestion/resolvers/index.js +13 -0
  72. package/dist/core/ingestion/resolvers/jvm.d.ts +23 -0
  73. package/dist/core/ingestion/resolvers/jvm.js +87 -0
  74. package/dist/core/ingestion/resolvers/php.d.ts +15 -0
  75. package/dist/core/ingestion/resolvers/php.js +35 -0
  76. package/dist/core/ingestion/resolvers/python.d.ts +19 -0
  77. package/dist/core/ingestion/resolvers/python.js +52 -0
  78. package/dist/core/ingestion/resolvers/ruby.d.ts +12 -0
  79. package/dist/core/ingestion/resolvers/ruby.js +15 -0
  80. package/dist/core/ingestion/resolvers/rust.d.ts +15 -0
  81. package/dist/core/ingestion/resolvers/rust.js +73 -0
  82. package/dist/core/ingestion/resolvers/standard.d.ts +28 -0
  83. package/dist/core/ingestion/resolvers/standard.js +123 -0
  84. package/dist/core/ingestion/resolvers/utils.d.ts +33 -0
  85. package/dist/core/ingestion/resolvers/utils.js +122 -0
  86. package/dist/core/ingestion/symbol-table.d.ts +21 -1
  87. package/dist/core/ingestion/symbol-table.js +40 -12
  88. package/dist/core/ingestion/tree-sitter-queries.d.ts +13 -10
  89. package/dist/core/ingestion/tree-sitter-queries.js +297 -7
  90. package/dist/core/ingestion/type-env.d.ts +49 -0
  91. package/dist/core/ingestion/type-env.js +611 -0
  92. package/dist/core/ingestion/type-extractors/c-cpp.d.ts +2 -0
  93. package/dist/core/ingestion/type-extractors/c-cpp.js +385 -0
  94. package/dist/core/ingestion/type-extractors/csharp.d.ts +2 -0
  95. package/dist/core/ingestion/type-extractors/csharp.js +383 -0
  96. package/dist/core/ingestion/type-extractors/go.d.ts +2 -0
  97. package/dist/core/ingestion/type-extractors/go.js +467 -0
  98. package/dist/core/ingestion/type-extractors/index.d.ts +22 -0
  99. package/dist/core/ingestion/type-extractors/index.js +31 -0
  100. package/dist/core/ingestion/type-extractors/jvm.d.ts +3 -0
  101. package/dist/core/ingestion/type-extractors/jvm.js +681 -0
  102. package/dist/core/ingestion/type-extractors/php.d.ts +2 -0
  103. package/dist/core/ingestion/type-extractors/php.js +549 -0
  104. package/dist/core/ingestion/type-extractors/python.d.ts +2 -0
  105. package/dist/core/ingestion/type-extractors/python.js +406 -0
  106. package/dist/core/ingestion/type-extractors/ruby.d.ts +2 -0
  107. package/dist/core/ingestion/type-extractors/ruby.js +389 -0
  108. package/dist/core/ingestion/type-extractors/rust.d.ts +2 -0
  109. package/dist/core/ingestion/type-extractors/rust.js +449 -0
  110. package/dist/core/ingestion/type-extractors/shared.d.ts +133 -0
  111. package/dist/core/ingestion/type-extractors/shared.js +703 -0
  112. package/dist/core/ingestion/type-extractors/swift.d.ts +2 -0
  113. package/dist/core/ingestion/type-extractors/swift.js +137 -0
  114. package/dist/core/ingestion/type-extractors/types.d.ts +127 -0
  115. package/dist/core/ingestion/type-extractors/typescript.d.ts +2 -0
  116. package/dist/core/ingestion/type-extractors/typescript.js +494 -0
  117. package/dist/core/ingestion/utils.d.ts +103 -0
  118. package/dist/core/ingestion/utils.js +1085 -4
  119. package/dist/core/ingestion/workers/parse-worker.d.ts +51 -4
  120. package/dist/core/ingestion/workers/parse-worker.js +634 -222
  121. package/dist/core/ingestion/workers/worker-pool.js +8 -0
  122. package/dist/core/{kuzu → lbug}/csv-generator.d.ts +12 -10
  123. package/dist/core/{kuzu → lbug}/csv-generator.js +82 -101
  124. package/dist/core/{kuzu/kuzu-adapter.d.ts → lbug/lbug-adapter.d.ts} +20 -25
  125. package/dist/core/{kuzu/kuzu-adapter.js → lbug/lbug-adapter.js} +150 -122
  126. package/dist/core/{kuzu → lbug}/schema.d.ts +4 -4
  127. package/dist/core/{kuzu → lbug}/schema.js +23 -22
  128. package/dist/core/lbug/schema.test.d.ts +1 -0
  129. package/dist/core/search/bm25-index.d.ts +4 -4
  130. package/dist/core/search/bm25-index.js +12 -11
  131. package/dist/core/search/hybrid-search.d.ts +2 -2
  132. package/dist/core/search/hybrid-search.js +6 -6
  133. package/dist/core/tree-sitter/parser-loader.d.ts +1 -0
  134. package/dist/core/tree-sitter/parser-loader.js +19 -0
  135. package/dist/core/wiki/generator.d.ts +2 -2
  136. package/dist/core/wiki/generator.js +6 -6
  137. package/dist/core/wiki/graph-queries.d.ts +4 -4
  138. package/dist/core/wiki/graph-queries.js +7 -7
  139. package/dist/mcp/compatible-stdio-transport.d.ts +25 -0
  140. package/dist/mcp/compatible-stdio-transport.js +200 -0
  141. package/dist/mcp/core/{kuzu-adapter.d.ts → lbug-adapter.d.ts} +11 -10
  142. package/dist/mcp/core/lbug-adapter.js +327 -0
  143. package/dist/mcp/local/local-backend.d.ts +21 -16
  144. package/dist/mcp/local/local-backend.js +306 -706
  145. package/dist/mcp/local/unity-parity-seed-loader.d.ts +6 -1
  146. package/dist/mcp/local/unity-parity-seed-loader.js +119 -9
  147. package/dist/mcp/local/unity-parity-seed-loader.test.js +95 -7
  148. package/dist/mcp/resources.js +2 -2
  149. package/dist/mcp/server.js +28 -13
  150. package/dist/mcp/staleness.js +2 -2
  151. package/dist/mcp/tools.js +12 -3
  152. package/dist/server/api.js +12 -12
  153. package/dist/server/mcp-http.d.ts +1 -1
  154. package/dist/server/mcp-http.js +1 -1
  155. package/dist/storage/git.js +4 -1
  156. package/dist/storage/repo-manager.d.ts +20 -2
  157. package/dist/storage/repo-manager.js +74 -4
  158. package/dist/types/pipeline.d.ts +1 -1
  159. package/hooks/claude/gitnexus-hook.cjs +149 -46
  160. package/hooks/claude/pre-tool-use.sh +2 -1
  161. package/hooks/claude/session-start.sh +0 -0
  162. package/package.json +20 -4
  163. package/scripts/patch-tree-sitter-swift.cjs +74 -0
  164. package/skills/gitnexus-cli.md +8 -8
  165. package/skills/gitnexus-debugging.md +1 -1
  166. package/skills/gitnexus-exploring.md +1 -1
  167. package/skills/gitnexus-guide.md +1 -1
  168. package/skills/gitnexus-impact-analysis.md +1 -1
  169. package/skills/gitnexus-pr-review.md +163 -0
  170. package/skills/gitnexus-refactoring.md +1 -1
  171. package/dist/cli/claude-hooks.d.ts +0 -22
  172. package/dist/cli/claude-hooks.js +0 -97
  173. package/dist/mcp/core/kuzu-adapter.js +0 -231
  174. /package/dist/core/{kuzu/csv-generator.test.d.ts → ingestion/type-extractors/types.js} +0 -0
  175. /package/dist/core/{kuzu/relationship-pair-buckets.test.d.ts → lbug/csv-generator.test.d.ts} +0 -0
  176. /package/dist/core/{kuzu → lbug}/csv-generator.test.js +0 -0
  177. /package/dist/core/{kuzu → lbug}/relationship-pair-buckets.d.ts +0 -0
  178. /package/dist/core/{kuzu → lbug}/relationship-pair-buckets.js +0 -0
  179. /package/dist/core/{kuzu/schema.test.d.ts → lbug/relationship-pair-buckets.test.d.ts} +0 -0
  180. /package/dist/core/{kuzu → lbug}/relationship-pair-buckets.test.js +0 -0
  181. /package/dist/core/{kuzu → lbug}/schema.test.js +0 -0
@@ -3,39 +3,30 @@
3
3
  *
4
4
  * Provides tool implementations using local .gitnexus/ indexes.
5
5
  * Supports multiple indexed repositories via a global registry.
6
- * KuzuDB connections are opened lazily per repo on first query.
6
+ * LadybugDB connections are opened lazily per repo on first query.
7
7
  */
8
8
  import fs from 'fs/promises';
9
9
  import path from 'path';
10
- import { initKuzu, executeQuery, closeKuzu, isKuzuReady } from '../core/kuzu-adapter.js';
11
- import { parseUnityHydrationMode, parseUnityResourcesMode } from '../../core/unity/options.js';
12
- import { buildUnityScanContext, buildUnityScanContextFromSeed } from '../../core/unity/scan-context.js';
13
- import { resolveUnityBindings } from '../../core/unity/resolver.js';
14
- import { formatLazyHydrationBudgetDiagnostic, loadUnityContext, } from './unity-enrichment.js';
15
- import { resolveUnityLazyConfig } from './unity-lazy-config.js';
16
- import { hydrateLazyBindings } from './unity-lazy-hydrator.js';
17
- import { readUnityOverlayBindings, upsertUnityOverlayBindings } from './unity-lazy-overlay.js';
18
- import { readUnityParityCache, upsertUnityParityCache } from './unity-parity-cache.js';
19
- import { createParityWarmupQueue } from './unity-parity-warmup-queue.js';
20
- import { loadUnityParitySeed } from './unity-parity-seed-loader.js';
10
+ import { initLbug, executeQuery, executeParameterized, closeLbug, isLbugReady } from '../core/lbug-adapter.js';
21
11
  // Embedding imports are lazy (dynamic import) to avoid loading onnxruntime-node
22
12
  // at MCP server startup — crashes on unsupported Node ABI versions (#89)
23
13
  // git utilities available if needed
24
14
  // import { isGitRepo, getCurrentCommit, getGitRoot } from '../../storage/git.js';
25
- import { listRegisteredRepos, } from '../../storage/repo-manager.js';
15
+ import { listRegisteredRepos, cleanupOldKuzuFiles, } from '../../storage/repo-manager.js';
26
16
  // AI context generation is CLI-only (gitnexus analyze)
27
17
  // import { generateAIContextFiles } from '../../cli/ai-context.js';
28
18
  /**
29
19
  * Quick test-file detection for filtering impact results.
30
20
  * Matches common test file patterns across all supported languages.
31
21
  */
32
- function isTestFilePath(filePath) {
22
+ export function isTestFilePath(filePath) {
33
23
  const p = filePath.toLowerCase().replace(/\\/g, '/');
34
24
  return (p.includes('.test.') || p.includes('.spec.') ||
35
25
  p.includes('__tests__/') || p.includes('__mocks__/') ||
36
26
  p.includes('/test/') || p.includes('/tests/') ||
37
27
  p.includes('/testing/') || p.includes('/fixtures/') ||
38
28
  p.endsWith('_test.go') || p.endsWith('_test.py') ||
29
+ p.endsWith('_spec.rb') || p.endsWith('_test.rb') || p.includes('/spec/') ||
39
30
  p.includes('/test_') || p.includes('/conftest.'));
40
31
  }
41
32
  function normalizePath(filePath) {
@@ -93,7 +84,7 @@ export function attachUnityHydrationMeta(payload, input) {
93
84
  if (hasExpandableBindings) {
94
85
  reasons.push('lightweight_bindings_remaining');
95
86
  }
96
- if (payload.unityDiagnostics.some((diag) => /budget exceeded/i.test(String(diag || '')))) {
87
+ if ((payload.unityDiagnostics || []).some((diag) => /budget exceeded/i.test(String(diag || '')))) {
97
88
  reasons.push('budget_exceeded');
98
89
  }
99
90
  const isComplete = reasons.length === 0;
@@ -111,40 +102,25 @@ export function attachUnityHydrationMeta(payload, input) {
111
102
  },
112
103
  };
113
104
  }
114
- const inFlightParityHydration = new Map();
115
- const parityWarmupQueue = createParityWarmupQueue({
116
- maxParallel: resolveParityWarmupMaxParallel(process.env),
117
- });
118
- function resolveParityWarmupMaxParallel(env) {
119
- const raw = String(env.GITNEXUS_UNITY_PARITY_WARMUP_MAX_PARALLEL || '').trim();
120
- const parsed = Number.parseInt(raw, 10);
121
- if (Number.isFinite(parsed) && parsed > 0) {
122
- return parsed;
123
- }
124
- return 2;
125
- }
126
- /** Valid KuzuDB node labels for safe Cypher query construction */
127
- const VALID_NODE_LABELS = new Set([
105
+ /** Valid LadybugDB node labels for safe Cypher query construction */
106
+ export const VALID_NODE_LABELS = new Set([
128
107
  'File', 'Folder', 'Function', 'Class', 'Interface', 'Method', 'CodeElement',
129
108
  'Community', 'Process', 'Struct', 'Enum', 'Macro', 'Typedef', 'Union',
130
109
  'Namespace', 'Trait', 'Impl', 'TypeAlias', 'Const', 'Static', 'Property',
131
110
  'Record', 'Delegate', 'Annotation', 'Constructor', 'Template', 'Module',
132
111
  ]);
133
- function inferTypeFromId(id) {
134
- if (typeof id !== 'string' || id.length === 0)
135
- return undefined;
136
- const firstColon = id.indexOf(':');
137
- if (firstColon <= 0)
138
- return undefined;
139
- return id.slice(0, firstColon);
112
+ /** Valid relation types for impact analysis filtering */
113
+ export const VALID_RELATION_TYPES = new Set(['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS', 'HAS_METHOD', 'OVERRIDES']);
114
+ /** Regex to detect write operations in user-supplied Cypher queries */
115
+ export const CYPHER_WRITE_RE = /\b(CREATE|DELETE|SET|MERGE|REMOVE|DROP|ALTER|COPY|DETACH)\b/i;
116
+ /** Check if a Cypher query contains write operations */
117
+ export function isWriteQuery(query) {
118
+ return CYPHER_WRITE_RE.test(query);
140
119
  }
141
- function getRowValue(row, key, index) {
142
- if (!row || typeof row !== 'object')
143
- return undefined;
144
- const named = row[key];
145
- if (named !== undefined)
146
- return named;
147
- return row[index];
120
+ /** Structured error logging for query failures — replaces empty catch blocks */
121
+ function logQueryError(context, err) {
122
+ const msg = err instanceof Error ? err.message : String(err);
123
+ console.error(`GitNexus [${context}]: ${msg}`);
148
124
  }
149
125
  export class LocalBackend {
150
126
  repos = new Map();
@@ -162,7 +138,7 @@ export class LocalBackend {
162
138
  /**
163
139
  * Re-read the global registry and update the in-memory repo map.
164
140
  * New repos are added, existing repos are updated, removed repos are pruned.
165
- * KuzuDB connections for removed repos are NOT closed (they idle-timeout naturally).
141
+ * LadybugDB connections for removed repos are NOT closed (they idle-timeout naturally).
166
142
  */
167
143
  async refreshRepos() {
168
144
  const entries = await listRegisteredRepos({ validate: true });
@@ -171,19 +147,25 @@ export class LocalBackend {
171
147
  const id = this.repoId(entry.name, entry.path);
172
148
  freshIds.add(id);
173
149
  const storagePath = entry.storagePath;
174
- const kuzuPath = path.join(storagePath, 'kuzu');
150
+ const lbugPath = path.join(storagePath, 'lbug');
151
+ // Clean up any leftover KuzuDB files from before the LadybugDB migration.
152
+ // If kuzu exists but lbug doesn't, warn so the user knows to re-analyze.
153
+ const kuzu = await cleanupOldKuzuFiles(storagePath);
154
+ if (kuzu.found && kuzu.needsReindex) {
155
+ console.error(`GitNexus: "${entry.name}" has a stale KuzuDB index. Run: gitnexus analyze ${entry.path}`);
156
+ }
175
157
  const handle = {
176
158
  id,
177
159
  name: entry.name,
178
160
  repoPath: entry.path,
179
161
  storagePath,
180
- kuzuPath,
162
+ lbugPath,
181
163
  indexedAt: entry.indexedAt,
182
164
  lastCommit: entry.lastCommit,
183
165
  stats: entry.stats,
184
166
  };
185
167
  this.repos.set(id, handle);
186
- // Build lightweight context (no KuzuDB needed)
168
+ // Build lightweight context (no LadybugDB needed)
187
169
  const s = entry.stats || {};
188
170
  this.contextCache.set(id, {
189
171
  projectName: entry.name,
@@ -284,16 +266,16 @@ export class LocalBackend {
284
266
  }
285
267
  return null; // Multiple repos, no param — ambiguous
286
268
  }
287
- // ─── Lazy KuzuDB Init ────────────────────────────────────────────
269
+ // ─── Lazy LadybugDB Init ────────────────────────────────────────────
288
270
  async ensureInitialized(repoId) {
289
271
  // Always check the actual pool — the idle timer may have evicted the connection
290
- if (this.initializedRepos.has(repoId) && isKuzuReady(repoId))
272
+ if (this.initializedRepos.has(repoId) && isLbugReady(repoId))
291
273
  return;
292
274
  const handle = this.repos.get(repoId);
293
275
  if (!handle)
294
276
  throw new Error(`Unknown repo: ${repoId}`);
295
277
  try {
296
- await initKuzu(repoId, handle.kuzuPath);
278
+ await initLbug(repoId, handle.lbugPath);
297
279
  this.initializedRepos.add(repoId);
298
280
  }
299
281
  catch (err) {
@@ -380,8 +362,6 @@ export class LocalBackend {
380
362
  const processLimit = params.limit || 5;
381
363
  const maxSymbolsPerProcess = params.max_symbols || 10;
382
364
  const includeContent = params.include_content ?? false;
383
- const unityResourcesMode = parseUnityResourcesMode(params.unity_resources);
384
- const unityHydrationMode = parseUnityHydrationMode(params.unity_hydration_mode);
385
365
  const searchQuery = params.query.trim();
386
366
  // Step 1: Run hybrid search to get matching symbols
387
367
  const searchLimit = processLimit * maxSymbolsPerProcess; // fetch enough raw results
@@ -432,44 +412,49 @@ export class LocalBackend {
432
412
  });
433
413
  continue;
434
414
  }
435
- const escaped = sym.nodeId.replace(/'/g, "''");
436
415
  // Find processes this symbol participates in
437
416
  let processRows = [];
438
417
  try {
439
- processRows = await executeQuery(repo.id, `
440
- MATCH (n {id: '${escaped}'})-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p:Process)
418
+ processRows = await executeParameterized(repo.id, `
419
+ MATCH (n {id: $nodeId})-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p:Process)
441
420
  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
442
- `);
421
+ `, { nodeId: sym.nodeId });
422
+ }
423
+ catch (e) {
424
+ logQueryError('query:process-lookup', e);
443
425
  }
444
- catch { /* symbol might not be in any process */ }
445
426
  // Get cluster membership + cohesion (cohesion used as internal ranking signal)
446
427
  let cohesion = 0;
447
428
  let module;
448
429
  try {
449
- const cohesionRows = await executeQuery(repo.id, `
450
- MATCH (n {id: '${escaped}'})-[:CodeRelation {type: 'MEMBER_OF'}]->(c:Community)
430
+ const cohesionRows = await executeParameterized(repo.id, `
431
+ MATCH (n {id: $nodeId})-[:CodeRelation {type: 'MEMBER_OF'}]->(c:Community)
451
432
  RETURN c.cohesion AS cohesion, c.heuristicLabel AS module
452
433
  LIMIT 1
453
- `);
434
+ `, { nodeId: sym.nodeId });
454
435
  if (cohesionRows.length > 0) {
455
436
  cohesion = (cohesionRows[0].cohesion ?? cohesionRows[0][0]) || 0;
456
437
  module = cohesionRows[0].module ?? cohesionRows[0][1];
457
438
  }
458
439
  }
459
- catch { /* no cluster info */ }
440
+ catch (e) {
441
+ logQueryError('query:cluster-info', e);
442
+ }
460
443
  // Optionally fetch content
461
444
  let content;
462
445
  if (includeContent) {
463
446
  try {
464
- const contentRows = await executeQuery(repo.id, `
465
- MATCH (n {id: '${escaped}'})
447
+ const contentRows = await executeParameterized(repo.id, `
448
+ MATCH (n {id: $nodeId})
466
449
  RETURN n.content AS content
467
- `);
450
+ `, { nodeId: sym.nodeId });
468
451
  if (contentRows.length > 0) {
469
452
  content = contentRows[0].content ?? contentRows[0][0];
470
453
  }
471
454
  }
472
- catch { /* skip */ }
455
+ catch (e) {
456
+ logQueryError('query:content-fetch', e);
457
+ }
473
458
  }
474
459
  const symbolEntry = {
475
460
  id: sym.nodeId,
@@ -480,15 +465,6 @@ export class LocalBackend {
480
465
  endLine: sym.endLine,
481
466
  ...(module ? { module } : {}),
482
467
  ...(includeContent && content ? { content } : {}),
483
- ...((unityResourcesMode !== 'off' && sym.nodeId && sym.type === 'Class')
484
- ? await this.hydrateUnityContext(repo, {
485
- symbolUid: sym.nodeId,
486
- symbolName: sym.name || '',
487
- symbolFilePath: sym.filePath || '',
488
- payload: await loadUnityContext(repo.id, sym.nodeId, (query) => executeQuery(repo.id, query)),
489
- hydrationMode: unityHydrationMode,
490
- })
491
- : {}),
492
468
  };
493
469
  if (processRows.length === 0) {
494
470
  // Symbol not in any process — goes to definitions
@@ -562,117 +538,62 @@ export class LocalBackend {
562
538
  };
563
539
  }
564
540
  /**
565
- * BM25 keyword search helper - uses KuzuDB FTS for always-fresh results
541
+ * BM25 keyword search helper - uses LadybugDB FTS for always-fresh results
566
542
  */
567
543
  async bm25Search(repo, query, limit) {
568
- const escapedQuery = query.replace(/'/g, "''");
569
- const symbolTables = [
570
- { table: 'Function', index: 'function_fts', type: 'Function' },
571
- { table: 'Class', index: 'class_fts', type: 'Class' },
572
- { table: 'Method', index: 'method_fts', type: 'Method' },
573
- { table: 'Interface', index: 'interface_fts', type: 'Interface' },
574
- ];
575
- const symbolHits = [];
576
- for (const spec of symbolTables) {
577
- try {
578
- const rows = await executeQuery(repo.id, `
579
- CALL QUERY_FTS_INDEX('${spec.table}', '${spec.index}', '${escapedQuery}', conjunctive := false)
580
- RETURN node.id AS id, node.name AS name, node.filePath AS filePath, node.startLine AS startLine, node.endLine AS endLine, score AS score
581
- ORDER BY score DESC
582
- LIMIT ${limit}
583
- `);
584
- for (const row of rows) {
585
- const nodeId = getRowValue(row, 'id', 0);
586
- if (!nodeId)
587
- continue;
588
- symbolHits.push({
589
- nodeId,
590
- name: getRowValue(row, 'name', 1) || '',
591
- type: spec.type,
592
- filePath: getRowValue(row, 'filePath', 2) || '',
593
- startLine: getRowValue(row, 'startLine', 3),
594
- endLine: getRowValue(row, 'endLine', 4),
595
- bm25Score: Number(getRowValue(row, 'score', 5) ?? 0),
596
- });
597
- }
598
- }
599
- catch {
600
- // Missing FTS index for this table is expected in some repos/languages.
601
- }
544
+ const { searchFTSFromLbug } = await import('../../core/search/bm25-index.js');
545
+ let bm25Results;
546
+ try {
547
+ bm25Results = await searchFTSFromLbug(query, limit, repo.id);
602
548
  }
603
- const rawTokens = query
604
- .toLowerCase()
605
- .split(/[^a-z0-9_]+/)
606
- .filter(Boolean);
607
- const stopTokens = new Set(['class', 'method', 'function', 'interface', 'file', 'symbol']);
608
- const meaningfulTokens = rawTokens.filter(t => t.length >= 4 && !stopTokens.has(t));
609
- // Add exact-name hits so symbol lookup queries rank correctly.
610
- for (const token of meaningfulTokens) {
549
+ catch (err) {
550
+ console.error('GitNexus: BM25/FTS search failed (FTS indexes may not exist) -', err.message);
551
+ return [];
552
+ }
553
+ const results = [];
554
+ for (const bm25Result of bm25Results) {
555
+ const fullPath = bm25Result.filePath;
611
556
  try {
612
- const exactRows = await executeQuery(repo.id, `
557
+ const symbols = await executeParameterized(repo.id, `
613
558
  MATCH (n)
614
- WHERE toLower(n.name) = '${token.replace(/'/g, "''")}'
615
- RETURN n.id AS id, n.name AS name, n.filePath AS filePath, n.startLine AS startLine, n.endLine AS endLine
616
- LIMIT ${limit}
617
- `);
618
- for (const row of exactRows) {
619
- const nodeId = getRowValue(row, 'id', 0);
620
- if (!nodeId)
621
- continue;
622
- symbolHits.push({
623
- nodeId,
624
- name: getRowValue(row, 'name', 1) || '',
625
- type: inferTypeFromId(nodeId) || 'CodeElement',
626
- filePath: getRowValue(row, 'filePath', 2) || '',
627
- startLine: getRowValue(row, 'startLine', 3),
628
- endLine: getRowValue(row, 'endLine', 4),
629
- bm25Score: 10_000,
559
+ WHERE n.filePath = $filePath
560
+ 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
561
+ LIMIT 3
562
+ `, { filePath: fullPath });
563
+ if (symbols.length > 0) {
564
+ for (const sym of symbols) {
565
+ results.push({
566
+ nodeId: sym.id || sym[0],
567
+ name: sym.name || sym[1],
568
+ type: sym.type || sym[2],
569
+ filePath: sym.filePath || sym[3],
570
+ startLine: sym.startLine || sym[4],
571
+ endLine: sym.endLine || sym[5],
572
+ bm25Score: bm25Result.score,
573
+ });
574
+ }
575
+ }
576
+ else {
577
+ const fileName = fullPath.split('/').pop() || fullPath;
578
+ results.push({
579
+ name: fileName,
580
+ type: 'File',
581
+ filePath: bm25Result.filePath,
582
+ bm25Score: bm25Result.score,
630
583
  });
631
584
  }
632
585
  }
633
586
  catch {
634
- // ignore exact-hit fallback failures
635
- }
636
- }
637
- if (symbolHits.length > 0) {
638
- const filteredByName = meaningfulTokens.length > 0
639
- ? symbolHits.filter(hit => meaningfulTokens.some(t => (hit.name || '').toLowerCase().includes(t)))
640
- : symbolHits;
641
- const candidateHits = filteredByName.length > 0 ? filteredByName : symbolHits;
642
- const deduped = new Map();
643
- for (const hit of candidateHits) {
644
- const existing = deduped.get(hit.nodeId);
645
- if (!existing || hit.bm25Score > existing.bm25Score) {
646
- deduped.set(hit.nodeId, hit);
647
- }
648
- }
649
- return Array.from(deduped.values())
650
- .sort((a, b) => b.bm25Score - a.bm25Score)
651
- .slice(0, limit);
652
- }
653
- // Fallback to file-level FTS when symbol indexes are missing/empty.
654
- try {
655
- const fileRows = await executeQuery(repo.id, `
656
- CALL QUERY_FTS_INDEX('File', 'file_fts', '${escapedQuery}', conjunctive := false)
657
- RETURN node.filePath AS filePath, score AS score
658
- ORDER BY score DESC
659
- LIMIT ${limit}
660
- `);
661
- return fileRows.map((row) => {
662
- const filePath = getRowValue(row, 'filePath', 0) || '';
663
- const fileName = filePath.split('/').pop() || filePath;
664
- return {
587
+ const fileName = fullPath.split('/').pop() || fullPath;
588
+ results.push({
665
589
  name: fileName,
666
590
  type: 'File',
667
- filePath,
668
- bm25Score: Number(getRowValue(row, 'score', 1) ?? 0),
669
- };
670
- });
671
- }
672
- catch (err) {
673
- console.error('GitNexus: BM25/FTS search failed (FTS indexes may not exist) -', err.message);
674
- return [];
591
+ filePath: bm25Result.filePath,
592
+ bm25Score: bm25Result.score,
593
+ });
594
+ }
675
595
  }
596
+ return results;
676
597
  }
677
598
  /**
678
599
  * Semantic vector search helper
@@ -709,11 +630,10 @@ export class LocalBackend {
709
630
  if (!VALID_NODE_LABELS.has(label))
710
631
  continue;
711
632
  try {
712
- const escapedId = nodeId.replace(/'/g, "''");
713
633
  const nodeQuery = label === 'File'
714
- ? `MATCH (n:File {id: '${escapedId}'}) RETURN n.name AS name, n.filePath AS filePath`
715
- : `MATCH (n:\`${label}\` {id: '${escapedId}'}) RETURN n.name AS name, n.filePath AS filePath, n.startLine AS startLine, n.endLine AS endLine`;
716
- const nodeRows = await executeQuery(repo.id, nodeQuery);
634
+ ? `MATCH (n:File {id: $nodeId}) RETURN n.name AS name, n.filePath AS filePath`
635
+ : `MATCH (n:\`${label}\` {id: $nodeId}) RETURN n.name AS name, n.filePath AS filePath, n.startLine AS startLine, n.endLine AS endLine`;
636
+ const nodeRows = await executeParameterized(repo.id, nodeQuery, { nodeId });
717
637
  if (nodeRows.length > 0) {
718
638
  const nodeRow = nodeRows[0];
719
639
  results.push({
@@ -742,8 +662,12 @@ export class LocalBackend {
742
662
  }
743
663
  async cypher(repo, params) {
744
664
  await this.ensureInitialized(repo.id);
745
- if (!isKuzuReady(repo.id)) {
746
- return { error: 'KuzuDB not ready. Index may be corrupted.' };
665
+ if (!isLbugReady(repo.id)) {
666
+ return { error: 'LadybugDB not ready. Index may be corrupted.' };
667
+ }
668
+ // Block write operations (defense-in-depth — DB is already read-only)
669
+ if (CYPHER_WRITE_RE.test(params.query)) {
670
+ return { error: 'Write operations (CREATE, DELETE, SET, MERGE, REMOVE, DROP, ALTER, COPY, DETACH) are not allowed. The knowledge graph is read-only.' };
747
671
  }
748
672
  try {
749
673
  const result = await executeQuery(repo.id, params.query);
@@ -760,29 +684,7 @@ export class LocalBackend {
760
684
  formatCypherAsMarkdown(result) {
761
685
  if (!Array.isArray(result) || result.length === 0)
762
686
  return result;
763
- const normalizedRows = result.map((row) => {
764
- if (!row || typeof row !== 'object')
765
- return row;
766
- const next = { ...row };
767
- if (Object.prototype.hasOwnProperty.call(next, 'type')) {
768
- const typeValue = next.type;
769
- if (typeValue === null || typeValue === undefined || (typeof typeValue === 'string' && typeValue.trim() === '')) {
770
- const inferred = inferTypeFromId(next.id ?? next.uid ?? next.sourceId ?? next.targetId);
771
- if (inferred)
772
- next.type = inferred;
773
- }
774
- }
775
- if (Object.prototype.hasOwnProperty.call(next, 'kind')) {
776
- const kindValue = next.kind;
777
- if (kindValue === null || kindValue === undefined || (typeof kindValue === 'string' && kindValue.trim() === '')) {
778
- const inferred = inferTypeFromId(next.id ?? next.uid ?? next.sourceId ?? next.targetId);
779
- if (inferred)
780
- next.kind = inferred;
781
- }
782
- }
783
- return next;
784
- });
785
- const firstRow = normalizedRows[0];
687
+ const firstRow = result[0];
786
688
  if (typeof firstRow !== 'object' || firstRow === null)
787
689
  return result;
788
690
  const keys = Object.keys(firstRow);
@@ -790,7 +692,7 @@ export class LocalBackend {
790
692
  return result;
791
693
  const header = '| ' + keys.join(' | ') + ' |';
792
694
  const separator = '| ' + keys.map(() => '---').join(' | ') + ' |';
793
- const dataRows = normalizedRows.map((row) => '| ' + keys.map(k => {
695
+ const dataRows = result.map((row) => '| ' + keys.map(k => {
794
696
  const v = row[k];
795
697
  if (v === null || v === undefined)
796
698
  return '';
@@ -800,13 +702,13 @@ export class LocalBackend {
800
702
  }).join(' | ') + ' |');
801
703
  return {
802
704
  markdown: [header, separator, ...dataRows].join('\n'),
803
- row_count: normalizedRows.length,
705
+ row_count: result.length,
804
706
  };
805
707
  }
806
708
  /**
807
709
  * Aggregate same-named clusters: group by heuristicLabel, sum symbols,
808
710
  * weighted-average cohesion, filter out tiny clusters (<5 symbols).
809
- * Raw communities stay intact in KuzuDB for Cypher queries.
711
+ * Raw communities stay intact in LadybugDB for Cypher queries.
810
712
  */
811
713
  aggregateClusters(clusters) {
812
714
  const groups = new Map();
@@ -902,40 +804,39 @@ export class LocalBackend {
902
804
  async context(repo, params) {
903
805
  await this.ensureInitialized(repo.id);
904
806
  const { name, uid, file_path, include_content } = params;
905
- const unityResourcesMode = parseUnityResourcesMode(params.unity_resources);
906
- const unityHydrationMode = parseUnityHydrationMode(params.unity_hydration_mode);
907
807
  if (!name && !uid) {
908
808
  return { error: 'Either "name" or "uid" parameter is required.' };
909
809
  }
910
810
  // Step 1: Find the symbol
911
811
  let symbols;
912
812
  if (uid) {
913
- const escaped = uid.replace(/'/g, "''");
914
- symbols = await executeQuery(repo.id, `
915
- MATCH (n {id: '${escaped}'})
916
- RETURN n.id AS id, n.name AS name, n.filePath AS filePath, n.startLine AS startLine, n.endLine AS endLine${include_content ? ', n.content AS content' : ''}
813
+ symbols = await executeParameterized(repo.id, `
814
+ MATCH (n {id: $uid})
815
+ 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' : ''}
917
816
  LIMIT 1
918
- `);
817
+ `, { uid });
919
818
  }
920
819
  else {
921
- const escaped = name.replace(/'/g, "''");
922
820
  const isQualified = name.includes('/') || name.includes(':');
923
821
  let whereClause;
822
+ let queryParams;
924
823
  if (file_path) {
925
- const fpEscaped = file_path.replace(/'/g, "''");
926
- whereClause = `WHERE n.name = '${escaped}' AND n.filePath CONTAINS '${fpEscaped}'`;
824
+ whereClause = `WHERE n.name = $symName AND n.filePath CONTAINS $filePath`;
825
+ queryParams = { symName: name, filePath: file_path };
927
826
  }
928
827
  else if (isQualified) {
929
- whereClause = `WHERE n.id = '${escaped}' OR n.name = '${escaped}'`;
828
+ whereClause = `WHERE n.id = $symName OR n.name = $symName`;
829
+ queryParams = { symName: name };
930
830
  }
931
831
  else {
932
- whereClause = `WHERE n.name = '${escaped}'`;
832
+ whereClause = `WHERE n.name = $symName`;
833
+ queryParams = { symName: name };
933
834
  }
934
- symbols = await executeQuery(repo.id, `
835
+ symbols = await executeParameterized(repo.id, `
935
836
  MATCH (n) ${whereClause}
936
- RETURN n.id AS id, n.name AS name, n.filePath AS filePath, n.startLine AS startLine, n.endLine AS endLine${include_content ? ', n.content AS content' : ''}
837
+ 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' : ''}
937
838
  LIMIT 10
938
- `);
839
+ `, queryParams);
939
840
  }
940
841
  if (symbols.length === 0) {
941
842
  return { error: `Symbol '${name || uid}' not found` };
@@ -946,109 +847,52 @@ export class LocalBackend {
946
847
  status: 'ambiguous',
947
848
  message: `Found ${symbols.length} symbols matching '${name}'. Use uid or file_path to disambiguate.`,
948
849
  candidates: symbols.map((s) => ({
949
- uid: getRowValue(s, 'id', 0),
950
- name: getRowValue(s, 'name', 1),
951
- kind: inferTypeFromId(getRowValue(s, 'id', 0)) || 'CodeElement',
952
- filePath: getRowValue(s, 'filePath', 2),
953
- line: getRowValue(s, 'startLine', 3),
850
+ uid: s.id || s[0],
851
+ name: s.name || s[1],
852
+ kind: s.type || s[2],
853
+ filePath: s.filePath || s[3],
854
+ line: s.startLine || s[4],
954
855
  })),
955
856
  };
956
857
  }
957
858
  // Step 3: Build full context
958
859
  const sym = symbols[0];
959
- const symNodeId = getRowValue(sym, 'id', 0) || '';
960
- const symId = symNodeId.replace(/'/g, "''");
961
- const symKind = inferTypeFromId(symNodeId) || 'CodeElement';
962
- const symFilePath = getRowValue(sym, 'filePath', 2) || '';
860
+ const symId = sym.id || sym[0];
963
861
  // Categorized incoming refs
964
- let incomingRows = await executeQuery(repo.id, `
965
- MATCH (caller)-[r:CodeRelation]->(n {id: '${symId}'})
862
+ const incomingRows = await executeParameterized(repo.id, `
863
+ MATCH (caller)-[r:CodeRelation]->(n {id: $symId})
966
864
  WHERE r.type IN ['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS']
967
- RETURN r.type AS relType, caller.id AS uid, caller.name AS name, caller.filePath AS filePath
865
+ RETURN r.type AS relType, caller.id AS uid, caller.name AS name, caller.filePath AS filePath, labels(caller)[0] AS kind
968
866
  LIMIT 30
969
- `);
867
+ `, { symId });
970
868
  // Categorized outgoing refs
971
- let outgoingRows = await executeQuery(repo.id, `
972
- MATCH (n {id: '${symId}'})-[r:CodeRelation]->(target)
869
+ const outgoingRows = await executeParameterized(repo.id, `
870
+ MATCH (n {id: $symId})-[r:CodeRelation]->(target)
973
871
  WHERE r.type IN ['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS']
974
- RETURN r.type AS relType, target.id AS uid, target.name AS name, target.filePath AS filePath
872
+ RETURN r.type AS relType, target.id AS uid, target.name AS name, target.filePath AS filePath, labels(target)[0] AS kind
975
873
  LIMIT 30
976
- `);
874
+ `, { symId });
977
875
  // Process participation
978
876
  let processRows = [];
979
877
  try {
980
- processRows = await executeQuery(repo.id, `
981
- MATCH (n {id: '${symId}'})-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p:Process)
878
+ processRows = await executeParameterized(repo.id, `
879
+ MATCH (n {id: $symId})-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p:Process)
982
880
  RETURN p.id AS pid, p.heuristicLabel AS label, r.step AS step, p.stepCount AS stepCount
983
- `);
881
+ `, { symId });
984
882
  }
985
- catch { /* no process info */ }
986
- // Classes/interfaces are often represented by method/property level edges.
987
- // When direct edges are empty, fall back to file-scoped references/processes.
988
- if ((symKind === 'Class' || symKind === 'Interface') && symFilePath) {
989
- const escapedPath = symFilePath.replace(/'/g, "''");
990
- if (incomingRows.length === 0) {
991
- try {
992
- incomingRows = await executeQuery(repo.id, `
993
- MATCH (caller)-[r:CodeRelation]->(n)
994
- WHERE n.filePath = '${escapedPath}' AND r.type IN ['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS']
995
- RETURN r.type AS relType, caller.id AS uid, caller.name AS name, caller.filePath AS filePath
996
- LIMIT 30
997
- `);
998
- }
999
- catch { /* ignore fallback failures */ }
1000
- }
1001
- if (outgoingRows.length === 0) {
1002
- try {
1003
- outgoingRows = await executeQuery(repo.id, `
1004
- MATCH (n)-[r:CodeRelation]->(target)
1005
- WHERE n.filePath = '${escapedPath}' AND r.type IN ['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS']
1006
- RETURN r.type AS relType, target.id AS uid, target.name AS name, target.filePath AS filePath
1007
- LIMIT 30
1008
- `);
1009
- }
1010
- catch { /* ignore fallback failures */ }
1011
- }
1012
- if (processRows.length === 0) {
1013
- try {
1014
- const scopedProcessRows = await executeQuery(repo.id, `
1015
- MATCH (n)-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p:Process)
1016
- WHERE n.filePath = '${escapedPath}'
1017
- RETURN p.id AS pid, p.heuristicLabel AS label, r.step AS step, p.stepCount AS stepCount
1018
- LIMIT 200
1019
- `);
1020
- const minStepByProcess = new Map();
1021
- for (const row of scopedProcessRows) {
1022
- const pid = getRowValue(row, 'pid', 0);
1023
- if (!pid)
1024
- continue;
1025
- const step = Number(getRowValue(row, 'step', 2) ?? Number.MAX_SAFE_INTEGER);
1026
- const existing = minStepByProcess.get(pid);
1027
- if (!existing || step < existing.step) {
1028
- minStepByProcess.set(pid, {
1029
- pid,
1030
- label: getRowValue(row, 'label', 1),
1031
- step,
1032
- stepCount: getRowValue(row, 'stepCount', 3),
1033
- });
1034
- }
1035
- }
1036
- processRows = Array.from(minStepByProcess.values());
1037
- }
1038
- catch { /* ignore fallback failures */ }
1039
- }
883
+ catch (e) {
884
+ logQueryError('context:process-participation', e);
1040
885
  }
1041
886
  // Helper to categorize refs
1042
887
  const categorize = (rows) => {
1043
888
  const cats = {};
1044
889
  for (const row of rows) {
1045
- const relType = String(getRowValue(row, 'relType', 0) || '').toLowerCase();
1046
- const uid = getRowValue(row, 'uid', 1);
890
+ const relType = (row.relType || row[0] || '').toLowerCase();
1047
891
  const entry = {
1048
- uid,
1049
- name: getRowValue(row, 'name', 2),
1050
- filePath: getRowValue(row, 'filePath', 3),
1051
- kind: getRowValue(row, 'kind', 4) || inferTypeFromId(uid) || 'CodeElement',
892
+ uid: row.uid || row[1],
893
+ name: row.name || row[2],
894
+ filePath: row.filePath || row[3],
895
+ kind: row.kind || row[4],
1052
896
  };
1053
897
  if (!cats[relType])
1054
898
  cats[relType] = [];
@@ -1056,38 +900,26 @@ export class LocalBackend {
1056
900
  }
1057
901
  return cats;
1058
902
  };
1059
- const result = {
903
+ return {
1060
904
  status: 'found',
1061
905
  symbol: {
1062
- uid: getRowValue(sym, 'id', 0),
1063
- name: getRowValue(sym, 'name', 1),
1064
- kind: symKind,
1065
- filePath: symFilePath,
1066
- startLine: getRowValue(sym, 'startLine', 3),
1067
- endLine: getRowValue(sym, 'endLine', 4),
1068
- ...(include_content && getRowValue(sym, 'content', 5) ? { content: getRowValue(sym, 'content', 5) } : {}),
906
+ uid: sym.id || sym[0],
907
+ name: sym.name || sym[1],
908
+ kind: sym.type || sym[2],
909
+ filePath: sym.filePath || sym[3],
910
+ startLine: sym.startLine || sym[4],
911
+ endLine: sym.endLine || sym[5],
912
+ ...(include_content && (sym.content || sym[6]) ? { content: sym.content || sym[6] } : {}),
1069
913
  },
1070
914
  incoming: categorize(incomingRows),
1071
915
  outgoing: categorize(outgoingRows),
1072
916
  processes: processRows.map((r) => ({
1073
- id: getRowValue(r, 'pid', 0),
1074
- name: getRowValue(r, 'label', 1),
1075
- step_index: getRowValue(r, 'step', 2),
1076
- step_count: getRowValue(r, 'stepCount', 3),
917
+ id: r.pid || r[0],
918
+ name: r.label || r[1],
919
+ step_index: r.step || r[2],
920
+ step_count: r.stepCount || r[3],
1077
921
  })),
1078
922
  };
1079
- if (unityResourcesMode !== 'off' && symNodeId && symKind === 'Class') {
1080
- const unityContext = await loadUnityContext(repo.id, symNodeId, (query) => executeQuery(repo.id, query));
1081
- const hydratedUnityContext = await this.hydrateUnityContext(repo, {
1082
- symbolUid: symNodeId,
1083
- symbolName: getRowValue(sym, 'name', 1) || '',
1084
- symbolFilePath: symFilePath,
1085
- payload: unityContext,
1086
- hydrationMode: unityHydrationMode,
1087
- });
1088
- Object.assign(result, hydratedUnityContext);
1089
- }
1090
- return result;
1091
923
  }
1092
924
  /**
1093
925
  * Legacy explore — kept for backwards compatibility with resources.ts.
@@ -1100,13 +932,11 @@ export class LocalBackend {
1100
932
  return this.context(repo, { name });
1101
933
  }
1102
934
  if (type === 'cluster') {
1103
- const escaped = name.replace(/'/g, "''");
1104
- const clusterQuery = `
935
+ const clusters = await executeParameterized(repo.id, `
1105
936
  MATCH (c:Community)
1106
- WHERE c.label = '${escaped}' OR c.heuristicLabel = '${escaped}'
937
+ WHERE c.label = $clusterName OR c.heuristicLabel = $clusterName
1107
938
  RETURN c.id AS id, c.label AS label, c.heuristicLabel AS heuristicLabel, c.cohesion AS cohesion, c.symbolCount AS symbolCount
1108
- `;
1109
- const clusters = await executeQuery(repo.id, clusterQuery);
939
+ `, { clusterName: name });
1110
940
  if (clusters.length === 0)
1111
941
  return { error: `Cluster '${name}' not found` };
1112
942
  const rawClusters = clusters.map((c) => ({
@@ -1119,12 +949,12 @@ export class LocalBackend {
1119
949
  totalSymbols += s;
1120
950
  weightedCohesion += (c.cohesion || 0) * s;
1121
951
  }
1122
- const members = await executeQuery(repo.id, `
952
+ const members = await executeParameterized(repo.id, `
1123
953
  MATCH (n)-[:CodeRelation {type: 'MEMBER_OF'}]->(c:Community)
1124
- WHERE c.label = '${escaped}' OR c.heuristicLabel = '${escaped}'
1125
- RETURN DISTINCT n.id AS id, n.name AS name, n.filePath AS filePath
954
+ WHERE c.label = $clusterName OR c.heuristicLabel = $clusterName
955
+ RETURN DISTINCT n.name AS name, labels(n)[0] AS type, n.filePath AS filePath
1126
956
  LIMIT 30
1127
- `);
957
+ `, { clusterName: name });
1128
958
  return {
1129
959
  cluster: {
1130
960
  id: rawClusters[0].id,
@@ -1135,250 +965,38 @@ export class LocalBackend {
1135
965
  subCommunities: rawClusters.length,
1136
966
  },
1137
967
  members: members.map((m) => ({
1138
- name: getRowValue(m, 'name', 1),
1139
- type: inferTypeFromId(getRowValue(m, 'id', 0)) || 'CodeElement',
1140
- filePath: getRowValue(m, 'filePath', 2),
968
+ name: m.name || m[0], type: m.type || m[1], filePath: m.filePath || m[2],
1141
969
  })),
1142
970
  };
1143
971
  }
1144
972
  if (type === 'process') {
1145
- const processes = await executeQuery(repo.id, `
973
+ const processes = await executeParameterized(repo.id, `
1146
974
  MATCH (p:Process)
1147
- WHERE p.label = '${name.replace(/'/g, "''")}' OR p.heuristicLabel = '${name.replace(/'/g, "''")}'
975
+ WHERE p.label = $processName OR p.heuristicLabel = $processName
1148
976
  RETURN p.id AS id, p.label AS label, p.heuristicLabel AS heuristicLabel, p.processType AS processType, p.stepCount AS stepCount
1149
977
  LIMIT 1
1150
- `);
978
+ `, { processName: name });
1151
979
  if (processes.length === 0)
1152
980
  return { error: `Process '${name}' not found` };
1153
981
  const proc = processes[0];
1154
982
  const procId = proc.id || proc[0];
1155
- const steps = await executeQuery(repo.id, `
1156
- MATCH (n)-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p {id: '${procId}'})
1157
- RETURN n.id AS id, n.name AS name, n.filePath AS filePath, r.step AS step
983
+ const steps = await executeParameterized(repo.id, `
984
+ MATCH (n)-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p {id: $procId})
985
+ RETURN n.name AS name, labels(n)[0] AS type, n.filePath AS filePath, r.step AS step
1158
986
  ORDER BY r.step
1159
- `);
987
+ `, { procId });
1160
988
  return {
1161
989
  process: {
1162
990
  id: procId, label: proc.label || proc[1], heuristicLabel: proc.heuristicLabel || proc[2],
1163
991
  processType: proc.processType || proc[3], stepCount: proc.stepCount || proc[4],
1164
992
  },
1165
993
  steps: steps.map((s) => ({
1166
- step: getRowValue(s, 'step', 3),
1167
- name: getRowValue(s, 'name', 1),
1168
- type: inferTypeFromId(getRowValue(s, 'id', 0)) || 'CodeElement',
1169
- filePath: getRowValue(s, 'filePath', 2),
994
+ step: s.step || s[3], name: s.name || s[0], type: s.type || s[1], filePath: s.filePath || s[2],
1170
995
  })),
1171
996
  };
1172
997
  }
1173
998
  return { error: 'Invalid type. Use: symbol, cluster, or process' };
1174
999
  }
1175
- async hydrateUnityContext(repo, input) {
1176
- const startedAt = Date.now();
1177
- if (input.hydrationMode === 'compact') {
1178
- const compactPayload = await this.hydrateUnityContextCompact(repo, input);
1179
- const withMeta = attachUnityHydrationMeta(compactPayload, {
1180
- requestedMode: 'compact',
1181
- effectiveMode: 'compact',
1182
- elapsedMs: Date.now() - startedAt,
1183
- fallbackToCompact: false,
1184
- hasExpandableBindings: compactPayload.resourceBindings.some((binding) => binding.lightweight || binding.componentObjectId === 'summary'),
1185
- });
1186
- if (withMeta.hydrationMeta?.needsParityRetry) {
1187
- this.scheduleParityWarmup(repo, input);
1188
- }
1189
- return withMeta;
1190
- }
1191
- const parityResult = await this.hydrateUnityContextParity(repo, input);
1192
- return attachUnityHydrationMeta(parityResult.payload, {
1193
- requestedMode: 'parity',
1194
- effectiveMode: parityResult.effectiveMode,
1195
- elapsedMs: Date.now() - startedAt,
1196
- fallbackToCompact: parityResult.fallbackToCompact,
1197
- hasExpandableBindings: parityResult.payload.resourceBindings.some((binding) => binding.lightweight || binding.componentObjectId === 'summary'),
1198
- });
1199
- }
1200
- buildParityWarmupKey(repo, symbolUid) {
1201
- return `${repo.storagePath}::${repo.lastCommit}::${symbolUid}`;
1202
- }
1203
- scheduleParityWarmup(repo, input) {
1204
- if (!this.shouldEnableParityWarmup()) {
1205
- return;
1206
- }
1207
- if (!input.symbolUid || !input.symbolName || !input.symbolFilePath) {
1208
- return;
1209
- }
1210
- void parityWarmupQueue.run(() => this.getOrRunParityHydration(repo, input))
1211
- .then(() => undefined)
1212
- .catch(() => undefined);
1213
- }
1214
- shouldEnableParityWarmup() {
1215
- const raw = String(process.env.GITNEXUS_UNITY_PARITY_WARMUP || '').trim().toLowerCase();
1216
- return raw === '1' || raw === 'true' || raw === 'on';
1217
- }
1218
- async getOrRunParityHydration(repo, input) {
1219
- const key = this.buildParityWarmupKey(repo, input.symbolUid);
1220
- const existing = inFlightParityHydration.get(key);
1221
- if (existing) {
1222
- return existing;
1223
- }
1224
- const pending = (async () => {
1225
- const cached = await readUnityParityCache(repo.storagePath, repo.lastCommit, input.symbolUid);
1226
- if (cached) {
1227
- return cached;
1228
- }
1229
- const payload = await this.computeParityPayload(repo, input);
1230
- await upsertUnityParityCache(repo.storagePath, repo.lastCommit, input.symbolUid, payload);
1231
- return payload;
1232
- })().finally(() => {
1233
- inFlightParityHydration.delete(key);
1234
- });
1235
- inFlightParityHydration.set(key, pending);
1236
- return pending;
1237
- }
1238
- async computeParityPayload(repo, input) {
1239
- const symbolDeclarations = [{ symbol: input.symbolName, scriptPath: input.symbolFilePath }];
1240
- const paritySeed = await loadUnityParitySeed(repo.storagePath);
1241
- const seededScanContext = paritySeed
1242
- ? buildUnityScanContextFromSeed({
1243
- seed: paritySeed,
1244
- symbolDeclarations,
1245
- })
1246
- : null;
1247
- let resolved = await resolveUnityBindings({
1248
- repoRoot: repo.repoPath,
1249
- symbol: input.symbolName,
1250
- scanContext: seededScanContext || await buildUnityScanContext({
1251
- repoRoot: repo.repoPath,
1252
- symbolDeclarations,
1253
- }),
1254
- deepParseLargeResources: true,
1255
- });
1256
- if (seededScanContext
1257
- && resolved.resourceBindings.length === 0
1258
- && input.payload.resourceBindings.length > 0) {
1259
- const fallbackScanContext = await buildUnityScanContext({
1260
- repoRoot: repo.repoPath,
1261
- symbolDeclarations,
1262
- });
1263
- resolved = await resolveUnityBindings({
1264
- repoRoot: repo.repoPath,
1265
- symbol: input.symbolName,
1266
- scanContext: fallbackScanContext,
1267
- deepParseLargeResources: true,
1268
- });
1269
- }
1270
- if (resolved.resourceBindings.length === 0 && input.payload.resourceBindings.length > 0) {
1271
- throw new Error('parity-expand returned zero bindings');
1272
- }
1273
- const baseNonLightweight = input.payload.resourceBindings.filter((binding) => !binding.lightweight && binding.componentObjectId !== 'summary');
1274
- const mergedBindings = mergeParityUnityBindings(baseNonLightweight, resolved.resourceBindings);
1275
- return this.toUnityContextPayload(mergedBindings, [
1276
- ...input.payload.unityDiagnostics,
1277
- ...resolved.unityDiagnostics,
1278
- ]);
1279
- }
1280
- async hydrateUnityContextParity(repo, input) {
1281
- try {
1282
- return {
1283
- payload: await this.getOrRunParityHydration(repo, input),
1284
- effectiveMode: 'parity',
1285
- fallbackToCompact: false,
1286
- };
1287
- }
1288
- catch (error) {
1289
- const compactFallback = await this.hydrateUnityContextCompact(repo, input);
1290
- const message = String(error instanceof Error ? error.message : error);
1291
- return {
1292
- payload: {
1293
- ...compactFallback,
1294
- unityDiagnostics: [
1295
- ...compactFallback.unityDiagnostics,
1296
- /parity-expand returned zero bindings/i.test(message)
1297
- ? 'parity-expand returned zero bindings; fell back to compact hydration'
1298
- : `parity-expand failed: ${message}`,
1299
- ],
1300
- },
1301
- effectiveMode: 'compact',
1302
- fallbackToCompact: true,
1303
- };
1304
- }
1305
- }
1306
- async hydrateUnityContextCompact(repo, input) {
1307
- const lightweightPaths = [...new Set(input.payload.resourceBindings
1308
- .filter((binding) => binding.lightweight || binding.componentObjectId === 'summary')
1309
- .map((binding) => normalizePath(binding.resourcePath))
1310
- .filter((value) => value.length > 0))];
1311
- if (lightweightPaths.length === 0) {
1312
- return input.payload;
1313
- }
1314
- const overlayHits = await readUnityOverlayBindings(repo.storagePath, repo.lastCommit, input.symbolUid, lightweightPaths);
1315
- const pendingPaths = lightweightPaths.filter((resourcePath) => !overlayHits.has(resourcePath));
1316
- const resolvedByPath = new Map(overlayHits);
1317
- const unityDiagnostics = [...input.payload.unityDiagnostics];
1318
- if (pendingPaths.length > 0) {
1319
- try {
1320
- const cfg = resolveUnityLazyConfig(process.env);
1321
- const hydration = await hydrateLazyBindings({
1322
- pendingPaths,
1323
- config: cfg,
1324
- dedupeKey: `${input.symbolUid}::${pendingPaths.slice().sort().join('|')}`,
1325
- resolveBatch: async (resourcePaths) => {
1326
- const scopedPaths = [
1327
- input.symbolFilePath,
1328
- `${input.symbolFilePath}.meta`,
1329
- ...resourcePaths,
1330
- ...resourcePaths.map((resourcePath) => `${resourcePath}.meta`),
1331
- ].map(normalizePath);
1332
- const scanContext = await buildUnityScanContext({
1333
- repoRoot: repo.repoPath,
1334
- scopedPaths,
1335
- symbolDeclarations: [{ symbol: input.symbolName, scriptPath: input.symbolFilePath }],
1336
- });
1337
- const resolved = await resolveUnityBindings({
1338
- repoRoot: repo.repoPath,
1339
- symbol: input.symbolName,
1340
- scanContext,
1341
- resourcePathAllowlist: resourcePaths,
1342
- deepParseLargeResources: true,
1343
- });
1344
- unityDiagnostics.push(...resolved.unityDiagnostics);
1345
- const byPath = new Map();
1346
- for (const resourcePath of resourcePaths) {
1347
- byPath.set(resourcePath, resolved.resourceBindings.filter((binding) => normalizePath(binding.resourcePath) === normalizePath(resourcePath)));
1348
- }
1349
- return byPath;
1350
- },
1351
- });
1352
- const freshByPath = hydration.resolvedByPath;
1353
- if (hydration.timedOut) {
1354
- unityDiagnostics.push(formatLazyHydrationBudgetDiagnostic(hydration.elapsedMs));
1355
- }
1356
- const hydrationExtras = hydration.diagnostics.filter((diag) => !/budget exceeded/i.test(diag));
1357
- if (hydrationExtras.length > 0) {
1358
- unityDiagnostics.push(...hydrationExtras);
1359
- }
1360
- await upsertUnityOverlayBindings(repo.storagePath, repo.lastCommit, input.symbolUid, freshByPath);
1361
- for (const [resourcePath, bindings] of freshByPath.entries()) {
1362
- resolvedByPath.set(resourcePath, bindings);
1363
- }
1364
- }
1365
- catch (error) {
1366
- unityDiagnostics.push(`lazy-expand failed: ${error instanceof Error ? error.message : String(error)}`);
1367
- }
1368
- }
1369
- const mergedBindings = mergeUnityBindings(input.payload.resourceBindings, resolvedByPath);
1370
- return this.toUnityContextPayload(mergedBindings, unityDiagnostics);
1371
- }
1372
- toUnityContextPayload(resourceBindings, unityDiagnostics) {
1373
- return {
1374
- resourceBindings,
1375
- serializedFields: {
1376
- scalarFields: resourceBindings.flatMap((binding) => binding.serializedFields.scalarFields),
1377
- referenceFields: resourceBindings.flatMap((binding) => binding.serializedFields.referenceFields),
1378
- },
1379
- unityDiagnostics,
1380
- };
1381
- }
1382
1000
  /**
1383
1001
  * Detect changes — git-diff based impact analysis.
1384
1002
  * Maps changed lines to indexed symbols, then finds affected processes.
@@ -1386,29 +1004,29 @@ export class LocalBackend {
1386
1004
  async detectChanges(repo, params) {
1387
1005
  await this.ensureInitialized(repo.id);
1388
1006
  const scope = params.scope || 'unstaged';
1389
- const { execSync } = await import('child_process');
1390
- // Build git diff command based on scope
1391
- let diffCmd;
1007
+ const { execFileSync } = await import('child_process');
1008
+ // Build git diff args based on scope (using execFileSync to avoid shell injection)
1009
+ let diffArgs;
1392
1010
  switch (scope) {
1393
1011
  case 'staged':
1394
- diffCmd = 'git diff --staged --name-only';
1012
+ diffArgs = ['diff', '--staged', '--name-only'];
1395
1013
  break;
1396
1014
  case 'all':
1397
- diffCmd = 'git diff HEAD --name-only';
1015
+ diffArgs = ['diff', 'HEAD', '--name-only'];
1398
1016
  break;
1399
1017
  case 'compare':
1400
1018
  if (!params.base_ref)
1401
1019
  return { error: 'base_ref is required for "compare" scope' };
1402
- diffCmd = `git diff ${params.base_ref} --name-only`;
1020
+ diffArgs = ['diff', params.base_ref, '--name-only'];
1403
1021
  break;
1404
1022
  case 'unstaged':
1405
1023
  default:
1406
- diffCmd = 'git diff --name-only';
1024
+ diffArgs = ['diff', '--name-only'];
1407
1025
  break;
1408
1026
  }
1409
1027
  let changedFiles;
1410
1028
  try {
1411
- const output = execSync(diffCmd, { cwd: repo.repoPath, encoding: 'utf-8' });
1029
+ const output = execFileSync('git', diffArgs, { cwd: repo.repoPath, encoding: 'utf-8' });
1412
1030
  changedFiles = output.trim().split('\n').filter(f => f.length > 0);
1413
1031
  }
1414
1032
  catch (err) {
@@ -1424,35 +1042,35 @@ export class LocalBackend {
1424
1042
  // Map changed files to indexed symbols
1425
1043
  const changedSymbols = [];
1426
1044
  for (const file of changedFiles) {
1427
- const escaped = file.replace(/\\/g, '/').replace(/'/g, "''");
1045
+ const normalizedFile = file.replace(/\\/g, '/');
1428
1046
  try {
1429
- const symbols = await executeQuery(repo.id, `
1430
- MATCH (n) WHERE n.filePath CONTAINS '${escaped}'
1431
- RETURN n.id AS id, n.name AS name, n.filePath AS filePath
1047
+ const symbols = await executeParameterized(repo.id, `
1048
+ MATCH (n) WHERE n.filePath CONTAINS $filePath
1049
+ RETURN n.id AS id, n.name AS name, labels(n)[0] AS type, n.filePath AS filePath
1432
1050
  LIMIT 20
1433
- `);
1051
+ `, { filePath: normalizedFile });
1434
1052
  for (const sym of symbols) {
1435
- const id = getRowValue(sym, 'id', 0) || '';
1436
1053
  changedSymbols.push({
1437
- id,
1438
- name: getRowValue(sym, 'name', 1),
1439
- type: inferTypeFromId(id) || 'CodeElement',
1440
- filePath: getRowValue(sym, 'filePath', 2),
1054
+ id: sym.id || sym[0],
1055
+ name: sym.name || sym[1],
1056
+ type: sym.type || sym[2],
1057
+ filePath: sym.filePath || sym[3],
1441
1058
  change_type: 'Modified',
1442
1059
  });
1443
1060
  }
1444
1061
  }
1445
- catch { /* skip */ }
1062
+ catch (e) {
1063
+ logQueryError('detect-changes:file-symbols', e);
1064
+ }
1446
1065
  }
1447
1066
  // Find affected processes
1448
1067
  const affectedProcesses = new Map();
1449
1068
  for (const sym of changedSymbols) {
1450
- const escaped = sym.id.replace(/'/g, "''");
1451
1069
  try {
1452
- const procs = await executeQuery(repo.id, `
1453
- MATCH (n {id: '${escaped}'})-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p:Process)
1070
+ const procs = await executeParameterized(repo.id, `
1071
+ MATCH (n {id: $nodeId})-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p:Process)
1454
1072
  RETURN p.id AS pid, p.heuristicLabel AS label, p.processType AS processType, p.stepCount AS stepCount, r.step AS step
1455
- `);
1073
+ `, { nodeId: sym.id });
1456
1074
  for (const proc of procs) {
1457
1075
  const pid = proc.pid || proc[0];
1458
1076
  if (!affectedProcesses.has(pid)) {
@@ -1470,7 +1088,9 @@ export class LocalBackend {
1470
1088
  });
1471
1089
  }
1472
1090
  }
1473
- catch { /* skip */ }
1091
+ catch (e) {
1092
+ logQueryError('detect-changes:process-lookup', e);
1093
+ }
1474
1094
  }
1475
1095
  const processCount = affectedProcesses.size;
1476
1096
  const risk = processCount === 0 ? 'low' : processCount <= 5 ? 'medium' : processCount <= 15 ? 'high' : 'critical';
@@ -1497,6 +1117,14 @@ export class LocalBackend {
1497
1117
  if (!params.symbol_name && !params.symbol_uid) {
1498
1118
  return { error: 'Either symbol_name or symbol_uid is required.' };
1499
1119
  }
1120
+ /** Guard: ensure a file path resolves within the repo root (prevents path traversal) */
1121
+ const assertSafePath = (filePath) => {
1122
+ const full = path.resolve(repo.repoPath, filePath);
1123
+ if (!full.startsWith(repo.repoPath + path.sep) && full !== repo.repoPath) {
1124
+ throw new Error(`Path traversal blocked: ${filePath}`);
1125
+ }
1126
+ return full;
1127
+ };
1500
1128
  // Step 1: Find the target symbol (reuse context's lookup)
1501
1129
  const lookupResult = await this.context(repo, {
1502
1130
  name: params.symbol_name,
@@ -1525,14 +1153,17 @@ export class LocalBackend {
1525
1153
  // The definition itself
1526
1154
  if (sym.filePath && sym.startLine) {
1527
1155
  try {
1528
- const content = await fs.readFile(path.join(repo.repoPath, sym.filePath), 'utf-8');
1156
+ const content = await fs.readFile(assertSafePath(sym.filePath), 'utf-8');
1529
1157
  const lines = content.split('\n');
1530
1158
  const lineIdx = sym.startLine - 1;
1531
1159
  if (lineIdx >= 0 && lineIdx < lines.length && lines[lineIdx].includes(oldName)) {
1532
- addEdit(sym.filePath, sym.startLine, lines[lineIdx].trim(), lines[lineIdx].replace(oldName, new_name).trim(), 'graph');
1160
+ const defRegex = new RegExp(`\\b${oldName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'g');
1161
+ addEdit(sym.filePath, sym.startLine, lines[lineIdx].trim(), lines[lineIdx].replace(defRegex, new_name).trim(), 'graph');
1533
1162
  }
1534
1163
  }
1535
- catch { /* skip */ }
1164
+ catch (e) {
1165
+ logQueryError('rename:read-definition', e);
1166
+ }
1536
1167
  }
1537
1168
  // All incoming refs from graph (callers, importers, etc.)
1538
1169
  const allIncoming = [
@@ -1546,7 +1177,7 @@ export class LocalBackend {
1546
1177
  if (!ref.filePath)
1547
1178
  continue;
1548
1179
  try {
1549
- const content = await fs.readFile(path.join(repo.repoPath, ref.filePath), 'utf-8');
1180
+ const content = await fs.readFile(assertSafePath(ref.filePath), 'utf-8');
1550
1181
  const lines = content.split('\n');
1551
1182
  for (let i = 0; i < lines.length; i++) {
1552
1183
  if (lines[i].includes(oldName)) {
@@ -1556,37 +1187,50 @@ export class LocalBackend {
1556
1187
  }
1557
1188
  }
1558
1189
  }
1559
- catch { /* skip */ }
1190
+ catch (e) {
1191
+ logQueryError('rename:read-ref', e);
1192
+ }
1560
1193
  }
1561
1194
  // Step 3: Text search for refs the graph might have missed
1562
1195
  let astSearchEdits = 0;
1563
1196
  const graphFiles = new Set([sym.filePath, ...allIncoming.map(r => r.filePath)].filter(Boolean));
1564
1197
  // Simple text search across the repo for the old name (in files not already covered by graph)
1565
1198
  try {
1566
- const { execSync } = await import('child_process');
1567
- const rgCmd = `rg -l --type-add "code:*.{ts,tsx,js,jsx,py,go,rs,java}" -t code "\\b${oldName}\\b" .`;
1568
- const output = execSync(rgCmd, { cwd: repo.repoPath, encoding: 'utf-8', timeout: 5000 });
1199
+ const { execFileSync } = await import('child_process');
1200
+ const rgArgs = [
1201
+ '-l',
1202
+ '--type-add', 'code:*.{ts,tsx,js,jsx,py,go,rs,java,c,h,cpp,cc,cxx,hpp,hxx,hh,cs,php,swift}',
1203
+ '-t', 'code',
1204
+ `\\b${oldName}\\b`,
1205
+ '.',
1206
+ ];
1207
+ const output = execFileSync('rg', rgArgs, { cwd: repo.repoPath, encoding: 'utf-8', timeout: 5000 });
1569
1208
  const files = output.trim().split('\n').filter(f => f.length > 0);
1570
1209
  for (const file of files) {
1571
1210
  const normalizedFile = file.replace(/\\/g, '/').replace(/^\.\//, '');
1572
1211
  if (graphFiles.has(normalizedFile))
1573
1212
  continue; // already covered by graph
1574
1213
  try {
1575
- const content = await fs.readFile(path.join(repo.repoPath, normalizedFile), 'utf-8');
1214
+ const content = await fs.readFile(assertSafePath(normalizedFile), 'utf-8');
1576
1215
  const lines = content.split('\n');
1577
1216
  const regex = new RegExp(`\\b${oldName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'g');
1578
1217
  for (let i = 0; i < lines.length; i++) {
1218
+ regex.lastIndex = 0;
1579
1219
  if (regex.test(lines[i])) {
1220
+ regex.lastIndex = 0;
1580
1221
  addEdit(normalizedFile, i + 1, lines[i].trim(), lines[i].replace(regex, new_name).trim(), 'text_search');
1581
1222
  astSearchEdits++;
1582
- regex.lastIndex = 0; // reset regex
1583
1223
  }
1584
1224
  }
1585
1225
  }
1586
- catch { /* skip */ }
1226
+ catch (e) {
1227
+ logQueryError('rename:text-search-read', e);
1228
+ }
1587
1229
  }
1588
1230
  }
1589
- catch { /* rg not available or no additional matches */ }
1231
+ catch (e) {
1232
+ logQueryError('rename:ripgrep', e);
1233
+ }
1590
1234
  // Step 4: Apply or preview
1591
1235
  const allChanges = Array.from(changes.values());
1592
1236
  const totalEdits = allChanges.reduce((sum, c) => sum + c.edits.length, 0);
@@ -1594,13 +1238,15 @@ export class LocalBackend {
1594
1238
  // Apply edits to files
1595
1239
  for (const change of allChanges) {
1596
1240
  try {
1597
- const fullPath = path.join(repo.repoPath, change.file_path);
1241
+ const fullPath = assertSafePath(change.file_path);
1598
1242
  let content = await fs.readFile(fullPath, 'utf-8');
1599
1243
  const regex = new RegExp(`\\b${oldName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'g');
1600
1244
  content = content.replace(regex, new_name);
1601
1245
  await fs.writeFile(fullPath, content, 'utf-8');
1602
1246
  }
1603
- catch { /* skip failed files */ }
1247
+ catch (e) {
1248
+ logQueryError('rename:apply-edit', e);
1249
+ }
1604
1250
  }
1605
1251
  }
1606
1252
  return {
@@ -1616,122 +1262,83 @@ export class LocalBackend {
1616
1262
  };
1617
1263
  }
1618
1264
  async impact(repo, params) {
1265
+ try {
1266
+ return await this._impactImpl(repo, params);
1267
+ }
1268
+ catch (err) {
1269
+ // Return structured error instead of crashing (#321)
1270
+ return {
1271
+ error: (err instanceof Error ? err.message : String(err)) || 'Impact analysis failed',
1272
+ target: { name: params.target },
1273
+ direction: params.direction,
1274
+ impactedCount: 0,
1275
+ risk: 'UNKNOWN',
1276
+ suggestion: 'The graph query failed — try gitnexus context <symbol> as a fallback',
1277
+ };
1278
+ }
1279
+ }
1280
+ async _impactImpl(repo, params) {
1619
1281
  await this.ensureInitialized(repo.id);
1620
1282
  const { target, direction } = params;
1621
- const maxDepth = params.maxDepth ?? 3;
1622
- const relationTypes = params.relationTypes && params.relationTypes.length > 0
1623
- ? params.relationTypes
1283
+ const maxDepth = params.maxDepth || 3;
1284
+ const rawRelTypes = params.relationTypes && params.relationTypes.length > 0
1285
+ ? params.relationTypes.filter(t => VALID_RELATION_TYPES.has(t))
1624
1286
  : ['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS'];
1287
+ const relationTypes = rawRelTypes.length > 0 ? rawRelTypes : ['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS'];
1625
1288
  const includeTests = params.includeTests ?? false;
1626
- const minConfidence = params.minConfidence ?? 0.3;
1289
+ const minConfidence = params.minConfidence ?? 0;
1627
1290
  const relTypeFilter = relationTypes.map(t => `'${t}'`).join(', ');
1628
1291
  const confidenceFilter = minConfidence > 0 ? ` AND r.confidence >= ${minConfidence}` : '';
1629
- let targets = [];
1630
- if (params.target_uid?.trim()) {
1631
- const escapedUid = params.target_uid.trim().replace(/'/g, "''");
1632
- targets = await executeQuery(repo.id, `
1633
- MATCH (n {id: '${escapedUid}'})
1634
- RETURN n.id AS id, n.name AS name, n.filePath AS filePath, n.startLine AS startLine
1635
- LIMIT 1
1636
- `);
1637
- }
1638
- else {
1639
- const escapedTarget = target.replace(/'/g, "''");
1640
- const fileFilter = params.file_path?.trim()
1641
- ? ` AND n.filePath CONTAINS '${params.file_path.trim().replace(/'/g, "''")}'`
1642
- : '';
1643
- targets = await executeQuery(repo.id, `
1644
- MATCH (n)
1645
- WHERE n.name = '${escapedTarget}'${fileFilter}
1646
- RETURN n.id AS id, n.name AS name, n.filePath AS filePath, n.startLine AS startLine
1647
- LIMIT 20
1648
- `);
1649
- }
1292
+ const targets = await executeParameterized(repo.id, `
1293
+ MATCH (n)
1294
+ WHERE n.name = $targetName
1295
+ RETURN n.id AS id, n.name AS name, labels(n)[0] AS type, n.filePath AS filePath
1296
+ LIMIT 1
1297
+ `, { targetName: target });
1650
1298
  if (targets.length === 0)
1651
1299
  return { error: `Target '${target}' not found` };
1652
- if (targets.length > 1 && !params.target_uid) {
1653
- return {
1654
- status: 'ambiguous',
1655
- message: `Found ${targets.length} symbols matching '${target}'. Use target_uid or file_path to disambiguate.`,
1656
- candidates: targets.map((s) => ({
1657
- uid: getRowValue(s, 'id', 0),
1658
- name: getRowValue(s, 'name', 1),
1659
- kind: inferTypeFromId(getRowValue(s, 'id', 0)) || 'CodeElement',
1660
- filePath: getRowValue(s, 'filePath', 2),
1661
- line: getRowValue(s, 'startLine', 3),
1662
- })),
1663
- };
1664
- }
1665
1300
  const sym = targets[0];
1666
- const symId = getRowValue(sym, 'id', 0) || '';
1667
- const symType = inferTypeFromId(symId) || 'CodeElement';
1668
- let seedIds = [symId];
1669
- // Class/interface references are frequently attached to member symbols.
1670
- // Seed traversal with symbols from the same file to approximate class blast radius.
1671
- if ((symType === 'Class' || symType === 'Interface')) {
1672
- const targetFilePath = getRowValue(sym, 'filePath', 2);
1673
- if (targetFilePath) {
1674
- try {
1675
- const escapedPath = targetFilePath.replace(/'/g, "''");
1676
- const seedRows = await executeQuery(repo.id, `
1677
- MATCH (n)
1678
- WHERE n.filePath = '${escapedPath}'
1679
- RETURN n.id AS id
1680
- LIMIT 200
1681
- `);
1682
- const seedSet = new Set([symId]);
1683
- for (const row of seedRows) {
1684
- const id = getRowValue(row, 'id', 0);
1685
- if (!id)
1686
- continue;
1687
- if (id.startsWith('File:') || id.startsWith('Folder:') || id.startsWith('Community:') || id.startsWith('Process:')) {
1688
- continue;
1689
- }
1690
- seedSet.add(id);
1691
- }
1692
- seedIds = Array.from(seedSet);
1693
- }
1694
- catch {
1695
- // fallback to class node only
1696
- }
1697
- }
1698
- }
1301
+ const symId = sym.id || sym[0];
1699
1302
  const impacted = [];
1700
- const visited = new Set(seedIds);
1701
- let frontier = [...seedIds];
1303
+ const visited = new Set([symId]);
1304
+ let frontier = [symId];
1305
+ let traversalComplete = true;
1702
1306
  for (let depth = 1; depth <= maxDepth && frontier.length > 0; depth++) {
1703
1307
  const nextFrontier = [];
1704
1308
  // Batch frontier nodes into a single Cypher query per depth level
1705
1309
  const idList = frontier.map(id => `'${id.replace(/'/g, "''")}'`).join(', ');
1706
1310
  const query = direction === 'upstream'
1707
- ? `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, caller.filePath AS filePath, r.type AS relType, r.confidence AS confidence`
1708
- : `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, callee.filePath AS filePath, r.type AS relType, r.confidence AS confidence`;
1311
+ ? `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`
1312
+ : `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`;
1709
1313
  try {
1710
1314
  const related = await executeQuery(repo.id, query);
1711
1315
  for (const rel of related) {
1712
- const relId = getRowValue(rel, 'id', 1) || '';
1713
- const filePath = getRowValue(rel, 'filePath', 3) || '';
1714
- const relNodeType = inferTypeFromId(relId) || 'CodeElement';
1316
+ const relId = rel.id || rel[1];
1317
+ const filePath = rel.filePath || rel[4] || '';
1715
1318
  if (!includeTests && isTestFilePath(filePath))
1716
1319
  continue;
1717
- if (relNodeType === 'File' || relNodeType === 'Folder' || relNodeType === 'Community' || relNodeType === 'Process')
1718
- continue;
1719
1320
  if (!visited.has(relId)) {
1720
1321
  visited.add(relId);
1721
1322
  nextFrontier.push(relId);
1722
1323
  impacted.push({
1723
1324
  depth,
1724
1325
  id: relId,
1725
- name: getRowValue(rel, 'name', 2),
1726
- type: relNodeType,
1326
+ name: rel.name || rel[2],
1327
+ type: rel.type || rel[3],
1727
1328
  filePath,
1728
- relationType: getRowValue(rel, 'relType', 4),
1729
- confidence: Number(getRowValue(rel, 'confidence', 5) ?? 1.0),
1329
+ relationType: rel.relType || rel[5],
1330
+ confidence: rel.confidence || rel[6] || 1.0,
1730
1331
  });
1731
1332
  }
1732
1333
  }
1733
1334
  }
1734
- catch { /* query failed for this depth level */ }
1335
+ catch (e) {
1336
+ logQueryError('impact:depth-traversal', e);
1337
+ // Break out of depth loop on query failure but return partial results
1338
+ // collected so far, rather than silently swallowing the error (#321)
1339
+ traversalComplete = false;
1340
+ break;
1341
+ }
1735
1342
  frontier = nextFrontier;
1736
1343
  }
1737
1344
  const grouped = {};
@@ -1801,13 +1408,14 @@ export class LocalBackend {
1801
1408
  return {
1802
1409
  target: {
1803
1410
  id: symId,
1804
- name: getRowValue(sym, 'name', 1),
1805
- type: symType,
1806
- filePath: getRowValue(sym, 'filePath', 2),
1411
+ name: sym.name || sym[1],
1412
+ type: sym.type || sym[2],
1413
+ filePath: sym.filePath || sym[3],
1807
1414
  },
1808
1415
  direction,
1809
1416
  impactedCount: impacted.length,
1810
1417
  risk,
1418
+ ...(!traversalComplete && { partial: true }),
1811
1419
  summary: {
1812
1420
  direct: directCount,
1813
1421
  processes_affected: processCount,
@@ -1882,13 +1490,11 @@ export class LocalBackend {
1882
1490
  async queryClusterDetail(name, repoName) {
1883
1491
  const repo = await this.resolveRepo(repoName);
1884
1492
  await this.ensureInitialized(repo.id);
1885
- const escaped = name.replace(/'/g, "''");
1886
- const clusterQuery = `
1493
+ const clusters = await executeParameterized(repo.id, `
1887
1494
  MATCH (c:Community)
1888
- WHERE c.label = '${escaped}' OR c.heuristicLabel = '${escaped}'
1495
+ WHERE c.label = $clusterName OR c.heuristicLabel = $clusterName
1889
1496
  RETURN c.id AS id, c.label AS label, c.heuristicLabel AS heuristicLabel, c.cohesion AS cohesion, c.symbolCount AS symbolCount
1890
- `;
1891
- const clusters = await executeQuery(repo.id, clusterQuery);
1497
+ `, { clusterName: name });
1892
1498
  if (clusters.length === 0)
1893
1499
  return { error: `Cluster '${name}' not found` };
1894
1500
  const rawClusters = clusters.map((c) => ({
@@ -1901,12 +1507,12 @@ export class LocalBackend {
1901
1507
  totalSymbols += s;
1902
1508
  weightedCohesion += (c.cohesion || 0) * s;
1903
1509
  }
1904
- const members = await executeQuery(repo.id, `
1510
+ const members = await executeParameterized(repo.id, `
1905
1511
  MATCH (n)-[:CodeRelation {type: 'MEMBER_OF'}]->(c:Community)
1906
- WHERE c.label = '${escaped}' OR c.heuristicLabel = '${escaped}'
1907
- RETURN DISTINCT n.id AS id, n.name AS name, n.filePath AS filePath
1512
+ WHERE c.label = $clusterName OR c.heuristicLabel = $clusterName
1513
+ RETURN DISTINCT n.name AS name, labels(n)[0] AS type, n.filePath AS filePath
1908
1514
  LIMIT 30
1909
- `);
1515
+ `, { clusterName: name });
1910
1516
  return {
1911
1517
  cluster: {
1912
1518
  id: rawClusters[0].id,
@@ -1917,9 +1523,7 @@ export class LocalBackend {
1917
1523
  subCommunities: rawClusters.length,
1918
1524
  },
1919
1525
  members: members.map((m) => ({
1920
- name: getRowValue(m, 'name', 1),
1921
- type: inferTypeFromId(getRowValue(m, 'id', 0)) || 'CodeElement',
1922
- filePath: getRowValue(m, 'filePath', 2),
1526
+ name: m.name || m[0], type: m.type || m[1], filePath: m.filePath || m[2],
1923
1527
  })),
1924
1528
  };
1925
1529
  }
@@ -1930,37 +1534,33 @@ export class LocalBackend {
1930
1534
  async queryProcessDetail(name, repoName) {
1931
1535
  const repo = await this.resolveRepo(repoName);
1932
1536
  await this.ensureInitialized(repo.id);
1933
- const escaped = name.replace(/'/g, "''");
1934
- const processes = await executeQuery(repo.id, `
1537
+ const processes = await executeParameterized(repo.id, `
1935
1538
  MATCH (p:Process)
1936
- WHERE p.label = '${escaped}' OR p.heuristicLabel = '${escaped}'
1539
+ WHERE p.label = $processName OR p.heuristicLabel = $processName
1937
1540
  RETURN p.id AS id, p.label AS label, p.heuristicLabel AS heuristicLabel, p.processType AS processType, p.stepCount AS stepCount
1938
1541
  LIMIT 1
1939
- `);
1542
+ `, { processName: name });
1940
1543
  if (processes.length === 0)
1941
1544
  return { error: `Process '${name}' not found` };
1942
1545
  const proc = processes[0];
1943
1546
  const procId = proc.id || proc[0];
1944
- const steps = await executeQuery(repo.id, `
1945
- MATCH (n)-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p {id: '${procId}'})
1946
- RETURN n.id AS id, n.name AS name, n.filePath AS filePath, r.step AS step
1547
+ const steps = await executeParameterized(repo.id, `
1548
+ MATCH (n)-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p {id: $procId})
1549
+ RETURN n.name AS name, labels(n)[0] AS type, n.filePath AS filePath, r.step AS step
1947
1550
  ORDER BY r.step
1948
- `);
1551
+ `, { procId });
1949
1552
  return {
1950
1553
  process: {
1951
1554
  id: procId, label: proc.label || proc[1], heuristicLabel: proc.heuristicLabel || proc[2],
1952
1555
  processType: proc.processType || proc[3], stepCount: proc.stepCount || proc[4],
1953
1556
  },
1954
1557
  steps: steps.map((s) => ({
1955
- step: getRowValue(s, 'step', 3),
1956
- name: getRowValue(s, 'name', 1),
1957
- type: inferTypeFromId(getRowValue(s, 'id', 0)) || 'CodeElement',
1958
- filePath: getRowValue(s, 'filePath', 2),
1558
+ step: s.step || s[3], name: s.name || s[0], type: s.type || s[1], filePath: s.filePath || s[2],
1959
1559
  })),
1960
1560
  };
1961
1561
  }
1962
1562
  async disconnect() {
1963
- await closeKuzu(); // close all connections
1563
+ await closeLbug(); // close all connections
1964
1564
  // Note: we intentionally do NOT call disposeEmbedder() here.
1965
1565
  // ONNX Runtime's native cleanup segfaults on macOS and some Linux configs,
1966
1566
  // and importing the embedder module on Node v24+ crashes if onnxruntime