@optave/codegraph 2.5.1 → 3.0.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, multiBatchData, splitTargets } 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';
@@ -14,19 +16,28 @@ import {
14
16
  MODELS,
15
17
  search,
16
18
  } from './embedder.js';
17
- import { exportDOT, exportJSON, exportMermaid } from './export.js';
19
+ import {
20
+ exportDOT,
21
+ exportGraphML,
22
+ exportGraphSON,
23
+ exportJSON,
24
+ exportMermaid,
25
+ exportNeo4jCSV,
26
+ } from './export.js';
18
27
  import { setVerbose } from './logger.js';
28
+ import { printNdjson } from './paginate.js';
19
29
  import {
20
- ALL_SYMBOL_KINDS,
30
+ children,
21
31
  context,
22
32
  diffImpact,
33
+ EVERY_SYMBOL_KIND,
23
34
  explain,
24
35
  fileDeps,
36
+ fileExports,
25
37
  fnDeps,
26
38
  fnImpact,
27
39
  impactAnalysis,
28
40
  moduleMap,
29
- queryName,
30
41
  roles,
31
42
  stats,
32
43
  symbolPath,
@@ -40,6 +51,7 @@ import {
40
51
  registerRepo,
41
52
  unregisterRepo,
42
53
  } from './registry.js';
54
+ import { snapshotDelete, snapshotList, snapshotRestore, snapshotSave } from './snapshot.js';
43
55
  import { checkForUpdates, printUpdateNotification } from './update-check.js';
44
56
  import { watchProject } from './watcher.js';
45
57
 
@@ -83,20 +95,41 @@ function resolveNoTests(opts) {
83
95
  return config.query?.excludeTests || false;
84
96
  }
85
97
 
98
+ function formatSize(bytes) {
99
+ if (bytes < 1024) return `${bytes} B`;
100
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
101
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
102
+ }
103
+
86
104
  program
87
105
  .command('build [dir]')
88
106
  .description('Parse repo and build graph in .codegraph/graph.db')
89
107
  .option('--no-incremental', 'Force full rebuild (ignore file hashes)')
108
+ .option('--dataflow', 'Extract data flow edges (flows_to, returns, mutates)')
109
+ .option('--cfg', 'Build intraprocedural control flow graphs')
90
110
  .action(async (dir, opts) => {
91
111
  const root = path.resolve(dir || '.');
92
112
  const engine = program.opts().engine;
93
- await buildGraph(root, { incremental: opts.incremental, engine });
113
+ await buildGraph(root, {
114
+ incremental: opts.incremental,
115
+ engine,
116
+ dataflow: opts.dataflow,
117
+ cfg: opts.cfg,
118
+ });
94
119
  });
95
120
 
96
121
  program
97
122
  .command('query <name>')
98
- .description('Find a function/class, show callers and callees')
123
+ .description('Function-level dependency chain or shortest path between symbols')
99
124
  .option('-d, --db <path>', 'Path to graph.db')
125
+ .option('--depth <n>', 'Transitive caller depth', '3')
126
+ .option('-f, --file <path>', 'Scope search to functions in this file (partial match)')
127
+ .option('-k, --kind <kind>', 'Filter to a specific symbol kind')
128
+ .option('--path <to>', 'Path mode: find shortest path to <to>')
129
+ .option('--kinds <kinds>', 'Path mode: comma-separated edge kinds to follow (default: calls)')
130
+ .option('--reverse', 'Path mode: follow edges backward')
131
+ .option('--from-file <path>', 'Path mode: disambiguate source symbol by file')
132
+ .option('--to-file <path>', 'Path mode: disambiguate target symbol by file')
100
133
  .option('-T, --no-tests', 'Exclude test/spec files from results')
101
134
  .option('--include-tests', 'Include test/spec files (overrides excludeTests config)')
102
135
  .option('-j, --json', 'Output as JSON')
@@ -104,12 +137,63 @@ program
104
137
  .option('--offset <number>', 'Skip N results (default: 0)')
105
138
  .option('--ndjson', 'Newline-delimited JSON output')
106
139
  .action((name, opts) => {
107
- queryName(name, opts.db, {
140
+ if (opts.kind && !EVERY_SYMBOL_KIND.includes(opts.kind)) {
141
+ console.error(`Invalid kind "${opts.kind}". Valid: ${EVERY_SYMBOL_KIND.join(', ')}`);
142
+ process.exit(1);
143
+ }
144
+ if (opts.path) {
145
+ console.error('Note: "query --path" is deprecated, use "codegraph path <from> <to>" instead');
146
+ symbolPath(name, opts.path, opts.db, {
147
+ maxDepth: opts.depth ? parseInt(opts.depth, 10) : 10,
148
+ edgeKinds: opts.kinds ? opts.kinds.split(',').map((s) => s.trim()) : undefined,
149
+ reverse: opts.reverse,
150
+ fromFile: opts.fromFile,
151
+ toFile: opts.toFile,
152
+ kind: opts.kind,
153
+ noTests: resolveNoTests(opts),
154
+ json: opts.json,
155
+ });
156
+ } else {
157
+ fnDeps(name, opts.db, {
158
+ depth: parseInt(opts.depth, 10),
159
+ file: opts.file,
160
+ kind: opts.kind,
161
+ noTests: resolveNoTests(opts),
162
+ json: opts.json,
163
+ limit: opts.limit ? parseInt(opts.limit, 10) : undefined,
164
+ offset: opts.offset ? parseInt(opts.offset, 10) : undefined,
165
+ ndjson: opts.ndjson,
166
+ });
167
+ }
168
+ });
169
+
170
+ program
171
+ .command('path <from> <to>')
172
+ .description('Find shortest path between two symbols')
173
+ .option('-d, --db <path>', 'Path to graph.db')
174
+ .option('--reverse', 'Follow edges backward')
175
+ .option('--kinds <kinds>', 'Comma-separated edge kinds to follow (default: calls)')
176
+ .option('--from-file <path>', 'Disambiguate source symbol by file')
177
+ .option('--to-file <path>', 'Disambiguate target symbol by file')
178
+ .option('--depth <n>', 'Max traversal depth', '10')
179
+ .option('-k, --kind <kind>', 'Filter to a specific symbol kind')
180
+ .option('-T, --no-tests', 'Exclude test/spec files from results')
181
+ .option('--include-tests', 'Include test/spec files (overrides excludeTests config)')
182
+ .option('-j, --json', 'Output as JSON')
183
+ .action((from, to, opts) => {
184
+ if (opts.kind && !EVERY_SYMBOL_KIND.includes(opts.kind)) {
185
+ console.error(`Invalid kind "${opts.kind}". Valid: ${EVERY_SYMBOL_KIND.join(', ')}`);
186
+ process.exit(1);
187
+ }
188
+ symbolPath(from, to, opts.db, {
189
+ maxDepth: opts.depth ? parseInt(opts.depth, 10) : 10,
190
+ edgeKinds: opts.kinds ? opts.kinds.split(',').map((s) => s.trim()) : undefined,
191
+ reverse: opts.reverse,
192
+ fromFile: opts.fromFile,
193
+ toFile: opts.toFile,
194
+ kind: opts.kind,
108
195
  noTests: resolveNoTests(opts),
109
196
  json: opts.json,
110
- limit: opts.limit ? parseInt(opts.limit, 10) : undefined,
111
- offset: opts.offset ? parseInt(opts.offset, 10) : undefined,
112
- ndjson: opts.ndjson,
113
197
  });
114
198
  });
115
199
 
@@ -120,8 +204,17 @@ program
120
204
  .option('-T, --no-tests', 'Exclude test/spec files from results')
121
205
  .option('--include-tests', 'Include test/spec files (overrides excludeTests config)')
122
206
  .option('-j, --json', 'Output as JSON')
207
+ .option('--limit <number>', 'Max results to return')
208
+ .option('--offset <number>', 'Skip N results (default: 0)')
209
+ .option('--ndjson', 'Newline-delimited JSON output')
123
210
  .action((file, opts) => {
124
- impactAnalysis(file, opts.db, { noTests: resolveNoTests(opts), json: opts.json });
211
+ impactAnalysis(file, opts.db, {
212
+ noTests: resolveNoTests(opts),
213
+ json: opts.json,
214
+ limit: opts.limit ? parseInt(opts.limit, 10) : undefined,
215
+ offset: opts.offset ? parseInt(opts.offset, 10) : undefined,
216
+ ndjson: opts.ndjson,
217
+ });
125
218
  });
126
219
 
127
220
  program
@@ -157,31 +250,36 @@ program
157
250
  .option('-T, --no-tests', 'Exclude test/spec files from results')
158
251
  .option('--include-tests', 'Include test/spec files (overrides excludeTests config)')
159
252
  .option('-j, --json', 'Output as JSON')
253
+ .option('--limit <number>', 'Max results to return')
254
+ .option('--offset <number>', 'Skip N results (default: 0)')
255
+ .option('--ndjson', 'Newline-delimited JSON output')
160
256
  .action((file, opts) => {
161
- fileDeps(file, opts.db, { noTests: resolveNoTests(opts), json: opts.json });
257
+ fileDeps(file, opts.db, {
258
+ noTests: resolveNoTests(opts),
259
+ json: opts.json,
260
+ limit: opts.limit ? parseInt(opts.limit, 10) : undefined,
261
+ offset: opts.offset ? parseInt(opts.offset, 10) : undefined,
262
+ ndjson: opts.ndjson,
263
+ });
162
264
  });
163
265
 
164
266
  program
165
- .command('fn <name>')
166
- .description('Function-level dependencies: callers, callees, and transitive call chain')
267
+ .command('exports <file>')
268
+ .description('Show exported symbols with per-symbol consumers (who calls each export)')
167
269
  .option('-d, --db <path>', 'Path to graph.db')
168
- .option('--depth <n>', 'Transitive caller depth', '3')
169
- .option('-f, --file <path>', 'Scope search to functions in this file (partial match)')
170
- .option('-k, --kind <kind>', 'Filter to a specific symbol kind')
171
270
  .option('-T, --no-tests', 'Exclude test/spec files from results')
172
271
  .option('--include-tests', 'Include test/spec files (overrides excludeTests config)')
173
272
  .option('-j, --json', 'Output as JSON')
174
- .action((name, opts) => {
175
- if (opts.kind && !ALL_SYMBOL_KINDS.includes(opts.kind)) {
176
- console.error(`Invalid kind "${opts.kind}". Valid: ${ALL_SYMBOL_KINDS.join(', ')}`);
177
- process.exit(1);
178
- }
179
- fnDeps(name, opts.db, {
180
- depth: parseInt(opts.depth, 10),
181
- file: opts.file,
182
- kind: opts.kind,
273
+ .option('--limit <number>', 'Max results to return')
274
+ .option('--offset <number>', 'Skip N results (default: 0)')
275
+ .option('--ndjson', 'Newline-delimited JSON output')
276
+ .action((file, opts) => {
277
+ fileExports(file, opts.db, {
183
278
  noTests: resolveNoTests(opts),
184
279
  json: opts.json,
280
+ limit: opts.limit ? parseInt(opts.limit, 10) : undefined,
281
+ offset: opts.offset ? parseInt(opts.offset, 10) : undefined,
282
+ ndjson: opts.ndjson,
185
283
  });
186
284
  });
187
285
 
@@ -195,9 +293,12 @@ program
195
293
  .option('-T, --no-tests', 'Exclude test/spec files from results')
196
294
  .option('--include-tests', 'Include test/spec files (overrides excludeTests config)')
197
295
  .option('-j, --json', 'Output as JSON')
296
+ .option('--limit <number>', 'Max results to return')
297
+ .option('--offset <number>', 'Skip N results (default: 0)')
298
+ .option('--ndjson', 'Newline-delimited JSON output')
198
299
  .action((name, opts) => {
199
- if (opts.kind && !ALL_SYMBOL_KINDS.includes(opts.kind)) {
200
- console.error(`Invalid kind "${opts.kind}". Valid: ${ALL_SYMBOL_KINDS.join(', ')}`);
300
+ if (opts.kind && !EVERY_SYMBOL_KIND.includes(opts.kind)) {
301
+ console.error(`Invalid kind "${opts.kind}". Valid: ${EVERY_SYMBOL_KIND.join(', ')}`);
201
302
  process.exit(1);
202
303
  }
203
304
  fnImpact(name, opts.db, {
@@ -206,78 +307,105 @@ program
206
307
  kind: opts.kind,
207
308
  noTests: resolveNoTests(opts),
208
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,
209
313
  });
210
314
  });
211
315
 
212
316
  program
213
- .command('path <from> <to>')
214
- .description('Find shortest path between two symbols (A calls...calls B)')
317
+ .command('context <name>')
318
+ .description('Full context for a function: source, deps, callers, tests, signature')
215
319
  .option('-d, --db <path>', 'Path to graph.db')
216
- .option('--max-depth <n>', 'Maximum BFS depth', '10')
217
- .option('--kinds <kinds>', 'Comma-separated edge kinds to follow (default: calls)')
218
- .option('--reverse', 'Follow edges backward (B is called by...called by A)')
219
- .option('--from-file <path>', 'Disambiguate source symbol by file (partial match)')
220
- .option('--to-file <path>', 'Disambiguate target symbol by file (partial match)')
221
- .option('-k, --kind <kind>', 'Filter both symbols by kind')
320
+ .option('--depth <n>', 'Include callee source up to N levels deep', '0')
321
+ .option('-f, --file <path>', 'Scope search to functions in this file (partial match)')
322
+ .option('-k, --kind <kind>', 'Filter to a specific symbol kind')
323
+ .option('--no-source', 'Metadata only (skip source extraction)')
324
+ .option('--with-test-source', 'Include test source code')
222
325
  .option('-T, --no-tests', 'Exclude test/spec files from results')
223
326
  .option('--include-tests', 'Include test/spec files (overrides excludeTests config)')
224
327
  .option('-j, --json', 'Output as JSON')
225
- .action((from, to, opts) => {
226
- if (opts.kind && !ALL_SYMBOL_KINDS.includes(opts.kind)) {
227
- console.error(`Invalid kind "${opts.kind}". Valid: ${ALL_SYMBOL_KINDS.join(', ')}`);
328
+ .option('--limit <number>', 'Max results to return')
329
+ .option('--offset <number>', 'Skip N results (default: 0)')
330
+ .option('--ndjson', 'Newline-delimited JSON output')
331
+ .action((name, opts) => {
332
+ if (opts.kind && !EVERY_SYMBOL_KIND.includes(opts.kind)) {
333
+ console.error(`Invalid kind "${opts.kind}". Valid: ${EVERY_SYMBOL_KIND.join(', ')}`);
228
334
  process.exit(1);
229
335
  }
230
- symbolPath(from, to, opts.db, {
231
- maxDepth: parseInt(opts.maxDepth, 10),
232
- edgeKinds: opts.kinds ? opts.kinds.split(',').map((s) => s.trim()) : undefined,
233
- reverse: opts.reverse,
234
- fromFile: opts.fromFile,
235
- toFile: opts.toFile,
336
+ context(name, opts.db, {
337
+ depth: parseInt(opts.depth, 10),
338
+ file: opts.file,
236
339
  kind: opts.kind,
340
+ noSource: !opts.source,
237
341
  noTests: resolveNoTests(opts),
342
+ includeTests: opts.withTestSource,
238
343
  json: opts.json,
344
+ limit: opts.limit ? parseInt(opts.limit, 10) : undefined,
345
+ offset: opts.offset ? parseInt(opts.offset, 10) : undefined,
346
+ ndjson: opts.ndjson,
239
347
  });
240
348
  });
241
349
 
242
350
  program
243
- .command('context <name>')
244
- .description('Full context for a function: source, deps, callers, tests, signature')
351
+ .command('children <name>')
352
+ .description('List parameters, properties, and constants of a symbol')
245
353
  .option('-d, --db <path>', 'Path to graph.db')
246
- .option('--depth <n>', 'Include callee source up to N levels deep', '0')
247
- .option('-f, --file <path>', 'Scope search to functions in this file (partial match)')
354
+ .option('-f, --file <path>', 'Scope search to symbols in this file (partial match)')
248
355
  .option('-k, --kind <kind>', 'Filter to a specific symbol kind')
249
- .option('--no-source', 'Metadata only (skip source extraction)')
250
- .option('--with-test-source', 'Include test source code')
251
356
  .option('-T, --no-tests', 'Exclude test/spec files from results')
252
- .option('--include-tests', 'Include test/spec files (overrides excludeTests config)')
253
357
  .option('-j, --json', 'Output as JSON')
358
+ .option('--limit <number>', 'Max results to return')
359
+ .option('--offset <number>', 'Skip N results (default: 0)')
254
360
  .action((name, opts) => {
255
- if (opts.kind && !ALL_SYMBOL_KINDS.includes(opts.kind)) {
256
- console.error(`Invalid kind "${opts.kind}". Valid: ${ALL_SYMBOL_KINDS.join(', ')}`);
361
+ if (opts.kind && !EVERY_SYMBOL_KIND.includes(opts.kind)) {
362
+ console.error(`Invalid kind "${opts.kind}". Valid: ${EVERY_SYMBOL_KIND.join(', ')}`);
257
363
  process.exit(1);
258
364
  }
259
- context(name, opts.db, {
260
- depth: parseInt(opts.depth, 10),
365
+ children(name, opts.db, {
261
366
  file: opts.file,
262
367
  kind: opts.kind,
263
- noSource: !opts.source,
264
368
  noTests: resolveNoTests(opts),
265
- includeTests: opts.withTestSource,
266
369
  json: opts.json,
370
+ limit: opts.limit ? parseInt(opts.limit, 10) : undefined,
371
+ offset: opts.offset ? parseInt(opts.offset, 10) : undefined,
267
372
  });
268
373
  });
269
374
 
270
375
  program
271
- .command('explain <target>')
272
- .description('Structural summary of a file or function (no LLM needed)')
376
+ .command('audit <target>')
377
+ .description('Composite report: explain + impact + health metrics per function')
273
378
  .option('-d, --db <path>', 'Path to graph.db')
274
- .option('--depth <n>', 'Recursively explain dependencies up to N levels deep', '0')
379
+ .option('--quick', 'Structural summary only (skip impact analysis and health metrics)')
380
+ .option('--depth <n>', 'Impact/explain depth', '3')
381
+ .option('-f, --file <path>', 'Scope to file (partial match)')
382
+ .option('-k, --kind <kind>', 'Filter by symbol kind')
275
383
  .option('-T, --no-tests', 'Exclude test/spec files from results')
276
384
  .option('--include-tests', 'Include test/spec files (overrides excludeTests config)')
277
385
  .option('-j, --json', 'Output as JSON')
386
+ .option('--limit <number>', 'Max results to return (quick mode)')
387
+ .option('--offset <number>', 'Skip N results (quick mode)')
388
+ .option('--ndjson', 'Newline-delimited JSON output (quick mode)')
278
389
  .action((target, opts) => {
279
- explain(target, opts.db, {
390
+ if (opts.kind && !EVERY_SYMBOL_KIND.includes(opts.kind)) {
391
+ console.error(`Invalid kind "${opts.kind}". Valid: ${EVERY_SYMBOL_KIND.join(', ')}`);
392
+ process.exit(1);
393
+ }
394
+ if (opts.quick) {
395
+ explain(target, opts.db, {
396
+ depth: parseInt(opts.depth, 10),
397
+ noTests: resolveNoTests(opts),
398
+ json: opts.json,
399
+ limit: opts.limit ? parseInt(opts.limit, 10) : undefined,
400
+ offset: opts.offset ? parseInt(opts.offset, 10) : undefined,
401
+ ndjson: opts.ndjson,
402
+ });
403
+ return;
404
+ }
405
+ audit(target, opts.db, {
280
406
  depth: parseInt(opts.depth, 10),
407
+ file: opts.file,
408
+ kind: opts.kind,
281
409
  noTests: resolveNoTests(opts),
282
410
  json: opts.json,
283
411
  });
@@ -320,6 +448,9 @@ program
320
448
  .option('--include-tests', 'Include test/spec files (overrides excludeTests config)')
321
449
  .option('-j, --json', 'Output as JSON')
322
450
  .option('-f, --format <format>', 'Output format: text, mermaid, json', 'text')
451
+ .option('--limit <number>', 'Max results to return')
452
+ .option('--offset <number>', 'Skip N results (default: 0)')
453
+ .option('--ndjson', 'Newline-delimited JSON output')
323
454
  .action((ref, opts) => {
324
455
  diffImpact(opts.db, {
325
456
  ref,
@@ -328,16 +459,99 @@ program
328
459
  noTests: resolveNoTests(opts),
329
460
  json: opts.json,
330
461
  format: opts.format,
462
+ limit: opts.limit ? parseInt(opts.limit, 10) : undefined,
463
+ offset: opts.offset ? parseInt(opts.offset, 10) : undefined,
464
+ ndjson: opts.ndjson,
465
+ });
466
+ });
467
+
468
+ program
469
+ .command('check [ref]')
470
+ .description(
471
+ 'CI gate: run manifesto rules (no args), diff predicates (with ref/--staged), or both (--rules)',
472
+ )
473
+ .option('-d, --db <path>', 'Path to graph.db')
474
+ .option('--staged', 'Analyze staged changes')
475
+ .option('--rules', 'Also run manifesto rules alongside diff predicates')
476
+ .option('--cycles', 'Assert no dependency cycles involve changed files')
477
+ .option('--blast-radius <n>', 'Assert no function exceeds N transitive callers')
478
+ .option('--signatures', 'Assert no function declaration lines were modified')
479
+ .option('--boundaries', 'Assert no cross-owner boundary violations')
480
+ .option('--depth <n>', 'Max BFS depth for blast radius (default: 3)')
481
+ .option('-f, --file <path>', 'Scope to file (partial match, manifesto mode)')
482
+ .option('-k, --kind <kind>', 'Filter by symbol kind (manifesto mode)')
483
+ .option('-T, --no-tests', 'Exclude test/spec files from results')
484
+ .option('--include-tests', 'Include test/spec files (overrides excludeTests config)')
485
+ .option('-j, --json', 'Output as JSON')
486
+ .option('--limit <number>', 'Max results to return (manifesto mode)')
487
+ .option('--offset <number>', 'Skip N results (manifesto mode)')
488
+ .option('--ndjson', 'Newline-delimited JSON output (manifesto mode)')
489
+ .action(async (ref, opts) => {
490
+ const isDiffMode = ref || opts.staged;
491
+
492
+ if (!isDiffMode && !opts.rules) {
493
+ // No ref, no --staged → run manifesto rules on whole codebase
494
+ if (opts.kind && !EVERY_SYMBOL_KIND.includes(opts.kind)) {
495
+ console.error(`Invalid kind "${opts.kind}". Valid: ${EVERY_SYMBOL_KIND.join(', ')}`);
496
+ process.exit(1);
497
+ }
498
+ const { manifesto } = await import('./manifesto.js');
499
+ manifesto(opts.db, {
500
+ file: opts.file,
501
+ kind: opts.kind,
502
+ noTests: resolveNoTests(opts),
503
+ json: opts.json,
504
+ limit: opts.limit ? parseInt(opts.limit, 10) : undefined,
505
+ offset: opts.offset ? parseInt(opts.offset, 10) : undefined,
506
+ ndjson: opts.ndjson,
507
+ });
508
+ return;
509
+ }
510
+
511
+ // Diff predicates mode
512
+ const { check } = await import('./check.js');
513
+ check(opts.db, {
514
+ ref,
515
+ staged: opts.staged,
516
+ cycles: opts.cycles || undefined,
517
+ blastRadius: opts.blastRadius ? parseInt(opts.blastRadius, 10) : undefined,
518
+ signatures: opts.signatures || undefined,
519
+ boundaries: opts.boundaries || undefined,
520
+ depth: opts.depth ? parseInt(opts.depth, 10) : undefined,
521
+ noTests: resolveNoTests(opts),
522
+ json: opts.json,
331
523
  });
524
+
525
+ // If --rules, also run manifesto after diff predicates
526
+ if (opts.rules) {
527
+ if (opts.kind && !EVERY_SYMBOL_KIND.includes(opts.kind)) {
528
+ console.error(`Invalid kind "${opts.kind}". Valid: ${EVERY_SYMBOL_KIND.join(', ')}`);
529
+ process.exit(1);
530
+ }
531
+ const { manifesto } = await import('./manifesto.js');
532
+ manifesto(opts.db, {
533
+ file: opts.file,
534
+ kind: opts.kind,
535
+ noTests: resolveNoTests(opts),
536
+ json: opts.json,
537
+ limit: opts.limit ? parseInt(opts.limit, 10) : undefined,
538
+ offset: opts.offset ? parseInt(opts.offset, 10) : undefined,
539
+ ndjson: opts.ndjson,
540
+ });
541
+ }
332
542
  });
333
543
 
334
544
  // ─── New commands ────────────────────────────────────────────────────────
335
545
 
336
546
  program
337
547
  .command('export')
338
- .description('Export dependency graph as DOT (Graphviz), Mermaid, or JSON')
548
+ .description('Export dependency graph as DOT, Mermaid, JSON, GraphML, GraphSON, or Neo4j CSV')
339
549
  .option('-d, --db <path>', 'Path to graph.db')
340
- .option('-f, --format <format>', 'Output format: dot, mermaid, json', 'dot')
550
+ .option(
551
+ '-f, --format <format>',
552
+ 'Output format: dot, mermaid, json, graphml, graphson, neo4j',
553
+ 'dot',
554
+ )
341
555
  .option('--functions', 'Function-level graph instead of file-level')
342
556
  .option('-T, --no-tests', 'Exclude test/spec files')
343
557
  .option('--include-tests', 'Include test/spec files (overrides excludeTests config)')
@@ -361,6 +575,25 @@ program
361
575
  case 'json':
362
576
  output = JSON.stringify(exportJSON(db, exportOpts), null, 2);
363
577
  break;
578
+ case 'graphml':
579
+ output = exportGraphML(db, exportOpts);
580
+ break;
581
+ case 'graphson':
582
+ output = JSON.stringify(exportGraphSON(db, exportOpts), null, 2);
583
+ break;
584
+ case 'neo4j': {
585
+ const csv = exportNeo4jCSV(db, exportOpts);
586
+ if (opts.output) {
587
+ const base = opts.output.replace(/\.[^.]+$/, '') || opts.output;
588
+ fs.writeFileSync(`${base}-nodes.csv`, csv.nodes, 'utf-8');
589
+ fs.writeFileSync(`${base}-relationships.csv`, csv.relationships, 'utf-8');
590
+ db.close();
591
+ console.log(`Exported to ${base}-nodes.csv and ${base}-relationships.csv`);
592
+ return;
593
+ }
594
+ output = `--- nodes.csv ---\n${csv.nodes}\n\n--- relationships.csv ---\n${csv.relationships}`;
595
+ break;
596
+ }
364
597
  default:
365
598
  output = exportDOT(db, exportOpts);
366
599
  break;
@@ -376,6 +609,81 @@ program
376
609
  }
377
610
  });
378
611
 
612
+ program
613
+ .command('plot')
614
+ .description('Generate an interactive HTML dependency graph viewer')
615
+ .option('-d, --db <path>', 'Path to graph.db')
616
+ .option('--functions', 'Function-level graph instead of file-level')
617
+ .option('-T, --no-tests', 'Exclude test/spec files')
618
+ .option('--include-tests', 'Include test/spec files (overrides excludeTests config)')
619
+ .option('--min-confidence <score>', 'Minimum edge confidence threshold (default: 0.5)', '0.5')
620
+ .option('-o, --output <file>', 'Write HTML to file')
621
+ .option('-c, --config <path>', 'Path to .plotDotCfg config file')
622
+ .option('--no-open', 'Do not open in browser')
623
+ .option('--cluster <mode>', 'Cluster nodes: none | community | directory')
624
+ .option('--overlay <list>', 'Comma-separated overlays: complexity,risk')
625
+ .option('--seed <strategy>', 'Seed strategy: all | top-fanin | entry')
626
+ .option('--seed-count <n>', 'Number of seed nodes (default: 30)')
627
+ .option('--size-by <metric>', 'Size nodes by: uniform | fan-in | fan-out | complexity')
628
+ .option('--color-by <mode>', 'Color nodes by: kind | role | community | complexity')
629
+ .action(async (opts) => {
630
+ const { generatePlotHTML, loadPlotConfig } = await import('./viewer.js');
631
+ const os = await import('node:os');
632
+ const db = openReadonlyOrFail(opts.db);
633
+
634
+ let plotCfg;
635
+ if (opts.config) {
636
+ try {
637
+ plotCfg = JSON.parse(fs.readFileSync(opts.config, 'utf-8'));
638
+ } catch (e) {
639
+ console.error(`Failed to load config: ${e.message}`);
640
+ db.close();
641
+ process.exitCode = 1;
642
+ return;
643
+ }
644
+ } else {
645
+ plotCfg = loadPlotConfig(process.cwd());
646
+ }
647
+
648
+ // Merge CLI flags into config
649
+ if (opts.cluster) plotCfg.clusterBy = opts.cluster;
650
+ if (opts.colorBy) plotCfg.colorBy = opts.colorBy;
651
+ if (opts.sizeBy) plotCfg.sizeBy = opts.sizeBy;
652
+ if (opts.seed) plotCfg.seedStrategy = opts.seed;
653
+ if (opts.seedCount) plotCfg.seedCount = parseInt(opts.seedCount, 10);
654
+ if (opts.overlay) {
655
+ const parts = opts.overlay.split(',').map((s) => s.trim());
656
+ if (!plotCfg.overlays) plotCfg.overlays = {};
657
+ if (parts.includes('complexity')) plotCfg.overlays.complexity = true;
658
+ if (parts.includes('risk')) plotCfg.overlays.risk = true;
659
+ }
660
+
661
+ const html = generatePlotHTML(db, {
662
+ fileLevel: !opts.functions,
663
+ noTests: resolveNoTests(opts),
664
+ minConfidence: parseFloat(opts.minConfidence),
665
+ config: plotCfg,
666
+ });
667
+ db.close();
668
+
669
+ const outPath = opts.output || path.join(os.tmpdir(), `codegraph-plot-${Date.now()}.html`);
670
+ fs.writeFileSync(outPath, html, 'utf-8');
671
+ console.log(`Plot written to ${outPath}`);
672
+
673
+ if (opts.open !== false) {
674
+ const { execFile } = await import('node:child_process');
675
+ const args =
676
+ process.platform === 'win32'
677
+ ? ['cmd', ['/c', 'start', '', outPath]]
678
+ : process.platform === 'darwin'
679
+ ? ['open', [outPath]]
680
+ : ['xdg-open', [outPath]];
681
+ execFile(args[0], args[1], (err) => {
682
+ if (err) console.error('Could not open browser:', err.message);
683
+ });
684
+ }
685
+ });
686
+
379
687
  program
380
688
  .command('cycles')
381
689
  .description('Detect circular dependencies in the codebase')
@@ -498,6 +806,81 @@ registry
498
806
  }
499
807
  });
500
808
 
809
+ // ─── Snapshot commands ──────────────────────────────────────────────────
810
+
811
+ const snapshot = program
812
+ .command('snapshot')
813
+ .description('Save and restore graph database snapshots');
814
+
815
+ snapshot
816
+ .command('save <name>')
817
+ .description('Save a snapshot of the current graph database')
818
+ .option('-d, --db <path>', 'Path to graph.db')
819
+ .option('--force', 'Overwrite existing snapshot')
820
+ .action((name, opts) => {
821
+ try {
822
+ const result = snapshotSave(name, { dbPath: opts.db, force: opts.force });
823
+ console.log(`Snapshot saved: ${result.name} (${formatSize(result.size)})`);
824
+ } catch (err) {
825
+ console.error(err.message);
826
+ process.exit(1);
827
+ }
828
+ });
829
+
830
+ snapshot
831
+ .command('restore <name>')
832
+ .description('Restore a snapshot over the current graph database')
833
+ .option('-d, --db <path>', 'Path to graph.db')
834
+ .action((name, opts) => {
835
+ try {
836
+ snapshotRestore(name, { dbPath: opts.db });
837
+ console.log(`Snapshot "${name}" restored.`);
838
+ } catch (err) {
839
+ console.error(err.message);
840
+ process.exit(1);
841
+ }
842
+ });
843
+
844
+ snapshot
845
+ .command('list')
846
+ .description('List all saved snapshots')
847
+ .option('-d, --db <path>', 'Path to graph.db')
848
+ .option('-j, --json', 'Output as JSON')
849
+ .action((opts) => {
850
+ try {
851
+ const snapshots = snapshotList({ dbPath: opts.db });
852
+ if (opts.json) {
853
+ console.log(JSON.stringify(snapshots, null, 2));
854
+ } else if (snapshots.length === 0) {
855
+ console.log('No snapshots found.');
856
+ } else {
857
+ console.log(`Snapshots (${snapshots.length}):\n`);
858
+ for (const s of snapshots) {
859
+ console.log(
860
+ ` ${s.name.padEnd(30)} ${formatSize(s.size).padStart(10)} ${s.createdAt.toISOString()}`,
861
+ );
862
+ }
863
+ }
864
+ } catch (err) {
865
+ console.error(err.message);
866
+ process.exit(1);
867
+ }
868
+ });
869
+
870
+ snapshot
871
+ .command('delete <name>')
872
+ .description('Delete a saved snapshot')
873
+ .option('-d, --db <path>', 'Path to graph.db')
874
+ .action((name, opts) => {
875
+ try {
876
+ snapshotDelete(name, { dbPath: opts.db });
877
+ console.log(`Snapshot "${name}" deleted.`);
878
+ } catch (err) {
879
+ console.error(err.message);
880
+ process.exit(1);
881
+ }
882
+ });
883
+
501
884
  // ─── Embedding commands ─────────────────────────────────────────────────
502
885
 
503
886
  program
@@ -556,8 +939,16 @@ program
556
939
  .option('-k, --kind <kind>', 'Filter by kind: function, method, class')
557
940
  .option('--file <pattern>', 'Filter by file path pattern')
558
941
  .option('--rrf-k <number>', 'RRF k parameter for multi-query ranking', '60')
942
+ .option('--mode <mode>', 'Search mode: hybrid, semantic, keyword (default: hybrid)')
559
943
  .option('-j, --json', 'Output as JSON')
944
+ .option('--offset <number>', 'Skip N results (default: 0)')
945
+ .option('--ndjson', 'Newline-delimited JSON output')
560
946
  .action(async (query, opts) => {
947
+ const validModes = ['hybrid', 'semantic', 'keyword'];
948
+ if (opts.mode && !validModes.includes(opts.mode)) {
949
+ console.error(`Invalid mode "${opts.mode}". Valid: ${validModes.join(', ')}`);
950
+ process.exit(1);
951
+ }
561
952
  await search(query, opts.db, {
562
953
  limit: parseInt(opts.limit, 10),
563
954
  noTests: resolveNoTests(opts),
@@ -566,6 +957,7 @@ program
566
957
  kind: opts.kind,
567
958
  filePattern: opts.file,
568
959
  rrfK: parseInt(opts.rrfK, 10),
960
+ mode: opts.mode,
569
961
  json: opts.json,
570
962
  });
571
963
  });
@@ -582,6 +974,9 @@ program
582
974
  .option('-T, --no-tests', 'Exclude test/spec files')
583
975
  .option('--include-tests', 'Include test/spec files (overrides excludeTests config)')
584
976
  .option('-j, --json', 'Output as JSON')
977
+ .option('--limit <number>', 'Max results to return')
978
+ .option('--offset <number>', 'Skip N results (default: 0)')
979
+ .option('--ndjson', 'Newline-delimited JSON output')
585
980
  .action(async (dir, opts) => {
586
981
  const { structureData, formatStructure } = await import('./structure.js');
587
982
  const data = structureData(opts.db, {
@@ -590,41 +985,18 @@ program
590
985
  sort: opts.sort,
591
986
  full: opts.full,
592
987
  noTests: resolveNoTests(opts),
988
+ limit: opts.limit ? parseInt(opts.limit, 10) : undefined,
989
+ offset: opts.offset ? parseInt(opts.offset, 10) : undefined,
593
990
  });
594
- if (opts.json) {
991
+ if (opts.ndjson) {
992
+ printNdjson(data, 'directories');
993
+ } else if (opts.json) {
595
994
  console.log(JSON.stringify(data, null, 2));
596
995
  } else {
597
996
  console.log(formatStructure(data));
598
997
  }
599
998
  });
600
999
 
601
- program
602
- .command('hotspots')
603
- .description(
604
- 'Find structural hotspots: files or directories with extreme fan-in, fan-out, or symbol density',
605
- )
606
- .option('-d, --db <path>', 'Path to graph.db')
607
- .option('-n, --limit <number>', 'Number of results', '10')
608
- .option('--metric <metric>', 'fan-in | fan-out | density | coupling', 'fan-in')
609
- .option('--level <level>', 'file | directory', 'file')
610
- .option('-T, --no-tests', 'Exclude test/spec files from results')
611
- .option('--include-tests', 'Include test/spec files (overrides excludeTests config)')
612
- .option('-j, --json', 'Output as JSON')
613
- .action(async (opts) => {
614
- const { hotspotsData, formatHotspots } = await import('./structure.js');
615
- const data = hotspotsData(opts.db, {
616
- metric: opts.metric,
617
- level: opts.level,
618
- limit: parseInt(opts.limit, 10),
619
- noTests: resolveNoTests(opts),
620
- });
621
- if (opts.json) {
622
- console.log(JSON.stringify(data, null, 2));
623
- } else {
624
- console.log(formatHotspots(data));
625
- }
626
- });
627
-
628
1000
  program
629
1001
  .command('roles')
630
1002
  .description('Show node role classification: entry, core, utility, adapter, dead, leaf')
@@ -668,6 +1040,8 @@ program
668
1040
  .option('-T, --no-tests', 'Exclude test/spec files')
669
1041
  .option('--include-tests', 'Include test/spec files (overrides excludeTests config)')
670
1042
  .option('-j, --json', 'Output as JSON')
1043
+ .option('--offset <number>', 'Skip N results (default: 0)')
1044
+ .option('--ndjson', 'Newline-delimited JSON output')
671
1045
  .action(async (file, opts) => {
672
1046
  const { analyzeCoChanges, coChangeData, coChangeTopData, formatCoChange, formatCoChangeTop } =
673
1047
  await import('./cochange.js');
@@ -694,20 +1068,25 @@ program
694
1068
 
695
1069
  const queryOpts = {
696
1070
  limit: parseInt(opts.limit, 10),
1071
+ offset: opts.offset ? parseInt(opts.offset, 10) : undefined,
697
1072
  minJaccard: opts.minJaccard ? parseFloat(opts.minJaccard) : config.coChange?.minJaccard,
698
1073
  noTests: resolveNoTests(opts),
699
1074
  };
700
1075
 
701
1076
  if (file) {
702
1077
  const data = coChangeData(file, opts.db, queryOpts);
703
- if (opts.json) {
1078
+ if (opts.ndjson) {
1079
+ printNdjson(data, 'partners');
1080
+ } else if (opts.json) {
704
1081
  console.log(JSON.stringify(data, null, 2));
705
1082
  } else {
706
1083
  console.log(formatCoChange(data));
707
1084
  }
708
1085
  } else {
709
1086
  const data = coChangeTopData(opts.db, queryOpts);
710
- if (opts.json) {
1087
+ if (opts.ndjson) {
1088
+ printNdjson(data, 'pairs');
1089
+ } else if (opts.json) {
711
1090
  console.log(JSON.stringify(data, null, 2));
712
1091
  } else {
713
1092
  console.log(formatCoChangeTop(data));
@@ -736,8 +1115,8 @@ program
736
1115
  console.error('Provide a function/entry point name or use --list to see all entry points.');
737
1116
  process.exit(1);
738
1117
  }
739
- if (opts.kind && !ALL_SYMBOL_KINDS.includes(opts.kind)) {
740
- console.error(`Invalid kind "${opts.kind}". Valid: ${ALL_SYMBOL_KINDS.join(', ')}`);
1118
+ if (opts.kind && !EVERY_SYMBOL_KIND.includes(opts.kind)) {
1119
+ console.error(`Invalid kind "${opts.kind}". Valid: ${EVERY_SYMBOL_KIND.join(', ')}`);
741
1120
  process.exit(1);
742
1121
  }
743
1122
  const { flow } = await import('./flow.js');
@@ -754,6 +1133,70 @@ program
754
1133
  });
755
1134
  });
756
1135
 
1136
+ program
1137
+ .command('dataflow <name>')
1138
+ .description('Show data flow for a function: parameters, return consumers, mutations')
1139
+ .option('-d, --db <path>', 'Path to graph.db')
1140
+ .option('-f, --file <path>', 'Scope to file (partial match)')
1141
+ .option('-k, --kind <kind>', 'Filter by symbol kind')
1142
+ .option('-T, --no-tests', 'Exclude test/spec files from results')
1143
+ .option('--include-tests', 'Include test/spec files (overrides excludeTests config)')
1144
+ .option('-j, --json', 'Output as JSON')
1145
+ .option('--ndjson', 'Newline-delimited JSON output')
1146
+ .option('--limit <number>', 'Max results to return')
1147
+ .option('--offset <number>', 'Skip N results (default: 0)')
1148
+ .option('--impact', 'Show data-dependent blast radius')
1149
+ .option('--depth <n>', 'Max traversal depth', '5')
1150
+ .action(async (name, opts) => {
1151
+ if (opts.kind && !EVERY_SYMBOL_KIND.includes(opts.kind)) {
1152
+ console.error(`Invalid kind "${opts.kind}". Valid: ${EVERY_SYMBOL_KIND.join(', ')}`);
1153
+ process.exit(1);
1154
+ }
1155
+ const { dataflow } = await import('./dataflow.js');
1156
+ dataflow(name, opts.db, {
1157
+ file: opts.file,
1158
+ kind: opts.kind,
1159
+ noTests: resolveNoTests(opts),
1160
+ json: opts.json,
1161
+ ndjson: opts.ndjson,
1162
+ limit: opts.limit ? parseInt(opts.limit, 10) : undefined,
1163
+ offset: opts.offset ? parseInt(opts.offset, 10) : undefined,
1164
+ impact: opts.impact,
1165
+ depth: opts.depth,
1166
+ });
1167
+ });
1168
+
1169
+ program
1170
+ .command('cfg <name>')
1171
+ .description('Show control flow graph for a function')
1172
+ .option('-d, --db <path>', 'Path to graph.db')
1173
+ .option('--format <fmt>', 'Output format: text, dot, mermaid', 'text')
1174
+ .option('-f, --file <path>', 'Scope to file (partial match)')
1175
+ .option('-k, --kind <kind>', 'Filter by symbol kind')
1176
+ .option('-T, --no-tests', 'Exclude test/spec files from results')
1177
+ .option('--include-tests', 'Include test/spec files (overrides excludeTests config)')
1178
+ .option('-j, --json', 'Output as JSON')
1179
+ .option('--ndjson', 'Newline-delimited JSON output')
1180
+ .option('--limit <number>', 'Max results to return')
1181
+ .option('--offset <number>', 'Skip N results (default: 0)')
1182
+ .action(async (name, opts) => {
1183
+ if (opts.kind && !EVERY_SYMBOL_KIND.includes(opts.kind)) {
1184
+ console.error(`Invalid kind "${opts.kind}". Valid: ${EVERY_SYMBOL_KIND.join(', ')}`);
1185
+ process.exit(1);
1186
+ }
1187
+ const { cfg } = await import('./cfg.js');
1188
+ cfg(name, opts.db, {
1189
+ format: opts.format,
1190
+ file: opts.file,
1191
+ kind: opts.kind,
1192
+ noTests: resolveNoTests(opts),
1193
+ json: opts.json,
1194
+ ndjson: opts.ndjson,
1195
+ limit: opts.limit ? parseInt(opts.limit, 10) : undefined,
1196
+ offset: opts.offset ? parseInt(opts.offset, 10) : undefined,
1197
+ });
1198
+ });
1199
+
757
1200
  program
758
1201
  .command('complexity [target]')
759
1202
  .description('Show per-function complexity metrics (cognitive, cyclomatic, nesting depth, MI)')
@@ -771,15 +1214,18 @@ program
771
1214
  .option('-T, --no-tests', 'Exclude test/spec files from results')
772
1215
  .option('--include-tests', 'Include test/spec files (overrides excludeTests config)')
773
1216
  .option('-j, --json', 'Output as JSON')
1217
+ .option('--offset <number>', 'Skip N results (default: 0)')
1218
+ .option('--ndjson', 'Newline-delimited JSON output')
774
1219
  .action(async (target, opts) => {
775
- if (opts.kind && !ALL_SYMBOL_KINDS.includes(opts.kind)) {
776
- console.error(`Invalid kind "${opts.kind}". Valid: ${ALL_SYMBOL_KINDS.join(', ')}`);
1220
+ if (opts.kind && !EVERY_SYMBOL_KIND.includes(opts.kind)) {
1221
+ console.error(`Invalid kind "${opts.kind}". Valid: ${EVERY_SYMBOL_KIND.join(', ')}`);
777
1222
  process.exit(1);
778
1223
  }
779
1224
  const { complexity } = await import('./complexity.js');
780
1225
  complexity(opts.db, {
781
1226
  target,
782
1227
  limit: parseInt(opts.limit, 10),
1228
+ offset: opts.offset ? parseInt(opts.offset, 10) : undefined,
783
1229
  sort: opts.sort,
784
1230
  aboveThreshold: opts.aboveThreshold,
785
1231
  health: opts.health,
@@ -787,29 +1233,36 @@ program
787
1233
  kind: opts.kind,
788
1234
  noTests: resolveNoTests(opts),
789
1235
  json: opts.json,
1236
+ ndjson: opts.ndjson,
790
1237
  });
791
1238
  });
792
1239
 
793
1240
  program
794
- .command('manifesto')
795
- .description('Evaluate manifesto rules (pass/fail verdicts for code health)')
1241
+ .command('ast [pattern]')
1242
+ .description('Search stored AST nodes (calls, new, string, regex, throw, await) by pattern')
796
1243
  .option('-d, --db <path>', 'Path to graph.db')
1244
+ .option('-k, --kind <kind>', 'Filter by AST node kind (call, new, string, regex, throw, await)')
1245
+ .option('-f, --file <path>', 'Scope to file (partial match)')
797
1246
  .option('-T, --no-tests', 'Exclude test/spec files from results')
798
1247
  .option('--include-tests', 'Include test/spec files (overrides excludeTests config)')
799
- .option('-f, --file <path>', 'Scope to file (partial match)')
800
- .option('-k, --kind <kind>', 'Filter by symbol kind')
801
1248
  .option('-j, --json', 'Output as JSON')
802
- .action(async (opts) => {
803
- if (opts.kind && !ALL_SYMBOL_KINDS.includes(opts.kind)) {
804
- console.error(`Invalid kind "${opts.kind}". Valid: ${ALL_SYMBOL_KINDS.join(', ')}`);
1249
+ .option('--ndjson', 'Newline-delimited JSON output')
1250
+ .option('--limit <number>', 'Max results to return')
1251
+ .option('--offset <number>', 'Skip N results (default: 0)')
1252
+ .action(async (pattern, opts) => {
1253
+ const { AST_NODE_KINDS, astQuery } = await import('./ast.js');
1254
+ if (opts.kind && !AST_NODE_KINDS.includes(opts.kind)) {
1255
+ console.error(`Invalid AST kind "${opts.kind}". Valid: ${AST_NODE_KINDS.join(', ')}`);
805
1256
  process.exit(1);
806
1257
  }
807
- const { manifesto } = await import('./manifesto.js');
808
- manifesto(opts.db, {
809
- file: opts.file,
1258
+ astQuery(pattern, opts.db, {
810
1259
  kind: opts.kind,
1260
+ file: opts.file,
811
1261
  noTests: resolveNoTests(opts),
812
1262
  json: opts.json,
1263
+ ndjson: opts.ndjson,
1264
+ limit: opts.limit ? parseInt(opts.limit, 10) : undefined,
1265
+ offset: opts.offset ? parseInt(opts.offset, 10) : undefined,
813
1266
  });
814
1267
  });
815
1268
 
@@ -823,6 +1276,9 @@ program
823
1276
  .option('-T, --no-tests', 'Exclude test/spec files from results')
824
1277
  .option('--include-tests', 'Include test/spec files (overrides excludeTests config)')
825
1278
  .option('-j, --json', 'Output as JSON')
1279
+ .option('--limit <number>', 'Max results to return')
1280
+ .option('--offset <number>', 'Skip N results (default: 0)')
1281
+ .option('--ndjson', 'Newline-delimited JSON output')
826
1282
  .action(async (opts) => {
827
1283
  const { communities } = await import('./communities.js');
828
1284
  communities(opts.db, {
@@ -831,6 +1287,114 @@ program
831
1287
  drift: opts.drift,
832
1288
  noTests: resolveNoTests(opts),
833
1289
  json: opts.json,
1290
+ limit: opts.limit ? parseInt(opts.limit, 10) : undefined,
1291
+ offset: opts.offset ? parseInt(opts.offset, 10) : undefined,
1292
+ ndjson: opts.ndjson,
1293
+ });
1294
+ });
1295
+
1296
+ program
1297
+ .command('triage')
1298
+ .description(
1299
+ 'Ranked audit queue by composite risk score (connectivity + complexity + churn + role)',
1300
+ )
1301
+ .option('-d, --db <path>', 'Path to graph.db')
1302
+ .option('-n, --limit <number>', 'Max results to return', '20')
1303
+ .option(
1304
+ '--level <level>',
1305
+ 'Granularity: function (default) | file | directory. File/directory level shows hotspots',
1306
+ 'function',
1307
+ )
1308
+ .option(
1309
+ '--sort <metric>',
1310
+ 'Sort metric: risk | complexity | churn | fan-in | mi (function level); fan-in | fan-out | density | coupling (file/directory level)',
1311
+ 'risk',
1312
+ )
1313
+ .option('--min-score <score>', 'Only show symbols with risk score >= threshold')
1314
+ .option('--role <role>', 'Filter by role (entry, core, utility, adapter, leaf, dead)')
1315
+ .option('-f, --file <path>', 'Scope to a specific file (partial match)')
1316
+ .option('-k, --kind <kind>', 'Filter by symbol kind (function, method, class)')
1317
+ .option('-T, --no-tests', 'Exclude test/spec files from results')
1318
+ .option('--include-tests', 'Include test/spec files (overrides excludeTests config)')
1319
+ .option('-j, --json', 'Output as JSON')
1320
+ .option('--offset <number>', 'Skip N results (default: 0)')
1321
+ .option('--ndjson', 'Newline-delimited JSON output')
1322
+ .option('--weights <json>', 'Custom weights JSON (e.g. \'{"fanIn":1,"complexity":0}\')')
1323
+ .action(async (opts) => {
1324
+ if (opts.level === 'file' || opts.level === 'directory') {
1325
+ // Delegate to hotspots for file/directory level
1326
+ const { hotspotsData, formatHotspots } = await import('./structure.js');
1327
+ const metric = opts.sort === 'risk' ? 'fan-in' : opts.sort;
1328
+ const data = hotspotsData(opts.db, {
1329
+ metric,
1330
+ level: opts.level,
1331
+ limit: parseInt(opts.limit, 10),
1332
+ offset: opts.offset ? parseInt(opts.offset, 10) : undefined,
1333
+ noTests: resolveNoTests(opts),
1334
+ });
1335
+ if (opts.ndjson) {
1336
+ printNdjson(data, 'hotspots');
1337
+ } else if (opts.json) {
1338
+ console.log(JSON.stringify(data, null, 2));
1339
+ } else {
1340
+ console.log(formatHotspots(data));
1341
+ }
1342
+ return;
1343
+ }
1344
+
1345
+ if (opts.kind && !EVERY_SYMBOL_KIND.includes(opts.kind)) {
1346
+ console.error(`Invalid kind "${opts.kind}". Valid: ${EVERY_SYMBOL_KIND.join(', ')}`);
1347
+ process.exit(1);
1348
+ }
1349
+ if (opts.role && !VALID_ROLES.includes(opts.role)) {
1350
+ console.error(`Invalid role "${opts.role}". Valid: ${VALID_ROLES.join(', ')}`);
1351
+ process.exit(1);
1352
+ }
1353
+ let weights;
1354
+ if (opts.weights) {
1355
+ try {
1356
+ weights = JSON.parse(opts.weights);
1357
+ } catch {
1358
+ console.error('Invalid --weights JSON');
1359
+ process.exit(1);
1360
+ }
1361
+ }
1362
+ const { triage } = await import('./triage.js');
1363
+ triage(opts.db, {
1364
+ limit: parseInt(opts.limit, 10),
1365
+ offset: opts.offset ? parseInt(opts.offset, 10) : undefined,
1366
+ sort: opts.sort,
1367
+ minScore: opts.minScore,
1368
+ role: opts.role,
1369
+ file: opts.file,
1370
+ kind: opts.kind,
1371
+ noTests: resolveNoTests(opts),
1372
+ json: opts.json,
1373
+ ndjson: opts.ndjson,
1374
+ weights,
1375
+ });
1376
+ });
1377
+
1378
+ program
1379
+ .command('owners [target]')
1380
+ .description('Show CODEOWNERS mapping for files and functions')
1381
+ .option('-d, --db <path>', 'Path to graph.db')
1382
+ .option('--owner <owner>', 'Filter to a specific owner')
1383
+ .option('--boundary', 'Show cross-owner boundary edges')
1384
+ .option('-f, --file <path>', 'Scope to a specific file')
1385
+ .option('-k, --kind <kind>', 'Filter by symbol kind')
1386
+ .option('-T, --no-tests', 'Exclude test/spec files')
1387
+ .option('--include-tests', 'Include test/spec files (overrides excludeTests config)')
1388
+ .option('-j, --json', 'Output as JSON')
1389
+ .action(async (target, opts) => {
1390
+ const { owners } = await import('./owners.js');
1391
+ owners(opts.db, {
1392
+ owner: opts.owner,
1393
+ boundary: opts.boundary,
1394
+ file: opts.file || target,
1395
+ kind: opts.kind,
1396
+ noTests: resolveNoTests(opts),
1397
+ json: opts.json,
834
1398
  });
835
1399
  });
836
1400
 
@@ -927,4 +1491,67 @@ program
927
1491
  }
928
1492
  });
929
1493
 
1494
+ program
1495
+ .command('batch <command> [targets...]')
1496
+ .description(
1497
+ `Run a query against multiple targets in one call. Output is always JSON.\nValid commands: ${Object.keys(BATCH_COMMANDS).join(', ')}`,
1498
+ )
1499
+ .option('-d, --db <path>', 'Path to graph.db')
1500
+ .option('--from-file <path>', 'Read targets from file (JSON array or newline-delimited)')
1501
+ .option('--stdin', 'Read targets from stdin (JSON array)')
1502
+ .option('--depth <n>', 'Traversal depth passed to underlying command')
1503
+ .option('-f, --file <path>', 'Scope to file (partial match)')
1504
+ .option('-k, --kind <kind>', 'Filter by symbol kind')
1505
+ .option('-T, --no-tests', 'Exclude test/spec files from results')
1506
+ .option('--include-tests', 'Include test/spec files (overrides excludeTests config)')
1507
+ .action(async (command, positionalTargets, opts) => {
1508
+ if (opts.kind && !EVERY_SYMBOL_KIND.includes(opts.kind)) {
1509
+ console.error(`Invalid kind "${opts.kind}". Valid: ${EVERY_SYMBOL_KIND.join(', ')}`);
1510
+ process.exit(1);
1511
+ }
1512
+
1513
+ let targets;
1514
+ try {
1515
+ if (opts.fromFile) {
1516
+ const raw = fs.readFileSync(opts.fromFile, 'utf-8').trim();
1517
+ if (raw.startsWith('[')) {
1518
+ targets = JSON.parse(raw);
1519
+ } else {
1520
+ targets = raw.split(/\r?\n/).filter(Boolean);
1521
+ }
1522
+ } else if (opts.stdin) {
1523
+ const chunks = [];
1524
+ for await (const chunk of process.stdin) chunks.push(chunk);
1525
+ const raw = Buffer.concat(chunks).toString('utf-8').trim();
1526
+ targets = raw.startsWith('[') ? JSON.parse(raw) : raw.split(/\r?\n/).filter(Boolean);
1527
+ } else {
1528
+ targets = splitTargets(positionalTargets);
1529
+ }
1530
+ } catch (err) {
1531
+ console.error(`Failed to parse targets: ${err.message}`);
1532
+ process.exit(1);
1533
+ }
1534
+
1535
+ if (!targets || targets.length === 0) {
1536
+ console.error('No targets provided. Pass targets as arguments, --from-file, or --stdin.');
1537
+ process.exit(1);
1538
+ }
1539
+
1540
+ const batchOpts = {
1541
+ depth: opts.depth ? parseInt(opts.depth, 10) : undefined,
1542
+ file: opts.file,
1543
+ kind: opts.kind,
1544
+ noTests: resolveNoTests(opts),
1545
+ };
1546
+
1547
+ // Multi-command mode: items from --from-file / --stdin may be objects with { command, target }
1548
+ const isMulti = targets.length > 0 && typeof targets[0] === 'object' && targets[0].command;
1549
+ if (isMulti) {
1550
+ const data = multiBatchData(targets, opts.db, batchOpts);
1551
+ console.log(JSON.stringify(data, null, 2));
1552
+ } else {
1553
+ batch(command, targets, opts.db, batchOpts);
1554
+ }
1555
+ });
1556
+
930
1557
  program.parse();