@optave/codegraph 2.4.0 → 2.5.1
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 +66 -10
- package/package.json +15 -5
- package/src/branch-compare.js +568 -0
- package/src/builder.js +183 -22
- package/src/cli.js +253 -8
- package/src/cochange.js +8 -8
- package/src/communities.js +303 -0
- package/src/complexity.js +2056 -0
- package/src/config.js +20 -1
- package/src/db.js +111 -1
- package/src/embedder.js +49 -12
- package/src/export.js +25 -1
- package/src/flow.js +361 -0
- package/src/index.js +32 -2
- package/src/manifesto.js +442 -0
- package/src/mcp.js +244 -5
- package/src/paginate.js +70 -0
- package/src/parser.js +21 -5
- package/src/queries.js +396 -7
- package/src/registry.js +6 -3
- package/src/structure.js +88 -24
- package/src/update-check.js +1 -0
- package/src/watcher.js +2 -2
package/src/flow.js
ADDED
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Execution flow tracing — forward BFS from entry points through callees to leaves.
|
|
3
|
+
*
|
|
4
|
+
* Answers "what happens when a user hits POST /login?" by tracing from
|
|
5
|
+
* framework entry points (routes, commands, events) through their call chains.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { openReadonlyOrFail } from './db.js';
|
|
9
|
+
import { paginateResult } from './paginate.js';
|
|
10
|
+
import { isTestFile, kindIcon } from './queries.js';
|
|
11
|
+
import { FRAMEWORK_ENTRY_PREFIXES } from './structure.js';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Determine the entry point type from a node name based on framework prefixes.
|
|
15
|
+
* @param {string} name
|
|
16
|
+
* @returns {'route'|'event'|'command'|'exported'|null}
|
|
17
|
+
*/
|
|
18
|
+
export function entryPointType(name) {
|
|
19
|
+
for (const prefix of FRAMEWORK_ENTRY_PREFIXES) {
|
|
20
|
+
if (name.startsWith(prefix)) {
|
|
21
|
+
return prefix.slice(0, -1); // 'route:', 'event:', 'command:' → 'route', 'event', 'command'
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Query all entry points from the graph, grouped by type.
|
|
29
|
+
* Entry points are nodes with framework prefixes or role = 'entry'.
|
|
30
|
+
*
|
|
31
|
+
* @param {string} [dbPath]
|
|
32
|
+
* @param {object} [opts]
|
|
33
|
+
* @param {boolean} [opts.noTests]
|
|
34
|
+
* @returns {{ entries: object[], byType: object, count: number }}
|
|
35
|
+
*/
|
|
36
|
+
export function listEntryPointsData(dbPath, opts = {}) {
|
|
37
|
+
const db = openReadonlyOrFail(dbPath);
|
|
38
|
+
const noTests = opts.noTests || false;
|
|
39
|
+
|
|
40
|
+
// Find all framework-prefixed nodes
|
|
41
|
+
const prefixConditions = FRAMEWORK_ENTRY_PREFIXES.map(() => 'n.name LIKE ?').join(' OR ');
|
|
42
|
+
const prefixParams = FRAMEWORK_ENTRY_PREFIXES.map((p) => `${p}%`);
|
|
43
|
+
|
|
44
|
+
let rows = db
|
|
45
|
+
.prepare(
|
|
46
|
+
`SELECT n.name, n.kind, n.file, n.line, n.role
|
|
47
|
+
FROM nodes n
|
|
48
|
+
WHERE (${prefixConditions})
|
|
49
|
+
AND n.kind NOT IN ('file', 'directory')
|
|
50
|
+
ORDER BY n.name`,
|
|
51
|
+
)
|
|
52
|
+
.all(...prefixParams);
|
|
53
|
+
|
|
54
|
+
if (noTests) rows = rows.filter((r) => !isTestFile(r.file));
|
|
55
|
+
|
|
56
|
+
const entries = rows.map((r) => ({
|
|
57
|
+
name: r.name,
|
|
58
|
+
kind: r.kind,
|
|
59
|
+
file: r.file,
|
|
60
|
+
line: r.line,
|
|
61
|
+
role: r.role,
|
|
62
|
+
type: entryPointType(r.name),
|
|
63
|
+
}));
|
|
64
|
+
|
|
65
|
+
const byType = {};
|
|
66
|
+
for (const e of entries) {
|
|
67
|
+
const t = e.type || 'other';
|
|
68
|
+
if (!byType[t]) byType[t] = [];
|
|
69
|
+
byType[t].push(e);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
db.close();
|
|
73
|
+
const base = { entries, byType, count: entries.length };
|
|
74
|
+
return paginateResult(base, 'entries', { limit: opts.limit, offset: opts.offset });
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Forward BFS from a matched node through callees to leaves.
|
|
79
|
+
*
|
|
80
|
+
* @param {string} name - Node name to trace from (supports partial/prefix-stripped matching)
|
|
81
|
+
* @param {string} [dbPath]
|
|
82
|
+
* @param {object} [opts]
|
|
83
|
+
* @param {number} [opts.depth=10]
|
|
84
|
+
* @param {boolean} [opts.noTests]
|
|
85
|
+
* @param {string} [opts.file]
|
|
86
|
+
* @param {string} [opts.kind]
|
|
87
|
+
* @returns {{ entry: object|null, depth: number, steps: object[], leaves: object[], cycles: object[], totalReached: number, truncated: boolean }}
|
|
88
|
+
*/
|
|
89
|
+
export function flowData(name, dbPath, opts = {}) {
|
|
90
|
+
const db = openReadonlyOrFail(dbPath);
|
|
91
|
+
const maxDepth = opts.depth || 10;
|
|
92
|
+
const noTests = opts.noTests || false;
|
|
93
|
+
|
|
94
|
+
// Phase 1: Direct LIKE match on full name
|
|
95
|
+
let matchNode = findBestMatch(db, name, opts);
|
|
96
|
+
|
|
97
|
+
// Phase 2: Prefix-stripped matching — try adding framework prefixes
|
|
98
|
+
if (!matchNode) {
|
|
99
|
+
for (const prefix of FRAMEWORK_ENTRY_PREFIXES) {
|
|
100
|
+
matchNode = findBestMatch(db, `${prefix}${name}`, opts);
|
|
101
|
+
if (matchNode) break;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (!matchNode) {
|
|
106
|
+
db.close();
|
|
107
|
+
return {
|
|
108
|
+
entry: null,
|
|
109
|
+
depth: maxDepth,
|
|
110
|
+
steps: [],
|
|
111
|
+
leaves: [],
|
|
112
|
+
cycles: [],
|
|
113
|
+
totalReached: 0,
|
|
114
|
+
truncated: false,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const epType = entryPointType(matchNode.name);
|
|
119
|
+
const entry = {
|
|
120
|
+
name: matchNode.name,
|
|
121
|
+
kind: matchNode.kind,
|
|
122
|
+
file: matchNode.file,
|
|
123
|
+
line: matchNode.line,
|
|
124
|
+
type: epType || 'exported',
|
|
125
|
+
role: matchNode.role,
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
// Forward BFS through callees
|
|
129
|
+
const visited = new Set([matchNode.id]);
|
|
130
|
+
let frontier = [matchNode.id];
|
|
131
|
+
const steps = [];
|
|
132
|
+
const cycles = [];
|
|
133
|
+
let truncated = false;
|
|
134
|
+
|
|
135
|
+
// Track which nodes are at each depth and their depth for leaf detection
|
|
136
|
+
const nodeDepths = new Map();
|
|
137
|
+
const idToNode = new Map();
|
|
138
|
+
idToNode.set(matchNode.id, entry);
|
|
139
|
+
|
|
140
|
+
for (let d = 1; d <= maxDepth; d++) {
|
|
141
|
+
const nextFrontier = [];
|
|
142
|
+
const levelNodes = [];
|
|
143
|
+
|
|
144
|
+
for (const fid of frontier) {
|
|
145
|
+
const callees = db
|
|
146
|
+
.prepare(
|
|
147
|
+
`SELECT DISTINCT n.id, n.name, n.kind, n.file, n.line, n.role
|
|
148
|
+
FROM edges e JOIN nodes n ON e.target_id = n.id
|
|
149
|
+
WHERE e.source_id = ? AND e.kind = 'calls'`,
|
|
150
|
+
)
|
|
151
|
+
.all(fid);
|
|
152
|
+
|
|
153
|
+
for (const c of callees) {
|
|
154
|
+
if (noTests && isTestFile(c.file)) continue;
|
|
155
|
+
|
|
156
|
+
if (visited.has(c.id)) {
|
|
157
|
+
// Cycle detected
|
|
158
|
+
const fromNode = idToNode.get(fid);
|
|
159
|
+
if (fromNode) {
|
|
160
|
+
cycles.push({ from: fromNode.name, to: c.name, depth: d });
|
|
161
|
+
}
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
visited.add(c.id);
|
|
166
|
+
nextFrontier.push(c.id);
|
|
167
|
+
const nodeInfo = { name: c.name, kind: c.kind, file: c.file, line: c.line };
|
|
168
|
+
levelNodes.push(nodeInfo);
|
|
169
|
+
nodeDepths.set(c.id, d);
|
|
170
|
+
idToNode.set(c.id, nodeInfo);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (levelNodes.length > 0) {
|
|
175
|
+
steps.push({ depth: d, nodes: levelNodes });
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
frontier = nextFrontier;
|
|
179
|
+
if (frontier.length === 0) break;
|
|
180
|
+
|
|
181
|
+
if (d === maxDepth && frontier.length > 0) {
|
|
182
|
+
truncated = true;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Identify leaves: visited nodes that have no outgoing 'calls' edges to other visited nodes
|
|
187
|
+
// (or no outgoing calls at all)
|
|
188
|
+
const leaves = [];
|
|
189
|
+
for (const [id, depth] of nodeDepths) {
|
|
190
|
+
const outgoing = db
|
|
191
|
+
.prepare(
|
|
192
|
+
`SELECT DISTINCT n.id
|
|
193
|
+
FROM edges e JOIN nodes n ON e.target_id = n.id
|
|
194
|
+
WHERE e.source_id = ? AND e.kind = 'calls'`,
|
|
195
|
+
)
|
|
196
|
+
.all(id);
|
|
197
|
+
|
|
198
|
+
if (outgoing.length === 0) {
|
|
199
|
+
const node = idToNode.get(id);
|
|
200
|
+
if (node) {
|
|
201
|
+
leaves.push({ ...node, depth });
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
db.close();
|
|
207
|
+
return {
|
|
208
|
+
entry,
|
|
209
|
+
depth: maxDepth,
|
|
210
|
+
steps,
|
|
211
|
+
leaves,
|
|
212
|
+
cycles,
|
|
213
|
+
totalReached: visited.size - 1, // exclude the entry node itself
|
|
214
|
+
truncated,
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Find the best matching node using the same relevance scoring as queries.js findMatchingNodes.
|
|
220
|
+
*/
|
|
221
|
+
function findBestMatch(db, name, opts = {}) {
|
|
222
|
+
const kinds = opts.kind
|
|
223
|
+
? [opts.kind]
|
|
224
|
+
: [
|
|
225
|
+
'function',
|
|
226
|
+
'method',
|
|
227
|
+
'class',
|
|
228
|
+
'interface',
|
|
229
|
+
'type',
|
|
230
|
+
'struct',
|
|
231
|
+
'enum',
|
|
232
|
+
'trait',
|
|
233
|
+
'record',
|
|
234
|
+
'module',
|
|
235
|
+
];
|
|
236
|
+
const placeholders = kinds.map(() => '?').join(', ');
|
|
237
|
+
const params = [`%${name}%`, ...kinds];
|
|
238
|
+
|
|
239
|
+
let fileCondition = '';
|
|
240
|
+
if (opts.file) {
|
|
241
|
+
fileCondition = ' AND n.file LIKE ?';
|
|
242
|
+
params.push(`%${opts.file}%`);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const rows = db
|
|
246
|
+
.prepare(
|
|
247
|
+
`SELECT n.*, COALESCE(fi.cnt, 0) AS fan_in
|
|
248
|
+
FROM nodes n
|
|
249
|
+
LEFT JOIN (
|
|
250
|
+
SELECT target_id, COUNT(*) AS cnt FROM edges WHERE kind = 'calls' GROUP BY target_id
|
|
251
|
+
) fi ON fi.target_id = n.id
|
|
252
|
+
WHERE n.name LIKE ? AND n.kind IN (${placeholders})${fileCondition}`,
|
|
253
|
+
)
|
|
254
|
+
.all(...params);
|
|
255
|
+
|
|
256
|
+
const noTests = opts.noTests || false;
|
|
257
|
+
const nodes = noTests ? rows.filter((n) => !isTestFile(n.file)) : rows;
|
|
258
|
+
|
|
259
|
+
if (nodes.length === 0) return null;
|
|
260
|
+
|
|
261
|
+
const lowerQuery = name.toLowerCase();
|
|
262
|
+
for (const node of nodes) {
|
|
263
|
+
const lowerName = node.name.toLowerCase();
|
|
264
|
+
const bareName = lowerName.includes('.') ? lowerName.split('.').pop() : lowerName;
|
|
265
|
+
|
|
266
|
+
let matchScore;
|
|
267
|
+
if (lowerName === lowerQuery || bareName === lowerQuery) {
|
|
268
|
+
matchScore = 100;
|
|
269
|
+
} else if (lowerName.startsWith(lowerQuery) || bareName.startsWith(lowerQuery)) {
|
|
270
|
+
matchScore = 60;
|
|
271
|
+
} else if (lowerName.includes(`.${lowerQuery}`) || lowerName.includes(`${lowerQuery}.`)) {
|
|
272
|
+
matchScore = 40;
|
|
273
|
+
} else {
|
|
274
|
+
matchScore = 10;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const fanInBonus = Math.min(Math.log2(node.fan_in + 1) * 5, 25);
|
|
278
|
+
node._relevance = matchScore + fanInBonus;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
nodes.sort((a, b) => b._relevance - a._relevance);
|
|
282
|
+
return nodes[0];
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* CLI formatter — text or JSON output.
|
|
287
|
+
*/
|
|
288
|
+
export function flow(name, dbPath, opts = {}) {
|
|
289
|
+
if (opts.list) {
|
|
290
|
+
const data = listEntryPointsData(dbPath, {
|
|
291
|
+
noTests: opts.noTests,
|
|
292
|
+
limit: opts.limit,
|
|
293
|
+
offset: opts.offset,
|
|
294
|
+
});
|
|
295
|
+
if (opts.ndjson) {
|
|
296
|
+
if (data._pagination) console.log(JSON.stringify({ _meta: data._pagination }));
|
|
297
|
+
for (const e of data.entries) console.log(JSON.stringify(e));
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
if (opts.json) {
|
|
301
|
+
console.log(JSON.stringify(data, null, 2));
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
if (data.count === 0) {
|
|
305
|
+
console.log('No entry points found. Run "codegraph build" first.');
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
console.log(`\nEntry points (${data.count} total):\n`);
|
|
309
|
+
for (const [type, entries] of Object.entries(data.byType)) {
|
|
310
|
+
console.log(` ${type} (${entries.length}):`);
|
|
311
|
+
for (const e of entries) {
|
|
312
|
+
console.log(` [${kindIcon(e.kind)}] ${e.name} ${e.file}:${e.line}`);
|
|
313
|
+
}
|
|
314
|
+
console.log();
|
|
315
|
+
}
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const data = flowData(name, dbPath, opts);
|
|
320
|
+
if (opts.json) {
|
|
321
|
+
console.log(JSON.stringify(data, null, 2));
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
if (!data.entry) {
|
|
326
|
+
console.log(`No matching entry point or function found for "${name}".`);
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const e = data.entry;
|
|
331
|
+
const typeTag = e.type !== 'exported' ? ` (${e.type})` : '';
|
|
332
|
+
console.log(`\nFlow from: [${kindIcon(e.kind)}] ${e.name}${typeTag} ${e.file}:${e.line}`);
|
|
333
|
+
console.log(
|
|
334
|
+
`Depth: ${data.depth} Reached: ${data.totalReached} nodes Leaves: ${data.leaves.length}`,
|
|
335
|
+
);
|
|
336
|
+
if (data.truncated) {
|
|
337
|
+
console.log(` (truncated at depth ${data.depth})`);
|
|
338
|
+
}
|
|
339
|
+
console.log();
|
|
340
|
+
|
|
341
|
+
if (data.steps.length === 0) {
|
|
342
|
+
console.log(' (leaf node — no callees)');
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
for (const step of data.steps) {
|
|
347
|
+
console.log(` depth ${step.depth}:`);
|
|
348
|
+
for (const n of step.nodes) {
|
|
349
|
+
const isLeaf = data.leaves.some((l) => l.name === n.name && l.file === n.file);
|
|
350
|
+
const leafTag = isLeaf ? ' [leaf]' : '';
|
|
351
|
+
console.log(` [${kindIcon(n.kind)}] ${n.name} ${n.file}:${n.line}${leafTag}`);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
if (data.cycles.length > 0) {
|
|
356
|
+
console.log('\n Cycles detected:');
|
|
357
|
+
for (const c of data.cycles) {
|
|
358
|
+
console.log(` ${c.from} -> ${c.to} (at depth ${c.depth})`);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
}
|
package/src/index.js
CHANGED
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
* import { buildGraph, queryNameData, findCycles, exportDOT } from 'codegraph';
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
// Branch comparison
|
|
9
|
+
export { branchCompareData, branchCompareMermaid } from './branch-compare.js';
|
|
8
10
|
// Graph building
|
|
9
11
|
export { buildGraph, collectFiles, loadPathAliases, resolveImportPath } from './builder.js';
|
|
10
12
|
// Co-change analysis
|
|
@@ -16,6 +18,19 @@ export {
|
|
|
16
18
|
computeCoChanges,
|
|
17
19
|
scanGitHistory,
|
|
18
20
|
} from './cochange.js';
|
|
21
|
+
// Community detection
|
|
22
|
+
export { communities, communitiesData, communitySummaryForStats } from './communities.js';
|
|
23
|
+
// Complexity metrics
|
|
24
|
+
export {
|
|
25
|
+
COMPLEXITY_RULES,
|
|
26
|
+
complexity,
|
|
27
|
+
complexityData,
|
|
28
|
+
computeFunctionComplexity,
|
|
29
|
+
computeHalsteadMetrics,
|
|
30
|
+
computeLOCMetrics,
|
|
31
|
+
computeMaintainabilityIndex,
|
|
32
|
+
HALSTEAD_RULES,
|
|
33
|
+
} from './complexity.js';
|
|
19
34
|
// Configuration
|
|
20
35
|
export { loadConfig } from './config.js';
|
|
21
36
|
// Shared constants
|
|
@@ -23,8 +38,14 @@ export { EXTENSIONS, IGNORE_DIRS, normalizePath } from './constants.js';
|
|
|
23
38
|
// Circular dependency detection
|
|
24
39
|
export { findCycles, formatCycles } from './cycles.js';
|
|
25
40
|
// Database utilities
|
|
26
|
-
export {
|
|
27
|
-
|
|
41
|
+
export {
|
|
42
|
+
findDbPath,
|
|
43
|
+
getBuildMeta,
|
|
44
|
+
initSchema,
|
|
45
|
+
openDb,
|
|
46
|
+
openReadonlyOrFail,
|
|
47
|
+
setBuildMeta,
|
|
48
|
+
} from './db.js';
|
|
28
49
|
// Embeddings
|
|
29
50
|
export {
|
|
30
51
|
buildEmbeddings,
|
|
@@ -41,10 +62,16 @@ export {
|
|
|
41
62
|
} from './embedder.js';
|
|
42
63
|
// Export (DOT/Mermaid/JSON)
|
|
43
64
|
export { exportDOT, exportJSON, exportMermaid } from './export.js';
|
|
65
|
+
// Execution flow tracing
|
|
66
|
+
export { entryPointType, flowData, listEntryPointsData } from './flow.js';
|
|
44
67
|
// Logger
|
|
45
68
|
export { setVerbose } from './logger.js';
|
|
69
|
+
// Manifesto rule engine
|
|
70
|
+
export { manifesto, manifestoData, RULE_DEFS } from './manifesto.js';
|
|
46
71
|
// Native engine
|
|
47
72
|
export { isNativeAvailable } from './native.js';
|
|
73
|
+
// Pagination utilities
|
|
74
|
+
export { MCP_DEFAULTS, MCP_MAX_LIMIT, paginate, paginateResult } from './paginate.js';
|
|
48
75
|
|
|
49
76
|
// Unified parser API
|
|
50
77
|
export { getActiveEngine, parseFileAuto, parseFilesAuto } from './parser.js';
|
|
@@ -61,7 +88,9 @@ export {
|
|
|
61
88
|
fnDepsData,
|
|
62
89
|
fnImpactData,
|
|
63
90
|
impactAnalysisData,
|
|
91
|
+
kindIcon,
|
|
64
92
|
moduleMapData,
|
|
93
|
+
pathData,
|
|
65
94
|
queryNameData,
|
|
66
95
|
rolesData,
|
|
67
96
|
statsData,
|
|
@@ -83,6 +112,7 @@ export {
|
|
|
83
112
|
export {
|
|
84
113
|
buildStructure,
|
|
85
114
|
classifyNodeRoles,
|
|
115
|
+
FRAMEWORK_ENTRY_PREFIXES,
|
|
86
116
|
formatHotspots,
|
|
87
117
|
formatModuleBoundaries,
|
|
88
118
|
formatStructure,
|