Files
Joe MattielloandClaude Opus 5.5 caca12ad71 feat(cheats): CI-generated community cheat packs on the project site (#1071, part 1) (#1073)
* docs(cheats): community cheat store design and plan (#1071)

Refs #1071

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* docs(cheats): map printf_notify to app toasts

Refs #1071

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* feat(cheat-import): BigPEmu API map; int_full settings omitted; printf_notify ignored

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(cheat-import): refuse side effects in message args; scope Unsupported.call to API gaps

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* feat(cheat-import): committed cartridge identity table (hashes only)

scan_cartridges.py walks a ROM directory (read-only) and writes a sorted
FNV1a64 -> CRC32 table with size, title and name; no ROM bytes or paths.
load_cartridges rejects duplicate fnv/crc, bad hex and format != 1. The
generated cartridges.json (152 rows) lets CI map community script hashes
to the CRC32 the app binds packs to without any ROMs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* feat(cheat-import): community cheat pack generator (#1071)

build_packs.py converts every pinned community C cheat source with convert.py
and writes the site/cheats tree: per-cartridge .cht packs (one per CRC32, sources
merged, identical cheats deduped, same-name cheats suffixed), manifest.json,
the unsupported.json worklist with per-call API usage, index.html and README.
Deterministic, bounded fetch of one codeload tarball per source, --source-dir for
local checkouts, --summary and --bump for the bump workflow. sources.json pins
jguffer/BigPEmu_Scripts at 2421150.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* data(cheats): generate community packs from jguffer/BigPEmu_Scripts@2421150

23 cartridge packs (72 cheats, 9 partial) plus the unsupported.json worklist
(55 entries), manifest and index, generated with build_packs.py from the pinned
codeload tarball; byte-identical to a local checkout run.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(cheat-import): worklist unmatched literals beside a hit; bound fetch errors and member count

- identity row for table-missing literals in a file that also has a hit
- http.client.HTTPException during fetch is a FetchError (exit 2)
- tar expansion cap counts a header block per member and caps member count
- Collector uses the api_map given to build()

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* data(cheats): regenerate for generator fix (unmatched-literal identity rows)

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* feat(site): publish community cheat packs under /cheats/

build_site.py copies the committed site/cheats tree verbatim; check_site.py
gains check_cheats (manifest format/sha256/bytes/CRC guard, unlisted .cht,
unsupported.json, index.html links, 256 KiB manifest and 1 MiB tree limits)
with unit tests. pages.yml watches site/cheats and tools/cheat-import. The
converter README now documents the author's permission, the credit, the
sources -> cartridges -> packs workflow and how to add a source; the
community_test harness and its fixtures are removed (the committed packs and
the regeneration diff are the conversion contract).

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* ci(cheats): regen-diff job, concurrency, weekly pin-bump PR

cheat-import.yml: add site/cheats/** to the path filters, a per-ref
concurrency group, drop the community_test.py fetch step (the files are
gone), and add a packs job that regenerates twice, diffs the runs and the
committed tree, flags unmapped BigPEmu calls, then runs the site-check tests
and build_site + check_site.

cheat-packs-bump.yml: weekly (Mon 06:23 UTC) poll of each pinned github
source; on movement, bump the pin, regenerate, validate, force-push the fixed
branch cheats/pin-bump and open or refresh a develop PR (validation failures
head the body instead of being dropped), then dispatch cheat-import.yml on the
branch because GITHUB_TOKEN PRs do not fire pull_request workflows.

test_committed_sources no longer pins the commit/date value: every bump PR
would otherwise fail the unit tests; only the shape is asserted.

Refs #1071

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* ci(cheats): gate bump workflow to the upstream repo, drop bot trailer

Only libretro/virtualjaguar-libretro opens bump PRs (the Provenance-Emu
fork runs crons too), with a 30 minute job timeout. The bot-authored bump
commit no longer carries a Claude co-author trailer.

Refs #1071

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* ci(cheats): pin pycparser, force LF, add job timeouts

Pin pycparser==3.11 (newest release that installs on the Python 3.10 CI leg;
2.22, 2.23, 3.0, 3.11 and 3.12 all regenerate byte-identical packs). Add a
root .gitattributes forcing LF for the generator and generated tree so pack
bytes cannot drift by checkout. Give both jobs a 20 minute timeout.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(cheats): dedupe pack header title, refuse control-character paths

The pack's first line printed the title twice when the cartridge name began
with the title. Print the name alone in that case. Fetch and local-dir reads
now fail closed on any path containing a control character, since names can
reach ::warning:: annotations and PR bodies.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* data(cheats): regenerate packs with deduplicated header title

Line 1 of each pack no longer repeats the title when the cartridge name
already starts with it. Regenerated from the pinned source commit; two runs
and a --source-dir run are byte-identical.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* ci(cheats): even indentation on a bump-workflow continuation line

editorconfig-checker wants multiples of 2 in workflow files.

Refs #1071

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-23 02:45:05 -04:00

442 lines
18 KiB
Python

#!/usr/bin/env python3
"""Post-build checks for the generated site. Stdlib only.
`build_site.py` fails loudly on bad *input*; this checks the *output*, so the
SEO and accessibility guarantees documented in docs/site-maintenance.md stay
true as pages are edited. Run in CI right after the build:
python3 scripts/build_site.py && python3 scripts/check_site.py _site
Checks, per page: exactly one <h1>, lang="en", non-empty descriptive alt on
every image, every local href/src resolves inside the output, no leftover
{{PLACEHOLDER}}, canonical == og:url == the sitemap <loc>, the full Open
Graph / Twitter card set present with an absolute image that exists
(twitter:image == og:image, width/height tags matching the PNG, under 1 MB),
rel=icon and rel=apple-touch-icon links present, JSON-LD
parses and is the expected @type. Site-wide: titles and descriptions are
distinct, sitemap.xml is well-formed and lists exactly the generated pages,
robots.txt carries an absolute Sitemap: line, no page contains an em or en
dash (house copy standard; use a hyphen), and no page references a
tracker, an external asset, or any <script> other than JSON-LD blocks and
deferred <script src> tags pointing at local files under assets/js/ that
exist in the output (inline and external scripts stay hard failures). The
site currently ships no script beyond JSON-LD; that allowance is the
contract for future pages, not something in use.
"""
import hashlib
import json
import re
import sys
import xml.etree.ElementTree as ET
from html.parser import HTMLParser
from pathlib import Path
from urllib.parse import urldefrag
sys.path.insert(0, str(Path(__file__).resolve().parent))
from build_site import ALL_PAGES, SITE_BASE, page_url, png_size # noqa: E402
# Substrings that must never appear: analytics, tag managers, CDNs, web fonts.
FORBIDDEN = ("googletagmanager", "google-analytics", "gtag(", "plausible.io",
"matomo", "cdn.jsdelivr", "unpkg.com", "cdnjs.cloudflare",
"fonts.googleapis", "fonts.gstatic")
# Em dash and en dash. See the copy-standard check in check_page().
DASH_RE = re.compile("[\u2014\u2013]")
REQUIRED_META = ("og:type", "og:title", "og:description", "og:image",
"og:image:width", "og:image:height", "og:image:alt",
"twitter:card", "twitter:title", "twitter:description",
"twitter:image")
# Share-card budget: what the networks document for summary_large_image.
SHARE_MAX_BYTES = 1000 * 1000
# Head <link>s every page must carry, by rel; each href is also covered by
# the local-link check, so a missing file fails there.
REQUIRED_ICON_RELS = ("icon", "apple-touch-icon")
FAILS = []
def check(cond, msg):
if not cond:
FAILS.append(msg)
return cond
class PageParser(HTMLParser):
def __init__(self):
HTMLParser.__init__(self, convert_charrefs=True)
self.h1 = 0
self.imgs = []
self.links = []
self.link_rels = set()
self.meta = {}
self.canonical = None
self.lang = None
self.title = ""
self.ld = []
self._in_title = False
self._in_ld = False
def handle_starttag(self, tag, attrs):
a = dict(attrs)
if tag == "html":
self.lang = a.get("lang")
elif tag == "h1":
self.h1 += 1
elif tag == "title":
self._in_title = True
elif tag == "img":
self.imgs.append((a.get("src"), a.get("alt")))
elif tag == "a" and a.get("href"):
self.links.append(a["href"])
elif tag == "link":
if a.get("rel") == "canonical":
self.canonical = a.get("href")
elif a.get("href"):
self.links.append(a["href"])
self.link_rels.add(a.get("rel"))
elif tag == "meta":
key = a.get("name") or a.get("property")
if key:
self.meta[key] = a.get("content", "")
elif tag == "script" and a.get("type") == "application/ld+json":
self._in_ld = True
def handle_endtag(self, tag):
if tag == "title":
self._in_title = False
elif tag == "script":
self._in_ld = False
def handle_data(self, data):
if self._in_title:
self.title += data
if self._in_ld:
self.ld.append(data)
def check_page(out, name):
path = out / name
if not check(path.is_file(), "missing output page %s" % name):
return None
text = path.read_text(encoding="utf-8")
left = re.search(r"\{\{[A-Z_]+\}\}", text)
check(not left, "%s: unresolved placeholder %s"
% (name, left.group(0) if left else ""))
pp = PageParser()
pp.feed(text)
url = page_url(name)
check(pp.lang == "en", "%s: <html lang> is %r, expected 'en'"
% (name, pp.lang))
check(pp.h1 == 1, "%s: %d <h1> elements, expected exactly 1"
% (name, pp.h1))
check(pp.title.strip(), "%s: empty <title>" % name)
check(pp.meta.get("description"), "%s: no meta description" % name)
for src, alt in pp.imgs:
check(alt and alt.strip(),
"%s: <img src=%r> has no alt text" % (name, src))
check(alt is None or len(alt) > 15,
"%s: <img src=%r> alt is not descriptive: %r" % (name, src, alt))
for href in pp.links + [s for s, _ in pp.imgs]:
if not href or re.match(r"^(https?:|mailto:|#|data:)", href):
continue
# The home page is canonical as the directory form, so nothing may
# link it as index.html -- that would point crawlers at the duplicate
# URL rel=canonical explicitly rejects.
check(urldefrag(href)[0] != "index.html",
"%s: links the home page as %r; use './' to match its canonical "
"URL" % (name, href))
target = urldefrag(href)[0]
if target:
check((out / target).exists(),
"%s: local link %r resolves to nothing" % (name, href))
check(pp.canonical == url, "%s: canonical %r != expected %r"
% (name, pp.canonical, url))
check(pp.meta.get("og:url") == url, "%s: og:url %r != canonical %r"
% (name, pp.meta.get("og:url"), url))
for key in REQUIRED_META:
check(pp.meta.get(key), "%s: missing %s" % (name, key))
check(pp.meta.get("twitter:card") == "summary_large_image",
"%s: twitter:card is %r" % (name, pp.meta.get("twitter:card")))
for key in ("og:image", "twitter:image"):
val = pp.meta.get(key, "")
check(val.startswith(SITE_BASE),
"%s: %s is not an absolute site URL: %r" % (name, key, val))
rel = val[len(SITE_BASE):] if val.startswith(SITE_BASE) else ""
check(rel and (out / rel).is_file(),
"%s: %s does not point at a published file: %r"
% (name, key, val))
check(pp.meta.get("twitter:image") == pp.meta.get("og:image"),
"%s: twitter:image %r != og:image %r" % (
name, pp.meta.get("twitter:image"), pp.meta.get("og:image")))
img = pp.meta.get("og:image", "")
img_path = out / img[len(SITE_BASE):] if img.startswith(SITE_BASE) else None
if img_path is not None and img_path.is_file():
w, h = png_size(img_path)
check((str(w), str(h)) == (pp.meta.get("og:image:width"),
pp.meta.get("og:image:height")),
"%s: og:image is %dx%d but the tags say %sx%s" % (
name, w, h, pp.meta.get("og:image:width"),
pp.meta.get("og:image:height")))
check(img_path.stat().st_size < SHARE_MAX_BYTES,
"%s: og:image %s is %d bytes, over the 1 MB card budget"
% (name, img, img_path.stat().st_size))
for rel in REQUIRED_ICON_RELS:
check(rel in pp.link_rels, "%s: no <link rel=%r>" % (name, rel))
check(pp.ld, "%s: no JSON-LD block" % name)
want = "SoftwareApplication" if name == "index.html" else "BreadcrumbList"
for blob in pp.ld:
try:
data = json.loads(blob)
except ValueError as exc:
FAILS.append("%s: JSON-LD does not parse: %s" % (name, exc))
continue
check(data.get("@context") == "https://schema.org",
"%s: JSON-LD @context %r" % (name, data.get("@context")))
check(data.get("@type") == want, "%s: JSON-LD @type %r, expected %r"
% (name, data.get("@type"), want))
if want == "SoftwareApplication":
check(re.match(r"^\d+\.\d+\.\d+", data.get("softwareVersion", "")),
"%s: softwareVersion %r" % (name, data.get("softwareVersion")))
check(data.get("isAccessibleForFree") is True,
"%s: isAccessibleForFree is not true" % name)
check("gpl-3.0" in data.get("license", ""),
"%s: license %r" % (name, data.get("license")))
# We have no ratings or reviews; publishing any would be a lie.
check("aggregateRating" not in data and "review" not in data,
"%s: JSON-LD carries rating/review data we do not have"
% name)
else:
items = data.get("itemListElement", [])
check(len(items) == 2,
"%s: breadcrumb has %d items, expected 2" % (name, len(items)))
check(items and items[-1].get("item") == url,
"%s: breadcrumb tail %r != %r"
% (name, items[-1].get("item") if items else None, url))
for bad in FORBIDDEN:
check(bad not in text, "%s: references %r -- no trackers or external "
"assets are allowed" % (name, bad))
# Copy standard: zero em/en dashes anywhere the reader can see, title
# attributes and <title> included. It is a house style (a hyphen is what
# the 1994 readme voice this site is written in would have used) and it
# is fragile, because dashes arrive from three directions: prose someone
# types into a fragment, the layout template, and the generated boot
# matrices -- build_site.normalize_dashes covers the third. Without a
# check the rule survives exactly until the next edit.
for m in DASH_RE.finditer(text):
s = max(0, m.start() - 45)
check(False, "%s: em/en dash in output, use a hyphen: ...%s..."
% (name, text[s:m.end() + 25].replace("\n", " ")))
break # one report per page is enough to send someone looking
# Scripts: JSON-LD blocks, or <script src> pointing at a LOCAL file
# under assets/js/ that exists in the output. The site currently ships
# NO script at all -- the period chrome is pure CSS -- so this loop
# normally has nothing to inspect; the rule stays as the contract for
# any future page that needs behaviour. External src and inline
# non-JSON-LD scripts remain hard failures: no CDNs, no trackers, no
# surprises (see docs/site-maintenance.md).
for m in re.finditer(r"<script([^>]*)>", text):
attrs = m.group(1)
if 'type="application/ld+json"' in attrs:
continue
srcm = re.search(r'src="([^"]*)"', attrs)
if not check(srcm is not None,
"%s: inline non-JSON-LD <script%s>" % (name, attrs)):
continue
src = srcm.group(1)
check(not re.match(r"^(https?:)?//", src),
"%s: external script src %r -- vendor it locally" % (name, src))
check(re.match(r"^assets/js/[\w.-]+\.js$", src),
"%s: script src %r is outside assets/js/" % (name, src))
check((out / src).is_file(),
"%s: script src %r does not exist in the output" % (name, src))
check(re.search(r"\bdefer\b", attrs) is not None,
"%s: script src %r must be deferred -- effects are progressive "
"enhancement, not render-blocking" % (name, src))
return pp
def check_sitemap(out):
path = out / "sitemap.xml"
if not check(path.is_file(), "missing sitemap.xml"):
return
try:
root = ET.fromstring(path.read_text(encoding="utf-8"))
except ET.ParseError as exc:
FAILS.append("sitemap.xml is not well-formed XML: %s" % exc)
return
ns = "{http://www.sitemaps.org/schemas/sitemap/0.9}"
check(root.tag == ns + "urlset", "sitemap root tag is %r" % root.tag)
locs = []
for u in root.findall(ns + "url"):
locs.append(u.findtext(ns + "loc"))
lastmod = u.findtext(ns + "lastmod")
check(lastmod and re.match(r"^\d{4}-\d{2}-\d{2}$", lastmod),
"sitemap lastmod %r is not YYYY-MM-DD" % lastmod)
check(set(locs) == set(page_url(p) for p in ALL_PAGES),
"sitemap URLs %r do not match the generated pages" % sorted(locs))
def check_robots(out):
path = out / "robots.txt"
if not check(path.is_file(), "missing robots.txt"):
return
body = path.read_text(encoding="utf-8")
check("User-agent: *" in body, "robots.txt has no 'User-agent: *' line")
check("Sitemap: " + SITE_BASE + "sitemap.xml" in body,
"robots.txt has no absolute 'Sitemap:' line")
CHEATS_MANIFEST_MAX = 256 * 1024
CHEATS_TREE_MAX = 1024 * 1024
CRC_RE = re.compile(r'^\s*vj_rom_crc32\s*=\s*"([0-9A-Fa-f]{8})"', re.M)
def check_cheats(out):
"""Validate the committed community cheat packs copied to <out>/cheats.
Returns the list of problems found; an absent cheats/ directory is not
an error.
"""
root = out / "cheats"
if not root.is_dir():
return []
problems = []
def bad(msg):
problems.append("cheats: " + msg)
manifest = None
mpath = root / "manifest.json"
if not mpath.is_file():
bad("manifest.json is missing")
else:
if mpath.stat().st_size > CHEATS_MANIFEST_MAX:
bad("manifest.json is %d bytes, over the %d byte limit"
% (mpath.stat().st_size, CHEATS_MANIFEST_MAX))
try:
manifest = json.loads(mpath.read_text(encoding="utf-8"))
except (ValueError, OSError) as e:
bad("manifest.json does not parse: %s" % e)
total = sum(f.stat().st_size for f in root.rglob("*") if f.is_file())
if total > CHEATS_TREE_MAX:
bad("cheats tree is %d bytes, over the %d byte limit"
% (total, CHEATS_TREE_MAX))
listed = set()
if isinstance(manifest, dict):
if manifest.get("format") != 1:
bad("manifest format is %r, expected 1" % (manifest.get("format"),))
packs = manifest.get("packs")
if not isinstance(packs, list):
bad("manifest has no packs list")
packs = []
for pk in packs:
rel = pk.get("file") if isinstance(pk, dict) else None
if not isinstance(rel, str) or not rel.startswith("packs/") \
or ".." in rel.split("/"):
bad("pack entry has a bad file path: %r" % (rel,))
continue
listed.add(rel)
f = root / rel
if not f.is_file():
bad("%s is listed but missing" % rel)
continue
data = f.read_bytes()
if hashlib.sha256(data).hexdigest() != pk.get("sha256"):
bad("%s sha256 does not match the manifest" % rel)
if len(data) != pk.get("bytes"):
bad("%s is %d bytes, manifest says %r"
% (rel, len(data), pk.get("bytes")))
m = CRC_RE.search(data.decode("utf-8", "replace"))
crc = m.group(1).upper() if m else None
if crc is None:
bad("%s has no vj_rom_crc32 guard" % rel)
elif crc != str(pk.get("crc32", "")).upper():
bad("%s vj_rom_crc32 %s != manifest crc32 %r"
% (rel, crc, pk.get("crc32")))
msgs = pk.get("messages", [])
if not (isinstance(msgs, list)
and all(isinstance(x, str) for x in msgs)):
bad("%s messages must be a list of strings" % rel)
pdir = root / "packs"
for f in sorted(pdir.glob("*.cht")) if pdir.is_dir() else []:
if "packs/" + f.name not in listed:
bad("packs/%s is not listed in the manifest" % f.name)
upath = root / "unsupported.json"
if not upath.is_file():
bad("unsupported.json is missing")
else:
try:
json.loads(upath.read_text(encoding="utf-8"))
except (ValueError, OSError) as e:
bad("unsupported.json does not parse: %s" % e)
ipath = root / "index.html"
if not ipath.is_file():
bad("index.html is missing")
else:
pp = PageParser()
pp.feed(ipath.read_text(encoding="utf-8"))
for href in pp.links:
if not href or re.match(r"^(https?:|mailto:|#|data:)", href):
continue
target = urldefrag(href)[0]
if target and not (root / target).resolve().exists():
bad("index.html: local link %r resolves to nothing" % href)
return problems
def main():
out = Path(sys.argv[1] if len(sys.argv) > 1 else "_site").resolve()
if not out.is_dir():
sys.stderr.write("no such build output directory: %s\n" % out)
return 2
parsed = {}
for name in ALL_PAGES:
pp = check_page(out, name)
if pp is not None:
parsed[name] = pp
titles = dict((n, p.title.strip()) for n, p in parsed.items())
descs = dict((n, p.meta.get("description")) for n, p in parsed.items())
check(len(set(titles.values())) == len(titles),
"duplicate <title> across pages: %r" % titles)
check(len(set(descs.values())) == len(descs),
"duplicate meta descriptions across pages")
for name, title in titles.items():
if name != "index.html":
check(title.endswith("Virtual Jaguar libretro"),
"%s: title %r should end with the site name" % (name, title))
check_sitemap(out)
check_robots(out)
for problem in check_cheats(out):
check(False, problem)
if FAILS:
sys.stderr.write("\nFAIL: check_site.py found %d problem(s):\n"
% len(FAILS))
for f in FAILS:
sys.stderr.write(" - %s\n" % f)
return 1
print("check_site.py: OK -- %d pages, sitemap and robots.txt verified"
% len(parsed))
return 0
if __name__ == "__main__":
sys.exit(main())