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.
Files changed (43) hide show
  1. package/core/cli.js +19 -5
  2. package/core/embedding/embedding-cache.js +177 -15
  3. package/core/embedding/embedding-service.js +18 -4
  4. package/core/graph/graph-expansion.js +52 -12
  5. package/core/graph/graph-extractor.js +30 -1
  6. package/core/indexing/ast-chunker.js +331 -16
  7. package/core/indexing/chunking/chunk-builder.js +34 -1
  8. package/core/indexing/index.js +6 -3
  9. package/core/indexing/indexer-ann.js +45 -6
  10. package/core/indexing/indexer-build.js +9 -1
  11. package/core/indexing/indexer-phases.js +6 -4
  12. package/core/indexing/indexing-file-policy.js +140 -0
  13. package/core/indexing/li-skip-policy.js +11 -220
  14. package/core/infrastructure/codebase-repository.js +21 -0
  15. package/core/infrastructure/config/embedding.js +20 -1
  16. package/core/infrastructure/config/graph.js +2 -2
  17. package/core/infrastructure/config/ranking.js +10 -0
  18. package/core/infrastructure/config/vector-store.js +1 -1
  19. package/core/infrastructure/coreml-cascade.js +236 -30
  20. package/core/infrastructure/coreml-cascade.json +25 -0
  21. package/core/infrastructure/index.js +15 -0
  22. package/core/infrastructure/init-config.js +78 -0
  23. package/core/infrastructure/language-patterns/registry-core.js +18 -0
  24. package/core/infrastructure/model-registry.js +12 -0
  25. package/core/infrastructure/native-inference.js +143 -51
  26. package/core/infrastructure/tree-sitter-provider.js +92 -2
  27. package/core/ranking/cascaded-scorer.js +6 -2
  28. package/core/ranking/file-kind-ranking.js +264 -0
  29. package/core/ranking/late-interaction-index.js +10 -4
  30. package/core/ranking/late-interaction-policy.js +304 -0
  31. package/core/search/context-expander.js +267 -28
  32. package/core/search/index.js +4 -0
  33. package/core/search/search-cli.js +3 -1
  34. package/core/search/search-pattern.js +4 -3
  35. package/core/search/search-postprocess.js +189 -8
  36. package/core/search/search-read-semantic.js +717 -0
  37. package/core/search/search-read.js +481 -0
  38. package/core/search/search-server.js +6 -4
  39. package/core/search/sweet-search.js +119 -15
  40. package/mcp/server.js +41 -0
  41. package/mcp/tool-handlers.js +117 -6
  42. package/package.json +9 -7
  43. 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> Search the indexed codebase
28
- sweet-search init [options] Set up runtime assets and models
29
- sweet-search uninstall [opts] Remove local state created by init
30
- sweet-search prewarm-vocab [file] Pre-warm vocabulary cache with terms
31
- sweet-search --help Show this help
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
- const data = { queries: Object.fromEntries(this.stats), lastUpdated: new Date().toISOString() };
93
- await fs.mkdir(path.dirname(this.statsPath), { recursive: true });
94
- await fs.writeFile(this.statsPath, JSON.stringify(data));
95
- this.dirty = false;
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 = { created: null, lastUpdated: null, version: 2, provider: null };
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
- if (data.metadata?.provider && data.metadata.provider !== EMBEDDING_CONFIG.provider) {
129
- console.log(`Vocabulary: Provider changed (${data.metadata.provider} → ${EMBEDDING_CONFIG.provider}), clearing cache`);
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 || this.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.metadata.lastUpdated = new Date().toISOString();
147
- this.metadata.provider = EMBEDDING_CONFIG.provider;
148
- if (!this.metadata.created) this.metadata.created = this.metadata.lastUpdated;
149
- const data = { metadata: this.metadata, terms: Object.fromEntries(this.terms) };
150
- await fs.mkdir(path.dirname(this.vocabPath), { recursive: true });
151
- await fs.writeFile(this.vocabPath, JSON.stringify(data, null, 2));
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) { return this.terms.get(this.normalize(term)) || null; }
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.set(text, embedding);
263
- vocabulary.save().catch(() => {});
264
- console.log(`Vocabulary: Auto-added "${text}" (used ${usageCount}x)`);
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 (seedIds.size > 0) return seedIds;
296
+ if (needsLineMatch.length === 0) return seedIds;
287
297
 
288
- // Fallback: match results to entities by file_path + line range
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
- for (const r of results) {
300
- const filePath = r.file_path || r.file || r.metadata?.file || r.metadata?.path;
301
- const lineStart = r.start_line || r.startLine || r.metadata?.line_start || r.metadata?.startLine;
302
- if (!filePath) continue;
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
- for (const e of entityLookup) {
305
- if (e.file_path === filePath && e.start_line != null && lineStart != null &&
306
- e.start_line <= lineStart && e.end_line >= lineStart) {
307
- seedIds.add(e.id);
308
- break;
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 };