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,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
@@ -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)