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.
- rung/__init__.py +3 -0
- rung/__main__.py +3 -0
- rung/agents/__init__.py +15 -0
- rung/agents/hooks.py +66 -0
- rung/agents/install.py +120 -0
- rung/analysis/__init__.py +1 -0
- rung/analysis/plan.py +165 -0
- rung/analysis/report.py +125 -0
- rung/analysis/survey.py +319 -0
- rung/checks/__init__.py +1 -0
- rung/checks/base.py +59 -0
- rung/checks/command.py +122 -0
- rung/checks/deps.py +230 -0
- rung/checks/findings.py +153 -0
- rung/checks/formal.py +112 -0
- rung/checks/presets.py +96 -0
- rung/checks/prove.py +125 -0
- rung/checks/registry.py +35 -0
- rung/checks/runner.py +161 -0
- rung/checks/scan.py +152 -0
- rung/checks/shortcuts.py +109 -0
- rung/cli/__init__.py +157 -0
- rung/cli/commands/__init__.py +13 -0
- rung/cli/commands/common.py +41 -0
- rung/cli/commands/draft.py +214 -0
- rung/cli/commands/manage.py +112 -0
- rung/cli/commands/run.py +114 -0
- rung/cli/commands/setup.py +174 -0
- rung/cli/output.py +54 -0
- rung/core/__init__.py +1 -0
- rung/core/config.py +278 -0
- rung/core/errors.py +9 -0
- rung/core/git.py +236 -0
- rung/core/laws.py +124 -0
- rung/core/ledger.py +225 -0
- rung/core/proc.py +58 -0
- rung/core/redact.py +36 -0
- rung/drafting/__init__.py +1 -0
- rung/drafting/common.py +86 -0
- rung/drafting/lean.py +117 -0
- rung/drafting/property.py +339 -0
- rung/drafting/providers.py +294 -0
- rung/drafting/runtime.py +186 -0
- rung/drafting/sandbox.py +63 -0
- rung/drafting/tla.py +97 -0
- rung/engines/__init__.py +1 -0
- rung/engines/drt.py +123 -0
- rung/engines/lean.py +209 -0
- rung/engines/mutate.py +138 -0
- rung/engines/sarif.py +223 -0
- rung/engines/shortcuts.py +426 -0
- rung/engines/tla.py +199 -0
- rung/guides/__init__.py +20 -0
- rung/guides/lean.md +64 -0
- rung/guides/property.md +48 -0
- rung/guides/rules.md +15 -0
- rung/guides/runtime.md +94 -0
- rung/guides/tla.md +58 -0
- rung/guides/verify.md +14 -0
- rung_cli-0.1.2.dist-info/METADATA +296 -0
- rung_cli-0.1.2.dist-info/RECORD +64 -0
- rung_cli-0.1.2.dist-info/WHEEL +4 -0
- rung_cli-0.1.2.dist-info/entry_points.txt +2 -0
- rung_cli-0.1.2.dist-info/licenses/LICENSE +202 -0
rung/drafting/common.py
ADDED
|
@@ -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
|