#!/usr/bin/env python3 """Validate web/assets/manifest.json — runs in Lane F's integration gate. Checks: JSON parses; every referenced skin exists locally; every fitting/furniture GLB exists locally (pipeline/_normalized) OR HEADs 200 on the depot; every thumb exists; footprints & heights are sane; every registry shop type has >=2 facades. python3 pipeline/validate_manifest.py # local + soft depot check python3 pipeline/validate_manifest.py --depot # require GLBs live on the depot (post-publish) Exit 0 = green, 1 = fail. Plain stdlib (no deps), so it runs anywhere. """ import json, os, sys, urllib.request, urllib.error ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) ASSETS = os.path.join(ROOT, "web", "assets") NORM = os.path.join(ROOT, "pipeline", "_normalized") MANIFEST = os.path.join(ASSETS, "manifest.json") REGISTRY_TYPES = ["record", "opshop", "toy", "book", "video", "pawn", "milkbar", "dept", "stall"] STRICT_DEPOT = "--depot" in sys.argv errors, warnings = [], [] def err(m): errors.append(m) def warn(m): warnings.append(m) def head_ok(url): # The depot contract documents GET only, not HEAD — probe with a 1-byte Range GET so we don't # download the whole GLB and don't depend on HEAD being supported by the CDN/cache. try: # custom UA: Cloudflare 403s the default Python-urllib agent on the public path req = urllib.request.Request(url, headers={"Range": "bytes=0-0", "User-Agent": "procity-validator/1.0"}) with urllib.request.urlopen(req, timeout=12) as r: return r.status in (200, 206) except urllib.error.HTTPError as e: if e.code == 416: # range not satisfiable but the file exists return True return False except Exception: return False def check_skin(file): p = os.path.join(ASSETS, file) if not os.path.isfile(p): err(f"skin missing: {file}") def check_glb(entry, depot): file = entry["file"] local = os.path.join(NORM, file) on_disk = os.path.isfile(local) live = head_ok(f"{depot}/a/{file}") if STRICT_DEPOT and not live: err(f"GLB not live on depot: {file}") elif not on_disk and not live: err(f"GLB missing (not local, not on depot): {file}") elif not live: warn(f"GLB not yet published to depot (local only): {file}") # thumb thumb = entry.get("thumb") if thumb and not os.path.isfile(os.path.join(ASSETS, thumb)): err(f"thumb missing: {thumb}") # footprint / height sanity fp = entry.get("footprint") if not (isinstance(fp, list) and len(fp) == 2 and all(0 < x < 12 for x in fp)): err(f"insane footprint for {file}: {fp}") h = entry.get("height") if not (isinstance(h, (int, float)) and 0 < h < 8): err(f"insane height for {file}: {h}") def main(): try: m = json.load(open(MANIFEST)) except Exception as e: print(f"FAIL: manifest does not parse: {e}") sys.exit(1) # GOD3_DEPOT overrides for the direct tailnet path (same env publish.py uses) depot = os.environ.get("GOD3_DEPOT", m.get("depot", "https://digalot.fyi/3god")).rstrip("/") sk = m.get("skins", {}) # facades + type coverage facade = sk.get("facade", {}) for k, v in facade.items(): check_skin(v["file"]) for t in REGISTRY_TYPES: n = sum(1 for v in facade.values() if t in v.get("types", [])) if n < 2: err(f"shop type '{t}' has only {n} facade(s) (need >=2)") # other skin groups for s in sk.get("sky", []): check_skin(s["file"]) for g in sk.get("ground", {}).values(): check_skin(g["file"]) for w in sk.get("wall", []): check_skin(w["file"]) interior = sk.get("interior", {}) for grp in ("floor", "surface"): for it in interior.get(grp, []): check_skin(it["file"]) for a in sk.get("awning", []): check_skin(a["file"]) # fittings + furniture GLBs n_glb = 0 manifest_files = set() for grp in ("fittings", "furniture"): for entry in m.get(grp, {}).values(): check_glb(entry, depot) manifest_files.add(entry["file"]) n_glb += 1 # audio pack (round-11): if the manifest names an audio file it must ship locally (both the # ogg primary and the m4a fallback). Silent-happy is a runtime rule, not a licence to dangle refs. n_audio = 0 def _chk_audio(e): for key in ("file", "fallback"): if key in e: check_skin(e[key]) for grp in m.get("audio", {}).values(): for v in grp.values(): if isinstance(v, dict) and "file" in v: _chk_audio(v); n_audio += 1 elif isinstance(v, dict): # footstep {surface:[variants]} for arr in v.values(): for e in arr: _chk_audio(e); n_audio += 1 # provenance-drift gate: every manifest depot GLB must be recorded in _published.json, so a # clobbered/stale provenance record (the R5 bug) fails QA loudly instead of hiding. recpath = os.path.join(ROOT, "pipeline", "_published.json") try: record = set(json.load(open(recpath))) except Exception as e: err(f"_published.json unreadable: {e}") record = set() for f in sorted(manifest_files): if f.startswith("procity_") and f not in record: err(f"manifest GLB not in _published.json (provenance drift): {f}") # pack-index QA (round-8 E2): a bad stock-pack bake fails the same gate as the manifest try: import validate_pack if validate_pack.main() != 0: err("stock-pack index validation failed (see pack-QA errors above)") except Exception as e: warn(f"pack-QA skipped: {e}") print(f"manifest v{m.get('version')} — facades {len(facade)}, " f"skins {sum(len(v) if isinstance(v, list) else (len(v) if isinstance(v, dict) else 0) for v in sk.values())} groups, " f"GLBs {n_glb}, audio {n_audio}") for w in warnings: print(f" WARN {w}") if errors: for e in errors: print(f" ERR {e}") print(f"\nFAIL — {len(errors)} error(s), {len(warnings)} warning(s)") sys.exit(1) print(f"\nOK — 0 errors, {len(warnings)} warning(s)") sys.exit(0) if __name__ == "__main__": main()