@optave/codegraph 2.1.1-dev.3c12b64 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +49 -31
- package/package.json +5 -5
- package/src/builder.js +238 -33
- package/src/cli.js +93 -9
- package/src/cycles.js +13 -1
- package/src/db.js +4 -0
- package/src/export.js +20 -7
- package/src/extractors/csharp.js +6 -1
- package/src/extractors/go.js +6 -1
- package/src/extractors/java.js +4 -1
- package/src/extractors/javascript.js +145 -5
- package/src/extractors/php.js +8 -2
- package/src/extractors/python.js +8 -1
- package/src/extractors/ruby.js +4 -1
- package/src/extractors/rust.js +12 -2
- package/src/index.js +6 -0
- package/src/journal.js +109 -0
- package/src/mcp.js +131 -7
- package/src/parser.js +1 -0
- package/src/queries.js +1143 -38
- package/src/structure.js +21 -7
- package/src/watcher.js +25 -0
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
|
-
function isTestFile(filePath) {
|
|
20
|
+
export 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':
|
|
@@ -81,16 +190,18 @@ function kindIcon(kind) {
|
|
|
81
190
|
|
|
82
191
|
// ─── Data-returning functions ───────────────────────────────────────────
|
|
83
192
|
|
|
84
|
-
export function queryNameData(name, customDbPath) {
|
|
193
|
+
export function queryNameData(name, customDbPath, opts = {}) {
|
|
85
194
|
const db = openReadonlyOrFail(customDbPath);
|
|
86
|
-
const
|
|
195
|
+
const noTests = opts.noTests || false;
|
|
196
|
+
let nodes = db.prepare(`SELECT * FROM nodes WHERE name LIKE ?`).all(`%${name}%`);
|
|
197
|
+
if (noTests) nodes = nodes.filter((n) => !isTestFile(n.file));
|
|
87
198
|
if (nodes.length === 0) {
|
|
88
199
|
db.close();
|
|
89
200
|
return { query: name, results: [] };
|
|
90
201
|
}
|
|
91
202
|
|
|
92
203
|
const results = nodes.map((node) => {
|
|
93
|
-
|
|
204
|
+
let callees = db
|
|
94
205
|
.prepare(`
|
|
95
206
|
SELECT n.name, n.kind, n.file, n.line, e.kind as edge_kind
|
|
96
207
|
FROM edges e JOIN nodes n ON e.target_id = n.id
|
|
@@ -98,7 +209,7 @@ export function queryNameData(name, customDbPath) {
|
|
|
98
209
|
`)
|
|
99
210
|
.all(node.id);
|
|
100
211
|
|
|
101
|
-
|
|
212
|
+
let callers = db
|
|
102
213
|
.prepare(`
|
|
103
214
|
SELECT n.name, n.kind, n.file, n.line, e.kind as edge_kind
|
|
104
215
|
FROM edges e JOIN nodes n ON e.source_id = n.id
|
|
@@ -106,6 +217,11 @@ export function queryNameData(name, customDbPath) {
|
|
|
106
217
|
`)
|
|
107
218
|
.all(node.id);
|
|
108
219
|
|
|
220
|
+
if (noTests) {
|
|
221
|
+
callees = callees.filter((c) => !isTestFile(c.file));
|
|
222
|
+
callers = callers.filter((c) => !isTestFile(c.file));
|
|
223
|
+
}
|
|
224
|
+
|
|
109
225
|
return {
|
|
110
226
|
name: node.name,
|
|
111
227
|
kind: node.kind,
|
|
@@ -132,8 +248,9 @@ export function queryNameData(name, customDbPath) {
|
|
|
132
248
|
return { query: name, results };
|
|
133
249
|
}
|
|
134
250
|
|
|
135
|
-
export function impactAnalysisData(file, customDbPath) {
|
|
251
|
+
export function impactAnalysisData(file, customDbPath, opts = {}) {
|
|
136
252
|
const db = openReadonlyOrFail(customDbPath);
|
|
253
|
+
const noTests = opts.noTests || false;
|
|
137
254
|
const fileNodes = db
|
|
138
255
|
.prepare(`SELECT * FROM nodes WHERE file LIKE ? AND kind = 'file'`)
|
|
139
256
|
.all(`%${file}%`);
|
|
@@ -162,7 +279,7 @@ export function impactAnalysisData(file, customDbPath) {
|
|
|
162
279
|
`)
|
|
163
280
|
.all(current);
|
|
164
281
|
for (const dep of dependents) {
|
|
165
|
-
if (!visited.has(dep.id)) {
|
|
282
|
+
if (!visited.has(dep.id) && (!noTests || !isTestFile(dep.file))) {
|
|
166
283
|
visited.add(dep.id);
|
|
167
284
|
queue.push(dep.id);
|
|
168
285
|
levels.set(dep.id, level + 1);
|
|
@@ -187,8 +304,17 @@ export function impactAnalysisData(file, customDbPath) {
|
|
|
187
304
|
};
|
|
188
305
|
}
|
|
189
306
|
|
|
190
|
-
export function moduleMapData(customDbPath, limit = 20) {
|
|
307
|
+
export function moduleMapData(customDbPath, limit = 20, opts = {}) {
|
|
191
308
|
const db = openReadonlyOrFail(customDbPath);
|
|
309
|
+
const noTests = opts.noTests || false;
|
|
310
|
+
|
|
311
|
+
const testFilter = noTests
|
|
312
|
+
? `AND n.file NOT LIKE '%.test.%'
|
|
313
|
+
AND n.file NOT LIKE '%.spec.%'
|
|
314
|
+
AND n.file NOT LIKE '%__test__%'
|
|
315
|
+
AND n.file NOT LIKE '%__tests__%'
|
|
316
|
+
AND n.file NOT LIKE '%.stories.%'`
|
|
317
|
+
: '';
|
|
192
318
|
|
|
193
319
|
const nodes = db
|
|
194
320
|
.prepare(`
|
|
@@ -197,9 +323,7 @@ export function moduleMapData(customDbPath, limit = 20) {
|
|
|
197
323
|
(SELECT COUNT(*) FROM edges WHERE target_id = n.id AND kind != 'contains') as in_edges
|
|
198
324
|
FROM nodes n
|
|
199
325
|
WHERE n.kind = 'file'
|
|
200
|
-
|
|
201
|
-
AND n.file NOT LIKE '%.spec.%'
|
|
202
|
-
AND n.file NOT LIKE '%__test__%'
|
|
326
|
+
${testFilter}
|
|
203
327
|
ORDER BY (SELECT COUNT(*) FROM edges WHERE target_id = n.id AND kind != 'contains') DESC
|
|
204
328
|
LIMIT ?
|
|
205
329
|
`)
|
|
@@ -220,8 +344,9 @@ export function moduleMapData(customDbPath, limit = 20) {
|
|
|
220
344
|
return { limit, topNodes, stats: { totalFiles, totalNodes, totalEdges } };
|
|
221
345
|
}
|
|
222
346
|
|
|
223
|
-
export function fileDepsData(file, customDbPath) {
|
|
347
|
+
export function fileDepsData(file, customDbPath, opts = {}) {
|
|
224
348
|
const db = openReadonlyOrFail(customDbPath);
|
|
349
|
+
const noTests = opts.noTests || false;
|
|
225
350
|
const fileNodes = db
|
|
226
351
|
.prepare(`SELECT * FROM nodes WHERE file LIKE ? AND kind = 'file'`)
|
|
227
352
|
.all(`%${file}%`);
|
|
@@ -231,19 +356,21 @@ export function fileDepsData(file, customDbPath) {
|
|
|
231
356
|
}
|
|
232
357
|
|
|
233
358
|
const results = fileNodes.map((fn) => {
|
|
234
|
-
|
|
359
|
+
let importsTo = db
|
|
235
360
|
.prepare(`
|
|
236
361
|
SELECT n.file, e.kind as edge_kind FROM edges e JOIN nodes n ON e.target_id = n.id
|
|
237
362
|
WHERE e.source_id = ? AND e.kind IN ('imports', 'imports-type')
|
|
238
363
|
`)
|
|
239
364
|
.all(fn.id);
|
|
365
|
+
if (noTests) importsTo = importsTo.filter((i) => !isTestFile(i.file));
|
|
240
366
|
|
|
241
|
-
|
|
367
|
+
let importedBy = db
|
|
242
368
|
.prepare(`
|
|
243
369
|
SELECT n.file, e.kind as edge_kind FROM edges e JOIN nodes n ON e.source_id = n.id
|
|
244
370
|
WHERE e.target_id = ? AND e.kind IN ('imports', 'imports-type')
|
|
245
371
|
`)
|
|
246
372
|
.all(fn.id);
|
|
373
|
+
if (noTests) importedBy = importedBy.filter((i) => !isTestFile(i.file));
|
|
247
374
|
|
|
248
375
|
const defs = db
|
|
249
376
|
.prepare(`SELECT * FROM nodes WHERE file = ? AND kind != 'file' ORDER BY line`)
|
|
@@ -266,12 +393,7 @@ export function fnDepsData(name, customDbPath, opts = {}) {
|
|
|
266
393
|
const depth = opts.depth || 3;
|
|
267
394
|
const noTests = opts.noTests || false;
|
|
268
395
|
|
|
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));
|
|
396
|
+
const nodes = findMatchingNodes(db, name, { noTests, file: opts.file, kind: opts.kind });
|
|
275
397
|
if (nodes.length === 0) {
|
|
276
398
|
db.close();
|
|
277
399
|
return { name, results: [] };
|
|
@@ -391,10 +513,7 @@ export function fnImpactData(name, customDbPath, opts = {}) {
|
|
|
391
513
|
const maxDepth = opts.depth || 5;
|
|
392
514
|
const noTests = opts.noTests || false;
|
|
393
515
|
|
|
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));
|
|
516
|
+
const nodes = findMatchingNodes(db, name, { noTests, file: opts.file, kind: opts.kind });
|
|
398
517
|
if (nodes.length === 0) {
|
|
399
518
|
db.close();
|
|
400
519
|
return { name, results: [] };
|
|
@@ -616,11 +735,40 @@ export function listFunctionsData(customDbPath, opts = {}) {
|
|
|
616
735
|
return { count: rows.length, functions: rows };
|
|
617
736
|
}
|
|
618
737
|
|
|
619
|
-
export function statsData(customDbPath) {
|
|
738
|
+
export function statsData(customDbPath, opts = {}) {
|
|
620
739
|
const db = openReadonlyOrFail(customDbPath);
|
|
740
|
+
const noTests = opts.noTests || false;
|
|
741
|
+
|
|
742
|
+
// Build set of test file IDs for filtering nodes and edges
|
|
743
|
+
let testFileIds = null;
|
|
744
|
+
if (noTests) {
|
|
745
|
+
const allFileNodes = db.prepare("SELECT id, file FROM nodes WHERE kind = 'file'").all();
|
|
746
|
+
testFileIds = new Set();
|
|
747
|
+
const testFiles = new Set();
|
|
748
|
+
for (const n of allFileNodes) {
|
|
749
|
+
if (isTestFile(n.file)) {
|
|
750
|
+
testFileIds.add(n.id);
|
|
751
|
+
testFiles.add(n.file);
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
// Also collect non-file node IDs that belong to test files
|
|
755
|
+
const allNodes = db.prepare('SELECT id, file FROM nodes').all();
|
|
756
|
+
for (const n of allNodes) {
|
|
757
|
+
if (testFiles.has(n.file)) testFileIds.add(n.id);
|
|
758
|
+
}
|
|
759
|
+
}
|
|
621
760
|
|
|
622
761
|
// Node breakdown by kind
|
|
623
|
-
|
|
762
|
+
let nodeRows;
|
|
763
|
+
if (noTests) {
|
|
764
|
+
const allNodes = db.prepare('SELECT id, kind, file FROM nodes').all();
|
|
765
|
+
const filtered = allNodes.filter((n) => !testFileIds.has(n.id));
|
|
766
|
+
const counts = {};
|
|
767
|
+
for (const n of filtered) counts[n.kind] = (counts[n.kind] || 0) + 1;
|
|
768
|
+
nodeRows = Object.entries(counts).map(([kind, c]) => ({ kind, c }));
|
|
769
|
+
} else {
|
|
770
|
+
nodeRows = db.prepare('SELECT kind, COUNT(*) as c FROM nodes GROUP BY kind').all();
|
|
771
|
+
}
|
|
624
772
|
const nodesByKind = {};
|
|
625
773
|
let totalNodes = 0;
|
|
626
774
|
for (const r of nodeRows) {
|
|
@@ -629,7 +777,18 @@ export function statsData(customDbPath) {
|
|
|
629
777
|
}
|
|
630
778
|
|
|
631
779
|
// Edge breakdown by kind
|
|
632
|
-
|
|
780
|
+
let edgeRows;
|
|
781
|
+
if (noTests) {
|
|
782
|
+
const allEdges = db.prepare('SELECT source_id, target_id, kind FROM edges').all();
|
|
783
|
+
const filtered = allEdges.filter(
|
|
784
|
+
(e) => !testFileIds.has(e.source_id) && !testFileIds.has(e.target_id),
|
|
785
|
+
);
|
|
786
|
+
const counts = {};
|
|
787
|
+
for (const e of filtered) counts[e.kind] = (counts[e.kind] || 0) + 1;
|
|
788
|
+
edgeRows = Object.entries(counts).map(([kind, c]) => ({ kind, c }));
|
|
789
|
+
} else {
|
|
790
|
+
edgeRows = db.prepare('SELECT kind, COUNT(*) as c FROM edges GROUP BY kind').all();
|
|
791
|
+
}
|
|
633
792
|
const edgesByKind = {};
|
|
634
793
|
let totalEdges = 0;
|
|
635
794
|
for (const r of edgeRows) {
|
|
@@ -644,7 +803,8 @@ export function statsData(customDbPath) {
|
|
|
644
803
|
extToLang.set(ext, entry.id);
|
|
645
804
|
}
|
|
646
805
|
}
|
|
647
|
-
|
|
806
|
+
let fileNodes = db.prepare("SELECT file FROM nodes WHERE kind = 'file'").all();
|
|
807
|
+
if (noTests) fileNodes = fileNodes.filter((n) => !isTestFile(n.file));
|
|
648
808
|
const byLanguage = {};
|
|
649
809
|
for (const row of fileNodes) {
|
|
650
810
|
const ext = path.extname(row.file).toLowerCase();
|
|
@@ -654,23 +814,30 @@ export function statsData(customDbPath) {
|
|
|
654
814
|
const langCount = Object.keys(byLanguage).length;
|
|
655
815
|
|
|
656
816
|
// Cycles
|
|
657
|
-
const fileCycles = findCycles(db, { fileLevel: true });
|
|
658
|
-
const fnCycles = findCycles(db, { fileLevel: false });
|
|
817
|
+
const fileCycles = findCycles(db, { fileLevel: true, noTests });
|
|
818
|
+
const fnCycles = findCycles(db, { fileLevel: false, noTests });
|
|
659
819
|
|
|
660
820
|
// Top 5 coupling hotspots (fan-in + fan-out, file nodes)
|
|
821
|
+
const testFilter = noTests
|
|
822
|
+
? `AND n.file NOT LIKE '%.test.%'
|
|
823
|
+
AND n.file NOT LIKE '%.spec.%'
|
|
824
|
+
AND n.file NOT LIKE '%__test__%'
|
|
825
|
+
AND n.file NOT LIKE '%__tests__%'
|
|
826
|
+
AND n.file NOT LIKE '%.stories.%'`
|
|
827
|
+
: '';
|
|
661
828
|
const hotspotRows = db
|
|
662
829
|
.prepare(`
|
|
663
830
|
SELECT n.file,
|
|
664
831
|
(SELECT COUNT(*) FROM edges WHERE target_id = n.id) as fan_in,
|
|
665
832
|
(SELECT COUNT(*) FROM edges WHERE source_id = n.id) as fan_out
|
|
666
833
|
FROM nodes n
|
|
667
|
-
WHERE n.kind = 'file'
|
|
834
|
+
WHERE n.kind = 'file' ${testFilter}
|
|
668
835
|
ORDER BY (SELECT COUNT(*) FROM edges WHERE target_id = n.id)
|
|
669
836
|
+ (SELECT COUNT(*) FROM edges WHERE source_id = n.id) DESC
|
|
670
|
-
LIMIT 5
|
|
671
837
|
`)
|
|
672
838
|
.all();
|
|
673
|
-
const
|
|
839
|
+
const filteredHotspots = noTests ? hotspotRows.filter((r) => !isTestFile(r.file)) : hotspotRows;
|
|
840
|
+
const hotspots = filteredHotspots.slice(0, 5).map((r) => ({
|
|
674
841
|
file: r.file,
|
|
675
842
|
fanIn: r.fan_in,
|
|
676
843
|
fanOut: r.fan_out,
|
|
@@ -695,6 +862,70 @@ export function statsData(customDbPath) {
|
|
|
695
862
|
/* embeddings table may not exist */
|
|
696
863
|
}
|
|
697
864
|
|
|
865
|
+
// Graph quality metrics
|
|
866
|
+
const qualityTestFilter = testFilter.replace(/n\.file/g, 'file');
|
|
867
|
+
const totalCallable = db
|
|
868
|
+
.prepare(
|
|
869
|
+
`SELECT COUNT(*) as c FROM nodes WHERE kind IN ('function', 'method') ${qualityTestFilter}`,
|
|
870
|
+
)
|
|
871
|
+
.get().c;
|
|
872
|
+
const callableWithCallers = db
|
|
873
|
+
.prepare(`
|
|
874
|
+
SELECT COUNT(DISTINCT e.target_id) as c FROM edges e
|
|
875
|
+
JOIN nodes n ON e.target_id = n.id
|
|
876
|
+
WHERE e.kind = 'calls' AND n.kind IN ('function', 'method') ${testFilter}
|
|
877
|
+
`)
|
|
878
|
+
.get().c;
|
|
879
|
+
const callerCoverage = totalCallable > 0 ? callableWithCallers / totalCallable : 0;
|
|
880
|
+
|
|
881
|
+
const totalCallEdges = db.prepare("SELECT COUNT(*) as c FROM edges WHERE kind = 'calls'").get().c;
|
|
882
|
+
const highConfCallEdges = db
|
|
883
|
+
.prepare("SELECT COUNT(*) as c FROM edges WHERE kind = 'calls' AND confidence >= 0.7")
|
|
884
|
+
.get().c;
|
|
885
|
+
const callConfidence = totalCallEdges > 0 ? highConfCallEdges / totalCallEdges : 0;
|
|
886
|
+
|
|
887
|
+
// False-positive warnings: generic names with > threshold callers
|
|
888
|
+
const fpRows = db
|
|
889
|
+
.prepare(`
|
|
890
|
+
SELECT n.name, n.file, n.line, COUNT(e.source_id) as caller_count
|
|
891
|
+
FROM nodes n
|
|
892
|
+
LEFT JOIN edges e ON n.id = e.target_id AND e.kind = 'calls'
|
|
893
|
+
WHERE n.kind IN ('function', 'method')
|
|
894
|
+
GROUP BY n.id
|
|
895
|
+
HAVING caller_count > ?
|
|
896
|
+
ORDER BY caller_count DESC
|
|
897
|
+
`)
|
|
898
|
+
.all(FALSE_POSITIVE_CALLER_THRESHOLD);
|
|
899
|
+
const falsePositiveWarnings = fpRows
|
|
900
|
+
.filter((r) =>
|
|
901
|
+
FALSE_POSITIVE_NAMES.has(r.name.includes('.') ? r.name.split('.').pop() : r.name),
|
|
902
|
+
)
|
|
903
|
+
.map((r) => ({ name: r.name, file: r.file, line: r.line, callerCount: r.caller_count }));
|
|
904
|
+
|
|
905
|
+
// Edges from suspicious nodes
|
|
906
|
+
let fpEdgeCount = 0;
|
|
907
|
+
for (const fp of falsePositiveWarnings) fpEdgeCount += fp.callerCount;
|
|
908
|
+
const falsePositiveRatio = totalCallEdges > 0 ? fpEdgeCount / totalCallEdges : 0;
|
|
909
|
+
|
|
910
|
+
const score = Math.round(
|
|
911
|
+
callerCoverage * 40 + callConfidence * 40 + (1 - falsePositiveRatio) * 20,
|
|
912
|
+
);
|
|
913
|
+
|
|
914
|
+
const quality = {
|
|
915
|
+
score,
|
|
916
|
+
callerCoverage: {
|
|
917
|
+
ratio: callerCoverage,
|
|
918
|
+
covered: callableWithCallers,
|
|
919
|
+
total: totalCallable,
|
|
920
|
+
},
|
|
921
|
+
callConfidence: {
|
|
922
|
+
ratio: callConfidence,
|
|
923
|
+
highConf: highConfCallEdges,
|
|
924
|
+
total: totalCallEdges,
|
|
925
|
+
},
|
|
926
|
+
falsePositiveWarnings,
|
|
927
|
+
};
|
|
928
|
+
|
|
698
929
|
db.close();
|
|
699
930
|
return {
|
|
700
931
|
nodes: { total: totalNodes, byKind: nodesByKind },
|
|
@@ -703,11 +934,12 @@ export function statsData(customDbPath) {
|
|
|
703
934
|
cycles: { fileLevel: fileCycles.length, functionLevel: fnCycles.length },
|
|
704
935
|
hotspots,
|
|
705
936
|
embeddings,
|
|
937
|
+
quality,
|
|
706
938
|
};
|
|
707
939
|
}
|
|
708
940
|
|
|
709
941
|
export function stats(customDbPath, opts = {}) {
|
|
710
|
-
const data = statsData(customDbPath);
|
|
942
|
+
const data = statsData(customDbPath, { noTests: opts.noTests });
|
|
711
943
|
if (opts.json) {
|
|
712
944
|
console.log(JSON.stringify(data, null, 2));
|
|
713
945
|
return;
|
|
@@ -779,13 +1011,33 @@ export function stats(customDbPath, opts = {}) {
|
|
|
779
1011
|
console.log('\nEmbeddings: not built');
|
|
780
1012
|
}
|
|
781
1013
|
|
|
1014
|
+
// Quality
|
|
1015
|
+
if (data.quality) {
|
|
1016
|
+
const q = data.quality;
|
|
1017
|
+
const cc = q.callerCoverage;
|
|
1018
|
+
const cf = q.callConfidence;
|
|
1019
|
+
console.log(`\nGraph Quality: ${q.score}/100`);
|
|
1020
|
+
console.log(
|
|
1021
|
+
` Caller coverage: ${(cc.ratio * 100).toFixed(1)}% (${cc.covered}/${cc.total} functions have >=1 caller)`,
|
|
1022
|
+
);
|
|
1023
|
+
console.log(
|
|
1024
|
+
` Call confidence: ${(cf.ratio * 100).toFixed(1)}% (${cf.highConf}/${cf.total} call edges are high-confidence)`,
|
|
1025
|
+
);
|
|
1026
|
+
if (q.falsePositiveWarnings.length > 0) {
|
|
1027
|
+
console.log(' False-positive warnings:');
|
|
1028
|
+
for (const fp of q.falsePositiveWarnings) {
|
|
1029
|
+
console.log(` ! ${fp.name} (${fp.callerCount} callers) -- ${fp.file}:${fp.line}`);
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
|
|
782
1034
|
console.log();
|
|
783
1035
|
}
|
|
784
1036
|
|
|
785
1037
|
// ─── Human-readable output (original formatting) ───────────────────────
|
|
786
1038
|
|
|
787
1039
|
export function queryName(name, customDbPath, opts = {}) {
|
|
788
|
-
const data = queryNameData(name, customDbPath);
|
|
1040
|
+
const data = queryNameData(name, customDbPath, { noTests: opts.noTests });
|
|
789
1041
|
if (opts.json) {
|
|
790
1042
|
console.log(JSON.stringify(data, null, 2));
|
|
791
1043
|
return;
|
|
@@ -815,7 +1067,7 @@ export function queryName(name, customDbPath, opts = {}) {
|
|
|
815
1067
|
}
|
|
816
1068
|
|
|
817
1069
|
export function impactAnalysis(file, customDbPath, opts = {}) {
|
|
818
|
-
const data = impactAnalysisData(file, customDbPath);
|
|
1070
|
+
const data = impactAnalysisData(file, customDbPath, { noTests: opts.noTests });
|
|
819
1071
|
if (opts.json) {
|
|
820
1072
|
console.log(JSON.stringify(data, null, 2));
|
|
821
1073
|
return;
|
|
@@ -846,7 +1098,7 @@ export function impactAnalysis(file, customDbPath, opts = {}) {
|
|
|
846
1098
|
}
|
|
847
1099
|
|
|
848
1100
|
export function moduleMap(customDbPath, limit = 20, opts = {}) {
|
|
849
|
-
const data = moduleMapData(customDbPath, limit);
|
|
1101
|
+
const data = moduleMapData(customDbPath, limit, { noTests: opts.noTests });
|
|
850
1102
|
if (opts.json) {
|
|
851
1103
|
console.log(JSON.stringify(data, null, 2));
|
|
852
1104
|
return;
|
|
@@ -874,7 +1126,7 @@ export function moduleMap(customDbPath, limit = 20, opts = {}) {
|
|
|
874
1126
|
}
|
|
875
1127
|
|
|
876
1128
|
export function fileDeps(file, customDbPath, opts = {}) {
|
|
877
|
-
const data = fileDepsData(file, customDbPath);
|
|
1129
|
+
const data = fileDepsData(file, customDbPath, { noTests: opts.noTests });
|
|
878
1130
|
if (opts.json) {
|
|
879
1131
|
console.log(JSON.stringify(data, null, 2));
|
|
880
1132
|
return;
|
|
@@ -945,6 +1197,859 @@ export function fnDeps(name, customDbPath, opts = {}) {
|
|
|
945
1197
|
}
|
|
946
1198
|
}
|
|
947
1199
|
|
|
1200
|
+
// ─── Context helpers (private) ──────────────────────────────────────────
|
|
1201
|
+
|
|
1202
|
+
function readSourceRange(repoRoot, file, startLine, endLine) {
|
|
1203
|
+
try {
|
|
1204
|
+
const absPath = safePath(repoRoot, file);
|
|
1205
|
+
if (!absPath) return null;
|
|
1206
|
+
const content = fs.readFileSync(absPath, 'utf-8');
|
|
1207
|
+
const lines = content.split('\n');
|
|
1208
|
+
const start = Math.max(0, (startLine || 1) - 1);
|
|
1209
|
+
const end = Math.min(lines.length, endLine || startLine + 50);
|
|
1210
|
+
return lines.slice(start, end).join('\n');
|
|
1211
|
+
} catch (e) {
|
|
1212
|
+
debug(`readSourceRange failed for ${file}: ${e.message}`);
|
|
1213
|
+
return null;
|
|
1214
|
+
}
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
function extractSummary(fileLines, line) {
|
|
1218
|
+
if (!fileLines || !line || line <= 1) return null;
|
|
1219
|
+
const idx = line - 2; // line above the definition (0-indexed)
|
|
1220
|
+
// Scan up to 10 lines above for JSDoc or comment
|
|
1221
|
+
let jsdocEnd = -1;
|
|
1222
|
+
for (let i = idx; i >= Math.max(0, idx - 10); i--) {
|
|
1223
|
+
const trimmed = fileLines[i].trim();
|
|
1224
|
+
if (trimmed.endsWith('*/')) {
|
|
1225
|
+
jsdocEnd = i;
|
|
1226
|
+
break;
|
|
1227
|
+
}
|
|
1228
|
+
if (trimmed.startsWith('//') || trimmed.startsWith('#')) {
|
|
1229
|
+
// Single-line comment immediately above
|
|
1230
|
+
const text = trimmed
|
|
1231
|
+
.replace(/^\/\/\s*/, '')
|
|
1232
|
+
.replace(/^#\s*/, '')
|
|
1233
|
+
.trim();
|
|
1234
|
+
return text.length > 100 ? `${text.slice(0, 100)}...` : text;
|
|
1235
|
+
}
|
|
1236
|
+
if (trimmed !== '' && !trimmed.startsWith('*') && !trimmed.startsWith('/*')) break;
|
|
1237
|
+
}
|
|
1238
|
+
if (jsdocEnd >= 0) {
|
|
1239
|
+
// Find opening /**
|
|
1240
|
+
for (let i = jsdocEnd; i >= Math.max(0, jsdocEnd - 20); i--) {
|
|
1241
|
+
if (fileLines[i].trim().startsWith('/**')) {
|
|
1242
|
+
// Extract first non-tag, non-empty line
|
|
1243
|
+
for (let j = i + 1; j <= jsdocEnd; j++) {
|
|
1244
|
+
const docLine = fileLines[j]
|
|
1245
|
+
.trim()
|
|
1246
|
+
.replace(/^\*\s?/, '')
|
|
1247
|
+
.trim();
|
|
1248
|
+
if (docLine && !docLine.startsWith('@') && docLine !== '/' && docLine !== '*/') {
|
|
1249
|
+
return docLine.length > 100 ? `${docLine.slice(0, 100)}...` : docLine;
|
|
1250
|
+
}
|
|
1251
|
+
}
|
|
1252
|
+
break;
|
|
1253
|
+
}
|
|
1254
|
+
}
|
|
1255
|
+
}
|
|
1256
|
+
return null;
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
function extractSignature(fileLines, line) {
|
|
1260
|
+
if (!fileLines || !line) return null;
|
|
1261
|
+
const idx = line - 1;
|
|
1262
|
+
// Gather up to 5 lines to handle multi-line params
|
|
1263
|
+
const chunk = fileLines.slice(idx, Math.min(fileLines.length, idx + 5)).join('\n');
|
|
1264
|
+
|
|
1265
|
+
// JS/TS: function name(params) or (params) => or async function
|
|
1266
|
+
let m = chunk.match(
|
|
1267
|
+
/(?:export\s+)?(?:async\s+)?function\s*\*?\s*\w*\s*\(([^)]*)\)\s*(?::\s*([^\n{]+))?/,
|
|
1268
|
+
);
|
|
1269
|
+
if (m) {
|
|
1270
|
+
return {
|
|
1271
|
+
params: m[1].trim() || null,
|
|
1272
|
+
returnType: m[2] ? m[2].trim().replace(/\s*\{$/, '') : null,
|
|
1273
|
+
};
|
|
1274
|
+
}
|
|
1275
|
+
// Arrow: const name = (params) => or (params):ReturnType =>
|
|
1276
|
+
m = chunk.match(/=\s*(?:async\s+)?\(([^)]*)\)\s*(?::\s*([^=>\n{]+))?\s*=>/);
|
|
1277
|
+
if (m) {
|
|
1278
|
+
return {
|
|
1279
|
+
params: m[1].trim() || null,
|
|
1280
|
+
returnType: m[2] ? m[2].trim() : null,
|
|
1281
|
+
};
|
|
1282
|
+
}
|
|
1283
|
+
// Python: def name(params) -> return:
|
|
1284
|
+
m = chunk.match(/def\s+\w+\s*\(([^)]*)\)\s*(?:->\s*([^:\n]+))?/);
|
|
1285
|
+
if (m) {
|
|
1286
|
+
return {
|
|
1287
|
+
params: m[1].trim() || null,
|
|
1288
|
+
returnType: m[2] ? m[2].trim() : null,
|
|
1289
|
+
};
|
|
1290
|
+
}
|
|
1291
|
+
// Go: func (recv) name(params) (returns)
|
|
1292
|
+
m = chunk.match(/func\s+(?:\([^)]*\)\s+)?\w+\s*\(([^)]*)\)\s*(?:\(([^)]+)\)|(\w[^\n{]*))?/);
|
|
1293
|
+
if (m) {
|
|
1294
|
+
return {
|
|
1295
|
+
params: m[1].trim() || null,
|
|
1296
|
+
returnType: (m[2] || m[3] || '').trim() || null,
|
|
1297
|
+
};
|
|
1298
|
+
}
|
|
1299
|
+
// Rust: fn name(params) -> ReturnType
|
|
1300
|
+
m = chunk.match(/fn\s+\w+\s*\(([^)]*)\)\s*(?:->\s*([^\n{]+))?/);
|
|
1301
|
+
if (m) {
|
|
1302
|
+
return {
|
|
1303
|
+
params: m[1].trim() || null,
|
|
1304
|
+
returnType: m[2] ? m[2].trim() : null,
|
|
1305
|
+
};
|
|
1306
|
+
}
|
|
1307
|
+
return null;
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
// ─── contextData ────────────────────────────────────────────────────────
|
|
1311
|
+
|
|
1312
|
+
export function contextData(name, customDbPath, opts = {}) {
|
|
1313
|
+
const db = openReadonlyOrFail(customDbPath);
|
|
1314
|
+
const depth = opts.depth || 0;
|
|
1315
|
+
const noSource = opts.noSource || false;
|
|
1316
|
+
const noTests = opts.noTests || false;
|
|
1317
|
+
const includeTests = opts.includeTests || false;
|
|
1318
|
+
|
|
1319
|
+
const dbPath = findDbPath(customDbPath);
|
|
1320
|
+
const repoRoot = path.resolve(path.dirname(dbPath), '..');
|
|
1321
|
+
|
|
1322
|
+
let nodes = findMatchingNodes(db, name, { noTests, file: opts.file, kind: opts.kind });
|
|
1323
|
+
if (nodes.length === 0) {
|
|
1324
|
+
db.close();
|
|
1325
|
+
return { name, results: [] };
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
// Limit to first 5 results
|
|
1329
|
+
nodes = nodes.slice(0, 5);
|
|
1330
|
+
|
|
1331
|
+
// File-lines cache to avoid re-reading the same file
|
|
1332
|
+
const fileCache = new Map();
|
|
1333
|
+
function getFileLines(file) {
|
|
1334
|
+
if (fileCache.has(file)) return fileCache.get(file);
|
|
1335
|
+
try {
|
|
1336
|
+
const absPath = safePath(repoRoot, file);
|
|
1337
|
+
if (!absPath) {
|
|
1338
|
+
fileCache.set(file, null);
|
|
1339
|
+
return null;
|
|
1340
|
+
}
|
|
1341
|
+
const lines = fs.readFileSync(absPath, 'utf-8').split('\n');
|
|
1342
|
+
fileCache.set(file, lines);
|
|
1343
|
+
return lines;
|
|
1344
|
+
} catch (e) {
|
|
1345
|
+
debug(`getFileLines failed for ${file}: ${e.message}`);
|
|
1346
|
+
fileCache.set(file, null);
|
|
1347
|
+
return null;
|
|
1348
|
+
}
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
const results = nodes.map((node) => {
|
|
1352
|
+
const fileLines = getFileLines(node.file);
|
|
1353
|
+
|
|
1354
|
+
// Source
|
|
1355
|
+
const source = noSource ? null : readSourceRange(repoRoot, node.file, node.line, node.end_line);
|
|
1356
|
+
|
|
1357
|
+
// Signature
|
|
1358
|
+
const signature = fileLines ? extractSignature(fileLines, node.line) : null;
|
|
1359
|
+
|
|
1360
|
+
// Callees
|
|
1361
|
+
const calleeRows = db
|
|
1362
|
+
.prepare(
|
|
1363
|
+
`SELECT n.id, n.name, n.kind, n.file, n.line, n.end_line
|
|
1364
|
+
FROM edges e JOIN nodes n ON e.target_id = n.id
|
|
1365
|
+
WHERE e.source_id = ? AND e.kind = 'calls'`,
|
|
1366
|
+
)
|
|
1367
|
+
.all(node.id);
|
|
1368
|
+
const filteredCallees = noTests ? calleeRows.filter((c) => !isTestFile(c.file)) : calleeRows;
|
|
1369
|
+
|
|
1370
|
+
const callees = filteredCallees.map((c) => {
|
|
1371
|
+
const cLines = getFileLines(c.file);
|
|
1372
|
+
const summary = cLines ? extractSummary(cLines, c.line) : null;
|
|
1373
|
+
let calleeSource = null;
|
|
1374
|
+
if (depth >= 1) {
|
|
1375
|
+
calleeSource = readSourceRange(repoRoot, c.file, c.line, c.end_line);
|
|
1376
|
+
}
|
|
1377
|
+
return {
|
|
1378
|
+
name: c.name,
|
|
1379
|
+
kind: c.kind,
|
|
1380
|
+
file: c.file,
|
|
1381
|
+
line: c.line,
|
|
1382
|
+
endLine: c.end_line || null,
|
|
1383
|
+
summary,
|
|
1384
|
+
source: calleeSource,
|
|
1385
|
+
};
|
|
1386
|
+
});
|
|
1387
|
+
|
|
1388
|
+
// Deep callee expansion via BFS (depth > 1, capped at 5)
|
|
1389
|
+
if (depth > 1) {
|
|
1390
|
+
const visited = new Set(filteredCallees.map((c) => c.id));
|
|
1391
|
+
visited.add(node.id);
|
|
1392
|
+
let frontier = filteredCallees.map((c) => c.id);
|
|
1393
|
+
const maxDepth = Math.min(depth, 5);
|
|
1394
|
+
for (let d = 2; d <= maxDepth; d++) {
|
|
1395
|
+
const nextFrontier = [];
|
|
1396
|
+
for (const fid of frontier) {
|
|
1397
|
+
const deeper = db
|
|
1398
|
+
.prepare(
|
|
1399
|
+
`SELECT n.id, n.name, n.kind, n.file, n.line, n.end_line
|
|
1400
|
+
FROM edges e JOIN nodes n ON e.target_id = n.id
|
|
1401
|
+
WHERE e.source_id = ? AND e.kind = 'calls'`,
|
|
1402
|
+
)
|
|
1403
|
+
.all(fid);
|
|
1404
|
+
for (const c of deeper) {
|
|
1405
|
+
if (!visited.has(c.id) && (!noTests || !isTestFile(c.file))) {
|
|
1406
|
+
visited.add(c.id);
|
|
1407
|
+
nextFrontier.push(c.id);
|
|
1408
|
+
const cLines = getFileLines(c.file);
|
|
1409
|
+
callees.push({
|
|
1410
|
+
name: c.name,
|
|
1411
|
+
kind: c.kind,
|
|
1412
|
+
file: c.file,
|
|
1413
|
+
line: c.line,
|
|
1414
|
+
endLine: c.end_line || null,
|
|
1415
|
+
summary: cLines ? extractSummary(cLines, c.line) : null,
|
|
1416
|
+
source: readSourceRange(repoRoot, c.file, c.line, c.end_line),
|
|
1417
|
+
});
|
|
1418
|
+
}
|
|
1419
|
+
}
|
|
1420
|
+
}
|
|
1421
|
+
frontier = nextFrontier;
|
|
1422
|
+
if (frontier.length === 0) break;
|
|
1423
|
+
}
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
// Callers
|
|
1427
|
+
let callerRows = db
|
|
1428
|
+
.prepare(
|
|
1429
|
+
`SELECT n.name, n.kind, n.file, n.line
|
|
1430
|
+
FROM edges e JOIN nodes n ON e.source_id = n.id
|
|
1431
|
+
WHERE e.target_id = ? AND e.kind = 'calls'`,
|
|
1432
|
+
)
|
|
1433
|
+
.all(node.id);
|
|
1434
|
+
|
|
1435
|
+
// Method hierarchy resolution
|
|
1436
|
+
if (node.kind === 'method' && node.name.includes('.')) {
|
|
1437
|
+
const methodName = node.name.split('.').pop();
|
|
1438
|
+
const relatedMethods = resolveMethodViaHierarchy(db, methodName);
|
|
1439
|
+
for (const rm of relatedMethods) {
|
|
1440
|
+
if (rm.id === node.id) continue;
|
|
1441
|
+
const extraCallers = db
|
|
1442
|
+
.prepare(
|
|
1443
|
+
`SELECT n.name, n.kind, n.file, n.line
|
|
1444
|
+
FROM edges e JOIN nodes n ON e.source_id = n.id
|
|
1445
|
+
WHERE e.target_id = ? AND e.kind = 'calls'`,
|
|
1446
|
+
)
|
|
1447
|
+
.all(rm.id);
|
|
1448
|
+
callerRows.push(...extraCallers.map((c) => ({ ...c, viaHierarchy: rm.name })));
|
|
1449
|
+
}
|
|
1450
|
+
}
|
|
1451
|
+
if (noTests) callerRows = callerRows.filter((c) => !isTestFile(c.file));
|
|
1452
|
+
|
|
1453
|
+
const callers = callerRows.map((c) => ({
|
|
1454
|
+
name: c.name,
|
|
1455
|
+
kind: c.kind,
|
|
1456
|
+
file: c.file,
|
|
1457
|
+
line: c.line,
|
|
1458
|
+
viaHierarchy: c.viaHierarchy || undefined,
|
|
1459
|
+
}));
|
|
1460
|
+
|
|
1461
|
+
// Related tests: callers that live in test files
|
|
1462
|
+
const testCallerRows = db
|
|
1463
|
+
.prepare(
|
|
1464
|
+
`SELECT n.name, n.kind, n.file, n.line
|
|
1465
|
+
FROM edges e JOIN nodes n ON e.source_id = n.id
|
|
1466
|
+
WHERE e.target_id = ? AND e.kind = 'calls'`,
|
|
1467
|
+
)
|
|
1468
|
+
.all(node.id);
|
|
1469
|
+
const testCallers = testCallerRows.filter((c) => isTestFile(c.file));
|
|
1470
|
+
|
|
1471
|
+
const testsByFile = new Map();
|
|
1472
|
+
for (const tc of testCallers) {
|
|
1473
|
+
if (!testsByFile.has(tc.file)) testsByFile.set(tc.file, []);
|
|
1474
|
+
testsByFile.get(tc.file).push(tc);
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1477
|
+
const relatedTests = [];
|
|
1478
|
+
for (const [file] of testsByFile) {
|
|
1479
|
+
const tLines = getFileLines(file);
|
|
1480
|
+
const testNames = [];
|
|
1481
|
+
if (tLines) {
|
|
1482
|
+
for (const tl of tLines) {
|
|
1483
|
+
const tm = tl.match(/(?:it|test|describe)\s*\(\s*['"`]([^'"`]+)['"`]/);
|
|
1484
|
+
if (tm) testNames.push(tm[1]);
|
|
1485
|
+
}
|
|
1486
|
+
}
|
|
1487
|
+
const testSource = includeTests && tLines ? tLines.join('\n') : undefined;
|
|
1488
|
+
relatedTests.push({
|
|
1489
|
+
file,
|
|
1490
|
+
testCount: testNames.length,
|
|
1491
|
+
testNames,
|
|
1492
|
+
source: testSource,
|
|
1493
|
+
});
|
|
1494
|
+
}
|
|
1495
|
+
|
|
1496
|
+
return {
|
|
1497
|
+
name: node.name,
|
|
1498
|
+
kind: node.kind,
|
|
1499
|
+
file: node.file,
|
|
1500
|
+
line: node.line,
|
|
1501
|
+
endLine: node.end_line || null,
|
|
1502
|
+
source,
|
|
1503
|
+
signature,
|
|
1504
|
+
callees,
|
|
1505
|
+
callers,
|
|
1506
|
+
relatedTests,
|
|
1507
|
+
};
|
|
1508
|
+
});
|
|
1509
|
+
|
|
1510
|
+
db.close();
|
|
1511
|
+
return { name, results };
|
|
1512
|
+
}
|
|
1513
|
+
|
|
1514
|
+
export function context(name, customDbPath, opts = {}) {
|
|
1515
|
+
const data = contextData(name, customDbPath, opts);
|
|
1516
|
+
if (opts.json) {
|
|
1517
|
+
console.log(JSON.stringify(data, null, 2));
|
|
1518
|
+
return;
|
|
1519
|
+
}
|
|
1520
|
+
if (data.results.length === 0) {
|
|
1521
|
+
console.log(`No function/method/class matching "${name}"`);
|
|
1522
|
+
return;
|
|
1523
|
+
}
|
|
1524
|
+
|
|
1525
|
+
for (const r of data.results) {
|
|
1526
|
+
const lineRange = r.endLine ? `${r.line}-${r.endLine}` : `${r.line}`;
|
|
1527
|
+
console.log(`\n# ${r.name} (${r.kind}) — ${r.file}:${lineRange}\n`);
|
|
1528
|
+
|
|
1529
|
+
// Signature
|
|
1530
|
+
if (r.signature) {
|
|
1531
|
+
console.log('## Type/Shape Info');
|
|
1532
|
+
if (r.signature.params != null) console.log(` Parameters: (${r.signature.params})`);
|
|
1533
|
+
if (r.signature.returnType) console.log(` Returns: ${r.signature.returnType}`);
|
|
1534
|
+
console.log();
|
|
1535
|
+
}
|
|
1536
|
+
|
|
1537
|
+
// Source
|
|
1538
|
+
if (r.source) {
|
|
1539
|
+
console.log('## Source');
|
|
1540
|
+
for (const line of r.source.split('\n')) {
|
|
1541
|
+
console.log(` ${line}`);
|
|
1542
|
+
}
|
|
1543
|
+
console.log();
|
|
1544
|
+
}
|
|
1545
|
+
|
|
1546
|
+
// Callees
|
|
1547
|
+
if (r.callees.length > 0) {
|
|
1548
|
+
console.log(`## Direct Dependencies (${r.callees.length})`);
|
|
1549
|
+
for (const c of r.callees) {
|
|
1550
|
+
const summary = c.summary ? ` — ${c.summary}` : '';
|
|
1551
|
+
console.log(` ${kindIcon(c.kind)} ${c.name} ${c.file}:${c.line}${summary}`);
|
|
1552
|
+
if (c.source) {
|
|
1553
|
+
for (const line of c.source.split('\n').slice(0, 10)) {
|
|
1554
|
+
console.log(` | ${line}`);
|
|
1555
|
+
}
|
|
1556
|
+
}
|
|
1557
|
+
}
|
|
1558
|
+
console.log();
|
|
1559
|
+
}
|
|
1560
|
+
|
|
1561
|
+
// Callers
|
|
1562
|
+
if (r.callers.length > 0) {
|
|
1563
|
+
console.log(`## Callers (${r.callers.length})`);
|
|
1564
|
+
for (const c of r.callers) {
|
|
1565
|
+
const via = c.viaHierarchy ? ` (via ${c.viaHierarchy})` : '';
|
|
1566
|
+
console.log(` ${kindIcon(c.kind)} ${c.name} ${c.file}:${c.line}${via}`);
|
|
1567
|
+
}
|
|
1568
|
+
console.log();
|
|
1569
|
+
}
|
|
1570
|
+
|
|
1571
|
+
// Related tests
|
|
1572
|
+
if (r.relatedTests.length > 0) {
|
|
1573
|
+
console.log('## Related Tests');
|
|
1574
|
+
for (const t of r.relatedTests) {
|
|
1575
|
+
console.log(` ${t.file} — ${t.testCount} tests`);
|
|
1576
|
+
for (const tn of t.testNames) {
|
|
1577
|
+
console.log(` - ${tn}`);
|
|
1578
|
+
}
|
|
1579
|
+
if (t.source) {
|
|
1580
|
+
console.log(' Source:');
|
|
1581
|
+
for (const line of t.source.split('\n').slice(0, 20)) {
|
|
1582
|
+
console.log(` | ${line}`);
|
|
1583
|
+
}
|
|
1584
|
+
}
|
|
1585
|
+
}
|
|
1586
|
+
console.log();
|
|
1587
|
+
}
|
|
1588
|
+
|
|
1589
|
+
if (r.callees.length === 0 && r.callers.length === 0 && r.relatedTests.length === 0) {
|
|
1590
|
+
console.log(
|
|
1591
|
+
' (no call edges or tests found — may be invoked dynamically or via re-exports)',
|
|
1592
|
+
);
|
|
1593
|
+
console.log();
|
|
1594
|
+
}
|
|
1595
|
+
}
|
|
1596
|
+
}
|
|
1597
|
+
|
|
1598
|
+
// ─── explainData ────────────────────────────────────────────────────────
|
|
1599
|
+
|
|
1600
|
+
function isFileLikeTarget(target) {
|
|
1601
|
+
if (target.includes('/') || target.includes('\\')) return true;
|
|
1602
|
+
const ext = path.extname(target).toLowerCase();
|
|
1603
|
+
if (!ext) return false;
|
|
1604
|
+
for (const entry of LANGUAGE_REGISTRY) {
|
|
1605
|
+
if (entry.extensions.includes(ext)) return true;
|
|
1606
|
+
}
|
|
1607
|
+
return false;
|
|
1608
|
+
}
|
|
1609
|
+
|
|
1610
|
+
function explainFileImpl(db, target, getFileLines) {
|
|
1611
|
+
const fileNodes = db
|
|
1612
|
+
.prepare(`SELECT * FROM nodes WHERE file LIKE ? AND kind = 'file'`)
|
|
1613
|
+
.all(`%${target}%`);
|
|
1614
|
+
if (fileNodes.length === 0) return [];
|
|
1615
|
+
|
|
1616
|
+
return fileNodes.map((fn) => {
|
|
1617
|
+
const symbols = db
|
|
1618
|
+
.prepare(`SELECT * FROM nodes WHERE file = ? AND kind != 'file' ORDER BY line`)
|
|
1619
|
+
.all(fn.file);
|
|
1620
|
+
|
|
1621
|
+
// IDs of symbols that have incoming calls from other files (public)
|
|
1622
|
+
const publicIds = new Set(
|
|
1623
|
+
db
|
|
1624
|
+
.prepare(
|
|
1625
|
+
`SELECT DISTINCT e.target_id FROM edges e
|
|
1626
|
+
JOIN nodes caller ON e.source_id = caller.id
|
|
1627
|
+
JOIN nodes target ON e.target_id = target.id
|
|
1628
|
+
WHERE target.file = ? AND caller.file != ? AND e.kind = 'calls'`,
|
|
1629
|
+
)
|
|
1630
|
+
.all(fn.file, fn.file)
|
|
1631
|
+
.map((r) => r.target_id),
|
|
1632
|
+
);
|
|
1633
|
+
|
|
1634
|
+
const fileLines = getFileLines(fn.file);
|
|
1635
|
+
const mapSymbol = (s) => ({
|
|
1636
|
+
name: s.name,
|
|
1637
|
+
kind: s.kind,
|
|
1638
|
+
line: s.line,
|
|
1639
|
+
summary: fileLines ? extractSummary(fileLines, s.line) : null,
|
|
1640
|
+
signature: fileLines ? extractSignature(fileLines, s.line) : null,
|
|
1641
|
+
});
|
|
1642
|
+
|
|
1643
|
+
const publicApi = symbols.filter((s) => publicIds.has(s.id)).map(mapSymbol);
|
|
1644
|
+
const internal = symbols.filter((s) => !publicIds.has(s.id)).map(mapSymbol);
|
|
1645
|
+
|
|
1646
|
+
// Imports / importedBy
|
|
1647
|
+
const imports = db
|
|
1648
|
+
.prepare(
|
|
1649
|
+
`SELECT n.file FROM edges e JOIN nodes n ON e.target_id = n.id
|
|
1650
|
+
WHERE e.source_id = ? AND e.kind IN ('imports', 'imports-type')`,
|
|
1651
|
+
)
|
|
1652
|
+
.all(fn.id)
|
|
1653
|
+
.map((r) => ({ file: r.file }));
|
|
1654
|
+
|
|
1655
|
+
const importedBy = db
|
|
1656
|
+
.prepare(
|
|
1657
|
+
`SELECT n.file FROM edges e JOIN nodes n ON e.source_id = n.id
|
|
1658
|
+
WHERE e.target_id = ? AND e.kind IN ('imports', 'imports-type')`,
|
|
1659
|
+
)
|
|
1660
|
+
.all(fn.id)
|
|
1661
|
+
.map((r) => ({ file: r.file }));
|
|
1662
|
+
|
|
1663
|
+
// Intra-file data flow
|
|
1664
|
+
const intraEdges = db
|
|
1665
|
+
.prepare(
|
|
1666
|
+
`SELECT caller.name as caller_name, callee.name as callee_name
|
|
1667
|
+
FROM edges e
|
|
1668
|
+
JOIN nodes caller ON e.source_id = caller.id
|
|
1669
|
+
JOIN nodes callee ON e.target_id = callee.id
|
|
1670
|
+
WHERE caller.file = ? AND callee.file = ? AND e.kind = 'calls'
|
|
1671
|
+
ORDER BY caller.line`,
|
|
1672
|
+
)
|
|
1673
|
+
.all(fn.file, fn.file);
|
|
1674
|
+
|
|
1675
|
+
const dataFlowMap = new Map();
|
|
1676
|
+
for (const edge of intraEdges) {
|
|
1677
|
+
if (!dataFlowMap.has(edge.caller_name)) dataFlowMap.set(edge.caller_name, []);
|
|
1678
|
+
dataFlowMap.get(edge.caller_name).push(edge.callee_name);
|
|
1679
|
+
}
|
|
1680
|
+
const dataFlow = [...dataFlowMap.entries()].map(([caller, callees]) => ({
|
|
1681
|
+
caller,
|
|
1682
|
+
callees,
|
|
1683
|
+
}));
|
|
1684
|
+
|
|
1685
|
+
// Line count: prefer node_metrics (actual), fall back to MAX(end_line)
|
|
1686
|
+
const metric = db
|
|
1687
|
+
.prepare(`SELECT nm.line_count FROM node_metrics nm WHERE nm.node_id = ?`)
|
|
1688
|
+
.get(fn.id);
|
|
1689
|
+
let lineCount = metric?.line_count || null;
|
|
1690
|
+
if (!lineCount) {
|
|
1691
|
+
const maxLine = db
|
|
1692
|
+
.prepare(`SELECT MAX(end_line) as max_end FROM nodes WHERE file = ?`)
|
|
1693
|
+
.get(fn.file);
|
|
1694
|
+
lineCount = maxLine?.max_end || null;
|
|
1695
|
+
}
|
|
1696
|
+
|
|
1697
|
+
return {
|
|
1698
|
+
file: fn.file,
|
|
1699
|
+
lineCount,
|
|
1700
|
+
symbolCount: symbols.length,
|
|
1701
|
+
publicApi,
|
|
1702
|
+
internal,
|
|
1703
|
+
imports,
|
|
1704
|
+
importedBy,
|
|
1705
|
+
dataFlow,
|
|
1706
|
+
};
|
|
1707
|
+
});
|
|
1708
|
+
}
|
|
1709
|
+
|
|
1710
|
+
function explainFunctionImpl(db, target, noTests, getFileLines) {
|
|
1711
|
+
let nodes = db
|
|
1712
|
+
.prepare(
|
|
1713
|
+
`SELECT * FROM nodes WHERE name LIKE ? AND kind IN ('function','method','class','interface','type','struct','enum','trait','record','module') ORDER BY file, line`,
|
|
1714
|
+
)
|
|
1715
|
+
.all(`%${target}%`);
|
|
1716
|
+
if (noTests) nodes = nodes.filter((n) => !isTestFile(n.file));
|
|
1717
|
+
if (nodes.length === 0) return [];
|
|
1718
|
+
|
|
1719
|
+
return nodes.slice(0, 10).map((node) => {
|
|
1720
|
+
const fileLines = getFileLines(node.file);
|
|
1721
|
+
const lineCount = node.end_line ? node.end_line - node.line + 1 : null;
|
|
1722
|
+
const summary = fileLines ? extractSummary(fileLines, node.line) : null;
|
|
1723
|
+
const signature = fileLines ? extractSignature(fileLines, node.line) : null;
|
|
1724
|
+
|
|
1725
|
+
const callees = db
|
|
1726
|
+
.prepare(
|
|
1727
|
+
`SELECT n.name, n.kind, n.file, n.line
|
|
1728
|
+
FROM edges e JOIN nodes n ON e.target_id = n.id
|
|
1729
|
+
WHERE e.source_id = ? AND e.kind = 'calls'`,
|
|
1730
|
+
)
|
|
1731
|
+
.all(node.id)
|
|
1732
|
+
.map((c) => ({ name: c.name, kind: c.kind, file: c.file, line: c.line }));
|
|
1733
|
+
|
|
1734
|
+
let callers = db
|
|
1735
|
+
.prepare(
|
|
1736
|
+
`SELECT n.name, n.kind, n.file, n.line
|
|
1737
|
+
FROM edges e JOIN nodes n ON e.source_id = n.id
|
|
1738
|
+
WHERE e.target_id = ? AND e.kind = 'calls'`,
|
|
1739
|
+
)
|
|
1740
|
+
.all(node.id)
|
|
1741
|
+
.map((c) => ({ name: c.name, kind: c.kind, file: c.file, line: c.line }));
|
|
1742
|
+
if (noTests) callers = callers.filter((c) => !isTestFile(c.file));
|
|
1743
|
+
|
|
1744
|
+
const testCallerRows = db
|
|
1745
|
+
.prepare(
|
|
1746
|
+
`SELECT DISTINCT n.file FROM edges e JOIN nodes n ON e.source_id = n.id
|
|
1747
|
+
WHERE e.target_id = ? AND e.kind = 'calls'`,
|
|
1748
|
+
)
|
|
1749
|
+
.all(node.id);
|
|
1750
|
+
const relatedTests = testCallerRows
|
|
1751
|
+
.filter((r) => isTestFile(r.file))
|
|
1752
|
+
.map((r) => ({ file: r.file }));
|
|
1753
|
+
|
|
1754
|
+
return {
|
|
1755
|
+
name: node.name,
|
|
1756
|
+
kind: node.kind,
|
|
1757
|
+
file: node.file,
|
|
1758
|
+
line: node.line,
|
|
1759
|
+
endLine: node.end_line || null,
|
|
1760
|
+
lineCount,
|
|
1761
|
+
summary,
|
|
1762
|
+
signature,
|
|
1763
|
+
callees,
|
|
1764
|
+
callers,
|
|
1765
|
+
relatedTests,
|
|
1766
|
+
};
|
|
1767
|
+
});
|
|
1768
|
+
}
|
|
1769
|
+
|
|
1770
|
+
export function explainData(target, customDbPath, opts = {}) {
|
|
1771
|
+
const db = openReadonlyOrFail(customDbPath);
|
|
1772
|
+
const noTests = opts.noTests || false;
|
|
1773
|
+
const kind = isFileLikeTarget(target) ? 'file' : 'function';
|
|
1774
|
+
|
|
1775
|
+
const dbPath = findDbPath(customDbPath);
|
|
1776
|
+
const repoRoot = path.resolve(path.dirname(dbPath), '..');
|
|
1777
|
+
|
|
1778
|
+
const fileCache = new Map();
|
|
1779
|
+
function getFileLines(file) {
|
|
1780
|
+
if (fileCache.has(file)) return fileCache.get(file);
|
|
1781
|
+
try {
|
|
1782
|
+
const absPath = safePath(repoRoot, file);
|
|
1783
|
+
if (!absPath) {
|
|
1784
|
+
fileCache.set(file, null);
|
|
1785
|
+
return null;
|
|
1786
|
+
}
|
|
1787
|
+
const lines = fs.readFileSync(absPath, 'utf-8').split('\n');
|
|
1788
|
+
fileCache.set(file, lines);
|
|
1789
|
+
return lines;
|
|
1790
|
+
} catch (e) {
|
|
1791
|
+
debug(`getFileLines failed for ${file}: ${e.message}`);
|
|
1792
|
+
fileCache.set(file, null);
|
|
1793
|
+
return null;
|
|
1794
|
+
}
|
|
1795
|
+
}
|
|
1796
|
+
|
|
1797
|
+
const results =
|
|
1798
|
+
kind === 'file'
|
|
1799
|
+
? explainFileImpl(db, target, getFileLines)
|
|
1800
|
+
: explainFunctionImpl(db, target, noTests, getFileLines);
|
|
1801
|
+
|
|
1802
|
+
db.close();
|
|
1803
|
+
return { target, kind, results };
|
|
1804
|
+
}
|
|
1805
|
+
|
|
1806
|
+
export function explain(target, customDbPath, opts = {}) {
|
|
1807
|
+
const data = explainData(target, customDbPath, opts);
|
|
1808
|
+
if (opts.json) {
|
|
1809
|
+
console.log(JSON.stringify(data, null, 2));
|
|
1810
|
+
return;
|
|
1811
|
+
}
|
|
1812
|
+
if (data.results.length === 0) {
|
|
1813
|
+
console.log(`No ${data.kind === 'file' ? 'file' : 'function/symbol'} matching "${target}"`);
|
|
1814
|
+
return;
|
|
1815
|
+
}
|
|
1816
|
+
|
|
1817
|
+
if (data.kind === 'file') {
|
|
1818
|
+
for (const r of data.results) {
|
|
1819
|
+
const publicCount = r.publicApi.length;
|
|
1820
|
+
const internalCount = r.internal.length;
|
|
1821
|
+
const lineInfo = r.lineCount ? `${r.lineCount} lines, ` : '';
|
|
1822
|
+
console.log(`\n# ${r.file}`);
|
|
1823
|
+
console.log(
|
|
1824
|
+
` ${lineInfo}${r.symbolCount} symbols (${publicCount} exported, ${internalCount} internal)`,
|
|
1825
|
+
);
|
|
1826
|
+
|
|
1827
|
+
if (r.imports.length > 0) {
|
|
1828
|
+
console.log(` Imports: ${r.imports.map((i) => i.file).join(', ')}`);
|
|
1829
|
+
}
|
|
1830
|
+
if (r.importedBy.length > 0) {
|
|
1831
|
+
console.log(` Imported by: ${r.importedBy.map((i) => i.file).join(', ')}`);
|
|
1832
|
+
}
|
|
1833
|
+
|
|
1834
|
+
if (r.publicApi.length > 0) {
|
|
1835
|
+
console.log(`\n## Exported`);
|
|
1836
|
+
for (const s of r.publicApi) {
|
|
1837
|
+
const sig = s.signature?.params != null ? `(${s.signature.params})` : '';
|
|
1838
|
+
const summary = s.summary ? ` -- ${s.summary}` : '';
|
|
1839
|
+
console.log(` ${kindIcon(s.kind)} ${s.name}${sig} :${s.line}${summary}`);
|
|
1840
|
+
}
|
|
1841
|
+
}
|
|
1842
|
+
|
|
1843
|
+
if (r.internal.length > 0) {
|
|
1844
|
+
console.log(`\n## Internal`);
|
|
1845
|
+
for (const s of r.internal) {
|
|
1846
|
+
const sig = s.signature?.params != null ? `(${s.signature.params})` : '';
|
|
1847
|
+
const summary = s.summary ? ` -- ${s.summary}` : '';
|
|
1848
|
+
console.log(` ${kindIcon(s.kind)} ${s.name}${sig} :${s.line}${summary}`);
|
|
1849
|
+
}
|
|
1850
|
+
}
|
|
1851
|
+
|
|
1852
|
+
if (r.dataFlow.length > 0) {
|
|
1853
|
+
console.log(`\n## Data Flow`);
|
|
1854
|
+
for (const df of r.dataFlow) {
|
|
1855
|
+
console.log(` ${df.caller} -> ${df.callees.join(', ')}`);
|
|
1856
|
+
}
|
|
1857
|
+
}
|
|
1858
|
+
console.log();
|
|
1859
|
+
}
|
|
1860
|
+
} else {
|
|
1861
|
+
for (const r of data.results) {
|
|
1862
|
+
const lineRange = r.endLine ? `${r.line}-${r.endLine}` : `${r.line}`;
|
|
1863
|
+
const lineInfo = r.lineCount ? `${r.lineCount} lines` : '';
|
|
1864
|
+
const summaryPart = r.summary ? ` | ${r.summary}` : '';
|
|
1865
|
+
console.log(`\n# ${r.name} (${r.kind}) ${r.file}:${lineRange}`);
|
|
1866
|
+
if (lineInfo || r.summary) {
|
|
1867
|
+
console.log(` ${lineInfo}${summaryPart}`);
|
|
1868
|
+
}
|
|
1869
|
+
if (r.signature) {
|
|
1870
|
+
if (r.signature.params != null) console.log(` Parameters: (${r.signature.params})`);
|
|
1871
|
+
if (r.signature.returnType) console.log(` Returns: ${r.signature.returnType}`);
|
|
1872
|
+
}
|
|
1873
|
+
|
|
1874
|
+
if (r.callees.length > 0) {
|
|
1875
|
+
console.log(`\n## Calls (${r.callees.length})`);
|
|
1876
|
+
for (const c of r.callees) {
|
|
1877
|
+
console.log(` ${kindIcon(c.kind)} ${c.name} ${c.file}:${c.line}`);
|
|
1878
|
+
}
|
|
1879
|
+
}
|
|
1880
|
+
|
|
1881
|
+
if (r.callers.length > 0) {
|
|
1882
|
+
console.log(`\n## Called by (${r.callers.length})`);
|
|
1883
|
+
for (const c of r.callers) {
|
|
1884
|
+
console.log(` ${kindIcon(c.kind)} ${c.name} ${c.file}:${c.line}`);
|
|
1885
|
+
}
|
|
1886
|
+
}
|
|
1887
|
+
|
|
1888
|
+
if (r.relatedTests.length > 0) {
|
|
1889
|
+
const label = r.relatedTests.length === 1 ? 'file' : 'files';
|
|
1890
|
+
console.log(`\n## Tests (${r.relatedTests.length} ${label})`);
|
|
1891
|
+
for (const t of r.relatedTests) {
|
|
1892
|
+
console.log(` ${t.file}`);
|
|
1893
|
+
}
|
|
1894
|
+
}
|
|
1895
|
+
|
|
1896
|
+
if (r.callees.length === 0 && r.callers.length === 0) {
|
|
1897
|
+
console.log(` (no call edges found -- may be invoked dynamically or via re-exports)`);
|
|
1898
|
+
}
|
|
1899
|
+
console.log();
|
|
1900
|
+
}
|
|
1901
|
+
}
|
|
1902
|
+
}
|
|
1903
|
+
|
|
1904
|
+
// ─── whereData ──────────────────────────────────────────────────────────
|
|
1905
|
+
|
|
1906
|
+
function whereSymbolImpl(db, target, noTests) {
|
|
1907
|
+
const placeholders = ALL_SYMBOL_KINDS.map(() => '?').join(', ');
|
|
1908
|
+
let nodes = db
|
|
1909
|
+
.prepare(
|
|
1910
|
+
`SELECT * FROM nodes WHERE name LIKE ? AND kind IN (${placeholders}) ORDER BY file, line`,
|
|
1911
|
+
)
|
|
1912
|
+
.all(`%${target}%`, ...ALL_SYMBOL_KINDS);
|
|
1913
|
+
if (noTests) nodes = nodes.filter((n) => !isTestFile(n.file));
|
|
1914
|
+
|
|
1915
|
+
return nodes.map((node) => {
|
|
1916
|
+
const crossFileCallers = db
|
|
1917
|
+
.prepare(
|
|
1918
|
+
`SELECT COUNT(*) as cnt FROM edges e JOIN nodes n ON e.source_id = n.id
|
|
1919
|
+
WHERE e.target_id = ? AND e.kind = 'calls' AND n.file != ?`,
|
|
1920
|
+
)
|
|
1921
|
+
.get(node.id, node.file);
|
|
1922
|
+
const exported = crossFileCallers.cnt > 0;
|
|
1923
|
+
|
|
1924
|
+
let uses = db
|
|
1925
|
+
.prepare(
|
|
1926
|
+
`SELECT n.name, n.file, n.line FROM edges e JOIN nodes n ON e.source_id = n.id
|
|
1927
|
+
WHERE e.target_id = ? AND e.kind = 'calls'`,
|
|
1928
|
+
)
|
|
1929
|
+
.all(node.id);
|
|
1930
|
+
if (noTests) uses = uses.filter((u) => !isTestFile(u.file));
|
|
1931
|
+
|
|
1932
|
+
return {
|
|
1933
|
+
name: node.name,
|
|
1934
|
+
kind: node.kind,
|
|
1935
|
+
file: node.file,
|
|
1936
|
+
line: node.line,
|
|
1937
|
+
exported,
|
|
1938
|
+
uses: uses.map((u) => ({ name: u.name, file: u.file, line: u.line })),
|
|
1939
|
+
};
|
|
1940
|
+
});
|
|
1941
|
+
}
|
|
1942
|
+
|
|
1943
|
+
function whereFileImpl(db, target) {
|
|
1944
|
+
const fileNodes = db
|
|
1945
|
+
.prepare(`SELECT * FROM nodes WHERE file LIKE ? AND kind = 'file'`)
|
|
1946
|
+
.all(`%${target}%`);
|
|
1947
|
+
if (fileNodes.length === 0) return [];
|
|
1948
|
+
|
|
1949
|
+
return fileNodes.map((fn) => {
|
|
1950
|
+
const symbols = db
|
|
1951
|
+
.prepare(`SELECT * FROM nodes WHERE file = ? AND kind != 'file' ORDER BY line`)
|
|
1952
|
+
.all(fn.file);
|
|
1953
|
+
|
|
1954
|
+
const imports = db
|
|
1955
|
+
.prepare(
|
|
1956
|
+
`SELECT n.file FROM edges e JOIN nodes n ON e.target_id = n.id
|
|
1957
|
+
WHERE e.source_id = ? AND e.kind IN ('imports', 'imports-type')`,
|
|
1958
|
+
)
|
|
1959
|
+
.all(fn.id)
|
|
1960
|
+
.map((r) => r.file);
|
|
1961
|
+
|
|
1962
|
+
const importedBy = db
|
|
1963
|
+
.prepare(
|
|
1964
|
+
`SELECT n.file FROM edges e JOIN nodes n ON e.source_id = n.id
|
|
1965
|
+
WHERE e.target_id = ? AND e.kind IN ('imports', 'imports-type')`,
|
|
1966
|
+
)
|
|
1967
|
+
.all(fn.id)
|
|
1968
|
+
.map((r) => r.file);
|
|
1969
|
+
|
|
1970
|
+
const exportedIds = new Set(
|
|
1971
|
+
db
|
|
1972
|
+
.prepare(
|
|
1973
|
+
`SELECT DISTINCT e.target_id FROM edges e
|
|
1974
|
+
JOIN nodes caller ON e.source_id = caller.id
|
|
1975
|
+
JOIN nodes target ON e.target_id = target.id
|
|
1976
|
+
WHERE target.file = ? AND caller.file != ? AND e.kind = 'calls'`,
|
|
1977
|
+
)
|
|
1978
|
+
.all(fn.file, fn.file)
|
|
1979
|
+
.map((r) => r.target_id),
|
|
1980
|
+
);
|
|
1981
|
+
|
|
1982
|
+
const exported = symbols.filter((s) => exportedIds.has(s.id)).map((s) => s.name);
|
|
1983
|
+
|
|
1984
|
+
return {
|
|
1985
|
+
file: fn.file,
|
|
1986
|
+
symbols: symbols.map((s) => ({ name: s.name, kind: s.kind, line: s.line })),
|
|
1987
|
+
imports,
|
|
1988
|
+
importedBy,
|
|
1989
|
+
exported,
|
|
1990
|
+
};
|
|
1991
|
+
});
|
|
1992
|
+
}
|
|
1993
|
+
|
|
1994
|
+
export function whereData(target, customDbPath, opts = {}) {
|
|
1995
|
+
const db = openReadonlyOrFail(customDbPath);
|
|
1996
|
+
const noTests = opts.noTests || false;
|
|
1997
|
+
const fileMode = opts.file || false;
|
|
1998
|
+
|
|
1999
|
+
const results = fileMode ? whereFileImpl(db, target) : whereSymbolImpl(db, target, noTests);
|
|
2000
|
+
|
|
2001
|
+
db.close();
|
|
2002
|
+
return { target, mode: fileMode ? 'file' : 'symbol', results };
|
|
2003
|
+
}
|
|
2004
|
+
|
|
2005
|
+
export function where(target, customDbPath, opts = {}) {
|
|
2006
|
+
const data = whereData(target, customDbPath, opts);
|
|
2007
|
+
if (opts.json) {
|
|
2008
|
+
console.log(JSON.stringify(data, null, 2));
|
|
2009
|
+
return;
|
|
2010
|
+
}
|
|
2011
|
+
|
|
2012
|
+
if (data.results.length === 0) {
|
|
2013
|
+
console.log(
|
|
2014
|
+
data.mode === 'file'
|
|
2015
|
+
? `No file matching "${target}" in graph`
|
|
2016
|
+
: `No symbol matching "${target}" in graph`,
|
|
2017
|
+
);
|
|
2018
|
+
return;
|
|
2019
|
+
}
|
|
2020
|
+
|
|
2021
|
+
if (data.mode === 'symbol') {
|
|
2022
|
+
for (const r of data.results) {
|
|
2023
|
+
const tag = r.exported ? ' (exported)' : '';
|
|
2024
|
+
console.log(`\n${kindIcon(r.kind)} ${r.name} ${r.file}:${r.line}${tag}`);
|
|
2025
|
+
if (r.uses.length > 0) {
|
|
2026
|
+
const useStrs = r.uses.map((u) => `${u.file}:${u.line}`);
|
|
2027
|
+
console.log(` Used in: ${useStrs.join(', ')}`);
|
|
2028
|
+
} else {
|
|
2029
|
+
console.log(' No uses found');
|
|
2030
|
+
}
|
|
2031
|
+
}
|
|
2032
|
+
} else {
|
|
2033
|
+
for (const r of data.results) {
|
|
2034
|
+
console.log(`\n# ${r.file}`);
|
|
2035
|
+
if (r.symbols.length > 0) {
|
|
2036
|
+
const symStrs = r.symbols.map((s) => `${s.name}:${s.line}`);
|
|
2037
|
+
console.log(` Symbols: ${symStrs.join(', ')}`);
|
|
2038
|
+
}
|
|
2039
|
+
if (r.imports.length > 0) {
|
|
2040
|
+
console.log(` Imports: ${r.imports.join(', ')}`);
|
|
2041
|
+
}
|
|
2042
|
+
if (r.importedBy.length > 0) {
|
|
2043
|
+
console.log(` Imported by: ${r.importedBy.join(', ')}`);
|
|
2044
|
+
}
|
|
2045
|
+
if (r.exported.length > 0) {
|
|
2046
|
+
console.log(` Exported: ${r.exported.join(', ')}`);
|
|
2047
|
+
}
|
|
2048
|
+
}
|
|
2049
|
+
}
|
|
2050
|
+
console.log();
|
|
2051
|
+
}
|
|
2052
|
+
|
|
948
2053
|
export function fnImpact(name, customDbPath, opts = {}) {
|
|
949
2054
|
const data = fnImpactData(name, customDbPath, opts);
|
|
950
2055
|
if (opts.json) {
|