@optave/codegraph 2.1.1-dev.3c12b64 → 2.2.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/builder.js CHANGED
@@ -5,12 +5,44 @@ import path from 'node:path';
5
5
  import { loadConfig } from './config.js';
6
6
  import { EXTENSIONS, IGNORE_DIRS, normalizePath } from './constants.js';
7
7
  import { initSchema, openDb } from './db.js';
8
+ import { readJournal, writeJournalHeader } from './journal.js';
8
9
  import { debug, warn } from './logger.js';
9
10
  import { getActiveEngine, parseFilesAuto } from './parser.js';
10
11
  import { computeConfidence, resolveImportPath, resolveImportsBatch } from './resolve.js';
11
12
 
12
13
  export { resolveImportPath } from './resolve.js';
13
14
 
15
+ const BUILTIN_RECEIVERS = new Set([
16
+ 'console',
17
+ 'Math',
18
+ 'JSON',
19
+ 'Object',
20
+ 'Array',
21
+ 'String',
22
+ 'Number',
23
+ 'Boolean',
24
+ 'Date',
25
+ 'RegExp',
26
+ 'Map',
27
+ 'Set',
28
+ 'WeakMap',
29
+ 'WeakSet',
30
+ 'Promise',
31
+ 'Symbol',
32
+ 'Error',
33
+ 'TypeError',
34
+ 'RangeError',
35
+ 'Proxy',
36
+ 'Reflect',
37
+ 'Intl',
38
+ 'globalThis',
39
+ 'window',
40
+ 'document',
41
+ 'process',
42
+ 'Buffer',
43
+ 'require',
44
+ ]);
45
+
14
46
  export function collectFiles(dir, files = [], config = {}, directories = null) {
15
47
  const trackDirs = directories !== null;
16
48
  let entries;
@@ -81,8 +113,24 @@ function fileHash(content) {
81
113
  return createHash('md5').update(content).digest('hex');
82
114
  }
83
115
 
116
+ /**
117
+ * Stat a file, returning { mtimeMs, size } or null on error.
118
+ */
119
+ function fileStat(filePath) {
120
+ try {
121
+ const s = fs.statSync(filePath);
122
+ return { mtimeMs: s.mtimeMs, size: s.size };
123
+ } catch {
124
+ return null;
125
+ }
126
+ }
127
+
84
128
  /**
85
129
  * Determine which files have changed since last build.
130
+ * Three-tier cascade:
131
+ * Tier 0 — Journal: O(changed) when watcher was running
132
+ * Tier 1 — mtime+size: O(n) stats, O(changed) reads
133
+ * Tier 2 — Hash comparison: O(changed) reads (fallback from Tier 1)
86
134
  */
87
135
  function getChangedFiles(db, allFiles, rootDir) {
88
136
  // Check if file_hashes table exists
@@ -95,7 +143,6 @@ function getChangedFiles(db, allFiles, rootDir) {
95
143
  }
96
144
 
97
145
  if (!hasTable) {
98
- // No hash table = first build, everything is new
99
146
  return {
100
147
  changed: allFiles.map((f) => ({ file: f })),
101
148
  removed: [],
@@ -105,36 +152,140 @@ function getChangedFiles(db, allFiles, rootDir) {
105
152
 
106
153
  const existing = new Map(
107
154
  db
108
- .prepare('SELECT file, hash FROM file_hashes')
155
+ .prepare('SELECT file, hash, mtime, size FROM file_hashes')
109
156
  .all()
110
- .map((r) => [r.file, r.hash]),
157
+ .map((r) => [r.file, r]),
111
158
  );
112
159
 
113
- const changed = [];
160
+ // Build set of current files for removal detection
114
161
  const currentFiles = new Set();
162
+ for (const file of allFiles) {
163
+ currentFiles.add(normalizePath(path.relative(rootDir, file)));
164
+ }
165
+
166
+ const removed = [];
167
+ for (const existingFile of existing.keys()) {
168
+ if (!currentFiles.has(existingFile)) {
169
+ removed.push(existingFile);
170
+ }
171
+ }
172
+
173
+ // ── Tier 0: Journal ──────────────────────────────────────────────
174
+ const journal = readJournal(rootDir);
175
+ if (journal.valid) {
176
+ // Validate journal timestamp against DB — journal should be from after the last build
177
+ const dbMtimes = db.prepare('SELECT MAX(mtime) as latest FROM file_hashes').get();
178
+ const latestDbMtime = dbMtimes?.latest || 0;
179
+
180
+ // Empty journal = no watcher was running, fall to Tier 1 for safety
181
+ const hasJournalEntries = journal.changed.length > 0 || journal.removed.length > 0;
182
+
183
+ if (hasJournalEntries && journal.timestamp >= latestDbMtime) {
184
+ debug(
185
+ `Tier 0: journal valid, ${journal.changed.length} changed, ${journal.removed.length} removed`,
186
+ );
187
+ const changed = [];
188
+
189
+ for (const relPath of journal.changed) {
190
+ const absPath = path.join(rootDir, relPath);
191
+ const stat = fileStat(absPath);
192
+ if (!stat) continue;
193
+
194
+ let content;
195
+ try {
196
+ content = fs.readFileSync(absPath, 'utf-8');
197
+ } catch {
198
+ continue;
199
+ }
200
+ const hash = fileHash(content);
201
+ const record = existing.get(relPath);
202
+ if (!record || record.hash !== hash) {
203
+ changed.push({ file: absPath, content, hash, relPath, stat });
204
+ }
205
+ }
206
+
207
+ // Merge journal removals with filesystem removals (dedup)
208
+ const removedSet = new Set(removed);
209
+ for (const relPath of journal.removed) {
210
+ if (existing.has(relPath)) removedSet.add(relPath);
211
+ }
212
+
213
+ return { changed, removed: [...removedSet], isFullBuild: false };
214
+ }
215
+ debug(
216
+ `Tier 0: skipped (${hasJournalEntries ? 'timestamp stale' : 'no entries'}), falling to Tier 1`,
217
+ );
218
+ }
219
+
220
+ // ── Tier 1: mtime+size fast-path ─────────────────────────────────
221
+ const needsHash = []; // Files that failed mtime+size check
222
+ const skipped = []; // Files that passed mtime+size check
115
223
 
116
224
  for (const file of allFiles) {
117
225
  const relPath = normalizePath(path.relative(rootDir, file));
118
- currentFiles.add(relPath);
226
+ const record = existing.get(relPath);
227
+
228
+ if (!record) {
229
+ // New file — needs full read+hash
230
+ needsHash.push({ file, relPath });
231
+ continue;
232
+ }
233
+
234
+ const stat = fileStat(file);
235
+ if (!stat) continue;
236
+
237
+ const storedMtime = record.mtime || 0;
238
+ const storedSize = record.size || 0;
239
+
240
+ // size > 0 guard: pre-v4 rows have size=0, always fall through to hash
241
+ if (storedSize > 0 && Math.floor(stat.mtimeMs) === storedMtime && stat.size === storedSize) {
242
+ skipped.push(relPath);
243
+ continue;
244
+ }
245
+
246
+ needsHash.push({ file, relPath, stat });
247
+ }
248
+
249
+ if (needsHash.length > 0) {
250
+ debug(`Tier 1: ${skipped.length} skipped by mtime+size, ${needsHash.length} need hash check`);
251
+ }
252
+
253
+ // ── Tier 2: Hash comparison ──────────────────────────────────────
254
+ const changed = [];
119
255
 
256
+ for (const item of needsHash) {
120
257
  let content;
121
258
  try {
122
- content = fs.readFileSync(file, 'utf-8');
259
+ content = fs.readFileSync(item.file, 'utf-8');
123
260
  } catch {
124
261
  continue;
125
262
  }
126
263
  const hash = fileHash(content);
127
-
128
- if (existing.get(relPath) !== hash) {
129
- changed.push({ file, content, hash, relPath });
264
+ const stat = item.stat || fileStat(item.file);
265
+ const record = existing.get(item.relPath);
266
+
267
+ if (!record || record.hash !== hash) {
268
+ changed.push({ file: item.file, content, hash, relPath: item.relPath, stat });
269
+ } else if (stat) {
270
+ // Hash matches but mtime/size was stale — self-heal by updating stored metadata
271
+ changed.push({
272
+ file: item.file,
273
+ content,
274
+ hash,
275
+ relPath: item.relPath,
276
+ stat,
277
+ metadataOnly: true,
278
+ });
130
279
  }
131
280
  }
132
281
 
133
- const removed = [];
134
- for (const existingFile of existing.keys()) {
135
- if (!currentFiles.has(existingFile)) {
136
- removed.push(existingFile);
137
- }
282
+ // Filter out metadata-only updates from the "changed" list for parsing,
283
+ // but keep them so the caller can update file_hashes
284
+ const parseChanged = changed.filter((c) => !c.metadataOnly);
285
+ if (needsHash.length > 0) {
286
+ debug(
287
+ `Tier 2: ${parseChanged.length} actually changed, ${changed.length - parseChanged.length} metadata-only`,
288
+ );
138
289
  }
139
290
 
140
291
  return { changed, removed, isFullBuild: false };
@@ -180,9 +331,33 @@ export async function buildGraph(rootDir, opts = {}) {
180
331
  ? getChangedFiles(db, files, rootDir)
181
332
  : { changed: files.map((f) => ({ file: f })), removed: [], isFullBuild: true };
182
333
 
183
- if (!isFullBuild && changed.length === 0 && removed.length === 0) {
334
+ // Separate metadata-only updates (mtime/size self-heal) from real changes
335
+ const parseChanges = changed.filter((c) => !c.metadataOnly);
336
+ const metadataUpdates = changed.filter((c) => c.metadataOnly);
337
+
338
+ if (!isFullBuild && parseChanges.length === 0 && removed.length === 0) {
339
+ // Still update metadata for self-healing even when no real changes
340
+ if (metadataUpdates.length > 0) {
341
+ try {
342
+ const healHash = db.prepare(
343
+ 'INSERT OR REPLACE INTO file_hashes (file, hash, mtime, size) VALUES (?, ?, ?, ?)',
344
+ );
345
+ const healTx = db.transaction(() => {
346
+ for (const item of metadataUpdates) {
347
+ const mtime = item.stat ? Math.floor(item.stat.mtimeMs) : 0;
348
+ const size = item.stat ? item.stat.size : 0;
349
+ healHash.run(item.relPath, item.hash, mtime, size);
350
+ }
351
+ });
352
+ healTx();
353
+ debug(`Self-healed mtime/size for ${metadataUpdates.length} files`);
354
+ } catch {
355
+ /* ignore heal errors */
356
+ }
357
+ }
184
358
  console.log('No changes detected. Graph is up to date.');
185
359
  db.close();
360
+ writeJournalHeader(rootDir, Date.now());
186
361
  return;
187
362
  }
188
363
 
@@ -191,7 +366,7 @@ export async function buildGraph(rootDir, opts = {}) {
191
366
  'PRAGMA foreign_keys = OFF; DELETE FROM node_metrics; DELETE FROM edges; DELETE FROM nodes; PRAGMA foreign_keys = ON;',
192
367
  );
193
368
  } else {
194
- console.log(`Incremental: ${changed.length} changed, ${removed.length} removed`);
369
+ console.log(`Incremental: ${parseChanges.length} changed, ${removed.length} removed`);
195
370
  // Remove metrics/edges/nodes for changed and removed files
196
371
  const deleteNodesForFile = db.prepare('DELETE FROM nodes WHERE file = ?');
197
372
  const deleteEdgesForFile = db.prepare(`
@@ -206,7 +381,7 @@ export async function buildGraph(rootDir, opts = {}) {
206
381
  deleteMetricsForFile.run(relPath);
207
382
  deleteNodesForFile.run(relPath);
208
383
  }
209
- for (const item of changed) {
384
+ for (const item of parseChanges) {
210
385
  const relPath = item.relPath || normalizePath(path.relative(rootDir, item.file));
211
386
  deleteEdgesForFile.run({ f: relPath });
212
387
  deleteMetricsForFile.run(relPath);
@@ -224,11 +399,11 @@ export async function buildGraph(rootDir, opts = {}) {
224
399
  'INSERT INTO edges (source_id, target_id, kind, confidence, dynamic) VALUES (?, ?, ?, ?, ?)',
225
400
  );
226
401
 
227
- // Prepare hash upsert
402
+ // Prepare hash upsert (with size column from migration v4)
228
403
  let upsertHash;
229
404
  try {
230
405
  upsertHash = db.prepare(
231
- 'INSERT OR REPLACE INTO file_hashes (file, hash, mtime) VALUES (?, ?, ?)',
406
+ 'INSERT OR REPLACE INTO file_hashes (file, hash, mtime, size) VALUES (?, ?, ?, ?)',
232
407
  );
233
408
  } catch {
234
409
  upsertHash = null;
@@ -246,17 +421,17 @@ export async function buildGraph(rootDir, opts = {}) {
246
421
  // We'll fill these in during the parse pass + edge pass
247
422
  }
248
423
 
249
- const filesToParse = isFullBuild ? files.map((f) => ({ file: f })) : changed;
424
+ const filesToParse = isFullBuild ? files.map((f) => ({ file: f })) : parseChanges;
250
425
 
251
426
  // ── Unified parse via parseFilesAuto ───────────────────────────────
252
427
  const filePaths = filesToParse.map((item) => item.file);
253
428
  const allSymbols = await parseFilesAuto(filePaths, rootDir, engineOpts);
254
429
 
255
- // Build a hash lookup from incremental data (changed items may carry pre-computed hashes)
256
- const precomputedHashes = new Map();
430
+ // Build a lookup from incremental data (changed items may carry pre-computed hashes + stats)
431
+ const precomputedData = new Map();
257
432
  for (const item of filesToParse) {
258
- if (item.hash && item.relPath) {
259
- precomputedHashes.set(item.relPath, item.hash);
433
+ if (item.relPath) {
434
+ precomputedData.set(item.relPath, item);
260
435
  }
261
436
  }
262
437
 
@@ -272,11 +447,14 @@ export async function buildGraph(rootDir, opts = {}) {
272
447
  insertNode.run(exp.name, exp.kind, relPath, exp.line, null);
273
448
  }
274
449
 
275
- // Update file hash for incremental builds
450
+ // Update file hash with real mtime+size for incremental builds
276
451
  if (upsertHash) {
277
- const existingHash = precomputedHashes.get(relPath);
278
- if (existingHash) {
279
- upsertHash.run(relPath, existingHash, Date.now());
452
+ const precomputed = precomputedData.get(relPath);
453
+ if (precomputed?.hash) {
454
+ const stat = precomputed.stat || fileStat(path.join(rootDir, relPath));
455
+ const mtime = stat ? Math.floor(stat.mtimeMs) : 0;
456
+ const size = stat ? stat.size : 0;
457
+ upsertHash.run(relPath, precomputed.hash, mtime, size);
280
458
  } else {
281
459
  const absPath = path.join(rootDir, relPath);
282
460
  let code;
@@ -286,11 +464,23 @@ export async function buildGraph(rootDir, opts = {}) {
286
464
  code = null;
287
465
  }
288
466
  if (code !== null) {
289
- upsertHash.run(relPath, fileHash(code), Date.now());
467
+ const stat = fileStat(absPath);
468
+ const mtime = stat ? Math.floor(stat.mtimeMs) : 0;
469
+ const size = stat ? stat.size : 0;
470
+ upsertHash.run(relPath, fileHash(code), mtime, size);
290
471
  }
291
472
  }
292
473
  }
293
474
  }
475
+
476
+ // Also update metadata-only entries (self-heal mtime/size without re-parse)
477
+ if (upsertHash) {
478
+ for (const item of metadataUpdates) {
479
+ const mtime = item.stat ? Math.floor(item.stat.mtimeMs) : 0;
480
+ const size = item.stat ? item.stat.size : 0;
481
+ upsertHash.run(item.relPath, item.hash, mtime, size);
482
+ }
483
+ }
294
484
  });
295
485
  insertAll();
296
486
 
@@ -458,7 +648,9 @@ export async function buildGraph(rootDir, opts = {}) {
458
648
  }
459
649
 
460
650
  // Call edges with confidence scoring — using pre-loaded lookup maps (N+1 fix)
651
+ const seenCallEdges = new Set();
461
652
  for (const call of symbols.calls) {
653
+ if (call.receiver && BUILTIN_RECEIVERS.has(call.receiver)) continue;
462
654
  let caller = null;
463
655
  for (const def of symbols.definitions) {
464
656
  if (def.line <= call.line) {
@@ -493,10 +685,18 @@ export async function buildGraph(rootDir, opts = {}) {
493
685
  );
494
686
  if (methodCandidates.length > 0) {
495
687
  targets = methodCandidates;
496
- } else {
497
- // Global fallback
498
- targets = nodesByName.get(call.name) || [];
688
+ } else if (
689
+ !call.receiver ||
690
+ call.receiver === 'this' ||
691
+ call.receiver === 'self' ||
692
+ call.receiver === 'super'
693
+ ) {
694
+ // Scoped fallback — same-dir or parent-dir only, not global
695
+ targets = (nodesByName.get(call.name) || []).filter(
696
+ (n) => computeConfidence(relPath, n.file, null) >= 0.5,
697
+ );
499
698
  }
699
+ // else: method call on a receiver — skip global fallback entirely
500
700
  }
501
701
  }
502
702
 
@@ -509,7 +709,9 @@ export async function buildGraph(rootDir, opts = {}) {
509
709
  }
510
710
 
511
711
  for (const t of targets) {
512
- if (t.id !== caller.id) {
712
+ const edgeKey = `${caller.id}|${t.id}`;
713
+ if (t.id !== caller.id && !seenCallEdges.has(edgeKey)) {
714
+ seenCallEdges.add(edgeKey);
513
715
  const confidence = computeConfidence(relPath, t.file, importedFrom);
514
716
  insertEdge.run(caller.id, t.id, 'calls', confidence, isDynamic);
515
717
  edgeCount++;
@@ -582,6 +784,9 @@ export async function buildGraph(rootDir, opts = {}) {
582
784
  console.log(`Stored in ${dbPath}`);
583
785
  db.close();
584
786
 
787
+ // Write journal header after successful build
788
+ writeJournalHeader(rootDir, Date.now());
789
+
585
790
  if (!opts.skipRegistry) {
586
791
  const tmpDir = path.resolve(os.tmpdir());
587
792
  const resolvedRoot = path.resolve(rootDir);
package/src/cli.js CHANGED
@@ -11,7 +11,10 @@ import { buildEmbeddings, MODELS, search } from './embedder.js';
11
11
  import { exportDOT, exportJSON, exportMermaid } from './export.js';
12
12
  import { setVerbose } from './logger.js';
13
13
  import {
14
+ ALL_SYMBOL_KINDS,
15
+ context,
14
16
  diffImpact,
17
+ explain,
15
18
  fileDeps,
16
19
  fnDeps,
17
20
  fnImpact,
@@ -19,6 +22,7 @@ import {
19
22
  moduleMap,
20
23
  queryName,
21
24
  stats,
25
+ where,
22
26
  } from './queries.js';
23
27
  import {
24
28
  listRepos,
@@ -58,18 +62,20 @@ program
58
62
  .command('query <name>')
59
63
  .description('Find a function/class, show callers and callees')
60
64
  .option('-d, --db <path>', 'Path to graph.db')
65
+ .option('-T, --no-tests', 'Exclude test/spec files from results')
61
66
  .option('-j, --json', 'Output as JSON')
62
67
  .action((name, opts) => {
63
- queryName(name, opts.db, { json: opts.json });
68
+ queryName(name, opts.db, { noTests: !opts.tests, json: opts.json });
64
69
  });
65
70
 
66
71
  program
67
72
  .command('impact <file>')
68
73
  .description('Show what depends on this file (transitive)')
69
74
  .option('-d, --db <path>', 'Path to graph.db')
75
+ .option('-T, --no-tests', 'Exclude test/spec files from results')
70
76
  .option('-j, --json', 'Output as JSON')
71
77
  .action((file, opts) => {
72
- impactAnalysis(file, opts.db, { json: opts.json });
78
+ impactAnalysis(file, opts.db, { noTests: !opts.tests, json: opts.json });
73
79
  });
74
80
 
75
81
  program
@@ -77,27 +83,30 @@ program
77
83
  .description('High-level module overview with most-connected nodes')
78
84
  .option('-d, --db <path>', 'Path to graph.db')
79
85
  .option('-n, --limit <number>', 'Number of top nodes', '20')
86
+ .option('-T, --no-tests', 'Exclude test/spec files from results')
80
87
  .option('-j, --json', 'Output as JSON')
81
88
  .action((opts) => {
82
- moduleMap(opts.db, parseInt(opts.limit, 10), { json: opts.json });
89
+ moduleMap(opts.db, parseInt(opts.limit, 10), { noTests: !opts.tests, json: opts.json });
83
90
  });
84
91
 
85
92
  program
86
93
  .command('stats')
87
94
  .description('Show graph health overview: nodes, edges, languages, cycles, hotspots, embeddings')
88
95
  .option('-d, --db <path>', 'Path to graph.db')
96
+ .option('-T, --no-tests', 'Exclude test/spec files from results')
89
97
  .option('-j, --json', 'Output as JSON')
90
98
  .action((opts) => {
91
- stats(opts.db, { json: opts.json });
99
+ stats(opts.db, { noTests: !opts.tests, json: opts.json });
92
100
  });
93
101
 
94
102
  program
95
103
  .command('deps <file>')
96
104
  .description('Show what this file imports and what imports it')
97
105
  .option('-d, --db <path>', 'Path to graph.db')
106
+ .option('-T, --no-tests', 'Exclude test/spec files from results')
98
107
  .option('-j, --json', 'Output as JSON')
99
108
  .action((file, opts) => {
100
- fileDeps(file, opts.db, { json: opts.json });
109
+ fileDeps(file, opts.db, { noTests: !opts.tests, json: opts.json });
101
110
  });
102
111
 
103
112
  program
@@ -105,11 +114,19 @@ program
105
114
  .description('Function-level dependencies: callers, callees, and transitive call chain')
106
115
  .option('-d, --db <path>', 'Path to graph.db')
107
116
  .option('--depth <n>', 'Transitive caller depth', '3')
117
+ .option('-f, --file <path>', 'Scope search to functions in this file (partial match)')
118
+ .option('-k, --kind <kind>', 'Filter to a specific symbol kind')
108
119
  .option('-T, --no-tests', 'Exclude test/spec files from results')
109
120
  .option('-j, --json', 'Output as JSON')
110
121
  .action((name, opts) => {
122
+ if (opts.kind && !ALL_SYMBOL_KINDS.includes(opts.kind)) {
123
+ console.error(`Invalid kind "${opts.kind}". Valid: ${ALL_SYMBOL_KINDS.join(', ')}`);
124
+ process.exit(1);
125
+ }
111
126
  fnDeps(name, opts.db, {
112
127
  depth: parseInt(opts.depth, 10),
128
+ file: opts.file,
129
+ kind: opts.kind,
113
130
  noTests: !opts.tests,
114
131
  json: opts.json,
115
132
  });
@@ -120,16 +137,77 @@ program
120
137
  .description('Function-level impact: what functions break if this one changes')
121
138
  .option('-d, --db <path>', 'Path to graph.db')
122
139
  .option('--depth <n>', 'Max transitive depth', '5')
140
+ .option('-f, --file <path>', 'Scope search to functions in this file (partial match)')
141
+ .option('-k, --kind <kind>', 'Filter to a specific symbol kind')
123
142
  .option('-T, --no-tests', 'Exclude test/spec files from results')
124
143
  .option('-j, --json', 'Output as JSON')
125
144
  .action((name, opts) => {
145
+ if (opts.kind && !ALL_SYMBOL_KINDS.includes(opts.kind)) {
146
+ console.error(`Invalid kind "${opts.kind}". Valid: ${ALL_SYMBOL_KINDS.join(', ')}`);
147
+ process.exit(1);
148
+ }
126
149
  fnImpact(name, opts.db, {
127
150
  depth: parseInt(opts.depth, 10),
151
+ file: opts.file,
152
+ kind: opts.kind,
153
+ noTests: !opts.tests,
154
+ json: opts.json,
155
+ });
156
+ });
157
+
158
+ program
159
+ .command('context <name>')
160
+ .description('Full context for a function: source, deps, callers, tests, signature')
161
+ .option('-d, --db <path>', 'Path to graph.db')
162
+ .option('--depth <n>', 'Include callee source up to N levels deep', '0')
163
+ .option('-f, --file <path>', 'Scope search to functions in this file (partial match)')
164
+ .option('-k, --kind <kind>', 'Filter to a specific symbol kind')
165
+ .option('--no-source', 'Metadata only (skip source extraction)')
166
+ .option('--include-tests', 'Include test source code')
167
+ .option('-T, --no-tests', 'Exclude test/spec files from results')
168
+ .option('-j, --json', 'Output as JSON')
169
+ .action((name, opts) => {
170
+ if (opts.kind && !ALL_SYMBOL_KINDS.includes(opts.kind)) {
171
+ console.error(`Invalid kind "${opts.kind}". Valid: ${ALL_SYMBOL_KINDS.join(', ')}`);
172
+ process.exit(1);
173
+ }
174
+ context(name, opts.db, {
175
+ depth: parseInt(opts.depth, 10),
176
+ file: opts.file,
177
+ kind: opts.kind,
178
+ noSource: !opts.source,
128
179
  noTests: !opts.tests,
180
+ includeTests: opts.includeTests,
129
181
  json: opts.json,
130
182
  });
131
183
  });
132
184
 
185
+ program
186
+ .command('explain <target>')
187
+ .description('Structural summary of a file or function (no LLM needed)')
188
+ .option('-d, --db <path>', 'Path to graph.db')
189
+ .option('-T, --no-tests', 'Exclude test/spec files from results')
190
+ .option('-j, --json', 'Output as JSON')
191
+ .action((target, opts) => {
192
+ explain(target, opts.db, { noTests: !opts.tests, json: opts.json });
193
+ });
194
+
195
+ program
196
+ .command('where [name]')
197
+ .description('Find where a symbol is defined and used (minimal, fast lookup)')
198
+ .option('-d, --db <path>', 'Path to graph.db')
199
+ .option('-f, --file <path>', 'File overview: list symbols, imports, exports')
200
+ .option('-T, --no-tests', 'Exclude test/spec files from results')
201
+ .option('-j, --json', 'Output as JSON')
202
+ .action((name, opts) => {
203
+ if (!name && !opts.file) {
204
+ console.error('Provide a symbol name or use --file <path>');
205
+ process.exit(1);
206
+ }
207
+ const target = opts.file || name;
208
+ where(target, opts.db, { file: !!opts.file, noTests: !opts.tests, json: opts.json });
209
+ });
210
+
133
211
  program
134
212
  .command('diff-impact [ref]')
135
213
  .description('Show impact of git changes (unstaged, staged, or vs a ref)')
@@ -156,10 +234,11 @@ program
156
234
  .option('-d, --db <path>', 'Path to graph.db')
157
235
  .option('-f, --format <format>', 'Output format: dot, mermaid, json', 'dot')
158
236
  .option('--functions', 'Function-level graph instead of file-level')
237
+ .option('-T, --no-tests', 'Exclude test/spec files')
159
238
  .option('-o, --output <file>', 'Write to file instead of stdout')
160
239
  .action((opts) => {
161
240
  const db = new Database(findDbPath(opts.db), { readonly: true });
162
- const exportOpts = { fileLevel: !opts.functions };
241
+ const exportOpts = { fileLevel: !opts.functions, noTests: !opts.tests };
163
242
 
164
243
  let output;
165
244
  switch (opts.format) {
@@ -167,7 +246,7 @@ program
167
246
  output = exportMermaid(db, exportOpts);
168
247
  break;
169
248
  case 'json':
170
- output = JSON.stringify(exportJSON(db), null, 2);
249
+ output = JSON.stringify(exportJSON(db, exportOpts), null, 2);
171
250
  break;
172
251
  default:
173
252
  output = exportDOT(db, exportOpts);
@@ -189,10 +268,11 @@ program
189
268
  .description('Detect circular dependencies in the codebase')
190
269
  .option('-d, --db <path>', 'Path to graph.db')
191
270
  .option('--functions', 'Function-level cycle detection')
271
+ .option('-T, --no-tests', 'Exclude test/spec files')
192
272
  .option('-j, --json', 'Output as JSON')
193
273
  .action((opts) => {
194
274
  const db = new Database(findDbPath(opts.db), { readonly: true });
195
- const cycles = findCycles(db, { fileLevel: !opts.functions });
275
+ const cycles = findCycles(db, { fileLevel: !opts.functions, noTests: !opts.tests });
196
276
  db.close();
197
277
 
198
278
  if (opts.json) {
@@ -294,7 +374,7 @@ program
294
374
  .action(() => {
295
375
  console.log('\nAvailable embedding models:\n');
296
376
  for (const [key, config] of Object.entries(MODELS)) {
297
- const def = key === 'jina-code' ? ' (default)' : '';
377
+ const def = key === 'nomic-v1.5' ? ' (default)' : '';
298
378
  console.log(` ${key.padEnd(12)} ${String(config.dim).padStart(4)}d ${config.desc}${def}`);
299
379
  }
300
380
  console.log('\nUsage: codegraph embed --model <name>');
@@ -308,8 +388,8 @@ program
308
388
  )
309
389
  .option(
310
390
  '-m, --model <name>',
311
- 'Embedding model: minilm, jina-small, jina-base, jina-code (default), nomic, nomic-v1.5, bge-large. Run `codegraph models` for details',
312
- 'jina-code',
391
+ 'Embedding model: minilm, jina-small, jina-base, jina-code, nomic, nomic-v1.5 (default), bge-large. Run `codegraph models` for details',
392
+ 'nomic-v1.5',
313
393
  )
314
394
  .action(async (dir, opts) => {
315
395
  const root = path.resolve(dir || '.');
@@ -322,7 +402,7 @@ program
322
402
  .option('-d, --db <path>', 'Path to graph.db')
323
403
  .option('-m, --model <name>', 'Override embedding model (auto-detects from DB)')
324
404
  .option('-n, --limit <number>', 'Max results', '15')
325
- .option('-T, --no-tests', 'Exclude test/spec files')
405
+ .option('-T, --no-tests', 'Exclude test/spec files from results')
326
406
  .option('--min-score <score>', 'Minimum similarity threshold', '0.2')
327
407
  .option('-k, --kind <kind>', 'Filter by kind: function, method, class')
328
408
  .option('--file <pattern>', 'Filter by file path pattern')
@@ -347,6 +427,7 @@ program
347
427
  .option('-d, --db <path>', 'Path to graph.db')
348
428
  .option('--depth <n>', 'Max directory depth')
349
429
  .option('--sort <metric>', 'Sort by: cohesion | fan-in | fan-out | density | files', 'files')
430
+ .option('-T, --no-tests', 'Exclude test/spec files')
350
431
  .option('-j, --json', 'Output as JSON')
351
432
  .action(async (dir, opts) => {
352
433
  const { structureData, formatStructure } = await import('./structure.js');
@@ -354,6 +435,7 @@ program
354
435
  directory: dir,
355
436
  depth: opts.depth ? parseInt(opts.depth, 10) : undefined,
356
437
  sort: opts.sort,
438
+ noTests: !opts.tests,
357
439
  });
358
440
  if (opts.json) {
359
441
  console.log(JSON.stringify(data, null, 2));
@@ -371,6 +453,7 @@ program
371
453
  .option('-n, --limit <number>', 'Number of results', '10')
372
454
  .option('--metric <metric>', 'fan-in | fan-out | density | coupling', 'fan-in')
373
455
  .option('--level <level>', 'file | directory', 'file')
456
+ .option('-T, --no-tests', 'Exclude test/spec files from results')
374
457
  .option('-j, --json', 'Output as JSON')
375
458
  .action(async (opts) => {
376
459
  const { hotspotsData, formatHotspots } = await import('./structure.js');
@@ -378,6 +461,7 @@ program
378
461
  metric: opts.metric,
379
462
  level: opts.level,
380
463
  limit: parseInt(opts.limit, 10),
464
+ noTests: !opts.tests,
381
465
  });
382
466
  if (opts.json) {
383
467
  console.log(JSON.stringify(data, null, 2));
package/src/config.js CHANGED
@@ -19,7 +19,7 @@ export const DEFAULTS = {
19
19
  defaultDepth: 3,
20
20
  defaultLimit: 20,
21
21
  },
22
- embeddings: { model: 'jina-code', llmProvider: null },
22
+ embeddings: { model: 'nomic-v1.5', llmProvider: null },
23
23
  llm: { provider: null, model: null, baseUrl: null, apiKey: null, apiKeyCommand: null },
24
24
  search: { defaultMinScore: 0.2, rrfK: 60, topK: 15 },
25
25
  ci: { failOnCycles: false, impactThreshold: null },