@optave/codegraph 2.1.1-dev.00f091c → 2.1.1-dev.0e15f12

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@optave/codegraph",
3
- "version": "2.1.1-dev.00f091c",
3
+ "version": "2.1.1-dev.0e15f12",
4
4
  "description": "Local code graph CLI — parse codebases with tree-sitter, build dependency graphs, query them",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -61,10 +61,10 @@
61
61
  "optionalDependencies": {
62
62
  "@huggingface/transformers": "^3.8.1",
63
63
  "@modelcontextprotocol/sdk": "^1.0.0",
64
- "@optave/codegraph-darwin-arm64": "2.1.1-dev.00f091c",
65
- "@optave/codegraph-darwin-x64": "2.1.1-dev.00f091c",
66
- "@optave/codegraph-linux-x64-gnu": "2.1.1-dev.00f091c",
67
- "@optave/codegraph-win32-x64-msvc": "2.1.1-dev.00f091c"
64
+ "@optave/codegraph-darwin-arm64": "2.1.1-dev.0e15f12",
65
+ "@optave/codegraph-darwin-x64": "2.1.1-dev.0e15f12",
66
+ "@optave/codegraph-linux-x64-gnu": "2.1.1-dev.0e15f12",
67
+ "@optave/codegraph-win32-x64-msvc": "2.1.1-dev.0e15f12"
68
68
  },
69
69
  "devDependencies": {
70
70
  "@biomejs/biome": "^2.4.4",
package/src/cli.js CHANGED
@@ -11,8 +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,
14
15
  context,
15
16
  diffImpact,
17
+ explain,
16
18
  fileDeps,
17
19
  fnDeps,
18
20
  fnImpact,
@@ -20,6 +22,7 @@ import {
20
22
  moduleMap,
21
23
  queryName,
22
24
  stats,
25
+ where,
23
26
  } from './queries.js';
24
27
  import {
25
28
  listRepos,
@@ -106,11 +109,19 @@ program
106
109
  .description('Function-level dependencies: callers, callees, and transitive call chain')
107
110
  .option('-d, --db <path>', 'Path to graph.db')
108
111
  .option('--depth <n>', 'Transitive caller depth', '3')
112
+ .option('-f, --file <path>', 'Scope search to functions in this file (partial match)')
113
+ .option('-k, --kind <kind>', 'Filter to a specific symbol kind')
109
114
  .option('-T, --no-tests', 'Exclude test/spec files from results')
110
115
  .option('-j, --json', 'Output as JSON')
111
116
  .action((name, opts) => {
117
+ if (opts.kind && !ALL_SYMBOL_KINDS.includes(opts.kind)) {
118
+ console.error(`Invalid kind "${opts.kind}". Valid: ${ALL_SYMBOL_KINDS.join(', ')}`);
119
+ process.exit(1);
120
+ }
112
121
  fnDeps(name, opts.db, {
113
122
  depth: parseInt(opts.depth, 10),
123
+ file: opts.file,
124
+ kind: opts.kind,
114
125
  noTests: !opts.tests,
115
126
  json: opts.json,
116
127
  });
@@ -121,11 +132,19 @@ program
121
132
  .description('Function-level impact: what functions break if this one changes')
122
133
  .option('-d, --db <path>', 'Path to graph.db')
123
134
  .option('--depth <n>', 'Max transitive depth', '5')
135
+ .option('-f, --file <path>', 'Scope search to functions in this file (partial match)')
136
+ .option('-k, --kind <kind>', 'Filter to a specific symbol kind')
124
137
  .option('-T, --no-tests', 'Exclude test/spec files from results')
125
138
  .option('-j, --json', 'Output as JSON')
126
139
  .action((name, opts) => {
140
+ if (opts.kind && !ALL_SYMBOL_KINDS.includes(opts.kind)) {
141
+ console.error(`Invalid kind "${opts.kind}". Valid: ${ALL_SYMBOL_KINDS.join(', ')}`);
142
+ process.exit(1);
143
+ }
127
144
  fnImpact(name, opts.db, {
128
145
  depth: parseInt(opts.depth, 10),
146
+ file: opts.file,
147
+ kind: opts.kind,
129
148
  noTests: !opts.tests,
130
149
  json: opts.json,
131
150
  });
@@ -136,13 +155,21 @@ program
136
155
  .description('Full context for a function: source, deps, callers, tests, signature')
137
156
  .option('-d, --db <path>', 'Path to graph.db')
138
157
  .option('--depth <n>', 'Include callee source up to N levels deep', '0')
158
+ .option('-f, --file <path>', 'Scope search to functions in this file (partial match)')
159
+ .option('-k, --kind <kind>', 'Filter to a specific symbol kind')
139
160
  .option('--no-source', 'Metadata only (skip source extraction)')
140
161
  .option('--include-tests', 'Include test source code')
141
162
  .option('-T, --no-tests', 'Exclude test files from callers')
142
163
  .option('-j, --json', 'Output as JSON')
143
164
  .action((name, opts) => {
165
+ if (opts.kind && !ALL_SYMBOL_KINDS.includes(opts.kind)) {
166
+ console.error(`Invalid kind "${opts.kind}". Valid: ${ALL_SYMBOL_KINDS.join(', ')}`);
167
+ process.exit(1);
168
+ }
144
169
  context(name, opts.db, {
145
170
  depth: parseInt(opts.depth, 10),
171
+ file: opts.file,
172
+ kind: opts.kind,
146
173
  noSource: !opts.source,
147
174
  noTests: !opts.tests,
148
175
  includeTests: opts.includeTests,
@@ -150,6 +177,32 @@ program
150
177
  });
151
178
  });
152
179
 
180
+ program
181
+ .command('explain <target>')
182
+ .description('Structural summary of a file or function (no LLM needed)')
183
+ .option('-d, --db <path>', 'Path to graph.db')
184
+ .option('-T, --no-tests', 'Exclude test/spec files')
185
+ .option('-j, --json', 'Output as JSON')
186
+ .action((target, opts) => {
187
+ explain(target, opts.db, { noTests: !opts.tests, json: opts.json });
188
+ });
189
+
190
+ program
191
+ .command('where [name]')
192
+ .description('Find where a symbol is defined and used (minimal, fast lookup)')
193
+ .option('-d, --db <path>', 'Path to graph.db')
194
+ .option('-f, --file <path>', 'File overview: list symbols, imports, exports')
195
+ .option('-T, --no-tests', 'Exclude test/spec files')
196
+ .option('-j, --json', 'Output as JSON')
197
+ .action((name, opts) => {
198
+ if (!name && !opts.file) {
199
+ console.error('Provide a symbol name or use --file <path>');
200
+ process.exit(1);
201
+ }
202
+ const target = opts.file || name;
203
+ where(target, opts.db, { file: !!opts.file, noTests: !opts.tests, json: opts.json });
204
+ });
205
+
153
206
  program
154
207
  .command('diff-impact [ref]')
155
208
  .description('Show impact of git changes (unstaged, staged, or vs a ref)')
@@ -140,6 +140,8 @@ export function extractSymbols(tree, _filePath) {
140
140
  calls.push(callInfo);
141
141
  }
142
142
  }
143
+ const cbDef = extractCallbackDefinition(node);
144
+ if (cbDef) definitions.push(cbDef);
143
145
  break;
144
146
  }
145
147
 
@@ -372,6 +374,126 @@ function extractCallInfo(fn, callNode) {
372
374
  return null;
373
375
  }
374
376
 
377
+ function findAnonymousCallback(argsNode) {
378
+ for (let i = 0; i < argsNode.childCount; i++) {
379
+ const child = argsNode.child(i);
380
+ if (child && (child.type === 'arrow_function' || child.type === 'function_expression')) {
381
+ return child;
382
+ }
383
+ }
384
+ return null;
385
+ }
386
+
387
+ function findFirstStringArg(argsNode) {
388
+ for (let i = 0; i < argsNode.childCount; i++) {
389
+ const child = argsNode.child(i);
390
+ if (child && child.type === 'string') {
391
+ return child.text.replace(/['"]/g, '');
392
+ }
393
+ }
394
+ return null;
395
+ }
396
+
397
+ function walkCallChain(startNode, methodName) {
398
+ let current = startNode;
399
+ while (current) {
400
+ if (current.type === 'call_expression') {
401
+ const fn = current.childForFieldName('function');
402
+ if (fn && fn.type === 'member_expression') {
403
+ const prop = fn.childForFieldName('property');
404
+ if (prop && prop.text === methodName) {
405
+ return current;
406
+ }
407
+ }
408
+ }
409
+ if (current.type === 'member_expression') {
410
+ const obj = current.childForFieldName('object');
411
+ current = obj;
412
+ } else if (current.type === 'call_expression') {
413
+ const fn = current.childForFieldName('function');
414
+ current = fn;
415
+ } else {
416
+ break;
417
+ }
418
+ }
419
+ return null;
420
+ }
421
+
422
+ const EXPRESS_METHODS = new Set([
423
+ 'get',
424
+ 'post',
425
+ 'put',
426
+ 'delete',
427
+ 'patch',
428
+ 'options',
429
+ 'head',
430
+ 'all',
431
+ 'use',
432
+ ]);
433
+ const EVENT_METHODS = new Set(['on', 'once', 'addEventListener', 'addListener']);
434
+
435
+ function extractCallbackDefinition(callNode) {
436
+ const fn = callNode.childForFieldName('function');
437
+ if (!fn || fn.type !== 'member_expression') return null;
438
+
439
+ const prop = fn.childForFieldName('property');
440
+ if (!prop) return null;
441
+ const method = prop.text;
442
+
443
+ const args = callNode.childForFieldName('arguments') || findChild(callNode, 'arguments');
444
+ if (!args) return null;
445
+
446
+ // Commander: .action(callback) with .command('name') in chain
447
+ if (method === 'action') {
448
+ const cb = findAnonymousCallback(args);
449
+ if (!cb) return null;
450
+ const commandCall = walkCallChain(fn.childForFieldName('object'), 'command');
451
+ if (!commandCall) return null;
452
+ const cmdArgs =
453
+ commandCall.childForFieldName('arguments') || findChild(commandCall, 'arguments');
454
+ if (!cmdArgs) return null;
455
+ const cmdName = findFirstStringArg(cmdArgs);
456
+ if (!cmdName) return null;
457
+ const firstWord = cmdName.split(/\s/)[0];
458
+ return {
459
+ name: `command:${firstWord}`,
460
+ kind: 'function',
461
+ line: cb.startPosition.row + 1,
462
+ endLine: nodeEndLine(cb),
463
+ };
464
+ }
465
+
466
+ // Express: app.get('/path', callback)
467
+ if (EXPRESS_METHODS.has(method)) {
468
+ const strArg = findFirstStringArg(args);
469
+ if (!strArg || !strArg.startsWith('/')) return null;
470
+ const cb = findAnonymousCallback(args);
471
+ if (!cb) return null;
472
+ return {
473
+ name: `route:${method.toUpperCase()} ${strArg}`,
474
+ kind: 'function',
475
+ line: cb.startPosition.row + 1,
476
+ endLine: nodeEndLine(cb),
477
+ };
478
+ }
479
+
480
+ // Events: emitter.on('event', callback)
481
+ if (EVENT_METHODS.has(method)) {
482
+ const eventName = findFirstStringArg(args);
483
+ if (!eventName) return null;
484
+ const cb = findAnonymousCallback(args);
485
+ if (!cb) return null;
486
+ return {
487
+ name: `event:${eventName}`,
488
+ kind: 'function',
489
+ line: cb.startPosition.row + 1,
490
+ endLine: nodeEndLine(cb),
491
+ };
492
+ }
493
+
494
+ return null;
495
+ }
496
+
375
497
  function extractSuperclass(heritage) {
376
498
  for (let i = 0; i < heritage.childCount; i++) {
377
499
  const child = heritage.child(i);
package/src/index.js CHANGED
@@ -38,8 +38,12 @@ export { isNativeAvailable } from './native.js';
38
38
  export { getActiveEngine, parseFileAuto, parseFilesAuto } from './parser.js';
39
39
  // Query functions (data-returning)
40
40
  export {
41
+ ALL_SYMBOL_KINDS,
41
42
  contextData,
42
43
  diffImpactData,
44
+ explainData,
45
+ FALSE_POSITIVE_CALLER_THRESHOLD,
46
+ FALSE_POSITIVE_NAMES,
43
47
  fileDepsData,
44
48
  fnDepsData,
45
49
  fnImpactData,
@@ -47,6 +51,7 @@ export {
47
51
  moduleMapData,
48
52
  queryNameData,
49
53
  statsData,
54
+ whereData,
50
55
  } from './queries.js';
51
56
  // Registry (multi-repo)
52
57
  export {
package/src/mcp.js CHANGED
@@ -8,6 +8,7 @@
8
8
  import { createRequire } from 'node:module';
9
9
  import { findCycles } from './cycles.js';
10
10
  import { findDbPath } from './db.js';
11
+ import { ALL_SYMBOL_KINDS } from './queries.js';
11
12
 
12
13
  const REPO_PROP = {
13
14
  repo: {
@@ -81,6 +82,15 @@ const BASE_TOOLS = [
81
82
  properties: {
82
83
  name: { type: 'string', description: 'Function/method/class name (partial match)' },
83
84
  depth: { type: 'number', description: 'Transitive caller depth', default: 3 },
85
+ file: {
86
+ type: 'string',
87
+ description: 'Scope search to functions in this file (partial match)',
88
+ },
89
+ kind: {
90
+ type: 'string',
91
+ enum: ALL_SYMBOL_KINDS,
92
+ description: 'Filter to a specific symbol kind',
93
+ },
84
94
  no_tests: { type: 'boolean', description: 'Exclude test files', default: false },
85
95
  },
86
96
  required: ['name'],
@@ -95,6 +105,15 @@ const BASE_TOOLS = [
95
105
  properties: {
96
106
  name: { type: 'string', description: 'Function/method/class name (partial match)' },
97
107
  depth: { type: 'number', description: 'Max traversal depth', default: 5 },
108
+ file: {
109
+ type: 'string',
110
+ description: 'Scope search to functions in this file (partial match)',
111
+ },
112
+ kind: {
113
+ type: 'string',
114
+ enum: ALL_SYMBOL_KINDS,
115
+ description: 'Filter to a specific symbol kind',
116
+ },
98
117
  no_tests: { type: 'boolean', description: 'Exclude test files', default: false },
99
118
  },
100
119
  required: ['name'],
@@ -113,6 +132,15 @@ const BASE_TOOLS = [
113
132
  description: 'Include callee source up to N levels deep (0=no source, 1=direct)',
114
133
  default: 0,
115
134
  },
135
+ file: {
136
+ type: 'string',
137
+ description: 'Scope search to functions in this file (partial match)',
138
+ },
139
+ kind: {
140
+ type: 'string',
141
+ enum: ALL_SYMBOL_KINDS,
142
+ description: 'Filter to a specific symbol kind',
143
+ },
116
144
  no_source: {
117
145
  type: 'boolean',
118
146
  description: 'Skip source extraction (metadata only)',
@@ -128,6 +156,37 @@ const BASE_TOOLS = [
128
156
  required: ['name'],
129
157
  },
130
158
  },
159
+ {
160
+ name: 'explain',
161
+ description:
162
+ 'Structural summary of a file or function: public/internal API, data flow, dependencies. No LLM needed.',
163
+ inputSchema: {
164
+ type: 'object',
165
+ properties: {
166
+ target: { type: 'string', description: 'File path or function name' },
167
+ no_tests: { type: 'boolean', description: 'Exclude test files', default: false },
168
+ },
169
+ required: ['target'],
170
+ },
171
+ },
172
+ {
173
+ name: 'where',
174
+ description:
175
+ 'Find where a symbol is defined and used, or list symbols/imports/exports for a file. Minimal, fast lookup.',
176
+ inputSchema: {
177
+ type: 'object',
178
+ properties: {
179
+ target: { type: 'string', description: 'Symbol name or file path' },
180
+ file_mode: {
181
+ type: 'boolean',
182
+ description: 'Treat target as file path (list symbols/imports/exports)',
183
+ default: false,
184
+ },
185
+ no_tests: { type: 'boolean', description: 'Exclude test files', default: false },
186
+ },
187
+ required: ['target'],
188
+ },
189
+ },
131
190
  {
132
191
  name: 'diff_impact',
133
192
  description: 'Analyze git diff to find which functions changed and their transitive callers',
@@ -299,6 +358,8 @@ export async function startMCPServer(customDbPath, options = {}) {
299
358
  fnDepsData,
300
359
  fnImpactData,
301
360
  contextData,
361
+ explainData,
362
+ whereData,
302
363
  diffImpactData,
303
364
  listFunctionsData,
304
365
  } = await import('./queries.js');
@@ -368,23 +429,38 @@ export async function startMCPServer(customDbPath, options = {}) {
368
429
  case 'fn_deps':
369
430
  result = fnDepsData(args.name, dbPath, {
370
431
  depth: args.depth,
432
+ file: args.file,
433
+ kind: args.kind,
371
434
  noTests: args.no_tests,
372
435
  });
373
436
  break;
374
437
  case 'fn_impact':
375
438
  result = fnImpactData(args.name, dbPath, {
376
439
  depth: args.depth,
440
+ file: args.file,
441
+ kind: args.kind,
377
442
  noTests: args.no_tests,
378
443
  });
379
444
  break;
380
445
  case 'context':
381
446
  result = contextData(args.name, dbPath, {
382
447
  depth: args.depth,
448
+ file: args.file,
449
+ kind: args.kind,
383
450
  noSource: args.no_source,
384
451
  noTests: args.no_tests,
385
452
  includeTests: args.include_tests,
386
453
  });
387
454
  break;
455
+ case 'explain':
456
+ result = explainData(args.target, dbPath, { noTests: args.no_tests });
457
+ break;
458
+ case 'where':
459
+ result = whereData(args.target, dbPath, {
460
+ file: args.file_mode,
461
+ noTests: args.no_tests,
462
+ });
463
+ break;
388
464
  case 'diff_impact':
389
465
  result = diffImpactData(dbPath, {
390
466
  staged: args.staged,
package/src/queries.js CHANGED
@@ -3,13 +3,70 @@ import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import { findCycles } from './cycles.js';
5
5
  import { findDbPath, openReadonlyOrFail } from './db.js';
6
+ import { debug } from './logger.js';
6
7
  import { LANGUAGE_REGISTRY } from './parser.js';
7
8
 
9
+ /**
10
+ * Resolve a file path relative to repoRoot, rejecting traversal outside the repo.
11
+ * Returns null if the resolved path escapes repoRoot.
12
+ */
13
+ function safePath(repoRoot, file) {
14
+ const resolved = path.resolve(repoRoot, file);
15
+ if (!resolved.startsWith(repoRoot + path.sep) && resolved !== repoRoot) return null;
16
+ return resolved;
17
+ }
18
+
8
19
  const TEST_PATTERN = /\.(test|spec)\.|__test__|__tests__|\.stories\./;
9
20
  function isTestFile(filePath) {
10
21
  return TEST_PATTERN.test(filePath);
11
22
  }
12
23
 
24
+ export const FALSE_POSITIVE_NAMES = new Set([
25
+ 'run',
26
+ 'get',
27
+ 'set',
28
+ 'init',
29
+ 'start',
30
+ 'handle',
31
+ 'main',
32
+ 'new',
33
+ 'create',
34
+ 'update',
35
+ 'delete',
36
+ 'process',
37
+ 'execute',
38
+ 'call',
39
+ 'apply',
40
+ 'setup',
41
+ 'render',
42
+ 'build',
43
+ 'load',
44
+ 'save',
45
+ 'find',
46
+ 'make',
47
+ 'open',
48
+ 'close',
49
+ 'reset',
50
+ 'send',
51
+ 'read',
52
+ 'write',
53
+ ]);
54
+ export const FALSE_POSITIVE_CALLER_THRESHOLD = 20;
55
+
56
+ const FUNCTION_KINDS = ['function', 'method', 'class'];
57
+ export const ALL_SYMBOL_KINDS = [
58
+ 'function',
59
+ 'method',
60
+ 'class',
61
+ 'interface',
62
+ 'type',
63
+ 'struct',
64
+ 'enum',
65
+ 'trait',
66
+ 'record',
67
+ 'module',
68
+ ];
69
+
13
70
  /**
14
71
  * Get all ancestor class names for a given class using extends edges.
15
72
  */
@@ -60,6 +117,58 @@ function resolveMethodViaHierarchy(db, methodName) {
60
117
  return results;
61
118
  }
62
119
 
120
+ /**
121
+ * Find nodes matching a name query, ranked by relevance.
122
+ * Scoring: exact=100, prefix=60, word-boundary=40, substring=10, plus fan-in tiebreaker.
123
+ */
124
+ function findMatchingNodes(db, name, opts = {}) {
125
+ const kinds = opts.kind ? [opts.kind] : FUNCTION_KINDS;
126
+ const placeholders = kinds.map(() => '?').join(', ');
127
+ const params = [`%${name}%`, ...kinds];
128
+
129
+ let fileCondition = '';
130
+ if (opts.file) {
131
+ fileCondition = ' AND n.file LIKE ?';
132
+ params.push(`%${opts.file}%`);
133
+ }
134
+
135
+ const rows = db
136
+ .prepare(`
137
+ SELECT n.*, COALESCE(fi.cnt, 0) AS fan_in
138
+ FROM nodes n
139
+ LEFT JOIN (
140
+ SELECT target_id, COUNT(*) AS cnt FROM edges WHERE kind = 'calls' GROUP BY target_id
141
+ ) fi ON fi.target_id = n.id
142
+ WHERE n.name LIKE ? AND n.kind IN (${placeholders})${fileCondition}
143
+ `)
144
+ .all(...params);
145
+
146
+ const nodes = opts.noTests ? rows.filter((n) => !isTestFile(n.file)) : rows;
147
+
148
+ const lowerQuery = name.toLowerCase();
149
+ for (const node of nodes) {
150
+ const lowerName = node.name.toLowerCase();
151
+ const bareName = lowerName.includes('.') ? lowerName.split('.').pop() : lowerName;
152
+
153
+ let matchScore;
154
+ if (lowerName === lowerQuery || bareName === lowerQuery) {
155
+ matchScore = 100;
156
+ } else if (lowerName.startsWith(lowerQuery) || bareName.startsWith(lowerQuery)) {
157
+ matchScore = 60;
158
+ } else if (lowerName.includes(`.${lowerQuery}`) || lowerName.includes(`${lowerQuery}.`)) {
159
+ matchScore = 40;
160
+ } else {
161
+ matchScore = 10;
162
+ }
163
+
164
+ const fanInBonus = Math.min(Math.log2(node.fan_in + 1) * 5, 25);
165
+ node._relevance = matchScore + fanInBonus;
166
+ }
167
+
168
+ nodes.sort((a, b) => b._relevance - a._relevance);
169
+ return nodes;
170
+ }
171
+
63
172
  function kindIcon(kind) {
64
173
  switch (kind) {
65
174
  case 'function':
@@ -132,8 +241,9 @@ export function queryNameData(name, customDbPath) {
132
241
  return { query: name, results };
133
242
  }
134
243
 
135
- export function impactAnalysisData(file, customDbPath) {
244
+ export function impactAnalysisData(file, customDbPath, opts = {}) {
136
245
  const db = openReadonlyOrFail(customDbPath);
246
+ const noTests = opts.noTests || false;
137
247
  const fileNodes = db
138
248
  .prepare(`SELECT * FROM nodes WHERE file LIKE ? AND kind = 'file'`)
139
249
  .all(`%${file}%`);
@@ -162,7 +272,7 @@ export function impactAnalysisData(file, customDbPath) {
162
272
  `)
163
273
  .all(current);
164
274
  for (const dep of dependents) {
165
- if (!visited.has(dep.id)) {
275
+ if (!visited.has(dep.id) && (!noTests || !isTestFile(dep.file))) {
166
276
  visited.add(dep.id);
167
277
  queue.push(dep.id);
168
278
  levels.set(dep.id, level + 1);
@@ -187,8 +297,17 @@ export function impactAnalysisData(file, customDbPath) {
187
297
  };
188
298
  }
189
299
 
190
- export function moduleMapData(customDbPath, limit = 20) {
300
+ export function moduleMapData(customDbPath, limit = 20, opts = {}) {
191
301
  const db = openReadonlyOrFail(customDbPath);
302
+ const noTests = opts.noTests || false;
303
+
304
+ const testFilter = noTests
305
+ ? `AND n.file NOT LIKE '%.test.%'
306
+ AND n.file NOT LIKE '%.spec.%'
307
+ AND n.file NOT LIKE '%__test__%'
308
+ AND n.file NOT LIKE '%__tests__%'
309
+ AND n.file NOT LIKE '%.stories.%'`
310
+ : '';
192
311
 
193
312
  const nodes = db
194
313
  .prepare(`
@@ -197,9 +316,7 @@ export function moduleMapData(customDbPath, limit = 20) {
197
316
  (SELECT COUNT(*) FROM edges WHERE target_id = n.id AND kind != 'contains') as in_edges
198
317
  FROM nodes n
199
318
  WHERE n.kind = 'file'
200
- AND n.file NOT LIKE '%.test.%'
201
- AND n.file NOT LIKE '%.spec.%'
202
- AND n.file NOT LIKE '%__test__%'
319
+ ${testFilter}
203
320
  ORDER BY (SELECT COUNT(*) FROM edges WHERE target_id = n.id AND kind != 'contains') DESC
204
321
  LIMIT ?
205
322
  `)
@@ -220,8 +337,9 @@ export function moduleMapData(customDbPath, limit = 20) {
220
337
  return { limit, topNodes, stats: { totalFiles, totalNodes, totalEdges } };
221
338
  }
222
339
 
223
- export function fileDepsData(file, customDbPath) {
340
+ export function fileDepsData(file, customDbPath, opts = {}) {
224
341
  const db = openReadonlyOrFail(customDbPath);
342
+ const noTests = opts.noTests || false;
225
343
  const fileNodes = db
226
344
  .prepare(`SELECT * FROM nodes WHERE file LIKE ? AND kind = 'file'`)
227
345
  .all(`%${file}%`);
@@ -231,19 +349,21 @@ export function fileDepsData(file, customDbPath) {
231
349
  }
232
350
 
233
351
  const results = fileNodes.map((fn) => {
234
- const importsTo = db
352
+ let importsTo = db
235
353
  .prepare(`
236
354
  SELECT n.file, e.kind as edge_kind FROM edges e JOIN nodes n ON e.target_id = n.id
237
355
  WHERE e.source_id = ? AND e.kind IN ('imports', 'imports-type')
238
356
  `)
239
357
  .all(fn.id);
358
+ if (noTests) importsTo = importsTo.filter((i) => !isTestFile(i.file));
240
359
 
241
- const importedBy = db
360
+ let importedBy = db
242
361
  .prepare(`
243
362
  SELECT n.file, e.kind as edge_kind FROM edges e JOIN nodes n ON e.source_id = n.id
244
363
  WHERE e.target_id = ? AND e.kind IN ('imports', 'imports-type')
245
364
  `)
246
365
  .all(fn.id);
366
+ if (noTests) importedBy = importedBy.filter((i) => !isTestFile(i.file));
247
367
 
248
368
  const defs = db
249
369
  .prepare(`SELECT * FROM nodes WHERE file = ? AND kind != 'file' ORDER BY line`)
@@ -266,12 +386,7 @@ export function fnDepsData(name, customDbPath, opts = {}) {
266
386
  const depth = opts.depth || 3;
267
387
  const noTests = opts.noTests || false;
268
388
 
269
- let nodes = db
270
- .prepare(
271
- `SELECT * FROM nodes WHERE name LIKE ? AND kind IN ('function', 'method', 'class') ORDER BY file, line`,
272
- )
273
- .all(`%${name}%`);
274
- if (noTests) nodes = nodes.filter((n) => !isTestFile(n.file));
389
+ const nodes = findMatchingNodes(db, name, { noTests, file: opts.file, kind: opts.kind });
275
390
  if (nodes.length === 0) {
276
391
  db.close();
277
392
  return { name, results: [] };
@@ -391,10 +506,7 @@ export function fnImpactData(name, customDbPath, opts = {}) {
391
506
  const maxDepth = opts.depth || 5;
392
507
  const noTests = opts.noTests || false;
393
508
 
394
- let nodes = db
395
- .prepare(`SELECT * FROM nodes WHERE name LIKE ? AND kind IN ('function', 'method', 'class')`)
396
- .all(`%${name}%`);
397
- if (noTests) nodes = nodes.filter((n) => !isTestFile(n.file));
509
+ const nodes = findMatchingNodes(db, name, { noTests, file: opts.file, kind: opts.kind });
398
510
  if (nodes.length === 0) {
399
511
  db.close();
400
512
  return { name, results: [] };
@@ -695,6 +807,67 @@ export function statsData(customDbPath) {
695
807
  /* embeddings table may not exist */
696
808
  }
697
809
 
810
+ // Graph quality metrics
811
+ const totalCallable = db
812
+ .prepare("SELECT COUNT(*) as c FROM nodes WHERE kind IN ('function', 'method')")
813
+ .get().c;
814
+ const callableWithCallers = db
815
+ .prepare(`
816
+ SELECT COUNT(DISTINCT e.target_id) as c FROM edges e
817
+ JOIN nodes n ON e.target_id = n.id
818
+ WHERE e.kind = 'calls' AND n.kind IN ('function', 'method')
819
+ `)
820
+ .get().c;
821
+ const callerCoverage = totalCallable > 0 ? callableWithCallers / totalCallable : 0;
822
+
823
+ const totalCallEdges = db.prepare("SELECT COUNT(*) as c FROM edges WHERE kind = 'calls'").get().c;
824
+ const highConfCallEdges = db
825
+ .prepare("SELECT COUNT(*) as c FROM edges WHERE kind = 'calls' AND confidence >= 0.7")
826
+ .get().c;
827
+ const callConfidence = totalCallEdges > 0 ? highConfCallEdges / totalCallEdges : 0;
828
+
829
+ // False-positive warnings: generic names with > threshold callers
830
+ const fpRows = db
831
+ .prepare(`
832
+ SELECT n.name, n.file, n.line, COUNT(e.source_id) as caller_count
833
+ FROM nodes n
834
+ LEFT JOIN edges e ON n.id = e.target_id AND e.kind = 'calls'
835
+ WHERE n.kind IN ('function', 'method')
836
+ GROUP BY n.id
837
+ HAVING caller_count > ?
838
+ ORDER BY caller_count DESC
839
+ `)
840
+ .all(FALSE_POSITIVE_CALLER_THRESHOLD);
841
+ const falsePositiveWarnings = fpRows
842
+ .filter((r) =>
843
+ FALSE_POSITIVE_NAMES.has(r.name.includes('.') ? r.name.split('.').pop() : r.name),
844
+ )
845
+ .map((r) => ({ name: r.name, file: r.file, line: r.line, callerCount: r.caller_count }));
846
+
847
+ // Edges from suspicious nodes
848
+ let fpEdgeCount = 0;
849
+ for (const fp of falsePositiveWarnings) fpEdgeCount += fp.callerCount;
850
+ const falsePositiveRatio = totalCallEdges > 0 ? fpEdgeCount / totalCallEdges : 0;
851
+
852
+ const score = Math.round(
853
+ callerCoverage * 40 + callConfidence * 40 + (1 - falsePositiveRatio) * 20,
854
+ );
855
+
856
+ const quality = {
857
+ score,
858
+ callerCoverage: {
859
+ ratio: callerCoverage,
860
+ covered: callableWithCallers,
861
+ total: totalCallable,
862
+ },
863
+ callConfidence: {
864
+ ratio: callConfidence,
865
+ highConf: highConfCallEdges,
866
+ total: totalCallEdges,
867
+ },
868
+ falsePositiveWarnings,
869
+ };
870
+
698
871
  db.close();
699
872
  return {
700
873
  nodes: { total: totalNodes, byKind: nodesByKind },
@@ -703,6 +876,7 @@ export function statsData(customDbPath) {
703
876
  cycles: { fileLevel: fileCycles.length, functionLevel: fnCycles.length },
704
877
  hotspots,
705
878
  embeddings,
879
+ quality,
706
880
  };
707
881
  }
708
882
 
@@ -779,6 +953,26 @@ export function stats(customDbPath, opts = {}) {
779
953
  console.log('\nEmbeddings: not built');
780
954
  }
781
955
 
956
+ // Quality
957
+ if (data.quality) {
958
+ const q = data.quality;
959
+ const cc = q.callerCoverage;
960
+ const cf = q.callConfidence;
961
+ console.log(`\nGraph Quality: ${q.score}/100`);
962
+ console.log(
963
+ ` Caller coverage: ${(cc.ratio * 100).toFixed(1)}% (${cc.covered}/${cc.total} functions have >=1 caller)`,
964
+ );
965
+ console.log(
966
+ ` Call confidence: ${(cf.ratio * 100).toFixed(1)}% (${cf.highConf}/${cf.total} call edges are high-confidence)`,
967
+ );
968
+ if (q.falsePositiveWarnings.length > 0) {
969
+ console.log(' False-positive warnings:');
970
+ for (const fp of q.falsePositiveWarnings) {
971
+ console.log(` ! ${fp.name} (${fp.callerCount} callers) -- ${fp.file}:${fp.line}`);
972
+ }
973
+ }
974
+ }
975
+
782
976
  console.log();
783
977
  }
784
978
 
@@ -815,7 +1009,7 @@ export function queryName(name, customDbPath, opts = {}) {
815
1009
  }
816
1010
 
817
1011
  export function impactAnalysis(file, customDbPath, opts = {}) {
818
- const data = impactAnalysisData(file, customDbPath);
1012
+ const data = impactAnalysisData(file, customDbPath, { noTests: opts.noTests });
819
1013
  if (opts.json) {
820
1014
  console.log(JSON.stringify(data, null, 2));
821
1015
  return;
@@ -846,7 +1040,7 @@ export function impactAnalysis(file, customDbPath, opts = {}) {
846
1040
  }
847
1041
 
848
1042
  export function moduleMap(customDbPath, limit = 20, opts = {}) {
849
- const data = moduleMapData(customDbPath, limit);
1043
+ const data = moduleMapData(customDbPath, limit, { noTests: opts.noTests });
850
1044
  if (opts.json) {
851
1045
  console.log(JSON.stringify(data, null, 2));
852
1046
  return;
@@ -874,7 +1068,7 @@ export function moduleMap(customDbPath, limit = 20, opts = {}) {
874
1068
  }
875
1069
 
876
1070
  export function fileDeps(file, customDbPath, opts = {}) {
877
- const data = fileDepsData(file, customDbPath);
1071
+ const data = fileDepsData(file, customDbPath, { noTests: opts.noTests });
878
1072
  if (opts.json) {
879
1073
  console.log(JSON.stringify(data, null, 2));
880
1074
  return;
@@ -949,13 +1143,15 @@ export function fnDeps(name, customDbPath, opts = {}) {
949
1143
 
950
1144
  function readSourceRange(repoRoot, file, startLine, endLine) {
951
1145
  try {
952
- const absPath = path.resolve(repoRoot, file);
1146
+ const absPath = safePath(repoRoot, file);
1147
+ if (!absPath) return null;
953
1148
  const content = fs.readFileSync(absPath, 'utf-8');
954
1149
  const lines = content.split('\n');
955
1150
  const start = Math.max(0, (startLine || 1) - 1);
956
1151
  const end = Math.min(lines.length, endLine || startLine + 50);
957
1152
  return lines.slice(start, end).join('\n');
958
- } catch {
1153
+ } catch (e) {
1154
+ debug(`readSourceRange failed for ${file}: ${e.message}`);
959
1155
  return null;
960
1156
  }
961
1157
  }
@@ -1065,12 +1261,7 @@ export function contextData(name, customDbPath, opts = {}) {
1065
1261
  const dbPath = findDbPath(customDbPath);
1066
1262
  const repoRoot = path.resolve(path.dirname(dbPath), '..');
1067
1263
 
1068
- let nodes = db
1069
- .prepare(
1070
- `SELECT * FROM nodes WHERE name LIKE ? AND kind IN ('function', 'method', 'class') ORDER BY file, line`,
1071
- )
1072
- .all(`%${name}%`);
1073
- if (noTests) nodes = nodes.filter((n) => !isTestFile(n.file));
1264
+ let nodes = findMatchingNodes(db, name, { noTests, file: opts.file, kind: opts.kind });
1074
1265
  if (nodes.length === 0) {
1075
1266
  db.close();
1076
1267
  return { name, results: [] };
@@ -1084,11 +1275,16 @@ export function contextData(name, customDbPath, opts = {}) {
1084
1275
  function getFileLines(file) {
1085
1276
  if (fileCache.has(file)) return fileCache.get(file);
1086
1277
  try {
1087
- const absPath = path.resolve(repoRoot, file);
1278
+ const absPath = safePath(repoRoot, file);
1279
+ if (!absPath) {
1280
+ fileCache.set(file, null);
1281
+ return null;
1282
+ }
1088
1283
  const lines = fs.readFileSync(absPath, 'utf-8').split('\n');
1089
1284
  fileCache.set(file, lines);
1090
1285
  return lines;
1091
- } catch {
1286
+ } catch (e) {
1287
+ debug(`getFileLines failed for ${file}: ${e.message}`);
1092
1288
  fileCache.set(file, null);
1093
1289
  return null;
1094
1290
  }
@@ -1341,6 +1537,461 @@ export function context(name, customDbPath, opts = {}) {
1341
1537
  }
1342
1538
  }
1343
1539
 
1540
+ // ─── explainData ────────────────────────────────────────────────────────
1541
+
1542
+ function isFileLikeTarget(target) {
1543
+ if (target.includes('/') || target.includes('\\')) return true;
1544
+ const ext = path.extname(target).toLowerCase();
1545
+ if (!ext) return false;
1546
+ for (const entry of LANGUAGE_REGISTRY) {
1547
+ if (entry.extensions.includes(ext)) return true;
1548
+ }
1549
+ return false;
1550
+ }
1551
+
1552
+ function explainFileImpl(db, target, getFileLines) {
1553
+ const fileNodes = db
1554
+ .prepare(`SELECT * FROM nodes WHERE file LIKE ? AND kind = 'file'`)
1555
+ .all(`%${target}%`);
1556
+ if (fileNodes.length === 0) return [];
1557
+
1558
+ return fileNodes.map((fn) => {
1559
+ const symbols = db
1560
+ .prepare(`SELECT * FROM nodes WHERE file = ? AND kind != 'file' ORDER BY line`)
1561
+ .all(fn.file);
1562
+
1563
+ // IDs of symbols that have incoming calls from other files (public)
1564
+ const publicIds = new Set(
1565
+ db
1566
+ .prepare(
1567
+ `SELECT DISTINCT e.target_id FROM edges e
1568
+ JOIN nodes caller ON e.source_id = caller.id
1569
+ JOIN nodes target ON e.target_id = target.id
1570
+ WHERE target.file = ? AND caller.file != ? AND e.kind = 'calls'`,
1571
+ )
1572
+ .all(fn.file, fn.file)
1573
+ .map((r) => r.target_id),
1574
+ );
1575
+
1576
+ const fileLines = getFileLines(fn.file);
1577
+ const mapSymbol = (s) => ({
1578
+ name: s.name,
1579
+ kind: s.kind,
1580
+ line: s.line,
1581
+ summary: fileLines ? extractSummary(fileLines, s.line) : null,
1582
+ signature: fileLines ? extractSignature(fileLines, s.line) : null,
1583
+ });
1584
+
1585
+ const publicApi = symbols.filter((s) => publicIds.has(s.id)).map(mapSymbol);
1586
+ const internal = symbols.filter((s) => !publicIds.has(s.id)).map(mapSymbol);
1587
+
1588
+ // Imports / importedBy
1589
+ const imports = db
1590
+ .prepare(
1591
+ `SELECT n.file FROM edges e JOIN nodes n ON e.target_id = n.id
1592
+ WHERE e.source_id = ? AND e.kind IN ('imports', 'imports-type')`,
1593
+ )
1594
+ .all(fn.id)
1595
+ .map((r) => ({ file: r.file }));
1596
+
1597
+ const importedBy = db
1598
+ .prepare(
1599
+ `SELECT n.file FROM edges e JOIN nodes n ON e.source_id = n.id
1600
+ WHERE e.target_id = ? AND e.kind IN ('imports', 'imports-type')`,
1601
+ )
1602
+ .all(fn.id)
1603
+ .map((r) => ({ file: r.file }));
1604
+
1605
+ // Intra-file data flow
1606
+ const intraEdges = db
1607
+ .prepare(
1608
+ `SELECT caller.name as caller_name, callee.name as callee_name
1609
+ FROM edges e
1610
+ JOIN nodes caller ON e.source_id = caller.id
1611
+ JOIN nodes callee ON e.target_id = callee.id
1612
+ WHERE caller.file = ? AND callee.file = ? AND e.kind = 'calls'
1613
+ ORDER BY caller.line`,
1614
+ )
1615
+ .all(fn.file, fn.file);
1616
+
1617
+ const dataFlowMap = new Map();
1618
+ for (const edge of intraEdges) {
1619
+ if (!dataFlowMap.has(edge.caller_name)) dataFlowMap.set(edge.caller_name, []);
1620
+ dataFlowMap.get(edge.caller_name).push(edge.callee_name);
1621
+ }
1622
+ const dataFlow = [...dataFlowMap.entries()].map(([caller, callees]) => ({
1623
+ caller,
1624
+ callees,
1625
+ }));
1626
+
1627
+ // Line count: prefer node_metrics (actual), fall back to MAX(end_line)
1628
+ const metric = db
1629
+ .prepare(`SELECT nm.line_count FROM node_metrics nm WHERE nm.node_id = ?`)
1630
+ .get(fn.id);
1631
+ let lineCount = metric?.line_count || null;
1632
+ if (!lineCount) {
1633
+ const maxLine = db
1634
+ .prepare(`SELECT MAX(end_line) as max_end FROM nodes WHERE file = ?`)
1635
+ .get(fn.file);
1636
+ lineCount = maxLine?.max_end || null;
1637
+ }
1638
+
1639
+ return {
1640
+ file: fn.file,
1641
+ lineCount,
1642
+ symbolCount: symbols.length,
1643
+ publicApi,
1644
+ internal,
1645
+ imports,
1646
+ importedBy,
1647
+ dataFlow,
1648
+ };
1649
+ });
1650
+ }
1651
+
1652
+ function explainFunctionImpl(db, target, noTests, getFileLines) {
1653
+ let nodes = db
1654
+ .prepare(
1655
+ `SELECT * FROM nodes WHERE name LIKE ? AND kind IN ('function','method','class','interface','type','struct','enum','trait','record','module') ORDER BY file, line`,
1656
+ )
1657
+ .all(`%${target}%`);
1658
+ if (noTests) nodes = nodes.filter((n) => !isTestFile(n.file));
1659
+ if (nodes.length === 0) return [];
1660
+
1661
+ return nodes.slice(0, 10).map((node) => {
1662
+ const fileLines = getFileLines(node.file);
1663
+ const lineCount = node.end_line ? node.end_line - node.line + 1 : null;
1664
+ const summary = fileLines ? extractSummary(fileLines, node.line) : null;
1665
+ const signature = fileLines ? extractSignature(fileLines, node.line) : null;
1666
+
1667
+ const callees = db
1668
+ .prepare(
1669
+ `SELECT n.name, n.kind, n.file, n.line
1670
+ FROM edges e JOIN nodes n ON e.target_id = n.id
1671
+ WHERE e.source_id = ? AND e.kind = 'calls'`,
1672
+ )
1673
+ .all(node.id)
1674
+ .map((c) => ({ name: c.name, kind: c.kind, file: c.file, line: c.line }));
1675
+
1676
+ let callers = db
1677
+ .prepare(
1678
+ `SELECT n.name, n.kind, n.file, n.line
1679
+ FROM edges e JOIN nodes n ON e.source_id = n.id
1680
+ WHERE e.target_id = ? AND e.kind = 'calls'`,
1681
+ )
1682
+ .all(node.id)
1683
+ .map((c) => ({ name: c.name, kind: c.kind, file: c.file, line: c.line }));
1684
+ if (noTests) callers = callers.filter((c) => !isTestFile(c.file));
1685
+
1686
+ const testCallerRows = db
1687
+ .prepare(
1688
+ `SELECT DISTINCT n.file FROM edges e JOIN nodes n ON e.source_id = n.id
1689
+ WHERE e.target_id = ? AND e.kind = 'calls'`,
1690
+ )
1691
+ .all(node.id);
1692
+ const relatedTests = testCallerRows
1693
+ .filter((r) => isTestFile(r.file))
1694
+ .map((r) => ({ file: r.file }));
1695
+
1696
+ return {
1697
+ name: node.name,
1698
+ kind: node.kind,
1699
+ file: node.file,
1700
+ line: node.line,
1701
+ endLine: node.end_line || null,
1702
+ lineCount,
1703
+ summary,
1704
+ signature,
1705
+ callees,
1706
+ callers,
1707
+ relatedTests,
1708
+ };
1709
+ });
1710
+ }
1711
+
1712
+ export function explainData(target, customDbPath, opts = {}) {
1713
+ const db = openReadonlyOrFail(customDbPath);
1714
+ const noTests = opts.noTests || false;
1715
+ const kind = isFileLikeTarget(target) ? 'file' : 'function';
1716
+
1717
+ const dbPath = findDbPath(customDbPath);
1718
+ const repoRoot = path.resolve(path.dirname(dbPath), '..');
1719
+
1720
+ const fileCache = new Map();
1721
+ function getFileLines(file) {
1722
+ if (fileCache.has(file)) return fileCache.get(file);
1723
+ try {
1724
+ const absPath = safePath(repoRoot, file);
1725
+ if (!absPath) {
1726
+ fileCache.set(file, null);
1727
+ return null;
1728
+ }
1729
+ const lines = fs.readFileSync(absPath, 'utf-8').split('\n');
1730
+ fileCache.set(file, lines);
1731
+ return lines;
1732
+ } catch (e) {
1733
+ debug(`getFileLines failed for ${file}: ${e.message}`);
1734
+ fileCache.set(file, null);
1735
+ return null;
1736
+ }
1737
+ }
1738
+
1739
+ const results =
1740
+ kind === 'file'
1741
+ ? explainFileImpl(db, target, getFileLines)
1742
+ : explainFunctionImpl(db, target, noTests, getFileLines);
1743
+
1744
+ db.close();
1745
+ return { target, kind, results };
1746
+ }
1747
+
1748
+ export function explain(target, customDbPath, opts = {}) {
1749
+ const data = explainData(target, customDbPath, opts);
1750
+ if (opts.json) {
1751
+ console.log(JSON.stringify(data, null, 2));
1752
+ return;
1753
+ }
1754
+ if (data.results.length === 0) {
1755
+ console.log(`No ${data.kind === 'file' ? 'file' : 'function/symbol'} matching "${target}"`);
1756
+ return;
1757
+ }
1758
+
1759
+ if (data.kind === 'file') {
1760
+ for (const r of data.results) {
1761
+ const publicCount = r.publicApi.length;
1762
+ const internalCount = r.internal.length;
1763
+ const lineInfo = r.lineCount ? `${r.lineCount} lines, ` : '';
1764
+ console.log(`\n# ${r.file}`);
1765
+ console.log(
1766
+ ` ${lineInfo}${r.symbolCount} symbols (${publicCount} exported, ${internalCount} internal)`,
1767
+ );
1768
+
1769
+ if (r.imports.length > 0) {
1770
+ console.log(` Imports: ${r.imports.map((i) => i.file).join(', ')}`);
1771
+ }
1772
+ if (r.importedBy.length > 0) {
1773
+ console.log(` Imported by: ${r.importedBy.map((i) => i.file).join(', ')}`);
1774
+ }
1775
+
1776
+ if (r.publicApi.length > 0) {
1777
+ console.log(`\n## Exported`);
1778
+ for (const s of r.publicApi) {
1779
+ const sig = s.signature?.params != null ? `(${s.signature.params})` : '';
1780
+ const summary = s.summary ? ` -- ${s.summary}` : '';
1781
+ console.log(` ${kindIcon(s.kind)} ${s.name}${sig} :${s.line}${summary}`);
1782
+ }
1783
+ }
1784
+
1785
+ if (r.internal.length > 0) {
1786
+ console.log(`\n## Internal`);
1787
+ for (const s of r.internal) {
1788
+ const sig = s.signature?.params != null ? `(${s.signature.params})` : '';
1789
+ const summary = s.summary ? ` -- ${s.summary}` : '';
1790
+ console.log(` ${kindIcon(s.kind)} ${s.name}${sig} :${s.line}${summary}`);
1791
+ }
1792
+ }
1793
+
1794
+ if (r.dataFlow.length > 0) {
1795
+ console.log(`\n## Data Flow`);
1796
+ for (const df of r.dataFlow) {
1797
+ console.log(` ${df.caller} -> ${df.callees.join(', ')}`);
1798
+ }
1799
+ }
1800
+ console.log();
1801
+ }
1802
+ } else {
1803
+ for (const r of data.results) {
1804
+ const lineRange = r.endLine ? `${r.line}-${r.endLine}` : `${r.line}`;
1805
+ const lineInfo = r.lineCount ? `${r.lineCount} lines` : '';
1806
+ const summaryPart = r.summary ? ` | ${r.summary}` : '';
1807
+ console.log(`\n# ${r.name} (${r.kind}) ${r.file}:${lineRange}`);
1808
+ if (lineInfo || r.summary) {
1809
+ console.log(` ${lineInfo}${summaryPart}`);
1810
+ }
1811
+ if (r.signature) {
1812
+ if (r.signature.params != null) console.log(` Parameters: (${r.signature.params})`);
1813
+ if (r.signature.returnType) console.log(` Returns: ${r.signature.returnType}`);
1814
+ }
1815
+
1816
+ if (r.callees.length > 0) {
1817
+ console.log(`\n## Calls (${r.callees.length})`);
1818
+ for (const c of r.callees) {
1819
+ console.log(` ${kindIcon(c.kind)} ${c.name} ${c.file}:${c.line}`);
1820
+ }
1821
+ }
1822
+
1823
+ if (r.callers.length > 0) {
1824
+ console.log(`\n## Called by (${r.callers.length})`);
1825
+ for (const c of r.callers) {
1826
+ console.log(` ${kindIcon(c.kind)} ${c.name} ${c.file}:${c.line}`);
1827
+ }
1828
+ }
1829
+
1830
+ if (r.relatedTests.length > 0) {
1831
+ const label = r.relatedTests.length === 1 ? 'file' : 'files';
1832
+ console.log(`\n## Tests (${r.relatedTests.length} ${label})`);
1833
+ for (const t of r.relatedTests) {
1834
+ console.log(` ${t.file}`);
1835
+ }
1836
+ }
1837
+
1838
+ if (r.callees.length === 0 && r.callers.length === 0) {
1839
+ console.log(` (no call edges found -- may be invoked dynamically or via re-exports)`);
1840
+ }
1841
+ console.log();
1842
+ }
1843
+ }
1844
+ }
1845
+
1846
+ // ─── whereData ──────────────────────────────────────────────────────────
1847
+
1848
+ function whereSymbolImpl(db, target, noTests) {
1849
+ const placeholders = ALL_SYMBOL_KINDS.map(() => '?').join(', ');
1850
+ let nodes = db
1851
+ .prepare(
1852
+ `SELECT * FROM nodes WHERE name LIKE ? AND kind IN (${placeholders}) ORDER BY file, line`,
1853
+ )
1854
+ .all(`%${target}%`, ...ALL_SYMBOL_KINDS);
1855
+ if (noTests) nodes = nodes.filter((n) => !isTestFile(n.file));
1856
+
1857
+ return nodes.map((node) => {
1858
+ const crossFileCallers = db
1859
+ .prepare(
1860
+ `SELECT COUNT(*) as cnt FROM edges e JOIN nodes n ON e.source_id = n.id
1861
+ WHERE e.target_id = ? AND e.kind = 'calls' AND n.file != ?`,
1862
+ )
1863
+ .get(node.id, node.file);
1864
+ const exported = crossFileCallers.cnt > 0;
1865
+
1866
+ let uses = db
1867
+ .prepare(
1868
+ `SELECT n.name, n.file, n.line FROM edges e JOIN nodes n ON e.source_id = n.id
1869
+ WHERE e.target_id = ? AND e.kind = 'calls'`,
1870
+ )
1871
+ .all(node.id);
1872
+ if (noTests) uses = uses.filter((u) => !isTestFile(u.file));
1873
+
1874
+ return {
1875
+ name: node.name,
1876
+ kind: node.kind,
1877
+ file: node.file,
1878
+ line: node.line,
1879
+ exported,
1880
+ uses: uses.map((u) => ({ name: u.name, file: u.file, line: u.line })),
1881
+ };
1882
+ });
1883
+ }
1884
+
1885
+ function whereFileImpl(db, target) {
1886
+ const fileNodes = db
1887
+ .prepare(`SELECT * FROM nodes WHERE file LIKE ? AND kind = 'file'`)
1888
+ .all(`%${target}%`);
1889
+ if (fileNodes.length === 0) return [];
1890
+
1891
+ return fileNodes.map((fn) => {
1892
+ const symbols = db
1893
+ .prepare(`SELECT * FROM nodes WHERE file = ? AND kind != 'file' ORDER BY line`)
1894
+ .all(fn.file);
1895
+
1896
+ const imports = db
1897
+ .prepare(
1898
+ `SELECT n.file FROM edges e JOIN nodes n ON e.target_id = n.id
1899
+ WHERE e.source_id = ? AND e.kind IN ('imports', 'imports-type')`,
1900
+ )
1901
+ .all(fn.id)
1902
+ .map((r) => r.file);
1903
+
1904
+ const importedBy = db
1905
+ .prepare(
1906
+ `SELECT n.file FROM edges e JOIN nodes n ON e.source_id = n.id
1907
+ WHERE e.target_id = ? AND e.kind IN ('imports', 'imports-type')`,
1908
+ )
1909
+ .all(fn.id)
1910
+ .map((r) => r.file);
1911
+
1912
+ const exportedIds = new Set(
1913
+ db
1914
+ .prepare(
1915
+ `SELECT DISTINCT e.target_id FROM edges e
1916
+ JOIN nodes caller ON e.source_id = caller.id
1917
+ JOIN nodes target ON e.target_id = target.id
1918
+ WHERE target.file = ? AND caller.file != ? AND e.kind = 'calls'`,
1919
+ )
1920
+ .all(fn.file, fn.file)
1921
+ .map((r) => r.target_id),
1922
+ );
1923
+
1924
+ const exported = symbols.filter((s) => exportedIds.has(s.id)).map((s) => s.name);
1925
+
1926
+ return {
1927
+ file: fn.file,
1928
+ symbols: symbols.map((s) => ({ name: s.name, kind: s.kind, line: s.line })),
1929
+ imports,
1930
+ importedBy,
1931
+ exported,
1932
+ };
1933
+ });
1934
+ }
1935
+
1936
+ export function whereData(target, customDbPath, opts = {}) {
1937
+ const db = openReadonlyOrFail(customDbPath);
1938
+ const noTests = opts.noTests || false;
1939
+ const fileMode = opts.file || false;
1940
+
1941
+ const results = fileMode ? whereFileImpl(db, target) : whereSymbolImpl(db, target, noTests);
1942
+
1943
+ db.close();
1944
+ return { target, mode: fileMode ? 'file' : 'symbol', results };
1945
+ }
1946
+
1947
+ export function where(target, customDbPath, opts = {}) {
1948
+ const data = whereData(target, customDbPath, opts);
1949
+ if (opts.json) {
1950
+ console.log(JSON.stringify(data, null, 2));
1951
+ return;
1952
+ }
1953
+
1954
+ if (data.results.length === 0) {
1955
+ console.log(
1956
+ data.mode === 'file'
1957
+ ? `No file matching "${target}" in graph`
1958
+ : `No symbol matching "${target}" in graph`,
1959
+ );
1960
+ return;
1961
+ }
1962
+
1963
+ if (data.mode === 'symbol') {
1964
+ for (const r of data.results) {
1965
+ const tag = r.exported ? ' (exported)' : '';
1966
+ console.log(`\n${kindIcon(r.kind)} ${r.name} ${r.file}:${r.line}${tag}`);
1967
+ if (r.uses.length > 0) {
1968
+ const useStrs = r.uses.map((u) => `${u.file}:${u.line}`);
1969
+ console.log(` Used in: ${useStrs.join(', ')}`);
1970
+ } else {
1971
+ console.log(' No uses found');
1972
+ }
1973
+ }
1974
+ } else {
1975
+ for (const r of data.results) {
1976
+ console.log(`\n# ${r.file}`);
1977
+ if (r.symbols.length > 0) {
1978
+ const symStrs = r.symbols.map((s) => `${s.name}:${s.line}`);
1979
+ console.log(` Symbols: ${symStrs.join(', ')}`);
1980
+ }
1981
+ if (r.imports.length > 0) {
1982
+ console.log(` Imports: ${r.imports.join(', ')}`);
1983
+ }
1984
+ if (r.importedBy.length > 0) {
1985
+ console.log(` Imported by: ${r.importedBy.join(', ')}`);
1986
+ }
1987
+ if (r.exported.length > 0) {
1988
+ console.log(` Exported: ${r.exported.join(', ')}`);
1989
+ }
1990
+ }
1991
+ }
1992
+ console.log();
1993
+ }
1994
+
1344
1995
  export function fnImpact(name, customDbPath, opts = {}) {
1345
1996
  const data = fnImpactData(name, customDbPath, opts);
1346
1997
  if (opts.json) {
package/src/structure.js CHANGED
@@ -315,30 +315,40 @@ export function hotspotsData(customDbPath, opts = {}) {
315
315
  const metric = opts.metric || 'fan-in';
316
316
  const level = opts.level || 'file';
317
317
  const limit = opts.limit || 10;
318
+ const noTests = opts.noTests || false;
318
319
 
319
320
  const kind = level === 'directory' ? 'directory' : 'file';
320
321
 
322
+ const testFilter =
323
+ noTests && kind === 'file'
324
+ ? `AND n.name NOT LIKE '%.test.%'
325
+ AND n.name NOT LIKE '%.spec.%'
326
+ AND n.name NOT LIKE '%__test__%'
327
+ AND n.name NOT LIKE '%__tests__%'
328
+ AND n.name NOT LIKE '%.stories.%'`
329
+ : '';
330
+
321
331
  const HOTSPOT_QUERIES = {
322
332
  'fan-in': db.prepare(`
323
333
  SELECT n.name, n.kind, nm.line_count, nm.symbol_count, nm.import_count, nm.export_count,
324
334
  nm.fan_in, nm.fan_out, nm.cohesion, nm.file_count
325
335
  FROM nodes n JOIN node_metrics nm ON n.id = nm.node_id
326
- WHERE n.kind = ? ORDER BY nm.fan_in DESC NULLS LAST LIMIT ?`),
336
+ WHERE n.kind = ? ${testFilter} ORDER BY nm.fan_in DESC NULLS LAST LIMIT ?`),
327
337
  'fan-out': db.prepare(`
328
338
  SELECT n.name, n.kind, nm.line_count, nm.symbol_count, nm.import_count, nm.export_count,
329
339
  nm.fan_in, nm.fan_out, nm.cohesion, nm.file_count
330
340
  FROM nodes n JOIN node_metrics nm ON n.id = nm.node_id
331
- WHERE n.kind = ? ORDER BY nm.fan_out DESC NULLS LAST LIMIT ?`),
341
+ WHERE n.kind = ? ${testFilter} ORDER BY nm.fan_out DESC NULLS LAST LIMIT ?`),
332
342
  density: db.prepare(`
333
343
  SELECT n.name, n.kind, nm.line_count, nm.symbol_count, nm.import_count, nm.export_count,
334
344
  nm.fan_in, nm.fan_out, nm.cohesion, nm.file_count
335
345
  FROM nodes n JOIN node_metrics nm ON n.id = nm.node_id
336
- WHERE n.kind = ? ORDER BY nm.symbol_count DESC NULLS LAST LIMIT ?`),
346
+ WHERE n.kind = ? ${testFilter} ORDER BY nm.symbol_count DESC NULLS LAST LIMIT ?`),
337
347
  coupling: db.prepare(`
338
348
  SELECT n.name, n.kind, nm.line_count, nm.symbol_count, nm.import_count, nm.export_count,
339
349
  nm.fan_in, nm.fan_out, nm.cohesion, nm.file_count
340
350
  FROM nodes n JOIN node_metrics nm ON n.id = nm.node_id
341
- WHERE n.kind = ? ORDER BY (COALESCE(nm.fan_in, 0) + COALESCE(nm.fan_out, 0)) DESC NULLS LAST LIMIT ?`),
351
+ WHERE n.kind = ? ${testFilter} ORDER BY (COALESCE(nm.fan_in, 0) + COALESCE(nm.fan_out, 0)) DESC NULLS LAST LIMIT ?`),
342
352
  };
343
353
 
344
354
  const stmt = HOTSPOT_QUERIES[metric] || HOTSPOT_QUERIES['fan-in'];