@wrongstack/tools 0.306.3 → 0.307.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/dist/audit.js +50 -9
  2. package/dist/auto-proceed-loop-guard.js +8 -2
  3. package/dist/bash.js +57 -22
  4. package/dist/builtin.d.ts +6 -6
  5. package/dist/builtin.js +11315 -9317
  6. package/dist/clarify.d.ts +32 -0
  7. package/dist/codebase-index/ast-invariant-engine.d.ts +102 -0
  8. package/dist/codebase-index/ast-symbol-mutator.d.ts +28 -0
  9. package/dist/codebase-index/background-indexer.d.ts +9 -0
  10. package/dist/codebase-index/codebase-ast-replace-tool.d.ts +31 -0
  11. package/dist/codebase-index/codebase-impact-analysis-tool.d.ts +40 -0
  12. package/dist/codebase-index/codebase-invariant-check-tool.d.ts +23 -0
  13. package/dist/codebase-index/codebase-repo-map-tool.d.ts +22 -0
  14. package/dist/codebase-index/codebase-skeleton-tool.d.ts +36 -0
  15. package/dist/codebase-index/codebase-targeted-test-tool.d.ts +30 -0
  16. package/dist/codebase-index/index.d.ts +11 -1
  17. package/dist/codebase-index/index.js +4141 -1479
  18. package/dist/codebase-index/project-server-client-state.d.ts +79 -0
  19. package/dist/codebase-index/project-server-client.d.ts +3 -71
  20. package/dist/codebase-index/project-server-protocol.d.ts +1 -0
  21. package/dist/codebase-index/project-server.js +751 -909
  22. package/dist/codebase-index/repo-map.d.ts +20 -0
  23. package/dist/codebase-index/skeleton-extractor.d.ts +64 -0
  24. package/dist/codebase-index/tree-sitter-parser.d.ts +11 -0
  25. package/dist/codebase-index/worker.js +723 -891
  26. package/dist/codebase-index/writer-mutations.d.ts +26 -0
  27. package/dist/codebase-index/writer-refs.d.ts +50 -0
  28. package/dist/codebase-index/writer-search.d.ts +30 -0
  29. package/dist/codebase-index/writer.d.ts +2 -320
  30. package/dist/diff.js +3 -2
  31. package/dist/document.js +37 -2
  32. package/dist/e2e.js +3 -2
  33. package/dist/edit.js +8558 -348
  34. package/dist/exec.js +76 -22
  35. package/dist/fetch.js +11 -9
  36. package/dist/format.js +50 -9
  37. package/dist/glob.js +8 -3
  38. package/dist/grep.js +22 -10
  39. package/dist/index.d.ts +3 -1
  40. package/dist/index.js +11411 -9440
  41. package/dist/install.js +50 -9
  42. package/dist/json.js +91 -17
  43. package/dist/kanban-board-actions.d.ts +4 -0
  44. package/dist/kanban-lifecycle-actions.d.ts +4 -0
  45. package/dist/kanban-serializer.d.ts +21 -0
  46. package/dist/kanban.js +985 -1034
  47. package/dist/languages/index.js +17 -10
  48. package/dist/lint.js +50 -9
  49. package/dist/logs.js +17 -6
  50. package/dist/next-steps.d.ts +8 -0
  51. package/dist/next-steps.js +18 -0
  52. package/dist/outdated.js +18 -11
  53. package/dist/pack.js +11308 -9317
  54. package/dist/patch.js +8301 -77
  55. package/dist/plan.js +1231 -1281
  56. package/dist/process-registry.js +14 -8
  57. package/dist/ps-slash.js +65 -30
  58. package/dist/read.js +754 -912
  59. package/dist/replace.js +8456 -195
  60. package/dist/scaffold.js +36 -1
  61. package/dist/search.js +21 -15
  62. package/dist/security-ast-scan-tool.d.ts +43 -0
  63. package/dist/session-kanban-graph.d.ts +9 -0
  64. package/dist/session-kanban-sync.d.ts +31 -0
  65. package/dist/session-kanban.d.ts +5 -141
  66. package/dist/session-kanban.js +367 -360
  67. package/dist/task.js +1157 -1207
  68. package/dist/test.js +50 -9
  69. package/dist/todo.js +2187 -2237
  70. package/dist/tool-diff.js +6 -1
  71. package/dist/tool-summary.js +4 -2
  72. package/dist/tool-tier.js +11315 -9317
  73. package/dist/tree.js +36 -1
  74. package/dist/typecheck.js +51 -10
  75. package/dist/write.js +8374 -152
  76. package/package.json +7 -7
@@ -566,17 +566,44 @@ function fallbackParse(filePath, content, lang) {
566
566
  const col = line.length - trimmed.length + 1;
567
567
  const fn = /^func\s+(?:\([^)]*\)\s*)?([A-Za-z_]\w*)\s*\(/.exec(trimmed);
568
568
  if (fn?.[1]) {
569
- addFallbackSymbol(symbols, { filePath, lang, kind: trimmed.startsWith("func (") ? "method" : "function", name: fn[1], line: idx + 1, col, signature: trimmed, scope: packageName ? `${packageName}.${fn[1]}` : fn[1] });
569
+ addFallbackSymbol(symbols, {
570
+ filePath,
571
+ lang,
572
+ kind: trimmed.startsWith("func (") ? "method" : "function",
573
+ name: fn[1],
574
+ line: idx + 1,
575
+ col,
576
+ signature: trimmed,
577
+ scope: packageName ? `${packageName}.${fn[1]}` : fn[1]
578
+ });
570
579
  continue;
571
580
  }
572
581
  const typeDecl = /^type\s+([A-Za-z_]\w*)\b/.exec(trimmed);
573
582
  if (typeDecl?.[1]) {
574
- addFallbackSymbol(symbols, { filePath, lang, kind: "type", name: typeDecl[1], line: idx + 1, col, signature: trimmed, scope: packageName });
583
+ addFallbackSymbol(symbols, {
584
+ filePath,
585
+ lang,
586
+ kind: "type",
587
+ name: typeDecl[1],
588
+ line: idx + 1,
589
+ col,
590
+ signature: trimmed,
591
+ scope: packageName
592
+ });
575
593
  continue;
576
594
  }
577
595
  const valueDecl = /^(const|var)\s+([A-Za-z_]\w*)\b/.exec(trimmed);
578
596
  if (valueDecl?.[1] && valueDecl[2]) {
579
- addFallbackSymbol(symbols, { filePath, lang, kind: valueDecl[1], name: valueDecl[2], line: idx + 1, col, signature: trimmed, scope: packageName });
597
+ addFallbackSymbol(symbols, {
598
+ filePath,
599
+ lang,
600
+ kind: valueDecl[1],
601
+ name: valueDecl[2],
602
+ line: idx + 1,
603
+ col,
604
+ signature: trimmed,
605
+ scope: packageName
606
+ });
580
607
  }
581
608
  }
582
609
  return { file: filePath, lang, symbols, mtimeMs: Date.now() };
@@ -1027,7 +1054,10 @@ function parseGeneric(opts) {
1027
1054
  const seen = /* @__PURE__ */ new Set();
1028
1055
  const nlOffsets = newlineOffsets2(content);
1029
1056
  for (const pattern of patterns) {
1030
- const re = new RegExp(pattern.re.source, pattern.re.flags.includes("g") ? pattern.re.flags : `${pattern.re.flags}g`);
1057
+ const re = new RegExp(
1058
+ pattern.re.source,
1059
+ pattern.re.flags.includes("g") ? pattern.re.flags : `${pattern.re.flags}g`
1060
+ );
1031
1061
  re.lastIndex = 0;
1032
1062
  for (const match of content.matchAll(re)) {
1033
1063
  if (symbols.length >= maxSymbols) break;
@@ -1134,7 +1164,10 @@ var init_generic_parser = __esm({
1134
1164
  ],
1135
1165
  kotlin: [
1136
1166
  { re: /\b(?:fun)\s+([A-Za-z_]\w*)/g, kind: "function" },
1137
- { re: /\b(?:class|interface|object|enum\s+class|data\s+class)\s+([A-Za-z_]\w*)/g, kind: "class" }
1167
+ {
1168
+ re: /\b(?:class|interface|object|enum\s+class|data\s+class)\s+([A-Za-z_]\w*)/g,
1169
+ kind: "class"
1170
+ }
1138
1171
  ],
1139
1172
  scala: [
1140
1173
  { re: /\b(?:def)\s+([A-Za-z_]\w*)/g, kind: "function" },
@@ -1145,14 +1178,13 @@ var init_generic_parser = __esm({
1145
1178
  { re: /^([A-Za-z_][\w]*)\s*\(\)\s*\{/gm, kind: "function" }
1146
1179
  ],
1147
1180
  sql: [
1148
- { re: /\bCREATE\s+(?:OR\s+REPLACE\s+)?(?:TABLE|VIEW|INDEX|FUNCTION|PROCEDURE|TRIGGER)\s+(?:IF\s+NOT\s+EXISTS\s+)?([A-Za-z_"][\w."]*)/gi, kind: "type" }
1149
- ],
1150
- md: [
1151
- { re: /^(#{1,6})\s+(.+)$/gm, kind: "namespace" }
1152
- ],
1153
- toml: [
1154
- { re: /^\[([^\]]+)\]/gm, kind: "namespace" }
1181
+ {
1182
+ re: /\bCREATE\s+(?:OR\s+REPLACE\s+)?(?:TABLE|VIEW|INDEX|FUNCTION|PROCEDURE|TRIGGER)\s+(?:IF\s+NOT\s+EXISTS\s+)?([A-Za-z_"][\w."]*)/gi,
1183
+ kind: "type"
1184
+ }
1155
1185
  ],
1186
+ md: [{ re: /^(#{1,6})\s+(.+)$/gm, kind: "namespace" }],
1187
+ toml: [{ re: /^\[([^\]]+)\]/gm, kind: "namespace" }],
1156
1188
  html: [
1157
1189
  { re: /\bid\s*=\s*["']([^"']+)["']/gi, kind: "property" },
1158
1190
  { re: /<(?:script|template|style)\b/gi, kind: "namespace" }
@@ -1162,11 +1194,17 @@ var init_generic_parser = __esm({
1162
1194
  { re: /@(?:keyframes|media|supports)\s+([^{\s]+)/g, kind: "namespace" }
1163
1195
  ],
1164
1196
  vue: [
1165
- { re: /\b(?:function|const|let|var|class|export\s+(?:default\s+)?(?:function|class|const))\s+([A-Za-z_]\w*)/g, kind: "function" },
1197
+ {
1198
+ re: /\b(?:function|const|let|var|class|export\s+(?:default\s+)?(?:function|class|const))\s+([A-Za-z_]\w*)/g,
1199
+ kind: "function"
1200
+ },
1166
1201
  { re: /<(?:script|template|style)\b/gi, kind: "namespace" }
1167
1202
  ],
1168
1203
  svelte: [
1169
- { re: /\b(?:function|const|let|var|class|export\s+(?:default\s+)?(?:function|class|const))\s+([A-Za-z_]\w*)/g, kind: "function" }
1204
+ {
1205
+ re: /\b(?:function|const|let|var|class|export\s+(?:default\s+)?(?:function|class|const))\s+([A-Za-z_]\w*)/g,
1206
+ kind: "function"
1207
+ }
1170
1208
  ],
1171
1209
  dart: [
1172
1210
  { re: /\b(?:class|enum|mixin|extension)\s+([A-Za-z_]\w*)/g, kind: "class" },
@@ -1380,12 +1418,7 @@ async function syncPyParse(filePath, content, lang) {
1380
1418
  cachedPyBinary ??= resolvePython();
1381
1419
  const pyBinary = await cachedPyBinary;
1382
1420
  if (!pyBinary) return null;
1383
- const { code, stdout } = await spawnPyParser(
1384
- pyBinary,
1385
- _cachedScriptPath,
1386
- filePath,
1387
- content
1388
- );
1421
+ const { code, stdout } = await spawnPyParser(pyBinary, _cachedScriptPath, filePath, content);
1389
1422
  if (code !== 0 || !stdout.trim()) {
1390
1423
  return { file: filePath, lang, symbols: [], mtimeMs: Date.now() };
1391
1424
  }
@@ -2579,7 +2612,8 @@ __export(tree_sitter_parser_exports, {
2579
2612
  getGrammarWasmPath: () => getGrammarWasmPath,
2580
2613
  isTreeSitterSupported: () => isTreeSitterSupported,
2581
2614
  loadTreeSitterLanguage: () => loadTreeSitterLanguage,
2582
- parseSymbols: () => parseSymbols8
2615
+ parseSymbols: () => parseSymbols8,
2616
+ parseTreeSitterAst: () => parseTreeSitterAst
2583
2617
  });
2584
2618
  import * as path9 from "node:path";
2585
2619
  import { fileURLToPath } from "node:url";
@@ -2671,6 +2705,26 @@ async function __smokeRootType(opts) {
2671
2705
  parser.delete();
2672
2706
  }
2673
2707
  }
2708
+ async function parseTreeSitterAst(opts) {
2709
+ const grammar = resolveGrammarName(opts.lang) ?? (opts.lang === "go" ? "go" : opts.lang === "py" ? "python" : opts.lang === "rs" ? "rust" : void 0);
2710
+ if (!grammar) return null;
2711
+ try {
2712
+ const { Parser, Language, init } = await getRuntime();
2713
+ await init();
2714
+ const wasmPath = path9.join(WASM_DIR, grammar, `tree-sitter-${grammar}.wasm`);
2715
+ const languageObj = await Language.load(wasmPath);
2716
+ const parser = new Parser();
2717
+ parser.setLanguage(languageObj);
2718
+ const tree = parser.parse(opts.content);
2719
+ if (!tree) {
2720
+ parser.delete();
2721
+ return null;
2722
+ }
2723
+ return { tree, parser };
2724
+ } catch {
2725
+ return null;
2726
+ }
2727
+ }
2674
2728
  var WASM_DIR, RUNTIME_WASM, LANG_TO_GRAMMAR, GO_OPT_IN, PY_OPT_IN, RS_OPT_IN, runtimePromise, languageCache;
2675
2729
  var init_tree_sitter_parser = __esm({
2676
2730
  "src/codebase-index/tree-sitter-parser.ts"() {
@@ -3482,10 +3536,7 @@ var LANG_IMPORTS = {
3482
3536
  // quoted path. A stray match elsewhere resolves to no file and is dropped.
3483
3537
  { re: /^[ \t]*(?:[A-Za-z_.]\w*\s+)?"([^"]+)"\s*$/gm }
3484
3538
  ],
3485
- py: [
3486
- { re: /^[ \t]*import\s+([\w.]+)/gm },
3487
- { re: /^[ \t]*from\s+([.\w]+)\s+import\b/gm }
3488
- ],
3539
+ py: [{ re: /^[ \t]*import\s+([\w.]+)/gm }, { re: /^[ \t]*from\s+([.\w]+)\s+import\b/gm }],
3489
3540
  rs: [
3490
3541
  // use a::b::C; | use a::b::{C, D}; → the path before any brace
3491
3542
  { re: /^[ \t]*(?:pub\s+)?use\s+([\w:]+?)(?:::\{|\s*;|\s+as\b)/gm },
@@ -3822,10 +3873,7 @@ function defaultWorkerCount() {
3822
3873
  return Math.max(1, Math.min(4, cores - 1));
3823
3874
  }
3824
3875
  function resolveWorkerScriptUrl() {
3825
- for (const rel of [
3826
- "./parser-worker-script.js",
3827
- "./codebase-index/parser-worker-script.js"
3828
- ]) {
3876
+ for (const rel of ["./parser-worker-script.js", "./codebase-index/parser-worker-script.js"]) {
3829
3877
  try {
3830
3878
  const url = new URL(rel, import.meta.url);
3831
3879
  if (url.protocol === "file:" && fs6.existsSync(fileURLToPath2(url))) return url;
@@ -3841,7 +3889,6 @@ function getParserPool() {
3841
3889
  }
3842
3890
 
3843
3891
  // src/codebase-index/writer.ts
3844
- import { expectDefined as expectDefined4 } from "@wrongstack/core/utils";
3845
3892
  import * as fs8 from "node:fs";
3846
3893
  import * as path11 from "node:path";
3847
3894
 
@@ -3937,39 +3984,6 @@ var Bm25Index = class {
3937
3984
  // src/codebase-index/writer.ts
3938
3985
  init_languages();
3939
3986
 
3940
- // src/codebase-index/lsp-kind.ts
3941
- function lspKindToInternalKind(k) {
3942
- switch (k) {
3943
- case 5 /* Class */:
3944
- return "class";
3945
- case 6 /* Method */:
3946
- return "method";
3947
- case 7 /* Property */:
3948
- case 8 /* Field */:
3949
- return "property";
3950
- case 9 /* Constructor */:
3951
- return "class";
3952
- case 10 /* Enum */:
3953
- return "enum";
3954
- case 11 /* Interface */:
3955
- return "interface";
3956
- case 12 /* Function */:
3957
- return "function";
3958
- case 13 /* Variable */:
3959
- return "var";
3960
- case 14 /* Constant */:
3961
- return "const";
3962
- case 22 /* EnumMember */:
3963
- return "enum";
3964
- case 26 /* TypeParameter */:
3965
- return "type";
3966
- case 3 /* Namespace */:
3967
- return "namespace";
3968
- default:
3969
- return null;
3970
- }
3971
- }
3972
-
3973
3987
  // src/codebase-index/schema.ts
3974
3988
  var SCHEMA_VERSION = 4;
3975
3989
 
@@ -4116,91 +4130,14 @@ function runSqliteWithRetry(fn) {
4116
4130
  throw lastError;
4117
4131
  }
4118
4132
 
4119
- // src/codebase-index/vector-search.ts
4120
- var RRF_K = 60;
4121
- var VECTOR_DIMENSIONS = 384;
4122
- var NGRAM_SIZE = 3;
4123
- function embedText(text) {
4124
- const vec = new Float32Array(VECTOR_DIMENSIONS);
4125
- const normalized = text.toLowerCase().trim();
4126
- if (normalized.length < NGRAM_SIZE) {
4127
- const padded = ` ${normalized} `.slice(0, Math.max(NGRAM_SIZE, normalized.length + 2));
4128
- for (let i = 0; i <= padded.length - NGRAM_SIZE; i++) {
4129
- const ngram = padded.slice(i, i + NGRAM_SIZE);
4130
- const bucket = hashNgram(ngram) % VECTOR_DIMENSIONS;
4131
- vec[bucket] += 1;
4132
- }
4133
- } else {
4134
- for (let i = 0; i <= normalized.length - NGRAM_SIZE; i++) {
4135
- const ngram = normalized.slice(i, i + NGRAM_SIZE);
4136
- const bucket = hashNgram(ngram) % VECTOR_DIMENSIONS;
4137
- vec[bucket] += 1;
4138
- }
4139
- }
4140
- let norm = 0;
4141
- for (let i = 0; i < VECTOR_DIMENSIONS; i++) {
4142
- norm += vec[i] * vec[i];
4143
- }
4144
- norm = Math.sqrt(norm);
4145
- if (norm > 0) {
4146
- for (let i = 0; i < VECTOR_DIMENSIONS; i++) {
4147
- vec[i] /= norm;
4148
- }
4149
- }
4150
- return vec;
4151
- }
4152
- function hashNgram(str) {
4153
- let hash = 2166136261;
4154
- for (let i = 0; i < str.length; i++) {
4155
- hash ^= str.charCodeAt(i);
4156
- hash = Math.imul(hash, 16777619);
4157
- }
4158
- return hash >>> 0;
4159
- }
4160
- function cosineSimilarity(a, b) {
4161
- let dot = 0;
4162
- const len = Math.min(a.length, b.length);
4163
- for (let i = 0; i < len; i++) {
4164
- dot += a[i] * b[i];
4165
- }
4166
- return dot;
4167
- }
4168
- function encodeVector(vec) {
4169
- return Buffer.from(vec.buffer, vec.byteOffset, vec.byteLength);
4170
- }
4171
- function decodeVector(buf) {
4172
- const view = new DataView(
4173
- buf.buffer,
4174
- buf.byteOffset,
4175
- buf.byteLength
4176
- );
4177
- const copy = new Float32Array(buf.byteLength / 4);
4178
- for (let i = 0; i < copy.length; i++) {
4179
- copy[i] = view.getFloat32(i * 4, true);
4180
- }
4181
- return copy;
4182
- }
4183
- function reciprocalRankFusion(bm25Ranks, vectorRanks, k = RRF_K) {
4184
- const allIds = /* @__PURE__ */ new Set([...bm25Ranks.keys(), ...vectorRanks.keys()]);
4185
- const scored = [];
4186
- for (const id of allIds) {
4187
- const bm25Rank = bm25Ranks.get(id);
4188
- const vecRank = vectorRanks.get(id);
4189
- let score = 0;
4190
- if (bm25Rank !== void 0) score += 1 / (k + bm25Rank);
4191
- if (vecRank !== void 0) score += 1 / (k + vecRank);
4192
- scored.push([id, score]);
4193
- }
4194
- scored.sort((a, b) => b[1] - a[1]);
4195
- return scored;
4196
- }
4197
-
4198
4133
  // src/codebase-index/writer-admin.ts
4199
4134
  import * as fs7 from "node:fs";
4200
4135
  import * as path10 from "node:path";
4201
4136
  var DB_FILE = "index.db";
4202
4137
  function getAllIndexableWithStatement(stmt) {
4203
- return stmt("SELECT id, text FROM symbols").all().map(({ id, text }) => ({ id, text }));
4138
+ return stmt("SELECT id, text FROM symbols").all().map(
4139
+ ({ id, text }) => ({ id, text })
4140
+ );
4204
4141
  }
4205
4142
  function getMaxSymbolIdWithStatement(stmt) {
4206
4143
  const rows = stmt("SELECT MAX(id) AS m FROM symbols").all();
@@ -4540,7 +4477,8 @@ function resolveSymbolIds(stmt, symbolName, file) {
4540
4477
  }
4541
4478
  function findIncomingCallsByName(stmt, symbolName, file, limit) {
4542
4479
  const targetIds = resolveSymbolIds(stmt, symbolName, file);
4543
- if (targetIds.length === 0) return { calls: [], symbolFound: false, ambiguous: false, totalMatches: 0 };
4480
+ if (targetIds.length === 0)
4481
+ return { calls: [], symbolFound: false, ambiguous: false, totalMatches: 0 };
4544
4482
  let matchIds = targetIds;
4545
4483
  let ambiguous = false;
4546
4484
  if (file !== void 0) {
@@ -4591,11 +4529,17 @@ function findIncomingCallsByName(stmt, symbolName, file, limit) {
4591
4529
  }
4592
4530
  rows.sort((a, b) => a.ref_line - b.ref_line || a.sym_id - b.sym_id);
4593
4531
  const allCalls = rows.map(mapCallSiteRow);
4594
- return { calls: allCalls.slice(0, limit), symbolFound: true, ambiguous, totalMatches: allCalls.length };
4532
+ return {
4533
+ calls: allCalls.slice(0, limit),
4534
+ symbolFound: true,
4535
+ ambiguous,
4536
+ totalMatches: allCalls.length
4537
+ };
4595
4538
  }
4596
4539
  function findOutgoingCallsByName(stmt, symbolName, file, limit) {
4597
4540
  const sourceIds = resolveSymbolIds(stmt, symbolName, file);
4598
- if (sourceIds.length === 0) return { calls: [], symbolFound: false, unresolvedCount: 0, totalMatches: 0 };
4541
+ if (sourceIds.length === 0)
4542
+ return { calls: [], symbolFound: false, unresolvedCount: 0, totalMatches: 0 };
4599
4543
  const unresolvedCount = chunkedIdScalar(
4600
4544
  stmt,
4601
4545
  sourceIds,
@@ -4999,6 +4943,184 @@ function resolveIndexDir(projectRoot2, override) {
4999
4943
  return override ?? resolveWstackPaths({ projectRoot: projectRoot2 }).projectCodebaseIndex;
5000
4944
  }
5001
4945
 
4946
+ // src/codebase-index/vector-search.ts
4947
+ var RRF_K = 60;
4948
+ var VECTOR_DIMENSIONS = 384;
4949
+ var NGRAM_SIZE = 3;
4950
+ function embedText(text) {
4951
+ const vec = new Float32Array(VECTOR_DIMENSIONS);
4952
+ const normalized = text.toLowerCase().trim();
4953
+ if (normalized.length < NGRAM_SIZE) {
4954
+ const padded = ` ${normalized} `.slice(0, Math.max(NGRAM_SIZE, normalized.length + 2));
4955
+ for (let i = 0; i <= padded.length - NGRAM_SIZE; i++) {
4956
+ const ngram = padded.slice(i, i + NGRAM_SIZE);
4957
+ const bucket = hashNgram(ngram) % VECTOR_DIMENSIONS;
4958
+ vec[bucket] += 1;
4959
+ }
4960
+ } else {
4961
+ for (let i = 0; i <= normalized.length - NGRAM_SIZE; i++) {
4962
+ const ngram = normalized.slice(i, i + NGRAM_SIZE);
4963
+ const bucket = hashNgram(ngram) % VECTOR_DIMENSIONS;
4964
+ vec[bucket] += 1;
4965
+ }
4966
+ }
4967
+ let norm = 0;
4968
+ for (let i = 0; i < VECTOR_DIMENSIONS; i++) {
4969
+ norm += vec[i] * vec[i];
4970
+ }
4971
+ norm = Math.sqrt(norm);
4972
+ if (norm > 0) {
4973
+ for (let i = 0; i < VECTOR_DIMENSIONS; i++) {
4974
+ vec[i] /= norm;
4975
+ }
4976
+ }
4977
+ return vec;
4978
+ }
4979
+ function hashNgram(str) {
4980
+ let hash = 2166136261;
4981
+ for (let i = 0; i < str.length; i++) {
4982
+ hash ^= str.charCodeAt(i);
4983
+ hash = Math.imul(hash, 16777619);
4984
+ }
4985
+ return hash >>> 0;
4986
+ }
4987
+ function cosineSimilarity(a, b) {
4988
+ let dot = 0;
4989
+ const len = Math.min(a.length, b.length);
4990
+ for (let i = 0; i < len; i++) {
4991
+ dot += a[i] * b[i];
4992
+ }
4993
+ return dot;
4994
+ }
4995
+ function encodeVector(vec) {
4996
+ return Buffer.from(vec.buffer, vec.byteOffset, vec.byteLength);
4997
+ }
4998
+ function decodeVector(buf) {
4999
+ const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
5000
+ const copy = new Float32Array(buf.byteLength / 4);
5001
+ for (let i = 0; i < copy.length; i++) {
5002
+ copy[i] = view.getFloat32(i * 4, true);
5003
+ }
5004
+ return copy;
5005
+ }
5006
+ function reciprocalRankFusion(bm25Ranks, vectorRanks, k = RRF_K) {
5007
+ const allIds = /* @__PURE__ */ new Set([...bm25Ranks.keys(), ...vectorRanks.keys()]);
5008
+ const scored = [];
5009
+ for (const id of allIds) {
5010
+ const bm25Rank = bm25Ranks.get(id);
5011
+ const vecRank = vectorRanks.get(id);
5012
+ let score = 0;
5013
+ if (bm25Rank !== void 0) score += 1 / (k + bm25Rank);
5014
+ if (vecRank !== void 0) score += 1 / (k + vecRank);
5015
+ scored.push([id, score]);
5016
+ }
5017
+ scored.sort((a, b) => b[1] - a[1]);
5018
+ return scored;
5019
+ }
5020
+
5021
+ // src/codebase-index/writer-mutations.ts
5022
+ function commitBatchWithStatement(stmtFn, maxSqlVars, ftsAvailable, vectorsAvailable, allocateSymbolIds, invalidateIncomingRefsForFiles, resolveRefsForNamesUnsafe2, entries, options = {}) {
5023
+ if (entries.length === 0 && (options.deleteForFiles?.length ?? 0) === 0) {
5024
+ return [];
5025
+ }
5026
+ const affectedNames = /* @__PURE__ */ new Set();
5027
+ for (const entry of entries) {
5028
+ for (const symbol of entry.symbols) affectedNames.add(symbol.name);
5029
+ for (const ref of entry.refs) affectedNames.add(ref.toName);
5030
+ }
5031
+ if (options.deleteForFiles && options.deleteForFiles.length > 0) {
5032
+ const placeholders = options.deleteForFiles.map(() => "?").join(",");
5033
+ for (const name of invalidateIncomingRefsForFiles(options.deleteForFiles)) {
5034
+ affectedNames.add(name);
5035
+ }
5036
+ if (ftsAvailable) {
5037
+ stmtFn(
5038
+ `DELETE FROM symbols_fts WHERE rowid IN (SELECT id FROM symbols WHERE file IN (${placeholders}))`
5039
+ ).run(...options.deleteForFiles);
5040
+ }
5041
+ if (vectorsAvailable) {
5042
+ stmtFn(
5043
+ `DELETE FROM symbol_vectors WHERE symbol_id IN (SELECT id FROM symbols WHERE file IN (${placeholders}))`
5044
+ ).run(...options.deleteForFiles);
5045
+ }
5046
+ stmtFn(
5047
+ `DELETE FROM refs WHERE from_id IN (SELECT id FROM symbols WHERE file IN (${placeholders}))`
5048
+ ).run(...options.deleteForFiles);
5049
+ stmtFn(`DELETE FROM symbols WHERE file IN (${placeholders})`).run(...options.deleteForFiles);
5050
+ }
5051
+ const totalSymbols = entries.reduce((n, e) => n + e.symbols.length, 0);
5052
+ let nextId = allocateSymbolIds(totalSymbols);
5053
+ const allInserted = [];
5054
+ const refsToInsert = [];
5055
+ const bulkSyms = [];
5056
+ const ftsRows = [];
5057
+ const vectorRows = [];
5058
+ for (const entry of entries) {
5059
+ const insertedForEntry = [];
5060
+ for (const s of entry.symbols) {
5061
+ const id = nextId++;
5062
+ bulkSyms.push({
5063
+ id,
5064
+ lang: s.lang,
5065
+ kind: s.kind,
5066
+ name: s.name,
5067
+ file: s.file,
5068
+ line: s.line,
5069
+ col: s.col,
5070
+ signature: s.signature,
5071
+ docComment: s.docComment,
5072
+ scope: s.scope,
5073
+ text: s.text
5074
+ });
5075
+ if (ftsAvailable) {
5076
+ ftsRows.push({
5077
+ id,
5078
+ text: buildIndexableText(s.name, s.signature, s.docComment)
5079
+ });
5080
+ }
5081
+ vectorRows.push({
5082
+ id,
5083
+ vector: encodeVector(
5084
+ embedText(s.text || buildIndexableText(s.name, s.signature, s.docComment))
5085
+ )
5086
+ });
5087
+ const inserted = { ...s, id };
5088
+ allInserted.push(inserted);
5089
+ insertedForEntry.push(inserted);
5090
+ }
5091
+ refsToInsert.push(...assignRefsToSymbols(entry.refs, insertedForEntry));
5092
+ }
5093
+ bulkInsertSymbolsWithStatement((sql) => stmtFn(sql), maxSqlVars, bulkSyms);
5094
+ bulkInsertFtsWithStatement((sql) => stmtFn(sql), maxSqlVars, ftsAvailable, ftsRows);
5095
+ if (vectorsAvailable) {
5096
+ bulkInsertVectorsWithStatement((sql) => stmtFn(sql), maxSqlVars, vectorRows);
5097
+ }
5098
+ bulkInsertRefsWithStatement((sql) => stmtFn(sql), maxSqlVars, refsToInsert);
5099
+ const upsertStmt = stmtFn(
5100
+ `INSERT INTO files(file, lang, mtime_ms, content_hash, symbol_count, last_indexed)
5101
+ VALUES (?, ?, ?, ?, ?, ?)
5102
+ ON CONFLICT(file) DO UPDATE SET
5103
+ lang = excluded.lang,
5104
+ mtime_ms = excluded.mtime_ms,
5105
+ content_hash = excluded.content_hash,
5106
+ symbol_count = excluded.symbol_count,
5107
+ last_indexed = excluded.last_indexed`
5108
+ );
5109
+ const now = Date.now();
5110
+ for (const entry of entries) {
5111
+ upsertStmt.run(
5112
+ entry.file,
5113
+ entry.lang,
5114
+ entry.mtimeMs,
5115
+ entry.contentHash ?? "",
5116
+ entry.symbolCount,
5117
+ now
5118
+ );
5119
+ }
5120
+ resolveRefsForNamesUnsafe2(affectedNames);
5121
+ return allInserted;
5122
+ }
5123
+
5002
5124
  // src/codebase-index/writer-pragmas.ts
5003
5125
  import { sqliteCachePragmas } from "@wrongstack/core/utils";
5004
5126
  function applyIndexStorePragmas(db) {
@@ -5111,24 +5233,255 @@ var SYMBOL_VECTORS_TABLE_SQL = `
5111
5233
  );
5112
5234
  `;
5113
5235
 
5114
- // src/codebase-index/writer-search-helpers.ts
5115
- var SEARCH_CANDIDATE_SCAN_CAP = 5e3;
5116
- function normalizeSearchLimit(limit) {
5117
- return typeof limit === "number" && Number.isFinite(limit) ? Math.max(0, Math.trunc(limit)) : void 0;
5236
+ // src/codebase-index/writer-refs.ts
5237
+ var FAMILY_MATCH_SQL = `(
5238
+ sym.lang = refs.lang
5239
+ OR EXISTS (
5240
+ SELECT 1 FROM lang_family lf1
5241
+ JOIN lang_family lf2 ON lf1.family = lf2.family
5242
+ WHERE lf1.lang = sym.lang AND lf2.lang = refs.lang
5243
+ )
5244
+ OR ? IN (
5245
+ SELECT family FROM lang_family WHERE lang = refs.lang
5246
+ )
5247
+ )`;
5248
+ function getNamespaceDeclarationsWithStatement(stmtFn) {
5249
+ return stmtFn(
5250
+ `SELECT name, file FROM symbols WHERE kind = 'namespace' ORDER BY file, id`
5251
+ ).all();
5118
5252
  }
5119
- function buildWriterSearchWhere(query, filter) {
5120
- const conditions = [];
5121
- const values = [];
5122
- let effectiveKind = filter?.kind;
5123
- if (filter?.lspKind !== void 0) {
5124
- const mapped = lspKindToInternalKind(filter.lspKind);
5125
- if (mapped !== null) {
5126
- effectiveKind = mapped;
5127
- } else {
5128
- return null;
5129
- }
5253
+ function getFilePackagesWithStatement(stmtFn) {
5254
+ const rows = stmtFn("SELECT file, package FROM files WHERE package != ''").all();
5255
+ return new Map(rows.map((row) => [row.file, row.package]));
5256
+ }
5257
+ function getUnresolvedImportsWithStatement(stmtFn, maxSqlVars, onlyFiles) {
5258
+ const base = `SELECT DISTINCT s.file AS fromFile, r.lang AS lang, r.module AS module
5259
+ FROM refs r
5260
+ JOIN symbols s ON s.id = r.from_id
5261
+ WHERE r.call_type = 'import' AND r.module IS NOT NULL`;
5262
+ if (!onlyFiles?.length) {
5263
+ return stmtFn(base).all();
5130
5264
  }
5131
- if (effectiveKind) {
5265
+ const out = [];
5266
+ for (let i = 0; i < onlyFiles.length; i += maxSqlVars) {
5267
+ const chunk = onlyFiles.slice(i, i + maxSqlVars);
5268
+ const placeholders = chunk.map(() => "?").join(",");
5269
+ out.push(
5270
+ ...stmtFn(`${base} AND s.file IN (${placeholders})`).all(...chunk)
5271
+ );
5272
+ }
5273
+ return out;
5274
+ }
5275
+ function getAllResolvedRefsWithStatement(stmtFn) {
5276
+ return stmtFn(
5277
+ "SELECT from_id AS fromId, to_id AS toId, call_type AS callType FROM refs WHERE to_id IS NOT NULL"
5278
+ ).all();
5279
+ }
5280
+ function getAllImportRefsWithStatement(stmtFn) {
5281
+ return stmtFn(
5282
+ `SELECT s.file AS sourceFile, r.to_name AS toName, r.to_id AS toId,
5283
+ r.call_type AS callType, r.line
5284
+ FROM refs r
5285
+ LEFT JOIN symbols s ON r.from_id = s.id
5286
+ WHERE r.call_type = 'import'
5287
+ ORDER BY r.line`
5288
+ ).all();
5289
+ }
5290
+ function resolveRefsWithStatement(stmtFn) {
5291
+ try {
5292
+ const result = stmtFn(
5293
+ `UPDATE refs
5294
+ SET to_id = s.id
5295
+ FROM (
5296
+ SELECT sym.name AS name, lf.family AS family, MIN(sym.id) AS id
5297
+ FROM symbols sym
5298
+ JOIN lang_family lf ON lf.lang = sym.lang
5299
+ GROUP BY sym.name, lf.family
5300
+ UNION ALL
5301
+ SELECT sym.name AS name, '${LANG_FAMILY_WILDCARD}' AS family, MIN(sym.id) AS id
5302
+ FROM symbols sym
5303
+ GROUP BY sym.name
5304
+ ) AS s,
5305
+ lang_family AS rf
5306
+ WHERE refs.to_id IS NULL
5307
+ AND refs.to_name IS NOT NULL
5308
+ AND rf.lang = refs.lang
5309
+ AND s.name = refs.to_name
5310
+ AND s.family = rf.family`
5311
+ ).run();
5312
+ return result.changes ?? 0;
5313
+ } catch {
5314
+ const result = stmtFn(
5315
+ `UPDATE refs SET to_id = (
5316
+ SELECT sym.id FROM symbols sym
5317
+ WHERE sym.name = refs.to_name AND ${FAMILY_MATCH_SQL}
5318
+ ORDER BY sym.id LIMIT 1
5319
+ ) WHERE to_id IS NULL AND to_name IS NOT NULL
5320
+ AND EXISTS (
5321
+ SELECT 1 FROM symbols sym
5322
+ WHERE sym.name = refs.to_name AND ${FAMILY_MATCH_SQL}
5323
+ )`
5324
+ ).run(LANG_FAMILY_WILDCARD, LANG_FAMILY_WILDCARD);
5325
+ return result.changes ?? 0;
5326
+ }
5327
+ }
5328
+ function applyImportResolutionsWithStatement(db, stmtFn, runWithRetry, maxSqlVars, resolutions) {
5329
+ if (resolutions.length === 0) return 0;
5330
+ return runWithRetry(() => {
5331
+ db.exec("DROP TABLE IF EXISTS temp.import_resolution");
5332
+ db.exec(
5333
+ `CREATE TEMP TABLE import_resolution (
5334
+ from_file TEXT NOT NULL,
5335
+ lang TEXT NOT NULL,
5336
+ module TEXT NOT NULL,
5337
+ to_file TEXT NOT NULL
5338
+ )`
5339
+ );
5340
+ const chunkSize = Math.max(1, Math.floor(maxSqlVars / 4));
5341
+ for (let i = 0; i < resolutions.length; i += chunkSize) {
5342
+ const chunk = resolutions.slice(i, i + chunkSize);
5343
+ const placeholders = chunk.map(() => "(?, ?, ?, ?)").join(", ");
5344
+ const binds = [];
5345
+ for (const entry of chunk) {
5346
+ binds.push(entry.fromFile, entry.lang, entry.module, entry.toFile);
5347
+ }
5348
+ stmtFn(
5349
+ `INSERT INTO temp.import_resolution(from_file, lang, module, to_file)
5350
+ VALUES ${placeholders}`
5351
+ ).run(...binds);
5352
+ }
5353
+ db.exec(
5354
+ `CREATE INDEX IF NOT EXISTS temp.idx_ir
5355
+ ON import_resolution(module, lang, from_file)`
5356
+ );
5357
+ const result = stmtFn(
5358
+ `UPDATE refs
5359
+ SET to_file = (
5360
+ SELECT ir.to_file
5361
+ FROM temp.import_resolution ir
5362
+ JOIN symbols s ON s.id = refs.from_id
5363
+ WHERE ir.module = refs.module
5364
+ AND ir.lang = refs.lang
5365
+ AND ir.from_file = s.file
5366
+ LIMIT 1
5367
+ )
5368
+ WHERE refs.call_type = 'import'
5369
+ AND refs.module IS NOT NULL
5370
+ AND EXISTS (
5371
+ SELECT 1
5372
+ FROM temp.import_resolution ir
5373
+ JOIN symbols s ON s.id = refs.from_id
5374
+ WHERE ir.module = refs.module
5375
+ AND ir.lang = refs.lang
5376
+ AND ir.from_file = s.file
5377
+ )`
5378
+ ).run();
5379
+ db.exec("DROP TABLE IF EXISTS temp.import_resolution");
5380
+ return result.changes ?? 0;
5381
+ });
5382
+ }
5383
+ function resolveRefsForNamesUnsafe(stmtFn, maxSqlVars, names) {
5384
+ const list = [...names].filter((name) => name.length > 0);
5385
+ if (list.length === 0) return 0;
5386
+ let total = 0;
5387
+ for (let i = 0; i < list.length; i += maxSqlVars) {
5388
+ const chunk = list.slice(i, i + maxSqlVars);
5389
+ const placeholders = chunk.map(() => "?").join(",");
5390
+ try {
5391
+ const result = stmtFn(
5392
+ `UPDATE refs
5393
+ SET to_id = s.id
5394
+ FROM (
5395
+ SELECT sym.name AS name, lf.family AS family, MIN(sym.id) AS id
5396
+ FROM symbols sym
5397
+ JOIN lang_family lf ON lf.lang = sym.lang
5398
+ WHERE sym.name IN (${placeholders})
5399
+ GROUP BY sym.name, lf.family
5400
+ UNION ALL
5401
+ SELECT sym.name AS name, '${LANG_FAMILY_WILDCARD}' AS family, MIN(sym.id) AS id
5402
+ FROM symbols sym
5403
+ WHERE sym.name IN (${placeholders})
5404
+ GROUP BY sym.name
5405
+ ) AS s,
5406
+ lang_family AS rf
5407
+ WHERE refs.to_name IN (${placeholders})
5408
+ AND rf.lang = refs.lang
5409
+ AND s.name = refs.to_name
5410
+ AND s.family = rf.family`
5411
+ ).run(...chunk, ...chunk, ...chunk);
5412
+ total += result.changes ?? 0;
5413
+ } catch {
5414
+ const result = stmtFn(
5415
+ `UPDATE refs SET to_id = (
5416
+ SELECT sym.id FROM symbols sym
5417
+ WHERE sym.name = refs.to_name AND ${FAMILY_MATCH_SQL}
5418
+ ORDER BY sym.id LIMIT 1
5419
+ ) WHERE refs.to_name IN (${placeholders})
5420
+ AND EXISTS (
5421
+ SELECT 1 FROM symbols sym
5422
+ WHERE sym.name = refs.to_name AND ${FAMILY_MATCH_SQL}
5423
+ )`
5424
+ ).run(LANG_FAMILY_WILDCARD, ...chunk, LANG_FAMILY_WILDCARD);
5425
+ total += result.changes ?? 0;
5426
+ }
5427
+ }
5428
+ return total;
5429
+ }
5430
+
5431
+ // src/codebase-index/writer-search.ts
5432
+ import { expectDefined as expectDefined4 } from "@wrongstack/core/utils";
5433
+
5434
+ // src/codebase-index/lsp-kind.ts
5435
+ function lspKindToInternalKind(k) {
5436
+ switch (k) {
5437
+ case 5 /* Class */:
5438
+ return "class";
5439
+ case 6 /* Method */:
5440
+ return "method";
5441
+ case 7 /* Property */:
5442
+ case 8 /* Field */:
5443
+ return "property";
5444
+ case 9 /* Constructor */:
5445
+ return "class";
5446
+ case 10 /* Enum */:
5447
+ return "enum";
5448
+ case 11 /* Interface */:
5449
+ return "interface";
5450
+ case 12 /* Function */:
5451
+ return "function";
5452
+ case 13 /* Variable */:
5453
+ return "var";
5454
+ case 14 /* Constant */:
5455
+ return "const";
5456
+ case 22 /* EnumMember */:
5457
+ return "enum";
5458
+ case 26 /* TypeParameter */:
5459
+ return "type";
5460
+ case 3 /* Namespace */:
5461
+ return "namespace";
5462
+ default:
5463
+ return null;
5464
+ }
5465
+ }
5466
+
5467
+ // src/codebase-index/writer-search-helpers.ts
5468
+ var SEARCH_CANDIDATE_SCAN_CAP = 5e3;
5469
+ function normalizeSearchLimit(limit) {
5470
+ return typeof limit === "number" && Number.isFinite(limit) ? Math.max(0, Math.trunc(limit)) : void 0;
5471
+ }
5472
+ function buildWriterSearchWhere(query, filter) {
5473
+ const conditions = [];
5474
+ const values = [];
5475
+ let effectiveKind = filter?.kind;
5476
+ if (filter?.lspKind !== void 0) {
5477
+ const mapped = lspKindToInternalKind(filter.lspKind);
5478
+ if (mapped !== null) {
5479
+ effectiveKind = mapped;
5480
+ } else {
5481
+ return null;
5482
+ }
5483
+ }
5484
+ if (effectiveKind) {
5132
5485
  conditions.push("kind = ?");
5133
5486
  values.push(effectiveKind);
5134
5487
  }
@@ -5164,6 +5517,173 @@ function mapWriterSearchRow(row, lspKind, score = 0, snippet = "") {
5164
5517
  };
5165
5518
  }
5166
5519
 
5520
+ // src/codebase-index/writer-search.ts
5521
+ function searchWithStatement(stmtFn, query, filter, opts) {
5522
+ const built = buildWriterSearchWhere(query, filter);
5523
+ if (built === null) return [];
5524
+ const { where, values } = built;
5525
+ const limit = normalizeSearchLimit(opts?.limit);
5526
+ const limitSql = limit !== void 0 ? " LIMIT ?" : "";
5527
+ const sql = `SELECT id, lang, kind, name, file, line, col, signature, doc_comment FROM symbols ${where}${limitSql}`;
5528
+ const binds = limit !== void 0 ? [...values, limit] : values;
5529
+ const rows = stmtFn(sql).all(...binds);
5530
+ return rows.map((row) => mapWriterSearchRow(row, filter?.lspKind));
5531
+ }
5532
+ function countSearchWithStatement(stmtFn, query, filter) {
5533
+ const built = buildWriterSearchWhere(query, filter);
5534
+ if (built === null) return 0;
5535
+ const row = stmtFn(`SELECT COUNT(*) AS n FROM symbols ${built.where}`).get(
5536
+ ...built.values
5537
+ );
5538
+ return Number(row?.n ?? 0);
5539
+ }
5540
+ function searchRankedWithStatement(stmtFn, searchFn, ftsAvailable, vectorsAvailable, getOrBuildBm25, query, filter, limit) {
5541
+ const rawLimit = Number.isFinite(limit) ? Math.trunc(limit) : 20;
5542
+ const safeLimit = Math.max(1, Math.min(rawLimit, 100));
5543
+ const tokens = tokenise(query);
5544
+ if (tokens.length === 0 || !ftsAvailable) {
5545
+ return searchRankedFallbackWithStatement(
5546
+ stmtFn,
5547
+ searchFn,
5548
+ getOrBuildBm25,
5549
+ query,
5550
+ filter,
5551
+ safeLimit
5552
+ );
5553
+ }
5554
+ let effectiveKind = filter?.kind;
5555
+ if (filter?.lspKind !== void 0) {
5556
+ const mapped = lspKindToInternalKind(filter.lspKind);
5557
+ if (mapped === null) return { results: [], total: 0 };
5558
+ effectiveKind = mapped;
5559
+ }
5560
+ const longTokens = tokens.filter((t) => t.length >= 3);
5561
+ const shortTokens = tokens.filter((t) => t.length < 3);
5562
+ if (longTokens.length === 0) {
5563
+ return searchRankedFallbackWithStatement(
5564
+ stmtFn,
5565
+ searchFn,
5566
+ getOrBuildBm25,
5567
+ query,
5568
+ filter,
5569
+ safeLimit
5570
+ );
5571
+ }
5572
+ const match = longTokens.map((t) => `"${t.replaceAll('"', "")}"`).join(" OR ");
5573
+ const conditions = ["symbols_fts MATCH ?"];
5574
+ const values = [match];
5575
+ for (const shortTok of shortTokens) {
5576
+ conditions.push("s.text LIKE ? ESCAPE '\\'");
5577
+ values.push(`%${escapeLike(shortTok)}%`);
5578
+ }
5579
+ if (effectiveKind) {
5580
+ conditions.push("s.kind = ?");
5581
+ values.push(effectiveKind);
5582
+ }
5583
+ if (filter?.lang) {
5584
+ conditions.push("s.lang = ?");
5585
+ values.push(filter.lang);
5586
+ }
5587
+ if (filter?.file) {
5588
+ conditions.push("replace(s.file, '\\', '/') LIKE ? ESCAPE '\\'");
5589
+ values.push(`%${escapeLike(filter.file.replace(/\\/g, "/"))}%`);
5590
+ }
5591
+ const where = conditions.join(" AND ");
5592
+ const countRows = stmtFn(
5593
+ `SELECT COUNT(*) AS n FROM symbols_fts JOIN symbols s ON s.id = symbols_fts.rowid WHERE ${where}`
5594
+ ).all(...values);
5595
+ const total = countRows[0] ? Number(countRows[0].n) : 0;
5596
+ if (total === 0) return { results: [], total: 0 };
5597
+ const bm25Rows = stmtFn(
5598
+ `SELECT s.id, s.lang, s.kind, s.name, s.file, s.line, s.col, s.signature, s.doc_comment,
5599
+ -bm25(symbols_fts) AS score,
5600
+ snippet(symbols_fts, 0, '', '', '\u2026', 12) AS snippet
5601
+ FROM symbols_fts JOIN symbols s ON s.id = symbols_fts.rowid
5602
+ WHERE ${where}
5603
+ ORDER BY
5604
+ CASE WHEN lower(s.name) = lower(?) THEN 0
5605
+ WHEN lower(s.name) LIKE lower(?) ESCAPE '\\' THEN 1
5606
+ ELSE 2 END,
5607
+ bm25(symbols_fts), lower(s.name), s.file, s.line, s.col, s.id
5608
+ LIMIT ?`
5609
+ ).all(...values, query.trim(), `${escapeLike(query.trim())}%`, safeLimit);
5610
+ if (vectorsAvailable && bm25Rows.length > 0) {
5611
+ const queryVec = embedText(query);
5612
+ const candidateIds = bm25Rows.map((r) => r.id);
5613
+ const placeholders = candidateIds.map(() => "?").join(",");
5614
+ const vecRows = stmtFn(
5615
+ `SELECT sv.symbol_id, sv.vector FROM symbol_vectors sv WHERE sv.symbol_id IN (${placeholders})`
5616
+ ).all(...candidateIds);
5617
+ const vecScores = vecRows.map((r) => ({
5618
+ id: r.symbol_id,
5619
+ sim: cosineSimilarity(queryVec, decodeVector(r.vector))
5620
+ })).sort((a, b) => b.sim - a.sim);
5621
+ const bm25Rank = /* @__PURE__ */ new Map();
5622
+ bm25Rows.forEach((r, i) => {
5623
+ bm25Rank.set(r.id, i);
5624
+ });
5625
+ const vecRank = /* @__PURE__ */ new Map();
5626
+ vecScores.forEach((r, i) => {
5627
+ vecRank.set(r.id, i);
5628
+ });
5629
+ const fused = reciprocalRankFusion(bm25Rank, vecRank, 60);
5630
+ const fusedScore = new Map(fused);
5631
+ const sorted = [...bm25Rows].sort(
5632
+ (a, b) => (fusedScore.get(b.id) ?? 0) - (fusedScore.get(a.id) ?? 0)
5633
+ );
5634
+ return {
5635
+ results: sorted.map(
5636
+ (row) => mapWriterSearchRow(row, filter?.lspKind, Math.max(1e-4, row.score), row.snippet)
5637
+ ),
5638
+ total
5639
+ };
5640
+ }
5641
+ return {
5642
+ results: bm25Rows.map(
5643
+ (row) => mapWriterSearchRow(row, filter?.lspKind, Math.max(1e-4, row.score), row.snippet)
5644
+ ),
5645
+ total
5646
+ };
5647
+ }
5648
+ function searchRankedFallbackWithStatement(stmtFn, searchFn, getOrBuildBm25, query, filter, limit) {
5649
+ if (!query.trim()) {
5650
+ const total2 = countSearchWithStatement(stmtFn, query, filter);
5651
+ if (total2 === 0) return { results: [], total: 0 };
5652
+ return { results: searchFn(query, filter, { limit }), total: total2 };
5653
+ }
5654
+ const total = countSearchWithStatement(stmtFn, query, filter);
5655
+ if (total === 0) return { results: [], total: 0 };
5656
+ const candidates = searchFn(query, filter, {
5657
+ limit: SEARCH_CANDIDATE_SCAN_CAP
5658
+ });
5659
+ if (candidates.length === 0) return { results: [], total: 0 };
5660
+ const candidateById = new Map(candidates.map((c) => [c.id, c]));
5661
+ const bm25 = getOrBuildBm25();
5662
+ const scored = bm25.score(query, (id) => candidateById.has(id));
5663
+ const q = query.trim().toLowerCase();
5664
+ const rank = (id) => {
5665
+ const name = candidateById.get(id)?.name.toLowerCase() ?? "";
5666
+ if (name === q) return 0;
5667
+ if (name.startsWith(q)) return 1;
5668
+ return 2;
5669
+ };
5670
+ scored.sort((a, b) => {
5671
+ const rankDiff = rank(a.id) - rank(b.id);
5672
+ if (rankDiff !== 0) return rankDiff;
5673
+ const scoreDiff = b.score - a.score;
5674
+ if (scoreDiff !== 0) return scoreDiff;
5675
+ const left = expectDefined4(candidateById.get(a.id));
5676
+ const right = expectDefined4(candidateById.get(b.id));
5677
+ return left.name.localeCompare(right.name) || left.file.localeCompare(right.file) || left.line - right.line || left.col - right.col || left.id - right.id;
5678
+ });
5679
+ const qTokens = tokenise(query);
5680
+ const results = scored.slice(0, limit).map(({ id, score }) => {
5681
+ const c = expectDefined4(candidateById.get(id));
5682
+ return { ...c, score, snippet: bm25.extractSnippet(id, qTokens) };
5683
+ });
5684
+ return { results, total };
5685
+ }
5686
+
5167
5687
  // src/codebase-index/writer-store-pool.ts
5168
5688
  var DEFAULT_MAX_WARM_STORES = 2;
5169
5689
  var StorePool = class {
@@ -5255,68 +5775,14 @@ var DB_FILE2 = "index.db";
5255
5775
  var MAX_STATEMENT_CACHE = 128;
5256
5776
  var IndexStore = class _IndexStore {
5257
5777
  db;
5258
- /**
5259
- * True while an index run owns one outer SQLite transaction. Individual
5260
- * writer methods normally protect themselves with BEGIN/COMMIT, but during
5261
- * a refresh they join this transaction so readers observe either the last
5262
- * completed index or the next completed index, never an in-between batch.
5263
- */
5264
5778
  atomicIndexUpdateActive = false;
5265
5779
  writeSavepointSequence = 0;
5266
- /** Absolute path to this project's index directory. */
5267
5780
  indexDir;
5268
- /**
5269
- * True when the SQLite build provides FTS5 (Node's bundled SQLite does).
5270
- * When false, ranked search falls back to the LIKE + in-process BM25 path.
5271
- */
5272
5781
  ftsAvailable = false;
5273
- /**
5274
- * Phase 3: true when the `symbol_vectors` table was created successfully.
5275
- * When false, hybrid search skips the vector pass and falls back to FTS5
5276
- * (or LIKE) only.
5277
- */
5278
5782
  vectorsAvailable = false;
5279
- /**
5280
- * Cache of prepared statements keyed by their SQL text. `DatabaseSync`
5281
- * compiles SQL on every `.prepare()` call; for the fixed-SQL methods
5282
- * (upsertFile, getFileMeta, deleteFile, insertRefs, …) that runs thousands
5283
- * of times during a full reindex. `StatementSync` objects are reusable
5284
- * across calls on the same connection, so we compile each distinct SQL once
5285
- * and reuse it. Cleared in {@link close} when the connection is torn down.
5286
- */
5287
5783
  stmtCache = /* @__PURE__ */ new Map();
5288
- /**
5289
- * Cached full-corpus BM25 index for the FTS5-unavailable fallback path.
5290
- * Built lazily on the first `searchRankedFallback` call and invalidated
5291
- * (via `bm25Dirty`) whenever the `symbols` table is mutated. Computing
5292
- * IDF over the full corpus is also more correct than the old per-query
5293
- * candidate-subset IDF.
5294
- *
5295
- * Cache-lifecycle invariants (single source of truth lives at the
5296
- * `invalidateBm25()` helper — see its docblock for the "every mutation
5297
- * MUST call this" contract):
5298
- * - declaration: this field + `bm25Dirty` (here)
5299
- * - invalidation: `invalidateBm25()` flips the flag and nulls the cache
5300
- * - build: `getOrBuildBm25()` rebuilds against current `symbols` rows
5301
- * - teardown: `close()` resets the flag and nulls the cache
5302
- */
5303
5784
  bm25Cache = null;
5304
- // Dirty on open so the first getOrBuildBm25() rebuilds against current rows;
5305
- // an empty or pre-existing corpus makes a stale IDF table meaningless.
5306
5785
  bm25Dirty = true;
5307
- /**
5308
- * Prepare-once helper: compile `sql` on first use, reuse thereafter.
5309
- *
5310
- * Bounded LRU rather than an open Map. The cache is keyed by SQL TEXT, and
5311
- * the fallback search builder emits one `text LIKE ?` clause per query token
5312
- * — so the SQL varies with the token count and a stream of differently-sized
5313
- * queries grew the cache without limit. Sage's store already bounds its
5314
- * equivalent at 128 (WS-096).
5315
- *
5316
- * Re-inserting on a hit keeps the hot fixed-SQL statements (upsertFile,
5317
- * insertRefs, …) at the young end, so a burst of one-off search SQL evicts
5318
- * itself rather than the reindex hot path.
5319
- */
5320
5786
  stmt(sql) {
5321
5787
  const cached = this.stmtCache.get(sql);
5322
5788
  if (cached !== void 0) {
@@ -5343,7 +5809,6 @@ var IndexStore = class _IndexStore {
5343
5809
  runWithRetry(fn) {
5344
5810
  return runSqliteWithRetry(fn);
5345
5811
  }
5346
- /** Run a complete index mutation as one WAL-visible publication. */
5347
5812
  async runAtomicIndexUpdate(job) {
5348
5813
  if (this.atomicIndexUpdateActive) return job();
5349
5814
  this.runWithRetry(() => this.db.exec("BEGIN IMMEDIATE"));
@@ -5362,11 +5827,6 @@ var IndexStore = class _IndexStore {
5362
5827
  this.atomicIndexUpdateActive = false;
5363
5828
  }
5364
5829
  }
5365
- /**
5366
- * Begin a method-local transaction. Inside an atomic index publication a
5367
- * SAVEPOINT preserves the old per-batch rollback boundary, which is needed
5368
- * when commitBatch falls back to per-file writes after one batch fails.
5369
- */
5370
5830
  beginWriteTransaction() {
5371
5831
  if (this.atomicIndexUpdateActive) {
5372
5832
  const savepoint = `index_write_${++this.writeSavepointSequence}`;
@@ -5388,35 +5848,11 @@ var IndexStore = class _IndexStore {
5388
5848
  this.db.exec("ROLLBACK");
5389
5849
  }
5390
5850
  }
5391
- /**
5392
- * Mirror the in-process language→family map into SQLite.
5393
- *
5394
- * Rewritten on every open rather than only on schema bumps: the mapping is
5395
- * static lookup data, so a code-side change (a new language, a language
5396
- * moving families) must take effect without forcing a full reindex.
5397
- */
5398
5851
  seedLangFamilies() {
5399
5852
  const insert = this.stmt("INSERT OR REPLACE INTO lang_family(lang, family) VALUES (?, ?)");
5400
5853
  for (const [lang, family] of LANG_FAMILY_ENTRIES) insert.run(lang, family);
5401
5854
  insert.run("", LANG_FAMILY_WILDCARD);
5402
5855
  }
5403
- /**
5404
- * Add any column the current schema expects but the on-disk table lacks.
5405
- *
5406
- * `CREATE TABLE IF NOT EXISTS` silently keeps an existing table's old shape,
5407
- * and the version check above only rebuilds on a version *mismatch*. That
5408
- * leaves a real gap: several wstack processes share this database, and while
5409
- * a version upgrade is rolling out one of them may still be running the
5410
- * previous build. That older process sees the newer version number, drops the
5411
- * tables, and recreates them from *its* DDL — without the newer columns —
5412
- * while the metadata row still reads the new version. Every later query for
5413
- * one of those columns then fails with `no such column`, and no amount of
5414
- * reindexing fixes it, because the version numbers already agree.
5415
- *
5416
- * Repairing column-by-column makes the schema self-healing from any of those
5417
- * states. Table and column names are compile-time literals from this module,
5418
- * never user input.
5419
- */
5420
5856
  repairMissingColumns() {
5421
5857
  const expected = [
5422
5858
  {
@@ -5521,27 +5957,8 @@ var IndexStore = class _IndexStore {
5521
5957
  }
5522
5958
  this.ensureNextSymbolIdSeeded();
5523
5959
  }
5524
- // ─── ID allocation & bulk helpers ────────────────────────────────────────────
5525
5960
  static NEXT_SYMBOL_ID_KEY = "next_symbol_id";
5526
- /** Stay under typical SQLite SQLITE_MAX_VARIABLE_NUMBER (often 999). */
5527
5961
  static MAX_SQL_VARS = 900;
5528
- /**
5529
- * Correlated predicate: the ref in `refs` and the candidate symbol aliased
5530
- * `sym` belong to the same language family — or the ref carries no language,
5531
- * in which case the wildcard bind matches everything.
5532
- *
5533
- * Each textual occurrence consumes one `?` bind of {@link LANG_FAMILY_WILDCARD}.
5534
- */
5535
- static FAMILY_MATCH_SQL = `(
5536
- (SELECT family FROM lang_family WHERE lang = refs.lang) = ?
5537
- OR (SELECT family FROM lang_family WHERE lang = sym.lang)
5538
- = (SELECT family FROM lang_family WHERE lang = refs.lang)
5539
- )`;
5540
- /**
5541
- * Ensure `metadata.next_symbol_id` exists. Safe to call outside a write
5542
- * transaction on open; the first concurrent writer under BEGIN IMMEDIATE
5543
- * re-reads and advances the counter atomically.
5544
- */
5545
5962
  ensureNextSymbolIdSeeded() {
5546
5963
  const existing = this.stmt("SELECT value FROM metadata WHERE key = ?").get(
5547
5964
  _IndexStore.NEXT_SYMBOL_ID_KEY
@@ -5554,10 +5971,6 @@ var IndexStore = class _IndexStore {
5554
5971
  String(next)
5555
5972
  );
5556
5973
  }
5557
- /**
5558
- * Reserve `count` consecutive symbol ids. MUST run inside BEGIN IMMEDIATE
5559
- * so concurrent indexers cannot hand out overlapping ranges.
5560
- */
5561
5974
  allocateSymbolIds(count) {
5562
5975
  if (count <= 0) return this.getMaxSymbolId() + 1;
5563
5976
  this.ensureNextSymbolIdSeeded();
@@ -5571,16 +5984,8 @@ var IndexStore = class _IndexStore {
5571
5984
  );
5572
5985
  return start;
5573
5986
  }
5574
- /**
5575
- * Disconnect inbound refs before their target symbols are replaced and
5576
- * return the affected names for scoped re-resolution.
5577
- *
5578
- * This also repairs a long-standing dangling-id edge case: `refs.to_id` has
5579
- * no physical FK, so deleting a symbol previously left callers pointing at a
5580
- * non-existent row.
5581
- */
5582
5987
  invalidateIncomingRefsForFiles(files) {
5583
- if (files.length === 0) return [];
5988
+ if (files.length === 0) return /* @__PURE__ */ new Set();
5584
5989
  const placeholders = files.map(() => "?").join(",");
5585
5990
  const names = this.stmt(`SELECT DISTINCT name FROM symbols WHERE file IN (${placeholders})`).all(
5586
5991
  ...files
@@ -5589,36 +5994,11 @@ var IndexStore = class _IndexStore {
5589
5994
  `UPDATE refs SET to_id = NULL
5590
5995
  WHERE to_id IN (SELECT id FROM symbols WHERE file IN (${placeholders}))`
5591
5996
  ).run(...files);
5592
- return names;
5997
+ return new Set(names);
5593
5998
  }
5594
- /** Resolve only refs whose target names may have changed. */
5595
5999
  resolveRefsForNamesUnsafe(names) {
5596
- const unique = [...new Set(names)].filter(Boolean);
5597
- let changes = 0;
5598
- for (let start = 0; start < unique.length; start += _IndexStore.MAX_SQL_VARS) {
5599
- const chunk = unique.slice(start, start + _IndexStore.MAX_SQL_VARS);
5600
- const placeholders = chunk.map(() => "?").join(",");
5601
- const result = this.stmt(
5602
- `UPDATE refs
5603
- SET to_id = (
5604
- SELECT MIN(sym.id) FROM symbols sym
5605
- WHERE sym.name = refs.to_name AND ${_IndexStore.FAMILY_MATCH_SQL}
5606
- )
5607
- WHERE to_name IN (${placeholders})`
5608
- ).run(LANG_FAMILY_WILDCARD, ...chunk);
5609
- changes += result.changes ?? 0;
5610
- }
5611
- return changes;
6000
+ return resolveRefsForNamesUnsafe((sql) => this.stmt(sql), _IndexStore.MAX_SQL_VARS, names);
5612
6001
  }
5613
- // ─── Symbol CRUD ─────────────────────────────────────────────────────────────
5614
- /**
5615
- * Insert symbols, assigning IDs atomically inside `BEGIN IMMEDIATE` /
5616
- * `COMMIT`. Id ranges come from the `next_symbol_id` metadata counter
5617
- * (O(1)); multi-row INSERT amortizes bind overhead for large files.
5618
- *
5619
- * @returns The symbols array with `id` fields populated so the caller can
5620
- * use them for refs without re-reading from the DB.
5621
- */
5622
6002
  insertSymbols(symbols) {
5623
6003
  this.invalidateBm25();
5624
6004
  return this.runWithRetry(() => {
@@ -5647,12 +6027,6 @@ var IndexStore = class _IndexStore {
5647
6027
  if (this.ftsAvailable) {
5648
6028
  ftsRows.push({ id, text: buildIndexableText(s.name, s.signature, s.docComment) });
5649
6029
  }
5650
- vectorRows.push({
5651
- id,
5652
- vector: encodeVector(
5653
- embedText(s.text || buildIndexableText(s.name, s.signature, s.docComment))
5654
- )
5655
- });
5656
6030
  result.push({ ...s, id });
5657
6031
  }
5658
6032
  bulkInsertSymbolsWithStatement((sql) => this.stmt(sql), _IndexStore.MAX_SQL_VARS, bulk);
@@ -5702,11 +6076,6 @@ var IndexStore = class _IndexStore {
5702
6076
  }
5703
6077
  });
5704
6078
  }
5705
- /**
5706
- * Remove every trace of a file (refs, symbols, FTS rows, file meta). Used
5707
- * when a source file disappears between index runs — previously this only
5708
- * dropped the `files` row, leaving its symbols orphaned but still searchable.
5709
- */
5710
6079
  deleteFile(file) {
5711
6080
  this.invalidateBm25();
5712
6081
  this.runWithRetry(() => {
@@ -5736,7 +6105,6 @@ var IndexStore = class _IndexStore {
5736
6105
  }
5737
6106
  });
5738
6107
  }
5739
- // ─── File metadata ──────────────────────────────────────────────────────────
5740
6108
  upsertFile(meta) {
5741
6109
  this.runWithRetry(() => {
5742
6110
  this.stmt(
@@ -5764,8 +6132,6 @@ var IndexStore = class _IndexStore {
5764
6132
  getAllFileMetas() {
5765
6133
  return getAllFileMetasWithStatement((sql) => this.stmt(sql));
5766
6134
  }
5767
- // ─── Project structure & module resolution ──────────────────────────────────
5768
- /** Store the Code Atlas grouping label for each indexed file. */
5769
6135
  setFilePackages(entries) {
5770
6136
  if (entries.size === 0) return;
5771
6137
  this.runWithRetry(() => {
@@ -5773,272 +6139,50 @@ var IndexStore = class _IndexStore {
5773
6139
  for (const [file, label] of entries) update.run(label, file);
5774
6140
  });
5775
6141
  }
5776
- /**
5777
- * Every indexed `namespace`/`module` declaration, for ecosystems whose import
5778
- * specifiers name a namespace rather than a path (C#, PHP, Elixir, Haskell).
5779
- * Ordered so the resolver's choice among duplicate declarations is stable.
5780
- */
5781
6142
  getNamespaceDeclarations() {
5782
- return this.stmt(
5783
- `SELECT name, file FROM symbols WHERE kind = 'namespace' ORDER BY file, id`
5784
- ).all();
6143
+ return getNamespaceDeclarationsWithStatement((sql) => this.stmt(sql));
5785
6144
  }
5786
- /** `file → package` for every indexed file that has a label. */
5787
6145
  getFilePackages() {
5788
- const rows = this.stmt("SELECT file, package FROM files WHERE package != ''").all();
5789
- return new Map(rows.map((row) => [row.file, row.package]));
6146
+ return getFilePackagesWithStatement((sql) => this.stmt(sql));
5790
6147
  }
5791
- /**
5792
- * Distinct `(fromFile, lang, module)` triples needing module resolution.
5793
- *
5794
- * Distinct rather than per-ref because resolution depends only on these three
5795
- * values: a file importing the same module twenty times resolves it once.
5796
- */
5797
6148
  getUnresolvedImports(onlyFiles) {
5798
- const base = `SELECT DISTINCT s.file AS fromFile, r.lang AS lang, r.module AS module
5799
- FROM refs r
5800
- JOIN symbols s ON s.id = r.from_id
5801
- WHERE r.call_type = 'import' AND r.module IS NOT NULL`;
5802
- if (!onlyFiles?.length) {
5803
- return this.stmt(base).all();
5804
- }
5805
- const out = [];
5806
- for (let i = 0; i < onlyFiles.length; i += _IndexStore.MAX_SQL_VARS) {
5807
- const chunk = onlyFiles.slice(i, i + _IndexStore.MAX_SQL_VARS);
5808
- const placeholders = chunk.map(() => "?").join(",");
5809
- out.push(
5810
- ...this.stmt(`${base} AND s.file IN (${placeholders})`).all(...chunk)
5811
- );
5812
- }
5813
- return out;
6149
+ return getUnresolvedImportsWithStatement(
6150
+ (sql) => this.stmt(sql),
6151
+ _IndexStore.MAX_SQL_VARS,
6152
+ onlyFiles
6153
+ );
5814
6154
  }
5815
- /**
5816
- * Write resolved import targets back onto `refs.to_file`.
5817
- *
5818
- * Applied through a temp table and a single UPDATE: one statement per
5819
- * resolution would mean thousands of round-trips on a first index.
5820
- */
5821
6155
  applyImportResolutions(resolutions) {
5822
- if (resolutions.length === 0) return 0;
5823
- return this.runWithRetry(() => {
5824
- this.db.exec("DROP TABLE IF EXISTS temp.import_resolution");
5825
- this.db.exec(
5826
- `CREATE TEMP TABLE import_resolution (
5827
- from_file TEXT NOT NULL,
5828
- lang TEXT NOT NULL,
5829
- module TEXT NOT NULL,
5830
- to_file TEXT NOT NULL
5831
- )`
5832
- );
5833
- const chunkSize = Math.max(1, Math.floor(_IndexStore.MAX_SQL_VARS / 4));
5834
- for (let i = 0; i < resolutions.length; i += chunkSize) {
5835
- const chunk = resolutions.slice(i, i + chunkSize);
5836
- const placeholders = chunk.map(() => "(?, ?, ?, ?)").join(", ");
5837
- const binds = [];
5838
- for (const entry of chunk) {
5839
- binds.push(entry.fromFile, entry.lang, entry.module, entry.toFile);
5840
- }
5841
- this.stmt(
5842
- `INSERT INTO temp.import_resolution(from_file, lang, module, to_file)
5843
- VALUES ${placeholders}`
5844
- ).run(...binds);
5845
- }
5846
- this.db.exec(
5847
- `CREATE INDEX IF NOT EXISTS temp.idx_ir
5848
- ON import_resolution(module, lang, from_file)`
5849
- );
5850
- const result = this.stmt(
5851
- `UPDATE refs
5852
- SET to_file = (
5853
- SELECT ir.to_file
5854
- FROM temp.import_resolution ir
5855
- JOIN symbols s ON s.id = refs.from_id
5856
- WHERE ir.module = refs.module
5857
- AND ir.lang = refs.lang
5858
- AND ir.from_file = s.file
5859
- LIMIT 1
5860
- )
5861
- WHERE refs.call_type = 'import'
5862
- AND refs.module IS NOT NULL
5863
- AND EXISTS (
5864
- SELECT 1
5865
- FROM temp.import_resolution ir
5866
- JOIN symbols s ON s.id = refs.from_id
5867
- WHERE ir.module = refs.module
5868
- AND ir.lang = refs.lang
5869
- AND ir.from_file = s.file
5870
- )`
5871
- ).run();
5872
- this.db.exec("DROP TABLE IF EXISTS temp.import_resolution");
5873
- return result.changes ?? 0;
5874
- });
5875
- }
5876
- // ─── Search ──────────────────────────────────────────────────────────────────
5877
- search(query, filter, opts) {
5878
- const built = this.buildSearchWhere(query, filter);
5879
- if (built === null) return [];
5880
- const { where, values } = built;
5881
- const limit = normalizeSearchLimit(opts?.limit);
5882
- const limitSql = limit !== void 0 ? " LIMIT ?" : "";
5883
- const sql = `SELECT id, lang, kind, name, file, line, col, signature, doc_comment FROM symbols ${where}${limitSql}`;
5884
- const binds = limit !== void 0 ? [...values, limit] : values;
5885
- const rows = this.stmt(sql).all(
5886
- ...binds
6156
+ return applyImportResolutionsWithStatement(
6157
+ this.db,
6158
+ (sql) => this.stmt(sql),
6159
+ this.runWithRetry.bind(this),
6160
+ _IndexStore.MAX_SQL_VARS,
6161
+ resolutions
5887
6162
  );
5888
- return rows.map((row) => mapWriterSearchRow(row, filter?.lspKind));
5889
6163
  }
5890
- /** Shared WHERE builder for {@link search} / empty-query ranked totals. */
5891
- buildSearchWhere(query, filter) {
5892
- return buildWriterSearchWhere(query, filter);
6164
+ search(query, filter, opts) {
6165
+ return searchWithStatement((sql) => this.stmt(sql), query, filter, opts);
5893
6166
  }
5894
6167
  countSearch(query, filter) {
5895
- const built = this.buildSearchWhere(query, filter);
5896
- if (built === null) return 0;
5897
- const row = this.stmt(`SELECT COUNT(*) AS n FROM symbols ${built.where}`).get(
5898
- ...built.values
5899
- );
5900
- return Number(row?.n ?? 0);
6168
+ return countSearchWithStatement((sql) => this.stmt(sql), query, filter);
5901
6169
  }
5902
- /**
5903
- * Ranked search — the one-stop query the codebase-search tool and plug-lsp
5904
- * use. With FTS5 this is a single indexed `MATCH` ranked by SQLite's native
5905
- * `bm25()` with a built-in `snippet()`; without FTS5 it falls back to the
5906
- * legacy LIKE scan + in-process BM25 (identical semantics, slower).
5907
- *
5908
- * Tokens are matched as prefixes (`"tok"*`), mirroring the old
5909
- * `LIKE '%tok%'` recall for the common symbol-search shapes ("user" finds
5910
- * "users", camelCase-split text makes "complex" find "complexOperation").
5911
- */
5912
6170
  searchRanked(query, filter, limit) {
5913
- const rawLimit = Number.isFinite(limit) ? Math.trunc(limit) : 20;
5914
- const safeLimit = Math.max(1, Math.min(rawLimit, 100));
5915
- const tokens = tokenise(query);
5916
- if (tokens.length === 0 || !this.ftsAvailable) {
5917
- return this.searchRankedFallback(query, filter, safeLimit);
5918
- }
5919
- let effectiveKind = filter?.kind;
5920
- if (filter?.lspKind !== void 0) {
5921
- const mapped = lspKindToInternalKind(filter.lspKind);
5922
- if (mapped === null) return { results: [], total: 0 };
5923
- effectiveKind = mapped;
5924
- }
5925
- const longTokens = tokens.filter((t) => t.length >= 3);
5926
- const shortTokens = tokens.filter((t) => t.length < 3);
5927
- if (longTokens.length === 0) {
5928
- return this.searchRankedFallback(query, filter, safeLimit);
5929
- }
5930
- const match = longTokens.map((t) => `"${t.replaceAll('"', "")}"`).join(" OR ");
5931
- const conditions = ["symbols_fts MATCH ?"];
5932
- const values = [match];
5933
- for (const shortTok of shortTokens) {
5934
- conditions.push("s.text LIKE ? ESCAPE '\\'");
5935
- values.push(`%${escapeLike(shortTok)}%`);
5936
- }
5937
- if (effectiveKind) {
5938
- conditions.push("s.kind = ?");
5939
- values.push(effectiveKind);
5940
- }
5941
- if (filter?.lang) {
5942
- conditions.push("s.lang = ?");
5943
- values.push(filter.lang);
5944
- }
5945
- if (filter?.file) {
5946
- conditions.push("replace(s.file, '\\', '/') LIKE ? ESCAPE '\\'");
5947
- values.push(`%${escapeLike(filter.file.replace(/\\/g, "/"))}%`);
5948
- }
5949
- const where = conditions.join(" AND ");
5950
- const countRows = this.stmt(
5951
- `SELECT COUNT(*) AS n FROM symbols_fts JOIN symbols s ON s.id = symbols_fts.rowid WHERE ${where}`
5952
- ).all(...values);
5953
- const total = countRows[0] ? Number(countRows[0].n) : 0;
5954
- if (total === 0) return { results: [], total: 0 };
5955
- const bm25Rows = this.stmt(
5956
- `SELECT s.id, s.lang, s.kind, s.name, s.file, s.line, s.col, s.signature, s.doc_comment,
5957
- -bm25(symbols_fts) AS score,
5958
- snippet(symbols_fts, 0, '', '', '\u2026', 12) AS snippet
5959
- FROM symbols_fts JOIN symbols s ON s.id = symbols_fts.rowid
5960
- WHERE ${where}
5961
- ORDER BY
5962
- CASE WHEN lower(s.name) = lower(?) THEN 0
5963
- WHEN lower(s.name) LIKE lower(?) ESCAPE '\\' THEN 1
5964
- ELSE 2 END,
5965
- bm25(symbols_fts), lower(s.name), s.file, s.line, s.col, s.id
5966
- LIMIT ?`
5967
- ).all(...values, query.trim(), `${escapeLike(query.trim())}%`, safeLimit);
5968
- if (this.vectorsAvailable && bm25Rows.length > 0) {
5969
- const queryVec = embedText(query);
5970
- const candidateIds = bm25Rows.map((r) => r.id);
5971
- const placeholders = candidateIds.map(() => "?").join(",");
5972
- const vecRows = this.stmt(
5973
- `SELECT sv.symbol_id, sv.vector FROM symbol_vectors sv WHERE sv.symbol_id IN (${placeholders})`
5974
- ).all(...candidateIds);
5975
- const vecScores = vecRows.map((r) => ({
5976
- id: r.symbol_id,
5977
- sim: cosineSimilarity(queryVec, decodeVector(r.vector))
5978
- })).sort((a, b) => b.sim - a.sim);
5979
- const bm25Rank = /* @__PURE__ */ new Map();
5980
- bm25Rows.forEach((r, i) => {
5981
- bm25Rank.set(r.id, i);
5982
- });
5983
- const vecRank = /* @__PURE__ */ new Map();
5984
- vecScores.forEach((r, i) => {
5985
- vecRank.set(r.id, i);
5986
- });
5987
- const fused = reciprocalRankFusion(bm25Rank, vecRank, 60);
5988
- const fusedScore = new Map(fused);
5989
- const sorted = [...bm25Rows].sort(
5990
- (a, b) => (fusedScore.get(b.id) ?? 0) - (fusedScore.get(a.id) ?? 0)
5991
- );
5992
- return {
5993
- results: sorted.map(
5994
- (row) => mapWriterSearchRow(row, filter?.lspKind, Math.max(1e-4, row.score), row.snippet)
5995
- ),
5996
- total
5997
- };
5998
- }
5999
- return {
6000
- results: bm25Rows.map(
6001
- (row) => mapWriterSearchRow(row, filter?.lspKind, Math.max(1e-4, row.score), row.snippet)
6002
- ),
6003
- total
6004
- };
6171
+ return searchRankedWithStatement(
6172
+ (sql) => this.stmt(sql),
6173
+ this.search.bind(this),
6174
+ this.ftsAvailable,
6175
+ this.vectorsAvailable,
6176
+ this.getOrBuildBm25.bind(this),
6177
+ query,
6178
+ filter,
6179
+ limit
6180
+ );
6005
6181
  }
6006
- /**
6007
- * Invalidate the cached BM25 index.
6008
- *
6009
- * **Contract: every method that mutates `symbols` MUST call this before
6010
- * returning.** (`refs` mutations do not affect the BM25 fallback because
6011
- * the corpus is built from `symbols.text` via `getAllIndexable()` and the
6012
- * BM25 score is filtered by the LIKE-selected candidate set in
6013
- * `searchRankedFallback`.) Today the call sites are `repairDrift`,
6014
- * `insertSymbols`, `deleteSymbolsForFile`, `deleteFile`, `clearAll`, and
6015
- * `commitBatch`. A future mutation that adds a new write path (e.g.
6016
- * `renameFile`, `updateSignature`) MUST also call this — otherwise the
6017
- * FTS5-unavailable fallback will serve stale search results. The
6018
- * `close()` reset at L1820-1821 tears the cache down on store shutdown,
6019
- * which is the only legitimate place that flips the flag outside this
6020
- * helper.
6021
- *
6022
- * Called *before* `runWithRetry` on purpose: if the write fails all
6023
- * retries the flag stays set, forcing a rebuild on the next search rather
6024
- * than trusting a cache that may not reflect the intended mutation.
6025
- * Do not move this inside the retry closure.
6026
- */
6027
6182
  invalidateBm25() {
6028
6183
  this.bm25Dirty = true;
6029
6184
  this.bm25Cache = null;
6030
6185
  }
6031
- /**
6032
- * Return the cached full-corpus BM25 index, rebuilding it only when the
6033
- * symbols table has been mutated since the last build. The full-corpus IDF
6034
- * is more correct than the old per-query candidate-subset IDF, and the
6035
- * amortized build cost drops from O(symbols × tokens) per search to once
6036
- * per write batch.
6037
- *
6038
- * Note: the first call after a long idle (or on a freshly opened store)
6039
- * pays the full corpus rebuild synchronously on the search path. For a
6040
- * 5 500+ symbol corpus this is a visible one-time latency spike.
6041
- */
6042
6186
  getOrBuildBm25() {
6043
6187
  if (this.bm25Cache && !this.bm25Dirty) return this.bm25Cache;
6044
6188
  const docs = this.getAllIndexable();
@@ -6046,57 +6190,12 @@ var IndexStore = class _IndexStore {
6046
6190
  this.bm25Dirty = false;
6047
6191
  return this.bm25Cache;
6048
6192
  }
6049
- /** Legacy ranked path: LIKE candidates + in-process BM25 + JS snippets. */
6050
- searchRankedFallback(query, filter, limit) {
6051
- if (!query.trim()) {
6052
- const total2 = this.countSearch(query, filter);
6053
- if (total2 === 0) return { results: [], total: 0 };
6054
- return { results: this.search(query, filter, { limit }), total: total2 };
6055
- }
6056
- const total = this.countSearch(query, filter);
6057
- if (total === 0) return { results: [], total: 0 };
6058
- const candidates = this.search(query, filter, { limit: SEARCH_CANDIDATE_SCAN_CAP });
6059
- if (candidates.length === 0) return { results: [], total: 0 };
6060
- const candidateById = new Map(candidates.map((c) => [c.id, c]));
6061
- const bm25 = this.getOrBuildBm25();
6062
- const scored = bm25.score(query, (id) => candidateById.has(id));
6063
- const q = query.trim().toLowerCase();
6064
- const rank = (id) => {
6065
- const name = candidateById.get(id)?.name.toLowerCase() ?? "";
6066
- if (name === q) return 0;
6067
- if (name.startsWith(q)) return 1;
6068
- return 2;
6069
- };
6070
- scored.sort((a, b) => {
6071
- const rankDiff = rank(a.id) - rank(b.id);
6072
- if (rankDiff !== 0) return rankDiff;
6073
- const scoreDiff = b.score - a.score;
6074
- if (scoreDiff !== 0) return scoreDiff;
6075
- const left = expectDefined4(candidateById.get(a.id));
6076
- const right = expectDefined4(candidateById.get(b.id));
6077
- return left.name.localeCompare(right.name) || left.file.localeCompare(right.file) || left.line - right.line || left.col - right.col || left.id - right.id;
6078
- });
6079
- const qTokens = tokenise(query);
6080
- const results = scored.slice(0, limit).map(({ id, score }) => {
6081
- const c = expectDefined4(candidateById.get(id));
6082
- return { ...c, score, snippet: bm25.extractSnippet(id, qTokens) };
6083
- });
6084
- return { results, total };
6085
- }
6086
6193
  getAllIndexable() {
6087
6194
  return getAllIndexableWithStatement((sql) => this.stmt(sql));
6088
6195
  }
6089
- /**
6090
- * Largest symbol id currently in the table (0 when empty). New ids must be
6091
- * allocated from this, NOT from `COUNT(*)`: incremental reindexes delete a
6092
- * changed file's rows, so the row count drops below the max id and a
6093
- * count-based id would collide with a surviving row (UNIQUE constraint on
6094
- * `symbols.id`). Ids may have gaps — that is fine.
6095
- */
6096
6196
  getMaxSymbolId() {
6097
6197
  return getMaxSymbolIdWithStatement((sql) => this.stmt(sql));
6098
6198
  }
6099
- // ─── Stats ───────────────────────────────────────────────────────────────────
6100
6199
  getStats() {
6101
6200
  return getStatsWithStatement((sql) => this.stmt(sql), this.indexDir);
6102
6201
  }
@@ -6139,11 +6238,6 @@ var IndexStore = class _IndexStore {
6139
6238
  }
6140
6239
  });
6141
6240
  }
6142
- // ─── Ref CRUD ────────────────────────────────────────────────────────────────
6143
- /**
6144
- * Insert cross-references for a given source symbol id.
6145
- * Replaces any existing refs from the same source (idempotent on re-index).
6146
- */
6147
6241
  insertRefs(fromId, refs) {
6148
6242
  this.runWithRetry(() => {
6149
6243
  this.stmt("DELETE FROM refs WHERE from_id = ?").run(fromId);
@@ -6155,167 +6249,36 @@ var IndexStore = class _IndexStore {
6155
6249
  );
6156
6250
  });
6157
6251
  }
6158
- /**
6159
- * Bulk-insert refs for many source symbols in a single transaction.
6160
- *
6161
- * Unlike {@link insertRefs} this does NOT delete per source id — the caller
6162
- * (the indexer) has already cleared stale refs for the file via
6163
- * {@link deleteRefsForFile}, so the per-source DELETE would be redundant work
6164
- * repeated once per symbol. One transaction for the whole file instead of one
6165
- * per symbol turns an O(symbols) transaction count into O(1).
6166
- *
6167
- * Each ref's own {@link Ref.fromId} is used; pass an empty array to no-op.
6168
- */
6169
6252
  insertRefsBatch(refs) {
6170
6253
  if (refs.length === 0) return;
6171
6254
  this.runWithRetry(() => {
6172
6255
  bulkInsertRefsWithStatement((sql) => this.stmt(sql), _IndexStore.MAX_SQL_VARS, refs);
6173
6256
  });
6174
6257
  }
6175
- /**
6176
- * Commit a batch of file-level symbol/refs/upserts in a single transaction.
6177
- *
6178
- * Used by the indexer to amortize SQLite commit overhead across many files.
6179
- * Before this, the indexer issued one transaction per file (BEGIN IMMEDIATE
6180
- * for symbols, plus per-file deletes and an upsertFile call), so a 20-file
6181
- * parallel batch cost ~5+ transactions × 20 files = 100+ commits. With
6182
- * this entry point we do exactly one BEGIN/COMMIT per parallel batch.
6183
- *
6184
- * Each entry must already be a fully-parsed FileSymbols (symbols + refs).
6185
- * The caller is responsible for the per-file prefix accounting
6186
- * (refsByLine → flat list with `fromId` populated). `deleteForFiles` lets
6187
- * the caller clear stale symbols/refs for any files being re-indexed before
6188
- * the inserts run (required to keep refs → symbols FK invariants).
6189
- *
6190
- * Returns the symbols back with their assigned `id` (same shape as
6191
- * {@link insertSymbols}) so callers can build final per-file results.
6192
- */
6193
6258
  commitBatch(entries, options = {}) {
6194
- if (entries.length === 0 && (options.deleteForFiles?.length ?? 0) === 0) {
6195
- return [];
6196
- }
6197
6259
  this.invalidateBm25();
6198
6260
  return this.runWithRetry(() => {
6199
6261
  const ownsTransaction = this.beginWriteTransaction();
6200
6262
  try {
6201
- const affectedNames = /* @__PURE__ */ new Set();
6202
- for (const entry of entries) {
6203
- for (const symbol of entry.symbols) affectedNames.add(symbol.name);
6204
- for (const ref of entry.refs) affectedNames.add(ref.toName);
6205
- }
6206
- if (options.deleteForFiles && options.deleteForFiles.length > 0) {
6207
- const placeholders = options.deleteForFiles.map(() => "?").join(",");
6208
- for (const name of this.invalidateIncomingRefsForFiles(options.deleteForFiles)) {
6209
- affectedNames.add(name);
6210
- }
6211
- if (this.ftsAvailable) {
6212
- this.stmt(
6213
- `DELETE FROM symbols_fts WHERE rowid IN (SELECT id FROM symbols WHERE file IN (${placeholders}))`
6214
- ).run(...options.deleteForFiles);
6215
- }
6216
- if (this.vectorsAvailable) {
6217
- this.stmt(
6218
- `DELETE FROM symbol_vectors WHERE symbol_id IN (SELECT id FROM symbols WHERE file IN (${placeholders}))`
6219
- ).run(...options.deleteForFiles);
6220
- }
6221
- this.stmt(
6222
- `DELETE FROM refs WHERE from_id IN (SELECT id FROM symbols WHERE file IN (${placeholders}))`
6223
- ).run(...options.deleteForFiles);
6224
- this.stmt(`DELETE FROM symbols WHERE file IN (${placeholders})`).run(
6225
- ...options.deleteForFiles
6226
- );
6227
- }
6228
- const totalSymbols = entries.reduce((n, e) => n + e.symbols.length, 0);
6229
- let nextId = this.allocateSymbolIds(totalSymbols);
6230
- const allInserted = [];
6231
- const refsToInsert = [];
6232
- const bulkSyms = [];
6233
- const ftsRows = [];
6234
- const vectorRows = [];
6235
- for (const entry of entries) {
6236
- const insertedForEntry = [];
6237
- for (const s of entry.symbols) {
6238
- const id = nextId++;
6239
- bulkSyms.push({
6240
- id,
6241
- lang: s.lang,
6242
- kind: s.kind,
6243
- name: s.name,
6244
- file: s.file,
6245
- line: s.line,
6246
- col: s.col,
6247
- signature: s.signature,
6248
- docComment: s.docComment,
6249
- scope: s.scope,
6250
- text: s.text
6251
- });
6252
- if (this.ftsAvailable) {
6253
- ftsRows.push({
6254
- id,
6255
- text: buildIndexableText(s.name, s.signature, s.docComment)
6256
- });
6257
- }
6258
- vectorRows.push({
6259
- id,
6260
- vector: encodeVector(
6261
- embedText(s.text || buildIndexableText(s.name, s.signature, s.docComment))
6262
- )
6263
- });
6264
- const inserted = { ...s, id };
6265
- allInserted.push(inserted);
6266
- insertedForEntry.push(inserted);
6267
- }
6268
- refsToInsert.push(...assignRefsToSymbols(entry.refs, insertedForEntry));
6269
- }
6270
- bulkInsertSymbolsWithStatement((sql) => this.stmt(sql), _IndexStore.MAX_SQL_VARS, bulkSyms);
6271
- bulkInsertFtsWithStatement(
6263
+ const result = commitBatchWithStatement(
6272
6264
  (sql) => this.stmt(sql),
6273
6265
  _IndexStore.MAX_SQL_VARS,
6274
6266
  this.ftsAvailable,
6275
- ftsRows
6276
- );
6277
- if (this.vectorsAvailable) {
6278
- bulkInsertVectorsWithStatement(
6279
- (sql) => this.stmt(sql),
6280
- _IndexStore.MAX_SQL_VARS,
6281
- vectorRows
6282
- );
6283
- }
6284
- bulkInsertRefsWithStatement((sql) => this.stmt(sql), _IndexStore.MAX_SQL_VARS, refsToInsert);
6285
- const upsertStmt = this.stmt(
6286
- `INSERT INTO files(file, lang, mtime_ms, content_hash, symbol_count, last_indexed)
6287
- VALUES (?, ?, ?, ?, ?, ?)
6288
- ON CONFLICT(file) DO UPDATE SET
6289
- lang = excluded.lang,
6290
- mtime_ms = excluded.mtime_ms,
6291
- content_hash = excluded.content_hash,
6292
- symbol_count = excluded.symbol_count,
6293
- last_indexed = excluded.last_indexed`
6267
+ this.vectorsAvailable,
6268
+ this.allocateSymbolIds.bind(this),
6269
+ this.invalidateIncomingRefsForFiles.bind(this),
6270
+ this.resolveRefsForNamesUnsafe.bind(this),
6271
+ entries,
6272
+ options
6294
6273
  );
6295
- const now = Date.now();
6296
- for (const entry of entries) {
6297
- upsertStmt.run(
6298
- entry.file,
6299
- entry.lang,
6300
- entry.mtimeMs,
6301
- entry.contentHash ?? "",
6302
- entry.symbolCount,
6303
- now
6304
- );
6305
- }
6306
- this.resolveRefsForNamesUnsafe(affectedNames);
6307
6274
  this.commitWriteTransaction(ownsTransaction);
6308
- return allInserted;
6275
+ return result;
6309
6276
  } catch (err) {
6310
6277
  this.rollbackWriteTransaction(ownsTransaction);
6311
6278
  throw err;
6312
6279
  }
6313
6280
  });
6314
6281
  }
6315
- /**
6316
- * Delete all refs whose source symbols are in a given file.
6317
- * Used when re-indexing a file to clear stale refs.
6318
- */
6319
6282
  deleteRefsForFile(file) {
6320
6283
  this.runWithRetry(() => {
6321
6284
  this.stmt("DELETE FROM refs WHERE from_id IN (SELECT id FROM symbols WHERE file = ?)").run(
@@ -6323,64 +6286,12 @@ var IndexStore = class _IndexStore {
6323
6286
  );
6324
6287
  });
6325
6288
  }
6326
- /**
6327
- * Resolve `to_name` → `to_id` for all refs that have a name but no id.
6328
- * Call this after all symbols have been inserted to fill in cross-references.
6329
- *
6330
- * A match additionally requires the referencing ref and the target symbol to
6331
- * be in the same {@link LangFamily}. Without that guard a name match is a
6332
- * cross-language accident waiting to happen — `main`, `New`, `Parse` and
6333
- * `Config` are declared in most languages at once, and each collision draws a
6334
- * Code Atlas edge between files that never reference each other. Refs stored
6335
- * without a language keep the old global behaviour via the `'*'` wildcard row.
6336
- */
6337
6289
  resolveRefs() {
6338
- return this.runWithRetry(() => {
6339
- try {
6340
- const result = this.stmt(
6341
- `UPDATE refs
6342
- SET to_id = s.id
6343
- FROM (
6344
- SELECT sym.name AS name, lf.family AS family, MIN(sym.id) AS id
6345
- FROM symbols sym
6346
- JOIN lang_family lf ON lf.lang = sym.lang
6347
- GROUP BY sym.name, lf.family
6348
- UNION ALL
6349
- SELECT sym.name AS name, '${LANG_FAMILY_WILDCARD}' AS family, MIN(sym.id) AS id
6350
- FROM symbols sym
6351
- GROUP BY sym.name
6352
- ) AS s,
6353
- lang_family AS rf
6354
- WHERE refs.to_id IS NULL
6355
- AND refs.to_name IS NOT NULL
6356
- AND rf.lang = refs.lang
6357
- AND s.name = refs.to_name
6358
- AND s.family = rf.family`
6359
- ).run();
6360
- return result.changes ?? 0;
6361
- } catch {
6362
- const result = this.stmt(
6363
- `UPDATE refs SET to_id = (
6364
- SELECT sym.id FROM symbols sym
6365
- WHERE sym.name = refs.to_name AND ${_IndexStore.FAMILY_MATCH_SQL}
6366
- ORDER BY sym.id LIMIT 1
6367
- ) WHERE to_id IS NULL AND to_name IS NOT NULL
6368
- AND EXISTS (
6369
- SELECT 1 FROM symbols sym
6370
- WHERE sym.name = refs.to_name AND ${_IndexStore.FAMILY_MATCH_SQL}
6371
- )`
6372
- ).run(LANG_FAMILY_WILDCARD, LANG_FAMILY_WILDCARD);
6373
- return result.changes ?? 0;
6374
- }
6375
- });
6290
+ return this.runWithRetry(() => resolveRefsWithStatement((sql) => this.stmt(sql)));
6376
6291
  }
6377
6292
  resolveRefsForNames(names) {
6378
6293
  return this.runWithRetry(() => this.resolveRefsForNamesUnsafe(names));
6379
6294
  }
6380
- /**
6381
- * Clear symbols/refs for a file and mark it as indexed with zero symbols.
6382
- * Used by the indexer for empty-parse results so three writes share one txn.
6383
- */
6384
6295
  replaceEmptyFile(meta) {
6385
6296
  this.invalidateBm25();
6386
6297
  this.runWithRetry(() => {
@@ -6426,20 +6337,12 @@ var IndexStore = class _IndexStore {
6426
6337
  }
6427
6338
  });
6428
6339
  }
6429
- /** Best-effort query planner refresh after a large reindex. */
6430
6340
  optimize() {
6431
6341
  try {
6432
6342
  this.db.exec("PRAGMA optimize");
6433
6343
  } catch {
6434
6344
  }
6435
6345
  }
6436
- /**
6437
- * Reclaim page churn left by repeated force rebuilds.
6438
- *
6439
- * SQLite's DROP/CREATE path makes rebuilds fast but leaves pages on the
6440
- * freelist. Compact only large, materially sparse databases and only when the
6441
- * caller is already on a full-index maintenance path.
6442
- */
6443
6346
  compactIfNeeded(options = {}) {
6444
6347
  const minBytes = options.minBytes ?? 256 * 1024 * 1024;
6445
6348
  const minFreeRatio = options.minFreeRatio ?? 0.35;
@@ -6466,115 +6369,44 @@ var IndexStore = class _IndexStore {
6466
6369
  return false;
6467
6370
  }
6468
6371
  }
6469
- /**
6470
- * Find all symbols that reference the named target symbol (incoming callers).
6471
- * Accepts a name instead of an id so the agent doesn't need a prior lookup.
6472
- */
6473
6372
  findIncomingCallsByName(symbolName, file, limit = 100) {
6474
6373
  return findIncomingCallsByName((sql) => this.stmt(sql), symbolName, file, limit);
6475
6374
  }
6476
- /**
6477
- * Find all symbols that the named source symbol references (outgoing callees).
6478
- * Accepts a name instead of an id so the agent doesn't need a prior lookup.
6479
- */
6480
6375
  findOutgoingCallsByName(symbolName, file, limit = 100) {
6481
6376
  return findOutgoingCallsByName((sql) => this.stmt(sql), symbolName, file, limit);
6482
6377
  }
6483
- /**
6484
- * Transitive incoming-call tree: all symbols that transitively call the
6485
- * target, to an unbounded depth (cycle-safe via SQL UNION deduplication).
6486
- * Used by `codebase-incoming-calls` when the caller wants the full call
6487
- * chain rather than just direct callers.
6488
- */
6489
6378
  findTransitiveIncomingCallsByName(symbolName, file, limit = 200) {
6490
6379
  return findTransitiveIncomingCallsByName((sql) => this.stmt(sql), symbolName, file, limit);
6491
6380
  }
6492
- /**
6493
- * Transitive outgoing-call tree: all symbols the target transitively calls.
6494
- * Used by `codebase-outgoing-calls` when the caller wants the full
6495
- * dependency chain rather than just direct callees.
6496
- */
6497
6381
  findTransitiveOutgoingCallsByName(symbolName, file, limit = 200) {
6498
6382
  return findTransitiveOutgoingCallsByName((sql) => this.stmt(sql), symbolName, file, limit);
6499
6383
  }
6500
- /**
6501
- * Compute the set of symbol IDs reachable from the given seed IDs using a
6502
- * native SQLite recursive CTE. Used by dead-code detection to replace the
6503
- * in-memory BFS.
6504
- */
6505
6384
  findReachableSymbolIds(seedIds) {
6506
6385
  return findReachableSymbolIds((sql) => this.stmt(sql), seedIds);
6507
6386
  }
6508
- /**
6509
- * Find all references TO a given symbol (who calls / uses this symbol?).
6510
- */
6511
6387
  findRefsTo(symbolId) {
6512
6388
  return findRefsToWithStatement((sql) => this.stmt(sql), symbolId);
6513
6389
  }
6514
- /**
6515
- * Find all references FROM a given symbol (what does this symbol call/use?).
6516
- */
6517
6390
  findRefsFrom(symbolId) {
6518
6391
  return findRefsFromWithStatement((sql) => this.stmt(sql), symbolId);
6519
6392
  }
6520
- // ─── CodeMap graph aggregation ──────────────────────────────────────────────
6521
- /**
6522
- * Package-level graph: each workspace package is a node; edges are derived
6523
- * from cross-package symbol references (a symbol in package A references a
6524
- * symbol resolved in package B). Node metadata includes symbol/file counts.
6525
- */
6526
6393
  getPackageGraph() {
6527
6394
  return getPackageGraphWithStatement((sql) => this.stmt(sql));
6528
6395
  }
6529
- /**
6530
- * File-level graph for a single package: each file is a node; edges are
6531
- * derived from cross-file symbol references within the package.
6532
- */
6533
6396
  getFileGraph(packageFilter) {
6534
6397
  return getFileGraphWithStatement((sql) => this.stmt(sql), packageFilter);
6535
6398
  }
6536
- /**
6537
- * Symbol-level graph for a single file: each symbol is a node; edges are
6538
- * derived from intra-file and cross-file symbol references (who calls whom).
6539
- */
6540
6399
  getSymbolGraph(fileFilter) {
6541
6400
  return getSymbolGraphWithStatement((sql) => this.stmt(sql), fileFilter);
6542
6401
  }
6543
- /**
6544
- * Returns every symbol in the index. Used by dead-code analysis to
6545
- * build the full symbol universe for the reachability scan.
6546
- */
6547
6402
  getAllSymbols() {
6548
6403
  return this.stmt("SELECT id, name, file, kind, line FROM symbols ORDER BY id").all().map((r) => ({ ...r, kind: r.kind }));
6549
6404
  }
6550
- /**
6551
- * Returns every resolved reference (to_id IS NOT NULL). Used by
6552
- * dead-code analysis to build the consumer-ship graph. Refs whose
6553
- * target symbol id is null (unresolved imports) are excluded.
6554
- */
6555
6405
  getAllResolvedRefs() {
6556
- return this.stmt(
6557
- "SELECT from_id AS fromId, to_id AS toId, call_type AS callType FROM refs WHERE to_id IS NOT NULL"
6558
- ).all();
6406
+ return getAllResolvedRefsWithStatement((sql) => this.stmt(sql));
6559
6407
  }
6560
- /**
6561
- * Returns ALL import refs (including unresolved) with their source-file
6562
- * path and resolved target id. Used by the dead-code scan's file-level
6563
- * graph traversal to handle barrel-only entry points where no symbol
6564
- * carries the ref.
6565
- *
6566
- * Refs whose `from_id` doesn't match a known symbol (e.g. pure-barrel
6567
- * files with no declarations) will have `sourceFile === null`.
6568
- */
6569
6408
  getAllImportRefs() {
6570
- return this.stmt(
6571
- `SELECT s.file AS sourceFile, r.to_name AS toName, r.to_id AS toId,
6572
- r.call_type AS callType, r.line
6573
- FROM refs r
6574
- LEFT JOIN symbols s ON r.from_id = s.id
6575
- WHERE r.call_type = 'import'
6576
- ORDER BY r.line`
6577
- ).all();
6409
+ return getAllImportRefsWithStatement((sql) => this.stmt(sql));
6578
6410
  }
6579
6411
  close() {
6580
6412
  this.stmtCache.clear();
@@ -7448,7 +7280,9 @@ var stopMemoryWatchdog = startSharedHeapWatchdog({
7448
7280
  var lastProgressBroadcastAt = 0;
7449
7281
  var externalWatcher;
7450
7282
  var DEFAULT_EXTERNAL_DEBOUNCE_MS = 400;
7283
+ var DEFAULT_EXTERNAL_COALESCE_WINDOW_MS = 50;
7451
7284
  var externalDebounceMs = DEFAULT_EXTERNAL_DEBOUNCE_MS;
7285
+ var externalCoalesceWindowMs = DEFAULT_EXTERNAL_COALESCE_WINDOW_MS;
7452
7286
  var externalDebounceTimers = /* @__PURE__ */ new Map();
7453
7287
  var externalReadyFiles = /* @__PURE__ */ new Set();
7454
7288
  var externalReadyFlush;
@@ -7720,9 +7554,14 @@ async function handleMessage(state, message) {
7720
7554
  if (message.type === "configure") {
7721
7555
  const previousWatchExternal = state.watchExternal;
7722
7556
  const previousDebounceMs = state.debounceMs;
7557
+ const previousCoalesceWindowMs = state.coalesceWindowMs;
7723
7558
  try {
7724
7559
  state.watchExternal = message.watchExternal;
7725
7560
  state.debounceMs = Math.max(0, message.debounceMs);
7561
+ state.coalesceWindowMs = Math.max(
7562
+ 0,
7563
+ message.coalesceWindowMs ?? DEFAULT_EXTERNAL_COALESCE_WINDOW_MS
7564
+ );
7726
7565
  reconcileExternalWatcher();
7727
7566
  send(state, {
7728
7567
  type: "response",
@@ -7736,6 +7575,7 @@ async function handleMessage(state, message) {
7736
7575
  } catch (error) {
7737
7576
  state.watchExternal = previousWatchExternal;
7738
7577
  state.debounceMs = previousDebounceMs;
7578
+ state.coalesceWindowMs = previousCoalesceWindowMs;
7739
7579
  try {
7740
7580
  reconcileExternalWatcher();
7741
7581
  } catch {
@@ -7791,25 +7631,24 @@ function enqueueExternalFile(file) {
7791
7631
  const timer = setTimeout(() => {
7792
7632
  externalDebounceTimers.delete(file);
7793
7633
  externalReadyFiles.add(file);
7794
- if (!externalReadyFlush) {
7795
- externalReadyFlush = setImmediate(() => {
7796
- externalReadyFlush = void 0;
7797
- const files = [...externalReadyFiles].sort();
7798
- externalReadyFiles.clear();
7799
- void withIndexWrite(
7800
- (onProgress) => indexService(
7801
- {
7802
- projectRoot,
7803
- indexDir,
7804
- files
7805
- },
7806
- { onProgress }
7807
- )
7808
- ).catch(() => {
7809
- });
7634
+ if (externalReadyFlush) clearTimeout(externalReadyFlush);
7635
+ externalReadyFlush = setTimeout(() => {
7636
+ externalReadyFlush = void 0;
7637
+ const files = [...externalReadyFiles].sort();
7638
+ externalReadyFiles.clear();
7639
+ void withIndexWrite(
7640
+ (onProgress) => indexService(
7641
+ {
7642
+ projectRoot,
7643
+ indexDir,
7644
+ files
7645
+ },
7646
+ { onProgress }
7647
+ )
7648
+ ).catch(() => {
7810
7649
  });
7811
- externalReadyFlush.unref?.();
7812
- }
7650
+ }, externalCoalesceWindowMs);
7651
+ externalReadyFlush.unref?.();
7813
7652
  }, externalDebounceMs);
7814
7653
  timer.unref?.();
7815
7654
  externalDebounceTimers.set(file, timer);
@@ -7841,7 +7680,7 @@ function stopExternalWatcher() {
7841
7680
  externalWatcher = void 0;
7842
7681
  for (const timer of externalDebounceTimers.values()) clearTimeout(timer);
7843
7682
  externalDebounceTimers.clear();
7844
- if (externalReadyFlush) clearImmediate(externalReadyFlush);
7683
+ if (externalReadyFlush) clearTimeout(externalReadyFlush);
7845
7684
  externalReadyFlush = void 0;
7846
7685
  externalReadyFiles.clear();
7847
7686
  }
@@ -7849,10 +7688,12 @@ function reconcileExternalWatcher() {
7849
7688
  const owners = [...clients].filter((client) => client.watchExternal);
7850
7689
  if (owners.length === 0) {
7851
7690
  externalDebounceMs = DEFAULT_EXTERNAL_DEBOUNCE_MS;
7691
+ externalCoalesceWindowMs = DEFAULT_EXTERNAL_COALESCE_WINDOW_MS;
7852
7692
  stopExternalWatcher();
7853
7693
  return;
7854
7694
  }
7855
7695
  externalDebounceMs = Math.min(...owners.map((client) => client.debounceMs));
7696
+ externalCoalesceWindowMs = Math.min(...owners.map((client) => client.coalesceWindowMs));
7856
7697
  ensureExternalWatcher();
7857
7698
  }
7858
7699
  function consume(state, chunk) {
@@ -7919,6 +7760,7 @@ var server = net.createServer((socket) => {
7919
7760
  cancel: /* @__PURE__ */ new Map(),
7920
7761
  watchExternal: false,
7921
7762
  debounceMs: DEFAULT_EXTERNAL_DEBOUNCE_MS,
7763
+ coalesceWindowMs: DEFAULT_EXTERNAL_COALESCE_WINDOW_MS,
7922
7764
  lastSeenAt: Date.now()
7923
7765
  };
7924
7766
  clients.add(state);