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/checks/prove.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""`rung prove`: show that a new test actually catches the bug it claims to.
|
|
2
|
+
|
|
3
|
+
A regression test that passes before the fix proves nothing; agents write such
|
|
4
|
+
tests all the time. The proof is mechanical: take the test files changed on this
|
|
5
|
+
branch, put them on top of the merge base (the code *without* the fix), and run
|
|
6
|
+
the test command there. It must fail. Then run it on the branch. It must pass.
|
|
7
|
+
Both runs are recorded in the ledger.
|
|
8
|
+
|
|
9
|
+
When the base fails for the wrong reason, typically an import error because the
|
|
10
|
+
fix added a new function, `--expect` names text the base failure must contain,
|
|
11
|
+
such as the assertion or the test name.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import re
|
|
17
|
+
import shlex
|
|
18
|
+
import shutil
|
|
19
|
+
from dataclasses import dataclass, field
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
from rung.core import git, ledger
|
|
23
|
+
from rung.core.errors import RungError
|
|
24
|
+
from rung.core.proc import run as run_process
|
|
25
|
+
from rung.core.redact import redact_lines
|
|
26
|
+
|
|
27
|
+
TEST_PATTERNS = [
|
|
28
|
+
"**/test_*.py", "**/*_test.py", "**/tests/**", "**/__tests__/**", "**/*.test.*",
|
|
29
|
+
"**/*.spec.*", "**/*_test.go", "**/spec/**", "**/*Test.java", "**/*Tests.cs",
|
|
30
|
+
] # fmt: skip
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class Run:
|
|
35
|
+
returncode: int
|
|
36
|
+
tail: list[str]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class Proof:
|
|
41
|
+
verdict: str # pass | fail | error
|
|
42
|
+
summary: str
|
|
43
|
+
tests: list[str] = field(default_factory=list)
|
|
44
|
+
base: Run | None = None
|
|
45
|
+
head: Run | None = None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def is_test_file(path: str) -> bool:
|
|
49
|
+
return any(git.glob_match(p, path) for p in TEST_PATTERNS)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _run(command: str, cwd: Path, timeout: int) -> Run:
|
|
53
|
+
result = run_process(shlex.split(command), cwd=cwd, timeout=timeout)
|
|
54
|
+
return Run(result.returncode, (result.stdout + result.stderr).strip().splitlines()[-25:])
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# Output that means the tests never reached an assertion on the base: they could not even load.
|
|
58
|
+
SETUP_FAILURE = re.compile(
|
|
59
|
+
r"ModuleNotFoundError|ImportError|SyntaxError|NameError: name .* is not defined|error collecting|"
|
|
60
|
+
r"ERROR collecting|errors? during collection|no tests ran|collected 0 items|Cannot find module|"
|
|
61
|
+
r"is not exported|has no exported member|TS2307|TS2305|undefined: |cannot find package",
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def prove(
|
|
66
|
+
root: Path, command: str, base_ref: str, *, tests: list[str] | None = None,
|
|
67
|
+
expect: str | None = None, timeout: int = 1800, cwd: str = ".",
|
|
68
|
+
) -> Proof: # fmt: skip
|
|
69
|
+
mb = git.merge_base(root, base_ref)
|
|
70
|
+
changed = git.changed_files(root, base_ref)
|
|
71
|
+
chosen = sorted(tests) if tests else sorted(f for f in changed if is_test_file(f))
|
|
72
|
+
if not chosen:
|
|
73
|
+
return Proof("error", "no changed test files on this branch; name them with --tests")
|
|
74
|
+
missing = [t for t in chosen if not (root / t).is_file()]
|
|
75
|
+
if missing:
|
|
76
|
+
raise RungError(f"test file(s) not found: {', '.join(missing)}")
|
|
77
|
+
sources = sorted(f for f in changed if not is_test_file(f) and (root / f).is_file())
|
|
78
|
+
before = ledger.snapshot(root, sources + chosen)
|
|
79
|
+
|
|
80
|
+
with git.worktree(root, mb) as tree:
|
|
81
|
+
for rel in chosen:
|
|
82
|
+
dest = tree / rel
|
|
83
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
84
|
+
shutil.copy2(root / rel, dest)
|
|
85
|
+
base_run = _run(command, tree / cwd, timeout)
|
|
86
|
+
head_run = _run(command, root / cwd, timeout)
|
|
87
|
+
|
|
88
|
+
proof = Proof("pass", "", chosen, base_run, head_run)
|
|
89
|
+
base_text = "\n".join(base_run.tail)
|
|
90
|
+
if base_run.returncode == 0:
|
|
91
|
+
proof.verdict, proof.summary = "fail", "the tests pass without the fix, so they do not catch the bug"
|
|
92
|
+
elif expect and expect not in base_text:
|
|
93
|
+
proof.verdict = "fail"
|
|
94
|
+
proof.summary = f"the base run failed, but not with {expect!r}; it may be failing for another reason"
|
|
95
|
+
elif not expect and SETUP_FAILURE.search(base_text):
|
|
96
|
+
proof.verdict = "fail"
|
|
97
|
+
proof.summary = (
|
|
98
|
+
"on the merge base the tests failed while loading (an import or collection error), not on an "
|
|
99
|
+
"assertion. If that failure is the bug, re-run with --expect '<text of the failing assertion>'"
|
|
100
|
+
)
|
|
101
|
+
elif head_run.returncode != 0:
|
|
102
|
+
proof.verdict, proof.summary = "fail", "the tests fail on this branch too"
|
|
103
|
+
else:
|
|
104
|
+
proof.summary = f"fails on merge base {mb[:10]}, passes on this branch"
|
|
105
|
+
if ledger.snapshot(root, sources + chosen) != before:
|
|
106
|
+
proof.verdict, proof.summary = "error", "files changed while the proof ran; re-run it"
|
|
107
|
+
|
|
108
|
+
slug = re.sub(r"[^a-z0-9]+", "-", command.lower()).strip("-")[:40] or "tests"
|
|
109
|
+
ledger.record(
|
|
110
|
+
root,
|
|
111
|
+
check_id=f"proof:{slug}",
|
|
112
|
+
kind="proof",
|
|
113
|
+
verdict=proof.verdict,
|
|
114
|
+
covers=before,
|
|
115
|
+
specs={},
|
|
116
|
+
summary=proof.summary,
|
|
117
|
+
context=mb,
|
|
118
|
+
details={
|
|
119
|
+
"command": command, "cwd": cwd, "base": mb, "tests": chosen, "expect": expect or "",
|
|
120
|
+
"files": sorted(before),
|
|
121
|
+
"base_returncode": base_run.returncode, "head_returncode": head_run.returncode,
|
|
122
|
+
"base_tail": redact_lines(base_run.tail), "head_tail": redact_lines(head_run.tail),
|
|
123
|
+
},
|
|
124
|
+
) # fmt: skip
|
|
125
|
+
return proof
|
rung/checks/registry.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Check kinds. Each is a `Checker`; `get(kind)` returns the one for a declared check."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from rung.checks.base import Checker, Context, Outcome
|
|
6
|
+
from rung.checks.command import CommandChecker, EvidenceChecker
|
|
7
|
+
from rung.checks.deps import DepsChecker
|
|
8
|
+
from rung.checks.formal import DRTChecker, LeanChecker, TLAChecker
|
|
9
|
+
from rung.checks.scan import ScanChecker
|
|
10
|
+
from rung.checks.shortcuts import ShortcutsChecker
|
|
11
|
+
from rung.core.config import ConfigError
|
|
12
|
+
|
|
13
|
+
REGISTRY: dict[str, Checker] = {
|
|
14
|
+
c.kind: c
|
|
15
|
+
for c in (
|
|
16
|
+
CommandChecker(),
|
|
17
|
+
EvidenceChecker(),
|
|
18
|
+
ScanChecker(),
|
|
19
|
+
ShortcutsChecker(),
|
|
20
|
+
DepsChecker(),
|
|
21
|
+
TLAChecker(),
|
|
22
|
+
LeanChecker(),
|
|
23
|
+
DRTChecker(),
|
|
24
|
+
)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def get(kind: str) -> Checker:
|
|
29
|
+
try:
|
|
30
|
+
return REGISTRY[kind]
|
|
31
|
+
except KeyError:
|
|
32
|
+
raise ConfigError(f"unknown check kind {kind!r}; choose from {', '.join(REGISTRY)}") from None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
__all__ = ["REGISTRY", "Checker", "Context", "Outcome", "get"]
|
rung/checks/runner.py
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""Select checks, run them through their checkers, and record verdicts in the ledger."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import shutil
|
|
6
|
+
import subprocess
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from rung.checks import registry
|
|
11
|
+
from rung.checks.base import Context, Outcome
|
|
12
|
+
from rung.checks.command import evidence_dir, is_attested, missing_required
|
|
13
|
+
from rung.core import git, ledger
|
|
14
|
+
from rung.core.config import Check, Config, ConfigError
|
|
15
|
+
from rung.core.errors import RungError
|
|
16
|
+
from rung.core.redact import redact, redact_lines
|
|
17
|
+
|
|
18
|
+
MAX_RECORDED_FINDINGS = 200
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class Result:
|
|
23
|
+
check: Check
|
|
24
|
+
outcome: Outcome
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
def verdict(self) -> str:
|
|
28
|
+
return self.outcome.verdict
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def covers(check: Check) -> list[str]:
|
|
32
|
+
return registry.get(check.kind).covers(check)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def specs(check: Check) -> list[str]:
|
|
36
|
+
return registry.get(check.kind).specs(check)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def definition(check: Check) -> str:
|
|
40
|
+
return ledger.definition_hash(check.kind, check.covers, check.options)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def touched_by(check: Check, changed: set[str]) -> bool:
|
|
44
|
+
"""Does a change to `changed` files affect this check? Checks covering nothing always run."""
|
|
45
|
+
patterns = covers(check) + specs(check)
|
|
46
|
+
if not patterns:
|
|
47
|
+
return True
|
|
48
|
+
return any(git.glob_match(p, f) for p in patterns for f in changed)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def select(config: Config, ids: list[str], changed: set[str] | None) -> list[Check]:
|
|
52
|
+
"""Checks to run: all (or the named ones), narrowed to those a change touches.
|
|
53
|
+
|
|
54
|
+
A check that is not currently fresh is always selected, so editing a check's own declaration,
|
|
55
|
+
a baseline or a waiver, or breaking the ledger, can never be skipped by `--changed`.
|
|
56
|
+
"""
|
|
57
|
+
chosen = [config.check(i) for i in ids] if ids else list(config.checks)
|
|
58
|
+
if changed is None:
|
|
59
|
+
return chosen
|
|
60
|
+
view = ledger.View(config.root)
|
|
61
|
+
return [c for c in chosen if touched_by(c, changed) or not status(config, c, view=view).trusted]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def run_check(config: Config, check: Check, *, base: str | None = None) -> Result:
|
|
65
|
+
root = config.root
|
|
66
|
+
if is_attested(check) and (held := status(config, check)).trusted and held.entry:
|
|
67
|
+
# Attested evidence cannot be re-run; while its files are unchanged, the attestation stands.
|
|
68
|
+
return Result(check, Outcome("pass", held.entry.get("summary", "attested"), held.entry.get("evidence", [])))
|
|
69
|
+
checker = registry.get(check.kind)
|
|
70
|
+
cover_patterns, spec_patterns = checker.covers(check), checker.specs(check)
|
|
71
|
+
before = (ledger.snapshot(root, cover_patterns), ledger.snapshot(root, spec_patterns))
|
|
72
|
+
try:
|
|
73
|
+
outcome = checker.run(Context(root=root, base=base), check)
|
|
74
|
+
except (RungError, FileNotFoundError, subprocess.TimeoutExpired) as exc:
|
|
75
|
+
outcome = Outcome("error", str(exc))
|
|
76
|
+
# Hash before and after: a file edited mid-run must not be recorded as verified.
|
|
77
|
+
after = (ledger.snapshot(root, cover_patterns), ledger.snapshot(root, spec_patterns))
|
|
78
|
+
if after != before:
|
|
79
|
+
moved = sorted(p for b, a in zip(before, after, strict=True) for p in set(b) | set(a) if b.get(p) != a.get(p))
|
|
80
|
+
hint = (
|
|
81
|
+
"files it covers changed while it ran: " + ", ".join(moved[:5]) + (" ..." if len(moved) > 5 else "")
|
|
82
|
+
+ ". If the check writes them itself (coverage, reports), add them to .gitignore or narrow `covers`."
|
|
83
|
+
) # fmt: skip
|
|
84
|
+
outcome = Outcome("error", hint, outcome.lines)
|
|
85
|
+
ledger.record(
|
|
86
|
+
root,
|
|
87
|
+
check_id=check.id,
|
|
88
|
+
kind=check.kind,
|
|
89
|
+
verdict=outcome.verdict,
|
|
90
|
+
covers=before[0],
|
|
91
|
+
specs=before[1],
|
|
92
|
+
summary=redact(outcome.summary),
|
|
93
|
+
evidence=[f"{path} sha256:{digest}" for path, digest in outcome.evidence.items()],
|
|
94
|
+
definition=definition(check),
|
|
95
|
+
context=base or "",
|
|
96
|
+
valid_until=outcome.valid_until,
|
|
97
|
+
details={
|
|
98
|
+
"lines": redact_lines(outcome.lines[:60]),
|
|
99
|
+
"base": base or "",
|
|
100
|
+
"findings": outcome.findings[:MAX_RECORDED_FINDINGS],
|
|
101
|
+
},
|
|
102
|
+
)
|
|
103
|
+
return Result(check, outcome)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def status(config: Config, check: Check, *, base: str | None = None, view: ledger.View | None = None) -> ledger.Status:
|
|
107
|
+
"""Whether `check`'s latest verdict still holds. With `base`, it must have been made against that base."""
|
|
108
|
+
view = view or ledger.View(config.root)
|
|
109
|
+
max_age = registry.get(check.kind).max_age_days(check)
|
|
110
|
+
return view.status(check.id, covers(check), specs(check), definition(check), base, max_age)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def statuses(config: Config, *, base: str | None = None) -> list[ledger.Status]:
|
|
114
|
+
view = ledger.View(config.root)
|
|
115
|
+
return [status(config, c, base=base, view=view) for c in config.checks]
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def attest(config: Config, check: Check, files: list[Path], *, by: str, note: str = "") -> Result:
|
|
119
|
+
"""Record evidence an agent or a person captured (a browser recording, screenshots) for an attested check.
|
|
120
|
+
|
|
121
|
+
The files are copied into `.rung/evidence/` and hashed into the verdict, which binds them to the
|
|
122
|
+
code as it is now. Nothing is re-run: the verdict says who recorded what, not that Rung saw it work.
|
|
123
|
+
"""
|
|
124
|
+
if not is_attested(check):
|
|
125
|
+
raise ConfigError(
|
|
126
|
+
f'check {check.id!r} is not an attested evidence check (kind = "evidence" without `run`); '
|
|
127
|
+
"scripted checks record their own evidence with `rung check`"
|
|
128
|
+
)
|
|
129
|
+
if not by.strip():
|
|
130
|
+
raise ConfigError("say which tool or person recorded the evidence with --by")
|
|
131
|
+
empty = [str(f) for f in files if not f.is_file() or f.stat().st_size == 0]
|
|
132
|
+
if empty:
|
|
133
|
+
raise ConfigError(f"not a non-empty file: {', '.join(empty)}")
|
|
134
|
+
if missing := missing_required(check, (f.name for f in files)):
|
|
135
|
+
raise ConfigError(f"check {check.id!r} requires evidence ending in {', '.join(missing)}")
|
|
136
|
+
root = config.root
|
|
137
|
+
cover_patterns, spec_patterns = covers(check), specs(check)
|
|
138
|
+
before = (ledger.snapshot(root, cover_patterns), ledger.snapshot(root, spec_patterns))
|
|
139
|
+
out_dir = evidence_dir(root, check.id)
|
|
140
|
+
artifacts: dict[str, str] = {}
|
|
141
|
+
for i, f in enumerate(files):
|
|
142
|
+
target = out_dir / f"{i + 1:02d}-{f.name}"
|
|
143
|
+
shutil.copyfile(f, target)
|
|
144
|
+
artifacts[target.relative_to(root).as_posix()] = ledger.file_digest(target)
|
|
145
|
+
if (ledger.snapshot(root, cover_patterns), ledger.snapshot(root, spec_patterns)) != before:
|
|
146
|
+
raise RungError("files the check covers changed while the evidence was being recorded; attest again")
|
|
147
|
+
summary = redact(f"attested by {by.strip()}: {len(artifacts)} artifact(s), not re-run by Rung")
|
|
148
|
+
lines = [f"evidence: {path}" for path in artifacts] + ([redact(f"note: {note}")] if note else [])
|
|
149
|
+
ledger.record(
|
|
150
|
+
root,
|
|
151
|
+
check_id=check.id,
|
|
152
|
+
kind=check.kind,
|
|
153
|
+
verdict="pass",
|
|
154
|
+
covers=before[0],
|
|
155
|
+
specs=before[1],
|
|
156
|
+
summary=summary,
|
|
157
|
+
evidence=[f"{path} sha256:{digest}" for path, digest in artifacts.items()],
|
|
158
|
+
definition=definition(check),
|
|
159
|
+
details={"lines": lines, "attested_by": by.strip(), "note": redact(note)},
|
|
160
|
+
)
|
|
161
|
+
return Result(check, Outcome("pass", summary, lines, artifacts))
|
rung/checks/scan.py
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""Scan checker: run any SARIF-emitting analyzer and gate on what a change introduces."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import shlex
|
|
7
|
+
import shutil
|
|
8
|
+
import tempfile
|
|
9
|
+
from datetime import date
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from rung.checks import findings
|
|
13
|
+
from rung.checks.base import Checker, Context, Outcome
|
|
14
|
+
from rung.checks.presets import PRESETS, Preset
|
|
15
|
+
from rung.core import git
|
|
16
|
+
from rung.core.config import Check, ConfigError
|
|
17
|
+
from rung.core.errors import RungError, ToolMissing
|
|
18
|
+
from rung.core.proc import run as run_process
|
|
19
|
+
from rung.engines import sarif
|
|
20
|
+
|
|
21
|
+
MAX_LISTED = 10
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def project_findings(root: Path, found: list[sarif.Finding]) -> list[sarif.Finding]:
|
|
25
|
+
"""Drop findings in Rung's own state and in files git ignores (caches, build output)."""
|
|
26
|
+
found = [f for f in found if not f.path.startswith(".rung/")]
|
|
27
|
+
if not found or not git.is_repo(root):
|
|
28
|
+
return found
|
|
29
|
+
files = set(git.list_files(root))
|
|
30
|
+
return [
|
|
31
|
+
f for f in found
|
|
32
|
+
if not f.path.startswith(".rung/") and (not f.path or f.path in files or not (root / f.path).is_file())
|
|
33
|
+
] # fmt: skip
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class ScanChecker(Checker):
|
|
37
|
+
kind = "scan"
|
|
38
|
+
summary = "Run a SARIF analyzer (secrets, dependencies, code patterns) and gate on new findings"
|
|
39
|
+
|
|
40
|
+
def _preset(self, check: Check) -> Preset | None:
|
|
41
|
+
name = check.get("tool")
|
|
42
|
+
if name is None:
|
|
43
|
+
return None
|
|
44
|
+
if name not in PRESETS:
|
|
45
|
+
raise ConfigError(f"check {check.id!r}: unknown scanner {name!r}; choose from {', '.join(PRESETS)}")
|
|
46
|
+
return PRESETS[name]
|
|
47
|
+
|
|
48
|
+
def _command(self, check: Check) -> str:
|
|
49
|
+
if run := check.get("run"):
|
|
50
|
+
return str(run)
|
|
51
|
+
preset = self._preset(check)
|
|
52
|
+
if preset is None:
|
|
53
|
+
raise ConfigError(f"check {check.id!r} (scan) needs `tool` (a preset) or `run`")
|
|
54
|
+
return preset.run
|
|
55
|
+
|
|
56
|
+
def fail_on(self, check: Check) -> str:
|
|
57
|
+
preset = self._preset(check)
|
|
58
|
+
level = str(check.get("fail_on", preset.fail_on if preset else "warning"))
|
|
59
|
+
if level not in sarif.LEVELS:
|
|
60
|
+
raise ConfigError(f"check {check.id!r}: fail_on must be one of {', '.join(sarif.LEVELS)}")
|
|
61
|
+
return level
|
|
62
|
+
|
|
63
|
+
def specs(self, check: Check) -> list[str]:
|
|
64
|
+
"""The policy a verdict depends on: this check's baseline and the waivers file."""
|
|
65
|
+
return [*check.get_list("specs"), findings.baseline_path_rel(check.id), findings.WAIVERS_FILE.as_posix()]
|
|
66
|
+
|
|
67
|
+
def max_age_days(self, check: Check) -> int | None:
|
|
68
|
+
if "max_age_days" in check.options:
|
|
69
|
+
return check.get_int("max_age_days", 0) or None
|
|
70
|
+
preset = self._preset(check)
|
|
71
|
+
return preset.max_age_days if preset else None
|
|
72
|
+
|
|
73
|
+
def default_covers(self, check: Check) -> list[str]:
|
|
74
|
+
preset = self._preset(check)
|
|
75
|
+
return list(preset.covers) if preset else ["**/*"]
|
|
76
|
+
|
|
77
|
+
def collect(self, cwd: Path, check: Check, timeout: int) -> list[sarif.Finding]:
|
|
78
|
+
"""Run the analyzer in `cwd` and parse its SARIF."""
|
|
79
|
+
command = self._command(check)
|
|
80
|
+
executable = shlex.split(command)[0]
|
|
81
|
+
if not shutil.which(executable):
|
|
82
|
+
preset = self._preset(check)
|
|
83
|
+
hint = f" Install: {preset.install}" if preset else ""
|
|
84
|
+
raise ToolMissing(f"`{executable}` is not installed.{hint}")
|
|
85
|
+
with tempfile.TemporaryDirectory(prefix="rung-scan-") as tmp:
|
|
86
|
+
out = Path(tmp) / "result.sarif"
|
|
87
|
+
argv = [part.replace("{out}", str(out)) for part in shlex.split(command)]
|
|
88
|
+
result = run_process(argv, cwd=cwd / str(check.get("cwd", ".")), timeout=timeout)
|
|
89
|
+
raw = out.read_text() if "{out}" in command and out.exists() else result.stdout
|
|
90
|
+
detail = (result.stderr or result.stdout).strip()[-800:]
|
|
91
|
+
preset = self._preset(check)
|
|
92
|
+
default = str(check.get("default_level", preset.default_level if preset else "warning"))
|
|
93
|
+
try:
|
|
94
|
+
found = sarif.parse(raw, cwd, default_level=default)
|
|
95
|
+
except (json.JSONDecodeError, sarif.SarifError, AttributeError, TypeError, KeyError) as exc:
|
|
96
|
+
raise RungError(
|
|
97
|
+
f"`{command}` exited {result.returncode} without a valid SARIF result ({exc}): {detail}"
|
|
98
|
+
) from exc
|
|
99
|
+
# Scanners exit non-zero when they find something. Non-zero with nothing found means it failed.
|
|
100
|
+
if result.returncode != 0 and not found:
|
|
101
|
+
raise RungError(
|
|
102
|
+
f"`{command}` exited {result.returncode} and reported no findings; it likely failed: {detail}"
|
|
103
|
+
)
|
|
104
|
+
return project_findings(cwd, found)
|
|
105
|
+
|
|
106
|
+
def run(self, ctx: Context, check: Check) -> Outcome:
|
|
107
|
+
timeout = check.get_int("timeout", 900)
|
|
108
|
+
head = self.collect(ctx.root, check, timeout)
|
|
109
|
+
|
|
110
|
+
if ctx.base:
|
|
111
|
+
with git.worktree(ctx.root, ctx.base) as tree:
|
|
112
|
+
base = self.collect(tree, check, timeout)
|
|
113
|
+
compared = f"merge base {ctx.base[:10]}"
|
|
114
|
+
else:
|
|
115
|
+
base = findings.load_baseline(ctx.root, check.id)
|
|
116
|
+
compared = "the recorded baseline" if base is not None else ""
|
|
117
|
+
|
|
118
|
+
t = findings.triage(
|
|
119
|
+
check.id,
|
|
120
|
+
head,
|
|
121
|
+
base,
|
|
122
|
+
findings.load_waivers(ctx.root),
|
|
123
|
+
fail_on=self.fail_on(check),
|
|
124
|
+
today=date.today(),
|
|
125
|
+
compared_with=compared,
|
|
126
|
+
)
|
|
127
|
+
lines = [
|
|
128
|
+
f"{len(head)} finding(s); compared with {t.compared_with}: "
|
|
129
|
+
f"{len(t.blocking)} new blocking, {len(t.nonblocking)} new below `{self.fail_on(check)}`, "
|
|
130
|
+
f"{len(t.waived)} waived, {len(t.resolved)} resolved"
|
|
131
|
+
]
|
|
132
|
+
for f in t.blocking[:MAX_LISTED]:
|
|
133
|
+
lines.append(f"NEW {f.level:7} {f.rule} {f.where} [{f.fingerprint}]")
|
|
134
|
+
if f.message:
|
|
135
|
+
lines.append(f" {f.message[:160]}")
|
|
136
|
+
if len(t.blocking) > MAX_LISTED:
|
|
137
|
+
lines.append(f"... and {len(t.blocking) - MAX_LISTED} more (`rung report` or `--json` lists all)")
|
|
138
|
+
if not compared and t.blocking:
|
|
139
|
+
lines.append(
|
|
140
|
+
f"Existing code? `rung baseline {check.id}` accepts today's findings, or "
|
|
141
|
+
"`rung check --changed` compares with the merge base. Either way only new findings block."
|
|
142
|
+
)
|
|
143
|
+
for w in t.expired:
|
|
144
|
+
lines.append(f"EXPIRED waiver {w.fingerprint} ({w.reason}), expired {w.expires}")
|
|
145
|
+
|
|
146
|
+
records = [f.to_dict() | {"status": "new"} for f in t.blocking]
|
|
147
|
+
records += [f.to_dict() | {"status": "new-below-threshold"} for f in t.nonblocking]
|
|
148
|
+
records += [f.to_dict() | {"status": "waived"} for f in t.waived]
|
|
149
|
+
if t.blocking:
|
|
150
|
+
return Outcome("fail", f"{len(t.blocking)} new finding(s) at or above `{self.fail_on(check)}`",
|
|
151
|
+
lines, findings=records) # fmt: skip
|
|
152
|
+
return Outcome("pass", "no new blocking findings", lines, findings=records, valid_until=t.valid_until)
|
rung/checks/shortcuts.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Shortcuts checker: block the type-checker silencers, skipped tests and relaxed configs a change adds.
|
|
2
|
+
|
|
3
|
+
It reuses the scan machinery (fingerprints, baselines, waivers), so it gates the same way: only what
|
|
4
|
+
a change introduces blocks. Without `--base` it compares with the recorded baseline if there is one,
|
|
5
|
+
otherwise with the merge base of the default branch, so existing shortcuts never block day one.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from datetime import date
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from rung.checks import findings
|
|
14
|
+
from rung.checks.base import Context, Outcome
|
|
15
|
+
from rung.checks.scan import MAX_LISTED, ScanChecker
|
|
16
|
+
from rung.core import git
|
|
17
|
+
from rung.core.config import Check
|
|
18
|
+
from rung.engines import sarif
|
|
19
|
+
from rung.engines import shortcuts as engine
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _working_files(root: Path) -> list[tuple[str, str]]:
|
|
23
|
+
def read(rel: str) -> str | None:
|
|
24
|
+
try:
|
|
25
|
+
return (root / rel).read_text(errors="replace")
|
|
26
|
+
except OSError:
|
|
27
|
+
return None
|
|
28
|
+
|
|
29
|
+
return engine.read_all((f for f in git.list_files(root) if not git.is_rung_output(f)), read)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _files_at(root: Path, commit: str) -> list[tuple[str, str]]:
|
|
33
|
+
paths = [p for p in git.files_at(root, commit) if engine.scannable(p)]
|
|
34
|
+
blobs = git.show_many(root, commit, paths)
|
|
35
|
+
|
|
36
|
+
def read(rel: str) -> str | None:
|
|
37
|
+
try:
|
|
38
|
+
return blobs[rel].decode() if rel in blobs else None
|
|
39
|
+
except UnicodeDecodeError: # not text
|
|
40
|
+
return None
|
|
41
|
+
|
|
42
|
+
return engine.read_all(paths, read)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class ShortcutsChecker(ScanChecker):
|
|
46
|
+
kind = "shortcuts"
|
|
47
|
+
summary = "Block new type-checker silencers, skipped or deleted tests, and relaxed checker configs"
|
|
48
|
+
|
|
49
|
+
def _preset(self, check: Check) -> None:
|
|
50
|
+
return None
|
|
51
|
+
|
|
52
|
+
def max_age_days(self, check: Check) -> int | None:
|
|
53
|
+
return check.get_int("max_age_days", 0) or None
|
|
54
|
+
|
|
55
|
+
def default_covers(self, check: Check) -> list[str]:
|
|
56
|
+
return ["**/*"]
|
|
57
|
+
|
|
58
|
+
def collect(self, cwd: Path, check: Check, timeout: int) -> list[sarif.Finding]:
|
|
59
|
+
return engine.find(_working_files(cwd), check.get_list("allow"))
|
|
60
|
+
|
|
61
|
+
def _commit(self, ctx: Context) -> str:
|
|
62
|
+
"""The commit deleted tests and weakened settings are compared with: the base, else the default branch's."""
|
|
63
|
+
if ctx.base:
|
|
64
|
+
return ctx.base
|
|
65
|
+
try:
|
|
66
|
+
return git.merge_base(ctx.root, git.default_base(ctx.root))
|
|
67
|
+
except git.GitError:
|
|
68
|
+
return ""
|
|
69
|
+
|
|
70
|
+
def run(self, ctx: Context, check: Check) -> Outcome:
|
|
71
|
+
allow = check.get_list("allow")
|
|
72
|
+
head_files = _working_files(ctx.root)
|
|
73
|
+
head = engine.find(head_files, allow)
|
|
74
|
+
commit = self._commit(ctx)
|
|
75
|
+
base_files = _files_at(ctx.root, commit) if commit else []
|
|
76
|
+
# Findings are compared with the base, or with an accepted baseline when one is recorded; the
|
|
77
|
+
# structural comparisons (deleted tests, coverage, strictness) always use the base commit.
|
|
78
|
+
recorded = None if ctx.base else findings.load_baseline(ctx.root, check.id)
|
|
79
|
+
if recorded is not None:
|
|
80
|
+
base, compared = recorded, "the recorded baseline"
|
|
81
|
+
elif commit:
|
|
82
|
+
base, compared = engine.find(base_files, allow), f"merge base {commit[:10]}"
|
|
83
|
+
else:
|
|
84
|
+
base, compared = None, ""
|
|
85
|
+
if commit:
|
|
86
|
+
head += engine.compare(head_files, base_files, allow)
|
|
87
|
+
t = findings.triage(check.id, head, base, findings.load_waivers(ctx.root),
|
|
88
|
+
fail_on=self.fail_on(check), today=date.today(), compared_with=compared) # fmt: skip
|
|
89
|
+
lines = [
|
|
90
|
+
f"{len(head)} shortcut(s) in the code; compared with {t.compared_with}: {len(t.blocking)} new blocking, "
|
|
91
|
+
f"{len(t.waived)} waived, {len(t.resolved)} removed"
|
|
92
|
+
]
|
|
93
|
+
for f in t.blocking[:MAX_LISTED]:
|
|
94
|
+
lines.append(f"NEW {f.rule:17} {f.where or '(project)'} [{f.fingerprint}]")
|
|
95
|
+
lines.append(f" {f.message[:160]}")
|
|
96
|
+
if len(t.blocking) > MAX_LISTED:
|
|
97
|
+
lines.append(f"... and {len(t.blocking) - MAX_LISTED} more (`rung report` or `--json` lists all)")
|
|
98
|
+
if t.blocking:
|
|
99
|
+
lines.append(
|
|
100
|
+
"Fix the code instead. If one is genuinely needed, a person can accept it: "
|
|
101
|
+
f"`rung waive {check.id} <fingerprint> --reason ...`."
|
|
102
|
+
)
|
|
103
|
+
for w in t.expired:
|
|
104
|
+
lines.append(f"EXPIRED waiver {w.fingerprint} ({w.reason}), expired {w.expires}")
|
|
105
|
+
records = [f.to_dict() | {"status": "new"} for f in t.blocking]
|
|
106
|
+
records += [f.to_dict() | {"status": "waived"} for f in t.waived]
|
|
107
|
+
if t.blocking:
|
|
108
|
+
return Outcome("fail", f"{len(t.blocking)} new shortcut(s)", lines, findings=records)
|
|
109
|
+
return Outcome("pass", "no new shortcuts", lines, findings=records, valid_until=t.valid_until)
|