pytest-wirecheck 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.
- pytest_wirecheck-0.1.0.dist-info/METADATA +175 -0
- pytest_wirecheck-0.1.0.dist-info/RECORD +16 -0
- pytest_wirecheck-0.1.0.dist-info/WHEEL +5 -0
- pytest_wirecheck-0.1.0.dist-info/entry_points.txt +5 -0
- pytest_wirecheck-0.1.0.dist-info/licenses/LICENSE +21 -0
- pytest_wirecheck-0.1.0.dist-info/top_level.txt +1 -0
- wirecheck/__init__.py +3 -0
- wirecheck/__main__.py +3 -0
- wirecheck/capture.py +231 -0
- wirecheck/cli.py +93 -0
- wirecheck/clock.py +17 -0
- wirecheck/plugin.py +314 -0
- wirecheck/report.py +183 -0
- wirecheck/rules.py +239 -0
- wirecheck/scan.py +320 -0
- wirecheck/versions.py +65 -0
wirecheck/plugin.py
ADDED
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
"""pytest plugin: ``pytest --wirecheck``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import shutil
|
|
8
|
+
import tempfile
|
|
9
|
+
from collections import Counter
|
|
10
|
+
from collections.abc import Sequence
|
|
11
|
+
from types import SimpleNamespace
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
import pytest
|
|
15
|
+
|
|
16
|
+
from . import __version__, clock, report, rules, versions
|
|
17
|
+
from .capture import Recorder
|
|
18
|
+
from .rules import Captured
|
|
19
|
+
|
|
20
|
+
STATE_KEY = pytest.StashKey["WireCheck"]()
|
|
21
|
+
FAIL_ON = ("error", "warn", "never")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def pytest_addoption(parser: pytest.Parser) -> None:
|
|
25
|
+
group = parser.getgroup("wirecheck", "Claude API request checks")
|
|
26
|
+
group.addoption(
|
|
27
|
+
"--wirecheck",
|
|
28
|
+
action="store_true",
|
|
29
|
+
default=None,
|
|
30
|
+
help="Capture requests to the Claude API and check them against API version 2026-10-21.",
|
|
31
|
+
)
|
|
32
|
+
group.addoption(
|
|
33
|
+
"--wirecheck-base-url",
|
|
34
|
+
action="append",
|
|
35
|
+
default=[],
|
|
36
|
+
metavar="URL",
|
|
37
|
+
help="Extra base URL to capture (for gateways and proxies). Repeatable. "
|
|
38
|
+
"api.anthropic.com and $ANTHROPIC_BASE_URL are always captured.",
|
|
39
|
+
)
|
|
40
|
+
group.addoption(
|
|
41
|
+
"--wirecheck-fail-on",
|
|
42
|
+
choices=FAIL_ON,
|
|
43
|
+
default=None,
|
|
44
|
+
help="Fail tests on 'error' findings (default), on any finding ('warn'), or 'never'.",
|
|
45
|
+
)
|
|
46
|
+
group.addoption("--wirecheck-report", metavar="PATH", default=None, help="Write findings as JSON.")
|
|
47
|
+
group.addoption("--wirecheck-markdown", metavar="PATH", default=None, help="Write findings as Markdown.")
|
|
48
|
+
parser.addini("wirecheck", type="bool", default=False, help="Turn WireCheck on for every run.")
|
|
49
|
+
parser.addini("wirecheck_base_urls", type="linelist", default=[], help="Extra base URLs to capture.")
|
|
50
|
+
parser.addini("wirecheck_fail_on", default="error", help="error, warn or never.")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _enabled(config: pytest.Config) -> bool:
|
|
54
|
+
flag = config.getoption("wirecheck")
|
|
55
|
+
if flag is not None:
|
|
56
|
+
return bool(flag)
|
|
57
|
+
if os.environ.get("WIRECHECK", "").strip().lower() in ("1", "true", "yes", "on"):
|
|
58
|
+
return True
|
|
59
|
+
return bool(config.getini("wirecheck"))
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def pytest_configure(config: pytest.Config) -> None:
|
|
63
|
+
config.addinivalue_line("markers", "wirecheck_skip: do not check Claude API requests made by this test")
|
|
64
|
+
if not _enabled(config):
|
|
65
|
+
return
|
|
66
|
+
state = WireCheck(config)
|
|
67
|
+
config.stash[STATE_KEY] = state
|
|
68
|
+
config.pluginmanager.register(state, "wirecheck-state")
|
|
69
|
+
state.start()
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def pytest_unconfigure(config: pytest.Config) -> None:
|
|
73
|
+
state = config.stash.get(STATE_KEY, None)
|
|
74
|
+
if state is not None:
|
|
75
|
+
state.stop()
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class CapturedView(Sequence):
|
|
79
|
+
"""Live, read-only list of the requests one test has sent so far."""
|
|
80
|
+
|
|
81
|
+
def __init__(self, state: "WireCheck | None", nodeid: str) -> None:
|
|
82
|
+
self._state = state
|
|
83
|
+
self._nodeid = nodeid
|
|
84
|
+
|
|
85
|
+
def _items(self) -> list[Captured]:
|
|
86
|
+
if self._state is None:
|
|
87
|
+
return []
|
|
88
|
+
return [c for c in self._state.captured if c.test == self._nodeid]
|
|
89
|
+
|
|
90
|
+
def __getitem__(self, index: Any) -> Any:
|
|
91
|
+
return self._items()[index]
|
|
92
|
+
|
|
93
|
+
def __len__(self) -> int:
|
|
94
|
+
return len(self._items())
|
|
95
|
+
|
|
96
|
+
def __repr__(self) -> str:
|
|
97
|
+
return f"CapturedView({self._items()!r})"
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@pytest.fixture
|
|
101
|
+
def wirecheck_requests(request: pytest.FixtureRequest) -> CapturedView:
|
|
102
|
+
"""Requests to the Claude API captured during this test (always empty if WireCheck is off)."""
|
|
103
|
+
return CapturedView(request.config.stash.get(STATE_KEY, None), request.node.nodeid)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@pytest.fixture
|
|
107
|
+
def wirecheck_stub(request: pytest.FixtureRequest) -> Any:
|
|
108
|
+
"""Answer every Claude API request in this test with a canned 200 reply, without the network.
|
|
109
|
+
|
|
110
|
+
This mocks the response, not the client, so WireCheck still sees what your code sends.
|
|
111
|
+
Works with or without --wirecheck. Non-streaming calls only.
|
|
112
|
+
"""
|
|
113
|
+
state = request.config.stash.get(STATE_KEY, None)
|
|
114
|
+
own: Recorder | None = None
|
|
115
|
+
if state is None:
|
|
116
|
+
own = Recorder(on_capture=lambda c: None)
|
|
117
|
+
own.install()
|
|
118
|
+
recorder = own
|
|
119
|
+
else:
|
|
120
|
+
recorder = state.recorder
|
|
121
|
+
stub = {"text": "stub reply"}
|
|
122
|
+
recorder.stub = stub
|
|
123
|
+
try:
|
|
124
|
+
yield stub
|
|
125
|
+
finally:
|
|
126
|
+
recorder.stub = None
|
|
127
|
+
if own is not None:
|
|
128
|
+
own.uninstall()
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class FailureRepr:
|
|
132
|
+
"""Test failure text with a one-line summary for pytest's short test summary."""
|
|
133
|
+
|
|
134
|
+
def __init__(self, text: str, summary: str) -> None:
|
|
135
|
+
self.text = text
|
|
136
|
+
self.reprcrash = SimpleNamespace(message=summary, path="", lineno=0)
|
|
137
|
+
|
|
138
|
+
def toterminal(self, tw: Any) -> None:
|
|
139
|
+
for line in self.text.splitlines():
|
|
140
|
+
tw.line(line)
|
|
141
|
+
|
|
142
|
+
def __str__(self) -> str:
|
|
143
|
+
return self.text
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _one_line(findings: list[rules.Finding]) -> str:
|
|
147
|
+
parts = []
|
|
148
|
+
for f in findings:
|
|
149
|
+
item = f"{f.rule_id} {f.where}"
|
|
150
|
+
if item not in parts:
|
|
151
|
+
parts.append(item)
|
|
152
|
+
shown = ", ".join(parts[:3]) + (f" and {len(parts) - 3} more" if len(parts) > 3 else "")
|
|
153
|
+
return f"WireCheck: {shown} (breaks under Claude API 2026-10-21)"
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class WireCheck:
|
|
157
|
+
def __init__(self, config: pytest.Config) -> None:
|
|
158
|
+
self.config = config
|
|
159
|
+
workerinput = getattr(config, "workerinput", None)
|
|
160
|
+
self.worker_id: str | None = workerinput.get("workerid") if workerinput else None
|
|
161
|
+
if workerinput:
|
|
162
|
+
self.share_dir: str | None = workerinput.get("wirecheck_dir")
|
|
163
|
+
self.own_share_dir = False
|
|
164
|
+
else:
|
|
165
|
+
self.share_dir = tempfile.mkdtemp(prefix="wirecheck-")
|
|
166
|
+
self.own_share_dir = True
|
|
167
|
+
self.merged: dict[str, Any] | None = None
|
|
168
|
+
base_urls = list(config.getoption("wirecheck_base_url") or []) + list(config.getini("wirecheck_base_urls"))
|
|
169
|
+
env_base = os.environ.get("ANTHROPIC_BASE_URL")
|
|
170
|
+
if env_base:
|
|
171
|
+
base_urls.append(env_base)
|
|
172
|
+
fail_on = config.getoption("wirecheck_fail_on") or config.getini("wirecheck_fail_on") or "error"
|
|
173
|
+
if fail_on not in FAIL_ON:
|
|
174
|
+
raise pytest.UsageError(f"wirecheck_fail_on must be one of {', '.join(FAIL_ON)}, not {fail_on!r}")
|
|
175
|
+
self.fail_on = fail_on
|
|
176
|
+
self.sdk_version = versions.installed_sdk_version()
|
|
177
|
+
self.recorder = Recorder(self._on_capture, base_urls=base_urls, root=str(config.rootpath))
|
|
178
|
+
self.captured: list[Captured] = []
|
|
179
|
+
self.findings: list[rules.Finding] = []
|
|
180
|
+
self.pending: dict[str, list[rules.Finding]] = {}
|
|
181
|
+
self.skip_tests: set[str] = set()
|
|
182
|
+
|
|
183
|
+
# -- lifecycle -------------------------------------------------------------
|
|
184
|
+
def start(self) -> None:
|
|
185
|
+
self.recorder.install()
|
|
186
|
+
|
|
187
|
+
def stop(self) -> None:
|
|
188
|
+
self.recorder.uninstall()
|
|
189
|
+
if self.own_share_dir and self.share_dir:
|
|
190
|
+
shutil.rmtree(self.share_dir, ignore_errors=True)
|
|
191
|
+
|
|
192
|
+
@pytest.hookimpl(optionalhook=True)
|
|
193
|
+
def pytest_configure_node(self, node: Any) -> None:
|
|
194
|
+
"""pytest-xdist: tell each worker where to leave its findings."""
|
|
195
|
+
if self.share_dir:
|
|
196
|
+
node.workerinput["wirecheck_dir"] = self.share_dir
|
|
197
|
+
|
|
198
|
+
def _on_capture(self, captured: Captured) -> None:
|
|
199
|
+
if captured.test in self.skip_tests:
|
|
200
|
+
return
|
|
201
|
+
self.captured.append(captured)
|
|
202
|
+
ctx = rules.Context(sdk_version=self.sdk_version, today=clock.today())
|
|
203
|
+
found = rules.check(captured, ctx)
|
|
204
|
+
self.findings.extend(found)
|
|
205
|
+
if captured.test:
|
|
206
|
+
self.pending.setdefault(captured.test, []).extend(found)
|
|
207
|
+
|
|
208
|
+
def _fails(self, finding: rules.Finding) -> bool:
|
|
209
|
+
if self.fail_on == "never":
|
|
210
|
+
return False
|
|
211
|
+
if self.fail_on == "warn":
|
|
212
|
+
return True
|
|
213
|
+
return finding.severity == rules.ERROR
|
|
214
|
+
|
|
215
|
+
# -- pytest hooks ----------------------------------------------------------
|
|
216
|
+
@pytest.hookimpl(wrapper=True)
|
|
217
|
+
def pytest_runtest_protocol(self, item: pytest.Item, nextitem: Any) -> Any:
|
|
218
|
+
if item.get_closest_marker("wirecheck_skip"):
|
|
219
|
+
self.skip_tests.add(item.nodeid)
|
|
220
|
+
self.recorder.current_test = item.nodeid
|
|
221
|
+
try:
|
|
222
|
+
return (yield)
|
|
223
|
+
finally:
|
|
224
|
+
self.recorder.current_test = None
|
|
225
|
+
|
|
226
|
+
@pytest.hookimpl(wrapper=True)
|
|
227
|
+
def pytest_runtest_makereport(self, item: pytest.Item, call: pytest.CallInfo) -> Any:
|
|
228
|
+
rep = yield
|
|
229
|
+
new = self.pending.pop(item.nodeid, [])
|
|
230
|
+
failing = [f for f in new if self._fails(f)]
|
|
231
|
+
if failing:
|
|
232
|
+
text = report.render_test_failure([f.to_dict() for f in failing])
|
|
233
|
+
if rep.passed:
|
|
234
|
+
rep.outcome = "failed"
|
|
235
|
+
rep.longrepr = FailureRepr(text, _one_line(failing))
|
|
236
|
+
else:
|
|
237
|
+
rep.sections.append(("wirecheck", text))
|
|
238
|
+
return rep
|
|
239
|
+
|
|
240
|
+
@pytest.hookimpl(trylast=True)
|
|
241
|
+
def pytest_sessionfinish(self, session: pytest.Session, exitstatus: int) -> None:
|
|
242
|
+
if self.worker_id:
|
|
243
|
+
# pytest-xdist worker: hand the findings to the controller, which reports them.
|
|
244
|
+
if self.share_dir and os.path.isdir(self.share_dir):
|
|
245
|
+
report.dump_json(self.data(), os.path.join(self.share_dir, f"worker-{self.worker_id}.json"))
|
|
246
|
+
return
|
|
247
|
+
data = self.merged = self._merge_workers(self.data())
|
|
248
|
+
outside = [f for f in data["findings"] if not f.get("test") and self._fails_dict(f)]
|
|
249
|
+
if outside and session.exitstatus == 0:
|
|
250
|
+
session.exitstatus = pytest.ExitCode.TESTS_FAILED
|
|
251
|
+
path = self.config.getoption("wirecheck_report")
|
|
252
|
+
if path:
|
|
253
|
+
report.dump_json(data, path)
|
|
254
|
+
md = self.config.getoption("wirecheck_markdown")
|
|
255
|
+
if md:
|
|
256
|
+
with open(md, "w", encoding="utf-8") as fh:
|
|
257
|
+
fh.write(report.render_markdown(data, None))
|
|
258
|
+
|
|
259
|
+
def pytest_report_header(self, config: pytest.Config) -> str:
|
|
260
|
+
libs = ", ".join(self.recorder.libraries) or "none (install httpx2 or httpx)"
|
|
261
|
+
sdk = self.sdk_version or "not installed"
|
|
262
|
+
return f"wirecheck {__version__}: hooked {libs}; anthropic SDK {sdk}; fail on {self.fail_on}"
|
|
263
|
+
|
|
264
|
+
def pytest_terminal_summary(self, terminalreporter: Any) -> None:
|
|
265
|
+
if self.worker_id:
|
|
266
|
+
return
|
|
267
|
+
terminalreporter.write_sep("=", "wirecheck")
|
|
268
|
+
terminalreporter.write_line(report.render_text(self.merged or self.data()))
|
|
269
|
+
|
|
270
|
+
def _fails_dict(self, finding: dict[str, Any]) -> bool:
|
|
271
|
+
if self.fail_on == "never":
|
|
272
|
+
return False
|
|
273
|
+
return self.fail_on == "warn" or finding.get("severity") == rules.ERROR
|
|
274
|
+
|
|
275
|
+
def _merge_workers(self, own: dict[str, Any]) -> dict[str, Any]:
|
|
276
|
+
if not self.share_dir or not os.path.isdir(self.share_dir):
|
|
277
|
+
return own
|
|
278
|
+
merged = dict(
|
|
279
|
+
own,
|
|
280
|
+
findings=list(own["findings"]),
|
|
281
|
+
api_versions=Counter(own["api_versions"]),
|
|
282
|
+
internal_errors=list(own.get("internal_errors", [])),
|
|
283
|
+
)
|
|
284
|
+
for name in sorted(os.listdir(self.share_dir)):
|
|
285
|
+
if not (name.startswith("worker-") and name.endswith(".json")):
|
|
286
|
+
continue
|
|
287
|
+
try:
|
|
288
|
+
with open(os.path.join(self.share_dir, name), encoding="utf-8") as fh:
|
|
289
|
+
part = json.load(fh)
|
|
290
|
+
except (OSError, ValueError):
|
|
291
|
+
continue
|
|
292
|
+
merged["requests_checked"] += part.get("requests_checked", 0)
|
|
293
|
+
merged["api_versions"].update(part.get("api_versions", {}))
|
|
294
|
+
merged["findings"].extend(part.get("findings", []))
|
|
295
|
+
merged["internal_errors"].extend(part.get("internal_errors", []))
|
|
296
|
+
merged["sdk_version"] = merged.get("sdk_version") or part.get("sdk_version")
|
|
297
|
+
merged["api_versions"] = dict(merged["api_versions"])
|
|
298
|
+
return merged
|
|
299
|
+
|
|
300
|
+
def data(self) -> dict[str, Any]:
|
|
301
|
+
versions_seen = Counter(
|
|
302
|
+
versions.resolve(c.headers, self.sdk_version).api_version for c in self.captured
|
|
303
|
+
)
|
|
304
|
+
return {
|
|
305
|
+
"tool": "wirecheck",
|
|
306
|
+
"version": __version__,
|
|
307
|
+
"kind": "pytest",
|
|
308
|
+
"requests_checked": len(self.captured),
|
|
309
|
+
"sdk_version": self.sdk_version,
|
|
310
|
+
"api_versions": dict(versions_seen),
|
|
311
|
+
"fail_on": self.fail_on,
|
|
312
|
+
"findings": [f.to_dict() for f in self.findings],
|
|
313
|
+
"internal_errors": list(self.recorder.errors[:50]),
|
|
314
|
+
}
|
wirecheck/report.py
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""Turn findings into a terminal table, JSON, or a Markdown PR comment."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from collections import Counter, OrderedDict
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from typing import Any, Iterable
|
|
9
|
+
|
|
10
|
+
from . import __version__
|
|
11
|
+
from .rules import RULES
|
|
12
|
+
|
|
13
|
+
COMMENT_MARKER = "<!-- wirecheck-comment -->"
|
|
14
|
+
REPO_URL = "https://github.com/ishanyash/wirecheck"
|
|
15
|
+
SCAN_RULE_DOCS: dict[str, str] = {} # filled by scan.py to avoid an import cycle
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class Row:
|
|
20
|
+
rule_id: str
|
|
21
|
+
severity: str
|
|
22
|
+
test: str
|
|
23
|
+
call_site: str
|
|
24
|
+
count: int = 0
|
|
25
|
+
where: list[str] = field(default_factory=list)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def group_findings(findings: Iterable[dict[str, Any]]) -> list[Row]:
|
|
29
|
+
rows: "OrderedDict[tuple, Row]" = OrderedDict()
|
|
30
|
+
for f in findings:
|
|
31
|
+
key = (f["rule_id"], f["severity"], f.get("test") or "(outside tests)", f.get("call_site") or "?")
|
|
32
|
+
row = rows.get(key)
|
|
33
|
+
if row is None:
|
|
34
|
+
row = rows[key] = Row(*key)
|
|
35
|
+
row.count += 1
|
|
36
|
+
if f.get("where") and f["where"] not in row.where:
|
|
37
|
+
row.where.append(f["where"])
|
|
38
|
+
order = {"error": 0, "warn": 1}
|
|
39
|
+
return sorted(rows.values(), key=lambda r: (order.get(r.severity, 2), r.rule_id, r.test, r.call_site))
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _counts(findings: Iterable[dict[str, Any]]) -> tuple[int, int]:
|
|
43
|
+
c = Counter(f["severity"] for f in findings)
|
|
44
|
+
return c.get("error", 0), c.get("warn", 0)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _plural(n: int, word: str) -> str:
|
|
48
|
+
return f"{n} {word}{'' if n == 1 else 's'}"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# -- terminal -------------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
def render_text(data: dict[str, Any]) -> str:
|
|
54
|
+
findings = data["findings"]
|
|
55
|
+
errors, warns = _counts(findings)
|
|
56
|
+
lines = [
|
|
57
|
+
f"{_plural(data['requests_checked'], 'request')} to the Claude API checked: "
|
|
58
|
+
f"{_plural(errors, 'error')}, {_plural(warns, 'warning')}",
|
|
59
|
+
f"anthropic SDK: {data.get('sdk_version') or 'not installed'}; "
|
|
60
|
+
f"API versions seen: {', '.join(f'{k} x{v}' for k, v in data.get('api_versions', {}).items()) or 'none'}",
|
|
61
|
+
]
|
|
62
|
+
internal = data.get("internal_errors") or []
|
|
63
|
+
if internal:
|
|
64
|
+
lines.append(
|
|
65
|
+
f"WARNING: WireCheck hit {len(internal)} internal error(s), so some requests may be missing. "
|
|
66
|
+
f"First: {internal[0]}. Please report it at {REPO_URL}/issues"
|
|
67
|
+
)
|
|
68
|
+
if data["requests_checked"] == 0:
|
|
69
|
+
lines.append(
|
|
70
|
+
"No requests were seen. If your tests replace client.messages.create with a mock, no request is "
|
|
71
|
+
"ever built. Mock the HTTP response instead (see the wirecheck_stub fixture)."
|
|
72
|
+
)
|
|
73
|
+
rows = group_findings(findings)
|
|
74
|
+
if rows:
|
|
75
|
+
table = [("RULE", "LEVEL", "COUNT", "TEST", "CALL SITE", "WHAT")]
|
|
76
|
+
for r in rows:
|
|
77
|
+
table.append((r.rule_id, r.severity, str(r.count), r.test, r.call_site, ", ".join(r.where)))
|
|
78
|
+
widths = [max(len(row[i]) for row in table) for i in range(len(table[0]) - 1)]
|
|
79
|
+
lines.append("")
|
|
80
|
+
for row in table:
|
|
81
|
+
cells = [row[i].ljust(widths[i]) for i in range(len(widths))] + [row[-1]]
|
|
82
|
+
lines.append(" ".join(cells).rstrip())
|
|
83
|
+
lines.append("")
|
|
84
|
+
for rule_id in sorted({r.rule_id for r in rows}):
|
|
85
|
+
rule = RULES[rule_id]
|
|
86
|
+
lines.append(f"{rule_id} {rule.name}: {rule.summary}")
|
|
87
|
+
lines.append(f" {rule.doc}")
|
|
88
|
+
return "\n".join(lines)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def render_test_failure(findings: list[dict[str, Any]]) -> str:
|
|
92
|
+
out = ["WireCheck: this test sent a request that breaks under Claude API 2026-10-21", ""]
|
|
93
|
+
for f in findings:
|
|
94
|
+
out.append(f" {f['rule_id']} [{f['severity']}] {f['where']} (api {f['api_version']}, from {f['api_version_source']})")
|
|
95
|
+
out.append(f" call site: {f.get('call_site') or '?'}")
|
|
96
|
+
out.append(f" {f['message']}")
|
|
97
|
+
out.append(f" {f['doc']}")
|
|
98
|
+
return "\n".join(out)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
# -- markdown (PR comment and step summary) -------------------------------------
|
|
102
|
+
|
|
103
|
+
def _md(text: str) -> str:
|
|
104
|
+
return str(text).replace("|", "\\|").replace("\n", " ")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def render_markdown(
|
|
108
|
+
pytest_data: dict[str, Any] | None,
|
|
109
|
+
scan_data: dict[str, Any] | None,
|
|
110
|
+
pytest_exit: int | None = None,
|
|
111
|
+
) -> str:
|
|
112
|
+
runtime = (pytest_data or {}).get("findings", [])
|
|
113
|
+
static = (scan_data or {}).get("findings", [])
|
|
114
|
+
errors, warns = _counts(list(runtime) + list(static))
|
|
115
|
+
if errors:
|
|
116
|
+
headline = f"**{_plural(errors, 'error')}, {_plural(warns, 'warning')}.** These requests return 400 under API version 2026-10-21."
|
|
117
|
+
elif warns:
|
|
118
|
+
headline = f"**No errors, {_plural(warns, 'warning')}.**"
|
|
119
|
+
else:
|
|
120
|
+
headline = "**No problems found.**"
|
|
121
|
+
|
|
122
|
+
parts = [COMMENT_MARKER, "### WireCheck: Claude API 2026-10-21", "", headline, ""]
|
|
123
|
+
facts = []
|
|
124
|
+
if pytest_data is not None:
|
|
125
|
+
facts.append(f"{_plural(pytest_data.get('requests_checked', 0), 'request')} captured in tests")
|
|
126
|
+
if pytest_data.get("sdk_version"):
|
|
127
|
+
facts.append(f"anthropic {pytest_data['sdk_version']}")
|
|
128
|
+
if scan_data is not None:
|
|
129
|
+
facts.append(f"{_plural(scan_data.get('files_scanned', 0), 'file')} scanned")
|
|
130
|
+
if facts:
|
|
131
|
+
parts += ["<sub>" + " · ".join(facts) + "</sub>", ""]
|
|
132
|
+
internal = (pytest_data or {}).get("internal_errors") or []
|
|
133
|
+
if internal:
|
|
134
|
+
parts += [f"> WireCheck hit {len(internal)} internal error(s), so some requests may be missing. "
|
|
135
|
+
f"First: `{_md(internal[0])}`", ""]
|
|
136
|
+
if pytest_exit not in (None, 0, 1, 5):
|
|
137
|
+
parts += [f"> pytest exited with code {pytest_exit}, so some tests may not have run. Check the job log.", ""]
|
|
138
|
+
elif pytest_data is not None and pytest_data.get("requests_checked", 0) == 0:
|
|
139
|
+
parts += [
|
|
140
|
+
"> Your tests sent no requests to the Claude API. If they replace `client.messages.create` with a mock, "
|
|
141
|
+
"no request is ever built. Mock the HTTP response instead (see the `wirecheck_stub` fixture).",
|
|
142
|
+
"",
|
|
143
|
+
]
|
|
144
|
+
|
|
145
|
+
rows = group_findings(runtime)
|
|
146
|
+
if rows:
|
|
147
|
+
parts += [
|
|
148
|
+
"#### Requests your tests sent",
|
|
149
|
+
"",
|
|
150
|
+
"| Rule | Level | Count | Test | Call site | What |",
|
|
151
|
+
"|---|---|---:|---|---|---|",
|
|
152
|
+
]
|
|
153
|
+
for r in rows:
|
|
154
|
+
parts.append(
|
|
155
|
+
f"| {r.rule_id} | {r.severity} | {r.count} | `{_md(r.test)}` | `{_md(r.call_site)}` | {_md(', '.join(r.where))} |"
|
|
156
|
+
)
|
|
157
|
+
parts.append("")
|
|
158
|
+
|
|
159
|
+
if static:
|
|
160
|
+
parts += ["#### Static scan", "", "| Rule | Level | File | What |", "|---|---|---|---|"]
|
|
161
|
+
for f in sorted(static, key=lambda x: ({"error": 0, "warn": 1}.get(x["severity"], 2), x["path"], x["line"])):
|
|
162
|
+
parts.append(f"| {f['rule_id']} | {f['severity']} | `{_md(f['path'])}:{f['line']}` | {_md(f['message'])} |")
|
|
163
|
+
parts.append("")
|
|
164
|
+
|
|
165
|
+
used = sorted({f["rule_id"] for f in list(runtime) + list(static)})
|
|
166
|
+
if used:
|
|
167
|
+
parts += ["<details><summary>What the rules mean</summary>", ""]
|
|
168
|
+
for rule_id in used:
|
|
169
|
+
if rule_id in RULES:
|
|
170
|
+
rule = RULES[rule_id]
|
|
171
|
+
parts.append(f"- **{rule_id}** {_md(rule.summary)} [Docs]({rule.doc})")
|
|
172
|
+
elif rule_id in SCAN_RULE_DOCS:
|
|
173
|
+
parts.append(f"- **{rule_id}** {SCAN_RULE_DOCS[rule_id]}")
|
|
174
|
+
parts += ["", "</details>", ""]
|
|
175
|
+
|
|
176
|
+
parts.append(f"<sub>[WireCheck]({REPO_URL}) v{__version__}</sub>")
|
|
177
|
+
return "\n".join(parts) + "\n"
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def dump_json(data: dict[str, Any], path: str) -> None:
|
|
181
|
+
with open(path, "w", encoding="utf-8") as fh:
|
|
182
|
+
json.dump(data, fh, indent=2)
|
|
183
|
+
fh.write("\n")
|