1use super::DriftAnalyzer;
2use crate::context::ProjectContext;
3use crate::domain::{Divergence, FactKind, Location, RuleId, Severity};
4use crate::llm::LlmClient;
5use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd};
6use std::sync::Arc;
7
8pub struct OutdatedLogicAnalyzer {
19 client: Arc<dyn LlmClient>,
20}
21
22impl OutdatedLogicAnalyzer {
23 pub fn new(client: Arc<dyn LlmClient>) -> Self {
24 Self { client }
25 }
26}
27
28const SYSTEM_PROMPT: &str = "You are auditing technical documentation for drift from source code. \
29You receive (1) a Markdown section describing intended behavior, and (2) one or more Rust function \
30bodies that implement the behavior. Decide whether the section is still accurate. \
31Reply ONLY with a JSON object of the shape \
32`{\"match_spec\": <bool>, \"reason\": \"<short explanation>\"}`. No prose before or after.";
33
34impl DriftAnalyzer for OutdatedLogicAnalyzer {
35 fn id(&self) -> &'static str {
36 "outdated_logic"
37 }
38
39 fn analyze(&self, ctx: &ProjectContext) -> Vec<Divergence> {
40 let mut out = Vec::new();
41
42 for md in &ctx.markdown_files {
43 let Ok(source) = std::fs::read_to_string(md) else {
44 continue;
45 };
46 let sections = split_sections(&source);
47
48 for section in sections {
49 let mentioned: Vec<String> = section.mentioned_identifiers();
50 let bodies: Vec<(String, String)> = mentioned
54 .iter()
55 .filter_map(|name| fetch_fn_body(ctx, name).map(|b| (name.clone(), b)))
56 .collect();
57 if bodies.is_empty() {
58 continue;
59 }
60
61 let user_prompt = render_user_prompt(§ion.text, &bodies);
62 let Some(verdict) = self.client.evaluate(SYSTEM_PROMPT, &user_prompt) else {
63 continue;
66 };
67 if verdict.match_spec {
68 continue;
69 }
70
71 out.push(Divergence {
72 rule: RuleId::OutdatedLogic,
73 severity: Severity::Notice,
74 location: Location::new(md.clone(), section.line),
75 stated: "section describes current behavior".into(),
76 reality: verdict.reason,
77 risk: "Docs teach behavior the code no longer implements.".into(),
78 attribution: None,
79
80 });
81 }
82 }
83
84 out
85 }
86}
87
88struct Section {
89 text: String,
90 line: u32,
91}
92
93impl Section {
94 fn mentioned_identifiers(&self) -> Vec<String> {
96 let mut out = Vec::new();
97 for part in self.text.split('`') {
98 let trimmed = part.trim().trim_end_matches("()");
99 if is_ident(trimmed) {
100 out.push(trimmed.to_string());
101 }
102 if let Some(leaf) = trimmed.rsplit("::").next()
104 && is_ident(leaf)
105 && leaf != trimmed
106 {
107 out.push(leaf.to_string());
108 }
109 }
110 out.sort();
111 out.dedup();
112 out
113 }
114}
115
116fn is_ident(s: &str) -> bool {
117 if s.is_empty() {
118 return false;
119 }
120 let mut chars = s.chars();
121 let first = chars.next().unwrap();
122 (first.is_ascii_alphabetic() || first == '_')
123 && chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
124}
125
126fn split_sections(source: &str) -> Vec<Section> {
130 let mut sections: Vec<Section> = Vec::new();
131 let mut current = Section {
132 text: String::new(),
133 line: 1,
134 };
135
136 let parser = Parser::new_ext(source, Options::all()).into_offset_iter();
137 let mut in_heading = false;
138 let mut heading_level = 0u8;
139
140 for (event, range) in parser {
141 match event {
142 Event::Start(Tag::Heading { level, .. }) => {
143 let lvl = match level {
144 pulldown_cmark::HeadingLevel::H2 => 2,
145 pulldown_cmark::HeadingLevel::H3 => 3,
146 _ => 0,
147 };
148 if lvl == 2 || lvl == 3 {
149 if !current.text.trim().is_empty() {
150 sections.push(std::mem::replace(
151 &mut current,
152 Section {
153 text: String::new(),
154 line: line_of_offset(source, range.start),
155 },
156 ));
157 } else {
158 current.line = line_of_offset(source, range.start);
159 }
160 in_heading = true;
161 heading_level = lvl;
162 }
163 }
164 Event::End(TagEnd::Heading(_)) => {
165 in_heading = false;
166 heading_level = 0;
167 }
168 Event::Text(t) => {
169 if in_heading && (heading_level == 2 || heading_level == 3) {
170 current.text.push_str(&t);
171 current.text.push('\n');
172 } else if !in_heading {
173 current.text.push_str(&t);
174 current.text.push(' ');
175 }
176 }
177 Event::Code(t) => {
178 current.text.push('`');
179 current.text.push_str(&t);
180 current.text.push('`');
181 current.text.push(' ');
182 }
183 _ => {}
184 }
185 }
186 if !current.text.trim().is_empty() {
187 sections.push(current);
188 }
189 sections
190}
191
192fn line_of_offset(src: &str, offset: usize) -> u32 {
193 let end = offset.min(src.len());
194 (src[..end].bytes().filter(|&b| b == b'\n').count() as u32) + 1
195}
196
197fn fetch_fn_body(ctx: &ProjectContext, name: &str) -> Option<String> {
198 let fact = ctx
199 .facts_named(name)
200 .find(|f| matches!(f.kind, FactKind::Function))?;
201 let src = std::fs::read_to_string(&fact.location.file).ok()?;
202 const WINDOW: usize = 50;
205 let lines: Vec<&str> = src.lines().collect();
206 let start = fact.location.line.saturating_sub(1) as usize;
207 let end = (start + WINDOW).min(lines.len());
208 Some(lines[start..end].join("\n"))
209}
210
211fn render_user_prompt(section: &str, bodies: &[(String, String)]) -> String {
212 let mut prompt = String::new();
213 prompt.push_str("## Documentation section\n");
214 prompt.push_str(section.trim());
215 prompt.push_str("\n\n## Current code\n");
216 for (name, body) in bodies {
217 prompt.push_str(&format!("### fn {name}\n```rust\n{body}\n```\n"));
218 }
219 prompt.push_str("\nRespond with the JSON object described in the system prompt.");
220 prompt
221}
222
223#[cfg(test)]
224mod tests {
225 use super::*;
226 use crate::domain::{CodeFact, Location};
227 use crate::llm::LlmVerdict;
228 use std::sync::atomic::{AtomicBool, Ordering};
229
230 struct FakeClient {
231 verdict: LlmVerdict,
232 was_called: AtomicBool,
233 }
234
235 impl LlmClient for FakeClient {
236 fn evaluate(&self, _: &str, _: &str) -> Option<LlmVerdict> {
237 self.was_called.store(true, Ordering::Release);
238 Some(self.verdict.clone())
239 }
240 }
241
242 struct BlankClient;
243 impl LlmClient for BlankClient {
244 fn evaluate(&self, _: &str, _: &str) -> Option<LlmVerdict> {
245 None
246 }
247 }
248
249 fn fn_fact(name: &str, file: &std::path::Path, line: u32) -> CodeFact {
250 CodeFact {
251 location: Location::new(file, line),
252 kind: FactKind::Function,
253 name: name.to_string(),
254 }
255 }
256
257 #[test]
258 fn flags_when_model_says_drift() {
259 let tmp = tempfile::tempdir().unwrap();
260 let md = tmp.path().join("README.md");
261 std::fs::write(
262 &md,
263 "## Usage\n\nCall `start()` to begin the flow.\n",
264 )
265 .unwrap();
266 let rs = tmp.path().join("lib.rs");
267 std::fs::write(&rs, "pub fn start() { /* old impl */ }\n").unwrap();
268
269 let mut ctx = ProjectContext::new(tmp.path());
270 ctx.markdown_files.push(md.clone());
271 ctx.rust_files.push(rs.clone());
272 ctx.code_facts.push(fn_fact("start", &rs, 1));
273
274 let client = Arc::new(FakeClient {
275 verdict: LlmVerdict {
276 match_spec: false,
277 reason: "start() no longer takes the happy path".into(),
278 },
279 was_called: AtomicBool::new(false),
280 });
281 let divs = OutdatedLogicAnalyzer::new(client.clone()).analyze(&ctx);
282
283 assert!(client.was_called.load(Ordering::Acquire));
284 assert_eq!(divs.len(), 1);
285 assert_eq!(divs[0].rule, RuleId::OutdatedLogic);
286 assert_eq!(divs[0].severity, Severity::Notice);
287 assert!(divs[0].reality.contains("no longer"));
288 }
289
290 #[test]
291 fn silent_when_model_says_match() {
292 let tmp = tempfile::tempdir().unwrap();
293 let md = tmp.path().join("README.md");
294 std::fs::write(&md, "## Usage\n\nCall `go()` to run.\n").unwrap();
295 let rs = tmp.path().join("lib.rs");
296 std::fs::write(&rs, "pub fn go() {}\n").unwrap();
297
298 let mut ctx = ProjectContext::new(tmp.path());
299 ctx.markdown_files.push(md);
300 ctx.rust_files.push(rs.clone());
301 ctx.code_facts.push(fn_fact("go", &rs, 1));
302
303 let client = Arc::new(FakeClient {
304 verdict: LlmVerdict {
305 match_spec: true,
306 reason: "matches".into(),
307 },
308 was_called: AtomicBool::new(false),
309 });
310 assert!(OutdatedLogicAnalyzer::new(client).analyze(&ctx).is_empty());
311 }
312
313 #[test]
314 fn silent_when_client_is_null() {
315 let tmp = tempfile::tempdir().unwrap();
316 let md = tmp.path().join("README.md");
317 std::fs::write(&md, "## Usage\n\nCall `go()` to run.\n").unwrap();
318 let rs = tmp.path().join("lib.rs");
319 std::fs::write(&rs, "pub fn go() {}\n").unwrap();
320
321 let mut ctx = ProjectContext::new(tmp.path());
322 ctx.markdown_files.push(md);
323 ctx.rust_files.push(rs.clone());
324 ctx.code_facts.push(fn_fact("go", &rs, 1));
325
326 assert!(
327 OutdatedLogicAnalyzer::new(Arc::new(BlankClient))
328 .analyze(&ctx)
329 .is_empty()
330 );
331 }
332
333 #[test]
334 fn skips_sections_with_no_resolvable_symbols() {
335 let tmp = tempfile::tempdir().unwrap();
336 let md = tmp.path().join("README.md");
337 std::fs::write(
338 &md,
339 "## Overview\n\nA short philosophical paragraph with no symbols.\n",
340 )
341 .unwrap();
342
343 let mut ctx = ProjectContext::new(tmp.path());
344 ctx.markdown_files.push(md);
345
346 let client = Arc::new(FakeClient {
347 verdict: LlmVerdict {
348 match_spec: false,
349 reason: "would fire".into(),
350 },
351 was_called: AtomicBool::new(false),
352 });
353 let divs = OutdatedLogicAnalyzer::new(client.clone()).analyze(&ctx);
354 assert!(divs.is_empty());
357 }
358}