@optave/codegraph 2.3.1-dev.1aeea34 → 2.5.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
@@ -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';
@@ -39,6 +40,7 @@ import {
39
40
  registerRepo,
40
41
  unregisterRepo,
41
42
  } from './registry.js';
43
+ import { checkForUpdates, printUpdateNotification } from './update-check.js';
42
44
  import { watchProject } from './watcher.js';
43
45
 
44
46
  const __cliDir = path.dirname(new URL(import.meta.url).pathname.replace(/^\/([A-Z]:)/i, '$1'));
@@ -56,6 +58,17 @@ program
56
58
  .hook('preAction', (thisCommand) => {
57
59
  const opts = thisCommand.opts();
58
60
  if (opts.verbose) setVerbose(true);
61
+ })
62
+ .hook('postAction', async (_thisCommand, actionCommand) => {
63
+ const name = actionCommand.name();
64
+ if (name === 'mcp' || name === 'watch') return;
65
+ if (actionCommand.opts().json) return;
66
+ try {
67
+ const result = await checkForUpdates(pkg.version);
68
+ if (result) printUpdateNotification(result.current, result.latest);
69
+ } catch {
70
+ /* never break CLI */
71
+ }
59
72
  });
60
73
 
61
74
  /**
@@ -87,8 +100,17 @@ program
87
100
  .option('-T, --no-tests', 'Exclude test/spec files from results')
88
101
  .option('--include-tests', 'Include test/spec files (overrides excludeTests config)')
89
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')
90
106
  .action((name, opts) => {
91
- 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
+ });
92
114
  });
93
115
 
94
116
  program
@@ -124,8 +146,8 @@ program
124
146
  .option('-T, --no-tests', 'Exclude test/spec files from results')
125
147
  .option('--include-tests', 'Include test/spec files (overrides excludeTests config)')
126
148
  .option('-j, --json', 'Output as JSON')
127
- .action((opts) => {
128
- stats(opts.db, { noTests: resolveNoTests(opts), json: opts.json });
149
+ .action(async (opts) => {
150
+ await stats(opts.db, { noTests: resolveNoTests(opts), json: opts.json });
129
151
  });
130
152
 
131
153
  program
@@ -187,6 +209,36 @@ program
187
209
  });
188
210
  });
189
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
+
190
242
  program
191
243
  .command('context <name>')
192
244
  .description('Full context for a function: source, deps, callers, tests, signature')
@@ -239,13 +291,23 @@ program
239
291
  .option('-T, --no-tests', 'Exclude test/spec files from results')
240
292
  .option('--include-tests', 'Include test/spec files (overrides excludeTests config)')
241
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')
242
297
  .action((name, opts) => {
243
298
  if (!name && !opts.file) {
244
299
  console.error('Provide a symbol name or use --file <path>');
245
300
  process.exit(1);
246
301
  }
247
302
  const target = opts.file || name;
248
- 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
+ });
249
311
  });
250
312
 
251
313
  program
@@ -458,6 +520,7 @@ program
458
520
  `Embedding strategy: ${EMBEDDING_STRATEGIES.join(', ')}. "structured" uses graph context (callers/callees), "source" embeds raw code`,
459
521
  'structured',
460
522
  )
523
+ .option('-d, --db <path>', 'Path to graph.db')
461
524
  .action(async (dir, opts) => {
462
525
  if (!EMBEDDING_STRATEGIES.includes(opts.strategy)) {
463
526
  console.error(
@@ -467,7 +530,7 @@ program
467
530
  }
468
531
  const root = path.resolve(dir || '.');
469
532
  const model = opts.model || config.embeddings?.model || DEFAULT_MODEL;
470
- await buildEmbeddings(root, model, undefined, { strategy: opts.strategy });
533
+ await buildEmbeddings(root, model, opts.db, { strategy: opts.strategy });
471
534
  });
472
535
 
473
536
  program
@@ -504,6 +567,7 @@ program
504
567
  .option('-d, --db <path>', 'Path to graph.db')
505
568
  .option('--depth <n>', 'Max directory depth')
506
569
  .option('--sort <metric>', 'Sort by: cohesion | fan-in | fan-out | density | files', 'files')
570
+ .option('--full', 'Show all files without limit')
507
571
  .option('-T, --no-tests', 'Exclude test/spec files')
508
572
  .option('--include-tests', 'Include test/spec files (overrides excludeTests config)')
509
573
  .option('-j, --json', 'Output as JSON')
@@ -513,6 +577,7 @@ program
513
577
  directory: dir,
514
578
  depth: opts.depth ? parseInt(opts.depth, 10) : undefined,
515
579
  sort: opts.sort,
580
+ full: opts.full,
516
581
  noTests: resolveNoTests(opts),
517
582
  });
518
583
  if (opts.json) {
@@ -558,6 +623,9 @@ program
558
623
  .option('-T, --no-tests', 'Exclude test/spec files')
559
624
  .option('--include-tests', 'Include test/spec files (overrides excludeTests config)')
560
625
  .option('-j, --json', 'Output as JSON')
626
+ .option('--limit <number>', 'Max results to return')
627
+ .option('--offset <number>', 'Skip N results (default: 0)')
628
+ .option('--ndjson', 'Newline-delimited JSON output')
561
629
  .action((opts) => {
562
630
  if (opts.role && !VALID_ROLES.includes(opts.role)) {
563
631
  console.error(`Invalid role "${opts.role}". Valid roles: ${VALID_ROLES.join(', ')}`);
@@ -568,6 +636,9 @@ program
568
636
  file: opts.file,
569
637
  noTests: resolveNoTests(opts),
570
638
  json: opts.json,
639
+ limit: opts.limit ? parseInt(opts.limit, 10) : undefined,
640
+ offset: opts.offset ? parseInt(opts.offset, 10) : undefined,
641
+ ndjson: opts.ndjson,
571
642
  });
572
643
  });
573
644
 
@@ -633,6 +704,144 @@ program
633
704
  }
634
705
  });
635
706
 
707
+ program
708
+ .command('flow [name]')
709
+ .description(
710
+ 'Trace execution flow forward from an entry point (route, command, event) through callees to leaves',
711
+ )
712
+ .option('--list', 'List all entry points grouped by type')
713
+ .option('--depth <n>', 'Max forward traversal depth', '10')
714
+ .option('-d, --db <path>', 'Path to graph.db')
715
+ .option('-f, --file <path>', 'Scope to a specific file (partial match)')
716
+ .option('-k, --kind <kind>', 'Filter by symbol kind')
717
+ .option('-T, --no-tests', 'Exclude test/spec files from results')
718
+ .option('--include-tests', 'Include test/spec files (overrides excludeTests config)')
719
+ .option('-j, --json', 'Output as JSON')
720
+ .option('--limit <number>', 'Max results to return')
721
+ .option('--offset <number>', 'Skip N results (default: 0)')
722
+ .option('--ndjson', 'Newline-delimited JSON output')
723
+ .action(async (name, opts) => {
724
+ if (!name && !opts.list) {
725
+ console.error('Provide a function/entry point name or use --list to see all entry points.');
726
+ process.exit(1);
727
+ }
728
+ if (opts.kind && !ALL_SYMBOL_KINDS.includes(opts.kind)) {
729
+ console.error(`Invalid kind "${opts.kind}". Valid: ${ALL_SYMBOL_KINDS.join(', ')}`);
730
+ process.exit(1);
731
+ }
732
+ const { flow } = await import('./flow.js');
733
+ flow(name, opts.db, {
734
+ list: opts.list,
735
+ depth: parseInt(opts.depth, 10),
736
+ file: opts.file,
737
+ kind: opts.kind,
738
+ noTests: resolveNoTests(opts),
739
+ json: opts.json,
740
+ limit: opts.limit ? parseInt(opts.limit, 10) : undefined,
741
+ offset: opts.offset ? parseInt(opts.offset, 10) : undefined,
742
+ ndjson: opts.ndjson,
743
+ });
744
+ });
745
+
746
+ program
747
+ .command('complexity [target]')
748
+ .description('Show per-function complexity metrics (cognitive, cyclomatic, nesting depth, MI)')
749
+ .option('-d, --db <path>', 'Path to graph.db')
750
+ .option('-n, --limit <number>', 'Max results', '20')
751
+ .option(
752
+ '--sort <metric>',
753
+ 'Sort by: cognitive | cyclomatic | nesting | mi | volume | effort | bugs | loc',
754
+ 'cognitive',
755
+ )
756
+ .option('--above-threshold', 'Only functions exceeding warn thresholds')
757
+ .option('--health', 'Show health metrics (Halstead, MI) columns')
758
+ .option('-f, --file <path>', 'Scope to file (partial match)')
759
+ .option('-k, --kind <kind>', 'Filter by symbol kind')
760
+ .option('-T, --no-tests', 'Exclude test/spec files from results')
761
+ .option('--include-tests', 'Include test/spec files (overrides excludeTests config)')
762
+ .option('-j, --json', 'Output as JSON')
763
+ .action(async (target, opts) => {
764
+ if (opts.kind && !ALL_SYMBOL_KINDS.includes(opts.kind)) {
765
+ console.error(`Invalid kind "${opts.kind}". Valid: ${ALL_SYMBOL_KINDS.join(', ')}`);
766
+ process.exit(1);
767
+ }
768
+ const { complexity } = await import('./complexity.js');
769
+ complexity(opts.db, {
770
+ target,
771
+ limit: parseInt(opts.limit, 10),
772
+ sort: opts.sort,
773
+ aboveThreshold: opts.aboveThreshold,
774
+ health: opts.health,
775
+ file: opts.file,
776
+ kind: opts.kind,
777
+ noTests: resolveNoTests(opts),
778
+ json: opts.json,
779
+ });
780
+ });
781
+
782
+ program
783
+ .command('manifesto')
784
+ .description('Evaluate manifesto rules (pass/fail verdicts for code health)')
785
+ .option('-d, --db <path>', 'Path to graph.db')
786
+ .option('-T, --no-tests', 'Exclude test/spec files from results')
787
+ .option('--include-tests', 'Include test/spec files (overrides excludeTests config)')
788
+ .option('-f, --file <path>', 'Scope to file (partial match)')
789
+ .option('-k, --kind <kind>', 'Filter by symbol kind')
790
+ .option('-j, --json', 'Output as JSON')
791
+ .action(async (opts) => {
792
+ if (opts.kind && !ALL_SYMBOL_KINDS.includes(opts.kind)) {
793
+ console.error(`Invalid kind "${opts.kind}". Valid: ${ALL_SYMBOL_KINDS.join(', ')}`);
794
+ process.exit(1);
795
+ }
796
+ const { manifesto } = await import('./manifesto.js');
797
+ manifesto(opts.db, {
798
+ file: opts.file,
799
+ kind: opts.kind,
800
+ noTests: resolveNoTests(opts),
801
+ json: opts.json,
802
+ });
803
+ });
804
+
805
+ program
806
+ .command('communities')
807
+ .description('Detect natural module boundaries using Louvain community detection')
808
+ .option('--functions', 'Function-level instead of file-level')
809
+ .option('--resolution <n>', 'Louvain resolution parameter (default 1.0)', '1.0')
810
+ .option('--drift', 'Show only drift analysis')
811
+ .option('-d, --db <path>', 'Path to graph.db')
812
+ .option('-T, --no-tests', 'Exclude test/spec files from results')
813
+ .option('--include-tests', 'Include test/spec files (overrides excludeTests config)')
814
+ .option('-j, --json', 'Output as JSON')
815
+ .action(async (opts) => {
816
+ const { communities } = await import('./communities.js');
817
+ communities(opts.db, {
818
+ functions: opts.functions,
819
+ resolution: parseFloat(opts.resolution),
820
+ drift: opts.drift,
821
+ noTests: resolveNoTests(opts),
822
+ json: opts.json,
823
+ });
824
+ });
825
+
826
+ program
827
+ .command('branch-compare <base> <target>')
828
+ .description('Compare code structure between two branches/refs')
829
+ .option('--depth <n>', 'Max transitive caller depth', '3')
830
+ .option('-T, --no-tests', 'Exclude test/spec files')
831
+ .option('--include-tests', 'Include test/spec files (overrides excludeTests config)')
832
+ .option('-j, --json', 'Output as JSON')
833
+ .option('-f, --format <format>', 'Output format: text, mermaid, json', 'text')
834
+ .action(async (base, target, opts) => {
835
+ const { branchCompare } = await import('./branch-compare.js');
836
+ await branchCompare(base, target, {
837
+ engine: program.opts().engine,
838
+ depth: parseInt(opts.depth, 10),
839
+ noTests: resolveNoTests(opts),
840
+ json: opts.json,
841
+ format: opts.format,
842
+ });
843
+ });
844
+
636
845
  program
637
846
  .command('watch [dir]')
638
847
  .description('Watch project for file changes and incrementally update the graph')
@@ -668,6 +877,43 @@ program
668
877
  console.log(` Engine flag : --engine ${engine}`);
669
878
  console.log(` Active engine : ${activeName}${activeVersion ? ` (v${activeVersion})` : ''}`);
670
879
  console.log();
880
+
881
+ // Build metadata from DB
882
+ try {
883
+ const { findDbPath, getBuildMeta } = await import('./db.js');
884
+ const Database = (await import('better-sqlite3')).default;
885
+ const dbPath = findDbPath();
886
+ const fs = await import('node:fs');
887
+ if (fs.existsSync(dbPath)) {
888
+ const db = new Database(dbPath, { readonly: true });
889
+ const buildEngine = getBuildMeta(db, 'engine');
890
+ const buildVersion = getBuildMeta(db, 'codegraph_version');
891
+ const builtAt = getBuildMeta(db, 'built_at');
892
+ db.close();
893
+
894
+ if (buildEngine || buildVersion || builtAt) {
895
+ console.log('Build metadata');
896
+ console.log('──────────────');
897
+ if (buildEngine) console.log(` Engine : ${buildEngine}`);
898
+ if (buildVersion) console.log(` Version : ${buildVersion}`);
899
+ if (builtAt) console.log(` Built at : ${builtAt}`);
900
+
901
+ if (buildVersion && buildVersion !== program.version()) {
902
+ console.log(
903
+ ` ⚠ DB was built with v${buildVersion}, current is v${program.version()}. Consider: codegraph build --no-incremental`,
904
+ );
905
+ }
906
+ if (buildEngine && buildEngine !== activeName) {
907
+ console.log(
908
+ ` ⚠ DB was built with ${buildEngine} engine, active is ${activeName}. Consider: codegraph build --no-incremental`,
909
+ );
910
+ }
911
+ console.log();
912
+ }
913
+ }
914
+ } catch {
915
+ /* diagnostics must never crash */
916
+ }
671
917
  });
672
918
 
673
919
  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
  }