@zuvia-software-solutions/code-mapper 1.4.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (137) hide show
  1. package/dist/cli/ai-context.js +1 -1
  2. package/dist/cli/analyze.d.ts +1 -0
  3. package/dist/cli/analyze.js +73 -82
  4. package/dist/cli/augment.js +0 -2
  5. package/dist/cli/eval-server.d.ts +2 -2
  6. package/dist/cli/eval-server.js +6 -6
  7. package/dist/cli/index.js +6 -10
  8. package/dist/cli/mcp.d.ts +1 -3
  9. package/dist/cli/mcp.js +3 -3
  10. package/dist/cli/refresh.d.ts +2 -2
  11. package/dist/cli/refresh.js +24 -29
  12. package/dist/cli/status.js +4 -13
  13. package/dist/cli/tool.d.ts +5 -4
  14. package/dist/cli/tool.js +8 -10
  15. package/dist/config/ignore-service.js +14 -34
  16. package/dist/core/augmentation/engine.js +53 -83
  17. package/dist/core/db/adapter.d.ts +99 -0
  18. package/dist/core/db/adapter.js +402 -0
  19. package/dist/core/db/graph-loader.d.ts +27 -0
  20. package/dist/core/db/graph-loader.js +148 -0
  21. package/dist/core/db/queries.d.ts +160 -0
  22. package/dist/core/db/queries.js +441 -0
  23. package/dist/core/db/schema.d.ts +108 -0
  24. package/dist/core/db/schema.js +136 -0
  25. package/dist/core/embeddings/embedder.d.ts +21 -12
  26. package/dist/core/embeddings/embedder.js +104 -50
  27. package/dist/core/embeddings/embedding-pipeline.d.ts +48 -22
  28. package/dist/core/embeddings/embedding-pipeline.js +220 -262
  29. package/dist/core/embeddings/text-generator.js +4 -19
  30. package/dist/core/embeddings/types.d.ts +1 -1
  31. package/dist/core/graph/graph.d.ts +1 -1
  32. package/dist/core/graph/graph.js +1 -0
  33. package/dist/core/graph/types.d.ts +11 -9
  34. package/dist/core/graph/types.js +4 -1
  35. package/dist/core/incremental/refresh.d.ts +46 -0
  36. package/dist/core/incremental/refresh.js +464 -0
  37. package/dist/core/incremental/types.d.ts +2 -1
  38. package/dist/core/incremental/types.js +42 -44
  39. package/dist/core/ingestion/ast-cache.js +1 -0
  40. package/dist/core/ingestion/call-processor.d.ts +15 -3
  41. package/dist/core/ingestion/call-processor.js +448 -60
  42. package/dist/core/ingestion/cluster-enricher.d.ts +1 -1
  43. package/dist/core/ingestion/cluster-enricher.js +2 -0
  44. package/dist/core/ingestion/community-processor.d.ts +1 -1
  45. package/dist/core/ingestion/community-processor.js +8 -3
  46. package/dist/core/ingestion/export-detection.d.ts +1 -1
  47. package/dist/core/ingestion/export-detection.js +1 -1
  48. package/dist/core/ingestion/filesystem-walker.js +1 -1
  49. package/dist/core/ingestion/heritage-processor.d.ts +2 -2
  50. package/dist/core/ingestion/heritage-processor.js +22 -11
  51. package/dist/core/ingestion/import-processor.d.ts +2 -2
  52. package/dist/core/ingestion/import-processor.js +24 -9
  53. package/dist/core/ingestion/language-config.js +7 -4
  54. package/dist/core/ingestion/mro-processor.d.ts +1 -1
  55. package/dist/core/ingestion/mro-processor.js +23 -11
  56. package/dist/core/ingestion/named-binding-extraction.js +5 -5
  57. package/dist/core/ingestion/parsing-processor.d.ts +4 -4
  58. package/dist/core/ingestion/parsing-processor.js +26 -18
  59. package/dist/core/ingestion/pipeline.d.ts +4 -2
  60. package/dist/core/ingestion/pipeline.js +50 -20
  61. package/dist/core/ingestion/process-processor.d.ts +2 -2
  62. package/dist/core/ingestion/process-processor.js +28 -14
  63. package/dist/core/ingestion/resolution-context.d.ts +1 -1
  64. package/dist/core/ingestion/resolution-context.js +14 -4
  65. package/dist/core/ingestion/resolvers/csharp.js +4 -3
  66. package/dist/core/ingestion/resolvers/go.js +3 -1
  67. package/dist/core/ingestion/resolvers/jvm.js +13 -4
  68. package/dist/core/ingestion/resolvers/standard.js +2 -2
  69. package/dist/core/ingestion/resolvers/utils.js +6 -2
  70. package/dist/core/ingestion/route-stitcher.d.ts +15 -0
  71. package/dist/core/ingestion/route-stitcher.js +92 -0
  72. package/dist/core/ingestion/structure-processor.d.ts +1 -1
  73. package/dist/core/ingestion/structure-processor.js +3 -2
  74. package/dist/core/ingestion/symbol-table.d.ts +2 -0
  75. package/dist/core/ingestion/symbol-table.js +5 -1
  76. package/dist/core/ingestion/tree-sitter-queries.d.ts +2 -2
  77. package/dist/core/ingestion/tree-sitter-queries.js +177 -0
  78. package/dist/core/ingestion/type-env.js +20 -0
  79. package/dist/core/ingestion/type-extractors/csharp.js +4 -3
  80. package/dist/core/ingestion/type-extractors/go.js +23 -12
  81. package/dist/core/ingestion/type-extractors/php.js +18 -10
  82. package/dist/core/ingestion/type-extractors/ruby.js +15 -3
  83. package/dist/core/ingestion/type-extractors/rust.js +3 -2
  84. package/dist/core/ingestion/type-extractors/shared.js +3 -2
  85. package/dist/core/ingestion/type-extractors/typescript.js +11 -5
  86. package/dist/core/ingestion/utils.d.ts +27 -4
  87. package/dist/core/ingestion/utils.js +145 -100
  88. package/dist/core/ingestion/workers/parse-worker.d.ts +1 -0
  89. package/dist/core/ingestion/workers/parse-worker.js +97 -29
  90. package/dist/core/ingestion/workers/worker-pool.js +3 -0
  91. package/dist/core/search/bm25-index.d.ts +15 -8
  92. package/dist/core/search/bm25-index.js +48 -98
  93. package/dist/core/search/hybrid-search.d.ts +9 -3
  94. package/dist/core/search/hybrid-search.js +30 -25
  95. package/dist/core/search/reranker.js +9 -7
  96. package/dist/core/search/types.d.ts +0 -4
  97. package/dist/core/semantic/tsgo-service.d.ts +5 -1
  98. package/dist/core/semantic/tsgo-service.js +161 -66
  99. package/dist/lib/tsgo-test.d.ts +2 -0
  100. package/dist/lib/tsgo-test.js +6 -0
  101. package/dist/lib/type-utils.d.ts +25 -0
  102. package/dist/lib/type-utils.js +22 -0
  103. package/dist/lib/utils.d.ts +3 -2
  104. package/dist/lib/utils.js +3 -2
  105. package/dist/mcp/compatible-stdio-transport.js +1 -1
  106. package/dist/mcp/local/local-backend.d.ts +29 -56
  107. package/dist/mcp/local/local-backend.js +808 -1118
  108. package/dist/mcp/resources.js +35 -25
  109. package/dist/mcp/server.d.ts +1 -1
  110. package/dist/mcp/server.js +5 -5
  111. package/dist/mcp/tools.js +24 -25
  112. package/dist/storage/repo-manager.d.ts +2 -12
  113. package/dist/storage/repo-manager.js +1 -47
  114. package/dist/types/pipeline.d.ts +8 -5
  115. package/dist/types/pipeline.js +5 -0
  116. package/package.json +18 -11
  117. package/dist/cli/serve.d.ts +0 -5
  118. package/dist/cli/serve.js +0 -8
  119. package/dist/core/incremental/child-process.d.ts +0 -8
  120. package/dist/core/incremental/child-process.js +0 -649
  121. package/dist/core/incremental/refresh-coordinator.d.ts +0 -32
  122. package/dist/core/incremental/refresh-coordinator.js +0 -147
  123. package/dist/core/lbug/csv-generator.d.ts +0 -28
  124. package/dist/core/lbug/csv-generator.js +0 -355
  125. package/dist/core/lbug/lbug-adapter.d.ts +0 -96
  126. package/dist/core/lbug/lbug-adapter.js +0 -753
  127. package/dist/core/lbug/schema.d.ts +0 -46
  128. package/dist/core/lbug/schema.js +0 -402
  129. package/dist/mcp/core/embedder.d.ts +0 -24
  130. package/dist/mcp/core/embedder.js +0 -168
  131. package/dist/mcp/core/lbug-adapter.d.ts +0 -29
  132. package/dist/mcp/core/lbug-adapter.js +0 -330
  133. package/dist/server/api.d.ts +0 -5
  134. package/dist/server/api.js +0 -340
  135. package/dist/server/mcp-http.d.ts +0 -7
  136. package/dist/server/mcp-http.js +0 -95
  137. package/models/mlx-embedder.py +0 -185
@@ -2,26 +2,19 @@
2
2
  /** @file local-backend.ts
3
3
  * @description Tool implementations using local .code-mapper/ indexes
4
4
  * Supports multiple indexed repositories via a global registry
5
- * LadybugDB connections are opened lazily per repo on first query */
5
+ * SQLite connections are opened lazily per repo on first query */
6
6
  import fs from 'fs/promises';
7
7
  import path from 'path';
8
- import fsSync from 'fs';
9
8
  import { execFileSync } from 'child_process';
10
- import Parser from 'tree-sitter';
11
- import { initLbug, executeQuery, executeParameterized, closeLbug, isLbugReady } from '../core/lbug-adapter.js';
9
+ import { openDb, closeDb, getNode, findNodesByName, findNodesByFile, rawQuery, searchVector, countEmbeddings, searchFTS } from '../../core/db/adapter.js';
10
+ import { toNodeId, assertEdgeType } from '../../core/db/schema.js';
11
+ import * as queries from '../../core/db/queries.js';
12
+ import { refreshFiles, refreshEmbeddings } from '../../core/incremental/refresh.js';
12
13
  import { FileSystemWatcher } from '../../core/incremental/watcher.js';
13
- import { toRelativeFilePath, toRepoRoot } from '../../core/incremental/types.js';
14
- import { getLanguageFromFilename, getDefinitionNodeFromCaptures } from '../../core/ingestion/utils.js';
15
- import { loadParser, loadLanguage, isLanguageAvailable } from '../../core/tree-sitter/parser-loader.js';
16
- import { LANGUAGE_QUERIES } from '../../core/ingestion/tree-sitter-queries.js';
17
- import { getTreeSitterBufferSize, TREE_SITTER_MAX_BUFFER } from '../../core/ingestion/constants.js';
18
- import { generateId } from '../../lib/utils.js';
19
- import { NODE_TABLES, REL_TABLE_NAME } from '../../core/lbug/schema.js';
20
- import { FTS_TABLES } from '../../core/search/types.js';
14
+ import { toRepoRoot, toRelativeFilePath } from '../../core/incremental/types.js';
21
15
  import { getTsgoService, stopTsgoService } from '../../core/semantic/tsgo-service.js';
22
- // Embedding imports are lazy (dynamic import) to avoid loading onnxruntime-node
23
- // at MCP server startup — crashes on unsupported Node ABI versions (#89)
24
- import { listRegisteredRepos, cleanupOldKuzuFiles, } from '../../storage/repo-manager.js';
16
+ import {} from '../../core/search/types.js';
17
+ import { listRegisteredRepos, } from '../../storage/repo-manager.js';
25
18
  /** Quick test-file detection for filtering impact results across all supported languages */
26
19
  export function isTestFilePath(filePath) {
27
20
  const p = filePath.toLowerCase().replace(/\\/g, '/');
@@ -33,7 +26,7 @@ export function isTestFilePath(filePath) {
33
26
  p.endsWith('_spec.rb') || p.endsWith('_test.rb') || p.includes('/spec/') ||
34
27
  p.includes('/test_') || p.includes('/conftest.'));
35
28
  }
36
- /** Valid LadybugDB node labels for safe Cypher query construction */
29
+ /** Valid node labels for safe query construction */
37
30
  export const VALID_NODE_LABELS = new Set([
38
31
  'File', 'Folder', 'Function', 'Class', 'Interface', 'Method', 'CodeElement',
39
32
  'Community', 'Process', 'Struct', 'Enum', 'Macro', 'Typedef', 'Union',
@@ -42,11 +35,11 @@ export const VALID_NODE_LABELS = new Set([
42
35
  ]);
43
36
  /** Valid relation types for impact analysis filtering */
44
37
  export const VALID_RELATION_TYPES = new Set(['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS', 'DEPENDS_ON', 'PROVIDES']);
45
- /** Regex to detect write operations in user-supplied Cypher queries */
46
- export const CYPHER_WRITE_RE = /\b(CREATE|DELETE|SET|MERGE|REMOVE|DROP|ALTER|COPY|DETACH)\b/i;
47
- /** Check if a Cypher query contains write operations */
38
+ /** Regex to detect write operations in user-supplied SQL queries */
39
+ export const SQL_WRITE_RE = /\b(INSERT|UPDATE|DELETE|DROP|ALTER|CREATE\s+TABLE|CREATE\s+INDEX|REPLACE\s+INTO)\b/i;
40
+ /** Check if a SQL query contains write operations */
48
41
  export function isWriteQuery(query) {
49
- return CYPHER_WRITE_RE.test(query);
42
+ return SQL_WRITE_RE.test(query);
50
43
  }
51
44
  /** Structured error logging for query failures — replaces empty catch blocks */
52
45
  function logQueryError(context, err) {
@@ -56,15 +49,39 @@ function logQueryError(context, err) {
56
49
  export class LocalBackend {
57
50
  repos = new Map();
58
51
  contextCache = new Map();
59
- initializedRepos = new Set();
60
52
  watchers = new Map();
61
53
  /** Per-repo promise chain that serializes ensureFresh calls.
62
54
  * Prevents race: Call 2 skipping refresh while Call 1 is still writing. */
63
55
  refreshLocks = new Map();
56
+ /** Per-repo tsgo LSP service instances for live semantic enrichment */
57
+ tsgoServices = new Map();
58
+ /** Get (or lazily start) a tsgo LSP service for a repo. Returns null if unavailable. */
59
+ async getTsgo(repo) {
60
+ const existing = this.tsgoServices.get(repo.id);
61
+ if (existing?.isReady())
62
+ return existing;
63
+ try {
64
+ const service = getTsgoService(repo.repoPath);
65
+ if (await service.start()) {
66
+ this.tsgoServices.set(repo.id, service);
67
+ return service;
68
+ }
69
+ }
70
+ catch {
71
+ // tsgo not available — completely fine, graph-only mode
72
+ }
73
+ return null;
74
+ }
75
+ /** Get (or lazily open) the SQLite database for a repo. */
76
+ getDb(repoId) {
77
+ const handle = this.repos.get(repoId);
78
+ if (!handle)
79
+ throw new Error(`Unknown repo: ${repoId}`);
80
+ const dbPath = path.join(handle.storagePath, 'index.db');
81
+ return openDb(dbPath);
82
+ }
64
83
  /** Hard ceiling — beyond this, incremental is unreliable, warn prominently */
65
84
  static MAX_INCREMENTAL_FILES = 200;
66
- /** Optional tsgo LSP service for confidence-1.0 semantic resolution */
67
- tsgoEnabled = false;
68
85
  /** Start file system watcher for a repo to detect source changes */
69
86
  startWatcher(repoId, handle) {
70
87
  if (this.watchers.has(repoId))
@@ -152,8 +169,6 @@ export class LocalBackend {
152
169
  const watcher = this.watchers.get(repo.id);
153
170
  if (!watcher)
154
171
  return;
155
- // Flush pending debounce timers — edits within the 500ms window become
156
- // visible immediately so no tool call can miss a recent save
157
172
  await watcher.flush();
158
173
  if (!watcher.hasDirtyFiles())
159
174
  return;
@@ -162,463 +177,45 @@ export class LocalBackend {
162
177
  return;
163
178
  const dirtyFiles = [...dirtyMap.values()];
164
179
  const totalChanged = dirtyFiles.length;
165
- // Hard ceiling — incremental is unreliable for huge diffs (branch switch, etc.)
166
180
  if (totalChanged > LocalBackend.MAX_INCREMENTAL_FILES) {
167
- // Re-inject so the files aren't lost; user must run full analyze
168
181
  watcher.inject(dirtyFiles);
169
182
  console.error(`Code Mapper: ${totalChanged} files changed — exceeds incremental limit (${LocalBackend.MAX_INCREMENTAL_FILES}), run: code-mapper analyze`);
170
- // Don't silently serve stale — the staleness warning from getStalenessWarning
171
- // will flag the tool response since git HEAD will differ from meta.lastCommit
172
183
  return;
173
184
  }
174
185
  try {
175
- // In-process incremental refresh — uses the existing read-write connection
176
- // pool directly. No child process fork needed, no lock conflicts.
177
- await this.ensureInitialized(repo.id);
178
186
  const result = await this.inProcessRefresh(repo, dirtyFiles);
179
187
  console.error(`Code Mapper: Refreshed ${result.filesProcessed} file(s) in ${result.durationMs}ms (${result.nodesInserted} nodes, ${result.edgesInserted} edges)`);
180
- // Incremental embedding refresh — keep embeddings in sync with the graph
181
- await this.refreshEmbeddings(repo, dirtyFiles);
188
+ const db = this.getDb(repo.id);
189
+ const hasEmb = (repo.stats?.embeddings ?? 0) > 0;
190
+ await refreshEmbeddings(db, dirtyFiles, hasEmb);
182
191
  }
183
192
  catch (err) {
184
- // Re-inject dirty files so the next tool call retries them
185
193
  watcher.inject(dirtyFiles);
186
194
  console.error(`Code Mapper: Incremental refresh failed (will retry next call): ${err instanceof Error ? err.message : err}`);
187
195
  }
188
196
  }
189
197
  /**
190
- * Tables that are global metadata NOT deleted per-file during incremental refresh.
191
- */
192
- static SKIP_DELETE_TABLES = new Set(['Community', 'Process']);
193
- /** Tables requiring backtick-quoting in Cypher (reserved words) */
194
- static BACKTICK_TABLES = new Set([
195
- 'Struct', 'Enum', 'Macro', 'Typedef', 'Union', 'Namespace', 'Trait', 'Impl',
196
- 'TypeAlias', 'Const', 'Static', 'Property', 'Record', 'Delegate', 'Annotation',
197
- 'Constructor', 'Template', 'Module',
198
- ]);
199
- static quoteTable(table) {
200
- return LocalBackend.BACKTICK_TABLES.has(table) ? `\`${table}\`` : table;
201
- }
202
- static escapeCypher(value) {
203
- return value.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
204
- }
205
- /**
206
- * In-process incremental refresh — parses dirty files with tree-sitter and
207
- * writes directly to the DB through the existing connection pool.
208
- *
209
- * This avoids the LadybugDB lock conflict that prevented the child-process
210
- * approach from working: LadybugDB on macOS holds an exclusive file lock
211
- * even for read-only connections, and db.close() segfaults via N-API.
198
+ * In-process incremental refreshdelegates to the shared refreshFiles module.
212
199
  */
213
200
  async inProcessRefresh(repo, dirtyFiles) {
214
- const t0 = Date.now();
215
- const esc = LocalBackend.escapeCypher;
216
- const qt = LocalBackend.quoteTable;
217
- let nodesDeleted = 0;
218
- let nodesInserted = 0;
219
- let edgesInserted = 0;
220
- let filesSkipped = 0;
221
- // Phase 1: Delete old nodes for all dirty files
222
- for (const entry of dirtyFiles) {
223
- const escaped = esc(entry.relativePath);
224
- for (const table of NODE_TABLES) {
225
- if (LocalBackend.SKIP_DELETE_TABLES.has(table))
226
- continue;
227
- try {
228
- await executeQuery(repo.id, `MATCH (n:${qt(table)}) WHERE n.filePath = '${escaped}' DETACH DELETE n`);
229
- nodesDeleted++;
230
- }
231
- catch (err) {
232
- console.error(`Code Mapper: [refresh] DELETE ${table} for ${entry.relativePath}: ${err instanceof Error ? err.message : err}`);
233
- }
234
- }
235
- }
236
- // Phase 2: Parse modified/created files with tree-sitter
237
- const parser = await loadParser();
238
- const filesToProcess = dirtyFiles.filter(f => f.changeKind === 'modified' || f.changeKind === 'created');
239
- const allDefinitions = [];
240
- const callSites = [];
241
- const insertedFilePaths = new Set();
242
- for (const entry of filesToProcess) {
243
- const relPath = entry.relativePath;
244
- const absPath = path.resolve(repo.repoPath, relPath);
245
- const language = getLanguageFromFilename(relPath);
246
- if (!language || !isLanguageAvailable(language)) {
247
- filesSkipped++;
248
- continue;
249
- }
250
- let content;
251
- try {
252
- content = fsSync.readFileSync(absPath, 'utf-8');
253
- }
254
- catch {
255
- filesSkipped++;
256
- continue;
257
- }
258
- if (content.length > TREE_SITTER_MAX_BUFFER) {
259
- filesSkipped++;
260
- continue;
261
- }
262
- try {
263
- await loadLanguage(language, relPath);
264
- }
265
- catch {
266
- filesSkipped++;
267
- continue;
268
- }
269
- let tree;
270
- try {
271
- tree = parser.parse(content, undefined, { bufferSize: getTreeSitterBufferSize(content.length) });
272
- }
273
- catch {
274
- filesSkipped++;
275
- continue;
276
- }
277
- const queryString = LANGUAGE_QUERIES[language];
278
- if (!queryString) {
279
- filesSkipped++;
280
- continue;
281
- }
282
- let matches;
283
- try {
284
- const tsLang = parser.getLanguage();
285
- const query = new Parser.Query(tsLang, queryString);
286
- matches = query.matches(tree.rootNode);
287
- }
288
- catch {
289
- filesSkipped++;
290
- continue;
291
- }
292
- insertedFilePaths.add(relPath);
293
- for (const match of matches) {
294
- const captureMap = {};
295
- for (const c of match.captures)
296
- captureMap[c.name] = c.node;
297
- // Skip imports/heritage captures — only extract definitions and calls
298
- if (captureMap['import'] || captureMap['import.source'])
299
- continue;
300
- if (captureMap['heritage'] || captureMap['heritage.impl'])
301
- continue;
302
- // Collect call sites for tsgo resolution
303
- if (captureMap['call'] || captureMap['call.name']) {
304
- const callNameNode = captureMap['call.name'];
305
- if (callNameNode) {
306
- callSites.push({
307
- filePath: relPath,
308
- absPath: absPath,
309
- name: callNameNode.text,
310
- line: callNameNode.startPosition.row,
311
- character: callNameNode.startPosition.column,
312
- });
313
- }
314
- continue;
315
- }
316
- const nameNode = captureMap['name'];
317
- if (!nameNode && !captureMap['definition.constructor'])
318
- continue;
319
- const nodeName = nameNode ? nameNode.text : 'init';
320
- let nodeLabel = 'CodeElement';
321
- if (captureMap['definition.function'])
322
- nodeLabel = 'Function';
323
- else if (captureMap['definition.class'])
324
- nodeLabel = 'Class';
325
- else if (captureMap['definition.interface'])
326
- nodeLabel = 'Interface';
327
- else if (captureMap['definition.method'])
328
- nodeLabel = 'Method';
329
- else if (captureMap['definition.struct'])
330
- nodeLabel = 'Struct';
331
- else if (captureMap['definition.enum'])
332
- nodeLabel = 'Enum';
333
- else if (captureMap['definition.namespace'])
334
- nodeLabel = 'Namespace';
335
- else if (captureMap['definition.module'])
336
- nodeLabel = 'Module';
337
- else if (captureMap['definition.trait'])
338
- nodeLabel = 'Trait';
339
- else if (captureMap['definition.impl'])
340
- nodeLabel = 'Impl';
341
- else if (captureMap['definition.type'])
342
- nodeLabel = 'TypeAlias';
343
- else if (captureMap['definition.const'])
344
- nodeLabel = 'Const';
345
- else if (captureMap['definition.static'])
346
- nodeLabel = 'Static';
347
- else if (captureMap['definition.typedef'])
348
- nodeLabel = 'Typedef';
349
- else if (captureMap['definition.macro'])
350
- nodeLabel = 'Macro';
351
- else if (captureMap['definition.union'])
352
- nodeLabel = 'Union';
353
- else if (captureMap['definition.property'])
354
- nodeLabel = 'Property';
355
- else if (captureMap['definition.record'])
356
- nodeLabel = 'Record';
357
- else if (captureMap['definition.delegate'])
358
- nodeLabel = 'Delegate';
359
- else if (captureMap['definition.annotation'])
360
- nodeLabel = 'Annotation';
361
- else if (captureMap['definition.constructor'])
362
- nodeLabel = 'Constructor';
363
- else if (captureMap['definition.template'])
364
- nodeLabel = 'Template';
365
- const defNode = getDefinitionNodeFromCaptures(captureMap);
366
- const startLine = defNode ? defNode.startPosition.row : (nameNode ? nameNode.startPosition.row : 0);
367
- const endLine = defNode ? defNode.endPosition.row : startLine;
368
- const nodeContent = defNode ? (defNode.text || '').slice(0, 50_000) : '';
369
- allDefinitions.push({
370
- nodeId: generateId(nodeLabel, `${relPath}:${nodeName}`),
371
- name: nodeName,
372
- label: nodeLabel,
373
- filePath: relPath,
374
- startLine,
375
- endLine,
376
- content: nodeContent,
377
- });
378
- }
379
- }
380
- // Phase 3: Insert File nodes + symbol nodes
381
- for (const filePath of insertedFilePaths) {
382
- const fileId = generateId('File', filePath);
383
- try {
384
- await executeQuery(repo.id, `CREATE (n:File {id: '${esc(fileId)}', name: '${esc(path.basename(filePath))}', filePath: '${esc(filePath)}', content: ''})`);
385
- nodesInserted++;
386
- }
387
- catch (err) {
388
- console.error(`Code Mapper: [refresh] CREATE File ${filePath}: ${err instanceof Error ? err.message : err}`);
389
- }
390
- }
391
- for (const def of allDefinitions) {
392
- try {
393
- await executeQuery(repo.id, `CREATE (n:${qt(def.label)} {id: '${esc(def.nodeId)}', name: '${esc(def.name)}', filePath: '${esc(def.filePath)}', startLine: ${def.startLine}, endLine: ${def.endLine}, content: '${esc(def.content)}', description: ''})`);
394
- nodesInserted++;
395
- }
396
- catch (err) {
397
- console.error(`Code Mapper: [refresh] CREATE ${def.label} ${def.name}: ${err instanceof Error ? err.message : err}`);
398
- }
399
- const fileId = generateId('File', def.filePath);
400
- try {
401
- await executeQuery(repo.id, `MATCH (a:File), (b:${qt(def.label)}) WHERE a.id = '${esc(fileId)}' AND b.id = '${esc(def.nodeId)}' CREATE (a)-[:${REL_TABLE_NAME} {type: 'DEFINES', confidence: 1.0, reason: '', step: 0}]->(b)`);
402
- edgesInserted++;
403
- }
404
- catch (err) {
405
- console.error(`Code Mapper: [refresh] CREATE DEFINES edge for ${def.name}: ${err instanceof Error ? err.message : err}`);
406
- }
407
- }
408
- // Phase 4: Resolve call edges via tsgo (if enabled)
409
- if (this.tsgoEnabled && callSites.length > 0) {
410
- const tsgo = getTsgoService(repo.repoPath);
411
- if (await tsgo.start()) {
412
- // Notify tsgo about changed files so it has fresh state
413
- for (const entry of filesToProcess) {
414
- const absPath = path.resolve(repo.repoPath, entry.relativePath);
415
- await tsgo.notifyFileChanged(absPath);
416
- }
417
- let tsgoResolved = 0;
418
- for (const call of callSites) {
419
- try {
420
- const def = await tsgo.resolveDefinition(call.absPath, call.line, call.character);
421
- if (!def)
422
- continue;
423
- // Find the target node in the DB by file path + name match
424
- const targetRows = await executeQuery(repo.id, `MATCH (n) WHERE n.filePath = '${esc(def.filePath)}' AND n.startLine <= ${def.line} AND n.endLine >= ${def.line} RETURN n.id AS id LIMIT 1`);
425
- if (targetRows.length === 0)
426
- continue;
427
- const targetId = String(targetRows[0].id ?? '');
428
- if (!targetId)
429
- continue;
430
- // Find the caller node (the function/method containing this call site)
431
- const callerRows = await executeQuery(repo.id, `MATCH (n) WHERE n.filePath = '${esc(call.filePath)}' AND n.startLine <= ${call.line} AND n.endLine >= ${call.line} AND NOT n:File RETURN n.id AS id LIMIT 1`);
432
- const callerId = callerRows.length > 0
433
- ? String(callerRows[0].id ?? '')
434
- : generateId('File', call.filePath);
435
- if (callerId === targetId)
436
- continue; // self-call
437
- try {
438
- await executeQuery(repo.id, `MATCH (a), (b) WHERE a.id = '${esc(callerId)}' AND b.id = '${esc(targetId)}' CREATE (a)-[:${REL_TABLE_NAME} {type: 'CALLS', confidence: 1.0, reason: 'tsgo-semantic', step: 0}]->(b)`);
439
- edgesInserted++;
440
- tsgoResolved++;
441
- }
442
- catch { /* duplicate edge */ }
443
- }
444
- catch { /* resolution failed for this call */ }
445
- }
446
- if (tsgoResolved > 0) {
447
- console.error(`Code Mapper: tsgo resolved ${tsgoResolved}/${callSites.length} call(s) at confidence 1.0`);
448
- }
449
- }
450
- }
451
- // Phase 5: Rebuild FTS indexes
452
- for (const { table, index } of FTS_TABLES) {
453
- try {
454
- await executeQuery(repo.id, `CALL DROP_FTS_INDEX('${table}', '${index}')`);
455
- }
456
- catch { /* may not exist */ }
457
- try {
458
- await executeQuery(repo.id, `CALL CREATE_FTS_INDEX('${table}', '${index}', ['name', 'content'], stemmer := 'porter')`);
459
- }
460
- catch { /* non-fatal */ }
461
- }
462
- return {
463
- filesProcessed: filesToProcess.length,
464
- filesSkipped,
465
- nodesDeleted,
466
- nodesInserted,
467
- edgesInserted,
468
- durationMs: Date.now() - t0,
469
- };
470
- }
471
- /**
472
- * Update CodeEmbedding rows for dirty files so semantic search is never stale.
473
- *
474
- * Runs ONLY when the repo previously had embeddings (stats.embeddings > 0).
475
- * Steps:
476
- * 1. Delete stale CodeEmbedding rows for all dirty file paths (always)
477
- * 2. Query new embeddable nodes for modified/created files
478
- * 3. Generate text → batch embed using the warm MCP singleton model
479
- * 4. Insert new CodeEmbedding rows
480
- * 5. Drop + recreate HNSW vector index
481
- *
482
- * If the embedding model fails to load, stale rows are still deleted —
483
- * semantic search returns fewer results but never wrong ones.
484
- */
485
- async refreshEmbeddings(repo, dirtyFiles) {
486
- if (!repo.stats?.embeddings || repo.stats.embeddings === 0)
487
- return;
488
- if (dirtyFiles.length === 0)
489
- return;
490
- const esc = LocalBackend.escapeCypher;
491
- const dirtyRelPaths = dirtyFiles.map((f) => f.relativePath);
492
- try {
493
- // Step 1: Delete stale embeddings for all dirty files
494
- for (const relPath of dirtyRelPaths) {
495
- try {
496
- const rows = await executeQuery(repo.id, `MATCH (n) WHERE n.filePath = '${esc(relPath)}' RETURN n.id AS id`);
497
- for (const row of rows) {
498
- const nodeId = String(row.id ?? '');
499
- if (!nodeId)
500
- continue;
501
- try {
502
- await executeQuery(repo.id, `MATCH (e:CodeEmbedding) WHERE e.nodeId = '${esc(nodeId)}' DETACH DELETE e`);
503
- }
504
- catch { /* row may not exist */ }
505
- }
506
- }
507
- catch { /* file may not have nodes */ }
508
- }
509
- // Step 2: Query new embeddable nodes
510
- const embeddableLabels = ['Function', 'Class', 'Method', 'Interface', 'File'];
511
- const modifiedPaths = dirtyFiles
512
- .filter((f) => f.changeKind === 'modified' || f.changeKind === 'created')
513
- .map((f) => f.relativePath);
514
- if (modifiedPaths.length === 0) {
515
- await this.rebuildVectorIndex(repo.id);
516
- return;
517
- }
518
- const newNodes = [];
519
- for (const relPath of modifiedPaths) {
520
- for (const label of embeddableLabels) {
521
- try {
522
- const q = label === 'File'
523
- ? `MATCH (n:File) WHERE n.filePath = '${esc(relPath)}' RETURN n.id AS id, n.name AS name, 'File' AS label, n.filePath AS filePath, n.content AS content`
524
- : `MATCH (n:${label}) WHERE n.filePath = '${esc(relPath)}' RETURN n.id AS id, n.name AS name, '${label}' AS label, n.filePath AS filePath, n.content AS content, n.startLine AS startLine, n.endLine AS endLine`;
525
- const rows = await executeQuery(repo.id, q);
526
- for (const row of rows) {
527
- const r = row;
528
- newNodes.push({
529
- id: String(r.id ?? ''), name: String(r.name ?? ''), label: String(r.label ?? label),
530
- filePath: String(r.filePath ?? ''), content: String(r.content ?? ''),
531
- startLine: r.startLine != null ? Number(r.startLine) : undefined,
532
- endLine: r.endLine != null ? Number(r.endLine) : undefined,
533
- });
534
- }
535
- }
536
- catch { /* table may not exist */ }
537
- }
538
- }
539
- // Step 3: Embed + insert
540
- if (newNodes.length > 0) {
541
- try {
542
- const { generateEmbeddingText } = await import('../../core/embeddings/text-generator.js');
543
- const { embedBatch } = await import('../core/embedder.js');
544
- const texts = newNodes.map((node) => generateEmbeddingText(node));
545
- const embeddings = await embedBatch(texts);
546
- for (let i = 0; i < newNodes.length; i++) {
547
- const vecStr = `[${embeddings[i].join(',')}]`;
548
- try {
549
- await executeQuery(repo.id, `CREATE (e:CodeEmbedding {nodeId: '${esc(newNodes[i].id)}', embedding: CAST(${vecStr} AS FLOAT[256])})`);
550
- }
551
- catch { /* duplicate */ }
552
- }
553
- console.error(`Code Mapper: Embedded ${newNodes.length} node(s) incrementally`);
554
- }
555
- catch (err) {
556
- console.error(`Code Mapper: Incremental embedding failed (stale entries removed): ${err instanceof Error ? err.message : err}`);
557
- }
558
- }
559
- // Step 4: Rebuild vector index
560
- await this.rebuildVectorIndex(repo.id);
561
- }
562
- catch (err) {
563
- console.error(`Code Mapper: Embedding refresh failed: ${err instanceof Error ? err.message : err}`);
564
- }
565
- }
566
- async rebuildVectorIndex(repoId) {
567
- try {
568
- await executeQuery(repoId, 'INSTALL VECTOR');
569
- }
570
- catch { }
571
- try {
572
- await executeQuery(repoId, 'LOAD EXTENSION VECTOR');
573
- }
574
- catch { }
575
- try {
576
- await executeQuery(repoId, `CALL DROP_VECTOR_INDEX('CodeEmbedding', 'code_embedding_idx')`);
577
- }
578
- catch { }
579
- try {
580
- const rows = await executeQuery(repoId, `MATCH (e:CodeEmbedding) RETURN COUNT(*) AS cnt`);
581
- const cnt = Number(rows[0]?.cnt ?? 0);
582
- if (cnt === 0)
583
- return;
584
- }
585
- catch {
586
- return;
587
- }
588
- try {
589
- await executeQuery(repoId, `CALL CREATE_VECTOR_INDEX('CodeEmbedding', 'code_embedding_idx', 'embedding', metric := 'cosine')`);
590
- }
591
- catch (err) {
592
- const msg = err instanceof Error ? err.message : '';
593
- if (!msg.includes('already exists')) {
594
- console.error(`Code Mapper: Vector index rebuild failed: ${msg}`);
595
- }
596
- }
201
+ const db = this.getDb(repo.id);
202
+ return refreshFiles(db, repo.repoPath, dirtyFiles);
597
203
  }
598
204
  // Initialization
599
- /**
600
- * Initialize from the global registry, returns true if at least one repo is available.
601
- * @param opts.tsgo — Enable tsgo semantic resolution (confidence-1.0 call edges)
602
- */
603
- async init(opts) {
604
- this.tsgoEnabled = opts?.tsgo ?? false;
205
+ /** Initialize from the global registry, returns true if at least one repo is available */
206
+ async init() {
605
207
  await this.refreshRepos();
606
208
  // Start file watchers for incremental refresh
607
209
  for (const [id, handle] of this.repos) {
608
210
  this.startWatcher(id, handle);
609
211
  // Seed watcher with changes that happened while the server was down
610
212
  this.seedWatcherFromGit(id, handle);
611
- // Start tsgo LSP for semantic resolution (optional, non-blocking)
612
- if (this.tsgoEnabled) {
613
- const svc = getTsgoService(handle.repoPath);
614
- svc.start().catch(() => { }); // warm up in background
615
- }
616
213
  }
617
214
  return this.repos.size > 0;
618
215
  }
619
216
  /**
620
217
  * Re-read the global registry and update the in-memory repo map
621
- * LadybugDB connections for removed repos idle-timeout naturally
218
+ * SQLite connections for removed repos are cleaned up on prune
622
219
  */
623
220
  async refreshRepos() {
624
221
  const entries = await listRegisteredRepos({ validate: true });
@@ -627,25 +224,17 @@ export class LocalBackend {
627
224
  const id = this.repoId(entry.name, entry.path);
628
225
  freshIds.add(id);
629
226
  const storagePath = entry.storagePath;
630
- const lbugPath = path.join(storagePath, 'lbug');
631
- // Clean up leftover KuzuDB files from before the LadybugDB migration
632
- // Warn if kuzu exists but lbug doesn't (re-analyze needed)
633
- const kuzu = await cleanupOldKuzuFiles(storagePath);
634
- if (kuzu.found && kuzu.needsReindex) {
635
- console.error(`Code Mapper: "${entry.name}" has a stale KuzuDB index. Run: code-mapper analyze ${entry.path}`);
636
- }
637
227
  const handle = {
638
228
  id,
639
229
  name: entry.name,
640
230
  repoPath: entry.path,
641
231
  storagePath,
642
- lbugPath,
643
232
  indexedAt: entry.indexedAt,
644
233
  lastCommit: entry.lastCommit,
645
234
  stats: entry.stats,
646
235
  };
647
236
  this.repos.set(id, handle);
648
- // Build lightweight context (no LadybugDB needed)
237
+ // Build lightweight context from registry stats
649
238
  const s = entry.stats || {};
650
239
  this.contextCache.set(id, {
651
240
  projectName: entry.name,
@@ -662,7 +251,6 @@ export class LocalBackend {
662
251
  if (!freshIds.has(id)) {
663
252
  this.repos.delete(id);
664
253
  this.contextCache.delete(id);
665
- this.initializedRepos.delete(id);
666
254
  }
667
255
  }
668
256
  }
@@ -736,23 +324,9 @@ export class LocalBackend {
736
324
  }
737
325
  return null; // Multiple repos, no param — ambiguous
738
326
  }
739
- // Lazy LadybugDB Init
327
+ // Lazy DB Init
740
328
  async ensureInitialized(repoId) {
741
- // Always check the actual pool — the idle timer may have evicted the connection
742
- if (this.initializedRepos.has(repoId) && isLbugReady(repoId))
743
- return;
744
- const handle = this.repos.get(repoId);
745
- if (!handle)
746
- throw new Error(`Unknown repo: ${repoId}`);
747
- try {
748
- await initLbug(repoId, handle.lbugPath);
749
- this.initializedRepos.add(repoId);
750
- }
751
- catch (err) {
752
- // If lock error, mark as not initialized so next call retries
753
- this.initializedRepos.delete(repoId);
754
- throw err;
755
- }
329
+ this.getDb(repoId); // openDb is idempotent
756
330
  }
757
331
  // Public Getters
758
332
  /** Get context for a specific repo (or the single repo if only one) */
@@ -776,22 +350,48 @@ export class LocalBackend {
776
350
  stats: h.stats,
777
351
  }));
778
352
  }
353
+ /** Find the narrowest symbol node enclosing a given file position (for tsgo ref merging) */
354
+ findNodeAtPosition(db, filePath, line) {
355
+ try {
356
+ const rows = rawQuery(db, `SELECT name, label, filePath FROM nodes
357
+ WHERE filePath = ? AND startLine <= ? AND endLine >= ?
358
+ AND label NOT IN ('File', 'Folder', 'Community', 'Process')
359
+ ORDER BY (endLine - startLine) ASC
360
+ LIMIT 1`, [filePath, line + 1, line + 1]);
361
+ return rows[0] ?? null;
362
+ }
363
+ catch {
364
+ return null;
365
+ }
366
+ }
779
367
  // ── Compact text formatters — optimized for LLM token efficiency ────
780
- /** Extract signature from content: the declaration line(s), not the full body */
368
+ /** Extract signature from content. For interfaces/types, returns the full body (fields ARE the signature). */
781
369
  extractSignature(content, name, _type) {
782
370
  if (!content)
783
371
  return name || '?';
372
+ // For interfaces and type aliases, the body IS the signature — return it fully
373
+ // (capped at 20 lines to prevent huge classes from flooding context)
374
+ if (_type === 'Interface' || _type === 'TypeAlias' || _type === 'Enum' || _type === 'Const') {
375
+ const trimmed = content.trim();
376
+ const lines = trimmed.split('\n');
377
+ if (lines.length <= 20)
378
+ return trimmed;
379
+ return lines.slice(0, 20).join('\n') + '\n // ... and more fields';
380
+ }
784
381
  const lines = content.split('\n');
785
382
  const declKeywords = /^\s*(export\s+)?(default\s+)?(async\s+)?(function|class|interface|type|const|let|var|enum|struct|trait|impl|pub\s|fn\s|def\s|private|protected|public|static|abstract|override|readonly)/;
786
383
  const namePattern = name ? new RegExp(`\\b${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`) : null;
787
384
  // Helper: collect multi-line signature (for arrow functions with params on multiple lines)
788
385
  const collectSignature = (startIdx) => {
789
- let sig = lines[startIdx].trim();
386
+ const startLine = lines[startIdx];
387
+ if (!startLine)
388
+ return name || '?';
389
+ let sig = startLine.trim();
790
390
  // If the line ends with '(' or has unmatched parens, collect continuation lines
791
391
  let openParens = (sig.match(/\(/g) || []).length - (sig.match(/\)/g) || []).length;
792
392
  let i = startIdx + 1;
793
393
  while (openParens > 0 && i < lines.length && i < startIdx + 8) {
794
- const next = lines[i].trim();
394
+ const next = (lines[i] ?? '').trim();
795
395
  if (!next || next.startsWith('//') || next.startsWith('*')) {
796
396
  i++;
797
397
  continue;
@@ -814,14 +414,20 @@ export class LocalBackend {
814
414
  };
815
415
  // Strategy 1: Find the line containing the symbol name AND a declaration keyword
816
416
  for (let i = 0; i < lines.length; i++) {
817
- const trimmed = lines[i].trim();
417
+ const line = lines[i];
418
+ if (!line)
419
+ continue;
420
+ const trimmed = line.trim();
818
421
  if (namePattern && namePattern.test(trimmed) && (declKeywords.test(trimmed) || trimmed.includes('(') || trimmed.includes(':'))) {
819
422
  return collectSignature(i);
820
423
  }
821
424
  }
822
425
  // Strategy 2: Find any line with a declaration keyword
823
426
  for (let i = 0; i < lines.length; i++) {
824
- const trimmed = lines[i].trim();
427
+ const line = lines[i];
428
+ if (!line)
429
+ continue;
430
+ const trimmed = line.trim();
825
431
  if (declKeywords.test(trimmed)) {
826
432
  return collectSignature(i);
827
433
  }
@@ -859,12 +465,12 @@ export class LocalBackend {
859
465
  .replace(/([a-z])([A-Z])/g, '$1 $2')
860
466
  .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2')
861
467
  .toLowerCase();
862
- const first = humanize(stepNames[0]);
863
- const last = humanize(stepNames[stepNames.length - 1]);
468
+ const first = humanize(stepNames[0] ?? '');
469
+ const last = humanize(stepNames[stepNames.length - 1] ?? '');
864
470
  if (stepNames.length <= 2)
865
471
  return `${first} → ${last}`;
866
472
  // Step 2 is the dispatch point — where the flow specializes
867
- const dispatch = humanize(stepNames[1]);
473
+ const dispatch = humanize(stepNames[1] ?? '');
868
474
  return `${first} → ${dispatch} → ${last}`;
869
475
  }
870
476
  formatQueryAsText(result) {
@@ -933,15 +539,23 @@ export class LocalBackend {
933
539
  const toShow = maxFlows ? flows.slice(0, maxFlows) : flows;
934
540
  let i = 0;
935
541
  while (i < toShow.length) {
936
- const group = [toShow[i]];
542
+ const current = toShow[i];
543
+ if (!current) {
544
+ i++;
545
+ continue;
546
+ }
547
+ const group = [current];
937
548
  for (let j = i + 1; j < toShow.length; j++) {
938
- if (this.sharedPrefixLength(toShow[i].syms, toShow[j].syms) >= 3)
939
- group.push(toShow[j]);
549
+ const candidate = toShow[j];
550
+ if (candidate && this.sharedPrefixLength(current.syms, candidate.syms) >= 3)
551
+ group.push(candidate);
940
552
  }
941
- if (group.length >= 2) {
942
- const prefixLen = this.sharedPrefixLength(group[0].syms, group[1].syms);
943
- const prefix = group[0].syms.slice(0, prefixLen);
944
- const desc = this.describeFlow(group[0].syms.map((s) => s.name));
553
+ const first = group[0];
554
+ const second = group[1];
555
+ if (group.length >= 2 && first && second) {
556
+ const prefixLen = this.sharedPrefixLength(first.syms, second.syms);
557
+ const prefix = first.syms.slice(0, prefixLen);
558
+ const desc = this.describeFlow(first.syms.map((s) => s.name));
945
559
  lines.push('');
946
560
  lines.push(`## ${group.length} flows: ${desc} (shared prefix: ${prefixLen} steps)`);
947
561
  for (const sym of prefix) {
@@ -959,11 +573,10 @@ export class LocalBackend {
959
573
  i += group.length;
960
574
  }
961
575
  else {
962
- const flow = toShow[i];
963
- const desc = this.describeFlow(flow.syms.map((s) => s.name));
576
+ const desc = this.describeFlow(current.syms.map((s) => s.name));
964
577
  lines.push('');
965
- lines.push(`## ${flow.proc.summary}: ${desc} (${flow.proc.step_count} steps)`);
966
- for (const sym of flow.syms) {
578
+ lines.push(`## ${current.proc.summary}: ${desc} (${current.proc.step_count} steps)`);
579
+ for (const sym of current.syms) {
967
580
  lines.push(formatStep(sym));
968
581
  }
969
582
  i++;
@@ -989,6 +602,50 @@ export class LocalBackend {
989
602
  formatContextAsText(result) {
990
603
  if (result.error)
991
604
  return `Error: ${result.error}`;
605
+ // File overview mode
606
+ if (result.status === 'file_overview') {
607
+ const lines = [];
608
+ const modTag = result.module ? ` [${result.module}]` : '';
609
+ lines.push(`## ${result.fileName}${modTag}`);
610
+ lines.push(`${result.file} | ${result.symbolCount} symbols`);
611
+ // Fallback for files with no symbols
612
+ if (result.note) {
613
+ lines.push(`_${result.note}_`);
614
+ }
615
+ if (result.imports?.length > 0) {
616
+ lines.push('');
617
+ lines.push(`### Imports (${result.imports.length})`);
618
+ for (const imp of result.imports) {
619
+ lines.push(` ${imp.name} — ${imp.filePath}`);
620
+ }
621
+ }
622
+ if (result.preview) {
623
+ lines.push('');
624
+ lines.push('### Content preview');
625
+ lines.push('```');
626
+ lines.push(result.preview);
627
+ lines.push('```');
628
+ }
629
+ lines.push('');
630
+ // Group by kind
631
+ const byKind = new Map();
632
+ for (const sym of result.symbols) {
633
+ const list = byKind.get(sym.kind) ?? [];
634
+ list.push(sym);
635
+ byKind.set(sym.kind, list);
636
+ }
637
+ for (const [kind, syms] of byKind) {
638
+ lines.push(`### ${kind}s (${syms.length})`);
639
+ for (const sym of syms) {
640
+ const sig = sym.signature ? ` — \`${sym.signature}\`` : '';
641
+ const calls = sym.callers > 0 || sym.callees > 0 ? ` (${sym.callers} callers, ${sym.callees} callees)` : '';
642
+ const mod = sym.module ? ` [${sym.module}]` : '';
643
+ lines.push(` ${sym.name}${sig}${calls}${mod}`);
644
+ }
645
+ lines.push('');
646
+ }
647
+ return lines.join('\n').trim();
648
+ }
992
649
  if (result.status === 'ambiguous') {
993
650
  const lines = [`Ambiguous: ${result.candidates.length} matches for '${result.message}'`];
994
651
  for (const c of result.candidates) {
@@ -999,7 +656,6 @@ export class LocalBackend {
999
656
  const sym = result.symbol;
1000
657
  const lines = [];
1001
658
  // Header with signature + C5 module
1002
- const sig = sym.signature || sym.name;
1003
659
  const modTag = sym.module ? ` [${sym.module}]` : '';
1004
660
  lines.push(`## ${sym.name}`);
1005
661
  lines.push(`${sym.kind || sym.type || 'Symbol'} @ ${sym.filePath}:${sym.startLine || '?'}-${sym.endLine || '?'}${modTag}`);
@@ -1127,7 +783,8 @@ export class LocalBackend {
1127
783
  for (const [fp, syms] of sortedFiles) {
1128
784
  const names = syms.slice(0, 5).map((s) => s.name).join(', ');
1129
785
  const more = syms.length > 5 ? ` +${syms.length - 5} more` : '';
1130
- const stat = diffStats[fp] || diffStats[fp.split('/').slice(-1)[0]] || '';
786
+ const basename = fp.split('/').slice(-1)[0] ?? '';
787
+ const stat = diffStats[fp] || diffStats[basename] || '';
1131
788
  const statSuffix = stat ? ` (${stat})` : '';
1132
789
  lines.push(` ${this.shortPath(fp)}${statSuffix} — ${syms.length} symbols: ${names}${more}`);
1133
790
  }
@@ -1148,15 +805,21 @@ export class LocalBackend {
1148
805
  // ── Staleness check ────────────────────────────────────────────────
1149
806
  /** C3: Check if index is behind HEAD and return a warning prefix */
1150
807
  getStalenessWarning(repo) {
808
+ const warnings = [];
1151
809
  try {
1152
810
  const { checkStaleness } = require('../staleness.js');
1153
811
  const info = checkStaleness(repo.repoPath, repo.lastCommit);
1154
812
  if (info.isStale) {
1155
- return `⚠ index ${info.commitsBehind} commit${info.commitsBehind > 1 ? 's' : ''} behind HEAD\n\n`;
813
+ warnings.push(`⚠ index ${info.commitsBehind} commit${info.commitsBehind > 1 ? 's' : ''} behind HEAD`);
1156
814
  }
1157
815
  }
1158
816
  catch { }
1159
- return '';
817
+ // Warn if tsgo is unavailable — agents should know call resolution is heuristic-only
818
+ const tsgo = this.tsgoServices.get(repo.id);
819
+ if (!tsgo?.isReady()) {
820
+ warnings.push('⚠ tsgo unavailable — call resolution uses heuristic matching (install @typescript/native-preview for compiler-verified accuracy)');
821
+ }
822
+ return warnings.length > 0 ? warnings.join('\n') + '\n\n' : '';
1160
823
  }
1161
824
  // ── Tool Dispatch ─────────────────────────────────────────────────
1162
825
  async callTool(method, params) {
@@ -1166,14 +829,24 @@ export class LocalBackend {
1166
829
  // Resolve repo from optional param (re-reads registry on miss)
1167
830
  const repo = await this.resolveRepo(params?.repo);
1168
831
  await this.ensureFresh(repo);
832
+ // Eagerly attempt tsgo start so the staleness warning is accurate
833
+ // Agents can opt out with tsgo: false for faster responses
834
+ if (params?.tsgo !== false) {
835
+ await this.getTsgo(repo);
836
+ }
1169
837
  // C3: Prepend staleness warning to all tool responses
1170
838
  const staleWarning = this.getStalenessWarning(repo);
1171
839
  switch (method) {
1172
- case 'query':
1173
- return staleWarning + this.formatQueryAsText(await this.query(repo, params));
1174
- case 'cypher': {
1175
- const raw = await this.cypher(repo, params);
1176
- return this.formatCypherAsMarkdown(raw);
840
+ case 'query': {
841
+ const queryResult = await this.query(repo, params);
842
+ // Overview/architecture queries return pre-formatted strings
843
+ if (typeof queryResult === 'string')
844
+ return staleWarning + queryResult;
845
+ return staleWarning + this.formatQueryAsText(queryResult);
846
+ }
847
+ case 'sql': {
848
+ const raw = await this.sqlQuery(repo, params);
849
+ return this.formatSqlAsMarkdown(raw);
1177
850
  }
1178
851
  case 'context': {
1179
852
  // F5: Bulk context — if names array provided, fetch context for each
@@ -1222,19 +895,15 @@ export class LocalBackend {
1222
895
  if (searchQuery.toLowerCase() === 'overview' || searchQuery.toLowerCase() === 'architecture') {
1223
896
  // Return top clusters with their key symbols instead of search results
1224
897
  const clusterResult = await this.queryClusters(repo.name, 10);
898
+ const overviewDb = this.getDb(repo.id);
1225
899
  const lines = [`## Codebase Overview: ${repo.name}`];
1226
900
  for (const cluster of (clusterResult.clusters || []).slice(0, 8)) {
1227
901
  lines.push(`\n### ${cluster.heuristicLabel || cluster.label} (${cluster.symbolCount} symbols, cohesion: ${(cluster.cohesion || 0).toFixed(2)})`);
1228
902
  // Fetch top 5 symbols from this cluster
1229
903
  try {
1230
- const members = await executeQuery(repo.id, `
1231
- MATCH (n)-[:CodeRelation {type: 'MEMBER_OF'}]->(c:Community {heuristicLabel: '${(cluster.heuristicLabel || cluster.label || '').replace(/'/g, "''")}'})
1232
- RETURN n.name AS name, labels(n) AS type, n.filePath AS filePath
1233
- ORDER BY n.startLine
1234
- LIMIT 5
1235
- `);
904
+ const members = queries.getCommunityMembers(overviewDb, cluster.heuristicLabel || cluster.label || '', 5);
1236
905
  for (const m of members) {
1237
- lines.push(` ${m.name || m[0]} — ${m.type || m[1]} @ ${m.filePath || m[2]}`);
906
+ lines.push(` ${m.name} — ${m.label} @ ${m.filePath}`);
1238
907
  }
1239
908
  }
1240
909
  catch { }
@@ -1265,13 +934,14 @@ export class LocalBackend {
1265
934
  filePath: String(r.filePath ?? ''),
1266
935
  score: Number(r.bm25Score ?? 0),
1267
936
  rank: i + 1,
1268
- startLine: r.startLine,
1269
- endLine: r.endLine,
937
+ ...(r.startLine != null ? { startLine: r.startLine } : {}),
938
+ ...(r.endLine != null ? { endLine: r.endLine } : {}),
1270
939
  }));
1271
940
  const semanticForRRF = semanticResults.map((r) => ({
1272
941
  nodeId: String(r.nodeId ?? ''), name: String(r.name ?? ''), label: String(r.type ?? ''),
1273
942
  filePath: String(r.filePath ?? ''), distance: Number(r.distance ?? 1),
1274
- startLine: r.startLine, endLine: r.endLine,
943
+ ...(r.startLine != null ? { startLine: r.startLine } : {}),
944
+ ...(r.endLine != null ? { endLine: r.endLine } : {}),
1275
945
  }));
1276
946
  const rrfMerged = mergeWithRRF(bm25ForRRF, semanticForRRF, { limit: searchLimit });
1277
947
  // Build lookup from original search data (keyed by both nodeId and filePath for cross-referencing)
@@ -1347,14 +1017,12 @@ export class LocalBackend {
1347
1017
  try {
1348
1018
  const rerankCandidates = merged.filter(m => m.data.nodeId).slice(0, 30);
1349
1019
  if (rerankCandidates.length > 1) {
1350
- const nodeIdList = rerankCandidates.map(c => `'${String(c.data.nodeId).replace(/'/g, "''")}'`).join(', ');
1351
- const snippetRows = await executeQuery(repo.id, `
1352
- MATCH (n) WHERE n.id IN [${nodeIdList}]
1353
- RETURN n.id AS nodeId, COALESCE(n.content, n.name) AS snippet
1354
- `);
1020
+ const rerankDb = this.getDb(repo.id);
1021
+ const rerankIds = rerankCandidates.map(c => toNodeId(String(c.data.nodeId)));
1022
+ const snippetNodes = queries.findNodesByIds(rerankDb, rerankIds);
1355
1023
  const snippetMap = new Map();
1356
- for (const row of snippetRows) {
1357
- snippetMap.set(String(row.nodeId ?? row[0]), String(row.snippet ?? row[1] ?? ''));
1024
+ for (const node of snippetNodes) {
1025
+ snippetMap.set(node.id, node.content || node.name || '');
1358
1026
  }
1359
1027
  const passages = rerankCandidates
1360
1028
  .map(c => ({ id: String(c.data.nodeId), text: snippetMap.get(c.data.nodeId) ?? String(c.data.name ?? '') }))
@@ -1424,80 +1092,49 @@ export class LocalBackend {
1424
1092
  let signatureMap = new Map();
1425
1093
  let clusterByNode = new Map();
1426
1094
  if (symbolsWithNodeId.length > 0) {
1427
- const nodeIdList = symbolsWithNodeId.map(s => `'${String(s.nodeId).replace(/'/g, "''")}'`).join(', ');
1095
+ const queryDb = this.getDb(repo.id);
1096
+ const nodeIds = symbolsWithNodeId.map(s => toNodeId(String(s.nodeId)));
1428
1097
  // Batch process lookup
1429
- let processRows = [];
1430
- try {
1431
- processRows = await executeQuery(repo.id, `
1432
- MATCH (n)-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p:Process)
1433
- WHERE n.id IN [${nodeIdList}]
1434
- RETURN n.id AS nodeId, p.id AS pid, p.label AS label, p.heuristicLabel AS heuristicLabel,
1435
- p.processType AS processType, p.stepCount AS stepCount, r.step AS step
1436
- `);
1437
- }
1438
- catch (e) {
1439
- logQueryError('query:batch-process-lookup', e);
1440
- }
1098
+ const processRows = queries.batchFindProcesses(queryDb, nodeIds);
1441
1099
  // Batch cluster lookup
1442
- let clusterRows = [];
1443
- try {
1444
- clusterRows = await executeQuery(repo.id, `
1445
- MATCH (n)-[:CodeRelation {type: 'MEMBER_OF'}]->(c:Community)
1446
- WHERE n.id IN [${nodeIdList}]
1447
- RETURN n.id AS nodeId, c.cohesion AS cohesion, c.heuristicLabel AS module
1448
- `);
1449
- }
1450
- catch (e) {
1451
- logQueryError('query:batch-cluster-info', e);
1452
- }
1100
+ const clusterRows = queries.batchFindCommunities(queryDb, nodeIds);
1453
1101
  // Always fetch content for signature extraction (eliminates follow-up Read calls)
1454
1102
  const contentMap = new Map();
1455
- // signatureMap hoisted above for F1 access
1456
- try {
1457
- const contentRows = await executeQuery(repo.id, `
1458
- MATCH (n) WHERE n.id IN [${nodeIdList}] RETURN n.id AS nodeId, n.content AS content
1459
- `);
1460
- // Build a name lookup for signature extraction
1461
- const nodeNameMap = new Map();
1462
- for (const s of symbolsWithNodeId) {
1463
- nodeNameMap.set(s.nodeId, String(s.data.name ?? ''));
1464
- }
1465
- for (const row of contentRows) {
1466
- const nid = String(row.nodeId ?? row[0]);
1467
- const cnt = row.content ?? row[1];
1468
- if (cnt) {
1469
- const full = String(cnt);
1470
- // Extract signature: the declaration line only
1471
- signatureMap.set(nid, this.extractSignature(full, nodeNameMap.get(nid) || '', ''));
1472
- if (includeContent) {
1473
- const lines = full.split('\n');
1474
- let snippet = '';
1475
- for (const line of lines) {
1476
- snippet += (snippet ? '\n' : '') + line;
1477
- if (snippet.length > 200 || line.includes('{') || line.includes('=>'))
1478
- break;
1479
- }
1480
- contentMap.set(nid, snippet);
1103
+ const contentNodes = queries.findNodesByIds(queryDb, nodeIds);
1104
+ const nodeNameMap = new Map();
1105
+ for (const s of symbolsWithNodeId) {
1106
+ nodeNameMap.set(s.nodeId, String(s.data.name ?? ''));
1107
+ }
1108
+ for (const node of contentNodes) {
1109
+ const nid = node.id;
1110
+ if (node.content) {
1111
+ const full = String(node.content);
1112
+ signatureMap.set(nid, this.extractSignature(full, nodeNameMap.get(nid) || '', node.label));
1113
+ if (includeContent) {
1114
+ const contentLines = full.split('\n');
1115
+ let snippet = '';
1116
+ for (const line of contentLines) {
1117
+ snippet += (snippet ? '\n' : '') + line;
1118
+ if (snippet.length > 200 || line.includes('{') || line.includes('=>'))
1119
+ break;
1481
1120
  }
1121
+ contentMap.set(nid, snippet);
1482
1122
  }
1483
1123
  }
1484
1124
  }
1485
- catch (e) {
1486
- logQueryError('query:batch-content-fetch', e);
1487
- }
1488
1125
  // Index batched results by nodeId
1489
1126
  const processRowsByNode = new Map();
1490
1127
  for (const row of processRows) {
1491
- const nid = String(row.nodeId ?? row[0]);
1128
+ const nid = row.nodeId;
1492
1129
  if (!processRowsByNode.has(nid))
1493
1130
  processRowsByNode.set(nid, []);
1494
1131
  processRowsByNode.get(nid).push(row);
1495
1132
  }
1496
1133
  // clusterByNode hoisted above for F1 access
1497
1134
  for (const row of clusterRows) {
1498
- const nid = String(row.nodeId ?? row[0]);
1135
+ const nid = row.nodeId;
1499
1136
  if (!clusterByNode.has(nid)) {
1500
- clusterByNode.set(nid, { cohesion: Number(row.cohesion ?? row[1] ?? 0), module: String(row.module ?? row[2] ?? '') });
1137
+ clusterByNode.set(nid, { cohesion: row.cohesion, module: row.module });
1501
1138
  }
1502
1139
  }
1503
1140
  // Assemble using batched data
@@ -1521,13 +1158,13 @@ export class LocalBackend {
1521
1158
  }
1522
1159
  else {
1523
1160
  for (const row of symProcessRows) {
1524
- const pid = String(row.pid ?? row[1]);
1161
+ const pid = row.processId;
1525
1162
  if (!processMap.has(pid)) {
1526
1163
  processMap.set(pid, {
1527
- id: pid, label: String(row.label ?? row[2] ?? ''),
1528
- heuristicLabel: String(row.heuristicLabel ?? row[3] ?? ''),
1529
- processType: String(row.processType ?? row[4] ?? ''),
1530
- stepCount: Number(row.stepCount ?? row[5] ?? 0),
1164
+ id: pid, label: row.label,
1165
+ heuristicLabel: row.heuristicLabel,
1166
+ processType: row.processType,
1167
+ stepCount: row.stepCount,
1531
1168
  bestScore: 0, symbolScoreSum: 0, symbolCount: 0, cohesionBoost: 0, symbols: [],
1532
1169
  });
1533
1170
  }
@@ -1536,7 +1173,7 @@ export class LocalBackend {
1536
1173
  proc.symbolScoreSum += symInfo.score;
1537
1174
  proc.symbolCount++;
1538
1175
  proc.cohesionBoost = Math.max(proc.cohesionBoost, cluster?.cohesion ?? 0);
1539
- proc.symbols.push({ ...symbolEntry, process_id: pid, step_index: Number(row.step ?? row[6] ?? 0) });
1176
+ proc.symbols.push({ ...symbolEntry, process_id: pid, step_index: row.step });
1540
1177
  }
1541
1178
  }
1542
1179
  }
@@ -1573,7 +1210,7 @@ export class LocalBackend {
1573
1210
  if (!queryMentionsServer && rankedProcesses.length > 1) {
1574
1211
  const SERVER_ENTRY_PREFIXES = ['createserver', 'mcpcommand', 'startmcpserver', 'handlerequest', 'servecommand', 'setupcommand', 'statuscommand', 'listcommand', 'cleancommand'];
1575
1212
  for (const proc of rankedProcesses) {
1576
- const entryName = (proc.heuristicLabel || proc.label || '').toLowerCase().split(' ')[0];
1213
+ const entryName = (proc.heuristicLabel || proc.label || '').toLowerCase().split(' ')[0] ?? '';
1577
1214
  if (SERVER_ENTRY_PREFIXES.some(p => entryName.includes(p))) {
1578
1215
  proc.priority *= 0.5;
1579
1216
  }
@@ -1591,29 +1228,23 @@ export class LocalBackend {
1591
1228
  let allStepsMap = new Map(); // pid -> all steps
1592
1229
  if (topProcIds.length > 0) {
1593
1230
  try {
1594
- const procIdList = topProcIds.map(id => `'${id.replace(/'/g, "''")}'`).join(', ');
1595
- const allStepsRows = await executeQuery(repo.id, `
1596
- MATCH (s)-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p:Process)
1597
- WHERE p.id IN [${procIdList}]
1598
- RETURN s.id AS nodeId, s.name AS name, labels(s) AS type, s.filePath AS filePath, s.startLine AS startLine,
1599
- p.id AS pid, r.step AS step
1600
- ORDER BY p.id, r.step
1601
- `);
1231
+ const stepsDb = this.getDb(repo.id);
1232
+ const allStepsRows = queries.batchGetProcessSteps(stepsDb, topProcIds.map(id => toNodeId(id)));
1602
1233
  for (const row of allStepsRows) {
1603
- const pid = String(row.pid ?? row[5]);
1234
+ const pid = row.processId;
1604
1235
  if (!allStepsMap.has(pid))
1605
1236
  allStepsMap.set(pid, []);
1606
- const nodeId = String(row.nodeId ?? row[0]);
1237
+ const nodeId = row.nodeId;
1607
1238
  const sig = signatureMap.get(nodeId);
1608
1239
  allStepsMap.get(pid).push({
1609
1240
  nodeId,
1610
- name: String(row.name ?? row[1]),
1611
- type: String(row.type ?? row[2] ?? 'Symbol'),
1612
- filePath: String(row.filePath ?? row[3] ?? ''),
1613
- startLine: row.startLine ?? row[4],
1614
- step_index: Number(row.step ?? row[6] ?? 0),
1241
+ name: row.name,
1242
+ type: row.label,
1243
+ filePath: row.filePath,
1244
+ startLine: row.startLine,
1245
+ step_index: row.step,
1615
1246
  matched: matchedNodeIds.has(nodeId),
1616
- signature: sig || String(row.name ?? row[1]),
1247
+ signature: sig || row.name,
1617
1248
  module: clusterByNode.get(nodeId)?.module,
1618
1249
  });
1619
1250
  }
@@ -1650,45 +1281,58 @@ export class LocalBackend {
1650
1281
  return { processes, process_symbols: dedupedSymbols, definitions: definitions.slice(0, DEFAULT_MAX_DEFINITIONS), _searchQuery: searchQuery };
1651
1282
  }
1652
1283
  /**
1653
- * BM25 keyword search helper - uses LadybugDB FTS for always-fresh results
1284
+ * BM25 keyword search helper uses SQLite FTS5 for always-fresh results
1654
1285
  */
1655
1286
  async bm25Search(repo, query, limit) {
1656
- const { searchFTSFromLbug } = await import('../../core/search/bm25-index.js');
1657
1287
  const { expandQuery } = await import('../../core/search/query-expansion.js');
1658
1288
  const { PRF_SPARSE_THRESHOLD, PRF_WEAK_SCORE_THRESHOLD } = await import('../../core/search/types.js');
1659
1289
  try {
1660
- let results = await searchFTSFromLbug(query, limit, repo.id);
1290
+ const ftsDb = this.getDb(repo.id);
1291
+ let results = searchFTS(ftsDb, query, limit);
1661
1292
  // Pseudo-relevance feedback: expand when results are sparse OR top score is weak
1662
- const topScore = results.length > 0 ? results[0].score : 0;
1293
+ const firstResult = results[0];
1294
+ const topScore = firstResult ? firstResult.score : 0;
1663
1295
  const shouldExpand = results.length > 0 && (results.length < PRF_SPARSE_THRESHOLD || topScore < PRF_WEAK_SCORE_THRESHOLD);
1664
1296
  if (shouldExpand) {
1665
1297
  const topSymbolNames = results.slice(0, 3).map(r => r.name);
1666
1298
  const expandedQuery = expandQuery(query, topSymbolNames);
1667
1299
  if (expandedQuery !== query) {
1668
- const expandedResults = await searchFTSFromLbug(expandedQuery, limit, repo.id);
1669
- const seen = new Set(results.map(r => r.nodeId));
1300
+ const expandedResults = searchFTS(ftsDb, expandedQuery, limit);
1301
+ const seen = new Set(results.map(r => r.id));
1670
1302
  for (const r of expandedResults) {
1671
- if (!seen.has(r.nodeId)) {
1303
+ if (!seen.has(r.id)) {
1672
1304
  results.push(r);
1673
- seen.add(r.nodeId);
1305
+ seen.add(r.id);
1674
1306
  }
1675
1307
  }
1676
1308
  results = results.slice(0, limit);
1677
1309
  }
1678
1310
  }
1311
+ // Fetch full node data for startLine/endLine
1312
+ const nodeIds = results.map(r => r.id);
1313
+ const nodeMap = new Map();
1314
+ if (nodeIds.length > 0) {
1315
+ const fullNodes = queries.findNodesByIds(ftsDb, nodeIds);
1316
+ for (const n of fullNodes) {
1317
+ nodeMap.set(n.id, n);
1318
+ }
1319
+ }
1679
1320
  // Map to the shape expected by the query pipeline
1680
- return results.map(r => ({
1681
- nodeId: r.nodeId,
1682
- name: r.name,
1683
- type: r.type,
1684
- filePath: r.filePath,
1685
- startLine: r.startLine,
1686
- endLine: r.endLine,
1687
- bm25Score: r.score,
1688
- }));
1321
+ return results.map(r => {
1322
+ const node = nodeMap.get(r.id);
1323
+ return {
1324
+ nodeId: r.id,
1325
+ name: r.name,
1326
+ type: r.label,
1327
+ filePath: r.filePath,
1328
+ startLine: node?.startLine,
1329
+ endLine: node?.endLine,
1330
+ bm25Score: r.score,
1331
+ };
1332
+ });
1689
1333
  }
1690
1334
  catch (err) {
1691
- console.error('Code Mapper: BM25/FTS search failed (FTS indexes may not exist) -', err.message);
1335
+ console.error('Code Mapper: BM25/FTS search failed -', err instanceof Error ? err.message : err);
1692
1336
  return [];
1693
1337
  }
1694
1338
  }
@@ -1697,85 +1341,62 @@ export class LocalBackend {
1697
1341
  */
1698
1342
  async semanticSearch(repo, query, limit) {
1699
1343
  try {
1700
- // Check if embedding table exists before loading the model (avoids heavy model init when embeddings are off)
1701
- const tableCheck = await executeQuery(repo.id, `MATCH (e:CodeEmbedding) RETURN COUNT(*) AS cnt LIMIT 1`);
1702
- if (!tableCheck.length || (tableCheck[0].cnt ?? tableCheck[0][0]) === 0)
1344
+ // Check if embeddings exist before loading the model (avoids heavy model init when embeddings are off)
1345
+ const semDb = this.getDb(repo.id);
1346
+ const embCount = countEmbeddings(semDb);
1347
+ if (embCount === 0)
1703
1348
  return [];
1704
1349
  const { DEFAULT_MAX_SEMANTIC_DISTANCE } = await import('../../core/search/types.js');
1705
- const { embedQuery, getEmbeddingDims } = await import('../core/embedder.js');
1350
+ const { embedQuery } = await import('../../core/embeddings/embedder.js');
1706
1351
  const queryVec = await embedQuery(query);
1707
- const dims = getEmbeddingDims();
1708
- const queryVecStr = `[${queryVec.join(',')}]`;
1709
- const vectorQuery = `
1710
- CALL QUERY_VECTOR_INDEX('CodeEmbedding', 'code_embedding_idx',
1711
- CAST(${queryVecStr} AS FLOAT[${dims}]), ${limit})
1712
- YIELD node AS emb, distance
1713
- RETURN emb.nodeId AS nodeId, distance
1714
- ORDER BY distance
1715
- `;
1716
- const embResults = await executeQuery(repo.id, vectorQuery);
1717
- if (embResults.length === 0)
1718
- return [];
1719
- // Filter by distance threshold — cut irrelevant results before RRF merge
1720
- const filteredResults = embResults.filter(r => Number(r.distance ?? r[1] ?? 1) < DEFAULT_MAX_SEMANTIC_DISTANCE);
1721
- if (filteredResults.length === 0)
1352
+ // Brute-force cosine search via adapter (fast enough for <200K vectors at 256 dims)
1353
+ const vecResults = searchVector(semDb, queryVec, limit, DEFAULT_MAX_SEMANTIC_DISTANCE);
1354
+ if (vecResults.length === 0)
1722
1355
  return [];
1723
- // Batch metadata fetch — single query instead of N per-row queries
1724
- const nodeIds = filteredResults.map(r => String(r.nodeId ?? r[0]));
1356
+ // Batch metadata fetch
1357
+ const vecNodeIds = vecResults.map(r => r.nodeId);
1725
1358
  const distanceMap = new Map();
1726
- for (const r of filteredResults) {
1727
- distanceMap.set(String(r.nodeId ?? r[0]), Number(r.distance ?? r[1] ?? 1));
1728
- }
1729
- const idList = nodeIds.map(id => `'${id.replace(/'/g, "''")}'`).join(', ');
1730
- let metaRows = [];
1731
- try {
1732
- metaRows = await executeQuery(repo.id, `
1733
- MATCH (n) WHERE n.id IN [${idList}]
1734
- RETURN n.id AS nodeId, n.name AS name, labels(n) AS type, n.filePath AS filePath, n.startLine AS startLine, n.endLine AS endLine
1735
- `);
1736
- }
1737
- catch { }
1738
- return metaRows.map(row => {
1739
- const nid = String(row.nodeId ?? row[0]);
1740
- return {
1741
- nodeId: nid,
1742
- name: String(row.name ?? row[1] ?? ''),
1743
- type: String(row.type ?? row[2] ?? 'Unknown'),
1744
- filePath: String(row.filePath ?? row[3] ?? ''),
1745
- distance: distanceMap.get(nid) ?? 1,
1746
- startLine: row.startLine ?? row[4],
1747
- endLine: row.endLine ?? row[5],
1748
- };
1749
- });
1359
+ for (const r of vecResults) {
1360
+ distanceMap.set(r.nodeId, r.distance);
1361
+ }
1362
+ const metaNodes = queries.findNodesByIds(semDb, vecNodeIds);
1363
+ return metaNodes.map(node => ({
1364
+ nodeId: node.id,
1365
+ name: node.name,
1366
+ type: node.label,
1367
+ filePath: node.filePath,
1368
+ distance: distanceMap.get(node.id) ?? 1,
1369
+ startLine: node.startLine,
1370
+ endLine: node.endLine,
1371
+ }));
1750
1372
  }
1751
1373
  catch {
1752
1374
  // Expected when embeddings are disabled — silently fall back to BM25-only
1753
1375
  return [];
1754
1376
  }
1755
1377
  }
1756
- async executeCypher(repoName, query) {
1378
+ async executeSql(repoName, query) {
1757
1379
  const repo = await this.resolveRepo(repoName);
1758
- return this.cypher(repo, { query });
1380
+ return this.sqlQuery(repo, { query });
1759
1381
  }
1760
- async cypher(repo, params) {
1382
+ async sqlQuery(repo, params) {
1761
1383
  await this.ensureInitialized(repo.id);
1762
- if (!isLbugReady(repo.id)) {
1763
- return { error: 'LadybugDB not ready. Index may be corrupted.' };
1764
- }
1384
+ const db = this.getDb(repo.id);
1765
1385
  // Block write operations (defense-in-depth — DB is already read-only)
1766
- if (CYPHER_WRITE_RE.test(params.query)) {
1767
- return { error: 'Write operations (CREATE, DELETE, SET, MERGE, REMOVE, DROP, ALTER, COPY, DETACH) are not allowed. The knowledge graph is read-only.' };
1386
+ const SQL_WRITE_RE = /\b(INSERT|UPDATE|DELETE|DROP|ALTER|CREATE\s+TABLE|CREATE\s+INDEX|REPLACE\s+INTO)\b/i;
1387
+ if (SQL_WRITE_RE.test(params.query)) {
1388
+ return { error: 'Write operations are not allowed. The knowledge graph is read-only.' };
1768
1389
  }
1769
1390
  try {
1770
- const result = await executeQuery(repo.id, params.query);
1391
+ const result = rawQuery(db, params.query);
1771
1392
  return result;
1772
1393
  }
1773
1394
  catch (err) {
1774
- return { error: err.message || 'Query failed' };
1395
+ return { error: err instanceof Error ? err.message : 'Query failed' };
1775
1396
  }
1776
1397
  }
1777
- /** Format raw Cypher result rows as a markdown table, with raw fallback */
1778
- formatCypherAsMarkdown(result) {
1398
+ /** Format raw SQL result rows as a markdown table, with raw fallback */
1399
+ formatSqlAsMarkdown(result) {
1779
1400
  if (!Array.isArray(result) || result.length === 0)
1780
1401
  return result;
1781
1402
  const firstRow = result[0];
@@ -1843,20 +1464,12 @@ export class LocalBackend {
1843
1464
  };
1844
1465
  if (params.showClusters !== false) {
1845
1466
  try {
1846
- // Fetch more raw communities than the display limit so aggregation has enough data
1467
+ const db = this.getDb(repo.id);
1847
1468
  const rawLimit = Math.max(limit * 5, 200);
1848
- const clusters = await executeQuery(repo.id, `
1849
- MATCH (c:Community)
1850
- RETURN c.id AS id, c.label AS label, c.heuristicLabel AS heuristicLabel, c.cohesion AS cohesion, c.symbolCount AS symbolCount
1851
- ORDER BY c.symbolCount DESC
1852
- LIMIT ${rawLimit}
1853
- `);
1854
- const rawClusters = clusters.map((c) => ({
1855
- id: c.id || c[0],
1856
- label: c.label || c[1],
1857
- heuristicLabel: c.heuristicLabel || c[2],
1858
- cohesion: c.cohesion || c[3],
1859
- symbolCount: c.symbolCount || c[4],
1469
+ const clusterNodes = queries.listCommunities(db, rawLimit);
1470
+ const rawClusters = clusterNodes.map(c => ({
1471
+ id: c.id, label: c.name, heuristicLabel: c.heuristicLabel || c.name,
1472
+ cohesion: c.cohesion, symbolCount: c.symbolCount,
1860
1473
  }));
1861
1474
  result.clusters = this.aggregateClusters(rawClusters).slice(0, limit);
1862
1475
  }
@@ -1866,18 +1479,11 @@ export class LocalBackend {
1866
1479
  }
1867
1480
  if (params.showProcesses !== false) {
1868
1481
  try {
1869
- const processes = await executeQuery(repo.id, `
1870
- MATCH (p:Process)
1871
- RETURN p.id AS id, p.label AS label, p.heuristicLabel AS heuristicLabel, p.processType AS processType, p.stepCount AS stepCount
1872
- ORDER BY p.stepCount DESC
1873
- LIMIT ${limit}
1874
- `);
1875
- result.processes = processes.map((p) => ({
1876
- id: p.id || p[0],
1877
- label: p.label || p[1],
1878
- heuristicLabel: p.heuristicLabel || p[2],
1879
- processType: p.processType || p[3],
1880
- stepCount: p.stepCount || p[4],
1482
+ const db = this.getDb(repo.id);
1483
+ const processNodes = queries.listProcesses(db, limit);
1484
+ result.processes = processNodes.map(p => ({
1485
+ id: p.id, label: p.name, heuristicLabel: p.heuristicLabel || p.name,
1486
+ processType: p.processType, stepCount: p.stepCount,
1881
1487
  }));
1882
1488
  }
1883
1489
  catch {
@@ -1886,46 +1492,208 @@ export class LocalBackend {
1886
1492
  }
1887
1493
  return result;
1888
1494
  }
1889
- /** Context tool: 360-degree symbol view with categorized refs and disambiguation */
1495
+ /**
1496
+ * File overview: list all symbols in a file with signatures and caller/callee counts.
1497
+ * Triggered when context() is called with file_path only (no name/uid).
1498
+ */
1499
+ async fileOverview(repo, filePath, includeContent, enableTsgo) {
1500
+ const db = this.getDb(repo.id);
1501
+ // Find the file node and all symbols in it
1502
+ const fileNodes = findNodesByFile(db, filePath);
1503
+ if (fileNodes.length === 0) {
1504
+ // Try substring match
1505
+ const allFiles = db.prepare("SELECT DISTINCT filePath FROM nodes WHERE filePath LIKE ? AND label = 'File' LIMIT 5").all(`%${filePath}%`);
1506
+ if (allFiles.length === 0)
1507
+ return { error: `No file matching '${filePath}' found in index.` };
1508
+ if (allFiles.length > 1) {
1509
+ return {
1510
+ status: 'ambiguous',
1511
+ message: `Multiple files match '${filePath}'. Be more specific.`,
1512
+ candidates: allFiles.map(f => ({
1513
+ name: f.filePath.split('/').pop() ?? f.filePath,
1514
+ kind: 'File',
1515
+ filePath: f.filePath,
1516
+ line: null,
1517
+ })),
1518
+ };
1519
+ }
1520
+ // Exact single match — re-query
1521
+ return this.fileOverview(repo, allFiles[0].filePath, includeContent, enableTsgo);
1522
+ }
1523
+ const symbols = fileNodes.filter(n => n.label !== 'File' && n.label !== 'Folder');
1524
+ // Fallback: file has no extractable symbols (e.g., imperative entry points, config files)
1525
+ // Return imports + file content preview so the agent can still orient
1526
+ if (symbols.length === 0) {
1527
+ const fileNode = fileNodes.find(n => n.label === 'File');
1528
+ const actualPath = fileNode?.filePath ?? filePath;
1529
+ // Get outgoing IMPORTS edges from this file
1530
+ const imports = fileNode
1531
+ ? db.prepare(`SELECT t.name, t.filePath FROM edges e JOIN nodes t ON t.id = e.targetId
1532
+ WHERE e.sourceId = ? AND e.type = 'IMPORTS'`).all(fileNode.id)
1533
+ : [];
1534
+ // Read first 30 lines of file content from the stored content or disk
1535
+ let preview = fileNode?.content ?? '';
1536
+ if (preview.length > 2000)
1537
+ preview = preview.slice(0, 2000) + '\n// ...truncated';
1538
+ return {
1539
+ status: 'file_overview',
1540
+ file: actualPath,
1541
+ fileName: actualPath.split('/').pop() ?? filePath,
1542
+ module: null,
1543
+ symbolCount: 0,
1544
+ note: 'No extractable symbols (entry point or config file). Showing imports and content preview.',
1545
+ imports: imports.map(i => ({ name: i.name, filePath: i.filePath })),
1546
+ preview,
1547
+ symbols: [],
1548
+ };
1549
+ }
1550
+ // Batch-fetch caller and callee counts per symbol
1551
+ const symbolIds = symbols.map(s => s.id);
1552
+ const callerCounts = new Map();
1553
+ const calleeCounts = new Map();
1554
+ if (symbolIds.length > 0) {
1555
+ const ph = symbolIds.map(() => '?').join(',');
1556
+ const callerRows = db.prepare(`SELECT targetId, COUNT(*) as cnt FROM edges WHERE targetId IN (${ph}) AND type = 'CALLS' GROUP BY targetId`).all(...symbolIds);
1557
+ for (const r of callerRows)
1558
+ callerCounts.set(r.targetId, r.cnt);
1559
+ const calleeRows = db.prepare(`SELECT sourceId, COUNT(*) as cnt FROM edges WHERE sourceId IN (${ph}) AND type = 'CALLS' GROUP BY sourceId`).all(...symbolIds);
1560
+ for (const r of calleeRows)
1561
+ calleeCounts.set(r.sourceId, r.cnt);
1562
+ }
1563
+ // Get community membership for symbols
1564
+ const communityMap = new Map();
1565
+ if (symbolIds.length > 0) {
1566
+ const ph = symbolIds.map(() => '?').join(',');
1567
+ const memberRows = db.prepare(`SELECT e.sourceId, c.heuristicLabel FROM edges e JOIN nodes c ON c.id = e.targetId
1568
+ WHERE e.sourceId IN (${ph}) AND e.type = 'MEMBER_OF' AND c.label = 'Community'`).all(...symbolIds);
1569
+ for (const r of memberRows)
1570
+ communityMap.set(r.sourceId, r.heuristicLabel);
1571
+ }
1572
+ // Optionally enrich with tsgo hover for real type signatures
1573
+ const signatures = new Map();
1574
+ if (enableTsgo !== false) {
1575
+ const tsgo = await this.getTsgo(repo);
1576
+ if (tsgo) {
1577
+ const absBase = repo.repoPath;
1578
+ for (const sym of symbols.slice(0, 30)) { // cap to avoid slow
1579
+ if (sym.startLine != null && /\.(ts|tsx|js|jsx|mts|mjs)$/.test(sym.filePath)) {
1580
+ try {
1581
+ const hover = await tsgo.getHover(path.resolve(absBase, sym.filePath), sym.startLine - 1, 0);
1582
+ if (hover) {
1583
+ const clean = hover.replace(/```\w*\n?/g, '').replace(/```/g, '').trim();
1584
+ signatures.set(sym.id, clean);
1585
+ }
1586
+ }
1587
+ catch { }
1588
+ }
1589
+ }
1590
+ }
1591
+ }
1592
+ // Build result
1593
+ const actualFilePath = fileNodes.find(n => n.label === 'File')?.filePath ?? filePath;
1594
+ const fileName = actualFilePath.split('/').pop() ?? filePath;
1595
+ const module = communityMap.values().next().value ?? null;
1596
+ // Group by label
1597
+ const grouped = new Map();
1598
+ for (const sym of symbols) {
1599
+ const list = grouped.get(sym.label) ?? [];
1600
+ list.push(sym);
1601
+ grouped.set(sym.label, list);
1602
+ }
1603
+ return {
1604
+ status: 'file_overview',
1605
+ file: actualFilePath,
1606
+ fileName,
1607
+ module,
1608
+ symbolCount: symbols.length,
1609
+ symbols: symbols.map(sym => ({
1610
+ uid: sym.id,
1611
+ name: sym.name,
1612
+ kind: sym.label,
1613
+ startLine: sym.startLine,
1614
+ endLine: sym.endLine,
1615
+ signature: signatures.get(sym.id) ?? null,
1616
+ callers: callerCounts.get(sym.id) ?? 0,
1617
+ callees: calleeCounts.get(sym.id) ?? 0,
1618
+ module: communityMap.get(sym.id) ?? null,
1619
+ ...(includeContent ? { content: sym.content } : {}),
1620
+ })),
1621
+ };
1622
+ }
1623
+ /** Context tool: 360-degree symbol view, file overview, or disambiguation */
1890
1624
  async context(repo, params) {
1891
1625
  await this.ensureInitialized(repo.id);
1892
1626
  const { name, uid, file_path, include_content } = params;
1627
+ // File overview mode: file_path only, no symbol name
1628
+ if (file_path && !name && !uid) {
1629
+ return this.fileOverview(repo, file_path, include_content, params.tsgo);
1630
+ }
1893
1631
  if (!name && !uid) {
1894
- return { error: 'Either "name" or "uid" parameter is required.' };
1632
+ return { error: 'Either "name", "uid", or "file_path" parameter is required.' };
1895
1633
  }
1896
1634
  // Step 1: Find the symbol
1897
1635
  let symbols;
1636
+ const db = this.getDb(repo.id);
1898
1637
  if (uid) {
1899
- // Always fetch content for signature extraction (token-efficient alternative to full Read calls)
1900
- symbols = await executeParameterized(repo.id, `
1901
- MATCH (n {id: $uid})
1902
- RETURN n.id AS id, n.name AS name, labels(n) AS type, n.filePath AS filePath, n.startLine AS startLine, n.endLine AS endLine, n.content AS content
1903
- LIMIT 1
1904
- `, { uid });
1638
+ const node = getNode(db, toNodeId(uid));
1639
+ symbols = node ? [{ id: node.id, name: node.name, type: node.label, filePath: node.filePath, startLine: node.startLine, endLine: node.endLine, content: node.content }] : [];
1905
1640
  }
1906
1641
  else {
1907
1642
  const isQualified = name.includes('/') || name.includes(':');
1908
- let whereClause;
1909
- let queryParams;
1643
+ let matchedNodes;
1910
1644
  if (file_path) {
1911
- whereClause = `WHERE n.name = $symName AND n.filePath CONTAINS $filePath`;
1912
- queryParams = { symName: name, filePath: file_path };
1645
+ matchedNodes = findNodesByName(db, name).filter(n => n.filePath.includes(file_path));
1913
1646
  }
1914
1647
  else if (isQualified) {
1915
- whereClause = `WHERE n.id = $symName OR n.name = $symName`;
1916
- queryParams = { symName: name };
1648
+ const byId = getNode(db, toNodeId(name));
1649
+ matchedNodes = byId ? [byId] : findNodesByName(db, name, undefined, 10);
1917
1650
  }
1918
1651
  else {
1919
- whereClause = `WHERE n.name = $symName`;
1920
- queryParams = { symName: name };
1652
+ matchedNodes = findNodesByName(db, name, undefined, 10);
1921
1653
  }
1922
- symbols = await executeParameterized(repo.id, `
1923
- MATCH (n) ${whereClause}
1924
- RETURN n.id AS id, n.name AS name, labels(n) AS type, n.filePath AS filePath, n.startLine AS startLine, n.endLine AS endLine, n.content AS content
1925
- LIMIT 10
1926
- `, queryParams);
1654
+ symbols = matchedNodes.map(n => ({ id: n.id, name: n.name, type: n.label, filePath: n.filePath, startLine: n.startLine, endLine: n.endLine, content: n.content }));
1927
1655
  }
1656
+ // Symbol not found in graph — try tsgo live lookup for recently created symbols
1928
1657
  if (symbols.length === 0) {
1658
+ try {
1659
+ const tsgoFallback = params?.tsgo !== false ? await this.getTsgo(repo) : null;
1660
+ if (tsgoFallback && name) {
1661
+ // Search FTS for files that mention this symbol, then ask tsgo for the type
1662
+ const searchResults = searchFTS(db, name, 5);
1663
+ for (const sr of searchResults) {
1664
+ if (sr.filePath && /\.(ts|tsx|js|jsx|mts|mjs)$/.test(sr.filePath)) {
1665
+ // Look up the full node to get startLine/endLine
1666
+ const fullNode = getNode(db, sr.id);
1667
+ const startLine = fullNode?.startLine ?? 1;
1668
+ const absPath = path.resolve(repo.repoPath, sr.filePath);
1669
+ const hover = await tsgoFallback.getHover(absPath, startLine - 1, 0);
1670
+ if (hover) {
1671
+ // Found it via tsgo — return a minimal context with live data
1672
+ const cleaned = hover.replace(/^```\w*\n?/, '').replace(/\n?```$/, '').trim();
1673
+ return {
1674
+ status: 'found',
1675
+ symbol: {
1676
+ uid: sr.id,
1677
+ name: sr.name ?? name,
1678
+ kind: sr.label ?? 'Symbol',
1679
+ filePath: sr.filePath,
1680
+ startLine: fullNode?.startLine ?? null,
1681
+ endLine: fullNode?.endLine ?? null,
1682
+ signature: cleaned || name,
1683
+ _source: 'tsgo-live',
1684
+ },
1685
+ incoming: {},
1686
+ outgoing: {},
1687
+ processes: [],
1688
+ };
1689
+ }
1690
+ }
1691
+ }
1692
+ }
1693
+ }
1694
+ catch {
1695
+ // tsgo fallback lookup failed — return normal not-found error
1696
+ }
1929
1697
  return { error: `Symbol '${name || uid}' not found` };
1930
1698
  }
1931
1699
  // Step 2: Disambiguation
@@ -1934,74 +1702,102 @@ export class LocalBackend {
1934
1702
  status: 'ambiguous',
1935
1703
  message: `Found ${symbols.length} symbols matching '${name}'. Use uid or file_path to disambiguate.`,
1936
1704
  candidates: symbols.map((s) => ({
1937
- uid: s.id || s[0],
1938
- name: s.name || s[1],
1939
- kind: s.type || s[2],
1940
- filePath: s.filePath || s[3],
1941
- line: s.startLine || s[4],
1705
+ uid: s.id,
1706
+ name: s.name,
1707
+ kind: s.type,
1708
+ filePath: s.filePath,
1709
+ line: s.startLine,
1942
1710
  })),
1943
1711
  };
1944
1712
  }
1945
1713
  // Step 3: Build full context
1946
1714
  const sym = symbols[0];
1947
- const symId = sym.id || sym[0];
1948
- // Categorized incoming refs (F3: line numbers, E1: UNION ALL for single round-trip)
1949
- // KuzuDB bug: `r.type IN [list]` drops results. Use UNION ALL as workaround.
1715
+ const symId = sym.id;
1716
+ // Categorized incoming refs
1950
1717
  const REL_TYPES = ['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS', 'DEPENDS_ON', 'PROVIDES'];
1951
- // Incoming: use lower confidence threshold (0.5) — callers are more important to see even if fuzzy
1952
- const incomingUnion = REL_TYPES.map(t => `MATCH (caller)-[r:CodeRelation {type: '${t}'}]->(n {id: '${symId.replace(/'/g, "''")}'}) WHERE r.confidence >= 0.5 RETURN '${t}' AS relType, caller.id AS uid, caller.name AS name, caller.filePath AS filePath, labels(caller) AS kind, caller.startLine AS startLine, r.reason AS reason LIMIT 15`).join(' UNION ALL ');
1953
1718
  let incomingRows = [];
1954
- try {
1955
- incomingRows = await executeQuery(repo.id, incomingUnion);
1719
+ for (const relType of REL_TYPES) {
1720
+ const edges = queries.findIncomingEdges(db, toNodeId(symId), relType, { minConfidence: 0.5, limit: 15 });
1721
+ for (const { edge, node } of edges) {
1722
+ incomingRows.push({
1723
+ relType, uid: node.id, name: node.name, filePath: node.filePath,
1724
+ kind: node.label, startLine: node.startLine, reason: edge.reason,
1725
+ });
1726
+ }
1956
1727
  }
1957
- catch { }
1958
- // Outgoing refs lower threshold to 0.5 so dynamic imports (global tier) show up,
1959
- // but exclude generic method names that produce false positives at low confidence
1960
- const GENERIC_NAMES_EXCLUDE = ['has', 'get', 'set', 'add', 'remove', 'delete', 'close', 'stop', 'clear', 'reset', 'toString', 'valueOf', 'push', 'pop', 'entries', 'keys', 'values'];
1961
- const genericExclude = GENERIC_NAMES_EXCLUDE.map(n => `'${n}'`).join(', ');
1962
- const outgoingUnion = REL_TYPES.map(t => `MATCH (n {id: '${symId.replace(/'/g, "''")}'} )-[r:CodeRelation {type: '${t}'}]->(target) WHERE r.confidence >= 0.5 AND NOT target.name IN [${genericExclude}] RETURN '${t}' AS relType, target.id AS uid, target.name AS name, target.filePath AS filePath, labels(target) AS kind, target.startLine AS startLine, r.reason AS reason, r.callLine AS callLine LIMIT 15`).join(' UNION ALL ');
1728
+ // Enrich incoming refs with tsgo live references (supplements stale graph)
1729
+ // Agents can disable with tsgo: false for faster responses
1730
+ const tsgo = params?.tsgo !== false ? await this.getTsgo(repo) : null;
1731
+ if (tsgo && sym.filePath && /\.(ts|tsx|js|jsx|mts|mjs)$/.test(sym.filePath)) {
1732
+ try {
1733
+ const absPath = path.resolve(repo.repoPath, sym.filePath);
1734
+ const liveRefs = await tsgo.findReferences(absPath, (sym.startLine ?? 1) - 1, 0);
1735
+ // Merge live refs with graph refs — live refs may contain callers not in the graph
1736
+ for (const ref of liveRefs) {
1737
+ // Skip self-references (same file, same line as the symbol definition)
1738
+ if (ref.filePath === sym.filePath && Math.abs(ref.line - ((sym.startLine ?? 1) - 1)) <= 1)
1739
+ continue;
1740
+ // Check if this reference is already known from the graph
1741
+ const alreadyKnown = incomingRows.some(row => row.filePath === ref.filePath && Math.abs((row.startLine ?? 0) - (ref.line + 1)) <= 2);
1742
+ if (!alreadyKnown) {
1743
+ // Find the enclosing function at this reference location
1744
+ const refNode = this.findNodeAtPosition(db, ref.filePath, ref.line);
1745
+ if (refNode) {
1746
+ incomingRows.push({
1747
+ relType: 'CALLS',
1748
+ uid: '',
1749
+ name: refNode.name,
1750
+ filePath: ref.filePath,
1751
+ kind: refNode.label,
1752
+ startLine: ref.line + 1,
1753
+ reason: 'tsgo-live',
1754
+ });
1755
+ }
1756
+ }
1757
+ }
1758
+ }
1759
+ catch {
1760
+ // tsgo reference lookup failed — non-fatal, graph results still available
1761
+ }
1762
+ }
1763
+ // Outgoing refs — exclude generic method names that produce false positives at low confidence
1764
+ const GENERIC_NAMES_EXCLUDE = new Set(['has', 'get', 'set', 'add', 'remove', 'delete', 'close', 'stop', 'clear', 'reset', 'toString', 'valueOf', 'push', 'pop', 'entries', 'keys', 'values']);
1963
1765
  let outgoingRows = [];
1964
- try {
1965
- outgoingRows = await executeQuery(repo.id, outgoingUnion);
1766
+ for (const relType of REL_TYPES) {
1767
+ const edges = queries.findOutgoingEdges(db, toNodeId(symId), relType, { minConfidence: 0.5, limit: 15 });
1768
+ for (const { edge, node } of edges) {
1769
+ if (edge.confidence < 0.6 && GENERIC_NAMES_EXCLUDE.has(node.name))
1770
+ continue;
1771
+ outgoingRows.push({
1772
+ relType, uid: node.id, name: node.name, filePath: node.filePath,
1773
+ kind: node.label, startLine: node.startLine, reason: edge.reason, callLine: edge.callLine,
1774
+ });
1775
+ }
1966
1776
  }
1967
- catch { }
1968
1777
  // Process participation
1969
- let processRows = [];
1970
- try {
1971
- processRows = await executeParameterized(repo.id, `
1972
- MATCH (n {id: $symId})-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p:Process)
1973
- RETURN p.id AS pid, p.heuristicLabel AS label, r.step AS step, p.stepCount AS stepCount
1974
- `, { symId });
1975
- }
1976
- catch (e) {
1977
- logQueryError('context:process-participation', e);
1978
- }
1778
+ const procParticipation = queries.findProcessesForNode(db, toNodeId(symId));
1779
+ const processRows = procParticipation.map(r => ({
1780
+ pid: r.processId, label: r.heuristicLabel || r.label, step: r.step, stepCount: r.stepCount,
1781
+ }));
1979
1782
  // C5: Module/cluster membership
1783
+ const community = queries.findCommunityForNode(db, toNodeId(symId));
1980
1784
  let module;
1981
- try {
1982
- const clusterRows = await executeParameterized(repo.id, `
1983
- MATCH (n {id: $symId})-[:CodeRelation {type: 'MEMBER_OF'}]->(c:Community)
1984
- RETURN c.heuristicLabel AS module
1985
- LIMIT 1
1986
- `, { symId });
1987
- if (clusterRows.length > 0) {
1988
- module = String(clusterRows[0].module ?? clusterRows[0][0] ?? '');
1989
- }
1785
+ if (community) {
1786
+ module = community.label;
1990
1787
  }
1991
- catch { }
1992
- // Helper to categorize refs
1788
+ // Helper to categorize refs by relationship type
1993
1789
  const categorize = (rows) => {
1994
1790
  const cats = {};
1995
1791
  for (const row of rows) {
1996
- const relType = (row.relType || row[0] || '').toLowerCase();
1792
+ const relType = row.relType.toLowerCase();
1997
1793
  const entry = {
1998
- uid: row.uid || row[1],
1999
- name: row.name || row[2],
2000
- filePath: row.filePath || row[3],
2001
- kind: row.kind || row[4] || 'Symbol',
2002
- startLine: row.startLine ?? row[5],
2003
- reason: row.reason || row[6] || '',
2004
- callLine: row.callLine ?? row[7],
1794
+ uid: row.uid,
1795
+ name: row.name,
1796
+ filePath: row.filePath,
1797
+ kind: row.kind || 'Symbol',
1798
+ startLine: row.startLine,
1799
+ reason: row.reason || '',
1800
+ callLine: row.callLine ?? null,
2005
1801
  };
2006
1802
  if (!cats[relType])
2007
1803
  cats[relType] = [];
@@ -2010,104 +1806,47 @@ export class LocalBackend {
2010
1806
  return cats;
2011
1807
  };
2012
1808
  // Always extract signature for compact display
2013
- const rawContent = sym.content || sym[6] || '';
2014
- const signature = rawContent ? this.extractSignature(String(rawContent), sym.name || sym[1], sym.type || sym[2]) : (sym.name || sym[1]);
1809
+ const rawContent = sym.content || '';
1810
+ let signature = rawContent ? this.extractSignature(String(rawContent), sym.name, sym.type) : sym.name;
1811
+ // Enrich with tsgo for live type information (TS/JS files only)
1812
+ if (tsgo && sym.filePath && /\.(ts|tsx|js|jsx|mts|mjs)$/.test(sym.filePath)) {
1813
+ try {
1814
+ const absPath = path.resolve(repo.repoPath, sym.filePath);
1815
+ const hover = await tsgo.getHover(absPath, (sym.startLine ?? 1) - 1, 0);
1816
+ if (hover) {
1817
+ // Strip markdown code fences if present (```typescript ... ```)
1818
+ const cleaned = hover.replace(/^```\w*\n?/, '').replace(/\n?```$/, '').trim();
1819
+ if (cleaned)
1820
+ signature = cleaned;
1821
+ }
1822
+ }
1823
+ catch {
1824
+ // tsgo hover failed — non-fatal, use regex-based signature
1825
+ }
1826
+ }
2015
1827
  return {
2016
1828
  status: 'found',
2017
1829
  symbol: {
2018
- uid: sym.id || sym[0],
2019
- name: sym.name || sym[1],
2020
- kind: sym.type || sym[2],
2021
- filePath: sym.filePath || sym[3],
2022
- startLine: sym.startLine || sym[4],
2023
- endLine: sym.endLine || sym[5],
1830
+ uid: sym.id,
1831
+ name: sym.name,
1832
+ kind: sym.type,
1833
+ filePath: sym.filePath,
1834
+ startLine: sym.startLine,
1835
+ endLine: sym.endLine,
2024
1836
  signature,
2025
1837
  ...(module ? { module } : {}),
2026
1838
  ...(include_content && rawContent ? { content: rawContent } : {}),
2027
1839
  },
2028
1840
  incoming: categorize(incomingRows),
2029
1841
  outgoing: categorize(outgoingRows),
2030
- processes: processRows.map((r) => ({
2031
- id: r.pid || r[0],
2032
- name: r.label || r[1],
2033
- step_index: r.step || r[2],
2034
- step_count: r.stepCount || r[3],
1842
+ processes: processRows.map(r => ({
1843
+ id: r.pid,
1844
+ name: r.label,
1845
+ step_index: r.step,
1846
+ step_count: r.stepCount,
2035
1847
  })),
2036
1848
  };
2037
1849
  }
2038
- /** Legacy explore for backwards compatibility with resources.ts */
2039
- async explore(repo, params) {
2040
- await this.ensureInitialized(repo.id);
2041
- const { name, type } = params;
2042
- if (type === 'symbol') {
2043
- return this.context(repo, { name });
2044
- }
2045
- if (type === 'cluster') {
2046
- const clusters = await executeParameterized(repo.id, `
2047
- MATCH (c:Community)
2048
- WHERE c.label = $clusterName OR c.heuristicLabel = $clusterName
2049
- RETURN c.id AS id, c.label AS label, c.heuristicLabel AS heuristicLabel, c.cohesion AS cohesion, c.symbolCount AS symbolCount
2050
- `, { clusterName: name });
2051
- if (clusters.length === 0)
2052
- return { error: `Cluster '${name}' not found` };
2053
- const rawClusters = clusters.map((c) => ({
2054
- id: c.id || c[0], label: c.label || c[1], heuristicLabel: c.heuristicLabel || c[2],
2055
- cohesion: c.cohesion || c[3], symbolCount: c.symbolCount || c[4],
2056
- }));
2057
- let totalSymbols = 0, weightedCohesion = 0;
2058
- for (const c of rawClusters) {
2059
- const s = c.symbolCount || 0;
2060
- totalSymbols += s;
2061
- weightedCohesion += (c.cohesion || 0) * s;
2062
- }
2063
- const members = await executeParameterized(repo.id, `
2064
- MATCH (n)-[:CodeRelation {type: 'MEMBER_OF'}]->(c:Community)
2065
- WHERE c.label = $clusterName OR c.heuristicLabel = $clusterName
2066
- RETURN DISTINCT n.name AS name, labels(n) AS type, n.filePath AS filePath
2067
- LIMIT 30
2068
- `, { clusterName: name });
2069
- return {
2070
- cluster: {
2071
- id: rawClusters[0].id,
2072
- label: rawClusters[0].heuristicLabel || rawClusters[0].label,
2073
- heuristicLabel: rawClusters[0].heuristicLabel || rawClusters[0].label,
2074
- cohesion: totalSymbols > 0 ? weightedCohesion / totalSymbols : 0,
2075
- symbolCount: totalSymbols,
2076
- subCommunities: rawClusters.length,
2077
- },
2078
- members: members.map((m) => ({
2079
- name: m.name || m[0], type: m.type || m[1], filePath: m.filePath || m[2],
2080
- })),
2081
- };
2082
- }
2083
- if (type === 'process') {
2084
- const processes = await executeParameterized(repo.id, `
2085
- MATCH (p:Process)
2086
- WHERE p.label = $processName OR p.heuristicLabel = $processName
2087
- RETURN p.id AS id, p.label AS label, p.heuristicLabel AS heuristicLabel, p.processType AS processType, p.stepCount AS stepCount
2088
- LIMIT 1
2089
- `, { processName: name });
2090
- if (processes.length === 0)
2091
- return { error: `Process '${name}' not found` };
2092
- const proc = processes[0];
2093
- const procId = proc.id || proc[0];
2094
- const steps = await executeParameterized(repo.id, `
2095
- MATCH (n)-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p {id: $procId})
2096
- RETURN n.name AS name, labels(n) AS type, n.filePath AS filePath, r.step AS step
2097
- ORDER BY r.step
2098
- `, { procId });
2099
- return {
2100
- process: {
2101
- id: procId, label: proc.label || proc[1], heuristicLabel: proc.heuristicLabel || proc[2],
2102
- processType: proc.processType || proc[3], stepCount: proc.stepCount || proc[4],
2103
- },
2104
- steps: steps.map((s) => ({
2105
- step: s.step || s[3], name: s.name || s[0], type: s.type || s[1], filePath: s.filePath || s[2],
2106
- })),
2107
- };
2108
- }
2109
- return { error: 'Invalid type. Use: symbol, cluster, or process' };
2110
- }
2111
1850
  /** Detect changes: git-diff impact analysis mapping changed lines to symbols and processes */
2112
1851
  async detectChanges(repo, params) {
2113
1852
  await this.ensureInitialized(repo.id);
@@ -2147,10 +1886,13 @@ export class LocalBackend {
2147
1886
  const statOutput = execFileSync('git', statArgs, { cwd: repo.repoPath, encoding: 'utf-8' });
2148
1887
  for (const line of statOutput.trim().split('\n')) {
2149
1888
  const parts = line.split('\t');
2150
- if (parts.length >= 3) {
2151
- const added = parts[0] === '-' ? '?' : parts[0];
2152
- const removed = parts[1] === '-' ? '?' : parts[1];
2153
- diffStatMap.set(parts[2], `+${added}/-${removed}`);
1889
+ const p0 = parts[0];
1890
+ const p1 = parts[1];
1891
+ const p2 = parts[2];
1892
+ if (parts.length >= 3 && p0 && p1 && p2) {
1893
+ const added = p0 === '-' ? '?' : p0;
1894
+ const removed = p1 === '-' ? '?' : p1;
1895
+ diffStatMap.set(p2, `+${added}/-${removed}`);
2154
1896
  }
2155
1897
  }
2156
1898
  }
@@ -2163,28 +1905,16 @@ export class LocalBackend {
2163
1905
  };
2164
1906
  }
2165
1907
  // Map changed files to indexed symbols
1908
+ const db = this.getDb(repo.id);
2166
1909
  const changedSymbols = [];
2167
1910
  for (const file of changedFiles) {
2168
1911
  const normalizedFile = file.replace(/\\/g, '/');
2169
- try {
2170
- const symbols = await executeParameterized(repo.id, `
2171
- MATCH (n) WHERE n.filePath CONTAINS $filePath
2172
- RETURN n.id AS id, n.name AS name, labels(n) AS type, n.filePath AS filePath, n.startLine AS startLine
2173
- LIMIT 20
2174
- `, { filePath: normalizedFile });
2175
- for (const sym of symbols) {
2176
- changedSymbols.push({
2177
- id: sym.id || sym[0],
2178
- name: sym.name || sym[1],
2179
- type: sym.type || sym[2],
2180
- filePath: sym.filePath || sym[3],
2181
- startLine: sym.startLine ?? sym[4],
2182
- change_type: 'Modified',
2183
- });
2184
- }
2185
- }
2186
- catch (e) {
2187
- logQueryError('detect-changes:file-symbols', e);
1912
+ const nodes = findNodesByFile(db, normalizedFile);
1913
+ for (const node of nodes) {
1914
+ changedSymbols.push({
1915
+ id: node.id, name: node.name, type: node.label,
1916
+ filePath: node.filePath, startLine: node.startLine, change_type: 'Modified',
1917
+ });
2188
1918
  }
2189
1919
  }
2190
1920
  // Fix 7: Detect REAL interface changes by comparing signature text
@@ -2206,23 +1936,15 @@ export class LocalBackend {
2206
1936
  }
2207
1937
  };
2208
1938
  // Batch-fetch stored signatures from graph for changed symbols
2209
- const symIds = changedSymbols.filter(s => s.id).map(s => `'${String(s.id).replace(/'/g, "''")}'`);
2210
1939
  const storedSigs = new Map();
2211
- if (symIds.length > 0) {
2212
- try {
2213
- const sigRows = await executeParameterized(repo.id, `
2214
- MATCH (n) WHERE n.id IN [${symIds.join(', ')}]
2215
- RETURN n.id AS id, n.content AS content
2216
- `, {});
2217
- for (const row of sigRows) {
2218
- const id = String(row.id ?? row[0]);
2219
- const content = row.content ?? row[1];
2220
- if (content) {
2221
- storedSigs.set(id, this.extractSignature(String(content), '', ''));
2222
- }
1940
+ const sigNodeIds = changedSymbols.filter(s => s.id).map(s => toNodeId(String(s.id)));
1941
+ if (sigNodeIds.length > 0) {
1942
+ const sigNodes = queries.findNodesByIds(db, sigNodeIds);
1943
+ for (const node of sigNodes) {
1944
+ if (node.content) {
1945
+ storedSigs.set(node.id, this.extractSignature(String(node.content), '', ''));
2223
1946
  }
2224
1947
  }
2225
- catch { }
2226
1948
  }
2227
1949
  // Compare stored signatures against current file content
2228
1950
  for (const sym of changedSymbols) {
@@ -2241,7 +1963,8 @@ export class LocalBackend {
2241
1963
  let currentSig = null;
2242
1964
  const namePattern = new RegExp(`\\b${sym.name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`);
2243
1965
  for (let i = searchStart; i < searchEnd; i++) {
2244
- if (namePattern.test(lines[i])) {
1966
+ const line = lines[i];
1967
+ if (line && namePattern.test(line)) {
2245
1968
  currentSig = this.extractSignature(lines.slice(Math.max(0, i - 1), i + 5).join('\n'), sym.name, sym.type);
2246
1969
  break;
2247
1970
  }
@@ -2253,30 +1976,24 @@ export class LocalBackend {
2253
1976
  // Find affected processes
2254
1977
  const affectedProcesses = new Map();
2255
1978
  for (const sym of changedSymbols) {
2256
- try {
2257
- const procs = await executeParameterized(repo.id, `
2258
- MATCH (n {id: $nodeId})-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p:Process)
2259
- RETURN p.id AS pid, p.heuristicLabel AS label, p.processType AS processType, p.stepCount AS stepCount, r.step AS step
2260
- `, { nodeId: sym.id });
2261
- for (const proc of procs) {
2262
- const pid = proc.pid || proc[0];
2263
- if (!affectedProcesses.has(pid)) {
2264
- affectedProcesses.set(pid, {
2265
- id: pid,
2266
- name: proc.label || proc[1],
2267
- process_type: proc.processType || proc[2],
2268
- step_count: proc.stepCount || proc[3],
2269
- changed_steps: [],
2270
- });
2271
- }
2272
- affectedProcesses.get(pid).changed_steps.push({
2273
- symbol: sym.name,
2274
- step: proc.step || proc[4],
1979
+ if (!sym.id)
1980
+ continue;
1981
+ const procData = queries.findProcessesForNode(db, toNodeId(String(sym.id)));
1982
+ for (const proc of procData) {
1983
+ const pid = proc.processId;
1984
+ if (!affectedProcesses.has(pid)) {
1985
+ affectedProcesses.set(pid, {
1986
+ id: pid,
1987
+ name: proc.heuristicLabel || proc.label,
1988
+ process_type: proc.processType,
1989
+ step_count: proc.stepCount,
1990
+ changed_steps: [],
2275
1991
  });
2276
1992
  }
2277
- }
2278
- catch (e) {
2279
- logQueryError('detect-changes:process-lookup', e);
1993
+ affectedProcesses.get(pid).changed_steps.push({
1994
+ symbol: sym.name,
1995
+ step: proc.step,
1996
+ });
2280
1997
  }
2281
1998
  }
2282
1999
  const processCount = affectedProcesses.size;
@@ -2310,11 +2027,14 @@ export class LocalBackend {
2310
2027
  return full;
2311
2028
  };
2312
2029
  // Step 1: Find the target symbol (reuse context's lookup)
2313
- const lookupResult = await this.context(repo, {
2314
- name: params.symbol_name,
2315
- uid: params.symbol_uid,
2316
- file_path,
2317
- });
2030
+ const contextParams = {};
2031
+ if (params.symbol_name)
2032
+ contextParams.name = params.symbol_name;
2033
+ if (params.symbol_uid)
2034
+ contextParams.uid = params.symbol_uid;
2035
+ if (file_path)
2036
+ contextParams.file_path = file_path;
2037
+ const lookupResult = await this.context(repo, contextParams);
2318
2038
  if (lookupResult.status === 'ambiguous') {
2319
2039
  return lookupResult; // pass disambiguation through
2320
2040
  }
@@ -2340,9 +2060,10 @@ export class LocalBackend {
2340
2060
  const content = await fs.readFile(assertSafePath(sym.filePath), 'utf-8');
2341
2061
  const lines = content.split('\n');
2342
2062
  const lineIdx = sym.startLine - 1;
2343
- if (lineIdx >= 0 && lineIdx < lines.length && lines[lineIdx].includes(oldName)) {
2063
+ const defLine = lines[lineIdx];
2064
+ if (lineIdx >= 0 && lineIdx < lines.length && defLine && defLine.includes(oldName)) {
2344
2065
  const defRegex = new RegExp(`\\b${oldName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'g');
2345
- addEdit(sym.filePath, sym.startLine, lines[lineIdx].trim(), lines[lineIdx].replace(defRegex, new_name).trim(), 'graph');
2066
+ addEdit(sym.filePath, sym.startLine, defLine.trim(), defLine.replace(defRegex, new_name).trim(), 'graph');
2346
2067
  }
2347
2068
  }
2348
2069
  catch (e) {
@@ -2364,8 +2085,9 @@ export class LocalBackend {
2364
2085
  const content = await fs.readFile(assertSafePath(ref.filePath), 'utf-8');
2365
2086
  const lines = content.split('\n');
2366
2087
  for (let i = 0; i < lines.length; i++) {
2367
- if (lines[i].includes(oldName)) {
2368
- addEdit(ref.filePath, i + 1, lines[i].trim(), lines[i].replace(new RegExp(`\\b${oldName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'g'), new_name).trim(), 'graph');
2088
+ const refLine = lines[i];
2089
+ if (refLine && refLine.includes(oldName)) {
2090
+ addEdit(ref.filePath, i + 1, refLine.trim(), refLine.replace(new RegExp(`\\b${oldName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'g'), new_name).trim(), 'graph');
2369
2091
  graphEdits++;
2370
2092
  break; // one edit per file from graph refs
2371
2093
  }
@@ -2399,10 +2121,13 @@ export class LocalBackend {
2399
2121
  const lines = content.split('\n');
2400
2122
  const regex = new RegExp(`\\b${oldName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'g');
2401
2123
  for (let i = 0; i < lines.length; i++) {
2124
+ const searchLine = lines[i];
2125
+ if (!searchLine)
2126
+ continue;
2402
2127
  regex.lastIndex = 0;
2403
- if (regex.test(lines[i])) {
2128
+ if (regex.test(searchLine)) {
2404
2129
  regex.lastIndex = 0;
2405
- addEdit(normalizedFile, i + 1, lines[i].trim(), lines[i].replace(regex, new_name).trim(), 'text_search');
2130
+ addEdit(normalizedFile, i + 1, searchLine.trim(), searchLine.replace(regex, new_name).trim(), 'text_search');
2406
2131
  astSearchEdits++;
2407
2132
  }
2408
2133
  }
@@ -2447,144 +2172,133 @@ export class LocalBackend {
2447
2172
  }
2448
2173
  async impact(repo, params) {
2449
2174
  await this.ensureInitialized(repo.id);
2175
+ const db = this.getDb(repo.id);
2450
2176
  const { target, direction } = params;
2451
2177
  const maxDepth = params.maxDepth || 3;
2452
2178
  const rawRelTypes = params.relationTypes && params.relationTypes.length > 0
2453
2179
  ? params.relationTypes.filter(t => VALID_RELATION_TYPES.has(t))
2454
2180
  : ['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS', 'DEPENDS_ON', 'PROVIDES'];
2455
2181
  const relationTypes = rawRelTypes.length > 0 ? rawRelTypes : ['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS', 'DEPENDS_ON', 'PROVIDES'];
2456
- const includeTests = params.includeTests ?? false;
2182
+ const includeTests = params.includeTests ?? true;
2457
2183
  const minConfidence = params.minConfidence ?? 0.6;
2458
- // d=1 uses lower threshold (0.5) direct callers are critical, even dynamic imports
2459
- // d=2+ uses 0.6 to avoid noise explosion from transitive fuzzy edges
2460
- const d1Confidence = Math.min(minConfidence, 0.5);
2461
- // C6: Use OR chain instead of IN list — KuzuDB IN list silently drops results
2462
- const relTypeFilter = relationTypes.map(t => `r.type = '${t}'`).join(' OR ');
2463
- // Generic method names that produce false positives at low confidence
2464
- const IMPACT_GENERIC_NAMES = new Set(['has', 'get', 'set', 'add', 'remove', 'delete', 'close', 'stop', 'clear', 'reset', 'toString', 'valueOf', 'push', 'pop', 'entries', 'keys', 'values']);
2465
- const targets = await executeParameterized(repo.id, `
2466
- MATCH (n)
2467
- WHERE n.name = $targetName
2468
- RETURN n.id AS id, n.name AS name, labels(n) AS type, n.filePath AS filePath
2469
- LIMIT 1
2470
- `, { targetName: target });
2471
- if (targets.length === 0)
2472
- return { error: `Target '${target}' not found` };
2473
- const sym = targets[0];
2474
- const symId = sym.id || sym[0];
2475
- // Safety caps: prevent OOM / segfaults on high-fan-in graphs.
2476
- // Without caps, a hub with 50 callers explodes: d1=50, d2=1500, d3=45000,
2477
- // producing ~400KB Cypher WHERE-IN clauses that crash LadybugDB.
2478
- const MAX_IMPACTED = 500;
2479
- const MAX_FRONTIER_PER_DEPTH = 200;
2480
- const impacted = [];
2481
- const visited = new Set([symId]);
2482
- let frontier = [symId];
2483
- let truncated = false;
2484
- for (let depth = 1; depth <= maxDepth && frontier.length > 0; depth++) {
2485
- const nextFrontier = [];
2486
- // Cap frontier to prevent massive Cypher queries
2487
- const effectiveFrontier = frontier.length > MAX_FRONTIER_PER_DEPTH
2488
- ? (truncated = true, frontier.slice(0, MAX_FRONTIER_PER_DEPTH))
2489
- : frontier;
2490
- // Batch frontier nodes into a single Cypher query per depth level
2491
- const idList = effectiveFrontier.map(id => `'${id.replace(/'/g, "''")}'`).join(', ');
2492
- // Per-depth confidence: d=1 uses lower threshold to catch dynamic imports
2493
- const depthConfidence = depth === 1 ? d1Confidence : minConfidence;
2494
- const confidenceFilter = depthConfidence > 0 ? ` AND r.confidence >= ${depthConfidence}` : '';
2495
- const query = direction === 'upstream'
2496
- ? `MATCH (caller)-[r:CodeRelation]->(n) WHERE n.id IN [${idList}] AND (${relTypeFilter})${confidenceFilter} RETURN n.id AS sourceId, caller.id AS id, caller.name AS name, labels(caller) AS nodeType, caller.filePath AS filePath, r.type AS relType, r.confidence AS confidence`
2497
- : `MATCH (n)-[r:CodeRelation]->(callee) WHERE n.id IN [${idList}] AND (${relTypeFilter})${confidenceFilter} RETURN n.id AS sourceId, callee.id AS id, callee.name AS name, labels(callee) AS nodeType, callee.filePath AS filePath, r.type AS relType, r.confidence AS confidence`;
2498
- try {
2499
- const related = await executeQuery(repo.id, query);
2500
- for (const rel of related) {
2501
- const relId = rel.id || rel[1];
2502
- const filePath = rel.filePath || rel[4] || '';
2503
- if (!includeTests && isTestFilePath(filePath))
2504
- continue;
2505
- // Skip generic method names at low confidence (false positives like Map.has → type-env.has)
2506
- const relName = rel.name || rel[2] || '';
2507
- const relConf = rel.confidence || rel[6] || 1.0;
2508
- if (relConf < 0.6 && IMPACT_GENERIC_NAMES.has(relName))
2509
- continue;
2510
- if (!visited.has(relId)) {
2511
- visited.add(relId);
2512
- nextFrontier.push(relId);
2513
- impacted.push({
2514
- depth,
2515
- id: relId,
2516
- name: rel.name || rel[2],
2517
- type: rel.nodeType || rel[3] || 'Symbol',
2518
- filePath,
2519
- relationType: rel.relType || rel[5],
2520
- confidence: rel.confidence || rel[6] || 1.0,
2521
- });
2522
- // Cap total impacted count
2523
- if (impacted.length >= MAX_IMPACTED) {
2524
- truncated = true;
2525
- break;
2526
- }
2527
- }
2184
+ // Find target symboldisambiguate by file_path if provided
2185
+ let targetNodes = findNodesByName(db, target, undefined, 20);
2186
+ if (params.file_path && targetNodes.length > 1) {
2187
+ const fp = params.file_path;
2188
+ const filtered = targetNodes.filter(n => n.filePath.includes(fp));
2189
+ if (filtered.length > 0)
2190
+ targetNodes = filtered;
2191
+ }
2192
+ // If still ambiguous, prefer the symbol with the most callers (most impactful)
2193
+ let sym = targetNodes[0];
2194
+ if (targetNodes.length > 1) {
2195
+ let maxCallers = -1;
2196
+ for (const node of targetNodes) {
2197
+ const callerCount = db.prepare(`SELECT COUNT(*) as cnt FROM edges WHERE targetId = ? AND type = 'CALLS'`).get(node.id)?.cnt ?? 0;
2198
+ if (callerCount > maxCallers) {
2199
+ maxCallers = callerCount;
2200
+ sym = node;
2528
2201
  }
2529
2202
  }
2530
- catch (e) {
2531
- logQueryError('impact:depth-traversal', e);
2203
+ }
2204
+ if (!sym)
2205
+ return { error: `Target '${target}' not found` };
2206
+ const symId = sym.id;
2207
+ // RC-F: For Class/Interface/Struct nodes, also traverse through their methods.
2208
+ // Impact on a class should include impact on all its methods (via HAS_METHOD edges).
2209
+ const startIds = [symId];
2210
+ if (sym.label === 'Class' || sym.label === 'Interface' || sym.label === 'Struct') {
2211
+ const methodRows = db.prepare(`SELECT targetId FROM edges WHERE sourceId = ? AND type = 'HAS_METHOD'`).all(symId);
2212
+ for (const row of methodRows) {
2213
+ startIds.push(toNodeId(row.targetId));
2214
+ }
2215
+ }
2216
+ // Validate each edge type from user input
2217
+ const validatedEdgeTypes = relationTypes.map(rt => {
2218
+ assertEdgeType(rt);
2219
+ return rt;
2220
+ });
2221
+ // Multi-hop BFS traversal via queries.traverseImpact
2222
+ // For classes, traverse from each method start ID and merge results
2223
+ const mergedNodes = [];
2224
+ const seenIds = new Set();
2225
+ let anyTruncated = false;
2226
+ for (const sid of startIds) {
2227
+ const partial = queries.traverseImpact(db, sid, direction, {
2228
+ maxDepth,
2229
+ edgeTypes: validatedEdgeTypes,
2230
+ minConfidence,
2231
+ includeTests,
2232
+ maxImpacted: 500,
2233
+ maxFrontierPerDepth: 200,
2234
+ });
2235
+ if (partial.truncated)
2236
+ anyTruncated = true;
2237
+ for (const node of partial.nodes) {
2238
+ // Skip the class's own methods and the start node itself
2239
+ if (startIds.some(s => s === node.id))
2240
+ continue;
2241
+ if (!seenIds.has(node.id)) {
2242
+ seenIds.add(node.id);
2243
+ mergedNodes.push(node);
2244
+ }
2532
2245
  }
2533
- if (impacted.length >= MAX_IMPACTED)
2534
- break;
2535
- frontier = nextFrontier;
2536
2246
  }
2247
+ const impacted = mergedNodes;
2248
+ const truncated = anyTruncated;
2537
2249
  const grouped = {};
2538
2250
  for (const item of impacted) {
2539
- if (!grouped[item.depth])
2540
- grouped[item.depth] = [];
2541
- grouped[item.depth].push(item);
2251
+ let bucket = grouped[item.depth];
2252
+ if (!bucket) {
2253
+ bucket = [];
2254
+ grouped[item.depth] = bucket;
2255
+ }
2256
+ bucket.push(item);
2542
2257
  }
2543
- // ── Enrichment: affected processes, modules, risk ──────────────
2258
+ // Enrichment: affected processes, modules, risk
2544
2259
  const directCount = (grouped[1] || []).length;
2545
2260
  let affectedProcesses = [];
2546
2261
  let affectedModules = [];
2547
2262
  if (impacted.length > 0) {
2548
- const allIds = impacted.map(i => `'${i.id.replace(/'/g, "''")}'`).join(', ');
2549
- const d1Ids = (grouped[1] || []).map((i) => `'${i.id.replace(/'/g, "''")}'`).join(', ');
2550
- // Affected processes: which execution flows are broken and at which step
2551
- const [processRows, moduleRows, directModuleRows] = await Promise.all([
2552
- executeQuery(repo.id, `
2553
- MATCH (s)-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p:Process)
2554
- WHERE s.id IN [${allIds}]
2555
- RETURN p.heuristicLabel AS name, COUNT(DISTINCT s.id) AS hits, MIN(r.step) AS minStep, p.stepCount AS stepCount
2556
- ORDER BY hits DESC
2557
- LIMIT 20
2558
- `).catch(() => []),
2559
- executeQuery(repo.id, `
2560
- MATCH (s)-[:CodeRelation {type: 'MEMBER_OF'}]->(c:Community)
2561
- WHERE s.id IN [${allIds}]
2562
- RETURN c.heuristicLabel AS name, COUNT(DISTINCT s.id) AS hits
2563
- ORDER BY hits DESC
2564
- LIMIT 20
2565
- `).catch(() => []),
2566
- d1Ids ? executeQuery(repo.id, `
2567
- MATCH (s)-[:CodeRelation {type: 'MEMBER_OF'}]->(c:Community)
2568
- WHERE s.id IN [${d1Ids}]
2569
- RETURN DISTINCT c.heuristicLabel AS name
2570
- LIMIT 20
2571
- `).catch(() => []) : Promise.resolve([]),
2572
- ]);
2573
- affectedProcesses = processRows.map((r) => ({
2574
- name: r.name || r[0],
2575
- hits: r.hits || r[1],
2576
- broken_at_step: r.minStep ?? r[2],
2577
- step_count: r.stepCount ?? r[3],
2578
- }));
2579
- const directModuleSet = new Set(directModuleRows.map((r) => r.name || r[0]));
2580
- affectedModules = moduleRows.map((r) => {
2581
- const name = r.name || r[0];
2582
- return {
2583
- name,
2584
- hits: r.hits || r[1],
2585
- impact: directModuleSet.has(name) ? 'direct' : 'indirect',
2586
- };
2587
- });
2263
+ const allImpactedIds = impacted.map(i => i.id);
2264
+ const d1Ids = impacted.filter(i => i.depth === 1).map(i => i.id);
2265
+ // Batch process lookup for all impacted nodes
2266
+ try {
2267
+ const processHits = queries.batchFindProcesses(db, allImpactedIds);
2268
+ const procMap = new Map();
2269
+ for (const r of processHits) {
2270
+ const key = r.processId;
2271
+ const existing = procMap.get(key);
2272
+ if (!existing) {
2273
+ procMap.set(key, { name: r.heuristicLabel || r.label, hits: 1, minStep: r.step, stepCount: r.stepCount });
2274
+ }
2275
+ else {
2276
+ existing.hits++;
2277
+ if (r.step < existing.minStep)
2278
+ existing.minStep = r.step;
2279
+ }
2280
+ }
2281
+ affectedProcesses = Array.from(procMap.values())
2282
+ .sort((a, b) => b.hits - a.hits)
2283
+ .slice(0, 20)
2284
+ .map(p => ({ name: p.name, hits: p.hits, broken_at_step: p.minStep, step_count: p.stepCount }));
2285
+ }
2286
+ catch { }
2287
+ // Batch community lookup for all impacted + d1 nodes
2288
+ try {
2289
+ const communityHits = queries.batchFindCommunities(db, allImpactedIds);
2290
+ const modMap = new Map();
2291
+ for (const r of communityHits) {
2292
+ modMap.set(r.module, (modMap.get(r.module) || 0) + 1);
2293
+ }
2294
+ const d1CommunityHits = queries.batchFindCommunities(db, d1Ids);
2295
+ const directModuleSet = new Set(d1CommunityHits.map(r => r.module));
2296
+ affectedModules = Array.from(modMap.entries())
2297
+ .sort((a, b) => b[1] - a[1])
2298
+ .slice(0, 20)
2299
+ .map(([name, hits]) => ({ name, hits, impact: directModuleSet.has(name) ? 'direct' : 'indirect' }));
2300
+ }
2301
+ catch { }
2588
2302
  }
2589
2303
  // Risk scoring
2590
2304
  const processCount = affectedProcesses.length;
@@ -2602,9 +2316,9 @@ export class LocalBackend {
2602
2316
  return {
2603
2317
  target: {
2604
2318
  id: symId,
2605
- name: sym.name || sym[1],
2606
- type: sym.type || sym[2],
2607
- filePath: sym.filePath || sym[3],
2319
+ name: sym.name,
2320
+ type: sym.label,
2321
+ filePath: sym.filePath,
2608
2322
  },
2609
2323
  direction,
2610
2324
  impactedCount: impacted.length,
@@ -2625,20 +2339,13 @@ export class LocalBackend {
2625
2339
  async queryClusters(repoName, limit = 100) {
2626
2340
  const repo = await this.resolveRepo(repoName);
2627
2341
  await this.ensureInitialized(repo.id);
2342
+ const db = this.getDb(repo.id);
2628
2343
  try {
2629
2344
  const rawLimit = Math.max(limit * 5, 200);
2630
- const clusters = await executeQuery(repo.id, `
2631
- MATCH (c:Community)
2632
- RETURN c.id AS id, c.label AS label, c.heuristicLabel AS heuristicLabel, c.cohesion AS cohesion, c.symbolCount AS symbolCount
2633
- ORDER BY c.symbolCount DESC
2634
- LIMIT ${rawLimit}
2635
- `);
2636
- const rawClusters = clusters.map((c) => ({
2637
- id: c.id || c[0],
2638
- label: c.label || c[1],
2639
- heuristicLabel: c.heuristicLabel || c[2],
2640
- cohesion: c.cohesion || c[3],
2641
- symbolCount: c.symbolCount || c[4],
2345
+ const clusterNodes = queries.listCommunities(db, rawLimit);
2346
+ const rawClusters = clusterNodes.map(c => ({
2347
+ id: c.id, label: c.name, heuristicLabel: c.heuristicLabel || c.name,
2348
+ cohesion: c.cohesion, symbolCount: c.symbolCount,
2642
2349
  }));
2643
2350
  return { clusters: this.aggregateClusters(rawClusters).slice(0, limit) };
2644
2351
  }
@@ -2650,20 +2357,13 @@ export class LocalBackend {
2650
2357
  async queryProcesses(repoName, limit = 50) {
2651
2358
  const repo = await this.resolveRepo(repoName);
2652
2359
  await this.ensureInitialized(repo.id);
2360
+ const db = this.getDb(repo.id);
2653
2361
  try {
2654
- const processes = await executeQuery(repo.id, `
2655
- MATCH (p:Process)
2656
- RETURN p.id AS id, p.label AS label, p.heuristicLabel AS heuristicLabel, p.processType AS processType, p.stepCount AS stepCount
2657
- ORDER BY p.stepCount DESC
2658
- LIMIT ${limit}
2659
- `);
2362
+ const processNodes = queries.listProcesses(db, limit);
2660
2363
  return {
2661
- processes: processes.map((p) => ({
2662
- id: p.id || p[0],
2663
- label: p.label || p[1],
2664
- heuristicLabel: p.heuristicLabel || p[2],
2665
- processType: p.processType || p[3],
2666
- stepCount: p.stepCount || p[4],
2364
+ processes: processNodes.map(p => ({
2365
+ id: p.id, label: p.name, heuristicLabel: p.heuristicLabel || p.name,
2366
+ processType: p.processType, stepCount: p.stepCount,
2667
2367
  })),
2668
2368
  };
2669
2369
  }
@@ -2675,16 +2375,13 @@ export class LocalBackend {
2675
2375
  async queryClusterDetail(name, repoName) {
2676
2376
  const repo = await this.resolveRepo(repoName);
2677
2377
  await this.ensureInitialized(repo.id);
2678
- const clusters = await executeParameterized(repo.id, `
2679
- MATCH (c:Community)
2680
- WHERE c.label = $clusterName OR c.heuristicLabel = $clusterName
2681
- RETURN c.id AS id, c.label AS label, c.heuristicLabel AS heuristicLabel, c.cohesion AS cohesion, c.symbolCount AS symbolCount
2682
- `, { clusterName: name });
2683
- if (clusters.length === 0)
2378
+ const db = this.getDb(repo.id);
2379
+ const communityNodes = queries.findCommunitiesByName(db, name);
2380
+ if (communityNodes.length === 0)
2684
2381
  return { error: `Cluster '${name}' not found` };
2685
- const rawClusters = clusters.map((c) => ({
2686
- id: c.id || c[0], label: c.label || c[1], heuristicLabel: c.heuristicLabel || c[2],
2687
- cohesion: c.cohesion || c[3], symbolCount: c.symbolCount || c[4],
2382
+ const rawClusters = communityNodes.map(c => ({
2383
+ id: c.id, label: c.name, heuristicLabel: c.heuristicLabel || c.name,
2384
+ cohesion: c.cohesion, symbolCount: c.symbolCount,
2688
2385
  }));
2689
2386
  let totalSymbols = 0, weightedCohesion = 0;
2690
2387
  for (const c of rawClusters) {
@@ -2692,23 +2389,21 @@ export class LocalBackend {
2692
2389
  totalSymbols += s;
2693
2390
  weightedCohesion += (c.cohesion || 0) * s;
2694
2391
  }
2695
- const members = await executeParameterized(repo.id, `
2696
- MATCH (n)-[:CodeRelation {type: 'MEMBER_OF'}]->(c:Community)
2697
- WHERE c.label = $clusterName OR c.heuristicLabel = $clusterName
2698
- RETURN DISTINCT n.name AS name, labels(n) AS type, n.filePath AS filePath
2699
- LIMIT 30
2700
- `, { clusterName: name });
2392
+ const memberNodes = queries.getCommunityMembers(db, name, 30);
2393
+ const firstCluster = rawClusters[0];
2394
+ if (!firstCluster)
2395
+ return { error: `Cluster '${name}' not found` };
2701
2396
  return {
2702
2397
  cluster: {
2703
- id: rawClusters[0].id,
2704
- label: rawClusters[0].heuristicLabel || rawClusters[0].label,
2705
- heuristicLabel: rawClusters[0].heuristicLabel || rawClusters[0].label,
2398
+ id: firstCluster.id,
2399
+ label: firstCluster.heuristicLabel || firstCluster.label,
2400
+ heuristicLabel: firstCluster.heuristicLabel || firstCluster.label,
2706
2401
  cohesion: totalSymbols > 0 ? weightedCohesion / totalSymbols : 0,
2707
2402
  symbolCount: totalSymbols,
2708
2403
  subCommunities: rawClusters.length,
2709
2404
  },
2710
- members: members.map((m) => ({
2711
- name: m.name || m[0], type: m.type || m[1], filePath: m.filePath || m[2],
2405
+ members: memberNodes.map(m => ({
2406
+ name: m.name, type: m.label, filePath: m.filePath,
2712
2407
  })),
2713
2408
  };
2714
2409
  }
@@ -2716,28 +2411,19 @@ export class LocalBackend {
2716
2411
  async queryProcessDetail(name, repoName) {
2717
2412
  const repo = await this.resolveRepo(repoName);
2718
2413
  await this.ensureInitialized(repo.id);
2719
- const processes = await executeParameterized(repo.id, `
2720
- MATCH (p:Process)
2721
- WHERE p.label = $processName OR p.heuristicLabel = $processName
2722
- RETURN p.id AS id, p.label AS label, p.heuristicLabel AS heuristicLabel, p.processType AS processType, p.stepCount AS stepCount
2723
- LIMIT 1
2724
- `, { processName: name });
2725
- if (processes.length === 0)
2414
+ const db = this.getDb(repo.id);
2415
+ const processNodes = queries.findProcessesByName(db, name);
2416
+ const proc = processNodes[0];
2417
+ if (!proc)
2726
2418
  return { error: `Process '${name}' not found` };
2727
- const proc = processes[0];
2728
- const procId = proc.id || proc[0];
2729
- const steps = await executeParameterized(repo.id, `
2730
- MATCH (n)-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p {id: $procId})
2731
- RETURN n.name AS name, labels(n) AS type, n.filePath AS filePath, r.step AS step
2732
- ORDER BY r.step
2733
- `, { procId });
2419
+ const steps = queries.getProcessSteps(db, proc.id);
2734
2420
  return {
2735
2421
  process: {
2736
- id: procId, label: proc.label || proc[1], heuristicLabel: proc.heuristicLabel || proc[2],
2737
- processType: proc.processType || proc[3], stepCount: proc.stepCount || proc[4],
2422
+ id: proc.id, label: proc.name, heuristicLabel: proc.heuristicLabel || proc.name,
2423
+ processType: proc.processType, stepCount: proc.stepCount,
2738
2424
  },
2739
- steps: steps.map((s) => ({
2740
- step: s.step || s[3], name: s.name || s[0], type: s.type || s[1], filePath: s.filePath || s[2],
2425
+ steps: steps.map(s => ({
2426
+ step: s.step, name: s.node.name, type: s.node.label, filePath: s.node.filePath,
2741
2427
  })),
2742
2428
  };
2743
2429
  }
@@ -2745,8 +2431,13 @@ export class LocalBackend {
2745
2431
  for (const watcher of this.watchers.values())
2746
2432
  watcher.stop();
2747
2433
  this.watchers.clear();
2748
- stopTsgoService();
2749
- await closeLbug(); // close all connections
2434
+ // Stop all per-repo tsgo LSP services
2435
+ for (const service of this.tsgoServices.values()) {
2436
+ service.stop();
2437
+ }
2438
+ this.tsgoServices.clear();
2439
+ stopTsgoService(); // also stop the global singleton
2440
+ closeDb(); // close all SQLite connections
2750
2441
  // Note: we intentionally do NOT call disposeEmbedder() here.
2751
2442
  // ONNX Runtime's native cleanup segfaults on macOS and some Linux configs,
2752
2443
  // and importing the embedder module on Node v24+ crashes if onnxruntime
@@ -2754,6 +2445,5 @@ export class LocalBackend {
2754
2445
  // immediately after disconnect(), the OS reclaims everything. See #38, #89.
2755
2446
  this.repos.clear();
2756
2447
  this.contextCache.clear();
2757
- this.initializedRepos.clear();
2758
2448
  }
2759
2449
  }