diff --git a/src/ast-analysis/engine.js b/src/ast-analysis/engine.js index 6775a7f0..981ec514 100644 --- a/src/ast-analysis/engine.js +++ b/src/ast-analysis/engine.js @@ -38,6 +38,7 @@ import { createDataflowVisitor } from './visitors/dataflow-visitor.js'; // ─── Extension sets for quick language-support checks ──────────────────── const CFG_EXTENSIONS = buildExtensionSet(CFG_RULES); +const COMPLEXITY_EXTENSIONS = buildExtensionSet(COMPLEXITY_RULES); const DATAFLOW_EXTENSIONS = buildExtensionSet(DATAFLOW_RULES); const WALK_EXTENSIONS = buildExtensionSet(AST_TYPE_MAPS); @@ -74,15 +75,34 @@ export async function runAnalyses(db, fileSymbols, rootDir, opts, engineOpts) { const extToLang = buildExtToLangMap(); // ── WASM pre-parse for files that need it ─────────────────────────── - // CFG now runs as a visitor in the unified walk, so only dataflow - // triggers WASM pre-parse when no tree exists. - if (doDataflow) { + // The native engine only handles parsing (symbols, calls, imports). + // Complexity, CFG, and dataflow all require a WASM tree-sitter tree + // for their visitor walks. Without this, incremental rebuilds on the + // native engine silently lose these analyses for changed files (#468). + if (doComplexity || doCfg || doDataflow) { let needsWasmTrees = false; for (const [relPath, symbols] of fileSymbols) { if (symbols._tree) continue; const ext = path.extname(relPath).toLowerCase(); - - if (!symbols.dataflow && DATAFLOW_EXTENSIONS.has(ext)) { + const defs = symbols.definitions || []; + + const needsComplexity = + doComplexity && + COMPLEXITY_EXTENSIONS.has(ext) && + defs.some((d) => (d.kind === 'function' || d.kind === 'method') && d.line && !d.complexity); + const needsCfg = + doCfg && + CFG_EXTENSIONS.has(ext) && + defs.some( + (d) => + (d.kind === 'function' || d.kind === 'method') && + d.line && + d.cfg !== null && + !Array.isArray(d.cfg?.blocks), + ); + const needsDataflow = doDataflow && !symbols.dataflow && DATAFLOW_EXTENSIONS.has(ext); + + if (needsComplexity || needsCfg || needsDataflow) { needsWasmTrees = true; break; } @@ -320,7 +340,7 @@ export async function runAnalyses(db, fileSymbols, rootDir, opts, engineOpts) { if (doAst) { const t0 = performance.now(); try { - const { buildAstNodes } = await import('../ast.js'); + const { buildAstNodes } = await import('../features/ast.js'); await buildAstNodes(db, fileSymbols, rootDir, engineOpts); } catch (err) { debug(`buildAstNodes failed: ${err.message}`); @@ -331,7 +351,7 @@ export async function runAnalyses(db, fileSymbols, rootDir, opts, engineOpts) { if (doComplexity) { const t0 = performance.now(); try { - const { buildComplexityMetrics } = await import('../complexity.js'); + const { buildComplexityMetrics } = await import('../features/complexity.js'); await buildComplexityMetrics(db, fileSymbols, rootDir, engineOpts); } catch (err) { debug(`buildComplexityMetrics failed: ${err.message}`); @@ -342,7 +362,7 @@ export async function runAnalyses(db, fileSymbols, rootDir, opts, engineOpts) { if (doCfg) { const t0 = performance.now(); try { - const { buildCFGData } = await import('../cfg.js'); + const { buildCFGData } = await import('../features/cfg.js'); await buildCFGData(db, fileSymbols, rootDir, engineOpts); } catch (err) { debug(`buildCFGData failed: ${err.message}`); @@ -353,7 +373,7 @@ export async function runAnalyses(db, fileSymbols, rootDir, opts, engineOpts) { if (doDataflow) { const t0 = performance.now(); try { - const { buildDataflowEdges } = await import('../dataflow.js'); + const { buildDataflowEdges } = await import('../features/dataflow.js'); await buildDataflowEdges(db, fileSymbols, rootDir, engineOpts); } catch (err) { debug(`buildDataflowEdges failed: ${err.message}`); diff --git a/tests/integration/incremental-parity.test.js b/tests/integration/incremental-parity.test.js index 555975b8..21a2a56c 100644 --- a/tests/integration/incremental-parity.test.js +++ b/tests/integration/incremental-parity.test.js @@ -43,6 +43,49 @@ function readGraph(dbPath) { return { nodes, edges }; } +function readAnalysisTables(dbPath) { + const db = new Database(dbPath, { readonly: true }); + const result = {}; + try { + try { + result.complexity = db + .prepare( + `SELECT fc.node_id, fc.cognitive, fc.cyclomatic, n.name, n.file + FROM function_complexity fc JOIN nodes n ON fc.node_id = n.id + ORDER BY n.name, n.file`, + ) + .all(); + } catch { + result.complexity = []; + } + try { + result.cfgBlocks = db + .prepare( + `SELECT cb.function_node_id, cb.block_index, cb.block_type, n.name, n.file + FROM cfg_blocks cb JOIN nodes n ON cb.function_node_id = n.id + ORDER BY n.name, n.file, cb.block_index`, + ) + .all(); + } catch { + result.cfgBlocks = []; + } + try { + result.dataflow = db + .prepare( + `SELECT d.source_id, d.kind, n.name, n.file + FROM dataflow d JOIN nodes n ON d.source_id = n.id + ORDER BY n.name, n.file, d.kind`, + ) + .all(); + } catch { + result.dataflow = []; + } + } finally { + db.close(); + } + return result; +} + describe('Incremental build parity: full vs incremental', () => { let fullDir; let incrDir; @@ -103,4 +146,25 @@ describe('Incremental build parity: full vs incremental', () => { const incrGraph = readGraph(path.join(incrDir, '.codegraph', 'graph.db')); expect(incrGraph.edges).toEqual(fullGraph.edges); }); + + it('preserves complexity metrics for changed file (#468)', () => { + const fullAnalysis = readAnalysisTables(path.join(fullDir, '.codegraph', 'graph.db')); + const incrAnalysis = readAnalysisTables(path.join(incrDir, '.codegraph', 'graph.db')); + expect(incrAnalysis.complexity.length).toBeGreaterThan(0); + expect(incrAnalysis.complexity.length).toBe(fullAnalysis.complexity.length); + }); + + it('preserves CFG blocks for changed file (#468)', () => { + const fullAnalysis = readAnalysisTables(path.join(fullDir, '.codegraph', 'graph.db')); + const incrAnalysis = readAnalysisTables(path.join(incrDir, '.codegraph', 'graph.db')); + expect(incrAnalysis.cfgBlocks.length).toBeGreaterThan(0); + expect(incrAnalysis.cfgBlocks.length).toBe(fullAnalysis.cfgBlocks.length); + }); + + it('preserves dataflow edges for changed file (#468)', () => { + const fullAnalysis = readAnalysisTables(path.join(fullDir, '.codegraph', 'graph.db')); + const incrAnalysis = readAnalysisTables(path.join(incrDir, '.codegraph', 'graph.db')); + expect(incrAnalysis.dataflow.length).toBeGreaterThan(0); + expect(incrAnalysis.dataflow.length).toBe(fullAnalysis.dataflow.length); + }); });