@optave/codegraph 2.5.0 → 2.6.0

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/cli.js CHANGED
@@ -3,6 +3,8 @@
3
3
  import fs from 'node:fs';
4
4
  import path from 'node:path';
5
5
  import { Command } from 'commander';
6
+ import { audit } from './audit.js';
7
+ import { BATCH_COMMANDS, batch } from './batch.js';
6
8
  import { buildGraph } from './builder.js';
7
9
  import { loadConfig } from './config.js';
8
10
  import { findCycles, formatCycles } from './cycles.js';
@@ -16,6 +18,7 @@ import {
16
18
  } from './embedder.js';
17
19
  import { exportDOT, exportJSON, exportMermaid } from './export.js';
18
20
  import { setVerbose } from './logger.js';
21
+ import { printNdjson } from './paginate.js';
19
22
  import {
20
23
  ALL_SYMBOL_KINDS,
21
24
  context,
@@ -40,6 +43,7 @@ import {
40
43
  registerRepo,
41
44
  unregisterRepo,
42
45
  } from './registry.js';
46
+ import { snapshotDelete, snapshotList, snapshotRestore, snapshotSave } from './snapshot.js';
43
47
  import { checkForUpdates, printUpdateNotification } from './update-check.js';
44
48
  import { watchProject } from './watcher.js';
45
49
 
@@ -83,6 +87,12 @@ function resolveNoTests(opts) {
83
87
  return config.query?.excludeTests || false;
84
88
  }
85
89
 
90
+ function formatSize(bytes) {
91
+ if (bytes < 1024) return `${bytes} B`;
92
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
93
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
94
+ }
95
+
86
96
  program
87
97
  .command('build [dir]')
88
98
  .description('Parse repo and build graph in .codegraph/graph.db')
@@ -120,8 +130,17 @@ program
120
130
  .option('-T, --no-tests', 'Exclude test/spec files from results')
121
131
  .option('--include-tests', 'Include test/spec files (overrides excludeTests config)')
122
132
  .option('-j, --json', 'Output as JSON')
133
+ .option('--limit <number>', 'Max results to return')
134
+ .option('--offset <number>', 'Skip N results (default: 0)')
135
+ .option('--ndjson', 'Newline-delimited JSON output')
123
136
  .action((file, opts) => {
124
- impactAnalysis(file, opts.db, { noTests: resolveNoTests(opts), json: opts.json });
137
+ impactAnalysis(file, opts.db, {
138
+ noTests: resolveNoTests(opts),
139
+ json: opts.json,
140
+ limit: opts.limit ? parseInt(opts.limit, 10) : undefined,
141
+ offset: opts.offset ? parseInt(opts.offset, 10) : undefined,
142
+ ndjson: opts.ndjson,
143
+ });
125
144
  });
126
145
 
127
146
  program
@@ -157,8 +176,17 @@ program
157
176
  .option('-T, --no-tests', 'Exclude test/spec files from results')
158
177
  .option('--include-tests', 'Include test/spec files (overrides excludeTests config)')
159
178
  .option('-j, --json', 'Output as JSON')
179
+ .option('--limit <number>', 'Max results to return')
180
+ .option('--offset <number>', 'Skip N results (default: 0)')
181
+ .option('--ndjson', 'Newline-delimited JSON output')
160
182
  .action((file, opts) => {
161
- fileDeps(file, opts.db, { noTests: resolveNoTests(opts), json: opts.json });
183
+ fileDeps(file, opts.db, {
184
+ noTests: resolveNoTests(opts),
185
+ json: opts.json,
186
+ limit: opts.limit ? parseInt(opts.limit, 10) : undefined,
187
+ offset: opts.offset ? parseInt(opts.offset, 10) : undefined,
188
+ ndjson: opts.ndjson,
189
+ });
162
190
  });
163
191
 
164
192
  program
@@ -171,6 +199,9 @@ program
171
199
  .option('-T, --no-tests', 'Exclude test/spec files from results')
172
200
  .option('--include-tests', 'Include test/spec files (overrides excludeTests config)')
173
201
  .option('-j, --json', 'Output as JSON')
202
+ .option('--limit <number>', 'Max results to return')
203
+ .option('--offset <number>', 'Skip N results (default: 0)')
204
+ .option('--ndjson', 'Newline-delimited JSON output')
174
205
  .action((name, opts) => {
175
206
  if (opts.kind && !ALL_SYMBOL_KINDS.includes(opts.kind)) {
176
207
  console.error(`Invalid kind "${opts.kind}". Valid: ${ALL_SYMBOL_KINDS.join(', ')}`);
@@ -182,6 +213,9 @@ program
182
213
  kind: opts.kind,
183
214
  noTests: resolveNoTests(opts),
184
215
  json: opts.json,
216
+ limit: opts.limit ? parseInt(opts.limit, 10) : undefined,
217
+ offset: opts.offset ? parseInt(opts.offset, 10) : undefined,
218
+ ndjson: opts.ndjson,
185
219
  });
186
220
  });
187
221
 
@@ -195,6 +229,9 @@ program
195
229
  .option('-T, --no-tests', 'Exclude test/spec files from results')
196
230
  .option('--include-tests', 'Include test/spec files (overrides excludeTests config)')
197
231
  .option('-j, --json', 'Output as JSON')
232
+ .option('--limit <number>', 'Max results to return')
233
+ .option('--offset <number>', 'Skip N results (default: 0)')
234
+ .option('--ndjson', 'Newline-delimited JSON output')
198
235
  .action((name, opts) => {
199
236
  if (opts.kind && !ALL_SYMBOL_KINDS.includes(opts.kind)) {
200
237
  console.error(`Invalid kind "${opts.kind}". Valid: ${ALL_SYMBOL_KINDS.join(', ')}`);
@@ -206,6 +243,9 @@ program
206
243
  kind: opts.kind,
207
244
  noTests: resolveNoTests(opts),
208
245
  json: opts.json,
246
+ limit: opts.limit ? parseInt(opts.limit, 10) : undefined,
247
+ offset: opts.offset ? parseInt(opts.offset, 10) : undefined,
248
+ ndjson: opts.ndjson,
209
249
  });
210
250
  });
211
251
 
@@ -251,6 +291,9 @@ program
251
291
  .option('-T, --no-tests', 'Exclude test/spec files from results')
252
292
  .option('--include-tests', 'Include test/spec files (overrides excludeTests config)')
253
293
  .option('-j, --json', 'Output as JSON')
294
+ .option('--limit <number>', 'Max results to return')
295
+ .option('--offset <number>', 'Skip N results (default: 0)')
296
+ .option('--ndjson', 'Newline-delimited JSON output')
254
297
  .action((name, opts) => {
255
298
  if (opts.kind && !ALL_SYMBOL_KINDS.includes(opts.kind)) {
256
299
  console.error(`Invalid kind "${opts.kind}". Valid: ${ALL_SYMBOL_KINDS.join(', ')}`);
@@ -264,6 +307,9 @@ program
264
307
  noTests: resolveNoTests(opts),
265
308
  includeTests: opts.withTestSource,
266
309
  json: opts.json,
310
+ limit: opts.limit ? parseInt(opts.limit, 10) : undefined,
311
+ offset: opts.offset ? parseInt(opts.offset, 10) : undefined,
312
+ ndjson: opts.ndjson,
267
313
  });
268
314
  });
269
315
 
@@ -275,11 +321,41 @@ program
275
321
  .option('-T, --no-tests', 'Exclude test/spec files from results')
276
322
  .option('--include-tests', 'Include test/spec files (overrides excludeTests config)')
277
323
  .option('-j, --json', 'Output as JSON')
324
+ .option('--limit <number>', 'Max results to return')
325
+ .option('--offset <number>', 'Skip N results (default: 0)')
326
+ .option('--ndjson', 'Newline-delimited JSON output')
278
327
  .action((target, opts) => {
279
328
  explain(target, opts.db, {
280
329
  depth: parseInt(opts.depth, 10),
281
330
  noTests: resolveNoTests(opts),
282
331
  json: opts.json,
332
+ limit: opts.limit ? parseInt(opts.limit, 10) : undefined,
333
+ offset: opts.offset ? parseInt(opts.offset, 10) : undefined,
334
+ ndjson: opts.ndjson,
335
+ });
336
+ });
337
+
338
+ program
339
+ .command('audit <target>')
340
+ .description('Composite report: explain + impact + health metrics per function')
341
+ .option('-d, --db <path>', 'Path to graph.db')
342
+ .option('--depth <n>', 'Impact analysis depth', '3')
343
+ .option('-f, --file <path>', 'Scope to file (partial match)')
344
+ .option('-k, --kind <kind>', 'Filter by symbol kind')
345
+ .option('-T, --no-tests', 'Exclude test/spec files from results')
346
+ .option('--include-tests', 'Include test/spec files (overrides excludeTests config)')
347
+ .option('-j, --json', 'Output as JSON')
348
+ .action((target, opts) => {
349
+ if (opts.kind && !ALL_SYMBOL_KINDS.includes(opts.kind)) {
350
+ console.error(`Invalid kind "${opts.kind}". Valid: ${ALL_SYMBOL_KINDS.join(', ')}`);
351
+ process.exit(1);
352
+ }
353
+ audit(target, opts.db, {
354
+ depth: parseInt(opts.depth, 10),
355
+ file: opts.file,
356
+ kind: opts.kind,
357
+ noTests: resolveNoTests(opts),
358
+ json: opts.json,
283
359
  });
284
360
  });
285
361
 
@@ -320,6 +396,9 @@ program
320
396
  .option('--include-tests', 'Include test/spec files (overrides excludeTests config)')
321
397
  .option('-j, --json', 'Output as JSON')
322
398
  .option('-f, --format <format>', 'Output format: text, mermaid, json', 'text')
399
+ .option('--limit <number>', 'Max results to return')
400
+ .option('--offset <number>', 'Skip N results (default: 0)')
401
+ .option('--ndjson', 'Newline-delimited JSON output')
323
402
  .action((ref, opts) => {
324
403
  diffImpact(opts.db, {
325
404
  ref,
@@ -328,6 +407,37 @@ program
328
407
  noTests: resolveNoTests(opts),
329
408
  json: opts.json,
330
409
  format: opts.format,
410
+ limit: opts.limit ? parseInt(opts.limit, 10) : undefined,
411
+ offset: opts.offset ? parseInt(opts.offset, 10) : undefined,
412
+ ndjson: opts.ndjson,
413
+ });
414
+ });
415
+
416
+ program
417
+ .command('check [ref]')
418
+ .description('Run validation predicates against git changes (CI gate)')
419
+ .option('-d, --db <path>', 'Path to graph.db')
420
+ .option('--staged', 'Analyze staged changes')
421
+ .option('--cycles', 'Assert no dependency cycles involve changed files')
422
+ .option('--blast-radius <n>', 'Assert no function exceeds N transitive callers')
423
+ .option('--signatures', 'Assert no function declaration lines were modified')
424
+ .option('--boundaries', 'Assert no cross-owner boundary violations')
425
+ .option('--depth <n>', 'Max BFS depth for blast radius (default: 3)')
426
+ .option('-T, --no-tests', 'Exclude test/spec files from results')
427
+ .option('--include-tests', 'Include test/spec files (overrides excludeTests config)')
428
+ .option('-j, --json', 'Output as JSON')
429
+ .action(async (ref, opts) => {
430
+ const { check } = await import('./check.js');
431
+ check(opts.db, {
432
+ ref,
433
+ staged: opts.staged,
434
+ cycles: opts.cycles || undefined,
435
+ blastRadius: opts.blastRadius ? parseInt(opts.blastRadius, 10) : undefined,
436
+ signatures: opts.signatures || undefined,
437
+ boundaries: opts.boundaries || undefined,
438
+ depth: opts.depth ? parseInt(opts.depth, 10) : undefined,
439
+ noTests: resolveNoTests(opts),
440
+ json: opts.json,
331
441
  });
332
442
  });
333
443
 
@@ -468,6 +578,7 @@ registry
468
578
  .description('Remove stale registry entries (missing directories or idle beyond TTL)')
469
579
  .option('--ttl <days>', 'Days of inactivity before pruning (default: 30)', '30')
470
580
  .option('--exclude <names>', 'Comma-separated repo names to preserve from pruning')
581
+ .option('--dry-run', 'Show what would be pruned without removing anything')
471
582
  .action((opts) => {
472
583
  const excludeNames = opts.exclude
473
584
  ? opts.exclude
@@ -475,15 +586,100 @@ registry
475
586
  .map((s) => s.trim())
476
587
  .filter((s) => s.length > 0)
477
588
  : [];
478
- const pruned = pruneRegistry(undefined, parseInt(opts.ttl, 10), excludeNames);
589
+ const dryRun = !!opts.dryRun;
590
+ const pruned = pruneRegistry(undefined, parseInt(opts.ttl, 10), excludeNames, dryRun);
479
591
  if (pruned.length === 0) {
480
592
  console.log('No stale entries found.');
481
593
  } else {
594
+ const prefix = dryRun ? 'Would prune' : 'Pruned';
482
595
  for (const entry of pruned) {
483
596
  const tag = entry.reason === 'expired' ? 'expired' : 'missing';
484
- console.log(`Pruned "${entry.name}" (${entry.path}) [${tag}]`);
597
+ console.log(`${prefix} "${entry.name}" (${entry.path}) [${tag}]`);
598
+ }
599
+ if (dryRun) {
600
+ console.log(
601
+ `\nDry run: ${pruned.length} ${pruned.length === 1 ? 'entry' : 'entries'} would be removed.`,
602
+ );
603
+ } else {
604
+ console.log(
605
+ `\nRemoved ${pruned.length} stale ${pruned.length === 1 ? 'entry' : 'entries'}.`,
606
+ );
485
607
  }
486
- console.log(`\nRemoved ${pruned.length} stale ${pruned.length === 1 ? 'entry' : 'entries'}.`);
608
+ }
609
+ });
610
+
611
+ // ─── Snapshot commands ──────────────────────────────────────────────────
612
+
613
+ const snapshot = program
614
+ .command('snapshot')
615
+ .description('Save and restore graph database snapshots');
616
+
617
+ snapshot
618
+ .command('save <name>')
619
+ .description('Save a snapshot of the current graph database')
620
+ .option('-d, --db <path>', 'Path to graph.db')
621
+ .option('--force', 'Overwrite existing snapshot')
622
+ .action((name, opts) => {
623
+ try {
624
+ const result = snapshotSave(name, { dbPath: opts.db, force: opts.force });
625
+ console.log(`Snapshot saved: ${result.name} (${formatSize(result.size)})`);
626
+ } catch (err) {
627
+ console.error(err.message);
628
+ process.exit(1);
629
+ }
630
+ });
631
+
632
+ snapshot
633
+ .command('restore <name>')
634
+ .description('Restore a snapshot over the current graph database')
635
+ .option('-d, --db <path>', 'Path to graph.db')
636
+ .action((name, opts) => {
637
+ try {
638
+ snapshotRestore(name, { dbPath: opts.db });
639
+ console.log(`Snapshot "${name}" restored.`);
640
+ } catch (err) {
641
+ console.error(err.message);
642
+ process.exit(1);
643
+ }
644
+ });
645
+
646
+ snapshot
647
+ .command('list')
648
+ .description('List all saved snapshots')
649
+ .option('-d, --db <path>', 'Path to graph.db')
650
+ .option('-j, --json', 'Output as JSON')
651
+ .action((opts) => {
652
+ try {
653
+ const snapshots = snapshotList({ dbPath: opts.db });
654
+ if (opts.json) {
655
+ console.log(JSON.stringify(snapshots, null, 2));
656
+ } else if (snapshots.length === 0) {
657
+ console.log('No snapshots found.');
658
+ } else {
659
+ console.log(`Snapshots (${snapshots.length}):\n`);
660
+ for (const s of snapshots) {
661
+ console.log(
662
+ ` ${s.name.padEnd(30)} ${formatSize(s.size).padStart(10)} ${s.createdAt.toISOString()}`,
663
+ );
664
+ }
665
+ }
666
+ } catch (err) {
667
+ console.error(err.message);
668
+ process.exit(1);
669
+ }
670
+ });
671
+
672
+ snapshot
673
+ .command('delete <name>')
674
+ .description('Delete a saved snapshot')
675
+ .option('-d, --db <path>', 'Path to graph.db')
676
+ .action((name, opts) => {
677
+ try {
678
+ snapshotDelete(name, { dbPath: opts.db });
679
+ console.log(`Snapshot "${name}" deleted.`);
680
+ } catch (err) {
681
+ console.error(err.message);
682
+ process.exit(1);
487
683
  }
488
684
  });
489
685
 
@@ -545,8 +741,16 @@ program
545
741
  .option('-k, --kind <kind>', 'Filter by kind: function, method, class')
546
742
  .option('--file <pattern>', 'Filter by file path pattern')
547
743
  .option('--rrf-k <number>', 'RRF k parameter for multi-query ranking', '60')
744
+ .option('--mode <mode>', 'Search mode: hybrid, semantic, keyword (default: hybrid)')
548
745
  .option('-j, --json', 'Output as JSON')
746
+ .option('--offset <number>', 'Skip N results (default: 0)')
747
+ .option('--ndjson', 'Newline-delimited JSON output')
549
748
  .action(async (query, opts) => {
749
+ const validModes = ['hybrid', 'semantic', 'keyword'];
750
+ if (opts.mode && !validModes.includes(opts.mode)) {
751
+ console.error(`Invalid mode "${opts.mode}". Valid: ${validModes.join(', ')}`);
752
+ process.exit(1);
753
+ }
550
754
  await search(query, opts.db, {
551
755
  limit: parseInt(opts.limit, 10),
552
756
  noTests: resolveNoTests(opts),
@@ -555,6 +759,7 @@ program
555
759
  kind: opts.kind,
556
760
  filePattern: opts.file,
557
761
  rrfK: parseInt(opts.rrfK, 10),
762
+ mode: opts.mode,
558
763
  json: opts.json,
559
764
  });
560
765
  });
@@ -571,6 +776,9 @@ program
571
776
  .option('-T, --no-tests', 'Exclude test/spec files')
572
777
  .option('--include-tests', 'Include test/spec files (overrides excludeTests config)')
573
778
  .option('-j, --json', 'Output as JSON')
779
+ .option('--limit <number>', 'Max results to return')
780
+ .option('--offset <number>', 'Skip N results (default: 0)')
781
+ .option('--ndjson', 'Newline-delimited JSON output')
574
782
  .action(async (dir, opts) => {
575
783
  const { structureData, formatStructure } = await import('./structure.js');
576
784
  const data = structureData(opts.db, {
@@ -579,8 +787,12 @@ program
579
787
  sort: opts.sort,
580
788
  full: opts.full,
581
789
  noTests: resolveNoTests(opts),
790
+ limit: opts.limit ? parseInt(opts.limit, 10) : undefined,
791
+ offset: opts.offset ? parseInt(opts.offset, 10) : undefined,
582
792
  });
583
- if (opts.json) {
793
+ if (opts.ndjson) {
794
+ printNdjson(data, 'directories');
795
+ } else if (opts.json) {
584
796
  console.log(JSON.stringify(data, null, 2));
585
797
  } else {
586
798
  console.log(formatStructure(data));
@@ -599,15 +811,20 @@ program
599
811
  .option('-T, --no-tests', 'Exclude test/spec files from results')
600
812
  .option('--include-tests', 'Include test/spec files (overrides excludeTests config)')
601
813
  .option('-j, --json', 'Output as JSON')
814
+ .option('--offset <number>', 'Skip N results (default: 0)')
815
+ .option('--ndjson', 'Newline-delimited JSON output')
602
816
  .action(async (opts) => {
603
817
  const { hotspotsData, formatHotspots } = await import('./structure.js');
604
818
  const data = hotspotsData(opts.db, {
605
819
  metric: opts.metric,
606
820
  level: opts.level,
607
821
  limit: parseInt(opts.limit, 10),
822
+ offset: opts.offset ? parseInt(opts.offset, 10) : undefined,
608
823
  noTests: resolveNoTests(opts),
609
824
  });
610
- if (opts.json) {
825
+ if (opts.ndjson) {
826
+ printNdjson(data, 'hotspots');
827
+ } else if (opts.json) {
611
828
  console.log(JSON.stringify(data, null, 2));
612
829
  } else {
613
830
  console.log(formatHotspots(data));
@@ -657,6 +874,8 @@ program
657
874
  .option('-T, --no-tests', 'Exclude test/spec files')
658
875
  .option('--include-tests', 'Include test/spec files (overrides excludeTests config)')
659
876
  .option('-j, --json', 'Output as JSON')
877
+ .option('--offset <number>', 'Skip N results (default: 0)')
878
+ .option('--ndjson', 'Newline-delimited JSON output')
660
879
  .action(async (file, opts) => {
661
880
  const { analyzeCoChanges, coChangeData, coChangeTopData, formatCoChange, formatCoChangeTop } =
662
881
  await import('./cochange.js');
@@ -683,20 +902,25 @@ program
683
902
 
684
903
  const queryOpts = {
685
904
  limit: parseInt(opts.limit, 10),
905
+ offset: opts.offset ? parseInt(opts.offset, 10) : undefined,
686
906
  minJaccard: opts.minJaccard ? parseFloat(opts.minJaccard) : config.coChange?.minJaccard,
687
907
  noTests: resolveNoTests(opts),
688
908
  };
689
909
 
690
910
  if (file) {
691
911
  const data = coChangeData(file, opts.db, queryOpts);
692
- if (opts.json) {
912
+ if (opts.ndjson) {
913
+ printNdjson(data, 'partners');
914
+ } else if (opts.json) {
693
915
  console.log(JSON.stringify(data, null, 2));
694
916
  } else {
695
917
  console.log(formatCoChange(data));
696
918
  }
697
919
  } else {
698
920
  const data = coChangeTopData(opts.db, queryOpts);
699
- if (opts.json) {
921
+ if (opts.ndjson) {
922
+ printNdjson(data, 'pairs');
923
+ } else if (opts.json) {
700
924
  console.log(JSON.stringify(data, null, 2));
701
925
  } else {
702
926
  console.log(formatCoChangeTop(data));
@@ -760,6 +984,8 @@ program
760
984
  .option('-T, --no-tests', 'Exclude test/spec files from results')
761
985
  .option('--include-tests', 'Include test/spec files (overrides excludeTests config)')
762
986
  .option('-j, --json', 'Output as JSON')
987
+ .option('--offset <number>', 'Skip N results (default: 0)')
988
+ .option('--ndjson', 'Newline-delimited JSON output')
763
989
  .action(async (target, opts) => {
764
990
  if (opts.kind && !ALL_SYMBOL_KINDS.includes(opts.kind)) {
765
991
  console.error(`Invalid kind "${opts.kind}". Valid: ${ALL_SYMBOL_KINDS.join(', ')}`);
@@ -769,6 +995,7 @@ program
769
995
  complexity(opts.db, {
770
996
  target,
771
997
  limit: parseInt(opts.limit, 10),
998
+ offset: opts.offset ? parseInt(opts.offset, 10) : undefined,
772
999
  sort: opts.sort,
773
1000
  aboveThreshold: opts.aboveThreshold,
774
1001
  health: opts.health,
@@ -776,6 +1003,7 @@ program
776
1003
  kind: opts.kind,
777
1004
  noTests: resolveNoTests(opts),
778
1005
  json: opts.json,
1006
+ ndjson: opts.ndjson,
779
1007
  });
780
1008
  });
781
1009
 
@@ -788,6 +1016,9 @@ program
788
1016
  .option('-f, --file <path>', 'Scope to file (partial match)')
789
1017
  .option('-k, --kind <kind>', 'Filter by symbol kind')
790
1018
  .option('-j, --json', 'Output as JSON')
1019
+ .option('--limit <number>', 'Max results to return')
1020
+ .option('--offset <number>', 'Skip N results (default: 0)')
1021
+ .option('--ndjson', 'Newline-delimited JSON output')
791
1022
  .action(async (opts) => {
792
1023
  if (opts.kind && !ALL_SYMBOL_KINDS.includes(opts.kind)) {
793
1024
  console.error(`Invalid kind "${opts.kind}". Valid: ${ALL_SYMBOL_KINDS.join(', ')}`);
@@ -799,6 +1030,9 @@ program
799
1030
  kind: opts.kind,
800
1031
  noTests: resolveNoTests(opts),
801
1032
  json: opts.json,
1033
+ limit: opts.limit ? parseInt(opts.limit, 10) : undefined,
1034
+ offset: opts.offset ? parseInt(opts.offset, 10) : undefined,
1035
+ ndjson: opts.ndjson,
802
1036
  });
803
1037
  });
804
1038
 
@@ -812,6 +1046,9 @@ program
812
1046
  .option('-T, --no-tests', 'Exclude test/spec files from results')
813
1047
  .option('--include-tests', 'Include test/spec files (overrides excludeTests config)')
814
1048
  .option('-j, --json', 'Output as JSON')
1049
+ .option('--limit <number>', 'Max results to return')
1050
+ .option('--offset <number>', 'Skip N results (default: 0)')
1051
+ .option('--ndjson', 'Newline-delimited JSON output')
815
1052
  .action(async (opts) => {
816
1053
  const { communities } = await import('./communities.js');
817
1054
  communities(opts.db, {
@@ -820,6 +1057,84 @@ program
820
1057
  drift: opts.drift,
821
1058
  noTests: resolveNoTests(opts),
822
1059
  json: opts.json,
1060
+ limit: opts.limit ? parseInt(opts.limit, 10) : undefined,
1061
+ offset: opts.offset ? parseInt(opts.offset, 10) : undefined,
1062
+ ndjson: opts.ndjson,
1063
+ });
1064
+ });
1065
+
1066
+ program
1067
+ .command('triage')
1068
+ .description(
1069
+ 'Ranked audit queue by composite risk score (connectivity + complexity + churn + role)',
1070
+ )
1071
+ .option('-d, --db <path>', 'Path to graph.db')
1072
+ .option('-n, --limit <number>', 'Max results to return', '20')
1073
+ .option('--sort <metric>', 'Sort metric: risk | complexity | churn | fan-in | mi', 'risk')
1074
+ .option('--min-score <score>', 'Only show symbols with risk score >= threshold')
1075
+ .option('--role <role>', 'Filter by role (entry, core, utility, adapter, leaf, dead)')
1076
+ .option('-f, --file <path>', 'Scope to a specific file (partial match)')
1077
+ .option('-k, --kind <kind>', 'Filter by symbol kind (function, method, class)')
1078
+ .option('-T, --no-tests', 'Exclude test/spec files from results')
1079
+ .option('--include-tests', 'Include test/spec files (overrides excludeTests config)')
1080
+ .option('-j, --json', 'Output as JSON')
1081
+ .option('--offset <number>', 'Skip N results (default: 0)')
1082
+ .option('--ndjson', 'Newline-delimited JSON output')
1083
+ .option('--weights <json>', 'Custom weights JSON (e.g. \'{"fanIn":1,"complexity":0}\')')
1084
+ .action(async (opts) => {
1085
+ if (opts.kind && !ALL_SYMBOL_KINDS.includes(opts.kind)) {
1086
+ console.error(`Invalid kind "${opts.kind}". Valid: ${ALL_SYMBOL_KINDS.join(', ')}`);
1087
+ process.exit(1);
1088
+ }
1089
+ if (opts.role && !VALID_ROLES.includes(opts.role)) {
1090
+ console.error(`Invalid role "${opts.role}". Valid: ${VALID_ROLES.join(', ')}`);
1091
+ process.exit(1);
1092
+ }
1093
+ let weights;
1094
+ if (opts.weights) {
1095
+ try {
1096
+ weights = JSON.parse(opts.weights);
1097
+ } catch {
1098
+ console.error('Invalid --weights JSON');
1099
+ process.exit(1);
1100
+ }
1101
+ }
1102
+ const { triage } = await import('./triage.js');
1103
+ triage(opts.db, {
1104
+ limit: parseInt(opts.limit, 10),
1105
+ offset: opts.offset ? parseInt(opts.offset, 10) : undefined,
1106
+ sort: opts.sort,
1107
+ minScore: opts.minScore,
1108
+ role: opts.role,
1109
+ file: opts.file,
1110
+ kind: opts.kind,
1111
+ noTests: resolveNoTests(opts),
1112
+ json: opts.json,
1113
+ ndjson: opts.ndjson,
1114
+ weights,
1115
+ });
1116
+ });
1117
+
1118
+ program
1119
+ .command('owners [target]')
1120
+ .description('Show CODEOWNERS mapping for files and functions')
1121
+ .option('-d, --db <path>', 'Path to graph.db')
1122
+ .option('--owner <owner>', 'Filter to a specific owner')
1123
+ .option('--boundary', 'Show cross-owner boundary edges')
1124
+ .option('-f, --file <path>', 'Scope to a specific file')
1125
+ .option('-k, --kind <kind>', 'Filter by symbol kind')
1126
+ .option('-T, --no-tests', 'Exclude test/spec files')
1127
+ .option('--include-tests', 'Include test/spec files (overrides excludeTests config)')
1128
+ .option('-j, --json', 'Output as JSON')
1129
+ .action(async (target, opts) => {
1130
+ const { owners } = await import('./owners.js');
1131
+ owners(opts.db, {
1132
+ owner: opts.owner,
1133
+ boundary: opts.boundary,
1134
+ file: opts.file || target,
1135
+ kind: opts.kind,
1136
+ noTests: resolveNoTests(opts),
1137
+ json: opts.json,
823
1138
  });
824
1139
  });
825
1140
 
@@ -916,4 +1231,55 @@ program
916
1231
  }
917
1232
  });
918
1233
 
1234
+ program
1235
+ .command('batch <command> [targets...]')
1236
+ .description(
1237
+ `Run a query against multiple targets in one call. Output is always JSON.\nValid commands: ${Object.keys(BATCH_COMMANDS).join(', ')}`,
1238
+ )
1239
+ .option('-d, --db <path>', 'Path to graph.db')
1240
+ .option('--from-file <path>', 'Read targets from file (JSON array or newline-delimited)')
1241
+ .option('--stdin', 'Read targets from stdin (JSON array)')
1242
+ .option('--depth <n>', 'Traversal depth passed to underlying command')
1243
+ .option('-f, --file <path>', 'Scope to file (partial match)')
1244
+ .option('-k, --kind <kind>', 'Filter by symbol kind')
1245
+ .option('-T, --no-tests', 'Exclude test/spec files from results')
1246
+ .option('--include-tests', 'Include test/spec files (overrides excludeTests config)')
1247
+ .action(async (command, positionalTargets, opts) => {
1248
+ if (opts.kind && !ALL_SYMBOL_KINDS.includes(opts.kind)) {
1249
+ console.error(`Invalid kind "${opts.kind}". Valid: ${ALL_SYMBOL_KINDS.join(', ')}`);
1250
+ process.exit(1);
1251
+ }
1252
+
1253
+ let targets;
1254
+ if (opts.fromFile) {
1255
+ const raw = fs.readFileSync(opts.fromFile, 'utf-8').trim();
1256
+ if (raw.startsWith('[')) {
1257
+ targets = JSON.parse(raw);
1258
+ } else {
1259
+ targets = raw.split(/\r?\n/).filter(Boolean);
1260
+ }
1261
+ } else if (opts.stdin) {
1262
+ const chunks = [];
1263
+ for await (const chunk of process.stdin) chunks.push(chunk);
1264
+ const raw = Buffer.concat(chunks).toString('utf-8').trim();
1265
+ targets = raw.startsWith('[') ? JSON.parse(raw) : raw.split(/\r?\n/).filter(Boolean);
1266
+ } else {
1267
+ targets = positionalTargets;
1268
+ }
1269
+
1270
+ if (!targets || targets.length === 0) {
1271
+ console.error('No targets provided. Pass targets as arguments, --from-file, or --stdin.');
1272
+ process.exit(1);
1273
+ }
1274
+
1275
+ const batchOpts = {
1276
+ depth: opts.depth ? parseInt(opts.depth, 10) : undefined,
1277
+ file: opts.file,
1278
+ kind: opts.kind,
1279
+ noTests: resolveNoTests(opts),
1280
+ };
1281
+
1282
+ batch(command, targets, opts.db, batchOpts);
1283
+ });
1284
+
919
1285
  program.parse();
package/src/cochange.js CHANGED
@@ -11,6 +11,7 @@ import path from 'node:path';
11
11
  import { normalizePath } from './constants.js';
12
12
  import { closeDb, findDbPath, initSchema, openDb, openReadonlyOrFail } from './db.js';
13
13
  import { warn } from './logger.js';
14
+ import { paginateResult } from './paginate.js';
14
15
  import { isTestFile } from './queries.js';
15
16
 
16
17
  /**
@@ -313,7 +314,8 @@ export function coChangeData(file, customDbPath, opts = {}) {
313
314
  const meta = getCoChangeMeta(db);
314
315
  closeDb(db);
315
316
 
316
- return { file: resolvedFile, partners, meta };
317
+ const base = { file: resolvedFile, partners, meta };
318
+ return paginateResult(base, 'partners', { limit: opts.limit, offset: opts.offset });
317
319
  }
318
320
 
319
321
  /**
@@ -365,7 +367,8 @@ export function coChangeTopData(customDbPath, opts = {}) {
365
367
  const meta = getCoChangeMeta(db);
366
368
  closeDb(db);
367
369
 
368
- return { pairs, meta };
370
+ const base = { pairs, meta };
371
+ return paginateResult(base, 'pairs', { limit: opts.limit, offset: opts.offset });
369
372
  }
370
373
 
371
374
  /**