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}