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