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