Skip to content

Commit 764faf0

Browse files
haasonsaasclaude
andcommitted
fix: dedup severity bug in composable pipeline, accept ssh:// URLs, clean up comments
- Fix DeduplicateStage in composable_pipeline.rs to preserve highest severity when deduplicating (same bug as comment.rs, different path) - Accept ssh:// URLs in is_safe_git_url (was rejecting legitimate SSH) - Consolidate is_git_source to delegate to is_safe_git_url - Replace stale "BUG:" test comments with "Regression:" phrasing Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 1a3aefa commit 764faf0

File tree

7 files changed

+57
-26
lines changed

7 files changed

+57
-26
lines changed

src/core/comment.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -561,7 +561,7 @@ mod tests {
561561

562562
#[test]
563563
fn test_deduplicate_preserves_highest_severity() {
564-
// BUG: dedup_by keeps the first element of a consecutive pair,
564+
// Regression: dedup_by keeps the first element of a consecutive pair,
565565
// but doesn't consider severity. If Warning comes before Error
566566
// (due to stable sort on file/line/content), the Error is dropped.
567567
let raw_comments = vec![

src/core/composable_pipeline.rs

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,12 +211,21 @@ impl PipelineStage for DeduplicateStage {
211211
}
212212

213213
fn execute(&self, ctx: &mut PipelineContext) -> Result<()> {
214+
use crate::core::comment::Severity;
215+
let severity_rank = |s: &Severity| match s {
216+
Severity::Error => 0,
217+
Severity::Warning => 1,
218+
Severity::Info => 2,
219+
Severity::Suggestion => 3,
220+
};
214221
ctx.comments.sort_by(|a, b| {
215222
a.file_path
216223
.cmp(&b.file_path)
217224
.then(a.line_number.cmp(&b.line_number))
218225
.then(a.content.cmp(&b.content))
226+
.then(severity_rank(&a.severity).cmp(&severity_rank(&b.severity)))
219227
});
228+
// dedup_by keeps b (the earlier element); our sort puts highest severity first
220229
ctx.comments.dedup_by(|a, b| {
221230
a.file_path == b.file_path && a.line_number == b.line_number && a.content == b.content
222231
});
@@ -688,6 +697,28 @@ mod tests {
688697
assert_eq!(ctx.comments.len(), 2);
689698
}
690699

700+
// Regression: DeduplicateStage must keep the highest severity when deduplicating
701+
#[test]
702+
fn test_deduplicate_preserves_highest_severity() {
703+
let mut ctx = PipelineContext::new();
704+
let mut info = make_comment("test.rs", 10, "duplicate issue", 0.7);
705+
info.severity = Severity::Info;
706+
let mut error = make_comment("test.rs", 10, "duplicate issue", 0.7);
707+
error.severity = Severity::Error;
708+
// Insert lower severity first to ensure sort fixes order
709+
ctx.comments.push(info);
710+
ctx.comments.push(error);
711+
712+
let stage = DeduplicateStage;
713+
stage.execute(&mut ctx).unwrap();
714+
assert_eq!(ctx.comments.len(), 1);
715+
assert_eq!(
716+
ctx.comments[0].severity,
717+
Severity::Error,
718+
"DeduplicateStage should keep the highest severity"
719+
);
720+
}
721+
691722
// Regression: SortBySeverityStage must sort Error > Warning > Info
692723
#[test]
693724
fn test_sort_by_severity_order() {

src/core/diff_parser.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -544,7 +544,7 @@ index 0000000..f735c20\n\
544544

545545
#[test]
546546
fn test_parse_hunk_empty_lines_not_skipped() {
547-
// BUG: empty lines in diff body are skipped, causing line number desync.
547+
// Regression: empty lines in diff body were previously skipped.
548548
// An empty line (no leading space) in some diff tools represents an empty
549549
// context line. The parser should treat it as context, not skip it.
550550
let diff_text = "\
@@ -579,7 +579,7 @@ index abc..def 100644\n\
579579

580580
#[test]
581581
fn test_parse_text_diff_sets_is_new_for_new_file() {
582-
// BUG: parse_text_diff always sets is_new=false even when old_content is empty
582+
// Regression: parse_text_diff must set is_new when old_content is empty
583583
let diff =
584584
DiffParser::parse_text_diff("", "new content\n", PathBuf::from("new_file.rs")).unwrap();
585585
assert!(
@@ -590,7 +590,7 @@ index abc..def 100644\n\
590590

591591
#[test]
592592
fn test_parse_text_diff_sets_is_deleted_for_deleted_file() {
593-
// BUG: parse_text_diff always sets is_deleted=false even when new_content is empty
593+
// Regression: parse_text_diff must set is_deleted when new_content is empty
594594
let diff =
595595
DiffParser::parse_text_diff("old content\n", "", PathBuf::from("deleted_file.rs"))
596596
.unwrap();

src/core/function_chunker.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -916,7 +916,7 @@ fn next_func() {
916916

917917
#[test]
918918
fn test_find_function_end_single_quoted_string_with_brace() {
919-
// BUG: single-quoted strings aren't tracked, so `'{'` is counted as a real brace
919+
// Regression: single-quoted strings must be tracked so `'{'` isn't a real brace
920920
let lines: Vec<&str> = vec![
921921
"function foo() {", // line 0: depth = 1
922922
" let s = '{';", // line 1: depth should stay 1, but goes to 2
@@ -936,8 +936,8 @@ fn next_func() {
936936

937937
#[test]
938938
fn test_find_function_end_rust_lifetime_not_string() {
939-
// BUG: Rust lifetime annotations ('a, 'static) toggle in_single_quote,
940-
// causing the opening brace on the same line to be ignored.
939+
// Regression: Rust lifetime annotations ('a, 'static) must not toggle
940+
// in_single_quote, which would cause the opening brace to be ignored.
941941
let lines: Vec<&str> = vec![
942942
"pub fn new(name: &'static str) -> Self {", // line 0: has lifetime '
943943
" Self { name }", // line 1

src/review/context_helpers.rs

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -56,30 +56,25 @@ pub fn resolve_pattern_repositories(
5656
resolved
5757
}
5858

59-
fn is_git_source(source: &str) -> bool {
60-
if source.starts_with("https://") || source.starts_with("git@") {
61-
return true;
62-
}
63-
if source.starts_with("http://") && source.ends_with(".git") {
64-
return true;
65-
}
66-
false
67-
}
68-
69-
/// Validate that a pattern repository source URL uses an allowed scheme.
70-
/// Only `https://`, `git@`, and `http://` (with `.git` suffix) are permitted.
59+
/// Check whether a source string looks like a git URL and uses an allowed scheme.
60+
/// Accepts `https://`, `ssh://`, `git@` (SSH shorthand), and `http://` (with `.git` suffix).
7161
fn is_safe_git_url(source: &str) -> bool {
7262
source.starts_with("https://")
63+
|| source.starts_with("ssh://")
7364
|| source.starts_with("git@")
7465
|| (source.starts_with("http://") && source.ends_with(".git"))
7566
}
7667

68+
fn is_git_source(source: &str) -> bool {
69+
is_safe_git_url(source)
70+
}
71+
7772
fn prepare_pattern_repository_checkout(source: &str) -> Option<PathBuf> {
7873
use std::process::Command;
7974

8075
if !is_safe_git_url(source) {
8176
warn!(
82-
"Rejecting pattern repository '{}': only https:// and git@ URLs are allowed",
77+
"Rejecting pattern repository '{}': only https://, ssh://, and git@ URLs are allowed",
8378
source
8479
);
8580
return None;
@@ -527,7 +522,11 @@ mod tests {
527522
#[test]
528523
fn test_is_git_source_rejects_other_schemes() {
529524
assert!(!is_git_source("ftp://example.com/repo.git"));
530-
assert!(!is_git_source("ssh://example.com/repo"));
525+
}
526+
527+
#[test]
528+
fn test_is_git_source_accepts_ssh() {
529+
assert!(is_git_source("ssh://example.com/repo"));
531530
}
532531

533532
#[test]
@@ -539,6 +538,8 @@ mod tests {
539538
#[test]
540539
fn test_is_safe_git_url_allows_ssh() {
541540
assert!(is_safe_git_url("git@github.com:org/repo.git"));
541+
assert!(is_safe_git_url("ssh://example.com/repo"));
542+
assert!(is_safe_git_url("ssh://git@gitlab.internal/org/rules.git"));
542543
}
543544

544545
#[test]
@@ -551,7 +552,6 @@ mod tests {
551552
#[test]
552553
fn test_is_safe_git_url_rejects_arbitrary_schemes() {
553554
assert!(!is_safe_git_url("ftp://example.com/repo"));
554-
assert!(!is_safe_git_url("ssh://example.com/repo"));
555555
assert!(!is_safe_git_url("gopher://example.com/repo"));
556556
}
557557

src/review/triage.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -966,7 +966,7 @@ mod tests {
966966
make_line(2, ChangeType::Added, "#!"),
967967
],
968968
);
969-
// BUG: currently returns NeedsReview because `#` doesn't match `"# "` prefix
969+
// Regression: bare `#` and `#!` must be treated as comments
970970
assert_eq!(triage_diff(&diff), TriageResult::SkipCommentOnly);
971971
}
972972

src/server/storage_json.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1377,7 +1377,7 @@ mod tests {
13771377

13781378
#[tokio::test]
13791379
async fn test_error_rate_only_counts_explicit_failures() {
1380-
// BUG: `failed = total - completed` misclassifies non-failure events as failures.
1380+
// Regression: `failed = total - completed` used to misclassify non-failure events.
13811381
// Events with type "review.started" or "review.timeout" are counted as failures.
13821382
let dir = tempfile::tempdir().unwrap();
13831383
let backend = JsonStorageBackend::new(&dir.path().join("reviews.json"));
@@ -1425,8 +1425,8 @@ mod tests {
14251425
.get_event_stats(&EventFilters::default())
14261426
.await
14271427
.unwrap();
1428-
// Only "review.failed" should count as a failure (1 out of 3)
1429-
// BUG: currently reports 2/3 because timeout is also counted as failed
1428+
// Only "review.failed" should count as a failure (1 out of 3).
1429+
// "review.timeout" is a separate event type and should not inflate error_rate.
14301430
let expected_error_rate = 1.0 / 3.0;
14311431
assert!(
14321432
(stats.error_rate - expected_error_rate).abs() < 0.01,

0 commit comments

Comments
 (0)