@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,3489 @@
1
+ /**
2
+ * Coding-graph write pipeline — two-pass single-batch single-transaction
3
+ * delete + reinsert per file. PR1 scope (issue #1552):
4
+ *
5
+ * - Node ids are sha256 over SORTED key material (qualified name, file
6
+ * path, label) — rule 23/38; the same hash MUST be computed identically
7
+ * at ingest and lookup time.
8
+ * - File contents are NEVER stored — only spans + content hashes
9
+ * (privacy + DB size; rule 11). `get_code_snippet` is a read-side
10
+ * concern that lands in PR2.
11
+ * - DB handles live on the GraphStore instance, keyed per instance —
12
+ * never module scope (rule 11).
13
+ * - Writes are serialized per DB with a rejection-recovering queue
14
+ * (rule 40). A second concurrent `upsertFileBatch` call waits on a
15
+ * FIFO tail then runs; if it rejects (timeout / abort), the queue
16
+ * drains so the next call can proceed.
17
+ * - One DB per namespace path passed in by the caller — this PR does
18
+ * NOT add namespace resolution; rule 42 keeps it that way until PR2
19
+ * wires the namespace layer.
20
+ *
21
+ * Two-pass ingestion (per `upsertFileBatch`):
22
+ * 1. Every file in the batch is upserted (file row + node row per
23
+ * symbol). FTS5 is kept in lockstep via explicit DELETE/INSERT on
24
+ * the contentless `nodes_fts` table (the write pipeline is the
25
+ * single source of FTS truth — no auto-triggers).
26
+ * 2. Edges are resolved against the FULL batch's node map (already in
27
+ * the DB after pass 1) so cross-file edges are order-independent.
28
+ * Stale edges for files in this batch are deleted first so changed
29
+ * confidence/provenance values overwrite prior rows.
30
+ *
31
+ * Tagged failure shapes (rule 34): the open + write paths return a
32
+ * discriminated union. Success: `{ok:true, results: UpsertResult[]}`. Failure:
33
+ * `{ok:false, code:"db_locked"|"db_corrupt"}` — no message is exposed
34
+ * to callers because `error.message` from better-sqlite3 frequently
35
+ * contains absolute filesystem paths and stack snippets that should
36
+ * never reach agents or HTTP surfaces (rule 11). The store logs the
37
+ * underlying error internally and returns the code only.
38
+ */
39
+ import { createHash } from "node:crypto";
40
+ import { mkdir, readFile } from "node:fs/promises";
41
+ import path from "node:path";
42
+
43
+ import {
44
+ openBetterSqlite3,
45
+ type BetterSqlite3Database,
46
+ } from "@remnic/core/runtime/better-sqlite";
47
+
48
+ import {
49
+ applyCodingGraphSchema,
50
+ ftsRowidForNodeId,
51
+ isEdgeProvenance,
52
+ readSchemaVersion,
53
+ type EdgeProvenance,
54
+ } from "./graph-schema.js";
55
+
56
+ import { expectRow, expectRows } from "./row-types.js";
57
+
58
+ // Re-export the core IR contract types so existing imports from
59
+ // `./graph-store.js` still resolve. The store no longer redefines these;
60
+ // it derives from @remnic/core's contract so PR2 callers can pass
61
+ // `ParseResult.ir` directly without field-name translation or casts
62
+ // (chatgpt-codex-connector P2: 'Derive store FileIR from the core parser
63
+ // contract'). Core owns the canonical types in
64
+ // packages/remnic-core/src/coding/coding-graph-types.ts; this package
65
+ // implements against them.
66
+ export type {
67
+ FileIR,
68
+ SymbolIR,
69
+ ImportIR,
70
+ ExportIR,
71
+ CallSiteIR,
72
+ RouteIR,
73
+ CodingGraphLanguage,
74
+ } from "@remnic/core/coding/coding-graph-types";
75
+
76
+ import type {
77
+ FileIR,
78
+ SymbolIR,
79
+ ExportIR,
80
+ RouteIR,
81
+ CodingGraphLanguage,
82
+ } from "@remnic/core/coding/coding-graph-types";
83
+
84
+ /**
85
+ * Half-open byte span `[startByte, endByte)` — matches @remnic/core's
86
+ * inline span type. Kept as a named alias for API consumers that import
87
+ * `ByteSpan` from the store subpath (issue #1551 / rule 35).
88
+ */
89
+ export type ByteSpan = { readonly startByte: number; readonly endByte: number };
90
+
91
+ /**
92
+ * Symbol kind union — matches @remnic/core's `SymbolIR["kind"]` exactly
93
+ * (core does not export this as a named type).
94
+ */
95
+ export type SymbolKind =
96
+ | "function"
97
+ | "class"
98
+ | "method"
99
+ | "interface"
100
+ | "enum"
101
+ | "type"
102
+ | "module";
103
+
104
+ /**
105
+ * Store-specific edge — references nodes by `qualifiedName` so the store
106
+ * can resolve them against the same batch's symbol set plus the on-disk
107
+ * node table. PR1 only carries CALLS-style edges; PR2 adds the rest of
108
+ * #1552's edge types.
109
+ *
110
+ * Optional `srcNodeId` / `dstNodeId` (issue #1677) carry the content-
111
+ * derived node id (the same canonical hash form the store uses as
112
+ * `nodes.id`, see `nodeIdFor`). When present, the standalone
113
+ * `upsertEdges` path resolves the endpoint by `nodes.id` (unique) instead
114
+ * of by qualified name, so a SIMILAR_TO edge between two symbols that
115
+ * share a qualified name across files is persisted rather than dropped as
116
+ * ambiguous. The qname-keyed file-batch path and the existing
117
+ * `ambiguous … drops edges` behavior are unchanged. Only populated by
118
+ * callers that originate edges from node-id-keyed pairs (the semantic
119
+ * SIMILAR_TO pipeline); structural/trace edges keep the qname path.
120
+ */
121
+ export interface EdgeIR {
122
+ /** Qualified name of the source node (caller / definition site). */
123
+ srcQualifiedName: string;
124
+ /** Qualified name of the destination node (callee / type used). */
125
+ dstQualifiedName: string;
126
+ type: string;
127
+ confidence: number;
128
+ provenance: EdgeProvenance;
129
+ /**
130
+ * Optional content-derived source node id (`nodes.id`). When present on
131
+ * a standalone-edge upsert, the store resolves the endpoint by id
132
+ * (unambiguous) instead of falling back to qualified-name resolution.
133
+ */
134
+ readonly srcNodeId?: string;
135
+ /** Optional content-derived destination node id — see {@link EdgeIR.srcNodeId}. */
136
+ readonly dstNodeId?: string;
137
+ }
138
+
139
+ /**
140
+ * Store input — the subset of @remnic/core's `FileIR` the store reads,
141
+ * plus the store-specific `edges` extension. A core `FileIR` (from
142
+ * `ParseResult.ir`) is structurally assignable here: all required fields
143
+ * (path, language, contentHash, symbols, imports, exports, callSites,
144
+ * routes) match by name and readonly-ness. PR2 callers pass
145
+ * `{ ...parseResult.ir, edges }` (or the bare IR when edges are absent)
146
+ * with zero casts or field-name translation.
147
+ *
148
+ * PR2 adds optional `exports` and `routes` consumption: when present,
149
+ * the write pipeline marks matching nodes in `node_attributes` so the
150
+ * `deadCode()` query can exclude them via the
151
+ * {@link DEAD_CODE_EXCLUSION} constant. Both fields are optional because
152
+ * a PR1-era caller (or a JSON-IR caller that strips them) still ingests
153
+ * cleanly — the dead-code query simply sees no exclusion flags.
154
+ */
155
+ export interface StoreFileIR {
156
+ readonly path: string;
157
+ readonly language: CodingGraphLanguage;
158
+ readonly contentHash: string;
159
+ readonly symbols: readonly SymbolIR[];
160
+ /** Store-specific edges derived from the IR by the caller. */
161
+ readonly edges?: readonly EdgeIR[];
162
+ /**
163
+ * Per-file export list (mirrors core FileIR.exports). When present,
164
+ * the write pipeline marks every node in this file whose `name`
165
+ * matches an ExportIR.name as `is_exported=1` in `node_attributes`.
166
+ * Name-matching is the conventional pattern: a parser that emits a
167
+ * `export const foo` declaration also emits a SymbolIR named `foo`
168
+ * (or omits it if foo is a non-symbol like a plain variable); the
169
+ * dead-code query then excludes surviving exported symbols.
170
+ */
171
+ readonly exports?: readonly ExportIR[];
172
+ /**
173
+ * Per-file HTTP route declarations (mirrors core FileIR.routes). When
174
+ * present, the write pipeline marks the node whose `qualifiedName`
175
+ * equals `route.handlerQualifiedName` as `is_route_handler=1` in
176
+ * `node_attributes`. Route handlers are reachable from HTTP traffic
177
+ * regardless of whether any other indexed node CALLS them.
178
+ */
179
+ readonly routes?: readonly RouteIR[];
180
+ }
181
+
182
+ // ──────────────────────────────────────────────────────────────────────────
183
+ // Result shapes — tagged failures (rule 34).
184
+ // ──────────────────────────────────────────────────────────────────────────
185
+
186
+ export type GraphStoreFailureCode = "db_locked" | "db_corrupt" | "db_error" | "store_closed";
187
+
188
+ export interface GraphStoreFailure {
189
+ ok: false;
190
+ code: GraphStoreFailureCode;
191
+ }
192
+
193
+ export interface UpsertResult {
194
+ path: string;
195
+ fileId: number;
196
+ nodeCount: number;
197
+ edgeCount: number;
198
+ /**
199
+ * Dangling edges observed while deleting the file's prior subgraph
200
+ * (cross-file edges whose `dst` belonged to a node owned by this file).
201
+ * Per the PR1 dangling-edge policy in {@link graph-schema}, they are
202
+ * DROPPED, not kept with a marker. Surfaced here so callers can log
203
+ * the loss (rule 11, 40).
204
+ */
205
+ droppedDanglingEdges: number;
206
+ }
207
+
208
+ export interface UpsertSuccess {
209
+ ok: true;
210
+ results: UpsertResult[];
211
+ }
212
+
213
+ export type UpsertBatchResult = UpsertSuccess | GraphStoreFailure;
214
+
215
+ /**
216
+ * Result of {@link GraphStore.upsertEdges} — a standalone-edge write used by
217
+ * the codegraph ingest_traces surface (issue #1554). `persisted` counts
218
+ * edges actually inserted/updated; `skipped` counts edges whose src or dst
219
+ * did not resolve to exactly one node (dangling-edge policy).
220
+ */
221
+ export interface UpsertEdgesSuccess {
222
+ ok: true;
223
+ persisted: number;
224
+ skipped: number;
225
+ }
226
+ export type UpsertEdgesResult = UpsertEdgesSuccess | GraphStoreFailure;
227
+
228
+ // ──────────────────────────────────────────────────────────────────────────
229
+ // PR2 read primitives — query / result types (issue #1552 steps 4–5).
230
+ // Tagged failures share the {@link GraphStoreFailureCode} set so callers
231
+ // can use one switch over every store surface.
232
+ // ──────────────────────────────────────────────────────────────────────────
233
+
234
+ /** Direction of traversal relative to the edge's src→dst orientation. */
235
+ export type TraverseDirection = "outgoing" | "incoming" | "both";
236
+
237
+ /**
238
+ * Iterative frontier BFS over the edges table. Cycle-safe via a JS
239
+ * visited set keyed by node id; predictable memory regardless of graph
240
+ * shape (recursive CTEs are the documented fallback if benchmarks ever
241
+ * justify them — issue #1552 design section).
242
+ */
243
+ export interface TraverseQuery {
244
+ /**
245
+ * Start node. Accepts either a node id or a qualified name — the
246
+ * store resolves a qualified name to its deterministic id via the
247
+ * same `(qualifiedName, filePath, label)` identity used at ingest
248
+ * time. When the qualified name is ambiguous (declared in more than
249
+ * one file), the query is rejected with `code: "ambiguous_start"`
250
+ * so the caller can pass an explicit node id instead.
251
+ */
252
+ start: string;
253
+ /** Default `"outgoing"`. */
254
+ direction?: TraverseDirection;
255
+ /**
256
+ * Edge types to follow (e.g. `["CALLS", "USES_TYPE"]`). When omitted
257
+ * or empty, every edge type in the table is followed. Unknown edge
258
+ * types simply contribute no rows — the query is not rejected
259
+ * because the schema places no CHECK constraint on `edges.type`.
260
+ */
261
+ edgeTypes?: readonly string[];
262
+ /**
263
+ * Maximum BFS depth. Half-open: a node at depth == maxDepth IS
264
+ * included; a node at depth maxDepth+1 is NOT (rule 35). The start
265
+ * node itself sits at depth 0 and is always included in the result
266
+ * set when it exists. A maxDepth of 0 returns just the start node.
267
+ * MUST be a non-negative integer — invalid values are rejected with
268
+ * `code: "invalid_query"` rather than silently clamped (rule 51).
269
+ */
270
+ maxDepth: number;
271
+ }
272
+
273
+ export interface TraverseHit {
274
+ nodeId: string;
275
+ qualifiedName: string;
276
+ name: string;
277
+ label: string;
278
+ /** Repo-relative file path of the node (joined from files.path). */
279
+ filePath: string;
280
+ /** BFS depth from the start node (start = 0). */
281
+ depth: number;
282
+ }
283
+
284
+ export type TraverseResult =
285
+ | { ok: true; hits: TraverseHit[] }
286
+ | ({ ok: false } & GraphStoreFailure)
287
+ | { ok: false; code: "unknown_start" | "ambiguous_start" | "invalid_query" };
288
+
289
+ /**
290
+ * Default cap on the number of concrete paths {@link GraphStore.traversePaths}
291
+ * enumerates before stopping and flagging `truncated`. Bounds the worst-case
292
+ * exponential blowup of relationship-simple path enumeration on dense
293
+ * subgraphs (issue #1650). Callers may override per-query via
294
+ * {@link TraversePathsQuery.maxPaths}.
295
+ */
296
+ export const DEFAULT_TRAVERSE_PATHS_MAX = 10_000;
297
+
298
+ /**
299
+ * Hard upper bound on {@link TraversePathsQuery.maxHops}. The DFS recurses
300
+ * once per hop; an unbounded depth (e.g. a Cypher `*15000`) would overflow
301
+ * the call stack before `maxPaths` could stop it. 1000 is ~100x any
302
+ * realistic code-graph depth and recurses safely (chatgpt-codex-connector
303
+ * P2: 'Avoid recursive DFS for deep bounded paths').
304
+ */
305
+ export const MAX_TRAVERSE_PATHS_HOPS = 1000;
306
+
307
+ /**
308
+ * Path-enumerating traversal query (issue #1650). Mirrors {@link TraverseQuery}
309
+ * but yields CONCRETE paths rather than BFS-shortest-depth reachability, so an
310
+ * exact `*N` (N > 1) hop count is honored for nodes reachable at both a shorter
311
+ * and a length-N path.
312
+ */
313
+ export interface TraversePathsQuery {
314
+ /** Start node id or qualified name (same resolution rules as {@link TraverseQuery.start}). */
315
+ start: string;
316
+ /** Default `"outgoing"`. */
317
+ direction?: TraverseDirection;
318
+ /** Edge types to follow; omitted/empty means every type. */
319
+ edgeTypes?: readonly string[];
320
+ /**
321
+ * Inclusive upper bound on enumerated path LENGTH (hop count). MUST be a
322
+ * non-negative integer. A `maxHops` of 0 yields no paths (every enumerated
323
+ * path has length >= 1); callers that need the length-0 trivial path add it
324
+ * themselves.
325
+ */
326
+ maxHops: number;
327
+ /**
328
+ * Inclusive LOWER bound on EMITTED path length (hop count). Defaults
329
+ * to 1. The DFS still EXPLORES shorter prefixes to reach longer paths,
330
+ * but only EMITS (and counts toward {@link maxPaths}) paths whose length
331
+ * is in `[minHops, maxHops]` -- so an exact `*N` cap is not consumed by
332
+ * the shorter prefixes (cursor Bugbot: 'Path cap ignores hop minimum').
333
+ * MUST be a positive integer (>= 1) when present.
334
+ */
335
+ minHops?: number;
336
+ /**
337
+ * Safety cap on total enumerated paths. Defaults to
338
+ * {@link DEFAULT_TRAVERSE_PATHS_MAX}. When the cap is reached, enumeration
339
+ * STOPS and the result carries `truncated: true` so callers can detect that
340
+ * the result is incomplete (e.g. to narrow the query or raise the cap).
341
+ */
342
+ maxPaths?: number;
343
+ }
344
+
345
+ /**
346
+ * One enumerated path. The endpoint node is fully resolved; the full node-id
347
+ * sequence lets callers reconstruct the path (issue #1650 acceptance).
348
+ */
349
+ export interface TraversePathHit {
350
+ nodeId: string;
351
+ qualifiedName: string;
352
+ name: string;
353
+ label: string;
354
+ filePath: string;
355
+ /** Length of this path in hops (>= 1). */
356
+ length: number;
357
+ /** Full path as node ids, start-first (`length + 1` entries). */
358
+ nodeIds: string[];
359
+ /**
360
+ * Edge type per hop, parallel to {@link nodeIds} (`length` entries). Two
361
+ * distinct relationships can connect the same node pair with different
362
+ * types (the edges table is UNIQUE on `(src, dst, type)`); exposing the
363
+ * type per hop lets callers distinguish those otherwise-identical-node
364
+ * paths (chatgpt-codex-connector P2: 'Include edge identity in path
365
+ * hits').
366
+ */
367
+ edgeTypes: string[];
368
+ /**
369
+ * Per-hop edge endpoints, parallel to {@link nodeIds} (`length` entries).
370
+ * Under `direction: "both"` antiparallel same-type edges (A->B and B->A)
371
+ * yield distinct relationship-simple paths that share nodeIds + edgeTypes;
372
+ * the src/dst per hop disambiguates which edge was traversed and in which
373
+ * direction (chatgpt-codex-connector P2: 'Include edge endpoints in path
374
+ * hits').
375
+ */
376
+ edgeEndpoints: Array<{ src: string; dst: string }>;
377
+ }
378
+
379
+ export type TraversePathsResult =
380
+ | { ok: true; hits: TraversePathHit[]; truncated: boolean }
381
+ | ({ ok: false } & GraphStoreFailure)
382
+ | { ok: false; code: "unknown_start" | "ambiguous_start" | "invalid_query" };
383
+
384
+ /**
385
+ * Structured node search. All filters are AND-combined; every filter
386
+ * is optional so the bare query `{}` returns the whole graph (capped
387
+ * by `limit`). Patterns use SQLite `LIKE` semantics — `%` matches any
388
+ * run, `_` matches one character — applied case-insensitively via
389
+ * `LIKE ... COLLATE NOCASE`. Patterns are parameter-bound, never
390
+ * string-interpolated, so a `%`/`_` in user input cannot inject SQL.
391
+ */
392
+ export interface SearchQuery {
393
+ /** Filter by node label (the symbol kind, e.g. `"function"`). */
394
+ label?: string;
395
+ /** LIKE pattern on `nodes.name` (case-insensitive). */
396
+ namePattern?: string;
397
+ /** LIKE pattern on `files.path` (case-insensitive). */
398
+ filePattern?: string;
399
+ /**
400
+ * Inclusive lower bound on total degree (in + out edge count).
401
+ * Combined with {@link degreeMax} for a half-open? — no, inclusive
402
+ * on both ends by convention since degree is an integer count, not
403
+ * a span (rule 35 covers byte/time spans, not integer ranges).
404
+ */
405
+ degreeMin?: number;
406
+ /** Inclusive upper bound on total degree. */
407
+ degreeMax?: number;
408
+ /**
409
+ * Cap on returned rows. Default 100; clamped to [0, 1000]. A
410
+ * `limit: 0` returns an empty `hits` array (rule 27 — guard the
411
+ * slice/LIMIT against the zero case).
412
+ */
413
+ limit?: number;
414
+ }
415
+
416
+ export interface SearchHit {
417
+ nodeId: string;
418
+ qualifiedName: string;
419
+ name: string;
420
+ label: string;
421
+ filePath: string;
422
+ /** Total in + out edge count for this node. */
423
+ degree: number;
424
+ }
425
+
426
+ export type SearchResult =
427
+ | { ok: true; hits: SearchHit[] }
428
+ | ({ ok: false } & GraphStoreFailure)
429
+ | { ok: false; code: "invalid_query" };
430
+
431
+ /** Aggregate counts over the whole graph — single round-trip. */
432
+ export interface SchemaStats {
433
+ files: number;
434
+ nodes: number;
435
+ edges: number;
436
+ /** Node count grouped by `label` (symbol kind). */
437
+ nodesByLabel: Record<string, number>;
438
+ /** Edge count grouped by `type`. */
439
+ edgesByType: Record<string, number>;
440
+ }
441
+
442
+ export type SchemaStatsResult =
443
+ | { ok: true; stats: SchemaStats }
444
+ | ({ ok: false } & GraphStoreFailure);
445
+
446
+ export interface DeadCodeHit {
447
+ nodeId: string;
448
+ qualifiedName: string;
449
+ name: string;
450
+ label: string;
451
+ filePath: string;
452
+ }
453
+
454
+ export type DeadCodeResult =
455
+ | { ok: true; hits: DeadCodeHit[] }
456
+ | ({ ok: false } & GraphStoreFailure);
457
+
458
+ /**
459
+ * Read a symbol's source span from disk. The store NEVER persists file
460
+ * contents (privacy + DB size — issue #1552 design); `snippetFor`
461
+ * resolves the node's `files.path` against {@link GraphStoreOptions.repoRoot}
462
+ * and slices `[span_start, span_end)` from the on-disk bytes.
463
+ */
464
+ export interface SnippetQuery {
465
+ /**
466
+ * Qualified name to resolve. Optional when `nodeId` is supplied — the
467
+ * guard requires at least one of the two.
468
+ */
469
+ qualifiedName?: string;
470
+ /**
471
+ * Optional repo root override. When set, the snippet is read from this
472
+ * root instead of the root captured at GraphStore.open() time, so a
473
+ * caller that supplies its own repoRoot (e.g. semanticQuery) hydrates
474
+ * snippets even when the store was opened without one (chatgpt-codex-
475
+ * connector + cursor: 'Snippet hydration ignores query repoRoot').
476
+ */
477
+ repoRoot?: string;
478
+ /**
479
+ * Optional deterministic node id. When set, the lookup resolves by
480
+ * `nodes.id` (unique) instead of `qualified_name`, so a hit whose
481
+ * qualified name is duplicated across files still hydrates the exact
482
+ * node's snippet instead of failing with `ambiguous_name`
483
+ * (chatgpt-codex-connector P2: 'Hydrate snippets by node id as well').
484
+ */
485
+ nodeId?: string;
486
+ /**
487
+ * Optional lines of context to include before and after the span
488
+ * (default 0 — exact span only). Context is line-aligned: the slice
489
+ * expands to the nearest line boundary at each end.
490
+ */
491
+ contextLines?: number;
492
+ }
493
+
494
+ export interface SnippetSuccess {
495
+ ok: true;
496
+ qualifiedName: string;
497
+ filePath: string;
498
+ /** Absolute path the bytes were read from (`repoRoot/files.path`). */
499
+ absolutePath: string;
500
+ startByte: number;
501
+ endByte: number;
502
+ /** The decoded source slice (UTF-8). */
503
+ text: string;
504
+ lang: string;
505
+ }
506
+
507
+ export type SnippetFailureCode =
508
+ | "not_found"
509
+ | "ambiguous_name"
510
+ | "repo_root_unset"
511
+ | "read_failed"
512
+ | "invalid_query"
513
+ | "store_closed"
514
+ // DB-level failures surface from the node-lookup catch path so the
515
+ // typed contract matches every code classifyReadError can return
516
+ // (cursor Bugbot: 'snippetFor omits store failure codes').
517
+ | "db_locked"
518
+ | "db_corrupt"
519
+ | "db_error";
520
+
521
+ export type SnippetResult = SnippetSuccess | { ok: false; code: SnippetFailureCode };
522
+
523
+ // ──────────────────────────────────────────────────────────────────────────
524
+ // KV / list read primitives — tagged failures (rule 22). readMeta,
525
+ // readFileHashes, and readCoChanges previously caught every error and
526
+ // returned the empty value (null / new Map() / []), making a SQLITE_BUSY
527
+ // indistinguishable from "key absent" / "empty index" / "no co-change
528
+ // edges". The reindex executor's prune + head-advance decisions depend on
529
+ // readFileHashes, so conflating error with empty could skip pruning while
530
+ // advancing head, or prune against a falsely-empty set. These result types
531
+ // force callers to handle the two cases distinctly (cursor Bugbot HIGH:
532
+ // 'readFileHashes conflates error with empty'; 'readCoChanges swallows
533
+ // store errors'; 'readMeta conflates absent key with db failure').
534
+ // ──────────────────────────────────────────────────────────────────────────
535
+
536
+ /** Result of readMeta — `{ ok: true; value: null }` is a genuinely absent key;
537
+ * a tagged failure is a backend error (rule 22). */
538
+ export type ReadMetaResult =
539
+ | { ok: true; value: string | null }
540
+ | ({ ok: false } & GraphStoreFailure);
541
+
542
+ /** Result of readFileHashes — `{ ok: true; hashes: <empty> }` is an empty
543
+ * index; a tagged failure is a backend error (rule 22). */
544
+ export type ReadFileHashesResult =
545
+ | { ok: true; hashes: Map<string, string> }
546
+ | ({ ok: false } & GraphStoreFailure);
547
+
548
+ /** A co-change edge row returned by readCoChanges. */
549
+ export interface ReadCoChangeEdge {
550
+ readonly fileA: string;
551
+ readonly fileB: string;
552
+ readonly support: number;
553
+ readonly confidence: number;
554
+ }
555
+
556
+ /** Result of readCoChanges — `{ ok: true; edges: [] }` means no edges
557
+ * recorded; a tagged failure is a backend error (rule 22). */
558
+ export type ReadCoChangesResult =
559
+ | { ok: true; edges: readonly ReadCoChangeEdge[] }
560
+ | ({ ok: false } & GraphStoreFailure);
561
+
562
+ // ──────────────────────────────────────────────────────────────────────────
563
+ // PR2 dead-code exclusion — explicit named constant (rule 53 analog).
564
+ // ──────────────────────────────────────────────────────────────────────────
565
+
566
+ /**
567
+ * The single source of truth for what `deadCode()` EXCLUDES from the
568
+ * candidate set. Anything matched by these patterns or flags is treated
569
+ * as a non-dead surface even when it has zero inbound call/usage edges.
570
+ *
571
+ * This constant exists so the exclusion criteria are NAMED, DOCUMENTED,
572
+ * and auditable in one place — not scattered across ad-hoc `WHERE`
573
+ * clauses (rule 53 analog). Adding a new exclusion category means
574
+ * extending this constant plus the matching `node_attributes` column;
575
+ * the query then picks both up automatically.
576
+ *
577
+ * Categories:
578
+ * - {@link INBOUND_USAGE_EDGE_TYPES} — an inbound edge of any of these
579
+ * types disqualifies a node from being dead.
580
+ * - {@link TEST_PATH_PATTERNS} — a node whose `files.path` matches is
581
+ * in a test file; tests can call into private code without the
582
+ * production graph seeing the edge.
583
+ * - {@link ENTRY_POINT_PATH_PATTERNS} — process entry points (index,
584
+ * main, cli, bin/); these are reachable from outside the graph.
585
+ * - {@link EXCLUDED_ATTRIBUTE_FLAGS} — per-node flags stored in
586
+ * `node_attributes` (set at write time from FileIR.exports /
587
+ * FileIR.routes); `is_exported` and `is_route_handler`.
588
+ */
589
+ export const DEAD_CODE_EXCLUSION = {
590
+ /**
591
+ * Edge types that — when pointing INTO a node — count as "this node
592
+ * is used". Mirrors the issue's `CALLS/USAGE` wording plus the four
593
+ * call-flavored edge types in the wider coding-graph vocabulary.
594
+ */
595
+ INBOUND_USAGE_EDGE_TYPES: [
596
+ "CALLS",
597
+ "USES_TYPE",
598
+ "ASYNC_CALLS",
599
+ "HTTP_CALLS",
600
+ "DATA_FLOWS",
601
+ ] as const,
602
+ /**
603
+ * File-path regexes identifying test files. Matched against
604
+ * `files.path` (repo-relative, forward slashes).
605
+ */
606
+ TEST_PATH_PATTERNS: [
607
+ /\.test\.[cm]?[tj]sx?$/,
608
+ /\.spec\.[cm]?[tj]sx?$/,
609
+ /(^|\/)__tests__\//,
610
+ /(^|\/)__mocks__\//,
611
+ /(^|\/)tests?\//,
612
+ /(^|\/)test\//,
613
+ ] as const,
614
+ /**
615
+ * File-path regexes identifying entry points (reachable from
616
+ * outside the indexed code). Matched against `files.path`. Kept
617
+ * deliberately narrow — `server.ts` / `app.ts` are intentionally
618
+ * NOT treated as entry points because they are common module
619
+ * names that may also contain dead helpers. The conservative
620
+ * direction is to report a symbol as dead rather than hide it.
621
+ */
622
+ ENTRY_POINT_PATH_PATTERNS: [
623
+ /(^|\/)index\.[cm]?[tj]sx?$/,
624
+ /(^|\/)main\.[cm]?[tj]sx?$/,
625
+ /(^|\/)cli\.[cm]?[tj]sx?$/,
626
+ /(^|\/)bin\//,
627
+ /(^|\/)src\/bin\//,
628
+ ] as const,
629
+ /**
630
+ * Columns on `node_attributes` whose value being `1` excludes the
631
+ * node. Names mirror the schema so a future column add is a one-line
632
+ * constant extension + a query clause (no scattered edits).
633
+ */
634
+ EXCLUDED_ATTRIBUTE_FLAGS: ["is_exported", "is_route_handler"] as const,
635
+ } as const;
636
+
637
+ /**
638
+ * @returns true iff `filePath` matches any pattern in
639
+ * {@link DEAD_CODE_EXCLUSION.TEST_PATH_PATTERNS} or
640
+ * {@link DEAD_CODE_EXCLUSION.ENTRY_POINT_PATH_PATTERNS}.
641
+ */
642
+ function isExcludedByPath(filePath: string): boolean {
643
+ for (const re of DEAD_CODE_EXCLUSION.TEST_PATH_PATTERNS) {
644
+ if (re.test(filePath)) return true;
645
+ }
646
+ for (const re of DEAD_CODE_EXCLUSION.ENTRY_POINT_PATH_PATTERNS) {
647
+ if (re.test(filePath)) return true;
648
+ }
649
+ return false;
650
+ }
651
+
652
+ // ──────────────────────────────────────────────────────────────────────────
653
+ // Internal: SQL variable-limit chunking (PR1 pattern — keep under 32766).
654
+ // ──────────────────────────────────────────────────────────────────────────
655
+
656
+ /** SQLite variable bind limit for the bundled better-sqlite3 native build. */
657
+ const SQLITE_VARIABLE_LIMIT = 32_766;
658
+
659
+ /**
660
+ * Run a parameterized `IN (?, ?, …)` query in chunks small enough to
661
+ * stay under SQLite's variable bind limit. The caller provides the
662
+ * statement prefix/suffix with a single `%PH%` placeholder where the
663
+ * `?,?,…` list goes; this helper substitutes the chunked placeholders
664
+ * and runs `.run(...)` per chunk, aggregating the returned rows.
665
+ *
666
+ * Mirrors the chunking pattern PR1 already uses inside
667
+ * `pruneFileNodes` (the FTS rowid deletes) and `upsertFileEdges` (the
668
+ * stale-edge tuple deletes). The reviews already hardened this class
669
+ * against `too many SQL variables` failures.
670
+ */
671
+ function chunkedInQuery(
672
+ db: BetterSqlite3Database,
673
+ sqlTemplate: string,
674
+ params: readonly (string | number)[],
675
+ ): unknown[] {
676
+ const out: unknown[] = [];
677
+ if (params.length === 0) return out;
678
+ for (let i = 0; i < params.length; i += SQLITE_VARIABLE_LIMIT) {
679
+ const chunk = params.slice(i, i + SQLITE_VARIABLE_LIMIT);
680
+ const placeholders = chunk.map(() => "?").join(", ");
681
+ const sql = sqlTemplate.replace("%PH%", placeholders);
682
+ const rows = db.prepare(sql).all(...chunk);
683
+ if (Array.isArray(rows)) {
684
+ for (const r of rows) out.push(r);
685
+ }
686
+ }
687
+ return out;
688
+ }
689
+
690
+ // ──────────────────────────────────────────────────────────────────────────
691
+ // Internal: write-queue (rule 40).
692
+ // ──────────────────────────────────────────────────────────────────────────
693
+
694
+ /**
695
+ * Per-instance FIFO. A second call to `upsertFileBatch` enqueues and the
696
+ * previous call's promise is awaited before the new one runs. `schedule()`
697
+ * returns immediately with a promise — callers can `await` it or fire-and-
698
+ * forget. A throwing handler propagates the rejection AND drains the queue
699
+ * so the next enqueued call doesn't deadlock (rejection-recovering).
700
+ */
701
+ class WriteQueue {
702
+ private tail: Promise<unknown> = Promise.resolve();
703
+
704
+ schedule<T>(run: () => Promise<T>): Promise<T> {
705
+ const next = this.tail.then(run, run);
706
+ // Swallow the tail's settlement for callers waiting on `next` only.
707
+ // The actual rejection still surfaces from `next` itself.
708
+ this.tail = next.catch(() => undefined);
709
+ return next as Promise<T>;
710
+ }
711
+
712
+ /** Test seam: wait until the queue has drained. */
713
+ async drain(): Promise<void> {
714
+ await this.tail;
715
+ }
716
+ }
717
+
718
+ // ──────────────────────────────────────────────────────────────────────────
719
+ // Store.
720
+ // ──────────────────────────────────────────────────────────────────────────
721
+
722
+ export interface GraphStoreOptions {
723
+ /** Absolute path to the SQLite file. The caller resolves the namespace. */
724
+ dbPath: string;
725
+ /**
726
+ * Optional absolute path to the repo root. When set, `snippetFor()`
727
+ * resolves a node's repo-relative `files.path` against this root to
728
+ * read its source span from disk. When unset, `snippetFor()` returns
729
+ * `code: "repo_root_unset"` for every call. The store NEVER persists
730
+ * file contents (privacy + DB size — issue #1552 design); this is
731
+ * the only path the read-side uses.
732
+ */
733
+ repoRoot?: string;
734
+ }
735
+
736
+ /**
737
+ * One DB per instance. The store does NOT mutate its path or close the
738
+ * handle until {@link close} is called explicitly (rule 11).
739
+ */
740
+ export class GraphStore {
741
+ private readonly db: BetterSqlite3Database;
742
+ private readonly queue = new WriteQueue();
743
+ private readonly repoRoot: string | undefined;
744
+ private closed = false;
745
+ private closing = false;
746
+ /**
747
+ * True once close() has begun (closing) or completed (closed). Public so
748
+ * callers that hold a GraphStore reference can return the documented
749
+ * 'store_closed' degradation code instead of treating a closed store as
750
+ * an empty graph (cursor Bugbot: 'Closed store reports success'). The
751
+ * read primitives already short-circuit on this internally; this getter
752
+ * lets the semantic entry points do the same BEFORE calling a read that
753
+ * would return [].
754
+ */
755
+ get isClosed(): boolean {
756
+ return this.closed || this.closing;
757
+ }
758
+ // Shared drain-and-close promise so a second close() called while the
759
+ // first is still draining awaits the same completion instead of
760
+ // resolving early (chatgpt-codex-connector P2: 'Wait for an
761
+ // in-progress close').
762
+ private closePromise: Promise<void> | undefined;
763
+
764
+ private constructor(db: BetterSqlite3Database, repoRoot: string | undefined) {
765
+ this.db = db;
766
+ // Validate at open() so the failure mode is a thrown, name-specific
767
+ // error at construction — never a silent `code: "repo_root_unset"`
768
+ // cascade on the first snippetFor() call after a long ingest. The
769
+ // caller may still pass `undefined` (the PR1 default); they just
770
+ // cannot pass a relative path that would silently slice the wrong
771
+ // file (rule 11 — no path assembly at call sites).
772
+ this.repoRoot = repoRoot;
773
+ }
774
+
775
+ /**
776
+ * Open a store at the given dbPath. Creates parent directories and
777
+ * applies the schema (idempotent — also handles upgrade). The dbPath
778
+ * does no namespace resolution.
779
+ */
780
+ static async open(options: GraphStoreOptions): Promise<GraphStore> {
781
+ const { dbPath, repoRoot } = options;
782
+ if (!path.isAbsolute(dbPath)) {
783
+ throw new Error(
784
+ `graph-store: dbPath must be absolute; received ${JSON.stringify(dbPath)}`,
785
+ );
786
+ }
787
+ // Validate repoRoot up-front (rule 11). When provided it MUST be
788
+ // absolute — a relative repoRoot would silently resolve against
789
+ // the process CWD and `snippetFor()` would slice the wrong file
790
+ // (or a non-existent one) without a clear failure shape. The
791
+ // PR1 baseline keeps `repoRoot` optional so existing callers that
792
+ // do not need snippets continue to open() with just `{ dbPath }`.
793
+ if (repoRoot !== undefined && !path.isAbsolute(repoRoot)) {
794
+ throw new Error(
795
+ `graph-store: repoRoot must be absolute when provided; received ${JSON.stringify(repoRoot)}`,
796
+ );
797
+ }
798
+ await mkdir(path.dirname(dbPath), { recursive: true });
799
+ const db = openBetterSqlite3(dbPath);
800
+ // Pragmas verbatim from packages/remnic-core/src/lcm/schema.ts — the
801
+ // shared in-repo pattern. Do not tune per-store (rule 23).
802
+ db.pragma("journal_mode = WAL");
803
+ db.pragma("busy_timeout = 5000");
804
+ db.pragma("synchronous = NORMAL");
805
+ // SQLite defaults to foreign_keys=OFF per-connection. The graph's
806
+ // `edges` table relies on `ON DELETE CASCADE` from `nodes(id)` to
807
+ // drop owned edges when a file's prior nodes are pruned; without
808
+ // this pragma the cascade silently no-ops and edges accumulate as
809
+ // orphans (cursor + codex review). PR2's `node_attributes` table
810
+ // also relies on this cascade so attribute rows die with their node.
811
+ db.pragma("foreign_keys = ON");
812
+ applyCodingGraphSchema(db);
813
+ return new GraphStore(db, repoRoot);
814
+ }
815
+
816
+ /**
817
+ * The current schema_version row. Test seam — never expires, never
818
+ * cached so migrations land without a restart.
819
+ */
820
+ schemaVersion(): number {
821
+ return readSchemaVersion(this.db);
822
+ }
823
+
824
+ /**
825
+ * Ingest a batch of IR files atomically. One transaction wraps every
826
+ * file's delete + insert; if any file throws, the whole batch rolls
827
+ * back (rule 34 — never partial-write a coding graph).
828
+ *
829
+ * Re-ingesting the same IR is a no-op once the rows are written
830
+ * (idempotency — node ids are deterministic so the second pass collides
831
+ * on PRIMARY KEY).
832
+ *
833
+ * Two-pass ordering: pass 1 upserts every file's nodes (so FTS stays
834
+ * in sync and cross-file edge targets exist by the time pass 2 runs),
835
+ * pass 2 resolves edges against the full batch's node map and deletes
836
+ * prior edges owned by these files so changed confidence/provenance
837
+ * values overwrite (chatgpt-codex-connector P1 + cursor medium + PR1
838
+ * design anchor in graph-schema).
839
+ *
840
+ * Tagging:
841
+ * - `{ok:true, results}` — every file's counts.
842
+ * - `{ok:false, code:"db_locked"}` — busy_timeout elapsed; caller may
843
+ * retry. NOT a thrown error so the agent can degrade gracefully.
844
+ * - `{ok:false, code:"db_corrupt"}` — SQLite reported
845
+ * `database disk image is malformed`; the caller must surface and
846
+ * stop trusting this DB.
847
+ */
848
+ async upsertFileBatch(
849
+ files: StoreFileIR[],
850
+ /**
851
+ * Optional paths to delete in the SAME transaction as the upsert
852
+ * (issue #1553 — the reindex executor prunes deleted files atomically
853
+ * with the changed-files upsert so a mid-batch failure cannot leave
854
+ * the graph with committed deletions but no re-ingested replacements).
855
+ * Cascades to nodes + edges + node_attributes via the schema's
856
+ * `ON DELETE CASCADE`. Empty/omitted = no deletions.
857
+ */
858
+ deletePaths: readonly string[] = [],
859
+ ): Promise<UpsertBatchResult> {
860
+ if (this.closed || this.closing) {
861
+ return {
862
+ ok: false,
863
+ code: "store_closed",
864
+ };
865
+ }
866
+ return this.queue.schedule(() => this.runUpsert(files, deletePaths));
867
+ }
868
+
869
+ /**
870
+ * Upsert standalone edges whose endpoints are resolved from the FULL
871
+ * database (not just a per-file batch). Used by the codegraph
872
+ * ingest_traces surface (issue #1554) to persist runtime HTTP_CALLS
873
+ * observations as edges with `provenance: "trace"` — upgrading
874
+ * confidence on existing edges and inserting new ones.
875
+ *
876
+ * Endpoint resolution: when an edge carries `srcNodeId` / `dstNodeId`
877
+ * (issue #1677 — the SIMILAR_TO pipeline populates them from
878
+ * content-derived node ids), the endpoint is resolved by `nodes.id`
879
+ * (unique primary key), so an edge between two symbols that share a
880
+ * qualified name across files is persisted rather than dropped as
881
+ * ambiguous. Edges WITHOUT node ids fall back to qualified_name
882
+ * resolution via the global `resolveNodeId` (unambiguous single-match
883
+ * policy). Edges whose endpoints do not resolve (missing node id row OR
884
+ * an ambiguous/dangling qualified name) are skipped (and counted in
885
+ * `skipped`) rather than attached to the wrong node — the dangling-edge
886
+ * policy from `upsertFileBatch` applies.
887
+ *
888
+ * Serialized on the store's write queue like `upsertFileBatch` so a
889
+ * concurrent file-batch upsert and a trace upsert cannot interleave
890
+ * (rule 40).
891
+ */
892
+ async upsertEdges(
893
+ edges: readonly EdgeIR[],
894
+ ): Promise<UpsertEdgesResult> {
895
+ if (this.closed || this.closing) {
896
+ return { ok: false, code: "store_closed" };
897
+ }
898
+ return this.queue.schedule(() => this.runUpsertEdges(edges));
899
+ }
900
+
901
+ /** Wait for pending writes to drain — test seam. */
902
+ async drain(): Promise<void> {
903
+ await this.queue.drain();
904
+ }
905
+ // ──────────────────────────────────────────────────────────────────────
906
+ // PR3 (issue #1553): meta-table + file-management methods for the
907
+ // incremental reindex pipeline.
908
+ // ──────────────────────────────────────────────────────────────────────
909
+
910
+ /**
911
+ * Read a value from the `meta` table. Returns `null` when the key is
912
+ * absent. Synchronous (like the other read primitives) so the reindex
913
+ * planner can read `last_indexed_head` without an await.
914
+ */
915
+ readMeta(key: string): ReadMetaResult {
916
+ if (this.closed) return { ok: false, code: "store_closed" };
917
+ try {
918
+ const row = expectRow<{ value: string }>(
919
+ this.db.prepare("SELECT value FROM meta WHERE key = ?").get(key),
920
+ ["value"],
921
+ );
922
+ return { ok: true, value: row ? row.value : null };
923
+ } catch (error) {
924
+ logWriteFailure(error);
925
+ return classifyReadError(error);
926
+ }
927
+ }
928
+
929
+ /**
930
+ * Write a key/value pair to the `meta` table. Synchronous — runs in its
931
+ * own implicit transaction. The reindex executor calls this AFTER
932
+ * `upsertFileBatch` resolves (rule 25: head/state updates only after
933
+ * the data transaction commits). A crash between the two leaves the old
934
+ * head, and the next run re-ingests idempotently (deterministic node ids).
935
+ */
936
+ writeMeta(key: string, value: string): void {
937
+ if (this.closed) return;
938
+ this.db
939
+ .prepare("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)")
940
+ .run(key, value);
941
+ }
942
+
943
+ /**
944
+ * Read every file row's path → content_hash. Used by hash_scan mode
945
+ * to detect content drift without a reachable base commit (issue #1553).
946
+ */
947
+ readFileHashes(): ReadFileHashesResult {
948
+ if (this.closed) return { ok: false, code: "store_closed" };
949
+ try {
950
+ const rows = expectRows<{ path: string; content_hash: string }>(
951
+ this.db.prepare("SELECT path, content_hash FROM files").all(),
952
+ ["path", "content_hash"],
953
+ );
954
+ const out = new Map<string, string>();
955
+ for (const r of rows) out.set(r.path, r.content_hash);
956
+ return { ok: true, hashes: out };
957
+ } catch (error) {
958
+ logWriteFailure(error);
959
+ return classifyReadError(error);
960
+ }
961
+ }
962
+
963
+ /**
964
+ * Drop file rows by path, cascading to their nodes + edges +
965
+ * node_attributes (the schema's `ON DELETE CASCADE` from `files(id)`
966
+ * handles the cascade — `foreign_keys = ON` is set in `open()`).
967
+ * Used by the reindex executor to prune deleted files.
968
+ *
969
+ * Paths are chunked under the SQLite variable limit (rule 23 pattern).
970
+ */
971
+ async dropFiles(paths: readonly string[]): Promise<void> {
972
+ if (this.closed || this.closing || paths.length === 0) return;
973
+ await this.queue.schedule(async () => {
974
+ this.runChunkedDelete(
975
+ "DELETE FROM files WHERE path IN (%PH%)",
976
+ paths,
977
+ );
978
+ });
979
+ }
980
+
981
+ /**
982
+ * Chunk a parameterized DELETE-with-IN-list under SQLite's variable
983
+ * bind limit. Mirrors the chunking pattern used by `runChunkedUpdate`
984
+ * and the stale-edge deletes.
985
+ */
986
+ private runChunkedDelete(sqlTemplate: string, params: readonly string[]): void {
987
+ if (params.length === 0) return;
988
+ for (let i = 0; i < params.length; i += SQLITE_VARIABLE_LIMIT) {
989
+ const chunk = params.slice(i, i + SQLITE_VARIABLE_LIMIT);
990
+ const placeholders = chunk.map(() => "?").join(", ");
991
+ this.db.prepare(sqlTemplate.replace("%PH%", placeholders)).run(...chunk);
992
+ }
993
+ }
994
+ /**
995
+ * PR3 (issue #1553): upsert co-change edges into the `co_changes`
996
+ * table. Clears existing edges then inserts the new set in one
997
+ * transaction (idempotent — re-running on unchanged history produces
998
+ * the same table). Serialized through the write queue.
999
+ */
1000
+ /**
1001
+ * PR3 (issue #1553): upsert co-change edges into the `co_changes`
1002
+ * table. Clears existing edges then inserts the new set in one
1003
+ * transaction (idempotent — re-running on unchanged history produces
1004
+ * the same table). Serialized through the write queue.
1005
+ *
1006
+ * Returns `{ ok: false, code: "store_closed" }` when the store is
1007
+ * closed/closing so the caller does NOT believe mining succeeded
1008
+ * while nothing was persisted (cursor Bugbot: 'Co-change store
1009
+ * reports false success').
1010
+ */
1011
+ async upsertCoChanges(edges: readonly {
1012
+ readonly fileA: string;
1013
+ readonly fileB: string;
1014
+ readonly support: number;
1015
+ readonly confidence: number;
1016
+ }[]): Promise<
1017
+ | { ok: true }
1018
+ | { ok: false; code: "store_closed" }
1019
+ | { ok: false; code: "db_error" }
1020
+ > {
1021
+ if (this.closed || this.closing) {
1022
+ return { ok: false, code: "store_closed" };
1023
+ }
1024
+ try {
1025
+ await this.queue.schedule(async () => {
1026
+ const tx = this.db.transaction(() => {
1027
+ this.db.exec("DELETE FROM co_changes");
1028
+ const insert = this.db.prepare(
1029
+ `INSERT INTO co_changes (file_a, file_b, support, confidence)
1030
+ VALUES (?, ?, ?, ?)
1031
+ ON CONFLICT(file_a, file_b) DO UPDATE SET
1032
+ support = excluded.support,
1033
+ confidence = excluded.confidence`,
1034
+ );
1035
+ for (const e of edges) {
1036
+ insert.run(e.fileA, e.fileB, e.support, e.confidence);
1037
+ }
1038
+ });
1039
+ tx();
1040
+ });
1041
+ return { ok: true };
1042
+ } catch (error) {
1043
+ // A locked/corrupt DB would otherwise throw out of the queued
1044
+ // callback and crash the caller even though the public type only
1045
+ // advertises tagged failures. Surface a tagged db_error instead
1046
+ // (chatgpt-codex-connector: 'Return a tagged co-change store
1047
+ // failure').
1048
+ logWriteFailure(error);
1049
+ return { ok: false, code: "db_error" };
1050
+ }
1051
+ }
1052
+
1053
+ /**
1054
+ * PR3 (issue #1553): read co-change edges for a file. Returns edges
1055
+ * where the file is either `file_a` or `file_b`. Synchronous read.
1056
+ */
1057
+ readCoChanges(filePath: string): ReadCoChangesResult {
1058
+ if (this.closed) return { ok: false, code: "store_closed" };
1059
+ try {
1060
+ const rows = expectRows<{
1061
+ file_a: string;
1062
+ file_b: string;
1063
+ support: number;
1064
+ confidence: number;
1065
+ }>(
1066
+ this.db
1067
+ .prepare(
1068
+ `SELECT file_a, file_b, support, confidence
1069
+ FROM co_changes
1070
+ WHERE file_a = ? OR file_b = ?
1071
+ ORDER BY confidence DESC, file_a ASC, file_b ASC`,
1072
+ )
1073
+ .all(filePath, filePath),
1074
+ ["file_a", "file_b", "support", "confidence"],
1075
+ );
1076
+ return {
1077
+ ok: true,
1078
+ edges: rows.map((r) => ({
1079
+ fileA: r.file_a,
1080
+ fileB: r.file_b,
1081
+ support: r.support,
1082
+ confidence: r.confidence,
1083
+ })),
1084
+ };
1085
+ } catch (error) {
1086
+ logWriteFailure(error);
1087
+ return classifyReadError(error);
1088
+ }
1089
+ }
1090
+
1091
+ /**
1092
+ * Close the SQLite handle after draining the write queue. A batch
1093
+ * that has already been scheduled on the queue would otherwise run
1094
+ * against a closed DB and surface as `db_corrupt` — the caller
1095
+ * would stop trusting the store for unrelated reasons. Drain first,
1096
+ * then close (cursor Bugbot #09be5784).
1097
+ */
1098
+ async close(): Promise<void> {
1099
+ if (this.closed) return;
1100
+ // A concurrent close() is already draining. Return the shared
1101
+ // promise so this caller's `await store.close()` actually waits
1102
+ // for the drain to finish and the SQLite handle to close — the
1103
+ // pre-fix early `return` resolved immediately, so a caller that
1104
+ // treats close() as a flush barrier could delete/reopen the DB
1105
+ // while writes were still in flight (chatgpt-codex-connector P2:
1106
+ // 'Wait for an in-progress close').
1107
+ if (this.closing) return this.closePromise;
1108
+ // Block NEW writes before draining so a concurrent upsertFileBatch
1109
+ // cannot schedule a write that runs after this drain's await captured
1110
+ // the old tail. Without this flag, close() drains the queue snapshot,
1111
+ // closes the handle, and the late-scheduled write hits a closed DB
1112
+ // (chatgpt-codex-connector P2: 'Block new writes before draining').
1113
+ this.closing = true;
1114
+ this.closePromise = this.finishClose();
1115
+ return this.closePromise;
1116
+ }
1117
+
1118
+ /** Drain queued writes then close the SQLite handle exactly once. */
1119
+ private async finishClose(): Promise<void> {
1120
+ await this.queue.drain();
1121
+ this.closed = true;
1122
+ this.db.close();
1123
+ }
1124
+
1125
+ // ────────────── private ──────────────
1126
+
1127
+ private async runUpsert(
1128
+ files: StoreFileIR[],
1129
+ deletePaths: readonly string[] = [],
1130
+ ): Promise<UpsertBatchResult> {
1131
+ // Guard: duplicate paths in one batch silently corrupt the edge
1132
+ // pass — pass 2 deletes the first entry's edges when the second
1133
+ // entry's edge pass runs against the same file row. Fail loud so
1134
+ // the caller fixes the input (cursor Bugbot: 'Duplicate paths
1135
+ // corrupt edge pass').
1136
+ const seenPaths = new Set<string>();
1137
+ for (const ir of files) {
1138
+ // Canonical-path check BEFORE the duplicate check: a caller that
1139
+ // passes the same repo file as `./src/a.ts` in one ingest and
1140
+ // `src/a.ts` in another (or uses backslashes / an absolute path)
1141
+ // would persist two distinct files rows + node-id hashes and
1142
+ // leave duplicate/stale symbols the later canonical ingest
1143
+ // cannot match or prune. The FileIR contract requires
1144
+ // repo-relative forward-slash paths; reject the violation at the
1145
+ // store boundary rather than silently normalizing
1146
+ // (chatgpt-codex-connector P2: 'Reject non-canonical file paths
1147
+ // before persisting').
1148
+ assertCanonicalFilePath(ir.path);
1149
+ if (seenPaths.has(ir.path)) {
1150
+ throw new Error(
1151
+ `graph-store: duplicate path '${ir.path}' in batch — each FileIR must have a unique path`,
1152
+ );
1153
+ }
1154
+ seenPaths.add(ir.path);
1155
+ // `symbols` is a REQUIRED FileIR contract field (non-optional
1156
+ // `readonly symbols: readonly SymbolIR[]`). Runtime null can
1157
+ // still arrive via JSON deserialization or a malformed parser
1158
+ // result; without this guard the `?? []` fallback made a
1159
+ // missing/null field indistinguishable from an explicit empty
1160
+ // array, so the prune step silently wiped every existing
1161
+ // node/edge for the path while the batch returned ok. Reject
1162
+ // the contract violation instead of clearing the file
1163
+ // (chatgpt-codex-connector P2: 'Reject missing symbols instead
1164
+ // of pruning the file').
1165
+ const symbolsField = ir.symbols as unknown;
1166
+ if (!Array.isArray(symbolsField)) {
1167
+ throw new Error(
1168
+ `graph-store: file '${ir.path}' symbols must be an array (FileIR contract requires it); received ${
1169
+ symbolsField === null ? "null" : typeof symbolsField
1170
+ } — refusing to ingest to avoid wiping existing nodes`,
1171
+ );
1172
+ }
1173
+ // Span check: a malformed parser or JSON caller can emit
1174
+ // startByte > endByte (or non-integer / negative spans); the
1175
+ // values are bound directly into span_start/span_end and PR2
1176
+ // snippet/search consumers will trust them as half-open byte
1177
+ // offsets. Reject before insertion so bad IR cannot corrupt
1178
+ // graph metadata (chatgpt-codex-connector P2: 'Reject invalid
1179
+ // symbol spans before storing nodes').
1180
+ for (const sym of symbolsField) {
1181
+ assertValidSymbolSpan(sym, ir.path);
1182
+ }
1183
+ // Attribute arrays: `exports` and `routes` are optional, but
1184
+ // when present MUST be arrays. A malformed non-array (e.g. a
1185
+ // JSON caller passing `exports: "publicApi"`) is iterable as
1186
+ // characters whose entries have no `.name`, so the per-flag
1187
+ // rebuild in upsertFileAttributes would compute an empty set
1188
+ // and then WIPE every is_exported / is_route_handler flag for
1189
+ // the file — turning a single bad re-ingest into silent
1190
+ // dead-code misclassification. Reject at the boundary like the
1191
+ // symbols check above (chatgpt-codex-connector P2: 'Validate
1192
+ // attribute arrays before clearing flags').
1193
+ if (ir.exports != null) {
1194
+ if (!Array.isArray(ir.exports)) {
1195
+ throw new Error(
1196
+ `graph-store: file '${ir.path}' exports must be an array when present; received ${
1197
+ ir.exports === null ? "null" : typeof ir.exports
1198
+ } — refusing to ingest to avoid wiping existing flags`,
1199
+ );
1200
+ }
1201
+ // Each entry must carry a non-empty string `name`; a malformed
1202
+ // entry (e.g. `{ name: 42 }`) is silently skipped by the
1203
+ // per-flag rebuild, so it contributes nothing while the wipe
1204
+ // still clears every is_exported flag. Reject the whole batch
1205
+ // like the symbols check (chatgpt-codex-connector P2: 'Reject
1206
+ // malformed attribute entries before clearing flags').
1207
+ for (const ex of ir.exports) {
1208
+ if (!ex || typeof ex.name !== "string" || ex.name.length === 0) {
1209
+ throw new Error(
1210
+ `graph-store: file '${ir.path}' has a malformed export entry — expected { name: string (non-empty) }; refusing to ingest to avoid wiping existing flags`,
1211
+ );
1212
+ }
1213
+ }
1214
+ }
1215
+ if (ir.routes != null) {
1216
+ if (!Array.isArray(ir.routes)) {
1217
+ throw new Error(
1218
+ `graph-store: file '${ir.path}' routes must be an array when present; received ${
1219
+ ir.routes === null ? "null" : typeof ir.routes
1220
+ } — refusing to ingest to avoid wiping existing flags`,
1221
+ );
1222
+ }
1223
+ for (const r of ir.routes) {
1224
+ if (
1225
+ !r ||
1226
+ typeof r.handlerQualifiedName !== "string" ||
1227
+ r.handlerQualifiedName.length === 0
1228
+ ) {
1229
+ throw new Error(
1230
+ `graph-store: file '${ir.path}' has a malformed route entry — expected { handlerQualifiedName: string (non-empty) }; refusing to ingest to avoid wiping existing flags`,
1231
+ );
1232
+ }
1233
+ }
1234
+ }
1235
+ }
1236
+ try {
1237
+ const results: UpsertResult[] = [];
1238
+
1239
+ // Single transaction for the whole batch — atomic, faster than
1240
+ // per-file BEGIN/COMMIT, and rule 34 mandates "never partial-write
1241
+ // a coding graph". Two passes: pass 1 upserts every file's nodes
1242
+ // (so FTS stays in sync and cross-file edge targets exist by the
1243
+ // time pass 2 runs), pass 2 resolves edges against the full
1244
+ // batch's node map and deletes prior edges owned by these files
1245
+ // so changed confidence/provenance values overwrite. The two
1246
+ // passes together make the write pipeline order-independent for
1247
+ // cross-file edges (chatgpt-codex-connector P1/P2).
1248
+ const tx = this.db.transaction((irs: StoreFileIR[]) => {
1249
+ // Pass 0 (issue #1553): prune deleted-file rows in the SAME
1250
+ // transaction as the upsert so a failure rolls both back
1251
+ // atomically (cursor Bugbot: 'Deletes commit before ingest
1252
+ // fails'). Cascades to nodes + edges + node_attributes.
1253
+ if (deletePaths.length > 0) {
1254
+ for (let i = 0; i < deletePaths.length; i += SQLITE_VARIABLE_LIMIT) {
1255
+ const chunk = deletePaths.slice(i, i + SQLITE_VARIABLE_LIMIT);
1256
+ const placeholders = chunk.map(() => "?").join(", ");
1257
+ this.db
1258
+ .prepare("DELETE FROM files WHERE path IN (%PH%)".replace("%PH%", placeholders))
1259
+ .run(...chunk);
1260
+ }
1261
+ }
1262
+ // Pass 1a: upsert every file's nodes and collect the per-file
1263
+ // prune sets WITHOUT deleting yet. `upsertFileNodes` returns
1264
+ // the result plus the node ids it wants to prune; the actual
1265
+ // prune (and the dangling-edge count that gates it) is deferred
1266
+ // to pass 1b so all files in the batch share one batch-wide
1267
+ // view of what is being pruned.
1268
+ const pending: { result: UpsertResult; prunedNodeIds: string[] }[] = [];
1269
+ for (const ir of irs) {
1270
+ const { result, prunedNodeIds } = this.upsertFileNodes(ir);
1271
+ pending.push({ result, prunedNodeIds });
1272
+ results.push(result);
1273
+ }
1274
+ // Pass 1b: count + delete dangling edges per file. The src
1275
+ // exclusion uses the BATCH-WIDE pruned set, not just this
1276
+ // file's, so an edge whose both ends are pruned in different
1277
+ // files is never reported as "dangling" — it is
1278
+ // cascade-deleted, and the reported loss no longer depends on
1279
+ // which file the loop visits first
1280
+ // (chatgpt-codex-connector P2: 'Count dangling edges against
1281
+ // the whole batch').
1282
+ const batchPrunedIds: string[] = [];
1283
+ for (const { prunedNodeIds } of pending) {
1284
+ for (const id of prunedNodeIds) batchPrunedIds.push(id);
1285
+ }
1286
+ for (const { result, prunedNodeIds } of pending) {
1287
+ this.pruneFileNodes(result, prunedNodeIds, batchPrunedIds);
1288
+ }
1289
+ // Pass 2: every file's edges. Resolves against the full DB
1290
+ // (which already contains every node from this batch plus
1291
+ // every node from prior batches).
1292
+ for (let i = 0; i < irs.length; i += 1) {
1293
+ const ir = irs[i]!;
1294
+ const result = results[i]!;
1295
+ this.upsertFileEdges(ir, result);
1296
+ }
1297
+ // Pass 3 (PR2): every file's node_attributes rows
1298
+ // (`is_exported`, `is_route_handler`). Derived from the IR's
1299
+ // optional `exports` and `routes` arrays. Runs after the prune
1300
+ // so attribute rows for nodes that survived into this batch
1301
+ // are written against the final node set. Cascade-delete on
1302
+ // `nodes(id)` already cleaned up rows for pruned nodes during
1303
+ // pass 1b; this pass only inserts new / updates existing rows
1304
+ // for surviving nodes.
1305
+ for (let i = 0; i < irs.length; i += 1) {
1306
+ const ir = irs[i]!;
1307
+ const result = results[i]!;
1308
+ this.upsertFileAttributes(ir, result);
1309
+ }
1310
+ });
1311
+ tx(files);
1312
+ return { ok: true, results };
1313
+ } catch (error) {
1314
+ logWriteFailure(error);
1315
+ return classifyError(error);
1316
+ }
1317
+ }
1318
+
1319
+ /**
1320
+ * Standalone-edge upsert body (runs under the write queue). Resolves
1321
+ * both endpoints from the full DB via the unambiguous single-match
1322
+ * `resolveNodeId` fallback, then upserts each edge with the same
1323
+ * ON CONFLICT(src,dst,type) policy as the file-batch path. Edges whose
1324
+ * src or dst do not resolve to exactly one node are skipped (counted
1325
+ * in `skipped`) per the dangling-edge policy.
1326
+ */
1327
+ private async runUpsertEdges(
1328
+ edges: readonly EdgeIR[],
1329
+ ): Promise<UpsertEdgesResult> {
1330
+ const emptyBatch: Map<string, string> = new Map();
1331
+ const insertEdge = this.db.prepare(
1332
+ `INSERT INTO edges (src, dst, type, confidence, provenance)
1333
+ VALUES (?, ?, ?, ?, ?)
1334
+ ON CONFLICT(src, dst, type) DO UPDATE SET
1335
+ confidence = excluded.confidence,
1336
+ provenance = excluded.provenance`,
1337
+ );
1338
+ // node-id resolution (issue #1677). `nodes.id` is the PRIMARY KEY, so
1339
+ // this is a unique, unambiguous lookup — a SIMILAR_TO edge between two
1340
+ // same-qualified-name symbols resolves here instead of being dropped by
1341
+ // the ambiguous qualified-name fallback. The row's qualified_name is
1342
+ // returned so the caller's (src|dst)QualifiedName can be matched against
1343
+ // it: a stale or mismatched id+qname pair is skipped (counted in
1344
+ // `skipped`) rather than silently writing an edge from the wrong node
1345
+ // (chatgpt-codex-connector P2 — id/qname consistency at the boundary).
1346
+ const nodeById = this.db.prepare(
1347
+ "SELECT qualified_name FROM nodes WHERE id = ? LIMIT 1",
1348
+ );
1349
+ try {
1350
+ let persisted = 0;
1351
+ let skipped = 0;
1352
+ this.db.transaction(() => {
1353
+ for (const edge of edges) {
1354
+ if (!isEdgeProvenance(edge.provenance)) {
1355
+ throw new Error(
1356
+ `graph-store: edge has invalid provenance ${JSON.stringify(edge.provenance)}`,
1357
+ );
1358
+ }
1359
+ if (
1360
+ !Number.isFinite(edge.confidence) ||
1361
+ edge.confidence < 0 ||
1362
+ edge.confidence > 1
1363
+ ) {
1364
+ throw new Error(
1365
+ `graph-store: edge confidence ${edge.confidence} is out of range [0, 1] for edge ${edge.srcQualifiedName} → ${edge.dstQualifiedName}`,
1366
+ );
1367
+ }
1368
+ // Prefer the content-derived node id when the caller supplied one
1369
+ // (issue #1677). Only fall through to qualified-name resolution
1370
+ // when no id is present, preserving the existing trace/HTTP_CALLS
1371
+ // path verbatim. When an id IS supplied, its row's qualified_name
1372
+ // MUST match the edge's (src|dst)QualifiedName — a mismatched pair
1373
+ // (stale body map, custom integration) is skipped like a dangling
1374
+ // edge instead of corrupting the graph (chatgpt-codex-connector P2).
1375
+ const srcId = edge.srcNodeId
1376
+ ? resolveByNodeId(nodeById, edge.srcNodeId, edge.srcQualifiedName)
1377
+ : resolveNodeId(edge.srcQualifiedName, emptyBatch, this.db);
1378
+ const dstId = edge.dstNodeId
1379
+ ? resolveByNodeId(nodeById, edge.dstNodeId, edge.dstQualifiedName)
1380
+ : resolveNodeId(edge.dstQualifiedName, emptyBatch, this.db);
1381
+ if (!srcId || !dstId) {
1382
+ skipped += 1;
1383
+ continue;
1384
+ }
1385
+ const r = insertEdge.run(srcId, dstId, edge.type, edge.confidence, edge.provenance);
1386
+ persisted += r.changes;
1387
+ }
1388
+ })();
1389
+ return { ok: true, persisted, skipped };
1390
+ } catch (error) {
1391
+ logWriteFailure(error);
1392
+ return classifyError(error);
1393
+ }
1394
+ }
1395
+
1396
+ /**
1397
+ * Pass 1a: upsert the file row and every symbol node, refreshing the
1398
+ * contentless `nodes_fts` index in lockstep, and compute the set of
1399
+ * stale node ids this file wants to prune (deterministic id, NOT
1400
+ * qualified_name, so a kind change gets a new id and the OLD row is
1401
+ * deleted). The prune itself — and the dangling-edge count that
1402
+ * gates it — is deferred to {@link pruneFileNodes} so the whole batch
1403
+ * shares one batch-wide view of what is being pruned before any
1404
+ * cascade runs.
1405
+ */
1406
+ private upsertFileNodes(ir: StoreFileIR): {
1407
+ result: UpsertResult;
1408
+ prunedNodeIds: string[];
1409
+ } {
1410
+ // Upsert the file row first; the nodes table references files(id).
1411
+ const upsertFile = this.db.prepare(
1412
+ `INSERT INTO files (path, lang, content_hash)
1413
+ VALUES (?, ?, ?)
1414
+ ON CONFLICT(path) DO UPDATE SET
1415
+ lang = excluded.lang,
1416
+ content_hash = excluded.content_hash
1417
+ RETURNING id`,
1418
+ );
1419
+ const fileRow = expectRow<{ id: number }>(
1420
+ upsertFile.get(ir.path, ir.language, ir.contentHash),
1421
+ ["id"],
1422
+ );
1423
+ if (!fileRow) {
1424
+ throw new Error(
1425
+ `graph-store: INSERT INTO files RETURNING id returned no row for path=${ir.path}`,
1426
+ );
1427
+ }
1428
+ const fileId = fileRow.id;
1429
+
1430
+ // Build the seen id set FIRST (every symbol → its deterministic id)
1431
+ // so the prune step is order-stable and never deletes an id we
1432
+ // are about to (re)insert. Determinism is non-negotiable — see
1433
+ // nodeIdFor for the canonical form.
1434
+ const seenNodeIds = new Set<string>();
1435
+ const symbolByNodeId = new Map<string, SymbolIR>();
1436
+ for (const sym of ir.symbols) {
1437
+ const id = nodeIdFor({
1438
+ qualifiedName: sym.qualifiedName,
1439
+ filePath: ir.path,
1440
+ label: sym.kind,
1441
+ });
1442
+ seenNodeIds.add(id);
1443
+ symbolByNodeId.set(id, sym);
1444
+ }
1445
+
1446
+ // Snapshot the prior nodes owned by this file so we can (a) skip
1447
+ // true no-op UPSERTs to keep `changes` honest, and (b) count
1448
+ // dangling edges the prune step will cascade.
1449
+ const existingNodes = expectRows<{
1450
+ id: string;
1451
+ label: string;
1452
+ name: string;
1453
+ qualified_name: string;
1454
+ file_id: number;
1455
+ span_start: number;
1456
+ span_end: number;
1457
+ lang: string;
1458
+ }>(
1459
+ this.db
1460
+ .prepare(
1461
+ `SELECT id, label, name, qualified_name, file_id,
1462
+ span_start, span_end, lang
1463
+ FROM nodes WHERE file_id = ?`,
1464
+ )
1465
+ .all(fileId),
1466
+ ["id", "label", "name", "qualified_name", "file_id", "span_start", "span_end", "lang"],
1467
+ );
1468
+ const existingById = new Map(existingNodes.map((n) => [n.id, n]));
1469
+
1470
+ const insertNode = this.db.prepare(
1471
+ `INSERT INTO nodes (
1472
+ id, label, name, qualified_name,
1473
+ file_id, span_start, span_end, lang
1474
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
1475
+ ON CONFLICT(id) DO UPDATE SET
1476
+ label = excluded.label,
1477
+ name = excluded.name,
1478
+ qualified_name = excluded.qualified_name,
1479
+ file_id = excluded.file_id,
1480
+ span_start = excluded.span_start,
1481
+ span_end = excluded.span_end,
1482
+ lang = excluded.lang`,
1483
+ );
1484
+ const insertFts = this.db.prepare(
1485
+ `INSERT INTO nodes_fts (rowid, name, qualified_name) VALUES (?, ?, ?)`,
1486
+ );
1487
+ const deleteFtsByRowid = this.db.prepare(
1488
+ `DELETE FROM nodes_fts WHERE rowid = ?`,
1489
+ );
1490
+ // fts_index: maps FTS rowid → node id so PR2's search can JOIN
1491
+ // hits back to `nodes`. Contentless FTS5 does NOT store column
1492
+ // values, so the `id UNINDEXED` column reads NULL on every
1493
+ // MATCH — this table is the only reverse-mapping the read path
1494
+ // has (chatgpt-codex-connector P2: 'Preserve a node key for
1495
+ // FTS hits'). UNIQUE(node_id) lets the upsert use INSERT OR
1496
+ // REPLACE so a same-node re-upsert (the common no-op path)
1497
+ // keeps a single mapping row.
1498
+ const upsertFtsIndex = this.db.prepare(
1499
+ `INSERT INTO fts_index (fts_rowid, node_id) VALUES (?, ?)
1500
+ ON CONFLICT(node_id) DO UPDATE SET
1501
+ fts_rowid = excluded.fts_rowid`,
1502
+ );
1503
+ const deleteFtsIndexByRowid = this.db.prepare(
1504
+ `DELETE FROM fts_index WHERE fts_rowid = ?`,
1505
+ );
1506
+ let nodeCount = 0;
1507
+ for (const [id, sym] of symbolByNodeId) {
1508
+ const prior = existingById.get(id);
1509
+ if (
1510
+ prior &&
1511
+ prior.label === sym.kind &&
1512
+ prior.name === sym.name &&
1513
+ prior.qualified_name === sym.qualifiedName &&
1514
+ prior.span_start === sym.span.startByte &&
1515
+ prior.span_end === sym.span.endByte &&
1516
+ prior.lang === ir.language
1517
+ ) {
1518
+ // Truly a no-op — the row already matches the IR. Skip the
1519
+ // INSERT/UPDATE entirely so `changes` stays 0.
1520
+ continue;
1521
+ }
1522
+ // Drop any prior FTS row for this id before the new INSERT.
1523
+ // Contentless FTS5 (`content=''`) does NOT store UNINDEXED
1524
+ // column values, so the only reliable key is the deterministic
1525
+ // rowid we derive from the node id hash (chatgpt-codex-connector
1526
+ // P2: `WHERE id = ?` matches zero rows in contentless mode).
1527
+ const ftsRowid = ftsRowidForNodeId(id);
1528
+ deleteFtsByRowid.run(ftsRowid);
1529
+ // Mirror the delete into the FTS → node id reverse map so a
1530
+ // re-upsert does not collide on the UNIQUE(fts_rowid) PK when
1531
+ // the same rowid previously pointed at a different node id
1532
+ // (chatgpt-codex-connector P2: 'Preserve a node key for
1533
+ // FTS hits').
1534
+ deleteFtsIndexByRowid.run(ftsRowid);
1535
+ insertNode.run(
1536
+ id,
1537
+ sym.kind,
1538
+ sym.name,
1539
+ sym.qualifiedName,
1540
+ fileId,
1541
+ sym.span.startByte,
1542
+ sym.span.endByte,
1543
+ ir.language,
1544
+ );
1545
+ insertFts.run(ftsRowid, sym.name, sym.qualifiedName);
1546
+ upsertFtsIndex.run(ftsRowid, id);
1547
+ nodeCount += 1;
1548
+ }
1549
+
1550
+ // Prune by id (NOT qualified_name). A symbol whose kind changed
1551
+ // keeps the same qualifiedName but has a new node id; the old
1552
+ // id's row must be deleted to keep the file's symbol set honest.
1553
+ // We do this AFTER the upserts so any same-id re-upsert above is
1554
+ // preserved. The actual delete (and the dangling-edge count) is
1555
+ // deferred to pruneFileNodes so the batch can share one batch-wide
1556
+ // view of every pruned node before any cascade runs.
1557
+ const prunedNodeIds = existingNodes
1558
+ .map((n) => n.id)
1559
+ .filter((id) => !seenNodeIds.has(id));
1560
+
1561
+ return {
1562
+ result: {
1563
+ path: ir.path,
1564
+ fileId,
1565
+ nodeCount,
1566
+ edgeCount: 0,
1567
+ droppedDanglingEdges: 0,
1568
+ },
1569
+ prunedNodeIds,
1570
+ };
1571
+ }
1572
+
1573
+ /**
1574
+ * Pass 1b: count the dangling edges this file's prune will drop and
1575
+ * perform the cascade delete + FTS cleanup. A dangling edge is one
1576
+ * whose dst is pruned by THIS file but whose src survives — and
1577
+ * "survives" is judged against the BATCH-WIDE pruned set, so an edge
1578
+ * whose both ends are pruned (possibly in different files) is
1579
+ * cascade-deleted and never reported as dangling. This makes the
1580
+ * reported loss independent of the order files are visited in
1581
+ * (chatgpt-codex-connector P2: 'Count dangling edges against the
1582
+ * whole batch').
1583
+ */
1584
+ private pruneFileNodes(
1585
+ result: UpsertResult,
1586
+ prunedNodeIds: readonly string[],
1587
+ batchPrunedIds: readonly string[],
1588
+ ): void {
1589
+ if (prunedNodeIds.length === 0) {
1590
+ result.droppedDanglingEdges = 0;
1591
+ return;
1592
+ }
1593
+ // Two temp tables keep the IN / NOT IN queries under SQLite's
1594
+ // ~32766 variable bind limit for large prune sets (cursor Bugbot:
1595
+ // 'Prune path exceeds SQL variable limit'). Each insert binds 1
1596
+ // param; the subquery-based count/delete bind zero.
1597
+ this.db.exec(
1598
+ "CREATE TEMP TABLE IF NOT EXISTS _pruned_ids (id TEXT NOT NULL PRIMARY KEY)",
1599
+ );
1600
+ this.db.exec(
1601
+ "CREATE TEMP TABLE IF NOT EXISTS _batch_pruned_ids (id TEXT NOT NULL PRIMARY KEY)",
1602
+ );
1603
+ const clearPruned = this.db.prepare("DELETE FROM _pruned_ids");
1604
+ const clearBatch = this.db.prepare("DELETE FROM _batch_pruned_ids");
1605
+ const insertPruned = this.db.prepare(
1606
+ "INSERT OR IGNORE INTO _pruned_ids (id) VALUES (?)",
1607
+ );
1608
+ const insertBatch = this.db.prepare(
1609
+ "INSERT OR IGNORE INTO _batch_pruned_ids (id) VALUES (?)",
1610
+ );
1611
+ clearPruned.run();
1612
+ clearBatch.run();
1613
+ const fillTemp = this.db.transaction(
1614
+ (rows: { table: string; ids: readonly string[] }[]) => {
1615
+ for (const { table, ids } of rows) {
1616
+ const stmt =
1617
+ table === "_pruned_ids"
1618
+ ? insertPruned
1619
+ : insertBatch;
1620
+ for (const id of ids) stmt.run(id);
1621
+ }
1622
+ },
1623
+ );
1624
+ fillTemp([
1625
+ { table: "_pruned_ids", ids: prunedNodeIds },
1626
+ { table: "_batch_pruned_ids", ids: batchPrunedIds },
1627
+ ]);
1628
+ // Count dangling edges BEFORE the cascade: dst is a node pruned by
1629
+ // THIS file AND src is NOT pruned anywhere in the batch (edges
1630
+ // between two batch-pruned nodes are cascade-deleted, not
1631
+ // "dangling", and must not be attributed to either file).
1632
+ const dangling = expectRow<{ c: number }>(
1633
+ this.db
1634
+ .prepare(
1635
+ `SELECT COUNT(*) AS c FROM edges
1636
+ WHERE dst IN (SELECT id FROM _pruned_ids)
1637
+ AND src NOT IN (SELECT id FROM _batch_pruned_ids)`,
1638
+ )
1639
+ .get(),
1640
+ ["c"],
1641
+ );
1642
+ result.droppedDanglingEdges = dangling?.c ?? 0;
1643
+ // DELETE stale nodes — ON DELETE CASCADE on edges drops every
1644
+ // edge whose src or dst is pruned (FK pragma set in open()).
1645
+ this.db.exec("DELETE FROM nodes WHERE id IN (SELECT id FROM _pruned_ids)");
1646
+ clearPruned.run();
1647
+ clearBatch.run();
1648
+ // FTS + fts_index cleanup: rowids are derived in JS (not SQL),
1649
+ // so chunk the IN list to stay under the bind limit.
1650
+ const SQLITE_VAR_LIMIT = 32_766;
1651
+ const ftsRowids = prunedNodeIds.map(ftsRowidForNodeId);
1652
+ for (let i = 0; i < ftsRowids.length; i += SQLITE_VAR_LIMIT) {
1653
+ const chunk = ftsRowids.slice(i, i + SQLITE_VAR_LIMIT);
1654
+ const ph = chunk.map(() => "?").join(", ");
1655
+ this.db.prepare(`DELETE FROM nodes_fts WHERE rowid IN (${ph})`).run(...chunk);
1656
+ this.db.prepare(`DELETE FROM fts_index WHERE fts_rowid IN (${ph})`).run(...chunk);
1657
+ }
1658
+ }
1659
+
1660
+ /**
1661
+ * Pass 2: re-insert edges for one file. Runs AFTER every file's
1662
+ * nodes are in place (the full batch is committed to nodes) so
1663
+ * cross-file edges resolve regardless of input order. Stale edges
1664
+ * for nodes owned by this file are deleted first so a changed
1665
+ * `confidence` or `provenance` actually overwrites the prior row
1666
+ * (chatgpt-codex-connector P1: ON CONFLICT DO NOTHING silently
1667
+ * kept stale edges across re-ingests).
1668
+ */
1669
+ private upsertFileEdges(ir: StoreFileIR, result: UpsertResult): void {
1670
+ // If edges are not provided (undefined or null — e.g. from JSON
1671
+ // deserialization), preserve prior edges rather than treating a
1672
+ // missing field as an empty assertion set. A bare core
1673
+ // ParseResult.ir (which has no edges field) re-upsert must NOT
1674
+ // wipe previously stored edges. An explicit empty array [] DOES
1675
+ // assert "no edges" and deletes all prior src-owned edges
1676
+ // (cursor Bugbot: 'Omitted edges field wipes stored edges' /
1677
+ // 'Null edges wipe stored edges').
1678
+ if (ir.edges == null) {
1679
+ return;
1680
+ }
1681
+ // Build the set of edges the IR is asserting for this file. The
1682
+ // SRC of each edge MUST belong to this file (it is resolved from
1683
+ // the per-file `qualifiedNameToId` map only — no DB fallback);
1684
+ // a FileIR that asserts an edge whose src is absent from this
1685
+ // file but present elsewhere is malformed and the edge is dropped
1686
+ // so it cannot be silently cross-owned. The DST may be cross-file
1687
+ // and uses the full-DB fallback (chatgpt-codex-connector P2:
1688
+ // 'Require edge sources to belong to the ingested file'). Edges
1689
+ // whose dst cannot resolve are also dropped — the caller is
1690
+ // responsible for the batch's canonical file set (rule 40).
1691
+ const qualifiedNameToId = new Map<string, string>();
1692
+ const ownSymbols = expectRows<{ id: string; qualified_name: string }>(
1693
+ this.db
1694
+ .prepare("SELECT id, qualified_name FROM nodes WHERE file_id = ?")
1695
+ .all(result.fileId),
1696
+ ["id", "qualified_name"],
1697
+ );
1698
+ // Count qualified_name occurrences so ambiguous names are excluded.
1699
+ // Node identity is (qualifiedName, filePath, label) — two symbols in
1700
+ // the same file CAN share a qualified_name (e.g. a TS type + value
1701
+ // both named Foo, with different labels → different node ids). A
1702
+ // qualified_name-only map would silently keep just one; instead,
1703
+ // ambiguous names are left out so edges to them resolve to undefined
1704
+ // and are dropped — matching the DST conservative-drop policy
1705
+ // (chatgpt-codex-connector P2: 'Reject ambiguous local qualified names').
1706
+ const qnameCounts = new Map<string, number>();
1707
+ for (const row of ownSymbols) {
1708
+ qnameCounts.set(row.qualified_name, (qnameCounts.get(row.qualified_name) ?? 0) + 1);
1709
+ }
1710
+ for (const row of ownSymbols) {
1711
+ if ((qnameCounts.get(row.qualified_name) ?? 0) === 1) {
1712
+ qualifiedNameToId.set(row.qualified_name, row.id);
1713
+ }
1714
+ }
1715
+
1716
+ const assertedKeys = new Set<string>();
1717
+ // Re-resolve each edge in the IR to its deterministic key so the
1718
+ // delete below only drops edges that are NOT being re-asserted.
1719
+ // Doing this BEFORE the delete is critical: deleting first then
1720
+ // checking the no-op skip leaves cross-file edges (whose src is
1721
+ // owned here but whose dst lives elsewhere) orphaned when the
1722
+ // IR re-asserts them — the prior-edge snapshot matches, the
1723
+ // insert is skipped, and the row is gone (cursor Bugbot #6a78cd0a).
1724
+ const seenKeys: string[] = [];
1725
+ // Map each resolved key to its first edge so the insertion pass
1726
+ // can look up metadata in O(1) instead of rescanning ir.edges
1727
+ // and re-running resolveNodeId (with DB lookups) per key
1728
+ // (chatgpt-codex-connector P2: 'Preserve resolved edge metadata
1729
+ // instead of rescanning'). First-edge-wins dedupe policy
1730
+ // (cursor Bugbot #28876d4c) is preserved by only setting on
1731
+ // first occurrence.
1732
+ const keyToEdge = new Map<string, EdgeIR>();
1733
+ for (const edge of ir.edges ?? []) {
1734
+ // Reject malformed edges up-front (rule 51: surface what is wrong).
1735
+ if (!isEdgeProvenance(edge.provenance)) {
1736
+ throw new Error(
1737
+ `graph-store: edge has invalid provenance ${JSON.stringify(edge.provenance)}`,
1738
+ );
1739
+ }
1740
+ if (
1741
+ !Number.isFinite(edge.confidence) ||
1742
+ edge.confidence < 0 ||
1743
+ edge.confidence > 1
1744
+ ) {
1745
+ throw new Error(
1746
+ `graph-store: edge confidence ${edge.confidence} is out of range [0, 1] for edge ${edge.srcQualifiedName} → ${edge.dstQualifiedName}`,
1747
+ );
1748
+ }
1749
+ // SRC must be a symbol in THIS file — resolve from the per-file
1750
+ // map only. A FileIR whose edge src lives in another file is
1751
+ // malformed; dropping it prevents cross-owned edges that survive
1752
+ // re-ingest (chatgpt-codex-connector P2).
1753
+ const srcId = qualifiedNameToId.get(edge.srcQualifiedName);
1754
+ if (!srcId) continue;
1755
+ // DST may be cross-file — use the full-DB fallback.
1756
+ const dstId = resolveNodeId(
1757
+ edge.dstQualifiedName,
1758
+ qualifiedNameToId,
1759
+ this.db,
1760
+ );
1761
+ if (!dstId) continue;
1762
+ const key = `${srcId}\u0000${dstId}\u0000${edge.type}`;
1763
+ assertedKeys.add(key);
1764
+ seenKeys.push(key);
1765
+ if (!keyToEdge.has(key)) {
1766
+ keyToEdge.set(key, edge);
1767
+ }
1768
+ }
1769
+
1770
+ // Pre-fetch the prior edges owned by this file (src in this file's
1771
+ // nodes) so we can (a) skip the no-op re-upsert when confidence +
1772
+ // provenance match exactly, and (b) compute the stale-edge delete
1773
+ // set: prior src-owned edges that are NOT in the current IR's
1774
+ // asserted keys.
1775
+ const priorEdges = expectRows<{
1776
+ src: string;
1777
+ dst: string;
1778
+ type: string;
1779
+ confidence: number;
1780
+ provenance: string;
1781
+ }>(
1782
+ this.db
1783
+ .prepare(
1784
+ "SELECT src, dst, type, confidence, provenance FROM edges WHERE src IN (SELECT id FROM nodes WHERE file_id = ?)",
1785
+ )
1786
+ .all(result.fileId),
1787
+ ["src", "dst", "type", "confidence", "provenance"],
1788
+ );
1789
+ const priorByKey = new Map<string, { confidence: number; provenance: string }>();
1790
+ const staleSrcDstTypes: Array<{ src: string; dst: string; type: string }> = [];
1791
+ for (const p of priorEdges) {
1792
+ const key = `${p.src}\u0000${p.dst}\u0000${p.type}`;
1793
+ priorByKey.set(key, { confidence: p.confidence, provenance: p.provenance });
1794
+ if (!assertedKeys.has(key)) {
1795
+ staleSrcDstTypes.push({ src: p.src, dst: p.dst, type: p.type });
1796
+ }
1797
+ }
1798
+
1799
+ // Delete only the stale src-owned edges (prior-but-not-asserted).
1800
+ // This preserves any cross-file edge that the current IR
1801
+ // re-asserts, even when its dst lives in a file NOT in this
1802
+ // batch — the row survives the delete and the no-op skip below
1803
+ // keeps `changes` honest.
1804
+ //
1805
+ // Chunk the deletes: each tuple binds 3 parameters and SQLite
1806
+ // enforces a variable limit (32766 in the bundled build). An
1807
+ // unbounded IN list would throw `too many SQL variables` for a
1808
+ // file with >10,922 stale edges, rolling back the whole batch
1809
+ // (chatgpt-codex-connector P2: 'Chunk stale-edge deletes before
1810
+ // binding them').
1811
+ const SQLITE_VARIABLE_LIMIT = 32_766;
1812
+ const PARAMS_PER_TUPLE = 3;
1813
+ const MAX_TUPLES_PER_CHUNK = Math.floor(SQLITE_VARIABLE_LIMIT / PARAMS_PER_TUPLE);
1814
+ for (let i = 0; i < staleSrcDstTypes.length; i += MAX_TUPLES_PER_CHUNK) {
1815
+ const chunk = staleSrcDstTypes.slice(i, i + MAX_TUPLES_PER_CHUNK);
1816
+ const placeholders = chunk.map(() => "(?, ?, ?)").join(", ");
1817
+ this.db
1818
+ .prepare(`DELETE FROM edges WHERE (src, dst, type) IN (${placeholders})`)
1819
+ .run(...chunk.flatMap((e) => [e.src, e.dst, e.type]));
1820
+ }
1821
+
1822
+ const insertEdge = this.db.prepare(
1823
+ `INSERT INTO edges (src, dst, type, confidence, provenance)
1824
+ VALUES (?, ?, ?, ?, ?)
1825
+ ON CONFLICT(src, dst, type) DO UPDATE SET
1826
+ confidence = excluded.confidence,
1827
+ provenance = excluded.provenance`,
1828
+ );
1829
+ let edgeCount = 0;
1830
+ // Dedupe keys: a FileIR with two edges sharing `(src, dst, type)`
1831
+ // (but differing confidence/provenance) is malformed input. First-edge
1832
+ // metadata wins; later duplicates are skipped so they cannot inflate
1833
+ // edgeCount via redundant re-upserts (cursor Bugbot #28876d4c).
1834
+ const processedKeys = new Set<string>();
1835
+ for (const key of seenKeys) {
1836
+ if (processedKeys.has(key)) continue;
1837
+ processedKeys.add(key);
1838
+ const edge = keyToEdge.get(key);
1839
+ if (!edge) continue;
1840
+ const parts = key.split("\u0000");
1841
+ const srcId = parts[0]!;
1842
+ const dstId = parts[1]!;
1843
+ const prior = priorByKey.get(key);
1844
+ if (
1845
+ prior &&
1846
+ prior.confidence === edge.confidence &&
1847
+ prior.provenance === edge.provenance
1848
+ ) {
1849
+ // Identical to the prior row — no INSERT/UPDATE needed, so
1850
+ // `changes` stays 0 and the idempotency contract holds. The
1851
+ // row still exists because the delete above only removed
1852
+ // stale keys.
1853
+ continue;
1854
+ }
1855
+ const r = insertEdge.run(srcId, dstId, edge.type, edge.confidence, edge.provenance);
1856
+ edgeCount += r.changes;
1857
+ }
1858
+ result.edgeCount = edgeCount;
1859
+ }
1860
+
1861
+ /**
1862
+ * Pass 3 (PR2): upsert `node_attributes` rows for this file's
1863
+ * surviving nodes, derived from the IR's optional `exports` and
1864
+ * `routes` arrays. Per-field preservation semantics (mirrors the
1865
+ * edges pass, generalized to two independent flags):
1866
+ * - `exports == null` (omitted) → preserve existing `is_exported`
1867
+ * flags untouched (PR1-era IR has no exports field). The
1868
+ * `is_route_handler` flag is rebuilt independently from
1869
+ * `routes` — the two columns do NOT interact.
1870
+ * - `exports === []` (explicit empty) → wipe the file's
1871
+ * `is_exported` flags (the caller is asserting "this file
1872
+ * exports nothing").
1873
+ * - same rule for `routes` / `is_route_handler`.
1874
+ *
1875
+ * A symbol is `is_exported=1` when its `name` matches an entry in
1876
+ * `ir.exports` (multiple symbols with the same name in one file all
1877
+ * get the flag — the dead-code query treats this conservatively,
1878
+ * never silently picking one). A symbol is `is_route_handler=1`
1879
+ * when its `qualifiedName` equals a route's `handlerQualifiedName`.
1880
+ *
1881
+ * Implementation: per-flag UPDATE, not a delete-then-insert (the
1882
+ * original PR2 implementation wiped both flags whenever either field
1883
+ * was present, so a re-ingest with only `exports` silently dropped
1884
+ * `is_route_handler` — cursor Bugbot + chatgpt-codex-connector P2).
1885
+ * The two flags live in the same row keyed by node_id; INSERT OR
1886
+ * IGNORE ensures a row exists, then UPDATE-per-flag changes only
1887
+ * the column the IR is asserting.
1888
+ */
1889
+ private upsertFileAttributes(ir: StoreFileIR, result: UpsertResult): void {
1890
+ // Both fields omitted → nothing to assert. Preserve every flag
1891
+ // (PR1 baseline). Avoids touching the table at all so truly-no-op
1892
+ // re-ingests stay zero-cost.
1893
+ if (ir.exports == null && ir.routes == null) {
1894
+ return;
1895
+ }
1896
+
1897
+ const ownNodes = expectRows<{ id: string; name: string; qualified_name: string }>(
1898
+ this.db
1899
+ .prepare("SELECT id, name, qualified_name FROM nodes WHERE file_id = ?")
1900
+ .all(result.fileId),
1901
+ ["id", "name", "qualified_name"],
1902
+ );
1903
+ const ownNodeIds = ownNodes.map((n) => n.id);
1904
+ if (ownNodeIds.length === 0) {
1905
+ return;
1906
+ }
1907
+
1908
+ // Per-flag rebuild. The pattern is identical for each flag:
1909
+ // 1. Ensure every node in this file has an attributes row
1910
+ // (default 0,0). INSERT OR IGNORE keeps any existing row.
1911
+ // 2. If the IR field for this flag is present, wipe the column
1912
+ // for this file's nodes (so removed flags clear), then set
1913
+ // the column for nodes in the new set.
1914
+ // 3. If the IR field is omitted, leave the column untouched.
1915
+ const ensureRow = this.db.prepare(
1916
+ `INSERT OR IGNORE INTO node_attributes (node_id, is_exported, is_route_handler)
1917
+ VALUES (?, 0, 0)`,
1918
+ );
1919
+ for (const id of ownNodeIds) ensureRow.run(id);
1920
+
1921
+ if (ir.exports != null) {
1922
+ const exportNames = new Set<string>();
1923
+ for (const ex of ir.exports) {
1924
+ if (ex && typeof ex.name === "string" && ex.name.length > 0) {
1925
+ exportNames.add(ex.name);
1926
+ }
1927
+ }
1928
+ const newExportedIds = new Set<string>();
1929
+ for (const n of ownNodes) {
1930
+ if (exportNames.has(n.name)) newExportedIds.add(n.id);
1931
+ }
1932
+ // Wipe is_exported for this file's nodes, chunked under the
1933
+ // SQLite variable limit. The other column is untouched.
1934
+ this.runChunkedUpdate(
1935
+ `UPDATE node_attributes SET is_exported = 0 WHERE node_id IN (%PH%)`,
1936
+ ownNodeIds,
1937
+ );
1938
+ // Set the flag for the new exported set.
1939
+ const setExported = this.db.prepare(
1940
+ `UPDATE node_attributes SET is_exported = 1 WHERE node_id = ?`,
1941
+ );
1942
+ for (const id of newExportedIds) setExported.run(id);
1943
+ }
1944
+
1945
+ if (ir.routes != null) {
1946
+ const handlerQNames = new Set<string>();
1947
+ for (const r of ir.routes) {
1948
+ if (r && typeof r.handlerQualifiedName === "string" && r.handlerQualifiedName.length > 0) {
1949
+ handlerQNames.add(r.handlerQualifiedName);
1950
+ }
1951
+ }
1952
+ const newRouteIds = new Set<string>();
1953
+ for (const n of ownNodes) {
1954
+ if (handlerQNames.has(n.qualified_name)) newRouteIds.add(n.id);
1955
+ }
1956
+ this.runChunkedUpdate(
1957
+ `UPDATE node_attributes SET is_route_handler = 0 WHERE node_id IN (%PH%)`,
1958
+ ownNodeIds,
1959
+ );
1960
+ const setRoute = this.db.prepare(
1961
+ `UPDATE node_attributes SET is_route_handler = 1 WHERE node_id = ?`,
1962
+ );
1963
+ for (const id of newRouteIds) setRoute.run(id);
1964
+ }
1965
+ }
1966
+
1967
+ /**
1968
+ * Chunk a parameterized UPDATE-with-IN-list under SQLite's variable
1969
+ * bind limit. The SQL template uses `%PH%` as a placeholder for the
1970
+ * `?,?,…` list. Mirrors the chunking pattern PR1 uses for deletes.
1971
+ */
1972
+ private runChunkedUpdate(sqlTemplate: string, params: readonly string[]): void {
1973
+ if (params.length === 0) return;
1974
+ for (let i = 0; i < params.length; i += SQLITE_VARIABLE_LIMIT) {
1975
+ const chunk = params.slice(i, i + SQLITE_VARIABLE_LIMIT);
1976
+ const placeholders = chunk.map(() => "?").join(", ");
1977
+ this.db.prepare(sqlTemplate.replace("%PH%", placeholders)).run(...chunk);
1978
+ }
1979
+ }
1980
+
1981
+ // ──────────────────────────────────────────────────────────────────────
1982
+ // PR2 read primitives (issue #1552 steps 4–5).
1983
+ // ──────────────────────────────────────────────────────────────────────
1984
+
1985
+ /**
1986
+ * Iterative frontier BFS over the edges table. Cycle-safe via a JS
1987
+ * visited set keyed by node id; depth-capped by {@link TraverseQuery.maxDepth}
1988
+ * (half-open — depth==maxDepth is INCLUDED, maxDepth+1 is NOT — rule 35).
1989
+ * The start node is always included at depth 0 when it exists.
1990
+ *
1991
+ * Reads the edges table via a single prepared statement per
1992
+ * direction; the frontier expands level-by-level so memory is
1993
+ * bounded by the visited set's size, not the recursion depth.
1994
+ */
1995
+ traverse(query: TraverseQuery): TraverseResult {
1996
+ if (this.closed) return { ok: false, code: "store_closed" };
1997
+ // Guard the query object before any dereference: a null/undefined
1998
+ // payload (e.g. malformed JSON forwarded at an MCP boundary) would
1999
+ // throw on `query.maxDepth` below instead of returning the tagged
2000
+ // invalid_query the read contract advertises
2001
+ // (chatgpt-codex-connector P2: 'Validate read query objects before
2002
+ // dereferencing').
2003
+ if (query == null || typeof query !== "object") {
2004
+ return { ok: false, code: "invalid_query" };
2005
+ }
2006
+ // Validate maxDepth up-front (rule 51: surface what's wrong).
2007
+ if (
2008
+ typeof query.maxDepth !== "number" ||
2009
+ !Number.isInteger(query.maxDepth) ||
2010
+ query.maxDepth < 0
2011
+ ) {
2012
+ return {
2013
+ ok: false,
2014
+ code: "invalid_query",
2015
+ };
2016
+ }
2017
+ // Validate direction against the allowed set (rule 51 +
2018
+ // chatgpt-codex-connector P2: 'Reject invalid traversal directions
2019
+ // explicitly'). Default ONLY on `undefined` — a `null` from a
2020
+ // JSON/tool caller is a malformed value, not an absent one, so the
2021
+ // `??` operator (which treats null as nullish) would silently turn
2022
+ // it into "outgoing" and mask the bad input. Reject null explicitly
2023
+ // (chatgpt-codex-connector P2: 'Reject null traversal directions').
2024
+ const direction: TraverseDirection =
2025
+ query.direction === undefined ? "outgoing" : query.direction;
2026
+ if (
2027
+ direction !== "outgoing" &&
2028
+ direction !== "incoming" &&
2029
+ direction !== "both"
2030
+ ) {
2031
+ return { ok: false, code: "invalid_query" };
2032
+ }
2033
+ // Validate edgeTypes, if present, is an array of strings (rule 51 +
2034
+ // chatgpt-codex-connector P2: 'Validate edgeTypes before building
2035
+ // traversal SQL'). A malformed value like a bare string "CALLS"
2036
+ // would otherwise throw at .map() instead of returning the
2037
+ // tagged invalid_query failure the contract advertises.
2038
+ if (
2039
+ query.edgeTypes !== undefined &&
2040
+ (!Array.isArray(query.edgeTypes) ||
2041
+ !query.edgeTypes.every((e) => typeof e === "string"))
2042
+ ) {
2043
+ return { ok: false, code: "invalid_query" };
2044
+ }
2045
+ // Validate `start` is a non-empty string before it reaches the
2046
+ // SQLite bind (rule 51 + chatgpt-codex-connector P2: 'Validate
2047
+ // traverse start before binding it'). A JS/JSON caller passing an
2048
+ // object/array survives the regex `.test()` coercion but then
2049
+ // throws a non-SQLite TypeError at bind time; surface that as the
2050
+ // precise `invalid_query` rather than letting it fall through to
2051
+ // the generic db_error catch-all.
2052
+ if (
2053
+ typeof query.start !== "string" ||
2054
+ query.start.length === 0
2055
+ ) {
2056
+ return { ok: false, code: "invalid_query" };
2057
+ }
2058
+ // Wrap DB operations in try/catch so lock/corrupt errors return a
2059
+ // tagged failure instead of throwing (cursor Bugbot: 'SQLite errors
2060
+ // escape read APIs'). Same shape as schemaStats/deadCode.
2061
+ try {
2062
+ // Resolve the start node. If `start` is a 64-char lowercase hex
2063
+ // string (the nodeIdFor sha256 format), resolve ONLY by id — this
2064
+ // is the unambiguous path a caller uses after seeing an ambiguous
2065
+ // qualified_name rejection. Otherwise resolve ONLY by qualified_name.
2066
+ // Splitting the two paths (cursor Bugbot: 'Traverse start conflates id
2067
+ // and name') prevents a mistyped id from silently matching an
2068
+ // unrelated qualified_name, and prevents a unique id from being
2069
+ // reported as ambiguous just because some other node shares the id
2070
+ // string as a qualified_name.
2071
+ let startId: string;
2072
+ const isNodeId = /^[0-9a-f]{64}$/.test(query.start);
2073
+ const rows = expectRows<{ id: string }>(
2074
+ this.db
2075
+ .prepare(
2076
+ isNodeId
2077
+ ? "SELECT id FROM nodes WHERE id = ?"
2078
+ : "SELECT id FROM nodes WHERE qualified_name = ?",
2079
+ )
2080
+ .all(query.start),
2081
+ ["id"],
2082
+ );
2083
+ if (rows.length === 0) {
2084
+ return { ok: false, code: "unknown_start" };
2085
+ }
2086
+ if (rows.length > 1) {
2087
+ // Multiple rows mean the start resolved to more than one node —
2088
+ // either the same id matching twice (impossible — PRIMARY KEY)
2089
+ // or a qualified_name declared in multiple files. Reject so the
2090
+ // caller passes an explicit node id.
2091
+ return { ok: false, code: "ambiguous_start" };
2092
+ }
2093
+ startId = rows[0]!.id;
2094
+
2095
+ // BFS. Edge-type filter is bound into the prepared statement via
2096
+ // IN (?, ?, ...) — empty list means "all types" (no WHERE clause
2097
+ // on type). The edge-type list is small (≤ ~25) so chunking is
2098
+ // unnecessary here, but we still parameterize so user input
2099
+ // cannot inject SQL.
2100
+ const edgeTypes = query.edgeTypes ?? [];
2101
+ const typeClause =
2102
+ edgeTypes.length > 0
2103
+ ? `AND type IN (${edgeTypes.map(() => "?").join(", ")})`
2104
+ : "";
2105
+ const outgoingStmt = this.db.prepare(
2106
+ `SELECT dst AS neighbor, src AS via_id FROM edges WHERE src = ? ${typeClause}`,
2107
+ );
2108
+ const incomingStmt = this.db.prepare(
2109
+ `SELECT src AS neighbor, dst AS via_id FROM edges WHERE dst = ? ${typeClause}`,
2110
+ );
2111
+
2112
+ const visited = new Set<string>([startId]);
2113
+ const hits: TraverseHit[] = [];
2114
+ const startRow = expectRow<{
2115
+ id: string;
2116
+ qualified_name: string;
2117
+ name: string;
2118
+ label: string;
2119
+ file_path: string;
2120
+ }>(
2121
+ this.db
2122
+ .prepare(
2123
+ "SELECT n.id, n.qualified_name, n.name, n.label, f.path AS file_path FROM nodes n JOIN files f ON n.file_id = f.id WHERE n.id = ?",
2124
+ )
2125
+ .get(startId),
2126
+ ["id", "qualified_name", "name", "label", "file_path"],
2127
+ );
2128
+ if (!startRow) {
2129
+ // Race: the node vanished between the resolve and the read.
2130
+ // Treat as unknown rather than crash.
2131
+ return { ok: false, code: "unknown_start" };
2132
+ }
2133
+ hits.push({
2134
+ nodeId: startRow.id,
2135
+ qualifiedName: startRow.qualified_name,
2136
+ name: startRow.name,
2137
+ label: startRow.label,
2138
+ filePath: startRow.file_path,
2139
+ depth: 0,
2140
+ });
2141
+
2142
+ // maxDepth === 0 → just the start node (half-open: depth 0 is
2143
+ // included, depth 1 is not).
2144
+ if (query.maxDepth === 0) {
2145
+ return { ok: true, hits };
2146
+ }
2147
+
2148
+ let frontier: string[] = [startId];
2149
+ for (let depth = 1; depth <= query.maxDepth; depth += 1) {
2150
+ const nextFrontier: string[] = [];
2151
+ for (const nodeId of frontier) {
2152
+ const params = [nodeId, ...edgeTypes];
2153
+ const outRows =
2154
+ direction === "outgoing" || direction === "both"
2155
+ ? expectRows<{ neighbor: string }>(
2156
+ outgoingStmt.all(...params),
2157
+ ["neighbor"],
2158
+ )
2159
+ : [];
2160
+ const inRows =
2161
+ direction === "incoming" || direction === "both"
2162
+ ? expectRows<{ neighbor: string }>(
2163
+ incomingStmt.all(...params),
2164
+ ["neighbor"],
2165
+ )
2166
+ : [];
2167
+ for (const r of [...outRows, ...inRows]) {
2168
+ const neighbor = r.neighbor;
2169
+ // Cycle safety: a node already in `visited` is not re-added.
2170
+ // This also handles self-edges (src == dst): the start is in
2171
+ // visited, so a self-loop on it never re-expands the frontier.
2172
+ if (visited.has(neighbor)) continue;
2173
+ visited.add(neighbor);
2174
+ nextFrontier.push(neighbor);
2175
+ const hitRow = expectRow<{
2176
+ id: string;
2177
+ qualified_name: string;
2178
+ name: string;
2179
+ label: string;
2180
+ file_path: string;
2181
+ }>(
2182
+ this.db
2183
+ .prepare(
2184
+ "SELECT n.id, n.qualified_name, n.name, n.label, f.path AS file_path FROM nodes n JOIN files f ON n.file_id = f.id WHERE n.id = ?",
2185
+ )
2186
+ .get(neighbor),
2187
+ ["id", "qualified_name", "name", "label", "file_path"],
2188
+ );
2189
+ if (hitRow) {
2190
+ hits.push({
2191
+ nodeId: hitRow.id,
2192
+ qualifiedName: hitRow.qualified_name,
2193
+ name: hitRow.name,
2194
+ label: hitRow.label,
2195
+ filePath: hitRow.file_path,
2196
+ depth,
2197
+ });
2198
+ }
2199
+ }
2200
+ }
2201
+ if (nextFrontier.length === 0) break;
2202
+ frontier = nextFrontier;
2203
+ }
2204
+ return { ok: true, hits };
2205
+ } catch (error) {
2206
+ logWriteFailure(error);
2207
+ return classifyReadError(error) as TraverseResult;
2208
+ }
2209
+ }
2210
+ /**
2211
+ * Path-enumerating traversal (issue #1650). Unlike {@link traverse}'s BFS —
2212
+ * which visits each node ONCE at its shortest-path depth and so cannot honor
2213
+ * an exact `*N` (N > 1) hop count for nodes reachable at both a shorter and a
2214
+ * length-N path — this primitive enumerates concrete relationship-simple
2215
+ * paths from the start, yielding one hit per distinct (path, endpoint) pair
2216
+ * up to {@link TraversePathsQuery.maxHops}.
2217
+ *
2218
+ * Cycle safety uses RELATIONSHIP UNIQUENESS (the real Cypher rule): a single
2219
+ * path never traverses the same edge twice, keyed by the edge's canonical
2220
+ * `(src, dst, type)` identity. A node MAY recur in a path via distinct edges
2221
+ * (e.g. A->B->A over two different edges) — that is correct Cypher behavior.
2222
+ * The {@link TraversePathsQuery.maxHops} cap bounds each path's length;
2223
+ * {@link TraversePathsQuery.maxPaths} bounds the total enumerated count so a
2224
+ * dense subgraph cannot blow enumeration up exponentially without notice
2225
+ * (when hit, enumeration stops and the result carries `truncated: true`).
2226
+ *
2227
+ * Every yielded path has length >= 1 (at least one edge). A length-0 "path"
2228
+ * (the trivial start->start) is NOT enumerated; callers that need the start
2229
+ * node for a `*0..N` bound add it themselves.
2230
+ */
2231
+ traversePaths(query: TraversePathsQuery): TraversePathsResult {
2232
+ if (this.closed) return { ok: false, code: "store_closed" };
2233
+ // Guard the query object before any dereference (mirrors traverse).
2234
+ if (query == null || typeof query !== "object") {
2235
+ return { ok: false, code: "invalid_query" };
2236
+ }
2237
+ if (
2238
+ typeof query.maxHops !== "number" ||
2239
+ !Number.isInteger(query.maxHops) ||
2240
+ query.maxHops < 0
2241
+ ) {
2242
+ return { ok: false, code: "invalid_query" };
2243
+ }
2244
+ // Reject depths that would overflow the recursive DFS before maxPaths
2245
+ // can bind it (chatgpt-codex-connector P2: 'Avoid recursive DFS for deep
2246
+ // bounded paths').
2247
+ if (query.maxHops > MAX_TRAVERSE_PATHS_HOPS) {
2248
+ return { ok: false, code: "invalid_query" };
2249
+ }
2250
+ const direction: TraverseDirection =
2251
+ query.direction === undefined ? "outgoing" : query.direction;
2252
+ if (
2253
+ direction !== "outgoing" &&
2254
+ direction !== "incoming" &&
2255
+ direction !== "both"
2256
+ ) {
2257
+ return { ok: false, code: "invalid_query" };
2258
+ }
2259
+ if (
2260
+ query.edgeTypes !== undefined &&
2261
+ (!Array.isArray(query.edgeTypes) ||
2262
+ !query.edgeTypes.every((e) => typeof e === "string"))
2263
+ ) {
2264
+ return { ok: false, code: "invalid_query" };
2265
+ }
2266
+ if (typeof query.start !== "string" || query.start.length === 0) {
2267
+ return { ok: false, code: "invalid_query" };
2268
+ }
2269
+ // minHops: validate only when explicitly provided; default 1. MUST be a
2270
+ // positive integer -- the primitive never emits length-0 paths.
2271
+ const minHops = query.minHops === undefined ? 1 : query.minHops;
2272
+ if (
2273
+ typeof minHops !== "number" ||
2274
+ !Number.isInteger(minHops) ||
2275
+ minHops < 1
2276
+ ) {
2277
+ return { ok: false, code: "invalid_query" };
2278
+ }
2279
+ // maxPaths: reject a malformed EXPLICIT value rather than silently
2280
+ // defaulting (rule 51 -- surface what's wrong). Only `undefined` defaults
2281
+ // (chatgpt-codex-connector P2: 'Reject malformed maxPaths instead of
2282
+ // defaulting').
2283
+ let maxPaths: number;
2284
+ if (query.maxPaths === undefined) {
2285
+ maxPaths = DEFAULT_TRAVERSE_PATHS_MAX;
2286
+ } else if (
2287
+ typeof query.maxPaths !== "number" ||
2288
+ !Number.isInteger(query.maxPaths) ||
2289
+ query.maxPaths < 0
2290
+ ) {
2291
+ return { ok: false, code: "invalid_query" };
2292
+ } else {
2293
+ maxPaths = query.maxPaths;
2294
+ }
2295
+
2296
+ try {
2297
+ // Resolve the start node — same split id/qualified_name policy as
2298
+ // traverse (cursor Bugbot: 'Traverse start conflates id and name').
2299
+ const isNodeId = /^[0-9a-f]{64}$/.test(query.start);
2300
+ const rows = expectRows<{ id: string }>(
2301
+ this.db
2302
+ .prepare(
2303
+ isNodeId
2304
+ ? "SELECT id FROM nodes WHERE id = ?"
2305
+ : "SELECT id FROM nodes WHERE qualified_name = ?",
2306
+ )
2307
+ .all(query.start),
2308
+ ["id"],
2309
+ );
2310
+ if (rows.length === 0) return { ok: false, code: "unknown_start" };
2311
+ if (rows.length > 1) return { ok: false, code: "ambiguous_start" };
2312
+ const startId = rows[0]!.id;
2313
+
2314
+ // maxHops === 0 -> no edge paths exist.
2315
+ if (query.maxHops === 0) {
2316
+ return { ok: true, hits: [], truncated: false };
2317
+ }
2318
+
2319
+ const edgeTypes = query.edgeTypes ?? [];
2320
+ const typeClause =
2321
+ edgeTypes.length > 0
2322
+ ? `AND type IN (${edgeTypes.map(() => "?").join(", ")})`
2323
+ : "";
2324
+ // Return the canonical (src, dst, type) so relationship-uniqueness keys
2325
+ // are direction-independent: traversing edge A->B outgoing then B->A
2326
+ // incoming reuses the SAME relationship and is blocked.
2327
+ const outgoingStmt = this.db.prepare(
2328
+ `SELECT dst AS neighbor, src, dst, type FROM edges WHERE src = ? ${typeClause}`,
2329
+ );
2330
+ const incomingStmt = this.db.prepare(
2331
+ `SELECT src AS neighbor, src, dst, type FROM edges WHERE dst = ? ${typeClause}`,
2332
+ );
2333
+ const nodeStmt = this.db.prepare(
2334
+ "SELECT n.id, n.qualified_name, n.name, n.label, f.path AS file_path FROM nodes n JOIN files f ON n.file_id = f.id WHERE n.id = ?",
2335
+ );
2336
+
2337
+ type NodeRow = {
2338
+ id: string;
2339
+ qualified_name: string;
2340
+ name: string;
2341
+ label: string;
2342
+ file_path: string;
2343
+ };
2344
+ type EdgeRow = {
2345
+ neighbor: string;
2346
+ src: string;
2347
+ dst: string;
2348
+ type: string;
2349
+ };
2350
+
2351
+ const nodeCache = new Map<string, NodeRow>();
2352
+ const getNode = (id: string): NodeRow | undefined => {
2353
+ const cached = nodeCache.get(id);
2354
+ if (cached) return cached;
2355
+ const row = expectRow<NodeRow>(nodeStmt.get(id), [
2356
+ "id",
2357
+ "qualified_name",
2358
+ "name",
2359
+ "label",
2360
+ "file_path",
2361
+ ]);
2362
+ if (row) nodeCache.set(id, row);
2363
+ return row;
2364
+ };
2365
+
2366
+ const neighborsOf = (id: string): EdgeRow[] => {
2367
+ const params = [id, ...edgeTypes];
2368
+ const out: EdgeRow[] =
2369
+ direction === "outgoing" || direction === "both"
2370
+ ? expectRows<EdgeRow>(outgoingStmt.all(...params), [
2371
+ "neighbor",
2372
+ "src",
2373
+ "dst",
2374
+ "type",
2375
+ ])
2376
+ : [];
2377
+ const inn: EdgeRow[] =
2378
+ direction === "incoming" || direction === "both"
2379
+ ? expectRows<EdgeRow>(incomingStmt.all(...params), [
2380
+ "neighbor",
2381
+ "src",
2382
+ "dst",
2383
+ "type",
2384
+ ])
2385
+ : [];
2386
+ // Dedupe by the canonical (src, dst, type) key. Under
2387
+ // direction "both" a SELF-LOOP (src == dst) is matched by BOTH
2388
+ // the outgoing and incoming SELECTs, and because usedEdges is
2389
+ // cleared after each branch the same relationship-simple path
2390
+ // would otherwise be emitted twice -- violating the one-hit-per-
2391
+ // distinct-path contract and double-consuming the maxPaths cap
2392
+ // (chatgpt-codex-connector P2: 'Deduplicate self-loop edges for
2393
+ // both-direction traversal'). The UNIQUE(src,dst,type) table
2394
+ // constraint guarantees no dup within a single direction, so this
2395
+ // only ever collapses the both-direction self-loop overlap.
2396
+ const seenEdge = new Set<string>();
2397
+ const deduped: EdgeRow[] = [];
2398
+ for (const e of [...out, ...inn]) {
2399
+ const k = e.src + "\u0000" + e.dst + "\u0000" + e.type;
2400
+ if (seenEdge.has(k)) continue;
2401
+ seenEdge.add(k);
2402
+ deduped.push(e);
2403
+ }
2404
+ return deduped;
2405
+ };
2406
+
2407
+ const hits: TraversePathHit[] = [];
2408
+ let truncated = false;
2409
+ const usedEdges = new Set<string>();
2410
+ const pathNodes: string[] = [startId];
2411
+ const pathEdgeTypes: string[] = [];
2412
+ const pathEndpoints: Array<{ src: string; dst: string }> = [];
2413
+
2414
+ // Recursive DFS. `length` is the current path's hop count (edges taken).
2415
+ // We EXPLORE while length < maxHops (shorter prefixes must be walked to
2416
+ // reach longer paths) but EMIT only when newLength >= minHops, so the
2417
+ // maxPaths cap protects the in-range result set instead of being
2418
+ // consumed by discarded shorter prefixes (cursor Bugbot: 'Path cap
2419
+ // ignores hop minimum').
2420
+ const dfs = (currentId: string, length: number): void => {
2421
+ if (truncated) return;
2422
+ if (length >= query.maxHops) return;
2423
+ for (const e of neighborsOf(currentId)) {
2424
+ if (truncated) return;
2425
+ const key = `${e.src}\u0000${e.dst}\u0000${e.type}`;
2426
+ if (usedEdges.has(key)) continue;
2427
+ usedEdges.add(key);
2428
+ pathNodes.push(e.neighbor);
2429
+ pathEdgeTypes.push(e.type);
2430
+ pathEndpoints.push({ src: e.src, dst: e.dst });
2431
+ const newLength = length + 1;
2432
+ if (newLength >= minHops) {
2433
+ // Cap check on EMITTED (in-range) hits only.
2434
+ if (hits.length >= maxPaths) {
2435
+ truncated = true;
2436
+ } else {
2437
+ const info = getNode(e.neighbor);
2438
+ if (info) {
2439
+ hits.push({
2440
+ nodeId: info.id,
2441
+ qualifiedName: info.qualified_name,
2442
+ name: info.name,
2443
+ label: info.label,
2444
+ filePath: info.file_path,
2445
+ length: newLength,
2446
+ nodeIds: pathNodes.slice(),
2447
+ edgeTypes: pathEdgeTypes.slice(),
2448
+ edgeEndpoints: pathEndpoints.slice(),
2449
+ });
2450
+ }
2451
+ }
2452
+ }
2453
+ if (!truncated) dfs(e.neighbor, newLength);
2454
+ pathEndpoints.pop();
2455
+ pathEdgeTypes.pop();
2456
+ pathNodes.pop();
2457
+ usedEdges.delete(key);
2458
+ }
2459
+ };
2460
+
2461
+ dfs(startId, 0);
2462
+ return { ok: true, hits, truncated };
2463
+ } catch (error) {
2464
+ logWriteFailure(error);
2465
+ return classifyReadError(error) as TraversePathsResult;
2466
+ }
2467
+ }
2468
+ /**
2469
+ * Structured node search. All filters are AND-combined; patterns use
2470
+ * SQLite LIKE (case-insensitive via COLLATE NOCASE). Patterns and
2471
+ * limits are parameter-bound, never string-interpolated, so user
2472
+ * input cannot inject SQL.
2473
+ */
2474
+ searchGraph(query: SearchQuery): SearchResult {
2475
+ if (this.closed) return { ok: false, code: "store_closed" };
2476
+ // Guard the query object before any dereference (see traverse).
2477
+ if (query == null || typeof query !== "object") {
2478
+ return { ok: false, code: "invalid_query" };
2479
+ }
2480
+ // Validate numeric inputs (rule 51). NaN / negative / non-integer
2481
+ // limits are rejected, not silently clamped, so callers learn what
2482
+ // they passed.
2483
+ if (
2484
+ (query.degreeMin !== undefined &&
2485
+ (typeof query.degreeMin !== "number" ||
2486
+ !Number.isInteger(query.degreeMin) ||
2487
+ query.degreeMin < 0)) ||
2488
+ (query.degreeMax !== undefined &&
2489
+ (typeof query.degreeMax !== "number" ||
2490
+ !Number.isInteger(query.degreeMax) ||
2491
+ query.degreeMax < 0))
2492
+ ) {
2493
+ return { ok: false, code: "invalid_query" };
2494
+ }
2495
+ if (
2496
+ query.degreeMin !== undefined &&
2497
+ query.degreeMax !== undefined &&
2498
+ query.degreeMin > query.degreeMax
2499
+ ) {
2500
+ return { ok: false, code: "invalid_query" };
2501
+ }
2502
+ const rawLimit = query.limit ?? 100;
2503
+ if (
2504
+ typeof rawLimit !== "number" ||
2505
+ !Number.isInteger(rawLimit) ||
2506
+ rawLimit < 0
2507
+ ) {
2508
+ return { ok: false, code: "invalid_query" };
2509
+ }
2510
+ // Clamp to [0, MAX_SEARCH_LIMIT]. A `limit: 0` returns an empty
2511
+ // hits array (rule 27 — guard the slice/LIMIT against zero).
2512
+ const MAX_SEARCH_LIMIT = 1000;
2513
+ const limit = Math.min(rawLimit, MAX_SEARCH_LIMIT);
2514
+
2515
+ // Validate string filters are strings when present (rule 51 +
2516
+ // chatgpt-codex-connector P2: 'Reject non-string search patterns
2517
+ // instead of dropping filters'). A non-string like namePattern: 42
2518
+ // has undefined .length, so the guard below would silently drop
2519
+ // the filter and return unrelated nodes. Reject up-front instead.
2520
+ if (
2521
+ (query.label !== undefined && typeof query.label !== "string") ||
2522
+ (query.namePattern !== undefined &&
2523
+ typeof query.namePattern !== "string") ||
2524
+ (query.filePattern !== undefined &&
2525
+ typeof query.filePattern !== "string")
2526
+ ) {
2527
+ return { ok: false, code: "invalid_query" };
2528
+ }
2529
+
2530
+ // Wrap DB operations in try/catch (cursor Bugbot: 'SQLite errors
2531
+ // escape read APIs'). Same shape as schemaStats/deadCode/traverse.
2532
+ try {
2533
+ // Build a single parameterized query. The degree subquery counts
2534
+ // inbound + outbound edges per node; the WHERE clause AND-combines
2535
+ // every present filter; the LIMIT is bound last. LIKE patterns are
2536
+ // bound as-is so SQLite interprets `%` and `_`.
2537
+ const params: (string | number)[] = [];
2538
+ const where: string[] = [];
2539
+ if (query.label !== undefined && query.label.length > 0) {
2540
+ where.push("n.label = ?");
2541
+ params.push(query.label);
2542
+ }
2543
+ if (query.namePattern !== undefined && query.namePattern.length > 0) {
2544
+ where.push("n.name LIKE ? COLLATE NOCASE");
2545
+ params.push(query.namePattern);
2546
+ }
2547
+ if (query.filePattern !== undefined && query.filePattern.length > 0) {
2548
+ where.push("f.path LIKE ? COLLATE NOCASE");
2549
+ params.push(query.filePattern);
2550
+ }
2551
+ // Degree filter on the computed subquery. We re-emit the COUNT
2552
+ // subquery in the WHERE clause rather than using HAVING — SQLite
2553
+ // requires HAVING to be paired with GROUP BY, and this query has
2554
+ // no GROUP BY (each row is one node). The correlated subquery is
2555
+ // evaluated per-row; SQLite's planner caches it cheaply for the
2556
+ // graph sizes we target (issue #1552 scale targets).
2557
+ if (query.degreeMin !== undefined) {
2558
+ where.push(
2559
+ "(SELECT COUNT(*) FROM edges e WHERE e.src = n.id OR e.dst = n.id) >= ?",
2560
+ );
2561
+ params.push(query.degreeMin);
2562
+ }
2563
+ if (query.degreeMax !== undefined) {
2564
+ where.push(
2565
+ "(SELECT COUNT(*) FROM edges e WHERE e.src = n.id OR e.dst = n.id) <= ?",
2566
+ );
2567
+ params.push(query.degreeMax);
2568
+ }
2569
+
2570
+ params.push(limit);
2571
+ const sql = `SELECT n.id AS node_id, n.qualified_name, n.name, n.label,
2572
+ f.path AS file_path,
2573
+ (SELECT COUNT(*) FROM edges e WHERE e.src = n.id OR e.dst = n.id) AS degree
2574
+ FROM nodes n
2575
+ JOIN files f ON n.file_id = f.id
2576
+ ${where.length > 0 ? "WHERE " + where.join(" AND ") : ""}
2577
+ ORDER BY degree DESC, n.qualified_name ASC
2578
+ LIMIT ?`;
2579
+ const rows = expectRows<{
2580
+ node_id: string;
2581
+ qualified_name: string;
2582
+ name: string;
2583
+ label: string;
2584
+ file_path: string;
2585
+ degree: number;
2586
+ }>(this.db.prepare(sql).all(...params), [
2587
+ "node_id",
2588
+ "qualified_name",
2589
+ "name",
2590
+ "label",
2591
+ "file_path",
2592
+ "degree",
2593
+ ]);
2594
+ const hits: SearchHit[] = rows.map((r) => ({
2595
+ nodeId: r.node_id,
2596
+ qualifiedName: r.qualified_name,
2597
+ name: r.name,
2598
+ label: r.label,
2599
+ filePath: r.file_path,
2600
+ degree: r.degree,
2601
+ }));
2602
+ return { ok: true, hits };
2603
+ } catch (error) {
2604
+ logWriteFailure(error);
2605
+ return classifyReadError(error) as SearchResult;
2606
+ }
2607
+ }
2608
+
2609
+ /**
2610
+ * Aggregate counts over the whole graph. Single round-trip: one
2611
+ * scalar per metric, two GROUP BY queries for the by-label /
2612
+ * by-type histograms.
2613
+ */
2614
+ schemaStats(): SchemaStatsResult {
2615
+ if (this.closed) return { ok: false, code: "store_closed" };
2616
+ try {
2617
+ const fileCount = expectRow<{ c: number }>(
2618
+ this.db.prepare("SELECT COUNT(*) AS c FROM files").get(),
2619
+ ["c"],
2620
+ );
2621
+ const nodeCount = expectRow<{ c: number }>(
2622
+ this.db.prepare("SELECT COUNT(*) AS c FROM nodes").get(),
2623
+ ["c"],
2624
+ );
2625
+ const edgeCount = expectRow<{ c: number }>(
2626
+ this.db.prepare("SELECT COUNT(*) AS c FROM edges").get(),
2627
+ ["c"],
2628
+ );
2629
+ const labelRows = expectRows<{ label: string; c: number }>(
2630
+ this.db
2631
+ .prepare(
2632
+ "SELECT label, COUNT(*) AS c FROM nodes GROUP BY label ORDER BY label",
2633
+ )
2634
+ .all(),
2635
+ ["label", "c"],
2636
+ );
2637
+ const typeRows = expectRows<{ type: string; c: number }>(
2638
+ this.db
2639
+ .prepare(
2640
+ "SELECT type, COUNT(*) AS c FROM edges GROUP BY type ORDER BY type",
2641
+ )
2642
+ .all(),
2643
+ ["type", "c"],
2644
+ );
2645
+ const nodesByLabel: Record<string, number> = {};
2646
+ for (const r of labelRows) nodesByLabel[r.label] = r.c;
2647
+ const edgesByType: Record<string, number> = {};
2648
+ for (const r of typeRows) edgesByType[r.type] = r.c;
2649
+ return {
2650
+ ok: true,
2651
+ stats: {
2652
+ files: fileCount?.c ?? 0,
2653
+ nodes: nodeCount?.c ?? 0,
2654
+ edges: edgeCount?.c ?? 0,
2655
+ nodesByLabel,
2656
+ edgesByType,
2657
+ },
2658
+ };
2659
+ } catch (error) {
2660
+ logWriteFailure(error);
2661
+ const failure = classifyReadError(error);
2662
+ return failure as unknown as SchemaStatsResult;
2663
+ }
2664
+ }
2665
+
2666
+ /**
2667
+ * Dead-code candidates: nodes with zero inbound
2668
+ * {@link DEAD_CODE_EXCLUSION.INBOUND_USAGE_EDGE_TYPES} edges, excluding
2669
+ * nodes whose `node_attributes` row marks them exported / route-handler
2670
+ * AND nodes whose file path matches the test / entry-point patterns
2671
+ * in {@link DEAD_CODE_EXCLUSION}.
2672
+ *
2673
+ * The exclusion criteria live in the named constant — not in
2674
+ * ad-hoc WHERE clauses (rule 53 analog). The stored flags come from
2675
+ * the write pipeline's `upsertFileAttributes` pass, which the IR's
2676
+ * `exports` and `routes` arrays feed.
2677
+ */
2678
+ deadCode(): DeadCodeResult {
2679
+ if (this.closed) return { ok: false, code: "store_closed" };
2680
+ try {
2681
+ // The inbound-usage edge-type list comes from the named
2682
+ // constant; bind it as a parameterized IN (...) so the criteria
2683
+ // are auditable in one place and a future edge-type add is a
2684
+ // one-line constant extension.
2685
+ const usageTypes = DEAD_CODE_EXCLUSION.INBOUND_USAGE_EDGE_TYPES;
2686
+ const typePlaceholders = usageTypes.map(() => "?").join(", ");
2687
+ // LEFT JOIN node_attributes so a missing row reads as (0, 0).
2688
+ // COALESCE is belt-and-braces — the LEFT JOIN already produces
2689
+ // NULL for missing rows, and `NULL OR ...` would surface NULL
2690
+ // in the WHERE; explicit COALESCE collapses NULL → 0.
2691
+ // Self-edges (e.src <> n.id) are excluded from inbound usage: a
2692
+ // private recursive helper whose only edge is `fn → fn` is
2693
+ // unreachable from the rest of the program, so the self-call
2694
+ // must not count as external usage — otherwise deadCode() omits
2695
+ // it and the unreferenced symbol stays invisible
2696
+ // (chatgpt-codex-connector P2: 'Ignore self-edges in dead-code
2697
+ // reachability').
2698
+ const sql = `SELECT n.id AS node_id, n.qualified_name, n.name, n.label,
2699
+ f.path AS file_path
2700
+ FROM nodes n
2701
+ JOIN files f ON n.file_id = f.id
2702
+ LEFT JOIN node_attributes a ON a.node_id = n.id
2703
+ WHERE NOT EXISTS (
2704
+ SELECT 1 FROM edges e
2705
+ WHERE e.dst = n.id
2706
+ AND e.src <> n.id
2707
+ AND e.type IN (${typePlaceholders})
2708
+ )
2709
+ AND COALESCE(a.is_exported, 0) = 0
2710
+ AND COALESCE(a.is_route_handler, 0) = 0
2711
+ ORDER BY n.qualified_name ASC`;
2712
+ const rows = expectRows<{
2713
+ node_id: string;
2714
+ qualified_name: string;
2715
+ name: string;
2716
+ label: string;
2717
+ file_path: string;
2718
+ }>(this.db.prepare(sql).all(...usageTypes), [
2719
+ "node_id",
2720
+ "qualified_name",
2721
+ "name",
2722
+ "label",
2723
+ "file_path",
2724
+ ]);
2725
+ // Apply path-based exclusions in JS — SQLite's regex support is
2726
+ // opt-in and inconsistent across builds; doing it here keeps the
2727
+ // exclusion logic entirely in the named constant.
2728
+ const hits: DeadCodeHit[] = [];
2729
+ for (const r of rows) {
2730
+ if (isExcludedByPath(r.file_path)) continue;
2731
+ hits.push({
2732
+ nodeId: r.node_id,
2733
+ qualifiedName: r.qualified_name,
2734
+ name: r.name,
2735
+ label: r.label,
2736
+ filePath: r.file_path,
2737
+ });
2738
+ }
2739
+ return { ok: true, hits };
2740
+ } catch (error) {
2741
+ logWriteFailure(error);
2742
+ const failure = classifyReadError(error);
2743
+ return failure as unknown as DeadCodeResult;
2744
+ }
2745
+ }
2746
+
2747
+ /**
2748
+ * Read a symbol's source span from disk. The store NEVER persists
2749
+ * file contents (privacy + DB size — issue #1552 design); this
2750
+ * method resolves `files.path` against {@link GraphStoreOptions.repoRoot}
2751
+ * and slices the half-open `[startByte, endByte)` span from the
2752
+ * on-disk bytes.
2753
+ */
2754
+ async snippetFor(query: SnippetQuery): Promise<SnippetResult> {
2755
+ if (this.closed) return { ok: false, code: "store_closed" };
2756
+ // Guard the query object before any dereference (see traverse).
2757
+ if (query == null || typeof query !== "object") {
2758
+ return { ok: false, code: "invalid_query" };
2759
+ }
2760
+ // Prefer a deterministic node id when supplied — it is unique, so it
2761
+ // never hits the qualified-name ambiguity path.
2762
+ const hasNodeId = typeof query.nodeId === "string" && query.nodeId.length > 0;
2763
+ if (
2764
+ !hasNodeId &&
2765
+ (typeof query.qualifiedName !== "string" ||
2766
+ query.qualifiedName.length === 0)
2767
+ ) {
2768
+ return { ok: false, code: "invalid_query" };
2769
+ }
2770
+ // Validate contextLines is a non-negative integer when present,
2771
+ // consistent with traverse's maxDepth and the other numeric read
2772
+ // fields. The old path coerced (Math.floor), so `1.9` silently
2773
+ // became 1 and `"2"` became 2 — reject malformed values up-front
2774
+ // instead (chatgpt-codex-connector P2: 'Reject invalid context
2775
+ // line counts').
2776
+ if (
2777
+ query.contextLines !== undefined &&
2778
+ (typeof query.contextLines !== "number" ||
2779
+ !Number.isInteger(query.contextLines) ||
2780
+ query.contextLines < 0)
2781
+ ) {
2782
+ return { ok: false, code: "invalid_query" };
2783
+ }
2784
+ const root = typeof query.repoRoot === "string" && query.repoRoot.length > 0
2785
+ ? query.repoRoot
2786
+ : this.repoRoot;
2787
+ if (root === undefined) {
2788
+ return { ok: false, code: "repo_root_unset" };
2789
+ }
2790
+ // Wrap the DB lookup in try/catch (cursor Bugbot: 'SQLite errors
2791
+ // escape read APIs'). The file read below has its own catch.
2792
+ let rows: {
2793
+ id: string;
2794
+ qualified_name: string;
2795
+ file_path: string;
2796
+ span_start: number;
2797
+ span_end: number;
2798
+ lang: string;
2799
+ }[];
2800
+ try {
2801
+ rows = expectRows<{
2802
+ id: string;
2803
+ qualified_name: string;
2804
+ file_path: string;
2805
+ span_start: number;
2806
+ span_end: number;
2807
+ lang: string;
2808
+ }>(
2809
+ this.db
2810
+ .prepare(
2811
+ `SELECT n.id, n.qualified_name, n.span_start, n.span_end, n.lang,
2812
+ f.path AS file_path
2813
+ FROM nodes n JOIN files f ON n.file_id = f.id
2814
+ WHERE ${hasNodeId ? "n.id = ?" : "n.qualified_name = ?"}`,
2815
+ )
2816
+ .all(hasNodeId ? query.nodeId : query.qualifiedName),
2817
+ ["id", "qualified_name", "file_path", "span_start", "span_end", "lang"],
2818
+ );
2819
+ } catch (error) {
2820
+ logWriteFailure(error);
2821
+ return classifyReadError(error) as unknown as SnippetResult;
2822
+ }
2823
+ if (rows.length === 0) return { ok: false, code: "not_found" };
2824
+ if (rows.length > 1) return { ok: false, code: "ambiguous_name" };
2825
+ const node = rows[0]!;
2826
+ const absolutePath = path.resolve(root, node.file_path);
2827
+ // Read the file from disk and slice the span. readFile is the
2828
+ // single fs call — no streaming, no mmap, just one allocation per
2829
+ // request. The store caches nothing; the caller may.
2830
+ let bytes: Buffer;
2831
+ try {
2832
+ bytes = await readFile(absolutePath);
2833
+ } catch (error) {
2834
+ logWriteFailure(error);
2835
+ return { ok: false, code: "read_failed" };
2836
+ }
2837
+ // Half-open [startByte, endByte). Guard endByte ≤ buffer.length
2838
+ // so a stale span after a file edit does not throw OutOfRange.
2839
+ const start = Math.max(0, node.span_start);
2840
+ const end = Math.min(bytes.length, node.span_end);
2841
+ if (start > end) {
2842
+ // The file shrank below the span — return an empty snippet
2843
+ // rather than throw; the caller can decide whether to re-ingest.
2844
+ return {
2845
+ ok: true,
2846
+ qualifiedName: node.qualified_name,
2847
+ filePath: node.file_path,
2848
+ absolutePath,
2849
+ startByte: node.span_start,
2850
+ endByte: node.span_end,
2851
+ text: "",
2852
+ lang: node.lang,
2853
+ };
2854
+ }
2855
+ let text = bytes.subarray(start, end).toString("utf8");
2856
+ // Optional context lines (line-aligned expansion). contextLines
2857
+ // is bounded to a sane cap so a caller cannot ask for megabytes
2858
+ // of surrounding code.
2859
+ const ctx = query.contextLines ?? 0;
2860
+ if (ctx > 0) {
2861
+ const MAX_CTX = 200;
2862
+ const contextLines = Math.min(Math.max(0, Math.floor(ctx)), MAX_CTX);
2863
+ if (contextLines > 0) {
2864
+ // Line-aligned expansion. For contextLines=N we include the N
2865
+ // full lines preceding the span's line and the N full lines
2866
+ // following the span's line. Walk backward from `start`,
2867
+ // skipping (contextLines) line-end newlines, then walk to the
2868
+ // start of the (contextLines+1)th line back; walk forward
2869
+ // from `end` symmetrically.
2870
+ //
2871
+ // The first newline we hit going backward is the END of the
2872
+ // span's own line, NOT a context line — so we need to count
2873
+ // `contextLines` newlines after that boundary to find where
2874
+ // the context region begins. Concrete example for N=1:
2875
+ // "...line one\nline two\n..." with span starting at "line two"
2876
+ // walking back from start of "line two", we hit \n (end of
2877
+ // "line one"). The line "line one" IS the context. Its start
2878
+ // is one further newline back (or buffer start).
2879
+ let lineStart = start;
2880
+ // Move lineStart to the beginning of the line containing `start`.
2881
+ while (lineStart > 0 && bytes[lineStart - 1] !== 0x0a) {
2882
+ lineStart -= 1;
2883
+ }
2884
+ // For each of contextLines, jump past the newline at
2885
+ // lineStart - 1 and walk to the previous line's start.
2886
+ for (let i = 0; i < contextLines && lineStart > 0; i += 1) {
2887
+ // Step past the newline ending the prior line.
2888
+ lineStart -= 1;
2889
+ // Walk to the start of THAT line.
2890
+ while (lineStart > 0 && bytes[lineStart - 1] !== 0x0a) {
2891
+ lineStart -= 1;
2892
+ }
2893
+ }
2894
+ let lineEnd = end;
2895
+ // Move lineEnd to the end of the line containing `end`
2896
+ // (inclusive of the trailing newline if present).
2897
+ while (lineEnd < bytes.length && bytes[lineEnd] !== 0x0a) {
2898
+ lineEnd += 1;
2899
+ }
2900
+ if (lineEnd < bytes.length && bytes[lineEnd] === 0x0a) {
2901
+ lineEnd += 1;
2902
+ }
2903
+ // For each of contextLines, advance past one more line.
2904
+ for (let i = 0; i < contextLines && lineEnd < bytes.length; i += 1) {
2905
+ while (lineEnd < bytes.length && bytes[lineEnd] !== 0x0a) {
2906
+ lineEnd += 1;
2907
+ }
2908
+ if (lineEnd < bytes.length && bytes[lineEnd] === 0x0a) {
2909
+ lineEnd += 1;
2910
+ }
2911
+ }
2912
+ text = bytes.subarray(lineStart, lineEnd).toString("utf8");
2913
+ }
2914
+ }
2915
+ return {
2916
+ ok: true,
2917
+ qualifiedName: node.qualified_name,
2918
+ filePath: node.file_path,
2919
+ absolutePath,
2920
+ startByte: node.span_start,
2921
+ endByte: node.span_end,
2922
+ text,
2923
+ lang: node.lang,
2924
+ };
2925
+ }
2926
+
2927
+ // ──────────────────────────────────────────────────────────────────────
2928
+ // Semantic layer (issue #1556): symbol_vectors table read/write.
2929
+ // The db is private; these methods are the ONLY surface the semantic
2930
+ // indexer/query path uses. Vectors are float32 BLOBs; content_hash is
2931
+ // the canonical-text hash (rule 37 — the cache invalidation key).
2932
+ // ──────────────────────────────────────────────────────────────────────
2933
+
2934
+ /**
2935
+ * Upsert one symbol vector. Idempotent on (node_id, model_id). The
2936
+ * caller (the semantic indexer) has ALREADY decided to re-embed (the
2937
+ * content_hash differs from the cached row); this method just persists.
2938
+ */
2939
+ async writeSymbolVector(input: {
2940
+ readonly nodeId: string;
2941
+ readonly modelId: string;
2942
+ readonly contentHash: string;
2943
+ readonly dims: number;
2944
+ readonly vector: Float32Array;
2945
+ }): Promise<boolean> {
2946
+ // Honor the closing flag (not just closed) and serialize via the write
2947
+ // queue, matching upsertFileBatch / upsertEdges / clearSemanticSimilarToEdges
2948
+ // — otherwise concurrent graph ingestion can interleave a vector upsert
2949
+ // with a transactional node delete (cursor Bugbot: 'Vector writes ignore
2950
+ // closing flag' + 'Vector writes bypass write queue').
2951
+ if (this.closed || this.closing) return false;
2952
+ const buf = Buffer.from(input.vector.buffer, input.vector.byteOffset, input.vector.byteLength);
2953
+ await this.queue.schedule(async () => {
2954
+ this.db
2955
+ .prepare(
2956
+ `INSERT INTO symbol_vectors (node_id, model_id, content_hash, dims, vector)
2957
+ VALUES (?, ?, ?, ?, ?)
2958
+ ON CONFLICT(node_id, model_id) DO UPDATE SET
2959
+ content_hash = excluded.content_hash,
2960
+ dims = excluded.dims,
2961
+ vector = excluded.vector`,
2962
+ )
2963
+ .run(input.nodeId, input.modelId, input.contentHash, input.dims, buf);
2964
+ });
2965
+ return true;
2966
+ }
2967
+
2968
+ /**
2969
+ * Read one vector row by (node_id, model_id). Returns null when absent.
2970
+ * Used by the indexer's cache-check path (skip re-embed when content_hash
2971
+ * matches) and by the cache-hit test.
2972
+ */
2973
+ readSymbolVector(
2974
+ nodeId: string,
2975
+ modelId: string,
2976
+ ): { readonly contentHash: string; readonly dims: number; readonly vector: Float32Array } | null {
2977
+ if (this.closed) return null;
2978
+ const row = expectRow<{ content_hash: string; dims: number; vector: Uint8Array }>(
2979
+ this.db
2980
+ .prepare(
2981
+ `SELECT content_hash, dims, vector FROM symbol_vectors
2982
+ WHERE node_id = ? AND model_id = ?`,
2983
+ )
2984
+ .get(nodeId, modelId),
2985
+ ["content_hash", "dims", "vector"],
2986
+ );
2987
+ if (!row) return null;
2988
+ return {
2989
+ contentHash: row.content_hash,
2990
+ dims: row.dims,
2991
+ vector: new Float32Array(row.vector.buffer, row.vector.byteOffset, row.vector.byteLength / 4),
2992
+ };
2993
+ }
2994
+
2995
+ /**
2996
+ * Read every vector row for a given model. Used by brute-force cosine
2997
+ * retrieval (SIMILAR_TO confirmation + semantic_query). Returns node
2998
+ * metadata alongside the vector so callers can hydrate hits without a
2999
+ * second round-trip.
3000
+ */
3001
+ readAllSymbolVectors(modelId: string): readonly {
3002
+ readonly nodeId: string;
3003
+ readonly qualifiedName: string;
3004
+ readonly filePath: string;
3005
+ readonly kind: string;
3006
+ readonly dims: number;
3007
+ readonly vector: Float32Array;
3008
+ readonly contentHash: string;
3009
+ }[] {
3010
+ if (this.closed) return [];
3011
+ const rows = expectRows<{
3012
+ node_id: string;
3013
+ qualified_name: string;
3014
+ label: string;
3015
+ file_path: string;
3016
+ dims: number;
3017
+ vector: Uint8Array;
3018
+ content_hash: string;
3019
+ }>(
3020
+ this.db
3021
+ .prepare(
3022
+ `SELECT sv.node_id, sv.dims, sv.vector, sv.content_hash,
3023
+ n.qualified_name, n.label, f.path AS file_path
3024
+ FROM symbol_vectors sv
3025
+ JOIN nodes n ON sv.node_id = n.id
3026
+ JOIN files f ON n.file_id = f.id
3027
+ WHERE sv.model_id = ?`,
3028
+ )
3029
+ .all(modelId),
3030
+ ["node_id", "qualified_name", "label", "file_path", "dims", "vector", "content_hash"],
3031
+ );
3032
+ return rows.map((r) => ({
3033
+ nodeId: r.node_id,
3034
+ qualifiedName: r.qualified_name,
3035
+ filePath: r.file_path,
3036
+ kind: r.label,
3037
+ dims: r.dims,
3038
+ contentHash: r.content_hash,
3039
+ vector: new Float32Array(r.vector.buffer, r.vector.byteOffset, r.vector.byteLength / 4),
3040
+ }));
3041
+ }
3042
+
3043
+ /**
3044
+ * Delete vector rows for a set of node ids (all models). Used by the
3045
+ * cache-invalidation path when a symbol's canonical text changed AND
3046
+ * it could not be re-embedded (provider gone) — the stale vector must
3047
+ * not survive to pollute cosine retrieval. Cascades via the schema's
3048
+ * ON DELETE CASCADE on nodes(id) when a node is pruned, so this method
3049
+ * is only for the targeted-invalidation path.
3050
+ */
3051
+ async deleteSymbolVectors(nodeIds: readonly string[]): Promise<void> {
3052
+ // Same closing-flag + write-queue discipline as writeSymbolVector
3053
+ // (cursor Bugbot: 'Vector writes ignore closing flag' + 'Vector writes
3054
+ // bypass write queue').
3055
+ if (this.closed || this.closing || nodeIds.length === 0) return;
3056
+ await this.queue.schedule(async () => {
3057
+ this.runChunkedDelete(
3058
+ "DELETE FROM symbol_vectors WHERE node_id IN (%PH%)",
3059
+ nodeIds,
3060
+ );
3061
+ });
3062
+ }
3063
+
3064
+ /**
3065
+ * Remove every SIMILAR_TO edge written by the semantic similarity
3066
+ * pipeline (type 'SIMILAR_TO', provenance 'semantic'). The pipeline
3067
+ * recomputes the FULL near-clone edge set on each run, so callers MUST
3068
+ * clear the prior set before upserting the new one — otherwise an edge
3069
+ * between two symbols that stopped being similar survives indefinitely
3070
+ * and graph traversal keeps reporting a stale clone relationship
3071
+ * (chatgpt-codex-connector P2: 'Replace old SIMILAR_TO edges on
3072
+ * recompute'). Scoped to provenance 'semantic' so non-semantic edges
3073
+ * are untouched. Serialized via the write queue so it cannot interleave
3074
+ * a concurrent file-batch edge upsert.
3075
+ */
3076
+ async clearSemanticSimilarToEdges(): Promise<void> {
3077
+ if (this.closed || this.closing) return;
3078
+ await this.queue.schedule(async () => {
3079
+ this.db
3080
+ .prepare("DELETE FROM edges WHERE type = ? AND provenance = ?")
3081
+ .run("SIMILAR_TO", "semantic");
3082
+ });
3083
+ }
3084
+
3085
+ /**
3086
+ * Read every node with its file path + span, for the semantic indexer.
3087
+ * The indexer reads source text from disk (via repoRoot) and builds
3088
+ * canonical text per node. Returns kind + qualified_name + span so the
3089
+ * indexer can reconstruct the SymbolIR-equivalent without a second
3090
+ * join. Ordered by qualified_name for deterministic processing order.
3091
+ */
3092
+ readNodesForSemantic(): readonly {
3093
+ readonly nodeId: string;
3094
+ readonly qualifiedName: string;
3095
+ readonly kind: string;
3096
+ readonly filePath: string;
3097
+ readonly startByte: number;
3098
+ readonly endByte: number;
3099
+ readonly lang: string;
3100
+ }[] {
3101
+ if (this.closed) return [];
3102
+ const rows = expectRows<{
3103
+ id: string;
3104
+ qualified_name: string;
3105
+ label: string;
3106
+ file_path: string;
3107
+ span_start: number;
3108
+ span_end: number;
3109
+ lang: string;
3110
+ }>(
3111
+ this.db
3112
+ .prepare(
3113
+ `SELECT n.id, n.qualified_name, n.label, n.span_start, n.span_end, n.lang,
3114
+ f.path AS file_path
3115
+ FROM nodes n JOIN files f ON n.file_id = f.id
3116
+ ORDER BY n.qualified_name ASC`,
3117
+ )
3118
+ .all(),
3119
+ ["id", "qualified_name", "label", "file_path", "span_start", "span_end", "lang"],
3120
+ );
3121
+ return rows.map((r) => ({
3122
+ nodeId: r.id,
3123
+ qualifiedName: r.qualified_name,
3124
+ kind: r.label,
3125
+ filePath: r.file_path,
3126
+ startByte: r.span_start,
3127
+ endByte: r.span_end,
3128
+ lang: r.lang,
3129
+ }));
3130
+ }
3131
+
3132
+ /**
3133
+ * Read the callers and callees of a node by qualified name, for
3134
+ * semantic_query hydration (the issue: hydrate each hit with graph
3135
+ * context — defining file, direct callers/callees).
3136
+ */
3137
+ readNeighbors(
3138
+ qualifiedName: string,
3139
+ ): { readonly callers: readonly string[]; readonly callees: readonly string[] } {
3140
+ if (this.closed) return { callers: [], callees: [] };
3141
+ // Resolve the node id first.
3142
+ const nodeRow = expectRow<{ id: string }>(
3143
+ this.db
3144
+ .prepare("SELECT id FROM nodes WHERE qualified_name = ?")
3145
+ .get(qualifiedName),
3146
+ ["id"],
3147
+ );
3148
+ if (!nodeRow) return { callers: [], callees: [] };
3149
+ const id = nodeRow.id;
3150
+ // Callers: nodes that CALL this node (edges where dst = id, type CALLS).
3151
+ const callerRows = expectRows<{ qualified_name: string }>(
3152
+ this.db
3153
+ .prepare(
3154
+ `SELECT n.qualified_name FROM edges e
3155
+ JOIN nodes n ON e.src = n.id
3156
+ WHERE e.dst = ? AND e.type = 'CALLS'`,
3157
+ )
3158
+ .all(id),
3159
+ ["qualified_name"],
3160
+ );
3161
+ // Callees: nodes this node CALLS (edges where src = id, type CALLS).
3162
+ const calleeRows = expectRows<{ qualified_name: string }>(
3163
+ this.db
3164
+ .prepare(
3165
+ `SELECT n.qualified_name FROM edges e
3166
+ JOIN nodes n ON e.dst = n.id
3167
+ WHERE e.src = ? AND e.type = 'CALLS'`,
3168
+ )
3169
+ .all(id),
3170
+ ["qualified_name"],
3171
+ );
3172
+ return {
3173
+ callers: callerRows.map((r) => r.qualified_name),
3174
+ callees: calleeRows.map((r) => r.qualified_name),
3175
+ };
3176
+ }
3177
+
3178
+ /**
3179
+ * Read callers/callees by node id directly (avoids the qualified-name
3180
+ * ambiguity when duplicate names exist across files). Used by
3181
+ * semantic_query hydration (chatgpt-codex-connector: 'Use the hit node
3182
+ * id when hydrating neighbors').
3183
+ */
3184
+ readNeighborsByNodeId(
3185
+ nodeId: string,
3186
+ ): { readonly callers: readonly string[]; readonly callees: readonly string[] } {
3187
+ if (this.closed) return { callers: [], callees: [] };
3188
+ const callerRows = expectRows<{ qualified_name: string }>(
3189
+ this.db
3190
+ .prepare(
3191
+ `SELECT n.qualified_name FROM edges e
3192
+ JOIN nodes n ON e.src = n.id
3193
+ WHERE e.dst = ? AND e.type = 'CALLS'`,
3194
+ )
3195
+ .all(nodeId),
3196
+ ["qualified_name"],
3197
+ );
3198
+ const calleeRows = expectRows<{ qualified_name: string }>(
3199
+ this.db
3200
+ .prepare(
3201
+ `SELECT n.qualified_name FROM edges e
3202
+ JOIN nodes n ON e.dst = n.id
3203
+ WHERE e.src = ? AND e.type = 'CALLS'`,
3204
+ )
3205
+ .all(nodeId),
3206
+ ["qualified_name"],
3207
+ );
3208
+ return {
3209
+ callers: callerRows.map((r) => r.qualified_name),
3210
+ callees: calleeRows.map((r) => r.qualified_name),
3211
+ };
3212
+ }
3213
+ }
3214
+
3215
+ // ──────────────────────────────────────────────────────────────────────────
3216
+ // Node id hashing — sorted key material (rule 23/38).
3217
+ // ──────────────────────────────────────────────────────────────────────────
3218
+
3219
+ export interface NodeIdInput {
3220
+ qualifiedName: string;
3221
+ filePath: string;
3222
+ label: string;
3223
+ }
3224
+
3225
+ /**
3226
+ * sha256 over the sorted key material. The exact form MUST match between
3227
+ * ingest and lookup; tests assert this. Sort is stable (string compare),
3228
+ * no separators needed — the three fields are concatenated with a length
3229
+ * prefix so collision space is unambiguous.
3230
+ */
3231
+ export function nodeIdFor(input: NodeIdInput): string {
3232
+ const fields = [
3233
+ ["qualifiedName", input.qualifiedName],
3234
+ ["filePath", input.filePath],
3235
+ ["label", input.label],
3236
+ ]
3237
+ .map(([k, v]) => [k as string, String(v)] as [string, string])
3238
+ .sort(([a], [b]) => a.localeCompare(b));
3239
+ const hash = createHash("sha256");
3240
+ for (const [k, v] of fields) {
3241
+ hash.update(`${k.length}:${k}:`);
3242
+ hash.update(`${v.length}:${v}:`);
3243
+ }
3244
+ return hash.digest("hex");
3245
+ }
3246
+
3247
+ /**
3248
+ * Resolve a qualified_name to its deterministic node id.
3249
+ *
3250
+ * `inBatch` is the per-file map built from `nodes` rows owned by the
3251
+ * edge's source file (the FileIR.path the edge came in on). The
3252
+ * DB fallback is for cross-file edges whose src/dst lives in a
3253
+ * DIFFERENT file (in the same batch or a prior batch). Node
3254
+ * identity is the full `(qualifiedName, filePath, label)` triple
3255
+ * (see `nodeIdFor`), so a qualified_name match alone is ambiguous
3256
+ * when two files declare the same symbol. The fallback uses
3257
+ * `ORDER BY file_id, id` to pick deterministically, but only when
3258
+ * exactly one row matches — multiple matches return `undefined`
3259
+ * and the edge is dropped at insert time (per the dangling-edge
3260
+ * policy; the caller is responsible for the batch's canonical
3261
+ * file set). This is the conservative call for a write pipeline
3262
+ * whose caller knows the canonical file set on each batch
3263
+ * (rule 11, 40 — chatgpt-codex-connector P2 + cursor Bugbot
3264
+ * #1380bc89).
3265
+ */
3266
+ function resolveNodeId(
3267
+ qualifiedName: string,
3268
+ inBatch: Map<string, string>,
3269
+ db: BetterSqlite3Database,
3270
+ ): string | undefined {
3271
+ const local = inBatch.get(qualifiedName);
3272
+ if (local) return local;
3273
+ const rows = expectRows<{ id: string; file_id: number }>(
3274
+ db
3275
+ .prepare(
3276
+ "SELECT id, file_id FROM nodes WHERE qualified_name = ? ORDER BY file_id, id",
3277
+ )
3278
+ .all(qualifiedName),
3279
+ ["id", "file_id"],
3280
+ );
3281
+ if (rows.length === 0) return undefined;
3282
+ if (rows.length > 1) {
3283
+ // Ambiguous — drop the edge rather than attach it to the wrong
3284
+ // node. Callers needing disambiguation should include the target
3285
+ // file in the same batch (the per-file map then wins) or extend
3286
+ // EdgeIR with file identity material.
3287
+ return undefined;
3288
+ }
3289
+ return rows[0]?.id;
3290
+ }
3291
+
3292
+ /**
3293
+ * Resolve a standalone-edge endpoint by content-derived node id (issue #1677).
3294
+ *
3295
+ * `nodes.id` is the PRIMARY KEY, so the lookup is unique and unambiguous —
3296
+ * this is what lets a SIMILAR_TO edge between two same-qualified-name symbols
3297
+ * resolve where the qualified-name fallback would be ambiguous. The supplied
3298
+ * `qualifiedName` is validated against the row: a stale or mismatched
3299
+ * id+qname pair (stale body map, custom integration) returns `undefined` so
3300
+ * the caller skips the edge like a dangling endpoint instead of silently
3301
+ * writing an edge from the wrong node (chatgpt-codex-connector P2 — id/qname
3302
+ * consistency at the store boundary). `stmt` is the caller's prepared
3303
+ * `SELECT qualified_name FROM nodes WHERE id = ?`.
3304
+ */
3305
+ function resolveByNodeId(
3306
+ stmt: { get(...args: unknown[]): unknown },
3307
+ nodeId: string,
3308
+ qualifiedName: string,
3309
+ ): string | undefined {
3310
+ const row = expectRow<{ qualified_name: string }>(stmt.get(nodeId), ["qualified_name"]);
3311
+ if (!row) return undefined;
3312
+ if (row.qualified_name !== qualifiedName) return undefined;
3313
+ return nodeId;
3314
+ }
3315
+ // ──────────────────────────────────────────────────────────────────────────
3316
+ // Error classification — tag SQLITE_BUSY / SQLITE_CORRUPT into the failure
3317
+ // shape (rule 34). better-sqlite3 surfaces them as `SqliteError` with `.code`.
3318
+ // ──────────────────────────────────────────────────────────────────────────
3319
+
3320
+ function classifyError(error: unknown): GraphStoreFailure {
3321
+ const code = hasErrorCode(error) ? error.code : "";
3322
+ if (code === "SQLITE_BUSY" || code === "SQLITE_LOCKED") {
3323
+ return { ok: false, code: "db_locked" };
3324
+ }
3325
+ const msg = error instanceof Error ? error.message : String(error ?? "");
3326
+ if (
3327
+ code === "SQLITE_CORRUPT" ||
3328
+ code === "SQLITE_NOTADB" ||
3329
+ msg.includes("database disk image is malformed")
3330
+ ) {
3331
+ return { ok: false, code: "db_corrupt" };
3332
+ }
3333
+ // Non-SQLite errors are NOT disk corruption — they are validation
3334
+ // failures (e.g. invalid edge provenance) or programming errors.
3335
+ // Conflating them with `db_corrupt` would tell the caller to stop
3336
+ // trusting the store for the wrong reason; re-throw so the caller
3337
+ // sees the real error (chatgpt-codex-connector P2).
3338
+ throw error instanceof Error ? error : new Error(String(error ?? ""));
3339
+ }
3340
+
3341
+ /**
3342
+ * Read-path error classifier. Unlike {@link classifyError} (write
3343
+ * path), this is TOTAL — it never throws. Every unexpected error
3344
+ * maps to a tagged `db_error` failure so the read APIs
3345
+ * (`traverse`/`searchGraph`/`schemaStats`/`deadCode`/`snippetFor`)
3346
+ * honor their advertised discriminated-union contract: a caller that
3347
+ * exhaustively switches on `result.code` never observes a throw
3348
+ * (cursor Bugbot: 'Read APIs rethrow SQLite errors';
3349
+ * chatgpt-codex-connector P2: 'Return tagged failures from read
3350
+ * queries'). The write path keeps {@link classifyError} because its
3351
+ * validation throws (duplicate path, bad confidence, non-array
3352
+ * symbols) are intentional fail-loud contract violations that
3353
+ * callers and tests catch as rejections.
3354
+ *
3355
+ * `db_error` is deliberately distinct from `db_corrupt` so a generic
3356
+ * failure does not signal the caller to stop trusting the DB
3357
+ * (chatgpt-codex-connector P2: do not conflate unexpected errors
3358
+ * with corruption).
3359
+ */
3360
+ function classifyReadError(error: unknown): GraphStoreFailure {
3361
+ try {
3362
+ return classifyError(error);
3363
+ } catch {
3364
+ return { ok: false, code: "db_error" };
3365
+ }
3366
+ }
3367
+
3368
+ function hasErrorCode(value: unknown): value is { code: string } {
3369
+ if (typeof value !== "object" || value === null) return false;
3370
+ if (!("code" in value)) return false;
3371
+ const codeValue: unknown = (value as Record<string, unknown>)["code"];
3372
+ return typeof codeValue === "string";
3373
+ }
3374
+
3375
+ /**
3376
+ * Internal log for write failures. Never exposed to callers — the
3377
+ * public failure union only carries the code, so absolute paths
3378
+ * from `error.message` cannot leak into agents, HTTP responses, or
3379
+ * MCP tool results (rule 11).
3380
+ */
3381
+ function logWriteFailure(error: unknown): void {
3382
+ // eslint-disable-next-line no-console
3383
+ console.error(
3384
+ "[coding-graph] write failure:",
3385
+ error instanceof Error ? error.message : String(error ?? ""),
3386
+ );
3387
+ }
3388
+
3389
+ /**
3390
+ * Reject non-canonical repo-relative paths at the store boundary. The
3391
+ * FileIR contract requires forward-slash, repo-relative paths; a caller
3392
+ * that emits `./src/a.ts`, backslashes, or an absolute path would hash
3393
+ * to a distinct files row + node id and leave duplicate/stale symbols a
3394
+ * later canonical ingest cannot match or prune
3395
+ * (chatgpt-codex-connector P2: 'Reject non-canonical file paths before
3396
+ * persisting').
3397
+ */
3398
+ function assertCanonicalFilePath(filePath: unknown): void {
3399
+ if (typeof filePath !== "string" || filePath.length === 0) {
3400
+ throw new Error(
3401
+ `graph-store: file path must be a non-empty string; received ${
3402
+ filePath === null ? "null" : typeof filePath
3403
+ }`,
3404
+ );
3405
+ }
3406
+ // Windows separators — the contract mandates forward slashes.
3407
+ if (filePath.includes("\\")) {
3408
+ throw new Error(
3409
+ `graph-store: file path '${filePath}' must use forward slashes (backslash rejected — FileIR contract requires repo-relative POSIX paths)`,
3410
+ );
3411
+ }
3412
+ // Absolute POSIX path or a Windows drive root.
3413
+ if (filePath.startsWith("/") || /^[A-Za-z]:[\\/]/.test(filePath)) {
3414
+ throw new Error(
3415
+ `graph-store: file path '${filePath}' must be repo-relative (absolute path rejected — FileIR contract requires repo-relative forward-slash paths)`,
3416
+ );
3417
+ }
3418
+ // `.` / `..` segments alias a canonical path (`./src/a.ts` vs
3419
+ // `src/a.ts`, or `src/../a.ts`) and would hash to a distinct files
3420
+ // row + node id, leaving duplicates the canonical ingest cannot
3421
+ // match or prune. Segment-based check avoids false positives on
3422
+ // names like `a..b.ts`.
3423
+ if (filePath.split("/").some((segment) => segment === "." || segment === "..")) {
3424
+ throw new Error(
3425
+ `graph-store: file path '${filePath}' must be canonical (no '.' or '..' segments — FileIR contract requires repo-relative forward-slash paths)`,
3426
+ );
3427
+ }
3428
+ }
3429
+
3430
+ /**
3431
+ * Reject malformed symbol spans before they are bound into span_start /
3432
+ * span_end. The FileIR contract documents half-open byte spans
3433
+ * `[startByte, endByte)`; a buggy parser or JSON caller can emit
3434
+ * startByte > endByte or non-integer values, and PR2 snippet/search
3435
+ * consumers will trust the offsets as-is, producing invalid source
3436
+ * slices. Reject at the boundary rather than persisting corrupt metadata
3437
+ * (chatgpt-codex-connector P2: 'Reject invalid symbol spans before
3438
+ * storing nodes'). Narrowing is done with typeof/in guards (no casts) so
3439
+ * the compiler verifies every access.
3440
+ */
3441
+ function assertValidSymbolSpan(sym: unknown, filePath: string): void {
3442
+ if (typeof sym !== "object" || sym === null) {
3443
+ throw new Error(
3444
+ `graph-store: file '${filePath}' has a non-object symbol; received ${
3445
+ sym === null ? "null" : typeof sym
3446
+ }`,
3447
+ );
3448
+ }
3449
+ if (!("span" in sym)) {
3450
+ throw new Error(
3451
+ `graph-store: file '${filePath}' has a symbol with no span (FileIR contract requires startByte/endByte)`,
3452
+ );
3453
+ }
3454
+ const span: unknown = sym.span;
3455
+ if (
3456
+ typeof span !== "object" ||
3457
+ span === null ||
3458
+ !("startByte" in span) ||
3459
+ !("endByte" in span)
3460
+ ) {
3461
+ throw new Error(
3462
+ `graph-store: file '${filePath}' has a symbol with a malformed span — expected { startByte, endByte }; received ${JSON.stringify(span)}`,
3463
+ );
3464
+ }
3465
+ const startByte: unknown = span.startByte;
3466
+ const endByte: unknown = span.endByte;
3467
+ // typeof narrows unknown → number; Number.isInteger then rejects
3468
+ // NaN/Infinity, which typeof === "number" admits.
3469
+ if (
3470
+ typeof startByte !== "number" ||
3471
+ typeof endByte !== "number" ||
3472
+ !Number.isInteger(startByte) ||
3473
+ !Number.isInteger(endByte)
3474
+ ) {
3475
+ throw new Error(
3476
+ `graph-store: file '${filePath}' has a symbol with a non-integer span [${JSON.stringify(startByte)}, ${JSON.stringify(endByte)}) — startByte and endByte must be finite integers`,
3477
+ );
3478
+ }
3479
+ if (startByte < 0 || endByte < 0) {
3480
+ throw new Error(
3481
+ `graph-store: file '${filePath}' has a symbol with a negative span [${startByte}, ${endByte}) — byte offsets must be non-negative`,
3482
+ );
3483
+ }
3484
+ if (startByte > endByte) {
3485
+ throw new Error(
3486
+ `graph-store: file '${filePath}' has a symbol with startByte > endByte [${startByte}, ${endByte}) — half-open spans require startByte <= endByte`,
3487
+ );
3488
+ }
3489
+ }