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.
- {validibot_shared-0.9.2 → validibot_shared-0.11.0}/PKG-INFO +4 -3
- {validibot_shared-0.9.2 → validibot_shared-0.11.0}/pyproject.toml +3 -3
- {validibot_shared-0.9.2 → validibot_shared-0.11.0}/tests/test_evidence_manifest.py +112 -0
- validibot_shared-0.11.0/tests/test_schematron_envelopes.py +183 -0
- validibot_shared-0.11.0/tests/test_schematron_svrl.py +290 -0
- {validibot_shared-0.9.2 → validibot_shared-0.11.0}/validibot_shared/__init__.py +4 -0
- {validibot_shared-0.9.2 → validibot_shared-0.11.0}/validibot_shared/evidence/__init__.py +4 -0
- {validibot_shared-0.9.2 → validibot_shared-0.11.0}/validibot_shared/evidence/manifest.py +71 -1
- validibot_shared-0.11.0/validibot_shared/schematron/__init__.py +49 -0
- validibot_shared-0.11.0/validibot_shared/schematron/envelopes.py +301 -0
- validibot_shared-0.11.0/validibot_shared/schematron/svrl.py +282 -0
- {validibot_shared-0.9.2 → validibot_shared-0.11.0}/validibot_shared/validations/envelopes.py +1 -0
- {validibot_shared-0.9.2 → validibot_shared-0.11.0}/.gitignore +0 -0
- {validibot_shared-0.9.2 → validibot_shared-0.11.0}/LICENSE +0 -0
- {validibot_shared-0.9.2 → validibot_shared-0.11.0}/NOTICE +0 -0
- {validibot_shared-0.9.2 → validibot_shared-0.11.0}/README.md +0 -0
- {validibot_shared-0.9.2 → validibot_shared-0.11.0}/tests/__init__.py +0 -0
- {validibot_shared-0.9.2 → validibot_shared-0.11.0}/tests/test_energyplus_envelopes.py +0 -0
- {validibot_shared-0.9.2 → validibot_shared-0.11.0}/tests/test_energyplus_models.py +0 -0
- {validibot_shared-0.9.2 → validibot_shared-0.11.0}/tests/test_fmu_envelopes.py +0 -0
- {validibot_shared-0.9.2 → validibot_shared-0.11.0}/tests/test_fmu_models.py +0 -0
- {validibot_shared-0.9.2 → validibot_shared-0.11.0}/tests/test_package_init.py +0 -0
- {validibot_shared-0.9.2 → validibot_shared-0.11.0}/tests/test_shacl_envelopes.py +0 -0
- {validibot_shared-0.9.2 → validibot_shared-0.11.0}/tests/test_validations_envelopes.py +0 -0
- {validibot_shared-0.9.2 → validibot_shared-0.11.0}/validibot_shared/energyplus/__init__.py +0 -0
- {validibot_shared-0.9.2 → validibot_shared-0.11.0}/validibot_shared/energyplus/envelopes.py +0 -0
- {validibot_shared-0.9.2 → validibot_shared-0.11.0}/validibot_shared/energyplus/models.py +0 -0
- {validibot_shared-0.9.2 → validibot_shared-0.11.0}/validibot_shared/fmu/__init__.py +0 -0
- {validibot_shared-0.9.2 → validibot_shared-0.11.0}/validibot_shared/fmu/envelopes.py +0 -0
- {validibot_shared-0.9.2 → validibot_shared-0.11.0}/validibot_shared/fmu/models.py +0 -0
- {validibot_shared-0.9.2 → validibot_shared-0.11.0}/validibot_shared/py.typed +0 -0
- {validibot_shared-0.9.2 → validibot_shared-0.11.0}/validibot_shared/shacl/__init__.py +0 -0
- {validibot_shared-0.9.2 → validibot_shared-0.11.0}/validibot_shared/shacl/envelopes.py +0 -0
- {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.
|
|
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.
|
|
30
|
-
Requires-Dist: ruff==0.15.
|
|
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.
|
|
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.
|
|
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",
|