@optave/codegraph 1.1.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/LICENSE +190 -0
- package/README.md +311 -0
- package/grammars/tree-sitter-hcl.wasm +0 -0
- package/grammars/tree-sitter-javascript.wasm +0 -0
- package/grammars/tree-sitter-python.wasm +0 -0
- package/grammars/tree-sitter-tsx.wasm +0 -0
- package/grammars/tree-sitter-typescript.wasm +0 -0
- package/package.json +69 -0
- package/src/builder.js +547 -0
- package/src/cli.js +224 -0
- package/src/config.js +55 -0
- package/src/constants.js +28 -0
- package/src/cycles.js +104 -0
- package/src/db.js +117 -0
- package/src/embedder.js +330 -0
- package/src/export.js +138 -0
- package/src/index.js +39 -0
- package/src/logger.js +20 -0
- package/src/mcp.js +139 -0
- package/src/parser.js +573 -0
- package/src/queries.js +616 -0
- package/src/watcher.js +213 -0
package/src/queries.js
ADDED
|
@@ -0,0 +1,616 @@
|
|
|
1
|
+
|
|
2
|
+
import Database from 'better-sqlite3';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import { execFileSync } from 'child_process';
|
|
5
|
+
import { findDbPath, openReadonlyOrFail } from './db.js';
|
|
6
|
+
import { warn, debug } from './logger.js';
|
|
7
|
+
|
|
8
|
+
const TEST_PATTERN = /\.(test|spec)\.|__test__|__tests__|\.stories\./;
|
|
9
|
+
function isTestFile(filePath) {
|
|
10
|
+
return TEST_PATTERN.test(filePath);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Get all ancestor class names for a given class using extends edges.
|
|
15
|
+
*/
|
|
16
|
+
function getClassHierarchy(db, classNodeId) {
|
|
17
|
+
const ancestors = new Set();
|
|
18
|
+
const queue = [classNodeId];
|
|
19
|
+
while (queue.length > 0) {
|
|
20
|
+
const current = queue.shift();
|
|
21
|
+
const parents = db.prepare(`
|
|
22
|
+
SELECT n.id, n.name FROM edges e JOIN nodes n ON e.target_id = n.id
|
|
23
|
+
WHERE e.source_id = ? AND e.kind = 'extends'
|
|
24
|
+
`).all(current);
|
|
25
|
+
for (const p of parents) {
|
|
26
|
+
if (!ancestors.has(p.id)) {
|
|
27
|
+
ancestors.add(p.id);
|
|
28
|
+
queue.push(p.id);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return ancestors;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function resolveMethodViaHierarchy(db, methodName) {
|
|
36
|
+
const methods = db.prepare(
|
|
37
|
+
`SELECT * FROM nodes WHERE kind = 'method' AND name LIKE ?`
|
|
38
|
+
).all(`%.${methodName}`);
|
|
39
|
+
|
|
40
|
+
const results = [...methods];
|
|
41
|
+
for (const m of methods) {
|
|
42
|
+
const className = m.name.split('.')[0];
|
|
43
|
+
const classNode = db.prepare(
|
|
44
|
+
`SELECT * FROM nodes WHERE name = ? AND kind = 'class' AND file = ?`
|
|
45
|
+
).get(className, m.file);
|
|
46
|
+
if (!classNode) continue;
|
|
47
|
+
|
|
48
|
+
const ancestors = getClassHierarchy(db, classNode.id);
|
|
49
|
+
for (const ancestorId of ancestors) {
|
|
50
|
+
const ancestor = db.prepare('SELECT name FROM nodes WHERE id = ?').get(ancestorId);
|
|
51
|
+
if (!ancestor) continue;
|
|
52
|
+
const parentMethods = db.prepare(
|
|
53
|
+
`SELECT * FROM nodes WHERE name = ? AND kind = 'method'`
|
|
54
|
+
).all(`${ancestor.name}.${methodName}`);
|
|
55
|
+
results.push(...parentMethods);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return results;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function kindIcon(kind) {
|
|
62
|
+
switch (kind) {
|
|
63
|
+
case 'function': return 'f';
|
|
64
|
+
case 'class': return '*';
|
|
65
|
+
case 'method': return 'o';
|
|
66
|
+
case 'file': return '#';
|
|
67
|
+
case 'interface': return 'I';
|
|
68
|
+
case 'type': return 'T';
|
|
69
|
+
default: return '-';
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ─── Data-returning functions ───────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
export function queryNameData(name, customDbPath) {
|
|
76
|
+
const db = openReadonlyOrFail(customDbPath);
|
|
77
|
+
const nodes = db.prepare(`SELECT * FROM nodes WHERE name LIKE ?`).all(`%${name}%`);
|
|
78
|
+
if (nodes.length === 0) { db.close(); return { query: name, results: [] }; }
|
|
79
|
+
|
|
80
|
+
const results = nodes.map(node => {
|
|
81
|
+
const callees = db.prepare(`
|
|
82
|
+
SELECT n.name, n.kind, n.file, n.line, e.kind as edge_kind
|
|
83
|
+
FROM edges e JOIN nodes n ON e.target_id = n.id
|
|
84
|
+
WHERE e.source_id = ?
|
|
85
|
+
`).all(node.id);
|
|
86
|
+
|
|
87
|
+
const callers = db.prepare(`
|
|
88
|
+
SELECT n.name, n.kind, n.file, n.line, e.kind as edge_kind
|
|
89
|
+
FROM edges e JOIN nodes n ON e.source_id = n.id
|
|
90
|
+
WHERE e.target_id = ?
|
|
91
|
+
`).all(node.id);
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
name: node.name, kind: node.kind, file: node.file, line: node.line,
|
|
95
|
+
callees: callees.map(c => ({ name: c.name, kind: c.kind, file: c.file, line: c.line, edgeKind: c.edge_kind })),
|
|
96
|
+
callers: callers.map(c => ({ name: c.name, kind: c.kind, file: c.file, line: c.line, edgeKind: c.edge_kind })),
|
|
97
|
+
};
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
db.close();
|
|
101
|
+
return { query: name, results };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function impactAnalysisData(file, customDbPath) {
|
|
105
|
+
const db = openReadonlyOrFail(customDbPath);
|
|
106
|
+
const fileNodes = db.prepare(`SELECT * FROM nodes WHERE file LIKE ? AND kind = 'file'`).all(`%${file}%`);
|
|
107
|
+
if (fileNodes.length === 0) { db.close(); return { file, sources: [], levels: {}, totalDependents: 0 }; }
|
|
108
|
+
|
|
109
|
+
const visited = new Set();
|
|
110
|
+
const queue = [];
|
|
111
|
+
const levels = new Map();
|
|
112
|
+
|
|
113
|
+
for (const fn of fileNodes) {
|
|
114
|
+
visited.add(fn.id);
|
|
115
|
+
queue.push(fn.id);
|
|
116
|
+
levels.set(fn.id, 0);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
while (queue.length > 0) {
|
|
120
|
+
const current = queue.shift();
|
|
121
|
+
const level = levels.get(current);
|
|
122
|
+
const dependents = db.prepare(`
|
|
123
|
+
SELECT n.* FROM edges e JOIN nodes n ON e.source_id = n.id
|
|
124
|
+
WHERE e.target_id = ? AND e.kind IN ('imports', 'imports-type')
|
|
125
|
+
`).all(current);
|
|
126
|
+
for (const dep of dependents) {
|
|
127
|
+
if (!visited.has(dep.id)) {
|
|
128
|
+
visited.add(dep.id);
|
|
129
|
+
queue.push(dep.id);
|
|
130
|
+
levels.set(dep.id, level + 1);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const byLevel = {};
|
|
136
|
+
for (const [id, level] of levels) {
|
|
137
|
+
if (level === 0) continue;
|
|
138
|
+
if (!byLevel[level]) byLevel[level] = [];
|
|
139
|
+
const node = db.prepare('SELECT * FROM nodes WHERE id = ?').get(id);
|
|
140
|
+
if (node) byLevel[level].push({ file: node.file });
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
db.close();
|
|
144
|
+
return {
|
|
145
|
+
file,
|
|
146
|
+
sources: fileNodes.map(f => f.file),
|
|
147
|
+
levels: byLevel,
|
|
148
|
+
totalDependents: visited.size - fileNodes.length,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function moduleMapData(customDbPath, limit = 20) {
|
|
153
|
+
const db = openReadonlyOrFail(customDbPath);
|
|
154
|
+
|
|
155
|
+
const nodes = db.prepare(`
|
|
156
|
+
SELECT n.*,
|
|
157
|
+
(SELECT COUNT(*) FROM edges WHERE source_id = n.id) as out_edges,
|
|
158
|
+
(SELECT COUNT(*) FROM edges WHERE target_id = n.id) as in_edges
|
|
159
|
+
FROM nodes n
|
|
160
|
+
WHERE n.kind = 'file'
|
|
161
|
+
AND n.file NOT LIKE '%.test.%'
|
|
162
|
+
AND n.file NOT LIKE '%.spec.%'
|
|
163
|
+
AND n.file NOT LIKE '%__test__%'
|
|
164
|
+
ORDER BY (SELECT COUNT(*) FROM edges WHERE target_id = n.id) DESC
|
|
165
|
+
LIMIT ?
|
|
166
|
+
`).all(limit);
|
|
167
|
+
|
|
168
|
+
const topNodes = nodes.map(n => ({
|
|
169
|
+
file: n.file,
|
|
170
|
+
dir: path.dirname(n.file) || '.',
|
|
171
|
+
inEdges: n.in_edges,
|
|
172
|
+
outEdges: n.out_edges,
|
|
173
|
+
}));
|
|
174
|
+
|
|
175
|
+
const totalNodes = db.prepare('SELECT COUNT(*) as c FROM nodes').get().c;
|
|
176
|
+
const totalEdges = db.prepare('SELECT COUNT(*) as c FROM edges').get().c;
|
|
177
|
+
const totalFiles = db.prepare("SELECT COUNT(*) as c FROM nodes WHERE kind = 'file'").get().c;
|
|
178
|
+
|
|
179
|
+
db.close();
|
|
180
|
+
return { limit, topNodes, stats: { totalFiles, totalNodes, totalEdges } };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function fileDepsData(file, customDbPath) {
|
|
184
|
+
const db = openReadonlyOrFail(customDbPath);
|
|
185
|
+
const fileNodes = db.prepare(`SELECT * FROM nodes WHERE file LIKE ? AND kind = 'file'`).all(`%${file}%`);
|
|
186
|
+
if (fileNodes.length === 0) { db.close(); return { file, results: [] }; }
|
|
187
|
+
|
|
188
|
+
const results = fileNodes.map(fn => {
|
|
189
|
+
const importsTo = db.prepare(`
|
|
190
|
+
SELECT n.file, e.kind as edge_kind FROM edges e JOIN nodes n ON e.target_id = n.id
|
|
191
|
+
WHERE e.source_id = ? AND e.kind IN ('imports', 'imports-type')
|
|
192
|
+
`).all(fn.id);
|
|
193
|
+
|
|
194
|
+
const importedBy = db.prepare(`
|
|
195
|
+
SELECT n.file, e.kind as edge_kind FROM edges e JOIN nodes n ON e.source_id = n.id
|
|
196
|
+
WHERE e.target_id = ? AND e.kind IN ('imports', 'imports-type')
|
|
197
|
+
`).all(fn.id);
|
|
198
|
+
|
|
199
|
+
const defs = db.prepare(`SELECT * FROM nodes WHERE file = ? AND kind != 'file' ORDER BY line`).all(fn.file);
|
|
200
|
+
|
|
201
|
+
return {
|
|
202
|
+
file: fn.file,
|
|
203
|
+
imports: importsTo.map(i => ({ file: i.file, typeOnly: i.edge_kind === 'imports-type' })),
|
|
204
|
+
importedBy: importedBy.map(i => ({ file: i.file })),
|
|
205
|
+
definitions: defs.map(d => ({ name: d.name, kind: d.kind, line: d.line })),
|
|
206
|
+
};
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
db.close();
|
|
210
|
+
return { file, results };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export function fnDepsData(name, customDbPath, opts = {}) {
|
|
214
|
+
const db = openReadonlyOrFail(customDbPath);
|
|
215
|
+
const depth = opts.depth || 3;
|
|
216
|
+
const noTests = opts.noTests || false;
|
|
217
|
+
|
|
218
|
+
let nodes = db.prepare(
|
|
219
|
+
`SELECT * FROM nodes WHERE name LIKE ? AND kind IN ('function', 'method', 'class') ORDER BY file, line`
|
|
220
|
+
).all(`%${name}%`);
|
|
221
|
+
if (noTests) nodes = nodes.filter(n => !isTestFile(n.file));
|
|
222
|
+
if (nodes.length === 0) { db.close(); return { name, results: [] }; }
|
|
223
|
+
|
|
224
|
+
const results = nodes.map(node => {
|
|
225
|
+
const callees = db.prepare(`
|
|
226
|
+
SELECT n.name, n.kind, n.file, n.line, e.kind as edge_kind
|
|
227
|
+
FROM edges e JOIN nodes n ON e.target_id = n.id
|
|
228
|
+
WHERE e.source_id = ? AND e.kind = 'calls'
|
|
229
|
+
`).all(node.id);
|
|
230
|
+
let filteredCallees = noTests ? callees.filter(c => !isTestFile(c.file)) : callees;
|
|
231
|
+
|
|
232
|
+
let callers = db.prepare(`
|
|
233
|
+
SELECT n.name, n.kind, n.file, n.line, e.kind as edge_kind
|
|
234
|
+
FROM edges e JOIN nodes n ON e.source_id = n.id
|
|
235
|
+
WHERE e.target_id = ? AND e.kind = 'calls'
|
|
236
|
+
`).all(node.id);
|
|
237
|
+
|
|
238
|
+
if (node.kind === 'method' && node.name.includes('.')) {
|
|
239
|
+
const methodName = node.name.split('.').pop();
|
|
240
|
+
const relatedMethods = resolveMethodViaHierarchy(db, methodName);
|
|
241
|
+
for (const rm of relatedMethods) {
|
|
242
|
+
if (rm.id === node.id) continue;
|
|
243
|
+
const extraCallers = db.prepare(`
|
|
244
|
+
SELECT n.name, n.kind, n.file, n.line, e.kind as edge_kind
|
|
245
|
+
FROM edges e JOIN nodes n ON e.source_id = n.id
|
|
246
|
+
WHERE e.target_id = ? AND e.kind = 'calls'
|
|
247
|
+
`).all(rm.id);
|
|
248
|
+
callers.push(...extraCallers.map(c => ({ ...c, viaHierarchy: rm.name })));
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
if (noTests) callers = callers.filter(c => !isTestFile(c.file));
|
|
252
|
+
|
|
253
|
+
// Transitive callers
|
|
254
|
+
const transitiveCallers = {};
|
|
255
|
+
if (depth > 1) {
|
|
256
|
+
const visited = new Set([node.id]);
|
|
257
|
+
let frontier = callers.map(c => {
|
|
258
|
+
const row = db.prepare('SELECT id FROM nodes WHERE name = ? AND kind = ? AND file = ? AND line = ?').get(c.name, c.kind, c.file, c.line);
|
|
259
|
+
return row ? { ...c, id: row.id } : null;
|
|
260
|
+
}).filter(Boolean);
|
|
261
|
+
|
|
262
|
+
for (let d = 2; d <= depth; d++) {
|
|
263
|
+
const nextFrontier = [];
|
|
264
|
+
for (const f of frontier) {
|
|
265
|
+
if (visited.has(f.id)) continue;
|
|
266
|
+
visited.add(f.id);
|
|
267
|
+
const upstream = db.prepare(`
|
|
268
|
+
SELECT n.name, n.kind, n.file, n.line
|
|
269
|
+
FROM edges e JOIN nodes n ON e.source_id = n.id
|
|
270
|
+
WHERE e.target_id = ? AND e.kind = 'calls'
|
|
271
|
+
`).all(f.id);
|
|
272
|
+
for (const u of upstream) {
|
|
273
|
+
if (noTests && isTestFile(u.file)) continue;
|
|
274
|
+
const uid = db.prepare('SELECT id FROM nodes WHERE name = ? AND kind = ? AND file = ? AND line = ?').get(u.name, u.kind, u.file, u.line)?.id;
|
|
275
|
+
if (uid && !visited.has(uid)) {
|
|
276
|
+
nextFrontier.push({ ...u, id: uid });
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
if (nextFrontier.length > 0) {
|
|
281
|
+
transitiveCallers[d] = nextFrontier.map(n => ({ name: n.name, kind: n.kind, file: n.file, line: n.line }));
|
|
282
|
+
}
|
|
283
|
+
frontier = nextFrontier;
|
|
284
|
+
if (frontier.length === 0) break;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
return {
|
|
289
|
+
name: node.name, kind: node.kind, file: node.file, line: node.line,
|
|
290
|
+
callees: filteredCallees.map(c => ({ name: c.name, kind: c.kind, file: c.file, line: c.line })),
|
|
291
|
+
callers: callers.map(c => ({ name: c.name, kind: c.kind, file: c.file, line: c.line, viaHierarchy: c.viaHierarchy || undefined })),
|
|
292
|
+
transitiveCallers,
|
|
293
|
+
};
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
db.close();
|
|
297
|
+
return { name, results };
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
export function fnImpactData(name, customDbPath, opts = {}) {
|
|
301
|
+
const db = openReadonlyOrFail(customDbPath);
|
|
302
|
+
const maxDepth = opts.depth || 5;
|
|
303
|
+
const noTests = opts.noTests || false;
|
|
304
|
+
|
|
305
|
+
let nodes = db.prepare(
|
|
306
|
+
`SELECT * FROM nodes WHERE name LIKE ? AND kind IN ('function', 'method', 'class')`
|
|
307
|
+
).all(`%${name}%`);
|
|
308
|
+
if (noTests) nodes = nodes.filter(n => !isTestFile(n.file));
|
|
309
|
+
if (nodes.length === 0) { db.close(); return { name, results: [] }; }
|
|
310
|
+
|
|
311
|
+
const results = nodes.slice(0, 3).map(node => {
|
|
312
|
+
const visited = new Set([node.id]);
|
|
313
|
+
const levels = {};
|
|
314
|
+
let frontier = [node.id];
|
|
315
|
+
|
|
316
|
+
for (let d = 1; d <= maxDepth; d++) {
|
|
317
|
+
const nextFrontier = [];
|
|
318
|
+
for (const fid of frontier) {
|
|
319
|
+
const callers = db.prepare(`
|
|
320
|
+
SELECT DISTINCT n.id, n.name, n.kind, n.file, n.line
|
|
321
|
+
FROM edges e JOIN nodes n ON e.source_id = n.id
|
|
322
|
+
WHERE e.target_id = ? AND e.kind = 'calls'
|
|
323
|
+
`).all(fid);
|
|
324
|
+
for (const c of callers) {
|
|
325
|
+
if (!visited.has(c.id) && (!noTests || !isTestFile(c.file))) {
|
|
326
|
+
visited.add(c.id);
|
|
327
|
+
nextFrontier.push(c.id);
|
|
328
|
+
if (!levels[d]) levels[d] = [];
|
|
329
|
+
levels[d].push({ name: c.name, kind: c.kind, file: c.file, line: c.line });
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
frontier = nextFrontier;
|
|
334
|
+
if (frontier.length === 0) break;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
return {
|
|
338
|
+
name: node.name, kind: node.kind, file: node.file, line: node.line,
|
|
339
|
+
levels,
|
|
340
|
+
totalDependents: visited.size - 1,
|
|
341
|
+
};
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
db.close();
|
|
345
|
+
return { name, results };
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Fix #2: Shell injection vulnerability.
|
|
350
|
+
* Uses execFileSync instead of execSync to prevent shell interpretation of user input.
|
|
351
|
+
*/
|
|
352
|
+
export function diffImpactData(customDbPath, opts = {}) {
|
|
353
|
+
const db = openReadonlyOrFail(customDbPath);
|
|
354
|
+
const noTests = opts.noTests || false;
|
|
355
|
+
const maxDepth = opts.depth || 3;
|
|
356
|
+
|
|
357
|
+
const dbPath = findDbPath(customDbPath);
|
|
358
|
+
const repoRoot = path.resolve(path.dirname(dbPath), '..');
|
|
359
|
+
|
|
360
|
+
let diffOutput;
|
|
361
|
+
try {
|
|
362
|
+
// FIX: Use execFileSync with array args to prevent shell injection
|
|
363
|
+
const args = opts.staged
|
|
364
|
+
? ['diff', '--cached', '--unified=0', '--no-color']
|
|
365
|
+
: ['diff', opts.ref || 'HEAD', '--unified=0', '--no-color'];
|
|
366
|
+
diffOutput = execFileSync('git', args, {
|
|
367
|
+
cwd: repoRoot,
|
|
368
|
+
encoding: 'utf-8',
|
|
369
|
+
maxBuffer: 10 * 1024 * 1024
|
|
370
|
+
});
|
|
371
|
+
} catch (e) {
|
|
372
|
+
db.close();
|
|
373
|
+
return { error: `Failed to run git diff: ${e.message}` };
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
if (!diffOutput.trim()) { db.close(); return { changedFiles: 0, affectedFunctions: [], affectedFiles: [], summary: null }; }
|
|
377
|
+
|
|
378
|
+
const changedRanges = new Map();
|
|
379
|
+
let currentFile = null;
|
|
380
|
+
for (const line of diffOutput.split('\n')) {
|
|
381
|
+
const fileMatch = line.match(/^\+\+\+ b\/(.+)/);
|
|
382
|
+
if (fileMatch) { currentFile = fileMatch[1]; if (!changedRanges.has(currentFile)) changedRanges.set(currentFile, []); continue; }
|
|
383
|
+
const hunkMatch = line.match(/^@@ .+ \+(\d+)(?:,(\d+))? @@/);
|
|
384
|
+
if (hunkMatch && currentFile) {
|
|
385
|
+
const start = parseInt(hunkMatch[1]);
|
|
386
|
+
const count = parseInt(hunkMatch[2] || '1');
|
|
387
|
+
changedRanges.get(currentFile).push({ start, end: start + count - 1 });
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
if (changedRanges.size === 0) { db.close(); return { changedFiles: 0, affectedFunctions: [], affectedFiles: [], summary: null }; }
|
|
392
|
+
|
|
393
|
+
const affectedFunctions = [];
|
|
394
|
+
for (const [file, ranges] of changedRanges) {
|
|
395
|
+
if (noTests && isTestFile(file)) continue;
|
|
396
|
+
const defs = db.prepare(
|
|
397
|
+
`SELECT * FROM nodes WHERE file = ? AND kind IN ('function', 'method', 'class') ORDER BY line`
|
|
398
|
+
).all(file);
|
|
399
|
+
for (let i = 0; i < defs.length; i++) {
|
|
400
|
+
const def = defs[i];
|
|
401
|
+
const endLine = def.end_line || (defs[i + 1] ? defs[i + 1].line - 1 : 999999);
|
|
402
|
+
for (const range of ranges) {
|
|
403
|
+
if (range.start <= endLine && range.end >= def.line) {
|
|
404
|
+
affectedFunctions.push(def);
|
|
405
|
+
break;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
const allAffected = new Set();
|
|
412
|
+
const functionResults = affectedFunctions.map(fn => {
|
|
413
|
+
const visited = new Set([fn.id]);
|
|
414
|
+
let frontier = [fn.id];
|
|
415
|
+
let totalCallers = 0;
|
|
416
|
+
for (let d = 1; d <= maxDepth; d++) {
|
|
417
|
+
const nextFrontier = [];
|
|
418
|
+
for (const fid of frontier) {
|
|
419
|
+
const callers = db.prepare(`
|
|
420
|
+
SELECT DISTINCT n.id, n.name, n.kind, n.file, n.line
|
|
421
|
+
FROM edges e JOIN nodes n ON e.source_id = n.id
|
|
422
|
+
WHERE e.target_id = ? AND e.kind = 'calls'
|
|
423
|
+
`).all(fid);
|
|
424
|
+
for (const c of callers) {
|
|
425
|
+
if (!visited.has(c.id) && (!noTests || !isTestFile(c.file))) {
|
|
426
|
+
visited.add(c.id);
|
|
427
|
+
nextFrontier.push(c.id);
|
|
428
|
+
allAffected.add(`${c.file}:${c.name}`);
|
|
429
|
+
totalCallers++;
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
frontier = nextFrontier;
|
|
434
|
+
if (frontier.length === 0) break;
|
|
435
|
+
}
|
|
436
|
+
return { name: fn.name, kind: fn.kind, file: fn.file, line: fn.line, transitiveCallers: totalCallers };
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
const affectedFiles = new Set();
|
|
440
|
+
for (const key of allAffected) affectedFiles.add(key.split(':')[0]);
|
|
441
|
+
|
|
442
|
+
db.close();
|
|
443
|
+
return {
|
|
444
|
+
changedFiles: changedRanges.size,
|
|
445
|
+
affectedFunctions: functionResults,
|
|
446
|
+
affectedFiles: [...affectedFiles],
|
|
447
|
+
summary: {
|
|
448
|
+
functionsChanged: affectedFunctions.length,
|
|
449
|
+
callersAffected: allAffected.size,
|
|
450
|
+
filesAffected: affectedFiles.size,
|
|
451
|
+
},
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// ─── Human-readable output (original formatting) ───────────────────────
|
|
456
|
+
|
|
457
|
+
export function queryName(name, customDbPath, opts = {}) {
|
|
458
|
+
const data = queryNameData(name, customDbPath);
|
|
459
|
+
if (opts.json) { console.log(JSON.stringify(data, null, 2)); return; }
|
|
460
|
+
if (data.results.length === 0) { console.log(`No results for "${name}"`); return; }
|
|
461
|
+
|
|
462
|
+
console.log(`\nResults for "${name}":\n`);
|
|
463
|
+
for (const r of data.results) {
|
|
464
|
+
console.log(` ${kindIcon(r.kind)} ${r.name} (${r.kind}) -- ${r.file}:${r.line}`);
|
|
465
|
+
if (r.callees.length > 0) {
|
|
466
|
+
console.log(` -> calls/uses:`);
|
|
467
|
+
for (const c of r.callees.slice(0, 15)) console.log(` -> ${c.name} (${c.edgeKind}) ${c.file}:${c.line}`);
|
|
468
|
+
if (r.callees.length > 15) console.log(` ... and ${r.callees.length - 15} more`);
|
|
469
|
+
}
|
|
470
|
+
if (r.callers.length > 0) {
|
|
471
|
+
console.log(` <- called by:`);
|
|
472
|
+
for (const c of r.callers.slice(0, 15)) console.log(` <- ${c.name} (${c.edgeKind}) ${c.file}:${c.line}`);
|
|
473
|
+
if (r.callers.length > 15) console.log(` ... and ${r.callers.length - 15} more`);
|
|
474
|
+
}
|
|
475
|
+
console.log();
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
export function impactAnalysis(file, customDbPath, opts = {}) {
|
|
480
|
+
const data = impactAnalysisData(file, customDbPath);
|
|
481
|
+
if (opts.json) { console.log(JSON.stringify(data, null, 2)); return; }
|
|
482
|
+
if (data.sources.length === 0) { console.log(`No file matching "${file}" in graph`); return; }
|
|
483
|
+
|
|
484
|
+
console.log(`\nImpact analysis for files matching "${file}":\n`);
|
|
485
|
+
for (const s of data.sources) console.log(` # ${s} (source)`);
|
|
486
|
+
|
|
487
|
+
const levels = data.levels;
|
|
488
|
+
if (Object.keys(levels).length === 0) {
|
|
489
|
+
console.log(` No dependents found.`);
|
|
490
|
+
} else {
|
|
491
|
+
for (const level of Object.keys(levels).sort((a, b) => a - b)) {
|
|
492
|
+
const nodes = levels[level];
|
|
493
|
+
console.log(`\n ${'--'.repeat(parseInt(level))} Level ${level} (${nodes.length} files):`);
|
|
494
|
+
for (const n of nodes.slice(0, 30)) console.log(` ${' '.repeat(parseInt(level))}^ ${n.file}`);
|
|
495
|
+
if (nodes.length > 30) console.log(` ... and ${nodes.length - 30} more`);
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
console.log(`\n Total: ${data.totalDependents} files transitively depend on "${file}"\n`);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
export function moduleMap(customDbPath, limit = 20, opts = {}) {
|
|
502
|
+
const data = moduleMapData(customDbPath, limit);
|
|
503
|
+
if (opts.json) { console.log(JSON.stringify(data, null, 2)); return; }
|
|
504
|
+
|
|
505
|
+
console.log(`\nModule map (top ${limit} most-connected nodes):\n`);
|
|
506
|
+
const dirs = new Map();
|
|
507
|
+
for (const n of data.topNodes) {
|
|
508
|
+
if (!dirs.has(n.dir)) dirs.set(n.dir, []);
|
|
509
|
+
dirs.get(n.dir).push(n);
|
|
510
|
+
}
|
|
511
|
+
for (const [dir, files] of [...dirs].sort()) {
|
|
512
|
+
console.log(` [${dir}/]`);
|
|
513
|
+
for (const f of files) {
|
|
514
|
+
const total = f.inEdges + f.outEdges;
|
|
515
|
+
const bar = '#'.repeat(Math.min(total, 40));
|
|
516
|
+
console.log(` ${path.basename(f.file).padEnd(35)} <-${String(f.inEdges).padStart(3)} ->${String(f.outEdges).padStart(3)} ${bar}`);
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
console.log(`\n Total: ${data.stats.totalFiles} files, ${data.stats.totalNodes} symbols, ${data.stats.totalEdges} edges\n`);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
export function fileDeps(file, customDbPath, opts = {}) {
|
|
523
|
+
const data = fileDepsData(file, customDbPath);
|
|
524
|
+
if (opts.json) { console.log(JSON.stringify(data, null, 2)); return; }
|
|
525
|
+
if (data.results.length === 0) { console.log(`No file matching "${file}" in graph`); return; }
|
|
526
|
+
|
|
527
|
+
for (const r of data.results) {
|
|
528
|
+
console.log(`\n# ${r.file}\n`);
|
|
529
|
+
console.log(` -> Imports (${r.imports.length}):`);
|
|
530
|
+
for (const i of r.imports) {
|
|
531
|
+
const typeTag = i.typeOnly ? ' (type-only)' : '';
|
|
532
|
+
console.log(` -> ${i.file}${typeTag}`);
|
|
533
|
+
}
|
|
534
|
+
console.log(`\n <- Imported by (${r.importedBy.length}):`);
|
|
535
|
+
for (const i of r.importedBy) console.log(` <- ${i.file}`);
|
|
536
|
+
if (r.definitions.length > 0) {
|
|
537
|
+
console.log(`\n Definitions (${r.definitions.length}):`);
|
|
538
|
+
for (const d of r.definitions.slice(0, 30)) console.log(` ${kindIcon(d.kind)} ${d.name} :${d.line}`);
|
|
539
|
+
if (r.definitions.length > 30) console.log(` ... and ${r.definitions.length - 30} more`);
|
|
540
|
+
}
|
|
541
|
+
console.log();
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
export function fnDeps(name, customDbPath, opts = {}) {
|
|
546
|
+
const data = fnDepsData(name, customDbPath, opts);
|
|
547
|
+
if (opts.json) { console.log(JSON.stringify(data, null, 2)); return; }
|
|
548
|
+
if (data.results.length === 0) { console.log(`No function/method/class matching "${name}"`); return; }
|
|
549
|
+
|
|
550
|
+
for (const r of data.results) {
|
|
551
|
+
console.log(`\n${kindIcon(r.kind)} ${r.name} (${r.kind}) -- ${r.file}:${r.line}\n`);
|
|
552
|
+
if (r.callees.length > 0) {
|
|
553
|
+
console.log(` -> Calls (${r.callees.length}):`);
|
|
554
|
+
for (const c of r.callees) console.log(` -> ${kindIcon(c.kind)} ${c.name} ${c.file}:${c.line}`);
|
|
555
|
+
}
|
|
556
|
+
if (r.callers.length > 0) {
|
|
557
|
+
console.log(`\n <- Called by (${r.callers.length}):`);
|
|
558
|
+
for (const c of r.callers) {
|
|
559
|
+
const via = c.viaHierarchy ? ` (via ${c.viaHierarchy})` : '';
|
|
560
|
+
console.log(` <- ${kindIcon(c.kind)} ${c.name} ${c.file}:${c.line}${via}`);
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
for (const [d, fns] of Object.entries(r.transitiveCallers)) {
|
|
564
|
+
console.log(`\n ${'<-'.repeat(parseInt(d))} Transitive callers (depth ${d}, ${fns.length}):`);
|
|
565
|
+
for (const n of fns.slice(0, 20)) console.log(` ${' '.repeat(parseInt(d)-1)}<- ${kindIcon(n.kind)} ${n.name} ${n.file}:${n.line}`);
|
|
566
|
+
if (fns.length > 20) console.log(` ... and ${fns.length - 20} more`);
|
|
567
|
+
}
|
|
568
|
+
if (r.callees.length === 0 && r.callers.length === 0) {
|
|
569
|
+
console.log(` (no call edges found -- may be invoked dynamically or via re-exports)`);
|
|
570
|
+
}
|
|
571
|
+
console.log();
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
export function fnImpact(name, customDbPath, opts = {}) {
|
|
576
|
+
const data = fnImpactData(name, customDbPath, opts);
|
|
577
|
+
if (opts.json) { console.log(JSON.stringify(data, null, 2)); return; }
|
|
578
|
+
if (data.results.length === 0) { console.log(`No function/method/class matching "${name}"`); return; }
|
|
579
|
+
|
|
580
|
+
for (const r of data.results) {
|
|
581
|
+
console.log(`\nFunction impact: ${kindIcon(r.kind)} ${r.name} -- ${r.file}:${r.line}\n`);
|
|
582
|
+
if (Object.keys(r.levels).length === 0) {
|
|
583
|
+
console.log(` No callers found.`);
|
|
584
|
+
} else {
|
|
585
|
+
for (const [level, fns] of Object.entries(r.levels).sort((a, b) => a[0] - b[0])) {
|
|
586
|
+
const l = parseInt(level);
|
|
587
|
+
console.log(` ${'--'.repeat(l)} Level ${level} (${fns.length} functions):`);
|
|
588
|
+
for (const f of fns.slice(0, 20)) console.log(` ${' '.repeat(l)}^ ${kindIcon(f.kind)} ${f.name} ${f.file}:${f.line}`);
|
|
589
|
+
if (fns.length > 20) console.log(` ... and ${fns.length - 20} more`);
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
console.log(`\n Total: ${r.totalDependents} functions transitively depend on ${r.name}\n`);
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
export function diffImpact(customDbPath, opts = {}) {
|
|
597
|
+
const data = diffImpactData(customDbPath, opts);
|
|
598
|
+
if (opts.json) { console.log(JSON.stringify(data, null, 2)); return; }
|
|
599
|
+
if (data.error) { console.log(data.error); return; }
|
|
600
|
+
if (data.changedFiles === 0) { console.log('No changes detected.'); return; }
|
|
601
|
+
if (data.affectedFunctions.length === 0) {
|
|
602
|
+
console.log(' No function-level changes detected (changes may be in imports, types, or config).');
|
|
603
|
+
return;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
console.log(`\ndiff-impact: ${data.changedFiles} files changed\n`);
|
|
607
|
+
console.log(` ${data.affectedFunctions.length} functions changed:\n`);
|
|
608
|
+
for (const fn of data.affectedFunctions) {
|
|
609
|
+
console.log(` ${kindIcon(fn.kind)} ${fn.name} -- ${fn.file}:${fn.line}`);
|
|
610
|
+
if (fn.transitiveCallers > 0) console.log(` ^ ${fn.transitiveCallers} transitive callers`);
|
|
611
|
+
}
|
|
612
|
+
if (data.summary) {
|
|
613
|
+
console.log(`\n Summary: ${data.summary.functionsChanged} functions changed -> ${data.summary.callersAffected} callers affected across ${data.summary.filesAffected} files\n`);
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
|