Skip to content

Commit 1b62f94

Browse files
haasonsaasclaude
andauthored
feat: competitive review pipeline improvements (#33)
* feat: add file triage, dynamic context boundaries, and robust LLM parsing Phase 1 improvements to the review pipeline, informed by competitive analysis of Greptile, CodeRabbit, and Qodo Merge architectures: - File triage (#29): Heuristic classifier skips lock files, generated files, deletion-only, whitespace-only, and comment-only changes before expensive LLM review. 45 unit tests. - Dynamic context boundaries (#25): Context fetcher now searches upward for enclosing function/class definitions instead of using fixed-window context. Uses asymmetric padding (more before, less after) matching Qodo's approach. 6 unit tests. - Robust LLM output parsing (#28): 5-strategy fallback chain for parsing LLM responses — primary format, numbered lists, markdown bullets, file:line format, and JSON extraction. Prevents silent comment loss when models deviate from the expected format. 11 unit tests. - Fix rust-embed v8 derive macro (Embed -> RustEmbed) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: add verification pass and multi-model routing - Verification pass (#23): LLM validates review findings against actual code, scoring 0-10 with auto-zero for noise (docstrings, type hints, imports). Filters hallucinated comments before output. 17 new tests. - Multi-model routing (#26): ModelRole enum (Primary/Weak/Reasoning/Embedding) with per-role model config and fallback chain. 12 new tests. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: add adaptive patch compression for large PRs (#30) 4-stage progressive degradation strategy for diffs exceeding token budget: Stage 1 (Full): all diffs fit → use as-is Stage 2 (Compressed): remove deletion-only hunks, drop tail files Stage 3 (Clipped): truncate large files at hunk boundaries Stage 4 (MultiCall): split into multiple batches (up to 5) Integrated into review pipeline — files dropped by compression are skipped with logging. 24 new tests. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: address review pipeline false skips and UTF-8 safety Prevent compression analysis from skipping files in per-file review mode, make verification diff truncation UTF-8 safe, and tighten comment-only triage to avoid dereference false positives while improving compression packing order. Made-with: Cursor * fix: rustfmt formatting and bump quinn-proto 0.11.14 (RUSTSEC-2026-0037) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: skip DiffScope self-review when API key not configured Instead of failing the workflow, emit a notice and skip the review and comment-posting steps when OPENAI_API_KEY is not set. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: address Devin review feedback — triage false positives and embedding default - `#` comment prefix: exclude Rust attributes (#[...], #![...]), C preprocessor directives (#include, #define, etc.) from comment detection - `generated/` match: use boundary-aware checks (starts_with or /generated/) to avoid false positives on paths like `user_generated/` - Embedding model default: fall back to primary model instead of hardcoding OpenAI's text-embedding-3-small, respecting self-hosted provider support - 4 new tests for the triage fixes Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: wire ModelRole into verification pass, resolve clippy dead_code errors Use the Weak model role for the verification pass — it's a validation task that doesn't need the frontier model. This also resolves the clippy dead_code errors for ModelRole, model_for_role, and to_model_config_for_role by giving them their first real callsite. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent aa2f3c4 commit 1b62f94

File tree

12 files changed

+2602
-8
lines changed

12 files changed

+2602
-8
lines changed

.github/workflows/diffscope.yml

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,14 +27,26 @@ jobs:
2727
git fetch origin ${{ github.base_ref }} --depth=1
2828
git diff origin/${{ github.base_ref }}...HEAD > pr.diff
2929
30+
- name: Check API key
31+
id: check_key
32+
run: |
33+
if [ -z "${{ secrets.OPENAI_API_KEY }}" ]; then
34+
echo "skip=true" >> "$GITHUB_OUTPUT"
35+
echo "::notice::DiffScope review skipped — OPENAI_API_KEY secret not configured"
36+
else
37+
echo "skip=false" >> "$GITHUB_OUTPUT"
38+
fi
39+
3040
- name: Run DiffScope
41+
if: steps.check_key.outputs.skip != 'true'
3142
uses: docker://ghcr.io/haasonsaas/diffscope:latest
3243
env:
3344
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
3445
with:
3546
args: review --diff pr.diff --output-format json --output comments.json
36-
47+
3748
- name: Post comments
49+
if: steps.check_key.outputs.skip != 'true'
3850
uses: actions/github-script@v7
3951
with:
4052
script: |

Cargo.lock

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/config.rs

Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,24 @@ use std::collections::HashMap;
44
use std::path::{Path, PathBuf};
55
use tracing::warn;
66

7+
/// Identifies the role a model plays in the review pipeline.
8+
///
9+
/// Different tasks benefit from different model tiers: cheap/fast models
10+
/// for triage and summarization, frontier models for deep review, and
11+
/// specialised models for reasoning or embeddings.
12+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
13+
#[serde(rename_all = "snake_case")]
14+
pub enum ModelRole {
15+
/// The main review model (default).
16+
Primary,
17+
/// Cheap/fast model for triage, summarization, NL translation.
18+
Weak,
19+
/// Reasoning-capable model for complex analysis and self-reflection.
20+
Reasoning,
21+
/// Embedding model for RAG indexing.
22+
Embedding,
23+
}
24+
725
#[derive(Debug, Clone, Serialize, Deserialize)]
826
pub struct ProviderConfig {
927
#[serde(default)]
@@ -29,6 +47,22 @@ pub struct Config {
2947
#[serde(default = "default_model")]
3048
pub model: String,
3149

50+
/// Cheap/fast model for triage, summarization, NL translation.
51+
#[serde(default)]
52+
pub model_weak: Option<String>,
53+
54+
/// Reasoning-capable model for complex analysis and self-reflection.
55+
#[serde(default)]
56+
pub model_reasoning: Option<String>,
57+
58+
/// Embedding model for RAG indexing.
59+
#[serde(default)]
60+
pub model_embedding: Option<String>,
61+
62+
/// Fallback models tried in order when the primary model fails.
63+
#[serde(default)]
64+
pub fallback_models: Vec<String>,
65+
3266
#[serde(default = "default_temperature")]
3367
pub temperature: f32,
3468

@@ -303,6 +337,10 @@ impl Default for Config {
303337
fn default() -> Self {
304338
Self {
305339
model: default_model(),
340+
model_weak: None,
341+
model_reasoning: None,
342+
model_embedding: None,
343+
fallback_models: Vec::new(),
306344
temperature: default_temperature(),
307345
max_tokens: default_max_tokens(),
308346
max_context_chars: default_max_context_chars(),
@@ -869,6 +907,23 @@ impl Config {
869907
}
870908
}
871909

910+
/// Get the model name for a specific role, falling back to the primary model.
911+
pub fn model_for_role(&self, role: ModelRole) -> &str {
912+
match role {
913+
ModelRole::Primary => &self.model,
914+
ModelRole::Weak => self.model_weak.as_deref().unwrap_or(&self.model),
915+
ModelRole::Reasoning => self.model_reasoning.as_deref().unwrap_or(&self.model),
916+
ModelRole::Embedding => self.model_embedding.as_deref().unwrap_or(&self.model),
917+
}
918+
}
919+
920+
/// Build a ModelConfig for a specific role.
921+
pub fn to_model_config_for_role(&self, role: ModelRole) -> crate::adapters::llm::ModelConfig {
922+
let mut config = self.to_model_config();
923+
config.model_name = self.model_for_role(role).to_string();
924+
config
925+
}
926+
872927
/// Resolve which provider to use based on configuration.
873928
///
874929
/// Returns `(api_key, base_url, adapter)` by checking:
@@ -1509,4 +1564,146 @@ mod tests {
15091564
Some("rust-analyzer")
15101565
);
15111566
}
1567+
1568+
#[test]
1569+
fn test_model_role_primary_returns_model() {
1570+
let config = Config {
1571+
model: "claude-sonnet-4-6".to_string(),
1572+
..Config::default()
1573+
};
1574+
assert_eq!(
1575+
config.model_for_role(ModelRole::Primary),
1576+
"claude-sonnet-4-6"
1577+
);
1578+
}
1579+
1580+
#[test]
1581+
fn test_model_role_weak_fallback_to_primary() {
1582+
let config = Config {
1583+
model: "claude-sonnet-4-6".to_string(),
1584+
model_weak: None,
1585+
..Config::default()
1586+
};
1587+
assert_eq!(config.model_for_role(ModelRole::Weak), "claude-sonnet-4-6");
1588+
}
1589+
1590+
#[test]
1591+
fn test_model_role_weak_explicit() {
1592+
let config = Config {
1593+
model: "claude-sonnet-4-6".to_string(),
1594+
model_weak: Some("claude-haiku-4-5".to_string()),
1595+
..Config::default()
1596+
};
1597+
assert_eq!(config.model_for_role(ModelRole::Weak), "claude-haiku-4-5");
1598+
}
1599+
1600+
#[test]
1601+
fn test_model_role_reasoning_fallback() {
1602+
let config = Config {
1603+
model: "claude-sonnet-4-6".to_string(),
1604+
model_reasoning: None,
1605+
..Config::default()
1606+
};
1607+
assert_eq!(
1608+
config.model_for_role(ModelRole::Reasoning),
1609+
"claude-sonnet-4-6"
1610+
);
1611+
}
1612+
1613+
#[test]
1614+
fn test_model_role_reasoning_explicit() {
1615+
let config = Config {
1616+
model: "claude-sonnet-4-6".to_string(),
1617+
model_reasoning: Some("claude-opus-4-6".to_string()),
1618+
..Config::default()
1619+
};
1620+
assert_eq!(
1621+
config.model_for_role(ModelRole::Reasoning),
1622+
"claude-opus-4-6"
1623+
);
1624+
}
1625+
1626+
#[test]
1627+
fn test_model_role_embedding_default() {
1628+
let config = Config {
1629+
model: "claude-sonnet-4-6".to_string(),
1630+
model_embedding: None,
1631+
..Config::default()
1632+
};
1633+
// Falls back to primary model when no embedding model configured
1634+
assert_eq!(
1635+
config.model_for_role(ModelRole::Embedding),
1636+
"claude-sonnet-4-6"
1637+
);
1638+
}
1639+
1640+
#[test]
1641+
fn test_model_role_embedding_explicit() {
1642+
let config = Config {
1643+
model: "claude-sonnet-4-6".to_string(),
1644+
model_embedding: Some("custom-embedding-model".to_string()),
1645+
..Config::default()
1646+
};
1647+
assert_eq!(
1648+
config.model_for_role(ModelRole::Embedding),
1649+
"custom-embedding-model"
1650+
);
1651+
}
1652+
1653+
#[test]
1654+
fn test_to_model_config_for_role_uses_correct_model() {
1655+
let config = Config {
1656+
model: "claude-sonnet-4-6".to_string(),
1657+
model_weak: Some("claude-haiku-4-5".to_string()),
1658+
..Config::default()
1659+
};
1660+
let primary_config = config.to_model_config_for_role(ModelRole::Primary);
1661+
assert_eq!(primary_config.model_name, "claude-sonnet-4-6");
1662+
1663+
let weak_config = config.to_model_config_for_role(ModelRole::Weak);
1664+
assert_eq!(weak_config.model_name, "claude-haiku-4-5");
1665+
}
1666+
1667+
#[test]
1668+
fn test_fallback_models_default_empty() {
1669+
let config = Config::default();
1670+
assert!(config.fallback_models.is_empty());
1671+
}
1672+
1673+
#[test]
1674+
fn test_config_deserialization_with_model_roles() {
1675+
let yaml = r#"
1676+
model: claude-sonnet-4-6
1677+
model_weak: claude-haiku-4-5
1678+
model_reasoning: claude-opus-4-6
1679+
model_embedding: text-embedding-3-small
1680+
fallback_models:
1681+
- gpt-4o
1682+
- claude-sonnet-4-6
1683+
"#;
1684+
let config: Config = serde_yaml::from_str(yaml).unwrap();
1685+
assert_eq!(config.model, "claude-sonnet-4-6");
1686+
assert_eq!(config.model_weak, Some("claude-haiku-4-5".to_string()));
1687+
assert_eq!(config.model_reasoning, Some("claude-opus-4-6".to_string()));
1688+
assert_eq!(
1689+
config.model_embedding,
1690+
Some("text-embedding-3-small".to_string())
1691+
);
1692+
assert_eq!(config.fallback_models.len(), 2);
1693+
}
1694+
1695+
#[test]
1696+
fn test_config_deserialization_without_model_roles() {
1697+
// Existing configs without new fields should still work
1698+
let yaml = r#"
1699+
model: claude-sonnet-4-6
1700+
temperature: 0.3
1701+
"#;
1702+
let config: Config = serde_yaml::from_str(yaml).unwrap();
1703+
assert_eq!(config.model, "claude-sonnet-4-6");
1704+
assert!(config.model_weak.is_none());
1705+
assert!(config.model_reasoning.is_none());
1706+
assert!(config.model_embedding.is_none());
1707+
assert!(config.fallback_models.is_empty());
1708+
}
15121709
}

src/core/context.rs

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ use std::collections::HashSet;
55
use std::path::Path;
66
use std::path::PathBuf;
77

8+
use crate::core::function_chunker::find_enclosing_boundary_line;
89
use crate::core::SymbolIndex;
910
#[derive(Debug, Clone, Serialize, Deserialize)]
1011
pub struct LLMContextChunk {
@@ -50,8 +51,18 @@ impl ContextFetcher {
5051
}
5152
let start = start.max(1);
5253
let end = end.max(start);
53-
let start_idx = start.saturating_sub(1);
54-
let end_idx = end.min(file_lines.len());
54+
55+
// Dynamic context: expand start to enclosing function boundary
56+
let expanded_start = find_enclosing_boundary_line(&content, file_path, start, 10)
57+
.filter(|&boundary| boundary >= start.saturating_sub(10))
58+
.unwrap_or_else(|| start.saturating_sub(5)); // fallback: 5 lines before
59+
let expanded_start = expanded_start.max(1);
60+
61+
// Asymmetric: less context after (1 extra line)
62+
let expanded_end = (end + 1).min(file_lines.len());
63+
64+
let start_idx = expanded_start.saturating_sub(1);
65+
let end_idx = expanded_end.min(file_lines.len());
5566

5667
if start_idx < file_lines.len() {
5768
let chunk_content = truncate_with_notice(
@@ -62,7 +73,7 @@ impl ContextFetcher {
6273
file_path: file_path.clone(),
6374
content: chunk_content,
6475
context_type: ContextType::FileContent,
65-
line_range: Some((start, end)),
76+
line_range: Some((expanded_start, expanded_end)),
6677
});
6778
}
6879
}
@@ -330,6 +341,28 @@ mod tests {
330341
let merged = merge_ranges(&ranges);
331342
assert_eq!(merged, vec![(1, 10)]);
332343
}
344+
345+
#[tokio::test]
346+
async fn test_fetch_context_expands_to_function_boundary() {
347+
// Create a temp file with a function
348+
let dir = tempfile::tempdir().unwrap();
349+
let file_path = dir.path().join("test.rs");
350+
let content = "use std::io;\n\npub fn process(x: i32) -> bool {\n let y = x + 1;\n y > 0\n}\n\npub fn other() {\n println!(\"hi\");\n}\n";
351+
std::fs::write(&file_path, content).unwrap();
352+
353+
let fetcher = ContextFetcher::new(dir.path().to_path_buf());
354+
let relative = PathBuf::from("test.rs");
355+
// Request context for line 4-5 (inside process function)
356+
let chunks = fetcher
357+
.fetch_context_for_file(&relative, &[(4, 5)])
358+
.await
359+
.unwrap();
360+
361+
assert!(!chunks.is_empty());
362+
// Should expand to include the function signature (line 3)
363+
let chunk = &chunks[0];
364+
assert!(chunk.content.contains("pub fn process"));
365+
}
333366
}
334367

335368
async fn read_file_lossy(path: &Path) -> Result<String> {

0 commit comments

Comments
 (0)