@optave/codegraph 3.1.0 → 3.1.2

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 (83) hide show
  1. package/README.md +5 -5
  2. package/grammars/tree-sitter-go.wasm +0 -0
  3. package/package.json +8 -9
  4. package/src/ast-analysis/engine.js +365 -0
  5. package/src/ast-analysis/metrics.js +118 -0
  6. package/src/ast-analysis/rules/csharp.js +201 -0
  7. package/src/ast-analysis/rules/go.js +182 -0
  8. package/src/ast-analysis/rules/index.js +82 -0
  9. package/src/ast-analysis/rules/java.js +175 -0
  10. package/src/ast-analysis/rules/javascript.js +246 -0
  11. package/src/ast-analysis/rules/php.js +219 -0
  12. package/src/ast-analysis/rules/python.js +196 -0
  13. package/src/ast-analysis/rules/ruby.js +204 -0
  14. package/src/ast-analysis/rules/rust.js +173 -0
  15. package/src/ast-analysis/shared.js +223 -0
  16. package/src/ast-analysis/visitor-utils.js +176 -0
  17. package/src/ast-analysis/visitor.js +162 -0
  18. package/src/ast-analysis/visitors/ast-store-visitor.js +150 -0
  19. package/src/ast-analysis/visitors/cfg-visitor.js +792 -0
  20. package/src/ast-analysis/visitors/complexity-visitor.js +243 -0
  21. package/src/ast-analysis/visitors/dataflow-visitor.js +358 -0
  22. package/src/ast.js +26 -166
  23. package/src/audit.js +2 -88
  24. package/src/batch.js +0 -25
  25. package/src/boundaries.js +1 -1
  26. package/src/branch-compare.js +82 -172
  27. package/src/builder.js +48 -184
  28. package/src/cfg.js +148 -1174
  29. package/src/check.js +1 -84
  30. package/src/cli.js +118 -197
  31. package/src/cochange.js +1 -39
  32. package/src/commands/audit.js +88 -0
  33. package/src/commands/batch.js +26 -0
  34. package/src/commands/branch-compare.js +97 -0
  35. package/src/commands/cfg.js +55 -0
  36. package/src/commands/check.js +82 -0
  37. package/src/commands/cochange.js +37 -0
  38. package/src/commands/communities.js +69 -0
  39. package/src/commands/complexity.js +77 -0
  40. package/src/commands/dataflow.js +110 -0
  41. package/src/commands/flow.js +70 -0
  42. package/src/commands/manifesto.js +77 -0
  43. package/src/commands/owners.js +52 -0
  44. package/src/commands/query.js +21 -0
  45. package/src/commands/sequence.js +33 -0
  46. package/src/commands/structure.js +64 -0
  47. package/src/commands/triage.js +49 -0
  48. package/src/communities.js +22 -96
  49. package/src/complexity.js +234 -1591
  50. package/src/cycles.js +1 -1
  51. package/src/dataflow.js +274 -1352
  52. package/src/db/connection.js +88 -0
  53. package/src/db/migrations.js +312 -0
  54. package/src/db/query-builder.js +280 -0
  55. package/src/db/repository/build-stmts.js +104 -0
  56. package/src/db/repository/cfg.js +83 -0
  57. package/src/db/repository/cochange.js +41 -0
  58. package/src/db/repository/complexity.js +15 -0
  59. package/src/db/repository/dataflow.js +12 -0
  60. package/src/db/repository/edges.js +259 -0
  61. package/src/db/repository/embeddings.js +40 -0
  62. package/src/db/repository/graph-read.js +39 -0
  63. package/src/db/repository/index.js +42 -0
  64. package/src/db/repository/nodes.js +236 -0
  65. package/src/db.js +58 -399
  66. package/src/embedder.js +158 -174
  67. package/src/export.js +1 -1
  68. package/src/extractors/javascript.js +130 -5
  69. package/src/flow.js +153 -222
  70. package/src/index.js +53 -16
  71. package/src/infrastructure/result-formatter.js +21 -0
  72. package/src/infrastructure/test-filter.js +7 -0
  73. package/src/kinds.js +50 -0
  74. package/src/manifesto.js +1 -82
  75. package/src/mcp.js +37 -20
  76. package/src/owners.js +127 -182
  77. package/src/queries-cli.js +866 -0
  78. package/src/queries.js +1271 -2416
  79. package/src/sequence.js +179 -223
  80. package/src/structure.js +211 -269
  81. package/src/triage.js +117 -212
  82. package/src/viewer.js +1 -1
  83. package/src/watcher.js +7 -4
package/src/ast.js CHANGED
@@ -7,10 +7,14 @@
7
7
  */
8
8
 
9
9
  import path from 'node:path';
10
- import { openReadonlyOrFail } from './db.js';
10
+ import { AST_TYPE_MAPS } from './ast-analysis/rules/index.js';
11
+ import { buildExtensionSet } from './ast-analysis/shared.js';
12
+ import { walkWithVisitors } from './ast-analysis/visitor.js';
13
+ import { createAstStoreVisitor } from './ast-analysis/visitors/ast-store-visitor.js';
14
+ import { bulkNodeIdsByFile, openReadonlyOrFail } from './db.js';
15
+ import { outputResult } from './infrastructure/result-formatter.js';
11
16
  import { debug } from './logger.js';
12
- import { paginateResult, printNdjson } from './paginate.js';
13
- import { LANGUAGE_REGISTRY } from './parser.js';
17
+ import { paginateResult } from './paginate.js';
14
18
 
15
19
  // ─── Constants ────────────────────────────────────────────────────────
16
20
 
@@ -25,99 +29,15 @@ const KIND_ICONS = {
25
29
  await: '\u22B3', // ⊳
26
30
  };
27
31
 
28
- /** Max length for the `text` column. */
29
- const TEXT_MAX = 200;
30
-
31
- /** tree-sitter node types that map to our AST node kinds (JS/TS/TSX). */
32
- const JS_TS_AST_TYPES = {
33
- new_expression: 'new',
34
- throw_statement: 'throw',
35
- await_expression: 'await',
36
- string: 'string',
37
- template_string: 'string',
38
- regex: 'regex',
39
- };
32
+ /** tree-sitter node types that map to our AST node kinds — imported from rules. */
33
+ const JS_TS_AST_TYPES = AST_TYPE_MAPS.get('javascript');
40
34
 
41
35
  /** Extensions that support full AST walk (new/throw/await/string/regex). */
42
- const WALK_EXTENSIONS = new Set();
43
- for (const lang of Object.values(LANGUAGE_REGISTRY)) {
44
- if (['javascript', 'typescript', 'tsx'].includes(lang.id)) {
45
- for (const ext of lang.extensions) WALK_EXTENSIONS.add(ext);
46
- }
47
- }
36
+ const WALK_EXTENSIONS = buildExtensionSet(AST_TYPE_MAPS);
48
37
 
49
38
  // ─── Helpers ──────────────────────────────────────────────────────────
50
-
51
- function truncate(s, max = TEXT_MAX) {
52
- if (!s) return null;
53
- return s.length <= max ? s : `${s.slice(0, max - 1)}\u2026`;
54
- }
55
-
56
- /**
57
- * Extract the constructor name from a `new_expression` node.
58
- * Handles `new Foo()`, `new a.Foo()`, `new Foo.Bar()`.
59
- */
60
- function extractNewName(node) {
61
- for (let i = 0; i < node.childCount; i++) {
62
- const child = node.child(i);
63
- if (child.type === 'identifier') return child.text;
64
- if (child.type === 'member_expression') {
65
- // e.g. new a.Foo() → "a.Foo"
66
- return child.text;
67
- }
68
- }
69
- return node.text?.split('(')[0]?.replace('new ', '').trim() || '?';
70
- }
71
-
72
- /**
73
- * Extract the expression text from a throw/await node.
74
- */
75
- function extractExpressionText(node) {
76
- // Skip keyword child, take the rest
77
- for (let i = 0; i < node.childCount; i++) {
78
- const child = node.child(i);
79
- if (child.type !== 'throw' && child.type !== 'await') {
80
- return truncate(child.text);
81
- }
82
- }
83
- return truncate(node.text);
84
- }
85
-
86
- /**
87
- * Extract a meaningful name from throw/await nodes.
88
- * For throw: the constructor or expression type.
89
- * For await: the called function name.
90
- */
91
- function extractName(kind, node) {
92
- if (kind === 'throw') {
93
- // throw new Error(...) → "Error"; throw x → "x"
94
- for (let i = 0; i < node.childCount; i++) {
95
- const child = node.child(i);
96
- if (child.type === 'new_expression') return extractNewName(child);
97
- if (child.type === 'call_expression') {
98
- const fn = child.childForFieldName('function');
99
- return fn ? fn.text : child.text?.split('(')[0] || '?';
100
- }
101
- if (child.type === 'identifier') return child.text;
102
- }
103
- return truncate(node.text);
104
- }
105
- if (kind === 'await') {
106
- // await fetch(...) → "fetch"; await this.foo() → "this.foo"
107
- for (let i = 0; i < node.childCount; i++) {
108
- const child = node.child(i);
109
- if (child.type === 'call_expression') {
110
- const fn = child.childForFieldName('function');
111
- return fn ? fn.text : child.text?.split('(')[0] || '?';
112
- }
113
- if (child.type === 'identifier' || child.type === 'member_expression') {
114
- return child.text;
115
- }
116
- }
117
- return truncate(node.text);
118
- }
119
- return truncate(node.text);
120
- }
39
+ // Node extraction helpers (extractNewName, extractName, etc.) moved to
40
+ // ast-analysis/visitors/ast-store-visitor.js as part of the visitor framework.
121
41
 
122
42
  /**
123
43
  * Find the narrowest enclosing definition for a given line.
@@ -156,9 +76,6 @@ export async function buildAstNodes(db, fileSymbols, _rootDir, _engineOpts) {
156
76
  return;
157
77
  }
158
78
 
159
- // Bulk-fetch all node IDs per file (replaces per-def getNodeId calls)
160
- const bulkGetNodeIds = db.prepare('SELECT id, name, kind, line FROM nodes WHERE file = ?');
161
-
162
79
  const tx = db.transaction((rows) => {
163
80
  for (const r of rows) {
164
81
  insertStmt.run(r.file, r.line, r.kind, r.name, r.text, r.receiver, r.parentNodeId);
@@ -172,7 +89,7 @@ export async function buildAstNodes(db, fileSymbols, _rootDir, _engineOpts) {
172
89
 
173
90
  // Pre-load all node IDs for this file into a map (read-only, fast)
174
91
  const nodeIdMap = new Map();
175
- for (const row of bulkGetNodeIds.all(relPath)) {
92
+ for (const row of bulkNodeIdsByFile(db, relPath)) {
176
93
  nodeIdMap.set(`${row.name}|${row.kind}|${row.line}`, row.id);
177
94
  }
178
95
 
@@ -237,66 +154,13 @@ export async function buildAstNodes(db, fileSymbols, _rootDir, _engineOpts) {
237
154
 
238
155
  /**
239
156
  * Walk a tree-sitter AST and collect new/throw/await/string/regex nodes.
157
+ * Delegates to the ast-store visitor via the unified walker.
240
158
  */
241
- function walkAst(node, defs, relPath, rows, nodeIdMap) {
242
- const kind = JS_TS_AST_TYPES[node.type];
243
- if (kind) {
244
- // tree-sitter lines are 0-indexed, our DB uses 1-indexed
245
- const line = node.startPosition.row + 1;
246
-
247
- let name;
248
- let text = null;
249
-
250
- if (kind === 'new') {
251
- name = extractNewName(node);
252
- text = truncate(node.text);
253
- } else if (kind === 'throw') {
254
- name = extractName('throw', node);
255
- text = extractExpressionText(node);
256
- } else if (kind === 'await') {
257
- name = extractName('await', node);
258
- text = extractExpressionText(node);
259
- } else if (kind === 'string') {
260
- // Skip trivial strings (length < 2 after removing quotes)
261
- const content = node.text?.replace(/^['"`]|['"`]$/g, '') || '';
262
- if (content.length < 2) {
263
- // Still recurse children
264
- for (let i = 0; i < node.childCount; i++) {
265
- walkAst(node.child(i), defs, relPath, rows, nodeIdMap);
266
- }
267
- return;
268
- }
269
- name = truncate(content, 100);
270
- text = truncate(node.text);
271
- } else if (kind === 'regex') {
272
- name = node.text || '?';
273
- text = truncate(node.text);
274
- }
275
-
276
- const parentDef = findParentDef(defs, line);
277
- let parentNodeId = null;
278
- if (parentDef) {
279
- parentNodeId = nodeIdMap.get(`${parentDef.name}|${parentDef.kind}|${parentDef.line}`) || null;
280
- }
281
-
282
- rows.push({
283
- file: relPath,
284
- line,
285
- kind,
286
- name,
287
- text,
288
- receiver: null,
289
- parentNodeId,
290
- });
291
-
292
- // Don't recurse into the children of matched nodes for new/throw/await
293
- // (we already extracted what we need, and nested strings inside them are noise)
294
- if (kind !== 'string' && kind !== 'regex') return;
295
- }
296
-
297
- for (let i = 0; i < node.childCount; i++) {
298
- walkAst(node.child(i), defs, relPath, rows, nodeIdMap);
299
- }
159
+ function walkAst(rootNode, defs, relPath, rows, nodeIdMap) {
160
+ const visitor = createAstStoreVisitor(JS_TS_AST_TYPES, defs, relPath, nodeIdMap);
161
+ const results = walkWithVisitors(rootNode, [visitor], 'javascript');
162
+ const collected = results['ast-store'] || [];
163
+ rows.push(...collected);
300
164
  }
301
165
 
302
166
  // ─── Query ────────────────────────────────────────────────────────────
@@ -351,8 +215,12 @@ export function astQueryData(pattern, customDbPath, opts = {}) {
351
215
  ORDER BY a.file, a.line
352
216
  `;
353
217
 
354
- const rows = db.prepare(sql).all(...params);
355
- db.close();
218
+ let rows;
219
+ try {
220
+ rows = db.prepare(sql).all(...params);
221
+ } finally {
222
+ db.close();
223
+ }
356
224
 
357
225
  const results = rows.map((r) => ({
358
226
  kind: r.kind,
@@ -382,15 +250,7 @@ export function astQueryData(pattern, customDbPath, opts = {}) {
382
250
  export function astQuery(pattern, customDbPath, opts = {}) {
383
251
  const data = astQueryData(pattern, customDbPath, opts);
384
252
 
385
- if (opts.ndjson) {
386
- printNdjson(data, 'results');
387
- return;
388
- }
389
-
390
- if (opts.json) {
391
- console.log(JSON.stringify(data, null, 2));
392
- return;
393
- }
253
+ if (outputResult(data, 'results', opts)) return;
394
254
 
395
255
  // Human-readable output
396
256
  if (data.results.length === 0) {
package/src/audit.js CHANGED
@@ -9,8 +9,9 @@
9
9
  import path from 'node:path';
10
10
  import { loadConfig } from './config.js';
11
11
  import { openReadonlyOrFail } from './db.js';
12
+ import { isTestFile } from './infrastructure/test-filter.js';
12
13
  import { RULE_DEFS } from './manifesto.js';
13
- import { explainData, isTestFile, kindIcon } from './queries.js';
14
+ import { explainData } from './queries.js';
14
15
 
15
16
  // ─── Threshold resolution ───────────────────────────────────────────
16
17
 
@@ -334,90 +335,3 @@ function defaultHealth() {
334
335
  thresholdBreaches: [],
335
336
  };
336
337
  }
337
-
338
- // ─── CLI formatter ──────────────────────────────────────────────────
339
-
340
- export function audit(target, customDbPath, opts = {}) {
341
- const data = auditData(target, customDbPath, opts);
342
-
343
- if (opts.json) {
344
- console.log(JSON.stringify(data, null, 2));
345
- return;
346
- }
347
-
348
- if (data.functions.length === 0) {
349
- console.log(`No ${data.kind === 'file' ? 'file' : 'function/symbol'} matching "${target}"`);
350
- return;
351
- }
352
-
353
- console.log(`\n# Audit: ${target} (${data.kind})`);
354
- console.log(` ${data.functions.length} function(s) analyzed\n`);
355
-
356
- for (const fn of data.functions) {
357
- const lineRange = fn.endLine ? `${fn.line}-${fn.endLine}` : `${fn.line}`;
358
- const roleTag = fn.role ? ` [${fn.role}]` : '';
359
- console.log(`## ${kindIcon(fn.kind)} ${fn.name} (${fn.kind})${roleTag}`);
360
- console.log(` ${fn.file}:${lineRange}${fn.lineCount ? ` (${fn.lineCount} lines)` : ''}`);
361
- if (fn.summary) console.log(` ${fn.summary}`);
362
- if (fn.signature) {
363
- if (fn.signature.params != null) console.log(` Parameters: (${fn.signature.params})`);
364
- if (fn.signature.returnType) console.log(` Returns: ${fn.signature.returnType}`);
365
- }
366
-
367
- // Health metrics
368
- if (fn.health.cognitive != null) {
369
- console.log(`\n Health:`);
370
- console.log(
371
- ` Cognitive: ${fn.health.cognitive} Cyclomatic: ${fn.health.cyclomatic} Nesting: ${fn.health.maxNesting}`,
372
- );
373
- console.log(` MI: ${fn.health.maintainabilityIndex}`);
374
- if (fn.health.halstead.volume) {
375
- console.log(
376
- ` Halstead: vol=${fn.health.halstead.volume} diff=${fn.health.halstead.difficulty} effort=${fn.health.halstead.effort} bugs=${fn.health.halstead.bugs}`,
377
- );
378
- }
379
- if (fn.health.loc) {
380
- console.log(
381
- ` LOC: ${fn.health.loc} SLOC: ${fn.health.sloc} Comments: ${fn.health.commentLines}`,
382
- );
383
- }
384
- }
385
-
386
- // Threshold breaches
387
- if (fn.health.thresholdBreaches.length > 0) {
388
- console.log(`\n Threshold Breaches:`);
389
- for (const b of fn.health.thresholdBreaches) {
390
- const icon = b.level === 'fail' ? 'FAIL' : 'WARN';
391
- console.log(` [${icon}] ${b.metric}: ${b.value} >= ${b.threshold}`);
392
- }
393
- }
394
-
395
- // Impact
396
- console.log(`\n Impact: ${fn.impact.totalDependents} transitive dependent(s)`);
397
- for (const [level, nodes] of Object.entries(fn.impact.levels)) {
398
- console.log(` Level ${level}: ${nodes.map((n) => n.name).join(', ')}`);
399
- }
400
-
401
- // Call edges
402
- if (fn.callees.length > 0) {
403
- console.log(`\n Calls (${fn.callees.length}):`);
404
- for (const c of fn.callees) {
405
- console.log(` ${kindIcon(c.kind)} ${c.name} ${c.file}:${c.line}`);
406
- }
407
- }
408
- if (fn.callers.length > 0) {
409
- console.log(`\n Called by (${fn.callers.length}):`);
410
- for (const c of fn.callers) {
411
- console.log(` ${kindIcon(c.kind)} ${c.name} ${c.file}:${c.line}`);
412
- }
413
- }
414
- if (fn.relatedTests.length > 0) {
415
- console.log(`\n Tests (${fn.relatedTests.length}):`);
416
- for (const t of fn.relatedTests) {
417
- console.log(` ${t.file}`);
418
- }
419
- }
420
-
421
- console.log();
422
- }
423
- }
package/src/batch.js CHANGED
@@ -83,14 +83,6 @@ export function batchData(command, targets, customDbPath, opts = {}) {
83
83
  return { command, total: targets.length, succeeded, failed, results };
84
84
  }
85
85
 
86
- /**
87
- * CLI wrapper — calls batchData and prints JSON to stdout.
88
- */
89
- export function batch(command, targets, customDbPath, opts = {}) {
90
- const data = batchData(command, targets, customDbPath, opts);
91
- console.log(JSON.stringify(data, null, 2));
92
- }
93
-
94
86
  /**
95
87
  * Expand comma-separated positional args into individual entries.
96
88
  * `['a,b', 'c']` → `['a', 'b', 'c']`.
@@ -161,20 +153,3 @@ export function multiBatchData(items, customDbPath, sharedOpts = {}) {
161
153
 
162
154
  return { mode: 'multi', total: items.length, succeeded, failed, results };
163
155
  }
164
-
165
- /**
166
- * CLI wrapper for batch-query — detects multi-command mode (objects with .command)
167
- * or falls back to single-command batchData (default: 'where').
168
- */
169
- export function batchQuery(targets, customDbPath, opts = {}) {
170
- const { command: defaultCommand = 'where', ...rest } = opts;
171
- const isMulti = targets.length > 0 && typeof targets[0] === 'object' && targets[0].command;
172
-
173
- let data;
174
- if (isMulti) {
175
- data = multiBatchData(targets, customDbPath, rest);
176
- } else {
177
- data = batchData(defaultCommand, targets, customDbPath, rest);
178
- }
179
- console.log(JSON.stringify(data, null, 2));
180
- }
package/src/boundaries.js CHANGED
@@ -1,5 +1,5 @@
1
+ import { isTestFile } from './infrastructure/test-filter.js';
1
2
  import { debug } from './logger.js';
2
- import { isTestFile } from './queries.js';
3
3
 
4
4
  // ─── Glob-to-Regex ───────────────────────────────────────────────────
5
5
 
@@ -12,7 +12,8 @@ import os from 'node:os';
12
12
  import path from 'node:path';
13
13
  import Database from 'better-sqlite3';
14
14
  import { buildGraph } from './builder.js';
15
- import { isTestFile, kindIcon } from './queries.js';
15
+ import { isTestFile } from './infrastructure/test-filter.js';
16
+ import { kindIcon } from './queries.js';
16
17
 
17
18
  // ─── Git Helpers ────────────────────────────────────────────────────────
18
19
 
@@ -81,55 +82,57 @@ function makeSymbolKey(kind, file, name) {
81
82
 
82
83
  function loadSymbolsFromDb(dbPath, changedFiles, noTests) {
83
84
  const db = new Database(dbPath, { readonly: true });
84
- const symbols = new Map();
85
+ try {
86
+ const symbols = new Map();
85
87
 
86
- if (changedFiles.length === 0) {
87
- db.close();
88
- return symbols;
89
- }
88
+ if (changedFiles.length === 0) {
89
+ return symbols;
90
+ }
90
91
 
91
- // Query nodes in changed files
92
- const placeholders = changedFiles.map(() => '?').join(', ');
93
- const rows = db
94
- .prepare(
95
- `SELECT n.id, n.name, n.kind, n.file, n.line, n.end_line
96
- FROM nodes n
97
- WHERE n.file IN (${placeholders})
98
- AND n.kind NOT IN ('file', 'directory')
99
- ORDER BY n.file, n.line`,
100
- )
101
- .all(...changedFiles);
102
-
103
- // Compute fan_in and fan_out for each node
104
- const fanInStmt = db.prepare(
105
- `SELECT COUNT(*) AS cnt FROM edges WHERE target_id = ? AND kind = 'calls'`,
106
- );
107
- const fanOutStmt = db.prepare(
108
- `SELECT COUNT(*) AS cnt FROM edges WHERE source_id = ? AND kind = 'calls'`,
109
- );
110
-
111
- for (const row of rows) {
112
- if (noTests && isTestFile(row.file)) continue;
113
-
114
- const lineCount = row.end_line ? row.end_line - row.line + 1 : 0;
115
- const fanIn = fanInStmt.get(row.id).cnt;
116
- const fanOut = fanOutStmt.get(row.id).cnt;
117
- const key = makeSymbolKey(row.kind, row.file, row.name);
118
-
119
- symbols.set(key, {
120
- id: row.id,
121
- name: row.name,
122
- kind: row.kind,
123
- file: row.file,
124
- line: row.line,
125
- lineCount,
126
- fanIn,
127
- fanOut,
128
- });
129
- }
92
+ // Query nodes in changed files
93
+ const placeholders = changedFiles.map(() => '?').join(', ');
94
+ const rows = db
95
+ .prepare(
96
+ `SELECT n.id, n.name, n.kind, n.file, n.line, n.end_line
97
+ FROM nodes n
98
+ WHERE n.file IN (${placeholders})
99
+ AND n.kind NOT IN ('file', 'directory')
100
+ ORDER BY n.file, n.line`,
101
+ )
102
+ .all(...changedFiles);
103
+
104
+ // Compute fan_in and fan_out for each node
105
+ const fanInStmt = db.prepare(
106
+ `SELECT COUNT(*) AS cnt FROM edges WHERE target_id = ? AND kind = 'calls'`,
107
+ );
108
+ const fanOutStmt = db.prepare(
109
+ `SELECT COUNT(*) AS cnt FROM edges WHERE source_id = ? AND kind = 'calls'`,
110
+ );
111
+
112
+ for (const row of rows) {
113
+ if (noTests && isTestFile(row.file)) continue;
114
+
115
+ const lineCount = row.end_line ? row.end_line - row.line + 1 : 0;
116
+ const fanIn = fanInStmt.get(row.id).cnt;
117
+ const fanOut = fanOutStmt.get(row.id).cnt;
118
+ const key = makeSymbolKey(row.kind, row.file, row.name);
119
+
120
+ symbols.set(key, {
121
+ id: row.id,
122
+ name: row.name,
123
+ kind: row.kind,
124
+ file: row.file,
125
+ line: row.line,
126
+ lineCount,
127
+ fanIn,
128
+ fanOut,
129
+ });
130
+ }
130
131
 
131
- db.close();
132
- return symbols;
132
+ return symbols;
133
+ } finally {
134
+ db.close();
135
+ }
133
136
  }
134
137
 
135
138
  // ─── Caller BFS ─────────────────────────────────────────────────────────
@@ -138,40 +141,43 @@ function loadCallersFromDb(dbPath, nodeIds, maxDepth, noTests) {
138
141
  if (nodeIds.length === 0) return [];
139
142
 
140
143
  const db = new Database(dbPath, { readonly: true });
141
- const allCallers = new Set();
142
-
143
- for (const startId of nodeIds) {
144
- const visited = new Set([startId]);
145
- let frontier = [startId];
146
-
147
- for (let d = 1; d <= maxDepth; d++) {
148
- const nextFrontier = [];
149
- for (const fid of frontier) {
150
- const callers = db
151
- .prepare(
152
- `SELECT DISTINCT n.id, n.name, n.kind, n.file, n.line
153
- FROM edges e JOIN nodes n ON e.source_id = n.id
154
- WHERE e.target_id = ? AND e.kind = 'calls'`,
155
- )
156
- .all(fid);
157
-
158
- for (const c of callers) {
159
- if (!visited.has(c.id) && (!noTests || !isTestFile(c.file))) {
160
- visited.add(c.id);
161
- nextFrontier.push(c.id);
162
- allCallers.add(
163
- JSON.stringify({ name: c.name, kind: c.kind, file: c.file, line: c.line }),
164
- );
144
+ try {
145
+ const allCallers = new Set();
146
+
147
+ for (const startId of nodeIds) {
148
+ const visited = new Set([startId]);
149
+ let frontier = [startId];
150
+
151
+ for (let d = 1; d <= maxDepth; d++) {
152
+ const nextFrontier = [];
153
+ for (const fid of frontier) {
154
+ const callers = db
155
+ .prepare(
156
+ `SELECT DISTINCT n.id, n.name, n.kind, n.file, n.line
157
+ FROM edges e JOIN nodes n ON e.source_id = n.id
158
+ WHERE e.target_id = ? AND e.kind = 'calls'`,
159
+ )
160
+ .all(fid);
161
+
162
+ for (const c of callers) {
163
+ if (!visited.has(c.id) && (!noTests || !isTestFile(c.file))) {
164
+ visited.add(c.id);
165
+ nextFrontier.push(c.id);
166
+ allCallers.add(
167
+ JSON.stringify({ name: c.name, kind: c.kind, file: c.file, line: c.line }),
168
+ );
169
+ }
165
170
  }
166
171
  }
172
+ frontier = nextFrontier;
173
+ if (frontier.length === 0) break;
167
174
  }
168
- frontier = nextFrontier;
169
- if (frontier.length === 0) break;
170
175
  }
171
- }
172
176
 
173
- db.close();
174
- return [...allCallers].map((s) => JSON.parse(s));
177
+ return [...allCallers].map((s) => JSON.parse(s));
178
+ } finally {
179
+ db.close();
180
+ }
175
181
  }
176
182
 
177
183
  // ─── Symbol Comparison ──────────────────────────────────────────────────
@@ -470,99 +476,3 @@ export function branchCompareMermaid(data) {
470
476
 
471
477
  return lines.join('\n');
472
478
  }
473
-
474
- // ─── Text Formatting ────────────────────────────────────────────────────
475
-
476
- function formatText(data) {
477
- if (data.error) return `Error: ${data.error}`;
478
-
479
- const lines = [];
480
- const shortBase = data.baseSha.slice(0, 7);
481
- const shortTarget = data.targetSha.slice(0, 7);
482
-
483
- lines.push(`branch-compare: ${data.baseRef}..${data.targetRef}`);
484
- lines.push(` Base: ${data.baseRef} (${shortBase})`);
485
- lines.push(` Target: ${data.targetRef} (${shortTarget})`);
486
- lines.push(` Files changed: ${data.changedFiles.length}`);
487
-
488
- if (data.added.length > 0) {
489
- lines.push('');
490
- lines.push(` + Added (${data.added.length} symbol${data.added.length !== 1 ? 's' : ''}):`);
491
- for (const sym of data.added) {
492
- lines.push(` [${kindIcon(sym.kind)}] ${sym.name} -- ${sym.file}:${sym.line}`);
493
- }
494
- }
495
-
496
- if (data.removed.length > 0) {
497
- lines.push('');
498
- lines.push(
499
- ` - Removed (${data.removed.length} symbol${data.removed.length !== 1 ? 's' : ''}):`,
500
- );
501
- for (const sym of data.removed) {
502
- lines.push(` [${kindIcon(sym.kind)}] ${sym.name} -- ${sym.file}:${sym.line}`);
503
- if (sym.impact && sym.impact.length > 0) {
504
- lines.push(
505
- ` ^ ${sym.impact.length} transitive caller${sym.impact.length !== 1 ? 's' : ''} affected`,
506
- );
507
- }
508
- }
509
- }
510
-
511
- if (data.changed.length > 0) {
512
- lines.push('');
513
- lines.push(
514
- ` ~ Changed (${data.changed.length} symbol${data.changed.length !== 1 ? 's' : ''}):`,
515
- );
516
- for (const sym of data.changed) {
517
- const parts = [];
518
- if (sym.changes.lineCount !== 0) {
519
- parts.push(`lines: ${sym.base.lineCount} -> ${sym.target.lineCount}`);
520
- }
521
- if (sym.changes.fanIn !== 0) {
522
- parts.push(`fan_in: ${sym.base.fanIn} -> ${sym.target.fanIn}`);
523
- }
524
- if (sym.changes.fanOut !== 0) {
525
- parts.push(`fan_out: ${sym.base.fanOut} -> ${sym.target.fanOut}`);
526
- }
527
- const detail = parts.length > 0 ? ` (${parts.join(', ')})` : '';
528
- lines.push(
529
- ` [${kindIcon(sym.kind)}] ${sym.name} -- ${sym.file}:${sym.base.line}${detail}`,
530
- );
531
- if (sym.impact && sym.impact.length > 0) {
532
- lines.push(
533
- ` ^ ${sym.impact.length} transitive caller${sym.impact.length !== 1 ? 's' : ''} affected`,
534
- );
535
- }
536
- }
537
- }
538
-
539
- const s = data.summary;
540
- lines.push('');
541
- lines.push(
542
- ` Summary: +${s.added} added, -${s.removed} removed, ~${s.changed} changed` +
543
- ` -> ${s.totalImpacted} caller${s.totalImpacted !== 1 ? 's' : ''} impacted` +
544
- (s.filesAffected > 0
545
- ? ` across ${s.filesAffected} file${s.filesAffected !== 1 ? 's' : ''}`
546
- : ''),
547
- );
548
-
549
- return lines.join('\n');
550
- }
551
-
552
- // ─── CLI Display Function ───────────────────────────────────────────────
553
-
554
- export async function branchCompare(baseRef, targetRef, opts = {}) {
555
- const data = await branchCompareData(baseRef, targetRef, opts);
556
-
557
- if (opts.json || opts.format === 'json') {
558
- console.log(JSON.stringify(data, null, 2));
559
- return;
560
- }
561
-
562
- if (opts.format === 'mermaid') {
563
- console.log(branchCompareMermaid(data));
564
- return;
565
- }
566
-
567
- console.log(formatText(data));
568
- }