spec_drift/analyzers/
docs.rs1use super::DriftAnalyzer;
2use crate::context::ProjectContext;
3use crate::domain::{Divergence, RuleId, Severity, SpecClaim};
4use crate::parsers::MarkdownParser;
5use regex::Regex;
6
7pub struct DocsAnalyzer {
14 ident_re: Regex,
15}
16
17impl Default for DocsAnalyzer {
18 fn default() -> Self {
19 Self {
20 ident_re: Regex::new(
21 r"^([A-Za-z_][A-Za-z0-9_]*(?:::[A-Za-z_][A-Za-z0-9_]*)*)(\(\))?$",
22 )
23 .expect("static regex"),
24 }
25 }
26}
27
28impl DriftAnalyzer for DocsAnalyzer {
29 fn id(&self) -> &'static str {
30 "docs"
31 }
32
33 fn analyze(&self, ctx: &ProjectContext) -> Vec<Divergence> {
34 let mut out = Vec::new();
35
36 for md in &ctx.markdown_files {
37 let claims = match MarkdownParser::parse(md) {
38 Ok(c) => c,
39 Err(_) => continue,
40 };
41
42 for claim in claims {
43 let Some(symbol) = self.extract_symbol(&claim) else {
44 continue;
45 };
46 let leaf = symbol.rsplit("::").next().unwrap_or(&symbol);
47
48 if is_language_intrinsic(leaf) {
50 continue;
51 }
52
53 if ctx.facts_named(leaf).next().is_none() {
54 out.push(Divergence {
55 rule: RuleId::SymbolAbsence,
56 severity: Severity::Critical,
57 location: claim.location.clone(),
58 stated: format!("`{symbol}` exists in the codebase"),
59 reality: format!(
60 "no symbol named `{leaf}` found in the parsed Rust sources"
61 ),
62 risk: "New developers and AI agents will reach for a non-existent API."
63 .to_string(),
64 attribution: None,
65
66 });
67 }
68 }
69 }
70
71 out
72 }
73}
74
75impl DocsAnalyzer {
76 fn extract_symbol(&self, claim: &SpecClaim) -> Option<String> {
87 let text = claim.text.trim();
88 let caps = self.ident_re.captures(text)?;
89 let symbol = caps.get(1)?.as_str().to_string();
90 let has_call_parens = caps.get(2).is_some();
91 let has_path_sep = symbol.contains("::");
92 let looks_like_type = symbol.starts_with(|c: char| c.is_ascii_uppercase())
97 && symbol.chars().any(|c| c.is_ascii_lowercase());
98
99 if has_call_parens || has_path_sep || looks_like_type {
100 Some(symbol)
101 } else {
102 None
103 }
104 }
105}
106
107fn is_language_intrinsic(name: &str) -> bool {
112 matches!(
113 name,
114 "bool"
116 | "char"
117 | "str"
118 | "String"
119 | "i8"
120 | "i16"
121 | "i32"
122 | "i64"
123 | "i128"
124 | "isize"
125 | "u8"
126 | "u16"
127 | "u32"
128 | "u64"
129 | "u128"
130 | "usize"
131 | "f32"
132 | "f64"
133 | "Option"
134 | "Some"
135 | "None"
136 | "Result"
137 | "Ok"
138 | "Err"
139 | "Vec"
140 | "Box"
141 | "Arc"
142 | "Rc"
143 | "RefCell"
144 | "Cell"
145 | "Mutex"
146 | "RwLock"
147 | "HashMap"
148 | "HashSet"
149 | "BTreeMap"
150 | "BTreeSet"
151 | "fn"
152 | "impl"
153 | "trait"
154 | "struct"
155 | "enum"
156 | "pub"
157 | "mod"
158 | "use"
159 | "self"
160 | "Self"
161 | "super"
162 | "crate"
163 | "Makefile"
165 | "GNUmakefile"
166 | "Dockerfile"
167 | "Cargo"
168 | "Cargo.toml"
169 | "Cargo.lock"
170 )
171}
172
173#[cfg(test)]
174mod tests {
175 use super::*;
176 use crate::domain::{CodeFact, FactKind, Location};
177
178 fn fact(name: &str) -> CodeFact {
179 CodeFact {
180 location: Location::new("src/lib.rs", 1),
181 kind: FactKind::Function,
182 name: name.to_string(),
183 }
184 }
185
186 #[test]
187 fn flags_missing_symbol_referenced_in_markdown() {
188 let tmp = tempfile::tempdir().unwrap();
189 let md = tmp.path().join("README.md");
190 std::fs::write(&md, "Use `connect_to_db()` to start.\n").unwrap();
191
192 let mut ctx = ProjectContext::new(tmp.path());
193 ctx.markdown_files.push(md.clone());
194 ctx.code_facts.push(fact("init_connection"));
195
196 let divergences = DocsAnalyzer::default().analyze(&ctx);
197 assert_eq!(divergences.len(), 1);
198 assert_eq!(divergences[0].rule, RuleId::SymbolAbsence);
199 assert_eq!(divergences[0].location.line, 1);
200 }
201
202 #[test]
203 fn does_not_flag_symbol_that_exists() {
204 let tmp = tempfile::tempdir().unwrap();
205 let md = tmp.path().join("README.md");
206 std::fs::write(&md, "Use `connect_to_db()` to start.\n").unwrap();
207
208 let mut ctx = ProjectContext::new(tmp.path());
209 ctx.markdown_files.push(md);
210 ctx.code_facts.push(fact("connect_to_db"));
211
212 assert!(DocsAnalyzer::default().analyze(&ctx).is_empty());
213 }
214
215 #[test]
216 fn ignores_language_intrinsics() {
217 let tmp = tempfile::tempdir().unwrap();
218 let md = tmp.path().join("README.md");
219 std::fs::write(&md, "Returns `Option<String>` on failure.\n").unwrap();
220
221 let mut ctx = ProjectContext::new(tmp.path());
222 ctx.markdown_files.push(md);
223
224 assert!(DocsAnalyzer::default().analyze(&ctx).is_empty());
226 }
227
228 #[test]
229 fn ignores_bare_lowercase_words() {
230 let tmp = tempfile::tempdir().unwrap();
233 let md = tmp.path().join("README.md");
234 std::fs::write(&md, "Uses the `ignore` crate and `syn` for parsing.\n").unwrap();
235
236 let mut ctx = ProjectContext::new(tmp.path());
237 ctx.markdown_files.push(md);
238 assert!(DocsAnalyzer::default().analyze(&ctx).is_empty());
239 }
240
241 #[test]
242 fn ignores_all_caps_words() {
243 let tmp = tempfile::tempdir().unwrap();
245 let md = tmp.path().join("README.md");
246 std::fs::write(&md, "See `README` and `HEAD` and `AGENTS`.\n").unwrap();
247
248 let mut ctx = ProjectContext::new(tmp.path());
249 ctx.markdown_files.push(md);
250 assert!(DocsAnalyzer::default().analyze(&ctx).is_empty());
251 }
252
253 #[test]
254 fn resolves_method_path_by_leaf() {
255 let tmp = tempfile::tempdir().unwrap();
256 let md = tmp.path().join("README.md");
257 std::fs::write(&md, "Call `Client::new()` to build.\n").unwrap();
258
259 let mut ctx = ProjectContext::new(tmp.path());
260 ctx.markdown_files.push(md);
261 ctx.code_facts.push(fact("new"));
262
263 assert!(DocsAnalyzer::default().analyze(&ctx).is_empty());
264 }
265}