mirror of
https://github.com/Abdess/retroarch_system.git
synced 2026-10-18 17:33:30 -05:00
A comment named the Bluetooth directory twice, a path no revision holds, and every firmware ref sat ten lines above the case it meant. Each now cites its case and return at the pinned commit. The schema check reads profiles as text and refuses a citation that repeats its own directories.
419 lines
15 KiB
Python
419 lines
15 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 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(_doubled_paths(path))
|
|
return out
|
|
|
|
|
|
_CHECK_FIELDS = {
|
|
"crc32": ("crc32",),
|
|
"md5": ("md5",),
|
|
"sha1": ("sha1",),
|
|
"sha256": ("sha256",),
|
|
"size": ("size", "min_size", "max_size"),
|
|
}
|
|
|
|
|
|
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) -> 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.
|
|
|
|
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 _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 _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 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 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] = _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())
|
|
|
|
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")
|
|
)
|
|
|
|
release_path = ROOT / "release.json"
|
|
if release_path.exists():
|
|
errors.extend(_validate_json_files([release_path], "release.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__":
|
|
from common import run_cli
|
|
|
|
run_cli(main)
|