Skip to main content

ought_analysis/
survey.rs

1use std::collections::HashSet;
2use std::path::PathBuf;
3
4use ought_spec::{Keyword, Section, SpecGraph};
5
6use crate::types::{SurveyResult, UncoveredBehavior};
7
8/// Discover behaviors in source code not covered by any spec clause.
9///
10/// Reads source files, reads all specs, and compares public function/method
11/// signatures against existing clause texts to find uncovered behaviors.
12pub fn survey(
13    specs: &SpecGraph,
14    paths: &[PathBuf],
15) -> anyhow::Result<SurveyResult> {
16    // 1. Collect all existing clause texts (lowercased) so we can check coverage.
17    let mut covered_texts: HashSet<String> = HashSet::new();
18    let mut spec_source_roots: Vec<PathBuf> = Vec::new();
19
20    for spec in specs.specs() {
21        collect_clause_texts(&spec.sections, &mut covered_texts);
22        // Collect source roots from spec metadata for fallback path discovery.
23        for src in &spec.metadata.sources {
24            let base = spec
25                .source_path
26                .parent()
27                .unwrap_or(std::path::Path::new("."));
28            spec_source_roots.push(base.join(src));
29        }
30    }
31
32    // 2. Determine which paths to scan.
33    let scan_paths: Vec<PathBuf> = if paths.is_empty() {
34        spec_source_roots
35    } else {
36        paths.to_vec()
37    };
38
39    // 3. Walk paths and read source files, extracting public function signatures.
40    let mut uncovered: Vec<UncoveredBehavior> = Vec::new();
41
42    for path in &scan_paths {
43        if path.is_file() {
44            if let Ok(content) = std::fs::read_to_string(path) {
45                extract_uncovered_from_file(path, &content, &covered_texts, specs, &mut uncovered);
46            }
47        } else if path.is_dir() {
48            walk_source_dir(path, &covered_texts, specs, &mut uncovered);
49        }
50    }
51
52    // 4. Sort: public API behaviors first (MUST keyword), then helpers (SHOULD).
53    uncovered.sort_by(|a, b| {
54        let a_pub = a.description.contains("public") || a.suggested_keyword == Keyword::Must;
55        let b_pub = b.description.contains("public") || b.suggested_keyword == Keyword::Must;
56        b_pub.cmp(&a_pub)
57    });
58
59    // 5. Group by suggested_spec so that behaviors for the same file are adjacent.
60    uncovered.sort_by(|a, b| a.suggested_spec.cmp(&b.suggested_spec));
61
62    // Re-apply risk ranking within each group.
63    let mut grouped: Vec<UncoveredBehavior> = Vec::new();
64    let mut current_spec: Option<PathBuf> = None;
65    let mut current_group: Vec<UncoveredBehavior> = Vec::new();
66
67    for item in uncovered {
68        if current_spec.as_ref() != Some(&item.suggested_spec) {
69            // Flush previous group, sorted by risk.
70            current_group.sort_by(|a, b| {
71                let a_pub =
72                    a.description.contains("public") || a.suggested_keyword == Keyword::Must;
73                let b_pub =
74                    b.description.contains("public") || b.suggested_keyword == Keyword::Must;
75                b_pub.cmp(&a_pub)
76            });
77            grouped.append(&mut current_group);
78            current_spec = Some(item.suggested_spec.clone());
79        }
80        current_group.push(item);
81    }
82    // Flush last group.
83    current_group.sort_by(|a, b| {
84        let a_pub = a.description.contains("public") || a.suggested_keyword == Keyword::Must;
85        let b_pub = b.description.contains("public") || b.suggested_keyword == Keyword::Must;
86        b_pub.cmp(&a_pub)
87    });
88    grouped.append(&mut current_group);
89
90    Ok(SurveyResult {
91        uncovered: grouped,
92    })
93}
94
95/// Recursively collect clause texts from sections (lowercased for matching).
96fn collect_clause_texts(sections: &[Section], texts: &mut HashSet<String>) {
97    for section in sections {
98        for clause in &section.clauses {
99            texts.insert(clause.text.to_lowercase());
100            // Also collect otherwise clause texts.
101            for ow in &clause.otherwise {
102                texts.insert(ow.text.to_lowercase());
103            }
104        }
105        collect_clause_texts(&section.subsections, texts);
106    }
107}
108
109/// Walk a directory recursively and process source files.
110fn walk_source_dir(
111    dir: &std::path::Path,
112    covered_texts: &HashSet<String>,
113    specs: &SpecGraph,
114    uncovered: &mut Vec<UncoveredBehavior>,
115) {
116    let entries = match std::fs::read_dir(dir) {
117        Ok(e) => e,
118        Err(_) => return,
119    };
120    for entry in entries.flatten() {
121        let path = entry.path();
122        if path.is_dir() {
123            // Skip common non-source directories.
124            if let Some(name) = path.file_name().and_then(|n| n.to_str())
125                && matches!(
126                    name,
127                    "target"
128                        | "node_modules"
129                        | ".git"
130                        | "__pycache__"
131                        | "vendor"
132                        | ".venv"
133                        | "venv"
134                ) {
135                    continue;
136                }
137            walk_source_dir(&path, covered_texts, specs, uncovered);
138        } else if is_source_file(&path)
139            && let Ok(content) = std::fs::read_to_string(&path) {
140                extract_uncovered_from_file(&path, &content, covered_texts, specs, uncovered);
141            }
142    }
143}
144
145/// Check if a file looks like source code.
146fn is_source_file(path: &std::path::Path) -> bool {
147    let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
148    matches!(
149        ext,
150        "rs" | "py" | "ts" | "tsx" | "js" | "jsx" | "go" | "java" | "rb" | "kt" | "swift" | "c"
151            | "cpp" | "h" | "hpp"
152    )
153}
154
155/// Extract public function signatures from a source file and check coverage.
156fn extract_uncovered_from_file(
157    file: &std::path::Path,
158    content: &str,
159    covered_texts: &HashSet<String>,
160    specs: &SpecGraph,
161    uncovered: &mut Vec<UncoveredBehavior>,
162) {
163    let suggested_spec = infer_spec_path(file, specs);
164
165    for (line_num_0, line) in content.lines().enumerate() {
166        let trimmed = line.trim();
167        if let Some(fn_name) = extract_public_fn_name(trimmed) {
168            // Check if any clause text mentions this function name.
169            let fn_lower = fn_name.to_lowercase();
170            let fn_words = fn_lower.replace('_', " ");
171            let is_covered = covered_texts
172                .iter()
173                .any(|text| text.contains(&fn_lower) || text.contains(&fn_words));
174
175            if !is_covered {
176                let is_public = trimmed.starts_with("pub ")
177                    || trimmed.starts_with("export ")
178                    || trimmed.starts_with("def ")
179                    || trimmed.starts_with("func ");
180                let (keyword, desc_prefix) = if is_public {
181                    (Keyword::Must, "public")
182                } else {
183                    (Keyword::Should, "private")
184                };
185
186                uncovered.push(UncoveredBehavior {
187                    file: file.to_path_buf(),
188                    line: line_num_0 + 1,
189                    description: format!(
190                        "{} function `{}` has no corresponding spec clause",
191                        desc_prefix, fn_name
192                    ),
193                    suggested_clause: format!(
194                        "{} handle {} correctly",
195                        if keyword == Keyword::Must {
196                            "MUST"
197                        } else {
198                            "SHOULD"
199                        },
200                        fn_name.replace('_', " ")
201                    ),
202                    suggested_keyword: keyword,
203                    suggested_spec: suggested_spec.clone(),
204                });
205            }
206        }
207    }
208}
209
210/// Extract a function name from a line if it declares a public function/method.
211fn extract_public_fn_name(line: &str) -> Option<String> {
212    // Rust: pub fn name(...) or pub async fn name(...)
213    if let Some(rest) = line
214        .strip_prefix("pub fn ")
215        .or_else(|| line.strip_prefix("pub async fn "))
216        .or_else(|| line.strip_prefix("pub(crate) fn "))
217        .or_else(|| line.strip_prefix("pub(super) fn "))
218    {
219        return extract_ident(rest);
220    }
221    // Also match `fn ` for private Rust functions (but we'll mark them differently).
222    if line.starts_with("fn ")
223        && let Some(rest) = line.strip_prefix("fn ") {
224            return extract_ident(rest);
225        }
226    // Python: def name(
227    if let Some(rest) = line.strip_prefix("def ") {
228        let name = extract_ident(rest);
229        // Skip dunder methods
230        if let Some(ref n) = name
231            && n.starts_with("__") && n.ends_with("__") {
232                return None;
233            }
234        return name;
235    }
236    // TypeScript/JavaScript: export function name, function name, export const name
237    if let Some(rest) = line
238        .strip_prefix("export function ")
239        .or_else(|| line.strip_prefix("export async function "))
240        .or_else(|| line.strip_prefix("function "))
241        .or_else(|| line.strip_prefix("async function "))
242    {
243        return extract_ident(rest);
244    }
245    // Go: func name(
246    if let Some(rest) = line.strip_prefix("func ") {
247        // Skip method receivers: func (r *Type) Name(...)
248        if rest.starts_with('(') {
249            // Method: find closing paren then extract name
250            if let Some(idx) = rest.find(')') {
251                let after = rest[idx + 1..].trim();
252                return extract_ident(after);
253            }
254            return None;
255        }
256        return extract_ident(rest);
257    }
258    None
259}
260
261/// Extract an identifier (letters, digits, underscores) from the start of a string.
262fn extract_ident(s: &str) -> Option<String> {
263    let s = s.trim();
264    let end = s
265        .find(|c: char| !c.is_alphanumeric() && c != '_')
266        .unwrap_or(s.len());
267    if end == 0 {
268        return None;
269    }
270    Some(s[..end].to_string())
271}
272
273/// Infer which spec file a source file would belong to.
274fn infer_spec_path(source_file: &std::path::Path, specs: &SpecGraph) -> PathBuf {
275    // Try to match by source metadata in specs.
276    let source_str = source_file.to_string_lossy();
277    for spec in specs.specs() {
278        for src in &spec.metadata.sources {
279            if source_str.contains(src) || source_str.contains(&src.replace("./", "")) {
280                return spec.source_path.clone();
281            }
282        }
283    }
284    // Fallback: derive from the source file name.
285    let stem = source_file
286        .file_stem()
287        .and_then(|s| s.to_str())
288        .unwrap_or("unknown");
289    PathBuf::from(format!("ought/{}.ought.md", stem))
290}