Skip to main content

brokk_bifrost_python/
test_detection.rs

1use crate::declarations::py_node_text;
2use brokk_bifrost_core::analyzer::ProjectFile;
3use brokk_bifrost_core::analyzer::model::{TestAssertionSmell, TestAssertionWeights};
4use brokk_bifrost_core::analyzer::test_assertions::{
5    CommonTestAssertionKind, TestAssertionSignal, append_test_assertion_findings,
6    classify_common_test_assertion, compact_assertion_excerpt, is_common_assertion_literal,
7    normalize_assertion_expression, oversized_quoted_literal,
8};
9use regex::Regex;
10use std::sync::LazyLock;
11use tree_sitter::{Node, Parser};
12
13const TEST_ASSERTION_EXCERPT_MAX_LEN: usize = 180;
14
15static PY_ASSERT_EQUALITY_RE: LazyLock<Regex> = LazyLock::new(|| {
16    Regex::new(r#"self\.assert(?:Equal|Is)\s*\((?P<left>[^,\n]+?)\s*,\s*(?P<right>[^,\n\)]+)"#)
17        .expect("valid regex")
18});
19static PY_ASSERT_TRUTH_RE: LazyLock<Regex> = LazyLock::new(|| {
20    Regex::new(r#"self\.assert(?P<matcher>True|False|IsNone|IsNotNone)\s*\((?P<arg>[^,\n\)]+)"#)
21        .expect("valid regex")
22});
23static PY_BARE_ASSERT_RE: LazyLock<Regex> =
24    LazyLock::new(|| Regex::new(r#"(?m)^\s*assert\s+(?P<expr>[^\n]+)"#).expect("valid regex"));
25static PY_RAISES_RE: LazyLock<Regex> = LazyLock::new(|| {
26    Regex::new(r#"pytest\.raises\s*\(|self\.assertRaises\s*\("#).expect("valid regex")
27});
28static PY_VERIFY_RE: LazyLock<Regex> = LazyLock::new(|| {
29    Regex::new(r#"\.\s*assert_(?:called|called_once|called_once_with|called_with|not_called)\s*\("#)
30        .expect("valid regex")
31});
32
33#[derive(Clone)]
34struct PythonTestCase {
35    name: String,
36    body: String,
37    start_byte: usize,
38}
39
40pub fn detect_python_test_assertion_smells(
41    file: &ProjectFile,
42    source: &str,
43    weights: &TestAssertionWeights,
44) -> Vec<TestAssertionSmell> {
45    let mut parser = Parser::new();
46    parser
47        .set_language(&tree_sitter_python::LANGUAGE.into())
48        .expect("failed to load python parser");
49    let Some(tree) = parser.parse(source, None) else {
50        return Vec::new();
51    };
52
53    let mut test_cases = Vec::new();
54    collect_python_test_cases(tree.root_node(), source, &mut test_cases);
55
56    let mut findings = Vec::new();
57    for test_case in test_cases {
58        analyze_python_test_case(file, &test_case, weights, &mut findings);
59    }
60    findings
61}
62
63fn collect_python_test_cases(node: Node<'_>, source: &str, out: &mut Vec<PythonTestCase>) {
64    match node.kind() {
65        "function_definition" => {
66            if python_function_has_fixture_decorator(node, source) {
67                return;
68            }
69            if let Some(name_node) = node.child_by_field_name("name") {
70                let name = py_node_text(name_node, source).trim().to_string();
71                if name.starts_with("test_") {
72                    let body = node
73                        .child_by_field_name("body")
74                        .map(|body| py_node_text(body, source).to_string())
75                        .unwrap_or_else(|| py_node_text(node, source).to_string());
76                    out.push(PythonTestCase {
77                        name,
78                        body,
79                        start_byte: node.start_byte(),
80                    });
81                }
82            }
83        }
84        "decorated_definition" => {
85            if python_decorated_definition_has_fixture(node, source) {
86                return;
87            }
88            let has_pytest_mark = py_node_text(node, source).contains("pytest.mark");
89            if has_pytest_mark
90                && let Some(definition) = node.child_by_field_name("definition")
91                && definition.kind() == "function_definition"
92            {
93                let name = definition
94                    .child_by_field_name("name")
95                    .map(|name_node| py_node_text(name_node, source).trim().to_string())
96                    .unwrap_or_else(|| "anonymous".to_string());
97                let body = definition
98                    .child_by_field_name("body")
99                    .map(|body| py_node_text(body, source).to_string())
100                    .unwrap_or_else(|| py_node_text(definition, source).to_string());
101                out.push(PythonTestCase {
102                    name,
103                    body,
104                    start_byte: node.start_byte(),
105                });
106            }
107        }
108        _ => {}
109    }
110
111    let mut cursor = node.walk();
112    for child in node.named_children(&mut cursor) {
113        collect_python_test_cases(child, source, out);
114    }
115}
116
117fn python_function_has_fixture_decorator(function: Node<'_>, source: &str) -> bool {
118    let Some(parent) = function.parent() else {
119        return false;
120    };
121    parent.kind() == "decorated_definition"
122        && python_decorated_definition_has_fixture(parent, source)
123}
124
125fn python_decorated_definition_has_fixture(node: Node<'_>, source: &str) -> bool {
126    py_node_text(node, source).contains("@pytest.fixture")
127}
128
129fn analyze_python_test_case(
130    file: &ProjectFile,
131    test_case: &PythonTestCase,
132    weights: &TestAssertionWeights,
133    out: &mut Vec<TestAssertionSmell>,
134) {
135    let mut assertions = collect_python_assertions(&test_case.body, weights);
136    for assertion in &mut assertions {
137        assertion.start_byte = test_case.start_byte.saturating_add(assertion.start_byte);
138    }
139    let symbol = format!("{}::{}", file, test_case.name);
140    append_test_assertion_findings(
141        file,
142        symbol,
143        compact_assertion_excerpt(&test_case.body, TEST_ASSERTION_EXCERPT_MAX_LEN),
144        test_case.start_byte,
145        &assertions,
146        weights,
147        out,
148    );
149}
150
151fn collect_python_assertions(
152    body: &str,
153    weights: &TestAssertionWeights,
154) -> Vec<TestAssertionSignal> {
155    let mut assertions = Vec::new();
156
157    for captures in PY_ASSERT_EQUALITY_RE.captures_iter(body) {
158        let whole = captures.get(0).expect("whole match");
159        let left = normalize_python_expr(captures.name("left").map(|m| m.as_str()).unwrap_or(""));
160        let right = normalize_python_expr(captures.name("right").map(|m| m.as_str()).unwrap_or(""));
161        if left.is_empty() || right.is_empty() {
162            continue;
163        }
164        if left == right {
165            let classification = classify_common_test_assertion(
166                if is_python_literal(&left) {
167                    CommonTestAssertionKind::ConstantEquality
168                } else {
169                    CommonTestAssertionKind::SelfComparison
170                },
171                weights,
172            );
173            assertions.push(TestAssertionSignal {
174                kind: classification.kind.to_string(),
175                score: classification.score,
176                shallow: classification.shallow,
177                meaningful: false,
178                reasons: vec![classification.kind.to_string()],
179                excerpt: compact_assertion_excerpt(whole.as_str(), TEST_ASSERTION_EXCERPT_MAX_LEN),
180                start_byte: whole.start(),
181            });
182        } else if let Some(literal) = oversized_python_literal(&left, &right, weights) {
183            assertions.push(TestAssertionSignal {
184                kind: "overspecified-literal".to_string(),
185                score: weights.overspecified_literal_weight,
186                shallow: false,
187                meaningful: false,
188                reasons: vec![format!("overspecified-literal:{literal}")],
189                excerpt: compact_assertion_excerpt(whole.as_str(), TEST_ASSERTION_EXCERPT_MAX_LEN),
190                start_byte: whole.start(),
191            });
192        } else {
193            let classification =
194                classify_common_test_assertion(CommonTestAssertionKind::Meaningful, weights);
195            assertions.push(TestAssertionSignal {
196                kind: classification.kind.to_string(),
197                score: classification.score,
198                shallow: classification.shallow,
199                meaningful: classification.score == 0,
200                reasons: vec![classification.kind.to_string()],
201                excerpt: compact_assertion_excerpt(whole.as_str(), TEST_ASSERTION_EXCERPT_MAX_LEN),
202                start_byte: whole.start(),
203            });
204        }
205    }
206
207    for captures in PY_ASSERT_TRUTH_RE.captures_iter(body) {
208        let whole = captures.get(0).expect("whole match");
209        let matcher = captures.name("matcher").map(|m| m.as_str()).unwrap_or("");
210        let arg = normalize_python_expr(captures.name("arg").map(|m| m.as_str()).unwrap_or(""));
211        let kind = match matcher {
212            "True" if arg == "True" => CommonTestAssertionKind::ConstantTruth,
213            "False" if arg == "False" => CommonTestAssertionKind::ConstantTruth,
214            "IsNone" | "IsNotNone" => CommonTestAssertionKind::NullnessOnly,
215            _ => CommonTestAssertionKind::Meaningful,
216        };
217        let classification = classify_common_test_assertion(kind, weights);
218        assertions.push(TestAssertionSignal {
219            kind: classification.kind.to_string(),
220            score: classification.score,
221            shallow: classification.shallow,
222            meaningful: classification.score == 0,
223            reasons: vec![classification.kind.to_string()],
224            excerpt: compact_assertion_excerpt(whole.as_str(), TEST_ASSERTION_EXCERPT_MAX_LEN),
225            start_byte: whole.start(),
226        });
227    }
228
229    for captures in PY_BARE_ASSERT_RE.captures_iter(body) {
230        let whole = captures.get(0).expect("whole match");
231        let expr = normalize_python_expr(captures.name("expr").map(|m| m.as_str()).unwrap_or(""));
232        let trimmed = expr.trim();
233        let maybe_signal = if trimmed == "True" || trimmed == "False" {
234            Some(("constant-truth", weights.constant_truth_weight, true))
235        } else if trimmed.contains(" is not None") || trimmed.contains(" is None") {
236            Some(("nullness-only", weights.nullness_only_weight, true))
237        } else if let Some((left, right)) = trimmed.split_once("==") {
238            let left = normalize_python_expr(left);
239            let right = normalize_python_expr(right);
240            if left == right {
241                if is_python_literal(&left) {
242                    Some(("constant-equality", weights.constant_equality_weight, false))
243                } else {
244                    Some((
245                        "self-comparison",
246                        weights.tautological_assertion_weight,
247                        false,
248                    ))
249                }
250            } else if oversized_python_literal(&left, &right, weights).is_some() {
251                Some((
252                    "overspecified-literal",
253                    weights.overspecified_literal_weight,
254                    false,
255                ))
256            } else {
257                None
258            }
259        } else {
260            None
261        };
262
263        if let Some((kind, score, shallow)) = maybe_signal {
264            assertions.push(TestAssertionSignal {
265                kind: kind.to_string(),
266                score,
267                shallow,
268                meaningful: false,
269                reasons: vec![kind.to_string()],
270                excerpt: compact_assertion_excerpt(whole.as_str(), TEST_ASSERTION_EXCERPT_MAX_LEN),
271                start_byte: whole.start(),
272            });
273        } else {
274            assertions.push(TestAssertionSignal {
275                kind: "meaningful-assertion".to_string(),
276                score: 0,
277                shallow: false,
278                meaningful: true,
279                reasons: vec!["meaningful-assertion".to_string()],
280                excerpt: compact_assertion_excerpt(whole.as_str(), TEST_ASSERTION_EXCERPT_MAX_LEN),
281                start_byte: whole.start(),
282            });
283        }
284    }
285
286    for regex in [&*PY_RAISES_RE, &*PY_VERIFY_RE] {
287        for captures in regex.captures_iter(body) {
288            let whole = captures.get(0).expect("whole match");
289            assertions.push(TestAssertionSignal {
290                kind: "meaningful-assertion".to_string(),
291                score: 0,
292                shallow: false,
293                meaningful: true,
294                reasons: vec!["meaningful-assertion".to_string()],
295                excerpt: compact_assertion_excerpt(whole.as_str(), TEST_ASSERTION_EXCERPT_MAX_LEN),
296                start_byte: whole.start(),
297            });
298        }
299    }
300
301    assertions
302}
303
304fn normalize_python_expr(expr: &str) -> String {
305    normalize_assertion_expression(expr.trim().trim_end_matches(',').trim_end_matches(':'))
306}
307
308fn is_python_literal(expr: &str) -> bool {
309    let trimmed = expr.trim();
310    is_common_assertion_literal(trimmed)
311        || (trimmed.starts_with('\'') && trimmed.ends_with('\''))
312        || matches!(trimmed, "True" | "False" | "None")
313}
314
315fn oversized_python_literal(
316    left: &str,
317    right: &str,
318    weights: &TestAssertionWeights,
319) -> Option<String> {
320    oversized_quoted_literal(left, right, weights, &['"', '\''])
321}
322
323pub fn python_source_contains_tests(source: &str) -> bool {
324    static TEST_DEF_RE: std::sync::LazyLock<Regex> =
325        std::sync::LazyLock::new(|| Regex::new(r"(?m)^\s*def\s+test_[A-Za-z0-9_]*\s*\(").unwrap());
326    source.contains("@pytest.mark.") || TEST_DEF_RE.is_match(source)
327}