From 0d7e8e66781f6f0ad2af1cb9f223e933460f2c3e Mon Sep 17 00:00:00 2001 From: carlos-alm Date: Tue, 7 Jul 2026 17:02:46 -0600 Subject: [PATCH] fix: capture removed files' cross-directory neighbors before purge The small-incremental fast path's directory-metrics refresh (refreshAffectedDirectoryMetrics / refresh_affected_directory_metrics) discovers cross-directory neighbors by querying live import edges from the affected directories. This works for added/modified files but not for removed files, since purgeFilesFromGraph/purgeFilesData deletes both directions of their edges before the structure stage runs, leaving no live evidence to discover a lone connecting neighbor directory from. Add captureRemovedFileNeighbors (WASM) / capture_removed_file_neighbors (native), which reads a removed file's forward+reverse import-neighbor file set in detectChanges/detect_changes, before the purge step, and threads it through to the directory-metrics refresh so the neighbor directory's ancestor chain still gets folded into the affected set. Fixes #1839 Impact: 6 functions changed, 10 affected --- .../src/domain/graph/builder/pipeline.rs | 34 +++- .../graph/builder/stages/detect_changes.rs | 102 ++++++++++++ .../codegraph-core/src/features/structure.rs | 17 +- src/domain/graph/builder/context.ts | 8 + .../graph/builder/stages/build-structure.ts | 20 ++- .../graph/builder/stages/detect-changes.ts | 37 +++++ ...1738-structure-metrics-incremental.test.ts | 8 +- ...rectory-fanin-fanout-stale-removal.test.ts | 153 ++++++++++++++++++ 8 files changed, 354 insertions(+), 25 deletions(-) create mode 100644 tests/integration/issue-1839-directory-fanin-fanout-stale-removal.test.ts diff --git a/crates/codegraph-core/src/domain/graph/builder/pipeline.rs b/crates/codegraph-core/src/domain/graph/builder/pipeline.rs index ef81583f..6f310252 100644 --- a/crates/codegraph-core/src/domain/graph/builder/pipeline.rs +++ b/crates/codegraph-core/src/domain/graph/builder/pipeline.rs @@ -177,22 +177,24 @@ fn early_exit_result( /// Save reverse-dep edges (and reverse-deps of removed files) before purging /// changed files. Mirrors the JS save-then-purge sequence in `build-edges.ts` -/// (#1012). Returns `(saved_reverse_dep_edges, removal_reverse_deps)` so the -/// pipeline can reconnect them after Stage 5 and reclassify roles in Stage 8. +/// (#1012). Returns `(saved_reverse_dep_edges, removal_reverse_deps, +/// removed_file_neighbors)` so the pipeline can reconnect edges after Stage 5, +/// reclassify roles in Stage 8, and (#1839) fold a removed file's +/// cross-directory neighbors into Stage 8's directory-metrics refresh. fn save_and_purge_changed( conn: &Connection, parse_changes: &[&detect_changes::ChangedFile], change_result: &detect_changes::ChangeResult, opts: &BuildOpts, root_dir: &str, -) -> (Vec, Vec) { +) -> (Vec, Vec, Vec) { let mut saved_reverse_dep_edges: Vec = Vec::new(); let mut removal_reverse_deps: Vec = Vec::new(); if change_result.is_full_build { let has_embeddings = detect_changes::has_embeddings(conn); detect_changes::clear_all_graph_data(conn, has_embeddings); - return (saved_reverse_dep_edges, removal_reverse_deps); + return (saved_reverse_dep_edges, removal_reverse_deps, Vec::new()); } let changed_paths: Vec = parse_changes.iter().map(|c| c.rel_path.clone()).collect(); @@ -209,6 +211,12 @@ fn save_and_purge_changed( } } + // Capture removed files' cross-directory neighbor set BEFORE purging — + // both directions of their import edges are deleted below, so this is + // the last point they can still be discovered from live evidence (#1839). + let removed_file_neighbors = + detect_changes::capture_removed_file_neighbors(conn, &change_result.removed); + let files_to_purge: Vec = change_result .removed .iter() @@ -217,7 +225,7 @@ fn save_and_purge_changed( .collect(); detect_changes::purge_changed_files(conn, &files_to_purge, &[]); - (saved_reverse_dep_edges, removal_reverse_deps) + (saved_reverse_dep_edges, removal_reverse_deps, removed_file_neighbors) } /// Parse a changed-file slice in parallel and key the results by relative path. @@ -298,7 +306,10 @@ fn reconnect_saved_reverse_dep_edges( /// `removed_files` is threaded through separately from `parse_changes_len` /// (which only counts re-parsed files) so the fast path's directory-metrics /// refresh also covers files deleted from a directory, not just files added -/// or modified within it (#1738). +/// or modified within it (#1738). `removed_file_neighbors` — the +/// cross-directory import neighbors of `removed_files`, captured before they +/// were purged — lets that refresh also reach a directory whose only link to +/// the touched set was an edge to/from one of those removed files (#1839). fn run_structure_phase( conn: &Connection, file_symbols: &BTreeMap, @@ -307,6 +318,7 @@ fn run_structure_phase( line_count_map: &HashMap, parse_changes_len: usize, removed_files: &[String], + removed_file_neighbors: &[String], is_full_build: bool, ) { let changed_files: Vec = file_symbols.keys().cloned().collect(); @@ -317,7 +329,12 @@ fn run_structure_phase( if use_fast_path { structure::update_changed_file_metrics(conn, &changed_files, line_count_map, file_symbols); - structure::refresh_affected_directory_metrics(conn, &changed_files, removed_files); + structure::refresh_affected_directory_metrics( + conn, + &changed_files, + removed_files, + removed_file_neighbors, + ); } else { let changed_for_structure: Option> = if is_full_build { None @@ -511,7 +528,7 @@ pub fn run_pipeline( // Stage 3b: save reverse-dep edges (incremental) or clear all (full), // then purge changed files. Returns the saved edges for Stage 7 // reconnect and the removal reverse-dep set for Stage 8 reclassification. - let (saved_reverse_dep_edges, removal_reverse_deps) = + let (saved_reverse_dep_edges, removal_reverse_deps, removed_file_neighbors) = save_and_purge_changed(conn, &parse_changes, &change_result, &opts, root_dir); // ── Stage 4: Parse files ─────────────────────────────────────────── @@ -645,6 +662,7 @@ pub fn run_pipeline( &line_count_map, parse_changes.len(), &change_result.removed, + &removed_file_neighbors, change_result.is_full_build, ); timing.structure_ms = t0.elapsed().as_secs_f64() * 1000.0; diff --git a/crates/codegraph-core/src/domain/graph/builder/stages/detect_changes.rs b/crates/codegraph-core/src/domain/graph/builder/stages/detect_changes.rs index 907e6ab8..98400973 100644 --- a/crates/codegraph-core/src/domain/graph/builder/stages/detect_changes.rs +++ b/crates/codegraph-core/src/domain/graph/builder/stages/detect_changes.rs @@ -667,6 +667,49 @@ pub fn find_reverse_dependencies( reverse_deps } +/// Captures the forward+reverse import-neighbor file set for files about to +/// be removed, BEFORE `purge_changed_files` deletes their edges. +/// +/// `refresh_affected_directory_metrics` discovers cross-directory neighbors +/// by querying LIVE import edges from the affected directories — this works +/// for added/modified files (their edges are rebuilt and still present) but +/// not for removed files, whose edges in both directions are purged before +/// the structure stage runs. Reading them here, one step earlier in the +/// pipeline, closes that gap. Mirrors `captureRemovedFileNeighbors` in +/// `detect-changes.ts` (#1839). +pub fn capture_removed_file_neighbors(conn: &Connection, removed_files: &[String]) -> Vec { + if removed_files.is_empty() { + return Vec::new(); + } + let removed_set: HashSet<&str> = removed_files.iter().map(|s| s.as_str()).collect(); + let mut neighbors: HashSet = HashSet::new(); + + let mut stmt = match conn.prepare( + "SELECT n2.file AS other FROM edges e \ + JOIN nodes n1 ON e.source_id = n1.id JOIN nodes n2 ON e.target_id = n2.id \ + WHERE e.kind IN ('imports', 'imports-type') AND n1.file != n2.file AND n1.file = ?1 \ + UNION \ + SELECT n1.file AS other FROM edges e \ + JOIN nodes n1 ON e.source_id = n1.id JOIN nodes n2 ON e.target_id = n2.id \ + WHERE e.kind IN ('imports', 'imports-type') AND n1.file != n2.file AND n2.file = ?1", + ) { + Ok(s) => s, + Err(_) => return Vec::new(), + }; + + for f in removed_files { + if let Ok(rows) = stmt.query_map([f], |row| row.get::<_, String>(0)) { + for row in rows.flatten() { + if !removed_set.contains(row.as_str()) { + neighbors.insert(row); + } + } + } + } + + neighbors.into_iter().collect() +} + /// Purge graph data for changed/removed files and delete outgoing edges for reverse deps. /// /// Deletion order: analysis dependents → edges → nodes (matches `connection::purge_files_data`). @@ -1109,4 +1152,63 @@ mod tests { .unwrap(); assert_eq!(new_target, target_new_id); } + + // ── capture_removed_file_neighbors (#1839) ────────────────────────── + + #[test] + fn capture_removed_file_neighbors_finds_both_directions() { + let conn = test_conn(); + // src/pkgA/a1.js imports src/pkgB/b1.js (forward); src/pkgC/c1.js + // imports src/pkgA/a1.js (reverse) — both directions must surface. + let a1 = insert_node(&conn, "a1", "function", "src/pkgA/a1.js", 1); + let b1 = insert_node(&conn, "b1", "function", "src/pkgB/b1.js", 1); + let c1 = insert_node(&conn, "c1", "function", "src/pkgC/c1.js", 1); + // Unrelated file — must not appear in the result. + insert_node(&conn, "d1", "function", "src/pkgD/d1.js", 1); + + conn.execute( + "INSERT INTO edges (source_id, target_id, kind, confidence, dynamic) VALUES (?1, ?2, 'imports', 1.0, 0)", + rusqlite::params![a1, b1], + ) + .unwrap(); + conn.execute( + "INSERT INTO edges (source_id, target_id, kind, confidence, dynamic) VALUES (?1, ?2, 'imports', 1.0, 0)", + rusqlite::params![c1, a1], + ) + .unwrap(); + + let mut neighbors = + capture_removed_file_neighbors(&conn, &["src/pkgA/a1.js".to_string()]); + neighbors.sort(); + assert_eq!( + neighbors, + vec!["src/pkgB/b1.js".to_string(), "src/pkgC/c1.js".to_string()] + ); + } + + #[test] + fn capture_removed_file_neighbors_empty_for_no_removed_files() { + let conn = test_conn(); + assert!(capture_removed_file_neighbors(&conn, &[]).is_empty()); + } + + #[test] + fn capture_removed_file_neighbors_excludes_other_removed_files() { + // Two files being removed together that import each other must not + // report each other as "neighbors" — only still-live files count. + let conn = test_conn(); + let a1 = insert_node(&conn, "a1", "function", "src/pkgA/a1.js", 1); + let a2 = insert_node(&conn, "a2", "function", "src/pkgA/a2.js", 1); + conn.execute( + "INSERT INTO edges (source_id, target_id, kind, confidence, dynamic) VALUES (?1, ?2, 'imports', 1.0, 0)", + rusqlite::params![a1, a2], + ) + .unwrap(); + + let neighbors = capture_removed_file_neighbors( + &conn, + &["src/pkgA/a1.js".to_string(), "src/pkgA/a2.js".to_string()], + ); + assert!(neighbors.is_empty()); + } } diff --git a/crates/codegraph-core/src/features/structure.rs b/crates/codegraph-core/src/features/structure.rs index 08e737b9..514b7fd3 100644 --- a/crates/codegraph-core/src/features/structure.rs +++ b/crates/codegraph-core/src/features/structure.rs @@ -193,19 +193,24 @@ fn find_neighbor_files(conn: &Connection, dir: &str) -> Vec { /// Removed files need no edge/node cleanup of their own — the purge step /// already deleted their nodes and every edge referencing them (including /// their old `contains` edge) earlier in the pipeline; only their ancestor -/// directories' aggregates need recomputing here. Note this expansion can't -/// reach a directory whose ONLY relationship to the touched set was an edge -/// to/from a file that was JUST removed — that edge is already gone by the -/// time this runs, so there's nothing left to discover it from (tracked -/// separately, see #1738 follow-up). +/// directories' aggregates need recomputing here. A removed file's own +/// cross-directory neighbors (files it imported, or that imported it) can no +/// longer be discovered from LIVE edges by the time this runs — those edges +/// are already purged — so the pipeline captures them up front, before the +/// purge, via `detect_changes::capture_removed_file_neighbors` and passes +/// them in as `removed_file_neighbors` (#1839). pub fn refresh_affected_directory_metrics( conn: &Connection, changed_files: &[String], removed_files: &[String], + removed_file_neighbors: &[String], ) { - let mut touched: Vec = Vec::with_capacity(changed_files.len() + removed_files.len()); + let mut touched: Vec = Vec::with_capacity( + changed_files.len() + removed_files.len() + removed_file_neighbors.len(), + ); touched.extend_from_slice(changed_files); touched.extend_from_slice(removed_files); + touched.extend_from_slice(removed_file_neighbors); let mut affected_dirs = get_ancestor_dirs(&touched); if affected_dirs.is_empty() { return; diff --git a/src/domain/graph/builder/context.ts b/src/domain/graph/builder/context.ts index 59ceaa5b..7f7a085a 100644 --- a/src/domain/graph/builder/context.ts +++ b/src/domain/graph/builder/context.ts @@ -58,6 +58,14 @@ export class PipelineContext { parseChanges!: ParseChange[]; metadataUpdates!: MetadataUpdate[]; removed!: string[]; + /** + * Forward+reverse import-neighbor files of `removed`, captured before + * `purgeFilesFromGraph`/`purgeFilesData` deletes those files' edges. Lets + * `refreshAffectedDirectoryMetrics` still discover a removed file's + * cross-directory neighbor even though the live edge evidence for it is + * gone by the time the structure stage runs (#1839). + */ + removedFileNeighbors: string[] = []; earlyExit: boolean = false; // ── Parsing (set by parseFiles stage) ────────────────────────────── diff --git a/src/domain/graph/builder/stages/build-structure.ts b/src/domain/graph/builder/stages/build-structure.ts index fc4fd152..4ec435d7 100644 --- a/src/domain/graph/builder/stages/build-structure.ts +++ b/src/domain/graph/builder/stages/build-structure.ts @@ -55,7 +55,12 @@ async function buildDirectoryStructure( ): Promise { if (useSmallIncrementalFastPath) { updateChangedFileMetrics(ctx, changedFileList!); - refreshAffectedDirectoryMetrics(ctx, changedFileList!, ctx.removed ?? []); + refreshAffectedDirectoryMetrics( + ctx, + changedFileList!, + ctx.removed ?? [], + ctx.removedFileNeighbors ?? [], + ); return; } @@ -275,19 +280,20 @@ function updateChangedFileMetrics(ctx: PipelineContext, changedFiles: string[]): * Removed files need no edge/node cleanup of their own — `purgeFilesData` * already deleted their nodes and every edge referencing them (including * their old `contains` edge) earlier in the pipeline; only their ancestor - * directories' aggregates need recomputing here. Note this expansion can't - * reach a directory whose ONLY relationship to the touched set was an edge - * to/from a file that was JUST removed — that edge is already gone by the - * time this runs, so there's nothing left to discover it from (tracked - * separately, see #1738 follow-up). + * directories' aggregates need recomputing here. A removed file's own + * cross-directory neighbors (files it imported, or that imported it) can no + * longer be discovered from LIVE edges by the time this runs — those edges + * are already purged — so `detectChanges` captures them up front, before the + * purge, and passes them in as `removedFileNeighbors` (#1839). */ function refreshAffectedDirectoryMetrics( ctx: PipelineContext, changedFiles: string[], removedFiles: string[], + removedFileNeighbors: string[], ): void { const { db } = ctx; - const affectedDirs = getAncestorDirs([...changedFiles, ...removedFiles]); + const affectedDirs = getAncestorDirs([...changedFiles, ...removedFiles, ...removedFileNeighbors]); if (affectedDirs.size === 0) return; const getDirId = db.prepare( diff --git a/src/domain/graph/builder/stages/detect-changes.ts b/src/domain/graph/builder/stages/detect-changes.ts index f58155a9..94d59a5f 100644 --- a/src/domain/graph/builder/stages/detect-changes.ts +++ b/src/domain/graph/builder/stages/detect-changes.ts @@ -377,6 +377,41 @@ function findReverseDependencies( return reverseDeps; } +/** + * Captures the forward+reverse import-neighbor file set for files about to be + * removed, BEFORE `purgeFilesFromGraph`/`purgeFilesData` deletes their edges. + * + * `refreshAffectedDirectoryMetrics` discovers cross-directory neighbors by + * querying LIVE import edges from the affected directories — this works for + * added/modified files (their edges are rebuilt and still present) but not + * for removed files, whose edges in both directions are purged before + * `buildStructure` runs. Reading them here, one step earlier in the pipeline, + * closes that gap: the neighbor files' ancestor directories are unioned into + * the affected-directory set so a directory whose only link to the touched + * set was an edge to/from a now-removed file still gets its fan-in/fan-out + * recomputed (#1839). + */ +function captureRemovedFileNeighbors(db: BetterSqlite3Database, removedFiles: string[]): string[] { + if (removedFiles.length === 0) return []; + const removedSet = new Set(removedFiles); + const neighbors = new Set(); + const neighborStmt = db.prepare(` + SELECT n2.file AS other FROM edges e + JOIN nodes n1 ON e.source_id = n1.id JOIN nodes n2 ON e.target_id = n2.id + WHERE e.kind IN ('imports', 'imports-type') AND n1.file != n2.file AND n1.file = ? + UNION + SELECT n1.file AS other FROM edges e + JOIN nodes n1 ON e.source_id = n1.id JOIN nodes n2 ON e.target_id = n2.id + WHERE e.kind IN ('imports', 'imports-type') AND n1.file != n2.file AND n2.file = ? + `); + for (const relPath of removedFiles) { + for (const row of neighborStmt.all(relPath, relPath) as Array<{ other: string }>) { + if (!removedSet.has(row.other)) neighbors.add(row.other); + } + } + return [...neighbors]; +} + /** * Computes each node's 1-based ordinal rank (by ascending line) among nodes * sharing its (name, kind) within `file`, plus the sibling-group size, keyed @@ -579,6 +614,7 @@ function handleScopedBuild(ctx: PipelineContext): void { const changedRelPaths = new Set([...changePaths, ...ctx.removed]); reverseDeps = findReverseDependencies(db, changedRelPaths, rootDir, ctx.nativeDb); } + ctx.removedFileNeighbors = captureRemovedFileNeighbors(db, ctx.removed); purgeAndAddReverseDeps(ctx, changePaths, reverseDeps); info( `Scoped rebuild: ${changePaths.length} changed, ${ctx.removed.length} removed, ${reverseDeps.size} reverse-deps`, @@ -621,6 +657,7 @@ function handleIncrementalBuild(ctx: PipelineContext): void { const changePaths = ctx.parseChanges.map( (item) => item.relPath || normalizePath(path.relative(rootDir, item.file)), ); + ctx.removedFileNeighbors = captureRemovedFileNeighbors(db, ctx.removed); purgeAndAddReverseDeps(ctx, changePaths, reverseDeps); } diff --git a/tests/integration/issue-1738-structure-metrics-incremental.test.ts b/tests/integration/issue-1738-structure-metrics-incremental.test.ts index 0086446f..6c0477b0 100644 --- a/tests/integration/issue-1738-structure-metrics-incremental.test.ts +++ b/tests/integration/issue-1738-structure-metrics-incremental.test.ts @@ -24,10 +24,10 @@ * changed file gaining/losing an import into a sibling package shifts that * package's fan-in/fan-out too, even though none of its own files changed) * — cheap because it's bounded by (changed files x path depth) rather than - * the size of the repo. See #1839 for a narrower residual gap this does not - * cover: a directory whose only link to the touched set was an edge to/from - * a file that was itself just removed (that edge's evidence is gone by the - * time the refresh runs). + * the size of the repo. See #1839 (fixed separately, in + * `issue-1839-directory-fanin-fanout-stale-removal.test.ts`) for the related + * case of a directory whose only link to the touched set was an edge to/from + * a file that was itself just removed. * * Strategy: build a fixture with >20 files (crossing the fast-path's * `existingFileCount > 20` gate), mutate it incrementally, then diff the diff --git a/tests/integration/issue-1839-directory-fanin-fanout-stale-removal.test.ts b/tests/integration/issue-1839-directory-fanin-fanout-stale-removal.test.ts new file mode 100644 index 00000000..4a6cdfed --- /dev/null +++ b/tests/integration/issue-1839-directory-fanin-fanout-stale-removal.test.ts @@ -0,0 +1,153 @@ +/** + * Regression test for #1839: a directory's fan-in/fan-out stayed stale after + * an incremental rebuild removed the ONLY file connecting it to a neighbor + * directory via a cross-directory import. + * + * Follow-up to #1738. `refreshAffectedDirectoryMetrics` / + * `refresh_affected_directory_metrics` (the small-incremental fast path's + * directory-metrics refresh) discovers cross-directory neighbors by querying + * LIVE import edges from the affected directories — this works when a file is + * added or modified (its edges are rebuilt and still present), but not when + * the file is removed: `purgeFilesFromGraph`/`purgeFilesData` deletes both + * directions of its edges before the structure stage runs, so there is no + * live evidence left to discover the neighbor directory from. + * + * Root cause: `detectChanges` didn't capture a removed file's cross-directory + * import neighbors before purging it. Fixed by `captureRemovedFileNeighbors` + * (WASM) / `capture_removed_file_neighbors` (native), which reads that + * forward+reverse neighbor-file set BEFORE the purge runs and threads it + * through to `refreshAffectedDirectoryMetrics`/`refresh_affected_directory_metrics` + * so the neighbor directory's ancestor chain is still folded into the + * affected-directory set. + * + * Strategy: mirrors the #1738 test — build a fixture with >20 files (crossing + * the fast path's `existingFileCount > 20` gate), remove the sole + * cross-directory-import file, then diff the resulting directory metrics + * against a from-scratch full build of the exact same final file set. + */ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterAll, describe, expect, it } from 'vitest'; +import { buildGraph } from '../../src/domain/graph/builder.js'; +import { structureData } from '../../src/features/structure.js'; +import { isNativeAvailable } from '../../src/infrastructure/native.js'; + +// Small-incremental fast path requires existingFileCount > 20 — keep a +// healthy margin above that boundary. +const BASE_FILE_COUNT = 24; + +function fileContent(i: number): string { + return `export function fn${i}() { return ${i}; }\n`; +} + +/** Write `count` standalone (no cross-imports) padding files into `/src/pad`. */ +function writePaddingFixture(root: string, count: number): void { + fs.mkdirSync(path.join(root, 'src', 'pad'), { recursive: true }); + for (let i = 0; i < count; i++) { + fs.writeFileSync(path.join(root, 'src', 'pad', `file${i}.js`), fileContent(i)); + } +} + +interface DirSnapshot { + fileCount: number; + fanIn: number; + fanOut: number; + cohesion: number | null; + fileNames: string[]; +} + +function snapshotDir(dbPath: string, dirName: string): DirSnapshot { + const data = structureData(dbPath, { directory: dirName, full: true }); + const entry = data.directories.find((d) => d.directory === dirName); + expect(entry, `${dirName} directory missing from structureData output`).toBeDefined(); + return { + fileCount: entry!.fileCount, + fanIn: entry!.fanIn, + fanOut: entry!.fanOut, + cohesion: entry!.cohesion, + fileNames: entry!.files.map((f) => f.file).sort(), + }; +} + +/** Writes the pkgA/pkgB fixture: a1.js is the ONLY file in pkgA importing pkgB. */ +function writeCrossDirFixture(root: string): void { + fs.mkdirSync(path.join(root, 'src', 'pkgA'), { recursive: true }); + fs.mkdirSync(path.join(root, 'src', 'pkgB'), { recursive: true }); + writePaddingFixture(root, BASE_FILE_COUNT); + fs.writeFileSync( + path.join(root, 'src', 'pkgA', 'a1.js'), + "import { b1 } from '../pkgB/b1.js';\nexport function a1() { return b1(); }\n", + ); + fs.writeFileSync(path.join(root, 'src', 'pkgA', 'a2.js'), 'export function a2() { return 2; }\n'); + fs.writeFileSync(path.join(root, 'src', 'pkgB', 'b1.js'), 'export function b1() { return 1; }\n'); +} + +function runScenario(engine: 'wasm' | 'native'): void { + describe(`directory fan-in/fan-out after removing the only connecting file (#1839) — ${engine}`, () => { + const tmpDirs: string[] = []; + + function mkTmp(prefix: string): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + tmpDirs.push(dir); + return dir; + } + + afterAll(() => { + for (const dir of tmpDirs) fs.rmSync(dir, { recursive: true, force: true }); + }); + + it("clears the neighbor directory's fanIn once the only connecting file is removed via the incremental fast path", async () => { + const crossDir = mkTmp(`cg-1839-${engine}-`); + writeCrossDirFixture(crossDir); + await buildGraph(crossDir, { engine, incremental: false, skipRegistry: true }); + + const crossDbPath = () => path.join(crossDir, '.codegraph', 'graph.db'); + const baseline = snapshotDir(crossDbPath(), 'src/pkgB'); + expect(baseline.fanIn).toBe(1); + + // Remove pkgA/a1.js — the ONLY file connecting pkgA to pkgB. This is a + // pure-removal incremental build (0 parsed files), which trivially + // stays within smallFilesThreshold, so it exercises the fast path. + fs.rmSync(path.join(crossDir, 'src', 'pkgA', 'a1.js')); + await buildGraph(crossDir, { engine, skipRegistry: true }); // incremental (default) + const incremental = snapshotDir(crossDbPath(), 'src/pkgB'); + + // Ground truth: an independent from-scratch full build of the same + // final source (a1.js gone, everything else unchanged). + const refDir = mkTmp(`cg-1839-ref-${engine}-`); + fs.mkdirSync(path.join(refDir, 'src', 'pkgA'), { recursive: true }); + fs.mkdirSync(path.join(refDir, 'src', 'pkgB'), { recursive: true }); + writePaddingFixture(refDir, BASE_FILE_COUNT); + fs.writeFileSync( + path.join(refDir, 'src', 'pkgA', 'a2.js'), + 'export function a2() { return 2; }\n', + ); + fs.writeFileSync( + path.join(refDir, 'src', 'pkgB', 'b1.js'), + 'export function b1() { return 1; }\n', + ); + await buildGraph(refDir, { engine, incremental: false, skipRegistry: true }); + const reference = snapshotDir(path.join(refDir, '.codegraph', 'graph.db'), 'src/pkgB'); + + expect( + incremental.fanIn, + "pkgB's fanIn is stale after removing pkgA's only file importing it (#1839)", + ).toBe(reference.fanIn); + expect(incremental.fanIn).toBe(0); + expect(incremental.cohesion).toBe(reference.cohesion); + + // pkgA itself must also have lost the fanOut it had via a1.js. + const pkgAIncremental = snapshotDir(crossDbPath(), 'src/pkgA'); + const pkgAReference = snapshotDir(path.join(refDir, '.codegraph', 'graph.db'), 'src/pkgA'); + expect(pkgAIncremental.fanOut).toBe(pkgAReference.fanOut); + expect(pkgAIncremental.fanOut).toBe(0); + }, 60_000); + }); +} + +runScenario('wasm'); + +describe.skipIf(!isNativeAvailable())('native engine coverage', () => { + runScenario('native'); +});