mirror of
https://github.com/Abdess/retroarch_system.git
synced 2026-10-10 13:33:24 -05:00
feat: watch the profiles that declare no files
An empty file list is the one assertion here that ages unwatched. Nothing can go missing and no ref can drift, so nothing notices when a core that embedded everything grows a path. virtualjaguar said "No external BIOS files are required or loaded by this core" while its source had grown eleven filenames read from the system directory, and only a reading found it. fileless_audit looks for the request itself, the system directory ask, in the sources each profile already cites. Over the 151 fileless profiles it named eleven, of which two were covered by data_directories, six carried an exclusion_note, and three had nothing written down at all: craft writes its world database in that directory, dice stores the answer in a variable no other file in the tree names, lutro hands it to the Lua game. Each now says so. The check settles: declared files, a declared directory, or a written answer all end it, so what it reports is the set nobody has read yet. A test holds the corpus at zero.
This commit is contained in:
1 parent
2ea745a062
commit
d124f6c516
6 files changed
+284
No files matched your search
@@ -9,6 +9,12 @@ core_version: "v1"
|
||||
display_name: "Minecraft (Craft)"
|
||||
cores: [craft]
|
||||
systems: []
|
||||
exclusion_note: >
|
||||
The system directory is asked for, and what goes in it is written by the
|
||||
core: g->db_path and g->db_auth_path, the world database and its auth
|
||||
table. Nothing is read from it that the user has to supply
|
||||
(src/main.c:2682-2697).
|
||||
|
||||
files: []
|
||||
notes: >
|
||||
Libretro port of Craft, a simple Minecraft clone by Michael Fogleman.
|
||||
|
||||
@@ -22,6 +22,11 @@ notes: |
|
||||
dummy launcher files. The system directory is retrieved but never used
|
||||
for file loading (libretro.cpp:60-62).
|
||||
|
||||
exclusion_note: >
|
||||
The system directory is asked for and stored in retro_base_directory, which
|
||||
is named in no other file of the tree and read nowhere: the answer is never
|
||||
used (libretro.cpp:28,60-63).
|
||||
|
||||
files: []
|
||||
|
||||
analysis:
|
||||
|
||||
@@ -22,4 +22,9 @@ notes: |
|
||||
(filesystem.c:296) but does not load any files from it itself.
|
||||
No BIOS, firmware, or system files required.
|
||||
|
||||
exclusion_note: >
|
||||
The system directory is not read by the core but handed to the Lua game
|
||||
through fs_getAppdataDirectory, so whatever is loaded from it is the game's
|
||||
own content, not a file this core names (filesystem.c:291-303).
|
||||
|
||||
files: []
|
||||
@@ -0,0 +1,110 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Find profiles that declare no files whose source now asks for a directory.
|
||||
|
||||
A profile with an empty `files:` list asserts that the emulator loads nothing
|
||||
from disk. That assertion ages: a core that embedded everything can grow a
|
||||
path, and nothing in the repository notices, because there is no file to go
|
||||
missing and no ref to drift. virtualjaguar carried "No external BIOS files are
|
||||
required or loaded by this core" while its source had grown eleven filenames
|
||||
read from the system directory.
|
||||
|
||||
The signal is the request for a directory to read from. A libretro core asks
|
||||
the frontend with RETRO_ENVIRONMENT_GET_SYSTEM_DIRECTORY; other shapes ask the
|
||||
environment or build a path from a home directory. Finding one in a file the
|
||||
profile itself cites does not prove a file is loaded, and this reports rather
|
||||
than concludes: it names the profiles worth a reading, so the other hundred and
|
||||
fifty need none.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
|
||||
import upstream
|
||||
from profile_sync import collect_citations, select_views, split_source_ref
|
||||
from safeparse import yaml_load
|
||||
|
||||
# Ways a program asks for somewhere to read from.
|
||||
SIGNALS = (
|
||||
"RETRO_ENVIRONMENT_GET_SYSTEM_DIRECTORY",
|
||||
"GET_SYSTEM_DIRECTORY",
|
||||
"system_directory",
|
||||
"get_system_directory",
|
||||
)
|
||||
|
||||
SOURCE_SUFFIX = (".c", ".cpp", ".cc", ".cxx", ".h", ".hpp", ".m", ".mm")
|
||||
|
||||
|
||||
def cited_paths(document: dict) -> list[str]:
|
||||
"""Source paths the profile points at, in document order, deduplicated."""
|
||||
seen: list[str] = []
|
||||
for citation in collect_citations(document):
|
||||
for part in split_source_ref(citation.ref):
|
||||
path = part.path
|
||||
if path.endswith(SOURCE_SUFFIX) and path not in seen:
|
||||
seen.append(path)
|
||||
return seen
|
||||
|
||||
|
||||
def audit(name: str, emulators_dir: str, cache_dir: str, offline: bool):
|
||||
"""Signals found in the sources a fileless profile cites."""
|
||||
with open(os.path.join(emulators_dir, f"{name}.yml"), encoding="utf-8") as handle:
|
||||
document = yaml_load(handle) or {}
|
||||
if document.get("files"):
|
||||
return None
|
||||
if str(document.get("exclusion_note") or "").strip():
|
||||
# Someone read this one and wrote down why nothing is declared. The
|
||||
# check exists to find the profiles nobody has answered yet; an
|
||||
# answer that goes stale shows up as a drifting ref instead.
|
||||
return None
|
||||
if document.get("data_directories"):
|
||||
# The load is declared, as a directory rather than a file. dinothawr
|
||||
# reads system_dir/dinothawr/ and says so there; that is coverage, not
|
||||
# an assertion waiting to age.
|
||||
return None
|
||||
views = select_views(document, cache_dir, offline)
|
||||
if not views:
|
||||
return []
|
||||
hits: list[tuple[str, str]] = []
|
||||
for path in cited_paths(document)[:40]:
|
||||
for view in views:
|
||||
lines = upstream.fetch_file(view.repo, view.pin, path, cache_dir, offline)
|
||||
if lines is None:
|
||||
continue
|
||||
for signal in SIGNALS:
|
||||
if any(signal in line for line in lines):
|
||||
hits.append((path, signal))
|
||||
break
|
||||
break
|
||||
return hits
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||
parser.add_argument("emulators", nargs="+")
|
||||
parser.add_argument("--emulators-dir", default="emulators")
|
||||
parser.add_argument("--cache-dir", default=".cache")
|
||||
parser.add_argument("--offline", action="store_true")
|
||||
args = parser.parse_args()
|
||||
|
||||
flagged = 0
|
||||
for name in args.emulators:
|
||||
try:
|
||||
hits = audit(name, args.emulators_dir, args.cache_dir, args.offline)
|
||||
except upstream.UpstreamError as exc:
|
||||
print(f"{name}: unreachable, {exc}", file=sys.stderr)
|
||||
continue
|
||||
if hits is None or not hits:
|
||||
continue
|
||||
flagged += 1
|
||||
print(f"{name}: declares no files, yet its source asks for a directory")
|
||||
for path, signal in hits:
|
||||
print(f" {path}: {signal}")
|
||||
raise SystemExit(1 if flagged else 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,131 @@
|
||||
"""A profile that declares no files has nothing that can go missing.
|
||||
|
||||
No file to be absent, no ref to drift: an empty `files:` list is the one
|
||||
assertion in the repository that ages without any existing check noticing.
|
||||
virtualjaguar carried "No external BIOS files are required or loaded by this
|
||||
core" while its source had grown eleven filenames read from the system
|
||||
directory, and only a manual reading found it.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(ROOT / "scripts"))
|
||||
|
||||
import fileless_audit
|
||||
|
||||
|
||||
ASKS = [
|
||||
"static void init(void)",
|
||||
" if (environ_cb(RETRO_ENVIRONMENT_GET_SYSTEM_DIRECTORY, &dir) && dir)",
|
||||
" snprintf(path, sizeof(path), \"%s/bios.rom\", dir);",
|
||||
]
|
||||
QUIET = ["static void init(void) { /* nothing */ }"]
|
||||
|
||||
BASE = """emulator: Test
|
||||
source: "https://github.com/o/n"
|
||||
source_commit: "pinsha"
|
||||
files: []
|
||||
"""
|
||||
|
||||
|
||||
class TestFilelessAudit(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.dir = self.tmp.name
|
||||
self._orig = (
|
||||
fileless_audit.select_views,
|
||||
fileless_audit.upstream.fetch_file,
|
||||
fileless_audit.collect_citations,
|
||||
)
|
||||
|
||||
class View:
|
||||
repo = "repo"
|
||||
pin = "pinsha"
|
||||
|
||||
fileless_audit.select_views = lambda doc, cache, offline: [View()]
|
||||
fileless_audit.collect_citations = lambda doc: [
|
||||
type("C", (), {"ref": "libretro.c:10"})()
|
||||
]
|
||||
self.source = ASKS
|
||||
fileless_audit.upstream.fetch_file = (
|
||||
lambda repo, sha, path, cache, offline=False: self.source
|
||||
)
|
||||
|
||||
def tearDown(self):
|
||||
(
|
||||
fileless_audit.select_views,
|
||||
fileless_audit.upstream.fetch_file,
|
||||
fileless_audit.collect_citations,
|
||||
) = self._orig
|
||||
self.tmp.cleanup()
|
||||
|
||||
def _write(self, body: str) -> None:
|
||||
Path(self.dir, "p.yml").write_text(body, encoding="utf-8")
|
||||
|
||||
def _run(self):
|
||||
return fileless_audit.audit("p", self.dir, ".c", False)
|
||||
|
||||
def test_a_fileless_profile_whose_source_asks_is_flagged(self):
|
||||
self._write(BASE)
|
||||
self.assertEqual(self._run(), [("libretro.c", "RETRO_ENVIRONMENT_GET_SYSTEM_DIRECTORY")])
|
||||
|
||||
def test_a_source_that_asks_for_nothing_is_quiet(self):
|
||||
self._write(BASE)
|
||||
self.source = QUIET
|
||||
self.assertEqual(self._run(), [])
|
||||
|
||||
def test_a_profile_that_declares_files_is_not_the_subject(self):
|
||||
self._write("emulator: T\nfiles:\n - name: a.bin\n")
|
||||
self.assertIsNone(self._run())
|
||||
|
||||
def test_a_directory_declaration_is_coverage(self):
|
||||
"""dinothawr reads system_dir/dinothawr/ and says so there."""
|
||||
self._write(BASE + "data_directories:\n - key: d\n")
|
||||
self.assertIsNone(self._run())
|
||||
|
||||
def test_a_written_answer_settles_it(self):
|
||||
"""craft asks for the directory to write its world database in."""
|
||||
self._write(BASE + 'exclusion_note: "the directory is written, not read"\n')
|
||||
self.assertIsNone(self._run())
|
||||
|
||||
def test_an_empty_answer_does_not_settle_it(self):
|
||||
self._write(BASE + 'exclusion_note: " "\n')
|
||||
self.assertEqual(len(self._run()), 1)
|
||||
|
||||
|
||||
class TestCorpusIsAnswered(unittest.TestCase):
|
||||
"""Every fileless profile is covered, answered, or flagged.
|
||||
|
||||
Offline, so it reads the fetch cache; skipped where that is cold rather
|
||||
than turning a network absence into a failure.
|
||||
"""
|
||||
|
||||
def test_no_fileless_profile_is_left_unexplained(self):
|
||||
if not (ROOT / ".cache").is_dir():
|
||||
self.skipTest("no upstream cache")
|
||||
from safeparse import yaml_load
|
||||
|
||||
flagged = []
|
||||
for path in sorted((ROOT / "emulators").glob("*.yml")):
|
||||
with path.open(encoding="utf-8") as handle:
|
||||
document = yaml_load(handle) or {}
|
||||
if document.get("files"):
|
||||
continue
|
||||
try:
|
||||
hits = fileless_audit.audit(
|
||||
path.stem, str(ROOT / "emulators"), str(ROOT / ".cache"), True
|
||||
)
|
||||
except Exception:
|
||||
continue
|
||||
if hits:
|
||||
flagged.append(path.stem)
|
||||
self.assertEqual(
|
||||
flagged, [],
|
||||
"these declare no files and their source asks for a directory, "
|
||||
"with nothing written down about why",
|
||||
)
|
||||
@@ -238,6 +238,33 @@ to the files inside the archive, so the exported DAT lists those entries
|
||||
without a container sha1. Anyone submitting the DAT upstream should mention
|
||||
this.
|
||||
|
||||
### fileless_audit.py
|
||||
|
||||
Names the profiles that declare no files whose source asks for a directory to
|
||||
read from.
|
||||
|
||||
```bash
|
||||
python scripts/fileless_audit.py craft dice lutro
|
||||
```
|
||||
|
||||
An empty `files:` list is the one assertion in the repository that ages
|
||||
unwatched: there is no file to go missing and no ref to drift, so nothing
|
||||
notices when a core that embedded everything grows a path. virtualjaguar
|
||||
carried "No external BIOS files are required or loaded by this core" while its
|
||||
source had grown eleven filenames read from the system directory.
|
||||
|
||||
The signal is the request itself, `RETRO_ENVIRONMENT_GET_SYSTEM_DIRECTORY` and
|
||||
its spellings, looked for in the sources the profile already cites. Finding one
|
||||
does not prove a file is loaded, which is why this reports rather than
|
||||
concludes.
|
||||
|
||||
Three answers settle a profile and it is not reported again: it declares files,
|
||||
it declares `data_directories` (dinothawr reads `system_dir/dinothawr/` and
|
||||
says so there), or it carries an `exclusion_note` saying what the directory is
|
||||
for. craft writes its world database in it, dice stores the answer in a
|
||||
variable no other file names, lutro hands it to the Lua game. What is left is
|
||||
the set nobody has read yet, which is the only set worth reading.
|
||||
|
||||
### mame_ref_audit.py
|
||||
|
||||
Checks that each MAME romset ref names the line declaring its own set, at the
|
||||
|
||||
Reference in new issue
Block a user