@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/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 { findDbPath, initSchema, openDb, openReadonlyOrFail } from './db.js';
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,