@optave/codegraph 2.1.0 → 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/README.md +21 -20
- package/package.json +5 -5
- package/src/builder.js +238 -33
- package/src/cli.js +73 -0
- package/src/db.js +4 -0
- 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 +121 -3
- package/src/parser.js +1 -0
- package/src/queries.js +1069 -22
- package/src/structure.js +14 -4
- 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
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;
|
|
@@ -945,6 +1139,859 @@ export function fnDeps(name, customDbPath, opts = {}) {
|
|
|
945
1139
|
}
|
|
946
1140
|
}
|
|
947
1141
|
|
|
1142
|
+
// ─── Context helpers (private) ──────────────────────────────────────────
|
|
1143
|
+
|
|
1144
|
+
function readSourceRange(repoRoot, file, startLine, endLine) {
|
|
1145
|
+
try {
|
|
1146
|
+
const absPath = safePath(repoRoot, file);
|
|
1147
|
+
if (!absPath) return null;
|
|
1148
|
+
const content = fs.readFileSync(absPath, 'utf-8');
|
|
1149
|
+
const lines = content.split('\n');
|
|
1150
|
+
const start = Math.max(0, (startLine || 1) - 1);
|
|
1151
|
+
const end = Math.min(lines.length, endLine || startLine + 50);
|
|
1152
|
+
return lines.slice(start, end).join('\n');
|
|
1153
|
+
} catch (e) {
|
|
1154
|
+
debug(`readSourceRange failed for ${file}: ${e.message}`);
|
|
1155
|
+
return null;
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
function extractSummary(fileLines, line) {
|
|
1160
|
+
if (!fileLines || !line || line <= 1) return null;
|
|
1161
|
+
const idx = line - 2; // line above the definition (0-indexed)
|
|
1162
|
+
// Scan up to 10 lines above for JSDoc or comment
|
|
1163
|
+
let jsdocEnd = -1;
|
|
1164
|
+
for (let i = idx; i >= Math.max(0, idx - 10); i--) {
|
|
1165
|
+
const trimmed = fileLines[i].trim();
|
|
1166
|
+
if (trimmed.endsWith('*/')) {
|
|
1167
|
+
jsdocEnd = i;
|
|
1168
|
+
break;
|
|
1169
|
+
}
|
|
1170
|
+
if (trimmed.startsWith('//') || trimmed.startsWith('#')) {
|
|
1171
|
+
// Single-line comment immediately above
|
|
1172
|
+
const text = trimmed
|
|
1173
|
+
.replace(/^\/\/\s*/, '')
|
|
1174
|
+
.replace(/^#\s*/, '')
|
|
1175
|
+
.trim();
|
|
1176
|
+
return text.length > 100 ? `${text.slice(0, 100)}...` : text;
|
|
1177
|
+
}
|
|
1178
|
+
if (trimmed !== '' && !trimmed.startsWith('*') && !trimmed.startsWith('/*')) break;
|
|
1179
|
+
}
|
|
1180
|
+
if (jsdocEnd >= 0) {
|
|
1181
|
+
// Find opening /**
|
|
1182
|
+
for (let i = jsdocEnd; i >= Math.max(0, jsdocEnd - 20); i--) {
|
|
1183
|
+
if (fileLines[i].trim().startsWith('/**')) {
|
|
1184
|
+
// Extract first non-tag, non-empty line
|
|
1185
|
+
for (let j = i + 1; j <= jsdocEnd; j++) {
|
|
1186
|
+
const docLine = fileLines[j]
|
|
1187
|
+
.trim()
|
|
1188
|
+
.replace(/^\*\s?/, '')
|
|
1189
|
+
.trim();
|
|
1190
|
+
if (docLine && !docLine.startsWith('@') && docLine !== '/' && docLine !== '*/') {
|
|
1191
|
+
return docLine.length > 100 ? `${docLine.slice(0, 100)}...` : docLine;
|
|
1192
|
+
}
|
|
1193
|
+
}
|
|
1194
|
+
break;
|
|
1195
|
+
}
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
return null;
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
function extractSignature(fileLines, line) {
|
|
1202
|
+
if (!fileLines || !line) return null;
|
|
1203
|
+
const idx = line - 1;
|
|
1204
|
+
// Gather up to 5 lines to handle multi-line params
|
|
1205
|
+
const chunk = fileLines.slice(idx, Math.min(fileLines.length, idx + 5)).join('\n');
|
|
1206
|
+
|
|
1207
|
+
// JS/TS: function name(params) or (params) => or async function
|
|
1208
|
+
let m = chunk.match(
|
|
1209
|
+
/(?:export\s+)?(?:async\s+)?function\s*\*?\s*\w*\s*\(([^)]*)\)\s*(?::\s*([^\n{]+))?/,
|
|
1210
|
+
);
|
|
1211
|
+
if (m) {
|
|
1212
|
+
return {
|
|
1213
|
+
params: m[1].trim() || null,
|
|
1214
|
+
returnType: m[2] ? m[2].trim().replace(/\s*\{$/, '') : null,
|
|
1215
|
+
};
|
|
1216
|
+
}
|
|
1217
|
+
// Arrow: const name = (params) => or (params):ReturnType =>
|
|
1218
|
+
m = chunk.match(/=\s*(?:async\s+)?\(([^)]*)\)\s*(?::\s*([^=>\n{]+))?\s*=>/);
|
|
1219
|
+
if (m) {
|
|
1220
|
+
return {
|
|
1221
|
+
params: m[1].trim() || null,
|
|
1222
|
+
returnType: m[2] ? m[2].trim() : null,
|
|
1223
|
+
};
|
|
1224
|
+
}
|
|
1225
|
+
// Python: def name(params) -> return:
|
|
1226
|
+
m = chunk.match(/def\s+\w+\s*\(([^)]*)\)\s*(?:->\s*([^:\n]+))?/);
|
|
1227
|
+
if (m) {
|
|
1228
|
+
return {
|
|
1229
|
+
params: m[1].trim() || null,
|
|
1230
|
+
returnType: m[2] ? m[2].trim() : null,
|
|
1231
|
+
};
|
|
1232
|
+
}
|
|
1233
|
+
// Go: func (recv) name(params) (returns)
|
|
1234
|
+
m = chunk.match(/func\s+(?:\([^)]*\)\s+)?\w+\s*\(([^)]*)\)\s*(?:\(([^)]+)\)|(\w[^\n{]*))?/);
|
|
1235
|
+
if (m) {
|
|
1236
|
+
return {
|
|
1237
|
+
params: m[1].trim() || null,
|
|
1238
|
+
returnType: (m[2] || m[3] || '').trim() || null,
|
|
1239
|
+
};
|
|
1240
|
+
}
|
|
1241
|
+
// Rust: fn name(params) -> ReturnType
|
|
1242
|
+
m = chunk.match(/fn\s+\w+\s*\(([^)]*)\)\s*(?:->\s*([^\n{]+))?/);
|
|
1243
|
+
if (m) {
|
|
1244
|
+
return {
|
|
1245
|
+
params: m[1].trim() || null,
|
|
1246
|
+
returnType: m[2] ? m[2].trim() : null,
|
|
1247
|
+
};
|
|
1248
|
+
}
|
|
1249
|
+
return null;
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
// ─── contextData ────────────────────────────────────────────────────────
|
|
1253
|
+
|
|
1254
|
+
export function contextData(name, customDbPath, opts = {}) {
|
|
1255
|
+
const db = openReadonlyOrFail(customDbPath);
|
|
1256
|
+
const depth = opts.depth || 0;
|
|
1257
|
+
const noSource = opts.noSource || false;
|
|
1258
|
+
const noTests = opts.noTests || false;
|
|
1259
|
+
const includeTests = opts.includeTests || false;
|
|
1260
|
+
|
|
1261
|
+
const dbPath = findDbPath(customDbPath);
|
|
1262
|
+
const repoRoot = path.resolve(path.dirname(dbPath), '..');
|
|
1263
|
+
|
|
1264
|
+
let nodes = findMatchingNodes(db, name, { noTests, file: opts.file, kind: opts.kind });
|
|
1265
|
+
if (nodes.length === 0) {
|
|
1266
|
+
db.close();
|
|
1267
|
+
return { name, results: [] };
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
// Limit to first 5 results
|
|
1271
|
+
nodes = nodes.slice(0, 5);
|
|
1272
|
+
|
|
1273
|
+
// File-lines cache to avoid re-reading the same file
|
|
1274
|
+
const fileCache = new Map();
|
|
1275
|
+
function getFileLines(file) {
|
|
1276
|
+
if (fileCache.has(file)) return fileCache.get(file);
|
|
1277
|
+
try {
|
|
1278
|
+
const absPath = safePath(repoRoot, file);
|
|
1279
|
+
if (!absPath) {
|
|
1280
|
+
fileCache.set(file, null);
|
|
1281
|
+
return null;
|
|
1282
|
+
}
|
|
1283
|
+
const lines = fs.readFileSync(absPath, 'utf-8').split('\n');
|
|
1284
|
+
fileCache.set(file, lines);
|
|
1285
|
+
return lines;
|
|
1286
|
+
} catch (e) {
|
|
1287
|
+
debug(`getFileLines failed for ${file}: ${e.message}`);
|
|
1288
|
+
fileCache.set(file, null);
|
|
1289
|
+
return null;
|
|
1290
|
+
}
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
const results = nodes.map((node) => {
|
|
1294
|
+
const fileLines = getFileLines(node.file);
|
|
1295
|
+
|
|
1296
|
+
// Source
|
|
1297
|
+
const source = noSource ? null : readSourceRange(repoRoot, node.file, node.line, node.end_line);
|
|
1298
|
+
|
|
1299
|
+
// Signature
|
|
1300
|
+
const signature = fileLines ? extractSignature(fileLines, node.line) : null;
|
|
1301
|
+
|
|
1302
|
+
// Callees
|
|
1303
|
+
const calleeRows = db
|
|
1304
|
+
.prepare(
|
|
1305
|
+
`SELECT n.id, n.name, n.kind, n.file, n.line, n.end_line
|
|
1306
|
+
FROM edges e JOIN nodes n ON e.target_id = n.id
|
|
1307
|
+
WHERE e.source_id = ? AND e.kind = 'calls'`,
|
|
1308
|
+
)
|
|
1309
|
+
.all(node.id);
|
|
1310
|
+
const filteredCallees = noTests ? calleeRows.filter((c) => !isTestFile(c.file)) : calleeRows;
|
|
1311
|
+
|
|
1312
|
+
const callees = filteredCallees.map((c) => {
|
|
1313
|
+
const cLines = getFileLines(c.file);
|
|
1314
|
+
const summary = cLines ? extractSummary(cLines, c.line) : null;
|
|
1315
|
+
let calleeSource = null;
|
|
1316
|
+
if (depth >= 1) {
|
|
1317
|
+
calleeSource = readSourceRange(repoRoot, c.file, c.line, c.end_line);
|
|
1318
|
+
}
|
|
1319
|
+
return {
|
|
1320
|
+
name: c.name,
|
|
1321
|
+
kind: c.kind,
|
|
1322
|
+
file: c.file,
|
|
1323
|
+
line: c.line,
|
|
1324
|
+
endLine: c.end_line || null,
|
|
1325
|
+
summary,
|
|
1326
|
+
source: calleeSource,
|
|
1327
|
+
};
|
|
1328
|
+
});
|
|
1329
|
+
|
|
1330
|
+
// Deep callee expansion via BFS (depth > 1, capped at 5)
|
|
1331
|
+
if (depth > 1) {
|
|
1332
|
+
const visited = new Set(filteredCallees.map((c) => c.id));
|
|
1333
|
+
visited.add(node.id);
|
|
1334
|
+
let frontier = filteredCallees.map((c) => c.id);
|
|
1335
|
+
const maxDepth = Math.min(depth, 5);
|
|
1336
|
+
for (let d = 2; d <= maxDepth; d++) {
|
|
1337
|
+
const nextFrontier = [];
|
|
1338
|
+
for (const fid of frontier) {
|
|
1339
|
+
const deeper = db
|
|
1340
|
+
.prepare(
|
|
1341
|
+
`SELECT n.id, n.name, n.kind, n.file, n.line, n.end_line
|
|
1342
|
+
FROM edges e JOIN nodes n ON e.target_id = n.id
|
|
1343
|
+
WHERE e.source_id = ? AND e.kind = 'calls'`,
|
|
1344
|
+
)
|
|
1345
|
+
.all(fid);
|
|
1346
|
+
for (const c of deeper) {
|
|
1347
|
+
if (!visited.has(c.id) && (!noTests || !isTestFile(c.file))) {
|
|
1348
|
+
visited.add(c.id);
|
|
1349
|
+
nextFrontier.push(c.id);
|
|
1350
|
+
const cLines = getFileLines(c.file);
|
|
1351
|
+
callees.push({
|
|
1352
|
+
name: c.name,
|
|
1353
|
+
kind: c.kind,
|
|
1354
|
+
file: c.file,
|
|
1355
|
+
line: c.line,
|
|
1356
|
+
endLine: c.end_line || null,
|
|
1357
|
+
summary: cLines ? extractSummary(cLines, c.line) : null,
|
|
1358
|
+
source: readSourceRange(repoRoot, c.file, c.line, c.end_line),
|
|
1359
|
+
});
|
|
1360
|
+
}
|
|
1361
|
+
}
|
|
1362
|
+
}
|
|
1363
|
+
frontier = nextFrontier;
|
|
1364
|
+
if (frontier.length === 0) break;
|
|
1365
|
+
}
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
// Callers
|
|
1369
|
+
let callerRows = db
|
|
1370
|
+
.prepare(
|
|
1371
|
+
`SELECT n.name, n.kind, n.file, n.line
|
|
1372
|
+
FROM edges e JOIN nodes n ON e.source_id = n.id
|
|
1373
|
+
WHERE e.target_id = ? AND e.kind = 'calls'`,
|
|
1374
|
+
)
|
|
1375
|
+
.all(node.id);
|
|
1376
|
+
|
|
1377
|
+
// Method hierarchy resolution
|
|
1378
|
+
if (node.kind === 'method' && node.name.includes('.')) {
|
|
1379
|
+
const methodName = node.name.split('.').pop();
|
|
1380
|
+
const relatedMethods = resolveMethodViaHierarchy(db, methodName);
|
|
1381
|
+
for (const rm of relatedMethods) {
|
|
1382
|
+
if (rm.id === node.id) continue;
|
|
1383
|
+
const extraCallers = db
|
|
1384
|
+
.prepare(
|
|
1385
|
+
`SELECT n.name, n.kind, n.file, n.line
|
|
1386
|
+
FROM edges e JOIN nodes n ON e.source_id = n.id
|
|
1387
|
+
WHERE e.target_id = ? AND e.kind = 'calls'`,
|
|
1388
|
+
)
|
|
1389
|
+
.all(rm.id);
|
|
1390
|
+
callerRows.push(...extraCallers.map((c) => ({ ...c, viaHierarchy: rm.name })));
|
|
1391
|
+
}
|
|
1392
|
+
}
|
|
1393
|
+
if (noTests) callerRows = callerRows.filter((c) => !isTestFile(c.file));
|
|
1394
|
+
|
|
1395
|
+
const callers = callerRows.map((c) => ({
|
|
1396
|
+
name: c.name,
|
|
1397
|
+
kind: c.kind,
|
|
1398
|
+
file: c.file,
|
|
1399
|
+
line: c.line,
|
|
1400
|
+
viaHierarchy: c.viaHierarchy || undefined,
|
|
1401
|
+
}));
|
|
1402
|
+
|
|
1403
|
+
// Related tests: callers that live in test files
|
|
1404
|
+
const testCallerRows = db
|
|
1405
|
+
.prepare(
|
|
1406
|
+
`SELECT n.name, n.kind, n.file, n.line
|
|
1407
|
+
FROM edges e JOIN nodes n ON e.source_id = n.id
|
|
1408
|
+
WHERE e.target_id = ? AND e.kind = 'calls'`,
|
|
1409
|
+
)
|
|
1410
|
+
.all(node.id);
|
|
1411
|
+
const testCallers = testCallerRows.filter((c) => isTestFile(c.file));
|
|
1412
|
+
|
|
1413
|
+
const testsByFile = new Map();
|
|
1414
|
+
for (const tc of testCallers) {
|
|
1415
|
+
if (!testsByFile.has(tc.file)) testsByFile.set(tc.file, []);
|
|
1416
|
+
testsByFile.get(tc.file).push(tc);
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
const relatedTests = [];
|
|
1420
|
+
for (const [file] of testsByFile) {
|
|
1421
|
+
const tLines = getFileLines(file);
|
|
1422
|
+
const testNames = [];
|
|
1423
|
+
if (tLines) {
|
|
1424
|
+
for (const tl of tLines) {
|
|
1425
|
+
const tm = tl.match(/(?:it|test|describe)\s*\(\s*['"`]([^'"`]+)['"`]/);
|
|
1426
|
+
if (tm) testNames.push(tm[1]);
|
|
1427
|
+
}
|
|
1428
|
+
}
|
|
1429
|
+
const testSource = includeTests && tLines ? tLines.join('\n') : undefined;
|
|
1430
|
+
relatedTests.push({
|
|
1431
|
+
file,
|
|
1432
|
+
testCount: testNames.length,
|
|
1433
|
+
testNames,
|
|
1434
|
+
source: testSource,
|
|
1435
|
+
});
|
|
1436
|
+
}
|
|
1437
|
+
|
|
1438
|
+
return {
|
|
1439
|
+
name: node.name,
|
|
1440
|
+
kind: node.kind,
|
|
1441
|
+
file: node.file,
|
|
1442
|
+
line: node.line,
|
|
1443
|
+
endLine: node.end_line || null,
|
|
1444
|
+
source,
|
|
1445
|
+
signature,
|
|
1446
|
+
callees,
|
|
1447
|
+
callers,
|
|
1448
|
+
relatedTests,
|
|
1449
|
+
};
|
|
1450
|
+
});
|
|
1451
|
+
|
|
1452
|
+
db.close();
|
|
1453
|
+
return { name, results };
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
export function context(name, customDbPath, opts = {}) {
|
|
1457
|
+
const data = contextData(name, customDbPath, opts);
|
|
1458
|
+
if (opts.json) {
|
|
1459
|
+
console.log(JSON.stringify(data, null, 2));
|
|
1460
|
+
return;
|
|
1461
|
+
}
|
|
1462
|
+
if (data.results.length === 0) {
|
|
1463
|
+
console.log(`No function/method/class matching "${name}"`);
|
|
1464
|
+
return;
|
|
1465
|
+
}
|
|
1466
|
+
|
|
1467
|
+
for (const r of data.results) {
|
|
1468
|
+
const lineRange = r.endLine ? `${r.line}-${r.endLine}` : `${r.line}`;
|
|
1469
|
+
console.log(`\n# ${r.name} (${r.kind}) — ${r.file}:${lineRange}\n`);
|
|
1470
|
+
|
|
1471
|
+
// Signature
|
|
1472
|
+
if (r.signature) {
|
|
1473
|
+
console.log('## Type/Shape Info');
|
|
1474
|
+
if (r.signature.params != null) console.log(` Parameters: (${r.signature.params})`);
|
|
1475
|
+
if (r.signature.returnType) console.log(` Returns: ${r.signature.returnType}`);
|
|
1476
|
+
console.log();
|
|
1477
|
+
}
|
|
1478
|
+
|
|
1479
|
+
// Source
|
|
1480
|
+
if (r.source) {
|
|
1481
|
+
console.log('## Source');
|
|
1482
|
+
for (const line of r.source.split('\n')) {
|
|
1483
|
+
console.log(` ${line}`);
|
|
1484
|
+
}
|
|
1485
|
+
console.log();
|
|
1486
|
+
}
|
|
1487
|
+
|
|
1488
|
+
// Callees
|
|
1489
|
+
if (r.callees.length > 0) {
|
|
1490
|
+
console.log(`## Direct Dependencies (${r.callees.length})`);
|
|
1491
|
+
for (const c of r.callees) {
|
|
1492
|
+
const summary = c.summary ? ` — ${c.summary}` : '';
|
|
1493
|
+
console.log(` ${kindIcon(c.kind)} ${c.name} ${c.file}:${c.line}${summary}`);
|
|
1494
|
+
if (c.source) {
|
|
1495
|
+
for (const line of c.source.split('\n').slice(0, 10)) {
|
|
1496
|
+
console.log(` | ${line}`);
|
|
1497
|
+
}
|
|
1498
|
+
}
|
|
1499
|
+
}
|
|
1500
|
+
console.log();
|
|
1501
|
+
}
|
|
1502
|
+
|
|
1503
|
+
// Callers
|
|
1504
|
+
if (r.callers.length > 0) {
|
|
1505
|
+
console.log(`## Callers (${r.callers.length})`);
|
|
1506
|
+
for (const c of r.callers) {
|
|
1507
|
+
const via = c.viaHierarchy ? ` (via ${c.viaHierarchy})` : '';
|
|
1508
|
+
console.log(` ${kindIcon(c.kind)} ${c.name} ${c.file}:${c.line}${via}`);
|
|
1509
|
+
}
|
|
1510
|
+
console.log();
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1513
|
+
// Related tests
|
|
1514
|
+
if (r.relatedTests.length > 0) {
|
|
1515
|
+
console.log('## Related Tests');
|
|
1516
|
+
for (const t of r.relatedTests) {
|
|
1517
|
+
console.log(` ${t.file} — ${t.testCount} tests`);
|
|
1518
|
+
for (const tn of t.testNames) {
|
|
1519
|
+
console.log(` - ${tn}`);
|
|
1520
|
+
}
|
|
1521
|
+
if (t.source) {
|
|
1522
|
+
console.log(' Source:');
|
|
1523
|
+
for (const line of t.source.split('\n').slice(0, 20)) {
|
|
1524
|
+
console.log(` | ${line}`);
|
|
1525
|
+
}
|
|
1526
|
+
}
|
|
1527
|
+
}
|
|
1528
|
+
console.log();
|
|
1529
|
+
}
|
|
1530
|
+
|
|
1531
|
+
if (r.callees.length === 0 && r.callers.length === 0 && r.relatedTests.length === 0) {
|
|
1532
|
+
console.log(
|
|
1533
|
+
' (no call edges or tests found — may be invoked dynamically or via re-exports)',
|
|
1534
|
+
);
|
|
1535
|
+
console.log();
|
|
1536
|
+
}
|
|
1537
|
+
}
|
|
1538
|
+
}
|
|
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
|
+
|
|
948
1995
|
export function fnImpact(name, customDbPath, opts = {}) {
|
|
949
1996
|
const data = fnImpactData(name, customDbPath, opts);
|
|
950
1997
|
if (opts.json) {
|