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 crate::parser::{DocumentContent, ParsedDocument};
6use crate::rules::semantic::AIL104;
7use crate::rules::{Rule, RuleContext, RuleId, Severity, Violation};
8
9/// AIL104 negative-constraint-overload: list dominated by "do not" constraints.
10#[derive(Debug, Default)]
11pub struct NegativeConstraintOverloadRule;
12
13impl Rule for NegativeConstraintOverloadRule {
14    fn id(&self) -> RuleId {
15        AIL104
16    }
17
18    fn default_severity(&self) -> Severity {
19        Severity::Warning
20    }
21
22    fn description(&self) -> &'static str {
23        "List is dominated by negative constraints; LLMs perform better with affirmative phrasing."
24    }
25
26    fn fix_hint(&self) -> &'static str {
27        "Rewrite as positive directives (\"Do X\" over \"Don't Y\")."
28    }
29
30    fn run(&self, doc: &ParsedDocument, ctx: &RuleContext<'_>) -> Vec<Violation> {
31        let md = match &doc.content {
32            DocumentContent::Markdown(m) => m,
33            _ => return Vec::new(),
34        };
35
36        if md.list_items.len() < 5 {
37            return Vec::new();
38        }
39
40        // Prefix match only: mid-sentence negations ("prefer X, not Y") are fine.
41        let negative_prefixes = [
42            "do not", "don't", "never", "avoid", "stop ", "no ", "must not",
43        ];
44
45        let mut negative_count = 0;
46        for item in &md.list_items {
47            let lower_text = item
48                .text
49                .trim_start_matches(['*', '_', '`'])
50                .trim()
51                .to_lowercase();
52            if negative_prefixes.iter().any(|p| lower_text.starts_with(p)) {
53                negative_count += 1;
54            }
55        }
56
57        if negative_count > md.list_items.len() / 2 {
58            let v = Violation::new(
59                AIL104,
60                ctx.severity,
61                doc.path.clone(),
62                "list dominated by negative constraints",
63            )
64            .with_detail(format!(
65                "{} of {} items are negative",
66                negative_count,
67                md.list_items.len()
68            ));
69            vec![v]
70        } else {
71            Vec::new()
72        }
73    }
74}