sweet-search 2.5.2 → 2.5.3

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 (155) hide show
  1. package/core/cli.js +24 -3
  2. package/core/graph/graph-expansion.js +215 -36
  3. package/core/graph/graph-extractor.js +196 -11
  4. package/core/graph/graph-search.js +395 -92
  5. package/core/graph/hcgs-generator.js +2 -1
  6. package/core/graph/index.js +2 -0
  7. package/core/graph/repo-map.js +28 -6
  8. package/core/graph/structural-answer-cues.js +168 -0
  9. package/core/graph/structural-callsite-hints.js +40 -0
  10. package/core/graph/structural-context-format.js +40 -0
  11. package/core/graph/structural-context.js +450 -0
  12. package/core/graph/structural-forward-push.js +156 -0
  13. package/core/graph/structural-header-context.js +19 -0
  14. package/core/graph/structural-importance.js +148 -0
  15. package/core/graph/structural-pagerank.js +197 -0
  16. package/core/graph/summary-manager.js +13 -9
  17. package/core/incremental-indexing/application/dirty-scan.mjs +236 -0
  18. package/core/incremental-indexing/application/file-watcher.mjs +197 -0
  19. package/core/incremental-indexing/application/maintenance-handlers.mjs +519 -0
  20. package/core/incremental-indexing/application/maintenance-worker.mjs +380 -0
  21. package/core/incremental-indexing/application/operator-cli.mjs +554 -0
  22. package/core/incremental-indexing/application/production-li-delta.mjs +192 -0
  23. package/core/incremental-indexing/application/production-reconciler-helpers.mjs +107 -0
  24. package/core/incremental-indexing/application/production-reconciler.mjs +583 -0
  25. package/core/incremental-indexing/application/reconciler.mjs +477 -0
  26. package/core/incremental-indexing/application/tombstone-injector.mjs +148 -0
  27. package/core/incremental-indexing/domain/chunk-identity.mjs +260 -0
  28. package/core/incremental-indexing/domain/encoder-deps.mjs +193 -0
  29. package/core/incremental-indexing/domain/encoder-input.mjs +225 -0
  30. package/core/incremental-indexing/domain/interval-autotune.mjs +255 -0
  31. package/core/incremental-indexing/domain/reconcile-counters.mjs +149 -0
  32. package/core/incremental-indexing/domain/watermark-scheduler.mjs +239 -0
  33. package/core/incremental-indexing/infrastructure/artifact-temp-sweep.mjs +163 -0
  34. package/core/incremental-indexing/infrastructure/baseline-readiness.mjs +121 -0
  35. package/core/incremental-indexing/infrastructure/dirty-set.mjs +233 -0
  36. package/core/incremental-indexing/infrastructure/graph-gc.mjs +314 -0
  37. package/core/incremental-indexing/infrastructure/hashing.mjs +298 -0
  38. package/core/incremental-indexing/infrastructure/hcgs-invalidation.mjs +182 -0
  39. package/core/incremental-indexing/infrastructure/li-segment-merge.mjs +278 -0
  40. package/core/incremental-indexing/infrastructure/li-segment-state.mjs +173 -0
  41. package/core/incremental-indexing/infrastructure/lockfile.mjs +119 -0
  42. package/core/incremental-indexing/infrastructure/maintenance-state-reader.mjs +283 -0
  43. package/core/incremental-indexing/infrastructure/manifest.mjs +194 -0
  44. package/core/incremental-indexing/infrastructure/path-filter.mjs +190 -0
  45. package/core/incremental-indexing/infrastructure/reader-heartbeat.mjs +201 -0
  46. package/core/incremental-indexing/infrastructure/schema-migrations.mjs +257 -0
  47. package/core/incremental-indexing/infrastructure/sparse-gram-delta.mjs +335 -0
  48. package/core/incremental-indexing/infrastructure/sqlite-fts5.mjs +176 -0
  49. package/core/incremental-indexing/infrastructure/staleness-display.mjs +105 -0
  50. package/core/incremental-indexing/infrastructure/tombstone-bitmap.mjs +234 -0
  51. package/core/incremental-indexing/infrastructure/vector-delta-writer.mjs +359 -0
  52. package/core/incremental-indexing/infrastructure/vector-gc.mjs +133 -0
  53. package/core/incremental-indexing/infrastructure/worktree-stamp.mjs +155 -0
  54. package/core/incremental-indexing/infrastructure/wsl2-detect.mjs +115 -0
  55. package/core/indexing/admission-policy.js +139 -0
  56. package/core/indexing/artifact-builder.js +29 -12
  57. package/core/indexing/ast-chunker.js +107 -30
  58. package/core/indexing/dedup/exemplar-selector.js +19 -1
  59. package/core/indexing/gitignore-filter.js +223 -0
  60. package/core/indexing/incremental-tracker.js +99 -30
  61. package/core/indexing/index-codebase-v21.js +6 -5
  62. package/core/indexing/index-maintainer.mjs +698 -6
  63. package/core/indexing/indexer-ann.js +99 -15
  64. package/core/indexing/indexer-build.js +158 -45
  65. package/core/indexing/indexer-empty-baseline.js +80 -0
  66. package/core/indexing/indexer-manifest.js +66 -0
  67. package/core/indexing/indexer-phases.js +56 -23
  68. package/core/indexing/indexer-sparse-gram.js +54 -13
  69. package/core/indexing/indexer-utils.js +26 -208
  70. package/core/indexing/indexing-file-policy.js +32 -7
  71. package/core/indexing/maintainer-launcher.mjs +137 -0
  72. package/core/indexing/merkle-tracker.js +251 -244
  73. package/core/indexing/model-pool.js +46 -5
  74. package/core/infrastructure/code-graph-repository.js +758 -6
  75. package/core/infrastructure/code-graph-visibility.js +157 -0
  76. package/core/infrastructure/codebase-repository.js +100 -13
  77. package/core/infrastructure/config/search.js +1 -1
  78. package/core/infrastructure/db-utils.js +118 -0
  79. package/core/infrastructure/dedup-hashing.js +10 -13
  80. package/core/infrastructure/hardware-capability.js +17 -7
  81. package/core/infrastructure/index.js +8 -2
  82. package/core/infrastructure/language-patterns/maps.js +4 -1
  83. package/core/infrastructure/language-patterns/registry-core.js +56 -17
  84. package/core/infrastructure/language-patterns/registry-object-oriented.js +12 -5
  85. package/core/infrastructure/language-patterns.js +69 -0
  86. package/core/infrastructure/model-registry.js +20 -0
  87. package/core/infrastructure/native-inference.js +7 -12
  88. package/core/infrastructure/native-resolver.js +52 -37
  89. package/core/infrastructure/native-sparse-gram.js +261 -20
  90. package/core/infrastructure/native-tokenizer.js +6 -15
  91. package/core/infrastructure/simd-distance.js +10 -16
  92. package/core/infrastructure/sparse-gram-delta-reader.js +76 -0
  93. package/core/infrastructure/structural-alias-resolver.js +122 -0
  94. package/core/infrastructure/structural-candidate-ranker.js +34 -0
  95. package/core/infrastructure/structural-context-repository.js +472 -0
  96. package/core/infrastructure/structural-context-utils.js +51 -0
  97. package/core/infrastructure/structural-graph-signals.js +121 -0
  98. package/core/infrastructure/structural-qualified-resolution.js +15 -0
  99. package/core/infrastructure/structural-source-definitions.js +100 -0
  100. package/core/infrastructure/tombstone-bitmap-reader.js +139 -0
  101. package/core/infrastructure/tree-sitter-provider.js +811 -37
  102. package/core/prompt-optimization/data/p7-final/sweet-search-system-prompt.md +50 -0
  103. package/core/query/query-router.js +55 -5
  104. package/core/ranking/file-kind-ranking.js +2192 -15
  105. package/core/ranking/late-interaction-index.js +87 -12
  106. package/core/search/cli-decoration.js +290 -0
  107. package/core/search/context-expander.js +988 -78
  108. package/core/search/index.js +1 -0
  109. package/core/search/output-policy.js +275 -0
  110. package/core/search/search-anchor.js +499 -0
  111. package/core/search/search-boost.js +93 -1
  112. package/core/search/search-cli.js +61 -204
  113. package/core/search/search-hybrid.js +250 -10
  114. package/core/search/search-pattern-chunks.js +57 -8
  115. package/core/search/search-pattern-planner.js +68 -9
  116. package/core/search/search-pattern-prefilter.js +30 -10
  117. package/core/search/search-pattern-ripgrep.js +40 -4
  118. package/core/search/search-pattern-sparse-overlay.js +256 -0
  119. package/core/search/search-pattern.js +117 -29
  120. package/core/search/search-postprocess.js +479 -5
  121. package/core/search/search-read-semantic.js +260 -23
  122. package/core/search/search-read.js +82 -64
  123. package/core/search/search-reader-pin.js +71 -0
  124. package/core/search/search-rrf.js +279 -0
  125. package/core/search/search-semantic.js +110 -5
  126. package/core/search/search-server.js +130 -57
  127. package/core/search/search-trace.js +107 -0
  128. package/core/search/server-identity.js +93 -0
  129. package/core/search/session-daemon-prewarm.mjs +33 -10
  130. package/core/search/sweet-search.js +399 -7
  131. package/core/skills/sweet-index/SKILL.md +8 -6
  132. package/core/vector-store/binary-hnsw-index.js +194 -30
  133. package/core/vector-store/float-vector-store.js +96 -6
  134. package/core/vector-store/hnsw-index.js +220 -49
  135. package/eval/agent-read-workflows/bin/_ss-helpers.mjs +471 -0
  136. package/eval/agent-read-workflows/bin/ss-find +15 -0
  137. package/eval/agent-read-workflows/bin/ss-grep +12 -0
  138. package/eval/agent-read-workflows/bin/ss-read +14 -0
  139. package/eval/agent-read-workflows/bin/ss-search +18 -0
  140. package/eval/agent-read-workflows/bin/ss-semantic +12 -0
  141. package/eval/agent-read-workflows/bin/ss-trace +11 -0
  142. package/mcp/read-tool.js +109 -0
  143. package/mcp/server.js +55 -15
  144. package/mcp/tool-handlers.js +14 -124
  145. package/mcp/trace-tool.js +81 -0
  146. package/package.json +25 -10
  147. package/scripts/hooks/intercept-read.mjs +55 -0
  148. package/scripts/hooks/remind-tools.mjs +40 -0
  149. package/scripts/init.js +698 -54
  150. package/scripts/inject-agent-instructions.js +431 -0
  151. package/scripts/install-prompt-reminders.js +188 -0
  152. package/scripts/install-tool-enforcement.js +220 -0
  153. package/scripts/smoke-test.js +12 -9
  154. package/scripts/uninstall.js +276 -18
  155. package/scripts/write-claude-rules.js +110 -0
@@ -11,15 +11,58 @@
11
11
  import Database from 'better-sqlite3';
12
12
  import { existsSync, statSync } from 'fs';
13
13
  import { applyReadPragmas } from './db-utils.js';
14
+ import { readAdjacentManifest, resolveManifestCodeGraphPath, sqlAliasPrefix } from './code-graph-visibility.js';
14
15
 
15
16
  export class CodeGraphRepository {
16
- constructor(dbPath) {
17
- this._dbPath = dbPath;
17
+ constructor(dbPath, options = {}) {
18
+ this._baseDbPath = dbPath;
19
+ this._explicitManifestEpoch = Number.isInteger(options.manifestEpoch);
20
+ this._manifestEpoch = this._explicitManifestEpoch ? options.manifestEpoch : null;
21
+ const manifest = this._explicitManifestEpoch ? readAdjacentManifest(this._baseDbPath) : null;
22
+ this._dbPath = this._explicitManifestEpoch
23
+ ? resolveManifestCodeGraphPath(this._baseDbPath, manifest)
24
+ : dbPath;
18
25
  this._db = null;
26
+ this._hasEntityEpochVisibility = null;
27
+ this._hasRelationshipEpochVisibility = null;
28
+ if (!this._explicitManifestEpoch) {
29
+ this._syncAdjacentManifest();
30
+ }
31
+ }
32
+
33
+ _syncAdjacentManifest() {
34
+ if (this._explicitManifestEpoch) return false;
35
+ const manifest = readAdjacentManifest(this._baseDbPath);
36
+ const nextEpoch = Number.isInteger(manifest?.epoch) ? manifest.epoch : null;
37
+ const nextDbPath = resolveManifestCodeGraphPath(this._baseDbPath, manifest);
38
+ const changed = nextEpoch !== this._manifestEpoch || nextDbPath !== this._dbPath;
39
+ this._manifestEpoch = nextEpoch;
40
+ this._dbPath = nextDbPath;
41
+ if (changed) {
42
+ this.close();
43
+ }
44
+ return changed;
45
+ }
46
+
47
+ refreshManifestEpoch() {
48
+ this._syncAdjacentManifest();
49
+ return this._manifestEpoch;
50
+ }
51
+
52
+ getManifestEpoch() {
53
+ return this._manifestEpoch;
54
+ }
55
+
56
+ _resetDerivedCaches() {
57
+ this._refCountExact = null;
58
+ this._refSuffixSafe = null;
59
+ this._refBareRelationshipFanout = null;
60
+ this._refCountIndex = null;
19
61
  }
20
62
 
21
63
  /** Lazy read-only connection with optimized pragmas. */
22
64
  _open() {
65
+ this._syncAdjacentManifest();
23
66
  if (!this._db) {
24
67
  if (!existsSync(this._dbPath)) return null;
25
68
  this._db = new Database(this._dbPath, { readonly: true });
@@ -28,6 +71,56 @@ export class CodeGraphRepository {
28
71
  return this._db;
29
72
  }
30
73
 
74
+ _hasColumns(db, table, columns) {
75
+ const rows = db.prepare(`PRAGMA table_info(${table})`).all();
76
+ const names = new Set(rows.map((row) => row.name));
77
+ return columns.every((column) => names.has(column));
78
+ }
79
+
80
+ _ensureVisibilityInfo(db) {
81
+ if (this._hasEntityEpochVisibility === null) {
82
+ this._hasEntityEpochVisibility = this._hasColumns(db, 'entities', ['epoch_written', 'epoch_retired']);
83
+ }
84
+ if (this._hasRelationshipEpochVisibility === null) {
85
+ this._hasRelationshipEpochVisibility = this._hasColumns(db, 'relationships', ['epoch_written', 'epoch_retired']);
86
+ }
87
+ }
88
+
89
+ _entityVisibilitySql(db, alias = '') {
90
+ this._ensureVisibilityInfo(db);
91
+ const prefix = sqlAliasPrefix(alias);
92
+ if (!this._hasEntityEpochVisibility) return `${prefix}stale_since IS NULL`;
93
+ if (this._manifestEpoch !== null) {
94
+ return `(${prefix}epoch_written IS NULL OR ${prefix}epoch_written <= ?)
95
+ AND (${prefix}epoch_retired IS NULL OR ${prefix}epoch_retired > ?)
96
+ AND (${prefix}stale_since IS NULL OR (${prefix}epoch_retired IS NOT NULL AND ${prefix}epoch_retired > ?))`;
97
+ }
98
+ return `${prefix}stale_since IS NULL AND ${prefix}epoch_retired IS NULL`;
99
+ }
100
+
101
+ _entityVisibilityParams(db) {
102
+ this._ensureVisibilityInfo(db);
103
+ if (!this._hasEntityEpochVisibility || this._manifestEpoch === null) return [];
104
+ return [this._manifestEpoch, this._manifestEpoch, this._manifestEpoch];
105
+ }
106
+
107
+ _relationshipVisibilitySql(db, alias = 'r') {
108
+ this._ensureVisibilityInfo(db);
109
+ if (!this._hasRelationshipEpochVisibility) return '1=1';
110
+ const prefix = sqlAliasPrefix(alias);
111
+ if (this._manifestEpoch !== null) {
112
+ return `(${prefix}epoch_written IS NULL OR ${prefix}epoch_written <= ?)
113
+ AND (${prefix}epoch_retired IS NULL OR ${prefix}epoch_retired > ?)`;
114
+ }
115
+ return `${prefix}epoch_retired IS NULL`;
116
+ }
117
+
118
+ _relationshipVisibilityParams(db) {
119
+ this._ensureVisibilityInfo(db);
120
+ if (!this._hasRelationshipEpochVisibility || this._manifestEpoch === null) return [];
121
+ return [this._manifestEpoch, this._manifestEpoch];
122
+ }
123
+
31
124
  /**
32
125
  * Find the tightest entity that fully encloses a given line range.
33
126
  *
@@ -39,34 +132,658 @@ export class CodeGraphRepository {
39
132
  * @param {string} filePath - Relative file path (as stored in entities.file_path)
40
133
  * @param {number} startLine - 1-indexed start line of the chunk
41
134
  * @param {number} endLine - 1-indexed end line of the chunk
42
- * @returns {{ name: string, type: string, startLine: number, endLine: number, parentClass: string|null }|null}
135
+ * @returns {{ id: string, name: string, type: string, startLine: number, endLine: number, parentClass: string|null }|null}
43
136
  */
44
137
  findEnclosingEntity(filePath, startLine, endLine) {
45
138
  const db = this._open();
46
139
  if (!db) return null;
47
140
  try {
48
141
  const row = db.prepare(`
49
- SELECT name, type, start_line, end_line, parent_class
142
+ SELECT id, name, type, start_line, end_line, parent_class
50
143
  FROM entities
51
144
  WHERE file_path = ?
52
145
  AND start_line <= ?
53
146
  AND end_line >= ?
147
+ AND ${this._entityVisibilitySql(db)}
54
148
  ORDER BY (end_line - start_line) ASC
55
149
  LIMIT 1
56
- `).get(filePath, startLine, endLine);
150
+ `).get(filePath, startLine, endLine, ...this._entityVisibilityParams(db));
151
+ if (!row) return null;
152
+ return {
153
+ id: row.id,
154
+ name: row.name,
155
+ type: row.type,
156
+ startLine: row.start_line,
157
+ endLine: row.end_line,
158
+ parentClass: row.parent_class || null,
159
+ };
160
+ } catch {
161
+ return null;
162
+ }
163
+ }
164
+
165
+ /**
166
+ * Find the first indexed entity fully contained in a chunk range.
167
+ * Useful when a chunk starts at file line 1 but includes the declaration
168
+ * shortly after imports/comments, so strict enclosing lookup cannot match.
169
+ *
170
+ * @param {string} filePath
171
+ * @param {number} startLine
172
+ * @param {number} endLine
173
+ * @returns {{ id: string, name: string, type: string, startLine: number, endLine: number, parentClass: string|null }|null}
174
+ */
175
+ /**
176
+ * Check whether a chunk range contains an entity whose name matches the target
177
+ * (case-insensitive, also tries snake_case ↔ camelCase normalization).
178
+ * Used by symbol-exact-match boost when chunk's labeled symbol doesn't match
179
+ * but a sibling/contained entity does.
180
+ *
181
+ * @param {string} filePath
182
+ * @param {number} startLine
183
+ * @param {number} endLine
184
+ * @param {string} targetName
185
+ * @returns {boolean}
186
+ */
187
+ /**
188
+ * Find the matching entity by name within a chunk range, if it exists.
189
+ * Returns the actual canonical name + type (so callers can relabel chunks
190
+ * to the contained-entity name when the query target matches).
191
+ *
192
+ * @param {string} filePath
193
+ * @param {number} startLine
194
+ * @param {number} endLine
195
+ * @param {string} targetName
196
+ * @returns {{ name: string, type: string, startLine: number, endLine: number }|null}
197
+ */
198
+ findEntityWithNameInRange(filePath, startLine, endLine, targetName) {
199
+ if (!targetName) return null;
200
+ const db = this._open();
201
+ if (!db) return null;
202
+ try {
203
+ const tLower = String(targetName).toLowerCase();
204
+ const tNorm = tLower.replace(/[_-]/g, '');
205
+ // Two-pass query: prefer entities FULLY CONTAINED in chunk range (most
206
+ // confident match); if none, fall back to entities whose START line is
207
+ // in the chunk range (catches entities like a multi-chunk struct whose
208
+ // declaration starts in this chunk but body extends beyond — e.g. gin
209
+ // Engine struct at gin.go:92-189 split across chunks 92-133 and 134-189).
210
+ const containedRows = db.prepare(`
211
+ SELECT name, type, start_line, end_line FROM entities
212
+ WHERE file_path = ? AND start_line >= ? AND end_line <= ? AND ${this._entityVisibilitySql(db)}
213
+ ORDER BY (end_line - start_line) DESC
214
+ `).all(filePath, startLine, endLine, ...this._entityVisibilityParams(db));
215
+ for (const row of containedRows) {
216
+ if (!row.name) continue;
217
+ const nLower = String(row.name).toLowerCase();
218
+ if (nLower === tLower || nLower.replace(/[_-]/g, '') === tNorm) {
219
+ return { name: row.name, type: row.type, startLine: row.start_line, endLine: row.end_line };
220
+ }
221
+ }
222
+ const startsInRows = db.prepare(`
223
+ SELECT name, type, start_line, end_line FROM entities
224
+ WHERE file_path = ? AND start_line >= ? AND start_line <= ? AND ${this._entityVisibilitySql(db)}
225
+ ORDER BY (end_line - start_line) DESC
226
+ `).all(filePath, startLine, endLine, ...this._entityVisibilityParams(db));
227
+ for (const row of startsInRows) {
228
+ if (!row.name) continue;
229
+ const nLower = String(row.name).toLowerCase();
230
+ if (nLower === tLower || nLower.replace(/[_-]/g, '') === tNorm) {
231
+ return { name: row.name, type: row.type, startLine: row.start_line, endLine: row.end_line };
232
+ }
233
+ }
234
+ return null;
235
+ } catch {
236
+ return null;
237
+ }
238
+ }
239
+
240
+ hasEntityWithNameInRange(filePath, startLine, endLine, targetName) {
241
+ return this.findEntityWithNameInRange(filePath, startLine, endLine, targetName) !== null;
242
+ }
243
+
244
+ findFirstEntityInRange(filePath, startLine, endLine) {
245
+ const db = this._open();
246
+ if (!db) return null;
247
+ try {
248
+ const row = db.prepare(`
249
+ SELECT id, name, type, start_line, end_line, parent_class
250
+ FROM entities
251
+ WHERE file_path = ?
252
+ AND start_line >= ?
253
+ AND start_line <= ?
254
+ AND ${this._entityVisibilitySql(db)}
255
+ ORDER BY start_line ASC, (end_line - start_line) ASC
256
+ LIMIT 1
257
+ `).get(filePath, startLine, endLine, ...this._entityVisibilityParams(db));
57
258
  if (!row) return null;
58
259
  return {
260
+ id: row.id,
261
+ name: row.name,
262
+ type: row.type,
263
+ startLine: row.start_line,
264
+ endLine: row.end_line,
265
+ parentClass: row.parent_class || null,
266
+ };
267
+ } catch {
268
+ return null;
269
+ }
270
+ }
271
+
272
+ /**
273
+ * Return ALL entities whose start_line falls in [startLine, endLine]
274
+ * (i.e. every entity declared inside the chunk's range). Used by the
275
+ * F9 additional_symbols re-anchoring rule (file-kind-ranking.js): when a
276
+ * chunk merged ≥2 top-level boundaries, the chunker's stored `symbol` is
277
+ * just the first boundary's name. Re-querying the entities table picks up
278
+ * every declared symbol in the chunk and lets ranking pick the best name
279
+ * match against the user's query.
280
+ *
281
+ * Capped at 64 to bound the per-result scan cost. Ordered by start_line.
282
+ *
283
+ * @param {string} filePath
284
+ * @param {number} startLine
285
+ * @param {number} endLine
286
+ * @returns {Array<{ id, name, type, startLine, endLine, parentClass }>}
287
+ */
288
+ findEntitiesInRange(filePath, startLine, endLine) {
289
+ const db = this._open();
290
+ if (!db) return [];
291
+ try {
292
+ const rows = db.prepare(`
293
+ SELECT id, name, type, start_line, end_line, parent_class
294
+ FROM entities
295
+ WHERE file_path = ?
296
+ AND start_line >= ?
297
+ AND start_line <= ?
298
+ AND ${this._entityVisibilitySql(db)}
299
+ ORDER BY start_line ASC
300
+ LIMIT 64
301
+ `).all(filePath, startLine, endLine, ...this._entityVisibilityParams(db));
302
+ return rows.map(row => ({
303
+ id: row.id,
59
304
  name: row.name,
60
305
  type: row.type,
61
306
  startLine: row.start_line,
62
307
  endLine: row.end_line,
63
308
  parentClass: row.parent_class || null,
309
+ }));
310
+ } catch {
311
+ return [];
312
+ }
313
+ }
314
+
315
+ /**
316
+ * Get a single entity by id, with file:line metadata.
317
+ *
318
+ * @param {string} entityId
319
+ * @returns {{ id, name, type, filePath, startLine, endLine, parentClass }|null}
320
+ */
321
+ getEntityById(entityId) {
322
+ const db = this._open();
323
+ if (!db) return null;
324
+ try {
325
+ const row = db.prepare(`
326
+ SELECT id, name, type, file_path, start_line, end_line, parent_class
327
+ FROM entities
328
+ WHERE id = ? AND ${this._entityVisibilitySql(db)}
329
+ `).get(entityId, ...this._entityVisibilityParams(db));
330
+ if (!row) return null;
331
+ return {
332
+ id: row.id, name: row.name, type: row.type,
333
+ filePath: row.file_path,
334
+ startLine: row.start_line, endLine: row.end_line,
335
+ parentClass: row.parent_class || null,
64
336
  };
65
337
  } catch {
66
338
  return null;
67
339
  }
68
340
  }
69
341
 
342
+ /**
343
+ * One-hop outgoing relationships from a given source entity.
344
+ *
345
+ * Returns up to `limit` (target_name, type, context_line, full_import_path)
346
+ * tuples joined to the target entity's metadata when target_id is resolved.
347
+ * Used by the agent context packager to render a "neighbours" tier.
348
+ *
349
+ * The relationship `type` field comes from graph-extractor and is one of:
350
+ * imports | calls | extends | implements | overrides | throws | uses
351
+ *
352
+ * @param {string} sourceId
353
+ * @param {object} [opts]
354
+ * @param {string[]} [opts.types] - filter to these types (default: all)
355
+ * @param {number} [opts.limit=8]
356
+ * @returns {Array<{ type, targetName, targetId: string|null, contextLine: number|null,
357
+ * fullImportPath: string|null, target: { id, name, type, filePath, startLine, endLine }|null }>}
358
+ */
359
+ getOutgoingRelationships(sourceId, opts = {}) {
360
+ const db = this._open();
361
+ if (!db || !sourceId) return [];
362
+ const limit = Math.max(1, Math.min(50, opts.limit ?? 8));
363
+ const types = (opts.types && opts.types.length) ? opts.types : null;
364
+ try {
365
+ const baseSql = `
366
+ SELECT r.target_id, r.target_name, r.type as rel_type, r.context_line,
367
+ r.full_import_path,
368
+ e.id as e_id, e.name as e_name, e.type as e_type,
369
+ e.file_path as e_file, e.start_line as e_start, e.end_line as e_end
370
+ FROM relationships r
371
+ LEFT JOIN entities e ON e.id = r.target_id AND ${this._entityVisibilitySql(db, 'e')}
372
+ WHERE r.source_id = ?
373
+ AND ${this._relationshipVisibilitySql(db, 'r')}
374
+ AND (r.target_id IS NULL OR e.id IS NOT NULL)
375
+ ${types ? `AND r.type IN (${types.map(() => '?').join(',')})` : ''}
376
+ ORDER BY (CASE WHEN r.target_id IS NULL THEN 1 ELSE 0 END), r.weight DESC
377
+ LIMIT ?
378
+ `;
379
+ const args = types
380
+ ? [...this._entityVisibilityParams(db), sourceId, ...this._relationshipVisibilityParams(db), ...types, limit]
381
+ : [...this._entityVisibilityParams(db), sourceId, ...this._relationshipVisibilityParams(db), limit];
382
+ const rows = db.prepare(baseSql).all(...args);
383
+ return rows.map(r => ({
384
+ type: r.rel_type,
385
+ targetName: r.target_name,
386
+ targetId: r.target_id || null,
387
+ contextLine: r.context_line || null,
388
+ fullImportPath: r.full_import_path || null,
389
+ target: r.e_id ? {
390
+ id: r.e_id, name: r.e_name, type: r.e_type,
391
+ filePath: r.e_file, startLine: r.e_start, endLine: r.e_end,
392
+ } : null,
393
+ }));
394
+ } catch {
395
+ return [];
396
+ }
397
+ }
398
+
399
+ /**
400
+ * Look up entities by name (case-sensitive), filtered to a set of types.
401
+ *
402
+ * Used by the agent context packager to surface TYPE definitions
403
+ * (struct/enum/interface/class/trait/type) referenced from the top-1's
404
+ * body — the relationships table only captures explicit edges (calls,
405
+ * imports), so a method that uses a struct via a field's type
406
+ * declaration leaves no edge. This name-based lookup recovers them.
407
+ *
408
+ * Returns at most one entity per (name, type) pair, preferring
409
+ * non-stale entries with the smallest body (most likely the canonical
410
+ * definition rather than a re-export).
411
+ *
412
+ * @param {string[]} names - candidate identifier names (Capitalized, etc.)
413
+ * @param {object} [opts]
414
+ * @param {string[]} [opts.types] - filter to entity types
415
+ * (default: ['struct','class','interface','enum','trait','type'])
416
+ * @param {number} [opts.limit=8] - cap total returned entities
417
+ * @param {string} [opts.excludeFile] - skip entities defined in this file
418
+ * (caller's own file, since same-file ranks already cover that)
419
+ * @returns {Array<{ id, name, type, filePath, startLine, endLine }>}
420
+ */
421
+ findEntitiesByNames(names, opts = {}) {
422
+ const db = this._open();
423
+ if (!db || !Array.isArray(names) || names.length === 0) return [];
424
+ const uniq = [...new Set(names.filter(n => typeof n === 'string' && n.length >= 2))];
425
+ if (!uniq.length) return [];
426
+ const types = (opts.types && opts.types.length)
427
+ ? opts.types
428
+ : ['struct', 'class', 'interface', 'enum', 'trait', 'type', 'typeAlias'];
429
+ const limit = Math.max(1, Math.min(32, opts.limit ?? 8));
430
+ const excludeFile = typeof opts.excludeFile === 'string' ? opts.excludeFile : null;
431
+ try {
432
+ // One row per (name, type), picking the smallest body when name collides
433
+ // (canonical definition rather than re-export). chunk-style entities are
434
+ // intentionally excluded to avoid false hits on test scaffolding.
435
+ const sql = `
436
+ SELECT id, name, type, file_path, start_line, end_line
437
+ FROM entities
438
+ WHERE name IN (${uniq.map(() => '?').join(',')})
439
+ AND type IN (${types.map(() => '?').join(',')})
440
+ AND ${this._entityVisibilitySql(db)}
441
+ ${excludeFile ? 'AND file_path != ?' : ''}
442
+ ORDER BY (end_line - start_line) ASC
443
+ LIMIT ?
444
+ `;
445
+ const args = excludeFile
446
+ ? [...uniq, ...types, ...this._entityVisibilityParams(db), excludeFile, limit]
447
+ : [...uniq, ...types, ...this._entityVisibilityParams(db), limit];
448
+ const rows = db.prepare(sql).all(...args);
449
+ // De-dup by name+type, keeping the first (smallest body).
450
+ const seen = new Set();
451
+ const out = [];
452
+ for (const r of rows) {
453
+ const k = `${r.name}|${r.type}`;
454
+ if (seen.has(k)) continue;
455
+ seen.add(k);
456
+ out.push({
457
+ id: r.id, name: r.name, type: r.type,
458
+ filePath: r.file_path, startLine: r.start_line, endLine: r.end_line,
459
+ });
460
+ }
461
+ return out;
462
+ } catch {
463
+ return [];
464
+ }
465
+ }
466
+
467
+ /**
468
+ * Case-insensitive variant for query-derived name hints. Used only for
469
+ * ranking tiebreakers where user prose may lowercase an entity name.
470
+ *
471
+ * @param {string[]} names
472
+ * @param {object} [opts]
473
+ * @returns {Array<{ id, name, type, filePath, startLine, endLine }>}
474
+ */
475
+ findEntitiesByNamesCaseInsensitive(names, opts = {}) {
476
+ const db = this._open();
477
+ if (!db || !Array.isArray(names) || names.length === 0) return [];
478
+ const uniq = [...new Set(names
479
+ .filter(n => typeof n === 'string' && n.length >= 2)
480
+ .map(n => n.toLowerCase()))];
481
+ if (!uniq.length) return [];
482
+ const types = (opts.types && opts.types.length)
483
+ ? opts.types
484
+ : ['struct', 'class', 'interface', 'enum', 'trait', 'type', 'typeAlias'];
485
+ const limit = Math.max(1, Math.min(32, opts.limit ?? 8));
486
+ try {
487
+ const sql = `
488
+ SELECT id, name, type, file_path, start_line, end_line
489
+ FROM entities
490
+ WHERE lower(name) IN (${uniq.map(() => '?').join(',')})
491
+ AND type IN (${types.map(() => '?').join(',')})
492
+ AND ${this._entityVisibilitySql(db)}
493
+ ORDER BY (end_line - start_line) ASC
494
+ LIMIT ?
495
+ `;
496
+ const rows = db.prepare(sql).all(...uniq, ...types, ...this._entityVisibilityParams(db), limit);
497
+ return rows.map(row => ({
498
+ id: row.id,
499
+ name: row.name,
500
+ type: row.type,
501
+ filePath: row.file_path,
502
+ startLine: row.start_line,
503
+ endLine: row.end_line,
504
+ }));
505
+ } catch {
506
+ return [];
507
+ }
508
+ }
509
+
510
+ /**
511
+ * Look up entities by name (case-insensitive) across ALL entity kinds —
512
+ * functions, methods, type aliases, structs, classes, etc. Used by the
513
+ * Identifier-Anchored Retrieval (IAR) layer in search-anchor.js, which
514
+ * extracts identifier-shaped tokens from natural-language queries and
515
+ * needs to find matching entities regardless of their declared kind.
516
+ *
517
+ * Distinct from `findEntitiesByNamesCaseInsensitive` (which filters to
518
+ * type-shaped kinds for the entity-kind ranking preference).
519
+ *
520
+ * Returns a small set per name, preferring the smallest body (canonical
521
+ * definition over re-exports). Excludes obviously-non-symbol kinds
522
+ * ('chunk', 'message', 'topKey', 'target', 'variable') so we don't
523
+ * surface generic constants on hits like "config".
524
+ *
525
+ * @param {string[]} names
526
+ * @param {object} [opts]
527
+ * @param {number} [opts.limit=16]
528
+ * @param {string[]} [opts.excludeKinds]
529
+ * @returns {Array<{ id, name, type, filePath, startLine, endLine }>}
530
+ */
531
+ findEntitiesByAnyName(names, opts = {}) {
532
+ const db = this._open();
533
+ if (!db || !Array.isArray(names) || names.length === 0) return [];
534
+ const uniq = [...new Set(names
535
+ .filter(n => typeof n === 'string' && n.length >= 2)
536
+ .map(n => n.toLowerCase()))];
537
+ if (!uniq.length) return [];
538
+ const exclude = Array.isArray(opts.excludeKinds) && opts.excludeKinds.length
539
+ ? opts.excludeKinds
540
+ : ['chunk', 'message', 'topKey', 'target', 'variable', 'const'];
541
+ const limit = Math.max(1, Math.min(64, opts.limit ?? 16));
542
+ try {
543
+ const sql = `
544
+ SELECT id, name, type, file_path, start_line, end_line
545
+ FROM entities
546
+ WHERE lower(name) IN (${uniq.map(() => '?').join(',')})
547
+ AND type NOT IN (${exclude.map(() => '?').join(',')})
548
+ AND ${this._entityVisibilitySql(db)}
549
+ ORDER BY (end_line - start_line) ASC
550
+ LIMIT ?
551
+ `;
552
+ const rows = db.prepare(sql).all(...uniq, ...exclude, ...this._entityVisibilityParams(db), limit);
553
+ return rows.map(row => ({
554
+ id: row.id,
555
+ name: row.name,
556
+ type: row.type,
557
+ filePath: row.file_path,
558
+ startLine: row.start_line,
559
+ endLine: row.end_line,
560
+ }));
561
+ } catch {
562
+ return [];
563
+ }
564
+ }
565
+
566
+ /**
567
+ * Count how many entities exist for each requested name (case-insensitive),
568
+ * across the same kind-set as `findEntitiesByAnyName`. Used by IAR to gate
569
+ * anchor injection on per-name uniqueness — common identifiers ("get",
570
+ * "set", "default") matching dozens of unrelated entities flood the
571
+ * candidate set; rare ones ("kSchemaController", "FST_ERR_HOOK_TIMEOUT")
572
+ * are safe.
573
+ *
574
+ * Mirrors KPR/SPAR's IDF-gated entity injection: the helpfulness of
575
+ * anchoring is proportional to entity rarity in the target corpus.
576
+ *
577
+ * @param {string[]} names
578
+ * @param {object} [opts]
579
+ * @param {string[]} [opts.excludeKinds]
580
+ * @returns {Map<string, number>} lowercase-name → entity count
581
+ */
582
+ countEntitiesByAnyName(names, opts = {}) {
583
+ const db = this._open();
584
+ if (!db || !Array.isArray(names) || names.length === 0) return new Map();
585
+ const uniq = [...new Set(names
586
+ .filter(n => typeof n === 'string' && n.length >= 2)
587
+ .map(n => n.toLowerCase()))];
588
+ if (!uniq.length) return new Map();
589
+ const exclude = Array.isArray(opts.excludeKinds) && opts.excludeKinds.length
590
+ ? opts.excludeKinds
591
+ : ['chunk', 'message', 'topKey', 'target', 'variable', 'const'];
592
+ try {
593
+ const sql = `
594
+ SELECT lower(name) as lname, COUNT(*) as count
595
+ FROM entities
596
+ WHERE lower(name) IN (${uniq.map(() => '?').join(',')})
597
+ AND type NOT IN (${exclude.map(() => '?').join(',')})
598
+ AND ${this._entityVisibilitySql(db)}
599
+ GROUP BY lname
600
+ `;
601
+ const rows = db.prepare(sql).all(...uniq, ...exclude, ...this._entityVisibilityParams(db));
602
+ const map = new Map();
603
+ for (const r of rows) map.set(r.lname, r.count);
604
+ // Names with no row are absent — caller treats absent as 0.
605
+ return map;
606
+ } catch {
607
+ return new Map();
608
+ }
609
+ }
610
+
611
+ /**
612
+ * One-hop incoming relationships into a given target entity (its callers,
613
+ * importers, etc.). Joined to the source entity for file:line rendering.
614
+ *
615
+ * @param {string} targetId
616
+ * @param {object} [opts]
617
+ * @param {string[]} [opts.types]
618
+ * @param {number} [opts.limit=8]
619
+ * @returns {Array<{ type, source: { id, name, type, filePath, startLine, endLine }|null, contextLine }>}
620
+ */
621
+ getIncomingRelationships(targetId, opts = {}) {
622
+ const db = this._open();
623
+ if (!db || !targetId) return [];
624
+ const limit = Math.max(1, Math.min(50, opts.limit ?? 8));
625
+ const types = (opts.types && opts.types.length) ? opts.types : null;
626
+ try {
627
+ const baseSql = `
628
+ SELECT r.source_id, r.type as rel_type, r.context_line,
629
+ e.id as e_id, e.name as e_name, e.type as e_type,
630
+ e.file_path as e_file, e.start_line as e_start, e.end_line as e_end
631
+ FROM relationships r
632
+ LEFT JOIN entities e ON e.id = r.source_id AND ${this._entityVisibilitySql(db, 'e')}
633
+ WHERE r.target_id = ?
634
+ AND ${this._relationshipVisibilitySql(db, 'r')}
635
+ AND e.id IS NOT NULL
636
+ ${types ? `AND r.type IN (${types.map(() => '?').join(',')})` : ''}
637
+ ORDER BY r.weight DESC
638
+ LIMIT ?
639
+ `;
640
+ const args = types
641
+ ? [...this._entityVisibilityParams(db), targetId, ...this._relationshipVisibilityParams(db), ...types, limit]
642
+ : [...this._entityVisibilityParams(db), targetId, ...this._relationshipVisibilityParams(db), limit];
643
+ const rows = db.prepare(baseSql).all(...args);
644
+ return rows.map(r => ({
645
+ type: r.rel_type,
646
+ contextLine: r.context_line || null,
647
+ source: r.e_id ? {
648
+ id: r.e_id, name: r.e_name, type: r.e_type,
649
+ filePath: r.e_file, startLine: r.e_start, endLine: r.e_end,
650
+ } : null,
651
+ })).filter(r => r.source);
652
+ } catch {
653
+ return [];
654
+ }
655
+ }
656
+
657
+ /**
658
+ * Build in-memory ref-count indexes for incoming `calls` edges.
659
+ *
660
+ * Two maps:
661
+ * - `_refCountExact`: `target_name` → count (one GROUP BY row each)
662
+ * - `_refSuffixSafe`: bare suffix → Σ counts, only for bare names whose
663
+ * relationship fanout (distinct qualified targets sharing that suffix)
664
+ * is ≤ SWEET_SEARCH_REF_SUFFIX_AGG_FANOUT_MAX (default 12; tighten via env).
665
+ *
666
+ * Previously we always merged every `*.decorate` into bare `decorate`, which
667
+ * is correct for real repos (few homonyms) but poisons GenCodeSearchNet-style
668
+ * corpora (dozens of unrelated `get` functions sharing one inflated total).
669
+ *
670
+ * Lookup rule (see countIncomingCallsByNames):
671
+ * - Qualified names (contain `.`) → exact map only
672
+ * - Bare names → suffix safe aggregate when present, else exact fallback
673
+ */
674
+ prebuildRefCountIndex() {
675
+ if (this._refCountExact) return this._refCountExact;
676
+ const exact = new Map();
677
+ const bareFanout = new Map();
678
+ const db = this._open();
679
+ if (!db) {
680
+ this._refCountExact = exact;
681
+ this._refSuffixSafe = new Map();
682
+ this._refBareRelationshipFanout = bareFanout;
683
+ this._refCountIndex = exact;
684
+ return exact;
685
+ }
686
+ const maxFan = (() => {
687
+ const raw = process.env.SWEET_SEARCH_REF_SUFFIX_AGG_FANOUT_MAX;
688
+ const n = parseInt(raw != null && raw !== '' ? raw : '12', 10);
689
+ return Number.isFinite(n) && n > 0 ? n : 12;
690
+ })();
691
+ try {
692
+ const rows = db.prepare(`
693
+ SELECT r.target_name, COUNT(*) as cnt
694
+ FROM relationships r
695
+ LEFT JOIN entities e ON e.id = r.target_id AND ${this._entityVisibilitySql(db, 'e')}
696
+ WHERE r.type = 'calls' AND r.target_name IS NOT NULL AND r.target_name != ''
697
+ AND ${this._relationshipVisibilitySql(db, 'r')}
698
+ AND (r.target_id IS NULL OR e.id IS NOT NULL)
699
+ GROUP BY target_name
700
+ `).all(...this._entityVisibilityParams(db), ...this._relationshipVisibilityParams(db));
701
+
702
+ for (const row of rows) {
703
+ const tn = row.target_name;
704
+ const cnt = row.cnt;
705
+ exact.set(tn, (exact.get(tn) || 0) + cnt);
706
+ const dot = tn.lastIndexOf('.');
707
+ const bareKey = dot > 0 ? tn.slice(dot + 1) : tn;
708
+ bareFanout.set(bareKey, (bareFanout.get(bareKey) || 0) + 1);
709
+ }
710
+
711
+ const suffix = new Map();
712
+ for (const row of rows) {
713
+ const tn = row.target_name;
714
+ const cnt = row.cnt;
715
+ const dot = tn.lastIndexOf('.');
716
+ const bareKey = dot > 0 ? tn.slice(dot + 1) : tn;
717
+ if ((bareFanout.get(bareKey) || 0) <= maxFan) {
718
+ suffix.set(bareKey, (suffix.get(bareKey) || 0) + cnt);
719
+ }
720
+ }
721
+
722
+ this._refSuffixSafe = suffix;
723
+ } catch {
724
+ this._refSuffixSafe = new Map();
725
+ }
726
+ this._refCountExact = exact;
727
+ this._refBareRelationshipFanout = bareFanout;
728
+ // Legacy field: kept for any code that peeked at the exact map
729
+ this._refCountIndex = exact;
730
+ return exact;
731
+ }
732
+
733
+ /**
734
+ * @param {string} bareName
735
+ * @returns {number}
736
+ */
737
+ relationshipBareFanout(bareName) {
738
+ if (!bareName || typeof bareName !== 'string') return 1;
739
+ this.prebuildRefCountIndex();
740
+ const m = this._refBareRelationshipFanout;
741
+ if (!m || !(m instanceof Map)) return 1;
742
+ return m.get(bareName) || 1;
743
+ }
744
+
745
+ /**
746
+ * Batched incoming-call count by entity NAME.
747
+ *
748
+ * For each name in `names`, returns the total number of `relationships`
749
+ * rows of type='calls' whose target is either the bare name or any
750
+ * qualified suffix (e.g. `fastify.decorate`, `app.decorate`). Backed by
751
+ * the lazy in-memory index built by `prebuildRefCountIndex` — first call
752
+ * pays ~10 ms; subsequent calls are sub-millisecond.
753
+ *
754
+ * Counts EXCLUDE 'imports', 'uses', 'extends', 'implements' — only direct
755
+ * `calls` participate. This keeps the signal behavioural (function-was-
756
+ * invoked) rather than structural (type-was-mentioned), matching the
757
+ * Aider PageRank / Cody ref-rank intent.
758
+ *
759
+ * Returns a Map<name, count> with one entry per input name (counts of 0
760
+ * are present, not omitted).
761
+ *
762
+ * @param {string[]} names
763
+ * @returns {Map<string, number>}
764
+ */
765
+ countIncomingCallsByNames(names) {
766
+ const out = new Map();
767
+ if (!Array.isArray(names) || names.length === 0) return out;
768
+ const uniq = [...new Set(names.filter(n => typeof n === 'string' && n.length >= 2))];
769
+ if (uniq.length === 0) return out;
770
+ this.prebuildRefCountIndex();
771
+ const exact = this._refCountExact || new Map();
772
+ const suffix = this._refSuffixSafe || new Map();
773
+ for (const name of uniq) {
774
+ const dot = name.lastIndexOf('.');
775
+ let c;
776
+ if (dot > 0) {
777
+ c = exact.get(name) || 0;
778
+ } else {
779
+ const agg = suffix.get(name);
780
+ c = agg !== undefined ? agg : (exact.get(name) || 0);
781
+ }
782
+ out.set(name, c);
783
+ }
784
+ return out;
785
+ }
786
+
70
787
  /**
71
788
  * Get the index timestamp for a file (from stale_since or fallback to mtime).
72
789
  *
@@ -80,7 +797,39 @@ export class CodeGraphRepository {
80
797
  const db = this._open();
81
798
  if (!db) return null;
82
799
  try {
83
- // Check if any entity for this file is stale
800
+ if (this._hasColumns(db, 'entities', ['epoch_written', 'epoch_retired'])) {
801
+ const visible = db.prepare(`
802
+ SELECT 1 FROM entities
803
+ WHERE file_path = ? AND ${this._entityVisibilitySql(db)}
804
+ LIMIT 1
805
+ `).get(filePath, ...this._entityVisibilityParams(db));
806
+ if (visible) return { staleSince: null };
807
+
808
+ const staleSql = this._manifestEpoch !== null
809
+ ? `
810
+ SELECT stale_since
811
+ FROM entities
812
+ WHERE file_path = ?
813
+ AND stale_since IS NOT NULL
814
+ AND (epoch_written IS NULL OR epoch_written <= ?)
815
+ AND (epoch_retired IS NULL OR epoch_retired <= ?)
816
+ ORDER BY stale_since DESC
817
+ LIMIT 1
818
+ `
819
+ : `
820
+ SELECT stale_since
821
+ FROM entities
822
+ WHERE file_path = ?
823
+ AND stale_since IS NOT NULL
824
+ ORDER BY stale_since DESC
825
+ LIMIT 1
826
+ `;
827
+ const staleArgs = this._manifestEpoch !== null
828
+ ? [filePath, this._manifestEpoch, this._manifestEpoch]
829
+ : [filePath];
830
+ const staleRow = db.prepare(staleSql).get(...staleArgs);
831
+ return staleRow ? { staleSince: staleRow.stale_since || null } : null;
832
+ }
84
833
  const row = db.prepare(`
85
834
  SELECT stale_since, MIN(ROWID) as first_row
86
835
  FROM entities
@@ -116,5 +865,8 @@ export class CodeGraphRepository {
116
865
  this._db.close();
117
866
  this._db = null;
118
867
  }
868
+ this._hasEntityEpochVisibility = null;
869
+ this._hasRelationshipEpochVisibility = null;
870
+ this._resetDerivedCaches();
119
871
  }
120
872
  }