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.
@@ -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()}"