Skip to content

Commit 4bab02c

Browse files
committed
feat(context): primary change detection, metadata breaking, symbol tri-state
- PRIMARY_CHANGE: line in prompt anchors subject to most significant change (ranked: new public API > removed > largest file) - Metadata-aware breaking detection: scans diffs for MSRV changes, engines.node tightening, requires-python tightening, removed exports - Symbol tri-state: AddedOnly/RemovedOnly/ModifiedSignature — public modified symbols contribute to breaking risk - Focus instruction for groups >5 files - String::with_capacity in truncate_diff_adaptive - PromptContext gains file_count, primary_change, group_rationale, metadata_breaking_signals, symbols_modified fields
1 parent 01b1cf6 commit 4bab02c

2 files changed

Lines changed: 476 additions & 30 deletions

File tree

src/domain/context.rs

Lines changed: 147 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,32 +10,92 @@ pub struct PromptContext {
1010
pub file_breakdown: String,
1111
pub symbols_added: String,
1212
pub symbols_removed: String,
13+
pub symbols_modified: String,
14+
pub public_api_removed: String,
1315
pub suggested_type: CommitType,
1416
pub suggested_scope: Option<String>,
1517
pub truncated_diff: String,
18+
// Evidence flags for constraint-based anti-hallucination
19+
pub is_mechanical: bool,
20+
pub has_bug_evidence: bool,
21+
pub public_api_removed_count: usize,
22+
pub has_new_public_api: bool,
23+
pub is_dependency_only: bool,
24+
/// Number of files in this group (for focus instruction on large groups)
25+
pub file_count: usize,
26+
/// Most significant change for subject anchoring (e.g., "added CommitValidator struct")
27+
pub primary_change: Option<String>,
28+
/// Short rationale for why these files were grouped together
29+
pub group_rationale: Option<String>,
30+
/// Metadata-level breaking signals detected from diff content
31+
pub metadata_breaking_signals: Vec<String>,
1632
}
1733

1834
impl PromptContext {
1935
pub fn to_prompt(&self) -> String {
2036
let symbols_section = self.format_symbols_section();
37+
let breaking_warning = self.format_breaking_warning();
38+
let evidence_section = self.format_evidence_section();
39+
let constraints_section = self.format_constraints_section();
40+
41+
// Calculate available chars for subject after type(scope): prefix
42+
let prefix_len = self.suggested_type.as_str().len()
43+
+ self
44+
.suggested_scope
45+
.as_ref()
46+
.map(|s| s.len() + 2)
47+
.unwrap_or(0) // "(scope)"
48+
+ 2; // ": "
49+
let subject_budget = 72_usize.saturating_sub(prefix_len);
50+
51+
let focus_instruction = if self.file_count > 5 {
52+
"\nFOCUS: This group contains many files. Focus the subject on the single most significant change. Do not try to describe every change — pick the primary one.\n"
53+
} else {
54+
""
55+
};
56+
57+
let primary_change_line = self
58+
.primary_change
59+
.as_ref()
60+
.map(|pc| format!("\nPRIMARY_CHANGE: {}\n", pc))
61+
.unwrap_or_default();
62+
63+
let group_rationale_line = self
64+
.group_rationale
65+
.as_ref()
66+
.map(|gr| format!("GROUP_REASON: {}\n", gr))
67+
.unwrap_or_default();
68+
69+
let metadata_breaking_section = if self.metadata_breaking_signals.is_empty() {
70+
String::new()
71+
} else {
72+
format!(
73+
"\n⚠ METADATA BREAKING CHANGES DETECTED:\n{}\n",
74+
self.metadata_breaking_signals
75+
.iter()
76+
.map(|s| format!("- {}", s))
77+
.collect::<Vec<_>>()
78+
.join("\n")
79+
)
80+
};
2181

2282
format!(
2383
r#"Analyze this git diff and generate a commit message.
2484
2585
SUMMARY: {summary}
2686
FILES: {files}
2787
SUGGESTED TYPE: {commit_type}{scope}
28-
{symbols}
88+
{group_rationale}{evidence}{primary_change}{symbols}
2989
DIFF:
3090
{diff}
31-
91+
{constraints}{breaking}{metadata_breaking}{focus}
3292
Write a JSON commit message describing the changes shown in the diff.
33-
The subject must be specific - describe WHAT was changed (e.g., "add system prompt to ollama provider", "update dependency versions").
93+
The subject must be specific and under {subject_budget} chars — describe WHAT was changed (e.g., "add system prompt to ollama provider", "update dependency versions").
3494
For the body: if the change is trivial (single rename, typo fix), use null. Otherwise write a short body (1-3 sentences) explaining WHY the change was made or what it enables.
3595
For breaking_change: only set this if existing users or dependents must change their code, config, or scripts to keep working — e.g., a public function/endpoint removed or renamed, a required parameter or field added, a config key changed. New optional features, bug fixes, and internal refactors are NOT breaking. Default to null.
3696
3797
Output format:
38-
{{"type": "{commit_type}", "scope": {scope_json}, "subject": "<imperative verb + what changed>", "body": "<why this change was made, or null if trivial>", "breaking_change": null}}"#,
98+
{{"type": "<type>", "scope": {scope_json}, "subject": "<imperative verb + what changed>", "body": "<why this change was made, or null if trivial>", "breaking_change": null}}"#,
3999
summary = self.change_summary,
40100
files = self.file_breakdown.trim(),
41101
commit_type = self.suggested_type.as_str(),
@@ -44,7 +104,15 @@ Output format:
44104
.as_ref()
45105
.map(|s| format!("\nSCOPE: {}", s))
46106
.unwrap_or_default(),
107+
evidence = evidence_section,
47108
symbols = symbols_section,
109+
breaking = breaking_warning,
110+
constraints = constraints_section,
111+
focus = focus_instruction,
112+
primary_change = primary_change_line,
113+
group_rationale = group_rationale_line,
114+
metadata_breaking = metadata_breaking_section,
115+
subject_budget = subject_budget,
48116
scope_json = self
49117
.suggested_scope
50118
.as_ref()
@@ -57,8 +125,9 @@ Output format:
57125
fn format_symbols_section(&self) -> String {
58126
let has_added = !self.symbols_added.is_empty();
59127
let has_removed = !self.symbols_removed.is_empty();
128+
let has_modified = !self.symbols_modified.is_empty();
60129

61-
if !has_added && !has_removed {
130+
if !has_added && !has_removed && !has_modified {
62131
return String::new();
63132
}
64133

@@ -75,7 +144,80 @@ Output format:
75144
self.symbols_removed.replace('\n', "\n ")
76145
));
77146
}
147+
if has_modified {
148+
section.push_str(&format!(
149+
"\n Modified (signature changed):\n {}",
150+
self.symbols_modified.replace('\n', "\n ")
151+
));
152+
}
78153
section.push('\n');
79154
section
80155
}
156+
157+
fn format_breaking_warning(&self) -> String {
158+
if self.public_api_removed.is_empty() {
159+
return String::new();
160+
}
161+
162+
format!(
163+
"\n⚠ PUBLIC API REMOVED — describe this in breaking_change field:\n {}\n",
164+
self.public_api_removed.replace('\n', "\n ")
165+
)
166+
}
167+
168+
fn format_evidence_section(&self) -> String {
169+
let yn = |b: bool| if b { "yes" } else { "no" };
170+
171+
format!(
172+
"\nEVIDENCE:\n\
173+
- Is this a mechanical/formatting change? {}\n\
174+
- Does the diff contain bug-fix comments? {}\n\
175+
- How many public APIs were removed? {}\n\
176+
- Were new public APIs added? {}\n\
177+
- Are all changes in dependency/config files? {}\n",
178+
yn(self.is_mechanical),
179+
yn(self.has_bug_evidence),
180+
self.public_api_removed_count,
181+
yn(self.has_new_public_api),
182+
yn(self.is_dependency_only),
183+
)
184+
}
185+
186+
fn format_constraints_section(&self) -> String {
187+
let mut rules = Vec::new();
188+
189+
if !self.has_bug_evidence {
190+
rules.push(
191+
"- No bug-fix comments found: prefer \"refactor\" over \"fix\". \
192+
Only use \"fix\" if the diff clearly corrects wrong behavior.",
193+
);
194+
}
195+
if self.is_mechanical {
196+
rules.push(
197+
"- Mechanical/formatting change detected: use \"style\" or \"refactor\", not \"feat\" or \"fix\".",
198+
);
199+
}
200+
if self.public_api_removed_count > 0 {
201+
rules.push(
202+
"- Public APIs were removed: set breaking_change to describe what was removed \
203+
(e.g., \"removed `old_fn()`, use `new_fn()` instead\"). \
204+
Never copy labels from this prompt as the description.",
205+
);
206+
}
207+
if self.is_dependency_only {
208+
rules.push("- All changes are in dependency/config files: use \"chore\".");
209+
}
210+
if !self.metadata_breaking_signals.is_empty() {
211+
rules.push(
212+
"- Metadata breaking changes detected (version requirements raised, features removed, etc.): \
213+
set breaking_change to describe the compatibility impact.",
214+
);
215+
}
216+
217+
if rules.is_empty() {
218+
return String::new();
219+
}
220+
221+
format!("\nCONSTRAINTS (must follow):\n{}\n", rules.join("\n"))
222+
}
81223
}

0 commit comments

Comments
 (0)