diff --git a/scripts/download.py b/scripts/download.py index 1678ae1e..f9b603ff 100644 --- a/scripts/download.py +++ b/scripts/download.py @@ -298,8 +298,9 @@ def show_info(platform: str, release: dict) -> None: """Show pack information for a platform.""" pack = find_pack(release, platform) if not pack: - print(f"Platform '{platform}' not found in release") - return + print(f"Platform '{platform}' not found in release.") + print("Available:", ", ".join(list_platforms(release))) + sys.exit(1) print(f" Platform: {pack.platform}") print(f" File: {pack.name}") diff --git a/scripts/fileless_audit.py b/scripts/fileless_audit.py index b6d050f6..cc3f01e8 100644 --- a/scripts/fileless_audit.py +++ b/scripts/fileless_audit.py @@ -20,10 +20,12 @@ from __future__ import annotations import argparse import os import sys +from pathlib import Path sys.path.insert(0, os.path.dirname(__file__)) import upstream +from common import suggest_names from profile_sync import collect_citations, select_views, split_source_ref from safeparse import yaml_load @@ -84,20 +86,52 @@ def audit( return hits +def _settled_before_reading(document: dict) -> bool: + """A profile answered without its sources: files, an exclusion note, or + a declared data directory (see audit).""" + return bool( + document.get("files") + or str(document.get("exclusion_note") or "").strip() + or document.get("data_directories") + ) + + def main() -> None: parser = argparse.ArgumentParser(allow_abbrev=False, description=__doc__.splitlines()[0]) - parser.add_argument("emulators", nargs="+") + parser.add_argument("emulators", nargs="*", help="profiles to read") + parser.add_argument( + "--all", action="store_true", + help="every profile that declares no files and has no answer yet", + ) 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() + if args.all == bool(args.emulators): + parser.error("name profiles or pass --all, not both") - flagged = 0 - for name in args.emulators: + known = sorted(path.stem for path in Path(args.emulators_dir).glob("*.yml")) + unknown = [name for name in args.emulators if name not in known] + if unknown: + raise SystemExit( + "ERROR: unknown profile " + ", ".join(unknown) + ". " + + suggest_names(unknown, known, "verify.py --list-emulators") + ) + names = args.emulators + if args.all: + names = [] + for name in known: + with open(Path(args.emulators_dir) / f"{name}.yml", encoding="utf-8") as handle: + if not _settled_before_reading(yaml_load(handle) or {}): + names.append(name) + + flagged = unreachable = 0 + for name in names: 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) + unreachable += 1 continue if hits is None or not hits: continue @@ -105,7 +139,13 @@ def main() -> None: 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) + print( + f"{len(names)} profile(s) read, {flagged} to read again" + + (f", {unreachable} unreachable" if unreachable else ""), + file=sys.stderr, + ) + # An unreachable source is not a clean one. + raise SystemExit(1 if flagged or unreachable else 0) if __name__ == "__main__": diff --git a/tests/test_audit_regressions.py b/tests/test_audit_regressions.py index cd1d6309..0e3a6f4c 100644 --- a/tests/test_audit_regressions.py +++ b/tests/test_audit_regressions.py @@ -4300,5 +4300,32 @@ class WikiProseHasNoTornSentence(unittest.TestCase): self.fail(f"{page.name}:{number} ends mid-sentence: {line.strip()!r}") + +class EveryOptionIsDocumented(unittest.TestCase): + """profile_sync's --system, --stale-before and --ref, auto_fetch + --create-issues, download.py --info and forty more were in no page: a + maintainer ran --all and spent the API quota that --system would have + saved. --help lists a flag; only a page says when to reach for it.""" + + def test_every_long_option_of_a_tracked_script_is_in_the_docs(self): + tracked = subprocess.run( + ["git", "ls-files", "scripts"], capture_output=True, text=True, + cwd=str(ROOT), check=True, + ).stdout.split() + docs = "\n".join( + path.read_text(encoding="utf-8") + for path in [*sorted((ROOT / "wiki").glob("*.md")), + ROOT / "README.md", ROOT / "CONTRIBUTING.md"] + ) + option = re.compile(r'add_argument\(\s*(?:"-\w",\s*)?"(--[\w-]+)"') + undocumented = sorted( + f"{name}: {flag}" + for name in tracked if name.endswith(".py") + for flag in set(option.findall((ROOT / name).read_text(encoding="utf-8"))) + if not re.search(re.escape(flag) + r"(?![\w-])", docs) + ) + self.assertEqual(undocumented, []) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_download.py b/tests/test_download.py index dca383d1..3a6fbbad 100644 --- a/tests/test_download.py +++ b/tests/test_download.py @@ -9,10 +9,12 @@ checksum of each layout and the staging location. """ from __future__ import annotations +import contextlib import functools import hashlib import http.server import importlib.util +import io import json import os import shutil @@ -94,6 +96,15 @@ def _release(names, base="https://example.invalid", size=10): class TestPackGrouping(unittest.TestCase): + def test_info_on_an_unknown_platform_fails(self): + """--info printed "not found" and exited 0.""" + release = _release(["EmuDeck_2.3.8_BIOS_Pack.zip"]) + with contextlib.redirect_stdout(io.StringIO()) as out: + with self.assertRaises(SystemExit) as caught: + download.show_info("emudek", release) + self.assertEqual(caught.exception.code, 1) + self.assertIn("EmuDeck", out.getvalue()) + def test_whole_pack_has_one_part(self): packs = download.group_packs(_release(["EmuDeck_2.3.8_BIOS_Pack.zip"])) self.assertEqual([p.name for p in packs], ["EmuDeck_2.3.8_BIOS_Pack.zip"]) diff --git a/tests/test_fileless_audit.py b/tests/test_fileless_audit.py index 4738b2b3..d8985199 100644 --- a/tests/test_fileless_audit.py +++ b/tests/test_fileless_audit.py @@ -99,6 +99,53 @@ class TestFilelessAudit(unittest.TestCase): self.assertEqual(len(self._run()), 1) +class TestCommandLine(unittest.TestCase): + """Named profiles were the only mode, so the documented example printed + nothing; a misspelt name ended in a traceback and an unreachable source + left the run green.""" + + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + Path(self.tmp.name, "open.yml").write_text(BASE, encoding="utf-8") + Path(self.tmp.name, "done.yml").write_text( + "emulator: D\nfiles:\n - name: a.bin\n", encoding="utf-8" + ) + + def _main(self, *argv, audit=None): + read = [] + + def fake(name, *args): + read.append(name) + if audit: + return audit(name) + return [] + + with mock.patch.object(sys, "argv", ["fileless_audit.py", *argv, + "--emulators-dir", self.tmp.name]), \ + mock.patch.object(fileless_audit, "audit", side_effect=fake), \ + mock.patch("sys.stderr"), mock.patch("sys.stdout"): + with self.assertRaises(SystemExit) as caught: + fileless_audit.main() + return caught.exception.code, read + + def test_all_reads_only_the_profiles_nobody_answered(self): + code, read = self._main("--all") + self.assertEqual((code, read), (0, ["open"])) + + def test_a_misspelt_profile_is_named(self): + code, read = self._main("opne") + self.assertIn("open", str(code)) + self.assertEqual(read, []) + + def test_an_unreachable_source_fails_the_run(self): + def unreachable(name): + raise fileless_audit.upstream.UpstreamError("HTTP 403") + + code, _ = self._main("open", audit=unreachable) + self.assertEqual(code, 1) + + class TestCorpusIsAnswered(unittest.TestCase): """Every fileless profile is covered, answered, or flagged. diff --git a/wiki/tools.md b/wiki/tools.md index 76c4dd32..74c00b2a 100644 --- a/wiki/tools.md +++ b/wiki/tools.md @@ -279,9 +279,14 @@ 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 +python scripts/fileless_audit.py --all # every profile not yet answered +python scripts/fileless_audit.py virtualjaguar b2 # the profiles named ``` +It exits 1 when a profile is worth a reading or a source could not be reached +(`--offline` reads the cache only, `--cache-dir` moves it), and ends on a +count of what it read. + 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 @@ -343,6 +348,21 @@ python scripts/profile_sync.py --all --changed-only --json python scripts/profile_sync.py --emulator vice --fetch-plan ``` +Selection narrows `--all` before any request is spent: `--system sega-saturn`, +`--type standalone`, `--stale-before 2026-06-01` (profiled before that date) +and `--limit 20`. Output takes `--markdown` (a report written under +`--report-dir`, `reports/` by default), and `--full-diff` takes `--ref PATH` +to show one cited file and `--context N` lines around each hunk. `--strict` +exits 1 when a profile has refs to review; CI runs it on every profile a change +adds. `--offline` answers from `--cache-dir` alone. + +```bash +python scripts/profile_sync.py --all --system sega-saturn --changed-only +python scripts/profile_sync.py --all --stale-before 2026-06-01 --limit 20 --triage +python scripts/profile_sync.py --emulator vice --full-diff --ref vice/src/c64/c64rom.h --context 6 +python scripts/profile_sync.py --emulator vice --strict +``` + Per part of a ref: `ANCHORED` (same content, same lines), `SHIFTED` (same content, moved), `RENAMED` (the source file moved), `CHANGED` (content edited), `AMBIGUOUS` (several equally good candidates), `GONE` (nothing @@ -568,6 +588,36 @@ python scripts/refresh_stale.py --only platforms,targets --jobs 6 | `refresh_stale.py` | Run the refresher behind every stale row of that report (see above) | | `migrate.py` | Migrate flat bios structure to Manufacturer/Console/ hierarchy | +Further options, by script: + +| Script | Options | +|--------|---------| +| `auto_fetch.py` | `--create-issues` prints a GitHub issue body per file still missing; it posts nothing | +| `check_buildbot_system.py` | `--update` refreshes the entries found stale, `--json`, `--registry` | +| `check_freshness.py` | `--cache-dir`, `--native-cache-dir` (originals of the native export) and `--truth-dir` move the caches it reads | +| `check_release.py` | `--tag`, `--sums dist/SHA256SUMS.txt`, `--record` (default `release.json`), `--json` | +| `check_release_assets.py` | `--gitignore` (the asset registry), `--db`, `--notes FILE`, `--json` | +| `diff_truth.py` | `--format markdown`, `--truth-dir`, `--target` | +| `download.py` | `--list`, `--info ` shows a pack's file name, size and parts without downloading | +| `export_native.py` | `--truth-dir`, `--upstream-dir` (where the platforms' own files are cached) | +| `find_pinned_revision.py` | `--repo URL --path FILE --md5 HASH`, `--write PATH`, `--cache-dir` | +| `generate_db.py` | `--provenance-dir` (dump-catalog snapshots joined into the database) | +| `generate_site.py` | `--docs-dir` (output, `docs/` by default) | +| `mame_ref_audit.py` | `--write` points each stale ref at the line declaring its set, `--offline`, `--cache-dir` | +| `migrate.py` | `--dry-run`, `--include-branches` also reads BIOS files from every remote branch | +| `provenance_report.py` | `--provenance-dir`, `--write-derivable PATH` (with `--derive`; the pipeline writes `derivable.json`) | +| `refresh_stale.py` | `--freshness-arg FLAG` passes a flag through to `check_freshness.py`, repeatable | +| `release_record.py` | `--install-dir` (manifests the archives are held against), `--output` | +| `restore_large_files.py` | `--cache` (downloaded assets), `--gitignore`, `--db` | +| `romset_recipes.py` | `--dat-recipes PATH`, a snapshot or a directory of them (default `recipes/`) | +| `split_pack.py` | `--max-size 1900M`, the ceiling per part (K, M, G; default 2G) | +| `torrentzip.py` | `--check ZIP` reports conformance, `--rebuild ZIP -o OUT` normalizes an archive | +| `validate_pr.py` | `--changed` takes the BIOS files the branch changes, `--markdown`, `--json` | +| `validate_site.py` | `--site-dir` (rendered site), `--config` (mkdocs.yml), `--max-errors N` | +| `scraper/coreinfo_scraper.py` | `--compare-db database.json` lists the `.info` firmware the database lacks | +| `scraper/mame_hash_scraper.py` | `--release mameNNNN --profile FILE --source-commit SHA --core NAME --built-by PLATFORM` writes the profile of a MAME build a platform compiles | +| `scraper/romset_dat_importer.py` | `--cache-dir` (downloaded listxml and DATs) | + ## Installation tools Cross-platform BIOS installer for end users: