openmapstack 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- openmapstack/__init__.py +6 -0
- openmapstack/__main__.py +3 -0
- openmapstack/checks/__init__.py +112 -0
- openmapstack/checks/geodata.py +318 -0
- openmapstack/checks/overrides.py +263 -0
- openmapstack/checks/presentation.py +168 -0
- openmapstack/checks/project.py +261 -0
- openmapstack/checks/provenance.py +133 -0
- openmapstack/checks/qgis.py +832 -0
- openmapstack/checks/rerun.py +340 -0
- openmapstack/checks/spatial.py +65 -0
- openmapstack/checks/validation.py +288 -0
- openmapstack/checks/visual.py +642 -0
- openmapstack/cli.py +431 -0
- openmapstack/expectations.py +284 -0
- openmapstack/integrity.py +137 -0
- openmapstack/project.py +79 -0
- openmapstack/rerun.py +332 -0
- openmapstack/schema.py +39 -0
- openmapstack/schemas/__init__.py +1 -0
- openmapstack/schemas/project-v1.schema.json +264 -0
- openmapstack/validation.py +1019 -0
- openmapstack/verify.py +386 -0
- openmapstack-0.2.0.dist-info/METADATA +268 -0
- openmapstack-0.2.0.dist-info/RECORD +29 -0
- openmapstack-0.2.0.dist-info/WHEEL +5 -0
- openmapstack-0.2.0.dist-info/entry_points.txt +2 -0
- openmapstack-0.2.0.dist-info/licenses/LICENSE +21 -0
- openmapstack-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
"""Project-contract assertions: schema, manifest graph, status/report agreement.
|
|
2
|
+
|
|
3
|
+
See references/project-spec.md sections 1, 2.1, 2.4, 4.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from . import (
|
|
11
|
+
AssertionResult,
|
|
12
|
+
failed,
|
|
13
|
+
get_in,
|
|
14
|
+
load_json,
|
|
15
|
+
load_project_yaml,
|
|
16
|
+
not_testable,
|
|
17
|
+
passed,
|
|
18
|
+
project_root,
|
|
19
|
+
warning,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def exists(workspace: Path, project_dir: str = ".", path: str = "") -> AssertionResult:
|
|
24
|
+
"""A declared file exists relative to the project root."""
|
|
25
|
+
# An omitted path resolves to the project root itself, which always
|
|
26
|
+
# exists -- so the check would report `passed` having verified nothing.
|
|
27
|
+
# A check that cannot fail is worse than a missing check, because it
|
|
28
|
+
# reads as evidence.
|
|
29
|
+
if not path.strip():
|
|
30
|
+
return not_testable("no path argument given", code="missing_argument")
|
|
31
|
+
target = project_root(workspace, project_dir) / path
|
|
32
|
+
if target.exists():
|
|
33
|
+
return passed(f"{path} exists")
|
|
34
|
+
return failed(f"{path} does not exist", code="file_missing")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def parses(workspace: Path, project_dir: str = ".") -> AssertionResult:
|
|
38
|
+
proj = load_project_yaml(workspace, project_dir)
|
|
39
|
+
if proj is None:
|
|
40
|
+
return failed("project.yaml missing or unreadable", code="manifest_missing")
|
|
41
|
+
return passed("project.yaml parses", schema=proj.get("schema"))
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def schema_is(workspace: Path, schema: str, project_dir: str = ".") -> AssertionResult:
|
|
45
|
+
proj = load_project_yaml(workspace, project_dir)
|
|
46
|
+
if proj is None:
|
|
47
|
+
return failed("project.yaml missing", code="manifest_missing")
|
|
48
|
+
actual = proj.get("schema")
|
|
49
|
+
if actual == schema:
|
|
50
|
+
return passed(f"schema == {schema}")
|
|
51
|
+
return failed(f"schema mismatch: expected {schema!r}, got {actual!r}", code="schema_mismatch")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def conforms_to_schema(workspace: Path, project_dir: str = ".") -> AssertionResult:
|
|
55
|
+
"""Validate the complete manifest with the packaged formal JSON Schema."""
|
|
56
|
+
from openmapstack.schema import project_schema_errors
|
|
57
|
+
|
|
58
|
+
proj = load_project_yaml(workspace, project_dir)
|
|
59
|
+
if proj is None:
|
|
60
|
+
return failed("project.yaml missing", code="manifest_missing")
|
|
61
|
+
errors = project_schema_errors(proj)
|
|
62
|
+
if errors:
|
|
63
|
+
return failed(
|
|
64
|
+
f"project.yaml does not conform to the OpenMapStack v1 schema: {errors}",
|
|
65
|
+
code="manifest_schema_invalid",
|
|
66
|
+
errors=errors,
|
|
67
|
+
)
|
|
68
|
+
return passed("project.yaml conforms to the packaged OpenMapStack v1 JSON Schema")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def status_is(workspace: Path, status: str, project_dir: str = ".") -> AssertionResult:
|
|
72
|
+
proj = load_project_yaml(workspace, project_dir)
|
|
73
|
+
if proj is None:
|
|
74
|
+
return failed("project.yaml missing", code="manifest_missing")
|
|
75
|
+
actual = get_in(proj, "project.status")
|
|
76
|
+
if actual == status:
|
|
77
|
+
return passed(f"project.status == {status}")
|
|
78
|
+
return failed(f"project.status mismatch: expected {status!r}, got {actual!r}", code="status_mismatch")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def status_in(workspace: Path, statuses: list[str], project_dir: str = ".") -> AssertionResult:
|
|
82
|
+
proj = load_project_yaml(workspace, project_dir)
|
|
83
|
+
if proj is None:
|
|
84
|
+
return failed("project.yaml missing", code="manifest_missing")
|
|
85
|
+
actual = get_in(proj, "project.status")
|
|
86
|
+
if actual in statuses:
|
|
87
|
+
return passed(f"project.status {actual!r} in {statuses}")
|
|
88
|
+
return failed(f"project.status {actual!r} not in {statuses}", code="status_not_in_set")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def status_agrees_with_validation_report(
|
|
92
|
+
workspace: Path, project_dir: str = ".", report_path: str = "validation/latest-report.json"
|
|
93
|
+
) -> AssertionResult:
|
|
94
|
+
"""project.status must not be 'validated' unless the referenced report
|
|
95
|
+
status is 'passed' (all required checks passed, none warning/failed/not_testable)."""
|
|
96
|
+
proj = load_project_yaml(workspace, project_dir)
|
|
97
|
+
if proj is None:
|
|
98
|
+
return failed("project.yaml missing", code="manifest_missing")
|
|
99
|
+
proj_status = get_in(proj, "project.status")
|
|
100
|
+
report = load_json(project_root(workspace, project_dir) / report_path)
|
|
101
|
+
if report is None:
|
|
102
|
+
if proj_status == "validated":
|
|
103
|
+
return failed(
|
|
104
|
+
"project.status is 'validated' but no validation report exists",
|
|
105
|
+
code="validated_without_report",
|
|
106
|
+
)
|
|
107
|
+
return not_testable("no validation report to cross-check against project.status", code="report_missing")
|
|
108
|
+
|
|
109
|
+
report_status = report.get("status")
|
|
110
|
+
if proj_status == "validated" and report_status != "passed":
|
|
111
|
+
return failed(
|
|
112
|
+
f"project.status is 'validated' but report status is {report_status!r} "
|
|
113
|
+
"(warning/failed/not_testable must never be laundered into validated)",
|
|
114
|
+
code="status_laundering",
|
|
115
|
+
)
|
|
116
|
+
if proj_status == "warning" and report_status == "failed":
|
|
117
|
+
return failed(
|
|
118
|
+
"project.status is 'warning' but report status is 'failed'",
|
|
119
|
+
code="status_understated",
|
|
120
|
+
)
|
|
121
|
+
return passed(f"project.status {proj_status!r} agrees with report status {report_status!r}")
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def graph_resolves(workspace: Path, project_dir: str = ".") -> AssertionResult:
|
|
125
|
+
"""Every step source/input/inputs/target symbol is a sources key or an
|
|
126
|
+
earlier step's output; every step override id and every
|
|
127
|
+
outputs.*.generated_by names something real. Steps are also checked for
|
|
128
|
+
duplicate ids and duplicate produced symbols (a manifest that looks
|
|
129
|
+
complete but silently redefines a step id or output symbol is just as
|
|
130
|
+
unrunnable as a dangling reference)."""
|
|
131
|
+
proj = load_project_yaml(workspace, project_dir)
|
|
132
|
+
if proj is None:
|
|
133
|
+
return failed("project.yaml missing", code="manifest_missing")
|
|
134
|
+
|
|
135
|
+
sources = set((proj.get("sources") or {}).keys())
|
|
136
|
+
steps = get_in(proj, "processing.steps", []) or []
|
|
137
|
+
outputs = proj.get("outputs") or {}
|
|
138
|
+
override_ids = {
|
|
139
|
+
str(item.get("id"))
|
|
140
|
+
for item in (proj.get("overrides") or [])
|
|
141
|
+
if isinstance(item, dict) and item.get("id")
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
produced: set[str] = set()
|
|
145
|
+
step_ids: list[str] = []
|
|
146
|
+
errors: list[str] = []
|
|
147
|
+
|
|
148
|
+
for step in steps:
|
|
149
|
+
step_id = step.get("id")
|
|
150
|
+
if step_id:
|
|
151
|
+
step_ids.append(str(step_id))
|
|
152
|
+
|
|
153
|
+
for key in ("input", "source", "target"):
|
|
154
|
+
val = step.get(key)
|
|
155
|
+
if isinstance(val, str) and val not in sources and val not in produced:
|
|
156
|
+
errors.append(
|
|
157
|
+
f"step {step_id!r} {key}={val!r} resolves to neither a source nor a prior output"
|
|
158
|
+
)
|
|
159
|
+
inputs = step.get("inputs")
|
|
160
|
+
if isinstance(inputs, list):
|
|
161
|
+
for val in inputs:
|
|
162
|
+
if val not in sources and val not in produced:
|
|
163
|
+
errors.append(
|
|
164
|
+
f"step {step_id!r} inputs contains {val!r}, resolves to neither a source nor a prior output"
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
override_ref = step.get("override")
|
|
168
|
+
if override_ref is not None and str(override_ref) not in override_ids:
|
|
169
|
+
errors.append(f"step {step_id!r} override={override_ref!r} is not a declared override id")
|
|
170
|
+
|
|
171
|
+
out = step.get("output")
|
|
172
|
+
new_symbols: list[str] = []
|
|
173
|
+
if isinstance(out, str):
|
|
174
|
+
new_symbols = [name.strip() for name in out.split(",") if name.strip()]
|
|
175
|
+
elif isinstance(out, list):
|
|
176
|
+
new_symbols = [str(item) for item in out]
|
|
177
|
+
for symbol in new_symbols:
|
|
178
|
+
if symbol in produced or symbol in sources:
|
|
179
|
+
errors.append(f"step {step_id!r} produces duplicate symbol {symbol!r}")
|
|
180
|
+
produced.add(symbol)
|
|
181
|
+
|
|
182
|
+
duplicate_steps = [step_id for step_id in set(step_ids) if step_ids.count(step_id) > 1]
|
|
183
|
+
if duplicate_steps:
|
|
184
|
+
errors.append(f"duplicate step ids: {sorted(duplicate_steps)}")
|
|
185
|
+
|
|
186
|
+
for out_key, out_def in outputs.items():
|
|
187
|
+
gen_by = out_def.get("generated_by") if isinstance(out_def, dict) else None
|
|
188
|
+
if gen_by and gen_by not in step_ids:
|
|
189
|
+
errors.append(f"outputs.{out_key}.generated_by={gen_by!r} does not name a real step")
|
|
190
|
+
|
|
191
|
+
if errors:
|
|
192
|
+
return failed("; ".join(errors), errors=errors, code="graph_unresolved")
|
|
193
|
+
return passed(
|
|
194
|
+
f"graph resolves: {len(steps)} steps, {len(produced)} produced symbols, "
|
|
195
|
+
f"{len(outputs)} outputs all traced to real steps"
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def one_canonical_pipeline(
|
|
200
|
+
workspace: Path, project_dir: str = ".", pipeline_path: str = "pipeline.py", wrapper_paths: list[str] | None = None
|
|
201
|
+
) -> AssertionResult:
|
|
202
|
+
"""Convenience/E2E entrypoints must wrap pipeline.py, not duplicate its logic.
|
|
203
|
+
|
|
204
|
+
Heuristic (static, no LLM): a wrapper file should be short and should
|
|
205
|
+
import from the pipeline module rather than redefining its own
|
|
206
|
+
top-level `run_pipeline`/`write_validation`/`write_qgis_project`-shaped
|
|
207
|
+
functions.
|
|
208
|
+
"""
|
|
209
|
+
root = project_root(workspace, project_dir)
|
|
210
|
+
pipeline_file = root / pipeline_path
|
|
211
|
+
if not pipeline_file.exists():
|
|
212
|
+
return failed(f"{pipeline_path} does not exist", code="pipeline_missing")
|
|
213
|
+
|
|
214
|
+
wrapper_paths = wrapper_paths or []
|
|
215
|
+
duplicated: list[str] = []
|
|
216
|
+
for wrapper_rel in wrapper_paths:
|
|
217
|
+
wrapper_file = root / wrapper_rel
|
|
218
|
+
if not wrapper_file.exists():
|
|
219
|
+
continue
|
|
220
|
+
text = wrapper_file.read_text(encoding="utf-8", errors="ignore")
|
|
221
|
+
pipeline_module = Path(pipeline_path).stem
|
|
222
|
+
imports_pipeline = f"import {pipeline_module}" in text or f"from {pipeline_module}" in text
|
|
223
|
+
# crude duplication smell: wrapper defines its own "def main(" AND
|
|
224
|
+
# does not import the pipeline module at all.
|
|
225
|
+
if "def main(" in text and not imports_pipeline:
|
|
226
|
+
duplicated.append(wrapper_rel)
|
|
227
|
+
|
|
228
|
+
if duplicated:
|
|
229
|
+
return failed(
|
|
230
|
+
f"wrapper(s) {duplicated} define their own main() without importing {pipeline_path}",
|
|
231
|
+
code="duplicated_pipeline_logic",
|
|
232
|
+
)
|
|
233
|
+
return passed(f"{pipeline_path} is the canonical implementation; wrappers import it")
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def declared_files_exist(workspace: Path, files: list[str], project_dir: str = ".") -> AssertionResult:
|
|
237
|
+
root = project_root(workspace, project_dir)
|
|
238
|
+
missing = [f for f in files if not (root / f).exists()]
|
|
239
|
+
if missing:
|
|
240
|
+
return failed(f"missing declared files: {missing}", missing=missing, code="declared_files_missing")
|
|
241
|
+
return passed(f"all {len(files)} declared files exist")
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def assumptions_have_rationale(workspace: Path, project_dir: str = ".") -> AssertionResult:
|
|
245
|
+
proj = load_project_yaml(workspace, project_dir)
|
|
246
|
+
if proj is None:
|
|
247
|
+
return failed("project.yaml missing", code="manifest_missing")
|
|
248
|
+
assumptions = get_in(proj, "interpretation.assumptions", []) or []
|
|
249
|
+
if not assumptions:
|
|
250
|
+
return warning("no assumptions declared", code="no_assumptions_declared")
|
|
251
|
+
missing = [
|
|
252
|
+
a.get("id", "?")
|
|
253
|
+
for a in assumptions
|
|
254
|
+
if not a.get("statement") or not a.get("rationale")
|
|
255
|
+
]
|
|
256
|
+
if missing:
|
|
257
|
+
return failed(
|
|
258
|
+
f"assumptions missing statement/rationale: {missing}",
|
|
259
|
+
code="assumption_missing_rationale",
|
|
260
|
+
)
|
|
261
|
+
return passed(f"all {len(assumptions)} assumptions have statement + rationale")
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""Source provenance assertions.
|
|
2
|
+
|
|
3
|
+
See references/project-spec.md section 2.2.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from . import AssertionResult, failed, get_in, load_project_yaml, passed, warning
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def every_source_has_provider_and_access(workspace: Path, project_dir: str = ".") -> AssertionResult:
|
|
14
|
+
proj = load_project_yaml(workspace, project_dir)
|
|
15
|
+
if proj is None:
|
|
16
|
+
return failed("project.yaml missing", code="manifest_missing")
|
|
17
|
+
sources = proj.get("sources") or {}
|
|
18
|
+
if not sources:
|
|
19
|
+
return failed("no sources declared", code="no_sources")
|
|
20
|
+
missing: list[str] = []
|
|
21
|
+
for key, src in sources.items():
|
|
22
|
+
if not src.get("provider"):
|
|
23
|
+
missing.append(f"{key}.provider")
|
|
24
|
+
if not get_in(src, "access.method"):
|
|
25
|
+
missing.append(f"{key}.access.method")
|
|
26
|
+
if not (get_in(src, "access.retrieved_at") or get_in(src, "access.downloaded_at")):
|
|
27
|
+
missing.append(f"{key}.access.retrieved_at")
|
|
28
|
+
if missing:
|
|
29
|
+
return failed(f"sources missing provider/access fields: {missing}", code="provider_access_missing")
|
|
30
|
+
return passed(f"all {len(sources)} sources declare provider + access method + retrieval timestamp")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def every_source_pinned(workspace: Path, project_dir: str = ".") -> AssertionResult:
|
|
34
|
+
"""Pinning to 'latest' is not reproducible — version.identifier/published_at
|
|
35
|
+
must be present and not equal to the literal string 'latest'."""
|
|
36
|
+
proj = load_project_yaml(workspace, project_dir)
|
|
37
|
+
if proj is None:
|
|
38
|
+
return failed("project.yaml missing", code="manifest_missing")
|
|
39
|
+
sources = proj.get("sources") or {}
|
|
40
|
+
if not sources:
|
|
41
|
+
return failed("no sources declared", code="no_sources")
|
|
42
|
+
unpinned: list[str] = []
|
|
43
|
+
for key, src in sources.items():
|
|
44
|
+
identifier = get_in(src, "version.identifier")
|
|
45
|
+
published_at = get_in(src, "version.published_at")
|
|
46
|
+
if not identifier and not published_at:
|
|
47
|
+
unpinned.append(key)
|
|
48
|
+
elif str(identifier).strip().lower() == "latest":
|
|
49
|
+
unpinned.append(key)
|
|
50
|
+
if unpinned:
|
|
51
|
+
return failed(f"sources not pinned to a version/identifier: {unpinned}", code="source_unpinned")
|
|
52
|
+
return passed(f"all {len(sources)} sources are pinned")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def license_present_where_required(
|
|
56
|
+
workspace: Path, required_for: list[str] | None = None, project_dir: str = "."
|
|
57
|
+
) -> AssertionResult:
|
|
58
|
+
proj = load_project_yaml(workspace, project_dir)
|
|
59
|
+
if proj is None:
|
|
60
|
+
return failed("project.yaml missing", code="manifest_missing")
|
|
61
|
+
sources = proj.get("sources") or {}
|
|
62
|
+
required_for = required_for or list(sources.keys())
|
|
63
|
+
missing = [k for k in required_for if k in sources and not get_in(sources[k], "license.name")]
|
|
64
|
+
if missing:
|
|
65
|
+
return failed(f"sources missing license.name: {missing}", code="license_missing")
|
|
66
|
+
return passed("license metadata present for required sources")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def rationale_present(workspace: Path, project_dir: str = ".") -> AssertionResult:
|
|
70
|
+
proj = load_project_yaml(workspace, project_dir)
|
|
71
|
+
if proj is None:
|
|
72
|
+
return failed("project.yaml missing", code="manifest_missing")
|
|
73
|
+
sources = proj.get("sources") or {}
|
|
74
|
+
missing = [k for k, s in sources.items() if not s.get("rationale")]
|
|
75
|
+
if missing:
|
|
76
|
+
return failed(f"sources missing selection rationale: {missing}", code="rationale_missing")
|
|
77
|
+
return passed(f"all {len(sources)} sources document selection rationale")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def bounded_api_completeness(
|
|
81
|
+
workspace: Path, source: str, project_dir: str = "."
|
|
82
|
+
) -> AssertionResult:
|
|
83
|
+
"""For bounded/paginated APIs, matched == returned must be recorded, or
|
|
84
|
+
an explicit reason for incompleteness must be present."""
|
|
85
|
+
proj = load_project_yaml(workspace, project_dir)
|
|
86
|
+
if proj is None:
|
|
87
|
+
return failed("project.yaml missing", code="manifest_missing")
|
|
88
|
+
src = get_in(proj, f"sources.{source}")
|
|
89
|
+
if src is None:
|
|
90
|
+
return failed(f"source {source!r} not declared", code="source_not_declared")
|
|
91
|
+
completeness = get_in(src, "selection.completeness") or get_in(src, "completeness")
|
|
92
|
+
if completeness is None:
|
|
93
|
+
return warning(
|
|
94
|
+
f"source {source!r} does not record completeness (matched/returned)",
|
|
95
|
+
code="completeness_undeclared",
|
|
96
|
+
)
|
|
97
|
+
matched = completeness.get("matched")
|
|
98
|
+
returned = completeness.get("returned")
|
|
99
|
+
if matched is None or returned is None:
|
|
100
|
+
return warning(
|
|
101
|
+
f"source {source!r} completeness block missing matched/returned",
|
|
102
|
+
code="completeness_incomplete_fields",
|
|
103
|
+
)
|
|
104
|
+
if matched != returned:
|
|
105
|
+
return failed(
|
|
106
|
+
f"source {source!r} incomplete: matched={matched} returned={returned} "
|
|
107
|
+
"(a response filled to the page limit is not proof of completeness)",
|
|
108
|
+
code="completeness_mismatch",
|
|
109
|
+
)
|
|
110
|
+
return passed(f"source {source!r} complete: matched == returned == {matched}")
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def semantic_predicate_documented(
|
|
114
|
+
workspace: Path, source: str, project_dir: str = "."
|
|
115
|
+
) -> AssertionResult:
|
|
116
|
+
"""If a source's selection depends on a semantic predicate (ownership,
|
|
117
|
+
active status, etc.), the authoritative field/domain must be documented."""
|
|
118
|
+
proj = load_project_yaml(workspace, project_dir)
|
|
119
|
+
if proj is None:
|
|
120
|
+
return failed("project.yaml missing", code="manifest_missing")
|
|
121
|
+
src = get_in(proj, f"sources.{source}")
|
|
122
|
+
if src is None:
|
|
123
|
+
return failed(f"source {source!r} not declared", code="source_not_declared")
|
|
124
|
+
predicates = get_in(src, "selection.semantic_predicates")
|
|
125
|
+
if not predicates:
|
|
126
|
+
return warning(f"source {source!r} declares no semantic_predicates block", code="predicates_undeclared")
|
|
127
|
+
missing = [p for p in predicates if not p.get("field") or p.get("domain_value") in (None, "")]
|
|
128
|
+
if missing:
|
|
129
|
+
return failed(
|
|
130
|
+
f"source {source!r} has semantic_predicates missing field/domain_value",
|
|
131
|
+
code="predicate_fields_missing",
|
|
132
|
+
)
|
|
133
|
+
return passed(f"source {source!r} documents {len(predicates)} semantic predicate(s)")
|