kotadb 2.2.0-next.20260204230500 → 2.2.0-next.20260205005118

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.
@@ -1,14 +1,17 @@
1
1
  /**
2
2
  * Database query layer for indexed data
3
- *
3
+ *
4
4
  * Local-only implementation using SQLite for all operations.
5
- *
5
+ *
6
6
  * @module @api/queries
7
7
  */
8
8
 
9
9
  import { randomUUID } from "node:crypto";
10
10
  import type { Reference } from "@indexer/reference-extractor";
11
- import type { Symbol as ExtractedSymbol, SymbolKind } from "@indexer/symbol-extractor";
11
+ import type {
12
+ Symbol as ExtractedSymbol,
13
+ SymbolKind,
14
+ } from "@indexer/symbol-extractor";
12
15
  import { createLogger } from "@logging/logger.js";
13
16
  import type { IndexRequest, IndexedFile } from "@shared/types";
14
17
  import { detectLanguage } from "@shared/language-utils";
@@ -18,253 +21,310 @@ import { parseTsConfig, type PathMappings } from "@indexer/path-resolver.js";
18
21
 
19
22
  const logger = createLogger({ module: "api-queries" });
20
23
 
21
-
22
24
  /**
23
25
  * Normalize file path to consistent format for database storage.
24
- *
26
+ *
25
27
  * Rules:
26
28
  * - No leading slashes
27
29
  * - Forward slashes only (replace backslashes)
28
30
  * - No ./ prefix
29
31
  * - Consistent relative-to-repo-root format
30
- *
32
+ *
31
33
  * @param filePath - Absolute or relative file path
32
34
  * @returns Normalized relative path
33
35
  */
34
36
  function normalizePath(filePath: string): string {
35
- let normalized = filePath;
36
-
37
- // Replace backslashes with forward slashes
38
- normalized = normalized.replace(/\\/g, '/');
39
-
40
- // Remove leading slash if present
41
- if (normalized.startsWith('/')) {
42
- normalized = normalized.slice(1);
43
- }
44
-
45
- // Remove ./ prefix
46
- if (normalized.startsWith('./')) {
47
- normalized = normalized.slice(2);
48
- }
49
-
50
- return normalized;
37
+ let normalized = filePath;
38
+
39
+ // Replace backslashes with forward slashes
40
+ normalized = normalized.replace(/\\/g, "/");
41
+
42
+ // Remove leading slash if present
43
+ if (normalized.startsWith("/")) {
44
+ normalized = normalized.slice(1);
45
+ }
46
+
47
+ // Remove ./ prefix
48
+ if (normalized.startsWith("./")) {
49
+ normalized = normalized.slice(2);
50
+ }
51
+
52
+ return normalized;
51
53
  }
52
54
 
53
55
  export interface SearchOptions {
54
- repositoryId?: string;
55
- projectId?: string;
56
- limit?: number;
56
+ repositoryId?: string;
57
+ projectId?: string;
58
+ limit?: number;
57
59
  }
58
60
 
61
+ /**
62
+ * Represents a single matching line with surrounding context
63
+ */
64
+ export interface SnippetMatch {
65
+ line: number; // 1-indexed line number
66
+ content: string; // The matching line
67
+ context_before: string[]; // N lines before
68
+ context_after: string[]; // N lines after
69
+ }
70
+
71
+ /**
72
+ * Extract line-based snippets from file content showing matches with context.
73
+ *
74
+ * Algorithm:
75
+ * 1. Split content into lines
76
+ * 2. Find all lines matching query (case-insensitive substring match)
77
+ * 3. For each match, extract contextLines before/after
78
+ * 4. Return array of SnippetMatch objects (one per matching line)
79
+ *
80
+ * NOTE: Does NOT merge overlapping contexts - each match gets separate snippet.
81
+ *
82
+ * @param content - Full file content
83
+ * @param query - Search query term
84
+ * @param contextLines - Lines of context before/after (default: 3, max: 10)
85
+ * @returns Array of snippet matches with line numbers and context
86
+ */
87
+ export function extractLineSnippets(
88
+ content: string,
89
+ query: string,
90
+ contextLines: number,
91
+ ): SnippetMatch[] {
92
+ if (!content || !query) return [];
93
+
94
+ const lines = content.split("\n");
95
+ const lowerQuery = query.toLowerCase();
96
+ const matches: SnippetMatch[] = [];
97
+
98
+ lines.forEach((line, index) => {
99
+ if (line.toLowerCase().includes(lowerQuery)) {
100
+ const lineNumber = index + 1; // 1-indexed
101
+ const start = Math.max(0, index - contextLines);
102
+ const end = Math.min(lines.length, index + contextLines + 1);
103
+
104
+ matches.push({
105
+ line: lineNumber,
106
+ content: line,
107
+ context_before: lines.slice(start, index),
108
+ context_after: lines.slice(index + 1, end),
109
+ });
110
+ }
111
+ });
112
+
113
+ return matches;
114
+ }
59
115
  // ============================================================================
60
116
  // Internal implementations that accept a database parameter
61
117
  // These are used by both the new API and backward-compatible aliases
62
118
  // ============================================================================
63
119
 
64
120
  function saveIndexedFilesInternal(
65
- db: KotaDatabase,
66
- files: IndexedFile[],
67
- repositoryId: string,
121
+ db: KotaDatabase,
122
+ files: IndexedFile[],
123
+ repositoryId: string,
68
124
  ): number {
69
- if (files.length === 0) {
70
- return 0;
71
- }
125
+ if (files.length === 0) {
126
+ return 0;
127
+ }
72
128
 
73
- let count = 0;
129
+ let count = 0;
74
130
 
75
- db.transaction(() => {
76
- const stmt = db.prepare(`
131
+ db.transaction(() => {
132
+ const stmt = db.prepare(`
77
133
  INSERT OR REPLACE INTO indexed_files (
78
134
  id, repository_id, path, content, language,
79
135
  size_bytes, content_hash, indexed_at, metadata
80
136
  ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
81
137
  `);
82
138
 
83
- for (const file of files) {
84
- const id = randomUUID();
85
- const language = detectLanguage(file.path);
86
- const sizeBytes = new TextEncoder().encode(file.content).length;
87
- const indexedAt = file.indexedAt ? file.indexedAt.toISOString() : new Date().toISOString();
88
- const metadata = JSON.stringify({ dependencies: file.dependencies || [] });
89
-
90
- stmt.run([
91
- id,
92
- repositoryId,
93
- file.path,
94
- file.content,
95
- language,
96
- sizeBytes,
97
- null, // content_hash
98
- indexedAt,
99
- metadata
100
- ]);
101
- count++;
102
- }
103
- });
104
-
105
- logger.info("Saved indexed files to SQLite", { count, repositoryId });
106
- return count;
139
+ for (const file of files) {
140
+ const id = randomUUID();
141
+ const language = detectLanguage(file.path);
142
+ const sizeBytes = new TextEncoder().encode(file.content).length;
143
+ const indexedAt = file.indexedAt
144
+ ? file.indexedAt.toISOString()
145
+ : new Date().toISOString();
146
+ const metadata = JSON.stringify({
147
+ dependencies: file.dependencies || [],
148
+ });
149
+
150
+ stmt.run([
151
+ id,
152
+ repositoryId,
153
+ file.path,
154
+ file.content,
155
+ language,
156
+ sizeBytes,
157
+ null, // content_hash
158
+ indexedAt,
159
+ metadata,
160
+ ]);
161
+ count++;
162
+ }
163
+ });
164
+
165
+ logger.info("Saved indexed files to SQLite", { count, repositoryId });
166
+ return count;
107
167
  }
108
168
 
109
169
  function storeSymbolsInternal(
110
- db: KotaDatabase,
111
- symbols: ExtractedSymbol[],
112
- fileId: string,
170
+ db: KotaDatabase,
171
+ symbols: ExtractedSymbol[],
172
+ fileId: string,
113
173
  ): number {
114
- if (symbols.length === 0) {
115
- return 0;
116
- }
174
+ if (symbols.length === 0) {
175
+ return 0;
176
+ }
117
177
 
118
- // Get repository_id from the file
119
- const fileResult = db.queryOne<{ repository_id: string }>(
120
- "SELECT repository_id FROM indexed_files WHERE id = ?",
121
- [fileId]
122
- );
178
+ // Get repository_id from the file
179
+ const fileResult = db.queryOne<{ repository_id: string }>(
180
+ "SELECT repository_id FROM indexed_files WHERE id = ?",
181
+ [fileId],
182
+ );
123
183
 
124
- if (!fileResult) {
125
- throw new Error(`File not found: ${fileId}`);
126
- }
184
+ if (!fileResult) {
185
+ throw new Error(`File not found: ${fileId}`);
186
+ }
127
187
 
128
- const repositoryId = fileResult.repository_id;
129
- let count = 0;
188
+ const repositoryId = fileResult.repository_id;
189
+ let count = 0;
130
190
 
131
- db.transaction(() => {
132
- const stmt = db.prepare(`
191
+ db.transaction(() => {
192
+ const stmt = db.prepare(`
133
193
  INSERT OR REPLACE INTO indexed_symbols (
134
194
  id, file_id, repository_id, name, kind,
135
195
  line_start, line_end, signature, documentation, metadata
136
196
  ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
137
197
  `);
138
198
 
139
- for (const symbol of symbols) {
140
- const id = randomUUID();
141
- const metadata = JSON.stringify({
142
- column_start: symbol.columnStart,
143
- column_end: symbol.columnEnd,
144
- is_exported: symbol.isExported,
145
- is_async: symbol.isAsync,
146
- access_modifier: symbol.accessModifier,
147
- });
148
-
149
- stmt.run([
150
- id,
151
- fileId,
152
- repositoryId,
153
- symbol.name,
154
- symbol.kind,
155
- symbol.lineStart,
156
- symbol.lineEnd,
157
- symbol.signature || null,
158
- symbol.documentation || null,
159
- metadata
160
- ]);
161
- count++;
162
- }
163
- });
164
-
165
- logger.info("Stored symbols to SQLite", { count, fileId });
166
- return count;
199
+ for (const symbol of symbols) {
200
+ const id = randomUUID();
201
+ const metadata = JSON.stringify({
202
+ column_start: symbol.columnStart,
203
+ column_end: symbol.columnEnd,
204
+ is_exported: symbol.isExported,
205
+ is_async: symbol.isAsync,
206
+ access_modifier: symbol.accessModifier,
207
+ });
208
+
209
+ stmt.run([
210
+ id,
211
+ fileId,
212
+ repositoryId,
213
+ symbol.name,
214
+ symbol.kind,
215
+ symbol.lineStart,
216
+ symbol.lineEnd,
217
+ symbol.signature || null,
218
+ symbol.documentation || null,
219
+ metadata,
220
+ ]);
221
+ count++;
222
+ }
223
+ });
224
+
225
+ logger.info("Stored symbols to SQLite", { count, fileId });
226
+ return count;
167
227
  }
168
228
 
169
229
  function storeReferencesInternal(
170
- db: KotaDatabase,
171
- fileId: string,
172
- repositoryId: string,
173
- filePath: string,
174
- references: Reference[],
175
- allFiles: Array<{ path: string }>,
176
- pathMappings?: PathMappings | null,
230
+ db: KotaDatabase,
231
+ fileId: string,
232
+ repositoryId: string,
233
+ filePath: string,
234
+ references: Reference[],
235
+ allFiles: Array<{ path: string }>,
236
+ pathMappings?: PathMappings | null,
177
237
  ): number {
178
- if (references.length === 0) {
179
- return 0;
180
- }
238
+ if (references.length === 0) {
239
+ return 0;
240
+ }
181
241
 
182
- let count = 0;
242
+ let count = 0;
183
243
 
184
- db.transaction(() => {
185
- // First, delete existing references for this file
186
- db.run("DELETE FROM indexed_references WHERE file_id = ?", [fileId]);
244
+ db.transaction(() => {
245
+ // First, delete existing references for this file
246
+ db.run("DELETE FROM indexed_references WHERE file_id = ?", [fileId]);
187
247
 
188
- const stmt = db.prepare(`
248
+ const stmt = db.prepare(`
189
249
  INSERT INTO indexed_references (
190
250
  id, file_id, repository_id, symbol_name, target_symbol_id,
191
251
  target_file_path, line_number, column_number, reference_type, metadata
192
252
  ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
193
253
  `);
194
254
 
195
- for (const ref of references) {
196
- const id = randomUUID();
197
- const metadata = JSON.stringify({
198
- target_name: ref.targetName,
199
- column_number: ref.columnNumber,
200
- ...ref.metadata,
201
- });
202
-
203
- // Resolve target_file_path for import references
204
- let targetFilePath: string | null = null;
205
- if (ref.referenceType === 'import' && ref.metadata?.importSource) {
206
- const resolved = resolveImport(
207
- ref.metadata.importSource,
208
- filePath,
209
- allFiles,
210
- pathMappings
211
- );
212
-
213
- // Normalize path if resolved
214
- if (resolved) {
215
- targetFilePath = normalizePath(resolved);
216
- }
217
- }
218
-
219
- stmt.run([
220
- id,
221
- fileId,
222
- repositoryId,
223
- ref.targetName || "unknown",
224
- null, // target_symbol_id - deferred
225
- targetFilePath, // NOW RESOLVED for imports
226
- ref.lineNumber,
227
- ref.columnNumber || 0,
228
- ref.referenceType,
229
- metadata
230
- ]);
231
- count++;
232
- }
233
- });
234
-
235
- logger.info("Stored references to SQLite", { count, fileId });
236
- return count;
255
+ for (const ref of references) {
256
+ const id = randomUUID();
257
+ const metadata = JSON.stringify({
258
+ target_name: ref.targetName,
259
+ column_number: ref.columnNumber,
260
+ ...ref.metadata,
261
+ });
262
+
263
+ // Resolve target_file_path for import references
264
+ let targetFilePath: string | null = null;
265
+ if (ref.referenceType === "import" && ref.metadata?.importSource) {
266
+ const resolved = resolveImport(
267
+ ref.metadata.importSource,
268
+ filePath,
269
+ allFiles,
270
+ pathMappings,
271
+ );
272
+
273
+ // Normalize path if resolved
274
+ if (resolved) {
275
+ targetFilePath = normalizePath(resolved);
276
+ }
277
+ }
278
+
279
+ stmt.run([
280
+ id,
281
+ fileId,
282
+ repositoryId,
283
+ ref.targetName || "unknown",
284
+ null, // target_symbol_id - deferred
285
+ targetFilePath, // NOW RESOLVED for imports
286
+ ref.lineNumber,
287
+ ref.columnNumber || 0,
288
+ ref.referenceType,
289
+ metadata,
290
+ ]);
291
+ count++;
292
+ }
293
+ });
294
+
295
+ logger.info("Stored references to SQLite", { count, fileId });
296
+ return count;
237
297
  }
238
298
 
239
299
  /**
240
300
  * Escape a search term for use in SQLite FTS5 MATCH clause.
241
301
  * Wraps the entire term in double quotes for exact phrase matching.
242
302
  * Escapes internal double quotes by doubling them.
243
- *
303
+ *
244
304
  * This ensures that:
245
305
  * - Multi-word searches match adjacent words in order ("hello world")
246
306
  * - Hyphenated terms don't trigger FTS5 operator parsing ("mom-and-pop")
247
307
  * - FTS5 keywords (AND, OR, NOT) are treated as literals, not operators
248
- *
308
+ *
249
309
  * @param term - Raw search term from user input
250
310
  * @returns Escaped term safe for FTS5 MATCH clause
251
311
  */
252
312
  function escapeFts5Term(term: string): string {
253
- // Escape internal double quotes by doubling them
254
- const escaped = term.replace(/"/g, '""');
255
- // Wrap in double quotes for exact phrase matching
256
- return `"${escaped}"`;
313
+ // Escape internal double quotes by doubling them
314
+ const escaped = term.replace(/"/g, '""');
315
+ // Wrap in double quotes for exact phrase matching
316
+ return `"${escaped}"`;
257
317
  }
258
318
 
259
319
  function searchFilesInternal(
260
- db: KotaDatabase,
261
- term: string,
262
- repositoryId: string | undefined,
263
- limit: number,
320
+ db: KotaDatabase,
321
+ term: string,
322
+ repositoryId: string | undefined,
323
+ limit: number,
264
324
  ): IndexedFile[] {
265
- const hasRepoFilter = repositoryId !== undefined;
266
- const sql = hasRepoFilter
267
- ? `
325
+ const hasRepoFilter = repositoryId !== undefined;
326
+ const sql = hasRepoFilter
327
+ ? `
268
328
  SELECT
269
329
  f.id,
270
330
  f.repository_id,
@@ -280,7 +340,7 @@ function searchFilesInternal(
280
340
  ORDER BY bm25(indexed_files_fts)
281
341
  LIMIT ?
282
342
  `
283
- : `
343
+ : `
284
344
  SELECT
285
345
  f.id,
286
346
  f.repository_id,
@@ -296,38 +356,40 @@ function searchFilesInternal(
296
356
  LIMIT ?
297
357
  `;
298
358
 
299
- const escapedTerm = escapeFts5Term(term);
300
- const params = hasRepoFilter ? [escapedTerm, repositoryId, limit] : [escapedTerm, limit];
301
- const rows = db.query<{
302
- id: string;
303
- repository_id: string;
304
- path: string;
305
- content: string;
306
- metadata: string;
307
- indexed_at: string;
308
- }>(sql, params);
309
-
310
- return rows.map((row) => {
311
- const metadata = JSON.parse(row.metadata || '{}');
312
- return {
313
- id: row.id,
314
- projectRoot: row.repository_id,
315
- path: row.path,
316
- content: row.content,
317
- dependencies: metadata.dependencies || [],
318
- indexedAt: new Date(row.indexed_at),
319
- };
320
- });
359
+ const escapedTerm = escapeFts5Term(term);
360
+ const params = hasRepoFilter
361
+ ? [escapedTerm, repositoryId, limit]
362
+ : [escapedTerm, limit];
363
+ const rows = db.query<{
364
+ id: string;
365
+ repository_id: string;
366
+ path: string;
367
+ content: string;
368
+ metadata: string;
369
+ indexed_at: string;
370
+ }>(sql, params);
371
+
372
+ return rows.map((row) => {
373
+ const metadata = JSON.parse(row.metadata || "{}");
374
+ return {
375
+ id: row.id,
376
+ projectRoot: row.repository_id,
377
+ path: row.path,
378
+ content: row.content,
379
+ dependencies: metadata.dependencies || [],
380
+ indexedAt: new Date(row.indexed_at),
381
+ };
382
+ });
321
383
  }
322
384
 
323
385
  function listRecentFilesInternal(
324
- db: KotaDatabase,
325
- limit: number,
326
- repositoryId?: string,
386
+ db: KotaDatabase,
387
+ limit: number,
388
+ repositoryId?: string,
327
389
  ): IndexedFile[] {
328
- const hasRepoFilter = repositoryId !== undefined;
329
- const sql = hasRepoFilter
330
- ? `
390
+ const hasRepoFilter = repositoryId !== undefined;
391
+ const sql = hasRepoFilter
392
+ ? `
331
393
  SELECT
332
394
  id, repository_id, path, content, metadata, indexed_at
333
395
  FROM indexed_files
@@ -335,7 +397,7 @@ function listRecentFilesInternal(
335
397
  ORDER BY indexed_at DESC
336
398
  LIMIT ?
337
399
  `
338
- : `
400
+ : `
339
401
  SELECT
340
402
  id, repository_id, path, content, metadata, indexed_at
341
403
  FROM indexed_files
@@ -343,94 +405,92 @@ function listRecentFilesInternal(
343
405
  LIMIT ?
344
406
  `;
345
407
 
346
- const params = hasRepoFilter ? [repositoryId, limit] : [limit];
347
- const rows = db.query<{
348
- id: string;
349
- repository_id: string;
350
- path: string;
351
- content: string;
352
- metadata: string;
353
- indexed_at: string;
354
- }>(sql, params);
355
-
356
- return rows.map((row) => {
357
- const metadata = JSON.parse(row.metadata || '{}');
358
- return {
359
- id: row.id,
360
- projectRoot: row.repository_id,
361
- path: row.path,
362
- content: row.content,
363
- dependencies: metadata.dependencies || [],
364
- indexedAt: new Date(row.indexed_at),
365
- };
366
- });
408
+ const params = hasRepoFilter ? [repositoryId, limit] : [limit];
409
+ const rows = db.query<{
410
+ id: string;
411
+ repository_id: string;
412
+ path: string;
413
+ content: string;
414
+ metadata: string;
415
+ indexed_at: string;
416
+ }>(sql, params);
417
+
418
+ return rows.map((row) => {
419
+ const metadata = JSON.parse(row.metadata || "{}");
420
+ return {
421
+ id: row.id,
422
+ projectRoot: row.repository_id,
423
+ path: row.path,
424
+ content: row.content,
425
+ dependencies: metadata.dependencies || [],
426
+ indexedAt: new Date(row.indexed_at),
427
+ };
428
+ });
367
429
  }
368
430
 
369
431
  function resolveFilePathInternal(
370
- db: KotaDatabase,
371
- filePath: string,
372
- repositoryId: string,
432
+ db: KotaDatabase,
433
+ filePath: string,
434
+ repositoryId: string,
373
435
  ): string | null {
374
- const sql = `
436
+ const sql = `
375
437
  SELECT id
376
438
  FROM indexed_files
377
439
  WHERE repository_id = ? AND path = ?
378
440
  LIMIT 1
379
441
  `;
380
442
 
381
- const result = db.queryOne<{ id: string }>(sql, [repositoryId, filePath]);
382
- return result?.id || null;
443
+ const result = db.queryOne<{ id: string }>(sql, [repositoryId, filePath]);
444
+ return result?.id || null;
383
445
  }
384
446
 
385
447
  function ensureRepositoryInternal(
386
- db: KotaDatabase,
387
- fullName: string,
388
- gitUrl?: string,
389
- defaultBranch?: string,
448
+ db: KotaDatabase,
449
+ fullName: string,
450
+ gitUrl?: string,
451
+ defaultBranch?: string,
390
452
  ): string {
391
- // Check if repository already exists
392
- const existing = db.queryOne<{ id: string }>(
393
- "SELECT id FROM repositories WHERE full_name = ?",
394
- [fullName]
395
- );
396
-
397
- if (existing) {
398
- logger.debug("Repository already exists in SQLite", { fullName, id: existing.id });
399
- return existing.id;
400
- }
401
-
402
- // Create new repository
403
- const id = randomUUID();
404
- const name = fullName.split("/").pop() || fullName;
405
- const now = new Date().toISOString();
406
-
407
- db.run(`
453
+ // Check if repository already exists
454
+ const existing = db.queryOne<{ id: string }>(
455
+ "SELECT id FROM repositories WHERE full_name = ?",
456
+ [fullName],
457
+ );
458
+
459
+ if (existing) {
460
+ logger.debug("Repository already exists in SQLite", {
461
+ fullName,
462
+ id: existing.id,
463
+ });
464
+ return existing.id;
465
+ }
466
+
467
+ // Create new repository
468
+ const id = randomUUID();
469
+ const name = fullName.split("/").pop() || fullName;
470
+ const now = new Date().toISOString();
471
+
472
+ db.run(
473
+ `
408
474
  INSERT INTO repositories (id, name, full_name, git_url, default_branch, created_at, updated_at)
409
475
  VALUES (?, ?, ?, ?, ?, ?, ?)
410
- `, [
411
- id,
412
- name,
413
- fullName,
414
- gitUrl || null,
415
- defaultBranch || "main",
416
- now,
417
- now
418
- ]);
419
-
420
- logger.info("Created repository in SQLite", { fullName, id });
421
- return id;
476
+ `,
477
+ [id, name, fullName, gitUrl || null, defaultBranch || "main", now, now],
478
+ );
479
+
480
+ logger.info("Created repository in SQLite", { fullName, id });
481
+ return id;
422
482
  }
423
483
 
424
484
  function updateRepositoryLastIndexedInternal(
425
- db: KotaDatabase,
426
- repositoryId: string,
485
+ db: KotaDatabase,
486
+ repositoryId: string,
427
487
  ): void {
428
- const now = new Date().toISOString();
429
- db.run(
430
- "UPDATE repositories SET last_indexed_at = ?, updated_at = ? WHERE id = ?",
431
- [now, now, repositoryId]
432
- );
433
- logger.debug("Updated repository last_indexed_at", { repositoryId });
488
+ const now = new Date().toISOString();
489
+ db.run(
490
+ "UPDATE repositories SET last_indexed_at = ?, updated_at = ? WHERE id = ?",
491
+ [now, now, repositoryId],
492
+ );
493
+ logger.debug("Updated repository last_indexed_at", { repositoryId });
434
494
  }
435
495
 
436
496
  // ============================================================================
@@ -439,16 +499,16 @@ function updateRepositoryLastIndexedInternal(
439
499
 
440
500
  /**
441
501
  * Save indexed files to SQLite database
442
- *
502
+ *
443
503
  * @param files - Array of indexed files
444
504
  * @param repositoryId - Repository UUID
445
505
  * @returns Number of files saved
446
506
  */
447
507
  export function saveIndexedFiles(
448
- files: IndexedFile[],
449
- repositoryId: string,
508
+ files: IndexedFile[],
509
+ repositoryId: string,
450
510
  ): number {
451
- return saveIndexedFilesInternal(getGlobalDatabase(), files, repositoryId);
511
+ return saveIndexedFilesInternal(getGlobalDatabase(), files, repositoryId);
452
512
  }
453
513
 
454
514
  /**
@@ -459,10 +519,10 @@ export function saveIndexedFiles(
459
519
  * @returns Number of symbols stored
460
520
  */
461
521
  export function storeSymbols(
462
- symbols: ExtractedSymbol[],
463
- fileId: string,
522
+ symbols: ExtractedSymbol[],
523
+ fileId: string,
464
524
  ): number {
465
- return storeSymbolsInternal(getGlobalDatabase(), symbols, fileId);
525
+ return storeSymbolsInternal(getGlobalDatabase(), symbols, fileId);
466
526
  }
467
527
 
468
528
  /**
@@ -473,33 +533,33 @@ export function storeSymbols(
473
533
  * @returns Number of references stored
474
534
  */
475
535
  export function storeReferences(
476
- fileId: string,
477
- filePath: string,
478
- references: Reference[],
479
- allFiles: Array<{ path: string }>,
480
- pathMappings?: PathMappings | null
536
+ fileId: string,
537
+ filePath: string,
538
+ references: Reference[],
539
+ allFiles: Array<{ path: string }>,
540
+ pathMappings?: PathMappings | null,
481
541
  ): number {
482
- const db = getGlobalDatabase();
483
-
484
- // Get repository_id from file
485
- const result = db.queryOne<{ repository_id: string }>(
486
- "SELECT repository_id FROM indexed_files WHERE id = ?",
487
- [fileId]
488
- );
489
-
490
- if (!result) {
491
- throw new Error(`File not found: ${fileId}`);
492
- }
493
-
494
- return storeReferencesInternal(
495
- db,
496
- fileId,
497
- result.repository_id,
498
- filePath,
499
- references,
500
- allFiles,
501
- pathMappings
502
- );
542
+ const db = getGlobalDatabase();
543
+
544
+ // Get repository_id from file
545
+ const result = db.queryOne<{ repository_id: string }>(
546
+ "SELECT repository_id FROM indexed_files WHERE id = ?",
547
+ [fileId],
548
+ );
549
+
550
+ if (!result) {
551
+ throw new Error(`File not found: ${fileId}`);
552
+ }
553
+
554
+ return storeReferencesInternal(
555
+ db,
556
+ fileId,
557
+ result.repository_id,
558
+ filePath,
559
+ references,
560
+ allFiles,
561
+ pathMappings,
562
+ );
503
563
  }
504
564
 
505
565
  /**
@@ -510,11 +570,16 @@ export function storeReferences(
510
570
  * @returns Array of matching indexed files
511
571
  */
512
572
  export function searchFiles(
513
- term: string,
514
- options: SearchOptions = {},
573
+ term: string,
574
+ options: SearchOptions = {},
515
575
  ): IndexedFile[] {
516
- const limit = Math.min(Math.max(options.limit ?? 20, 1), 100);
517
- return searchFilesInternal(getGlobalDatabase(), term, options.repositoryId, limit);
576
+ const limit = Math.min(Math.max(options.limit ?? 20, 1), 100);
577
+ return searchFilesInternal(
578
+ getGlobalDatabase(),
579
+ term,
580
+ options.repositoryId,
581
+ limit,
582
+ );
518
583
  }
519
584
 
520
585
  /**
@@ -524,10 +589,10 @@ export function searchFiles(
524
589
  * @returns Array of recently indexed files
525
590
  */
526
591
  export function listRecentFiles(
527
- limit: number,
528
- repositoryId?: string,
592
+ limit: number,
593
+ repositoryId?: string,
529
594
  ): IndexedFile[] {
530
- return listRecentFilesInternal(getGlobalDatabase(), limit, repositoryId);
595
+ return listRecentFilesInternal(getGlobalDatabase(), limit, repositoryId);
531
596
  }
532
597
 
533
598
  /**
@@ -538,16 +603,16 @@ export function listRecentFiles(
538
603
  * @returns File UUID or null if not found
539
604
  */
540
605
  export function resolveFilePath(
541
- filePath: string,
542
- repositoryId: string,
606
+ filePath: string,
607
+ repositoryId: string,
543
608
  ): string | null {
544
- return resolveFilePathInternal(getGlobalDatabase(), filePath, repositoryId);
609
+ return resolveFilePathInternal(getGlobalDatabase(), filePath, repositoryId);
545
610
  }
546
611
 
547
612
  export interface DependencyResult {
548
- direct: string[];
549
- indirect: Record<string, string[]>;
550
- cycles: string[][];
613
+ direct: string[];
614
+ indirect: Record<string, string[]>;
615
+ cycles: string[][];
551
616
  }
552
617
 
553
618
  /**
@@ -570,27 +635,27 @@ export interface DependencyResult {
570
635
  * @returns Dependency result with direct/indirect relationships and cycles
571
636
  */
572
637
  export function queryDependents(
573
- fileId: string,
574
- depth: number,
575
- includeTests: boolean,
576
- referenceTypes: string[] = ["import", "re_export", "export_all"],
638
+ fileId: string,
639
+ depth: number,
640
+ includeTests: boolean,
641
+ referenceTypes: string[] = ["import", "re_export", "export_all"],
577
642
  ): DependencyResult {
578
- const db = getGlobalDatabase();
579
-
580
- // Get repository_id and path for target file
581
- const fileRecord = db.queryOne<{ repository_id: string; path: string }>(
582
- "SELECT repository_id, path FROM indexed_files WHERE id = ?",
583
- [fileId]
584
- );
585
-
586
- if (!fileRecord) {
587
- throw new Error(`File not found: ${fileId}`);
588
- }
589
-
590
- // Build IN clause placeholders for reference types
591
- const refTypePlaceholders = referenceTypes.map(() => "?").join(", ");
592
-
593
- const sql = `
643
+ const db = getGlobalDatabase();
644
+
645
+ // Get repository_id and path for target file
646
+ const fileRecord = db.queryOne<{ repository_id: string; path: string }>(
647
+ "SELECT repository_id, path FROM indexed_files WHERE id = ?",
648
+ [fileId],
649
+ );
650
+
651
+ if (!fileRecord) {
652
+ throw new Error(`File not found: ${fileId}`);
653
+ }
654
+
655
+ // Build IN clause placeholders for reference types
656
+ const refTypePlaceholders = referenceTypes.map(() => "?").join(", ");
657
+
658
+ const sql = `
594
659
  WITH RECURSIVE
595
660
  dependents AS (
596
661
  SELECT
@@ -645,21 +710,26 @@ export function queryDependents(
645
710
  FROM cycles
646
711
  ORDER BY depth ASC, file_path ASC
647
712
  `;
648
-
649
- // Build params array: [refTypes..., repoId, path, refTypes..., repoId, depth, refTypes..., repoId, depth]
650
- const results = db.query<{
651
- file_path: string | null;
652
- depth: number | null;
653
- cycle_path: string | null;
654
- }>(sql, [
655
- ...referenceTypes, fileRecord.repository_id, fileRecord.path,
656
- ...referenceTypes, fileRecord.repository_id, depth,
657
- ...referenceTypes, fileRecord.repository_id, depth
658
- ]);
659
-
660
- return processDepthResults(results, includeTests);
661
- }
662
713
 
714
+ // Build params array: [refTypes..., repoId, path, refTypes..., repoId, depth, refTypes..., repoId, depth]
715
+ const results = db.query<{
716
+ file_path: string | null;
717
+ depth: number | null;
718
+ cycle_path: string | null;
719
+ }>(sql, [
720
+ ...referenceTypes,
721
+ fileRecord.repository_id,
722
+ fileRecord.path,
723
+ ...referenceTypes,
724
+ fileRecord.repository_id,
725
+ depth,
726
+ ...referenceTypes,
727
+ fileRecord.repository_id,
728
+ depth,
729
+ ]);
730
+
731
+ return processDepthResults(results, includeTests);
732
+ }
663
733
 
664
734
  /**
665
735
  * Query files that the given file depends on (forward lookup).
@@ -672,26 +742,26 @@ export function queryDependents(
672
742
  * @returns Dependency result with direct/indirect relationships and cycles
673
743
  */
674
744
  export function queryDependencies(
675
- fileId: string,
676
- depth: number,
677
- referenceTypes: string[] = ["import", "re_export", "export_all"],
745
+ fileId: string,
746
+ depth: number,
747
+ referenceTypes: string[] = ["import", "re_export", "export_all"],
678
748
  ): DependencyResult {
679
- const db = getGlobalDatabase();
680
-
681
- // Get repository_id for source file
682
- const fileRecord = db.queryOne<{ repository_id: string }>(
683
- "SELECT repository_id FROM indexed_files WHERE id = ?",
684
- [fileId]
685
- );
686
-
687
- if (!fileRecord) {
688
- throw new Error(`File not found: ${fileId}`);
689
- }
690
-
691
- // Build IN clause placeholders for reference types
692
- const refTypePlaceholders = referenceTypes.map(() => "?").join(", ");
693
-
694
- const sql = `
749
+ const db = getGlobalDatabase();
750
+
751
+ // Get repository_id for source file
752
+ const fileRecord = db.queryOne<{ repository_id: string }>(
753
+ "SELECT repository_id FROM indexed_files WHERE id = ?",
754
+ [fileId],
755
+ );
756
+
757
+ if (!fileRecord) {
758
+ throw new Error(`File not found: ${fileId}`);
759
+ }
760
+
761
+ // Build IN clause placeholders for reference types
762
+ const refTypePlaceholders = referenceTypes.map(() => "?").join(", ");
763
+
764
+ const sql = `
695
765
  WITH RECURSIVE
696
766
  dependencies AS (
697
767
  SELECT
@@ -744,326 +814,343 @@ export function queryDependencies(
744
814
  FROM cycles
745
815
  ORDER BY depth ASC, file_path ASC
746
816
  `;
747
-
748
- // Build params array: [refTypes..., repoId, fileId, refTypes..., repoId, depth, refTypes..., repoId, depth]
749
- const results = db.query<{
750
- file_path: string | null;
751
- depth: number | null;
752
- cycle_path: string | null;
753
- }>(sql, [
754
- ...referenceTypes, fileRecord.repository_id, fileId,
755
- ...referenceTypes, fileRecord.repository_id, depth,
756
- ...referenceTypes, fileRecord.repository_id, depth
757
- ]);
758
-
759
- return processDepthResults(results, true); // Always include tests for dependencies
760
- }
761
817
 
818
+ // Build params array: [refTypes..., repoId, fileId, refTypes..., repoId, depth, refTypes..., repoId, depth]
819
+ const results = db.query<{
820
+ file_path: string | null;
821
+ depth: number | null;
822
+ cycle_path: string | null;
823
+ }>(sql, [
824
+ ...referenceTypes,
825
+ fileRecord.repository_id,
826
+ fileId,
827
+ ...referenceTypes,
828
+ fileRecord.repository_id,
829
+ depth,
830
+ ...referenceTypes,
831
+ fileRecord.repository_id,
832
+ depth,
833
+ ]);
834
+
835
+ return processDepthResults(results, true); // Always include tests for dependencies
836
+ }
762
837
 
763
838
  function processDepthResults(
764
- results: Array<{
765
- file_path: string | null;
766
- depth: number | null;
767
- cycle_path: string | null;
768
- }>,
769
- includeTests: boolean
839
+ results: Array<{
840
+ file_path: string | null;
841
+ depth: number | null;
842
+ cycle_path: string | null;
843
+ }>,
844
+ includeTests: boolean,
770
845
  ): DependencyResult {
771
- const direct: string[] = [];
772
- const indirect: Record<string, string[]> = {};
773
- const cycles: string[][] = [];
774
- const seenCycles = new Set<string>();
775
-
776
- for (const result of results) {
777
- // Handle cycle detection
778
- if (result.cycle_path) {
779
- const cycleKey = result.cycle_path;
780
- if (!seenCycles.has(cycleKey)) {
781
- seenCycles.add(cycleKey);
782
- const cyclePaths = result.cycle_path
783
- .split('|')
784
- .filter(path => path.length > 0);
785
-
786
- if (cyclePaths.length > 1) {
787
- cycles.push(cyclePaths);
788
- }
789
- }
790
- continue; // Don't add cycles to direct/indirect
791
- }
792
-
793
- // Skip if file_path is null (cycle-only rows)
794
- if (!result.file_path || result.depth === null) {
795
- continue;
796
- }
797
-
798
- // Filter test files if requested
799
- if (!includeTests && (result.file_path.includes("test") || result.file_path.includes("spec"))) {
800
- continue;
801
- }
802
-
803
- // Categorize by depth
804
- if (result.depth === 1) {
805
- if (!direct.includes(result.file_path)) {
806
- direct.push(result.file_path);
807
- }
808
- } else {
809
- const key = `depth_${result.depth}`;
810
- if (!indirect[key]) {
811
- indirect[key] = [];
812
- }
813
- if (!indirect[key].includes(result.file_path)) {
814
- indirect[key].push(result.file_path);
815
- }
816
- }
817
- }
818
-
819
- return { direct, indirect, cycles };
846
+ const direct: string[] = [];
847
+ const indirect: Record<string, string[]> = {};
848
+ const cycles: string[][] = [];
849
+ const seenCycles = new Set<string>();
850
+
851
+ for (const result of results) {
852
+ // Handle cycle detection
853
+ if (result.cycle_path) {
854
+ const cycleKey = result.cycle_path;
855
+ if (!seenCycles.has(cycleKey)) {
856
+ seenCycles.add(cycleKey);
857
+ const cyclePaths = result.cycle_path
858
+ .split("|")
859
+ .filter((path) => path.length > 0);
860
+
861
+ if (cyclePaths.length > 1) {
862
+ cycles.push(cyclePaths);
863
+ }
864
+ }
865
+ continue; // Don't add cycles to direct/indirect
866
+ }
867
+
868
+ // Skip if file_path is null (cycle-only rows)
869
+ if (!result.file_path || result.depth === null) {
870
+ continue;
871
+ }
872
+
873
+ // Filter test files if requested
874
+ if (
875
+ !includeTests &&
876
+ (result.file_path.includes("test") || result.file_path.includes("spec"))
877
+ ) {
878
+ continue;
879
+ }
880
+
881
+ // Categorize by depth
882
+ if (result.depth === 1) {
883
+ if (!direct.includes(result.file_path)) {
884
+ direct.push(result.file_path);
885
+ }
886
+ } else {
887
+ const key = `depth_${result.depth}`;
888
+ if (!indirect[key]) {
889
+ indirect[key] = [];
890
+ }
891
+ if (!indirect[key].includes(result.file_path)) {
892
+ indirect[key].push(result.file_path);
893
+ }
894
+ }
895
+ }
896
+
897
+ return { direct, indirect, cycles };
820
898
  }
821
899
 
822
-
823
900
  /**
824
901
  * Query dependents (reverse lookup): files/symbols that depend on the target
825
- *
902
+ *
826
903
  * @internal - Use queryDependents() for the wrapped version
827
904
  */
828
905
 
829
906
  /**
830
907
  * Ensure repository exists in SQLite, create if not.
831
- *
908
+ *
832
909
  * @param fullName - Repository full name (owner/repo format)
833
910
  * @param gitUrl - Git URL for the repository (optional)
834
911
  * @param defaultBranch - Default branch name (optional, defaults to 'main')
835
912
  * @returns Repository UUID
836
913
  */
837
914
  export function ensureRepository(
838
- fullName: string,
839
- gitUrl?: string,
840
- defaultBranch?: string,
915
+ fullName: string,
916
+ gitUrl?: string,
917
+ defaultBranch?: string,
841
918
  ): string {
842
- return ensureRepositoryInternal(getGlobalDatabase(), fullName, gitUrl, defaultBranch);
919
+ return ensureRepositoryInternal(
920
+ getGlobalDatabase(),
921
+ fullName,
922
+ gitUrl,
923
+ defaultBranch,
924
+ );
843
925
  }
844
926
 
845
927
  /**
846
928
  * Update repository last_indexed_at timestamp.
847
- *
929
+ *
848
930
  * @param repositoryId - Repository UUID
849
931
  */
850
- export function updateRepositoryLastIndexed(
851
- repositoryId: string,
852
- ): void {
853
- updateRepositoryLastIndexedInternal(getGlobalDatabase(), repositoryId);
932
+ export function updateRepositoryLastIndexed(repositoryId: string): void {
933
+ updateRepositoryLastIndexedInternal(getGlobalDatabase(), repositoryId);
854
934
  }
855
935
 
856
936
  /**
857
937
  * Run indexing workflow for local mode (synchronous, no queue).
858
- *
938
+ *
859
939
  * @param request - Index request with repository details
860
940
  * @returns Indexing result with stats
861
941
  */
862
- export async function runIndexingWorkflow(
863
- request: IndexRequest,
864
- ): Promise<{
865
- repositoryId: string;
866
- filesIndexed: number;
867
- symbolsExtracted: number;
868
- referencesExtracted: number;
942
+ export async function runIndexingWorkflow(request: IndexRequest): Promise<{
943
+ repositoryId: string;
944
+ filesIndexed: number;
945
+ symbolsExtracted: number;
946
+ referencesExtracted: number;
869
947
  }> {
870
- const { existsSync } = await import("node:fs");
871
- const { resolve } = await import("node:path");
872
- const { prepareRepository } = await import("@indexer/repos");
873
- const { discoverSources, parseSourceFile } = await import("@indexer/parsers");
874
- const { parseFileWithRecovery, isSupportedForAST } = await import("@indexer/ast-parser");
875
- const { extractSymbols } = await import("@indexer/symbol-extractor");
876
- const { extractReferences } = await import("@indexer/reference-extractor");
877
- const { parseTsConfig } = await import("@indexer/path-resolver");
878
-
879
- const db = getGlobalDatabase();
880
-
881
- let localPath: string;
882
- let fullName = request.repository;
883
-
884
- if (request.localPath) {
885
- localPath = resolve(request.localPath);
886
-
887
- const workspaceRoot = resolve(process.cwd());
888
- if (!localPath.startsWith(workspaceRoot)) {
889
- throw new Error(`localPath must be within workspace: ${workspaceRoot}`);
890
- }
891
- if (!fullName.includes("/")) {
892
- fullName = `local/${fullName}`;
893
- }
894
- } else {
895
- const repo = await prepareRepository(request);
896
- localPath = repo.localPath;
897
- }
898
-
899
- if (!existsSync(localPath)) {
900
- throw new Error(`Repository path does not exist: ${localPath}`);
901
- }
902
-
903
- const gitUrl = request.localPath ? localPath : `https://github.com/${fullName}.git`;
904
- const repositoryId = ensureRepository(fullName, gitUrl, request.ref);
905
-
906
- logger.info("Starting local indexing workflow", {
907
- repositoryId,
908
- fullName,
909
- localPath,
910
- });
911
-
912
- // Parse tsconfig.json for path alias resolution
913
- const pathMappings = parseTsConfig(localPath);
914
- if (pathMappings) {
915
- logger.info("Loaded path mappings from tsconfig.json", {
916
- aliasCount: Object.keys(pathMappings.paths).length,
917
- baseUrl: pathMappings.baseUrl,
918
- });
919
- }
920
-
921
- const sources = await discoverSources(localPath);
922
- const records = (
923
- await Promise.all(sources.map((source) => parseSourceFile(source, localPath)))
924
- ).filter((entry): entry is NonNullable<typeof entry> => entry !== null);
925
-
926
- const filesIndexed = saveIndexedFiles(records, repositoryId);
927
-
928
- // Query ALL indexed files for complete resolution (fixes order dependency bug)
929
- const allIndexedFiles = db
930
- .query<{ id: string; path: string }>(
931
- `SELECT id, path FROM indexed_files WHERE repository_id = ?`,
932
- [repositoryId]
933
- )
934
- .map(row => ({ id: row.id, path: row.path, repository_id: repositoryId }));
935
-
936
- logger.debug("Queried all indexed files for path alias resolution", {
937
- count: allIndexedFiles.length,
938
- repositoryId,
939
- });
940
-
941
- let totalSymbols = 0;
942
- let totalReferences = 0;
943
-
944
- // First pass: Store symbols and collect references (but don't resolve imports yet)
945
- interface FileWithReferences {
946
- fileId: string;
947
- filePath: string;
948
- references: Reference[];
949
- }
950
- const filesWithReferences: FileWithReferences[] = [];
951
-
952
- for (const file of records) {
953
- if (!isSupportedForAST(file.path)) continue;
954
-
955
- const parseResult = parseFileWithRecovery(file.path, file.content);
956
- if (!parseResult.ast) continue;
957
-
958
- const symbols = extractSymbols(parseResult.ast!, file.path);
959
- const references = extractReferences(parseResult.ast!, file.path);
960
-
961
- const fileRecord = db.queryOne<{ id: string }>(
962
- "SELECT id FROM indexed_files WHERE repository_id = ? AND path = ?",
963
- [repositoryId, file.path]
964
- );
965
-
966
- if (!fileRecord) {
967
- logger.warn("Could not find file record after indexing", {
968
- filePath: file.path,
969
- repositoryId,
970
- });
971
- continue;
972
- }
973
-
974
- // Store symbols immediately
975
- const symbolCount = storeSymbols(symbols, fileRecord.id);
976
- totalSymbols += symbolCount;
977
-
978
- // Collect references for later processing
979
- filesWithReferences.push({
980
- fileId: fileRecord.id,
981
- filePath: file.path,
982
- references,
983
- });
984
- }
985
-
986
- // Second pass: Store references with complete file list for proper path alias resolution
987
- for (const fileWithRefs of filesWithReferences) {
988
- const referenceCount = storeReferences(
989
- fileWithRefs.fileId,
990
- fileWithRefs.filePath,
991
- fileWithRefs.references,
992
- allIndexedFiles, // Use complete file list instead of incremental array
993
- pathMappings
994
- );
995
- totalReferences += referenceCount;
996
- }
997
-
998
- // Build symbol metadata for backward compatibility
999
- const allSymbolsWithFileId: Array<{
1000
- id: string;
1001
- file_id: string;
1002
- name: string;
1003
- kind: SymbolKind;
1004
- lineStart: number;
1005
- lineEnd: number;
1006
- columnStart: number;
1007
- columnEnd: number;
1008
- signature: string | null;
1009
- documentation: string | null;
1010
- isExported: boolean;
1011
- }> = [];
1012
- const allReferencesWithFileId: Array<Reference & { file_id: string }> = [];
1013
-
1014
- for (const fileWithRefs of filesWithReferences) {
1015
- const storedSymbols = db.query<{
1016
- id: string;
1017
- file_id: string;
1018
- name: string;
1019
- kind: SymbolKind;
1020
- line_start: number;
1021
- line_end: number;
1022
- signature: string | null;
1023
- documentation: string | null;
1024
- metadata: string;
1025
- }>(
1026
- "SELECT id, file_id, name, kind, line_start, line_end, signature, documentation, metadata FROM indexed_symbols WHERE file_id = ?",
1027
- [fileWithRefs.fileId]
1028
- );
1029
-
1030
- for (const s of storedSymbols) {
1031
- const metadata = JSON.parse(s.metadata || "{}");
1032
- allSymbolsWithFileId.push({
1033
- id: s.id,
1034
- file_id: s.file_id,
1035
- name: s.name,
1036
- kind: s.kind as SymbolKind,
1037
- lineStart: s.line_start,
1038
- lineEnd: s.line_end,
1039
- columnStart: metadata.column_start || 0,
1040
- columnEnd: metadata.column_end || 0,
1041
- signature: s.signature || null,
1042
- documentation: s.documentation || null,
1043
- isExported: metadata.is_exported || false,
1044
- });
1045
- }
1046
-
1047
- for (const ref of fileWithRefs.references) {
1048
- allReferencesWithFileId.push({ ...ref, file_id: fileWithRefs.fileId });
1049
- }
1050
- }
1051
-
1052
- updateRepositoryLastIndexed(repositoryId);
1053
-
1054
- logger.info("Local indexing workflow completed", {
1055
- repositoryId,
1056
- filesIndexed,
1057
- symbolsExtracted: totalSymbols,
1058
- referencesExtracted: totalReferences,
1059
- });
1060
-
1061
- return {
1062
- repositoryId,
1063
- filesIndexed,
1064
- symbolsExtracted: totalSymbols,
1065
- referencesExtracted: totalReferences,
1066
- };
948
+ const { existsSync } = await import("node:fs");
949
+ const { resolve } = await import("node:path");
950
+ const { prepareRepository } = await import("@indexer/repos");
951
+ const { discoverSources, parseSourceFile } = await import("@indexer/parsers");
952
+ const { parseFileWithRecovery, isSupportedForAST } =
953
+ await import("@indexer/ast-parser");
954
+ const { extractSymbols } = await import("@indexer/symbol-extractor");
955
+ const { extractReferences } = await import("@indexer/reference-extractor");
956
+ const { parseTsConfig } = await import("@indexer/path-resolver");
957
+
958
+ const db = getGlobalDatabase();
959
+
960
+ let localPath: string;
961
+ let fullName = request.repository;
962
+
963
+ if (request.localPath) {
964
+ localPath = resolve(request.localPath);
965
+
966
+ const workspaceRoot = resolve(process.cwd());
967
+ if (!localPath.startsWith(workspaceRoot)) {
968
+ throw new Error(`localPath must be within workspace: ${workspaceRoot}`);
969
+ }
970
+ if (!fullName.includes("/")) {
971
+ fullName = `local/${fullName}`;
972
+ }
973
+ } else {
974
+ const repo = await prepareRepository(request);
975
+ localPath = repo.localPath;
976
+ }
977
+
978
+ if (!existsSync(localPath)) {
979
+ throw new Error(`Repository path does not exist: ${localPath}`);
980
+ }
981
+
982
+ const gitUrl = request.localPath
983
+ ? localPath
984
+ : `https://github.com/${fullName}.git`;
985
+ const repositoryId = ensureRepository(fullName, gitUrl, request.ref);
986
+
987
+ logger.info("Starting local indexing workflow", {
988
+ repositoryId,
989
+ fullName,
990
+ localPath,
991
+ });
992
+
993
+ // Parse tsconfig.json for path alias resolution
994
+ const pathMappings = parseTsConfig(localPath);
995
+ if (pathMappings) {
996
+ logger.info("Loaded path mappings from tsconfig.json", {
997
+ aliasCount: Object.keys(pathMappings.paths).length,
998
+ baseUrl: pathMappings.baseUrl,
999
+ });
1000
+ }
1001
+
1002
+ const sources = await discoverSources(localPath);
1003
+ const records = (
1004
+ await Promise.all(
1005
+ sources.map((source) => parseSourceFile(source, localPath)),
1006
+ )
1007
+ ).filter((entry): entry is NonNullable<typeof entry> => entry !== null);
1008
+
1009
+ const filesIndexed = saveIndexedFiles(records, repositoryId);
1010
+
1011
+ // Query ALL indexed files for complete resolution (fixes order dependency bug)
1012
+ const allIndexedFiles = db
1013
+ .query<{
1014
+ id: string;
1015
+ path: string;
1016
+ }>(`SELECT id, path FROM indexed_files WHERE repository_id = ?`, [repositoryId])
1017
+ .map((row) => ({
1018
+ id: row.id,
1019
+ path: row.path,
1020
+ repository_id: repositoryId,
1021
+ }));
1022
+
1023
+ logger.debug("Queried all indexed files for path alias resolution", {
1024
+ count: allIndexedFiles.length,
1025
+ repositoryId,
1026
+ });
1027
+
1028
+ let totalSymbols = 0;
1029
+ let totalReferences = 0;
1030
+
1031
+ // First pass: Store symbols and collect references (but don't resolve imports yet)
1032
+ interface FileWithReferences {
1033
+ fileId: string;
1034
+ filePath: string;
1035
+ references: Reference[];
1036
+ }
1037
+ const filesWithReferences: FileWithReferences[] = [];
1038
+
1039
+ for (const file of records) {
1040
+ if (!isSupportedForAST(file.path)) continue;
1041
+
1042
+ const parseResult = parseFileWithRecovery(file.path, file.content);
1043
+ if (!parseResult.ast) continue;
1044
+
1045
+ const symbols = extractSymbols(parseResult.ast!, file.path);
1046
+ const references = extractReferences(parseResult.ast!, file.path);
1047
+
1048
+ const fileRecord = db.queryOne<{ id: string }>(
1049
+ "SELECT id FROM indexed_files WHERE repository_id = ? AND path = ?",
1050
+ [repositoryId, file.path],
1051
+ );
1052
+
1053
+ if (!fileRecord) {
1054
+ logger.warn("Could not find file record after indexing", {
1055
+ filePath: file.path,
1056
+ repositoryId,
1057
+ });
1058
+ continue;
1059
+ }
1060
+
1061
+ // Store symbols immediately
1062
+ const symbolCount = storeSymbols(symbols, fileRecord.id);
1063
+ totalSymbols += symbolCount;
1064
+
1065
+ // Collect references for later processing
1066
+ filesWithReferences.push({
1067
+ fileId: fileRecord.id,
1068
+ filePath: file.path,
1069
+ references,
1070
+ });
1071
+ }
1072
+
1073
+ // Second pass: Store references with complete file list for proper path alias resolution
1074
+ for (const fileWithRefs of filesWithReferences) {
1075
+ const referenceCount = storeReferences(
1076
+ fileWithRefs.fileId,
1077
+ fileWithRefs.filePath,
1078
+ fileWithRefs.references,
1079
+ allIndexedFiles, // Use complete file list instead of incremental array
1080
+ pathMappings,
1081
+ );
1082
+ totalReferences += referenceCount;
1083
+ }
1084
+
1085
+ // Build symbol metadata for backward compatibility
1086
+ const allSymbolsWithFileId: Array<{
1087
+ id: string;
1088
+ file_id: string;
1089
+ name: string;
1090
+ kind: SymbolKind;
1091
+ lineStart: number;
1092
+ lineEnd: number;
1093
+ columnStart: number;
1094
+ columnEnd: number;
1095
+ signature: string | null;
1096
+ documentation: string | null;
1097
+ isExported: boolean;
1098
+ }> = [];
1099
+ const allReferencesWithFileId: Array<Reference & { file_id: string }> = [];
1100
+
1101
+ for (const fileWithRefs of filesWithReferences) {
1102
+ const storedSymbols = db.query<{
1103
+ id: string;
1104
+ file_id: string;
1105
+ name: string;
1106
+ kind: SymbolKind;
1107
+ line_start: number;
1108
+ line_end: number;
1109
+ signature: string | null;
1110
+ documentation: string | null;
1111
+ metadata: string;
1112
+ }>(
1113
+ "SELECT id, file_id, name, kind, line_start, line_end, signature, documentation, metadata FROM indexed_symbols WHERE file_id = ?",
1114
+ [fileWithRefs.fileId],
1115
+ );
1116
+
1117
+ for (const s of storedSymbols) {
1118
+ const metadata = JSON.parse(s.metadata || "{}");
1119
+ allSymbolsWithFileId.push({
1120
+ id: s.id,
1121
+ file_id: s.file_id,
1122
+ name: s.name,
1123
+ kind: s.kind as SymbolKind,
1124
+ lineStart: s.line_start,
1125
+ lineEnd: s.line_end,
1126
+ columnStart: metadata.column_start || 0,
1127
+ columnEnd: metadata.column_end || 0,
1128
+ signature: s.signature || null,
1129
+ documentation: s.documentation || null,
1130
+ isExported: metadata.is_exported || false,
1131
+ });
1132
+ }
1133
+
1134
+ for (const ref of fileWithRefs.references) {
1135
+ allReferencesWithFileId.push({ ...ref, file_id: fileWithRefs.fileId });
1136
+ }
1137
+ }
1138
+
1139
+ updateRepositoryLastIndexed(repositoryId);
1140
+
1141
+ logger.info("Local indexing workflow completed", {
1142
+ repositoryId,
1143
+ filesIndexed,
1144
+ symbolsExtracted: totalSymbols,
1145
+ referencesExtracted: totalReferences,
1146
+ });
1147
+
1148
+ return {
1149
+ repositoryId,
1150
+ filesIndexed,
1151
+ symbolsExtracted: totalSymbols,
1152
+ referencesExtracted: totalReferences,
1153
+ };
1067
1154
  }
1068
1155
 
1069
1156
  // ============================================================================
@@ -1072,174 +1159,181 @@ export async function runIndexingWorkflow(
1072
1159
 
1073
1160
  /**
1074
1161
  * Check if a repository has been indexed (has files in indexed_files table).
1075
- *
1162
+ *
1076
1163
  * @param repositoryId - Repository UUID or full_name
1077
1164
  * @returns true if the repository has indexed files, false otherwise
1078
1165
  */
1079
1166
  function isRepositoryIndexedInternal(
1080
- db: KotaDatabase,
1081
- repositoryId: string,
1167
+ db: KotaDatabase,
1168
+ repositoryId: string,
1082
1169
  ): boolean {
1083
- // First try to match by ID, then by full_name
1084
- const result = db.queryOne<{ count: number }>(
1085
- `SELECT COUNT(*) as count FROM indexed_files
1170
+ // First try to match by ID, then by full_name
1171
+ const result = db.queryOne<{ count: number }>(
1172
+ `SELECT COUNT(*) as count FROM indexed_files
1086
1173
  WHERE repository_id = ?
1087
1174
  OR repository_id IN (SELECT id FROM repositories WHERE full_name = ?)`,
1088
- [repositoryId, repositoryId]
1089
- );
1090
- return (result?.count ?? 0) > 0;
1175
+ [repositoryId, repositoryId],
1176
+ );
1177
+ return (result?.count ?? 0) > 0;
1091
1178
  }
1092
1179
 
1093
1180
  /**
1094
1181
  * Check if a repository has been indexed.
1095
- *
1182
+ *
1096
1183
  * @param repositoryId - Repository UUID or full_name
1097
1184
  * @returns true if the repository has indexed files, false otherwise
1098
1185
  */
1099
1186
  export function isRepositoryIndexed(repositoryId: string): boolean {
1100
- return isRepositoryIndexedInternal(getGlobalDatabase(), repositoryId);
1187
+ return isRepositoryIndexedInternal(getGlobalDatabase(), repositoryId);
1101
1188
  }
1102
1189
 
1103
1190
  /**
1104
1191
  * Delete a single file from the index by path.
1105
1192
  * Cascading deletes will remove associated symbols and references.
1106
- *
1193
+ *
1107
1194
  * @param repositoryId - Repository UUID
1108
1195
  * @param filePath - Relative file path to delete
1109
1196
  * @returns true if file was deleted, false if not found
1110
1197
  */
1111
1198
  function deleteFileByPathInternal(
1112
- db: KotaDatabase,
1113
- repositoryId: string,
1114
- filePath: string,
1199
+ db: KotaDatabase,
1200
+ repositoryId: string,
1201
+ filePath: string,
1115
1202
  ): boolean {
1116
- const normalizedPath = normalizePath(filePath);
1117
-
1118
- // The indexed_files table has ON DELETE CASCADE for:
1119
- // - indexed_symbols (via file_id FK)
1120
- // - indexed_references (via file_id FK)
1121
- // FTS5 triggers handle indexed_files_fts cleanup automatically
1122
-
1123
- const result = db.queryOne<{ id: string }>(
1124
- `SELECT id FROM indexed_files WHERE repository_id = ? AND path = ?`,
1125
- [repositoryId, normalizedPath]
1126
- );
1127
-
1128
- if (!result) {
1129
- logger.debug("File not found for deletion", { repositoryId, filePath: normalizedPath });
1130
- return false;
1131
- }
1132
-
1133
- db.run(
1134
- `DELETE FROM indexed_files WHERE id = ?`,
1135
- [result.id]
1136
- );
1137
-
1138
- logger.info("Deleted file from index", { repositoryId, filePath: normalizedPath, fileId: result.id });
1139
- return true;
1203
+ const normalizedPath = normalizePath(filePath);
1204
+
1205
+ // The indexed_files table has ON DELETE CASCADE for:
1206
+ // - indexed_symbols (via file_id FK)
1207
+ // - indexed_references (via file_id FK)
1208
+ // FTS5 triggers handle indexed_files_fts cleanup automatically
1209
+
1210
+ const result = db.queryOne<{ id: string }>(
1211
+ `SELECT id FROM indexed_files WHERE repository_id = ? AND path = ?`,
1212
+ [repositoryId, normalizedPath],
1213
+ );
1214
+
1215
+ if (!result) {
1216
+ logger.debug("File not found for deletion", {
1217
+ repositoryId,
1218
+ filePath: normalizedPath,
1219
+ });
1220
+ return false;
1221
+ }
1222
+
1223
+ db.run(`DELETE FROM indexed_files WHERE id = ?`, [result.id]);
1224
+
1225
+ logger.info("Deleted file from index", {
1226
+ repositoryId,
1227
+ filePath: normalizedPath,
1228
+ fileId: result.id,
1229
+ });
1230
+ return true;
1140
1231
  }
1141
1232
 
1142
1233
  /**
1143
1234
  * Delete a single file from the index by path.
1144
- *
1235
+ *
1145
1236
  * @param repositoryId - Repository UUID
1146
1237
  * @param filePath - Relative file path to delete
1147
1238
  * @returns true if file was deleted, false if not found
1148
1239
  */
1149
1240
  export function deleteFileByPath(
1150
- repositoryId: string,
1151
- filePath: string,
1241
+ repositoryId: string,
1242
+ filePath: string,
1152
1243
  ): boolean {
1153
- return deleteFileByPathInternal(getGlobalDatabase(), repositoryId, filePath);
1244
+ return deleteFileByPathInternal(getGlobalDatabase(), repositoryId, filePath);
1154
1245
  }
1155
1246
 
1156
1247
  /**
1157
1248
  * Delete multiple files from the index by paths.
1158
1249
  * Uses a transaction for atomic operation.
1159
1250
  * Cascading deletes will remove associated symbols and references.
1160
- *
1251
+ *
1161
1252
  * @param repositoryId - Repository UUID
1162
1253
  * @param filePaths - Array of relative file paths to delete
1163
1254
  * @returns Object with deleted count and list of deleted paths
1164
1255
  */
1165
1256
  function deleteFilesByPathsInternal(
1166
- db: KotaDatabase,
1167
- repositoryId: string,
1168
- filePaths: string[],
1257
+ db: KotaDatabase,
1258
+ repositoryId: string,
1259
+ filePaths: string[],
1169
1260
  ): { deletedCount: number; deletedPaths: string[] } {
1170
- if (filePaths.length === 0) {
1171
- return { deletedCount: 0, deletedPaths: [] };
1172
- }
1173
-
1174
- const normalizedPaths = filePaths.map(normalizePath);
1175
- const deletedPaths: string[] = [];
1176
-
1177
- db.transaction(() => {
1178
- for (const normalizedPath of normalizedPaths) {
1179
- const result = db.queryOne<{ id: string }>(
1180
- `SELECT id FROM indexed_files WHERE repository_id = ? AND path = ?`,
1181
- [repositoryId, normalizedPath]
1182
- );
1183
-
1184
- if (result) {
1185
- db.run(`DELETE FROM indexed_files WHERE id = ?`, [result.id]);
1186
- deletedPaths.push(normalizedPath);
1187
- }
1188
- }
1189
- });
1190
-
1191
- logger.info("Deleted files from index", {
1192
- repositoryId,
1193
- requestedCount: filePaths.length,
1194
- deletedCount: deletedPaths.length
1195
- });
1196
-
1197
- return { deletedCount: deletedPaths.length, deletedPaths };
1261
+ if (filePaths.length === 0) {
1262
+ return { deletedCount: 0, deletedPaths: [] };
1263
+ }
1264
+
1265
+ const normalizedPaths = filePaths.map(normalizePath);
1266
+ const deletedPaths: string[] = [];
1267
+
1268
+ db.transaction(() => {
1269
+ for (const normalizedPath of normalizedPaths) {
1270
+ const result = db.queryOne<{ id: string }>(
1271
+ `SELECT id FROM indexed_files WHERE repository_id = ? AND path = ?`,
1272
+ [repositoryId, normalizedPath],
1273
+ );
1274
+
1275
+ if (result) {
1276
+ db.run(`DELETE FROM indexed_files WHERE id = ?`, [result.id]);
1277
+ deletedPaths.push(normalizedPath);
1278
+ }
1279
+ }
1280
+ });
1281
+
1282
+ logger.info("Deleted files from index", {
1283
+ repositoryId,
1284
+ requestedCount: filePaths.length,
1285
+ deletedCount: deletedPaths.length,
1286
+ });
1287
+
1288
+ return { deletedCount: deletedPaths.length, deletedPaths };
1198
1289
  }
1199
1290
 
1200
1291
  /**
1201
1292
  * Delete multiple files from the index by paths.
1202
- *
1293
+ *
1203
1294
  * @param repositoryId - Repository UUID
1204
1295
  * @param filePaths - Array of relative file paths to delete
1205
1296
  * @returns Object with deleted count and list of deleted paths
1206
1297
  */
1207
1298
  export function deleteFilesByPaths(
1208
- repositoryId: string,
1209
- filePaths: string[],
1299
+ repositoryId: string,
1300
+ filePaths: string[],
1210
1301
  ): { deletedCount: number; deletedPaths: string[] } {
1211
- return deleteFilesByPathsInternal(getGlobalDatabase(), repositoryId, filePaths);
1302
+ return deleteFilesByPathsInternal(
1303
+ getGlobalDatabase(),
1304
+ repositoryId,
1305
+ filePaths,
1306
+ );
1212
1307
  }
1213
1308
 
1214
1309
  /**
1215
1310
  * Get repository ID from full_name.
1216
1311
  * Useful for auto-indexing when you have the path but need the UUID.
1217
- *
1312
+ *
1218
1313
  * @param fullName - Repository full name (e.g., "owner/repo" or "local/path")
1219
1314
  * @returns Repository UUID or null if not found
1220
1315
  */
1221
1316
  function getRepositoryIdByNameInternal(
1222
- db: KotaDatabase,
1223
- fullName: string,
1317
+ db: KotaDatabase,
1318
+ fullName: string,
1224
1319
  ): string | null {
1225
- const result = db.queryOne<{ id: string }>(
1226
- `SELECT id FROM repositories WHERE full_name = ?`,
1227
- [fullName]
1228
- );
1229
- return result?.id ?? null;
1320
+ const result = db.queryOne<{ id: string }>(
1321
+ `SELECT id FROM repositories WHERE full_name = ?`,
1322
+ [fullName],
1323
+ );
1324
+ return result?.id ?? null;
1230
1325
  }
1231
1326
 
1232
1327
  /**
1233
1328
  * Get repository ID from full_name.
1234
- *
1329
+ *
1235
1330
  * @param fullName - Repository full name
1236
1331
  * @returns Repository UUID or null if not found
1237
1332
  */
1238
1333
  export function getRepositoryIdByName(fullName: string): string | null {
1239
- return getRepositoryIdByNameInternal(getGlobalDatabase(), fullName);
1334
+ return getRepositoryIdByNameInternal(getGlobalDatabase(), fullName);
1240
1335
  }
1241
1336
 
1242
-
1243
1337
  // ============================================================================
1244
1338
  // Backward-compatible aliases that accept db parameter
1245
1339
  // These use the passed database (for tests) rather than the global one
@@ -1249,101 +1343,109 @@ export function getRepositoryIdByName(fullName: string): string | null {
1249
1343
  * @deprecated Use saveIndexedFiles() directly
1250
1344
  */
1251
1345
  export function saveIndexedFilesLocal(
1252
- db: KotaDatabase,
1253
- files: IndexedFile[],
1254
- repositoryId: string
1346
+ db: KotaDatabase,
1347
+ files: IndexedFile[],
1348
+ repositoryId: string,
1255
1349
  ): number {
1256
- return saveIndexedFilesInternal(db, files, repositoryId);
1350
+ return saveIndexedFilesInternal(db, files, repositoryId);
1257
1351
  }
1258
1352
 
1259
1353
  /**
1260
1354
  * @deprecated Use storeSymbols() directly
1261
1355
  */
1262
1356
  export function storeSymbolsLocal(
1263
- db: KotaDatabase,
1264
- symbols: ExtractedSymbol[],
1265
- fileId: string
1357
+ db: KotaDatabase,
1358
+ symbols: ExtractedSymbol[],
1359
+ fileId: string,
1266
1360
  ): number {
1267
- return storeSymbolsInternal(db, symbols, fileId);
1361
+ return storeSymbolsInternal(db, symbols, fileId);
1268
1362
  }
1269
1363
 
1270
1364
  /**
1271
1365
  * @deprecated Use storeReferences() directly
1272
1366
  */
1273
1367
  export function storeReferencesLocal(
1274
- db: KotaDatabase,
1275
- fileId: string,
1276
- filePath: string,
1277
- references: Reference[],
1278
- allFiles: Array<{ path: string }>,
1279
- pathMappings?: PathMappings | null
1368
+ db: KotaDatabase,
1369
+ fileId: string,
1370
+ filePath: string,
1371
+ references: Reference[],
1372
+ allFiles: Array<{ path: string }>,
1373
+ pathMappings?: PathMappings | null,
1280
1374
  ): number {
1281
- const repositoryId = db.queryOne<{ repository_id: string }>(
1282
- "SELECT repository_id FROM indexed_files WHERE id = ?",
1283
- [fileId]
1284
- )?.repository_id;
1285
-
1286
- if (!repositoryId) {
1287
- throw new Error(`File not found: ${fileId}`);
1288
- }
1289
-
1290
- return storeReferencesInternal(db, fileId, repositoryId, filePath, references, allFiles, pathMappings);
1375
+ const repositoryId = db.queryOne<{ repository_id: string }>(
1376
+ "SELECT repository_id FROM indexed_files WHERE id = ?",
1377
+ [fileId],
1378
+ )?.repository_id;
1379
+
1380
+ if (!repositoryId) {
1381
+ throw new Error(`File not found: ${fileId}`);
1382
+ }
1383
+
1384
+ return storeReferencesInternal(
1385
+ db,
1386
+ fileId,
1387
+ repositoryId,
1388
+ filePath,
1389
+ references,
1390
+ allFiles,
1391
+ pathMappings,
1392
+ );
1291
1393
  }
1292
1394
 
1293
1395
  /**
1294
1396
  * @deprecated Use searchFiles() directly
1295
1397
  */
1296
1398
  export function searchFilesLocal(
1297
- db: KotaDatabase,
1298
- term: string,
1299
- repositoryId: string | undefined,
1300
- limit: number
1399
+ db: KotaDatabase,
1400
+ term: string,
1401
+ repositoryId: string | undefined,
1402
+ limit: number,
1301
1403
  ): IndexedFile[] {
1302
- return searchFilesInternal(db, term, repositoryId, limit);
1404
+ return searchFilesInternal(db, term, repositoryId, limit);
1303
1405
  }
1304
1406
 
1305
1407
  /**
1306
1408
  * @deprecated Use listRecentFiles() directly
1307
1409
  */
1308
1410
  export function listRecentFilesLocal(
1309
- db: KotaDatabase,
1310
- limit: number,
1311
- repositoryId?: string,
1411
+ db: KotaDatabase,
1412
+ limit: number,
1413
+ repositoryId?: string,
1312
1414
  ): IndexedFile[] {
1313
- return listRecentFilesInternal(db, limit, repositoryId);
1415
+ return listRecentFilesInternal(db, limit, repositoryId);
1314
1416
  }
1315
1417
 
1316
1418
  /**
1317
1419
  * @deprecated Use resolveFilePath() directly
1318
1420
  */
1319
1421
  export function resolveFilePathLocal(
1320
- db: KotaDatabase,
1321
- filePath: string,
1322
- repositoryId: string
1422
+ db: KotaDatabase,
1423
+ filePath: string,
1424
+ repositoryId: string,
1323
1425
  ): string | null {
1324
- return resolveFilePathInternal(db, filePath, repositoryId);
1426
+ return resolveFilePathInternal(db, filePath, repositoryId);
1325
1427
  }
1326
1428
 
1327
1429
  /**
1328
1430
  * @deprecated Use ensureRepository() directly
1329
1431
  */
1330
1432
  export function ensureRepositoryLocal(
1331
- db: KotaDatabase,
1332
- fullName: string,
1333
- gitUrl?: string,
1334
- defaultBranch?: string
1433
+ db: KotaDatabase,
1434
+ fullName: string,
1435
+ gitUrl?: string,
1436
+ defaultBranch?: string,
1335
1437
  ): string {
1336
- return ensureRepositoryInternal(db, fullName, gitUrl, defaultBranch);
1438
+ return ensureRepositoryInternal(db, fullName, gitUrl, defaultBranch);
1337
1439
  }
1338
1440
 
1339
1441
  /**
1340
1442
  * @deprecated Use updateRepositoryLastIndexed() directly
1341
1443
  */
1342
1444
  export function updateRepositoryLastIndexedLocal(
1343
- db: KotaDatabase,
1344
- repositoryId: string
1445
+ db: KotaDatabase,
1446
+ repositoryId: string,
1345
1447
  ): void {
1346
- return updateRepositoryLastIndexedInternal(db, repositoryId);
1448
+ return updateRepositoryLastIndexedInternal(db, repositoryId);
1347
1449
  }
1348
1450
 
1349
1451
  /**
@@ -1351,10 +1453,10 @@ export function updateRepositoryLastIndexedLocal(
1351
1453
  * Version that accepts db parameter for testing.
1352
1454
  */
1353
1455
  export function isRepositoryIndexedLocal(
1354
- db: KotaDatabase,
1355
- repositoryId: string
1456
+ db: KotaDatabase,
1457
+ repositoryId: string,
1356
1458
  ): boolean {
1357
- return isRepositoryIndexedInternal(db, repositoryId);
1459
+ return isRepositoryIndexedInternal(db, repositoryId);
1358
1460
  }
1359
1461
 
1360
1462
  /**
@@ -1362,11 +1464,11 @@ export function isRepositoryIndexedLocal(
1362
1464
  * Version that accepts db parameter for testing.
1363
1465
  */
1364
1466
  export function deleteFileByPathLocal(
1365
- db: KotaDatabase,
1366
- repositoryId: string,
1367
- filePath: string
1467
+ db: KotaDatabase,
1468
+ repositoryId: string,
1469
+ filePath: string,
1368
1470
  ): boolean {
1369
- return deleteFileByPathInternal(db, repositoryId, filePath);
1471
+ return deleteFileByPathInternal(db, repositoryId, filePath);
1370
1472
  }
1371
1473
 
1372
1474
  /**
@@ -1374,11 +1476,11 @@ export function deleteFileByPathLocal(
1374
1476
  * Version that accepts db parameter for testing.
1375
1477
  */
1376
1478
  export function deleteFilesByPathsLocal(
1377
- db: KotaDatabase,
1378
- repositoryId: string,
1379
- filePaths: string[]
1479
+ db: KotaDatabase,
1480
+ repositoryId: string,
1481
+ filePaths: string[],
1380
1482
  ): { deletedCount: number; deletedPaths: string[] } {
1381
- return deleteFilesByPathsInternal(db, repositoryId, filePaths);
1483
+ return deleteFilesByPathsInternal(db, repositoryId, filePaths);
1382
1484
  }
1383
1485
 
1384
1486
  /**
@@ -1386,26 +1488,101 @@ export function deleteFilesByPathsLocal(
1386
1488
  * Version that accepts db parameter for testing.
1387
1489
  */
1388
1490
  export function getRepositoryIdByNameLocal(
1389
- db: KotaDatabase,
1390
- fullName: string
1491
+ db: KotaDatabase,
1492
+ fullName: string,
1391
1493
  ): string | null {
1392
- return getRepositoryIdByNameInternal(db, fullName);
1494
+ return getRepositoryIdByNameInternal(db, fullName);
1393
1495
  }
1394
1496
 
1395
1497
  // Add alias for runIndexingWorkflowLocal
1396
1498
  export const runIndexingWorkflowLocal = runIndexingWorkflow;
1397
1499
 
1398
-
1399
1500
  /**
1400
1501
  * Create default organization for a new user.
1401
- *
1502
+ *
1402
1503
  * @deprecated This function is not available in local-only mode.
1403
1504
  * Organizations are a cloud-only feature.
1404
1505
  */
1405
1506
  export async function createDefaultOrganization(
1406
- _client: unknown,
1407
- _userId: string,
1408
- _userEmail?: string,
1507
+ _client: unknown,
1508
+ _userId: string,
1509
+ _userEmail?: string,
1409
1510
  ): Promise<string> {
1410
- throw new Error('createDefaultOrganization() is not available in local-only mode - organizations are a cloud-only feature');
1511
+ throw new Error(
1512
+ "createDefaultOrganization() is not available in local-only mode - organizations are a cloud-only feature",
1513
+ );
1514
+ }
1515
+
1516
+ /**
1517
+ * Get index statistics for startup context display.
1518
+ * Queries counts of indexed files, symbols, references, and memory entries.
1519
+ *
1520
+ * @param db - Database instance (for testability)
1521
+ * @returns Statistics object with counts by type
1522
+ */
1523
+ function getIndexStatisticsInternal(db: KotaDatabase): {
1524
+ files: number;
1525
+ symbols: number;
1526
+ references: number;
1527
+ decisions: number;
1528
+ patterns: number;
1529
+ failures: number;
1530
+ repositories: number;
1531
+ } {
1532
+ const stats = {
1533
+ files: 0,
1534
+ symbols: 0,
1535
+ references: 0,
1536
+ decisions: 0,
1537
+ patterns: 0,
1538
+ failures: 0,
1539
+ repositories: 0,
1540
+ };
1541
+
1542
+ // Helper function to safely query count with fallback for missing tables
1543
+ const safeCount = (tableName: string): number => {
1544
+ try {
1545
+ const result = db.queryOne<{ count: number }>(
1546
+ `SELECT COUNT(*) as count FROM ${tableName}`,
1547
+ );
1548
+ return result?.count || 0;
1549
+ } catch (error) {
1550
+ // Table doesn't exist yet (e.g., memory layer tables)
1551
+ return 0;
1552
+ }
1553
+ };
1554
+
1555
+ // Count indexed files
1556
+ stats.files = safeCount("indexed_files");
1557
+
1558
+ // Count indexed symbols
1559
+ stats.symbols = safeCount("indexed_symbols");
1560
+
1561
+ // Count references
1562
+ stats.references = safeCount("indexed_references");
1563
+
1564
+ // Count decisions (may not exist in all installations)
1565
+ stats.decisions = safeCount("kota_decisions");
1566
+
1567
+ // Count patterns (may not exist in all installations)
1568
+ stats.patterns = safeCount("kota_patterns");
1569
+
1570
+ // Count failures (may not exist in all installations)
1571
+ stats.failures = safeCount("kota_failures");
1572
+
1573
+ // Count repositories
1574
+ stats.repositories = safeCount("repositories");
1575
+
1576
+ return stats;
1577
+ }
1578
+
1579
+ /**
1580
+ * Get index statistics for startup context display (public API).
1581
+ *
1582
+ * @returns Statistics object with counts by type
1583
+ */
1584
+ export function getIndexStatistics(): ReturnType<
1585
+ typeof getIndexStatisticsInternal
1586
+ > {
1587
+ return getIndexStatisticsInternal(getGlobalDatabase());
1411
1588
  }