@optave/codegraph 2.6.0 → 3.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/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,35 @@ 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('--no-ast', 'Skip AST node extraction (calls, new, string, regex, throw, await)')
109
+ .option('--no-complexity', 'Skip complexity metrics computation')
110
+ .option('--no-dataflow', 'Skip data flow edge extraction')
111
+ .option('--no-cfg', 'Skip control flow graph building')
100
112
  .action(async (dir, opts) => {
101
113
  const root = path.resolve(dir || '.');
102
114
  const engine = program.opts().engine;
103
- await buildGraph(root, { incremental: opts.incremental, engine });
115
+ await buildGraph(root, {
116
+ incremental: opts.incremental,
117
+ ast: opts.ast,
118
+ complexity: opts.complexity,
119
+ engine,
120
+ dataflow: opts.dataflow,
121
+ cfg: opts.cfg,
122
+ });
104
123
  });
105
124
 
106
125
  program
107
126
  .command('query <name>')
108
- .description('Find a function/class, show callers and callees')
127
+ .description('Function-level dependency chain or shortest path between symbols')
109
128
  .option('-d, --db <path>', 'Path to graph.db')
129
+ .option('--depth <n>', 'Transitive caller depth', '3')
130
+ .option('-f, --file <path>', 'Scope search to functions in this file (partial match)')
131
+ .option('-k, --kind <kind>', 'Filter to a specific symbol kind')
132
+ .option('--path <to>', 'Path mode: find shortest path to <to>')
133
+ .option('--kinds <kinds>', 'Path mode: comma-separated edge kinds to follow (default: calls)')
134
+ .option('--reverse', 'Path mode: follow edges backward')
135
+ .option('--from-file <path>', 'Path mode: disambiguate source symbol by file')
136
+ .option('--to-file <path>', 'Path mode: disambiguate target symbol by file')
110
137
  .option('-T, --no-tests', 'Exclude test/spec files from results')
111
138
  .option('--include-tests', 'Include test/spec files (overrides excludeTests config)')
112
139
  .option('-j, --json', 'Output as JSON')
@@ -114,12 +141,63 @@ program
114
141
  .option('--offset <number>', 'Skip N results (default: 0)')
115
142
  .option('--ndjson', 'Newline-delimited JSON output')
116
143
  .action((name, opts) => {
117
- queryName(name, opts.db, {
144
+ if (opts.kind && !EVERY_SYMBOL_KIND.includes(opts.kind)) {
145
+ console.error(`Invalid kind "${opts.kind}". Valid: ${EVERY_SYMBOL_KIND.join(', ')}`);
146
+ process.exit(1);
147
+ }
148
+ if (opts.path) {
149
+ console.error('Note: "query --path" is deprecated, use "codegraph path <from> <to>" instead');
150
+ symbolPath(name, opts.path, opts.db, {
151
+ maxDepth: opts.depth ? parseInt(opts.depth, 10) : 10,
152
+ edgeKinds: opts.kinds ? opts.kinds.split(',').map((s) => s.trim()) : undefined,
153
+ reverse: opts.reverse,
154
+ fromFile: opts.fromFile,
155
+ toFile: opts.toFile,
156
+ kind: opts.kind,
157
+ noTests: resolveNoTests(opts),
158
+ json: opts.json,
159
+ });
160
+ } else {
161
+ fnDeps(name, opts.db, {
162
+ depth: parseInt(opts.depth, 10),
163
+ file: opts.file,
164
+ kind: opts.kind,
165
+ noTests: resolveNoTests(opts),
166
+ json: opts.json,
167
+ limit: opts.limit ? parseInt(opts.limit, 10) : undefined,
168
+ offset: opts.offset ? parseInt(opts.offset, 10) : undefined,
169
+ ndjson: opts.ndjson,
170
+ });
171
+ }
172
+ });
173
+
174
+ program
175
+ .command('path <from> <to>')
176
+ .description('Find shortest path between two symbols')
177
+ .option('-d, --db <path>', 'Path to graph.db')
178
+ .option('--reverse', 'Follow edges backward')
179
+ .option('--kinds <kinds>', 'Comma-separated edge kinds to follow (default: calls)')
180
+ .option('--from-file <path>', 'Disambiguate source symbol by file')
181
+ .option('--to-file <path>', 'Disambiguate target symbol by file')
182
+ .option('--depth <n>', 'Max traversal depth', '10')
183
+ .option('-k, --kind <kind>', 'Filter to a specific symbol kind')
184
+ .option('-T, --no-tests', 'Exclude test/spec files from results')
185
+ .option('--include-tests', 'Include test/spec files (overrides excludeTests config)')
186
+ .option('-j, --json', 'Output as JSON')
187
+ .action((from, to, opts) => {
188
+ if (opts.kind && !EVERY_SYMBOL_KIND.includes(opts.kind)) {
189
+ console.error(`Invalid kind "${opts.kind}". Valid: ${EVERY_SYMBOL_KIND.join(', ')}`);
190
+ process.exit(1);
191
+ }
192
+ symbolPath(from, to, opts.db, {
193
+ maxDepth: opts.depth ? parseInt(opts.depth, 10) : 10,
194
+ edgeKinds: opts.kinds ? opts.kinds.split(',').map((s) => s.trim()) : undefined,
195
+ reverse: opts.reverse,
196
+ fromFile: opts.fromFile,
197
+ toFile: opts.toFile,
198
+ kind: opts.kind,
118
199
  noTests: resolveNoTests(opts),
119
200
  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
201
  });
124
202
  });
125
203
 
@@ -190,27 +268,17 @@ program
190
268
  });
191
269
 
192
270
  program
193
- .command('fn <name>')
194
- .description('Function-level dependencies: callers, callees, and transitive call chain')
271
+ .command('exports <file>')
272
+ .description('Show exported symbols with per-symbol consumers (who calls each export)')
195
273
  .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
274
  .option('-T, --no-tests', 'Exclude test/spec files from results')
200
275
  .option('--include-tests', 'Include test/spec files (overrides excludeTests config)')
201
276
  .option('-j, --json', 'Output as JSON')
202
277
  .option('--limit <number>', 'Max results to return')
203
278
  .option('--offset <number>', 'Skip N results (default: 0)')
204
279
  .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,
280
+ .action((file, opts) => {
281
+ fileExports(file, opts.db, {
214
282
  noTests: resolveNoTests(opts),
215
283
  json: opts.json,
216
284
  limit: opts.limit ? parseInt(opts.limit, 10) : undefined,
@@ -233,8 +301,8 @@ program
233
301
  .option('--offset <number>', 'Skip N results (default: 0)')
234
302
  .option('--ndjson', 'Newline-delimited JSON output')
235
303
  .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(', ')}`);
304
+ if (opts.kind && !EVERY_SYMBOL_KIND.includes(opts.kind)) {
305
+ console.error(`Invalid kind "${opts.kind}". Valid: ${EVERY_SYMBOL_KIND.join(', ')}`);
238
306
  process.exit(1);
239
307
  }
240
308
  fnImpact(name, opts.db, {
@@ -249,36 +317,6 @@ program
249
317
  });
250
318
  });
251
319
 
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
320
  program
283
321
  .command('context <name>')
284
322
  .description('Full context for a function: source, deps, callers, tests, signature')
@@ -295,8 +333,8 @@ program
295
333
  .option('--offset <number>', 'Skip N results (default: 0)')
296
334
  .option('--ndjson', 'Newline-delimited JSON output')
297
335
  .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(', ')}`);
336
+ if (opts.kind && !EVERY_SYMBOL_KIND.includes(opts.kind)) {
337
+ console.error(`Invalid kind "${opts.kind}". Valid: ${EVERY_SYMBOL_KIND.join(', ')}`);
300
338
  process.exit(1);
301
339
  }
302
340
  context(name, opts.db, {
@@ -314,24 +352,27 @@ program
314
352
  });
315
353
 
316
354
  program
317
- .command('explain <target>')
318
- .description('Structural summary of a file or function (no LLM needed)')
355
+ .command('children <name>')
356
+ .description('List parameters, properties, and constants of a symbol')
319
357
  .option('-d, --db <path>', 'Path to graph.db')
320
- .option('--depth <n>', 'Recursively explain dependencies up to N levels deep', '0')
358
+ .option('-f, --file <path>', 'Scope search to symbols in this file (partial match)')
359
+ .option('-k, --kind <kind>', 'Filter to a specific symbol kind')
321
360
  .option('-T, --no-tests', 'Exclude test/spec files from results')
322
- .option('--include-tests', 'Include test/spec files (overrides excludeTests config)')
323
361
  .option('-j, --json', 'Output as JSON')
324
362
  .option('--limit <number>', 'Max results to return')
325
363
  .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),
364
+ .action((name, opts) => {
365
+ if (opts.kind && !EVERY_SYMBOL_KIND.includes(opts.kind)) {
366
+ console.error(`Invalid kind "${opts.kind}". Valid: ${EVERY_SYMBOL_KIND.join(', ')}`);
367
+ process.exit(1);
368
+ }
369
+ children(name, opts.db, {
370
+ file: opts.file,
371
+ kind: opts.kind,
330
372
  noTests: resolveNoTests(opts),
331
373
  json: opts.json,
332
374
  limit: opts.limit ? parseInt(opts.limit, 10) : undefined,
333
375
  offset: opts.offset ? parseInt(opts.offset, 10) : undefined,
334
- ndjson: opts.ndjson,
335
376
  });
336
377
  });
337
378
 
@@ -339,17 +380,32 @@ program
339
380
  .command('audit <target>')
340
381
  .description('Composite report: explain + impact + health metrics per function')
341
382
  .option('-d, --db <path>', 'Path to graph.db')
342
- .option('--depth <n>', 'Impact analysis depth', '3')
383
+ .option('--quick', 'Structural summary only (skip impact analysis and health metrics)')
384
+ .option('--depth <n>', 'Impact/explain depth', '3')
343
385
  .option('-f, --file <path>', 'Scope to file (partial match)')
344
386
  .option('-k, --kind <kind>', 'Filter by symbol kind')
345
387
  .option('-T, --no-tests', 'Exclude test/spec files from results')
346
388
  .option('--include-tests', 'Include test/spec files (overrides excludeTests config)')
347
389
  .option('-j, --json', 'Output as JSON')
390
+ .option('--limit <number>', 'Max results to return (quick mode)')
391
+ .option('--offset <number>', 'Skip N results (quick mode)')
392
+ .option('--ndjson', 'Newline-delimited JSON output (quick mode)')
348
393
  .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(', ')}`);
394
+ if (opts.kind && !EVERY_SYMBOL_KIND.includes(opts.kind)) {
395
+ console.error(`Invalid kind "${opts.kind}". Valid: ${EVERY_SYMBOL_KIND.join(', ')}`);
351
396
  process.exit(1);
352
397
  }
398
+ if (opts.quick) {
399
+ explain(target, opts.db, {
400
+ depth: parseInt(opts.depth, 10),
401
+ noTests: resolveNoTests(opts),
402
+ json: opts.json,
403
+ limit: opts.limit ? parseInt(opts.limit, 10) : undefined,
404
+ offset: opts.offset ? parseInt(opts.offset, 10) : undefined,
405
+ ndjson: opts.ndjson,
406
+ });
407
+ return;
408
+ }
353
409
  audit(target, opts.db, {
354
410
  depth: parseInt(opts.depth, 10),
355
411
  file: opts.file,
@@ -415,18 +471,48 @@ program
415
471
 
416
472
  program
417
473
  .command('check [ref]')
418
- .description('Run validation predicates against git changes (CI gate)')
474
+ .description(
475
+ 'CI gate: run manifesto rules (no args), diff predicates (with ref/--staged), or both (--rules)',
476
+ )
419
477
  .option('-d, --db <path>', 'Path to graph.db')
420
478
  .option('--staged', 'Analyze staged changes')
479
+ .option('--rules', 'Also run manifesto rules alongside diff predicates')
421
480
  .option('--cycles', 'Assert no dependency cycles involve changed files')
422
481
  .option('--blast-radius <n>', 'Assert no function exceeds N transitive callers')
423
482
  .option('--signatures', 'Assert no function declaration lines were modified')
424
483
  .option('--boundaries', 'Assert no cross-owner boundary violations')
425
484
  .option('--depth <n>', 'Max BFS depth for blast radius (default: 3)')
485
+ .option('-f, --file <path>', 'Scope to file (partial match, manifesto mode)')
486
+ .option('-k, --kind <kind>', 'Filter by symbol kind (manifesto mode)')
426
487
  .option('-T, --no-tests', 'Exclude test/spec files from results')
427
488
  .option('--include-tests', 'Include test/spec files (overrides excludeTests config)')
428
489
  .option('-j, --json', 'Output as JSON')
490
+ .option('--limit <number>', 'Max results to return (manifesto mode)')
491
+ .option('--offset <number>', 'Skip N results (manifesto mode)')
492
+ .option('--ndjson', 'Newline-delimited JSON output (manifesto mode)')
429
493
  .action(async (ref, opts) => {
494
+ const isDiffMode = ref || opts.staged;
495
+
496
+ if (!isDiffMode && !opts.rules) {
497
+ // No ref, no --staged → run manifesto rules on whole codebase
498
+ if (opts.kind && !EVERY_SYMBOL_KIND.includes(opts.kind)) {
499
+ console.error(`Invalid kind "${opts.kind}". Valid: ${EVERY_SYMBOL_KIND.join(', ')}`);
500
+ process.exit(1);
501
+ }
502
+ const { manifesto } = await import('./manifesto.js');
503
+ manifesto(opts.db, {
504
+ file: opts.file,
505
+ kind: opts.kind,
506
+ noTests: resolveNoTests(opts),
507
+ json: opts.json,
508
+ limit: opts.limit ? parseInt(opts.limit, 10) : undefined,
509
+ offset: opts.offset ? parseInt(opts.offset, 10) : undefined,
510
+ ndjson: opts.ndjson,
511
+ });
512
+ return;
513
+ }
514
+
515
+ // Diff predicates mode
430
516
  const { check } = await import('./check.js');
431
517
  check(opts.db, {
432
518
  ref,
@@ -439,15 +525,37 @@ program
439
525
  noTests: resolveNoTests(opts),
440
526
  json: opts.json,
441
527
  });
528
+
529
+ // If --rules, also run manifesto after diff predicates
530
+ if (opts.rules) {
531
+ if (opts.kind && !EVERY_SYMBOL_KIND.includes(opts.kind)) {
532
+ console.error(`Invalid kind "${opts.kind}". Valid: ${EVERY_SYMBOL_KIND.join(', ')}`);
533
+ process.exit(1);
534
+ }
535
+ const { manifesto } = await import('./manifesto.js');
536
+ manifesto(opts.db, {
537
+ file: opts.file,
538
+ kind: opts.kind,
539
+ noTests: resolveNoTests(opts),
540
+ json: opts.json,
541
+ limit: opts.limit ? parseInt(opts.limit, 10) : undefined,
542
+ offset: opts.offset ? parseInt(opts.offset, 10) : undefined,
543
+ ndjson: opts.ndjson,
544
+ });
545
+ }
442
546
  });
443
547
 
444
548
  // ─── New commands ────────────────────────────────────────────────────────
445
549
 
446
550
  program
447
551
  .command('export')
448
- .description('Export dependency graph as DOT (Graphviz), Mermaid, or JSON')
552
+ .description('Export dependency graph as DOT, Mermaid, JSON, GraphML, GraphSON, or Neo4j CSV')
449
553
  .option('-d, --db <path>', 'Path to graph.db')
450
- .option('-f, --format <format>', 'Output format: dot, mermaid, json', 'dot')
554
+ .option(
555
+ '-f, --format <format>',
556
+ 'Output format: dot, mermaid, json, graphml, graphson, neo4j',
557
+ 'dot',
558
+ )
451
559
  .option('--functions', 'Function-level graph instead of file-level')
452
560
  .option('-T, --no-tests', 'Exclude test/spec files')
453
561
  .option('--include-tests', 'Include test/spec files (overrides excludeTests config)')
@@ -471,6 +579,25 @@ program
471
579
  case 'json':
472
580
  output = JSON.stringify(exportJSON(db, exportOpts), null, 2);
473
581
  break;
582
+ case 'graphml':
583
+ output = exportGraphML(db, exportOpts);
584
+ break;
585
+ case 'graphson':
586
+ output = JSON.stringify(exportGraphSON(db, exportOpts), null, 2);
587
+ break;
588
+ case 'neo4j': {
589
+ const csv = exportNeo4jCSV(db, exportOpts);
590
+ if (opts.output) {
591
+ const base = opts.output.replace(/\.[^.]+$/, '') || opts.output;
592
+ fs.writeFileSync(`${base}-nodes.csv`, csv.nodes, 'utf-8');
593
+ fs.writeFileSync(`${base}-relationships.csv`, csv.relationships, 'utf-8');
594
+ db.close();
595
+ console.log(`Exported to ${base}-nodes.csv and ${base}-relationships.csv`);
596
+ return;
597
+ }
598
+ output = `--- nodes.csv ---\n${csv.nodes}\n\n--- relationships.csv ---\n${csv.relationships}`;
599
+ break;
600
+ }
474
601
  default:
475
602
  output = exportDOT(db, exportOpts);
476
603
  break;
@@ -486,6 +613,81 @@ program
486
613
  }
487
614
  });
488
615
 
616
+ program
617
+ .command('plot')
618
+ .description('Generate an interactive HTML dependency graph viewer')
619
+ .option('-d, --db <path>', 'Path to graph.db')
620
+ .option('--functions', 'Function-level graph instead of file-level')
621
+ .option('-T, --no-tests', 'Exclude test/spec files')
622
+ .option('--include-tests', 'Include test/spec files (overrides excludeTests config)')
623
+ .option('--min-confidence <score>', 'Minimum edge confidence threshold (default: 0.5)', '0.5')
624
+ .option('-o, --output <file>', 'Write HTML to file')
625
+ .option('-c, --config <path>', 'Path to .plotDotCfg config file')
626
+ .option('--no-open', 'Do not open in browser')
627
+ .option('--cluster <mode>', 'Cluster nodes: none | community | directory')
628
+ .option('--overlay <list>', 'Comma-separated overlays: complexity,risk')
629
+ .option('--seed <strategy>', 'Seed strategy: all | top-fanin | entry')
630
+ .option('--seed-count <n>', 'Number of seed nodes (default: 30)')
631
+ .option('--size-by <metric>', 'Size nodes by: uniform | fan-in | fan-out | complexity')
632
+ .option('--color-by <mode>', 'Color nodes by: kind | role | community | complexity')
633
+ .action(async (opts) => {
634
+ const { generatePlotHTML, loadPlotConfig } = await import('./viewer.js');
635
+ const os = await import('node:os');
636
+ const db = openReadonlyOrFail(opts.db);
637
+
638
+ let plotCfg;
639
+ if (opts.config) {
640
+ try {
641
+ plotCfg = JSON.parse(fs.readFileSync(opts.config, 'utf-8'));
642
+ } catch (e) {
643
+ console.error(`Failed to load config: ${e.message}`);
644
+ db.close();
645
+ process.exitCode = 1;
646
+ return;
647
+ }
648
+ } else {
649
+ plotCfg = loadPlotConfig(process.cwd());
650
+ }
651
+
652
+ // Merge CLI flags into config
653
+ if (opts.cluster) plotCfg.clusterBy = opts.cluster;
654
+ if (opts.colorBy) plotCfg.colorBy = opts.colorBy;
655
+ if (opts.sizeBy) plotCfg.sizeBy = opts.sizeBy;
656
+ if (opts.seed) plotCfg.seedStrategy = opts.seed;
657
+ if (opts.seedCount) plotCfg.seedCount = parseInt(opts.seedCount, 10);
658
+ if (opts.overlay) {
659
+ const parts = opts.overlay.split(',').map((s) => s.trim());
660
+ if (!plotCfg.overlays) plotCfg.overlays = {};
661
+ if (parts.includes('complexity')) plotCfg.overlays.complexity = true;
662
+ if (parts.includes('risk')) plotCfg.overlays.risk = true;
663
+ }
664
+
665
+ const html = generatePlotHTML(db, {
666
+ fileLevel: !opts.functions,
667
+ noTests: resolveNoTests(opts),
668
+ minConfidence: parseFloat(opts.minConfidence),
669
+ config: plotCfg,
670
+ });
671
+ db.close();
672
+
673
+ const outPath = opts.output || path.join(os.tmpdir(), `codegraph-plot-${Date.now()}.html`);
674
+ fs.writeFileSync(outPath, html, 'utf-8');
675
+ console.log(`Plot written to ${outPath}`);
676
+
677
+ if (opts.open !== false) {
678
+ const { execFile } = await import('node:child_process');
679
+ const args =
680
+ process.platform === 'win32'
681
+ ? ['cmd', ['/c', 'start', '', outPath]]
682
+ : process.platform === 'darwin'
683
+ ? ['open', [outPath]]
684
+ : ['xdg-open', [outPath]];
685
+ execFile(args[0], args[1], (err) => {
686
+ if (err) console.error('Could not open browser:', err.message);
687
+ });
688
+ }
689
+ });
690
+
489
691
  program
490
692
  .command('cycles')
491
693
  .description('Detect circular dependencies in the codebase')
@@ -799,38 +1001,6 @@ program
799
1001
  }
800
1002
  });
801
1003
 
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
1004
  program
835
1005
  .command('roles')
836
1006
  .description('Show node role classification: entry, core, utility, adapter, dead, leaf')
@@ -949,8 +1119,8 @@ program
949
1119
  console.error('Provide a function/entry point name or use --list to see all entry points.');
950
1120
  process.exit(1);
951
1121
  }
952
- if (opts.kind && !ALL_SYMBOL_KINDS.includes(opts.kind)) {
953
- console.error(`Invalid kind "${opts.kind}". Valid: ${ALL_SYMBOL_KINDS.join(', ')}`);
1122
+ if (opts.kind && !EVERY_SYMBOL_KIND.includes(opts.kind)) {
1123
+ console.error(`Invalid kind "${opts.kind}". Valid: ${EVERY_SYMBOL_KIND.join(', ')}`);
954
1124
  process.exit(1);
955
1125
  }
956
1126
  const { flow } = await import('./flow.js');
@@ -967,6 +1137,70 @@ program
967
1137
  });
968
1138
  });
969
1139
 
1140
+ program
1141
+ .command('dataflow <name>')
1142
+ .description('Show data flow for a function: parameters, return consumers, mutations')
1143
+ .option('-d, --db <path>', 'Path to graph.db')
1144
+ .option('-f, --file <path>', 'Scope to file (partial match)')
1145
+ .option('-k, --kind <kind>', 'Filter by symbol kind')
1146
+ .option('-T, --no-tests', 'Exclude test/spec files from results')
1147
+ .option('--include-tests', 'Include test/spec files (overrides excludeTests config)')
1148
+ .option('-j, --json', 'Output as JSON')
1149
+ .option('--ndjson', 'Newline-delimited JSON output')
1150
+ .option('--limit <number>', 'Max results to return')
1151
+ .option('--offset <number>', 'Skip N results (default: 0)')
1152
+ .option('--impact', 'Show data-dependent blast radius')
1153
+ .option('--depth <n>', 'Max traversal depth', '5')
1154
+ .action(async (name, opts) => {
1155
+ if (opts.kind && !EVERY_SYMBOL_KIND.includes(opts.kind)) {
1156
+ console.error(`Invalid kind "${opts.kind}". Valid: ${EVERY_SYMBOL_KIND.join(', ')}`);
1157
+ process.exit(1);
1158
+ }
1159
+ const { dataflow } = await import('./dataflow.js');
1160
+ dataflow(name, opts.db, {
1161
+ file: opts.file,
1162
+ kind: opts.kind,
1163
+ noTests: resolveNoTests(opts),
1164
+ json: opts.json,
1165
+ ndjson: opts.ndjson,
1166
+ limit: opts.limit ? parseInt(opts.limit, 10) : undefined,
1167
+ offset: opts.offset ? parseInt(opts.offset, 10) : undefined,
1168
+ impact: opts.impact,
1169
+ depth: opts.depth,
1170
+ });
1171
+ });
1172
+
1173
+ program
1174
+ .command('cfg <name>')
1175
+ .description('Show control flow graph for a function')
1176
+ .option('-d, --db <path>', 'Path to graph.db')
1177
+ .option('--format <fmt>', 'Output format: text, dot, mermaid', 'text')
1178
+ .option('-f, --file <path>', 'Scope to file (partial match)')
1179
+ .option('-k, --kind <kind>', 'Filter by symbol kind')
1180
+ .option('-T, --no-tests', 'Exclude test/spec files from results')
1181
+ .option('--include-tests', 'Include test/spec files (overrides excludeTests config)')
1182
+ .option('-j, --json', 'Output as JSON')
1183
+ .option('--ndjson', 'Newline-delimited JSON output')
1184
+ .option('--limit <number>', 'Max results to return')
1185
+ .option('--offset <number>', 'Skip N results (default: 0)')
1186
+ .action(async (name, opts) => {
1187
+ if (opts.kind && !EVERY_SYMBOL_KIND.includes(opts.kind)) {
1188
+ console.error(`Invalid kind "${opts.kind}". Valid: ${EVERY_SYMBOL_KIND.join(', ')}`);
1189
+ process.exit(1);
1190
+ }
1191
+ const { cfg } = await import('./cfg.js');
1192
+ cfg(name, opts.db, {
1193
+ format: opts.format,
1194
+ file: opts.file,
1195
+ kind: opts.kind,
1196
+ noTests: resolveNoTests(opts),
1197
+ json: opts.json,
1198
+ ndjson: opts.ndjson,
1199
+ limit: opts.limit ? parseInt(opts.limit, 10) : undefined,
1200
+ offset: opts.offset ? parseInt(opts.offset, 10) : undefined,
1201
+ });
1202
+ });
1203
+
970
1204
  program
971
1205
  .command('complexity [target]')
972
1206
  .description('Show per-function complexity metrics (cognitive, cyclomatic, nesting depth, MI)')
@@ -987,8 +1221,8 @@ program
987
1221
  .option('--offset <number>', 'Skip N results (default: 0)')
988
1222
  .option('--ndjson', 'Newline-delimited JSON output')
989
1223
  .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(', ')}`);
1224
+ if (opts.kind && !EVERY_SYMBOL_KIND.includes(opts.kind)) {
1225
+ console.error(`Invalid kind "${opts.kind}". Valid: ${EVERY_SYMBOL_KIND.join(', ')}`);
992
1226
  process.exit(1);
993
1227
  }
994
1228
  const { complexity } = await import('./complexity.js');
@@ -1008,31 +1242,31 @@ program
1008
1242
  });
1009
1243
 
1010
1244
  program
1011
- .command('manifesto')
1012
- .description('Evaluate manifesto rules (pass/fail verdicts for code health)')
1245
+ .command('ast [pattern]')
1246
+ .description('Search stored AST nodes (calls, new, string, regex, throw, await) by pattern')
1013
1247
  .option('-d, --db <path>', 'Path to graph.db')
1248
+ .option('-k, --kind <kind>', 'Filter by AST node kind (call, new, string, regex, throw, await)')
1249
+ .option('-f, --file <path>', 'Scope to file (partial match)')
1014
1250
  .option('-T, --no-tests', 'Exclude test/spec files from results')
1015
1251
  .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
1252
  .option('-j, --json', 'Output as JSON')
1253
+ .option('--ndjson', 'Newline-delimited JSON output')
1019
1254
  .option('--limit <number>', 'Max results to return')
1020
1255
  .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(', ')}`);
1256
+ .action(async (pattern, opts) => {
1257
+ const { AST_NODE_KINDS, astQuery } = await import('./ast.js');
1258
+ if (opts.kind && !AST_NODE_KINDS.includes(opts.kind)) {
1259
+ console.error(`Invalid AST kind "${opts.kind}". Valid: ${AST_NODE_KINDS.join(', ')}`);
1025
1260
  process.exit(1);
1026
1261
  }
1027
- const { manifesto } = await import('./manifesto.js');
1028
- manifesto(opts.db, {
1029
- file: opts.file,
1262
+ astQuery(pattern, opts.db, {
1030
1263
  kind: opts.kind,
1264
+ file: opts.file,
1031
1265
  noTests: resolveNoTests(opts),
1032
1266
  json: opts.json,
1267
+ ndjson: opts.ndjson,
1033
1268
  limit: opts.limit ? parseInt(opts.limit, 10) : undefined,
1034
1269
  offset: opts.offset ? parseInt(opts.offset, 10) : undefined,
1035
- ndjson: opts.ndjson,
1036
1270
  });
1037
1271
  });
1038
1272
 
@@ -1070,7 +1304,16 @@ program
1070
1304
  )
1071
1305
  .option('-d, --db <path>', 'Path to graph.db')
1072
1306
  .option('-n, --limit <number>', 'Max results to return', '20')
1073
- .option('--sort <metric>', 'Sort metric: risk | complexity | churn | fan-in | mi', 'risk')
1307
+ .option(
1308
+ '--level <level>',
1309
+ 'Granularity: function (default) | file | directory. File/directory level shows hotspots',
1310
+ 'function',
1311
+ )
1312
+ .option(
1313
+ '--sort <metric>',
1314
+ 'Sort metric: risk | complexity | churn | fan-in | mi (function level); fan-in | fan-out | density | coupling (file/directory level)',
1315
+ 'risk',
1316
+ )
1074
1317
  .option('--min-score <score>', 'Only show symbols with risk score >= threshold')
1075
1318
  .option('--role <role>', 'Filter by role (entry, core, utility, adapter, leaf, dead)')
1076
1319
  .option('-f, --file <path>', 'Scope to a specific file (partial match)')
@@ -1082,8 +1325,29 @@ program
1082
1325
  .option('--ndjson', 'Newline-delimited JSON output')
1083
1326
  .option('--weights <json>', 'Custom weights JSON (e.g. \'{"fanIn":1,"complexity":0}\')')
1084
1327
  .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(', ')}`);
1328
+ if (opts.level === 'file' || opts.level === 'directory') {
1329
+ // Delegate to hotspots for file/directory level
1330
+ const { hotspotsData, formatHotspots } = await import('./structure.js');
1331
+ const metric = opts.sort === 'risk' ? 'fan-in' : opts.sort;
1332
+ const data = hotspotsData(opts.db, {
1333
+ metric,
1334
+ level: opts.level,
1335
+ limit: parseInt(opts.limit, 10),
1336
+ offset: opts.offset ? parseInt(opts.offset, 10) : undefined,
1337
+ noTests: resolveNoTests(opts),
1338
+ });
1339
+ if (opts.ndjson) {
1340
+ printNdjson(data, 'hotspots');
1341
+ } else if (opts.json) {
1342
+ console.log(JSON.stringify(data, null, 2));
1343
+ } else {
1344
+ console.log(formatHotspots(data));
1345
+ }
1346
+ return;
1347
+ }
1348
+
1349
+ if (opts.kind && !EVERY_SYMBOL_KIND.includes(opts.kind)) {
1350
+ console.error(`Invalid kind "${opts.kind}". Valid: ${EVERY_SYMBOL_KIND.join(', ')}`);
1087
1351
  process.exit(1);
1088
1352
  }
1089
1353
  if (opts.role && !VALID_ROLES.includes(opts.role)) {
@@ -1245,26 +1509,31 @@ program
1245
1509
  .option('-T, --no-tests', 'Exclude test/spec files from results')
1246
1510
  .option('--include-tests', 'Include test/spec files (overrides excludeTests config)')
1247
1511
  .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(', ')}`);
1512
+ if (opts.kind && !EVERY_SYMBOL_KIND.includes(opts.kind)) {
1513
+ console.error(`Invalid kind "${opts.kind}". Valid: ${EVERY_SYMBOL_KIND.join(', ')}`);
1250
1514
  process.exit(1);
1251
1515
  }
1252
1516
 
1253
1517
  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);
1518
+ try {
1519
+ if (opts.fromFile) {
1520
+ const raw = fs.readFileSync(opts.fromFile, 'utf-8').trim();
1521
+ if (raw.startsWith('[')) {
1522
+ targets = JSON.parse(raw);
1523
+ } else {
1524
+ targets = raw.split(/\r?\n/).filter(Boolean);
1525
+ }
1526
+ } else if (opts.stdin) {
1527
+ const chunks = [];
1528
+ for await (const chunk of process.stdin) chunks.push(chunk);
1529
+ const raw = Buffer.concat(chunks).toString('utf-8').trim();
1530
+ targets = raw.startsWith('[') ? JSON.parse(raw) : raw.split(/\r?\n/).filter(Boolean);
1258
1531
  } else {
1259
- targets = raw.split(/\r?\n/).filter(Boolean);
1532
+ targets = splitTargets(positionalTargets);
1260
1533
  }
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;
1534
+ } catch (err) {
1535
+ console.error(`Failed to parse targets: ${err.message}`);
1536
+ process.exit(1);
1268
1537
  }
1269
1538
 
1270
1539
  if (!targets || targets.length === 0) {
@@ -1279,7 +1548,14 @@ program
1279
1548
  noTests: resolveNoTests(opts),
1280
1549
  };
1281
1550
 
1282
- batch(command, targets, opts.db, batchOpts);
1551
+ // Multi-command mode: items from --from-file / --stdin may be objects with { command, target }
1552
+ const isMulti = targets.length > 0 && typeof targets[0] === 'object' && targets[0].command;
1553
+ if (isMulti) {
1554
+ const data = multiBatchData(targets, opts.db, batchOpts);
1555
+ console.log(JSON.stringify(data, null, 2));
1556
+ } else {
1557
+ batch(command, targets, opts.db, batchOpts);
1558
+ }
1283
1559
  });
1284
1560
 
1285
1561
  program.parse();