diff --git a/scripts/validate_schemas.py b/scripts/validate_schemas.py index c28cd2d82..97b40707a 100644 --- a/scripts/validate_schemas.py +++ b/scripts/validate_schemas.py @@ -87,6 +87,7 @@ def _validate_yaml_directory( out.extend(_systems_outside_profile(path, data)) out.extend(_doubled_paths(path)) out.extend(_group_requirement_split(path, data)) + out.extend(_data_only_source_refs(path, data)) return out @@ -155,6 +156,65 @@ def _group_requirement_split(path: Path, document: object) -> list[str]: return out +_CODE_SUFFIXES = frozenset( + "c cc cpp cxx c++ h hh hpp hxx inc inl ipp m mm mjs cjs js jsx ts tsx py cs java kt " + "go rs lua pas pp asm s sh bash zsh ps1 bat cmd vbs swift d zig nim qml qc rb pl php " + "scala ml hs dart vue ino z80 smali".split() +) +_CODE_FILE = re.compile(r"[\w+.-]\.([A-Za-z0-9+]+)\b") +_BINARY_EVIDENCE = re.compile( + r"\b0x[0-9a-fA-F]+\b|\bFUN_[0-9a-fA-F]+\b|\.(?:so|dll|exe|dylib|dex|apk|jar|elf|nro|xex)\b", + re.IGNORECASE, +) + +# Profiles whose files cite no published code. Each reason says why the +# check cannot apply yet. +DATA_ONLY_REF_EXCEPTIONS: dict[str, str] = { + "ace-dl": "closed source: the refs describe what the shipped binary opens", + "cpcemu": "closed source: the refs describe what the shipped binary opens", + "drastic": "closed source: the shader format files are read by the shipped binary", + "lexaloffle": "closed source: PICO-8 reads pico8_gpio, the refs are its readme and the KNULLI install script", + "mfme": "closed source: the refs are the release notes of the shipped binary", +} + + +def _ref_cites_code(ref: str) -> bool: + """A source_ref part names a source file, or a binary with an address.""" + from sourceref import split_source_ref + + if _BINARY_EVIDENCE.search(ref): + return True + for part in split_source_ref(ref): + for suffix in _CODE_FILE.findall(part.raw): + if suffix.lower() in _CODE_SUFFIXES: + return True + return False + + +def _data_only_source_refs(path: Path, document: object) -> list[str]: + """A files[] source_ref that cites only data or build files. + + A data file is evidence that the emulator reads a file only once the + code that ships and parses it is cited too: iwadinfo.txt names the + IWADs, d_iwad.cpp is what reads it. 655 entries in 27 profiles cited + the table alone, and six of them named a file no code opens. + """ + if not isinstance(document, dict) or path.stem in DATA_ONLY_REF_EXCEPTIONS: + return [] + out: list[str] = [] + for index, entry in enumerate(document.get("files") or []): + if not isinstance(entry, dict) or not entry.get("source_ref"): + continue + refs = entry["source_ref"] + for ref in refs.values() if isinstance(refs, dict) else [refs]: + if ref and not _ref_cites_code(str(ref)): + out.append( + f"{path.relative_to(ROOT)}: files[{index}] ({entry.get('name')}) " + f"source_ref cites no code that reads the file: {ref}" + ) + return out + + def _checks_without_values(path: Path, document: object) -> list[str]: """A check an entry declares with nothing to compare against. diff --git a/tests/test_data_only_refs.py b/tests/test_data_only_refs.py new file mode 100644 index 000000000..345f7d091 --- /dev/null +++ b/tests/test_data_only_refs.py @@ -0,0 +1,83 @@ +"""A files[] source_ref must cite the code that reads the file.""" + +from __future__ import annotations + +import tests # noqa: F401 offline, scratch under tmp/, the suite's own cache + +import sys +import unittest +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(REPO_ROOT / "scripts")) + +try: + import validate_schemas +except ImportError as exc: + validate_schemas = None + IMPORT_ERROR = str(exc) +else: + IMPORT_ERROR = "" + + +def _profile(ref: object) -> dict: + return {"files": [{"name": "doom.wad", "source_ref": ref}]} + + +@unittest.skipIf(validate_schemas is None, f"validate_schemas unavailable: {IMPORT_ERROR}") +class DataOnlySourceRefs(unittest.TestCase): + path = REPO_ROOT / "emulators" / "synthetic.yml" + + def errors(self, ref: object) -> list[str]: + return validate_schemas._data_only_source_refs(self.path, _profile(ref)) + + def test_a_data_table_alone_is_refused(self): + self.assertEqual(len(self.errors("wadsrc_extra/static/iwadinfo.txt:420, 437")), 1) + + def test_a_build_file_alone_is_refused(self): + for ref in ("pc-bios/meson.build:30", "wadsrc/CMakeLists.txt:1", + "dists/engine-data/engine_data.mk (ENABLE_KYRA)", "src/configure:1933"): + with self.subTest(ref=ref): + self.assertEqual(len(self.errors(ref)), 1) + + def test_a_file_in_the_tree_alone_is_refused(self): + self.assertEqual(len(self.errors("fMSX/ROMs/MSX.ROM")), 1) + + def test_the_table_with_its_reader_passes(self): + self.assertEqual(self.errors( + "wadsrc_extra/static/iwadinfo.txt:420, wadsrc_extra/CMakeLists.txt:1, " + "src/d_iwad.cpp:286-296, 502-531"), []) + + def test_a_role_prefix_is_read_through(self): + self.assertEqual(self.errors("source pc-bios/meson.build:30, source hw/i386/pc_piix.c:466"), []) + + def test_a_binary_with_an_address_passes(self): + for ref in ("libemucore.so 0x1a2b3c", "Fusion.exe 0x00401000", + "bigpemu FUN_006d8be0 (BootROM read)", "nethersx2 v2.2n classes.dex icons/star-%d.png"): + with self.subTest(ref=ref): + self.assertEqual(self.errors(ref), []) + + def test_each_build_mode_is_judged(self): + self.assertEqual(len(self.errors({"standalone": "src/main.cpp:10", "libretro": "data/list.txt:3"})), 1) + + def test_an_excepted_profile_is_not_judged(self): + name = next(iter(validate_schemas.DATA_ONLY_REF_EXCEPTIONS)) + path = REPO_ROOT / "emulators" / f"{name}.yml" + self.assertEqual(validate_schemas._data_only_source_refs(path, _profile("history.txt:38")), []) + + def test_every_exception_names_a_profile_and_a_reason(self): + for name, reason in validate_schemas.DATA_ONLY_REF_EXCEPTIONS.items(): + with self.subTest(profile=name): + self.assertTrue((REPO_ROOT / "emulators" / f"{name}.yml").is_file()) + self.assertTrue(reason.strip()) + + def test_the_profiles_cite_their_readers(self): + errors: list[str] = [] + for path in sorted((REPO_ROOT / "emulators").glob("*.yml")): + errors.extend(validate_schemas._data_only_source_refs( + path, validate_schemas._load_source(path))) + self.assertEqual(errors, []) + + +if __name__ == "__main__": + unittest.main()