Skip to main content

spec_drift/analyzers/
outdated_logic.rs

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
8/// OutdatedLogicAnalyzer — enforces `outdated_logic` (experimental, Notice).
9///
10/// Strategy: split each Markdown file into sections delimited by H2/H3
11/// headings. For every section whose prose references at least one function
12/// that exists in the codebase, send the section text together with the
13/// function bodies to the LLM and ask whether the description still
14/// accurately describes the behavior.
15///
16/// The rule is experimental. A null or budget-exhausted client silently
17/// skips; the analyzer never emits a divergence it can't back with a verdict.
18pub 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                // Section must mention at least one identifier that actually
54                // exists in the codebase as a function — otherwise there's
55                // nothing concrete to compare against.
56                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(&section.text, &bodies);
65                let Some(verdict) = self.client.evaluate(SYSTEM_PROMPT, &user_prompt) else {
66                    // Fail closed — client is null, budget exhausted, or
67                    // network failed. Never flag on incomplete evidence.
68                    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    /// Pull out every backticked token that looks like a bare Rust identifier.
97    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            // Handle qualified paths: grab the leaf.
105            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
128/// Split Markdown into sections delimited by H2/H3 headings. The section
129/// `text` is the concatenation of every block in the section; `line` is the
130/// source line of the heading (or 1 for the implicit leading section).
131fn 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    // Extract ~50 lines of context around the fact for the prompt. Keeping the
205    // prompt bounded is important for both cost and model attention.
206    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        // Client is probed once (the liveness probe). What we really care
362        // about: no drift emitted when sections don't bind to symbols.
363        assert!(divs.is_empty());
364    }
365}