sweet-search 2.4.2 → 2.5.1
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/core/cli.js +19 -5
- package/core/embedding/embedding-cache.js +177 -15
- package/core/embedding/embedding-service.js +18 -4
- package/core/graph/graph-expansion.js +52 -12
- package/core/graph/graph-extractor.js +30 -1
- package/core/indexing/ast-chunker.js +331 -16
- package/core/indexing/chunking/chunk-builder.js +34 -1
- package/core/indexing/index.js +6 -3
- package/core/indexing/indexer-ann.js +45 -6
- package/core/indexing/indexer-build.js +9 -1
- package/core/indexing/indexer-phases.js +6 -4
- package/core/indexing/indexing-file-policy.js +140 -0
- package/core/indexing/li-skip-policy.js +11 -220
- package/core/infrastructure/codebase-repository.js +21 -0
- package/core/infrastructure/config/embedding.js +20 -1
- package/core/infrastructure/config/graph.js +2 -2
- package/core/infrastructure/config/ranking.js +10 -0
- package/core/infrastructure/config/vector-store.js +1 -1
- package/core/infrastructure/coreml-cascade.js +236 -30
- package/core/infrastructure/coreml-cascade.json +25 -0
- package/core/infrastructure/index.js +15 -0
- package/core/infrastructure/init-config.js +78 -0
- package/core/infrastructure/language-patterns/registry-core.js +18 -0
- package/core/infrastructure/model-registry.js +12 -0
- package/core/infrastructure/native-inference.js +143 -51
- package/core/infrastructure/tree-sitter-provider.js +92 -2
- package/core/ranking/cascaded-scorer.js +6 -2
- package/core/ranking/file-kind-ranking.js +264 -0
- package/core/ranking/late-interaction-index.js +10 -4
- package/core/ranking/late-interaction-policy.js +304 -0
- package/core/search/context-expander.js +267 -28
- package/core/search/index.js +4 -0
- package/core/search/search-cli.js +3 -1
- package/core/search/search-pattern.js +4 -3
- package/core/search/search-postprocess.js +189 -8
- package/core/search/search-read-semantic.js +717 -0
- package/core/search/search-read.js +481 -0
- package/core/search/search-server.js +6 -4
- package/core/search/sweet-search.js +119 -15
- package/mcp/server.js +41 -0
- package/mcp/tool-handlers.js +117 -6
- package/package.json +9 -7
- package/scripts/init.js +386 -5
package/core/cli.js
CHANGED
|
@@ -20,15 +20,29 @@ if (args[0] === 'init') {
|
|
|
20
20
|
} else if (args[0] === 'prewarm-vocab') {
|
|
21
21
|
const { handlePrewarmVocabCli } = await import('./vocabulary/index.js');
|
|
22
22
|
await handlePrewarmVocabCli(args.slice(1));
|
|
23
|
+
} else if (args[0] === 'read') {
|
|
24
|
+
// Filesystem-grounded reader; runs in JS (no native equivalent yet).
|
|
25
|
+
const { handleReadCli } = await import('./search/search-read.js');
|
|
26
|
+
await handleReadCli(args.slice(1));
|
|
27
|
+
} else if (args[0] === 'read-semantic') {
|
|
28
|
+
// Hybrid span-selection reader; runs in JS (depends on LI index + ranking).
|
|
29
|
+
const { handleReadSemanticCli } = await import('./search/search-read-semantic.js');
|
|
30
|
+
await handleReadSemanticCli(args.slice(1));
|
|
31
|
+
} else if (args[0] === '--serve' || args[0] === '--stop') {
|
|
32
|
+
// Warm search server lifecycle is implemented in JS.
|
|
33
|
+
const { runCli } = await import('./search/index.js');
|
|
34
|
+
await runCli(args);
|
|
23
35
|
} else if (args[0] === '--help' || args[0] === '-h' || args.length === 0) {
|
|
24
36
|
console.log(`sweet-search — hybrid code search engine
|
|
25
37
|
|
|
26
38
|
Usage:
|
|
27
|
-
sweet-search <query>
|
|
28
|
-
sweet-search
|
|
29
|
-
sweet-search
|
|
30
|
-
sweet-search
|
|
31
|
-
sweet-search
|
|
39
|
+
sweet-search <query> Search the indexed codebase
|
|
40
|
+
sweet-search read <file...> Filesystem-grounded read (1-20 files)
|
|
41
|
+
sweet-search read-semantic <f> <q> Return only file spans relevant to a query
|
|
42
|
+
sweet-search init [options] Set up runtime assets and models
|
|
43
|
+
sweet-search uninstall [opts] Remove local state created by init
|
|
44
|
+
sweet-search prewarm-vocab [file] Pre-warm vocabulary cache with terms
|
|
45
|
+
sweet-search --help Show this help
|
|
32
46
|
|
|
33
47
|
Options:
|
|
34
48
|
--mode <mode> Search mode: auto, lexical, semantic, hybrid, pattern
|
|
@@ -60,6 +60,67 @@ export class LRUCache {
|
|
|
60
60
|
}
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
// =============================================================================
|
|
64
|
+
// ATOMIC JSON WRITER — serialised tmp-file-and-rename
|
|
65
|
+
// =============================================================================
|
|
66
|
+
//
|
|
67
|
+
// Both Vocabulary.save() and QueryStats.save() are fired as background
|
|
68
|
+
// promises from the embedding hot path (`vocabulary.save().catch(()=>{})`
|
|
69
|
+
// inside getEmbedding). Under concurrent benchmarks (12+ in-flight queries)
|
|
70
|
+
// multiple save() calls overlap on the same file. The previous direct
|
|
71
|
+
// `fs.writeFile` was non-atomic — interleaving writes produced invalid JSON,
|
|
72
|
+
// which then poisoned every subsequent `.load()` call and silently degraded
|
|
73
|
+
// retrieval quality to near-zero.
|
|
74
|
+
//
|
|
75
|
+
// `writeJsonAtomic` writes to a unique temp file then atomically renames it
|
|
76
|
+
// into place. `serialiseAtomicWrite` chains an instance's writes so at most
|
|
77
|
+
// one is in flight at a time — and at most one extra coalesced write is
|
|
78
|
+
// queued behind the in-flight one. Bursts of N saves collapse into 2 writes,
|
|
79
|
+
// each producing a fully-formed file.
|
|
80
|
+
|
|
81
|
+
async function writeJsonAtomic(targetPath, json) {
|
|
82
|
+
await fs.mkdir(path.dirname(targetPath), { recursive: true });
|
|
83
|
+
const tmpPath = `${targetPath}.tmp.${process.pid}.${Date.now().toString(36)}.${Math.random().toString(36).slice(2, 8)}`;
|
|
84
|
+
try {
|
|
85
|
+
await fs.writeFile(tmpPath, json);
|
|
86
|
+
await fs.rename(tmpPath, targetPath);
|
|
87
|
+
} catch (err) {
|
|
88
|
+
try { await fs.unlink(tmpPath); } catch { /* may not exist */ }
|
|
89
|
+
throw err;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Serialise calls to `produce()` for a given owner. At most one write is
|
|
95
|
+
* in flight; bursts coalesce so callers arriving during an in-flight save
|
|
96
|
+
* all share a single follow-up save, but no further waste accumulates.
|
|
97
|
+
*
|
|
98
|
+
* @param {object} owner - instance with mutable `_atomicInFlight` / `_atomicPending` slots
|
|
99
|
+
* @param {() => Promise<void>} produce - async writer that captures the
|
|
100
|
+
* current state at call time and writes it atomically
|
|
101
|
+
* @returns {Promise<void>}
|
|
102
|
+
*/
|
|
103
|
+
function serialiseAtomicWrite(owner, produce) {
|
|
104
|
+
if (owner._atomicPending) return owner._atomicPending;
|
|
105
|
+
|
|
106
|
+
const previous = owner._atomicInFlight || Promise.resolve();
|
|
107
|
+
owner._atomicPending = previous
|
|
108
|
+
.catch(() => { /* a previous save's failure must not block the next one */ })
|
|
109
|
+
.then(() => {
|
|
110
|
+
// Move from "pending" to "in-flight" before doing the actual write,
|
|
111
|
+
// so additional save() callers arriving during the write start a new
|
|
112
|
+
// pending entry rather than piggy-backing on this one (otherwise they
|
|
113
|
+
// would not see their latest state on disk).
|
|
114
|
+
owner._atomicPending = null;
|
|
115
|
+
const inFlight = produce();
|
|
116
|
+
owner._atomicInFlight = inFlight.finally(() => {
|
|
117
|
+
if (owner._atomicInFlight === inFlight) owner._atomicInFlight = null;
|
|
118
|
+
});
|
|
119
|
+
return owner._atomicInFlight;
|
|
120
|
+
});
|
|
121
|
+
return owner._atomicPending;
|
|
122
|
+
}
|
|
123
|
+
|
|
63
124
|
// =============================================================================
|
|
64
125
|
// QUERY STATS (Cross-session usage tracking)
|
|
65
126
|
// =============================================================================
|
|
@@ -89,10 +150,20 @@ export class QueryStats {
|
|
|
89
150
|
|
|
90
151
|
async save() {
|
|
91
152
|
if (!this.dirty) return;
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
153
|
+
return serialiseAtomicWrite(this, async () => {
|
|
154
|
+
// Re-check dirty inside the queued task: if a coalesced earlier write
|
|
155
|
+
// already persisted everything, there is nothing left to do.
|
|
156
|
+
if (!this.dirty) return;
|
|
157
|
+
const data = { queries: Object.fromEntries(this.stats), lastUpdated: new Date().toISOString() };
|
|
158
|
+
this.dirty = false;
|
|
159
|
+
try {
|
|
160
|
+
await writeJsonAtomic(this.statsPath, JSON.stringify(data));
|
|
161
|
+
} catch (err) {
|
|
162
|
+
// Re-mark dirty so a future save retries this state
|
|
163
|
+
this.dirty = true;
|
|
164
|
+
throw err;
|
|
165
|
+
}
|
|
166
|
+
});
|
|
96
167
|
}
|
|
97
168
|
|
|
98
169
|
increment(query) {
|
|
@@ -112,24 +183,92 @@ export class QueryStats {
|
|
|
112
183
|
// VOCABULARY
|
|
113
184
|
// =============================================================================
|
|
114
185
|
|
|
186
|
+
// Schema version for the persisted vocabulary file. Bump when the on-disk
|
|
187
|
+
// shape changes in a way that should invalidate previously-saved files.
|
|
188
|
+
// v2: { metadata: { created, lastUpdated, version, provider }, terms: {...} }
|
|
189
|
+
// v3: { metadata: { ..., model, dimension, schemaVersion: 3 }, terms: {...} }
|
|
190
|
+
// — adds full embedding fingerprint so a cache produced under one
|
|
191
|
+
// model is not silently served when a different model is active.
|
|
192
|
+
const VOCAB_SCHEMA_VERSION = 3;
|
|
193
|
+
|
|
194
|
+
/** Build the embedding-fingerprint we expect a vocabulary file to match. */
|
|
195
|
+
function currentVocabFingerprint() {
|
|
196
|
+
return {
|
|
197
|
+
schemaVersion: VOCAB_SCHEMA_VERSION,
|
|
198
|
+
provider: EMBEDDING_CONFIG.provider,
|
|
199
|
+
model: EMBEDDING_CONFIG.model,
|
|
200
|
+
dimension: EMBEDDING_CONFIG.dimension,
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Decide whether a persisted vocabulary file's metadata is compatible
|
|
206
|
+
* with the active embedding configuration. Returns `{ compatible: bool,
|
|
207
|
+
* reason?: string }`. Stale `provider` / `model` / `dimension` /
|
|
208
|
+
* schema-version mismatches are explicitly rejected; a missing file is
|
|
209
|
+
* treated as a fresh start (compatible by definition).
|
|
210
|
+
*/
|
|
211
|
+
export function isVocabFingerprintCompatible(metadata, fingerprint = currentVocabFingerprint()) {
|
|
212
|
+
if (!metadata || typeof metadata !== 'object') {
|
|
213
|
+
return { compatible: false, reason: 'missing-metadata' };
|
|
214
|
+
}
|
|
215
|
+
// Pre-v3 files persist `version` (not `schemaVersion`) and never recorded
|
|
216
|
+
// `model` / `dimension`. Treat those as incompatible — we cannot prove the
|
|
217
|
+
// cached embeddings came from the active model.
|
|
218
|
+
const persistedSchema = metadata.schemaVersion ?? metadata.version;
|
|
219
|
+
if (persistedSchema !== fingerprint.schemaVersion) {
|
|
220
|
+
return { compatible: false, reason: `schema-version (file=${persistedSchema} expected=${fingerprint.schemaVersion})` };
|
|
221
|
+
}
|
|
222
|
+
if (metadata.provider && metadata.provider !== fingerprint.provider) {
|
|
223
|
+
return { compatible: false, reason: `provider (file=${metadata.provider} expected=${fingerprint.provider})` };
|
|
224
|
+
}
|
|
225
|
+
if (metadata.model && metadata.model !== fingerprint.model) {
|
|
226
|
+
return { compatible: false, reason: `model (file=${metadata.model} expected=${fingerprint.model})` };
|
|
227
|
+
}
|
|
228
|
+
if (Number.isFinite(metadata.dimension)
|
|
229
|
+
&& Number.isFinite(fingerprint.dimension)
|
|
230
|
+
&& metadata.dimension !== fingerprint.dimension) {
|
|
231
|
+
return { compatible: false, reason: `dimension (file=${metadata.dimension} expected=${fingerprint.dimension})` };
|
|
232
|
+
}
|
|
233
|
+
return { compatible: true };
|
|
234
|
+
}
|
|
235
|
+
|
|
115
236
|
export class Vocabulary {
|
|
116
237
|
constructor(vocabPath) {
|
|
117
238
|
this.vocabPath = vocabPath;
|
|
118
239
|
this.terms = new Map();
|
|
119
|
-
this.metadata = {
|
|
240
|
+
this.metadata = {
|
|
241
|
+
created: null,
|
|
242
|
+
lastUpdated: null,
|
|
243
|
+
schemaVersion: VOCAB_SCHEMA_VERSION,
|
|
244
|
+
provider: null,
|
|
245
|
+
model: null,
|
|
246
|
+
dimension: null,
|
|
247
|
+
};
|
|
120
248
|
this.loaded = false;
|
|
121
249
|
}
|
|
122
250
|
|
|
251
|
+
/**
|
|
252
|
+
* Whether `getEmbedding` should consult this vocabulary at all.
|
|
253
|
+
* Reads from `EMBEDDING_CONFIG.cache.useVocabulary` at call time so
|
|
254
|
+
* tests / benchmarks that toggle the env var see the change without
|
|
255
|
+
* having to re-import the module.
|
|
256
|
+
*/
|
|
257
|
+
static isEnabled() {
|
|
258
|
+
return EMBEDDING_CONFIG.cache?.useVocabulary !== false;
|
|
259
|
+
}
|
|
260
|
+
|
|
123
261
|
async load() {
|
|
124
262
|
if (this.loaded) return;
|
|
125
263
|
try {
|
|
126
264
|
if (existsSync(this.vocabPath)) {
|
|
127
265
|
const data = JSON.parse(await fs.readFile(this.vocabPath, 'utf-8'));
|
|
128
|
-
|
|
129
|
-
|
|
266
|
+
const compat = isVocabFingerprintCompatible(data.metadata);
|
|
267
|
+
if (!compat.compatible) {
|
|
268
|
+
console.log(`Vocabulary: Ignoring incompatible cache (${compat.reason})`);
|
|
130
269
|
this.terms.clear();
|
|
131
270
|
} else {
|
|
132
|
-
this.metadata = data.metadata ||
|
|
271
|
+
this.metadata = { ...this.metadata, ...(data.metadata || {}) };
|
|
133
272
|
for (const [term, embedding] of Object.entries(data.terms || {})) {
|
|
134
273
|
this.terms.set(term, embedding);
|
|
135
274
|
}
|
|
@@ -143,20 +282,43 @@ export class Vocabulary {
|
|
|
143
282
|
}
|
|
144
283
|
|
|
145
284
|
async save() {
|
|
146
|
-
this
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
285
|
+
return serialiseAtomicWrite(this, async () => {
|
|
286
|
+
// Snapshot mutable state INSIDE the queued task so the file written
|
|
287
|
+
// here matches the latest set/has state at write time, not whatever
|
|
288
|
+
// it was when this save() was first scheduled.
|
|
289
|
+
this.metadata.lastUpdated = new Date().toISOString();
|
|
290
|
+
this.metadata.schemaVersion = VOCAB_SCHEMA_VERSION;
|
|
291
|
+
this.metadata.provider = EMBEDDING_CONFIG.provider;
|
|
292
|
+
this.metadata.model = EMBEDDING_CONFIG.model;
|
|
293
|
+
this.metadata.dimension = EMBEDDING_CONFIG.dimension;
|
|
294
|
+
if (!this.metadata.created) this.metadata.created = this.metadata.lastUpdated;
|
|
295
|
+
const data = { metadata: this.metadata, terms: Object.fromEntries(this.terms) };
|
|
296
|
+
await writeJsonAtomic(this.vocabPath, JSON.stringify(data, null, 2));
|
|
297
|
+
});
|
|
152
298
|
}
|
|
153
299
|
|
|
154
|
-
get(term) {
|
|
300
|
+
get(term) {
|
|
301
|
+
if (!Vocabulary.isEnabled()) return null;
|
|
302
|
+
return this.terms.get(this.normalize(term)) || null;
|
|
303
|
+
}
|
|
155
304
|
set(term, embedding) { this.terms.set(this.normalize(term), embedding); }
|
|
156
305
|
has(term) { return this.terms.has(this.normalize(term)); }
|
|
157
306
|
normalize(term) { return term.toLowerCase().trim(); }
|
|
158
307
|
size() { return this.terms.size; }
|
|
159
308
|
|
|
309
|
+
/**
|
|
310
|
+
* Whether the vocabulary is at or above the configured max-terms cap.
|
|
311
|
+
* Auto-expansion in `getEmbedding` is gated on this so a long-running
|
|
312
|
+
* benchmark cannot inflate the file unbounded. Explicit
|
|
313
|
+
* `addToVocabulary` / `expandVocabulary` calls (admin / pre-warm
|
|
314
|
+
* paths) bypass the cap.
|
|
315
|
+
*/
|
|
316
|
+
isFull() {
|
|
317
|
+
const cap = EMBEDDING_CONFIG.cache?.maxTerms;
|
|
318
|
+
if (!Number.isFinite(cap) || cap <= 0) return false;
|
|
319
|
+
return this.terms.size >= cap;
|
|
320
|
+
}
|
|
321
|
+
|
|
160
322
|
async addDefaultTerms(embedFn) {
|
|
161
323
|
const defaultTerms = [
|
|
162
324
|
'AuthService', 'EmployeeService', 'LoginService', 'UserService',
|
|
@@ -63,6 +63,9 @@ export { TimeWindowRateLimiter };
|
|
|
63
63
|
// UNIFIED EMBEDDING SERVICE (hub functions)
|
|
64
64
|
// =============================================================================
|
|
65
65
|
|
|
66
|
+
// Process-scoped flag so the "vocab is full" message logs once, not per-query.
|
|
67
|
+
let _vocabFullLogged = false;
|
|
68
|
+
|
|
66
69
|
/** Generate embedding using the active provider with circuit breaker */
|
|
67
70
|
async function generateEmbedding(text, provider = EMBEDDING_CONFIG.provider, isQuery = false) {
|
|
68
71
|
const localText = isQuery ? applyLocalQueryPrefix(text) : text;
|
|
@@ -206,7 +209,7 @@ export async function getEmbedding(text, options = {}) {
|
|
|
206
209
|
return { embedding: cached, cached: true, source: 'lru', latency_us: Math.round((performance.now() - start) * 1000) };
|
|
207
210
|
}
|
|
208
211
|
|
|
209
|
-
if (isQuery) {
|
|
212
|
+
if (isQuery && EMBEDDING_CONFIG.cache?.useVocabulary !== false) {
|
|
210
213
|
await vocabulary.load();
|
|
211
214
|
const vocabHit = vocabulary.get(text);
|
|
212
215
|
if (vocabHit) {
|
|
@@ -259,9 +262,20 @@ export async function getEmbedding(text, options = {}) {
|
|
|
259
262
|
queryStats.save().catch(() => {});
|
|
260
263
|
const threshold = EMBEDDING_CONFIG.cache?.expansionThreshold || 3;
|
|
261
264
|
if (usageCount >= threshold && !vocabulary.has(text)) {
|
|
262
|
-
vocabulary.
|
|
263
|
-
|
|
264
|
-
|
|
265
|
+
if (vocabulary.isFull()) {
|
|
266
|
+
// Cap reached: skip auto-promotion and log once per batch (the
|
|
267
|
+
// queryStats counter still increments so we don't lose the
|
|
268
|
+
// signal — explicit `addToVocabulary` can still write through).
|
|
269
|
+
if (!_vocabFullLogged) {
|
|
270
|
+
const cap = EMBEDDING_CONFIG.cache?.maxTerms;
|
|
271
|
+
console.log(`Vocabulary: Auto-expand cap reached (${cap} terms); skipping further auto-promotion. Override via SWEET_SEARCH_VOCAB_MAX_TERMS.`);
|
|
272
|
+
_vocabFullLogged = true;
|
|
273
|
+
}
|
|
274
|
+
} else {
|
|
275
|
+
vocabulary.set(text, embedding);
|
|
276
|
+
vocabulary.save().catch(() => {});
|
|
277
|
+
console.log(`Vocabulary: Auto-added "${text}" (used ${usageCount}x)`);
|
|
278
|
+
}
|
|
265
279
|
}
|
|
266
280
|
}
|
|
267
281
|
}
|
|
@@ -276,16 +276,26 @@ export function expandResults(db, results, options = {}) {
|
|
|
276
276
|
*/
|
|
277
277
|
function collectSeedIds(db, results) {
|
|
278
278
|
const seedIds = new Set();
|
|
279
|
+
const needsLineMatch = [];
|
|
280
|
+
|
|
281
|
+
// Distinguish chunk ids from entity ids by shape: chunk ids look like
|
|
282
|
+
// `path/to/file.ext:start-end:n` (always contain `:`), entity ids are
|
|
283
|
+
// hex hashes / opaque tokens that never contain `:`. Treating chunk ids
|
|
284
|
+
// as entity ids feeds them into the relationships SQL and yields zero
|
|
285
|
+
// neighbours — which silently disabled graph expansion on HNSW results.
|
|
286
|
+
const looksLikeEntityId = (s) => typeof s === 'string' && !s.includes(':');
|
|
279
287
|
|
|
280
288
|
for (const r of results) {
|
|
281
289
|
if (r.entity_id) seedIds.add(r.entity_id);
|
|
282
290
|
else if (r.metadata?.entity_id) seedIds.add(r.metadata.entity_id);
|
|
283
|
-
else if (r.id) seedIds.add(r.id);
|
|
291
|
+
else if (r.is_expanded && r.id) seedIds.add(r.id);
|
|
292
|
+
else if (r.id && looksLikeEntityId(r.id)) seedIds.add(r.id);
|
|
293
|
+
else needsLineMatch.push(r);
|
|
284
294
|
}
|
|
285
295
|
|
|
286
|
-
if (
|
|
296
|
+
if (needsLineMatch.length === 0) return seedIds;
|
|
287
297
|
|
|
288
|
-
//
|
|
298
|
+
// Line-range fallback for chunk-id keyed results.
|
|
289
299
|
let entityLookup;
|
|
290
300
|
try {
|
|
291
301
|
entityLookup = db.prepare(`
|
|
@@ -296,18 +306,48 @@ function collectSeedIds(db, results) {
|
|
|
296
306
|
return seedIds;
|
|
297
307
|
}
|
|
298
308
|
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
309
|
+
// Chunk-id pattern: `path/to/file.ext:<start>-<end>:<n>`. When metadata
|
|
310
|
+
// doesn't carry file_path / line numbers (older indexes can be sparse),
|
|
311
|
+
// parse them out of the id itself.
|
|
312
|
+
const parseChunkId = (id) => {
|
|
313
|
+
if (typeof id !== 'string' || !id.includes(':')) return null;
|
|
314
|
+
const m = id.match(/^(.+):(\d+)-(\d+):(\d+)$/);
|
|
315
|
+
if (!m) return null;
|
|
316
|
+
return { file: m[1], startLine: parseInt(m[2], 10), endLine: parseInt(m[3], 10) };
|
|
317
|
+
};
|
|
303
318
|
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
319
|
+
for (const r of needsLineMatch) {
|
|
320
|
+
let filePath = r.file_path || r.file || r.metadata?.file || r.metadata?.path;
|
|
321
|
+
let lineStart = r.start_line || r.startLine
|
|
322
|
+
|| r.metadata?.line_start || r.metadata?.startLine || r.metadata?.start_line;
|
|
323
|
+
let lineEnd = r.end_line || r.endLine
|
|
324
|
+
|| r.metadata?.line_end || r.metadata?.endLine || r.metadata?.end_line;
|
|
325
|
+
if (!filePath || lineStart == null || lineEnd == null) {
|
|
326
|
+
const parsed = parseChunkId(r.id);
|
|
327
|
+
if (parsed) {
|
|
328
|
+
filePath = filePath || parsed.file;
|
|
329
|
+
lineStart = lineStart ?? parsed.startLine;
|
|
330
|
+
lineEnd = lineEnd ?? parsed.endLine;
|
|
309
331
|
}
|
|
310
332
|
}
|
|
333
|
+
if (!filePath || lineStart == null) continue;
|
|
334
|
+
// If we still don't have an end line, treat the chunk as a single line.
|
|
335
|
+
if (lineEnd == null) lineEnd = lineStart;
|
|
336
|
+
|
|
337
|
+
// Find the SMALLEST entity that overlaps the chunk's [start, end] —
|
|
338
|
+
// smaller entities (functions/methods) are more meaningful seeds than
|
|
339
|
+
// file-level container entities. Cap to one seed per result to avoid
|
|
340
|
+
// unbounded seed-set blow-up that can break the relationships SQL.
|
|
341
|
+
let bestId = null;
|
|
342
|
+
let bestSize = Infinity;
|
|
343
|
+
for (const e of entityLookup) {
|
|
344
|
+
if (e.file_path !== filePath) continue;
|
|
345
|
+
if (e.start_line == null || e.end_line == null) continue;
|
|
346
|
+
if (e.start_line > lineEnd || e.end_line < lineStart) continue;
|
|
347
|
+
const size = (e.end_line - e.start_line) + 1;
|
|
348
|
+
if (size < bestSize) { bestSize = size; bestId = e.id; }
|
|
349
|
+
}
|
|
350
|
+
if (bestId) seedIds.add(bestId);
|
|
311
351
|
}
|
|
312
352
|
|
|
313
353
|
return seedIds;
|
|
@@ -253,9 +253,34 @@ export const GENERIC_RELATIONSHIP_MAPPING = Object.freeze({
|
|
|
253
253
|
mixin: 'extends',
|
|
254
254
|
with: 'extends',
|
|
255
255
|
category: 'extends',
|
|
256
|
+
// TS: interface extends interface(s) is a true `extends` edge in
|
|
257
|
+
// the graph (separate pattern key because the registry regex needs
|
|
258
|
+
// to match on the `interface` keyword, not `class`).
|
|
259
|
+
interfaceExtends: 'extends',
|
|
256
260
|
implements: 'implements',
|
|
257
261
|
protocol: 'implements',
|
|
258
262
|
implFor: 'implements',
|
|
263
|
+
// TS: type-only imports/re-exports are still module-level
|
|
264
|
+
// dependencies, so they map to the same `imports` edge.
|
|
265
|
+
typeImport: 'imports',
|
|
266
|
+
typeReexport: 'imports',
|
|
267
|
+
// TS: `<T extends Foo>` is a type reference, not an inheritance
|
|
268
|
+
// edge — emit it as a `uses` relationship (consistent with how
|
|
269
|
+
// decorators and method-of references are handled).
|
|
270
|
+
genericConstraint: 'uses',
|
|
271
|
+
// FOLLOW-UP (documented, NOT implemented): per-line type references
|
|
272
|
+
// in function/method/property signatures (e.g. `function foo(x: User):
|
|
273
|
+
// Result` → `uses` edges to User and Result; `field: Token` → `uses`
|
|
274
|
+
// edge to Token). Intentionally not added at the regex layer — the
|
|
275
|
+
// false-positive surface (matching identifiers in comments, strings,
|
|
276
|
+
// and unrelated positions) is too high. Two prerequisites before
|
|
277
|
+
// shipping:
|
|
278
|
+
// 1. AST-level type-reference extractor (walk `type_annotation` /
|
|
279
|
+
// `parameter` / `return_type` nodes via tree-sitter, not regex)
|
|
280
|
+
// 2. Graph-density benchmark showing retrieval benefit without
|
|
281
|
+
// precision loss (the new `uses` edges should improve graph
|
|
282
|
+
// expansion recall without adding noise that hurts MRR).
|
|
283
|
+
// See May-2026 design discussion in chat history for details.
|
|
259
284
|
decorator: 'uses',
|
|
260
285
|
embed: 'uses',
|
|
261
286
|
extend: 'uses',
|
|
@@ -274,6 +299,9 @@ const escapeRegexLiteral = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&
|
|
|
274
299
|
// Module-scope constant to avoid per-call Set allocation.
|
|
275
300
|
const MULTI_TARGET_TYPES = new Set([
|
|
276
301
|
'plainImport', 'implements', 'inherit', 'protocol', 'with',
|
|
302
|
+
// TS: `interface Foo extends Bar, Baz<T>` — comma-separated
|
|
303
|
+
// parents, generics handled by expandRelationshipTargets.
|
|
304
|
+
'interfaceExtends',
|
|
277
305
|
]);
|
|
278
306
|
|
|
279
307
|
export const TREE_SITTER_ENTITY_PRIORITY = Object.freeze({
|
|
@@ -1328,7 +1356,8 @@ export class GraphExtractor {
|
|
|
1328
1356
|
return { targets: [source], filtered: false };
|
|
1329
1357
|
}
|
|
1330
1358
|
|
|
1331
|
-
if (isJsTs && (relType === 'require' || relType === 'reexport' || relType === 'dynamicImport'
|
|
1359
|
+
if (isJsTs && (relType === 'require' || relType === 'reexport' || relType === 'dynamicImport'
|
|
1360
|
+
|| relType === 'typeImport' || relType === 'typeReexport')) {
|
|
1332
1361
|
const source = match[1]?.trim();
|
|
1333
1362
|
if (!source) return { targets: [], filtered: false };
|
|
1334
1363
|
if (source.startsWith('.')) return { targets: [], filtered: true };
|