@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.
- package/README.md +130 -0
- package/dist/chunk-5I2DBHOQ.js +1042 -0
- package/dist/chunk-5I2DBHOQ.js.map +1 -0
- package/dist/chunk-CPYJACC5.js +1838 -0
- package/dist/chunk-CPYJACC5.js.map +1 -0
- package/dist/chunk-ZVCMIM4T.js +216 -0
- package/dist/chunk-ZVCMIM4T.js.map +1 -0
- package/dist/cypher/query-parser.d.ts +253 -0
- package/dist/cypher/query-parser.js +17 -0
- package/dist/cypher/query-parser.js.map +1 -0
- package/dist/graph-schema.d.ts +84 -0
- package/dist/graph-schema.js +17 -0
- package/dist/graph-schema.js.map +1 -0
- package/dist/graph-store.d.ts +938 -0
- package/dist/graph-store.js +16 -0
- package/dist/graph-store.js.map +1 -0
- package/dist/index.d.ts +1953 -0
- package/dist/index.js +3509 -0
- package/dist/index.js.map +1 -0
- package/grammars/tree-sitter-bash.wasm +0 -0
- package/grammars/tree-sitter-c.wasm +0 -0
- package/grammars/tree-sitter-c_sharp.wasm +0 -0
- package/grammars/tree-sitter-cpp.wasm +0 -0
- package/grammars/tree-sitter-go.wasm +0 -0
- package/grammars/tree-sitter-java.wasm +0 -0
- package/grammars/tree-sitter-javascript.wasm +0 -0
- package/grammars/tree-sitter-kotlin.wasm +0 -0
- package/grammars/tree-sitter-php.wasm +0 -0
- package/grammars/tree-sitter-python.wasm +0 -0
- package/grammars/tree-sitter-ruby.wasm +0 -0
- package/grammars/tree-sitter-rust.wasm +0 -0
- package/grammars/tree-sitter-swift.wasm +0 -0
- package/grammars/tree-sitter-tsx.wasm +0 -0
- package/grammars/tree-sitter-typescript.wasm +0 -0
- package/package.json +79 -0
- package/src/co-change.test.ts +175 -0
- package/src/co-change.ts +167 -0
- package/src/cypher/query-parser.test.ts +1107 -0
- package/src/cypher/query-parser.ts +1692 -0
- package/src/detect-changes.test.ts +533 -0
- package/src/detect-changes.ts +367 -0
- package/src/engine/emit.ts +556 -0
- package/src/engine/engine.test.ts +1417 -0
- package/src/engine/engine.ts +182 -0
- package/src/engine/extractors.ts +486 -0
- package/src/engine/fixtures.ts +364 -0
- package/src/engine/language-sniff.ts +56 -0
- package/src/engine/parser-backend.ts +206 -0
- package/src/engine/utf16-offsets.ts +68 -0
- package/src/git-invoker.test.ts +116 -0
- package/src/git-invoker.ts +426 -0
- package/src/graph-schema.test.ts +541 -0
- package/src/graph-schema.ts +383 -0
- package/src/graph-store-pr2.test.ts +1879 -0
- package/src/graph-store.test.ts +1420 -0
- package/src/graph-store.ts +3489 -0
- package/src/index-status.test.ts +303 -0
- package/src/index-status.ts +135 -0
- package/src/index.ts +384 -0
- package/src/lsp/byte-position.ts +173 -0
- package/src/lsp/characterization.test.ts +174 -0
- package/src/lsp/client.test.ts +275 -0
- package/src/lsp/client.ts +484 -0
- package/src/lsp/config.ts +219 -0
- package/src/lsp/degradation.ts +86 -0
- package/src/lsp/fixtures/fake-server.mjs +198 -0
- package/src/lsp/framing.test.ts +180 -0
- package/src/lsp/framing.ts +177 -0
- package/src/lsp/resolution.test.ts +497 -0
- package/src/lsp/resolution.ts +483 -0
- package/src/lsp/status.ts +140 -0
- package/src/lsp/types.ts +167 -0
- package/src/reindex.test.ts +1038 -0
- package/src/reindex.ts +908 -0
- package/src/row-types.ts +45 -0
- package/src/semantic/canonical-text.test.ts +150 -0
- package/src/semantic/canonical-text.ts +219 -0
- package/src/semantic/config.ts +235 -0
- package/src/semantic/index.ts +78 -0
- package/src/semantic/minhash.test.ts +197 -0
- package/src/semantic/minhash.ts +261 -0
- package/src/semantic/semantic-query.ts +173 -0
- package/src/semantic/semantic.test.ts +1315 -0
- package/src/semantic/similarity.ts +268 -0
- package/src/semantic/types.ts +145 -0
- 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
|
+
});
|