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/__init__.py
ADDED
rung/__main__.py
ADDED
rung/agents/__init__.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Make coding agents work inside the ladder instead of around it.
|
|
2
|
+
|
|
3
|
+
Two layers. Guidance (`install`): Claude Code skills and an AGENTS.md section saying how to use
|
|
4
|
+
Rung, how to write TLA+ and Lean, and which shortcuts are off limits. Enforcement (`hooks`): a
|
|
5
|
+
Claude Code `Stop` hook that keeps an agent from finishing while its edits are unverified.
|
|
6
|
+
Guidance can be ignored; the hook cannot.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from rung.agents.hooks import HOOK_COMMAND, main_stop_hook, stop_hook
|
|
10
|
+
from rung.agents.install import BEGIN, END, install, install_stop_hook, install_verify_skill
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"BEGIN", "END", "HOOK_COMMAND", "install", "install_stop_hook", "install_verify_skill", "main_stop_hook",
|
|
14
|
+
"stop_hook",
|
|
15
|
+
] # fmt: skip
|
rung/agents/hooks.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""The Claude Code `Stop` hook: an agent may not finish while its edits are unverified.
|
|
2
|
+
|
|
3
|
+
Claude Code runs `rung hook stop` when the agent tries to end its turn and sends a JSON
|
|
4
|
+
payload on stdin (`cwd`, `stop_hook_active`, ...). Exit code 2 blocks the stop and shows
|
|
5
|
+
stderr to the agent as the reason to continue. The hook fails open on anything unexpected,
|
|
6
|
+
and allows the stop when `stop_hook_active` is set, so it can never trap an agent in a loop.
|
|
7
|
+
See https://code.claude.com/docs/en/hooks.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import sys
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from rung.checks import runner
|
|
18
|
+
from rung.core import config, git
|
|
19
|
+
|
|
20
|
+
HOOK_COMMAND = "rung hook stop"
|
|
21
|
+
BLOCK = 2
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def stop_hook(cwd: Path, payload: str) -> tuple[int, str]:
|
|
25
|
+
"""Decide whether an agent may stop. Returns (exit code, message for the agent)."""
|
|
26
|
+
try:
|
|
27
|
+
data = json.loads(payload) if payload.strip() else {}
|
|
28
|
+
except json.JSONDecodeError:
|
|
29
|
+
data = {}
|
|
30
|
+
if data.get("stop_hook_active"):
|
|
31
|
+
return 0, "" # already blocked once this turn; do not loop
|
|
32
|
+
try:
|
|
33
|
+
root = config.find_root(cwd)
|
|
34
|
+
cfg = config.load(root)
|
|
35
|
+
# Compare with where the branch left its base, so committing the edits does not hide them.
|
|
36
|
+
try:
|
|
37
|
+
changed = git.changed_files(root, git.default_base(root))
|
|
38
|
+
except git.GitError:
|
|
39
|
+
changed = git.changed_files(root, "HEAD")
|
|
40
|
+
except Exception:
|
|
41
|
+
return 0, ""
|
|
42
|
+
if not changed:
|
|
43
|
+
return 0, ""
|
|
44
|
+
pending = [c for c in runner.select(cfg, [], changed) if not runner.status(cfg, c).trusted]
|
|
45
|
+
if not pending:
|
|
46
|
+
return 0, ""
|
|
47
|
+
ids = " ".join(c.id for c in pending)
|
|
48
|
+
return BLOCK, (
|
|
49
|
+
f"Rung: your edits touch checks that are not freshly passing: {ids}.\n"
|
|
50
|
+
f"Run `rung check {ids}` and fix what fails before finishing. "
|
|
51
|
+
"If a check cannot pass for a reason outside this task, say so explicitly."
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def main_stop_hook() -> int:
|
|
56
|
+
payload = sys.stdin.read() if not sys.stdin.isatty() else ""
|
|
57
|
+
# Claude Code sends the session's directory in the payload and exports CLAUDE_PROJECT_DIR.
|
|
58
|
+
try:
|
|
59
|
+
where = json.loads(payload).get("cwd") if payload.strip() else None
|
|
60
|
+
except (json.JSONDecodeError, AttributeError):
|
|
61
|
+
where = None
|
|
62
|
+
start = Path(where or os.environ.get("CLAUDE_PROJECT_DIR") or Path.cwd())
|
|
63
|
+
code, message = stop_hook(start, payload)
|
|
64
|
+
if message:
|
|
65
|
+
print(message, file=sys.stderr)
|
|
66
|
+
return code
|
rung/agents/install.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Install guidance for coding agents: Claude Code skills, an AGENTS.md section, and the Stop hook.
|
|
2
|
+
|
|
3
|
+
Skills are installed for Claude Code under `.claude/skills/`; the AGENTS.md section is read by
|
|
4
|
+
Codex, Cursor and other agents. Every skill body comes from `rung.guides`, the same text Rung
|
|
5
|
+
sends to the model when it drafts specs and proofs itself.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from rung import guides
|
|
14
|
+
from rung.agents.hooks import HOOK_COMMAND
|
|
15
|
+
from rung.core.config import ConfigError
|
|
16
|
+
|
|
17
|
+
BEGIN, END = "<!-- rung:begin -->", "<!-- rung:end -->"
|
|
18
|
+
|
|
19
|
+
USAGE = """\
|
|
20
|
+
# Working with Rung
|
|
21
|
+
|
|
22
|
+
This repository proves its behaviour instead of asserting it. Rung is a CLI; this skill only
|
|
23
|
+
says how to use it.
|
|
24
|
+
|
|
25
|
+
## Rules
|
|
26
|
+
|
|
27
|
+
{rules}
|
|
28
|
+
## Commands
|
|
29
|
+
|
|
30
|
+
- `rung check --changed`: run the checks your change touches and record verdicts.
|
|
31
|
+
- `rung status`: `stale` means covered code or a spec changed since the last green run.
|
|
32
|
+
- `rung laws`: which laws hold.
|
|
33
|
+
- `rung prove '<test command>'`: show your new test fails without the fix and passes with it.
|
|
34
|
+
- `rung report`: everything at once; add `--json` to read it programmatically.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
SKILLS = {
|
|
38
|
+
"rung": (
|
|
39
|
+
"Verify changes with Rung (laws, scans, TLA+, Lean, differential tests, evidence ledger). Use before "
|
|
40
|
+
"declaring any change done, when a check fails or goes stale, when fixing a bug, or when adding a "
|
|
41
|
+
"dependency, law, spec or proof."
|
|
42
|
+
),
|
|
43
|
+
"rung-tla": (
|
|
44
|
+
"Write or fix a TLA+ spec that mirrors real code and model-checks with TLC. Use when creating or "
|
|
45
|
+
"editing a .tla/.cfg file, or when a TLC counterexample needs reading."
|
|
46
|
+
),
|
|
47
|
+
"rung-property": (
|
|
48
|
+
"Write or strengthen Hypothesis or fast-check property-based tests for a law. Use when adding a "
|
|
49
|
+
"property test, when `rung draft property` reports surviving mutants, or when a property fails."
|
|
50
|
+
),
|
|
51
|
+
"rung-lean": (
|
|
52
|
+
"Write or fix a core-only Lean 4 model with kernel-checked proofs and a differential-testing "
|
|
53
|
+
"program. Use when creating or editing .lean files or when a Lean proof or build fails."
|
|
54
|
+
),
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _skill(name: str, description: str, body: str) -> str:
|
|
59
|
+
return f"---\nname: {name}\ndescription: >-\n {description}\n---\n\n{body}"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _write_skill(root: Path, name: str, body: str, description: str) -> Path:
|
|
63
|
+
path = root / ".claude" / "skills" / name / "SKILL.md"
|
|
64
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
65
|
+
path.write_text(_skill(name, description, body))
|
|
66
|
+
return path
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def install(root: Path, *, hooks: bool = False, lean_version: str = "the pinned version") -> list[Path]:
|
|
70
|
+
rules = guides.load("rules")
|
|
71
|
+
bodies = {
|
|
72
|
+
"rung": USAGE.format(rules=rules),
|
|
73
|
+
"rung-tla": guides.load("tla"),
|
|
74
|
+
"rung-property": guides.load("property"),
|
|
75
|
+
"rung-lean": guides.load("lean", lean_version=lean_version),
|
|
76
|
+
}
|
|
77
|
+
written = [_write_skill(root, name, bodies[name], desc) for name, desc in SKILLS.items()]
|
|
78
|
+
|
|
79
|
+
agents = root / "AGENTS.md"
|
|
80
|
+
block = f"{BEGIN}\n## Verification (Rung)\n\n{rules}{END}\n"
|
|
81
|
+
text = agents.read_text() if agents.exists() else ""
|
|
82
|
+
if BEGIN in text and END in text:
|
|
83
|
+
head, _, rest = text.partition(BEGIN)
|
|
84
|
+
_, _, tail = rest.partition(END)
|
|
85
|
+
text = head + block.rstrip("\n") + tail
|
|
86
|
+
else:
|
|
87
|
+
text = (text.rstrip() + "\n\n" if text.strip() else "") + block
|
|
88
|
+
agents.write_text(text)
|
|
89
|
+
written.append(agents)
|
|
90
|
+
|
|
91
|
+
if hooks:
|
|
92
|
+
written.append(install_stop_hook(root))
|
|
93
|
+
return written
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def install_verify_skill(root: Path, project: str, check_id: str) -> Path:
|
|
97
|
+
"""A project-specific skill pointing agents at the proven drive script and its feature map."""
|
|
98
|
+
slug = "".join(ch if ch.isalnum() else "-" for ch in project.lower()).strip("-") or "app"
|
|
99
|
+
description = (
|
|
100
|
+
f"Drive {project} the way a user does and record evidence. Use after any user-facing change "
|
|
101
|
+
"(UI, API, CLI), and before saying such a change works."
|
|
102
|
+
)
|
|
103
|
+
body = guides.load("verify", project=project, check_id=check_id)
|
|
104
|
+
return _write_skill(root, f"verify-{slug}", body, description)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def install_stop_hook(root: Path) -> Path:
|
|
108
|
+
"""Add the Stop hook to `.claude/settings.json`, keeping everything already there."""
|
|
109
|
+
path = root / ".claude" / "settings.json"
|
|
110
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
111
|
+
try:
|
|
112
|
+
settings = json.loads(path.read_text()) if path.exists() else {}
|
|
113
|
+
except json.JSONDecodeError as exc:
|
|
114
|
+
raise ConfigError(f"{path} is not plain JSON ({exc}); add the Stop hook by hand or remove comments") from exc
|
|
115
|
+
stop = settings.setdefault("hooks", {}).setdefault("Stop", [])
|
|
116
|
+
present = any(h.get("command") == HOOK_COMMAND for group in stop for h in group.get("hooks", []))
|
|
117
|
+
if not present:
|
|
118
|
+
stop.append({"hooks": [{"type": "command", "command": HOOK_COMMAND, "timeout": 120}]})
|
|
119
|
+
path.write_text(json.dumps(settings, indent=2) + "\n")
|
|
120
|
+
return path
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Reading a repository: the survey, the recommended plan, and the report over verdicts and laws."""
|
rung/analysis/plan.py
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""Recommend checks for a repository, cheapest and most widely useful first.
|
|
2
|
+
|
|
3
|
+
The ladder is applied by escalation, not by climbing every rung everywhere.
|
|
4
|
+
Every repository benefits from the cheap rungs: tests recorded as evidence,
|
|
5
|
+
security scanners gated on new findings, vetted dependencies, fix proofs.
|
|
6
|
+
Formal methods are recommended only where the survey finds code they pay off
|
|
7
|
+
for: TLA+ for stateful protocols, a Lean model for small pure cores.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import shutil
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
|
|
15
|
+
from rung.analysis.survey import Inspection
|
|
16
|
+
from rung.checks.presets import PRESETS
|
|
17
|
+
from rung.core.config import safe_id
|
|
18
|
+
|
|
19
|
+
LADDER = (
|
|
20
|
+
("tests", "Record the test suite as evidence; prove that each bug fix's test catches the bug."),
|
|
21
|
+
("security", "Gate on new secrets, vulnerable or invented dependencies, and risky CI workflows."),
|
|
22
|
+
("architecture", "Make the wrong shortcut impossible: import boundaries and lint rules, in CI."),
|
|
23
|
+
("runtime", "Drive the real app like a user and keep the evidence: transcripts, screenshots, video."),
|
|
24
|
+
("protocol", "Model stateful code (queues, retries, leases, sync) in TLA+ and check every interleaving."),
|
|
25
|
+
("model", "Prove the laws of a small pure core in Lean and hold the real code to it on random inputs."),
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class Recommendation:
|
|
31
|
+
rung: str
|
|
32
|
+
title: str
|
|
33
|
+
why: str
|
|
34
|
+
targets: list[str] = field(default_factory=list)
|
|
35
|
+
effort: str = "low"
|
|
36
|
+
command: str = ""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _scanner_rec(tool: str, check_id: str, why: str) -> Recommendation:
|
|
40
|
+
preset = PRESETS[tool]
|
|
41
|
+
installed = shutil.which(tool) is not None
|
|
42
|
+
command = f"rung add scan {check_id} --tool {tool}"
|
|
43
|
+
if not installed:
|
|
44
|
+
why += f" Install it first: {preset.install}."
|
|
45
|
+
return Recommendation("security", f"Scan for {preset.purpose}", why, effort="low", command=command)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def static_check_id(project_path: str, kind: str) -> str:
|
|
49
|
+
"""`typecheck` at the root, `frontend-typecheck` for a sub-project."""
|
|
50
|
+
return kind if project_path == "." else safe_id(f"{project_path.replace('/', '-')}-{kind}")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def plan(info: Inspection, existing: set[str] | None = None) -> list[Recommendation]:
|
|
54
|
+
"""Recommendations for a surveyed repository, skipping check ids that already exist."""
|
|
55
|
+
existing = existing or set()
|
|
56
|
+
recs: list[Recommendation] = []
|
|
57
|
+
lang = info.primary_language
|
|
58
|
+
|
|
59
|
+
tested = [p for p in info.projects if p.test_command]
|
|
60
|
+
for project in tested:
|
|
61
|
+
name = static_check_id(project.path, "tests")
|
|
62
|
+
if name in existing:
|
|
63
|
+
continue
|
|
64
|
+
label = "the" if project.path == "." else f"the {project.path}"
|
|
65
|
+
cwd = "" if project.path == "." else f" --cwd {project.path} --covers '{project.path}/**'"
|
|
66
|
+
recs.append(Recommendation(
|
|
67
|
+
"tests", f"Record {label} test suite as a check",
|
|
68
|
+
"A green run becomes a ledger entry tied to the exact code it ran against, and goes stale "
|
|
69
|
+
"when that code changes.",
|
|
70
|
+
command=f"rung add command {name}{cwd} --run '{project.test_command}'",
|
|
71
|
+
)) # fmt: skip
|
|
72
|
+
if tested:
|
|
73
|
+
first = tested[0]
|
|
74
|
+
cwd = "" if first.path == "." else f" --cwd {first.path}"
|
|
75
|
+
recs.append(Recommendation(
|
|
76
|
+
"tests", "Prove bug-fix tests catch the bug",
|
|
77
|
+
"Agents often write regression tests that pass with or without the fix. `rung prove` runs the "
|
|
78
|
+
"branch's new tests on the merge base, where they must fail, and on the branch, where they must pass.",
|
|
79
|
+
command=f"rung prove{cwd} '{first.test_command}'",
|
|
80
|
+
)) # fmt: skip
|
|
81
|
+
|
|
82
|
+
for project in info.projects:
|
|
83
|
+
for kind, command in project.static:
|
|
84
|
+
name = static_check_id(project.path, kind)
|
|
85
|
+
if name in existing:
|
|
86
|
+
continue
|
|
87
|
+
cwd = "" if project.path == "." else f" --cwd {project.path} --covers '{project.path}/**'"
|
|
88
|
+
recs.append(Recommendation(
|
|
89
|
+
"architecture", f"Record {command!r} as a check",
|
|
90
|
+
"The project already configures it; recorded as a check, an agent cannot finish with it failing, "
|
|
91
|
+
"and the `shortcuts` check stops it being silenced.",
|
|
92
|
+
command=f"rung add command {name}{cwd} --run '{command}'",
|
|
93
|
+
)) # fmt: skip
|
|
94
|
+
where = "" if project.path == "." else f" in {project.path}"
|
|
95
|
+
for tool in project.unrecorded:
|
|
96
|
+
recs.append(Recommendation(
|
|
97
|
+
"architecture", f"Record a check for {tool.split(' ', 1)[0]}{where} by hand",
|
|
98
|
+
f"Configured but not recorded automatically: {tool}.",
|
|
99
|
+
)) # fmt: skip
|
|
100
|
+
if "shortcuts" not in existing:
|
|
101
|
+
recs.append(Recommendation(
|
|
102
|
+
"tests", "Block the shortcuts agents take to get to green",
|
|
103
|
+
"New `@ts-ignore`, `# type: ignore`, `as any`, lint disables, skipped or focused tests, deleted tests, "
|
|
104
|
+
"lowered coverage thresholds and relaxed type-checker settings block; existing ones do not. No tool "
|
|
105
|
+
"to install.",
|
|
106
|
+
command="rung add shortcuts shortcuts",
|
|
107
|
+
)) # fmt: skip
|
|
108
|
+
|
|
109
|
+
if "secrets" not in existing:
|
|
110
|
+
recs.append(_scanner_rec("gitleaks", "secrets", "Leaked keys are the most common agent-introduced incident."))
|
|
111
|
+
if "vulnerable-deps" not in existing:
|
|
112
|
+
recs.append(_scanner_rec("osv-scanner", "vulnerable-deps", "Known-vulnerable dependency versions."))
|
|
113
|
+
if "deps" not in existing:
|
|
114
|
+
recs.append(Recommendation(
|
|
115
|
+
"security", "Vet every newly added dependency",
|
|
116
|
+
"Agents sometimes add packages that do not exist; attackers register those names. New packages "
|
|
117
|
+
"must exist on their registry and be at least 30 days old unless a person allows them.",
|
|
118
|
+
command="rung add deps deps",
|
|
119
|
+
)) # fmt: skip
|
|
120
|
+
if info.ci and "workflows" not in existing and any(p.startswith(".github/workflows/") for p in info.ci):
|
|
121
|
+
recs.append(_scanner_rec("zizmor", "workflows", "CI workflows run with secrets and write access."))
|
|
122
|
+
if lang in ("Python", "TypeScript", "JavaScript", "Go", "Java", "Ruby") and "code-patterns" not in existing:
|
|
123
|
+
recs.append(_scanner_rec("semgrep", "code-patterns", "Injection, SSRF and unsafe deserialization patterns."))
|
|
124
|
+
|
|
125
|
+
if not info.lint:
|
|
126
|
+
recs.append(Recommendation(
|
|
127
|
+
"architecture", "Add a linter and import-boundary rules",
|
|
128
|
+
f"No lint configuration found. Agents copy the patterns they see; for {lang}, a linter plus "
|
|
129
|
+
"layer rules stops the shortcut before review. Record it with `rung add command lint --run ...`.",
|
|
130
|
+
)) # fmt: skip
|
|
131
|
+
|
|
132
|
+
if info.surfaces and info.surfaces != ["library"]:
|
|
133
|
+
recs.append(Recommendation(
|
|
134
|
+
"runtime", f"Record runtime evidence for the {info.surfaces[0]}",
|
|
135
|
+
"Write a script that launches the app, drives one feature end to end, and saves what it saw to "
|
|
136
|
+
"$RUNG_EVIDENCE_DIR (a transcript, response bodies, a screenshot or a video). It must record "
|
|
137
|
+
"something to pass.",
|
|
138
|
+
effort="medium", command="rung add evidence runtime --run '<your drive script>'",
|
|
139
|
+
)) # fmt: skip
|
|
140
|
+
|
|
141
|
+
if info.protocol_candidates:
|
|
142
|
+
top = info.protocol_candidates[:3]
|
|
143
|
+
recs.append(Recommendation(
|
|
144
|
+
"protocol", "Model the stateful core in TLA+",
|
|
145
|
+
"These files look like they hold state, retries or leases, where bugs need a specific "
|
|
146
|
+
"interleaving that tests rarely reach: " + "; ".join(c.path for c in top) + ". Worth it only if "
|
|
147
|
+
"a concurrency bug here would be expensive.",
|
|
148
|
+
targets=[c.path for c in top], effort="high", command=f"rung draft tla {top[0].path}",
|
|
149
|
+
)) # fmt: skip
|
|
150
|
+
if info.model_candidates:
|
|
151
|
+
top = info.model_candidates[:3]
|
|
152
|
+
recs.append(Recommendation(
|
|
153
|
+
"model", "Model a pure core in Lean and test the code against it",
|
|
154
|
+
"Small, side-effect-free logic (pricing, permissions, parsers, reducers) is where a proven model "
|
|
155
|
+
"pays off: " + "; ".join(c.path for c in top) + ".",
|
|
156
|
+
targets=[c.path for c in top], effort="high", command=f"rung draft lean {top[0].path}",
|
|
157
|
+
)) # fmt: skip
|
|
158
|
+
|
|
159
|
+
recs.append(Recommendation(
|
|
160
|
+
"ledger", "Gate pull requests on fresh verdicts",
|
|
161
|
+
"In CI, `rung check --changed` runs what a branch touches and blocks only what it introduces; "
|
|
162
|
+
"`rung report` writes the pull request comment.",
|
|
163
|
+
command="rung check --changed",
|
|
164
|
+
)) # fmt: skip
|
|
165
|
+
return recs
|
rung/analysis/report.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""One structured report over checks, laws, new findings and proofs.
|
|
2
|
+
|
|
3
|
+
The same data renders as JSON (for agents and tools) or Markdown (for a pull
|
|
4
|
+
request comment). The Markdown starts with a marker so CI can update one
|
|
5
|
+
comment in place instead of posting a new one on every push.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from rung.checks import runner
|
|
13
|
+
from rung.core import laws, ledger
|
|
14
|
+
from rung.core.config import Config
|
|
15
|
+
|
|
16
|
+
COMMENT_MARKER = "<!-- rung-report -->"
|
|
17
|
+
STATE_ICON = {"fresh": "โ
", "stale": "๐ก", "failing": "โ", "never": "โช"}
|
|
18
|
+
LAW_ICON = {"held": "โ
", "unproven": "๐ก", "broken": "โ", "proposed": "๐", "unenforced": "โช"}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def build(config: Config) -> dict[str, Any]:
|
|
22
|
+
statuses = {s.check_id: s for s in runner.statuses(config)}
|
|
23
|
+
verdicts = ledger.read_all(config.root)
|
|
24
|
+
|
|
25
|
+
rows = []
|
|
26
|
+
new_findings = []
|
|
27
|
+
below_threshold = 0
|
|
28
|
+
for check in config.checks:
|
|
29
|
+
s = statuses[check.id]
|
|
30
|
+
latest = s.entry or {}
|
|
31
|
+
rows.append({
|
|
32
|
+
"id": check.id,
|
|
33
|
+
"kind": check.kind,
|
|
34
|
+
"state": s.state,
|
|
35
|
+
"summary": latest.get("summary", ""),
|
|
36
|
+
"at": latest.get("at", ""),
|
|
37
|
+
"changed": s.changed,
|
|
38
|
+
}) # fmt: skip
|
|
39
|
+
for f in (latest.get("details") or {}).get("findings", []):
|
|
40
|
+
if f.get("status") == "new":
|
|
41
|
+
new_findings.append({"check": check.id, **f})
|
|
42
|
+
elif f.get("status") == "new-below-threshold":
|
|
43
|
+
below_threshold += 1
|
|
44
|
+
|
|
45
|
+
law_rows = []
|
|
46
|
+
for law in laws.load(config.root):
|
|
47
|
+
state, why = laws.state(law, statuses)
|
|
48
|
+
law_rows.append({"id": law.id, "text": law.text, "state": state, "why": why})
|
|
49
|
+
|
|
50
|
+
proofs = []
|
|
51
|
+
for p in (v for v in verdicts.values() if v.get("kind") == "proof"):
|
|
52
|
+
# A proof is about specific bytes: once its tests or the fixed code change, it no longer holds.
|
|
53
|
+
files = p.get("details", {}).get("files", [])
|
|
54
|
+
current = ledger.inputs_digest(ledger.snapshot(config.root, files))
|
|
55
|
+
stale = p["verdict"] == "pass" and current != p.get("inputs", {}).get("digest")
|
|
56
|
+
proofs.append({
|
|
57
|
+
"id": p["check"], "verdict": "stale" if stale else p["verdict"],
|
|
58
|
+
"summary": "the tests or the fixed code changed since this proof" if stale else p["summary"],
|
|
59
|
+
"tests": p.get("details", {}).get("tests", []),
|
|
60
|
+
}) # fmt: skip
|
|
61
|
+
|
|
62
|
+
trusted = sum(1 for r in rows if r["state"] == "fresh")
|
|
63
|
+
held = sum(1 for law in law_rows if law["state"] == "held")
|
|
64
|
+
approved = sum(1 for law in law_rows if law["state"] != "proposed")
|
|
65
|
+
return {
|
|
66
|
+
"project": config.name,
|
|
67
|
+
"checks": rows,
|
|
68
|
+
"laws": law_rows,
|
|
69
|
+
"new_findings": new_findings,
|
|
70
|
+
"proofs": proofs,
|
|
71
|
+
"ledger_intact": not any("invalid" in v for v in verdicts.values()),
|
|
72
|
+
"summary": {
|
|
73
|
+
"checks_fresh": trusted,
|
|
74
|
+
"checks_total": len(rows),
|
|
75
|
+
"laws_held": held,
|
|
76
|
+
"laws_approved": approved,
|
|
77
|
+
"new_findings": len(new_findings),
|
|
78
|
+
"new_below_threshold": below_threshold,
|
|
79
|
+
},
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def markdown(data: dict[str, Any]) -> str:
|
|
84
|
+
s = data["summary"]
|
|
85
|
+
out = [
|
|
86
|
+
COMMENT_MARKER,
|
|
87
|
+
f"### Rung ยท {data['project']}",
|
|
88
|
+
"",
|
|
89
|
+
f"**{s['checks_fresh']}/{s['checks_total']}** checks fresh ยท "
|
|
90
|
+
f"**{s['laws_held']}/{s['laws_approved']}** approved laws held ยท "
|
|
91
|
+
f"**{s['new_findings']}** new blocking findings"
|
|
92
|
+
+ (f" ({s['new_below_threshold']} more below the blocking level)" if s["new_below_threshold"] else ""),
|
|
93
|
+
"",
|
|
94
|
+
]
|
|
95
|
+
if not data["ledger_intact"]:
|
|
96
|
+
out += ["> โ Some verdict files are corrupt or were edited by hand; those checks must be re-run.", ""]
|
|
97
|
+
|
|
98
|
+
if data["laws"]:
|
|
99
|
+
out += ["| | Law | Status |", "|---|---|---|"]
|
|
100
|
+
for law in data["laws"]:
|
|
101
|
+
out.append(f"| {LAW_ICON[law['state']]} | {law['text']} | {law['state']}: {law['why']} |")
|
|
102
|
+
out.append("")
|
|
103
|
+
|
|
104
|
+
out += ["| | Check | Kind | Result |", "|---|---|---|---|"]
|
|
105
|
+
for row in data["checks"]:
|
|
106
|
+
detail = row["summary"] or "never run"
|
|
107
|
+
if row["state"] == "stale" and row["changed"]:
|
|
108
|
+
detail += f" (stale: {', '.join(row['changed'][:3])})"
|
|
109
|
+
out.append(f"| {STATE_ICON[row['state']]} | `{row['id']}` | {row['kind']} | {detail} |")
|
|
110
|
+
out.append("")
|
|
111
|
+
|
|
112
|
+
if data["new_findings"]:
|
|
113
|
+
out += ["<details><summary>New blocking findings</summary>", ""]
|
|
114
|
+
for f in data["new_findings"][:50]:
|
|
115
|
+
where = f"{f['path']}:{f['line']}" if f.get("line") else f.get("path", "")
|
|
116
|
+
out.append(f"- **{f['level']}** `{f['rule']}` in `{where}` ({f['check']}): {f.get('message', '')[:200]}")
|
|
117
|
+
out += ["", "</details>", ""]
|
|
118
|
+
|
|
119
|
+
if data["proofs"]:
|
|
120
|
+
out += ["**Fix proofs**", ""]
|
|
121
|
+
for p in data["proofs"]:
|
|
122
|
+
icon = {"pass": "โ
", "stale": "๐ก"}.get(p["verdict"], "โ")
|
|
123
|
+
out.append(f"- {icon} `{', '.join(p['tests'])}`: {p['summary']}")
|
|
124
|
+
out.append("")
|
|
125
|
+
return "\n".join(out)
|