From 5d417c82297069c52b83cbadcb75c8c79f5d1828 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Mon, 10 Aug 2026 13:35:33 +0200 Subject: [PATCH] feat: add data contract validation JSON Schemas for the database, install and pack manifests, target manifests, site API envelopes and stats, plus the semantic invariants a schema cannot express: declared totals matching their lists, no destination both installed and omitted, database keys matching their sha1. validate_site.py checks the rendered HTML for metadata, headings, image alternatives, duplicate ids and unresolved local links. Pack manifests are read from inside the generated archives, where generate_pack writes them, rather than from a dist/ glob that matches nothing. Emulator and platform schemas gain additionalProperties: false, and cores[] plus contents[].name must be strings: an unquoted 81 or 01 in YAML parses as a number and stops matching the upstream name. --- schemas/database.schema.json | 61 ++++++ schemas/emulator.schema.json | 88 ++++++++- schemas/install-manifest.schema.json | 74 +++++++ schemas/pack-manifest.schema.json | 42 ++++ schemas/platform.schema.json | 32 +++- schemas/site-api-envelope.schema.json | 26 +++ schemas/stats.schema.json | 41 ++++ schemas/target-manifest.schema.json | 21 ++ scripts/validate_schemas.py | 231 ++++++++++++++++++++++ scripts/validate_site.py | 266 ++++++++++++++++++++++++++ tests/test_site_validation.py | 96 ++++++++++ 11 files changed, 973 insertions(+), 5 deletions(-) create mode 100644 schemas/database.schema.json create mode 100644 schemas/install-manifest.schema.json create mode 100644 schemas/pack-manifest.schema.json create mode 100644 schemas/site-api-envelope.schema.json create mode 100644 schemas/stats.schema.json create mode 100644 schemas/target-manifest.schema.json create mode 100644 scripts/validate_schemas.py create mode 100644 scripts/validate_site.py create mode 100644 tests/test_site_validation.py diff --git a/schemas/database.schema.json b/schemas/database.schema.json new file mode 100644 index 00000000..75befdae --- /dev/null +++ b/schemas/database.schema.json @@ -0,0 +1,61 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/Abdess/retrobios/schemas/database.schema.json", + "title": "RetroBIOS content database", + "type": "object", + "additionalProperties": false, + "required": ["schema_version", "generated_at", "total_files", "total_size", "files", "indexes"], + "properties": { + "schema_version": { "const": 1 }, + "generated_at": { "type": "string", "format": "date-time" }, + "total_files": { "type": "integer", "minimum": 0 }, + "total_size": { "type": "integer", "minimum": 0 }, + "files": { + "type": "object", + "propertyNames": { "pattern": "^[a-f0-9]{40}$" }, + "additionalProperties": { + "type": "object", + "additionalProperties": false, + "required": ["path", "name", "size", "sha1", "md5", "sha256", "crc32", "adler32"], + "properties": { + "path": { "type": "string", "minLength": 1 }, + "name": { "type": "string", "minLength": 1 }, + "size": { "type": "integer", "minimum": 0 }, + "sha1": { "type": "string", "pattern": "^[a-f0-9]{40}$" }, + "md5": { "type": "string", "pattern": "^[a-f0-9]{32}$" }, + "sha256": { "type": "string", "pattern": "^[a-f0-9]{64}$" }, + "crc32": { "type": "string", "pattern": "^[a-f0-9]{8}$" }, + "adler32": { "type": "string", "pattern": "^[a-f0-9]{8}$" }, + "provenance": { "type": "object" } + } + } + }, + "indexes": { + "type": "object", + "additionalProperties": false, + "required": ["by_md5", "by_name", "by_crc32", "by_sha256", "by_path_suffix"], + "properties": { + "by_md5": { "$ref": "#/$defs/hashIndex" }, + "by_crc32": { "$ref": "#/$defs/hashIndex" }, + "by_sha256": { "$ref": "#/$defs/hashIndex" }, + "by_name": { "$ref": "#/$defs/listIndex" }, + "by_path_suffix": { "$ref": "#/$defs/listIndex" } + } + } + }, + "$defs": { + "hashIndex": { + "type": "object", + "additionalProperties": { "type": "string", "pattern": "^[a-f0-9]{40}$" } + }, + "listIndex": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { "type": "string", "pattern": "^[a-f0-9]{40}$" }, + "minItems": 1, + "uniqueItems": true + } + } + } +} diff --git a/schemas/emulator.schema.json b/schemas/emulator.schema.json index 7c10f0a4..31dce9a0 100644 --- a/schemas/emulator.schema.json +++ b/schemas/emulator.schema.json @@ -3,6 +3,7 @@ "title": "Emulator BIOS Profile", "description": "One YAML per emulator core or engine, documenting every external file the code loads. Field reference: https://abdess.github.io/retrobios/wiki/profiling/", "type": "object", + "additionalProperties": false, "required": ["emulator", "type"], "allOf": [ { @@ -59,18 +60,68 @@ "core_version": { "type": ["string", "number", "object", "null"] }, "alias_of": { "type": "string" }, "cores": { - "description": "Upstream names the core is known by. A dict maps each name to per-variant metadata.", - "type": ["array", "object"] + "description": "Upstream names the core is known by. A dict maps each name to per-variant metadata. Names are strings: an unquoted 81 or 2048 parses as a number and stops matching the buildbot name.", + "oneOf": [ + { "type": "array", "items": { "type": "string", "minLength": 1 }, "uniqueItems": true }, + { "type": "object", "propertyNames": { "minLength": 1 } } + ] }, "systems": { "type": "array", "items": { "type": "string" } }, "mode": { "type": "string", "enum": ["standalone", "libretro", "both"] }, "verification": { "type": "string", "enum": ["existence", "md5", "sha1", "crc32"] }, "exclusion_note": { "type": "string" }, + "analysis": { "type": "object" }, + "analysis_commit": { "type": "string" }, + "analysis_date": { "type": "string" }, + "archive_prefix": { "type": "string" }, + "author": { "type": "string" }, + "based_on": { "type": "string" }, + "bios_directory": { "type": "string" }, + "bios_identical_to": { "type": "string" }, + "bios_mode": { "type": "string" }, + "bios_size": { "type": "integer", "minimum": 0 }, + "builtin_boot_roms": { "type": "object" }, + "core": { "type": "string" }, + "core_name": { "type": "string" }, + "data_directories": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["ref"], + "properties": { + "ref": { "type": "string" }, + "destination": { "type": "string" }, + "source_ref": { "type": "string" } + } + } + }, + "dev_flash": { "type": "object" }, + "dev_flash2": { "type": "object" }, + "dev_flash3": { "type": "object" }, + "firmware_titles": { "type": "array", "items": { "type": "object" } }, + "game_count": { "type": "integer", "minimum": 0 }, + "logo": { "type": "string" }, + "machine_directories": { "type": "object" }, + "machine_properties": { "type": "object" }, + "mame_version": { "type": "string" }, + "memory_layout": { "type": "object" }, + "mlc_structure": { "type": "array", "items": { "type": "string" } }, + "model_kickstart_map": { "type": "object" }, + "note": { "type": "string" }, "notes": { "type": ["string", "array", "object"] }, + "pack_structure": { "type": "object" }, + "platform_details": { "type": "object" }, + "resources_directory": { "type": "string" }, + "rom_path": { "type": "string" }, + "source_ref": { "type": "string" }, + "valid_bios_crc32": { "type": "object" }, + "whdload_kickstarts": { "type": "array", "items": { "type": "string" } }, "files": { "type": "array", "items": { "type": "object", + "additionalProperties": false, "required": ["name"], "properties": { "name": { "type": "string" }, @@ -93,7 +144,21 @@ "enum": ["embedded", "external", "user_provided", "large_file", "release"] }, "hle_fallback": { "type": "boolean" }, + "adler32_byteswap": { "type": "boolean" }, "agnostic": { "type": "boolean" }, + "bundled": { "type": "boolean" }, + "config_key": { "type": "string" }, + "embedded": { "type": "boolean" }, + "fast_boot": { "type": "string" }, + "has_builtin": { "type": "boolean" }, + "load_from": { "type": "string" }, + "priority": { "type": "integer" }, + "region_check": { "type": "boolean" }, + "size_note": { "type": "string" }, + "size_options": { "type": "array", "items": { "type": "integer" } }, + "standalone_path": { "type": "string" }, + "type": { "type": "string" }, + "variant_group": { "type": "string", "minLength": 1 }, "size": { "type": ["integer", "array", "null"] }, "min_size": { "type": "integer" }, "max_size": { "type": "integer" }, @@ -120,7 +185,7 @@ "type": "string", "pattern": "^[a-fA-F0-9]{64}(,[a-fA-F0-9]{64})*$" }, - "crc32": { "type": "string" }, + "crc32": { "type": "string", "pattern": "^[a-fA-F0-9]{8}$" }, "known_hash_adler32": { "type": ["string", "integer"] }, "validation": { "description": "Checks the code performs. A dict splits core from upstream when they diverge.", @@ -146,7 +211,22 @@ ] } }, - "contents": { "type": "array", "items": { "type": "object" } }, + "contents": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["name"], + "properties": { + "name": { "type": "string", "minLength": 1 }, + "size": { "type": "integer", "minimum": 0 }, + "sha1": { "type": "string", "pattern": "^[a-fA-F0-9]{40}$" }, + "crc32": { "type": "string", "pattern": "^[a-fA-F0-9]{8}$" }, + "description": { "type": "string" }, + "bad_dump": { "type": "boolean" } + } + } + }, "source_ref": { "description": "A dict splits core from upstream when the two diverge.", "type": ["string", "array", "object"] diff --git a/schemas/install-manifest.schema.json b/schemas/install-manifest.schema.json new file mode 100644 index 00000000..9da97398 --- /dev/null +++ b/schemas/install-manifest.schema.json @@ -0,0 +1,74 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/Abdess/retrobios/schemas/install-manifest.schema.json", + "title": "RetroBIOS installer manifest", + "type": "object", + "additionalProperties": false, + "required": ["manifest_version", "source", "regions", "platform", "display_name", "version", "generated", "base_destination", "detect", "standalone_copies", "total_files", "total_size", "total_omitted", "omitted_files", "files"], + "properties": { + "manifest_version": { "const": 2 }, + "source": { "enum": ["full", "platform", "truth"] }, + "regions": { "type": "array", "items": { "type": "string" }, "uniqueItems": true }, + "platform": { "type": "string", "minLength": 1 }, + "display_name": { "type": "string", "minLength": 1 }, + "version": { "type": "string" }, + "generated": { "type": "string", "format": "date-time" }, + "base_destination": { "type": "string" }, + "detect": { "type": "array", "items": { "type": "object" } }, + "standalone_copies": { "type": "array", "items": { "type": "object" } }, + "total_files": { "type": "integer", "minimum": 0 }, + "total_size": { "type": "integer", "minimum": 0 }, + "total_omitted": { "type": "integer", "minimum": 0 }, + "omitted_files": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["dest", "name", "system", "required", "reason", "cores"], + "properties": { + "dest": { "type": "string", "pattern": "^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$)).+$" }, + "name": { "type": "string", "minLength": 1 }, + "system": { "type": "string" }, + "required": { "type": "boolean" }, + "reason": { "enum": ["hash_mismatch", "not_found", "external", "user_provided"] }, + "cores": { + "oneOf": [ + { "type": "null" }, + { "type": "array", "items": { "type": "string" }, "uniqueItems": true } + ] + } + } + } + }, + "files": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["dest", "sha1", "sha256", "size", "repo_path", "cores"], + "properties": { + "dest": { "type": "string", "pattern": "^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$)).+$" }, + "sha1": { "type": "string", "pattern": "^[a-f0-9]{40}$" }, + "sha256": { "type": "string", "pattern": "^[a-f0-9]{64}$" }, + "size": { "type": "integer", "minimum": 0 }, + "repo_path": { "type": "string" }, + "cores": { + "oneOf": [ + { "type": "null" }, + { "type": "array", "items": { "type": "string" }, "uniqueItems": true } + ] + }, + "storage": { "const": "release" }, + "release_asset": { "type": "string", "pattern": "^[^/\\\\]+$" } + }, + "oneOf": [ + { "required": ["release_asset"] }, + { + "properties": { "repo_path": { "type": "string", "pattern": "^bios/" } }, + "not": { "required": ["release_asset"] } + } + ] + } + } + } +} diff --git a/schemas/pack-manifest.schema.json b/schemas/pack-manifest.schema.json new file mode 100644 index 00000000..f7210cb0 --- /dev/null +++ b/schemas/pack-manifest.schema.json @@ -0,0 +1,42 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/Abdess/retrobios/schemas/pack-manifest.schema.json", + "title": "RetroBIOS pack integrity manifest", + "type": "object", + "additionalProperties": false, + "required": ["schema_version", "version", "generator", "generated", "files", "summary", "errors"], + "properties": { + "schema_version": { "const": 1 }, + "version": { "const": 1 }, + "generator": { "const": "retrobios generate_pack.py" }, + "generated": { "type": "string", "format": "date-time" }, + "files": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["path", "sha1", "md5", "size", "status", "name"], + "properties": { + "path": { "type": "string", "minLength": 1 }, + "sha1": { "type": "string", "pattern": "^[a-f0-9]{40}$" }, + "md5": { "type": "string", "pattern": "^[a-f0-9]{32}$" }, + "size": { "type": "integer", "minimum": 0 }, + "status": { "enum": ["verified", "verified_md5", "verified_rebuild", "verified_data", "untracked"] }, + "name": { "type": "string" } + } + } + }, + "summary": { + "type": "object", + "additionalProperties": false, + "required": ["total_files", "verified", "untracked", "errors"], + "properties": { + "total_files": { "type": "integer", "minimum": 0 }, + "verified": { "type": "integer", "minimum": 0 }, + "untracked": { "type": "integer", "minimum": 0 }, + "errors": { "type": "integer", "minimum": 0 } + } + }, + "errors": { "type": "array", "items": { "type": "string" } } + } +} diff --git a/schemas/platform.schema.json b/schemas/platform.schema.json index 021e8c6b..72609fc5 100644 --- a/schemas/platform.schema.json +++ b/schemas/platform.schema.json @@ -4,6 +4,7 @@ "title": "Platform Configuration", "description": "Schema for retrogaming platform BIOS configuration files", "type": "object", + "additionalProperties": false, "required": ["platform"], "properties": { "inherits": { @@ -18,6 +19,18 @@ "type": "string", "description": "Platform version" }, + "dat_version": { "type": "string" }, + "cores": { + "oneOf": [ + { "type": "string" }, + { "type": "array", "items": { "type": "string" }, "uniqueItems": true } + ] + }, + "standalone_cores": { + "type": "array", + "items": { "type": "string" }, + "uniqueItems": true + }, "homepage": { "type": "string", "format": "uri", @@ -51,6 +64,7 @@ }, "overrides": { "type": "object", + "additionalProperties": false, "description": "Override inherited system configs", "properties": { "systems": { @@ -65,6 +79,7 @@ "$defs": { "system": { "type": "object", + "additionalProperties": false, "properties": { "core": { "type": "string", @@ -74,6 +89,8 @@ "type": "string", "description": "Hardware manufacturer" }, + "name": { "type": "string" }, + "native_id": { "type": "string" }, "dat_version": { "type": "string", "description": "Version of the DAT file used as source for BIOS hashes" @@ -98,6 +115,18 @@ "items": { "type": "string" }, "description": "Shared BIOS groups to include (from _shared.yml)" }, + "data_directories": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["ref"], + "properties": { + "ref": { "type": "string" }, + "destination": { "type": "string" } + } + } + }, "files": { "type": "array", "items": { @@ -109,6 +138,7 @@ }, "bios_file": { "type": "object", + "additionalProperties": false, "required": ["name", "destination"], "properties": { "name": { @@ -156,7 +186,7 @@ }, "storage": { "type": "string", - "enum": ["embedded", "external", "user_provided"], + "enum": ["embedded", "external", "user_provided", "large_file", "release"], "default": "embedded", "description": "Storage tier: embedded (in repo), external (download at build), user_provided (manual)" }, diff --git a/schemas/site-api-envelope.schema.json b/schemas/site-api-envelope.schema.json new file mode 100644 index 00000000..3bd965e9 --- /dev/null +++ b/schemas/site-api-envelope.schema.json @@ -0,0 +1,26 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/Abdess/retrobios/schemas/site-api-envelope.schema.json", + "title": "RetroBIOS static API v1 envelope", + "type": "object", + "additionalProperties": false, + "required": ["schema_version", "generated_at", "kind", "count", "items"], + "properties": { + "schema_version": { "const": 1 }, + "generated_at": { "type": "string", "format": "date-time" }, + "kind": { + "type": "string", + "enum": [ + "catalog", + "platforms", + "emulators", + "verification-and-coverage-gaps" + ] + }, + "count": { "type": "integer", "minimum": 0 }, + "items": { + "type": "array", + "items": { "type": "object" } + } + } +} diff --git a/schemas/stats.schema.json b/schemas/stats.schema.json new file mode 100644 index 00000000..6e0c9a90 --- /dev/null +++ b/schemas/stats.schema.json @@ -0,0 +1,41 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/Abdess/retrobios/schemas/stats.schema.json", + "title": "RetroBIOS public statistics", + "type": "object", + "additionalProperties": false, + "required": ["schema_version", "generated_at", "files", "size_bytes", "platforms", "emulators", "systems", "catalog_matched", "source", "downloads", "composition"], + "properties": { + "schema_version": { "const": 1 }, + "generated_at": { "type": "string", "format": "date-time" }, + "files": { "type": "integer", "minimum": 0 }, + "size_bytes": { "type": "integer", "minimum": 0 }, + "platforms": { "type": "integer", "minimum": 0 }, + "emulators": { "type": "integer", "minimum": 0 }, + "systems": { "type": "integer", "minimum": 0 }, + "catalog_matched": { "type": "integer", "minimum": 0 }, + "source": { "type": "string", "format": "uri" }, + "downloads": { "type": "string", "format": "uri" }, + "composition": { + "type": "object", + "additionalProperties": false, + "required": ["systems", "arcade", "game_data"], + "properties": { + "systems": { "$ref": "#/$defs/composition" }, + "arcade": { "$ref": "#/$defs/composition" }, + "game_data": { "$ref": "#/$defs/composition" } + } + } + }, + "$defs": { + "composition": { + "type": "object", + "additionalProperties": false, + "required": ["files", "size_bytes"], + "properties": { + "files": { "type": "integer", "minimum": 0 }, + "size_bytes": { "type": "integer", "minimum": 0 } + } + } + } +} diff --git a/schemas/target-manifest.schema.json b/schemas/target-manifest.schema.json new file mode 100644 index 00000000..6f74ed53 --- /dev/null +++ b/schemas/target-manifest.schema.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/Abdess/retrobios/schemas/target-manifest.schema.json", + "title": "RetroBIOS hardware target manifest", + "description": "Per-target core availability. A null core list means the target publishes no inventory: a known target with no filter, not an absent one.", + "type": "object", + "additionalProperties": { + "oneOf": [ + { "type": "null" }, + { "type": "array", "items": { "type": "string", "minLength": 1 }, "uniqueItems": true }, + { + "type": "object", + "additionalProperties": false, + "required": ["cores"], + "properties": { + "cores": { "type": "array", "items": { "type": "string", "minLength": 1 }, "uniqueItems": true } + } + } + ] + } +} diff --git a/scripts/validate_schemas.py b/scripts/validate_schemas.py new file mode 100644 index 00000000..9f28b1d2 --- /dev/null +++ b/scripts/validate_schemas.py @@ -0,0 +1,231 @@ +#!/usr/bin/env python3 +"""Validate source and generated RetroBIOS data contracts.""" + +from __future__ import annotations + +import argparse +import json +import sys +import zipfile +from pathlib import Path, PurePosixPath + +import yaml +from jsonschema import Draft202012Validator, FormatChecker + +ROOT = Path(__file__).resolve().parent.parent +SCHEMAS = ROOT / "schemas" + + +def _load_json(path: Path) -> object: + with path.open(encoding="utf-8") as handle: + return json.load(handle) + + +def _validator(name: str) -> Draft202012Validator: + schema = _load_json(SCHEMAS / name) + Draft202012Validator.check_schema(schema) + return Draft202012Validator(schema, format_checker=FormatChecker()) + + +def _errors(validator: Draft202012Validator, data: object, label: str) -> list[str]: + out: list[str] = [] + for error in sorted(validator.iter_errors(data), key=lambda item: list(item.path)): + location = "/".join(str(part) for part in error.absolute_path) or "" + out.append(f"{label}:{location}: {error.message}") + return out + + +def _validate_yaml_directory( + directory: Path, + schema_name: str, + *, + skip_private: bool = False, +) -> list[str]: + validator = _validator(schema_name) + out: list[str] = [] + for path in sorted(directory.glob("*.yml")): + if skip_private and path.name.startswith("_"): + continue + try: + with path.open(encoding="utf-8") as handle: + data = yaml.safe_load(handle) + except (OSError, yaml.YAMLError) as exc: + out.append(f"{path.relative_to(ROOT)}: {exc}") + continue + out.extend(_errors(validator, data, str(path.relative_to(ROOT)))) + return out + + +def _validate_json_files(paths: list[Path], schema_name: str) -> list[str]: + validator = _validator(schema_name) + out: list[str] = [] + for path in paths: + try: + data = _load_json(path) + except (OSError, json.JSONDecodeError) as exc: + out.append(f"{path.relative_to(ROOT)}: {exc}") + continue + out.extend(_errors(validator, data, str(path.relative_to(ROOT)))) + return out + + +def _validate_pack_manifests(dist: Path) -> list[str]: + """Validate the integrity manifest each generated pack carries. + + generate_pack.py writes manifest.json inside the archive, not beside it, + so a filesystem glob over dist/ matches nothing and silently validates + zero documents. + """ + if not dist.is_dir(): + return [] + validator = _validator("pack-manifest.schema.json") + + def _label(path: Path) -> str: + resolved = path.resolve() + try: + return str(resolved.relative_to(ROOT)) + except ValueError: + return str(resolved) + + out: list[str] = [] + for archive in sorted(dist.glob("*.zip")): + try: + with zipfile.ZipFile(archive) as handle: + members = [ + name + for name in handle.namelist() + if PurePosixPath(name).name == "manifest.json" + ] + for member in members: + label = f"{_label(archive)}:{member}" + try: + document = json.loads(handle.read(member).decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + out.append(f"{label}: {exc}") + continue + out.extend(_errors(validator, document, label)) + except (OSError, zipfile.BadZipFile) as exc: + out.append(f"{_label(archive)}: {exc}") + return out + + +def _semantic_envelope_checks(path: Path, document: dict) -> list[str]: + if document.get("count") != len(document.get("items", [])): + return [f"{path.relative_to(ROOT)}: count does not equal len(items)"] + return [] + + +def _semantic_database_checks(database: dict) -> list[str]: + out: list[str] = [] + files = database.get("files", {}) + if database.get("total_files") != len(files): + out.append("database.json: total_files does not equal len(files)") + if database.get("total_size") != sum(entry.get("size", 0) for entry in files.values()): + out.append("database.json: total_size does not equal the file-size sum") + for sha1, entry in files.items(): + if entry.get("sha1") != sha1: + out.append(f"database.json: files/{sha1}: key and sha1 differ") + return out + + +def _semantic_install_checks(path: Path, manifest: dict) -> list[str]: + out: list[str] = [] + files = manifest.get("files", []) + if manifest.get("total_files") != len(files): + out.append(f"{path.relative_to(ROOT)}: total_files does not equal len(files)") + if manifest.get("total_size") != sum(entry.get("size", 0) for entry in files): + out.append(f"{path.relative_to(ROOT)}: total_size does not equal the file-size sum") + omitted = manifest.get("omitted_files", []) + if manifest.get("total_omitted") != len(omitted): + out.append( + f"{path.relative_to(ROOT)}: total_omitted does not equal " + "len(omitted_files)" + ) + destinations = [entry.get("dest") for entry in files] + if len(destinations) != len(set(destinations)): + out.append(f"{path.relative_to(ROOT)}: duplicate destinations") + omitted_destinations = [entry.get("dest") for entry in omitted] + if len(omitted_destinations) != len(set(omitted_destinations)): + out.append(f"{path.relative_to(ROOT)}: duplicate omitted destinations") + overlap = set(destinations) & set(omitted_destinations) + if overlap: + out.append( + f"{path.relative_to(ROOT)}: destinations are both downloadable and omitted" + ) + return out + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--source-only", + action="store_true", + help="validate emulator/platform YAML only", + ) + args = parser.parse_args() + + errors: list[str] = [] + errors.extend( + _validate_yaml_directory(ROOT / "emulators", "emulator.schema.json") + ) + errors.extend( + _validate_yaml_directory( + ROOT / "platforms", "platform.schema.json", skip_private=True + ) + ) + + if not args.source_only: + database_path = ROOT / "database.json" + errors.extend(_validate_json_files([database_path], "database.schema.json")) + if database_path.exists(): + errors.extend(_semantic_database_checks(_load_json(database_path))) + + install_paths = sorted((ROOT / "install").glob("*.json")) + errors.extend( + _validate_json_files(install_paths, "install-manifest.schema.json") + ) + for path in install_paths: + errors.extend(_semantic_install_checks(path, _load_json(path))) + + target_paths = sorted((ROOT / "install" / "targets").glob("*.json")) + errors.extend( + _validate_json_files(target_paths, "target-manifest.schema.json") + ) + + stats_path = ROOT / "docs" / "stats.json" + if stats_path.exists(): + errors.extend(_validate_json_files([stats_path], "stats.schema.json")) + + api_dir = ROOT / "docs" / "api" / "v1" + api_database = api_dir / "database.json" + if api_database.exists(): + errors.extend( + _validate_json_files([api_database], "database.schema.json") + ) + api_stats = api_dir / "stats.json" + if api_stats.exists(): + errors.extend(_validate_json_files([api_stats], "stats.schema.json")) + envelope_paths = [ + api_dir / name + for name in ("catalog.json", "platforms.json", "emulators.json", "gaps.json") + if (api_dir / name).exists() + ] + errors.extend( + _validate_json_files(envelope_paths, "site-api-envelope.schema.json") + ) + for path in envelope_paths: + errors.extend(_semantic_envelope_checks(path, _load_json(path))) + + errors.extend(_validate_pack_manifests(ROOT / "dist")) + + if errors: + for error in errors: + print(f"ERROR {error}") + print(f"{len(errors)} schema or semantic error(s)") + return 1 + print("All RetroBIOS schemas and semantic invariants are valid.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/validate_site.py b/scripts/validate_site.py new file mode 100644 index 00000000..aaeef026 --- /dev/null +++ b/scripts/validate_site.py @@ -0,0 +1,266 @@ +#!/usr/bin/env python3 +"""Validate the built RetroBIOS site as a navigable, accessible data product. + +This complements ``mkdocs build --strict`` with checks on the rendered HTML: +metadata, heading structure, image alternatives, duplicate IDs, structured +data, local resources, links and fragments. +""" + +from __future__ import annotations + +import argparse +import json +import re +import sys +from collections import Counter, defaultdict +from dataclasses import dataclass, field +from html.parser import HTMLParser +from pathlib import Path +from urllib.parse import unquote, urlsplit + + +@dataclass +class Page: + path: Path + title: str = "" + lang: str = "" + descriptions: list[str] = field(default_factory=list) + canonical: list[str] = field(default_factory=list) + h1_count: int = 0 + main_count: int = 0 + ids: list[str] = field(default_factory=list) + links: list[str] = field(default_factory=list) + missing_alt: list[str] = field(default_factory=list) + jsonld: list[str] = field(default_factory=list) + + +class _PageParser(HTMLParser): + def __init__(self, path: Path): + super().__init__(convert_charrefs=True) + self.page = Page(path) + self._title_depth = 0 + self._title_parts: list[str] = [] + self._jsonld_depth = 0 + self._jsonld_parts: list[str] = [] + + def handle_starttag(self, tag: str, attrs) -> None: + data = dict(attrs) + if tag == "html": + self.page.lang = data.get("lang", "") + elif tag == "title": + self._title_depth += 1 + elif tag == "meta" and data.get("name", "").lower() == "description": + self.page.descriptions.append(data.get("content", "").strip()) + elif tag == "h1": + self.page.h1_count += 1 + elif tag == "main": + self.page.main_count += 1 + elif tag == "img" and "alt" not in data: + self.page.missing_alt.append(data.get("src", "?")) + elif tag == "script" and data.get("type") == "application/ld+json": + self._jsonld_depth += 1 + self._jsonld_parts = [] + + element_id = data.get("id") + if element_id is not None: + self.page.ids.append(element_id) + + if tag in ("a", "link") and data.get("href"): + self.page.links.append(data["href"]) + if tag in ("img", "script", "source") and data.get("src"): + self.page.links.append(data["src"]) + if tag == "link" and "canonical" in data.get("rel", "").split(): + self.page.canonical.append(data.get("href", "")) + + def handle_endtag(self, tag: str) -> None: + if tag == "title": + self._title_depth -= 1 + elif tag == "script" and self._jsonld_depth: + self.page.jsonld.append("".join(self._jsonld_parts).strip()) + self._jsonld_depth -= 1 + self._jsonld_parts = [] + + def handle_data(self, data: str) -> None: + if self._title_depth: + self._title_parts.append(data) + if self._jsonld_depth: + self._jsonld_parts.append(data) + + def close(self) -> None: + super().close() + self.page.title = "".join(self._title_parts).strip() + + +def _parse_page(path: Path) -> Page: + parser = _PageParser(path) + parser.feed(path.read_text(encoding="utf-8", errors="replace")) + parser.close() + return parser.page + + +def _base_path(config_path: Path) -> str: + """Read site_url without constructing executable YAML extension tags.""" + config = config_path.read_text(encoding="utf-8") + match = re.search( + r"(?m)^site_url:\s*['\"]?([^\s#'\"]+)", + config, + ) + site_url = match.group(1) if match else "" + path = urlsplit(site_url).path or "/" + return "/" + path.strip("/") + "/" if path.strip("/") else "/" + + +def _local_target( + href: str, page_path: Path, site: Path, base_path: str +) -> tuple[list[Path], str] | None: + parsed = urlsplit(href) + if parsed.scheme or parsed.netloc or href.startswith( + ("mailto:", "tel:", "data:", "javascript:") + ): + return None + + raw_path = unquote(parsed.path) + if not raw_path: + return [page_path], unquote(parsed.fragment) + + if base_path != "/" and ( + raw_path == base_path.rstrip("/") or raw_path.startswith(base_path) + ): + suffix = raw_path[len(base_path.rstrip("/")) :] + raw_path = "/" + suffix.lstrip("/") + + target = ( + site / raw_path.lstrip("/") + if raw_path.startswith("/") + else page_path.parent / raw_path + ).resolve() + try: + target.relative_to(site) + except ValueError: + return [], unquote(parsed.fragment) + + candidates = [target] + if raw_path.endswith("/"): + candidates = [target / "index.html"] + elif not target.suffix: + candidates.extend([target / "index.html", target.with_suffix(".html")]) + return candidates, unquote(parsed.fragment) + + +def validate_site(site: Path, config_path: Path) -> list[str]: + site = site.resolve() + if not site.is_dir(): + return [f"site directory does not exist: {site}"] + + base_path = _base_path(config_path) + html_paths = sorted(site.rglob("*.html")) + if not html_paths: + return [f"no HTML pages found in {site}"] + + pages = {path.resolve(): _parse_page(path) for path in html_paths} + issues: list[str] = [] + titles: dict[str, list[Path]] = defaultdict(list) + descriptions: dict[str, list[Path]] = defaultdict(list) + + def report(path: Path, message: str) -> None: + issues.append(f"{path.relative_to(site)}: {message}") + + for path, page in pages.items(): + is_404 = path.relative_to(site) == Path("404.html") + if not page.title: + report(path, "missing document title") + elif not is_404: + titles[page.title].append(path) + if page.lang != "en": + report(path, f"expected lang='en', got {page.lang!r}") + if len(page.descriptions) != 1 or not page.descriptions[0]: + report(path, f"expected one non-empty description, got {len(page.descriptions)}") + elif not is_404: + descriptions[page.descriptions[0]].append(path) + if not is_404 and page.h1_count != 1: + report(path, f"expected one H1, got {page.h1_count}") + if page.main_count != 1: + report(path, f"expected one main landmark, got {page.main_count}") + if not is_404 and ( + len(page.canonical) != 1 + or not page.canonical[0].startswith("https://") + ): + report(path, f"expected one HTTPS canonical URL, got {page.canonical!r}") + duplicate_ids = [ + value + for value, count in Counter(page.ids).items() + if value and count > 1 + ] + if duplicate_ids: + report(path, f"duplicate IDs: {duplicate_ids[:5]}") + if page.missing_alt: + report(path, f"images missing alt: {page.missing_alt[:5]}") + + if not is_404 and len(page.jsonld) != 1: + report(path, f"expected one JSON-LD block, got {len(page.jsonld)}") + for document in page.jsonld: + try: + payload = json.loads(document) + except json.JSONDecodeError as exc: + report(path, f"invalid JSON-LD: {exc}") + continue + if not isinstance(payload, dict) or payload.get("@context") != "https://schema.org": + report(path, "JSON-LD is not a schema.org object") + + for label, values in (("title", titles), ("description", descriptions)): + for value, paths in values.items(): + if len(paths) > 1: + rendered = ", ".join(str(path.relative_to(site)) for path in paths[:5]) + issues.append(f"duplicate {label} {value!r}: {rendered}") + + for path, page in pages.items(): + for href in page.links: + resolved = _local_target(href, path, site, base_path) + if resolved is None: + continue + candidates, fragment = resolved + if not candidates: + report(path, f"link escapes site root: {href}") + continue + existing = next((candidate for candidate in candidates if candidate.is_file()), None) + if existing is None: + report(path, f"broken local link or resource: {href}") + continue + if fragment and existing.suffix.lower() == ".html": + target_page = pages.get(existing.resolve()) + if target_page is not None and fragment not in target_page.ids: + report(path, f"missing fragment in {href}") + + return issues + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--site-dir", default="site") + parser.add_argument("--config", default="mkdocs.yml") + parser.add_argument("--max-errors", type=int, default=100) + args = parser.parse_args() + + try: + issues = validate_site(Path(args.site_dir), Path(args.config)) + except (OSError, RuntimeError, ValueError) as exc: + print(f"Site validation failed: {exc}", file=sys.stderr) + raise SystemExit(1) from exc + + if issues: + for issue in issues[: args.max_errors]: + print(f"ERROR {issue}", file=sys.stderr) + if len(issues) > args.max_errors: + print( + f"ERROR ... {len(issues) - args.max_errors} additional issue(s)", + file=sys.stderr, + ) + print(f"{len(issues)} rendered-site issue(s)", file=sys.stderr) + raise SystemExit(1) + + html_count = sum(1 for _ in Path(args.site_dir).rglob("*.html")) + print(f"Rendered site is valid: {html_count} HTML pages, all local links resolved.") + + +if __name__ == "__main__": + main() diff --git a/tests/test_site_validation.py b/tests/test_site_validation.py new file mode 100644 index 00000000..134c0ba0 --- /dev/null +++ b/tests/test_site_validation.py @@ -0,0 +1,96 @@ +"""Tests for rendered-site metadata and link validation.""" + +from __future__ import annotations + +import json +import sys +import tempfile +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +TMP_ROOT = ROOT / "tmp" / "tests" +TMP_ROOT.mkdir(parents=True, exist_ok=True) +sys.path.insert(0, str(ROOT / "scripts")) + +from validate_site import validate_site # noqa: E402 + + +def _page(title: str, description: str, body: str = "") -> str: + structured = json.dumps( + {"@context": "https://schema.org", "@type": "TechArticle"} + ) + return f""" + + + {title} + + + + +

{title}

{body}
+ +""" + + +class RenderedSiteValidation(unittest.TestCase): + def setUp(self) -> None: + self.temp = tempfile.TemporaryDirectory(dir=TMP_ROOT) + self.root = Path(self.temp.name) + self.site = self.root / "site" + self.site.mkdir() + self.config = self.root / "mkdocs.yml" + self.config.write_text( + "site_url: https://example.test/retrobios/\n", encoding="utf-8" + ) + + def tearDown(self) -> None: + self.temp.cleanup() + + def test_valid_page_and_base_path_link_pass(self): + asset = self.site / "assets" / "file.json" + asset.parent.mkdir() + asset.write_text("{}\n", encoding="utf-8") + (self.site / "index.html").write_text( + _page( + "Home", + "Unique home description.", + 'Top' + 'Data' + 'Logo', + ), + encoding="utf-8", + ) + (self.site / "assets" / "logo.png").write_bytes(b"png") + self.assertEqual(validate_site(self.site, self.config), []) + + def test_broken_fragment_and_missing_alt_fail(self): + (self.site / "index.html").write_text( + _page( + "Home", + "Unique home description.", + 'Missing', + ), + encoding="utf-8", + ) + issues = validate_site(self.site, self.config) + self.assertTrue(any("missing fragment" in issue for issue in issues)) + self.assertTrue(any("images missing alt" in issue for issue in issues)) + self.assertTrue(any("broken local link" in issue for issue in issues)) + + def test_duplicate_search_metadata_fails(self): + (self.site / "index.html").write_text( + _page("Repeated", "Repeated description."), encoding="utf-8" + ) + child = self.site / "child" + child.mkdir() + (child / "index.html").write_text( + _page("Repeated", "Repeated description."), encoding="utf-8" + ) + issues = validate_site(self.site, self.config) + self.assertTrue(any("duplicate title" in issue for issue in issues)) + self.assertTrue(any("duplicate description" in issue for issue in issues)) + + +if __name__ == "__main__": + unittest.main()