Skip to content

Commit 5af8a45

Browse files
authored
feat: natural language review rules (#12) and optional skip deletion-only (#29) (#44)
- Config: review_rules_prose (YAML list), triage_skip_deletion_only (bool) - Guidance: prose_rules_section() injects custom rules into review prompt - Triage: TriageOptions.skip_deletion_only, SkipDeletionOnly result - Pipeline: prepare_diff_analysis uses triage options from config - ROADMAP: shipped section updated Made-with: Cursor
1 parent f2dc456 commit 5af8a45

File tree

9 files changed

+151
-4
lines changed

9 files changed

+151
-4
lines changed

docs/ROADMAP.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,8 @@ Create labels once: `priority: high`, `priority: medium`, `priority: low`, `area
7272

7373
## Shipped (recent)
7474

75+
- **Natural language rules (#12):** `review_rules_prose: [ "Rule one", "Rule two" ]` in config; injected as "Custom rules (natural language)" bullets into review guidance. Tests: `test_config_deserialize_review_rules_prose_from_yaml`, `build_review_guidance_includes_prose_rules`.
76+
- **Triage skip deletion-only (#29):** `triage_skip_deletion_only: true` in config; when true, deletion-only diffs get `SkipDeletionOnly` and skip expensive review. Default false. Tests: `test_triage_deletion_only_with_skip_true_returns_skip_deletion_only`, config deserialize.
7577
- **LLM parsing (#28):** Repair candidate for diff-style line prefixes (`+` on each line) in `repair_json_candidates`; test `parse_json_with_diff_prefix_artifact`.
7678
- **Secrets (#20):** Built-in secret scanner in `plugins/builtin/secret_scanner.rs`.
7779
- **Verification (#23):** Verification pass and config (verification.*) in pipeline.

src/config.rs

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,10 @@ pub struct Config {
269269
#[serde(default)]
270270
pub review_instructions: Option<String>,
271271

272+
/// Natural language review rules (one per item); injected into prompt as bullets (#12).
273+
#[serde(default)]
274+
pub review_rules_prose: Option<Vec<String>>,
275+
272276
#[serde(default = "default_true")]
273277
pub smart_review_summary: bool,
274278

@@ -302,6 +306,10 @@ pub struct Config {
302306
#[serde(default = "default_symbol_index_lsp_languages")]
303307
pub symbol_index_lsp_languages: HashMap<String, String>,
304308

309+
/// When true, triage skips deletion-only diffs (#29). Default false (deletions get review).
310+
#[serde(default)]
311+
pub triage_skip_deletion_only: bool,
312+
305313
#[serde(default = "default_feedback_path")]
306314
pub feedback_path: PathBuf,
307315

@@ -557,6 +565,7 @@ impl Default for Config {
557565
comment_types: default_comment_types(),
558566
review_profile: None,
559567
review_instructions: None,
568+
review_rules_prose: None,
560569
smart_review_summary: true,
561570
smart_review_diagram: false,
562571
symbol_index: true,
@@ -568,6 +577,7 @@ impl Default for Config {
568577
symbol_index_graph_max_files: default_symbol_index_graph_max_files(),
569578
symbol_index_lsp_command: None,
570579
symbol_index_lsp_languages: default_symbol_index_lsp_languages(),
580+
triage_skip_deletion_only: false,
571581
feedback_path: default_feedback_path(),
572582
eval_trend_path: default_eval_trend_path(),
573583
feedback_eval_trend_path: default_feedback_eval_trend_path(),
@@ -2147,6 +2157,33 @@ verification_consensus_mode: all
21472157
);
21482158
}
21492159

2160+
#[test]
2161+
fn test_config_deserialize_review_rules_prose_from_yaml() {
2162+
// #12: natural language rules — list of strings in YAML
2163+
let yaml = r#"
2164+
model: claude-sonnet-4-6
2165+
review_rules_prose:
2166+
- Always use parameterized queries.
2167+
- No direct SQL string concatenation.
2168+
"#;
2169+
let config: Config = serde_yaml::from_str(yaml).unwrap();
2170+
let rules = config.review_rules_prose.as_ref().unwrap();
2171+
assert_eq!(rules.len(), 2);
2172+
assert!(rules[0].contains("parameterized"));
2173+
assert!(rules[1].contains("SQL"));
2174+
}
2175+
2176+
#[test]
2177+
fn test_config_deserialize_triage_skip_deletion_only() {
2178+
// #29: optional skip deletion-only diffs
2179+
let yaml = r#"
2180+
model: claude-sonnet-4-6
2181+
triage_skip_deletion_only: true
2182+
"#;
2183+
let config: Config = serde_yaml::from_str(yaml).unwrap();
2184+
assert!(config.triage_skip_deletion_only);
2185+
}
2186+
21502187
#[test]
21512188
fn test_default_frontier_role_models_match_requested_pair() {
21522189
let config = Config::default();

src/review/pipeline/guidance.rs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,4 +96,23 @@ mod tests {
9696
let guidance = build_review_guidance(&config, None).unwrap();
9797
assert!(guidance.contains("Do not include code fix suggestions"));
9898
}
99+
100+
#[test]
101+
fn build_review_guidance_includes_prose_rules() {
102+
// #12: natural language custom rules — injected as bullets into guidance
103+
let config = config::Config {
104+
review_rules_prose: Some(vec![
105+
"Always use parameterized queries.".to_string(),
106+
"No direct SQL string concatenation.".to_string(),
107+
]),
108+
..config::Config::default()
109+
};
110+
let guidance = build_review_guidance(&config, None).unwrap();
111+
assert!(
112+
guidance.contains("Custom rules (natural language)"),
113+
"guidance should include prose rules section"
114+
);
115+
assert!(guidance.contains("Always use parameterized queries"));
116+
assert!(guidance.contains("No direct SQL string concatenation"));
117+
}
99118
}

src/review/pipeline/guidance/sections.rs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ pub(super) fn collect_guidance_sections(
99
push_section(&mut sections, comment_types_section(config));
1010
push_section(&mut sections, review_profile_section(config));
1111
push_section(&mut sections, global_instructions_section(config));
12+
push_section(&mut sections, prose_rules_section(config));
1213
push_section(&mut sections, path_instructions_section(path_config));
1314
push_section(&mut sections, output_language_section(config));
1415
sections.push(fix_suggestion_section(config));
@@ -66,6 +67,25 @@ fn global_instructions_section(config: &config::Config) -> Option<String> {
6667
}
6768
}
6869

70+
fn prose_rules_section(config: &config::Config) -> Option<String> {
71+
let rules = config.review_rules_prose.as_deref()?;
72+
let rules: Vec<&str> = rules
73+
.iter()
74+
.map(String::as_str)
75+
.filter(|s| !s.trim().is_empty())
76+
.collect();
77+
if rules.is_empty() {
78+
None
79+
} else {
80+
let bullets = rules
81+
.iter()
82+
.map(|r| format!("- {}", r.trim()))
83+
.collect::<Vec<_>>()
84+
.join("\n");
85+
Some(format!("Custom rules (natural language):\n{bullets}"))
86+
}
87+
}
88+
6989
fn path_instructions_section(path_config: Option<&config::PathConfig>) -> Option<String> {
7090
let instructions = path_config?.review_instructions.as_deref()?.trim();
7191
if instructions.is_empty() {

src/review/pipeline/prepare/runner/analysis.rs

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ use std::path::PathBuf;
44

55
use crate::core;
66
use crate::plugins::PreAnalysis;
7-
use crate::review::triage::triage_diff;
7+
use crate::review::triage::{triage_diff_with_options, TriageOptions};
88

99
use super::super::super::comments::{filter_comments_for_diff, synthesize_analyzer_comments};
1010

@@ -22,6 +22,7 @@ pub(super) struct PreparedDiffAnalysis {
2222
pub(super) fn prepare_diff_analysis(
2323
diff: &core::UnifiedDiff,
2424
batched_pre_analysis: &mut HashMap<PathBuf, PreAnalysis>,
25+
triage_skip_deletion_only: bool,
2526
) -> Result<DiffPreparationDecision> {
2627
let pre_analysis = batched_pre_analysis
2728
.remove(&diff.file_path)
@@ -31,7 +32,12 @@ pub(super) fn prepare_diff_analysis(
3132
synthesize_analyzer_comments(pre_analysis.findings.clone())?,
3233
);
3334

34-
let triage_result = triage_diff(diff);
35+
let triage_result = triage_diff_with_options(
36+
diff,
37+
TriageOptions {
38+
skip_deletion_only: triage_skip_deletion_only,
39+
},
40+
);
3541
if triage_result.should_skip() {
3642
if deterministic_comments.is_empty() {
3743
tracing::info!(

src/review/pipeline/prepare/runner/run.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,11 @@ pub(in super::super::super) async fn prepare_file_review_jobs(
2929
continue;
3030
}
3131

32-
match prepare_diff_analysis(&diff, &mut batched_pre_analysis)? {
32+
match prepare_diff_analysis(
33+
&diff,
34+
&mut batched_pre_analysis,
35+
services.config.triage_skip_deletion_only,
36+
)? {
3337
DiffPreparationDecision::Skip => {
3438
progress.skip_file();
3539
}

src/review/triage.rs

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@ mod run;
1111

1212
#[allow(unused_imports)]
1313
pub use result::TriageResult;
14-
pub use run::triage_diff;
14+
#[allow(unused_imports)]
15+
pub use run::{triage_diff, triage_diff_with_options, TriageOptions};
1516

1617
#[cfg(test)]
1718
mod tests {
@@ -657,6 +658,7 @@ mod tests {
657658
assert!(TriageResult::SkipWhitespaceOnly.should_skip());
658659
assert!(TriageResult::SkipGenerated.should_skip());
659660
assert!(TriageResult::SkipCommentOnly.should_skip());
661+
assert!(TriageResult::SkipDeletionOnly.should_skip());
660662
}
661663

662664
#[test]
@@ -671,6 +673,7 @@ mod tests {
671673
assert!(!TriageResult::SkipWhitespaceOnly.reason().is_empty());
672674
assert!(!TriageResult::SkipGenerated.reason().is_empty());
673675
assert!(!TriageResult::SkipCommentOnly.reason().is_empty());
676+
assert!(!TriageResult::SkipDeletionOnly.reason().is_empty());
674677
}
675678

676679
#[test]
@@ -681,6 +684,7 @@ mod tests {
681684
TriageResult::SkipWhitespaceOnly.reason(),
682685
TriageResult::SkipGenerated.reason(),
683686
TriageResult::SkipCommentOnly.reason(),
687+
TriageResult::SkipDeletionOnly.reason(),
684688
];
685689
// All should be unique
686690
let unique: std::collections::HashSet<&str> = reasons.iter().copied().collect();
@@ -691,6 +695,42 @@ mod tests {
691695
);
692696
}
693697

698+
// ── #29 optional skip deletion-only ──────────────────────────────────
699+
700+
#[test]
701+
fn test_triage_deletion_only_with_skip_true_returns_skip_deletion_only() {
702+
let diff = make_diff(
703+
"src/lib.rs",
704+
vec![
705+
make_line(1, ChangeType::Removed, "let x = 1;"),
706+
make_line(2, ChangeType::Removed, "let y = 2;"),
707+
],
708+
);
709+
let options = TriageOptions {
710+
skip_deletion_only: true,
711+
};
712+
assert_eq!(
713+
triage_diff_with_options(&diff, options),
714+
TriageResult::SkipDeletionOnly
715+
);
716+
}
717+
718+
#[test]
719+
fn test_triage_deletion_only_with_skip_false_returns_needs_review() {
720+
let diff = make_diff(
721+
"src/lib.rs",
722+
vec![make_line(1, ChangeType::Removed, "removed line")],
723+
);
724+
assert_eq!(triage_diff(&diff), TriageResult::NeedsReview);
725+
let options = TriageOptions {
726+
skip_deletion_only: false,
727+
};
728+
assert_eq!(
729+
triage_diff_with_options(&diff, options),
730+
TriageResult::NeedsReview
731+
);
732+
}
733+
694734
// ── Adversarial edge cases ──────────────────────────────────────────
695735

696736
#[test]

src/review/triage/result.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ pub enum TriageResult {
55
SkipWhitespaceOnly,
66
SkipGenerated,
77
SkipCommentOnly,
8+
/// File has only removal hunks; skip when config triage_skip_deletion_only is true (#29).
9+
SkipDeletionOnly,
810
}
911

1012
impl TriageResult {
@@ -19,6 +21,7 @@ impl TriageResult {
1921
TriageResult::SkipWhitespaceOnly => "whitespace-only changes",
2022
TriageResult::SkipGenerated => "generated file",
2123
TriageResult::SkipCommentOnly => "comment-only changes",
24+
TriageResult::SkipDeletionOnly => "deletion-only changes",
2225
}
2326
}
2427
}

src/review/triage/run.rs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,20 @@ use super::comments::is_comment_line;
77
use super::files::{is_generated_file, is_lock_file};
88
use super::result::TriageResult;
99

10+
/// Options for triage behavior (e.g. from config).
11+
#[derive(Debug, Clone, Copy, Default)]
12+
pub struct TriageOptions {
13+
/// When true, treat deletion-only diffs as skip (#29). Default false (deletions still get review).
14+
pub skip_deletion_only: bool,
15+
}
16+
17+
/// Convenience: triage with default options (skip_deletion_only = false). Used by tests and callers that do not need config.
18+
#[allow(dead_code)]
1019
pub fn triage_diff(diff: &UnifiedDiff) -> TriageResult {
20+
triage_diff_with_options(diff, TriageOptions::default())
21+
}
22+
23+
pub fn triage_diff_with_options(diff: &UnifiedDiff, options: TriageOptions) -> TriageResult {
1124
if is_lock_file(&diff.file_path) {
1225
return TriageResult::SkipLockFile;
1326
}
@@ -34,6 +47,9 @@ pub fn triage_diff(diff: &UnifiedDiff) -> TriageResult {
3447
}
3548

3649
if is_deletion_only_change(&all_changes) {
50+
if options.skip_deletion_only {
51+
return TriageResult::SkipDeletionOnly;
52+
}
3753
// Pure deletions can still remove required fields, checks, or error handling.
3854
return TriageResult::NeedsReview;
3955
}

0 commit comments

Comments
 (0)