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