Skip to content

Commit c36ce89

Browse files
haasonsaasclaude
andcommitted
feat: audit fixes — graceful OTEL, async-safe plugins, URL validation, +100 tests
- OTEL init no longer panics on misconfigured endpoints; falls back gracefully - Semgrep/ESLint plugins use tokio::spawn_blocking to avoid blocking the runtime - Added semgrep --timeout flag (30s) for process execution safety - Git clone URL validation: only https://, git@, and http://*.git accepted - Added PartialEq derive to ContextType for test assertions - 100+ new tests across storage_json, interactive commands, plugins, CLI commands (pr.rs, git.rs), and duplicate_filter Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent c8af0ed commit c36ce89

File tree

8 files changed

+1543
-48
lines changed

8 files changed

+1543
-48
lines changed

src/core/context.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ pub struct LLMContextChunk {
1515
pub line_range: Option<(usize, usize)>,
1616
}
1717

18-
#[derive(Debug, Clone, Serialize, Deserialize)]
18+
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1919
pub enum ContextType {
2020
FileContent,
2121
Definition,

src/core/interactive.rs

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,188 @@ Interactive commands respect these configurations."#
246246
}
247247
}
248248

249+
#[cfg(test)]
250+
mod tests {
251+
use super::*;
252+
253+
// === InteractiveCommand::parse tests ===
254+
255+
#[test]
256+
fn test_parse_review_command() {
257+
let cmd = InteractiveCommand::parse("@diffscope review").unwrap();
258+
assert_eq!(cmd.command, CommandType::Review);
259+
assert!(cmd.args.is_empty());
260+
assert!(cmd.context.is_none());
261+
}
262+
263+
#[test]
264+
fn test_parse_review_with_focus() {
265+
let cmd = InteractiveCommand::parse("@diffscope review security performance").unwrap();
266+
assert_eq!(cmd.command, CommandType::Review);
267+
assert_eq!(cmd.args, vec!["security", "performance"]);
268+
}
269+
270+
#[test]
271+
fn test_parse_ignore_command() {
272+
let cmd = InteractiveCommand::parse("@diffscope ignore src/generated/").unwrap();
273+
assert_eq!(cmd.command, CommandType::Ignore);
274+
assert_eq!(cmd.args, vec!["src/generated/"]);
275+
}
276+
277+
#[test]
278+
fn test_parse_explain_command() {
279+
let cmd = InteractiveCommand::parse("@diffscope explain").unwrap();
280+
assert_eq!(cmd.command, CommandType::Explain);
281+
}
282+
283+
#[test]
284+
fn test_parse_generate_tests() {
285+
let cmd = InteractiveCommand::parse("@diffscope generate tests").unwrap();
286+
assert_eq!(cmd.command, CommandType::Generate);
287+
assert_eq!(cmd.args, vec!["tests"]);
288+
}
289+
290+
#[test]
291+
fn test_parse_help() {
292+
let cmd = InteractiveCommand::parse("@diffscope help").unwrap();
293+
assert_eq!(cmd.command, CommandType::Help);
294+
}
295+
296+
#[test]
297+
fn test_parse_config() {
298+
let cmd = InteractiveCommand::parse("@diffscope config").unwrap();
299+
assert_eq!(cmd.command, CommandType::Config);
300+
}
301+
302+
#[test]
303+
fn test_parse_case_insensitive() {
304+
let cmd = InteractiveCommand::parse("@diffscope REVIEW").unwrap();
305+
assert_eq!(cmd.command, CommandType::Review);
306+
307+
let cmd = InteractiveCommand::parse("@diffscope Help").unwrap();
308+
assert_eq!(cmd.command, CommandType::Help);
309+
}
310+
311+
#[test]
312+
fn test_parse_unknown_command_returns_none() {
313+
assert!(InteractiveCommand::parse("@diffscope foobar").is_none());
314+
}
315+
316+
#[test]
317+
fn test_parse_no_at_mention_returns_none() {
318+
assert!(InteractiveCommand::parse("just a regular comment").is_none());
319+
}
320+
321+
#[test]
322+
fn test_parse_empty_string_returns_none() {
323+
assert!(InteractiveCommand::parse("").is_none());
324+
}
325+
326+
#[test]
327+
fn test_parse_at_mention_only_returns_none() {
328+
assert!(InteractiveCommand::parse("@diffscope").is_none());
329+
}
330+
331+
#[test]
332+
fn test_parse_embedded_in_text() {
333+
// Command embedded in longer comment
334+
let cmd =
335+
InteractiveCommand::parse("Hey team, can you @diffscope review this PR?").unwrap();
336+
assert_eq!(cmd.command, CommandType::Review);
337+
}
338+
339+
#[test]
340+
fn test_parse_extra_whitespace() {
341+
let cmd = InteractiveCommand::parse("@diffscope review security").unwrap();
342+
assert_eq!(cmd.command, CommandType::Review);
343+
assert_eq!(cmd.args, vec!["security"]);
344+
}
345+
346+
#[test]
347+
fn test_parse_different_at_mention_ignored() {
348+
assert!(InteractiveCommand::parse("@someone review").is_none());
349+
}
350+
351+
// === execute_ignore tests ===
352+
353+
#[test]
354+
fn test_ignore_no_args() {
355+
let cmd = InteractiveCommand {
356+
command: CommandType::Ignore,
357+
args: vec![],
358+
context: None,
359+
};
360+
let result = cmd.execute_ignore().unwrap();
361+
assert!(result.contains("specify what to ignore"));
362+
}
363+
364+
#[test]
365+
fn test_ignore_with_patterns() {
366+
let cmd = InteractiveCommand {
367+
command: CommandType::Ignore,
368+
args: vec!["src/generated/".to_string(), "*.test.js".to_string()],
369+
context: None,
370+
};
371+
let result = cmd.execute_ignore().unwrap();
372+
assert!(result.contains("src/generated/"));
373+
assert!(result.contains("*.test.js"));
374+
}
375+
376+
// === help_text / config tests ===
377+
378+
#[test]
379+
fn test_help_text_contains_commands() {
380+
let help = InteractiveCommand::help_text();
381+
assert!(help.contains("@diffscope review"));
382+
assert!(help.contains("@diffscope ignore"));
383+
assert!(help.contains("@diffscope explain"));
384+
assert!(help.contains("@diffscope generate"));
385+
assert!(help.contains("@diffscope help"));
386+
assert!(help.contains("@diffscope config"));
387+
}
388+
389+
#[test]
390+
fn test_config_info_contains_yaml() {
391+
let info = InteractiveCommand::get_config_info();
392+
assert!(info.contains(".diffscope.yml"));
393+
assert!(info.contains("exclude_patterns"));
394+
}
395+
396+
// === InteractiveProcessor tests ===
397+
398+
#[test]
399+
fn test_processor_new_empty() {
400+
let processor = InteractiveProcessor::new();
401+
assert!(!processor.should_ignore("any/path"));
402+
}
403+
404+
#[test]
405+
fn test_processor_add_and_check_pattern() {
406+
let mut processor = InteractiveProcessor::new();
407+
processor.add_ignore_pattern("src/generated/");
408+
assert!(processor.should_ignore("src/generated/types.rs"));
409+
assert!(!processor.should_ignore("src/main.rs"));
410+
}
411+
412+
#[test]
413+
fn test_processor_glob_pattern() {
414+
let mut processor = InteractiveProcessor::new();
415+
processor.add_ignore_pattern("*.test.js");
416+
assert!(processor.should_ignore("foo.test.js"));
417+
assert!(!processor.should_ignore("foo.js"));
418+
}
419+
420+
#[test]
421+
fn test_processor_multiple_patterns() {
422+
let mut processor = InteractiveProcessor::new();
423+
processor.add_ignore_pattern("vendor/");
424+
processor.add_ignore_pattern("*.generated.*");
425+
assert!(processor.should_ignore("vendor/lib.js"));
426+
assert!(processor.should_ignore("types.generated.ts"));
427+
assert!(!processor.should_ignore("src/main.rs"));
428+
}
429+
}
430+
249431
/// Manages per-session ignore patterns from @diffscope ignore commands.
250432
/// Will be wired into the review pipeline's triage filter.
251433
#[allow(dead_code)]

src/main.rs

Lines changed: 36 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -304,32 +304,44 @@ async fn main() -> Result<()> {
304304
let _otel_guard: Option<opentelemetry_sdk::trace::TracerProvider> = {
305305
let otel_enabled = std::env::var("OTEL_EXPORTER_OTLP_ENDPOINT").is_ok();
306306
if otel_enabled {
307-
let exporter = opentelemetry_otlp::SpanExporter::builder()
307+
match opentelemetry_otlp::SpanExporter::builder()
308308
.with_tonic()
309309
.build()
310-
.expect("Failed to build OTLP span exporter");
311-
312-
let tracer_provider = opentelemetry_sdk::trace::TracerProvider::builder()
313-
.with_batch_exporter(exporter, opentelemetry_sdk::runtime::Tokio)
314-
.with_resource(opentelemetry_sdk::Resource::new(vec![
315-
opentelemetry::KeyValue::new("service.name", "diffscope"),
316-
]))
317-
.build();
318-
319-
opentelemetry::global::set_tracer_provider(tracer_provider.clone());
320-
321-
let otel_layer =
322-
tracing_opentelemetry::layer().with_tracer(tracer_provider.tracer("diffscope"));
323-
324-
let subscriber = tracing_subscriber::fmt::Subscriber::builder()
325-
.with_env_filter(filter)
326-
.finish()
327-
.with(otel_layer);
328-
329-
tracing::subscriber::set_global_default(subscriber)
330-
.expect("Failed to set tracing subscriber");
331-
332-
Some(tracer_provider)
310+
{
311+
Ok(exporter) => {
312+
let tracer_provider = opentelemetry_sdk::trace::TracerProvider::builder()
313+
.with_batch_exporter(exporter, opentelemetry_sdk::runtime::Tokio)
314+
.with_resource(opentelemetry_sdk::Resource::new(vec![
315+
opentelemetry::KeyValue::new("service.name", "diffscope"),
316+
]))
317+
.build();
318+
319+
opentelemetry::global::set_tracer_provider(tracer_provider.clone());
320+
321+
let otel_layer = tracing_opentelemetry::layer()
322+
.with_tracer(tracer_provider.tracer("diffscope"));
323+
324+
let subscriber = tracing_subscriber::fmt::Subscriber::builder()
325+
.with_env_filter(filter)
326+
.finish()
327+
.with(otel_layer);
328+
329+
if let Err(e) = tracing::subscriber::set_global_default(subscriber) {
330+
eprintln!("Warning: failed to set OTEL tracing subscriber: {}", e);
331+
// Already initialized by another thread or test — not fatal
332+
}
333+
334+
Some(tracer_provider)
335+
}
336+
Err(e) => {
337+
eprintln!(
338+
"Warning: OTEL_EXPORTER_OTLP_ENDPOINT set but exporter failed to initialize: {}. Continuing without OpenTelemetry.",
339+
e
340+
);
341+
tracing_subscriber::fmt().with_env_filter(filter).init();
342+
None
343+
}
344+
}
333345
} else {
334346
tracing_subscriber::fmt().with_env_filter(filter).init();
335347
None

src/plugins/builtin/duplicate_filter.rs

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,3 +33,102 @@ impl PostProcessor for DuplicateFilter {
3333
Ok(comments)
3434
}
3535
}
36+
37+
#[cfg(test)]
38+
mod tests {
39+
use super::*;
40+
use crate::core::comment::{Category, FixEffort, Severity};
41+
use std::path::PathBuf;
42+
43+
fn make_comment(file: &str, line: usize, content: &str) -> Comment {
44+
Comment {
45+
id: format!("c-{}-{}", file, line),
46+
file_path: PathBuf::from(file),
47+
line_number: line,
48+
content: content.to_string(),
49+
rule_id: None,
50+
severity: Severity::Warning,
51+
category: Category::Style,
52+
suggestion: None,
53+
confidence: 0.8,
54+
code_suggestion: None,
55+
tags: vec![],
56+
fix_effort: FixEffort::Low,
57+
feedback: None,
58+
}
59+
}
60+
61+
#[test]
62+
fn test_duplicate_filter_id() {
63+
let filter = DuplicateFilter::new();
64+
assert_eq!(filter.id(), "duplicate_filter");
65+
}
66+
67+
#[tokio::test]
68+
async fn test_removes_exact_duplicates() {
69+
let filter = DuplicateFilter::new();
70+
let comments = vec![
71+
make_comment("a.rs", 10, "fix this"),
72+
make_comment("a.rs", 10, "fix this"),
73+
make_comment("a.rs", 10, "fix this"),
74+
];
75+
let result = filter.run(comments, "/repo").await.unwrap();
76+
assert_eq!(result.len(), 1);
77+
}
78+
79+
#[tokio::test]
80+
async fn test_keeps_different_lines() {
81+
let filter = DuplicateFilter::new();
82+
let comments = vec![
83+
make_comment("a.rs", 10, "fix this"),
84+
make_comment("a.rs", 20, "fix this"),
85+
];
86+
let result = filter.run(comments, "/repo").await.unwrap();
87+
assert_eq!(result.len(), 2);
88+
}
89+
90+
#[tokio::test]
91+
async fn test_keeps_different_content() {
92+
let filter = DuplicateFilter::new();
93+
let comments = vec![
94+
make_comment("a.rs", 10, "fix this"),
95+
make_comment("a.rs", 10, "fix that"),
96+
];
97+
let result = filter.run(comments, "/repo").await.unwrap();
98+
assert_eq!(result.len(), 2);
99+
}
100+
101+
#[tokio::test]
102+
async fn test_keeps_different_files() {
103+
let filter = DuplicateFilter::new();
104+
let comments = vec![
105+
make_comment("a.rs", 10, "fix this"),
106+
make_comment("b.rs", 10, "fix this"),
107+
];
108+
let result = filter.run(comments, "/repo").await.unwrap();
109+
assert_eq!(result.len(), 2);
110+
}
111+
112+
#[tokio::test]
113+
async fn test_empty_input() {
114+
let filter = DuplicateFilter::new();
115+
let result = filter.run(vec![], "/repo").await.unwrap();
116+
assert!(result.is_empty());
117+
}
118+
119+
#[tokio::test]
120+
async fn test_preserves_order() {
121+
let filter = DuplicateFilter::new();
122+
let comments = vec![
123+
make_comment("c.rs", 1, "third"),
124+
make_comment("a.rs", 1, "first"),
125+
make_comment("b.rs", 1, "second"),
126+
make_comment("a.rs", 1, "first"), // duplicate
127+
];
128+
let result = filter.run(comments, "/repo").await.unwrap();
129+
assert_eq!(result.len(), 3);
130+
assert_eq!(result[0].content, "third");
131+
assert_eq!(result[1].content, "first");
132+
assert_eq!(result[2].content, "second");
133+
}
134+
}

0 commit comments

Comments
 (0)