openmapstack 0.3.0__py3-none-any.whl → 0.5.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.
Files changed (51) hide show
  1. openmapstack/__init__.py +1 -1
  2. openmapstack/api.py +23 -2
  3. openmapstack/checks/__init__.py +4 -3
  4. openmapstack/checks/_axis_order.py +66 -0
  5. openmapstack/checks/credits.py +318 -0
  6. openmapstack/checks/delivery.py +231 -0
  7. openmapstack/checks/findings.py +19 -0
  8. openmapstack/checks/geodata.py +352 -14
  9. openmapstack/checks/integration.py +39 -0
  10. openmapstack/checks/overrides.py +10 -7
  11. openmapstack/checks/presentation.py +238 -5
  12. openmapstack/checks/project.py +38 -10
  13. openmapstack/checks/qgis.py +177 -18
  14. openmapstack/checks/rerun.py +102 -0
  15. openmapstack/checks/spatial.py +17 -7
  16. openmapstack/checks/tables.py +330 -0
  17. openmapstack/checks/tiling.py +83 -0
  18. openmapstack/checks/validation.py +51 -4
  19. openmapstack/checks/visual.py +845 -53
  20. openmapstack/cli.py +361 -10
  21. openmapstack/collection.py +191 -0
  22. openmapstack/connectors/__init__.py +101 -14
  23. openmapstack/connectors/bigquery.py +410 -0
  24. openmapstack/connectors/motherduck.py +279 -0
  25. openmapstack/credits.py +150 -0
  26. openmapstack/declarations.py +41 -0
  27. openmapstack/delivery.py +209 -0
  28. openmapstack/environment.py +193 -0
  29. openmapstack/findings.py +197 -0
  30. openmapstack/integration.py +154 -0
  31. openmapstack/integrations/__init__.py +1 -0
  32. openmapstack/integrations/qgis.py +132 -0
  33. openmapstack/integrity.py +16 -9
  34. openmapstack/parameters.py +64 -2
  35. openmapstack/rerun.py +19 -0
  36. openmapstack/sampling.py +202 -0
  37. openmapstack/schema.py +38 -1
  38. openmapstack/schemas/findings-v1.schema.json +53 -0
  39. openmapstack/schemas/project-v1.schema.json +318 -7
  40. openmapstack/schemas/skill-snapshot-v2.schema.json +25 -0
  41. openmapstack/snapshot.py +3 -0
  42. openmapstack/validation.py +302 -98
  43. openmapstack/verify.py +127 -9
  44. openmapstack-0.5.0.dist-info/METADATA +565 -0
  45. openmapstack-0.5.0.dist-info/RECORD +61 -0
  46. {openmapstack-0.3.0.dist-info → openmapstack-0.5.0.dist-info}/WHEEL +1 -1
  47. openmapstack-0.3.0.dist-info/METADATA +0 -300
  48. openmapstack-0.3.0.dist-info/RECORD +0 -40
  49. {openmapstack-0.3.0.dist-info → openmapstack-0.5.0.dist-info}/entry_points.txt +0 -0
  50. {openmapstack-0.3.0.dist-info → openmapstack-0.5.0.dist-info}/licenses/LICENSE +0 -0
  51. {openmapstack-0.3.0.dist-info → openmapstack-0.5.0.dist-info}/top_level.txt +0 -0
openmapstack/__init__.py CHANGED
@@ -3,4 +3,4 @@
3
3
  from .validation import Check, ValidationResult, validate_project
4
4
 
5
5
  __all__ = ["Check", "ValidationResult", "validate_project"]
6
- __version__ = "0.3.0"
6
+ __version__ = "0.5.0"
openmapstack/api.py CHANGED
@@ -65,7 +65,13 @@ CHECK_MODULES = (
65
65
  "overrides",
66
66
  "validation",
67
67
  "geodata",
68
+ "tiling",
69
+ "tables",
70
+ "findings",
71
+ "credits",
68
72
  "presentation",
73
+ "delivery",
74
+ "integration",
69
75
  "qgis",
70
76
  "visual",
71
77
  "rerun",
@@ -79,16 +85,23 @@ DIMENSIONS = {
79
85
  "overrides": "override_handling",
80
86
  "provenance": "provenance",
81
87
  "geodata": "gis_correctness",
88
+ "tiling": "gis_correctness",
89
+ "tables": "table_correctness",
90
+ "findings": "reproducibility_compliance",
91
+ "credits": "presentation_contract",
82
92
  "validation": "validation_integrity",
83
93
  "qgis": "presentation_contract",
84
94
  "presentation": "presentation_contract",
95
+ "delivery": "presentation_contract",
96
+ "integration": "reproducibility_compliance",
85
97
  "visual": "visual_judgement",
86
98
  "rerun": "rerun_success",
87
99
  "metamorphic": "metamorphic_evidence",
88
100
  }
89
101
 
90
- # The checks that need a known answer. They are reachable on user data
91
- # only through validation.expectations[] attestations.
102
+ # Checks that need an independent known answer. Automatic user-project plans
103
+ # admit only the allowlisted validation.expectations[] attestations; external
104
+ # harnesses may supply their own oracles to the other known-answer checks.
92
105
  KNOWN_ANSWER_CHECKS = frozenset(
93
106
  {
94
107
  "geodata.row_count",
@@ -96,6 +109,9 @@ KNOWN_ANSWER_CHECKS = frozenset(
96
109
  "geodata.feature_absent",
97
110
  "geodata.feature_field_equals",
98
111
  "geodata.field_range",
112
+ "geodata.coordinate_pair_equals",
113
+ "tiling.command_matches",
114
+ "delivery.selection_is",
99
115
  }
100
116
  )
101
117
 
@@ -263,6 +279,11 @@ def api_info() -> dict[str, Any]:
263
279
  "package_version": __version__,
264
280
  "check_api_version": CHECK_API_VERSION,
265
281
  "project_schema": PROJECT_SCHEMA,
282
+ "delivery_schemas": ["openmapstack-delivery/v1"],
283
+ "integration_schemas": ["openmapstack-integrations/v1"],
284
+ "delivery_kinds": ["dashboard", "qgis", "observable"],
285
+ "skill_snapshot_schemas": ["openmapstack-skill-snapshot/v1", "openmapstack-skill-snapshot/v2"],
286
+ "benchmark_arm_schemas": ["openmapstack-benchmark-arm/v1", "openmapstack-benchmark-arm/v2"],
266
287
  "result_schemas": {
267
288
  "check": CHECK_RESULT_SCHEMA,
268
289
  "verify": VERIFY_RESULT_SCHEMA,
@@ -22,9 +22,10 @@ That is the point of it living in the shipped package rather than under
22
22
  any correct project on any data, so they transfer to data this repository
23
23
  has never seen. The exceptions — the ones that need a known answer — are
24
24
  `geodata.row_count(equals=)`, `feature_present`, `feature_absent`,
25
- `feature_field_equals`, and `field_range`. On user data those are reachable
26
- only through allowlisted, input-bound `validation.expectations` attestations,
27
- never from an answer the pipeline computed and certified for itself.
25
+ `feature_field_equals`, `field_range`, `coordinate_pair_equals`, and `tiling.command_matches`. The automatic
26
+ user-project plan admits only allowlisted, input-bound `validation.expectations`
27
+ attestations, never an answer the pipeline computed and certified for itself.
28
+ External harnesses can supply independent oracles to other known-answer checks.
28
29
  """
29
30
 
30
31
  from __future__ import annotations
@@ -0,0 +1,66 @@
1
+ """Detect obvious omissions, without trying to prove Python connection state.
2
+
3
+ Setup anywhere in the source is only a reason to defer to runtime evidence.
4
+ Bindings, execution order, aliases and side effects are deliberately not
5
+ interpreted: this advisory check can never certify coordinate order.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import ast
10
+ import re
11
+
12
+ _SENSITIVE = re.compile(r"\bST_Transform\s*\(|\bCOPY\b[\s\S]*\bFORMAT\s+['\"]?GDAL\b", re.I)
13
+ _HELPERS = {'configure_spatial_axes', 'connect_spatial', 'require_spatial'}
14
+ _SQL_METHODS = {'execute', 'sql', 'query'}
15
+
16
+
17
+ def inspect_python(text: str) -> list[int]:
18
+ """Return sensitive SQL lines only when there is no setup evidence.
19
+
20
+ Evidence is syntactic presence, not proof that a setting executes, remains
21
+ set, or belongs to the connection doing the work. Dynamic SQL also defers:
22
+ it might contain setup that cannot be recognized as a literal here.
23
+ """
24
+ tree = ast.parse(text)
25
+ duckdb_seen = False
26
+ helper_names = set(_HELPERS)
27
+ sql_names = set()
28
+ for node in ast.walk(tree):
29
+ if isinstance(node, ast.Import):
30
+ duckdb_seen |= any(alias.name == 'duckdb' for alias in node.names)
31
+ elif isinstance(node, ast.ImportFrom):
32
+ if node.module == 'duckdb':
33
+ duckdb_seen = True
34
+ sql_names.update(alias.asname or alias.name for alias in node.names
35
+ if alias.name in _SQL_METHODS)
36
+ elif node.level == 0 and node.module == 'openmapstack.checks.spatial':
37
+ duckdb_seen = True
38
+ helper_names.update(alias.asname or alias.name for alias in node.names
39
+ if alias.name in _HELPERS)
40
+ if not duckdb_seen:
41
+ return []
42
+
43
+ sensitive = []
44
+ setup_or_unknown = False
45
+ for node in ast.walk(tree):
46
+ if not isinstance(node, ast.Call):
47
+ continue
48
+ fn = node.func
49
+ name = fn.attr if isinstance(fn, ast.Attribute) else fn.id if isinstance(fn, ast.Name) else None
50
+ if name in helper_names:
51
+ setup_or_unknown = True
52
+ if not ((isinstance(fn, ast.Attribute) and name in _SQL_METHODS)
53
+ or (isinstance(fn, ast.Name) and name in sql_names)):
54
+ continue
55
+ sql = node.args[0] if node.args else next(
56
+ (kw.value for kw in node.keywords if kw.arg in ('query', 'sql')), None)
57
+ if not isinstance(sql, ast.Constant) or not isinstance(sql.value, str):
58
+ setup_or_unknown = True
59
+ continue
60
+ # A mention may be a RESET, quoted value, or conditional setup. None
61
+ # proves correctness; all make the omission diagnosis inconclusive.
62
+ if 'geometry_always_xy' in sql.value.lower() or '--' in sql.value or '/*' in sql.value:
63
+ setup_or_unknown = True
64
+ if _SENSITIVE.search(sql.value):
65
+ sensitive.append(node.lineno)
66
+ return sorted(set(sensitive)) if not setup_or_unknown else []
@@ -0,0 +1,318 @@
1
+ """Declared delivery identity and actual HTML/QGIS credit values.
2
+
3
+ No network is used: links must be structurally valid and match the manifest;
4
+ remote endpoint availability and natural-language translation are not certified.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ from decimal import Decimal
9
+ import re
10
+ import xml.etree.ElementTree as ET
11
+ import zipfile
12
+ from html.parser import HTMLParser
13
+ from pathlib import Path
14
+ from urllib.parse import quote
15
+
16
+ from . import AssertionResult, failed, load_project_yaml, passed, project_root, warning, not_testable
17
+ from .. import credits
18
+ from ..delivery import targets
19
+ from ..project import get_in, project_path
20
+
21
+
22
+ def declaration_valid(workspace: Path, project_dir: str = ".") -> AssertionResult:
23
+ project = load_project_yaml(workspace, project_dir)
24
+ if project is None:
25
+ return failed("project.yaml missing", code="manifest_missing")
26
+ if not isinstance(project.get("project"), dict):
27
+ return failed("project metadata must be an object", code="credits_declaration_invalid")
28
+ if not credits.declared(project):
29
+ return warning("legacy v1 project has neither author nor generated_with; add both before new delivery",
30
+ code="credits_legacy_missing")
31
+ errors = credits.declaration_errors(project)
32
+ if errors:
33
+ return failed("; ".join(errors), code="credits_declaration_invalid")
34
+ try:
35
+ credits.credit_payload(project)
36
+ except (ValueError, TypeError, AttributeError) as exc:
37
+ return failed(str(exc), code="credits_declaration_invalid")
38
+ return passed("delivery author and toolkit metadata are complete")
39
+
40
+
41
+ def _surfaces(root: Path, project: dict) -> dict[str, str]:
42
+ selected = targets(project)
43
+ if selected:
44
+ surfaces = {project["outputs"][target["output"]]["path"]: target["kind"] for target in selected}
45
+ for view in project.get("views") or []:
46
+ if isinstance(view, dict):
47
+ surfaces[project["outputs"][view["output"]]["path"]] = "dashboard"
48
+ return surfaces
49
+ surfaces = {}
50
+ for output in (project.get("outputs") or {}).values():
51
+ path = output.get("path", "") if isinstance(output, dict) else ""
52
+ suffix = Path(path).suffix.lower()
53
+ if suffix in {".html", ".qgz", ".qgs"}:
54
+ surfaces[path] = "qgis" if suffix != ".html" else "dashboard"
55
+ if get_in(project, "presentation", "dashboard") is not None:
56
+ surfaces.setdefault("dashboard.html", "dashboard")
57
+ if (root / "project.qgz").is_file():
58
+ surfaces.setdefault("project.qgz", "qgis")
59
+ return surfaces
60
+
61
+
62
+ class _HTML(HTMLParser):
63
+ def __init__(self):
64
+ super().__init__(convert_charrefs=True)
65
+ self.stack = []
66
+ self.nodes = []
67
+ self.text_parts = []
68
+
69
+ def handle_starttag(self, tag, attrs):
70
+ node = {"tag": tag, "attrs": dict(attrs), "text": "", "own_text": "", "parents": list(self.stack)}
71
+ self.nodes.append(node)
72
+ if tag not in {"area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "param", "source", "track", "wbr"}:
73
+ self.stack.append(node)
74
+
75
+ def handle_endtag(self, tag):
76
+ for index in range(len(self.stack) - 1, -1, -1):
77
+ if self.stack[index]["tag"] == tag:
78
+ del self.stack[index:]
79
+ break
80
+
81
+ def handle_data(self, data):
82
+ if any(node["tag"] in {"script", "style", "template"} for node in self.stack):
83
+ return
84
+ for node in self.stack:
85
+ node["text"] += data
86
+ if self.stack:
87
+ self.stack[-1]["own_text"] += data
88
+ self.text_parts.append((self.stack[-1], data))
89
+
90
+
91
+ class _AmbiguousStyle(Exception):
92
+ """Inline CSS outside the deliberately small static visibility contract."""
93
+
94
+
95
+ def _inline_hidden(style: str) -> bool:
96
+ """Recognize simple declarations only; leave CSS interpretation to Chromium."""
97
+ if not style:
98
+ return False
99
+ if re.search(r"[\"'()/{}\\]|--", style):
100
+ raise _AmbiguousStyle("comments, strings, functions or custom properties")
101
+ seen, hidden = set(), False
102
+ for declaration in style.split(";"):
103
+ if not declaration.strip():
104
+ continue
105
+ match = re.fullmatch(r"\s*([a-zA-Z][\w-]*)\s*:\s*([^:;!]+?)(?:\s*!\s*important)?\s*", declaration, re.I)
106
+ if not match:
107
+ raise _AmbiguousStyle("not a simple declaration list")
108
+ name, value = match[1].lower(), match[2].strip().lower()
109
+ if name in seen:
110
+ raise _AmbiguousStyle("repeated declaration: " + name)
111
+ seen.add(name)
112
+ if name == "opacity":
113
+ if not re.fullmatch(r"[+-]?(?:\d*\.\d+|\d+)", value) or Decimal(value) < 0:
114
+ raise _AmbiguousStyle("opacity requires a simple nonnegative number")
115
+ hidden |= Decimal(value) == 0
116
+ elif name in {"display", "visibility"}:
117
+ allowed = ({"none", "block", "inline", "inline-block", "flex", "grid", "contents"}
118
+ if name == "display" else {"visible", "hidden", "collapse"})
119
+ if value not in allowed:
120
+ raise _AmbiguousStyle("unsupported " + name + " value")
121
+ hidden |= value in {"none", "hidden", "collapse"}
122
+ return hidden
123
+
124
+
125
+ def _reachable_panel(node: dict, nodes: list[dict], visiting: tuple[int, ...]) -> bool:
126
+ """A hidden panel needs an unambiguous, visible and enabled tab control.
127
+
128
+ Static associations do not prove that its handler works; browser QA does.
129
+ Cycles and a control inside its own hidden panel cannot establish access.
130
+ """
131
+ attrs = node["attrs"]
132
+ panel_id = attrs.get("id")
133
+ if not panel_id or sum(n["attrs"].get("id") == panel_id for n in nodes) != 1:
134
+ return False
135
+ labels = attrs.get("aria-labelledby", "").split()
136
+ for tab in nodes:
137
+ values = tab["attrs"]
138
+ tab_id = values.get("id")
139
+ if (values.get("role") != "tab" or not tab_id or tab_id not in labels
140
+ or panel_id not in values.get("aria-controls", "").split()
141
+ or sum(n["attrs"].get("id") == tab_id for n in nodes) != 1
142
+ or any(parent is node for parent in tab["parents"])
143
+ or (tab["tag"] == "input" and values.get("type", "").lower() == "hidden")
144
+ or "disabled" in values or values.get("aria-disabled") == "true"
145
+ or any("disabled" in parent["attrs"] and parent["tag"] == "fieldset"
146
+ or parent["attrs"].get("aria-disabled") == "true" for parent in tab["parents"])
147
+ or values.get("aria-selected", "false") != "false"):
148
+ continue
149
+ label_ids = values.get("aria-labelledby", "").split()
150
+ descendants = [n for n in nodes if n is tab or any(parent is tab for parent in n["parents"])]
151
+ # Implicit names use rendered content, not text/alt hidden inside the
152
+ # control. Explicit aria-labelledby references may name hidden nodes.
153
+ visible_content = [n for n in descendants if not _hidden(n, nodes, visiting)]
154
+ named = (any(n["own_text"].strip() or (n is not tab and n["attrs"].get("alt", "").strip()) for n in visible_content)
155
+ or values.get("aria-label", "").strip()
156
+ or any(n["attrs"].get("id") in label_ids and n["text"].strip() for n in nodes)
157
+ )
158
+ if not named:
159
+ continue
160
+ if not _hidden(tab, nodes, visiting):
161
+ return True
162
+ return False
163
+
164
+
165
+ def _hidden(node: dict, nodes: list[dict], visiting: tuple[int, ...] = ()) -> bool:
166
+ if id(node) in visiting:
167
+ return True
168
+ visiting = (*visiting, id(node))
169
+ for ancestor in [node, *node["parents"]]:
170
+ attrs = ancestor["attrs"]
171
+ if ancestor["tag"] in {"script", "style", "template", "noscript"}:
172
+ return True
173
+ if "inert" in attrs:
174
+ return True
175
+ inline_hidden = _inline_hidden(attrs.get("style") or "")
176
+ if "hidden" in attrs or attrs.get("aria-hidden") == "true" or inline_hidden:
177
+ if attrs.get("role") != "tabpanel" or not _reachable_panel(ancestor, nodes, visiting):
178
+ return True
179
+ return False
180
+
181
+
182
+ def _visible_text(node: dict, parser: _HTML) -> str:
183
+ # Preserve document order, including text on both sides of nested markup.
184
+ return "".join(text for owner, text in parser.text_parts
185
+ if (owner is node or any(parent is node for parent in owner["parents"]))
186
+ and not _hidden(owner, parser.nodes))
187
+
188
+
189
+ def _html_errors(path: Path, expected: dict) -> list[str]:
190
+ parser = _HTML()
191
+ parser.feed(path.read_text(encoding="utf-8"))
192
+ blocks = [node for node in parser.nodes if "data-openmapstack-credits" in node["attrs"]]
193
+ if len(blocks) != 1:
194
+ return ["view must expose exactly one non-hidden credits block"]
195
+ block = blocks[0]
196
+ # Even empty descendants can affect layout; unsupported styles never earn
197
+ # a static visibility pass merely because they contain no text.
198
+ for node in parser.nodes:
199
+ if node is block or any(parent is block for parent in node["parents"]):
200
+ _inline_hidden(node["attrs"].get("style") or "")
201
+ if _hidden(block, parser.nodes):
202
+ return ["view must expose exactly one non-hidden credits block"]
203
+ errors = []
204
+ def field(role):
205
+ values = [node for node in parser.nodes if node["attrs"].get("data-oms-credit") == role and any(parent is block for parent in node["parents"])]
206
+ return values[0] if len(values) == 1 and not _hidden(values[0], parser.nodes) else None
207
+
208
+ author, toolkit = expected["author"], expected["generated_with"]
209
+ block_text = _visible_text(block, parser)
210
+ normalize = lambda text: " ".join(text.split())
211
+ for role, href, labels in [
212
+ ("toolkit", toolkit["url"], [toolkit["tool"], toolkit["tool"] + " toolkit"]),
213
+ ("author", "mailto:" + quote(author["email"], safe="@+.-_"),
214
+ [author["name"], f"{author['name']} ({author['email']})"]),
215
+ ]:
216
+ node = field(role)
217
+ if node is None or node["tag"] != "a" or node["attrs"].get("href") != href or normalize(_visible_text(node, parser)) not in [normalize(label) for label in labels]:
218
+ errors.append(f"{role} credit or link differs from the manifest")
219
+ if author["email"] not in block_text:
220
+ errors.append("author email missing from visible credits")
221
+ if author.get("url"):
222
+ node = field("author-url")
223
+ if node is None or node["tag"] != "a" or node["attrs"].get("href") != author["url"] or not _visible_text(node, parser).strip():
224
+ errors.append("author website credit/link differs from the manifest")
225
+ node = field("analysis")
226
+ try:
227
+ if node is None or node["tag"] != "time" or credits.instant(node["attrs"].get("datetime")) != credits.instant(expected["analysis_at"]) or credits.instant(_visible_text(node, parser).strip()) != credits.instant(expected["analysis_at"]):
228
+ raise ValueError("stale timestamp")
229
+ except (TypeError, ValueError):
230
+ errors.append("visible analysis timestamp/offset differs from the canonical completion time")
231
+ clock_source = node["attrs"].get("data-oms-clock-source") if node else None
232
+ expected_source = "project.updated_at" if expected["fallback"] else "runs.latest.completed_at"
233
+ # Old helper output had only an English fallback label. New renderers bind
234
+ # the source independently so labels, including that explanation, translate.
235
+ if (clock_source != expected_source if clock_source is not None else
236
+ ("no canonical run yet" in block_text) != expected["fallback"]):
237
+ errors.append("pre-run timestamp fallback label differs")
238
+ return errors
239
+
240
+
241
+ def _qgis_errors(path: Path, expected: dict) -> list[str]:
242
+ if path.suffix.lower() == ".qgz":
243
+ with zipfile.ZipFile(path) as archive:
244
+ names = [name for name in archive.namelist() if name.endswith(".qgs")]
245
+ if len(names) != 1:
246
+ return ["QGZ must contain exactly one QGS member"]
247
+ root = ET.fromstring(archive.read(names[0]))
248
+ else:
249
+ root = ET.fromstring(path.read_bytes())
250
+ nodes = root.findall("projectMetadata")
251
+ if len(nodes) != 1:
252
+ return ["QGIS must expose one desktop-visible metadata block"]
253
+ node = nodes[0]
254
+ # Three leading logical credit lines, followed by unrestricted provenance.
255
+ # Labels translate; the identity, ISO instant and field-name source do not.
256
+ header = node.findtext("abstract", "").splitlines()[:3]
257
+ author, toolkit = expected["author"], expected["generated_with"]
258
+ errors = []
259
+ if not header or toolkit["tool"] not in header[0]:
260
+ errors.append("QGIS toolkit identity missing from the credit header")
261
+ author_value = f"{author['name']} ({author['email']})"
262
+ if author_value != node.findtext("author", ""):
263
+ errors.append("QGIS author differs from the manifest")
264
+ # The translated label is free; the entire author value must match in both
265
+ # the abstract header and native field. Provenance elsewhere cannot supply it.
266
+ if len(header) < 3 or not re.fullmatch(r"(?:[^\n]+?:\s*)?" + re.escape(author_value) + r"\.?", header[2].strip()):
267
+ errors.append("QGIS credit header author differs from the manifest")
268
+ times = re.findall(r"(?<![\w])\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})(?![\w:+-])", header[1] if len(header) > 1 else "")
269
+ try:
270
+ if len(times) != 1 or credits.instant(times[0]) != credits.instant(expected["analysis_at"]):
271
+ raise ValueError("stale timestamp")
272
+ except ValueError:
273
+ errors.append("QGIS analysis timestamp/offset differs from the canonical completion time")
274
+ header_text = "\n".join(header)
275
+ sources = re.findall(r"\[clock:([^\]]*)\]", header_text)
276
+ expected_source = "project.updated_at" if expected["fallback"] else "runs.latest.completed_at"
277
+ if (sources != [expected_source] if "[clock:" in header_text else
278
+ ("no canonical run yet" in header_text) != expected["fallback"]):
279
+ errors.append("QGIS pre-run timestamp fallback label differs")
280
+ urls = [link.get("url") for link in node.findall("links/link")]
281
+ for url in [toolkit["url"], "mailto:" + quote(author["email"], safe="@+.-_"), *([author["url"]] if author.get("url") else [])]:
282
+ if url not in urls:
283
+ errors.append(f"QGIS credit link missing: {url}")
284
+ return errors
285
+
286
+
287
+ def rendered_matches(workspace: Path, project_dir: str = ".") -> AssertionResult:
288
+ """Check selected views, or known legacy view paths; never fetch remote links."""
289
+ declaration = declaration_valid(workspace, project_dir)
290
+ if declaration.status != "passed":
291
+ return declaration
292
+ root = project_root(workspace, project_dir)
293
+ project = load_project_yaml(workspace, project_dir)
294
+ try:
295
+ expected = credits.credit_payload(project)
296
+ surfaces = _surfaces(root, project)
297
+ except (ValueError, TypeError, KeyError) as exc:
298
+ return failed(str(exc), code="credits_declaration_invalid")
299
+ errors, uncertain = [], []
300
+ for relative, kind in surfaces.items():
301
+ path = project_path(root, relative)
302
+ if path is None or not path.is_file():
303
+ errors.append(f"{relative}: delivery view is missing or unsafe")
304
+ continue
305
+ try:
306
+ problems = _qgis_errors(path, expected) if kind == "qgis" else _html_errors(path, expected)
307
+ except _AmbiguousStyle as exc:
308
+ uncertain.append(f"{relative}: {exc}")
309
+ continue
310
+ except (OSError, UnicodeError, ValueError, ET.ParseError, zipfile.BadZipFile) as exc:
311
+ problems = [f"cannot inspect credits: {exc}"]
312
+ errors.extend(f"{relative}: {problem}" for problem in problems)
313
+ if errors:
314
+ return failed("; ".join(errors), code="credits_rendered_mismatch", errors=errors)
315
+ if uncertain:
316
+ return not_testable("ambiguous inline credits visibility; run visual.credits_visible_in_browser: "
317
+ + "; ".join(uncertain), code="credits_visibility_ambiguous", surfaces=list(surfaces))
318
+ return passed(f"credits match in {len(surfaces)} delivery surface(s)", surfaces=list(surfaces))