huskies: merge 849
This commit is contained in:
@@ -83,6 +83,103 @@ fn adapter_for_ext(ext: &str) -> Option<Box<dyn LanguageAdapter>> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Parse added line ranges from a unified diff output.
|
||||||
|
///
|
||||||
|
/// Returns the 1-based, inclusive line ranges in the new version of the file
|
||||||
|
/// that were introduced by the diff. Lines that are context or deletions are
|
||||||
|
/// not included.
|
||||||
|
fn parse_added_ranges(diff: &str) -> Vec<std::ops::RangeInclusive<usize>> {
|
||||||
|
let mut ranges = Vec::new();
|
||||||
|
for line in diff.lines() {
|
||||||
|
// Unified diff hunk header: @@ -old[,count] +new[,count] @@
|
||||||
|
if !line.starts_with("@@") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let parts: Vec<&str> = line.split_whitespace().collect();
|
||||||
|
// Expected: ["@@", "-old[,count]", "+new[,count]", "@@", ...]
|
||||||
|
if parts.len() < 3 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let new_part = parts[2];
|
||||||
|
let Some(new_info) = new_part.strip_prefix('+') else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let (start, count) = if let Some((s, c)) = new_info.split_once(',') {
|
||||||
|
(
|
||||||
|
s.parse::<usize>().unwrap_or(0),
|
||||||
|
c.parse::<usize>().unwrap_or(0),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
(new_info.parse::<usize>().unwrap_or(0), 1usize)
|
||||||
|
};
|
||||||
|
if count > 0 && start > 0 {
|
||||||
|
ranges.push(start..=start + count - 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ranges
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the 1-based line ranges in `file` that were added since `base` in `worktree`.
|
||||||
|
///
|
||||||
|
/// Uses `git diff --unified=0 {base}...HEAD -- {file}` and parses the hunk headers.
|
||||||
|
/// Returns an empty `Vec` on git errors or when there are no added lines.
|
||||||
|
pub fn added_line_ranges(
|
||||||
|
worktree: &Path,
|
||||||
|
base: &str,
|
||||||
|
file: &Path,
|
||||||
|
) -> Vec<std::ops::RangeInclusive<usize>> {
|
||||||
|
let rel = file.strip_prefix(worktree).unwrap_or(file);
|
||||||
|
let output = Command::new("git")
|
||||||
|
.args([
|
||||||
|
"diff",
|
||||||
|
"--unified=0",
|
||||||
|
&format!("{base}...HEAD"),
|
||||||
|
"--",
|
||||||
|
&rel.to_string_lossy(),
|
||||||
|
])
|
||||||
|
.current_dir(worktree)
|
||||||
|
.output();
|
||||||
|
match output {
|
||||||
|
Ok(o) => parse_added_ranges(&String::from_utf8_lossy(&o.stdout)),
|
||||||
|
Err(_) => Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check documentation coverage, reporting only violations in lines added since `base`.
|
||||||
|
///
|
||||||
|
/// Like [`check_files`], but filters each [`CheckFailure`] to items whose declaration
|
||||||
|
/// line falls within a range added by `git diff {base}...HEAD` against `worktree`.
|
||||||
|
/// Pre-existing undocumented items whose lines were not touched by the commit are
|
||||||
|
/// silently ignored.
|
||||||
|
pub fn check_files_ratcheted(files: &[&Path], worktree: &Path, base: &str) -> CheckResult {
|
||||||
|
let mut by_ext: HashMap<String, Vec<&Path>> = HashMap::new();
|
||||||
|
for &file in files {
|
||||||
|
if let Some(ext) = file.extension().and_then(|e| e.to_str()) {
|
||||||
|
by_ext.entry(ext.to_string()).or_default().push(file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut all_failures = Vec::new();
|
||||||
|
for (ext, ext_files) in &by_ext {
|
||||||
|
if let Some(adapter) = adapter_for_ext(ext)
|
||||||
|
&& let CheckResult::Failures(failures) = adapter.check(ext_files)
|
||||||
|
{
|
||||||
|
for failure in failures {
|
||||||
|
let added = added_line_ranges(worktree, base, &failure.file_path);
|
||||||
|
// Only report if the item's declaration line is within an added range.
|
||||||
|
// If added is empty (no additions or git error), skip — nothing new to blame.
|
||||||
|
if !added.is_empty() && added.iter().any(|r| r.contains(&failure.line)) {
|
||||||
|
all_failures.push(failure);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if all_failures.is_empty() {
|
||||||
|
CheckResult::Ok
|
||||||
|
} else {
|
||||||
|
CheckResult::Failures(all_failures)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Check documentation coverage for a mixed list of files.
|
/// Check documentation coverage for a mixed list of files.
|
||||||
///
|
///
|
||||||
/// Dispatches each file to the appropriate [`LanguageAdapter`] based on its
|
/// Dispatches each file to the appropriate [`LanguageAdapter`] based on its
|
||||||
@@ -412,6 +509,139 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Ratchet tests: AC3 / AC4 ---
|
||||||
|
|
||||||
|
/// AC3: a file with N pre-existing undocumented items plus 1 new undocumented item
|
||||||
|
/// added by the commit reports exactly 1 violation, not N+1.
|
||||||
|
#[test]
|
||||||
|
fn ratchet_only_new_undocumented_items_are_flagged() {
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
init_git_repo(tmp.path());
|
||||||
|
|
||||||
|
// Base commit: file with 2 undocumented public fns (pre-existing).
|
||||||
|
write_rs(
|
||||||
|
tmp.path(),
|
||||||
|
"lib.rs",
|
||||||
|
"//! Module doc.\n\npub fn old_a() {}\npub fn old_b() {}\n",
|
||||||
|
);
|
||||||
|
Command::new("git")
|
||||||
|
.args(["add", "lib.rs"])
|
||||||
|
.current_dir(tmp.path())
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
Command::new("git")
|
||||||
|
.args(["commit", "-m", "base"])
|
||||||
|
.current_dir(tmp.path())
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Second commit: append 1 new undocumented fn.
|
||||||
|
write_rs(
|
||||||
|
tmp.path(),
|
||||||
|
"lib.rs",
|
||||||
|
"//! Module doc.\n\npub fn old_a() {}\npub fn old_b() {}\npub fn new_c() {}\n",
|
||||||
|
);
|
||||||
|
Command::new("git")
|
||||||
|
.args(["add", "lib.rs"])
|
||||||
|
.current_dir(tmp.path())
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
Command::new("git")
|
||||||
|
.args(["commit", "-m", "add new_c"])
|
||||||
|
.current_dir(tmp.path())
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let file = tmp.path().join("lib.rs");
|
||||||
|
let result = check_files_ratcheted(&[file.as_path()], tmp.path(), "HEAD~1");
|
||||||
|
match result {
|
||||||
|
CheckResult::Failures(failures) => {
|
||||||
|
assert_eq!(
|
||||||
|
failures.len(),
|
||||||
|
1,
|
||||||
|
"expected exactly 1 failure (new_c), got {failures:?}"
|
||||||
|
);
|
||||||
|
assert_eq!(failures[0].item_name, "new_c");
|
||||||
|
}
|
||||||
|
CheckResult::Ok => panic!("expected 1 failure for new_c, got Ok"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// AC4: a commit that doesn't change a file does not blame it for pre-existing
|
||||||
|
/// undocumented items.
|
||||||
|
#[test]
|
||||||
|
fn ratchet_unchanged_file_not_blamed() {
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
init_git_repo(tmp.path());
|
||||||
|
|
||||||
|
// Base commit: undocumented file.
|
||||||
|
write_rs(
|
||||||
|
tmp.path(),
|
||||||
|
"untouched.rs",
|
||||||
|
"//! Module doc.\n\npub fn old_undocumented() {}\n",
|
||||||
|
);
|
||||||
|
Command::new("git")
|
||||||
|
.args(["add", "untouched.rs"])
|
||||||
|
.current_dir(tmp.path())
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
Command::new("git")
|
||||||
|
.args(["commit", "-m", "base"])
|
||||||
|
.current_dir(tmp.path())
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Second commit: add a different, fully documented file; untouched.rs unchanged.
|
||||||
|
write_rs(
|
||||||
|
tmp.path(),
|
||||||
|
"new_file.rs",
|
||||||
|
"//! Module doc.\n\n/// A function.\npub fn documented() {}\n",
|
||||||
|
);
|
||||||
|
Command::new("git")
|
||||||
|
.args(["add", "new_file.rs"])
|
||||||
|
.current_dir(tmp.path())
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
Command::new("git")
|
||||||
|
.args(["commit", "-m", "add new_file"])
|
||||||
|
.current_dir(tmp.path())
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Simulate passing untouched.rs to the ratcheted check.
|
||||||
|
// Since it has no added lines in the diff, it should produce no failures.
|
||||||
|
let file = tmp.path().join("untouched.rs");
|
||||||
|
let result = check_files_ratcheted(&[file.as_path()], tmp.path(), "HEAD~1");
|
||||||
|
assert_eq!(
|
||||||
|
result,
|
||||||
|
CheckResult::Ok,
|
||||||
|
"file not touched by the commit should not be blamed"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- parse_added_ranges unit tests ---
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_added_ranges_single_hunk() {
|
||||||
|
let diff = "@@ -0,0 +1,3 @@ some context\n+line1\n+line2\n+line3\n";
|
||||||
|
let ranges = parse_added_ranges(diff);
|
||||||
|
assert_eq!(ranges, vec![1..=3]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_added_ranges_multiple_hunks() {
|
||||||
|
let diff =
|
||||||
|
"@@ -1,2 +1,3 @@\n context\n+new\n context\n@@ -10,0 +11,2 @@\n+added1\n+added2\n";
|
||||||
|
let ranges = parse_added_ranges(diff);
|
||||||
|
assert_eq!(ranges, vec![1..=3, 11..=12]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_added_ranges_empty_diff() {
|
||||||
|
let ranges = parse_added_ranges("");
|
||||||
|
assert!(ranges.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
// --- Spawn integration: update_for_worktree writes map at expected path ---
|
// --- Spawn integration: update_for_worktree writes map at expected path ---
|
||||||
|
|
||||||
fn init_git_repo(dir: &Path) {
|
fn init_git_repo(dir: &Path) {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
//! missing doc comments. Exits 0 (silently) when all changed files are fully
|
//! missing doc comments. Exits 0 (silently) when all changed files are fully
|
||||||
//! documented or when there are no relevant changes to check.
|
//! documented or when there are no relevant changes to check.
|
||||||
|
|
||||||
use source_map_gen::{CheckResult, check_files};
|
use source_map_gen::{CheckResult, check_files_ratcheted};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
|
|
||||||
@@ -47,7 +47,7 @@ fn main() {
|
|||||||
|
|
||||||
let file_refs: Vec<&Path> = changed.iter().map(PathBuf::as_path).collect();
|
let file_refs: Vec<&Path> = changed.iter().map(PathBuf::as_path).collect();
|
||||||
|
|
||||||
match check_files(&file_refs) {
|
match check_files_ratcheted(&file_refs, worktree_path, &base) {
|
||||||
CheckResult::Ok => {}
|
CheckResult::Ok => {}
|
||||||
CheckResult::Failures(failures) => {
|
CheckResult::Failures(failures) => {
|
||||||
eprintln!(
|
eprintln!(
|
||||||
|
|||||||
Reference in New Issue
Block a user