diff --git a/crates/codegraph-core/src/build_pipeline.rs b/crates/codegraph-core/src/build_pipeline.rs index 8821de71..e9899863 100644 --- a/crates/codegraph-core/src/build_pipeline.rs +++ b/crates/codegraph-core/src/build_pipeline.rs @@ -67,6 +67,16 @@ pub struct BuildPipelineResult { pub changed_count: usize, pub removed_count: usize, pub is_full_build: bool, + /// Full set of changed files including reverse-dep files. Used by the JS + /// structure fallback path so it can update metrics for files whose edges + /// changed even though their content didn't. `None` for full builds. + #[serde(skip_serializing_if = "Option::is_none")] + pub structure_scope: Option>, + /// Whether the Rust pipeline handled the structure phase (directory nodes, + /// contains edges, file metrics). True when the small-incremental fast path + /// ran (≤5 changed files, >20 existing files). When false, the JS caller + /// must run its own structure phase as a post-processing step. + pub structure_handled: bool, } /// Normalize path to forward slashes. @@ -163,6 +173,8 @@ pub fn run_pipeline( changed_count: 0, removed_count: 0, is_full_build: false, + structure_scope: Some(vec![]), + structure_handled: true, }); } @@ -352,15 +364,6 @@ pub fn run_pipeline( &line_count_map, &file_symbols, ); - } else { - // Emit a debug-level warning so users of `codegraph stats` know - // structure metrics were not updated on this build path. - eprintln!( - "[codegraph] note: structure metrics skipped (native fast-path not applicable — \ - {} changed files, full_build={}). Run JS pipeline for full structure.", - parse_changes.len(), - change_result.is_full_build, - ); } // For full/larger builds, the JS fallback handles full structure via // `features/structure.ts`. The Rust orchestrator handles the fast path @@ -417,6 +420,8 @@ pub fn run_pipeline( changed_count: parse_changes.len(), removed_count: change_result.removed.len(), is_full_build: change_result.is_full_build, + structure_scope: changed_file_list.clone(), + structure_handled: use_fast_path, }) } diff --git a/src/domain/graph/builder/pipeline.ts b/src/domain/graph/builder/pipeline.ts index c9c7b5b2..8c1fb7bf 100644 --- a/src/domain/graph/builder/pipeline.ts +++ b/src/domain/graph/builder/pipeline.ts @@ -263,6 +263,10 @@ interface NativeOrchestratorResult { changedCount?: number; removedCount?: number; isFullBuild?: boolean; + /** Full changed files including reverse-dep files — used by JS structure fallback. */ + structureScope?: string[]; + /** Whether the Rust pipeline handled the structure phase (small-incremental fast path). */ + structureHandled?: boolean; } // ── Native orchestrator helpers ─────────────────────────────────────── @@ -295,13 +299,26 @@ function handoffWalAfterNativeBuild(ctx: PipelineContext): boolean { } } -/** Reconstruct fileSymbols from the DB after a native orchestrator build. */ -function reconstructFileSymbolsFromDb(ctx: PipelineContext): Map { - const allFileRows = ctx.db - .prepare( - 'SELECT file, name, kind, line, end_line as endLine FROM nodes WHERE file IS NOT NULL ORDER BY file, line', - ) - .all() as { +/** + * Reconstruct fileSymbols from the DB after a native orchestrator build. + * When `scopeFiles` is provided, only loads those files (for analysis-only). + * When omitted, loads all files (needed for structure rebuilds). + */ +function reconstructFileSymbolsFromDb( + ctx: PipelineContext, + scopeFiles?: string[], +): Map { + let query = + 'SELECT file, name, kind, line, end_line as endLine FROM nodes WHERE file IS NOT NULL'; + const params: string[] = []; + if (scopeFiles && scopeFiles.length > 0) { + const placeholders = scopeFiles.map(() => '?').join(','); + query += ` AND file IN (${placeholders})`; + params.push(...scopeFiles); + } + query += ' ORDER BY file, line'; + + const rows = ctx.db.prepare(query).all(...params) as { file: string; name: string; kind: string; @@ -309,9 +326,9 @@ function reconstructFileSymbolsFromDb(ctx: PipelineContext): Map(); - for (const row of allFileRows) { - let entry = allFileSymbols.get(row.file); + const fileSymbols = new Map(); + for (const row of rows) { + let entry = fileSymbols.get(row.file); if (!entry) { entry = { definitions: [], @@ -321,7 +338,7 @@ function reconstructFileSymbolsFromDb(ctx: PipelineContext): Map, + isFullBuild: boolean, + changedFiles: string[] | undefined, ): Promise { const structureStart = performance.now(); try { @@ -396,7 +419,10 @@ async function runPostNativeStructure( lineCountMap.set(row.file, row.line_count); } - const changedFilePaths = null; // full rebuild — every directory gets nodes + // Full builds need null (rebuild everything). Incremental builds pass the + // changed file list so buildStructure only updates those files' metrics + // and contains edges — matching the JS pipeline's medium-incremental path. + const changedFilePaths = isFullBuild || !changedFiles?.length ? null : changedFiles; const { buildStructure: buildStructureFn } = (await import( '../../../features/structure.js' )) as { @@ -417,7 +443,9 @@ async function runPostNativeStructure( directories, changedFilePaths, ); - debug('Structure phase completed after native orchestrator'); + debug( + `Structure phase completed after native orchestrator${changedFilePaths ? ` (${changedFilePaths.length} files)` : ' (full)'}`, + ); } catch (err) { warn(`Structure phase failed after native build: ${toErrorMessage(err)}`); } @@ -573,8 +601,10 @@ async function tryNativeOrchestrator( ctx.opts.complexity !== false || ctx.opts.cfg !== false || ctx.opts.dataflow !== false; - // Always run JS structure — native fast-path guard can't be reliably detected. - const needsStructure = true; + // Skip JS structure when the Rust pipeline's small-incremental fast path + // already handled it. For full builds and large incrementals where Rust + // skipped structure, we must run the JS fallback. + const needsStructure = !result.structureHandled; if (needsAnalysis || needsStructure) { if (!handoffWalAfterNativeBuild(ctx)) { @@ -582,14 +612,23 @@ async function tryNativeOrchestrator( return formatNativeTimingResult(p, 0, analysisTiming); } - const allFileSymbols = reconstructFileSymbolsFromDb(ctx); + // When structure was handled by Rust, we only need changed files for + // analysis — no need to load the entire graph from DB. When structure + // was NOT handled, we need all files to build the complete directory tree. + const scopeFiles = needsStructure ? undefined : result.changedFiles; + const fileSymbols = reconstructFileSymbolsFromDb(ctx, scopeFiles); if (needsStructure) { - structurePatchMs = await runPostNativeStructure(ctx, allFileSymbols); + structurePatchMs = await runPostNativeStructure( + ctx, + fileSymbols, + !!result.isFullBuild, + result.structureScope ?? result.changedFiles, + ); } if (needsAnalysis) { - analysisTiming = await runPostNativeAnalysis(ctx, allFileSymbols, result.changedFiles); + analysisTiming = await runPostNativeAnalysis(ctx, fileSymbols, result.changedFiles); } }