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 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                // Section must mention at least one identifier that actually
51                // exists in the codebase as a function — otherwise there's
52                // nothing concrete to compare against.
53                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(&section.text, &bodies);
62                let Some(verdict) = self.client.evaluate(SYSTEM_PROMPT, &user_prompt) else {
63                    // Fail closed — client is null, budget exhausted, or
64                    // network failed. Never flag on incomplete evidence.
65                    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    /// Pull out every backticked token that looks like a bare Rust identifier.
95    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            // Handle qualified paths: grab the leaf.
103            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
126/// Split Markdown into sections delimited by H2/H3 headings. The section
127/// `text` is the concatenation of every block in the section; `line` is the
128/// source line of the heading (or 1 for the implicit leading section).
129fn 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    // Extract ~50 lines of context around the fact for the prompt. Keeping the
203    // prompt bounded is important for both cost and model attention.
204    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        // Client is probed once (the liveness probe). What we really care
355        // about: no drift emitted when sections don't bind to symbols.
356        assert!(divs.is_empty());
357    }
358}