From 98aab2e80ad5e349159fb329a177dcfc3a70c74f Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Mon, 2 Mar 2026 23:02:41 -0700 Subject: [PATCH 1/2] feat: extend CFG to all supported languages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add control flow graph support for Python, Go, Rust, Java, C#, Ruby, and PHP. The core algorithm was already language-agnostic; this adds per-language CFG_RULES mapping tree-sitter node types and refactors processIf (3 else-if patterns), getStatements (blockNodes set), processStatement (expression_statement unwrapping, unless/until), and processSwitch/processTryCatch for cross-language flexibility. Adds processInfiniteLoop for Rust's `loop {}`. Also fixes C# language ID mismatch (`c_sharp` → `csharp`) in COMPLEXITY_RULES, HALSTEAD_RULES, and COMMENT_PREFIXES, and corrects `for_each_statement` → `foreach_statement` to match tree-sitter. Impact: 8 functions changed, 11 affected --- src/cfg.js | 642 ++++++++++++++++++++++++---- src/complexity.js | 14 +- tests/unit/cfg.test.js | 771 ++++++++++++++++++++++++++++++++++ tests/unit/complexity.test.js | 6 +- 4 files changed, 1340 insertions(+), 93 deletions(-) diff --git a/src/cfg.js b/src/cfg.js index c9f7dd0f..4aad6210 100644 --- a/src/cfg.js +++ b/src/cfg.js @@ -2,7 +2,7 @@ * Intraprocedural Control Flow Graph (CFG) construction from tree-sitter AST. * * Builds basic-block CFGs for individual functions, stored in cfg_blocks + cfg_edges tables. - * Opt-in via `build --cfg`. JS/TS/TSX only for Phase 1. + * Opt-in via `build --cfg`. Supports JS/TS/TSX, Python, Go, Rust, Java, C#, Ruby, PHP. */ import fs from 'node:fs'; @@ -18,12 +18,22 @@ import { isTestFile } from './queries.js'; const JS_TS_CFG = { ifNode: 'if_statement', + ifNodes: null, + elifNode: null, elseClause: 'else_clause', + elseViaAlternative: false, + ifConsequentField: null, forNodes: new Set(['for_statement', 'for_in_statement']), whileNode: 'while_statement', + whileNodes: null, doNode: 'do_statement', + infiniteLoopNode: null, + unlessNode: null, + untilNode: null, switchNode: 'switch_statement', + switchNodes: null, caseNode: 'switch_case', + caseNodes: null, defaultNode: 'switch_default', tryNode: 'try_statement', catchNode: 'catch_clause', @@ -33,6 +43,7 @@ const JS_TS_CFG = { breakNode: 'break_statement', continueNode: 'continue_statement', blockNode: 'statement_block', + blockNodes: null, labeledNode: 'labeled_statement', functionNodes: new Set([ 'function_declaration', @@ -44,14 +55,269 @@ const JS_TS_CFG = { ]), }; +const PYTHON_CFG = { + ifNode: 'if_statement', + ifNodes: null, + elifNode: 'elif_clause', + elseClause: 'else_clause', + elseViaAlternative: false, + ifConsequentField: null, + forNodes: new Set(['for_statement']), + whileNode: 'while_statement', + whileNodes: null, + doNode: null, + infiniteLoopNode: null, + unlessNode: null, + untilNode: null, + switchNode: 'match_statement', + switchNodes: null, + caseNode: 'case_clause', + caseNodes: null, + defaultNode: null, + tryNode: 'try_statement', + catchNode: 'except_clause', + finallyNode: 'finally_clause', + returnNode: 'return_statement', + throwNode: 'raise_statement', + breakNode: 'break_statement', + continueNode: 'continue_statement', + blockNode: 'block', + blockNodes: null, + labeledNode: null, + functionNodes: new Set(['function_definition']), +}; + +const GO_CFG = { + ifNode: 'if_statement', + ifNodes: null, + elifNode: null, + elseClause: null, + elseViaAlternative: true, + ifConsequentField: null, + forNodes: new Set(['for_statement']), + whileNode: null, + whileNodes: null, + doNode: null, + infiniteLoopNode: null, + unlessNode: null, + untilNode: null, + switchNode: null, + switchNodes: new Set([ + 'expression_switch_statement', + 'type_switch_statement', + 'select_statement', + ]), + caseNode: 'expression_case', + caseNodes: new Set(['type_case', 'communication_case']), + defaultNode: 'default_case', + tryNode: null, + catchNode: null, + finallyNode: null, + returnNode: 'return_statement', + throwNode: null, + breakNode: 'break_statement', + continueNode: 'continue_statement', + blockNode: 'block', + blockNodes: null, + labeledNode: 'labeled_statement', + functionNodes: new Set(['function_declaration', 'method_declaration', 'func_literal']), +}; + +const RUST_CFG = { + ifNode: 'if_expression', + ifNodes: new Set(['if_let_expression']), + elifNode: null, + elseClause: 'else_clause', + elseViaAlternative: false, + ifConsequentField: null, + forNodes: new Set(['for_expression']), + whileNode: 'while_expression', + whileNodes: new Set(['while_let_expression']), + doNode: null, + infiniteLoopNode: 'loop_expression', + unlessNode: null, + untilNode: null, + switchNode: 'match_expression', + switchNodes: null, + caseNode: 'match_arm', + caseNodes: null, + defaultNode: null, + tryNode: null, + catchNode: null, + finallyNode: null, + returnNode: 'return_expression', + throwNode: null, + breakNode: 'break_expression', + continueNode: 'continue_expression', + blockNode: 'block', + blockNodes: null, + labeledNode: null, + functionNodes: new Set(['function_item', 'closure_expression']), +}; + +const JAVA_CFG = { + ifNode: 'if_statement', + ifNodes: null, + elifNode: null, + elseClause: null, + elseViaAlternative: true, + ifConsequentField: null, + forNodes: new Set(['for_statement', 'enhanced_for_statement']), + whileNode: 'while_statement', + whileNodes: null, + doNode: 'do_statement', + infiniteLoopNode: null, + unlessNode: null, + untilNode: null, + switchNode: 'switch_expression', + switchNodes: null, + caseNode: 'switch_block_statement_group', + caseNodes: new Set(['switch_rule']), + defaultNode: null, + tryNode: 'try_statement', + catchNode: 'catch_clause', + finallyNode: 'finally_clause', + returnNode: 'return_statement', + throwNode: 'throw_statement', + breakNode: 'break_statement', + continueNode: 'continue_statement', + blockNode: 'block', + blockNodes: null, + labeledNode: 'labeled_statement', + functionNodes: new Set(['method_declaration', 'constructor_declaration', 'lambda_expression']), +}; + +const CSHARP_CFG = { + ifNode: 'if_statement', + ifNodes: null, + elifNode: null, + elseClause: null, + elseViaAlternative: true, + ifConsequentField: null, + forNodes: new Set(['for_statement', 'foreach_statement']), + whileNode: 'while_statement', + whileNodes: null, + doNode: 'do_statement', + infiniteLoopNode: null, + unlessNode: null, + untilNode: null, + switchNode: 'switch_statement', + switchNodes: null, + caseNode: 'switch_section', + caseNodes: null, + defaultNode: null, + tryNode: 'try_statement', + catchNode: 'catch_clause', + finallyNode: 'finally_clause', + returnNode: 'return_statement', + throwNode: 'throw_statement', + breakNode: 'break_statement', + continueNode: 'continue_statement', + blockNode: 'block', + blockNodes: null, + labeledNode: 'labeled_statement', + functionNodes: new Set([ + 'method_declaration', + 'constructor_declaration', + 'lambda_expression', + 'local_function_statement', + ]), +}; + +const RUBY_CFG = { + ifNode: 'if', + ifNodes: null, + elifNode: 'elsif', + elseClause: 'else', + elseViaAlternative: false, + ifConsequentField: null, + forNodes: new Set(['for']), + whileNode: 'while', + whileNodes: null, + doNode: null, + infiniteLoopNode: null, + unlessNode: 'unless', + untilNode: 'until', + switchNode: 'case', + switchNodes: null, + caseNode: 'when', + caseNodes: null, + defaultNode: 'else', + tryNode: 'begin', + catchNode: 'rescue', + finallyNode: 'ensure', + returnNode: 'return', + throwNode: null, + breakNode: 'break', + continueNode: 'next', + blockNode: null, + blockNodes: new Set(['then', 'do', 'body_statement']), + labeledNode: null, + functionNodes: new Set(['method', 'singleton_method']), +}; + +const PHP_CFG = { + ifNode: 'if_statement', + ifNodes: null, + elifNode: 'else_if_clause', + elseClause: 'else_clause', + elseViaAlternative: false, + ifConsequentField: 'body', + forNodes: new Set(['for_statement', 'foreach_statement']), + whileNode: 'while_statement', + whileNodes: null, + doNode: 'do_statement', + infiniteLoopNode: null, + unlessNode: null, + untilNode: null, + switchNode: 'switch_statement', + switchNodes: null, + caseNode: 'case_statement', + caseNodes: null, + defaultNode: 'default_statement', + tryNode: 'try_statement', + catchNode: 'catch_clause', + finallyNode: 'finally_clause', + returnNode: 'return_statement', + throwNode: 'throw_expression', + breakNode: 'break_statement', + continueNode: 'continue_statement', + blockNode: 'compound_statement', + blockNodes: null, + labeledNode: null, + functionNodes: new Set([ + 'function_definition', + 'method_declaration', + 'anonymous_function_creation_expression', + 'arrow_function', + ]), +}; + export const CFG_RULES = new Map([ ['javascript', JS_TS_CFG], ['typescript', JS_TS_CFG], ['tsx', JS_TS_CFG], + ['python', PYTHON_CFG], + ['go', GO_CFG], + ['rust', RUST_CFG], + ['java', JAVA_CFG], + ['csharp', CSHARP_CFG], + ['ruby', RUBY_CFG], + ['php', PHP_CFG], ]); -// Language IDs that support CFG (Phase 1: JS/TS/TSX only) -const CFG_LANG_IDS = new Set(['javascript', 'typescript', 'tsx']); +const CFG_LANG_IDS = new Set([ + 'javascript', + 'typescript', + 'tsx', + 'python', + 'go', + 'rust', + 'java', + 'csharp', + 'ruby', + 'php', +]); // JS/TS extensions const CFG_EXTENSIONS = new Set(); @@ -121,8 +387,8 @@ export function buildFunctionCFG(functionNode, langId) { */ function getStatements(node) { if (!node) return []; - // statement_block: get named children - if (node.type === rules.blockNode) { + // Block-like nodes: extract named children + if (node.type === rules.blockNode || rules.blockNodes?.has(node.type)) { const stmts = []; for (let i = 0; i < node.namedChildCount; i++) { stmts.push(node.namedChild(i)); @@ -157,6 +423,31 @@ export function buildFunctionCFG(functionNode, langId) { function processStatement(stmt, currentBlock) { if (!stmt || !currentBlock) return currentBlock; + // Unwrap expression_statement (Rust uses expressions for control flow) + if (stmt.type === 'expression_statement' && stmt.namedChildCount === 1) { + const inner = stmt.namedChild(0); + const t = inner.type; + if ( + t === rules.ifNode || + rules.ifNodes?.has(t) || + rules.forNodes?.has(t) || + t === rules.whileNode || + rules.whileNodes?.has(t) || + t === rules.doNode || + t === rules.infiniteLoopNode || + t === rules.switchNode || + rules.switchNodes?.has(t) || + t === rules.returnNode || + t === rules.throwNode || + t === rules.breakNode || + t === rules.continueNode || + t === rules.unlessNode || + t === rules.untilNode + ) { + return processStatement(inner, currentBlock); + } + } + const type = stmt.type; // Labeled statement: register label then process inner statement @@ -175,8 +466,13 @@ export function buildFunctionCFG(functionNode, langId) { return currentBlock; } - // If statement - if (type === rules.ifNode) { + // If statement (including language variants like if_let_expression) + if (type === rules.ifNode || rules.ifNodes?.has(type)) { + return processIf(stmt, currentBlock); + } + + // Unless (Ruby) — same CFG shape as if + if (rules.unlessNode && type === rules.unlessNode) { return processIf(stmt, currentBlock); } @@ -185,23 +481,33 @@ export function buildFunctionCFG(functionNode, langId) { return processForLoop(stmt, currentBlock); } - // While loop - if (type === rules.whileNode) { + // While loop (including language variants like while_let_expression) + if (type === rules.whileNode || rules.whileNodes?.has(type)) { + return processWhileLoop(stmt, currentBlock); + } + + // Until (Ruby) — same CFG shape as while + if (rules.untilNode && type === rules.untilNode) { return processWhileLoop(stmt, currentBlock); } // Do-while loop - if (type === rules.doNode) { + if (rules.doNode && type === rules.doNode) { return processDoWhileLoop(stmt, currentBlock); } - // Switch statement - if (type === rules.switchNode) { + // Infinite loop (Rust's loop {}) + if (rules.infiniteLoopNode && type === rules.infiniteLoopNode) { + return processInfiniteLoop(stmt, currentBlock); + } + + // Switch / match statement + if (type === rules.switchNode || rules.switchNodes?.has(type)) { return processSwitch(stmt, currentBlock); } // Try/catch/finally - if (type === rules.tryNode) { + if (rules.tryNode && type === rules.tryNode) { return processTryCatch(stmt, currentBlock); } @@ -270,6 +576,10 @@ export function buildFunctionCFG(functionNode, langId) { /** * Process an if/else-if/else chain. + * Handles three patterns: + * A) Wrapper: alternative → else_clause → nested if or block (JS/TS, C#, Rust) + * B) Siblings: elif/elsif/else_if as sibling children (Python, Ruby, PHP) + * C) Direct: alternative → if_statement or block directly (Go, Java) */ function processIf(ifStmt, currentBlock) { // Terminate current block at condition @@ -286,7 +596,8 @@ export function buildFunctionCFG(functionNode, langId) { const joinBlock = makeBlock('body'); // True branch (consequent) - const consequent = ifStmt.childForFieldName('consequence'); + const consequentField = rules.ifConsequentField || 'consequence'; + const consequent = ifStmt.childForFieldName(consequentField); const trueBlock = makeBlock('branch_true', null, null, 'then'); addEdge(condBlock, trueBlock, 'branch_true'); const trueStmts = getStatements(consequent); @@ -295,41 +606,132 @@ export function buildFunctionCFG(functionNode, langId) { addEdge(trueEnd, joinBlock, 'fallthrough'); } - // False branch (alternative / else / else-if) - const alternative = ifStmt.childForFieldName('alternative'); - if (alternative) { - if (alternative.type === rules.elseClause) { - // else clause — may contain another if (else-if) or a block - const elseChildren = []; - for (let i = 0; i < alternative.namedChildCount; i++) { - elseChildren.push(alternative.namedChild(i)); - } - if (elseChildren.length === 1 && elseChildren[0].type === rules.ifNode) { - // else-if: recurse - const falseBlock = makeBlock('branch_false', null, null, 'else-if'); - addEdge(condBlock, falseBlock, 'branch_false'); - const elseIfEnd = processIf(elseChildren[0], falseBlock); - if (elseIfEnd) { - addEdge(elseIfEnd, joinBlock, 'fallthrough'); + // False branch — depends on language pattern + if (rules.elifNode) { + // Pattern B: elif/else as siblings of the if node + processElifSiblings(ifStmt, condBlock, joinBlock); + } else { + const alternative = ifStmt.childForFieldName('alternative'); + if (alternative) { + if (rules.elseViaAlternative && alternative.type !== rules.elseClause) { + // Pattern C: alternative points directly to if or block + if (alternative.type === rules.ifNode || rules.ifNodes?.has(alternative.type)) { + // else-if: recurse + const falseBlock = makeBlock('branch_false', null, null, 'else-if'); + addEdge(condBlock, falseBlock, 'branch_false'); + const elseIfEnd = processIf(alternative, falseBlock); + if (elseIfEnd) { + addEdge(elseIfEnd, joinBlock, 'fallthrough'); + } + } else { + // else block + const falseBlock = makeBlock('branch_false', null, null, 'else'); + addEdge(condBlock, falseBlock, 'branch_false'); + const falseStmts = getStatements(alternative); + const falseEnd = processStatements(falseStmts, falseBlock); + if (falseEnd) { + addEdge(falseEnd, joinBlock, 'fallthrough'); + } } - } else { - // else block - const falseBlock = makeBlock('branch_false', null, null, 'else'); - addEdge(condBlock, falseBlock, 'branch_false'); - const falseEnd = processStatements(elseChildren, falseBlock); - if (falseEnd) { - addEdge(falseEnd, joinBlock, 'fallthrough'); + } else if (alternative.type === rules.elseClause) { + // Pattern A: else_clause wrapper — may contain another if (else-if) or a block + const elseChildren = []; + for (let i = 0; i < alternative.namedChildCount; i++) { + elseChildren.push(alternative.namedChild(i)); + } + if ( + elseChildren.length === 1 && + (elseChildren[0].type === rules.ifNode || rules.ifNodes?.has(elseChildren[0].type)) + ) { + // else-if: recurse + const falseBlock = makeBlock('branch_false', null, null, 'else-if'); + addEdge(condBlock, falseBlock, 'branch_false'); + const elseIfEnd = processIf(elseChildren[0], falseBlock); + if (elseIfEnd) { + addEdge(elseIfEnd, joinBlock, 'fallthrough'); + } + } else { + // else block + const falseBlock = makeBlock('branch_false', null, null, 'else'); + addEdge(condBlock, falseBlock, 'branch_false'); + const falseEnd = processStatements(elseChildren, falseBlock); + if (falseEnd) { + addEdge(falseEnd, joinBlock, 'fallthrough'); + } } } + } else { + // No else: condition-false goes directly to join + addEdge(condBlock, joinBlock, 'branch_false'); } - } else { - // No else: condition-false goes directly to join - addEdge(condBlock, joinBlock, 'branch_false'); } return joinBlock; } + /** + * Handle Pattern B: elif/elsif/else_if as sibling children of the if node. + */ + function processElifSiblings(ifStmt, firstCondBlock, joinBlock) { + let lastCondBlock = firstCondBlock; + let foundElse = false; + + for (let i = 0; i < ifStmt.namedChildCount; i++) { + const child = ifStmt.namedChild(i); + + if (child.type === rules.elifNode) { + // Create condition block for elif + const elifCondBlock = makeBlock( + 'condition', + child.startPosition.row + 1, + child.startPosition.row + 1, + 'else-if', + ); + addEdge(lastCondBlock, elifCondBlock, 'branch_false'); + + // True branch of elif + const elifConsequentField = rules.ifConsequentField || 'consequence'; + const elifConsequent = child.childForFieldName(elifConsequentField); + const elifTrueBlock = makeBlock('branch_true', null, null, 'then'); + addEdge(elifCondBlock, elifTrueBlock, 'branch_true'); + const elifTrueStmts = getStatements(elifConsequent); + const elifTrueEnd = processStatements(elifTrueStmts, elifTrueBlock); + if (elifTrueEnd) { + addEdge(elifTrueEnd, joinBlock, 'fallthrough'); + } + + lastCondBlock = elifCondBlock; + } else if (child.type === rules.elseClause) { + // Else body + const elseBlock = makeBlock('branch_false', null, null, 'else'); + addEdge(lastCondBlock, elseBlock, 'branch_false'); + + // Try field access first, then collect children + const elseBody = child.childForFieldName('body'); + let elseStmts; + if (elseBody) { + elseStmts = getStatements(elseBody); + } else { + elseStmts = []; + for (let j = 0; j < child.namedChildCount; j++) { + elseStmts.push(child.namedChild(j)); + } + } + const elseEnd = processStatements(elseStmts, elseBlock); + if (elseEnd) { + addEdge(elseEnd, joinBlock, 'fallthrough'); + } + + foundElse = true; + } + } + + // If no else clause, last condition's false goes to join + if (!foundElse) { + addEdge(lastCondBlock, joinBlock, 'branch_false'); + } + } + /** * Process a for/for-in loop. */ @@ -452,6 +854,48 @@ export function buildFunctionCFG(functionNode, langId) { return loopExitBlock; } + /** + * Process an infinite loop (Rust's `loop {}`). + * No condition — body always executes. Exit only via break. + */ + function processInfiniteLoop(loopStmt, currentBlock) { + const headerBlock = makeBlock( + 'loop_header', + loopStmt.startPosition.row + 1, + loopStmt.startPosition.row + 1, + 'loop', + ); + addEdge(currentBlock, headerBlock, 'fallthrough'); + + const loopExitBlock = makeBlock('body'); + + const loopCtx = { headerBlock, exitBlock: loopExitBlock }; + loopStack.push(loopCtx); + + for (const [, ctx] of labelMap) { + if (!ctx.headerBlock) { + ctx.headerBlock = headerBlock; + ctx.exitBlock = loopExitBlock; + } + } + + const body = loopStmt.childForFieldName('body'); + const bodyBlock = makeBlock('loop_body'); + addEdge(headerBlock, bodyBlock, 'branch_true'); + + const bodyStmts = getStatements(body); + const bodyEnd = processStatements(bodyStmts, bodyBlock); + + if (bodyEnd) { + addEdge(bodyEnd, headerBlock, 'loop_back'); + } + + // No loop_exit from header — can only exit via break + + loopStack.pop(); + return loopExitBlock; + } + /** * Process a switch statement. */ @@ -472,49 +916,54 @@ export function buildFunctionCFG(functionNode, langId) { const switchCtx = { headerBlock: switchHeader, exitBlock: joinBlock }; loopStack.push(switchCtx); - // Collect case clauses from the switch body + // Get case children from body field or direct children const switchBody = switchStmt.childForFieldName('body'); - if (switchBody) { - let hasDefault = false; - for (let i = 0; i < switchBody.namedChildCount; i++) { - const caseClause = switchBody.namedChild(i); - const isDefault = - caseClause.type === rules.defaultNode || - (caseClause.type === rules.caseNode && !caseClause.childForFieldName('value')); - - const caseLabel = isDefault ? 'default' : 'case'; - const caseBlock = makeBlock( - isDefault ? 'case' : 'case', - caseClause.startPosition.row + 1, - null, - caseLabel, - ); - addEdge(switchHeader, caseBlock, isDefault ? 'branch_false' : 'branch_true'); - if (isDefault) hasDefault = true; + const container = switchBody || switchStmt; + + let hasDefault = false; + for (let i = 0; i < container.namedChildCount; i++) { + const caseClause = container.namedChild(i); + + const isDefault = caseClause.type === rules.defaultNode; + const isCase = + isDefault || caseClause.type === rules.caseNode || rules.caseNodes?.has(caseClause.type); + + if (!isCase) continue; - // Process case body statements - const caseStmts = []; + const caseLabel = isDefault ? 'default' : 'case'; + const caseBlock = makeBlock('case', caseClause.startPosition.row + 1, null, caseLabel); + addEdge(switchHeader, caseBlock, isDefault ? 'branch_false' : 'branch_true'); + if (isDefault) hasDefault = true; + + // Extract case body: try field access, then collect non-header children + const caseBodyNode = + caseClause.childForFieldName('body') || caseClause.childForFieldName('consequence'); + let caseStmts; + if (caseBodyNode) { + caseStmts = getStatements(caseBodyNode); + } else { + caseStmts = []; + const valueNode = caseClause.childForFieldName('value'); + const patternNode = caseClause.childForFieldName('pattern'); for (let j = 0; j < caseClause.namedChildCount; j++) { const child = caseClause.namedChild(j); - // Skip the case value expression - if (child.type !== 'identifier' && child.type !== 'string' && child.type !== 'number') { + if (child !== valueNode && child !== patternNode && child.type !== 'switch_label') { caseStmts.push(child); } } - - const caseEnd = processStatements(caseStmts, caseBlock); - if (caseEnd) { - // Fall-through to join (or next case, but we simplify to join) - addEdge(caseEnd, joinBlock, 'fallthrough'); - } } - // If no default case, switch header can skip to join - if (!hasDefault) { - addEdge(switchHeader, joinBlock, 'branch_false'); + const caseEnd = processStatements(caseStmts, caseBlock); + if (caseEnd) { + addEdge(caseEnd, joinBlock, 'fallthrough'); } } + // If no default case, switch header can skip to join + if (!hasDefault) { + addEdge(switchHeader, joinBlock, 'branch_false'); + } + loopStack.pop(); return joinBlock; } @@ -527,12 +976,26 @@ export function buildFunctionCFG(functionNode, langId) { const joinBlock = makeBlock('body'); - // Try body + // Try body — field access or collect non-handler children (e.g., Ruby's begin) const tryBody = tryStmt.childForFieldName('body'); - const tryBlock = makeBlock('body', tryBody ? tryBody.startPosition.row + 1 : null, null, 'try'); - addEdge(currentBlock, tryBlock, 'fallthrough'); + let tryBodyStart; + let tryStmts; + if (tryBody) { + tryBodyStart = tryBody.startPosition.row + 1; + tryStmts = getStatements(tryBody); + } else { + tryBodyStart = tryStmt.startPosition.row + 1; + tryStmts = []; + for (let i = 0; i < tryStmt.namedChildCount; i++) { + const child = tryStmt.namedChild(i); + if (rules.catchNode && child.type === rules.catchNode) continue; + if (rules.finallyNode && child.type === rules.finallyNode) continue; + tryStmts.push(child); + } + } - const tryStmts = getStatements(tryBody); + const tryBlock = makeBlock('body', tryBodyStart, null, 'try'); + addEdge(currentBlock, tryBlock, 'fallthrough'); const tryEnd = processStatements(tryStmts, tryBlock); // Catch handler @@ -540,8 +1003,8 @@ export function buildFunctionCFG(functionNode, langId) { let finallyHandler = null; for (let i = 0; i < tryStmt.namedChildCount; i++) { const child = tryStmt.namedChild(i); - if (child.type === rules.catchNode) catchHandler = child; - if (child.type === rules.finallyNode) finallyHandler = child; + if (rules.catchNode && child.type === rules.catchNode) catchHandler = child; + if (rules.finallyNode && child.type === rules.finallyNode) finallyHandler = child; } if (catchHandler) { @@ -549,8 +1012,17 @@ export function buildFunctionCFG(functionNode, langId) { // Exception edge from try to catch addEdge(tryBlock, catchBlock, 'exception'); - const catchBody = catchHandler.childForFieldName('body'); - const catchStmts = getStatements(catchBody); + // Catch body — try field access, then collect children + const catchBodyNode = catchHandler.childForFieldName('body'); + let catchStmts; + if (catchBodyNode) { + catchStmts = getStatements(catchBodyNode); + } else { + catchStmts = []; + for (let i = 0; i < catchHandler.namedChildCount; i++) { + catchStmts.push(catchHandler.namedChild(i)); + } + } const catchEnd = processStatements(catchStmts, catchBlock); if (finallyHandler) { @@ -563,8 +1035,10 @@ export function buildFunctionCFG(functionNode, langId) { if (tryEnd) addEdge(tryEnd, finallyBlock, 'fallthrough'); if (catchEnd) addEdge(catchEnd, finallyBlock, 'fallthrough'); - const finallyBody = finallyHandler.childForFieldName('body'); - const finallyStmts = getStatements(finallyBody); + const finallyBodyNode = finallyHandler.childForFieldName('body'); + const finallyStmts = finallyBodyNode + ? getStatements(finallyBodyNode) + : getStatements(finallyHandler); const finallyEnd = processStatements(finallyStmts, finallyBlock); if (finallyEnd) addEdge(finallyEnd, joinBlock, 'fallthrough'); } else { @@ -580,8 +1054,10 @@ export function buildFunctionCFG(functionNode, langId) { ); if (tryEnd) addEdge(tryEnd, finallyBlock, 'fallthrough'); - const finallyBody = finallyHandler.childForFieldName('body'); - const finallyStmts = getStatements(finallyBody); + const finallyBodyNode = finallyHandler.childForFieldName('body'); + const finallyStmts = finallyBodyNode + ? getStatements(finallyBodyNode) + : getStatements(finallyHandler); const finallyEnd = processStatements(finallyStmts, finallyBlock); if (finallyEnd) addEdge(finallyEnd, joinBlock, 'fallthrough'); } else { diff --git a/src/complexity.js b/src/complexity.js index 132ccb25..383b4edf 100644 --- a/src/complexity.js +++ b/src/complexity.js @@ -183,7 +183,7 @@ const CSHARP_RULES = { 'if_statement', 'else_clause', 'for_statement', - 'for_each_statement', + 'foreach_statement', 'while_statement', 'do_statement', 'catch_clause', @@ -197,7 +197,7 @@ const CSHARP_RULES = { nestingNodes: new Set([ 'if_statement', 'for_statement', - 'for_each_statement', + 'foreach_statement', 'while_statement', 'do_statement', 'catch_clause', @@ -211,9 +211,9 @@ const CSHARP_RULES = { 'local_function_statement', ]), ifNodeType: 'if_statement', - elseNodeType: 'else_clause', + elseNodeType: null, elifNodeType: null, - elseViaAlternative: false, + elseViaAlternative: true, switchLikeNodes: new Set(['switch_statement']), }; @@ -291,7 +291,7 @@ export const COMPLEXITY_RULES = new Map([ ['go', GO_RULES], ['rust', RUST_RULES], ['java', JAVA_RULES], - ['c_sharp', CSHARP_RULES], + ['csharp', CSHARP_RULES], ['ruby', RUBY_RULES], ['php', PHP_RULES], ]); @@ -1026,7 +1026,7 @@ export const HALSTEAD_RULES = new Map([ ['go', GO_HALSTEAD], ['rust', RUST_HALSTEAD], ['java', JAVA_HALSTEAD], - ['c_sharp', CSHARP_HALSTEAD], + ['csharp', CSHARP_HALSTEAD], ['ruby', RUBY_HALSTEAD], ['php', PHP_HALSTEAD], ]); @@ -1116,7 +1116,7 @@ const COMMENT_PREFIXES = new Map([ ['go', C_STYLE_PREFIXES], ['rust', C_STYLE_PREFIXES], ['java', C_STYLE_PREFIXES], - ['c_sharp', C_STYLE_PREFIXES], + ['csharp', C_STYLE_PREFIXES], ['python', ['#']], ['ruby', ['#']], ['php', ['//', '#', '/*', '*', '*/']], diff --git a/tests/unit/cfg.test.js b/tests/unit/cfg.test.js index 99a52471..e1543bb0 100644 --- a/tests/unit/cfg.test.js +++ b/tests/unit/cfg.test.js @@ -455,3 +455,774 @@ describe('buildFunctionCFG', () => { }); }); }); + +// ─── Cross-language CFG tests ──────────────────────────────────────────── + +function makeLangHelpers(langId, parsers) { + const parser = parsers.get(langId); + if (!parser) return null; + const langRules = COMPLEXITY_RULES.get(langId); + if (!langRules) return null; + + function parseLang(code) { + return parser.parse(code).rootNode; + } + + function findFunc(node) { + if (langRules.functionNodes.has(node.type)) return node; + for (let i = 0; i < node.childCount; i++) { + const result = findFunc(node.child(i)); + if (result) return result; + } + return null; + } + + function buildCFGLang(code) { + const root = parseLang(code); + const funcNode = findFunc(root); + if (!funcNode) throw new Error(`No function found for ${langId}`); + return buildFunctionCFG(funcNode, langId); + } + + return { parseLang, findFunc, buildCFGLang }; +} + +// ── Python ─────────────────────────────────────────────────────────────── + +describe('buildFunctionCFG — Python', () => { + let helpers; + + beforeAll(async () => { + const parsers = await createParsers(); + helpers = makeLangHelpers('python', parsers); + }); + + it('empty function: ENTRY → EXIT', () => { + if (!helpers) return; + const cfg = helpers.buildCFGLang('def empty():\n pass'); + const entry = cfg.blocks.find((b) => b.type === 'entry'); + const exit = cfg.blocks.find((b) => b.type === 'exit'); + expect(entry).toBeDefined(); + expect(exit).toBeDefined(); + }); + + it('if/elif/else chain (Pattern B)', () => { + if (!helpers) return; + const cfg = helpers.buildCFGLang(` +def check(x): + if x > 10: + return "big" + elif x > 0: + return "small" + else: + return "negative" +`); + const conditions = blockByType(cfg, 'condition'); + expect(conditions.length).toBeGreaterThanOrEqual(2); + const trueBlocks = blockByType(cfg, 'branch_true'); + expect(trueBlocks.length).toBeGreaterThanOrEqual(2); + }); + + it('for loop', () => { + if (!helpers) return; + const cfg = helpers.buildCFGLang(` +def loop_fn(): + for i in range(10): + print(i) +`); + const headers = blockByType(cfg, 'loop_header'); + expect(headers.length).toBe(1); + expect(cfg.edges.some((e) => e.kind === 'loop_back')).toBe(true); + expect(cfg.edges.some((e) => e.kind === 'loop_exit')).toBe(true); + }); + + it('while loop with break', () => { + if (!helpers) return; + const cfg = helpers.buildCFGLang(` +def while_fn(): + while True: + x = 1 + break +`); + const headers = blockByType(cfg, 'loop_header'); + expect(headers.length).toBe(1); + expect(cfg.edges.some((e) => e.kind === 'break')).toBe(true); + }); + + it('try/except/finally', () => { + if (!helpers) return; + const cfg = helpers.buildCFGLang(` +def safe(): + try: + risky() + except Exception: + handle() + finally: + cleanup() +`); + const catchBlocks = blockByType(cfg, 'catch'); + const finallyBlocks = blockByType(cfg, 'finally'); + expect(catchBlocks.length).toBe(1); + expect(finallyBlocks.length).toBe(1); + expect(cfg.edges.some((e) => e.kind === 'exception')).toBe(true); + }); + + it('return and raise', () => { + if (!helpers) return; + const cfg = helpers.buildCFGLang(` +def guard(x): + if x < 0: + raise ValueError("negative") + return x +`); + const exit = cfg.blocks.find((b) => b.type === 'exit'); + expect(cfg.edges.some((e) => e.targetIndex === exit.index && e.kind === 'exception')).toBe( + true, + ); + expect(cfg.edges.some((e) => e.targetIndex === exit.index && e.kind === 'return')).toBe(true); + }); +}); + +// ── Go ─────────────────────────────────────────────────────────────────── + +describe('buildFunctionCFG — Go', () => { + let helpers; + + beforeAll(async () => { + const parsers = await createParsers(); + helpers = makeLangHelpers('go', parsers); + }); + + it('empty function: ENTRY → EXIT', () => { + if (!helpers) return; + const cfg = helpers.buildCFGLang('package main\nfunc empty() {}'); + const entry = cfg.blocks.find((b) => b.type === 'entry'); + const exit = cfg.blocks.find((b) => b.type === 'exit'); + expect(entry).toBeDefined(); + expect(exit).toBeDefined(); + expect(hasEdge(cfg, entry.index, exit.index, 'fallthrough')).toBe(true); + }); + + it('if/else if/else (Pattern C — direct alternative)', () => { + if (!helpers) return; + const cfg = helpers.buildCFGLang(` +package main +func check(x int) string { + if x > 10 { + return "big" + } else if x > 0 { + return "small" + } else { + return "negative" + } +} +`); + const conditions = blockByType(cfg, 'condition'); + expect(conditions.length).toBeGreaterThanOrEqual(2); + }); + + it('for loop (Go only has for)', () => { + if (!helpers) return; + const cfg = helpers.buildCFGLang(` +package main +func loop_fn() { + for i := 0; i < 10; i++ { + println(i) + } +} +`); + const headers = blockByType(cfg, 'loop_header'); + expect(headers.length).toBe(1); + expect(cfg.edges.some((e) => e.kind === 'loop_back')).toBe(true); + expect(cfg.edges.some((e) => e.kind === 'loop_exit')).toBe(true); + }); + + it('break and continue', () => { + if (!helpers) return; + const cfg = helpers.buildCFGLang(` +package main +func brk() { + for i := 0; i < 10; i++ { + if i == 5 { + break + } + if i == 3 { + continue + } + } +} +`); + expect(cfg.edges.some((e) => e.kind === 'break')).toBe(true); + expect(cfg.edges.some((e) => e.kind === 'continue')).toBe(true); + }); + + it('switch statement', () => { + if (!helpers) return; + const cfg = helpers.buildCFGLang(` +package main +func sw(x int) { + switch x { + case 1: + println("one") + case 2: + println("two") + default: + println("other") + } +} +`); + const switchHeaders = cfg.blocks.filter((b) => b.type === 'condition' && b.label === 'switch'); + expect(switchHeaders.length).toBe(1); + const caseBlocks = blockByType(cfg, 'case'); + expect(caseBlocks.length).toBeGreaterThanOrEqual(2); + }); +}); + +// ── Rust ───────────────────────────────────────────────────────────────── + +describe('buildFunctionCFG — Rust', () => { + let helpers; + + beforeAll(async () => { + const parsers = await createParsers(); + helpers = makeLangHelpers('rust', parsers); + }); + + it('empty function: ENTRY → EXIT', () => { + if (!helpers) return; + const cfg = helpers.buildCFGLang('fn empty() {}'); + const entry = cfg.blocks.find((b) => b.type === 'entry'); + const exit = cfg.blocks.find((b) => b.type === 'exit'); + expect(entry).toBeDefined(); + expect(exit).toBeDefined(); + expect(hasEdge(cfg, entry.index, exit.index, 'fallthrough')).toBe(true); + }); + + it('if/else', () => { + if (!helpers) return; + const cfg = helpers.buildCFGLang(` +fn check(x: i32) -> &'static str { + if x > 0 { + return "positive"; + } else { + return "non-positive"; + } +} +`); + const conditions = blockByType(cfg, 'condition'); + expect(conditions.length).toBe(1); + expect(blockByType(cfg, 'branch_true').length).toBe(1); + expect(blockByType(cfg, 'branch_false').length).toBe(1); + }); + + it('loop (infinite loop) with break', () => { + if (!helpers) return; + const cfg = helpers.buildCFGLang(` +fn infinite() { + loop { + let x = 1; + break; + } +} +`); + const headers = blockByType(cfg, 'loop_header'); + expect(headers.length).toBe(1); + expect(headers[0].label).toBe('loop'); + expect(cfg.edges.some((e) => e.kind === 'break')).toBe(true); + // Infinite loop should NOT have a loop_exit edge + const headerIdx = headers[0].index; + expect(cfg.edges.some((e) => e.sourceIndex === headerIdx && e.kind === 'loop_exit')).toBe( + false, + ); + }); + + it('while loop', () => { + if (!helpers) return; + const cfg = helpers.buildCFGLang(` +fn while_fn() { + let mut i = 0; + while i < 10 { + i += 1; + } +} +`); + const headers = blockByType(cfg, 'loop_header'); + expect(headers.length).toBe(1); + expect(cfg.edges.some((e) => e.kind === 'loop_back')).toBe(true); + expect(cfg.edges.some((e) => e.kind === 'loop_exit')).toBe(true); + }); + + it('for loop', () => { + if (!helpers) return; + const cfg = helpers.buildCFGLang(` +fn for_fn() { + for i in 0..10 { + println!("{}", i); + } +} +`); + const headers = blockByType(cfg, 'loop_header'); + expect(headers.length).toBe(1); + expect(cfg.edges.some((e) => e.kind === 'loop_back')).toBe(true); + }); + + it('match expression', () => { + if (!helpers) return; + const cfg = helpers.buildCFGLang(` +fn match_fn(x: i32) { + match x { + 1 => println!("one"), + 2 => println!("two"), + _ => println!("other"), + } +} +`); + const switchHeaders = cfg.blocks.filter((b) => b.type === 'condition' && b.label === 'switch'); + expect(switchHeaders.length).toBe(1); + const caseBlocks = blockByType(cfg, 'case'); + expect(caseBlocks.length).toBeGreaterThanOrEqual(2); + }); +}); + +// ── Java ───────────────────────────────────────────────────────────────── + +describe('buildFunctionCFG — Java', () => { + let helpers; + + beforeAll(async () => { + const parsers = await createParsers(); + helpers = makeLangHelpers('java', parsers); + }); + + it('empty method: ENTRY → EXIT', () => { + if (!helpers) return; + const cfg = helpers.buildCFGLang('class A { void empty() {} }'); + const entry = cfg.blocks.find((b) => b.type === 'entry'); + const exit = cfg.blocks.find((b) => b.type === 'exit'); + expect(entry).toBeDefined(); + expect(exit).toBeDefined(); + expect(hasEdge(cfg, entry.index, exit.index, 'fallthrough')).toBe(true); + }); + + it('if/else if/else (Pattern C)', () => { + if (!helpers) return; + const cfg = helpers.buildCFGLang(` +class A { + String check(int x) { + if (x > 10) { + return "big"; + } else if (x > 0) { + return "small"; + } else { + return "negative"; + } + } +} +`); + const conditions = blockByType(cfg, 'condition'); + expect(conditions.length).toBeGreaterThanOrEqual(2); + }); + + it('for loop', () => { + if (!helpers) return; + const cfg = helpers.buildCFGLang(` +class A { + void loop_fn() { + for (int i = 0; i < 10; i++) { + System.out.println(i); + } + } +} +`); + const headers = blockByType(cfg, 'loop_header'); + expect(headers.length).toBe(1); + expect(cfg.edges.some((e) => e.kind === 'loop_back')).toBe(true); + expect(cfg.edges.some((e) => e.kind === 'loop_exit')).toBe(true); + }); + + it('enhanced for loop', () => { + if (!helpers) return; + const cfg = helpers.buildCFGLang(` +class A { + void each(int[] arr) { + for (int x : arr) { + System.out.println(x); + } + } +} +`); + const headers = blockByType(cfg, 'loop_header'); + expect(headers.length).toBe(1); + }); + + it('while and do-while', () => { + if (!helpers) return; + const cfg = helpers.buildCFGLang(` +class A { + void loops() { + int i = 0; + while (i < 5) { i++; } + do { i--; } while (i > 0); + } +} +`); + const headers = blockByType(cfg, 'loop_header'); + expect(headers.length).toBe(2); + }); + + it('try/catch/finally', () => { + if (!helpers) return; + const cfg = helpers.buildCFGLang(` +class A { + void safe() { + try { + risky(); + } catch (Exception e) { + handle(); + } finally { + cleanup(); + } + } +} +`); + expect(blockByType(cfg, 'catch').length).toBe(1); + expect(blockByType(cfg, 'finally').length).toBe(1); + expect(cfg.edges.some((e) => e.kind === 'exception')).toBe(true); + }); + + it('break and continue', () => { + if (!helpers) return; + const cfg = helpers.buildCFGLang(` +class A { + void brk() { + for (int i = 0; i < 10; i++) { + if (i == 5) break; + if (i == 3) continue; + } + } +} +`); + expect(cfg.edges.some((e) => e.kind === 'break')).toBe(true); + expect(cfg.edges.some((e) => e.kind === 'continue')).toBe(true); + }); +}); + +// ── C# ─────────────────────────────────────────────────────────────────── + +describe('buildFunctionCFG — C#', () => { + let helpers; + + beforeAll(async () => { + const parsers = await createParsers(); + helpers = makeLangHelpers('csharp', parsers); + }); + + it('empty method: ENTRY → EXIT', () => { + if (!helpers) return; + const cfg = helpers.buildCFGLang('class A { void Empty() {} }'); + const entry = cfg.blocks.find((b) => b.type === 'entry'); + const exit = cfg.blocks.find((b) => b.type === 'exit'); + expect(entry).toBeDefined(); + expect(exit).toBeDefined(); + expect(hasEdge(cfg, entry.index, exit.index, 'fallthrough')).toBe(true); + }); + + it('if/else', () => { + if (!helpers) return; + const cfg = helpers.buildCFGLang(` +class A { + string Check(int x) { + if (x > 0) { + return "positive"; + } else { + return "non-positive"; + } + } +} +`); + expect(blockByType(cfg, 'condition').length).toBe(1); + expect(blockByType(cfg, 'branch_true').length).toBe(1); + expect(blockByType(cfg, 'branch_false').length).toBe(1); + }); + + it('for and foreach loops', () => { + if (!helpers) return; + const cfg = helpers.buildCFGLang(` +class A { + void Loops() { + for (int i = 0; i < 10; i++) { Console.WriteLine(i); } + foreach (var x in new int[]{1,2,3}) { Console.WriteLine(x); } + } +} +`); + const headers = blockByType(cfg, 'loop_header'); + expect(headers.length).toBe(2); + }); + + it('try/catch/finally', () => { + if (!helpers) return; + const cfg = helpers.buildCFGLang(` +class A { + void Safe() { + try { + Risky(); + } catch (Exception e) { + Handle(); + } finally { + Cleanup(); + } + } +} +`); + expect(blockByType(cfg, 'catch').length).toBe(1); + expect(blockByType(cfg, 'finally').length).toBe(1); + }); + + it('do-while', () => { + if (!helpers) return; + const cfg = helpers.buildCFGLang(` +class A { + void DoWhile() { + int i = 0; + do { i++; } while (i < 10); + } +} +`); + const headers = blockByType(cfg, 'loop_header'); + expect(headers.length).toBe(1); + expect(headers[0].label).toBe('do-while'); + }); +}); + +// ── Ruby ───────────────────────────────────────────────────────────────── + +describe('buildFunctionCFG — Ruby', () => { + let helpers; + + beforeAll(async () => { + const parsers = await createParsers(); + helpers = makeLangHelpers('ruby', parsers); + }); + + it('empty method: ENTRY → EXIT', () => { + if (!helpers) return; + const cfg = helpers.buildCFGLang('def empty; end'); + const entry = cfg.blocks.find((b) => b.type === 'entry'); + const exit = cfg.blocks.find((b) => b.type === 'exit'); + expect(entry).toBeDefined(); + expect(exit).toBeDefined(); + }); + + it('if/elsif/else (Pattern B)', () => { + if (!helpers) return; + const cfg = helpers.buildCFGLang(` +def check(x) + if x > 10 + return "big" + elsif x > 0 + return "small" + else + return "negative" + end +end +`); + const conditions = blockByType(cfg, 'condition'); + expect(conditions.length).toBeGreaterThanOrEqual(2); + }); + + it('while loop', () => { + if (!helpers) return; + const cfg = helpers.buildCFGLang(` +def while_fn + i = 0 + while i < 10 + i += 1 + end +end +`); + const headers = blockByType(cfg, 'loop_header'); + expect(headers.length).toBe(1); + expect(cfg.edges.some((e) => e.kind === 'loop_back')).toBe(true); + expect(cfg.edges.some((e) => e.kind === 'loop_exit')).toBe(true); + }); + + it('unless (inverse if)', () => { + if (!helpers) return; + const cfg = helpers.buildCFGLang(` +def unless_fn(x) + unless x + return "falsy" + end + return "truthy" +end +`); + const conditions = blockByType(cfg, 'condition'); + expect(conditions.length).toBe(1); + }); + + it('until loop (inverse while)', () => { + if (!helpers) return; + const cfg = helpers.buildCFGLang(` +def until_fn + i = 0 + until i >= 10 + i += 1 + end +end +`); + const headers = blockByType(cfg, 'loop_header'); + expect(headers.length).toBe(1); + expect(cfg.edges.some((e) => e.kind === 'loop_back')).toBe(true); + }); + + it('break and next (continue)', () => { + if (!helpers) return; + const cfg = helpers.buildCFGLang(` +def brk + i = 0 + while i < 10 + if i == 5 + break + end + if i == 3 + next + end + i += 1 + end +end +`); + expect(cfg.edges.some((e) => e.kind === 'break')).toBe(true); + expect(cfg.edges.some((e) => e.kind === 'continue')).toBe(true); + }); +}); + +// ── PHP ────────────────────────────────────────────────────────────────── + +describe('buildFunctionCFG — PHP', () => { + let helpers; + + beforeAll(async () => { + const parsers = await createParsers(); + helpers = makeLangHelpers('php', parsers); + }); + + it('empty function: ENTRY → EXIT', () => { + if (!helpers) return; + const cfg = helpers.buildCFGLang(' b.type === 'entry'); + const exit = cfg.blocks.find((b) => b.type === 'exit'); + expect(entry).toBeDefined(); + expect(exit).toBeDefined(); + expect(hasEdge(cfg, entry.index, exit.index, 'fallthrough')).toBe(true); + }); + + it('if/elseif/else (Pattern B)', () => { + if (!helpers) return; + const cfg = helpers.buildCFGLang(` 10) { + return "big"; + } elseif ($x > 0) { + return "small"; + } else { + return "negative"; + } +} +`); + const conditions = blockByType(cfg, 'condition'); + expect(conditions.length).toBeGreaterThanOrEqual(2); + }); + + it('for loop', () => { + if (!helpers) return; + const cfg = helpers.buildCFGLang(` e.kind === 'loop_back')).toBe(true); + expect(cfg.edges.some((e) => e.kind === 'loop_exit')).toBe(true); + }); + + it('foreach loop', () => { + if (!helpers) return; + const cfg = helpers.buildCFGLang(` { + if (!helpers) return; + const cfg = helpers.buildCFGLang(` 0); +} +`); + const headers = blockByType(cfg, 'loop_header'); + expect(headers.length).toBe(2); + }); + + it('try/catch/finally', () => { + if (!helpers) return; + const cfg = helpers.buildCFGLang(` e.kind === 'exception')).toBe(true); + }); + + it('switch/case', () => { + if (!helpers) return; + const cfg = helpers.buildCFGLang(` b.type === 'condition' && b.label === 'switch'); + expect(switchHeaders.length).toBe(1); + const caseBlocks = blockByType(cfg, 'case'); + expect(caseBlocks.length).toBeGreaterThanOrEqual(2); + }); + + it('break and continue', () => { + if (!helpers) return; + const cfg = helpers.buildCFGLang(` e.kind === 'break')).toBe(true); + expect(cfg.edges.some((e) => e.kind === 'continue')).toBe(true); + }); +}); diff --git a/tests/unit/complexity.test.js b/tests/unit/complexity.test.js index a23c9060..d3b4b73f 100644 --- a/tests/unit/complexity.test.js +++ b/tests/unit/complexity.test.js @@ -255,7 +255,7 @@ describe('COMPLEXITY_RULES', () => { }); it('supports all 10 languages, not hcl', () => { - for (const lang of ['python', 'go', 'rust', 'java', 'c_sharp', 'ruby', 'php']) { + for (const lang of ['python', 'go', 'rust', 'java', 'csharp', 'ruby', 'php']) { expect(COMPLEXITY_RULES.has(lang)).toBe(true); } expect(COMPLEXITY_RULES.has('hcl')).toBe(false); @@ -347,7 +347,7 @@ describe('HALSTEAD_RULES', () => { }); it('supports all 10 languages, not hcl', () => { - for (const lang of ['python', 'go', 'rust', 'java', 'c_sharp', 'ruby', 'php']) { + for (const lang of ['python', 'go', 'rust', 'java', 'csharp', 'ruby', 'php']) { expect(HALSTEAD_RULES.has(lang)).toBe(true); } expect(HALSTEAD_RULES.has('hcl')).toBe(false); @@ -756,7 +756,7 @@ describe('Java complexity', () => { // ─── C# ────────────────────────────────────────────────────────────────── describe('C# complexity', () => { - const { analyze, halstead } = makeHelpers('c_sharp', sharedParsers()); + const { analyze, halstead } = makeHelpers('csharp', sharedParsers()); it('simple method', () => { const r = analyze('class C {\n int Add(int a, int b) {\n return a + b;\n }\n}\n'); From 2f3ac78976317e83b56bfc153fb17ea77f9ab77a Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Mon, 2 Mar 2026 23:18:34 -0700 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20correct=20processIf=20comment=20?= =?UTF-8?q?=E2=80=94=20C#=20uses=20Pattern=20C,=20not=20A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Impact: 1 functions changed, 1 affected --- src/cfg.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/cfg.js b/src/cfg.js index 4aad6210..bd6aa3a6 100644 --- a/src/cfg.js +++ b/src/cfg.js @@ -577,9 +577,9 @@ export function buildFunctionCFG(functionNode, langId) { /** * Process an if/else-if/else chain. * Handles three patterns: - * A) Wrapper: alternative → else_clause → nested if or block (JS/TS, C#, Rust) + * A) Wrapper: alternative → else_clause → nested if or block (JS/TS, Rust) * B) Siblings: elif/elsif/else_if as sibling children (Python, Ruby, PHP) - * C) Direct: alternative → if_statement or block directly (Go, Java) + * C) Direct: alternative → if_statement or block directly (Go, Java, C#) */ function processIf(ifStmt, currentBlock) { // Terminate current block at condition