codereview-loop 0.13.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.
codereview/sarif.py ADDED
@@ -0,0 +1,309 @@
1
+ #!/usr/bin/env python3
2
+ """SARIF 2.1.0 export (Epic D, ticket D1).
3
+
4
+ `record_to_sarif(record)` converts a saved review record — the
5
+ dict `core.write_report` persists as JSON — into a SARIF 2.1.0
6
+ log (https://docs.oasis-open.org/sarif/sarif/v2.1.0/), the
7
+ interchange format GitHub code scanning and most other static
8
+ analysis surfaces ingest.
9
+
10
+ What is exported: every finding that is still OPEN on the
11
+ record, deduped by finding id — the merged `findings` list, a
12
+ verification record's still-open `remaining` list, and the
13
+ human-decision side lists (`disputed_findings`,
14
+ `refutation_weak_findings`, `escalated_findings`), whose
15
+ findings were partitioned out of `findings` by adjudication
16
+ routing but are not resolved: the gate stopped on them for a
17
+ human. Each result's properties carry its adjudication state.
18
+ Resolved or classified-away lists (cleared, withdrawn,
19
+ adjudicated-refuted, precedent-standing, fixture, archived,
20
+ out-of-scope) are NOT exported: code scanning should show
21
+ current open problems, not the record's full history.
22
+
23
+ Mapping (SARIF forces a few choices; each is deliberate):
24
+ - Rules derive from finding categories. A deterministic-scan
25
+ finding keeps its scan rule id (`eval-exec`, ...) and takes
26
+ its description and help text from the scan's own rule table;
27
+ a model finding has no rule of its own in the record, so its
28
+ rule id is `model-<category>` (`model-security`, ...).
29
+ - Severity maps blocker -> error, major -> warning,
30
+ minor -> note, nit -> note. Every result carries its own
31
+ level; a rule's defaultConfiguration level is the level of
32
+ its most severe result.
33
+ - Locations come from the finding's file + line. A finding
34
+ whose record entry carries a `line_basis` (which numbering
35
+ the cited line uses — written by the dispute tooling) has
36
+ that basis passed through in the result properties; SARIF
37
+ regions have no basis field of their own. A finding with no
38
+ file (a failing test command) gets no location: SARIF
39
+ permits it, and inventing one would be a false claim.
40
+ - The region snippet is the finding's evidence quote (first
41
+ line) — the exact text the reviewer cited.
42
+ - partialFingerprints carries the record's own finding id
43
+ (`codereviewFindingId`), so alerts stay stable across runs
44
+ exactly as far as the record's fingerprint is stable.
45
+ - The gate, mode, patch hash and other record facts ride in
46
+ the run's property bag — SARIF's sanctioned extension point
47
+ — never in fields a strict consumer could misread as its own.
48
+
49
+ The writer is stdlib-only, like the rest of the package.
50
+ """
51
+
52
+ from __future__ import annotations
53
+
54
+ import posixpath
55
+ import re
56
+ from urllib.parse import quote
57
+
58
+ from . import core
59
+
60
+ SARIF_SCHEMA_URL = (
61
+ "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/"
62
+ "master/Schemata/sarif-schema-2.1.0.json")
63
+ SARIF_VERSION = "2.1.0"
64
+
65
+ # Task-prescribed mapping; nit shares minor's level.
66
+ LEVEL_BY_SEVERITY = {"blocker": "error", "major": "warning",
67
+ "minor": "note", "nit": "note"}
68
+ _LEVEL_RANK = {"error": 0, "warning": 1, "note": 2, "none": 3}
69
+
70
+ # (record key, adjudication state) for the exported lists, in
71
+ # priority order: the first list a finding id appears in names
72
+ # its state. `findings`/`remaining` are the gate's standing set;
73
+ # the side lists are open items awaiting a human decision.
74
+ _EXPORT_LISTS = (("findings", "standing"),
75
+ ("remaining", "standing"),
76
+ ("disputed_findings", "disputed"),
77
+ ("refutation_weak_findings", "refutation-weak"),
78
+ ("escalated_findings", "escalated"))
79
+
80
+ # Canonical text for the deterministic rules, from the scan's
81
+ # own table: rule id -> (title, recommendation, category).
82
+ _DET_RULE_INFO = {rule_id: (title, rec, cat)
83
+ for rule_id, _sev, cat, _rx, title, rec
84
+ in core._RULES}
85
+
86
+
87
+ def level_for_severity(severity):
88
+ """SARIF level for a record severity. Unknown severities
89
+ take the path `_normalize_finding` takes: they behave as
90
+ minor, hence note."""
91
+ sev = str(severity or "minor").lower()
92
+ return LEVEL_BY_SEVERITY.get(sev, LEVEL_BY_SEVERITY["minor"])
93
+
94
+
95
+ def record_findings(record):
96
+ """The record's open findings, deduped by id, each paired
97
+ with its adjudication state (see _EXPORT_LISTS)."""
98
+ seen = set()
99
+ out = []
100
+ for key, state in _EXPORT_LISTS:
101
+ for f in record.get(key) or []:
102
+ if not isinstance(f, dict):
103
+ continue
104
+ fid = str(f.get("id") or "")
105
+ if not fid or fid in seen:
106
+ continue
107
+ seen.add(fid)
108
+ out.append((f, state))
109
+ return out
110
+
111
+
112
+ def _rule_id(finding):
113
+ rule = str(finding.get("rule") or "").strip()
114
+ if rule:
115
+ return rule
116
+ category = str(finding.get("category") or "correctness")
117
+ return f"model-{category.lower()}"
118
+
119
+
120
+ def _rule_name(rule_id):
121
+ parts = [p for p in re.split(r"[^a-zA-Z0-9]+", rule_id) if p]
122
+ return "".join(p[:1].upper() + p[1:] for p in parts) or "Rule"
123
+
124
+
125
+ def _build_rules(pairs):
126
+ """One reportingDescriptor per rule id. The default level
127
+ is the most severe level among the rule's results; every
128
+ result still carries its own level explicitly."""
129
+ by_id = {}
130
+ for finding, _state in pairs:
131
+ rid = _rule_id(finding)
132
+ entry = by_id.setdefault(
133
+ rid, {"finding": finding, "level": None})
134
+ level = level_for_severity(finding.get("severity"))
135
+ if entry["level"] is None \
136
+ or _LEVEL_RANK[level] < _LEVEL_RANK[entry["level"]]:
137
+ entry["level"] = level
138
+ rules = []
139
+ for rid in sorted(by_id):
140
+ entry = by_id[rid]
141
+ finding = entry["finding"]
142
+ category = str(finding.get("category")
143
+ or "correctness").lower()
144
+ canonical = _DET_RULE_INFO.get(rid)
145
+ if canonical is not None:
146
+ title, rec, _cat = canonical
147
+ short = title
148
+ full = (f"{title} — raised by CodeReview's "
149
+ f"deterministic scan (pattern match on the "
150
+ f"diff; no model involved).")
151
+ help_text = rec
152
+ kind = "deterministic"
153
+ elif rid.startswith("model-"):
154
+ short = f"Model-reported {category} finding"
155
+ full = (f"A {category} problem reported by an "
156
+ f"independent reviewer model and carried "
157
+ f"through CodeReview's deterministic gate.")
158
+ help_text = None
159
+ kind = "model"
160
+ else:
161
+ # A scan rule the current table no longer carries
162
+ # (an older record): describe it from the finding
163
+ # itself rather than inventing canonical text.
164
+ short = f"CodeReview deterministic check: {rid}"
165
+ full = short + "."
166
+ help_text = None
167
+ kind = "deterministic"
168
+ rule = {
169
+ "id": rid,
170
+ "name": _rule_name(rid),
171
+ "shortDescription": {"text": short},
172
+ "fullDescription": {"text": full},
173
+ "defaultConfiguration": {"level": entry["level"]},
174
+ "properties": {"category": category,
175
+ "tags": [category, kind]},
176
+ }
177
+ if help_text:
178
+ rule["help"] = {"text": help_text}
179
+ rules.append(rule)
180
+ return rules
181
+
182
+
183
+ _CONTROL_CHARS = re.compile(r"[\x00-\x1f\x7f]")
184
+ _MAX_LINE = 2 ** 31 - 1
185
+
186
+
187
+ def _clean_message(text):
188
+ """message.text with control characters stripped (P5-01):
189
+ a finding title is model/diff-derived text and may carry
190
+ raw newlines, NULs and other Cc characters that strict
191
+ SARIF consumers reject."""
192
+ return _CONTROL_CHARS.sub("", str(text))
193
+
194
+
195
+ def _artifact_uri(path):
196
+ """Normalise a record file path to a SARIF artifactLocation
197
+ uri (P5-01): a percent-encoded RELATIVE URI reference. Drive
198
+ letters and leading slashes are stripped — the uri names a
199
+ repo-relative artefact, and a hostile path must not be
200
+ mistaken for a URI scheme.
201
+
202
+ `..` segments are RESOLVED with posixpath.normpath, not
203
+ dropped (P5-01b): dropping them silently repointed the
204
+ finding (`a/../../b.py` became `a/b.py`, a different file).
205
+ A path that still escapes the root after normalisation
206
+ (a leading `../`) returns "" — the caller then emits no
207
+ location rather than a pointer at the wrong file."""
208
+ p = str(path or "").replace("\\", "/").strip()
209
+ p = re.sub(r"^[A-Za-z]:", "", p) # Windows drive letter
210
+ p = p.lstrip("/")
211
+ if not p:
212
+ return ""
213
+ p = posixpath.normpath(p)
214
+ if p in (".", "..") or p.startswith("../"):
215
+ return ""
216
+ return "/".join(quote(s, safe="") for s in p.split("/"))
217
+
218
+
219
+ def _result(finding, state):
220
+ rid = _rule_id(finding)
221
+ severity = str(finding.get("severity") or "minor").lower()
222
+ if severity not in core.SEVERITIES:
223
+ severity = "minor"
224
+ category = str(finding.get("category")
225
+ or "correctness").lower()
226
+ result = {
227
+ "ruleId": rid,
228
+ "level": level_for_severity(severity),
229
+ "message": {"text": _clean_message(finding.get("title")
230
+ or "Untitled finding")},
231
+ "partialFingerprints": {
232
+ "codereviewFindingId": str(finding.get("id") or "")},
233
+ }
234
+ file = _artifact_uri(finding.get("file"))
235
+ if file:
236
+ physical = {"artifactLocation": {"uri": file}}
237
+ region = {}
238
+ line = finding.get("line")
239
+ if isinstance(line, int) and not isinstance(line, bool) \
240
+ and line >= 1:
241
+ region["startLine"] = min(line, _MAX_LINE)
242
+ quote = str(finding.get("evidence_quote") or "")
243
+ if quote:
244
+ region["snippet"] = {"text": quote.splitlines()[0][:500]}
245
+ if region:
246
+ physical["region"] = region
247
+ result["locations"] = [{"physicalLocation": physical}]
248
+ props = {
249
+ "codereviewFindingId": str(finding.get("id") or ""),
250
+ "severity": severity,
251
+ "category": category,
252
+ "adjudicationState": state,
253
+ "sources": [str(s) for s in finding.get("sources") or []],
254
+ "consensus": bool(finding.get("consensus")),
255
+ }
256
+ if finding.get("confidence") is not None:
257
+ props["confidence"] = finding["confidence"]
258
+ rec = str(finding.get("recommendation") or "")
259
+ if rec:
260
+ props["recommendation"] = rec
261
+ # Optional record fields, passed through only when present:
262
+ # grounding state, cross-family confirmation, and the line
263
+ # basis the dispute tooling records (`line_basis` — which
264
+ # numbering `line` uses, e.g. the head blob's).
265
+ for src_key, prop_key in (("grounding", "grounding"),
266
+ ("cross_family", "crossFamily"),
267
+ ("line_basis", "lineBasis")):
268
+ value = finding.get(src_key)
269
+ if isinstance(value, str) and value:
270
+ props[prop_key] = value
271
+ result["properties"] = props
272
+ return result
273
+
274
+
275
+ def record_to_sarif(record):
276
+ """Convert a saved review record (dict) to a SARIF 2.1.0
277
+ log (dict). Pure: no I/O, no mutation of the record."""
278
+ pairs = record_findings(record)
279
+ cr_props = {}
280
+ for src_key, prop_key in (("schema", "recordSchema"),
281
+ ("mode", "mode"),
282
+ ("gate", "gate"),
283
+ ("created_at", "recordCreatedAt"),
284
+ ("patch_sha256", "patchSha256"),
285
+ ("range", "range"),
286
+ ("head", "head")):
287
+ value = record.get(src_key)
288
+ if value:
289
+ cr_props[prop_key] = value
290
+ reasons = [str(r) for r in record.get("gate_reasons") or []]
291
+ if reasons:
292
+ cr_props["gateReasons"] = reasons
293
+ if record.get("degraded"):
294
+ cr_props["degraded"] = True
295
+ cr_props["exportedResults"] = len(pairs)
296
+ run = {
297
+ "tool": {"driver": {
298
+ "name": "CodeReview",
299
+ "fullName": "CodeReview — multi-model code review "
300
+ "with a deterministic gate",
301
+ "version": str(record.get("version") or core.VERSION),
302
+ "rules": _build_rules(pairs),
303
+ }},
304
+ "results": [_result(f, state) for f, state in pairs],
305
+ "properties": {"codereview": cr_props},
306
+ }
307
+ return {"$schema": SARIF_SCHEMA_URL,
308
+ "version": SARIF_VERSION,
309
+ "runs": [run]}