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