From dc22c7a5938d266a621c987be6d123cd39179f8a Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Wed, 25 Mar 2026 10:43:34 -0600 Subject: [PATCH 01/16] feat(cfg): bypass JS CFG visitor on native builds, fix Go for-range parity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add allCfgNative() fast path in buildCFGData that skips WASM parser init, tree parsing, and JS visitor when all definitions already have native CFG data from the Rust extractor — directly persists to SQLite. Fix Go for-range loop detection in Rust CFG builder: check for range_clause child node so for-range is treated as bounded (emitting branch_true + loop_exit) instead of infinite. Expand parity tests: add Go/Rust/Ruby to matrix, add complex pattern fixtures (try/catch/finally, switch, do-while, nested loops, labeled break) validating block counts, edge counts, and kinds match. --- crates/codegraph-core/src/cfg.rs | 7 ++ src/features/cfg.ts | 52 ++++++++- tests/parsers/cfg-all-langs.test.ts | 165 +++++++++++++++++++++++++++- 3 files changed, 222 insertions(+), 2 deletions(-) diff --git a/crates/codegraph-core/src/cfg.rs b/crates/codegraph-core/src/cfg.rs index cc24a842..b9fe422f 100644 --- a/crates/codegraph-core/src/cfg.rs +++ b/crates/codegraph-core/src/cfg.rs @@ -803,6 +803,8 @@ impl<'a> CfgBuilder<'a> { for_stmt.child_by_field_name(field).is_some() || for_stmt.child_by_field_name("right").is_some() || for_stmt.child_by_field_name("value").is_some() + // Go: for-range has a range_clause child (no condition/right/value fields) + || has_child_of_kind(for_stmt, "range_clause") // Explicit iterator-style node kinds across supported languages: // JS: for_in_statement, Java: enhanced_for_statement, // C#/PHP: foreach_statement, Ruby: for @@ -1134,6 +1136,11 @@ impl<'a> CfgBuilder<'a> { // ─── Helpers ──────────────────────────────────────────────────────────── +fn has_child_of_kind(node: &Node, kind: &str) -> bool { + let cursor = &mut node.walk(); + node.children(cursor).any(|c| c.kind() == kind) +} + fn node_line(node: &Node) -> u32 { node.start_position().row as u32 + 1 } diff --git a/src/features/cfg.ts b/src/features/cfg.ts index 3ffdadfc..708c9073 100644 --- a/src/features/cfg.ts +++ b/src/features/cfg.ts @@ -246,14 +246,43 @@ function persistCfg( // ─── Build-Time: Compute CFG for Changed Files ───────────────────────── +/** + * Check if all function/method definitions across all files already have + * native CFG data (blocks array populated by the Rust extractor). + * When true, the WASM parser and JS CFG visitor can be fully bypassed. + */ +function allCfgNative(fileSymbols: Map): boolean { + for (const [relPath, symbols] of fileSymbols) { + const ext = path.extname(relPath).toLowerCase(); + if (!CFG_EXTENSIONS.has(ext)) continue; + + for (const d of symbols.definitions) { + if (d.kind !== 'function' && d.kind !== 'method') continue; + if (!d.line) continue; + // cfg === null means no body (expected), cfg with empty blocks means not computed + if (d.cfg !== null && !(d.cfg?.blocks?.length)) return false; + } + } + return true; +} + export async function buildCFGData( db: BetterSqlite3Database, fileSymbols: Map, rootDir: string, _engineOpts?: unknown, ): Promise { + // Fast path: when all function/method defs already have native CFG data, + // skip WASM parser init, tree parsing, and JS visitor entirely — just persist. + const allNative = allCfgNative(fileSymbols); + const extToLang = buildExtToLangMap(); - const { parsers, getParserFn } = await initCfgParsers(fileSymbols); + let parsers: unknown = null; + let getParserFn: unknown = null; + + if (!allNative) { + ({ parsers, getParserFn } = await initCfgParsers(fileSymbols)); + } const insertBlock = db.prepare( `INSERT INTO cfg_blocks (function_node_id, block_index, block_type, start_line, end_line, label) @@ -270,6 +299,27 @@ export async function buildCFGData( const ext = path.extname(relPath).toLowerCase(); if (!CFG_EXTENSIONS.has(ext)) continue; + // Native fast path: skip tree/visitor setup when all CFG is pre-computed + if (allNative) { + for (const def of symbols.definitions) { + if (def.kind !== 'function' && def.kind !== 'method') continue; + if (!def.line || !def.cfg?.blocks?.length) continue; + + const nodeId = getFunctionNodeId(db, def.name, relPath, def.line); + if (!nodeId) continue; + + deleteCfgForNode(db, nodeId); + persistCfg( + def.cfg as unknown as { blocks: CfgBuildBlock[]; edges: CfgBuildEdge[] }, + nodeId, + insertBlock, + insertEdge, + ); + analyzed++; + } + continue; + } + const treeLang = getTreeAndLang(symbols, relPath, rootDir, extToLang, parsers, getParserFn); if (!treeLang) continue; const { tree, langId } = treeLang; diff --git a/tests/parsers/cfg-all-langs.test.ts b/tests/parsers/cfg-all-langs.test.ts index b13308c8..af8c301f 100644 --- a/tests/parsers/cfg-all-langs.test.ts +++ b/tests/parsers/cfg-all-langs.test.ts @@ -246,6 +246,65 @@ class Processor { `, }; +// Complex fixtures for deeper parity validation (try/catch, switch, do-while, nested loops) +const COMPLEX_CFG_FIXTURES = { + 'complex-trycatch.js': ` +function handleRequest(data) { + try { + if (!data) throw new Error("no data"); + return JSON.parse(data); + } catch (err) { + console.error(err); + return null; + } finally { + console.log("done"); + } +} +`, + 'complex-switch.js': ` +function classify(x) { + switch (x) { + case 1: + return "one"; + case 2: + return "two"; + default: + return "other"; + } +} +`, + 'complex-dowhile.js': ` +function retry(fn) { + let attempts = 0; + do { + attempts++; + if (fn()) return true; + } while (attempts < 3); + return false; +} +`, + 'complex-nested.js': ` +function matrix(rows, cols) { + for (let i = 0; i < rows; i++) { + for (let j = 0; j < cols; j++) { + if (i === j) continue; + console.log(i, j); + } + } +} +`, + 'complex-labeled.js': ` +function search(grid) { + outer: for (let i = 0; i < grid.length; i++) { + for (let j = 0; j < grid[i].length; j++) { + if (grid[i][j] === 0) break outer; + } + } + return -1; +} +`, +}; + function nativeSupportsCfg() { const native = loadNative(); if (!native) return false; @@ -428,16 +487,31 @@ describe.skipIf(!canTestNativeCfg || !hasFixedCfg)('native vs WASM CFG parity', if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true }); }); + // Go for-range and Ruby loop parity depend on the range_clause fix in cfg.rs. + // Detect: a Go for-range should produce loop_exit (bounded), not just loop_back (infinite). + const hasGoRangeFix = (() => { + const symbols = nativeResults.get('src/fixture.go'); + if (!symbols) return false; + const def = symbols.definitions.find((d: any) => d.name === 'process'); + if (!def?.cfg?.edges) return false; + return def.cfg.edges.some((e: any) => e.kind === 'loop_exit'); + })(); + const parityTests = [ { file: 'fixture.js', ext: '.js', funcPattern: /processItems/ }, { file: 'fixture.py', ext: '.py', funcPattern: /process/ }, + { file: 'fixture.go', ext: '.go', funcPattern: /process/, requiresFix: true }, + { file: 'fixture.rs', ext: '.rs', funcPattern: /process/ }, { file: 'fixture.java', ext: '.java', funcPattern: /process/ }, { file: 'fixture.cs', ext: '.cs', funcPattern: /Process/ }, + { file: 'fixture.rb', ext: '.rb', funcPattern: /process/ }, { file: 'fixture.php', ext: '.php', funcPattern: /process/ }, ]; - for (const { file, ext, funcPattern } of parityTests) { + for (const { file, ext, funcPattern, requiresFix } of parityTests) { test(`parity: ${file} — native vs WASM block/edge counts match`, () => { + if (requiresFix && !hasGoRangeFix) return; // Skip until native binary has range_clause fix + const relPath = `src/${file}`; const symbols = nativeResults.get(relPath); if (!symbols) return; @@ -485,3 +559,92 @@ describe.skipIf(!canTestNativeCfg || !hasFixedCfg)('native vs WASM CFG parity', }); } }); + +// ─── Complex parity: try/catch, switch, do-while, nested, labeled ────── + +describe.skipIf(!canTestNativeCfg || !hasFixedCfg)( + 'native vs WASM CFG parity — complex patterns', + () => { + let tmpDir: string; + const nativeResults = new Map(); + let parsers: any; + + beforeAll(async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-cfg-complex-')); + const srcDir = path.join(tmpDir, 'src'); + fs.mkdirSync(srcDir, { recursive: true }); + + const filePaths: string[] = []; + for (const [name, code] of Object.entries(COMPLEX_CFG_FIXTURES)) { + const fp = path.join(srcDir, name); + fs.writeFileSync(fp, code); + filePaths.push(fp); + } + + const allSymbols = await parseFilesAuto(filePaths, tmpDir, { engine: 'native' }); + for (const [relPath, symbols] of allSymbols) { + nativeResults.set(relPath, symbols); + } + + parsers = await createParsers(); + }); + + afterAll(() => { + if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + const complexTests = [ + { file: 'complex-trycatch.js', funcPattern: /handleRequest/, desc: 'try/catch/finally' }, + { file: 'complex-switch.js', funcPattern: /classify/, desc: 'switch/case/default' }, + { file: 'complex-dowhile.js', funcPattern: /retry/, desc: 'do-while with break' }, + { file: 'complex-nested.js', funcPattern: /matrix/, desc: 'nested for + continue' }, + { file: 'complex-labeled.js', funcPattern: /search/, desc: 'labeled break' }, + ]; + + for (const { file, funcPattern, desc } of complexTests) { + test(`parity: ${desc} — native vs WASM block/edge counts match`, () => { + const relPath = `src/${file}`; + const symbols = nativeResults.get(relPath); + if (!symbols) return; + + const langId = 'javascript'; + const complexityRules = COMPLEXITY_RULES.get(langId); + if (!complexityRules) return; + + const absPath = path.join(tmpDir, relPath); + const parser = getParser(parsers, absPath); + if (!parser) return; + + const code = fs.readFileSync(absPath, 'utf-8'); + const tree = parser.parse(code); + if (!tree) return; + + const funcDefs = symbols.definitions.filter( + (d: any) => (d.kind === 'function' || d.kind === 'method') && funcPattern.test(d.name), + ); + + for (const def of funcDefs) { + if (!def.cfg?.blocks?.length) continue; + + const funcNode = findFunctionNode(tree.rootNode, def.line, def.endLine, complexityRules); + if (!funcNode) continue; + + const wasmCfg = buildFunctionCFG(funcNode, langId); + + expect(def.cfg.blocks.length, `${desc}: block count mismatch`).toBe( + wasmCfg.blocks.length, + ); + expect(def.cfg.edges.length, `${desc}: edge count mismatch`).toBe(wasmCfg.edges.length); + + const nativeTypes = def.cfg.blocks.map((b: any) => b.type).sort(); + const wasmTypes = wasmCfg.blocks.map((b: any) => b.type).sort(); + expect(nativeTypes, `${desc}: block types mismatch`).toEqual(wasmTypes); + + const nativeKinds = def.cfg.edges.map((e: any) => e.kind).sort(); + const wasmKinds = wasmCfg.edges.map((e: any) => e.kind).sort(); + expect(nativeKinds, `${desc}: edge kinds mismatch`).toEqual(wasmKinds); + } + }); + } + }, +); From e37c3747826615eaf171b693fe6d2444734a56c4 Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Wed, 25 Mar 2026 10:43:34 -0600 Subject: [PATCH 02/16] feat(cfg): bypass JS CFG visitor on native builds, fix Go for-range parity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add allCfgNative() fast path in buildCFGData that skips WASM parser init, tree parsing, and JS visitor when all definitions already have native CFG data from the Rust extractor — directly persists to SQLite. Fix Go for-range loop detection in Rust CFG builder: check for range_clause child node so for-range is treated as bounded (emitting branch_true + loop_exit) instead of infinite. Expand parity tests: add Go/Rust/Ruby to matrix, add complex pattern fixtures (try/catch/finally, switch, do-while, nested loops, labeled break) validating block counts, edge counts, and kinds match. --- crates/codegraph-core/src/cfg.rs | 7 ++ src/features/cfg.ts | 52 ++++++++- tests/parsers/cfg-all-langs.test.ts | 165 +++++++++++++++++++++++++++- 3 files changed, 222 insertions(+), 2 deletions(-) diff --git a/crates/codegraph-core/src/cfg.rs b/crates/codegraph-core/src/cfg.rs index cc24a842..b9fe422f 100644 --- a/crates/codegraph-core/src/cfg.rs +++ b/crates/codegraph-core/src/cfg.rs @@ -803,6 +803,8 @@ impl<'a> CfgBuilder<'a> { for_stmt.child_by_field_name(field).is_some() || for_stmt.child_by_field_name("right").is_some() || for_stmt.child_by_field_name("value").is_some() + // Go: for-range has a range_clause child (no condition/right/value fields) + || has_child_of_kind(for_stmt, "range_clause") // Explicit iterator-style node kinds across supported languages: // JS: for_in_statement, Java: enhanced_for_statement, // C#/PHP: foreach_statement, Ruby: for @@ -1134,6 +1136,11 @@ impl<'a> CfgBuilder<'a> { // ─── Helpers ──────────────────────────────────────────────────────────── +fn has_child_of_kind(node: &Node, kind: &str) -> bool { + let cursor = &mut node.walk(); + node.children(cursor).any(|c| c.kind() == kind) +} + fn node_line(node: &Node) -> u32 { node.start_position().row as u32 + 1 } diff --git a/src/features/cfg.ts b/src/features/cfg.ts index 3ffdadfc..708c9073 100644 --- a/src/features/cfg.ts +++ b/src/features/cfg.ts @@ -246,14 +246,43 @@ function persistCfg( // ─── Build-Time: Compute CFG for Changed Files ───────────────────────── +/** + * Check if all function/method definitions across all files already have + * native CFG data (blocks array populated by the Rust extractor). + * When true, the WASM parser and JS CFG visitor can be fully bypassed. + */ +function allCfgNative(fileSymbols: Map): boolean { + for (const [relPath, symbols] of fileSymbols) { + const ext = path.extname(relPath).toLowerCase(); + if (!CFG_EXTENSIONS.has(ext)) continue; + + for (const d of symbols.definitions) { + if (d.kind !== 'function' && d.kind !== 'method') continue; + if (!d.line) continue; + // cfg === null means no body (expected), cfg with empty blocks means not computed + if (d.cfg !== null && !(d.cfg?.blocks?.length)) return false; + } + } + return true; +} + export async function buildCFGData( db: BetterSqlite3Database, fileSymbols: Map, rootDir: string, _engineOpts?: unknown, ): Promise { + // Fast path: when all function/method defs already have native CFG data, + // skip WASM parser init, tree parsing, and JS visitor entirely — just persist. + const allNative = allCfgNative(fileSymbols); + const extToLang = buildExtToLangMap(); - const { parsers, getParserFn } = await initCfgParsers(fileSymbols); + let parsers: unknown = null; + let getParserFn: unknown = null; + + if (!allNative) { + ({ parsers, getParserFn } = await initCfgParsers(fileSymbols)); + } const insertBlock = db.prepare( `INSERT INTO cfg_blocks (function_node_id, block_index, block_type, start_line, end_line, label) @@ -270,6 +299,27 @@ export async function buildCFGData( const ext = path.extname(relPath).toLowerCase(); if (!CFG_EXTENSIONS.has(ext)) continue; + // Native fast path: skip tree/visitor setup when all CFG is pre-computed + if (allNative) { + for (const def of symbols.definitions) { + if (def.kind !== 'function' && def.kind !== 'method') continue; + if (!def.line || !def.cfg?.blocks?.length) continue; + + const nodeId = getFunctionNodeId(db, def.name, relPath, def.line); + if (!nodeId) continue; + + deleteCfgForNode(db, nodeId); + persistCfg( + def.cfg as unknown as { blocks: CfgBuildBlock[]; edges: CfgBuildEdge[] }, + nodeId, + insertBlock, + insertEdge, + ); + analyzed++; + } + continue; + } + const treeLang = getTreeAndLang(symbols, relPath, rootDir, extToLang, parsers, getParserFn); if (!treeLang) continue; const { tree, langId } = treeLang; diff --git a/tests/parsers/cfg-all-langs.test.ts b/tests/parsers/cfg-all-langs.test.ts index b13308c8..af8c301f 100644 --- a/tests/parsers/cfg-all-langs.test.ts +++ b/tests/parsers/cfg-all-langs.test.ts @@ -246,6 +246,65 @@ class Processor { `, }; +// Complex fixtures for deeper parity validation (try/catch, switch, do-while, nested loops) +const COMPLEX_CFG_FIXTURES = { + 'complex-trycatch.js': ` +function handleRequest(data) { + try { + if (!data) throw new Error("no data"); + return JSON.parse(data); + } catch (err) { + console.error(err); + return null; + } finally { + console.log("done"); + } +} +`, + 'complex-switch.js': ` +function classify(x) { + switch (x) { + case 1: + return "one"; + case 2: + return "two"; + default: + return "other"; + } +} +`, + 'complex-dowhile.js': ` +function retry(fn) { + let attempts = 0; + do { + attempts++; + if (fn()) return true; + } while (attempts < 3); + return false; +} +`, + 'complex-nested.js': ` +function matrix(rows, cols) { + for (let i = 0; i < rows; i++) { + for (let j = 0; j < cols; j++) { + if (i === j) continue; + console.log(i, j); + } + } +} +`, + 'complex-labeled.js': ` +function search(grid) { + outer: for (let i = 0; i < grid.length; i++) { + for (let j = 0; j < grid[i].length; j++) { + if (grid[i][j] === 0) break outer; + } + } + return -1; +} +`, +}; + function nativeSupportsCfg() { const native = loadNative(); if (!native) return false; @@ -428,16 +487,31 @@ describe.skipIf(!canTestNativeCfg || !hasFixedCfg)('native vs WASM CFG parity', if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true }); }); + // Go for-range and Ruby loop parity depend on the range_clause fix in cfg.rs. + // Detect: a Go for-range should produce loop_exit (bounded), not just loop_back (infinite). + const hasGoRangeFix = (() => { + const symbols = nativeResults.get('src/fixture.go'); + if (!symbols) return false; + const def = symbols.definitions.find((d: any) => d.name === 'process'); + if (!def?.cfg?.edges) return false; + return def.cfg.edges.some((e: any) => e.kind === 'loop_exit'); + })(); + const parityTests = [ { file: 'fixture.js', ext: '.js', funcPattern: /processItems/ }, { file: 'fixture.py', ext: '.py', funcPattern: /process/ }, + { file: 'fixture.go', ext: '.go', funcPattern: /process/, requiresFix: true }, + { file: 'fixture.rs', ext: '.rs', funcPattern: /process/ }, { file: 'fixture.java', ext: '.java', funcPattern: /process/ }, { file: 'fixture.cs', ext: '.cs', funcPattern: /Process/ }, + { file: 'fixture.rb', ext: '.rb', funcPattern: /process/ }, { file: 'fixture.php', ext: '.php', funcPattern: /process/ }, ]; - for (const { file, ext, funcPattern } of parityTests) { + for (const { file, ext, funcPattern, requiresFix } of parityTests) { test(`parity: ${file} — native vs WASM block/edge counts match`, () => { + if (requiresFix && !hasGoRangeFix) return; // Skip until native binary has range_clause fix + const relPath = `src/${file}`; const symbols = nativeResults.get(relPath); if (!symbols) return; @@ -485,3 +559,92 @@ describe.skipIf(!canTestNativeCfg || !hasFixedCfg)('native vs WASM CFG parity', }); } }); + +// ─── Complex parity: try/catch, switch, do-while, nested, labeled ────── + +describe.skipIf(!canTestNativeCfg || !hasFixedCfg)( + 'native vs WASM CFG parity — complex patterns', + () => { + let tmpDir: string; + const nativeResults = new Map(); + let parsers: any; + + beforeAll(async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-cfg-complex-')); + const srcDir = path.join(tmpDir, 'src'); + fs.mkdirSync(srcDir, { recursive: true }); + + const filePaths: string[] = []; + for (const [name, code] of Object.entries(COMPLEX_CFG_FIXTURES)) { + const fp = path.join(srcDir, name); + fs.writeFileSync(fp, code); + filePaths.push(fp); + } + + const allSymbols = await parseFilesAuto(filePaths, tmpDir, { engine: 'native' }); + for (const [relPath, symbols] of allSymbols) { + nativeResults.set(relPath, symbols); + } + + parsers = await createParsers(); + }); + + afterAll(() => { + if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + const complexTests = [ + { file: 'complex-trycatch.js', funcPattern: /handleRequest/, desc: 'try/catch/finally' }, + { file: 'complex-switch.js', funcPattern: /classify/, desc: 'switch/case/default' }, + { file: 'complex-dowhile.js', funcPattern: /retry/, desc: 'do-while with break' }, + { file: 'complex-nested.js', funcPattern: /matrix/, desc: 'nested for + continue' }, + { file: 'complex-labeled.js', funcPattern: /search/, desc: 'labeled break' }, + ]; + + for (const { file, funcPattern, desc } of complexTests) { + test(`parity: ${desc} — native vs WASM block/edge counts match`, () => { + const relPath = `src/${file}`; + const symbols = nativeResults.get(relPath); + if (!symbols) return; + + const langId = 'javascript'; + const complexityRules = COMPLEXITY_RULES.get(langId); + if (!complexityRules) return; + + const absPath = path.join(tmpDir, relPath); + const parser = getParser(parsers, absPath); + if (!parser) return; + + const code = fs.readFileSync(absPath, 'utf-8'); + const tree = parser.parse(code); + if (!tree) return; + + const funcDefs = symbols.definitions.filter( + (d: any) => (d.kind === 'function' || d.kind === 'method') && funcPattern.test(d.name), + ); + + for (const def of funcDefs) { + if (!def.cfg?.blocks?.length) continue; + + const funcNode = findFunctionNode(tree.rootNode, def.line, def.endLine, complexityRules); + if (!funcNode) continue; + + const wasmCfg = buildFunctionCFG(funcNode, langId); + + expect(def.cfg.blocks.length, `${desc}: block count mismatch`).toBe( + wasmCfg.blocks.length, + ); + expect(def.cfg.edges.length, `${desc}: edge count mismatch`).toBe(wasmCfg.edges.length); + + const nativeTypes = def.cfg.blocks.map((b: any) => b.type).sort(); + const wasmTypes = wasmCfg.blocks.map((b: any) => b.type).sort(); + expect(nativeTypes, `${desc}: block types mismatch`).toEqual(wasmTypes); + + const nativeKinds = def.cfg.edges.map((e: any) => e.kind).sort(); + const wasmKinds = wasmCfg.edges.map((e: any) => e.kind).sort(); + expect(nativeKinds, `${desc}: edge kinds mismatch`).toEqual(wasmKinds); + } + }); + } + }, +); From c5b79a05604cef5dd513e50a9983b48ddca10520 Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Wed, 25 Mar 2026 11:28:27 -0600 Subject: [PATCH 03/16] fix(cfg): resolve temporary value borrow error in has_child_of_kind Use a let binding for the tree cursor instead of borrowing a temporary, fixing E0716 compilation error on all platforms. --- crates/codegraph-core/src/cfg.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/codegraph-core/src/cfg.rs b/crates/codegraph-core/src/cfg.rs index b9fe422f..70885fbd 100644 --- a/crates/codegraph-core/src/cfg.rs +++ b/crates/codegraph-core/src/cfg.rs @@ -1137,8 +1137,8 @@ impl<'a> CfgBuilder<'a> { // ─── Helpers ──────────────────────────────────────────────────────────── fn has_child_of_kind(node: &Node, kind: &str) -> bool { - let cursor = &mut node.walk(); - node.children(cursor).any(|c| c.kind() == kind) + let mut cursor = node.walk(); + node.children(&mut cursor).any(|c| c.kind() == kind) } fn node_line(node: &Node) -> u32 { From d642c8d944ed02e86b1821ac2eda00553262b59f Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Wed, 25 Mar 2026 11:28:37 -0600 Subject: [PATCH 04/16] fix(cfg): remove unnecessary parentheses in allCfgNative check Biome formatter flagged the redundant grouping parens around the optional-chain expression. --- src/features/cfg.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/features/cfg.ts b/src/features/cfg.ts index 708c9073..1039e968 100644 --- a/src/features/cfg.ts +++ b/src/features/cfg.ts @@ -260,7 +260,7 @@ function allCfgNative(fileSymbols: Map): boolean { if (d.kind !== 'function' && d.kind !== 'method') continue; if (!d.line) continue; // cfg === null means no body (expected), cfg with empty blocks means not computed - if (d.cfg !== null && !(d.cfg?.blocks?.length)) return false; + if (d.cfg !== null && !d.cfg?.blocks?.length) return false; } } return true; From 52422eb999be829b71fe4536e4b830a6f67e4a16 Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Wed, 25 Mar 2026 11:28:51 -0600 Subject: [PATCH 05/16] fix(test): move hasGoRangeFix to beforeAll, use ctx.skip() for skipped tests - Move hasGoRangeFix computation into beforeAll where nativeResults is already populated, fixing the IIFE evaluation-order bug that always yielded false. - Replace bare return statements with ctx.skip() so Vitest reports skipped tests explicitly instead of as silent passes. - Fix inaccurate test description: complex-dowhile uses early return, not break. --- tests/parsers/cfg-all-langs.test.ts | 54 +++++++++++++++++++---------- 1 file changed, 35 insertions(+), 19 deletions(-) diff --git a/tests/parsers/cfg-all-langs.test.ts b/tests/parsers/cfg-all-langs.test.ts index af8c301f..22bf1cdb 100644 --- a/tests/parsers/cfg-all-langs.test.ts +++ b/tests/parsers/cfg-all-langs.test.ts @@ -463,6 +463,8 @@ describe.skipIf(!canTestNativeCfg || !hasFixedCfg)('native vs WASM CFG parity', '.php': 'php', }; + let hasGoRangeFix = false; + beforeAll(async () => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-cfg-parity-')); const srcDir = path.join(tmpDir, 'src'); @@ -481,22 +483,18 @@ describe.skipIf(!canTestNativeCfg || !hasFixedCfg)('native vs WASM CFG parity', } parsers = await createParsers(); + + // Determine if the loaded native binary includes the range_clause fix. + // Must be computed here (after nativeResults is populated), not at describe() registration time. + const goSymbols = nativeResults.get('src/fixture.go'); + const goDef = goSymbols?.definitions.find((d: any) => d.name === 'process'); + hasGoRangeFix = goDef?.cfg?.edges?.some((e: any) => e.kind === 'loop_exit') ?? false; }); afterAll(() => { if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true }); }); - // Go for-range and Ruby loop parity depend on the range_clause fix in cfg.rs. - // Detect: a Go for-range should produce loop_exit (bounded), not just loop_back (infinite). - const hasGoRangeFix = (() => { - const symbols = nativeResults.get('src/fixture.go'); - if (!symbols) return false; - const def = symbols.definitions.find((d: any) => d.name === 'process'); - if (!def?.cfg?.edges) return false; - return def.cfg.edges.some((e: any) => e.kind === 'loop_exit'); - })(); - const parityTests = [ { file: 'fixture.js', ext: '.js', funcPattern: /processItems/ }, { file: 'fixture.py', ext: '.py', funcPattern: /process/ }, @@ -509,12 +507,18 @@ describe.skipIf(!canTestNativeCfg || !hasFixedCfg)('native vs WASM CFG parity', ]; for (const { file, ext, funcPattern, requiresFix } of parityTests) { - test(`parity: ${file} — native vs WASM block/edge counts match`, () => { - if (requiresFix && !hasGoRangeFix) return; // Skip until native binary has range_clause fix + test(`parity: ${file} — native vs WASM block/edge counts match`, (ctx) => { + if (requiresFix && !hasGoRangeFix) { + ctx.skip(); + return; + } const relPath = `src/${file}`; const symbols = nativeResults.get(relPath); - if (!symbols) return; + if (!symbols) { + ctx.skip(); + return; + } const langId = LANG_MAP[ext]; const complexityRules = COMPLEXITY_RULES.get(langId); @@ -596,28 +600,40 @@ describe.skipIf(!canTestNativeCfg || !hasFixedCfg)( const complexTests = [ { file: 'complex-trycatch.js', funcPattern: /handleRequest/, desc: 'try/catch/finally' }, { file: 'complex-switch.js', funcPattern: /classify/, desc: 'switch/case/default' }, - { file: 'complex-dowhile.js', funcPattern: /retry/, desc: 'do-while with break' }, + { file: 'complex-dowhile.js', funcPattern: /retry/, desc: 'do-while with early return' }, { file: 'complex-nested.js', funcPattern: /matrix/, desc: 'nested for + continue' }, { file: 'complex-labeled.js', funcPattern: /search/, desc: 'labeled break' }, ]; for (const { file, funcPattern, desc } of complexTests) { - test(`parity: ${desc} — native vs WASM block/edge counts match`, () => { + test(`parity: ${desc} — native vs WASM block/edge counts match`, (ctx) => { const relPath = `src/${file}`; const symbols = nativeResults.get(relPath); - if (!symbols) return; + if (!symbols) { + ctx.skip(); + return; + } const langId = 'javascript'; const complexityRules = COMPLEXITY_RULES.get(langId); - if (!complexityRules) return; + if (!complexityRules) { + ctx.skip(); + return; + } const absPath = path.join(tmpDir, relPath); const parser = getParser(parsers, absPath); - if (!parser) return; + if (!parser) { + ctx.skip(); + return; + } const code = fs.readFileSync(absPath, 'utf-8'); const tree = parser.parse(code); - if (!tree) return; + if (!tree) { + ctx.skip(); + return; + } const funcDefs = symbols.definitions.filter( (d: any) => (d.kind === 'function' || d.kind === 'method') && funcPattern.test(d.name), From 822e922638aaea140244cc298e2575d6129a0e4b Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Wed, 25 Mar 2026 11:38:54 -0600 Subject: [PATCH 06/16] fix(cfg): bind iterator result to extend cursor lifetime The iterator returned by node.children() borrows the cursor, but the cursor is dropped at the end of the statement when the result is returned directly. Binding the result to a local variable ensures the cursor outlives the iterator. --- crates/codegraph-core/src/cfg.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/codegraph-core/src/cfg.rs b/crates/codegraph-core/src/cfg.rs index 70885fbd..63d1a690 100644 --- a/crates/codegraph-core/src/cfg.rs +++ b/crates/codegraph-core/src/cfg.rs @@ -1138,7 +1138,8 @@ impl<'a> CfgBuilder<'a> { fn has_child_of_kind(node: &Node, kind: &str) -> bool { let mut cursor = node.walk(); - node.children(&mut cursor).any(|c| c.kind() == kind) + let result = node.children(&mut cursor).any(|c| c.kind() == kind); + result } fn node_line(node: &Node) -> u32 { From 23cafb9fdbca8c5d5d575d270d6741e8f0030159 Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Wed, 25 Mar 2026 11:57:42 -0600 Subject: [PATCH 07/16] fix(cfg): align allCfgNative with initCfgParsers, clean stale CFG rows on fast path (#595) - Skip _tree files in allCfgNative to match initCfgParsers behavior, preventing false fast-path suppression in mixed-engine pipelines - Always call deleteCfgForNode in fast path before checking cfg content, ensuring stale rows are cleaned when function bodies are removed - Replace bare return guards with ctx.skip() in parity tests so Vitest reports them as skipped rather than silently passed Impact: 2 functions changed, 6 affected --- src/features/cfg.ts | 6 +++++- tests/parsers/cfg-all-langs.test.ts | 15 ++++++++++++--- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/src/features/cfg.ts b/src/features/cfg.ts index 1039e968..6c070d3f 100644 --- a/src/features/cfg.ts +++ b/src/features/cfg.ts @@ -253,6 +253,7 @@ function persistCfg( */ function allCfgNative(fileSymbols: Map): boolean { for (const [relPath, symbols] of fileSymbols) { + if (symbols._tree) continue; // already parsed via WASM; will use _tree in slow path const ext = path.extname(relPath).toLowerCase(); if (!CFG_EXTENSIONS.has(ext)) continue; @@ -303,12 +304,15 @@ export async function buildCFGData( if (allNative) { for (const def of symbols.definitions) { if (def.kind !== 'function' && def.kind !== 'method') continue; - if (!def.line || !def.cfg?.blocks?.length) continue; + if (!def.line) continue; const nodeId = getFunctionNodeId(db, def.name, relPath, def.line); if (!nodeId) continue; + // Always delete stale CFG rows (handles body-removed case) deleteCfgForNode(db, nodeId); + if (!def.cfg?.blocks?.length) continue; + persistCfg( def.cfg as unknown as { blocks: CfgBuildBlock[]; edges: CfgBuildEdge[] }, nodeId, diff --git a/tests/parsers/cfg-all-langs.test.ts b/tests/parsers/cfg-all-langs.test.ts index 22bf1cdb..49ebdfcf 100644 --- a/tests/parsers/cfg-all-langs.test.ts +++ b/tests/parsers/cfg-all-langs.test.ts @@ -522,16 +522,25 @@ describe.skipIf(!canTestNativeCfg || !hasFixedCfg)('native vs WASM CFG parity', const langId = LANG_MAP[ext]; const complexityRules = COMPLEXITY_RULES.get(langId); - if (!complexityRules) return; + if (!complexityRules) { + ctx.skip(); + return; + } // Parse with WASM const absPath = path.join(tmpDir, relPath); const parser = getParser(parsers, absPath); - if (!parser) return; + if (!parser) { + ctx.skip(); + return; + } const code = fs.readFileSync(absPath, 'utf-8'); const tree = parser.parse(code); - if (!tree) return; + if (!tree) { + ctx.skip(); + return; + } const funcDefs = symbols.definitions.filter( (d) => (d.kind === 'function' || d.kind === 'method') && funcPattern.test(d.name), From 979e1f509d14742e47ed09fbf729e4d3ae94308e Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Wed, 25 Mar 2026 12:45:39 -0600 Subject: [PATCH 08/16] fix(cfg): prevent fast path from wiping CFG in WASM-only builds (#595) When all files have _tree set (WASM-only builds), allCfgNative() vacuously returns true. The fast path then deletes all existing CFG rows without recomputing them, since _tree files have no native CFG. Guard the fast path with !symbols._tree so _tree files fall through to the slow path where getTreeAndLang uses the pre-parsed tree directly. Impact: 1 functions changed, 5 affected --- src/features/cfg.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/features/cfg.ts b/src/features/cfg.ts index 6c070d3f..67fbf202 100644 --- a/src/features/cfg.ts +++ b/src/features/cfg.ts @@ -300,8 +300,10 @@ export async function buildCFGData( const ext = path.extname(relPath).toLowerCase(); if (!CFG_EXTENSIONS.has(ext)) continue; - // Native fast path: skip tree/visitor setup when all CFG is pre-computed - if (allNative) { + // Native fast path: skip tree/visitor setup when all CFG is pre-computed. + // Only apply to files without _tree — files with _tree were WASM-parsed + // and need the slow path (visitor) to compute CFG. + if (allNative && !symbols._tree) { for (const def of symbols.definitions) { if (def.kind !== 'function' && def.kind !== 'method') continue; if (!def.line) continue; From a34e3c859a62c54abd646ee2294ed4f50b6f7937 Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Wed, 25 Mar 2026 12:55:03 -0600 Subject: [PATCH 09/16] refactor(cfg): simplify has_child_of_kind by returning directly (#595) Impact: 1 functions changed, 1 affected --- crates/codegraph-core/src/cfg.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/crates/codegraph-core/src/cfg.rs b/crates/codegraph-core/src/cfg.rs index 63d1a690..70885fbd 100644 --- a/crates/codegraph-core/src/cfg.rs +++ b/crates/codegraph-core/src/cfg.rs @@ -1138,8 +1138,7 @@ impl<'a> CfgBuilder<'a> { fn has_child_of_kind(node: &Node, kind: &str) -> bool { let mut cursor = node.walk(); - let result = node.children(&mut cursor).any(|c| c.kind() == kind); - result + node.children(&mut cursor).any(|c| c.kind() == kind) } fn node_line(node: &Node) -> u32 { From 50893de23ea0c9116bc03be0695052b3ee908a25 Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Wed, 25 Mar 2026 12:55:13 -0600 Subject: [PATCH 10/16] docs(test): clarify hasGoRangeFix heuristic false-positive risk (#595) --- tests/parsers/cfg-all-langs.test.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/parsers/cfg-all-langs.test.ts b/tests/parsers/cfg-all-langs.test.ts index 49ebdfcf..a64d134c 100644 --- a/tests/parsers/cfg-all-langs.test.ts +++ b/tests/parsers/cfg-all-langs.test.ts @@ -486,6 +486,12 @@ describe.skipIf(!canTestNativeCfg || !hasFixedCfg)('native vs WASM CFG parity', // Determine if the loaded native binary includes the range_clause fix. // Must be computed here (after nativeResults is populated), not at describe() registration time. + // Note: this heuristic checks for any loop_exit edge in the Go `process` function. + // If the fixture also contains a C-style for loop with a condition, that loop emits + // loop_exit regardless of the range_clause fix — causing a false positive (test runs + // instead of skipping on an unpatched binary). The current fixture only has a range loop, + // so this is safe. If fixture.go gains additional loop types, scope the check to + // range-specific block labels. const goSymbols = nativeResults.get('src/fixture.go'); const goDef = goSymbols?.definitions.find((d: any) => d.name === 'process'); hasGoRangeFix = goDef?.cfg?.edges?.some((e: any) => e.kind === 'loop_exit') ?? false; From d4120ae81b746c337e0af91e7f6c9f2f14d3f3c2 Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Wed, 25 Mar 2026 12:58:16 -0600 Subject: [PATCH 11/16] fix(cfg): revert has_child_of_kind simplification (borrow checker) (#595) The intermediate `result` variable is required because `cursor` must outlive the iterator returned by `node.children()`. Returning directly causes a borrow-after-drop error since cursor is dropped before the temporary iterator's destructor runs. Impact: 1 functions changed, 1 affected --- crates/codegraph-core/src/cfg.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/codegraph-core/src/cfg.rs b/crates/codegraph-core/src/cfg.rs index 70885fbd..63d1a690 100644 --- a/crates/codegraph-core/src/cfg.rs +++ b/crates/codegraph-core/src/cfg.rs @@ -1138,7 +1138,8 @@ impl<'a> CfgBuilder<'a> { fn has_child_of_kind(node: &Node, kind: &str) -> bool { let mut cursor = node.walk(); - node.children(&mut cursor).any(|c| c.kind() == kind) + let result = node.children(&mut cursor).any(|c| c.kind() == kind); + result } fn node_line(node: &Node) -> u32 { From 9f01479f7cb2b14c5e8882419d48f19592b91f5e Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Wed, 25 Mar 2026 13:03:25 -0600 Subject: [PATCH 12/16] refactor(cfg): extract hasNativeCfgForFile shared predicate (#595) The native-CFG check was duplicated in allCfgNative, initCfgParsers, and getTreeAndLang. Extract into a single hasNativeCfgForFile helper so all three callers stay in sync automatically. Impact: 4 functions changed, 4 affected --- src/features/cfg.ts | 29 ++++++++++++++--------------- 1 file changed, 14 insertions(+), 15 deletions(-) diff --git a/src/features/cfg.ts b/src/features/cfg.ts index 67fbf202..16e2104d 100644 --- a/src/features/cfg.ts +++ b/src/features/cfg.ts @@ -84,6 +84,17 @@ interface FileSymbols { _langId?: string; } +/** + * Check whether all function/method definitions in a single file already + * have native CFG data (blocks populated by the Rust extractor). + * cfg === null means no body (expected); cfg with empty blocks means not computed. + */ +function hasNativeCfgForFile(symbols: FileSymbols): boolean { + return symbols.definitions + .filter((d) => (d.kind === 'function' || d.kind === 'method') && d.line) + .every((d) => d.cfg === null || (d.cfg?.blocks?.length ?? 0) > 0); +} + async function initCfgParsers( fileSymbols: Map, ): Promise<{ parsers: unknown; getParserFn: unknown }> { @@ -93,10 +104,7 @@ async function initCfgParsers( if (!symbols._tree) { const ext = path.extname(relPath).toLowerCase(); if (CFG_EXTENSIONS.has(ext)) { - const hasNativeCfg = symbols.definitions - .filter((d) => (d.kind === 'function' || d.kind === 'method') && d.line) - .every((d) => d.cfg === null || (d.cfg?.blocks?.length ?? 0) > 0); - if (!hasNativeCfg) { + if (!hasNativeCfgForFile(symbols)) { needsFallback = true; break; } @@ -129,11 +137,7 @@ function getTreeAndLang( let tree = symbols._tree; let langId = symbols._langId; - const allNative = symbols.definitions - .filter((d) => (d.kind === 'function' || d.kind === 'method') && d.line) - .every((d) => d.cfg === null || (d.cfg?.blocks?.length ?? 0) > 0); - - if (!tree && !allNative) { + if (!tree && !hasNativeCfgForFile(symbols)) { if (!getParserFn) return null; langId = extToLang.get(ext); if (!langId || !CFG_RULES.has(langId)) return null; @@ -257,12 +261,7 @@ function allCfgNative(fileSymbols: Map): boolean { const ext = path.extname(relPath).toLowerCase(); if (!CFG_EXTENSIONS.has(ext)) continue; - for (const d of symbols.definitions) { - if (d.kind !== 'function' && d.kind !== 'method') continue; - if (!d.line) continue; - // cfg === null means no body (expected), cfg with empty blocks means not computed - if (d.cfg !== null && !d.cfg?.blocks?.length) return false; - } + if (!hasNativeCfgForFile(symbols)) return false; } return true; } From 0453948e7ca00762c7f23b4547960ff9e45974b8 Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Wed, 25 Mar 2026 14:53:39 -0600 Subject: [PATCH 13/16] =?UTF-8?q?fix(cfg):=20address=20review=20feedback?= =?UTF-8?q?=20=E2=80=94=20vacuous=20allCfgNative,=20stale=20CFG=20cleanup,?= =?UTF-8?q?=20doc=20comment=20(#595)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - allCfgNative now returns false (not vacuous true) when fileSymbols is empty, all-_tree, or contains only non-CFG extensions. Tracks hasCfgFile sentinel to distinguish "all native" from "nothing to process". - Fast path adds !symbols._tree guard so WASM-parsed files fall through to the slow path (prevents CFG wipe on WASM-only builds). - Both fast and slow paths now unconditionally call deleteCfgForNode before the blocks-length guard, cleaning stale cfg_blocks/cfg_edges rows when a function body is removed. - Added doc comment to has_child_of_kind clarifying shallow (one-level) direct-child check semantics. Impact: 2 functions changed, 6 affected --- crates/codegraph-core/src/cfg.rs | 2 ++ src/features/cfg.ts | 19 ++++++++++++++----- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/crates/codegraph-core/src/cfg.rs b/crates/codegraph-core/src/cfg.rs index b9fe422f..13188e52 100644 --- a/crates/codegraph-core/src/cfg.rs +++ b/crates/codegraph-core/src/cfg.rs @@ -1136,6 +1136,8 @@ impl<'a> CfgBuilder<'a> { // ─── Helpers ──────────────────────────────────────────────────────────── +/// Returns true if `node` has a direct child whose node kind equals `kind`. +/// This is a shallow (one-level) check — it does not recurse into grandchildren. fn has_child_of_kind(node: &Node, kind: &str) -> bool { let cursor = &mut node.walk(); node.children(cursor).any(|c| c.kind() == kind) diff --git a/src/features/cfg.ts b/src/features/cfg.ts index 708c9073..9ed9292f 100644 --- a/src/features/cfg.ts +++ b/src/features/cfg.ts @@ -252,18 +252,23 @@ function persistCfg( * When true, the WASM parser and JS CFG visitor can be fully bypassed. */ function allCfgNative(fileSymbols: Map): boolean { + let hasCfgFile = false; for (const [relPath, symbols] of fileSymbols) { + if (symbols._tree) continue; // WASM-parsed file — needs visitor path const ext = path.extname(relPath).toLowerCase(); if (!CFG_EXTENSIONS.has(ext)) continue; + hasCfgFile = true; for (const d of symbols.definitions) { if (d.kind !== 'function' && d.kind !== 'method') continue; if (!d.line) continue; // cfg === null means no body (expected), cfg with empty blocks means not computed - if (d.cfg !== null && !(d.cfg?.blocks?.length)) return false; + if (d.cfg !== null && !d.cfg?.blocks?.length) return false; } } - return true; + // Return false when no CFG files found (empty map, all _tree, or all non-CFG + // extensions) to avoid vacuously triggering the fast path. + return hasCfgFile; } export async function buildCFGData( @@ -300,15 +305,18 @@ export async function buildCFGData( if (!CFG_EXTENSIONS.has(ext)) continue; // Native fast path: skip tree/visitor setup when all CFG is pre-computed - if (allNative) { + if (allNative && !symbols._tree) { for (const def of symbols.definitions) { if (def.kind !== 'function' && def.kind !== 'method') continue; - if (!def.line || !def.cfg?.blocks?.length) continue; + if (!def.line) continue; const nodeId = getFunctionNodeId(db, def.name, relPath, def.line); if (!nodeId) continue; + // Always purge stale rows (handles body-removed case) deleteCfgForNode(db, nodeId); + if (!def.cfg?.blocks?.length) continue; + persistCfg( def.cfg as unknown as { blocks: CfgBuildBlock[]; edges: CfgBuildEdge[] }, nodeId, @@ -352,9 +360,10 @@ export async function buildCFGData( if (r) cfg = { blocks: r.blocks, edges: r.edges }; } + // Always purge stale rows (handles body-removed case) + deleteCfgForNode(db, nodeId); if (!cfg || cfg.blocks.length === 0) continue; - deleteCfgForNode(db, nodeId); persistCfg(cfg, nodeId, insertBlock, insertEdge); analyzed++; } From c153eaa030dc96f1b36ed8f6c2193bf631ab21ef Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Wed, 25 Mar 2026 15:15:54 -0600 Subject: [PATCH 14/16] fix(test): guard against silent pass when no defs have CFG blocks (#595) --- tests/parsers/cfg-all-langs.test.ts | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/tests/parsers/cfg-all-langs.test.ts b/tests/parsers/cfg-all-langs.test.ts index a64d134c..4ec526d5 100644 --- a/tests/parsers/cfg-all-langs.test.ts +++ b/tests/parsers/cfg-all-langs.test.ts @@ -552,9 +552,14 @@ describe.skipIf(!canTestNativeCfg || !hasFixedCfg)('native vs WASM CFG parity', (d) => (d.kind === 'function' || d.kind === 'method') && funcPattern.test(d.name), ); - for (const def of funcDefs) { - if (!def.cfg?.blocks?.length) continue; + // Guard: skip rather than silently pass when no defs have CFG blocks populated + const defsWithCfg = funcDefs.filter((d: any) => d.cfg?.blocks?.length); + if (defsWithCfg.length === 0) { + ctx.skip(); + return; + } + for (const def of defsWithCfg) { const funcNode = findFunctionNode(tree.rootNode, def.line, def.endLine, complexityRules); if (!funcNode) continue; @@ -654,9 +659,14 @@ describe.skipIf(!canTestNativeCfg || !hasFixedCfg)( (d: any) => (d.kind === 'function' || d.kind === 'method') && funcPattern.test(d.name), ); - for (const def of funcDefs) { - if (!def.cfg?.blocks?.length) continue; + // Guard: skip rather than silently pass when no defs have CFG blocks populated + const defsWithCfg = funcDefs.filter((d: any) => d.cfg?.blocks?.length); + if (defsWithCfg.length === 0) { + ctx.skip(); + return; + } + for (const def of defsWithCfg) { const funcNode = findFunctionNode(tree.rootNode, def.line, def.endLine, complexityRules); if (!funcNode) continue; From 501bcfb62c8e23c2effd4dbfef1167f1103c2584 Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Wed, 25 Mar 2026 15:16:09 -0600 Subject: [PATCH 15/16] docs(cfg): clarify parsers=null invariant for _tree files in fast path (#595) Impact: 1 functions changed, 5 affected --- src/features/cfg.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/features/cfg.ts b/src/features/cfg.ts index 142fee97..7485e971 100644 --- a/src/features/cfg.ts +++ b/src/features/cfg.ts @@ -329,6 +329,9 @@ export async function buildCFGData( continue; } + // When allNative=true, parsers/getParserFn are null. This is safe because + // _tree files use symbols._tree directly in getTreeAndLang (the parser + // code path is never reached). Non-_tree files are handled by the fast path above. const treeLang = getTreeAndLang(symbols, relPath, rootDir, extToLang, parsers, getParserFn); if (!treeLang) continue; const { tree, langId } = treeLang; From edb9985beb82e31a2d2fe4d2679af02cacab6a6e Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Wed, 25 Mar 2026 16:08:35 -0600 Subject: [PATCH 16/16] fix(test): guard against findFunctionNode returning null for all defs (#595) Both the parity test loop and complex patterns suite could silently pass with zero assertions if findFunctionNode returned null for every entry in defsWithCfg (e.g., due to line-number offset mismatch between native and WASM parsers). Added defsWithNode guard that calls ctx.skip() when no function nodes are found, matching the existing defsWithCfg pattern. --- tests/parsers/cfg-all-langs.test.ts | 33 +++++++++++++++++++++++------ 1 file changed, 27 insertions(+), 6 deletions(-) diff --git a/tests/parsers/cfg-all-langs.test.ts b/tests/parsers/cfg-all-langs.test.ts index 4ec526d5..aca6a3a5 100644 --- a/tests/parsers/cfg-all-langs.test.ts +++ b/tests/parsers/cfg-all-langs.test.ts @@ -559,10 +559,21 @@ describe.skipIf(!canTestNativeCfg || !hasFixedCfg)('native vs WASM CFG parity', return; } - for (const def of defsWithCfg) { - const funcNode = findFunctionNode(tree.rootNode, def.line, def.endLine, complexityRules); - if (!funcNode) continue; + // Guard: skip rather than silently pass when findFunctionNode returns null for all defs + // (e.g., due to line-number offset mismatch between native and WASM parsers) + const defsWithNode = defsWithCfg + .map((def) => ({ + def, + funcNode: findFunctionNode(tree.rootNode, def.line, def.endLine, complexityRules), + })) + .filter(({ funcNode }) => funcNode !== null); + + if (defsWithNode.length === 0) { + ctx.skip(); + return; + } + for (const { def, funcNode } of defsWithNode) { const wasmCfg = buildFunctionCFG(funcNode, langId); // Block counts should match @@ -666,10 +677,20 @@ describe.skipIf(!canTestNativeCfg || !hasFixedCfg)( return; } - for (const def of defsWithCfg) { - const funcNode = findFunctionNode(tree.rootNode, def.line, def.endLine, complexityRules); - if (!funcNode) continue; + // Guard: skip rather than silently pass when findFunctionNode returns null for all defs + const defsWithNode = defsWithCfg + .map((def: any) => ({ + def, + funcNode: findFunctionNode(tree.rootNode, def.line, def.endLine, complexityRules), + })) + .filter(({ funcNode }) => funcNode !== null); + + if (defsWithNode.length === 0) { + ctx.skip(); + return; + } + for (const { def, funcNode } of defsWithNode) { const wasmCfg = buildFunctionCFG(funcNode, langId); expect(def.cfg.blocks.length, `${desc}: block count mismatch`).toBe(