fix: reconnect reverse-dep edges correctly when a sibling group's size also changes#2014
Conversation
…e also changes reconnectReverseDepEdges (build-edges.ts) and its native mirror reconnect_reverse_dep_edges (detect_changes.rs) re-attach a reverse-dep caller's edge to its purged-and-reinserted target using a saved ordinal rank among same-(name, kind) siblings, falling back to nearest-line matching whenever the sibling count itself changed since save (a same-named sibling added or removed in the same edit). That fallback is exactly the nearest-line heuristic #1752 already proved unreliable once a same-named group shifts far enough, so the compound scenario (shift + count change in one incremental build) still produced call edges that diverged from a full rebuild. Replaces the ordinal/nearest-line two-tier heuristic with a single alignment: match old-to-new siblings by rank when the sibling count is unchanged (subsumes #1752's fix exactly), or by the single dominant line-shift that best explains the surviving (untouched) siblings when the count changed. Untouched siblings' own source text is never edited, so they all shift by the exact same delta regardless of how far the group has moved — finding that shift correctly identifies which old sibling was removed (or which new one was added) without guessing based on raw line proximity, which a uniform shift can fool (confirmed against this repo's own real #1752 fixture numbers, where a naive minimum-distance alignment picks the wrong element to drop). Adds `build.reverseDepAlignmentMaxGroupSize` (default 200) bounding the alignment's shift-histogram cost for pathologically large sibling groups, mirrored in both engines' DEFAULTS.
Greptile SummaryThis PR replaces the two-tier ordinal/nearest-line heuristic for reconnecting reverse-dep call edges with a single
Confidence Score: 4/5Safe to merge; changes are scoped entirely to the reverse-dep reconnection subsystem and are well-tested by both Rust unit tests and a TS end-to-end integration test. The dominant-shift alignment is correct and well-verified for the described compound scenario. One small asymmetry exists: after a successful alignment lookup, the TS src/domain/graph/builder/stages/build-edges.ts — the Important Files Changed
Flowchart%%{init: {'theme': 'neutral'}}%%
flowchart TD
A["save_and_purge_changed / addReverseDeps"] -->|"computeSiblingGroups before purge"| B["savedSiblingGroups: name,kind,file to sorted lines"]
A --> C["savedReverseDepEdges: one per caller-to-target edge"]
B --> D["reconnect_reverse_dep_edges"]
C --> D
D --> E{"candidates.length == 1?"}
E -->|yes| F["return that id"]
E -->|no| G{"savedSiblingGroups has key AND size <= maxAlignGroupSize?"}
G -->|no| H["nearest-line fallback"]
G -->|yes| I["alignSiblingLines: old_lines vs new_lines"]
I --> J{"counts equal?"}
J -->|yes| K["rank-based zip: 1st to 1st, 2nd to 2nd"]
J -->|no| L["shift histogram O(n*m): pick dominant shift S"]
K --> M["alignment map: old_line to new_line"]
L --> M
M --> N{"tgt_line in map?"}
N -->|yes| O["return matched candidate id"]
N -->|no| P["return None or null: edge dropped"]
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
flowchart TD
A["save_and_purge_changed / addReverseDeps"] -->|"computeSiblingGroups before purge"| B["savedSiblingGroups: name,kind,file to sorted lines"]
A --> C["savedReverseDepEdges: one per caller-to-target edge"]
B --> D["reconnect_reverse_dep_edges"]
C --> D
D --> E{"candidates.length == 1?"}
E -->|yes| F["return that id"]
E -->|no| G{"savedSiblingGroups has key AND size <= maxAlignGroupSize?"}
G -->|no| H["nearest-line fallback"]
G -->|yes| I["alignSiblingLines: old_lines vs new_lines"]
I --> J{"counts equal?"}
J -->|yes| K["rank-based zip: 1st to 1st, 2nd to 2nd"]
J -->|no| L["shift histogram O(n*m): pick dominant shift S"]
K --> M["alignment map: old_line to new_line"]
L --> M
M --> N{"tgt_line in map?"}
N -->|yes| O["return matched candidate id"]
N -->|no| P["return None or null: edge dropped"]
Reviews (1): Last reviewed commit: "fix: reconnect reverse-dep edges correct..." | Re-trigger Greptile |
| const match = candidates.find((c) => c.line === newLine); | ||
| if (match) return match.id; | ||
| } | ||
|
|
||
| // No sibling-group snapshot (shouldn't normally happen — every saved edge | ||
| // has one recorded at save time) or the group exceeds the alignment size | ||
| // cap — fall back to nearest-line. |
There was a problem hiding this comment.
Silent nearest-line fallthrough after a successful alignment
When alignment.get(tgtLine) returns a newLine but candidates.find((c) => c.line === newLine) returns undefined (theoretically impossible since the alignment is built from candidates.map((c) => c.line), but still reachable as dead code), the TS function falls through to the nearest-line block — reconnecting the edge to a likely-wrong target. The Rust mirror (pick_reconnect_target) instead always returns inside the alignment block, so .find().map() returning None correctly drops the edge. Adding an explicit return null after the if (match) check would close this gap and make the misleading "No sibling-group snapshot … fall back to nearest-line" comment accurate for the code paths that actually reach it.
Codegraph Impact Analysis7 functions changed → 13 callers affected across 5 files
|
Summary
reconnectReverseDepEdges(build-edges.ts) and its native mirrorreconnect_reverse_dep_edges(detect_changes.rs) re-attach a reverse-dep caller's edge to its purged-and-reinserted target using a saved ordinal rank among same-(name, kind) siblings (Incremental rebuild leaves stale blast-radius/fn-impact numbers for line-shifted declarations (same root cause as #1738, #1743) #1752), falling back to nearest-line matching whenever the sibling count itself changed since save (a same-named sibling added or removed in the same edit). That fallback is exactly the nearest-line heuristic Incremental rebuild leaves stale blast-radius/fn-impact numbers for line-shifted declarations (same root cause as #1738, #1743) #1752 proved unreliable once a same-named group shifts far enough, so the compound scenario (shift + count change in the same incremental build) still produced call edges that diverged from a full rebuild.alignSiblingLines/align_sibling_lines): match old-to-new siblings by rank when the sibling count is unchanged (subsumes Incremental rebuild leaves stale blast-radius/fn-impact numbers for line-shifted declarations (same root cause as #1738, #1743) #1752's fix exactly, since order is always preserved regardless of shift uniformity), or by the single dominant line-shift that best explains the surviving (untouched) siblings when the count changed. Untouched siblings' own source text is never edited, so they all shift by the exact same delta — finding that shift correctly identifies which old sibling was removed (or which new one was added) without guessing based on raw line proximity.build.reverseDepAlignmentMaxGroupSize(default 200) toDEFAULTSin both engines, bounding the alignment's shift-histogram cost for pathologically large sibling groups (falls back to nearest-line above the threshold).Fixes #1865 — both scenarios named in the issue's own reproduction (a same-named sibling removed, and one added, each combined with a whole-group line shift) are fixed and verified on both engines. Filed #2015 for a genuinely distinct, out-of-scope follow-up: a same-named sibling simultaneously added and removed in one edit (net-zero count change) is undetectable from line data alone and needs per-declaration content hashing, as floated as one possible direction in #1865's own text — a separate, schema-touching change.
Test plan
detect_changes.rs:pick_reconnect_target_drops_edge_when_its_own_sibling_was_removed,pick_reconnect_target_survives_added_sibling_plus_shift,align_sibling_lines_forces_exact_pairing_when_counts_match,align_sibling_lines_empty_inputs_yield_empty_map,reconnect_survives_shift_plus_sibling_removed_in_same_edit(end-to-end, real Incremental rebuild leaves stale blast-radius/fn-impact numbers for line-shifted declarations (same root cause as #1738, #1743) #1752 fixture line numbers) — plus updated existing ordinal tests for the new API shape.tests/integration/issue-1865-reverse-dep-sibling-count-change.test.ts— seeds the exact DB topologyaddReverseDeps/reconnectReverseDepEdgesoperate on (four realclose()method nodes + four synthetic reverse-dep edges), then exercises the real incrementalbuildGraph()pipeline. A/B-verified: reverted to pre-fix source and confirmed this test fails with the exact mis-wiring signature described in the issue (three of four callers collapse onto the same wrong target); restored the fix and confirmed it passes.cargo test --release— 569 passed, 0 failed (full crate suite)cargo clippy --release— no new warnings (fixed onetype_complexitywarning introduced by an extended tuple return type via a named type alias)npm test— full suite green: 3882 passed, 0 failed, 30 skipped, 2 todo (native addon locally built + codesigned to exercise both engines)npm run lint/npm run typecheck/npm run build— cleancodegraph diff-impact --staged -T— confirms the change is scoped exactly to the reverse-dep reconnection subsystem (7 functions, 13 transitive callers, all withinbuild-edges.ts/detect-changes.ts/context.ts/types.ts)Stacked on #1847 (base branch
fix/issue-1847-native-ffi-hybrid-import-edge-path-drops) — only the reverse-dep reconnection diff is this issue's change.