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,1019 @@
|
|
|
1
|
+
"""Static artifact validation for ``openmapstack-project/v1`` projects."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import re
|
|
7
|
+
import zipfile
|
|
8
|
+
from collections import Counter
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from datetime import date, datetime
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any, Iterable
|
|
13
|
+
|
|
14
|
+
from .integrity import (
|
|
15
|
+
canonical_file_set_hash,
|
|
16
|
+
declared_input_paths,
|
|
17
|
+
declared_output_paths,
|
|
18
|
+
normalize_digest,
|
|
19
|
+
sha256_file,
|
|
20
|
+
)
|
|
21
|
+
from .project import ProjectError, get_in, load_json, load_project, project_path, step_outputs
|
|
22
|
+
from .schema import project_schema_errors
|
|
23
|
+
|
|
24
|
+
SCHEMA = "openmapstack-project/v1"
|
|
25
|
+
CHECK_STATUSES = {"passed", "failed", "warning", "not_testable"}
|
|
26
|
+
PROJECT_STATUSES = {"draft", "in_progress", "validated", "warning", "failed"}
|
|
27
|
+
RUN_STATUSES = {"passed", "warning", "failed"}
|
|
28
|
+
OVERRIDE_RESULTS = {"applied", "rejected", "not_testable"}
|
|
29
|
+
OVERRIDE_ACTIONS = {
|
|
30
|
+
"add_feature",
|
|
31
|
+
"edit_geometry",
|
|
32
|
+
"replace_geometry",
|
|
33
|
+
"modify_attribute",
|
|
34
|
+
"hide_source_feature",
|
|
35
|
+
"remove_source_feature",
|
|
36
|
+
"merge_features",
|
|
37
|
+
"split_feature",
|
|
38
|
+
"add_annotation",
|
|
39
|
+
"add_aoi",
|
|
40
|
+
"add_scenario",
|
|
41
|
+
}
|
|
42
|
+
GEOMETRY_ACTIONS = {
|
|
43
|
+
"add_feature",
|
|
44
|
+
"edit_geometry",
|
|
45
|
+
"replace_geometry",
|
|
46
|
+
"add_annotation",
|
|
47
|
+
"add_aoi",
|
|
48
|
+
"add_scenario",
|
|
49
|
+
}
|
|
50
|
+
TARGET_ACTIONS = {
|
|
51
|
+
"edit_geometry",
|
|
52
|
+
"replace_geometry",
|
|
53
|
+
"modify_attribute",
|
|
54
|
+
"hide_source_feature",
|
|
55
|
+
"remove_source_feature",
|
|
56
|
+
"merge_features",
|
|
57
|
+
"split_feature",
|
|
58
|
+
}
|
|
59
|
+
PLACEHOLDERS = {"", "todo", "tbd", "n/a", "none", "null", "...", "https://..."}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(frozen=True)
|
|
63
|
+
class Check:
|
|
64
|
+
id: str
|
|
65
|
+
status: str
|
|
66
|
+
message: str
|
|
67
|
+
path: str | None = None
|
|
68
|
+
details: dict[str, Any] = field(default_factory=dict)
|
|
69
|
+
|
|
70
|
+
def to_dict(self) -> dict[str, Any]:
|
|
71
|
+
result: dict[str, Any] = {
|
|
72
|
+
"id": self.id,
|
|
73
|
+
"status": self.status,
|
|
74
|
+
"message": self.message,
|
|
75
|
+
}
|
|
76
|
+
if self.path:
|
|
77
|
+
result["path"] = self.path
|
|
78
|
+
if self.details:
|
|
79
|
+
result["details"] = self.details
|
|
80
|
+
return result
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass
|
|
84
|
+
class ValidationResult:
|
|
85
|
+
project_file: Path
|
|
86
|
+
checks: list[Check]
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def status(self) -> str:
|
|
90
|
+
statuses = {check.status for check in self.checks}
|
|
91
|
+
if "failed" in statuses:
|
|
92
|
+
return "failed"
|
|
93
|
+
if statuses & {"warning", "not_testable"}:
|
|
94
|
+
return "warning"
|
|
95
|
+
return "passed"
|
|
96
|
+
|
|
97
|
+
@property
|
|
98
|
+
def counts(self) -> dict[str, int]:
|
|
99
|
+
counts = Counter(check.status for check in self.checks)
|
|
100
|
+
return {status: counts.get(status, 0) for status in ("passed", "warning", "not_testable", "failed")}
|
|
101
|
+
|
|
102
|
+
def ok(self, *, strict: bool = False) -> bool:
|
|
103
|
+
return self.status == "passed" if strict else self.status != "failed"
|
|
104
|
+
|
|
105
|
+
def to_dict(self) -> dict[str, Any]:
|
|
106
|
+
return {
|
|
107
|
+
"schema": "openmapstack-validation-result/v1",
|
|
108
|
+
"project_file": str(self.project_file),
|
|
109
|
+
"status": self.status,
|
|
110
|
+
"counts": self.counts,
|
|
111
|
+
"checks": [check.to_dict() for check in self.checks],
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class _Validator:
|
|
116
|
+
def __init__(self, project_file: Path, project: dict[str, Any], *, artifacts: bool) -> None:
|
|
117
|
+
self.project_file = project_file
|
|
118
|
+
# Resolved so it can never disagree with project_path()'s resolved
|
|
119
|
+
# returns: relative_to() against a mixed pair raises on any symlinked
|
|
120
|
+
# root, which on macOS includes the whole temp and /var tree.
|
|
121
|
+
self.root = project_file.parent.resolve()
|
|
122
|
+
self.project = project
|
|
123
|
+
self.artifacts = artifacts
|
|
124
|
+
self.checks: list[Check] = []
|
|
125
|
+
self.report: dict[str, Any] | None = None
|
|
126
|
+
self.report_path: Path | None = None
|
|
127
|
+
|
|
128
|
+
def add(
|
|
129
|
+
self,
|
|
130
|
+
check_id: str,
|
|
131
|
+
status: str,
|
|
132
|
+
message: str,
|
|
133
|
+
*,
|
|
134
|
+
path: str | None = None,
|
|
135
|
+
**details: Any,
|
|
136
|
+
) -> None:
|
|
137
|
+
self.checks.append(Check(check_id, status, message, path, details))
|
|
138
|
+
|
|
139
|
+
def require(self, condition: bool, check_id: str, message: str, *, path: str | None = None) -> bool:
|
|
140
|
+
if not condition:
|
|
141
|
+
self.add(check_id, "failed", message, path=path)
|
|
142
|
+
return False
|
|
143
|
+
return True
|
|
144
|
+
|
|
145
|
+
def run(self) -> ValidationResult:
|
|
146
|
+
self._schema_and_metadata()
|
|
147
|
+
self._interpretation()
|
|
148
|
+
self._sources()
|
|
149
|
+
self._overrides()
|
|
150
|
+
self._processing_and_outputs()
|
|
151
|
+
self._presentation()
|
|
152
|
+
self._warnings()
|
|
153
|
+
self._runtime()
|
|
154
|
+
self._validation_declaration()
|
|
155
|
+
if self.artifacts:
|
|
156
|
+
self._artifact_files()
|
|
157
|
+
self._validation_report()
|
|
158
|
+
self._run_record()
|
|
159
|
+
else:
|
|
160
|
+
self._run_record_present_files()
|
|
161
|
+
self._declared_status_consistency()
|
|
162
|
+
return ValidationResult(self.project_file, self.checks)
|
|
163
|
+
|
|
164
|
+
def _schema_and_metadata(self) -> None:
|
|
165
|
+
schema_errors = project_schema_errors(self.project)
|
|
166
|
+
if schema_errors:
|
|
167
|
+
self.add(
|
|
168
|
+
"manifest.json_schema",
|
|
169
|
+
"failed",
|
|
170
|
+
"; ".join(schema_errors),
|
|
171
|
+
path="project.yaml",
|
|
172
|
+
errors=schema_errors,
|
|
173
|
+
)
|
|
174
|
+
else:
|
|
175
|
+
self.add(
|
|
176
|
+
"manifest.json_schema",
|
|
177
|
+
"passed",
|
|
178
|
+
"project.yaml conforms to the packaged OpenMapStack v1 JSON Schema",
|
|
179
|
+
path="project.yaml",
|
|
180
|
+
)
|
|
181
|
+
schema = self.project.get("schema")
|
|
182
|
+
if schema == SCHEMA:
|
|
183
|
+
self.add("manifest.schema", "passed", f"schema is {SCHEMA}", path="schema")
|
|
184
|
+
else:
|
|
185
|
+
self.add("manifest.schema", "failed", f"expected {SCHEMA!r}, got {schema!r}", path="schema")
|
|
186
|
+
|
|
187
|
+
metadata = self.project.get("project")
|
|
188
|
+
if not isinstance(metadata, dict):
|
|
189
|
+
self.add("manifest.project", "failed", "project must be a mapping", path="project")
|
|
190
|
+
return
|
|
191
|
+
missing = [key for key in ("id", "title", "question", "created_at", "updated_at", "status") if not _present(metadata.get(key))]
|
|
192
|
+
if missing:
|
|
193
|
+
self.add("manifest.project", "failed", f"project metadata is missing: {', '.join(missing)}", path="project")
|
|
194
|
+
elif metadata.get("status") not in PROJECT_STATUSES:
|
|
195
|
+
self.add("manifest.project", "failed", f"unknown project.status {metadata.get('status')!r}", path="project.status")
|
|
196
|
+
else:
|
|
197
|
+
self.add("manifest.project", "passed", "project identity, timestamps, question, and status are declared", path="project")
|
|
198
|
+
|
|
199
|
+
def _interpretation(self) -> None:
|
|
200
|
+
interpretation = self.project.get("interpretation")
|
|
201
|
+
if not isinstance(interpretation, dict) or not _present(interpretation.get("objective")):
|
|
202
|
+
self.add("interpretation.objective", "failed", "interpretation.objective is required", path="interpretation.objective")
|
|
203
|
+
return
|
|
204
|
+
assumptions = interpretation.get("assumptions")
|
|
205
|
+
if not isinstance(assumptions, list) or not assumptions:
|
|
206
|
+
self.add("interpretation.assumptions", "warning", "no assumptions are documented", path="interpretation.assumptions")
|
|
207
|
+
return
|
|
208
|
+
bad: list[str] = []
|
|
209
|
+
ids: list[str] = []
|
|
210
|
+
for index, assumption in enumerate(assumptions):
|
|
211
|
+
if not isinstance(assumption, dict):
|
|
212
|
+
bad.append(str(index))
|
|
213
|
+
continue
|
|
214
|
+
aid = str(assumption.get("id", index))
|
|
215
|
+
ids.append(aid)
|
|
216
|
+
if not _present(assumption.get("id")) or not _present(assumption.get("statement")) or not _present(assumption.get("rationale")):
|
|
217
|
+
bad.append(aid)
|
|
218
|
+
duplicates = _duplicates(ids)
|
|
219
|
+
if bad or duplicates:
|
|
220
|
+
message = []
|
|
221
|
+
if bad:
|
|
222
|
+
message.append(f"missing id/statement/rationale: {bad}")
|
|
223
|
+
if duplicates:
|
|
224
|
+
message.append(f"duplicate ids: {duplicates}")
|
|
225
|
+
self.add("interpretation.assumptions", "failed", "; ".join(message), path="interpretation.assumptions")
|
|
226
|
+
else:
|
|
227
|
+
self.add("interpretation.assumptions", "passed", f"{len(assumptions)} assumptions have rationale", path="interpretation.assumptions")
|
|
228
|
+
|
|
229
|
+
def _sources(self) -> None:
|
|
230
|
+
sources = self.project.get("sources")
|
|
231
|
+
if not isinstance(sources, dict) or not sources:
|
|
232
|
+
self.add("sources.provenance", "failed", "at least one source is required", path="sources")
|
|
233
|
+
return
|
|
234
|
+
for key, source in sources.items():
|
|
235
|
+
base = f"sources.{key}"
|
|
236
|
+
if not isinstance(source, dict):
|
|
237
|
+
self.add("source.provenance", "failed", "source must be a mapping", path=base)
|
|
238
|
+
continue
|
|
239
|
+
missing: list[str] = []
|
|
240
|
+
required_values = {
|
|
241
|
+
"provider": source.get("provider"),
|
|
242
|
+
"dataset": source.get("dataset"),
|
|
243
|
+
"source_url": source.get("source_url"),
|
|
244
|
+
"access.retrieved_at": get_in(source, "access", "retrieved_at") or get_in(source, "access", "downloaded_at"),
|
|
245
|
+
"version.identifier/published_at": get_in(source, "version", "identifier") or get_in(source, "version", "published_at"),
|
|
246
|
+
"selection": source.get("selection"),
|
|
247
|
+
"rationale": source.get("rationale"),
|
|
248
|
+
}
|
|
249
|
+
for name, value in required_values.items():
|
|
250
|
+
if not _present(value):
|
|
251
|
+
missing.append(name)
|
|
252
|
+
if missing:
|
|
253
|
+
self.add("source.provenance", "failed", f"missing reproducibility fields: {', '.join(missing)}", path=base)
|
|
254
|
+
else:
|
|
255
|
+
self.add("source.provenance", "passed", "source URL, retrieval, version, selection, and rationale are pinned", path=base)
|
|
256
|
+
|
|
257
|
+
license_block = source.get("license")
|
|
258
|
+
if not isinstance(license_block, dict) or not _present(license_block.get("name")) or not _present(license_block.get("url")):
|
|
259
|
+
self.add("source.license", "failed", "license.name and license.url are required", path=f"{base}.license")
|
|
260
|
+
elif _unknown(license_block.get("name")):
|
|
261
|
+
self.add("source.license", "warning", f"license is unresolved: {license_block.get('name')}", path=f"{base}.license")
|
|
262
|
+
else:
|
|
263
|
+
self.add("source.license", "passed", f"license recorded as {license_block.get('name')}", path=f"{base}.license")
|
|
264
|
+
|
|
265
|
+
completeness = source.get("completeness") or get_in(source, "selection", "completeness")
|
|
266
|
+
method = str(get_in(source, "access", "method", default="")).lower()
|
|
267
|
+
bounded = any(token in method for token in ("wfs", "api", "feature", "arcgis"))
|
|
268
|
+
if bounded:
|
|
269
|
+
matched = completeness.get("matched") if isinstance(completeness, dict) else None
|
|
270
|
+
returned = completeness.get("returned") if isinstance(completeness, dict) else None
|
|
271
|
+
if matched is None or returned is None:
|
|
272
|
+
self.add("source.completeness", "warning", "bounded API does not record matched and returned counts", path=base)
|
|
273
|
+
elif matched != returned:
|
|
274
|
+
self.add("source.completeness", "failed", f"bounded API is incomplete: matched={matched}, returned={returned}", path=base)
|
|
275
|
+
else:
|
|
276
|
+
self.add("source.completeness", "passed", f"bounded API completeness proved ({returned}/{matched})", path=base)
|
|
277
|
+
|
|
278
|
+
def _overrides(self) -> None:
|
|
279
|
+
overrides = self.project.get("overrides", [])
|
|
280
|
+
if overrides is None:
|
|
281
|
+
overrides = []
|
|
282
|
+
if not isinstance(overrides, list):
|
|
283
|
+
self.add("overrides.declaration", "failed", "overrides must be a list", path="overrides")
|
|
284
|
+
return
|
|
285
|
+
ids: list[str] = []
|
|
286
|
+
geometry_files: set[Path] = set()
|
|
287
|
+
for index, override in enumerate(overrides):
|
|
288
|
+
base = f"overrides[{index}]"
|
|
289
|
+
if not isinstance(override, dict):
|
|
290
|
+
self.add("override.declaration", "failed", "override must be a mapping", path=base)
|
|
291
|
+
continue
|
|
292
|
+
oid = str(override.get("id", index))
|
|
293
|
+
ids.append(oid)
|
|
294
|
+
missing = [key for key in ("id", "action", "rationale", "created_at", "created_by") if not _present(override.get(key))]
|
|
295
|
+
action = override.get("action")
|
|
296
|
+
if action not in OVERRIDE_ACTIONS:
|
|
297
|
+
missing.append("supported action")
|
|
298
|
+
evidence = override.get("evidence")
|
|
299
|
+
if not isinstance(evidence, list) or not evidence or any(_placeholder_evidence(item) for item in evidence):
|
|
300
|
+
missing.append("non-placeholder evidence")
|
|
301
|
+
if action in TARGET_ACTIONS:
|
|
302
|
+
target = override.get("target")
|
|
303
|
+
if not isinstance(target, dict) or not _present(target.get("source")):
|
|
304
|
+
missing.append("target.source")
|
|
305
|
+
elif str(target.get("source")) not in set((self.project.get("sources") or {}).keys()):
|
|
306
|
+
missing.append("target.source referencing sources.*")
|
|
307
|
+
if action not in {"merge_features", "split_feature"} and (not isinstance(target, dict) or not _present(target.get("feature_id"))):
|
|
308
|
+
missing.append("target.feature_id")
|
|
309
|
+
if action == "modify_attribute":
|
|
310
|
+
change = override.get("change")
|
|
311
|
+
if not isinstance(change, dict) or not _present(change.get("field")) or "from" not in change or "to" not in change:
|
|
312
|
+
missing.append("change.field/from/to")
|
|
313
|
+
if action in GEOMETRY_ACTIONS:
|
|
314
|
+
geometry_rel = get_in(override, "geometry_file", "path")
|
|
315
|
+
target = project_path(self.root, geometry_rel)
|
|
316
|
+
if target is None:
|
|
317
|
+
missing.append("safe geometry_file.path")
|
|
318
|
+
else:
|
|
319
|
+
geometry_files.add(target)
|
|
320
|
+
if not target.is_file():
|
|
321
|
+
self.add("override.geometry_file", "failed", f"geometry file does not exist: {geometry_rel}", path=base)
|
|
322
|
+
if missing:
|
|
323
|
+
self.add("override.declaration", "failed", f"{oid} is missing/invalid: {', '.join(missing)}", path=base)
|
|
324
|
+
else:
|
|
325
|
+
self.add("override.declaration", "passed", f"{oid} has action, provenance, rationale, and evidence", path=base)
|
|
326
|
+
|
|
327
|
+
duplicates = _duplicates(ids)
|
|
328
|
+
if duplicates:
|
|
329
|
+
self.add("overrides.ids", "failed", f"duplicate override ids: {duplicates}", path="overrides")
|
|
330
|
+
elif overrides:
|
|
331
|
+
self.add("overrides.ids", "passed", f"{len(ids)} override ids are unique", path="overrides")
|
|
332
|
+
|
|
333
|
+
if self.artifacts:
|
|
334
|
+
override_dir = self.root / "data" / "overrides"
|
|
335
|
+
if override_dir.is_dir():
|
|
336
|
+
actual = {path.resolve() for path in override_dir.rglob("*") if path.is_file() and path.name != ".gitkeep"}
|
|
337
|
+
unreferenced = sorted(str(path.relative_to(self.root)) for path in actual - geometry_files)
|
|
338
|
+
if unreferenced:
|
|
339
|
+
self.add("overrides.undocumented_files", "warning", f"override files are not referenced by project.yaml: {unreferenced}", path="data/overrides")
|
|
340
|
+
else:
|
|
341
|
+
self.add("overrides.undocumented_files", "passed", "all override geodata files are declared", path="data/overrides")
|
|
342
|
+
|
|
343
|
+
def _processing_and_outputs(self) -> None:
|
|
344
|
+
processing = self.project.get("processing")
|
|
345
|
+
if not isinstance(processing, dict):
|
|
346
|
+
self.add("processing.declaration", "failed", "processing must be a mapping", path="processing")
|
|
347
|
+
return
|
|
348
|
+
analysis_crs = processing.get("analysis_crs")
|
|
349
|
+
storage_crs = processing.get("storage_crs")
|
|
350
|
+
if not _present(analysis_crs) or not _present(storage_crs):
|
|
351
|
+
self.add("processing.crs", "failed", "analysis_crs and storage_crs are required", path="processing")
|
|
352
|
+
elif str(analysis_crs).upper() in {"EPSG:4326", "EPSG:3857"}:
|
|
353
|
+
self.add("processing.crs", "failed", f"{analysis_crs} is not valid for metric analysis", path="processing.analysis_crs")
|
|
354
|
+
else:
|
|
355
|
+
self.add("processing.crs", "passed", f"analysis={analysis_crs}; storage={storage_crs}", path="processing")
|
|
356
|
+
|
|
357
|
+
steps = processing.get("steps")
|
|
358
|
+
if not isinstance(steps, list) or not steps:
|
|
359
|
+
self.add("processing.graph", "failed", "processing.steps must be a non-empty ordered list", path="processing.steps")
|
|
360
|
+
steps = []
|
|
361
|
+
source_symbols = set((self.project.get("sources") or {}).keys())
|
|
362
|
+
override_ids = {
|
|
363
|
+
str(item.get("id"))
|
|
364
|
+
for item in (self.project.get("overrides") or [])
|
|
365
|
+
if isinstance(item, dict) and _present(item.get("id"))
|
|
366
|
+
}
|
|
367
|
+
produced: set[str] = set()
|
|
368
|
+
step_ids: list[str] = []
|
|
369
|
+
graph_errors: list[str] = []
|
|
370
|
+
for index, step in enumerate(steps):
|
|
371
|
+
if not isinstance(step, dict):
|
|
372
|
+
graph_errors.append(f"step {index} is not a mapping")
|
|
373
|
+
continue
|
|
374
|
+
step_id = step.get("id")
|
|
375
|
+
operation = step.get("operation")
|
|
376
|
+
if not _present(step_id) or not _present(operation):
|
|
377
|
+
graph_errors.append(f"step {index} needs id and operation")
|
|
378
|
+
continue
|
|
379
|
+
step_ids.append(str(step_id))
|
|
380
|
+
references: list[tuple[str, object]] = []
|
|
381
|
+
for key in ("input", "source", "target"):
|
|
382
|
+
if key in step:
|
|
383
|
+
references.append((key, step.get(key)))
|
|
384
|
+
inputs = step.get("inputs")
|
|
385
|
+
if inputs is not None:
|
|
386
|
+
if not isinstance(inputs, list):
|
|
387
|
+
graph_errors.append(f"step {step_id} inputs must be a list")
|
|
388
|
+
else:
|
|
389
|
+
references.extend(("inputs", item) for item in inputs)
|
|
390
|
+
for key, reference in references:
|
|
391
|
+
if not isinstance(reference, str) or reference not in source_symbols | produced:
|
|
392
|
+
graph_errors.append(f"step {step_id} {key}={reference!r} is not a source or prior output")
|
|
393
|
+
if "override" in step and str(step.get("override")) not in override_ids:
|
|
394
|
+
graph_errors.append(f"step {step_id} override={step.get('override')!r} is not declared")
|
|
395
|
+
for symbol in step_outputs(step):
|
|
396
|
+
if symbol in produced or symbol in source_symbols:
|
|
397
|
+
graph_errors.append(f"step {step_id} produces duplicate symbol {symbol!r}")
|
|
398
|
+
produced.add(symbol)
|
|
399
|
+
duplicate_steps = _duplicates(step_ids)
|
|
400
|
+
if duplicate_steps:
|
|
401
|
+
graph_errors.append(f"duplicate step ids: {duplicate_steps}")
|
|
402
|
+
|
|
403
|
+
outputs = self.project.get("outputs")
|
|
404
|
+
if not isinstance(outputs, dict) or not outputs:
|
|
405
|
+
self.add("outputs.declaration", "failed", "outputs must be a non-empty mapping", path="outputs")
|
|
406
|
+
outputs = {}
|
|
407
|
+
output_errors: list[str] = []
|
|
408
|
+
for key, output in outputs.items():
|
|
409
|
+
if not isinstance(output, dict):
|
|
410
|
+
output_errors.append(f"{key} is not a mapping")
|
|
411
|
+
continue
|
|
412
|
+
for field_name in ("path", "format", "generated_by"):
|
|
413
|
+
if not _present(output.get(field_name)):
|
|
414
|
+
output_errors.append(f"{key}.{field_name} is missing")
|
|
415
|
+
generated_by = output.get("generated_by")
|
|
416
|
+
if _present(generated_by) and str(generated_by) not in step_ids:
|
|
417
|
+
output_errors.append(f"{key}.generated_by={generated_by!r} is not a step id")
|
|
418
|
+
if _present(output.get("path")) and project_path(self.root, output.get("path")) is None:
|
|
419
|
+
output_errors.append(f"{key}.path escapes the project directory")
|
|
420
|
+
if graph_errors:
|
|
421
|
+
self.add("processing.graph", "failed", "; ".join(graph_errors), path="processing.steps", errors=graph_errors)
|
|
422
|
+
elif steps:
|
|
423
|
+
self.add("processing.graph", "passed", f"{len(steps)} ordered steps and {len(produced)} symbols resolve", path="processing.steps")
|
|
424
|
+
if output_errors:
|
|
425
|
+
self.add("outputs.declaration", "failed", "; ".join(output_errors), path="outputs", errors=output_errors)
|
|
426
|
+
elif outputs:
|
|
427
|
+
self.add("outputs.declaration", "passed", f"{len(outputs)} outputs trace to real steps", path="outputs")
|
|
428
|
+
|
|
429
|
+
def _presentation(self) -> None:
|
|
430
|
+
presentation = self.project.get("presentation")
|
|
431
|
+
if not isinstance(presentation, dict):
|
|
432
|
+
self.add("presentation.declaration", "failed", "presentation must be a mapping", path="presentation")
|
|
433
|
+
return
|
|
434
|
+
missing = [key for key in ("intent", "primary_view", "layout", "map", "provenance_ui") if not _present(presentation.get(key))]
|
|
435
|
+
layers = get_in(presentation, "map", "layers", default=[])
|
|
436
|
+
groups = get_in(presentation, "map", "layer_groups", default=[])
|
|
437
|
+
group_ids = {str(group.get("id")) for group in groups if isinstance(group, dict) and _present(group.get("id"))} if isinstance(groups, list) else set()
|
|
438
|
+
layer_errors: list[str] = []
|
|
439
|
+
if isinstance(layers, list):
|
|
440
|
+
for index, layer in enumerate(layers):
|
|
441
|
+
if not isinstance(layer, dict):
|
|
442
|
+
layer_errors.append(f"layer {index} is not a mapping")
|
|
443
|
+
continue
|
|
444
|
+
if not _present(layer.get("source")) or not _present(layer.get("semantic_role")):
|
|
445
|
+
layer_errors.append(f"layer {index} needs source and semantic_role")
|
|
446
|
+
if _present(layer.get("group")) and str(layer.get("group")) not in group_ids:
|
|
447
|
+
layer_errors.append(f"layer {index} references unknown group {layer.get('group')!r}")
|
|
448
|
+
else:
|
|
449
|
+
layer_errors.append("map.layers must be a list")
|
|
450
|
+
if missing or layer_errors:
|
|
451
|
+
self.add("presentation.declaration", "failed", "; ".join(([f"missing {missing}"] if missing else []) + layer_errors), path="presentation")
|
|
452
|
+
else:
|
|
453
|
+
self.add("presentation.declaration", "passed", f"semantic presentation declares {len(layers)} layers", path="presentation")
|
|
454
|
+
|
|
455
|
+
def _runtime(self) -> None:
|
|
456
|
+
implementation = get_in(self.project, "runtime", "implementation")
|
|
457
|
+
if not isinstance(implementation, dict):
|
|
458
|
+
self.add("runtime.pipeline", "failed", "runtime.implementation is required", path="runtime.implementation")
|
|
459
|
+
return
|
|
460
|
+
command = implementation.get("command")
|
|
461
|
+
pipeline = implementation.get("pipeline")
|
|
462
|
+
if command is None and not _present(pipeline):
|
|
463
|
+
self.add("runtime.pipeline", "failed", "runtime.implementation.pipeline or command is required", path="runtime.implementation")
|
|
464
|
+
return
|
|
465
|
+
if _present(pipeline):
|
|
466
|
+
target = project_path(self.root, pipeline)
|
|
467
|
+
if target is None:
|
|
468
|
+
self.add("runtime.pipeline", "failed", "pipeline path escapes the project directory", path="runtime.implementation.pipeline")
|
|
469
|
+
elif not target.is_file():
|
|
470
|
+
self.add("runtime.pipeline", "failed", f"pipeline does not exist: {pipeline}", path="runtime.implementation.pipeline")
|
|
471
|
+
else:
|
|
472
|
+
self.add("runtime.pipeline", "passed", f"canonical pipeline exists: {pipeline}", path="runtime.implementation.pipeline")
|
|
473
|
+
else:
|
|
474
|
+
self.add("runtime.pipeline", "passed", "explicit shell-free runtime command is declared", path="runtime.implementation.command")
|
|
475
|
+
dependencies = implementation.get("dependencies")
|
|
476
|
+
if dependencies is not None:
|
|
477
|
+
dependency_errors: list[str] = []
|
|
478
|
+
if not isinstance(dependencies, list):
|
|
479
|
+
dependency_errors.append("dependencies must be a list")
|
|
480
|
+
dependencies = []
|
|
481
|
+
elif not dependencies:
|
|
482
|
+
dependency_errors.append("dependencies must not be empty when declared")
|
|
483
|
+
for index, dependency in enumerate(dependencies):
|
|
484
|
+
target = project_path(self.root, dependency)
|
|
485
|
+
if target is None:
|
|
486
|
+
dependency_errors.append(f"dependency {index} is not a safe project-relative path")
|
|
487
|
+
elif not target.exists():
|
|
488
|
+
dependency_errors.append(f"dependency does not exist: {dependency}")
|
|
489
|
+
if dependency_errors:
|
|
490
|
+
self.add(
|
|
491
|
+
"runtime.dependencies",
|
|
492
|
+
"failed",
|
|
493
|
+
"; ".join(dependency_errors),
|
|
494
|
+
path="runtime.implementation.dependencies",
|
|
495
|
+
errors=dependency_errors,
|
|
496
|
+
)
|
|
497
|
+
else:
|
|
498
|
+
self.add(
|
|
499
|
+
"runtime.dependencies",
|
|
500
|
+
"passed",
|
|
501
|
+
f"{len(dependencies)} clean-run dependencies resolve inside the project",
|
|
502
|
+
path="runtime.implementation.dependencies",
|
|
503
|
+
)
|
|
504
|
+
environment = get_in(self.project, "runtime", "environment")
|
|
505
|
+
if not isinstance(environment, dict) or not environment:
|
|
506
|
+
self.add("runtime.environment", "warning", "runtime.environment does not pin tool versions", path="runtime.environment")
|
|
507
|
+
elif any(not _present(value) for value in environment.values()):
|
|
508
|
+
self.add("runtime.environment", "warning", "runtime.environment contains unpinned values", path="runtime.environment")
|
|
509
|
+
else:
|
|
510
|
+
self.add("runtime.environment", "passed", f"{len(environment)} runtime versions are recorded", path="runtime.environment")
|
|
511
|
+
|
|
512
|
+
def _warnings(self) -> None:
|
|
513
|
+
warnings = self.project.get("warnings", [])
|
|
514
|
+
if warnings is None:
|
|
515
|
+
return
|
|
516
|
+
if not isinstance(warnings, list):
|
|
517
|
+
self.add("warnings.declaration", "failed", "warnings must be a list", path="warnings")
|
|
518
|
+
return
|
|
519
|
+
bad: list[str] = []
|
|
520
|
+
ids: list[str] = []
|
|
521
|
+
for index, warning in enumerate(warnings):
|
|
522
|
+
if not isinstance(warning, dict):
|
|
523
|
+
bad.append(str(index))
|
|
524
|
+
continue
|
|
525
|
+
warning_id = str(warning.get("id", index))
|
|
526
|
+
ids.append(warning_id)
|
|
527
|
+
if any(not _present(warning.get(key)) for key in ("id", "severity", "issue", "statement", "mitigation")):
|
|
528
|
+
bad.append(warning_id)
|
|
529
|
+
duplicates = _duplicates(ids)
|
|
530
|
+
if bad or duplicates:
|
|
531
|
+
self.add(
|
|
532
|
+
"warnings.declaration",
|
|
533
|
+
"failed",
|
|
534
|
+
f"warnings missing required fields={bad}; duplicate ids={duplicates}",
|
|
535
|
+
path="warnings",
|
|
536
|
+
)
|
|
537
|
+
elif warnings:
|
|
538
|
+
self.add("warnings.declaration", "passed", f"{len(warnings)} unresolved warnings are documented", path="warnings")
|
|
539
|
+
|
|
540
|
+
def _validation_declaration(self) -> None:
|
|
541
|
+
validation = self.project.get("validation")
|
|
542
|
+
if not isinstance(validation, dict):
|
|
543
|
+
self.add("validation.declaration", "failed", "validation must be a mapping", path="validation")
|
|
544
|
+
return
|
|
545
|
+
required = validation.get("required")
|
|
546
|
+
domains = validation.get("domain_checks", [])
|
|
547
|
+
errors: list[str] = []
|
|
548
|
+
if not isinstance(required, list) or not required or any(not isinstance(item, str) or not item.strip() for item in required):
|
|
549
|
+
errors.append("required must be a non-empty list of flat ids")
|
|
550
|
+
required = []
|
|
551
|
+
if not isinstance(domains, list):
|
|
552
|
+
errors.append("domain_checks must be a list")
|
|
553
|
+
domains = []
|
|
554
|
+
domain_names = [item.get("name") for item in domains if isinstance(item, dict)]
|
|
555
|
+
if len(domain_names) != len(domains) or any(not _present(item) for item in domain_names):
|
|
556
|
+
errors.append("each domain check needs a name")
|
|
557
|
+
declared = [str(item) for item in required] + [str(item) for item in domain_names]
|
|
558
|
+
duplicates = _duplicates(declared)
|
|
559
|
+
if duplicates:
|
|
560
|
+
errors.append(f"duplicate check ids: {duplicates}")
|
|
561
|
+
if errors:
|
|
562
|
+
self.add("validation.declaration", "failed", "; ".join(errors), path="validation")
|
|
563
|
+
else:
|
|
564
|
+
self.add("validation.declaration", "passed", f"{len(declared)} validation checks are declared", path="validation")
|
|
565
|
+
|
|
566
|
+
def _artifact_files(self) -> None:
|
|
567
|
+
outputs = self.project.get("outputs") or {}
|
|
568
|
+
missing: list[str] = []
|
|
569
|
+
declared_targets: set[Path] = set()
|
|
570
|
+
for output in outputs.values() if isinstance(outputs, dict) else []:
|
|
571
|
+
if not isinstance(output, dict):
|
|
572
|
+
continue
|
|
573
|
+
relative = output.get("path")
|
|
574
|
+
target = project_path(self.root, relative)
|
|
575
|
+
if target is not None:
|
|
576
|
+
declared_targets.add(target)
|
|
577
|
+
if not target.exists():
|
|
578
|
+
missing.append(str(relative))
|
|
579
|
+
if missing:
|
|
580
|
+
self.add("outputs.files", "failed", f"declared outputs do not exist: {missing}", path="outputs", missing=missing)
|
|
581
|
+
elif outputs:
|
|
582
|
+
self.add("outputs.files", "passed", f"all {len(outputs)} declared outputs exist", path="outputs")
|
|
583
|
+
|
|
584
|
+
derived_dir = self.root / "data" / "derived"
|
|
585
|
+
if derived_dir.is_dir():
|
|
586
|
+
actual_derived = {
|
|
587
|
+
path.resolve()
|
|
588
|
+
for path in derived_dir.rglob("*")
|
|
589
|
+
if path.is_file() and path.name != ".gitkeep"
|
|
590
|
+
}
|
|
591
|
+
undeclared = sorted(
|
|
592
|
+
str(path.relative_to(self.root)) for path in actual_derived - declared_targets
|
|
593
|
+
)
|
|
594
|
+
if undeclared:
|
|
595
|
+
self.add(
|
|
596
|
+
"outputs.undeclared_derived_files",
|
|
597
|
+
"warning",
|
|
598
|
+
f"derived files are not declared in outputs: {undeclared}",
|
|
599
|
+
path="data/derived",
|
|
600
|
+
undeclared=undeclared,
|
|
601
|
+
)
|
|
602
|
+
else:
|
|
603
|
+
self.add(
|
|
604
|
+
"outputs.undeclared_derived_files",
|
|
605
|
+
"passed",
|
|
606
|
+
"every file under data/derived is a declared output",
|
|
607
|
+
path="data/derived",
|
|
608
|
+
)
|
|
609
|
+
|
|
610
|
+
if not (self.root / "README.md").is_file():
|
|
611
|
+
self.add("project.readme", "warning", "README.md is missing", path="README.md")
|
|
612
|
+
else:
|
|
613
|
+
self.add("project.readme", "passed", "README.md exists", path="README.md")
|
|
614
|
+
if get_in(self.project, "presentation", "primary_view") == "map":
|
|
615
|
+
if not (self.root / "project.qgz").is_file():
|
|
616
|
+
self.add("qgis.project", "warning", "map project has no project.qgz companion", path="project.qgz")
|
|
617
|
+
else:
|
|
618
|
+
self.add("qgis.project", "passed", "project.qgz exists", path="project.qgz")
|
|
619
|
+
self._qgis_layer_crs()
|
|
620
|
+
|
|
621
|
+
def _qgis_layer_crs(self) -> None:
|
|
622
|
+
"""Every map layer in the .qgs must declare a CRS authority id.
|
|
623
|
+
|
|
624
|
+
A layer with no <srs> is assumed to be in the project CRS and is
|
|
625
|
+
never reprojected. On a Web Mercator tile basemap in a project using
|
|
626
|
+
a national grid, that silently draws the background map thousands of
|
|
627
|
+
kilometres from the data while every other signal stays healthy --
|
|
628
|
+
layers valid, datasources resolving, nothing blank. A confidently
|
|
629
|
+
wrong map is worse than a missing one, so this is checked here
|
|
630
|
+
rather than left to whoever notices the picture looks odd.
|
|
631
|
+
"""
|
|
632
|
+
qgz_path = self.root / "project.qgz"
|
|
633
|
+
try:
|
|
634
|
+
with zipfile.ZipFile(qgz_path) as archive:
|
|
635
|
+
names = [name for name in archive.namelist() if name.endswith(".qgs")]
|
|
636
|
+
if not names:
|
|
637
|
+
self.add(
|
|
638
|
+
"qgis.layer_crs", "warning",
|
|
639
|
+
"project.qgz contains no .qgs document to inspect", path="project.qgz",
|
|
640
|
+
)
|
|
641
|
+
return
|
|
642
|
+
xml = archive.read(names[0]).decode("utf-8", errors="ignore")
|
|
643
|
+
except (OSError, zipfile.BadZipFile) as exc:
|
|
644
|
+
self.add("qgis.layer_crs", "failed", f"project.qgz is not readable as a zip: {exc}", path="project.qgz")
|
|
645
|
+
return
|
|
646
|
+
|
|
647
|
+
maplayers = re.findall(r"<maplayer[ >].*?</maplayer>", xml, re.DOTALL)
|
|
648
|
+
if not maplayers:
|
|
649
|
+
self.add("qgis.layer_crs", "warning", "project.qgz declares no map layers", path="project.qgz")
|
|
650
|
+
return
|
|
651
|
+
|
|
652
|
+
missing: list[str] = []
|
|
653
|
+
declared: dict[str, str] = {}
|
|
654
|
+
for layer_xml in maplayers:
|
|
655
|
+
name_match = re.search(r"<layername>(.*?)</layername>", layer_xml, re.DOTALL)
|
|
656
|
+
name = name_match.group(1).strip() if name_match else "?"
|
|
657
|
+
authid = re.search(r"<authid>(.*?)</authid>", layer_xml, re.DOTALL)
|
|
658
|
+
if authid is None or not authid.group(1).strip():
|
|
659
|
+
missing.append(name)
|
|
660
|
+
else:
|
|
661
|
+
declared[name] = authid.group(1).strip()
|
|
662
|
+
if missing:
|
|
663
|
+
self.add(
|
|
664
|
+
"qgis.layer_crs", "failed",
|
|
665
|
+
f"map layers declare no CRS and will not be reprojected: {missing}",
|
|
666
|
+
path="project.qgz", missing=missing, declared=declared,
|
|
667
|
+
)
|
|
668
|
+
else:
|
|
669
|
+
self.add(
|
|
670
|
+
"qgis.layer_crs", "passed",
|
|
671
|
+
f"all {len(declared)} map layers declare a CRS",
|
|
672
|
+
path="project.qgz", declared=declared,
|
|
673
|
+
)
|
|
674
|
+
|
|
675
|
+
def _validation_report(self) -> None:
|
|
676
|
+
relative = get_in(self.project, "runs", "latest", "validation_report", "path") or "validation/latest-report.json"
|
|
677
|
+
target = project_path(self.root, relative)
|
|
678
|
+
if target is None:
|
|
679
|
+
self.add("validation.report", "failed", "validation report path escapes the project directory", path="runs.latest.validation_report.path")
|
|
680
|
+
return
|
|
681
|
+
self.report_path = target
|
|
682
|
+
if not target.is_file():
|
|
683
|
+
self.add("validation.report", "failed", f"validation report does not exist: {relative}", path=str(relative))
|
|
684
|
+
return
|
|
685
|
+
try:
|
|
686
|
+
report = load_json(target)
|
|
687
|
+
except ProjectError as exc:
|
|
688
|
+
self.add("validation.report", "failed", str(exc), path=str(relative))
|
|
689
|
+
return
|
|
690
|
+
self.report = report
|
|
691
|
+
checks = report.get("checks")
|
|
692
|
+
if not isinstance(checks, list):
|
|
693
|
+
self.add("validation.report", "failed", "report.checks must be a list", path=str(relative))
|
|
694
|
+
return
|
|
695
|
+
bad_status = [str(item.get("id", "?")) for item in checks if not isinstance(item, dict) or item.get("status") not in CHECK_STATUSES]
|
|
696
|
+
ids = [str(item.get("id")) for item in checks if isinstance(item, dict) and _present(item.get("id"))]
|
|
697
|
+
duplicate_ids = _duplicates(ids)
|
|
698
|
+
expected = set(get_in(self.project, "validation", "required", default=[]) or [])
|
|
699
|
+
expected.update(
|
|
700
|
+
item.get("name")
|
|
701
|
+
for item in (get_in(self.project, "validation", "domain_checks", default=[]) or [])
|
|
702
|
+
if isinstance(item, dict) and _present(item.get("name"))
|
|
703
|
+
)
|
|
704
|
+
missing = sorted(str(item) for item in expected - set(ids))
|
|
705
|
+
errors: list[str] = []
|
|
706
|
+
if bad_status:
|
|
707
|
+
errors.append(f"checks with invalid/missing status: {bad_status}")
|
|
708
|
+
if duplicate_ids:
|
|
709
|
+
errors.append(f"duplicate check ids: {duplicate_ids}")
|
|
710
|
+
if missing:
|
|
711
|
+
errors.append(f"declared checks missing from report: {missing}")
|
|
712
|
+
actual_statuses = [item.get("status") for item in checks if isinstance(item, dict)]
|
|
713
|
+
expected_status = _rollup_report_status(actual_statuses)
|
|
714
|
+
report_status = report.get("status")
|
|
715
|
+
if report_status != expected_status:
|
|
716
|
+
errors.append(f"report.status={report_status!r}, expected {expected_status!r} from check statuses")
|
|
717
|
+
project_status = get_in(self.project, "project", "status")
|
|
718
|
+
expected_project_status = {"passed": "validated", "warning": "warning", "failed": "failed"}.get(report_status)
|
|
719
|
+
if project_status in {"validated", "warning", "failed"} and project_status != expected_project_status:
|
|
720
|
+
errors.append(f"project.status={project_status!r}, expected {expected_project_status!r} from report")
|
|
721
|
+
if report_status == "failed" and not errors:
|
|
722
|
+
errors.append("report contains failed checks")
|
|
723
|
+
if errors:
|
|
724
|
+
self.add("validation.report", "failed", "; ".join(errors), path=str(relative), errors=errors)
|
|
725
|
+
else:
|
|
726
|
+
status = "warning" if report_status == "warning" else "passed"
|
|
727
|
+
self.add("validation.report", status, f"report has {len(checks)} explicit checks; status={report_status}", path=str(relative))
|
|
728
|
+
self._override_application_results(report)
|
|
729
|
+
|
|
730
|
+
def _override_application_results(self, report: dict[str, Any]) -> None:
|
|
731
|
+
overrides = self.project.get("overrides") or []
|
|
732
|
+
if not overrides:
|
|
733
|
+
return
|
|
734
|
+
declared = [str(item.get("id")) for item in overrides if isinstance(item, dict) and _present(item.get("id"))]
|
|
735
|
+
checks = report.get("checks") or []
|
|
736
|
+
override_check = next((item for item in checks if isinstance(item, dict) and item.get("id") == "overrides_applied"), None)
|
|
737
|
+
results = (override_check or {}).get("results") or report.get("overrides") or []
|
|
738
|
+
indexed: dict[str, list[dict[str, Any]]] = {}
|
|
739
|
+
for result in results if isinstance(results, list) else []:
|
|
740
|
+
if isinstance(result, dict) and _present(result.get("id")):
|
|
741
|
+
indexed.setdefault(str(result.get("id")), []).append(result)
|
|
742
|
+
missing = [oid for oid in declared if len(indexed.get(oid, [])) != 1]
|
|
743
|
+
invalid = [
|
|
744
|
+
oid
|
|
745
|
+
for oid in declared
|
|
746
|
+
if len(indexed.get(oid, [])) == 1 and indexed[oid][0].get("status") not in OVERRIDE_RESULTS
|
|
747
|
+
]
|
|
748
|
+
rejected = [oid for oid in declared if indexed.get(oid) and indexed[oid][0].get("status") == "rejected"]
|
|
749
|
+
not_testable = [oid for oid in declared if indexed.get(oid) and indexed[oid][0].get("status") == "not_testable"]
|
|
750
|
+
if missing or invalid or rejected:
|
|
751
|
+
self.add(
|
|
752
|
+
"overrides.application",
|
|
753
|
+
"failed",
|
|
754
|
+
f"override application results invalid; missing/duplicate={missing}, invalid={invalid}, rejected={rejected}",
|
|
755
|
+
path=str(self.report_path.relative_to(self.root)) if self.report_path else None,
|
|
756
|
+
)
|
|
757
|
+
elif not_testable:
|
|
758
|
+
self.add("overrides.application", "warning", f"overrides not testable: {not_testable}", path="validation/latest-report.json")
|
|
759
|
+
else:
|
|
760
|
+
self.add("overrides.application", "passed", f"all {len(declared)} overrides have one applied result", path="validation/latest-report.json")
|
|
761
|
+
|
|
762
|
+
def _run_record(self) -> None:
|
|
763
|
+
latest = get_in(self.project, "runs", "latest")
|
|
764
|
+
if not isinstance(latest, dict) or not _present(latest.get("id")):
|
|
765
|
+
self.add("runs.latest", "failed", "runs.latest.id is required", path="runs.latest")
|
|
766
|
+
return
|
|
767
|
+
run_id = str(latest.get("id"))
|
|
768
|
+
run_path = self.root / "runs" / f"{run_id}.json"
|
|
769
|
+
if not run_path.is_file():
|
|
770
|
+
self.add("runs.latest", "failed", f"run record does not exist: runs/{run_id}.json", path="runs.latest.id")
|
|
771
|
+
return
|
|
772
|
+
try:
|
|
773
|
+
run = load_json(run_path)
|
|
774
|
+
except ProjectError as exc:
|
|
775
|
+
self.add("runs.latest", "failed", str(exc), path=str(run_path.relative_to(self.root)))
|
|
776
|
+
return
|
|
777
|
+
errors: list[str] = []
|
|
778
|
+
if str(run.get("run_id")) != run_id:
|
|
779
|
+
errors.append(f"run record id {run.get('run_id')!r} != manifest {run_id!r}")
|
|
780
|
+
if self.report is not None and str(self.report.get("run_id")) != run_id:
|
|
781
|
+
errors.append(f"report run_id {self.report.get('run_id')!r} != manifest {run_id!r}")
|
|
782
|
+
report_status = self.report.get("status") if self.report else None
|
|
783
|
+
if report_status and run.get("status") != report_status:
|
|
784
|
+
errors.append(f"run status {run.get('status')!r} != report status {report_status!r}")
|
|
785
|
+
if report_status and latest.get("status") != report_status:
|
|
786
|
+
errors.append(f"manifest run status {latest.get('status')!r} != report status {report_status!r}")
|
|
787
|
+
if run.get("status") not in RUN_STATUSES:
|
|
788
|
+
errors.append(f"invalid run status {run.get('status')!r}")
|
|
789
|
+
for timestamp in ("started_at", "completed_at"):
|
|
790
|
+
if not _present(run.get(timestamp)):
|
|
791
|
+
errors.append(f"run record {timestamp} is missing")
|
|
792
|
+
if not _present(latest.get(timestamp)):
|
|
793
|
+
errors.append(f"manifest runs.latest.{timestamp} is missing")
|
|
794
|
+
environment = run.get("environment")
|
|
795
|
+
if not isinstance(environment, dict) or not environment:
|
|
796
|
+
errors.append("run record environment is missing")
|
|
797
|
+
input_paths = self._verify_run_inventory(
|
|
798
|
+
run.get("inputs"), "input", set(declared_input_paths(self.root, self.project)), errors
|
|
799
|
+
)
|
|
800
|
+
output_paths = self._verify_run_inventory(
|
|
801
|
+
run.get("outputs"), "output", set(declared_output_paths(self.project)), errors
|
|
802
|
+
)
|
|
803
|
+
for hash_name, inventory_paths in (
|
|
804
|
+
("inputs_hash", input_paths),
|
|
805
|
+
("outputs_hash", output_paths),
|
|
806
|
+
):
|
|
807
|
+
labelled_values = {
|
|
808
|
+
"manifest": normalize_digest(latest.get(hash_name)),
|
|
809
|
+
"report": normalize_digest(self.report.get(hash_name)) if self.report else None,
|
|
810
|
+
"run": normalize_digest(run.get(hash_name)),
|
|
811
|
+
}
|
|
812
|
+
invalid_from = [
|
|
813
|
+
label
|
|
814
|
+
for label, value in labelled_values.items()
|
|
815
|
+
if value is None
|
|
816
|
+
]
|
|
817
|
+
if invalid_from:
|
|
818
|
+
errors.append(f"{hash_name} is missing or invalid in {invalid_from}")
|
|
819
|
+
continue
|
|
820
|
+
if not inventory_paths:
|
|
821
|
+
continue
|
|
822
|
+
try:
|
|
823
|
+
actual = canonical_file_set_hash(self.root, inventory_paths)
|
|
824
|
+
except ValueError as exc:
|
|
825
|
+
errors.append(f"cannot recompute {hash_name}: {exc}")
|
|
826
|
+
continue
|
|
827
|
+
wrong = [label for label, value in labelled_values.items() if value != actual]
|
|
828
|
+
if wrong:
|
|
829
|
+
errors.append(
|
|
830
|
+
f"{hash_name} does not match the real canonical file-set hash in {wrong}; "
|
|
831
|
+
f"actual={actual}"
|
|
832
|
+
)
|
|
833
|
+
if errors:
|
|
834
|
+
self.add("runs.latest", "failed", "; ".join(errors), path=str(run_path.relative_to(self.root)), errors=errors)
|
|
835
|
+
else:
|
|
836
|
+
self.add("runs.latest", "passed", f"{run_id} resolves and matches report status/hashes", path=str(run_path.relative_to(self.root)))
|
|
837
|
+
|
|
838
|
+
def _run_record_present_files(self) -> None:
|
|
839
|
+
"""Preflight: every run-record entry whose file is present in this
|
|
840
|
+
checkout must still hash to the value the record claims.
|
|
841
|
+
|
|
842
|
+
The full ``_run_record`` check needs the generated outputs, and a
|
|
843
|
+
project that gitignores its data (they are regenerable, and a county
|
|
844
|
+
cadastral snapshot is 55 MB) has none of them in a fresh clone -- which
|
|
845
|
+
is why CI runs preflight there. But the inputs that ARE committed, the
|
|
846
|
+
pipeline above all, can be compared without any of that, and the
|
|
847
|
+
pipeline is exactly where the record goes stale: the code is edited,
|
|
848
|
+
the run is not repeated, and the committed record then describes a
|
|
849
|
+
version of the pipeline that no longer exists.
|
|
850
|
+
"""
|
|
851
|
+
latest = get_in(self.project, "runs", "latest")
|
|
852
|
+
run_id = str(latest.get("id")) if isinstance(latest, dict) else ""
|
|
853
|
+
run_path = self.root / "runs" / f"{run_id}.json" if run_id else None
|
|
854
|
+
# A project that has not run yet has no record to compare against, and
|
|
855
|
+
# preflight exists precisely for that state: stay silent and let the
|
|
856
|
+
# full run-record check speak once artifacts exist.
|
|
857
|
+
if run_path is None or not run_path.is_file():
|
|
858
|
+
return
|
|
859
|
+
try:
|
|
860
|
+
run = load_json(run_path)
|
|
861
|
+
except ProjectError:
|
|
862
|
+
return
|
|
863
|
+
|
|
864
|
+
stale: list[str] = []
|
|
865
|
+
compared = 0
|
|
866
|
+
for kind in ("inputs", "outputs"):
|
|
867
|
+
for item in run.get(kind) or []:
|
|
868
|
+
if not isinstance(item, dict):
|
|
869
|
+
continue
|
|
870
|
+
target = project_path(self.root, item.get("path"))
|
|
871
|
+
expected = normalize_digest(item.get("sha256"))
|
|
872
|
+
# Absent files are the regenerable ones this mode exists for.
|
|
873
|
+
if target is None or not target.is_file() or expected is None:
|
|
874
|
+
continue
|
|
875
|
+
compared += 1
|
|
876
|
+
if sha256_file(target) != expected:
|
|
877
|
+
stale.append(target.relative_to(self.root).as_posix())
|
|
878
|
+
if stale:
|
|
879
|
+
self.add(
|
|
880
|
+
"runs.present_files",
|
|
881
|
+
"failed",
|
|
882
|
+
f"files changed since the recorded run: {stale}; re-run the pipeline "
|
|
883
|
+
"so the run record describes the code and data actually present",
|
|
884
|
+
path=str(run_path.relative_to(self.root)),
|
|
885
|
+
stale=stale,
|
|
886
|
+
)
|
|
887
|
+
elif compared:
|
|
888
|
+
self.add(
|
|
889
|
+
"runs.present_files",
|
|
890
|
+
"passed",
|
|
891
|
+
f"all {compared} run-record file(s) present in this checkout match {run_id}",
|
|
892
|
+
path=str(run_path.relative_to(self.root)),
|
|
893
|
+
)
|
|
894
|
+
else:
|
|
895
|
+
self.add(
|
|
896
|
+
"runs.present_files",
|
|
897
|
+
"not_testable",
|
|
898
|
+
f"no file listed by {run_id} is present in this checkout",
|
|
899
|
+
path=str(run_path.relative_to(self.root)),
|
|
900
|
+
)
|
|
901
|
+
|
|
902
|
+
def _verify_run_inventory(
|
|
903
|
+
self,
|
|
904
|
+
inventory: object,
|
|
905
|
+
kind: str,
|
|
906
|
+
required_paths: set[str],
|
|
907
|
+
errors: list[str],
|
|
908
|
+
) -> list[str]:
|
|
909
|
+
if not isinstance(inventory, list) or not inventory:
|
|
910
|
+
errors.append(f"run record {kind} inventory is missing")
|
|
911
|
+
return []
|
|
912
|
+
verified: list[str] = []
|
|
913
|
+
seen: set[str] = set()
|
|
914
|
+
for index, item in enumerate(inventory):
|
|
915
|
+
if not isinstance(item, dict):
|
|
916
|
+
errors.append(f"run {kind} {index} is not a mapping")
|
|
917
|
+
continue
|
|
918
|
+
relative = item.get("path")
|
|
919
|
+
target = project_path(self.root, relative)
|
|
920
|
+
expected_hash = normalize_digest(item.get("sha256"))
|
|
921
|
+
if target is None:
|
|
922
|
+
errors.append(f"run {kind} {index} has an unsafe path")
|
|
923
|
+
continue
|
|
924
|
+
normalized_path = target.relative_to(self.root).as_posix()
|
|
925
|
+
if normalized_path in seen:
|
|
926
|
+
errors.append(f"run {kind} inventory has duplicate path: {normalized_path}")
|
|
927
|
+
continue
|
|
928
|
+
seen.add(normalized_path)
|
|
929
|
+
if not target.is_file():
|
|
930
|
+
errors.append(f"run {kind} does not exist: {normalized_path}")
|
|
931
|
+
elif expected_hash is None:
|
|
932
|
+
errors.append(f"run {kind} has missing or invalid sha256: {normalized_path}")
|
|
933
|
+
elif sha256_file(target) != expected_hash:
|
|
934
|
+
errors.append(f"run {kind} hash mismatch: {normalized_path}")
|
|
935
|
+
else:
|
|
936
|
+
verified.append(normalized_path)
|
|
937
|
+
omitted = sorted(required_paths - seen)
|
|
938
|
+
if omitted:
|
|
939
|
+
errors.append(
|
|
940
|
+
f"declared {kind}s do not participate in run {kind} hashing: {omitted}"
|
|
941
|
+
)
|
|
942
|
+
return verified
|
|
943
|
+
|
|
944
|
+
def _declared_status_consistency(self) -> None:
|
|
945
|
+
project_status = get_in(self.project, "project", "status")
|
|
946
|
+
non_passed = [check for check in self.checks if check.status in {"warning", "not_testable"}]
|
|
947
|
+
if project_status == "validated" and non_passed:
|
|
948
|
+
self.add(
|
|
949
|
+
"project.status_consistency",
|
|
950
|
+
"failed",
|
|
951
|
+
"project.status is validated but the artifact has warnings or not-testable checks",
|
|
952
|
+
path="project.status",
|
|
953
|
+
checks=[check.id for check in non_passed],
|
|
954
|
+
)
|
|
955
|
+
elif self.artifacts and project_status in {"draft", "in_progress"}:
|
|
956
|
+
self.add(
|
|
957
|
+
"project.status_consistency",
|
|
958
|
+
"warning",
|
|
959
|
+
f"project.status is {project_status!r}; the artifact is not finalized",
|
|
960
|
+
path="project.status",
|
|
961
|
+
)
|
|
962
|
+
|
|
963
|
+
|
|
964
|
+
def validate_project(value: str | Path, *, artifacts: bool = True) -> ValidationResult:
|
|
965
|
+
"""Validate a project manifest and, by default, its generated artifacts."""
|
|
966
|
+
try:
|
|
967
|
+
project_file, project = load_project(value)
|
|
968
|
+
except ProjectError as exc:
|
|
969
|
+
project_file = Path(value).expanduser().resolve()
|
|
970
|
+
return ValidationResult(project_file, [Check("manifest.parse", "failed", str(exc))])
|
|
971
|
+
return _Validator(project_file, project, artifacts=artifacts).run()
|
|
972
|
+
|
|
973
|
+
|
|
974
|
+
def _present(value: object) -> bool:
|
|
975
|
+
if value is None:
|
|
976
|
+
return False
|
|
977
|
+
if isinstance(value, str):
|
|
978
|
+
normalized = value.strip().lower()
|
|
979
|
+
return normalized not in PLACEHOLDERS and not normalized.startswith("todo-") and not normalized.startswith("todo ")
|
|
980
|
+
if isinstance(value, (list, dict, tuple, set)):
|
|
981
|
+
return bool(value)
|
|
982
|
+
if isinstance(value, (date, datetime)):
|
|
983
|
+
return True
|
|
984
|
+
return True
|
|
985
|
+
|
|
986
|
+
|
|
987
|
+
def _unknown(value: object) -> bool:
|
|
988
|
+
text = str(value).strip().lower()
|
|
989
|
+
return any(marker in text for marker in ("unknown", "not stated", "unresolved", "tbd", "todo"))
|
|
990
|
+
|
|
991
|
+
|
|
992
|
+
def _placeholder_evidence(value: object) -> bool:
|
|
993
|
+
if isinstance(value, dict):
|
|
994
|
+
candidate = value.get("value") or value.get("source") or value.get("title")
|
|
995
|
+
else:
|
|
996
|
+
candidate = value
|
|
997
|
+
return not _present(candidate)
|
|
998
|
+
|
|
999
|
+
|
|
1000
|
+
def _duplicates(values: Iterable[str]) -> list[str]:
|
|
1001
|
+
counts = Counter(values)
|
|
1002
|
+
return sorted(value for value, count in counts.items() if count > 1)
|
|
1003
|
+
|
|
1004
|
+
|
|
1005
|
+
def _rollup_report_status(statuses: Iterable[object]) -> str:
|
|
1006
|
+
values = set(statuses)
|
|
1007
|
+
if "failed" in values:
|
|
1008
|
+
return "failed"
|
|
1009
|
+
if values & {"warning", "not_testable"}:
|
|
1010
|
+
return "warning"
|
|
1011
|
+
return "passed"
|
|
1012
|
+
|
|
1013
|
+
|
|
1014
|
+
def _sha256(path: Path) -> str:
|
|
1015
|
+
digest = hashlib.sha256()
|
|
1016
|
+
with path.open("rb") as stream:
|
|
1017
|
+
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
|
|
1018
|
+
digest.update(chunk)
|
|
1019
|
+
return f"sha256:{digest.hexdigest()}"
|