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,174 @@
1
+ """Commands that set a project up: init, plan, doctor, agents, hook."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import shutil
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ from rung import agents
10
+ from rung.analysis import plan, survey
11
+ from rung.checks.presets import PRESETS
12
+ from rung.cli.commands.common import OK, ask
13
+ from rung.cli.output import Output
14
+ from rung.core import laws
15
+ from rung.core.config import CONFIG_NAME, Check, Config, ConfigError, LLMConfig, find_root, load, save
16
+ from rung.core.errors import ToolMissing
17
+ from rung.drafting import providers
18
+ from rung.engines import lean, tla
19
+
20
+
21
+ def _choose_provider(requested: str | None) -> LLMConfig:
22
+ if requested:
23
+ return LLMConfig(provider=requested)
24
+ options = providers.detect()
25
+ # Prompts go to stderr so `--json` output on stdout stays one clean document.
26
+ print("\nWhich model should draft specs, proofs and explanations?", file=sys.stderr)
27
+ for i, name in enumerate(options, 1):
28
+ print(f" {i}. {name:18} {providers.PROVIDERS[name]}", file=sys.stderr)
29
+ picked = ask("Choose", "1")
30
+ name = options[int(picked) - 1] if picked.isdigit() and 0 < int(picked) <= len(options) else picked
31
+ if name not in providers.PROVIDERS:
32
+ raise ConfigError(f"unknown provider {name!r}")
33
+ llm = LLMConfig(provider=name)
34
+ if name == "openai-compatible":
35
+ llm.base_url = ask("Base URL", "http://localhost:11434/v1")
36
+ llm.model = ask("Model name", "qwen2.5-coder")
37
+ return llm
38
+
39
+
40
+ def cmd_init(args, out: Output) -> int:
41
+ root = Path(args.dir).resolve()
42
+ existing = (root / CONFIG_NAME).exists()
43
+ cfg = load(root) if existing else Config(root=root, name=root.name, llm=LLMConfig(), checks=[])
44
+ info = survey.survey(root)
45
+
46
+ if not existing or args.provider:
47
+ cfg.llm = _choose_provider(args.provider)
48
+ if args.model is not None:
49
+ cfg.llm.model = args.model
50
+ provider = providers.get(cfg.llm)
51
+ state = provider.login_state()
52
+ needs_login = not state.ok and cfg.llm.provider in ("claude-cli", "codex-cli")
53
+ if needs_login and ask("Not logged in. Log in now? (y/n)", "y").lower().startswith("y"):
54
+ provider.login()
55
+ state = provider.login_state()
56
+
57
+ added = []
58
+ if not existing:
59
+ # Wire up the security scanners that are installed and relevant to this repo.
60
+ for check_id, tool in (
61
+ ("secrets", "gitleaks"),
62
+ ("vulnerable-deps", "osv-scanner"),
63
+ ("workflows", "zizmor"),
64
+ ):
65
+ relevant = tool != "zizmor" or (root / ".github" / "workflows").is_dir()
66
+ if shutil.which(tool) and relevant:
67
+ cfg.checks.append(Check(check_id, "scan", [], {"tool": tool}))
68
+ added.append(check_id)
69
+ cfg.checks.append(Check("deps", "deps", [], {"min_age_days": 30}))
70
+ added.append("deps")
71
+ # Record each detected test suite, covering its own project directory.
72
+ for project in info.projects:
73
+ if not project.test_command:
74
+ continue
75
+ check_id = plan.static_check_id(project.path, "tests")
76
+ options = {"run": project.test_command} | ({} if project.path == "." else {"cwd": project.path})
77
+ covers = [] if project.path == "." else [f"{project.path}/**"]
78
+ cfg.checks.append(Check(check_id, "command", covers, options))
79
+ added.append(check_id)
80
+ # The type checkers and linters the project already configures, and the shortcuts that silence them.
81
+ for project in info.projects:
82
+ for kind, command in project.static:
83
+ check_id = plan.static_check_id(project.path, kind)
84
+ if any(c.id == check_id for c in cfg.checks):
85
+ continue
86
+ options = {"run": command} | ({} if project.path == "." else {"cwd": project.path})
87
+ covers = [] if project.path == "." else [f"{project.path}/**"]
88
+ cfg.checks.append(Check(check_id, "command", covers, options))
89
+ added.append(check_id)
90
+ if info.languages:
91
+ cfg.checks.append(Check("shortcuts", "shortcuts", [], {}))
92
+ added.append("shortcuts")
93
+
94
+ save(cfg)
95
+ (root / ".rung").mkdir(exist_ok=True)
96
+ if not (root / laws.LAWS_FILE).exists():
97
+ (root / laws.LAWS_FILE).write_text(laws.TEMPLATE)
98
+ recs = plan.plan(info, {c.id for c in cfg.checks})
99
+ out.emit({"root": str(root), "provider": cfg.llm.provider, "login": state.detail,
100
+ "added_checks": added, "survey": info.to_dict(), "plan": [r.__dict__ for r in recs]}) # fmt: skip
101
+ langs = ", ".join(f"{k} ({v})" for k, v in list(info.languages.items())[:4]) or "none found"
102
+ out.line(f"Surveyed {root}")
103
+ out.line(f" languages: {langs}")
104
+ out.line(f" surfaces: {', '.join(info.surfaces) or 'none found'}")
105
+ out.line(f" tests: {', '.join(info.test_commands) or 'none found'}")
106
+ out.line(f"Model provider: {cfg.llm.provider} ({state.detail})")
107
+ if added:
108
+ out.line(f"Added checks: {', '.join(added)}")
109
+ out.line(f"Wrote {CONFIG_NAME}, {laws.LAWS_FILE} and .rung/.")
110
+ _print_plan(recs, out)
111
+ out.line("\nNext: `rung agents --hooks`, then `rung check`.")
112
+ return OK
113
+
114
+
115
+ def _print_plan(recs: list[plan.Recommendation], out: Output) -> None:
116
+ out.line("\nRecommended next steps:")
117
+ for i, rec in enumerate(recs, 1):
118
+ out.line(f"\n {i}. {out.style(rec.title, 'bold')} [{rec.rung}, effort {rec.effort}]")
119
+ out.detail(rec.why, indent=4)
120
+ if rec.command:
121
+ out.line(f" {out.style('$ ' + rec.command, 'blue')}")
122
+
123
+
124
+ def cmd_plan(args, out: Output) -> int:
125
+ root = Path(args.dir).resolve()
126
+ try:
127
+ existing = {c.id for c in load(find_root(root)).checks}
128
+ except ConfigError:
129
+ existing = set()
130
+ info = survey.survey(root)
131
+ recs = plan.plan(info, existing)
132
+ out.emit({"survey": info.to_dict(), "plan": [r.__dict__ for r in recs]})
133
+ _print_plan(recs, out)
134
+ return OK
135
+
136
+
137
+ def cmd_doctor(args, out: Output) -> int:
138
+ rows: list[tuple[str, bool, str]] = []
139
+ for label, probe in (
140
+ ("java (TLC)", tla.find_java),
141
+ ("tla2tools.jar", tla.find_jar),
142
+ ("lake (Lean)", lean.find_lake),
143
+ ):
144
+ try:
145
+ rows.append((label, True, str(probe())))
146
+ except ToolMissing as exc:
147
+ rows.append((label, False, str(exc)))
148
+ for name, preset in PRESETS.items():
149
+ found = shutil.which(name)
150
+ note = "" if preset.verified else " (preset not yet verified against real output)"
151
+ rows.append((name, bool(found), (found or f"not installed: {preset.install}") + note))
152
+ for name in ("claude-cli", "codex-cli"):
153
+ state = providers.get(LLMConfig(provider=name)).login_state()
154
+ rows.append((name, state.ok, state.detail))
155
+ out.emit([{"tool": label, "ok": ok, "detail": detail} for label, ok, detail in rows])
156
+ for label, ok, detail in rows:
157
+ mark = out.style("ok ", "green") if ok else out.style("-- ", "dim")
158
+ out.line(f"{mark} {label:14} {detail}")
159
+ return OK
160
+
161
+
162
+ def cmd_agents(args, out: Output) -> int:
163
+ root = find_root(Path(args.dir))
164
+ written = agents.install(root, hooks=args.hooks, lean_version=lean.toolchain().split(":")[-1])
165
+ out.emit({"written": [str(p.relative_to(root)) for p in written]})
166
+ for path in written:
167
+ out.line(f"wrote {path.relative_to(root)}")
168
+ if args.hooks:
169
+ out.line("Claude Code will now run `rung hook stop` before an agent finishes.")
170
+ return OK
171
+
172
+
173
+ def cmd_hook(args, out: Output) -> int:
174
+ return agents.main_stop_hook()
rung/cli/output.py ADDED
@@ -0,0 +1,54 @@
1
+ """Terminal output: colour when attached to a terminal, plain text otherwise, JSON on request."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import sys
8
+ from typing import Any
9
+
10
+ _CODES = {"red": "31", "green": "32", "yellow": "33", "blue": "34", "dim": "2", "bold": "1"}
11
+ VERDICT_STYLE = {"pass": ("PASS", "green"), "fail": ("FAIL", "red"), "error": ("ERROR", "yellow")}
12
+ STATE_STYLE = {
13
+ "fresh": ("fresh", "green"),
14
+ "stale": ("STALE", "yellow"),
15
+ "failing": ("FAILING", "red"),
16
+ "never": ("never run", "dim"),
17
+ "held": ("HELD", "green"),
18
+ "unproven": ("UNPROVEN", "yellow"),
19
+ "broken": ("BROKEN", "red"),
20
+ "proposed": ("PROPOSED", "blue"),
21
+ "unenforced": ("UNENFORCED", "dim"),
22
+ }
23
+
24
+
25
+ class Output:
26
+ def __init__(self, *, json_mode: bool = False, color: bool | None = None) -> None:
27
+ self.json_mode = json_mode
28
+ if color is None:
29
+ color = sys.stdout.isatty() and "NO_COLOR" not in os.environ and os.environ.get("TERM") != "dumb"
30
+ self.color = color
31
+
32
+ def style(self, text: str, name: str) -> str:
33
+ return f"\033[{_CODES[name]}m{text}\033[0m" if self.color else text
34
+
35
+ def badge(self, key: str, table: dict[str, tuple[str, str]], width: int = 9) -> str:
36
+ label, colour = table.get(key, (key, "dim"))
37
+ return self.style(label.ljust(width), colour)
38
+
39
+ def line(self, text: str = "") -> None:
40
+ if not self.json_mode:
41
+ print(text)
42
+
43
+ def detail(self, text: str, indent: int = 6) -> None:
44
+ if not self.json_mode:
45
+ for part in text.splitlines() or [""]:
46
+ print(" " * indent + part)
47
+
48
+ def emit(self, payload: Any) -> None:
49
+ """In JSON mode, print the command's result as one JSON document."""
50
+ if self.json_mode:
51
+ print(json.dumps(payload, indent=2, default=str))
52
+
53
+ def error(self, text: str) -> None:
54
+ print(f"rung: {text}", file=sys.stderr)
rung/core/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """Foundations every other layer builds on: configuration, the ledger, laws, git and errors."""
rung/core/config.py ADDED
@@ -0,0 +1,278 @@
1
+ """`rung.toml`: the project's declared checks and model provider.
2
+
3
+ The file is small and hand-editable. Rung reads it with `tomllib`, validates every field it
4
+ understands before running anything, and writes it with a serializer that covers the shapes
5
+ Rung itself produces. A check's id becomes part of file paths (baselines, evidence), so ids are
6
+ restricted to letters, digits, `.`, `_` and `-`.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import difflib
12
+ import json
13
+ import os
14
+ import re
15
+ import shlex
16
+ import tempfile
17
+ import tomllib
18
+ from dataclasses import dataclass, field
19
+ from pathlib import Path
20
+ from typing import Any
21
+
22
+ from rung.core.errors import RungError
23
+
24
+ CONFIG_NAME = "rung.toml"
25
+ CHECK_KINDS = ("command", "evidence", "scan", "shortcuts", "deps", "tla", "lean", "drt")
26
+ CHECK_ID = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,63}\Z")
27
+ # Options that must be non-negative integers wherever they appear.
28
+ INT_OPTIONS = ("timeout", "cases", "min_len", "max_len", "seed", "min_age_days")
29
+ # Options that must be lists of strings wherever they appear.
30
+ LIST_OPTIONS = ("specs", "require", "allow", "theorems", "alphabet")
31
+ # Every option each kind understands; anything else is a typo that would otherwise do nothing.
32
+ COMMON_OPTIONS = ("timeout", "max_age_days", "specs", "drafted_by")
33
+ KIND_OPTIONS = {
34
+ "command": ("run", "cwd"),
35
+ "evidence": ("run", "cwd", "require"),
36
+ "scan": ("tool", "run", "cwd", "fail_on", "default_level"),
37
+ "shortcuts": ("allow", "fail_on"),
38
+ "deps": ("allow", "min_age_days"),
39
+ "tla": ("spec", "config"),
40
+ "lean": ("project", "theorems"),
41
+ "drt": ("model", "impl", "model_cwd", "impl_cwd", "alphabet", "cases", "min_len", "max_len", "seed"),
42
+ }
43
+
44
+
45
+ class ConfigError(RungError, ValueError):
46
+ """`rung.toml` is missing, malformed, or declares an invalid check."""
47
+
48
+
49
+ @dataclass
50
+ class Check:
51
+ """One verification check: a spec, a proof, a differential test, or a command."""
52
+
53
+ id: str
54
+ kind: str
55
+ covers: list[str] = field(default_factory=list)
56
+ options: dict[str, Any] = field(default_factory=dict)
57
+
58
+ def get(self, key: str, default: Any = None) -> Any:
59
+ return self.options.get(key, default)
60
+
61
+ def require(self, key: str) -> Any:
62
+ if key not in self.options:
63
+ raise ConfigError(f"check {self.id!r} ({self.kind}) needs `{key}`")
64
+ return self.options[key]
65
+
66
+ def get_int(self, key: str, default: int) -> int:
67
+ value = self.options.get(key, default)
68
+ if isinstance(value, bool) or not isinstance(value, int) or value < 0:
69
+ raise ConfigError(f"check {self.id!r}: `{key}` must be a non-negative integer, not {value!r}")
70
+ return value
71
+
72
+ def get_list(self, key: str) -> list[str]:
73
+ value = self.options.get(key, [])
74
+ if not isinstance(value, list) or not all(isinstance(v, str) for v in value):
75
+ raise ConfigError(f"check {self.id!r}: `{key}` must be a list of strings")
76
+ return value
77
+
78
+
79
+ @dataclass
80
+ class LLMConfig:
81
+ provider: str = "none"
82
+ model: str = ""
83
+ base_url: str = ""
84
+ api_key_env: str = "RUNG_API_KEY"
85
+
86
+
87
+ @dataclass
88
+ class Config:
89
+ root: Path
90
+ name: str
91
+ llm: LLMConfig
92
+ checks: list[Check]
93
+
94
+ def check(self, check_id: str) -> Check:
95
+ for c in self.checks:
96
+ if c.id == check_id:
97
+ return c
98
+ raise ConfigError(f"no check named {check_id!r} in {CONFIG_NAME}")
99
+
100
+
101
+ def find_root(start: Path) -> Path:
102
+ """Walk up from `start` to the directory holding `rung.toml`."""
103
+ start = start.resolve()
104
+ for d in (start, *start.parents):
105
+ if (d / CONFIG_NAME).is_file():
106
+ return d
107
+ raise ConfigError(f"no {CONFIG_NAME} in {start} or its parents; run `rung init`")
108
+
109
+
110
+ def _unquoted_tokens(command: str) -> list[str]:
111
+ """Tokens with quoted text removed, so `node -e "a();"` is not mistaken for shell syntax."""
112
+ lexer = shlex.shlex(command, posix=False, punctuation_chars=False)
113
+ lexer.whitespace_split = True
114
+ try:
115
+ return [t for t in lexer if not (len(t) >= 2 and t[0] == t[-1] and t[0] in "'\"")]
116
+ except ValueError:
117
+ return command.split()
118
+
119
+
120
+ SHELL_TOKENS = {"&&", "||", "|", ";", ">", ">>", "<", "2>", "2>&1", "&"}
121
+ SHELLS = {"sh", "bash", "zsh", "dash", "fish", "cmd", "powershell", "pwsh"}
122
+
123
+
124
+ def safe_id(text: str) -> str:
125
+ """A check id made from a path or name: unsupported characters become `-`."""
126
+ return re.sub(r"[^A-Za-z0-9._-]+", "-", text).strip("-.")[:64] or "check"
127
+
128
+
129
+ def check_command(check_id: str, key: str, command: str) -> None:
130
+ """Commands run without a shell. Reject shell syntax instead of passing it to a program as arguments.
131
+
132
+ Without this, `cd src && pytest` runs `/usr/bin/cd` on macOS, which ignores its arguments and
133
+ exits 0: a pass that never ran the tests.
134
+ """
135
+ try:
136
+ argv = shlex.split(command)
137
+ except ValueError as exc:
138
+ raise ConfigError(f"check {check_id!r}: `{key}` is not a valid command ({exc})") from exc
139
+ if not argv:
140
+ raise ConfigError(f"check {check_id!r}: `{key}` is empty")
141
+ if Path(argv[0]).name in SHELLS:
142
+ return # an explicit shell, e.g. sh -c "cd src && pytest", is the supported way
143
+ problem = ""
144
+ bare = _unquoted_tokens(command)
145
+ if any(t in SHELL_TOKENS or t.startswith(("$(", "`")) or t.endswith(";") or "&&" in t or "||" in t for t in bare):
146
+ problem = "uses shell syntax (&&, |, ;, redirects)"
147
+ elif "=" in argv[0] and not argv[0].startswith(("-", "/", ".")):
148
+ problem = "starts with an environment assignment"
149
+ elif argv[0] == "cd":
150
+ problem = "starts with `cd` (use the check's `cwd` option instead)"
151
+ if problem:
152
+ raise ConfigError(
153
+ f"check {check_id!r}: `{key}` {problem}, but commands run without a shell. "
154
+ f'Use `cwd`, or wrap it explicitly: sh -c "{command}"'
155
+ )
156
+
157
+
158
+ def _validate_check(check: Check) -> None:
159
+ known = (*COMMON_OPTIONS, *KIND_OPTIONS[check.kind])
160
+ for key in check.options:
161
+ if key not in known:
162
+ close = difflib.get_close_matches(key, (*known, "covers"), n=1)
163
+ hint = f"; did you mean `{close[0]}`?" if close else f"; {check.kind} checks take {', '.join(known)}"
164
+ raise ConfigError(f"check {check.id!r}: unknown option `{key}`{hint}")
165
+ for key in INT_OPTIONS:
166
+ if key in check.options:
167
+ check.get_int(key, 0)
168
+ for key in LIST_OPTIONS:
169
+ if key in check.options:
170
+ check.get_list(key)
171
+ for key in ("run", "cwd", "tool", "spec", "config", "project", "model", "impl", "fail_on", "default_level",
172
+ "model_cwd", "impl_cwd"): # fmt: skip
173
+ if key in check.options and not isinstance(check.options[key], str):
174
+ raise ConfigError(f"check {check.id!r}: `{key}` must be a string")
175
+ for key in ("run", "model", "impl"):
176
+ if key in check.options:
177
+ check_command(check.id, key, check.options[key])
178
+
179
+
180
+ def load(root: Path) -> Config:
181
+ path = root / CONFIG_NAME
182
+ try:
183
+ raw = tomllib.loads(path.read_text())
184
+ except FileNotFoundError as exc:
185
+ raise ConfigError(f"{path} not found; run `rung init`") from exc
186
+ except tomllib.TOMLDecodeError as exc:
187
+ raise ConfigError(f"{path}: {exc}") from exc
188
+
189
+ llm_raw = raw.get("llm", {})
190
+ if not isinstance(llm_raw, dict):
191
+ raise ConfigError("[llm] must be a table")
192
+ llm = LLMConfig(**{k: str(v) for k, v in llm_raw.items() if k in LLMConfig.__dataclass_fields__})
193
+
194
+ entries = raw.get("check", [])
195
+ if not isinstance(entries, list):
196
+ raise ConfigError("checks are declared as [[check]] tables")
197
+ checks: list[Check] = []
198
+ seen: set[str] = set()
199
+ for i, entry in enumerate(entries):
200
+ entry = dict(entry)
201
+ check_id = entry.pop("id", None)
202
+ kind = entry.pop("kind", None)
203
+ if not check_id or not kind:
204
+ raise ConfigError(f"[[check]] #{i + 1} needs both `id` and `kind`")
205
+ if not isinstance(check_id, str) or not CHECK_ID.match(check_id):
206
+ raise ConfigError(f"check id {check_id!r} must be letters, digits, `.`, `_` or `-` (at most 64)")
207
+ if kind not in CHECK_KINDS:
208
+ raise ConfigError(f"check {check_id!r}: kind must be one of {', '.join(CHECK_KINDS)}")
209
+ if check_id in seen:
210
+ raise ConfigError(f"duplicate check id {check_id!r}")
211
+ seen.add(check_id)
212
+ covers = entry.pop("covers", [])
213
+ if not isinstance(covers, list) or not all(isinstance(p, str) for p in covers):
214
+ raise ConfigError(f'check {check_id!r}: `covers` must be a list of glob strings, e.g. ["src/**"]')
215
+ check = Check(id=check_id, kind=kind, covers=covers, options=entry)
216
+ _validate_check(check)
217
+ checks.append(check)
218
+
219
+ project = raw.get("project", {})
220
+ name = (project.get("name") if isinstance(project, dict) else None) or root.name
221
+ return Config(root=root, name=str(name), llm=llm, checks=checks)
222
+
223
+
224
+ def toml_value(value: Any) -> str:
225
+ """A TOML literal for the values Rung writes. Strings use JSON escaping, which is valid TOML."""
226
+ if isinstance(value, bool):
227
+ return "true" if value else "false"
228
+ if isinstance(value, (int, float)):
229
+ return repr(value)
230
+ if isinstance(value, str):
231
+ return json.dumps(value, ensure_ascii=False)
232
+ if isinstance(value, (list, tuple)):
233
+ return "[" + ", ".join(toml_value(v) for v in value) + "]"
234
+ raise TypeError(f"cannot write {type(value).__name__} to TOML")
235
+
236
+
237
+ def dump(config: Config) -> str:
238
+ lines = ["[project]", f"name = {toml_value(config.name)}", "", "[llm]"]
239
+ for key in ("provider", "model", "base_url", "api_key_env"):
240
+ lines.append(f"{key} = {toml_value(getattr(config.llm, key))}")
241
+ for c in config.checks:
242
+ lines += ["", "[[check]]", f"id = {toml_value(c.id)}", f"kind = {toml_value(c.kind)}"]
243
+ for key, value in c.options.items():
244
+ lines.append(f"{key} = {toml_value(value)}")
245
+ lines.append(f"covers = {toml_value(c.covers)}")
246
+ return "\n".join(lines) + "\n"
247
+
248
+
249
+ def write_atomic(path: Path, text: str) -> None:
250
+ """Replace `path` in one step, so a crash never leaves a half-written file."""
251
+ path.parent.mkdir(parents=True, exist_ok=True)
252
+ fd, tmp = tempfile.mkstemp(dir=path.parent, prefix=f".{path.name}.")
253
+ try:
254
+ # mkstemp creates the file 0600; keep the existing mode, or use the usual 0644.
255
+ os.chmod(tmp, path.stat().st_mode & 0o7777 if path.exists() else 0o644)
256
+ with os.fdopen(fd, "w") as fh:
257
+ fh.write(text)
258
+ os.replace(tmp, path)
259
+ except BaseException:
260
+ Path(tmp).unlink(missing_ok=True)
261
+ raise
262
+
263
+
264
+ def save(config: Config) -> Path:
265
+ """Write `rung.toml`. Comments are not preserved; the file is regenerated from the parsed config.
266
+
267
+ Every check is validated exactly as `load` will validate it, so a command can never save a
268
+ configuration that the next command rejects.
269
+ """
270
+ for check in config.checks:
271
+ if not CHECK_ID.match(check.id):
272
+ raise ConfigError(f"check id {check.id!r} must be letters, digits, `.`, `_` or `-` (at most 64)")
273
+ _validate_check(check)
274
+ text = dump(config)
275
+ tomllib.loads(text) # never write a file Rung itself could not read back
276
+ path = config.root / CONFIG_NAME
277
+ write_atomic(path, text)
278
+ return path
rung/core/errors.py ADDED
@@ -0,0 +1,9 @@
1
+ """Errors shared across Rung."""
2
+
3
+
4
+ class RungError(Exception):
5
+ """Base class for errors Rung reports to the user without a traceback."""
6
+
7
+
8
+ class ToolMissing(RungError):
9
+ """A required external tool (java, tla2tools.jar, lake, a scanner, a model CLI) is not installed."""