validibot-shared 0.10.0__tar.gz → 0.12.0__tar.gz

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 (34) hide show
  1. {validibot_shared-0.10.0 → validibot_shared-0.12.0}/PKG-INFO +3 -2
  2. {validibot_shared-0.10.0 → validibot_shared-0.12.0}/pyproject.toml +3 -3
  3. validibot_shared-0.12.0/tests/test_schematron_envelopes.py +204 -0
  4. validibot_shared-0.12.0/tests/test_schematron_svrl.py +288 -0
  5. validibot_shared-0.12.0/validibot_shared/schematron/__init__.py +49 -0
  6. validibot_shared-0.12.0/validibot_shared/schematron/envelopes.py +296 -0
  7. validibot_shared-0.12.0/validibot_shared/schematron/svrl.py +277 -0
  8. {validibot_shared-0.10.0 → validibot_shared-0.12.0}/validibot_shared/validations/envelopes.py +1 -0
  9. {validibot_shared-0.10.0 → validibot_shared-0.12.0}/.gitignore +0 -0
  10. {validibot_shared-0.10.0 → validibot_shared-0.12.0}/LICENSE +0 -0
  11. {validibot_shared-0.10.0 → validibot_shared-0.12.0}/NOTICE +0 -0
  12. {validibot_shared-0.10.0 → validibot_shared-0.12.0}/README.md +0 -0
  13. {validibot_shared-0.10.0 → validibot_shared-0.12.0}/tests/__init__.py +0 -0
  14. {validibot_shared-0.10.0 → validibot_shared-0.12.0}/tests/test_energyplus_envelopes.py +0 -0
  15. {validibot_shared-0.10.0 → validibot_shared-0.12.0}/tests/test_energyplus_models.py +0 -0
  16. {validibot_shared-0.10.0 → validibot_shared-0.12.0}/tests/test_evidence_manifest.py +0 -0
  17. {validibot_shared-0.10.0 → validibot_shared-0.12.0}/tests/test_fmu_envelopes.py +0 -0
  18. {validibot_shared-0.10.0 → validibot_shared-0.12.0}/tests/test_fmu_models.py +0 -0
  19. {validibot_shared-0.10.0 → validibot_shared-0.12.0}/tests/test_package_init.py +0 -0
  20. {validibot_shared-0.10.0 → validibot_shared-0.12.0}/tests/test_shacl_envelopes.py +0 -0
  21. {validibot_shared-0.10.0 → validibot_shared-0.12.0}/tests/test_validations_envelopes.py +0 -0
  22. {validibot_shared-0.10.0 → validibot_shared-0.12.0}/validibot_shared/__init__.py +0 -0
  23. {validibot_shared-0.10.0 → validibot_shared-0.12.0}/validibot_shared/energyplus/__init__.py +0 -0
  24. {validibot_shared-0.10.0 → validibot_shared-0.12.0}/validibot_shared/energyplus/envelopes.py +0 -0
  25. {validibot_shared-0.10.0 → validibot_shared-0.12.0}/validibot_shared/energyplus/models.py +0 -0
  26. {validibot_shared-0.10.0 → validibot_shared-0.12.0}/validibot_shared/evidence/__init__.py +0 -0
  27. {validibot_shared-0.10.0 → validibot_shared-0.12.0}/validibot_shared/evidence/manifest.py +0 -0
  28. {validibot_shared-0.10.0 → validibot_shared-0.12.0}/validibot_shared/fmu/__init__.py +0 -0
  29. {validibot_shared-0.10.0 → validibot_shared-0.12.0}/validibot_shared/fmu/envelopes.py +0 -0
  30. {validibot_shared-0.10.0 → validibot_shared-0.12.0}/validibot_shared/fmu/models.py +0 -0
  31. {validibot_shared-0.10.0 → validibot_shared-0.12.0}/validibot_shared/py.typed +0 -0
  32. {validibot_shared-0.10.0 → validibot_shared-0.12.0}/validibot_shared/shacl/__init__.py +0 -0
  33. {validibot_shared-0.10.0 → validibot_shared-0.12.0}/validibot_shared/shacl/envelopes.py +0 -0
  34. {validibot_shared-0.10.0 → validibot_shared-0.12.0}/validibot_shared/validations/__init__.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: validibot-shared
3
- Version: 0.10.0
3
+ Version: 0.12.0
4
4
  Summary: Shared library for data interchange between Validibot and validator containers
5
5
  Project-URL: Homepage, https://validibot.com
6
6
  Project-URL: Documentation, https://docs.validibot.com
@@ -24,10 +24,11 @@ Classifier: Programming Language :: Python :: 3.13
24
24
  Classifier: Topic :: Scientific/Engineering
25
25
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
26
26
  Requires-Python: >=3.10
27
+ Requires-Dist: defusedxml<0.8,>=0.7.1
27
28
  Requires-Dist: pydantic<3.0,>=2.13
28
29
  Provides-Extra: dev
29
30
  Requires-Dist: pytest==9.1.1; extra == 'dev'
30
- Requires-Dist: ruff==0.15.18; extra == 'dev'
31
+ Requires-Dist: ruff==0.15.20; extra == 'dev'
31
32
  Description-Content-Type: text/markdown
32
33
 
33
34
  <div align="center">
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "validibot-shared"
7
- version = "0.10.0"
7
+ version = "0.12.0"
8
8
  description = "Shared library for data interchange between Validibot and validator containers"
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
@@ -34,10 +34,10 @@ classifiers = [
34
34
  "Topic :: Software Development :: Libraries :: Python Modules",
35
35
  "Topic :: Scientific/Engineering",
36
36
  ]
37
- dependencies = ["pydantic>=2.13,<3.0"]
37
+ dependencies = ["pydantic>=2.13,<3.0", "defusedxml>=0.7.1,<0.8"]
38
38
 
39
39
  [project.optional-dependencies]
40
- dev = ["pytest==9.1.1", "ruff==0.15.18"]
40
+ dev = ["pytest==9.1.1", "ruff==0.15.20"]
41
41
 
42
42
  [project.urls]
43
43
  Homepage = "https://validibot.com"
@@ -0,0 +1,204 @@
1
+ """Tests for the Schematron container contract (ADR-2026-07-01, D4b/D9).
2
+
3
+ The Schematron validator ships the author's rules **inline as text** (the
4
+ SHACL ``shapes_text`` pattern — the container compiles them itself), and its
5
+ outputs carry an ``engine_status`` failure taxonomy where ``passed`` is
6
+ *tri-state* — True/False when the engine ran, ``None`` (unknown) when it
7
+ could not. These tests pin those contract properties so a refactor cannot
8
+ silently weaken them: the Django side and the validator backend both program
9
+ against exactly this shape.
10
+ """
11
+
12
+ import pytest
13
+ from pydantic import ValidationError
14
+
15
+ from validibot_shared.schematron.envelopes import (
16
+ ENGINE_ERROR_RULES_INVALID,
17
+ ENGINE_STATUS_OK,
18
+ ENGINE_STATUS_TIMEOUT,
19
+ SchematronFinding,
20
+ SchematronInputEnvelope,
21
+ SchematronInputs,
22
+ SchematronOutputEnvelope,
23
+ SchematronOutputs,
24
+ build_schematron_input_envelope,
25
+ )
26
+ from validibot_shared.validations.envelopes import (
27
+ SupportedMimeType,
28
+ ValidationStatus,
29
+ ValidatorType,
30
+ )
31
+
32
+ # Test constants to avoid magic values
33
+ DEFAULT_MAX_FINDINGS = 500
34
+ DEFAULT_XSLT_TIMEOUT = 60
35
+ TEST_ERROR_COUNT = 3
36
+ RULES_SHA = "b" * 64
37
+
38
+ SCH_SOURCE = (
39
+ '<schema xmlns="http://purl.oclc.org/dsdl/schematron">'
40
+ "<pattern><rule context='/'><assert test='true()'>ok</assert></rule>"
41
+ "</pattern></schema>"
42
+ )
43
+
44
+
45
+ class _ValidatorStub:
46
+ """Duck-typed validator (id/validation_type/version) for the builder."""
47
+
48
+ id = "val-1"
49
+ validation_type = "SCHEMATRON"
50
+ version = "1"
51
+
52
+
53
+ def _inputs(**overrides) -> SchematronInputs:
54
+ base = {
55
+ "schematron_text": SCH_SOURCE,
56
+ "schematron_sha256": RULES_SHA,
57
+ }
58
+ base.update(overrides)
59
+ return SchematronInputs(**base)
60
+
61
+
62
+ def test_inputs_carry_inline_rules_and_limit_defaults():
63
+ """Inputs ship the rules inline with provenance sha and D8 defaults.
64
+
65
+ Inline text is the whole delivery model (no staging, no artefact URIs):
66
+ the container gets everything it needs from the envelope alone, exactly
67
+ like SHACL's shapes_text.
68
+ """
69
+ inputs = _inputs()
70
+
71
+ assert inputs.schematron_text == SCH_SOURCE
72
+ assert inputs.schematron_sha256 == RULES_SHA
73
+ assert inputs.max_findings == DEFAULT_MAX_FINDINGS
74
+ assert inputs.xslt_timeout_seconds == DEFAULT_XSLT_TIMEOUT
75
+
76
+
77
+ def test_inputs_require_the_rules_text():
78
+ """Omitting schematron_text is a validation error, not a default.
79
+
80
+ An input envelope without rules would force the container to run
81
+ nothing and report... something. Refuse at the contract layer.
82
+ """
83
+ with pytest.raises(ValidationError):
84
+ SchematronInputs()
85
+
86
+
87
+ def test_outputs_default_to_unknown_passed_not_false():
88
+ """``passed`` defaults to None — unknown, not failed (D9).
89
+
90
+ A default of False would make an unpopulated envelope read as "the
91
+ document failed the rules"; None forces every consumer to distinguish
92
+ "engine didn't run" from "rules failed".
93
+ """
94
+ outputs = SchematronOutputs()
95
+ assert outputs.passed is None
96
+ assert outputs.engine_status == ENGINE_STATUS_OK
97
+
98
+
99
+ def test_engine_failure_shapes_round_trip():
100
+ """Timeout and rules-invalid outputs serialize and re-validate losslessly.
101
+
102
+ The callback path deserializes output.json with this model; the D9
103
+ fields must survive the JSON round trip exactly, including the
104
+ machine hint that lets Django distinguish "your rules don't compile"
105
+ from a generic engine error.
106
+ """
107
+ timeout = SchematronOutputs(
108
+ engine_status=ENGINE_STATUS_TIMEOUT,
109
+ engine_message="Transform exceeded 60s",
110
+ passed=None,
111
+ )
112
+ invalid_rules = SchematronOutputs(
113
+ engine_status="error",
114
+ engine_error_code=ENGINE_ERROR_RULES_INVALID,
115
+ engine_message="Schematron failed to compile: unexpected element",
116
+ passed=None,
117
+ )
118
+ for outputs, status in (
119
+ (timeout, ValidationStatus.FAILED_RUNTIME),
120
+ (invalid_rules, ValidationStatus.FAILED_RUNTIME),
121
+ ):
122
+ envelope = SchematronOutputEnvelope(
123
+ run_id="run-1",
124
+ validator={"id": "v1", "type": ValidatorType.SCHEMATRON, "version": "1"},
125
+ status=status,
126
+ timing={},
127
+ outputs=outputs,
128
+ )
129
+ restored = SchematronOutputEnvelope.model_validate(
130
+ envelope.model_dump(mode="json"),
131
+ )
132
+ assert restored.outputs.engine_status == outputs.engine_status
133
+ assert restored.outputs.engine_error_code == outputs.engine_error_code
134
+ assert restored.outputs.passed is None
135
+
136
+
137
+ def test_findings_map_and_provenance_round_trip():
138
+ """Findings keep native ids/locations; provenance keeps the rules sha."""
139
+ outputs = SchematronOutputs(
140
+ engine_status=ENGINE_STATUS_OK,
141
+ passed=False,
142
+ error_count=TEST_ERROR_COUNT,
143
+ finding_rule_ids_by_severity={"BR-CO-15": "ERROR", "BR-05": "WARNING"},
144
+ findings=[
145
+ SchematronFinding(
146
+ rule_id="BR-CO-15",
147
+ message="Totals must reconcile.",
148
+ severity="ERROR",
149
+ location_xpath="/Invoice/LegalMonetaryTotal",
150
+ flag="fatal",
151
+ ),
152
+ ],
153
+ schematron_sha256=RULES_SHA,
154
+ query_binding="xslt2",
155
+ engine="SaxonC-HE 12.9",
156
+ )
157
+
158
+ restored = SchematronOutputs.model_validate(outputs.model_dump(mode="json"))
159
+ assert restored.finding_rule_ids_by_severity["BR-CO-15"] == "ERROR"
160
+ assert restored.findings[0].rule_id == "BR-CO-15"
161
+ assert restored.findings[0].location_xpath == "/Invoice/LegalMonetaryTotal"
162
+ assert restored.schematron_sha256 == RULES_SHA
163
+ assert restored.query_binding == "xslt2"
164
+
165
+
166
+ def test_outputs_forbid_unknown_fields():
167
+ """extra="forbid" holds — a typo'd field fails loudly, not silently.
168
+
169
+ Contract models must reject unknown keys so a mismatched backend/Django
170
+ version pair surfaces as an explicit error instead of dropped data.
171
+ This also guards the 0.11 → 0.12 break: old pack_* fields are refused.
172
+ """
173
+ with pytest.raises(ValidationError):
174
+ SchematronOutputs(engine_status="ok", pack_id="stale-field")
175
+
176
+
177
+ def test_build_schematron_input_envelope_assembles_the_xml_submission():
178
+ """The builder produces a valid envelope with an XML primary input.
179
+
180
+ Pins the file-item conventions (name/mime/role) the container reads and
181
+ that ``ValidatorType.SCHEMATRON`` exists — the builder would crash
182
+ without the enum member.
183
+ """
184
+ envelope = build_schematron_input_envelope(
185
+ run_id="run-1",
186
+ validator=_ValidatorStub(),
187
+ org_id="org-1",
188
+ org_name="ValidiBot",
189
+ workflow_id="wf-1",
190
+ step_id="step-1",
191
+ step_name="Peppol rules",
192
+ submission_uri="gs://bucket/run-1/submission.xml",
193
+ inputs=_inputs(),
194
+ callback_url="https://example.com/callback",
195
+ execution_bundle_uri="gs://bucket/run-1/",
196
+ )
197
+
198
+ assert isinstance(envelope, SchematronInputEnvelope)
199
+ assert envelope.validator.type == ValidatorType.SCHEMATRON
200
+ file_item = envelope.input_files[0]
201
+ assert file_item.name == "submission.xml"
202
+ assert file_item.mime_type == SupportedMimeType.APPLICATION_XML
203
+ assert file_item.role == "primary-model"
204
+ assert envelope.inputs.schematron_text == SCH_SOURCE
@@ -0,0 +1,288 @@
1
+ """Unit tests for the canonical SVRL parser (ADR-2026-07-01, D3/D10).
2
+
3
+ ``validibot_shared.schematron.svrl`` is the pure SVRL → findings/summary
4
+ parser both consumers rely on — the validator backend container (parsing
5
+ Saxon's SVRL into ``SchematronOutputs``) and the Django app (fixture and
6
+ round-trip tests). These tests feed it canned SVRL documents and pin the
7
+ D3/D10 contract:
8
+
9
+ - ``svrl:failed-assert`` AND ``svrl:successful-report`` are active findings
10
+ handled identically — element type never drives severity.
11
+ - Severity resolves ``@flag`` → ``@role`` → fail-closed ERROR, so nothing
12
+ publisher-authored is silently downgraded.
13
+ - ``svrl:fired-rule`` counts rules/contexts evaluated, never assertions.
14
+ - The ``finding_rule_ids_by_severity`` map is the pinned CEL contract
15
+ ({rule_id: severity}, most severe wins).
16
+ - Truncation keeps ERROR findings first and is always explicit, while the
17
+ aggregate counts still reflect the FULL totals.
18
+
19
+ Different Schematron compilers emit slightly different SVRL dialects, so the
20
+ parser must be defensive — the un-namespaced-SVRL test guards that.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import pytest
26
+
27
+ from validibot_shared.schematron.svrl import (
28
+ SEVERITY_ERROR,
29
+ SEVERITY_INFO,
30
+ SEVERITY_WARNING,
31
+ SvrlParseError,
32
+ parse_svrl,
33
+ )
34
+
35
+ # Named test values (avoid magic literals in assertions).
36
+ TRUNCATION_CAP = 2
37
+ TOTAL_FINDINGS_FOR_TRUNCATION = 5
38
+ EXPECTED_SUPPRESSED = TOTAL_FINDINGS_FOR_TRUNCATION - TRUNCATION_CAP
39
+ EXPECTED_FIRED_RULES = 2
40
+ ERRORS_IN_TRUNCATION_BODY = 2
41
+ WARNINGS_IN_TRUNCATION_BODY = 2
42
+
43
+
44
+ def _svrl(body: str) -> str:
45
+ """Wrap finding elements in a minimal schematron-output document."""
46
+ return (
47
+ '<svrl:schematron-output xmlns:svrl="http://purl.oclc.org/dsdl/svrl">'
48
+ f"{body}"
49
+ "</svrl:schematron-output>"
50
+ )
51
+
52
+
53
+ def _failed_assert(
54
+ *,
55
+ rule_id: str = "VB-X",
56
+ flag: str = "",
57
+ role: str = "",
58
+ location: str = "/Invoice",
59
+ text: str = "Something failed.",
60
+ ) -> str:
61
+ attrs = f' id="{rule_id}" location="{location}"'
62
+ if flag:
63
+ attrs += f' flag="{flag}"'
64
+ if role:
65
+ attrs += f' role="{role}"'
66
+ return (
67
+ f"<svrl:failed-assert{attrs}><svrl:text>{text}</svrl:text></svrl:failed-assert>"
68
+ )
69
+
70
+
71
+ # ── Severity resolution: @flag → @role → fail-closed ERROR ──────────────────
72
+
73
+
74
+ def test_failed_assert_flag_fatal_maps_to_error():
75
+ """flag="fatal" resolves to ERROR and id/location survive the mapping.
76
+
77
+ The rule id and location are the whole value proposition (D10): they make
78
+ findings actionable and cross-referenceable against the published rules.
79
+ """
80
+ summary = parse_svrl(
81
+ _svrl(
82
+ _failed_assert(
83
+ rule_id="VB-CO-15",
84
+ flag="fatal",
85
+ location="/Invoice/LegalMonetaryTotal",
86
+ ),
87
+ ),
88
+ )
89
+
90
+ assert summary.error_count == 1
91
+ assert not summary.passed
92
+ finding = summary.findings[0]
93
+ assert finding.rule_id == "VB-CO-15"
94
+ assert finding.severity == SEVERITY_ERROR
95
+ assert finding.location == "/Invoice/LegalMonetaryTotal"
96
+ assert finding.flag == "fatal"
97
+
98
+
99
+ def test_successful_report_is_an_active_finding_handled_identically():
100
+ """A svrl:successful-report with flag="fatal" is an ERROR finding.
101
+
102
+ This is the D3 rule the naive implementation gets wrong: a ``<report>``
103
+ can carry a publisher-authored *error*, so the element type must never
104
+ drive severity — only @flag/@role do.
105
+ """
106
+ summary = parse_svrl(
107
+ _svrl(
108
+ '<svrl:successful-report id="VB-R-01" flag="fatal" location="/a">'
109
+ "<svrl:text>Reported error.</svrl:text>"
110
+ "</svrl:successful-report>",
111
+ ),
112
+ )
113
+
114
+ assert summary.error_count == 1
115
+ assert summary.findings[0].severity == SEVERITY_ERROR
116
+ assert summary.findings[0].element == "successful-report"
117
+
118
+
119
+ def test_role_is_the_fallback_when_flag_is_absent():
120
+ """@role maps warning→WARNING and information→INFO when @flag is missing.
121
+
122
+ The official packs use both spellings families (warn/warning,
123
+ info/information); the resolver accepts them all.
124
+ """
125
+ summary = parse_svrl(
126
+ _svrl(
127
+ _failed_assert(rule_id="VB-W", role="warning")
128
+ + _failed_assert(rule_id="VB-I", role="information"),
129
+ ),
130
+ )
131
+
132
+ by_id = {f.rule_id: f for f in summary.findings}
133
+ assert by_id["VB-W"].severity == SEVERITY_WARNING
134
+ assert by_id["VB-I"].severity == SEVERITY_INFO
135
+ assert summary.warning_count == 1
136
+ assert summary.info_count == 1
137
+ # Warnings/info alone never fail a Schematron step (D3).
138
+ assert summary.passed
139
+
140
+
141
+ def test_flag_wins_over_role():
142
+ """When both attributes exist, @flag is authoritative (D3 resolution order)."""
143
+ summary = parse_svrl(
144
+ _svrl(_failed_assert(rule_id="VB-B", flag="fatal", role="warning")),
145
+ )
146
+ assert summary.findings[0].severity == SEVERITY_ERROR
147
+
148
+
149
+ def test_fail_closed_to_error_when_neither_flag_nor_role_present():
150
+ """A finding with no severity attributes fail-closes to ERROR.
151
+
152
+ Fail-open (defaulting to INFO) would silently downgrade publisher-authored
153
+ rules whose pack relies on a default phase severity — the exact bug class
154
+ D3 forbids.
155
+ """
156
+ summary = parse_svrl(_svrl(_failed_assert(rule_id="VB-N")))
157
+ assert summary.findings[0].severity == SEVERITY_ERROR
158
+ assert summary.error_count == 1
159
+
160
+
161
+ # ── Message text extraction ──────────────────────────────────────────────────
162
+
163
+
164
+ def test_message_text_collapses_whitespace_and_inline_markup():
165
+ """svrl:text content survives inline markup and layout whitespace.
166
+
167
+ Publisher messages contain inline elements (emph/span) and authored
168
+ newlines; the human-readable message must come out flat and readable.
169
+ """
170
+ summary = parse_svrl(
171
+ _svrl(
172
+ '<svrl:failed-assert id="VB-T" flag="fatal" location="/a">'
173
+ "<svrl:text>Total \n must <emph>equal</emph>\n sum.</svrl:text>"
174
+ "</svrl:failed-assert>",
175
+ ),
176
+ )
177
+ assert summary.findings[0].message == "Total must equal sum."
178
+
179
+
180
+ # ── fired-rule counting ──────────────────────────────────────────────────────
181
+
182
+
183
+ def test_fired_rule_count_counts_rules_not_assertions():
184
+ """svrl:fired-rule elements count evaluated rules/contexts — nothing else.
185
+
186
+ The review explicitly renamed this signal away from "assertion count":
187
+ a fired rule is a context the engine evaluated, not an assertion that
188
+ failed. Two fired rules + one failed assert must yield 2 / 1.
189
+ """
190
+ summary = parse_svrl(
191
+ _svrl(
192
+ '<svrl:fired-rule context="/Invoice"/>'
193
+ '<svrl:fired-rule context="/Invoice/Total"/>'
194
+ + _failed_assert(rule_id="VB-1", flag="fatal"),
195
+ ),
196
+ )
197
+ assert summary.fired_rule_count == EXPECTED_FIRED_RULES
198
+ assert summary.error_count == 1
199
+
200
+
201
+ # ── The finding_rule_ids_by_severity map (CEL contract) ─────────────────────
202
+
203
+
204
+ def test_rule_id_map_shape_and_most_severe_wins():
205
+ """The map is {rule_id: severity} and the most severe occurrence wins.
206
+
207
+ This is the pinned D2 CEL contract: key membership tests
208
+ (``"VB-D" in o.finding_rule_ids_by_severity``) and severity-aware gates
209
+ both depend on this exact shape. A rule firing at WARNING in one context
210
+ and ERROR in another must surface as ERROR.
211
+ """
212
+ summary = parse_svrl(
213
+ _svrl(
214
+ _failed_assert(rule_id="VB-D", role="warning")
215
+ + _failed_assert(rule_id="VB-D", flag="fatal")
216
+ + _failed_assert(rule_id="VB-W", role="warning"),
217
+ ),
218
+ )
219
+ assert summary.finding_rule_ids_by_severity == {
220
+ "VB-D": SEVERITY_ERROR,
221
+ "VB-W": SEVERITY_WARNING,
222
+ }
223
+
224
+
225
+ # ── Truncation (D10: capped, never silent) ───────────────────────────────────
226
+
227
+
228
+ def test_truncation_keeps_errors_first_and_counts_stay_full():
229
+ """Capping keeps ERROR findings first and records the suppressed count.
230
+
231
+ Two invariants: (1) the kept findings are ordered ERROR → WARNING → INFO
232
+ so the most actionable rows survive; (2) the aggregate counts reflect the
233
+ FULL document, so "clean-ish" and "thousands of errors, capped" can never
234
+ look the same to a CEL gate.
235
+ """
236
+ body = (
237
+ _failed_assert(rule_id="VB-I1", role="info")
238
+ + _failed_assert(rule_id="VB-W1", role="warning")
239
+ + _failed_assert(rule_id="VB-E1", flag="fatal")
240
+ + _failed_assert(rule_id="VB-E2", flag="error")
241
+ + _failed_assert(rule_id="VB-W2", role="warning")
242
+ )
243
+ summary = parse_svrl(_svrl(body), max_findings=TRUNCATION_CAP)
244
+
245
+ assert summary.findings_truncated
246
+ assert summary.findings_suppressed_count == EXPECTED_SUPPRESSED
247
+ assert len(summary.findings) == TRUNCATION_CAP
248
+ # ERROR findings survive the cap ahead of warnings/info.
249
+ assert {f.rule_id for f in summary.findings} == {"VB-E1", "VB-E2"}
250
+ # Aggregates are computed pre-cap.
251
+ assert summary.error_count == ERRORS_IN_TRUNCATION_BODY
252
+ assert summary.warning_count == WARNINGS_IN_TRUNCATION_BODY
253
+ assert summary.info_count == 1
254
+ assert len(summary.finding_rule_ids_by_severity) == TOTAL_FINDINGS_FOR_TRUNCATION
255
+
256
+
257
+ # ── Dialect tolerance + input guards ─────────────────────────────────────────
258
+
259
+
260
+ def test_unnamespaced_svrl_is_tolerated():
261
+ """SVRL without the namespace still parses (compiler-dialect defensiveness).
262
+
263
+ Some Schematron toolchains emit un-namespaced SVRL; the parser matches on
264
+ local names so those reports still map to findings.
265
+ """
266
+ summary = parse_svrl(
267
+ "<schematron-output>"
268
+ '<fired-rule context="/a"/>'
269
+ '<failed-assert id="VB-U" flag="fatal" location="/a">'
270
+ "<text>Unnamespaced.</text>"
271
+ "</failed-assert>"
272
+ "</schematron-output>",
273
+ )
274
+ assert summary.fired_rule_count == 1
275
+ assert summary.findings[0].rule_id == "VB-U"
276
+
277
+
278
+ def test_empty_and_malformed_svrl_raise():
279
+ """Empty or non-XML input raises SvrlParseError rather than passing.
280
+
281
+ Silently returning an empty summary for garbage input would read as
282
+ "zero findings" — i.e. a pass — which is exactly the fail-open behaviour
283
+ the D9 taxonomy exists to prevent.
284
+ """
285
+ with pytest.raises(SvrlParseError):
286
+ parse_svrl("")
287
+ with pytest.raises(SvrlParseError):
288
+ parse_svrl("this is not XML <")
@@ -0,0 +1,49 @@
1
+ """Schematron Advanced validator envelopes and models."""
2
+
3
+ from validibot_shared.schematron.envelopes import (
4
+ ENGINE_ERROR_BACKEND_UNAVAILABLE,
5
+ ENGINE_ERROR_RULES_INVALID,
6
+ ENGINE_STATUS_ERROR,
7
+ ENGINE_STATUS_OK,
8
+ ENGINE_STATUS_TIMEOUT,
9
+ QUERY_BINDING_XSLT1,
10
+ QUERY_BINDING_XSLT2,
11
+ SchematronFinding,
12
+ SchematronInputEnvelope,
13
+ SchematronInputs,
14
+ SchematronOutputEnvelope,
15
+ SchematronOutputs,
16
+ build_schematron_input_envelope,
17
+ )
18
+ from validibot_shared.schematron.svrl import (
19
+ SEVERITY_ERROR,
20
+ SEVERITY_INFO,
21
+ SEVERITY_WARNING,
22
+ SvrlFinding,
23
+ SvrlParseError,
24
+ SvrlSummary,
25
+ parse_svrl,
26
+ )
27
+
28
+ __all__ = [
29
+ "ENGINE_ERROR_BACKEND_UNAVAILABLE",
30
+ "ENGINE_ERROR_RULES_INVALID",
31
+ "ENGINE_STATUS_ERROR",
32
+ "ENGINE_STATUS_OK",
33
+ "ENGINE_STATUS_TIMEOUT",
34
+ "QUERY_BINDING_XSLT1",
35
+ "QUERY_BINDING_XSLT2",
36
+ "SEVERITY_ERROR",
37
+ "SEVERITY_INFO",
38
+ "SEVERITY_WARNING",
39
+ "SchematronFinding",
40
+ "SchematronInputEnvelope",
41
+ "SchematronInputs",
42
+ "SchematronOutputEnvelope",
43
+ "SchematronOutputs",
44
+ "SvrlFinding",
45
+ "SvrlParseError",
46
+ "SvrlSummary",
47
+ "build_schematron_input_envelope",
48
+ "parse_svrl",
49
+ ]
@@ -0,0 +1,296 @@
1
+ """
2
+ Pydantic envelopes for the Schematron Advanced validator backend.
3
+
4
+ The Schematron validator is an Advanced validator — it runs in an isolated
5
+ container/Cloud Run Job rather than inside the Django worker. Schematron
6
+ rules compile to XSLT (a full programming language), so author-uploaded
7
+ rules are executable code and only ever run inside the sandboxed container:
8
+ no database, no secrets, no network egress, locked-down engine
9
+ (ADR-2026-07-01, decisions D4/D8).
10
+
11
+ These schemas define the contract between Django and the Schematron
12
+ container:
13
+
14
+ - **Input envelope**: the XML submission (as an ``InputFileItem`` URI) plus
15
+ the author's Schematron rules **inline as text** — exactly how SHACL
16
+ ships its merged shapes text. Django resolves the rules from the step's
17
+ ``Ruleset`` before dispatch; the container compiles them (SchXslt2 →
18
+ XSLT, baked into the image as fixed tooling) and runs the result over the
19
+ submission. The D8 resource limits ride along, already clamped
20
+ Django-side; the container re-clamps defensively.
21
+ - **Output envelope**: the parsed SVRL summary — per-severity counts, the
22
+ ``finding_rule_ids_by_severity`` map, structured findings preserving
23
+ native rule ids/locations — plus ``engine_status`` (D9: findings are only
24
+ meaningful when the engine actually ran) and provenance (the sha256 of
25
+ the executed rules, the detected query binding, and the engine that ran).
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ from pydantic import BaseModel, Field
31
+
32
+ from validibot_shared.validations.envelopes import (
33
+ ExecutionContext,
34
+ InputFileItem,
35
+ SupportedMimeType,
36
+ ValidationInputEnvelope,
37
+ ValidationOutputEnvelope,
38
+ ValidatorInfo,
39
+ ValidatorType,
40
+ )
41
+
42
+ # ── D9 engine status: "couldn't run the rules" ≠ "the rules failed" ────────
43
+ # ``passed``/findings on the outputs are only meaningful when the engine
44
+ # status is OK. On error/timeout, Django surfaces a single reserved
45
+ # infrastructure finding and never synthesises rule findings.
46
+ ENGINE_STATUS_OK = "ok"
47
+ ENGINE_STATUS_ERROR = "error"
48
+ ENGINE_STATUS_TIMEOUT = "timeout"
49
+
50
+ # Machine hints for ``SchematronOutputs.engine_error_code`` — Django maps
51
+ # these to its reserved ``schematron.*`` finding codes.
52
+ #
53
+ # ``rules_invalid``: the author's uploaded Schematron failed to compile.
54
+ # That's a workflow-authoring problem, not a fact about the submitted
55
+ # document — the submitter's run still reads "the check couldn't run".
56
+ ENGINE_ERROR_RULES_INVALID = "rules_invalid"
57
+ ENGINE_ERROR_BACKEND_UNAVAILABLE = "backend_unavailable"
58
+
59
+ # Schematron query bindings (declared by the .sch root's ``queryBinding``
60
+ # attribute; the container detects and echoes it for provenance).
61
+ QUERY_BINDING_XSLT1 = "xslt1"
62
+ QUERY_BINDING_XSLT2 = "xslt2"
63
+
64
+
65
+ class SchematronInputs(BaseModel):
66
+ """Schematron run configuration resolved by Django for the container.
67
+
68
+ Everything the container needs without database access: the author's
69
+ rules inline (the SHACL ``shapes_text`` pattern — a ``.sch`` is a text
70
+ document, typically tens to hundreds of KB) and the D8 resource limits
71
+ (already clamped Django-side; re-clamped in the container).
72
+ """
73
+
74
+ schematron_text: str = Field(
75
+ description=(
76
+ "The Schematron source (.sch) to compile and run, resolved from "
77
+ "the step's Ruleset by Django. The container compiles it with "
78
+ "the SchXslt2 transpiler baked into the image."
79
+ ),
80
+ )
81
+ schematron_sha256: str = Field(
82
+ default="",
83
+ description=(
84
+ "sha256 of schematron_text, computed by Django at dispatch — "
85
+ "the provenance identity of the rules this run executed."
86
+ ),
87
+ )
88
+
89
+ # ── D8 resource limits (clamped Django-side; re-clamped in container) ──
90
+ max_input_bytes: int = Field(default=10_000_000, gt=0)
91
+ max_input_depth: int = Field(default=200, gt=0)
92
+ xslt_timeout_seconds: int = Field(default=60, gt=0)
93
+ max_memory_mb: int = Field(default=512, gt=0)
94
+ max_findings: int = Field(default=500, gt=0)
95
+
96
+ model_config = {"extra": "forbid"}
97
+
98
+
99
+ class SchematronFinding(BaseModel):
100
+ """One active SVRL finding with the detail Django needs for D10.
101
+
102
+ Both ``svrl:failed-assert`` and ``svrl:successful-report`` entries are
103
+ active findings (a ``<report>`` can carry a publisher-authored error).
104
+ ``rule_id`` is the rule's native identifier (``BR-CO-15``,
105
+ ``PEPPOL-EN16931-R010``) and becomes ``ValidationFinding.code`` in
106
+ Django; ``location_xpath`` preserves the SVRL ``@location`` so the
107
+ finding can point at the offending element. ``flag``/``role`` carry the
108
+ raw SVRL attributes for provenance (severity was resolved from them via
109
+ the @flag → @role → fail-closed-ERROR chain, D3).
110
+ """
111
+
112
+ rule_id: str = Field(default="", description="Native rule id (@id).")
113
+ message: str = Field(description="Human-readable finding text.")
114
+ severity: str = Field(description="ERROR | WARNING | INFO (resolved).")
115
+ location_xpath: str = Field(
116
+ default="",
117
+ description="SVRL @location XPath into the submitted document.",
118
+ )
119
+ flag: str = Field(default="", description="Raw SVRL @flag attribute.")
120
+ role: str = Field(default="", description="Raw SVRL @role attribute.")
121
+
122
+ model_config = {"extra": "forbid"}
123
+
124
+
125
+ class SchematronOutputs(BaseModel):
126
+ """Schematron results: engine status, SVRL summary, and provenance.
127
+
128
+ The signal fields (``passed`` … ``engine``) mirror the catalog entries in
129
+ the Django Schematron ``ValidatorConfig`` — Django's
130
+ ``extract_output_signals`` pulls exactly those keys for CEL assertion
131
+ evaluation.
132
+
133
+ D9 contract: when ``engine_status != "ok"`` the run never evaluated the
134
+ rules — ``passed`` is ``None`` (*unknown*, not failed), the counts are
135
+ meaningless, and ``finding_rule_ids_by_severity``/``findings`` stay
136
+ empty. Django surfaces one reserved infrastructure finding instead.
137
+ """
138
+
139
+ # ── D9 engine status ──
140
+ engine_status: str = Field(
141
+ default=ENGINE_STATUS_OK,
142
+ description="ok | error | timeout — findings only meaningful on ok.",
143
+ )
144
+ engine_message: str = Field(
145
+ default="",
146
+ description="Human-readable engine failure detail (when not ok).",
147
+ )
148
+ engine_error_code: str = Field(
149
+ default="",
150
+ description=(
151
+ "Machine hint for the failure kind (e.g. rules_invalid, "
152
+ "backend_unavailable); maps to Django's reserved codes."
153
+ ),
154
+ )
155
+
156
+ # ── o.* signals (must stay aligned with the ValidatorConfig catalog) ──
157
+ passed: bool | None = Field(
158
+ default=None,
159
+ description=(
160
+ "True iff zero ERROR-level findings; None (unknown) when the "
161
+ "engine could not run."
162
+ ),
163
+ )
164
+ error_count: int = Field(default=0, ge=0)
165
+ warning_count: int = Field(default=0, ge=0)
166
+ info_count: int = Field(default=0, ge=0)
167
+ # svrl:fired-rule counts rules/contexts EVALUATED, not assertions that
168
+ # fired — never surface this as an "assertion count" (D3 SVRL note).
169
+ fired_rule_count: int = Field(default=0, ge=0)
170
+ finding_rule_ids_by_severity: dict[str, str] = Field(
171
+ default_factory=dict,
172
+ description=(
173
+ 'Map of native rule id to resolved severity, e.g. {"BR-CO-15": '
174
+ '"ERROR"}. Key membership + severity gates in CEL (D2).'
175
+ ),
176
+ )
177
+
178
+ # ── Findings (volume-capped, never silently — D10) ──
179
+ findings: list[SchematronFinding] = Field(
180
+ default_factory=list,
181
+ description=(
182
+ "Active findings (failed-asserts AND successful-reports), "
183
+ "capped at max_findings ordered ERROR → WARNING → INFO."
184
+ ),
185
+ )
186
+ findings_truncated: bool = Field(
187
+ default=False,
188
+ description="True when the findings list was capped.",
189
+ )
190
+ findings_suppressed_count: int = Field(
191
+ default=0,
192
+ ge=0,
193
+ description="How many findings the cap suppressed (counts stay full).",
194
+ )
195
+
196
+ # ── Provenance of the executed rules + engine (D5) ──
197
+ schematron_sha256: str = Field(
198
+ default="",
199
+ description="sha256 of the Schematron source that was executed.",
200
+ )
201
+ query_binding: str = Field(
202
+ default="",
203
+ description=(
204
+ "Query binding detected from the .sch root (xslt1/xslt2/…), "
205
+ "echoed for provenance."
206
+ ),
207
+ )
208
+ engine: str = Field(
209
+ default="",
210
+ description="Engine name + version that ran, e.g. 'SaxonC-HE 12.9'.",
211
+ )
212
+
213
+ execution_seconds: float = Field(default=0.0, ge=0)
214
+
215
+ model_config = {"extra": "forbid"}
216
+
217
+
218
+ class SchematronInputEnvelope(ValidationInputEnvelope):
219
+ """Input envelope for Schematron validator containers."""
220
+
221
+ inputs: SchematronInputs
222
+
223
+
224
+ class SchematronOutputEnvelope(ValidationOutputEnvelope):
225
+ """Output envelope from Schematron validator containers.
226
+
227
+ ``outputs`` can be ``None`` for runtime-failure cases where the backend
228
+ crashed before producing even an engine-status summary.
229
+ """
230
+
231
+ outputs: SchematronOutputs | None = None
232
+
233
+
234
+ def build_schematron_input_envelope(
235
+ *,
236
+ run_id: str,
237
+ validator,
238
+ org_id: str,
239
+ org_name: str,
240
+ workflow_id: str,
241
+ step_id: str,
242
+ step_name: str | None,
243
+ submission_uri: str,
244
+ inputs: SchematronInputs,
245
+ callback_url: str,
246
+ execution_bundle_uri: str,
247
+ callback_id: str | None = None,
248
+ skip_callback: bool = False,
249
+ ) -> SchematronInputEnvelope:
250
+ """Build a ``SchematronInputEnvelope`` from Django validation data.
251
+
252
+ Args:
253
+ run_id: ValidationRun ID.
254
+ validator: Validator-like object (id/validation_type/version attrs).
255
+ org_id / org_name: Organization identity.
256
+ workflow_id / step_id / step_name: Workflow context.
257
+ submission_uri: Storage URI for the XML submission (gs:// or file://).
258
+ inputs: Fully-resolved ``SchematronInputs`` (inline rules + limits).
259
+ callback_url: URL the container POSTs to on completion.
260
+ execution_bundle_uri: Base URI for this run's bundle.
261
+ callback_id: Idempotency key echoed back in the callback.
262
+ skip_callback: True for synchronous (Docker) execution.
263
+ """
264
+ input_files = [
265
+ InputFileItem(
266
+ name="submission.xml",
267
+ mime_type=SupportedMimeType.APPLICATION_XML,
268
+ role="primary-model",
269
+ uri=submission_uri,
270
+ ),
271
+ ]
272
+
273
+ context = ExecutionContext(
274
+ callback_id=callback_id,
275
+ callback_url=callback_url,
276
+ execution_bundle_uri=execution_bundle_uri,
277
+ skip_callback=skip_callback,
278
+ )
279
+
280
+ return SchematronInputEnvelope(
281
+ run_id=run_id,
282
+ validator=ValidatorInfo(
283
+ id=str(validator.id),
284
+ type=ValidatorType(validator.validation_type),
285
+ version=str(getattr(validator, "version", "1")),
286
+ ),
287
+ org={"id": org_id, "name": org_name},
288
+ workflow={
289
+ "id": workflow_id,
290
+ "step_id": step_id,
291
+ "step_name": step_name,
292
+ },
293
+ input_files=input_files,
294
+ inputs=inputs,
295
+ context=context,
296
+ )
@@ -0,0 +1,277 @@
1
+ """SVRL report parsing — findings + signal summary (ADR-2026-07-01, D3/D10).
2
+
3
+ SVRL (Schematron Validation Report Language, ISO/IEC 19757-3) is the XML
4
+ report a Schematron run produces. This module parses an SVRL document into a
5
+ :class:`SvrlSummary`: the individual findings plus the aggregate counts and
6
+ the ``finding_rule_ids_by_severity`` map that feed the validator's ``o.*``
7
+ signal surface.
8
+
9
+ Design rules carried from the ADR:
10
+
11
+ - **Both ``svrl:failed-assert`` and ``svrl:successful-report`` are active
12
+ findings**, handled identically. A ``<report>`` can carry a
13
+ publisher-authored error, so the element type must never drive severity.
14
+ - **Severity resolves ``@flag`` → ``@role`` → fail-closed ``ERROR``.**
15
+ ``fatal``/``error`` → ERROR, ``warning``/``warn`` → WARNING,
16
+ ``info``/``information`` → INFO. A finding with neither attribute is
17
+ fail-closed to ERROR and logged for pack-curation review — nothing
18
+ publisher-authored is silently downgraded.
19
+ - **``svrl:fired-rule`` counts rules/contexts evaluated**, *not* assertions
20
+ that fired — surfaced as ``fired_rule_count``, never an "assertion count".
21
+ - **Findings are volume-capped, never silently** (D10): when a document blows
22
+ ``max_findings`` the parser keeps the first N ordered ERROR → WARNING →
23
+ INFO (document order within a severity) and reports how many were
24
+ suppressed. The caller renders the explicit truncation finding.
25
+
26
+ This is the CANONICAL parser for the whole pipeline: the validator backend
27
+ container parses Saxon's SVRL with it to build ``SchematronOutputs``, and the
28
+ Django app re-exports it (``validibot.validations.validators.schematron.svrl``)
29
+ for its fixture/round-trip tests. It lives beside the envelope models because
30
+ it defines the same protocol boundary — SVRL in, the D2 signal contract out.
31
+ Deliberately framework-free: plain-string severities, ``defusedxml``-only
32
+ parsing, no Django.
33
+
34
+ Different Schematron compilers emit slightly different SVRL dialects; parsing
35
+ is defensive (namespace-tolerant, attribute-tolerant) and covered by the
36
+ unit tests in this repo.
37
+ """
38
+
39
+ from __future__ import annotations
40
+
41
+ import logging
42
+ from dataclasses import dataclass, field
43
+
44
+ from defusedxml import ElementTree as SafeET
45
+ from defusedxml.common import (
46
+ DTDForbidden,
47
+ EntitiesForbidden,
48
+ ExternalReferenceForbidden,
49
+ )
50
+
51
+ logger = logging.getLogger(__name__)
52
+
53
+ # The SVRL namespace from ISO/IEC 19757-3. Some tools emit un-namespaced
54
+ # SVRL; matching is namespace-tolerant (see _local_name).
55
+ SVRL_NS = "http://purl.oclc.org/dsdl/svrl"
56
+
57
+ # Plain-string severities (shared-envelope compatible; Django maps them to
58
+ # its own Severity enum in validator.py).
59
+ SEVERITY_ERROR = "ERROR"
60
+ SEVERITY_WARNING = "WARNING"
61
+ SEVERITY_INFO = "INFO"
62
+
63
+ # Rank used both for the "most severe wins" duplicate-rule-id rule and the
64
+ # ERROR → WARNING → INFO truncation ordering (D10).
65
+ _SEVERITY_RANK = {SEVERITY_ERROR: 0, SEVERITY_WARNING: 1, SEVERITY_INFO: 2}
66
+
67
+ # @flag / @role values → severity. Lowercased before lookup.
68
+ _SEVERITY_FROM_ATTR = {
69
+ "fatal": SEVERITY_ERROR,
70
+ "error": SEVERITY_ERROR,
71
+ "warning": SEVERITY_WARNING,
72
+ "warn": SEVERITY_WARNING,
73
+ "info": SEVERITY_INFO,
74
+ "information": SEVERITY_INFO,
75
+ }
76
+
77
+ # Default findings cap — mirrors SCHEMATRON_MAX_FINDINGS in security.py
78
+ # (kept as a literal here so this module stays import-light and pure).
79
+ DEFAULT_MAX_FINDINGS = 500
80
+
81
+
82
+ class SvrlParseError(ValueError):
83
+ """Raised when SVRL content cannot be parsed as XML."""
84
+
85
+
86
+ @dataclass(frozen=True)
87
+ class SvrlFinding:
88
+ """One active SVRL finding (a failed assert or a successful report).
89
+
90
+ ``rule_id`` is the publisher's native identifier (``BR-CO-15``,
91
+ ``PEPPOL-EN16931-R010``, or a fixture's ``VB-*`` id) taken from the
92
+ element's ``@id``; it becomes ``ValidationFinding.code`` downstream (D10).
93
+ ``flag`` and ``role`` carry the raw attributes for provenance.
94
+ """
95
+
96
+ rule_id: str
97
+ message: str
98
+ severity: str # SEVERITY_ERROR | SEVERITY_WARNING | SEVERITY_INFO
99
+ location: str = "" # SVRL @location XPath into the submitted document
100
+ flag: str = ""
101
+ role: str = ""
102
+ element: str = "" # "failed-assert" | "successful-report"
103
+
104
+
105
+ @dataclass
106
+ class SvrlSummary:
107
+ """Parsed SVRL: findings plus the aggregate ``o.*`` signal values.
108
+
109
+ ``finding_rule_ids_by_severity`` is the D2-pinned CEL map contract:
110
+ ``{rule_id: severity_string}`` so ``"BR-CO-15" in o.finding_rule_ids_by_severity``
111
+ is key membership and ``o.finding_rule_ids_by_severity["BR-CO-15"] == "ERROR"``
112
+ is a severity-aware gate. When one rule id fires at several severities,
113
+ the most severe wins.
114
+ """
115
+
116
+ findings: list[SvrlFinding] = field(default_factory=list)
117
+ error_count: int = 0
118
+ warning_count: int = 0
119
+ info_count: int = 0
120
+ fired_rule_count: int = 0
121
+ finding_rule_ids_by_severity: dict[str, str] = field(default_factory=dict)
122
+ findings_truncated: bool = False
123
+ findings_suppressed_count: int = 0
124
+
125
+ @property
126
+ def passed(self) -> bool:
127
+ """A Schematron run passes iff there are no ERROR-level findings (D3)."""
128
+ return self.error_count == 0
129
+
130
+
131
+ def parse_svrl(
132
+ svrl_content: str | bytes,
133
+ *,
134
+ max_findings: int = DEFAULT_MAX_FINDINGS,
135
+ ) -> SvrlSummary:
136
+ """Parse an SVRL document into findings + the signal summary.
137
+
138
+ Args:
139
+ svrl_content: The SVRL report as produced by a Schematron run
140
+ (lxml.isoschematron for XSLT-1.0 fixtures; Saxon in production).
141
+ max_findings: Volume cap (D8/D10). The kept findings are the first N
142
+ ordered ERROR → WARNING → INFO; the summary records how many were
143
+ suppressed so the caller can surface an explicit truncation
144
+ finding — truncation is never silent.
145
+
146
+ Raises:
147
+ SvrlParseError: If the content is empty or not well-formed XML.
148
+ SVRL comes from our own engine, but defusedxml is used anyway as
149
+ defence in depth.
150
+ """
151
+ if not svrl_content or not str(svrl_content).strip():
152
+ raise SvrlParseError("Empty SVRL content.")
153
+
154
+ raw = (
155
+ svrl_content.encode("utf-8") if isinstance(svrl_content, str) else svrl_content
156
+ )
157
+ try:
158
+ root = SafeET.fromstring(raw, forbid_dtd=True)
159
+ except (EntitiesForbidden, ExternalReferenceForbidden, DTDForbidden) as exc:
160
+ raise SvrlParseError(
161
+ "SVRL contains forbidden constructs (entities/DTD).",
162
+ ) from exc
163
+ except SafeET.ParseError as exc:
164
+ raise SvrlParseError(f"Invalid SVRL XML: {exc}") from exc
165
+
166
+ summary = SvrlSummary()
167
+ all_findings: list[SvrlFinding] = []
168
+
169
+ for element in root.iter():
170
+ name = _local_name(element.tag)
171
+ if name == "fired-rule":
172
+ summary.fired_rule_count += 1
173
+ elif name in ("failed-assert", "successful-report"):
174
+ all_findings.append(_parse_finding(element, name))
175
+
176
+ # Aggregate counts + the rule-id map over ALL findings (pre-cap): the
177
+ # signal surface must reflect the true totals even when the persisted
178
+ # findings list is truncated.
179
+ for finding in all_findings:
180
+ if finding.severity == SEVERITY_ERROR:
181
+ summary.error_count += 1
182
+ elif finding.severity == SEVERITY_WARNING:
183
+ summary.warning_count += 1
184
+ else:
185
+ summary.info_count += 1
186
+
187
+ if finding.rule_id:
188
+ existing = summary.finding_rule_ids_by_severity.get(finding.rule_id)
189
+ if (
190
+ existing is None
191
+ or _SEVERITY_RANK[finding.severity] < _SEVERITY_RANK[existing]
192
+ ):
193
+ summary.finding_rule_ids_by_severity[finding.rule_id] = finding.severity
194
+ else:
195
+ logger.warning(
196
+ "SVRL %s finding has no @id; kept without a rule id (message=%r)",
197
+ finding.element,
198
+ finding.message[:120],
199
+ )
200
+
201
+ # Volume cap (D10): keep ERROR findings first, then WARNING, then INFO,
202
+ # preserving document order within each severity band.
203
+ if max_findings > 0 and len(all_findings) > max_findings:
204
+ ordered = sorted(
205
+ all_findings,
206
+ key=lambda f: _SEVERITY_RANK[f.severity],
207
+ )
208
+ summary.findings = ordered[:max_findings]
209
+ summary.findings_truncated = True
210
+ summary.findings_suppressed_count = len(all_findings) - max_findings
211
+ logger.warning(
212
+ "SVRL findings truncated: kept %d of %d (cap=%d)",
213
+ max_findings,
214
+ len(all_findings),
215
+ max_findings,
216
+ )
217
+ else:
218
+ summary.findings = all_findings
219
+
220
+ return summary
221
+
222
+
223
+ def _parse_finding(element, element_name: str) -> SvrlFinding:
224
+ """Map one failed-assert / successful-report element to a finding.
225
+
226
+ Severity resolution is the D3 chain: ``@flag`` first, then ``@role``,
227
+ then fail-closed to ERROR with a log line for pack-curation review.
228
+ """
229
+ flag = (element.get("flag") or "").strip()
230
+ role = (element.get("role") or "").strip()
231
+
232
+ severity = _SEVERITY_FROM_ATTR.get(flag.lower())
233
+ if severity is None:
234
+ severity = _SEVERITY_FROM_ATTR.get(role.lower())
235
+ if severity is None:
236
+ severity = SEVERITY_ERROR
237
+ rule_id_for_log = (element.get("id") or "").strip()
238
+ logger.warning(
239
+ "SVRL %s (id=%r) has no recognisable @flag/@role "
240
+ "(flag=%r, role=%r); fail-closed to ERROR",
241
+ element_name,
242
+ rule_id_for_log,
243
+ flag,
244
+ role,
245
+ )
246
+
247
+ return SvrlFinding(
248
+ rule_id=(element.get("id") or "").strip(),
249
+ message=_finding_text(element),
250
+ severity=severity,
251
+ location=(element.get("location") or "").strip(),
252
+ flag=flag,
253
+ role=role,
254
+ element=element_name,
255
+ )
256
+
257
+
258
+ def _finding_text(element) -> str:
259
+ """Extract the human-readable message from a finding's svrl:text child.
260
+
261
+ Joins all descendant text of every ``svrl:text`` child (publisher
262
+ messages may contain inline markup like ``<emph>``), collapsing internal
263
+ whitespace — SVRL text is authored with layout newlines that mean nothing.
264
+ """
265
+ parts: list[str] = []
266
+ for child in element:
267
+ if _local_name(child.tag) == "text":
268
+ parts.append(" ".join("".join(child.itertext()).split()))
269
+ return " ".join(p for p in parts if p).strip()
270
+
271
+
272
+ def _local_name(tag: object) -> str:
273
+ """Strip any XML namespace from a tag name (namespace-tolerant matching)."""
274
+ text = str(tag)
275
+ if text.startswith("{"):
276
+ return text.split("}", 1)[1]
277
+ return text
@@ -143,6 +143,7 @@ class ValidatorType(str, Enum):
143
143
  BASIC = "BASIC"
144
144
  JSON_SCHEMA = "JSON_SCHEMA"
145
145
  XML_SCHEMA = "XML_SCHEMA"
146
+ SCHEMATRON = "SCHEMATRON"
146
147
  SHACL = "SHACL"
147
148
  ENERGYPLUS = "ENERGYPLUS"
148
149
  FMU = "FMU"