Skip to main content

ailint_core/rules/semantic/
negative_constraint_overload.rs

1//! AIL104 `negative-constraint-overload` — negative constraints dominate affirmative guidance.
2//!
3//! See: `docs/rules/semantic/AIL104.md`
4
5use serde::Deserialize;
6
7use crate::file_type::FileType;
8use crate::parser::{DocumentContent, ParsedDocument};
9use crate::rules::semantic::AIL104;
10use crate::rules::{dictionary_lines, Rule, RuleContext, RuleId, Severity, Violation};
11
12const DEFAULT_PREFIXES: &str = include_str!("negation_prefixes.txt");
13const DEFAULT_MIN_LIST_ITEMS: usize = 5;
14
15#[derive(Debug, Default, Deserialize)]
16#[serde(default, deny_unknown_fields)]
17struct Options {
18    prefixes: Option<Vec<String>>,
19    extra_prefixes: Option<Vec<String>>,
20    min_list_items: Option<usize>,
21}
22
23/// AIL104 negative-constraint-overload: list dominated by "do not" constraints.
24#[derive(Debug, Default)]
25pub struct NegativeConstraintOverloadRule;
26
27impl Rule for NegativeConstraintOverloadRule {
28    fn id(&self) -> RuleId {
29        AIL104
30    }
31
32    fn default_severity(&self) -> Severity {
33        Severity::Warning
34    }
35
36    fn description(&self) -> &'static str {
37        "List is dominated by negative constraints; LLMs perform better with affirmative phrasing."
38    }
39
40    fn fix_hint(&self) -> &'static str {
41        "Rewrite as positive directives (\"Do X\" over \"Don't Y\")."
42    }
43
44    fn applies_to(&self, file_type: FileType) -> bool {
45        file_type.has_prose_content()
46    }
47
48    fn run(&self, doc: &ParsedDocument, ctx: &RuleContext<'_>) -> Vec<Violation> {
49        let md = match &doc.content {
50            DocumentContent::Markdown(m) => m,
51            _ => return Vec::new(),
52        };
53
54        let opts: Options = ctx
55            .options
56            .and_then(|v| serde_yaml::from_value(v.clone()).ok())
57            .unwrap_or_default();
58
59        let min_list_items = opts.min_list_items.unwrap_or(DEFAULT_MIN_LIST_ITEMS);
60        if md.list_items.len() < min_list_items {
61            return Vec::new();
62        }
63
64        // Prefix match only: mid-sentence negations ("prefer X, not Y") are fine.
65        let mut prefixes: Vec<String> = match opts.prefixes {
66            Some(p) => p,
67            None => dictionary_lines(DEFAULT_PREFIXES)
68                .into_iter()
69                .map(String::from)
70                .collect(),
71        };
72        if let Some(extra) = opts.extra_prefixes {
73            prefixes.extend(extra);
74        }
75        for p in &mut prefixes {
76            *p = p.to_lowercase();
77        }
78
79        let mut negative_count = 0;
80        for item in &md.list_items {
81            let lower_text = item
82                .text
83                .trim_start_matches(['*', '_', '`'])
84                .trim()
85                .to_lowercase();
86            if prefixes.iter().any(|p| lower_text.starts_with(p.as_str())) {
87                negative_count += 1;
88            }
89        }
90
91        if negative_count > md.list_items.len() / 2 {
92            let v = Violation::new(
93                AIL104,
94                ctx.severity,
95                doc.path.clone(),
96                "list dominated by negative constraints",
97            )
98            .with_detail(format!(
99                "{} of {} items are negative",
100                negative_count,
101                md.list_items.len()
102            ));
103            vec![v]
104        } else {
105            Vec::new()
106        }
107    }
108}