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/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Rung: an opinionated verification ladder for software written with coding agents."""
2
+
3
+ __version__ = "0.1.2"
rung/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from rung.cli import main
2
+
3
+ raise SystemExit(main())
@@ -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
@@ -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)