sweet-search 0.0.1 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (161) hide show
  1. package/LICENSE +190 -0
  2. package/NOTICE +23 -0
  3. package/core/cli.js +51 -0
  4. package/core/config.js +27 -0
  5. package/core/embedding/embedding-cache.js +467 -0
  6. package/core/embedding/embedding-local-model.js +845 -0
  7. package/core/embedding/embedding-remote.js +492 -0
  8. package/core/embedding/embedding-service.js +712 -0
  9. package/core/embedding/embedding-telemetry.js +219 -0
  10. package/core/embedding/index.js +40 -0
  11. package/core/graph/community-detector.js +294 -0
  12. package/core/graph/graph-expansion.js +839 -0
  13. package/core/graph/graph-extractor.js +2304 -0
  14. package/core/graph/graph-search.js +2148 -0
  15. package/core/graph/hcgs-generator.js +666 -0
  16. package/core/graph/index.js +16 -0
  17. package/core/graph/leiden-algorithm.js +547 -0
  18. package/core/graph/relationship-resolver.js +366 -0
  19. package/core/graph/repo-map.js +408 -0
  20. package/core/graph/summary-manager.js +549 -0
  21. package/core/indexing/artifact-builder.js +1054 -0
  22. package/core/indexing/ast-chunker.js +709 -0
  23. package/core/indexing/chunking/chunk-builder.js +170 -0
  24. package/core/indexing/chunking/markdown-chunker.js +503 -0
  25. package/core/indexing/chunking/plaintext-chunker.js +104 -0
  26. package/core/indexing/dedup/dedup-phase.js +159 -0
  27. package/core/indexing/dedup/exemplar-selector.js +65 -0
  28. package/core/indexing/document-chunker.js +56 -0
  29. package/core/indexing/incremental-parser.js +390 -0
  30. package/core/indexing/incremental-tracker.js +761 -0
  31. package/core/indexing/index-codebase-v21.js +472 -0
  32. package/core/indexing/index-maintainer.mjs +1674 -0
  33. package/core/indexing/index.js +90 -0
  34. package/core/indexing/indexer-ann.js +1077 -0
  35. package/core/indexing/indexer-build.js +742 -0
  36. package/core/indexing/indexer-phases.js +800 -0
  37. package/core/indexing/indexer-pool.js +764 -0
  38. package/core/indexing/indexer-sparse-gram.js +98 -0
  39. package/core/indexing/indexer-utils.js +536 -0
  40. package/core/indexing/indexer-worker.js +148 -0
  41. package/core/indexing/li-skip-policy.js +225 -0
  42. package/core/indexing/merkle-tracker.js +244 -0
  43. package/core/indexing/model-pool.js +166 -0
  44. package/core/infrastructure/code-graph-repository.js +120 -0
  45. package/core/infrastructure/codebase-repository.js +131 -0
  46. package/core/infrastructure/config/dedup.js +54 -0
  47. package/core/infrastructure/config/embedding.js +298 -0
  48. package/core/infrastructure/config/graph.js +80 -0
  49. package/core/infrastructure/config/index.js +82 -0
  50. package/core/infrastructure/config/indexing.js +8 -0
  51. package/core/infrastructure/config/platform.js +254 -0
  52. package/core/infrastructure/config/ranking.js +221 -0
  53. package/core/infrastructure/config/search.js +396 -0
  54. package/core/infrastructure/config/translation.js +89 -0
  55. package/core/infrastructure/config/vector-store.js +114 -0
  56. package/core/infrastructure/constants.js +86 -0
  57. package/core/infrastructure/coreml-cascade.js +909 -0
  58. package/core/infrastructure/coreml-cascade.json +46 -0
  59. package/core/infrastructure/coreml-provider.js +81 -0
  60. package/core/infrastructure/db-utils.js +69 -0
  61. package/core/infrastructure/dedup-hashing.js +83 -0
  62. package/core/infrastructure/hardware-capability.js +332 -0
  63. package/core/infrastructure/index.js +104 -0
  64. package/core/infrastructure/language-patterns/maps.js +121 -0
  65. package/core/infrastructure/language-patterns/registry-core.js +323 -0
  66. package/core/infrastructure/language-patterns/registry-data-query.js +155 -0
  67. package/core/infrastructure/language-patterns/registry-object-oriented.js +285 -0
  68. package/core/infrastructure/language-patterns/registry-tooling.js +240 -0
  69. package/core/infrastructure/language-patterns/registry-web-style.js +143 -0
  70. package/core/infrastructure/language-patterns/registry.js +19 -0
  71. package/core/infrastructure/language-patterns.js +141 -0
  72. package/core/infrastructure/llm-provider.js +733 -0
  73. package/core/infrastructure/manifest.json +46 -0
  74. package/core/infrastructure/maxsim.wasm +0 -0
  75. package/core/infrastructure/model-fetcher.js +423 -0
  76. package/core/infrastructure/model-registry.js +214 -0
  77. package/core/infrastructure/native-inference.js +587 -0
  78. package/core/infrastructure/native-resolver.js +187 -0
  79. package/core/infrastructure/native-sparse-gram.js +257 -0
  80. package/core/infrastructure/native-tokenizer.js +160 -0
  81. package/core/infrastructure/onnx-mutex.js +45 -0
  82. package/core/infrastructure/onnx-session-utils.js +261 -0
  83. package/core/infrastructure/ort-pipeline.js +111 -0
  84. package/core/infrastructure/project-detector.js +102 -0
  85. package/core/infrastructure/quantization.js +410 -0
  86. package/core/infrastructure/simd-distance.js +502 -0
  87. package/core/infrastructure/simd-distance.wasm +0 -0
  88. package/core/infrastructure/tree-sitter-provider.js +665 -0
  89. package/core/infrastructure/webgpu-maxsim.js +222 -0
  90. package/core/query/index.js +35 -0
  91. package/core/query/intent-detector.js +201 -0
  92. package/core/query/intent-router.js +156 -0
  93. package/core/query/query-router-catboost.js +222 -0
  94. package/core/query/query-router-ml.js +266 -0
  95. package/core/query/query-router.js +213 -0
  96. package/core/ranking/cascaded-scorer.js +379 -0
  97. package/core/ranking/flashrank.js +810 -0
  98. package/core/ranking/index.js +49 -0
  99. package/core/ranking/late-interaction-index.js +2383 -0
  100. package/core/ranking/late-interaction-model.js +812 -0
  101. package/core/ranking/local-reranker.js +374 -0
  102. package/core/ranking/mmr.js +379 -0
  103. package/core/ranking/quality-scorer.js +363 -0
  104. package/core/search/context-expander.js +1167 -0
  105. package/core/search/dedup/sibling-expander.js +327 -0
  106. package/core/search/index.js +16 -0
  107. package/core/search/search-boost.js +259 -0
  108. package/core/search/search-cli.js +544 -0
  109. package/core/search/search-format.js +282 -0
  110. package/core/search/search-fusion.js +327 -0
  111. package/core/search/search-hybrid.js +204 -0
  112. package/core/search/search-pattern-chunks.js +337 -0
  113. package/core/search/search-pattern-planner.js +439 -0
  114. package/core/search/search-pattern-prefilter.js +412 -0
  115. package/core/search/search-pattern-ripgrep.js +663 -0
  116. package/core/search/search-pattern.js +463 -0
  117. package/core/search/search-postprocess.js +452 -0
  118. package/core/search/search-semantic.js +706 -0
  119. package/core/search/search-server.js +554 -0
  120. package/core/search/session-daemon-prewarm.mjs +164 -0
  121. package/core/search/session-warmup.js +595 -0
  122. package/core/search/sweet-search.js +632 -0
  123. package/core/search/warmup-metrics.js +532 -0
  124. package/core/start-server.js +6 -0
  125. package/core/training/query-router/features/extractor.js +762 -0
  126. package/core/training/query-router/features/multilingual-patterns.js +431 -0
  127. package/core/training/query-router/features/text-segmenter.js +303 -0
  128. package/core/training/query-router/features/unicode-utils.js +383 -0
  129. package/core/training/query-router/output/v45_router_d4.js +11521 -0
  130. package/core/training/query-router/output/v46_router_d4.js +11498 -0
  131. package/core/vector-store/binary-heap.js +227 -0
  132. package/core/vector-store/binary-hnsw-index.js +1004 -0
  133. package/core/vector-store/float-vector-store.js +234 -0
  134. package/core/vector-store/hnsw-index.js +580 -0
  135. package/core/vector-store/index.js +39 -0
  136. package/core/vector-store/seismic-index.js +498 -0
  137. package/core/vocabulary/index.js +84 -0
  138. package/core/vocabulary/vocab-constants.js +20 -0
  139. package/core/vocabulary/vocab-miner-extractors.js +375 -0
  140. package/core/vocabulary/vocab-miner-nl.js +404 -0
  141. package/core/vocabulary/vocab-miner-utils.js +146 -0
  142. package/core/vocabulary/vocab-miner.js +574 -0
  143. package/core/vocabulary/vocab-prewarm-cli.js +110 -0
  144. package/core/vocabulary/vocab-ranker.js +492 -0
  145. package/core/vocabulary/vocab-warmer.js +523 -0
  146. package/core/vocabulary/vocab-warmup-orchestrator.js +425 -0
  147. package/core/vocabulary/vocabulary-utils.js +704 -0
  148. package/crates/wasm-router/pkg/package.json +13 -0
  149. package/crates/wasm-router/pkg/query_router_wasm.d.ts +36 -0
  150. package/crates/wasm-router/pkg/query_router_wasm.js +271 -0
  151. package/crates/wasm-router/pkg/query_router_wasm_bg.wasm +0 -0
  152. package/crates/wasm-router/pkg/query_router_wasm_bg.wasm.d.ts +19 -0
  153. package/mcp/config-gen.js +121 -0
  154. package/mcp/server.js +335 -0
  155. package/mcp/tool-handlers.js +476 -0
  156. package/package.json +131 -9
  157. package/scripts/benchmark-harness.js +794 -0
  158. package/scripts/init.js +1058 -0
  159. package/scripts/smoke-test.js +435 -0
  160. package/scripts/uninstall.js +478 -0
  161. package/scripts/verify-runtime.js +176 -0
@@ -0,0 +1,2148 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Graph-Expanded Search
5
+ *
6
+ * Fast lexical search with code graph expansion:
7
+ * - FTS5/BM25 for initial matches (with trigram fuzzy search)
8
+ * - Graph traversal to find related entities
9
+ * - Weighted scoring based on relationship types
10
+ *
11
+ * Target: <10ms p50 for lexical queries
12
+ *
13
+ * Uses better-sqlite3 for native FTS5 with trigram tokenizer support.
14
+ */
15
+
16
+ import { existsSync } from 'fs';
17
+ import { DB_PATHS, GRAPH_CONFIG } from '../infrastructure/config/index.js';
18
+ import { applyReadPragmas } from '../infrastructure/db-utils.js';
19
+ import { detectIntent, getIntentBoost } from '../query/intent-detector.js';
20
+ import { applyMMR, shouldApplyMMR } from '../ranking/mmr.js';
21
+ import { SYMBOL_KIND_WEIGHTS, DEFINITION_TYPES } from '../infrastructure/constants.js';
22
+
23
+ // Fix 9: Abbreviation expansion dictionary for common software abbreviations
24
+ const ABBREVIATION_EXPANSIONS = {
25
+ auth: ['authentication', 'authorize'],
26
+ cfg: ['config', 'configuration'],
27
+ btn: ['button'],
28
+ repo: ['repository'],
29
+ req: ['request'],
30
+ res: ['response'],
31
+ impl: ['implementation'],
32
+ env: ['environment'],
33
+ msg: ['message'],
34
+ err: ['error'],
35
+ ctx: ['context'],
36
+ db: ['database'],
37
+ conn: ['connection'],
38
+ mgr: ['manager'],
39
+ svc: ['service'],
40
+ };
41
+
42
+ // =============================================================================
43
+ // GRAPH SEARCH CLASS
44
+ // =============================================================================
45
+
46
+ export class GraphSearch {
47
+ constructor(dbPath = DB_PATHS.codeGraph) {
48
+ this.dbPath = dbPath;
49
+ this.db = null;
50
+ this.hasFts5 = false;
51
+ this.hasTrigram = false;
52
+ }
53
+
54
+ /**
55
+ * Initialize database connection (synchronous with better-sqlite3)
56
+ */
57
+ async init() {
58
+ if (this.db) return;
59
+
60
+ if (!existsSync(this.dbPath)) {
61
+ throw new Error(`Code graph database not found at ${this.dbPath}. Run graph indexing first.`);
62
+ }
63
+
64
+ // Use better-sqlite3 for native FTS5 with trigram support
65
+ const Database = (await import('better-sqlite3')).default;
66
+ const db = new Database(this.dbPath, { readonly: true });
67
+
68
+ try {
69
+ applyReadPragmas(db, { tempStoreMemory: true });
70
+
71
+ // Check if FTS5 tables exist
72
+ try {
73
+ const ftsCheck = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='entities_fts'").get();
74
+ this.hasFts5 = !!ftsCheck;
75
+
76
+ const trigramCheck = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='entities_trigram'").get();
77
+ this.hasTrigram = !!trigramCheck;
78
+ } catch {
79
+ this.hasFts5 = false;
80
+ this.hasTrigram = false;
81
+ }
82
+
83
+ // Detect FTS5 column count to warn about schema mismatches with old databases.
84
+ // The bm25() weights are positional — wrong column count produces wrong ranking.
85
+ if (this.hasFts5) {
86
+ try {
87
+ const ftsColInfo = db.prepare("SELECT * FROM entities_fts LIMIT 0").columns();
88
+ this._ftsColumnCount = ftsColInfo.length;
89
+ const expectedCols = 4; // name, name_alias, signature, doc_comment
90
+ if (this._ftsColumnCount !== expectedCols) {
91
+ console.warn(
92
+ `[GraphSearch] FTS5 schema mismatch: expected ${expectedCols} columns, found ${this._ftsColumnCount}. ` +
93
+ `BM25 weights will be misaligned. Run \`/index-codebase --full\` to rebuild.`
94
+ );
95
+ }
96
+ } catch {
97
+ // Non-fatal — continue with FTS5
98
+ }
99
+ }
100
+
101
+ // Cache hot-path prepared statements (eliminates ~8-9µs per prepare() call)
102
+ // Prepare failures on optional FTS paths should degrade to existing fallbacks.
103
+ if (this.hasFts5) {
104
+ try {
105
+ this._stmtFts5 = db.prepare(`
106
+ SELECT
107
+ e.id, e.file_path, e.type, e.name, e.signature,
108
+ e.doc_comment, e.start_line, e.end_line, e.package, e.parent_class,
109
+ bm25(entities_fts, 10.0, 4.0, 5.0, 1.0) AS score
110
+ FROM entities_fts
111
+ JOIN entities e ON entities_fts.rowid = e.rowid
112
+ WHERE entities_fts MATCH ?
113
+ AND e.stale_since IS NULL
114
+ ORDER BY score
115
+ LIMIT ?
116
+ `);
117
+ } catch {
118
+ this.hasFts5 = false;
119
+ this._stmtFts5 = null;
120
+ }
121
+ }
122
+
123
+ if (this.hasTrigram) {
124
+ try {
125
+ this._stmtTrigram = db.prepare(`
126
+ SELECT
127
+ e.id, e.file_path, e.type, e.name, e.signature,
128
+ e.doc_comment, e.start_line, e.end_line, e.package, e.parent_class,
129
+ bm25(entities_trigram, 10.0, 3.0) AS score
130
+ FROM entities_trigram
131
+ JOIN entities e ON entities_trigram.rowid = e.rowid
132
+ WHERE entities_trigram MATCH ?
133
+ AND e.stale_since IS NULL
134
+ ORDER BY score
135
+ LIMIT ?
136
+ `);
137
+ } catch {
138
+ this.hasTrigram = false;
139
+ this._stmtTrigram = null;
140
+ }
141
+ }
142
+
143
+ this._stmtEntityById = db.prepare(
144
+ 'SELECT * FROM entities WHERE id = ? AND stale_since IS NULL'
145
+ );
146
+
147
+ this._stmtOutRels = db.prepare(`
148
+ SELECT e.*, r.type as rel_type, r.weight as rel_weight
149
+ FROM relationships r
150
+ JOIN entities e ON e.id = r.target_id OR e.name = r.target_name
151
+ WHERE r.source_id = ?
152
+ AND e.stale_since IS NULL
153
+ LIMIT 20
154
+ `);
155
+
156
+ this._stmtInRels = db.prepare(`
157
+ SELECT e.*, r.type as rel_type, r.weight as rel_weight
158
+ FROM relationships r
159
+ JOIN entities e ON e.id = r.source_id
160
+ WHERE (r.target_id = ? OR r.target_name = (SELECT name FROM entities WHERE id = ?))
161
+ AND e.stale_since IS NULL
162
+ LIMIT 20
163
+ `);
164
+
165
+ this.db = db;
166
+ } catch (err) {
167
+ this.hasFts5 = false;
168
+ this.hasTrigram = false;
169
+ this._stmtFts5 = null;
170
+ this._stmtTrigram = null;
171
+ this._stmtEntityById = null;
172
+ this._stmtOutRels = null;
173
+ this._stmtInRels = null;
174
+ db.close();
175
+ throw err;
176
+ }
177
+ }
178
+
179
+ /**
180
+ * Map a raw FTS5/trigram/LIKE row to a search result object.
181
+ */
182
+ _mapRow(row, source, scoreMultiplier = 1.0) {
183
+ return {
184
+ id: row.id,
185
+ file: row.file_path,
186
+ type: row.type,
187
+ name: row.name,
188
+ signature: row.signature,
189
+ docComment: row.doc_comment,
190
+ startLine: row.start_line,
191
+ endLine: row.end_line,
192
+ package: row.package,
193
+ parentClass: row.parent_class,
194
+ score: Math.abs(row.score) * scoreMultiplier,
195
+ source,
196
+ };
197
+ }
198
+
199
+ /**
200
+ * Merge new rows into results, deduplicating by id.
201
+ */
202
+ _mergeRows(results, rows, source, scoreMultiplier = 1.0) {
203
+ const existingIds = new Set(results.map(r => r.id));
204
+ for (const row of rows) {
205
+ if (!existingIds.has(row.id)) {
206
+ results.push(this._mapRow(row, source, scoreMultiplier));
207
+ existingIds.add(row.id);
208
+ }
209
+ }
210
+ }
211
+
212
+ /**
213
+ * LIKE-based fallback search (used when FTS5 is unavailable or returns no results).
214
+ */
215
+ _likeFallback(results, query, limit, source = 'like') {
216
+ const searchTerms = query.toLowerCase().split(/\s+/).filter(t => t.length > 1);
217
+ const likeConditions = searchTerms.map(() => 'search_text LIKE ?').join(' AND ');
218
+ const likeParams = searchTerms.map(t => `%${t}%`);
219
+
220
+ const stmt = this.db.prepare(`
221
+ SELECT
222
+ id, file_path, type, name, signature, doc_comment,
223
+ start_line, end_line, package, parent_class, search_text
224
+ FROM entities
225
+ WHERE ${likeConditions || '1=1'}
226
+ AND stale_since IS NULL
227
+ ORDER BY
228
+ CASE WHEN name LIKE ? THEN 0 ELSE 1 END,
229
+ length(name)
230
+ LIMIT ?
231
+ `);
232
+
233
+ const rows = stmt.all(...likeParams, `%${query}%`, limit);
234
+
235
+ let rank = 0;
236
+ for (const row of rows) {
237
+ rank++;
238
+ const nameMatch = row.name.toLowerCase().includes(query.toLowerCase()) ? 2 : 0;
239
+ const exactMatch = row.name.toLowerCase() === query.toLowerCase() ? 5 : 0;
240
+ row.score = -(10 - rank * 0.1 + nameMatch + exactMatch);
241
+ const mapped = this._mapRow(row, source);
242
+ mapped.score = Math.max(0.1, mapped.score);
243
+ results.push(mapped);
244
+ }
245
+ }
246
+
247
+ /**
248
+ * Fix 11: Compute lexical path observability metadata.
249
+ */
250
+ _computeLexicalMeta(results, query, options = {}) {
251
+ const {
252
+ restrictedAttempted = false,
253
+ restrictedFallback = false,
254
+ definitionFirstUsed = false,
255
+ definitionFirstTopHit = false,
256
+ } = options;
257
+ const sources = new Set(results.map(r => r.source));
258
+ const hasFtsSource = Array.from(sources).some(source => source.startsWith('fts5'));
259
+ const hasTrigramSource = Array.from(sources).some(source => source.startsWith('trigram'));
260
+ let searchQuality = 'fallback';
261
+ if (hasFtsSource) {
262
+ searchQuality = 'exact';
263
+ } else if (hasTrigramSource) {
264
+ searchQuality = 'fuzzy';
265
+ }
266
+
267
+ const lexicalMeta = {
268
+ searchQuality,
269
+ lexicalPath: hasFtsSource ? 'fts5' : (hasTrigramSource ? 'trigram' : 'like'),
270
+ identifierRestricted: restrictedAttempted && !restrictedFallback,
271
+ restrictedAttempted,
272
+ restrictedFallback,
273
+ abbreviationExpanded: sources.has('fts5_abbr'),
274
+ variantExpanded: sources.has('fts5_expanded'),
275
+ definitionFirstUsed,
276
+ definitionFirstTopHit,
277
+ };
278
+
279
+ return { searchQuality, lexicalMeta };
280
+ }
281
+
282
+ /**
283
+ * Find an entity by name (non-stale).
284
+ * Returns { id, name, type, signature, summary, file_path, start_line } or null.
285
+ */
286
+ async findEntityByName(name) {
287
+ await this.init();
288
+ if (!this._stmtEntityByName) {
289
+ this._stmtEntityByName = this.db.prepare(`
290
+ SELECT id, name, type, signature, summary, file_path, start_line
291
+ FROM entities
292
+ WHERE name = ? AND stale_since IS NULL
293
+ LIMIT 1
294
+ `);
295
+ }
296
+ return this._stmtEntityByName.get(name) || null;
297
+ }
298
+
299
+ /**
300
+ * Find an entity by file location (non-stale).
301
+ * Returns { id, name, type, signature, summary, file_path, start_line } or null.
302
+ */
303
+ async findEntityByLocation(filePath, line) {
304
+ await this.init();
305
+ if (!this._stmtEntityByLocation) {
306
+ this._stmtEntityByLocation = this.db.prepare(`
307
+ SELECT id, name, type, signature, summary, file_path, start_line
308
+ FROM entities
309
+ WHERE file_path LIKE ? AND start_line <= ? AND end_line >= ?
310
+ AND stale_since IS NULL
311
+ LIMIT 1
312
+ `);
313
+ }
314
+ return this._stmtEntityByLocation.get(`%${filePath}%`, line, line) || null;
315
+ }
316
+
317
+ /**
318
+ * Batch-find entities by name and location for a results array.
319
+ * Two queries total (one per lookup type) instead of n sequential lookups.
320
+ * @param {Array} results - search results with name/file/startLine fields
321
+ * @returns {{ byName: Map<string, Object>, byLocation: Map<string, Object> }}
322
+ */
323
+ async findEntitiesBatch(results) {
324
+ await this.init();
325
+ const byName = new Map();
326
+ const byLocation = new Map();
327
+
328
+ // Collect unique names
329
+ const names = new Set();
330
+ const locations = [];
331
+ for (const r of results) {
332
+ const name = r.name || r.metadata?.name;
333
+ const file = r.file || r.metadata?.file;
334
+ const line = r.startLine || r.metadata?.startLine;
335
+ if (name) names.add(name);
336
+ if (file && line) locations.push({ file, line });
337
+ }
338
+
339
+ // Batch name lookup
340
+ if (names.size > 0) {
341
+ if (!this._stmtEntityByName) {
342
+ this._stmtEntityByName = this.db.prepare(`
343
+ SELECT id, name, type, signature, summary, file_path, start_line
344
+ FROM entities
345
+ WHERE name = ? AND stale_since IS NULL
346
+ LIMIT 1
347
+ `);
348
+ }
349
+ for (const name of names) {
350
+ const entity = this._stmtEntityByName.get(name);
351
+ if (entity) byName.set(name, entity);
352
+ }
353
+ }
354
+
355
+ // Batch location lookup (only for results not found by name)
356
+ if (locations.length > 0) {
357
+ if (!this._stmtEntityByLocation) {
358
+ this._stmtEntityByLocation = this.db.prepare(`
359
+ SELECT id, name, type, signature, summary, file_path, start_line
360
+ FROM entities
361
+ WHERE file_path LIKE ? AND start_line <= ? AND end_line >= ?
362
+ AND stale_since IS NULL
363
+ LIMIT 1
364
+ `);
365
+ }
366
+ for (const { file, line } of locations) {
367
+ const key = `${file}:${line}`;
368
+ if (byLocation.has(key)) continue;
369
+ const entity = this._stmtEntityByLocation.get(`%${file}%`, line, line);
370
+ if (entity) byLocation.set(key, entity);
371
+ }
372
+ }
373
+
374
+ return { byName, byLocation };
375
+ }
376
+
377
+ /**
378
+ * Get IDs of all stale entities.
379
+ * @returns {Set<string>}
380
+ */
381
+ async getStaleEntityIds() {
382
+ await this.init();
383
+ const result = new Set();
384
+ try {
385
+ const rows = this.db.prepare('SELECT id FROM entities WHERE stale_since IS NOT NULL').all();
386
+ for (const row of rows) result.add(row.id);
387
+ } catch {
388
+ // Column may not exist in older indexes
389
+ }
390
+ return result;
391
+ }
392
+
393
+ /**
394
+ * Run lightweight queries to warm the SQLite page cache.
395
+ * Touches FTS5, relationship, and summary pages.
396
+ * @returns {{ summaries: number }}
397
+ */
398
+ async warmCache() {
399
+ await this.init();
400
+ let summaries = 0;
401
+ try { this.db.prepare('SELECT count(*) FROM entities_fts WHERE name MATCH "warmup"').get(); } catch { /* table may not exist */ }
402
+ try { this.db.prepare('SELECT count(*) FROM relationships').get(); } catch { /* table may not exist */ }
403
+ try {
404
+ const row = this.db.prepare('SELECT count(*) as cnt FROM entities WHERE summary IS NOT NULL').get();
405
+ summaries = row?.cnt || 0;
406
+ } catch { /* column may not exist */ }
407
+ return { summaries };
408
+ }
409
+
410
+ /**
411
+ * Close database connection
412
+ */
413
+ close() {
414
+ this._stmtFts5 = null;
415
+ this._stmtTrigram = null;
416
+ this._stmtEntityById = null;
417
+ this._stmtOutRels = null;
418
+ this._stmtInRels = null;
419
+ this._stmtEntityByName = null;
420
+ this._stmtEntityByLocation = null;
421
+ if (this.db) {
422
+ this.db.close();
423
+ this.db = null;
424
+ }
425
+ }
426
+
427
+ /**
428
+ * Fast text search (FTS5 with trigram fuzzy + LIKE fallback)
429
+ *
430
+ * @param {string} query - Search query
431
+ * @param {Object|number} options - Options object or limit (for backwards compat)
432
+ * @param {number} [options.limit=20] - Maximum results to return
433
+ * @param {boolean} [options.skipBoosts=false] - Skip post-FTS5 ranking boosts (for fair hybrid fusion)
434
+ * @returns {Promise<{results: Array, latency: number}>}
435
+ */
436
+ async bm25Search(query, options = {}) {
437
+ // Support legacy signature: bm25Search(query, limit)
438
+ const opts = typeof options === 'number' ? { limit: options } : options;
439
+ const { limit = 20, skipBoosts = false } = opts;
440
+ await this.init();
441
+
442
+ const start = Date.now();
443
+ let results = [];
444
+ let restrictedAttempted = false;
445
+ let restrictedFallback = false;
446
+
447
+ if (this.hasFts5) {
448
+ // Fix 4: Try name:-restricted FTS5 query first for code-shaped identifiers
449
+ const useNameRestriction = this.isStrictIdentifierQuery(query);
450
+ let rows = [];
451
+
452
+ if (useNameRestriction) {
453
+ restrictedAttempted = true;
454
+ try {
455
+ rows = this._stmtFts5.all(`name : ${this.sanitizeFtsQuery(query)}`, limit);
456
+ } catch (err) {
457
+ this.log(`[bm25Search] Name-restricted FTS5 query failed: ${err.message}`);
458
+ rows = [];
459
+ }
460
+ }
461
+
462
+ // Fall back to unrestricted FTS5 if name-restricted returned nothing
463
+ if (rows.length === 0) {
464
+ restrictedFallback = restrictedAttempted;
465
+ try {
466
+ rows = this._stmtFts5.all(this.sanitizeFtsQuery(query), limit);
467
+ } catch (err) {
468
+ this.log(`[bm25Search] FTS5 query failed: ${err.message}`);
469
+ rows = [];
470
+ }
471
+ }
472
+
473
+ for (const row of rows) {
474
+ results.push(this._mapRow(row, 'fts5'));
475
+ }
476
+ }
477
+
478
+ // Try trigram fuzzy search if standard FTS5 returned few results
479
+ // This enables "Auth" → "AuthenticationService" matching
480
+ if (this.hasTrigram && results.length < 3 && query.length >= 3) {
481
+ try {
482
+ // Trigram search - just use the raw query (trigram handles substrings)
483
+ const escaped = query.replace(/"/g, '""');
484
+ const trigramRows = this._stmtTrigram.all(`"${escaped}"`, limit);
485
+
486
+ this._mergeRows(results, trigramRows, 'trigram', 0.9);
487
+ } catch (err) {
488
+ this.log(`[bm25Search] Trigram query failed: ${err.message}`);
489
+ }
490
+ }
491
+
492
+ // Fix 8: Identifier variant expansion — run secondary expanded query for code-shaped identifiers
493
+ if (this.hasFts5 && results.length < 5 && this.isStrictIdentifierQuery(query)) {
494
+ try {
495
+ const expandedForm = this.expandIdentifierQuery(query);
496
+ if (expandedForm && expandedForm !== this.sanitizeFtsQuery(query)) {
497
+ const expandedRows = this._stmtFts5.all(expandedForm, limit);
498
+ this._mergeRows(results, expandedRows, 'fts5_expanded', 0.85);
499
+ }
500
+ } catch (err) {
501
+ this.log(`[bm25Search] Identifier expansion query failed: ${err.message}`);
502
+ }
503
+ }
504
+
505
+ // Fix 9: Abbreviation expansion — try expanding abbreviations when results are sparse
506
+ if (this.hasFts5 && results.length < 3) {
507
+ try {
508
+ const abbrQuery = this.expandAbbreviations(query);
509
+ if (abbrQuery) {
510
+ const abbrRows = this._stmtFts5.all(abbrQuery, limit);
511
+ this._mergeRows(results, abbrRows, 'fts5_abbr', 0.8);
512
+ }
513
+ } catch (err) {
514
+ this.log(`[bm25Search] Abbreviation expansion failed: ${err.message}`);
515
+ }
516
+ }
517
+
518
+ if (!this.hasFts5 || results.length === 0) {
519
+ this._likeFallback(results, query, limit, 'like');
520
+ }
521
+
522
+ const latency = Date.now() - start;
523
+
524
+ // Apply post-FTS5 ranking boosts (Strategies #1, #19, #4, #20)
525
+ // Skip boosts if skipBoosts=true (for fair hybrid fusion - boosts applied post-fusion)
526
+ const finalResults = skipBoosts ? results : this.applyRankingBoosts(results, query);
527
+
528
+ // Fix 11: Lexical path observability
529
+ const { searchQuality, lexicalMeta } = this._computeLexicalMeta(finalResults, query, {
530
+ restrictedAttempted,
531
+ restrictedFallback,
532
+ });
533
+
534
+ this.log(`[bm25Search] quality=${searchQuality} path=${lexicalMeta.lexicalPath} restricted=${lexicalMeta.identifierRestricted} fallback=${lexicalMeta.restrictedFallback} results=${finalResults.length} latency=${latency}ms`);
535
+
536
+ return { results: finalResults, latency, searchQuality, lexicalMeta };
537
+ }
538
+
539
+ /**
540
+ * Raw BM25 search for hybrid fusion (PHASE_1_FIXES Change 1)
541
+ *
542
+ * Returns raw FTS5/BM25 scores WITHOUT post-FTS5 boosts or flood control.
543
+ * Used by hybrid search to ensure fair CC fusion between lexical and semantic paths.
544
+ *
545
+ * @param {string} query - Search query
546
+ * @param {number} [limit=50] - Maximum results to return
547
+ * @returns {Promise<{results: Array, latency: number}>}
548
+ */
549
+ async bm25SearchRaw(query, limit = 50) {
550
+ await this.init();
551
+
552
+ const start = Date.now();
553
+ let results = [];
554
+
555
+ if (this.hasFts5) {
556
+ // Fix 4: Name-restricted FTS5 query for code-shaped identifiers (plan requires both bm25Search & Raw)
557
+ const useNameRestriction = this.isStrictIdentifierQuery(query);
558
+ let rows = [];
559
+
560
+ if (useNameRestriction) {
561
+ try {
562
+ rows = this._stmtFts5.all(`name : ${this.sanitizeFtsQuery(query)}`, limit);
563
+ } catch (err) {
564
+ this.log(`[bm25SearchRaw] Name-restricted FTS5 query failed: ${err.message}`);
565
+ rows = [];
566
+ }
567
+ }
568
+
569
+ if (rows.length === 0) {
570
+ try {
571
+ rows = this._stmtFts5.all(this.sanitizeFtsQuery(query), limit);
572
+ } catch (err) {
573
+ this.log(`[bm25SearchRaw] FTS5 query failed: ${err.message}`);
574
+ rows = [];
575
+ }
576
+ }
577
+
578
+ for (const row of rows) {
579
+ results.push(this._mapRow(row, 'fts5_raw'));
580
+ }
581
+ }
582
+
583
+ // Try trigram fuzzy search if standard FTS5 returned few results
584
+ if (this.hasTrigram && results.length < 3 && query.length >= 3) {
585
+ try {
586
+ const escapedRaw = query.replace(/"/g, '""');
587
+ const trigramRows = this._stmtTrigram.all(`"${escapedRaw}"`, limit);
588
+ this._mergeRows(results, trigramRows, 'trigram_raw', 0.9);
589
+ } catch (err) {
590
+ this.log(`[bm25SearchRaw] Trigram query failed: ${err.message}`);
591
+ }
592
+ }
593
+
594
+ // LIKE fallback for no FTS5
595
+ if (!this.hasFts5 || results.length === 0) {
596
+ this._likeFallback(results, query, limit, 'like_raw');
597
+ }
598
+
599
+ const latency = Date.now() - start;
600
+
601
+ // NO boosts, NO flood control - return raw scores for fair fusion
602
+ return { results, latency };
603
+ }
604
+
605
+ // =============================================================================
606
+ // POST-FTS5 RANKING BOOST STRATEGIES (#1, #19, #4, #20)
607
+ // =============================================================================
608
+
609
+ // SYMBOL_KIND_WEIGHTS and DEFINITION_TYPES imported from constants.js
610
+
611
+ /**
612
+ * Apply all post-FTS5 ranking boosts to search results.
613
+ *
614
+ * Implements 4 strategies:
615
+ * - Strategy #1: Definition Boost - prioritize files that DEFINE the queried entity
616
+ * - Strategy #19: Definition Syntax Boost - detect definition patterns in signatures
617
+ * - Strategy #4: Symbol Kind Hierarchy - weight by entity type importance
618
+ * - Strategy #20: Structural Position Boost - prioritize early-in-file entities
619
+ *
620
+ * @param {Array<Object>} results - BM25 search results with score, name, type, file, startLine, signature
621
+ * @param {string} query - Original search query
622
+ * @returns {Array<Object>} Results with boosted scores, re-sorted by score descending
623
+ *
624
+ * @example
625
+ * // AuthService.java defining "class AuthService" will get:
626
+ * // - 2.0x from Strategy #1 (filename match + definition type)
627
+ * // - 1.8x from Strategy #19 (contains "class AuthService")
628
+ * // - 1.0x from Strategy #4 (class = highest weight)
629
+ * // - ~1.24x from Strategy #20 (if at line 10)
630
+ * // Total: score *= 2.0 * 1.8 * 1.0 * 1.24 = 4.46x boost
631
+ */
632
+ applyRankingBoosts(results, query) {
633
+ if (!results || results.length === 0) {
634
+ return results;
635
+ }
636
+
637
+ const queryLower = query.toLowerCase().trim();
638
+ const queryTokens = this.extractQueryTokens(query);
639
+ const debugMode = process.env.DEBUG_GRAPH_SEARCH;
640
+
641
+ // Strategy #5: Detect query intent once for all results
642
+ const { intent, confidence } = detectIntent(query);
643
+ const applyIntentBoost = intent !== 'general' && confidence >= 0.7;
644
+
645
+ if (debugMode && applyIntentBoost) {
646
+ this.log(`Intent: ${intent} (confidence: ${confidence.toFixed(2)})`);
647
+ }
648
+
649
+ const boostedResults = results.map(result => {
650
+ let totalBoost = 1.0;
651
+ const boostDetails = [];
652
+
653
+ // Strategy #1: Post-FTS5 Definition Boost
654
+ const defBoost = this.applyDefinitionBoost(result, queryLower, queryTokens);
655
+ if (defBoost > 1.0) {
656
+ totalBoost *= defBoost;
657
+ boostDetails.push(`def:${defBoost.toFixed(2)}`);
658
+ }
659
+
660
+ // Strategy #19: Definition Syntax Boost
661
+ const syntaxBoost = this.applyDefinitionSyntaxBoost(result, queryTokens);
662
+ if (syntaxBoost > 1.0) {
663
+ totalBoost *= syntaxBoost;
664
+ boostDetails.push(`syntax:${syntaxBoost.toFixed(2)}`);
665
+ }
666
+
667
+ // Strategy #4: Symbol Kind Hierarchy
668
+ const kindWeight = this.applySymbolKindWeight(result);
669
+ // Apply as soft boost: score *= (0.5 + 0.5 * kindWeight) to avoid crushing low types
670
+ const kindBoost = 0.5 + 0.5 * kindWeight;
671
+ totalBoost *= kindBoost;
672
+ if (kindWeight !== 1.0) {
673
+ boostDetails.push(`kind:${kindBoost.toFixed(2)}`);
674
+ }
675
+
676
+ // Strategy #20: Structural Position Boost
677
+ const posBoost = this.applyPositionBoost(result);
678
+ if (posBoost > 1.0) {
679
+ totalBoost *= posBoost;
680
+ boostDetails.push(`pos:${posBoost.toFixed(2)}`);
681
+ }
682
+
683
+ // Strategy #5: Intent-based Boost
684
+ if (applyIntentBoost) {
685
+ const intentBoost = getIntentBoost(intent, result.type);
686
+ if (intentBoost > 1.0) {
687
+ totalBoost *= intentBoost;
688
+ boostDetails.push(`intent:${intentBoost.toFixed(2)}`);
689
+ }
690
+ }
691
+
692
+ // Fix 6: Path-aware lexical signal boost
693
+ const pathBoost = this.applyPathBoost(result, queryTokens);
694
+ if (pathBoost > 1.0) {
695
+ totalBoost *= pathBoost;
696
+ boostDetails.push(`path:${pathBoost.toFixed(2)}`);
697
+ }
698
+
699
+ const boostedScore = result.score * totalBoost;
700
+
701
+ if (debugMode && totalBoost > 1.0) {
702
+ this.log(`Boost ${result.name}: ${result.score.toFixed(3)} -> ${boostedScore.toFixed(3)} (${boostDetails.join(', ')})`);
703
+ }
704
+
705
+ return {
706
+ ...result,
707
+ score: boostedScore,
708
+ _originalScore: result.score,
709
+ _boostFactor: totalBoost,
710
+ };
711
+ });
712
+
713
+ // Re-sort by boosted score descending
714
+ boostedResults.sort((a, b) => b.score - a.score);
715
+
716
+ // Apply MMR diversification if needed (replaces Strategy #21 flood control)
717
+ if (shouldApplyMMR(boostedResults)) {
718
+ const { results: diversified, stats } = applyMMR(boostedResults, { k: boostedResults.length, lambda: 0.9 });
719
+ if (debugMode) {
720
+ this.log(`MMR: ${stats.candidates} → ${stats.selected} (λ=${stats.lambda}, ${stats.reorderCount} reordered)`);
721
+ }
722
+ return diversified;
723
+ }
724
+
725
+ return boostedResults;
726
+ }
727
+
728
+ /**
729
+ * Extract normalized tokens from query for matching.
730
+ * Handles PascalCase, camelCase, and space-separated terms.
731
+ *
732
+ * @param {string} query - Search query
733
+ * @returns {string[]} Lowercase tokens
734
+ *
735
+ * @example
736
+ * extractQueryTokens("AuthService") // ["auth", "service", "authservice"]
737
+ * extractQueryTokens("user login") // ["user", "login"]
738
+ */
739
+ extractQueryTokens(query) {
740
+ const tokens = new Set();
741
+ const trimmed = query.trim();
742
+
743
+ if (!trimmed) {
744
+ return [];
745
+ }
746
+
747
+ // Add full query as token
748
+ tokens.add(trimmed.toLowerCase());
749
+
750
+ // Split on identifier boundaries and path-like separators.
751
+ const normalized = trimmed
752
+ .replace(/([A-Z]{2,})([A-Z][a-z])/g, '$1 $2')
753
+ .replace(/([a-z])([A-Z])/g, '$1 $2')
754
+ .replace(/([a-zA-Z])(\d)/g, '$1 $2')
755
+ .replace(/(\d)([a-zA-Z])/g, '$1 $2')
756
+ .replace(/[_\-./:\\]/g, ' ');
757
+
758
+ const words = normalized.toLowerCase().split(/\s+/).filter(Boolean);
759
+ for (const word of words) {
760
+ if (word.length > 0) {
761
+ tokens.add(word);
762
+ }
763
+ }
764
+
765
+ const collapsed = words.join('');
766
+ if (collapsed) {
767
+ tokens.add(collapsed);
768
+ }
769
+
770
+ return Array.from(tokens);
771
+ }
772
+
773
+ /**
774
+ * Strategy #1: Post-FTS5 Definition Boost
775
+ *
776
+ * Prioritizes results that DEFINE the queried entity over results that merely
777
+ * reference or use it. A search for "AuthService" should rank AuthService.java
778
+ * (the definition) above LoginController.java (a caller).
779
+ *
780
+ * @param {Object} result - Search result with file, name, type properties
781
+ * @param {string} queryLower - Lowercase query string
782
+ * @param {string[]} queryTokens - Query tokens for matching
783
+ * @returns {number} Boost multiplier (1.0 = no boost, 2.0 = strong boost)
784
+ *
785
+ * Boost logic:
786
+ * - Filename matches query AND is definition type -> 2.0x
787
+ * - Entity name matches exactly AND is definition type -> 1.5x
788
+ * - Any definition type -> 1.2x
789
+ * - Otherwise -> 1.0x (no boost)
790
+ */
791
+ applyDefinitionBoost(result, queryLower, queryTokens) {
792
+ const isDefinitionType = DEFINITION_TYPES.has(result.type);
793
+ const resultNameLower = (result.name || '').toLowerCase();
794
+
795
+ // Extract filename without extension
796
+ const filePath = result.file || '';
797
+ const fileName = filePath.split('/').pop() || '';
798
+ const fileNameNoExt = fileName.replace(/\.[^.]+$/, '').toLowerCase();
799
+
800
+ // Check if filename matches query (e.g., query="AuthService", file="AuthService.java")
801
+ const filenameMatchesQuery = queryTokens.some(token =>
802
+ fileNameNoExt === token || fileNameNoExt.includes(token)
803
+ );
804
+
805
+ // Check if entity name exactly matches query
806
+ const exactNameMatch = queryTokens.some(token => resultNameLower === token);
807
+
808
+ // Apply boost based on match strength
809
+ if (filenameMatchesQuery && isDefinitionType) {
810
+ // Strongest signal: file is named after query and contains definition
811
+ return 2.0;
812
+ }
813
+
814
+ if (exactNameMatch && isDefinitionType) {
815
+ // Strong signal: entity name matches and is a definition
816
+ return 1.5;
817
+ }
818
+
819
+ if (isDefinitionType) {
820
+ // Weak signal: at least it's a definition type
821
+ return 1.2;
822
+ }
823
+
824
+ return 1.0;
825
+ }
826
+
827
+ /**
828
+ * Strategy #19: Definition Syntax Boost
829
+ *
830
+ * Detects language-specific definition patterns in entity signatures/content.
831
+ * Boosts entities whose signatures explicitly define the queried symbol.
832
+ *
833
+ * @param {Object} result - Search result with signature, name, type properties
834
+ * @param {string[]} queryTokens - Query tokens for pattern matching
835
+ * @returns {number} Boost multiplier (1.0 = no boost, 1.8 = strong boost)
836
+ *
837
+ * Patterns detected:
838
+ * - Java: "class <Q>", "interface <Q>", "enum <Q>", "public class <Q>"
839
+ * - JS/TS: "class <Q>", "function <Q>", "export class <Q>", "export function <Q>"
840
+ * - General: "<visibility> <type> <Q>" patterns
841
+ */
842
+ applyDefinitionSyntaxBoost(result, queryTokens) {
843
+ const signature = (result.signature || '').toLowerCase();
844
+ if (!signature) return 1.0;
845
+
846
+ // Definition syntax patterns (order matters - check strongest first)
847
+ const definitionPatterns = [
848
+ // Java patterns
849
+ /\b(?:public|private|protected)?\s*(?:abstract|final)?\s*class\s+(\w+)/,
850
+ /\b(?:public|private|protected)?\s*interface\s+(\w+)/,
851
+ /\b(?:public|private|protected)?\s*enum\s+(\w+)/,
852
+ /\b(?:public|private|protected)?\s*(?:static)?\s*(?:abstract)?\s*(?:final)?\s*\w+\s+(\w+)\s*\(/,
853
+
854
+ // JavaScript/TypeScript patterns
855
+ /\bclass\s+(\w+)/,
856
+ /\bfunction\s+(\w+)/,
857
+ /\bexport\s+(?:default\s+)?class\s+(\w+)/,
858
+ /\bexport\s+(?:default\s+)?function\s+(\w+)/,
859
+ /\bconst\s+(\w+)\s*=/,
860
+ /\bexport\s+const\s+(\w+)\s*=/,
861
+
862
+ // TypeScript specific
863
+ /\binterface\s+(\w+)/,
864
+ /\btype\s+(\w+)\s*=/,
865
+ /\benum\s+(\w+)/,
866
+ ];
867
+
868
+ for (const pattern of definitionPatterns) {
869
+ const match = signature.match(pattern);
870
+ if (match && match[1]) {
871
+ const definedName = match[1].toLowerCase();
872
+ // Check if the defined name matches any query token
873
+ if (queryTokens.some(token => definedName === token || definedName.includes(token))) {
874
+ return 1.8;
875
+ }
876
+ }
877
+ }
878
+
879
+ return 1.0;
880
+ }
881
+
882
+ /**
883
+ * Strategy #4: Symbol Kind Hierarchy Weight
884
+ *
885
+ * Applies importance weights based on entity type. Classes and interfaces
886
+ * are generally more important than variables or imports.
887
+ *
888
+ * @param {Object} result - Search result with type property
889
+ * @returns {number} Weight multiplier (0.1 - 1.0)
890
+ *
891
+ * Hierarchy (highest to lowest):
892
+ * - class, interface, struct (0.95-1.0): Primary definitions
893
+ * - function, method (0.80-0.85): Behavior definitions
894
+ * - constant, property, field (0.6-0.7): Data definitions
895
+ * - variable, parameter (0.3-0.4): Local scope
896
+ * - reference, call, import (0.1-0.2): Usage patterns
897
+ */
898
+ applySymbolKindWeight(result) {
899
+ const entityType = (result.type || '').toLowerCase();
900
+ return SYMBOL_KIND_WEIGHTS[entityType] ?? 0.5;
901
+ }
902
+
903
+ /**
904
+ * Strategy #20: Structural Position Boost
905
+ *
906
+ * Boosts entities that appear early in files, as definitions typically
907
+ * appear near the top (after imports). This helps prioritize class declarations
908
+ * over method implementations deep in the file.
909
+ *
910
+ * @param {Object} result - Search result with startLine and type properties
911
+ * @returns {number} Boost multiplier (1.0 - 1.3 for definitions)
912
+ *
913
+ * Formula:
914
+ * - positionPrior = 1 / (1 + startLine / 50), clamped to [0.5, 1.0]
915
+ * - For definition types: score *= (1 + 0.3 * positionPrior)
916
+ * - For non-definitions: no boost (return 1.0)
917
+ */
918
+ applyPositionBoost(result) {
919
+ const startLine = result.startLine;
920
+ if (startLine == null || startLine < 0) return 1.0;
921
+
922
+ // Only apply position boost to definition types
923
+ if (!DEFINITION_TYPES.has(result.type)) {
924
+ return 1.0;
925
+ }
926
+
927
+ // Calculate position prior: higher for entities near the top
928
+ // Line 1 -> prior = 0.98
929
+ // Line 50 -> prior = 0.5
930
+ // Line 100 -> prior = 0.33 (clamped to 0.5)
931
+ const rawPrior = 1 / (1 + startLine / 50);
932
+ const positionPrior = Math.max(0.5, Math.min(1.0, rawPrior));
933
+
934
+ // Apply boost: definitions at top get up to 1.3x, at bottom get 1.15x
935
+ return 1 + 0.3 * positionPrior;
936
+ }
937
+
938
+ /**
939
+ * Fix 6: Path-aware lexical signal boost.
940
+ * Boosts results where the file path/basename contains query tokens.
941
+ */
942
+ applyPathBoost(result, queryTokens) {
943
+ if (!result.file) return 1.0;
944
+
945
+ const filePath = result.file.toLowerCase();
946
+ const parts = filePath.split('/');
947
+ const basename = parts[parts.length - 1]?.replace(/\.[^.]+$/, '') || '';
948
+
949
+ let boost = 1.0;
950
+
951
+ for (const token of queryTokens) {
952
+ if (token.length < 2) continue;
953
+
954
+ if (basename === token) {
955
+ // Exact basename match: highest boost
956
+ boost = Math.max(boost, 1.5);
957
+ } else if (basename.includes(token)) {
958
+ // Basename contains token
959
+ boost = Math.max(boost, 1.25);
960
+ } else if (parts.some(seg => seg.includes(token))) {
961
+ // Directory segment contains token
962
+ boost = Math.max(boost, 1.1);
963
+ }
964
+ }
965
+
966
+ return boost;
967
+ }
968
+
969
+ /**
970
+ * Search by entity name (exact match)
971
+ */
972
+ async searchByName(name, type = null) {
973
+ await this.init();
974
+
975
+ let query = `SELECT * FROM entities WHERE name = ? AND stale_since IS NULL`;
976
+ const params = [name];
977
+
978
+ if (type) {
979
+ query += ' AND type = ?';
980
+ params.push(type);
981
+ }
982
+
983
+ query += ' LIMIT 50';
984
+
985
+ const stmt = this.db.prepare(query);
986
+ return stmt.all(...params);
987
+ }
988
+
989
+ /**
990
+ * Search by file path pattern
991
+ */
992
+ async searchByFile(pathPattern) {
993
+ await this.init();
994
+
995
+ const stmt = this.db.prepare(`
996
+ SELECT * FROM entities
997
+ WHERE file_path LIKE ?
998
+ AND stale_since IS NULL
999
+ ORDER BY start_line
1000
+ LIMIT 100
1001
+ `);
1002
+
1003
+ return stmt.all(`%${pathPattern}%`);
1004
+ }
1005
+
1006
+ /**
1007
+ * Graph expansion - find related entities
1008
+ *
1009
+ * P2 FIX: Batched queries to eliminate N+1 problem
1010
+ * Before: O(n) queries for n entities (getEntity + getRelated in loop)
1011
+ * After: O(1) batched queries per hop (single query for all entities + relationships)
1012
+ */
1013
+ async expandGraph(entityIds, maxHops = GRAPH_CONFIG.expansion.maxHops) {
1014
+ await this.init();
1015
+
1016
+ const expanded = new Map();
1017
+ let frontier = new Set(entityIds);
1018
+
1019
+ for (let hop = 0; hop < maxHops; hop++) {
1020
+ const nextFrontier = new Set();
1021
+
1022
+ // P2 FIX: Batch fetch all entities in frontier at once
1023
+ const frontierIds = Array.from(frontier).filter(id => !expanded.has(id));
1024
+ if (frontierIds.length === 0) break;
1025
+
1026
+ // Single query for all entities in this hop
1027
+ const placeholders = frontierIds.map(() => '?').join(',');
1028
+ const entities = this.db.prepare(`
1029
+ SELECT * FROM entities
1030
+ WHERE id IN (${placeholders})
1031
+ AND stale_since IS NULL
1032
+ `).all(...frontierIds);
1033
+
1034
+ // Create lookup map for fast access
1035
+ const entityMap = new Map(entities.map(e => [e.id, e]));
1036
+
1037
+ // P2 FIX: Batch fetch all relationships (outgoing + incoming) in single query
1038
+ const relationships = this.db.prepare(`
1039
+ SELECT r.*, 'out' as direction, r.source_id as origin_id
1040
+ FROM relationships r
1041
+ JOIN entities e ON r.target_id = e.id
1042
+ WHERE r.source_id IN (${placeholders})
1043
+ AND e.stale_since IS NULL
1044
+ UNION ALL
1045
+ SELECT r.*, 'in' as direction, r.target_id as origin_id
1046
+ FROM relationships r
1047
+ JOIN entities e ON r.source_id = e.id
1048
+ WHERE r.target_id IN (${placeholders})
1049
+ AND e.stale_since IS NULL
1050
+ `).all(...frontierIds, ...frontierIds);
1051
+
1052
+ // Group relationships by their origin entity
1053
+ const relMap = new Map();
1054
+ for (const rel of relationships) {
1055
+ const key = rel.origin_id;
1056
+ if (!relMap.has(key)) relMap.set(key, []);
1057
+ relMap.get(key).push(rel);
1058
+ }
1059
+
1060
+ // Process all entities without additional queries
1061
+ for (const entityId of frontierIds) {
1062
+ const entity = entityMap.get(entityId);
1063
+ if (entity) {
1064
+ expanded.set(entityId, {
1065
+ ...entity,
1066
+ hop,
1067
+ source: hop === 0 ? 'direct' : 'graph',
1068
+ });
1069
+ }
1070
+
1071
+ // Get related entities from pre-fetched relationships
1072
+ const related = relMap.get(entityId) || [];
1073
+ for (const rel of related) {
1074
+ const relatedId = rel.direction === 'out' ? rel.target_id : rel.source_id;
1075
+ if (relatedId && !expanded.has(relatedId) && !frontier.has(relatedId)) {
1076
+ nextFrontier.add(relatedId);
1077
+ }
1078
+ }
1079
+ }
1080
+
1081
+ frontier = nextFrontier;
1082
+ if (frontier.size === 0) break;
1083
+ }
1084
+
1085
+ return Array.from(expanded.values());
1086
+ }
1087
+
1088
+ /**
1089
+ * Get single entity by ID
1090
+ */
1091
+ async getEntity(entityId) {
1092
+ await this.init();
1093
+ return this._stmtEntityById.get(entityId) || null;
1094
+ }
1095
+
1096
+ /**
1097
+ * Get entities related to given entity
1098
+ */
1099
+ async getRelated(entityId) {
1100
+ await this.init();
1101
+
1102
+ const results = [];
1103
+
1104
+ // Find outgoing relationships (cached prepared statement)
1105
+ const outRows = this._stmtOutRels.all(entityId);
1106
+ for (const row of outRows) {
1107
+ results.push({
1108
+ ...row,
1109
+ direction: 'outgoing',
1110
+ });
1111
+ }
1112
+
1113
+ // Find incoming relationships (cached prepared statement)
1114
+ const inRows = this._stmtInRels.all(entityId, entityId);
1115
+ for (const row of inRows) {
1116
+ results.push({
1117
+ ...row,
1118
+ direction: 'incoming',
1119
+ });
1120
+ }
1121
+
1122
+ return results;
1123
+ }
1124
+
1125
+ /**
1126
+ * Check if query is an exact match for top result (used for adaptive expansion).
1127
+ * Skips expensive graph traversal when we have a clear winner.
1128
+ * D4 FIX: Added JSDoc documentation
1129
+ *
1130
+ * @param {string} query - The search query
1131
+ * @param {Object} topResult - The top search result
1132
+ * @param {Object} [secondResult] - Optional second result for score gap comparison
1133
+ * @returns {boolean} True if top result is an exact/near-exact match
1134
+ */
1135
+ isExactMatchResult(query, topResult, secondResult = null) {
1136
+ if (!topResult) return false;
1137
+
1138
+ const queryLower = query.toLowerCase().trim();
1139
+ const nameLower = (topResult.name || '').toLowerCase();
1140
+
1141
+ // Criterion 1: Name exactly matches query (case-insensitive)
1142
+ if (nameLower === queryLower) {
1143
+ return true;
1144
+ }
1145
+
1146
+ // Criterion 2: High absolute score (BM25 > 30 indicates strong match)
1147
+ if (topResult.score > 30.0) {
1148
+ return true;
1149
+ }
1150
+
1151
+ // Criterion 3: Score significantly higher than #2 (2x or more)
1152
+ if (secondResult && topResult.score >= secondResult.score * 2) {
1153
+ return true;
1154
+ }
1155
+
1156
+ // Criterion 4: Query is a camelCase/PascalCase identifier that matches name
1157
+ // e.g., "AuthService" matching "AuthService" or "AuthenticationService"
1158
+ if (/^[A-Z][a-zA-Z0-9]*$/.test(query) && nameLower.includes(queryLower)) {
1159
+ return true;
1160
+ }
1161
+
1162
+ return false;
1163
+ }
1164
+
1165
+ /**
1166
+ * Log helper (can be overridden for testing)
1167
+ */
1168
+ log(message) {
1169
+ if (process.env.DEBUG_GRAPH_SEARCH) {
1170
+ console.error(`[GraphSearch] ${message}`);
1171
+ }
1172
+ }
1173
+
1174
+ /**
1175
+ * Graph-expanded search: BM25 + graph traversal
1176
+ *
1177
+ * ADAPTIVE EXPANSION: Skips expensive graph expansion when BM25 returns
1178
+ * a high-confidence exact match, making "hits" as fast as "misses" (<10ms).
1179
+ *
1180
+ * DEFINITION-FIRST (Strategy #2): For identifier queries, uses two-pass search
1181
+ * to guarantee definition files appear in top-3 results.
1182
+ */
1183
+ async graphExpandedSearch(query, options = {}) {
1184
+ const {
1185
+ k = 10,
1186
+ expand = true,
1187
+ maxHops = GRAPH_CONFIG.expansion.maxHops,
1188
+ maxExpanded = GRAPH_CONFIG.expansion.maxExpanded,
1189
+ skipExpansionForExactMatch = true, // Adaptive expansion (default: enabled)
1190
+ useDefinitionFirst = null, // Auto-detect if null, force on/off if boolean
1191
+ skipBoosts = false, // Skip ranking boosts (for fair hybrid fusion)
1192
+ deferExpansion = false, // When true, ambiguous queries skip internal expansion (lexical-only path)
1193
+ } = options;
1194
+
1195
+ const start = Date.now();
1196
+
1197
+ // STRATEGY #2: Use definition-first search for identifier queries
1198
+ // This ensures "AuthService" returns AuthService.java at position 1
1199
+ const shouldUseDefinitionFirst = useDefinitionFirst ?? this.isIdentifierQuery(query);
1200
+
1201
+ if (shouldUseDefinitionFirst) {
1202
+ this.log(`Using definition-first search for identifier query: "${query}"`);
1203
+
1204
+ // Two-pass search: definitions in parallel with BM25
1205
+ const { results: definitionResults, stats: defStats } = await this.hybridDefinitionSearch(query, { k, limit: 20, skipBoosts });
1206
+
1207
+ // If we got definition results, optionally expand graph from them
1208
+ if (definitionResults.length > 0 && expand) {
1209
+ const topResult = definitionResults[0];
1210
+ const secondResult = definitionResults[1] || null;
1211
+ const isExactMatch = skipExpansionForExactMatch &&
1212
+ this.isExactMatchResult(query, topResult, secondResult);
1213
+
1214
+ if (isExactMatch) {
1215
+ this.log(`Skipping graph expansion: exact definition match found (${topResult.name})`);
1216
+ return {
1217
+ results: definitionResults.slice(0, k),
1218
+ stats: {
1219
+ bm25_ms: defStats.fts5_latency_ms,
1220
+ definition_ms: defStats.latency_ms,
1221
+ graph_ms: 0,
1222
+ total_ms: Date.now() - start,
1223
+ direct_matches: definitionResults.length,
1224
+ expanded_total: definitionResults.length,
1225
+ mode: 'definition_first_exact',
1226
+ confidence: 'exact',
1227
+ definition_count: defStats.definition_count,
1228
+ skipped_expansion: true,
1229
+ },
1230
+ };
1231
+ }
1232
+
1233
+ if (deferExpansion) {
1234
+ // Lexical-only path: defer expansion to postprocess expandResults()
1235
+ // (adaptive hop2, alpha decay, degree normalization, cosine scoring)
1236
+ this.log(`Definition-first ambiguous: deferring expansion to postprocess`);
1237
+ return {
1238
+ results: definitionResults.slice(0, k),
1239
+ stats: {
1240
+ bm25_ms: defStats.fts5_latency_ms,
1241
+ definition_ms: defStats.latency_ms,
1242
+ graph_ms: 0,
1243
+ total_ms: Date.now() - start,
1244
+ direct_matches: definitionResults.length,
1245
+ expanded_total: definitionResults.length,
1246
+ mode: 'definition_first_ambiguous',
1247
+ confidence: 'ambiguous',
1248
+ definition_count: defStats.definition_count,
1249
+ skipped_expansion: true,
1250
+ },
1251
+ };
1252
+ }
1253
+
1254
+ // Internal expansion (used by hybrid and direct callers)
1255
+ const graphStart = Date.now();
1256
+ const expanded = new Map();
1257
+
1258
+ for (const result of definitionResults) {
1259
+ expanded.set(result.id, { ...result, source: result.source || 'definition_pass' });
1260
+ }
1261
+
1262
+ const entityIds = definitionResults.map(r => r.id);
1263
+ for (let hop = 1; hop <= maxHops && expanded.size < maxExpanded; hop++) {
1264
+ for (const entityId of entityIds.slice(0, 5)) {
1265
+ const related = await this.getRelated(entityId);
1266
+
1267
+ for (const rel of related) {
1268
+ if (!expanded.has(rel.id)) {
1269
+ const relMultiplier = this.getRelTypeMultiplier(rel.rel_type);
1270
+ const parentResult = definitionResults.find(r => r.id === entityId);
1271
+ const relScore = (parentResult?.score || 1) * rel.rel_weight * relMultiplier * 0.8;
1272
+
1273
+ expanded.set(rel.id, {
1274
+ id: rel.id,
1275
+ file: rel.file_path,
1276
+ type: rel.type,
1277
+ name: rel.name,
1278
+ signature: rel.signature,
1279
+ docComment: rel.doc_comment,
1280
+ startLine: rel.start_line,
1281
+ endLine: rel.end_line,
1282
+ score: relScore,
1283
+ relType: rel.rel_type,
1284
+ source: 'graph',
1285
+ hop,
1286
+ });
1287
+ }
1288
+ }
1289
+ }
1290
+ }
1291
+
1292
+ const graphLatency = Date.now() - graphStart;
1293
+ const ranked = Array.from(expanded.values())
1294
+ .sort((a, b) => b.score - a.score)
1295
+ .slice(0, k);
1296
+
1297
+ return {
1298
+ results: ranked,
1299
+ stats: {
1300
+ bm25_ms: defStats.fts5_latency_ms,
1301
+ definition_ms: defStats.latency_ms,
1302
+ graph_ms: graphLatency,
1303
+ total_ms: Date.now() - start,
1304
+ direct_matches: definitionResults.length,
1305
+ expanded_total: expanded.size,
1306
+ mode: 'definition_first_graph',
1307
+ confidence: 'ambiguous',
1308
+ definition_count: defStats.definition_count,
1309
+ skipped_expansion: false,
1310
+ },
1311
+ };
1312
+ }
1313
+
1314
+ // No expansion needed or no results - return definition results
1315
+ return {
1316
+ results: definitionResults.slice(0, k),
1317
+ stats: {
1318
+ bm25_ms: defStats.fts5_latency_ms,
1319
+ definition_ms: defStats.latency_ms,
1320
+ graph_ms: 0,
1321
+ total_ms: Date.now() - start,
1322
+ direct_matches: definitionResults.length,
1323
+ expanded_total: definitionResults.length,
1324
+ mode: 'definition_first_only',
1325
+ confidence: 'exact',
1326
+ definition_count: defStats.definition_count,
1327
+ skipped_expansion: true,
1328
+ },
1329
+ };
1330
+ }
1331
+
1332
+ // Standard BM25 path for non-identifier queries
1333
+ // Step 1: BM25 search for initial matches
1334
+ // Pass skipBoosts for fair hybrid fusion
1335
+ const { results: bm25Results, latency: bm25Latency } = await this.bm25Search(query, { limit: 20, skipBoosts });
1336
+
1337
+ if (bm25Results.length === 0) {
1338
+ return {
1339
+ results: [],
1340
+ stats: {
1341
+ bm25_ms: bm25Latency,
1342
+ total_ms: Date.now() - start,
1343
+ mode: 'bm25_only',
1344
+ confidence: 'exact',
1345
+ },
1346
+ };
1347
+ }
1348
+
1349
+ if (!expand) {
1350
+ return {
1351
+ results: bm25Results.slice(0, k),
1352
+ stats: {
1353
+ bm25_ms: bm25Latency,
1354
+ total_ms: Date.now() - start,
1355
+ mode: 'bm25_only',
1356
+ confidence: 'exact',
1357
+ },
1358
+ };
1359
+ }
1360
+
1361
+ // ADAPTIVE EXPANSION: Skip graph traversal for exact matches
1362
+ // This prevents "hits" from being slower than "misses"
1363
+ const topResult = bm25Results[0];
1364
+ const secondResult = bm25Results[1] || null;
1365
+ const isExactMatch = skipExpansionForExactMatch &&
1366
+ this.isExactMatchResult(query, topResult, secondResult);
1367
+
1368
+ if (isExactMatch) {
1369
+ this.log(`Skipping graph expansion: exact match found (${topResult.name}, score=${topResult.score.toFixed(2)})`);
1370
+ return {
1371
+ results: bm25Results.slice(0, k),
1372
+ stats: {
1373
+ bm25_ms: bm25Latency,
1374
+ graph_ms: 0,
1375
+ total_ms: Date.now() - start,
1376
+ direct_matches: bm25Results.length,
1377
+ expanded_total: bm25Results.length,
1378
+ mode: 'bm25_exact_match',
1379
+ confidence: 'exact',
1380
+ skipped_expansion: true,
1381
+ },
1382
+ };
1383
+ }
1384
+
1385
+ if (deferExpansion) {
1386
+ // Lexical-only path: defer expansion to postprocess expandResults()
1387
+ this.log(`BM25 ambiguous: deferring expansion to postprocess`);
1388
+ return {
1389
+ results: bm25Results.slice(0, k),
1390
+ stats: {
1391
+ bm25_ms: bm25Latency,
1392
+ graph_ms: 0,
1393
+ total_ms: Date.now() - start,
1394
+ direct_matches: bm25Results.length,
1395
+ expanded_total: bm25Results.length,
1396
+ mode: 'bm25_ambiguous',
1397
+ confidence: 'ambiguous',
1398
+ skipped_expansion: true,
1399
+ },
1400
+ };
1401
+ }
1402
+
1403
+ // Internal expansion (used by hybrid and direct callers)
1404
+ const graphStart = Date.now();
1405
+ const expanded = new Map();
1406
+
1407
+ for (const result of bm25Results) {
1408
+ expanded.set(result.id, { ...result, source: 'direct' });
1409
+ }
1410
+
1411
+ const entityIds = bm25Results.map(r => r.id);
1412
+ for (let hop = 1; hop <= maxHops && expanded.size < maxExpanded; hop++) {
1413
+ for (const entityId of entityIds.slice(0, 5)) {
1414
+ const related = await this.getRelated(entityId);
1415
+
1416
+ for (const rel of related) {
1417
+ if (!expanded.has(rel.id)) {
1418
+ const relMultiplier = this.getRelTypeMultiplier(rel.rel_type);
1419
+ const parentResult = bm25Results.find(r => r.id === entityId);
1420
+ const relScore = (parentResult?.score || 1) * rel.rel_weight * relMultiplier * 0.8;
1421
+
1422
+ expanded.set(rel.id, {
1423
+ id: rel.id,
1424
+ file: rel.file_path,
1425
+ type: rel.type,
1426
+ name: rel.name,
1427
+ signature: rel.signature,
1428
+ docComment: rel.doc_comment,
1429
+ startLine: rel.start_line,
1430
+ endLine: rel.end_line,
1431
+ score: relScore,
1432
+ relType: rel.rel_type,
1433
+ source: 'graph',
1434
+ hop,
1435
+ });
1436
+ }
1437
+ }
1438
+ }
1439
+ }
1440
+
1441
+ const graphLatency = Date.now() - graphStart;
1442
+
1443
+ const ranked = Array.from(expanded.values())
1444
+ .sort((a, b) => b.score - a.score)
1445
+ .slice(0, k);
1446
+
1447
+ return {
1448
+ results: ranked,
1449
+ stats: {
1450
+ bm25_ms: bm25Latency,
1451
+ graph_ms: graphLatency,
1452
+ total_ms: Date.now() - start,
1453
+ direct_matches: bm25Results.length,
1454
+ expanded_total: expanded.size,
1455
+ mode: 'bm25_graph',
1456
+ confidence: 'ambiguous',
1457
+ skipped_expansion: false,
1458
+ },
1459
+ };
1460
+ }
1461
+
1462
+ /**
1463
+ * Get score multiplier for relationship type in graph expansion.
1464
+ * Higher values = more important relationships to follow.
1465
+ * D5 FIX: Added JSDoc documentation
1466
+ *
1467
+ * @param {string} relType - Relationship type ('calls', 'implements', 'extends', etc.)
1468
+ * @returns {number} Score multiplier (0.0 - 1.0)
1469
+ *
1470
+ * @example
1471
+ * getRelTypeMultiplier('implements') // 0.9 - strong signal
1472
+ * getRelTypeMultiplier('calls') // 0.7 - medium signal
1473
+ * getRelTypeMultiplier('unknown') // 0.5 - fallback
1474
+ */
1475
+ getRelTypeMultiplier(relType) {
1476
+ const multipliers = {
1477
+ implements: 0.9,
1478
+ extends: 0.85,
1479
+ overrides: 0.8,
1480
+ calls: 0.7,
1481
+ throws: 0.6,
1482
+ uses: 0.5,
1483
+ imports: 0.3,
1484
+ };
1485
+ return multipliers[relType] || 0.5;
1486
+ }
1487
+
1488
+ /**
1489
+ * Sanitize query for FTS5 (escape special characters)
1490
+ */
1491
+ sanitizeFtsQuery(query) {
1492
+ // Remove FTS5 special characters
1493
+ let sanitized = query.replace(/[":*^~()+\-]/g, ' ');
1494
+
1495
+ // Convert to prefix match if single word
1496
+ const words = sanitized.trim().split(/\s+/).filter(w => w.length > 0);
1497
+
1498
+ if (words.length === 1) {
1499
+ // Single word: use prefix matching
1500
+ return `"${words[0]}"*`;
1501
+ }
1502
+
1503
+ // Multiple words: use AND logic with prefix on last word
1504
+ return words.map((w, i) => {
1505
+ if (i === words.length - 1) {
1506
+ return `"${w}"*`;
1507
+ }
1508
+ return `"${w}"`;
1509
+ }).join(' ');
1510
+ }
1511
+
1512
+ /**
1513
+ * Fix 8: Expand a code-shaped identifier into split-form FTS5 query.
1514
+ * Preserves the original as primary; this produces the secondary expanded form.
1515
+ *
1516
+ * @example
1517
+ * expandIdentifierQuery('getUserName') // '"get" "user" "name"*'
1518
+ * expandIdentifierQuery('UserService') // '"user" "service"*'
1519
+ */
1520
+ expandIdentifierQuery(query) {
1521
+ const split = query
1522
+ .replace(/([A-Z]{2,})([A-Z][a-z])/g, '$1 $2')
1523
+ .replace(/([a-z])([A-Z])/g, '$1 $2')
1524
+ .replace(/([a-zA-Z])(\d)/g, '$1 $2')
1525
+ .replace(/(\d)([a-zA-Z])/g, '$1 $2')
1526
+ .replace(/[_\-./:\\]/g, ' ');
1527
+
1528
+ const parts = split.trim().split(/\s+/).filter(w => w.length > 0);
1529
+ if (parts.length <= 1) return null; // No expansion needed for single tokens
1530
+
1531
+ return parts.map((w, i) => {
1532
+ const lower = w.toLowerCase();
1533
+ if (i === parts.length - 1) {
1534
+ return `"${lower}"*`;
1535
+ }
1536
+ return `"${lower}"`;
1537
+ }).join(' ');
1538
+ }
1539
+
1540
+ /**
1541
+ * Fix 9: Expand abbreviations in a query using the curated dictionary.
1542
+ * Returns a grouped FTS5 query, or null if no expansions apply.
1543
+ * Single-word queries expand to OR groups; two-word queries preserve the
1544
+ * non-abbreviation term as an AND requirement to avoid noisy broad matches.
1545
+ */
1546
+ expandAbbreviations(query) {
1547
+ const sanitizedWords = query
1548
+ .replace(/[":*^~()+\-]/g, ' ')
1549
+ .trim()
1550
+ .toLowerCase()
1551
+ .split(/\s+/)
1552
+ .filter(Boolean);
1553
+
1554
+ // Only expand single-word or two-word queries to avoid explosion
1555
+ if (sanitizedWords.length === 0 || sanitizedWords.length > 2) return null;
1556
+
1557
+ let changed = false;
1558
+ const groups = sanitizedWords.map(word => {
1559
+ const variants = [word, ...(ABBREVIATION_EXPANSIONS[word] || [])];
1560
+ const uniqueVariants = [...new Set(variants)];
1561
+ if (uniqueVariants.length > 1) {
1562
+ changed = true;
1563
+ }
1564
+
1565
+ const clauses = uniqueVariants.map(variant => `"${variant}"*`);
1566
+ return clauses.length === 1 ? clauses[0] : `(${clauses.join(' OR ')})`;
1567
+ });
1568
+
1569
+ return changed ? groups.join(' ') : null;
1570
+ }
1571
+
1572
+ // =============================================================================
1573
+ // STRATEGY #2: TWO-PASS DEFINITION-FIRST SEARCH
1574
+ // =============================================================================
1575
+
1576
+ /**
1577
+ * Check if query looks like an identifier (PascalCase, camelCase, or snake_case).
1578
+ * Used to decide whether to prioritize definition-first search.
1579
+ *
1580
+ * @param {string} query - The search query
1581
+ * @returns {boolean} True if query matches identifier patterns
1582
+ *
1583
+ * @example
1584
+ * isIdentifierQuery('AuthService') // true (PascalCase)
1585
+ * isIdentifierQuery('employeeService') // true (camelCase)
1586
+ * isIdentifierQuery('get_employee') // true (snake_case)
1587
+ * isIdentifierQuery('how does auth work') // false (natural language)
1588
+ */
1589
+ isIdentifierQuery(query) {
1590
+ const trimmed = query.trim();
1591
+ // Skip multi-word queries (likely natural language)
1592
+ if (/\s/.test(trimmed)) return false;
1593
+
1594
+ // PascalCase: starts with uppercase, alphanumeric (AuthService, EmployeeDTO)
1595
+ if (/^[A-Z][a-zA-Z0-9]*$/.test(trimmed)) return true;
1596
+
1597
+ // camelCase: starts with lowercase, has at least one uppercase (getUserById)
1598
+ if (/^[a-z][a-zA-Z0-9]*$/.test(trimmed) && /[A-Z]/.test(trimmed)) return true;
1599
+
1600
+ // snake_case: lowercase with underscores (get_employee, user_id)
1601
+ if (/^[a-z][a-z0-9_]*$/.test(trimmed) && trimmed.includes('_')) return true;
1602
+
1603
+ // Single lowercase word that could be a simple identifier (auth, user)
1604
+ if (/^[a-z][a-z0-9]*$/.test(trimmed) && trimmed.length >= 3) return true;
1605
+
1606
+ return false;
1607
+ }
1608
+
1609
+ /**
1610
+ * Check if query is a clearly code-shaped identifier suitable for name: field restriction.
1611
+ * More restrictive than isIdentifierQuery() — excludes generic single lowercase words
1612
+ * like "cache", "error", "token" that should search all fields.
1613
+ *
1614
+ * Matches: PascalCase, camelCase, snake_case, SCREAMING_SNAKE_CASE, dotted identifiers.
1615
+ */
1616
+ isStrictIdentifierQuery(query) {
1617
+ const trimmed = query.trim();
1618
+ if (/\s/.test(trimmed)) return false;
1619
+
1620
+ // PascalCase: UserService, AuthDTO, HTMLParser (uppercase start, has at least one lowercase)
1621
+ if (/^[A-Z][a-zA-Z0-9]+$/.test(trimmed) && /[a-z]/.test(trimmed)) return true;
1622
+
1623
+ // camelCase: getUserName, authService
1624
+ if (/^[a-z][a-zA-Z0-9]*$/.test(trimmed) && /[A-Z]/.test(trimmed)) return true;
1625
+
1626
+ // snake_case: get_user_name
1627
+ if (/^[a-z][a-z0-9_]*$/.test(trimmed) && trimmed.includes('_')) return true;
1628
+
1629
+ // SCREAMING_SNAKE_CASE: MAX_RETRIES
1630
+ if (/^[A-Z][A-Z0-9_]+$/.test(trimmed) && trimmed.includes('_')) return true;
1631
+
1632
+ // All-uppercase code identifiers (2+ chars): JSON, URL, API, HTTP, IO
1633
+ if (/^[A-Z]{2,}$/.test(trimmed)) return true;
1634
+
1635
+ // Dotted identifier: foo.bar.baz
1636
+ if (/^[a-zA-Z_]\w*(?:\.[a-zA-Z_]\w*)+$/.test(trimmed)) return true;
1637
+
1638
+ return false;
1639
+ }
1640
+
1641
+ /**
1642
+ * Two-Pass Definition-First Search (Strategy #2)
1643
+ *
1644
+ * Runs two searches in parallel:
1645
+ * 1. Definition search: Find files where filename == query OR entity is class/interface with exact name
1646
+ * 2. Normal FTS5/BM25: Standard ranking
1647
+ *
1648
+ * Merges results with definition matches guaranteed in top positions.
1649
+ * This fixes the ranking problem where "AuthService" returns AuthService.java
1650
+ * at position 5 instead of position 1.
1651
+ *
1652
+ * @param {string} query - The search query
1653
+ * @param {Object} options - Search options
1654
+ * @param {number} [options.k=10] - Number of results to return
1655
+ * @param {number} [options.limit=20] - FTS5 search limit
1656
+ * @param {boolean} [options.skipBoosts=false] - Skip ranking boosts (for fair hybrid fusion)
1657
+ * @returns {Promise<Object>} Merged results with definitions prioritized
1658
+ */
1659
+ async hybridDefinitionSearch(query, options = {}) {
1660
+ await this.init();
1661
+
1662
+ const { k = 10, limit = 20, skipBoosts = false } = options;
1663
+ const queryNormalized = query.toLowerCase().replace(/[^a-z0-9]/g, '');
1664
+ const queryLower = query.toLowerCase();
1665
+ const start = Date.now();
1666
+
1667
+ // Pass 1: Definition-first search (parallel with Pass 2)
1668
+ const definitionPromise = new Promise((resolve) => {
1669
+ try {
1670
+ // Search for entities where:
1671
+ // - Entity name exactly matches query (case-insensitive)
1672
+ // - Entity is a top-level definition type (class, interface, function, etc.)
1673
+ // - Only match primary definitions, not methods inside files
1674
+ const stmt = this.db.prepare(`
1675
+ SELECT
1676
+ e.*,
1677
+ 100.0 as definition_boost,
1678
+ CASE
1679
+ WHEN LOWER(e.name) = ? THEN 1
1680
+ WHEN LOWER(e.name) LIKE ? || '%' THEN 2
1681
+ ELSE 3
1682
+ END as match_priority
1683
+ FROM entities e
1684
+ WHERE (
1685
+ -- Entity name exactly matches query
1686
+ LOWER(e.name) = ?
1687
+ OR
1688
+ -- Entity name starts with query (prefix match for partial identifiers)
1689
+ LOWER(e.name) LIKE ? || '%'
1690
+ )
1691
+ -- Only match top-level definition types, not methods
1692
+ AND e.type IN ('class', 'interface', 'struct', 'enum', 'function', 'type', 'component')
1693
+ AND e.stale_since IS NULL
1694
+ ORDER BY
1695
+ match_priority,
1696
+ CASE e.type
1697
+ WHEN 'class' THEN 0
1698
+ WHEN 'interface' THEN 1
1699
+ WHEN 'function' THEN 2
1700
+ WHEN 'component' THEN 3
1701
+ ELSE 4
1702
+ END,
1703
+ length(e.name)
1704
+ LIMIT 5
1705
+ `);
1706
+ const rows = stmt.all(queryLower, queryLower, queryLower, queryLower);
1707
+ resolve(rows);
1708
+ } catch (err) {
1709
+ this.log(`Definition search error: ${err.message}`);
1710
+ resolve([]);
1711
+ }
1712
+ });
1713
+
1714
+ // Pass 2: Normal BM25/FTS5 search (parallel)
1715
+ // Pass skipBoosts through for fair hybrid fusion
1716
+ const fts5Promise = this.bm25Search(query, { limit, skipBoosts });
1717
+
1718
+ // Wait for both searches
1719
+ const [definitionResults, fts5Result] = await Promise.all([definitionPromise, fts5Promise]);
1720
+
1721
+ const latency = Date.now() - start;
1722
+ this.log(`hybridDefinitionSearch: ${definitionResults.length} definitions, ${fts5Result.results.length} FTS5 in ${latency}ms`);
1723
+
1724
+ // Merge: definitions first, then FTS5 (deduplicated)
1725
+ const merged = this.mergeWithDefinitionPriority(definitionResults, fts5Result.results, k);
1726
+
1727
+ return {
1728
+ results: merged,
1729
+ stats: {
1730
+ definition_count: definitionResults.length,
1731
+ fts5_count: fts5Result.results.length,
1732
+ latency_ms: latency,
1733
+ fts5_latency_ms: fts5Result.latency,
1734
+ },
1735
+ };
1736
+ }
1737
+
1738
+ /**
1739
+ * Merge definition search results with FTS5 results, prioritizing definitions.
1740
+ * Definitions are guaranteed in top positions, followed by deduplicated FTS5 results.
1741
+ *
1742
+ * @param {Array} definitions - Results from definition-first search
1743
+ * @param {Array} fts5Results - Results from BM25/FTS5 search
1744
+ * @param {number} [k=10] - Maximum results to return
1745
+ * @returns {Array} Merged and deduplicated results
1746
+ */
1747
+ mergeWithDefinitionPriority(definitions, fts5Results, k = 10) {
1748
+ const seen = new Set();
1749
+ const merged = [];
1750
+
1751
+ // Add definitions first (guaranteed top positions)
1752
+ for (const def of definitions) {
1753
+ const key = `${def.file_path}:${def.name}`;
1754
+ if (!seen.has(key)) {
1755
+ seen.add(key);
1756
+ merged.push({
1757
+ id: def.id,
1758
+ file: def.file_path,
1759
+ type: def.type,
1760
+ name: def.name,
1761
+ signature: def.signature,
1762
+ docComment: def.doc_comment,
1763
+ startLine: def.start_line,
1764
+ endLine: def.end_line,
1765
+ package: def.package,
1766
+ parentClass: def.parent_class,
1767
+ score: (def.score || 0) + (def.definition_boost || 100),
1768
+ source: 'definition_pass',
1769
+ isDefinition: true,
1770
+ });
1771
+ }
1772
+ }
1773
+
1774
+ // Add remaining FTS5 results (deduplicated)
1775
+ for (const result of fts5Results) {
1776
+ const key = `${result.file}:${result.name}`;
1777
+ if (!seen.has(key) && merged.length < k * 2) {
1778
+ seen.add(key);
1779
+ merged.push({
1780
+ ...result,
1781
+ isDefinition: false,
1782
+ });
1783
+ }
1784
+ }
1785
+
1786
+ return merged.slice(0, k);
1787
+ }
1788
+
1789
+ /**
1790
+ * Find all callers of a given entity
1791
+ */
1792
+ async findCallers(entityName, options = {}) {
1793
+ await this.init();
1794
+ const { maxDepth = 2, limit = 50 } = options;
1795
+
1796
+ // Find target entity
1797
+ const target = this.db.prepare(`
1798
+ SELECT id, name, type, file_path FROM entities
1799
+ WHERE (name = ? OR name LIKE ?)
1800
+ AND stale_since IS NULL
1801
+ LIMIT 1
1802
+ `).get(entityName, `%${entityName}%`);
1803
+
1804
+ if (!target) return { results: [], stats: { found: false, query: entityName } };
1805
+
1806
+ // Create lowercase camelCase pattern for target_name matching
1807
+ // e.g., "EmployeeService" -> "employeeService.%" to match "employeeService.getEmployee"
1808
+ const lowerCamelCase = target.name.charAt(0).toLowerCase() + target.name.slice(1);
1809
+ const targetNamePattern = `${lowerCamelCase}.%`;
1810
+
1811
+ // Find callers (reverse 'calls' relationship)
1812
+ // Match by target_id (resolved) OR target_name pattern (unresolved)
1813
+ const callers = this.db.prepare(`
1814
+ SELECT DISTINCT
1815
+ e.id, e.name, e.type, e.file_path, e.start_line, e.signature, e.summary,
1816
+ r.context_line as call_line, r.target_name
1817
+ FROM relationships r
1818
+ JOIN entities e ON e.id = r.source_id
1819
+ WHERE r.type = 'calls' AND (
1820
+ r.target_id = ?
1821
+ OR r.target_name LIKE ?
1822
+ OR r.target_name LIKE ?
1823
+ )
1824
+ AND e.stale_since IS NULL
1825
+ ORDER BY e.file_path, r.context_line
1826
+ LIMIT ?
1827
+ `).all(target.id, targetNamePattern, `${target.name}.%`, limit);
1828
+
1829
+ return {
1830
+ results: callers.map(c => ({
1831
+ ...c,
1832
+ relationship: 'calls',
1833
+ depth: 1,
1834
+ })),
1835
+ stats: { targetEntity: target.name, targetType: target.type, totalCallers: callers.length },
1836
+ };
1837
+ }
1838
+
1839
+ /**
1840
+ * Find all callees of a given entity
1841
+ */
1842
+ async findCallees(entityName, options = {}) {
1843
+ await this.init();
1844
+ const { limit = 50 } = options;
1845
+
1846
+ const source = this.db.prepare(`
1847
+ SELECT id, name, type FROM entities
1848
+ WHERE (name = ? OR name LIKE ?)
1849
+ AND stale_since IS NULL
1850
+ LIMIT 1
1851
+ `).get(entityName, `%${entityName}%`);
1852
+
1853
+ if (!source) return { results: [], stats: { found: false } };
1854
+
1855
+ const callees = this.db.prepare(`
1856
+ SELECT DISTINCT
1857
+ e.id, e.name, e.type, e.file_path, e.start_line, e.signature, e.summary,
1858
+ r.context_line as call_line, r.target_name
1859
+ FROM relationships r
1860
+ LEFT JOIN entities e ON e.id = r.target_id OR e.name = r.target_name
1861
+ WHERE r.source_id = ? AND r.type = 'calls'
1862
+ AND (e.stale_since IS NULL OR e.id IS NULL)
1863
+ ORDER BY r.context_line
1864
+ LIMIT ?
1865
+ `).all(source.id, limit);
1866
+
1867
+ return {
1868
+ results: callees.map(c => ({
1869
+ id: c.id,
1870
+ name: c.name || c.target_name,
1871
+ type: c.type || 'external',
1872
+ file_path: c.file_path,
1873
+ start_line: c.start_line,
1874
+ signature: c.signature,
1875
+ call_line: c.call_line,
1876
+ relationship: 'calls',
1877
+ })),
1878
+ stats: { sourceEntity: source.name, totalCallees: callees.length },
1879
+ };
1880
+ }
1881
+
1882
+ /**
1883
+ * Find implementations of an interface
1884
+ */
1885
+ async findImplementations(interfaceName, options = {}) {
1886
+ await this.init();
1887
+ const { limit = 50 } = options;
1888
+
1889
+ const implementations = this.db.prepare(`
1890
+ SELECT DISTINCT
1891
+ e.id, e.name, e.type, e.file_path, e.start_line, e.signature, e.summary,
1892
+ r.type as rel_type
1893
+ FROM relationships r
1894
+ JOIN entities e ON e.id = r.source_id
1895
+ WHERE (r.target_name = ? OR r.target_name LIKE ?)
1896
+ AND r.type IN ('implements', 'extends')
1897
+ AND e.stale_since IS NULL
1898
+ ORDER BY e.name
1899
+ LIMIT ?
1900
+ `).all(interfaceName, `%${interfaceName}%`, limit);
1901
+
1902
+ return {
1903
+ results: implementations.map(i => ({ ...i, relationship: i.rel_type })),
1904
+ stats: { targetInterface: interfaceName, totalImplementations: implementations.length },
1905
+ };
1906
+ }
1907
+
1908
+ /**
1909
+ * Impact analysis - what depends on this entity?
1910
+ */
1911
+ async findImpact(entityName, options = {}) {
1912
+ await this.init();
1913
+ const { maxDepth = 3, limit = 100 } = options;
1914
+
1915
+ const target = this.db.prepare(`
1916
+ SELECT id, name, type FROM entities
1917
+ WHERE (name = ? OR name LIKE ?)
1918
+ AND stale_since IS NULL
1919
+ LIMIT 1
1920
+ `).get(entityName, `%${entityName}%`);
1921
+
1922
+ if (!target) return { results: [], stats: { found: false } };
1923
+
1924
+ const impacted = new Map();
1925
+ let frontier = [target.id];
1926
+
1927
+ // For depth 1, also match on target_name pattern (e.g., "employeeService.getEmployee")
1928
+ const targetNamePattern = target.name.charAt(0).toLowerCase() + target.name.slice(1) + '.%';
1929
+
1930
+ for (let depth = 1; depth <= maxDepth && impacted.size < limit; depth++) {
1931
+ if (frontier.length === 0) break;
1932
+
1933
+ const placeholders = frontier.map(() => '?').join(',');
1934
+ let dependents;
1935
+
1936
+ if (depth === 1) {
1937
+ // First depth: match by target_id OR target_name pattern (for instance.method() calls)
1938
+ dependents = this.db.prepare(`
1939
+ SELECT DISTINCT
1940
+ e.id, e.name, e.type, e.file_path, e.start_line, e.signature, e.summary,
1941
+ r.type as rel_type
1942
+ FROM relationships r
1943
+ JOIN entities e ON e.id = r.source_id
1944
+ WHERE (r.target_id IN (${placeholders}) OR r.target_name LIKE ?)
1945
+ AND e.stale_since IS NULL
1946
+ LIMIT 50
1947
+ `).all(...frontier, targetNamePattern);
1948
+ } else {
1949
+ // Subsequent depths: only match by target_id
1950
+ dependents = this.db.prepare(`
1951
+ SELECT DISTINCT
1952
+ e.id, e.name, e.type, e.file_path, e.start_line, e.signature, e.summary,
1953
+ r.type as rel_type
1954
+ FROM relationships r
1955
+ JOIN entities e ON e.id = r.source_id
1956
+ WHERE r.target_id IN (${placeholders})
1957
+ AND e.stale_since IS NULL
1958
+ LIMIT 50
1959
+ `).all(...frontier);
1960
+ }
1961
+
1962
+ const nextFrontier = [];
1963
+ for (const dep of dependents) {
1964
+ if (!impacted.has(dep.id) && dep.id !== target.id) {
1965
+ impacted.set(dep.id, {
1966
+ ...dep,
1967
+ relationship: dep.rel_type,
1968
+ depth,
1969
+ riskScore: (4 - depth) / 3, // Higher for closer dependencies
1970
+ });
1971
+ nextFrontier.push(dep.id);
1972
+ }
1973
+ }
1974
+ frontier = nextFrontier.slice(0, 20); // Limit branching
1975
+ }
1976
+
1977
+ const results = Array.from(impacted.values()).sort((a, b) => b.riskScore - a.riskScore);
1978
+ return {
1979
+ results,
1980
+ stats: {
1981
+ targetEntity: target.name,
1982
+ totalImpacted: results.length,
1983
+ highRisk: results.filter(r => r.depth === 1).length,
1984
+ },
1985
+ };
1986
+ }
1987
+
1988
+ /**
1989
+ * Get database statistics
1990
+ */
1991
+ async getStats() {
1992
+ await this.init();
1993
+
1994
+ const entityCount = this.db.prepare('SELECT COUNT(*) as count FROM entities').get()?.count || 0;
1995
+ const relCount = this.db.prepare('SELECT COUNT(*) as count FROM relationships').get()?.count || 0;
1996
+
1997
+ const typeCounts = {};
1998
+ const typeRows = this.db.prepare('SELECT type, COUNT(*) as count FROM entities GROUP BY type').all();
1999
+ for (const row of typeRows) {
2000
+ typeCounts[row.type] = row.count;
2001
+ }
2002
+
2003
+ const relTypeCounts = {};
2004
+ const relTypeRows = this.db.prepare('SELECT type, COUNT(*) as count FROM relationships GROUP BY type').all();
2005
+ for (const row of relTypeRows) {
2006
+ relTypeCounts[row.type] = row.count;
2007
+ }
2008
+
2009
+ return {
2010
+ entities: entityCount,
2011
+ relationships: relCount,
2012
+ entityTypes: typeCounts,
2013
+ relationshipTypes: relTypeCounts,
2014
+ hasFts5: this.hasFts5,
2015
+ hasTrigram: this.hasTrigram,
2016
+ };
2017
+ }
2018
+ }
2019
+
2020
+ // =============================================================================
2021
+ // CLI
2022
+ // =============================================================================
2023
+
2024
+ if (import.meta.url === `file://${process.argv[1]}`) {
2025
+ const args = process.argv.slice(2);
2026
+
2027
+ if (args.length === 0) {
2028
+ console.log(`
2029
+ Graph-Expanded Search (better-sqlite3 + FTS5 trigram)
2030
+
2031
+ Usage:
2032
+ graph-search.js <query> [options]
2033
+
2034
+ Options:
2035
+ --no-expand Disable graph expansion (BM25 only)
2036
+ --force-expand Force graph expansion even for exact matches
2037
+ --no-definition-first Disable definition-first search for identifier queries
2038
+ --top <n> Number of results (default: 10)
2039
+ --stats Show database statistics
2040
+ --json Output as JSON
2041
+
2042
+ Definition-First Search (Strategy #2):
2043
+ For identifier queries (PascalCase, camelCase, snake_case), a two-pass
2044
+ search runs in parallel:
2045
+ 1. Definition search: Find files where filename or entity name matches
2046
+ 2. Standard FTS5/BM25 search
2047
+ Definition results are guaranteed in top positions.
2048
+ Use --no-definition-first to disable this behavior.
2049
+
2050
+ Adaptive Expansion:
2051
+ By default, graph expansion is SKIPPED for exact identifier matches
2052
+ (e.g., "AuthService" finds AuthService.java with score 50.0).
2053
+ This keeps lexical hits as fast as misses (<10ms).
2054
+ Use --force-expand to always perform graph traversal.
2055
+
2056
+ Examples:
2057
+ graph-search.js "AuthService" # Definition-first search (<10ms)
2058
+ graph-search.js "authentication" --top 20 # Conceptual query (may expand)
2059
+ graph-search.js "LoginService" --no-expand # BM25 only
2060
+ graph-search.js "AuthService" --force-expand # Force graph traversal
2061
+ graph-search.js "AuthService" --no-definition-first # Standard BM25 ranking
2062
+ graph-search.js --stats
2063
+
2064
+ Environment:
2065
+ DEBUG_GRAPH_SEARCH=1 Enable debug logging for expansion decisions
2066
+ `);
2067
+ process.exit(0);
2068
+ }
2069
+
2070
+ let query = '';
2071
+ let expand = true;
2072
+ let forceExpand = false;
2073
+ let useDefinitionFirst = null; // Auto-detect
2074
+ let top = 10;
2075
+ let showStats = false;
2076
+ let json = false;
2077
+
2078
+ for (let i = 0; i < args.length; i++) {
2079
+ if (args[i] === '--no-expand') {
2080
+ expand = false;
2081
+ } else if (args[i] === '--force-expand') {
2082
+ forceExpand = true;
2083
+ } else if (args[i] === '--no-definition-first') {
2084
+ useDefinitionFirst = false;
2085
+ } else if (args[i] === '--definition-first') {
2086
+ useDefinitionFirst = true;
2087
+ } else if (args[i] === '--top' && args[i + 1]) {
2088
+ top = parseInt(args[++i], 10);
2089
+ } else if (args[i] === '--stats') {
2090
+ showStats = true;
2091
+ } else if (args[i] === '--json') {
2092
+ json = true;
2093
+ } else if (!args[i].startsWith('--')) {
2094
+ query = args[i];
2095
+ }
2096
+ }
2097
+
2098
+ (async () => {
2099
+ const searcher = new GraphSearch();
2100
+
2101
+ try {
2102
+ if (showStats) {
2103
+ const stats = await searcher.getStats();
2104
+ console.log(json ? JSON.stringify(stats, null, 2) : stats);
2105
+ return;
2106
+ }
2107
+
2108
+ if (!query) {
2109
+ console.error('Error: Query required');
2110
+ process.exit(1);
2111
+ }
2112
+
2113
+ const { results, stats } = await searcher.graphExpandedSearch(query, {
2114
+ k: top,
2115
+ expand,
2116
+ skipExpansionForExactMatch: !forceExpand, // Override adaptive behavior
2117
+ useDefinitionFirst, // Pass through (null = auto-detect)
2118
+ });
2119
+
2120
+ if (json) {
2121
+ console.log(JSON.stringify({ results, stats }, null, 2));
2122
+ } else {
2123
+ console.log(`\nGraph Search Results for: "${query}"`);
2124
+ const expansionInfo = stats.skipped_expansion ? ' (expansion skipped: exact match)' : '';
2125
+ const defInfo = stats.definition_count != null ? ` | Definitions: ${stats.definition_count}` : '';
2126
+ console.log(`Mode: ${stats.mode}${expansionInfo}${defInfo} | BM25: ${stats.bm25_ms}ms | Graph: ${stats.graph_ms || 0}ms | Total: ${stats.total_ms}ms`);
2127
+ console.log('='.repeat(80));
2128
+
2129
+ for (let i = 0; i < results.length; i++) {
2130
+ const r = results[i];
2131
+ console.log(`\n${i + 1}. ${r.name} (${r.type})`);
2132
+ console.log(` File: ${r.file}:${r.startLine}`);
2133
+ console.log(` Score: ${r.score?.toFixed(4)} | Source: ${r.source}${r.relType ? ` via ${r.relType}` : ''}`);
2134
+ if (r.signature) {
2135
+ console.log(` ${r.signature.slice(0, 80)}${r.signature.length > 80 ? '...' : ''}`);
2136
+ }
2137
+ }
2138
+ }
2139
+ } catch (err) {
2140
+ console.error('Error:', err.message);
2141
+ process.exit(1);
2142
+ } finally {
2143
+ searcher.close();
2144
+ }
2145
+ })();
2146
+ }
2147
+
2148
+ export { GraphSearch as default };