@optave/codegraph 2.4.0 → 2.5.1

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/src/queries.js CHANGED
@@ -5,6 +5,7 @@ import { coChangeForFiles } from './cochange.js';
5
5
  import { findCycles } from './cycles.js';
6
6
  import { findDbPath, openReadonlyOrFail } from './db.js';
7
7
  import { debug } from './logger.js';
8
+ import { paginateResult } from './paginate.js';
8
9
  import { LANGUAGE_REGISTRY } from './parser.js';
9
10
 
10
11
  /**
@@ -172,7 +173,7 @@ function findMatchingNodes(db, name, opts = {}) {
172
173
  return nodes;
173
174
  }
174
175
 
175
- function kindIcon(kind) {
176
+ export function kindIcon(kind) {
176
177
  switch (kind) {
177
178
  case 'function':
178
179
  return 'f';
@@ -248,7 +249,8 @@ export function queryNameData(name, customDbPath, opts = {}) {
248
249
  });
249
250
 
250
251
  db.close();
251
- return { query: name, results };
252
+ const base = { query: name, results };
253
+ return paginateResult(base, 'results', { limit: opts.limit, offset: opts.offset });
252
254
  }
253
255
 
254
256
  export function impactAnalysisData(file, customDbPath, opts = {}) {
@@ -565,6 +567,255 @@ export function fnImpactData(name, customDbPath, opts = {}) {
565
567
  return { name, results };
566
568
  }
567
569
 
570
+ export function pathData(from, to, customDbPath, opts = {}) {
571
+ const db = openReadonlyOrFail(customDbPath);
572
+ const noTests = opts.noTests || false;
573
+ const maxDepth = opts.maxDepth || 10;
574
+ const edgeKinds = opts.edgeKinds || ['calls'];
575
+ const reverse = opts.reverse || false;
576
+
577
+ const fromNodes = findMatchingNodes(db, from, {
578
+ noTests,
579
+ file: opts.fromFile,
580
+ kind: opts.kind,
581
+ });
582
+ if (fromNodes.length === 0) {
583
+ db.close();
584
+ return {
585
+ from,
586
+ to,
587
+ found: false,
588
+ error: `No symbol matching "${from}"`,
589
+ fromCandidates: [],
590
+ toCandidates: [],
591
+ };
592
+ }
593
+
594
+ const toNodes = findMatchingNodes(db, to, {
595
+ noTests,
596
+ file: opts.toFile,
597
+ kind: opts.kind,
598
+ });
599
+ if (toNodes.length === 0) {
600
+ db.close();
601
+ return {
602
+ from,
603
+ to,
604
+ found: false,
605
+ error: `No symbol matching "${to}"`,
606
+ fromCandidates: fromNodes
607
+ .slice(0, 5)
608
+ .map((n) => ({ name: n.name, kind: n.kind, file: n.file, line: n.line })),
609
+ toCandidates: [],
610
+ };
611
+ }
612
+
613
+ const sourceNode = fromNodes[0];
614
+ const targetNode = toNodes[0];
615
+
616
+ const fromCandidates = fromNodes
617
+ .slice(0, 5)
618
+ .map((n) => ({ name: n.name, kind: n.kind, file: n.file, line: n.line }));
619
+ const toCandidates = toNodes
620
+ .slice(0, 5)
621
+ .map((n) => ({ name: n.name, kind: n.kind, file: n.file, line: n.line }));
622
+
623
+ // Self-path
624
+ if (sourceNode.id === targetNode.id) {
625
+ db.close();
626
+ return {
627
+ from,
628
+ to,
629
+ fromCandidates,
630
+ toCandidates,
631
+ found: true,
632
+ hops: 0,
633
+ path: [
634
+ {
635
+ name: sourceNode.name,
636
+ kind: sourceNode.kind,
637
+ file: sourceNode.file,
638
+ line: sourceNode.line,
639
+ edgeKind: null,
640
+ },
641
+ ],
642
+ alternateCount: 0,
643
+ edgeKinds,
644
+ reverse,
645
+ maxDepth,
646
+ };
647
+ }
648
+
649
+ // Build edge kind filter
650
+ const kindPlaceholders = edgeKinds.map(() => '?').join(', ');
651
+
652
+ // BFS — direction depends on `reverse` flag
653
+ // Forward: source_id → target_id (A calls... calls B)
654
+ // Reverse: target_id → source_id (B is called by... called by A)
655
+ const neighborQuery = reverse
656
+ ? `SELECT n.id, n.name, n.kind, n.file, n.line, e.kind AS edge_kind
657
+ FROM edges e JOIN nodes n ON e.source_id = n.id
658
+ WHERE e.target_id = ? AND e.kind IN (${kindPlaceholders})`
659
+ : `SELECT n.id, n.name, n.kind, n.file, n.line, e.kind AS edge_kind
660
+ FROM edges e JOIN nodes n ON e.target_id = n.id
661
+ WHERE e.source_id = ? AND e.kind IN (${kindPlaceholders})`;
662
+ const neighborStmt = db.prepare(neighborQuery);
663
+
664
+ const visited = new Set([sourceNode.id]);
665
+ // parent map: nodeId → { parentId, edgeKind }
666
+ const parent = new Map();
667
+ let queue = [sourceNode.id];
668
+ let found = false;
669
+ let alternateCount = 0;
670
+ let foundDepth = -1;
671
+
672
+ for (let depth = 1; depth <= maxDepth; depth++) {
673
+ const nextQueue = [];
674
+ for (const currentId of queue) {
675
+ const neighbors = neighborStmt.all(currentId, ...edgeKinds);
676
+ for (const n of neighbors) {
677
+ if (noTests && isTestFile(n.file)) continue;
678
+ if (n.id === targetNode.id) {
679
+ if (!found) {
680
+ found = true;
681
+ foundDepth = depth;
682
+ parent.set(n.id, { parentId: currentId, edgeKind: n.edge_kind });
683
+ }
684
+ alternateCount++;
685
+ continue;
686
+ }
687
+ if (!visited.has(n.id)) {
688
+ visited.add(n.id);
689
+ parent.set(n.id, { parentId: currentId, edgeKind: n.edge_kind });
690
+ nextQueue.push(n.id);
691
+ }
692
+ }
693
+ }
694
+ if (found) break;
695
+ queue = nextQueue;
696
+ if (queue.length === 0) break;
697
+ }
698
+
699
+ if (!found) {
700
+ db.close();
701
+ return {
702
+ from,
703
+ to,
704
+ fromCandidates,
705
+ toCandidates,
706
+ found: false,
707
+ hops: null,
708
+ path: [],
709
+ alternateCount: 0,
710
+ edgeKinds,
711
+ reverse,
712
+ maxDepth,
713
+ };
714
+ }
715
+
716
+ // alternateCount includes the one we kept; subtract 1 for "alternates"
717
+ alternateCount = Math.max(0, alternateCount - 1);
718
+
719
+ // Reconstruct path from target back to source
720
+ const pathIds = [targetNode.id];
721
+ let cur = targetNode.id;
722
+ while (cur !== sourceNode.id) {
723
+ const p = parent.get(cur);
724
+ pathIds.push(p.parentId);
725
+ cur = p.parentId;
726
+ }
727
+ pathIds.reverse();
728
+
729
+ // Build path with node info
730
+ const nodeCache = new Map();
731
+ const getNode = (id) => {
732
+ if (nodeCache.has(id)) return nodeCache.get(id);
733
+ const row = db.prepare('SELECT name, kind, file, line FROM nodes WHERE id = ?').get(id);
734
+ nodeCache.set(id, row);
735
+ return row;
736
+ };
737
+
738
+ const resultPath = pathIds.map((id, idx) => {
739
+ const node = getNode(id);
740
+ const edgeKind = idx === 0 ? null : parent.get(id).edgeKind;
741
+ return { name: node.name, kind: node.kind, file: node.file, line: node.line, edgeKind };
742
+ });
743
+
744
+ db.close();
745
+ return {
746
+ from,
747
+ to,
748
+ fromCandidates,
749
+ toCandidates,
750
+ found: true,
751
+ hops: foundDepth,
752
+ path: resultPath,
753
+ alternateCount,
754
+ edgeKinds,
755
+ reverse,
756
+ maxDepth,
757
+ };
758
+ }
759
+
760
+ export function symbolPath(from, to, customDbPath, opts = {}) {
761
+ const data = pathData(from, to, customDbPath, opts);
762
+ if (opts.json) {
763
+ console.log(JSON.stringify(data, null, 2));
764
+ return;
765
+ }
766
+
767
+ if (data.error) {
768
+ console.log(data.error);
769
+ return;
770
+ }
771
+
772
+ if (!data.found) {
773
+ const dir = data.reverse ? 'reverse ' : '';
774
+ console.log(`No ${dir}path from "${from}" to "${to}" within ${data.maxDepth} hops.`);
775
+ if (data.fromCandidates.length > 1) {
776
+ console.log(
777
+ `\n "${from}" matched ${data.fromCandidates.length} symbols — using top match: ${data.fromCandidates[0].name} (${data.fromCandidates[0].file}:${data.fromCandidates[0].line})`,
778
+ );
779
+ }
780
+ if (data.toCandidates.length > 1) {
781
+ console.log(
782
+ ` "${to}" matched ${data.toCandidates.length} symbols — using top match: ${data.toCandidates[0].name} (${data.toCandidates[0].file}:${data.toCandidates[0].line})`,
783
+ );
784
+ }
785
+ return;
786
+ }
787
+
788
+ if (data.hops === 0) {
789
+ console.log(`\n"${from}" and "${to}" resolve to the same symbol (0 hops):`);
790
+ const n = data.path[0];
791
+ console.log(` ${kindIcon(n.kind)} ${n.name} (${n.kind}) -- ${n.file}:${n.line}\n`);
792
+ return;
793
+ }
794
+
795
+ const dir = data.reverse ? ' (reverse)' : '';
796
+ console.log(
797
+ `\nPath from ${from} to ${to} (${data.hops} ${data.hops === 1 ? 'hop' : 'hops'})${dir}:\n`,
798
+ );
799
+ for (let i = 0; i < data.path.length; i++) {
800
+ const n = data.path[i];
801
+ const indent = ' '.repeat(i + 1);
802
+ if (i === 0) {
803
+ console.log(`${indent}${kindIcon(n.kind)} ${n.name} (${n.kind}) -- ${n.file}:${n.line}`);
804
+ } else {
805
+ console.log(
806
+ `${indent}--[${n.edgeKind}]--> ${kindIcon(n.kind)} ${n.name} (${n.kind}) -- ${n.file}:${n.line}`,
807
+ );
808
+ }
809
+ }
810
+
811
+ if (data.alternateCount > 0) {
812
+ console.log(
813
+ `\n (${data.alternateCount} alternate shortest ${data.alternateCount === 1 ? 'path' : 'paths'} at same depth)`,
814
+ );
815
+ }
816
+ console.log();
817
+ }
818
+
568
819
  /**
569
820
  * Fix #2: Shell injection vulnerability.
570
821
  * Uses execFileSync instead of execSync to prevent shell interpretation of user input.
@@ -904,7 +1155,8 @@ export function listFunctionsData(customDbPath, opts = {}) {
904
1155
  if (noTests) rows = rows.filter((r) => !isTestFile(r.file));
905
1156
 
906
1157
  db.close();
907
- return { count: rows.length, functions: rows };
1158
+ const base = { count: rows.length, functions: rows };
1159
+ return paginateResult(base, 'functions', { limit: opts.limit, offset: opts.offset });
908
1160
  }
909
1161
 
910
1162
  export function statsData(customDbPath, opts = {}) {
@@ -1114,6 +1366,32 @@ export function statsData(customDbPath, opts = {}) {
1114
1366
  const roles = {};
1115
1367
  for (const r of roleRows) roles[r.role] = r.c;
1116
1368
 
1369
+ // Complexity summary
1370
+ let complexity = null;
1371
+ try {
1372
+ const cRows = db
1373
+ .prepare(
1374
+ `SELECT fc.cognitive, fc.cyclomatic, fc.max_nesting, fc.maintainability_index
1375
+ FROM function_complexity fc JOIN nodes n ON fc.node_id = n.id
1376
+ WHERE n.kind IN ('function','method') ${testFilter}`,
1377
+ )
1378
+ .all();
1379
+ if (cRows.length > 0) {
1380
+ const miValues = cRows.map((r) => r.maintainability_index || 0);
1381
+ complexity = {
1382
+ analyzed: cRows.length,
1383
+ avgCognitive: +(cRows.reduce((s, r) => s + r.cognitive, 0) / cRows.length).toFixed(1),
1384
+ avgCyclomatic: +(cRows.reduce((s, r) => s + r.cyclomatic, 0) / cRows.length).toFixed(1),
1385
+ maxCognitive: Math.max(...cRows.map((r) => r.cognitive)),
1386
+ maxCyclomatic: Math.max(...cRows.map((r) => r.cyclomatic)),
1387
+ avgMI: +(miValues.reduce((s, v) => s + v, 0) / miValues.length).toFixed(1),
1388
+ minMI: +Math.min(...miValues).toFixed(1),
1389
+ };
1390
+ }
1391
+ } catch {
1392
+ /* table may not exist in older DBs */
1393
+ }
1394
+
1117
1395
  db.close();
1118
1396
  return {
1119
1397
  nodes: { total: totalNodes, byKind: nodesByKind },
@@ -1124,11 +1402,21 @@ export function statsData(customDbPath, opts = {}) {
1124
1402
  embeddings,
1125
1403
  quality,
1126
1404
  roles,
1405
+ complexity,
1127
1406
  };
1128
1407
  }
1129
1408
 
1130
- export function stats(customDbPath, opts = {}) {
1409
+ export async function stats(customDbPath, opts = {}) {
1131
1410
  const data = statsData(customDbPath, { noTests: opts.noTests });
1411
+
1412
+ // Community detection summary (async import for lazy-loading)
1413
+ try {
1414
+ const { communitySummaryForStats } = await import('./communities.js');
1415
+ data.communities = communitySummaryForStats(customDbPath, { noTests: opts.noTests });
1416
+ } catch {
1417
+ /* graphology may not be available */
1418
+ }
1419
+
1132
1420
  if (opts.json) {
1133
1421
  console.log(JSON.stringify(data, null, 2));
1134
1422
  return;
@@ -1236,13 +1524,39 @@ export function stats(customDbPath, opts = {}) {
1236
1524
  }
1237
1525
  }
1238
1526
 
1527
+ // Complexity
1528
+ if (data.complexity) {
1529
+ const cx = data.complexity;
1530
+ const miPart = cx.avgMI != null ? ` | avg MI: ${cx.avgMI} | min MI: ${cx.minMI}` : '';
1531
+ console.log(
1532
+ `\nComplexity: ${cx.analyzed} functions | avg cognitive: ${cx.avgCognitive} | avg cyclomatic: ${cx.avgCyclomatic} | max cognitive: ${cx.maxCognitive}${miPart}`,
1533
+ );
1534
+ }
1535
+
1536
+ // Communities
1537
+ if (data.communities) {
1538
+ const cm = data.communities;
1539
+ console.log(
1540
+ `\nCommunities: ${cm.communityCount} detected | modularity: ${cm.modularity} | drift: ${cm.driftScore}%`,
1541
+ );
1542
+ }
1543
+
1239
1544
  console.log();
1240
1545
  }
1241
1546
 
1242
1547
  // ─── Human-readable output (original formatting) ───────────────────────
1243
1548
 
1244
1549
  export function queryName(name, customDbPath, opts = {}) {
1245
- const data = queryNameData(name, customDbPath, { noTests: opts.noTests });
1550
+ const data = queryNameData(name, customDbPath, {
1551
+ noTests: opts.noTests,
1552
+ limit: opts.limit,
1553
+ offset: opts.offset,
1554
+ });
1555
+ if (opts.ndjson) {
1556
+ if (data._pagination) console.log(JSON.stringify({ _meta: data._pagination }));
1557
+ for (const r of data.results) console.log(JSON.stringify(r));
1558
+ return;
1559
+ }
1246
1560
  if (opts.json) {
1247
1561
  console.log(JSON.stringify(data, null, 2));
1248
1562
  return;
@@ -1698,6 +2012,27 @@ export function contextData(name, customDbPath, opts = {}) {
1698
2012
  });
1699
2013
  }
1700
2014
 
2015
+ // Complexity metrics
2016
+ let complexityMetrics = null;
2017
+ try {
2018
+ const cRow = db
2019
+ .prepare(
2020
+ 'SELECT cognitive, cyclomatic, max_nesting, maintainability_index, halstead_volume FROM function_complexity WHERE node_id = ?',
2021
+ )
2022
+ .get(node.id);
2023
+ if (cRow) {
2024
+ complexityMetrics = {
2025
+ cognitive: cRow.cognitive,
2026
+ cyclomatic: cRow.cyclomatic,
2027
+ maxNesting: cRow.max_nesting,
2028
+ maintainabilityIndex: cRow.maintainability_index || 0,
2029
+ halsteadVolume: cRow.halstead_volume || 0,
2030
+ };
2031
+ }
2032
+ } catch {
2033
+ /* table may not exist */
2034
+ }
2035
+
1701
2036
  return {
1702
2037
  name: node.name,
1703
2038
  kind: node.kind,
@@ -1707,6 +2042,7 @@ export function contextData(name, customDbPath, opts = {}) {
1707
2042
  endLine: node.end_line || null,
1708
2043
  source,
1709
2044
  signature,
2045
+ complexity: complexityMetrics,
1710
2046
  callees,
1711
2047
  callers,
1712
2048
  relatedTests,
@@ -1741,6 +2077,17 @@ export function context(name, customDbPath, opts = {}) {
1741
2077
  console.log();
1742
2078
  }
1743
2079
 
2080
+ // Complexity
2081
+ if (r.complexity) {
2082
+ const cx = r.complexity;
2083
+ const miPart = cx.maintainabilityIndex ? ` | MI: ${cx.maintainabilityIndex}` : '';
2084
+ console.log('## Complexity');
2085
+ console.log(
2086
+ ` Cognitive: ${cx.cognitive} | Cyclomatic: ${cx.cyclomatic} | Max Nesting: ${cx.maxNesting}${miPart}`,
2087
+ );
2088
+ console.log();
2089
+ }
2090
+
1744
2091
  // Source
1745
2092
  if (r.source) {
1746
2093
  console.log('## Source');
@@ -1959,6 +2306,27 @@ function explainFunctionImpl(db, target, noTests, getFileLines) {
1959
2306
  .filter((r) => isTestFile(r.file))
1960
2307
  .map((r) => ({ file: r.file }));
1961
2308
 
2309
+ // Complexity metrics
2310
+ let complexityMetrics = null;
2311
+ try {
2312
+ const cRow = db
2313
+ .prepare(
2314
+ 'SELECT cognitive, cyclomatic, max_nesting, maintainability_index, halstead_volume FROM function_complexity WHERE node_id = ?',
2315
+ )
2316
+ .get(node.id);
2317
+ if (cRow) {
2318
+ complexityMetrics = {
2319
+ cognitive: cRow.cognitive,
2320
+ cyclomatic: cRow.cyclomatic,
2321
+ maxNesting: cRow.max_nesting,
2322
+ maintainabilityIndex: cRow.maintainability_index || 0,
2323
+ halsteadVolume: cRow.halstead_volume || 0,
2324
+ };
2325
+ }
2326
+ } catch {
2327
+ /* table may not exist */
2328
+ }
2329
+
1962
2330
  return {
1963
2331
  name: node.name,
1964
2332
  kind: node.kind,
@@ -1969,6 +2337,7 @@ function explainFunctionImpl(db, target, noTests, getFileLines) {
1969
2337
  lineCount,
1970
2338
  summary,
1971
2339
  signature,
2340
+ complexity: complexityMetrics,
1972
2341
  callees,
1973
2342
  callers,
1974
2343
  relatedTests,
@@ -2118,6 +2487,14 @@ export function explain(target, customDbPath, opts = {}) {
2118
2487
  if (r.signature.returnType) console.log(`${indent} Returns: ${r.signature.returnType}`);
2119
2488
  }
2120
2489
 
2490
+ if (r.complexity) {
2491
+ const cx = r.complexity;
2492
+ const miPart = cx.maintainabilityIndex ? ` MI=${cx.maintainabilityIndex}` : '';
2493
+ console.log(
2494
+ `${indent} Complexity: cognitive=${cx.cognitive} cyclomatic=${cx.cyclomatic} nesting=${cx.maxNesting}${miPart}`,
2495
+ );
2496
+ }
2497
+
2121
2498
  if (r.callees.length > 0) {
2122
2499
  console.log(`\n${indent} Calls (${r.callees.length}):`);
2123
2500
  for (const c of r.callees) {
@@ -2261,11 +2638,17 @@ export function whereData(target, customDbPath, opts = {}) {
2261
2638
  const results = fileMode ? whereFileImpl(db, target) : whereSymbolImpl(db, target, noTests);
2262
2639
 
2263
2640
  db.close();
2264
- return { target, mode: fileMode ? 'file' : 'symbol', results };
2641
+ const base = { target, mode: fileMode ? 'file' : 'symbol', results };
2642
+ return paginateResult(base, 'results', { limit: opts.limit, offset: opts.offset });
2265
2643
  }
2266
2644
 
2267
2645
  export function where(target, customDbPath, opts = {}) {
2268
2646
  const data = whereData(target, customDbPath, opts);
2647
+ if (opts.ndjson) {
2648
+ if (data._pagination) console.log(JSON.stringify({ _meta: data._pagination }));
2649
+ for (const r of data.results) console.log(JSON.stringify(r));
2650
+ return;
2651
+ }
2269
2652
  if (opts.json) {
2270
2653
  console.log(JSON.stringify(data, null, 2));
2271
2654
  return;
@@ -2347,11 +2730,17 @@ export function rolesData(customDbPath, opts = {}) {
2347
2730
  }
2348
2731
 
2349
2732
  db.close();
2350
- return { count: rows.length, summary, symbols: rows };
2733
+ const base = { count: rows.length, summary, symbols: rows };
2734
+ return paginateResult(base, 'symbols', { limit: opts.limit, offset: opts.offset });
2351
2735
  }
2352
2736
 
2353
2737
  export function roles(customDbPath, opts = {}) {
2354
2738
  const data = rolesData(customDbPath, opts);
2739
+ if (opts.ndjson) {
2740
+ if (data._pagination) console.log(JSON.stringify({ _meta: data._pagination }));
2741
+ for (const s of data.symbols) console.log(JSON.stringify(s));
2742
+ return;
2743
+ }
2355
2744
  if (opts.json) {
2356
2745
  console.log(JSON.stringify(data, null, 2));
2357
2746
  return;
package/src/registry.js CHANGED
@@ -135,11 +135,14 @@ export function resolveRepoDbPath(name, registryPath = REGISTRY_PATH) {
135
135
  * Remove registry entries whose repo directory no longer exists on disk,
136
136
  * or that haven't been accessed within `ttlDays` days.
137
137
  * Returns an array of `{ name, path, reason }` for each pruned entry.
138
+ *
139
+ * When `dryRun` is true, entries are identified but not removed from disk.
138
140
  */
139
141
  export function pruneRegistry(
140
142
  registryPath = REGISTRY_PATH,
141
143
  ttlDays = DEFAULT_TTL_DAYS,
142
144
  excludeNames = [],
145
+ dryRun = false,
143
146
  ) {
144
147
  const registry = loadRegistry(registryPath);
145
148
  const pruned = [];
@@ -152,17 +155,17 @@ export function pruneRegistry(
152
155
  if (excludeSet.has(name)) continue;
153
156
  if (!fs.existsSync(entry.path)) {
154
157
  pruned.push({ name, path: entry.path, reason: 'missing' });
155
- delete registry.repos[name];
158
+ if (!dryRun) delete registry.repos[name];
156
159
  continue;
157
160
  }
158
161
  const lastAccess = Date.parse(entry.lastAccessedAt || entry.addedAt);
159
162
  if (lastAccess < cutoff) {
160
163
  pruned.push({ name, path: entry.path, reason: 'expired' });
161
- delete registry.repos[name];
164
+ if (!dryRun) delete registry.repos[name];
162
165
  }
163
166
  }
164
167
 
165
- if (pruned.length > 0) {
168
+ if (!dryRun && pruned.length > 0) {
166
169
  saveRegistry(registry, registryPath);
167
170
  }
168
171