@wrongstack/tools 0.306.3 → 0.307.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/dist/audit.js +50 -9
  2. package/dist/auto-proceed-loop-guard.js +8 -2
  3. package/dist/bash.js +57 -22
  4. package/dist/builtin.d.ts +6 -6
  5. package/dist/builtin.js +11315 -9317
  6. package/dist/clarify.d.ts +32 -0
  7. package/dist/codebase-index/ast-invariant-engine.d.ts +102 -0
  8. package/dist/codebase-index/ast-symbol-mutator.d.ts +28 -0
  9. package/dist/codebase-index/background-indexer.d.ts +9 -0
  10. package/dist/codebase-index/codebase-ast-replace-tool.d.ts +31 -0
  11. package/dist/codebase-index/codebase-impact-analysis-tool.d.ts +40 -0
  12. package/dist/codebase-index/codebase-invariant-check-tool.d.ts +23 -0
  13. package/dist/codebase-index/codebase-repo-map-tool.d.ts +22 -0
  14. package/dist/codebase-index/codebase-skeleton-tool.d.ts +36 -0
  15. package/dist/codebase-index/codebase-targeted-test-tool.d.ts +30 -0
  16. package/dist/codebase-index/index.d.ts +11 -1
  17. package/dist/codebase-index/index.js +4141 -1479
  18. package/dist/codebase-index/project-server-client-state.d.ts +79 -0
  19. package/dist/codebase-index/project-server-client.d.ts +3 -71
  20. package/dist/codebase-index/project-server-protocol.d.ts +1 -0
  21. package/dist/codebase-index/project-server.js +751 -909
  22. package/dist/codebase-index/repo-map.d.ts +20 -0
  23. package/dist/codebase-index/skeleton-extractor.d.ts +64 -0
  24. package/dist/codebase-index/tree-sitter-parser.d.ts +11 -0
  25. package/dist/codebase-index/worker.js +723 -891
  26. package/dist/codebase-index/writer-mutations.d.ts +26 -0
  27. package/dist/codebase-index/writer-refs.d.ts +50 -0
  28. package/dist/codebase-index/writer-search.d.ts +30 -0
  29. package/dist/codebase-index/writer.d.ts +2 -320
  30. package/dist/diff.js +3 -2
  31. package/dist/document.js +37 -2
  32. package/dist/e2e.js +3 -2
  33. package/dist/edit.js +8558 -348
  34. package/dist/exec.js +76 -22
  35. package/dist/fetch.js +11 -9
  36. package/dist/format.js +50 -9
  37. package/dist/glob.js +8 -3
  38. package/dist/grep.js +22 -10
  39. package/dist/index.d.ts +3 -1
  40. package/dist/index.js +11411 -9440
  41. package/dist/install.js +50 -9
  42. package/dist/json.js +91 -17
  43. package/dist/kanban-board-actions.d.ts +4 -0
  44. package/dist/kanban-lifecycle-actions.d.ts +4 -0
  45. package/dist/kanban-serializer.d.ts +21 -0
  46. package/dist/kanban.js +985 -1034
  47. package/dist/languages/index.js +17 -10
  48. package/dist/lint.js +50 -9
  49. package/dist/logs.js +17 -6
  50. package/dist/next-steps.d.ts +8 -0
  51. package/dist/next-steps.js +18 -0
  52. package/dist/outdated.js +18 -11
  53. package/dist/pack.js +11308 -9317
  54. package/dist/patch.js +8301 -77
  55. package/dist/plan.js +1231 -1281
  56. package/dist/process-registry.js +14 -8
  57. package/dist/ps-slash.js +65 -30
  58. package/dist/read.js +754 -912
  59. package/dist/replace.js +8456 -195
  60. package/dist/scaffold.js +36 -1
  61. package/dist/search.js +21 -15
  62. package/dist/security-ast-scan-tool.d.ts +43 -0
  63. package/dist/session-kanban-graph.d.ts +9 -0
  64. package/dist/session-kanban-sync.d.ts +31 -0
  65. package/dist/session-kanban.d.ts +5 -141
  66. package/dist/session-kanban.js +367 -360
  67. package/dist/task.js +1157 -1207
  68. package/dist/test.js +50 -9
  69. package/dist/todo.js +2187 -2237
  70. package/dist/tool-diff.js +6 -1
  71. package/dist/tool-summary.js +4 -2
  72. package/dist/tool-tier.js +11315 -9317
  73. package/dist/tree.js +36 -1
  74. package/dist/typecheck.js +51 -10
  75. package/dist/write.js +8374 -152
  76. package/package.json +7 -7
package/dist/read.js CHANGED
@@ -562,17 +562,44 @@ function fallbackParse(filePath, content, lang) {
562
562
  const col = line.length - trimmed.length + 1;
563
563
  const fn = /^func\s+(?:\([^)]*\)\s*)?([A-Za-z_]\w*)\s*\(/.exec(trimmed);
564
564
  if (fn?.[1]) {
565
- 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] });
565
+ addFallbackSymbol(symbols, {
566
+ filePath,
567
+ lang,
568
+ kind: trimmed.startsWith("func (") ? "method" : "function",
569
+ name: fn[1],
570
+ line: idx + 1,
571
+ col,
572
+ signature: trimmed,
573
+ scope: packageName ? `${packageName}.${fn[1]}` : fn[1]
574
+ });
566
575
  continue;
567
576
  }
568
577
  const typeDecl = /^type\s+([A-Za-z_]\w*)\b/.exec(trimmed);
569
578
  if (typeDecl?.[1]) {
570
- addFallbackSymbol(symbols, { filePath, lang, kind: "type", name: typeDecl[1], line: idx + 1, col, signature: trimmed, scope: packageName });
579
+ addFallbackSymbol(symbols, {
580
+ filePath,
581
+ lang,
582
+ kind: "type",
583
+ name: typeDecl[1],
584
+ line: idx + 1,
585
+ col,
586
+ signature: trimmed,
587
+ scope: packageName
588
+ });
571
589
  continue;
572
590
  }
573
591
  const valueDecl = /^(const|var)\s+([A-Za-z_]\w*)\b/.exec(trimmed);
574
592
  if (valueDecl?.[1] && valueDecl[2]) {
575
- addFallbackSymbol(symbols, { filePath, lang, kind: valueDecl[1], name: valueDecl[2], line: idx + 1, col, signature: trimmed, scope: packageName });
593
+ addFallbackSymbol(symbols, {
594
+ filePath,
595
+ lang,
596
+ kind: valueDecl[1],
597
+ name: valueDecl[2],
598
+ line: idx + 1,
599
+ col,
600
+ signature: trimmed,
601
+ scope: packageName
602
+ });
576
603
  }
577
604
  }
578
605
  return { file: filePath, lang, symbols, mtimeMs: Date.now() };
@@ -1023,7 +1050,10 @@ function parseGeneric(opts) {
1023
1050
  const seen = /* @__PURE__ */ new Set();
1024
1051
  const nlOffsets = newlineOffsets2(content);
1025
1052
  for (const pattern of patterns) {
1026
- const re = new RegExp(pattern.re.source, pattern.re.flags.includes("g") ? pattern.re.flags : `${pattern.re.flags}g`);
1053
+ const re = new RegExp(
1054
+ pattern.re.source,
1055
+ pattern.re.flags.includes("g") ? pattern.re.flags : `${pattern.re.flags}g`
1056
+ );
1027
1057
  re.lastIndex = 0;
1028
1058
  for (const match of content.matchAll(re)) {
1029
1059
  if (symbols.length >= maxSymbols) break;
@@ -1130,7 +1160,10 @@ var init_generic_parser = __esm({
1130
1160
  ],
1131
1161
  kotlin: [
1132
1162
  { re: /\b(?:fun)\s+([A-Za-z_]\w*)/g, kind: "function" },
1133
- { re: /\b(?:class|interface|object|enum\s+class|data\s+class)\s+([A-Za-z_]\w*)/g, kind: "class" }
1163
+ {
1164
+ re: /\b(?:class|interface|object|enum\s+class|data\s+class)\s+([A-Za-z_]\w*)/g,
1165
+ kind: "class"
1166
+ }
1134
1167
  ],
1135
1168
  scala: [
1136
1169
  { re: /\b(?:def)\s+([A-Za-z_]\w*)/g, kind: "function" },
@@ -1141,14 +1174,13 @@ var init_generic_parser = __esm({
1141
1174
  { re: /^([A-Za-z_][\w]*)\s*\(\)\s*\{/gm, kind: "function" }
1142
1175
  ],
1143
1176
  sql: [
1144
- { 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" }
1145
- ],
1146
- md: [
1147
- { re: /^(#{1,6})\s+(.+)$/gm, kind: "namespace" }
1148
- ],
1149
- toml: [
1150
- { re: /^\[([^\]]+)\]/gm, kind: "namespace" }
1177
+ {
1178
+ 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,
1179
+ kind: "type"
1180
+ }
1151
1181
  ],
1182
+ md: [{ re: /^(#{1,6})\s+(.+)$/gm, kind: "namespace" }],
1183
+ toml: [{ re: /^\[([^\]]+)\]/gm, kind: "namespace" }],
1152
1184
  html: [
1153
1185
  { re: /\bid\s*=\s*["']([^"']+)["']/gi, kind: "property" },
1154
1186
  { re: /<(?:script|template|style)\b/gi, kind: "namespace" }
@@ -1158,11 +1190,17 @@ var init_generic_parser = __esm({
1158
1190
  { re: /@(?:keyframes|media|supports)\s+([^{\s]+)/g, kind: "namespace" }
1159
1191
  ],
1160
1192
  vue: [
1161
- { re: /\b(?:function|const|let|var|class|export\s+(?:default\s+)?(?:function|class|const))\s+([A-Za-z_]\w*)/g, kind: "function" },
1193
+ {
1194
+ re: /\b(?:function|const|let|var|class|export\s+(?:default\s+)?(?:function|class|const))\s+([A-Za-z_]\w*)/g,
1195
+ kind: "function"
1196
+ },
1162
1197
  { re: /<(?:script|template|style)\b/gi, kind: "namespace" }
1163
1198
  ],
1164
1199
  svelte: [
1165
- { re: /\b(?:function|const|let|var|class|export\s+(?:default\s+)?(?:function|class|const))\s+([A-Za-z_]\w*)/g, kind: "function" }
1200
+ {
1201
+ re: /\b(?:function|const|let|var|class|export\s+(?:default\s+)?(?:function|class|const))\s+([A-Za-z_]\w*)/g,
1202
+ kind: "function"
1203
+ }
1166
1204
  ],
1167
1205
  dart: [
1168
1206
  { re: /\b(?:class|enum|mixin|extension)\s+([A-Za-z_]\w*)/g, kind: "class" },
@@ -1376,12 +1414,7 @@ async function syncPyParse(filePath, content, lang) {
1376
1414
  cachedPyBinary ??= resolvePython();
1377
1415
  const pyBinary = await cachedPyBinary;
1378
1416
  if (!pyBinary) return null;
1379
- const { code, stdout } = await spawnPyParser(
1380
- pyBinary,
1381
- _cachedScriptPath,
1382
- filePath,
1383
- content
1384
- );
1417
+ const { code, stdout } = await spawnPyParser(pyBinary, _cachedScriptPath, filePath, content);
1385
1418
  if (code !== 0 || !stdout.trim()) {
1386
1419
  return { file: filePath, lang, symbols: [], mtimeMs: Date.now() };
1387
1420
  }
@@ -2575,7 +2608,8 @@ __export(tree_sitter_parser_exports, {
2575
2608
  getGrammarWasmPath: () => getGrammarWasmPath,
2576
2609
  isTreeSitterSupported: () => isTreeSitterSupported,
2577
2610
  loadTreeSitterLanguage: () => loadTreeSitterLanguage,
2578
- parseSymbols: () => parseSymbols8
2611
+ parseSymbols: () => parseSymbols8,
2612
+ parseTreeSitterAst: () => parseTreeSitterAst
2579
2613
  });
2580
2614
  import * as path10 from "node:path";
2581
2615
  import { fileURLToPath } from "node:url";
@@ -2667,6 +2701,26 @@ async function __smokeRootType(opts) {
2667
2701
  parser.delete();
2668
2702
  }
2669
2703
  }
2704
+ async function parseTreeSitterAst(opts) {
2705
+ const grammar = resolveGrammarName(opts.lang) ?? (opts.lang === "go" ? "go" : opts.lang === "py" ? "python" : opts.lang === "rs" ? "rust" : void 0);
2706
+ if (!grammar) return null;
2707
+ try {
2708
+ const { Parser, Language, init } = await getRuntime();
2709
+ await init();
2710
+ const wasmPath = path10.join(WASM_DIR, grammar, `tree-sitter-${grammar}.wasm`);
2711
+ const languageObj = await Language.load(wasmPath);
2712
+ const parser = new Parser();
2713
+ parser.setLanguage(languageObj);
2714
+ const tree = parser.parse(opts.content);
2715
+ if (!tree) {
2716
+ parser.delete();
2717
+ return null;
2718
+ }
2719
+ return { tree, parser };
2720
+ } catch {
2721
+ return null;
2722
+ }
2723
+ }
2670
2724
  var WASM_DIR, RUNTIME_WASM, LANG_TO_GRAMMAR, GO_OPT_IN, PY_OPT_IN, RS_OPT_IN, runtimePromise, languageCache;
2671
2725
  var init_tree_sitter_parser = __esm({
2672
2726
  "src/codebase-index/tree-sitter-parser.ts"() {
@@ -2699,7 +2753,7 @@ var init_tree_sitter_parser = __esm({
2699
2753
  });
2700
2754
 
2701
2755
  // src/read.ts
2702
- import * as fs13 from "node:fs/promises";
2756
+ import * as fs14 from "node:fs/promises";
2703
2757
  import { FsError, ToolValidationError } from "@wrongstack/core/types";
2704
2758
  import { toErrorMessage as toErrorMessage2 } from "@wrongstack/core/utils";
2705
2759
 
@@ -2753,8 +2807,9 @@ async function resolveRealInsideRoot(absPath, ctx) {
2753
2807
  }
2754
2808
  throw err;
2755
2809
  }
2756
- if (isInsideAny(real, realRoots)) {
2757
- return pendingTail.length > 0 ? path.join(real, ...pendingTail) : real;
2810
+ const candidate = pendingTail.length > 0 ? path.join(real, ...pendingTail) : real;
2811
+ if (isInsideAny(candidate, realRoots)) {
2812
+ return candidate;
2758
2813
  }
2759
2814
  throw new Error(
2760
2815
  `Path "${absPath}" resolves through a symlink outside project root "${realRoots[0]}"`
@@ -2774,8 +2829,8 @@ function isBinaryBuffer(buf) {
2774
2829
  }
2775
2830
 
2776
2831
  // src/codebase-index/background-indexer.ts
2777
- import * as fs12 from "node:fs";
2778
- import { fileURLToPath as fileURLToPath5 } from "node:url";
2832
+ import * as fs13 from "node:fs";
2833
+ import { fileURLToPath as fileURLToPath6 } from "node:url";
2779
2834
  import { Worker as Worker2 } from "node:worker_threads";
2780
2835
 
2781
2836
  // src/codebase-index/circuit-breaker.ts
@@ -3621,10 +3676,7 @@ var LANG_IMPORTS = {
3621
3676
  // quoted path. A stray match elsewhere resolves to no file and is dropped.
3622
3677
  { re: /^[ \t]*(?:[A-Za-z_.]\w*\s+)?"([^"]+)"\s*$/gm }
3623
3678
  ],
3624
- py: [
3625
- { re: /^[ \t]*import\s+([\w.]+)/gm },
3626
- { re: /^[ \t]*from\s+([.\w]+)\s+import\b/gm }
3627
- ],
3679
+ py: [{ re: /^[ \t]*import\s+([\w.]+)/gm }, { re: /^[ \t]*from\s+([.\w]+)\s+import\b/gm }],
3628
3680
  rs: [
3629
3681
  // use a::b::C; | use a::b::{C, D}; → the path before any brace
3630
3682
  { re: /^[ \t]*(?:pub\s+)?use\s+([\w:]+?)(?:::\{|\s*;|\s+as\b)/gm },
@@ -3961,10 +4013,7 @@ function defaultWorkerCount() {
3961
4013
  return Math.max(1, Math.min(4, cores - 1));
3962
4014
  }
3963
4015
  function resolveWorkerScriptUrl() {
3964
- for (const rel of [
3965
- "./parser-worker-script.js",
3966
- "./codebase-index/parser-worker-script.js"
3967
- ]) {
4016
+ for (const rel of ["./parser-worker-script.js", "./codebase-index/parser-worker-script.js"]) {
3968
4017
  try {
3969
4018
  const url = new URL(rel, import.meta.url);
3970
4019
  if (url.protocol === "file:" && fs6.existsSync(fileURLToPath2(url))) return url;
@@ -3980,7 +4029,6 @@ function getParserPool() {
3980
4029
  }
3981
4030
 
3982
4031
  // src/codebase-index/writer.ts
3983
- import { expectDefined as expectDefined4 } from "@wrongstack/core/utils";
3984
4032
  import * as fs8 from "node:fs";
3985
4033
  import * as path12 from "node:path";
3986
4034
 
@@ -4076,39 +4124,6 @@ var Bm25Index = class {
4076
4124
  // src/codebase-index/writer.ts
4077
4125
  init_languages();
4078
4126
 
4079
- // src/codebase-index/lsp-kind.ts
4080
- function lspKindToInternalKind(k) {
4081
- switch (k) {
4082
- case 5 /* Class */:
4083
- return "class";
4084
- case 6 /* Method */:
4085
- return "method";
4086
- case 7 /* Property */:
4087
- case 8 /* Field */:
4088
- return "property";
4089
- case 9 /* Constructor */:
4090
- return "class";
4091
- case 10 /* Enum */:
4092
- return "enum";
4093
- case 11 /* Interface */:
4094
- return "interface";
4095
- case 12 /* Function */:
4096
- return "function";
4097
- case 13 /* Variable */:
4098
- return "var";
4099
- case 14 /* Constant */:
4100
- return "const";
4101
- case 22 /* EnumMember */:
4102
- return "enum";
4103
- case 26 /* TypeParameter */:
4104
- return "type";
4105
- case 3 /* Namespace */:
4106
- return "namespace";
4107
- default:
4108
- return null;
4109
- }
4110
- }
4111
-
4112
4127
  // src/codebase-index/schema.ts
4113
4128
  var SCHEMA_VERSION = 4;
4114
4129
 
@@ -4179,91 +4194,14 @@ function runSqliteWithRetry(fn) {
4179
4194
  throw lastError;
4180
4195
  }
4181
4196
 
4182
- // src/codebase-index/vector-search.ts
4183
- var RRF_K = 60;
4184
- var VECTOR_DIMENSIONS = 384;
4185
- var NGRAM_SIZE = 3;
4186
- function embedText(text) {
4187
- const vec = new Float32Array(VECTOR_DIMENSIONS);
4188
- const normalized = text.toLowerCase().trim();
4189
- if (normalized.length < NGRAM_SIZE) {
4190
- const padded = ` ${normalized} `.slice(0, Math.max(NGRAM_SIZE, normalized.length + 2));
4191
- for (let i = 0; i <= padded.length - NGRAM_SIZE; i++) {
4192
- const ngram = padded.slice(i, i + NGRAM_SIZE);
4193
- const bucket = hashNgram(ngram) % VECTOR_DIMENSIONS;
4194
- vec[bucket] += 1;
4195
- }
4196
- } else {
4197
- for (let i = 0; i <= normalized.length - NGRAM_SIZE; i++) {
4198
- const ngram = normalized.slice(i, i + NGRAM_SIZE);
4199
- const bucket = hashNgram(ngram) % VECTOR_DIMENSIONS;
4200
- vec[bucket] += 1;
4201
- }
4202
- }
4203
- let norm = 0;
4204
- for (let i = 0; i < VECTOR_DIMENSIONS; i++) {
4205
- norm += vec[i] * vec[i];
4206
- }
4207
- norm = Math.sqrt(norm);
4208
- if (norm > 0) {
4209
- for (let i = 0; i < VECTOR_DIMENSIONS; i++) {
4210
- vec[i] /= norm;
4211
- }
4212
- }
4213
- return vec;
4214
- }
4215
- function hashNgram(str) {
4216
- let hash = 2166136261;
4217
- for (let i = 0; i < str.length; i++) {
4218
- hash ^= str.charCodeAt(i);
4219
- hash = Math.imul(hash, 16777619);
4220
- }
4221
- return hash >>> 0;
4222
- }
4223
- function cosineSimilarity(a, b) {
4224
- let dot = 0;
4225
- const len = Math.min(a.length, b.length);
4226
- for (let i = 0; i < len; i++) {
4227
- dot += a[i] * b[i];
4228
- }
4229
- return dot;
4230
- }
4231
- function encodeVector(vec) {
4232
- return Buffer.from(vec.buffer, vec.byteOffset, vec.byteLength);
4233
- }
4234
- function decodeVector(buf) {
4235
- const view = new DataView(
4236
- buf.buffer,
4237
- buf.byteOffset,
4238
- buf.byteLength
4239
- );
4240
- const copy = new Float32Array(buf.byteLength / 4);
4241
- for (let i = 0; i < copy.length; i++) {
4242
- copy[i] = view.getFloat32(i * 4, true);
4243
- }
4244
- return copy;
4245
- }
4246
- function reciprocalRankFusion(bm25Ranks, vectorRanks, k = RRF_K) {
4247
- const allIds = /* @__PURE__ */ new Set([...bm25Ranks.keys(), ...vectorRanks.keys()]);
4248
- const scored = [];
4249
- for (const id of allIds) {
4250
- const bm25Rank = bm25Ranks.get(id);
4251
- const vecRank = vectorRanks.get(id);
4252
- let score = 0;
4253
- if (bm25Rank !== void 0) score += 1 / (k + bm25Rank);
4254
- if (vecRank !== void 0) score += 1 / (k + vecRank);
4255
- scored.push([id, score]);
4256
- }
4257
- scored.sort((a, b) => b[1] - a[1]);
4258
- return scored;
4259
- }
4260
-
4261
4197
  // src/codebase-index/writer-admin.ts
4262
4198
  import * as fs7 from "node:fs";
4263
4199
  import * as path11 from "node:path";
4264
4200
  var DB_FILE = "index.db";
4265
4201
  function getAllIndexableWithStatement(stmt) {
4266
- return stmt("SELECT id, text FROM symbols").all().map(({ id, text }) => ({ id, text }));
4202
+ return stmt("SELECT id, text FROM symbols").all().map(
4203
+ ({ id, text }) => ({ id, text })
4204
+ );
4267
4205
  }
4268
4206
  function getMaxSymbolIdWithStatement(stmt) {
4269
4207
  const rows = stmt("SELECT MAX(id) AS m FROM symbols").all();
@@ -4603,7 +4541,8 @@ function resolveSymbolIds(stmt, symbolName, file) {
4603
4541
  }
4604
4542
  function findIncomingCallsByName(stmt, symbolName, file, limit) {
4605
4543
  const targetIds = resolveSymbolIds(stmt, symbolName, file);
4606
- if (targetIds.length === 0) return { calls: [], symbolFound: false, ambiguous: false, totalMatches: 0 };
4544
+ if (targetIds.length === 0)
4545
+ return { calls: [], symbolFound: false, ambiguous: false, totalMatches: 0 };
4607
4546
  let matchIds = targetIds;
4608
4547
  let ambiguous = false;
4609
4548
  if (file !== void 0) {
@@ -4654,11 +4593,17 @@ function findIncomingCallsByName(stmt, symbolName, file, limit) {
4654
4593
  }
4655
4594
  rows.sort((a, b) => a.ref_line - b.ref_line || a.sym_id - b.sym_id);
4656
4595
  const allCalls = rows.map(mapCallSiteRow);
4657
- return { calls: allCalls.slice(0, limit), symbolFound: true, ambiguous, totalMatches: allCalls.length };
4596
+ return {
4597
+ calls: allCalls.slice(0, limit),
4598
+ symbolFound: true,
4599
+ ambiguous,
4600
+ totalMatches: allCalls.length
4601
+ };
4658
4602
  }
4659
4603
  function findOutgoingCallsByName(stmt, symbolName, file, limit) {
4660
4604
  const sourceIds = resolveSymbolIds(stmt, symbolName, file);
4661
- if (sourceIds.length === 0) return { calls: [], symbolFound: false, unresolvedCount: 0, totalMatches: 0 };
4605
+ if (sourceIds.length === 0)
4606
+ return { calls: [], symbolFound: false, unresolvedCount: 0, totalMatches: 0 };
4662
4607
  const unresolvedCount = chunkedIdScalar(
4663
4608
  stmt,
4664
4609
  sourceIds,
@@ -5066,6 +5011,184 @@ function codebaseIndexDirOverride(ctx) {
5066
5011
  return typeof v === "string" ? v : void 0;
5067
5012
  }
5068
5013
 
5014
+ // src/codebase-index/vector-search.ts
5015
+ var RRF_K = 60;
5016
+ var VECTOR_DIMENSIONS = 384;
5017
+ var NGRAM_SIZE = 3;
5018
+ function embedText(text) {
5019
+ const vec = new Float32Array(VECTOR_DIMENSIONS);
5020
+ const normalized = text.toLowerCase().trim();
5021
+ if (normalized.length < NGRAM_SIZE) {
5022
+ const padded = ` ${normalized} `.slice(0, Math.max(NGRAM_SIZE, normalized.length + 2));
5023
+ for (let i = 0; i <= padded.length - NGRAM_SIZE; i++) {
5024
+ const ngram = padded.slice(i, i + NGRAM_SIZE);
5025
+ const bucket = hashNgram(ngram) % VECTOR_DIMENSIONS;
5026
+ vec[bucket] += 1;
5027
+ }
5028
+ } else {
5029
+ for (let i = 0; i <= normalized.length - NGRAM_SIZE; i++) {
5030
+ const ngram = normalized.slice(i, i + NGRAM_SIZE);
5031
+ const bucket = hashNgram(ngram) % VECTOR_DIMENSIONS;
5032
+ vec[bucket] += 1;
5033
+ }
5034
+ }
5035
+ let norm = 0;
5036
+ for (let i = 0; i < VECTOR_DIMENSIONS; i++) {
5037
+ norm += vec[i] * vec[i];
5038
+ }
5039
+ norm = Math.sqrt(norm);
5040
+ if (norm > 0) {
5041
+ for (let i = 0; i < VECTOR_DIMENSIONS; i++) {
5042
+ vec[i] /= norm;
5043
+ }
5044
+ }
5045
+ return vec;
5046
+ }
5047
+ function hashNgram(str) {
5048
+ let hash = 2166136261;
5049
+ for (let i = 0; i < str.length; i++) {
5050
+ hash ^= str.charCodeAt(i);
5051
+ hash = Math.imul(hash, 16777619);
5052
+ }
5053
+ return hash >>> 0;
5054
+ }
5055
+ function cosineSimilarity(a, b) {
5056
+ let dot = 0;
5057
+ const len = Math.min(a.length, b.length);
5058
+ for (let i = 0; i < len; i++) {
5059
+ dot += a[i] * b[i];
5060
+ }
5061
+ return dot;
5062
+ }
5063
+ function encodeVector(vec) {
5064
+ return Buffer.from(vec.buffer, vec.byteOffset, vec.byteLength);
5065
+ }
5066
+ function decodeVector(buf) {
5067
+ const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
5068
+ const copy = new Float32Array(buf.byteLength / 4);
5069
+ for (let i = 0; i < copy.length; i++) {
5070
+ copy[i] = view.getFloat32(i * 4, true);
5071
+ }
5072
+ return copy;
5073
+ }
5074
+ function reciprocalRankFusion(bm25Ranks, vectorRanks, k = RRF_K) {
5075
+ const allIds = /* @__PURE__ */ new Set([...bm25Ranks.keys(), ...vectorRanks.keys()]);
5076
+ const scored = [];
5077
+ for (const id of allIds) {
5078
+ const bm25Rank = bm25Ranks.get(id);
5079
+ const vecRank = vectorRanks.get(id);
5080
+ let score = 0;
5081
+ if (bm25Rank !== void 0) score += 1 / (k + bm25Rank);
5082
+ if (vecRank !== void 0) score += 1 / (k + vecRank);
5083
+ scored.push([id, score]);
5084
+ }
5085
+ scored.sort((a, b) => b[1] - a[1]);
5086
+ return scored;
5087
+ }
5088
+
5089
+ // src/codebase-index/writer-mutations.ts
5090
+ function commitBatchWithStatement(stmtFn, maxSqlVars, ftsAvailable, vectorsAvailable, allocateSymbolIds, invalidateIncomingRefsForFiles, resolveRefsForNamesUnsafe2, entries, options = {}) {
5091
+ if (entries.length === 0 && (options.deleteForFiles?.length ?? 0) === 0) {
5092
+ return [];
5093
+ }
5094
+ const affectedNames = /* @__PURE__ */ new Set();
5095
+ for (const entry of entries) {
5096
+ for (const symbol of entry.symbols) affectedNames.add(symbol.name);
5097
+ for (const ref of entry.refs) affectedNames.add(ref.toName);
5098
+ }
5099
+ if (options.deleteForFiles && options.deleteForFiles.length > 0) {
5100
+ const placeholders = options.deleteForFiles.map(() => "?").join(",");
5101
+ for (const name of invalidateIncomingRefsForFiles(options.deleteForFiles)) {
5102
+ affectedNames.add(name);
5103
+ }
5104
+ if (ftsAvailable) {
5105
+ stmtFn(
5106
+ `DELETE FROM symbols_fts WHERE rowid IN (SELECT id FROM symbols WHERE file IN (${placeholders}))`
5107
+ ).run(...options.deleteForFiles);
5108
+ }
5109
+ if (vectorsAvailable) {
5110
+ stmtFn(
5111
+ `DELETE FROM symbol_vectors WHERE symbol_id IN (SELECT id FROM symbols WHERE file IN (${placeholders}))`
5112
+ ).run(...options.deleteForFiles);
5113
+ }
5114
+ stmtFn(
5115
+ `DELETE FROM refs WHERE from_id IN (SELECT id FROM symbols WHERE file IN (${placeholders}))`
5116
+ ).run(...options.deleteForFiles);
5117
+ stmtFn(`DELETE FROM symbols WHERE file IN (${placeholders})`).run(...options.deleteForFiles);
5118
+ }
5119
+ const totalSymbols = entries.reduce((n, e) => n + e.symbols.length, 0);
5120
+ let nextId = allocateSymbolIds(totalSymbols);
5121
+ const allInserted = [];
5122
+ const refsToInsert = [];
5123
+ const bulkSyms = [];
5124
+ const ftsRows = [];
5125
+ const vectorRows = [];
5126
+ for (const entry of entries) {
5127
+ const insertedForEntry = [];
5128
+ for (const s of entry.symbols) {
5129
+ const id = nextId++;
5130
+ bulkSyms.push({
5131
+ id,
5132
+ lang: s.lang,
5133
+ kind: s.kind,
5134
+ name: s.name,
5135
+ file: s.file,
5136
+ line: s.line,
5137
+ col: s.col,
5138
+ signature: s.signature,
5139
+ docComment: s.docComment,
5140
+ scope: s.scope,
5141
+ text: s.text
5142
+ });
5143
+ if (ftsAvailable) {
5144
+ ftsRows.push({
5145
+ id,
5146
+ text: buildIndexableText(s.name, s.signature, s.docComment)
5147
+ });
5148
+ }
5149
+ vectorRows.push({
5150
+ id,
5151
+ vector: encodeVector(
5152
+ embedText(s.text || buildIndexableText(s.name, s.signature, s.docComment))
5153
+ )
5154
+ });
5155
+ const inserted = { ...s, id };
5156
+ allInserted.push(inserted);
5157
+ insertedForEntry.push(inserted);
5158
+ }
5159
+ refsToInsert.push(...assignRefsToSymbols(entry.refs, insertedForEntry));
5160
+ }
5161
+ bulkInsertSymbolsWithStatement((sql) => stmtFn(sql), maxSqlVars, bulkSyms);
5162
+ bulkInsertFtsWithStatement((sql) => stmtFn(sql), maxSqlVars, ftsAvailable, ftsRows);
5163
+ if (vectorsAvailable) {
5164
+ bulkInsertVectorsWithStatement((sql) => stmtFn(sql), maxSqlVars, vectorRows);
5165
+ }
5166
+ bulkInsertRefsWithStatement((sql) => stmtFn(sql), maxSqlVars, refsToInsert);
5167
+ const upsertStmt = stmtFn(
5168
+ `INSERT INTO files(file, lang, mtime_ms, content_hash, symbol_count, last_indexed)
5169
+ VALUES (?, ?, ?, ?, ?, ?)
5170
+ ON CONFLICT(file) DO UPDATE SET
5171
+ lang = excluded.lang,
5172
+ mtime_ms = excluded.mtime_ms,
5173
+ content_hash = excluded.content_hash,
5174
+ symbol_count = excluded.symbol_count,
5175
+ last_indexed = excluded.last_indexed`
5176
+ );
5177
+ const now = Date.now();
5178
+ for (const entry of entries) {
5179
+ upsertStmt.run(
5180
+ entry.file,
5181
+ entry.lang,
5182
+ entry.mtimeMs,
5183
+ entry.contentHash ?? "",
5184
+ entry.symbolCount,
5185
+ now
5186
+ );
5187
+ }
5188
+ resolveRefsForNamesUnsafe2(affectedNames);
5189
+ return allInserted;
5190
+ }
5191
+
5069
5192
  // src/codebase-index/writer-pragmas.ts
5070
5193
  import { sqliteCachePragmas } from "@wrongstack/core/utils";
5071
5194
  function applyIndexStorePragmas(db) {
@@ -5178,22 +5301,253 @@ var SYMBOL_VECTORS_TABLE_SQL = `
5178
5301
  );
5179
5302
  `;
5180
5303
 
5181
- // src/codebase-index/writer-search-helpers.ts
5182
- var SEARCH_CANDIDATE_SCAN_CAP = 5e3;
5183
- function normalizeSearchLimit(limit) {
5184
- return typeof limit === "number" && Number.isFinite(limit) ? Math.max(0, Math.trunc(limit)) : void 0;
5304
+ // src/codebase-index/writer-refs.ts
5305
+ var FAMILY_MATCH_SQL = `(
5306
+ sym.lang = refs.lang
5307
+ OR EXISTS (
5308
+ SELECT 1 FROM lang_family lf1
5309
+ JOIN lang_family lf2 ON lf1.family = lf2.family
5310
+ WHERE lf1.lang = sym.lang AND lf2.lang = refs.lang
5311
+ )
5312
+ OR ? IN (
5313
+ SELECT family FROM lang_family WHERE lang = refs.lang
5314
+ )
5315
+ )`;
5316
+ function getNamespaceDeclarationsWithStatement(stmtFn) {
5317
+ return stmtFn(
5318
+ `SELECT name, file FROM symbols WHERE kind = 'namespace' ORDER BY file, id`
5319
+ ).all();
5185
5320
  }
5186
- function buildWriterSearchWhere(query, filter) {
5187
- const conditions = [];
5188
- const values = [];
5189
- let effectiveKind = filter?.kind;
5190
- if (filter?.lspKind !== void 0) {
5191
- const mapped = lspKindToInternalKind(filter.lspKind);
5192
- if (mapped !== null) {
5193
- effectiveKind = mapped;
5194
- } else {
5195
- return null;
5196
- }
5321
+ function getFilePackagesWithStatement(stmtFn) {
5322
+ const rows = stmtFn("SELECT file, package FROM files WHERE package != ''").all();
5323
+ return new Map(rows.map((row) => [row.file, row.package]));
5324
+ }
5325
+ function getUnresolvedImportsWithStatement(stmtFn, maxSqlVars, onlyFiles) {
5326
+ const base = `SELECT DISTINCT s.file AS fromFile, r.lang AS lang, r.module AS module
5327
+ FROM refs r
5328
+ JOIN symbols s ON s.id = r.from_id
5329
+ WHERE r.call_type = 'import' AND r.module IS NOT NULL`;
5330
+ if (!onlyFiles?.length) {
5331
+ return stmtFn(base).all();
5332
+ }
5333
+ const out = [];
5334
+ for (let i = 0; i < onlyFiles.length; i += maxSqlVars) {
5335
+ const chunk = onlyFiles.slice(i, i + maxSqlVars);
5336
+ const placeholders = chunk.map(() => "?").join(",");
5337
+ out.push(
5338
+ ...stmtFn(`${base} AND s.file IN (${placeholders})`).all(...chunk)
5339
+ );
5340
+ }
5341
+ return out;
5342
+ }
5343
+ function getAllResolvedRefsWithStatement(stmtFn) {
5344
+ return stmtFn(
5345
+ "SELECT from_id AS fromId, to_id AS toId, call_type AS callType FROM refs WHERE to_id IS NOT NULL"
5346
+ ).all();
5347
+ }
5348
+ function getAllImportRefsWithStatement(stmtFn) {
5349
+ return stmtFn(
5350
+ `SELECT s.file AS sourceFile, r.to_name AS toName, r.to_id AS toId,
5351
+ r.call_type AS callType, r.line
5352
+ FROM refs r
5353
+ LEFT JOIN symbols s ON r.from_id = s.id
5354
+ WHERE r.call_type = 'import'
5355
+ ORDER BY r.line`
5356
+ ).all();
5357
+ }
5358
+ function resolveRefsWithStatement(stmtFn) {
5359
+ try {
5360
+ const result = stmtFn(
5361
+ `UPDATE refs
5362
+ SET to_id = s.id
5363
+ FROM (
5364
+ SELECT sym.name AS name, lf.family AS family, MIN(sym.id) AS id
5365
+ FROM symbols sym
5366
+ JOIN lang_family lf ON lf.lang = sym.lang
5367
+ GROUP BY sym.name, lf.family
5368
+ UNION ALL
5369
+ SELECT sym.name AS name, '${LANG_FAMILY_WILDCARD}' AS family, MIN(sym.id) AS id
5370
+ FROM symbols sym
5371
+ GROUP BY sym.name
5372
+ ) AS s,
5373
+ lang_family AS rf
5374
+ WHERE refs.to_id IS NULL
5375
+ AND refs.to_name IS NOT NULL
5376
+ AND rf.lang = refs.lang
5377
+ AND s.name = refs.to_name
5378
+ AND s.family = rf.family`
5379
+ ).run();
5380
+ return result.changes ?? 0;
5381
+ } catch {
5382
+ const result = stmtFn(
5383
+ `UPDATE refs SET to_id = (
5384
+ SELECT sym.id FROM symbols sym
5385
+ WHERE sym.name = refs.to_name AND ${FAMILY_MATCH_SQL}
5386
+ ORDER BY sym.id LIMIT 1
5387
+ ) WHERE to_id IS NULL AND to_name IS NOT NULL
5388
+ AND EXISTS (
5389
+ SELECT 1 FROM symbols sym
5390
+ WHERE sym.name = refs.to_name AND ${FAMILY_MATCH_SQL}
5391
+ )`
5392
+ ).run(LANG_FAMILY_WILDCARD, LANG_FAMILY_WILDCARD);
5393
+ return result.changes ?? 0;
5394
+ }
5395
+ }
5396
+ function applyImportResolutionsWithStatement(db, stmtFn, runWithRetry, maxSqlVars, resolutions) {
5397
+ if (resolutions.length === 0) return 0;
5398
+ return runWithRetry(() => {
5399
+ db.exec("DROP TABLE IF EXISTS temp.import_resolution");
5400
+ db.exec(
5401
+ `CREATE TEMP TABLE import_resolution (
5402
+ from_file TEXT NOT NULL,
5403
+ lang TEXT NOT NULL,
5404
+ module TEXT NOT NULL,
5405
+ to_file TEXT NOT NULL
5406
+ )`
5407
+ );
5408
+ const chunkSize = Math.max(1, Math.floor(maxSqlVars / 4));
5409
+ for (let i = 0; i < resolutions.length; i += chunkSize) {
5410
+ const chunk = resolutions.slice(i, i + chunkSize);
5411
+ const placeholders = chunk.map(() => "(?, ?, ?, ?)").join(", ");
5412
+ const binds = [];
5413
+ for (const entry of chunk) {
5414
+ binds.push(entry.fromFile, entry.lang, entry.module, entry.toFile);
5415
+ }
5416
+ stmtFn(
5417
+ `INSERT INTO temp.import_resolution(from_file, lang, module, to_file)
5418
+ VALUES ${placeholders}`
5419
+ ).run(...binds);
5420
+ }
5421
+ db.exec(
5422
+ `CREATE INDEX IF NOT EXISTS temp.idx_ir
5423
+ ON import_resolution(module, lang, from_file)`
5424
+ );
5425
+ const result = stmtFn(
5426
+ `UPDATE refs
5427
+ SET to_file = (
5428
+ SELECT ir.to_file
5429
+ FROM temp.import_resolution ir
5430
+ JOIN symbols s ON s.id = refs.from_id
5431
+ WHERE ir.module = refs.module
5432
+ AND ir.lang = refs.lang
5433
+ AND ir.from_file = s.file
5434
+ LIMIT 1
5435
+ )
5436
+ WHERE refs.call_type = 'import'
5437
+ AND refs.module IS NOT NULL
5438
+ AND EXISTS (
5439
+ SELECT 1
5440
+ FROM temp.import_resolution ir
5441
+ JOIN symbols s ON s.id = refs.from_id
5442
+ WHERE ir.module = refs.module
5443
+ AND ir.lang = refs.lang
5444
+ AND ir.from_file = s.file
5445
+ )`
5446
+ ).run();
5447
+ db.exec("DROP TABLE IF EXISTS temp.import_resolution");
5448
+ return result.changes ?? 0;
5449
+ });
5450
+ }
5451
+ function resolveRefsForNamesUnsafe(stmtFn, maxSqlVars, names) {
5452
+ const list = [...names].filter((name) => name.length > 0);
5453
+ if (list.length === 0) return 0;
5454
+ let total = 0;
5455
+ for (let i = 0; i < list.length; i += maxSqlVars) {
5456
+ const chunk = list.slice(i, i + maxSqlVars);
5457
+ const placeholders = chunk.map(() => "?").join(",");
5458
+ try {
5459
+ const result = stmtFn(
5460
+ `UPDATE refs
5461
+ SET to_id = s.id
5462
+ FROM (
5463
+ SELECT sym.name AS name, lf.family AS family, MIN(sym.id) AS id
5464
+ FROM symbols sym
5465
+ JOIN lang_family lf ON lf.lang = sym.lang
5466
+ WHERE sym.name IN (${placeholders})
5467
+ GROUP BY sym.name, lf.family
5468
+ UNION ALL
5469
+ SELECT sym.name AS name, '${LANG_FAMILY_WILDCARD}' AS family, MIN(sym.id) AS id
5470
+ FROM symbols sym
5471
+ WHERE sym.name IN (${placeholders})
5472
+ GROUP BY sym.name
5473
+ ) AS s,
5474
+ lang_family AS rf
5475
+ WHERE refs.to_name IN (${placeholders})
5476
+ AND rf.lang = refs.lang
5477
+ AND s.name = refs.to_name
5478
+ AND s.family = rf.family`
5479
+ ).run(...chunk, ...chunk, ...chunk);
5480
+ total += result.changes ?? 0;
5481
+ } catch {
5482
+ const result = stmtFn(
5483
+ `UPDATE refs SET to_id = (
5484
+ SELECT sym.id FROM symbols sym
5485
+ WHERE sym.name = refs.to_name AND ${FAMILY_MATCH_SQL}
5486
+ ORDER BY sym.id LIMIT 1
5487
+ ) WHERE refs.to_name IN (${placeholders})
5488
+ AND EXISTS (
5489
+ SELECT 1 FROM symbols sym
5490
+ WHERE sym.name = refs.to_name AND ${FAMILY_MATCH_SQL}
5491
+ )`
5492
+ ).run(LANG_FAMILY_WILDCARD, ...chunk, LANG_FAMILY_WILDCARD);
5493
+ total += result.changes ?? 0;
5494
+ }
5495
+ }
5496
+ return total;
5497
+ }
5498
+
5499
+ // src/codebase-index/writer-search.ts
5500
+ import { expectDefined as expectDefined4 } from "@wrongstack/core/utils";
5501
+
5502
+ // src/codebase-index/lsp-kind.ts
5503
+ function lspKindToInternalKind(k) {
5504
+ switch (k) {
5505
+ case 5 /* Class */:
5506
+ return "class";
5507
+ case 6 /* Method */:
5508
+ return "method";
5509
+ case 7 /* Property */:
5510
+ case 8 /* Field */:
5511
+ return "property";
5512
+ case 9 /* Constructor */:
5513
+ return "class";
5514
+ case 10 /* Enum */:
5515
+ return "enum";
5516
+ case 11 /* Interface */:
5517
+ return "interface";
5518
+ case 12 /* Function */:
5519
+ return "function";
5520
+ case 13 /* Variable */:
5521
+ return "var";
5522
+ case 14 /* Constant */:
5523
+ return "const";
5524
+ case 22 /* EnumMember */:
5525
+ return "enum";
5526
+ case 26 /* TypeParameter */:
5527
+ return "type";
5528
+ case 3 /* Namespace */:
5529
+ return "namespace";
5530
+ default:
5531
+ return null;
5532
+ }
5533
+ }
5534
+
5535
+ // src/codebase-index/writer-search-helpers.ts
5536
+ var SEARCH_CANDIDATE_SCAN_CAP = 5e3;
5537
+ function normalizeSearchLimit(limit) {
5538
+ return typeof limit === "number" && Number.isFinite(limit) ? Math.max(0, Math.trunc(limit)) : void 0;
5539
+ }
5540
+ function buildWriterSearchWhere(query, filter) {
5541
+ const conditions = [];
5542
+ const values = [];
5543
+ let effectiveKind = filter?.kind;
5544
+ if (filter?.lspKind !== void 0) {
5545
+ const mapped = lspKindToInternalKind(filter.lspKind);
5546
+ if (mapped !== null) {
5547
+ effectiveKind = mapped;
5548
+ } else {
5549
+ return null;
5550
+ }
5197
5551
  }
5198
5552
  if (effectiveKind) {
5199
5553
  conditions.push("kind = ?");
@@ -5231,6 +5585,173 @@ function mapWriterSearchRow(row, lspKind, score = 0, snippet = "") {
5231
5585
  };
5232
5586
  }
5233
5587
 
5588
+ // src/codebase-index/writer-search.ts
5589
+ function searchWithStatement(stmtFn, query, filter, opts) {
5590
+ const built = buildWriterSearchWhere(query, filter);
5591
+ if (built === null) return [];
5592
+ const { where, values } = built;
5593
+ const limit = normalizeSearchLimit(opts?.limit);
5594
+ const limitSql = limit !== void 0 ? " LIMIT ?" : "";
5595
+ const sql = `SELECT id, lang, kind, name, file, line, col, signature, doc_comment FROM symbols ${where}${limitSql}`;
5596
+ const binds = limit !== void 0 ? [...values, limit] : values;
5597
+ const rows = stmtFn(sql).all(...binds);
5598
+ return rows.map((row) => mapWriterSearchRow(row, filter?.lspKind));
5599
+ }
5600
+ function countSearchWithStatement(stmtFn, query, filter) {
5601
+ const built = buildWriterSearchWhere(query, filter);
5602
+ if (built === null) return 0;
5603
+ const row = stmtFn(`SELECT COUNT(*) AS n FROM symbols ${built.where}`).get(
5604
+ ...built.values
5605
+ );
5606
+ return Number(row?.n ?? 0);
5607
+ }
5608
+ function searchRankedWithStatement(stmtFn, searchFn, ftsAvailable, vectorsAvailable, getOrBuildBm25, query, filter, limit) {
5609
+ const rawLimit = Number.isFinite(limit) ? Math.trunc(limit) : 20;
5610
+ const safeLimit = Math.max(1, Math.min(rawLimit, 100));
5611
+ const tokens = tokenise(query);
5612
+ if (tokens.length === 0 || !ftsAvailable) {
5613
+ return searchRankedFallbackWithStatement(
5614
+ stmtFn,
5615
+ searchFn,
5616
+ getOrBuildBm25,
5617
+ query,
5618
+ filter,
5619
+ safeLimit
5620
+ );
5621
+ }
5622
+ let effectiveKind = filter?.kind;
5623
+ if (filter?.lspKind !== void 0) {
5624
+ const mapped = lspKindToInternalKind(filter.lspKind);
5625
+ if (mapped === null) return { results: [], total: 0 };
5626
+ effectiveKind = mapped;
5627
+ }
5628
+ const longTokens = tokens.filter((t) => t.length >= 3);
5629
+ const shortTokens = tokens.filter((t) => t.length < 3);
5630
+ if (longTokens.length === 0) {
5631
+ return searchRankedFallbackWithStatement(
5632
+ stmtFn,
5633
+ searchFn,
5634
+ getOrBuildBm25,
5635
+ query,
5636
+ filter,
5637
+ safeLimit
5638
+ );
5639
+ }
5640
+ const match = longTokens.map((t) => `"${t.replaceAll('"', "")}"`).join(" OR ");
5641
+ const conditions = ["symbols_fts MATCH ?"];
5642
+ const values = [match];
5643
+ for (const shortTok of shortTokens) {
5644
+ conditions.push("s.text LIKE ? ESCAPE '\\'");
5645
+ values.push(`%${escapeLike(shortTok)}%`);
5646
+ }
5647
+ if (effectiveKind) {
5648
+ conditions.push("s.kind = ?");
5649
+ values.push(effectiveKind);
5650
+ }
5651
+ if (filter?.lang) {
5652
+ conditions.push("s.lang = ?");
5653
+ values.push(filter.lang);
5654
+ }
5655
+ if (filter?.file) {
5656
+ conditions.push("replace(s.file, '\\', '/') LIKE ? ESCAPE '\\'");
5657
+ values.push(`%${escapeLike(filter.file.replace(/\\/g, "/"))}%`);
5658
+ }
5659
+ const where = conditions.join(" AND ");
5660
+ const countRows = stmtFn(
5661
+ `SELECT COUNT(*) AS n FROM symbols_fts JOIN symbols s ON s.id = symbols_fts.rowid WHERE ${where}`
5662
+ ).all(...values);
5663
+ const total = countRows[0] ? Number(countRows[0].n) : 0;
5664
+ if (total === 0) return { results: [], total: 0 };
5665
+ const bm25Rows = stmtFn(
5666
+ `SELECT s.id, s.lang, s.kind, s.name, s.file, s.line, s.col, s.signature, s.doc_comment,
5667
+ -bm25(symbols_fts) AS score,
5668
+ snippet(symbols_fts, 0, '', '', '\u2026', 12) AS snippet
5669
+ FROM symbols_fts JOIN symbols s ON s.id = symbols_fts.rowid
5670
+ WHERE ${where}
5671
+ ORDER BY
5672
+ CASE WHEN lower(s.name) = lower(?) THEN 0
5673
+ WHEN lower(s.name) LIKE lower(?) ESCAPE '\\' THEN 1
5674
+ ELSE 2 END,
5675
+ bm25(symbols_fts), lower(s.name), s.file, s.line, s.col, s.id
5676
+ LIMIT ?`
5677
+ ).all(...values, query.trim(), `${escapeLike(query.trim())}%`, safeLimit);
5678
+ if (vectorsAvailable && bm25Rows.length > 0) {
5679
+ const queryVec = embedText(query);
5680
+ const candidateIds = bm25Rows.map((r) => r.id);
5681
+ const placeholders = candidateIds.map(() => "?").join(",");
5682
+ const vecRows = stmtFn(
5683
+ `SELECT sv.symbol_id, sv.vector FROM symbol_vectors sv WHERE sv.symbol_id IN (${placeholders})`
5684
+ ).all(...candidateIds);
5685
+ const vecScores = vecRows.map((r) => ({
5686
+ id: r.symbol_id,
5687
+ sim: cosineSimilarity(queryVec, decodeVector(r.vector))
5688
+ })).sort((a, b) => b.sim - a.sim);
5689
+ const bm25Rank = /* @__PURE__ */ new Map();
5690
+ bm25Rows.forEach((r, i) => {
5691
+ bm25Rank.set(r.id, i);
5692
+ });
5693
+ const vecRank = /* @__PURE__ */ new Map();
5694
+ vecScores.forEach((r, i) => {
5695
+ vecRank.set(r.id, i);
5696
+ });
5697
+ const fused = reciprocalRankFusion(bm25Rank, vecRank, 60);
5698
+ const fusedScore = new Map(fused);
5699
+ const sorted = [...bm25Rows].sort(
5700
+ (a, b) => (fusedScore.get(b.id) ?? 0) - (fusedScore.get(a.id) ?? 0)
5701
+ );
5702
+ return {
5703
+ results: sorted.map(
5704
+ (row) => mapWriterSearchRow(row, filter?.lspKind, Math.max(1e-4, row.score), row.snippet)
5705
+ ),
5706
+ total
5707
+ };
5708
+ }
5709
+ return {
5710
+ results: bm25Rows.map(
5711
+ (row) => mapWriterSearchRow(row, filter?.lspKind, Math.max(1e-4, row.score), row.snippet)
5712
+ ),
5713
+ total
5714
+ };
5715
+ }
5716
+ function searchRankedFallbackWithStatement(stmtFn, searchFn, getOrBuildBm25, query, filter, limit) {
5717
+ if (!query.trim()) {
5718
+ const total2 = countSearchWithStatement(stmtFn, query, filter);
5719
+ if (total2 === 0) return { results: [], total: 0 };
5720
+ return { results: searchFn(query, filter, { limit }), total: total2 };
5721
+ }
5722
+ const total = countSearchWithStatement(stmtFn, query, filter);
5723
+ if (total === 0) return { results: [], total: 0 };
5724
+ const candidates = searchFn(query, filter, {
5725
+ limit: SEARCH_CANDIDATE_SCAN_CAP
5726
+ });
5727
+ if (candidates.length === 0) return { results: [], total: 0 };
5728
+ const candidateById = new Map(candidates.map((c) => [c.id, c]));
5729
+ const bm25 = getOrBuildBm25();
5730
+ const scored = bm25.score(query, (id) => candidateById.has(id));
5731
+ const q = query.trim().toLowerCase();
5732
+ const rank = (id) => {
5733
+ const name = candidateById.get(id)?.name.toLowerCase() ?? "";
5734
+ if (name === q) return 0;
5735
+ if (name.startsWith(q)) return 1;
5736
+ return 2;
5737
+ };
5738
+ scored.sort((a, b) => {
5739
+ const rankDiff = rank(a.id) - rank(b.id);
5740
+ if (rankDiff !== 0) return rankDiff;
5741
+ const scoreDiff = b.score - a.score;
5742
+ if (scoreDiff !== 0) return scoreDiff;
5743
+ const left = expectDefined4(candidateById.get(a.id));
5744
+ const right = expectDefined4(candidateById.get(b.id));
5745
+ return left.name.localeCompare(right.name) || left.file.localeCompare(right.file) || left.line - right.line || left.col - right.col || left.id - right.id;
5746
+ });
5747
+ const qTokens = tokenise(query);
5748
+ const results = scored.slice(0, limit).map(({ id, score }) => {
5749
+ const c = expectDefined4(candidateById.get(id));
5750
+ return { ...c, score, snippet: bm25.extractSnippet(id, qTokens) };
5751
+ });
5752
+ return { results, total };
5753
+ }
5754
+
5234
5755
  // src/codebase-index/writer-store-pool.ts
5235
5756
  var DEFAULT_MAX_WARM_STORES = 2;
5236
5757
  var StorePool = class {
@@ -5322,68 +5843,14 @@ var DB_FILE2 = "index.db";
5322
5843
  var MAX_STATEMENT_CACHE = 128;
5323
5844
  var IndexStore = class _IndexStore {
5324
5845
  db;
5325
- /**
5326
- * True while an index run owns one outer SQLite transaction. Individual
5327
- * writer methods normally protect themselves with BEGIN/COMMIT, but during
5328
- * a refresh they join this transaction so readers observe either the last
5329
- * completed index or the next completed index, never an in-between batch.
5330
- */
5331
5846
  atomicIndexUpdateActive = false;
5332
5847
  writeSavepointSequence = 0;
5333
- /** Absolute path to this project's index directory. */
5334
5848
  indexDir;
5335
- /**
5336
- * True when the SQLite build provides FTS5 (Node's bundled SQLite does).
5337
- * When false, ranked search falls back to the LIKE + in-process BM25 path.
5338
- */
5339
5849
  ftsAvailable = false;
5340
- /**
5341
- * Phase 3: true when the `symbol_vectors` table was created successfully.
5342
- * When false, hybrid search skips the vector pass and falls back to FTS5
5343
- * (or LIKE) only.
5344
- */
5345
5850
  vectorsAvailable = false;
5346
- /**
5347
- * Cache of prepared statements keyed by their SQL text. `DatabaseSync`
5348
- * compiles SQL on every `.prepare()` call; for the fixed-SQL methods
5349
- * (upsertFile, getFileMeta, deleteFile, insertRefs, …) that runs thousands
5350
- * of times during a full reindex. `StatementSync` objects are reusable
5351
- * across calls on the same connection, so we compile each distinct SQL once
5352
- * and reuse it. Cleared in {@link close} when the connection is torn down.
5353
- */
5354
5851
  stmtCache = /* @__PURE__ */ new Map();
5355
- /**
5356
- * Cached full-corpus BM25 index for the FTS5-unavailable fallback path.
5357
- * Built lazily on the first `searchRankedFallback` call and invalidated
5358
- * (via `bm25Dirty`) whenever the `symbols` table is mutated. Computing
5359
- * IDF over the full corpus is also more correct than the old per-query
5360
- * candidate-subset IDF.
5361
- *
5362
- * Cache-lifecycle invariants (single source of truth lives at the
5363
- * `invalidateBm25()` helper — see its docblock for the "every mutation
5364
- * MUST call this" contract):
5365
- * - declaration: this field + `bm25Dirty` (here)
5366
- * - invalidation: `invalidateBm25()` flips the flag and nulls the cache
5367
- * - build: `getOrBuildBm25()` rebuilds against current `symbols` rows
5368
- * - teardown: `close()` resets the flag and nulls the cache
5369
- */
5370
5852
  bm25Cache = null;
5371
- // Dirty on open so the first getOrBuildBm25() rebuilds against current rows;
5372
- // an empty or pre-existing corpus makes a stale IDF table meaningless.
5373
5853
  bm25Dirty = true;
5374
- /**
5375
- * Prepare-once helper: compile `sql` on first use, reuse thereafter.
5376
- *
5377
- * Bounded LRU rather than an open Map. The cache is keyed by SQL TEXT, and
5378
- * the fallback search builder emits one `text LIKE ?` clause per query token
5379
- * — so the SQL varies with the token count and a stream of differently-sized
5380
- * queries grew the cache without limit. Sage's store already bounds its
5381
- * equivalent at 128 (WS-096).
5382
- *
5383
- * Re-inserting on a hit keeps the hot fixed-SQL statements (upsertFile,
5384
- * insertRefs, …) at the young end, so a burst of one-off search SQL evicts
5385
- * itself rather than the reindex hot path.
5386
- */
5387
5854
  stmt(sql) {
5388
5855
  const cached = this.stmtCache.get(sql);
5389
5856
  if (cached !== void 0) {
@@ -5410,7 +5877,6 @@ var IndexStore = class _IndexStore {
5410
5877
  runWithRetry(fn) {
5411
5878
  return runSqliteWithRetry(fn);
5412
5879
  }
5413
- /** Run a complete index mutation as one WAL-visible publication. */
5414
5880
  async runAtomicIndexUpdate(job) {
5415
5881
  if (this.atomicIndexUpdateActive) return job();
5416
5882
  this.runWithRetry(() => this.db.exec("BEGIN IMMEDIATE"));
@@ -5429,11 +5895,6 @@ var IndexStore = class _IndexStore {
5429
5895
  this.atomicIndexUpdateActive = false;
5430
5896
  }
5431
5897
  }
5432
- /**
5433
- * Begin a method-local transaction. Inside an atomic index publication a
5434
- * SAVEPOINT preserves the old per-batch rollback boundary, which is needed
5435
- * when commitBatch falls back to per-file writes after one batch fails.
5436
- */
5437
5898
  beginWriteTransaction() {
5438
5899
  if (this.atomicIndexUpdateActive) {
5439
5900
  const savepoint = `index_write_${++this.writeSavepointSequence}`;
@@ -5455,35 +5916,11 @@ var IndexStore = class _IndexStore {
5455
5916
  this.db.exec("ROLLBACK");
5456
5917
  }
5457
5918
  }
5458
- /**
5459
- * Mirror the in-process language→family map into SQLite.
5460
- *
5461
- * Rewritten on every open rather than only on schema bumps: the mapping is
5462
- * static lookup data, so a code-side change (a new language, a language
5463
- * moving families) must take effect without forcing a full reindex.
5464
- */
5465
5919
  seedLangFamilies() {
5466
5920
  const insert = this.stmt("INSERT OR REPLACE INTO lang_family(lang, family) VALUES (?, ?)");
5467
5921
  for (const [lang, family] of LANG_FAMILY_ENTRIES) insert.run(lang, family);
5468
5922
  insert.run("", LANG_FAMILY_WILDCARD);
5469
5923
  }
5470
- /**
5471
- * Add any column the current schema expects but the on-disk table lacks.
5472
- *
5473
- * `CREATE TABLE IF NOT EXISTS` silently keeps an existing table's old shape,
5474
- * and the version check above only rebuilds on a version *mismatch*. That
5475
- * leaves a real gap: several wstack processes share this database, and while
5476
- * a version upgrade is rolling out one of them may still be running the
5477
- * previous build. That older process sees the newer version number, drops the
5478
- * tables, and recreates them from *its* DDL — without the newer columns —
5479
- * while the metadata row still reads the new version. Every later query for
5480
- * one of those columns then fails with `no such column`, and no amount of
5481
- * reindexing fixes it, because the version numbers already agree.
5482
- *
5483
- * Repairing column-by-column makes the schema self-healing from any of those
5484
- * states. Table and column names are compile-time literals from this module,
5485
- * never user input.
5486
- */
5487
5924
  repairMissingColumns() {
5488
5925
  const expected = [
5489
5926
  {
@@ -5588,27 +6025,8 @@ var IndexStore = class _IndexStore {
5588
6025
  }
5589
6026
  this.ensureNextSymbolIdSeeded();
5590
6027
  }
5591
- // ─── ID allocation & bulk helpers ────────────────────────────────────────────
5592
6028
  static NEXT_SYMBOL_ID_KEY = "next_symbol_id";
5593
- /** Stay under typical SQLite SQLITE_MAX_VARIABLE_NUMBER (often 999). */
5594
6029
  static MAX_SQL_VARS = 900;
5595
- /**
5596
- * Correlated predicate: the ref in `refs` and the candidate symbol aliased
5597
- * `sym` belong to the same language family — or the ref carries no language,
5598
- * in which case the wildcard bind matches everything.
5599
- *
5600
- * Each textual occurrence consumes one `?` bind of {@link LANG_FAMILY_WILDCARD}.
5601
- */
5602
- static FAMILY_MATCH_SQL = `(
5603
- (SELECT family FROM lang_family WHERE lang = refs.lang) = ?
5604
- OR (SELECT family FROM lang_family WHERE lang = sym.lang)
5605
- = (SELECT family FROM lang_family WHERE lang = refs.lang)
5606
- )`;
5607
- /**
5608
- * Ensure `metadata.next_symbol_id` exists. Safe to call outside a write
5609
- * transaction on open; the first concurrent writer under BEGIN IMMEDIATE
5610
- * re-reads and advances the counter atomically.
5611
- */
5612
6030
  ensureNextSymbolIdSeeded() {
5613
6031
  const existing = this.stmt("SELECT value FROM metadata WHERE key = ?").get(
5614
6032
  _IndexStore.NEXT_SYMBOL_ID_KEY
@@ -5621,10 +6039,6 @@ var IndexStore = class _IndexStore {
5621
6039
  String(next)
5622
6040
  );
5623
6041
  }
5624
- /**
5625
- * Reserve `count` consecutive symbol ids. MUST run inside BEGIN IMMEDIATE
5626
- * so concurrent indexers cannot hand out overlapping ranges.
5627
- */
5628
6042
  allocateSymbolIds(count) {
5629
6043
  if (count <= 0) return this.getMaxSymbolId() + 1;
5630
6044
  this.ensureNextSymbolIdSeeded();
@@ -5638,16 +6052,8 @@ var IndexStore = class _IndexStore {
5638
6052
  );
5639
6053
  return start;
5640
6054
  }
5641
- /**
5642
- * Disconnect inbound refs before their target symbols are replaced and
5643
- * return the affected names for scoped re-resolution.
5644
- *
5645
- * This also repairs a long-standing dangling-id edge case: `refs.to_id` has
5646
- * no physical FK, so deleting a symbol previously left callers pointing at a
5647
- * non-existent row.
5648
- */
5649
6055
  invalidateIncomingRefsForFiles(files) {
5650
- if (files.length === 0) return [];
6056
+ if (files.length === 0) return /* @__PURE__ */ new Set();
5651
6057
  const placeholders = files.map(() => "?").join(",");
5652
6058
  const names = this.stmt(`SELECT DISTINCT name FROM symbols WHERE file IN (${placeholders})`).all(
5653
6059
  ...files
@@ -5656,36 +6062,11 @@ var IndexStore = class _IndexStore {
5656
6062
  `UPDATE refs SET to_id = NULL
5657
6063
  WHERE to_id IN (SELECT id FROM symbols WHERE file IN (${placeholders}))`
5658
6064
  ).run(...files);
5659
- return names;
6065
+ return new Set(names);
5660
6066
  }
5661
- /** Resolve only refs whose target names may have changed. */
5662
6067
  resolveRefsForNamesUnsafe(names) {
5663
- const unique = [...new Set(names)].filter(Boolean);
5664
- let changes = 0;
5665
- for (let start = 0; start < unique.length; start += _IndexStore.MAX_SQL_VARS) {
5666
- const chunk = unique.slice(start, start + _IndexStore.MAX_SQL_VARS);
5667
- const placeholders = chunk.map(() => "?").join(",");
5668
- const result = this.stmt(
5669
- `UPDATE refs
5670
- SET to_id = (
5671
- SELECT MIN(sym.id) FROM symbols sym
5672
- WHERE sym.name = refs.to_name AND ${_IndexStore.FAMILY_MATCH_SQL}
5673
- )
5674
- WHERE to_name IN (${placeholders})`
5675
- ).run(LANG_FAMILY_WILDCARD, ...chunk);
5676
- changes += result.changes ?? 0;
5677
- }
5678
- return changes;
6068
+ return resolveRefsForNamesUnsafe((sql) => this.stmt(sql), _IndexStore.MAX_SQL_VARS, names);
5679
6069
  }
5680
- // ─── Symbol CRUD ─────────────────────────────────────────────────────────────
5681
- /**
5682
- * Insert symbols, assigning IDs atomically inside `BEGIN IMMEDIATE` /
5683
- * `COMMIT`. Id ranges come from the `next_symbol_id` metadata counter
5684
- * (O(1)); multi-row INSERT amortizes bind overhead for large files.
5685
- *
5686
- * @returns The symbols array with `id` fields populated so the caller can
5687
- * use them for refs without re-reading from the DB.
5688
- */
5689
6070
  insertSymbols(symbols) {
5690
6071
  this.invalidateBm25();
5691
6072
  return this.runWithRetry(() => {
@@ -5714,12 +6095,6 @@ var IndexStore = class _IndexStore {
5714
6095
  if (this.ftsAvailable) {
5715
6096
  ftsRows.push({ id, text: buildIndexableText(s.name, s.signature, s.docComment) });
5716
6097
  }
5717
- vectorRows.push({
5718
- id,
5719
- vector: encodeVector(
5720
- embedText(s.text || buildIndexableText(s.name, s.signature, s.docComment))
5721
- )
5722
- });
5723
6098
  result.push({ ...s, id });
5724
6099
  }
5725
6100
  bulkInsertSymbolsWithStatement((sql) => this.stmt(sql), _IndexStore.MAX_SQL_VARS, bulk);
@@ -5769,11 +6144,6 @@ var IndexStore = class _IndexStore {
5769
6144
  }
5770
6145
  });
5771
6146
  }
5772
- /**
5773
- * Remove every trace of a file (refs, symbols, FTS rows, file meta). Used
5774
- * when a source file disappears between index runs — previously this only
5775
- * dropped the `files` row, leaving its symbols orphaned but still searchable.
5776
- */
5777
6147
  deleteFile(file) {
5778
6148
  this.invalidateBm25();
5779
6149
  this.runWithRetry(() => {
@@ -5803,7 +6173,6 @@ var IndexStore = class _IndexStore {
5803
6173
  }
5804
6174
  });
5805
6175
  }
5806
- // ─── File metadata ──────────────────────────────────────────────────────────
5807
6176
  upsertFile(meta) {
5808
6177
  this.runWithRetry(() => {
5809
6178
  this.stmt(
@@ -5831,8 +6200,6 @@ var IndexStore = class _IndexStore {
5831
6200
  getAllFileMetas() {
5832
6201
  return getAllFileMetasWithStatement((sql) => this.stmt(sql));
5833
6202
  }
5834
- // ─── Project structure & module resolution ──────────────────────────────────
5835
- /** Store the Code Atlas grouping label for each indexed file. */
5836
6203
  setFilePackages(entries) {
5837
6204
  if (entries.size === 0) return;
5838
6205
  this.runWithRetry(() => {
@@ -5840,272 +6207,50 @@ var IndexStore = class _IndexStore {
5840
6207
  for (const [file, label] of entries) update.run(label, file);
5841
6208
  });
5842
6209
  }
5843
- /**
5844
- * Every indexed `namespace`/`module` declaration, for ecosystems whose import
5845
- * specifiers name a namespace rather than a path (C#, PHP, Elixir, Haskell).
5846
- * Ordered so the resolver's choice among duplicate declarations is stable.
5847
- */
5848
6210
  getNamespaceDeclarations() {
5849
- return this.stmt(
5850
- `SELECT name, file FROM symbols WHERE kind = 'namespace' ORDER BY file, id`
5851
- ).all();
6211
+ return getNamespaceDeclarationsWithStatement((sql) => this.stmt(sql));
5852
6212
  }
5853
- /** `file → package` for every indexed file that has a label. */
5854
6213
  getFilePackages() {
5855
- const rows = this.stmt("SELECT file, package FROM files WHERE package != ''").all();
5856
- return new Map(rows.map((row) => [row.file, row.package]));
6214
+ return getFilePackagesWithStatement((sql) => this.stmt(sql));
5857
6215
  }
5858
- /**
5859
- * Distinct `(fromFile, lang, module)` triples needing module resolution.
5860
- *
5861
- * Distinct rather than per-ref because resolution depends only on these three
5862
- * values: a file importing the same module twenty times resolves it once.
5863
- */
5864
6216
  getUnresolvedImports(onlyFiles) {
5865
- const base = `SELECT DISTINCT s.file AS fromFile, r.lang AS lang, r.module AS module
5866
- FROM refs r
5867
- JOIN symbols s ON s.id = r.from_id
5868
- WHERE r.call_type = 'import' AND r.module IS NOT NULL`;
5869
- if (!onlyFiles?.length) {
5870
- return this.stmt(base).all();
5871
- }
5872
- const out = [];
5873
- for (let i = 0; i < onlyFiles.length; i += _IndexStore.MAX_SQL_VARS) {
5874
- const chunk = onlyFiles.slice(i, i + _IndexStore.MAX_SQL_VARS);
5875
- const placeholders = chunk.map(() => "?").join(",");
5876
- out.push(
5877
- ...this.stmt(`${base} AND s.file IN (${placeholders})`).all(...chunk)
5878
- );
5879
- }
5880
- return out;
6217
+ return getUnresolvedImportsWithStatement(
6218
+ (sql) => this.stmt(sql),
6219
+ _IndexStore.MAX_SQL_VARS,
6220
+ onlyFiles
6221
+ );
5881
6222
  }
5882
- /**
5883
- * Write resolved import targets back onto `refs.to_file`.
5884
- *
5885
- * Applied through a temp table and a single UPDATE: one statement per
5886
- * resolution would mean thousands of round-trips on a first index.
5887
- */
5888
6223
  applyImportResolutions(resolutions) {
5889
- if (resolutions.length === 0) return 0;
5890
- return this.runWithRetry(() => {
5891
- this.db.exec("DROP TABLE IF EXISTS temp.import_resolution");
5892
- this.db.exec(
5893
- `CREATE TEMP TABLE import_resolution (
5894
- from_file TEXT NOT NULL,
5895
- lang TEXT NOT NULL,
5896
- module TEXT NOT NULL,
5897
- to_file TEXT NOT NULL
5898
- )`
5899
- );
5900
- const chunkSize = Math.max(1, Math.floor(_IndexStore.MAX_SQL_VARS / 4));
5901
- for (let i = 0; i < resolutions.length; i += chunkSize) {
5902
- const chunk = resolutions.slice(i, i + chunkSize);
5903
- const placeholders = chunk.map(() => "(?, ?, ?, ?)").join(", ");
5904
- const binds = [];
5905
- for (const entry of chunk) {
5906
- binds.push(entry.fromFile, entry.lang, entry.module, entry.toFile);
5907
- }
5908
- this.stmt(
5909
- `INSERT INTO temp.import_resolution(from_file, lang, module, to_file)
5910
- VALUES ${placeholders}`
5911
- ).run(...binds);
5912
- }
5913
- this.db.exec(
5914
- `CREATE INDEX IF NOT EXISTS temp.idx_ir
5915
- ON import_resolution(module, lang, from_file)`
5916
- );
5917
- const result = this.stmt(
5918
- `UPDATE refs
5919
- SET to_file = (
5920
- SELECT ir.to_file
5921
- FROM temp.import_resolution ir
5922
- JOIN symbols s ON s.id = refs.from_id
5923
- WHERE ir.module = refs.module
5924
- AND ir.lang = refs.lang
5925
- AND ir.from_file = s.file
5926
- LIMIT 1
5927
- )
5928
- WHERE refs.call_type = 'import'
5929
- AND refs.module IS NOT NULL
5930
- AND EXISTS (
5931
- SELECT 1
5932
- FROM temp.import_resolution ir
5933
- JOIN symbols s ON s.id = refs.from_id
5934
- WHERE ir.module = refs.module
5935
- AND ir.lang = refs.lang
5936
- AND ir.from_file = s.file
5937
- )`
5938
- ).run();
5939
- this.db.exec("DROP TABLE IF EXISTS temp.import_resolution");
5940
- return result.changes ?? 0;
5941
- });
5942
- }
5943
- // ─── Search ──────────────────────────────────────────────────────────────────
5944
- search(query, filter, opts) {
5945
- const built = this.buildSearchWhere(query, filter);
5946
- if (built === null) return [];
5947
- const { where, values } = built;
5948
- const limit = normalizeSearchLimit(opts?.limit);
5949
- const limitSql = limit !== void 0 ? " LIMIT ?" : "";
5950
- const sql = `SELECT id, lang, kind, name, file, line, col, signature, doc_comment FROM symbols ${where}${limitSql}`;
5951
- const binds = limit !== void 0 ? [...values, limit] : values;
5952
- const rows = this.stmt(sql).all(
5953
- ...binds
6224
+ return applyImportResolutionsWithStatement(
6225
+ this.db,
6226
+ (sql) => this.stmt(sql),
6227
+ this.runWithRetry.bind(this),
6228
+ _IndexStore.MAX_SQL_VARS,
6229
+ resolutions
5954
6230
  );
5955
- return rows.map((row) => mapWriterSearchRow(row, filter?.lspKind));
5956
6231
  }
5957
- /** Shared WHERE builder for {@link search} / empty-query ranked totals. */
5958
- buildSearchWhere(query, filter) {
5959
- return buildWriterSearchWhere(query, filter);
6232
+ search(query, filter, opts) {
6233
+ return searchWithStatement((sql) => this.stmt(sql), query, filter, opts);
5960
6234
  }
5961
6235
  countSearch(query, filter) {
5962
- const built = this.buildSearchWhere(query, filter);
5963
- if (built === null) return 0;
5964
- const row = this.stmt(`SELECT COUNT(*) AS n FROM symbols ${built.where}`).get(
5965
- ...built.values
5966
- );
5967
- return Number(row?.n ?? 0);
6236
+ return countSearchWithStatement((sql) => this.stmt(sql), query, filter);
5968
6237
  }
5969
- /**
5970
- * Ranked search — the one-stop query the codebase-search tool and plug-lsp
5971
- * use. With FTS5 this is a single indexed `MATCH` ranked by SQLite's native
5972
- * `bm25()` with a built-in `snippet()`; without FTS5 it falls back to the
5973
- * legacy LIKE scan + in-process BM25 (identical semantics, slower).
5974
- *
5975
- * Tokens are matched as prefixes (`"tok"*`), mirroring the old
5976
- * `LIKE '%tok%'` recall for the common symbol-search shapes ("user" finds
5977
- * "users", camelCase-split text makes "complex" find "complexOperation").
5978
- */
5979
6238
  searchRanked(query, filter, limit) {
5980
- const rawLimit = Number.isFinite(limit) ? Math.trunc(limit) : 20;
5981
- const safeLimit = Math.max(1, Math.min(rawLimit, 100));
5982
- const tokens = tokenise(query);
5983
- if (tokens.length === 0 || !this.ftsAvailable) {
5984
- return this.searchRankedFallback(query, filter, safeLimit);
5985
- }
5986
- let effectiveKind = filter?.kind;
5987
- if (filter?.lspKind !== void 0) {
5988
- const mapped = lspKindToInternalKind(filter.lspKind);
5989
- if (mapped === null) return { results: [], total: 0 };
5990
- effectiveKind = mapped;
5991
- }
5992
- const longTokens = tokens.filter((t) => t.length >= 3);
5993
- const shortTokens = tokens.filter((t) => t.length < 3);
5994
- if (longTokens.length === 0) {
5995
- return this.searchRankedFallback(query, filter, safeLimit);
5996
- }
5997
- const match = longTokens.map((t) => `"${t.replaceAll('"', "")}"`).join(" OR ");
5998
- const conditions = ["symbols_fts MATCH ?"];
5999
- const values = [match];
6000
- for (const shortTok of shortTokens) {
6001
- conditions.push("s.text LIKE ? ESCAPE '\\'");
6002
- values.push(`%${escapeLike(shortTok)}%`);
6003
- }
6004
- if (effectiveKind) {
6005
- conditions.push("s.kind = ?");
6006
- values.push(effectiveKind);
6007
- }
6008
- if (filter?.lang) {
6009
- conditions.push("s.lang = ?");
6010
- values.push(filter.lang);
6011
- }
6012
- if (filter?.file) {
6013
- conditions.push("replace(s.file, '\\', '/') LIKE ? ESCAPE '\\'");
6014
- values.push(`%${escapeLike(filter.file.replace(/\\/g, "/"))}%`);
6015
- }
6016
- const where = conditions.join(" AND ");
6017
- const countRows = this.stmt(
6018
- `SELECT COUNT(*) AS n FROM symbols_fts JOIN symbols s ON s.id = symbols_fts.rowid WHERE ${where}`
6019
- ).all(...values);
6020
- const total = countRows[0] ? Number(countRows[0].n) : 0;
6021
- if (total === 0) return { results: [], total: 0 };
6022
- const bm25Rows = this.stmt(
6023
- `SELECT s.id, s.lang, s.kind, s.name, s.file, s.line, s.col, s.signature, s.doc_comment,
6024
- -bm25(symbols_fts) AS score,
6025
- snippet(symbols_fts, 0, '', '', '\u2026', 12) AS snippet
6026
- FROM symbols_fts JOIN symbols s ON s.id = symbols_fts.rowid
6027
- WHERE ${where}
6028
- ORDER BY
6029
- CASE WHEN lower(s.name) = lower(?) THEN 0
6030
- WHEN lower(s.name) LIKE lower(?) ESCAPE '\\' THEN 1
6031
- ELSE 2 END,
6032
- bm25(symbols_fts), lower(s.name), s.file, s.line, s.col, s.id
6033
- LIMIT ?`
6034
- ).all(...values, query.trim(), `${escapeLike(query.trim())}%`, safeLimit);
6035
- if (this.vectorsAvailable && bm25Rows.length > 0) {
6036
- const queryVec = embedText(query);
6037
- const candidateIds = bm25Rows.map((r) => r.id);
6038
- const placeholders = candidateIds.map(() => "?").join(",");
6039
- const vecRows = this.stmt(
6040
- `SELECT sv.symbol_id, sv.vector FROM symbol_vectors sv WHERE sv.symbol_id IN (${placeholders})`
6041
- ).all(...candidateIds);
6042
- const vecScores = vecRows.map((r) => ({
6043
- id: r.symbol_id,
6044
- sim: cosineSimilarity(queryVec, decodeVector(r.vector))
6045
- })).sort((a, b) => b.sim - a.sim);
6046
- const bm25Rank = /* @__PURE__ */ new Map();
6047
- bm25Rows.forEach((r, i) => {
6048
- bm25Rank.set(r.id, i);
6049
- });
6050
- const vecRank = /* @__PURE__ */ new Map();
6051
- vecScores.forEach((r, i) => {
6052
- vecRank.set(r.id, i);
6053
- });
6054
- const fused = reciprocalRankFusion(bm25Rank, vecRank, 60);
6055
- const fusedScore = new Map(fused);
6056
- const sorted = [...bm25Rows].sort(
6057
- (a, b) => (fusedScore.get(b.id) ?? 0) - (fusedScore.get(a.id) ?? 0)
6058
- );
6059
- return {
6060
- results: sorted.map(
6061
- (row) => mapWriterSearchRow(row, filter?.lspKind, Math.max(1e-4, row.score), row.snippet)
6062
- ),
6063
- total
6064
- };
6065
- }
6066
- return {
6067
- results: bm25Rows.map(
6068
- (row) => mapWriterSearchRow(row, filter?.lspKind, Math.max(1e-4, row.score), row.snippet)
6069
- ),
6070
- total
6071
- };
6239
+ return searchRankedWithStatement(
6240
+ (sql) => this.stmt(sql),
6241
+ this.search.bind(this),
6242
+ this.ftsAvailable,
6243
+ this.vectorsAvailable,
6244
+ this.getOrBuildBm25.bind(this),
6245
+ query,
6246
+ filter,
6247
+ limit
6248
+ );
6072
6249
  }
6073
- /**
6074
- * Invalidate the cached BM25 index.
6075
- *
6076
- * **Contract: every method that mutates `symbols` MUST call this before
6077
- * returning.** (`refs` mutations do not affect the BM25 fallback because
6078
- * the corpus is built from `symbols.text` via `getAllIndexable()` and the
6079
- * BM25 score is filtered by the LIKE-selected candidate set in
6080
- * `searchRankedFallback`.) Today the call sites are `repairDrift`,
6081
- * `insertSymbols`, `deleteSymbolsForFile`, `deleteFile`, `clearAll`, and
6082
- * `commitBatch`. A future mutation that adds a new write path (e.g.
6083
- * `renameFile`, `updateSignature`) MUST also call this — otherwise the
6084
- * FTS5-unavailable fallback will serve stale search results. The
6085
- * `close()` reset at L1820-1821 tears the cache down on store shutdown,
6086
- * which is the only legitimate place that flips the flag outside this
6087
- * helper.
6088
- *
6089
- * Called *before* `runWithRetry` on purpose: if the write fails all
6090
- * retries the flag stays set, forcing a rebuild on the next search rather
6091
- * than trusting a cache that may not reflect the intended mutation.
6092
- * Do not move this inside the retry closure.
6093
- */
6094
6250
  invalidateBm25() {
6095
6251
  this.bm25Dirty = true;
6096
6252
  this.bm25Cache = null;
6097
6253
  }
6098
- /**
6099
- * Return the cached full-corpus BM25 index, rebuilding it only when the
6100
- * symbols table has been mutated since the last build. The full-corpus IDF
6101
- * is more correct than the old per-query candidate-subset IDF, and the
6102
- * amortized build cost drops from O(symbols × tokens) per search to once
6103
- * per write batch.
6104
- *
6105
- * Note: the first call after a long idle (or on a freshly opened store)
6106
- * pays the full corpus rebuild synchronously on the search path. For a
6107
- * 5 500+ symbol corpus this is a visible one-time latency spike.
6108
- */
6109
6254
  getOrBuildBm25() {
6110
6255
  if (this.bm25Cache && !this.bm25Dirty) return this.bm25Cache;
6111
6256
  const docs = this.getAllIndexable();
@@ -6113,57 +6258,12 @@ var IndexStore = class _IndexStore {
6113
6258
  this.bm25Dirty = false;
6114
6259
  return this.bm25Cache;
6115
6260
  }
6116
- /** Legacy ranked path: LIKE candidates + in-process BM25 + JS snippets. */
6117
- searchRankedFallback(query, filter, limit) {
6118
- if (!query.trim()) {
6119
- const total2 = this.countSearch(query, filter);
6120
- if (total2 === 0) return { results: [], total: 0 };
6121
- return { results: this.search(query, filter, { limit }), total: total2 };
6122
- }
6123
- const total = this.countSearch(query, filter);
6124
- if (total === 0) return { results: [], total: 0 };
6125
- const candidates = this.search(query, filter, { limit: SEARCH_CANDIDATE_SCAN_CAP });
6126
- if (candidates.length === 0) return { results: [], total: 0 };
6127
- const candidateById = new Map(candidates.map((c) => [c.id, c]));
6128
- const bm25 = this.getOrBuildBm25();
6129
- const scored = bm25.score(query, (id) => candidateById.has(id));
6130
- const q = query.trim().toLowerCase();
6131
- const rank = (id) => {
6132
- const name = candidateById.get(id)?.name.toLowerCase() ?? "";
6133
- if (name === q) return 0;
6134
- if (name.startsWith(q)) return 1;
6135
- return 2;
6136
- };
6137
- scored.sort((a, b) => {
6138
- const rankDiff = rank(a.id) - rank(b.id);
6139
- if (rankDiff !== 0) return rankDiff;
6140
- const scoreDiff = b.score - a.score;
6141
- if (scoreDiff !== 0) return scoreDiff;
6142
- const left = expectDefined4(candidateById.get(a.id));
6143
- const right = expectDefined4(candidateById.get(b.id));
6144
- return left.name.localeCompare(right.name) || left.file.localeCompare(right.file) || left.line - right.line || left.col - right.col || left.id - right.id;
6145
- });
6146
- const qTokens = tokenise(query);
6147
- const results = scored.slice(0, limit).map(({ id, score }) => {
6148
- const c = expectDefined4(candidateById.get(id));
6149
- return { ...c, score, snippet: bm25.extractSnippet(id, qTokens) };
6150
- });
6151
- return { results, total };
6152
- }
6153
6261
  getAllIndexable() {
6154
6262
  return getAllIndexableWithStatement((sql) => this.stmt(sql));
6155
6263
  }
6156
- /**
6157
- * Largest symbol id currently in the table (0 when empty). New ids must be
6158
- * allocated from this, NOT from `COUNT(*)`: incremental reindexes delete a
6159
- * changed file's rows, so the row count drops below the max id and a
6160
- * count-based id would collide with a surviving row (UNIQUE constraint on
6161
- * `symbols.id`). Ids may have gaps — that is fine.
6162
- */
6163
6264
  getMaxSymbolId() {
6164
6265
  return getMaxSymbolIdWithStatement((sql) => this.stmt(sql));
6165
6266
  }
6166
- // ─── Stats ───────────────────────────────────────────────────────────────────
6167
6267
  getStats() {
6168
6268
  return getStatsWithStatement((sql) => this.stmt(sql), this.indexDir);
6169
6269
  }
@@ -6206,11 +6306,6 @@ var IndexStore = class _IndexStore {
6206
6306
  }
6207
6307
  });
6208
6308
  }
6209
- // ─── Ref CRUD ────────────────────────────────────────────────────────────────
6210
- /**
6211
- * Insert cross-references for a given source symbol id.
6212
- * Replaces any existing refs from the same source (idempotent on re-index).
6213
- */
6214
6309
  insertRefs(fromId, refs) {
6215
6310
  this.runWithRetry(() => {
6216
6311
  this.stmt("DELETE FROM refs WHERE from_id = ?").run(fromId);
@@ -6222,167 +6317,36 @@ var IndexStore = class _IndexStore {
6222
6317
  );
6223
6318
  });
6224
6319
  }
6225
- /**
6226
- * Bulk-insert refs for many source symbols in a single transaction.
6227
- *
6228
- * Unlike {@link insertRefs} this does NOT delete per source id — the caller
6229
- * (the indexer) has already cleared stale refs for the file via
6230
- * {@link deleteRefsForFile}, so the per-source DELETE would be redundant work
6231
- * repeated once per symbol. One transaction for the whole file instead of one
6232
- * per symbol turns an O(symbols) transaction count into O(1).
6233
- *
6234
- * Each ref's own {@link Ref.fromId} is used; pass an empty array to no-op.
6235
- */
6236
6320
  insertRefsBatch(refs) {
6237
6321
  if (refs.length === 0) return;
6238
6322
  this.runWithRetry(() => {
6239
6323
  bulkInsertRefsWithStatement((sql) => this.stmt(sql), _IndexStore.MAX_SQL_VARS, refs);
6240
6324
  });
6241
6325
  }
6242
- /**
6243
- * Commit a batch of file-level symbol/refs/upserts in a single transaction.
6244
- *
6245
- * Used by the indexer to amortize SQLite commit overhead across many files.
6246
- * Before this, the indexer issued one transaction per file (BEGIN IMMEDIATE
6247
- * for symbols, plus per-file deletes and an upsertFile call), so a 20-file
6248
- * parallel batch cost ~5+ transactions × 20 files = 100+ commits. With
6249
- * this entry point we do exactly one BEGIN/COMMIT per parallel batch.
6250
- *
6251
- * Each entry must already be a fully-parsed FileSymbols (symbols + refs).
6252
- * The caller is responsible for the per-file prefix accounting
6253
- * (refsByLine → flat list with `fromId` populated). `deleteForFiles` lets
6254
- * the caller clear stale symbols/refs for any files being re-indexed before
6255
- * the inserts run (required to keep refs → symbols FK invariants).
6256
- *
6257
- * Returns the symbols back with their assigned `id` (same shape as
6258
- * {@link insertSymbols}) so callers can build final per-file results.
6259
- */
6260
6326
  commitBatch(entries, options = {}) {
6261
- if (entries.length === 0 && (options.deleteForFiles?.length ?? 0) === 0) {
6262
- return [];
6263
- }
6264
6327
  this.invalidateBm25();
6265
6328
  return this.runWithRetry(() => {
6266
6329
  const ownsTransaction = this.beginWriteTransaction();
6267
6330
  try {
6268
- const affectedNames = /* @__PURE__ */ new Set();
6269
- for (const entry of entries) {
6270
- for (const symbol of entry.symbols) affectedNames.add(symbol.name);
6271
- for (const ref of entry.refs) affectedNames.add(ref.toName);
6272
- }
6273
- if (options.deleteForFiles && options.deleteForFiles.length > 0) {
6274
- const placeholders = options.deleteForFiles.map(() => "?").join(",");
6275
- for (const name of this.invalidateIncomingRefsForFiles(options.deleteForFiles)) {
6276
- affectedNames.add(name);
6277
- }
6278
- if (this.ftsAvailable) {
6279
- this.stmt(
6280
- `DELETE FROM symbols_fts WHERE rowid IN (SELECT id FROM symbols WHERE file IN (${placeholders}))`
6281
- ).run(...options.deleteForFiles);
6282
- }
6283
- if (this.vectorsAvailable) {
6284
- this.stmt(
6285
- `DELETE FROM symbol_vectors WHERE symbol_id IN (SELECT id FROM symbols WHERE file IN (${placeholders}))`
6286
- ).run(...options.deleteForFiles);
6287
- }
6288
- this.stmt(
6289
- `DELETE FROM refs WHERE from_id IN (SELECT id FROM symbols WHERE file IN (${placeholders}))`
6290
- ).run(...options.deleteForFiles);
6291
- this.stmt(`DELETE FROM symbols WHERE file IN (${placeholders})`).run(
6292
- ...options.deleteForFiles
6293
- );
6294
- }
6295
- const totalSymbols = entries.reduce((n, e) => n + e.symbols.length, 0);
6296
- let nextId = this.allocateSymbolIds(totalSymbols);
6297
- const allInserted = [];
6298
- const refsToInsert = [];
6299
- const bulkSyms = [];
6300
- const ftsRows = [];
6301
- const vectorRows = [];
6302
- for (const entry of entries) {
6303
- const insertedForEntry = [];
6304
- for (const s of entry.symbols) {
6305
- const id = nextId++;
6306
- bulkSyms.push({
6307
- id,
6308
- lang: s.lang,
6309
- kind: s.kind,
6310
- name: s.name,
6311
- file: s.file,
6312
- line: s.line,
6313
- col: s.col,
6314
- signature: s.signature,
6315
- docComment: s.docComment,
6316
- scope: s.scope,
6317
- text: s.text
6318
- });
6319
- if (this.ftsAvailable) {
6320
- ftsRows.push({
6321
- id,
6322
- text: buildIndexableText(s.name, s.signature, s.docComment)
6323
- });
6324
- }
6325
- vectorRows.push({
6326
- id,
6327
- vector: encodeVector(
6328
- embedText(s.text || buildIndexableText(s.name, s.signature, s.docComment))
6329
- )
6330
- });
6331
- const inserted = { ...s, id };
6332
- allInserted.push(inserted);
6333
- insertedForEntry.push(inserted);
6334
- }
6335
- refsToInsert.push(...assignRefsToSymbols(entry.refs, insertedForEntry));
6336
- }
6337
- bulkInsertSymbolsWithStatement((sql) => this.stmt(sql), _IndexStore.MAX_SQL_VARS, bulkSyms);
6338
- bulkInsertFtsWithStatement(
6331
+ const result = commitBatchWithStatement(
6339
6332
  (sql) => this.stmt(sql),
6340
6333
  _IndexStore.MAX_SQL_VARS,
6341
6334
  this.ftsAvailable,
6342
- ftsRows
6335
+ this.vectorsAvailable,
6336
+ this.allocateSymbolIds.bind(this),
6337
+ this.invalidateIncomingRefsForFiles.bind(this),
6338
+ this.resolveRefsForNamesUnsafe.bind(this),
6339
+ entries,
6340
+ options
6343
6341
  );
6344
- if (this.vectorsAvailable) {
6345
- bulkInsertVectorsWithStatement(
6346
- (sql) => this.stmt(sql),
6347
- _IndexStore.MAX_SQL_VARS,
6348
- vectorRows
6349
- );
6350
- }
6351
- bulkInsertRefsWithStatement((sql) => this.stmt(sql), _IndexStore.MAX_SQL_VARS, refsToInsert);
6352
- const upsertStmt = this.stmt(
6353
- `INSERT INTO files(file, lang, mtime_ms, content_hash, symbol_count, last_indexed)
6354
- VALUES (?, ?, ?, ?, ?, ?)
6355
- ON CONFLICT(file) DO UPDATE SET
6356
- lang = excluded.lang,
6357
- mtime_ms = excluded.mtime_ms,
6358
- content_hash = excluded.content_hash,
6359
- symbol_count = excluded.symbol_count,
6360
- last_indexed = excluded.last_indexed`
6361
- );
6362
- const now = Date.now();
6363
- for (const entry of entries) {
6364
- upsertStmt.run(
6365
- entry.file,
6366
- entry.lang,
6367
- entry.mtimeMs,
6368
- entry.contentHash ?? "",
6369
- entry.symbolCount,
6370
- now
6371
- );
6372
- }
6373
- this.resolveRefsForNamesUnsafe(affectedNames);
6374
6342
  this.commitWriteTransaction(ownsTransaction);
6375
- return allInserted;
6343
+ return result;
6376
6344
  } catch (err) {
6377
6345
  this.rollbackWriteTransaction(ownsTransaction);
6378
6346
  throw err;
6379
6347
  }
6380
6348
  });
6381
6349
  }
6382
- /**
6383
- * Delete all refs whose source symbols are in a given file.
6384
- * Used when re-indexing a file to clear stale refs.
6385
- */
6386
6350
  deleteRefsForFile(file) {
6387
6351
  this.runWithRetry(() => {
6388
6352
  this.stmt("DELETE FROM refs WHERE from_id IN (SELECT id FROM symbols WHERE file = ?)").run(
@@ -6390,64 +6354,12 @@ var IndexStore = class _IndexStore {
6390
6354
  );
6391
6355
  });
6392
6356
  }
6393
- /**
6394
- * Resolve `to_name` → `to_id` for all refs that have a name but no id.
6395
- * Call this after all symbols have been inserted to fill in cross-references.
6396
- *
6397
- * A match additionally requires the referencing ref and the target symbol to
6398
- * be in the same {@link LangFamily}. Without that guard a name match is a
6399
- * cross-language accident waiting to happen — `main`, `New`, `Parse` and
6400
- * `Config` are declared in most languages at once, and each collision draws a
6401
- * Code Atlas edge between files that never reference each other. Refs stored
6402
- * without a language keep the old global behaviour via the `'*'` wildcard row.
6403
- */
6404
6357
  resolveRefs() {
6405
- return this.runWithRetry(() => {
6406
- try {
6407
- const result = this.stmt(
6408
- `UPDATE refs
6409
- SET to_id = s.id
6410
- FROM (
6411
- SELECT sym.name AS name, lf.family AS family, MIN(sym.id) AS id
6412
- FROM symbols sym
6413
- JOIN lang_family lf ON lf.lang = sym.lang
6414
- GROUP BY sym.name, lf.family
6415
- UNION ALL
6416
- SELECT sym.name AS name, '${LANG_FAMILY_WILDCARD}' AS family, MIN(sym.id) AS id
6417
- FROM symbols sym
6418
- GROUP BY sym.name
6419
- ) AS s,
6420
- lang_family AS rf
6421
- WHERE refs.to_id IS NULL
6422
- AND refs.to_name IS NOT NULL
6423
- AND rf.lang = refs.lang
6424
- AND s.name = refs.to_name
6425
- AND s.family = rf.family`
6426
- ).run();
6427
- return result.changes ?? 0;
6428
- } catch {
6429
- const result = this.stmt(
6430
- `UPDATE refs SET to_id = (
6431
- SELECT sym.id FROM symbols sym
6432
- WHERE sym.name = refs.to_name AND ${_IndexStore.FAMILY_MATCH_SQL}
6433
- ORDER BY sym.id LIMIT 1
6434
- ) WHERE to_id IS NULL AND to_name IS NOT NULL
6435
- AND EXISTS (
6436
- SELECT 1 FROM symbols sym
6437
- WHERE sym.name = refs.to_name AND ${_IndexStore.FAMILY_MATCH_SQL}
6438
- )`
6439
- ).run(LANG_FAMILY_WILDCARD, LANG_FAMILY_WILDCARD);
6440
- return result.changes ?? 0;
6441
- }
6442
- });
6358
+ return this.runWithRetry(() => resolveRefsWithStatement((sql) => this.stmt(sql)));
6443
6359
  }
6444
6360
  resolveRefsForNames(names) {
6445
6361
  return this.runWithRetry(() => this.resolveRefsForNamesUnsafe(names));
6446
6362
  }
6447
- /**
6448
- * Clear symbols/refs for a file and mark it as indexed with zero symbols.
6449
- * Used by the indexer for empty-parse results so three writes share one txn.
6450
- */
6451
6363
  replaceEmptyFile(meta) {
6452
6364
  this.invalidateBm25();
6453
6365
  this.runWithRetry(() => {
@@ -6493,20 +6405,12 @@ var IndexStore = class _IndexStore {
6493
6405
  }
6494
6406
  });
6495
6407
  }
6496
- /** Best-effort query planner refresh after a large reindex. */
6497
6408
  optimize() {
6498
6409
  try {
6499
6410
  this.db.exec("PRAGMA optimize");
6500
6411
  } catch {
6501
6412
  }
6502
6413
  }
6503
- /**
6504
- * Reclaim page churn left by repeated force rebuilds.
6505
- *
6506
- * SQLite's DROP/CREATE path makes rebuilds fast but leaves pages on the
6507
- * freelist. Compact only large, materially sparse databases and only when the
6508
- * caller is already on a full-index maintenance path.
6509
- */
6510
6414
  compactIfNeeded(options = {}) {
6511
6415
  const minBytes = options.minBytes ?? 256 * 1024 * 1024;
6512
6416
  const minFreeRatio = options.minFreeRatio ?? 0.35;
@@ -6533,115 +6437,44 @@ var IndexStore = class _IndexStore {
6533
6437
  return false;
6534
6438
  }
6535
6439
  }
6536
- /**
6537
- * Find all symbols that reference the named target symbol (incoming callers).
6538
- * Accepts a name instead of an id so the agent doesn't need a prior lookup.
6539
- */
6540
6440
  findIncomingCallsByName(symbolName, file, limit = 100) {
6541
6441
  return findIncomingCallsByName((sql) => this.stmt(sql), symbolName, file, limit);
6542
6442
  }
6543
- /**
6544
- * Find all symbols that the named source symbol references (outgoing callees).
6545
- * Accepts a name instead of an id so the agent doesn't need a prior lookup.
6546
- */
6547
6443
  findOutgoingCallsByName(symbolName, file, limit = 100) {
6548
6444
  return findOutgoingCallsByName((sql) => this.stmt(sql), symbolName, file, limit);
6549
6445
  }
6550
- /**
6551
- * Transitive incoming-call tree: all symbols that transitively call the
6552
- * target, to an unbounded depth (cycle-safe via SQL UNION deduplication).
6553
- * Used by `codebase-incoming-calls` when the caller wants the full call
6554
- * chain rather than just direct callers.
6555
- */
6556
6446
  findTransitiveIncomingCallsByName(symbolName, file, limit = 200) {
6557
6447
  return findTransitiveIncomingCallsByName((sql) => this.stmt(sql), symbolName, file, limit);
6558
6448
  }
6559
- /**
6560
- * Transitive outgoing-call tree: all symbols the target transitively calls.
6561
- * Used by `codebase-outgoing-calls` when the caller wants the full
6562
- * dependency chain rather than just direct callees.
6563
- */
6564
6449
  findTransitiveOutgoingCallsByName(symbolName, file, limit = 200) {
6565
6450
  return findTransitiveOutgoingCallsByName((sql) => this.stmt(sql), symbolName, file, limit);
6566
6451
  }
6567
- /**
6568
- * Compute the set of symbol IDs reachable from the given seed IDs using a
6569
- * native SQLite recursive CTE. Used by dead-code detection to replace the
6570
- * in-memory BFS.
6571
- */
6572
6452
  findReachableSymbolIds(seedIds) {
6573
6453
  return findReachableSymbolIds((sql) => this.stmt(sql), seedIds);
6574
6454
  }
6575
- /**
6576
- * Find all references TO a given symbol (who calls / uses this symbol?).
6577
- */
6578
6455
  findRefsTo(symbolId) {
6579
6456
  return findRefsToWithStatement((sql) => this.stmt(sql), symbolId);
6580
6457
  }
6581
- /**
6582
- * Find all references FROM a given symbol (what does this symbol call/use?).
6583
- */
6584
6458
  findRefsFrom(symbolId) {
6585
6459
  return findRefsFromWithStatement((sql) => this.stmt(sql), symbolId);
6586
6460
  }
6587
- // ─── CodeMap graph aggregation ──────────────────────────────────────────────
6588
- /**
6589
- * Package-level graph: each workspace package is a node; edges are derived
6590
- * from cross-package symbol references (a symbol in package A references a
6591
- * symbol resolved in package B). Node metadata includes symbol/file counts.
6592
- */
6593
6461
  getPackageGraph() {
6594
6462
  return getPackageGraphWithStatement((sql) => this.stmt(sql));
6595
6463
  }
6596
- /**
6597
- * File-level graph for a single package: each file is a node; edges are
6598
- * derived from cross-file symbol references within the package.
6599
- */
6600
6464
  getFileGraph(packageFilter) {
6601
6465
  return getFileGraphWithStatement((sql) => this.stmt(sql), packageFilter);
6602
6466
  }
6603
- /**
6604
- * Symbol-level graph for a single file: each symbol is a node; edges are
6605
- * derived from intra-file and cross-file symbol references (who calls whom).
6606
- */
6607
6467
  getSymbolGraph(fileFilter) {
6608
6468
  return getSymbolGraphWithStatement((sql) => this.stmt(sql), fileFilter);
6609
6469
  }
6610
- /**
6611
- * Returns every symbol in the index. Used by dead-code analysis to
6612
- * build the full symbol universe for the reachability scan.
6613
- */
6614
6470
  getAllSymbols() {
6615
6471
  return this.stmt("SELECT id, name, file, kind, line FROM symbols ORDER BY id").all().map((r) => ({ ...r, kind: r.kind }));
6616
6472
  }
6617
- /**
6618
- * Returns every resolved reference (to_id IS NOT NULL). Used by
6619
- * dead-code analysis to build the consumer-ship graph. Refs whose
6620
- * target symbol id is null (unresolved imports) are excluded.
6621
- */
6622
6473
  getAllResolvedRefs() {
6623
- return this.stmt(
6624
- "SELECT from_id AS fromId, to_id AS toId, call_type AS callType FROM refs WHERE to_id IS NOT NULL"
6625
- ).all();
6474
+ return getAllResolvedRefsWithStatement((sql) => this.stmt(sql));
6626
6475
  }
6627
- /**
6628
- * Returns ALL import refs (including unresolved) with their source-file
6629
- * path and resolved target id. Used by the dead-code scan's file-level
6630
- * graph traversal to handle barrel-only entry points where no symbol
6631
- * carries the ref.
6632
- *
6633
- * Refs whose `from_id` doesn't match a known symbol (e.g. pure-barrel
6634
- * files with no declarations) will have `sourceFile === null`.
6635
- */
6636
6476
  getAllImportRefs() {
6637
- return this.stmt(
6638
- `SELECT s.file AS sourceFile, r.to_name AS toName, r.to_id AS toId,
6639
- r.call_type AS callType, r.line
6640
- FROM refs r
6641
- LEFT JOIN symbols s ON r.from_id = s.id
6642
- WHERE r.call_type = 'import'
6643
- ORDER BY r.line`
6644
- ).all();
6477
+ return getAllImportRefsWithStatement((sql) => this.stmt(sql));
6645
6478
  }
6646
6479
  close() {
6647
6480
  this.stmtCache.clear();
@@ -7341,11 +7174,10 @@ function outgoingCallsService(args) {
7341
7174
 
7342
7175
  // src/codebase-index/project-server-client.ts
7343
7176
  import { spawn as spawn3 } from "node:child_process";
7344
- import * as fs11 from "node:fs";
7177
+ import * as fs12 from "node:fs";
7345
7178
  import * as net from "node:net";
7346
7179
  import { StringDecoder } from "node:string_decoder";
7347
- import { fileURLToPath as fileURLToPath4 } from "node:url";
7348
- import { checkUnixSocketPath } from "@wrongstack/core/utils";
7180
+ import { fileURLToPath as fileURLToPath5 } from "node:url";
7349
7181
 
7350
7182
  // src/codebase-index/binary-frame.ts
7351
7183
  import { decode, encode } from "@msgpack/msgpack";
@@ -7419,7 +7251,10 @@ function encodeProjectServerMessage(message) {
7419
7251
  `;
7420
7252
  }
7421
7253
 
7422
- // src/codebase-index/project-server-client.ts
7254
+ // src/codebase-index/project-server-client-state.ts
7255
+ import * as fs11 from "node:fs";
7256
+ import { fileURLToPath as fileURLToPath4 } from "node:url";
7257
+ import { checkUnixSocketPath } from "@wrongstack/core/utils";
7423
7258
  var CONNECT_ATTEMPT_TIMEOUT_MS = 750;
7424
7259
  var SERVER_START_TIMEOUT_MS = 1e4;
7425
7260
  var SERVER_CONTROL_TIMEOUT_MS = 5e3;
@@ -7439,6 +7274,9 @@ var latestConnectionState = {
7439
7274
  status: "offline",
7440
7275
  connected: false
7441
7276
  };
7277
+ function setLatestConnectionState(state) {
7278
+ latestConnectionState = state;
7279
+ }
7442
7280
  function resolveProjectIndexDaemonAvailability(projectRoot, indexDir) {
7443
7281
  if (process.env["WRONGSTACK_INDEX_INLINE"] || process.env["WRONGSTACK_INDEX_SERVER"] === "0") {
7444
7282
  return { kind: "inline-requested" };
@@ -7534,6 +7372,8 @@ function delay(ms) {
7534
7372
  function cancellationError(signal) {
7535
7373
  return signal.reason instanceof Error ? signal.reason : new Error("Indexing cancelled");
7536
7374
  }
7375
+
7376
+ // src/codebase-index/project-server-client.ts
7537
7377
  var ProjectServerConnection = class {
7538
7378
  constructor(projectRoot, indexDir, endpoint) {
7539
7379
  this.projectRoot = projectRoot;
@@ -7664,11 +7504,11 @@ var ProjectServerConnection = class {
7664
7504
  this.close();
7665
7505
  }
7666
7506
  }
7667
- async configure(watchExternal, debounceMs) {
7507
+ async configure(watchExternal, debounceMs, coalesceWindowMs) {
7668
7508
  await this.ensureConnected(true);
7669
7509
  const startedAt = Date.now();
7670
7510
  const result = await this.request(
7671
- { type: "configure", watchExternal, debounceMs },
7511
+ { type: "configure", watchExternal, debounceMs, coalesceWindowMs },
7672
7512
  { timeoutMs: SERVER_CONTROL_TIMEOUT_MS }
7673
7513
  );
7674
7514
  if (isProjectIndexServerHealth(result.health)) {
@@ -7712,7 +7552,7 @@ var ProjectServerConnection = class {
7712
7552
  currentAuthToken() {
7713
7553
  if (this.authToken === void 0) {
7714
7554
  try {
7715
- const raw = fs11.readFileSync(
7555
+ const raw = fs12.readFileSync(
7716
7556
  projectIndexServerMetadataPath(this.projectRoot, this.indexDir),
7717
7557
  "utf8"
7718
7558
  );
@@ -8028,11 +7868,11 @@ var ProjectServerConnection = class {
8028
7868
  if (!url) throw new Error("built codebase-index project server is unavailable");
8029
7869
  if (process.platform !== "win32") {
8030
7870
  try {
8031
- fs11.rmSync(this.endpoint, { force: true });
7871
+ fs12.rmSync(this.endpoint, { force: true });
8032
7872
  } catch {
8033
7873
  }
8034
7874
  }
8035
- const args = [fileURLToPath4(url), "--project-root", this.projectRoot];
7875
+ const args = [fileURLToPath5(url), "--project-root", this.projectRoot];
8036
7876
  if (this.indexDir) args.push("--index-dir", this.indexDir);
8037
7877
  const child = spawn3(process.execPath, args, {
8038
7878
  detached: true,
@@ -8052,8 +7892,8 @@ var ProjectServerConnection = class {
8052
7892
  process.kill(pid);
8053
7893
  const metadataPath = projectIndexServerMetadataPath(this.projectRoot, this.indexDir);
8054
7894
  try {
8055
- const metadata = JSON.parse(fs11.readFileSync(metadataPath, "utf8"));
8056
- if (metadata.pid === pid) fs11.rmSync(metadataPath, { force: true });
7895
+ const metadata = JSON.parse(fs12.readFileSync(metadataPath, "utf8"));
7896
+ if (metadata.pid === pid) fs12.rmSync(metadataPath, { force: true });
8057
7897
  } catch {
8058
7898
  }
8059
7899
  return true;
@@ -8070,10 +7910,12 @@ function forgetConnection(endpoint, connection) {
8070
7910
  connection.close();
8071
7911
  connectionStates.delete(endpoint);
8072
7912
  if (latestConnectionState.endpoint !== endpoint) return;
8073
- latestConnectionState = [...connectionStates.values()].at(-1) ?? {
8074
- status: isProjectIndexServerAvailable() ? "offline" : "unavailable",
8075
- connected: false
8076
- };
7913
+ setLatestConnectionState(
7914
+ [...connectionStates.values()].at(-1) ?? {
7915
+ status: isProjectIndexServerAvailable() ? "offline" : "unavailable",
7916
+ connected: false
7917
+ }
7918
+ );
8077
7919
  }
8078
7920
  function trimConnectionCache(protectedConnection) {
8079
7921
  if (connections.size <= MAX_CACHED_CONNECTIONS) return;
@@ -8152,7 +7994,7 @@ function resolveWorkerUrl() {
8152
7994
  for (const rel of ["./worker.js", "./codebase-index/worker.js"]) {
8153
7995
  try {
8154
7996
  const url = new URL(rel, import.meta.url);
8155
- if (url.protocol === "file:" && fs12.existsSync(fileURLToPath5(url))) return url;
7997
+ if (url.protocol === "file:" && fs13.existsSync(fileURLToPath6(url))) return url;
8156
7998
  } catch {
8157
7999
  }
8158
8000
  }
@@ -8405,7 +8247,7 @@ var readTool = {
8405
8247
  const shouldIncludeSymbols = input.includeSymbols === true || input.includeSymbols !== false && ctx.meta[ADVANCED_MODE_META_KEY] === true;
8406
8248
  let stat3;
8407
8249
  try {
8408
- stat3 = await fs13.stat(absPath);
8250
+ stat3 = await fs14.stat(absPath);
8409
8251
  } catch (err) {
8410
8252
  const code = err.code;
8411
8253
  if (code === "ENOENT") {
@@ -8457,7 +8299,7 @@ var readTool = {
8457
8299
  ...symResult2?.symbols ? { symbols: symResult2.symbols } : {}
8458
8300
  };
8459
8301
  }
8460
- const buf = await fs13.readFile(absPath);
8302
+ const buf = await fs14.readFile(absPath);
8461
8303
  if (isBinaryBuffer(buf)) {
8462
8304
  throw new FsError({
8463
8305
  message: `read: "${input.path}" appears to be binary`,