Skip to content

Commit 45e0b97

Browse files
haasonsaasclaude
andauthored
fix: 3 bugs found via mutation testing + 55 adversarial tests (#34)
Bugs fixed: - compression: zero token budget and all-deletion-only diffs fell through to Stage 3/4 instead of returning early when no diffs fit the budget - llm_response: JSON parser silently dropped findings when LLMs returned line numbers as strings ("42") instead of integers (42) New tests cover mutation testing gaps and adversarial edge cases across compression, triage, verification, and LLM response parsing. Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 1b62f94 commit 45e0b97

File tree

4 files changed

+646
-6
lines changed

4 files changed

+646
-6
lines changed

src/parsing/llm_response.rs

Lines changed: 191 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -246,12 +246,15 @@ fn parse_json_format(content: &str, file_path: &Path) -> Vec<core::comment::RawC
246246
return items
247247
.iter()
248248
.filter_map(|item| {
249-
let line = item
249+
let line_value = item
250250
.get("line")
251251
.or_else(|| item.get("line_number"))
252-
.or_else(|| item.get("lineNumber"))
253-
.and_then(|v| v.as_u64())
254-
.map(|v| v as usize)?;
252+
.or_else(|| item.get("lineNumber"));
253+
let line = line_value.and_then(|v| {
254+
v.as_u64()
255+
.map(|n| n as usize)
256+
.or_else(|| v.as_str().and_then(|s| s.trim().parse::<usize>().ok()))
257+
})?;
255258
let text = item
256259
.get("issue")
257260
.or_else(|| item.get("description"))
@@ -703,4 +706,188 @@ let data = &input;
703706
assert_eq!(comments.len(), 1);
704707
assert!(comments[0].code_suggestion.is_some());
705708
}
709+
710+
// ── Mutation-testing gap fills ─────────────────────────────────────
711+
712+
#[test]
713+
fn parse_primary_skips_code_fence_markers() {
714+
// ``` markers themselves are skipped (not parsed as comments)
715+
let input = "```rust\n```";
716+
let file_path = PathBuf::from("src/lib.rs");
717+
let comments = parse_llm_response(input, &file_path).unwrap();
718+
assert!(comments.is_empty());
719+
}
720+
721+
#[test]
722+
fn parse_primary_skips_heading_lines() {
723+
let input = "# Code Review\nLine 10: Real issue";
724+
let file_path = PathBuf::from("src/lib.rs");
725+
let comments = parse_llm_response(input, &file_path).unwrap();
726+
// Heading line skipped, but real Line 10 comment caught
727+
assert_eq!(comments.len(), 1);
728+
assert_eq!(comments[0].line_number, 10);
729+
}
730+
731+
#[test]
732+
fn parse_primary_skips_preamble_lines() {
733+
let input = "Here are the issues I found:\nLine 5: Missing check";
734+
let file_path = PathBuf::from("src/lib.rs");
735+
let comments = parse_llm_response(input, &file_path).unwrap();
736+
assert_eq!(comments.len(), 1);
737+
assert_eq!(comments[0].line_number, 5);
738+
}
739+
740+
#[test]
741+
fn parse_json_in_code_block_strategy() {
742+
// Test that extract_json_from_code_block specifically works
743+
let input = "Here are findings:\n```json\n[{\"line\": 7, \"issue\": \"Off by one\"}]\n```";
744+
let file_path = PathBuf::from("src/lib.rs");
745+
let comments = parse_llm_response(input, &file_path).unwrap();
746+
assert_eq!(comments.len(), 1);
747+
assert_eq!(comments[0].line_number, 7);
748+
assert!(comments[0].content.contains("Off by one"));
749+
}
750+
751+
#[test]
752+
fn parse_json_bare_array_strategy() {
753+
// Test find_json_array with text before/after
754+
let input = "Issues found: [{\"line\": 3, \"issue\": \"Bug\"}] end.";
755+
let file_path = PathBuf::from("src/lib.rs");
756+
let comments = parse_llm_response(input, &file_path).unwrap();
757+
assert_eq!(comments.len(), 1);
758+
assert_eq!(comments[0].line_number, 3);
759+
}
760+
761+
// ── Adversarial edge cases ──────────────────────────────────────────
762+
763+
#[test]
764+
fn parse_line_zero_not_panicking() {
765+
// Line 0 is technically invalid but should not panic
766+
let input = "Line 0: Edge case at line zero.";
767+
let file_path = PathBuf::from("src/lib.rs");
768+
let comments = parse_llm_response(input, &file_path).unwrap();
769+
assert_eq!(comments.len(), 1);
770+
assert_eq!(comments[0].line_number, 0);
771+
}
772+
773+
#[test]
774+
fn parse_huge_line_number_no_overflow() {
775+
let input = "Line 999999999999: Absurd line number.";
776+
let file_path = PathBuf::from("src/lib.rs");
777+
// Should either parse successfully or return empty, not panic
778+
let result = parse_llm_response(input, &file_path);
779+
assert!(result.is_ok() || result.is_err());
780+
}
781+
782+
#[test]
783+
fn parse_unicode_content_no_panic() {
784+
let input = "Line 10: 漏洞 — SQL注入风险 🔴";
785+
let file_path = PathBuf::from("src/lib.rs");
786+
let comments = parse_llm_response(input, &file_path).unwrap();
787+
assert_eq!(comments.len(), 1);
788+
assert!(comments[0].content.contains("漏洞"));
789+
}
790+
791+
#[test]
792+
fn parse_numbered_list_with_no_line_number_in_path() {
793+
// Numbered list where file path is missing the colon-number
794+
let input = "1. **src/lib.rs** - Missing null check";
795+
let file_path = PathBuf::from("src/lib.rs");
796+
let comments = parse_llm_response(input, &file_path).unwrap();
797+
// Should not extract a comment with a bogus line number
798+
for c in &comments {
799+
assert!(c.line_number > 0 || comments.is_empty());
800+
}
801+
}
802+
803+
#[test]
804+
fn parse_json_with_nested_brackets() {
805+
// JSON with nested arrays/objects should not confuse the bracket finder
806+
let input =
807+
r#"[{"line": 10, "issue": "Bug with [array] access", "details": {"nested": [1,2,3]}}]"#;
808+
let file_path = PathBuf::from("src/lib.rs");
809+
let comments = parse_llm_response(input, &file_path).unwrap();
810+
assert_eq!(comments.len(), 1);
811+
assert_eq!(comments[0].line_number, 10);
812+
}
813+
814+
#[test]
815+
fn parse_json_with_line_number_as_string() {
816+
// Some LLMs return line numbers as strings — should be handled
817+
let input = r#"[{"line": "42", "issue": "Bug"}]"#;
818+
let file_path = PathBuf::from("src/lib.rs");
819+
let comments = parse_llm_response(input, &file_path).unwrap();
820+
assert_eq!(comments.len(), 1);
821+
assert_eq!(comments[0].line_number, 42);
822+
}
823+
824+
#[test]
825+
fn parse_malformed_json_no_panic() {
826+
let input = r#"[{"line": 10, "issue": "unclosed string}]"#;
827+
let file_path = PathBuf::from("src/lib.rs");
828+
let comments = parse_llm_response(input, &file_path).unwrap();
829+
assert!(comments.is_empty());
830+
}
831+
832+
#[test]
833+
fn parse_mixed_strategies_first_wins() {
834+
// Input matches both primary AND numbered list — primary should win
835+
let input = "Line 10: Primary format.\n1. **src/lib.rs:20** - Numbered format.";
836+
let file_path = PathBuf::from("src/lib.rs");
837+
let comments = parse_llm_response(input, &file_path).unwrap();
838+
// Primary parser should have caught Line 10, so we get that
839+
assert_eq!(comments[0].line_number, 10);
840+
}
841+
842+
#[test]
843+
fn parse_code_suggestion_without_preceding_comment() {
844+
// <<<ORIGINAL block with no prior Line N: comment
845+
let input = "<<<ORIGINAL\nold code\n===\nnew code\n>>>SUGGESTED";
846+
let file_path = PathBuf::from("src/lib.rs");
847+
let comments = parse_llm_response(input, &file_path).unwrap();
848+
// Should not panic; suggestion just gets dropped since no comment to attach to
849+
assert!(comments.is_empty());
850+
}
851+
852+
#[test]
853+
fn parse_unclosed_code_suggestion_block() {
854+
// <<<ORIGINAL without >>>SUGGESTED
855+
let input = "Line 5: Issue here.\n<<<ORIGINAL\nold code\n===\nnew code";
856+
let file_path = PathBuf::from("src/lib.rs");
857+
let comments = parse_llm_response(input, &file_path).unwrap();
858+
assert_eq!(comments.len(), 1);
859+
// The code suggestion should be None since the block was never closed
860+
assert!(comments[0].code_suggestion.is_none());
861+
}
862+
863+
#[test]
864+
fn parse_only_whitespace_input() {
865+
let input = " \n\n \t \n ";
866+
let file_path = PathBuf::from("src/lib.rs");
867+
let comments = parse_llm_response(input, &file_path).unwrap();
868+
assert!(comments.is_empty());
869+
}
870+
871+
#[test]
872+
fn parse_json_in_code_block_with_extra_text() {
873+
let input = "Here are my findings:\n```json\n[{\"line\": 5, \"issue\": \"Bug\"}]\n```\nLet me know if you need more details.";
874+
let file_path = PathBuf::from("src/lib.rs");
875+
let comments = parse_llm_response(input, &file_path).unwrap();
876+
assert_eq!(comments.len(), 1);
877+
assert_eq!(comments[0].line_number, 5);
878+
}
879+
880+
#[test]
881+
fn parse_file_line_format_does_not_match_urls() {
882+
// URLs with port numbers like http://localhost:8080 should not be parsed as file:line
883+
let input = "Visit http://localhost:8080 for the dashboard.";
884+
let file_path = PathBuf::from("src/lib.rs");
885+
let comments = parse_llm_response(input, &file_path).unwrap();
886+
// Should not extract port 8080 as a line number
887+
assert!(
888+
comments.is_empty(),
889+
"URL port should not be parsed as line number, got {:?}",
890+
comments.iter().map(|c| c.line_number).collect::<Vec<_>>()
891+
);
892+
}
706893
}

0 commit comments

Comments
 (0)