#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PreferencePredicate {
CommandRegex {
pattern: String,
conflict_key: String,
},
CommitTrailerForbidden {
trailer: String,
conflict_key: String,
},
}
impl PreferencePredicate {
pub fn conflict_key(&self) -> String {
match self {
PreferencePredicate::CommandRegex { conflict_key, .. }
| PreferencePredicate::CommitTrailerForbidden { conflict_key, .. } => {
conflict_key.clone()
}
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PreferenceClassification {
pub predicate: PreferencePredicate,
pub summary: String,
}
const PACKAGE_MANAGER_PREDICATES: &[(&str, &str)] = &[
("npm", r"(^|\s)npm\s+(install|i|add|ci)\b"),
("yarn", r"(^|\s)yarn\s+(add|install)\b"),
("bun", r"(^|\s)bun\s+(add|install)\b"),
("pnpm", r"(^|\s)pnpm\s+(add|install|i)\b"),
];
const PACKAGE_MANAGERS: &[&str] = &["bun", "deno", "npm", "pnpm", "yarn"];
const PACKAGE_DIRECTIVE_SUFFIXES: &[&str] = &[
", for package installation commands in this project",
", for installing packages in this project",
", for package installation commands",
", for installing packages",
", in this project",
" in this project",
"",
];
const TRAILER_DIRECTIVE_SUFFIXES: &[&str] = &[
" in git commits",
" on git commits",
" to git commits",
" in commits",
" on commits",
" to commits",
"",
];
const TRAILER_ACTIONS: &[&str] = &[
"do not add",
"do not include",
"do not use",
"don't add",
"don't include",
"dont add",
"dont include",
"never add",
"never include",
"never use",
];
const KNOWN_TRAILERS: &[&str] = &["AI-generated-by", "Co-authored-by", "Generated-by"];
pub fn classify_preference_predicate(text: &str) -> Option<PreferenceClassification> {
classify_preference_predicates(text).into_iter().next()
}
pub fn classify_preference_predicates(text: &str) -> Vec<PreferenceClassification> {
if crate::memory_candidate::contains_unsafe_memory_marker(text)
|| crate::adapter::common::redact_sensitive_text(text) != text
{
return Vec::new();
}
let lower = text.to_lowercase();
let mut classifications = classify_package_manager(&lower)
.into_iter()
.collect::<Vec<_>>();
classifications.extend(classify_commit_trailers(&lower));
classifications
}
fn classify_package_manager(lower: &str) -> Option<PreferenceClassification> {
for (avoided, pattern) in PACKAGE_MANAGER_PREDICATES {
for preferred in PACKAGE_MANAGERS {
if preferred == avoided || !directly_prefers_manager(lower, preferred, avoided) {
continue;
}
return Some(PreferenceClassification {
predicate: PreferencePredicate::CommandRegex {
pattern: (*pattern).to_string(),
conflict_key: "package-manager-choice".to_string(),
},
summary: "Directed package-manager choice".to_string(),
});
}
}
None
}
fn directly_prefers_manager(lower: &str, preferred: &str, avoided: &str) -> bool {
[
format!("use {preferred}, not {avoided}"),
format!("use {preferred} instead of {avoided}"),
format!("use {preferred} rather than {avoided}"),
format!("prefer {preferred} over {avoided}"),
]
.iter()
.any(|directive| has_exact_directive(lower, directive, PACKAGE_DIRECTIVE_SUFFIXES))
}
fn classify_commit_trailers(lower: &str) -> Vec<PreferenceClassification> {
if !(lower.contains("trailer") || lower.contains("commit")) {
return Vec::new();
}
let mut forbidden = directly_forbidden_trailer_list(lower);
for trailer in KNOWN_TRAILERS {
if directly_forbids_term(lower, &trailer.to_lowercase())
|| directly_rejects_trailer_choice(lower, trailer)
{
forbidden.push((*trailer).to_string());
}
}
forbidden.sort();
forbidden.dedup();
forbidden
.into_iter()
.map(|trailer| PreferenceClassification {
predicate: PreferencePredicate::CommitTrailerForbidden {
conflict_key: format!("trailer:{}", trailer.to_lowercase()),
trailer,
},
summary: "Forbidden commit trailer".to_string(),
})
.collect()
}
fn directly_forbids_term(lower: &str, term: &str) -> bool {
TRAILER_ACTIONS.iter().any(|action| {
[
format!("{action} {term} trailer"),
format!("{action} the {term} trailer"),
format!("{action} {term} commit trailer"),
format!("{action} the {term} commit trailer"),
]
.iter()
.any(|directive| has_exact_directive(lower, directive, TRAILER_DIRECTIVE_SUFFIXES))
})
}
fn directly_rejects_trailer_choice(lower: &str, avoided: &str) -> bool {
KNOWN_TRAILERS.iter().any(|preferred| {
!preferred.eq_ignore_ascii_case(avoided)
&& [
format!(
"use {}, not {}, in commit trailers",
preferred.to_lowercase(),
avoided.to_lowercase()
),
format!(
"prefer {} over {} in commit trailers",
preferred.to_lowercase(),
avoided.to_lowercase()
),
]
.iter()
.any(|directive| has_exact_directive(lower, directive, &[""]))
})
}
fn directly_forbidden_trailer_list(lower: &str) -> Vec<String> {
let Some(statement) = normalized_single_statement(lower) else {
return Vec::new();
};
for action in TRAILER_ACTIONS {
let Some(rest) = statement.strip_prefix(&format!("{action} ")) else {
continue;
};
for suffix in TRAILER_DIRECTIVE_SUFFIXES {
let Some(body) = rest.strip_suffix(suffix) else {
continue;
};
let Some(list) = body
.strip_suffix(" commit trailers")
.or_else(|| body.strip_suffix(" trailers"))
else {
continue;
};
let names = list.split(" or ").collect::<Vec<_>>();
if names.len() < 2 {
continue;
}
let mut canonical = Vec::with_capacity(names.len());
for name in names {
let Some(trailer) = KNOWN_TRAILERS
.iter()
.find(|trailer| trailer.eq_ignore_ascii_case(name))
else {
canonical.clear();
break;
};
canonical.push((*trailer).to_string());
}
canonical.sort();
canonical.dedup();
if canonical.len() >= 2 {
return canonical;
}
}
}
Vec::new()
}
fn has_exact_directive(lower: &str, directive: &str, allowed_suffixes: &[&str]) -> bool {
normalized_single_statement(lower)
.and_then(|statement| statement.strip_prefix(directive))
.is_some_and(|suffix| allowed_suffixes.contains(&suffix))
}
fn normalized_single_statement(lower: &str) -> Option<&str> {
let statement = lower.trim().trim_end_matches(['.', '!', '?']).trim_end();
if statement
.chars()
.any(|ch| [';', ':', '.', '!', '?', '\n', '\r'].contains(&ch))
{
return None;
}
Some(statement)
}
#[cfg(test)]
mod tests {
use super::*;
fn command_pattern(text: &str) -> String {
match classify_preference_predicate(text)
.unwrap_or_else(|| panic!("expected classification for {text}"))
.predicate
{
PreferencePredicate::CommandRegex { pattern, .. } => pattern,
other => panic!("expected command regex for {text}, got {other:?}"),
}
}
#[test]
fn package_manager_direction_covers_supported_managers() {
for (text, banned) in [
("Use npm, not yarn", "yarn"),
("Prefer yarn over bun", "bun"),
("Use bun instead of pnpm", "pnpm"),
("Use pnpm, not npm", "npm"),
] {
assert!(
command_pattern(text).contains(banned),
"{text} banned the wrong manager"
);
}
assert!(classify_preference_predicate("Do not use npm; use pnpm").is_none());
}
#[test]
fn canonical_package_directive_with_project_suffix_classifies() {
let classification = classify_preference_predicate(
"Use bun, not npm, for package installation commands in this project.",
)
.expect("closed package-manager preference should classify");
assert!(matches!(
classification.predicate,
PreferencePredicate::CommandRegex { ref pattern, .. } if pattern.contains("npm")
));
}
#[test]
fn ambiguous_multiclause_preferences_fail_closed() {
for text in [
"Never omit Co-authored-by",
"Prefer bun, but do not forbid npm",
"Do not add AI-generated-by and use Co-authored-by",
"Do not avoid npm; use npm instead of yarn",
"Never, under any circumstances, avoid npm; use npm instead of yarn",
"Use bun, not npm; unless CI requires npm",
"Use bun, not npm. Actually use npm for CI",
"Never add the Co-authored-by trailer. Except for pair-authored commits",
"There is no good reason, whatsoever, to omit the Co-authored-by trailer",
] {
assert!(
classify_preference_predicate(text).is_none(),
"must fail closed: {text}"
);
}
}
#[test]
fn positive_commit_trailer_direction_is_not_forbidden() {
assert!(
classify_preference_predicate("Use the Co-authored-by trailer for paired commits")
.is_none()
);
}
#[test]
fn classifies_forbidden_commit_trailer() {
let classification =
classify_preference_predicate("Never add the AI-generated-by trailer to git commits")
.expect("commit trailer preference should classify");
assert!(matches!(
classification.predicate,
PreferencePredicate::CommitTrailerForbidden { ref trailer, .. }
if trailer == "AI-generated-by"
));
}
#[test]
fn trailer_choice_forbids_only_the_negative_direction() {
let trailers = classify_preference_predicates(
"Use AI-generated-by, not Co-authored-by, in commit trailers",
)
.into_iter()
.filter_map(|classification| match classification.predicate {
PreferencePredicate::CommitTrailerForbidden { trailer, .. } => Some(trailer),
PreferencePredicate::CommandRegex { .. } => None,
})
.collect::<Vec<_>>();
assert_eq!(trailers, ["Co-authored-by"]);
}
#[test]
fn classifies_each_forbidden_trailer_in_closed_list() {
let trailers = classify_preference_predicates(
"Do not add AI-generated-by or Co-authored-by trailers to commits",
)
.into_iter()
.filter_map(|classification| match classification.predicate {
PreferencePredicate::CommitTrailerForbidden { trailer, .. } => Some(trailer),
PreferencePredicate::CommandRegex { .. } => None,
})
.collect::<Vec<_>>();
assert_eq!(trailers, ["AI-generated-by", "Co-authored-by"]);
}
#[test]
fn ambiguous_or_sensitive_preference_is_not_machine_checkable() {
assert!(classify_preference_predicate("I like clean code and short functions").is_none());
assert!(classify_preference_predicate("npm is a package manager").is_none());
assert!(classify_preference_predicate(
"Use bun, not npm; the API key is sk-testsecret123456"
)
.is_none());
}
}