@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,268 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SIMILAR_TO near-clone pipeline (issue #1556 PR2 component).
|
|
3
|
+
*
|
|
4
|
+
* Cheap-first, deterministic:
|
|
5
|
+
* 1. MinHash/LSH candidate generation over normalized symbol bodies.
|
|
6
|
+
* 2. Cosine confirmation by embedding ≥ threshold when vectors exist.
|
|
7
|
+
*
|
|
8
|
+
* Edges carry `provenance: "semantic"` + the similarity score as
|
|
9
|
+
* confidence. No provider → MinHash-only edges carry a distinct lower
|
|
10
|
+
* confidence band (documented), and the pipeline still runs (MinHash is
|
|
11
|
+
* local and deterministic).
|
|
12
|
+
*
|
|
13
|
+
* Rule 35 spirit: the threshold boundary (≥ vs >) is decided ONCE here
|
|
14
|
+
* (≥ threshold confirms) and documented. The boundary test pins it.
|
|
15
|
+
*
|
|
16
|
+
* Rule 38: the candidate set is a pure function of (seeds, inputs). Two
|
|
17
|
+
* runs over the same fixture produce an identical edge set.
|
|
18
|
+
*/
|
|
19
|
+
import { readFileSync as fsReadFileSync } from "node:fs";
|
|
20
|
+
import path from "node:path";
|
|
21
|
+
const fs = { readFileSync: fsReadFileSync };
|
|
22
|
+
import type { GraphStore } from "../graph-store.js";
|
|
23
|
+
import type { EdgeIR } from "../graph-store.js";
|
|
24
|
+
import { extractBodyText } from "./canonical-text.js";
|
|
25
|
+
import {
|
|
26
|
+
MINHASH_ONLY_CONFIDENCE,
|
|
27
|
+
SEMANTIC_PROVENANCE,
|
|
28
|
+
SIMILAR_TO_EDGE_TYPE,
|
|
29
|
+
} from "./config.js";
|
|
30
|
+
import type { SemanticConfig } from "./config.js";
|
|
31
|
+
import { cosineSimilarity, createMinHasher, tokenizeForShingling, shingleSet } from "./minhash.js";
|
|
32
|
+
import type { SimilarEdge, SimilarToResult } from "./types.js";
|
|
33
|
+
import type { SemanticFailure } from "./types.js";
|
|
34
|
+
import { modelIdFor } from "./vectors.js";
|
|
35
|
+
import type { HostEmbeddingProvider } from "@remnic/core/host-embedding-provider";
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Input to {@link computeSimilarTo}.
|
|
39
|
+
*/
|
|
40
|
+
export interface SimilarToInput {
|
|
41
|
+
readonly store: GraphStore;
|
|
42
|
+
readonly provider: HostEmbeddingProvider | undefined;
|
|
43
|
+
readonly config: SemanticConfig;
|
|
44
|
+
/**
|
|
45
|
+
* Repo root for reading source text from disk when bodies are not
|
|
46
|
+
* supplied. Required when bodies is absent.
|
|
47
|
+
*/
|
|
48
|
+
readonly repoRoot?: string;
|
|
49
|
+
/**
|
|
50
|
+
* Symbol bodies keyed by nodeId. The caller (the indexer or a
|
|
51
|
+
* standalone pass) reads source text and builds canonical bodies. When
|
|
52
|
+
* absent, the pipeline reads nodes from the store + disk itself.
|
|
53
|
+
*/
|
|
54
|
+
readonly bodies?: ReadonlyMap<string, { readonly qualifiedName: string; readonly body: string }>;
|
|
55
|
+
/**
|
|
56
|
+
* Vectors keyed by nodeId (the persisted embedding). When absent, the
|
|
57
|
+
* pipeline reads them from the store via readAllSymbolVectors.
|
|
58
|
+
*/
|
|
59
|
+
readonly vectors?: ReadonlyMap<string, Float32Array>;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The comparison operator for cosine confirmation. Decided ONCE (rule 35
|
|
64
|
+
* spirit): `>= threshold`. A pair at EXACTLY the threshold confirms. The
|
|
65
|
+
* boundary test asserts this.
|
|
66
|
+
*/
|
|
67
|
+
export const CONFIRM_OPERATOR = ">=" as const;
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Jaccard gate for MinHash-only SIMILAR_TO edges (no embedding provider).
|
|
71
|
+
* Well below the cosine similarToThreshold (0.92) because token-Jaccard
|
|
72
|
+
* for near-clone code is typically 0.3-0.8. 0.5 catches genuine
|
|
73
|
+
* copy-paste with minor renames while rejecting structurally-similar
|
|
74
|
+
* but logically-unrelated pairs.
|
|
75
|
+
*/
|
|
76
|
+
export const MINHASH_JACCARD_GATE = 0.5;
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Compute SIMILAR_TO edges.
|
|
80
|
+
*
|
|
81
|
+
* Returns the edges (for the caller to persist via store.upsertEdges) plus
|
|
82
|
+
* counts. The caller persists; this function is pure over its inputs
|
|
83
|
+
* (rule 38 — deterministic given seeds + bodies + vectors).
|
|
84
|
+
*
|
|
85
|
+
* When `config.enabled` is false → tagged semantic_disabled (no work, no
|
|
86
|
+
* candidate generation — gate-off parity).
|
|
87
|
+
*/
|
|
88
|
+
export function computeSimilarTo(input: SimilarToInput): SimilarToResult | SemanticFailure {
|
|
89
|
+
const { store, provider, config } = input;
|
|
90
|
+
if (!config.enabled) {
|
|
91
|
+
return { ok: false, code: "semantic_disabled" };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Without pre-built bodies AND without a repoRoot, the only available
|
|
95
|
+
// text is the qualified name, which MinHashes name similarity rather
|
|
96
|
+
// than body similarity — real copy-paste clones with different names
|
|
97
|
+
// are silently missed. Refuse to guess: require one of the two so the
|
|
98
|
+
// pipeline always MinHashes actual symbol bodies (chatgpt-codex-
|
|
99
|
+
// connector P2: 'Require source bodies before MinHashing').
|
|
100
|
+
if (!input.bodies && !input.repoRoot) {
|
|
101
|
+
return {
|
|
102
|
+
ok: false,
|
|
103
|
+
code: "repo_root_unset",
|
|
104
|
+
message: "computeSimilarTo needs either 'bodies' or 'repoRoot' to read source text",
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
// Closed store is a distinct degradation (rule 34) — readNodesForSemantic
|
|
108
|
+
// would return [] and we would report { ok: true, edges: [] } instead of
|
|
109
|
+
// the documented store_closed code used by the other entry points (cursor
|
|
110
|
+
// Bugbot: 'SimilarTo ignores closed store').
|
|
111
|
+
if (store.isClosed) {
|
|
112
|
+
return { ok: false, code: "store_closed" };
|
|
113
|
+
}
|
|
114
|
+
const bodies = input.bodies ?? readBodiesFromStore(store, input.repoRoot);
|
|
115
|
+
const modelId = provider ? modelIdFor(provider) : undefined;
|
|
116
|
+
const vectors = input.vectors ?? (modelId ? readVectorsMap(store, modelId) : new Map<string, Float32Array>());
|
|
117
|
+
|
|
118
|
+
// Pass 1: MinHash/LSH candidates.
|
|
119
|
+
const hasher = createMinHasher();
|
|
120
|
+
for (const [nodeId, entry] of bodies) {
|
|
121
|
+
hasher.add({ nodeId, qualifiedName: entry.qualifiedName, body: entry.body });
|
|
122
|
+
}
|
|
123
|
+
const candidates = hasher.findCandidates();
|
|
124
|
+
|
|
125
|
+
// Pass 2: cosine confirmation.
|
|
126
|
+
const edges: SimilarEdge[] = [];
|
|
127
|
+
let confirmed = 0;
|
|
128
|
+
let minhashOnly = 0;
|
|
129
|
+
for (const c of candidates) {
|
|
130
|
+
const va = vectors.get(c.aNodeId);
|
|
131
|
+
const vb = vectors.get(c.bNodeId);
|
|
132
|
+
if (va && vb && va.length === vb.length) {
|
|
133
|
+
// Require matching dimensionality — cosineSimilarity compares over the
|
|
134
|
+
// shorter length, so mismatched-dims rows would get a misleading
|
|
135
|
+
// partial-overlap score (cursor Bugbot: 'SimilarTo skips embedding
|
|
136
|
+
// length check'). Pairs that fail this fall through to the no-provider
|
|
137
|
+
// MinHash-only branch or are skipped.
|
|
138
|
+
const cos = cosineSimilarity(va, vb);
|
|
139
|
+
// rule 35: >= threshold confirms (decided once, here).
|
|
140
|
+
if (cos >= config.similarToThreshold) {
|
|
141
|
+
edges.push({
|
|
142
|
+
srcNodeId: c.aNodeId,
|
|
143
|
+
dstNodeId: c.bNodeId,
|
|
144
|
+
srcQualifiedName: c.aQualifiedName,
|
|
145
|
+
dstQualifiedName: c.bQualifiedName,
|
|
146
|
+
confidence: cos,
|
|
147
|
+
confirmed: true,
|
|
148
|
+
});
|
|
149
|
+
confirmed += 1;
|
|
150
|
+
}
|
|
151
|
+
} else if (!provider) {
|
|
152
|
+
// MinHash-only is the documented fallback for the NO-PROVIDER
|
|
153
|
+
// (local, deterministic) mode. When a provider IS configured we do
|
|
154
|
+
// NOT emit MinHash-only edges for pairs missing a vector — that
|
|
155
|
+
// would bypass the cosine confirmation path during partial / not-
|
|
156
|
+
// yet-indexed state. Such pairs are simply skipped; they will be
|
|
157
|
+
// cosine-confirmed once indexing completes (cursor Bugbot: 'MinHash
|
|
158
|
+
// edges with provider set'). Use a Jaccard gate well below the
|
|
159
|
+
// cosine threshold — MinHash Jaccard for near-clones is typically
|
|
160
|
+
// 0.3-0.8; the MINHASH_JACCARD_GATE is the documented floor.
|
|
161
|
+
if (c.jaccard >= MINHASH_JACCARD_GATE) {
|
|
162
|
+
edges.push({
|
|
163
|
+
srcNodeId: c.aNodeId,
|
|
164
|
+
dstNodeId: c.bNodeId,
|
|
165
|
+
srcQualifiedName: c.aQualifiedName,
|
|
166
|
+
dstQualifiedName: c.bQualifiedName,
|
|
167
|
+
confidence: MINHASH_ONLY_CONFIDENCE,
|
|
168
|
+
confirmed: false,
|
|
169
|
+
});
|
|
170
|
+
minhashOnly += 1;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
// else: provider configured but a vector is missing → skip (await
|
|
174
|
+
// indexing + cosine confirmation; do not bypass with MinHash-only).
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Stable sort: by confidence desc, then src qname, then dst qname.
|
|
178
|
+
edges.sort((a, b) => {
|
|
179
|
+
if (b.confidence !== a.confidence) return b.confidence - a.confidence;
|
|
180
|
+
if (a.srcQualifiedName !== b.srcQualifiedName) return a.srcQualifiedName < b.srcQualifiedName ? -1 : 1;
|
|
181
|
+
return a.dstQualifiedName < b.dstQualifiedName ? -1 : a.dstQualifiedName > b.dstQualifiedName ? 1 : 0;
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
return { ok: true, edges, candidates: candidates.length, confirmed, minhashOnly };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Convert SimilarEdge[] to the store's EdgeIR[] for persistence via
|
|
189
|
+
* upsertEdges. Provenance is always "semantic"; type is SIMILAR_TO.
|
|
190
|
+
*
|
|
191
|
+
* Carries the content-derived node ids onto the EdgeIR (issue #1677) so
|
|
192
|
+
* the store resolves each endpoint by `nodes.id` (unique) instead of by
|
|
193
|
+
* qualified name — two symbols that share a qualified name across files
|
|
194
|
+
* get distinct, non-colliding SIMILAR_TO edges instead of being dropped
|
|
195
|
+
* as ambiguous.
|
|
196
|
+
*/
|
|
197
|
+
export function similarEdgesToEdgeIR(edges: readonly SimilarEdge[]): EdgeIR[] {
|
|
198
|
+
return edges.map((e) => ({
|
|
199
|
+
srcQualifiedName: e.srcQualifiedName,
|
|
200
|
+
dstQualifiedName: e.dstQualifiedName,
|
|
201
|
+
type: SIMILAR_TO_EDGE_TYPE,
|
|
202
|
+
confidence: e.confidence,
|
|
203
|
+
provenance: SEMANTIC_PROVENANCE,
|
|
204
|
+
srcNodeId: e.srcNodeId,
|
|
205
|
+
dstNodeId: e.dstNodeId,
|
|
206
|
+
}));
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Read canonical bodies for every persisted node from the store + disk.
|
|
211
|
+
* Used when the caller does not supply pre-built bodies. Returns a map
|
|
212
|
+
* keyed by nodeId with the canonical body text (the MinHash input).
|
|
213
|
+
*
|
|
214
|
+
* Note: this reads from disk synchronously per node, so callers that
|
|
215
|
+
* already have bodies in memory should pass them via `input.bodies`.
|
|
216
|
+
*/
|
|
217
|
+
function readBodiesFromStore(store: GraphStore, repoRoot?: string): Map<string, { readonly qualifiedName: string; readonly body: string }> {
|
|
218
|
+
const out = new Map<string, { readonly qualifiedName: string; readonly body: string }>();
|
|
219
|
+
for (const node of store.readNodesForSemantic()) {
|
|
220
|
+
let rawText = "";
|
|
221
|
+
if (repoRoot) {
|
|
222
|
+
try {
|
|
223
|
+
const abs = path.resolve(repoRoot, node.filePath);
|
|
224
|
+
const bytes = fs.readFileSync(abs);
|
|
225
|
+
const start = Math.max(0, node.startByte);
|
|
226
|
+
const end = Math.min(bytes.length, node.endByte);
|
|
227
|
+
if (start <= end) rawText = bytes.subarray(start, end).toString("utf8");
|
|
228
|
+
} catch {
|
|
229
|
+
// File not readable — body stays empty, symbol is skipped by MinHasher.
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
// MinHash the extracted BODY only, not the full canonical text. The
|
|
233
|
+
// canonical form includes KIND/QNAME/SIG metadata; tokenizing it would
|
|
234
|
+
// let empty-body stubs/declarations emit name-driven candidates among
|
|
235
|
+
// unrelated symbols. extractBodyText returns "" for a bodyless symbol,
|
|
236
|
+
// and the hasher skips empty bodies entirely (chatgpt-codex-connector:
|
|
237
|
+
// 'MinHash only the extracted body text').
|
|
238
|
+
const body = extractBodyText(rawText, 0);
|
|
239
|
+
out.set(node.nodeId, { qualifiedName: node.qualifiedName, body });
|
|
240
|
+
}
|
|
241
|
+
return out;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Read the vectors table into a nodeId → Float32Array map for cosine
|
|
246
|
+
* confirmation. Uses the model id derived from the provider.
|
|
247
|
+
*/
|
|
248
|
+
function readVectorsMap(store: GraphStore, modelId: string): Map<string, Float32Array> {
|
|
249
|
+
const out = new Map<string, Float32Array>();
|
|
250
|
+
for (const row of store.readAllSymbolVectors(modelId)) {
|
|
251
|
+
out.set(row.nodeId, row.vector);
|
|
252
|
+
}
|
|
253
|
+
return out;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Estimate Jaccard similarity between two bodies directly (no LSH). Used
|
|
258
|
+
* by the hard-negative test to assert two bodies are NOT similar.
|
|
259
|
+
*/
|
|
260
|
+
export function estimateJaccard(bodyA: string, bodyB: string): number {
|
|
261
|
+
const sa = shingleSet(tokenizeForShingling(bodyA));
|
|
262
|
+
const sb = shingleSet(tokenizeForShingling(bodyB));
|
|
263
|
+
if (sa.size === 0 && sb.size === 0) return 1;
|
|
264
|
+
let inter = 0;
|
|
265
|
+
for (const s of sa) if (sb.has(s)) inter += 1;
|
|
266
|
+
const union = sa.size + sb.size - inter;
|
|
267
|
+
return union === 0 ? 0 : inter / union;
|
|
268
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared types for the semantic layer (issue #1556).
|
|
3
|
+
*
|
|
4
|
+
* Tagged failures follow rule 34 — every entry point returns a
|
|
5
|
+
* discriminated union so a caller that switches on `result.code` never
|
|
6
|
+
* observes a thrown error from the semantic layer.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* A persisted symbol vector. `vector` is the float32 embedding; `dims`
|
|
11
|
+
* is its dimensionality; `modelId` identifies the provider+model that
|
|
12
|
+
* produced it (so a provider swap invalidates the cache); `contentHash`
|
|
13
|
+
* is the canonical-text hash (so a canonical-text change invalidates the
|
|
14
|
+
* cache — rule 37).
|
|
15
|
+
*/
|
|
16
|
+
export interface SymbolVector {
|
|
17
|
+
readonly nodeId: string;
|
|
18
|
+
readonly qualifiedName: string;
|
|
19
|
+
readonly vector: Float32Array;
|
|
20
|
+
readonly dims: number;
|
|
21
|
+
readonly modelId: string;
|
|
22
|
+
readonly contentHash: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* A row read back from the vectors table for brute-force cosine.
|
|
27
|
+
*/
|
|
28
|
+
export interface SymbolVectorRow {
|
|
29
|
+
readonly nodeId: string;
|
|
30
|
+
readonly qualifiedName: string;
|
|
31
|
+
readonly vector: Float32Array;
|
|
32
|
+
readonly dims: number;
|
|
33
|
+
readonly modelId: string;
|
|
34
|
+
readonly contentHash: string;
|
|
35
|
+
readonly filePath: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Tagged-failure codes shared across the semantic layer.
|
|
40
|
+
*
|
|
41
|
+
* - `semantic_disabled`: the master gate is off (rule 30/48). No provider
|
|
42
|
+
* call, no vectors-table write, no edge emitted.
|
|
43
|
+
* - `provider_unavailable`: no host embedding provider registered for the
|
|
44
|
+
* given scope.
|
|
45
|
+
* - `provider_timeout`: the provider exceeded the lookup budget.
|
|
46
|
+
* - `malformed_vector`: `normalizeHostEmbeddingVector` returned null.
|
|
47
|
+
* - `repo_root_unset`: the store was opened without a repoRoot, so source
|
|
48
|
+
* text cannot be read.
|
|
49
|
+
* - `store_closed`: the store is closed.
|
|
50
|
+
* - `db_error`: an underlying SQLite error.
|
|
51
|
+
* - `no_vectors`: semantic_query ran but the vectors table is empty.
|
|
52
|
+
* - `invalid_query`: malformed query input.
|
|
53
|
+
*/
|
|
54
|
+
export type SemanticFailureCode =
|
|
55
|
+
| "semantic_disabled"
|
|
56
|
+
| "provider_unavailable"
|
|
57
|
+
| "provider_timeout"
|
|
58
|
+
| "malformed_vector"
|
|
59
|
+
| "repo_root_unset"
|
|
60
|
+
| "store_closed"
|
|
61
|
+
| "db_error"
|
|
62
|
+
| "no_vectors"
|
|
63
|
+
| "invalid_query";
|
|
64
|
+
|
|
65
|
+
export interface SemanticFailure {
|
|
66
|
+
readonly ok: false;
|
|
67
|
+
readonly code: SemanticFailureCode;
|
|
68
|
+
readonly message?: string;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Result of indexing vectors for a batch of symbols.
|
|
73
|
+
*/
|
|
74
|
+
export interface IndexVectorsResult {
|
|
75
|
+
readonly ok: true;
|
|
76
|
+
readonly embedded: number;
|
|
77
|
+
readonly cached: number;
|
|
78
|
+
readonly skipped: number;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* A SIMILAR_TO candidate pair from the MinHash/LSH pass.
|
|
83
|
+
*/
|
|
84
|
+
export interface SimilarCandidate {
|
|
85
|
+
readonly aNodeId: string;
|
|
86
|
+
readonly bNodeId: string;
|
|
87
|
+
readonly aQualifiedName: string;
|
|
88
|
+
readonly bQualifiedName: string;
|
|
89
|
+
readonly jaccard: number;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* A confirmed SIMILAR_TO edge (after cosine confirmation when available).
|
|
94
|
+
*
|
|
95
|
+
* Carries the content-derived node ids (`nodes.id`) of both endpoints so
|
|
96
|
+
* the persisted edge resolves unambiguously even when the two symbols share
|
|
97
|
+
* a qualified name across files (issue #1677). The qualified-name fields
|
|
98
|
+
* remain for diagnostics / stable-sort tie-breaking.
|
|
99
|
+
*/
|
|
100
|
+
export interface SimilarEdge {
|
|
101
|
+
readonly srcNodeId: string;
|
|
102
|
+
readonly dstNodeId: string;
|
|
103
|
+
readonly srcQualifiedName: string;
|
|
104
|
+
readonly dstQualifiedName: string;
|
|
105
|
+
readonly confidence: number;
|
|
106
|
+
readonly confirmed: boolean;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Result of the SIMILAR_TO pipeline.
|
|
111
|
+
*/
|
|
112
|
+
export interface SimilarToResult {
|
|
113
|
+
readonly ok: true;
|
|
114
|
+
readonly edges: readonly SimilarEdge[];
|
|
115
|
+
readonly candidates: number;
|
|
116
|
+
readonly confirmed: number;
|
|
117
|
+
readonly minhashOnly: number;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* A hydrated semantic_query hit — graph context attached so the agent
|
|
122
|
+
* gets structure, not just a snippet.
|
|
123
|
+
*/
|
|
124
|
+
export interface SemanticQueryHit {
|
|
125
|
+
readonly qualifiedName: string;
|
|
126
|
+
readonly filePath: string;
|
|
127
|
+
readonly kind: string;
|
|
128
|
+
readonly score: number;
|
|
129
|
+
readonly snippet: string;
|
|
130
|
+
readonly callers: readonly string[];
|
|
131
|
+
readonly callees: readonly string[];
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Result of semantic_query. When degraded, `ok: true` still carries the
|
|
136
|
+
* (possibly empty) hits plus a `degraded` tag so the caller never
|
|
137
|
+
* mistakes "no matches" for "backend broken" (rule 34).
|
|
138
|
+
*/
|
|
139
|
+
export interface SemanticQuerySuccess {
|
|
140
|
+
readonly ok: true;
|
|
141
|
+
readonly hits: readonly SemanticQueryHit[];
|
|
142
|
+
readonly degraded?: "provider_unavailable" | "provider_timeout" | "malformed_vector";
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export type SemanticQueryOutcome = SemanticQuerySuccess | SemanticFailure;
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Symbol-vector indexing (issue #1556 PR1 component).
|
|
3
|
+
*
|
|
4
|
+
* Reads source text from disk, builds canonical text, checks the cache
|
|
5
|
+
* (skip re-embed when content_hash matches), embeds via the host provider,
|
|
6
|
+
* and persists vectors to the `symbol_vectors` table.
|
|
7
|
+
*
|
|
8
|
+
* Rule 30/48: when `config.enabled` is false, this module returns a tagged
|
|
9
|
+
* `{ ok: false, code: "semantic_disabled" }` WITHOUT reading source,
|
|
10
|
+
* calling the provider, or writing a vector (gate-off parity test covers
|
|
11
|
+
* this). The gate is checked at this single chokepoint — callers never
|
|
12
|
+
* need to re-check.
|
|
13
|
+
*
|
|
14
|
+
* Rule 44: vectors are written only for symbols that persisted
|
|
15
|
+
* successfully. The indexer reads nodes from the store (which only
|
|
16
|
+
* contains persisted nodes), so a `parse_failed` file contributes zero
|
|
17
|
+
* nodes and thus zero vectors.
|
|
18
|
+
*
|
|
19
|
+
* Rule 37: cache invalidation. When a symbol's canonical text changes,
|
|
20
|
+
* its content_hash changes, the cached row no longer matches, and the
|
|
21
|
+
* vector is re-embedded. The old vector is overwritten (ON CONFLICT
|
|
22
|
+
* UPDATE). Any SIMILAR_TO edge derived from the old vector is recomputed
|
|
23
|
+
* by the similarity pipeline on its next run.
|
|
24
|
+
*/
|
|
25
|
+
import { readFile } from "node:fs/promises";
|
|
26
|
+
import path from "node:path";
|
|
27
|
+
|
|
28
|
+
import type { HostEmbeddingProvider } from "@remnic/core/host-embedding-provider";
|
|
29
|
+
import { normalizeHostEmbeddingVector } from "@remnic/core/host-embedding-provider";
|
|
30
|
+
|
|
31
|
+
import type { GraphStore } from "../graph-store.js";
|
|
32
|
+
import { buildCanonicalTextAndHash } from "./canonical-text.js";
|
|
33
|
+
import type { SemanticConfig } from "./config.js";
|
|
34
|
+
import type { IndexVectorsResult, SemanticFailure } from "./types.js";
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Input to {@link indexSymbolVectors}. The store provides node metadata +
|
|
38
|
+
* the vectors table; the provider embeds; repoRoot resolves file paths.
|
|
39
|
+
*/
|
|
40
|
+
export interface IndexVectorsInput {
|
|
41
|
+
readonly store: GraphStore;
|
|
42
|
+
readonly provider: HostEmbeddingProvider | undefined;
|
|
43
|
+
readonly repoRoot: string;
|
|
44
|
+
readonly config: SemanticConfig;
|
|
45
|
+
/**
|
|
46
|
+
* Optional abort signal forwarded to the provider. The indexer does not
|
|
47
|
+
* impose its own timeout (the provider's embed() contract handles that).
|
|
48
|
+
*/
|
|
49
|
+
readonly signal?: AbortSignal;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The model id used for cache keying. Derives from the provider's `model`
|
|
54
|
+
* (falling back to `id`) so a provider/model swap produces a distinct
|
|
55
|
+
* cache namespace and does not overwrite the prior vectors.
|
|
56
|
+
*/
|
|
57
|
+
export function modelIdFor(provider: HostEmbeddingProvider): string {
|
|
58
|
+
return provider.model ?? provider.id;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Index symbol vectors for every persisted node in the store.
|
|
63
|
+
*
|
|
64
|
+
* Flow:
|
|
65
|
+
* 1. Gate: if !config.enabled → tagged semantic_disabled (no work).
|
|
66
|
+
* 2. Provider check: if no provider → tagged provider_unavailable.
|
|
67
|
+
* 3. Read all nodes from the store (persisted only — rule 44).
|
|
68
|
+
* 4. For each node (within maxSymbolsPerRun budget):
|
|
69
|
+
* a. Read source text from disk.
|
|
70
|
+
* b. Build canonical text + hash.
|
|
71
|
+
* c. Cache check: skip if cached row's content_hash matches.
|
|
72
|
+
* d. Embed via provider.
|
|
73
|
+
* e. Normalize (reject malformed → counted as skipped).
|
|
74
|
+
* f. Persist vector.
|
|
75
|
+
* 5. Return counts.
|
|
76
|
+
*
|
|
77
|
+
* Budget order (rule 27): recently-changed symbols first. The store's
|
|
78
|
+
* readNodesForSemantic returns nodes ordered by qualified_name; the
|
|
79
|
+
* indexer applies the caller-supplied priority before slicing. When no
|
|
80
|
+
* priority is given, all nodes are eligible (budget 0 = unlimited).
|
|
81
|
+
*/
|
|
82
|
+
export async function indexSymbolVectors(
|
|
83
|
+
input: IndexVectorsInput,
|
|
84
|
+
): Promise<IndexVectorsResult | SemanticFailure> {
|
|
85
|
+
const { store, provider, repoRoot, config, signal } = input;
|
|
86
|
+
|
|
87
|
+
// Closed store is a distinct degradation (rule 34) — do not treat it as
|
|
88
|
+
// an empty graph that returns { ok: true } with zero counts (cursor
|
|
89
|
+
// Bugbot: 'Closed store reports success').
|
|
90
|
+
if (store.isClosed) {
|
|
91
|
+
return { ok: false, code: "store_closed" };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Rule 30/48 + rule 39: single gate chokepoint.
|
|
95
|
+
if (!config.enabled) {
|
|
96
|
+
return { ok: false, code: "semantic_disabled" };
|
|
97
|
+
}
|
|
98
|
+
if (!provider) {
|
|
99
|
+
return { ok: false, code: "provider_unavailable" };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const modelId = modelIdFor(provider);
|
|
103
|
+
// Wrap the store read so a transient SQLite error (SQLITE_BUSY / CORRUPT)
|
|
104
|
+
// maps to a tagged db_error instead of escaping indexSymbolVectors (#1680).
|
|
105
|
+
let nodes: readonly ReturnType<typeof store.readNodesForSemantic>[number][];
|
|
106
|
+
try {
|
|
107
|
+
nodes = store.readNodesForSemantic();
|
|
108
|
+
} catch {
|
|
109
|
+
return { ok: false, code: "db_error" };
|
|
110
|
+
}
|
|
111
|
+
// Budget applies to EMBED WORK, not the candidate list. Slicing the
|
|
112
|
+
// full node list (ordered by qualified_name) BEFORE the cache check
|
|
113
|
+
// meant a bounded run kept re-visiting the cached alphabetical prefix
|
|
114
|
+
// and never reached uncached or changed symbols later in the list, so
|
|
115
|
+
// a bounded semantic index could remain permanently incomplete. Now
|
|
116
|
+
// every node gets a cache check and only a successful embed consumes
|
|
117
|
+
// budget, so progress accumulates across runs until every symbol is
|
|
118
|
+
// embedded (cursor Bugbot: 'Embedding budget uses alphabetical order';
|
|
119
|
+
// chatgpt-codex-connector P2: 'Apply vector budget after skipping
|
|
120
|
+
// cached rows'). maxSymbolsPerRun=0 means unlimited.
|
|
121
|
+
const limit = config.maxSymbolsPerRun;
|
|
122
|
+
|
|
123
|
+
let embedded = 0;
|
|
124
|
+
let cached = 0;
|
|
125
|
+
let skipped = 0;
|
|
126
|
+
// Budget bounds provider CALLS (cost), not successful writes. A degraded
|
|
127
|
+
// provider that throws / returns null / returns a malformed vector for
|
|
128
|
+
// many uncached symbols must not bypass the per-run cost cap by never
|
|
129
|
+
// incrementing `embedded` (chatgpt-codex-connector: 'Count failed embed
|
|
130
|
+
// attempts against the vector budget'). Cached rows do not consume
|
|
131
|
+
// budget (no provider call); source-read failures do not either.
|
|
132
|
+
let embedAttempts = 0;
|
|
133
|
+
|
|
134
|
+
for (const node of nodes) {
|
|
135
|
+
if (signal?.aborted) break;
|
|
136
|
+
if (limit > 0 && embedAttempts >= limit) break;
|
|
137
|
+
// Read source text from disk.
|
|
138
|
+
const absolutePath = path.resolve(repoRoot, node.filePath);
|
|
139
|
+
let bytes: Buffer;
|
|
140
|
+
try {
|
|
141
|
+
bytes = await readFile(absolutePath);
|
|
142
|
+
} catch {
|
|
143
|
+
skipped += 1;
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
const start = Math.max(0, node.startByte);
|
|
147
|
+
const end = Math.min(bytes.length, node.endByte);
|
|
148
|
+
if (start > end) {
|
|
149
|
+
skipped += 1;
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
const rawText = bytes.subarray(start, end).toString("utf8");
|
|
153
|
+
|
|
154
|
+
// Canonical text + hash (rule 23/37). The embedded string MUST equal
|
|
155
|
+
// the hashed string (rule 23 — one form everywhere).
|
|
156
|
+
const { text: canonicalText, hash } = buildCanonicalTextAndHash({
|
|
157
|
+
symbol: {
|
|
158
|
+
kind: node.kind as never,
|
|
159
|
+
name: node.qualifiedName.split(/[.#:]/).pop() ?? node.qualifiedName,
|
|
160
|
+
qualifiedName: node.qualifiedName,
|
|
161
|
+
span: { startByte: node.startByte, endByte: node.endByte },
|
|
162
|
+
},
|
|
163
|
+
rawText,
|
|
164
|
+
maxBodyLines: config.canonicalBodyLines,
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
// Cache check: skip re-embed when content_hash matches AND the stored
|
|
168
|
+
// dims still equal the active provider's declared dimensions. A model
|
|
169
|
+
// that keeps the same model_id but changes vector size would otherwise
|
|
170
|
+
// leave stale-dimensionality rows cached (cursor Bugbot: 'Cache ignores
|
|
171
|
+
// embedding dimension changes'). When the provider does not declare
|
|
172
|
+
// dimensions (optional), the dims gate is skipped so caching still
|
|
173
|
+
// works instead of re-embedding every run (cursor Bugbot: 'Cache misses
|
|
174
|
+
// without provider dimensions').
|
|
175
|
+
const cachedRow = store.readSymbolVector(node.nodeId, modelId);
|
|
176
|
+
if (
|
|
177
|
+
cachedRow &&
|
|
178
|
+
cachedRow.contentHash === hash &&
|
|
179
|
+
(provider.dimensions === undefined || cachedRow.dims === provider.dimensions)
|
|
180
|
+
) {
|
|
181
|
+
cached += 1;
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Count the embed attempt (cost) BEFORE the call, regardless of
|
|
186
|
+
// outcome, so a failed/null/malformed result still consumes budget.
|
|
187
|
+
embedAttempts += 1;
|
|
188
|
+
// Embed the CANONICAL text (not the raw span) so the vector
|
|
189
|
+
// corresponds to the content_hash that gates cache hits (rule 23).
|
|
190
|
+
// This also respects canonicalBodyLines as a cost/privacy bound.
|
|
191
|
+
let raw: ArrayLike<number> | null;
|
|
192
|
+
try {
|
|
193
|
+
raw = await provider.embed(canonicalText, {
|
|
194
|
+
signal,
|
|
195
|
+
inputType: "document",
|
|
196
|
+
});
|
|
197
|
+
} catch {
|
|
198
|
+
// Stale vector cleanup (rule 37): if a prior vector exists with a
|
|
199
|
+
// different content_hash and we cannot re-embed, delete the stale
|
|
200
|
+
// row so semantic_query/cosine confirmation do not serve it.
|
|
201
|
+
if (cachedRow && cachedRow.contentHash !== hash) {
|
|
202
|
+
await store.deleteSymbolVectors([node.nodeId]);
|
|
203
|
+
}
|
|
204
|
+
skipped += 1;
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
const vec = normalizeHostEmbeddingVector(raw);
|
|
208
|
+
if (!vec || vec.length === 0) {
|
|
209
|
+
if (cachedRow && cachedRow.contentHash !== hash) {
|
|
210
|
+
await store.deleteSymbolVectors([node.nodeId]);
|
|
211
|
+
}
|
|
212
|
+
skipped += 1;
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
const float32 = new Float32Array(vec);
|
|
216
|
+
// Only count a persisted embed — writeSymbolVector returns false (and
|
|
217
|
+
// is a no-op) when the store is closing/closed, so progress reporting
|
|
218
|
+
// must not claim an embedding that was dropped (cursor Bugbot: 'Embedded
|
|
219
|
+
// count after dropped writes').
|
|
220
|
+
const persisted = await store.writeSymbolVector({
|
|
221
|
+
nodeId: node.nodeId,
|
|
222
|
+
modelId,
|
|
223
|
+
contentHash: hash,
|
|
224
|
+
dims: float32.length,
|
|
225
|
+
vector: float32,
|
|
226
|
+
});
|
|
227
|
+
if (persisted) {
|
|
228
|
+
embedded += 1;
|
|
229
|
+
} else {
|
|
230
|
+
skipped += 1;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return { ok: true, embedded, cached, skipped };
|
|
235
|
+
}
|