Skip to main content

ailint_core/rules/semantic/
vague_instruction.rs

1//! AIL100 `no-vague-instruction` — list items containing hand-wavy phrases.
2//!
3//! See: `docs/rules/semantic/AIL100.md`
4
5use aho_corasick::AhoCorasick;
6use serde::Deserialize;
7
8use crate::parser::{DocumentContent, ParsedDocument};
9use crate::rules::semantic::AIL100;
10use crate::rules::{dictionary_lines, Rule, RuleContext, RuleId, Severity, Violation};
11
12const DEFAULT_PHRASES: &str = include_str!("vague_phrases.txt");
13
14#[derive(Debug, Default, Deserialize)]
15#[serde(default, deny_unknown_fields)]
16struct Options {
17    phrases: Option<Vec<String>>,
18    extra_phrases: Option<Vec<String>>,
19    case_sensitive: Option<bool>,
20}
21
22/// AIL100 no-vague-instruction: flags unactionable phrasing like "be careful".
23#[derive(Debug, Default)]
24pub struct NoVagueInstructionRule;
25
26impl Rule for NoVagueInstructionRule {
27    fn id(&self) -> RuleId {
28        AIL100
29    }
30
31    fn default_severity(&self) -> Severity {
32        Severity::Warning
33    }
34
35    fn description(&self) -> &'static str {
36        "List item contains vague, unactionable phrasing."
37    }
38
39    fn fix_hint(&self) -> &'static str {
40        "Replace with a concrete verb and target the agent can act on."
41    }
42
43    fn run(&self, doc: &ParsedDocument, ctx: &RuleContext<'_>) -> Vec<Violation> {
44        let md = match &doc.content {
45            DocumentContent::Markdown(m) => m,
46            _ => return Vec::new(),
47        };
48
49        let opts: Options = ctx
50            .options
51            .and_then(|v| serde_yaml::from_value(v.clone()).ok())
52            .unwrap_or_default();
53        let case_sensitive = opts.case_sensitive.unwrap_or(false);
54
55        let mut phrases: Vec<String> = match opts.phrases {
56            Some(p) => p,
57            None => dictionary_lines(DEFAULT_PHRASES)
58                .into_iter()
59                .map(String::from)
60                .collect(),
61        };
62        if let Some(extra) = opts.extra_phrases {
63            phrases.extend(extra);
64        }
65        if !case_sensitive {
66            for p in &mut phrases {
67                *p = p.to_lowercase();
68            }
69        }
70        // Single-pass search over all phrases at once.
71        let Ok(ac) = AhoCorasick::new(&phrases) else {
72            return Vec::new();
73        };
74
75        let mut out = Vec::new();
76        for item in &md.list_items {
77            let haystack = if case_sensitive {
78                item.text.clone()
79            } else {
80                item.text.to_lowercase()
81            };
82            if let Some(m) = ac.find(&haystack) {
83                let phrase = &phrases[m.pattern().as_usize()];
84                let snippet: String = item.text.chars().take(120).collect();
85                let mut v = Violation::new(
86                    AIL100,
87                    self.default_severity(),
88                    doc.path.clone(),
89                    "vague phrase",
90                )
91                .at(item.line, 1)
92                .with_detail(phrase.clone());
93                v.snippet = Some(snippet);
94                out.push(v);
95            }
96        }
97        out
98    }
99}