Skip to main content

source_map_php/
tests_linker.rs

1use std::collections::HashMap;
2use std::fs;
3use std::path::Path;
4
5use anyhow::Result;
6use regex::Regex;
7
8use crate::models::{RouteDoc, SymbolDoc, TestDoc, make_stable_id};
9use crate::{Framework, config::TestsConfig};
10
11pub fn extract_tests(
12    _repo: &Path,
13    repo_name: &str,
14    framework: Framework,
15    files: &[crate::scanner::ScannedFile],
16) -> Result<Vec<TestDoc>> {
17    let covers_class_re = Regex::new(r#"#\[CoversClass\(([^)]+)::class\)\]"#).unwrap();
18    let covers_method_re =
19        Regex::new(r#"#\[CoversMethod\(([^)]+)::class,\s*['"]([A-Za-z0-9_]+)['"]\)\]"#).unwrap();
20    let covers_doc_re = Regex::new(r#"@covers\s+\\?([A-Za-z0-9_\\:]+)"#).unwrap();
21    let route_call_re = Regex::new(
22        r#"(?:->|::)(?:get|post|put|patch|delete|json)\(\s*(?:['"][A-Z]+['"]\s*,\s*)?['"]([^'"]+)['"]"#,
23    )
24    .unwrap();
25    let test_name_re = Regex::new(r#"function\s+([A-Za-z0-9_]+)\s*\("#).unwrap();
26
27    let mut docs = Vec::new();
28    for file in files.iter().filter(|file| {
29        file.relative_path.starts_with("tests") || file.relative_path.starts_with("test")
30    }) {
31        let contents = fs::read_to_string(&file.absolute_path)?;
32        let fqn = test_name_re
33            .captures(&contents)
34            .and_then(|caps| caps.get(1))
35            .map(|item| item.as_str().to_string())
36            .unwrap_or_else(|| {
37                file.relative_path
38                    .file_stem()
39                    .and_then(|value| value.to_str())
40                    .unwrap_or("UnknownTest")
41                    .to_string()
42            });
43        let covered_symbols = covers_class_re
44            .captures_iter(&contents)
45            .filter_map(|caps| caps.get(1).map(|item| item.as_str().to_string()))
46            .chain(
47                covers_method_re
48                    .captures_iter(&contents)
49                    .filter_map(|caps| {
50                        Some(format!(
51                            "{}::{}",
52                            caps.get(1)?.as_str(),
53                            caps.get(2)?.as_str()
54                        ))
55                    }),
56            )
57            .chain(
58                covers_doc_re
59                    .captures_iter(&contents)
60                    .filter_map(|caps| caps.get(1).map(|item| item.as_str().to_string())),
61            )
62            .collect::<Vec<_>>();
63        let routes_called = route_call_re
64            .captures_iter(&contents)
65            .filter_map(|caps| caps.get(1).map(|item| item.as_str().to_string()))
66            .collect::<Vec<_>>();
67        let command = match framework {
68            Framework::Hyperf => format!("vendor/bin/co-phpunit --filter {fqn}"),
69            _ => format!("php artisan test --filter {fqn}"),
70        };
71        let path = file.relative_path.to_string_lossy().into_owned();
72        docs.push(TestDoc {
73            id: make_stable_id(&[repo_name, &path, &fqn]),
74            repo: repo_name.to_string(),
75            framework: framework.as_str().to_string(),
76            fqn,
77            path,
78            line_start: 1,
79            covered_symbols,
80            referenced_symbols: Vec::new(),
81            routes_called,
82            command,
83            confidence: 0.0,
84        });
85    }
86    Ok(docs)
87}
88
89pub fn link_symbols_and_routes(
90    symbols: &mut [SymbolDoc],
91    routes: &mut [RouteDoc],
92    tests: &mut [TestDoc],
93    test_config: &TestsConfig,
94) {
95    let mut test_scores: HashMap<String, Vec<(usize, f32)>> = HashMap::new();
96
97    for (test_index, test) in tests.iter_mut().enumerate() {
98        let mut best = 0.0f32;
99        for symbol in symbols.iter() {
100            let score = score_test_against_symbol(test, symbol);
101            if score > 0.0 {
102                test_scores
103                    .entry(symbol.id.clone())
104                    .or_default()
105                    .push((test_index, score));
106                if score > best {
107                    best = score;
108                }
109            }
110        }
111        test.confidence = best;
112    }
113
114    for symbol in symbols {
115        let Some(scored_tests) = test_scores.get(&symbol.id) else {
116            symbol.missing_test_warning = Some(format!(
117                "No related test with confidence >= {:.2} was found.",
118                test_config.validate_threshold
119            ));
120            continue;
121        };
122
123        let mut scored = scored_tests.clone();
124        scored.sort_by(|left, right| right.1.partial_cmp(&left.1).unwrap());
125        symbol.related_tests = scored
126            .iter()
127            .map(|(index, _)| tests[*index].fqn.clone())
128            .collect();
129        symbol.related_tests_count = symbol.related_tests.len() as u32;
130        symbol.validation_commands = scored
131            .iter()
132            .filter(|(_, score)| *score >= test_config.validate_threshold)
133            .map(|(index, _)| tests[*index].command.clone())
134            .collect();
135        if symbol.validation_commands.is_empty() {
136            symbol.missing_test_warning = Some(format!(
137                "No related test with confidence >= {:.2} was found.",
138                test_config.validate_threshold
139            ));
140        }
141    }
142
143    for route in routes {
144        route.related_tests = tests
145            .iter()
146            .filter(|test| test.routes_called.iter().any(|called| called == &route.uri))
147            .map(|test| test.fqn.clone())
148            .collect();
149    }
150}
151
152fn score_test_against_symbol(test: &TestDoc, symbol: &SymbolDoc) -> f32 {
153    if test
154        .covered_symbols
155        .iter()
156        .any(|covered| covered == &symbol.fqn)
157    {
158        return 0.95;
159    }
160    if test
161        .covered_symbols
162        .iter()
163        .any(|covered| covered.ends_with(&symbol.short_name))
164    {
165        return 0.8;
166    }
167    if test
168        .routes_called
169        .iter()
170        .any(|called| symbol.path.contains(called.trim_matches('/')))
171    {
172        return 0.65;
173    }
174    0.0
175}
176
177#[cfg(test)]
178mod tests {
179    use crate::config::TestsConfig;
180    use crate::models::{SymbolDoc, TestDoc};
181
182    use super::link_symbols_and_routes;
183
184    fn symbol() -> SymbolDoc {
185        SymbolDoc {
186            id: "symbol".to_string(),
187            stable_key: "symbol".to_string(),
188            repo: "repo".to_string(),
189            framework: "laravel".to_string(),
190            kind: "method".to_string(),
191            short_name: "sign".to_string(),
192            fqn: "App\\Services\\ConsentService::sign".to_string(),
193            owner_class: Some("App\\Services\\ConsentService".to_string()),
194            namespace: Some("App\\Services".to_string()),
195            signature: None,
196            doc_summary: None,
197            doc_description: None,
198            param_docs: Vec::new(),
199            return_doc: None,
200            throws_docs: Vec::new(),
201            magic_methods: Vec::new(),
202            magic_properties: Vec::new(),
203            inline_rule_comments: Vec::new(),
204            comment_keywords: Vec::new(),
205            symbol_tokens: Vec::new(),
206            framework_tags: Vec::new(),
207            risk_tags: Vec::new(),
208            route_ids: Vec::new(),
209            related_symbols: Vec::new(),
210            related_tests: Vec::new(),
211            related_tests_count: 0,
212            references_count: 0,
213            validation_commands: Vec::new(),
214            missing_test_warning: None,
215            package_name: "root/app".to_string(),
216            package_type: None,
217            package_version: None,
218            package_keywords: Vec::new(),
219            is_vendor: false,
220            is_project_code: true,
221            is_test: false,
222            autoloadable: true,
223            extraction_confidence: "fallback".to_string(),
224            path: "app/Services/ConsentService.php".to_string(),
225            absolute_path: "/repo/app/Services/ConsentService.php".to_string(),
226            line_start: 10,
227            line_end: 20,
228        }
229    }
230
231    #[test]
232    fn adds_validation_commands_for_high_confidence_tests() {
233        let mut symbols = vec![symbol()];
234        let mut tests = vec![TestDoc {
235            id: "test".to_string(),
236            repo: "repo".to_string(),
237            framework: "laravel".to_string(),
238            fqn: "PatientConsentTest".to_string(),
239            path: "tests/Feature/PatientConsentTest.php".to_string(),
240            line_start: 1,
241            covered_symbols: vec!["App\\Services\\ConsentService::sign".to_string()],
242            referenced_symbols: Vec::new(),
243            routes_called: Vec::new(),
244            command: "php artisan test --filter PatientConsentTest".to_string(),
245            confidence: 0.0,
246        }];
247
248        link_symbols_and_routes(&mut symbols, &mut [], &mut tests, &TestsConfig::default());
249
250        assert_eq!(
251            symbols[0].validation_commands,
252            vec!["php artisan test --filter PatientConsentTest"]
253        );
254        assert!(symbols[0].missing_test_warning.is_none());
255    }
256}