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