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.
- package/LICENSE +190 -0
- package/NOTICE +23 -0
- package/core/cli.js +51 -0
- package/core/config.js +27 -0
- package/core/embedding/embedding-cache.js +467 -0
- package/core/embedding/embedding-local-model.js +845 -0
- package/core/embedding/embedding-remote.js +492 -0
- package/core/embedding/embedding-service.js +712 -0
- package/core/embedding/embedding-telemetry.js +219 -0
- package/core/embedding/index.js +40 -0
- package/core/graph/community-detector.js +294 -0
- package/core/graph/graph-expansion.js +839 -0
- package/core/graph/graph-extractor.js +2304 -0
- package/core/graph/graph-search.js +2148 -0
- package/core/graph/hcgs-generator.js +666 -0
- package/core/graph/index.js +16 -0
- package/core/graph/leiden-algorithm.js +547 -0
- package/core/graph/relationship-resolver.js +366 -0
- package/core/graph/repo-map.js +408 -0
- package/core/graph/summary-manager.js +549 -0
- package/core/indexing/artifact-builder.js +1054 -0
- package/core/indexing/ast-chunker.js +709 -0
- package/core/indexing/chunking/chunk-builder.js +170 -0
- package/core/indexing/chunking/markdown-chunker.js +503 -0
- package/core/indexing/chunking/plaintext-chunker.js +104 -0
- package/core/indexing/dedup/dedup-phase.js +159 -0
- package/core/indexing/dedup/exemplar-selector.js +65 -0
- package/core/indexing/document-chunker.js +56 -0
- package/core/indexing/incremental-parser.js +390 -0
- package/core/indexing/incremental-tracker.js +761 -0
- package/core/indexing/index-codebase-v21.js +472 -0
- package/core/indexing/index-maintainer.mjs +1674 -0
- package/core/indexing/index.js +90 -0
- package/core/indexing/indexer-ann.js +1077 -0
- package/core/indexing/indexer-build.js +742 -0
- package/core/indexing/indexer-phases.js +800 -0
- package/core/indexing/indexer-pool.js +764 -0
- package/core/indexing/indexer-sparse-gram.js +98 -0
- package/core/indexing/indexer-utils.js +536 -0
- package/core/indexing/indexer-worker.js +148 -0
- package/core/indexing/li-skip-policy.js +225 -0
- package/core/indexing/merkle-tracker.js +244 -0
- package/core/indexing/model-pool.js +166 -0
- package/core/infrastructure/code-graph-repository.js +120 -0
- package/core/infrastructure/codebase-repository.js +131 -0
- package/core/infrastructure/config/dedup.js +54 -0
- package/core/infrastructure/config/embedding.js +298 -0
- package/core/infrastructure/config/graph.js +80 -0
- package/core/infrastructure/config/index.js +82 -0
- package/core/infrastructure/config/indexing.js +8 -0
- package/core/infrastructure/config/platform.js +254 -0
- package/core/infrastructure/config/ranking.js +221 -0
- package/core/infrastructure/config/search.js +396 -0
- package/core/infrastructure/config/translation.js +89 -0
- package/core/infrastructure/config/vector-store.js +114 -0
- package/core/infrastructure/constants.js +86 -0
- package/core/infrastructure/coreml-cascade.js +909 -0
- package/core/infrastructure/coreml-cascade.json +46 -0
- package/core/infrastructure/coreml-provider.js +81 -0
- package/core/infrastructure/db-utils.js +69 -0
- package/core/infrastructure/dedup-hashing.js +83 -0
- package/core/infrastructure/hardware-capability.js +332 -0
- package/core/infrastructure/index.js +104 -0
- package/core/infrastructure/language-patterns/maps.js +121 -0
- package/core/infrastructure/language-patterns/registry-core.js +323 -0
- package/core/infrastructure/language-patterns/registry-data-query.js +155 -0
- package/core/infrastructure/language-patterns/registry-object-oriented.js +285 -0
- package/core/infrastructure/language-patterns/registry-tooling.js +240 -0
- package/core/infrastructure/language-patterns/registry-web-style.js +143 -0
- package/core/infrastructure/language-patterns/registry.js +19 -0
- package/core/infrastructure/language-patterns.js +141 -0
- package/core/infrastructure/llm-provider.js +733 -0
- package/core/infrastructure/manifest.json +46 -0
- package/core/infrastructure/maxsim.wasm +0 -0
- package/core/infrastructure/model-fetcher.js +423 -0
- package/core/infrastructure/model-registry.js +214 -0
- package/core/infrastructure/native-inference.js +587 -0
- package/core/infrastructure/native-resolver.js +187 -0
- package/core/infrastructure/native-sparse-gram.js +257 -0
- package/core/infrastructure/native-tokenizer.js +160 -0
- package/core/infrastructure/onnx-mutex.js +45 -0
- package/core/infrastructure/onnx-session-utils.js +261 -0
- package/core/infrastructure/ort-pipeline.js +111 -0
- package/core/infrastructure/project-detector.js +102 -0
- package/core/infrastructure/quantization.js +410 -0
- package/core/infrastructure/simd-distance.js +502 -0
- package/core/infrastructure/simd-distance.wasm +0 -0
- package/core/infrastructure/tree-sitter-provider.js +665 -0
- package/core/infrastructure/webgpu-maxsim.js +222 -0
- package/core/query/index.js +35 -0
- package/core/query/intent-detector.js +201 -0
- package/core/query/intent-router.js +156 -0
- package/core/query/query-router-catboost.js +222 -0
- package/core/query/query-router-ml.js +266 -0
- package/core/query/query-router.js +213 -0
- package/core/ranking/cascaded-scorer.js +379 -0
- package/core/ranking/flashrank.js +810 -0
- package/core/ranking/index.js +49 -0
- package/core/ranking/late-interaction-index.js +2383 -0
- package/core/ranking/late-interaction-model.js +812 -0
- package/core/ranking/local-reranker.js +374 -0
- package/core/ranking/mmr.js +379 -0
- package/core/ranking/quality-scorer.js +363 -0
- package/core/search/context-expander.js +1167 -0
- package/core/search/dedup/sibling-expander.js +327 -0
- package/core/search/index.js +16 -0
- package/core/search/search-boost.js +259 -0
- package/core/search/search-cli.js +544 -0
- package/core/search/search-format.js +282 -0
- package/core/search/search-fusion.js +327 -0
- package/core/search/search-hybrid.js +204 -0
- package/core/search/search-pattern-chunks.js +337 -0
- package/core/search/search-pattern-planner.js +439 -0
- package/core/search/search-pattern-prefilter.js +412 -0
- package/core/search/search-pattern-ripgrep.js +663 -0
- package/core/search/search-pattern.js +463 -0
- package/core/search/search-postprocess.js +452 -0
- package/core/search/search-semantic.js +706 -0
- package/core/search/search-server.js +554 -0
- package/core/search/session-daemon-prewarm.mjs +164 -0
- package/core/search/session-warmup.js +595 -0
- package/core/search/sweet-search.js +632 -0
- package/core/search/warmup-metrics.js +532 -0
- package/core/start-server.js +6 -0
- package/core/training/query-router/features/extractor.js +762 -0
- package/core/training/query-router/features/multilingual-patterns.js +431 -0
- package/core/training/query-router/features/text-segmenter.js +303 -0
- package/core/training/query-router/features/unicode-utils.js +383 -0
- package/core/training/query-router/output/v45_router_d4.js +11521 -0
- package/core/training/query-router/output/v46_router_d4.js +11498 -0
- package/core/vector-store/binary-heap.js +227 -0
- package/core/vector-store/binary-hnsw-index.js +1004 -0
- package/core/vector-store/float-vector-store.js +234 -0
- package/core/vector-store/hnsw-index.js +580 -0
- package/core/vector-store/index.js +39 -0
- package/core/vector-store/seismic-index.js +498 -0
- package/core/vocabulary/index.js +84 -0
- package/core/vocabulary/vocab-constants.js +20 -0
- package/core/vocabulary/vocab-miner-extractors.js +375 -0
- package/core/vocabulary/vocab-miner-nl.js +404 -0
- package/core/vocabulary/vocab-miner-utils.js +146 -0
- package/core/vocabulary/vocab-miner.js +574 -0
- package/core/vocabulary/vocab-prewarm-cli.js +110 -0
- package/core/vocabulary/vocab-ranker.js +492 -0
- package/core/vocabulary/vocab-warmer.js +523 -0
- package/core/vocabulary/vocab-warmup-orchestrator.js +425 -0
- package/core/vocabulary/vocabulary-utils.js +704 -0
- package/crates/wasm-router/pkg/package.json +13 -0
- package/crates/wasm-router/pkg/query_router_wasm.d.ts +36 -0
- package/crates/wasm-router/pkg/query_router_wasm.js +271 -0
- package/crates/wasm-router/pkg/query_router_wasm_bg.wasm +0 -0
- package/crates/wasm-router/pkg/query_router_wasm_bg.wasm.d.ts +19 -0
- package/mcp/config-gen.js +121 -0
- package/mcp/server.js +335 -0
- package/mcp/tool-handlers.js +476 -0
- package/package.json +131 -9
- package/scripts/benchmark-harness.js +794 -0
- package/scripts/init.js +1058 -0
- package/scripts/smoke-test.js +435 -0
- package/scripts/uninstall.js +478 -0
- 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 };
|