@optave/codegraph 2.5.1 → 3.0.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/src/cfg.js ADDED
@@ -0,0 +1,1451 @@
1
+ /**
2
+ * Intraprocedural Control Flow Graph (CFG) construction from tree-sitter AST.
3
+ *
4
+ * Builds basic-block CFGs for individual functions, stored in cfg_blocks + cfg_edges tables.
5
+ * Opt-in via `build --cfg`. Supports JS/TS/TSX, Python, Go, Rust, Java, C#, Ruby, PHP.
6
+ */
7
+
8
+ import fs from 'node:fs';
9
+ import path from 'node:path';
10
+ import { COMPLEXITY_RULES } from './complexity.js';
11
+ import { openReadonlyOrFail } from './db.js';
12
+ import { info } from './logger.js';
13
+ import { paginateResult, printNdjson } from './paginate.js';
14
+ import { LANGUAGE_REGISTRY } from './parser.js';
15
+ import { isTestFile } from './queries.js';
16
+
17
+ // ─── CFG Node Type Rules (extends COMPLEXITY_RULES) ──────────────────────
18
+
19
+ const CFG_DEFAULTS = {
20
+ ifNode: null,
21
+ ifNodes: null,
22
+ elifNode: null,
23
+ elseClause: null,
24
+ elseViaAlternative: false,
25
+ ifConsequentField: null,
26
+ forNodes: new Set(),
27
+ whileNode: null,
28
+ whileNodes: null,
29
+ doNode: null,
30
+ infiniteLoopNode: null,
31
+ unlessNode: null,
32
+ untilNode: null,
33
+ switchNode: null,
34
+ switchNodes: null,
35
+ caseNode: null,
36
+ caseNodes: null,
37
+ defaultNode: null,
38
+ tryNode: null,
39
+ catchNode: null,
40
+ finallyNode: null,
41
+ returnNode: null,
42
+ throwNode: null,
43
+ breakNode: null,
44
+ continueNode: null,
45
+ blockNode: null,
46
+ blockNodes: null,
47
+ labeledNode: null,
48
+ functionNodes: new Set(),
49
+ };
50
+
51
+ const CFG_RULE_KEYS = new Set(Object.keys(CFG_DEFAULTS));
52
+
53
+ export function makeCfgRules(overrides) {
54
+ for (const key of Object.keys(overrides)) {
55
+ if (!CFG_RULE_KEYS.has(key)) {
56
+ throw new Error(`CFG rules: unknown key "${key}"`);
57
+ }
58
+ }
59
+ const rules = { ...CFG_DEFAULTS, ...overrides };
60
+ if (!(rules.functionNodes instanceof Set) || rules.functionNodes.size === 0) {
61
+ throw new Error('CFG rules: functionNodes must be a non-empty Set');
62
+ }
63
+ if (!(rules.forNodes instanceof Set)) {
64
+ throw new Error('CFG rules: forNodes must be a Set');
65
+ }
66
+ return rules;
67
+ }
68
+
69
+ const JS_TS_CFG = makeCfgRules({
70
+ ifNode: 'if_statement',
71
+ elseClause: 'else_clause',
72
+ forNodes: new Set(['for_statement', 'for_in_statement']),
73
+ whileNode: 'while_statement',
74
+ doNode: 'do_statement',
75
+ switchNode: 'switch_statement',
76
+ caseNode: 'switch_case',
77
+ defaultNode: 'switch_default',
78
+ tryNode: 'try_statement',
79
+ catchNode: 'catch_clause',
80
+ finallyNode: 'finally_clause',
81
+ returnNode: 'return_statement',
82
+ throwNode: 'throw_statement',
83
+ breakNode: 'break_statement',
84
+ continueNode: 'continue_statement',
85
+ blockNode: 'statement_block',
86
+ labeledNode: 'labeled_statement',
87
+ functionNodes: new Set([
88
+ 'function_declaration',
89
+ 'function_expression',
90
+ 'arrow_function',
91
+ 'method_definition',
92
+ 'generator_function',
93
+ 'generator_function_declaration',
94
+ ]),
95
+ });
96
+
97
+ const PYTHON_CFG = makeCfgRules({
98
+ ifNode: 'if_statement',
99
+ elifNode: 'elif_clause',
100
+ elseClause: 'else_clause',
101
+ forNodes: new Set(['for_statement']),
102
+ whileNode: 'while_statement',
103
+ switchNode: 'match_statement',
104
+ caseNode: 'case_clause',
105
+ tryNode: 'try_statement',
106
+ catchNode: 'except_clause',
107
+ finallyNode: 'finally_clause',
108
+ returnNode: 'return_statement',
109
+ throwNode: 'raise_statement',
110
+ breakNode: 'break_statement',
111
+ continueNode: 'continue_statement',
112
+ blockNode: 'block',
113
+ functionNodes: new Set(['function_definition']),
114
+ });
115
+
116
+ const GO_CFG = makeCfgRules({
117
+ ifNode: 'if_statement',
118
+ elseViaAlternative: true,
119
+ forNodes: new Set(['for_statement']),
120
+ switchNodes: new Set([
121
+ 'expression_switch_statement',
122
+ 'type_switch_statement',
123
+ 'select_statement',
124
+ ]),
125
+ caseNode: 'expression_case',
126
+ caseNodes: new Set(['type_case', 'communication_case']),
127
+ defaultNode: 'default_case',
128
+ returnNode: 'return_statement',
129
+ breakNode: 'break_statement',
130
+ continueNode: 'continue_statement',
131
+ blockNode: 'block',
132
+ labeledNode: 'labeled_statement',
133
+ functionNodes: new Set(['function_declaration', 'method_declaration', 'func_literal']),
134
+ });
135
+
136
+ const RUST_CFG = makeCfgRules({
137
+ ifNode: 'if_expression',
138
+ ifNodes: new Set(['if_let_expression']),
139
+ elseClause: 'else_clause',
140
+ forNodes: new Set(['for_expression']),
141
+ whileNode: 'while_expression',
142
+ whileNodes: new Set(['while_let_expression']),
143
+ infiniteLoopNode: 'loop_expression',
144
+ switchNode: 'match_expression',
145
+ caseNode: 'match_arm',
146
+ returnNode: 'return_expression',
147
+ breakNode: 'break_expression',
148
+ continueNode: 'continue_expression',
149
+ blockNode: 'block',
150
+ functionNodes: new Set(['function_item', 'closure_expression']),
151
+ });
152
+
153
+ const JAVA_CFG = makeCfgRules({
154
+ ifNode: 'if_statement',
155
+ elseViaAlternative: true,
156
+ forNodes: new Set(['for_statement', 'enhanced_for_statement']),
157
+ whileNode: 'while_statement',
158
+ doNode: 'do_statement',
159
+ switchNode: 'switch_expression',
160
+ caseNode: 'switch_block_statement_group',
161
+ caseNodes: new Set(['switch_rule']),
162
+ tryNode: 'try_statement',
163
+ catchNode: 'catch_clause',
164
+ finallyNode: 'finally_clause',
165
+ returnNode: 'return_statement',
166
+ throwNode: 'throw_statement',
167
+ breakNode: 'break_statement',
168
+ continueNode: 'continue_statement',
169
+ blockNode: 'block',
170
+ labeledNode: 'labeled_statement',
171
+ functionNodes: new Set(['method_declaration', 'constructor_declaration', 'lambda_expression']),
172
+ });
173
+
174
+ const CSHARP_CFG = makeCfgRules({
175
+ ifNode: 'if_statement',
176
+ elseViaAlternative: true,
177
+ forNodes: new Set(['for_statement', 'foreach_statement']),
178
+ whileNode: 'while_statement',
179
+ doNode: 'do_statement',
180
+ switchNode: 'switch_statement',
181
+ caseNode: 'switch_section',
182
+ tryNode: 'try_statement',
183
+ catchNode: 'catch_clause',
184
+ finallyNode: 'finally_clause',
185
+ returnNode: 'return_statement',
186
+ throwNode: 'throw_statement',
187
+ breakNode: 'break_statement',
188
+ continueNode: 'continue_statement',
189
+ blockNode: 'block',
190
+ labeledNode: 'labeled_statement',
191
+ functionNodes: new Set([
192
+ 'method_declaration',
193
+ 'constructor_declaration',
194
+ 'lambda_expression',
195
+ 'local_function_statement',
196
+ ]),
197
+ });
198
+
199
+ const RUBY_CFG = makeCfgRules({
200
+ ifNode: 'if',
201
+ elifNode: 'elsif',
202
+ elseClause: 'else',
203
+ forNodes: new Set(['for']),
204
+ whileNode: 'while',
205
+ unlessNode: 'unless',
206
+ untilNode: 'until',
207
+ switchNode: 'case',
208
+ caseNode: 'when',
209
+ defaultNode: 'else',
210
+ tryNode: 'begin',
211
+ catchNode: 'rescue',
212
+ finallyNode: 'ensure',
213
+ returnNode: 'return',
214
+ breakNode: 'break',
215
+ continueNode: 'next',
216
+ blockNodes: new Set(['then', 'do', 'body_statement']),
217
+ functionNodes: new Set(['method', 'singleton_method']),
218
+ });
219
+
220
+ const PHP_CFG = makeCfgRules({
221
+ ifNode: 'if_statement',
222
+ elifNode: 'else_if_clause',
223
+ elseClause: 'else_clause',
224
+ ifConsequentField: 'body',
225
+ forNodes: new Set(['for_statement', 'foreach_statement']),
226
+ whileNode: 'while_statement',
227
+ doNode: 'do_statement',
228
+ switchNode: 'switch_statement',
229
+ caseNode: 'case_statement',
230
+ defaultNode: 'default_statement',
231
+ tryNode: 'try_statement',
232
+ catchNode: 'catch_clause',
233
+ finallyNode: 'finally_clause',
234
+ returnNode: 'return_statement',
235
+ throwNode: 'throw_expression',
236
+ breakNode: 'break_statement',
237
+ continueNode: 'continue_statement',
238
+ blockNode: 'compound_statement',
239
+ functionNodes: new Set([
240
+ 'function_definition',
241
+ 'method_declaration',
242
+ 'anonymous_function_creation_expression',
243
+ 'arrow_function',
244
+ ]),
245
+ });
246
+
247
+ export const CFG_RULES = new Map([
248
+ ['javascript', JS_TS_CFG],
249
+ ['typescript', JS_TS_CFG],
250
+ ['tsx', JS_TS_CFG],
251
+ ['python', PYTHON_CFG],
252
+ ['go', GO_CFG],
253
+ ['rust', RUST_CFG],
254
+ ['java', JAVA_CFG],
255
+ ['csharp', CSHARP_CFG],
256
+ ['ruby', RUBY_CFG],
257
+ ['php', PHP_CFG],
258
+ ]);
259
+
260
+ const CFG_LANG_IDS = new Set(CFG_RULES.keys());
261
+
262
+ // JS/TS extensions
263
+ const CFG_EXTENSIONS = new Set();
264
+ for (const entry of LANGUAGE_REGISTRY) {
265
+ if (CFG_LANG_IDS.has(entry.id)) {
266
+ for (const ext of entry.extensions) CFG_EXTENSIONS.add(ext);
267
+ }
268
+ }
269
+
270
+ // ─── Core Algorithm: AST → CFG ──────────────────────────────────────────
271
+
272
+ /**
273
+ * Build a control flow graph for a single function AST node.
274
+ *
275
+ * @param {object} functionNode - tree-sitter function AST node
276
+ * @param {string} langId - language identifier (javascript, typescript, tsx)
277
+ * @returns {{ blocks: object[], edges: object[] }} - CFG blocks and edges
278
+ */
279
+ export function buildFunctionCFG(functionNode, langId) {
280
+ const rules = CFG_RULES.get(langId);
281
+ if (!rules) return { blocks: [], edges: [] };
282
+
283
+ const blocks = [];
284
+ const edges = [];
285
+ let nextIndex = 0;
286
+
287
+ function makeBlock(type, startLine = null, endLine = null, label = null) {
288
+ const block = {
289
+ index: nextIndex++,
290
+ type,
291
+ startLine,
292
+ endLine,
293
+ label,
294
+ };
295
+ blocks.push(block);
296
+ return block;
297
+ }
298
+
299
+ function addEdge(source, target, kind) {
300
+ edges.push({
301
+ sourceIndex: source.index,
302
+ targetIndex: target.index,
303
+ kind,
304
+ });
305
+ }
306
+
307
+ const entryBlock = makeBlock('entry');
308
+ const exitBlock = makeBlock('exit');
309
+
310
+ // Loop context stack for break/continue resolution
311
+ const loopStack = [];
312
+
313
+ // Label map for labeled break/continue
314
+ const labelMap = new Map();
315
+
316
+ /**
317
+ * Get the body node of a function (handles arrow functions with expression bodies).
318
+ */
319
+ function getFunctionBody(fnNode) {
320
+ const body = fnNode.childForFieldName('body');
321
+ if (!body) return null;
322
+ return body;
323
+ }
324
+
325
+ /**
326
+ * Get statement children from a block or statement list.
327
+ */
328
+ function getStatements(node) {
329
+ if (!node) return [];
330
+ // Block-like nodes: extract named children
331
+ if (node.type === rules.blockNode || rules.blockNodes?.has(node.type)) {
332
+ const stmts = [];
333
+ for (let i = 0; i < node.namedChildCount; i++) {
334
+ stmts.push(node.namedChild(i));
335
+ }
336
+ return stmts;
337
+ }
338
+ // Single statement (e.g., arrow fn with expression body, or unbraced if body)
339
+ return [node];
340
+ }
341
+
342
+ /**
343
+ * Process a list of statements, creating blocks and edges.
344
+ * Returns the last "current" block after processing, or null if all paths terminated.
345
+ */
346
+ function processStatements(stmts, currentBlock) {
347
+ let cur = currentBlock;
348
+
349
+ for (const stmt of stmts) {
350
+ if (!cur) {
351
+ // Dead code after return/break/continue/throw — skip remaining
352
+ break;
353
+ }
354
+ cur = processStatement(stmt, cur);
355
+ }
356
+
357
+ return cur;
358
+ }
359
+
360
+ /**
361
+ * Process a single statement, returns the new current block or null if terminated.
362
+ */
363
+ function processStatement(stmt, currentBlock) {
364
+ if (!stmt || !currentBlock) return currentBlock;
365
+
366
+ // Unwrap expression_statement (Rust uses expressions for control flow)
367
+ if (stmt.type === 'expression_statement' && stmt.namedChildCount === 1) {
368
+ const inner = stmt.namedChild(0);
369
+ const t = inner.type;
370
+ if (
371
+ t === rules.ifNode ||
372
+ rules.ifNodes?.has(t) ||
373
+ rules.forNodes?.has(t) ||
374
+ t === rules.whileNode ||
375
+ rules.whileNodes?.has(t) ||
376
+ t === rules.doNode ||
377
+ t === rules.infiniteLoopNode ||
378
+ t === rules.switchNode ||
379
+ rules.switchNodes?.has(t) ||
380
+ t === rules.returnNode ||
381
+ t === rules.throwNode ||
382
+ t === rules.breakNode ||
383
+ t === rules.continueNode ||
384
+ t === rules.unlessNode ||
385
+ t === rules.untilNode
386
+ ) {
387
+ return processStatement(inner, currentBlock);
388
+ }
389
+ }
390
+
391
+ const type = stmt.type;
392
+
393
+ // Labeled statement: register label then process inner statement
394
+ if (type === rules.labeledNode) {
395
+ const labelNode = stmt.childForFieldName('label');
396
+ const labelName = labelNode ? labelNode.text : null;
397
+ const body = stmt.childForFieldName('body');
398
+ if (body && labelName) {
399
+ // Will be filled when we encounter the loop
400
+ const labelCtx = { headerBlock: null, exitBlock: null };
401
+ labelMap.set(labelName, labelCtx);
402
+ const result = processStatement(body, currentBlock);
403
+ labelMap.delete(labelName);
404
+ return result;
405
+ }
406
+ return currentBlock;
407
+ }
408
+
409
+ // If statement (including language variants like if_let_expression)
410
+ if (type === rules.ifNode || rules.ifNodes?.has(type)) {
411
+ return processIf(stmt, currentBlock);
412
+ }
413
+
414
+ // Unless (Ruby) — same CFG shape as if
415
+ if (rules.unlessNode && type === rules.unlessNode) {
416
+ return processIf(stmt, currentBlock);
417
+ }
418
+
419
+ // For / for-in loops
420
+ if (rules.forNodes.has(type)) {
421
+ return processForLoop(stmt, currentBlock);
422
+ }
423
+
424
+ // While loop (including language variants like while_let_expression)
425
+ if (type === rules.whileNode || rules.whileNodes?.has(type)) {
426
+ return processWhileLoop(stmt, currentBlock);
427
+ }
428
+
429
+ // Until (Ruby) — same CFG shape as while
430
+ if (rules.untilNode && type === rules.untilNode) {
431
+ return processWhileLoop(stmt, currentBlock);
432
+ }
433
+
434
+ // Do-while loop
435
+ if (rules.doNode && type === rules.doNode) {
436
+ return processDoWhileLoop(stmt, currentBlock);
437
+ }
438
+
439
+ // Infinite loop (Rust's loop {})
440
+ if (rules.infiniteLoopNode && type === rules.infiniteLoopNode) {
441
+ return processInfiniteLoop(stmt, currentBlock);
442
+ }
443
+
444
+ // Switch / match statement
445
+ if (type === rules.switchNode || rules.switchNodes?.has(type)) {
446
+ return processSwitch(stmt, currentBlock);
447
+ }
448
+
449
+ // Try/catch/finally
450
+ if (rules.tryNode && type === rules.tryNode) {
451
+ return processTryCatch(stmt, currentBlock);
452
+ }
453
+
454
+ // Return statement
455
+ if (type === rules.returnNode) {
456
+ currentBlock.endLine = stmt.startPosition.row + 1;
457
+ addEdge(currentBlock, exitBlock, 'return');
458
+ return null; // path terminated
459
+ }
460
+
461
+ // Throw statement
462
+ if (type === rules.throwNode) {
463
+ currentBlock.endLine = stmt.startPosition.row + 1;
464
+ addEdge(currentBlock, exitBlock, 'exception');
465
+ return null; // path terminated
466
+ }
467
+
468
+ // Break statement
469
+ if (type === rules.breakNode) {
470
+ const labelNode = stmt.childForFieldName('label');
471
+ const labelName = labelNode ? labelNode.text : null;
472
+
473
+ let target = null;
474
+ if (labelName && labelMap.has(labelName)) {
475
+ target = labelMap.get(labelName).exitBlock;
476
+ } else if (loopStack.length > 0) {
477
+ target = loopStack[loopStack.length - 1].exitBlock;
478
+ }
479
+
480
+ if (target) {
481
+ currentBlock.endLine = stmt.startPosition.row + 1;
482
+ addEdge(currentBlock, target, 'break');
483
+ return null; // path terminated
484
+ }
485
+ // break with no enclosing loop/switch — treat as no-op
486
+ return currentBlock;
487
+ }
488
+
489
+ // Continue statement
490
+ if (type === rules.continueNode) {
491
+ const labelNode = stmt.childForFieldName('label');
492
+ const labelName = labelNode ? labelNode.text : null;
493
+
494
+ let target = null;
495
+ if (labelName && labelMap.has(labelName)) {
496
+ target = labelMap.get(labelName).headerBlock;
497
+ } else if (loopStack.length > 0) {
498
+ target = loopStack[loopStack.length - 1].headerBlock;
499
+ }
500
+
501
+ if (target) {
502
+ currentBlock.endLine = stmt.startPosition.row + 1;
503
+ addEdge(currentBlock, target, 'continue');
504
+ return null; // path terminated
505
+ }
506
+ return currentBlock;
507
+ }
508
+
509
+ // Regular statement — extend current block
510
+ if (!currentBlock.startLine) {
511
+ currentBlock.startLine = stmt.startPosition.row + 1;
512
+ }
513
+ currentBlock.endLine = stmt.endPosition.row + 1;
514
+ return currentBlock;
515
+ }
516
+
517
+ /**
518
+ * Process an if/else-if/else chain.
519
+ * Handles three patterns:
520
+ * A) Wrapper: alternative → else_clause → nested if or block (JS/TS, Rust)
521
+ * B) Siblings: elif/elsif/else_if as sibling children (Python, Ruby, PHP)
522
+ * C) Direct: alternative → if_statement or block directly (Go, Java, C#)
523
+ */
524
+ function processIf(ifStmt, currentBlock) {
525
+ // Terminate current block at condition
526
+ currentBlock.endLine = ifStmt.startPosition.row + 1;
527
+
528
+ const condBlock = makeBlock(
529
+ 'condition',
530
+ ifStmt.startPosition.row + 1,
531
+ ifStmt.startPosition.row + 1,
532
+ 'if',
533
+ );
534
+ addEdge(currentBlock, condBlock, 'fallthrough');
535
+
536
+ const joinBlock = makeBlock('body');
537
+
538
+ // True branch (consequent)
539
+ const consequentField = rules.ifConsequentField || 'consequence';
540
+ const consequent = ifStmt.childForFieldName(consequentField);
541
+ const trueBlock = makeBlock('branch_true', null, null, 'then');
542
+ addEdge(condBlock, trueBlock, 'branch_true');
543
+ const trueStmts = getStatements(consequent);
544
+ const trueEnd = processStatements(trueStmts, trueBlock);
545
+ if (trueEnd) {
546
+ addEdge(trueEnd, joinBlock, 'fallthrough');
547
+ }
548
+
549
+ // False branch — depends on language pattern
550
+ if (rules.elifNode) {
551
+ // Pattern B: elif/else as siblings of the if node
552
+ processElifSiblings(ifStmt, condBlock, joinBlock);
553
+ } else {
554
+ const alternative = ifStmt.childForFieldName('alternative');
555
+ if (alternative) {
556
+ if (rules.elseViaAlternative && alternative.type !== rules.elseClause) {
557
+ // Pattern C: alternative points directly to if or block
558
+ if (alternative.type === rules.ifNode || rules.ifNodes?.has(alternative.type)) {
559
+ // else-if: recurse
560
+ const falseBlock = makeBlock('branch_false', null, null, 'else-if');
561
+ addEdge(condBlock, falseBlock, 'branch_false');
562
+ const elseIfEnd = processIf(alternative, falseBlock);
563
+ if (elseIfEnd) {
564
+ addEdge(elseIfEnd, joinBlock, 'fallthrough');
565
+ }
566
+ } else {
567
+ // else block
568
+ const falseBlock = makeBlock('branch_false', null, null, 'else');
569
+ addEdge(condBlock, falseBlock, 'branch_false');
570
+ const falseStmts = getStatements(alternative);
571
+ const falseEnd = processStatements(falseStmts, falseBlock);
572
+ if (falseEnd) {
573
+ addEdge(falseEnd, joinBlock, 'fallthrough');
574
+ }
575
+ }
576
+ } else if (alternative.type === rules.elseClause) {
577
+ // Pattern A: else_clause wrapper — may contain another if (else-if) or a block
578
+ const elseChildren = [];
579
+ for (let i = 0; i < alternative.namedChildCount; i++) {
580
+ elseChildren.push(alternative.namedChild(i));
581
+ }
582
+ if (
583
+ elseChildren.length === 1 &&
584
+ (elseChildren[0].type === rules.ifNode || rules.ifNodes?.has(elseChildren[0].type))
585
+ ) {
586
+ // else-if: recurse
587
+ const falseBlock = makeBlock('branch_false', null, null, 'else-if');
588
+ addEdge(condBlock, falseBlock, 'branch_false');
589
+ const elseIfEnd = processIf(elseChildren[0], falseBlock);
590
+ if (elseIfEnd) {
591
+ addEdge(elseIfEnd, joinBlock, 'fallthrough');
592
+ }
593
+ } else {
594
+ // else block
595
+ const falseBlock = makeBlock('branch_false', null, null, 'else');
596
+ addEdge(condBlock, falseBlock, 'branch_false');
597
+ const falseEnd = processStatements(elseChildren, falseBlock);
598
+ if (falseEnd) {
599
+ addEdge(falseEnd, joinBlock, 'fallthrough');
600
+ }
601
+ }
602
+ }
603
+ } else {
604
+ // No else: condition-false goes directly to join
605
+ addEdge(condBlock, joinBlock, 'branch_false');
606
+ }
607
+ }
608
+
609
+ return joinBlock;
610
+ }
611
+
612
+ /**
613
+ * Handle Pattern B: elif/elsif/else_if as sibling children of the if node.
614
+ */
615
+ function processElifSiblings(ifStmt, firstCondBlock, joinBlock) {
616
+ let lastCondBlock = firstCondBlock;
617
+ let foundElse = false;
618
+
619
+ for (let i = 0; i < ifStmt.namedChildCount; i++) {
620
+ const child = ifStmt.namedChild(i);
621
+
622
+ if (child.type === rules.elifNode) {
623
+ // Create condition block for elif
624
+ const elifCondBlock = makeBlock(
625
+ 'condition',
626
+ child.startPosition.row + 1,
627
+ child.startPosition.row + 1,
628
+ 'else-if',
629
+ );
630
+ addEdge(lastCondBlock, elifCondBlock, 'branch_false');
631
+
632
+ // True branch of elif
633
+ const elifConsequentField = rules.ifConsequentField || 'consequence';
634
+ const elifConsequent = child.childForFieldName(elifConsequentField);
635
+ const elifTrueBlock = makeBlock('branch_true', null, null, 'then');
636
+ addEdge(elifCondBlock, elifTrueBlock, 'branch_true');
637
+ const elifTrueStmts = getStatements(elifConsequent);
638
+ const elifTrueEnd = processStatements(elifTrueStmts, elifTrueBlock);
639
+ if (elifTrueEnd) {
640
+ addEdge(elifTrueEnd, joinBlock, 'fallthrough');
641
+ }
642
+
643
+ lastCondBlock = elifCondBlock;
644
+ } else if (child.type === rules.elseClause) {
645
+ // Else body
646
+ const elseBlock = makeBlock('branch_false', null, null, 'else');
647
+ addEdge(lastCondBlock, elseBlock, 'branch_false');
648
+
649
+ // Try field access first, then collect children
650
+ const elseBody = child.childForFieldName('body');
651
+ let elseStmts;
652
+ if (elseBody) {
653
+ elseStmts = getStatements(elseBody);
654
+ } else {
655
+ elseStmts = [];
656
+ for (let j = 0; j < child.namedChildCount; j++) {
657
+ elseStmts.push(child.namedChild(j));
658
+ }
659
+ }
660
+ const elseEnd = processStatements(elseStmts, elseBlock);
661
+ if (elseEnd) {
662
+ addEdge(elseEnd, joinBlock, 'fallthrough');
663
+ }
664
+
665
+ foundElse = true;
666
+ }
667
+ }
668
+
669
+ // If no else clause, last condition's false goes to join
670
+ if (!foundElse) {
671
+ addEdge(lastCondBlock, joinBlock, 'branch_false');
672
+ }
673
+ }
674
+
675
+ /**
676
+ * Process a for/for-in loop.
677
+ */
678
+ function processForLoop(forStmt, currentBlock) {
679
+ const headerBlock = makeBlock(
680
+ 'loop_header',
681
+ forStmt.startPosition.row + 1,
682
+ forStmt.startPosition.row + 1,
683
+ 'for',
684
+ );
685
+ addEdge(currentBlock, headerBlock, 'fallthrough');
686
+
687
+ const loopExitBlock = makeBlock('body');
688
+
689
+ // Register loop context
690
+ const loopCtx = { headerBlock, exitBlock: loopExitBlock };
691
+ loopStack.push(loopCtx);
692
+
693
+ // Update label map if this is inside a labeled statement
694
+ for (const [, ctx] of labelMap) {
695
+ if (!ctx.headerBlock) {
696
+ ctx.headerBlock = headerBlock;
697
+ ctx.exitBlock = loopExitBlock;
698
+ }
699
+ }
700
+
701
+ // Loop body
702
+ const body = forStmt.childForFieldName('body');
703
+ const bodyBlock = makeBlock('loop_body');
704
+ addEdge(headerBlock, bodyBlock, 'branch_true');
705
+
706
+ const bodyStmts = getStatements(body);
707
+ const bodyEnd = processStatements(bodyStmts, bodyBlock);
708
+
709
+ if (bodyEnd) {
710
+ addEdge(bodyEnd, headerBlock, 'loop_back');
711
+ }
712
+
713
+ // Loop exit
714
+ addEdge(headerBlock, loopExitBlock, 'loop_exit');
715
+
716
+ loopStack.pop();
717
+ return loopExitBlock;
718
+ }
719
+
720
+ /**
721
+ * Process a while loop.
722
+ */
723
+ function processWhileLoop(whileStmt, currentBlock) {
724
+ const headerBlock = makeBlock(
725
+ 'loop_header',
726
+ whileStmt.startPosition.row + 1,
727
+ whileStmt.startPosition.row + 1,
728
+ 'while',
729
+ );
730
+ addEdge(currentBlock, headerBlock, 'fallthrough');
731
+
732
+ const loopExitBlock = makeBlock('body');
733
+
734
+ const loopCtx = { headerBlock, exitBlock: loopExitBlock };
735
+ loopStack.push(loopCtx);
736
+
737
+ for (const [, ctx] of labelMap) {
738
+ if (!ctx.headerBlock) {
739
+ ctx.headerBlock = headerBlock;
740
+ ctx.exitBlock = loopExitBlock;
741
+ }
742
+ }
743
+
744
+ const body = whileStmt.childForFieldName('body');
745
+ const bodyBlock = makeBlock('loop_body');
746
+ addEdge(headerBlock, bodyBlock, 'branch_true');
747
+
748
+ const bodyStmts = getStatements(body);
749
+ const bodyEnd = processStatements(bodyStmts, bodyBlock);
750
+
751
+ if (bodyEnd) {
752
+ addEdge(bodyEnd, headerBlock, 'loop_back');
753
+ }
754
+
755
+ addEdge(headerBlock, loopExitBlock, 'loop_exit');
756
+
757
+ loopStack.pop();
758
+ return loopExitBlock;
759
+ }
760
+
761
+ /**
762
+ * Process a do-while loop.
763
+ */
764
+ function processDoWhileLoop(doStmt, currentBlock) {
765
+ const bodyBlock = makeBlock('loop_body', doStmt.startPosition.row + 1, null, 'do');
766
+ addEdge(currentBlock, bodyBlock, 'fallthrough');
767
+
768
+ const condBlock = makeBlock('loop_header', null, null, 'do-while');
769
+ const loopExitBlock = makeBlock('body');
770
+
771
+ const loopCtx = { headerBlock: condBlock, exitBlock: loopExitBlock };
772
+ loopStack.push(loopCtx);
773
+
774
+ for (const [, ctx] of labelMap) {
775
+ if (!ctx.headerBlock) {
776
+ ctx.headerBlock = condBlock;
777
+ ctx.exitBlock = loopExitBlock;
778
+ }
779
+ }
780
+
781
+ const body = doStmt.childForFieldName('body');
782
+ const bodyStmts = getStatements(body);
783
+ const bodyEnd = processStatements(bodyStmts, bodyBlock);
784
+
785
+ if (bodyEnd) {
786
+ addEdge(bodyEnd, condBlock, 'fallthrough');
787
+ }
788
+
789
+ // Condition: loop_back or exit
790
+ addEdge(condBlock, bodyBlock, 'loop_back');
791
+ addEdge(condBlock, loopExitBlock, 'loop_exit');
792
+
793
+ loopStack.pop();
794
+ return loopExitBlock;
795
+ }
796
+
797
+ /**
798
+ * Process an infinite loop (Rust's `loop {}`).
799
+ * No condition — body always executes. Exit only via break.
800
+ */
801
+ function processInfiniteLoop(loopStmt, currentBlock) {
802
+ const headerBlock = makeBlock(
803
+ 'loop_header',
804
+ loopStmt.startPosition.row + 1,
805
+ loopStmt.startPosition.row + 1,
806
+ 'loop',
807
+ );
808
+ addEdge(currentBlock, headerBlock, 'fallthrough');
809
+
810
+ const loopExitBlock = makeBlock('body');
811
+
812
+ const loopCtx = { headerBlock, exitBlock: loopExitBlock };
813
+ loopStack.push(loopCtx);
814
+
815
+ for (const [, ctx] of labelMap) {
816
+ if (!ctx.headerBlock) {
817
+ ctx.headerBlock = headerBlock;
818
+ ctx.exitBlock = loopExitBlock;
819
+ }
820
+ }
821
+
822
+ const body = loopStmt.childForFieldName('body');
823
+ const bodyBlock = makeBlock('loop_body');
824
+ addEdge(headerBlock, bodyBlock, 'branch_true');
825
+
826
+ const bodyStmts = getStatements(body);
827
+ const bodyEnd = processStatements(bodyStmts, bodyBlock);
828
+
829
+ if (bodyEnd) {
830
+ addEdge(bodyEnd, headerBlock, 'loop_back');
831
+ }
832
+
833
+ // No loop_exit from header — can only exit via break
834
+
835
+ loopStack.pop();
836
+ return loopExitBlock;
837
+ }
838
+
839
+ /**
840
+ * Process a switch statement.
841
+ */
842
+ function processSwitch(switchStmt, currentBlock) {
843
+ currentBlock.endLine = switchStmt.startPosition.row + 1;
844
+
845
+ const switchHeader = makeBlock(
846
+ 'condition',
847
+ switchStmt.startPosition.row + 1,
848
+ switchStmt.startPosition.row + 1,
849
+ 'switch',
850
+ );
851
+ addEdge(currentBlock, switchHeader, 'fallthrough');
852
+
853
+ const joinBlock = makeBlock('body');
854
+
855
+ // Switch acts like a break target for contained break statements
856
+ const switchCtx = { headerBlock: switchHeader, exitBlock: joinBlock };
857
+ loopStack.push(switchCtx);
858
+
859
+ // Get case children from body field or direct children
860
+ const switchBody = switchStmt.childForFieldName('body');
861
+ const container = switchBody || switchStmt;
862
+
863
+ let hasDefault = false;
864
+ for (let i = 0; i < container.namedChildCount; i++) {
865
+ const caseClause = container.namedChild(i);
866
+
867
+ const isDefault = caseClause.type === rules.defaultNode;
868
+ const isCase =
869
+ isDefault || caseClause.type === rules.caseNode || rules.caseNodes?.has(caseClause.type);
870
+
871
+ if (!isCase) continue;
872
+
873
+ const caseLabel = isDefault ? 'default' : 'case';
874
+ const caseBlock = makeBlock('case', caseClause.startPosition.row + 1, null, caseLabel);
875
+ addEdge(switchHeader, caseBlock, isDefault ? 'branch_false' : 'branch_true');
876
+ if (isDefault) hasDefault = true;
877
+
878
+ // Extract case body: try field access, then collect non-header children
879
+ const caseBodyNode =
880
+ caseClause.childForFieldName('body') || caseClause.childForFieldName('consequence');
881
+ let caseStmts;
882
+ if (caseBodyNode) {
883
+ caseStmts = getStatements(caseBodyNode);
884
+ } else {
885
+ caseStmts = [];
886
+ const valueNode = caseClause.childForFieldName('value');
887
+ const patternNode = caseClause.childForFieldName('pattern');
888
+ for (let j = 0; j < caseClause.namedChildCount; j++) {
889
+ const child = caseClause.namedChild(j);
890
+ if (child !== valueNode && child !== patternNode && child.type !== 'switch_label') {
891
+ caseStmts.push(child);
892
+ }
893
+ }
894
+ }
895
+
896
+ const caseEnd = processStatements(caseStmts, caseBlock);
897
+ if (caseEnd) {
898
+ addEdge(caseEnd, joinBlock, 'fallthrough');
899
+ }
900
+ }
901
+
902
+ // If no default case, switch header can skip to join
903
+ if (!hasDefault) {
904
+ addEdge(switchHeader, joinBlock, 'branch_false');
905
+ }
906
+
907
+ loopStack.pop();
908
+ return joinBlock;
909
+ }
910
+
911
+ /**
912
+ * Process try/catch/finally.
913
+ */
914
+ function processTryCatch(tryStmt, currentBlock) {
915
+ currentBlock.endLine = tryStmt.startPosition.row + 1;
916
+
917
+ const joinBlock = makeBlock('body');
918
+
919
+ // Try body — field access or collect non-handler children (e.g., Ruby's begin)
920
+ const tryBody = tryStmt.childForFieldName('body');
921
+ let tryBodyStart;
922
+ let tryStmts;
923
+ if (tryBody) {
924
+ tryBodyStart = tryBody.startPosition.row + 1;
925
+ tryStmts = getStatements(tryBody);
926
+ } else {
927
+ tryBodyStart = tryStmt.startPosition.row + 1;
928
+ tryStmts = [];
929
+ for (let i = 0; i < tryStmt.namedChildCount; i++) {
930
+ const child = tryStmt.namedChild(i);
931
+ if (rules.catchNode && child.type === rules.catchNode) continue;
932
+ if (rules.finallyNode && child.type === rules.finallyNode) continue;
933
+ tryStmts.push(child);
934
+ }
935
+ }
936
+
937
+ const tryBlock = makeBlock('body', tryBodyStart, null, 'try');
938
+ addEdge(currentBlock, tryBlock, 'fallthrough');
939
+ const tryEnd = processStatements(tryStmts, tryBlock);
940
+
941
+ // Catch handler
942
+ let catchHandler = null;
943
+ let finallyHandler = null;
944
+ for (let i = 0; i < tryStmt.namedChildCount; i++) {
945
+ const child = tryStmt.namedChild(i);
946
+ if (rules.catchNode && child.type === rules.catchNode) catchHandler = child;
947
+ if (rules.finallyNode && child.type === rules.finallyNode) finallyHandler = child;
948
+ }
949
+
950
+ if (catchHandler) {
951
+ const catchBlock = makeBlock('catch', catchHandler.startPosition.row + 1, null, 'catch');
952
+ // Exception edge from try to catch
953
+ addEdge(tryBlock, catchBlock, 'exception');
954
+
955
+ // Catch body — try field access, then collect children
956
+ const catchBodyNode = catchHandler.childForFieldName('body');
957
+ let catchStmts;
958
+ if (catchBodyNode) {
959
+ catchStmts = getStatements(catchBodyNode);
960
+ } else {
961
+ catchStmts = [];
962
+ for (let i = 0; i < catchHandler.namedChildCount; i++) {
963
+ catchStmts.push(catchHandler.namedChild(i));
964
+ }
965
+ }
966
+ const catchEnd = processStatements(catchStmts, catchBlock);
967
+
968
+ if (finallyHandler) {
969
+ const finallyBlock = makeBlock(
970
+ 'finally',
971
+ finallyHandler.startPosition.row + 1,
972
+ null,
973
+ 'finally',
974
+ );
975
+ if (tryEnd) addEdge(tryEnd, finallyBlock, 'fallthrough');
976
+ if (catchEnd) addEdge(catchEnd, finallyBlock, 'fallthrough');
977
+
978
+ const finallyBodyNode = finallyHandler.childForFieldName('body');
979
+ const finallyStmts = finallyBodyNode
980
+ ? getStatements(finallyBodyNode)
981
+ : getStatements(finallyHandler);
982
+ const finallyEnd = processStatements(finallyStmts, finallyBlock);
983
+ if (finallyEnd) addEdge(finallyEnd, joinBlock, 'fallthrough');
984
+ } else {
985
+ if (tryEnd) addEdge(tryEnd, joinBlock, 'fallthrough');
986
+ if (catchEnd) addEdge(catchEnd, joinBlock, 'fallthrough');
987
+ }
988
+ } else if (finallyHandler) {
989
+ const finallyBlock = makeBlock(
990
+ 'finally',
991
+ finallyHandler.startPosition.row + 1,
992
+ null,
993
+ 'finally',
994
+ );
995
+ if (tryEnd) addEdge(tryEnd, finallyBlock, 'fallthrough');
996
+
997
+ const finallyBodyNode = finallyHandler.childForFieldName('body');
998
+ const finallyStmts = finallyBodyNode
999
+ ? getStatements(finallyBodyNode)
1000
+ : getStatements(finallyHandler);
1001
+ const finallyEnd = processStatements(finallyStmts, finallyBlock);
1002
+ if (finallyEnd) addEdge(finallyEnd, joinBlock, 'fallthrough');
1003
+ } else {
1004
+ if (tryEnd) addEdge(tryEnd, joinBlock, 'fallthrough');
1005
+ }
1006
+
1007
+ return joinBlock;
1008
+ }
1009
+
1010
+ // ── Main entry point ──────────────────────────────────────────────────
1011
+
1012
+ const body = getFunctionBody(functionNode);
1013
+ if (!body) {
1014
+ // Empty function or expression body
1015
+ addEdge(entryBlock, exitBlock, 'fallthrough');
1016
+ return { blocks, edges };
1017
+ }
1018
+
1019
+ const stmts = getStatements(body);
1020
+ if (stmts.length === 0) {
1021
+ addEdge(entryBlock, exitBlock, 'fallthrough');
1022
+ return { blocks, edges };
1023
+ }
1024
+
1025
+ const firstBlock = makeBlock('body');
1026
+ addEdge(entryBlock, firstBlock, 'fallthrough');
1027
+
1028
+ const lastBlock = processStatements(stmts, firstBlock);
1029
+ if (lastBlock) {
1030
+ addEdge(lastBlock, exitBlock, 'fallthrough');
1031
+ }
1032
+
1033
+ return { blocks, edges };
1034
+ }
1035
+
1036
+ // ─── Build-Time: Compute CFG for Changed Files ─────────────────────────
1037
+
1038
+ /**
1039
+ * Build CFG data for all function/method definitions and persist to DB.
1040
+ *
1041
+ * @param {object} db - open better-sqlite3 database (read-write)
1042
+ * @param {Map<string, object>} fileSymbols - Map<relPath, { definitions, _tree, _langId }>
1043
+ * @param {string} rootDir - absolute project root path
1044
+ * @param {object} [_engineOpts] - engine options (unused; always uses WASM for AST)
1045
+ */
1046
+ export async function buildCFGData(db, fileSymbols, rootDir, _engineOpts) {
1047
+ // Lazily init WASM parsers if needed
1048
+ let parsers = null;
1049
+ let extToLang = null;
1050
+ let needsFallback = false;
1051
+
1052
+ for (const [relPath, symbols] of fileSymbols) {
1053
+ if (!symbols._tree) {
1054
+ const ext = path.extname(relPath).toLowerCase();
1055
+ if (CFG_EXTENSIONS.has(ext)) {
1056
+ needsFallback = true;
1057
+ break;
1058
+ }
1059
+ }
1060
+ }
1061
+
1062
+ if (needsFallback) {
1063
+ const { createParsers } = await import('./parser.js');
1064
+ parsers = await createParsers();
1065
+ extToLang = new Map();
1066
+ for (const entry of LANGUAGE_REGISTRY) {
1067
+ for (const ext of entry.extensions) {
1068
+ extToLang.set(ext, entry.id);
1069
+ }
1070
+ }
1071
+ }
1072
+
1073
+ let getParserFn = null;
1074
+ if (parsers) {
1075
+ const mod = await import('./parser.js');
1076
+ getParserFn = mod.getParser;
1077
+ }
1078
+
1079
+ const { findFunctionNode } = await import('./complexity.js');
1080
+
1081
+ const insertBlock = db.prepare(
1082
+ `INSERT INTO cfg_blocks (function_node_id, block_index, block_type, start_line, end_line, label)
1083
+ VALUES (?, ?, ?, ?, ?, ?)`,
1084
+ );
1085
+ const insertEdge = db.prepare(
1086
+ `INSERT INTO cfg_edges (function_node_id, source_block_id, target_block_id, kind)
1087
+ VALUES (?, ?, ?, ?)`,
1088
+ );
1089
+ const deleteBlocks = db.prepare('DELETE FROM cfg_blocks WHERE function_node_id = ?');
1090
+ const deleteEdges = db.prepare('DELETE FROM cfg_edges WHERE function_node_id = ?');
1091
+ const getNodeId = db.prepare(
1092
+ "SELECT id FROM nodes WHERE name = ? AND kind IN ('function','method') AND file = ? AND line = ?",
1093
+ );
1094
+
1095
+ let analyzed = 0;
1096
+
1097
+ const tx = db.transaction(() => {
1098
+ for (const [relPath, symbols] of fileSymbols) {
1099
+ const ext = path.extname(relPath).toLowerCase();
1100
+ if (!CFG_EXTENSIONS.has(ext)) continue;
1101
+
1102
+ let tree = symbols._tree;
1103
+ let langId = symbols._langId;
1104
+
1105
+ // WASM fallback if no cached tree
1106
+ if (!tree) {
1107
+ if (!extToLang || !getParserFn) continue;
1108
+ langId = extToLang.get(ext);
1109
+ if (!langId || !CFG_LANG_IDS.has(langId)) continue;
1110
+
1111
+ const absPath = path.join(rootDir, relPath);
1112
+ let code;
1113
+ try {
1114
+ code = fs.readFileSync(absPath, 'utf-8');
1115
+ } catch {
1116
+ continue;
1117
+ }
1118
+
1119
+ const parser = getParserFn(parsers, absPath);
1120
+ if (!parser) continue;
1121
+
1122
+ try {
1123
+ tree = parser.parse(code);
1124
+ } catch {
1125
+ continue;
1126
+ }
1127
+ }
1128
+
1129
+ if (!langId) {
1130
+ langId = extToLang ? extToLang.get(ext) : null;
1131
+ if (!langId) continue;
1132
+ }
1133
+
1134
+ const cfgRules = CFG_RULES.get(langId);
1135
+ if (!cfgRules) continue;
1136
+
1137
+ const complexityRules = COMPLEXITY_RULES.get(langId);
1138
+ if (!complexityRules) continue;
1139
+
1140
+ for (const def of symbols.definitions) {
1141
+ if (def.kind !== 'function' && def.kind !== 'method') continue;
1142
+ if (!def.line) continue;
1143
+
1144
+ const row = getNodeId.get(def.name, relPath, def.line);
1145
+ if (!row) continue;
1146
+
1147
+ const funcNode = findFunctionNode(tree.rootNode, def.line, def.endLine, complexityRules);
1148
+ if (!funcNode) continue;
1149
+
1150
+ const cfg = buildFunctionCFG(funcNode, langId);
1151
+ if (cfg.blocks.length === 0) continue;
1152
+
1153
+ // Clear old CFG data for this function
1154
+ deleteEdges.run(row.id);
1155
+ deleteBlocks.run(row.id);
1156
+
1157
+ // Insert blocks and build index→dbId mapping
1158
+ const blockDbIds = new Map();
1159
+ for (const block of cfg.blocks) {
1160
+ const result = insertBlock.run(
1161
+ row.id,
1162
+ block.index,
1163
+ block.type,
1164
+ block.startLine,
1165
+ block.endLine,
1166
+ block.label,
1167
+ );
1168
+ blockDbIds.set(block.index, result.lastInsertRowid);
1169
+ }
1170
+
1171
+ // Insert edges
1172
+ for (const edge of cfg.edges) {
1173
+ const sourceDbId = blockDbIds.get(edge.sourceIndex);
1174
+ const targetDbId = blockDbIds.get(edge.targetIndex);
1175
+ if (sourceDbId && targetDbId) {
1176
+ insertEdge.run(row.id, sourceDbId, targetDbId, edge.kind);
1177
+ }
1178
+ }
1179
+
1180
+ analyzed++;
1181
+ }
1182
+
1183
+ // Don't release _tree here — complexity/dataflow may still need it
1184
+ }
1185
+ });
1186
+
1187
+ tx();
1188
+
1189
+ if (analyzed > 0) {
1190
+ info(`CFG: ${analyzed} functions analyzed`);
1191
+ }
1192
+ }
1193
+
1194
+ // ─── Query-Time Functions ───────────────────────────────────────────────
1195
+
1196
+ function hasCfgTables(db) {
1197
+ try {
1198
+ db.prepare('SELECT 1 FROM cfg_blocks LIMIT 0').get();
1199
+ return true;
1200
+ } catch {
1201
+ return false;
1202
+ }
1203
+ }
1204
+
1205
+ function findNodes(db, name, opts = {}) {
1206
+ const kinds = opts.kind ? [opts.kind] : ['function', 'method'];
1207
+ const placeholders = kinds.map(() => '?').join(', ');
1208
+ const params = [`%${name}%`, ...kinds];
1209
+
1210
+ let fileCondition = '';
1211
+ if (opts.file) {
1212
+ fileCondition = ' AND n.file LIKE ?';
1213
+ params.push(`%${opts.file}%`);
1214
+ }
1215
+
1216
+ const rows = db
1217
+ .prepare(
1218
+ `SELECT n.id, n.name, n.kind, n.file, n.line, n.end_line
1219
+ FROM nodes n
1220
+ WHERE n.name LIKE ? AND n.kind IN (${placeholders})${fileCondition}`,
1221
+ )
1222
+ .all(...params);
1223
+
1224
+ return opts.noTests ? rows.filter((n) => !isTestFile(n.file)) : rows;
1225
+ }
1226
+
1227
+ /**
1228
+ * Load CFG data for a function from the database.
1229
+ *
1230
+ * @param {string} name - Function name (partial match)
1231
+ * @param {string} [customDbPath] - Path to graph.db
1232
+ * @param {object} [opts] - Options
1233
+ * @returns {{ function: object, blocks: object[], edges: object[], summary: object }}
1234
+ */
1235
+ export function cfgData(name, customDbPath, opts = {}) {
1236
+ const db = openReadonlyOrFail(customDbPath);
1237
+ const noTests = opts.noTests || false;
1238
+
1239
+ if (!hasCfgTables(db)) {
1240
+ db.close();
1241
+ return {
1242
+ name,
1243
+ results: [],
1244
+ warning: 'No CFG data found. Run `codegraph build --cfg` first.',
1245
+ };
1246
+ }
1247
+
1248
+ const nodes = findNodes(db, name, { noTests, file: opts.file, kind: opts.kind });
1249
+ if (nodes.length === 0) {
1250
+ db.close();
1251
+ return { name, results: [] };
1252
+ }
1253
+
1254
+ const blockStmt = db.prepare(
1255
+ `SELECT id, block_index, block_type, start_line, end_line, label
1256
+ FROM cfg_blocks WHERE function_node_id = ?
1257
+ ORDER BY block_index`,
1258
+ );
1259
+ const edgeStmt = db.prepare(
1260
+ `SELECT e.kind,
1261
+ sb.block_index AS source_index, sb.block_type AS source_type,
1262
+ tb.block_index AS target_index, tb.block_type AS target_type
1263
+ FROM cfg_edges e
1264
+ JOIN cfg_blocks sb ON e.source_block_id = sb.id
1265
+ JOIN cfg_blocks tb ON e.target_block_id = tb.id
1266
+ WHERE e.function_node_id = ?
1267
+ ORDER BY sb.block_index, tb.block_index`,
1268
+ );
1269
+
1270
+ const results = nodes.map((node) => {
1271
+ const cfgBlocks = blockStmt.all(node.id);
1272
+ const cfgEdges = edgeStmt.all(node.id);
1273
+
1274
+ return {
1275
+ name: node.name,
1276
+ kind: node.kind,
1277
+ file: node.file,
1278
+ line: node.line,
1279
+ blocks: cfgBlocks.map((b) => ({
1280
+ index: b.block_index,
1281
+ type: b.block_type,
1282
+ startLine: b.start_line,
1283
+ endLine: b.end_line,
1284
+ label: b.label,
1285
+ })),
1286
+ edges: cfgEdges.map((e) => ({
1287
+ source: e.source_index,
1288
+ sourceType: e.source_type,
1289
+ target: e.target_index,
1290
+ targetType: e.target_type,
1291
+ kind: e.kind,
1292
+ })),
1293
+ summary: {
1294
+ blockCount: cfgBlocks.length,
1295
+ edgeCount: cfgEdges.length,
1296
+ },
1297
+ };
1298
+ });
1299
+
1300
+ db.close();
1301
+ return paginateResult({ name, results }, 'results', opts);
1302
+ }
1303
+
1304
+ // ─── Export Formats ─────────────────────────────────────────────────────
1305
+
1306
+ /**
1307
+ * Convert CFG data to DOT format for Graphviz rendering.
1308
+ */
1309
+ export function cfgToDOT(cfgResult) {
1310
+ const lines = [];
1311
+
1312
+ for (const r of cfgResult.results) {
1313
+ lines.push(`digraph "${r.name}" {`);
1314
+ lines.push(' rankdir=TB;');
1315
+ lines.push(' node [shape=box, fontname="monospace", fontsize=10];');
1316
+
1317
+ for (const block of r.blocks) {
1318
+ const label = blockLabel(block);
1319
+ const shape = block.type === 'entry' || block.type === 'exit' ? 'ellipse' : 'box';
1320
+ const style =
1321
+ block.type === 'condition' || block.type === 'loop_header'
1322
+ ? ', style=filled, fillcolor="#ffffcc"'
1323
+ : '';
1324
+ lines.push(` B${block.index} [label="${label}", shape=${shape}${style}];`);
1325
+ }
1326
+
1327
+ for (const edge of r.edges) {
1328
+ const style = edgeStyle(edge.kind);
1329
+ lines.push(` B${edge.source} -> B${edge.target} [label="${edge.kind}"${style}];`);
1330
+ }
1331
+
1332
+ lines.push('}');
1333
+ }
1334
+
1335
+ return lines.join('\n');
1336
+ }
1337
+
1338
+ /**
1339
+ * Convert CFG data to Mermaid format.
1340
+ */
1341
+ export function cfgToMermaid(cfgResult) {
1342
+ const lines = [];
1343
+
1344
+ for (const r of cfgResult.results) {
1345
+ lines.push(`graph TD`);
1346
+ lines.push(` subgraph "${r.name}"`);
1347
+
1348
+ for (const block of r.blocks) {
1349
+ const label = blockLabel(block);
1350
+ if (block.type === 'entry' || block.type === 'exit') {
1351
+ lines.push(` B${block.index}(["${label}"])`);
1352
+ } else if (block.type === 'condition' || block.type === 'loop_header') {
1353
+ lines.push(` B${block.index}{"${label}"}`);
1354
+ } else {
1355
+ lines.push(` B${block.index}["${label}"]`);
1356
+ }
1357
+ }
1358
+
1359
+ for (const edge of r.edges) {
1360
+ const label = edge.kind;
1361
+ lines.push(` B${edge.source} -->|${label}| B${edge.target}`);
1362
+ }
1363
+
1364
+ lines.push(' end');
1365
+ }
1366
+
1367
+ return lines.join('\n');
1368
+ }
1369
+
1370
+ function blockLabel(block) {
1371
+ const loc =
1372
+ block.startLine && block.endLine
1373
+ ? ` L${block.startLine}${block.endLine !== block.startLine ? `-${block.endLine}` : ''}`
1374
+ : '';
1375
+ const label = block.label ? ` (${block.label})` : '';
1376
+ return `${block.type}${label}${loc}`;
1377
+ }
1378
+
1379
+ function edgeStyle(kind) {
1380
+ if (kind === 'exception') return ', color=red, fontcolor=red';
1381
+ if (kind === 'branch_true') return ', color=green, fontcolor=green';
1382
+ if (kind === 'branch_false') return ', color=red, fontcolor=red';
1383
+ if (kind === 'loop_back') return ', style=dashed, color=blue';
1384
+ if (kind === 'loop_exit') return ', color=orange';
1385
+ if (kind === 'return') return ', color=purple';
1386
+ if (kind === 'break') return ', color=orange, style=dashed';
1387
+ if (kind === 'continue') return ', color=blue, style=dashed';
1388
+ return '';
1389
+ }
1390
+
1391
+ // ─── CLI Printer ────────────────────────────────────────────────────────
1392
+
1393
+ /**
1394
+ * CLI display for cfg command.
1395
+ */
1396
+ export function cfg(name, customDbPath, opts = {}) {
1397
+ const data = cfgData(name, customDbPath, opts);
1398
+
1399
+ if (opts.json) {
1400
+ console.log(JSON.stringify(data, null, 2));
1401
+ return;
1402
+ }
1403
+ if (opts.ndjson) {
1404
+ printNdjson(data.results);
1405
+ return;
1406
+ }
1407
+
1408
+ if (data.warning) {
1409
+ console.log(`\u26A0 ${data.warning}`);
1410
+ return;
1411
+ }
1412
+ if (data.results.length === 0) {
1413
+ console.log(`No symbols matching "${name}".`);
1414
+ return;
1415
+ }
1416
+
1417
+ const format = opts.format || 'text';
1418
+ if (format === 'dot') {
1419
+ console.log(cfgToDOT(data));
1420
+ return;
1421
+ }
1422
+ if (format === 'mermaid') {
1423
+ console.log(cfgToMermaid(data));
1424
+ return;
1425
+ }
1426
+
1427
+ // Text format
1428
+ for (const r of data.results) {
1429
+ console.log(`\n${r.kind} ${r.name} (${r.file}:${r.line})`);
1430
+ console.log('\u2500'.repeat(60));
1431
+ console.log(` Blocks: ${r.summary.blockCount} Edges: ${r.summary.edgeCount}`);
1432
+
1433
+ if (r.blocks.length > 0) {
1434
+ console.log('\n Blocks:');
1435
+ for (const b of r.blocks) {
1436
+ const loc = b.startLine
1437
+ ? ` L${b.startLine}${b.endLine && b.endLine !== b.startLine ? `-${b.endLine}` : ''}`
1438
+ : '';
1439
+ const label = b.label ? ` (${b.label})` : '';
1440
+ console.log(` [${b.index}] ${b.type}${label}${loc}`);
1441
+ }
1442
+ }
1443
+
1444
+ if (r.edges.length > 0) {
1445
+ console.log('\n Edges:');
1446
+ for (const e of r.edges) {
1447
+ console.log(` B${e.source} \u2192 B${e.target} [${e.kind}]`);
1448
+ }
1449
+ }
1450
+ }
1451
+ }