diff --git a/crates/cli/src/cmd/commit.rs b/crates/cli/src/cmd/commit.rs index bc7d280..d2b723d 100644 --- a/crates/cli/src/cmd/commit.rs +++ b/crates/cli/src/cmd/commit.rs @@ -38,6 +38,7 @@ pub fn run(args: &[String]) -> Result { let dry_run = parsed.dry_run; let json = parsed.json; let no_verify = parsed.no_verify; + let no_bump = parsed.no_bump; let configure_commit_policy = parsed.configure_commit_policy; let interactive_override = if parsed.interactive { Some(true) @@ -262,9 +263,16 @@ pub fn run(args: &[String]) -> Result { let _ = report_cache::write_cached_report(&cache_path, &cache_key, &report); report }; - let embedding_bump_level = infer_embedding_bump_level(&engine, &diff_text, &report); - let version_recommendations = - version_bump::recommend(&repo, &diff_text, &report, embedding_bump_level); + let embedding_bump_level = if no_bump { + None + } else { + infer_embedding_bump_level(&engine, &diff_text, &report) + }; + let version_recommendations = if no_bump { + Vec::new() + } else { + version_bump::recommend(&repo, &diff_text, &report, embedding_bump_level) + }; let approved_version_recommendations = resolve_version_bump_recommendations( &version_recommendations, interactive, @@ -472,6 +480,9 @@ struct CommitArgs { /// Assume yes for confirmations #[arg(long, short = 'y')] yes: bool, + /// Skip version bump detection and application + #[arg(long = "no-bump")] + no_bump: bool, /// Explicit local model path (`.gguf`) #[arg(long = "model-path", value_name = "PATH")] model_path: Option, @@ -1989,13 +2000,28 @@ fn format_version_recommendation(rec: &version_bump::VersionRecommendation) -> S fn compose_risk_section(report: &AnalysisReport) -> String { let level = report.risk.level.trim(); - let notes = report + let notes: Vec<&str> = report .risk .notes .iter() .map(|note| note.trim()) - .filter(|note| !note.is_empty() && !looks_like_internal_risk_tag(note)) - .collect::>(); + .filter(|note| { + if note.is_empty() { + return false; + } + if looks_like_internal_risk_tag(note) { + return false; + } + if looks_like_boilerplate_risk_note(note) { + return false; + } + true + }) + .collect(); + + if level == "low" && notes.is_empty() { + return String::new(); + } if level.is_empty() && notes.is_empty() { return String::new(); @@ -2017,6 +2043,42 @@ fn compose_risk_section(report: &AnalysisReport) -> String { out.trim_end().to_string() } +fn looks_like_boilerplate_risk_note(note: &str) -> bool { + let lower = note.to_ascii_lowercase(); + let boilerplate = [ + "no security risks", + "no data loss", + "no data loss or corruption", + "no security implications", + "no immediate risk", + "no new security risks", + "no new security risks introduced", + "no security risks are introduced", + "no data loss or corruption is expected", + "no known security impact", + "no breaking changes", + "no breaking change", + "no backwards incompatible", + "no backward incompatible", + "no api changes", + "no api change", + "no user-facing changes", + "no user facing changes", + "no functional changes", + "low risk", + "changes are low risk", + "change is low risk", + "formatting changes are for code consistency", + "formatting changes for code consistency", + ]; + if boilerplate.iter().any(|p| lower.contains(p)) { + return true; + } + + let trimmed = lower.trim_end_matches('.'); + boilerplate.iter().any(|p| trimmed.contains(p)) +} + fn format_change_item(item: &autocommit_core::types::ChangeItem) -> String { let path = item .files diff --git a/crates/cli/src/cmd/version_bump.rs b/crates/cli/src/cmd/version_bump.rs index 4610aa2..7d1d399 100644 --- a/crates/cli/src/cmd/version_bump.rs +++ b/crates/cli/src/cmd/version_bump.rs @@ -200,9 +200,6 @@ fn recommend_inner( .join(VERSION_CONTEXT_FILE); let changed_paths = parse_changed_paths(diff_text); - let source_changed = changed_paths - .iter() - .any(|path| !is_manifest_path(path) && !is_lockfile_path(path)); let heuristic_level = suggested_level(report); let recommended_level = combine_recommended_level(heuristic_level, embedding_level); @@ -228,7 +225,7 @@ fn recommend_inner( .get(&manifest_path) .and_then(|snapshot| snapshot.version.clone()); let manifest_changed = changed_paths.contains(&manifest_path); - let should_evaluate = source_changed || manifest_changed; + let should_evaluate = manifest_changed; if should_evaluate { if kind == ManifestKind::GoMod { diff --git a/crates/core/src/llm/prompts.rs b/crates/core/src/llm/prompts.rs index 48b4901..830ee57 100644 --- a/crates/core/src/llm/prompts.rs +++ b/crates/core/src/llm/prompts.rs @@ -18,6 +18,18 @@ Guidelines: - For diagnostics/debugging: "fix/chore: add diagnostic for X" - Multiple related changes: summarize the theme, then list key parts - Unrelated changes: indicate mixed scope clearly + +Type classification rules (CRITICAL - get these right): +- style: ONLY for formatting/whitespace-only changes that do NOT modify behavior, logic, types, or APIs. Never use style for any change that touches executable code. +- refactor: code restructuring without behavior change (renames, extract function, change signature). +- feat: new capability, new API, new parameter, new export for downstream consumers. +- fix: behavior correction, bug fix, edge-case handling. +- chore: build config, CI, dependency updates, manifest changes, internal tooling. +- docs: comments, documentation, README changes only. +- test: test additions or modifications only. +- perf: performance optimization without API change. + +For small diffs (under 15 lines): describe what was literally changed. Avoid inferring grand intent from minimal changes. If the change is trivial, say so directly. "#; pub const DISPATCH_DRAFT_ANCHOR: &str = "Small, low-risk diff limited to a few files with straightforward behavior updates, docs, or tests and no high-impact migration or workflow changes."; @@ -27,6 +39,14 @@ pub const IMPORTANCE_PRIMARY_ANCHOR: &str = "Primary code change that defines th pub const IMPORTANCE_SUPPORTING_ANCHOR: &str = "Supporting infrastructure or configuration change. Updates manifests, build scripts, CI workflows, dependency versions, or project settings without introducing new behavior."; pub fn build_analyze_prompt(chunk: &DiffChunk) -> String { + let chunk_line_count = chunk.text.lines().count(); + + let small_diff_warning = if chunk_line_count < 15 { + " NOTE: This is a small diff. Describe what was literally changed. Do NOT infer or invent intent that is not directly visible in the diff.\n" + } else { + "" + }; + format!( "/no_think\n\ Task: Analyze one diff chunk.\n\ @@ -39,6 +59,7 @@ intent: <= 16 words, concise rationale phrase (not a long sentence).\n\ Do not end `summary`, `title`, or `intent` with dangling filler words.\n\ Use backticks for file paths, variable names, CLI flags, and config keys in summary/title/intent.\n\ No markdown, no prose outside JSON.\n\ +{small_diff_warning}\ Path: {}\n\ Diff:\n```diff\n{}\n```", chunk.path, chunk.text @@ -63,9 +84,10 @@ Rules:\n\ - commit_message and summary must describe the overall intent and outcome, not enumerate individual files or list per-file changes\n\ - summary must be one sentence about the code change outcome\n\ - risk_level must be low, medium, or high\n\ -- risk_notes should be concise and concrete\n\ +- risk_notes should be concise and concrete. Never use generic phrases like \"no security risks\" or \"no data loss\" - describe actual risk if any, or omit.\n\ - use backticks for file paths, variable names, CLI flags, and config keys in summary and risk_notes\n\ -- absolutely no explanations, no markdown, no tags" +- absolutely no explanations, no markdown, no tags\n\ +- ONLY use \"style:\" prefix for formatting-only changes that do NOT touch any executable code" ) } diff --git a/crates/core/src/pipeline/analyze.rs b/crates/core/src/pipeline/analyze.rs index 0961a16..f680afd 100644 --- a/crates/core/src/pipeline/analyze.rs +++ b/crates/core/src/pipeline/analyze.rs @@ -81,7 +81,7 @@ fn run_inner( // Feature extraction uses the original per-file chunks for accuracy. let features = features::extract(&raw_chunks); let key_symbols = signals::extract_key_symbols(&raw_chunks); - let stats = to_stats(&features, key_symbols); + let stats = to_stats(&features, key_symbols.clone()); let heuristic = heuristics::score(&features); let embedding_hint = if embedding_gate::should_run_embedding(heuristic) { @@ -159,6 +159,17 @@ fn run_inner( // DraftOnly fast path: skip the reduce inference call. if decision.route == DispatchRoute::DraftOnly { + // For very small diffs, bypass the model entirely and generate a + // literal commit message from the diff structure. This avoids + // hallucination on tiny changes where the model has too little + // signal to infer meaningful intent. + if features.lines_changed <= 20 && features.files_changed <= 2 { + let report = + reduce::literal_report(&chunks, &features, &key_symbols, &decision, &stats); + progress::emit(cb, ProgressStage::DraftSynthesis); + validate::validate(&report)?; + return Ok(report); + } let partials = fanout::analyze_chunks(engine, &chunks)?; let report = reduce::synthesize_draft_report(&partials, &decision, &stats); progress::emit(cb, ProgressStage::DraftSynthesis); diff --git a/crates/core/src/pipeline/reduce.rs b/crates/core/src/pipeline/reduce.rs index 766eef0..cab855d 100644 --- a/crates/core/src/pipeline/reduce.rs +++ b/crates/core/src/pipeline/reduce.rs @@ -2,8 +2,10 @@ use crate::CoreError; use crate::diff::features::DiffFeatures; use crate::diff::importance::{self, ImportanceTier}; use crate::llm::traits::LlmEngine; +use crate::types::diff::{DiffChunk, FileStatus}; use crate::types::{ - AnalysisReport, ChangeItem, DiffStats, DispatchDecision, PartialReport, RiskReport, TypeTag, + AnalysisReport, ChangeBucket, ChangeItem, DiffStats, DispatchDecision, FileRef, PartialReport, + RiskReport, TypeTag, }; pub fn reduce( @@ -78,6 +80,267 @@ pub fn format_only_report( } } +/// Generate a report directly from diff structure, bypassing the LLM entirely. +/// Used for very small diffs where hallucination risk is high and model calls aren't +/// worth the latency. +pub fn literal_report( + chunks: &[DiffChunk], + features: &DiffFeatures, + _key_symbols: &[String], + decision: &DispatchDecision, + stats: &DiffStats, +) -> AnalysisReport { + let mut items = Vec::new(); + for chunk in chunks { + let (add_count, del_count) = count_diff_lines(&chunk.text); + let (added_syms, removed_syms) = extract_diff_declarations(&chunk.text); + let mut all_syms: Vec<&str> = Vec::new(); + if let Some(sym) = added_syms.first() { + all_syms.push(sym.as_str()); + } + if let Some(sym) = removed_syms.first() { + all_syms.push(sym.as_str()); + } + let type_tag = classify_literal_change(&chunk.path, add_count, del_count, &all_syms); + let title = build_literal_title( + &chunk.path, + add_count, + del_count, + &added_syms, + &removed_syms, + ); + let intent = build_literal_intent(add_count, del_count); + + items.push(ChangeItem { + id: format!("lit-{}", sanitize_path(&chunk.path)), + bucket: bucket_from_type(&type_tag), + type_tag, + title, + intent, + files: vec![FileRef { + path: chunk.path.clone(), + status: FileStatus::Modified, + ranges: chunk.ranges.clone(), + }], + confidence: 1.0, + }); + } + + if items.is_empty() { + items.push(ChangeItem { + id: "lit-update".to_string(), + bucket: ChangeBucket::Patch, + type_tag: TypeTag::Fix, + title: format!( + "Update {} line(s) across {} file(s)", + features.lines_changed, features.files_changed + ), + intent: "Apply diff changes".to_string(), + files: Vec::new(), + confidence: 1.0, + }); + } + + let commit_message = synthesize_commit_message(&items); + let summary = synthesize_summary(&items, stats); + + AnalysisReport { + schema_version: "1.0".to_string(), + commit_message, + summary, + items, + risk: RiskReport { + level: "low".to_string(), + notes: vec![ + "commit_source:literal".to_string(), + format!("dispatch:{:?}", decision.route), + ], + }, + stats: stats.clone(), + dispatch: decision.clone(), + } +} + +fn sanitize_path(path: &str) -> String { + path.replace(|c: char| !c.is_alphanumeric() && c != '-' && c != '_', "_") +} + +fn count_diff_lines(diff_text: &str) -> (usize, usize) { + let mut additions = 0usize; + let mut deletions = 0usize; + for line in diff_text.lines() { + if line.starts_with("+") && !line.starts_with("+++") { + additions += 1; + } else if line.starts_with("-") && !line.starts_with("---") { + deletions += 1; + } + } + (additions, deletions) +} + +fn extract_diff_declarations(diff_text: &str) -> (Vec, Vec) { + let mut added = Vec::new(); + let mut removed = Vec::new(); + for line in diff_text.lines() { + if let Some(rest) = line.strip_prefix('+') { + if rest.starts_with("++") { + continue; + } + if let Some(name) = extract_declaration(rest.trim()) { + added.push(name); + } + } else if let Some(rest) = line.strip_prefix('-') { + if rest.starts_with("--") { + continue; + } + if let Some(name) = extract_declaration(rest.trim()) { + removed.push(name); + } + } + } + (added, removed) +} + +/// Minimal declaration extractor for literal diffs. +fn extract_declaration(line: &str) -> Option { + let name = line + .trim_start_matches("pub ") + .trim_start_matches("pub(crate) ") + .trim_start_matches("async "); + if let Some(rest) = name + .strip_prefix("fn ") + .or_else(|| name.strip_prefix("def ")) + .or_else(|| name.strip_prefix("function ")) + .or_else(|| name.strip_prefix("func ")) + { + return rest + .split(['(', '<', ' ']) + .next() + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()); + } + if let Some(rest) = name + .strip_prefix("struct ") + .or_else(|| name.strip_prefix("class ")) + .or_else(|| name.strip_prefix("trait ")) + .or_else(|| name.strip_prefix("enum ")) + .or_else(|| name.strip_prefix("type ")) + { + return rest + .split_whitespace() + .next() + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()); + } + None +} + +fn classify_literal_change( + path: &str, + add_count: usize, + del_count: usize, + symbols: &[&str], +) -> TypeTag { + let path_lower = path.to_ascii_lowercase(); + if path_lower.ends_with(".md") || path_lower.ends_with("readme") || path_lower.contains("doc") { + return TypeTag::Docs; + } + if path_lower.ends_with("_test.rs") + || path_lower.ends_with("_test.go") + || path_lower.ends_with("_test.py") + || path_lower.contains("test_") + || path_lower.contains("/tests/") + { + return TypeTag::Test; + } + if add_count == 0 && del_count > 0 { + return TypeTag::Refactor; + } + if add_count > 0 && del_count == 0 && !symbols.is_empty() { + return TypeTag::Feat; + } + if symbols.len() >= 2 { + let first = symbols[0].to_ascii_lowercase(); + let second = symbols[1].to_ascii_lowercase(); + if first.chars().take(3).collect::() == second.chars().take(3).collect::() { + return TypeTag::Refactor; + } + } + TypeTag::Fix +} + +fn build_literal_title( + path: &str, + add_count: usize, + del_count: usize, + added_syms: &[String], + removed_syms: &[String], +) -> String { + let path_lower = path.to_ascii_lowercase(); + if path_lower.ends_with(".md") || path_lower.ends_with("readme") { + return capitalize_first(&format!("update documentation in {path}")); + } + if (path_lower.ends_with("cargo.toml") + || path_lower.ends_with("package.json") + || path_lower.ends_with("pyproject.toml")) + && !added_syms.is_empty() + { + return capitalize_first(&format!("add {} to {}", added_syms[0], path)); + } + + if let Some((added, removed)) = added_syms.first().zip(removed_syms.first()) + && added != removed + { + return capitalize_first(&format!("rename {} to {}", removed, added)); + } + + if let Some(sym) = added_syms.first() { + if del_count == 0 { + return capitalize_first(&format!("add {}", sym)); + } + return capitalize_first(&format!("add {} in {}", sym, path)); + } + + if let Some(sym) = removed_syms.first() { + if add_count == 0 { + return capitalize_first(&format!("remove {}", sym)); + } + return capitalize_first(&format!("update {}", sym)); + } + + if add_count > 0 && del_count == 0 { + return format!("Add {} line(s) to {path}", add_count); + } + if del_count > 0 && add_count == 0 { + return format!("Remove {} line(s) from {path}", del_count); + } + + format!("Update {} line(s) in {path}", add_count.max(del_count)) +} + +fn build_literal_intent(add_count: usize, del_count: usize) -> String { + if add_count > 0 && del_count > 0 { + format!( + "Modify {} addition(s) and {} deletion(s)", + add_count, del_count + ) + } else if add_count > 0 { + format!("Add {} line(s)", add_count) + } else if del_count > 0 { + format!("Remove {} line(s)", del_count) + } else { + "Apply diff changes".to_string() + } +} + +fn bucket_from_type(tag: &TypeTag) -> ChangeBucket { + match tag { + TypeTag::Feat => ChangeBucket::Feature, + TypeTag::Test | TypeTag::Docs => ChangeBucket::Addition, + _ => ChangeBucket::Patch, + } +} + fn synthesize_commit_message(items: &[ChangeItem]) -> String { let best = items.iter().max_by(|a, b| { let a_score = a.confidence + importance_boost(a); @@ -201,7 +464,8 @@ fn capitalize_first(s: &str) -> String { #[cfg(test)] mod tests { use super::*; - use crate::types::{ChangeBucket, DispatchRoute, FileRef, FileStatus}; + use crate::diff::features::DiffFeatures; + use crate::types::{ChangeBucket, DispatchRoute, FileRef, FileStatus, LineRange}; fn sample_partial(title: &str, type_tag: TypeTag, path: &str) -> PartialReport { PartialReport { @@ -312,4 +576,155 @@ mod tests { assert!(report.commit_message.contains("1 file")); assert!(!report.commit_message.contains("1 files")); } + + #[test] + fn literal_report_adds_new_function() { + let chunks = vec![DiffChunk { + path: "src/aml/namespace.rs".to_string(), + text: "\ +@@ -1,5 +1,8 @@ + pub fn existing() {} ++pub fn new_helper() -> bool { ++ true ++} ++pub struct NewType { +" + .to_string(), + ranges: vec![LineRange { + old_start: 1, + old_count: 5, + new_start: 1, + new_count: 8, + }], + estimated_tokens: 20, + }]; + let features = DiffFeatures { + files_changed: 1, + lines_changed: 5, + hunks: 1, + binary_files: 0, + risky_paths: 0, + whitespace_only_lines: 0, + }; + let decision = DispatchDecision { + route: DispatchRoute::DraftOnly, + reason_codes: vec!["small_diff".to_string()], + estimated_cost_tokens: 0, + }; + let stats = DiffStats { + files_changed: 1, + lines_changed: 5, + hunks: 1, + binary_files: 0, + whitespace_only_lines: 0, + key_symbols: vec!["new_helper".to_string(), "NewType".to_string()], + }; + + let report = literal_report( + &chunks, + &features, + &["new_helper".to_string()], + &decision, + &stats, + ); + assert!( + report.commit_message.contains("add") || report.commit_message.contains("Add"), + "expected commit message to mention 'add', got: {}", + report.commit_message + ); + assert!( + report.commit_message.contains("new_helper"), + "expected commit message to contain function name, got: {}", + report.commit_message + ); + assert_eq!(report.risk.level, "low"); + assert!(report.risk.notes.iter().any(|n| n.contains("literal"))); + } + + #[test] + fn literal_report_renames_function() { + let chunks = vec![DiffChunk { + path: "src/core/process.rs".to_string(), + text: "\ +@@ -10,7 +10,7 @@ +-pub fn old_name() -> i32 { ++pub fn new_name() -> i32 { + 42 + } +" + .to_string(), + ranges: vec![LineRange { + old_start: 10, + old_count: 7, + new_start: 10, + new_count: 7, + }], + estimated_tokens: 10, + }]; + let features = DiffFeatures { + files_changed: 1, + lines_changed: 1, + hunks: 1, + binary_files: 0, + risky_paths: 0, + whitespace_only_lines: 0, + }; + let decision = DispatchDecision { + route: DispatchRoute::DraftOnly, + reason_codes: vec!["small_diff".to_string()], + estimated_cost_tokens: 0, + }; + let stats = DiffStats { + files_changed: 1, + lines_changed: 1, + hunks: 1, + binary_files: 0, + whitespace_only_lines: 0, + key_symbols: vec!["new_name".to_string()], + }; + + let report = literal_report( + &chunks, + &features, + &["new_name".to_string()], + &decision, + &stats, + ); + assert!( + report.commit_message.contains("rename"), + "expected commit message to mention 'rename', got: {}", + report.commit_message + ); + assert!(report.items.len() >= 1); + } + + #[test] + fn literal_report_count_diff_lines() { + let diff = "\ +--- a/src/lib.rs ++++ b/src/lib.rs +@@ -1,3 +1,4 @@ +-// old comment ++// new comment ++// additional line +"; + let (add, del) = count_diff_lines(diff); + assert_eq!(add, 2); + assert_eq!(del, 1); + } + + #[test] + fn literal_report_extracts_declarations() { + let diff = "\ ++pub fn validate_input(s: &str) -> bool { ++pub struct McpServer { +-pub fn old_helper() { +-pub struct Deprecated { +"; + let (added, removed) = extract_diff_declarations(diff); + assert!(added.contains(&"validate_input".to_string())); + assert!(added.contains(&"McpServer".to_string())); + assert!(removed.contains(&"old_helper".to_string())); + assert!(removed.contains(&"Deprecated".to_string())); + } } diff --git a/third_party/llama.cpp b/third_party/llama.cpp index 418dea3..0ccbfde 160000 --- a/third_party/llama.cpp +++ b/third_party/llama.cpp @@ -1 +1 @@ -Subproject commit 418dea39cea85d3496c8b04a118c3b17f3940ad8 +Subproject commit 0ccbfdef3e0a635530aec490f863d83edc22cbc4