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