@optave/codegraph 2.4.0 → 2.5.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
@@ -29,6 +29,7 @@ import {
29
29
  queryName,
30
30
  roles,
31
31
  stats,
32
+ symbolPath,
32
33
  VALID_ROLES,
33
34
  where,
34
35
  } from './queries.js';
@@ -99,8 +100,17 @@ program
99
100
  .option('-T, --no-tests', 'Exclude test/spec files from results')
100
101
  .option('--include-tests', 'Include test/spec files (overrides excludeTests config)')
101
102
  .option('-j, --json', 'Output as JSON')
103
+ .option('--limit <number>', 'Max results to return')
104
+ .option('--offset <number>', 'Skip N results (default: 0)')
105
+ .option('--ndjson', 'Newline-delimited JSON output')
102
106
  .action((name, opts) => {
103
- queryName(name, opts.db, { noTests: resolveNoTests(opts), json: opts.json });
107
+ queryName(name, opts.db, {
108
+ noTests: resolveNoTests(opts),
109
+ 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
+ });
104
114
  });
105
115
 
106
116
  program
@@ -136,8 +146,8 @@ program
136
146
  .option('-T, --no-tests', 'Exclude test/spec files from results')
137
147
  .option('--include-tests', 'Include test/spec files (overrides excludeTests config)')
138
148
  .option('-j, --json', 'Output as JSON')
139
- .action((opts) => {
140
- stats(opts.db, { noTests: resolveNoTests(opts), json: opts.json });
149
+ .action(async (opts) => {
150
+ await stats(opts.db, { noTests: resolveNoTests(opts), json: opts.json });
141
151
  });
142
152
 
143
153
  program
@@ -199,6 +209,36 @@ program
199
209
  });
200
210
  });
201
211
 
212
+ program
213
+ .command('path <from> <to>')
214
+ .description('Find shortest path between two symbols (A calls...calls B)')
215
+ .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')
222
+ .option('-T, --no-tests', 'Exclude test/spec files from results')
223
+ .option('--include-tests', 'Include test/spec files (overrides excludeTests config)')
224
+ .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(', ')}`);
228
+ process.exit(1);
229
+ }
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,
236
+ kind: opts.kind,
237
+ noTests: resolveNoTests(opts),
238
+ json: opts.json,
239
+ });
240
+ });
241
+
202
242
  program
203
243
  .command('context <name>')
204
244
  .description('Full context for a function: source, deps, callers, tests, signature')
@@ -251,13 +291,23 @@ program
251
291
  .option('-T, --no-tests', 'Exclude test/spec files from results')
252
292
  .option('--include-tests', 'Include test/spec files (overrides excludeTests config)')
253
293
  .option('-j, --json', 'Output as JSON')
294
+ .option('--limit <number>', 'Max results to return')
295
+ .option('--offset <number>', 'Skip N results (default: 0)')
296
+ .option('--ndjson', 'Newline-delimited JSON output')
254
297
  .action((name, opts) => {
255
298
  if (!name && !opts.file) {
256
299
  console.error('Provide a symbol name or use --file <path>');
257
300
  process.exit(1);
258
301
  }
259
302
  const target = opts.file || name;
260
- where(target, opts.db, { file: !!opts.file, noTests: resolveNoTests(opts), json: opts.json });
303
+ where(target, opts.db, {
304
+ file: !!opts.file,
305
+ noTests: resolveNoTests(opts),
306
+ json: opts.json,
307
+ limit: opts.limit ? parseInt(opts.limit, 10) : undefined,
308
+ offset: opts.offset ? parseInt(opts.offset, 10) : undefined,
309
+ ndjson: opts.ndjson,
310
+ });
261
311
  });
262
312
 
263
313
  program
@@ -418,6 +468,7 @@ registry
418
468
  .description('Remove stale registry entries (missing directories or idle beyond TTL)')
419
469
  .option('--ttl <days>', 'Days of inactivity before pruning (default: 30)', '30')
420
470
  .option('--exclude <names>', 'Comma-separated repo names to preserve from pruning')
471
+ .option('--dry-run', 'Show what would be pruned without removing anything')
421
472
  .action((opts) => {
422
473
  const excludeNames = opts.exclude
423
474
  ? opts.exclude
@@ -425,15 +476,25 @@ registry
425
476
  .map((s) => s.trim())
426
477
  .filter((s) => s.length > 0)
427
478
  : [];
428
- const pruned = pruneRegistry(undefined, parseInt(opts.ttl, 10), excludeNames);
479
+ const dryRun = !!opts.dryRun;
480
+ const pruned = pruneRegistry(undefined, parseInt(opts.ttl, 10), excludeNames, dryRun);
429
481
  if (pruned.length === 0) {
430
482
  console.log('No stale entries found.');
431
483
  } else {
484
+ const prefix = dryRun ? 'Would prune' : 'Pruned';
432
485
  for (const entry of pruned) {
433
486
  const tag = entry.reason === 'expired' ? 'expired' : 'missing';
434
- console.log(`Pruned "${entry.name}" (${entry.path}) [${tag}]`);
487
+ console.log(`${prefix} "${entry.name}" (${entry.path}) [${tag}]`);
488
+ }
489
+ if (dryRun) {
490
+ console.log(
491
+ `\nDry run: ${pruned.length} ${pruned.length === 1 ? 'entry' : 'entries'} would be removed.`,
492
+ );
493
+ } else {
494
+ console.log(
495
+ `\nRemoved ${pruned.length} stale ${pruned.length === 1 ? 'entry' : 'entries'}.`,
496
+ );
435
497
  }
436
- console.log(`\nRemoved ${pruned.length} stale ${pruned.length === 1 ? 'entry' : 'entries'}.`);
437
498
  }
438
499
  });
439
500
 
@@ -470,6 +531,7 @@ program
470
531
  `Embedding strategy: ${EMBEDDING_STRATEGIES.join(', ')}. "structured" uses graph context (callers/callees), "source" embeds raw code`,
471
532
  'structured',
472
533
  )
534
+ .option('-d, --db <path>', 'Path to graph.db')
473
535
  .action(async (dir, opts) => {
474
536
  if (!EMBEDDING_STRATEGIES.includes(opts.strategy)) {
475
537
  console.error(
@@ -479,7 +541,7 @@ program
479
541
  }
480
542
  const root = path.resolve(dir || '.');
481
543
  const model = opts.model || config.embeddings?.model || DEFAULT_MODEL;
482
- await buildEmbeddings(root, model, undefined, { strategy: opts.strategy });
544
+ await buildEmbeddings(root, model, opts.db, { strategy: opts.strategy });
483
545
  });
484
546
 
485
547
  program
@@ -516,6 +578,7 @@ program
516
578
  .option('-d, --db <path>', 'Path to graph.db')
517
579
  .option('--depth <n>', 'Max directory depth')
518
580
  .option('--sort <metric>', 'Sort by: cohesion | fan-in | fan-out | density | files', 'files')
581
+ .option('--full', 'Show all files without limit')
519
582
  .option('-T, --no-tests', 'Exclude test/spec files')
520
583
  .option('--include-tests', 'Include test/spec files (overrides excludeTests config)')
521
584
  .option('-j, --json', 'Output as JSON')
@@ -525,6 +588,7 @@ program
525
588
  directory: dir,
526
589
  depth: opts.depth ? parseInt(opts.depth, 10) : undefined,
527
590
  sort: opts.sort,
591
+ full: opts.full,
528
592
  noTests: resolveNoTests(opts),
529
593
  });
530
594
  if (opts.json) {
@@ -570,6 +634,9 @@ program
570
634
  .option('-T, --no-tests', 'Exclude test/spec files')
571
635
  .option('--include-tests', 'Include test/spec files (overrides excludeTests config)')
572
636
  .option('-j, --json', 'Output as JSON')
637
+ .option('--limit <number>', 'Max results to return')
638
+ .option('--offset <number>', 'Skip N results (default: 0)')
639
+ .option('--ndjson', 'Newline-delimited JSON output')
573
640
  .action((opts) => {
574
641
  if (opts.role && !VALID_ROLES.includes(opts.role)) {
575
642
  console.error(`Invalid role "${opts.role}". Valid roles: ${VALID_ROLES.join(', ')}`);
@@ -580,6 +647,9 @@ program
580
647
  file: opts.file,
581
648
  noTests: resolveNoTests(opts),
582
649
  json: opts.json,
650
+ limit: opts.limit ? parseInt(opts.limit, 10) : undefined,
651
+ offset: opts.offset ? parseInt(opts.offset, 10) : undefined,
652
+ ndjson: opts.ndjson,
583
653
  });
584
654
  });
585
655
 
@@ -645,6 +715,144 @@ program
645
715
  }
646
716
  });
647
717
 
718
+ program
719
+ .command('flow [name]')
720
+ .description(
721
+ 'Trace execution flow forward from an entry point (route, command, event) through callees to leaves',
722
+ )
723
+ .option('--list', 'List all entry points grouped by type')
724
+ .option('--depth <n>', 'Max forward traversal depth', '10')
725
+ .option('-d, --db <path>', 'Path to graph.db')
726
+ .option('-f, --file <path>', 'Scope to a specific file (partial match)')
727
+ .option('-k, --kind <kind>', 'Filter by symbol kind')
728
+ .option('-T, --no-tests', 'Exclude test/spec files from results')
729
+ .option('--include-tests', 'Include test/spec files (overrides excludeTests config)')
730
+ .option('-j, --json', 'Output as JSON')
731
+ .option('--limit <number>', 'Max results to return')
732
+ .option('--offset <number>', 'Skip N results (default: 0)')
733
+ .option('--ndjson', 'Newline-delimited JSON output')
734
+ .action(async (name, opts) => {
735
+ if (!name && !opts.list) {
736
+ console.error('Provide a function/entry point name or use --list to see all entry points.');
737
+ process.exit(1);
738
+ }
739
+ if (opts.kind && !ALL_SYMBOL_KINDS.includes(opts.kind)) {
740
+ console.error(`Invalid kind "${opts.kind}". Valid: ${ALL_SYMBOL_KINDS.join(', ')}`);
741
+ process.exit(1);
742
+ }
743
+ const { flow } = await import('./flow.js');
744
+ flow(name, opts.db, {
745
+ list: opts.list,
746
+ depth: parseInt(opts.depth, 10),
747
+ file: opts.file,
748
+ kind: opts.kind,
749
+ noTests: resolveNoTests(opts),
750
+ json: opts.json,
751
+ limit: opts.limit ? parseInt(opts.limit, 10) : undefined,
752
+ offset: opts.offset ? parseInt(opts.offset, 10) : undefined,
753
+ ndjson: opts.ndjson,
754
+ });
755
+ });
756
+
757
+ program
758
+ .command('complexity [target]')
759
+ .description('Show per-function complexity metrics (cognitive, cyclomatic, nesting depth, MI)')
760
+ .option('-d, --db <path>', 'Path to graph.db')
761
+ .option('-n, --limit <number>', 'Max results', '20')
762
+ .option(
763
+ '--sort <metric>',
764
+ 'Sort by: cognitive | cyclomatic | nesting | mi | volume | effort | bugs | loc',
765
+ 'cognitive',
766
+ )
767
+ .option('--above-threshold', 'Only functions exceeding warn thresholds')
768
+ .option('--health', 'Show health metrics (Halstead, MI) columns')
769
+ .option('-f, --file <path>', 'Scope to file (partial match)')
770
+ .option('-k, --kind <kind>', 'Filter by symbol kind')
771
+ .option('-T, --no-tests', 'Exclude test/spec files from results')
772
+ .option('--include-tests', 'Include test/spec files (overrides excludeTests config)')
773
+ .option('-j, --json', 'Output as JSON')
774
+ .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(', ')}`);
777
+ process.exit(1);
778
+ }
779
+ const { complexity } = await import('./complexity.js');
780
+ complexity(opts.db, {
781
+ target,
782
+ limit: parseInt(opts.limit, 10),
783
+ sort: opts.sort,
784
+ aboveThreshold: opts.aboveThreshold,
785
+ health: opts.health,
786
+ file: opts.file,
787
+ kind: opts.kind,
788
+ noTests: resolveNoTests(opts),
789
+ json: opts.json,
790
+ });
791
+ });
792
+
793
+ program
794
+ .command('manifesto')
795
+ .description('Evaluate manifesto rules (pass/fail verdicts for code health)')
796
+ .option('-d, --db <path>', 'Path to graph.db')
797
+ .option('-T, --no-tests', 'Exclude test/spec files from results')
798
+ .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
+ .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(', ')}`);
805
+ process.exit(1);
806
+ }
807
+ const { manifesto } = await import('./manifesto.js');
808
+ manifesto(opts.db, {
809
+ file: opts.file,
810
+ kind: opts.kind,
811
+ noTests: resolveNoTests(opts),
812
+ json: opts.json,
813
+ });
814
+ });
815
+
816
+ program
817
+ .command('communities')
818
+ .description('Detect natural module boundaries using Louvain community detection')
819
+ .option('--functions', 'Function-level instead of file-level')
820
+ .option('--resolution <n>', 'Louvain resolution parameter (default 1.0)', '1.0')
821
+ .option('--drift', 'Show only drift analysis')
822
+ .option('-d, --db <path>', 'Path to graph.db')
823
+ .option('-T, --no-tests', 'Exclude test/spec files from results')
824
+ .option('--include-tests', 'Include test/spec files (overrides excludeTests config)')
825
+ .option('-j, --json', 'Output as JSON')
826
+ .action(async (opts) => {
827
+ const { communities } = await import('./communities.js');
828
+ communities(opts.db, {
829
+ functions: opts.functions,
830
+ resolution: parseFloat(opts.resolution),
831
+ drift: opts.drift,
832
+ noTests: resolveNoTests(opts),
833
+ json: opts.json,
834
+ });
835
+ });
836
+
837
+ program
838
+ .command('branch-compare <base> <target>')
839
+ .description('Compare code structure between two branches/refs')
840
+ .option('--depth <n>', 'Max transitive caller depth', '3')
841
+ .option('-T, --no-tests', 'Exclude test/spec files')
842
+ .option('--include-tests', 'Include test/spec files (overrides excludeTests config)')
843
+ .option('-j, --json', 'Output as JSON')
844
+ .option('-f, --format <format>', 'Output format: text, mermaid, json', 'text')
845
+ .action(async (base, target, opts) => {
846
+ const { branchCompare } = await import('./branch-compare.js');
847
+ await branchCompare(base, target, {
848
+ engine: program.opts().engine,
849
+ depth: parseInt(opts.depth, 10),
850
+ noTests: resolveNoTests(opts),
851
+ json: opts.json,
852
+ format: opts.format,
853
+ });
854
+ });
855
+
648
856
  program
649
857
  .command('watch [dir]')
650
858
  .description('Watch project for file changes and incrementally update the graph')
@@ -680,6 +888,43 @@ program
680
888
  console.log(` Engine flag : --engine ${engine}`);
681
889
  console.log(` Active engine : ${activeName}${activeVersion ? ` (v${activeVersion})` : ''}`);
682
890
  console.log();
891
+
892
+ // Build metadata from DB
893
+ try {
894
+ const { findDbPath, getBuildMeta } = await import('./db.js');
895
+ const Database = (await import('better-sqlite3')).default;
896
+ const dbPath = findDbPath();
897
+ const fs = await import('node:fs');
898
+ if (fs.existsSync(dbPath)) {
899
+ const db = new Database(dbPath, { readonly: true });
900
+ const buildEngine = getBuildMeta(db, 'engine');
901
+ const buildVersion = getBuildMeta(db, 'codegraph_version');
902
+ const builtAt = getBuildMeta(db, 'built_at');
903
+ db.close();
904
+
905
+ if (buildEngine || buildVersion || builtAt) {
906
+ console.log('Build metadata');
907
+ console.log('──────────────');
908
+ if (buildEngine) console.log(` Engine : ${buildEngine}`);
909
+ if (buildVersion) console.log(` Version : ${buildVersion}`);
910
+ if (builtAt) console.log(` Built at : ${builtAt}`);
911
+
912
+ if (buildVersion && buildVersion !== program.version()) {
913
+ console.log(
914
+ ` ⚠ DB was built with v${buildVersion}, current is v${program.version()}. Consider: codegraph build --no-incremental`,
915
+ );
916
+ }
917
+ if (buildEngine && buildEngine !== activeName) {
918
+ console.log(
919
+ ` ⚠ DB was built with ${buildEngine} engine, active is ${activeName}. Consider: codegraph build --no-incremental`,
920
+ );
921
+ }
922
+ console.log();
923
+ }
924
+ }
925
+ } catch {
926
+ /* diagnostics must never crash */
927
+ }
683
928
  });
684
929
 
685
930
  program.parse();
package/src/cochange.js CHANGED
@@ -9,7 +9,7 @@ import { execFileSync } from 'node:child_process';
9
9
  import fs from 'node:fs';
10
10
  import path from 'node:path';
11
11
  import { normalizePath } from './constants.js';
12
- import { findDbPath, initSchema, openDb, openReadonlyOrFail } from './db.js';
12
+ import { closeDb, findDbPath, initSchema, openDb, openReadonlyOrFail } from './db.js';
13
13
  import { warn } from './logger.js';
14
14
  import { isTestFile } from './queries.js';
15
15
 
@@ -145,7 +145,7 @@ export function analyzeCoChanges(customDbPath, opts = {}) {
145
145
  const repoRoot = path.resolve(path.dirname(dbPath), '..');
146
146
 
147
147
  if (!fs.existsSync(path.join(repoRoot, '.git'))) {
148
- db.close();
148
+ closeDb(db);
149
149
  return { error: `Not a git repository: ${repoRoot}` };
150
150
  }
151
151
 
@@ -245,7 +245,7 @@ export function analyzeCoChanges(customDbPath, opts = {}) {
245
245
 
246
246
  const totalPairs = db.prepare('SELECT COUNT(*) as cnt FROM co_changes').get().cnt;
247
247
 
248
- db.close();
248
+ closeDb(db);
249
249
 
250
250
  return {
251
251
  pairsFound: totalPairs,
@@ -275,14 +275,14 @@ export function coChangeData(file, customDbPath, opts = {}) {
275
275
  try {
276
276
  db.prepare('SELECT 1 FROM co_changes LIMIT 1').get();
277
277
  } catch {
278
- db.close();
278
+ closeDb(db);
279
279
  return { error: 'No co-change data found. Run `codegraph co-change --analyze` first.' };
280
280
  }
281
281
 
282
282
  // Resolve file via partial match
283
283
  const resolvedFile = resolveCoChangeFile(db, file);
284
284
  if (!resolvedFile) {
285
- db.close();
285
+ closeDb(db);
286
286
  return { error: `No co-change data found for file matching "${file}"` };
287
287
  }
288
288
 
@@ -311,7 +311,7 @@ export function coChangeData(file, customDbPath, opts = {}) {
311
311
  }
312
312
 
313
313
  const meta = getCoChangeMeta(db);
314
- db.close();
314
+ closeDb(db);
315
315
 
316
316
  return { file: resolvedFile, partners, meta };
317
317
  }
@@ -334,7 +334,7 @@ export function coChangeTopData(customDbPath, opts = {}) {
334
334
  try {
335
335
  db.prepare('SELECT 1 FROM co_changes LIMIT 1').get();
336
336
  } catch {
337
- db.close();
337
+ closeDb(db);
338
338
  return { error: 'No co-change data found. Run `codegraph co-change --analyze` first.' };
339
339
  }
340
340
 
@@ -363,7 +363,7 @@ export function coChangeTopData(customDbPath, opts = {}) {
363
363
  }
364
364
 
365
365
  const meta = getCoChangeMeta(db);
366
- db.close();
366
+ closeDb(db);
367
367
 
368
368
  return { pairs, meta };
369
369
  }