@wrongstack/tools 0.306.4 → 0.307.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/dist/audit.js +14 -8
  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 +11469 -9304
  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 +42 -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 +4239 -1492
  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 +819 -933
  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 +790 -914
  26. package/dist/codebase-index/writer-graph-helpers.d.ts +1 -1
  27. package/dist/codebase-index/writer-helpers.d.ts +12 -0
  28. package/dist/codebase-index/writer-mutations.d.ts +26 -0
  29. package/dist/codebase-index/writer-refs.d.ts +50 -0
  30. package/dist/codebase-index/writer-search.d.ts +30 -0
  31. package/dist/codebase-index/writer.d.ts +2 -320
  32. package/dist/edit.js +8602 -349
  33. package/dist/exec.js +73 -20
  34. package/dist/fetch.js +11 -9
  35. package/dist/format.js +14 -8
  36. package/dist/glob.js +5 -1
  37. package/dist/grep.js +19 -8
  38. package/dist/index.d.ts +3 -1
  39. package/dist/index.js +6178 -4037
  40. package/dist/install.js +14 -8
  41. package/dist/json.js +88 -15
  42. package/dist/kanban-board-actions.d.ts +4 -0
  43. package/dist/kanban-lifecycle-actions.d.ts +4 -0
  44. package/dist/kanban-serializer.d.ts +21 -0
  45. package/dist/kanban.js +985 -1034
  46. package/dist/languages/index.js +14 -8
  47. package/dist/lint.js +14 -8
  48. package/dist/logs.js +13 -3
  49. package/dist/next-steps.d.ts +8 -0
  50. package/dist/next-steps.js +18 -0
  51. package/dist/outdated.js +14 -8
  52. package/dist/pack.js +11461 -9304
  53. package/dist/patch.js +8345 -78
  54. package/dist/plan.js +1231 -1281
  55. package/dist/process-registry.js +14 -8
  56. package/dist/ps-slash.js +65 -30
  57. package/dist/read.js +822 -937
  58. package/dist/replace.js +8494 -224
  59. package/dist/search.js +21 -15
  60. package/dist/security-ast-scan-tool.d.ts +43 -0
  61. package/dist/session-kanban-graph.d.ts +9 -0
  62. package/dist/session-kanban-sync.d.ts +31 -0
  63. package/dist/session-kanban.d.ts +5 -141
  64. package/dist/session-kanban.js +367 -360
  65. package/dist/task.js +1157 -1207
  66. package/dist/test.js +14 -8
  67. package/dist/todo.js +2187 -2237
  68. package/dist/tool-diff.js +6 -1
  69. package/dist/tool-summary.js +4 -2
  70. package/dist/tool-tier.js +11469 -9304
  71. package/dist/typecheck.js +14 -8
  72. package/dist/write.js +8425 -160
  73. 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();
@@ -4425,10 +4362,16 @@ function buildFileGraphNodeState(pkgSyms, localFiles, packageOf) {
4425
4362
  }
4426
4363
  return { fileNodes, symToFile, fileStats, ensureFileNode };
4427
4364
  }
4428
- function buildSymbolGraphNodes(symById, relatedIds, fileFilter, packageOf) {
4365
+ function buildSymbolGraphNodes(symById, relatedIds, localFiles, packageOf) {
4366
+ const local = new Set(
4367
+ [...typeof localFiles === "string" ? [localFiles] : localFiles].map(
4368
+ (file) => file.replace(/\\/g, "/")
4369
+ )
4370
+ );
4371
+ const isLocal = (file) => local.has(file.replace(/\\/g, "/"));
4429
4372
  return [...relatedIds].map((id) => symById.get(id)).filter((symbol) => symbol !== void 0).sort((a, b) => {
4430
- const aExternal = a.file === fileFilter ? 0 : 1;
4431
- const bExternal = b.file === fileFilter ? 0 : 1;
4373
+ const aExternal = isLocal(a.file) ? 0 : 1;
4374
+ const bExternal = isLocal(b.file) ? 0 : 1;
4432
4375
  return aExternal - bExternal || a.file.localeCompare(b.file) || a.line - b.line || a.id - b.id;
4433
4376
  }).map((s) => ({
4434
4377
  id: `sym:${s.id}`,
@@ -4442,7 +4385,7 @@ function buildSymbolGraphNodes(symById, relatedIds, fileFilter, packageOf) {
4442
4385
  line: s.line,
4443
4386
  signature: s.signature,
4444
4387
  scope: s.scope,
4445
- external: s.file !== fileFilter
4388
+ external: !isLocal(s.file)
4446
4389
  }));
4447
4390
  }
4448
4391
  function addWeightedEdge(edgeMap, source, target, callType, weight) {
@@ -4477,6 +4420,52 @@ function materializeWeightedEdges(edgeMap, idPrefix) {
4477
4420
  return edges;
4478
4421
  }
4479
4422
 
4423
+ // src/codebase-index/writer-helpers.ts
4424
+ import { resolveWstackPaths } from "@wrongstack/core/utils";
4425
+ function escapeLike(value) {
4426
+ return value.replace(/[\\%_]/g, (char) => `\\${char}`);
4427
+ }
4428
+ function posixIndexPath(file) {
4429
+ return file.replace(/\\/g, "/").replace(/^\.\//, "");
4430
+ }
4431
+ function indexedFileMatchSql(column = "file") {
4432
+ return `(${column} = ? OR replace(${column}, '\\', '/') = ? OR replace(${column}, '\\', '/') LIKE ? ESCAPE '\\')`;
4433
+ }
4434
+ function indexedFileMatchArgs(file) {
4435
+ const posix4 = posixIndexPath(file.trim());
4436
+ return [file, posix4, `%/${escapeLike(posix4)}`];
4437
+ }
4438
+ function matchesIndexedPackageFilter(storedFile, packageLabel, filter) {
4439
+ if (packageLabel === filter) return true;
4440
+ const posixFile = posixIndexPath(storedFile);
4441
+ const posixFilter = posixIndexPath(filter.trim());
4442
+ if (!posixFilter) return false;
4443
+ return posixFile === posixFilter || posixFile.endsWith(`/${posixFilter}`) || posixFile.includes(`/${posixFilter}/`);
4444
+ }
4445
+ function assignRefsToSymbols(refs, symbols) {
4446
+ if (refs.length === 0 || symbols.length === 0) return [];
4447
+ const ordered = [...symbols].sort((a, b) => a.line - b.line || a.col - b.col || a.id - b.id);
4448
+ const seen = /* @__PURE__ */ new Set();
4449
+ const assigned = [];
4450
+ for (const ref of refs) {
4451
+ let owner;
4452
+ for (const symbol of ordered) {
4453
+ if (symbol.line > ref.line) break;
4454
+ owner = symbol;
4455
+ }
4456
+ if (!owner && ref.callType === "import") owner = ordered[0];
4457
+ if (!owner || owner.id <= 0) continue;
4458
+ const key = `${owner.id}:${ref.toName}:${ref.callType}:${ref.module ?? ""}`;
4459
+ if (seen.has(key)) continue;
4460
+ seen.add(key);
4461
+ assigned.push({ ...ref, fromId: owner.id });
4462
+ }
4463
+ return assigned;
4464
+ }
4465
+ function resolveIndexDir(projectRoot2, override) {
4466
+ return override ?? resolveWstackPaths({ projectRoot: projectRoot2 }).projectCodebaseIndex;
4467
+ }
4468
+
4480
4469
  // src/codebase-index/writer-ref-mapper.ts
4481
4470
  function mapWriterRefRow(row) {
4482
4471
  return {
@@ -4532,15 +4521,29 @@ function mapCallSiteRow(row) {
4532
4521
  line: row.ref_line
4533
4522
  };
4534
4523
  }
4524
+ function resolveIndexedFiles(stmt, file) {
4525
+ const rows = stmt(
4526
+ `SELECT DISTINCT file FROM symbols WHERE ${indexedFileMatchSql("file")} ORDER BY length(file), file`
4527
+ ).all(...indexedFileMatchArgs(file));
4528
+ return rows.map((row) => row.file);
4529
+ }
4535
4530
  function resolveSymbolIds(stmt, symbolName, file) {
4536
- const baseSql = file ? `SELECT id FROM symbols WHERE name = ? AND file = ? ORDER BY id` : `SELECT id FROM symbols WHERE name = ? ORDER BY id`;
4537
- const args = file ? [symbolName, file] : [symbolName];
4538
- const rows = stmt(baseSql).all(...args);
4531
+ if (!file) {
4532
+ const rows2 = stmt("SELECT id FROM symbols WHERE name = ? ORDER BY id").all(symbolName);
4533
+ return rows2.map((r) => r.id);
4534
+ }
4535
+ const indexedFiles = resolveIndexedFiles(stmt, file);
4536
+ if (indexedFiles.length === 0) return [];
4537
+ const placeholders = indexedFiles.map(() => "?").join(",");
4538
+ const rows = stmt(
4539
+ `SELECT id FROM symbols WHERE name = ? AND file IN (${placeholders}) ORDER BY id`
4540
+ ).all(symbolName, ...indexedFiles);
4539
4541
  return rows.map((r) => r.id);
4540
4542
  }
4541
4543
  function findIncomingCallsByName(stmt, symbolName, file, limit) {
4542
4544
  const targetIds = resolveSymbolIds(stmt, symbolName, file);
4543
- if (targetIds.length === 0) return { calls: [], symbolFound: false, ambiguous: false, totalMatches: 0 };
4545
+ if (targetIds.length === 0)
4546
+ return { calls: [], symbolFound: false, ambiguous: false, totalMatches: 0 };
4544
4547
  let matchIds = targetIds;
4545
4548
  let ambiguous = false;
4546
4549
  if (file !== void 0) {
@@ -4591,11 +4594,17 @@ function findIncomingCallsByName(stmt, symbolName, file, limit) {
4591
4594
  }
4592
4595
  rows.sort((a, b) => a.ref_line - b.ref_line || a.sym_id - b.sym_id);
4593
4596
  const allCalls = rows.map(mapCallSiteRow);
4594
- return { calls: allCalls.slice(0, limit), symbolFound: true, ambiguous, totalMatches: allCalls.length };
4597
+ return {
4598
+ calls: allCalls.slice(0, limit),
4599
+ symbolFound: true,
4600
+ ambiguous,
4601
+ totalMatches: allCalls.length
4602
+ };
4595
4603
  }
4596
4604
  function findOutgoingCallsByName(stmt, symbolName, file, limit) {
4597
4605
  const sourceIds = resolveSymbolIds(stmt, symbolName, file);
4598
- if (sourceIds.length === 0) return { calls: [], symbolFound: false, unresolvedCount: 0, totalMatches: 0 };
4606
+ if (sourceIds.length === 0)
4607
+ return { calls: [], symbolFound: false, unresolvedCount: 0, totalMatches: 0 };
4599
4608
  const unresolvedCount = chunkedIdScalar(
4600
4609
  stmt,
4601
4610
  sourceIds,
@@ -4850,7 +4859,7 @@ function getFileGraphWithStatement(stmt, packageFilter) {
4850
4859
  const allFiles = stmt("SELECT DISTINCT file FROM symbols").all();
4851
4860
  const packageOf = readPackageLabeller(stmt);
4852
4861
  const langOf = (file) => detectLang(file) ?? "other";
4853
- const pkgFilePaths = allFiles.filter((f) => packageOf(f.file) === packageFilter).map((f) => f.file);
4862
+ const pkgFilePaths = allFiles.filter((f) => matchesIndexedPackageFilter(f.file, packageOf(f.file), packageFilter)).map((f) => f.file);
4854
4863
  const localFiles = new Set(pkgFilePaths);
4855
4864
  if (localFiles.size === 0) return { nodes: [], edges: [] };
4856
4865
  const filePlaceholders = [...localFiles].map(() => "?").join(",");
@@ -4926,9 +4935,12 @@ function getFileGraphWithStatement(stmt, packageFilter) {
4926
4935
  return { nodes: [...fileNodes.values()], edges };
4927
4936
  }
4928
4937
  function getSymbolGraphWithStatement(stmt, fileFilter) {
4938
+ const indexedFiles = resolveIndexedFiles(stmt, fileFilter);
4939
+ if (indexedFiles.length === 0) return { nodes: [], edges: [] };
4940
+ const filePlaceholders = indexedFiles.map(() => "?").join(",");
4929
4941
  const syms = stmt(
4930
- "SELECT id, name, kind, lang, file, line, signature, scope FROM symbols WHERE file = ? ORDER BY line, id"
4931
- ).all(fileFilter);
4942
+ `SELECT id, name, kind, lang, file, line, signature, scope FROM symbols WHERE file IN (${filePlaceholders}) ORDER BY line, id`
4943
+ ).all(...indexedFiles);
4932
4944
  if (syms.length === 0) return { nodes: [], edges: [] };
4933
4945
  const symById = new Map(syms.map((symbol) => [symbol.id, symbol]));
4934
4946
  const relatedIds = new Set(syms.map((symbol) => symbol.id));
@@ -4938,16 +4950,16 @@ function getSymbolGraphWithStatement(stmt, fileFilter) {
4938
4950
  SELECT r.from_id, r.to_id, r.to_name, r.call_type, r.line
4939
4951
  FROM refs r
4940
4952
  JOIN symbols s ON s.id = r.from_id
4941
- WHERE s.file = ?
4953
+ WHERE s.file IN (${filePlaceholders})
4942
4954
  UNION
4943
4955
  SELECT r.from_id, r.to_id, r.to_name, r.call_type, r.line
4944
4956
  FROM refs r
4945
4957
  JOIN symbols s ON s.id = r.to_id
4946
- WHERE s.file = ?
4958
+ WHERE s.file IN (${filePlaceholders})
4947
4959
  )
4948
4960
  WHERE to_id IS NOT NULL
4949
4961
  GROUP BY from_id, to_id, call_type`
4950
- ).all(fileFilter, fileFilter);
4962
+ ).all(...indexedFiles, ...indexedFiles);
4951
4963
  const edgeMap = /* @__PURE__ */ new Map();
4952
4964
  for (const r of refRows) {
4953
4965
  if (r.to_id == null) continue;
@@ -4966,40 +4978,194 @@ function getSymbolGraphWithStatement(stmt, fileFilter) {
4966
4978
  ).all(...missingIds);
4967
4979
  for (const s of extras) symById.set(s.id, s);
4968
4980
  }
4969
- const nodes = buildSymbolGraphNodes(symById, relatedIds, fileFilter, readPackageLabeller(stmt));
4981
+ const nodes = buildSymbolGraphNodes(
4982
+ symById,
4983
+ relatedIds,
4984
+ new Set(syms.map((symbol) => symbol.file)),
4985
+ readPackageLabeller(stmt)
4986
+ );
4970
4987
  return { nodes, edges };
4971
4988
  }
4972
4989
 
4973
- // src/codebase-index/writer-helpers.ts
4974
- import { resolveWstackPaths } from "@wrongstack/core/utils";
4975
- function escapeLike(value) {
4976
- return value.replace(/[\\%_]/g, (char) => `\\${char}`);
4977
- }
4978
- function assignRefsToSymbols(refs, symbols) {
4979
- if (refs.length === 0 || symbols.length === 0) return [];
4980
- const ordered = [...symbols].sort((a, b) => a.line - b.line || a.col - b.col || a.id - b.id);
4981
- const seen = /* @__PURE__ */ new Set();
4982
- const assigned = [];
4983
- for (const ref of refs) {
4984
- let owner;
4985
- for (const symbol of ordered) {
4986
- if (symbol.line > ref.line) break;
4987
- owner = symbol;
4990
+ // src/codebase-index/vector-search.ts
4991
+ var RRF_K = 60;
4992
+ var VECTOR_DIMENSIONS = 384;
4993
+ var NGRAM_SIZE = 3;
4994
+ function embedText(text) {
4995
+ const vec = new Float32Array(VECTOR_DIMENSIONS);
4996
+ const normalized = text.toLowerCase().trim();
4997
+ if (normalized.length < NGRAM_SIZE) {
4998
+ const padded = ` ${normalized} `.slice(0, Math.max(NGRAM_SIZE, normalized.length + 2));
4999
+ for (let i = 0; i <= padded.length - NGRAM_SIZE; i++) {
5000
+ const ngram = padded.slice(i, i + NGRAM_SIZE);
5001
+ const bucket = hashNgram(ngram) % VECTOR_DIMENSIONS;
5002
+ vec[bucket] += 1;
5003
+ }
5004
+ } else {
5005
+ for (let i = 0; i <= normalized.length - NGRAM_SIZE; i++) {
5006
+ const ngram = normalized.slice(i, i + NGRAM_SIZE);
5007
+ const bucket = hashNgram(ngram) % VECTOR_DIMENSIONS;
5008
+ vec[bucket] += 1;
4988
5009
  }
4989
- if (!owner && ref.callType === "import") owner = ordered[0];
4990
- if (!owner || owner.id <= 0) continue;
4991
- const key = `${owner.id}:${ref.toName}:${ref.callType}:${ref.module ?? ""}`;
4992
- if (seen.has(key)) continue;
4993
- seen.add(key);
4994
- assigned.push({ ...ref, fromId: owner.id });
4995
5010
  }
4996
- return assigned;
5011
+ let norm = 0;
5012
+ for (let i = 0; i < VECTOR_DIMENSIONS; i++) {
5013
+ norm += vec[i] * vec[i];
5014
+ }
5015
+ norm = Math.sqrt(norm);
5016
+ if (norm > 0) {
5017
+ for (let i = 0; i < VECTOR_DIMENSIONS; i++) {
5018
+ vec[i] /= norm;
5019
+ }
5020
+ }
5021
+ return vec;
4997
5022
  }
4998
- function resolveIndexDir(projectRoot2, override) {
4999
- return override ?? resolveWstackPaths({ projectRoot: projectRoot2 }).projectCodebaseIndex;
5023
+ function hashNgram(str) {
5024
+ let hash = 2166136261;
5025
+ for (let i = 0; i < str.length; i++) {
5026
+ hash ^= str.charCodeAt(i);
5027
+ hash = Math.imul(hash, 16777619);
5028
+ }
5029
+ return hash >>> 0;
5000
5030
  }
5001
-
5002
- // src/codebase-index/writer-pragmas.ts
5031
+ function cosineSimilarity(a, b) {
5032
+ let dot = 0;
5033
+ const len = Math.min(a.length, b.length);
5034
+ for (let i = 0; i < len; i++) {
5035
+ dot += a[i] * b[i];
5036
+ }
5037
+ return dot;
5038
+ }
5039
+ function encodeVector(vec) {
5040
+ return Buffer.from(vec.buffer, vec.byteOffset, vec.byteLength);
5041
+ }
5042
+ function decodeVector(buf) {
5043
+ const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
5044
+ const copy = new Float32Array(buf.byteLength / 4);
5045
+ for (let i = 0; i < copy.length; i++) {
5046
+ copy[i] = view.getFloat32(i * 4, true);
5047
+ }
5048
+ return copy;
5049
+ }
5050
+ function reciprocalRankFusion(bm25Ranks, vectorRanks, k = RRF_K) {
5051
+ const allIds = /* @__PURE__ */ new Set([...bm25Ranks.keys(), ...vectorRanks.keys()]);
5052
+ const scored = [];
5053
+ for (const id of allIds) {
5054
+ const bm25Rank = bm25Ranks.get(id);
5055
+ const vecRank = vectorRanks.get(id);
5056
+ let score = 0;
5057
+ if (bm25Rank !== void 0) score += 1 / (k + bm25Rank);
5058
+ if (vecRank !== void 0) score += 1 / (k + vecRank);
5059
+ scored.push([id, score]);
5060
+ }
5061
+ scored.sort((a, b) => b[1] - a[1]);
5062
+ return scored;
5063
+ }
5064
+
5065
+ // src/codebase-index/writer-mutations.ts
5066
+ function commitBatchWithStatement(stmtFn, maxSqlVars, ftsAvailable, vectorsAvailable, allocateSymbolIds, invalidateIncomingRefsForFiles, resolveRefsForNamesUnsafe2, entries, options = {}) {
5067
+ if (entries.length === 0 && (options.deleteForFiles?.length ?? 0) === 0) {
5068
+ return [];
5069
+ }
5070
+ const affectedNames = /* @__PURE__ */ new Set();
5071
+ for (const entry of entries) {
5072
+ for (const symbol of entry.symbols) affectedNames.add(symbol.name);
5073
+ for (const ref of entry.refs) affectedNames.add(ref.toName);
5074
+ }
5075
+ if (options.deleteForFiles && options.deleteForFiles.length > 0) {
5076
+ const placeholders = options.deleteForFiles.map(() => "?").join(",");
5077
+ for (const name of invalidateIncomingRefsForFiles(options.deleteForFiles)) {
5078
+ affectedNames.add(name);
5079
+ }
5080
+ if (ftsAvailable) {
5081
+ stmtFn(
5082
+ `DELETE FROM symbols_fts WHERE rowid IN (SELECT id FROM symbols WHERE file IN (${placeholders}))`
5083
+ ).run(...options.deleteForFiles);
5084
+ }
5085
+ if (vectorsAvailable) {
5086
+ stmtFn(
5087
+ `DELETE FROM symbol_vectors WHERE symbol_id IN (SELECT id FROM symbols WHERE file IN (${placeholders}))`
5088
+ ).run(...options.deleteForFiles);
5089
+ }
5090
+ stmtFn(
5091
+ `DELETE FROM refs WHERE from_id IN (SELECT id FROM symbols WHERE file IN (${placeholders}))`
5092
+ ).run(...options.deleteForFiles);
5093
+ stmtFn(`DELETE FROM symbols WHERE file IN (${placeholders})`).run(...options.deleteForFiles);
5094
+ }
5095
+ const totalSymbols = entries.reduce((n, e) => n + e.symbols.length, 0);
5096
+ let nextId = allocateSymbolIds(totalSymbols);
5097
+ const allInserted = [];
5098
+ const refsToInsert = [];
5099
+ const bulkSyms = [];
5100
+ const ftsRows = [];
5101
+ const vectorRows = [];
5102
+ for (const entry of entries) {
5103
+ const insertedForEntry = [];
5104
+ for (const s of entry.symbols) {
5105
+ const id = nextId++;
5106
+ bulkSyms.push({
5107
+ id,
5108
+ lang: s.lang,
5109
+ kind: s.kind,
5110
+ name: s.name,
5111
+ file: s.file,
5112
+ line: s.line,
5113
+ col: s.col,
5114
+ signature: s.signature,
5115
+ docComment: s.docComment,
5116
+ scope: s.scope,
5117
+ text: s.text
5118
+ });
5119
+ if (ftsAvailable) {
5120
+ ftsRows.push({
5121
+ id,
5122
+ text: buildIndexableText(s.name, s.signature, s.docComment)
5123
+ });
5124
+ }
5125
+ vectorRows.push({
5126
+ id,
5127
+ vector: encodeVector(
5128
+ embedText(s.text || buildIndexableText(s.name, s.signature, s.docComment))
5129
+ )
5130
+ });
5131
+ const inserted = { ...s, id };
5132
+ allInserted.push(inserted);
5133
+ insertedForEntry.push(inserted);
5134
+ }
5135
+ refsToInsert.push(...assignRefsToSymbols(entry.refs, insertedForEntry));
5136
+ }
5137
+ bulkInsertSymbolsWithStatement((sql) => stmtFn(sql), maxSqlVars, bulkSyms);
5138
+ bulkInsertFtsWithStatement((sql) => stmtFn(sql), maxSqlVars, ftsAvailable, ftsRows);
5139
+ if (vectorsAvailable) {
5140
+ bulkInsertVectorsWithStatement((sql) => stmtFn(sql), maxSqlVars, vectorRows);
5141
+ }
5142
+ bulkInsertRefsWithStatement((sql) => stmtFn(sql), maxSqlVars, refsToInsert);
5143
+ const upsertStmt = stmtFn(
5144
+ `INSERT INTO files(file, lang, mtime_ms, content_hash, symbol_count, last_indexed)
5145
+ VALUES (?, ?, ?, ?, ?, ?)
5146
+ ON CONFLICT(file) DO UPDATE SET
5147
+ lang = excluded.lang,
5148
+ mtime_ms = excluded.mtime_ms,
5149
+ content_hash = excluded.content_hash,
5150
+ symbol_count = excluded.symbol_count,
5151
+ last_indexed = excluded.last_indexed`
5152
+ );
5153
+ const now = Date.now();
5154
+ for (const entry of entries) {
5155
+ upsertStmt.run(
5156
+ entry.file,
5157
+ entry.lang,
5158
+ entry.mtimeMs,
5159
+ entry.contentHash ?? "",
5160
+ entry.symbolCount,
5161
+ now
5162
+ );
5163
+ }
5164
+ resolveRefsForNamesUnsafe2(affectedNames);
5165
+ return allInserted;
5166
+ }
5167
+
5168
+ // src/codebase-index/writer-pragmas.ts
5003
5169
  import { sqliteCachePragmas } from "@wrongstack/core/utils";
5004
5170
  function applyIndexStorePragmas(db) {
5005
5171
  try {
@@ -5111,6 +5277,237 @@ var SYMBOL_VECTORS_TABLE_SQL = `
5111
5277
  );
5112
5278
  `;
5113
5279
 
5280
+ // src/codebase-index/writer-refs.ts
5281
+ var FAMILY_MATCH_SQL = `(
5282
+ sym.lang = refs.lang
5283
+ OR EXISTS (
5284
+ SELECT 1 FROM lang_family lf1
5285
+ JOIN lang_family lf2 ON lf1.family = lf2.family
5286
+ WHERE lf1.lang = sym.lang AND lf2.lang = refs.lang
5287
+ )
5288
+ OR ? IN (
5289
+ SELECT family FROM lang_family WHERE lang = refs.lang
5290
+ )
5291
+ )`;
5292
+ function getNamespaceDeclarationsWithStatement(stmtFn) {
5293
+ return stmtFn(
5294
+ `SELECT name, file FROM symbols WHERE kind = 'namespace' ORDER BY file, id`
5295
+ ).all();
5296
+ }
5297
+ function getFilePackagesWithStatement(stmtFn) {
5298
+ const rows = stmtFn("SELECT file, package FROM files WHERE package != ''").all();
5299
+ return new Map(rows.map((row) => [row.file, row.package]));
5300
+ }
5301
+ function getUnresolvedImportsWithStatement(stmtFn, maxSqlVars, onlyFiles) {
5302
+ const base = `SELECT DISTINCT s.file AS fromFile, r.lang AS lang, r.module AS module
5303
+ FROM refs r
5304
+ JOIN symbols s ON s.id = r.from_id
5305
+ WHERE r.call_type = 'import' AND r.module IS NOT NULL`;
5306
+ if (!onlyFiles?.length) {
5307
+ return stmtFn(base).all();
5308
+ }
5309
+ const out = [];
5310
+ for (let i = 0; i < onlyFiles.length; i += maxSqlVars) {
5311
+ const chunk = onlyFiles.slice(i, i + maxSqlVars);
5312
+ const placeholders = chunk.map(() => "?").join(",");
5313
+ out.push(
5314
+ ...stmtFn(`${base} AND s.file IN (${placeholders})`).all(...chunk)
5315
+ );
5316
+ }
5317
+ return out;
5318
+ }
5319
+ function getAllResolvedRefsWithStatement(stmtFn) {
5320
+ return stmtFn(
5321
+ "SELECT from_id AS fromId, to_id AS toId, call_type AS callType FROM refs WHERE to_id IS NOT NULL"
5322
+ ).all();
5323
+ }
5324
+ function getAllImportRefsWithStatement(stmtFn) {
5325
+ return stmtFn(
5326
+ `SELECT s.file AS sourceFile, r.to_name AS toName, r.to_id AS toId,
5327
+ r.call_type AS callType, r.line
5328
+ FROM refs r
5329
+ LEFT JOIN symbols s ON r.from_id = s.id
5330
+ WHERE r.call_type = 'import'
5331
+ ORDER BY r.line`
5332
+ ).all();
5333
+ }
5334
+ function resolveRefsWithStatement(stmtFn) {
5335
+ try {
5336
+ const result = stmtFn(
5337
+ `UPDATE refs
5338
+ SET to_id = s.id
5339
+ FROM (
5340
+ SELECT sym.name AS name, lf.family AS family, MIN(sym.id) AS id
5341
+ FROM symbols sym
5342
+ JOIN lang_family lf ON lf.lang = sym.lang
5343
+ GROUP BY sym.name, lf.family
5344
+ UNION ALL
5345
+ SELECT sym.name AS name, '${LANG_FAMILY_WILDCARD}' AS family, MIN(sym.id) AS id
5346
+ FROM symbols sym
5347
+ GROUP BY sym.name
5348
+ ) AS s,
5349
+ lang_family AS rf
5350
+ WHERE refs.to_id IS NULL
5351
+ AND refs.to_name IS NOT NULL
5352
+ AND rf.lang = refs.lang
5353
+ AND s.name = refs.to_name
5354
+ AND s.family = rf.family`
5355
+ ).run();
5356
+ return result.changes ?? 0;
5357
+ } catch {
5358
+ const result = stmtFn(
5359
+ `UPDATE refs SET to_id = (
5360
+ SELECT sym.id FROM symbols sym
5361
+ WHERE sym.name = refs.to_name AND ${FAMILY_MATCH_SQL}
5362
+ ORDER BY sym.id LIMIT 1
5363
+ ) WHERE to_id IS NULL AND to_name IS NOT NULL
5364
+ AND EXISTS (
5365
+ SELECT 1 FROM symbols sym
5366
+ WHERE sym.name = refs.to_name AND ${FAMILY_MATCH_SQL}
5367
+ )`
5368
+ ).run(LANG_FAMILY_WILDCARD, LANG_FAMILY_WILDCARD);
5369
+ return result.changes ?? 0;
5370
+ }
5371
+ }
5372
+ function applyImportResolutionsWithStatement(db, stmtFn, runWithRetry, maxSqlVars, resolutions) {
5373
+ if (resolutions.length === 0) return 0;
5374
+ return runWithRetry(() => {
5375
+ db.exec("DROP TABLE IF EXISTS temp.import_resolution");
5376
+ db.exec(
5377
+ `CREATE TEMP TABLE import_resolution (
5378
+ from_file TEXT NOT NULL,
5379
+ lang TEXT NOT NULL,
5380
+ module TEXT NOT NULL,
5381
+ to_file TEXT NOT NULL
5382
+ )`
5383
+ );
5384
+ const chunkSize = Math.max(1, Math.floor(maxSqlVars / 4));
5385
+ for (let i = 0; i < resolutions.length; i += chunkSize) {
5386
+ const chunk = resolutions.slice(i, i + chunkSize);
5387
+ const placeholders = chunk.map(() => "(?, ?, ?, ?)").join(", ");
5388
+ const binds = [];
5389
+ for (const entry of chunk) {
5390
+ binds.push(entry.fromFile, entry.lang, entry.module, entry.toFile);
5391
+ }
5392
+ stmtFn(
5393
+ `INSERT INTO temp.import_resolution(from_file, lang, module, to_file)
5394
+ VALUES ${placeholders}`
5395
+ ).run(...binds);
5396
+ }
5397
+ db.exec(
5398
+ `CREATE INDEX IF NOT EXISTS temp.idx_ir
5399
+ ON import_resolution(module, lang, from_file)`
5400
+ );
5401
+ const result = stmtFn(
5402
+ `UPDATE refs
5403
+ SET to_file = (
5404
+ SELECT ir.to_file
5405
+ FROM temp.import_resolution ir
5406
+ JOIN symbols s ON s.id = refs.from_id
5407
+ WHERE ir.module = refs.module
5408
+ AND ir.lang = refs.lang
5409
+ AND ir.from_file = s.file
5410
+ LIMIT 1
5411
+ )
5412
+ WHERE refs.call_type = 'import'
5413
+ AND refs.module IS NOT NULL
5414
+ AND EXISTS (
5415
+ SELECT 1
5416
+ FROM temp.import_resolution ir
5417
+ JOIN symbols s ON s.id = refs.from_id
5418
+ WHERE ir.module = refs.module
5419
+ AND ir.lang = refs.lang
5420
+ AND ir.from_file = s.file
5421
+ )`
5422
+ ).run();
5423
+ db.exec("DROP TABLE IF EXISTS temp.import_resolution");
5424
+ return result.changes ?? 0;
5425
+ });
5426
+ }
5427
+ function resolveRefsForNamesUnsafe(stmtFn, maxSqlVars, names) {
5428
+ const list = [...names].filter((name) => name.length > 0);
5429
+ if (list.length === 0) return 0;
5430
+ let total = 0;
5431
+ for (let i = 0; i < list.length; i += maxSqlVars) {
5432
+ const chunk = list.slice(i, i + maxSqlVars);
5433
+ const placeholders = chunk.map(() => "?").join(",");
5434
+ try {
5435
+ const result = stmtFn(
5436
+ `UPDATE refs
5437
+ SET to_id = s.id
5438
+ FROM (
5439
+ SELECT sym.name AS name, lf.family AS family, MIN(sym.id) AS id
5440
+ FROM symbols sym
5441
+ JOIN lang_family lf ON lf.lang = sym.lang
5442
+ WHERE sym.name IN (${placeholders})
5443
+ GROUP BY sym.name, lf.family
5444
+ UNION ALL
5445
+ SELECT sym.name AS name, '${LANG_FAMILY_WILDCARD}' AS family, MIN(sym.id) AS id
5446
+ FROM symbols sym
5447
+ WHERE sym.name IN (${placeholders})
5448
+ GROUP BY sym.name
5449
+ ) AS s,
5450
+ lang_family AS rf
5451
+ WHERE refs.to_name IN (${placeholders})
5452
+ AND rf.lang = refs.lang
5453
+ AND s.name = refs.to_name
5454
+ AND s.family = rf.family`
5455
+ ).run(...chunk, ...chunk, ...chunk);
5456
+ total += result.changes ?? 0;
5457
+ } catch {
5458
+ const result = stmtFn(
5459
+ `UPDATE refs SET to_id = (
5460
+ SELECT sym.id FROM symbols sym
5461
+ WHERE sym.name = refs.to_name AND ${FAMILY_MATCH_SQL}
5462
+ ORDER BY sym.id LIMIT 1
5463
+ ) WHERE refs.to_name IN (${placeholders})
5464
+ AND EXISTS (
5465
+ SELECT 1 FROM symbols sym
5466
+ WHERE sym.name = refs.to_name AND ${FAMILY_MATCH_SQL}
5467
+ )`
5468
+ ).run(LANG_FAMILY_WILDCARD, ...chunk, LANG_FAMILY_WILDCARD);
5469
+ total += result.changes ?? 0;
5470
+ }
5471
+ }
5472
+ return total;
5473
+ }
5474
+
5475
+ // src/codebase-index/writer-search.ts
5476
+ import { expectDefined as expectDefined4 } from "@wrongstack/core/utils";
5477
+
5478
+ // src/codebase-index/lsp-kind.ts
5479
+ function lspKindToInternalKind(k) {
5480
+ switch (k) {
5481
+ case 5 /* Class */:
5482
+ return "class";
5483
+ case 6 /* Method */:
5484
+ return "method";
5485
+ case 7 /* Property */:
5486
+ case 8 /* Field */:
5487
+ return "property";
5488
+ case 9 /* Constructor */:
5489
+ return "class";
5490
+ case 10 /* Enum */:
5491
+ return "enum";
5492
+ case 11 /* Interface */:
5493
+ return "interface";
5494
+ case 12 /* Function */:
5495
+ return "function";
5496
+ case 13 /* Variable */:
5497
+ return "var";
5498
+ case 14 /* Constant */:
5499
+ return "const";
5500
+ case 22 /* EnumMember */:
5501
+ return "enum";
5502
+ case 26 /* TypeParameter */:
5503
+ return "type";
5504
+ case 3 /* Namespace */:
5505
+ return "namespace";
5506
+ default:
5507
+ return null;
5508
+ }
5509
+ }
5510
+
5114
5511
  // src/codebase-index/writer-search-helpers.ts
5115
5512
  var SEARCH_CANDIDATE_SCAN_CAP = 5e3;
5116
5513
  function normalizeSearchLimit(limit) {
@@ -5164,6 +5561,173 @@ function mapWriterSearchRow(row, lspKind, score = 0, snippet = "") {
5164
5561
  };
5165
5562
  }
5166
5563
 
5564
+ // src/codebase-index/writer-search.ts
5565
+ function searchWithStatement(stmtFn, query, filter, opts) {
5566
+ const built = buildWriterSearchWhere(query, filter);
5567
+ if (built === null) return [];
5568
+ const { where, values } = built;
5569
+ const limit = normalizeSearchLimit(opts?.limit);
5570
+ const limitSql = limit !== void 0 ? " LIMIT ?" : "";
5571
+ const sql = `SELECT id, lang, kind, name, file, line, col, signature, doc_comment FROM symbols ${where}${limitSql}`;
5572
+ const binds = limit !== void 0 ? [...values, limit] : values;
5573
+ const rows = stmtFn(sql).all(...binds);
5574
+ return rows.map((row) => mapWriterSearchRow(row, filter?.lspKind));
5575
+ }
5576
+ function countSearchWithStatement(stmtFn, query, filter) {
5577
+ const built = buildWriterSearchWhere(query, filter);
5578
+ if (built === null) return 0;
5579
+ const row = stmtFn(`SELECT COUNT(*) AS n FROM symbols ${built.where}`).get(
5580
+ ...built.values
5581
+ );
5582
+ return Number(row?.n ?? 0);
5583
+ }
5584
+ function searchRankedWithStatement(stmtFn, searchFn, ftsAvailable, vectorsAvailable, getOrBuildBm25, query, filter, limit) {
5585
+ const rawLimit = Number.isFinite(limit) ? Math.trunc(limit) : 20;
5586
+ const safeLimit = Math.max(1, Math.min(rawLimit, 100));
5587
+ const tokens = tokenise(query);
5588
+ if (tokens.length === 0 || !ftsAvailable) {
5589
+ return searchRankedFallbackWithStatement(
5590
+ stmtFn,
5591
+ searchFn,
5592
+ getOrBuildBm25,
5593
+ query,
5594
+ filter,
5595
+ safeLimit
5596
+ );
5597
+ }
5598
+ let effectiveKind = filter?.kind;
5599
+ if (filter?.lspKind !== void 0) {
5600
+ const mapped = lspKindToInternalKind(filter.lspKind);
5601
+ if (mapped === null) return { results: [], total: 0 };
5602
+ effectiveKind = mapped;
5603
+ }
5604
+ const longTokens = tokens.filter((t) => t.length >= 3);
5605
+ const shortTokens = tokens.filter((t) => t.length < 3);
5606
+ if (longTokens.length === 0) {
5607
+ return searchRankedFallbackWithStatement(
5608
+ stmtFn,
5609
+ searchFn,
5610
+ getOrBuildBm25,
5611
+ query,
5612
+ filter,
5613
+ safeLimit
5614
+ );
5615
+ }
5616
+ const match = longTokens.map((t) => `"${t.replaceAll('"', "")}"`).join(" OR ");
5617
+ const conditions = ["symbols_fts MATCH ?"];
5618
+ const values = [match];
5619
+ for (const shortTok of shortTokens) {
5620
+ conditions.push("s.text LIKE ? ESCAPE '\\'");
5621
+ values.push(`%${escapeLike(shortTok)}%`);
5622
+ }
5623
+ if (effectiveKind) {
5624
+ conditions.push("s.kind = ?");
5625
+ values.push(effectiveKind);
5626
+ }
5627
+ if (filter?.lang) {
5628
+ conditions.push("s.lang = ?");
5629
+ values.push(filter.lang);
5630
+ }
5631
+ if (filter?.file) {
5632
+ conditions.push("replace(s.file, '\\', '/') LIKE ? ESCAPE '\\'");
5633
+ values.push(`%${escapeLike(filter.file.replace(/\\/g, "/"))}%`);
5634
+ }
5635
+ const where = conditions.join(" AND ");
5636
+ const countRows = stmtFn(
5637
+ `SELECT COUNT(*) AS n FROM symbols_fts JOIN symbols s ON s.id = symbols_fts.rowid WHERE ${where}`
5638
+ ).all(...values);
5639
+ const total = countRows[0] ? Number(countRows[0].n) : 0;
5640
+ if (total === 0) return { results: [], total: 0 };
5641
+ const bm25Rows = stmtFn(
5642
+ `SELECT s.id, s.lang, s.kind, s.name, s.file, s.line, s.col, s.signature, s.doc_comment,
5643
+ -bm25(symbols_fts) AS score,
5644
+ snippet(symbols_fts, 0, '', '', '\u2026', 12) AS snippet
5645
+ FROM symbols_fts JOIN symbols s ON s.id = symbols_fts.rowid
5646
+ WHERE ${where}
5647
+ ORDER BY
5648
+ CASE WHEN lower(s.name) = lower(?) THEN 0
5649
+ WHEN lower(s.name) LIKE lower(?) ESCAPE '\\' THEN 1
5650
+ ELSE 2 END,
5651
+ bm25(symbols_fts), lower(s.name), s.file, s.line, s.col, s.id
5652
+ LIMIT ?`
5653
+ ).all(...values, query.trim(), `${escapeLike(query.trim())}%`, safeLimit);
5654
+ if (vectorsAvailable && bm25Rows.length > 0) {
5655
+ const queryVec = embedText(query);
5656
+ const candidateIds = bm25Rows.map((r) => r.id);
5657
+ const placeholders = candidateIds.map(() => "?").join(",");
5658
+ const vecRows = stmtFn(
5659
+ `SELECT sv.symbol_id, sv.vector FROM symbol_vectors sv WHERE sv.symbol_id IN (${placeholders})`
5660
+ ).all(...candidateIds);
5661
+ const vecScores = vecRows.map((r) => ({
5662
+ id: r.symbol_id,
5663
+ sim: cosineSimilarity(queryVec, decodeVector(r.vector))
5664
+ })).sort((a, b) => b.sim - a.sim);
5665
+ const bm25Rank = /* @__PURE__ */ new Map();
5666
+ bm25Rows.forEach((r, i) => {
5667
+ bm25Rank.set(r.id, i);
5668
+ });
5669
+ const vecRank = /* @__PURE__ */ new Map();
5670
+ vecScores.forEach((r, i) => {
5671
+ vecRank.set(r.id, i);
5672
+ });
5673
+ const fused = reciprocalRankFusion(bm25Rank, vecRank, 60);
5674
+ const fusedScore = new Map(fused);
5675
+ const sorted = [...bm25Rows].sort(
5676
+ (a, b) => (fusedScore.get(b.id) ?? 0) - (fusedScore.get(a.id) ?? 0)
5677
+ );
5678
+ return {
5679
+ results: sorted.map(
5680
+ (row) => mapWriterSearchRow(row, filter?.lspKind, Math.max(1e-4, row.score), row.snippet)
5681
+ ),
5682
+ total
5683
+ };
5684
+ }
5685
+ return {
5686
+ results: bm25Rows.map(
5687
+ (row) => mapWriterSearchRow(row, filter?.lspKind, Math.max(1e-4, row.score), row.snippet)
5688
+ ),
5689
+ total
5690
+ };
5691
+ }
5692
+ function searchRankedFallbackWithStatement(stmtFn, searchFn, getOrBuildBm25, query, filter, limit) {
5693
+ if (!query.trim()) {
5694
+ const total2 = countSearchWithStatement(stmtFn, query, filter);
5695
+ if (total2 === 0) return { results: [], total: 0 };
5696
+ return { results: searchFn(query, filter, { limit }), total: total2 };
5697
+ }
5698
+ const total = countSearchWithStatement(stmtFn, query, filter);
5699
+ if (total === 0) return { results: [], total: 0 };
5700
+ const candidates = searchFn(query, filter, {
5701
+ limit: SEARCH_CANDIDATE_SCAN_CAP
5702
+ });
5703
+ if (candidates.length === 0) return { results: [], total: 0 };
5704
+ const candidateById = new Map(candidates.map((c) => [c.id, c]));
5705
+ const bm25 = getOrBuildBm25();
5706
+ const scored = bm25.score(query, (id) => candidateById.has(id));
5707
+ const q = query.trim().toLowerCase();
5708
+ const rank = (id) => {
5709
+ const name = candidateById.get(id)?.name.toLowerCase() ?? "";
5710
+ if (name === q) return 0;
5711
+ if (name.startsWith(q)) return 1;
5712
+ return 2;
5713
+ };
5714
+ scored.sort((a, b) => {
5715
+ const rankDiff = rank(a.id) - rank(b.id);
5716
+ if (rankDiff !== 0) return rankDiff;
5717
+ const scoreDiff = b.score - a.score;
5718
+ if (scoreDiff !== 0) return scoreDiff;
5719
+ const left = expectDefined4(candidateById.get(a.id));
5720
+ const right = expectDefined4(candidateById.get(b.id));
5721
+ return left.name.localeCompare(right.name) || left.file.localeCompare(right.file) || left.line - right.line || left.col - right.col || left.id - right.id;
5722
+ });
5723
+ const qTokens = tokenise(query);
5724
+ const results = scored.slice(0, limit).map(({ id, score }) => {
5725
+ const c = expectDefined4(candidateById.get(id));
5726
+ return { ...c, score, snippet: bm25.extractSnippet(id, qTokens) };
5727
+ });
5728
+ return { results, total };
5729
+ }
5730
+
5167
5731
  // src/codebase-index/writer-store-pool.ts
5168
5732
  var DEFAULT_MAX_WARM_STORES = 2;
5169
5733
  var StorePool = class {
@@ -5255,68 +5819,14 @@ var DB_FILE2 = "index.db";
5255
5819
  var MAX_STATEMENT_CACHE = 128;
5256
5820
  var IndexStore = class _IndexStore {
5257
5821
  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
5822
  atomicIndexUpdateActive = false;
5265
5823
  writeSavepointSequence = 0;
5266
- /** Absolute path to this project's index directory. */
5267
5824
  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
5825
  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
5826
  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
5827
  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
5828
  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
5829
  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
5830
  stmt(sql) {
5321
5831
  const cached = this.stmtCache.get(sql);
5322
5832
  if (cached !== void 0) {
@@ -5343,7 +5853,6 @@ var IndexStore = class _IndexStore {
5343
5853
  runWithRetry(fn) {
5344
5854
  return runSqliteWithRetry(fn);
5345
5855
  }
5346
- /** Run a complete index mutation as one WAL-visible publication. */
5347
5856
  async runAtomicIndexUpdate(job) {
5348
5857
  if (this.atomicIndexUpdateActive) return job();
5349
5858
  this.runWithRetry(() => this.db.exec("BEGIN IMMEDIATE"));
@@ -5362,11 +5871,6 @@ var IndexStore = class _IndexStore {
5362
5871
  this.atomicIndexUpdateActive = false;
5363
5872
  }
5364
5873
  }
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
5874
  beginWriteTransaction() {
5371
5875
  if (this.atomicIndexUpdateActive) {
5372
5876
  const savepoint = `index_write_${++this.writeSavepointSequence}`;
@@ -5388,35 +5892,11 @@ var IndexStore = class _IndexStore {
5388
5892
  this.db.exec("ROLLBACK");
5389
5893
  }
5390
5894
  }
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
5895
  seedLangFamilies() {
5399
5896
  const insert = this.stmt("INSERT OR REPLACE INTO lang_family(lang, family) VALUES (?, ?)");
5400
5897
  for (const [lang, family] of LANG_FAMILY_ENTRIES) insert.run(lang, family);
5401
5898
  insert.run("", LANG_FAMILY_WILDCARD);
5402
5899
  }
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
5900
  repairMissingColumns() {
5421
5901
  const expected = [
5422
5902
  {
@@ -5521,27 +6001,8 @@ var IndexStore = class _IndexStore {
5521
6001
  }
5522
6002
  this.ensureNextSymbolIdSeeded();
5523
6003
  }
5524
- // ─── ID allocation & bulk helpers ────────────────────────────────────────────
5525
6004
  static NEXT_SYMBOL_ID_KEY = "next_symbol_id";
5526
- /** Stay under typical SQLite SQLITE_MAX_VARIABLE_NUMBER (often 999). */
5527
6005
  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
6006
  ensureNextSymbolIdSeeded() {
5546
6007
  const existing = this.stmt("SELECT value FROM metadata WHERE key = ?").get(
5547
6008
  _IndexStore.NEXT_SYMBOL_ID_KEY
@@ -5554,10 +6015,6 @@ var IndexStore = class _IndexStore {
5554
6015
  String(next)
5555
6016
  );
5556
6017
  }
5557
- /**
5558
- * Reserve `count` consecutive symbol ids. MUST run inside BEGIN IMMEDIATE
5559
- * so concurrent indexers cannot hand out overlapping ranges.
5560
- */
5561
6018
  allocateSymbolIds(count) {
5562
6019
  if (count <= 0) return this.getMaxSymbolId() + 1;
5563
6020
  this.ensureNextSymbolIdSeeded();
@@ -5571,16 +6028,8 @@ var IndexStore = class _IndexStore {
5571
6028
  );
5572
6029
  return start;
5573
6030
  }
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
6031
  invalidateIncomingRefsForFiles(files) {
5583
- if (files.length === 0) return [];
6032
+ if (files.length === 0) return /* @__PURE__ */ new Set();
5584
6033
  const placeholders = files.map(() => "?").join(",");
5585
6034
  const names = this.stmt(`SELECT DISTINCT name FROM symbols WHERE file IN (${placeholders})`).all(
5586
6035
  ...files
@@ -5589,36 +6038,11 @@ var IndexStore = class _IndexStore {
5589
6038
  `UPDATE refs SET to_id = NULL
5590
6039
  WHERE to_id IN (SELECT id FROM symbols WHERE file IN (${placeholders}))`
5591
6040
  ).run(...files);
5592
- return names;
6041
+ return new Set(names);
5593
6042
  }
5594
- /** Resolve only refs whose target names may have changed. */
5595
6043
  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;
6044
+ return resolveRefsForNamesUnsafe((sql) => this.stmt(sql), _IndexStore.MAX_SQL_VARS, names);
5612
6045
  }
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
6046
  insertSymbols(symbols) {
5623
6047
  this.invalidateBm25();
5624
6048
  return this.runWithRetry(() => {
@@ -5647,12 +6071,6 @@ var IndexStore = class _IndexStore {
5647
6071
  if (this.ftsAvailable) {
5648
6072
  ftsRows.push({ id, text: buildIndexableText(s.name, s.signature, s.docComment) });
5649
6073
  }
5650
- vectorRows.push({
5651
- id,
5652
- vector: encodeVector(
5653
- embedText(s.text || buildIndexableText(s.name, s.signature, s.docComment))
5654
- )
5655
- });
5656
6074
  result.push({ ...s, id });
5657
6075
  }
5658
6076
  bulkInsertSymbolsWithStatement((sql) => this.stmt(sql), _IndexStore.MAX_SQL_VARS, bulk);
@@ -5702,11 +6120,6 @@ var IndexStore = class _IndexStore {
5702
6120
  }
5703
6121
  });
5704
6122
  }
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
6123
  deleteFile(file) {
5711
6124
  this.invalidateBm25();
5712
6125
  this.runWithRetry(() => {
@@ -5736,7 +6149,6 @@ var IndexStore = class _IndexStore {
5736
6149
  }
5737
6150
  });
5738
6151
  }
5739
- // ─── File metadata ──────────────────────────────────────────────────────────
5740
6152
  upsertFile(meta) {
5741
6153
  this.runWithRetry(() => {
5742
6154
  this.stmt(
@@ -5764,8 +6176,6 @@ var IndexStore = class _IndexStore {
5764
6176
  getAllFileMetas() {
5765
6177
  return getAllFileMetasWithStatement((sql) => this.stmt(sql));
5766
6178
  }
5767
- // ─── Project structure & module resolution ──────────────────────────────────
5768
- /** Store the Code Atlas grouping label for each indexed file. */
5769
6179
  setFilePackages(entries) {
5770
6180
  if (entries.size === 0) return;
5771
6181
  this.runWithRetry(() => {
@@ -5773,272 +6183,50 @@ var IndexStore = class _IndexStore {
5773
6183
  for (const [file, label] of entries) update.run(label, file);
5774
6184
  });
5775
6185
  }
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
6186
  getNamespaceDeclarations() {
5782
- return this.stmt(
5783
- `SELECT name, file FROM symbols WHERE kind = 'namespace' ORDER BY file, id`
5784
- ).all();
6187
+ return getNamespaceDeclarationsWithStatement((sql) => this.stmt(sql));
5785
6188
  }
5786
- /** `file → package` for every indexed file that has a label. */
5787
6189
  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]));
6190
+ return getFilePackagesWithStatement((sql) => this.stmt(sql));
5790
6191
  }
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
6192
  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;
6193
+ return getUnresolvedImportsWithStatement(
6194
+ (sql) => this.stmt(sql),
6195
+ _IndexStore.MAX_SQL_VARS,
6196
+ onlyFiles
6197
+ );
5814
6198
  }
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
6199
  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
6200
+ return applyImportResolutionsWithStatement(
6201
+ this.db,
6202
+ (sql) => this.stmt(sql),
6203
+ this.runWithRetry.bind(this),
6204
+ _IndexStore.MAX_SQL_VARS,
6205
+ resolutions
5887
6206
  );
5888
- return rows.map((row) => mapWriterSearchRow(row, filter?.lspKind));
5889
6207
  }
5890
- /** Shared WHERE builder for {@link search} / empty-query ranked totals. */
5891
- buildSearchWhere(query, filter) {
5892
- return buildWriterSearchWhere(query, filter);
6208
+ search(query, filter, opts) {
6209
+ return searchWithStatement((sql) => this.stmt(sql), query, filter, opts);
5893
6210
  }
5894
6211
  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);
6212
+ return countSearchWithStatement((sql) => this.stmt(sql), query, filter);
5901
6213
  }
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
6214
  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
- };
6215
+ return searchRankedWithStatement(
6216
+ (sql) => this.stmt(sql),
6217
+ this.search.bind(this),
6218
+ this.ftsAvailable,
6219
+ this.vectorsAvailable,
6220
+ this.getOrBuildBm25.bind(this),
6221
+ query,
6222
+ filter,
6223
+ limit
6224
+ );
6005
6225
  }
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
6226
  invalidateBm25() {
6028
6227
  this.bm25Dirty = true;
6029
6228
  this.bm25Cache = null;
6030
6229
  }
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
6230
  getOrBuildBm25() {
6043
6231
  if (this.bm25Cache && !this.bm25Dirty) return this.bm25Cache;
6044
6232
  const docs = this.getAllIndexable();
@@ -6046,57 +6234,12 @@ var IndexStore = class _IndexStore {
6046
6234
  this.bm25Dirty = false;
6047
6235
  return this.bm25Cache;
6048
6236
  }
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
6237
  getAllIndexable() {
6087
6238
  return getAllIndexableWithStatement((sql) => this.stmt(sql));
6088
6239
  }
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
6240
  getMaxSymbolId() {
6097
6241
  return getMaxSymbolIdWithStatement((sql) => this.stmt(sql));
6098
6242
  }
6099
- // ─── Stats ───────────────────────────────────────────────────────────────────
6100
6243
  getStats() {
6101
6244
  return getStatsWithStatement((sql) => this.stmt(sql), this.indexDir);
6102
6245
  }
@@ -6139,11 +6282,6 @@ var IndexStore = class _IndexStore {
6139
6282
  }
6140
6283
  });
6141
6284
  }
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
6285
  insertRefs(fromId, refs) {
6148
6286
  this.runWithRetry(() => {
6149
6287
  this.stmt("DELETE FROM refs WHERE from_id = ?").run(fromId);
@@ -6155,167 +6293,36 @@ var IndexStore = class _IndexStore {
6155
6293
  );
6156
6294
  });
6157
6295
  }
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
6296
  insertRefsBatch(refs) {
6170
6297
  if (refs.length === 0) return;
6171
6298
  this.runWithRetry(() => {
6172
6299
  bulkInsertRefsWithStatement((sql) => this.stmt(sql), _IndexStore.MAX_SQL_VARS, refs);
6173
6300
  });
6174
6301
  }
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
6302
  commitBatch(entries, options = {}) {
6194
- if (entries.length === 0 && (options.deleteForFiles?.length ?? 0) === 0) {
6195
- return [];
6196
- }
6197
6303
  this.invalidateBm25();
6198
6304
  return this.runWithRetry(() => {
6199
6305
  const ownsTransaction = this.beginWriteTransaction();
6200
6306
  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(
6307
+ const result = commitBatchWithStatement(
6272
6308
  (sql) => this.stmt(sql),
6273
6309
  _IndexStore.MAX_SQL_VARS,
6274
6310
  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`
6311
+ this.vectorsAvailable,
6312
+ this.allocateSymbolIds.bind(this),
6313
+ this.invalidateIncomingRefsForFiles.bind(this),
6314
+ this.resolveRefsForNamesUnsafe.bind(this),
6315
+ entries,
6316
+ options
6294
6317
  );
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
6318
  this.commitWriteTransaction(ownsTransaction);
6308
- return allInserted;
6319
+ return result;
6309
6320
  } catch (err) {
6310
6321
  this.rollbackWriteTransaction(ownsTransaction);
6311
6322
  throw err;
6312
6323
  }
6313
6324
  });
6314
6325
  }
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
6326
  deleteRefsForFile(file) {
6320
6327
  this.runWithRetry(() => {
6321
6328
  this.stmt("DELETE FROM refs WHERE from_id IN (SELECT id FROM symbols WHERE file = ?)").run(
@@ -6323,64 +6330,12 @@ var IndexStore = class _IndexStore {
6323
6330
  );
6324
6331
  });
6325
6332
  }
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
6333
  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
- });
6334
+ return this.runWithRetry(() => resolveRefsWithStatement((sql) => this.stmt(sql)));
6376
6335
  }
6377
6336
  resolveRefsForNames(names) {
6378
6337
  return this.runWithRetry(() => this.resolveRefsForNamesUnsafe(names));
6379
6338
  }
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
6339
  replaceEmptyFile(meta) {
6385
6340
  this.invalidateBm25();
6386
6341
  this.runWithRetry(() => {
@@ -6426,20 +6381,12 @@ var IndexStore = class _IndexStore {
6426
6381
  }
6427
6382
  });
6428
6383
  }
6429
- /** Best-effort query planner refresh after a large reindex. */
6430
6384
  optimize() {
6431
6385
  try {
6432
6386
  this.db.exec("PRAGMA optimize");
6433
6387
  } catch {
6434
6388
  }
6435
6389
  }
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
6390
  compactIfNeeded(options = {}) {
6444
6391
  const minBytes = options.minBytes ?? 256 * 1024 * 1024;
6445
6392
  const minFreeRatio = options.minFreeRatio ?? 0.35;
@@ -6466,115 +6413,44 @@ var IndexStore = class _IndexStore {
6466
6413
  return false;
6467
6414
  }
6468
6415
  }
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
6416
  findIncomingCallsByName(symbolName, file, limit = 100) {
6474
6417
  return findIncomingCallsByName((sql) => this.stmt(sql), symbolName, file, limit);
6475
6418
  }
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
6419
  findOutgoingCallsByName(symbolName, file, limit = 100) {
6481
6420
  return findOutgoingCallsByName((sql) => this.stmt(sql), symbolName, file, limit);
6482
6421
  }
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
6422
  findTransitiveIncomingCallsByName(symbolName, file, limit = 200) {
6490
6423
  return findTransitiveIncomingCallsByName((sql) => this.stmt(sql), symbolName, file, limit);
6491
6424
  }
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
6425
  findTransitiveOutgoingCallsByName(symbolName, file, limit = 200) {
6498
6426
  return findTransitiveOutgoingCallsByName((sql) => this.stmt(sql), symbolName, file, limit);
6499
6427
  }
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
6428
  findReachableSymbolIds(seedIds) {
6506
6429
  return findReachableSymbolIds((sql) => this.stmt(sql), seedIds);
6507
6430
  }
6508
- /**
6509
- * Find all references TO a given symbol (who calls / uses this symbol?).
6510
- */
6511
6431
  findRefsTo(symbolId) {
6512
6432
  return findRefsToWithStatement((sql) => this.stmt(sql), symbolId);
6513
6433
  }
6514
- /**
6515
- * Find all references FROM a given symbol (what does this symbol call/use?).
6516
- */
6517
6434
  findRefsFrom(symbolId) {
6518
6435
  return findRefsFromWithStatement((sql) => this.stmt(sql), symbolId);
6519
6436
  }
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
6437
  getPackageGraph() {
6527
6438
  return getPackageGraphWithStatement((sql) => this.stmt(sql));
6528
6439
  }
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
6440
  getFileGraph(packageFilter) {
6534
6441
  return getFileGraphWithStatement((sql) => this.stmt(sql), packageFilter);
6535
6442
  }
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
6443
  getSymbolGraph(fileFilter) {
6541
6444
  return getSymbolGraphWithStatement((sql) => this.stmt(sql), fileFilter);
6542
6445
  }
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
6446
  getAllSymbols() {
6548
6447
  return this.stmt("SELECT id, name, file, kind, line FROM symbols ORDER BY id").all().map((r) => ({ ...r, kind: r.kind }));
6549
6448
  }
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
6449
  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();
6450
+ return getAllResolvedRefsWithStatement((sql) => this.stmt(sql));
6559
6451
  }
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
6452
  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();
6453
+ return getAllImportRefsWithStatement((sql) => this.stmt(sql));
6578
6454
  }
6579
6455
  close() {
6580
6456
  this.stmtCache.clear();
@@ -7448,7 +7324,9 @@ var stopMemoryWatchdog = startSharedHeapWatchdog({
7448
7324
  var lastProgressBroadcastAt = 0;
7449
7325
  var externalWatcher;
7450
7326
  var DEFAULT_EXTERNAL_DEBOUNCE_MS = 400;
7327
+ var DEFAULT_EXTERNAL_COALESCE_WINDOW_MS = 50;
7451
7328
  var externalDebounceMs = DEFAULT_EXTERNAL_DEBOUNCE_MS;
7329
+ var externalCoalesceWindowMs = DEFAULT_EXTERNAL_COALESCE_WINDOW_MS;
7452
7330
  var externalDebounceTimers = /* @__PURE__ */ new Map();
7453
7331
  var externalReadyFiles = /* @__PURE__ */ new Set();
7454
7332
  var externalReadyFlush;
@@ -7720,9 +7598,14 @@ async function handleMessage(state, message) {
7720
7598
  if (message.type === "configure") {
7721
7599
  const previousWatchExternal = state.watchExternal;
7722
7600
  const previousDebounceMs = state.debounceMs;
7601
+ const previousCoalesceWindowMs = state.coalesceWindowMs;
7723
7602
  try {
7724
7603
  state.watchExternal = message.watchExternal;
7725
7604
  state.debounceMs = Math.max(0, message.debounceMs);
7605
+ state.coalesceWindowMs = Math.max(
7606
+ 0,
7607
+ message.coalesceWindowMs ?? DEFAULT_EXTERNAL_COALESCE_WINDOW_MS
7608
+ );
7726
7609
  reconcileExternalWatcher();
7727
7610
  send(state, {
7728
7611
  type: "response",
@@ -7736,6 +7619,7 @@ async function handleMessage(state, message) {
7736
7619
  } catch (error) {
7737
7620
  state.watchExternal = previousWatchExternal;
7738
7621
  state.debounceMs = previousDebounceMs;
7622
+ state.coalesceWindowMs = previousCoalesceWindowMs;
7739
7623
  try {
7740
7624
  reconcileExternalWatcher();
7741
7625
  } catch {
@@ -7791,25 +7675,24 @@ function enqueueExternalFile(file) {
7791
7675
  const timer = setTimeout(() => {
7792
7676
  externalDebounceTimers.delete(file);
7793
7677
  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
- });
7678
+ if (externalReadyFlush) clearTimeout(externalReadyFlush);
7679
+ externalReadyFlush = setTimeout(() => {
7680
+ externalReadyFlush = void 0;
7681
+ const files = [...externalReadyFiles].sort();
7682
+ externalReadyFiles.clear();
7683
+ void withIndexWrite(
7684
+ (onProgress) => indexService(
7685
+ {
7686
+ projectRoot,
7687
+ indexDir,
7688
+ files
7689
+ },
7690
+ { onProgress }
7691
+ )
7692
+ ).catch(() => {
7810
7693
  });
7811
- externalReadyFlush.unref?.();
7812
- }
7694
+ }, externalCoalesceWindowMs);
7695
+ externalReadyFlush.unref?.();
7813
7696
  }, externalDebounceMs);
7814
7697
  timer.unref?.();
7815
7698
  externalDebounceTimers.set(file, timer);
@@ -7841,7 +7724,7 @@ function stopExternalWatcher() {
7841
7724
  externalWatcher = void 0;
7842
7725
  for (const timer of externalDebounceTimers.values()) clearTimeout(timer);
7843
7726
  externalDebounceTimers.clear();
7844
- if (externalReadyFlush) clearImmediate(externalReadyFlush);
7727
+ if (externalReadyFlush) clearTimeout(externalReadyFlush);
7845
7728
  externalReadyFlush = void 0;
7846
7729
  externalReadyFiles.clear();
7847
7730
  }
@@ -7849,10 +7732,12 @@ function reconcileExternalWatcher() {
7849
7732
  const owners = [...clients].filter((client) => client.watchExternal);
7850
7733
  if (owners.length === 0) {
7851
7734
  externalDebounceMs = DEFAULT_EXTERNAL_DEBOUNCE_MS;
7735
+ externalCoalesceWindowMs = DEFAULT_EXTERNAL_COALESCE_WINDOW_MS;
7852
7736
  stopExternalWatcher();
7853
7737
  return;
7854
7738
  }
7855
7739
  externalDebounceMs = Math.min(...owners.map((client) => client.debounceMs));
7740
+ externalCoalesceWindowMs = Math.min(...owners.map((client) => client.coalesceWindowMs));
7856
7741
  ensureExternalWatcher();
7857
7742
  }
7858
7743
  function consume(state, chunk) {
@@ -7919,6 +7804,7 @@ var server = net.createServer((socket) => {
7919
7804
  cancel: /* @__PURE__ */ new Map(),
7920
7805
  watchExternal: false,
7921
7806
  debounceMs: DEFAULT_EXTERNAL_DEBOUNCE_MS,
7807
+ coalesceWindowMs: DEFAULT_EXTERNAL_COALESCE_WINDOW_MS,
7922
7808
  lastSeenAt: Date.now()
7923
7809
  };
7924
7810
  clients.add(state);