rung-cli 0.1.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. rung/__init__.py +3 -0
  2. rung/__main__.py +3 -0
  3. rung/agents/__init__.py +15 -0
  4. rung/agents/hooks.py +66 -0
  5. rung/agents/install.py +120 -0
  6. rung/analysis/__init__.py +1 -0
  7. rung/analysis/plan.py +165 -0
  8. rung/analysis/report.py +125 -0
  9. rung/analysis/survey.py +319 -0
  10. rung/checks/__init__.py +1 -0
  11. rung/checks/base.py +59 -0
  12. rung/checks/command.py +122 -0
  13. rung/checks/deps.py +230 -0
  14. rung/checks/findings.py +153 -0
  15. rung/checks/formal.py +112 -0
  16. rung/checks/presets.py +96 -0
  17. rung/checks/prove.py +125 -0
  18. rung/checks/registry.py +35 -0
  19. rung/checks/runner.py +161 -0
  20. rung/checks/scan.py +152 -0
  21. rung/checks/shortcuts.py +109 -0
  22. rung/cli/__init__.py +157 -0
  23. rung/cli/commands/__init__.py +13 -0
  24. rung/cli/commands/common.py +41 -0
  25. rung/cli/commands/draft.py +214 -0
  26. rung/cli/commands/manage.py +112 -0
  27. rung/cli/commands/run.py +114 -0
  28. rung/cli/commands/setup.py +174 -0
  29. rung/cli/output.py +54 -0
  30. rung/core/__init__.py +1 -0
  31. rung/core/config.py +278 -0
  32. rung/core/errors.py +9 -0
  33. rung/core/git.py +236 -0
  34. rung/core/laws.py +124 -0
  35. rung/core/ledger.py +225 -0
  36. rung/core/proc.py +58 -0
  37. rung/core/redact.py +36 -0
  38. rung/drafting/__init__.py +1 -0
  39. rung/drafting/common.py +86 -0
  40. rung/drafting/lean.py +117 -0
  41. rung/drafting/property.py +339 -0
  42. rung/drafting/providers.py +294 -0
  43. rung/drafting/runtime.py +186 -0
  44. rung/drafting/sandbox.py +63 -0
  45. rung/drafting/tla.py +97 -0
  46. rung/engines/__init__.py +1 -0
  47. rung/engines/drt.py +123 -0
  48. rung/engines/lean.py +209 -0
  49. rung/engines/mutate.py +138 -0
  50. rung/engines/sarif.py +223 -0
  51. rung/engines/shortcuts.py +426 -0
  52. rung/engines/tla.py +199 -0
  53. rung/guides/__init__.py +20 -0
  54. rung/guides/lean.md +64 -0
  55. rung/guides/property.md +48 -0
  56. rung/guides/rules.md +15 -0
  57. rung/guides/runtime.md +94 -0
  58. rung/guides/tla.md +58 -0
  59. rung/guides/verify.md +14 -0
  60. rung_cli-0.1.2.dist-info/METADATA +296 -0
  61. rung_cli-0.1.2.dist-info/RECORD +64 -0
  62. rung_cli-0.1.2.dist-info/WHEEL +4 -0
  63. rung_cli-0.1.2.dist-info/entry_points.txt +2 -0
  64. rung_cli-0.1.2.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,86 @@
1
+ """Shared pieces of the propose -> check -> repair loop.
2
+
3
+ Checker *errors* (a parse error, a failed build, a `sorry`, an adapter that crashes) go back
4
+ to the model to fix. Checker *findings* (a TLC counterexample, the code disagreeing with a
5
+ proven model) do not: each is either a real bug or a spec that says the wrong thing, and only
6
+ a person can tell which. A draft therefore stops and shows the finding instead of letting the
7
+ model edit the spec until the checker is quiet. That rule is the guard against specification
8
+ hacking.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from dataclasses import dataclass, field
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ # JSON Schemas for drafted artifacts. Strict style (every property required, no extra keys) is what
18
+ # both Claude Code `--json-schema` and Codex `--output-schema` accept.
19
+ FILES = {
20
+ "type": "array",
21
+ "items": {
22
+ "type": "object",
23
+ "properties": {"path": {"type": "string"}, "content": {"type": "string"}},
24
+ "required": ["path", "content"],
25
+ "additionalProperties": False,
26
+ },
27
+ }
28
+ STRINGS = {"type": "array", "items": {"type": "string"}}
29
+ LAWS = {
30
+ "type": "array",
31
+ "items": {
32
+ "type": "object",
33
+ "properties": {"id": {"type": "string"}, "text": {"type": "string"}},
34
+ "required": ["id", "text"],
35
+ "additionalProperties": False,
36
+ },
37
+ }
38
+ LAWS_FORMAT = """
39
+ - `laws`: each guarantee the spec or proofs check, as one plain sentence a non-specialist can approve,
40
+ with a short kebab-case `id`. These are added to LAWS.md as proposals; a person approves them."""
41
+
42
+
43
+ def schema(**properties: dict[str, Any]) -> dict[str, Any]:
44
+ return {"type": "object", "properties": properties, "required": list(properties), "additionalProperties": False}
45
+
46
+
47
+ @dataclass
48
+ class DraftResult:
49
+ ok: bool
50
+ message: str
51
+ files: list[Path] = field(default_factory=list)
52
+ rounds: int = 0
53
+ trace: str = "" # a counterexample or divergence for a person to judge
54
+ theorems: list[str] = field(default_factory=list)
55
+ alphabet: list[str] = field(default_factory=list)
56
+ warnings: list[str] = field(default_factory=list)
57
+ notes: str = "" # what the model wants the developer to know
58
+ laws: list[dict[str, str]] = field(default_factory=list) # proposed laws, for a person to approve
59
+
60
+
61
+ def files_of(reply: dict[str, Any]) -> dict[str, str]:
62
+ """`{"files": [{"path": ..., "content": ...}]}` -> {path: content}."""
63
+ return {f["path"]: f["content"].rstrip() + "\n" for f in reply.get("files", [])}
64
+
65
+
66
+ def safe_write(base: Path, files: dict[str, str]) -> list[Path]:
67
+ """Write model-produced files, refusing any path that escapes `base`."""
68
+ written = []
69
+ base = base.resolve()
70
+ for name, body in files.items():
71
+ target = (base / name).resolve()
72
+ if base not in target.parents:
73
+ raise ValueError(f"refusing to write outside {base}: {name}")
74
+ target.parent.mkdir(parents=True, exist_ok=True)
75
+ target.write_text(body)
76
+ written.append(target)
77
+ return written
78
+
79
+
80
+ def source_block(root: Path, sources: list[Path]) -> str:
81
+ """Source files with line numbers, so the model can cite `file:line` in comments."""
82
+ parts = []
83
+ for src in sources:
84
+ numbered = "\n".join(f"{i:4} {line}" for i, line in enumerate(src.read_text().splitlines(), 1))
85
+ parts.append(f"--- {src.relative_to(root)} ---\n{numbered}")
86
+ return "\n\n".join(parts)
rung/drafting/lean.py ADDED
@@ -0,0 +1,117 @@
1
+ """`rung draft lean`: a model writes a Lean model, proofs and a differential-testing adapter.
2
+
3
+ The Lean kernel decides the proofs, and a differential test against the real code decides
4
+ whether the model describes it. Rung pins the toolchain itself (models remember old versions)
5
+ and sends trivially-proved theorems back once for something meaningful.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import shlex
11
+ import sys
12
+ from pathlib import Path
13
+
14
+ from rung import guides
15
+ from rung.drafting.common import (
16
+ FILES,
17
+ LAWS,
18
+ LAWS_FORMAT,
19
+ STRINGS,
20
+ DraftResult,
21
+ files_of,
22
+ safe_write,
23
+ schema,
24
+ source_block,
25
+ )
26
+ from rung.drafting.providers import Provider
27
+ from rung.engines import drt, lean
28
+
29
+ FORMAT = (
30
+ """
31
+
32
+ Return:
33
+ - `files`: `lakefile.toml` (a `[[lean_lib]]` and a `[[lean_exe]]` whose `root = "Main"`), `<Lib>.lean`,
34
+ `Main.lean` and `drt_adapter.py`. Do not write `lean-toolchain`; Rung pins it.
35
+ - `theorems`: fully qualified names of every theorem, e.g. `GstModel.split_intrastate`.
36
+ - `alphabet`: every action code `Main.lean` accepts, as strings.
37
+ - `exe`: the `[[lean_exe]]` name.
38
+ - `notes`: anything the developer should know."""
39
+ + LAWS_FORMAT
40
+ )
41
+ SCHEMA = schema(
42
+ files=FILES, theorems=STRINGS, alphabet=STRINGS, exe={"type": "string"}, laws=LAWS, notes={"type": "string"}
43
+ )
44
+
45
+ DRT_SMOKE_CASES = 300
46
+
47
+
48
+ def _drt_check(out_dir: Path, root: Path, exe: str, alphabet: list[str]) -> drt.DRTResult:
49
+ model = drt.Side(shlex.join([lean.find_lake(), "exe", exe]), out_dir)
50
+ impl = drt.Side(shlex.join([sys.executable, str(out_dir / "drt_adapter.py")]), root)
51
+ return drt.run(model, impl, alphabet=alphabet, cases=DRT_SMOKE_CASES, max_len=6, seed=1)
52
+
53
+
54
+ def draft(
55
+ provider: Provider, root: Path, sources: list[Path], out_dir: Path, *, intent: str = "", max_rounds: int = 4
56
+ ) -> DraftResult:
57
+ toolchain = lean.toolchain()
58
+ system = guides.load("lean", lean_version=toolchain.split(":")[-1]) + FORMAT
59
+ prompt = (
60
+ "Write an executable Lean 4 model of this code, prove its laws, and write the adapter.\n"
61
+ + (f"Laws the developer cares about: {intent}\n" if intent else "")
62
+ + "\n"
63
+ + source_block(root, sources)
64
+ )
65
+ files: list[Path] = []
66
+ asked_for_stronger = False
67
+ for round_no in range(1, max_rounds + 1):
68
+ reply = provider.complete_json(prompt, schema=SCHEMA, system=system, cwd=root)
69
+ produced = files_of(reply)
70
+ if "Main.lean" not in produced or "lakefile.toml" not in produced or "drt_adapter.py" not in produced:
71
+ prompt += "\n\nYour reply was missing Main.lean, lakefile.toml or drt_adapter.py."
72
+ continue
73
+ produced["lean-toolchain"] = toolchain + "\n" # never the model's choice
74
+ produced[".gitignore"] = ".lake/\n" # build output must never be committed or stale a verdict
75
+ files = safe_write(out_dir, produced)
76
+ theorems = [t.strip() for t in reply["theorems"] if t.strip()]
77
+ alphabet = [str(a).strip() for a in reply["alphabet"] if str(a).strip()]
78
+ exe = reply["exe"].strip() or "model"
79
+
80
+ result = lean.run(out_dir, theorems)
81
+ if result.verdict != "pass":
82
+ problem = "\n".join(result.findings) or result.output[-3000:]
83
+ prompt += (
84
+ f"\n\nLean rejected the previous attempt ({result.reason}):\n{problem}\n"
85
+ "Fix the proofs or the model. Do not weaken a theorem to make it pass."
86
+ )
87
+ continue
88
+ if result.warnings and not asked_for_stronger:
89
+ asked_for_stronger = True
90
+ prompt += (
91
+ "\n\nThese theorems look true of anything, regardless of the model, so they may prove nothing:\n"
92
+ + "\n".join(result.warnings)
93
+ + "\nIf so, replace them with statements about the model's behaviour that matter to the developer."
94
+ )
95
+ continue
96
+ if not alphabet:
97
+ prompt += "\n\nYour reply has an empty `alphabet`."
98
+ continue
99
+
100
+ check = _drt_check(out_dir, root, exe, alphabet)
101
+ if check.verdict == "error":
102
+ prompt += (
103
+ f"\n\nThe differential test could not run ({check.reason}). Fix Main.lean or drt_adapter.py so "
104
+ "both read one input per line and print exactly one line per input."
105
+ )
106
+ continue
107
+ base = DraftResult(True, result.reason, files, round_no, theorems=theorems, alphabet=alphabet,
108
+ warnings=result.warnings, notes=reply["notes"].strip(), laws=reply["laws"]) # fmt: skip
109
+ if check.verdict == "fail" and check.minimal:
110
+ m = check.minimal
111
+ base.message = (
112
+ f"{result.reason}; the code and the model disagree on {check.divergences} of {check.cases} "
113
+ "inputs. Decide: a bug in the code, or a model that says the wrong thing?"
114
+ )
115
+ base.trace = f"input: {m.input or '(empty)'}\n model: {m.model}\n code: {m.impl}"
116
+ return base
117
+ return DraftResult(False, f"no kernel-checked model after {max_rounds} rounds", files, max_rounds)
@@ -0,0 +1,339 @@
1
+ """`rung draft property`: a model writes property-based tests for a law; real runs and mutants decide.
2
+
3
+ The loop, all inside a disposable copy of the project (`rung.drafting.sandbox`):
4
+
5
+ 1. The model writes one test file of Hypothesis (Python) or fast-check (TypeScript/JavaScript)
6
+ properties for the source files and the laws the developer gives.
7
+ 2. Rung runs it. A test that is broken (it fails in its own code, or runs nothing) goes back to the
8
+ model. A property that fails on the real code is a *finding* (the code or the law is wrong) and
9
+ stops the draft for a person to judge, exactly like a TLC counterexample. Failures Rung cannot
10
+ attribute to the test are treated as findings too, so a real bug is never handed back to the
11
+ model to make disappear.
12
+ 3. A canary proves the tests import the copied code (not an installed package), then the file runs
13
+ against mutants of the source: one-token bugs such as `<` to `<=`. Only a property failure counts
14
+ as catching one; a mutant that breaks the build or times out is not counted. A file that catches
15
+ fewer than half goes back to the model with the bugs it missed.
16
+
17
+ The developer's files are only written at the end: the accepted test, or nothing if a test already
18
+ exists at that path. A failed draft is kept under `.rung/cache/drafts/`.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import json
24
+ import re
25
+ import shlex
26
+ import shutil
27
+ import subprocess
28
+ from dataclasses import dataclass
29
+ from pathlib import Path
30
+
31
+ from rung import guides
32
+ from rung.core import proc
33
+ from rung.core.config import ConfigError
34
+ from rung.core.errors import RungError, ToolMissing
35
+ from rung.drafting import sandbox
36
+ from rung.drafting.common import LAWS, LAWS_FORMAT, DraftResult, schema, source_block
37
+ from rung.drafting.providers import Provider
38
+ from rung.engines import mutate
39
+
40
+ FORMAT = (
41
+ """
42
+
43
+ Return `test`: the complete contents of the test file (it is written to the path given in the request),
44
+ and put anything the developer should know in `notes`."""
45
+ + LAWS_FORMAT
46
+ )
47
+ SCHEMA = schema(test={"type": "string"}, laws=LAWS, notes={"type": "string"})
48
+ RUN_TIMEOUT = 300
49
+ MAX_MUTANTS = 10
50
+ GOOD_ENOUGH = 0.5 # share of viable mutants a property file must catch
51
+ CANARY = "rung-canary-7f3a"
52
+ DRAFTS_DIR = Path(".rung") / "cache" / "drafts"
53
+
54
+ _FAILING_EXAMPLE = re.compile(r"Falsifying example|Failing test case|Counterexample:|Property failed after", re.I)
55
+ # Errors that mean the test's own code is broken, when the failure happens inside the test file.
56
+ _BROKEN = {"NameError", "ImportError", "ModuleNotFoundError", "SyntaxError", "IndentationError", "ReferenceError",
57
+ "AttributeError", "TypeError", "UnboundLocalError"} # fmt: skip
58
+ _PY_LOCATION = re.compile(r"^([^\s:]+\.py):\d+: (\w+)", re.M) # pytest's `file.py:12: AssertionError`
59
+ _JS_LOCATION = re.compile(r"^\s*at .*?([^\s()]+\.[cm]?[jt]sx?):\d+:\d+", re.M)
60
+ _JS_ERROR = re.compile(r"\b(\w*Error)\b:")
61
+ _PASSED = re.compile(r"(\d+) passed|^\S* pass (\d+)$", re.M)
62
+ _MISSING_PACKAGE = re.compile(r"No module named '(hypothesis|pytest)'|Cannot find (?:module|package) 'fast-check'")
63
+
64
+
65
+ @dataclass
66
+ class Target:
67
+ """Where the property file lives and how to run it; paths are relative to the repository root."""
68
+
69
+ language: str # python | typescript
70
+ project: str # the directory the command runs in
71
+ test_file: str
72
+ argv: list[str]
73
+
74
+ @property
75
+ def command(self) -> str:
76
+ return shlex.join(self.argv)
77
+
78
+
79
+ def _nearest(start: Path, root: Path, names: tuple[str, ...]) -> Path:
80
+ for d in (start, *start.parents):
81
+ if any((d / n).exists() for n in names):
82
+ return d
83
+ if d == root:
84
+ break
85
+ return root
86
+
87
+
88
+ def target_for(root: Path, sources: list[Path], slug: str, out: str = "") -> Target:
89
+ """Pick the language, project directory, test file path and runner for these sources."""
90
+ first = sources[0]
91
+ if first.suffix == ".py":
92
+ project = _nearest(first.parent, root, ("pyproject.toml", "setup.py", "pytest.ini"))
93
+ test = root / out if out else project / "tests" / "properties" / f"test_{slug.replace('-', '_')}_properties.py"
94
+ rel = test.relative_to(project).as_posix()
95
+ if (project / "uv.lock").exists():
96
+ argv = [
97
+ "uv",
98
+ "run",
99
+ "--with",
100
+ "hypothesis",
101
+ "--with",
102
+ "pytest",
103
+ "pytest",
104
+ "-q",
105
+ "-p",
106
+ "no:cacheprovider",
107
+ rel,
108
+ ]
109
+ else:
110
+ argv = ["python", "-m", "pytest", "-q", "-p", "no:cacheprovider", rel]
111
+ language = "python"
112
+ elif first.suffix in mutate.JS:
113
+ project = _nearest(first.parent, root, ("package.json",))
114
+ try:
115
+ pkg = json.loads((project / "package.json").read_text())
116
+ except (OSError, json.JSONDecodeError) as exc:
117
+ raise ConfigError(f"cannot read {project / 'package.json'}: {exc}") from exc
118
+ deps = {**pkg.get("dependencies", {}), **pkg.get("devDependencies", {})}
119
+ if "fast-check" not in deps:
120
+ raise ToolMissing(f"add fast-check to {project.relative_to(root) or '.'} first: npm install -D fast-check")
121
+ ext = "ts" if first.suffix in (".ts", ".tsx", ".mts", ".cts") else "js"
122
+ test = root / out if out else project / "tests" / "properties" / f"{slug}.property.test.{ext}"
123
+ rel = test.relative_to(project).as_posix()
124
+ argv = _js_runner(pkg, deps, rel)
125
+ language = "typescript"
126
+ else:
127
+ raise ConfigError(f"property tests are drafted for Python and TypeScript/JavaScript, not {first.suffix} files")
128
+ if project not in test.resolve().parents and test.resolve().parent != project.resolve():
129
+ raise ConfigError(f"the test file must be inside {project.relative_to(root) or 'the project'}")
130
+ return Target(language, project.relative_to(root).as_posix(), test.relative_to(root).as_posix(), argv)
131
+
132
+
133
+ def undeclared(root: Path, target: Target) -> list[str]:
134
+ """A warning when the project does not declare the property library its own test run will import."""
135
+ project = root / target.project
136
+ if target.language == "python":
137
+ manifest = project / "pyproject.toml"
138
+ text = manifest.read_text() if manifest.exists() else ""
139
+ if "hypothesis" not in text.lower():
140
+ fix = "uv add --dev hypothesis" if (project / "uv.lock").exists() else "add it to your dev dependencies"
141
+ return [f"hypothesis is not a declared dependency, so the project's own test run will fail to import "
142
+ f"{Path(target.test_file).name}: {fix}"] # fmt: skip
143
+ return [] # fast-check is required up front for TypeScript
144
+
145
+
146
+ def _js_runner(pkg: dict, deps: dict, rel: str) -> list[str]:
147
+ """The project's own test runner, pointed at one file: Vitest, Jest, or Node's built-in `node --test`."""
148
+ script = str((pkg.get("scripts") or {}).get("test", ""))
149
+ if "vitest" in script or ("vitest" in deps and "jest" not in script):
150
+ return ["npx", "vitest", "run", rel]
151
+ if "jest" in script or "jest" in deps:
152
+ return ["npx", "jest", rel]
153
+ tokens = shlex.split(script) if script else []
154
+ if tokens and tokens[0] == "node" and "--test" in tokens:
155
+ # Keep the loader flags (e.g. `--import tsx`) and replace the file globs with this file.
156
+ return [*tokens[: tokens.index("--test") + 1], rel]
157
+ raise ToolMissing("property tests run under Vitest, Jest or `node --test`; package.json uses none of them")
158
+
159
+
160
+ def passed_count(output: str) -> int:
161
+ """How many tests the runner says passed (pytest, Vitest, Jest, `node --test`)."""
162
+ return max((int(a or b) for a, b in _PASSED.findall(output)), default=0)
163
+
164
+
165
+ def classify(returncode: int, output: str, sources: list[str], test_file: str) -> str:
166
+ """pass, finding (a property failed on the code) or error (the test itself is broken, or ran nothing).
167
+
168
+ `sources` and `test_file` are file names, used to tell where a failure happened: an error raised in
169
+ the code under test is a finding, even a `NameError`; a non-assertion error raised in the test file
170
+ is a broken test; anything Rung cannot place is a finding, for a person to judge.
171
+ """
172
+ if returncode == 0:
173
+ return "pass" if passed_count(output) > 0 else "error"
174
+ if returncode != 1 or not _FAILING_EXAMPLE.search(output):
175
+ return "error"
176
+ py = _PY_LOCATION.findall(output)
177
+ js = _JS_LOCATION.findall(output)
178
+ where, kind = "", ""
179
+ if py:
180
+ where, kind = Path(py[-1][0]).name, py[-1][1] # pytest lists the innermost frame last
181
+ elif js:
182
+ where = Path(js[0]).name # JavaScript stacks list it first
183
+ # fast-check wraps the real error in a plain `Error: Property failed`; the specific type is the cause.
184
+ kind = next((k for k in _JS_ERROR.findall(output) if k != "Error"), "")
185
+ if where in sources:
186
+ return "finding"
187
+ if where == test_file and kind in _BROKEN:
188
+ return "error"
189
+ return "finding"
190
+
191
+
192
+ def _run(target: Target, base: Path) -> tuple[int, str]:
193
+ """Run the property file in the copy at `base`, without credentials in the environment."""
194
+ project = base / target.project
195
+ path = [str(project / "src"), str(project)] if (project / "src").is_dir() else [str(project)]
196
+ # The copy's own code must win over an installed (editable) package pointing at the real tree, and
197
+ # bytecode is never cached for files Rung mutates.
198
+ env = sandbox.scrubbed_env({"PYTHONPATH": ":".join(path), "PYTHONDONTWRITEBYTECODE": "1"})
199
+ try:
200
+ result = proc.run(target.argv, cwd=project, timeout=RUN_TIMEOUT, env=env)
201
+ except FileNotFoundError as exc:
202
+ raise ToolMissing(f"cannot run `{target.command}`: {exc}") from exc
203
+ except subprocess.TimeoutExpired:
204
+ return 124, f"`{target.command}` timed out after {RUN_TIMEOUT}s"
205
+ return result.returncode, (result.stdout or "") + (result.stderr or "")
206
+
207
+
208
+ def _exercises_copy(target: Target, base: Path, sources: list[str]) -> bool:
209
+ """Whether the tests import the copied sources: a canary appended to one must show up in the run."""
210
+ source = base / sources[0]
211
+ line = f'\nraise RuntimeError("{CANARY}")\n' if source.suffix == ".py" else f'\nthrow new Error("{CANARY}");\n'
212
+ original = source.read_bytes()
213
+ try:
214
+ source.write_bytes(original + line.encode())
215
+ _, output = _run(target, base)
216
+ finally:
217
+ source.write_bytes(original)
218
+ return CANARY in output
219
+
220
+
221
+ @dataclass
222
+ class Score:
223
+ caught: list[mutate.Mutant]
224
+ survived: list[mutate.Mutant]
225
+ nonviable: list[mutate.Mutant] # broke the build, crashed the runner or timed out: not evidence either way
226
+
227
+ @property
228
+ def viable(self) -> int:
229
+ return len(self.caught) + len(self.survived)
230
+
231
+
232
+ def mutation_score(target: Target, base: Path, sources: list[str]) -> Score:
233
+ """Run the property file against each mutant in the copy; only a property failure counts as a catch."""
234
+ sample = [m for s in sources for m in mutate.mutants(base / s, limit=MAX_MUTANTS)][:MAX_MUTANTS]
235
+ score = Score([], [], [])
236
+ names = [Path(s).name for s in sources]
237
+ for mutant in sample:
238
+ with mutate.applied(mutant):
239
+ code, output = _run(target, base)
240
+ verdict = classify(code, output, names, Path(target.test_file).name)
241
+ {"finding": score.caught, "pass": score.survived}.get(verdict, score.nonviable).append(mutant)
242
+ return score
243
+
244
+
245
+ def _tail(text: str, n: int = 60) -> str:
246
+ return "\n".join(text.strip().splitlines()[-n:])
247
+
248
+
249
+ def _keep_failed(root: Path, target: Target, body: str) -> Path:
250
+ path = root / DRAFTS_DIR / Path(target.test_file).name
251
+ path.parent.mkdir(parents=True, exist_ok=True)
252
+ path.write_text(body)
253
+ return path
254
+
255
+
256
+ def draft(
257
+ provider: Provider,
258
+ root: Path,
259
+ sources: list[Path],
260
+ slug: str,
261
+ *,
262
+ intent: str = "",
263
+ out: str = "",
264
+ max_rounds: int = 4,
265
+ ) -> tuple[DraftResult, Target]:
266
+ target = target_for(root, sources, slug, out)
267
+ final = root / target.test_file
268
+ if final.exists():
269
+ raise ConfigError(f"{target.test_file} already exists; choose another path with --out, or move it first")
270
+ rel_sources = [s.relative_to(root).as_posix() for s in sources]
271
+ names = [Path(s).name for s in rel_sources]
272
+ system = guides.load("property") + FORMAT
273
+ prompt = (
274
+ f"Write {'Hypothesis' if target.language == 'python' else 'fast-check'} property-based tests for this code. "
275
+ f"The file is `{target.test_file}`, run with `{target.command}` from `{target.project}`. Import the code "
276
+ "under test as the project's own tests do.\n"
277
+ + (
278
+ f"The laws it must check (from the developer): {intent}\n"
279
+ if intent
280
+ else "State the laws you infer from names, docstrings and comments.\n"
281
+ )
282
+ + "\n"
283
+ + source_block(root, sources)
284
+ )
285
+ notes, laws, body = "", [], ""
286
+ with sandbox.copy_of(root) as box:
287
+ test_in_box = box / target.test_file
288
+ test_in_box.parent.mkdir(parents=True, exist_ok=True)
289
+ for round_no in range(1, max_rounds + 1):
290
+ reply = provider.complete_json(prompt, schema=SCHEMA, system=system, cwd=root)
291
+ notes, laws = reply["notes"].strip(), reply["laws"]
292
+ body = re.sub(r"^```.*$\n?", "", reply["test"], flags=re.M).rstrip() + "\n"
293
+ test_in_box.write_text(body)
294
+ code, output = _run(target, box)
295
+ if missing := _MISSING_PACKAGE.search(output):
296
+ raise ToolMissing(f"`{missing.group(0)}`: install the test dependencies, then draft again")
297
+ verdict = classify(code, output, names, test_in_box.name)
298
+ if verdict == "finding":
299
+ final.parent.mkdir(parents=True, exist_ok=True)
300
+ shutil.copyfile(test_in_box, final)
301
+ message = "a property fails on the real code. Decide: a bug in the code, or a wrong law?"
302
+ return DraftResult(True, message, [final], round_no, _tail(output), notes=notes, laws=laws), target
303
+ if verdict == "error":
304
+ why = "ran no tests" if code == 0 else "did not run cleanly"
305
+ prompt += f"\n\nThe test file {why}:\n{_tail(output)}\nFix the test (not the code)."
306
+ continue
307
+ if not _exercises_copy(target, box, rel_sources):
308
+ raise RungError(
309
+ "the tests pass, but they do not import the code being tested from the project (an "
310
+ "installed copy of the package?), so deliberate bugs could not reach them. Make the "
311
+ "project's own tests import its source, then draft again."
312
+ )
313
+ score = mutation_score(target, box, rel_sources)
314
+ warnings = [f"survived: {m.describe}" for m in score.survived]
315
+ warnings += [f"not counted (broke the build or timed out): {m.describe}" for m in score.nonviable]
316
+ if score.viable == 0 or len(score.caught) / score.viable >= GOOD_ENOUGH:
317
+ final.parent.mkdir(parents=True, exist_ok=True)
318
+ shutil.copyfile(test_in_box, final)
319
+ if score.viable:
320
+ caught = f"{len(score.caught)} of {score.viable}"
321
+ message = f"properties hold on the real code and caught {caught} deliberate bugs"
322
+ else:
323
+ message = (
324
+ "properties hold on the real code; INCONCLUSIVE: no deliberate bug could be made to test them"
325
+ )
326
+ result = DraftResult(True, message, [final], round_no, notes=notes, laws=laws,
327
+ warnings=warnings + undeclared(root, target)) # fmt: skip
328
+ return result, target
329
+ listed = "\n".join(f"- {m.describe}" for m in score.survived)
330
+ prompt += (
331
+ f"\n\nYour properties pass, but they also pass when the code is broken like this:\n{listed}\n"
332
+ "Strengthen them so a bug like these fails a property: pin exact values the law implies and "
333
+ "include boundary inputs. Do not test implementation details."
334
+ )
335
+ kept = _keep_failed(root, target, body) if body else None
336
+ message = f"no property file caught at least half the deliberate bugs after {max_rounds} rounds"
337
+ if kept:
338
+ message += f"; the last attempt is in {kept.relative_to(root)}"
339
+ return DraftResult(False, message, [kept] if kept else [], max_rounds, notes=notes, laws=laws), target