1#[derive(Debug, Clone, PartialEq, Eq)]
8pub enum PreferencePredicate {
9 CommandRegex {
10 pattern: String,
11 conflict_key: String,
12 },
13 CommitTrailerForbidden {
14 trailer: String,
15 conflict_key: String,
16 },
17 GitPushForceForbidden {
18 conflict_key: String,
19 },
20}
21
22impl PreferencePredicate {
23 pub fn conflict_key(&self) -> String {
24 match self {
25 PreferencePredicate::CommandRegex { conflict_key, .. }
26 | PreferencePredicate::CommitTrailerForbidden { conflict_key, .. }
27 | PreferencePredicate::GitPushForceForbidden { conflict_key } => conflict_key.clone(),
28 }
29 }
30}
31
32#[derive(Debug, Clone, PartialEq, Eq)]
33pub struct PreferenceClassification {
34 pub predicate: PreferencePredicate,
35 pub summary: String,
37}
38
39const PACKAGE_MANAGER_PREDICATES: &[(&str, &str)] = &[
40 (
41 "npm",
42 r"(^|[ \t\r\n])npm[ \t\r\n]+(install|i|add|ci)([ \t\r\n;&|)<>]|$)",
43 ),
44 (
45 "yarn",
46 r"(^|[ \t\r\n])yarn[ \t\r\n]+(add|install)([ \t\r\n;&|)<>]|$)",
47 ),
48 (
49 "bun",
50 r"(^|[ \t\r\n])bun[ \t\r\n]+(add|install)([ \t\r\n;&|)<>]|$)",
51 ),
52 (
53 "pnpm",
54 r"(^|[ \t\r\n])pnpm[ \t\r\n]+(add|install|i)([ \t\r\n;&|)<>]|$)",
55 ),
56];
57
58const FORBIDDEN_COMMANDS: &[(&str, &str)] = &[("git push --force", "git-push-force")];
59
60const FORBIDDEN_COMMAND_ACTIONS: &[&str] = &["do not run", "don't run", "dont run", "never run"];
61
62const FORBIDDEN_COMMAND_SUFFIXES: &[&str] = &[" in this project", ""];
63
64const PACKAGE_MANAGERS: &[&str] = &["bun", "deno", "npm", "pnpm", "yarn"];
65
66const PACKAGE_DIRECTIVE_SUFFIXES: &[&str] = &[
67 ", for package installation commands in this project",
68 ", for installing packages in this project",
69 ", for package installation commands",
70 ", for installing packages",
71 ", in this project",
72 " in this project",
73 "",
74];
75
76const TRAILER_DIRECTIVE_SUFFIXES: &[&str] = &[
77 " in git commits",
78 " on git commits",
79 " to git commits",
80 " in commits",
81 " on commits",
82 " to commits",
83 "",
84];
85
86const TRAILER_ACTIONS: &[&str] = &[
87 "do not add",
88 "do not include",
89 "do not use",
90 "don't add",
91 "don't include",
92 "dont add",
93 "dont include",
94 "never add",
95 "never include",
96 "never use",
97];
98
99const KNOWN_TRAILERS: &[&str] = &["AI-generated-by", "Co-authored-by", "Generated-by"];
100
101pub fn classify_preference_predicate(text: &str) -> Option<PreferenceClassification> {
102 classify_preference_predicates(text).into_iter().next()
103}
104
105pub fn classify_preference_predicates(text: &str) -> Vec<PreferenceClassification> {
108 if crate::memory_candidate::contains_unsafe_memory_marker(text)
109 || crate::adapter::common::redact_sensitive_text(text) != text
110 {
111 return Vec::new();
112 }
113 let lower = text.to_lowercase();
114 let mut classifications = classify_package_manager(&lower)
115 .into_iter()
116 .collect::<Vec<_>>();
117 classifications.extend(classify_forbidden_command(&lower));
118 classifications.extend(classify_commit_trailers(&lower));
119 classifications
120}
121
122fn classify_forbidden_command(lower: &str) -> Option<PreferenceClassification> {
123 for (command, key) in FORBIDDEN_COMMANDS {
124 let is_exact_directive = FORBIDDEN_COMMAND_ACTIONS.iter().any(|action| {
125 has_exact_directive(
126 lower,
127 &format!("{action} {command}"),
128 FORBIDDEN_COMMAND_SUFFIXES,
129 )
130 });
131 if is_exact_directive {
132 return Some(PreferenceClassification {
133 predicate: PreferencePredicate::GitPushForceForbidden {
134 conflict_key: format!("forbidden-command:{key}"),
135 },
136 summary: "Forbidden command".to_string(),
137 });
138 }
139 }
140 None
141}
142
143fn classify_package_manager(lower: &str) -> Option<PreferenceClassification> {
144 for (avoided, pattern) in PACKAGE_MANAGER_PREDICATES {
145 for preferred in PACKAGE_MANAGERS {
146 if preferred == avoided || !directly_prefers_manager(lower, preferred, avoided) {
147 continue;
148 }
149 return Some(PreferenceClassification {
150 predicate: PreferencePredicate::CommandRegex {
151 pattern: (*pattern).to_string(),
152 conflict_key: "package-manager-choice".to_string(),
153 },
154 summary: "Directed package-manager choice".to_string(),
155 });
156 }
157 }
158 None
159}
160
161fn directly_prefers_manager(lower: &str, preferred: &str, avoided: &str) -> bool {
162 [
163 format!("use {preferred}, not {avoided}"),
164 format!("use {preferred} instead of {avoided}"),
165 format!("use {preferred} rather than {avoided}"),
166 format!("prefer {preferred} over {avoided}"),
167 ]
168 .iter()
169 .any(|directive| has_exact_directive(lower, directive, PACKAGE_DIRECTIVE_SUFFIXES))
170}
171
172fn classify_commit_trailers(lower: &str) -> Vec<PreferenceClassification> {
173 if !(lower.contains("trailer") || lower.contains("commit")) {
174 return Vec::new();
175 }
176
177 let mut forbidden = directly_forbidden_trailer_list(lower);
178 for trailer in KNOWN_TRAILERS {
179 if directly_forbids_term(lower, &trailer.to_lowercase())
180 || directly_rejects_trailer_choice(lower, trailer)
181 {
182 forbidden.push((*trailer).to_string());
183 }
184 }
185 forbidden.sort();
186 forbidden.dedup();
187 forbidden
188 .into_iter()
189 .map(|trailer| PreferenceClassification {
190 predicate: PreferencePredicate::CommitTrailerForbidden {
191 conflict_key: format!("trailer:{}", trailer.to_lowercase()),
192 trailer,
193 },
194 summary: "Forbidden commit trailer".to_string(),
195 })
196 .collect()
197}
198
199fn directly_forbids_term(lower: &str, term: &str) -> bool {
200 TRAILER_ACTIONS.iter().any(|action| {
201 [
202 format!("{action} {term} trailer"),
203 format!("{action} the {term} trailer"),
204 format!("{action} {term} commit trailer"),
205 format!("{action} the {term} commit trailer"),
206 ]
207 .iter()
208 .any(|directive| has_exact_directive(lower, directive, TRAILER_DIRECTIVE_SUFFIXES))
209 })
210}
211
212fn directly_rejects_trailer_choice(lower: &str, avoided: &str) -> bool {
213 KNOWN_TRAILERS.iter().any(|preferred| {
214 !preferred.eq_ignore_ascii_case(avoided)
215 && [
216 format!(
217 "use {}, not {}, in commit trailers",
218 preferred.to_lowercase(),
219 avoided.to_lowercase()
220 ),
221 format!(
222 "prefer {} over {} in commit trailers",
223 preferred.to_lowercase(),
224 avoided.to_lowercase()
225 ),
226 ]
227 .iter()
228 .any(|directive| has_exact_directive(lower, directive, &[""]))
229 })
230}
231
232fn directly_forbidden_trailer_list(lower: &str) -> Vec<String> {
233 let Some(statement) = normalized_single_statement(lower) else {
234 return Vec::new();
235 };
236 for action in TRAILER_ACTIONS {
237 let Some(rest) = statement.strip_prefix(&format!("{action} ")) else {
238 continue;
239 };
240 for suffix in TRAILER_DIRECTIVE_SUFFIXES {
241 let Some(body) = rest.strip_suffix(suffix) else {
242 continue;
243 };
244 let Some(list) = body
245 .strip_suffix(" commit trailers")
246 .or_else(|| body.strip_suffix(" trailers"))
247 else {
248 continue;
249 };
250 let names = list.split(" or ").collect::<Vec<_>>();
251 if names.len() < 2 {
252 continue;
253 }
254 let mut canonical = Vec::with_capacity(names.len());
255 for name in names {
256 let Some(trailer) = KNOWN_TRAILERS
257 .iter()
258 .find(|trailer| trailer.eq_ignore_ascii_case(name))
259 else {
260 canonical.clear();
261 break;
262 };
263 canonical.push((*trailer).to_string());
264 }
265 canonical.sort();
266 canonical.dedup();
267 if canonical.len() >= 2 {
268 return canonical;
269 }
270 }
271 }
272 Vec::new()
273}
274
275fn has_exact_directive(lower: &str, directive: &str, allowed_suffixes: &[&str]) -> bool {
276 normalized_single_statement(lower)
277 .and_then(|statement| statement.strip_prefix(directive))
278 .is_some_and(|suffix| allowed_suffixes.contains(&suffix))
279}
280
281fn normalized_single_statement(lower: &str) -> Option<&str> {
282 let statement = lower.trim().trim_end_matches(['.', '!', '?']).trim_end();
283 if statement
284 .chars()
285 .any(|ch| [';', ':', '.', '!', '?', '\n', '\r'].contains(&ch))
286 {
287 return None;
288 }
289 Some(statement)
290}
291
292#[cfg(test)]
293mod tests {
294 use super::*;
295
296 fn command_pattern(text: &str) -> String {
297 match classify_preference_predicate(text)
298 .unwrap_or_else(|| panic!("expected classification for {text}"))
299 .predicate
300 {
301 PreferencePredicate::CommandRegex { pattern, .. } => pattern,
302 other => panic!("expected command regex for {text}, got {other:?}"),
303 }
304 }
305
306 #[test]
307 fn package_manager_direction_covers_supported_managers() {
308 for (text, banned) in [
309 ("Use npm, not yarn", "yarn"),
310 ("Prefer yarn over bun", "bun"),
311 ("Use bun instead of pnpm", "pnpm"),
312 ("Use pnpm, not npm", "npm"),
313 ] {
314 assert!(
315 command_pattern(text).contains(banned),
316 "{text} banned the wrong manager"
317 );
318 }
319 assert!(classify_preference_predicate("Do not use npm; use pnpm").is_none());
320 }
321
322 #[test]
323 fn canonical_package_directive_with_project_suffix_classifies() {
324 let classification = classify_preference_predicate(
325 "Use bun, not npm, for package installation commands in this project.",
326 )
327 .expect("closed package-manager preference should classify");
328 assert!(matches!(
329 classification.predicate,
330 PreferencePredicate::CommandRegex { ref pattern, .. } if pattern.contains("npm")
331 ));
332 }
333
334 #[test]
335 fn package_manager_pattern_stops_at_redirection_metacharacters() {
336 let pattern = command_pattern("Use pnpm, not npm");
337 let regex = regex_lite::Regex::new(&pattern).expect("classifier pattern must compile");
338 assert!(regex.is_match("npm install>install.log"));
339 assert!(regex.is_match("npm install >install.log"));
340 assert!(regex.is_match("npm i<packages.txt"));
341 assert!(regex.is_match("npm install 2>errors.log"));
342 assert!(!regex.is_match("npm installer"));
343 }
344
345 #[test]
346 fn ambiguous_multiclause_preferences_fail_closed() {
347 for text in [
348 "Never omit Co-authored-by",
349 "Prefer bun, but do not forbid npm",
350 "Do not add AI-generated-by and use Co-authored-by",
351 "Do not avoid npm; use npm instead of yarn",
352 "Never, under any circumstances, avoid npm; use npm instead of yarn",
353 "Use bun, not npm; unless CI requires npm",
354 "Use bun, not npm. Actually use npm for CI",
355 "Never add the Co-authored-by trailer. Except for pair-authored commits",
356 "There is no good reason, whatsoever, to omit the Co-authored-by trailer",
357 ] {
358 assert!(
359 classify_preference_predicate(text).is_none(),
360 "must fail closed: {text}"
361 );
362 }
363 }
364
365 #[test]
366 fn positive_commit_trailer_direction_is_not_forbidden() {
367 assert!(
368 classify_preference_predicate("Use the Co-authored-by trailer for paired commits")
369 .is_none()
370 );
371 }
372
373 #[test]
374 fn classifies_forbidden_commit_trailer() {
375 let classification =
376 classify_preference_predicate("Never add the AI-generated-by trailer to git commits")
377 .expect("commit trailer preference should classify");
378 assert!(matches!(
379 classification.predicate,
380 PreferencePredicate::CommitTrailerForbidden { ref trailer, .. }
381 if trailer == "AI-generated-by"
382 ));
383 }
384
385 #[test]
386 fn trailer_choice_forbids_only_the_negative_direction() {
387 let trailers = classify_preference_predicates(
388 "Use AI-generated-by, not Co-authored-by, in commit trailers",
389 )
390 .into_iter()
391 .filter_map(|classification| match classification.predicate {
392 PreferencePredicate::CommitTrailerForbidden { trailer, .. } => Some(trailer),
393 PreferencePredicate::CommandRegex { .. }
394 | PreferencePredicate::GitPushForceForbidden { .. } => None,
395 })
396 .collect::<Vec<_>>();
397 assert_eq!(trailers, ["Co-authored-by"]);
398 }
399
400 #[test]
401 fn classifies_each_forbidden_trailer_in_closed_list() {
402 let trailers = classify_preference_predicates(
403 "Do not add AI-generated-by or Co-authored-by trailers to commits",
404 )
405 .into_iter()
406 .filter_map(|classification| match classification.predicate {
407 PreferencePredicate::CommitTrailerForbidden { trailer, .. } => Some(trailer),
408 PreferencePredicate::CommandRegex { .. }
409 | PreferencePredicate::GitPushForceForbidden { .. } => None,
410 })
411 .collect::<Vec<_>>();
412 assert_eq!(trailers, ["AI-generated-by", "Co-authored-by"]);
413 }
414
415 #[test]
416 fn forbidden_command_classifier_is_exact_and_closed() -> anyhow::Result<()> {
417 assert!(matches!(
418 classify_preference_predicate("Never run git push --force")
419 .expect("closed forbidden command should classify")
420 .predicate,
421 PreferencePredicate::GitPushForceForbidden { .. }
422 ));
423
424 for text in [
425 "Never run rm -rf /",
426 "Never run git push --force unless asked",
427 "Never run git push --force; use --force-with-lease",
428 ] {
429 assert!(
430 classify_preference_predicate(text).is_none(),
431 "must fail closed: {text}"
432 );
433 }
434 Ok(())
435 }
436
437 #[test]
438 fn ambiguous_or_sensitive_preference_is_not_machine_checkable() {
439 assert!(classify_preference_predicate("I like clean code and short functions").is_none());
440 assert!(classify_preference_predicate("npm is a package manager").is_none());
441 assert!(classify_preference_predicate(
442 "Use bun, not npm; the API key is sk-testsecret123456"
443 )
444 .is_none());
445 }
446}