Skip to content

Commit 837fda9

Browse files
authored
Merge pull request #4 from Eilodon/audit-validation-optimization
Eilodon/audit validation optimization
2 parents 9453246 + b6aff8c commit 837fda9

6 files changed

Lines changed: 1967 additions & 291 deletions

File tree

crates/ci-core/src/analysis/diff_impact.rs

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,88 @@ pub fn get_git_diff(
7575
}
7676
}
7777

78+
#[derive(Debug, Clone, PartialEq, Eq)]
79+
pub struct FileDiff {
80+
pub path: String,
81+
/// (new_start, new_end) inclusive, 1-indexed line ranges touched in the new file.
82+
pub hunks: Vec<(i64, i64)>,
83+
pub is_new_file: bool,
84+
pub is_deleted_file: bool,
85+
}
86+
87+
/// Minimal unified-diff parser: extracts per-file changed line ranges (new-file side)
88+
/// from `diff --git` / `@@ ... @@` headers. Not a full diff/patch implementation —
89+
/// just enough to overlap against indexed symbol line ranges.
90+
pub fn parse_unified_diff(diff_text: &str) -> Vec<FileDiff> {
91+
let mut files: Vec<FileDiff> = Vec::new();
92+
let mut current: Option<FileDiff> = None;
93+
94+
for line in diff_text.lines() {
95+
if let Some(rest) = line.strip_prefix("diff --git ") {
96+
if let Some(f) = current.take() {
97+
files.push(f);
98+
}
99+
current = Some(FileDiff {
100+
path: parse_diff_git_header(rest),
101+
hunks: Vec::new(),
102+
is_new_file: false,
103+
is_deleted_file: false,
104+
});
105+
} else if line.starts_with("new file mode") {
106+
if let Some(f) = current.as_mut() {
107+
f.is_new_file = true;
108+
}
109+
} else if line.starts_with("deleted file mode") {
110+
if let Some(f) = current.as_mut() {
111+
f.is_deleted_file = true;
112+
}
113+
} else if let Some(rest) = line.strip_prefix("+++ ") {
114+
if let Some(f) = current.as_mut() {
115+
let rest = rest.trim();
116+
if rest != "/dev/null" {
117+
f.path = rest.strip_prefix("b/").unwrap_or(rest).to_string();
118+
}
119+
}
120+
} else if let Some(range) = line.strip_prefix("@@ ").and_then(parse_hunk_header)
121+
&& let Some(f) = current.as_mut()
122+
{
123+
f.hunks.push(range);
124+
}
125+
}
126+
if let Some(f) = current.take() {
127+
files.push(f);
128+
}
129+
files
130+
}
131+
132+
fn parse_diff_git_header(rest: &str) -> String {
133+
if let Some(idx) = rest.find(" b/") {
134+
rest[idx + 3..].trim().to_string()
135+
} else {
136+
rest.split_whitespace()
137+
.last()
138+
.map(|s| s.strip_prefix("b/").unwrap_or(s).to_string())
139+
.unwrap_or_default()
140+
}
141+
}
142+
143+
/// Parses the new-file `+start,len` range out of a hunk header tail (the part
144+
/// after the leading `"@@ "` has already been stripped by the caller).
145+
fn parse_hunk_header(rest: &str) -> Option<(i64, i64)> {
146+
let close = rest.find(" @@")?;
147+
let ranges = &rest[..close];
148+
let new_part = ranges.split(' ').find(|s| s.starts_with('+'))?;
149+
let new_part = &new_part[1..];
150+
let mut parts = new_part.splitn(2, ',');
151+
let start: i64 = parts.next()?.parse().ok()?;
152+
let len: i64 = parts
153+
.next()
154+
.and_then(|s| s.parse::<i64>().ok())
155+
.unwrap_or(1);
156+
let end = if len <= 0 { start } else { start + len - 1 };
157+
Some((start, end))
158+
}
159+
78160
pub fn is_signature_changed(signature_range: (i64, i64), hunk_ranges: &[(i64, i64)]) -> bool {
79161
let (sig_start, sig_end) = signature_range;
80162
hunk_ranges
@@ -227,4 +309,91 @@ mod tests {
227309
assert!(diff.is_none());
228310
assert!(err.is_some());
229311
}
312+
313+
#[test]
314+
fn test_parse_unified_diff_single_hunk() {
315+
let diff = "diff --git a/src/foo.rs b/src/foo.rs\n\
316+
index abc..def 100644\n\
317+
--- a/src/foo.rs\n\
318+
+++ b/src/foo.rs\n\
319+
@@ -10,3 +10,4 @@ fn foo() {\n\
320+
context\n\
321+
+new line\n\
322+
context\n";
323+
let files = parse_unified_diff(diff);
324+
assert_eq!(files.len(), 1);
325+
assert_eq!(files[0].path, "src/foo.rs");
326+
assert_eq!(files[0].hunks, vec![(10, 13)]);
327+
assert!(!files[0].is_new_file);
328+
assert!(!files[0].is_deleted_file);
329+
}
330+
331+
#[test]
332+
fn test_parse_unified_diff_new_file() {
333+
let diff = "diff --git a/src/new.rs b/src/new.rs\n\
334+
new file mode 100644\n\
335+
index 000..abc\n\
336+
--- /dev/null\n\
337+
+++ b/src/new.rs\n\
338+
@@ -0,0 +1,5 @@\n\
339+
+fn new_fn() {}\n";
340+
let files = parse_unified_diff(diff);
341+
assert_eq!(files.len(), 1);
342+
assert_eq!(files[0].path, "src/new.rs");
343+
assert!(files[0].is_new_file);
344+
assert_eq!(files[0].hunks, vec![(1, 5)]);
345+
}
346+
347+
#[test]
348+
fn test_parse_unified_diff_deleted_file() {
349+
let diff = "diff --git a/src/old.rs b/src/old.rs\n\
350+
deleted file mode 100644\n\
351+
index abc..000\n\
352+
--- a/src/old.rs\n\
353+
+++ /dev/null\n\
354+
@@ -1,5 +0,0 @@\n\
355+
-fn old_fn() {}\n";
356+
let files = parse_unified_diff(diff);
357+
assert_eq!(files.len(), 1);
358+
assert_eq!(files[0].path, "src/old.rs");
359+
assert!(files[0].is_deleted_file);
360+
}
361+
362+
#[test]
363+
fn test_parse_unified_diff_rename() {
364+
let diff = "diff --git a/src/old.rs b/src/renamed.rs\n\
365+
similarity index 95%\n\
366+
rename from src/old.rs\n\
367+
rename to src/renamed.rs\n\
368+
--- a/src/old.rs\n\
369+
+++ b/src/renamed.rs\n\
370+
@@ -1,2 +1,3 @@\n\
371+
context\n\
372+
+added\n";
373+
let files = parse_unified_diff(diff);
374+
assert_eq!(files.len(), 1);
375+
assert_eq!(files[0].path, "src/renamed.rs");
376+
}
377+
378+
#[test]
379+
fn test_parse_unified_diff_multiple_files_and_hunks() {
380+
let diff = "diff --git a/a.rs b/a.rs\n\
381+
--- a/a.rs\n\
382+
+++ b/a.rs\n\
383+
@@ -1,2 +1,2 @@\n\
384+
x\n\
385+
@@ -20,1 +20,1 @@\n\
386+
y\n\
387+
diff --git a/b.rs b/b.rs\n\
388+
--- a/b.rs\n\
389+
+++ b/b.rs\n\
390+
@@ -5 +5 @@\n\
391+
z\n";
392+
let files = parse_unified_diff(diff);
393+
assert_eq!(files.len(), 2);
394+
assert_eq!(files[0].path, "a.rs");
395+
assert_eq!(files[0].hunks, vec![(1, 2), (20, 20)]);
396+
assert_eq!(files[1].path, "b.rs");
397+
assert_eq!(files[1].hunks, vec![(5, 5)]);
398+
}
230399
}

crates/ci-core/src/analysis/hotspot.rs

Lines changed: 45 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -242,8 +242,12 @@ fn collect_git_churn(project_root: &Path, since: &str) -> (HashMap<String, Churn
242242
current_author = parts.get(1).map(|s| s.trim().to_string());
243243
current_date = parts.get(2).map(|s| s.trim().to_string());
244244
} else if !line.trim().is_empty() {
245-
let abs_path = project_root.join(line.trim()).to_string_lossy().to_string();
246-
let entry = churn_map.entry(abs_path).or_insert_with(|| ChurnInfo {
245+
// Git already reports paths relative to `current_dir` (project_root) using
246+
// forward slashes — this must match `symbols.path`'s format exactly (see
247+
// `pipeline::rel_path`), or the churn/complexity merge below silently drops
248+
// every candidate.
249+
let rel_path = line.trim().to_string();
250+
let entry = churn_map.entry(rel_path).or_insert_with(|| ChurnInfo {
247251
commit_count: 0,
248252
authors: HashSet::new(),
249253
// H-1 fix: last_changed is Option<String>, None instead of ""
@@ -383,4 +387,43 @@ mod tests {
383387
assert_eq!(syms.len(), 2);
384388
assert_eq!(syms[0].name, "m.foo"); // higher caller_count first
385389
}
390+
391+
fn run_git(dir: &Path, args: &[&str]) {
392+
let status = Command::new("git")
393+
.args(args)
394+
.current_dir(dir)
395+
.status()
396+
.unwrap();
397+
assert!(status.success(), "git {args:?} failed");
398+
}
399+
400+
/// Regression test for the path-format mismatch between `collect_git_churn`
401+
/// (which used to absolutize paths) and `symbols.path` (always project-root-
402+
/// relative): with a real git repo present, churn-ranked hotspots must
403+
/// actually surface indexed files, not silently merge to an empty result.
404+
#[test]
405+
fn test_git_churn_merges_with_relative_symbol_paths() {
406+
let dir = tempfile::tempdir().unwrap();
407+
run_git(dir.path(), &["init", "-q"]);
408+
run_git(dir.path(), &["config", "user.email", "test@example.com"]);
409+
run_git(dir.path(), &["config", "user.name", "Test"]);
410+
std::fs::write(dir.path().join("hot.py"), "def foo():\n pass\n").unwrap();
411+
run_git(dir.path(), &["add", "hot.py"]);
412+
run_git(dir.path(), &["commit", "-q", "-m", "init"]);
413+
// A second commit so commit_count >= min_churn=2.
414+
std::fs::write(dir.path().join("hot.py"), "def foo():\n return 1\n").unwrap();
415+
run_git(dir.path(), &["commit", "-q", "-am", "update"]);
416+
417+
let conn = setup_db();
418+
insert_symbol(&conn, "hot.foo", "hot.py", 3, true, 1);
419+
420+
let config = HotspotsConfig::default();
421+
let output = compute_hotspots(dir.path(), &conn, &config, 10, "1 year", 2, false);
422+
423+
assert!(output.git_available);
424+
assert_eq!(output.hotspot_method, "git+index");
425+
assert_eq!(output.hotspots.len(), 1);
426+
assert_eq!(output.hotspots[0].path, "hot.py");
427+
assert_eq!(output.hotspots[0].churn.commit_count, 2);
428+
}
386429
}

0 commit comments

Comments
 (0)