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