Spaces:
Running on Zero
Running on Zero
Upload 24 files
Browse filesdone donaa done done
- README.md +176 -6
- agent.py +87 -0
- analyzer.py +128 -0
- app.py +154 -0
- generator.py +166 -0
- inject.py +45 -0
- mutator.py +166 -0
- patch.py +45 -0
- pytest.ini +3 -0
- requirements.txt +4 -0
- runner.py +122 -0
- samples/legacy_repo/__init__.py +2 -0
- samples/legacy_repo/__pycache__/__init__.cpython-312.pyc +0 -0
- samples/legacy_repo/__pycache__/dates.cpython-312.pyc +0 -0
- samples/legacy_repo/__pycache__/invoice.cpython-312.pyc +0 -0
- samples/legacy_repo/__pycache__/pricing.cpython-312.pyc +0 -0
- samples/legacy_repo/__pycache__/slugify.cpython-312.pyc +0 -0
- samples/legacy_repo/dates.py +14 -0
- samples/legacy_repo/invoice.py +17 -0
- samples/legacy_repo/pricing.py +26 -0
- samples/legacy_repo/slugify.py +21 -0
- tests/__pycache__/test_testforge.cpython-312-pytest-8.3.4.pyc +0 -0
- tests/__pycache__/test_testforge.cpython-312.pyc +0 -0
- tests/test_testforge.py +136 -0
README.md
CHANGED
|
@@ -1,14 +1,184 @@
|
|
| 1 |
---
|
| 2 |
title: TestForge
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: gradio
|
| 7 |
-
sdk_version:
|
| 8 |
-
python_version:
|
| 9 |
app_file: app.py
|
| 10 |
pinned: false
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
short_description: Freeze legacy Python behavior before refactoring
|
| 12 |
---
|
| 13 |
|
| 14 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
title: TestForge
|
| 3 |
+
emoji: "🔨"
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: indigo
|
| 6 |
sdk: gradio
|
| 7 |
+
sdk_version: 4.44.0
|
| 8 |
+
python_version: "3.12"
|
| 9 |
app_file: app.py
|
| 10 |
pinned: false
|
| 11 |
+
license: mit
|
| 12 |
+
tags:
|
| 13 |
+
- build-small-hackathon
|
| 14 |
+
- sponsor:openai
|
| 15 |
+
- openai-codex
|
| 16 |
+
- testing
|
| 17 |
+
- agent
|
| 18 |
+
- python
|
| 19 |
+
- pytest
|
| 20 |
+
- developer-tools
|
| 21 |
short_description: Freeze legacy Python behavior before refactoring
|
| 22 |
---
|
| 23 |
|
| 24 |
+
# 🔨 TestForge — Characterization tests for legacy Python code
|
| 25 |
+
|
| 26 |
+
**Track:** OpenAI Codex · **Prize target:** Best Use of Codex
|
| 27 |
+
|
| 28 |
+
Untested legacy Python code is scary to change because you do not know what
|
| 29 |
+
behavior you are about to break. **TestForge** points at a small Python repo,
|
| 30 |
+
discovers its public functions, executes the real code on representative inputs,
|
| 31 |
+
and generates a green pytest suite that locks in current behavior. Then it
|
| 32 |
+
proves the suite is not fake confidence by mutating the code and showing a
|
| 33 |
+
headline **mutation score**.
|
| 34 |
+
|
| 35 |
+
> Built as **a coding agent built by a coding agent**. The goal is not "AI
|
| 36 |
+
> writes tests." The goal is "AI creates a runnable, inspectable, regression-
|
| 37 |
+
> catching safety net before you refactor."
|
| 38 |
+
|
| 39 |
+
---
|
| 40 |
+
|
| 41 |
+
## Why this is trustworthy: the code verifies itself
|
| 42 |
+
|
| 43 |
+
The core design choice is **capture-then-assert**.
|
| 44 |
+
|
| 45 |
+
For every discovered function, TestForge chooses a few inputs, **runs the real
|
| 46 |
+
function**, records the actual return value or exception, and only then emits
|
| 47 |
+
pytest assertions.
|
| 48 |
+
|
| 49 |
+
```python
|
| 50 |
+
def test_apply_discount_case_1():
|
| 51 |
+
assert apply_discount(100.0, 10.0) == 90.0
|
| 52 |
+
|
| 53 |
+
def test_with_tax_case_4():
|
| 54 |
+
with pytest.raises(ValueError):
|
| 55 |
+
with_tax(100.0, -1.0)
|
| 56 |
+
```
|
| 57 |
+
|
| 58 |
+
This means:
|
| 59 |
+
|
| 60 |
+
- every generated test is green on the current code by construction
|
| 61 |
+
- every test calls a real public function, so it cannot collapse into `assert True`
|
| 62 |
+
- the expected value comes from execution, not model guesswork
|
| 63 |
+
|
| 64 |
+
Then TestForge applies a fixed catalog of source mutations and reruns the same
|
| 65 |
+
generated suite. The result is a visible quality metric like:
|
| 66 |
+
|
| 67 |
+
```text
|
| 68 |
+
Mutation score: 7/9 behavior changes detected
|
| 69 |
+
```
|
| 70 |
+
|
| 71 |
+
That turns "the AI made tests" into "the AI made tests that demonstrably catch
|
| 72 |
+
regressions."
|
| 73 |
+
|
| 74 |
+
---
|
| 75 |
+
|
| 76 |
+
## Models
|
| 77 |
+
|
| 78 |
+
This shipped version is intentionally **deterministic and model-free** so the
|
| 79 |
+
demo works with **no model and no GPU**.
|
| 80 |
+
|
| 81 |
+
| Job | Current implementation | Fallback |
|
| 82 |
+
|---|---|---|
|
| 83 |
+
| Input generation | Type-hint-driven deterministic cases | Fixed mixed literals |
|
| 84 |
+
| Test expectation generation | Real code execution | None needed |
|
| 85 |
+
| Quality proof | Hand-rolled mutation scoring | None needed |
|
| 86 |
+
|
| 87 |
+
This keeps the demo fast, inspectable, and stable under hackathon time
|
| 88 |
+
pressure. A future enhancement path is to let a small coder model propose extra
|
| 89 |
+
edge-case inputs, but only accept those cases if they improve coverage or the
|
| 90 |
+
mutation score.
|
| 91 |
+
|
| 92 |
+
---
|
| 93 |
+
|
| 94 |
+
## How it works
|
| 95 |
+
|
| 96 |
+
```text
|
| 97 |
+
Pick bundled legacy repo
|
| 98 |
+
-> Analyze : AST discovery of public functions and signatures
|
| 99 |
+
-> Generate : choose deterministic inputs and capture real outputs
|
| 100 |
+
-> Render : write pytest characterization tests
|
| 101 |
+
-> Run : execute pytest in a subprocess
|
| 102 |
+
-> Score : apply deterministic mutations and count killed mutants
|
| 103 |
+
-> Export : produce a PR-style patch adding tests/
|
| 104 |
+
```
|
| 105 |
+
|
| 106 |
+
## Run locally
|
| 107 |
+
|
| 108 |
+
```bash
|
| 109 |
+
python -m pip install -r requirements.txt
|
| 110 |
+
python -m pytest -q
|
| 111 |
+
python app.py
|
| 112 |
+
```
|
| 113 |
+
|
| 114 |
+
Then click **Forge Tests** to generate a full suite for the bundled sample repo,
|
| 115 |
+
inspect the generated tests, and see the mutation score. Click **Inject a
|
| 116 |
+
regression** to watch the same suite turn red on a controlled behavior change.
|
| 117 |
+
|
| 118 |
+
## Tests
|
| 119 |
+
|
| 120 |
+
```bash
|
| 121 |
+
python -m pytest -q
|
| 122 |
+
```
|
| 123 |
+
|
| 124 |
+
The project's own test suite currently covers:
|
| 125 |
+
|
| 126 |
+
- analyzer discovery and arity checks
|
| 127 |
+
- capture of returned values and raised exceptions
|
| 128 |
+
- deterministic suite generation staying green
|
| 129 |
+
- runner parsing for passing and failing suites
|
| 130 |
+
- deterministic mutation scoring
|
| 131 |
+
- PR patch export
|
| 132 |
+
- forge pipeline smoke path
|
| 133 |
+
- injected regression causing exactly one failure
|
| 134 |
+
|
| 135 |
+
## Project layout
|
| 136 |
+
|
| 137 |
+
```text
|
| 138 |
+
app.py Gradio UI + orchestration
|
| 139 |
+
agent.py forge pipeline for the bundled sample repo
|
| 140 |
+
analyzer.py AST discovery of public functions and methods
|
| 141 |
+
generator.py deterministic inputs, capture, and pytest rendering
|
| 142 |
+
runner.py subprocess pytest execution and parsing
|
| 143 |
+
mutator.py hand-rolled mutation catalog and mutation score
|
| 144 |
+
patch.py PR-style unified diff export
|
| 145 |
+
inject.py controlled one-click demo regression
|
| 146 |
+
samples/legacy_repo/ bundled untested Python target repo
|
| 147 |
+
tests/test_testforge.py
|
| 148 |
+
```
|
| 149 |
+
|
| 150 |
+
## Demo arc
|
| 151 |
+
|
| 152 |
+
1. Click **Forge Tests**
|
| 153 |
+
2. Watch TestForge analyze the sample repo and generate tests from real execution
|
| 154 |
+
3. See a green pytest result and the mutation score
|
| 155 |
+
4. Click **Inject a regression**
|
| 156 |
+
5. Watch the same suite fail on exactly one controlled change
|
| 157 |
+
6. Download `testforge.patch` as a PR-style artifact
|
| 158 |
+
|
| 159 |
+
## Codex build log
|
| 160 |
+
|
| 161 |
+
This repo was built in milestone commits through OpenAI Codex:
|
| 162 |
+
|
| 163 |
+
- `Add legacy sample and AST analyzer`
|
| 164 |
+
- `Add deterministic capture runner`
|
| 165 |
+
- `Add deterministic mutation scoring`
|
| 166 |
+
- `Add forge app orchestration`
|
| 167 |
+
- `Document hackathon submission`
|
| 168 |
+
|
| 169 |
+
The most important product decision was keeping the **source of truth in the
|
| 170 |
+
code itself**, not in the model. Codex built the tooling and loop around that
|
| 171 |
+
principle.
|
| 172 |
+
|
| 173 |
+
## Privacy
|
| 174 |
+
|
| 175 |
+
The bundled target repo is synthetic and local. The deterministic demo path
|
| 176 |
+
uses no external model, no database, and no persistent user storage.
|
| 177 |
+
|
| 178 |
+
---
|
| 179 |
+
|
| 180 |
+
## Submission links
|
| 181 |
+
|
| 182 |
+
- 🤗 **Live Space:** add your Hugging Face Space URL here
|
| 183 |
+
- 🎥 **Demo video:** add your demo video link here
|
| 184 |
+
- 📣 **Social post:** add your social post link here
|
agent.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Agent orchestration for TestForge."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import shutil
|
| 6 |
+
import tempfile
|
| 7 |
+
from dataclasses import dataclass
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
|
| 10 |
+
from analyzer import Analysis, analyze
|
| 11 |
+
from generator import GeneratedSuite, generate_suite, write_suite
|
| 12 |
+
from inject import reset_sample
|
| 13 |
+
from mutator import MUTATIONS, Mutation, MutationScore, mutation_score
|
| 14 |
+
from patch import write_patch
|
| 15 |
+
from runner import RunResult, run_pytest
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
ROOT = Path(__file__).resolve().parent
|
| 19 |
+
SAMPLES_ROOT = ROOT / "samples"
|
| 20 |
+
LEGACY_ROOT = SAMPLES_ROOT / "legacy_repo"
|
| 21 |
+
RUNS_ROOT = ROOT / "generated" / "runs"
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
@dataclass(frozen=True)
|
| 25 |
+
class ForgeArtifacts:
|
| 26 |
+
run_dir: Path
|
| 27 |
+
analysis: Analysis
|
| 28 |
+
suite: GeneratedSuite
|
| 29 |
+
green: RunResult
|
| 30 |
+
mutation: MutationScore
|
| 31 |
+
patch_path: Path
|
| 32 |
+
logs: tuple[str, ...]
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def forge_legacy_repo(
|
| 36 |
+
max_cases_per_function: int = 4,
|
| 37 |
+
mutations: tuple[Mutation, ...] = MUTATIONS,
|
| 38 |
+
) -> ForgeArtifacts:
|
| 39 |
+
"""Run the deterministic TestForge pipeline for the bundled sample."""
|
| 40 |
+
run_dir = _fresh_run_dir()
|
| 41 |
+
logs: list[str] = []
|
| 42 |
+
|
| 43 |
+
logs.append("Target: bundled legacy_repo with zero tests")
|
| 44 |
+
reset_sample(SAMPLES_ROOT, run_dir)
|
| 45 |
+
|
| 46 |
+
analysis = analyze(run_dir / "samples" / "legacy_repo", package="legacy_repo")
|
| 47 |
+
logs.append(f"Analyzed {len(analysis.functions)} public functions")
|
| 48 |
+
for fn in analysis.functions:
|
| 49 |
+
logs.append(f" - {fn.module}.{fn.qualname}({', '.join(fn.parameters)})")
|
| 50 |
+
|
| 51 |
+
suite = generate_suite(analysis, max_cases_per_function=max_cases_per_function)
|
| 52 |
+
write_suite(suite, run_dir)
|
| 53 |
+
logs.append(f"Captured {suite.assertion_count} behaviors into {len(suite.files)} test files")
|
| 54 |
+
|
| 55 |
+
green = run_pytest(run_dir, package_parent=run_dir / "samples")
|
| 56 |
+
logs.append(f"Suite run: {green.summary}")
|
| 57 |
+
if green.coverage is not None:
|
| 58 |
+
logs.append(f"Coverage: 0% -> {green.coverage:.0f}%")
|
| 59 |
+
else:
|
| 60 |
+
logs.append("Coverage: pytest-cov unavailable locally; suite still ran green")
|
| 61 |
+
|
| 62 |
+
mutation = mutation_score(run_dir / "samples", suite, run_dir / "mutants", mutations=mutations)
|
| 63 |
+
logs.append(f"Mutation score: {mutation.headline()} ({mutation.percent:.1f}%)")
|
| 64 |
+
for survived in mutation.survived:
|
| 65 |
+
logs.append(f" survived: {survived.label}")
|
| 66 |
+
|
| 67 |
+
patch_path = write_patch(suite, run_dir / "testforge.patch")
|
| 68 |
+
logs.append(f"Patch ready: {patch_path.name}")
|
| 69 |
+
|
| 70 |
+
return ForgeArtifacts(
|
| 71 |
+
run_dir=run_dir,
|
| 72 |
+
analysis=analysis,
|
| 73 |
+
suite=suite,
|
| 74 |
+
green=green,
|
| 75 |
+
mutation=mutation,
|
| 76 |
+
patch_path=patch_path,
|
| 77 |
+
logs=tuple(logs),
|
| 78 |
+
)
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def _fresh_run_dir() -> Path:
|
| 82 |
+
RUNS_ROOT.mkdir(parents=True, exist_ok=True)
|
| 83 |
+
path = Path(tempfile.mkdtemp(prefix="testforge_", dir=RUNS_ROOT))
|
| 84 |
+
if path.exists():
|
| 85 |
+
shutil.rmtree(path)
|
| 86 |
+
path.mkdir(parents=True)
|
| 87 |
+
return path
|
analyzer.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""AST-based discovery for small Python packages."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import ast
|
| 6 |
+
from dataclasses import dataclass
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
@dataclass(frozen=True)
|
| 11 |
+
class FunctionInfo:
|
| 12 |
+
"""Metadata needed to generate characterization tests."""
|
| 13 |
+
|
| 14 |
+
module: str
|
| 15 |
+
qualname: str
|
| 16 |
+
name: str
|
| 17 |
+
parameters: tuple[str, ...]
|
| 18 |
+
type_hints: dict[str, str]
|
| 19 |
+
return_hint: str | None
|
| 20 |
+
docstring: str
|
| 21 |
+
source: str
|
| 22 |
+
file_path: str
|
| 23 |
+
lineno: int
|
| 24 |
+
|
| 25 |
+
@property
|
| 26 |
+
def import_name(self) -> str:
|
| 27 |
+
return self.qualname.split(".")[-1]
|
| 28 |
+
|
| 29 |
+
@property
|
| 30 |
+
def arity(self) -> int:
|
| 31 |
+
return len(self.parameters)
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
@dataclass(frozen=True)
|
| 35 |
+
class Analysis:
|
| 36 |
+
"""Public API discovered in a target package."""
|
| 37 |
+
|
| 38 |
+
root: Path
|
| 39 |
+
package: str
|
| 40 |
+
functions: tuple[FunctionInfo, ...]
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def analyze(root: str | Path, package: str | None = None) -> Analysis:
|
| 44 |
+
"""Discover public top-level functions and public class methods."""
|
| 45 |
+
root_path = Path(root).resolve()
|
| 46 |
+
if not root_path.exists():
|
| 47 |
+
raise FileNotFoundError(root_path)
|
| 48 |
+
|
| 49 |
+
package_name = package or root_path.name
|
| 50 |
+
functions: list[FunctionInfo] = []
|
| 51 |
+
for file_path in sorted(root_path.rglob("*.py")):
|
| 52 |
+
if file_path.name == "__init__.py":
|
| 53 |
+
continue
|
| 54 |
+
module = _module_name(root_path, package_name, file_path)
|
| 55 |
+
source = file_path.read_text(encoding="utf-8")
|
| 56 |
+
tree = ast.parse(source, filename=str(file_path))
|
| 57 |
+
lines = source.splitlines()
|
| 58 |
+
functions.extend(_top_level_functions(tree, module, file_path, lines))
|
| 59 |
+
functions.extend(_class_methods(tree, module, file_path, lines))
|
| 60 |
+
|
| 61 |
+
return Analysis(root=root_path, package=package_name, functions=tuple(functions))
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def _module_name(root: Path, package: str, file_path: Path) -> str:
|
| 65 |
+
relative = file_path.relative_to(root).with_suffix("")
|
| 66 |
+
parts = (package, *relative.parts)
|
| 67 |
+
return ".".join(parts)
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def _top_level_functions(
|
| 71 |
+
tree: ast.Module, module: str, file_path: Path, lines: list[str]
|
| 72 |
+
) -> list[FunctionInfo]:
|
| 73 |
+
found: list[FunctionInfo] = []
|
| 74 |
+
for node in tree.body:
|
| 75 |
+
if isinstance(node, ast.FunctionDef) and _is_public(node.name):
|
| 76 |
+
found.append(_function_info(node, module, node.name, file_path, lines))
|
| 77 |
+
return found
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def _class_methods(
|
| 81 |
+
tree: ast.Module, module: str, file_path: Path, lines: list[str]
|
| 82 |
+
) -> list[FunctionInfo]:
|
| 83 |
+
found: list[FunctionInfo] = []
|
| 84 |
+
for class_node in tree.body:
|
| 85 |
+
if not isinstance(class_node, ast.ClassDef) or not _is_public(class_node.name):
|
| 86 |
+
continue
|
| 87 |
+
for node in class_node.body:
|
| 88 |
+
if isinstance(node, ast.FunctionDef) and _is_public(node.name):
|
| 89 |
+
qualname = f"{class_node.name}.{node.name}"
|
| 90 |
+
found.append(_function_info(node, module, qualname, file_path, lines))
|
| 91 |
+
return found
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def _function_info(
|
| 95 |
+
node: ast.FunctionDef, module: str, qualname: str, file_path: Path, lines: list[str]
|
| 96 |
+
) -> FunctionInfo:
|
| 97 |
+
parameters: list[str] = []
|
| 98 |
+
hints: dict[str, str] = {}
|
| 99 |
+
for arg in node.args.args:
|
| 100 |
+
if arg.arg in {"self", "cls"}:
|
| 101 |
+
continue
|
| 102 |
+
parameters.append(arg.arg)
|
| 103 |
+
if arg.annotation is not None:
|
| 104 |
+
hints[arg.arg] = ast.unparse(arg.annotation)
|
| 105 |
+
|
| 106 |
+
source = _source_segment(node, lines)
|
| 107 |
+
return FunctionInfo(
|
| 108 |
+
module=module,
|
| 109 |
+
qualname=qualname,
|
| 110 |
+
name=qualname,
|
| 111 |
+
parameters=tuple(parameters),
|
| 112 |
+
type_hints=hints,
|
| 113 |
+
return_hint=ast.unparse(node.returns) if node.returns is not None else None,
|
| 114 |
+
docstring=ast.get_docstring(node) or "",
|
| 115 |
+
source=source,
|
| 116 |
+
file_path=str(file_path),
|
| 117 |
+
lineno=node.lineno,
|
| 118 |
+
)
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
def _source_segment(node: ast.AST, lines: list[str]) -> str:
|
| 122 |
+
end = getattr(node, "end_lineno", node.lineno)
|
| 123 |
+
return "\n".join(lines[node.lineno - 1 : end])
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
def _is_public(name: str) -> bool:
|
| 127 |
+
return not name.startswith("_")
|
| 128 |
+
|
app.py
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
|
| 5 |
+
import gradio as gr
|
| 6 |
+
|
| 7 |
+
from agent import forge_legacy_repo
|
| 8 |
+
from inject import reset_sample, run_injected_suite
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
LAST_RUN: dict[str, Path] = {}
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
CSS = """
|
| 15 |
+
:root {
|
| 16 |
+
--tf-ink: #171717;
|
| 17 |
+
--tf-muted: #595959;
|
| 18 |
+
--tf-line: #d6d3d1;
|
| 19 |
+
--tf-panel: #fafaf9;
|
| 20 |
+
--tf-accent: #0f766e;
|
| 21 |
+
--tf-warn: #b45309;
|
| 22 |
+
}
|
| 23 |
+
.gradio-container {
|
| 24 |
+
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
| 25 |
+
color: var(--tf-ink);
|
| 26 |
+
}
|
| 27 |
+
.tf-title h1 {
|
| 28 |
+
font-size: 34px;
|
| 29 |
+
line-height: 1.1;
|
| 30 |
+
letter-spacing: 0;
|
| 31 |
+
margin-bottom: 4px;
|
| 32 |
+
}
|
| 33 |
+
.tf-title p {
|
| 34 |
+
color: var(--tf-muted);
|
| 35 |
+
margin-top: 0;
|
| 36 |
+
}
|
| 37 |
+
.tf-metric {
|
| 38 |
+
border: 1px solid var(--tf-line);
|
| 39 |
+
background: var(--tf-panel);
|
| 40 |
+
border-radius: 8px;
|
| 41 |
+
padding: 14px;
|
| 42 |
+
}
|
| 43 |
+
"""
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def forge(use_model: bool, max_cases: int):
|
| 47 |
+
del use_model # deterministic backbone is the demo path.
|
| 48 |
+
artifacts = forge_legacy_repo(max_cases_per_function=max_cases)
|
| 49 |
+
LAST_RUN["run_dir"] = artifacts.run_dir
|
| 50 |
+
log = "\n".join(artifacts.logs)
|
| 51 |
+
preview = _suite_preview(artifacts.suite.files)
|
| 52 |
+
result_md = _result_markdown(artifacts)
|
| 53 |
+
return log, preview, result_md, str(artifacts.patch_path), gr.update(interactive=True)
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def inject_regression():
|
| 57 |
+
run_dir = LAST_RUN.get("run_dir")
|
| 58 |
+
if run_dir is None:
|
| 59 |
+
return "Forge tests first.", "No run yet."
|
| 60 |
+
|
| 61 |
+
reset_sample(Path(__file__).resolve().parent / "samples", run_dir)
|
| 62 |
+
result = run_injected_suite(run_dir)
|
| 63 |
+
status = "caught" if not result.run.ok else "survived"
|
| 64 |
+
summary = (
|
| 65 |
+
f"Injected: {result.mutation_label}\n"
|
| 66 |
+
f"Result: {result.run.summary} ({status})\n\n"
|
| 67 |
+
f"{_first_failure(result.run.stdout)}"
|
| 68 |
+
)
|
| 69 |
+
return summary, result.run.stdout[-4000:]
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def _suite_preview(files: dict[str, str]) -> str:
|
| 73 |
+
parts: list[str] = []
|
| 74 |
+
for name, content in sorted(files.items()):
|
| 75 |
+
parts.append(f"# {name}\n{content}")
|
| 76 |
+
return "\n\n".join(parts)
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def _result_markdown(artifacts) -> str:
|
| 80 |
+
survived = artifacts.mutation.survived
|
| 81 |
+
survived_text = "\n".join(f"- {item.label}" for item in survived) or "- None"
|
| 82 |
+
coverage = "not installed locally" if artifacts.green.coverage is None else f"{artifacts.green.coverage:.0f}%"
|
| 83 |
+
return f"""
|
| 84 |
+
### Forge Result
|
| 85 |
+
|
| 86 |
+
**Suite:** {artifacts.green.summary}
|
| 87 |
+
**Coverage:** 0% -> {coverage}
|
| 88 |
+
**Mutation score:** {artifacts.mutation.headline()} ({artifacts.mutation.percent:.1f}%)
|
| 89 |
+
|
| 90 |
+
**Surviving mutants**
|
| 91 |
+
{survived_text}
|
| 92 |
+
"""
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
def _first_failure(output: str) -> str:
|
| 96 |
+
lines = output.splitlines()
|
| 97 |
+
for index, line in enumerate(lines):
|
| 98 |
+
if line.startswith("FAILED ") or "E AssertionError" in line:
|
| 99 |
+
return "\n".join(lines[max(0, index - 4) : index + 10])
|
| 100 |
+
return "\n".join(lines[-20:])
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
with gr.Blocks(title="TestForge Characterization Lab") as demo:
|
| 104 |
+
gr.HTML(
|
| 105 |
+
"""
|
| 106 |
+
<div class="tf-title">
|
| 107 |
+
<h1>TestForge -- Characterization Lab</h1>
|
| 108 |
+
<p>Freeze what legacy Python code does today. Refactor without fear.</p>
|
| 109 |
+
</div>
|
| 110 |
+
"""
|
| 111 |
+
)
|
| 112 |
+
with gr.Row():
|
| 113 |
+
target = gr.Dropdown(
|
| 114 |
+
["bundled legacy_repo"],
|
| 115 |
+
value="bundled legacy_repo",
|
| 116 |
+
label="Target",
|
| 117 |
+
interactive=False,
|
| 118 |
+
)
|
| 119 |
+
use_model = gr.Checkbox(False, label="Use small coder model")
|
| 120 |
+
max_cases = gr.Slider(2, 4, value=4, step=1, label="Cases per function")
|
| 121 |
+
forge_btn = gr.Button("Forge Tests", variant="primary")
|
| 122 |
+
|
| 123 |
+
with gr.Row():
|
| 124 |
+
log = gr.Textbox(label="Agent log", lines=14)
|
| 125 |
+
results = gr.Markdown(label="Results")
|
| 126 |
+
|
| 127 |
+
tests_preview = gr.Code(label="Generated tests", language="python", lines=18)
|
| 128 |
+
|
| 129 |
+
with gr.Row():
|
| 130 |
+
inject_btn = gr.Button("Inject a regression", interactive=False)
|
| 131 |
+
patch_file = gr.File(label="Download PR patch")
|
| 132 |
+
|
| 133 |
+
with gr.Row():
|
| 134 |
+
inject_summary = gr.Textbox(label="Inject result", lines=8)
|
| 135 |
+
inject_output = gr.Textbox(label="Pytest output", lines=8)
|
| 136 |
+
|
| 137 |
+
forge_btn.click(
|
| 138 |
+
forge,
|
| 139 |
+
inputs=[use_model, max_cases],
|
| 140 |
+
outputs=[log, tests_preview, results, patch_file, inject_btn],
|
| 141 |
+
)
|
| 142 |
+
inject_btn.click(inject_regression, outputs=[inject_summary, inject_output])
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
if __name__ == "__main__":
|
| 146 |
+
demo.launch(
|
| 147 |
+
server_name="0.0.0.0",
|
| 148 |
+
server_port=7860,
|
| 149 |
+
prevent_thread_lock=False,
|
| 150 |
+
inbrowser=False,
|
| 151 |
+
show_error=True,
|
| 152 |
+
quiet=False,
|
| 153 |
+
css=CSS,
|
| 154 |
+
)
|
generator.py
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Deterministic capture-then-assert test generation."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import importlib
|
| 6 |
+
import itertools
|
| 7 |
+
import reprlib
|
| 8 |
+
import sys
|
| 9 |
+
from dataclasses import dataclass
|
| 10 |
+
from datetime import date
|
| 11 |
+
from pathlib import Path
|
| 12 |
+
from typing import Any
|
| 13 |
+
|
| 14 |
+
from analyzer import Analysis, FunctionInfo
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
@dataclass(frozen=True)
|
| 18 |
+
class CapturedCase:
|
| 19 |
+
"""One executed behavior captured from the current code."""
|
| 20 |
+
|
| 21 |
+
args: tuple[Any, ...]
|
| 22 |
+
outcome: str
|
| 23 |
+
value_repr: str | None = None
|
| 24 |
+
exception_type: str | None = None
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
@dataclass(frozen=True)
|
| 28 |
+
class GeneratedSuite:
|
| 29 |
+
"""Rendered tests and source capture metadata."""
|
| 30 |
+
|
| 31 |
+
files: dict[str, str]
|
| 32 |
+
cases_by_function: dict[str, list[CapturedCase]]
|
| 33 |
+
assertion_count: int
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def generate_suite(analysis: Analysis, max_cases_per_function: int = 4) -> GeneratedSuite:
|
| 37 |
+
"""Generate a green characterization suite by executing current behavior."""
|
| 38 |
+
_ensure_import_root(analysis.root.parent)
|
| 39 |
+
files: dict[str, str] = {}
|
| 40 |
+
cases_by_function: dict[str, list[CapturedCase]] = {}
|
| 41 |
+
for fn in analysis.functions:
|
| 42 |
+
candidates = deterministic_inputs(fn, max_cases=max_cases_per_function)
|
| 43 |
+
cases = capture(fn, candidates)
|
| 44 |
+
if not cases:
|
| 45 |
+
continue
|
| 46 |
+
cases_by_function[f"{fn.module}.{fn.qualname}"] = cases
|
| 47 |
+
file_name = f"tests/test_{_safe_name(fn.module)}_{_safe_name(fn.qualname)}.py"
|
| 48 |
+
files[file_name] = render_test_file(fn, cases)
|
| 49 |
+
|
| 50 |
+
assertion_count = sum(len(cases) for cases in cases_by_function.values())
|
| 51 |
+
return GeneratedSuite(files=files, cases_by_function=cases_by_function, assertion_count=assertion_count)
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def deterministic_inputs(fn: FunctionInfo, max_cases: int = 4) -> list[tuple[Any, ...]]:
|
| 55 |
+
"""Infer a compact set of inputs from type hints and parameter names."""
|
| 56 |
+
if fn.arity == 0:
|
| 57 |
+
return [()]
|
| 58 |
+
|
| 59 |
+
choices = [_values_for_parameter(name, fn.type_hints.get(name, "")) for name in fn.parameters]
|
| 60 |
+
cases = list(itertools.islice(itertools.product(*choices), max_cases))
|
| 61 |
+
return [tuple(case) for case in cases]
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def capture(fn: FunctionInfo, cases: list[tuple[Any, ...]]) -> list[CapturedCase]:
|
| 65 |
+
"""Execute a function on candidate inputs and record output or exception."""
|
| 66 |
+
callable_obj = _load_callable(fn)
|
| 67 |
+
captured: list[CapturedCase] = []
|
| 68 |
+
for args in cases:
|
| 69 |
+
if len(args) != fn.arity:
|
| 70 |
+
continue
|
| 71 |
+
try:
|
| 72 |
+
value = callable_obj(*args)
|
| 73 |
+
except Exception as exc: # noqa: BLE001 - exceptions are the behavior being captured.
|
| 74 |
+
captured.append(
|
| 75 |
+
CapturedCase(args=args, outcome="exception", exception_type=type(exc).__name__)
|
| 76 |
+
)
|
| 77 |
+
else:
|
| 78 |
+
captured.append(CapturedCase(args=args, outcome="return", value_repr=_literal(value)))
|
| 79 |
+
return captured
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def render_test_file(fn: FunctionInfo, cases: list[CapturedCase]) -> str:
|
| 83 |
+
"""Render a pytest module for captured behavior."""
|
| 84 |
+
needs_pytest = any(case.outcome == "exception" for case in cases)
|
| 85 |
+
imports = ["from datetime import date"]
|
| 86 |
+
if needs_pytest:
|
| 87 |
+
imports.append("import pytest")
|
| 88 |
+
imports.append(f"from {fn.module} import {fn.import_name}")
|
| 89 |
+
|
| 90 |
+
body: list[str] = []
|
| 91 |
+
for index, case in enumerate(cases, start=1):
|
| 92 |
+
test_name = f"test_{_safe_name(fn.qualname)}_case_{index}"
|
| 93 |
+
body.append("")
|
| 94 |
+
body.append(f"def {test_name}():")
|
| 95 |
+
args = ", ".join(_literal(arg) for arg in case.args)
|
| 96 |
+
if case.outcome == "exception":
|
| 97 |
+
body.append(f" with pytest.raises({case.exception_type}):")
|
| 98 |
+
body.append(f" {fn.import_name}({args})")
|
| 99 |
+
else:
|
| 100 |
+
body.append(f" assert {fn.import_name}({args}) == {case.value_repr}")
|
| 101 |
+
|
| 102 |
+
return "\n".join(imports + body) + "\n"
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
def write_suite(suite: GeneratedSuite, target_dir: str | Path) -> Path:
|
| 106 |
+
"""Write generated test files under target_dir and return that path."""
|
| 107 |
+
root = Path(target_dir)
|
| 108 |
+
for relative, content in suite.files.items():
|
| 109 |
+
path = root / relative
|
| 110 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 111 |
+
path.write_text(content, encoding="utf-8")
|
| 112 |
+
return root
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
def _ensure_import_root(path: Path) -> None:
|
| 116 |
+
text = str(path)
|
| 117 |
+
if text not in sys.path:
|
| 118 |
+
sys.path.insert(0, text)
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
def _load_callable(fn: FunctionInfo):
|
| 122 |
+
module = importlib.import_module(fn.module)
|
| 123 |
+
obj = module
|
| 124 |
+
for part in fn.qualname.split("."):
|
| 125 |
+
obj = getattr(obj, part)
|
| 126 |
+
return obj
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
def _values_for_parameter(name: str, hint: str) -> list[Any]:
|
| 130 |
+
hint = hint.replace("typing.", "")
|
| 131 |
+
lower_name = name.lower()
|
| 132 |
+
if hint in {"int"}:
|
| 133 |
+
if lower_name in {"qty", "quantity"}:
|
| 134 |
+
return [1, 10, 0, -1]
|
| 135 |
+
if lower_name in {"n", "limit", "length"}:
|
| 136 |
+
return [5, 3, 0, -1]
|
| 137 |
+
return [0, 1, -1, 2]
|
| 138 |
+
if hint in {"float"}:
|
| 139 |
+
if lower_name in {"pct", "rate"}:
|
| 140 |
+
return [10.0, 0.0, 100.0, -1.0]
|
| 141 |
+
return [100.0, 0.0, 12.5, -2.0]
|
| 142 |
+
if hint in {"str"}:
|
| 143 |
+
return ["Hello, World!", " Already clean ", "", "A/B test"]
|
| 144 |
+
if hint in {"bool"}:
|
| 145 |
+
return [True, False]
|
| 146 |
+
if hint in {"date", "datetime.date"}:
|
| 147 |
+
return [date(2026, 6, 15), date(2026, 6, 14), date(2024, 2, 29)]
|
| 148 |
+
if hint.startswith("list"):
|
| 149 |
+
if "tuple" in hint or lower_name == "lines":
|
| 150 |
+
return [[(2, 10.0), (1, 5.5)], [], [(0, 99.0)]]
|
| 151 |
+
return [[], [1], [1, 2, 3]]
|
| 152 |
+
return [1, "x", 0]
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
def _literal(value: Any) -> str:
|
| 156 |
+
if isinstance(value, date):
|
| 157 |
+
return f"date({value.year}, {value.month}, {value.day})"
|
| 158 |
+
text = repr(value)
|
| 159 |
+
if len(text) > 120:
|
| 160 |
+
return reprlib.repr(value)
|
| 161 |
+
return text
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
def _safe_name(name: str) -> str:
|
| 165 |
+
return "".join(ch if ch.isalnum() else "_" for ch in name).strip("_").lower()
|
| 166 |
+
|
inject.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""One-click live regression demo."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import shutil
|
| 6 |
+
from dataclasses import dataclass
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
|
| 9 |
+
from mutator import MUTATIONS, apply_single_mutation
|
| 10 |
+
from runner import RunResult, run_pytest
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
INJECT_MUTATION_ID = "bulk_threshold_ge_to_gt"
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
@dataclass(frozen=True)
|
| 17 |
+
class InjectResult:
|
| 18 |
+
mutation_label: str
|
| 19 |
+
run: RunResult
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def reset_sample(source_parent: str | Path, run_dir: str | Path) -> Path:
|
| 23 |
+
"""Copy samples into a runnable demo directory."""
|
| 24 |
+
source_parent = Path(source_parent).resolve()
|
| 25 |
+
run_dir = Path(run_dir).resolve()
|
| 26 |
+
sample_target = run_dir / "samples"
|
| 27 |
+
if sample_target.exists():
|
| 28 |
+
shutil.rmtree(sample_target)
|
| 29 |
+
shutil.copytree(source_parent, sample_target)
|
| 30 |
+
return sample_target / "legacy_repo"
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def apply(run_dir: str | Path, package_name: str = "legacy_repo") -> bool:
|
| 34 |
+
package_root = Path(run_dir).resolve() / "samples" / package_name
|
| 35 |
+
return apply_single_mutation(package_root, INJECT_MUTATION_ID)
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def run_injected_suite(run_dir: str | Path, package_name: str = "legacy_repo") -> InjectResult:
|
| 39 |
+
mutation = next(item for item in MUTATIONS if item.id == INJECT_MUTATION_ID)
|
| 40 |
+
applied = apply(run_dir, package_name=package_name)
|
| 41 |
+
if not applied:
|
| 42 |
+
raise RuntimeError(f"Could not apply inject mutation: {mutation.label}")
|
| 43 |
+
run_dir = Path(run_dir).resolve()
|
| 44 |
+
result = run_pytest(run_dir, package_parent=run_dir / "samples", package_name=package_name)
|
| 45 |
+
return InjectResult(mutation_label=mutation.label, run=result)
|
mutator.py
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Fast hand-rolled mutation scoring for the bundled legacy sample."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import shutil
|
| 6 |
+
from dataclasses import dataclass
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
|
| 9 |
+
from generator import GeneratedSuite, write_suite
|
| 10 |
+
from runner import RunResult, run_pytest
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
@dataclass(frozen=True)
|
| 14 |
+
class Mutation:
|
| 15 |
+
id: str
|
| 16 |
+
file: str
|
| 17 |
+
find: str
|
| 18 |
+
replace: str
|
| 19 |
+
label: str
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
@dataclass(frozen=True)
|
| 23 |
+
class MutationResult:
|
| 24 |
+
mutation: Mutation
|
| 25 |
+
killed: bool
|
| 26 |
+
run: RunResult
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
@dataclass(frozen=True)
|
| 30 |
+
class MutationScore:
|
| 31 |
+
killed: int
|
| 32 |
+
total: int
|
| 33 |
+
results: tuple[MutationResult, ...]
|
| 34 |
+
|
| 35 |
+
@property
|
| 36 |
+
def percent(self) -> float:
|
| 37 |
+
return 0.0 if self.total == 0 else round(self.killed / self.total * 100, 1)
|
| 38 |
+
|
| 39 |
+
@property
|
| 40 |
+
def survived(self) -> list[Mutation]:
|
| 41 |
+
return [result.mutation for result in self.results if not result.killed]
|
| 42 |
+
|
| 43 |
+
def headline(self) -> str:
|
| 44 |
+
return f"{self.killed}/{self.total} behavior changes detected"
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
MUTATIONS: tuple[Mutation, ...] = (
|
| 48 |
+
Mutation(
|
| 49 |
+
id="bulk_multiply_to_divide",
|
| 50 |
+
file="pricing.py",
|
| 51 |
+
find="return round(unit * qty * discount, 2)",
|
| 52 |
+
replace="return round(unit / qty * discount, 2)",
|
| 53 |
+
label="pricing.py bulk_price * -> /",
|
| 54 |
+
),
|
| 55 |
+
Mutation(
|
| 56 |
+
id="discount_minus_to_plus",
|
| 57 |
+
file="pricing.py",
|
| 58 |
+
find="return round(price * (1 - pct / 100), 2)",
|
| 59 |
+
replace="return round(price * (1 + pct / 100), 2)",
|
| 60 |
+
label="pricing.py apply_discount - -> +",
|
| 61 |
+
),
|
| 62 |
+
Mutation(
|
| 63 |
+
id="tax_plus_to_minus",
|
| 64 |
+
file="pricing.py",
|
| 65 |
+
find="return round(amount * (1 + rate / 100), 2)",
|
| 66 |
+
replace="return round(amount * (1 - rate / 100), 2)",
|
| 67 |
+
label="pricing.py with_tax + -> -",
|
| 68 |
+
),
|
| 69 |
+
Mutation(
|
| 70 |
+
id="line_multiply_to_plus",
|
| 71 |
+
file="invoice.py",
|
| 72 |
+
find="return round(qty * unit, 2)",
|
| 73 |
+
replace="return round(qty + unit, 2)",
|
| 74 |
+
label="invoice.py line_total * -> +",
|
| 75 |
+
),
|
| 76 |
+
Mutation(
|
| 77 |
+
id="weekend_ge_to_gt",
|
| 78 |
+
file="dates.py",
|
| 79 |
+
find="return d.weekday() >= 5",
|
| 80 |
+
replace="return d.weekday() > 5",
|
| 81 |
+
label="dates.py is_weekend >= -> >",
|
| 82 |
+
),
|
| 83 |
+
Mutation(
|
| 84 |
+
id="days_abs_removed",
|
| 85 |
+
file="dates.py",
|
| 86 |
+
find="return abs((b - a).days)",
|
| 87 |
+
replace="return (b - a).days",
|
| 88 |
+
label="dates.py days_between remove abs",
|
| 89 |
+
),
|
| 90 |
+
Mutation(
|
| 91 |
+
id="slug_strip_removed",
|
| 92 |
+
file="slugify.py",
|
| 93 |
+
find="return cleaned.strip(\"-\")",
|
| 94 |
+
replace="return cleaned",
|
| 95 |
+
label="slugify.py strip removed",
|
| 96 |
+
),
|
| 97 |
+
Mutation(
|
| 98 |
+
id="truncate_len_lt",
|
| 99 |
+
file="slugify.py",
|
| 100 |
+
find="if len(s) <= n:",
|
| 101 |
+
replace="if len(s) < n:",
|
| 102 |
+
label="slugify.py <= -> <",
|
| 103 |
+
),
|
| 104 |
+
Mutation(
|
| 105 |
+
id="bulk_threshold_ge_to_gt",
|
| 106 |
+
file="pricing.py",
|
| 107 |
+
find="discount = 0.9 if qty >= 10 else 1.0",
|
| 108 |
+
replace="discount = 0.9 if qty > 10 else 1.0",
|
| 109 |
+
label="pricing.py bulk_price >= -> >",
|
| 110 |
+
),
|
| 111 |
+
)
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
def mutation_score(
|
| 115 |
+
source_parent: str | Path,
|
| 116 |
+
suite: GeneratedSuite,
|
| 117 |
+
tmp_root: str | Path,
|
| 118 |
+
package_name: str = "legacy_repo",
|
| 119 |
+
mutations: tuple[Mutation, ...] = MUTATIONS,
|
| 120 |
+
) -> MutationScore:
|
| 121 |
+
"""Apply each mutation to a copy and run the generated suite."""
|
| 122 |
+
source_parent = Path(source_parent).resolve()
|
| 123 |
+
tmp_root = Path(tmp_root).resolve()
|
| 124 |
+
tmp_root.mkdir(parents=True, exist_ok=True)
|
| 125 |
+
results: list[MutationResult] = []
|
| 126 |
+
|
| 127 |
+
for index, mutation in enumerate(mutations, start=1):
|
| 128 |
+
mutant_dir = tmp_root / f"mutant_{index}_{mutation.id}"
|
| 129 |
+
if mutant_dir.exists():
|
| 130 |
+
shutil.rmtree(mutant_dir)
|
| 131 |
+
shutil.copytree(source_parent, mutant_dir / "samples")
|
| 132 |
+
write_suite(suite, mutant_dir)
|
| 133 |
+
applied = _apply_mutation(mutant_dir / "samples" / package_name, mutation)
|
| 134 |
+
if not applied:
|
| 135 |
+
run = RunResult(
|
| 136 |
+
ok=False,
|
| 137 |
+
passed=0,
|
| 138 |
+
failed=0,
|
| 139 |
+
errors=1,
|
| 140 |
+
coverage=None,
|
| 141 |
+
stdout="",
|
| 142 |
+
stderr=f"Mutation not applied: {mutation.label}",
|
| 143 |
+
returncode=2,
|
| 144 |
+
)
|
| 145 |
+
results.append(MutationResult(mutation=mutation, killed=False, run=run))
|
| 146 |
+
continue
|
| 147 |
+
run = run_pytest(mutant_dir, package_parent=mutant_dir / "samples", package_name=package_name)
|
| 148 |
+
results.append(MutationResult(mutation=mutation, killed=not run.ok, run=run))
|
| 149 |
+
|
| 150 |
+
killed = sum(1 for result in results if result.killed)
|
| 151 |
+
return MutationScore(killed=killed, total=len(results), results=tuple(results))
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
def apply_single_mutation(package_root: str | Path, mutation_id: str) -> bool:
|
| 155 |
+
"""Apply one catalog mutation in-place for the live Inject button."""
|
| 156 |
+
mutation = next(item for item in MUTATIONS if item.id == mutation_id)
|
| 157 |
+
return _apply_mutation(Path(package_root), mutation)
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
def _apply_mutation(package_root: Path, mutation: Mutation) -> bool:
|
| 161 |
+
path = package_root / mutation.file
|
| 162 |
+
text = path.read_text(encoding="utf-8")
|
| 163 |
+
if mutation.find not in text:
|
| 164 |
+
return False
|
| 165 |
+
path.write_text(text.replace(mutation.find, mutation.replace, 1), encoding="utf-8")
|
| 166 |
+
return True
|
patch.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""PR-style patch export for generated tests."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import difflib
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
|
| 8 |
+
from generator import GeneratedSuite
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def make_pr_patch(suite: GeneratedSuite) -> str:
|
| 12 |
+
"""Return a unified diff that adds the generated tests and a CI hint."""
|
| 13 |
+
sections: list[str] = []
|
| 14 |
+
for relative, content in sorted(suite.files.items()):
|
| 15 |
+
new_lines = content.splitlines(keepends=True)
|
| 16 |
+
diff = difflib.unified_diff(
|
| 17 |
+
[],
|
| 18 |
+
new_lines,
|
| 19 |
+
fromfile="/dev/null",
|
| 20 |
+
tofile=f"b/{relative}",
|
| 21 |
+
lineterm="",
|
| 22 |
+
)
|
| 23 |
+
sections.append("\n".join(diff))
|
| 24 |
+
|
| 25 |
+
ci_hint = "pytest tests -q\n"
|
| 26 |
+
sections.append(
|
| 27 |
+
"\n".join(
|
| 28 |
+
difflib.unified_diff(
|
| 29 |
+
[],
|
| 30 |
+
[ci_hint],
|
| 31 |
+
fromfile="/dev/null",
|
| 32 |
+
tofile="b/TESTFORGE_CI_HINT.txt",
|
| 33 |
+
lineterm="",
|
| 34 |
+
)
|
| 35 |
+
)
|
| 36 |
+
)
|
| 37 |
+
return "\n\n".join(sections) + "\n"
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def write_patch(suite: GeneratedSuite, path: str | Path) -> Path:
|
| 41 |
+
output = Path(path)
|
| 42 |
+
output.parent.mkdir(parents=True, exist_ok=True)
|
| 43 |
+
output.write_text(make_pr_patch(suite), encoding="utf-8")
|
| 44 |
+
return output
|
| 45 |
+
|
pytest.ini
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[pytest]
|
| 2 |
+
addopts = --basetemp=.pytest-tmp
|
| 3 |
+
testpaths = tests
|
requirements.txt
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
gradio>=4.44
|
| 2 |
+
pytest>=8.0
|
| 3 |
+
pytest-cov>=5.0
|
| 4 |
+
|
runner.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Subprocess pytest and coverage runner."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import re
|
| 6 |
+
import subprocess
|
| 7 |
+
import sys
|
| 8 |
+
from dataclasses import dataclass
|
| 9 |
+
from importlib.util import find_spec
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
@dataclass(frozen=True)
|
| 14 |
+
class RunResult:
|
| 15 |
+
ok: bool
|
| 16 |
+
passed: int
|
| 17 |
+
failed: int
|
| 18 |
+
errors: int
|
| 19 |
+
coverage: float | None
|
| 20 |
+
stdout: str
|
| 21 |
+
stderr: str
|
| 22 |
+
returncode: int
|
| 23 |
+
|
| 24 |
+
@property
|
| 25 |
+
def summary(self) -> str:
|
| 26 |
+
parts = []
|
| 27 |
+
if self.passed:
|
| 28 |
+
parts.append(f"{self.passed} passed")
|
| 29 |
+
if self.failed:
|
| 30 |
+
parts.append(f"{self.failed} failed")
|
| 31 |
+
if self.errors:
|
| 32 |
+
parts.append(f"{self.errors} errors")
|
| 33 |
+
return ", ".join(parts) or "no tests"
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def run_pytest(
|
| 37 |
+
workdir: str | Path,
|
| 38 |
+
package_parent: str | Path | None = None,
|
| 39 |
+
timeout: int = 30,
|
| 40 |
+
with_coverage: bool = True,
|
| 41 |
+
package_name: str = "legacy_repo",
|
| 42 |
+
) -> RunResult:
|
| 43 |
+
"""Run pytest in a subprocess and parse pass/fail/error counts."""
|
| 44 |
+
cwd = Path(workdir).resolve()
|
| 45 |
+
command = [sys.executable, "-m", "pytest", "tests", "-q"]
|
| 46 |
+
coverage_available = find_spec("pytest_cov") is not None
|
| 47 |
+
if with_coverage and coverage_available:
|
| 48 |
+
command.extend([f"--cov={package_name}", "--cov-report=term-missing"])
|
| 49 |
+
|
| 50 |
+
env = None
|
| 51 |
+
if package_parent is not None:
|
| 52 |
+
env = dict(__import__("os").environ)
|
| 53 |
+
parent = str(Path(package_parent).resolve())
|
| 54 |
+
current = env.get("PYTHONPATH", "")
|
| 55 |
+
env["PYTHONPATH"] = parent if not current else f"{parent}{__import__('os').pathsep}{current}"
|
| 56 |
+
|
| 57 |
+
try:
|
| 58 |
+
completed = subprocess.run(
|
| 59 |
+
command,
|
| 60 |
+
cwd=cwd,
|
| 61 |
+
env=env,
|
| 62 |
+
text=True,
|
| 63 |
+
capture_output=True,
|
| 64 |
+
timeout=timeout,
|
| 65 |
+
check=False,
|
| 66 |
+
)
|
| 67 |
+
except subprocess.TimeoutExpired as exc:
|
| 68 |
+
return RunResult(
|
| 69 |
+
ok=False,
|
| 70 |
+
passed=0,
|
| 71 |
+
failed=0,
|
| 72 |
+
errors=1,
|
| 73 |
+
coverage=None,
|
| 74 |
+
stdout=exc.stdout or "",
|
| 75 |
+
stderr=(exc.stderr or "") + f"\nTimed out after {timeout}s",
|
| 76 |
+
returncode=124,
|
| 77 |
+
)
|
| 78 |
+
|
| 79 |
+
stdout = completed.stdout
|
| 80 |
+
stderr = completed.stderr
|
| 81 |
+
passed, failed, errors = _parse_counts(stdout + "\n" + stderr)
|
| 82 |
+
coverage = _parse_coverage(stdout)
|
| 83 |
+
ok = completed.returncode == 0 and failed == 0 and errors == 0
|
| 84 |
+
return RunResult(
|
| 85 |
+
ok=ok,
|
| 86 |
+
passed=passed,
|
| 87 |
+
failed=failed,
|
| 88 |
+
errors=errors,
|
| 89 |
+
coverage=coverage,
|
| 90 |
+
stdout=stdout,
|
| 91 |
+
stderr=stderr,
|
| 92 |
+
returncode=completed.returncode,
|
| 93 |
+
)
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
def _parse_counts(output: str) -> tuple[int, int, int]:
|
| 97 |
+
passed = failed = errors = 0
|
| 98 |
+
for pattern, name in [
|
| 99 |
+
(r"(\d+)\s+passed", "passed"),
|
| 100 |
+
(r"(\d+)\s+failed", "failed"),
|
| 101 |
+
(r"(\d+)\s+errors?", "errors"),
|
| 102 |
+
(r"(\d+)\s+error", "errors"),
|
| 103 |
+
]:
|
| 104 |
+
matches = re.findall(pattern, output)
|
| 105 |
+
if not matches:
|
| 106 |
+
continue
|
| 107 |
+
value = int(matches[-1])
|
| 108 |
+
if name == "passed":
|
| 109 |
+
passed = value
|
| 110 |
+
elif name == "failed":
|
| 111 |
+
failed = value
|
| 112 |
+
else:
|
| 113 |
+
errors = value
|
| 114 |
+
return passed, failed, errors
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
def _parse_coverage(output: str) -> float | None:
|
| 118 |
+
total_lines = [line for line in output.splitlines() if line.strip().startswith("TOTAL")]
|
| 119 |
+
if not total_lines:
|
| 120 |
+
return None
|
| 121 |
+
match = re.search(r"(\d+(?:\.\d+)?)%", total_lines[-1])
|
| 122 |
+
return float(match.group(1)) if match else None
|
samples/legacy_repo/__init__.py
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Small untested legacy package used by TestForge demos."""
|
| 2 |
+
|
samples/legacy_repo/__pycache__/__init__.cpython-312.pyc
ADDED
|
Binary file (241 Bytes). View file
|
|
|
samples/legacy_repo/__pycache__/dates.cpython-312.pyc
ADDED
|
Binary file (821 Bytes). View file
|
|
|
samples/legacy_repo/__pycache__/invoice.cpython-312.pyc
ADDED
|
Binary file (1.02 kB). View file
|
|
|
samples/legacy_repo/__pycache__/pricing.cpython-312.pyc
ADDED
|
Binary file (1.5 kB). View file
|
|
|
samples/legacy_repo/__pycache__/slugify.cpython-312.pyc
ADDED
|
Binary file (1.11 kB). View file
|
|
|
samples/legacy_repo/dates.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Date helpers from a legacy scheduling module."""
|
| 2 |
+
|
| 3 |
+
from datetime import date
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
def days_between(a: date, b: date) -> int:
|
| 7 |
+
"""Return the absolute number of days between two dates."""
|
| 8 |
+
return abs((b - a).days)
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def is_weekend(d: date) -> bool:
|
| 12 |
+
"""Return True when a date falls on Saturday or Sunday."""
|
| 13 |
+
return d.weekday() >= 5
|
| 14 |
+
|
samples/legacy_repo/invoice.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Invoice helpers with deliberately ordinary business logic."""
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
def line_total(qty: int, unit: float) -> float:
|
| 5 |
+
"""Return total for one invoice line."""
|
| 6 |
+
if qty < 0:
|
| 7 |
+
raise ValueError("quantity cannot be negative")
|
| 8 |
+
return round(qty * unit, 2)
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def invoice_total(lines: list[tuple[int, float]]) -> float:
|
| 12 |
+
"""Return total for a list of ``(quantity, unit_price)`` pairs."""
|
| 13 |
+
total = 0.0
|
| 14 |
+
for qty, unit in lines:
|
| 15 |
+
total += line_total(qty, unit)
|
| 16 |
+
return round(total, 2)
|
| 17 |
+
|
samples/legacy_repo/pricing.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Pricing helpers from a pretend legacy billing module."""
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
def apply_discount(price: float, pct: float) -> float:
|
| 5 |
+
"""Return price after applying a percentage discount."""
|
| 6 |
+
if pct < 0:
|
| 7 |
+
raise ValueError("discount cannot be negative")
|
| 8 |
+
if pct > 100:
|
| 9 |
+
raise ValueError("discount cannot exceed 100")
|
| 10 |
+
return round(price * (1 - pct / 100), 2)
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def with_tax(amount: float, rate: float) -> float:
|
| 14 |
+
"""Return amount with tax added."""
|
| 15 |
+
if rate < 0:
|
| 16 |
+
raise ValueError("tax rate cannot be negative")
|
| 17 |
+
return round(amount * (1 + rate / 100), 2)
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def bulk_price(unit: float, qty: int) -> float:
|
| 21 |
+
"""Return total price after a simple bulk discount."""
|
| 22 |
+
if qty < 0:
|
| 23 |
+
raise ValueError("quantity cannot be negative")
|
| 24 |
+
discount = 0.9 if qty >= 10 else 1.0
|
| 25 |
+
return round(unit * qty * discount, 2)
|
| 26 |
+
|
samples/legacy_repo/slugify.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""String cleanup helpers used in old import jobs."""
|
| 2 |
+
|
| 3 |
+
import re
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
def slugify(s: str) -> str:
|
| 7 |
+
"""Convert text into a lower-case dash-separated slug."""
|
| 8 |
+
cleaned = re.sub(r"[^a-zA-Z0-9]+", "-", s.strip().lower())
|
| 9 |
+
return cleaned.strip("-")
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def truncate(s: str, n: int) -> str:
|
| 13 |
+
"""Return text clipped to at most n characters."""
|
| 14 |
+
if n < 0:
|
| 15 |
+
raise ValueError("length cannot be negative")
|
| 16 |
+
if len(s) <= n:
|
| 17 |
+
return s
|
| 18 |
+
if n <= 3:
|
| 19 |
+
return s[:n]
|
| 20 |
+
return s[: n - 3] + "..."
|
| 21 |
+
|
tests/__pycache__/test_testforge.cpython-312-pytest-8.3.4.pyc
ADDED
|
Binary file (24.8 kB). View file
|
|
|
tests/__pycache__/test_testforge.cpython-312.pyc
ADDED
|
Binary file (9.02 kB). View file
|
|
|
tests/test_testforge.py
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pathlib import Path
|
| 2 |
+
import shutil
|
| 3 |
+
import sys
|
| 4 |
+
|
| 5 |
+
from analyzer import analyze
|
| 6 |
+
from agent import forge_legacy_repo
|
| 7 |
+
from generator import capture, generate_suite, write_suite
|
| 8 |
+
from inject import reset_sample, run_injected_suite
|
| 9 |
+
from mutator import MUTATIONS, mutation_score
|
| 10 |
+
from patch import make_pr_patch
|
| 11 |
+
from runner import run_pytest
|
| 12 |
+
from samples.legacy_repo.pricing import apply_discount, with_tax
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
ROOT = Path(__file__).resolve().parents[1]
|
| 16 |
+
LEGACY_ROOT = ROOT / "samples" / "legacy_repo"
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def test_analyzer_discovers_expected_public_functions():
|
| 20 |
+
analysis = analyze(LEGACY_ROOT, package="legacy_repo")
|
| 21 |
+
discovered = {(fn.module, fn.qualname, fn.arity) for fn in analysis.functions}
|
| 22 |
+
|
| 23 |
+
assert discovered == {
|
| 24 |
+
("legacy_repo.dates", "days_between", 2),
|
| 25 |
+
("legacy_repo.dates", "is_weekend", 1),
|
| 26 |
+
("legacy_repo.invoice", "invoice_total", 1),
|
| 27 |
+
("legacy_repo.invoice", "line_total", 2),
|
| 28 |
+
("legacy_repo.pricing", "apply_discount", 2),
|
| 29 |
+
("legacy_repo.pricing", "bulk_price", 2),
|
| 30 |
+
("legacy_repo.pricing", "with_tax", 2),
|
| 31 |
+
("legacy_repo.slugify", "slugify", 1),
|
| 32 |
+
("legacy_repo.slugify", "truncate", 2),
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def test_analyzer_keeps_hints_docstrings_and_source():
|
| 37 |
+
analysis = analyze(LEGACY_ROOT, package="legacy_repo")
|
| 38 |
+
fn = next(item for item in analysis.functions if item.qualname == "apply_discount")
|
| 39 |
+
|
| 40 |
+
assert fn.type_hints == {"price": "float", "pct": "float"}
|
| 41 |
+
assert fn.return_hint == "float"
|
| 42 |
+
assert "percentage discount" in fn.docstring
|
| 43 |
+
assert "def apply_discount" in fn.source
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def test_capture_records_return_and_exception():
|
| 47 |
+
sys.path.insert(0, str((ROOT / "samples").resolve()))
|
| 48 |
+
analysis = analyze(LEGACY_ROOT, package="legacy_repo")
|
| 49 |
+
discount = next(item for item in analysis.functions if item.qualname == "apply_discount")
|
| 50 |
+
tax = next(item for item in analysis.functions if item.qualname == "with_tax")
|
| 51 |
+
|
| 52 |
+
assert apply_discount(100, 10) == 90.0
|
| 53 |
+
returned = capture(discount, [(100.0, 10.0)])
|
| 54 |
+
raised = capture(tax, [(100.0, -1.0)])
|
| 55 |
+
|
| 56 |
+
assert returned[0].value_repr == "90.0"
|
| 57 |
+
assert raised[0].exception_type == "ValueError"
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def test_deterministic_generated_suite_is_green(tmp_path):
|
| 61 |
+
analysis = analyze(LEGACY_ROOT, package="legacy_repo")
|
| 62 |
+
suite = generate_suite(analysis)
|
| 63 |
+
workdir = tmp_path / "work"
|
| 64 |
+
shutil.copytree(ROOT / "samples", workdir / "samples")
|
| 65 |
+
write_suite(suite, workdir)
|
| 66 |
+
|
| 67 |
+
result = run_pytest(workdir, package_parent=workdir / "samples")
|
| 68 |
+
|
| 69 |
+
assert result.ok, result.stdout + result.stderr
|
| 70 |
+
assert result.passed == suite.assertion_count
|
| 71 |
+
assert result.coverage is None or result.coverage >= 0
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def test_runner_parses_known_good_and_bad(tmp_path):
|
| 75 |
+
good = tmp_path / "good"
|
| 76 |
+
good.mkdir()
|
| 77 |
+
(good / "tests").mkdir()
|
| 78 |
+
(good / "tests" / "test_ok.py").write_text("def test_ok():\n assert 1 == 1\n", encoding="utf-8")
|
| 79 |
+
good_result = run_pytest(good, with_coverage=False)
|
| 80 |
+
|
| 81 |
+
bad = tmp_path / "bad"
|
| 82 |
+
bad.mkdir()
|
| 83 |
+
(bad / "tests").mkdir()
|
| 84 |
+
(bad / "tests" / "test_bad.py").write_text("def test_bad():\n assert 1 == 2\n", encoding="utf-8")
|
| 85 |
+
bad_result = run_pytest(bad, with_coverage=False)
|
| 86 |
+
|
| 87 |
+
assert good_result.ok
|
| 88 |
+
assert good_result.passed == 1
|
| 89 |
+
assert not bad_result.ok
|
| 90 |
+
assert bad_result.failed == 1
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def test_mutation_score_is_deterministic_and_kills_known_mutant(tmp_path):
|
| 94 |
+
analysis = analyze(LEGACY_ROOT, package="legacy_repo")
|
| 95 |
+
suite = generate_suite(analysis)
|
| 96 |
+
score = mutation_score(ROOT / "samples", suite, tmp_path / "mutants")
|
| 97 |
+
|
| 98 |
+
assert score.total == len(MUTATIONS)
|
| 99 |
+
assert score.killed > 0
|
| 100 |
+
known = next(
|
| 101 |
+
result for result in score.results if result.mutation.id == "bulk_multiply_to_divide"
|
| 102 |
+
)
|
| 103 |
+
assert known.killed
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
def test_patch_export_contains_generated_tests():
|
| 107 |
+
analysis = analyze(LEGACY_ROOT, package="legacy_repo")
|
| 108 |
+
suite = generate_suite(analysis)
|
| 109 |
+
patch = make_pr_patch(suite)
|
| 110 |
+
|
| 111 |
+
assert "b/tests/test_legacy_repo_pricing_apply_discount.py" in patch
|
| 112 |
+
assert "pytest tests -q" in patch
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
def test_forge_pipeline_produces_green_suite_and_patch():
|
| 116 |
+
one_mutant = (next(item for item in MUTATIONS if item.id == "bulk_threshold_ge_to_gt"),)
|
| 117 |
+
artifacts = forge_legacy_repo(max_cases_per_function=2, mutations=one_mutant)
|
| 118 |
+
|
| 119 |
+
assert artifacts.green.ok, artifacts.green.stdout + artifacts.green.stderr
|
| 120 |
+
assert artifacts.suite.assertion_count > 0
|
| 121 |
+
assert artifacts.mutation.killed > 0
|
| 122 |
+
assert artifacts.patch_path.exists()
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
def test_inject_regression_causes_exactly_one_failure(tmp_path):
|
| 126 |
+
analysis = analyze(LEGACY_ROOT, package="legacy_repo")
|
| 127 |
+
suite = generate_suite(analysis)
|
| 128 |
+
run_dir = tmp_path / "inject"
|
| 129 |
+
run_dir.mkdir()
|
| 130 |
+
reset_sample(ROOT / "samples", run_dir)
|
| 131 |
+
write_suite(suite, run_dir)
|
| 132 |
+
|
| 133 |
+
result = run_injected_suite(run_dir)
|
| 134 |
+
|
| 135 |
+
assert not result.run.ok
|
| 136 |
+
assert result.run.failed == 1
|