@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.
- package/dist/cli/ai-context.js +1 -1
- package/dist/cli/analyze.d.ts +1 -0
- package/dist/cli/analyze.js +73 -82
- package/dist/cli/augment.js +0 -2
- package/dist/cli/eval-server.d.ts +2 -2
- package/dist/cli/eval-server.js +6 -6
- package/dist/cli/index.js +6 -10
- package/dist/cli/mcp.d.ts +1 -3
- package/dist/cli/mcp.js +3 -3
- package/dist/cli/refresh.d.ts +2 -2
- package/dist/cli/refresh.js +24 -29
- package/dist/cli/status.js +4 -13
- package/dist/cli/tool.d.ts +5 -4
- package/dist/cli/tool.js +8 -10
- package/dist/config/ignore-service.js +14 -34
- package/dist/core/augmentation/engine.js +53 -83
- package/dist/core/db/adapter.d.ts +99 -0
- package/dist/core/db/adapter.js +402 -0
- package/dist/core/db/graph-loader.d.ts +27 -0
- package/dist/core/db/graph-loader.js +148 -0
- package/dist/core/db/queries.d.ts +160 -0
- package/dist/core/db/queries.js +441 -0
- package/dist/core/db/schema.d.ts +108 -0
- package/dist/core/db/schema.js +136 -0
- package/dist/core/embeddings/embedder.d.ts +21 -12
- package/dist/core/embeddings/embedder.js +104 -50
- package/dist/core/embeddings/embedding-pipeline.d.ts +48 -22
- package/dist/core/embeddings/embedding-pipeline.js +220 -262
- package/dist/core/embeddings/text-generator.js +4 -19
- package/dist/core/embeddings/types.d.ts +1 -1
- package/dist/core/graph/graph.d.ts +1 -1
- package/dist/core/graph/graph.js +1 -0
- package/dist/core/graph/types.d.ts +11 -9
- package/dist/core/graph/types.js +4 -1
- package/dist/core/incremental/refresh.d.ts +46 -0
- package/dist/core/incremental/refresh.js +464 -0
- package/dist/core/incremental/types.d.ts +2 -1
- package/dist/core/incremental/types.js +42 -44
- package/dist/core/ingestion/ast-cache.js +1 -0
- package/dist/core/ingestion/call-processor.d.ts +15 -3
- package/dist/core/ingestion/call-processor.js +448 -60
- package/dist/core/ingestion/cluster-enricher.d.ts +1 -1
- package/dist/core/ingestion/cluster-enricher.js +2 -0
- package/dist/core/ingestion/community-processor.d.ts +1 -1
- package/dist/core/ingestion/community-processor.js +8 -3
- package/dist/core/ingestion/export-detection.d.ts +1 -1
- package/dist/core/ingestion/export-detection.js +1 -1
- package/dist/core/ingestion/filesystem-walker.js +1 -1
- package/dist/core/ingestion/heritage-processor.d.ts +2 -2
- package/dist/core/ingestion/heritage-processor.js +22 -11
- package/dist/core/ingestion/import-processor.d.ts +2 -2
- package/dist/core/ingestion/import-processor.js +24 -9
- package/dist/core/ingestion/language-config.js +7 -4
- package/dist/core/ingestion/mro-processor.d.ts +1 -1
- package/dist/core/ingestion/mro-processor.js +23 -11
- package/dist/core/ingestion/named-binding-extraction.js +5 -5
- package/dist/core/ingestion/parsing-processor.d.ts +4 -4
- package/dist/core/ingestion/parsing-processor.js +26 -18
- package/dist/core/ingestion/pipeline.d.ts +4 -2
- package/dist/core/ingestion/pipeline.js +50 -20
- package/dist/core/ingestion/process-processor.d.ts +2 -2
- package/dist/core/ingestion/process-processor.js +28 -14
- package/dist/core/ingestion/resolution-context.d.ts +1 -1
- package/dist/core/ingestion/resolution-context.js +14 -4
- package/dist/core/ingestion/resolvers/csharp.js +4 -3
- package/dist/core/ingestion/resolvers/go.js +3 -1
- package/dist/core/ingestion/resolvers/jvm.js +13 -4
- package/dist/core/ingestion/resolvers/standard.js +2 -2
- package/dist/core/ingestion/resolvers/utils.js +6 -2
- package/dist/core/ingestion/route-stitcher.d.ts +15 -0
- package/dist/core/ingestion/route-stitcher.js +92 -0
- package/dist/core/ingestion/structure-processor.d.ts +1 -1
- package/dist/core/ingestion/structure-processor.js +3 -2
- package/dist/core/ingestion/symbol-table.d.ts +2 -0
- package/dist/core/ingestion/symbol-table.js +5 -1
- package/dist/core/ingestion/tree-sitter-queries.d.ts +2 -2
- package/dist/core/ingestion/tree-sitter-queries.js +177 -0
- package/dist/core/ingestion/type-env.js +20 -0
- package/dist/core/ingestion/type-extractors/csharp.js +4 -3
- package/dist/core/ingestion/type-extractors/go.js +23 -12
- package/dist/core/ingestion/type-extractors/php.js +18 -10
- package/dist/core/ingestion/type-extractors/ruby.js +15 -3
- package/dist/core/ingestion/type-extractors/rust.js +3 -2
- package/dist/core/ingestion/type-extractors/shared.js +3 -2
- package/dist/core/ingestion/type-extractors/typescript.js +11 -5
- package/dist/core/ingestion/utils.d.ts +27 -4
- package/dist/core/ingestion/utils.js +145 -100
- package/dist/core/ingestion/workers/parse-worker.d.ts +1 -0
- package/dist/core/ingestion/workers/parse-worker.js +97 -29
- package/dist/core/ingestion/workers/worker-pool.js +3 -0
- package/dist/core/search/bm25-index.d.ts +15 -8
- package/dist/core/search/bm25-index.js +48 -98
- package/dist/core/search/hybrid-search.d.ts +9 -3
- package/dist/core/search/hybrid-search.js +30 -25
- package/dist/core/search/reranker.js +9 -7
- package/dist/core/search/types.d.ts +0 -4
- package/dist/core/semantic/tsgo-service.d.ts +5 -1
- package/dist/core/semantic/tsgo-service.js +161 -66
- package/dist/lib/tsgo-test.d.ts +2 -0
- package/dist/lib/tsgo-test.js +6 -0
- package/dist/lib/type-utils.d.ts +25 -0
- package/dist/lib/type-utils.js +22 -0
- package/dist/lib/utils.d.ts +3 -2
- package/dist/lib/utils.js +3 -2
- package/dist/mcp/compatible-stdio-transport.js +1 -1
- package/dist/mcp/local/local-backend.d.ts +29 -56
- package/dist/mcp/local/local-backend.js +808 -1118
- package/dist/mcp/resources.js +35 -25
- package/dist/mcp/server.d.ts +1 -1
- package/dist/mcp/server.js +5 -5
- package/dist/mcp/tools.js +24 -25
- package/dist/storage/repo-manager.d.ts +2 -12
- package/dist/storage/repo-manager.js +1 -47
- package/dist/types/pipeline.d.ts +8 -5
- package/dist/types/pipeline.js +5 -0
- package/package.json +18 -11
- package/dist/cli/serve.d.ts +0 -5
- package/dist/cli/serve.js +0 -8
- package/dist/core/incremental/child-process.d.ts +0 -8
- package/dist/core/incremental/child-process.js +0 -649
- package/dist/core/incremental/refresh-coordinator.d.ts +0 -32
- package/dist/core/incremental/refresh-coordinator.js +0 -147
- package/dist/core/lbug/csv-generator.d.ts +0 -28
- package/dist/core/lbug/csv-generator.js +0 -355
- package/dist/core/lbug/lbug-adapter.d.ts +0 -96
- package/dist/core/lbug/lbug-adapter.js +0 -753
- package/dist/core/lbug/schema.d.ts +0 -46
- package/dist/core/lbug/schema.js +0 -402
- package/dist/mcp/core/embedder.d.ts +0 -24
- package/dist/mcp/core/embedder.js +0 -168
- package/dist/mcp/core/lbug-adapter.d.ts +0 -29
- package/dist/mcp/core/lbug-adapter.js +0 -330
- package/dist/server/api.d.ts +0 -5
- package/dist/server/api.js +0 -340
- package/dist/server/mcp-http.d.ts +0 -7
- package/dist/server/mcp-http.js +0 -95
- 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
|
-
*
|
|
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
|
|
11
|
-
import {
|
|
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 {
|
|
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
|
-
|
|
23
|
-
|
|
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
|
|
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
|
|
46
|
-
export const
|
|
47
|
-
/** Check if a
|
|
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
|
|
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
|
-
|
|
181
|
-
|
|
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
|
-
*
|
|
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 refresh — delegates to the shared refreshFiles module.
|
|
212
199
|
*/
|
|
213
200
|
async inProcessRefresh(repo, dirtyFiles) {
|
|
214
|
-
const
|
|
215
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
|
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
|
|
327
|
+
// Lazy DB Init
|
|
740
328
|
async ensureInitialized(repoId) {
|
|
741
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
939
|
-
|
|
549
|
+
const candidate = toShow[j];
|
|
550
|
+
if (candidate && this.sharedPrefixLength(current.syms, candidate.syms) >= 3)
|
|
551
|
+
group.push(candidate);
|
|
940
552
|
}
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
const
|
|
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
|
|
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(`## ${
|
|
966
|
-
for (const sym of
|
|
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
|
|
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
|
-
|
|
813
|
+
warnings.push(`⚠ index ${info.commitsBehind} commit${info.commitsBehind > 1 ? 's' : ''} behind HEAD`);
|
|
1156
814
|
}
|
|
1157
815
|
}
|
|
1158
816
|
catch { }
|
|
1159
|
-
|
|
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
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
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 =
|
|
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
|
|
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
|
|
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
|
|
1351
|
-
const
|
|
1352
|
-
|
|
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
|
|
1357
|
-
snippetMap.set(
|
|
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
|
|
1095
|
+
const queryDb = this.getDb(repo.id);
|
|
1096
|
+
const nodeIds = symbolsWithNodeId.map(s => toNodeId(String(s.nodeId)));
|
|
1428
1097
|
// Batch process lookup
|
|
1429
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
const
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
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 =
|
|
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 =
|
|
1135
|
+
const nid = row.nodeId;
|
|
1499
1136
|
if (!clusterByNode.has(nid)) {
|
|
1500
|
-
clusterByNode.set(nid, { cohesion:
|
|
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 =
|
|
1161
|
+
const pid = row.processId;
|
|
1525
1162
|
if (!processMap.has(pid)) {
|
|
1526
1163
|
processMap.set(pid, {
|
|
1527
|
-
id: pid, label:
|
|
1528
|
-
heuristicLabel:
|
|
1529
|
-
processType:
|
|
1530
|
-
stepCount:
|
|
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:
|
|
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
|
|
1595
|
-
const allStepsRows =
|
|
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 =
|
|
1234
|
+
const pid = row.processId;
|
|
1604
1235
|
if (!allStepsMap.has(pid))
|
|
1605
1236
|
allStepsMap.set(pid, []);
|
|
1606
|
-
const nodeId =
|
|
1237
|
+
const nodeId = row.nodeId;
|
|
1607
1238
|
const sig = signatureMap.get(nodeId);
|
|
1608
1239
|
allStepsMap.get(pid).push({
|
|
1609
1240
|
nodeId,
|
|
1610
|
-
name:
|
|
1611
|
-
type:
|
|
1612
|
-
filePath:
|
|
1613
|
-
startLine: row.startLine
|
|
1614
|
-
step_index:
|
|
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 ||
|
|
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
|
|
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
|
-
|
|
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
|
|
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 =
|
|
1669
|
-
const seen = new Set(results.map(r => r.
|
|
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.
|
|
1303
|
+
if (!seen.has(r.id)) {
|
|
1672
1304
|
results.push(r);
|
|
1673
|
-
seen.add(r.
|
|
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
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
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
|
|
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
|
|
1701
|
-
const
|
|
1702
|
-
|
|
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
|
|
1350
|
+
const { embedQuery } = await import('../../core/embeddings/embedder.js');
|
|
1706
1351
|
const queryVec = await embedQuery(query);
|
|
1707
|
-
|
|
1708
|
-
const
|
|
1709
|
-
|
|
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
|
|
1724
|
-
const
|
|
1356
|
+
// Batch metadata fetch
|
|
1357
|
+
const vecNodeIds = vecResults.map(r => r.nodeId);
|
|
1725
1358
|
const distanceMap = new Map();
|
|
1726
|
-
for (const r of
|
|
1727
|
-
distanceMap.set(
|
|
1728
|
-
}
|
|
1729
|
-
const
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
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
|
|
1378
|
+
async executeSql(repoName, query) {
|
|
1757
1379
|
const repo = await this.resolveRepo(repoName);
|
|
1758
|
-
return this.
|
|
1380
|
+
return this.sqlQuery(repo, { query });
|
|
1759
1381
|
}
|
|
1760
|
-
async
|
|
1382
|
+
async sqlQuery(repo, params) {
|
|
1761
1383
|
await this.ensureInitialized(repo.id);
|
|
1762
|
-
|
|
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
|
-
|
|
1767
|
-
|
|
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 =
|
|
1391
|
+
const result = rawQuery(db, params.query);
|
|
1771
1392
|
return result;
|
|
1772
1393
|
}
|
|
1773
1394
|
catch (err) {
|
|
1774
|
-
return { error: err.message
|
|
1395
|
+
return { error: err instanceof Error ? err.message : 'Query failed' };
|
|
1775
1396
|
}
|
|
1776
1397
|
}
|
|
1777
|
-
/** Format raw
|
|
1778
|
-
|
|
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
|
-
|
|
1467
|
+
const db = this.getDb(repo.id);
|
|
1847
1468
|
const rawLimit = Math.max(limit * 5, 200);
|
|
1848
|
-
const
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
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
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
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
|
-
/**
|
|
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 "
|
|
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
|
-
|
|
1900
|
-
symbols =
|
|
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
|
|
1909
|
-
let queryParams;
|
|
1643
|
+
let matchedNodes;
|
|
1910
1644
|
if (file_path) {
|
|
1911
|
-
|
|
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
|
-
|
|
1916
|
-
|
|
1648
|
+
const byId = getNode(db, toNodeId(name));
|
|
1649
|
+
matchedNodes = byId ? [byId] : findNodesByName(db, name, undefined, 10);
|
|
1917
1650
|
}
|
|
1918
1651
|
else {
|
|
1919
|
-
|
|
1920
|
-
queryParams = { symName: name };
|
|
1652
|
+
matchedNodes = findNodesByName(db, name, undefined, 10);
|
|
1921
1653
|
}
|
|
1922
|
-
symbols =
|
|
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
|
|
1938
|
-
name: s.name
|
|
1939
|
-
kind: s.type
|
|
1940
|
-
filePath: s.filePath
|
|
1941
|
-
line: s.startLine
|
|
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
|
|
1948
|
-
// Categorized incoming refs
|
|
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
|
-
|
|
1955
|
-
|
|
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
|
-
|
|
1958
|
-
//
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
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
|
-
|
|
1965
|
-
|
|
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
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
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
|
-
|
|
1982
|
-
|
|
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
|
-
|
|
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 =
|
|
1792
|
+
const relType = row.relType.toLowerCase();
|
|
1997
1793
|
const entry = {
|
|
1998
|
-
uid: row.uid
|
|
1999
|
-
name: row.name
|
|
2000
|
-
filePath: row.filePath
|
|
2001
|
-
kind: row.kind ||
|
|
2002
|
-
startLine: row.startLine
|
|
2003
|
-
reason: row.reason ||
|
|
2004
|
-
callLine: row.callLine ??
|
|
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 ||
|
|
2014
|
-
|
|
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
|
|
2019
|
-
name: sym.name
|
|
2020
|
-
kind: sym.type
|
|
2021
|
-
filePath: sym.filePath
|
|
2022
|
-
startLine: sym.startLine
|
|
2023
|
-
endLine: sym.endLine
|
|
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(
|
|
2031
|
-
id: r.pid
|
|
2032
|
-
name: r.label
|
|
2033
|
-
step_index: r.step
|
|
2034
|
-
step_count: r.stepCount
|
|
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
|
-
|
|
2151
|
-
|
|
2152
|
-
|
|
2153
|
-
|
|
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
|
-
|
|
2170
|
-
|
|
2171
|
-
|
|
2172
|
-
|
|
2173
|
-
|
|
2174
|
-
|
|
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
|
-
|
|
2212
|
-
|
|
2213
|
-
|
|
2214
|
-
|
|
2215
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2257
|
-
|
|
2258
|
-
|
|
2259
|
-
|
|
2260
|
-
|
|
2261
|
-
|
|
2262
|
-
|
|
2263
|
-
|
|
2264
|
-
|
|
2265
|
-
|
|
2266
|
-
|
|
2267
|
-
|
|
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
|
-
|
|
2279
|
-
|
|
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
|
|
2314
|
-
|
|
2315
|
-
|
|
2316
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
2368
|
-
|
|
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(
|
|
2128
|
+
if (regex.test(searchLine)) {
|
|
2404
2129
|
regex.lastIndex = 0;
|
|
2405
|
-
addEdit(normalizedFile, i + 1,
|
|
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 ??
|
|
2182
|
+
const includeTests = params.includeTests ?? true;
|
|
2457
2183
|
const minConfidence = params.minConfidence ?? 0.6;
|
|
2458
|
-
//
|
|
2459
|
-
|
|
2460
|
-
|
|
2461
|
-
|
|
2462
|
-
|
|
2463
|
-
|
|
2464
|
-
|
|
2465
|
-
|
|
2466
|
-
|
|
2467
|
-
|
|
2468
|
-
|
|
2469
|
-
|
|
2470
|
-
|
|
2471
|
-
|
|
2472
|
-
|
|
2473
|
-
|
|
2474
|
-
|
|
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 symbol — disambiguate 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
|
-
|
|
2531
|
-
|
|
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
|
-
|
|
2540
|
-
|
|
2541
|
-
|
|
2251
|
+
let bucket = grouped[item.depth];
|
|
2252
|
+
if (!bucket) {
|
|
2253
|
+
bucket = [];
|
|
2254
|
+
grouped[item.depth] = bucket;
|
|
2255
|
+
}
|
|
2256
|
+
bucket.push(item);
|
|
2542
2257
|
}
|
|
2543
|
-
//
|
|
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
|
|
2549
|
-
const d1Ids = (
|
|
2550
|
-
//
|
|
2551
|
-
|
|
2552
|
-
|
|
2553
|
-
|
|
2554
|
-
|
|
2555
|
-
|
|
2556
|
-
|
|
2557
|
-
|
|
2558
|
-
|
|
2559
|
-
|
|
2560
|
-
|
|
2561
|
-
|
|
2562
|
-
|
|
2563
|
-
|
|
2564
|
-
|
|
2565
|
-
|
|
2566
|
-
|
|
2567
|
-
|
|
2568
|
-
|
|
2569
|
-
|
|
2570
|
-
|
|
2571
|
-
|
|
2572
|
-
|
|
2573
|
-
|
|
2574
|
-
|
|
2575
|
-
|
|
2576
|
-
|
|
2577
|
-
|
|
2578
|
-
|
|
2579
|
-
|
|
2580
|
-
|
|
2581
|
-
|
|
2582
|
-
|
|
2583
|
-
|
|
2584
|
-
hits:
|
|
2585
|
-
|
|
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
|
|
2606
|
-
type: sym.
|
|
2607
|
-
filePath: sym.filePath
|
|
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
|
|
2631
|
-
|
|
2632
|
-
|
|
2633
|
-
|
|
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
|
|
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:
|
|
2662
|
-
id: p.id || p
|
|
2663
|
-
|
|
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
|
|
2679
|
-
|
|
2680
|
-
|
|
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 =
|
|
2686
|
-
id: c.id
|
|
2687
|
-
cohesion: c.cohesion
|
|
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
|
|
2696
|
-
|
|
2697
|
-
|
|
2698
|
-
|
|
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:
|
|
2704
|
-
label:
|
|
2705
|
-
heuristicLabel:
|
|
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:
|
|
2711
|
-
name: m.name
|
|
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
|
|
2720
|
-
|
|
2721
|
-
|
|
2722
|
-
|
|
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
|
|
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:
|
|
2737
|
-
processType: proc.processType
|
|
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(
|
|
2740
|
-
step: s.step
|
|
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
|
-
|
|
2749
|
-
|
|
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
|
}
|