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
74 changes: 68 additions & 6 deletions crates/cli/src/cmd/commit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ pub fn run(args: &[String]) -> Result<String, String> {
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)
Expand Down Expand Up @@ -262,9 +263,16 @@ pub fn run(args: &[String]) -> Result<String, String> {
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,
Expand Down Expand Up @@ -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<String>,
Expand Down Expand Up @@ -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::<Vec<_>>();
.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();
Expand All @@ -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
Expand Down
5 changes: 1 addition & 4 deletions crates/cli/src/cmd/version_bump.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand All @@ -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 {
Expand Down
26 changes: 24 additions & 2 deletions crates/core/src/llm/prompts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.";
Expand All @@ -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\
Expand All @@ -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
Expand All @@ -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 <think> tags"
- absolutely no explanations, no markdown, no <think> tags\n\
- ONLY use \"style:\" prefix for formatting-only changes that do NOT touch any executable code"
)
}

Expand Down
13 changes: 12 additions & 1 deletion crates/core/src/pipeline/analyze.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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);
Expand Down
Loading
Loading