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
rung/core/git.py ADDED
@@ -0,0 +1,236 @@
1
+ """Git plumbing: changed files, merge bases, temporary worktrees, and file globbing.
2
+
3
+ All paths are POSIX paths relative to the project root (the directory holding `rung.toml`),
4
+ which may be a subdirectory of the git repository. Git is always asked for NUL-delimited
5
+ output, so names with non-ASCII characters, tabs or newlines are never mangled.
6
+
7
+ Outside a git repository, file listing falls back to walking the tree, and diff-based
8
+ features report that they need git.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import contextlib
14
+ import os
15
+ import re
16
+ import shutil
17
+ import subprocess
18
+ import tempfile
19
+ from collections.abc import Iterator
20
+ from functools import lru_cache
21
+ from pathlib import Path
22
+
23
+ from rung.core.errors import RungError
24
+
25
+ GIT_TIMEOUT = 120
26
+
27
+ # Directories that are build output or dependencies. Untracked files inside them never count as
28
+ # project files; tracked files always do (a repository may track its own `build/` sources).
29
+ SKIP_DIRS = frozenset({
30
+ ".git", "node_modules", ".venv", "venv", "__pycache__", ".lake", "dist", "build",
31
+ ".next", "target", ".mypy_cache", ".pytest_cache", ".ruff_cache", "vendor",
32
+ }) # fmt: skip
33
+
34
+ # Rung's own outputs. Writing a verdict or recording evidence must never stale another verdict.
35
+ # Verification inputs under `.rung/` (drive scripts, baselines, waivers) are not listed here.
36
+ RUNG_OUTPUTS = (".rung/verdicts/", ".rung/evidence/", ".rung/cache/", ".rung/runs/", ".rung/ledger.jsonl")
37
+ # Files whose meaning reaches a verdict another way: each verdict hashes its check's declaration.
38
+ RUNG_STATE_FILES = frozenset({"rung.toml", "LAWS.md"})
39
+
40
+
41
+ class GitError(RungError):
42
+ """A git operation failed or the directory is not a repository."""
43
+
44
+
45
+ def _git(root: Path, *args: str, check: bool = True, timeout: int = GIT_TIMEOUT) -> str:
46
+ try:
47
+ proc = subprocess.run(["git", *args], cwd=root, capture_output=True, text=True, check=False, timeout=timeout)
48
+ except FileNotFoundError as exc:
49
+ raise GitError("git is not installed") from exc
50
+ except subprocess.TimeoutExpired as exc:
51
+ raise GitError(f"git {' '.join(args)} timed out after {timeout}s") from exc
52
+ if check and proc.returncode != 0:
53
+ raise GitError(f"git {' '.join(args)}: {proc.stderr.strip() or proc.stdout.strip()}")
54
+ return proc.stdout
55
+
56
+
57
+ def _paths(output: str) -> list[str]:
58
+ return [p for p in output.split("\0") if p]
59
+
60
+
61
+ def is_repo(root: Path) -> bool:
62
+ try:
63
+ return _git(root, "rev-parse", "--is-inside-work-tree").strip() == "true"
64
+ except GitError:
65
+ return False
66
+
67
+
68
+ def head(root: Path) -> str:
69
+ try:
70
+ return _git(root, "rev-parse", "HEAD").strip()
71
+ except GitError:
72
+ return ""
73
+
74
+
75
+ def prefix(root: Path) -> str:
76
+ """Where `root` sits inside its repository, e.g. `backend/` (empty at the top)."""
77
+ return _git(root, "rev-parse", "--show-prefix").strip()
78
+
79
+
80
+ def merge_base(root: Path, base: str) -> str:
81
+ """The commit where the current branch left `base`."""
82
+ return _git(root, "merge-base", base, "HEAD").strip()
83
+
84
+
85
+ def changed_files(root: Path, base: str) -> set[str]:
86
+ """Files under `root` that differ from the merge base with `base`, including uncommitted and untracked ones."""
87
+ mb = merge_base(root, base)
88
+ changed = set(_paths(_git(root, "diff", "--name-only", "-z", "--relative", mb)))
89
+ changed |= set(_paths(_git(root, "ls-files", "-z", "--others", "--exclude-standard")))
90
+ return changed
91
+
92
+
93
+ def show(root: Path, commit: str, path: str) -> str:
94
+ """The contents of `path` (relative to `root`) at `commit`; raises GitError if it did not exist there."""
95
+ return _git(root, "show", f"{commit}:./{path}")
96
+
97
+
98
+ def show_many(root: Path, commit: str, paths: list[str]) -> dict[str, bytes]:
99
+ """The contents of many files at `commit` in one `git cat-file --batch` process (missing ones left out)."""
100
+ if not paths:
101
+ return {}
102
+ request = "".join(f"{commit}:./{p}\n" for p in paths).encode()
103
+ try:
104
+ proc = subprocess.run(["git", "cat-file", "--batch"], cwd=root, input=request, capture_output=True,
105
+ check=False, timeout=600) # fmt: skip
106
+ except (OSError, subprocess.TimeoutExpired) as exc:
107
+ raise GitError(f"git cat-file failed: {exc}") from exc
108
+ if proc.returncode != 0:
109
+ raise GitError(f"git cat-file failed: {proc.stderr.decode(errors='replace').strip()}")
110
+ out, data, pos = {}, proc.stdout, 0
111
+ for path in paths:
112
+ end = data.index(b"\n", pos)
113
+ header = data[pos:end].split()
114
+ pos = end + 1
115
+ if len(header) == 3 and header[1] == b"blob":
116
+ size = int(header[2])
117
+ out[path] = data[pos : pos + size]
118
+ pos += size + 1
119
+ elif len(header) == 3: # a tree or other object: skip its body
120
+ pos += int(header[2]) + 1
121
+ return out
122
+
123
+
124
+ def files_at(root: Path, commit: str) -> list[str]:
125
+ """Files under `root` as they were at `commit`."""
126
+ return _paths(_git(root, "ls-tree", "-r", "-z", "--name-only", commit, "--", "."))
127
+
128
+
129
+ def default_base(root: Path) -> str:
130
+ """The branch a pull request would merge into: origin's default, else main or master."""
131
+ try:
132
+ ref = _git(root, "symbolic-ref", "--quiet", "refs/remotes/origin/HEAD").strip()
133
+ return ref.removeprefix("refs/remotes/")
134
+ except GitError:
135
+ pass
136
+ for candidate in ("origin/main", "main", "origin/master", "master"):
137
+ if _git(root, "rev-parse", "--verify", "--quiet", candidate, check=False).strip():
138
+ return candidate
139
+ raise GitError("cannot find a base branch; pass --base")
140
+
141
+
142
+ def list_files(root: Path) -> list[str]:
143
+ """Every project file git does not ignore: all tracked files, plus untracked ones outside build dirs."""
144
+ if is_repo(root):
145
+ tracked = _paths(_git(root, "ls-files", "-z", "--cached"))
146
+ untracked = [
147
+ p for p in _paths(_git(root, "ls-files", "-z", "--others", "--exclude-standard"))
148
+ if not SKIP_DIRS.intersection(p.split("/"))
149
+ ] # fmt: skip
150
+ return sorted({p for p in tracked + untracked if (root / p).is_file()})
151
+ files = []
152
+ for dirpath, dirnames, filenames in os.walk(root):
153
+ dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS and d != ".git"]
154
+ rel = Path(dirpath).relative_to(root)
155
+ files += [(rel / f).as_posix() for f in filenames]
156
+ return sorted(files)
157
+
158
+
159
+ @lru_cache(maxsize=512)
160
+ def _glob_regex(pattern: str) -> re.Pattern[str]:
161
+ """Translate a glob into an anchored regex over POSIX paths.
162
+
163
+ Supports `*` (within one directory), `**` (across directories), `?` and `[...]` classes
164
+ (`[!...]` negates). Everything else matches literally.
165
+ """
166
+ out, i = [], 0
167
+ while i < len(pattern):
168
+ c = pattern[i]
169
+ if pattern.startswith("**/", i):
170
+ out.append("(?:.*/)?")
171
+ i += 3
172
+ elif pattern.startswith("**", i):
173
+ out.append(".*")
174
+ i += 2
175
+ elif c == "*":
176
+ out.append("[^/]*")
177
+ i += 1
178
+ elif c == "?":
179
+ out.append("[^/]")
180
+ i += 1
181
+ elif c == "[" and (end := pattern.find("]", i + 2)) != -1:
182
+ body = pattern[i + 1 : end]
183
+ negate = body.startswith("!")
184
+ body = re.escape(body[1:] if negate else body).replace("\\-", "-")
185
+ out.append(f"[^/{body}]" if negate else f"[{body}]")
186
+ i = end + 1
187
+ else:
188
+ out.append(re.escape(c))
189
+ i += 1
190
+ return re.compile("".join(out) + r"\Z")
191
+
192
+
193
+ def glob_match(pattern: str, path: str) -> bool:
194
+ return bool(_glob_regex(pattern).match(path))
195
+
196
+
197
+ def is_rung_output(path: str) -> bool:
198
+ return path in RUNG_STATE_FILES or any(path == o or path.startswith(o) for o in RUNG_OUTPUTS)
199
+
200
+
201
+ def expand(root: Path, patterns: list[str]) -> list[str]:
202
+ """Resolve glob patterns against the project's files.
203
+
204
+ A pattern that matches nothing is kept literally, so a deleted file shows up
205
+ as missing instead of silently dropping out of a verdict's coverage.
206
+ """
207
+ if not patterns:
208
+ return []
209
+ files = [f for f in list_files(root) if not is_rung_output(f)]
210
+ out: set[str] = set()
211
+ for pattern in patterns:
212
+ matches = [f for f in files if glob_match(pattern, f)]
213
+ out.update(matches or [pattern])
214
+ return sorted(out)
215
+
216
+
217
+ @contextlib.contextmanager
218
+ def worktree(root: Path, ref: str) -> Iterator[Path]:
219
+ """Check out `ref` in a temporary worktree and yield the same project directory inside it."""
220
+ tmp = Path(tempfile.mkdtemp(prefix="rung-base-"))
221
+ target = tmp / "tree"
222
+ try:
223
+ sub = prefix(root)
224
+ _git(root, "worktree", "add", "--detach", str(target), ref, timeout=600)
225
+ yield target / sub if sub else target
226
+ finally:
227
+ if target.exists():
228
+ _git(root, "worktree", "remove", "--force", str(target), check=False)
229
+ shutil.rmtree(tmp, ignore_errors=True)
230
+
231
+
232
+ def user_name(root: Path) -> str:
233
+ try:
234
+ return _git(root, "config", "user.name").strip()
235
+ except GitError:
236
+ return ""
rung/core/laws.py ADDED
@@ -0,0 +1,124 @@
1
+ """LAWS.md: the rules the software must never break, in plain language.
2
+
3
+ Bend 2 introduced the idea of a LAWS file whose rules the compiler forces an AI
4
+ to prove. Rung keeps that contract but works with any language and any checker:
5
+ each law is a sentence a person can read and approve, mapped to the checks
6
+ (TLA+ invariants, Lean theorems, differential tests, test suites) that enforce
7
+ it. A law is only *held* when it is approved and every enforcing check has a
8
+ fresh passing verdict.
9
+
10
+ Format:
11
+
12
+ ## lease-result-from-holder
13
+ A job's result is always written by the worker that holds a valid lease.
14
+ - enforced by: lease-protocol, lease-model-proofs, impl-matches-model
15
+ - status: approved
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import re
21
+ from dataclasses import dataclass, field
22
+ from pathlib import Path
23
+
24
+ from rung.core.ledger import Status
25
+
26
+ LAWS_FILE = "LAWS.md"
27
+ _HEADING = re.compile(r"^##\s+(\S+)\s*$")
28
+ _FIELD = re.compile(r"^-\s*(enforced by|status):\s*(.*)$", re.I)
29
+
30
+ TEMPLATE = """# Laws
31
+
32
+ Rules this software must never break. Each law names the Rung checks that
33
+ enforce it; `rung laws` shows which laws currently hold. A law an agent proposes
34
+ stays `proposed` until a person reads it and changes it to `approved`.
35
+ """
36
+
37
+
38
+ @dataclass
39
+ class Law:
40
+ id: str
41
+ text: str
42
+ enforced_by: list[str] = field(default_factory=list)
43
+ status: str = "proposed"
44
+
45
+
46
+ def parse(source: str) -> list[Law]:
47
+ laws: list[Law] = []
48
+ current: Law | None = None
49
+ for line in source.splitlines():
50
+ if m := _HEADING.match(line):
51
+ current = Law(m.group(1), "")
52
+ laws.append(current)
53
+ continue
54
+ if current is None:
55
+ continue
56
+ if m := _FIELD.match(line.strip()):
57
+ key, value = m.group(1).lower(), m.group(2).strip()
58
+ if key == "enforced by":
59
+ current.enforced_by = [v.strip() for v in value.split(",") if v.strip()]
60
+ else:
61
+ current.status = value.lower() or "proposed"
62
+ elif line.strip() and not line.startswith("#"):
63
+ current.text = f"{current.text} {line.strip()}".strip()
64
+ return laws
65
+
66
+
67
+ def load(root: Path) -> list[Law]:
68
+ path = root / LAWS_FILE
69
+ return parse(path.read_text()) if path.exists() else []
70
+
71
+
72
+ def propose(root: Path, proposals: list[dict[str, str]], enforced_by: list[str]) -> list[str]:
73
+ """Append laws as `proposed`, skipping ids that already exist. Returns the ids added."""
74
+ path = root / LAWS_FILE
75
+ existing = {law.id for law in load(root)}
76
+ text = path.read_text() if path.exists() else TEMPLATE
77
+ added = []
78
+ for item in proposals:
79
+ law_id = re.sub(r"[^a-z0-9-]+", "-", item.get("id", "").lower()).strip("-")
80
+ sentence = " ".join(item.get("text", "").split())
81
+ if not law_id or not sentence or law_id in existing:
82
+ continue
83
+ text = (
84
+ text.rstrip()
85
+ + f"\n\n## {law_id}\n{sentence}\n- enforced by: {', '.join(enforced_by)}\n- status: proposed\n"
86
+ )
87
+ existing.add(law_id)
88
+ added.append(law_id)
89
+ if added:
90
+ path.write_text(text)
91
+ return added
92
+
93
+
94
+ def set_status(root: Path, law_id: str, status: str) -> None:
95
+ path = root / LAWS_FILE
96
+ lines = path.read_text().splitlines()
97
+ inside = False
98
+ for i, line in enumerate(lines):
99
+ if m := _HEADING.match(line):
100
+ inside = m.group(1) == law_id
101
+ continue
102
+ if inside and _FIELD.match(line.strip()) and line.strip().lower().startswith("- status"):
103
+ lines[i] = f"- status: {status}"
104
+ path.write_text("\n".join(lines) + "\n")
105
+ return
106
+ raise KeyError(f"law {law_id!r} not found in {LAWS_FILE}")
107
+
108
+
109
+ def state(law: Law, statuses: dict[str, Status]) -> tuple[str, str]:
110
+ """Return (state, explanation) for one law given the current check statuses."""
111
+ if law.status != "approved":
112
+ return "proposed", "awaiting a person's approval"
113
+ if not law.enforced_by:
114
+ return "unenforced", "no check enforces it"
115
+ unknown = [c for c in law.enforced_by if c not in statuses]
116
+ if unknown:
117
+ return "unenforced", f"unknown check(s): {', '.join(unknown)}"
118
+ failing = [c for c in law.enforced_by if statuses[c].state == "failing"]
119
+ if failing:
120
+ return "broken", f"failing: {', '.join(failing)}"
121
+ pending = [c for c in law.enforced_by if statuses[c].state != "fresh"]
122
+ if pending:
123
+ return "unproven", f"stale or never run: {', '.join(pending)}"
124
+ return "held", "every enforcing check is fresh and passing"
rung/core/ledger.py ADDED
@@ -0,0 +1,225 @@
1
+ """Verdicts: each check's latest result, bound to the exact inputs it checked.
2
+
3
+ Each check has one small file, `.rung/verdicts/<check>.json`, holding its latest verdict: the
4
+ outcome, a single digest over every covered and spec file (path and content), a hash of the check's
5
+ own declaration, the comparison base, and when it stops being valid (a waiver that expires). A
6
+ verdict is *fresh* only while all of that still matches and it is younger than the check's
7
+ `max_age_days`.
8
+
9
+ These files are meant to be committed, so teammates and CI see what was verified. One file per
10
+ check means two branches conflict only when both ran the same check, and a conflicted, truncated or
11
+ hand-edited file simply reads as "re-run this check". Each file carries a hash of its own contents,
12
+ which detects corruption and careless edits. It is not a signature: anyone who can write the file
13
+ can forge it, so CI should re-run the checks a change touches (`rung check --changed`) rather than
14
+ trust committed verdicts for them.
15
+
16
+ Per-file digests (to explain *which* file changed) and the full history of runs are kept in the
17
+ git-ignored `.rung/cache/`, so the committed files stay a few hundred bytes each.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import hashlib
23
+ import json
24
+ from dataclasses import dataclass
25
+ from datetime import UTC, datetime, timedelta
26
+ from pathlib import Path
27
+ from typing import Any
28
+
29
+ from rung.core import git
30
+ from rung.core.config import write_atomic
31
+
32
+ VERDICTS_DIR = Path(".rung") / "verdicts"
33
+ CACHE_DIR = Path(".rung") / "cache"
34
+ HISTORY_PATH = CACHE_DIR / "history.jsonl"
35
+ CORRUPT = "(the verdict file is corrupt or was edited; re-run the check)"
36
+
37
+
38
+ def file_digest(path: Path) -> str:
39
+ """sha256 of a file's bytes, or "missing" when it does not exist."""
40
+ try:
41
+ return hashlib.sha256(path.read_bytes()).hexdigest()
42
+ except (FileNotFoundError, IsADirectoryError, NotADirectoryError):
43
+ return "missing"
44
+
45
+
46
+ def snapshot(root: Path, patterns: list[str]) -> dict[str, str]:
47
+ return {rel: file_digest(root / rel) for rel in git.expand(root, patterns)}
48
+
49
+
50
+ def inputs_digest(files: dict[str, str]) -> str:
51
+ """One digest over every (path, content digest) pair, in sorted order."""
52
+ h = hashlib.sha256()
53
+ for path in sorted(files):
54
+ h.update(f"{path}\0{files[path]}\n".encode())
55
+ return h.hexdigest()
56
+
57
+
58
+ def definition_hash(kind: str, covers: list[str], options: dict[str, Any]) -> str:
59
+ """A hash of how a check is declared; editing its options must stale its verdict."""
60
+ body = json.dumps({"kind": kind, "covers": covers, "options": options}, sort_keys=True, default=str)
61
+ return hashlib.sha256(body.encode()).hexdigest()[:16]
62
+
63
+
64
+ def _self_hash(entry: dict[str, Any]) -> str:
65
+ body = {k: v for k, v in entry.items() if k != "hash"}
66
+ return hashlib.sha256(json.dumps(body, sort_keys=True).encode()).hexdigest()
67
+
68
+
69
+ def _file_name(check_id: str) -> str:
70
+ # Proof ids look like `proof:<slug>`; ids are otherwise already filename-safe.
71
+ return check_id.replace(":", "--") + ".json"
72
+
73
+
74
+ def verdict_path(root: Path, check_id: str) -> Path:
75
+ return root / VERDICTS_DIR / _file_name(check_id)
76
+
77
+
78
+ def record(
79
+ root: Path,
80
+ *,
81
+ check_id: str,
82
+ kind: str,
83
+ verdict: str,
84
+ covers: dict[str, str],
85
+ specs: dict[str, str],
86
+ summary: str,
87
+ evidence: list[str] | None = None,
88
+ details: dict[str, Any] | None = None,
89
+ definition: str = "",
90
+ context: str = "",
91
+ valid_until: str = "",
92
+ ) -> dict[str, Any]:
93
+ """Write a check's latest verdict, cache its per-file inputs, and append it to the local history."""
94
+ if verdict not in ("pass", "fail", "error"):
95
+ raise ValueError(f"verdict must be pass, fail or error, not {verdict!r}")
96
+ files = covers | specs
97
+ entry: dict[str, Any] = {
98
+ "check": check_id,
99
+ "kind": kind,
100
+ "verdict": verdict,
101
+ "summary": summary,
102
+ "at": datetime.now(UTC).isoformat(timespec="seconds"),
103
+ "commit": git.head(root),
104
+ "inputs": {"digest": inputs_digest(files), "files": len(files)},
105
+ "definition": definition,
106
+ "context": context,
107
+ "valid_until": valid_until,
108
+ "evidence": evidence or [],
109
+ "details": details or {},
110
+ }
111
+ entry["hash"] = _self_hash(entry)
112
+ write_atomic(verdict_path(root, check_id), json.dumps(entry, indent=1, sort_keys=True) + "\n")
113
+ write_atomic(root / CACHE_DIR / "inputs" / _file_name(check_id), json.dumps(files, sort_keys=True))
114
+ history = root / HISTORY_PATH
115
+ history.parent.mkdir(parents=True, exist_ok=True)
116
+ with history.open("a") as fh:
117
+ fh.write(json.dumps(entry, sort_keys=True) + "\n")
118
+ return entry
119
+
120
+
121
+ def _load(path: Path) -> dict[str, Any]:
122
+ """A verdict file, or `{"invalid": reason}` if it cannot be trusted."""
123
+ try:
124
+ entry = json.loads(path.read_text())
125
+ except (OSError, json.JSONDecodeError, UnicodeDecodeError):
126
+ return {"invalid": "not valid JSON (a merge conflict or a truncated write?)"}
127
+ if not isinstance(entry, dict) or not isinstance(entry.get("check"), str):
128
+ return {"invalid": "not a verdict"}
129
+ if _self_hash(entry) != entry.get("hash"):
130
+ return {"invalid": "contents do not match the file's own hash (edited by hand?)", "check": entry["check"]}
131
+ return entry
132
+
133
+
134
+ def read_all(root: Path) -> dict[str, dict[str, Any]]:
135
+ """Every verdict file, keyed by check id (or by file name when the file is unreadable)."""
136
+ out: dict[str, dict[str, Any]] = {}
137
+ directory = root / VERDICTS_DIR
138
+ if not directory.is_dir():
139
+ return out
140
+ for path in sorted(directory.glob("*.json")):
141
+ entry = _load(path)
142
+ out[entry.get("check") or path.stem] = entry | {"_file": path.relative_to(root).as_posix()}
143
+ return out
144
+
145
+
146
+ def problems(root: Path) -> list[str]:
147
+ return [f"{e['_file']}: {e['invalid']}" for e in read_all(root).values() if "invalid" in e]
148
+
149
+
150
+ def history(root: Path) -> list[dict[str, Any]]:
151
+ path = root / HISTORY_PATH
152
+ if not path.exists():
153
+ return []
154
+ out = []
155
+ for line in path.read_text().splitlines():
156
+ try:
157
+ out.append(json.loads(line))
158
+ except json.JSONDecodeError:
159
+ continue
160
+ return out
161
+
162
+
163
+ @dataclass
164
+ class Status:
165
+ check_id: str
166
+ state: str # fresh | stale | failing | never
167
+ entry: dict[str, Any] | None
168
+ changed: list[str]
169
+
170
+ @property
171
+ def trusted(self) -> bool:
172
+ return self.state == "fresh"
173
+
174
+
175
+ class View:
176
+ """All verdicts read once, for every status decision in one command."""
177
+
178
+ def __init__(self, root: Path) -> None:
179
+ self.root = root
180
+ self.verdicts = read_all(root)
181
+
182
+ def _changed_files(self, check_id: str, now: dict[str, str]) -> list[str]:
183
+ cached = self.root / CACHE_DIR / "inputs" / _file_name(check_id)
184
+ try:
185
+ then = json.loads(cached.read_text())
186
+ except (OSError, json.JSONDecodeError):
187
+ return ["(covered files changed)"]
188
+ return sorted(p for p in set(now) | set(then) if now.get(p) != then.get(p)) or ["(covered files changed)"]
189
+
190
+ def status(
191
+ self,
192
+ check_id: str,
193
+ covers: list[str],
194
+ specs: list[str],
195
+ definition: str = "",
196
+ context: str | None = None,
197
+ max_age_days: int | None = None,
198
+ ) -> Status:
199
+ """Compare a check's verdict with its inputs, declaration, context and age as they are now."""
200
+ entry = self.verdicts.get(check_id)
201
+ if entry is None:
202
+ return Status(check_id, "never", None, [])
203
+ if "invalid" in entry:
204
+ return Status(check_id, "stale", entry, [CORRUPT])
205
+ if entry.get("verdict") != "pass":
206
+ return Status(check_id, "failing", entry, [])
207
+ changed: list[str] = []
208
+ now = snapshot(self.root, covers) | snapshot(self.root, specs)
209
+ if inputs_digest(now) != entry.get("inputs", {}).get("digest"):
210
+ changed += self._changed_files(check_id, now)
211
+ if definition and entry.get("definition", definition) != definition:
212
+ changed.append("(the check's definition in rung.toml)")
213
+ if context is not None and entry.get("context", "") != context:
214
+ changed.append(f"(verified against {entry.get('context') or 'no base'}, not {context or 'no base'})")
215
+ today = datetime.now(UTC)
216
+ if (until := entry.get("valid_until")) and today.date().isoformat() > until:
217
+ changed.append(f"(a waiver it relied on expired on {until})")
218
+ if max_age_days is not None:
219
+ try:
220
+ at = datetime.fromisoformat(entry["at"])
221
+ except (KeyError, ValueError):
222
+ at = today - timedelta(days=max_age_days + 1)
223
+ if today - at > timedelta(days=max_age_days):
224
+ changed.append(f"(older than {max_age_days} day(s); its data source changes over time)")
225
+ return Status(check_id, "stale" if changed else "fresh", entry, changed)
rung/core/proc.py ADDED
@@ -0,0 +1,58 @@
1
+ """Run external commands with a deadline that owns the whole process tree.
2
+
3
+ `subprocess.run(timeout=...)` kills only the direct child; a test runner or dev server it started
4
+ keeps running. Here every command starts in its own process group (session), and on timeout the
5
+ whole group is killed. Output is captured in memory and truncated to a bounded tail.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import contextlib
11
+ import os
12
+ import signal
13
+ import subprocess
14
+ from collections.abc import Mapping, Sequence
15
+ from pathlib import Path
16
+
17
+ MAX_OUTPUT = 2_000_000 # characters kept per stream
18
+
19
+
20
+ def run(
21
+ argv: Sequence[str],
22
+ *,
23
+ cwd: Path,
24
+ timeout: float,
25
+ env: Mapping[str, str] | None = None,
26
+ stdin: str | None = None,
27
+ ) -> subprocess.CompletedProcess[str]:
28
+ """Run `argv` (no shell). Raises `subprocess.TimeoutExpired` after killing the whole process group."""
29
+ posix = os.name == "posix"
30
+ proc = subprocess.Popen(
31
+ list(argv),
32
+ cwd=cwd,
33
+ env=dict(env) if env is not None else None,
34
+ stdin=subprocess.PIPE if stdin is not None else subprocess.DEVNULL,
35
+ stdout=subprocess.PIPE,
36
+ stderr=subprocess.PIPE,
37
+ text=True,
38
+ start_new_session=posix,
39
+ )
40
+ try:
41
+ out, err = proc.communicate(input=stdin, timeout=timeout)
42
+ except subprocess.TimeoutExpired:
43
+ _kill_tree(proc, posix)
44
+ proc.communicate()
45
+ raise
46
+ finally:
47
+ # Anything the command left behind in its group (a server it backgrounded) goes too.
48
+ if proc.returncode is not None:
49
+ _kill_tree(proc, posix)
50
+ return subprocess.CompletedProcess(list(argv), proc.wait(), out[-MAX_OUTPUT:], err[-MAX_OUTPUT:])
51
+
52
+
53
+ def _kill_tree(proc: subprocess.Popen[str], posix: bool) -> None:
54
+ if posix:
55
+ with contextlib.suppress(ProcessLookupError, PermissionError):
56
+ os.killpg(proc.pid, signal.SIGKILL)
57
+ elif proc.poll() is None: # pragma: no cover - Windows
58
+ proc.kill()
rung/core/redact.py ADDED
@@ -0,0 +1,36 @@
1
+ """Mask secret-looking values before command output is stored in the committed ledger.
2
+
3
+ Rung keeps the last lines of each check's output so a failure can be understood later. That
4
+ output can contain a token a test printed or a URL with credentials in it. This is a best-effort
5
+ filter for well-known shapes, not a guarantee: commands should not print secrets.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+
12
+ MASK = "[REDACTED]"
13
+
14
+ # Each pattern has two groups: a prefix to keep, and the secret to mask.
15
+ _PATTERNS = [
16
+ re.compile(r"(\b)(gh[pousr]_[A-Za-z0-9]{20,})"), # GitHub tokens
17
+ re.compile(r"(\b)(github_pat_[A-Za-z0-9_]{20,})"),
18
+ re.compile(r"(\b)(xox[abprs]-[A-Za-z0-9-]{10,})"), # Slack
19
+ re.compile(r"(\b)((?:AKIA|ASIA)[A-Z0-9]{16})\b"), # AWS access key ids
20
+ re.compile(r"(\b)(sk-(?:proj-|ant-)?[A-Za-z0-9_-]{20,})"), # OpenAI / Anthropic style keys
21
+ re.compile(r"(\b)(AIza[0-9A-Za-z_-]{30,})"), # Google API keys
22
+ re.compile(r"(\b)(eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,})"), # JWTs
23
+ re.compile(r"(?i)(bearer\s+)([A-Za-z0-9._~+/=-]{16,})"),
24
+ re.compile(r"(://)([^/\s:@]+:[^/\s@]+)(?=@)"), # user:password@ in URLs
25
+ re.compile(r"(?i)((?:password|passwd|secret|token|api[_-]?key)\s*[=:]\s*)([^\s,;]{6,})"),
26
+ ]
27
+
28
+
29
+ def redact(text: str) -> str:
30
+ for pattern in _PATTERNS:
31
+ text = pattern.sub(lambda m: m.group(1) + MASK, text)
32
+ return text
33
+
34
+
35
+ def redact_lines(lines: list[str]) -> list[str]:
36
+ return [redact(line) for line in lines]
@@ -0,0 +1 @@
1
+ """Model-drafted artifacts (TLA+ specs, Lean models, runtime drive scripts) that a checker accepts or rejects."""