diff --git a/src/ast-analysis/visitor.ts b/src/ast-analysis/visitor.ts index e7a8a8a10..f2e72dd23 100644 --- a/src/ast-analysis/visitor.ts +++ b/src/ast-analysis/visitor.ts @@ -11,6 +11,7 @@ * so individual visitors don't need to track traversal state themselves. */ +import { debug } from '../infrastructure/logger.js'; import type { ScopeEntry, TreeSitterNode, @@ -132,6 +133,16 @@ function dispatchExitFunction( } } +/** + * Maximum AST depth before walk() bails out. WASM-backed tree-sitter parsers + * have a smaller stack than native V8 (~64-256 KB). Deep recursion in + * generated code, deeply nested object literals, or pathologically large + * switch statements can exhaust that stack, causing a V8 fatal error / + * segfault (#931). 500 levels is well beyond any realistic hand-written + * code while staying safely inside the WASM stack budget. + */ +const MAX_WALK_DEPTH = 500; + /** Collect finish() results from all visitors into a name-keyed map. */ function collectResults(visitors: Visitor[]): WalkResults { const results: WalkResults = {}; @@ -181,6 +192,10 @@ export function walkWithVisitors( function walk(node: TreeSitterNode | null, depth: number): void { if (!node) return; + if (depth > MAX_WALK_DEPTH) { + debug(`walkWithVisitors: AST depth limit (${MAX_WALK_DEPTH}) hit — subtree truncated`); + return; + } const type = node.type; const isFuncBoundary = allFuncTypes.has(type); diff --git a/src/domain/graph/builder/context.ts b/src/domain/graph/builder/context.ts index f1a0eb0a3..707526989 100644 --- a/src/domain/graph/builder/context.ts +++ b/src/domain/graph/builder/context.ts @@ -63,6 +63,23 @@ export class PipelineContext { nodesByName!: Map; nodesByNameAndFile!: Map; + // ── Reverse-dep edge reconnection (set by detectChanges) ─────────── + /** + * Edges from reverse-dep files to changed files, saved before purge so they + * can be reconnected to new node IDs after insertNodes (#932, #933). + * Eliminates the need to reparse reverse-dep files entirely. + */ + savedReverseDepEdges: Array<{ + sourceId: number; + tgtName: string; + tgtKind: string; + tgtFile: string; + tgtLine: number; + edgeKind: string; + confidence: number; + dynamic: number; + }> = []; + // ── Misc state ───────────────────────────────────────────────────── hasEmbeddings: boolean = false; lineCountMap!: Map; diff --git a/src/domain/graph/builder/pipeline.ts b/src/domain/graph/builder/pipeline.ts index 1607c7638..130d5531a 100644 --- a/src/domain/graph/builder/pipeline.ts +++ b/src/domain/graph/builder/pipeline.ts @@ -781,6 +781,23 @@ async function runPipelineStages(ctx: PipelineContext): Promise { await runAnalyses(ctx); + // Release WASM trees deterministically on the success path — same cleanup + // as the error-path catch block. Without this, trees stay allocated until + // GC collects ctx, holding WASM memory for the rest of the build (#931). + if (ctx.allSymbols?.size > 0) { + for (const [, symbols] of ctx.allSymbols) { + const tree = symbols._tree as { delete?: () => void } | undefined; + if (tree && typeof tree.delete === 'function') { + try { + tree.delete(); + } catch { + /* ignore cleanup errors */ + } + } + symbols._tree = undefined; + } + } + // Flush Rust WAL writes (AST, complexity, CFG, dataflow) so the JS // connection and any post-build readers can see them. One TRUNCATE // here replaces the N per-feature resumeJsDb checkpoints (#checkpoint-opt). @@ -842,8 +859,25 @@ export async function buildGraph( await runPipelineStages(ctx); } catch (err) { - if (!ctx.earlyExit && ctx.db) { - closeDbPair({ db: ctx.db, nativeDb: ctx.nativeDb }); + if (!ctx.earlyExit) { + // Release WASM trees before closing DB to prevent V8 crash during + // GC cleanup of orphaned WASM objects (#931). + if (ctx.allSymbols?.size > 0) { + for (const [, symbols] of ctx.allSymbols) { + const tree = symbols._tree as { delete?: () => void } | undefined; + if (tree && typeof tree.delete === 'function') { + try { + tree.delete(); + } catch { + /* ignore cleanup errors */ + } + } + symbols._tree = undefined; + } + } + if (ctx.db) { + closeDbPair({ db: ctx.db, nativeDb: ctx.nativeDb }); + } } throw err; } diff --git a/src/domain/graph/builder/stages/build-edges.ts b/src/domain/graph/builder/stages/build-edges.ts index 73a87d7c5..849b9e50f 100644 --- a/src/domain/graph/builder/stages/build-edges.ts +++ b/src/domain/graph/builder/stages/build-edges.ts @@ -699,6 +699,69 @@ function buildClassHierarchyEdges( } } +// ── Reverse-dep edge reconnection (#932, #933) ───────────────────────── + +/** + * Reconnect edges that were saved before changed-file purge. + * + * Each saved edge records: sourceId (still valid — reverse-dep nodes were not + * purged) and target attributes (name, kind, file, line). The target node was + * deleted and re-inserted with a new ID by insertNodes. We look up the new ID + * by (name, kind, file) and re-create the edge. + */ +function reconnectReverseDepEdges(ctx: PipelineContext): void { + const { db } = ctx; + const findNodeStmt = db.prepare( + 'SELECT id FROM nodes WHERE name = ? AND kind = ? AND file = ? ORDER BY ABS(line - ?) LIMIT 1', + ); + const reconnectedRows: EdgeRowTuple[] = []; + let dropped = 0; + + for (const saved of ctx.savedReverseDepEdges) { + const newTarget = findNodeStmt.get( + saved.tgtName, + saved.tgtKind, + saved.tgtFile, + saved.tgtLine, + ) as { id: number } | undefined; + if (newTarget) { + reconnectedRows.push([ + saved.sourceId, + newTarget.id, + saved.edgeKind, + saved.confidence, + saved.dynamic, + ]); + } else { + // Target was removed or renamed in the changed file — edge is stale + dropped++; + } + } + + if (reconnectedRows.length > 0) { + if (ctx.nativeDb?.bulkInsertEdges) { + const nativeEdges = reconnectedRows.map((r) => ({ + sourceId: r[0], + targetId: r[1], + kind: r[2], + confidence: r[3], + dynamic: r[4], + })); + const ok = ctx.nativeDb.bulkInsertEdges(nativeEdges); + if (!ok) { + batchInsertEdges(db, reconnectedRows); + } + } else { + batchInsertEdges(db, reconnectedRows); + } + } + + debug( + `Reconnected ${reconnectedRows.length} reverse-dep edges` + + (dropped > 0 ? ` (${dropped} dropped — targets removed/renamed)` : ''), + ); +} + // ── Main entry point ──────────────────────────────────────────────────── /** @@ -860,5 +923,14 @@ export async function buildEdges(ctx: PipelineContext): Promise { } } + // Phase 3: Reconnect saved reverse-dep edges (#932, #933). + // When the WASM/JS path purged changed files, edges FROM reverse-dep files TO + // those files were deleted (target-side). The reverse-dep files were NOT + // reparsed — instead we saved the edge topology before purge and now reconnect + // each edge to the new node IDs created by insertNodes. + if (ctx.savedReverseDepEdges.length > 0) { + reconnectReverseDepEdges(ctx); + } + ctx.timing.edgesMs = performance.now() - t0; } diff --git a/src/domain/graph/builder/stages/detect-changes.ts b/src/domain/graph/builder/stages/detect-changes.ts index ff5c6d8a6..1b5d612e0 100644 --- a/src/domain/graph/builder/stages/detect-changes.ts +++ b/src/domain/graph/builder/stages/detect-changes.ts @@ -374,24 +374,73 @@ function purgeAndAddReverseDeps( // Prefer NativeDatabase: purge + reverse-dep edge deletion in one transaction (#670) if (ctx.engineName === 'native' && ctx.nativeDb?.purgeFilesData) { ctx.nativeDb.purgeFilesData(filesToPurge, false, hasReverseDeps ? reverseDepList : undefined); + // Native path still reparses reverse-deps (works correctly with native edge builder) + for (const relPath of reverseDeps) { + const absPath = path.join(rootDir, relPath); + ctx.parseChanges.push({ file: absPath, relPath, _reverseDepOnly: true }); + } } else { + // WASM/JS path: save edges from reverse-dep files → changed files BEFORE + // purge, then reconnect them to new node IDs after insertNodes (#932, #933). + // + // purgeFilesFromGraph deletes edges in BOTH directions for changed files, + // which already removes the reverse-dep → changed-file edges. The old + // approach then over-deleted ALL outgoing edges from reverse-dep files and + // reparsed them to rebuild everything — expensive (87 extra parses) and + // lossy (442 missing edges due to imperfect resolution on rebuild). + // + // New approach: save the edge topology, let purge handle deletion, then + // reconnect using new node IDs. No reparse needed. + if (hasReverseDeps && hasPurge) { + const changePathSet = new Set(changePaths); + const saveEdgesStmt = db.prepare(` + SELECT e.source_id, n_tgt.name AS tgt_name, n_tgt.kind AS tgt_kind, + n_tgt.file AS tgt_file, n_tgt.line AS tgt_line, + e.kind AS edge_kind, e.confidence, e.dynamic, + n_src.file AS src_file + FROM edges e + JOIN nodes n_src ON e.source_id = n_src.id + JOIN nodes n_tgt ON e.target_id = n_tgt.id + WHERE n_tgt.file = ? AND n_src.file != n_tgt.file + `); + for (const changedPath of changePaths) { + for (const row of saveEdgesStmt.all(changedPath) as Array<{ + source_id: number; + tgt_name: string; + tgt_kind: string; + tgt_file: string; + tgt_line: number; + edge_kind: string; + confidence: number; + dynamic: number; + src_file: string; + }>) { + // Skip edges whose source is also being purged — buildEdges will + // re-create them with correct new IDs. + if (changePathSet.has(row.src_file)) continue; + ctx.savedReverseDepEdges.push({ + sourceId: row.source_id, + tgtName: row.tgt_name, + tgtKind: row.tgt_kind, + tgtFile: row.tgt_file, + tgtLine: row.tgt_line, + edgeKind: row.edge_kind, + confidence: row.confidence, + dynamic: row.dynamic, + }); + } + } + debug(`Saved ${ctx.savedReverseDepEdges.length} reverse-dep edges for reconnection`); + } + if (hasPurge) { purgeFilesFromGraph(db, filesToPurge, { purgeHashes: false }); } - if (hasReverseDeps) { - const deleteOutgoingEdgesForFile = db.prepare( - 'DELETE FROM edges WHERE source_id IN (SELECT id FROM nodes WHERE file = ?)', - ); - for (const relPath of reverseDepList) { - deleteOutgoingEdgesForFile.run(relPath); - } - } + // No outgoing-edge deletion for reverse-deps — purge already removed + // edges targeting the changed files, and other outgoing edges are valid. + // No reverse-deps added to parseChanges — no reparse needed. } } - for (const relPath of reverseDeps) { - const absPath = path.join(rootDir, relPath); - ctx.parseChanges.push({ file: absPath, relPath, _reverseDepOnly: true }); - } } function detectHasEmbeddings(db: BetterSqlite3Database, nativeDb?: NativeDatabase): boolean { diff --git a/src/domain/graph/builder/stages/run-analyses.ts b/src/domain/graph/builder/stages/run-analyses.ts index c943cdf44..decd288f9 100644 --- a/src/domain/graph/builder/stages/run-analyses.ts +++ b/src/domain/graph/builder/stages/run-analyses.ts @@ -2,39 +2,18 @@ * Stage: runAnalyses * * Dispatches to the unified AST analysis engine (AST nodes, complexity, CFG, dataflow). - * Filters out reverse-dep files for incremental builds. + * Reverse-dep files are no longer in allSymbols (they are not reparsed since #932/#933), + * so no filtering is needed here. */ -import { debug, warn } from '../../../../infrastructure/logger.js'; -import type { ExtractorOutput } from '../../../../types.js'; +import { warn } from '../../../../infrastructure/logger.js'; import type { PipelineContext } from '../context.js'; export async function runAnalyses(ctx: PipelineContext): Promise { - const { db, allSymbols, rootDir, opts, engineOpts, isFullBuild, filesToParse } = ctx; - - // For incremental builds, exclude reverse-dep-only files - let astComplexitySymbols: Map = allSymbols; - if (!isFullBuild) { - const reverseDepFiles = new Set( - filesToParse - .filter((item) => (item as { _reverseDepOnly?: boolean })._reverseDepOnly) - .map((item) => item.relPath), - ); - if (reverseDepFiles.size > 0) { - astComplexitySymbols = new Map(); - for (const [relPath, symbols] of allSymbols) { - if (!reverseDepFiles.has(relPath)) { - astComplexitySymbols.set(relPath, symbols); - } - } - debug( - `AST/complexity/CFG/dataflow: processing ${astComplexitySymbols.size} changed files (skipping ${reverseDepFiles.size} reverse-deps)`, - ); - } - } + const { db, allSymbols, rootDir, opts, engineOpts } = ctx; const { runAnalyses: runAnalysesFn } = await import('../../../../ast-analysis/engine.js'); try { - const analysisTiming = await runAnalysesFn(db, astComplexitySymbols, rootDir, opts, engineOpts); + const analysisTiming = await runAnalysesFn(db, allSymbols, rootDir, opts, engineOpts); ctx.timing.astMs = analysisTiming.astMs; ctx.timing.complexityMs = analysisTiming.complexityMs; ctx.timing.cfgMs = analysisTiming.cfgMs;