ois-validator 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.
@@ -0,0 +1,6 @@
1
+ """Reference validator for the Open Intent Schema (OIS)."""
2
+
3
+ from ois_validator.report import Level, Report, Result
4
+ from ois_validator.validator import validate
5
+
6
+ __all__ = ["Level", "Report", "Result", "validate"]
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from ois_validator.cli import main
4
+
5
+ sys.exit(main())
ois_validator/cli.py ADDED
@@ -0,0 +1,59 @@
1
+ """Command line interface: ois-validator FILE..."""
2
+
3
+ import argparse
4
+ import json
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ from ois_validator.report import Level, Report, Result
9
+ from ois_validator.validator import validate
10
+
11
+ EXIT_CONFORMS = 0
12
+ EXIT_VIOLATION = 1
13
+ EXIT_USAGE = 2
14
+
15
+
16
+ def main(argv: list[str] | None = None) -> int:
17
+ parser = argparse.ArgumentParser(
18
+ prog="ois-validator",
19
+ description="Validate OIS records, bundles and Quality Passports.",
20
+ )
21
+ parser.add_argument("files", nargs="+", metavar="FILE", help="JSON file, or - for stdin")
22
+ parser.add_argument("--format", choices=("text", "json"), default="text")
23
+ args = parser.parse_args(argv)
24
+
25
+ results: dict[str, Result] = {}
26
+ for name in args.files:
27
+ try:
28
+ text = sys.stdin.read() if name == "-" else Path(name).read_text(encoding="utf-8")
29
+ except (OSError, UnicodeDecodeError) as error:
30
+ print(f"ois-validator: {name}: {error}", file=sys.stderr)
31
+ return EXIT_USAGE
32
+ results[name] = _validate_text(text)
33
+
34
+ if args.format == "json":
35
+ out = [
36
+ {"file": name, "conforms": r.conforms, "reports": [x.as_dict() for x in r.reports]}
37
+ for name, r in results.items()
38
+ ]
39
+ print(json.dumps(out, indent=2))
40
+ else:
41
+ for name, result in results.items():
42
+ if not result.reports:
43
+ print(f"{name}: conforms")
44
+ for report in result.reports:
45
+ print(f"{name}: {report.level} [{report.rule}] {report.path}: {report.message}")
46
+
47
+ return EXIT_CONFORMS if all(r.conforms for r in results.values()) else EXIT_VIOLATION
48
+
49
+
50
+ def _validate_text(text: str) -> Result:
51
+ try:
52
+ document = json.loads(text)
53
+ except json.JSONDecodeError as error:
54
+ return Result([Report(Level.VIOLATION, "json", f"not valid JSON: {error}", "$")])
55
+ return validate(document)
56
+
57
+
58
+ if __name__ == "__main__":
59
+ sys.exit(main())
@@ -0,0 +1,28 @@
1
+ """Digests of criteria and intents (spec section 6.4), over their RFC 8785 canonical form."""
2
+
3
+ import hashlib
4
+ from typing import Any
5
+
6
+ import rfc8785
7
+
8
+ Digest = dict[str, str] # in-toto DigestSet: algorithm -> lowercase hex
9
+
10
+ CRITERION_FIELDS = ("key", "statement", "notation", "kind", "target", "language")
11
+
12
+
13
+ def criterion_sha256(criterion: dict[str, Any]) -> str:
14
+ """Over the criterion's own key, statement, notation, kind, target and language."""
15
+ fields = {k: criterion[k] for k in CRITERION_FIELDS if k in criterion}
16
+ return hashlib.sha256(rfc8785.dumps(fields)).hexdigest()
17
+
18
+
19
+ def intent_sha256(intent: dict[str, Any]) -> str:
20
+ """Over the intent without its top-level digest and status, criterion digests included."""
21
+ content = {k: v for k, v in intent.items() if k not in ("digest", "status")}
22
+ return hashlib.sha256(rfc8785.dumps(content)).hexdigest()
23
+
24
+
25
+ def equal(a: Digest, b: Digest) -> bool:
26
+ """Two digest sets name the same content: they share an algorithm and agree on each."""
27
+ shared = a.keys() & b.keys()
28
+ return bool(shared) and all(a[name] == b[name] for name in shared)
@@ -0,0 +1,107 @@
1
+ """The records of a bundle or passport and how references between them resolve.
2
+
3
+ Resolution follows the conformance fixtures (fixtures/README.md in the specification repository):
4
+ `<id>@<version>` resolves to the intent or actor with that id and version, `<id>` to a record
5
+ with that id and no version, and `<id>@<version>#<key>` to the intent version (the key is R2's
6
+ concern). Only Ref-typed fields are references.
7
+ """
8
+
9
+ from collections.abc import Iterator
10
+ from dataclasses import dataclass, field
11
+ from typing import Any
12
+
13
+ from ois_validator.schema import STATEMENT_TYPE
14
+
15
+ Record = dict[str, Any]
16
+
17
+ REFERENCE_FIELDS = {
18
+ "intent": ("producedBy", "supersedes"),
19
+ "artifact": ("producedBy", "inputs", "context", "contextRecordedBy"),
20
+ "actor": (),
21
+ "evidence": ("criterion", "test", "subject", "inputs", "recordedBy"),
22
+ "decision": ("subject", "decidedBy", "evidence"),
23
+ "finding": ("criterion", "intent", "subject", "reportedBy", "resolvedBy", "supersedes"),
24
+ }
25
+ PASSPORT_REFERENCE_FIELDS = ("intents", "scope", "issuedBy")
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class Entry:
30
+ record: Record
31
+ path: str # JSONPath of the record in the input document
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class Reference:
36
+ value: str
37
+ path: str # JSONPath of the reference in the input document
38
+
39
+
40
+ @dataclass
41
+ class Document:
42
+ """The records of a single record, a bundle or a passport, after the schema check passed."""
43
+
44
+ entries: list[Entry]
45
+ container: bool = True # False for a single record: no rules across records apply
46
+ passport: Record | None = None # the predicate, for passports
47
+ subjects: list[dict[str, str]] = field(default_factory=list) # statement subject digests
48
+ _by_id: dict[str, list[Entry]] = field(default_factory=dict, repr=False)
49
+
50
+ def __post_init__(self) -> None:
51
+ for entry in self.entries:
52
+ self._by_id.setdefault(entry.record["id"], []).append(entry)
53
+
54
+ @classmethod
55
+ def of(cls, document: Record) -> "Document":
56
+ if document.get("_type") == STATEMENT_TYPE:
57
+ predicate: Record = document["predicate"]
58
+ # Appendix B covers only the predicate, so the statement's subject is read defensively.
59
+ subject = document.get("subject")
60
+ subjects = [
61
+ s["digest"]
62
+ for s in (subject if isinstance(subject, list) else [])
63
+ if isinstance(s, dict) and isinstance(s.get("digest"), dict)
64
+ ]
65
+ entries = _entries(predicate["records"], "$.predicate.records")
66
+ return cls(entries, passport=predicate, subjects=subjects)
67
+ if document["type"] == "bundle":
68
+ return cls(_entries(document["records"], "$.records"))
69
+ return cls([Entry(document, "$")], container=False)
70
+
71
+ def resolve(self, reference: str) -> list[Entry]:
72
+ """Every record a reference names; more than one only for records in several states."""
73
+ base, _, rest = reference.partition("@")
74
+ version = rest.partition("#")[0]
75
+ candidates = self._by_id.get(base, [])
76
+ if version:
77
+ return [e for e in candidates if str(e.record.get("version")) == version]
78
+ return [e for e in candidates if "version" not in e.record]
79
+
80
+ def record(self, reference: str) -> Record | None:
81
+ """The record a reference names, or None if it does not resolve."""
82
+ found = self.resolve(reference)
83
+ return found[0].record if found else None
84
+
85
+ def of_type(self, kind: str) -> Iterator[Entry]:
86
+ return (entry for entry in self.entries if entry.record["type"] == kind)
87
+
88
+ def references(self) -> Iterator[Reference]:
89
+ """Every reference in the document, in document order."""
90
+ for entry in self.entries:
91
+ yield from _references(entry.record, REFERENCE_FIELDS[entry.record["type"]], entry.path)
92
+ if self.passport is not None:
93
+ yield from _references(self.passport, PASSPORT_REFERENCE_FIELDS, "$.predicate")
94
+
95
+
96
+ def _entries(records: list[Record], path: str) -> list[Entry]:
97
+ return [Entry(record, f"{path}[{index}]") for index, record in enumerate(records)]
98
+
99
+
100
+ def _references(record: Record, fields: tuple[str, ...], path: str) -> Iterator[Reference]:
101
+ for name in fields:
102
+ value = record.get(name)
103
+ if isinstance(value, str):
104
+ yield Reference(value, f"{path}.{name}")
105
+ elif isinstance(value, list):
106
+ for index, item in enumerate(value):
107
+ yield Reference(item, f"{path}.{name}[{index}]")
ois_validator/junit.py ADDED
@@ -0,0 +1,273 @@
1
+ """Level 1 adapter: turn JUnit XML reports into an OIS bundle (spec section 11.4).
2
+
3
+ The bundle holds what the quick start asks for: an actor for this adapter, an actor for the
4
+ existing test suite, one characterization intent in draft with a criterion per test case, an
5
+ artifact per test file and an evidence record per test result with derivedFrom unknown.
6
+
7
+ JUnit XML comes from the producer's own pipeline. It is parsed with the standard library, whose
8
+ Expat (2.4.1 and later) refuses entity expansion attacks and does not load external entities.
9
+ """
10
+
11
+ import hashlib
12
+ import re
13
+ import subprocess
14
+ import xml.etree.ElementTree as ElementTree
15
+ from dataclasses import dataclass
16
+ from datetime import UTC, datetime
17
+ from importlib.metadata import version as package_version
18
+ from pathlib import Path, PurePosixPath
19
+ from typing import Any
20
+ from urllib.parse import urlsplit, urlunsplit
21
+
22
+ Record = dict[str, Any]
23
+
24
+ RESULTS = ("pass", "skipped", "fail", "error") # from best to worst
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class JUnitReport:
29
+ content: bytes
30
+ written_at: datetime | None = None # file modification time, for timestamps without offset
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class Options:
35
+ namespace: str # URI under the producer's domain, e.g. https://ois.acme.example
36
+ suite: str # team or suite name; the test suite actor is named after it
37
+ repo: Path # Git working tree that holds the test files
38
+ location: str | None = None # defaults to git+<origin>@<HEAD>
39
+ run: str | None = None # link to the pipeline run
40
+ now: datetime | None = None
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class TestCase:
45
+ classname: str
46
+ name: str
47
+ file: str | None
48
+ result: str
49
+ observed_at: datetime
50
+ report: str # sha256 of the report the case comes from
51
+
52
+
53
+ def to_bundle(reports: list[JUnitReport], options: Options) -> Record:
54
+ """Build the bundle; the caller validates it before passing it on."""
55
+ now = (options.now or datetime.now(UTC)).astimezone(UTC)
56
+ ns = options.namespace.rstrip("/")
57
+ commit = _git(options.repo, "rev-parse", "HEAD")
58
+ location = options.location or location_of(options.repo)
59
+ tracked = [path for path in _git(options.repo, "ls-files", "-z").split("\0") if path]
60
+ cases = _dedupe([case for report in reports for case in _cases(report, now)])
61
+
62
+ adapter = _record(
63
+ "actor",
64
+ f"{ns}/actors/ois-junit",
65
+ now,
66
+ kind="tool",
67
+ name="ois-junit (JUnit XML adapter)",
68
+ version=package_version("ois-validator"),
69
+ )
70
+ suite = _record(
71
+ "actor",
72
+ f"{ns}/actors/{_slug(options.suite)}-tests",
73
+ now,
74
+ kind="tool",
75
+ name=f"{options.suite} test suite",
76
+ version="unknown",
77
+ )
78
+ adapter_ref = f"{adapter['id']}@{adapter['version']}"
79
+ suite_ref = f"{suite['id']}@{suite['version']}"
80
+
81
+ intent = _record(
82
+ "intent",
83
+ f"{ns}/intents/{_slug(options.suite)}-characterization",
84
+ now,
85
+ version=1,
86
+ title=f"Characterization of the {options.suite} test suite",
87
+ status="draft",
88
+ producedBy=adapter_ref,
89
+ criteria=[
90
+ {"key": _key(c), "statement": _statement(c), "notation": "text", "kind": "functional"}
91
+ for c in cases
92
+ ],
93
+ )
94
+
95
+ artifacts: dict[str, Record] = {}
96
+ evidence = []
97
+ for case in cases:
98
+ path = _test_file(tracked, case)
99
+ artifact = _artifact(ns, options.repo, commit, location, path, case, suite_ref, now)
100
+ artifacts.setdefault(artifact["id"], artifact)
101
+ record = _record(
102
+ "evidence",
103
+ f"{ns}/evidence/{case.report[:12]}-{_key(case)}",
104
+ now,
105
+ criterion=f"{intent['id']}@1#{_key(case)}",
106
+ test=artifact["id"],
107
+ derivedFrom="unknown",
108
+ result=case.result,
109
+ observedAt=_timestamp(case.observed_at),
110
+ recordedBy=adapter_ref,
111
+ )
112
+ if options.run:
113
+ record["run"] = options.run
114
+ evidence.append(record)
115
+
116
+ combined = hashlib.sha256("".join(c.report for c in cases).encode()).hexdigest()
117
+ records = [adapter, suite, intent, *artifacts.values(), *evidence]
118
+ return _record("bundle", f"{ns}/bundles/junit-{combined[:16]}", now, records=records)
119
+
120
+
121
+ def location_of(repo: Path) -> str:
122
+ """git+<origin URL>@<HEAD commit>, without credentials; scp-like remotes become ssh URIs."""
123
+ remote = _git(repo, "remote", "get-url", "origin")
124
+ commit = _git(repo, "rev-parse", "HEAD")
125
+ if "://" not in remote and (scp := re.fullmatch(r"(?:[^@/]+@)?([^:/]+):(.+)", remote)):
126
+ remote = f"ssh://{scp[1]}/{scp[2]}"
127
+ parts = urlsplit(remote)
128
+ if parts.scheme not in ("https", "http", "ssh", "git") or not parts.hostname:
129
+ raise ValueError(f"cannot turn remote {remote!r} into a location; pass --location")
130
+ host = parts.hostname + (f":{parts.port}" if parts.port else "")
131
+ return f"git+{urlunsplit((parts.scheme, host, parts.path, '', ''))}@{commit}"
132
+
133
+
134
+ def _cases(report: JUnitReport, now: datetime) -> list[TestCase]:
135
+ root = ElementTree.fromstring(report.content)
136
+ digest = hashlib.sha256(report.content).hexdigest()
137
+ cases = []
138
+ for suite in root.iter("testsuite"):
139
+ observed_at = _observed_at(suite.get("timestamp"), report.written_at or now)
140
+ for case in suite.findall("testcase"):
141
+ cases.append(
142
+ TestCase(
143
+ classname=case.get("classname", ""),
144
+ name=case.get("name", ""),
145
+ file=case.get("file"),
146
+ result=_result(case),
147
+ observed_at=observed_at,
148
+ report=digest,
149
+ )
150
+ )
151
+ return cases
152
+
153
+
154
+ def _result(case: ElementTree.Element) -> str:
155
+ if case.find("error") is not None:
156
+ return "error"
157
+ if case.find("failure") is not None:
158
+ return "fail"
159
+ if case.find("skipped") is not None:
160
+ return "skipped"
161
+ return "pass"
162
+
163
+
164
+ def _dedupe(cases: list[TestCase]) -> list[TestCase]:
165
+ """One case per test; a test that ran more than once keeps its worst result."""
166
+ worst: dict[str, TestCase] = {}
167
+ for case in cases:
168
+ known = worst.get(_key(case))
169
+ if known is None or RESULTS.index(case.result) > RESULTS.index(known.result):
170
+ worst[_key(case)] = case
171
+ return list(worst.values())
172
+
173
+
174
+ def _observed_at(timestamp: str | None, fallback: datetime) -> datetime:
175
+ """The suite's timestamp if it names an offset; JUnit often omits it, so otherwise the time
176
+ the report was written."""
177
+ if timestamp:
178
+ parsed = datetime.fromisoformat(timestamp)
179
+ if parsed.tzinfo is not None:
180
+ return parsed
181
+ return fallback
182
+
183
+
184
+ def _test_file(tracked: list[str], case: TestCase) -> str | None:
185
+ """The tracked file a test case comes from: its file attribute, or the longest prefix of its
186
+ class name that names exactly one file (com.acme.InvoiceTest -> .../com/acme/InvoiceTest.java,
187
+ tests.test_invoice -> tests/test_invoice.py)."""
188
+ if case.file and case.file in tracked:
189
+ return case.file
190
+ parts = case.classname.split(".")
191
+ for end in range(len(parts), 0, -1):
192
+ stem = "/".join(parts[:end])
193
+ matches = [p for p in tracked if _stem(p) == stem or _stem(p).endswith(f"/{stem}")]
194
+ if len(matches) == 1:
195
+ return matches[0]
196
+ return None
197
+
198
+
199
+ def _stem(path: str) -> str:
200
+ return str(PurePosixPath(path).with_suffix(""))
201
+
202
+
203
+ def _artifact(
204
+ ns: str,
205
+ repo: Path,
206
+ commit: str,
207
+ location: str,
208
+ path: str | None,
209
+ case: TestCase,
210
+ producer: str,
211
+ now: datetime,
212
+ ) -> Record:
213
+ if path is None:
214
+ # No file found: the artifact is the repository at that commit (section 6.4 allows
215
+ # gitCommit alone for artifacts located in Git).
216
+ anchor = hashlib.sha256(case.classname.encode()).hexdigest()[:8]
217
+ return _record(
218
+ "artifact",
219
+ f"{ns}/artifacts/test-{commit[:12]}-{anchor}",
220
+ now,
221
+ kind="test",
222
+ location=location,
223
+ digest={"gitCommit": commit},
224
+ producedBy=producer,
225
+ )
226
+ content = subprocess.run(
227
+ ["git", "-C", str(repo), "show", f"{commit}:{path}"], check=True, capture_output=True
228
+ ).stdout
229
+ sha256 = hashlib.sha256(content).hexdigest()
230
+ # The id follows path and content, so a changed test file is a new artifact (R10).
231
+ anchor = hashlib.sha256(f"{path}\n{sha256}".encode()).hexdigest()[:16]
232
+ return _record(
233
+ "artifact",
234
+ f"{ns}/artifacts/test-{anchor}",
235
+ now,
236
+ kind="test",
237
+ location=location,
238
+ path=path,
239
+ digest={"sha256": sha256, "gitCommit": commit},
240
+ producedBy=producer,
241
+ )
242
+
243
+
244
+ def _key(case: TestCase) -> str:
245
+ """Stable and valid for any test name: TC- and a hash of class and test name."""
246
+ return "TC-" + hashlib.sha256(f"{case.classname}::{case.name}".encode()).hexdigest()[:12]
247
+
248
+
249
+ def _statement(case: TestCase) -> str:
250
+ return f"{case.classname}.{case.name}" if case.classname else case.name
251
+
252
+
253
+ def _slug(text: str) -> str:
254
+ return re.sub(r"[^a-z0-9]+", "-", text.lower()).strip("-") or "suite"
255
+
256
+
257
+ def _record(type_: str, id_: str, now: datetime, **fields: Any) -> Record:
258
+ return {"ois": "0.1", "type": type_, "id": id_, "createdAt": _timestamp(now), **fields}
259
+
260
+
261
+ def _timestamp(moment: datetime) -> str:
262
+ return moment.astimezone(UTC).replace(microsecond=0).strftime("%Y-%m-%dT%H:%M:%SZ")
263
+
264
+
265
+ def _git(repo: Path, *args: str) -> str:
266
+ try:
267
+ return subprocess.run(
268
+ ["git", "-C", str(repo), *args], check=True, capture_output=True, text=True
269
+ ).stdout.strip()
270
+ except subprocess.CalledProcessError as error:
271
+ raise ValueError(
272
+ f"git {' '.join(args)} failed in {repo}: {error.stderr.strip()}"
273
+ ) from error
@@ -0,0 +1,76 @@
1
+ """Command line interface: ois-junit REPORT... --namespace URI --suite NAME"""
2
+
3
+ import argparse
4
+ import json
5
+ import sys
6
+ from datetime import UTC, datetime
7
+ from pathlib import Path
8
+ from xml.etree.ElementTree import ParseError
9
+
10
+ from ois_validator.junit import JUnitReport, Options, to_bundle
11
+ from ois_validator.validator import validate
12
+
13
+ EXIT_OK = 0
14
+ EXIT_INVALID = 1
15
+ EXIT_USAGE = 2
16
+
17
+
18
+ def main(argv: list[str] | None = None) -> int:
19
+ parser = argparse.ArgumentParser(
20
+ prog="ois-junit",
21
+ description="Turn JUnit XML reports into an OIS bundle (Level 1, spec section 11.4).",
22
+ )
23
+ parser.add_argument("reports", nargs="+", metavar="REPORT", help="JUnit XML file, or -")
24
+ parser.add_argument(
25
+ "--namespace",
26
+ required=True,
27
+ help="URI under your domain for record ids, e.g. https://ois.acme.example",
28
+ )
29
+ parser.add_argument("--suite", required=True, help="team or suite that owns the tests")
30
+ parser.add_argument("--repo", type=Path, default=Path("."), help="Git working tree (default .)")
31
+ parser.add_argument("--location", help="artifact location (default git+<origin>@<HEAD>)")
32
+ parser.add_argument("--run", help="URI of the pipeline run")
33
+ parser.add_argument("--output", type=Path, help="write the bundle here instead of stdout")
34
+ args = parser.parse_args(argv)
35
+
36
+ try:
37
+ reports = [_read(name) for name in args.reports]
38
+ options = Options(
39
+ namespace=args.namespace,
40
+ suite=args.suite,
41
+ repo=args.repo,
42
+ location=args.location,
43
+ run=args.run,
44
+ )
45
+ bundle = to_bundle(reports, options)
46
+ except (OSError, ParseError, ValueError) as error:
47
+ print(f"ois-junit: {error}", file=sys.stderr)
48
+ return EXIT_USAGE
49
+
50
+ result = validate(bundle)
51
+ if not result.conforms:
52
+ for report in result.reports:
53
+ print(
54
+ f"ois-junit: {report.level} [{report.rule}] {report.path}: {report.message}",
55
+ file=sys.stderr,
56
+ )
57
+ return EXIT_INVALID
58
+
59
+ text = json.dumps(bundle, indent=2, ensure_ascii=False) + "\n"
60
+ if args.output:
61
+ args.output.write_text(text, encoding="utf-8")
62
+ else:
63
+ sys.stdout.write(text)
64
+ return EXIT_OK
65
+
66
+
67
+ def _read(name: str) -> JUnitReport:
68
+ if name == "-":
69
+ return JUnitReport(sys.stdin.buffer.read())
70
+ path = Path(name)
71
+ written_at = datetime.fromtimestamp(path.stat().st_mtime, UTC)
72
+ return JUnitReport(path.read_bytes(), written_at)
73
+
74
+
75
+ if __name__ == "__main__":
76
+ sys.exit(main())
@@ -0,0 +1,30 @@
1
+ """Validation reports with the three levels of spec section 11.1."""
2
+
3
+ from dataclasses import dataclass, field
4
+ from enum import StrEnum
5
+
6
+
7
+ class Level(StrEnum):
8
+ VIOLATION = "violation" # a MUST is broken; the input does not conform
9
+ WARNING = "warning" # a rule asks for a report; the input still conforms
10
+ INFO = "info" # a SHOULD is not followed
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class Report:
15
+ level: Level
16
+ rule: str # "schema" for Appendix B, otherwise R1 to R15
17
+ message: str
18
+ path: str # JSONPath into the input document
19
+
20
+ def as_dict(self) -> dict[str, str]:
21
+ return {"level": self.level, "rule": self.rule, "message": self.message, "path": self.path}
22
+
23
+
24
+ @dataclass
25
+ class Result:
26
+ reports: list[Report] = field(default_factory=list)
27
+
28
+ @property
29
+ def conforms(self) -> bool:
30
+ return not any(r.level is Level.VIOLATION for r in self.reports)