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/cli/__init__.py
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""`rung` command line: argument parsing and error handling. Commands live in `commands`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
from rung import __version__
|
|
10
|
+
from rung.checks.presets import PRESETS
|
|
11
|
+
from rung.cli import commands as c
|
|
12
|
+
from rung.cli.output import Output
|
|
13
|
+
from rung.core.config import CHECK_KINDS, ConfigError
|
|
14
|
+
from rung.core.errors import RungError, ToolMissing
|
|
15
|
+
from rung.core.git import GitError
|
|
16
|
+
from rung.drafting import providers
|
|
17
|
+
from rung.engines.sarif import LEVELS
|
|
18
|
+
|
|
19
|
+
EXIT_USAGE, EXIT_TOOL, EXIT_GIT = 2, 3, 4
|
|
20
|
+
|
|
21
|
+
DESCRIPTION = """\
|
|
22
|
+
Rung keeps a ledger of what has actually been verified in a codebase, and against
|
|
23
|
+
which exact code. Checks range from your test suite and security scanners to TLA+
|
|
24
|
+
model checking and Lean proofs; laws in LAWS.md say what must never break.
|
|
25
|
+
|
|
26
|
+
exit codes: 0 ok · 1 a check or gate failed · 2 configuration or usage error ·
|
|
27
|
+
3 a required tool is missing · 4 git error"""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _common_options(*, defaults: bool) -> argparse.ArgumentParser:
|
|
31
|
+
"""Options accepted before or after the subcommand.
|
|
32
|
+
|
|
33
|
+
The subcommand copies use SUPPRESS defaults so `rung -C dir check` is not
|
|
34
|
+
overwritten by the subcommand's own default.
|
|
35
|
+
"""
|
|
36
|
+
parser = argparse.ArgumentParser(add_help=False)
|
|
37
|
+
suppress = not defaults
|
|
38
|
+
parser.add_argument("-C", "--dir", help="project directory (default: current)",
|
|
39
|
+
default=argparse.SUPPRESS if suppress else ".") # fmt: skip
|
|
40
|
+
parser.add_argument("--json", action="store_true", help="print machine-readable JSON",
|
|
41
|
+
default=argparse.SUPPRESS if suppress else False) # fmt: skip
|
|
42
|
+
parser.add_argument("--no-color", action="store_true", help="disable colour",
|
|
43
|
+
default=argparse.SUPPRESS if suppress else False) # fmt: skip
|
|
44
|
+
return parser
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
48
|
+
common = _common_options(defaults=False)
|
|
49
|
+
p = argparse.ArgumentParser(
|
|
50
|
+
prog="rung",
|
|
51
|
+
description=DESCRIPTION,
|
|
52
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
53
|
+
parents=[_common_options(defaults=True)],
|
|
54
|
+
)
|
|
55
|
+
p.add_argument("--version", action="version", version=f"rung {__version__}")
|
|
56
|
+
sub = p.add_subparsers(dest="command", required=True, metavar="<command>")
|
|
57
|
+
|
|
58
|
+
def add(name: str, func, help_text: str) -> argparse.ArgumentParser:
|
|
59
|
+
s = sub.add_parser(name, help=help_text, description=help_text, parents=[common])
|
|
60
|
+
s.set_defaults(func=func)
|
|
61
|
+
return s
|
|
62
|
+
|
|
63
|
+
s = add("init", c.cmd_init, "survey the repo, choose a model provider, write rung.toml")
|
|
64
|
+
s.add_argument("--provider", choices=list(providers.PROVIDERS))
|
|
65
|
+
s.add_argument("--model", help="model for drafting, passed to the provider (e.g. sonnet, opus, gpt-6.1-sol); "
|
|
66
|
+
"empty uses the CLI's own default") # fmt: skip
|
|
67
|
+
add("plan", c.cmd_plan, "recommend checks for this repo")
|
|
68
|
+
add("doctor", c.cmd_doctor, "show which checkers, scanners and model CLIs are available")
|
|
69
|
+
s = add("agents", c.cmd_agents, "install the agent skill and AGENTS.md rules (and enforcement hooks)")
|
|
70
|
+
s.add_argument("--hooks", action="store_true", help="also install a Claude Code Stop hook that blocks stale work")
|
|
71
|
+
s = add("hook", c.cmd_hook, "entry point for agent hooks (reads the hook payload on stdin)")
|
|
72
|
+
s.add_argument("event", choices=["stop"])
|
|
73
|
+
|
|
74
|
+
s = add("check", c.cmd_check, "run checks and record verdicts")
|
|
75
|
+
s.add_argument("ids", nargs="*", help="check ids (default: all)")
|
|
76
|
+
s.add_argument("--changed", action="store_true", help="only checks touched by this branch's changes")
|
|
77
|
+
s.add_argument("--base", help="branch to compare with (default: the repository's default branch)")
|
|
78
|
+
s.add_argument("--ci", action="store_true", help="skip checks that are already fresh")
|
|
79
|
+
s = add("status", c.cmd_status, "show which verdicts are fresh, stale or failing")
|
|
80
|
+
s.add_argument("--ci", action="store_true", help="exit 1 unless every verdict is fresh")
|
|
81
|
+
s = add("report", c.cmd_report, "summarize checks, laws, new findings and fix proofs")
|
|
82
|
+
s.add_argument("--format", choices=["md", "json"], default="md")
|
|
83
|
+
s = add("prove", c.cmd_prove, "show a new test fails without the fix and passes with it")
|
|
84
|
+
s.add_argument("test_command", metavar="command", help="test command, e.g. 'pytest tests/test_refunds.py'")
|
|
85
|
+
s.add_argument("--base", help="branch without the fix (default: the repository's default branch)")
|
|
86
|
+
s.add_argument("--tests", nargs="*", help="test files to carry over (default: test files changed on this branch)")
|
|
87
|
+
s.add_argument("--expect", help="text the failing run must contain, e.g. the assertion")
|
|
88
|
+
s.add_argument("--cwd", default=".", help="directory to run the test command in (e.g. backend)")
|
|
89
|
+
s = add("ledger", c.cmd_ledger, "check the verdict files for corruption")
|
|
90
|
+
s.add_argument("--prune", action="store_true", help="remove verdicts of checks no longer in rung.toml")
|
|
91
|
+
|
|
92
|
+
s = add("laws", c.cmd_laws, "list laws and whether each one holds")
|
|
93
|
+
s.add_argument("--approve", metavar="LAW_ID", help="mark a proposed law as approved")
|
|
94
|
+
s = add("add", c.cmd_add, "declare a check in rung.toml")
|
|
95
|
+
s.add_argument("kind", choices=[k for k in CHECK_KINDS if k not in ("tla", "lean", "drt")])
|
|
96
|
+
s.add_argument("id")
|
|
97
|
+
s.add_argument("--run", help="command to run (command, custom scan; for evidence, omit it to attest instead)")
|
|
98
|
+
s.add_argument("--cwd", help="directory to run the command in, relative to the project root")
|
|
99
|
+
s.add_argument("--tool", choices=list(PRESETS), help="scanner preset (scan)")
|
|
100
|
+
s.add_argument("--fail-on", choices=LEVELS, help="lowest SARIF level that blocks (scan)")
|
|
101
|
+
s.add_argument("--require", nargs="*", help="artifact suffixes the evidence must include, e.g. .webm")
|
|
102
|
+
s.add_argument("--min-age-days", type=int, default=30, help="youngest acceptable new package (deps)")
|
|
103
|
+
s.add_argument("--covers", nargs="*", help="files whose change stales the verdict (globs)")
|
|
104
|
+
s = add("attest", c.cmd_attest, "record evidence an agent or person captured (evidence checks without `run`)")
|
|
105
|
+
s.add_argument("check")
|
|
106
|
+
s.add_argument("files", nargs="+", help="recordings, screenshots, transcripts")
|
|
107
|
+
s.add_argument("--by", required=True, help="who or what recorded it, e.g. claude-in-chrome, computer-use, codex")
|
|
108
|
+
s.add_argument("--note", help="what was driven and what was seen")
|
|
109
|
+
s = add("baseline", c.cmd_baseline, "accept a scan check's existing findings; only new ones will block")
|
|
110
|
+
s.add_argument("check")
|
|
111
|
+
s = add("waive", c.cmd_waive, "accept one finding, with a reason and an expiry")
|
|
112
|
+
s.add_argument("check")
|
|
113
|
+
s.add_argument("fingerprint")
|
|
114
|
+
s.add_argument("--reason", required=True)
|
|
115
|
+
s.add_argument("--days", type=int, default=30, help="days until the waiver expires (default 30)")
|
|
116
|
+
s.add_argument("--until", help="expiry date, YYYY-MM-DD")
|
|
117
|
+
s.add_argument("--approved-by", help="who accepts the risk (default: git user.name)")
|
|
118
|
+
s = add("draft", c.cmd_draft, "draft a TLA+ spec, Lean model, property tests or drive script; a checker decides")
|
|
119
|
+
s.add_argument("kind", choices=["tla", "lean", "property", "runtime"])
|
|
120
|
+
s.add_argument("files", nargs="*", help="source files to model (tla, lean) or to focus on (runtime)")
|
|
121
|
+
s.add_argument("--intent", help="what the code must guarantee, or (runtime) which feature to drive")
|
|
122
|
+
s.add_argument("--require", nargs="*", help="(runtime) evidence the script must leave, e.g. .webm for a video")
|
|
123
|
+
s.add_argument("--id")
|
|
124
|
+
s.add_argument("--out", help="where to write the result: a directory (tla, lean) or a test file (property)")
|
|
125
|
+
s.add_argument("--model", help="model for this draft only (default: [llm] model in rung.toml, else the CLI's)")
|
|
126
|
+
return p
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def main(argv: list[str] | None = None) -> int:
|
|
130
|
+
args = build_parser().parse_args(argv)
|
|
131
|
+
out = Output(json_mode=args.json, color=False if args.no_color else None)
|
|
132
|
+
try:
|
|
133
|
+
return args.func(args, out)
|
|
134
|
+
except ConfigError as exc:
|
|
135
|
+
out.error(str(exc))
|
|
136
|
+
return EXIT_USAGE
|
|
137
|
+
except ToolMissing as exc:
|
|
138
|
+
out.error(str(exc))
|
|
139
|
+
return EXIT_TOOL
|
|
140
|
+
except GitError as exc:
|
|
141
|
+
out.error(str(exc))
|
|
142
|
+
return EXIT_GIT
|
|
143
|
+
except RungError as exc:
|
|
144
|
+
out.error(str(exc))
|
|
145
|
+
return c.GATE_FAILED
|
|
146
|
+
except KeyboardInterrupt:
|
|
147
|
+
out.error("interrupted")
|
|
148
|
+
return 130
|
|
149
|
+
except Exception as exc: # a bug or an unexpected input: say so briefly, not with a traceback
|
|
150
|
+
if os.environ.get("RUNG_DEBUG"):
|
|
151
|
+
raise
|
|
152
|
+
out.error(f"unexpected error: {type(exc).__name__}: {exc} (set RUNG_DEBUG=1 for the traceback)")
|
|
153
|
+
return EXIT_USAGE
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
if __name__ == "__main__":
|
|
157
|
+
sys.exit(main())
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Command implementations. Each takes parsed args and an Output, and returns an exit code."""
|
|
2
|
+
|
|
3
|
+
from rung.cli.commands.common import GATE_FAILED, OK
|
|
4
|
+
from rung.cli.commands.draft import cmd_draft
|
|
5
|
+
from rung.cli.commands.manage import cmd_add, cmd_attest, cmd_baseline, cmd_laws, cmd_waive
|
|
6
|
+
from rung.cli.commands.run import cmd_check, cmd_ledger, cmd_prove, cmd_report, cmd_status
|
|
7
|
+
from rung.cli.commands.setup import cmd_agents, cmd_doctor, cmd_hook, cmd_init, cmd_plan
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"GATE_FAILED", "OK", "cmd_add", "cmd_agents", "cmd_attest", "cmd_baseline", "cmd_check", "cmd_doctor", "cmd_draft",
|
|
11
|
+
"cmd_hook", "cmd_init", "cmd_laws", "cmd_ledger", "cmd_plan", "cmd_prove", "cmd_report", "cmd_status",
|
|
12
|
+
"cmd_waive",
|
|
13
|
+
] # fmt: skip
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Helpers shared by the command modules."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from rung.checks import registry
|
|
9
|
+
from rung.cli.output import Output
|
|
10
|
+
from rung.core import git
|
|
11
|
+
from rung.core.config import CONFIG_NAME, Check, Config, find_root, load, save
|
|
12
|
+
|
|
13
|
+
OK, GATE_FAILED = 0, 1
|
|
14
|
+
OK, GATE_FAILED = 0, 1
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def load_config(args) -> Config:
|
|
18
|
+
return load(find_root(Path(args.dir)))
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def ask(question: str, default: str = "") -> str:
|
|
22
|
+
if not sys.stdin.isatty():
|
|
23
|
+
return default
|
|
24
|
+
suffix = f" [{default}]" if default else ""
|
|
25
|
+
print(f"{question}{suffix}: ", end="", file=sys.stderr, flush=True)
|
|
26
|
+
return input().strip() or default
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def resolve_base(root: Path, base: str | None) -> str:
|
|
30
|
+
"""A merge base commit for `base` (or the default branch)."""
|
|
31
|
+
return git.merge_base(root, base or git.default_base(root))
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def add_check(config: Config, check: Check, out: Output) -> None:
|
|
35
|
+
registry.get(check.kind) # validate the kind before writing anything
|
|
36
|
+
replaced = any(c.id == check.id for c in config.checks)
|
|
37
|
+
config.checks = [c for c in config.checks if c.id != check.id] + [check]
|
|
38
|
+
save(config)
|
|
39
|
+
out.emit({"check": check.id, "kind": check.kind, "covers": check.covers, "options": check.options,
|
|
40
|
+
"replaced": replaced}) # fmt: skip
|
|
41
|
+
out.line(f"{'Updated' if replaced else 'Added'} check {check.id!r} ({check.kind}) in {CONFIG_NAME}.")
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"""`rung draft`: model-drafted TLA+ specs, Lean models and runtime drive scripts."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import tomllib
|
|
7
|
+
from dataclasses import replace
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from rung import agents
|
|
11
|
+
from rung.analysis import survey
|
|
12
|
+
from rung.cli.commands.common import GATE_FAILED, OK, add_check, load_config
|
|
13
|
+
from rung.cli.output import Output
|
|
14
|
+
from rung.core import laws
|
|
15
|
+
from rung.core.config import Check, Config, ConfigError
|
|
16
|
+
from rung.drafting import lean as lean_draft
|
|
17
|
+
from rung.drafting import property as property_draft
|
|
18
|
+
from rung.drafting import providers, runtime
|
|
19
|
+
from rung.drafting import tla as tla_draft
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def drafted_by(cfg: Config, provider: providers.Provider | None = None) -> str:
|
|
23
|
+
"""Which provider and model drafted an artifact, recorded on the check it creates.
|
|
24
|
+
|
|
25
|
+
After a draft, `provider` reports the models that actually answered (Claude Code's own report, or
|
|
26
|
+
Codex's configured default), so `default` is only left when the tool does not say.
|
|
27
|
+
"""
|
|
28
|
+
model = provider.models() if provider else cfg.llm.model or "your CLI's default model"
|
|
29
|
+
return f"{cfg.llm.provider}:{model}"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _provider(cfg: Config, args) -> providers.Provider:
|
|
33
|
+
"""The configured provider, with `--model` applied to this draft only (rung.toml is not changed)."""
|
|
34
|
+
return providers.get(replace(cfg.llm, model=args.model) if args.model else cfg.llm)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _slug(paths: list[str]) -> str:
|
|
38
|
+
return re.sub(r"[^a-z0-9]+", "-", Path(paths[0]).stem.lower()).strip("-") or "spec"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _lean_exe(project: Path) -> str:
|
|
42
|
+
try:
|
|
43
|
+
lakefile = tomllib.loads((project / "lakefile.toml").read_text())
|
|
44
|
+
except (OSError, tomllib.TOMLDecodeError):
|
|
45
|
+
return ""
|
|
46
|
+
exes = lakefile.get("lean_exe", [])
|
|
47
|
+
return exes[0].get("name", "") if exes else ""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _draft_runtime(args, cfg: Config, out: Output) -> int:
|
|
51
|
+
provider = _provider(cfg, args)
|
|
52
|
+
info = survey.survey(cfg.root)
|
|
53
|
+
check_id = args.id or "runtime"
|
|
54
|
+
out.line(
|
|
55
|
+
f"Drafting a runtime verification script with {drafted_by(cfg, provider)}; Rung runs it until it proves itself."
|
|
56
|
+
)
|
|
57
|
+
result = runtime.draft_runtime(
|
|
58
|
+
provider,
|
|
59
|
+
cfg.root,
|
|
60
|
+
info,
|
|
61
|
+
focus=args.files,
|
|
62
|
+
check_id=check_id,
|
|
63
|
+
intent=args.intent or "",
|
|
64
|
+
require=tuple(args.require or ()),
|
|
65
|
+
)
|
|
66
|
+
out.emit({"ok": result.ok, "message": result.message, "rounds": result.rounds, "covers": result.covers,
|
|
67
|
+
"files": [str(f.relative_to(cfg.root)) for f in result.files], "evidence": result.evidence}) # fmt: skip
|
|
68
|
+
out.line(f"{'OK' if result.ok else 'FAILED'} after {result.rounds} round(s): {result.message}")
|
|
69
|
+
out.detail(f"drafted by {drafted_by(cfg, provider)}", indent=2)
|
|
70
|
+
if result.notes:
|
|
71
|
+
out.line("Notes from the model:")
|
|
72
|
+
out.detail(result.notes, indent=2)
|
|
73
|
+
for f in result.files:
|
|
74
|
+
out.detail(f"wrote {f.relative_to(cfg.root)}", indent=2)
|
|
75
|
+
for e in result.evidence:
|
|
76
|
+
out.detail(f"evidence {e}", indent=2)
|
|
77
|
+
if not result.ok:
|
|
78
|
+
out.line("The last script is kept for you to finish by hand; it is not registered as a check.")
|
|
79
|
+
return GATE_FAILED
|
|
80
|
+
add_check(
|
|
81
|
+
cfg,
|
|
82
|
+
Check(
|
|
83
|
+
check_id,
|
|
84
|
+
"evidence",
|
|
85
|
+
result.covers,
|
|
86
|
+
{
|
|
87
|
+
"run": result.run,
|
|
88
|
+
"timeout": 900,
|
|
89
|
+
"require": list(args.require or []),
|
|
90
|
+
"drafted_by": drafted_by(cfg, provider),
|
|
91
|
+
},
|
|
92
|
+
),
|
|
93
|
+
out,
|
|
94
|
+
)
|
|
95
|
+
skill = agents.install_verify_skill(cfg.root, cfg.name, check_id)
|
|
96
|
+
out.line(f"wrote {skill.relative_to(cfg.root)} so agents verify user-facing changes with `rung check {check_id}`.")
|
|
97
|
+
return OK
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _draft_property(args, cfg: Config, out: Output, sources: list[Path], rel: list[str]) -> int:
|
|
101
|
+
root = cfg.root
|
|
102
|
+
check_id = args.id or f"{_slug(rel)}-properties"
|
|
103
|
+
provider = _provider(cfg, args)
|
|
104
|
+
out.line(
|
|
105
|
+
f"Drafting property tests for {', '.join(rel)} with {drafted_by(cfg, provider)}; Rung runs them, then breaks "
|
|
106
|
+
"the code on purpose to see whether they notice ..."
|
|
107
|
+
)
|
|
108
|
+
result, target = property_draft.draft(
|
|
109
|
+
provider, root, sources, _slug(rel), intent=args.intent or "", out=args.out or ""
|
|
110
|
+
)
|
|
111
|
+
files = [f.relative_to(root).as_posix() for f in result.files]
|
|
112
|
+
out.emit({"ok": result.ok, "message": result.message, "rounds": result.rounds, "trace": result.trace,
|
|
113
|
+
"files": files, "warnings": result.warnings, "run": target.command}) # fmt: skip
|
|
114
|
+
out.line(f"{'OK' if result.ok else 'FAILED'} after {result.rounds} round(s): {result.message}")
|
|
115
|
+
out.detail(f"drafted by {drafted_by(cfg, provider)}", indent=2)
|
|
116
|
+
if result.notes:
|
|
117
|
+
out.line("Notes from the model:")
|
|
118
|
+
out.detail(result.notes, indent=2)
|
|
119
|
+
for f in files:
|
|
120
|
+
out.detail(f"wrote {f}", indent=2)
|
|
121
|
+
for w in result.warnings:
|
|
122
|
+
out.detail(w, indent=2)
|
|
123
|
+
if result.trace:
|
|
124
|
+
out.line("\nFailing example:")
|
|
125
|
+
out.detail(result.trace, indent=2)
|
|
126
|
+
out.line(
|
|
127
|
+
"\nIf this is a real bug, fix the code and run `rung check`. If the law is wrong, edit the test yourself."
|
|
128
|
+
)
|
|
129
|
+
return GATE_FAILED
|
|
130
|
+
if not result.ok:
|
|
131
|
+
return GATE_FAILED
|
|
132
|
+
# The verdict depends on the code under test, whatever it imports, and the runner's configuration:
|
|
133
|
+
# cover the whole project directory, and the test file itself.
|
|
134
|
+
covers = ["**/*"] if target.project == "." else [f"{target.project}/**"]
|
|
135
|
+
options = {"run": target.command, "drafted_by": drafted_by(cfg, provider), "specs": [target.test_file]}
|
|
136
|
+
if target.project != ".":
|
|
137
|
+
options["cwd"] = target.project
|
|
138
|
+
add_check(cfg, Check(check_id, "command", covers, options), out)
|
|
139
|
+
if added := laws.propose(root, result.laws, [check_id]):
|
|
140
|
+
out.line(f"Proposed {len(added)} law(s) in {laws.LAWS_FILE}: {', '.join(added)}.")
|
|
141
|
+
out.line("Read each one; approve with `rung laws --approve <id>` only if it says what you mean.")
|
|
142
|
+
return OK
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def cmd_draft(args, out: Output) -> int:
|
|
146
|
+
cfg = load_config(args)
|
|
147
|
+
if args.kind == "runtime":
|
|
148
|
+
return _draft_runtime(args, cfg, out)
|
|
149
|
+
if not args.files:
|
|
150
|
+
raise ConfigError(f"`rung draft {args.kind}` needs the source files to model")
|
|
151
|
+
provider = _provider(cfg, args)
|
|
152
|
+
root = cfg.root
|
|
153
|
+
sources = [(root / p).resolve() for p in args.files]
|
|
154
|
+
missing = [str(s) for s in sources if not s.is_file()]
|
|
155
|
+
if missing:
|
|
156
|
+
raise ConfigError(f"not found: {', '.join(missing)}")
|
|
157
|
+
rel = [s.relative_to(root).as_posix() for s in sources]
|
|
158
|
+
if args.kind == "property":
|
|
159
|
+
return _draft_property(args, cfg, out, sources, rel)
|
|
160
|
+
check_id = args.id or f"{_slug(rel)}-{args.kind}"
|
|
161
|
+
target = root / (args.out or f"spec/{args.kind}/{_slug(rel)}")
|
|
162
|
+
target.mkdir(parents=True, exist_ok=True)
|
|
163
|
+
out.line(f"Drafting {args.kind} for {', '.join(rel)} with {drafted_by(cfg, provider)} ...")
|
|
164
|
+
|
|
165
|
+
drafter = tla_draft.draft if args.kind == "tla" else lean_draft.draft
|
|
166
|
+
result = drafter(provider, root, sources, target, intent=args.intent or "")
|
|
167
|
+
out.emit({"ok": result.ok, "message": result.message, "rounds": result.rounds, "trace": result.trace,
|
|
168
|
+
"files": [str(f.relative_to(root)) for f in result.files]}) # fmt: skip
|
|
169
|
+
out.line(f"{'OK' if result.ok else 'FAILED'} after {result.rounds} round(s): {result.message}")
|
|
170
|
+
out.detail(f"drafted by {drafted_by(cfg, provider)}", indent=2)
|
|
171
|
+
if result.notes:
|
|
172
|
+
out.line("Notes from the model:")
|
|
173
|
+
out.detail(result.notes, indent=2)
|
|
174
|
+
for f in result.files:
|
|
175
|
+
out.detail(f"wrote {f.relative_to(root)}", indent=2)
|
|
176
|
+
if result.trace:
|
|
177
|
+
out.line("\nCounterexample:")
|
|
178
|
+
out.detail(result.trace, indent=2)
|
|
179
|
+
out.line("\nIf this is a real bug, fix the code and run `rung check`. If the spec is wrong, edit it yourself.")
|
|
180
|
+
if not result.ok:
|
|
181
|
+
return GATE_FAILED
|
|
182
|
+
|
|
183
|
+
if args.kind == "tla":
|
|
184
|
+
spec = next(f for f in result.files if f.suffix == ".tla")
|
|
185
|
+
cfg_file = next(f for f in result.files if f.suffix == ".cfg")
|
|
186
|
+
options = {"spec": spec.relative_to(root).as_posix(), "config": cfg_file.relative_to(root).as_posix(),
|
|
187
|
+
"drafted_by": drafted_by(cfg, provider)} # fmt: skip
|
|
188
|
+
add_check(cfg, Check(check_id, "tla", rel, options), out)
|
|
189
|
+
else:
|
|
190
|
+
project = target.relative_to(root).as_posix()
|
|
191
|
+
add_check(
|
|
192
|
+
cfg,
|
|
193
|
+
Check(
|
|
194
|
+
check_id,
|
|
195
|
+
"lean",
|
|
196
|
+
[],
|
|
197
|
+
{"project": project, "theorems": result.theorems, "drafted_by": drafted_by(cfg, provider)},
|
|
198
|
+
),
|
|
199
|
+
out,
|
|
200
|
+
)
|
|
201
|
+
exe = _lean_exe(target)
|
|
202
|
+
if exe and result.alphabet and (target / "drt_adapter.py").exists():
|
|
203
|
+
options = {
|
|
204
|
+
"model": f"lake exe {exe}", "model_cwd": project,
|
|
205
|
+
"impl": f"python3 {project}/drt_adapter.py", "alphabet": result.alphabet,
|
|
206
|
+
"cases": 2000, "max_len": 8, "specs": [f"{project}/**/*.lean", f"{project}/drt_adapter.py"],
|
|
207
|
+
} # fmt: skip
|
|
208
|
+
add_check(cfg, Check(f"{check_id}-drt", "drt", rel, options), out)
|
|
209
|
+
enforcing = [check_id] if args.kind == "tla" else [check_id, f"{check_id}-drt"]
|
|
210
|
+
added = laws.propose(root, result.laws, enforcing)
|
|
211
|
+
if added:
|
|
212
|
+
out.line(f"Proposed {len(added)} law(s) in {laws.LAWS_FILE}: {', '.join(added)}.")
|
|
213
|
+
out.line("Read each one; approve with `rung laws --approve <id>` only if it says what you mean.")
|
|
214
|
+
return OK
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""Commands that change what is checked: laws, add, baseline, waive."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from datetime import date, timedelta
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from rung.checks import findings, registry, runner
|
|
10
|
+
from rung.checks.presets import PRESETS
|
|
11
|
+
from rung.checks.scan import ScanChecker
|
|
12
|
+
from rung.cli.commands.common import OK, add_check, load_config
|
|
13
|
+
from rung.cli.output import STATE_STYLE, VERDICT_STYLE, Output
|
|
14
|
+
from rung.core import git, laws
|
|
15
|
+
from rung.core.config import Check, ConfigError
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def cmd_laws(args, out: Output) -> int:
|
|
19
|
+
cfg = load_config(args)
|
|
20
|
+
if args.approve:
|
|
21
|
+
try:
|
|
22
|
+
laws.set_status(cfg.root, args.approve, "approved")
|
|
23
|
+
except KeyError as exc:
|
|
24
|
+
raise ConfigError(f"no law {args.approve!r} in {laws.LAWS_FILE}") from exc
|
|
25
|
+
out.line(f"Approved {args.approve}.")
|
|
26
|
+
statuses = {s.check_id: s for s in runner.statuses(cfg)}
|
|
27
|
+
rows = [(law, *laws.state(law, statuses)) for law in laws.load(cfg.root)]
|
|
28
|
+
out.emit([{"id": law.id, "text": law.text, "state": st, "why": why, "enforced_by": law.enforced_by}
|
|
29
|
+
for law, st, why in rows]) # fmt: skip
|
|
30
|
+
if not rows:
|
|
31
|
+
out.line(f"No laws in {laws.LAWS_FILE} yet.")
|
|
32
|
+
for law, st, why in rows:
|
|
33
|
+
out.line(f"{out.badge(st, STATE_STYLE, 11)}{law.id}")
|
|
34
|
+
out.detail(law.text, indent=11)
|
|
35
|
+
out.detail(out.style(why, "dim"), indent=11)
|
|
36
|
+
return OK
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def cmd_add(args, out: Output) -> int:
|
|
40
|
+
cfg = load_config(args)
|
|
41
|
+
options: dict[str, object] = {}
|
|
42
|
+
if args.kind in ("command", "evidence"):
|
|
43
|
+
if not args.run and args.kind == "command":
|
|
44
|
+
raise ConfigError("`rung add command` needs --run")
|
|
45
|
+
if args.run:
|
|
46
|
+
options["run"] = args.run
|
|
47
|
+
if args.cwd:
|
|
48
|
+
options["cwd"] = args.cwd
|
|
49
|
+
if args.require:
|
|
50
|
+
options["require"] = args.require
|
|
51
|
+
elif args.kind == "scan":
|
|
52
|
+
if not (args.tool or args.run):
|
|
53
|
+
raise ConfigError(f"`rung add scan` needs --tool (one of {', '.join(PRESETS)}) or --run")
|
|
54
|
+
if args.tool:
|
|
55
|
+
options["tool"] = args.tool
|
|
56
|
+
if args.run:
|
|
57
|
+
options["run"] = args.run
|
|
58
|
+
if args.cwd:
|
|
59
|
+
options["cwd"] = args.cwd
|
|
60
|
+
if args.fail_on:
|
|
61
|
+
options["fail_on"] = args.fail_on
|
|
62
|
+
elif args.kind == "deps":
|
|
63
|
+
options["min_age_days"] = args.min_age_days
|
|
64
|
+
add_check(cfg, Check(args.id, args.kind, args.covers or [], options), out)
|
|
65
|
+
return OK
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def cmd_baseline(args, out: Output) -> int:
|
|
69
|
+
cfg = load_config(args)
|
|
70
|
+
check = cfg.check(args.check)
|
|
71
|
+
if check.kind not in ("scan", "shortcuts"):
|
|
72
|
+
raise ConfigError("baselines apply to `scan` and `shortcuts` checks")
|
|
73
|
+
checker = registry.get(check.kind)
|
|
74
|
+
assert isinstance(checker, ScanChecker)
|
|
75
|
+
found = checker.collect(cfg.root, check, check.get_int("timeout", 900))
|
|
76
|
+
path = findings.save_baseline(cfg.root, check.id, found, git.head(cfg.root))
|
|
77
|
+
out.emit({"check": check.id, "findings": len(found), "path": str(path.relative_to(cfg.root))})
|
|
78
|
+
out.line(f"Recorded {len(found)} existing finding(s) for {check.id!r} as accepted ({path.relative_to(cfg.root)}).")
|
|
79
|
+
out.line("Commit this file; from now on only findings beyond it will block.")
|
|
80
|
+
return OK
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def cmd_waive(args, out: Output) -> int:
|
|
84
|
+
cfg = load_config(args)
|
|
85
|
+
check = cfg.check(args.check)
|
|
86
|
+
if not re.fullmatch(r"[0-9a-f]{16}", args.fingerprint):
|
|
87
|
+
raise ConfigError("a fingerprint is the 16-character hex id shown in brackets by `rung check`")
|
|
88
|
+
try:
|
|
89
|
+
expires = date.fromisoformat(args.until) if args.until else date.today() + timedelta(days=args.days)
|
|
90
|
+
except ValueError as exc:
|
|
91
|
+
raise ConfigError(f"--until must be a date like 2026-12-31, not {args.until!r}") from exc
|
|
92
|
+
if expires < date.today():
|
|
93
|
+
raise ConfigError("a waiver must expire in the future")
|
|
94
|
+
approver = args.approved_by or git.user_name(cfg.root)
|
|
95
|
+
if not approver:
|
|
96
|
+
raise ConfigError("cannot tell who approves this waiver; pass --approved-by")
|
|
97
|
+
w = findings.Waiver(check.id, args.fingerprint, args.reason, approver, expires)
|
|
98
|
+
path = findings.add_waiver(cfg.root, w)
|
|
99
|
+
out.emit({"check": w.check, "fingerprint": w.fingerprint, "expires": str(w.expires), "approved_by": approver})
|
|
100
|
+
out.line(f"Waived {w.fingerprint} on {w.check!r} until {w.expires} ({path.relative_to(cfg.root)}).")
|
|
101
|
+
return OK
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def cmd_attest(args, out: Output) -> int:
|
|
105
|
+
cfg = load_config(args)
|
|
106
|
+
result = runner.attest(cfg, cfg.check(args.check), [Path(f) for f in args.files], by=args.by, note=args.note or "")
|
|
107
|
+
out.emit({"check": result.check.id, "verdict": result.verdict, "summary": result.outcome.summary,
|
|
108
|
+
"evidence": list(result.outcome.evidence)}) # fmt: skip
|
|
109
|
+
out.line(f"{out.badge('pass', VERDICT_STYLE, 10)} {result.check.id}: {result.outcome.summary}")
|
|
110
|
+
for line in result.outcome.lines:
|
|
111
|
+
out.detail(line, indent=11)
|
|
112
|
+
return OK
|
rung/cli/commands/run.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Commands that run and report checks: check, status, report, prove, ledger."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from rung.analysis import report
|
|
8
|
+
from rung.checks import prove, runner
|
|
9
|
+
from rung.cli.commands.common import GATE_FAILED, OK, load_config, resolve_base
|
|
10
|
+
from rung.cli.output import STATE_STYLE, VERDICT_STYLE, Output
|
|
11
|
+
from rung.core import git, ledger
|
|
12
|
+
from rung.core.config import CONFIG_NAME, find_root
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _print_result(result: runner.Result, out: Output) -> None:
|
|
16
|
+
o = result.outcome
|
|
17
|
+
out.line(f"{out.badge(o.verdict, VERDICT_STYLE, 6)}{result.check.id} ({result.check.kind}): {o.summary}")
|
|
18
|
+
for line in o.lines:
|
|
19
|
+
out.detail(line)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def cmd_check(args, out: Output) -> int:
|
|
23
|
+
cfg = load_config(args)
|
|
24
|
+
base: str | None = resolve_base(cfg.root, args.base) if args.changed or args.base else None
|
|
25
|
+
changed = git.changed_files(cfg.root, base) if args.changed and base else None
|
|
26
|
+
selected = runner.select(cfg, args.ids, changed)
|
|
27
|
+
if args.ci:
|
|
28
|
+
# Reuse a verdict only if it is fresh and was made against the same comparison base.
|
|
29
|
+
view = ledger.View(cfg.root)
|
|
30
|
+
selected = [c for c in selected if not runner.status(cfg, c, base=base or "", view=view).trusted]
|
|
31
|
+
if not cfg.checks:
|
|
32
|
+
out.error(f"no checks declared in {CONFIG_NAME}; see `rung plan` or `rung add`")
|
|
33
|
+
return GATE_FAILED
|
|
34
|
+
results = [runner.run_check(cfg, c, base=base) for c in selected]
|
|
35
|
+
out.emit({
|
|
36
|
+
"base": base,
|
|
37
|
+
"results": [{"id": r.check.id, "kind": r.check.kind, "verdict": r.verdict, "summary": r.outcome.summary,
|
|
38
|
+
"lines": r.outcome.lines, "findings": r.outcome.findings} for r in results],
|
|
39
|
+
}) # fmt: skip
|
|
40
|
+
if not results:
|
|
41
|
+
out.line("Nothing to run: every selected check is fresh or untouched by this change.")
|
|
42
|
+
for r in results:
|
|
43
|
+
_print_result(r, out)
|
|
44
|
+
return GATE_FAILED if any(r.verdict != "pass" for r in results) else OK
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def cmd_status(args, out: Output) -> int:
|
|
48
|
+
cfg = load_config(args)
|
|
49
|
+
rows = runner.statuses(cfg)
|
|
50
|
+
problems = ledger.problems(cfg.root)
|
|
51
|
+
out.emit({
|
|
52
|
+
"checks": [{"id": s.check_id, "state": s.state, "at": (s.entry or {}).get("at"), "changed": s.changed}
|
|
53
|
+
for s in rows],
|
|
54
|
+
"ledger_problems": problems,
|
|
55
|
+
}) # fmt: skip
|
|
56
|
+
for s in rows:
|
|
57
|
+
when = (s.entry or {}).get("at", "")
|
|
58
|
+
out.line(f"{out.badge(s.state, STATE_STYLE, 10)} {s.check_id:28} {out.style(when, 'dim')}")
|
|
59
|
+
for path in s.changed:
|
|
60
|
+
out.detail(f"changed: {path}", indent=11)
|
|
61
|
+
for p in problems:
|
|
62
|
+
out.line(out.style(f"CORRUPT {p}", "red"))
|
|
63
|
+
if args.ci:
|
|
64
|
+
return OK if rows and all(s.trusted for s in rows) and not problems else GATE_FAILED
|
|
65
|
+
return OK
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def cmd_report(args, out: Output) -> int:
|
|
69
|
+
cfg = load_config(args)
|
|
70
|
+
data = report.build(cfg)
|
|
71
|
+
if out.json_mode or args.format == "json":
|
|
72
|
+
out.json_mode = True
|
|
73
|
+
out.emit(data)
|
|
74
|
+
else:
|
|
75
|
+
print(report.markdown(data))
|
|
76
|
+
return OK
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def cmd_prove(args, out: Output) -> int:
|
|
80
|
+
root = find_root(Path(args.dir))
|
|
81
|
+
base = args.base or git.default_base(root)
|
|
82
|
+
result = prove.prove(root, args.test_command, base, tests=args.tests or None, expect=args.expect, cwd=args.cwd)
|
|
83
|
+
out.emit({
|
|
84
|
+
"verdict": result.verdict, "summary": result.summary, "tests": result.tests,
|
|
85
|
+
"base": result.base.__dict__ if result.base else None, "head": result.head.__dict__ if result.head else None,
|
|
86
|
+
}) # fmt: skip
|
|
87
|
+
out.line(f"{out.badge(result.verdict, VERDICT_STYLE, 6)}{result.summary}")
|
|
88
|
+
for t in result.tests:
|
|
89
|
+
out.detail(f"test: {t}")
|
|
90
|
+
if result.base and result.verdict != "pass":
|
|
91
|
+
out.detail(f"merge base run (exit {result.base.returncode}):")
|
|
92
|
+
for line in result.base.tail[-12:]:
|
|
93
|
+
out.detail(line, indent=8)
|
|
94
|
+
return OK if result.verdict == "pass" else GATE_FAILED
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def cmd_ledger(args, out: Output) -> int:
|
|
98
|
+
"""Check every verdict file; with --prune, remove verdicts for checks no longer declared."""
|
|
99
|
+
cfg = load_config(args)
|
|
100
|
+
verdicts = ledger.read_all(cfg.root)
|
|
101
|
+
problems = ledger.problems(cfg.root)
|
|
102
|
+
declared = {c.id for c in cfg.checks}
|
|
103
|
+
orphans = [k for k, v in verdicts.items() if k not in declared and v.get("kind") != "proof" and "invalid" not in v]
|
|
104
|
+
if args.prune:
|
|
105
|
+
for check_id in orphans:
|
|
106
|
+
ledger.verdict_path(cfg.root, check_id).unlink(missing_ok=True)
|
|
107
|
+
out.emit({"verdicts": len(verdicts), "problems": problems, "orphans": orphans, "pruned": bool(args.prune)})
|
|
108
|
+
out.line(f"{len(verdicts)} verdict file(s); {len(problems)} corrupt")
|
|
109
|
+
for p in problems:
|
|
110
|
+
out.detail(p, indent=2)
|
|
111
|
+
if orphans:
|
|
112
|
+
verb = "removed" if args.prune else "for checks no longer in rung.toml (remove with --prune)"
|
|
113
|
+
out.line(f"{len(orphans)} verdict(s) {verb}: {', '.join(orphans)}")
|
|
114
|
+
return GATE_FAILED if problems else OK
|