Spaces:
Running
Running
| #!/usr/bin/env python3 | |
| """ | |
| validate_tests.py β validates that tests are actually meaningful. | |
| Pipeline: | |
| 1. Static scan (50ms) β finds tests with no/trivial assertions | |
| 2. Targeted mutation testing (~1-2min) β finds tests that don't catch real changes | |
| No humanos. No revisiΓ³n. 100% automatizado. | |
| Usage: | |
| uv run python scripts/validate_tests.py | |
| uv run python scripts/validate_tests.py --quick # static scan only | |
| uv run python scripts/validate_tests.py --target core/expression_parser.py | |
| uv run python scripts/validate_tests.py --list # show mutations without running | |
| """ | |
| import ast | |
| import os | |
| import re | |
| import subprocess | |
| import sys | |
| import time | |
| from dataclasses import dataclass | |
| from pathlib import Path | |
| # ββ Config ββ | |
| DEFAULT_TARGET = "core/expression_parser.py" | |
| TEST_MAP = { | |
| "core/expression_parser.py": [ | |
| "tests/test_filters_legacy.py", | |
| "tests/test_grammar_complete.py", | |
| ], | |
| } | |
| # Pytest flags for fast failure | |
| PYTEST_FLAGS = ["-x", "--tb=line", "-q", "--no-header"] | |
| ENV = { | |
| **os.environ, | |
| "NUMBA_DISABLE_JIT": "1", | |
| "PYTEST_ADDOPTS": "", | |
| "PYTHONWARNINGS": "ignore", | |
| } | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # Mutation primitives | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| class TextMutation: | |
| name: str | |
| lineno: int | |
| old_text: str | |
| new_text: str | |
| category: str = "operator" | |
| desc: str = "" | |
| def generate_text_mutations(source_path: str) -> list[TextMutation]: | |
| """Generate mutations using text pattern matching. | |
| Only mutates actual CODE lines β docstrings, comments, string literals skipped. | |
| Same generator as mutation_test.py. | |
| """ | |
| with open(source_path) as f: | |
| lines = f.readlines() | |
| mutations: list[TextMutation] = [] | |
| seen: set[tuple[int, str, str]] = set() | |
| def add(name, lineno, old_text, new_text, category="operator", desc=""): | |
| key = (lineno, old_text, new_text) | |
| if key not in seen: | |
| seen.add(key) | |
| mutations.append( | |
| TextMutation( | |
| name=name, lineno=lineno, old_text=old_text, new_text=new_text, category=category, desc=desc | |
| ) | |
| ) | |
| def _skip_line(stripped: str) -> bool: | |
| """Skip comments, docstrings, rst bullets, and string-only lines.""" | |
| if not stripped: | |
| return True | |
| if stripped.startswith("#"): | |
| return True | |
| if stripped.startswith("* "): | |
| return True | |
| # Single-line triple-quoted strings | |
| if stripped.startswith('"""') and stripped.endswith('"""') and len(stripped) > 3: | |
| return True | |
| if stripped.startswith("'''") and stripped.endswith("'''") and len(stripped) > 3: | |
| return True | |
| # String-only lines (full-line string literals β not f-strings) | |
| return (stripped.startswith('"') and stripped.endswith('"') and not stripped.startswith('f"')) or ( | |
| stripped.startswith("'") and stripped.endswith("'") and not stripped.startswith("f'") | |
| ) | |
| def _code_part(line: str) -> str: | |
| """Return code before inline comment.""" | |
| in_single = False | |
| in_double = False | |
| for j, ch in enumerate(line): | |
| if ch == '"' and not in_single: | |
| in_double = not in_double | |
| elif ch == "'" and not in_double: | |
| in_single = not in_single | |
| elif ch == "#" and not in_single and not in_double: | |
| return line[:j] | |
| return line | |
| # Pre-scan for multi-line string regions (docstrings, multi-line strings) | |
| in_multi = False | |
| multi_delim: str | None = None | |
| multi_lines: set[int] = set() | |
| for i, line in enumerate(lines, 1): | |
| stripped = line.strip() | |
| if in_multi: | |
| multi_lines.add(i) | |
| # Close on line that ends with the delimiter (or is just the delimiter) | |
| if multi_delim and stripped.rstrip().endswith(multi_delim): | |
| in_multi = False | |
| multi_delim = None | |
| continue | |
| if not stripped: | |
| continue | |
| # Line starts with """ or ''' but doesn't end with it β multi-line string start | |
| if stripped.startswith('"""') and not stripped.rstrip().endswith('"""'): | |
| in_multi = True | |
| multi_delim = '"""' | |
| multi_lines.add(i) | |
| elif stripped.startswith("'''") and not stripped.rstrip().endswith("'''"): | |
| in_multi = True | |
| multi_delim = "'''" | |
| multi_lines.add(i) | |
| for i, line in enumerate(lines, 1): | |
| # Get code part (strip inline comments, but only for operator matching) | |
| code = _code_part(line) | |
| stripped = code.strip() | |
| if _skip_line(stripped): | |
| continue | |
| if i in multi_lines: | |
| continue | |
| # Comparison operators β only check the code part, not comments | |
| if re.search(r"[\w)\]]\s*<=", stripped) and "<=" in stripped: | |
| add(f"L{i}: <= β <", i, "<=", "<", "operator") | |
| if re.search(r"[\w)\]]\s*>=", stripped) and ">=" in stripped: | |
| add(f"L{i}: >= β >", i, ">=", ">", "operator") | |
| if " == " in stripped: | |
| add(f"L{i}: == β !=", i, " == ", " != ", "operator") | |
| if " != " in stripped: | |
| add(f"L{i}: != β ==", i, " != ", " == ", "operator") | |
| if re.search(r"[\w)\]]\s+>\s+", stripped) and ">=" not in stripped: | |
| add(f"L{i}: > β >=", i, " > ", " >= ", "operator") | |
| if re.search(r"[\w)\]]\s+<\s+", stripped) and "<=" not in stripped: | |
| add(f"L{i}: < β <=", i, " < ", " <= ", "operator") | |
| # Boolean operators | |
| if ( | |
| re.search(r"\band\b", stripped) | |
| and " and " in stripped | |
| and (re.search(r"\w+\s+and\s+", stripped) or re.search(r"\)\s+and\s+", stripped)) | |
| ): | |
| add(f"L{i}: and β or", i, " and ", " or ", "operator") | |
| if ( | |
| re.search(r"\bor\b", stripped) | |
| and " or " in stripped | |
| and (re.search(r"\w+\s+or\s+", stripped) or re.search(r"\)\s+or\s+", stripped)) | |
| and ("for " not in stripped.split(" or ")[0] if " or " in stripped else True) | |
| ): | |
| add(f"L{i}: or β and", i, " or ", " and ", "operator") | |
| # Boolean literals in return statements | |
| if stripped == "return lambda ctx: False" or stripped == "return lambda ctx:True": | |
| add(f"L{i}: False β True", i, "False", "True", "literal") | |
| if stripped == "return lambda ctx: True" or stripped == "return lambda ctx:True": | |
| add(f"L{i}: True β False", i, "True", "False", "literal") | |
| # Numeric boundary mutations | |
| # 0 β 1 (empty/null checks) | |
| if re.search(r"\b==\s*0\b", stripped) and "0" in stripped: | |
| add(f"L{i}: == 0 β == 1", i, "== 0", "== 1", "boundary") | |
| if re.search(r"\b!=\s*0\b", stripped) and "0" in stripped: | |
| add(f"L{i}: != 0 β != 1", i, "!= 0", "!= 1", "boundary") | |
| # len(X) == N β len(X) == N+1 (off-by-one) | |
| # (only for N > 0 to avoid 0β1 overlap) | |
| len_match = re.search(r"\blen\((\w+)\)\s*==\s*(\d+)\b", stripped) | |
| if len_match and int(len_match.group(2)) > 0: | |
| n = int(len_match.group(2)) | |
| old = f"len({len_match.group(1)}) == {n}" | |
| new = f"len({len_match.group(1)}) == {n + 1}" | |
| add(f"L{i}: len() == {n} β == {n + 1}", i, old, new, "boundary") | |
| return mutations | |
| def apply_mutation(source_path: str, mutation: TextMutation) -> bool: | |
| """Apply a single mutation. Returns True if the file was changed.""" | |
| with open(source_path) as f: | |
| lines = f.readlines() | |
| idx = mutation.lineno - 1 | |
| if idx < 0 or idx >= len(lines): | |
| return False | |
| old = lines[idx] | |
| if mutation.old_text not in old: | |
| return False | |
| new = old.replace(mutation.old_text, mutation.new_text, 1) | |
| if new == old: | |
| return False | |
| lines[idx] = new | |
| with open(source_path, "w") as f: | |
| f.writelines(lines) | |
| return True | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # Stage 1: Static analysis | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| class TestIssue: | |
| test_file: str | |
| test_name: str | |
| lineno: int | |
| issue_type: str # NO_ASSERT, TRIVIAL_ASSERT | |
| detail: str = "" | |
| def analyze_test_quality(test_files: list[str]) -> list[TestIssue]: | |
| """Find tests with quality issues via AST analysis (no test execution).""" | |
| issues = [] | |
| for tf in test_files: | |
| if not os.path.exists(tf): | |
| continue | |
| with open(tf) as f: | |
| try: | |
| source = f.read() | |
| tree = ast.parse(source) | |
| except SyntaxError as e: | |
| issues.append(TestIssue(tf, "<file>", 0, "PARSE_ERROR", str(e))) | |
| continue | |
| for node in ast.walk(tree): | |
| if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): | |
| continue | |
| # Skip non-test functions (helpers with "assert" in name are OK) | |
| if not (node.name.startswith("test_") or node.name.startswith("async_test_")): | |
| # Check if this is a method inside a Test class | |
| for parent in ast.walk(tree): | |
| if isinstance(parent, ast.ClassDef) and parent.name.startswith("Test"): | |
| # Check if node is a direct child of this class | |
| # We can't easily check parentage in ast.walk, so skip this heuristic | |
| pass | |
| # Simple heuristic: only flag functions that start with test_ | |
| # Methods inside Test* classes that don't start with test_ are still | |
| # run by pytest but are unusual β skip them for now | |
| if not node.name.startswith("test_"): | |
| continue | |
| # Check for pytest.raises/pytest.warns context managers (valid assertions) | |
| has_pytest_raises = False | |
| for w in ast.walk(node): | |
| if isinstance(w, ast.With): | |
| for item in w.items: | |
| ctx = item.context_expr | |
| if ( | |
| isinstance(ctx, ast.Call) | |
| and isinstance(ctx.func, ast.Attribute) | |
| and isinstance(ctx.func.value, ast.Name) | |
| and ctx.func.value.id == "pytest" | |
| and ctx.func.attr in ("raises", "warns") | |
| ): | |
| has_pytest_raises = True | |
| # Count assert statements | |
| assert_nodes = [n for n in ast.walk(node) if isinstance(n, ast.Assert)] | |
| if len(assert_nodes) == 0 and not has_pytest_raises: | |
| issues.append(TestIssue(tf, node.name, node.lineno, "NO_ASSERT")) | |
| continue | |
| # Check for trivial assertions (assert True/False/None) | |
| for n in assert_nodes: | |
| if isinstance(n.test, ast.Constant) and n.test.value in (True, False, None): | |
| issues.append( | |
| TestIssue(tf, node.name, node.lineno, "TRIVIAL_ASSERT", f"L{n.lineno}: assert {n.test.value}") | |
| ) | |
| break # one flag per test is enough | |
| # Check for single assert that is just "assert X is not None" (weak) | |
| if len(assert_nodes) == 1: | |
| only = assert_nodes[0] | |
| if isinstance(only.test, ast.IsNot): | |
| issues.append( | |
| TestIssue( | |
| tf, node.name, node.lineno, "WEAK_ASSERT", f"L{only.lineno}: assert X is not None only" | |
| ) | |
| ) | |
| elif isinstance(only.test, ast.Is): | |
| issues.append( | |
| TestIssue(tf, node.name, node.lineno, "WEAK_ASSERT", f"L{only.lineno}: assert X is None only") | |
| ) | |
| return issues | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # Stage 2: Targeted mutation testing | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def get_function_at_line(source_path: str, lineno: int) -> str | None: | |
| """Find the function/method name that contains the given line number.""" | |
| with open(source_path) as f: | |
| try: | |
| tree = ast.parse(f.read()) | |
| except SyntaxError: | |
| return None | |
| best = None | |
| for node in ast.walk(tree): | |
| if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): | |
| end = getattr(node, "end_lineno", None) | |
| if ( | |
| end is not None | |
| and node.lineno <= lineno <= end | |
| and (best is None or node.lineno > getattr(best, "lineno", 0)) | |
| ): | |
| # Return the innermost function (the one with the highest start line) | |
| best = node | |
| return best.name if best else None | |
| def collect_node_ids(test_files: list[str], timeout: int = 30) -> list[str]: | |
| """Get all pytest test node IDs for the given files.""" | |
| cmd = [sys.executable, "-m", "pytest", *test_files, "--collect-only", "-q", "--no-header"] | |
| result = subprocess.run( | |
| cmd, | |
| capture_output=True, | |
| text=True, | |
| timeout=timeout, | |
| env=ENV, | |
| ) | |
| node_ids = [] | |
| for line in result.stdout.splitlines(): | |
| line = line.strip() | |
| if not line: | |
| continue | |
| # Filter out noise lines | |
| if line.startswith("no tests ran") or line.startswith("=="): | |
| continue | |
| # It's a valid node ID if it contains "::" | |
| if "::" in line and not line.startswith("<"): | |
| node_ids.append(line) | |
| return node_ids | |
| def select_tests_for_line( | |
| all_node_ids: list[str], | |
| source_path: str, | |
| lineno: int, | |
| ) -> list[str] | None: | |
| """Select test node IDs relevant to a mutation at the given line. | |
| Uses function name matching: if the mutation is in `compile_safe()`, | |
| it finds all tests with `compile_safe` or `TestCompileSafe` in their names. | |
| Returns None if no specific match found (fall back to all tests). | |
| """ | |
| func_name = get_function_at_line(source_path, lineno) | |
| if not func_name: | |
| return None | |
| # Normalize for matching: remove underscores, lowercase | |
| norm = func_name.lower().replace("_", "") | |
| matching = [nid for nid in all_node_ids if norm in nid.lower().replace("_", "")] | |
| if not matching: | |
| return None # fall back to all tests | |
| return matching | |
| def run_tests_baseline(test_files: list[str]) -> tuple[bool, str, float]: | |
| """Run all tests once to verify they pass.""" | |
| cmd = [sys.executable, "-m", "pytest", *test_files, *PYTEST_FLAGS] | |
| start = time.time() | |
| result = subprocess.run( | |
| cmd, | |
| capture_output=True, | |
| text=True, | |
| timeout=180, | |
| env=ENV, | |
| ) | |
| duration = time.time() - start | |
| passed = result.returncode == 0 | |
| return passed, result.stdout + result.stderr, duration | |
| def run_tests_by_node_ids(node_ids: list[str]) -> tuple[bool, float]: | |
| """Run specific tests by node IDs.""" | |
| cmd = [sys.executable, "-m", "pytest", *node_ids, *PYTEST_FLAGS] | |
| start = time.time() | |
| result = subprocess.run( | |
| cmd, | |
| capture_output=True, | |
| text=True, | |
| timeout=60, | |
| env=ENV, | |
| ) | |
| duration = time.time() - start | |
| passed = result.returncode == 0 | |
| return passed, duration | |
| def run_mutation_test( | |
| mutation: TextMutation, | |
| source_path: str, | |
| original_source: str, | |
| all_node_ids: list[str], | |
| ) -> dict: | |
| """Apply a single mutation, run relevant tests, and report result.""" | |
| # Select relevant tests | |
| matching = select_tests_for_line(all_node_ids, source_path, mutation.lineno) | |
| node_ids = matching if matching else all_node_ids | |
| label = f" {mutation.name:55s}" | |
| print(f"{label} ", end="", flush=True) | |
| # Apply mutation | |
| changed = apply_mutation(source_path, mutation) | |
| if not changed: | |
| print("β οΈ SKIP (text not found)") | |
| return {"mutation": mutation, "status": "SKIPPED", "duration": 0, "tests_run": 0} | |
| time.sleep(0.05) | |
| # Run tests | |
| try: | |
| ok, duration = run_tests_by_node_ids(node_ids) | |
| except subprocess.TimeoutExpired: | |
| print("β° TIMEOUT") | |
| # Timeout means tests are slow but not broken β count as KILLED | |
| ok, duration = True, 0 | |
| except Exception as e: | |
| print(f"π₯ ERROR: {e}") | |
| return {"mutation": mutation, "status": "ERROR", "duration": 0, "tests_run": len(node_ids)} | |
| # Restore original | |
| with open(source_path, "w") as f: | |
| f.write(original_source) | |
| if ok: | |
| status = "SURVIVED" | |
| icon = "π₯" | |
| else: | |
| status = "KILLED" | |
| icon = "β " | |
| print(f"{icon} {status} ({len(node_ids)} tests in {duration:.0f}s)") | |
| return { | |
| "mutation": mutation, | |
| "status": status, | |
| "duration": duration, | |
| "tests_run": len(node_ids), | |
| "matched_tests": len(matching) if matching else "ALL", | |
| } | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # Report | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def print_banner(text: str) -> None: | |
| width = 65 | |
| print(f"\n{'β' * width}") | |
| print(f" {text}") | |
| print(f"{'β' * width}") | |
| def print_report( | |
| source_path: str, | |
| static_issues: list[TestIssue], | |
| mutation_results: list[dict], | |
| total_duration: float, | |
| ) -> None: | |
| print() | |
| print("=" * 65) | |
| print(" TEST VALIDATION REPORT") | |
| print(f" Target: {source_path}") | |
| print(f"{'=' * 65}") | |
| # ββ Static analysis ββ | |
| print_banner("Stage 1: Static analysis") | |
| no_assert = [i for i in static_issues if i.issue_type == "NO_ASSERT"] | |
| trivial = [i for i in static_issues if i.issue_type == "TRIVIAL_ASSERT"] | |
| weak = [i for i in static_issues if i.issue_type == "WEAK_ASSERT"] | |
| if not static_issues: | |
| print(" β No issues found β all tests have assertions") | |
| else: | |
| if no_assert: | |
| print(f" β {len(no_assert)} test(s) with NO assertions:") | |
| for i in no_assert: | |
| print(f" - {Path(i.test_file).name}::{i.test_name} (L{i.lineno})") | |
| if trivial: | |
| print(f" β οΈ {len(trivial)} test(s) with TRIVIAL assertions (assert True/False/None):") | |
| for i in trivial: | |
| print(f" - {Path(i.test_file).name}::{i.test_name} (L{i.lineno}) β {i.detail}") | |
| if weak: | |
| print(f" β οΈ {len(weak)} test(s) with WEAK assertions (single is/is-not check):") | |
| for i in weak: | |
| print(f" - {Path(i.test_file).name}::{i.test_name} (L{i.lineno}) β {i.detail}") | |
| # ββ Mutation testing ββ | |
| print_banner("Stage 2: Targeted mutation testing") | |
| survived = [r for r in mutation_results if r["status"] == "SURVIVED"] | |
| killed = [r for r in mutation_results if r["status"] == "KILLED"] | |
| errors = [r for r in mutation_results if r["status"] == "ERROR"] | |
| total = len(mutation_results) | |
| score = len(killed) / total * 100 if total > 0 else 0 | |
| if not mutation_results: | |
| print(" (no mutations generated)") | |
| else: | |
| print(f" Total: {total}") | |
| print(f" Killed: {len(killed)}") | |
| print(f" Survived: {len(survived)} {'β οΈ' if survived else 'β '}") | |
| print(f" Errors: {len(errors)}") | |
| print(f" Score: {score:.0f}%") | |
| print(f" Duration: {total_duration:.0f}s") | |
| if survived: | |
| print(f"\n {'β' * 55}") | |
| print(" SURVIVED β these changes are NOT caught by tests:") | |
| print(f" {'β' * 55}") | |
| for i, r in enumerate(survived, 1): | |
| m = r["mutation"] | |
| tests_label = ( | |
| f"{r['tests_run']} tests matched" | |
| if isinstance(r.get("matched_tests"), int) | |
| else f"ALL {r['tests_run']} tests" | |
| ) | |
| print(f" [{i}] {m.name}") | |
| print(f" {m.old_text!r} β {m.new_text!r}") | |
| print(f" Ran {tests_label} β all passed in {r['duration']:.0f}s") | |
| if killed: | |
| print(f"\n β {len(killed)} mutation(s) killed β these tests work correctly") | |
| # ββ Summary ββ | |
| print_banner("Summary") | |
| if score >= 90 and not static_issues: | |
| print(" β HIGH β tests are solid") | |
| elif score >= 70 and not static_issues: | |
| print(" β οΈ MODERATE β some gaps found") | |
| elif score >= 50: | |
| print(" β οΈ LOW β significant gaps found") | |
| else: | |
| print(" β CRITICAL β tests miss most changes") | |
| quality_issues = len(no_assert) + len(trivial) | |
| if quality_issues > 0: | |
| print(f" β {quality_issues} test(s) with static quality issues (NO_ASSERT or TRIVIAL_ASSERT)") | |
| if survived: | |
| print(f" β {len(survived)} behavioral gap(s) β no tests catch these changes") | |
| print(" β Add tests for the specific edge cases listed above") | |
| print(f"\n Total time: {total_duration:.0f}s") | |
| print(f" {'=' * 65}") | |
| print() | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # CLI | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def find_test_files(source_path: str) -> list[str]: | |
| """Find test files for a given source module.""" | |
| rel = os.path.normpath(source_path) | |
| for key, tests in TEST_MAP.items(): | |
| if key == rel: | |
| return tests | |
| # Fallback: glob for test files | |
| basename = os.path.basename(source_path).replace(".py", "") | |
| test_files = list(Path("tests").glob(f"test_*{basename}*.py")) | |
| return [str(f) for f in test_files] if test_files else [] | |
| def main(): | |
| import argparse | |
| parser = argparse.ArgumentParser(description="Validate that tests are meaningful") | |
| parser.add_argument( | |
| "--target", default=DEFAULT_TARGET, help="Source module to test (default: core/expression_parser.py)" | |
| ) | |
| parser.add_argument("--quick", action="store_true", help="Skip mutation testing, static analysis only") | |
| parser.add_argument("--list", action="store_true", help="List mutations without running them") | |
| parser.add_argument("--tests", nargs="*", help="Test files (default: auto-detect)") | |
| args = parser.parse_args() | |
| source_path = args.target | |
| if not os.path.exists(source_path): | |
| print(f"β File not found: {source_path}") | |
| return 1 | |
| test_files = args.tests if args.tests else find_test_files(source_path) | |
| if not test_files: | |
| print(f"β No test files found for {source_path}") | |
| return 1 | |
| print(f"\n Source: {source_path}") | |
| print(f" Tests: {', '.join(test_files)}") | |
| start_all = time.time() | |
| # ββ Stage 1: Static analysis ββ | |
| print() | |
| print(" ββ Stage 1: Static analysis ββ") | |
| static_issues = analyze_test_quality(test_files) | |
| if static_issues: | |
| print(f" Found {len(static_issues)} issue(s)") | |
| for i in static_issues: | |
| label = { | |
| "NO_ASSERT": "no assert", | |
| "TRIVIAL_ASSERT": "trivial assert", | |
| "WEAK_ASSERT": "weak assert", | |
| "PARSE_ERROR": "parse error", | |
| }.get(i.issue_type, i.issue_type) | |
| print(f" β οΈ {Path(i.test_file).name}::{i.test_name} β {label}") | |
| else: | |
| print(" β All tests have assertions") | |
| if args.quick: | |
| total_duration = time.time() - start_all | |
| print() | |
| print(" --quick: skipping mutation testing") | |
| print(f" Total: {total_duration:.0f}s") | |
| return 0 | |
| # ββ Prepare mutation testing ββ | |
| print() | |
| print(" ββ Stage 2: Targeted mutation testing ββ") | |
| # Generate mutations | |
| mutations = generate_text_mutations(source_path) | |
| if not mutations: | |
| print(" No viable mutations found.") | |
| total_duration = time.time() - start_all | |
| print(f"\n Total: {total_duration:.0f}s") | |
| return 0 | |
| print(f" Generated {len(mutations)} mutations") | |
| if args.list: | |
| for m in mutations: | |
| func = get_function_at_line(source_path, m.lineno) or "?" | |
| print(f" L{m.lineno:4d} [{m.category:8s}] {m.name:45s} in {func}()") | |
| return 0 | |
| # Baseline check | |
| print(" Baseline check...", end=" ", flush=True) | |
| ok, out, dur = run_tests_baseline(test_files) | |
| if not ok: | |
| print("FAILED β fix your tests first!") | |
| print(out[:500]) | |
| return 1 | |
| print(f"β ({dur:.0f}s)") | |
| # Collect all test node IDs (once) | |
| print(" Collecting test node IDs...", end=" ", flush=True) | |
| all_node_ids = collect_node_ids(test_files) | |
| print(f"{len(all_node_ids)} tests collected") | |
| # Read original source | |
| with open(source_path) as f: | |
| original_source = f.read() | |
| # Run each mutation | |
| mutation_results = [] | |
| try: | |
| for mutation in mutations: | |
| result = run_mutation_test(mutation, source_path, original_source, all_node_ids) | |
| mutation_results.append(result) | |
| finally: | |
| # Ensure restoration | |
| with open(source_path) as fh: | |
| current = fh.read() | |
| if current != original_source: | |
| with open(source_path, "w") as f: | |
| f.write(original_source) | |
| print("\n β οΈ Restored original file from backup") | |
| total_duration = time.time() - start_all | |
| # ββ Report ββ | |
| print_report(source_path, static_issues, mutation_results, total_duration) | |
| has_survived = any(r["status"] == "SURVIVED" for r in mutation_results) | |
| return 1 if has_survived else 0 | |
| if __name__ == "__main__": | |
| sys.exit(main()) | |