perceptron01 commited on
Commit
c4f3819
·
verified ·
1 Parent(s): 5ea8775

Upload 24 files

Browse files

done donaa done done

README.md CHANGED
@@ -1,14 +1,184 @@
1
  ---
2
  title: TestForge
3
- emoji: 🐢
4
- colorFrom: green
5
- colorTo: purple
6
  sdk: gradio
7
- sdk_version: 6.18.0
8
- python_version: '3.12'
9
  app_file: app.py
10
  pinned: false
 
 
 
 
 
 
 
 
 
 
11
  short_description: Freeze legacy Python behavior before refactoring
12
  ---
13
 
14
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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