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/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
@@ -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)
@@ -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)