@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 +5 -5
- package/src/cli.js +53 -0
- package/src/extractors/javascript.js +122 -0
- package/src/index.js +5 -0
- package/src/mcp.js +76 -0
- package/src/queries.js +683 -32
- package/src/structure.js +14 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@optave/codegraph",
|
|
3
|
-
"version": "2.1.1-dev.
|
|
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.
|
|
65
|
-
"@optave/codegraph-darwin-x64": "2.1.1-dev.
|
|
66
|
-
"@optave/codegraph-linux-x64-gnu": "2.1.1-dev.
|
|
67
|
-
"@optave/codegraph-win32-x64-msvc": "2.1.1-dev.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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'];
|