@remnic/coding-graph 9.3.759

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/README.md +130 -0
  2. package/dist/chunk-5I2DBHOQ.js +1042 -0
  3. package/dist/chunk-5I2DBHOQ.js.map +1 -0
  4. package/dist/chunk-CPYJACC5.js +1838 -0
  5. package/dist/chunk-CPYJACC5.js.map +1 -0
  6. package/dist/chunk-ZVCMIM4T.js +216 -0
  7. package/dist/chunk-ZVCMIM4T.js.map +1 -0
  8. package/dist/cypher/query-parser.d.ts +253 -0
  9. package/dist/cypher/query-parser.js +17 -0
  10. package/dist/cypher/query-parser.js.map +1 -0
  11. package/dist/graph-schema.d.ts +84 -0
  12. package/dist/graph-schema.js +17 -0
  13. package/dist/graph-schema.js.map +1 -0
  14. package/dist/graph-store.d.ts +938 -0
  15. package/dist/graph-store.js +16 -0
  16. package/dist/graph-store.js.map +1 -0
  17. package/dist/index.d.ts +1953 -0
  18. package/dist/index.js +3509 -0
  19. package/dist/index.js.map +1 -0
  20. package/grammars/tree-sitter-bash.wasm +0 -0
  21. package/grammars/tree-sitter-c.wasm +0 -0
  22. package/grammars/tree-sitter-c_sharp.wasm +0 -0
  23. package/grammars/tree-sitter-cpp.wasm +0 -0
  24. package/grammars/tree-sitter-go.wasm +0 -0
  25. package/grammars/tree-sitter-java.wasm +0 -0
  26. package/grammars/tree-sitter-javascript.wasm +0 -0
  27. package/grammars/tree-sitter-kotlin.wasm +0 -0
  28. package/grammars/tree-sitter-php.wasm +0 -0
  29. package/grammars/tree-sitter-python.wasm +0 -0
  30. package/grammars/tree-sitter-ruby.wasm +0 -0
  31. package/grammars/tree-sitter-rust.wasm +0 -0
  32. package/grammars/tree-sitter-swift.wasm +0 -0
  33. package/grammars/tree-sitter-tsx.wasm +0 -0
  34. package/grammars/tree-sitter-typescript.wasm +0 -0
  35. package/package.json +79 -0
  36. package/src/co-change.test.ts +175 -0
  37. package/src/co-change.ts +167 -0
  38. package/src/cypher/query-parser.test.ts +1107 -0
  39. package/src/cypher/query-parser.ts +1692 -0
  40. package/src/detect-changes.test.ts +533 -0
  41. package/src/detect-changes.ts +367 -0
  42. package/src/engine/emit.ts +556 -0
  43. package/src/engine/engine.test.ts +1417 -0
  44. package/src/engine/engine.ts +182 -0
  45. package/src/engine/extractors.ts +486 -0
  46. package/src/engine/fixtures.ts +364 -0
  47. package/src/engine/language-sniff.ts +56 -0
  48. package/src/engine/parser-backend.ts +206 -0
  49. package/src/engine/utf16-offsets.ts +68 -0
  50. package/src/git-invoker.test.ts +116 -0
  51. package/src/git-invoker.ts +426 -0
  52. package/src/graph-schema.test.ts +541 -0
  53. package/src/graph-schema.ts +383 -0
  54. package/src/graph-store-pr2.test.ts +1879 -0
  55. package/src/graph-store.test.ts +1420 -0
  56. package/src/graph-store.ts +3489 -0
  57. package/src/index-status.test.ts +303 -0
  58. package/src/index-status.ts +135 -0
  59. package/src/index.ts +384 -0
  60. package/src/lsp/byte-position.ts +173 -0
  61. package/src/lsp/characterization.test.ts +174 -0
  62. package/src/lsp/client.test.ts +275 -0
  63. package/src/lsp/client.ts +484 -0
  64. package/src/lsp/config.ts +219 -0
  65. package/src/lsp/degradation.ts +86 -0
  66. package/src/lsp/fixtures/fake-server.mjs +198 -0
  67. package/src/lsp/framing.test.ts +180 -0
  68. package/src/lsp/framing.ts +177 -0
  69. package/src/lsp/resolution.test.ts +497 -0
  70. package/src/lsp/resolution.ts +483 -0
  71. package/src/lsp/status.ts +140 -0
  72. package/src/lsp/types.ts +167 -0
  73. package/src/reindex.test.ts +1038 -0
  74. package/src/reindex.ts +908 -0
  75. package/src/row-types.ts +45 -0
  76. package/src/semantic/canonical-text.test.ts +150 -0
  77. package/src/semantic/canonical-text.ts +219 -0
  78. package/src/semantic/config.ts +235 -0
  79. package/src/semantic/index.ts +78 -0
  80. package/src/semantic/minhash.test.ts +197 -0
  81. package/src/semantic/minhash.ts +261 -0
  82. package/src/semantic/semantic-query.ts +173 -0
  83. package/src/semantic/semantic.test.ts +1315 -0
  84. package/src/semantic/similarity.ts +268 -0
  85. package/src/semantic/types.ts +145 -0
  86. package/src/semantic/vectors.ts +235 -0
@@ -0,0 +1,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
+ }