@remnic/coding-graph 9.3.759

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 (86) hide show
  1. package/README.md +130 -0
  2. package/dist/chunk-5I2DBHOQ.js +1042 -0
  3. package/dist/chunk-5I2DBHOQ.js.map +1 -0
  4. package/dist/chunk-CPYJACC5.js +1838 -0
  5. package/dist/chunk-CPYJACC5.js.map +1 -0
  6. package/dist/chunk-ZVCMIM4T.js +216 -0
  7. package/dist/chunk-ZVCMIM4T.js.map +1 -0
  8. package/dist/cypher/query-parser.d.ts +253 -0
  9. package/dist/cypher/query-parser.js +17 -0
  10. package/dist/cypher/query-parser.js.map +1 -0
  11. package/dist/graph-schema.d.ts +84 -0
  12. package/dist/graph-schema.js +17 -0
  13. package/dist/graph-schema.js.map +1 -0
  14. package/dist/graph-store.d.ts +938 -0
  15. package/dist/graph-store.js +16 -0
  16. package/dist/graph-store.js.map +1 -0
  17. package/dist/index.d.ts +1953 -0
  18. package/dist/index.js +3509 -0
  19. package/dist/index.js.map +1 -0
  20. package/grammars/tree-sitter-bash.wasm +0 -0
  21. package/grammars/tree-sitter-c.wasm +0 -0
  22. package/grammars/tree-sitter-c_sharp.wasm +0 -0
  23. package/grammars/tree-sitter-cpp.wasm +0 -0
  24. package/grammars/tree-sitter-go.wasm +0 -0
  25. package/grammars/tree-sitter-java.wasm +0 -0
  26. package/grammars/tree-sitter-javascript.wasm +0 -0
  27. package/grammars/tree-sitter-kotlin.wasm +0 -0
  28. package/grammars/tree-sitter-php.wasm +0 -0
  29. package/grammars/tree-sitter-python.wasm +0 -0
  30. package/grammars/tree-sitter-ruby.wasm +0 -0
  31. package/grammars/tree-sitter-rust.wasm +0 -0
  32. package/grammars/tree-sitter-swift.wasm +0 -0
  33. package/grammars/tree-sitter-tsx.wasm +0 -0
  34. package/grammars/tree-sitter-typescript.wasm +0 -0
  35. package/package.json +79 -0
  36. package/src/co-change.test.ts +175 -0
  37. package/src/co-change.ts +167 -0
  38. package/src/cypher/query-parser.test.ts +1107 -0
  39. package/src/cypher/query-parser.ts +1692 -0
  40. package/src/detect-changes.test.ts +533 -0
  41. package/src/detect-changes.ts +367 -0
  42. package/src/engine/emit.ts +556 -0
  43. package/src/engine/engine.test.ts +1417 -0
  44. package/src/engine/engine.ts +182 -0
  45. package/src/engine/extractors.ts +486 -0
  46. package/src/engine/fixtures.ts +364 -0
  47. package/src/engine/language-sniff.ts +56 -0
  48. package/src/engine/parser-backend.ts +206 -0
  49. package/src/engine/utf16-offsets.ts +68 -0
  50. package/src/git-invoker.test.ts +116 -0
  51. package/src/git-invoker.ts +426 -0
  52. package/src/graph-schema.test.ts +541 -0
  53. package/src/graph-schema.ts +383 -0
  54. package/src/graph-store-pr2.test.ts +1879 -0
  55. package/src/graph-store.test.ts +1420 -0
  56. package/src/graph-store.ts +3489 -0
  57. package/src/index-status.test.ts +303 -0
  58. package/src/index-status.ts +135 -0
  59. package/src/index.ts +384 -0
  60. package/src/lsp/byte-position.ts +173 -0
  61. package/src/lsp/characterization.test.ts +174 -0
  62. package/src/lsp/client.test.ts +275 -0
  63. package/src/lsp/client.ts +484 -0
  64. package/src/lsp/config.ts +219 -0
  65. package/src/lsp/degradation.ts +86 -0
  66. package/src/lsp/fixtures/fake-server.mjs +198 -0
  67. package/src/lsp/framing.test.ts +180 -0
  68. package/src/lsp/framing.ts +177 -0
  69. package/src/lsp/resolution.test.ts +497 -0
  70. package/src/lsp/resolution.ts +483 -0
  71. package/src/lsp/status.ts +140 -0
  72. package/src/lsp/types.ts +167 -0
  73. package/src/reindex.test.ts +1038 -0
  74. package/src/reindex.ts +908 -0
  75. package/src/row-types.ts +45 -0
  76. package/src/semantic/canonical-text.test.ts +150 -0
  77. package/src/semantic/canonical-text.ts +219 -0
  78. package/src/semantic/config.ts +235 -0
  79. package/src/semantic/index.ts +78 -0
  80. package/src/semantic/minhash.test.ts +197 -0
  81. package/src/semantic/minhash.ts +261 -0
  82. package/src/semantic/semantic-query.ts +173 -0
  83. package/src/semantic/semantic.test.ts +1315 -0
  84. package/src/semantic/similarity.ts +268 -0
  85. package/src/semantic/types.ts +145 -0
  86. package/src/semantic/vectors.ts +235 -0
@@ -0,0 +1,1315 @@
1
+ /**
2
+ * Semantic-layer integration tests (issue #1556 steps 1–6 done-when).
3
+ *
4
+ * Covers:
5
+ * - Cache-hit: unchanged symbol on reindex → zero embed calls (counting
6
+ * stub provider, rule 33).
7
+ * - Gate-off parity: semantic.enabled=false → zero vectors-table writes,
8
+ * zero provider calls.
9
+ * - Degradation matrix: provider missing / timeout / malformed vector →
10
+ * three distinct {ok:false} codes (rule 34).
11
+ * - SIMILAR_TO pipeline end-to-end with a stub provider.
12
+ * - Budgets: maxSymbolsPerRun respected.
13
+ *
14
+ * Uses a real GraphStore backed by a temp SQLite DB + a counting stub
15
+ * HostEmbeddingProvider (rule 33 — signature matches the interface).
16
+ */
17
+ import assert from "node:assert/strict";
18
+ import { mkdtemp, rm, writeFile } from "node:fs/promises";
19
+ import { tmpdir } from "node:os";
20
+ import path from "node:path";
21
+ import test from "node:test";
22
+
23
+ import type { HostEmbeddingProvider } from "@remnic/core/host-embedding-provider";
24
+
25
+ import { GraphStore, nodeIdFor } from "../graph-store.js";
26
+ import type { StoreFileIR } from "../graph-store.js";
27
+ import {
28
+ indexSymbolVectors,
29
+ modelIdFor,
30
+ computeSimilarTo,
31
+ similarEdgesToEdgeIR,
32
+ semanticQuery,
33
+ resolveSemanticConfig,
34
+ DEFAULT_SIMILAR_TO_THRESHOLD,
35
+ DEFAULT_MAX_SYMBOLS_PER_RUN,
36
+ DEFAULT_SEMANTIC_QUERY_LIMIT,
37
+ DEFAULT_CANONICAL_BODY_LINES,
38
+ type SemanticConfig,
39
+ } from "./index.js";
40
+
41
+ // ──────────────────────────────────────────────────────────────────────────
42
+ // Counting stub provider — signature matches HostEmbeddingProvider (rule 33).
43
+ // ──────────────────────────────────────────────────────────────────────────
44
+
45
+ function createCountingProvider(
46
+ opts: { failMode?: "timeout" | "null" | "throw" | "malformed" } = {},
47
+ ): HostEmbeddingProvider & { embedCount: number; reset(): void } {
48
+ let embedCount = 0;
49
+ const dims = 8;
50
+ return {
51
+ id: "stub-embedder",
52
+ model: "stub-model-v1",
53
+ dimensions: dims,
54
+ async embed(text: string) {
55
+ embedCount += 1;
56
+ if (opts.failMode === "timeout") {
57
+ throw new (class extends Error {
58
+ readonly code = "ETIMEDOUT";
59
+ })("stub timeout");
60
+ }
61
+ if (opts.failMode === "throw") {
62
+ throw new Error("stub throw");
63
+ }
64
+ if (opts.failMode === "null") {
65
+ return null;
66
+ }
67
+ if (opts.failMode === "malformed") {
68
+ return [NaN, Infinity, "bad" as unknown as number] as unknown as ArrayLike<number>;
69
+ }
70
+ // Deterministic pseudo-embedding: hash the text to a fixed-size vector.
71
+ // Two similar texts produce similar vectors (cosine close to 1.0) so
72
+ // the SIMILAR_TO pipeline can confirm clones.
73
+ const vec = new Array<number>(dims).fill(0);
74
+ for (let i = 0; i < text.length; i++) {
75
+ vec[i % dims]! += text.charCodeAt(i)!;
76
+ }
77
+ // Normalize to unit length so cosine is meaningful.
78
+ const norm = Math.sqrt(vec.reduce((s, v) => s + v * v, 0)) || 1;
79
+ return vec.map((v) => v / norm);
80
+ },
81
+ reset() {
82
+ embedCount = 0;
83
+ },
84
+ get embedCount2() {
85
+ return embedCount;
86
+ },
87
+ } as unknown as HostEmbeddingProvider & { embedCount: number; reset(): void };
88
+ }
89
+
90
+ // Fix the counting provider properly.
91
+ function countingProvider(
92
+ opts: { failMode?: "timeout" | "null" | "throw" | "malformed" } = {},
93
+ ): HostEmbeddingProvider & { embedCount: number; reset(): void } {
94
+ let embedCount = 0;
95
+ const dims = 8;
96
+ const provider: HostEmbeddingProvider & { embedCount: number; reset(): void } = {
97
+ id: "stub-embedder",
98
+ model: "stub-model-v1",
99
+ dimensions: dims,
100
+ embedCount: 0,
101
+ reset() {
102
+ embedCount = 0;
103
+ provider.embedCount = 0;
104
+ },
105
+ async embed(text: string) {
106
+ embedCount += 1;
107
+ provider.embedCount = embedCount;
108
+ if (opts.failMode === "timeout") throw new Error("stub timeout");
109
+ if (opts.failMode === "throw") throw new Error("stub throw");
110
+ if (opts.failMode === "null") return null;
111
+ if (opts.failMode === "malformed") {
112
+ return [NaN, Infinity] as unknown as ArrayLike<number>;
113
+ }
114
+ const vec = new Array<number>(dims).fill(0);
115
+ for (let i = 0; i < text.length; i++) {
116
+ vec[i % dims]! += text.charCodeAt(i)!;
117
+ }
118
+ const norm = Math.sqrt(vec.reduce((s, v) => s + v * v, 0)) || 1;
119
+ return vec.map((v) => v / norm);
120
+ },
121
+ };
122
+ return provider;
123
+ }
124
+
125
+ // ──────────────────────────────────────────────────────────────────────────
126
+ // Helpers
127
+ // ──────────────────────────────────────────────────────────────────────────
128
+
129
+ const ENABLED_CONFIG: SemanticConfig = resolveSemanticConfig({ enabled: true });
130
+ const DISABLED_CONFIG: SemanticConfig = resolveSemanticConfig({ enabled: false });
131
+
132
+ function makeFileIR(filePath: string, symbols: { name: string; qname: string; kind: string; start: number; end: number }[], content: string): StoreFileIR {
133
+ return {
134
+ path: filePath,
135
+ language: "typescript" as never,
136
+ contentHash: "0".repeat(64),
137
+ symbols: symbols.map((s) => ({
138
+ kind: s.kind as never,
139
+ name: s.name,
140
+ qualifiedName: s.qname,
141
+ span: { startByte: s.start, endByte: s.end },
142
+ })),
143
+ imports: [],
144
+ exports: [],
145
+ callSites: [],
146
+ routes: [],
147
+ } as unknown as StoreFileIR;
148
+ }
149
+
150
+ async function tempStore(): Promise<{ store: GraphStore; dir: string; cleanup: () => Promise<void> }> {
151
+ const dir = await mkdtemp(path.join(tmpdir(), "semtest-"));
152
+ const dbPath = path.join(dir, "graph.db");
153
+ const store = await GraphStore.open({ dbPath, repoRoot: dir });
154
+ const cleanup = async () => {
155
+ await store.close();
156
+ await rm(dir, { recursive: true, force: true });
157
+ };
158
+ return { store, dir, cleanup };
159
+ }
160
+
161
+ // ──────────────────────────────────────────────────────────────────────────
162
+ // Cache-hit: unchanged symbol → zero embed calls on reindex (rule 37)
163
+ // ──────────────────────────────────────────────────────────────────────────
164
+
165
+ test("cache-hit: unchanged symbol on reindex → zero embed calls", async () => {
166
+ const { store, dir, cleanup } = await tempStore();
167
+ try {
168
+ const src = `function hello() { return 42; }`;
169
+ await writeFile(path.join(dir, "a.ts"), src);
170
+ const ir = makeFileIR("a.ts", [
171
+ { name: "hello", qname: "mod.hello", kind: "function", start: 0, end: src.length },
172
+ ], src);
173
+ await store.upsertFileBatch([ir]);
174
+
175
+ const provider = countingProvider();
176
+ // First index: embeds once.
177
+ const r1 = await indexSymbolVectors({ store, provider, repoRoot: dir, config: ENABLED_CONFIG });
178
+ assert.equal(r1.ok, true);
179
+ if (r1.ok) {
180
+ assert.equal(r1.embedded, 1);
181
+ assert.equal(r1.cached, 0);
182
+ }
183
+ assert.equal(provider.embedCount, 1, "first index should embed once");
184
+
185
+ // Re-index: cache hit → zero embeds.
186
+ provider.reset();
187
+ const r2 = await indexSymbolVectors({ store, provider, repoRoot: dir, config: ENABLED_CONFIG });
188
+ assert.equal(r2.ok, true);
189
+ if (r2.ok) {
190
+ assert.equal(r2.embedded, 0);
191
+ assert.equal(r2.cached, 1);
192
+ }
193
+ assert.equal(provider.embedCount, 0, "reindex should use cache — zero embeds");
194
+ } finally {
195
+ await cleanup();
196
+ }
197
+ });
198
+
199
+ // ──────────────────────────────────────────────────────────────────────────
200
+ // Gate-off parity: semantic disabled → zero writes, zero calls
201
+ // ──────────────────────────────────────────────────────────────────────────
202
+
203
+ test("gate-off: semantic.enabled=false → zero vectors writes, zero provider calls", async () => {
204
+ const { store, dir, cleanup } = await tempStore();
205
+ try {
206
+ const src = `function foo() { return 1; }`;
207
+ await writeFile(path.join(dir, "a.ts"), src);
208
+ const ir = makeFileIR("a.ts", [
209
+ { name: "foo", qname: "mod.foo", kind: "function", start: 0, end: src.length },
210
+ ], src);
211
+ await store.upsertFileBatch([ir]);
212
+
213
+ const provider = countingProvider();
214
+ const r = await indexSymbolVectors({ store, provider, repoRoot: dir, config: DISABLED_CONFIG });
215
+ assert.equal(r.ok, false);
216
+ if (!r.ok) assert.equal(r.code, "semantic_disabled");
217
+ assert.equal(provider.embedCount, 0, "disabled → zero provider calls");
218
+ // No vectors in the table.
219
+ const vecs = store.readAllSymbolVectors("stub-model-v1");
220
+ assert.equal(vecs.length, 0, "disabled → zero vectors written");
221
+ } finally {
222
+ await cleanup();
223
+ }
224
+ });
225
+
226
+ // ──────────────────────────────────────────────────────────────────────────
227
+ // Degradation matrix: provider missing / timeout / malformed → distinct codes
228
+ // ──────────────────────────────────────────────────────────────────────────
229
+
230
+ test("degradation: no provider → provider_unavailable", async () => {
231
+ const { store, dir, cleanup } = await tempStore();
232
+ try {
233
+ const r = await indexSymbolVectors({ store, provider: undefined, repoRoot: dir, config: ENABLED_CONFIG });
234
+ assert.equal(r.ok, false);
235
+ if (!r.ok) assert.equal(r.code, "provider_unavailable");
236
+ } finally {
237
+ await cleanup();
238
+ }
239
+ });
240
+
241
+ test("degradation: provider returns null → skipped, not crash", async () => {
242
+ const { store, dir, cleanup } = await tempStore();
243
+ try {
244
+ const src = `function foo() { return 1; }`;
245
+ await writeFile(path.join(dir, "a.ts"), src);
246
+ const ir = makeFileIR("a.ts", [
247
+ { name: "foo", qname: "mod.foo", kind: "function", start: 0, end: src.length },
248
+ ], src);
249
+ await store.upsertFileBatch([ir]);
250
+
251
+ const provider = countingProvider({ failMode: "null" });
252
+ const r = await indexSymbolVectors({ store, provider, repoRoot: dir, config: ENABLED_CONFIG });
253
+ assert.equal(r.ok, true);
254
+ if (r.ok) {
255
+ assert.equal(r.embedded, 0);
256
+ assert.equal(r.skipped, 1);
257
+ }
258
+ } finally {
259
+ await cleanup();
260
+ }
261
+ });
262
+
263
+ test("degradation: provider throws → skipped, not crash", async () => {
264
+ const { store, dir, cleanup } = await tempStore();
265
+ try {
266
+ const src = `function foo() { return 1; }`;
267
+ await writeFile(path.join(dir, "a.ts"), src);
268
+ const ir = makeFileIR("a.ts", [
269
+ { name: "foo", qname: "mod.foo", kind: "function", start: 0, end: src.length },
270
+ ], src);
271
+ await store.upsertFileBatch([ir]);
272
+
273
+ const provider = countingProvider({ failMode: "throw" });
274
+ const r = await indexSymbolVectors({ store, provider, repoRoot: dir, config: ENABLED_CONFIG });
275
+ assert.equal(r.ok, true);
276
+ if (r.ok) assert.equal(r.skipped, 1);
277
+ } finally {
278
+ await cleanup();
279
+ }
280
+ });
281
+
282
+ test("degradation: malformed vector (NaN/Infinity) → skipped", async () => {
283
+ const { store, dir, cleanup } = await tempStore();
284
+ try {
285
+ const src = `function foo() { return 1; }`;
286
+ await writeFile(path.join(dir, "a.ts"), src);
287
+ const ir = makeFileIR("a.ts", [
288
+ { name: "foo", qname: "mod.foo", kind: "function", start: 0, end: src.length },
289
+ ], src);
290
+ await store.upsertFileBatch([ir]);
291
+
292
+ const provider = countingProvider({ failMode: "malformed" });
293
+ const r = await indexSymbolVectors({ store, provider, repoRoot: dir, config: ENABLED_CONFIG });
294
+ assert.equal(r.ok, true);
295
+ if (r.ok) {
296
+ // normalizeHostEmbeddingVector rejects NaN/Infinity → skipped.
297
+ assert.equal(r.skipped, 1);
298
+ assert.equal(r.embedded, 0);
299
+ }
300
+ } finally {
301
+ await cleanup();
302
+ }
303
+ });
304
+
305
+ // ──────────────────────────────────────────────────────────────────────────
306
+ // Budgets: maxSymbolsPerRun respected
307
+ // ──────────────────────────────────────────────────────────────────────────
308
+
309
+ test("budget: maxSymbolsPerRun=2 limits embedding to 2 symbols", async () => {
310
+ const { store, dir, cleanup } = await tempStore();
311
+ try {
312
+ const src = `function a() { return 1; }\nfunction b() { return 2; }\nfunction c() { return 3; }\nfunction d() { return 4; }`;
313
+ await writeFile(path.join(dir, "a.ts"), src);
314
+ const ir = makeFileIR("a.ts", [
315
+ { name: "a", qname: "mod.a", kind: "function", start: 0, end: 24 },
316
+ { name: "b", qname: "mod.b", kind: "function", start: 25, end: 50 },
317
+ { name: "c", qname: "mod.c", kind: "function", start: 51, end: 76 },
318
+ { name: "d", qname: "mod.d", kind: "function", start: 77, end: 102 },
319
+ ], src);
320
+ await store.upsertFileBatch([ir]);
321
+
322
+ const provider = countingProvider();
323
+ const config = resolveSemanticConfig({ enabled: true, maxSymbolsPerRun: 2 });
324
+ const r = await indexSymbolVectors({ store, provider, repoRoot: dir, config });
325
+ assert.equal(r.ok, true);
326
+ if (r.ok) {
327
+ assert.ok(r.embedded <= 2, `should embed at most 2, got ${r.embedded}`);
328
+ }
329
+ assert.ok(provider.embedCount <= 2, `provider called at most 2 times, got ${provider.embedCount}`);
330
+ } finally {
331
+ await cleanup();
332
+ }
333
+ });
334
+
335
+ test("budget: maxSymbolsPerRun=0 means unlimited", async () => {
336
+ const { store, dir, cleanup } = await tempStore();
337
+ try {
338
+ const src = `function a() { return 1; }\nfunction b() { return 2; }`;
339
+ await writeFile(path.join(dir, "a.ts"), src);
340
+ const ir = makeFileIR("a.ts", [
341
+ { name: "a", qname: "mod.a", kind: "function", start: 0, end: 24 },
342
+ { name: "b", qname: "mod.b", kind: "function", start: 25, end: 50 },
343
+ ], src);
344
+ await store.upsertFileBatch([ir]);
345
+
346
+ const provider = countingProvider();
347
+ const r = await indexSymbolVectors({ store, provider, repoRoot: dir, config: ENABLED_CONFIG });
348
+ assert.equal(r.ok, true);
349
+ if (r.ok) assert.equal(r.embedded, 2, "unlimited budget embeds all");
350
+ } finally {
351
+ await cleanup();
352
+ }
353
+ });
354
+
355
+ // ──────────────────────────────────────────────────────────────────────────
356
+ // SIMILAR_TO pipeline end-to-end
357
+ // ──────────────────────────────────────────────────────────────────────────
358
+
359
+ test("SIMILAR_TO: computeSimilarTo with gate-off returns semantic_disabled", () => {
360
+ const r = computeSimilarTo({ store: null as never, provider: undefined, config: DISABLED_CONFIG });
361
+ assert.equal(r.ok, false);
362
+ if (!r.ok) assert.equal(r.code, "semantic_disabled");
363
+ });
364
+
365
+ test("SIMILAR_TO: similarEdgesToEdgeIR maps to EdgeIR with semantic provenance + node ids", () => {
366
+ const edges = similarEdgesToEdgeIR([
367
+ {
368
+ srcNodeId: "id-a",
369
+ dstNodeId: "id-b",
370
+ srcQualifiedName: "mod.a",
371
+ dstQualifiedName: "mod.b",
372
+ confidence: 0.95,
373
+ confirmed: true,
374
+ },
375
+ ]);
376
+ assert.equal(edges.length, 1);
377
+ assert.equal(edges[0]!.type, "SIMILAR_TO");
378
+ assert.equal(edges[0]!.provenance, "semantic");
379
+ assert.equal(edges[0]!.confidence, 0.95);
380
+ // Node ids are carried through so the store resolves endpoints by id
381
+ // (issue #1677 — duplicate qualified-name support).
382
+ assert.equal(edges[0]!.srcNodeId, "id-a");
383
+ assert.equal(edges[0]!.dstNodeId, "id-b");
384
+ });
385
+
386
+ // ──────────────────────────────────────────────────────────────────────────
387
+ // semantic_query degradation matrix
388
+ // ──────────────────────────────────────────────────────────────────────────
389
+
390
+ test("semanticQuery: disabled → semantic_disabled", async () => {
391
+ const { store, dir, cleanup } = await tempStore();
392
+ try {
393
+ const r = await semanticQuery({ store, provider: undefined, repoRoot: dir, config: DISABLED_CONFIG, query: "test" });
394
+ assert.equal(r.ok, false);
395
+ if (!r.ok) assert.equal(r.code, "semantic_disabled");
396
+ } finally {
397
+ await cleanup();
398
+ }
399
+ });
400
+
401
+ test("semanticQuery: no provider → provider_unavailable", async () => {
402
+ const { store, dir, cleanup } = await tempStore();
403
+ try {
404
+ const r = await semanticQuery({ store, provider: undefined, repoRoot: dir, config: ENABLED_CONFIG, query: "test" });
405
+ assert.equal(r.ok, false);
406
+ if (!r.ok) assert.equal(r.code, "provider_unavailable");
407
+ } finally {
408
+ await cleanup();
409
+ }
410
+ });
411
+
412
+ test("semanticQuery: empty query → invalid_query", async () => {
413
+ const { store, dir, cleanup } = await tempStore();
414
+ try {
415
+ const provider = countingProvider();
416
+ const r = await semanticQuery({ store, provider, repoRoot: dir, config: ENABLED_CONFIG, query: "" });
417
+ assert.equal(r.ok, false);
418
+ if (!r.ok) assert.equal(r.code, "invalid_query");
419
+ } finally {
420
+ await cleanup();
421
+ }
422
+ });
423
+
424
+ test("semanticQuery: no vectors in table → no_vectors", async () => {
425
+ const { store, dir, cleanup } = await tempStore();
426
+ try {
427
+ const provider = countingProvider();
428
+ const r = await semanticQuery({ store, provider, repoRoot: dir, config: ENABLED_CONFIG, query: "test" });
429
+ assert.equal(r.ok, false);
430
+ if (!r.ok) assert.equal(r.code, "no_vectors");
431
+ } finally {
432
+ await cleanup();
433
+ }
434
+ });
435
+
436
+ test("semanticQuery: with vectors → returns ranked hits", async () => {
437
+ const { store, dir, cleanup } = await tempStore();
438
+ try {
439
+ const src = `function greet(name) { return "hello " + name; }`;
440
+ await writeFile(path.join(dir, "a.ts"), src);
441
+ const ir = makeFileIR("a.ts", [
442
+ { name: "greet", qname: "mod.greet", kind: "function", start: 0, end: src.length },
443
+ ], src);
444
+ await store.upsertFileBatch([ir]);
445
+
446
+ const provider = countingProvider();
447
+ await indexSymbolVectors({ store, provider, repoRoot: dir, config: ENABLED_CONFIG });
448
+
449
+ const r = await semanticQuery({ store, provider, repoRoot: dir, config: ENABLED_CONFIG, query: "greet function" });
450
+ assert.equal(r.ok, true);
451
+ if (r.ok) {
452
+ assert.ok(r.hits.length >= 1);
453
+ assert.equal(r.hits[0]!.qualifiedName, "mod.greet");
454
+ assert.ok(r.hits[0]!.score > 0);
455
+ }
456
+ } finally {
457
+ await cleanup();
458
+ }
459
+ });
460
+
461
+
462
+ // ──────────────────────────────────────────────────────────────────────────
463
+ // Budget applies to embed WORK, not the candidate list — a bounded run
464
+ // must make progress across runs (cursor Bugbot + chatgpt-codex-connector
465
+ // P2: bounded index could remain permanently incomplete).
466
+ // ──────────────────────────────────────────────────────────────────────────
467
+
468
+ test("budget: bounded run makes progress across runs (not stuck on cached prefix)", async () => {
469
+ const { store, dir, cleanup } = await tempStore();
470
+ try {
471
+ const src = "function a() { return 1; }\nfunction b() { return 2; }\nfunction c() { return 3; }\nfunction d() { return 4; }";
472
+ await writeFile(path.join(dir, "a.ts"), src);
473
+ const ir = makeFileIR("a.ts", [
474
+ { name: "a", qname: "mod.a", kind: "function", start: 0, end: 24 },
475
+ { name: "b", qname: "mod.b", kind: "function", start: 25, end: 50 },
476
+ { name: "c", qname: "mod.c", kind: "function", start: 51, end: 76 },
477
+ { name: "d", qname: "mod.d", kind: "function", start: 77, end: 102 },
478
+ ], src);
479
+ await store.upsertFileBatch([ir]);
480
+
481
+ const provider = countingProvider();
482
+ const config = resolveSemanticConfig({ enabled: true, maxSymbolsPerRun: 2 });
483
+
484
+ // Run 1: embeds the first 2 (a, b).
485
+ const r1 = await indexSymbolVectors({ store, provider, repoRoot: dir, config });
486
+ assert.equal(r1.ok, true);
487
+ if (r1.ok) assert.equal(r1.embedded, 2, "run 1 embeds 2");
488
+ assert.equal(provider.embedCount, 2);
489
+
490
+ // Run 2: a, b are cached (no embed); c, d are now embedded. Before the
491
+ // fix this re-sliced the same alphabetical prefix and embedded 0 new
492
+ // symbols every run.
493
+ const r2 = await indexSymbolVectors({ store, provider, repoRoot: dir, config });
494
+ assert.equal(r2.ok, true);
495
+ if (r2.ok) {
496
+ assert.equal(r2.embedded, 2, "run 2 embeds the NEXT 2 (progress)");
497
+ assert.equal(r2.cached, 2, "run 2 skips the 2 already-cached symbols");
498
+ }
499
+ assert.equal(provider.embedCount, 4, "4 total embeds across the two runs");
500
+
501
+ // Run 3: everything cached — zero new embeds.
502
+ provider.reset();
503
+ const r3 = await indexSymbolVectors({ store, provider, repoRoot: dir, config });
504
+ if (r3.ok) assert.equal(r3.embedded, 0, "run 3: all cached, no new embeds");
505
+ assert.equal(provider.embedCount, 0);
506
+ } finally {
507
+ await cleanup();
508
+ }
509
+ });
510
+
511
+ // ──────────────────────────────────────────────────────────────────────────
512
+ // computeSimilarTo refuses to guess when it has neither bodies nor a
513
+ // repoRoot — silent qname-MinHashing would miss real clones (chatgpt-
514
+ // codex-connector P2: 'Require source bodies before MinHashing').
515
+ // ──────────────────────────────────────────────────────────────────────────
516
+
517
+ test("SIMILAR_TO: no bodies and no repoRoot → repo_root_unset (not silent qname MinHash)", () => {
518
+ const r = computeSimilarTo({ store: null as never, provider: undefined, config: ENABLED_CONFIG });
519
+ assert.equal(r.ok, false);
520
+ if (!r.ok) assert.equal(r.code, "repo_root_unset");
521
+ });
522
+
523
+ // ──────────────────────────────────────────────────────────────────────────
524
+ // clearSemanticSimilarToEdges: recompute replace semantics — stale
525
+ // SIMILAR_TO edges are removed while non-semantic edges survive
526
+ // (chatgpt-codex-connector P2: 'Replace old SIMILAR_TO edges on recompute').
527
+ // ──────────────────────────────────────────────────────────────────────────
528
+
529
+ test("SIMILAR_TO: clearSemanticSimilarToEdges removes only semantic SIMILAR_TO edges", async () => {
530
+ const { store, dir, cleanup } = await tempStore();
531
+ try {
532
+ const src = "function f() { return 1; }\nfunction g() { return 2; }";
533
+ await writeFile(path.join(dir, "a.ts"), src);
534
+ const ir = makeFileIR("a.ts", [
535
+ { name: "f", qname: "mod.f", kind: "function", start: 0, end: 23 },
536
+ { name: "g", qname: "mod.g", kind: "function", start: 24, end: 47 },
537
+ ], src);
538
+ await store.upsertFileBatch([ir]);
539
+
540
+ // Seed a semantic SIMILAR_TO edge AND a structural CALLS edge f→g.
541
+ await store.upsertEdges([
542
+ { srcQualifiedName: "mod.f", dstQualifiedName: "mod.g", type: "SIMILAR_TO", confidence: 0.93, provenance: "semantic" },
543
+ { srcQualifiedName: "mod.f", dstQualifiedName: "mod.g", type: "CALLS", confidence: 1, provenance: "heuristic" },
544
+ ]);
545
+
546
+ const beforeSimilar = store.traverse({ start: "mod.f", edgeTypes: ["SIMILAR_TO"], maxDepth: 1 });
547
+ assert.equal(beforeSimilar.ok, true);
548
+ if (beforeSimilar.ok) {
549
+ assert.ok(beforeSimilar.hits.some((h) => h.qualifiedName === "mod.g"), "SIMILAR_TO edge present before clear");
550
+ }
551
+
552
+ await store.clearSemanticSimilarToEdges();
553
+
554
+ const afterSimilar = store.traverse({ start: "mod.f", edgeTypes: ["SIMILAR_TO"], maxDepth: 1 });
555
+ assert.equal(afterSimilar.ok, true);
556
+ if (afterSimilar.ok) {
557
+ assert.ok(!afterSimilar.hits.some((h) => h.qualifiedName === "mod.g"), "SIMILAR_TO edge gone after clear");
558
+ }
559
+ // The structural CALLS edge survives.
560
+ const afterCalls = store.traverse({ start: "mod.f", edgeTypes: ["CALLS"], maxDepth: 1 });
561
+ assert.equal(afterCalls.ok, true);
562
+ if (afterCalls.ok) {
563
+ assert.ok(afterCalls.hits.some((h) => h.qualifiedName === "mod.g"), "CALLS edge preserved");
564
+ }
565
+ } finally {
566
+ await cleanup();
567
+ }
568
+ });
569
+
570
+ // ──────────────────────────────────────────────────────────────────────────
571
+ // semantic_query: hit.kind is the node label, not empty (cursor Bugbot:
572
+ // 'Query hits omit symbol kind').
573
+ // ──────────────────────────────────────────────────────────────────────────
574
+
575
+ test("semanticQuery: hits carry the symbol kind", async () => {
576
+ const { store, dir, cleanup } = await tempStore();
577
+ try {
578
+ const src = "function greet(name) { return 'hi ' + name; }";
579
+ await writeFile(path.join(dir, "a.ts"), src);
580
+ const ir = makeFileIR("a.ts", [
581
+ { name: "greet", qname: "mod.greet", kind: "function", start: 0, end: src.length },
582
+ ], src);
583
+ await store.upsertFileBatch([ir]);
584
+
585
+ const provider = countingProvider();
586
+ await indexSymbolVectors({ store, provider, repoRoot: dir, config: ENABLED_CONFIG });
587
+
588
+ const r = await semanticQuery({ store, provider, repoRoot: dir, config: ENABLED_CONFIG, query: "greet" });
589
+ assert.equal(r.ok, true);
590
+ if (r.ok) {
591
+ assert.ok(r.hits.length >= 1);
592
+ assert.equal(r.hits[0]!.kind, "function", "hit.kind should be the node label, not empty");
593
+ }
594
+ } finally {
595
+ await cleanup();
596
+ }
597
+ });
598
+
599
+ // ──────────────────────────────────────────────────────────────────────────
600
+ // semantic_query: snippet hydrates by node id, so a qualified name
601
+ // duplicated across files still resolves the exact node's snippet
602
+ // (chatgpt-codex-connector P2: 'Hydrate snippets by node id as well').
603
+ // ──────────────────────────────────────────────────────────────────────────
604
+
605
+ test("semanticQuery: duplicate qualified name still hydrates a snippet (by node id)", async () => {
606
+ const { store, dir, cleanup } = await tempStore();
607
+ try {
608
+ const srcA = "function dup() { return 1; }";
609
+ const srcB = "function dup() { return 2; }";
610
+ await writeFile(path.join(dir, "a.ts"), srcA);
611
+ await writeFile(path.join(dir, "b.ts"), srcB);
612
+ await store.upsertFileBatch([
613
+ makeFileIR("a.ts", [{ name: "dup", qname: "mod.dup", kind: "function", start: 0, end: srcA.length }], srcA),
614
+ makeFileIR("b.ts", [{ name: "dup", qname: "mod.dup", kind: "function", start: 0, end: srcB.length }], srcB),
615
+ ]);
616
+
617
+ const provider = countingProvider();
618
+ await indexSymbolVectors({ store, provider, repoRoot: dir, config: ENABLED_CONFIG });
619
+
620
+ const r = await semanticQuery({ store, provider, repoRoot: dir, config: ENABLED_CONFIG, query: "dup" });
621
+ assert.equal(r.ok, true);
622
+ if (r.ok) {
623
+ // Both nodes share qname "mod.dup" but distinct node ids; hydrating
624
+ // by node id must yield a real snippet for at least one hit (the old
625
+ // qname path returned 'ambiguous_name' and left every snippet empty).
626
+ const withSnippet = r.hits.filter((h) => h.snippet.length > 0);
627
+ assert.ok(withSnippet.length >= 1, "at least one hit should carry a snippet despite the duplicate name");
628
+ }
629
+ } finally {
630
+ await cleanup();
631
+ }
632
+ });
633
+
634
+
635
+ // ──────────────────────────────────────────────────────────────────────────
636
+ // MinHash-only edges are the documented NO-PROVIDER fallback. When a
637
+ // provider IS configured, a pair missing a vector is skipped (await
638
+ // cosine confirmation) instead of bypassing it with a MinHash-only edge
639
+ // (cursor Bugbot: 'MinHash edges with provider set').
640
+ // ──────────────────────────────────────────────────────────────────────────
641
+
642
+ test("SIMILAR_TO: provider set + missing vectors → no MinHash-only bypass", async () => {
643
+ const { store, cleanup } = await tempStore();
644
+ try {
645
+ const body = "function compute(x, y) { const z = x + y; return z * 2 + x - y; }";
646
+ const bodies = new Map<string, { readonly qualifiedName: string; readonly body: string }>([
647
+ ["n1", { qualifiedName: "mod.a", body }],
648
+ ["n2", { qualifiedName: "mod.b", body }],
649
+ ]);
650
+
651
+ // Provider configured, no vectors → must NOT emit MinHash-only edges.
652
+ const withProvider = computeSimilarTo({
653
+ store,
654
+ provider: countingProvider(),
655
+ config: ENABLED_CONFIG,
656
+ bodies,
657
+ vectors: new Map<string, Float32Array>(),
658
+ });
659
+ assert.equal(withProvider.ok, true);
660
+ if (withProvider.ok) {
661
+ assert.equal(withProvider.minhashOnly, 0, "provider active → no MinHash-only bypass");
662
+ assert.equal(withProvider.edges.length, 0, "no edges when vectors missing and provider active");
663
+ }
664
+
665
+ // No provider → MinHash-only edges are the documented local fallback.
666
+ const noProvider = computeSimilarTo({
667
+ store,
668
+ provider: undefined,
669
+ config: ENABLED_CONFIG,
670
+ bodies,
671
+ });
672
+ assert.equal(noProvider.ok, true);
673
+ if (noProvider.ok) {
674
+ assert.ok(noProvider.minhashOnly >= 1, "no provider → MinHash-only edges emitted");
675
+ }
676
+ } finally {
677
+ await cleanup();
678
+ }
679
+ });
680
+
681
+ // ──────────────────────────────────────────────────────────────────────────
682
+ // Mismatched-dimensionality vectors are excluded from ranking. cosine over
683
+ // the shorter length would otherwise give a misleading partial score to a
684
+ // row from a different model size (cursor Bugbot: 'Mismatched embedding
685
+ // lengths scored').
686
+ // ──────────────────────────────────────────────────────────────────────────
687
+
688
+ test("semanticQuery: mismatched-dims vectors are excluded from ranking", async () => {
689
+ const { store, dir, cleanup } = await tempStore();
690
+ try {
691
+ const src = "function a() { return 1; }\nfunction b() { return 2; }";
692
+ await writeFile(path.join(dir, "a.ts"), src);
693
+ const ir = makeFileIR("a.ts", [
694
+ { name: "a", qname: "mod.a", kind: "function", start: 0, end: 23 },
695
+ { name: "b", qname: "mod.b", kind: "function", start: 24, end: 47 },
696
+ ], src);
697
+ await store.upsertFileBatch([ir]);
698
+
699
+ const provider = countingProvider();
700
+ const modelId = modelIdFor(provider);
701
+ const idA = nodeIdFor({ qualifiedName: "mod.a", filePath: "a.ts", label: "function" });
702
+ const idB = nodeIdFor({ qualifiedName: "mod.b", filePath: "a.ts", label: "function" });
703
+ // a: dims=8 (matches the query provider); b: dims=4 (mismatched → excluded).
704
+ await store.writeSymbolVector({ nodeId: idA, modelId, contentHash: "h".repeat(64), dims: 8, vector: new Float32Array(8) });
705
+ await store.writeSymbolVector({ nodeId: idB, modelId, contentHash: "h".repeat(64), dims: 4, vector: new Float32Array(4) });
706
+
707
+ const r = await semanticQuery({ store, provider, repoRoot: dir, config: ENABLED_CONFIG, query: "a" });
708
+ assert.equal(r.ok, true);
709
+ if (r.ok) {
710
+ const names = r.hits.map((h) => h.qualifiedName);
711
+ assert.ok(names.includes("mod.a"), "matching-dims hit included");
712
+ assert.ok(!names.includes("mod.b"), "mismatched-dims hit excluded");
713
+ }
714
+ } finally {
715
+ await cleanup();
716
+ }
717
+ });
718
+
719
+ // ──────────────────────────────────────────────────────────────────────────
720
+ // Closed store is a distinct degradation code (rule 34), not an empty
721
+ // graph that returns ok:true / no_vectors (cursor Bugbot: 'Closed store
722
+ // reports success').
723
+ // ──────────────────────────────────────────────────────────────────────────
724
+
725
+ test("closed store: indexSymbolVectors returns store_closed (not ok with zeros)", async () => {
726
+ const { store, dir, cleanup } = await tempStore();
727
+ await store.close();
728
+ const r = await indexSymbolVectors({ store, provider: countingProvider(), repoRoot: dir, config: ENABLED_CONFIG });
729
+ assert.equal(r.ok, false);
730
+ if (!r.ok) assert.equal(r.code, "store_closed");
731
+ await cleanup();
732
+ });
733
+
734
+ test("closed store: semanticQuery returns store_closed (not no_vectors)", async () => {
735
+ const { store, dir, cleanup } = await tempStore();
736
+ await store.close();
737
+ const r = await semanticQuery({ store, provider: countingProvider(), repoRoot: dir, config: ENABLED_CONFIG, query: "x" });
738
+ assert.equal(r.ok, false);
739
+ if (!r.ok) assert.equal(r.code, "store_closed");
740
+ await cleanup();
741
+ });
742
+
743
+ // ──────────────────────────────────────────────────────────────────────────
744
+ // computeSimilarTo honors the closed store (cursor Bugbot: 'SimilarTo
745
+ // ignores closed store').
746
+ // ──────────────────────────────────────────────────────────────────────────
747
+
748
+ test("closed store: computeSimilarTo returns store_closed", async () => {
749
+ const { store, dir, cleanup } = await tempStore();
750
+ await store.close();
751
+ const r = computeSimilarTo({
752
+ store,
753
+ provider: undefined,
754
+ config: ENABLED_CONFIG,
755
+ repoRoot: dir,
756
+ bodies: new Map(),
757
+ });
758
+ assert.equal(r.ok, false);
759
+ if (!r.ok) assert.equal(r.code, "store_closed");
760
+ await cleanup();
761
+ });
762
+
763
+ // ──────────────────────────────────────────────────────────────────────────
764
+ // Cosine confirmation requires matching dims (cursor Bugbot: 'SimilarTo
765
+ // skips embedding length check').
766
+ // ──────────────────────────────────────────────────────────────────────────
767
+
768
+ test("SIMILAR_TO: cosine confirmation skips mismatched-dims pairs", async () => {
769
+ const { store, cleanup } = await tempStore();
770
+ try {
771
+ const body = "function compute(x, y) { const z = x + y; return z * 2; }";
772
+ const bodies = new Map<string, { readonly qualifiedName: string; readonly body: string }>([
773
+ ["n1", { qualifiedName: "mod.a", body }],
774
+ ["n2", { qualifiedName: "mod.b", body }],
775
+ ]);
776
+ const vectors = new Map<string, Float32Array>([
777
+ ["n1", new Float32Array(8)],
778
+ ["n2", new Float32Array(4)], // mismatched dims
779
+ ]);
780
+ // Pair has vectors but mismatched dims → not cosine-confirmed; provider
781
+ // active → not MinHash-only either → no edges.
782
+ const r = computeSimilarTo({
783
+ store,
784
+ provider: countingProvider(),
785
+ config: ENABLED_CONFIG,
786
+ bodies,
787
+ vectors,
788
+ });
789
+ assert.equal(r.ok, true);
790
+ if (r.ok) assert.equal(r.edges.length, 0, "mismatched-dims pair must not be cosine-confirmed");
791
+ } finally {
792
+ await cleanup();
793
+ }
794
+ });
795
+
796
+ // ──────────────────────────────────────────────────────────────────────────
797
+ // Budget bounds provider CALLS, not successful writes — a degraded provider
798
+ // must not bypass the cost cap (chatgpt-codex-connector: 'Count failed embed
799
+ // attempts against the vector budget').
800
+ // ──────────────────────────────────────────────────────────────────────────
801
+
802
+ test("budget: degraded provider cost is bounded by maxSymbolsPerRun (attempts, not successes)", async () => {
803
+ const { store, dir, cleanup } = await tempStore();
804
+ try {
805
+ const src = "function a() { return 1; }\nfunction b() { return 2; }\nfunction c() { return 3; }\nfunction d() { return 4; }";
806
+ await writeFile(path.join(dir, "a.ts"), src);
807
+ const ir = makeFileIR("a.ts", [
808
+ { name: "a", qname: "mod.a", kind: "function", start: 0, end: 24 },
809
+ { name: "b", qname: "mod.b", kind: "function", start: 25, end: 50 },
810
+ { name: "c", qname: "mod.c", kind: "function", start: 51, end: 76 },
811
+ { name: "d", qname: "mod.d", kind: "function", start: 77, end: 102 },
812
+ ], src);
813
+ await store.upsertFileBatch([ir]);
814
+
815
+ const provider = countingProvider({ failMode: "throw" });
816
+ const config = resolveSemanticConfig({ enabled: true, maxSymbolsPerRun: 2 });
817
+ const r = await indexSymbolVectors({ store, provider, repoRoot: dir, config });
818
+ assert.equal(r.ok, true);
819
+ // Every embed throws → zero successes, but the provider must NOT be
820
+ // called for all four nodes. Before the fix, `embedded` never reached
821
+ // the limit (all throws) so the loop called the provider for every
822
+ // remaining node, bypassing the per-run cost cap.
823
+ assert.ok(
824
+ provider.embedCount <= 2,
825
+ `degraded provider calls must be bounded by maxSymbolsPerRun, got ${provider.embedCount}`,
826
+ );
827
+ } finally {
828
+ await cleanup();
829
+ }
830
+ });
831
+
832
+ // ──────────────────────────────────────────────────────────────────────────
833
+ // MinHash runs over the extracted BODY only — bodyless declarations
834
+ // (stubs/types with no body) yield no candidates instead of name-driven
835
+ // spurious pairs (chatgpt-codex-connector: 'MinHash only the extracted
836
+ // body text').
837
+ // ──────────────────────────────────────────────────────────────────────────
838
+
839
+ test("SIMILAR_TO: bodyless declarations yield no MinHash candidates", async () => {
840
+ const { store, dir, cleanup } = await tempStore();
841
+ try {
842
+ const src = "type A = string;\ntype B = number;\ntype C = boolean;";
843
+ await writeFile(path.join(dir, "t.ts"), src);
844
+ const ir = makeFileIR("t.ts", [
845
+ { name: "A", qname: "mod.A", kind: "type", start: 0, end: 16 },
846
+ { name: "B", qname: "mod.B", kind: "type", start: 17, end: 33 },
847
+ { name: "C", qname: "mod.C", kind: "type", start: 34, end: 51 },
848
+ ], src);
849
+ await store.upsertFileBatch([ir]);
850
+
851
+ const r = computeSimilarTo({ store, provider: undefined, config: ENABLED_CONFIG, repoRoot: dir });
852
+ assert.equal(r.ok, true);
853
+ if (r.ok) {
854
+ assert.equal(r.candidates, 0, "bodyless declarations produce no MinHash candidates");
855
+ assert.equal(r.edges.length, 0, "no SIMILAR_TO edges from bodyless declarations");
856
+ }
857
+ } finally {
858
+ await cleanup();
859
+ }
860
+ });
861
+
862
+ // ──────────────────────────────────────────────────────────────────────────
863
+ // Host-provided enabled is coerced — "false"/"0" must not become truthy
864
+ // and silently enable vector indexing (chatgpt-codex-connector: 'Coerce
865
+ // host enabled before trusting it').
866
+ // ──────────────────────────────────────────────────────────────────────────
867
+
868
+ test("config: host enabled string 'false'/'0' coerce to false (not truthy)", () => {
869
+ assert.equal(resolveSemanticConfig({ enabled: "false" as never }).enabled, false);
870
+ assert.equal(resolveSemanticConfig({ enabled: "0" as never }).enabled, false);
871
+ assert.equal(resolveSemanticConfig({ enabled: "no" as never }).enabled, false);
872
+ assert.equal(resolveSemanticConfig({ enabled: "" as never }).enabled, false);
873
+ assert.equal(resolveSemanticConfig({ enabled: "true" as never }).enabled, true);
874
+ assert.equal(resolveSemanticConfig({ enabled: true }).enabled, true);
875
+ assert.equal(resolveSemanticConfig({ enabled: false }).enabled, false);
876
+ // Absent host value still falls through to env/default (false).
877
+ assert.equal(resolveSemanticConfig({}).enabled, false);
878
+ });
879
+
880
+ // ──────────────────────────────────────────────────────────────────────────
881
+ // snippetFor honors a query-level repoRoot override, so a store opened
882
+ // without repoRoot can still hydrate snippets when the caller supplies one
883
+ // (chatgpt-codex-connector + cursor: 'Snippet hydration ignores query
884
+ // repoRoot').
885
+ // ──────────────────────────────────────────────────────────────────────────
886
+
887
+ test("snippetFor: query repoRoot overrides a store opened without repoRoot", async () => {
888
+ const dir = await mkdtemp(path.join(tmpdir(), "semroot-"));
889
+ const dbPath = path.join(dir, "graph.db");
890
+ const store = await GraphStore.open({ dbPath }); // NO repoRoot
891
+ try {
892
+ const src = "function greet(name) { return 'hi ' + name; }";
893
+ await writeFile(path.join(dir, "a.ts"), src);
894
+ const ir = makeFileIR("a.ts", [
895
+ { name: "greet", qname: "mod.greet", kind: "function", start: 0, end: src.length },
896
+ ], src);
897
+ await store.upsertFileBatch([ir]);
898
+ const nodeId = nodeIdFor({ qualifiedName: "mod.greet", filePath: "a.ts", label: "function" });
899
+
900
+ // No override → store has no repoRoot → repo_root_unset.
901
+ const noRoot = await store.snippetFor({ nodeId });
902
+ assert.equal(noRoot.ok, false);
903
+ if (!noRoot.ok) assert.equal(noRoot.code, "repo_root_unset");
904
+
905
+ // Override supplied → snippet resolves from the caller's repoRoot.
906
+ const withRoot = await store.snippetFor({ nodeId, repoRoot: dir });
907
+ assert.equal(withRoot.ok, true);
908
+ if (withRoot.ok) assert.ok(withRoot.text.includes("greet"), "snippet hydrates from the query repoRoot");
909
+ } finally {
910
+ await store.close();
911
+ await rm(dir, { recursive: true, force: true });
912
+ }
913
+ });
914
+
915
+ // ──────────────────────────────────────────────────────────────────────────
916
+ // writeSymbolVector reports whether it persisted (returns false on a
917
+ // closed store) so the indexer does not count dropped writes as embedded
918
+ // (cursor Bugbot: 'Embedded count after dropped writes').
919
+ // ──────────────────────────────────────────────────────────────────────────
920
+
921
+ test("writeSymbolVector: returns false on a closed store (write dropped)", async () => {
922
+ const { store, cleanup } = await tempStore();
923
+ await store.close();
924
+ const persisted = await store.writeSymbolVector({
925
+ nodeId: "deadbeef",
926
+ modelId: "m",
927
+ contentHash: "h".repeat(64),
928
+ dims: 8,
929
+ vector: new Float32Array(8),
930
+ });
931
+ assert.equal(persisted, false, "closed store must report the write as not persisted");
932
+ await cleanup();
933
+ });
934
+
935
+
936
+ // ──────────────────────────────────────────────────────────────────────────
937
+ // coerceHostBool fails CLOSED: unrecognized enabled strings are an opt-out,
938
+ // not an opt-in, so a malformed host value can never silently enable remote
939
+ // embedding (cursor Bugbot: 'Unknown enabled strings enable semantic').
940
+ // ──────────────────────────────────────────────────────────────────────────
941
+
942
+ test("config: unknown enabled strings fail closed to false", () => {
943
+ // Explicit affirmatives still enable.
944
+ assert.equal(resolveSemanticConfig({ enabled: "on" as never }).enabled, true);
945
+ assert.equal(resolveSemanticConfig({ enabled: "yes" as never }).enabled, true);
946
+ assert.equal(resolveSemanticConfig({ enabled: "true" as never }).enabled, true);
947
+ // Unrecognized / opt-out wording must NOT enable (fail closed).
948
+ assert.equal(resolveSemanticConfig({ enabled: "disabled" as never }).enabled, false);
949
+ assert.equal(resolveSemanticConfig({ enabled: "off" as never }).enabled, false);
950
+ assert.equal(resolveSemanticConfig({ enabled: "maybe" as never }).enabled, false);
951
+ assert.equal(resolveSemanticConfig({ enabled: "enabled" as never }).enabled, false);
952
+ });
953
+
954
+ // ──────────────────────────────────────────────────────────────────────────
955
+ // Host numeric config is coerced: a malformed string ("abc") must not become
956
+ // NaN and silently disable the vector budget (NaN > 0 is false → unlimited)
957
+ // or the cosine gate. It falls through to the documented default instead
958
+ // (chatgpt-codex-connector: 'Validate host numeric config before clamping').
959
+ // ──────────────────────────────────────────────────────────────────────────
960
+
961
+ test("config: malformed host numbers fall back to default (no NaN)", () => {
962
+ const cfg = resolveSemanticConfig({
963
+ enabled: true,
964
+ maxSymbolsPerRun: "abc" as never,
965
+ similarToThreshold: "xyz" as never,
966
+ });
967
+ assert.equal(Number.isNaN(cfg.maxSymbolsPerRun), false, "maxSymbolsPerRun must not be NaN");
968
+ assert.equal(Number.isNaN(cfg.similarToThreshold), false, "similarToThreshold must not be NaN");
969
+ // Falls through to the documented defaults.
970
+ assert.equal(cfg.maxSymbolsPerRun, DEFAULT_MAX_SYMBOLS_PER_RUN);
971
+ assert.equal(cfg.similarToThreshold, DEFAULT_SIMILAR_TO_THRESHOLD);
972
+ // A valid numeric string is still honored.
973
+ const cfg2 = resolveSemanticConfig({ enabled: true, maxSymbolsPerRun: "5" as never });
974
+ assert.equal(cfg2.maxSymbolsPerRun, 5);
975
+ });
976
+
977
+ // ──────────────────────────────────────────────────────────────────────────
978
+ // Cache hits still work when the provider does not declare dimensions
979
+ // (optional). The dims gate is skipped, so re-indexing an unchanged symbol
980
+ // uses the cache instead of re-embedding every run (cursor Bugbot: 'Cache
981
+ // misses without provider dimensions').
982
+ // ──────────────────────────────────────────────────────────────────────────
983
+
984
+ test("cache-hit: provider without dimensions still caches on reindex", async () => {
985
+ const { store, dir, cleanup } = await tempStore();
986
+ try {
987
+ const src = `function cached() { return 1; }`;
988
+ await writeFile(path.join(dir, "a.ts"), src);
989
+ const ir = makeFileIR("a.ts", [
990
+ { name: "cached", qname: "mod.cached", kind: "function", start: 0, end: src.length },
991
+ ], src);
992
+ await store.upsertFileBatch([ir]);
993
+
994
+ // Provider that does NOT declare `dimensions` (the field is optional).
995
+ let calls = 0;
996
+ const provider = {
997
+ id: "nodims-embedder",
998
+ model: "nodims-model",
999
+ async embed() {
1000
+ calls += 1;
1001
+ return [1, 0, 0, 0, 0, 0, 0, 0];
1002
+ },
1003
+ } as unknown as HostEmbeddingProvider;
1004
+
1005
+ const r1 = await indexSymbolVectors({ store, provider, repoRoot: dir, config: ENABLED_CONFIG });
1006
+ assert.equal(r1.ok, true);
1007
+ if (r1.ok) {
1008
+ assert.equal(r1.embedded, 1);
1009
+ assert.equal(r1.cached, 0);
1010
+ }
1011
+ assert.equal(calls, 1, "first index embeds once");
1012
+
1013
+ const r2 = await indexSymbolVectors({ store, provider, repoRoot: dir, config: ENABLED_CONFIG });
1014
+ assert.equal(r2.ok, true);
1015
+ if (r2.ok) {
1016
+ assert.equal(r2.embedded, 0);
1017
+ assert.equal(r2.cached, 1);
1018
+ }
1019
+ assert.equal(calls, 1, "reindex must hit cache — no second embed");
1020
+ } finally {
1021
+ await cleanup();
1022
+ }
1023
+ });
1024
+
1025
+
1026
+ // ──────────────────────────────────────────────────────────────────────────
1027
+ // SIMILAR_TO end-to-end: two symbols that share a qualified name across two
1028
+ // files are persisted as a SIMILAR_TO edge by node id (issue #1677). Before
1029
+ // the fix the edge was dropped because resolveNodeId returns undefined for an
1030
+ // ambiguous qualified name.
1031
+ // ──────────────────────────────────────────────────────────────────────────
1032
+
1033
+ test("SIMILAR_TO: duplicate qualified name across files persists by node id (issue #1677)", async () => {
1034
+ const { store, dir, cleanup } = await tempStore();
1035
+ try {
1036
+ // Identical bodies → MinHash candidate. Same qualified name, different
1037
+ // files → distinct node ids, ambiguous qualified name.
1038
+ const body = "function clone() { const x = 1; const y = 2; return x + y; }";
1039
+ await writeFile(path.join(dir, "a.ts"), body);
1040
+ await writeFile(path.join(dir, "b.ts"), body);
1041
+ await store.upsertFileBatch([
1042
+ makeFileIR("a.ts", [{ name: "clone", qname: "mod.clone", kind: "function", start: 0, end: body.length }], body),
1043
+ makeFileIR("b.ts", [{ name: "clone", qname: "mod.clone", kind: "function", start: 0, end: body.length }], body),
1044
+ ]);
1045
+
1046
+ const idA = nodeIdFor({ qualifiedName: "mod.clone", filePath: "a.ts", label: "function" });
1047
+ const idB = nodeIdFor({ qualifiedName: "mod.clone", filePath: "b.ts", label: "function" });
1048
+ assert.notEqual(idA, idB, "same-qname symbols in different files have distinct node ids");
1049
+
1050
+ // No provider → MinHash-only path (deterministic, local).
1051
+ const r = computeSimilarTo({ store, provider: undefined, config: ENABLED_CONFIG, repoRoot: dir });
1052
+ assert.equal(r.ok, true);
1053
+ if (!r.ok) throw new Error("expected ok");
1054
+ assert.ok(r.edges.length >= 1, "a near-clone candidate is produced");
1055
+
1056
+ // The candidate edges MUST carry node ids (the fix).
1057
+ const pair = r.edges.find((e) => e.srcNodeId === idA && e.dstNodeId === idB);
1058
+ assert.ok(pair, "emitted edge carries the content-derived node ids of both same-qname nodes");
1059
+
1060
+ // Persist via the store. Pre-fix this returned skipped=1, persisted=0.
1061
+ const edgeIRs = similarEdgesToEdgeIR(r.edges);
1062
+ const upsert = await store.upsertEdges(edgeIRs);
1063
+ assert.equal(upsert.ok, true);
1064
+ if (!upsert.ok) throw new Error("expected ok");
1065
+ assert.equal(upsert.persisted, edgeIRs.length, "all SIMILAR_TO edges persisted by node id");
1066
+ assert.equal(upsert.skipped, 0, "no edge dropped as an ambiguous qualified name");
1067
+
1068
+ // Traversal by node id confirms the edge links the two same-qname nodes.
1069
+ const t1 = store.traverse({ start: idA, edgeTypes: ["SIMILAR_TO"], maxDepth: 1 });
1070
+ assert.equal(t1.ok, true);
1071
+ if (t1.ok) {
1072
+ assert.ok(
1073
+ t1.hits.some((h) => h.nodeId === idB),
1074
+ "SIMILAR_TO edge connects the two distinct same-qname nodes",
1075
+ );
1076
+ }
1077
+ } finally {
1078
+ await cleanup();
1079
+ }
1080
+ });
1081
+
1082
+ // ──────────────────────────────────────────────────────────────────────────
1083
+ // #1680 — deferred review nits on the semantic layer (tagged read failures,
1084
+ // limit validation, negative body budget, README recompute example).
1085
+ // ──────────────────────────────────────────────────────────────────────────
1086
+
1087
+ /**
1088
+ * Wrap a real store so one read method throws (simulates SQLITE_BUSY/CORRUPT)
1089
+ * while the rest of the surface (isClosed, etc.) keeps working. Used to prove
1090
+ * the tagged db_error wrapping (#1680) rather than an escaping throw.
1091
+ */
1092
+ function throwingReadStore(
1093
+ base: GraphStore,
1094
+ method: "readAllSymbolVectors" | "readNodesForSemantic",
1095
+ ): GraphStore {
1096
+ return new Proxy(base as object, {
1097
+ get(target, prop, receiver) {
1098
+ if (prop === method) {
1099
+ return () => {
1100
+ throw new Error("stub: SQLITE_BUSY");
1101
+ };
1102
+ }
1103
+ return Reflect.get(target, prop, receiver);
1104
+ },
1105
+ }) as GraphStore;
1106
+ }
1107
+
1108
+ // config: a negative/zero canonicalBodyLines must NOT clamp to 0 (which
1109
+ // extractBodyText treats as unlimited, sending full symbol bodies to the
1110
+ // remote embedder and defeating the cost/privacy cap). It falls back to the
1111
+ // documented default instead (#1680).
1112
+ test("config: negative/zero canonicalBodyLines falls back to default (not 0/unlimited)", () => {
1113
+ const neg = resolveSemanticConfig({ enabled: true, canonicalBodyLines: -5 });
1114
+ assert.equal(neg.canonicalBodyLines, DEFAULT_CANONICAL_BODY_LINES, "negative → default");
1115
+ const zero = resolveSemanticConfig({ enabled: true, canonicalBodyLines: 0 });
1116
+ assert.equal(zero.canonicalBodyLines, DEFAULT_CANONICAL_BODY_LINES, "zero → default");
1117
+ // A positive value is still honored (not clobbered by the fallback).
1118
+ const ok = resolveSemanticConfig({ enabled: true, canonicalBodyLines: 7 });
1119
+ assert.equal(ok.canonicalBodyLines, 7, "positive value honored");
1120
+ // String coercion path (coerceHostNumber) — a numeric string like "-3"
1121
+ // must also fall back, not clamp to 0.
1122
+ const negStr = resolveSemanticConfig({ enabled: true, canonicalBodyLines: "-3" as never });
1123
+ assert.equal(negStr.canonicalBodyLines, DEFAULT_CANONICAL_BODY_LINES, "numeric-string -3 → default");
1124
+ });
1125
+
1126
+ // semanticQuery: a transient store read failure (SQLITE_BUSY/CORRUPT) on
1127
+ // readAllSymbolVectors maps to a tagged db_error instead of escaping as a
1128
+ // throw (#1680).
1129
+ test("semanticQuery: transient store read error → db_error (tagged, not thrown)", async () => {
1130
+ const { store, dir, cleanup } = await tempStore();
1131
+ try {
1132
+ const throwing = throwingReadStore(store, "readAllSymbolVectors");
1133
+ const provider = countingProvider();
1134
+ const r = await semanticQuery({ store: throwing, provider, repoRoot: dir, config: ENABLED_CONFIG, query: "anything" });
1135
+ assert.equal(r.ok, false);
1136
+ if (!r.ok) assert.equal(r.code, "db_error");
1137
+ } finally {
1138
+ await cleanup();
1139
+ }
1140
+ });
1141
+
1142
+ // indexSymbolVectors: a transient store read failure on readNodesForSemantic
1143
+ // maps to a tagged db_error instead of rejecting the promise (#1680).
1144
+ test("indexSymbolVectors: transient store read error → db_error (tagged, not thrown)", async () => {
1145
+ const { store, dir, cleanup } = await tempStore();
1146
+ try {
1147
+ const throwing = throwingReadStore(store, "readNodesForSemantic");
1148
+ const provider = countingProvider();
1149
+ const r = await indexSymbolVectors({ store: throwing, provider, repoRoot: dir, config: ENABLED_CONFIG });
1150
+ assert.equal(r.ok, false);
1151
+ if (!r.ok) assert.equal(r.code, "db_error");
1152
+ } finally {
1153
+ await cleanup();
1154
+ }
1155
+ });
1156
+
1157
+ // semanticQuery: a caller-supplied limit of NaN/Infinity must not produce a
1158
+ // NaN/Infinity slice. slice(0, NaN) returns no hits; slice(0, Infinity)
1159
+ // returns every scored vector. Both are wrong — coerce to the finite default
1160
+ // (#1680).
1161
+ test("semanticQuery: NaN/Infinity limit falls back to default (no empty/all slice)", async () => {
1162
+ const { store, dir, cleanup } = await tempStore();
1163
+ try {
1164
+ // Index more symbols than DEFAULT_SEMANTIC_QUERY_LIMIT so the default
1165
+ // cap is observable (default=10; 12 symbols → NaN must NOT return 0 and
1166
+ // Infinity must NOT return 12).
1167
+ const src = Array.from({ length: 12 }, (_, i) => `function f${i}() { return ${i}; }`).join("\n");
1168
+ await writeFile(path.join(dir, "many.ts"), src);
1169
+ const symbols = Array.from({ length: 12 }, (_, i) => ({
1170
+ name: `f${i}`,
1171
+ qname: `mod.f${i}`,
1172
+ kind: "function",
1173
+ start: src.indexOf(`function f${i}`),
1174
+ end: src.length,
1175
+ }));
1176
+ // Fix the end byte for each to the next symbol's start (last = src.length).
1177
+ for (let i = 0; i < symbols.length; i++) {
1178
+ symbols[i]!.end = i + 1 < symbols.length ? symbols[i + 1]!.start : src.length;
1179
+ }
1180
+ await store.upsertFileBatch([makeFileIR("many.ts", symbols, src)]);
1181
+
1182
+ const provider = countingProvider();
1183
+ await indexSymbolVectors({ store, provider, repoRoot: dir, config: ENABLED_CONFIG });
1184
+
1185
+ // NaN limit → default (10), not 0.
1186
+ const nanR = await semanticQuery({ store, provider, repoRoot: dir, config: ENABLED_CONFIG, query: "function", limit: NaN });
1187
+ assert.equal(nanR.ok, true);
1188
+ if (nanR.ok) {
1189
+ assert.ok(nanR.hits.length > 0, `NaN limit must not return zero hits, got ${nanR.hits.length}`);
1190
+ assert.ok(nanR.hits.length <= DEFAULT_SEMANTIC_QUERY_LIMIT, `NaN limit capped to default, got ${nanR.hits.length}`);
1191
+ }
1192
+ // Infinity limit → default (10), not all 12.
1193
+ const infR = await semanticQuery({ store, provider, repoRoot: dir, config: ENABLED_CONFIG, query: "function", limit: Infinity });
1194
+ assert.equal(infR.ok, true);
1195
+ if (infR.ok) {
1196
+ assert.ok(infR.hits.length <= DEFAULT_SEMANTIC_QUERY_LIMIT, `Infinity limit capped to default, got ${infR.hits.length}`);
1197
+ }
1198
+ // A finite positive limit is honored.
1199
+ const two = await semanticQuery({ store, provider, repoRoot: dir, config: ENABLED_CONFIG, query: "function", limit: 2 });
1200
+ assert.equal(two.ok, true);
1201
+ if (two.ok) assert.equal(two.hits.length, 2, "limit=2 returns exactly 2 hits");
1202
+ } finally {
1203
+ await cleanup();
1204
+ }
1205
+ });
1206
+
1207
+
1208
+ test("semanticQuery: a fractional limit between 0 and 1 returns at least 1 hit (not an empty slice)", async () => {
1209
+ // Math.floor(0.5) === 0; without clamping, slice(0, 0) returns no hits
1210
+ // even when matching vectors exist (#1680 review: 'Clamp sub-one query
1211
+ // limits before slicing').
1212
+ const { store, dir, cleanup } = await tempStore();
1213
+ try {
1214
+ const src = Array.from({ length: 3 }, (_, i) => `function f${i}() { return ${i}; }`).join("\n");
1215
+ await writeFile(path.join(dir, "many.ts"), src);
1216
+ const symbols = Array.from({ length: 3 }, (_, i) => ({
1217
+ name: `f${i}`,
1218
+ qname: `mod.f${i}`,
1219
+ kind: "function",
1220
+ start: src.indexOf(`function f${i}`),
1221
+ end: src.length,
1222
+ }));
1223
+ for (let i = 0; i < symbols.length; i++) {
1224
+ symbols[i]!.end = i + 1 < symbols.length ? symbols[i + 1]!.start : src.length;
1225
+ }
1226
+ await store.upsertFileBatch([makeFileIR("many.ts", symbols, src)]);
1227
+ const provider = countingProvider();
1228
+ await indexSymbolVectors({ store, provider, repoRoot: dir, config: ENABLED_CONFIG });
1229
+
1230
+ const r = await semanticQuery({ store, provider, repoRoot: dir, config: ENABLED_CONFIG, query: "function", limit: 0.5 });
1231
+ assert.equal(r.ok, true);
1232
+ if (r.ok) {
1233
+ assert.ok(r.hits.length >= 1, `fractional limit 0.5 must return at least 1 hit (clamped to 1), got ${r.hits.length}`);
1234
+ }
1235
+ } finally {
1236
+ await cleanup();
1237
+ }
1238
+ });
1239
+
1240
+ // SIMILAR_TO recompute is replace-not-append: the README example clears
1241
+ // semantic SIMILAR_TO edges before upserting so two symbols that STOP being
1242
+ // similar do not leave a stale edge (upsertEdges does not delete absent rows).
1243
+ // This test exercises the documented README flow end to end (#1680).
1244
+ test("SIMILAR_TO recompute: clearSemanticSimilarToEdges makes recompute replace-not-append (README example)", async () => {
1245
+ const { store, dir, cleanup } = await tempStore();
1246
+ try {
1247
+ // Two near-identical bodies → MinHash candidate → SIMILAR_TO edge.
1248
+ const bodyA = "function twin() { const x = 1; const y = 2; return x + y; }";
1249
+ const bodyB = "function twin() { const x = 1; const y = 2; return x + y; }";
1250
+ await writeFile(path.join(dir, "a.ts"), bodyA);
1251
+ await writeFile(path.join(dir, "b.ts"), bodyB);
1252
+ await store.upsertFileBatch([
1253
+ makeFileIR("a.ts", [{ name: "twin", qname: "mod.a", kind: "function", start: 0, end: bodyA.length }], bodyA),
1254
+ makeFileIR("b.ts", [{ name: "twin", qname: "mod.b", kind: "function", start: 0, end: bodyB.length }], bodyB),
1255
+ ]);
1256
+
1257
+ const provider = countingProvider();
1258
+ // README step 0: index vectors FIRST so cosine confirmation has data.
1259
+ await indexSymbolVectors({ store, provider, repoRoot: dir, config: ENABLED_CONFIG });
1260
+ const r1 = computeSimilarTo({ store, provider, repoRoot: dir, config: ENABLED_CONFIG });
1261
+ assert.equal(r1.ok, true);
1262
+ if (!r1.ok) throw new Error("expected ok");
1263
+ assert.ok(r1.edges.length >= 1, "first recompute finds the near-clone pair");
1264
+ await store.upsertEdges(similarEdgesToEdgeIR(r1.edges));
1265
+
1266
+ const idA = nodeIdFor({ qualifiedName: "mod.a", filePath: "a.ts", label: "function" });
1267
+ const beforeClear = store.traverse({ start: idA, edgeTypes: ["SIMILAR_TO"], maxDepth: 1 });
1268
+ assert.ok(beforeClear.ok, "traverse ok before clear");
1269
+ if (beforeClear.ok) {
1270
+ assert.ok(beforeClear.hits.some((h) => h.qualifiedName === "mod.b"), "mod.b reachable via SIMILAR_TO before clear");
1271
+ }
1272
+
1273
+ // README flow: compute-first-then-swap. computeSimilarTo is pure (no
1274
+ // edge mutation), so compute candidates BEFORE clearing — if the
1275
+ // recompute fails, existing SIMILAR_TO edges are preserved instead of
1276
+ // leaving the graph empty (chatgpt-codex-connector P2: "Preserve
1277
+ // existing edges until recompute succeeds").
1278
+ const r2 = computeSimilarTo({ store, provider, repoRoot: dir, config: ENABLED_CONFIG });
1279
+ assert.equal(r2.ok, true);
1280
+ if (!r2.ok) throw new Error("expected ok");
1281
+
1282
+ // Compute succeeded → safe to swap. Clear makes it replace-not-append
1283
+ // (upsertEdges does not delete absent rows); without it, a stale edge
1284
+ // recompute would NOT re-emit would survive.
1285
+ await store.clearSemanticSimilarToEdges();
1286
+ const afterClear = store.traverse({ start: idA, edgeTypes: ["SIMILAR_TO"], maxDepth: 1 });
1287
+ assert.ok(afterClear.ok, "traverse ok after clear");
1288
+ if (afterClear.ok) {
1289
+ assert.ok(!afterClear.hits.some((h) => h.qualifiedName === "mod.b"), "SIMILAR_TO edge to mod.b gone after clear");
1290
+ }
1291
+ if (r2.edges.length >= 1) {
1292
+ await store.upsertEdges(similarEdgesToEdgeIR(r2.edges));
1293
+ }
1294
+ const final = store.traverse({ start: idA, edgeTypes: ["SIMILAR_TO"], maxDepth: 1 });
1295
+ assert.ok(final.ok, "traverse ok");
1296
+ if (final.ok) {
1297
+ // After clear + recompute + upsert, mod.b is reachable again — the
1298
+ // still-similar pair has its edge (clean replace, not a stale append).
1299
+ assert.ok(final.hits.some((h) => h.qualifiedName === "mod.b"), "mod.b reachable again after recompute (replace worked)");
1300
+ }
1301
+
1302
+ // Preserve-on-failure: a recompute that returns { ok: false } must NOT
1303
+ // clear — the existing edge survives. semantic_disabled short-circuits
1304
+ // before any table work, so no clear runs and mod.b stays reachable.
1305
+ const failing = computeSimilarTo({ store, provider, repoRoot: dir, config: DISABLED_CONFIG });
1306
+ assert.equal(failing.ok, false);
1307
+ const preserved = store.traverse({ start: idA, edgeTypes: ["SIMILAR_TO"], maxDepth: 1 });
1308
+ assert.ok(preserved.ok, "traverse ok");
1309
+ if (preserved.ok) {
1310
+ assert.ok(preserved.hits.some((h) => h.qualifiedName === "mod.b"), "existing SIMILAR_TO edge preserved when recompute fails");
1311
+ }
1312
+ } finally {
1313
+ await cleanup();
1314
+ }
1315
+ });