TestForge / mutator.py
perceptron01's picture
Upload 24 files
c4f3819 verified
Raw
History Blame Contribute Delete
5.22 kB
"""Fast hand-rolled mutation scoring for the bundled legacy sample."""
from __future__ import annotations
import shutil
from dataclasses import dataclass
from pathlib import Path
from generator import GeneratedSuite, write_suite
from runner import RunResult, run_pytest
@dataclass(frozen=True)
class Mutation:
id: str
file: str
find: str
replace: str
label: str
@dataclass(frozen=True)
class MutationResult:
mutation: Mutation
killed: bool
run: RunResult
@dataclass(frozen=True)
class MutationScore:
killed: int
total: int
results: tuple[MutationResult, ...]
@property
def percent(self) -> float:
return 0.0 if self.total == 0 else round(self.killed / self.total * 100, 1)
@property
def survived(self) -> list[Mutation]:
return [result.mutation for result in self.results if not result.killed]
def headline(self) -> str:
return f"{self.killed}/{self.total} behavior changes detected"
MUTATIONS: tuple[Mutation, ...] = (
Mutation(
id="bulk_multiply_to_divide",
file="pricing.py",
find="return round(unit * qty * discount, 2)",
replace="return round(unit / qty * discount, 2)",
label="pricing.py bulk_price * -> /",
),
Mutation(
id="discount_minus_to_plus",
file="pricing.py",
find="return round(price * (1 - pct / 100), 2)",
replace="return round(price * (1 + pct / 100), 2)",
label="pricing.py apply_discount - -> +",
),
Mutation(
id="tax_plus_to_minus",
file="pricing.py",
find="return round(amount * (1 + rate / 100), 2)",
replace="return round(amount * (1 - rate / 100), 2)",
label="pricing.py with_tax + -> -",
),
Mutation(
id="line_multiply_to_plus",
file="invoice.py",
find="return round(qty * unit, 2)",
replace="return round(qty + unit, 2)",
label="invoice.py line_total * -> +",
),
Mutation(
id="weekend_ge_to_gt",
file="dates.py",
find="return d.weekday() >= 5",
replace="return d.weekday() > 5",
label="dates.py is_weekend >= -> >",
),
Mutation(
id="days_abs_removed",
file="dates.py",
find="return abs((b - a).days)",
replace="return (b - a).days",
label="dates.py days_between remove abs",
),
Mutation(
id="slug_strip_removed",
file="slugify.py",
find="return cleaned.strip(\"-\")",
replace="return cleaned",
label="slugify.py strip removed",
),
Mutation(
id="truncate_len_lt",
file="slugify.py",
find="if len(s) <= n:",
replace="if len(s) < n:",
label="slugify.py <= -> <",
),
Mutation(
id="bulk_threshold_ge_to_gt",
file="pricing.py",
find="discount = 0.9 if qty >= 10 else 1.0",
replace="discount = 0.9 if qty > 10 else 1.0",
label="pricing.py bulk_price >= -> >",
),
)
def mutation_score(
source_parent: str | Path,
suite: GeneratedSuite,
tmp_root: str | Path,
package_name: str = "legacy_repo",
mutations: tuple[Mutation, ...] = MUTATIONS,
) -> MutationScore:
"""Apply each mutation to a copy and run the generated suite."""
source_parent = Path(source_parent).resolve()
tmp_root = Path(tmp_root).resolve()
tmp_root.mkdir(parents=True, exist_ok=True)
results: list[MutationResult] = []
for index, mutation in enumerate(mutations, start=1):
mutant_dir = tmp_root / f"mutant_{index}_{mutation.id}"
if mutant_dir.exists():
shutil.rmtree(mutant_dir)
shutil.copytree(source_parent, mutant_dir / "samples")
write_suite(suite, mutant_dir)
applied = _apply_mutation(mutant_dir / "samples" / package_name, mutation)
if not applied:
run = RunResult(
ok=False,
passed=0,
failed=0,
errors=1,
coverage=None,
stdout="",
stderr=f"Mutation not applied: {mutation.label}",
returncode=2,
)
results.append(MutationResult(mutation=mutation, killed=False, run=run))
continue
run = run_pytest(mutant_dir, package_parent=mutant_dir / "samples", package_name=package_name)
results.append(MutationResult(mutation=mutation, killed=not run.ok, run=run))
killed = sum(1 for result in results if result.killed)
return MutationScore(killed=killed, total=len(results), results=tuple(results))
def apply_single_mutation(package_root: str | Path, mutation_id: str) -> bool:
"""Apply one catalog mutation in-place for the live Inject button."""
mutation = next(item for item in MUTATIONS if item.id == mutation_id)
return _apply_mutation(Path(package_root), mutation)
def _apply_mutation(package_root: Path, mutation: Mutation) -> bool:
path = package_root / mutation.file
text = path.read_text(encoding="utf-8")
if mutation.find not in text:
return False
path.write_text(text.replace(mutation.find, mutation.replace, 1), encoding="utf-8")
return True