mirror of
https://github.com/Abdess/retroarch_system.git
synced 2026-11-01 15:43:45 -06:00
campaign.py takes buildbot nightlies (or a build of the pinned commit), the test content of tests/campaign/content.yml through romfetch, and runs core_probe for the files, boot, path, alias, variants and bench checks. A fixed rule table settles hle_fallback, required, path and aliases with the record ids as evidence, the rest goes to measurements/findings.jsonl. Resumable state, load, disk and budget guards, retries, content reference counting with an LRU cap, and an autorun job after the nightly.
720 lines
27 KiB
Python
720 lines
27 KiB
Python
#!/usr/bin/env python3
|
|
"""Validate source and generated RetroBIOS data contracts."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import re
|
|
import sys
|
|
import zipfile
|
|
from collections.abc import Callable
|
|
from pathlib import Path, PurePosixPath
|
|
|
|
import yaml
|
|
|
|
from common import source_yaml_paths, yaml_load
|
|
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 "<root>"
|
|
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 source_yaml_paths(directory):
|
|
if skip_private and path.name.startswith("_"):
|
|
continue
|
|
try:
|
|
with path.open(encoding="utf-8") as handle:
|
|
data = yaml_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))))
|
|
out.extend(_unreachable_citations(path, data))
|
|
if schema_name == "emulator.schema.json":
|
|
out.extend(_checks_without_values(path, data))
|
|
out.extend(_save_dir_without_library_name(path, data))
|
|
out.extend(_systems_outside_profile(path, data))
|
|
out.extend(_doubled_paths(path))
|
|
return out
|
|
|
|
|
|
_CHECK_FIELDS = {
|
|
"crc32": ("crc32",),
|
|
"md5": ("md5",),
|
|
"sha1": ("sha1",),
|
|
"sha256": ("sha256",),
|
|
"size": ("size", "min_size", "max_size"),
|
|
}
|
|
|
|
|
|
def _systems_outside_profile(path: Path, document: object) -> list[str]:
|
|
"""An entry's system: or systems: naming a system its profile does not list.
|
|
|
|
A --system pick selects the profile by its systems: and then the entry
|
|
by its own, so a name the profile lacks is one no pick ever reaches.
|
|
"""
|
|
if not isinstance(document, dict):
|
|
return []
|
|
listed = {str(s) for s in document.get("systems") or []}
|
|
out: list[str] = []
|
|
for index, entry in enumerate(document.get("files") or []):
|
|
if not isinstance(entry, dict):
|
|
continue
|
|
named = [entry["system"]] if entry.get("system") else []
|
|
named += list(entry.get("systems") or [])
|
|
unknown = [str(s) for s in named if str(s) not in listed]
|
|
if unknown:
|
|
out.append(
|
|
f"{path.relative_to(ROOT)}: files[{index}] ({entry.get('name')}) "
|
|
f"system {', '.join(unknown)} not in the profile's systems"
|
|
)
|
|
return out
|
|
|
|
|
|
def _checks_without_values(path: Path, document: object) -> list[str]:
|
|
"""A check an entry declares with nothing to compare against.
|
|
|
|
validation: [crc32] with no crc32 runs nothing: the file passes whatever
|
|
it holds, and the report reads as checked. Fourteen profiles carried
|
|
one, the code they describe either refusing or warning on a value the
|
|
entry never wrote down.
|
|
"""
|
|
out: list[str] = []
|
|
files = document.get("files") if isinstance(document, dict) else None
|
|
for index, entry in enumerate(files or []):
|
|
if not isinstance(entry, dict):
|
|
continue
|
|
declared = entry.get("validation") or []
|
|
if isinstance(declared, dict):
|
|
declared = [check for checks in declared.values() for check in checks or []]
|
|
for check in declared:
|
|
fields = _CHECK_FIELDS.get(check)
|
|
if fields and not any(entry.get(field) not in (None, "", []) for field in fields):
|
|
out.append(
|
|
f"{path.relative_to(ROOT)}: files[{index}] ({entry.get('name')}) "
|
|
f"declares a {check} check with no {'/'.join(fields)} value"
|
|
)
|
|
return out
|
|
|
|
|
|
def _validate_json_files(
|
|
paths: list[Path],
|
|
schema_name: str,
|
|
semantic_check: Callable[[Path, object], list[str]] | None = None,
|
|
) -> 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
|
|
validation_errors = _errors(validator, data, str(path.relative_to(ROOT)))
|
|
out.extend(validation_errors)
|
|
if not validation_errors and semantic_check is not None:
|
|
out.extend(semantic_check(path, data))
|
|
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.
|
|
|
|
Reading a pack while a build is writing it reports "File is not a zip
|
|
file" about an archive that is merely half-written, so this takes the same
|
|
shared lock --verify-packs does. A build holding the exclusive lock means
|
|
the packs on disk are mid-flight and there is nothing stable to validate.
|
|
"""
|
|
if not dist.is_dir():
|
|
return []
|
|
|
|
sys.path.insert(0, str(ROOT / "scripts"))
|
|
from common import ArtifactLockBusy, artifact_lock
|
|
|
|
try:
|
|
with artifact_lock(str(dist), exclusive=False):
|
|
return _scan_pack_manifests(dist)
|
|
except ArtifactLockBusy:
|
|
print(
|
|
f"note: {dist.name} is being written, skipping pack manifests",
|
|
file=sys.stderr,
|
|
)
|
|
return []
|
|
|
|
|
|
def _scan_pack_manifests(dist: Path) -> list[str]:
|
|
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
|
|
|
|
|
|
_UNREACHABLE_REF = re.compile(
|
|
r"(?:^|[\s,;(])(?:tmp/|/|[A-Za-z]:[\\/]|\.{1,2}/)[\w.\\/+-]*[\w+-]:\d+(?:-\d+)?"
|
|
)
|
|
|
|
|
|
def _unreachable_citations(path: Path, document: object) -> list[str]:
|
|
"""Refs naming a place no repository can hold.
|
|
|
|
A citation is read against a revision of a declared repository, so a
|
|
scratch directory, an absolute path or one climbing out of the tree can
|
|
never resolve and never will. kenji-nx carried tmp/es-de/ANDROID.md:470,
|
|
a path from whoever profiled it, which profile_sync could only report
|
|
missing forever.
|
|
"""
|
|
out: list[str] = []
|
|
stack: list[object] = [document]
|
|
while stack:
|
|
node = stack.pop()
|
|
if isinstance(node, dict):
|
|
stack.extend(node.values())
|
|
elif isinstance(node, list):
|
|
stack.extend(node)
|
|
elif isinstance(node, str):
|
|
for match in _UNREACHABLE_REF.finditer(node):
|
|
out.append(
|
|
f"{path.relative_to(ROOT)}: citation names a path outside "
|
|
f"any repository: {match.group().strip(' ,;(')}"
|
|
)
|
|
return out
|
|
|
|
|
|
_DOUBLED_PATH = re.compile(r"((?:[\w.-]+/){2,})\1")
|
|
|
|
|
|
def _doubled_paths(path: Path) -> list[str]:
|
|
"""A citation whose directory is written twice in a row.
|
|
|
|
Read from the raw text, comments included: primehack's note on the
|
|
Realtek loader cited Source/Core/Core/IOS/USB/Bluetooth/ twice over, a
|
|
path no revision holds, in a comment the parsed document never shows.
|
|
"""
|
|
out: list[str] = []
|
|
for number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
|
|
match = _DOUBLED_PATH.search(line)
|
|
if match:
|
|
out.append(
|
|
f"{path.relative_to(ROOT)}:{number}: citation repeats its "
|
|
f"directory: {match.group(1)}"
|
|
)
|
|
return out
|
|
|
|
|
|
def _unknown_data_dir_refs() -> list[str]:
|
|
"""data_directories refs the registry does not define.
|
|
|
|
A ref nobody registers is never fetched: yaps2 named yaps2-resources and
|
|
every run printed "not cached" while refresh_data_dirs had nothing to
|
|
refresh.
|
|
"""
|
|
registry_path = ROOT / "platforms" / "_data_dirs.yml"
|
|
with registry_path.open(encoding="utf-8") as handle:
|
|
registered = set((yaml_load(handle) or {}).get("data_directories", {}))
|
|
out: list[str] = []
|
|
for directory in (ROOT / "emulators", ROOT / "platforms"):
|
|
for path in source_yaml_paths(directory):
|
|
if path.name.startswith("_"):
|
|
continue
|
|
with path.open(encoding="utf-8") as handle:
|
|
document = yaml_load(handle) or {}
|
|
systems = document.get("systems")
|
|
holders = [document, *(systems.values() if isinstance(systems, dict) else [])]
|
|
for holder in holders:
|
|
if not isinstance(holder, dict):
|
|
continue
|
|
for entry in holder.get("data_directories") or []:
|
|
ref = entry.get("ref") if isinstance(entry, dict) else None
|
|
if ref and ref not in registered:
|
|
out.append(
|
|
f"{path.relative_to(ROOT)}: data directory {ref!r} "
|
|
f"is not in platforms/_data_dirs.yml"
|
|
)
|
|
return out
|
|
|
|
|
|
def _repository_layout_in_profiles() -> list[str]:
|
|
"""Profile destinations that name this repository's storage.
|
|
|
|
`path:` is where the emulator's code reads the file, never where we keep
|
|
it: a profile written from a clone filed its variants under
|
|
`.variants/...`, and the pack then wrote them where no emulator looks.
|
|
"""
|
|
out: list[str] = []
|
|
for path in source_yaml_paths(ROOT / "emulators"):
|
|
with path.open(encoding="utf-8") as handle:
|
|
document = yaml_load(handle) or {}
|
|
for entry in document.get("files") or []:
|
|
if not isinstance(entry, dict):
|
|
continue
|
|
for key in ("path", "standalone_path"):
|
|
value = str(entry.get(key) or "")
|
|
if ".variants" in value.replace("\\", "/").split("/"):
|
|
out.append(
|
|
f"{path.relative_to(ROOT)}: {entry.get('name')} {key} "
|
|
f"names the repository's .variants/ storage: {value}"
|
|
)
|
|
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")
|
|
# One path holds one content. Two entries naming the same path means one
|
|
# of them declares a hash the file at that path does not have.
|
|
owners: dict[str, str] = {}
|
|
for sha1, entry in files.items():
|
|
if entry.get("sha1") != sha1:
|
|
out.append(f"database.json: files/{sha1}: key and sha1 differ")
|
|
path = entry.get("path", "")
|
|
if not path:
|
|
continue
|
|
first = owners.setdefault(path, sha1)
|
|
if first != sha1:
|
|
out.append(
|
|
f"database.json: {path} is claimed by {first} and {sha1}"
|
|
)
|
|
return out
|
|
|
|
|
|
def _save_dir_without_library_name(path: Path, document: object) -> list[str]:
|
|
"""A file the libretro build reads below the save directory, in a profile
|
|
naming no library_name.
|
|
|
|
RetroArch hands each core <save directory>/<library_name> by default
|
|
(sort_savefiles_enable): without the name, packs and the installer
|
|
cannot say where the file goes, and leave it out.
|
|
"""
|
|
from validation import read_from_save_dir
|
|
|
|
if not isinstance(document, dict):
|
|
return []
|
|
if "library_name" in document:
|
|
return _save_folder_once_paths(path, document)
|
|
return [
|
|
f"{path.relative_to(ROOT)}: files[{index}] ({entry.get('name')}) is read "
|
|
"below the save directory and the profile declares no library_name"
|
|
for index, entry in enumerate(document.get("files") or [])
|
|
if isinstance(entry, dict)
|
|
and read_from_save_dir(entry)
|
|
and not ("path" in entry and entry["path"] is None)
|
|
]
|
|
|
|
|
|
def _save_folder_once_paths(path: Path, document: dict) -> list[str]:
|
|
"""A core adding its own folder once: each save path starts with it.
|
|
|
|
The pack places such a file at <save directory>/<path> whether the
|
|
frontend sorts saves by core or not, which holds only when the path
|
|
already names the folder the core reads below.
|
|
"""
|
|
from validation import read_from_save_dir
|
|
|
|
if not document.get("save_folder_once"):
|
|
return []
|
|
folder = f"{document.get('library_name') or ''}/"
|
|
return [
|
|
f"{path.relative_to(ROOT)}: files[{index}] ({entry.get('name')}) does not "
|
|
f"start with {folder}, the folder save_folder_once says the core adds"
|
|
for index, entry in enumerate(document.get("files") or [])
|
|
if isinstance(entry, dict)
|
|
and read_from_save_dir(entry)
|
|
and entry.get("path")
|
|
and not str(entry["path"]).startswith(folder)
|
|
]
|
|
|
|
|
|
def _registry_contract() -> list[str]:
|
|
"""The registry against its schema, and a release that still ships a pack.
|
|
|
|
`release_pack` is the only list of the ZIPs a release publishes: a
|
|
registry where no active platform carries it would cut a release with
|
|
no pack at all, and every download link would lead to the command.
|
|
"""
|
|
registry_path = ROOT / "platforms" / "_registry.yml"
|
|
if not registry_path.is_file():
|
|
return []
|
|
with registry_path.open(encoding="utf-8") as handle:
|
|
registry = yaml_load(handle) or {}
|
|
label = str(registry_path.relative_to(ROOT))
|
|
out = _errors(_validator("registry.schema.json"), registry, label)
|
|
platforms = registry.get("platforms") or {}
|
|
if isinstance(platforms, dict) and not any(
|
|
isinstance(entry, dict)
|
|
and entry.get("release_pack") is True
|
|
and entry.get("status", "active") == "active"
|
|
for entry in platforms.values()
|
|
):
|
|
out.append(f"{label}: no active platform has release_pack: true")
|
|
return out
|
|
|
|
|
|
def _save_directory_blocks() -> list[str]:
|
|
"""Each registry save_directory is null or a folder and a by_core flag.
|
|
|
|
The folder names the pack folder and the save directory's default place
|
|
beside the BIOS directory: a relative path that never climbs. ``by_system`` (a save
|
|
folder per launched system) needs the platform file's libretro_systems,
|
|
and ``by_core_for`` names the cores a launcher sorts by core.
|
|
"""
|
|
registry_path = ROOT / "platforms" / "_registry.yml"
|
|
if not registry_path.is_file():
|
|
return []
|
|
with registry_path.open(encoding="utf-8") as handle:
|
|
platforms = (yaml_load(handle) or {}).get("platforms") or {}
|
|
out: list[str] = []
|
|
for name, entry in sorted(platforms.items()):
|
|
if not isinstance(entry, dict) or "save_directory" not in entry:
|
|
continue
|
|
block = entry["save_directory"]
|
|
if block is None:
|
|
continue
|
|
folder = block.get("folder") if isinstance(block, dict) else None
|
|
sorted_cores = block.get("by_core_for", []) if isinstance(block, dict) else None
|
|
if (
|
|
not isinstance(block, dict)
|
|
or not {"folder", "by_core"} <= set(block) <= {
|
|
"folder", "by_core", "by_system", "by_core_for", "pack_folder",
|
|
}
|
|
or not isinstance(block.get("pack_folder", "x"), str)
|
|
or "/" in str(block.get("pack_folder", ""))
|
|
or not isinstance(folder, str)
|
|
or not folder
|
|
or "\\" in folder
|
|
or any(part in ("", ".", "..") for part in folder.split("/"))
|
|
or not isinstance(block.get("by_core"), bool)
|
|
or not isinstance(block.get("by_system", False), bool)
|
|
or not isinstance(sorted_cores, list)
|
|
or not all(isinstance(core, str) and core for core in sorted_cores)
|
|
):
|
|
out.append(
|
|
f"platforms/_registry.yml: {name}.save_directory must be null or "
|
|
"{folder: <relative path>, by_core: <bool>, by_system: <bool>, "
|
|
"by_core_for: [<core>]}"
|
|
)
|
|
continue
|
|
if block.get("by_system") and not _libretro_systems(name):
|
|
out.append(
|
|
f"platforms/_registry.yml: {name}.save_directory.by_system and "
|
|
f"platforms/{name}.yml lists no libretro_systems"
|
|
)
|
|
in_pack = block.get("pack_folder") or folder
|
|
taken = _bios_tree_below(name, in_pack)
|
|
if taken:
|
|
out.append(
|
|
f"platforms/_registry.yml: {name}.save_directory puts the save "
|
|
f"files in {in_pack}/, which the BIOS tree uses ({taken}): "
|
|
"name another pack_folder"
|
|
)
|
|
return out
|
|
|
|
|
|
def _bios_tree_below(platform: str, folder: str) -> str:
|
|
"""A destination the platform declares below ``folder``, or empty."""
|
|
from common import load_platform_config
|
|
|
|
try:
|
|
config = load_platform_config(platform, str(ROOT / "platforms"))
|
|
except FileNotFoundError:
|
|
return ""
|
|
prefix = f"{folder}/".lower()
|
|
return next(
|
|
(
|
|
str(entry["destination"])
|
|
for system in (config.get("systems") or {}).values()
|
|
for entry in system.get("files") or []
|
|
if str(entry.get("destination") or "").lower().startswith(prefix)
|
|
),
|
|
"",
|
|
)
|
|
|
|
|
|
def _libretro_systems(platform: str) -> dict:
|
|
"""The platform file's core -> systems table, inheritance included."""
|
|
from common import load_platform_config
|
|
|
|
try:
|
|
config = load_platform_config(platform, str(ROOT / "platforms"))
|
|
except FileNotFoundError:
|
|
return {}
|
|
return config.get("libretro_systems") or {}
|
|
|
|
|
|
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)"
|
|
)
|
|
# One dest below the BIOS folder and one below the save directory are
|
|
# two places.
|
|
destinations = [(entry.get("root"), 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("root"), 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"
|
|
)
|
|
saved = [
|
|
entry for entry in [*files, *manifest.get("case_variants", [])]
|
|
if entry.get("root") == "save"
|
|
]
|
|
layout = manifest.get("save_directory")
|
|
if saved and not layout:
|
|
out.append(
|
|
f"{path.relative_to(ROOT)}: entries read below the save directory "
|
|
"and no save_directory"
|
|
)
|
|
stray = sorted(
|
|
entry["dest"] for entry in [*files, *omitted]
|
|
if entry.get("core_folder") and entry.get("root") != "save"
|
|
)
|
|
if stray:
|
|
out.append(
|
|
f"{path.relative_to(ROOT)}: core_folder outside root: save ({stray[0]})"
|
|
)
|
|
return out
|
|
|
|
|
|
def _event_schema_errors() -> list[str]:
|
|
"""install-events.schema.json is EVENT_KEYS written out, and a valid schema."""
|
|
import generate_event_schema # noqa: PLC0415
|
|
|
|
path = ROOT / "schemas" / generate_event_schema.OUTPUT.name
|
|
label = path.relative_to(ROOT).as_posix()
|
|
if not path.is_file():
|
|
return [f"{label}: missing, run python scripts/generate_event_schema.py"]
|
|
if path.read_text(encoding="utf-8") != generate_event_schema.render(
|
|
generate_event_schema.build_schema()
|
|
):
|
|
return [f"{label}: differs from EVENT_KEYS in install.py, run "
|
|
"python scripts/generate_event_schema.py"]
|
|
Draft202012Validator.check_schema(_load_json(path))
|
|
return []
|
|
|
|
|
|
def unchecked_formats() -> list[str]:
|
|
"""Formats the schemas declare that this jsonschema install cannot check.
|
|
|
|
date-time and uri need the format extras. Without them the ten format
|
|
constraints of the published schemas were accepted unread.
|
|
"""
|
|
used = {
|
|
match
|
|
for path in SCHEMAS.glob("*.json")
|
|
for match in re.findall(r'"format"\s*:\s*"([^"]+)"', path.read_text(encoding="utf-8"))
|
|
}
|
|
return sorted(used - set(FormatChecker().checkers))
|
|
|
|
|
|
def _format_errors() -> list[str]:
|
|
"""jsonschema skips a format it has no checker for: say so, and fail."""
|
|
return [
|
|
f"no checker for format {name}: install \"jsonschema[format-nongpl]\""
|
|
for name in unchecked_formats()
|
|
]
|
|
|
|
|
|
def _measurement_errors(root: Path | None = None) -> list[str]:
|
|
"""Every record line under measurements/, against its schema and the
|
|
rules measurement_records.read_records applies."""
|
|
import measurement_records as mr # noqa: PLC0415
|
|
|
|
root = root or ROOT
|
|
validator = _validator("core-probe-record.schema.json")
|
|
out: list[str] = []
|
|
campaign = {mr.records_path(name): _validator(schema)
|
|
for name, (_, schema) in mr.CAMPAIGN_FILES.items()}
|
|
for path, file in sorted(mr.tree_files(root).items()):
|
|
if not path.endswith(".jsonl"):
|
|
continue
|
|
for number, line in enumerate(file.read_text(encoding="utf-8").splitlines(), 1):
|
|
try:
|
|
record = json.loads(line)
|
|
except json.JSONDecodeError as exc:
|
|
out.append(f"{path}:{number}: {exc}")
|
|
continue
|
|
out.extend(_errors(campaign.get(path, validator), record, f"{path}:{number}"))
|
|
if not out:
|
|
try:
|
|
mr.read_records(root)
|
|
for name in mr.CAMPAIGN_FILES:
|
|
mr.read_campaign(root, name)
|
|
except mr.MeasurementsError as exc:
|
|
out.append(str(exc))
|
|
return out
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(allow_abbrev=False, description=__doc__)
|
|
parser.add_argument(
|
|
"--source-only",
|
|
action="store_true",
|
|
help="validate emulator/platform YAML only",
|
|
)
|
|
args = parser.parse_args()
|
|
|
|
errors: list[str] = _format_errors()
|
|
errors.extend(
|
|
_validate_yaml_directory(ROOT / "emulators", "emulator.schema.json")
|
|
)
|
|
errors.extend(
|
|
_validate_yaml_directory(
|
|
ROOT / "platforms", "platform.schema.json", skip_private=True
|
|
)
|
|
)
|
|
errors.extend(_unknown_data_dir_refs())
|
|
errors.extend(_repository_layout_in_profiles())
|
|
errors.extend(_save_directory_blocks())
|
|
errors.extend(_registry_contract())
|
|
errors.extend(_event_schema_errors())
|
|
errors.extend(_measurement_errors())
|
|
|
|
if not args.source_only:
|
|
database_path = ROOT / "database.json"
|
|
errors.extend(
|
|
_validate_json_files(
|
|
[database_path],
|
|
"database.schema.json",
|
|
lambda _path, database: _semantic_database_checks(database),
|
|
)
|
|
)
|
|
|
|
install_paths = sorted((ROOT / "install").glob("*.json"))
|
|
errors.extend(
|
|
_validate_json_files(
|
|
install_paths, "install-manifest.schema.json", _semantic_install_checks
|
|
)
|
|
)
|
|
|
|
target_paths = sorted((ROOT / "install" / "targets").glob("*.json"))
|
|
errors.extend(
|
|
_validate_json_files(target_paths, "target-manifest.schema.json")
|
|
)
|
|
|
|
release_path = ROOT / "release.json"
|
|
if release_path.exists():
|
|
errors.extend(_validate_json_files([release_path], "release.schema.json"))
|
|
|
|
stats_path = ROOT / "website" / "public" / "stats.json"
|
|
if stats_path.exists():
|
|
errors.extend(_validate_json_files([stats_path], "stats.schema.json"))
|
|
|
|
api_dir = ROOT / "website" / "public" / "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()
|
|
] + sorted((api_dir / "platforms").glob("*.json")) + sorted(
|
|
(api_dir / "systems").glob("*.json")
|
|
) + sorted((api_dir / "lookup").glob("*.json"))
|
|
errors.extend(
|
|
_validate_json_files(
|
|
envelope_paths,
|
|
"site-api-envelope.schema.json",
|
|
_semantic_envelope_checks,
|
|
)
|
|
)
|
|
|
|
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__":
|
|
from common import run_cli
|
|
|
|
run_cli(main)
|