@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,1838 @@
1
+ import {
2
+ applyCodingGraphSchema,
3
+ expectRow,
4
+ expectRows,
5
+ ftsRowidForNodeId,
6
+ isEdgeProvenance,
7
+ readSchemaVersion
8
+ } from "./chunk-ZVCMIM4T.js";
9
+
10
+ // src/graph-store.ts
11
+ import { createHash } from "crypto";
12
+ import { mkdir, readFile } from "fs/promises";
13
+ import path from "path";
14
+ import {
15
+ openBetterSqlite3
16
+ } from "@remnic/core/runtime/better-sqlite";
17
+ var DEFAULT_TRAVERSE_PATHS_MAX = 1e4;
18
+ var MAX_TRAVERSE_PATHS_HOPS = 1e3;
19
+ var DEAD_CODE_EXCLUSION = {
20
+ /**
21
+ * Edge types that — when pointing INTO a node — count as "this node
22
+ * is used". Mirrors the issue's `CALLS/USAGE` wording plus the four
23
+ * call-flavored edge types in the wider coding-graph vocabulary.
24
+ */
25
+ INBOUND_USAGE_EDGE_TYPES: [
26
+ "CALLS",
27
+ "USES_TYPE",
28
+ "ASYNC_CALLS",
29
+ "HTTP_CALLS",
30
+ "DATA_FLOWS"
31
+ ],
32
+ /**
33
+ * File-path regexes identifying test files. Matched against
34
+ * `files.path` (repo-relative, forward slashes).
35
+ */
36
+ TEST_PATH_PATTERNS: [
37
+ /\.test\.[cm]?[tj]sx?$/,
38
+ /\.spec\.[cm]?[tj]sx?$/,
39
+ /(^|\/)__tests__\//,
40
+ /(^|\/)__mocks__\//,
41
+ /(^|\/)tests?\//,
42
+ /(^|\/)test\//
43
+ ],
44
+ /**
45
+ * File-path regexes identifying entry points (reachable from
46
+ * outside the indexed code). Matched against `files.path`. Kept
47
+ * deliberately narrow — `server.ts` / `app.ts` are intentionally
48
+ * NOT treated as entry points because they are common module
49
+ * names that may also contain dead helpers. The conservative
50
+ * direction is to report a symbol as dead rather than hide it.
51
+ */
52
+ ENTRY_POINT_PATH_PATTERNS: [
53
+ /(^|\/)index\.[cm]?[tj]sx?$/,
54
+ /(^|\/)main\.[cm]?[tj]sx?$/,
55
+ /(^|\/)cli\.[cm]?[tj]sx?$/,
56
+ /(^|\/)bin\//,
57
+ /(^|\/)src\/bin\//
58
+ ],
59
+ /**
60
+ * Columns on `node_attributes` whose value being `1` excludes the
61
+ * node. Names mirror the schema so a future column add is a one-line
62
+ * constant extension + a query clause (no scattered edits).
63
+ */
64
+ EXCLUDED_ATTRIBUTE_FLAGS: ["is_exported", "is_route_handler"]
65
+ };
66
+ function isExcludedByPath(filePath) {
67
+ for (const re of DEAD_CODE_EXCLUSION.TEST_PATH_PATTERNS) {
68
+ if (re.test(filePath)) return true;
69
+ }
70
+ for (const re of DEAD_CODE_EXCLUSION.ENTRY_POINT_PATH_PATTERNS) {
71
+ if (re.test(filePath)) return true;
72
+ }
73
+ return false;
74
+ }
75
+ var SQLITE_VARIABLE_LIMIT = 32766;
76
+ var WriteQueue = class {
77
+ tail = Promise.resolve();
78
+ schedule(run) {
79
+ const next = this.tail.then(run, run);
80
+ this.tail = next.catch(() => void 0);
81
+ return next;
82
+ }
83
+ /** Test seam: wait until the queue has drained. */
84
+ async drain() {
85
+ await this.tail;
86
+ }
87
+ };
88
+ var GraphStore = class _GraphStore {
89
+ db;
90
+ queue = new WriteQueue();
91
+ repoRoot;
92
+ closed = false;
93
+ closing = false;
94
+ /**
95
+ * True once close() has begun (closing) or completed (closed). Public so
96
+ * callers that hold a GraphStore reference can return the documented
97
+ * 'store_closed' degradation code instead of treating a closed store as
98
+ * an empty graph (cursor Bugbot: 'Closed store reports success'). The
99
+ * read primitives already short-circuit on this internally; this getter
100
+ * lets the semantic entry points do the same BEFORE calling a read that
101
+ * would return [].
102
+ */
103
+ get isClosed() {
104
+ return this.closed || this.closing;
105
+ }
106
+ // Shared drain-and-close promise so a second close() called while the
107
+ // first is still draining awaits the same completion instead of
108
+ // resolving early (chatgpt-codex-connector P2: 'Wait for an
109
+ // in-progress close').
110
+ closePromise;
111
+ constructor(db, repoRoot) {
112
+ this.db = db;
113
+ this.repoRoot = repoRoot;
114
+ }
115
+ /**
116
+ * Open a store at the given dbPath. Creates parent directories and
117
+ * applies the schema (idempotent — also handles upgrade). The dbPath
118
+ * does no namespace resolution.
119
+ */
120
+ static async open(options) {
121
+ const { dbPath, repoRoot } = options;
122
+ if (!path.isAbsolute(dbPath)) {
123
+ throw new Error(
124
+ `graph-store: dbPath must be absolute; received ${JSON.stringify(dbPath)}`
125
+ );
126
+ }
127
+ if (repoRoot !== void 0 && !path.isAbsolute(repoRoot)) {
128
+ throw new Error(
129
+ `graph-store: repoRoot must be absolute when provided; received ${JSON.stringify(repoRoot)}`
130
+ );
131
+ }
132
+ await mkdir(path.dirname(dbPath), { recursive: true });
133
+ const db = openBetterSqlite3(dbPath);
134
+ db.pragma("journal_mode = WAL");
135
+ db.pragma("busy_timeout = 5000");
136
+ db.pragma("synchronous = NORMAL");
137
+ db.pragma("foreign_keys = ON");
138
+ applyCodingGraphSchema(db);
139
+ return new _GraphStore(db, repoRoot);
140
+ }
141
+ /**
142
+ * The current schema_version row. Test seam — never expires, never
143
+ * cached so migrations land without a restart.
144
+ */
145
+ schemaVersion() {
146
+ return readSchemaVersion(this.db);
147
+ }
148
+ /**
149
+ * Ingest a batch of IR files atomically. One transaction wraps every
150
+ * file's delete + insert; if any file throws, the whole batch rolls
151
+ * back (rule 34 — never partial-write a coding graph).
152
+ *
153
+ * Re-ingesting the same IR is a no-op once the rows are written
154
+ * (idempotency — node ids are deterministic so the second pass collides
155
+ * on PRIMARY KEY).
156
+ *
157
+ * Two-pass ordering: pass 1 upserts every file's nodes (so FTS stays
158
+ * in sync and cross-file edge targets exist by the time pass 2 runs),
159
+ * pass 2 resolves edges against the full batch's node map and deletes
160
+ * prior edges owned by these files so changed confidence/provenance
161
+ * values overwrite (chatgpt-codex-connector P1 + cursor medium + PR1
162
+ * design anchor in graph-schema).
163
+ *
164
+ * Tagging:
165
+ * - `{ok:true, results}` — every file's counts.
166
+ * - `{ok:false, code:"db_locked"}` — busy_timeout elapsed; caller may
167
+ * retry. NOT a thrown error so the agent can degrade gracefully.
168
+ * - `{ok:false, code:"db_corrupt"}` — SQLite reported
169
+ * `database disk image is malformed`; the caller must surface and
170
+ * stop trusting this DB.
171
+ */
172
+ async upsertFileBatch(files, deletePaths = []) {
173
+ if (this.closed || this.closing) {
174
+ return {
175
+ ok: false,
176
+ code: "store_closed"
177
+ };
178
+ }
179
+ return this.queue.schedule(() => this.runUpsert(files, deletePaths));
180
+ }
181
+ /**
182
+ * Upsert standalone edges whose endpoints are resolved from the FULL
183
+ * database (not just a per-file batch). Used by the codegraph
184
+ * ingest_traces surface (issue #1554) to persist runtime HTTP_CALLS
185
+ * observations as edges with `provenance: "trace"` — upgrading
186
+ * confidence on existing edges and inserting new ones.
187
+ *
188
+ * Endpoint resolution: when an edge carries `srcNodeId` / `dstNodeId`
189
+ * (issue #1677 — the SIMILAR_TO pipeline populates them from
190
+ * content-derived node ids), the endpoint is resolved by `nodes.id`
191
+ * (unique primary key), so an edge between two symbols that share a
192
+ * qualified name across files is persisted rather than dropped as
193
+ * ambiguous. Edges WITHOUT node ids fall back to qualified_name
194
+ * resolution via the global `resolveNodeId` (unambiguous single-match
195
+ * policy). Edges whose endpoints do not resolve (missing node id row OR
196
+ * an ambiguous/dangling qualified name) are skipped (and counted in
197
+ * `skipped`) rather than attached to the wrong node — the dangling-edge
198
+ * policy from `upsertFileBatch` applies.
199
+ *
200
+ * Serialized on the store's write queue like `upsertFileBatch` so a
201
+ * concurrent file-batch upsert and a trace upsert cannot interleave
202
+ * (rule 40).
203
+ */
204
+ async upsertEdges(edges) {
205
+ if (this.closed || this.closing) {
206
+ return { ok: false, code: "store_closed" };
207
+ }
208
+ return this.queue.schedule(() => this.runUpsertEdges(edges));
209
+ }
210
+ /** Wait for pending writes to drain — test seam. */
211
+ async drain() {
212
+ await this.queue.drain();
213
+ }
214
+ // ──────────────────────────────────────────────────────────────────────
215
+ // PR3 (issue #1553): meta-table + file-management methods for the
216
+ // incremental reindex pipeline.
217
+ // ──────────────────────────────────────────────────────────────────────
218
+ /**
219
+ * Read a value from the `meta` table. Returns `null` when the key is
220
+ * absent. Synchronous (like the other read primitives) so the reindex
221
+ * planner can read `last_indexed_head` without an await.
222
+ */
223
+ readMeta(key) {
224
+ if (this.closed) return { ok: false, code: "store_closed" };
225
+ try {
226
+ const row = expectRow(
227
+ this.db.prepare("SELECT value FROM meta WHERE key = ?").get(key),
228
+ ["value"]
229
+ );
230
+ return { ok: true, value: row ? row.value : null };
231
+ } catch (error) {
232
+ logWriteFailure(error);
233
+ return classifyReadError(error);
234
+ }
235
+ }
236
+ /**
237
+ * Write a key/value pair to the `meta` table. Synchronous — runs in its
238
+ * own implicit transaction. The reindex executor calls this AFTER
239
+ * `upsertFileBatch` resolves (rule 25: head/state updates only after
240
+ * the data transaction commits). A crash between the two leaves the old
241
+ * head, and the next run re-ingests idempotently (deterministic node ids).
242
+ */
243
+ writeMeta(key, value) {
244
+ if (this.closed) return;
245
+ this.db.prepare("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)").run(key, value);
246
+ }
247
+ /**
248
+ * Read every file row's path → content_hash. Used by hash_scan mode
249
+ * to detect content drift without a reachable base commit (issue #1553).
250
+ */
251
+ readFileHashes() {
252
+ if (this.closed) return { ok: false, code: "store_closed" };
253
+ try {
254
+ const rows = expectRows(
255
+ this.db.prepare("SELECT path, content_hash FROM files").all(),
256
+ ["path", "content_hash"]
257
+ );
258
+ const out = /* @__PURE__ */ new Map();
259
+ for (const r of rows) out.set(r.path, r.content_hash);
260
+ return { ok: true, hashes: out };
261
+ } catch (error) {
262
+ logWriteFailure(error);
263
+ return classifyReadError(error);
264
+ }
265
+ }
266
+ /**
267
+ * Drop file rows by path, cascading to their nodes + edges +
268
+ * node_attributes (the schema's `ON DELETE CASCADE` from `files(id)`
269
+ * handles the cascade — `foreign_keys = ON` is set in `open()`).
270
+ * Used by the reindex executor to prune deleted files.
271
+ *
272
+ * Paths are chunked under the SQLite variable limit (rule 23 pattern).
273
+ */
274
+ async dropFiles(paths) {
275
+ if (this.closed || this.closing || paths.length === 0) return;
276
+ await this.queue.schedule(async () => {
277
+ this.runChunkedDelete(
278
+ "DELETE FROM files WHERE path IN (%PH%)",
279
+ paths
280
+ );
281
+ });
282
+ }
283
+ /**
284
+ * Chunk a parameterized DELETE-with-IN-list under SQLite's variable
285
+ * bind limit. Mirrors the chunking pattern used by `runChunkedUpdate`
286
+ * and the stale-edge deletes.
287
+ */
288
+ runChunkedDelete(sqlTemplate, params) {
289
+ if (params.length === 0) return;
290
+ for (let i = 0; i < params.length; i += SQLITE_VARIABLE_LIMIT) {
291
+ const chunk = params.slice(i, i + SQLITE_VARIABLE_LIMIT);
292
+ const placeholders = chunk.map(() => "?").join(", ");
293
+ this.db.prepare(sqlTemplate.replace("%PH%", placeholders)).run(...chunk);
294
+ }
295
+ }
296
+ /**
297
+ * PR3 (issue #1553): upsert co-change edges into the `co_changes`
298
+ * table. Clears existing edges then inserts the new set in one
299
+ * transaction (idempotent — re-running on unchanged history produces
300
+ * the same table). Serialized through the write queue.
301
+ */
302
+ /**
303
+ * PR3 (issue #1553): upsert co-change edges into the `co_changes`
304
+ * table. Clears existing edges then inserts the new set in one
305
+ * transaction (idempotent — re-running on unchanged history produces
306
+ * the same table). Serialized through the write queue.
307
+ *
308
+ * Returns `{ ok: false, code: "store_closed" }` when the store is
309
+ * closed/closing so the caller does NOT believe mining succeeded
310
+ * while nothing was persisted (cursor Bugbot: 'Co-change store
311
+ * reports false success').
312
+ */
313
+ async upsertCoChanges(edges) {
314
+ if (this.closed || this.closing) {
315
+ return { ok: false, code: "store_closed" };
316
+ }
317
+ try {
318
+ await this.queue.schedule(async () => {
319
+ const tx = this.db.transaction(() => {
320
+ this.db.exec("DELETE FROM co_changes");
321
+ const insert = this.db.prepare(
322
+ `INSERT INTO co_changes (file_a, file_b, support, confidence)
323
+ VALUES (?, ?, ?, ?)
324
+ ON CONFLICT(file_a, file_b) DO UPDATE SET
325
+ support = excluded.support,
326
+ confidence = excluded.confidence`
327
+ );
328
+ for (const e of edges) {
329
+ insert.run(e.fileA, e.fileB, e.support, e.confidence);
330
+ }
331
+ });
332
+ tx();
333
+ });
334
+ return { ok: true };
335
+ } catch (error) {
336
+ logWriteFailure(error);
337
+ return { ok: false, code: "db_error" };
338
+ }
339
+ }
340
+ /**
341
+ * PR3 (issue #1553): read co-change edges for a file. Returns edges
342
+ * where the file is either `file_a` or `file_b`. Synchronous read.
343
+ */
344
+ readCoChanges(filePath) {
345
+ if (this.closed) return { ok: false, code: "store_closed" };
346
+ try {
347
+ const rows = expectRows(
348
+ this.db.prepare(
349
+ `SELECT file_a, file_b, support, confidence
350
+ FROM co_changes
351
+ WHERE file_a = ? OR file_b = ?
352
+ ORDER BY confidence DESC, file_a ASC, file_b ASC`
353
+ ).all(filePath, filePath),
354
+ ["file_a", "file_b", "support", "confidence"]
355
+ );
356
+ return {
357
+ ok: true,
358
+ edges: rows.map((r) => ({
359
+ fileA: r.file_a,
360
+ fileB: r.file_b,
361
+ support: r.support,
362
+ confidence: r.confidence
363
+ }))
364
+ };
365
+ } catch (error) {
366
+ logWriteFailure(error);
367
+ return classifyReadError(error);
368
+ }
369
+ }
370
+ /**
371
+ * Close the SQLite handle after draining the write queue. A batch
372
+ * that has already been scheduled on the queue would otherwise run
373
+ * against a closed DB and surface as `db_corrupt` — the caller
374
+ * would stop trusting the store for unrelated reasons. Drain first,
375
+ * then close (cursor Bugbot #09be5784).
376
+ */
377
+ async close() {
378
+ if (this.closed) return;
379
+ if (this.closing) return this.closePromise;
380
+ this.closing = true;
381
+ this.closePromise = this.finishClose();
382
+ return this.closePromise;
383
+ }
384
+ /** Drain queued writes then close the SQLite handle exactly once. */
385
+ async finishClose() {
386
+ await this.queue.drain();
387
+ this.closed = true;
388
+ this.db.close();
389
+ }
390
+ // ────────────── private ──────────────
391
+ async runUpsert(files, deletePaths = []) {
392
+ const seenPaths = /* @__PURE__ */ new Set();
393
+ for (const ir of files) {
394
+ assertCanonicalFilePath(ir.path);
395
+ if (seenPaths.has(ir.path)) {
396
+ throw new Error(
397
+ `graph-store: duplicate path '${ir.path}' in batch \u2014 each FileIR must have a unique path`
398
+ );
399
+ }
400
+ seenPaths.add(ir.path);
401
+ const symbolsField = ir.symbols;
402
+ if (!Array.isArray(symbolsField)) {
403
+ throw new Error(
404
+ `graph-store: file '${ir.path}' symbols must be an array (FileIR contract requires it); received ${symbolsField === null ? "null" : typeof symbolsField} \u2014 refusing to ingest to avoid wiping existing nodes`
405
+ );
406
+ }
407
+ for (const sym of symbolsField) {
408
+ assertValidSymbolSpan(sym, ir.path);
409
+ }
410
+ if (ir.exports != null) {
411
+ if (!Array.isArray(ir.exports)) {
412
+ throw new Error(
413
+ `graph-store: file '${ir.path}' exports must be an array when present; received ${ir.exports === null ? "null" : typeof ir.exports} \u2014 refusing to ingest to avoid wiping existing flags`
414
+ );
415
+ }
416
+ for (const ex of ir.exports) {
417
+ if (!ex || typeof ex.name !== "string" || ex.name.length === 0) {
418
+ throw new Error(
419
+ `graph-store: file '${ir.path}' has a malformed export entry \u2014 expected { name: string (non-empty) }; refusing to ingest to avoid wiping existing flags`
420
+ );
421
+ }
422
+ }
423
+ }
424
+ if (ir.routes != null) {
425
+ if (!Array.isArray(ir.routes)) {
426
+ throw new Error(
427
+ `graph-store: file '${ir.path}' routes must be an array when present; received ${ir.routes === null ? "null" : typeof ir.routes} \u2014 refusing to ingest to avoid wiping existing flags`
428
+ );
429
+ }
430
+ for (const r of ir.routes) {
431
+ if (!r || typeof r.handlerQualifiedName !== "string" || r.handlerQualifiedName.length === 0) {
432
+ throw new Error(
433
+ `graph-store: file '${ir.path}' has a malformed route entry \u2014 expected { handlerQualifiedName: string (non-empty) }; refusing to ingest to avoid wiping existing flags`
434
+ );
435
+ }
436
+ }
437
+ }
438
+ }
439
+ try {
440
+ const results = [];
441
+ const tx = this.db.transaction((irs) => {
442
+ if (deletePaths.length > 0) {
443
+ for (let i = 0; i < deletePaths.length; i += SQLITE_VARIABLE_LIMIT) {
444
+ const chunk = deletePaths.slice(i, i + SQLITE_VARIABLE_LIMIT);
445
+ const placeholders = chunk.map(() => "?").join(", ");
446
+ this.db.prepare("DELETE FROM files WHERE path IN (%PH%)".replace("%PH%", placeholders)).run(...chunk);
447
+ }
448
+ }
449
+ const pending = [];
450
+ for (const ir of irs) {
451
+ const { result, prunedNodeIds } = this.upsertFileNodes(ir);
452
+ pending.push({ result, prunedNodeIds });
453
+ results.push(result);
454
+ }
455
+ const batchPrunedIds = [];
456
+ for (const { prunedNodeIds } of pending) {
457
+ for (const id of prunedNodeIds) batchPrunedIds.push(id);
458
+ }
459
+ for (const { result, prunedNodeIds } of pending) {
460
+ this.pruneFileNodes(result, prunedNodeIds, batchPrunedIds);
461
+ }
462
+ for (let i = 0; i < irs.length; i += 1) {
463
+ const ir = irs[i];
464
+ const result = results[i];
465
+ this.upsertFileEdges(ir, result);
466
+ }
467
+ for (let i = 0; i < irs.length; i += 1) {
468
+ const ir = irs[i];
469
+ const result = results[i];
470
+ this.upsertFileAttributes(ir, result);
471
+ }
472
+ });
473
+ tx(files);
474
+ return { ok: true, results };
475
+ } catch (error) {
476
+ logWriteFailure(error);
477
+ return classifyError(error);
478
+ }
479
+ }
480
+ /**
481
+ * Standalone-edge upsert body (runs under the write queue). Resolves
482
+ * both endpoints from the full DB via the unambiguous single-match
483
+ * `resolveNodeId` fallback, then upserts each edge with the same
484
+ * ON CONFLICT(src,dst,type) policy as the file-batch path. Edges whose
485
+ * src or dst do not resolve to exactly one node are skipped (counted
486
+ * in `skipped`) per the dangling-edge policy.
487
+ */
488
+ async runUpsertEdges(edges) {
489
+ const emptyBatch = /* @__PURE__ */ new Map();
490
+ const insertEdge = this.db.prepare(
491
+ `INSERT INTO edges (src, dst, type, confidence, provenance)
492
+ VALUES (?, ?, ?, ?, ?)
493
+ ON CONFLICT(src, dst, type) DO UPDATE SET
494
+ confidence = excluded.confidence,
495
+ provenance = excluded.provenance`
496
+ );
497
+ const nodeById = this.db.prepare(
498
+ "SELECT qualified_name FROM nodes WHERE id = ? LIMIT 1"
499
+ );
500
+ try {
501
+ let persisted = 0;
502
+ let skipped = 0;
503
+ this.db.transaction(() => {
504
+ for (const edge of edges) {
505
+ if (!isEdgeProvenance(edge.provenance)) {
506
+ throw new Error(
507
+ `graph-store: edge has invalid provenance ${JSON.stringify(edge.provenance)}`
508
+ );
509
+ }
510
+ if (!Number.isFinite(edge.confidence) || edge.confidence < 0 || edge.confidence > 1) {
511
+ throw new Error(
512
+ `graph-store: edge confidence ${edge.confidence} is out of range [0, 1] for edge ${edge.srcQualifiedName} \u2192 ${edge.dstQualifiedName}`
513
+ );
514
+ }
515
+ const srcId = edge.srcNodeId ? resolveByNodeId(nodeById, edge.srcNodeId, edge.srcQualifiedName) : resolveNodeId(edge.srcQualifiedName, emptyBatch, this.db);
516
+ const dstId = edge.dstNodeId ? resolveByNodeId(nodeById, edge.dstNodeId, edge.dstQualifiedName) : resolveNodeId(edge.dstQualifiedName, emptyBatch, this.db);
517
+ if (!srcId || !dstId) {
518
+ skipped += 1;
519
+ continue;
520
+ }
521
+ const r = insertEdge.run(srcId, dstId, edge.type, edge.confidence, edge.provenance);
522
+ persisted += r.changes;
523
+ }
524
+ })();
525
+ return { ok: true, persisted, skipped };
526
+ } catch (error) {
527
+ logWriteFailure(error);
528
+ return classifyError(error);
529
+ }
530
+ }
531
+ /**
532
+ * Pass 1a: upsert the file row and every symbol node, refreshing the
533
+ * contentless `nodes_fts` index in lockstep, and compute the set of
534
+ * stale node ids this file wants to prune (deterministic id, NOT
535
+ * qualified_name, so a kind change gets a new id and the OLD row is
536
+ * deleted). The prune itself — and the dangling-edge count that
537
+ * gates it — is deferred to {@link pruneFileNodes} so the whole batch
538
+ * shares one batch-wide view of what is being pruned before any
539
+ * cascade runs.
540
+ */
541
+ upsertFileNodes(ir) {
542
+ const upsertFile = this.db.prepare(
543
+ `INSERT INTO files (path, lang, content_hash)
544
+ VALUES (?, ?, ?)
545
+ ON CONFLICT(path) DO UPDATE SET
546
+ lang = excluded.lang,
547
+ content_hash = excluded.content_hash
548
+ RETURNING id`
549
+ );
550
+ const fileRow = expectRow(
551
+ upsertFile.get(ir.path, ir.language, ir.contentHash),
552
+ ["id"]
553
+ );
554
+ if (!fileRow) {
555
+ throw new Error(
556
+ `graph-store: INSERT INTO files RETURNING id returned no row for path=${ir.path}`
557
+ );
558
+ }
559
+ const fileId = fileRow.id;
560
+ const seenNodeIds = /* @__PURE__ */ new Set();
561
+ const symbolByNodeId = /* @__PURE__ */ new Map();
562
+ for (const sym of ir.symbols) {
563
+ const id = nodeIdFor({
564
+ qualifiedName: sym.qualifiedName,
565
+ filePath: ir.path,
566
+ label: sym.kind
567
+ });
568
+ seenNodeIds.add(id);
569
+ symbolByNodeId.set(id, sym);
570
+ }
571
+ const existingNodes = expectRows(
572
+ this.db.prepare(
573
+ `SELECT id, label, name, qualified_name, file_id,
574
+ span_start, span_end, lang
575
+ FROM nodes WHERE file_id = ?`
576
+ ).all(fileId),
577
+ ["id", "label", "name", "qualified_name", "file_id", "span_start", "span_end", "lang"]
578
+ );
579
+ const existingById = new Map(existingNodes.map((n) => [n.id, n]));
580
+ const insertNode = this.db.prepare(
581
+ `INSERT INTO nodes (
582
+ id, label, name, qualified_name,
583
+ file_id, span_start, span_end, lang
584
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
585
+ ON CONFLICT(id) DO UPDATE SET
586
+ label = excluded.label,
587
+ name = excluded.name,
588
+ qualified_name = excluded.qualified_name,
589
+ file_id = excluded.file_id,
590
+ span_start = excluded.span_start,
591
+ span_end = excluded.span_end,
592
+ lang = excluded.lang`
593
+ );
594
+ const insertFts = this.db.prepare(
595
+ `INSERT INTO nodes_fts (rowid, name, qualified_name) VALUES (?, ?, ?)`
596
+ );
597
+ const deleteFtsByRowid = this.db.prepare(
598
+ `DELETE FROM nodes_fts WHERE rowid = ?`
599
+ );
600
+ const upsertFtsIndex = this.db.prepare(
601
+ `INSERT INTO fts_index (fts_rowid, node_id) VALUES (?, ?)
602
+ ON CONFLICT(node_id) DO UPDATE SET
603
+ fts_rowid = excluded.fts_rowid`
604
+ );
605
+ const deleteFtsIndexByRowid = this.db.prepare(
606
+ `DELETE FROM fts_index WHERE fts_rowid = ?`
607
+ );
608
+ let nodeCount = 0;
609
+ for (const [id, sym] of symbolByNodeId) {
610
+ const prior = existingById.get(id);
611
+ if (prior && prior.label === sym.kind && prior.name === sym.name && prior.qualified_name === sym.qualifiedName && prior.span_start === sym.span.startByte && prior.span_end === sym.span.endByte && prior.lang === ir.language) {
612
+ continue;
613
+ }
614
+ const ftsRowid = ftsRowidForNodeId(id);
615
+ deleteFtsByRowid.run(ftsRowid);
616
+ deleteFtsIndexByRowid.run(ftsRowid);
617
+ insertNode.run(
618
+ id,
619
+ sym.kind,
620
+ sym.name,
621
+ sym.qualifiedName,
622
+ fileId,
623
+ sym.span.startByte,
624
+ sym.span.endByte,
625
+ ir.language
626
+ );
627
+ insertFts.run(ftsRowid, sym.name, sym.qualifiedName);
628
+ upsertFtsIndex.run(ftsRowid, id);
629
+ nodeCount += 1;
630
+ }
631
+ const prunedNodeIds = existingNodes.map((n) => n.id).filter((id) => !seenNodeIds.has(id));
632
+ return {
633
+ result: {
634
+ path: ir.path,
635
+ fileId,
636
+ nodeCount,
637
+ edgeCount: 0,
638
+ droppedDanglingEdges: 0
639
+ },
640
+ prunedNodeIds
641
+ };
642
+ }
643
+ /**
644
+ * Pass 1b: count the dangling edges this file's prune will drop and
645
+ * perform the cascade delete + FTS cleanup. A dangling edge is one
646
+ * whose dst is pruned by THIS file but whose src survives — and
647
+ * "survives" is judged against the BATCH-WIDE pruned set, so an edge
648
+ * whose both ends are pruned (possibly in different files) is
649
+ * cascade-deleted and never reported as dangling. This makes the
650
+ * reported loss independent of the order files are visited in
651
+ * (chatgpt-codex-connector P2: 'Count dangling edges against the
652
+ * whole batch').
653
+ */
654
+ pruneFileNodes(result, prunedNodeIds, batchPrunedIds) {
655
+ if (prunedNodeIds.length === 0) {
656
+ result.droppedDanglingEdges = 0;
657
+ return;
658
+ }
659
+ this.db.exec(
660
+ "CREATE TEMP TABLE IF NOT EXISTS _pruned_ids (id TEXT NOT NULL PRIMARY KEY)"
661
+ );
662
+ this.db.exec(
663
+ "CREATE TEMP TABLE IF NOT EXISTS _batch_pruned_ids (id TEXT NOT NULL PRIMARY KEY)"
664
+ );
665
+ const clearPruned = this.db.prepare("DELETE FROM _pruned_ids");
666
+ const clearBatch = this.db.prepare("DELETE FROM _batch_pruned_ids");
667
+ const insertPruned = this.db.prepare(
668
+ "INSERT OR IGNORE INTO _pruned_ids (id) VALUES (?)"
669
+ );
670
+ const insertBatch = this.db.prepare(
671
+ "INSERT OR IGNORE INTO _batch_pruned_ids (id) VALUES (?)"
672
+ );
673
+ clearPruned.run();
674
+ clearBatch.run();
675
+ const fillTemp = this.db.transaction(
676
+ (rows) => {
677
+ for (const { table, ids } of rows) {
678
+ const stmt = table === "_pruned_ids" ? insertPruned : insertBatch;
679
+ for (const id of ids) stmt.run(id);
680
+ }
681
+ }
682
+ );
683
+ fillTemp([
684
+ { table: "_pruned_ids", ids: prunedNodeIds },
685
+ { table: "_batch_pruned_ids", ids: batchPrunedIds }
686
+ ]);
687
+ const dangling = expectRow(
688
+ this.db.prepare(
689
+ `SELECT COUNT(*) AS c FROM edges
690
+ WHERE dst IN (SELECT id FROM _pruned_ids)
691
+ AND src NOT IN (SELECT id FROM _batch_pruned_ids)`
692
+ ).get(),
693
+ ["c"]
694
+ );
695
+ result.droppedDanglingEdges = dangling?.c ?? 0;
696
+ this.db.exec("DELETE FROM nodes WHERE id IN (SELECT id FROM _pruned_ids)");
697
+ clearPruned.run();
698
+ clearBatch.run();
699
+ const SQLITE_VAR_LIMIT = 32766;
700
+ const ftsRowids = prunedNodeIds.map(ftsRowidForNodeId);
701
+ for (let i = 0; i < ftsRowids.length; i += SQLITE_VAR_LIMIT) {
702
+ const chunk = ftsRowids.slice(i, i + SQLITE_VAR_LIMIT);
703
+ const ph = chunk.map(() => "?").join(", ");
704
+ this.db.prepare(`DELETE FROM nodes_fts WHERE rowid IN (${ph})`).run(...chunk);
705
+ this.db.prepare(`DELETE FROM fts_index WHERE fts_rowid IN (${ph})`).run(...chunk);
706
+ }
707
+ }
708
+ /**
709
+ * Pass 2: re-insert edges for one file. Runs AFTER every file's
710
+ * nodes are in place (the full batch is committed to nodes) so
711
+ * cross-file edges resolve regardless of input order. Stale edges
712
+ * for nodes owned by this file are deleted first so a changed
713
+ * `confidence` or `provenance` actually overwrites the prior row
714
+ * (chatgpt-codex-connector P1: ON CONFLICT DO NOTHING silently
715
+ * kept stale edges across re-ingests).
716
+ */
717
+ upsertFileEdges(ir, result) {
718
+ if (ir.edges == null) {
719
+ return;
720
+ }
721
+ const qualifiedNameToId = /* @__PURE__ */ new Map();
722
+ const ownSymbols = expectRows(
723
+ this.db.prepare("SELECT id, qualified_name FROM nodes WHERE file_id = ?").all(result.fileId),
724
+ ["id", "qualified_name"]
725
+ );
726
+ const qnameCounts = /* @__PURE__ */ new Map();
727
+ for (const row of ownSymbols) {
728
+ qnameCounts.set(row.qualified_name, (qnameCounts.get(row.qualified_name) ?? 0) + 1);
729
+ }
730
+ for (const row of ownSymbols) {
731
+ if ((qnameCounts.get(row.qualified_name) ?? 0) === 1) {
732
+ qualifiedNameToId.set(row.qualified_name, row.id);
733
+ }
734
+ }
735
+ const assertedKeys = /* @__PURE__ */ new Set();
736
+ const seenKeys = [];
737
+ const keyToEdge = /* @__PURE__ */ new Map();
738
+ for (const edge of ir.edges ?? []) {
739
+ if (!isEdgeProvenance(edge.provenance)) {
740
+ throw new Error(
741
+ `graph-store: edge has invalid provenance ${JSON.stringify(edge.provenance)}`
742
+ );
743
+ }
744
+ if (!Number.isFinite(edge.confidence) || edge.confidence < 0 || edge.confidence > 1) {
745
+ throw new Error(
746
+ `graph-store: edge confidence ${edge.confidence} is out of range [0, 1] for edge ${edge.srcQualifiedName} \u2192 ${edge.dstQualifiedName}`
747
+ );
748
+ }
749
+ const srcId = qualifiedNameToId.get(edge.srcQualifiedName);
750
+ if (!srcId) continue;
751
+ const dstId = resolveNodeId(
752
+ edge.dstQualifiedName,
753
+ qualifiedNameToId,
754
+ this.db
755
+ );
756
+ if (!dstId) continue;
757
+ const key = `${srcId}\0${dstId}\0${edge.type}`;
758
+ assertedKeys.add(key);
759
+ seenKeys.push(key);
760
+ if (!keyToEdge.has(key)) {
761
+ keyToEdge.set(key, edge);
762
+ }
763
+ }
764
+ const priorEdges = expectRows(
765
+ this.db.prepare(
766
+ "SELECT src, dst, type, confidence, provenance FROM edges WHERE src IN (SELECT id FROM nodes WHERE file_id = ?)"
767
+ ).all(result.fileId),
768
+ ["src", "dst", "type", "confidence", "provenance"]
769
+ );
770
+ const priorByKey = /* @__PURE__ */ new Map();
771
+ const staleSrcDstTypes = [];
772
+ for (const p of priorEdges) {
773
+ const key = `${p.src}\0${p.dst}\0${p.type}`;
774
+ priorByKey.set(key, { confidence: p.confidence, provenance: p.provenance });
775
+ if (!assertedKeys.has(key)) {
776
+ staleSrcDstTypes.push({ src: p.src, dst: p.dst, type: p.type });
777
+ }
778
+ }
779
+ const SQLITE_VARIABLE_LIMIT2 = 32766;
780
+ const PARAMS_PER_TUPLE = 3;
781
+ const MAX_TUPLES_PER_CHUNK = Math.floor(SQLITE_VARIABLE_LIMIT2 / PARAMS_PER_TUPLE);
782
+ for (let i = 0; i < staleSrcDstTypes.length; i += MAX_TUPLES_PER_CHUNK) {
783
+ const chunk = staleSrcDstTypes.slice(i, i + MAX_TUPLES_PER_CHUNK);
784
+ const placeholders = chunk.map(() => "(?, ?, ?)").join(", ");
785
+ this.db.prepare(`DELETE FROM edges WHERE (src, dst, type) IN (${placeholders})`).run(...chunk.flatMap((e) => [e.src, e.dst, e.type]));
786
+ }
787
+ const insertEdge = this.db.prepare(
788
+ `INSERT INTO edges (src, dst, type, confidence, provenance)
789
+ VALUES (?, ?, ?, ?, ?)
790
+ ON CONFLICT(src, dst, type) DO UPDATE SET
791
+ confidence = excluded.confidence,
792
+ provenance = excluded.provenance`
793
+ );
794
+ let edgeCount = 0;
795
+ const processedKeys = /* @__PURE__ */ new Set();
796
+ for (const key of seenKeys) {
797
+ if (processedKeys.has(key)) continue;
798
+ processedKeys.add(key);
799
+ const edge = keyToEdge.get(key);
800
+ if (!edge) continue;
801
+ const parts = key.split("\0");
802
+ const srcId = parts[0];
803
+ const dstId = parts[1];
804
+ const prior = priorByKey.get(key);
805
+ if (prior && prior.confidence === edge.confidence && prior.provenance === edge.provenance) {
806
+ continue;
807
+ }
808
+ const r = insertEdge.run(srcId, dstId, edge.type, edge.confidence, edge.provenance);
809
+ edgeCount += r.changes;
810
+ }
811
+ result.edgeCount = edgeCount;
812
+ }
813
+ /**
814
+ * Pass 3 (PR2): upsert `node_attributes` rows for this file's
815
+ * surviving nodes, derived from the IR's optional `exports` and
816
+ * `routes` arrays. Per-field preservation semantics (mirrors the
817
+ * edges pass, generalized to two independent flags):
818
+ * - `exports == null` (omitted) → preserve existing `is_exported`
819
+ * flags untouched (PR1-era IR has no exports field). The
820
+ * `is_route_handler` flag is rebuilt independently from
821
+ * `routes` — the two columns do NOT interact.
822
+ * - `exports === []` (explicit empty) → wipe the file's
823
+ * `is_exported` flags (the caller is asserting "this file
824
+ * exports nothing").
825
+ * - same rule for `routes` / `is_route_handler`.
826
+ *
827
+ * A symbol is `is_exported=1` when its `name` matches an entry in
828
+ * `ir.exports` (multiple symbols with the same name in one file all
829
+ * get the flag — the dead-code query treats this conservatively,
830
+ * never silently picking one). A symbol is `is_route_handler=1`
831
+ * when its `qualifiedName` equals a route's `handlerQualifiedName`.
832
+ *
833
+ * Implementation: per-flag UPDATE, not a delete-then-insert (the
834
+ * original PR2 implementation wiped both flags whenever either field
835
+ * was present, so a re-ingest with only `exports` silently dropped
836
+ * `is_route_handler` — cursor Bugbot + chatgpt-codex-connector P2).
837
+ * The two flags live in the same row keyed by node_id; INSERT OR
838
+ * IGNORE ensures a row exists, then UPDATE-per-flag changes only
839
+ * the column the IR is asserting.
840
+ */
841
+ upsertFileAttributes(ir, result) {
842
+ if (ir.exports == null && ir.routes == null) {
843
+ return;
844
+ }
845
+ const ownNodes = expectRows(
846
+ this.db.prepare("SELECT id, name, qualified_name FROM nodes WHERE file_id = ?").all(result.fileId),
847
+ ["id", "name", "qualified_name"]
848
+ );
849
+ const ownNodeIds = ownNodes.map((n) => n.id);
850
+ if (ownNodeIds.length === 0) {
851
+ return;
852
+ }
853
+ const ensureRow = this.db.prepare(
854
+ `INSERT OR IGNORE INTO node_attributes (node_id, is_exported, is_route_handler)
855
+ VALUES (?, 0, 0)`
856
+ );
857
+ for (const id of ownNodeIds) ensureRow.run(id);
858
+ if (ir.exports != null) {
859
+ const exportNames = /* @__PURE__ */ new Set();
860
+ for (const ex of ir.exports) {
861
+ if (ex && typeof ex.name === "string" && ex.name.length > 0) {
862
+ exportNames.add(ex.name);
863
+ }
864
+ }
865
+ const newExportedIds = /* @__PURE__ */ new Set();
866
+ for (const n of ownNodes) {
867
+ if (exportNames.has(n.name)) newExportedIds.add(n.id);
868
+ }
869
+ this.runChunkedUpdate(
870
+ `UPDATE node_attributes SET is_exported = 0 WHERE node_id IN (%PH%)`,
871
+ ownNodeIds
872
+ );
873
+ const setExported = this.db.prepare(
874
+ `UPDATE node_attributes SET is_exported = 1 WHERE node_id = ?`
875
+ );
876
+ for (const id of newExportedIds) setExported.run(id);
877
+ }
878
+ if (ir.routes != null) {
879
+ const handlerQNames = /* @__PURE__ */ new Set();
880
+ for (const r of ir.routes) {
881
+ if (r && typeof r.handlerQualifiedName === "string" && r.handlerQualifiedName.length > 0) {
882
+ handlerQNames.add(r.handlerQualifiedName);
883
+ }
884
+ }
885
+ const newRouteIds = /* @__PURE__ */ new Set();
886
+ for (const n of ownNodes) {
887
+ if (handlerQNames.has(n.qualified_name)) newRouteIds.add(n.id);
888
+ }
889
+ this.runChunkedUpdate(
890
+ `UPDATE node_attributes SET is_route_handler = 0 WHERE node_id IN (%PH%)`,
891
+ ownNodeIds
892
+ );
893
+ const setRoute = this.db.prepare(
894
+ `UPDATE node_attributes SET is_route_handler = 1 WHERE node_id = ?`
895
+ );
896
+ for (const id of newRouteIds) setRoute.run(id);
897
+ }
898
+ }
899
+ /**
900
+ * Chunk a parameterized UPDATE-with-IN-list under SQLite's variable
901
+ * bind limit. The SQL template uses `%PH%` as a placeholder for the
902
+ * `?,?,…` list. Mirrors the chunking pattern PR1 uses for deletes.
903
+ */
904
+ runChunkedUpdate(sqlTemplate, params) {
905
+ if (params.length === 0) return;
906
+ for (let i = 0; i < params.length; i += SQLITE_VARIABLE_LIMIT) {
907
+ const chunk = params.slice(i, i + SQLITE_VARIABLE_LIMIT);
908
+ const placeholders = chunk.map(() => "?").join(", ");
909
+ this.db.prepare(sqlTemplate.replace("%PH%", placeholders)).run(...chunk);
910
+ }
911
+ }
912
+ // ──────────────────────────────────────────────────────────────────────
913
+ // PR2 read primitives (issue #1552 steps 4–5).
914
+ // ──────────────────────────────────────────────────────────────────────
915
+ /**
916
+ * Iterative frontier BFS over the edges table. Cycle-safe via a JS
917
+ * visited set keyed by node id; depth-capped by {@link TraverseQuery.maxDepth}
918
+ * (half-open — depth==maxDepth is INCLUDED, maxDepth+1 is NOT — rule 35).
919
+ * The start node is always included at depth 0 when it exists.
920
+ *
921
+ * Reads the edges table via a single prepared statement per
922
+ * direction; the frontier expands level-by-level so memory is
923
+ * bounded by the visited set's size, not the recursion depth.
924
+ */
925
+ traverse(query) {
926
+ if (this.closed) return { ok: false, code: "store_closed" };
927
+ if (query == null || typeof query !== "object") {
928
+ return { ok: false, code: "invalid_query" };
929
+ }
930
+ if (typeof query.maxDepth !== "number" || !Number.isInteger(query.maxDepth) || query.maxDepth < 0) {
931
+ return {
932
+ ok: false,
933
+ code: "invalid_query"
934
+ };
935
+ }
936
+ const direction = query.direction === void 0 ? "outgoing" : query.direction;
937
+ if (direction !== "outgoing" && direction !== "incoming" && direction !== "both") {
938
+ return { ok: false, code: "invalid_query" };
939
+ }
940
+ if (query.edgeTypes !== void 0 && (!Array.isArray(query.edgeTypes) || !query.edgeTypes.every((e) => typeof e === "string"))) {
941
+ return { ok: false, code: "invalid_query" };
942
+ }
943
+ if (typeof query.start !== "string" || query.start.length === 0) {
944
+ return { ok: false, code: "invalid_query" };
945
+ }
946
+ try {
947
+ let startId;
948
+ const isNodeId = /^[0-9a-f]{64}$/.test(query.start);
949
+ const rows = expectRows(
950
+ this.db.prepare(
951
+ isNodeId ? "SELECT id FROM nodes WHERE id = ?" : "SELECT id FROM nodes WHERE qualified_name = ?"
952
+ ).all(query.start),
953
+ ["id"]
954
+ );
955
+ if (rows.length === 0) {
956
+ return { ok: false, code: "unknown_start" };
957
+ }
958
+ if (rows.length > 1) {
959
+ return { ok: false, code: "ambiguous_start" };
960
+ }
961
+ startId = rows[0].id;
962
+ const edgeTypes = query.edgeTypes ?? [];
963
+ const typeClause = edgeTypes.length > 0 ? `AND type IN (${edgeTypes.map(() => "?").join(", ")})` : "";
964
+ const outgoingStmt = this.db.prepare(
965
+ `SELECT dst AS neighbor, src AS via_id FROM edges WHERE src = ? ${typeClause}`
966
+ );
967
+ const incomingStmt = this.db.prepare(
968
+ `SELECT src AS neighbor, dst AS via_id FROM edges WHERE dst = ? ${typeClause}`
969
+ );
970
+ const visited = /* @__PURE__ */ new Set([startId]);
971
+ const hits = [];
972
+ const startRow = expectRow(
973
+ this.db.prepare(
974
+ "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 = ?"
975
+ ).get(startId),
976
+ ["id", "qualified_name", "name", "label", "file_path"]
977
+ );
978
+ if (!startRow) {
979
+ return { ok: false, code: "unknown_start" };
980
+ }
981
+ hits.push({
982
+ nodeId: startRow.id,
983
+ qualifiedName: startRow.qualified_name,
984
+ name: startRow.name,
985
+ label: startRow.label,
986
+ filePath: startRow.file_path,
987
+ depth: 0
988
+ });
989
+ if (query.maxDepth === 0) {
990
+ return { ok: true, hits };
991
+ }
992
+ let frontier = [startId];
993
+ for (let depth = 1; depth <= query.maxDepth; depth += 1) {
994
+ const nextFrontier = [];
995
+ for (const nodeId of frontier) {
996
+ const params = [nodeId, ...edgeTypes];
997
+ const outRows = direction === "outgoing" || direction === "both" ? expectRows(
998
+ outgoingStmt.all(...params),
999
+ ["neighbor"]
1000
+ ) : [];
1001
+ const inRows = direction === "incoming" || direction === "both" ? expectRows(
1002
+ incomingStmt.all(...params),
1003
+ ["neighbor"]
1004
+ ) : [];
1005
+ for (const r of [...outRows, ...inRows]) {
1006
+ const neighbor = r.neighbor;
1007
+ if (visited.has(neighbor)) continue;
1008
+ visited.add(neighbor);
1009
+ nextFrontier.push(neighbor);
1010
+ const hitRow = expectRow(
1011
+ this.db.prepare(
1012
+ "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 = ?"
1013
+ ).get(neighbor),
1014
+ ["id", "qualified_name", "name", "label", "file_path"]
1015
+ );
1016
+ if (hitRow) {
1017
+ hits.push({
1018
+ nodeId: hitRow.id,
1019
+ qualifiedName: hitRow.qualified_name,
1020
+ name: hitRow.name,
1021
+ label: hitRow.label,
1022
+ filePath: hitRow.file_path,
1023
+ depth
1024
+ });
1025
+ }
1026
+ }
1027
+ }
1028
+ if (nextFrontier.length === 0) break;
1029
+ frontier = nextFrontier;
1030
+ }
1031
+ return { ok: true, hits };
1032
+ } catch (error) {
1033
+ logWriteFailure(error);
1034
+ return classifyReadError(error);
1035
+ }
1036
+ }
1037
+ /**
1038
+ * Path-enumerating traversal (issue #1650). Unlike {@link traverse}'s BFS —
1039
+ * which visits each node ONCE at its shortest-path depth and so cannot honor
1040
+ * an exact `*N` (N > 1) hop count for nodes reachable at both a shorter and a
1041
+ * length-N path — this primitive enumerates concrete relationship-simple
1042
+ * paths from the start, yielding one hit per distinct (path, endpoint) pair
1043
+ * up to {@link TraversePathsQuery.maxHops}.
1044
+ *
1045
+ * Cycle safety uses RELATIONSHIP UNIQUENESS (the real Cypher rule): a single
1046
+ * path never traverses the same edge twice, keyed by the edge's canonical
1047
+ * `(src, dst, type)` identity. A node MAY recur in a path via distinct edges
1048
+ * (e.g. A->B->A over two different edges) — that is correct Cypher behavior.
1049
+ * The {@link TraversePathsQuery.maxHops} cap bounds each path's length;
1050
+ * {@link TraversePathsQuery.maxPaths} bounds the total enumerated count so a
1051
+ * dense subgraph cannot blow enumeration up exponentially without notice
1052
+ * (when hit, enumeration stops and the result carries `truncated: true`).
1053
+ *
1054
+ * Every yielded path has length >= 1 (at least one edge). A length-0 "path"
1055
+ * (the trivial start->start) is NOT enumerated; callers that need the start
1056
+ * node for a `*0..N` bound add it themselves.
1057
+ */
1058
+ traversePaths(query) {
1059
+ if (this.closed) return { ok: false, code: "store_closed" };
1060
+ if (query == null || typeof query !== "object") {
1061
+ return { ok: false, code: "invalid_query" };
1062
+ }
1063
+ if (typeof query.maxHops !== "number" || !Number.isInteger(query.maxHops) || query.maxHops < 0) {
1064
+ return { ok: false, code: "invalid_query" };
1065
+ }
1066
+ if (query.maxHops > MAX_TRAVERSE_PATHS_HOPS) {
1067
+ return { ok: false, code: "invalid_query" };
1068
+ }
1069
+ const direction = query.direction === void 0 ? "outgoing" : query.direction;
1070
+ if (direction !== "outgoing" && direction !== "incoming" && direction !== "both") {
1071
+ return { ok: false, code: "invalid_query" };
1072
+ }
1073
+ if (query.edgeTypes !== void 0 && (!Array.isArray(query.edgeTypes) || !query.edgeTypes.every((e) => typeof e === "string"))) {
1074
+ return { ok: false, code: "invalid_query" };
1075
+ }
1076
+ if (typeof query.start !== "string" || query.start.length === 0) {
1077
+ return { ok: false, code: "invalid_query" };
1078
+ }
1079
+ const minHops = query.minHops === void 0 ? 1 : query.minHops;
1080
+ if (typeof minHops !== "number" || !Number.isInteger(minHops) || minHops < 1) {
1081
+ return { ok: false, code: "invalid_query" };
1082
+ }
1083
+ let maxPaths;
1084
+ if (query.maxPaths === void 0) {
1085
+ maxPaths = DEFAULT_TRAVERSE_PATHS_MAX;
1086
+ } else if (typeof query.maxPaths !== "number" || !Number.isInteger(query.maxPaths) || query.maxPaths < 0) {
1087
+ return { ok: false, code: "invalid_query" };
1088
+ } else {
1089
+ maxPaths = query.maxPaths;
1090
+ }
1091
+ try {
1092
+ const isNodeId = /^[0-9a-f]{64}$/.test(query.start);
1093
+ const rows = expectRows(
1094
+ this.db.prepare(
1095
+ isNodeId ? "SELECT id FROM nodes WHERE id = ?" : "SELECT id FROM nodes WHERE qualified_name = ?"
1096
+ ).all(query.start),
1097
+ ["id"]
1098
+ );
1099
+ if (rows.length === 0) return { ok: false, code: "unknown_start" };
1100
+ if (rows.length > 1) return { ok: false, code: "ambiguous_start" };
1101
+ const startId = rows[0].id;
1102
+ if (query.maxHops === 0) {
1103
+ return { ok: true, hits: [], truncated: false };
1104
+ }
1105
+ const edgeTypes = query.edgeTypes ?? [];
1106
+ const typeClause = edgeTypes.length > 0 ? `AND type IN (${edgeTypes.map(() => "?").join(", ")})` : "";
1107
+ const outgoingStmt = this.db.prepare(
1108
+ `SELECT dst AS neighbor, src, dst, type FROM edges WHERE src = ? ${typeClause}`
1109
+ );
1110
+ const incomingStmt = this.db.prepare(
1111
+ `SELECT src AS neighbor, src, dst, type FROM edges WHERE dst = ? ${typeClause}`
1112
+ );
1113
+ const nodeStmt = this.db.prepare(
1114
+ "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 = ?"
1115
+ );
1116
+ const nodeCache = /* @__PURE__ */ new Map();
1117
+ const getNode = (id) => {
1118
+ const cached = nodeCache.get(id);
1119
+ if (cached) return cached;
1120
+ const row = expectRow(nodeStmt.get(id), [
1121
+ "id",
1122
+ "qualified_name",
1123
+ "name",
1124
+ "label",
1125
+ "file_path"
1126
+ ]);
1127
+ if (row) nodeCache.set(id, row);
1128
+ return row;
1129
+ };
1130
+ const neighborsOf = (id) => {
1131
+ const params = [id, ...edgeTypes];
1132
+ const out = direction === "outgoing" || direction === "both" ? expectRows(outgoingStmt.all(...params), [
1133
+ "neighbor",
1134
+ "src",
1135
+ "dst",
1136
+ "type"
1137
+ ]) : [];
1138
+ const inn = direction === "incoming" || direction === "both" ? expectRows(incomingStmt.all(...params), [
1139
+ "neighbor",
1140
+ "src",
1141
+ "dst",
1142
+ "type"
1143
+ ]) : [];
1144
+ const seenEdge = /* @__PURE__ */ new Set();
1145
+ const deduped = [];
1146
+ for (const e of [...out, ...inn]) {
1147
+ const k = e.src + "\0" + e.dst + "\0" + e.type;
1148
+ if (seenEdge.has(k)) continue;
1149
+ seenEdge.add(k);
1150
+ deduped.push(e);
1151
+ }
1152
+ return deduped;
1153
+ };
1154
+ const hits = [];
1155
+ let truncated = false;
1156
+ const usedEdges = /* @__PURE__ */ new Set();
1157
+ const pathNodes = [startId];
1158
+ const pathEdgeTypes = [];
1159
+ const pathEndpoints = [];
1160
+ const dfs = (currentId, length) => {
1161
+ if (truncated) return;
1162
+ if (length >= query.maxHops) return;
1163
+ for (const e of neighborsOf(currentId)) {
1164
+ if (truncated) return;
1165
+ const key = `${e.src}\0${e.dst}\0${e.type}`;
1166
+ if (usedEdges.has(key)) continue;
1167
+ usedEdges.add(key);
1168
+ pathNodes.push(e.neighbor);
1169
+ pathEdgeTypes.push(e.type);
1170
+ pathEndpoints.push({ src: e.src, dst: e.dst });
1171
+ const newLength = length + 1;
1172
+ if (newLength >= minHops) {
1173
+ if (hits.length >= maxPaths) {
1174
+ truncated = true;
1175
+ } else {
1176
+ const info = getNode(e.neighbor);
1177
+ if (info) {
1178
+ hits.push({
1179
+ nodeId: info.id,
1180
+ qualifiedName: info.qualified_name,
1181
+ name: info.name,
1182
+ label: info.label,
1183
+ filePath: info.file_path,
1184
+ length: newLength,
1185
+ nodeIds: pathNodes.slice(),
1186
+ edgeTypes: pathEdgeTypes.slice(),
1187
+ edgeEndpoints: pathEndpoints.slice()
1188
+ });
1189
+ }
1190
+ }
1191
+ }
1192
+ if (!truncated) dfs(e.neighbor, newLength);
1193
+ pathEndpoints.pop();
1194
+ pathEdgeTypes.pop();
1195
+ pathNodes.pop();
1196
+ usedEdges.delete(key);
1197
+ }
1198
+ };
1199
+ dfs(startId, 0);
1200
+ return { ok: true, hits, truncated };
1201
+ } catch (error) {
1202
+ logWriteFailure(error);
1203
+ return classifyReadError(error);
1204
+ }
1205
+ }
1206
+ /**
1207
+ * Structured node search. All filters are AND-combined; patterns use
1208
+ * SQLite LIKE (case-insensitive via COLLATE NOCASE). Patterns and
1209
+ * limits are parameter-bound, never string-interpolated, so user
1210
+ * input cannot inject SQL.
1211
+ */
1212
+ searchGraph(query) {
1213
+ if (this.closed) return { ok: false, code: "store_closed" };
1214
+ if (query == null || typeof query !== "object") {
1215
+ return { ok: false, code: "invalid_query" };
1216
+ }
1217
+ if (query.degreeMin !== void 0 && (typeof query.degreeMin !== "number" || !Number.isInteger(query.degreeMin) || query.degreeMin < 0) || query.degreeMax !== void 0 && (typeof query.degreeMax !== "number" || !Number.isInteger(query.degreeMax) || query.degreeMax < 0)) {
1218
+ return { ok: false, code: "invalid_query" };
1219
+ }
1220
+ if (query.degreeMin !== void 0 && query.degreeMax !== void 0 && query.degreeMin > query.degreeMax) {
1221
+ return { ok: false, code: "invalid_query" };
1222
+ }
1223
+ const rawLimit = query.limit ?? 100;
1224
+ if (typeof rawLimit !== "number" || !Number.isInteger(rawLimit) || rawLimit < 0) {
1225
+ return { ok: false, code: "invalid_query" };
1226
+ }
1227
+ const MAX_SEARCH_LIMIT = 1e3;
1228
+ const limit = Math.min(rawLimit, MAX_SEARCH_LIMIT);
1229
+ if (query.label !== void 0 && typeof query.label !== "string" || query.namePattern !== void 0 && typeof query.namePattern !== "string" || query.filePattern !== void 0 && typeof query.filePattern !== "string") {
1230
+ return { ok: false, code: "invalid_query" };
1231
+ }
1232
+ try {
1233
+ const params = [];
1234
+ const where = [];
1235
+ if (query.label !== void 0 && query.label.length > 0) {
1236
+ where.push("n.label = ?");
1237
+ params.push(query.label);
1238
+ }
1239
+ if (query.namePattern !== void 0 && query.namePattern.length > 0) {
1240
+ where.push("n.name LIKE ? COLLATE NOCASE");
1241
+ params.push(query.namePattern);
1242
+ }
1243
+ if (query.filePattern !== void 0 && query.filePattern.length > 0) {
1244
+ where.push("f.path LIKE ? COLLATE NOCASE");
1245
+ params.push(query.filePattern);
1246
+ }
1247
+ if (query.degreeMin !== void 0) {
1248
+ where.push(
1249
+ "(SELECT COUNT(*) FROM edges e WHERE e.src = n.id OR e.dst = n.id) >= ?"
1250
+ );
1251
+ params.push(query.degreeMin);
1252
+ }
1253
+ if (query.degreeMax !== void 0) {
1254
+ where.push(
1255
+ "(SELECT COUNT(*) FROM edges e WHERE e.src = n.id OR e.dst = n.id) <= ?"
1256
+ );
1257
+ params.push(query.degreeMax);
1258
+ }
1259
+ params.push(limit);
1260
+ const sql = `SELECT n.id AS node_id, n.qualified_name, n.name, n.label,
1261
+ f.path AS file_path,
1262
+ (SELECT COUNT(*) FROM edges e WHERE e.src = n.id OR e.dst = n.id) AS degree
1263
+ FROM nodes n
1264
+ JOIN files f ON n.file_id = f.id
1265
+ ${where.length > 0 ? "WHERE " + where.join(" AND ") : ""}
1266
+ ORDER BY degree DESC, n.qualified_name ASC
1267
+ LIMIT ?`;
1268
+ const rows = expectRows(this.db.prepare(sql).all(...params), [
1269
+ "node_id",
1270
+ "qualified_name",
1271
+ "name",
1272
+ "label",
1273
+ "file_path",
1274
+ "degree"
1275
+ ]);
1276
+ const hits = rows.map((r) => ({
1277
+ nodeId: r.node_id,
1278
+ qualifiedName: r.qualified_name,
1279
+ name: r.name,
1280
+ label: r.label,
1281
+ filePath: r.file_path,
1282
+ degree: r.degree
1283
+ }));
1284
+ return { ok: true, hits };
1285
+ } catch (error) {
1286
+ logWriteFailure(error);
1287
+ return classifyReadError(error);
1288
+ }
1289
+ }
1290
+ /**
1291
+ * Aggregate counts over the whole graph. Single round-trip: one
1292
+ * scalar per metric, two GROUP BY queries for the by-label /
1293
+ * by-type histograms.
1294
+ */
1295
+ schemaStats() {
1296
+ if (this.closed) return { ok: false, code: "store_closed" };
1297
+ try {
1298
+ const fileCount = expectRow(
1299
+ this.db.prepare("SELECT COUNT(*) AS c FROM files").get(),
1300
+ ["c"]
1301
+ );
1302
+ const nodeCount = expectRow(
1303
+ this.db.prepare("SELECT COUNT(*) AS c FROM nodes").get(),
1304
+ ["c"]
1305
+ );
1306
+ const edgeCount = expectRow(
1307
+ this.db.prepare("SELECT COUNT(*) AS c FROM edges").get(),
1308
+ ["c"]
1309
+ );
1310
+ const labelRows = expectRows(
1311
+ this.db.prepare(
1312
+ "SELECT label, COUNT(*) AS c FROM nodes GROUP BY label ORDER BY label"
1313
+ ).all(),
1314
+ ["label", "c"]
1315
+ );
1316
+ const typeRows = expectRows(
1317
+ this.db.prepare(
1318
+ "SELECT type, COUNT(*) AS c FROM edges GROUP BY type ORDER BY type"
1319
+ ).all(),
1320
+ ["type", "c"]
1321
+ );
1322
+ const nodesByLabel = {};
1323
+ for (const r of labelRows) nodesByLabel[r.label] = r.c;
1324
+ const edgesByType = {};
1325
+ for (const r of typeRows) edgesByType[r.type] = r.c;
1326
+ return {
1327
+ ok: true,
1328
+ stats: {
1329
+ files: fileCount?.c ?? 0,
1330
+ nodes: nodeCount?.c ?? 0,
1331
+ edges: edgeCount?.c ?? 0,
1332
+ nodesByLabel,
1333
+ edgesByType
1334
+ }
1335
+ };
1336
+ } catch (error) {
1337
+ logWriteFailure(error);
1338
+ const failure = classifyReadError(error);
1339
+ return failure;
1340
+ }
1341
+ }
1342
+ /**
1343
+ * Dead-code candidates: nodes with zero inbound
1344
+ * {@link DEAD_CODE_EXCLUSION.INBOUND_USAGE_EDGE_TYPES} edges, excluding
1345
+ * nodes whose `node_attributes` row marks them exported / route-handler
1346
+ * AND nodes whose file path matches the test / entry-point patterns
1347
+ * in {@link DEAD_CODE_EXCLUSION}.
1348
+ *
1349
+ * The exclusion criteria live in the named constant — not in
1350
+ * ad-hoc WHERE clauses (rule 53 analog). The stored flags come from
1351
+ * the write pipeline's `upsertFileAttributes` pass, which the IR's
1352
+ * `exports` and `routes` arrays feed.
1353
+ */
1354
+ deadCode() {
1355
+ if (this.closed) return { ok: false, code: "store_closed" };
1356
+ try {
1357
+ const usageTypes = DEAD_CODE_EXCLUSION.INBOUND_USAGE_EDGE_TYPES;
1358
+ const typePlaceholders = usageTypes.map(() => "?").join(", ");
1359
+ const sql = `SELECT n.id AS node_id, n.qualified_name, n.name, n.label,
1360
+ f.path AS file_path
1361
+ FROM nodes n
1362
+ JOIN files f ON n.file_id = f.id
1363
+ LEFT JOIN node_attributes a ON a.node_id = n.id
1364
+ WHERE NOT EXISTS (
1365
+ SELECT 1 FROM edges e
1366
+ WHERE e.dst = n.id
1367
+ AND e.src <> n.id
1368
+ AND e.type IN (${typePlaceholders})
1369
+ )
1370
+ AND COALESCE(a.is_exported, 0) = 0
1371
+ AND COALESCE(a.is_route_handler, 0) = 0
1372
+ ORDER BY n.qualified_name ASC`;
1373
+ const rows = expectRows(this.db.prepare(sql).all(...usageTypes), [
1374
+ "node_id",
1375
+ "qualified_name",
1376
+ "name",
1377
+ "label",
1378
+ "file_path"
1379
+ ]);
1380
+ const hits = [];
1381
+ for (const r of rows) {
1382
+ if (isExcludedByPath(r.file_path)) continue;
1383
+ hits.push({
1384
+ nodeId: r.node_id,
1385
+ qualifiedName: r.qualified_name,
1386
+ name: r.name,
1387
+ label: r.label,
1388
+ filePath: r.file_path
1389
+ });
1390
+ }
1391
+ return { ok: true, hits };
1392
+ } catch (error) {
1393
+ logWriteFailure(error);
1394
+ const failure = classifyReadError(error);
1395
+ return failure;
1396
+ }
1397
+ }
1398
+ /**
1399
+ * Read a symbol's source span from disk. The store NEVER persists
1400
+ * file contents (privacy + DB size — issue #1552 design); this
1401
+ * method resolves `files.path` against {@link GraphStoreOptions.repoRoot}
1402
+ * and slices the half-open `[startByte, endByte)` span from the
1403
+ * on-disk bytes.
1404
+ */
1405
+ async snippetFor(query) {
1406
+ if (this.closed) return { ok: false, code: "store_closed" };
1407
+ if (query == null || typeof query !== "object") {
1408
+ return { ok: false, code: "invalid_query" };
1409
+ }
1410
+ const hasNodeId = typeof query.nodeId === "string" && query.nodeId.length > 0;
1411
+ if (!hasNodeId && (typeof query.qualifiedName !== "string" || query.qualifiedName.length === 0)) {
1412
+ return { ok: false, code: "invalid_query" };
1413
+ }
1414
+ if (query.contextLines !== void 0 && (typeof query.contextLines !== "number" || !Number.isInteger(query.contextLines) || query.contextLines < 0)) {
1415
+ return { ok: false, code: "invalid_query" };
1416
+ }
1417
+ const root = typeof query.repoRoot === "string" && query.repoRoot.length > 0 ? query.repoRoot : this.repoRoot;
1418
+ if (root === void 0) {
1419
+ return { ok: false, code: "repo_root_unset" };
1420
+ }
1421
+ let rows;
1422
+ try {
1423
+ rows = expectRows(
1424
+ this.db.prepare(
1425
+ `SELECT n.id, n.qualified_name, n.span_start, n.span_end, n.lang,
1426
+ f.path AS file_path
1427
+ FROM nodes n JOIN files f ON n.file_id = f.id
1428
+ WHERE ${hasNodeId ? "n.id = ?" : "n.qualified_name = ?"}`
1429
+ ).all(hasNodeId ? query.nodeId : query.qualifiedName),
1430
+ ["id", "qualified_name", "file_path", "span_start", "span_end", "lang"]
1431
+ );
1432
+ } catch (error) {
1433
+ logWriteFailure(error);
1434
+ return classifyReadError(error);
1435
+ }
1436
+ if (rows.length === 0) return { ok: false, code: "not_found" };
1437
+ if (rows.length > 1) return { ok: false, code: "ambiguous_name" };
1438
+ const node = rows[0];
1439
+ const absolutePath = path.resolve(root, node.file_path);
1440
+ let bytes;
1441
+ try {
1442
+ bytes = await readFile(absolutePath);
1443
+ } catch (error) {
1444
+ logWriteFailure(error);
1445
+ return { ok: false, code: "read_failed" };
1446
+ }
1447
+ const start = Math.max(0, node.span_start);
1448
+ const end = Math.min(bytes.length, node.span_end);
1449
+ if (start > end) {
1450
+ return {
1451
+ ok: true,
1452
+ qualifiedName: node.qualified_name,
1453
+ filePath: node.file_path,
1454
+ absolutePath,
1455
+ startByte: node.span_start,
1456
+ endByte: node.span_end,
1457
+ text: "",
1458
+ lang: node.lang
1459
+ };
1460
+ }
1461
+ let text = bytes.subarray(start, end).toString("utf8");
1462
+ const ctx = query.contextLines ?? 0;
1463
+ if (ctx > 0) {
1464
+ const MAX_CTX = 200;
1465
+ const contextLines = Math.min(Math.max(0, Math.floor(ctx)), MAX_CTX);
1466
+ if (contextLines > 0) {
1467
+ let lineStart = start;
1468
+ while (lineStart > 0 && bytes[lineStart - 1] !== 10) {
1469
+ lineStart -= 1;
1470
+ }
1471
+ for (let i = 0; i < contextLines && lineStart > 0; i += 1) {
1472
+ lineStart -= 1;
1473
+ while (lineStart > 0 && bytes[lineStart - 1] !== 10) {
1474
+ lineStart -= 1;
1475
+ }
1476
+ }
1477
+ let lineEnd = end;
1478
+ while (lineEnd < bytes.length && bytes[lineEnd] !== 10) {
1479
+ lineEnd += 1;
1480
+ }
1481
+ if (lineEnd < bytes.length && bytes[lineEnd] === 10) {
1482
+ lineEnd += 1;
1483
+ }
1484
+ for (let i = 0; i < contextLines && lineEnd < bytes.length; i += 1) {
1485
+ while (lineEnd < bytes.length && bytes[lineEnd] !== 10) {
1486
+ lineEnd += 1;
1487
+ }
1488
+ if (lineEnd < bytes.length && bytes[lineEnd] === 10) {
1489
+ lineEnd += 1;
1490
+ }
1491
+ }
1492
+ text = bytes.subarray(lineStart, lineEnd).toString("utf8");
1493
+ }
1494
+ }
1495
+ return {
1496
+ ok: true,
1497
+ qualifiedName: node.qualified_name,
1498
+ filePath: node.file_path,
1499
+ absolutePath,
1500
+ startByte: node.span_start,
1501
+ endByte: node.span_end,
1502
+ text,
1503
+ lang: node.lang
1504
+ };
1505
+ }
1506
+ // ──────────────────────────────────────────────────────────────────────
1507
+ // Semantic layer (issue #1556): symbol_vectors table read/write.
1508
+ // The db is private; these methods are the ONLY surface the semantic
1509
+ // indexer/query path uses. Vectors are float32 BLOBs; content_hash is
1510
+ // the canonical-text hash (rule 37 — the cache invalidation key).
1511
+ // ──────────────────────────────────────────────────────────────────────
1512
+ /**
1513
+ * Upsert one symbol vector. Idempotent on (node_id, model_id). The
1514
+ * caller (the semantic indexer) has ALREADY decided to re-embed (the
1515
+ * content_hash differs from the cached row); this method just persists.
1516
+ */
1517
+ async writeSymbolVector(input) {
1518
+ if (this.closed || this.closing) return false;
1519
+ const buf = Buffer.from(input.vector.buffer, input.vector.byteOffset, input.vector.byteLength);
1520
+ await this.queue.schedule(async () => {
1521
+ this.db.prepare(
1522
+ `INSERT INTO symbol_vectors (node_id, model_id, content_hash, dims, vector)
1523
+ VALUES (?, ?, ?, ?, ?)
1524
+ ON CONFLICT(node_id, model_id) DO UPDATE SET
1525
+ content_hash = excluded.content_hash,
1526
+ dims = excluded.dims,
1527
+ vector = excluded.vector`
1528
+ ).run(input.nodeId, input.modelId, input.contentHash, input.dims, buf);
1529
+ });
1530
+ return true;
1531
+ }
1532
+ /**
1533
+ * Read one vector row by (node_id, model_id). Returns null when absent.
1534
+ * Used by the indexer's cache-check path (skip re-embed when content_hash
1535
+ * matches) and by the cache-hit test.
1536
+ */
1537
+ readSymbolVector(nodeId, modelId) {
1538
+ if (this.closed) return null;
1539
+ const row = expectRow(
1540
+ this.db.prepare(
1541
+ `SELECT content_hash, dims, vector FROM symbol_vectors
1542
+ WHERE node_id = ? AND model_id = ?`
1543
+ ).get(nodeId, modelId),
1544
+ ["content_hash", "dims", "vector"]
1545
+ );
1546
+ if (!row) return null;
1547
+ return {
1548
+ contentHash: row.content_hash,
1549
+ dims: row.dims,
1550
+ vector: new Float32Array(row.vector.buffer, row.vector.byteOffset, row.vector.byteLength / 4)
1551
+ };
1552
+ }
1553
+ /**
1554
+ * Read every vector row for a given model. Used by brute-force cosine
1555
+ * retrieval (SIMILAR_TO confirmation + semantic_query). Returns node
1556
+ * metadata alongside the vector so callers can hydrate hits without a
1557
+ * second round-trip.
1558
+ */
1559
+ readAllSymbolVectors(modelId) {
1560
+ if (this.closed) return [];
1561
+ const rows = expectRows(
1562
+ this.db.prepare(
1563
+ `SELECT sv.node_id, sv.dims, sv.vector, sv.content_hash,
1564
+ n.qualified_name, n.label, f.path AS file_path
1565
+ FROM symbol_vectors sv
1566
+ JOIN nodes n ON sv.node_id = n.id
1567
+ JOIN files f ON n.file_id = f.id
1568
+ WHERE sv.model_id = ?`
1569
+ ).all(modelId),
1570
+ ["node_id", "qualified_name", "label", "file_path", "dims", "vector", "content_hash"]
1571
+ );
1572
+ return rows.map((r) => ({
1573
+ nodeId: r.node_id,
1574
+ qualifiedName: r.qualified_name,
1575
+ filePath: r.file_path,
1576
+ kind: r.label,
1577
+ dims: r.dims,
1578
+ contentHash: r.content_hash,
1579
+ vector: new Float32Array(r.vector.buffer, r.vector.byteOffset, r.vector.byteLength / 4)
1580
+ }));
1581
+ }
1582
+ /**
1583
+ * Delete vector rows for a set of node ids (all models). Used by the
1584
+ * cache-invalidation path when a symbol's canonical text changed AND
1585
+ * it could not be re-embedded (provider gone) — the stale vector must
1586
+ * not survive to pollute cosine retrieval. Cascades via the schema's
1587
+ * ON DELETE CASCADE on nodes(id) when a node is pruned, so this method
1588
+ * is only for the targeted-invalidation path.
1589
+ */
1590
+ async deleteSymbolVectors(nodeIds) {
1591
+ if (this.closed || this.closing || nodeIds.length === 0) return;
1592
+ await this.queue.schedule(async () => {
1593
+ this.runChunkedDelete(
1594
+ "DELETE FROM symbol_vectors WHERE node_id IN (%PH%)",
1595
+ nodeIds
1596
+ );
1597
+ });
1598
+ }
1599
+ /**
1600
+ * Remove every SIMILAR_TO edge written by the semantic similarity
1601
+ * pipeline (type 'SIMILAR_TO', provenance 'semantic'). The pipeline
1602
+ * recomputes the FULL near-clone edge set on each run, so callers MUST
1603
+ * clear the prior set before upserting the new one — otherwise an edge
1604
+ * between two symbols that stopped being similar survives indefinitely
1605
+ * and graph traversal keeps reporting a stale clone relationship
1606
+ * (chatgpt-codex-connector P2: 'Replace old SIMILAR_TO edges on
1607
+ * recompute'). Scoped to provenance 'semantic' so non-semantic edges
1608
+ * are untouched. Serialized via the write queue so it cannot interleave
1609
+ * a concurrent file-batch edge upsert.
1610
+ */
1611
+ async clearSemanticSimilarToEdges() {
1612
+ if (this.closed || this.closing) return;
1613
+ await this.queue.schedule(async () => {
1614
+ this.db.prepare("DELETE FROM edges WHERE type = ? AND provenance = ?").run("SIMILAR_TO", "semantic");
1615
+ });
1616
+ }
1617
+ /**
1618
+ * Read every node with its file path + span, for the semantic indexer.
1619
+ * The indexer reads source text from disk (via repoRoot) and builds
1620
+ * canonical text per node. Returns kind + qualified_name + span so the
1621
+ * indexer can reconstruct the SymbolIR-equivalent without a second
1622
+ * join. Ordered by qualified_name for deterministic processing order.
1623
+ */
1624
+ readNodesForSemantic() {
1625
+ if (this.closed) return [];
1626
+ const rows = expectRows(
1627
+ this.db.prepare(
1628
+ `SELECT n.id, n.qualified_name, n.label, n.span_start, n.span_end, n.lang,
1629
+ f.path AS file_path
1630
+ FROM nodes n JOIN files f ON n.file_id = f.id
1631
+ ORDER BY n.qualified_name ASC`
1632
+ ).all(),
1633
+ ["id", "qualified_name", "label", "file_path", "span_start", "span_end", "lang"]
1634
+ );
1635
+ return rows.map((r) => ({
1636
+ nodeId: r.id,
1637
+ qualifiedName: r.qualified_name,
1638
+ kind: r.label,
1639
+ filePath: r.file_path,
1640
+ startByte: r.span_start,
1641
+ endByte: r.span_end,
1642
+ lang: r.lang
1643
+ }));
1644
+ }
1645
+ /**
1646
+ * Read the callers and callees of a node by qualified name, for
1647
+ * semantic_query hydration (the issue: hydrate each hit with graph
1648
+ * context — defining file, direct callers/callees).
1649
+ */
1650
+ readNeighbors(qualifiedName) {
1651
+ if (this.closed) return { callers: [], callees: [] };
1652
+ const nodeRow = expectRow(
1653
+ this.db.prepare("SELECT id FROM nodes WHERE qualified_name = ?").get(qualifiedName),
1654
+ ["id"]
1655
+ );
1656
+ if (!nodeRow) return { callers: [], callees: [] };
1657
+ const id = nodeRow.id;
1658
+ const callerRows = expectRows(
1659
+ this.db.prepare(
1660
+ `SELECT n.qualified_name FROM edges e
1661
+ JOIN nodes n ON e.src = n.id
1662
+ WHERE e.dst = ? AND e.type = 'CALLS'`
1663
+ ).all(id),
1664
+ ["qualified_name"]
1665
+ );
1666
+ const calleeRows = expectRows(
1667
+ this.db.prepare(
1668
+ `SELECT n.qualified_name FROM edges e
1669
+ JOIN nodes n ON e.dst = n.id
1670
+ WHERE e.src = ? AND e.type = 'CALLS'`
1671
+ ).all(id),
1672
+ ["qualified_name"]
1673
+ );
1674
+ return {
1675
+ callers: callerRows.map((r) => r.qualified_name),
1676
+ callees: calleeRows.map((r) => r.qualified_name)
1677
+ };
1678
+ }
1679
+ /**
1680
+ * Read callers/callees by node id directly (avoids the qualified-name
1681
+ * ambiguity when duplicate names exist across files). Used by
1682
+ * semantic_query hydration (chatgpt-codex-connector: 'Use the hit node
1683
+ * id when hydrating neighbors').
1684
+ */
1685
+ readNeighborsByNodeId(nodeId) {
1686
+ if (this.closed) return { callers: [], callees: [] };
1687
+ const callerRows = expectRows(
1688
+ this.db.prepare(
1689
+ `SELECT n.qualified_name FROM edges e
1690
+ JOIN nodes n ON e.src = n.id
1691
+ WHERE e.dst = ? AND e.type = 'CALLS'`
1692
+ ).all(nodeId),
1693
+ ["qualified_name"]
1694
+ );
1695
+ const calleeRows = expectRows(
1696
+ this.db.prepare(
1697
+ `SELECT n.qualified_name FROM edges e
1698
+ JOIN nodes n ON e.dst = n.id
1699
+ WHERE e.src = ? AND e.type = 'CALLS'`
1700
+ ).all(nodeId),
1701
+ ["qualified_name"]
1702
+ );
1703
+ return {
1704
+ callers: callerRows.map((r) => r.qualified_name),
1705
+ callees: calleeRows.map((r) => r.qualified_name)
1706
+ };
1707
+ }
1708
+ };
1709
+ function nodeIdFor(input) {
1710
+ const fields = [
1711
+ ["qualifiedName", input.qualifiedName],
1712
+ ["filePath", input.filePath],
1713
+ ["label", input.label]
1714
+ ].map(([k, v]) => [k, String(v)]).sort(([a], [b]) => a.localeCompare(b));
1715
+ const hash = createHash("sha256");
1716
+ for (const [k, v] of fields) {
1717
+ hash.update(`${k.length}:${k}:`);
1718
+ hash.update(`${v.length}:${v}:`);
1719
+ }
1720
+ return hash.digest("hex");
1721
+ }
1722
+ function resolveNodeId(qualifiedName, inBatch, db) {
1723
+ const local = inBatch.get(qualifiedName);
1724
+ if (local) return local;
1725
+ const rows = expectRows(
1726
+ db.prepare(
1727
+ "SELECT id, file_id FROM nodes WHERE qualified_name = ? ORDER BY file_id, id"
1728
+ ).all(qualifiedName),
1729
+ ["id", "file_id"]
1730
+ );
1731
+ if (rows.length === 0) return void 0;
1732
+ if (rows.length > 1) {
1733
+ return void 0;
1734
+ }
1735
+ return rows[0]?.id;
1736
+ }
1737
+ function resolveByNodeId(stmt, nodeId, qualifiedName) {
1738
+ const row = expectRow(stmt.get(nodeId), ["qualified_name"]);
1739
+ if (!row) return void 0;
1740
+ if (row.qualified_name !== qualifiedName) return void 0;
1741
+ return nodeId;
1742
+ }
1743
+ function classifyError(error) {
1744
+ const code = hasErrorCode(error) ? error.code : "";
1745
+ if (code === "SQLITE_BUSY" || code === "SQLITE_LOCKED") {
1746
+ return { ok: false, code: "db_locked" };
1747
+ }
1748
+ const msg = error instanceof Error ? error.message : String(error ?? "");
1749
+ if (code === "SQLITE_CORRUPT" || code === "SQLITE_NOTADB" || msg.includes("database disk image is malformed")) {
1750
+ return { ok: false, code: "db_corrupt" };
1751
+ }
1752
+ throw error instanceof Error ? error : new Error(String(error ?? ""));
1753
+ }
1754
+ function classifyReadError(error) {
1755
+ try {
1756
+ return classifyError(error);
1757
+ } catch {
1758
+ return { ok: false, code: "db_error" };
1759
+ }
1760
+ }
1761
+ function hasErrorCode(value) {
1762
+ if (typeof value !== "object" || value === null) return false;
1763
+ if (!("code" in value)) return false;
1764
+ const codeValue = value["code"];
1765
+ return typeof codeValue === "string";
1766
+ }
1767
+ function logWriteFailure(error) {
1768
+ console.error(
1769
+ "[coding-graph] write failure:",
1770
+ error instanceof Error ? error.message : String(error ?? "")
1771
+ );
1772
+ }
1773
+ function assertCanonicalFilePath(filePath) {
1774
+ if (typeof filePath !== "string" || filePath.length === 0) {
1775
+ throw new Error(
1776
+ `graph-store: file path must be a non-empty string; received ${filePath === null ? "null" : typeof filePath}`
1777
+ );
1778
+ }
1779
+ if (filePath.includes("\\")) {
1780
+ throw new Error(
1781
+ `graph-store: file path '${filePath}' must use forward slashes (backslash rejected \u2014 FileIR contract requires repo-relative POSIX paths)`
1782
+ );
1783
+ }
1784
+ if (filePath.startsWith("/") || /^[A-Za-z]:[\\/]/.test(filePath)) {
1785
+ throw new Error(
1786
+ `graph-store: file path '${filePath}' must be repo-relative (absolute path rejected \u2014 FileIR contract requires repo-relative forward-slash paths)`
1787
+ );
1788
+ }
1789
+ if (filePath.split("/").some((segment) => segment === "." || segment === "..")) {
1790
+ throw new Error(
1791
+ `graph-store: file path '${filePath}' must be canonical (no '.' or '..' segments \u2014 FileIR contract requires repo-relative forward-slash paths)`
1792
+ );
1793
+ }
1794
+ }
1795
+ function assertValidSymbolSpan(sym, filePath) {
1796
+ if (typeof sym !== "object" || sym === null) {
1797
+ throw new Error(
1798
+ `graph-store: file '${filePath}' has a non-object symbol; received ${sym === null ? "null" : typeof sym}`
1799
+ );
1800
+ }
1801
+ if (!("span" in sym)) {
1802
+ throw new Error(
1803
+ `graph-store: file '${filePath}' has a symbol with no span (FileIR contract requires startByte/endByte)`
1804
+ );
1805
+ }
1806
+ const span = sym.span;
1807
+ if (typeof span !== "object" || span === null || !("startByte" in span) || !("endByte" in span)) {
1808
+ throw new Error(
1809
+ `graph-store: file '${filePath}' has a symbol with a malformed span \u2014 expected { startByte, endByte }; received ${JSON.stringify(span)}`
1810
+ );
1811
+ }
1812
+ const startByte = span.startByte;
1813
+ const endByte = span.endByte;
1814
+ if (typeof startByte !== "number" || typeof endByte !== "number" || !Number.isInteger(startByte) || !Number.isInteger(endByte)) {
1815
+ throw new Error(
1816
+ `graph-store: file '${filePath}' has a symbol with a non-integer span [${JSON.stringify(startByte)}, ${JSON.stringify(endByte)}) \u2014 startByte and endByte must be finite integers`
1817
+ );
1818
+ }
1819
+ if (startByte < 0 || endByte < 0) {
1820
+ throw new Error(
1821
+ `graph-store: file '${filePath}' has a symbol with a negative span [${startByte}, ${endByte}) \u2014 byte offsets must be non-negative`
1822
+ );
1823
+ }
1824
+ if (startByte > endByte) {
1825
+ throw new Error(
1826
+ `graph-store: file '${filePath}' has a symbol with startByte > endByte [${startByte}, ${endByte}) \u2014 half-open spans require startByte <= endByte`
1827
+ );
1828
+ }
1829
+ }
1830
+
1831
+ export {
1832
+ DEFAULT_TRAVERSE_PATHS_MAX,
1833
+ MAX_TRAVERSE_PATHS_HOPS,
1834
+ DEAD_CODE_EXCLUSION,
1835
+ GraphStore,
1836
+ nodeIdFor
1837
+ };
1838
+ //# sourceMappingURL=chunk-CPYJACC5.js.map