Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions src/config/schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ pub struct Config {
pub timeout: u64,
/// Cache TTL in minutes for review caching.
pub cache_ttl: u64,
/// Static analysis context injection for reviews.
pub static_analysis: StaticAnalysisConfig,
}

/// Provider configuration.
Expand Down Expand Up @@ -110,6 +112,7 @@ impl Default for Config {
max_tokens: 4096,
timeout: 120,
cache_ttl: 1440, // 24h in minutes
static_analysis: StaticAnalysisConfig::default(),
}
}
}
Expand Down Expand Up @@ -189,6 +192,26 @@ pub struct ReviewSection {
pub system_prompt: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub system_prompt_file: Option<String>,
/// Static analysis context injection (e.g., clippy output).
#[serde(skip_serializing_if = "Option::is_none")]
pub static_analysis: Option<StaticAnalysisConfig>,
}

/// Static analysis configuration — inject linter/compiler output as review context.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct StaticAnalysisConfig {
/// Automatically run `cargo clippy` and inject output as context.
/// Adds ~2-5 seconds to review time.
#[serde(default, skip_serializing_if = "is_default")]
pub auto_clippy: bool,
/// Path to a file containing pre-computed static analysis output (e.g., clippy JSON).
/// If set, this file's content is injected instead of running auto_clippy.
#[serde(skip_serializing_if = "Option::is_none")]
pub clippy_output_file: Option<String>,
}

fn is_default<T: Default + PartialEq>(val: &T) -> bool {
*val == T::default()
}

/// Scan-specific configuration section.
Expand Down Expand Up @@ -275,6 +298,9 @@ impl CoraFile {
if let Some(v) = &r.system_prompt_file {
config.review_system_prompt_file = Some(v.clone());
}
if let Some(sa) = &r.static_analysis {
config.static_analysis.clone_from(sa);
}
}
if let Some(s) = &self.scan {
if let Some(v) = &s.system_prompt {
Expand Down Expand Up @@ -629,6 +655,7 @@ review:
response_format: Some("json_object".to_string()),
system_prompt: None,
system_prompt_file: None,
static_analysis: None,
}),
..Default::default()
};
Expand All @@ -644,6 +671,7 @@ review:
response_format: None,
system_prompt: Some("Custom prompt here.".to_string()),
system_prompt_file: None,
static_analysis: None,
}),
..Default::default()
};
Expand All @@ -662,6 +690,7 @@ review:
response_format: None,
system_prompt: None,
system_prompt_file: Some("prompts/review.md".to_string()),
static_analysis: None,
}),
..Default::default()
};
Expand Down
35 changes: 27 additions & 8 deletions src/engine/llm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,7 @@ fn create_spinner(message: &str) -> ProgressBar {
}

/// Review a diff using the LLM. Returns a `ReviewResponse`.
#[allow(clippy::too_many_arguments)]
pub async fn review_diff(
llm_config: &LLMConfig,
diff: &str,
Expand All @@ -265,14 +266,15 @@ pub async fn review_diff(
response_format: &str,
system_prompt_override: Option<&str>,
quiet: bool,
static_context: Option<&str>,
) -> std::result::Result<ReviewResponse, CoraError> {
let spinner = if quiet {
None
} else {
Some(create_spinner("Reviewing diff…"))
};

let user_prompt = build_review_prompt(diff, focus, rules);
let user_prompt = build_review_prompt(diff, focus, rules, static_context);

let system_prompt = system_prompt_override.unwrap_or(REVIEW_SYSTEM_PROMPT);

Expand Down Expand Up @@ -336,15 +338,17 @@ pub async fn review_diff(
///
/// Streams tokens from the LLM and prints them to stdout in real-time,
/// then collects the full response for parsing.
#[allow(clippy::too_many_arguments)]
pub async fn review_diff_stream(
llm_config: &LLMConfig,
diff: &str,
focus: &[String],
rules: &[String],
response_format: &str,
system_prompt_override: Option<&str>,
static_context: Option<&str>,
) -> std::result::Result<ReviewResponse, CoraError> {
let user_prompt = build_review_prompt(diff, focus, rules);
let user_prompt = build_review_prompt(diff, focus, rules, static_context);

let system_prompt = system_prompt_override.unwrap_or(REVIEW_SYSTEM_PROMPT);

Expand Down Expand Up @@ -585,7 +589,12 @@ pub(crate) fn extract_file_paths_from_diff(diff: &str) -> Vec<String> {

/// Build the user prompt for diff review.
#[allow(clippy::format_push_string)]
fn build_review_prompt(diff: &str, focus: &[String], rules: &[String]) -> String {
fn build_review_prompt(
diff: &str,
focus: &[String],
rules: &[String],
static_context: Option<&str>,
) -> String {
let mut prompt = String::new();

// Inject valid file paths to reduce hallucination
Expand All @@ -598,6 +607,16 @@ fn build_review_prompt(diff: &str, focus: &[String], rules: &[String]) -> String
prompt.push('\n');
}

// Inject static analysis context (clippy output, etc.)
if let Some(ctx) = static_context {
if !ctx.is_empty() {
prompt.push_str("Static analysis context (pre-verified by compiler/linter):\n");
prompt.push_str("---\n");
prompt.push_str(ctx);
prompt.push_str("\n---\n\n");
}
}

if !focus.is_empty() {
prompt.push_str(&format!("Focus areas: {}\n\n", focus.join(", ")));
}
Expand Down Expand Up @@ -1066,34 +1085,34 @@ mod tests {

#[test]
fn build_prompt_basic() {
let prompt = build_review_prompt("diff content", &[], &[]);
let prompt = build_review_prompt("diff content", &[], &[], None);
assert!(prompt.contains("diff content"));
assert!(prompt.contains("```diff"));
}

#[test]
fn build_prompt_with_focus() {
let prompt = build_review_prompt("d", &["security".to_string()], &[]);
let prompt = build_review_prompt("d", &["security".to_string()], &[], None);
assert!(prompt.contains("Focus areas: security"));
}

#[test]
fn build_prompt_with_rules() {
let prompt = build_review_prompt("d", &[], &["no unwrap".to_string()]);
let prompt = build_review_prompt("d", &[], &["no unwrap".to_string()], None);
assert!(prompt.contains("no unwrap"));
}

#[test]
fn build_prompt_contains_file_paths() {
let diff = "diff --git a/src/main.rs b/src/main.rs\n--- a/src/main.rs\n+++ b/src/main.rs\n@@ -1 +1 @@\n- old\n+ new";
let prompt = build_review_prompt(diff, &[], &[]);
let prompt = build_review_prompt(diff, &[], &[], None);
assert!(prompt.contains("Valid files in this diff:"));
assert!(prompt.contains("src/main.rs"));
}

#[test]
fn build_prompt_no_file_paths_for_empty_diff() {
let prompt = build_review_prompt("no diff headers here", &[], &[]);
let prompt = build_review_prompt("no diff headers here", &[], &[], None);
assert!(!prompt.contains("Valid files in this diff:"));
}

Expand Down
1 change: 1 addition & 0 deletions src/engine/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ pub mod cache;
pub mod llm;
pub mod review;
pub mod scanner;
pub mod static_analysis;
pub mod types;

// Re-export commonly used types from other modules for convenience
Expand Down
6 changes: 6 additions & 0 deletions src/engine/review.rs
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,10 @@ async fn review_diff_inner(
config.review_system_prompt_file.as_deref(),
);

// Collect static analysis context (clippy output, etc.)
let static_context =
crate::engine::static_analysis::collect_static_context(diff, &config.static_analysis);

let mut response = if stream {
llm::review_diff_stream(
llm_config,
Expand All @@ -117,6 +121,7 @@ async fn review_diff_inner(
&config.rules,
&config.response_format,
review_prompt.as_deref(),
static_context.as_deref(),
)
.await?
} else {
Expand All @@ -128,6 +133,7 @@ async fn review_diff_inner(
&config.response_format,
review_prompt.as_deref(),
quiet,
static_context.as_deref(),
)
.await?
};
Expand Down
Loading
Loading