@optave/codegraph 2.6.0 → 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
@@ -4,7 +4,7 @@ import fs from 'node:fs';
4
4
  import path from 'node:path';
5
5
  import { Command } from 'commander';
6
6
  import { audit } from './audit.js';
7
- import { BATCH_COMMANDS, batch } from './batch.js';
7
+ import { BATCH_COMMANDS, batch, multiBatchData, splitTargets } from './batch.js';
8
8
  import { buildGraph } from './builder.js';
9
9
  import { loadConfig } from './config.js';
10
10
  import { findCycles, formatCycles } from './cycles.js';
@@ -16,20 +16,28 @@ import {
16
16
  MODELS,
17
17
  search,
18
18
  } from './embedder.js';
19
- 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';
20
27
  import { setVerbose } from './logger.js';
21
28
  import { printNdjson } from './paginate.js';
22
29
  import {
23
- ALL_SYMBOL_KINDS,
30
+ children,
24
31
  context,
25
32
  diffImpact,
33
+ EVERY_SYMBOL_KIND,
26
34
  explain,
27
35
  fileDeps,
36
+ fileExports,
28
37
  fnDeps,
29
38
  fnImpact,
30
39
  impactAnalysis,
31
40
  moduleMap,
32
- queryName,
33
41
  roles,
34
42
  stats,
35
43
  symbolPath,
@@ -97,16 +105,31 @@ program
97
105
  .command('build [dir]')
98
106
  .description('Parse repo and build graph in .codegraph/graph.db')
99
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')
100
110
  .action(async (dir, opts) => {
101
111
  const root = path.resolve(dir || '.');
102
112
  const engine = program.opts().engine;
103
- 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
+ });
104
119
  });
105
120
 
106
121
  program
107
122
  .command('query <name>')
108
- .description('Find a function/class, show callers and callees')
123
+ .description('Function-level dependency chain or shortest path between symbols')
109
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')
110
133
  .option('-T, --no-tests', 'Exclude test/spec files from results')
111
134
  .option('--include-tests', 'Include test/spec files (overrides excludeTests config)')
112
135
  .option('-j, --json', 'Output as JSON')
@@ -114,12 +137,63 @@ program
114
137
  .option('--offset <number>', 'Skip N results (default: 0)')
115
138
  .option('--ndjson', 'Newline-delimited JSON output')
116
139
  .action((name, opts) => {
117
- 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,
118
195
  noTests: resolveNoTests(opts),
119
196
  json: opts.json,
120
- limit: opts.limit ? parseInt(opts.limit, 10) : undefined,
121
- offset: opts.offset ? parseInt(opts.offset, 10) : undefined,
122
- ndjson: opts.ndjson,
123
197
  });
124
198
  });
125
199
 
@@ -190,27 +264,17 @@ program
190
264
  });
191
265
 
192
266
  program
193
- .command('fn <name>')
194
- .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)')
195
269
  .option('-d, --db <path>', 'Path to graph.db')
196
- .option('--depth <n>', 'Transitive caller depth', '3')
197
- .option('-f, --file <path>', 'Scope search to functions in this file (partial match)')
198
- .option('-k, --kind <kind>', 'Filter to a specific symbol kind')
199
270
  .option('-T, --no-tests', 'Exclude test/spec files from results')
200
271
  .option('--include-tests', 'Include test/spec files (overrides excludeTests config)')
201
272
  .option('-j, --json', 'Output as JSON')
202
273
  .option('--limit <number>', 'Max results to return')
203
274
  .option('--offset <number>', 'Skip N results (default: 0)')
204
275
  .option('--ndjson', 'Newline-delimited JSON output')
205
- .action((name, opts) => {
206
- if (opts.kind && !ALL_SYMBOL_KINDS.includes(opts.kind)) {
207
- console.error(`Invalid kind "${opts.kind}". Valid: ${ALL_SYMBOL_KINDS.join(', ')}`);
208
- process.exit(1);
209
- }
210
- fnDeps(name, opts.db, {
211
- depth: parseInt(opts.depth, 10),
212
- file: opts.file,
213
- kind: opts.kind,
276
+ .action((file, opts) => {
277
+ fileExports(file, opts.db, {
214
278
  noTests: resolveNoTests(opts),
215
279
  json: opts.json,
216
280
  limit: opts.limit ? parseInt(opts.limit, 10) : undefined,
@@ -233,8 +297,8 @@ program
233
297
  .option('--offset <number>', 'Skip N results (default: 0)')
234
298
  .option('--ndjson', 'Newline-delimited JSON output')
235
299
  .action((name, opts) => {
236
- if (opts.kind && !ALL_SYMBOL_KINDS.includes(opts.kind)) {
237
- 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(', ')}`);
238
302
  process.exit(1);
239
303
  }
240
304
  fnImpact(name, opts.db, {
@@ -249,36 +313,6 @@ program
249
313
  });
250
314
  });
251
315
 
252
- program
253
- .command('path <from> <to>')
254
- .description('Find shortest path between two symbols (A calls...calls B)')
255
- .option('-d, --db <path>', 'Path to graph.db')
256
- .option('--max-depth <n>', 'Maximum BFS depth', '10')
257
- .option('--kinds <kinds>', 'Comma-separated edge kinds to follow (default: calls)')
258
- .option('--reverse', 'Follow edges backward (B is called by...called by A)')
259
- .option('--from-file <path>', 'Disambiguate source symbol by file (partial match)')
260
- .option('--to-file <path>', 'Disambiguate target symbol by file (partial match)')
261
- .option('-k, --kind <kind>', 'Filter both symbols by kind')
262
- .option('-T, --no-tests', 'Exclude test/spec files from results')
263
- .option('--include-tests', 'Include test/spec files (overrides excludeTests config)')
264
- .option('-j, --json', 'Output as JSON')
265
- .action((from, to, opts) => {
266
- if (opts.kind && !ALL_SYMBOL_KINDS.includes(opts.kind)) {
267
- console.error(`Invalid kind "${opts.kind}". Valid: ${ALL_SYMBOL_KINDS.join(', ')}`);
268
- process.exit(1);
269
- }
270
- symbolPath(from, to, opts.db, {
271
- maxDepth: parseInt(opts.maxDepth, 10),
272
- edgeKinds: opts.kinds ? opts.kinds.split(',').map((s) => s.trim()) : undefined,
273
- reverse: opts.reverse,
274
- fromFile: opts.fromFile,
275
- toFile: opts.toFile,
276
- kind: opts.kind,
277
- noTests: resolveNoTests(opts),
278
- json: opts.json,
279
- });
280
- });
281
-
282
316
  program
283
317
  .command('context <name>')
284
318
  .description('Full context for a function: source, deps, callers, tests, signature')
@@ -295,8 +329,8 @@ program
295
329
  .option('--offset <number>', 'Skip N results (default: 0)')
296
330
  .option('--ndjson', 'Newline-delimited JSON output')
297
331
  .action((name, opts) => {
298
- if (opts.kind && !ALL_SYMBOL_KINDS.includes(opts.kind)) {
299
- console.error(`Invalid kind "${opts.kind}". Valid: ${ALL_SYMBOL_KINDS.join(', ')}`);
332
+ if (opts.kind && !EVERY_SYMBOL_KIND.includes(opts.kind)) {
333
+ console.error(`Invalid kind "${opts.kind}". Valid: ${EVERY_SYMBOL_KIND.join(', ')}`);
300
334
  process.exit(1);
301
335
  }
302
336
  context(name, opts.db, {
@@ -314,24 +348,27 @@ program
314
348
  });
315
349
 
316
350
  program
317
- .command('explain <target>')
318
- .description('Structural summary of a file or function (no LLM needed)')
351
+ .command('children <name>')
352
+ .description('List parameters, properties, and constants of a symbol')
319
353
  .option('-d, --db <path>', 'Path to graph.db')
320
- .option('--depth <n>', 'Recursively explain dependencies up to N levels deep', '0')
354
+ .option('-f, --file <path>', 'Scope search to symbols in this file (partial match)')
355
+ .option('-k, --kind <kind>', 'Filter to a specific symbol kind')
321
356
  .option('-T, --no-tests', 'Exclude test/spec files from results')
322
- .option('--include-tests', 'Include test/spec files (overrides excludeTests config)')
323
357
  .option('-j, --json', 'Output as JSON')
324
358
  .option('--limit <number>', 'Max results to return')
325
359
  .option('--offset <number>', 'Skip N results (default: 0)')
326
- .option('--ndjson', 'Newline-delimited JSON output')
327
- .action((target, opts) => {
328
- explain(target, opts.db, {
329
- depth: parseInt(opts.depth, 10),
360
+ .action((name, opts) => {
361
+ if (opts.kind && !EVERY_SYMBOL_KIND.includes(opts.kind)) {
362
+ console.error(`Invalid kind "${opts.kind}". Valid: ${EVERY_SYMBOL_KIND.join(', ')}`);
363
+ process.exit(1);
364
+ }
365
+ children(name, opts.db, {
366
+ file: opts.file,
367
+ kind: opts.kind,
330
368
  noTests: resolveNoTests(opts),
331
369
  json: opts.json,
332
370
  limit: opts.limit ? parseInt(opts.limit, 10) : undefined,
333
371
  offset: opts.offset ? parseInt(opts.offset, 10) : undefined,
334
- ndjson: opts.ndjson,
335
372
  });
336
373
  });
337
374
 
@@ -339,17 +376,32 @@ program
339
376
  .command('audit <target>')
340
377
  .description('Composite report: explain + impact + health metrics per function')
341
378
  .option('-d, --db <path>', 'Path to graph.db')
342
- .option('--depth <n>', 'Impact analysis depth', '3')
379
+ .option('--quick', 'Structural summary only (skip impact analysis and health metrics)')
380
+ .option('--depth <n>', 'Impact/explain depth', '3')
343
381
  .option('-f, --file <path>', 'Scope to file (partial match)')
344
382
  .option('-k, --kind <kind>', 'Filter by symbol kind')
345
383
  .option('-T, --no-tests', 'Exclude test/spec files from results')
346
384
  .option('--include-tests', 'Include test/spec files (overrides excludeTests config)')
347
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)')
348
389
  .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(', ')}`);
390
+ if (opts.kind && !EVERY_SYMBOL_KIND.includes(opts.kind)) {
391
+ console.error(`Invalid kind "${opts.kind}". Valid: ${EVERY_SYMBOL_KIND.join(', ')}`);
351
392
  process.exit(1);
352
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
+ }
353
405
  audit(target, opts.db, {
354
406
  depth: parseInt(opts.depth, 10),
355
407
  file: opts.file,
@@ -415,18 +467,48 @@ program
415
467
 
416
468
  program
417
469
  .command('check [ref]')
418
- .description('Run validation predicates against git changes (CI gate)')
470
+ .description(
471
+ 'CI gate: run manifesto rules (no args), diff predicates (with ref/--staged), or both (--rules)',
472
+ )
419
473
  .option('-d, --db <path>', 'Path to graph.db')
420
474
  .option('--staged', 'Analyze staged changes')
475
+ .option('--rules', 'Also run manifesto rules alongside diff predicates')
421
476
  .option('--cycles', 'Assert no dependency cycles involve changed files')
422
477
  .option('--blast-radius <n>', 'Assert no function exceeds N transitive callers')
423
478
  .option('--signatures', 'Assert no function declaration lines were modified')
424
479
  .option('--boundaries', 'Assert no cross-owner boundary violations')
425
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)')
426
483
  .option('-T, --no-tests', 'Exclude test/spec files from results')
427
484
  .option('--include-tests', 'Include test/spec files (overrides excludeTests config)')
428
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)')
429
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
430
512
  const { check } = await import('./check.js');
431
513
  check(opts.db, {
432
514
  ref,
@@ -439,15 +521,37 @@ program
439
521
  noTests: resolveNoTests(opts),
440
522
  json: opts.json,
441
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
+ }
442
542
  });
443
543
 
444
544
  // ─── New commands ────────────────────────────────────────────────────────
445
545
 
446
546
  program
447
547
  .command('export')
448
- .description('Export dependency graph as DOT (Graphviz), Mermaid, or JSON')
548
+ .description('Export dependency graph as DOT, Mermaid, JSON, GraphML, GraphSON, or Neo4j CSV')
449
549
  .option('-d, --db <path>', 'Path to graph.db')
450
- .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
+ )
451
555
  .option('--functions', 'Function-level graph instead of file-level')
452
556
  .option('-T, --no-tests', 'Exclude test/spec files')
453
557
  .option('--include-tests', 'Include test/spec files (overrides excludeTests config)')
@@ -471,6 +575,25 @@ program
471
575
  case 'json':
472
576
  output = JSON.stringify(exportJSON(db, exportOpts), null, 2);
473
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
+ }
474
597
  default:
475
598
  output = exportDOT(db, exportOpts);
476
599
  break;
@@ -486,6 +609,81 @@ program
486
609
  }
487
610
  });
488
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
+
489
687
  program
490
688
  .command('cycles')
491
689
  .description('Detect circular dependencies in the codebase')
@@ -799,38 +997,6 @@ program
799
997
  }
800
998
  });
801
999
 
802
- program
803
- .command('hotspots')
804
- .description(
805
- 'Find structural hotspots: files or directories with extreme fan-in, fan-out, or symbol density',
806
- )
807
- .option('-d, --db <path>', 'Path to graph.db')
808
- .option('-n, --limit <number>', 'Number of results', '10')
809
- .option('--metric <metric>', 'fan-in | fan-out | density | coupling', 'fan-in')
810
- .option('--level <level>', 'file | directory', 'file')
811
- .option('-T, --no-tests', 'Exclude test/spec files from results')
812
- .option('--include-tests', 'Include test/spec files (overrides excludeTests config)')
813
- .option('-j, --json', 'Output as JSON')
814
- .option('--offset <number>', 'Skip N results (default: 0)')
815
- .option('--ndjson', 'Newline-delimited JSON output')
816
- .action(async (opts) => {
817
- const { hotspotsData, formatHotspots } = await import('./structure.js');
818
- const data = hotspotsData(opts.db, {
819
- metric: opts.metric,
820
- level: opts.level,
821
- limit: parseInt(opts.limit, 10),
822
- offset: opts.offset ? parseInt(opts.offset, 10) : undefined,
823
- noTests: resolveNoTests(opts),
824
- });
825
- if (opts.ndjson) {
826
- printNdjson(data, 'hotspots');
827
- } else if (opts.json) {
828
- console.log(JSON.stringify(data, null, 2));
829
- } else {
830
- console.log(formatHotspots(data));
831
- }
832
- });
833
-
834
1000
  program
835
1001
  .command('roles')
836
1002
  .description('Show node role classification: entry, core, utility, adapter, dead, leaf')
@@ -949,8 +1115,8 @@ program
949
1115
  console.error('Provide a function/entry point name or use --list to see all entry points.');
950
1116
  process.exit(1);
951
1117
  }
952
- if (opts.kind && !ALL_SYMBOL_KINDS.includes(opts.kind)) {
953
- 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(', ')}`);
954
1120
  process.exit(1);
955
1121
  }
956
1122
  const { flow } = await import('./flow.js');
@@ -967,6 +1133,70 @@ program
967
1133
  });
968
1134
  });
969
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
+
970
1200
  program
971
1201
  .command('complexity [target]')
972
1202
  .description('Show per-function complexity metrics (cognitive, cyclomatic, nesting depth, MI)')
@@ -987,8 +1217,8 @@ program
987
1217
  .option('--offset <number>', 'Skip N results (default: 0)')
988
1218
  .option('--ndjson', 'Newline-delimited JSON output')
989
1219
  .action(async (target, opts) => {
990
- if (opts.kind && !ALL_SYMBOL_KINDS.includes(opts.kind)) {
991
- 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(', ')}`);
992
1222
  process.exit(1);
993
1223
  }
994
1224
  const { complexity } = await import('./complexity.js');
@@ -1008,31 +1238,31 @@ program
1008
1238
  });
1009
1239
 
1010
1240
  program
1011
- .command('manifesto')
1012
- .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')
1013
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)')
1014
1246
  .option('-T, --no-tests', 'Exclude test/spec files from results')
1015
1247
  .option('--include-tests', 'Include test/spec files (overrides excludeTests config)')
1016
- .option('-f, --file <path>', 'Scope to file (partial match)')
1017
- .option('-k, --kind <kind>', 'Filter by symbol kind')
1018
1248
  .option('-j, --json', 'Output as JSON')
1249
+ .option('--ndjson', 'Newline-delimited JSON output')
1019
1250
  .option('--limit <number>', 'Max results to return')
1020
1251
  .option('--offset <number>', 'Skip N results (default: 0)')
1021
- .option('--ndjson', 'Newline-delimited JSON output')
1022
- .action(async (opts) => {
1023
- if (opts.kind && !ALL_SYMBOL_KINDS.includes(opts.kind)) {
1024
- console.error(`Invalid kind "${opts.kind}". Valid: ${ALL_SYMBOL_KINDS.join(', ')}`);
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(', ')}`);
1025
1256
  process.exit(1);
1026
1257
  }
1027
- const { manifesto } = await import('./manifesto.js');
1028
- manifesto(opts.db, {
1029
- file: opts.file,
1258
+ astQuery(pattern, opts.db, {
1030
1259
  kind: opts.kind,
1260
+ file: opts.file,
1031
1261
  noTests: resolveNoTests(opts),
1032
1262
  json: opts.json,
1263
+ ndjson: opts.ndjson,
1033
1264
  limit: opts.limit ? parseInt(opts.limit, 10) : undefined,
1034
1265
  offset: opts.offset ? parseInt(opts.offset, 10) : undefined,
1035
- ndjson: opts.ndjson,
1036
1266
  });
1037
1267
  });
1038
1268
 
@@ -1070,7 +1300,16 @@ program
1070
1300
  )
1071
1301
  .option('-d, --db <path>', 'Path to graph.db')
1072
1302
  .option('-n, --limit <number>', 'Max results to return', '20')
1073
- .option('--sort <metric>', 'Sort metric: risk | complexity | churn | fan-in | mi', 'risk')
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
+ )
1074
1313
  .option('--min-score <score>', 'Only show symbols with risk score >= threshold')
1075
1314
  .option('--role <role>', 'Filter by role (entry, core, utility, adapter, leaf, dead)')
1076
1315
  .option('-f, --file <path>', 'Scope to a specific file (partial match)')
@@ -1082,8 +1321,29 @@ program
1082
1321
  .option('--ndjson', 'Newline-delimited JSON output')
1083
1322
  .option('--weights <json>', 'Custom weights JSON (e.g. \'{"fanIn":1,"complexity":0}\')')
1084
1323
  .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(', ')}`);
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(', ')}`);
1087
1347
  process.exit(1);
1088
1348
  }
1089
1349
  if (opts.role && !VALID_ROLES.includes(opts.role)) {
@@ -1245,26 +1505,31 @@ program
1245
1505
  .option('-T, --no-tests', 'Exclude test/spec files from results')
1246
1506
  .option('--include-tests', 'Include test/spec files (overrides excludeTests config)')
1247
1507
  .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(', ')}`);
1508
+ if (opts.kind && !EVERY_SYMBOL_KIND.includes(opts.kind)) {
1509
+ console.error(`Invalid kind "${opts.kind}". Valid: ${EVERY_SYMBOL_KIND.join(', ')}`);
1250
1510
  process.exit(1);
1251
1511
  }
1252
1512
 
1253
1513
  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);
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);
1258
1527
  } else {
1259
- targets = raw.split(/\r?\n/).filter(Boolean);
1528
+ targets = splitTargets(positionalTargets);
1260
1529
  }
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;
1530
+ } catch (err) {
1531
+ console.error(`Failed to parse targets: ${err.message}`);
1532
+ process.exit(1);
1268
1533
  }
1269
1534
 
1270
1535
  if (!targets || targets.length === 0) {
@@ -1279,7 +1544,14 @@ program
1279
1544
  noTests: resolveNoTests(opts),
1280
1545
  };
1281
1546
 
1282
- batch(command, targets, opts.db, batchOpts);
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
+ }
1283
1555
  });
1284
1556
 
1285
1557
  program.parse();