Skip to content

Commit 61698ae

Browse files
authored
fix: capture removed files' cross-directory neighbors before purge (#1983)
fix: capture removed files' cross-directory neighbors before purge Fixes #1839. Investigated a P1 Greptile finding claiming the scoped-rebuild path leaves removedFileNeighbors empty — reproduced empirically via buildGraph(dir, { scope: [...] }) on both engines and confirmed the claim was incorrect (Greptile agreed after reviewing the evidence and withdrew the finding). Merge also reconciled main's independent #1855 file-scoping perf optimization (find_neighbor_files: directory-range scan -> exact-file match) with this PR's removed_file_neighbors threading — both compose cleanly since the added entries flow through the same per-file loop. Note: Pre-publish benchmark gate failed 3x on the known-flaky "1-file rebuild" metric, unrelated to this PR's change. Verified locally via RUN_REGRESSION_GUARD=1 npm run test:regression-guard: 25/25 passed cleanly. Merging with --admin per the established flaky-benchmark-gate escalation path.
1 parent 83aa182 commit 61698ae

8 files changed

Lines changed: 357 additions & 27 deletions

File tree

crates/codegraph-core/src/domain/graph/builder/pipeline.rs

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -177,22 +177,24 @@ fn early_exit_result(
177177

178178
/// Save reverse-dep edges (and reverse-deps of removed files) before purging
179179
/// changed files. Mirrors the JS save-then-purge sequence in `build-edges.ts`
180-
/// (#1012). Returns `(saved_reverse_dep_edges, removal_reverse_deps)` so the
181-
/// pipeline can reconnect them after Stage 5 and reclassify roles in Stage 8.
180+
/// (#1012). Returns `(saved_reverse_dep_edges, removal_reverse_deps,
181+
/// removed_file_neighbors)` so the pipeline can reconnect edges after Stage 5,
182+
/// reclassify roles in Stage 8, and (#1839) fold a removed file's
183+
/// cross-directory neighbors into Stage 8's directory-metrics refresh.
182184
fn save_and_purge_changed(
183185
conn: &Connection,
184186
parse_changes: &[&detect_changes::ChangedFile],
185187
change_result: &detect_changes::ChangeResult,
186188
opts: &BuildOpts,
187189
root_dir: &str,
188-
) -> (Vec<detect_changes::SavedReverseDepEdge>, Vec<String>) {
190+
) -> (Vec<detect_changes::SavedReverseDepEdge>, Vec<String>, Vec<String>) {
189191
let mut saved_reverse_dep_edges: Vec<detect_changes::SavedReverseDepEdge> = Vec::new();
190192
let mut removal_reverse_deps: Vec<String> = Vec::new();
191193

192194
if change_result.is_full_build {
193195
let has_embeddings = detect_changes::has_embeddings(conn);
194196
detect_changes::clear_all_graph_data(conn, has_embeddings);
195-
return (saved_reverse_dep_edges, removal_reverse_deps);
197+
return (saved_reverse_dep_edges, removal_reverse_deps, Vec::new());
196198
}
197199

198200
let changed_paths: Vec<String> = parse_changes.iter().map(|c| c.rel_path.clone()).collect();
@@ -209,6 +211,12 @@ fn save_and_purge_changed(
209211
}
210212
}
211213

214+
// Capture removed files' cross-directory neighbor set BEFORE purging —
215+
// both directions of their import edges are deleted below, so this is
216+
// the last point they can still be discovered from live evidence (#1839).
217+
let removed_file_neighbors =
218+
detect_changes::capture_removed_file_neighbors(conn, &change_result.removed);
219+
212220
let files_to_purge: Vec<String> = change_result
213221
.removed
214222
.iter()
@@ -217,7 +225,7 @@ fn save_and_purge_changed(
217225
.collect();
218226
detect_changes::purge_changed_files(conn, &files_to_purge, &[]);
219227

220-
(saved_reverse_dep_edges, removal_reverse_deps)
228+
(saved_reverse_dep_edges, removal_reverse_deps, removed_file_neighbors)
221229
}
222230

223231
/// Parse a changed-file slice in parallel and key the results by relative path.
@@ -298,7 +306,10 @@ fn reconnect_saved_reverse_dep_edges(
298306
/// `removed_files` is threaded through separately from `parse_changes_len`
299307
/// (which only counts re-parsed files) so the fast path's directory-metrics
300308
/// refresh also covers files deleted from a directory, not just files added
301-
/// or modified within it (#1738).
309+
/// or modified within it (#1738). `removed_file_neighbors` — the
310+
/// cross-directory import neighbors of `removed_files`, captured before they
311+
/// were purged — lets that refresh also reach a directory whose only link to
312+
/// the touched set was an edge to/from one of those removed files (#1839).
302313
fn run_structure_phase(
303314
conn: &Connection,
304315
file_symbols: &BTreeMap<String, FileSymbols>,
@@ -307,6 +318,7 @@ fn run_structure_phase(
307318
line_count_map: &HashMap<String, i64>,
308319
parse_changes_len: usize,
309320
removed_files: &[String],
321+
removed_file_neighbors: &[String],
310322
is_full_build: bool,
311323
) {
312324
let changed_files: Vec<String> = file_symbols.keys().cloned().collect();
@@ -317,7 +329,12 @@ fn run_structure_phase(
317329

318330
if use_fast_path {
319331
structure::update_changed_file_metrics(conn, &changed_files, line_count_map, file_symbols);
320-
structure::refresh_affected_directory_metrics(conn, &changed_files, removed_files);
332+
structure::refresh_affected_directory_metrics(
333+
conn,
334+
&changed_files,
335+
removed_files,
336+
removed_file_neighbors,
337+
);
321338
} else {
322339
let changed_for_structure: Option<Vec<String>> = if is_full_build {
323340
None
@@ -511,7 +528,7 @@ pub fn run_pipeline(
511528
// Stage 3b: save reverse-dep edges (incremental) or clear all (full),
512529
// then purge changed files. Returns the saved edges for Stage 7
513530
// reconnect and the removal reverse-dep set for Stage 8 reclassification.
514-
let (saved_reverse_dep_edges, removal_reverse_deps) =
531+
let (saved_reverse_dep_edges, removal_reverse_deps, removed_file_neighbors) =
515532
save_and_purge_changed(conn, &parse_changes, &change_result, &opts, root_dir);
516533

517534
// ── Stage 4: Parse files ───────────────────────────────────────────
@@ -645,6 +662,7 @@ pub fn run_pipeline(
645662
&line_count_map,
646663
parse_changes.len(),
647664
&change_result.removed,
665+
&removed_file_neighbors,
648666
change_result.is_full_build,
649667
);
650668
timing.structure_ms = t0.elapsed().as_secs_f64() * 1000.0;

crates/codegraph-core/src/domain/graph/builder/stages/detect_changes.rs

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -667,6 +667,49 @@ pub fn find_reverse_dependencies(
667667
reverse_deps
668668
}
669669

670+
/// Captures the forward+reverse import-neighbor file set for files about to
671+
/// be removed, BEFORE `purge_changed_files` deletes their edges.
672+
///
673+
/// `refresh_affected_directory_metrics` discovers cross-directory neighbors
674+
/// by querying LIVE import edges from the affected directories — this works
675+
/// for added/modified files (their edges are rebuilt and still present) but
676+
/// not for removed files, whose edges in both directions are purged before
677+
/// the structure stage runs. Reading them here, one step earlier in the
678+
/// pipeline, closes that gap. Mirrors `captureRemovedFileNeighbors` in
679+
/// `detect-changes.ts` (#1839).
680+
pub fn capture_removed_file_neighbors(conn: &Connection, removed_files: &[String]) -> Vec<String> {
681+
if removed_files.is_empty() {
682+
return Vec::new();
683+
}
684+
let removed_set: HashSet<&str> = removed_files.iter().map(|s| s.as_str()).collect();
685+
let mut neighbors: HashSet<String> = HashSet::new();
686+
687+
let mut stmt = match conn.prepare(
688+
"SELECT n2.file AS other FROM edges e \
689+
JOIN nodes n1 ON e.source_id = n1.id JOIN nodes n2 ON e.target_id = n2.id \
690+
WHERE e.kind IN ('imports', 'imports-type') AND n1.file != n2.file AND n1.file = ?1 \
691+
UNION \
692+
SELECT n1.file AS other FROM edges e \
693+
JOIN nodes n1 ON e.source_id = n1.id JOIN nodes n2 ON e.target_id = n2.id \
694+
WHERE e.kind IN ('imports', 'imports-type') AND n1.file != n2.file AND n2.file = ?1",
695+
) {
696+
Ok(s) => s,
697+
Err(_) => return Vec::new(),
698+
};
699+
700+
for f in removed_files {
701+
if let Ok(rows) = stmt.query_map([f], |row| row.get::<_, String>(0)) {
702+
for row in rows.flatten() {
703+
if !removed_set.contains(row.as_str()) {
704+
neighbors.insert(row);
705+
}
706+
}
707+
}
708+
}
709+
710+
neighbors.into_iter().collect()
711+
}
712+
670713
/// Purge graph data for changed/removed files and delete outgoing edges for reverse deps.
671714
///
672715
/// Deletion order: analysis dependents → edges → nodes (matches `connection::purge_files_data`).
@@ -1109,4 +1152,63 @@ mod tests {
11091152
.unwrap();
11101153
assert_eq!(new_target, target_new_id);
11111154
}
1155+
1156+
// ── capture_removed_file_neighbors (#1839) ──────────────────────────
1157+
1158+
#[test]
1159+
fn capture_removed_file_neighbors_finds_both_directions() {
1160+
let conn = test_conn();
1161+
// src/pkgA/a1.js imports src/pkgB/b1.js (forward); src/pkgC/c1.js
1162+
// imports src/pkgA/a1.js (reverse) — both directions must surface.
1163+
let a1 = insert_node(&conn, "a1", "function", "src/pkgA/a1.js", 1);
1164+
let b1 = insert_node(&conn, "b1", "function", "src/pkgB/b1.js", 1);
1165+
let c1 = insert_node(&conn, "c1", "function", "src/pkgC/c1.js", 1);
1166+
// Unrelated file — must not appear in the result.
1167+
insert_node(&conn, "d1", "function", "src/pkgD/d1.js", 1);
1168+
1169+
conn.execute(
1170+
"INSERT INTO edges (source_id, target_id, kind, confidence, dynamic) VALUES (?1, ?2, 'imports', 1.0, 0)",
1171+
rusqlite::params![a1, b1],
1172+
)
1173+
.unwrap();
1174+
conn.execute(
1175+
"INSERT INTO edges (source_id, target_id, kind, confidence, dynamic) VALUES (?1, ?2, 'imports', 1.0, 0)",
1176+
rusqlite::params![c1, a1],
1177+
)
1178+
.unwrap();
1179+
1180+
let mut neighbors =
1181+
capture_removed_file_neighbors(&conn, &["src/pkgA/a1.js".to_string()]);
1182+
neighbors.sort();
1183+
assert_eq!(
1184+
neighbors,
1185+
vec!["src/pkgB/b1.js".to_string(), "src/pkgC/c1.js".to_string()]
1186+
);
1187+
}
1188+
1189+
#[test]
1190+
fn capture_removed_file_neighbors_empty_for_no_removed_files() {
1191+
let conn = test_conn();
1192+
assert!(capture_removed_file_neighbors(&conn, &[]).is_empty());
1193+
}
1194+
1195+
#[test]
1196+
fn capture_removed_file_neighbors_excludes_other_removed_files() {
1197+
// Two files being removed together that import each other must not
1198+
// report each other as "neighbors" — only still-live files count.
1199+
let conn = test_conn();
1200+
let a1 = insert_node(&conn, "a1", "function", "src/pkgA/a1.js", 1);
1201+
let a2 = insert_node(&conn, "a2", "function", "src/pkgA/a2.js", 1);
1202+
conn.execute(
1203+
"INSERT INTO edges (source_id, target_id, kind, confidence, dynamic) VALUES (?1, ?2, 'imports', 1.0, 0)",
1204+
rusqlite::params![a1, a2],
1205+
)
1206+
.unwrap();
1207+
1208+
let neighbors = capture_removed_file_neighbors(
1209+
&conn,
1210+
&["src/pkgA/a1.js".to_string(), "src/pkgA/a2.js".to_string()],
1211+
);
1212+
assert!(neighbors.is_empty());
1213+
}
11121214
}

crates/codegraph-core/src/features/structure.rs

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,8 @@ pub fn get_existing_file_count(conn: &Connection) -> i64 {
145145
/// direction — the cross-directory neighbours whose own fan-in/out may have
146146
/// shifted even though `file` is the only one that actually changed. Used by
147147
/// `refresh_affected_directory_metrics` to expand its affected-directory set
148-
/// by exactly one hop.
148+
/// by exactly one hop, for both changed files and (via `removed_file_neighbors`,
149+
/// #1839) removed ones.
149150
///
150151
/// Scoped to the exact touched `file`, not its containing directory. An
151152
/// earlier version scoped this to the whole leaf directory (`dir >= x/ AND
@@ -217,19 +218,24 @@ fn find_neighbor_files(conn: &Connection, file: &str) -> Vec<String> {
217218
/// Removed files need no edge/node cleanup of their own — the purge step
218219
/// already deleted their nodes and every edge referencing them (including
219220
/// their old `contains` edge) earlier in the pipeline; only their ancestor
220-
/// directories' aggregates need recomputing here. Note this expansion can't
221-
/// reach a directory whose ONLY relationship to the touched set was an edge
222-
/// to/from a file that was JUST removed — that edge is already gone by the
223-
/// time this runs, so there's nothing left to discover it from (tracked
224-
/// separately, see #1738 follow-up).
221+
/// directories' aggregates need recomputing here. A removed file's own
222+
/// cross-directory neighbors (files it imported, or that imported it) can no
223+
/// longer be discovered from LIVE edges by the time this runs — those edges
224+
/// are already purged — so the pipeline captures them up front, before the
225+
/// purge, via `detect_changes::capture_removed_file_neighbors` and passes
226+
/// them in as `removed_file_neighbors` (#1839).
225227
pub fn refresh_affected_directory_metrics(
226228
conn: &Connection,
227229
changed_files: &[String],
228230
removed_files: &[String],
231+
removed_file_neighbors: &[String],
229232
) {
230-
let mut touched: Vec<String> = Vec::with_capacity(changed_files.len() + removed_files.len());
233+
let mut touched: Vec<String> = Vec::with_capacity(
234+
changed_files.len() + removed_files.len() + removed_file_neighbors.len(),
235+
);
231236
touched.extend_from_slice(changed_files);
232237
touched.extend_from_slice(removed_files);
238+
touched.extend_from_slice(removed_file_neighbors);
233239
let mut affected_dirs = get_ancestor_dirs(&touched);
234240
if affected_dirs.is_empty() {
235241
return;

src/domain/graph/builder/context.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,14 @@ export class PipelineContext {
5858
parseChanges!: ParseChange[];
5959
metadataUpdates!: MetadataUpdate[];
6060
removed!: string[];
61+
/**
62+
* Forward+reverse import-neighbor files of `removed`, captured before
63+
* `purgeFilesFromGraph`/`purgeFilesData` deletes those files' edges. Lets
64+
* `refreshAffectedDirectoryMetrics` still discover a removed file's
65+
* cross-directory neighbor even though the live edge evidence for it is
66+
* gone by the time the structure stage runs (#1839).
67+
*/
68+
removedFileNeighbors: string[] = [];
6169
earlyExit: boolean = false;
6270

6371
// ── Parsing (set by parseFiles stage) ──────────────────────────────

src/domain/graph/builder/stages/build-structure.ts

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,12 @@ async function buildDirectoryStructure(
5555
): Promise<void> {
5656
if (useSmallIncrementalFastPath) {
5757
updateChangedFileMetrics(ctx, changedFileList!);
58-
refreshAffectedDirectoryMetrics(ctx, changedFileList!, ctx.removed ?? []);
58+
refreshAffectedDirectoryMetrics(
59+
ctx,
60+
changedFileList!,
61+
ctx.removed ?? [],
62+
ctx.removedFileNeighbors ?? [],
63+
);
5964
return;
6065
}
6166

@@ -288,19 +293,20 @@ function updateChangedFileMetrics(ctx: PipelineContext, changedFiles: string[]):
288293
* Removed files need no edge/node cleanup of their own — `purgeFilesData`
289294
* already deleted their nodes and every edge referencing them (including
290295
* their old `contains` edge) earlier in the pipeline; only their ancestor
291-
* directories' aggregates need recomputing here. Note this expansion can't
292-
* reach a directory whose ONLY relationship to the touched set was an edge
293-
* to/from a file that was JUST removed — that edge is already gone by the
294-
* time this runs, so there's nothing left to discover it from (tracked
295-
* separately, see #1738 follow-up).
296+
* directories' aggregates need recomputing here. A removed file's own
297+
* cross-directory neighbors (files it imported, or that imported it) can no
298+
* longer be discovered from LIVE edges by the time this runs — those edges
299+
* are already purged — so `detectChanges` captures them up front, before the
300+
* purge, and passes them in as `removedFileNeighbors` (#1839).
296301
*/
297302
function refreshAffectedDirectoryMetrics(
298303
ctx: PipelineContext,
299304
changedFiles: string[],
300305
removedFiles: string[],
306+
removedFileNeighbors: string[],
301307
): void {
302308
const { db } = ctx;
303-
const affectedDirs = getAncestorDirs([...changedFiles, ...removedFiles]);
309+
const affectedDirs = getAncestorDirs([...changedFiles, ...removedFiles, ...removedFileNeighbors]);
304310
if (affectedDirs.size === 0) return;
305311

306312
const getDirId = db.prepare(
@@ -342,7 +348,7 @@ function refreshAffectedDirectoryMetrics(
342348
// comment: expanding from a broad ancestor like `src`, or from every
343349
// sibling in the touched file's own directory, is effectively repo-wide
344350
// whenever that directory is a widely-imported hub.
345-
for (const file of [...changedFiles, ...removedFiles]) {
351+
for (const file of [...changedFiles, ...removedFiles, ...removedFileNeighbors]) {
346352
const otherFiles = neighborFiles.all({ file }) as Array<{
347353
other: string;
348354
}>;

src/domain/graph/builder/stages/detect-changes.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -377,6 +377,41 @@ function findReverseDependencies(
377377
return reverseDeps;
378378
}
379379

380+
/**
381+
* Captures the forward+reverse import-neighbor file set for files about to be
382+
* removed, BEFORE `purgeFilesFromGraph`/`purgeFilesData` deletes their edges.
383+
*
384+
* `refreshAffectedDirectoryMetrics` discovers cross-directory neighbors by
385+
* querying LIVE import edges from the affected directories — this works for
386+
* added/modified files (their edges are rebuilt and still present) but not
387+
* for removed files, whose edges in both directions are purged before
388+
* `buildStructure` runs. Reading them here, one step earlier in the pipeline,
389+
* closes that gap: the neighbor files' ancestor directories are unioned into
390+
* the affected-directory set so a directory whose only link to the touched
391+
* set was an edge to/from a now-removed file still gets its fan-in/fan-out
392+
* recomputed (#1839).
393+
*/
394+
function captureRemovedFileNeighbors(db: BetterSqlite3Database, removedFiles: string[]): string[] {
395+
if (removedFiles.length === 0) return [];
396+
const removedSet = new Set(removedFiles);
397+
const neighbors = new Set<string>();
398+
const neighborStmt = db.prepare(`
399+
SELECT n2.file AS other FROM edges e
400+
JOIN nodes n1 ON e.source_id = n1.id JOIN nodes n2 ON e.target_id = n2.id
401+
WHERE e.kind IN ('imports', 'imports-type') AND n1.file != n2.file AND n1.file = ?
402+
UNION
403+
SELECT n1.file AS other FROM edges e
404+
JOIN nodes n1 ON e.source_id = n1.id JOIN nodes n2 ON e.target_id = n2.id
405+
WHERE e.kind IN ('imports', 'imports-type') AND n1.file != n2.file AND n2.file = ?
406+
`);
407+
for (const relPath of removedFiles) {
408+
for (const row of neighborStmt.all(relPath, relPath) as Array<{ other: string }>) {
409+
if (!removedSet.has(row.other)) neighbors.add(row.other);
410+
}
411+
}
412+
return [...neighbors];
413+
}
414+
380415
/**
381416
* Computes each node's 1-based ordinal rank (by ascending line) among nodes
382417
* sharing its (name, kind) within `file`, plus the sibling-group size, keyed
@@ -579,6 +614,7 @@ function handleScopedBuild(ctx: PipelineContext): void {
579614
const changedRelPaths = new Set<string>([...changePaths, ...ctx.removed]);
580615
reverseDeps = findReverseDependencies(db, changedRelPaths, rootDir, ctx.nativeDb);
581616
}
617+
ctx.removedFileNeighbors = captureRemovedFileNeighbors(db, ctx.removed);
582618
purgeAndAddReverseDeps(ctx, changePaths, reverseDeps);
583619
info(
584620
`Scoped rebuild: ${changePaths.length} changed, ${ctx.removed.length} removed, ${reverseDeps.size} reverse-deps`,
@@ -621,6 +657,7 @@ function handleIncrementalBuild(ctx: PipelineContext): void {
621657
const changePaths = ctx.parseChanges.map(
622658
(item) => item.relPath || normalizePath(path.relative(rootDir, item.file)),
623659
);
660+
ctx.removedFileNeighbors = captureRemovedFileNeighbors(db, ctx.removed);
624661
purgeAndAddReverseDeps(ctx, changePaths, reverseDeps);
625662
}
626663

tests/integration/issue-1738-structure-metrics-incremental.test.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,10 @@
2424
* changed file gaining/losing an import into a sibling package shifts that
2525
* package's fan-in/fan-out too, even though none of its own files changed)
2626
* — cheap because it's bounded by (changed files x path depth) rather than
27-
* the size of the repo. See #1839 for a narrower residual gap this does not
28-
* cover: a directory whose only link to the touched set was an edge to/from
29-
* a file that was itself just removed (that edge's evidence is gone by the
30-
* time the refresh runs).
27+
* the size of the repo. See #1839 (fixed separately, in
28+
* `issue-1839-directory-fanin-fanout-stale-removal.test.ts`) for the related
29+
* case of a directory whose only link to the touched set was an edge to/from
30+
* a file that was itself just removed.
3131
*
3232
* Strategy: build a fixture with >20 files (crossing the fast-path's
3333
* `existingFileCount > 20` gate), mutate it incrementally, then diff the

0 commit comments

Comments
 (0)