Skip to content

Commit 5beed1e

Browse files
itaypernoam-aharon20claude
authored
fix(git): recover deleted symbols from the base revision (supersedes #72) (#79)
* fix(git): anchor deletion-only hunks to the enclosing symbol's line `git diff --unified=0` emits a `+Z,0` hunk when lines are removed. These deletion-only hunks contributed nothing to `changed_lines`, so downstream symbol extraction in `core.rs` found no traceable symbol and skipped the reference-traversal cascade entirely ("No traceable symbols found ... skipping reference traversal"). The file's owning package was still marked affected, but every *dependent* of the deleted symbol was missed. Impact: any change whose diff is purely a deletion — removing a property from an exported object, a case from a switch, an enum member, an overload — under-reports affected projects to just the package the file lives in. In a real monorepo, deleting one entry from an exported lookup object that 26 projects transitively consume reported `["@lama/selectors"]` (1 project) instead of the 22 projects that actually reference it. Fix: a `W == 0` deletion hunk now anchors to its new-side line `Z` — the line just before the deletion point, which is still inside the enclosing top-level symbol — so the AST lookup resolves that symbol and traces its references normally. Clamped to 1 for deletions at the top of a file (`+0,0`). Known limitation (left for a follow-up): deleting an *entire* top-level symbol leaves no enclosing node at the anchor line, so the AST lookup finds nothing and behavior falls back to today's package-level marking. Catching that requires parsing the base revision at the old-side range. Adds a regression test and corrects two existing tests that had encoded the buggy "deletion contributes nothing" assumption. * fix(git): recover deleted symbols from the base revision Reworks the deletion-only fix in response to review. The previous commit anchored a `+Z,0` deletion hunk to its new-side line `Z` and fed that into the current-file AST lookup. That heuristic is incorrect: after a deletion, line `Z` belongs to whatever symbol now occupies it in the *new* file, so deleting an entire top-level symbol either mis-attributes the change to the next symbol or resolves nothing — the deleted symbol's real dependents were still dropped. Instead, capture the hunk's *old-side* range `-X,Y` in a new `ChangedFile.deleted_lines` field and, downstream, re-parse the file at the base revision (`git show <merge-base>:./path`) to resolve the top-level symbol that enclosed each removed line. This handles both shapes uniformly: - removing a member of a still-present declaration (object property, switch case, interface field) resolves to the enclosing symbol, and - removing an entire exported declaration resolves to that symbol itself — the case the anchor heuristic could not solve. The recovered symbols are traced through the *current* import graph, where consumers still import them, so their projects are correctly reported affected. Deletions whose lines resolve to no declaration contribute nothing (no regression). Also drops the now-unreachable `has_hunks` empty-`changed_lines` branch that review flagged: every non-deletion hunk yields new-side lines and every deletion yields `deleted_lines`, so a file with hunks is never empty on both. analyzer: split the top-level-symbol resolution out of `find_node_at_line` into `find_top_level_symbols(&FileSemanticData, ...)` so it can run against a one-off base-revision parse (`parse_source`) that never enters `self.files`; profiler timing now wraps the call in `find_node_at_line`. Known limitation (unchanged from before): a whole-file deletion, and a deleted symbol reachable only via a local reference from another symbol whose binding the deletion breaks, still fall back to package-level marking. Tests: rewrite the three git unit tests for `deleted_lines` semantics, add a leading-symbol-deletion case, add analyzer unit tests for `find_deleted_symbols`, and add integration tests for whole-symbol and member deletion tracing. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test: address CodeRabbit review on #79 - git.rs: fix stale symmetric-hunk test comment that referenced the old greedy `.*` in `LINE_RE`; describe the current explicit capture groups. - integration: assert the directly-changed package (`lib`) is also affected in the member-deletion test, mirroring the whole-symbol-deletion test. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix: address maintainer review on #79 - types.rs: drop the unused `Default` derive on `ChangedFile` — every construction (src + tests) sets both fields explicitly. - report.rs: deletion causes carry `line: 0` (no surviving new-side line); render that as "(deleted)" instead of the odd "(line 0)", and omit the locator entirely for the symbol-less whole-file/asset line-0 case. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Noam Aharon <noam.a@lama.ai> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 8b64617 commit 5beed1e

8 files changed

Lines changed: 575 additions & 83 deletions

File tree

src/core.rs

Lines changed: 54 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,39 @@ fn find_affected_internal(
222222
)
223223
.collect();
224224

225+
// Recover symbols removed by pure-deletion hunks. Their lines are gone from
226+
// the working tree, so `find_node_at_line` above (which reads the current
227+
// file) can't see them; instead we re-parse the file at the base revision
228+
// and resolve the enclosing top-level symbol at each deleted line. This is
229+
// what lets dependents of a deleted symbol — an object property, a `switch`
230+
// case, or a whole exported declaration — be traced. Consumers still import
231+
// the symbol in the current graph, so the traversal below reaches them.
232+
let deleted_symbols: Vec<String> = if changed_file.deleted_lines.is_empty() {
233+
Vec::new()
234+
} else {
235+
match git::get_file_at_revision(&config.cwd, &merge_base, file_path) {
236+
Ok(Some(base_source)) => {
237+
analyzer.find_deleted_symbols(file_path, &base_source, &changed_file.deleted_lines)
238+
}
239+
Ok(None) => Vec::new(),
240+
Err(e) => {
241+
debug!(
242+
"Failed to read base revision of {:?} for deleted-symbol recovery: {}",
243+
file_path, e
244+
);
245+
Vec::new()
246+
}
247+
}
248+
};
249+
if !deleted_symbols.is_empty() {
250+
debug!(
251+
"Recovered {} deleted symbol(s) from base revision of {:?}: {:?}",
252+
deleted_symbols.len(),
253+
file_path,
254+
deleted_symbols
255+
);
256+
}
257+
225258
// Add all packages that own this file (multiple projects can share the same sourceRoot).
226259
// Uses the unfiltered lookup — a directly changed file always belongs to its project
227260
// regardless of tsconfig excludes (spec files, stories, config files all count).
@@ -255,16 +288,31 @@ fn find_affected_internal(
255288
}
256289
}
257290
}
291+
292+
// Deleted symbols have no surviving new-side line; record them at line 0
293+
// so the report still explains why the owning package is affected.
294+
for symbol in &deleted_symbols {
295+
project_causes
296+
.entry(pkg.clone())
297+
.or_default()
298+
.push(AffectCause::DirectChange {
299+
file: file_path.clone(),
300+
symbol: Some(symbol.clone()),
301+
line: 0,
302+
});
303+
}
258304
}
259305
}
260306

261-
// Pre-deduplicate: collect unique symbols across all changed lines before tracing.
262-
// This avoids redundant recursive reference traversals when many changed lines
263-
// map to the same symbol (e.g., additions inside a single large exported object).
264-
let unique_symbols: FxHashSet<&String> = symbols_by_line
307+
// Pre-deduplicate: collect unique symbols across all changed lines (plus any
308+
// recovered from deletions) before tracing. This avoids redundant recursive
309+
// reference traversals when many changed lines map to the same symbol (e.g.
310+
// additions inside a single large exported object).
311+
let mut unique_symbols: FxHashSet<&String> = symbols_by_line
265312
.iter()
266313
.flat_map(|(_, symbols)| symbols.iter())
267314
.collect();
315+
unique_symbols.extend(deleted_symbols.iter());
268316

269317
if unique_symbols.is_empty() {
270318
debug!(
@@ -273,9 +321,10 @@ fn find_affected_internal(
273321
);
274322
} else {
275323
debug!(
276-
"Found {} unique symbols from {} changed lines in {:?}",
324+
"Found {} unique symbols from {} changed and {} deleted lines in {:?}",
277325
unique_symbols.len(),
278326
changed_file.changed_lines.len(),
327+
changed_file.deleted_lines.len(),
279328
file_path
280329
);
281330

src/git.rs

Lines changed: 155 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,13 @@ use tracing::{debug, warn};
88

99
static FILE_RE: LazyLock<Regex> =
1010
LazyLock::new(|| Regex::new(r#"(?:["\s]a/)(.*)(?:["\s]b/)"#).expect("file regex is valid"));
11-
static LINE_RE: LazyLock<Regex> =
12-
LazyLock::new(|| Regex::new(r"@@ -.* \+(\d+)(?:,(\d+))? @@").expect("line regex is valid"));
11+
/// Captures both sides of a hunk header `@@ -X,Y +Z,W @@`:
12+
/// group 1 = old start `X`, group 2 = old count `Y` (optional),
13+
/// group 3 = new start `Z`, group 4 = new count `W` (optional).
14+
/// The old side is needed to recover symbols removed by pure-deletion hunks.
15+
static LINE_RE: LazyLock<Regex> = LazyLock::new(|| {
16+
Regex::new(r"@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@").expect("line regex is valid")
17+
});
1318

1419
/// Detect the default branch (tries origin/main, then origin/master)
1520
pub fn detect_default_branch(repo_path: &Path) -> String {
@@ -129,6 +134,39 @@ pub fn get_diff(repo_path: &Path, base: &str, head: Option<&str>) -> Result<Stri
129134
)
130135
}
131136

137+
/// Read the contents of a file as it existed at a given revision.
138+
///
139+
/// `path` is interpreted relative to `repo_path` (the `./` prefix makes git
140+
/// resolve it against the working directory rather than the repo top-level),
141+
/// matching the `--relative` paths produced by [`get_diff`]. Returns
142+
/// `Ok(None)` when the file does not exist at that revision — e.g. a newly
143+
/// added file, for which there is no base content to recover — so callers can
144+
/// treat "no base" as "nothing to trace" without erroring.
145+
pub fn get_file_at_revision(
146+
repo_path: &Path,
147+
revision: &str,
148+
path: &Path,
149+
) -> Result<Option<String>> {
150+
let spec = format!("{}:./{}", revision, path.display());
151+
let output = Command::new("git")
152+
.args(["show", &spec])
153+
.current_dir(repo_path)
154+
.output()
155+
.map_err(|e| DominoError::Other(format!("Failed to execute git show: {}", e)))?;
156+
157+
if !output.status.success() {
158+
debug!(
159+
"git show {} returned non-zero (file likely absent at base); skipping",
160+
spec
161+
);
162+
return Ok(None);
163+
}
164+
165+
Ok(Some(String::from_utf8(output.stdout).unwrap_or_else(|e| {
166+
String::from_utf8_lossy(e.as_bytes()).into_owned()
167+
})))
168+
}
169+
132170
/// Parse git diff output to extract changed files and line numbers.
133171
/// Returns the changed files along with the computed merge-base SHA.
134172
///
@@ -187,44 +225,55 @@ fn parse_diff(diff: &str) -> Result<Vec<ChangedFile>> {
187225
let is_rename_or_copy = new_path.is_some();
188226
let file_path = new_path.unwrap_or(file_path);
189227

190-
// Extract changed line numbers. For each hunk header `@@ -X,Y +Z,W @@`
191-
// expand to every line in the new-side range `Z..Z+W`, so symbols that
192-
// live mid-hunk (not just at the hunk's starting line) are visible to
193-
// downstream AST lookups. When `,W` is omitted, git's convention is a
194-
// single-line hunk (count = 1). Pure deletion hunks (`W == 0`) produce
195-
// an empty range — see the `has_hunks` branch below for how those are
196-
// preserved.
197-
let ranges: Vec<std::ops::Range<usize>> = line_regex
198-
.captures_iter(file_diff)
199-
.filter_map(|caps| {
200-
let start_str = caps.get(1)?.as_str();
201-
let start: usize = start_str
228+
// Extract line numbers from each hunk header `@@ -X,Y +Z,W @@`.
229+
//
230+
// A hunk that adds or modifies lines (`W >= 1`) expands to every new-side
231+
// line `Z..Z+W`, so symbols living mid-hunk — not just at the hunk's start
232+
// — are visible to the downstream AST lookup. When `,W` / `,Y` is omitted,
233+
// git's convention is a single line (count = 1).
234+
//
235+
// A pure-deletion hunk (`W == 0`) has no new-side line to look up: the
236+
// removed code is gone from the working tree. But the symbol that enclosed
237+
// it (an exported object losing a property, a `switch` losing a case, or an
238+
// entire top-level declaration) is still changed, and its dependents are
239+
// affected. We record the *old-side* lines `X..X+Y` in `deleted_lines`;
240+
// downstream (`core.rs`) re-parses the base revision at those lines to
241+
// recover the enclosing symbol and trace its references. Using the old side
242+
// — rather than anchoring to a surviving new-side line — is what makes
243+
// deleting a whole symbol resolvable and avoids mis-attributing the change
244+
// to whichever symbol now happens to occupy the deletion point.
245+
let mut changed_lines: Vec<usize> = Vec::new();
246+
let mut deleted_lines: Vec<usize> = Vec::new();
247+
let parse_group = |caps: &regex::Captures, idx: usize| -> Option<usize> {
248+
caps.get(idx).and_then(|m| {
249+
m.as_str()
202250
.parse()
203-
.inspect_err(|e| warn!("Failed to parse hunk start line '{}': {}", start_str, e))
204-
.ok()?;
205-
let count: usize = caps
206-
.get(2)
207-
.and_then(|m| {
208-
m.as_str()
209-
.parse()
210-
.inspect_err(|e| warn!("Failed to parse hunk count '{}': {}", m.as_str(), e))
211-
.ok()
212-
})
213-
.unwrap_or(1);
214-
Some(start..start + count)
251+
.inspect_err(|e| warn!("Failed to parse hunk field '{}': {}", m.as_str(), e))
252+
.ok()
215253
})
216-
.collect();
217-
let has_hunks = !ranges.is_empty();
218-
let mut changed_lines: Vec<usize> = ranges.into_iter().flatten().collect();
254+
};
255+
for caps in line_regex.captures_iter(file_diff) {
256+
let (Some(old_start), Some(new_start)) = (parse_group(&caps, 1), parse_group(&caps, 3))
257+
else {
258+
continue;
259+
};
260+
let old_count = parse_group(&caps, 2).unwrap_or(1);
261+
let new_count = parse_group(&caps, 4).unwrap_or(1);
262+
if new_count == 0 {
263+
deleted_lines.extend(old_start..old_start + old_count);
264+
} else {
265+
changed_lines.extend(new_start..new_start + new_count);
266+
}
267+
}
219268

220269
if changed_lines.is_empty() {
221270
if is_rename_or_copy {
222271
changed_lines.push(1);
223-
} else if has_hunks {
224-
// Deletion-only file (every hunk is `+Z,0`). Keep the file entry
225-
// with no line numbers so its owning package is still marked as
226-
// affected — deleting an exported symbol is a real change even
227-
// though there's nothing in the new file to AST-lookup.
272+
} else if !deleted_lines.is_empty() {
273+
// Deletion-only file (every hunk is `+Z,0`). It has no new-side lines
274+
// to AST-lookup, but `deleted_lines` lets us recover the removed
275+
// symbols from the base revision downstream, and the file's owning
276+
// package is still marked affected because its path is present.
228277
debug!("Only deletion hunks for file: {}", file_path);
229278
} else if file_diff
230279
.lines()
@@ -240,6 +289,7 @@ fn parse_diff(diff: &str) -> Result<Vec<ChangedFile>> {
240289
Some(ChangedFile {
241290
file_path: file_path.into(),
242291
changed_lines,
292+
deleted_lines,
243293
})
244294
})
245295
.collect();
@@ -522,12 +572,13 @@ index 1234567..abcdefg 100644
522572
assert_eq!(result[0].changed_lines, vec![1]);
523573
}
524574

525-
/// A pure-deletion hunk (`+Z,0`) has no new-side lines to scan and must
526-
/// contribute zero entries to `changed_lines`. Pair it with a normal hunk
527-
/// so the file-skip branch (which triggers on a fully empty result) is not
528-
/// what's actually being tested.
575+
/// A pure-deletion hunk (`+Z,0`) records its *old-side* lines in
576+
/// `deleted_lines` (never in `changed_lines`), while a paired addition hunk
577+
/// contributes its new-side lines to `changed_lines`. Here the deletion at
578+
/// `-5,3` removed base lines 5, 6, 7 and the addition at `+21,2` added new
579+
/// lines 21, 22.
529580
#[test]
530-
fn test_parse_diff_pure_deletion_hunk_contributes_zero() {
581+
fn test_parse_diff_deletion_hunk_records_old_side_alongside_addition() {
531582
let diff = r#"diff --git a/src/foo.ts b/src/foo.ts
532583
index 1234567..abcdefg 100644
533584
--- a/src/foo.ts
@@ -544,16 +595,18 @@ index 1234567..abcdefg 100644
544595
let result = parse_diff(diff).unwrap();
545596
assert_eq!(result.len(), 1);
546597
assert_eq!(result[0].changed_lines, vec![21, 22]);
598+
assert_eq!(result[0].deleted_lines, vec![5, 6, 7]);
547599
}
548600

549-
/// A file whose only hunks are deletions (`+Z,0`) must still be kept in
550-
/// the result with an empty `changed_lines`. Dropping it would hide real
551-
/// source changes — deleting an exported symbol is a meaningful change
552-
/// even though there are no new-file lines to AST-lookup. Downstream in
553-
/// `core.rs`, the file's owning package is still marked affected because
554-
/// the file path is present.
601+
/// A file whose only hunks are deletions (`+Z,0`) is kept in the result with
602+
/// an empty `changed_lines` but the removed base-revision lines in
603+
/// `deleted_lines`. Downstream, those lines re-parse the base revision to
604+
/// recover the deleted symbol and trace its dependents — while the file's own
605+
/// package is still marked because its path is present. Previously such files
606+
/// were kept with empty `changed_lines` and nothing else, so the reference
607+
/// cascade was skipped and dependents were under-reported.
555608
#[test]
556-
fn test_parse_diff_deletion_only_file_kept_with_empty_lines() {
609+
fn test_parse_diff_deletion_only_file_records_deleted_lines() {
557610
let diff = r#"diff --git a/src/foo.ts b/src/foo.ts
558611
index 1234567..abcdefg 100644
559612
--- a/src/foo.ts
@@ -568,13 +621,14 @@ index 1234567..abcdefg 100644
568621
assert_eq!(result.len(), 1);
569622
assert_eq!(result[0].file_path.to_str().unwrap(), "src/foo.ts");
570623
assert!(result[0].changed_lines.is_empty());
624+
assert_eq!(result[0].deleted_lines, vec![5, 6, 7]);
571625
}
572626

573627
/// Symmetric hunk header — old and new sides both carry a count. Common in
574628
/// diffs with non-zero context (`--unified=N` for N > 0) or when an edit
575-
/// replaces a block with another block of the same size. The greedy `.*`
576-
/// in `LINE_RE` already handles this; the test locks it in against future
577-
/// regex tweaks.
629+
/// replaces a block with another block of the same size. `LINE_RE`'s explicit
630+
/// old/new capture groups (`-(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))?`) handle this;
631+
/// the test locks it in against future regex tweaks.
578632
#[test]
579633
fn test_parse_diff_symmetric_hunk_with_old_and_new_counts() {
580634
let diff = r#"diff --git a/src/foo.ts b/src/foo.ts
@@ -594,4 +648,56 @@ index 1234567..abcdefg 100644
594648
assert_eq!(result.len(), 1);
595649
assert_eq!(result[0].changed_lines, vec![3, 4, 5]);
596650
}
651+
652+
/// Regression for the deletion-only under-detection bug.
653+
///
654+
/// `git diff --unified=0` emits a `+Z,0` hunk when a line is removed. The
655+
/// removed content no longer exists in the new file, so it contributes
656+
/// nothing to `changed_lines`; instead its *old-side* line (the shorthand
657+
/// `-495` = base line 495) is recorded in `deleted_lines`. Downstream, that
658+
/// line re-parses the base revision, resolves the enclosing top-level symbol
659+
/// (here the exported `declarativeCalculations` object), and traces every
660+
/// project that consumes it.
661+
///
662+
/// Before the fix, deletion hunks contributed nothing at all, so symbol
663+
/// extraction found nothing and the reference cascade was skipped,
664+
/// under-reporting the deleted symbol's dependents.
665+
#[test]
666+
fn test_parse_diff_deletion_only_hunk_records_old_side_line() {
667+
let diff = r#"diff --git a/src/attrs.ts b/src/attrs.ts
668+
index 1234567..abcdefg 100644
669+
--- a/src/attrs.ts
670+
+++ b/src/attrs.ts
671+
@@ -495 +494,0 @@ export const declarativeCalculations = {
672+
- 'Commissions & Fees': { alternative: ['Bank Charges'] },
673+
"#;
674+
675+
let result = parse_diff(diff).unwrap();
676+
assert_eq!(result.len(), 1);
677+
assert!(result[0].changed_lines.is_empty());
678+
assert_eq!(result[0].deleted_lines, vec![495]);
679+
}
680+
681+
/// Deleting an entire multi-line top-level symbol at the very top of a file
682+
/// produces `@@ -1,N +0,0 @@`. The whole old-side range must land in
683+
/// `deleted_lines` so the base revision can be re-parsed to recover the
684+
/// deleted declaration — the case the earlier new-side "anchor" heuristic
685+
/// could not handle (it would resolve whichever symbol now occupies line 1).
686+
#[test]
687+
fn test_parse_diff_deletion_of_leading_symbol_records_full_old_range() {
688+
let diff = r#"diff --git a/src/foo.ts b/src/foo.ts
689+
index 1234567..abcdefg 100644
690+
--- a/src/foo.ts
691+
+++ b/src/foo.ts
692+
@@ -1,3 +0,0 @@
693+
-export const Removed = {
694+
- value: 1,
695+
-};
696+
"#;
697+
698+
let result = parse_diff(diff).unwrap();
699+
assert_eq!(result.len(), 1);
700+
assert!(result[0].changed_lines.is_empty());
701+
assert_eq!(result[0].deleted_lines, vec![1, 2, 3]);
702+
}
597703
}

src/lockfile.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1257,6 +1257,7 @@ mod tests {
12571257
let files = vec![ChangedFile {
12581258
file_path: "package-lock.json".into(),
12591259
changed_lines: vec![1],
1260+
deleted_lines: vec![],
12601261
}];
12611262
assert!(has_lockfile_changed(&files, &PackageManager::Npm));
12621263
}
@@ -1266,6 +1267,7 @@ mod tests {
12661267
let files = vec![ChangedFile {
12671268
file_path: "src/index.ts".into(),
12681269
changed_lines: vec![1],
1270+
deleted_lines: vec![],
12691271
}];
12701272
assert!(!has_lockfile_changed(&files, &PackageManager::Npm));
12711273
}
@@ -1275,6 +1277,7 @@ mod tests {
12751277
let files = vec![ChangedFile {
12761278
file_path: "yarn.lock".into(),
12771279
changed_lines: vec![1],
1280+
deleted_lines: vec![],
12781281
}];
12791282
assert!(has_lockfile_changed(&files, &PackageManager::Yarn));
12801283
}
@@ -1284,6 +1287,7 @@ mod tests {
12841287
let files = vec![ChangedFile {
12851288
file_path: "pnpm-lock.yaml".into(),
12861289
changed_lines: vec![1],
1290+
deleted_lines: vec![],
12871291
}];
12881292
assert!(has_lockfile_changed(&files, &PackageManager::Pnpm));
12891293
}
@@ -1293,6 +1297,7 @@ mod tests {
12931297
let files = vec![ChangedFile {
12941298
file_path: "bun.lock".into(),
12951299
changed_lines: vec![1],
1300+
deleted_lines: vec![],
12961301
}];
12971302
assert!(has_lockfile_changed(&files, &PackageManager::Bun));
12981303
}

0 commit comments

Comments
 (0)