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
|
@@ -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."""
|