grudge-cli 0.1.0__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.
- examples/__init__.py +1 -0
- examples/leaky_agent/__init__.py +1 -0
- examples/leaky_agent/agent.py +250 -0
- examples/leaky_agent/agent_fixed.py +183 -0
- examples/leaky_agent/tools.py +153 -0
- grudge/__init__.py +5 -0
- grudge/__main__.py +6 -0
- grudge/_stubs.py +8 -0
- grudge/admission/__init__.py +6 -0
- grudge/admission/court.py +239 -0
- grudge/admission/verdict.py +26 -0
- grudge/cli/__init__.py +1 -0
- grudge/cli/_common.py +126 -0
- grudge/cli/admit.py +93 -0
- grudge/cli/capture.py +39 -0
- grudge/cli/compile.py +37 -0
- grudge/cli/demo.py +136 -0
- grudge/cli/main.py +54 -0
- grudge/cli/run.py +60 -0
- grudge/cli/scars.py +91 -0
- grudge/cli/shrink.py +108 -0
- grudge/config.py +35 -0
- grudge/demo/__init__.py +1 -0
- grudge/demo/events.py +228 -0
- grudge/demo/orchestrator.py +326 -0
- grudge/demo/server.py +222 -0
- grudge/errors.py +25 -0
- grudge/fuzzer/__init__.py +1 -0
- grudge/fuzzer/fingerprint_guard.py +10 -0
- grudge/fuzzer/mutators.py +10 -0
- grudge/gate/__init__.py +5 -0
- grudge/gate/report.py +125 -0
- grudge/gate/runner.py +323 -0
- grudge/gate/selector.py +10 -0
- grudge/gate/wilson.py +36 -0
- grudge/guard/__init__.py +5 -0
- grudge/guard/compile.py +251 -0
- grudge/guard/import_session.py +106 -0
- grudge/integrations/__init__.py +1 -0
- grudge/integrations/github_comment.py +10 -0
- grudge/integrations/pytest_plugin.py +10 -0
- grudge/llm/__init__.py +6 -0
- grudge/llm/cache.py +92 -0
- grudge/llm/client.py +82 -0
- grudge/locator/__init__.py +1 -0
- grudge/locator/fork.py +10 -0
- grudge/locator/point_of_no_return.py +10 -0
- grudge/predicates/__init__.py +25 -0
- grudge/predicates/call_shape.py +116 -0
- grudge/predicates/compiler.py +116 -0
- grudge/predicates/dsl.py +295 -0
- grudge/predicates/fallback_rules.py +10 -0
- grudge/predicates/ordering.py +33 -0
- grudge/predicates/outcome.py +14 -0
- grudge/predicates/prompts/synthesize.md +15 -0
- grudge/predicates/synthesizer.py +127 -0
- grudge/predicates/taint_flow.py +84 -0
- grudge/recorder/__init__.py +5 -0
- grudge/recorder/adapters/__init__.py +1 -0
- grudge/recorder/adapters/langgraph.py +8 -0
- grudge/recorder/adapters/mcp_proxy.py +8 -0
- grudge/recorder/adapters/raw_sdk.py +159 -0
- grudge/recorder/recorder.py +374 -0
- grudge/recorder/redact.py +105 -0
- grudge/registry/__init__.py +1 -0
- grudge/registry/db.py +10 -0
- grudge/registry/fingerprint.py +10 -0
- grudge/registry/flakiness.py +10 -0
- grudge/registry/retirement.py +10 -0
- grudge/replay/__init__.py +19 -0
- grudge/replay/cassette_player.py +159 -0
- grudge/replay/sandbox.py +340 -0
- grudge/replay/scripted_llm.py +382 -0
- grudge/schema/__init__.py +45 -0
- grudge/schema/cassette.py +206 -0
- grudge/schema/predicate.py +263 -0
- grudge/schema/scar.py +113 -0
- grudge/schema/trace.py +243 -0
- grudge/shrinker/__init__.py +12 -0
- grudge/shrinker/cassette_builder.py +328 -0
- grudge/shrinker/ddmin.py +243 -0
- grudge/shrinker/oracle.py +230 -0
- grudge/shrinker/reducers.py +331 -0
- grudge_cli-0.1.0.dist-info/LICENSE +21 -0
- grudge_cli-0.1.0.dist-info/METADATA +313 -0
- grudge_cli-0.1.0.dist-info/RECORD +89 -0
- grudge_cli-0.1.0.dist-info/WHEEL +5 -0
- grudge_cli-0.1.0.dist-info/entry_points.txt +2 -0
- grudge_cli-0.1.0.dist-info/top_level.txt +2 -0
grudge/demo/server.py
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"""Dependency-free local server for the Phase 10 event stream and viewer."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import mimetypes
|
|
7
|
+
import queue
|
|
8
|
+
import threading
|
|
9
|
+
from collections.abc import Callable
|
|
10
|
+
from http import HTTPStatus
|
|
11
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from urllib.parse import unquote, urlparse
|
|
14
|
+
|
|
15
|
+
from grudge.demo.events import DemoEvent, EventBus
|
|
16
|
+
|
|
17
|
+
ROOT = Path(__file__).resolve().parents[2]
|
|
18
|
+
VIEWER_DIR = ROOT / "viewer"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class _DemoHTTPServer(ThreadingHTTPServer):
|
|
22
|
+
"""HTTP server carrying the bus and orchestration callback."""
|
|
23
|
+
|
|
24
|
+
daemon_threads = True
|
|
25
|
+
allow_reuse_address = True
|
|
26
|
+
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
address: tuple[str, int],
|
|
30
|
+
bus: EventBus,
|
|
31
|
+
viewer_dir: Path,
|
|
32
|
+
runner: Callable[[], object] | None,
|
|
33
|
+
) -> None:
|
|
34
|
+
self.bus = bus
|
|
35
|
+
self.viewer_dir = viewer_dir.resolve()
|
|
36
|
+
self.runner = runner
|
|
37
|
+
self._run_thread: threading.Thread | None = None
|
|
38
|
+
super().__init__(address, _DemoHandler)
|
|
39
|
+
|
|
40
|
+
def restart(self) -> None:
|
|
41
|
+
"""Clear the replay buffer and run the configured demo in the background."""
|
|
42
|
+
|
|
43
|
+
self.bus.clear()
|
|
44
|
+
if self.runner is None:
|
|
45
|
+
return
|
|
46
|
+
self._run_thread = threading.Thread(target=self.runner, daemon=True)
|
|
47
|
+
self._run_thread.start()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class _DemoHandler(BaseHTTPRequestHandler):
|
|
51
|
+
"""Serve static viewer files and the bus's SSE/JSON endpoints."""
|
|
52
|
+
|
|
53
|
+
server: _DemoHTTPServer
|
|
54
|
+
protocol_version = "HTTP/1.1"
|
|
55
|
+
|
|
56
|
+
def _json(self, payload: object, status: HTTPStatus = HTTPStatus.OK) -> None:
|
|
57
|
+
body = json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode()
|
|
58
|
+
self.send_response(status)
|
|
59
|
+
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
60
|
+
self.send_header("Content-Length", str(len(body)))
|
|
61
|
+
self.send_header("Cache-Control", "no-store")
|
|
62
|
+
self.end_headers()
|
|
63
|
+
self.wfile.write(body)
|
|
64
|
+
|
|
65
|
+
def do_GET(self) -> None:
|
|
66
|
+
path = urlparse(self.path).path
|
|
67
|
+
if path == "/state":
|
|
68
|
+
self._json(
|
|
69
|
+
{"events": [_payload(event) for event in self.server.bus.snapshot()]}
|
|
70
|
+
)
|
|
71
|
+
return
|
|
72
|
+
if path == "/events":
|
|
73
|
+
self._events()
|
|
74
|
+
return
|
|
75
|
+
self._static(path)
|
|
76
|
+
|
|
77
|
+
def do_POST(self) -> None:
|
|
78
|
+
if urlparse(self.path).path != "/restart":
|
|
79
|
+
self._json({"error": "not found"}, HTTPStatus.NOT_FOUND)
|
|
80
|
+
return
|
|
81
|
+
self.server.restart()
|
|
82
|
+
self._json({"restarted": True}, HTTPStatus.ACCEPTED)
|
|
83
|
+
|
|
84
|
+
def _events(self) -> None:
|
|
85
|
+
pending: queue.Queue[DemoEvent] = queue.Queue()
|
|
86
|
+
unsubscribe = self.server.bus.subscribe(pending.put)
|
|
87
|
+
history = self.server.bus.snapshot()
|
|
88
|
+
sent = {event.seq for event in history}
|
|
89
|
+
self.send_response(HTTPStatus.OK)
|
|
90
|
+
self.send_header("Content-Type", "text/event-stream; charset=utf-8")
|
|
91
|
+
self.send_header("Cache-Control", "no-cache")
|
|
92
|
+
self.send_header("Connection", "close")
|
|
93
|
+
self.end_headers()
|
|
94
|
+
try:
|
|
95
|
+
for event in history:
|
|
96
|
+
self._write_event(event)
|
|
97
|
+
self.wfile.flush()
|
|
98
|
+
# A completed replay is finite and useful to curl/test clients;
|
|
99
|
+
# a running stream remains open for the next event.
|
|
100
|
+
if history and history[-1].type == "demo.finished":
|
|
101
|
+
return
|
|
102
|
+
while True:
|
|
103
|
+
try:
|
|
104
|
+
event = pending.get(timeout=0.5)
|
|
105
|
+
except queue.Empty:
|
|
106
|
+
continue
|
|
107
|
+
if event.seq in sent:
|
|
108
|
+
continue
|
|
109
|
+
sent.add(event.seq)
|
|
110
|
+
self._write_event(event)
|
|
111
|
+
self.wfile.flush()
|
|
112
|
+
if event.type == "demo.finished":
|
|
113
|
+
return
|
|
114
|
+
except (
|
|
115
|
+
BrokenPipeError,
|
|
116
|
+
ConnectionAbortedError,
|
|
117
|
+
ConnectionResetError,
|
|
118
|
+
TimeoutError,
|
|
119
|
+
):
|
|
120
|
+
return
|
|
121
|
+
finally:
|
|
122
|
+
unsubscribe()
|
|
123
|
+
|
|
124
|
+
def _write_event(self, event: DemoEvent) -> None:
|
|
125
|
+
payload = json.dumps(_payload(event), ensure_ascii=False, separators=(",", ":"))
|
|
126
|
+
self.wfile.write(f"event: {event.type}\ndata: {payload}\n\n".encode())
|
|
127
|
+
|
|
128
|
+
def _static(self, path: str) -> None:
|
|
129
|
+
relative = unquote(path.lstrip("/")) or "index.html"
|
|
130
|
+
candidate = (self.server.viewer_dir / relative).resolve()
|
|
131
|
+
if not candidate.is_file() or not candidate.is_relative_to(
|
|
132
|
+
self.server.viewer_dir
|
|
133
|
+
):
|
|
134
|
+
self._json({"error": "not found"}, HTTPStatus.NOT_FOUND)
|
|
135
|
+
return
|
|
136
|
+
body = candidate.read_bytes()
|
|
137
|
+
content_type = mimetypes.guess_type(candidate.name)[0] or (
|
|
138
|
+
"application/octet-stream"
|
|
139
|
+
)
|
|
140
|
+
self.send_response(HTTPStatus.OK)
|
|
141
|
+
self.send_header("Content-Type", content_type)
|
|
142
|
+
self.send_header("Content-Length", str(len(body)))
|
|
143
|
+
self.end_headers()
|
|
144
|
+
self.wfile.write(body)
|
|
145
|
+
|
|
146
|
+
def log_message(self, format: str, *args: object) -> None:
|
|
147
|
+
return
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _payload(event: DemoEvent) -> dict[str, object]:
|
|
151
|
+
return event.model_dump(mode="json")
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
class DemoServer:
|
|
155
|
+
"""Lifecycle wrapper around the local HTTP server."""
|
|
156
|
+
|
|
157
|
+
def __init__(
|
|
158
|
+
self,
|
|
159
|
+
*,
|
|
160
|
+
host: str = "127.0.0.1",
|
|
161
|
+
port: int = 8765,
|
|
162
|
+
bus: EventBus | None = None,
|
|
163
|
+
viewer_dir: Path | str = VIEWER_DIR,
|
|
164
|
+
runner: Callable[[], object] | None = None,
|
|
165
|
+
) -> None:
|
|
166
|
+
self.bus = bus or EventBus()
|
|
167
|
+
self.httpd = _DemoHTTPServer((host, port), self.bus, Path(viewer_dir), runner)
|
|
168
|
+
self.thread: threading.Thread | None = None
|
|
169
|
+
|
|
170
|
+
@property
|
|
171
|
+
def address(self) -> tuple[str, int]:
|
|
172
|
+
"""Return the bound host and port, including an ephemeral port."""
|
|
173
|
+
|
|
174
|
+
host, port = self.httpd.server_address[:2]
|
|
175
|
+
return str(host), int(port)
|
|
176
|
+
|
|
177
|
+
@property
|
|
178
|
+
def server_address(self) -> tuple[str, int]:
|
|
179
|
+
"""Compatibility alias matching :class:`HTTPServer`."""
|
|
180
|
+
|
|
181
|
+
return self.address
|
|
182
|
+
|
|
183
|
+
def start(self, *, run_demo: bool = False) -> DemoServer:
|
|
184
|
+
"""Start serving in a daemon thread and optionally begin the demo."""
|
|
185
|
+
|
|
186
|
+
self.thread = threading.Thread(target=self.httpd.serve_forever, daemon=True)
|
|
187
|
+
self.thread.start()
|
|
188
|
+
if run_demo:
|
|
189
|
+
self.httpd.restart()
|
|
190
|
+
return self
|
|
191
|
+
|
|
192
|
+
def restart(self) -> None:
|
|
193
|
+
self.httpd.restart()
|
|
194
|
+
|
|
195
|
+
def close(self) -> None:
|
|
196
|
+
"""Stop the server and release its listening socket."""
|
|
197
|
+
|
|
198
|
+
self.httpd.shutdown()
|
|
199
|
+
self.httpd.server_close()
|
|
200
|
+
|
|
201
|
+
shutdown = close
|
|
202
|
+
|
|
203
|
+
def __enter__(self) -> DemoServer:
|
|
204
|
+
return self.start()
|
|
205
|
+
|
|
206
|
+
def __exit__(self, *_args: object) -> None:
|
|
207
|
+
self.close()
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def serve_demo(
|
|
211
|
+
*,
|
|
212
|
+
port: int = 8765,
|
|
213
|
+
host: str = "127.0.0.1",
|
|
214
|
+
bus: EventBus | None = None,
|
|
215
|
+
runner: Callable[[], object] | None = None,
|
|
216
|
+
) -> DemoServer:
|
|
217
|
+
"""Start a local viewer server and return its lifecycle handle."""
|
|
218
|
+
|
|
219
|
+
return DemoServer(host=host, port=port, bus=bus, runner=runner).start()
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
__all__ = ["DemoServer", "serve_demo"]
|
grudge/errors.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Exception hierarchy for errors crossing Grudge pipeline boundaries."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class GrudgeError(Exception):
|
|
5
|
+
"""Base class for expected Grudge failures."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class SchemaError(GrudgeError):
|
|
9
|
+
"""A trace, cassette, predicate, or scar does not match its contract."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class OracleError(GrudgeError):
|
|
13
|
+
"""The failure-reproduction oracle could not evaluate a candidate."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class PredicateParseError(GrudgeError):
|
|
17
|
+
"""A predicate source string cannot be parsed."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class AdmissionRejected(GrudgeError):
|
|
21
|
+
"""A candidate scar did not prove red-on-bad and green-on-good."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ReplayMismatch(GrudgeError):
|
|
25
|
+
"""An agent requested a response absent from a cassette."""
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Variant fuzzer package; both fuzzer modules remain roadmap stubs."""
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Failure fingerprint guard; this Phase 13 stretch item is a stub."""
|
|
2
|
+
|
|
3
|
+
from typing import NoReturn
|
|
4
|
+
|
|
5
|
+
from grudge._stubs import roadmap
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def preserve_fingerprint() -> NoReturn:
|
|
9
|
+
"""Validate a variant when the fuzzer stretch stage is implemented."""
|
|
10
|
+
roadmap("fuzzer.fingerprint_guard")
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Cassette mutation boundary; this Phase 13 stretch item is a stub."""
|
|
2
|
+
|
|
3
|
+
from typing import NoReturn
|
|
4
|
+
|
|
5
|
+
from grudge._stubs import roadmap
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def mutate() -> NoReturn:
|
|
9
|
+
"""Generate variants when the fuzzer stretch stage is implemented."""
|
|
10
|
+
roadmap("fuzzer.mutators")
|
grudge/gate/__init__.py
ADDED
grudge/gate/report.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""Deterministic terminal and pull-request reports for gate results."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from rich.console import Console
|
|
6
|
+
from rich.panel import Panel
|
|
7
|
+
from rich.table import Table
|
|
8
|
+
|
|
9
|
+
from grudge.gate.runner import GateResult
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _clean(value: object) -> str:
|
|
13
|
+
return " ".join(str(value).replace("|", "\\|").splitlines())
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def status_text(result: GateResult) -> str:
|
|
17
|
+
"""Return the stable status label shared by terminal and markdown output."""
|
|
18
|
+
|
|
19
|
+
return "PASS" if result.passed else "BLOCKED"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def render_terminal(result: GateResult, console: Console | None = None) -> str:
|
|
23
|
+
"""Render a Rich report and return captured text when no console is supplied."""
|
|
24
|
+
|
|
25
|
+
owns_console = console is None
|
|
26
|
+
target = console or Console(record=True, width=120, color_system=None)
|
|
27
|
+
colour = "green" if result.passed else "red"
|
|
28
|
+
target.print(
|
|
29
|
+
Panel(
|
|
30
|
+
f"[bold {colour}]{status_text(result)}[/bold {colour}] "
|
|
31
|
+
f"Scar: {result.scar_id}",
|
|
32
|
+
title="Grudge gate",
|
|
33
|
+
)
|
|
34
|
+
)
|
|
35
|
+
summary = Table(show_header=True, header_style="bold")
|
|
36
|
+
for column in ("Passes", "Runs", "Pass rate", "Wilson lower", "Threshold", "Mode"):
|
|
37
|
+
summary.add_column(column)
|
|
38
|
+
summary.add_row(
|
|
39
|
+
f"{result.successes}",
|
|
40
|
+
f"{result.runs}",
|
|
41
|
+
f"{result.pass_rate:.2%}",
|
|
42
|
+
f"{result.lower_bound:.4f}",
|
|
43
|
+
f"{result.threshold:.4f}",
|
|
44
|
+
"offline/deterministic" if result.offline else "deterministic",
|
|
45
|
+
)
|
|
46
|
+
target.print(summary)
|
|
47
|
+
for observation in result.observations:
|
|
48
|
+
target.print(f"• {observation}")
|
|
49
|
+
if result.reasons:
|
|
50
|
+
target.print("Reasons:")
|
|
51
|
+
for reason in result.reasons:
|
|
52
|
+
target.print(f" • {reason}")
|
|
53
|
+
if owns_console:
|
|
54
|
+
return target.export_text()
|
|
55
|
+
return ""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def render_markdown(result: GateResult) -> str:
|
|
59
|
+
"""Return a deterministic PR comment; this function performs no network I/O."""
|
|
60
|
+
|
|
61
|
+
status = status_text(result)
|
|
62
|
+
lines = [
|
|
63
|
+
f"## Grudge gate: **{status}**",
|
|
64
|
+
"",
|
|
65
|
+
f"- Scar: `{_clean(result.scar_id)}`",
|
|
66
|
+
f"- Gate runs: `{result.successes}/{result.runs}`",
|
|
67
|
+
f"- Pass rate: `{result.pass_rate:.4f}`",
|
|
68
|
+
f"- Wilson lower bound: `{result.lower_bound:.4f}`",
|
|
69
|
+
f"- Threshold: `{result.threshold:.4f}`",
|
|
70
|
+
f"- Mode: **{'offline/deterministic' if result.offline else 'deterministic'}**",
|
|
71
|
+
"",
|
|
72
|
+
"### Observations",
|
|
73
|
+
]
|
|
74
|
+
lines.extend(f"- {_clean(item)}" for item in result.observations)
|
|
75
|
+
if result.reasons:
|
|
76
|
+
lines.extend(["", "### Failed runs / reasons"])
|
|
77
|
+
lines.extend(f"- {_clean(item)}" for item in result.reasons)
|
|
78
|
+
lines.extend(
|
|
79
|
+
[
|
|
80
|
+
"",
|
|
81
|
+
"### Per-run evidence",
|
|
82
|
+
"| Run | Replay | Predicate | Gate | Evidence / reason |",
|
|
83
|
+
"| ---: | :---: | :---: | :---: | --- |",
|
|
84
|
+
]
|
|
85
|
+
)
|
|
86
|
+
for run in result.run_results:
|
|
87
|
+
predicate = (
|
|
88
|
+
"clean"
|
|
89
|
+
if run.predicate_result is not None and not run.predicate_result.violated
|
|
90
|
+
else "violation"
|
|
91
|
+
if run.predicate_result is not None
|
|
92
|
+
else "not evaluated"
|
|
93
|
+
)
|
|
94
|
+
evidence = (
|
|
95
|
+
f"{len(run.evidence)} evidence step(s)"
|
|
96
|
+
if run.evidence
|
|
97
|
+
else _clean(run.observation)
|
|
98
|
+
)
|
|
99
|
+
lines.append(
|
|
100
|
+
f"| {run.number} | "
|
|
101
|
+
f"{'ok' if run.replay_succeeded else 'failed'} | {predicate} | "
|
|
102
|
+
f"{'pass' if run.passed else 'fail'} | {evidence} |"
|
|
103
|
+
)
|
|
104
|
+
return "\n".join(lines) + "\n"
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def render_pr_comment(result: GateResult) -> str:
|
|
108
|
+
"""Alias naming the markdown format used by pull-request integrations."""
|
|
109
|
+
|
|
110
|
+
return render_markdown(result)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def render_report(result: GateResult, console: Console | None = None) -> str:
|
|
114
|
+
"""Backward-compatible report entry point for Rich terminal output."""
|
|
115
|
+
|
|
116
|
+
return render_terminal(result, console)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
__all__ = [
|
|
120
|
+
"render_markdown",
|
|
121
|
+
"render_pr_comment",
|
|
122
|
+
"render_report",
|
|
123
|
+
"render_terminal",
|
|
124
|
+
"status_text",
|
|
125
|
+
]
|