validibot-shared 0.9.2__tar.gz → 0.11.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.9.2 → validibot_shared-0.11.0}/PKG-INFO +4 -3
  2. {validibot_shared-0.9.2 → validibot_shared-0.11.0}/pyproject.toml +3 -3
  3. {validibot_shared-0.9.2 → validibot_shared-0.11.0}/tests/test_evidence_manifest.py +112 -0
  4. validibot_shared-0.11.0/tests/test_schematron_envelopes.py +183 -0
  5. validibot_shared-0.11.0/tests/test_schematron_svrl.py +290 -0
  6. {validibot_shared-0.9.2 → validibot_shared-0.11.0}/validibot_shared/__init__.py +4 -0
  7. {validibot_shared-0.9.2 → validibot_shared-0.11.0}/validibot_shared/evidence/__init__.py +4 -0
  8. {validibot_shared-0.9.2 → validibot_shared-0.11.0}/validibot_shared/evidence/manifest.py +71 -1
  9. validibot_shared-0.11.0/validibot_shared/schematron/__init__.py +49 -0
  10. validibot_shared-0.11.0/validibot_shared/schematron/envelopes.py +301 -0
  11. validibot_shared-0.11.0/validibot_shared/schematron/svrl.py +282 -0
  12. {validibot_shared-0.9.2 → validibot_shared-0.11.0}/validibot_shared/validations/envelopes.py +1 -0
  13. {validibot_shared-0.9.2 → validibot_shared-0.11.0}/.gitignore +0 -0
  14. {validibot_shared-0.9.2 → validibot_shared-0.11.0}/LICENSE +0 -0
  15. {validibot_shared-0.9.2 → validibot_shared-0.11.0}/NOTICE +0 -0
  16. {validibot_shared-0.9.2 → validibot_shared-0.11.0}/README.md +0 -0
  17. {validibot_shared-0.9.2 → validibot_shared-0.11.0}/tests/__init__.py +0 -0
  18. {validibot_shared-0.9.2 → validibot_shared-0.11.0}/tests/test_energyplus_envelopes.py +0 -0
  19. {validibot_shared-0.9.2 → validibot_shared-0.11.0}/tests/test_energyplus_models.py +0 -0
  20. {validibot_shared-0.9.2 → validibot_shared-0.11.0}/tests/test_fmu_envelopes.py +0 -0
  21. {validibot_shared-0.9.2 → validibot_shared-0.11.0}/tests/test_fmu_models.py +0 -0
  22. {validibot_shared-0.9.2 → validibot_shared-0.11.0}/tests/test_package_init.py +0 -0
  23. {validibot_shared-0.9.2 → validibot_shared-0.11.0}/tests/test_shacl_envelopes.py +0 -0
  24. {validibot_shared-0.9.2 → validibot_shared-0.11.0}/tests/test_validations_envelopes.py +0 -0
  25. {validibot_shared-0.9.2 → validibot_shared-0.11.0}/validibot_shared/energyplus/__init__.py +0 -0
  26. {validibot_shared-0.9.2 → validibot_shared-0.11.0}/validibot_shared/energyplus/envelopes.py +0 -0
  27. {validibot_shared-0.9.2 → validibot_shared-0.11.0}/validibot_shared/energyplus/models.py +0 -0
  28. {validibot_shared-0.9.2 → validibot_shared-0.11.0}/validibot_shared/fmu/__init__.py +0 -0
  29. {validibot_shared-0.9.2 → validibot_shared-0.11.0}/validibot_shared/fmu/envelopes.py +0 -0
  30. {validibot_shared-0.9.2 → validibot_shared-0.11.0}/validibot_shared/fmu/models.py +0 -0
  31. {validibot_shared-0.9.2 → validibot_shared-0.11.0}/validibot_shared/py.typed +0 -0
  32. {validibot_shared-0.9.2 → validibot_shared-0.11.0}/validibot_shared/shacl/__init__.py +0 -0
  33. {validibot_shared-0.9.2 → validibot_shared-0.11.0}/validibot_shared/shacl/envelopes.py +0 -0
  34. {validibot_shared-0.9.2 → validibot_shared-0.11.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.9.2
3
+ Version: 0.11.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
- Requires-Dist: pytest==9.0.3; extra == 'dev'
30
- Requires-Dist: ruff==0.15.15; extra == 'dev'
30
+ Requires-Dist: pytest==9.1.1; extra == 'dev'
31
+ Requires-Dist: ruff==0.15.18; 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.9.2"
7
+ version = "0.11.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.0.3", "ruff==0.15.15"]
40
+ dev = ["pytest==9.1.1", "ruff==0.15.18"]
41
41
 
42
42
  [project.urls]
43
43
  Homepage = "https://validibot.com"
@@ -25,6 +25,8 @@ from pydantic import ValidationError
25
25
 
26
26
  from validibot_shared.evidence import (
27
27
  SCHEMA_VERSION,
28
+ ContractConstant,
29
+ ContractSignalMapping,
28
30
  EvidenceManifest,
29
31
  ManifestPayloadDigests,
30
32
  ManifestRetentionInfo,
@@ -165,6 +167,116 @@ class TestOptionalFields:
165
167
  assert manifest.schema_version == "validibot.evidence.v1"
166
168
 
167
169
 
170
+ # ──────────────────────────────────────────────────────────────────────
171
+ # WorkflowContractSnapshot — constants, signal-mapping defs, definition hash
172
+ # ──────────────────────────────────────────────────────────────────────
173
+ #
174
+ # ADR-2026-06-18. These three fields are additive and optional: a producer
175
+ # predating them (or a workflow with none) leaves them empty, and the schema
176
+ # stays ``validibot.evidence.v1``. Recording constants + signal-mapping
177
+ # *definitions* + the definition hash is the transparency half of the constants
178
+ # feature — it lets EVERY run (not just Pro-signed ones) carry "checked against
179
+ # these constants". Resolved ``s.*`` values are deliberately NOT here (they are
180
+ # submission-derived and retention-gated); only the definition is.
181
+
182
+
183
+ class TestWorkflowContractPrimitives:
184
+ def test_new_fields_default_to_empty(self):
185
+ """A producer predating this change (or a bare workflow) leaves them empty.
186
+
187
+ Back-compat is the whole reason these are additive-optional: an older
188
+ manifest that never set them must still validate.
189
+ """
190
+ snap = WorkflowContractSnapshot(allowed_file_types=["json"])
191
+ assert snap.constants == []
192
+ assert snap.signal_mappings == []
193
+ assert snap.workflow_definition_hash == ""
194
+
195
+ def test_constant_value_is_preserved_verbatim(self):
196
+ """A NUMBER constant's decimal string is stored exactly (no float coercion).
197
+
198
+ The attested precision (``"0.40"``) must survive into the snapshot, or the
199
+ credential's "checked against c.energy_price = 0.40" claim would drift
200
+ from the value actually recorded.
201
+ """
202
+ snap = WorkflowContractSnapshot(
203
+ constants=[
204
+ ContractConstant(
205
+ name="energy_price",
206
+ data_type="NUMBER",
207
+ value="0.40",
208
+ ),
209
+ ContractConstant(
210
+ name="allowed_currencies",
211
+ data_type="LIST",
212
+ value=["EUR", "GBP"],
213
+ ),
214
+ ],
215
+ )
216
+ assert snap.constants[0].value == "0.40"
217
+ assert snap.constants[1].value == ["EUR", "GBP"]
218
+
219
+ def test_signal_mapping_records_definition_not_value(self):
220
+ """The snapshot carries a signal's DEFINITION, never a resolved value.
221
+
222
+ Publishing ``source_path``/``on_missing``/``default`` is safe; a resolved
223
+ ``s.*`` value is submission-derived and must not appear in the
224
+ always-publishable contract.
225
+ """
226
+ snap = WorkflowContractSnapshot(
227
+ signal_mappings=[
228
+ ContractSignalMapping(
229
+ name="reported_total",
230
+ source_path="$.total",
231
+ on_missing="error",
232
+ ),
233
+ ],
234
+ )
235
+ mapping = snap.signal_mappings[0]
236
+ assert mapping.name == "reported_total"
237
+ assert mapping.source_path == "$.total"
238
+ # There is no place to smuggle a resolved value in — the model has no
239
+ # such field.
240
+ assert not hasattr(mapping, "resolved_value")
241
+
242
+ def test_primitives_survive_json_round_trip(self):
243
+ """The fields serialize and re-validate identically (canonical evidence).
244
+
245
+ The manifest is hashed and signed as JSON, so the new fields must
246
+ round-trip through ``model_dump``/``model_validate`` unchanged.
247
+ """
248
+ snap = WorkflowContractSnapshot(
249
+ constants=[ContractConstant(name="p", data_type="NUMBER", value="0.40")],
250
+ signal_mappings=[ContractSignalMapping(name="t", source_path="$.t")],
251
+ workflow_definition_hash="sha256:deadbeef",
252
+ )
253
+ restored = WorkflowContractSnapshot.model_validate(
254
+ snap.model_dump(mode="json"),
255
+ )
256
+ assert restored == snap
257
+
258
+ def test_additive_fields_preserve_v1_schema(self):
259
+ """A manifest carrying constants still reports schema_version v1.
260
+
261
+ Additive optional fields preserve v1 per the module's own policy;
262
+ bumping SCHEMA_VERSION here would be wrong.
263
+ """
264
+ manifest = EvidenceManifest(
265
+ **{
266
+ **_minimal_manifest_kwargs(),
267
+ "workflow_contract": WorkflowContractSnapshot(
268
+ allowed_file_types=["json"],
269
+ constants=[
270
+ ContractConstant(name="p", data_type="NUMBER", value="0.40"),
271
+ ],
272
+ workflow_definition_hash="sha256:abc",
273
+ ),
274
+ },
275
+ )
276
+ assert manifest.schema_version == "validibot.evidence.v1"
277
+ assert manifest.workflow_contract.constants[0].name == "p"
278
+
279
+
168
280
  # ──────────────────────────────────────────────────────────────────────
169
281
  # StepValidatorRecord — semantic_digest can be None for legacy validators
170
282
  # ──────────────────────────────────────────────────────────────────────
@@ -0,0 +1,183 @@
1
+ """Tests for the Schematron container contract (ADR-2026-07-01, D4b/D9).
2
+
3
+ The Schematron validator ships an **artefact reference** (staged URI +
4
+ checksums) rather than inlined rule text, and its outputs carry an
5
+ ``engine_status`` failure taxonomy where ``passed`` is *tri-state* —
6
+ True/False when the engine ran, ``None`` (unknown) when it could not. These
7
+ tests pin those contract properties so a refactor cannot silently weaken
8
+ them: the Django side and the validator backend both program against exactly
9
+ this shape.
10
+ """
11
+
12
+ import pytest
13
+ from pydantic import ValidationError
14
+
15
+ from validibot_shared.schematron.envelopes import (
16
+ ENGINE_STATUS_OK,
17
+ ENGINE_STATUS_TIMEOUT,
18
+ SchematronFinding,
19
+ SchematronInputEnvelope,
20
+ SchematronInputs,
21
+ SchematronOutputEnvelope,
22
+ SchematronOutputs,
23
+ build_schematron_input_envelope,
24
+ )
25
+ from validibot_shared.validations.envelopes import (
26
+ SupportedMimeType,
27
+ ValidationStatus,
28
+ ValidatorType,
29
+ )
30
+
31
+ # Test constants to avoid magic values
32
+ DEFAULT_MAX_FINDINGS = 500
33
+ DEFAULT_XSLT_TIMEOUT = 60
34
+ TEST_ERROR_COUNT = 3
35
+ ARTIFACT_SHA = "b" * 64
36
+
37
+
38
+ class _ValidatorStub:
39
+ """Duck-typed validator (id/validation_type/version) for the builder."""
40
+
41
+ id = "val-1"
42
+ validation_type = "SCHEMATRON"
43
+ version = "1"
44
+
45
+
46
+ def _inputs(**overrides) -> SchematronInputs:
47
+ base = {
48
+ "pack_id": "en16931-ubl",
49
+ "pack_version": "1.3.16",
50
+ "artifact_uri": "gs://bucket/run-1/pack.xslt",
51
+ "artifact_sha256": ARTIFACT_SHA,
52
+ "query_binding": "xslt2",
53
+ }
54
+ base.update(overrides)
55
+ return SchematronInputs(**base)
56
+
57
+
58
+ def test_schematron_inputs_carry_artifact_reference_and_limit_defaults():
59
+ """Inputs pin the staged artefact + checksum and default the D8 limits.
60
+
61
+ The container MUST be able to verify what it fetched before executing —
62
+ so the artefact URI and sha256 are required — while the resource limits
63
+ default to the ADR's table values for direct consumers.
64
+ """
65
+ inputs = _inputs()
66
+
67
+ assert inputs.artifact_uri == "gs://bucket/run-1/pack.xslt"
68
+ assert inputs.artifact_sha256 == ARTIFACT_SHA
69
+ assert inputs.max_findings == DEFAULT_MAX_FINDINGS
70
+ assert inputs.xslt_timeout_seconds == DEFAULT_XSLT_TIMEOUT
71
+
72
+
73
+ def test_schematron_inputs_require_the_artifact_fields():
74
+ """Omitting the artefact reference is a validation error, not a default.
75
+
76
+ An input envelope without a verifiable artefact would force the container
77
+ to either refuse (good, but late) or trust an unpinned path (never).
78
+ """
79
+ with pytest.raises(ValidationError):
80
+ SchematronInputs(pack_id="x", pack_version="1")
81
+
82
+
83
+ def test_outputs_default_to_unknown_passed_not_false():
84
+ """``passed`` defaults to None — unknown, not failed (D9).
85
+
86
+ A default of False would make an unpopulated envelope read as "the
87
+ invoice failed the rules"; None forces every consumer to distinguish
88
+ "engine didn't run" from "rules failed".
89
+ """
90
+ outputs = SchematronOutputs()
91
+ assert outputs.passed is None
92
+ assert outputs.engine_status == ENGINE_STATUS_OK
93
+
94
+
95
+ def test_engine_failure_shape_round_trips():
96
+ """A timeout outputs object serializes and re-validates losslessly.
97
+
98
+ The callback path deserializes output.json with this model; the D9
99
+ fields must survive the JSON round trip exactly.
100
+ """
101
+ outputs = SchematronOutputs(
102
+ engine_status=ENGINE_STATUS_TIMEOUT,
103
+ engine_message="Transform exceeded 60s",
104
+ passed=None,
105
+ error_count=0,
106
+ )
107
+ envelope = SchematronOutputEnvelope(
108
+ run_id="run-1",
109
+ validator={"id": "v1", "type": ValidatorType.SCHEMATRON, "version": "1"},
110
+ status=ValidationStatus.FAILED_RUNTIME,
111
+ timing={},
112
+ outputs=outputs,
113
+ )
114
+
115
+ restored = SchematronOutputEnvelope.model_validate(
116
+ envelope.model_dump(mode="json"),
117
+ )
118
+ assert restored.outputs.engine_status == ENGINE_STATUS_TIMEOUT
119
+ assert restored.outputs.passed is None
120
+
121
+
122
+ def test_findings_and_rule_id_map_round_trip():
123
+ """Findings keep native ids/locations and the map keeps its D2 shape."""
124
+ outputs = SchematronOutputs(
125
+ engine_status=ENGINE_STATUS_OK,
126
+ passed=False,
127
+ error_count=TEST_ERROR_COUNT,
128
+ finding_rule_ids_by_severity={"BR-CO-15": "ERROR", "BR-05": "WARNING"},
129
+ findings=[
130
+ SchematronFinding(
131
+ rule_id="BR-CO-15",
132
+ message="Totals must reconcile.",
133
+ severity="ERROR",
134
+ location_xpath="/Invoice/LegalMonetaryTotal",
135
+ flag="fatal",
136
+ ),
137
+ ],
138
+ )
139
+
140
+ restored = SchematronOutputs.model_validate(outputs.model_dump(mode="json"))
141
+ assert restored.finding_rule_ids_by_severity["BR-CO-15"] == "ERROR"
142
+ assert restored.findings[0].rule_id == "BR-CO-15"
143
+ assert restored.findings[0].location_xpath == "/Invoice/LegalMonetaryTotal"
144
+
145
+
146
+ def test_outputs_forbid_unknown_fields():
147
+ """extra="forbid" holds — a typo'd field fails loudly, not silently.
148
+
149
+ Contract models must reject unknown keys so a mismatched backend/Django
150
+ version pair surfaces as an explicit error instead of dropped data.
151
+ """
152
+ with pytest.raises(ValidationError):
153
+ SchematronOutputs(engine_status="ok", not_a_field=1)
154
+
155
+
156
+ def test_build_schematron_input_envelope_assembles_the_xml_submission():
157
+ """The builder produces a valid envelope with an XML primary input.
158
+
159
+ Pins the file-item conventions (name/mime/role) the container reads and
160
+ that ``ValidatorType.SCHEMATRON`` exists — the builder would crash
161
+ without the enum member.
162
+ """
163
+ envelope = build_schematron_input_envelope(
164
+ run_id="run-1",
165
+ validator=_ValidatorStub(),
166
+ org_id="org-1",
167
+ org_name="ValidiBot",
168
+ workflow_id="wf-1",
169
+ step_id="step-1",
170
+ step_name="Peppol rules",
171
+ submission_uri="gs://bucket/run-1/submission.xml",
172
+ inputs=_inputs(),
173
+ callback_url="https://example.com/callback",
174
+ execution_bundle_uri="gs://bucket/run-1/",
175
+ )
176
+
177
+ assert isinstance(envelope, SchematronInputEnvelope)
178
+ assert envelope.validator.type == ValidatorType.SCHEMATRON
179
+ file_item = envelope.input_files[0]
180
+ assert file_item.name == "submission.xml"
181
+ assert file_item.mime_type == SupportedMimeType.APPLICATION_XML
182
+ assert file_item.role == "primary-model"
183
+ assert envelope.inputs.pack_id == "en16931-ubl"
@@ -0,0 +1,290 @@
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}>"
68
+ f"<svrl:text>{text}</svrl:text>"
69
+ "</svrl:failed-assert>"
70
+ )
71
+
72
+
73
+ # ── Severity resolution: @flag → @role → fail-closed ERROR ──────────────────
74
+
75
+
76
+ def test_failed_assert_flag_fatal_maps_to_error():
77
+ """flag="fatal" resolves to ERROR and id/location survive the mapping.
78
+
79
+ The rule id and location are the whole value proposition (D10): they make
80
+ findings actionable and cross-referenceable against the published rules.
81
+ """
82
+ summary = parse_svrl(
83
+ _svrl(
84
+ _failed_assert(
85
+ rule_id="VB-CO-15",
86
+ flag="fatal",
87
+ location="/Invoice/LegalMonetaryTotal",
88
+ ),
89
+ ),
90
+ )
91
+
92
+ assert summary.error_count == 1
93
+ assert not summary.passed
94
+ finding = summary.findings[0]
95
+ assert finding.rule_id == "VB-CO-15"
96
+ assert finding.severity == SEVERITY_ERROR
97
+ assert finding.location == "/Invoice/LegalMonetaryTotal"
98
+ assert finding.flag == "fatal"
99
+
100
+
101
+ def test_successful_report_is_an_active_finding_handled_identically():
102
+ """A svrl:successful-report with flag="fatal" is an ERROR finding.
103
+
104
+ This is the D3 rule the naive implementation gets wrong: a ``<report>``
105
+ can carry a publisher-authored *error*, so the element type must never
106
+ drive severity — only @flag/@role do.
107
+ """
108
+ summary = parse_svrl(
109
+ _svrl(
110
+ '<svrl:successful-report id="VB-R-01" flag="fatal" location="/a">'
111
+ "<svrl:text>Reported error.</svrl:text>"
112
+ "</svrl:successful-report>",
113
+ ),
114
+ )
115
+
116
+ assert summary.error_count == 1
117
+ assert summary.findings[0].severity == SEVERITY_ERROR
118
+ assert summary.findings[0].element == "successful-report"
119
+
120
+
121
+ def test_role_is_the_fallback_when_flag_is_absent():
122
+ """@role maps warning→WARNING and information→INFO when @flag is missing.
123
+
124
+ The official packs use both spellings families (warn/warning,
125
+ info/information); the resolver accepts them all.
126
+ """
127
+ summary = parse_svrl(
128
+ _svrl(
129
+ _failed_assert(rule_id="VB-W", role="warning")
130
+ + _failed_assert(rule_id="VB-I", role="information"),
131
+ ),
132
+ )
133
+
134
+ by_id = {f.rule_id: f for f in summary.findings}
135
+ assert by_id["VB-W"].severity == SEVERITY_WARNING
136
+ assert by_id["VB-I"].severity == SEVERITY_INFO
137
+ assert summary.warning_count == 1
138
+ assert summary.info_count == 1
139
+ # Warnings/info alone never fail a Schematron step (D3).
140
+ assert summary.passed
141
+
142
+
143
+ def test_flag_wins_over_role():
144
+ """When both attributes exist, @flag is authoritative (D3 resolution order)."""
145
+ summary = parse_svrl(
146
+ _svrl(_failed_assert(rule_id="VB-B", flag="fatal", role="warning")),
147
+ )
148
+ assert summary.findings[0].severity == SEVERITY_ERROR
149
+
150
+
151
+ def test_fail_closed_to_error_when_neither_flag_nor_role_present():
152
+ """A finding with no severity attributes fail-closes to ERROR.
153
+
154
+ Fail-open (defaulting to INFO) would silently downgrade publisher-authored
155
+ rules whose pack relies on a default phase severity — the exact bug class
156
+ D3 forbids.
157
+ """
158
+ summary = parse_svrl(_svrl(_failed_assert(rule_id="VB-N")))
159
+ assert summary.findings[0].severity == SEVERITY_ERROR
160
+ assert summary.error_count == 1
161
+
162
+
163
+ # ── Message text extraction ──────────────────────────────────────────────────
164
+
165
+
166
+ def test_message_text_collapses_whitespace_and_inline_markup():
167
+ """svrl:text content survives inline markup and layout whitespace.
168
+
169
+ Publisher messages contain inline elements (emph/span) and authored
170
+ newlines; the human-readable message must come out flat and readable.
171
+ """
172
+ summary = parse_svrl(
173
+ _svrl(
174
+ '<svrl:failed-assert id="VB-T" flag="fatal" location="/a">'
175
+ "<svrl:text>Total \n must <emph>equal</emph>\n sum.</svrl:text>"
176
+ "</svrl:failed-assert>",
177
+ ),
178
+ )
179
+ assert summary.findings[0].message == "Total must equal sum."
180
+
181
+
182
+ # ── fired-rule counting ──────────────────────────────────────────────────────
183
+
184
+
185
+ def test_fired_rule_count_counts_rules_not_assertions():
186
+ """svrl:fired-rule elements count evaluated rules/contexts — nothing else.
187
+
188
+ The review explicitly renamed this signal away from "assertion count":
189
+ a fired rule is a context the engine evaluated, not an assertion that
190
+ failed. Two fired rules + one failed assert must yield 2 / 1.
191
+ """
192
+ summary = parse_svrl(
193
+ _svrl(
194
+ '<svrl:fired-rule context="/Invoice"/>'
195
+ '<svrl:fired-rule context="/Invoice/Total"/>'
196
+ + _failed_assert(rule_id="VB-1", flag="fatal"),
197
+ ),
198
+ )
199
+ assert summary.fired_rule_count == EXPECTED_FIRED_RULES
200
+ assert summary.error_count == 1
201
+
202
+
203
+ # ── The finding_rule_ids_by_severity map (CEL contract) ─────────────────────
204
+
205
+
206
+ def test_rule_id_map_shape_and_most_severe_wins():
207
+ """The map is {rule_id: severity} and the most severe occurrence wins.
208
+
209
+ This is the pinned D2 CEL contract: key membership tests
210
+ (``"VB-D" in o.finding_rule_ids_by_severity``) and severity-aware gates
211
+ both depend on this exact shape. A rule firing at WARNING in one context
212
+ and ERROR in another must surface as ERROR.
213
+ """
214
+ summary = parse_svrl(
215
+ _svrl(
216
+ _failed_assert(rule_id="VB-D", role="warning")
217
+ + _failed_assert(rule_id="VB-D", flag="fatal")
218
+ + _failed_assert(rule_id="VB-W", role="warning"),
219
+ ),
220
+ )
221
+ assert summary.finding_rule_ids_by_severity == {
222
+ "VB-D": SEVERITY_ERROR,
223
+ "VB-W": SEVERITY_WARNING,
224
+ }
225
+
226
+
227
+ # ── Truncation (D10: capped, never silent) ───────────────────────────────────
228
+
229
+
230
+ def test_truncation_keeps_errors_first_and_counts_stay_full():
231
+ """Capping keeps ERROR findings first and records the suppressed count.
232
+
233
+ Two invariants: (1) the kept findings are ordered ERROR → WARNING → INFO
234
+ so the most actionable rows survive; (2) the aggregate counts reflect the
235
+ FULL document, so "clean-ish" and "thousands of errors, capped" can never
236
+ look the same to a CEL gate.
237
+ """
238
+ body = (
239
+ _failed_assert(rule_id="VB-I1", role="info")
240
+ + _failed_assert(rule_id="VB-W1", role="warning")
241
+ + _failed_assert(rule_id="VB-E1", flag="fatal")
242
+ + _failed_assert(rule_id="VB-E2", flag="error")
243
+ + _failed_assert(rule_id="VB-W2", role="warning")
244
+ )
245
+ summary = parse_svrl(_svrl(body), max_findings=TRUNCATION_CAP)
246
+
247
+ assert summary.findings_truncated
248
+ assert summary.findings_suppressed_count == EXPECTED_SUPPRESSED
249
+ assert len(summary.findings) == TRUNCATION_CAP
250
+ # ERROR findings survive the cap ahead of warnings/info.
251
+ assert {f.rule_id for f in summary.findings} == {"VB-E1", "VB-E2"}
252
+ # Aggregates are computed pre-cap.
253
+ assert summary.error_count == ERRORS_IN_TRUNCATION_BODY
254
+ assert summary.warning_count == WARNINGS_IN_TRUNCATION_BODY
255
+ assert summary.info_count == 1
256
+ assert len(summary.finding_rule_ids_by_severity) == TOTAL_FINDINGS_FOR_TRUNCATION
257
+
258
+
259
+ # ── Dialect tolerance + input guards ─────────────────────────────────────────
260
+
261
+
262
+ def test_unnamespaced_svrl_is_tolerated():
263
+ """SVRL without the namespace still parses (compiler-dialect defensiveness).
264
+
265
+ Some Schematron toolchains emit un-namespaced SVRL; the parser matches on
266
+ local names so those reports still map to findings.
267
+ """
268
+ summary = parse_svrl(
269
+ "<schematron-output>"
270
+ '<fired-rule context="/a"/>'
271
+ '<failed-assert id="VB-U" flag="fatal" location="/a">'
272
+ "<text>Unnamespaced.</text>"
273
+ "</failed-assert>"
274
+ "</schematron-output>",
275
+ )
276
+ assert summary.fired_rule_count == 1
277
+ assert summary.findings[0].rule_id == "VB-U"
278
+
279
+
280
+ def test_empty_and_malformed_svrl_raise():
281
+ """Empty or non-XML input raises SvrlParseError rather than passing.
282
+
283
+ Silently returning an empty summary for garbage input would read as
284
+ "zero findings" — i.e. a pass — which is exactly the fail-open behaviour
285
+ the D9 taxonomy exists to prevent.
286
+ """
287
+ with pytest.raises(SvrlParseError):
288
+ parse_svrl("")
289
+ with pytest.raises(SvrlParseError):
290
+ parse_svrl("this is not XML <")
@@ -9,6 +9,8 @@ from validibot_shared.evidence import (
9
9
  SCHEMA_VERSION as EVIDENCE_SCHEMA_VERSION,
10
10
  )
11
11
  from validibot_shared.evidence import (
12
+ ContractConstant,
13
+ ContractSignalMapping,
12
14
  EvidenceManifest,
13
15
  ManifestPayloadDigests,
14
16
  ManifestRetentionInfo,
@@ -26,6 +28,8 @@ from validibot_shared.shacl.envelopes import (
26
28
 
27
29
  __all__ = [
28
30
  "EVIDENCE_SCHEMA_VERSION",
31
+ "ContractConstant",
32
+ "ContractSignalMapping",
29
33
  "EnergyPlusSimulationLogs",
30
34
  "EnergyPlusSimulationMetrics",
31
35
  "EnergyPlusSimulationOutputs",
@@ -9,6 +9,8 @@ manifest without pulling in the application stack.
9
9
 
10
10
  from validibot_shared.evidence.manifest import (
11
11
  SCHEMA_VERSION,
12
+ ContractConstant,
13
+ ContractSignalMapping,
12
14
  EvidenceManifest,
13
15
  ManifestPayloadDigests,
14
16
  ManifestRetentionInfo,
@@ -18,6 +20,8 @@ from validibot_shared.evidence.manifest import (
18
20
 
19
21
  __all__ = [
20
22
  "SCHEMA_VERSION",
23
+ "ContractConstant",
24
+ "ContractSignalMapping",
21
25
  "EvidenceManifest",
22
26
  "ManifestPayloadDigests",
23
27
  "ManifestRetentionInfo",