codestrate 0.6.2 → 0.6.3

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 (223) hide show
  1. package/README.md +97 -101
  2. package/dist/constants.d.ts +1 -1
  3. package/dist/constants.d.ts.map +1 -1
  4. package/dist/constants.js +54 -4
  5. package/dist/constants.js.map +1 -1
  6. package/dist/index.d.ts +3 -2
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +198 -119
  9. package/dist/index.js.map +1 -1
  10. package/dist/install.d.ts +0 -0
  11. package/dist/install.d.ts.map +0 -0
  12. package/dist/install.js +2 -2
  13. package/dist/install.js.map +1 -1
  14. package/dist/profiles.d.ts +28 -0
  15. package/dist/profiles.d.ts.map +1 -0
  16. package/dist/profiles.js +89 -0
  17. package/dist/profiles.js.map +1 -0
  18. package/dist/schemas/index.d.ts +1441 -126
  19. package/dist/schemas/index.d.ts.map +1 -1
  20. package/dist/schemas/index.js +221 -10
  21. package/dist/schemas/index.js.map +1 -1
  22. package/dist/security/guard.d.ts +15 -2
  23. package/dist/security/guard.d.ts.map +1 -1
  24. package/dist/security/guard.js +42 -8
  25. package/dist/security/guard.js.map +1 -1
  26. package/dist/services/code-chunker.d.ts +50 -0
  27. package/dist/services/code-chunker.d.ts.map +1 -0
  28. package/dist/services/code-chunker.js +148 -0
  29. package/dist/services/code-chunker.js.map +1 -0
  30. package/dist/services/code-searcher.d.ts +6 -2
  31. package/dist/services/code-searcher.d.ts.map +1 -1
  32. package/dist/services/code-searcher.js +111 -17
  33. package/dist/services/code-searcher.js.map +1 -1
  34. package/dist/services/context-builder.d.ts +0 -11
  35. package/dist/services/context-builder.d.ts.map +1 -1
  36. package/dist/services/context-builder.js +74 -128
  37. package/dist/services/context-builder.js.map +1 -1
  38. package/dist/services/embedding-runtime.d.ts +41 -0
  39. package/dist/services/embedding-runtime.d.ts.map +1 -0
  40. package/dist/services/embedding-runtime.js +636 -0
  41. package/dist/services/embedding-runtime.js.map +1 -0
  42. package/dist/services/embedding.d.ts +79 -0
  43. package/dist/services/embedding.d.ts.map +1 -0
  44. package/dist/services/embedding.js +257 -0
  45. package/dist/services/embedding.js.map +1 -0
  46. package/dist/services/index-state.d.ts +74 -0
  47. package/dist/services/index-state.d.ts.map +1 -0
  48. package/dist/services/index-state.js +151 -0
  49. package/dist/services/index-state.js.map +1 -0
  50. package/dist/services/index-worker.d.ts +60 -0
  51. package/dist/services/index-worker.d.ts.map +1 -0
  52. package/dist/services/index-worker.js +182 -0
  53. package/dist/services/index-worker.js.map +1 -0
  54. package/dist/services/logger.d.ts +48 -0
  55. package/dist/services/logger.d.ts.map +1 -0
  56. package/dist/services/logger.js +106 -0
  57. package/dist/services/logger.js.map +1 -0
  58. package/dist/services/memory-store.d.ts +9 -7
  59. package/dist/services/memory-store.d.ts.map +1 -1
  60. package/dist/services/memory-store.js +93 -7
  61. package/dist/services/memory-store.js.map +1 -1
  62. package/dist/services/repository-parser.d.ts +0 -0
  63. package/dist/services/repository-parser.d.ts.map +0 -0
  64. package/dist/services/repository-parser.js +0 -0
  65. package/dist/services/repository-parser.js.map +0 -0
  66. package/dist/services/symbol-indexer.d.ts +0 -0
  67. package/dist/services/symbol-indexer.d.ts.map +1 -1
  68. package/dist/services/symbol-indexer.js +25 -13
  69. package/dist/services/symbol-indexer.js.map +1 -1
  70. package/dist/services/tree-sitter.d.ts +0 -0
  71. package/dist/services/tree-sitter.d.ts.map +0 -0
  72. package/dist/services/tree-sitter.js +0 -0
  73. package/dist/services/tree-sitter.js.map +0 -0
  74. package/dist/services/validation-runner.d.ts +0 -0
  75. package/dist/services/validation-runner.d.ts.map +1 -1
  76. package/dist/services/validation-runner.js +9 -9
  77. package/dist/services/validation-runner.js.map +1 -1
  78. package/dist/services/vector-indexer.d.ts +53 -0
  79. package/dist/services/vector-indexer.d.ts.map +1 -0
  80. package/dist/services/vector-indexer.js +230 -0
  81. package/dist/services/vector-indexer.js.map +1 -0
  82. package/dist/store/index.d.ts +0 -0
  83. package/dist/store/index.d.ts.map +0 -0
  84. package/dist/store/index.js +0 -0
  85. package/dist/store/index.js.map +0 -0
  86. package/dist/store/interface.d.ts +70 -1
  87. package/dist/store/interface.d.ts.map +1 -1
  88. package/dist/store/interface.js +0 -0
  89. package/dist/store/interface.js.map +0 -0
  90. package/dist/store/memory.d.ts +0 -0
  91. package/dist/store/memory.d.ts.map +0 -0
  92. package/dist/store/memory.js +0 -0
  93. package/dist/store/memory.js.map +0 -0
  94. package/dist/store/service.d.ts +49 -0
  95. package/dist/store/service.d.ts.map +1 -0
  96. package/dist/store/service.js +147 -0
  97. package/dist/store/service.js.map +1 -0
  98. package/dist/store/sqlite.d.ts +68 -3
  99. package/dist/store/sqlite.d.ts.map +1 -1
  100. package/dist/store/sqlite.js +708 -58
  101. package/dist/store/sqlite.js.map +1 -1
  102. package/dist/store/vector-store.d.ts +66 -0
  103. package/dist/store/vector-store.d.ts.map +1 -0
  104. package/dist/store/vector-store.js +330 -0
  105. package/dist/store/vector-store.js.map +1 -0
  106. package/dist/tools/analyze-impact.d.ts +0 -0
  107. package/dist/tools/analyze-impact.d.ts.map +1 -1
  108. package/dist/tools/analyze-impact.js +101 -39
  109. package/dist/tools/analyze-impact.js.map +1 -1
  110. package/dist/tools/deep-explore.d.ts +0 -0
  111. package/dist/tools/deep-explore.d.ts.map +1 -1
  112. package/dist/tools/deep-explore.js +18 -4
  113. package/dist/tools/deep-explore.js.map +1 -1
  114. package/dist/tools/detect-cycles.d.ts +3 -0
  115. package/dist/tools/detect-cycles.d.ts.map +1 -0
  116. package/dist/tools/detect-cycles.js +61 -0
  117. package/dist/tools/detect-cycles.js.map +1 -0
  118. package/dist/tools/detect-dead-code.d.ts +0 -0
  119. package/dist/tools/detect-dead-code.d.ts.map +1 -1
  120. package/dist/tools/detect-dead-code.js +1 -0
  121. package/dist/tools/detect-dead-code.js.map +1 -1
  122. package/dist/tools/explore.d.ts +13 -0
  123. package/dist/tools/explore.d.ts.map +1 -1
  124. package/dist/tools/explore.js +122 -88
  125. package/dist/tools/explore.js.map +1 -1
  126. package/dist/tools/get-context.d.ts +15 -0
  127. package/dist/tools/get-context.d.ts.map +1 -1
  128. package/dist/tools/get-context.js +66 -61
  129. package/dist/tools/get-context.js.map +1 -1
  130. package/dist/tools/graph-explain.d.ts +3 -0
  131. package/dist/tools/graph-explain.d.ts.map +1 -0
  132. package/dist/tools/graph-explain.js +140 -0
  133. package/dist/tools/graph-explain.js.map +1 -0
  134. package/dist/tools/graph-stats.d.ts +3 -0
  135. package/dist/tools/graph-stats.d.ts.map +1 -0
  136. package/dist/tools/graph-stats.js +73 -0
  137. package/dist/tools/graph-stats.js.map +1 -0
  138. package/dist/tools/inspect-symbol.d.ts +10 -0
  139. package/dist/tools/inspect-symbol.d.ts.map +1 -1
  140. package/dist/tools/inspect-symbol.js +112 -101
  141. package/dist/tools/inspect-symbol.js.map +1 -1
  142. package/dist/tools/ping.d.ts +0 -0
  143. package/dist/tools/ping.d.ts.map +1 -1
  144. package/dist/tools/ping.js +2 -3
  145. package/dist/tools/ping.js.map +1 -1
  146. package/dist/tools/profile.d.ts +9 -0
  147. package/dist/tools/profile.d.ts.map +1 -0
  148. package/dist/tools/profile.js +101 -0
  149. package/dist/tools/profile.js.map +1 -0
  150. package/dist/tools/query-graph.d.ts +3 -0
  151. package/dist/tools/query-graph.d.ts.map +1 -0
  152. package/dist/tools/query-graph.js +157 -0
  153. package/dist/tools/query-graph.js.map +1 -0
  154. package/dist/tools/recall-experience.d.ts +0 -0
  155. package/dist/tools/recall-experience.d.ts.map +1 -1
  156. package/dist/tools/recall-experience.js +5 -5
  157. package/dist/tools/recall-experience.js.map +1 -1
  158. package/dist/tools/record-experience.d.ts +0 -0
  159. package/dist/tools/record-experience.d.ts.map +1 -1
  160. package/dist/tools/record-experience.js +2 -5
  161. package/dist/tools/record-experience.js.map +1 -1
  162. package/dist/tools/reflect.d.ts +4 -0
  163. package/dist/tools/reflect.d.ts.map +1 -0
  164. package/dist/tools/reflect.js +99 -0
  165. package/dist/tools/reflect.js.map +1 -0
  166. package/dist/tools/register.d.ts +5 -2
  167. package/dist/tools/register.d.ts.map +1 -1
  168. package/dist/tools/register.js +31 -1
  169. package/dist/tools/register.js.map +1 -1
  170. package/dist/tools/reindex.d.ts +0 -0
  171. package/dist/tools/reindex.d.ts.map +1 -1
  172. package/dist/tools/reindex.js +102 -21
  173. package/dist/tools/reindex.js.map +1 -1
  174. package/dist/tools/repo-overview.d.ts +0 -0
  175. package/dist/tools/repo-overview.d.ts.map +1 -1
  176. package/dist/tools/repo-overview.js +8 -6
  177. package/dist/tools/repo-overview.js.map +1 -1
  178. package/dist/tools/run-validation.d.ts +0 -0
  179. package/dist/tools/run-validation.d.ts.map +1 -1
  180. package/dist/tools/run-validation.js +15 -1
  181. package/dist/tools/run-validation.js.map +1 -1
  182. package/dist/tools/search-code.d.ts +0 -0
  183. package/dist/tools/search-code.d.ts.map +1 -1
  184. package/dist/tools/search-code.js +11 -4
  185. package/dist/tools/search-code.js.map +1 -1
  186. package/dist/tools/search-history.d.ts +0 -0
  187. package/dist/tools/search-history.d.ts.map +0 -0
  188. package/dist/tools/search-history.js +0 -0
  189. package/dist/tools/search-history.js.map +0 -0
  190. package/dist/tools/session-heartbeat.d.ts +0 -0
  191. package/dist/tools/session-heartbeat.d.ts.map +1 -1
  192. package/dist/tools/session-heartbeat.js +1 -0
  193. package/dist/tools/session-heartbeat.js.map +1 -1
  194. package/dist/tools/session-status.d.ts +0 -0
  195. package/dist/tools/session-status.d.ts.map +1 -1
  196. package/dist/tools/session-status.js +1 -0
  197. package/dist/tools/session-status.js.map +1 -1
  198. package/dist/tools/status.d.ts +0 -0
  199. package/dist/tools/status.d.ts.map +1 -1
  200. package/dist/tools/status.js +87 -24
  201. package/dist/tools/status.js.map +1 -1
  202. package/dist/tools/trace-bfs.d.ts +13 -0
  203. package/dist/tools/trace-bfs.d.ts.map +1 -0
  204. package/dist/tools/trace-bfs.js +251 -0
  205. package/dist/tools/trace-bfs.js.map +1 -0
  206. package/dist/tools/trace-flow.d.ts +1 -1
  207. package/dist/tools/trace-flow.d.ts.map +1 -1
  208. package/dist/tools/trace-flow.js +113 -69
  209. package/dist/tools/trace-flow.js.map +1 -1
  210. package/dist/types.d.ts +75 -13
  211. package/dist/types.d.ts.map +1 -1
  212. package/dist/types.js +15 -0
  213. package/dist/types.js.map +1 -1
  214. package/grammars/tree-sitter-c.wasm +0 -0
  215. package/grammars/tree-sitter-cpp.wasm +0 -0
  216. package/grammars/tree-sitter-go.wasm +0 -0
  217. package/grammars/tree-sitter-java.wasm +0 -0
  218. package/grammars/tree-sitter-javascript.wasm +0 -0
  219. package/grammars/tree-sitter-python.wasm +0 -0
  220. package/grammars/tree-sitter-rust.wasm +0 -0
  221. package/grammars/tree-sitter-tsx.wasm +0 -0
  222. package/grammars/tree-sitter-typescript.wasm +0 -0
  223. package/package.json +8 -6
@@ -211,6 +211,66 @@ const MIGRATIONS = [
211
211
  `CREATE UNIQUE INDEX IF NOT EXISTS idx_graph_nodes_symbol ON graph_nodes(symbol_id)`,
212
212
  ],
213
213
  },
214
+ {
215
+ version: 6,
216
+ name: "graph-confidence-community",
217
+ sql: [
218
+ `ALTER TABLE graph_edges ADD COLUMN confidence TEXT NOT NULL DEFAULT 'EXTRACTED'`,
219
+ `ALTER TABLE graph_nodes ADD COLUMN community INTEGER DEFAULT NULL`,
220
+ `CREATE INDEX IF NOT EXISTS idx_graph_nodes_community ON graph_nodes(community)`,
221
+ `CREATE INDEX IF NOT EXISTS idx_graph_edges_confidence ON graph_edges(confidence)`,
222
+ ],
223
+ },
224
+ {
225
+ version: 7,
226
+ name: "graph-enhancements",
227
+ sql: [
228
+ // ── Provenance: track where edges were extracted from ──
229
+ `ALTER TABLE graph_edges ADD COLUMN provenance TEXT DEFAULT ''`,
230
+ // ── Trigram FTS for CJK support ──
231
+ `CREATE VIRTUAL TABLE IF NOT EXISTS nodes_trigram_fts USING fts5(
232
+ name, file, signature,
233
+ content='graph_nodes',
234
+ content_rowid='id',
235
+ tokenize='unicode61 tokenchars ''\\u4e00-\\u9fff\\u3040-\\u309f\\u30a0-\\u30ff\\uac00-\\ud7af'''
236
+ )`,
237
+ `CREATE TRIGGER IF NOT EXISTS nodes_trigram_fts_ai AFTER INSERT ON graph_nodes BEGIN
238
+ INSERT INTO nodes_trigram_fts(rowid, name, file, signature)
239
+ VALUES (NEW.id, NEW.name, NEW.file, NEW.signature);
240
+ END`,
241
+ `CREATE TRIGGER IF NOT EXISTS nodes_trigram_fts_ad AFTER DELETE ON graph_nodes BEGIN
242
+ INSERT INTO nodes_trigram_fts(nodes_trigram_fts, rowid, name, file, signature)
243
+ VALUES ('delete', OLD.id, OLD.name, OLD.file, OLD.signature);
244
+ END`,
245
+ `CREATE TRIGGER IF NOT EXISTS nodes_trigram_fts_au AFTER UPDATE ON graph_nodes BEGIN
246
+ INSERT INTO nodes_trigram_fts(nodes_trigram_fts, rowid, name, file, signature)
247
+ VALUES ('delete', OLD.id, OLD.name, OLD.file, OLD.signature);
248
+ INSERT INTO nodes_trigram_fts(rowid, name, file, signature)
249
+ VALUES (NEW.id, NEW.name, NEW.file, NEW.signature);
250
+ END`,
251
+ `INSERT OR IGNORE INTO nodes_trigram_fts(rowid, name, file, signature)
252
+ SELECT id, name, file, signature FROM graph_nodes`,
253
+ // ── name_segment_vocab: materialized camelCase/snake_case parts ──
254
+ `CREATE TABLE IF NOT EXISTS name_segment_vocab (
255
+ segment TEXT NOT NULL,
256
+ node_id INTEGER NOT NULL REFERENCES graph_nodes(id) ON DELETE CASCADE,
257
+ UNIQUE(segment, node_id)
258
+ )`,
259
+ `CREATE INDEX IF NOT EXISTS idx_segment_vocab_segment ON name_segment_vocab(segment)`,
260
+ `CREATE INDEX IF NOT EXISTS idx_segment_vocab_node ON name_segment_vocab(node_id)`,
261
+ // ── Session reflections table (work memory) ──
262
+ `CREATE TABLE IF NOT EXISTS session_reflections (
263
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
264
+ session_id TEXT NOT NULL,
265
+ goal TEXT NOT NULL,
266
+ task TEXT NOT NULL,
267
+ symbols TEXT DEFAULT '[]',
268
+ files TEXT DEFAULT '[]',
269
+ timestamp TEXT NOT NULL DEFAULT (datetime('now'))
270
+ )`,
271
+ `CREATE INDEX IF NOT EXISTS idx_reflections_session ON session_reflections(session_id)`,
272
+ ],
273
+ },
214
274
  ];
215
275
  export class SQLiteStore {
216
276
  db = null;
@@ -356,7 +416,8 @@ export class SQLiteStore {
356
416
  }
357
417
  clearSymbols() {
358
418
  this.ensureOpen();
359
- this.db.exec("DELETE FROM symbols; DELETE FROM relations");
419
+ // Keep symbols + relations + graph in lockstep (single source of truth)
420
+ this.db.exec("DELETE FROM graph_edges; DELETE FROM graph_nodes; DELETE FROM symbols; DELETE FROM relations");
360
421
  }
361
422
  // ── Full-text search (SQLite-only, requires migration v4) ──
362
423
  // These methods use the FTS5 virtual table for fast symbol search.
@@ -423,10 +484,32 @@ export class SQLiteStore {
423
484
  const name = key.split(":")[1];
424
485
  nameToId.set(name, id);
425
486
  }
487
+ // Insert name segments for every node (camelCase/snake_case splitting)
488
+ const segmentInsert = this.db.prepare(`
489
+ INSERT OR IGNORE INTO name_segment_vocab (segment, node_id)
490
+ VALUES (?, ?)
491
+ `);
492
+ const segmentBatch = this.db.transaction((segments) => {
493
+ for (const { seg, nodeId } of segments) {
494
+ segmentInsert.run(seg, nodeId);
495
+ }
496
+ });
497
+ const segmentEntries = [];
498
+ for (const [key, id] of nodeIds) {
499
+ const name = key.split(":")[1];
500
+ // Split on camelCase boundaries and snake_case
501
+ const parts = name.split(/(?<=[a-z])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])|_|-/).filter(p => p.length >= 2);
502
+ for (const part of parts) {
503
+ segmentEntries.push({ seg: part.toLowerCase(), nodeId: id });
504
+ }
505
+ // Also add the full lowercase name as a segment for prefix matching
506
+ segmentEntries.push({ seg: name.toLowerCase(), nodeId: id });
507
+ }
508
+ segmentBatch(segmentEntries);
426
509
  // Insert edges from call/called_by/imports in symbols
427
510
  const insertEdge = this.db.prepare(`
428
- INSERT OR IGNORE INTO graph_edges (source_node_id, target_node_id, kind)
429
- VALUES (?, ?, ?)
511
+ INSERT OR IGNORE INTO graph_edges (source_node_id, target_node_id, kind, confidence, provenance)
512
+ VALUES (?, ?, ?, 'EXTRACTED', ?)
430
513
  `);
431
514
  const parseArr = (val) => {
432
515
  if (Array.isArray(val))
@@ -449,7 +532,7 @@ export class SQLiteStore {
449
532
  for (const targetName of parseArr(sym.calls)) {
450
533
  const targetId = nameToId.get(targetName);
451
534
  if (targetId && targetId !== myNodeId) {
452
- insertEdge.run(myNodeId, targetId, "calls");
535
+ insertEdge.run(myNodeId, targetId, "calls", `call in ${sym.file}:${sym.line}`);
453
536
  }
454
537
  }
455
538
  }
@@ -458,7 +541,7 @@ export class SQLiteStore {
458
541
  const sourceId = nameToId.get(rel.source);
459
542
  const targetId = nameToId.get(rel.target);
460
543
  if (sourceId && targetId && sourceId !== targetId) {
461
- insertEdge.run(sourceId, targetId, rel.kind);
544
+ insertEdge.run(sourceId, targetId, rel.kind, `relation ${rel.kind}`);
462
545
  }
463
546
  }
464
547
  })();
@@ -478,54 +561,41 @@ export class SQLiteStore {
478
561
  signature: row.signature,
479
562
  line: row.line ?? undefined,
480
563
  column: row.column ?? undefined,
564
+ community: row.community ?? undefined,
481
565
  };
482
566
  }
483
- /** Get edges for a graph node. */
567
+ /** Get edges for a graph node — single query with OR for direction="both". */
484
568
  getGraphEdges(nodeId, direction = "both", kind) {
485
569
  this.ensureOpen();
486
- let query;
487
570
  const params = [];
488
- if (direction === "incoming" || direction === "both") {
489
- query = "SELECT id, source_node_id, target_node_id, kind, line, \"column\" FROM graph_edges WHERE target_node_id = ?";
571
+ let whereClause;
572
+ if (direction === "incoming") {
573
+ whereClause = "target_node_id = ?";
490
574
  params.push(nodeId);
491
575
  }
492
- else {
493
- query = "SELECT id, source_node_id, target_node_id, kind, line, \"column\" FROM graph_edges WHERE source_node_id = ?";
576
+ else if (direction === "outgoing") {
577
+ whereClause = "source_node_id = ?";
494
578
  params.push(nodeId);
495
579
  }
580
+ else {
581
+ // Single query with OR instead of two separate queries
582
+ whereClause = "(source_node_id = ? OR target_node_id = ?)";
583
+ params.push(nodeId, nodeId);
584
+ }
496
585
  if (kind) {
497
- query += " AND kind = ?";
586
+ whereClause += " AND kind = ?";
498
587
  params.push(kind);
499
588
  }
500
- const rows = this.db.prepare(query).all(...params);
501
- let edges = rows.map(r => ({
589
+ const rows = this.db.prepare(`SELECT id, source_node_id, target_node_id, kind, line, "column", confidence FROM graph_edges WHERE ${whereClause}`).all(...params);
590
+ return rows.map(r => ({
502
591
  id: r.id,
503
592
  sourceNodeId: r.source_node_id,
504
593
  targetNodeId: r.target_node_id,
505
594
  kind: r.kind,
506
595
  line: r.line ?? undefined,
507
596
  column: r.column ?? undefined,
597
+ confidence: String(r.confidence ?? "EXTRACTED"),
508
598
  }));
509
- // For "both", also query incoming edges
510
- if (direction === "both") {
511
- let incomingQuery = "SELECT id, source_node_id, target_node_id, kind, line, \"column\" FROM graph_edges WHERE source_node_id = ?";
512
- if (kind)
513
- incomingQuery += " AND kind = ?";
514
- const incomingParams = [nodeId];
515
- if (kind)
516
- incomingParams.push(kind);
517
- const incomingRows = this.db.prepare(incomingQuery).all(...incomingParams);
518
- const incomingEdges = incomingRows.map(r => ({
519
- id: r.id,
520
- sourceNodeId: r.source_node_id,
521
- targetNodeId: r.target_node_id,
522
- kind: r.kind,
523
- line: r.line ?? undefined,
524
- column: r.column ?? undefined,
525
- }));
526
- edges = [...edges, ...incomingEdges];
527
- }
528
- return edges;
529
599
  }
530
600
  /** Search graph nodes by name (LIKE fallback). */
531
601
  searchGraphNodes(query, limit = 20) {
@@ -544,6 +614,7 @@ export class SQLiteStore {
544
614
  signature: r.signature,
545
615
  line: r.line ?? undefined,
546
616
  column: r.column ?? undefined,
617
+ community: r.community != null ? Number(r.community) : undefined,
547
618
  }));
548
619
  }
549
620
  /** Search graph nodes by name using FTS5. */
@@ -650,49 +721,64 @@ export class SQLiteStore {
650
721
  return [];
651
722
  }
652
723
  }
653
- /** Find a path between two graph nodes using BFS. */
724
+ /** Find a path between two graph nodes using BFS — pre-loads all edges for O(1) traversal. */
654
725
  findGraphPath(fromNodeId, toNodeId, maxDepth = 5) {
655
726
  this.ensureOpen();
656
- // BFS on graph_edges
727
+ // Pre-load all edges into adjacency maps (one query instead of N)
728
+ const outgoing = new Map();
729
+ const incoming = new Map();
730
+ const allEdges = this.db.prepare("SELECT * FROM graph_edges").all();
731
+ for (const e of allEdges) {
732
+ if (!outgoing.has(e.source_node_id))
733
+ outgoing.set(e.source_node_id, []);
734
+ outgoing.get(e.source_node_id).push(e);
735
+ if (!incoming.has(e.target_node_id))
736
+ incoming.set(e.target_node_id, []);
737
+ incoming.get(e.target_node_id).push(e);
738
+ }
739
+ // BFS with index pointer (O(1) dequeue)
657
740
  const visited = new Set();
658
741
  const parent = new Map();
659
742
  const queue = [fromNodeId];
743
+ let head = 0;
660
744
  visited.add(fromNodeId);
661
- for (let depth = 0; queue.length > 0 && depth < maxDepth; depth++) {
662
- const levelSize = queue.length;
745
+ for (let depth = 0; head < queue.length && depth < maxDepth; depth++) {
746
+ const levelSize = queue.length - head;
663
747
  for (let i = 0; i < levelSize; i++) {
664
- const currentId = queue.shift();
748
+ const currentId = queue[head++];
665
749
  if (currentId === toNodeId) {
666
750
  // Reconstruct path
667
751
  const path = [];
668
752
  let nodeId = toNodeId;
669
753
  while (parent.has(nodeId)) {
670
754
  const p = parent.get(nodeId);
671
- path.unshift(p.edge);
755
+ path.unshift({
756
+ id: p.edge.id,
757
+ sourceNodeId: p.edge.source_node_id,
758
+ targetNodeId: p.edge.target_node_id,
759
+ kind: p.edge.kind,
760
+ line: p.edge.line ?? undefined,
761
+ column: p.edge.column ?? undefined,
762
+ confidence: String(p.edge.confidence ?? "EXTRACTED"),
763
+ });
672
764
  nodeId = p.parentId;
673
765
  }
674
766
  return path;
675
767
  }
676
- // Get outgoing edges from current node
677
- const edges = this.db
678
- .prepare("SELECT * FROM graph_edges WHERE source_node_id = ?")
679
- .all(currentId);
680
- for (const edge of edges) {
681
- if (!visited.has(edge.target_node_id)) {
682
- visited.add(edge.target_node_id);
683
- parent.set(edge.target_node_id, { parentId: currentId, edge });
684
- queue.push(edge.target_node_id);
768
+ for (const edge of outgoing.get(currentId) ?? []) {
769
+ const nid = edge.target_node_id;
770
+ if (!visited.has(nid)) {
771
+ visited.add(nid);
772
+ parent.set(nid, { parentId: currentId, edge });
773
+ queue.push(nid);
685
774
  }
686
775
  }
687
- // Get incoming edges
688
- const inEdges = this.db
689
- .prepare("SELECT * FROM graph_edges WHERE target_node_id = ?")
690
- .all(currentId);
691
- for (const edge of inEdges) {
692
- if (!visited.has(edge.source_node_id)) {
693
- visited.add(edge.source_node_id);
694
- parent.set(edge.source_node_id, { parentId: currentId, edge });
695
- queue.push(edge.source_node_id);
776
+ for (const edge of incoming.get(currentId) ?? []) {
777
+ const nid = edge.source_node_id;
778
+ if (!visited.has(nid)) {
779
+ visited.add(nid);
780
+ parent.set(nid, { parentId: currentId, edge });
781
+ queue.push(nid);
696
782
  }
697
783
  }
698
784
  }
@@ -704,6 +790,34 @@ export class SQLiteStore {
704
790
  this.ensureOpen();
705
791
  this.db.prepare("DELETE FROM graph_nodes WHERE file = ?").run(file);
706
792
  }
793
+ /**
794
+ * Find test files related to a source file by naming convention.
795
+ * Tests are found by matching `<name>.test.<ext>`, `<name>.spec.<ext>`,
796
+ * or `test/<name>.<ext>` patterns against the source file's basename.
797
+ */
798
+ findRelatedTests(filePath) {
799
+ this.ensureOpen();
800
+ const basename = filePath.split("/").pop()?.split(".")[0] ?? "";
801
+ if (!basename)
802
+ return [];
803
+ const rows = this.db
804
+ .prepare(`SELECT DISTINCT name FROM symbols
805
+ WHERE (file LIKE ? OR file LIKE ? OR file LIKE ?)
806
+ AND (file LIKE '%.test.%' OR file LIKE '%.spec.%' OR file LIKE '%/test/%')`)
807
+ .all(`%${basename}%`, `%${basename}.test.%`, `%${basename}.spec.%`);
808
+ return rows.map(r => r.name);
809
+ }
810
+ /**
811
+ * Find files in a scope (directory prefix match).
812
+ */
813
+ findFilesInScope(scope) {
814
+ this.ensureOpen();
815
+ const dirPrefix = scope.endsWith("/") ? scope : `${scope}/`;
816
+ const rows = this.db
817
+ .prepare("SELECT DISTINCT file FROM symbols WHERE file LIKE ?")
818
+ .all(`${dirPrefix}%`);
819
+ return rows.map(r => r.file);
820
+ }
707
821
  /** Clear all graph data. */
708
822
  clearGraph() {
709
823
  this.ensureOpen();
@@ -1015,6 +1129,542 @@ export class SQLiteStore {
1015
1129
  source: row.source,
1016
1130
  };
1017
1131
  }
1132
+ // ── Graph traversal (BFS) ─────────────────────────────
1133
+ /**
1134
+ * Look up a graph node by name.
1135
+ * Returns the first matching node if multiple exist.
1136
+ */
1137
+ getGraphNodeByName(name) {
1138
+ this.ensureOpen();
1139
+ const row = this.db
1140
+ .prepare("SELECT * FROM graph_nodes WHERE name = ? LIMIT 1")
1141
+ .get(name);
1142
+ if (!row)
1143
+ return null;
1144
+ return {
1145
+ id: Number(row.id),
1146
+ symbolId: row.symbol_id ? Number(row.symbol_id) : undefined,
1147
+ file: String(row.file ?? ""),
1148
+ name: String(row.name ?? ""),
1149
+ kind: String(row.kind ?? ""),
1150
+ signature: String(row.signature ?? ""),
1151
+ line: row.line ? Number(row.line) : undefined,
1152
+ column: row.column ? Number(row.column) : undefined,
1153
+ community: row.community ? Number(row.community) : undefined,
1154
+ };
1155
+ }
1156
+ /**
1157
+ * BFS traversal from a named graph node using a recursive CTE.
1158
+ * Returns a SubgraphResult with all reachable nodes, edges, and stats.
1159
+ *
1160
+ * The recursive CTE handles cycle detection via a visited-set path column,
1161
+ * so a diamond pattern is traversed exactly once per node.
1162
+ */
1163
+ traverseBFS(nodeName, opts) {
1164
+ this.ensureOpen();
1165
+ const maxDepth = opts?.maxDepth ?? 5;
1166
+ const limit = opts?.limit ?? 100;
1167
+ const direction = opts?.direction ?? "outgoing";
1168
+ const edgeKinds = opts?.edgeKinds;
1169
+ const nodeKinds = opts?.nodeKinds;
1170
+ // First, resolve the start node
1171
+ const startNode = this.getGraphNodeByName(nodeName);
1172
+ if (!startNode) {
1173
+ return { nodes: [], edges: [], stats: { depth: 0, nodeCount: 0, edgeCount: 0, truncated: false } };
1174
+ }
1175
+ // Build edge direction condition for the CTE
1176
+ // Note: recursive term uses "bfs" alias, not "n"
1177
+ let edgeJoin;
1178
+ let nextIdExpr;
1179
+ if (direction === "incoming") {
1180
+ edgeJoin = "graph_edges e ON e.target_node_id = bfs.id";
1181
+ nextIdExpr = "e.source_node_id";
1182
+ }
1183
+ else if (direction === "both") {
1184
+ edgeJoin = "graph_edges e ON (e.source_node_id = bfs.id OR e.target_node_id = bfs.id)";
1185
+ nextIdExpr = "CASE WHEN e.source_node_id = bfs.id THEN e.target_node_id ELSE e.source_node_id END";
1186
+ }
1187
+ else {
1188
+ // outgoing (default)
1189
+ edgeJoin = "graph_edges e ON e.source_node_id = bfs.id";
1190
+ nextIdExpr = "e.target_node_id";
1191
+ }
1192
+ let kindFilter = "";
1193
+ const params = [startNode.id, maxDepth];
1194
+ if (edgeKinds && edgeKinds.length > 0) {
1195
+ kindFilter = ` AND e.kind IN (${edgeKinds.map(() => "?").join(",")})`;
1196
+ params.push(...edgeKinds);
1197
+ }
1198
+ params.push(limit);
1199
+ // Recursive CTE for BFS
1200
+ const cte = `
1201
+ WITH RECURSIVE bfs AS (
1202
+ -- Anchor: start node at depth 0
1203
+ SELECT n.id, n.name, n.kind, n.file, n.signature, n.line, n."column",
1204
+ n.symbol_id, 0 AS depth,
1205
+ ',' || CAST(n.id AS TEXT) || ',' AS visited_path
1206
+ FROM graph_nodes n
1207
+ WHERE n.id = ?
1208
+
1209
+ UNION ALL
1210
+
1211
+ -- Recursive: follow edges to unvisited nodes
1212
+ SELECT gn.id, gn.name, gn.kind, gn.file, gn.signature,
1213
+ gn.line, gn."column", gn.symbol_id,
1214
+ bfs.depth + 1,
1215
+ bfs.visited_path || CAST(gn.id AS TEXT) || ','
1216
+ FROM bfs
1217
+ JOIN ${edgeJoin}
1218
+ JOIN graph_nodes gn ON gn.id = ${nextIdExpr}
1219
+ WHERE bfs.depth < ?
1220
+ AND instr(bfs.visited_path, ',' || CAST(gn.id AS TEXT) || ',') = 0
1221
+ ${kindFilter}
1222
+ )
1223
+ SELECT DISTINCT id, name, kind, file, signature, line, "column", symbol_id, depth
1224
+ FROM bfs
1225
+ ORDER BY depth, name
1226
+ LIMIT ?
1227
+ `;
1228
+ const nodeRows = this.db.prepare(cte).all(...params);
1229
+ // Build depth map for O(1) lookup (avoid O(n²) .find() in stats)
1230
+ const depthMap = new Map();
1231
+ for (const r of nodeRows) {
1232
+ depthMap.set(Number(r.id), Number(r.depth));
1233
+ }
1234
+ const nodes = nodeRows.map(r => ({
1235
+ id: Number(r.id),
1236
+ name: String(r.name ?? ""),
1237
+ kind: String(r.kind ?? ""),
1238
+ file: String(r.file ?? ""),
1239
+ signature: String(r.signature ?? ""),
1240
+ line: r.line ? Number(r.line) : undefined,
1241
+ column: r.column ? Number(r.column) : undefined,
1242
+ symbolId: r.symbol_id ? Number(r.symbol_id) : undefined,
1243
+ }));
1244
+ const nodeIds = nodes.map(n => n.id);
1245
+ const truncated = nodes.length >= limit;
1246
+ // Fetch all edges between the returned nodes
1247
+ let edges = [];
1248
+ if (nodeIds.length > 0) {
1249
+ const placeholders = nodeIds.map(() => "?").join(",");
1250
+ let edgeQuery = `
1251
+ SELECT id, source_node_id, target_node_id, kind, line, "column", confidence
1252
+ FROM graph_edges
1253
+ WHERE source_node_id IN (${placeholders}) AND target_node_id IN (${placeholders})
1254
+ `;
1255
+ let edgeParams = [...nodeIds, ...nodeIds];
1256
+ if (edgeKinds && edgeKinds.length > 0) {
1257
+ edgeQuery += ` AND kind IN (${edgeKinds.map(() => "?").join(",")})`;
1258
+ edgeParams = [...edgeParams, ...edgeKinds];
1259
+ }
1260
+ const edgeRows = this.db.prepare(edgeQuery).all(...edgeParams);
1261
+ edges = edgeRows.map(r => ({
1262
+ id: Number(r.id),
1263
+ sourceNodeId: Number(r.source_node_id),
1264
+ targetNodeId: Number(r.target_node_id),
1265
+ kind: String(r.kind ?? ""),
1266
+ line: r.line ? Number(r.line) : undefined,
1267
+ column: r.column ? Number(r.column) : undefined,
1268
+ confidence: String(r.confidence ?? "EXTRACTED"),
1269
+ }));
1270
+ }
1271
+ // Apply nodeKind filter post-CTE (since recursive CTE can't filter by code)
1272
+ let filteredNodes = nodes;
1273
+ if (nodeKinds && nodeKinds.length > 0) {
1274
+ const nodeKindSet = new Set(nodeKinds);
1275
+ filteredNodes = nodes.filter(n => nodeKindSet.has(n.kind));
1276
+ if (filteredNodes.length === 0) {
1277
+ return { nodes: [], edges: [], stats: { depth: 0, nodeCount: 0, edgeCount: 0, truncated: false } };
1278
+ }
1279
+ const filteredIds = new Set(filteredNodes.map(n => n.id));
1280
+ edges = edges.filter(e => filteredIds.has(e.sourceNodeId) && filteredIds.has(e.targetNodeId));
1281
+ }
1282
+ return {
1283
+ nodes: filteredNodes,
1284
+ edges,
1285
+ stats: {
1286
+ depth: Math.min(maxDepth, Math.max(...filteredNodes.map(n => depthMap.get(n.id) ?? 0), 0)),
1287
+ nodeCount: filteredNodes.length,
1288
+ edgeCount: edges.length,
1289
+ truncated,
1290
+ },
1291
+ };
1292
+ }
1293
+ /**
1294
+ * Find the shortest directed path between two named graph nodes.
1295
+ * Uses a bidirectional BFS strategy: a recursive CTE anchored at the start
1296
+ * searches for the end node, tracking paths.
1297
+ *
1298
+ * Returns the sequence of edges forming the path, or null if unreachable.
1299
+ */
1300
+ traverseBFSCallPath(startNode, endNode, opts) {
1301
+ this.ensureOpen();
1302
+ const maxDepth = opts?.maxDepth ?? 10;
1303
+ const direction = opts?.direction ?? "outgoing";
1304
+ const start = this.getGraphNodeByName(startNode);
1305
+ const end = this.getGraphNodeByName(endNode);
1306
+ if (!start || !end)
1307
+ return null;
1308
+ if (start.id === end.id)
1309
+ return [];
1310
+ // Build direction condition
1311
+ // Note: recursive term uses "ps" alias, not "n"
1312
+ let nextIdExpr;
1313
+ let joinOnEnd;
1314
+ if (direction === "incoming") {
1315
+ nextIdExpr = "e.source_node_id";
1316
+ joinOnEnd = "graph_edges e ON e.target_node_id = ps.id";
1317
+ }
1318
+ else if (direction === "both") {
1319
+ nextIdExpr = "CASE WHEN e.source_node_id = ps.id THEN e.target_node_id ELSE e.source_node_id END";
1320
+ joinOnEnd = "graph_edges e ON (e.source_node_id = ps.id OR e.target_node_id = ps.id)";
1321
+ }
1322
+ else {
1323
+ nextIdExpr = "e.target_node_id";
1324
+ joinOnEnd = "graph_edges e ON e.source_node_id = ps.id";
1325
+ }
1326
+ const cte = `
1327
+ WITH RECURSIVE path_search AS (
1328
+ SELECT n.id,
1329
+ ',' || CAST(n.id AS TEXT) || ',' AS path_ids,
1330
+ '' AS edge_ids
1331
+ FROM graph_nodes n WHERE n.id = ?
1332
+
1333
+ UNION ALL
1334
+
1335
+ SELECT gn.id,
1336
+ ps.path_ids || CAST(gn.id AS TEXT) || ',',
1337
+ ps.edge_ids || CAST(e.id AS TEXT) || ','
1338
+ FROM path_search ps
1339
+ JOIN ${joinOnEnd}
1340
+ JOIN graph_nodes gn ON gn.id = ${nextIdExpr}
1341
+ WHERE ps.id != ?
1342
+ AND instr(ps.path_ids, ',' || CAST(gn.id AS TEXT) || ',') = 0
1343
+ AND length(ps.path_ids) - length(replace(ps.path_ids, ',', '')) < ?
1344
+ )
1345
+ SELECT edge_ids FROM path_search
1346
+ WHERE instr(path_ids, ',' || CAST(? AS TEXT) || ',') > 0
1347
+ ORDER BY length(path_ids) - length(replace(path_ids, ',', '')) ASC
1348
+ LIMIT 1
1349
+ `;
1350
+ const row = this.db.prepare(cte).get(start.id, end.id, maxDepth, end.id);
1351
+ if (!row)
1352
+ return null;
1353
+ const edgeIdStr = String(row.edge_ids ?? "");
1354
+ if (!edgeIdStr)
1355
+ return [];
1356
+ const edgeIds = edgeIdStr.split(",").filter(Boolean).map(Number);
1357
+ if (edgeIds.length === 0)
1358
+ return [];
1359
+ // Fetch edges in path order
1360
+ const placeholders = edgeIds.map(() => "?").join(",");
1361
+ const edgeRows = this.db
1362
+ .prepare(`SELECT id, source_node_id, target_node_id, kind, line, "column", confidence FROM graph_edges WHERE id IN (${placeholders})`)
1363
+ .all(...edgeIds);
1364
+ const edgeMap = new Map();
1365
+ for (const r of edgeRows) {
1366
+ const e = {
1367
+ id: Number(r.id),
1368
+ sourceNodeId: Number(r.source_node_id),
1369
+ targetNodeId: Number(r.target_node_id),
1370
+ kind: String(r.kind ?? ""),
1371
+ line: r.line ? Number(r.line) : undefined,
1372
+ column: r.column ? Number(r.column) : undefined,
1373
+ confidence: String(r.confidence ?? "EXTRACTED"),
1374
+ };
1375
+ edgeMap.set(e.id, e);
1376
+ }
1377
+ return edgeIds.map(id => edgeMap.get(id)).filter((e) => e !== undefined);
1378
+ }
1379
+ // ── Graph analysis ──────────────────────────────────
1380
+ /**
1381
+ * Assign community IDs to graph nodes via connected-components analysis.
1382
+ * Uses Union-Find (DSU) with path compression, O(n α(n)) time.
1383
+ * Each connected component gets the minimum node ID as its community value.
1384
+ * Returns the number of communities found.
1385
+ */
1386
+ computeCommunities() {
1387
+ this.ensureOpen();
1388
+ // Load all nodes and edges
1389
+ const nodeIds = this.db.prepare("SELECT id FROM graph_nodes").all().map(r => r.id);
1390
+ const edges = this.db.prepare("SELECT source_node_id, target_node_id FROM graph_edges").all();
1391
+ // Union-Find (DSU) with path compression + union by rank
1392
+ const parent = new Map();
1393
+ const rank = new Map();
1394
+ function find(x) {
1395
+ let p = parent.get(x);
1396
+ if (p === undefined || p === x)
1397
+ return x;
1398
+ // Path compression
1399
+ const root = find(p);
1400
+ parent.set(x, root);
1401
+ return root;
1402
+ }
1403
+ function union(a, b) {
1404
+ const ra = find(a);
1405
+ const rb = find(b);
1406
+ if (ra === rb)
1407
+ return;
1408
+ // Union by rank — attach smaller tree under larger
1409
+ const raRank = rank.get(ra) ?? 0;
1410
+ const rbRank = rank.get(rb) ?? 0;
1411
+ if (raRank < rbRank) {
1412
+ parent.set(ra, rb);
1413
+ }
1414
+ else if (raRank > rbRank) {
1415
+ parent.set(rb, ra);
1416
+ }
1417
+ else {
1418
+ parent.set(rb, ra);
1419
+ rank.set(ra, (rank.get(ra) ?? 0) + 1);
1420
+ }
1421
+ }
1422
+ // Initialize each node as its own parent
1423
+ for (const id of nodeIds) {
1424
+ parent.set(id, id);
1425
+ rank.set(id, 0);
1426
+ }
1427
+ // Union each edge
1428
+ for (const edge of edges) {
1429
+ union(edge.source_node_id, edge.target_node_id);
1430
+ }
1431
+ // Assign community = minimum node ID in each component
1432
+ const rootToMin = new Map();
1433
+ for (const id of nodeIds) {
1434
+ const root = find(id);
1435
+ const cur = rootToMin.get(root);
1436
+ if (cur === undefined || id < cur) {
1437
+ rootToMin.set(root, id);
1438
+ }
1439
+ }
1440
+ // Build assignment: nodeId → communityId
1441
+ const assignment = new Map();
1442
+ for (const id of nodeIds) {
1443
+ const root = find(id);
1444
+ assignment.set(id, rootToMin.get(root));
1445
+ }
1446
+ // Write communities to DB in a transaction
1447
+ const updateCommunity = this.db.prepare("UPDATE graph_nodes SET community = ? WHERE id = ?");
1448
+ this.db.transaction(() => {
1449
+ // Reset first
1450
+ this.db.exec("UPDATE graph_nodes SET community = NULL");
1451
+ for (const [nodeId, communityId] of assignment) {
1452
+ updateCommunity.run(communityId, nodeId);
1453
+ }
1454
+ })();
1455
+ return rootToMin.size;
1456
+ }
1457
+ /**
1458
+ * Return the top-N most-connected graph nodes (god nodes).
1459
+ */
1460
+ getGodNodes(topN = 10) {
1461
+ this.ensureOpen();
1462
+ const limit = Math.min(Math.max(1, topN), 50);
1463
+ const rows = this.db.prepare(`
1464
+ SELECT * FROM (
1465
+ SELECT gn.*, (
1466
+ SELECT COUNT(*) FROM graph_edges e
1467
+ WHERE e.source_node_id = gn.id OR e.target_node_id = gn.id
1468
+ ) AS degree
1469
+ FROM graph_nodes gn
1470
+ )
1471
+ WHERE degree > 0
1472
+ ORDER BY degree DESC
1473
+ LIMIT ?
1474
+ `).all(limit);
1475
+ return rows.map(r => ({
1476
+ id: Number(r.id),
1477
+ symbolId: r.symbol_id ? Number(r.symbol_id) : undefined,
1478
+ file: String(r.file ?? ""),
1479
+ name: String(r.name ?? ""),
1480
+ kind: String(r.kind ?? ""),
1481
+ signature: String(r.signature ?? ""),
1482
+ line: r.line ? Number(r.line) : undefined,
1483
+ column: r.column ? Number(r.column) : undefined,
1484
+ community: r.community ? Number(r.community) : undefined,
1485
+ }));
1486
+ }
1487
+ /**
1488
+ * Return summary graph statistics.
1489
+ */
1490
+ getGraphStats() {
1491
+ this.ensureOpen();
1492
+ const nodeCount = this.db.prepare("SELECT COUNT(*) AS c FROM graph_nodes").get().c;
1493
+ const edgeCount = this.db.prepare("SELECT COUNT(*) AS c FROM graph_edges").get().c;
1494
+ const communityCount = this.db.prepare("SELECT COUNT(DISTINCT community) AS c FROM graph_nodes WHERE community IS NOT NULL").get().c;
1495
+ const unreferencedNodeCount = this.db.prepare(`
1496
+ SELECT COUNT(*) AS c FROM graph_nodes gn
1497
+ WHERE NOT EXISTS (SELECT 1 FROM graph_edges e WHERE e.source_node_id = gn.id OR e.target_node_id = gn.id)
1498
+ `).get().c;
1499
+ const nodeKinds = this.db.prepare("SELECT kind, COUNT(*) AS c FROM graph_nodes GROUP BY kind ORDER BY c DESC").all();
1500
+ const edgeKinds = this.db.prepare("SELECT kind, COUNT(*) AS c FROM graph_edges GROUP BY kind ORDER BY c DESC").all();
1501
+ const confidenceBreakdown = this.db.prepare("SELECT confidence, COUNT(*) AS c FROM graph_edges GROUP BY confidence ORDER BY c DESC").all();
1502
+ const maxDegree = edgeCount > 0
1503
+ ? this.db.prepare(`
1504
+ SELECT MAX(d) AS c FROM (
1505
+ SELECT COUNT(*) AS d, source_node_id FROM graph_edges GROUP BY source_node_id
1506
+ )
1507
+ `).get().c
1508
+ : 0;
1509
+ const avgDegree = edgeCount > 0 && nodeCount > 0
1510
+ ? Math.round((edgeCount * 2) / nodeCount * 10) / 10
1511
+ : 0;
1512
+ const communityCountVal = communityCount > 0 ? communityCount : 0;
1513
+ return {
1514
+ nodeCount: Number(nodeCount),
1515
+ edgeCount: Number(edgeCount),
1516
+ communityCount: Number(communityCountVal),
1517
+ unreferencedNodeCount: Number(unreferencedNodeCount),
1518
+ maxDegree: Number(maxDegree ?? 0),
1519
+ avgDegree: Number(avgDegree),
1520
+ nodeKinds: Object.fromEntries(nodeKinds.map(r => [r.kind, r.c])),
1521
+ edgeKinds: Object.fromEntries(edgeKinds.map(r => [r.kind, r.c])),
1522
+ confidenceBreakdown: Object.fromEntries(confidenceBreakdown.map(r => [r.confidence, r.c])),
1523
+ };
1524
+ }
1525
+ // ── Cycle detection ──────────────────────────────────
1526
+ detectCycles() {
1527
+ this.ensureOpen();
1528
+ // Use recursive CTE to find cycles: walk edges and detect when we revisit a node
1529
+ const rows = this.db.prepare(`
1530
+ WITH RECURSIVE walk(path, last_node, kind, depth, has_cycle) AS (
1531
+ SELECT
1532
+ ',' || CAST(e.source_node_id AS TEXT) || ',' || CAST(e.target_node_id AS TEXT) || ',',
1533
+ e.target_node_id,
1534
+ e.kind,
1535
+ 1,
1536
+ 0
1537
+ FROM graph_edges e
1538
+ UNION ALL
1539
+ SELECT
1540
+ w.path || CAST(e.target_node_id AS TEXT) || ',',
1541
+ e.target_node_id,
1542
+ e.kind,
1543
+ w.depth + 1,
1544
+ CASE WHEN w.path LIKE '%,' || CAST(e.target_node_id AS TEXT) || ',%' THEN 1 ELSE 0 END
1545
+ FROM walk w
1546
+ JOIN graph_edges e ON e.source_node_id = w.last_node
1547
+ WHERE w.depth < 20 AND w.has_cycle = 0
1548
+ )
1549
+ SELECT DISTINCT w.path, w.kind, w.depth
1550
+ FROM walk w
1551
+ WHERE w.has_cycle = 1
1552
+ LIMIT 50
1553
+ `).all();
1554
+ // Batch-resolve node IDs → names with ONE query instead of N
1555
+ const cycles = rows.map(r => {
1556
+ const ids = r.path.split(',').filter(Boolean).map(Number);
1557
+ return { ids, kind: r.kind, length: new Set(ids).size };
1558
+ });
1559
+ const nameMap = this.resolveNodeNames(cycles);
1560
+ return cycles.map(c => ({
1561
+ cycle: c.ids.slice(0, 10).map(id => nameMap.get(id) ?? `?${id}`),
1562
+ kind: c.kind,
1563
+ length: c.length,
1564
+ }));
1565
+ }
1566
+ /** Batch-resolve node IDs to names — one query instead of N. */
1567
+ resolveNodeNames(cycles) {
1568
+ const allIds = new Set();
1569
+ for (const c of cycles)
1570
+ for (const id of c.ids)
1571
+ allIds.add(id);
1572
+ const samples = [...allIds].slice(0, 100);
1573
+ if (samples.length === 0)
1574
+ return new Map();
1575
+ const placeholders = samples.map(() => '?').join(',');
1576
+ const rows = this.db.prepare(`SELECT id, name FROM graph_nodes WHERE id IN (${placeholders})`).all(...samples);
1577
+ return new Map(rows.map(n => [n.id, n.name]));
1578
+ }
1579
+ // ── Trigram FTS search (CJK support) ─────────────────
1580
+ searchGraphNodesTrigram(query, limit = 20) {
1581
+ this.ensureOpen();
1582
+ const safeLimit = Math.min(Math.max(1, limit), 50);
1583
+ // Escape FTS5 special characters
1584
+ const escaped = String(query).replace(/['"]/g, '').trim();
1585
+ if (!escaped)
1586
+ return [];
1587
+ try {
1588
+ const rows = this.db.prepare(`
1589
+ SELECT gn.* FROM nodes_trigram_fts f
1590
+ JOIN graph_nodes gn ON gn.id = f.rowid
1591
+ WHERE nodes_trigram_fts MATCH ?
1592
+ LIMIT ?
1593
+ `).all(escaped, safeLimit);
1594
+ return rows.map(r => ({
1595
+ id: Number(r.id),
1596
+ name: String(r.name ?? ''),
1597
+ kind: String(r.kind ?? ''),
1598
+ file: String(r.file ?? ''),
1599
+ line: r.line != null ? Number(r.line) : undefined,
1600
+ column: r.column != null ? Number(r.column) : undefined,
1601
+ signature: String(r.signature ?? ''),
1602
+ community: r.community != null ? Number(r.community) : undefined,
1603
+ symbolId: r.symbol_id != null ? Number(r.symbol_id) : undefined,
1604
+ }));
1605
+ }
1606
+ catch {
1607
+ return [];
1608
+ }
1609
+ }
1610
+ // ── Name segment vocab search (fast partial match) ───
1611
+ searchGraphNodesBySegment(segment, limit = 20) {
1612
+ this.ensureOpen();
1613
+ const safeLimit = Math.min(Math.max(1, limit), 50);
1614
+ const seg = String(segment).toLowerCase().trim();
1615
+ if (!seg)
1616
+ return [];
1617
+ const rows = this.db.prepare(`
1618
+ SELECT DISTINCT gn.* FROM name_segment_vocab nsv
1619
+ JOIN graph_nodes gn ON gn.id = nsv.node_id
1620
+ WHERE nsv.segment LIKE ? ESCAPE '\\'
1621
+ ORDER BY gn.name
1622
+ LIMIT ?
1623
+ `).all(`${seg}%`, safeLimit);
1624
+ return rows.map(r => ({
1625
+ id: Number(r.id),
1626
+ name: String(r.name ?? ''),
1627
+ kind: String(r.kind ?? ''),
1628
+ file: String(r.file ?? ''),
1629
+ line: r.line != null ? Number(r.line) : undefined,
1630
+ column: r.column != null ? Number(r.column) : undefined,
1631
+ signature: String(r.signature ?? ''),
1632
+ community: r.community != null ? Number(r.community) : undefined,
1633
+ symbolId: r.symbol_id != null ? Number(r.symbol_id) : undefined,
1634
+ }));
1635
+ }
1636
+ // ── Session reflections (work memory) ────────────────
1637
+ recordReflection(sessionId, goal, task, symbols, files) {
1638
+ this.ensureOpen();
1639
+ this.db.prepare(`
1640
+ INSERT INTO session_reflections (session_id, goal, task, symbols, files)
1641
+ VALUES (?, ?, ?, ?, ?)
1642
+ `).run(String(sessionId), String(goal), String(task), JSON.stringify(symbols ?? []), JSON.stringify(files ?? []));
1643
+ }
1644
+ getSessionReflections(sessionId, limit = 20) {
1645
+ this.ensureOpen();
1646
+ const safeLimit = Math.min(Math.max(1, limit), 100);
1647
+ let rows;
1648
+ if (sessionId) {
1649
+ rows = this.db.prepare(`
1650
+ SELECT * FROM session_reflections WHERE session_id = ? ORDER BY timestamp DESC LIMIT ?
1651
+ `).all(sessionId, safeLimit);
1652
+ }
1653
+ else {
1654
+ rows = this.db.prepare(`
1655
+ SELECT * FROM session_reflections ORDER BY timestamp DESC LIMIT ?
1656
+ `).all(safeLimit);
1657
+ }
1658
+ return rows.map(r => ({
1659
+ id: Number(r.id),
1660
+ sessionId: String(r.session_id ?? ''),
1661
+ goal: String(r.goal ?? ''),
1662
+ task: String(r.task ?? ''),
1663
+ symbols: String(r.symbols ?? '[]'),
1664
+ files: String(r.files ?? '[]'),
1665
+ timestamp: String(r.timestamp ?? ''),
1666
+ }));
1667
+ }
1018
1668
  }
1019
1669
  // ─── Factory ───────────────────────────────────────────────
1020
1670
  let globalStore = null;