Skip to main content

remem/rules/compiler/
classify.rs

1//! Deterministic, conservative classification of preference text into v1
2//! predicates. No LLM, no network. The recognised grammar is intentionally
3//! closed so ambiguous natural-language exceptions never become block rules.
4
5/// A predicate template derived from preference text. `conflict_key` groups
6/// contradictory preferences so the compiler keeps one authoritative source.
7#[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    /// Static classifier description; never copied from preference text.
36    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
105/// Return every deterministic predicate represented by one closed
106/// preference directive. A multi-trailer directive may emit multiple rules.
107pub 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}