codestrate 0.6.1 → 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.
- package/README.md +97 -101
- package/dist/constants.d.ts +1 -1
- package/dist/constants.d.ts.map +1 -1
- package/dist/constants.js +54 -4
- package/dist/constants.js.map +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +198 -119
- package/dist/index.js.map +1 -1
- package/dist/install.d.ts +0 -0
- package/dist/install.d.ts.map +0 -0
- package/dist/install.js +2 -2
- package/dist/install.js.map +1 -1
- package/dist/profiles.d.ts +28 -0
- package/dist/profiles.d.ts.map +1 -0
- package/dist/profiles.js +89 -0
- package/dist/profiles.js.map +1 -0
- package/dist/schemas/index.d.ts +1441 -126
- package/dist/schemas/index.d.ts.map +1 -1
- package/dist/schemas/index.js +221 -10
- package/dist/schemas/index.js.map +1 -1
- package/dist/security/guard.d.ts +15 -2
- package/dist/security/guard.d.ts.map +1 -1
- package/dist/security/guard.js +42 -8
- package/dist/security/guard.js.map +1 -1
- package/dist/services/code-chunker.d.ts +50 -0
- package/dist/services/code-chunker.d.ts.map +1 -0
- package/dist/services/code-chunker.js +148 -0
- package/dist/services/code-chunker.js.map +1 -0
- package/dist/services/code-searcher.d.ts +6 -2
- package/dist/services/code-searcher.d.ts.map +1 -1
- package/dist/services/code-searcher.js +111 -17
- package/dist/services/code-searcher.js.map +1 -1
- package/dist/services/context-builder.d.ts +0 -11
- package/dist/services/context-builder.d.ts.map +1 -1
- package/dist/services/context-builder.js +74 -128
- package/dist/services/context-builder.js.map +1 -1
- package/dist/services/embedding-runtime.d.ts +41 -0
- package/dist/services/embedding-runtime.d.ts.map +1 -0
- package/dist/services/embedding-runtime.js +636 -0
- package/dist/services/embedding-runtime.js.map +1 -0
- package/dist/services/embedding.d.ts +79 -0
- package/dist/services/embedding.d.ts.map +1 -0
- package/dist/services/embedding.js +257 -0
- package/dist/services/embedding.js.map +1 -0
- package/dist/services/index-state.d.ts +74 -0
- package/dist/services/index-state.d.ts.map +1 -0
- package/dist/services/index-state.js +151 -0
- package/dist/services/index-state.js.map +1 -0
- package/dist/services/index-worker.d.ts +60 -0
- package/dist/services/index-worker.d.ts.map +1 -0
- package/dist/services/index-worker.js +182 -0
- package/dist/services/index-worker.js.map +1 -0
- package/dist/services/logger.d.ts +48 -0
- package/dist/services/logger.d.ts.map +1 -0
- package/dist/services/logger.js +106 -0
- package/dist/services/logger.js.map +1 -0
- package/dist/services/memory-store.d.ts +9 -7
- package/dist/services/memory-store.d.ts.map +1 -1
- package/dist/services/memory-store.js +93 -7
- package/dist/services/memory-store.js.map +1 -1
- package/dist/services/repository-parser.d.ts +0 -0
- package/dist/services/repository-parser.d.ts.map +0 -0
- package/dist/services/repository-parser.js +0 -0
- package/dist/services/repository-parser.js.map +0 -0
- package/dist/services/symbol-indexer.d.ts +0 -0
- package/dist/services/symbol-indexer.d.ts.map +1 -1
- package/dist/services/symbol-indexer.js +25 -13
- package/dist/services/symbol-indexer.js.map +1 -1
- package/dist/services/tree-sitter.d.ts +0 -0
- package/dist/services/tree-sitter.d.ts.map +0 -0
- package/dist/services/tree-sitter.js +0 -0
- package/dist/services/tree-sitter.js.map +0 -0
- package/dist/services/validation-runner.d.ts +0 -0
- package/dist/services/validation-runner.d.ts.map +1 -1
- package/dist/services/validation-runner.js +9 -9
- package/dist/services/validation-runner.js.map +1 -1
- package/dist/services/vector-indexer.d.ts +53 -0
- package/dist/services/vector-indexer.d.ts.map +1 -0
- package/dist/services/vector-indexer.js +230 -0
- package/dist/services/vector-indexer.js.map +1 -0
- package/dist/store/index.d.ts +0 -0
- package/dist/store/index.d.ts.map +0 -0
- package/dist/store/index.js +0 -0
- package/dist/store/index.js.map +0 -0
- package/dist/store/interface.d.ts +70 -1
- package/dist/store/interface.d.ts.map +1 -1
- package/dist/store/interface.js +0 -0
- package/dist/store/interface.js.map +0 -0
- package/dist/store/memory.d.ts +0 -0
- package/dist/store/memory.d.ts.map +0 -0
- package/dist/store/memory.js +0 -0
- package/dist/store/memory.js.map +0 -0
- package/dist/store/service.d.ts +49 -0
- package/dist/store/service.d.ts.map +1 -0
- package/dist/store/service.js +147 -0
- package/dist/store/service.js.map +1 -0
- package/dist/store/sqlite.d.ts +68 -3
- package/dist/store/sqlite.d.ts.map +1 -1
- package/dist/store/sqlite.js +708 -58
- package/dist/store/sqlite.js.map +1 -1
- package/dist/store/vector-store.d.ts +66 -0
- package/dist/store/vector-store.d.ts.map +1 -0
- package/dist/store/vector-store.js +330 -0
- package/dist/store/vector-store.js.map +1 -0
- package/dist/tools/analyze-impact.d.ts +0 -0
- package/dist/tools/analyze-impact.d.ts.map +1 -1
- package/dist/tools/analyze-impact.js +101 -39
- package/dist/tools/analyze-impact.js.map +1 -1
- package/dist/tools/deep-explore.d.ts +0 -0
- package/dist/tools/deep-explore.d.ts.map +1 -1
- package/dist/tools/deep-explore.js +18 -4
- package/dist/tools/deep-explore.js.map +1 -1
- package/dist/tools/detect-cycles.d.ts +3 -0
- package/dist/tools/detect-cycles.d.ts.map +1 -0
- package/dist/tools/detect-cycles.js +61 -0
- package/dist/tools/detect-cycles.js.map +1 -0
- package/dist/tools/detect-dead-code.d.ts +0 -0
- package/dist/tools/detect-dead-code.d.ts.map +1 -1
- package/dist/tools/detect-dead-code.js +1 -0
- package/dist/tools/detect-dead-code.js.map +1 -1
- package/dist/tools/explore.d.ts +13 -0
- package/dist/tools/explore.d.ts.map +1 -1
- package/dist/tools/explore.js +122 -88
- package/dist/tools/explore.js.map +1 -1
- package/dist/tools/get-context.d.ts +15 -0
- package/dist/tools/get-context.d.ts.map +1 -1
- package/dist/tools/get-context.js +66 -61
- package/dist/tools/get-context.js.map +1 -1
- package/dist/tools/graph-explain.d.ts +3 -0
- package/dist/tools/graph-explain.d.ts.map +1 -0
- package/dist/tools/graph-explain.js +140 -0
- package/dist/tools/graph-explain.js.map +1 -0
- package/dist/tools/graph-stats.d.ts +3 -0
- package/dist/tools/graph-stats.d.ts.map +1 -0
- package/dist/tools/graph-stats.js +73 -0
- package/dist/tools/graph-stats.js.map +1 -0
- package/dist/tools/inspect-symbol.d.ts +10 -0
- package/dist/tools/inspect-symbol.d.ts.map +1 -1
- package/dist/tools/inspect-symbol.js +112 -101
- package/dist/tools/inspect-symbol.js.map +1 -1
- package/dist/tools/ping.d.ts +0 -0
- package/dist/tools/ping.d.ts.map +1 -1
- package/dist/tools/ping.js +2 -3
- package/dist/tools/ping.js.map +1 -1
- package/dist/tools/profile.d.ts +9 -0
- package/dist/tools/profile.d.ts.map +1 -0
- package/dist/tools/profile.js +101 -0
- package/dist/tools/profile.js.map +1 -0
- package/dist/tools/query-graph.d.ts +3 -0
- package/dist/tools/query-graph.d.ts.map +1 -0
- package/dist/tools/query-graph.js +157 -0
- package/dist/tools/query-graph.js.map +1 -0
- package/dist/tools/recall-experience.d.ts +0 -0
- package/dist/tools/recall-experience.d.ts.map +1 -1
- package/dist/tools/recall-experience.js +5 -5
- package/dist/tools/recall-experience.js.map +1 -1
- package/dist/tools/record-experience.d.ts +0 -0
- package/dist/tools/record-experience.d.ts.map +1 -1
- package/dist/tools/record-experience.js +2 -5
- package/dist/tools/record-experience.js.map +1 -1
- package/dist/tools/reflect.d.ts +4 -0
- package/dist/tools/reflect.d.ts.map +1 -0
- package/dist/tools/reflect.js +99 -0
- package/dist/tools/reflect.js.map +1 -0
- package/dist/tools/register.d.ts +5 -2
- package/dist/tools/register.d.ts.map +1 -1
- package/dist/tools/register.js +31 -1
- package/dist/tools/register.js.map +1 -1
- package/dist/tools/reindex.d.ts +0 -0
- package/dist/tools/reindex.d.ts.map +1 -1
- package/dist/tools/reindex.js +102 -21
- package/dist/tools/reindex.js.map +1 -1
- package/dist/tools/repo-overview.d.ts +0 -0
- package/dist/tools/repo-overview.d.ts.map +1 -1
- package/dist/tools/repo-overview.js +8 -6
- package/dist/tools/repo-overview.js.map +1 -1
- package/dist/tools/run-validation.d.ts +0 -0
- package/dist/tools/run-validation.d.ts.map +1 -1
- package/dist/tools/run-validation.js +15 -1
- package/dist/tools/run-validation.js.map +1 -1
- package/dist/tools/search-code.d.ts +0 -0
- package/dist/tools/search-code.d.ts.map +1 -1
- package/dist/tools/search-code.js +11 -4
- package/dist/tools/search-code.js.map +1 -1
- package/dist/tools/search-history.d.ts +0 -0
- package/dist/tools/search-history.d.ts.map +0 -0
- package/dist/tools/search-history.js +0 -0
- package/dist/tools/search-history.js.map +0 -0
- package/dist/tools/session-heartbeat.d.ts +0 -0
- package/dist/tools/session-heartbeat.d.ts.map +1 -1
- package/dist/tools/session-heartbeat.js +1 -0
- package/dist/tools/session-heartbeat.js.map +1 -1
- package/dist/tools/session-status.d.ts +0 -0
- package/dist/tools/session-status.d.ts.map +1 -1
- package/dist/tools/session-status.js +1 -0
- package/dist/tools/session-status.js.map +1 -1
- package/dist/tools/status.d.ts +0 -0
- package/dist/tools/status.d.ts.map +1 -1
- package/dist/tools/status.js +87 -24
- package/dist/tools/status.js.map +1 -1
- package/dist/tools/trace-bfs.d.ts +13 -0
- package/dist/tools/trace-bfs.d.ts.map +1 -0
- package/dist/tools/trace-bfs.js +251 -0
- package/dist/tools/trace-bfs.js.map +1 -0
- package/dist/tools/trace-flow.d.ts +1 -1
- package/dist/tools/trace-flow.d.ts.map +1 -1
- package/dist/tools/trace-flow.js +113 -69
- package/dist/tools/trace-flow.js.map +1 -1
- package/dist/types.d.ts +75 -13
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +15 -0
- package/dist/types.js.map +1 -1
- package/grammars/tree-sitter-c.wasm +0 -0
- package/grammars/tree-sitter-cpp.wasm +0 -0
- package/grammars/tree-sitter-go.wasm +0 -0
- package/grammars/tree-sitter-java.wasm +0 -0
- package/grammars/tree-sitter-javascript.wasm +0 -0
- package/grammars/tree-sitter-python.wasm +0 -0
- package/grammars/tree-sitter-rust.wasm +0 -0
- package/grammars/tree-sitter-tsx.wasm +0 -0
- package/grammars/tree-sitter-typescript.wasm +0 -0
- package/package.json +8 -6
package/dist/store/sqlite.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
489
|
-
|
|
571
|
+
let whereClause;
|
|
572
|
+
if (direction === "incoming") {
|
|
573
|
+
whereClause = "target_node_id = ?";
|
|
490
574
|
params.push(nodeId);
|
|
491
575
|
}
|
|
492
|
-
else {
|
|
493
|
-
|
|
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
|
-
|
|
586
|
+
whereClause += " AND kind = ?";
|
|
498
587
|
params.push(kind);
|
|
499
588
|
}
|
|
500
|
-
const rows = this.db.prepare(
|
|
501
|
-
|
|
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
|
-
//
|
|
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
|
|
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
|
|
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(
|
|
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
|
-
|
|
677
|
-
|
|
678
|
-
.
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
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
|
-
|
|
688
|
-
|
|
689
|
-
.
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
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;
|