@optave/codegraph 3.1.1 → 3.1.3

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.
Files changed (72) hide show
  1. package/README.md +6 -6
  2. package/package.json +7 -7
  3. package/src/ast-analysis/engine.js +365 -0
  4. package/src/ast-analysis/metrics.js +118 -0
  5. package/src/ast-analysis/visitor-utils.js +176 -0
  6. package/src/ast-analysis/visitor.js +162 -0
  7. package/src/ast-analysis/visitors/ast-store-visitor.js +150 -0
  8. package/src/ast-analysis/visitors/cfg-visitor.js +792 -0
  9. package/src/ast-analysis/visitors/complexity-visitor.js +243 -0
  10. package/src/ast-analysis/visitors/dataflow-visitor.js +358 -0
  11. package/src/ast.js +13 -140
  12. package/src/audit.js +2 -87
  13. package/src/batch.js +0 -25
  14. package/src/boundaries.js +1 -1
  15. package/src/branch-compare.js +1 -96
  16. package/src/builder.js +60 -178
  17. package/src/cfg.js +89 -883
  18. package/src/check.js +1 -84
  19. package/src/cli.js +31 -22
  20. package/src/cochange.js +1 -39
  21. package/src/commands/audit.js +88 -0
  22. package/src/commands/batch.js +26 -0
  23. package/src/commands/branch-compare.js +97 -0
  24. package/src/commands/cfg.js +55 -0
  25. package/src/commands/check.js +82 -0
  26. package/src/commands/cochange.js +37 -0
  27. package/src/commands/communities.js +69 -0
  28. package/src/commands/complexity.js +77 -0
  29. package/src/commands/dataflow.js +110 -0
  30. package/src/commands/flow.js +70 -0
  31. package/src/commands/manifesto.js +77 -0
  32. package/src/commands/owners.js +52 -0
  33. package/src/commands/query.js +21 -0
  34. package/src/commands/sequence.js +33 -0
  35. package/src/commands/structure.js +64 -0
  36. package/src/commands/triage.js +49 -0
  37. package/src/communities.js +12 -83
  38. package/src/complexity.js +43 -357
  39. package/src/cycles.js +1 -1
  40. package/src/dataflow.js +12 -665
  41. package/src/db/repository/build-stmts.js +104 -0
  42. package/src/db/repository/cached-stmt.js +19 -0
  43. package/src/db/repository/cfg.js +72 -0
  44. package/src/db/repository/cochange.js +54 -0
  45. package/src/db/repository/complexity.js +20 -0
  46. package/src/db/repository/dataflow.js +17 -0
  47. package/src/db/repository/edges.js +281 -0
  48. package/src/db/repository/embeddings.js +51 -0
  49. package/src/db/repository/graph-read.js +59 -0
  50. package/src/db/repository/index.js +43 -0
  51. package/src/db/repository/nodes.js +247 -0
  52. package/src/db.js +40 -1
  53. package/src/embedder.js +14 -34
  54. package/src/export.js +1 -1
  55. package/src/extractors/javascript.js +130 -5
  56. package/src/flow.js +2 -70
  57. package/src/index.js +30 -20
  58. package/src/{result-formatter.js → infrastructure/result-formatter.js} +1 -1
  59. package/src/kinds.js +1 -0
  60. package/src/manifesto.js +0 -76
  61. package/src/native.js +31 -9
  62. package/src/owners.js +1 -56
  63. package/src/parser.js +53 -2
  64. package/src/queries-cli.js +1 -1
  65. package/src/queries.js +79 -280
  66. package/src/sequence.js +5 -44
  67. package/src/structure.js +16 -75
  68. package/src/triage.js +1 -54
  69. package/src/viewer.js +1 -1
  70. package/src/watcher.js +7 -4
  71. package/src/db/repository.js +0 -134
  72. /package/src/{test-filter.js → infrastructure/test-filter.js} +0 -0
package/src/cfg.js CHANGED
@@ -7,20 +7,26 @@
7
7
 
8
8
  import fs from 'node:fs';
9
9
  import path from 'node:path';
10
- import { CFG_RULES, COMPLEXITY_RULES } from './ast-analysis/rules/index.js';
10
+ import { CFG_RULES } from './ast-analysis/rules/index.js';
11
11
  import {
12
12
  makeCfgRules as _makeCfgRules,
13
13
  buildExtensionSet,
14
14
  buildExtToLangMap,
15
- findFunctionNode,
16
15
  } from './ast-analysis/shared.js';
17
- import { openReadonlyOrFail } from './db.js';
16
+ import { walkWithVisitors } from './ast-analysis/visitor.js';
17
+ import { createCfgVisitor } from './ast-analysis/visitors/cfg-visitor.js';
18
+ import {
19
+ deleteCfgForNode,
20
+ getCfgBlocks,
21
+ getCfgEdges,
22
+ getFunctionNodeId,
23
+ hasCfgTables,
24
+ openReadonlyOrFail,
25
+ } from './db.js';
26
+ import { isTestFile } from './infrastructure/test-filter.js';
18
27
  import { info } from './logger.js';
19
28
  import { paginateResult } from './paginate.js';
20
29
 
21
- import { outputResult } from './result-formatter.js';
22
- import { isTestFile } from './test-filter.js';
23
-
24
30
  // Re-export for backward compatibility
25
31
  export { CFG_RULES };
26
32
  export { _makeCfgRules as makeCfgRules };
@@ -32,784 +38,35 @@ const CFG_EXTENSIONS = buildExtensionSet(CFG_RULES);
32
38
  /**
33
39
  * Build a control flow graph for a single function AST node.
34
40
  *
41
+ * Thin wrapper around the CFG visitor — runs walkWithVisitors on the function
42
+ * node and returns the first result. All CFG construction logic lives in
43
+ * `ast-analysis/visitors/cfg-visitor.js`.
44
+ *
35
45
  * @param {object} functionNode - tree-sitter function AST node
36
- * @param {string} langId - language identifier (javascript, typescript, tsx)
37
- * @returns {{ blocks: object[], edges: object[] }} - CFG blocks and edges
46
+ * @param {string} langId - language identifier
47
+ * @returns {{ blocks: object[], edges: object[], cyclomatic: number }} - CFG blocks, edges, and derived cyclomatic
38
48
  */
39
49
  export function buildFunctionCFG(functionNode, langId) {
40
50
  const rules = CFG_RULES.get(langId);
41
- if (!rules) return { blocks: [], edges: [] };
42
-
43
- const blocks = [];
44
- const edges = [];
45
- let nextIndex = 0;
46
-
47
- function makeBlock(type, startLine = null, endLine = null, label = null) {
48
- const block = {
49
- index: nextIndex++,
50
- type,
51
- startLine,
52
- endLine,
53
- label,
54
- };
55
- blocks.push(block);
56
- return block;
57
- }
58
-
59
- function addEdge(source, target, kind) {
60
- edges.push({
61
- sourceIndex: source.index,
62
- targetIndex: target.index,
63
- kind,
64
- });
65
- }
66
-
67
- const entryBlock = makeBlock('entry');
68
- const exitBlock = makeBlock('exit');
69
-
70
- // Loop context stack for break/continue resolution
71
- const loopStack = [];
72
-
73
- // Label map for labeled break/continue
74
- const labelMap = new Map();
75
-
76
- /**
77
- * Get the body node of a function (handles arrow functions with expression bodies).
78
- */
79
- function getFunctionBody(fnNode) {
80
- const body = fnNode.childForFieldName('body');
81
- if (!body) return null;
82
- return body;
83
- }
84
-
85
- /**
86
- * Get statement children from a block or statement list.
87
- */
88
- function getStatements(node) {
89
- if (!node) return [];
90
- // Block-like nodes (including statement_list wrappers from tree-sitter-go 0.25+)
91
- if (
92
- node.type === 'statement_list' ||
93
- node.type === rules.blockNode ||
94
- rules.blockNodes?.has(node.type)
95
- ) {
96
- const stmts = [];
97
- for (let i = 0; i < node.namedChildCount; i++) {
98
- const child = node.namedChild(i);
99
- if (child.type === 'statement_list') {
100
- // Unwrap nested statement_list (block → statement_list → stmts)
101
- for (let j = 0; j < child.namedChildCount; j++) {
102
- stmts.push(child.namedChild(j));
103
- }
104
- } else {
105
- stmts.push(child);
106
- }
107
- }
108
- return stmts;
109
- }
110
- // Single statement (e.g., arrow fn with expression body, or unbraced if body)
111
- return [node];
112
- }
113
-
114
- /**
115
- * Process a list of statements, creating blocks and edges.
116
- * Returns the last "current" block after processing, or null if all paths terminated.
117
- */
118
- function processStatements(stmts, currentBlock) {
119
- let cur = currentBlock;
120
-
121
- for (const stmt of stmts) {
122
- if (!cur) {
123
- // Dead code after return/break/continue/throw — skip remaining
124
- break;
125
- }
126
- cur = processStatement(stmt, cur);
127
- }
128
-
129
- return cur;
130
- }
131
-
132
- /**
133
- * Process a single statement, returns the new current block or null if terminated.
134
- */
135
- function processStatement(stmt, currentBlock) {
136
- if (!stmt || !currentBlock) return currentBlock;
137
-
138
- // Unwrap expression_statement (Rust uses expressions for control flow)
139
- if (stmt.type === 'expression_statement' && stmt.namedChildCount === 1) {
140
- const inner = stmt.namedChild(0);
141
- const t = inner.type;
142
- if (
143
- t === rules.ifNode ||
144
- rules.ifNodes?.has(t) ||
145
- rules.forNodes?.has(t) ||
146
- t === rules.whileNode ||
147
- rules.whileNodes?.has(t) ||
148
- t === rules.doNode ||
149
- t === rules.infiniteLoopNode ||
150
- t === rules.switchNode ||
151
- rules.switchNodes?.has(t) ||
152
- t === rules.returnNode ||
153
- t === rules.throwNode ||
154
- t === rules.breakNode ||
155
- t === rules.continueNode ||
156
- t === rules.unlessNode ||
157
- t === rules.untilNode
158
- ) {
159
- return processStatement(inner, currentBlock);
160
- }
161
- }
162
-
163
- const type = stmt.type;
164
-
165
- // Labeled statement: register label then process inner statement
166
- if (type === rules.labeledNode) {
167
- const labelNode = stmt.childForFieldName('label');
168
- const labelName = labelNode ? labelNode.text : null;
169
- const body = stmt.childForFieldName('body');
170
- if (body && labelName) {
171
- // Will be filled when we encounter the loop
172
- const labelCtx = { headerBlock: null, exitBlock: null };
173
- labelMap.set(labelName, labelCtx);
174
- const result = processStatement(body, currentBlock);
175
- labelMap.delete(labelName);
176
- return result;
177
- }
178
- return currentBlock;
179
- }
180
-
181
- // If statement (including language variants like if_let_expression)
182
- if (type === rules.ifNode || rules.ifNodes?.has(type)) {
183
- return processIf(stmt, currentBlock);
184
- }
185
-
186
- // Unless (Ruby) — same CFG shape as if
187
- if (rules.unlessNode && type === rules.unlessNode) {
188
- return processIf(stmt, currentBlock);
189
- }
190
-
191
- // For / for-in loops
192
- if (rules.forNodes.has(type)) {
193
- return processForLoop(stmt, currentBlock);
194
- }
195
-
196
- // While loop (including language variants like while_let_expression)
197
- if (type === rules.whileNode || rules.whileNodes?.has(type)) {
198
- return processWhileLoop(stmt, currentBlock);
199
- }
200
-
201
- // Until (Ruby) — same CFG shape as while
202
- if (rules.untilNode && type === rules.untilNode) {
203
- return processWhileLoop(stmt, currentBlock);
204
- }
205
-
206
- // Do-while loop
207
- if (rules.doNode && type === rules.doNode) {
208
- return processDoWhileLoop(stmt, currentBlock);
209
- }
210
-
211
- // Infinite loop (Rust's loop {})
212
- if (rules.infiniteLoopNode && type === rules.infiniteLoopNode) {
213
- return processInfiniteLoop(stmt, currentBlock);
214
- }
215
-
216
- // Switch / match statement
217
- if (type === rules.switchNode || rules.switchNodes?.has(type)) {
218
- return processSwitch(stmt, currentBlock);
219
- }
220
-
221
- // Try/catch/finally
222
- if (rules.tryNode && type === rules.tryNode) {
223
- return processTryCatch(stmt, currentBlock);
224
- }
225
-
226
- // Return statement
227
- if (type === rules.returnNode) {
228
- currentBlock.endLine = stmt.startPosition.row + 1;
229
- addEdge(currentBlock, exitBlock, 'return');
230
- return null; // path terminated
231
- }
232
-
233
- // Throw statement
234
- if (type === rules.throwNode) {
235
- currentBlock.endLine = stmt.startPosition.row + 1;
236
- addEdge(currentBlock, exitBlock, 'exception');
237
- return null; // path terminated
238
- }
239
-
240
- // Break statement
241
- if (type === rules.breakNode) {
242
- const labelNode = stmt.childForFieldName('label');
243
- const labelName = labelNode ? labelNode.text : null;
244
-
245
- let target = null;
246
- if (labelName && labelMap.has(labelName)) {
247
- target = labelMap.get(labelName).exitBlock;
248
- } else if (loopStack.length > 0) {
249
- target = loopStack[loopStack.length - 1].exitBlock;
250
- }
251
-
252
- if (target) {
253
- currentBlock.endLine = stmt.startPosition.row + 1;
254
- addEdge(currentBlock, target, 'break');
255
- return null; // path terminated
256
- }
257
- // break with no enclosing loop/switch — treat as no-op
258
- return currentBlock;
259
- }
260
-
261
- // Continue statement
262
- if (type === rules.continueNode) {
263
- const labelNode = stmt.childForFieldName('label');
264
- const labelName = labelNode ? labelNode.text : null;
265
-
266
- let target = null;
267
- if (labelName && labelMap.has(labelName)) {
268
- target = labelMap.get(labelName).headerBlock;
269
- } else if (loopStack.length > 0) {
270
- target = loopStack[loopStack.length - 1].headerBlock;
271
- }
272
-
273
- if (target) {
274
- currentBlock.endLine = stmt.startPosition.row + 1;
275
- addEdge(currentBlock, target, 'continue');
276
- return null; // path terminated
277
- }
278
- return currentBlock;
279
- }
280
-
281
- // Regular statement — extend current block
282
- if (!currentBlock.startLine) {
283
- currentBlock.startLine = stmt.startPosition.row + 1;
284
- }
285
- currentBlock.endLine = stmt.endPosition.row + 1;
286
- return currentBlock;
287
- }
288
-
289
- /**
290
- * Process an if/else-if/else chain.
291
- * Handles three patterns:
292
- * A) Wrapper: alternative → else_clause → nested if or block (JS/TS, Rust)
293
- * B) Siblings: elif/elsif/else_if as sibling children (Python, Ruby, PHP)
294
- * C) Direct: alternative → if_statement or block directly (Go, Java, C#)
295
- */
296
- function processIf(ifStmt, currentBlock) {
297
- // Terminate current block at condition
298
- currentBlock.endLine = ifStmt.startPosition.row + 1;
299
-
300
- const condBlock = makeBlock(
301
- 'condition',
302
- ifStmt.startPosition.row + 1,
303
- ifStmt.startPosition.row + 1,
304
- 'if',
305
- );
306
- addEdge(currentBlock, condBlock, 'fallthrough');
307
-
308
- const joinBlock = makeBlock('body');
309
-
310
- // True branch (consequent)
311
- const consequentField = rules.ifConsequentField || 'consequence';
312
- const consequent = ifStmt.childForFieldName(consequentField);
313
- const trueBlock = makeBlock('branch_true', null, null, 'then');
314
- addEdge(condBlock, trueBlock, 'branch_true');
315
- const trueStmts = getStatements(consequent);
316
- const trueEnd = processStatements(trueStmts, trueBlock);
317
- if (trueEnd) {
318
- addEdge(trueEnd, joinBlock, 'fallthrough');
319
- }
320
-
321
- // False branch — depends on language pattern
322
- if (rules.elifNode) {
323
- // Pattern B: elif/else as siblings of the if node
324
- processElifSiblings(ifStmt, condBlock, joinBlock);
325
- } else {
326
- const alternative = ifStmt.childForFieldName('alternative');
327
- if (alternative) {
328
- if (rules.elseViaAlternative && alternative.type !== rules.elseClause) {
329
- // Pattern C: alternative points directly to if or block
330
- if (alternative.type === rules.ifNode || rules.ifNodes?.has(alternative.type)) {
331
- // else-if: recurse
332
- const falseBlock = makeBlock('branch_false', null, null, 'else-if');
333
- addEdge(condBlock, falseBlock, 'branch_false');
334
- const elseIfEnd = processIf(alternative, falseBlock);
335
- if (elseIfEnd) {
336
- addEdge(elseIfEnd, joinBlock, 'fallthrough');
337
- }
338
- } else {
339
- // else block
340
- const falseBlock = makeBlock('branch_false', null, null, 'else');
341
- addEdge(condBlock, falseBlock, 'branch_false');
342
- const falseStmts = getStatements(alternative);
343
- const falseEnd = processStatements(falseStmts, falseBlock);
344
- if (falseEnd) {
345
- addEdge(falseEnd, joinBlock, 'fallthrough');
346
- }
347
- }
348
- } else if (alternative.type === rules.elseClause) {
349
- // Pattern A: else_clause wrapper — may contain another if (else-if) or a block
350
- const elseChildren = [];
351
- for (let i = 0; i < alternative.namedChildCount; i++) {
352
- elseChildren.push(alternative.namedChild(i));
353
- }
354
- if (
355
- elseChildren.length === 1 &&
356
- (elseChildren[0].type === rules.ifNode || rules.ifNodes?.has(elseChildren[0].type))
357
- ) {
358
- // else-if: recurse
359
- const falseBlock = makeBlock('branch_false', null, null, 'else-if');
360
- addEdge(condBlock, falseBlock, 'branch_false');
361
- const elseIfEnd = processIf(elseChildren[0], falseBlock);
362
- if (elseIfEnd) {
363
- addEdge(elseIfEnd, joinBlock, 'fallthrough');
364
- }
365
- } else {
366
- // else block
367
- const falseBlock = makeBlock('branch_false', null, null, 'else');
368
- addEdge(condBlock, falseBlock, 'branch_false');
369
- const falseEnd = processStatements(elseChildren, falseBlock);
370
- if (falseEnd) {
371
- addEdge(falseEnd, joinBlock, 'fallthrough');
372
- }
373
- }
374
- }
375
- } else {
376
- // No else: condition-false goes directly to join
377
- addEdge(condBlock, joinBlock, 'branch_false');
378
- }
379
- }
380
-
381
- return joinBlock;
382
- }
383
-
384
- /**
385
- * Handle Pattern B: elif/elsif/else_if as sibling children of the if node.
386
- */
387
- function processElifSiblings(ifStmt, firstCondBlock, joinBlock) {
388
- let lastCondBlock = firstCondBlock;
389
- let foundElse = false;
390
-
391
- for (let i = 0; i < ifStmt.namedChildCount; i++) {
392
- const child = ifStmt.namedChild(i);
393
-
394
- if (child.type === rules.elifNode) {
395
- // Create condition block for elif
396
- const elifCondBlock = makeBlock(
397
- 'condition',
398
- child.startPosition.row + 1,
399
- child.startPosition.row + 1,
400
- 'else-if',
401
- );
402
- addEdge(lastCondBlock, elifCondBlock, 'branch_false');
403
-
404
- // True branch of elif
405
- const elifConsequentField = rules.ifConsequentField || 'consequence';
406
- const elifConsequent = child.childForFieldName(elifConsequentField);
407
- const elifTrueBlock = makeBlock('branch_true', null, null, 'then');
408
- addEdge(elifCondBlock, elifTrueBlock, 'branch_true');
409
- const elifTrueStmts = getStatements(elifConsequent);
410
- const elifTrueEnd = processStatements(elifTrueStmts, elifTrueBlock);
411
- if (elifTrueEnd) {
412
- addEdge(elifTrueEnd, joinBlock, 'fallthrough');
413
- }
414
-
415
- lastCondBlock = elifCondBlock;
416
- } else if (child.type === rules.elseClause) {
417
- // Else body
418
- const elseBlock = makeBlock('branch_false', null, null, 'else');
419
- addEdge(lastCondBlock, elseBlock, 'branch_false');
420
-
421
- // Try field access first, then collect children
422
- const elseBody = child.childForFieldName('body');
423
- let elseStmts;
424
- if (elseBody) {
425
- elseStmts = getStatements(elseBody);
426
- } else {
427
- elseStmts = [];
428
- for (let j = 0; j < child.namedChildCount; j++) {
429
- elseStmts.push(child.namedChild(j));
430
- }
431
- }
432
- const elseEnd = processStatements(elseStmts, elseBlock);
433
- if (elseEnd) {
434
- addEdge(elseEnd, joinBlock, 'fallthrough');
435
- }
436
-
437
- foundElse = true;
438
- }
439
- }
440
-
441
- // If no else clause, last condition's false goes to join
442
- if (!foundElse) {
443
- addEdge(lastCondBlock, joinBlock, 'branch_false');
444
- }
445
- }
446
-
447
- /**
448
- * Process a for/for-in loop.
449
- */
450
- function processForLoop(forStmt, currentBlock) {
451
- const headerBlock = makeBlock(
452
- 'loop_header',
453
- forStmt.startPosition.row + 1,
454
- forStmt.startPosition.row + 1,
455
- 'for',
456
- );
457
- addEdge(currentBlock, headerBlock, 'fallthrough');
458
-
459
- const loopExitBlock = makeBlock('body');
460
-
461
- // Register loop context
462
- const loopCtx = { headerBlock, exitBlock: loopExitBlock };
463
- loopStack.push(loopCtx);
464
-
465
- // Update label map if this is inside a labeled statement
466
- for (const [, ctx] of labelMap) {
467
- if (!ctx.headerBlock) {
468
- ctx.headerBlock = headerBlock;
469
- ctx.exitBlock = loopExitBlock;
470
- }
471
- }
472
-
473
- // Loop body
474
- const body = forStmt.childForFieldName('body');
475
- const bodyBlock = makeBlock('loop_body');
476
- addEdge(headerBlock, bodyBlock, 'branch_true');
477
-
478
- const bodyStmts = getStatements(body);
479
- const bodyEnd = processStatements(bodyStmts, bodyBlock);
480
-
481
- if (bodyEnd) {
482
- addEdge(bodyEnd, headerBlock, 'loop_back');
483
- }
484
-
485
- // Loop exit
486
- addEdge(headerBlock, loopExitBlock, 'loop_exit');
487
-
488
- loopStack.pop();
489
- return loopExitBlock;
490
- }
491
-
492
- /**
493
- * Process a while loop.
494
- */
495
- function processWhileLoop(whileStmt, currentBlock) {
496
- const headerBlock = makeBlock(
497
- 'loop_header',
498
- whileStmt.startPosition.row + 1,
499
- whileStmt.startPosition.row + 1,
500
- 'while',
501
- );
502
- addEdge(currentBlock, headerBlock, 'fallthrough');
503
-
504
- const loopExitBlock = makeBlock('body');
505
-
506
- const loopCtx = { headerBlock, exitBlock: loopExitBlock };
507
- loopStack.push(loopCtx);
508
-
509
- for (const [, ctx] of labelMap) {
510
- if (!ctx.headerBlock) {
511
- ctx.headerBlock = headerBlock;
512
- ctx.exitBlock = loopExitBlock;
513
- }
514
- }
515
-
516
- const body = whileStmt.childForFieldName('body');
517
- const bodyBlock = makeBlock('loop_body');
518
- addEdge(headerBlock, bodyBlock, 'branch_true');
519
-
520
- const bodyStmts = getStatements(body);
521
- const bodyEnd = processStatements(bodyStmts, bodyBlock);
522
-
523
- if (bodyEnd) {
524
- addEdge(bodyEnd, headerBlock, 'loop_back');
525
- }
526
-
527
- addEdge(headerBlock, loopExitBlock, 'loop_exit');
528
-
529
- loopStack.pop();
530
- return loopExitBlock;
531
- }
532
-
533
- /**
534
- * Process a do-while loop.
535
- */
536
- function processDoWhileLoop(doStmt, currentBlock) {
537
- const bodyBlock = makeBlock('loop_body', doStmt.startPosition.row + 1, null, 'do');
538
- addEdge(currentBlock, bodyBlock, 'fallthrough');
539
-
540
- const condBlock = makeBlock('loop_header', null, null, 'do-while');
541
- const loopExitBlock = makeBlock('body');
542
-
543
- const loopCtx = { headerBlock: condBlock, exitBlock: loopExitBlock };
544
- loopStack.push(loopCtx);
545
-
546
- for (const [, ctx] of labelMap) {
547
- if (!ctx.headerBlock) {
548
- ctx.headerBlock = condBlock;
549
- ctx.exitBlock = loopExitBlock;
550
- }
551
- }
552
-
553
- const body = doStmt.childForFieldName('body');
554
- const bodyStmts = getStatements(body);
555
- const bodyEnd = processStatements(bodyStmts, bodyBlock);
556
-
557
- if (bodyEnd) {
558
- addEdge(bodyEnd, condBlock, 'fallthrough');
559
- }
560
-
561
- // Condition: loop_back or exit
562
- addEdge(condBlock, bodyBlock, 'loop_back');
563
- addEdge(condBlock, loopExitBlock, 'loop_exit');
564
-
565
- loopStack.pop();
566
- return loopExitBlock;
567
- }
568
-
569
- /**
570
- * Process an infinite loop (Rust's `loop {}`).
571
- * No condition — body always executes. Exit only via break.
572
- */
573
- function processInfiniteLoop(loopStmt, currentBlock) {
574
- const headerBlock = makeBlock(
575
- 'loop_header',
576
- loopStmt.startPosition.row + 1,
577
- loopStmt.startPosition.row + 1,
578
- 'loop',
579
- );
580
- addEdge(currentBlock, headerBlock, 'fallthrough');
581
-
582
- const loopExitBlock = makeBlock('body');
583
-
584
- const loopCtx = { headerBlock, exitBlock: loopExitBlock };
585
- loopStack.push(loopCtx);
586
-
587
- for (const [, ctx] of labelMap) {
588
- if (!ctx.headerBlock) {
589
- ctx.headerBlock = headerBlock;
590
- ctx.exitBlock = loopExitBlock;
591
- }
592
- }
593
-
594
- const body = loopStmt.childForFieldName('body');
595
- const bodyBlock = makeBlock('loop_body');
596
- addEdge(headerBlock, bodyBlock, 'branch_true');
597
-
598
- const bodyStmts = getStatements(body);
599
- const bodyEnd = processStatements(bodyStmts, bodyBlock);
600
-
601
- if (bodyEnd) {
602
- addEdge(bodyEnd, headerBlock, 'loop_back');
603
- }
604
-
605
- // No loop_exit from header — can only exit via break
606
-
607
- loopStack.pop();
608
- return loopExitBlock;
609
- }
610
-
611
- /**
612
- * Process a switch statement.
613
- */
614
- function processSwitch(switchStmt, currentBlock) {
615
- currentBlock.endLine = switchStmt.startPosition.row + 1;
616
-
617
- const switchHeader = makeBlock(
618
- 'condition',
619
- switchStmt.startPosition.row + 1,
620
- switchStmt.startPosition.row + 1,
621
- 'switch',
622
- );
623
- addEdge(currentBlock, switchHeader, 'fallthrough');
624
-
625
- const joinBlock = makeBlock('body');
626
-
627
- // Switch acts like a break target for contained break statements
628
- const switchCtx = { headerBlock: switchHeader, exitBlock: joinBlock };
629
- loopStack.push(switchCtx);
630
-
631
- // Get case children from body field or direct children
632
- const switchBody = switchStmt.childForFieldName('body');
633
- const container = switchBody || switchStmt;
634
-
635
- let hasDefault = false;
636
- for (let i = 0; i < container.namedChildCount; i++) {
637
- const caseClause = container.namedChild(i);
638
-
639
- const isDefault = caseClause.type === rules.defaultNode;
640
- const isCase =
641
- isDefault || caseClause.type === rules.caseNode || rules.caseNodes?.has(caseClause.type);
642
-
643
- if (!isCase) continue;
644
-
645
- const caseLabel = isDefault ? 'default' : 'case';
646
- const caseBlock = makeBlock('case', caseClause.startPosition.row + 1, null, caseLabel);
647
- addEdge(switchHeader, caseBlock, isDefault ? 'branch_false' : 'branch_true');
648
- if (isDefault) hasDefault = true;
649
-
650
- // Extract case body: try field access, then collect non-header children
651
- const caseBodyNode =
652
- caseClause.childForFieldName('body') || caseClause.childForFieldName('consequence');
653
- let caseStmts;
654
- if (caseBodyNode) {
655
- caseStmts = getStatements(caseBodyNode);
656
- } else {
657
- caseStmts = [];
658
- const valueNode = caseClause.childForFieldName('value');
659
- const patternNode = caseClause.childForFieldName('pattern');
660
- for (let j = 0; j < caseClause.namedChildCount; j++) {
661
- const child = caseClause.namedChild(j);
662
- if (child !== valueNode && child !== patternNode && child.type !== 'switch_label') {
663
- if (child.type === 'statement_list') {
664
- // Unwrap statement_list (tree-sitter-go 0.25+)
665
- for (let k = 0; k < child.namedChildCount; k++) {
666
- caseStmts.push(child.namedChild(k));
667
- }
668
- } else {
669
- caseStmts.push(child);
670
- }
671
- }
672
- }
673
- }
674
-
675
- const caseEnd = processStatements(caseStmts, caseBlock);
676
- if (caseEnd) {
677
- addEdge(caseEnd, joinBlock, 'fallthrough');
678
- }
679
- }
680
-
681
- // If no default case, switch header can skip to join
682
- if (!hasDefault) {
683
- addEdge(switchHeader, joinBlock, 'branch_false');
684
- }
685
-
686
- loopStack.pop();
687
- return joinBlock;
688
- }
689
-
690
- /**
691
- * Process try/catch/finally.
692
- */
693
- function processTryCatch(tryStmt, currentBlock) {
694
- currentBlock.endLine = tryStmt.startPosition.row + 1;
695
-
696
- const joinBlock = makeBlock('body');
697
-
698
- // Try body — field access or collect non-handler children (e.g., Ruby's begin)
699
- const tryBody = tryStmt.childForFieldName('body');
700
- let tryBodyStart;
701
- let tryStmts;
702
- if (tryBody) {
703
- tryBodyStart = tryBody.startPosition.row + 1;
704
- tryStmts = getStatements(tryBody);
705
- } else {
706
- tryBodyStart = tryStmt.startPosition.row + 1;
707
- tryStmts = [];
708
- for (let i = 0; i < tryStmt.namedChildCount; i++) {
709
- const child = tryStmt.namedChild(i);
710
- if (rules.catchNode && child.type === rules.catchNode) continue;
711
- if (rules.finallyNode && child.type === rules.finallyNode) continue;
712
- tryStmts.push(child);
713
- }
714
- }
715
-
716
- const tryBlock = makeBlock('body', tryBodyStart, null, 'try');
717
- addEdge(currentBlock, tryBlock, 'fallthrough');
718
- const tryEnd = processStatements(tryStmts, tryBlock);
719
-
720
- // Catch handler
721
- let catchHandler = null;
722
- let finallyHandler = null;
723
- for (let i = 0; i < tryStmt.namedChildCount; i++) {
724
- const child = tryStmt.namedChild(i);
725
- if (rules.catchNode && child.type === rules.catchNode) catchHandler = child;
726
- if (rules.finallyNode && child.type === rules.finallyNode) finallyHandler = child;
727
- }
728
-
729
- if (catchHandler) {
730
- const catchBlock = makeBlock('catch', catchHandler.startPosition.row + 1, null, 'catch');
731
- // Exception edge from try to catch
732
- addEdge(tryBlock, catchBlock, 'exception');
733
-
734
- // Catch body — try field access, then collect children
735
- const catchBodyNode = catchHandler.childForFieldName('body');
736
- let catchStmts;
737
- if (catchBodyNode) {
738
- catchStmts = getStatements(catchBodyNode);
739
- } else {
740
- catchStmts = [];
741
- for (let i = 0; i < catchHandler.namedChildCount; i++) {
742
- catchStmts.push(catchHandler.namedChild(i));
743
- }
744
- }
745
- const catchEnd = processStatements(catchStmts, catchBlock);
746
-
747
- if (finallyHandler) {
748
- const finallyBlock = makeBlock(
749
- 'finally',
750
- finallyHandler.startPosition.row + 1,
751
- null,
752
- 'finally',
753
- );
754
- if (tryEnd) addEdge(tryEnd, finallyBlock, 'fallthrough');
755
- if (catchEnd) addEdge(catchEnd, finallyBlock, 'fallthrough');
756
-
757
- const finallyBodyNode = finallyHandler.childForFieldName('body');
758
- const finallyStmts = finallyBodyNode
759
- ? getStatements(finallyBodyNode)
760
- : getStatements(finallyHandler);
761
- const finallyEnd = processStatements(finallyStmts, finallyBlock);
762
- if (finallyEnd) addEdge(finallyEnd, joinBlock, 'fallthrough');
763
- } else {
764
- if (tryEnd) addEdge(tryEnd, joinBlock, 'fallthrough');
765
- if (catchEnd) addEdge(catchEnd, joinBlock, 'fallthrough');
766
- }
767
- } else if (finallyHandler) {
768
- const finallyBlock = makeBlock(
769
- 'finally',
770
- finallyHandler.startPosition.row + 1,
771
- null,
772
- 'finally',
773
- );
774
- if (tryEnd) addEdge(tryEnd, finallyBlock, 'fallthrough');
775
-
776
- const finallyBodyNode = finallyHandler.childForFieldName('body');
777
- const finallyStmts = finallyBodyNode
778
- ? getStatements(finallyBodyNode)
779
- : getStatements(finallyHandler);
780
- const finallyEnd = processStatements(finallyStmts, finallyBlock);
781
- if (finallyEnd) addEdge(finallyEnd, joinBlock, 'fallthrough');
782
- } else {
783
- if (tryEnd) addEdge(tryEnd, joinBlock, 'fallthrough');
784
- }
785
-
786
- return joinBlock;
787
- }
788
-
789
- // ── Main entry point ──────────────────────────────────────────────────
790
-
791
- const body = getFunctionBody(functionNode);
792
- if (!body) {
793
- // Empty function or expression body
794
- addEdge(entryBlock, exitBlock, 'fallthrough');
795
- return { blocks, edges };
796
- }
797
-
798
- const stmts = getStatements(body);
799
- if (stmts.length === 0) {
800
- addEdge(entryBlock, exitBlock, 'fallthrough');
801
- return { blocks, edges };
802
- }
803
-
804
- const firstBlock = makeBlock('body');
805
- addEdge(entryBlock, firstBlock, 'fallthrough');
806
-
807
- const lastBlock = processStatements(stmts, firstBlock);
808
- if (lastBlock) {
809
- addEdge(lastBlock, exitBlock, 'fallthrough');
810
- }
811
-
812
- return { blocks, edges };
51
+ if (!rules) return { blocks: [], edges: [], cyclomatic: 0 };
52
+
53
+ const visitor = createCfgVisitor(rules);
54
+ const walkerOpts = {
55
+ functionNodeTypes: new Set(rules.functionNodes),
56
+ nestingNodeTypes: new Set(),
57
+ getFunctionName: (node) => {
58
+ const nameNode = node.childForFieldName('name');
59
+ return nameNode ? nameNode.text : null;
60
+ },
61
+ };
62
+
63
+ const results = walkWithVisitors(functionNode, [visitor], langId, walkerOpts);
64
+ const cfgResults = results.cfg || [];
65
+ if (cfgResults.length === 0) return { blocks: [], edges: [], cyclomatic: 0 };
66
+
67
+ const r = cfgResults.find((result) => result.funcNode === functionNode);
68
+ if (!r) return { blocks: [], edges: [], cyclomatic: 0 };
69
+ return { blocks: r.blocks, edges: r.edges, cyclomatic: r.cyclomatic };
813
70
  }
814
71
 
815
72
  // ─── Build-Time: Compute CFG for Changed Files ─────────────────────────
@@ -868,12 +125,6 @@ export async function buildCFGData(db, fileSymbols, rootDir, _engineOpts) {
868
125
  `INSERT INTO cfg_edges (function_node_id, source_block_id, target_block_id, kind)
869
126
  VALUES (?, ?, ?, ?)`,
870
127
  );
871
- const deleteBlocks = db.prepare('DELETE FROM cfg_blocks WHERE function_node_id = ?');
872
- const deleteEdges = db.prepare('DELETE FROM cfg_edges WHERE function_node_id = ?');
873
- const getNodeId = db.prepare(
874
- "SELECT id FROM nodes WHERE name = ? AND kind IN ('function','method') AND file = ? AND line = ?",
875
- );
876
-
877
128
  let analyzed = 0;
878
129
 
879
130
  const tx = db.transaction(() => {
@@ -921,39 +172,74 @@ export async function buildCFGData(db, fileSymbols, rootDir, _engineOpts) {
921
172
  const cfgRules = CFG_RULES.get(langId);
922
173
  if (!cfgRules) continue;
923
174
 
924
- const complexityRules = COMPLEXITY_RULES.get(langId);
925
- // complexityRules only needed for WASM fallback path
175
+ // WASM fallback: run file-level visitor walk to compute CFG for all functions
176
+ // that don't already have pre-computed data (from native engine or unified walk)
177
+ let visitorCfgByLine = null;
178
+ const needsVisitor =
179
+ tree &&
180
+ symbols.definitions.some(
181
+ (d) =>
182
+ (d.kind === 'function' || d.kind === 'method') &&
183
+ d.line &&
184
+ d.cfg !== null &&
185
+ !d.cfg?.blocks?.length,
186
+ );
187
+ if (needsVisitor) {
188
+ const visitor = createCfgVisitor(cfgRules);
189
+ const walkerOpts = {
190
+ functionNodeTypes: new Set(cfgRules.functionNodes),
191
+ nestingNodeTypes: new Set(),
192
+ getFunctionName: (node) => {
193
+ const nameNode = node.childForFieldName('name');
194
+ return nameNode ? nameNode.text : null;
195
+ },
196
+ };
197
+ const walkResults = walkWithVisitors(tree.rootNode, [visitor], langId, walkerOpts);
198
+ const cfgResults = walkResults.cfg || [];
199
+ visitorCfgByLine = new Map();
200
+ for (const r of cfgResults) {
201
+ if (r.funcNode) {
202
+ const line = r.funcNode.startPosition.row + 1;
203
+ if (!visitorCfgByLine.has(line)) visitorCfgByLine.set(line, []);
204
+ visitorCfgByLine.get(line).push(r);
205
+ }
206
+ }
207
+ }
926
208
 
927
209
  for (const def of symbols.definitions) {
928
210
  if (def.kind !== 'function' && def.kind !== 'method') continue;
929
211
  if (!def.line) continue;
930
212
 
931
- const row = getNodeId.get(def.name, relPath, def.line);
932
- if (!row) continue;
213
+ const nodeId = getFunctionNodeId(db, def.name, relPath, def.line);
214
+ if (!nodeId) continue;
933
215
 
934
- // Native path: use pre-computed CFG from Rust engine
216
+ // Use pre-computed CFG (native engine or unified walk), then visitor fallback
935
217
  let cfg = null;
936
218
  if (def.cfg?.blocks?.length) {
937
219
  cfg = def.cfg;
938
- } else {
939
- // WASM fallback: compute CFG from tree-sitter AST
940
- if (!tree || !complexityRules) continue;
941
- const funcNode = findFunctionNode(tree.rootNode, def.line, def.endLine, complexityRules);
942
- if (!funcNode) continue;
943
- cfg = buildFunctionCFG(funcNode, langId);
220
+ } else if (visitorCfgByLine) {
221
+ const candidates = visitorCfgByLine.get(def.line);
222
+ const r = !candidates
223
+ ? undefined
224
+ : candidates.length === 1
225
+ ? candidates[0]
226
+ : (candidates.find((c) => {
227
+ const n = c.funcNode.childForFieldName('name');
228
+ return n && n.text === def.name;
229
+ }) ?? candidates[0]);
230
+ if (r) cfg = { blocks: r.blocks, edges: r.edges };
944
231
  }
945
232
 
946
233
  if (!cfg || cfg.blocks.length === 0) continue;
947
234
 
948
235
  // Clear old CFG data for this function
949
- deleteEdges.run(row.id);
950
- deleteBlocks.run(row.id);
236
+ deleteCfgForNode(db, nodeId);
951
237
 
952
238
  // Insert blocks and build index→dbId mapping
953
239
  const blockDbIds = new Map();
954
240
  for (const block of cfg.blocks) {
955
241
  const result = insertBlock.run(
956
- row.id,
242
+ nodeId,
957
243
  block.index,
958
244
  block.type,
959
245
  block.startLine,
@@ -968,7 +254,7 @@ export async function buildCFGData(db, fileSymbols, rootDir, _engineOpts) {
968
254
  const sourceDbId = blockDbIds.get(edge.sourceIndex);
969
255
  const targetDbId = blockDbIds.get(edge.targetIndex);
970
256
  if (sourceDbId && targetDbId) {
971
- insertEdge.run(row.id, sourceDbId, targetDbId, edge.kind);
257
+ insertEdge.run(nodeId, sourceDbId, targetDbId, edge.kind);
972
258
  }
973
259
  }
974
260
 
@@ -988,15 +274,6 @@ export async function buildCFGData(db, fileSymbols, rootDir, _engineOpts) {
988
274
 
989
275
  // ─── Query-Time Functions ───────────────────────────────────────────────
990
276
 
991
- function hasCfgTables(db) {
992
- try {
993
- db.prepare('SELECT 1 FROM cfg_blocks LIMIT 0').get();
994
- return true;
995
- } catch {
996
- return false;
997
- }
998
- }
999
-
1000
277
  function findNodes(db, name, opts = {}) {
1001
278
  const kinds = opts.kind ? [opts.kind] : ['function', 'method'];
1002
279
  const placeholders = kinds.map(() => '?').join(', ');
@@ -1046,25 +323,9 @@ export function cfgData(name, customDbPath, opts = {}) {
1046
323
  return { name, results: [] };
1047
324
  }
1048
325
 
1049
- const blockStmt = db.prepare(
1050
- `SELECT id, block_index, block_type, start_line, end_line, label
1051
- FROM cfg_blocks WHERE function_node_id = ?
1052
- ORDER BY block_index`,
1053
- );
1054
- const edgeStmt = db.prepare(
1055
- `SELECT e.kind,
1056
- sb.block_index AS source_index, sb.block_type AS source_type,
1057
- tb.block_index AS target_index, tb.block_type AS target_type
1058
- FROM cfg_edges e
1059
- JOIN cfg_blocks sb ON e.source_block_id = sb.id
1060
- JOIN cfg_blocks tb ON e.target_block_id = tb.id
1061
- WHERE e.function_node_id = ?
1062
- ORDER BY sb.block_index, tb.block_index`,
1063
- );
1064
-
1065
326
  const results = nodes.map((node) => {
1066
- const cfgBlocks = blockStmt.all(node.id);
1067
- const cfgEdges = edgeStmt.all(node.id);
327
+ const cfgBlocks = getCfgBlocks(db, node.id);
328
+ const cfgEdges = getCfgEdges(db, node.id);
1068
329
 
1069
330
  return {
1070
331
  name: node.name,
@@ -1184,58 +445,3 @@ function edgeStyle(kind) {
1184
445
  if (kind === 'continue') return ', color=blue, style=dashed';
1185
446
  return '';
1186
447
  }
1187
-
1188
- // ─── CLI Printer ────────────────────────────────────────────────────────
1189
-
1190
- /**
1191
- * CLI display for cfg command.
1192
- */
1193
- export function cfg(name, customDbPath, opts = {}) {
1194
- const data = cfgData(name, customDbPath, opts);
1195
-
1196
- if (outputResult(data, 'results', opts)) return;
1197
-
1198
- if (data.warning) {
1199
- console.log(`\u26A0 ${data.warning}`);
1200
- return;
1201
- }
1202
- if (data.results.length === 0) {
1203
- console.log(`No symbols matching "${name}".`);
1204
- return;
1205
- }
1206
-
1207
- const format = opts.format || 'text';
1208
- if (format === 'dot') {
1209
- console.log(cfgToDOT(data));
1210
- return;
1211
- }
1212
- if (format === 'mermaid') {
1213
- console.log(cfgToMermaid(data));
1214
- return;
1215
- }
1216
-
1217
- // Text format
1218
- for (const r of data.results) {
1219
- console.log(`\n${r.kind} ${r.name} (${r.file}:${r.line})`);
1220
- console.log('\u2500'.repeat(60));
1221
- console.log(` Blocks: ${r.summary.blockCount} Edges: ${r.summary.edgeCount}`);
1222
-
1223
- if (r.blocks.length > 0) {
1224
- console.log('\n Blocks:');
1225
- for (const b of r.blocks) {
1226
- const loc = b.startLine
1227
- ? ` L${b.startLine}${b.endLine && b.endLine !== b.startLine ? `-${b.endLine}` : ''}`
1228
- : '';
1229
- const label = b.label ? ` (${b.label})` : '';
1230
- console.log(` [${b.index}] ${b.type}${label}${loc}`);
1231
- }
1232
- }
1233
-
1234
- if (r.edges.length > 0) {
1235
- console.log('\n Edges:');
1236
- for (const e of r.edges) {
1237
- console.log(` B${e.source} \u2192 B${e.target} [${e.kind}]`);
1238
- }
1239
- }
1240
- }
1241
- }