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,294 @@
1
+ """Model providers: the model that drafts specs, proofs and drive scripts.
2
+
3
+ Every call is schema-constrained: the model must return one JSON object matching a JSON Schema,
4
+ which the provider's own structured-output feature enforces. Claude Code gets the schema with
5
+ `--json-schema`, Codex with `--output-schema`, OpenAI-compatible servers with `response_format`.
6
+ Nothing is scraped out of free text.
7
+
8
+ Rung never asks for or stores credentials. The CLI providers reuse the login of a tool the
9
+ developer already has (`claude auth login`, `codex login`); the HTTP provider reads its key from
10
+ an environment variable. The model only *proposes*: a checker (TLC, the Lean kernel, a
11
+ differential test, the app itself) accepts or rejects what it drafts.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import os
18
+ import re
19
+ import shutil
20
+ import subprocess
21
+ import tempfile
22
+ import tomllib
23
+ import urllib.error
24
+ import urllib.request
25
+ from dataclasses import dataclass
26
+ from pathlib import Path
27
+ from typing import Any
28
+
29
+ from rung.core.config import LLMConfig
30
+ from rung.core.errors import RungError, ToolMissing
31
+
32
+ PROVIDERS = {
33
+ "claude-cli": "Claude Code CLI (uses your `claude auth login` session)",
34
+ "codex-cli": "Codex CLI (uses your `codex login` session)",
35
+ "openai-compatible": "Any OpenAI-compatible /v1/chat/completions endpoint (Ollama, vLLM, Kompact, ...)",
36
+ "none": "No model; Rung runs checks but drafts nothing",
37
+ }
38
+
39
+
40
+ class ProviderError(RungError):
41
+ """The model could not be reached, or did not return the requested JSON."""
42
+
43
+
44
+ @dataclass
45
+ class LoginState:
46
+ ok: bool
47
+ detail: str
48
+
49
+
50
+ def check_shape(value: Any, schema: dict[str, Any], path: str = "$") -> None:
51
+ """Verify the parts of a JSON Schema Rung uses (object, array, string, required).
52
+
53
+ The providers enforce the schema already; this guards against a provider that
54
+ silently ignores it, so a malformed reply fails here instead of deep in a draft.
55
+ """
56
+ kind = schema.get("type")
57
+ if kind == "object":
58
+ if not isinstance(value, dict):
59
+ raise ProviderError(f"{path}: expected an object")
60
+ for key in schema.get("required", []):
61
+ if key not in value:
62
+ raise ProviderError(f"{path}: missing `{key}`")
63
+ for key, sub in schema.get("properties", {}).items():
64
+ if key in value:
65
+ check_shape(value[key], sub, f"{path}.{key}")
66
+ elif kind == "array":
67
+ if not isinstance(value, list):
68
+ raise ProviderError(f"{path}: expected an array")
69
+ for i, item in enumerate(value):
70
+ check_shape(item, schema.get("items", {}), f"{path}[{i}]")
71
+ elif kind == "string" and not isinstance(value, str):
72
+ raise ProviderError(f"{path}: expected a string")
73
+
74
+
75
+ class Provider:
76
+ name = "none"
77
+
78
+ def __init__(self, config: LLMConfig) -> None:
79
+ self.config = config
80
+ self.models_used: set[str] = set() # the models that actually answered, as each tool reports them
81
+
82
+ def models(self) -> str:
83
+ """The models that answered so far, else the configured one, else the CLI's own default."""
84
+ return ", ".join(sorted(self.models_used)) or self.config.model or "the CLI's default model"
85
+
86
+ def available(self) -> bool:
87
+ return False
88
+
89
+ def login_state(self) -> LoginState:
90
+ return LoginState(False, "no provider configured")
91
+
92
+ def login(self) -> int:
93
+ """Run the provider's own interactive login; returns its exit code."""
94
+ return 1
95
+
96
+ def complete_json(
97
+ self, prompt: str, *, schema: dict[str, Any], system: str = "", cwd: Path | None = None, timeout: int = 1200
98
+ ) -> dict[str, Any]:
99
+ """Ask the model for one JSON object matching `schema`."""
100
+ raise ToolMissing("no model provider is configured; run `rung init` or set [llm] in rung.toml")
101
+
102
+
103
+ class ClaudeCLI(Provider):
104
+ name = "claude-cli"
105
+
106
+ def available(self) -> bool:
107
+ return shutil.which("claude") is not None
108
+
109
+ def login_state(self) -> LoginState:
110
+ if not self.available():
111
+ return LoginState(False, "`claude` is not installed")
112
+ proc = subprocess.run(["claude", "auth", "status"], capture_output=True, text=True, timeout=60)
113
+ try:
114
+ info = json.loads(proc.stdout)
115
+ except json.JSONDecodeError:
116
+ lines = (proc.stdout or proc.stderr).strip().splitlines()
117
+ return LoginState(proc.returncode == 0, lines[0] if lines else "")
118
+ # Report only the login state, never account identifiers such as the email.
119
+ if info.get("loggedIn"):
120
+ return LoginState(True, f"logged in ({info.get('authMethod', 'unknown method')})")
121
+ return LoginState(False, "not logged in")
122
+
123
+ def login(self) -> int:
124
+ return subprocess.call(["claude", "auth", "login"])
125
+
126
+ def complete_json(
127
+ self, prompt: str, *, schema: dict[str, Any], system: str = "", cwd: Path | None = None, timeout: int = 1200
128
+ ) -> dict[str, Any]:
129
+ if not self.available():
130
+ raise ToolMissing("`claude` is not installed")
131
+ # Drafting only: with no tools the model reads nothing and runs nothing on its own.
132
+ cmd = ["claude", "-p", "--tools", "", "--output-format", "json", "--json-schema", json.dumps(schema)]
133
+ if self.config.model:
134
+ cmd += ["--model", self.config.model]
135
+ if system:
136
+ cmd += ["--append-system-prompt", system]
137
+ proc = subprocess.run(cmd, input=prompt, cwd=cwd, capture_output=True, text=True, timeout=timeout)
138
+ try:
139
+ envelope = json.loads(proc.stdout)
140
+ except json.JSONDecodeError as exc:
141
+ raise ProviderError(
142
+ f"claude exited {proc.returncode}: {(proc.stderr or proc.stdout).strip()[:500]}"
143
+ ) from exc
144
+ if envelope.get("is_error"):
145
+ raise ProviderError(f"claude reported an error: {str(envelope.get('result'))[:500]}")
146
+ self.models_used.update(envelope.get("modelUsage") or {})
147
+ result = envelope.get("structured_output")
148
+ if result is None:
149
+ raise ProviderError("claude returned no structured output")
150
+ check_shape(result, schema)
151
+ return result
152
+
153
+
154
+ class CodexCLI(Provider):
155
+ name = "codex-cli"
156
+
157
+ def available(self) -> bool:
158
+ return shutil.which("codex") is not None
159
+
160
+ def login_state(self) -> LoginState:
161
+ if not self.available():
162
+ return LoginState(False, "`codex` is not installed")
163
+ proc = subprocess.run(["codex", "login", "status"], capture_output=True, text=True, timeout=60)
164
+ detail = (proc.stdout or proc.stderr).strip().splitlines()
165
+ return LoginState(proc.returncode == 0, detail[0] if detail else "")
166
+
167
+ def login(self) -> int:
168
+ return subprocess.call(["codex", "login"])
169
+
170
+ def complete_json(
171
+ self, prompt: str, *, schema: dict[str, Any], system: str = "", cwd: Path | None = None, timeout: int = 1200
172
+ ) -> dict[str, Any]:
173
+ if not self.available():
174
+ raise ToolMissing("`codex` is not installed")
175
+ full = f"{system}\n\n{prompt}" if system else prompt
176
+ with tempfile.TemporaryDirectory(prefix="rung-codex-") as tmp:
177
+ out, schema_file = Path(tmp) / "last.json", Path(tmp) / "schema.json"
178
+ schema_file.write_text(json.dumps(schema))
179
+ # Read-only sandbox: Codex may read the repository but cannot change or run anything in it.
180
+ cmd = ["codex", "exec", "--skip-git-repo-check", "--ephemeral", "-s", "read-only",
181
+ "--output-schema", str(schema_file), "-o", str(out)] # fmt: skip
182
+ if self.config.model:
183
+ cmd += ["-m", self.config.model]
184
+ cmd.append("-")
185
+ proc = subprocess.run(cmd, input=full, cwd=cwd or tmp, capture_output=True, text=True, timeout=timeout)
186
+ if proc.returncode != 0:
187
+ raise ProviderError(f"codex exited {proc.returncode}: {proc.stderr.strip()[-500:]}")
188
+ try:
189
+ result = json.loads(out.read_text())
190
+ except (OSError, json.JSONDecodeError) as exc:
191
+ raise ProviderError("codex did not return the requested JSON") from exc
192
+ # Codex does not report which model answered; record what was requested, and say so.
193
+ if model := self.config.model or _codex_default_model():
194
+ self.models_used.add(f"{model}, as configured")
195
+ check_shape(result, schema)
196
+ return result
197
+
198
+
199
+ def _codex_default_model() -> str:
200
+ """The model `codex exec` uses without `-m`, from its config (with the reasoning effort, if set)."""
201
+ path = Path(os.environ.get("CODEX_HOME") or Path.home() / ".codex") / "config.toml"
202
+ try:
203
+ config = tomllib.loads(path.read_text())
204
+ except (OSError, tomllib.TOMLDecodeError):
205
+ return ""
206
+ model = str(config.get("model") or "")
207
+ effort = config.get("model_reasoning_effort")
208
+ return f"{model} ({effort} effort)" if model and effort else model
209
+
210
+
211
+ class OpenAICompatible(Provider):
212
+ name = "openai-compatible"
213
+
214
+ def available(self) -> bool:
215
+ return bool(self.config.base_url)
216
+
217
+ def login_state(self) -> LoginState:
218
+ if not self.config.base_url:
219
+ return LoginState(False, "set [llm] base_url in rung.toml")
220
+ has_key = bool(os.environ.get(self.config.api_key_env))
221
+ return LoginState(True, f"{self.config.base_url} (key from ${self.config.api_key_env}: "
222
+ f"{'set' if has_key else 'not set'})") # fmt: skip
223
+
224
+ def login(self) -> int:
225
+ print(f"Export your key as ${self.config.api_key_env}; Rung never stores it.")
226
+ return 0
227
+
228
+ def _post(self, body: dict[str, Any], timeout: int) -> dict[str, Any]:
229
+ headers = {"Content-Type": "application/json"}
230
+ if key := os.environ.get(self.config.api_key_env):
231
+ headers["Authorization"] = f"Bearer {key}"
232
+ url = self.config.base_url.rstrip("/") + "/chat/completions"
233
+ request = urllib.request.Request(url, data=json.dumps(body).encode(), headers=headers, method="POST")
234
+ with urllib.request.urlopen(request, timeout=timeout) as resp:
235
+ return json.loads(resp.read())
236
+
237
+ def complete_json(
238
+ self, prompt: str, *, schema: dict[str, Any], system: str = "", cwd: Path | None = None, timeout: int = 1200
239
+ ) -> dict[str, Any]:
240
+ if not self.config.base_url:
241
+ raise ToolMissing("set [llm] base_url for the openai-compatible provider")
242
+ messages = ([{"role": "system", "content": system}] if system else []) + [{"role": "user", "content": prompt}]
243
+ body: dict[str, Any] = {
244
+ "model": self.config.model or "default",
245
+ "messages": messages,
246
+ "response_format": {
247
+ "type": "json_schema",
248
+ "json_schema": {"name": "rung", "schema": schema, "strict": True},
249
+ },
250
+ }
251
+ try:
252
+ payload = self._post(body, timeout)
253
+ except urllib.error.HTTPError as exc:
254
+ if exc.code not in (400, 422):
255
+ raise ProviderError(f"{self.config.base_url} returned HTTP {exc.code}") from exc
256
+ # The server does not support json_schema: ask in the prompt and extract the object.
257
+ body.pop("response_format")
258
+ messages[-1]["content"] += "\n\nReply with only one JSON object matching this schema:\n" + json.dumps(
259
+ schema
260
+ )
261
+ payload = self._post(body, timeout)
262
+ except urllib.error.URLError as exc:
263
+ raise ProviderError(f"cannot reach {self.config.base_url}: {exc.reason}") from exc
264
+ if payload.get("model"):
265
+ self.models_used.add(str(payload["model"]))
266
+ content = payload["choices"][0]["message"]["content"]
267
+ match = re.search(r"\{[\s\S]*\}", content)
268
+ try:
269
+ result = json.loads(match.group(0) if match else content)
270
+ except json.JSONDecodeError as exc:
271
+ raise ProviderError("the model did not return valid JSON") from exc
272
+ check_shape(result, schema)
273
+ return result
274
+
275
+
276
+ _REGISTRY: dict[str, type[Provider]] = {
277
+ "claude-cli": ClaudeCLI,
278
+ "codex-cli": CodexCLI,
279
+ "openai-compatible": OpenAICompatible,
280
+ "none": Provider,
281
+ }
282
+
283
+
284
+ def get(config: LLMConfig) -> Provider:
285
+ cls = _REGISTRY.get(config.provider)
286
+ if cls is None:
287
+ raise ValueError(f"unknown provider {config.provider!r}; choose one of {', '.join(PROVIDERS)}")
288
+ return cls(config)
289
+
290
+
291
+ def detect() -> list[str]:
292
+ """Providers usable on this machine right now, best first."""
293
+ found = [name for name, cls in (("claude-cli", ClaudeCLI), ("codex-cli", CodexCLI)) if cls(LLMConfig()).available()]
294
+ return [*found, "openai-compatible", "none"]
@@ -0,0 +1,186 @@
1
+ """`rung draft runtime`: generate a repo-specific verification script, and make it prove itself.
2
+
3
+ The idea comes from Lauren Tan's pstack (`/create-verification-skill`): every
4
+ project needs a scripted way to drive the real app the way a user does and
5
+ capture evidence, plus a feature map saying how each feature is reached and what
6
+ observable state proves it works. pstack states the key rule in prose: a
7
+ generated skill that was never executed is a draft, not a deliverable.
8
+
9
+ Rung enforces that rule mechanically. The model writes the drive script and the
10
+ feature map; Rung runs the script as an `evidence` check and feeds failures back
11
+ for repair. Nothing is registered until a run has passed and left evidence. The
12
+ feature map's sources are covered by the check, so when the app changes the
13
+ verdict goes stale and the script must be re-proven.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ import shlex
20
+ import shutil
21
+ from dataclasses import dataclass, field
22
+ from datetime import UTC, datetime
23
+ from pathlib import Path
24
+
25
+ from rung import guides
26
+ from rung.analysis.survey import Inspection
27
+ from rung.checks import deps
28
+ from rung.checks.base import Context
29
+ from rung.checks.command import EvidenceChecker
30
+ from rung.core import git
31
+ from rung.core.config import Check
32
+ from rung.core.errors import ToolMissing
33
+ from rung.drafting.common import FILES, STRINGS, files_of, safe_write, schema
34
+ from rung.drafting.providers import Provider
35
+
36
+ VERIFY_DIR = Path(".rung") / "verify"
37
+ CONTEXT_FILES = (
38
+ "README.md", "README.rst", "package.json", "pyproject.toml", "Makefile", "justfile", "Procfile",
39
+ "docker-compose.yml", "compose.yaml", "manage.py", "main.py", "app.py", "go.mod", "Cargo.toml",
40
+ ) # fmt: skip
41
+ MAX_CONTEXT_BYTES = 60_000
42
+ SCHEMA = schema(files=FILES, sources=STRINGS, requirements=STRINGS, notes={"type": "string"})
43
+
44
+ FORMAT = """
45
+
46
+ Return `files` with `drive.py` and `FEATURES.md`, `sources` with globs of the source files the driven
47
+ feature depends on, `requirements` with the Python packages the script imports beyond the standard
48
+ library (e.g. `playwright`; empty if none), and `notes` with anything the developer should know."""
49
+
50
+
51
+ MIN_PACKAGE_AGE_DAYS = 30
52
+
53
+
54
+ def vet_requirements(requirements: list[str]) -> list[str]:
55
+ """Problems with packages a model wants to install: missing from PyPI, or too new to trust.
56
+
57
+ The same rule the `deps` check applies to a project's own dependencies: a model can invent a
58
+ package name, and an attacker can register it.
59
+ """
60
+ problems = []
61
+ for spec in requirements:
62
+ name = deps.requirement_name(spec)
63
+ if not name:
64
+ problems.append(f"{spec!r} is not a package requirement")
65
+ continue
66
+ published = deps.first_published(deps.Package("pypi", name))
67
+ if published is None:
68
+ problems.append(f"{name} does not exist on PyPI")
69
+ elif (age := (datetime.now(UTC) - published).days) < MIN_PACKAGE_AGE_DAYS:
70
+ problems.append(f"{name} was first published {age} day(s) ago")
71
+ return problems
72
+
73
+
74
+ def run_command(requirements: list[str]) -> str:
75
+ """How Rung runs the drive script: plain Python, or an isolated uv environment with its packages."""
76
+ script = (VERIFY_DIR / "drive.py").as_posix()
77
+ if not requirements:
78
+ return f"python3 {script}"
79
+ if not shutil.which("uv"):
80
+ raise ToolMissing(f"the drive script needs {', '.join(requirements)}; install uv (https://docs.astral.sh/uv/)")
81
+ withs = " ".join(f"--with {shlex.quote(r)}" for r in requirements)
82
+ return f"uv run --no-project {withs} python {script}"
83
+
84
+
85
+ @dataclass
86
+ class RuntimeDraft:
87
+ ok: bool
88
+ message: str
89
+ files: list[Path] = field(default_factory=list)
90
+ rounds: int = 0
91
+ covers: list[str] = field(default_factory=list)
92
+ evidence: list[str] = field(default_factory=list)
93
+ notes: str = ""
94
+ run: str = ""
95
+
96
+
97
+ # Files that usually show how an app starts and what a user sees first, per project directory.
98
+ ENTRY_GLOBS = (
99
+ "README.md", "package.json", "pyproject.toml", "app/page.*", "src/app/page.*", "app/layout.*",
100
+ "src/app/layout.*", "pages/index.*", "src/pages/index.*", "src/main.*", "src/index.*", "main.py", "app.py",
101
+ "manage.py", "cmd/*/main.go", "src/main.rs",
102
+ ) # fmt: skip
103
+ MAX_LISTED_FILES = 400
104
+ SURVEY_KEYS = ("languages", "surfaces", "test_commands", "ci", "projects")
105
+
106
+
107
+ def _context_files(root: Path, info: Inspection, focus: list[str], files: list[str]) -> list[str]:
108
+ """Focus files first, then each project's docs, manifests and entry points, then root build files."""
109
+ chosen: list[str] = list(focus)
110
+ for project in info.projects:
111
+ prefix = "" if project.path == "." else f"{project.path}/"
112
+ for pattern in ENTRY_GLOBS:
113
+ chosen += [f for f in files if git.glob_match(prefix + pattern, f)]
114
+ chosen += list(CONTEXT_FILES)
115
+ return list(dict.fromkeys(chosen))
116
+
117
+
118
+ def gather_context(root: Path, info: Inspection, focus: list[str]) -> str:
119
+ """What the model sees: the survey, a file list, and the project's own docs and entry points.
120
+
121
+ Files near the focus come first in the listing, so a sub-project (`frontend/`) is never hidden
122
+ behind hundreds of unrelated files that sort earlier.
123
+ """
124
+ files = git.list_files(root)
125
+ focus_dirs = {str(Path(f).parent) for f in focus} - {"."}
126
+ near = [f for f in files if any(f.startswith(d + "/") for d in focus_dirs)]
127
+ listing = list(dict.fromkeys(near + files))[:MAX_LISTED_FILES]
128
+ parts = [
129
+ "Survey:",
130
+ json.dumps({k: info.to_dict()[k] for k in SURVEY_KEYS}, indent=1),
131
+ "",
132
+ f"Files ({len(listing)} of {len(files)}, those near the focus first):",
133
+ "\n".join(listing),
134
+ ]
135
+ budget = MAX_CONTEXT_BYTES
136
+ for rel in _context_files(root, info, focus, files):
137
+ path = root / rel
138
+ if not path.is_file() or budget <= 0:
139
+ continue
140
+ text = path.read_text(errors="ignore")[: min(budget, 12_000)]
141
+ budget -= len(text)
142
+ parts += ["", f"--- {rel} ---", text]
143
+ return "\n".join(parts)
144
+
145
+
146
+ def draft_runtime(
147
+ provider: Provider, root: Path, info: Inspection, *, focus: list[str], check_id: str = "runtime",
148
+ intent: str = "", max_rounds: int = 4, require: tuple[str, ...] = (),
149
+ ) -> RuntimeDraft: # fmt: skip
150
+ out_dir = root / VERIFY_DIR
151
+ prompt = (
152
+ "Write the verification script and feature map for this project.\n"
153
+ + (f"Drive this feature: {intent}\n" if intent else "")
154
+ + (f"The evidence must include: {', '.join(require)} (e.g. `.webm` means a video).\n" if require else "")
155
+ + "\n"
156
+ + gather_context(root, info, focus)
157
+ )
158
+ files: list[Path] = []
159
+ for round_no in range(1, max_rounds + 1):
160
+ reply = provider.complete_json(prompt, schema=SCHEMA, system=guides.load("runtime") + FORMAT, cwd=root)
161
+ produced = files_of(reply)
162
+ if "drive.py" not in produced or "FEATURES.md" not in produced:
163
+ prompt += "\n\nYour reply must contain both `drive.py` and `FEATURES.md` in `files`."
164
+ continue
165
+ files = safe_write(out_dir, produced)
166
+ covers = [g.strip() for g in reply["sources"] if g.strip()]
167
+ requirements = [p.strip() for p in reply["requirements"] if p.strip()]
168
+ if problems := vet_requirements(requirements):
169
+ prompt += (
170
+ "\n\nRung will not install these requirements: "
171
+ + "; ".join(problems)
172
+ + ". Use only established packages (or none). Return the same fields."
173
+ )
174
+ continue
175
+ run = run_command(requirements)
176
+ check = Check(check_id, "evidence", covers, {"run": run, "timeout": 900, "require": list(require)})
177
+ outcome = EvidenceChecker().run(Context(root=root), check)
178
+ if outcome.verdict == "pass":
179
+ return RuntimeDraft(True, outcome.summary, files, round_no, covers, list(outcome.evidence),
180
+ reply["notes"].strip(), run) # fmt: skip
181
+ prompt += (
182
+ f"\n\nRung ran your script and it did not prove the feature ({outcome.summary}):\n"
183
+ + "\n".join(outcome.lines[-30:])
184
+ + "\nFix the script. Do not weaken what it checks. Return the same fields."
185
+ )
186
+ return RuntimeDraft(False, f"no passing verification script after {max_rounds} rounds", files, max_rounds)
@@ -0,0 +1,63 @@
1
+ """A disposable copy of a project, for running model-written tests and deliberate bugs.
2
+
3
+ Model-written test code runs before any person has read it, and mutation testing edits source files.
4
+ Neither should touch the developer's working tree, so both happen in a copy:
5
+
6
+ - every file git would show (tracked, plus untracked files that are not ignored) is copied, so
7
+ ignored local files such as `.env` are not there;
8
+ - dependency directories (`node_modules`, `.venv`) are linked rather than copied, since they are
9
+ large and only read;
10
+ - environment variables that look like credentials are removed for the run.
11
+
12
+ This is damage control, not a security sandbox: a test can still reach the network and any path it
13
+ names explicitly. Read a drafted test before you trust it.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import os
19
+ import re
20
+ import shutil
21
+ import tempfile
22
+ from collections.abc import Iterator
23
+ from contextlib import contextmanager
24
+ from pathlib import Path
25
+
26
+ from rung.core import git
27
+
28
+ DEPENDENCY_DIRS = ("node_modules", ".venv", "venv", ".lake")
29
+ SECRET_NAME = re.compile(r"KEY|TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIAL|PRIVATE|SESSION|COOKIE|AUTH", re.I)
30
+ KEEP = {"PATH", "HOME", "USER", "LANG", "LC_ALL", "TERM", "TMPDIR", "SHELL", "UV_CACHE_DIR", "XDG_CACHE_HOME"}
31
+
32
+
33
+ @contextmanager
34
+ def copy_of(root: Path) -> Iterator[Path]:
35
+ """Yield a temporary copy of the project at `root`; it is deleted afterwards."""
36
+ tmp = Path(tempfile.mkdtemp(prefix="rung-sandbox-"))
37
+ copy = tmp / root.name
38
+ try:
39
+ dirs: set[Path] = {Path(".")}
40
+ for rel in git.list_files(root):
41
+ if git.is_rung_output(rel):
42
+ continue
43
+ source, target = root / rel, copy / rel
44
+ target.parent.mkdir(parents=True, exist_ok=True)
45
+ if source.is_symlink():
46
+ target.symlink_to(os.readlink(source))
47
+ else:
48
+ shutil.copy2(source, target)
49
+ dirs.update(Path(rel).parents)
50
+ for d in dirs:
51
+ for name in DEPENDENCY_DIRS:
52
+ if (root / d / name).is_dir() and not (copy / d / name).exists():
53
+ (copy / d).mkdir(parents=True, exist_ok=True)
54
+ (copy / d / name).symlink_to(root / d / name, target_is_directory=True)
55
+ yield copy
56
+ finally:
57
+ shutil.rmtree(tmp, ignore_errors=True)
58
+
59
+
60
+ def scrubbed_env(extra: dict[str, str] | None = None) -> dict[str, str]:
61
+ """The current environment without variables whose names look like credentials."""
62
+ env = {k: v for k, v in os.environ.items() if k in KEEP or not SECRET_NAME.search(k)}
63
+ return env | (extra or {})
rung/drafting/tla.py ADDED
@@ -0,0 +1,97 @@
1
+ """`rung draft tla`: a model writes a TLA+ spec that mirrors the code; TLC decides."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from pathlib import Path
7
+
8
+ from rung import guides
9
+ from rung.drafting.common import FILES, LAWS, LAWS_FORMAT, DraftResult, files_of, safe_write, schema, source_block
10
+ from rung.drafting.providers import Provider
11
+ from rung.engines import tla
12
+
13
+ FORMAT = (
14
+ """
15
+
16
+ Return `files` with exactly two entries: `<Module>.tla` (starting `---- MODULE <Module> ----` and ending
17
+ `====`) and `<Module>.cfg`. Put anything you want the developer to know in `notes`."""
18
+ + LAWS_FORMAT
19
+ )
20
+ SCHEMA = schema(files=FILES, laws=LAWS, notes={"type": "string"})
21
+
22
+ # Slips language models make in TLA+ that are safe to correct mechanically before SANY sees the spec.
23
+ UNICODE_OPERATORS = {
24
+ "\u2227": "/\\", # ∧
25
+ "\u2228": "\\/", # ∨
26
+ "\u00ac": "~", # ¬
27
+ "\u2208": "\\in", # ∈
28
+ "\u2209": "\\notin", # ∉
29
+ "\u21d2": "=>", # ⇒
30
+ "\u2192": "->", # →
31
+ "\u21d4": "<=>", # ⇔
32
+ "\u2260": "#", # ≠
33
+ "\u2264": "<=", # ≤
34
+ "\u2265": ">=", # ≥
35
+ "\u222a": "\\cup", # ∪
36
+ "\u2229": "\\cap", # ∩
37
+ "\u2286": "\\subseteq", # ⊆
38
+ "\u2200": "\\A", # ∀
39
+ "\u2203": "\\E", # ∃
40
+ "\u225c": "==", # ≜
41
+ "\u25a1": "[]", # □
42
+ "\u25c7": "<>", # ◇
43
+ "\u27e8": "<<", # ⟨
44
+ "\u27e9": ">>", # ⟩
45
+ "\u2032": "'", # ′
46
+ }
47
+
48
+
49
+ def normalize(spec: str) -> str:
50
+ """Replace Unicode operators with ASCII and drop stray Markdown fences."""
51
+ for uni, ascii_op in UNICODE_OPERATORS.items():
52
+ spec = spec.replace(uni, ascii_op)
53
+ return re.sub(r"^```.*$\n?", "", spec, flags=re.M)
54
+
55
+
56
+ def draft(
57
+ provider: Provider, root: Path, sources: list[Path], out_dir: Path, *, intent: str = "", max_rounds: int = 4
58
+ ) -> DraftResult:
59
+ system = guides.load("tla") + FORMAT
60
+ prompt = (
61
+ "Write a TLA+ model of this code.\n"
62
+ + (f"What it must guarantee (from the developer): {intent}\n" if intent else "")
63
+ + "\n"
64
+ + source_block(root, sources)
65
+ )
66
+ files: list[Path] = []
67
+ for round_no in range(1, max_rounds + 1):
68
+ reply = provider.complete_json(prompt, schema=SCHEMA, system=system, cwd=root)
69
+ produced, notes, laws = files_of(reply), reply["notes"].strip(), reply["laws"]
70
+ spec_name = next((n for n in produced if n.endswith(".tla")), None)
71
+ cfg_name = next((n for n in produced if n.endswith(".cfg")), None)
72
+ if not spec_name or not cfg_name:
73
+ prompt += "\n\nYour reply must contain one `.tla` file and one `.cfg` file."
74
+ continue
75
+ produced[spec_name] = normalize(produced[spec_name])
76
+ files = safe_write(out_dir, produced)
77
+ result = tla.run(out_dir / spec_name, out_dir / cfg_name)
78
+ if result.verdict == "fail":
79
+ return DraftResult(
80
+ True,
81
+ f"TLC found a counterexample: {result.reason}. Decide: real bug, or wrong spec?",
82
+ files,
83
+ round_no,
84
+ tla.render_trace(result.trace),
85
+ notes=notes,
86
+ laws=laws,
87
+ )
88
+ if result.verdict == "pass" and result.distinct_states > 1:
89
+ return DraftResult(True, f"model-checked {result.distinct_states} distinct states, no violation",
90
+ files, round_no, notes=notes, laws=laws) # fmt: skip
91
+ problem = (
92
+ (result.reason + "\n" + result.output[-2000:])
93
+ if result.verdict == "error"
94
+ else "TLC explored at most one state, so the invariants are vacuous. Make Init and Next reachable."
95
+ )
96
+ prompt += f"\n\nTLC rejected your previous spec:\n{problem}\nFix the spec (not the invariants)."
97
+ return DraftResult(False, f"no checkable spec after {max_rounds} rounds", files, max_rounds)
@@ -0,0 +1 @@
1
+ """Engines: thin wrappers around TLC, Lean, differential testing and SARIF; they know nothing of rung.toml."""