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/analysis/survey.py
ADDED
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
"""Read a repository and describe what can be verified, without running anything.
|
|
2
|
+
|
|
3
|
+
The inspection is deliberately heuristic and cheap. It answers the questions
|
|
4
|
+
the planner needs: which languages, how tests run, what a user touches, which
|
|
5
|
+
files look like stateful protocols (candidates for TLA+), which look like pure
|
|
6
|
+
logic (candidates for a Lean model), and which formal artifacts already exist.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import re
|
|
13
|
+
from collections import Counter
|
|
14
|
+
from dataclasses import asdict, dataclass, field
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
SKIP_DIRS = {
|
|
18
|
+
".git",
|
|
19
|
+
"node_modules",
|
|
20
|
+
".venv",
|
|
21
|
+
"venv",
|
|
22
|
+
"__pycache__",
|
|
23
|
+
".lake",
|
|
24
|
+
"dist",
|
|
25
|
+
"build",
|
|
26
|
+
".next",
|
|
27
|
+
"target",
|
|
28
|
+
".mypy_cache",
|
|
29
|
+
".pytest_cache",
|
|
30
|
+
".rung",
|
|
31
|
+
"vendor",
|
|
32
|
+
}
|
|
33
|
+
LANGUAGES = {
|
|
34
|
+
".py": "Python",
|
|
35
|
+
".ts": "TypeScript",
|
|
36
|
+
".tsx": "TypeScript",
|
|
37
|
+
".js": "JavaScript",
|
|
38
|
+
".go": "Go",
|
|
39
|
+
".rs": "Rust",
|
|
40
|
+
".java": "Java",
|
|
41
|
+
".kt": "Kotlin",
|
|
42
|
+
".rb": "Ruby",
|
|
43
|
+
".ex": "Elixir",
|
|
44
|
+
".exs": "Elixir",
|
|
45
|
+
".cs": "C#",
|
|
46
|
+
".swift": "Swift",
|
|
47
|
+
}
|
|
48
|
+
# Words that tend to appear where state transitions, leases and retries live.
|
|
49
|
+
STATEFUL_WORDS = frozenset({
|
|
50
|
+
"state", "status", "transition", "retry", "retries", "backoff", "lease", "leases", "lock", "locks",
|
|
51
|
+
"claim", "claimed", "queue", "dispatch", "timeout", "expire", "expired", "expires", "ack", "nack",
|
|
52
|
+
"commit", "rollback", "reconcile", "reconciliation", "idempotent", "idempotency", "attempt", "attempts",
|
|
53
|
+
}) # fmt: skip
|
|
54
|
+
_WORD = re.compile(r"[A-Z]?[a-z]+|[A-Z]+(?![a-z])")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def stateful_terms(code: str) -> list[str]:
|
|
58
|
+
"""Protocol-flavoured words in identifiers, splitting snake_case and camelCase (`leaseValid` -> lease)."""
|
|
59
|
+
return [w for w in (m.lower() for m in _WORD.findall(code)) if w in STATEFUL_WORDS]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
SIDE_EFFECTS = re.compile(
|
|
63
|
+
r"\b(open|requests|httpx|fetch|subprocess|socket|cursor|execute|session|write|print|"
|
|
64
|
+
r"datetime\.now|time\.time|random)\b"
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass
|
|
69
|
+
class Candidate:
|
|
70
|
+
path: str
|
|
71
|
+
score: int
|
|
72
|
+
reason: str
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass
|
|
76
|
+
class Inspection:
|
|
77
|
+
root: str
|
|
78
|
+
languages: dict[str, int] = field(default_factory=dict)
|
|
79
|
+
test_commands: list[str] = field(default_factory=list)
|
|
80
|
+
surfaces: list[str] = field(default_factory=list)
|
|
81
|
+
lint: list[str] = field(default_factory=list)
|
|
82
|
+
ci: list[str] = field(default_factory=list)
|
|
83
|
+
formal: dict[str, list[str]] = field(default_factory=dict)
|
|
84
|
+
protocol_candidates: list[Candidate] = field(default_factory=list)
|
|
85
|
+
model_candidates: list[Candidate] = field(default_factory=list)
|
|
86
|
+
agent_docs: list[str] = field(default_factory=list)
|
|
87
|
+
projects: list[Project] = field(default_factory=list)
|
|
88
|
+
|
|
89
|
+
@property
|
|
90
|
+
def primary_language(self) -> str:
|
|
91
|
+
return max(self.languages, key=lambda name: self.languages[name]) if self.languages else "unknown"
|
|
92
|
+
|
|
93
|
+
def to_dict(self) -> dict:
|
|
94
|
+
return asdict(self)
|
|
95
|
+
|
|
96
|
+
def to_json(self) -> str:
|
|
97
|
+
return json.dumps(self.to_dict(), indent=2)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def walk(root: Path):
|
|
101
|
+
for path in root.rglob("*"):
|
|
102
|
+
rel = path.relative_to(root)
|
|
103
|
+
if any(part in SKIP_DIRS for part in rel.parts):
|
|
104
|
+
continue
|
|
105
|
+
if path.is_file():
|
|
106
|
+
yield path, rel
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
_STRINGS_AND_COMMENTS = re.compile(
|
|
110
|
+
r'"""[\s\S]*?"""' # Python triple-quoted strings
|
|
111
|
+
r"|'''[\s\S]*?'''"
|
|
112
|
+
r'|"(?:\\.|[^"\\\n])*"' # double-quoted strings
|
|
113
|
+
r"|'(?:\\.|[^'\\\n])*'" # single-quoted strings
|
|
114
|
+
r"|`(?:\\.|[^`\\])*`" # template literals
|
|
115
|
+
r"|/\*[\s\S]*?\*/" # block comments
|
|
116
|
+
r"|//[^\n]*" # line comments
|
|
117
|
+
r"|#[^\n]*"
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def code_only(text: str) -> str:
|
|
122
|
+
"""Strip string literals and comments so keyword counts reflect code, not prose."""
|
|
123
|
+
return _STRINGS_AND_COMMENTS.sub(" ", text)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _read(path: Path, limit: int = 200_000) -> str:
|
|
127
|
+
try:
|
|
128
|
+
return path.read_text(errors="ignore")[:limit]
|
|
129
|
+
except OSError:
|
|
130
|
+
return ""
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _package_json(root: Path) -> dict:
|
|
134
|
+
try:
|
|
135
|
+
return json.loads((root / "package.json").read_text())
|
|
136
|
+
except (OSError, json.JSONDecodeError):
|
|
137
|
+
return {}
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
PY_TOOL_CONFIGS = ("ruff.toml", ".ruff.toml", "mypy.ini", ".mypy.ini", "pyrightconfig.json")
|
|
141
|
+
MANIFESTS = frozenset(
|
|
142
|
+
{"pyproject.toml", "package.json", "go.mod", "Cargo.toml", "pytest.ini", "setup.py", *PY_TOOL_CONFIGS}
|
|
143
|
+
)
|
|
144
|
+
ESLINT_CONFIGS = ("eslint.config.js", "eslint.config.mjs", "eslint.config.cjs", "eslint.config.ts", ".eslintrc",
|
|
145
|
+
".eslintrc.json", ".eslintrc.js", ".eslintrc.cjs") # fmt: skip
|
|
146
|
+
MAX_PROJECT_DEPTH = 6 # monorepos nest packages (packages/web/app); deeper is almost always fixtures
|
|
147
|
+
_NO_TEST_SCRIPT = "no test specified"
|
|
148
|
+
LINT_FILES = (
|
|
149
|
+
"ruff.toml", ".ruff.toml", ".eslintrc", ".eslintrc.json", "eslint.config.js", "eslint.config.mjs",
|
|
150
|
+
"biome.json", ".golangci.yml", "clippy.toml",
|
|
151
|
+
) # fmt: skip
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
@dataclass
|
|
155
|
+
class Project:
|
|
156
|
+
"""One buildable project inside the repository: the root, or e.g. `backend/` and `frontend/`."""
|
|
157
|
+
|
|
158
|
+
path: str
|
|
159
|
+
ecosystems: list[str] = field(default_factory=list)
|
|
160
|
+
test_command: str = ""
|
|
161
|
+
surfaces: list[str] = field(default_factory=list)
|
|
162
|
+
lint: list[str] = field(default_factory=list)
|
|
163
|
+
# Static checks the project has already configured, as (kind, command): kind is typecheck or lint.
|
|
164
|
+
static: list[tuple[str, str]] = field(default_factory=list)
|
|
165
|
+
# Tools that are configured but could not be recorded as a check, with the reason.
|
|
166
|
+
unrecorded: list[str] = field(default_factory=list)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _is_solution_config(path: Path) -> bool:
|
|
170
|
+
"""A tsconfig with `"files": []` and `references`: it type-checks nothing by itself."""
|
|
171
|
+
text = path.read_text(errors="ignore")
|
|
172
|
+
return '"references"' in text and re.search(r'"files"\s*:\s*\[\s*\]', text) is not None
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _node_runner(d: Path) -> str:
|
|
176
|
+
if (d / "pnpm-lock.yaml").exists():
|
|
177
|
+
return "pnpm"
|
|
178
|
+
if (d / "yarn.lock").exists():
|
|
179
|
+
return "yarn"
|
|
180
|
+
if (d / "bun.lockb").exists() or (d / "bun.lock").exists():
|
|
181
|
+
return "bun"
|
|
182
|
+
return "npm"
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _detect_project(root: Path, rel: str) -> Project | None:
|
|
186
|
+
d = root / rel
|
|
187
|
+
project = Project(path=rel)
|
|
188
|
+
pyproject = _read(d / "pyproject.toml")
|
|
189
|
+
pkg = _package_json(d)
|
|
190
|
+
|
|
191
|
+
if pyproject or any((d / n).exists() for n in ("setup.py", "pytest.ini", *PY_TOOL_CONFIGS)):
|
|
192
|
+
project.ecosystems.append("python")
|
|
193
|
+
has_tests = "pytest" in pyproject or (d / "pytest.ini").exists() or (d / "tests").is_dir()
|
|
194
|
+
if has_tests:
|
|
195
|
+
project.test_command = "uv run pytest" if (d / "uv.lock").exists() else "python -m pytest"
|
|
196
|
+
if any(k in pyproject for k in ("fastapi", "flask", "django", "starlette", "aiohttp")):
|
|
197
|
+
project.surfaces.append("HTTP API")
|
|
198
|
+
if "[project.scripts]" in pyproject:
|
|
199
|
+
project.surfaces.append("CLI")
|
|
200
|
+
if "[tool.ruff" in pyproject:
|
|
201
|
+
project.lint.append("ruff (pyproject)")
|
|
202
|
+
if "import-linter" in pyproject or (d / ".importlinter").exists():
|
|
203
|
+
project.lint.append("import-linter")
|
|
204
|
+
run = "uv run " if (d / "uv.lock").exists() else "python -m "
|
|
205
|
+
pyright = "[tool.pyright" in pyproject or (d / "pyrightconfig.json").exists()
|
|
206
|
+
mypy = "[tool.mypy" in pyproject or (d / "mypy.ini").exists() or (d / ".mypy.ini").exists()
|
|
207
|
+
if pyright:
|
|
208
|
+
project.static.append(("typecheck", f"{run}pyright"))
|
|
209
|
+
if mypy: # both can be configured; each gets its own check
|
|
210
|
+
project.static.append(("mypy" if pyright else "typecheck", f"{run}mypy ."))
|
|
211
|
+
if "[tool.ruff" in pyproject or (d / "ruff.toml").exists() or (d / ".ruff.toml").exists():
|
|
212
|
+
project.static.append(("lint", f"{run}ruff check ."))
|
|
213
|
+
if "import-linter" in project.lint:
|
|
214
|
+
project.static.append(("imports", "uv run lint-imports" if run == "uv run " else "lint-imports"))
|
|
215
|
+
if pkg:
|
|
216
|
+
project.ecosystems.append("node")
|
|
217
|
+
deps = {**pkg.get("dependencies", {}), **pkg.get("devDependencies", {})}
|
|
218
|
+
test_script = str(pkg.get("scripts", {}).get("test", ""))
|
|
219
|
+
if test_script and _NO_TEST_SCRIPT not in test_script and not project.test_command:
|
|
220
|
+
project.test_command = f"{_node_runner(d)} test"
|
|
221
|
+
if any(k in deps for k in ("next", "react", "vue", "svelte", "@angular/core", "solid-js", "astro")):
|
|
222
|
+
project.surfaces.append("web UI")
|
|
223
|
+
if "electron" in deps:
|
|
224
|
+
project.surfaces.append("desktop app")
|
|
225
|
+
if any(k in deps for k in ("express", "fastify", "hono", "koa", "@nestjs/core")):
|
|
226
|
+
project.surfaces.append("HTTP API")
|
|
227
|
+
if "bin" in pkg:
|
|
228
|
+
project.surfaces.append("CLI")
|
|
229
|
+
scripts = pkg.get("scripts", {}) or {}
|
|
230
|
+
runner = _node_runner(d)
|
|
231
|
+
typecheck = next((s for s in ("typecheck", "type-check", "tsc", "check-types") if s in scripts), "")
|
|
232
|
+
if typecheck:
|
|
233
|
+
project.static.append(("typecheck", f"{runner} run {typecheck}"))
|
|
234
|
+
elif "typescript" in deps and (d / "tsconfig.json").exists():
|
|
235
|
+
if _is_solution_config(d / "tsconfig.json"):
|
|
236
|
+
# `tsc --noEmit` on a solution config checks nothing; leave it to the developer.
|
|
237
|
+
project.unrecorded.append("tsc (tsconfig.json only references other projects; add a typecheck script)")
|
|
238
|
+
else:
|
|
239
|
+
project.static.append(("typecheck", f"{'npx' if runner == 'npm' else runner + ' exec'} tsc --noEmit"))
|
|
240
|
+
npx = "npx" if runner == "npm" else f"{runner} exec"
|
|
241
|
+
if "lint" in scripts:
|
|
242
|
+
project.static.append(("lint", f"{runner} run lint"))
|
|
243
|
+
elif any((d / n).exists() for n in ESLINT_CONFIGS) and "eslint" in deps:
|
|
244
|
+
project.static.append(("lint", f"{npx} eslint ."))
|
|
245
|
+
if (d / "go.mod").exists():
|
|
246
|
+
project.ecosystems.append("go")
|
|
247
|
+
project.test_command = project.test_command or "go test ./..."
|
|
248
|
+
project.static.append(("vet", "go vet ./..."))
|
|
249
|
+
if (d / "cmd").is_dir():
|
|
250
|
+
project.surfaces.append("CLI")
|
|
251
|
+
if (d / "Cargo.toml").exists():
|
|
252
|
+
project.ecosystems.append("rust")
|
|
253
|
+
project.test_command = project.test_command or "cargo test"
|
|
254
|
+
project.static.append(("lint", "cargo clippy --all-targets -- -D warnings"))
|
|
255
|
+
project.lint += [name for name in LINT_FILES if (d / name).exists()]
|
|
256
|
+
return project if project.ecosystems else None
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def survey(root: Path) -> Inspection:
|
|
260
|
+
root = root.resolve()
|
|
261
|
+
result = Inspection(root=str(root))
|
|
262
|
+
languages: Counter[str] = Counter()
|
|
263
|
+
formal: dict[str, list[str]] = {"tla": [], "lean": [], "bend": [], "dafny": [], "alloy": []}
|
|
264
|
+
protocol: list[Candidate] = []
|
|
265
|
+
model: list[Candidate] = []
|
|
266
|
+
manifest_dirs: set[str] = {"."}
|
|
267
|
+
|
|
268
|
+
for path, rel in walk(root):
|
|
269
|
+
suffix = path.suffix
|
|
270
|
+
if suffix in LANGUAGES:
|
|
271
|
+
languages[LANGUAGES[suffix]] += 1
|
|
272
|
+
posix = rel.as_posix()
|
|
273
|
+
if suffix == ".tla":
|
|
274
|
+
formal["tla"].append(posix)
|
|
275
|
+
elif suffix == ".lean":
|
|
276
|
+
formal["lean"].append(posix)
|
|
277
|
+
elif suffix == ".bend":
|
|
278
|
+
formal["bend"].append(posix)
|
|
279
|
+
elif suffix == ".dfy":
|
|
280
|
+
formal["dafny"].append(posix)
|
|
281
|
+
elif suffix == ".als":
|
|
282
|
+
formal["alloy"].append(posix)
|
|
283
|
+
if path.name in MANIFESTS and len(rel.parts) <= MAX_PROJECT_DEPTH + 1:
|
|
284
|
+
manifest_dirs.add(rel.parent.as_posix() or ".")
|
|
285
|
+
if path.name in {"AGENTS.md", "CLAUDE.md", ".cursorrules"}:
|
|
286
|
+
result.agent_docs.append(posix)
|
|
287
|
+
if posix.startswith(".github/workflows/") or path.name in {".gitlab-ci.yml", "Jenkinsfile"}:
|
|
288
|
+
result.ci.append(posix)
|
|
289
|
+
|
|
290
|
+
if suffix not in LANGUAGES or "test" in path.stem.lower() or "tests" in rel.parts:
|
|
291
|
+
continue
|
|
292
|
+
text = _read(path)
|
|
293
|
+
code = code_only(text)
|
|
294
|
+
lines = text.count("\n") + 1
|
|
295
|
+
hits = len(stateful_terms(code))
|
|
296
|
+
if hits >= 6:
|
|
297
|
+
protocol.append(Candidate(posix, hits, f"{hits} state/retry/lease terms in {lines} lines"))
|
|
298
|
+
effects = len(SIDE_EFFECTS.findall(code))
|
|
299
|
+
functions = len(re.findall(r"^\s*(?:def|function|fn|func)\s", code, re.M))
|
|
300
|
+
if functions >= 2 and effects == 0 and lines <= 600:
|
|
301
|
+
model.append(Candidate(posix, functions, f"{functions} functions and no I/O; a small pure core"))
|
|
302
|
+
|
|
303
|
+
result.languages = dict(languages.most_common())
|
|
304
|
+
result.formal = {k: v for k, v in formal.items() if v}
|
|
305
|
+
result.protocol_candidates = sorted(protocol, key=lambda c: -c.score)[:8]
|
|
306
|
+
result.model_candidates = sorted(model, key=lambda c: -c.score)[:8]
|
|
307
|
+
|
|
308
|
+
result.projects = [p for d in sorted(manifest_dirs) if (p := _detect_project(root, d))]
|
|
309
|
+
for project in result.projects:
|
|
310
|
+
where = "" if project.path == "." else f" (in {project.path})"
|
|
311
|
+
if project.test_command:
|
|
312
|
+
result.test_commands.append(project.test_command + where)
|
|
313
|
+
for surface in project.surfaces:
|
|
314
|
+
if surface not in result.surfaces:
|
|
315
|
+
result.surfaces.append(surface)
|
|
316
|
+
result.lint += [f"{name}{where}" for name in project.lint]
|
|
317
|
+
if not result.surfaces and languages:
|
|
318
|
+
result.surfaces.append("library")
|
|
319
|
+
return result
|
rung/checks/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Check kinds, the registry that maps kinds to checkers, and the runner that records verdicts."""
|
rung/checks/base.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""The checker interface every check kind implements."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, ClassVar
|
|
8
|
+
|
|
9
|
+
from rung.core.config import Check
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class Context:
|
|
14
|
+
"""What a checker may know about the run beyond its own check."""
|
|
15
|
+
|
|
16
|
+
root: Path
|
|
17
|
+
# Commit to compare against for "what did this change introduce" (a merge base), if any.
|
|
18
|
+
base: str | None = None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class Outcome:
|
|
23
|
+
verdict: str # pass | fail | error
|
|
24
|
+
summary: str
|
|
25
|
+
lines: list[str] = field(default_factory=list)
|
|
26
|
+
evidence: dict[str, str] = field(default_factory=dict) # artifact path -> sha256
|
|
27
|
+
findings: list[dict[str, Any]] = field(default_factory=list)
|
|
28
|
+
valid_until: str = "" # ISO date after which the verdict expires (an accepted waiver ends)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class Checker:
|
|
32
|
+
"""Turns one declared check into a verdict.
|
|
33
|
+
|
|
34
|
+
Subclasses set `kind`, implement `run`, and may override `specs` and
|
|
35
|
+
`default_covers`. A checker must never return `pass` unless its tool
|
|
36
|
+
actually confirmed the result; when unsure it returns `error`.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
kind: ClassVar[str] = ""
|
|
40
|
+
summary: ClassVar[str] = ""
|
|
41
|
+
|
|
42
|
+
def specs(self, check: Check) -> list[str]:
|
|
43
|
+
"""Spec-side files whose change should stale this check's verdict."""
|
|
44
|
+
return check.get_list("specs")
|
|
45
|
+
|
|
46
|
+
def default_covers(self, check: Check) -> list[str]:
|
|
47
|
+
return []
|
|
48
|
+
|
|
49
|
+
def max_age_days(self, check: Check) -> int | None:
|
|
50
|
+
"""How long a verdict stays valid regardless of file changes (for checks whose data changes)."""
|
|
51
|
+
if "max_age_days" not in check.options:
|
|
52
|
+
return None
|
|
53
|
+
return check.get_int("max_age_days", 0) or None
|
|
54
|
+
|
|
55
|
+
def covers(self, check: Check) -> list[str]:
|
|
56
|
+
return check.covers or self.default_covers(check)
|
|
57
|
+
|
|
58
|
+
def run(self, ctx: Context, check: Check) -> Outcome:
|
|
59
|
+
raise NotImplementedError
|
rung/checks/command.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Command checkers: any test suite, linter or script, and evidence-producing drive scripts."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import shlex
|
|
7
|
+
import subprocess
|
|
8
|
+
import uuid
|
|
9
|
+
from collections.abc import Iterable
|
|
10
|
+
from datetime import UTC, datetime
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from rung.checks.base import Checker, Context, Outcome
|
|
14
|
+
from rung.core import ledger, proc
|
|
15
|
+
from rung.core.config import Check
|
|
16
|
+
from rung.core.redact import redact
|
|
17
|
+
|
|
18
|
+
# A command check that declares no coverage covers the whole project: any change stales it.
|
|
19
|
+
# Narrow `covers` to make it cheaper to keep fresh, never to make it easier to trust.
|
|
20
|
+
WHOLE_PROJECT = ["**/*"]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def tail(result: subprocess.CompletedProcess[str], lines: int = 15) -> list[str]:
|
|
24
|
+
"""The end of the output, where test runners and scripts print their verdict.
|
|
25
|
+
|
|
26
|
+
A passing run shows the end of stdout (stderr only if stdout is empty), because tools like
|
|
27
|
+
`uv run` write build chatter to stderr. A failing run shows the end of both: a script that
|
|
28
|
+
prints progress to stdout usually reports the actual failure on stderr.
|
|
29
|
+
"""
|
|
30
|
+
out, err = result.stdout.strip().splitlines(), result.stderr.strip().splitlines()
|
|
31
|
+
if result.returncode == 0 or not err:
|
|
32
|
+
return (out or err)[-lines:]
|
|
33
|
+
if not out:
|
|
34
|
+
return err[-lines:]
|
|
35
|
+
return [*out[-(lines - 6) :], "stderr:", *err[-5:]]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _run(ctx: Context, check: Check, env: dict[str, str] | None = None) -> subprocess.CompletedProcess[str]:
|
|
39
|
+
return proc.run(
|
|
40
|
+
shlex.split(check.require("run")),
|
|
41
|
+
cwd=ctx.root / check.get("cwd", "."),
|
|
42
|
+
env={**os.environ, **(env or {})},
|
|
43
|
+
timeout=check.get_int("timeout", 1800),
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class CommandChecker(Checker):
|
|
48
|
+
kind = "command"
|
|
49
|
+
summary = "Run a command; exit status 0 passes"
|
|
50
|
+
|
|
51
|
+
def default_covers(self, check: Check) -> list[str]:
|
|
52
|
+
return list(WHOLE_PROJECT)
|
|
53
|
+
|
|
54
|
+
def run(self, ctx: Context, check: Check) -> Outcome:
|
|
55
|
+
result = _run(ctx, check)
|
|
56
|
+
verdict = "pass" if result.returncode == 0 else "fail"
|
|
57
|
+
return Outcome(verdict, f"`{check.require('run')}` exited {result.returncode}", tail(result))
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def evidence_dir(root: Path, check_id: str) -> Path:
|
|
61
|
+
"""A new directory, unique to this run, under `.rung/evidence/<check>/`."""
|
|
62
|
+
stamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ")
|
|
63
|
+
out_dir = root / ".rung" / "evidence" / check_id / f"{stamp}-{uuid.uuid4().hex[:8]}"
|
|
64
|
+
out_dir.mkdir(parents=True, exist_ok=False)
|
|
65
|
+
return out_dir
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def missing_required(check: Check, artifacts: Iterable[str]) -> list[str]:
|
|
69
|
+
"""Required artifact suffixes (`require`) that none of `artifacts` has."""
|
|
70
|
+
names = [a.lower() for a in artifacts]
|
|
71
|
+
return [r for r in (s.lower() for s in check.get_list("require")) if not any(n.endswith(r) for n in names)]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def is_attested(check: Check) -> bool:
|
|
75
|
+
"""An evidence check without `run`: an agent or a person records the evidence (`rung attest`)."""
|
|
76
|
+
return check.kind == "evidence" and "run" not in check.options
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class EvidenceChecker(Checker):
|
|
80
|
+
"""Run a script that drives the real app and must leave artifacts behind.
|
|
81
|
+
|
|
82
|
+
The script gets `RUNG_EVIDENCE_DIR`, a directory unique to this run; anything it writes there
|
|
83
|
+
(a video, a terminal transcript, screenshots, response bodies) is hashed into the ledger. A
|
|
84
|
+
script that exits 0 but records nothing fails: "it works" without evidence is exactly what
|
|
85
|
+
this rung exists to reject. `require` can demand particular artifact types, e.g. `[".webm"]`.
|
|
86
|
+
The drive script itself belongs in `specs`, so editing it stales the verdict.
|
|
87
|
+
|
|
88
|
+
Without `run`, the check is *attested*: an agent drives the app with its own browser or
|
|
89
|
+
computer-use tool, records it, and hands the files to `rung attest`. Rung cannot re-run that, so
|
|
90
|
+
the verdict holds only until a covered file changes, and running the check just reports whether
|
|
91
|
+
fresh evidence exists.
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
kind = "evidence"
|
|
95
|
+
summary = "Drive the real app and record evidence artifacts"
|
|
96
|
+
|
|
97
|
+
def default_covers(self, check: Check) -> list[str]:
|
|
98
|
+
return list(WHOLE_PROJECT)
|
|
99
|
+
|
|
100
|
+
def run(self, ctx: Context, check: Check) -> Outcome:
|
|
101
|
+
if is_attested(check):
|
|
102
|
+
return Outcome(
|
|
103
|
+
"error",
|
|
104
|
+
f"no fresh evidence: drive the feature, record it, then `rung attest {check.id} <files> --by <tool>`",
|
|
105
|
+
)
|
|
106
|
+
out_dir = evidence_dir(ctx.root, check.id)
|
|
107
|
+
result = _run(ctx, check, {"RUNG_EVIDENCE_DIR": str(out_dir)})
|
|
108
|
+
(out_dir / "run.log").write_text(redact(result.stdout + result.stderr))
|
|
109
|
+
artifacts = {
|
|
110
|
+
p.relative_to(ctx.root).as_posix(): ledger.file_digest(p)
|
|
111
|
+
for p in sorted(out_dir.rglob("*"))
|
|
112
|
+
if p.is_file() and p.name != "run.log" and p.stat().st_size > 0
|
|
113
|
+
}
|
|
114
|
+
lines = [f"evidence: {path}" for path in artifacts]
|
|
115
|
+
command = check.require("run")
|
|
116
|
+
if result.returncode != 0:
|
|
117
|
+
return Outcome("fail", f"`{command}` exited {result.returncode}", lines + tail(result, 10), artifacts)
|
|
118
|
+
if not artifacts:
|
|
119
|
+
return Outcome("fail", "the script exited 0 but recorded no evidence", lines)
|
|
120
|
+
if missing := missing_required(check, artifacts):
|
|
121
|
+
return Outcome("fail", f"required evidence missing: {', '.join(missing)}", lines, artifacts)
|
|
122
|
+
return Outcome("pass", f"{len(artifacts)} artifact(s) recorded", lines, artifacts)
|