ailint_core/rules/semantic/
negative_constraint_overload.rs1use 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#[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 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}