@optave/codegraph 2.5.1 → 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
 
@@ -498,6 +608,81 @@ registry
498
608
  }
499
609
  });
500
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);
683
+ }
684
+ });
685
+
501
686
  // ─── Embedding commands ─────────────────────────────────────────────────
502
687
 
503
688
  program
@@ -556,8 +741,16 @@ program
556
741
  .option('-k, --kind <kind>', 'Filter by kind: function, method, class')
557
742
  .option('--file <pattern>', 'Filter by file path pattern')
558
743
  .option('--rrf-k <number>', 'RRF k parameter for multi-query ranking', '60')
744
+ .option('--mode <mode>', 'Search mode: hybrid, semantic, keyword (default: hybrid)')
559
745
  .option('-j, --json', 'Output as JSON')
746
+ .option('--offset <number>', 'Skip N results (default: 0)')
747
+ .option('--ndjson', 'Newline-delimited JSON output')
560
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
+ }
561
754
  await search(query, opts.db, {
562
755
  limit: parseInt(opts.limit, 10),
563
756
  noTests: resolveNoTests(opts),
@@ -566,6 +759,7 @@ program
566
759
  kind: opts.kind,
567
760
  filePattern: opts.file,
568
761
  rrfK: parseInt(opts.rrfK, 10),
762
+ mode: opts.mode,
569
763
  json: opts.json,
570
764
  });
571
765
  });
@@ -582,6 +776,9 @@ program
582
776
  .option('-T, --no-tests', 'Exclude test/spec files')
583
777
  .option('--include-tests', 'Include test/spec files (overrides excludeTests config)')
584
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')
585
782
  .action(async (dir, opts) => {
586
783
  const { structureData, formatStructure } = await import('./structure.js');
587
784
  const data = structureData(opts.db, {
@@ -590,8 +787,12 @@ program
590
787
  sort: opts.sort,
591
788
  full: opts.full,
592
789
  noTests: resolveNoTests(opts),
790
+ limit: opts.limit ? parseInt(opts.limit, 10) : undefined,
791
+ offset: opts.offset ? parseInt(opts.offset, 10) : undefined,
593
792
  });
594
- if (opts.json) {
793
+ if (opts.ndjson) {
794
+ printNdjson(data, 'directories');
795
+ } else if (opts.json) {
595
796
  console.log(JSON.stringify(data, null, 2));
596
797
  } else {
597
798
  console.log(formatStructure(data));
@@ -610,15 +811,20 @@ program
610
811
  .option('-T, --no-tests', 'Exclude test/spec files from results')
611
812
  .option('--include-tests', 'Include test/spec files (overrides excludeTests config)')
612
813
  .option('-j, --json', 'Output as JSON')
814
+ .option('--offset <number>', 'Skip N results (default: 0)')
815
+ .option('--ndjson', 'Newline-delimited JSON output')
613
816
  .action(async (opts) => {
614
817
  const { hotspotsData, formatHotspots } = await import('./structure.js');
615
818
  const data = hotspotsData(opts.db, {
616
819
  metric: opts.metric,
617
820
  level: opts.level,
618
821
  limit: parseInt(opts.limit, 10),
822
+ offset: opts.offset ? parseInt(opts.offset, 10) : undefined,
619
823
  noTests: resolveNoTests(opts),
620
824
  });
621
- if (opts.json) {
825
+ if (opts.ndjson) {
826
+ printNdjson(data, 'hotspots');
827
+ } else if (opts.json) {
622
828
  console.log(JSON.stringify(data, null, 2));
623
829
  } else {
624
830
  console.log(formatHotspots(data));
@@ -668,6 +874,8 @@ program
668
874
  .option('-T, --no-tests', 'Exclude test/spec files')
669
875
  .option('--include-tests', 'Include test/spec files (overrides excludeTests config)')
670
876
  .option('-j, --json', 'Output as JSON')
877
+ .option('--offset <number>', 'Skip N results (default: 0)')
878
+ .option('--ndjson', 'Newline-delimited JSON output')
671
879
  .action(async (file, opts) => {
672
880
  const { analyzeCoChanges, coChangeData, coChangeTopData, formatCoChange, formatCoChangeTop } =
673
881
  await import('./cochange.js');
@@ -694,20 +902,25 @@ program
694
902
 
695
903
  const queryOpts = {
696
904
  limit: parseInt(opts.limit, 10),
905
+ offset: opts.offset ? parseInt(opts.offset, 10) : undefined,
697
906
  minJaccard: opts.minJaccard ? parseFloat(opts.minJaccard) : config.coChange?.minJaccard,
698
907
  noTests: resolveNoTests(opts),
699
908
  };
700
909
 
701
910
  if (file) {
702
911
  const data = coChangeData(file, opts.db, queryOpts);
703
- if (opts.json) {
912
+ if (opts.ndjson) {
913
+ printNdjson(data, 'partners');
914
+ } else if (opts.json) {
704
915
  console.log(JSON.stringify(data, null, 2));
705
916
  } else {
706
917
  console.log(formatCoChange(data));
707
918
  }
708
919
  } else {
709
920
  const data = coChangeTopData(opts.db, queryOpts);
710
- if (opts.json) {
921
+ if (opts.ndjson) {
922
+ printNdjson(data, 'pairs');
923
+ } else if (opts.json) {
711
924
  console.log(JSON.stringify(data, null, 2));
712
925
  } else {
713
926
  console.log(formatCoChangeTop(data));
@@ -771,6 +984,8 @@ program
771
984
  .option('-T, --no-tests', 'Exclude test/spec files from results')
772
985
  .option('--include-tests', 'Include test/spec files (overrides excludeTests config)')
773
986
  .option('-j, --json', 'Output as JSON')
987
+ .option('--offset <number>', 'Skip N results (default: 0)')
988
+ .option('--ndjson', 'Newline-delimited JSON output')
774
989
  .action(async (target, opts) => {
775
990
  if (opts.kind && !ALL_SYMBOL_KINDS.includes(opts.kind)) {
776
991
  console.error(`Invalid kind "${opts.kind}". Valid: ${ALL_SYMBOL_KINDS.join(', ')}`);
@@ -780,6 +995,7 @@ program
780
995
  complexity(opts.db, {
781
996
  target,
782
997
  limit: parseInt(opts.limit, 10),
998
+ offset: opts.offset ? parseInt(opts.offset, 10) : undefined,
783
999
  sort: opts.sort,
784
1000
  aboveThreshold: opts.aboveThreshold,
785
1001
  health: opts.health,
@@ -787,6 +1003,7 @@ program
787
1003
  kind: opts.kind,
788
1004
  noTests: resolveNoTests(opts),
789
1005
  json: opts.json,
1006
+ ndjson: opts.ndjson,
790
1007
  });
791
1008
  });
792
1009
 
@@ -799,6 +1016,9 @@ program
799
1016
  .option('-f, --file <path>', 'Scope to file (partial match)')
800
1017
  .option('-k, --kind <kind>', 'Filter by symbol kind')
801
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')
802
1022
  .action(async (opts) => {
803
1023
  if (opts.kind && !ALL_SYMBOL_KINDS.includes(opts.kind)) {
804
1024
  console.error(`Invalid kind "${opts.kind}". Valid: ${ALL_SYMBOL_KINDS.join(', ')}`);
@@ -810,6 +1030,9 @@ program
810
1030
  kind: opts.kind,
811
1031
  noTests: resolveNoTests(opts),
812
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,
813
1036
  });
814
1037
  });
815
1038
 
@@ -823,6 +1046,9 @@ program
823
1046
  .option('-T, --no-tests', 'Exclude test/spec files from results')
824
1047
  .option('--include-tests', 'Include test/spec files (overrides excludeTests config)')
825
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')
826
1052
  .action(async (opts) => {
827
1053
  const { communities } = await import('./communities.js');
828
1054
  communities(opts.db, {
@@ -831,6 +1057,84 @@ program
831
1057
  drift: opts.drift,
832
1058
  noTests: resolveNoTests(opts),
833
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,
834
1138
  });
835
1139
  });
836
1140
 
@@ -927,4 +1231,55 @@ program
927
1231
  }
928
1232
  });
929
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
+
930
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
  /**
@@ -2,6 +2,7 @@ import path from 'node:path';
2
2
  import Graph from 'graphology';
3
3
  import louvain from 'graphology-communities-louvain';
4
4
  import { openReadonlyOrFail } from './db.js';
5
+ import { paginateResult, printNdjson } from './paginate.js';
5
6
  import { isTestFile } from './queries.js';
6
7
 
7
8
  // ─── Graph Construction ───────────────────────────────────────────────
@@ -201,7 +202,7 @@ export function communitiesData(customDbPath, opts = {}) {
201
202
 
202
203
  const driftScore = Math.round(((splitRatio + mergeRatio) / 2) * 100);
203
204
 
204
- return {
205
+ const base = {
205
206
  communities: opts.drift ? [] : communities,
206
207
  modularity: +modularity.toFixed(4),
207
208
  drift: { splitCandidates, mergeCandidates },
@@ -212,6 +213,7 @@ export function communitiesData(customDbPath, opts = {}) {
212
213
  driftScore,
213
214
  },
214
215
  };
216
+ return paginateResult(base, 'communities', { limit: opts.limit, offset: opts.offset });
215
217
  }
216
218
 
217
219
  /**
@@ -238,6 +240,10 @@ export function communitySummaryForStats(customDbPath, opts = {}) {
238
240
  export function communities(customDbPath, opts = {}) {
239
241
  const data = communitiesData(customDbPath, opts);
240
242
 
243
+ if (opts.ndjson) {
244
+ printNdjson(data, 'communities');
245
+ return;
246
+ }
241
247
  if (opts.json) {
242
248
  console.log(JSON.stringify(data, null, 2));
243
249
  return;