Skip to main content

ailint_core/rules/consistency/
conflicting_rules.rs

1//! AIL300 `no-conflicting-rules` — negation-pair detection across files.
2//!
3//! See: `docs/rules/consistency/AIL300.md`
4
5use std::collections::HashMap;
6
7use aho_corasick::{AhoCorasick, Anchored, Input, MatchKind, StartKind};
8use serde::Deserialize;
9use unicode_segmentation::UnicodeSegmentation;
10
11use crate::parser::{DocumentContent, ParsedDocument};
12use crate::rules::consistency::AIL300;
13use crate::rules::{dictionary_lines, BatchRule, RuleContext, RuleId, Severity, Violation};
14
15const NEGATION_PREFIXES: &str = include_str!("negation_prefixes.txt");
16
17const DEFAULT_MIN_CORE_WORDS: usize = 3;
18
19#[derive(Debug, Default, Deserialize)]
20#[serde(default, deny_unknown_fields)]
21struct Options {
22    negation_prefixes: Option<Vec<String>>,
23    min_core_words: Option<usize>,
24}
25
26/// AIL300 no-conflicting-rules: flags contradictory instructions across files.
27#[derive(Debug, Default)]
28pub struct NoConflictingRulesRule;
29
30impl BatchRule for NoConflictingRulesRule {
31    fn id(&self) -> RuleId {
32        AIL300
33    }
34
35    fn default_severity(&self) -> Severity {
36        Severity::Warning
37    }
38
39    fn description(&self) -> &'static str {
40        "Rule contradicts another rule elsewhere in the corpus."
41    }
42
43    fn fix_hint(&self) -> &'static str {
44        "Reconcile the two rules, or delete one."
45    }
46
47    fn run_batch(&self, docs: &[ParsedDocument], ctx: &RuleContext<'_>) -> Vec<Violation> {
48        let opts: Options = ctx
49            .options
50            .and_then(|v| serde_yaml::from_value(v.clone()).ok())
51            .unwrap_or_default();
52        let prefixes: Vec<String> = opts
53            .negation_prefixes
54            .unwrap_or_else(|| {
55                dictionary_lines(NEGATION_PREFIXES)
56                    .into_iter()
57                    .map(String::from)
58                    .collect()
59            })
60            .into_iter()
61            .map(|p| normalize(&p))
62            .filter(|p| !p.is_empty())
63            .collect();
64        let Some(matcher) = prefix_matcher(&prefixes) else {
65            return Vec::new();
66        };
67        let min_core_words = opts.min_core_words.unwrap_or(DEFAULT_MIN_CORE_WORDS);
68
69        // (doc_index, item_index_in_doc)
70        #[derive(Debug, Clone)]
71        struct Entry {
72            doc_idx: usize,
73            item_idx: usize,
74            negated: bool,
75        }
76
77        let mut groups: HashMap<String, Vec<Entry>> = HashMap::new();
78        for (doc_idx, doc) in docs.iter().enumerate() {
79            let md = match &doc.content {
80                DocumentContent::Markdown(m) => m,
81                _ => continue,
82            };
83            for (item_idx, item) in md.list_items.iter().enumerate() {
84                let base = normalize(&item.text);
85                if base.is_empty() {
86                    continue;
87                }
88                let (core, negated) = strip_negation(&base, &matcher);
89                if core.split_whitespace().count() < min_core_words {
90                    continue;
91                }
92                groups.entry(core.to_string()).or_default().push(Entry {
93                    doc_idx,
94                    item_idx,
95                    negated,
96                });
97            }
98        }
99
100        let mut out = Vec::new();
101        for entries in groups.values() {
102            // Need at least two files represented and both polarities.
103            let mut has_pos = None::<&Entry>;
104            let mut has_neg = None::<&Entry>;
105            for e in entries {
106                if e.negated {
107                    if has_neg.is_none() {
108                        has_neg = Some(e);
109                    }
110                } else if has_pos.is_none() {
111                    has_pos = Some(e);
112                }
113            }
114            let (Some(pos), Some(neg)) = (has_pos, has_neg) else {
115                continue;
116            };
117            if docs[pos.doc_idx].path == docs[neg.doc_idx].path {
118                continue;
119            }
120            for e in entries {
121                let counter = if e.negated { pos } else { neg };
122                if docs[e.doc_idx].path == docs[counter.doc_idx].path {
123                    continue;
124                }
125                let doc = &docs[e.doc_idx];
126                let other = &docs[counter.doc_idx];
127                let md = match &doc.content {
128                    DocumentContent::Markdown(m) => m,
129                    _ => continue,
130                };
131                let other_md = match &other.content {
132                    DocumentContent::Markdown(m) => m,
133                    _ => continue,
134                };
135                let item = &md.list_items[e.item_idx];
136                let other_item = &other_md.list_items[counter.item_idx];
137                let other_text = truncate_chars(other_item.text.trim(), 80);
138                let other_name = other
139                    .path
140                    .file_name()
141                    .and_then(|s| s.to_str())
142                    .unwrap_or("<unknown>");
143                let mut v = Violation::new(
144                    AIL300,
145                    self.default_severity(),
146                    doc.path.clone(),
147                    "conflicting rule",
148                )
149                .at(item.line, 1)
150                .with_detail(format!("conflicts with '{}' in {}", other_text, other_name));
151                v.snippet = Some(truncate_chars(&item.text, 120));
152                out.push(v);
153            }
154        }
155        out
156    }
157}
158
159// Lowercase and collapse to space-joined words (UAX#29 segmentation keeps
160// in-word apostrophes like "don't" intact).
161fn normalize(s: &str) -> String {
162    s.unicode_words()
163        .map(str::to_lowercase)
164        .collect::<Vec<_>>()
165        .join(" ")
166}
167
168// Anchored leftmost-longest automaton over the negation prefixes.
169fn prefix_matcher(prefixes: &[String]) -> Option<AhoCorasick> {
170    AhoCorasick::builder()
171        .match_kind(MatchKind::LeftmostLongest)
172        .start_kind(StartKind::Anchored)
173        .build(prefixes)
174        .ok()
175}
176
177// If `s` starts with a negation prefix (as a whole word/phrase), return
178// (core, true). Otherwise return (s, false).
179fn strip_negation<'a>(s: &'a str, matcher: &AhoCorasick) -> (&'a str, bool) {
180    if let Some(m) = matcher.find(Input::new(s).anchored(Anchored::Yes)) {
181        let rest = &s[m.end()..];
182        if rest.is_empty() {
183            return ("", true);
184        }
185        if rest.starts_with(' ') {
186            return (rest.trim_start(), true);
187        }
188    }
189    (s, false)
190}
191
192fn truncate_chars(s: &str, max: usize) -> String {
193    if s.chars().count() <= max {
194        return s.to_string();
195    }
196    let mut out: String = s.chars().take(max).collect();
197    out.push('…');
198    out
199}
200
201#[cfg(test)]
202mod tests {
203    use super::*;
204
205    fn default_matcher() -> AhoCorasick {
206        let prefixes: Vec<String> = dictionary_lines(NEGATION_PREFIXES)
207            .into_iter()
208            .map(normalize)
209            .collect();
210        prefix_matcher(&prefixes).expect("default prefixes must compile")
211    }
212
213    #[test]
214    fn normalize_strips_punct_and_lowercases() {
215        assert_eq!(normalize("Use TABS, please!"), "use tabs please");
216    }
217
218    #[test]
219    fn normalize_keeps_inword_apostrophe() {
220        assert_eq!(normalize("Don't panic"), "don't panic");
221    }
222
223    #[test]
224    fn strip_negation_detects_dont() {
225        let (core, neg) = strip_negation("don't use tabs for indentation", &default_matcher());
226        assert!(neg);
227        assert_eq!(core, "use tabs for indentation");
228    }
229
230    #[test]
231    fn strip_negation_leaves_positive_alone() {
232        let (core, neg) = strip_negation("use tabs for indentation", &default_matcher());
233        assert!(!neg);
234        assert_eq!(core, "use tabs for indentation");
235    }
236
237    #[test]
238    fn strip_negation_requires_word_boundary() {
239        // "no" must not strip inside "notice".
240        let (core, neg) = strip_negation("notice the pattern here", &default_matcher());
241        assert!(!neg);
242        assert_eq!(core, "notice the pattern here");
243    }
244}