@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/structure.js CHANGED
@@ -1,9 +1,9 @@
1
1
  import path from 'node:path';
2
2
  import { normalizePath } from './constants.js';
3
- import { openReadonlyOrFail } from './db.js';
3
+ import { getNodeId, openReadonlyOrFail, testFilterSQL } from './db.js';
4
+ import { isTestFile } from './infrastructure/test-filter.js';
4
5
  import { debug } from './logger.js';
5
6
  import { paginateResult } from './paginate.js';
6
- import { isTestFile } from './queries.js';
7
7
 
8
8
  // ─── Build-time: insert directory nodes, contains edges, and metrics ────
9
9
 
@@ -21,9 +21,12 @@ export function buildStructure(db, fileSymbols, _rootDir, lineCountMap, director
21
21
  const insertNode = db.prepare(
22
22
  'INSERT OR IGNORE INTO nodes (name, kind, file, line, end_line) VALUES (?, ?, ?, ?, ?)',
23
23
  );
24
- const getNodeId = db.prepare(
25
- 'SELECT id FROM nodes WHERE name = ? AND kind = ? AND file = ? AND line = ?',
26
- );
24
+ const getNodeIdStmt = {
25
+ get: (name, kind, file, line) => {
26
+ const id = getNodeId(db, name, kind, file, line);
27
+ return id != null ? { id } : undefined;
28
+ },
29
+ };
27
30
  const insertEdge = db.prepare(
28
31
  'INSERT INTO edges (source_id, target_id, kind, confidence, dynamic) VALUES (?, ?, ?, ?, ?)',
29
32
  );
@@ -56,12 +59,12 @@ export function buildStructure(db, fileSymbols, _rootDir, lineCountMap, director
56
59
  }
57
60
  // Delete metrics for changed files
58
61
  for (const f of changedFiles) {
59
- const fileRow = getNodeId.get(f, 'file', f, 0);
62
+ const fileRow = getNodeIdStmt.get(f, 'file', f, 0);
60
63
  if (fileRow) deleteMetricForNode.run(fileRow.id);
61
64
  }
62
65
  // Delete metrics for affected directories
63
66
  for (const dir of affectedDirs) {
64
- const dirRow = getNodeId.get(dir, 'directory', dir, 0);
67
+ const dirRow = getNodeIdStmt.get(dir, 'directory', dir, 0);
65
68
  if (dirRow) deleteMetricForNode.run(dirRow.id);
66
69
  }
67
70
  })();
@@ -126,8 +129,8 @@ export function buildStructure(db, fileSymbols, _rootDir, lineCountMap, director
126
129
  if (!dir || dir === '.') continue;
127
130
  // On incremental, skip dirs whose contains edges are intact
128
131
  if (affectedDirs && !affectedDirs.has(dir)) continue;
129
- const dirRow = getNodeId.get(dir, 'directory', dir, 0);
130
- const fileRow = getNodeId.get(relPath, 'file', relPath, 0);
132
+ const dirRow = getNodeIdStmt.get(dir, 'directory', dir, 0);
133
+ const fileRow = getNodeIdStmt.get(relPath, 'file', relPath, 0);
131
134
  if (dirRow && fileRow) {
132
135
  insertEdge.run(dirRow.id, fileRow.id, 'contains', 1.0, 0);
133
136
  }
@@ -138,8 +141,8 @@ export function buildStructure(db, fileSymbols, _rootDir, lineCountMap, director
138
141
  if (!parent || parent === '.' || parent === dir) continue;
139
142
  // On incremental, skip parent dirs whose contains edges are intact
140
143
  if (affectedDirs && !affectedDirs.has(parent)) continue;
141
- const parentRow = getNodeId.get(parent, 'directory', parent, 0);
142
- const childRow = getNodeId.get(dir, 'directory', dir, 0);
144
+ const parentRow = getNodeIdStmt.get(parent, 'directory', parent, 0);
145
+ const childRow = getNodeIdStmt.get(dir, 'directory', dir, 0);
143
146
  if (parentRow && childRow) {
144
147
  insertEdge.run(parentRow.id, childRow.id, 'contains', 1.0, 0);
145
148
  }
@@ -169,7 +172,7 @@ export function buildStructure(db, fileSymbols, _rootDir, lineCountMap, director
169
172
 
170
173
  const computeFileMetrics = db.transaction(() => {
171
174
  for (const [relPath, symbols] of fileSymbols) {
172
- const fileRow = getNodeId.get(relPath, 'file', relPath, 0);
175
+ const fileRow = getNodeIdStmt.get(relPath, 'file', relPath, 0);
173
176
  if (!fileRow) continue;
174
177
 
175
178
  const lineCount = lineCountMap.get(relPath) || 0;
@@ -263,7 +266,7 @@ export function buildStructure(db, fileSymbols, _rootDir, lineCountMap, director
263
266
 
264
267
  const computeDirMetrics = db.transaction(() => {
265
268
  for (const [dir, files] of dirFiles) {
266
- const dirRow = getNodeId.get(dir, 'directory', dir, 0);
269
+ const dirRow = getNodeIdStmt.get(dir, 'directory', dir, 0);
267
270
  if (!dirRow) continue;
268
271
 
269
272
  const fileCount = files.length;
@@ -413,115 +416,117 @@ export function classifyNodeRoles(db) {
413
416
  */
414
417
  export function structureData(customDbPath, opts = {}) {
415
418
  const db = openReadonlyOrFail(customDbPath);
416
- const rawDir = opts.directory || null;
417
- const filterDir = rawDir && normalizePath(rawDir) !== '.' ? rawDir : null;
418
- const maxDepth = opts.depth || null;
419
- const sortBy = opts.sort || 'files';
420
- const noTests = opts.noTests || false;
421
- const full = opts.full || false;
422
- const fileLimit = opts.fileLimit || 25;
423
-
424
- // Get all directory nodes with their metrics
425
- let dirs = db
426
- .prepare(`
427
- SELECT n.id, n.name, n.file, nm.symbol_count, nm.fan_in, nm.fan_out, nm.cohesion, nm.file_count
428
- FROM nodes n
429
- LEFT JOIN node_metrics nm ON n.id = nm.node_id
430
- WHERE n.kind = 'directory'
431
- `)
432
- .all();
433
-
434
- if (filterDir) {
435
- const norm = normalizePath(filterDir);
436
- dirs = dirs.filter((d) => d.name === norm || d.name.startsWith(`${norm}/`));
437
- }
438
-
439
- if (maxDepth) {
440
- const baseDepth = filterDir ? normalizePath(filterDir).split('/').length : 0;
441
- dirs = dirs.filter((d) => {
442
- const depth = d.name.split('/').length - baseDepth;
443
- return depth <= maxDepth;
444
- });
445
- }
446
-
447
- // Sort
448
- const sortFn = getSortFn(sortBy);
449
- dirs.sort(sortFn);
450
-
451
- // Get file metrics for each directory
452
- const result = dirs.map((d) => {
453
- let files = db
419
+ try {
420
+ const rawDir = opts.directory || null;
421
+ const filterDir = rawDir && normalizePath(rawDir) !== '.' ? rawDir : null;
422
+ const maxDepth = opts.depth || null;
423
+ const sortBy = opts.sort || 'files';
424
+ const noTests = opts.noTests || false;
425
+ const full = opts.full || false;
426
+ const fileLimit = opts.fileLimit || 25;
427
+
428
+ // Get all directory nodes with their metrics
429
+ let dirs = db
454
430
  .prepare(`
455
- SELECT n.name, nm.line_count, nm.symbol_count, nm.import_count, nm.export_count, nm.fan_in, nm.fan_out
456
- FROM edges e
457
- JOIN nodes n ON e.target_id = n.id
431
+ SELECT n.id, n.name, n.file, nm.symbol_count, nm.fan_in, nm.fan_out, nm.cohesion, nm.file_count
432
+ FROM nodes n
458
433
  LEFT JOIN node_metrics nm ON n.id = nm.node_id
459
- WHERE e.source_id = ? AND e.kind = 'contains' AND n.kind = 'file'
434
+ WHERE n.kind = 'directory'
460
435
  `)
461
- .all(d.id);
462
- if (noTests) files = files.filter((f) => !isTestFile(f.name));
436
+ .all();
463
437
 
464
- const subdirs = db
465
- .prepare(`
466
- SELECT n.name
467
- FROM edges e
468
- JOIN nodes n ON e.target_id = n.id
469
- WHERE e.source_id = ? AND e.kind = 'contains' AND n.kind = 'directory'
470
- `)
471
- .all(d.id);
472
-
473
- const fileCount = noTests ? files.length : d.file_count || 0;
474
- return {
475
- directory: d.name,
476
- fileCount,
477
- symbolCount: d.symbol_count || 0,
478
- fanIn: d.fan_in || 0,
479
- fanOut: d.fan_out || 0,
480
- cohesion: d.cohesion,
481
- density: fileCount > 0 ? (d.symbol_count || 0) / fileCount : 0,
482
- files: files.map((f) => ({
483
- file: f.name,
484
- lineCount: f.line_count || 0,
485
- symbolCount: f.symbol_count || 0,
486
- importCount: f.import_count || 0,
487
- exportCount: f.export_count || 0,
488
- fanIn: f.fan_in || 0,
489
- fanOut: f.fan_out || 0,
490
- })),
491
- subdirectories: subdirs.map((s) => s.name),
492
- };
493
- });
438
+ if (filterDir) {
439
+ const norm = normalizePath(filterDir);
440
+ dirs = dirs.filter((d) => d.name === norm || d.name.startsWith(`${norm}/`));
441
+ }
494
442
 
495
- db.close();
496
-
497
- // Apply global file limit unless full mode
498
- if (!full) {
499
- const totalFiles = result.reduce((sum, d) => sum + d.files.length, 0);
500
- if (totalFiles > fileLimit) {
501
- let shown = 0;
502
- for (const d of result) {
503
- const remaining = fileLimit - shown;
504
- if (remaining <= 0) {
505
- d.files = [];
506
- } else if (d.files.length > remaining) {
507
- d.files = d.files.slice(0, remaining);
508
- shown = fileLimit;
509
- } else {
510
- shown += d.files.length;
511
- }
512
- }
513
- const suppressed = totalFiles - fileLimit;
443
+ if (maxDepth) {
444
+ const baseDepth = filterDir ? normalizePath(filterDir).split('/').length : 0;
445
+ dirs = dirs.filter((d) => {
446
+ const depth = d.name.split('/').length - baseDepth;
447
+ return depth <= maxDepth;
448
+ });
449
+ }
450
+
451
+ // Sort
452
+ const sortFn = getSortFn(sortBy);
453
+ dirs.sort(sortFn);
454
+
455
+ // Get file metrics for each directory
456
+ const result = dirs.map((d) => {
457
+ let files = db
458
+ .prepare(`
459
+ SELECT n.name, nm.line_count, nm.symbol_count, nm.import_count, nm.export_count, nm.fan_in, nm.fan_out
460
+ FROM edges e
461
+ JOIN nodes n ON e.target_id = n.id
462
+ LEFT JOIN node_metrics nm ON n.id = nm.node_id
463
+ WHERE e.source_id = ? AND e.kind = 'contains' AND n.kind = 'file'
464
+ `)
465
+ .all(d.id);
466
+ if (noTests) files = files.filter((f) => !isTestFile(f.name));
467
+
468
+ const subdirs = db
469
+ .prepare(`
470
+ SELECT n.name
471
+ FROM edges e
472
+ JOIN nodes n ON e.target_id = n.id
473
+ WHERE e.source_id = ? AND e.kind = 'contains' AND n.kind = 'directory'
474
+ `)
475
+ .all(d.id);
476
+
477
+ const fileCount = noTests ? files.length : d.file_count || 0;
514
478
  return {
515
- directories: result,
516
- count: result.length,
517
- suppressed,
518
- warning: `${suppressed} files omitted (showing ${fileLimit}/${totalFiles}). Use --full to show all files, or narrow with --directory.`,
479
+ directory: d.name,
480
+ fileCount,
481
+ symbolCount: d.symbol_count || 0,
482
+ fanIn: d.fan_in || 0,
483
+ fanOut: d.fan_out || 0,
484
+ cohesion: d.cohesion,
485
+ density: fileCount > 0 ? (d.symbol_count || 0) / fileCount : 0,
486
+ files: files.map((f) => ({
487
+ file: f.name,
488
+ lineCount: f.line_count || 0,
489
+ symbolCount: f.symbol_count || 0,
490
+ importCount: f.import_count || 0,
491
+ exportCount: f.export_count || 0,
492
+ fanIn: f.fan_in || 0,
493
+ fanOut: f.fan_out || 0,
494
+ })),
495
+ subdirectories: subdirs.map((s) => s.name),
519
496
  };
497
+ });
498
+
499
+ // Apply global file limit unless full mode
500
+ if (!full) {
501
+ const totalFiles = result.reduce((sum, d) => sum + d.files.length, 0);
502
+ if (totalFiles > fileLimit) {
503
+ let shown = 0;
504
+ for (const d of result) {
505
+ const remaining = fileLimit - shown;
506
+ if (remaining <= 0) {
507
+ d.files = [];
508
+ } else if (d.files.length > remaining) {
509
+ d.files = d.files.slice(0, remaining);
510
+ shown = fileLimit;
511
+ } else {
512
+ shown += d.files.length;
513
+ }
514
+ }
515
+ const suppressed = totalFiles - fileLimit;
516
+ return {
517
+ directories: result,
518
+ count: result.length,
519
+ suppressed,
520
+ warning: `${suppressed} files omitted (showing ${fileLimit}/${totalFiles}). Use --full to show all files, or narrow with --directory.`,
521
+ };
522
+ }
520
523
  }
521
- }
522
524
 
523
- const base = { directories: result, count: result.length };
524
- return paginateResult(base, 'directories', { limit: opts.limit, offset: opts.offset });
525
+ const base = { directories: result, count: result.length };
526
+ return paginateResult(base, 'directories', { limit: opts.limit, offset: opts.offset });
527
+ } finally {
528
+ db.close();
529
+ }
525
530
  }
526
531
 
527
532
  /**
@@ -529,71 +534,67 @@ export function structureData(customDbPath, opts = {}) {
529
534
  */
530
535
  export function hotspotsData(customDbPath, opts = {}) {
531
536
  const db = openReadonlyOrFail(customDbPath);
532
- const metric = opts.metric || 'fan-in';
533
- const level = opts.level || 'file';
534
- const limit = opts.limit || 10;
535
- const noTests = opts.noTests || false;
536
-
537
- const kind = level === 'directory' ? 'directory' : 'file';
538
-
539
- const testFilter =
540
- noTests && kind === 'file'
541
- ? `AND n.name NOT LIKE '%.test.%'
542
- AND n.name NOT LIKE '%.spec.%'
543
- AND n.name NOT LIKE '%__test__%'
544
- AND n.name NOT LIKE '%__tests__%'
545
- AND n.name NOT LIKE '%.stories.%'`
546
- : '';
547
-
548
- const HOTSPOT_QUERIES = {
549
- 'fan-in': db.prepare(`
550
- SELECT n.name, n.kind, nm.line_count, nm.symbol_count, nm.import_count, nm.export_count,
551
- nm.fan_in, nm.fan_out, nm.cohesion, nm.file_count
552
- FROM nodes n JOIN node_metrics nm ON n.id = nm.node_id
553
- WHERE n.kind = ? ${testFilter} ORDER BY nm.fan_in DESC NULLS LAST LIMIT ?`),
554
- 'fan-out': db.prepare(`
555
- SELECT n.name, n.kind, nm.line_count, nm.symbol_count, nm.import_count, nm.export_count,
556
- nm.fan_in, nm.fan_out, nm.cohesion, nm.file_count
557
- FROM nodes n JOIN node_metrics nm ON n.id = nm.node_id
558
- WHERE n.kind = ? ${testFilter} ORDER BY nm.fan_out DESC NULLS LAST LIMIT ?`),
559
- density: db.prepare(`
560
- SELECT n.name, n.kind, nm.line_count, nm.symbol_count, nm.import_count, nm.export_count,
561
- nm.fan_in, nm.fan_out, nm.cohesion, nm.file_count
562
- FROM nodes n JOIN node_metrics nm ON n.id = nm.node_id
563
- WHERE n.kind = ? ${testFilter} ORDER BY nm.symbol_count DESC NULLS LAST LIMIT ?`),
564
- coupling: db.prepare(`
565
- SELECT n.name, n.kind, nm.line_count, nm.symbol_count, nm.import_count, nm.export_count,
566
- nm.fan_in, nm.fan_out, nm.cohesion, nm.file_count
567
- FROM nodes n JOIN node_metrics nm ON n.id = nm.node_id
568
- WHERE n.kind = ? ${testFilter} ORDER BY (COALESCE(nm.fan_in, 0) + COALESCE(nm.fan_out, 0)) DESC NULLS LAST LIMIT ?`),
569
- };
537
+ try {
538
+ const metric = opts.metric || 'fan-in';
539
+ const level = opts.level || 'file';
540
+ const limit = opts.limit || 10;
541
+ const noTests = opts.noTests || false;
542
+
543
+ const kind = level === 'directory' ? 'directory' : 'file';
544
+
545
+ const testFilter = testFilterSQL('n.name', noTests && kind === 'file');
546
+
547
+ const HOTSPOT_QUERIES = {
548
+ 'fan-in': db.prepare(`
549
+ SELECT n.name, n.kind, nm.line_count, nm.symbol_count, nm.import_count, nm.export_count,
550
+ nm.fan_in, nm.fan_out, nm.cohesion, nm.file_count
551
+ FROM nodes n JOIN node_metrics nm ON n.id = nm.node_id
552
+ WHERE n.kind = ? ${testFilter} ORDER BY nm.fan_in DESC NULLS LAST LIMIT ?`),
553
+ 'fan-out': db.prepare(`
554
+ SELECT n.name, n.kind, nm.line_count, nm.symbol_count, nm.import_count, nm.export_count,
555
+ nm.fan_in, nm.fan_out, nm.cohesion, nm.file_count
556
+ FROM nodes n JOIN node_metrics nm ON n.id = nm.node_id
557
+ WHERE n.kind = ? ${testFilter} ORDER BY nm.fan_out DESC NULLS LAST LIMIT ?`),
558
+ density: db.prepare(`
559
+ SELECT n.name, n.kind, nm.line_count, nm.symbol_count, nm.import_count, nm.export_count,
560
+ nm.fan_in, nm.fan_out, nm.cohesion, nm.file_count
561
+ FROM nodes n JOIN node_metrics nm ON n.id = nm.node_id
562
+ WHERE n.kind = ? ${testFilter} ORDER BY nm.symbol_count DESC NULLS LAST LIMIT ?`),
563
+ coupling: db.prepare(`
564
+ SELECT n.name, n.kind, nm.line_count, nm.symbol_count, nm.import_count, nm.export_count,
565
+ nm.fan_in, nm.fan_out, nm.cohesion, nm.file_count
566
+ FROM nodes n JOIN node_metrics nm ON n.id = nm.node_id
567
+ WHERE n.kind = ? ${testFilter} ORDER BY (COALESCE(nm.fan_in, 0) + COALESCE(nm.fan_out, 0)) DESC NULLS LAST LIMIT ?`),
568
+ };
570
569
 
571
- const stmt = HOTSPOT_QUERIES[metric] || HOTSPOT_QUERIES['fan-in'];
572
- const rows = stmt.all(kind, limit);
573
-
574
- const hotspots = rows.map((r) => ({
575
- name: r.name,
576
- kind: r.kind,
577
- lineCount: r.line_count,
578
- symbolCount: r.symbol_count,
579
- importCount: r.import_count,
580
- exportCount: r.export_count,
581
- fanIn: r.fan_in,
582
- fanOut: r.fan_out,
583
- cohesion: r.cohesion,
584
- fileCount: r.file_count,
585
- density:
586
- r.file_count > 0
587
- ? (r.symbol_count || 0) / r.file_count
588
- : r.line_count > 0
589
- ? (r.symbol_count || 0) / r.line_count
590
- : 0,
591
- coupling: (r.fan_in || 0) + (r.fan_out || 0),
592
- }));
593
-
594
- db.close();
595
- const base = { metric, level, limit, hotspots };
596
- return paginateResult(base, 'hotspots', { limit: opts.limit, offset: opts.offset });
570
+ const stmt = HOTSPOT_QUERIES[metric] || HOTSPOT_QUERIES['fan-in'];
571
+ const rows = stmt.all(kind, limit);
572
+
573
+ const hotspots = rows.map((r) => ({
574
+ name: r.name,
575
+ kind: r.kind,
576
+ lineCount: r.line_count,
577
+ symbolCount: r.symbol_count,
578
+ importCount: r.import_count,
579
+ exportCount: r.export_count,
580
+ fanIn: r.fan_in,
581
+ fanOut: r.fan_out,
582
+ cohesion: r.cohesion,
583
+ fileCount: r.file_count,
584
+ density:
585
+ r.file_count > 0
586
+ ? (r.symbol_count || 0) / r.file_count
587
+ : r.line_count > 0
588
+ ? (r.symbol_count || 0) / r.line_count
589
+ : 0,
590
+ coupling: (r.fan_in || 0) + (r.fan_out || 0),
591
+ }));
592
+
593
+ const base = { metric, level, limit, hotspots };
594
+ return paginateResult(base, 'hotspots', { limit: opts.limit, offset: opts.offset });
595
+ } finally {
596
+ db.close();
597
+ }
597
598
  }
598
599
 
599
600
  /**
@@ -601,104 +602,45 @@ export function hotspotsData(customDbPath, opts = {}) {
601
602
  */
602
603
  export function moduleBoundariesData(customDbPath, opts = {}) {
603
604
  const db = openReadonlyOrFail(customDbPath);
604
- const threshold = opts.threshold || 0.3;
605
-
606
- const dirs = db
607
- .prepare(`
608
- SELECT n.id, n.name, nm.symbol_count, nm.fan_in, nm.fan_out, nm.cohesion, nm.file_count
609
- FROM nodes n
610
- JOIN node_metrics nm ON n.id = nm.node_id
611
- WHERE n.kind = 'directory' AND nm.cohesion IS NOT NULL AND nm.cohesion >= ?
612
- ORDER BY nm.cohesion DESC
613
- `)
614
- .all(threshold);
605
+ try {
606
+ const threshold = opts.threshold || 0.3;
615
607
 
616
- const modules = dirs.map((d) => {
617
- // Get files inside this directory
618
- const files = db
608
+ const dirs = db
619
609
  .prepare(`
620
- SELECT n.name FROM edges e
621
- JOIN nodes n ON e.target_id = n.id
622
- WHERE e.source_id = ? AND e.kind = 'contains' AND n.kind = 'file'
610
+ SELECT n.id, n.name, nm.symbol_count, nm.fan_in, nm.fan_out, nm.cohesion, nm.file_count
611
+ FROM nodes n
612
+ JOIN node_metrics nm ON n.id = nm.node_id
613
+ WHERE n.kind = 'directory' AND nm.cohesion IS NOT NULL AND nm.cohesion >= ?
614
+ ORDER BY nm.cohesion DESC
623
615
  `)
624
- .all(d.id)
625
- .map((f) => f.name);
626
-
627
- return {
628
- directory: d.name,
629
- cohesion: d.cohesion,
630
- fileCount: d.file_count || 0,
631
- symbolCount: d.symbol_count || 0,
632
- fanIn: d.fan_in || 0,
633
- fanOut: d.fan_out || 0,
634
- files,
635
- };
636
- });
637
-
638
- db.close();
639
- return { threshold, modules, count: modules.length };
640
- }
616
+ .all(threshold);
617
+
618
+ const modules = dirs.map((d) => {
619
+ // Get files inside this directory
620
+ const files = db
621
+ .prepare(`
622
+ SELECT n.name FROM edges e
623
+ JOIN nodes n ON e.target_id = n.id
624
+ WHERE e.source_id = ? AND e.kind = 'contains' AND n.kind = 'file'
625
+ `)
626
+ .all(d.id)
627
+ .map((f) => f.name);
641
628
 
642
- // ─── Formatters ───────────────────────────────────────────────────────
643
-
644
- export function formatStructure(data) {
645
- if (data.count === 0) return 'No directory structure found. Run "codegraph build" first.';
646
-
647
- const lines = [`\nProject structure (${data.count} directories):\n`];
648
- for (const d of data.directories) {
649
- const cohStr = d.cohesion !== null ? ` cohesion=${d.cohesion.toFixed(2)}` : '';
650
- const depth = d.directory.split('/').length - 1;
651
- const indent = ' '.repeat(depth);
652
- lines.push(
653
- `${indent}${d.directory}/ (${d.fileCount} files, ${d.symbolCount} symbols, <-${d.fanIn} ->${d.fanOut}${cohStr})`,
654
- );
655
- for (const f of d.files) {
656
- lines.push(
657
- `${indent} ${path.basename(f.file)} ${f.lineCount}L ${f.symbolCount}sym <-${f.fanIn} ->${f.fanOut}`,
658
- );
659
- }
660
- }
661
- if (data.warning) {
662
- lines.push('');
663
- lines.push(`⚠ ${data.warning}`);
664
- }
665
- return lines.join('\n');
666
- }
667
-
668
- export function formatHotspots(data) {
669
- if (data.hotspots.length === 0) return 'No hotspots found. Run "codegraph build" first.';
670
-
671
- const lines = [`\nHotspots by ${data.metric} (${data.level}-level, top ${data.limit}):\n`];
672
- let rank = 1;
673
- for (const h of data.hotspots) {
674
- const extra =
675
- h.kind === 'directory'
676
- ? `${h.fileCount} files, cohesion=${h.cohesion !== null ? h.cohesion.toFixed(2) : 'n/a'}`
677
- : `${h.lineCount || 0}L, ${h.symbolCount || 0} symbols`;
678
- lines.push(
679
- ` ${String(rank++).padStart(2)}. ${h.name} <-${h.fanIn || 0} ->${h.fanOut || 0} (${extra})`,
680
- );
681
- }
682
- return lines.join('\n');
683
- }
684
-
685
- export function formatModuleBoundaries(data) {
686
- if (data.count === 0) return `No modules found with cohesion >= ${data.threshold}.`;
629
+ return {
630
+ directory: d.name,
631
+ cohesion: d.cohesion,
632
+ fileCount: d.file_count || 0,
633
+ symbolCount: d.symbol_count || 0,
634
+ fanIn: d.fan_in || 0,
635
+ fanOut: d.fan_out || 0,
636
+ files,
637
+ };
638
+ });
687
639
 
688
- const lines = [`\nModule boundaries (cohesion >= ${data.threshold}, ${data.count} modules):\n`];
689
- for (const m of data.modules) {
690
- lines.push(
691
- ` ${m.directory}/ cohesion=${m.cohesion.toFixed(2)} (${m.fileCount} files, ${m.symbolCount} symbols)`,
692
- );
693
- lines.push(` Incoming: ${m.fanIn} edges Outgoing: ${m.fanOut} edges`);
694
- if (m.files.length > 0) {
695
- lines.push(
696
- ` Files: ${m.files.slice(0, 5).join(', ')}${m.files.length > 5 ? ` ... +${m.files.length - 5}` : ''}`,
697
- );
698
- }
699
- lines.push('');
640
+ return { threshold, modules, count: modules.length };
641
+ } finally {
642
+ db.close();
700
643
  }
701
- return lines.join('\n');
702
644
  }
703
645
 
704
646
  // ─── Helpers ──────────────────────────────────────────────────────────