gitnexus 1.4.8 → 1.4.10

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 (211) hide show
  1. package/README.md +7 -0
  2. package/dist/cli/index-repo.d.ts +15 -0
  3. package/dist/cli/index-repo.js +115 -0
  4. package/dist/cli/index.js +11 -2
  5. package/dist/cli/setup.js +12 -9
  6. package/dist/cli/wiki.d.ts +4 -0
  7. package/dist/cli/wiki.js +174 -53
  8. package/dist/config/supported-languages.d.ts +7 -5
  9. package/dist/config/supported-languages.js +6 -4
  10. package/dist/core/graph/graph.js +9 -1
  11. package/dist/core/graph/types.d.ts +10 -2
  12. package/dist/core/ingestion/call-processor.d.ts +18 -1
  13. package/dist/core/ingestion/call-processor.js +297 -38
  14. package/dist/core/ingestion/call-routing.d.ts +3 -18
  15. package/dist/core/ingestion/call-routing.js +0 -19
  16. package/dist/core/ingestion/cobol/cobol-copy-expander.d.ts +57 -0
  17. package/dist/core/ingestion/cobol/cobol-copy-expander.js +385 -0
  18. package/dist/core/ingestion/cobol/cobol-preprocessor.d.ts +210 -0
  19. package/dist/core/ingestion/cobol/cobol-preprocessor.js +1509 -0
  20. package/dist/core/ingestion/cobol/jcl-parser.d.ts +68 -0
  21. package/dist/core/ingestion/cobol/jcl-parser.js +217 -0
  22. package/dist/core/ingestion/cobol/jcl-processor.d.ts +33 -0
  23. package/dist/core/ingestion/cobol/jcl-processor.js +229 -0
  24. package/dist/core/ingestion/cobol-processor.d.ts +54 -0
  25. package/dist/core/ingestion/cobol-processor.js +1186 -0
  26. package/dist/core/ingestion/entry-point-scoring.d.ts +17 -0
  27. package/dist/core/ingestion/entry-point-scoring.js +18 -4
  28. package/dist/core/ingestion/export-detection.d.ts +47 -8
  29. package/dist/core/ingestion/export-detection.js +29 -50
  30. package/dist/core/ingestion/field-extractor.d.ts +29 -0
  31. package/dist/core/ingestion/field-extractor.js +25 -0
  32. package/dist/core/ingestion/field-extractors/configs/c-cpp.d.ts +3 -0
  33. package/dist/core/ingestion/field-extractors/configs/c-cpp.js +108 -0
  34. package/dist/core/ingestion/field-extractors/configs/csharp.d.ts +8 -0
  35. package/dist/core/ingestion/field-extractors/configs/csharp.js +73 -0
  36. package/dist/core/ingestion/field-extractors/configs/dart.d.ts +8 -0
  37. package/dist/core/ingestion/field-extractors/configs/dart.js +76 -0
  38. package/dist/core/ingestion/field-extractors/configs/go.d.ts +11 -0
  39. package/dist/core/ingestion/field-extractors/configs/go.js +64 -0
  40. package/dist/core/ingestion/field-extractors/configs/helpers.d.ts +44 -0
  41. package/dist/core/ingestion/field-extractors/configs/helpers.js +134 -0
  42. package/dist/core/ingestion/field-extractors/configs/jvm.d.ts +3 -0
  43. package/dist/core/ingestion/field-extractors/configs/jvm.js +118 -0
  44. package/dist/core/ingestion/field-extractors/configs/php.d.ts +8 -0
  45. package/dist/core/ingestion/field-extractors/configs/php.js +67 -0
  46. package/dist/core/ingestion/field-extractors/configs/python.d.ts +12 -0
  47. package/dist/core/ingestion/field-extractors/configs/python.js +91 -0
  48. package/dist/core/ingestion/field-extractors/configs/ruby.d.ts +16 -0
  49. package/dist/core/ingestion/field-extractors/configs/ruby.js +75 -0
  50. package/dist/core/ingestion/field-extractors/configs/rust.d.ts +9 -0
  51. package/dist/core/ingestion/field-extractors/configs/rust.js +55 -0
  52. package/dist/core/ingestion/field-extractors/configs/swift.d.ts +8 -0
  53. package/dist/core/ingestion/field-extractors/configs/swift.js +63 -0
  54. package/dist/core/ingestion/field-extractors/configs/typescript-javascript.d.ts +3 -0
  55. package/dist/core/ingestion/field-extractors/configs/typescript-javascript.js +60 -0
  56. package/dist/core/ingestion/field-extractors/generic.d.ts +46 -0
  57. package/dist/core/ingestion/field-extractors/generic.js +111 -0
  58. package/dist/core/ingestion/field-extractors/typescript.d.ts +77 -0
  59. package/dist/core/ingestion/field-extractors/typescript.js +291 -0
  60. package/dist/core/ingestion/field-types.d.ts +59 -0
  61. package/dist/core/ingestion/field-types.js +2 -0
  62. package/dist/core/ingestion/framework-detection.d.ts +87 -0
  63. package/dist/core/ingestion/framework-detection.js +65 -2
  64. package/dist/core/ingestion/heritage-processor.js +15 -17
  65. package/dist/core/ingestion/import-processor.d.ts +9 -10
  66. package/dist/core/ingestion/import-processor.js +59 -14
  67. package/dist/core/ingestion/{resolvers → import-resolvers}/csharp.d.ts +6 -9
  68. package/dist/core/ingestion/{resolvers → import-resolvers}/csharp.js +20 -2
  69. package/dist/core/ingestion/import-resolvers/dart.d.ts +7 -0
  70. package/dist/core/ingestion/import-resolvers/dart.js +44 -0
  71. package/dist/core/ingestion/{resolvers → import-resolvers}/go.d.ts +4 -5
  72. package/dist/core/ingestion/{resolvers → import-resolvers}/go.js +17 -0
  73. package/dist/core/ingestion/{resolvers → import-resolvers}/jvm.d.ts +9 -1
  74. package/dist/core/ingestion/{resolvers → import-resolvers}/jvm.js +56 -0
  75. package/dist/core/ingestion/{resolvers → import-resolvers}/php.d.ts +6 -10
  76. package/dist/core/ingestion/{resolvers → import-resolvers}/php.js +7 -2
  77. package/dist/core/ingestion/{resolvers → import-resolvers}/python.d.ts +9 -3
  78. package/dist/core/ingestion/{resolvers → import-resolvers}/python.js +35 -3
  79. package/dist/core/ingestion/{resolvers → import-resolvers}/ruby.d.ts +5 -2
  80. package/dist/core/ingestion/{resolvers → import-resolvers}/ruby.js +7 -2
  81. package/dist/core/ingestion/{resolvers → import-resolvers}/rust.d.ts +5 -2
  82. package/dist/core/ingestion/{resolvers → import-resolvers}/rust.js +41 -2
  83. package/dist/core/ingestion/{resolvers → import-resolvers}/standard.d.ts +15 -7
  84. package/dist/core/ingestion/{resolvers → import-resolvers}/standard.js +22 -3
  85. package/dist/core/ingestion/import-resolvers/swift.d.ts +7 -0
  86. package/dist/core/ingestion/import-resolvers/swift.js +23 -0
  87. package/dist/core/ingestion/import-resolvers/types.d.ts +44 -0
  88. package/dist/core/ingestion/import-resolvers/types.js +6 -0
  89. package/dist/core/ingestion/{resolvers → import-resolvers}/utils.d.ts +0 -3
  90. package/dist/core/ingestion/{resolvers → import-resolvers}/utils.js +0 -9
  91. package/dist/core/ingestion/language-config.d.ts +4 -1
  92. package/dist/core/ingestion/language-provider.d.ts +121 -0
  93. package/dist/core/ingestion/language-provider.js +24 -0
  94. package/dist/core/ingestion/languages/c-cpp.d.ts +12 -0
  95. package/dist/core/ingestion/languages/c-cpp.js +71 -0
  96. package/dist/core/ingestion/languages/cobol.d.ts +1 -0
  97. package/dist/core/ingestion/languages/cobol.js +26 -0
  98. package/dist/core/ingestion/languages/csharp.d.ts +8 -0
  99. package/dist/core/ingestion/languages/csharp.js +49 -0
  100. package/dist/core/ingestion/languages/dart.d.ts +12 -0
  101. package/dist/core/ingestion/languages/dart.js +58 -0
  102. package/dist/core/ingestion/languages/go.d.ts +11 -0
  103. package/dist/core/ingestion/languages/go.js +28 -0
  104. package/dist/core/ingestion/languages/index.d.ts +38 -0
  105. package/dist/core/ingestion/languages/index.js +63 -0
  106. package/dist/core/ingestion/languages/java.d.ts +9 -0
  107. package/dist/core/ingestion/languages/java.js +29 -0
  108. package/dist/core/ingestion/languages/kotlin.d.ts +9 -0
  109. package/dist/core/ingestion/languages/kotlin.js +53 -0
  110. package/dist/core/ingestion/languages/php.d.ts +8 -0
  111. package/dist/core/ingestion/languages/php.js +145 -0
  112. package/dist/core/ingestion/languages/python.d.ts +12 -0
  113. package/dist/core/ingestion/languages/python.js +39 -0
  114. package/dist/core/ingestion/languages/ruby.d.ts +9 -0
  115. package/dist/core/ingestion/languages/ruby.js +44 -0
  116. package/dist/core/ingestion/languages/rust.d.ts +12 -0
  117. package/dist/core/ingestion/languages/rust.js +44 -0
  118. package/dist/core/ingestion/languages/swift.d.ts +12 -0
  119. package/dist/core/ingestion/languages/swift.js +133 -0
  120. package/dist/core/ingestion/languages/typescript.d.ts +10 -0
  121. package/dist/core/ingestion/languages/typescript.js +60 -0
  122. package/dist/core/ingestion/mro-processor.js +14 -15
  123. package/dist/core/ingestion/{named-binding-extraction.d.ts → named-binding-processor.d.ts} +0 -9
  124. package/dist/core/ingestion/named-binding-processor.js +42 -0
  125. package/dist/core/ingestion/named-bindings/csharp.d.ts +3 -0
  126. package/dist/core/ingestion/named-bindings/csharp.js +37 -0
  127. package/dist/core/ingestion/named-bindings/java.d.ts +3 -0
  128. package/dist/core/ingestion/named-bindings/java.js +29 -0
  129. package/dist/core/ingestion/named-bindings/kotlin.d.ts +3 -0
  130. package/dist/core/ingestion/named-bindings/kotlin.js +36 -0
  131. package/dist/core/ingestion/named-bindings/php.d.ts +3 -0
  132. package/dist/core/ingestion/named-bindings/php.js +61 -0
  133. package/dist/core/ingestion/named-bindings/python.d.ts +3 -0
  134. package/dist/core/ingestion/named-bindings/python.js +49 -0
  135. package/dist/core/ingestion/named-bindings/rust.d.ts +3 -0
  136. package/dist/core/ingestion/named-bindings/rust.js +64 -0
  137. package/dist/core/ingestion/named-bindings/types.d.ts +16 -0
  138. package/dist/core/ingestion/named-bindings/types.js +6 -0
  139. package/dist/core/ingestion/named-bindings/typescript.d.ts +3 -0
  140. package/dist/core/ingestion/named-bindings/typescript.js +58 -0
  141. package/dist/core/ingestion/parsing-processor.d.ts +5 -1
  142. package/dist/core/ingestion/parsing-processor.js +115 -16
  143. package/dist/core/ingestion/pipeline.js +925 -424
  144. package/dist/core/ingestion/resolution-context.js +1 -1
  145. package/dist/core/ingestion/route-extractors/expo.d.ts +1 -0
  146. package/dist/core/ingestion/route-extractors/expo.js +36 -0
  147. package/dist/core/ingestion/route-extractors/middleware.d.ts +47 -0
  148. package/dist/core/ingestion/route-extractors/middleware.js +143 -0
  149. package/dist/core/ingestion/route-extractors/nextjs.d.ts +3 -0
  150. package/dist/core/ingestion/route-extractors/nextjs.js +76 -0
  151. package/dist/core/ingestion/route-extractors/php.d.ts +7 -0
  152. package/dist/core/ingestion/route-extractors/php.js +21 -0
  153. package/dist/core/ingestion/route-extractors/response-shapes.d.ts +20 -0
  154. package/dist/core/ingestion/route-extractors/response-shapes.js +290 -0
  155. package/dist/core/ingestion/tree-sitter-queries.d.ts +8 -7
  156. package/dist/core/ingestion/tree-sitter-queries.js +231 -9
  157. package/dist/core/ingestion/type-env.d.ts +14 -17
  158. package/dist/core/ingestion/type-env.js +66 -14
  159. package/dist/core/ingestion/type-extractors/c-cpp.d.ts +1 -1
  160. package/dist/core/ingestion/type-extractors/csharp.js +1 -1
  161. package/dist/core/ingestion/type-extractors/dart.d.ts +15 -0
  162. package/dist/core/ingestion/type-extractors/dart.js +371 -0
  163. package/dist/core/ingestion/type-extractors/jvm.js +1 -1
  164. package/dist/core/ingestion/type-extractors/shared.d.ts +1 -13
  165. package/dist/core/ingestion/type-extractors/shared.js +9 -102
  166. package/dist/core/ingestion/type-extractors/swift.js +334 -4
  167. package/dist/core/ingestion/type-extractors/types.d.ts +3 -1
  168. package/dist/core/ingestion/{ast-helpers.d.ts → utils/ast-helpers.d.ts} +16 -13
  169. package/dist/core/ingestion/{ast-helpers.js → utils/ast-helpers.js} +111 -32
  170. package/dist/core/ingestion/{call-analysis.js → utils/call-analysis.js} +37 -0
  171. package/dist/core/ingestion/utils/event-loop.d.ts +5 -0
  172. package/dist/core/ingestion/utils/event-loop.js +5 -0
  173. package/dist/core/ingestion/utils/language-detection.d.ts +9 -0
  174. package/dist/core/ingestion/utils/language-detection.js +70 -0
  175. package/dist/core/ingestion/utils/verbose.d.ts +1 -0
  176. package/dist/core/ingestion/utils/verbose.js +7 -0
  177. package/dist/core/ingestion/workers/parse-worker.d.ts +43 -2
  178. package/dist/core/ingestion/workers/parse-worker.js +361 -150
  179. package/dist/core/lbug/csv-generator.js +34 -1
  180. package/dist/core/lbug/lbug-adapter.js +6 -0
  181. package/dist/core/lbug/schema.d.ts +5 -3
  182. package/dist/core/lbug/schema.js +39 -2
  183. package/dist/core/tree-sitter/parser-loader.js +7 -1
  184. package/dist/core/wiki/cursor-client.d.ts +31 -0
  185. package/dist/core/wiki/cursor-client.js +127 -0
  186. package/dist/core/wiki/generator.d.ts +28 -9
  187. package/dist/core/wiki/generator.js +115 -18
  188. package/dist/core/wiki/graph-queries.d.ts +4 -0
  189. package/dist/core/wiki/graph-queries.js +7 -1
  190. package/dist/core/wiki/llm-client.d.ts +2 -0
  191. package/dist/core/wiki/llm-client.js +8 -4
  192. package/dist/core/wiki/prompts.d.ts +3 -3
  193. package/dist/core/wiki/prompts.js +6 -0
  194. package/dist/mcp/core/lbug-adapter.d.ts +5 -0
  195. package/dist/mcp/core/lbug-adapter.js +11 -1
  196. package/dist/mcp/local/local-backend.d.ts +16 -5
  197. package/dist/mcp/local/local-backend.js +711 -74
  198. package/dist/mcp/tools.js +71 -2
  199. package/dist/storage/repo-manager.d.ts +3 -0
  200. package/package.json +14 -14
  201. package/dist/core/ingestion/import-resolution.d.ts +0 -101
  202. package/dist/core/ingestion/import-resolution.js +0 -251
  203. package/dist/core/ingestion/named-binding-extraction.js +0 -373
  204. package/dist/core/ingestion/resolvers/index.d.ts +0 -18
  205. package/dist/core/ingestion/resolvers/index.js +0 -13
  206. package/dist/core/ingestion/type-extractors/index.d.ts +0 -22
  207. package/dist/core/ingestion/type-extractors/index.js +0 -31
  208. package/dist/core/ingestion/utils.d.ts +0 -20
  209. package/dist/core/ingestion/utils.js +0 -242
  210. package/scripts/patch-tree-sitter-swift.cjs +0 -74
  211. /package/dist/core/ingestion/{call-analysis.d.ts → utils/call-analysis.d.ts} +0 -0
@@ -7,7 +7,8 @@
7
7
  */
8
8
  import fs from 'fs/promises';
9
9
  import path from 'path';
10
- import { initLbug, executeQuery, executeParameterized, closeLbug, isLbugReady } from '../core/lbug-adapter.js';
10
+ import { initLbug, executeQuery, executeParameterized, closeLbug, isLbugReady, isWriteQuery } from '../core/lbug-adapter.js';
11
+ export { isWriteQuery };
11
12
  // Embedding imports are lazy (dynamic import) to avoid loading onnxruntime-node
12
13
  // at MCP server startup — crashes on unsupported Node ABI versions (#89)
13
14
  // git utilities available if needed
@@ -35,9 +36,11 @@ export const VALID_NODE_LABELS = new Set([
35
36
  'Community', 'Process', 'Struct', 'Enum', 'Macro', 'Typedef', 'Union',
36
37
  'Namespace', 'Trait', 'Impl', 'TypeAlias', 'Const', 'Static', 'Property',
37
38
  'Record', 'Delegate', 'Annotation', 'Constructor', 'Template', 'Module',
39
+ 'Route',
40
+ 'Tool',
38
41
  ]);
39
42
  /** Valid relation types for impact analysis filtering */
40
- export const VALID_RELATION_TYPES = new Set(['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS', 'HAS_METHOD', 'HAS_PROPERTY', 'OVERRIDES', 'ACCESSES']);
43
+ export const VALID_RELATION_TYPES = new Set(['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS', 'HAS_METHOD', 'HAS_PROPERTY', 'OVERRIDES', 'ACCESSES', 'HANDLES_ROUTE', 'FETCHES', 'HANDLES_TOOL', 'ENTRY_POINT_OF', 'WRAPS']);
41
44
  /**
42
45
  * Per-relation-type confidence floor for impact analysis.
43
46
  *
@@ -73,12 +76,6 @@ export const IMPACT_RELATION_CONFIDENCE = {
73
76
  * Falls back to 0.5 for unknown types so they are not silently elevated.
74
77
  */
75
78
  const confidenceForRelType = (relType) => IMPACT_RELATION_CONFIDENCE[relType ?? ''] ?? 0.5;
76
- /** Regex to detect write operations in user-supplied Cypher queries */
77
- export const CYPHER_WRITE_RE = /\b(CREATE|DELETE|SET|MERGE|REMOVE|DROP|ALTER|COPY|DETACH)\b/i;
78
- /** Check if a Cypher query contains write operations */
79
- export function isWriteQuery(query) {
80
- return CYPHER_WRITE_RE.test(query);
81
- }
82
79
  /** Structured error logging for query failures — replaces empty catch blocks */
83
80
  function logQueryError(context, err) {
84
81
  const msg = err instanceof Error ? err.message : String(err);
@@ -346,6 +343,14 @@ export class LocalBackend {
346
343
  return this.context(repo, { name: params?.name, ...params });
347
344
  case 'overview':
348
345
  return this.overview(repo, params);
346
+ case 'route_map':
347
+ return this.routeMap(repo, params);
348
+ case 'shape_check':
349
+ return this.shapeCheck(repo, params);
350
+ case 'tool_map':
351
+ return this.toolMap(repo, params);
352
+ case 'api_impact':
353
+ return this.apiImpact(repo, params);
349
354
  default:
350
355
  throw new Error(`Unknown tool: ${method}`);
351
356
  }
@@ -370,10 +375,12 @@ export class LocalBackend {
370
375
  const searchQuery = params.query.trim();
371
376
  // Step 1: Run hybrid search to get matching symbols
372
377
  const searchLimit = processLimit * maxSymbolsPerProcess; // fetch enough raw results
373
- const [bm25Results, semanticResults] = await Promise.all([
378
+ const [bm25SearchResult, semanticResults] = await Promise.all([
374
379
  this.bm25Search(repo, searchQuery, searchLimit),
375
380
  this.semanticSearch(repo, searchQuery, searchLimit),
376
381
  ]);
382
+ const bm25Results = bm25SearchResult.results;
383
+ const ftsUsed = bm25SearchResult.ftsUsed;
377
384
  // Merge via reciprocal rank fusion
378
385
  const scoreMap = new Map();
379
386
  for (let i = 0; i < bm25Results.length; i++) {
@@ -540,6 +547,7 @@ export class LocalBackend {
540
547
  processes,
541
548
  process_symbols: dedupedSymbols,
542
549
  definitions: definitions.slice(0, 20), // cap standalone definitions
550
+ ...(!ftsUsed && { warning: 'FTS extension unavailable - keyword search degraded. Run: gitnexus analyze --force to rebuild indexes.' }),
543
551
  };
544
552
  }
545
553
  /**
@@ -553,8 +561,9 @@ export class LocalBackend {
553
561
  }
554
562
  catch (err) {
555
563
  console.error('GitNexus: BM25/FTS search failed (FTS indexes may not exist) -', err.message);
556
- return [];
564
+ return { results: [], ftsUsed: false };
557
565
  }
566
+ const ftsUsed = bm25Results.length === 0 || (bm25Results[0]?.ftsUsed !== false);
558
567
  const results = [];
559
568
  for (const bm25Result of bm25Results) {
560
569
  const fullPath = bm25Result.filePath;
@@ -598,7 +607,7 @@ export class LocalBackend {
598
607
  });
599
608
  }
600
609
  }
601
- return results;
610
+ return { results, ftsUsed };
602
611
  }
603
612
  /**
604
613
  * Semantic vector search helper
@@ -671,7 +680,7 @@ export class LocalBackend {
671
680
  return { error: 'LadybugDB not ready. Index may be corrupted.' };
672
681
  }
673
682
  // Block write operations (defense-in-depth — DB is already read-only)
674
- if (CYPHER_WRITE_RE.test(params.query)) {
683
+ if (isWriteQuery(params.query)) {
675
684
  return { error: 'Write operations (CREATE, DELETE, SET, MERGE, REMOVE, DROP, ALTER, COPY, DETACH) are not allowed. The knowledge graph is read-only.' };
676
685
  }
677
686
  try {
@@ -847,6 +856,47 @@ export class LocalBackend {
847
856
  return { error: `Symbol '${name || uid}' not found` };
848
857
  }
849
858
  // Step 2: Disambiguation
859
+ // When multiple nodes share the same name (e.g. a Java Class and its
860
+ // Constructor both named 'SessionTracker'), prefer the Class node so
861
+ // context() returns the semantically meaningful result rather than
862
+ // triggering ambiguous disambiguation (#480).
863
+ // labels(n)[0] returns empty string in LadybugDB, so we resolve the
864
+ // preferred node by re-querying with explicit label filters, scoped to
865
+ // the candidate IDs already in symbols.
866
+ //
867
+ // Guard: only attempt Class-preference when at least one candidate has an
868
+ // empty/unknown type (LadybugDB limitation) or is a Constructor — meaning
869
+ // the ambiguity may be a Class/Constructor name collision rather than two
870
+ // genuinely distinct symbols (e.g. two Functions in different files).
871
+ //
872
+ // resolvedLabel is set here and threaded to Step 3 to avoid a redundant
873
+ // classCheck round-trip later.
874
+ let resolvedLabel = '';
875
+ if (symbols.length > 1 && !uid) {
876
+ const hasAmbiguousType = symbols.some((s) => {
877
+ const t = s.type || s[2] || '';
878
+ return t === '' || t === 'Constructor';
879
+ });
880
+ if (hasAmbiguousType) {
881
+ const candidateIds = symbols.map((s) => s.id || s[0]).filter(Boolean);
882
+ const PREFER_LABELS = ['Class', 'Interface'];
883
+ let preferred = null;
884
+ for (const label of PREFER_LABELS) {
885
+ const match = await executeParameterized(repo.id, `
886
+ MATCH (n:\`${label}\`) WHERE n.id IN $candidateIds RETURN n.id AS id LIMIT 1
887
+ `, { candidateIds }).catch(() => []);
888
+ if (match.length > 0) {
889
+ preferred = symbols.find((s) => (s.id || s[0]) === (match[0].id || match[0][0]));
890
+ if (preferred) {
891
+ resolvedLabel = label;
892
+ break;
893
+ }
894
+ }
895
+ }
896
+ if (preferred)
897
+ symbols = [preferred];
898
+ }
899
+ }
850
900
  if (symbols.length > 1 && !uid) {
851
901
  return {
852
902
  status: 'ambiguous',
@@ -864,12 +914,74 @@ export class LocalBackend {
864
914
  const sym = symbols[0];
865
915
  const symId = sym.id || sym[0];
866
916
  // Categorized incoming refs
867
- const incomingRows = await executeParameterized(repo.id, `
917
+ let incomingRows = await executeParameterized(repo.id, `
868
918
  MATCH (caller)-[r:CodeRelation]->(n {id: $symId})
869
919
  WHERE r.type IN ['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS', 'HAS_METHOD', 'HAS_PROPERTY', 'OVERRIDES', 'ACCESSES']
870
920
  RETURN r.type AS relType, caller.id AS uid, caller.name AS name, caller.filePath AS filePath, labels(caller)[0] AS kind
871
921
  LIMIT 30
872
922
  `, { symId });
923
+ // Fix #480: Class/Interface nodes have no direct CALLS/IMPORTS edges —
924
+ // those point to Constructor and File nodes respectively. Fetch those
925
+ // extra incoming refs and merge them in so context() shows real callers.
926
+ //
927
+ // Determine if this is a Class/Interface node. If resolvedLabel was set
928
+ // during disambiguation (Step 2), use it directly — no extra round-trip.
929
+ // Otherwise fall back to a single label check only when the type field is
930
+ // empty (LadybugDB labels(n)[0] limitation).
931
+ const symRawType = sym.type || sym[2] || '';
932
+ let isClassLike = resolvedLabel === 'Class' || resolvedLabel === 'Interface';
933
+ if (!isClassLike && symRawType === '') {
934
+ try {
935
+ // Single UNION query instead of two serial round-trips.
936
+ const typeCheck = await executeParameterized(repo.id, `
937
+ MATCH (n:Class) WHERE n.id = $symId RETURN 'Class' AS label LIMIT 1
938
+ UNION ALL
939
+ MATCH (n:Interface) WHERE n.id = $symId RETURN 'Interface' AS label LIMIT 1
940
+ `, { symId });
941
+ isClassLike = typeCheck.length > 0;
942
+ }
943
+ catch { /* not a Class/Interface node */ }
944
+ }
945
+ else if (!isClassLike) {
946
+ isClassLike = symRawType === 'Class' || symRawType === 'Interface';
947
+ }
948
+ if (isClassLike) {
949
+ try {
950
+ // Run both incoming-ref queries in parallel — they are independent.
951
+ const [ctorIncoming, fileIncoming] = await Promise.all([
952
+ executeParameterized(repo.id, `
953
+ MATCH (n)-[hm:CodeRelation]->(ctor:Constructor)
954
+ WHERE n.id = $symId AND hm.type = 'HAS_METHOD'
955
+ MATCH (caller)-[r:CodeRelation]->(ctor)
956
+ WHERE r.type IN ['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS', 'ACCESSES']
957
+ RETURN r.type AS relType, caller.id AS uid, caller.name AS name, caller.filePath AS filePath, labels(caller)[0] AS kind
958
+ LIMIT 30
959
+ `, { symId }),
960
+ executeParameterized(repo.id, `
961
+ MATCH (f:File)-[rel:CodeRelation]->(n)
962
+ WHERE n.id = $symId AND rel.type = 'DEFINES'
963
+ MATCH (caller)-[r:CodeRelation]->(f)
964
+ WHERE r.type IN ['CALLS', 'IMPORTS']
965
+ RETURN r.type AS relType, caller.id AS uid, caller.name AS name, caller.filePath AS filePath, labels(caller)[0] AS kind
966
+ LIMIT 30
967
+ `, { symId }),
968
+ ]);
969
+ // Deduplicate by (relType, uid) — a caller can have multiple relation
970
+ // types to the same target (e.g. both IMPORTS and CALLS), and each
971
+ // must be preserved so every category appears in the output.
972
+ const seenKeys = new Set(incomingRows.map((r) => `${r.relType || r[0]}:${r.uid || r[1]}`));
973
+ for (const r of [...ctorIncoming, ...fileIncoming]) {
974
+ const key = `${r.relType || r[0]}:${r.uid || r[1]}`;
975
+ if (!seenKeys.has(key)) {
976
+ seenKeys.add(key);
977
+ incomingRows.push(r);
978
+ }
979
+ }
980
+ }
981
+ catch (e) {
982
+ logQueryError('context:class-incoming-expansion', e);
983
+ }
984
+ }
873
985
  // Categorized outgoing refs
874
986
  const outgoingRows = await executeParameterized(repo.id, `
875
987
  MATCH (n {id: $symId})-[r:CodeRelation]->(target)
@@ -910,7 +1022,7 @@ export class LocalBackend {
910
1022
  symbol: {
911
1023
  uid: sym.id || sym[0],
912
1024
  name: sym.name || sym[1],
913
- kind: sym.type || sym[2],
1025
+ kind: isClassLike ? (resolvedLabel || 'Class') : (sym.type || sym[2]),
914
1026
  filePath: sym.filePath || sym[3],
915
1027
  startLine: sym.startLine || sym[4],
916
1028
  endLine: sym.endLine || sym[5],
@@ -1294,20 +1406,100 @@ export class LocalBackend {
1294
1406
  const minConfidence = params.minConfidence ?? 0;
1295
1407
  const relTypeFilter = relationTypes.map(t => `'${t}'`).join(', ');
1296
1408
  const confidenceFilter = minConfidence > 0 ? ` AND r.confidence >= ${minConfidence}` : '';
1297
- const targets = await executeParameterized(repo.id, `
1298
- MATCH (n)
1299
- WHERE n.name = $targetName
1300
- RETURN n.id AS id, n.name AS name, labels(n)[0] AS type, n.filePath AS filePath
1301
- LIMIT 1
1302
- `, { targetName: target });
1303
- if (targets.length === 0)
1409
+ // Resolve target by name, preferring Class/Interface over Constructor
1410
+ // (fix #480: Java class and constructor share the same name).
1411
+ // labels(n)[0] returns empty string in LadybugDB, so we use explicit
1412
+ // label-typed sub-queries in a single UNION ordered by priority to avoid
1413
+ // up to 6 serial round-trips for non-Class targets.
1414
+ let sym = null;
1415
+ let symType = '';
1416
+ try {
1417
+ const rows = await executeParameterized(repo.id, `
1418
+ MATCH (n:\`Class\`) WHERE n.name = $targetName
1419
+ RETURN n.id AS id, n.name AS name, n.filePath AS filePath, 0 AS priority LIMIT 1
1420
+ UNION ALL
1421
+ MATCH (n:\`Interface\`) WHERE n.name = $targetName
1422
+ RETURN n.id AS id, n.name AS name, n.filePath AS filePath, 1 AS priority LIMIT 1
1423
+ UNION ALL
1424
+ MATCH (n:\`Function\`) WHERE n.name = $targetName
1425
+ RETURN n.id AS id, n.name AS name, n.filePath AS filePath, 2 AS priority LIMIT 1
1426
+ UNION ALL
1427
+ MATCH (n:\`Method\`) WHERE n.name = $targetName
1428
+ RETURN n.id AS id, n.name AS name, n.filePath AS filePath, 3 AS priority LIMIT 1
1429
+ UNION ALL
1430
+ MATCH (n:\`Constructor\`) WHERE n.name = $targetName
1431
+ RETURN n.id AS id, n.name AS name, n.filePath AS filePath, 4 AS priority LIMIT 1
1432
+ `, { targetName: target }).catch(() => []);
1433
+ if (rows.length > 0) {
1434
+ // Pick the row with the lowest priority value (Class wins over Constructor)
1435
+ const best = rows.reduce((a, b) => (a.priority ?? a[3] ?? 99) <= (b.priority ?? b[3] ?? 99) ? a : b);
1436
+ sym = best;
1437
+ const priorityToLabel = ['Class', 'Interface', 'Function', 'Method', 'Constructor'];
1438
+ symType = priorityToLabel[best.priority ?? best[3]] ?? '';
1439
+ }
1440
+ }
1441
+ catch { /* fall through to unlabeled match */ }
1442
+ // Fall back to unlabeled match for any other node type
1443
+ if (!sym) {
1444
+ const rows = await executeParameterized(repo.id, `
1445
+ MATCH (n)
1446
+ WHERE n.name = $targetName
1447
+ RETURN n.id AS id, n.name AS name, n.filePath AS filePath
1448
+ LIMIT 1
1449
+ `, { targetName: target });
1450
+ if (rows.length > 0)
1451
+ sym = rows[0];
1452
+ }
1453
+ if (!sym)
1304
1454
  return { error: `Target '${target}' not found` };
1305
- const sym = targets[0];
1306
1455
  const symId = sym.id || sym[0];
1307
1456
  const impacted = [];
1308
1457
  const visited = new Set([symId]);
1309
1458
  let frontier = [symId];
1310
1459
  let traversalComplete = true;
1460
+ // Fix #480: For Java (and other JVM) Class/Interface nodes, CALLS edges
1461
+ // point to Constructor nodes and IMPORTS edges point to File nodes — not
1462
+ // the Class/Interface itself. Seed the frontier with the Constructor(s)
1463
+ // and owning File so the BFS traversal finds those edges naturally.
1464
+ // The owning File is kept only as an internal seed (frontier/visited) and
1465
+ // is NOT added to impacted — it is the definition container, not an
1466
+ // upstream dependent. The BFS will discover IMPORTS edges on it naturally.
1467
+ if (symType === 'Class' || symType === 'Interface') {
1468
+ try {
1469
+ // Run both seed queries in parallel — they are independent.
1470
+ const [ctorRows, fileRows] = await Promise.all([
1471
+ executeParameterized(repo.id, `
1472
+ MATCH (n)-[hm:CodeRelation]->(c:Constructor)
1473
+ WHERE n.id = $symId AND hm.type = 'HAS_METHOD'
1474
+ RETURN c.id AS id, c.name AS name, labels(c)[0] AS type, c.filePath AS filePath
1475
+ `, { symId }),
1476
+ // Restrict to DEFINES edges only — other File->Class edge types (if
1477
+ // any) should not be treated as the owning file relationship.
1478
+ executeParameterized(repo.id, `
1479
+ MATCH (f:File)-[rel:CodeRelation]->(n)
1480
+ WHERE n.id = $symId AND rel.type = 'DEFINES'
1481
+ RETURN f.id AS id, f.name AS name, labels(f)[0] AS type, f.filePath AS filePath
1482
+ `, { symId }),
1483
+ ]);
1484
+ for (const r of ctorRows) {
1485
+ const rid = r.id || r[0];
1486
+ if (rid && !visited.has(rid)) {
1487
+ visited.add(rid);
1488
+ frontier.push(rid);
1489
+ }
1490
+ }
1491
+ for (const r of fileRows) {
1492
+ const rid = r.id || r[0];
1493
+ if (rid && !visited.has(rid)) {
1494
+ visited.add(rid);
1495
+ frontier.push(rid);
1496
+ }
1497
+ }
1498
+ }
1499
+ catch (e) {
1500
+ logQueryError('impact:class-node-expansion', e);
1501
+ }
1502
+ }
1311
1503
  for (let depth = 1; depth <= maxDepth && frontier.length > 0; depth++) {
1312
1504
  const nextFrontier = [];
1313
1505
  // Batch frontier nodes into a single Cypher query per depth level
@@ -1364,62 +1556,201 @@ export class LocalBackend {
1364
1556
  let affectedProcesses = [];
1365
1557
  let affectedModules = [];
1366
1558
  if (impacted.length > 0) {
1367
- // Cap IN-clause to 100 IDs to prevent oversized queries that crash
1368
- // the native DB engine on arm64 macOS (#292)
1369
- const cappedImpacted = impacted.slice(0, 100);
1370
- const allIds = cappedImpacted.map(i => `'${String(i.id ?? '').replace(/'/g, "''")}'`).join(', ');
1371
- const d1Items = (grouped[1] || []).slice(0, 100);
1372
- const d1Ids = d1Items.map((i) => `'${String(i.id ?? '').replace(/'/g, "''")}'`).join(', ');
1373
- // Enrichment queries: sequential on arm64 macOS to avoid SIGSEGV from
1374
- // concurrent native DB access (#285, #290, #292); parallel elsewhere
1375
- // to preserve performance on unaffected platforms.
1376
- const isArm64Mac = process.platform === 'darwin' && process.arch === 'arm64';
1377
- const processQuery = executeQuery(repo.id, `
1378
- MATCH (s)-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p:Process)
1379
- WHERE s.id IN [${allIds}]
1380
- RETURN p.heuristicLabel AS name, COUNT(DISTINCT s.id) AS hits, MIN(r.step) AS minStep, p.stepCount AS stepCount
1381
- ORDER BY hits DESC
1382
- LIMIT 20
1383
- `).catch(() => []);
1384
- const moduleQuery = () => executeQuery(repo.id, `
1385
- MATCH (s)-[:CodeRelation {type: 'MEMBER_OF'}]->(c:Community)
1386
- WHERE s.id IN [${allIds}]
1387
- RETURN c.heuristicLabel AS name, COUNT(DISTINCT s.id) AS hits
1388
- ORDER BY hits DESC
1389
- LIMIT 20
1390
- `).catch(() => []);
1391
- const directModuleQuery = () => d1Ids
1392
- ? executeQuery(repo.id, `
1559
+ const CHUNK_SIZE = 100;
1560
+ // Max number of chunks to process to avoid unbounded DB round-trips.
1561
+ // Configurable via env IMPACT_MAX_CHUNKS, default 10 => max items = 1000
1562
+ const MAX_CHUNKS = parseInt(process.env.IMPACT_MAX_CHUNKS || '10', 10);
1563
+ // ── Process enrichment: batched chunking (bounded by MAX_CHUNKS)
1564
+ // Uses merged Cypher query (WITH + OPTIONAL MATCH) to fetch
1565
+ // process + entry point info in 1 round-trip per chunk. Converted to
1566
+ // parameterized queries to avoid manual string escaping and long query strings.
1567
+ const entryPointMap = new Map();
1568
+ // Map process id -> entryPointId to allow fixing missing minStep values later
1569
+ const processToEntryPoint = new Map();
1570
+ // Collect process ids where MIN(r.step) returned null so we can retry in batch
1571
+ const processesMissingMinStep = new Set();
1572
+ let chunksProcessed = 0;
1573
+ for (let i = 0; i < impacted.length && chunksProcessed < MAX_CHUNKS; i += CHUNK_SIZE, chunksProcessed++) {
1574
+ const chunk = impacted.slice(i, i + CHUNK_SIZE);
1575
+ const ids = chunk.map(item => String(item.id ?? ''));
1576
+ try {
1577
+ // Use parameterized list to avoid building long query strings
1578
+ const rows = await executeParameterized(repo.id, `
1579
+ MATCH (s)-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p:Process)
1580
+ WHERE s.id IN $ids
1581
+ WITH p, COUNT(DISTINCT s.id) AS hits, MIN(r.step) AS minStep
1582
+ OPTIONAL MATCH (ep {id: p.entryPointId})
1583
+ RETURN p.id AS pId, p.heuristicLabel AS name, p.processType AS processType,
1584
+ p.entryPointId AS entryPointId, hits, minStep, p.stepCount AS stepCount,
1585
+ ep.name AS epName, labels(ep)[0] AS epType, ep.filePath AS epFilePath
1586
+ `, { ids }).catch(() => []);
1587
+ for (const row of rows) {
1588
+ const pId = row.pId ?? row[0];
1589
+ const epId = row.entryPointId ?? row[3] ?? row.pId ?? row[0];
1590
+ // Track mapping from process -> entryPoint so we can backfill missing minStep
1591
+ if (pId)
1592
+ processToEntryPoint.set(String(pId), String(epId));
1593
+ // Normalize epName: prefer epName, fall back to other columns, and
1594
+ // ensure we don't keep an empty string (labels(...) can return "").
1595
+ const epNameRaw = row.epName ?? row[7] ?? row.name ?? row[1] ?? 'unknown';
1596
+ const epName = (typeof epNameRaw === 'string' && epNameRaw.trim().length > 0) ? epNameRaw.trim() : 'unknown';
1597
+ // Normalize epType: labels(ep)[0] can return an empty string in
1598
+ // some DBs (LadybugDB). Using nullish coalescing (??) preserves
1599
+ // empty strings, which results in empty `type` values being
1600
+ // propagated. Treat empty-string labels as missing and fall back
1601
+ // to the next candidate or a sensible default.
1602
+ const epTypeRaw = row.epType ?? row[8] ?? '';
1603
+ const epType = (typeof epTypeRaw === 'string' && epTypeRaw.trim().length > 0)
1604
+ ? epTypeRaw.trim()
1605
+ : 'Function';
1606
+ const epFilePath = row.epFilePath ?? row[9] ?? '';
1607
+ const hits = row.hits ?? row[4] ?? 0;
1608
+ const minStep = row.minStep ?? row[5];
1609
+ // If the DB returned null for minStep, note the process id so we
1610
+ // can run a follow-up query using a different aggregation strategy.
1611
+ if (minStep === null || minStep === undefined) {
1612
+ if (pId)
1613
+ processesMissingMinStep.add(String(pId));
1614
+ }
1615
+ if (!entryPointMap.has(epId)) {
1616
+ entryPointMap.set(epId, {
1617
+ name: epName,
1618
+ type: epType,
1619
+ filePath: epFilePath,
1620
+ affected_process_count: 0,
1621
+ total_hits: 0,
1622
+ earliest_broken_step: Infinity,
1623
+ });
1624
+ }
1625
+ const ep = entryPointMap.get(epId);
1626
+ ep.affected_process_count += 1;
1627
+ ep.total_hits += hits;
1628
+ ep.earliest_broken_step = Math.min(ep.earliest_broken_step, minStep ?? Infinity);
1629
+ }
1630
+ }
1631
+ catch (e) {
1632
+ logQueryError('impact:process-chunk', e);
1633
+ }
1634
+ }
1635
+ // If some processes returned null minStep, try a batched follow-up query
1636
+ // using the full impacted id set. This handles older indexes or DBs
1637
+ // where MIN(r.step) can come back null even when step properties exist.
1638
+ if (processesMissingMinStep.size > 0) {
1639
+ try {
1640
+ const pIds = Array.from(processesMissingMinStep);
1641
+ const allImpactedIds = impacted.map(it => String(it.id ?? ''));
1642
+ const missingRows = await executeParameterized(repo.id, `
1643
+ MATCH (s)-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p:Process)
1644
+ WHERE p.id IN $pIds AND s.id IN $ids
1645
+ RETURN p.id AS pid, MIN(r.step) AS minStep
1646
+ `, { pIds, ids: allImpactedIds }).catch(() => []);
1647
+ for (const mr of missingRows) {
1648
+ const pid = mr.pid ?? mr[0];
1649
+ const minStep = mr.minStep ?? mr[1];
1650
+ const epId = processToEntryPoint.get(String(pid));
1651
+ if (!epId)
1652
+ continue;
1653
+ const ep = entryPointMap.get(epId);
1654
+ if (!ep)
1655
+ continue;
1656
+ if (typeof minStep === 'number') {
1657
+ ep.earliest_broken_step = Math.min(ep.earliest_broken_step, minStep);
1658
+ }
1659
+ }
1660
+ }
1661
+ catch (e) {
1662
+ logQueryError('impact:process-chunk-backfill', e);
1663
+ }
1664
+ }
1665
+ // If we capped chunks, mark traversal incomplete so caller knows results are partial
1666
+ if (chunksProcessed * CHUNK_SIZE < impacted.length) {
1667
+ traversalComplete = false;
1668
+ }
1669
+ affectedProcesses = Array.from(entryPointMap.values())
1670
+ .map(ep => ({
1671
+ ...ep,
1672
+ earliest_broken_step: ep.earliest_broken_step === Infinity ? null : ep.earliest_broken_step,
1673
+ }))
1674
+ .sort((a, b) => b.total_hits - a.total_hits);
1675
+ // ── Module enrichment: use same cap as process enrichment and parameterized queries
1676
+ const maxItems = Math.min(impacted.length, MAX_CHUNKS * CHUNK_SIZE);
1677
+ const cappedImpacted = impacted.slice(0, maxItems);
1678
+ const allIdsArr = cappedImpacted.map((i) => String(i.id ?? ''));
1679
+ const d1Items = (grouped[1] || []).slice(0, maxItems);
1680
+ const d1IdsArr = d1Items.map((i) => String(i.id ?? ''));
1681
+ // Chunked module enrichment: run the MEMBER_OF queries in chunks
1682
+ // to avoid large single queries or concurrent Kuzu calls that can
1683
+ // crash (SIGSEGV) on arm64 macOS; behavior preserves existing maxItems cap and returns equivalent aggregated results.
1684
+ const moduleHitsMap = new Map();
1685
+ const directModuleSet = new Set();
1686
+ // Helper to run a single module chunk and accumulate hits by name
1687
+ const runModuleChunk = async (idsChunk) => {
1688
+ if (!idsChunk || idsChunk.length === 0)
1689
+ return;
1690
+ try {
1691
+ const rows = await executeParameterized(repo.id, `
1692
+ MATCH (s)-[:CodeRelation {type: 'MEMBER_OF'}]->(c:Community)
1693
+ WHERE s.id IN $ids
1694
+ RETURN c.heuristicLabel AS name, COUNT(DISTINCT s.id) AS hits
1695
+ ORDER BY hits DESC
1696
+ LIMIT 20
1697
+ `, { ids: idsChunk }).catch(() => []);
1698
+ for (const r of rows) {
1699
+ const name = r.name ?? r[0] ?? null;
1700
+ const hits = (r.hits ?? r[1]) || 0;
1701
+ if (!name)
1702
+ continue;
1703
+ moduleHitsMap.set(name, (moduleHitsMap.get(name) || 0) + hits);
1704
+ }
1705
+ }
1706
+ catch (e) {
1707
+ logQueryError('impact:module-chunk', e);
1708
+ }
1709
+ };
1710
+ // Run module query chunks sequentially (safe on arm64 macOS)
1711
+ for (let i = 0; i < allIdsArr.length; i += CHUNK_SIZE) {
1712
+ const chunkIds = allIdsArr.slice(i, i + CHUNK_SIZE);
1713
+ await runModuleChunk(chunkIds);
1714
+ }
1715
+ // Run direct module query similarly (distinct heuristic labels for depth-1 items)
1716
+ const runDirectModuleChunk = async (idsChunk) => {
1717
+ if (!idsChunk || idsChunk.length === 0)
1718
+ return;
1719
+ try {
1720
+ const rows = await executeParameterized(repo.id, `
1393
1721
  MATCH (s)-[:CodeRelation {type: 'MEMBER_OF'}]->(c:Community)
1394
- WHERE s.id IN [${d1Ids}]
1722
+ WHERE s.id IN $ids
1395
1723
  RETURN DISTINCT c.heuristicLabel AS name
1396
- `).catch(() => [])
1397
- : Promise.resolve([]);
1398
- let processRows, moduleRows, directModuleRows;
1399
- if (isArm64Mac) {
1400
- // Sequential: avoid concurrent native DB access
1401
- processRows = await processQuery;
1402
- moduleRows = await moduleQuery();
1403
- directModuleRows = await directModuleQuery();
1724
+ `, { ids: idsChunk }).catch(() => []);
1725
+ for (const r of rows) {
1726
+ const name = r.name ?? r[0] ?? null;
1727
+ if (name)
1728
+ directModuleSet.add(name);
1729
+ }
1730
+ }
1731
+ catch (e) {
1732
+ logQueryError('impact:direct-module-chunk', e);
1733
+ }
1734
+ };
1735
+ for (let i = 0; i < d1IdsArr.length; i += CHUNK_SIZE) {
1736
+ const chunkIds = d1IdsArr.slice(i, i + CHUNK_SIZE);
1737
+ await runDirectModuleChunk(chunkIds);
1404
1738
  }
1405
- else {
1406
- // Parallel: safe on non-arm64 platforms
1407
- processRows = await processQuery;
1408
- [moduleRows, directModuleRows] = await Promise.all([moduleQuery(), directModuleQuery()]);
1409
- }
1410
- affectedProcesses = processRows.map((r) => ({
1411
- name: r.name || r[0],
1412
- hits: r.hits || r[1],
1413
- broken_at_step: r.minStep ?? r[2],
1414
- step_count: r.stepCount ?? r[3],
1415
- }));
1416
- const directModuleSet = new Set(directModuleRows.map((r) => r.name || r[0]));
1739
+ // Build final moduleRows array from aggregated hits map, sorted & limited
1740
+ const moduleRows = Array.from(moduleHitsMap.entries())
1741
+ .map(([name, hits]) => ({ name, hits }))
1742
+ .sort((a, b) => b.hits - a.hits)
1743
+ .slice(0, 20);
1744
+ const directModuleRows = Array.from(directModuleSet).map(name => ({ name }));
1745
+ // Build affectedModules in the same shape as original implementation
1746
+ const directModuleNameSet = new Set(directModuleRows.map((r) => r.name || r[0]));
1417
1747
  affectedModules = moduleRows.map((r) => {
1418
- const name = r.name || r[0];
1748
+ const name = r.name ?? r[0];
1749
+ const hits = r.hits ?? r[1] ?? 0;
1419
1750
  return {
1420
1751
  name,
1421
- hits: r.hits || r[1],
1422
- impact: directModuleSet.has(name) ? 'direct' : 'indirect',
1752
+ hits,
1753
+ impact: directModuleNameSet.has(name) ? 'direct' : 'indirect',
1423
1754
  };
1424
1755
  });
1425
1756
  }
@@ -1440,8 +1771,8 @@ export class LocalBackend {
1440
1771
  target: {
1441
1772
  id: symId,
1442
1773
  name: sym.name || sym[1],
1443
- type: sym.type || sym[2],
1444
- filePath: sym.filePath || sym[3],
1774
+ type: symType,
1775
+ filePath: sym.filePath || sym[2],
1445
1776
  },
1446
1777
  direction,
1447
1778
  impactedCount: impacted.length,
@@ -1457,6 +1788,312 @@ export class LocalBackend {
1457
1788
  byDepth: grouped,
1458
1789
  };
1459
1790
  }
1791
+ /**
1792
+ * Fetch Route nodes with their consumers in a single query.
1793
+ * Shared by routeMap and shapeCheck to avoid N+1 query patterns.
1794
+ */
1795
+ async fetchRoutesWithConsumers(repoId, routeFilter, params) {
1796
+ const rows = await executeParameterized(repoId, `
1797
+ MATCH (n:Route)
1798
+ WHERE n.id STARTS WITH 'Route:' ${routeFilter}
1799
+ OPTIONAL MATCH (consumer)-[r:CodeRelation]->(n)
1800
+ WHERE r.type = 'FETCHES'
1801
+ RETURN n.id AS routeId, n.name AS routeName, n.filePath AS handlerFile,
1802
+ n.responseKeys AS responseKeys, n.errorKeys AS errorKeys, n.middleware AS middleware,
1803
+ consumer.name AS consumerName, consumer.filePath AS consumerFile,
1804
+ r.reason AS fetchReason
1805
+ `, params);
1806
+ // Strip wrapping quotes from DB array elements — CSV COPY stores ['key'] which
1807
+ // LadybugDB may return as "'key'" rather than "key"
1808
+ const stripQuotes = (keys) => keys ? keys.map(k => k.replace(/^['"]|['"]$/g, '')) : null;
1809
+ const routeMap = new Map();
1810
+ for (const row of rows) {
1811
+ const id = row.routeId ?? row[0];
1812
+ const name = row.routeName ?? row[1];
1813
+ const filePath = row.handlerFile ?? row[2];
1814
+ const responseKeys = stripQuotes(row.responseKeys ?? row[3] ?? null);
1815
+ const errorKeys = stripQuotes(row.errorKeys ?? row[4] ?? null);
1816
+ const middleware = stripQuotes(row.middleware ?? row[5] ?? null);
1817
+ const consumerName = row.consumerName ?? row[6];
1818
+ const consumerFile = row.consumerFile ?? row[7];
1819
+ const fetchReason = row.fetchReason ?? row[8] ?? null;
1820
+ if (!routeMap.has(id)) {
1821
+ routeMap.set(id, { id, name, filePath, responseKeys, errorKeys, middleware, consumers: [] });
1822
+ }
1823
+ if (consumerName && consumerFile) {
1824
+ // Parse accessed keys from reason field: "fetch-url-match|keys:data,pagination|fetches:3"
1825
+ let accessedKeys;
1826
+ let fetchCount;
1827
+ if (fetchReason) {
1828
+ const keysMatch = fetchReason.match(/\|keys:([^|]+)/);
1829
+ if (keysMatch) {
1830
+ accessedKeys = keysMatch[1].split(',').filter(k => k.length > 0);
1831
+ }
1832
+ const fetchesMatch = fetchReason.match(/\|fetches:(\d+)/);
1833
+ if (fetchesMatch) {
1834
+ fetchCount = parseInt(fetchesMatch[1], 10);
1835
+ }
1836
+ }
1837
+ routeMap.get(id).consumers.push({
1838
+ name: consumerName,
1839
+ filePath: consumerFile,
1840
+ ...(accessedKeys ? { accessedKeys } : {}),
1841
+ ...(fetchCount && fetchCount > 1 ? { fetchCount } : {}),
1842
+ });
1843
+ }
1844
+ }
1845
+ return [...routeMap.values()];
1846
+ }
1847
+ /**
1848
+ * Batch-fetch execution flows linked to a set of Route or Tool nodes.
1849
+ * Single query instead of N+1.
1850
+ */
1851
+ async fetchLinkedFlowsBatch(repoId, nodeIds) {
1852
+ const result = new Map();
1853
+ if (nodeIds.length === 0)
1854
+ return result;
1855
+ try {
1856
+ // Use list_contains to filter at DB level instead of fetching all and filtering in memory
1857
+ const rows = await executeParameterized(repoId, `
1858
+ MATCH (source)-[r:CodeRelation]->(proc:Process)
1859
+ WHERE r.type = 'ENTRY_POINT_OF'
1860
+ AND list_contains($nodeIds, source.id)
1861
+ RETURN source.id AS sourceId, proc.label AS name
1862
+ `, { nodeIds });
1863
+ for (const row of rows) {
1864
+ const sourceId = row.sourceId ?? row[0];
1865
+ const name = row.name ?? row[1];
1866
+ if (!name)
1867
+ continue;
1868
+ let list = result.get(sourceId);
1869
+ if (!list) {
1870
+ list = [];
1871
+ result.set(sourceId, list);
1872
+ }
1873
+ list.push(name);
1874
+ }
1875
+ }
1876
+ catch { /* no ENTRY_POINT_OF edges yet */ }
1877
+ return result;
1878
+ }
1879
+ async routeMap(repo, params) {
1880
+ await this.ensureInitialized(repo.id);
1881
+ const routeFilter = params.route ? `AND n.name CONTAINS $route` : '';
1882
+ const queryParams = params.route ? { route: params.route } : {};
1883
+ const routes = await this.fetchRoutesWithConsumers(repo.id, routeFilter, queryParams);
1884
+ if (routes.length === 0) {
1885
+ return { routes: [], total: 0, message: params.route ? `No routes matching "${params.route}"` : 'No routes found in this project.' };
1886
+ }
1887
+ const flowMap = await this.fetchLinkedFlowsBatch(repo.id, routes.map(r => r.id));
1888
+ return {
1889
+ routes: routes.map(r => ({
1890
+ route: r.name, handler: r.filePath,
1891
+ middleware: r.middleware || [],
1892
+ consumers: r.consumers,
1893
+ flows: flowMap.get(r.id) || [],
1894
+ })),
1895
+ total: routes.length,
1896
+ };
1897
+ }
1898
+ async shapeCheck(repo, params) {
1899
+ await this.ensureInitialized(repo.id);
1900
+ const routeFilter = params.route ? `AND n.name CONTAINS $route` : '';
1901
+ const queryParams = params.route ? { route: params.route } : {};
1902
+ const allRoutes = await this.fetchRoutesWithConsumers(repo.id, routeFilter, queryParams);
1903
+ const results = allRoutes
1904
+ .filter(r => ((r.responseKeys && r.responseKeys.length > 0) || (r.errorKeys && r.errorKeys.length > 0)) && r.consumers.length > 0)
1905
+ .map(r => {
1906
+ // Keys already normalized by fetchRoutesWithConsumers (quotes stripped)
1907
+ const responseKeys = r.responseKeys ?? [];
1908
+ const errorKeys = r.errorKeys ?? [];
1909
+ // Combined set: consumer accessing either success or error keys is valid
1910
+ const allKnownKeys = new Set([...responseKeys, ...errorKeys]);
1911
+ // Check each consumer's accessed keys against the route's response shape
1912
+ const responseKeySet = new Set(responseKeys);
1913
+ const consumers = r.consumers.map(c => {
1914
+ if (!c.accessedKeys || c.accessedKeys.length === 0) {
1915
+ return { name: c.name, filePath: c.filePath };
1916
+ }
1917
+ const mismatched = c.accessedKeys.filter(k => !allKnownKeys.has(k));
1918
+ // Keys in allKnownKeys but not in responseKeys — error-path access (e.g., .error from errorKeys)
1919
+ const errorPathKeys = c.accessedKeys.filter(k => allKnownKeys.has(k) && !responseKeySet.has(k));
1920
+ const isMultiFetch = (c.fetchCount ?? 1) > 1;
1921
+ return {
1922
+ name: c.name,
1923
+ filePath: c.filePath,
1924
+ accessedKeys: c.accessedKeys,
1925
+ ...(mismatched.length > 0 ? { mismatched, mismatchConfidence: isMultiFetch ? 'low' : 'high' } : {}),
1926
+ ...(errorPathKeys.length > 0 ? { errorPathKeys } : {}),
1927
+ ...(isMultiFetch ? { attributionNote: `This file fetches ${c.fetchCount} routes — accessed keys may belong to a different route.` } : {}),
1928
+ };
1929
+ });
1930
+ const hasMismatches = consumers.some(c => 'mismatched' in c && c.mismatched.length > 0);
1931
+ return {
1932
+ route: r.name,
1933
+ handler: r.filePath,
1934
+ ...(responseKeys.length > 0 ? { responseKeys } : {}),
1935
+ ...(errorKeys.length > 0 ? { errorKeys } : {}),
1936
+ consumers,
1937
+ ...(hasMismatches ? { status: 'MISMATCH' } : {}),
1938
+ };
1939
+ });
1940
+ const mismatchCount = results.filter(r => r.status === 'MISMATCH').length;
1941
+ return {
1942
+ routes: results,
1943
+ total: results.length,
1944
+ routesWithShapes: results.length,
1945
+ ...(mismatchCount > 0 ? { mismatches: mismatchCount } : {}),
1946
+ message: results.length === 0
1947
+ ? 'No routes with both response shapes and consumers found.'
1948
+ : mismatchCount > 0
1949
+ ? `Found ${results.length} route(s) with response shape data. ${mismatchCount} route(s) have consumer/shape mismatches.`
1950
+ : `Found ${results.length} route(s) with response shape data and consumers.`,
1951
+ };
1952
+ }
1953
+ async toolMap(repo, params) {
1954
+ await this.ensureInitialized(repo.id);
1955
+ const toolFilter = params.tool ? `AND n.name CONTAINS $tool` : '';
1956
+ const queryParams = params.tool ? { tool: params.tool } : {};
1957
+ const rows = await executeParameterized(repo.id, `
1958
+ MATCH (n:Tool)
1959
+ WHERE n.id STARTS WITH 'Tool:' ${toolFilter}
1960
+ RETURN n.id AS id, n.name AS name, n.filePath AS filePath, n.description AS description
1961
+ `, queryParams);
1962
+ if (rows.length === 0) {
1963
+ return { tools: [], total: 0, message: params.tool ? `No tools matching "${params.tool}"` : 'No tool definitions found.' };
1964
+ }
1965
+ const toolIds = rows.map((r) => r.id ?? r[0]);
1966
+ const flowMap = await this.fetchLinkedFlowsBatch(repo.id, toolIds);
1967
+ return {
1968
+ tools: rows.map((r) => {
1969
+ const id = r.id ?? r[0];
1970
+ return {
1971
+ name: r.name ?? r[1],
1972
+ filePath: r.filePath ?? r[2],
1973
+ description: (r.description ?? r[3] ?? '').slice(0, 200),
1974
+ flows: flowMap.get(id) || [],
1975
+ };
1976
+ }),
1977
+ total: rows.length,
1978
+ };
1979
+ }
1980
+ async apiImpact(repo, params) {
1981
+ await this.ensureInitialized(repo.id);
1982
+ if (!params.route && !params.file) {
1983
+ return { error: 'Either "route" or "file" parameter is required.' };
1984
+ }
1985
+ // If file is provided but route is not, look up the route by file path
1986
+ let routeFilter = '';
1987
+ const queryParams = {};
1988
+ if (params.route) {
1989
+ routeFilter = `AND n.name CONTAINS $route`;
1990
+ queryParams.route = params.route;
1991
+ }
1992
+ else if (params.file) {
1993
+ routeFilter = `AND n.filePath CONTAINS $file`;
1994
+ queryParams.file = params.file;
1995
+ }
1996
+ const routes = await this.fetchRoutesWithConsumers(repo.id, routeFilter, queryParams);
1997
+ if (routes.length === 0) {
1998
+ const target = params.route || params.file;
1999
+ return { error: `No routes found matching "${target}".` };
2000
+ }
2001
+ const flowMap = await this.fetchLinkedFlowsBatch(repo.id, routes.map(r => r.id));
2002
+ // Count how many routes share the same handler file (for middleware partial detection)
2003
+ const routeCountByHandler = new Map();
2004
+ for (const r of routes) {
2005
+ if (r.filePath) {
2006
+ routeCountByHandler.set(r.filePath, (routeCountByHandler.get(r.filePath) ?? 0) + 1);
2007
+ }
2008
+ }
2009
+ const results = routes.map(r => {
2010
+ // Keys already normalized by fetchRoutesWithConsumers (quotes stripped)
2011
+ const responseKeys = r.responseKeys ?? [];
2012
+ const errorKeys = r.errorKeys ?? [];
2013
+ const allKnownKeys = new Set([...responseKeys, ...errorKeys]);
2014
+ // Build consumer list with mismatch detection
2015
+ const consumers = r.consumers.map(c => ({
2016
+ name: c.name,
2017
+ file: c.filePath,
2018
+ accesses: c.accessedKeys ?? [],
2019
+ ...(c.fetchCount && c.fetchCount > 1 ? { attributionNote: `This file fetches ${c.fetchCount} routes — accessed keys may belong to a different route.` } : {}),
2020
+ }));
2021
+ // Detect mismatches: consumer accesses keys not in response shape
2022
+ const mismatches = [];
2023
+ if (allKnownKeys.size > 0) {
2024
+ for (const c of r.consumers) {
2025
+ if (!c.accessedKeys)
2026
+ continue;
2027
+ const isMultiFetch = (c.fetchCount ?? 1) > 1;
2028
+ for (const key of c.accessedKeys) {
2029
+ if (!allKnownKeys.has(key)) {
2030
+ mismatches.push({
2031
+ consumer: c.filePath,
2032
+ field: key,
2033
+ reason: 'accessed but not in response shape',
2034
+ confidence: isMultiFetch ? 'low' : 'high',
2035
+ });
2036
+ }
2037
+ }
2038
+ }
2039
+ }
2040
+ const flows = flowMap.get(r.id) || [];
2041
+ const consumerCount = r.consumers.length;
2042
+ // Risk level heuristic
2043
+ let riskLevel;
2044
+ if (consumerCount >= 10) {
2045
+ riskLevel = 'HIGH';
2046
+ }
2047
+ else if (consumerCount >= 4) {
2048
+ riskLevel = 'MEDIUM';
2049
+ }
2050
+ else {
2051
+ riskLevel = 'LOW';
2052
+ }
2053
+ // Bump up one level if mismatches exist
2054
+ if (mismatches.length > 0) {
2055
+ if (riskLevel === 'LOW')
2056
+ riskLevel = 'MEDIUM';
2057
+ else if (riskLevel === 'MEDIUM')
2058
+ riskLevel = 'HIGH';
2059
+ }
2060
+ const warning = consumerCount > 0
2061
+ ? `Changing response shape will affect ${consumerCount} component${consumerCount === 1 ? '' : 's'}`
2062
+ : undefined;
2063
+ // Flag when middleware was detected but handler exports multiple HTTP methods
2064
+ // (middleware chain may only reflect one export)
2065
+ const middlewareArr = r.middleware || [];
2066
+ const handlerRouteCount = r.filePath ? (routeCountByHandler.get(r.filePath) ?? 1) : 1;
2067
+ const middlewarePartial = middlewareArr.length > 0 && handlerRouteCount > 1;
2068
+ return {
2069
+ route: r.name,
2070
+ handler: r.filePath,
2071
+ responseShape: {
2072
+ success: responseKeys,
2073
+ error: errorKeys,
2074
+ },
2075
+ middleware: middlewareArr,
2076
+ ...(middlewarePartial ? {
2077
+ middlewareDetection: 'partial',
2078
+ middlewareNote: 'Middleware captured from first HTTP method export only — other methods in this handler may use different middleware chains.',
2079
+ } : {}),
2080
+ consumers,
2081
+ ...(mismatches.length > 0 ? { mismatches } : {}),
2082
+ executionFlows: flows,
2083
+ impactSummary: {
2084
+ directConsumers: consumerCount,
2085
+ affectedFlows: flows.length,
2086
+ riskLevel,
2087
+ ...(warning ? { warning } : {}),
2088
+ },
2089
+ };
2090
+ });
2091
+ // If a single route was targeted, return it directly (not wrapped in array)
2092
+ if (results.length === 1) {
2093
+ return results[0];
2094
+ }
2095
+ return { routes: results, total: results.length };
2096
+ }
1460
2097
  // ─── Direct Graph Queries (for resources.ts) ────────────────────
1461
2098
  /**
1462
2099
  * Query clusters (communities) directly from graph.