#!/usr/bin/env python3 from __future__ import annotations import argparse import json import re import sys from dataclasses import asdict, dataclass from pathlib import Path from known_issues import classify_issue_ids, load_known_issue_ids SOUND_DATA_RE = re.compile(r'^SoundData\d+\s+[0-9.]+\s+"([^"]+)"', re.IGNORECASE) @dataclass class AudioCheck: file: str references: list[str] missing_references: list[str] def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser( description="Validate .mss audio script references against the runtime asset set." ) parser.add_argument( "--runtime-root", type=Path, required=True, help="Client runtime root containing assets/.", ) parser.add_argument( "--json", action="store_true", help="Emit JSON output.", ) parser.add_argument( "--known-issues", type=str, default=None, help="Optional known issues baseline JSON. Defaults to repo known_issues/runtime_known_issues.json if present.", ) parser.add_argument( "--strict-known-issues", action="store_true", help="Also fail when the known-issues baseline contains stale entries not observed anymore.", ) return parser.parse_args() def normalize_virtual_path(value: str) -> str: value = value.strip().strip('"').replace("\\", "/").lower() for prefix in ("d:/", "d:"): if value.startswith(prefix): value = value[len(prefix):] break return value.lstrip("/") def build_asset_index(runtime_assets: Path) -> set[str]: index: set[str] = set() for path in runtime_assets.rglob("*"): if not path.is_file(): continue try: rel = path.relative_to(runtime_assets) except ValueError: continue rel_posix = rel.as_posix().lower() index.add(rel_posix) if len(rel.parts) >= 2: index.add(Path(*rel.parts[1:]).as_posix().lower()) return index def parse_mss_references(path: Path) -> list[str]: references: list[str] = [] for raw_line in path.read_text(encoding="utf-8", errors="ignore").splitlines(): line = raw_line.strip() match = SOUND_DATA_RE.match(line) if match: references.append(match.group(1)) return references def validate_mss(path: Path, runtime_assets: Path, asset_index: set[str]) -> AudioCheck: rel = path.relative_to(runtime_assets).as_posix() missing: list[str] = [] references = parse_mss_references(path) for reference in references: normalized = normalize_virtual_path(reference) if normalized and normalized not in asset_index: missing.append(reference) return AudioCheck(file=rel, references=references, missing_references=sorted(set(missing))) def main() -> int: args = parse_args() runtime_root = args.runtime_root.resolve() runtime_assets = runtime_root / "assets" if not runtime_assets.is_dir(): raise SystemExit(f"assets dir not found: {runtime_assets}") asset_index = build_asset_index(runtime_assets) checks: list[AudioCheck] = [] failures: list[str] = [] issue_map: dict[str, str] = {} referenced_audio_files: set[str] = set() for path in sorted(runtime_assets.rglob("*.mss")): check = validate_mss(path, runtime_assets, asset_index) checks.append(check) for missing in check.missing_references: normalized = normalize_virtual_path(missing) issue_id = f"audio:reference:{check.file}:{normalized}" message = f"{check.file}: missing reference {missing}" failures.append(message) issue_map[issue_id] = message for reference in check.references: normalized = normalize_virtual_path(reference) if normalized: referenced_audio_files.add(normalized) observed_issue_ids = set(issue_map.keys()) known_path, known_issue_ids = load_known_issue_ids(__file__, "audio", args.known_issues) known_observed, unexpected_issue_ids, stale_known_issue_ids = classify_issue_ids(observed_issue_ids, known_issue_ids) result = { "ok": not unexpected_issue_ids and (not args.strict_known_issues or not stale_known_issue_ids), "checked_files": len(checks), "referenced_audio_files": len(referenced_audio_files), "failures": failures, "issue_ids": sorted(observed_issue_ids), "known_issue_ids": sorted(known_observed), "unexpected_issue_ids": sorted(unexpected_issue_ids), "stale_known_issue_ids": sorted(stale_known_issue_ids), "unexpected_failures": [issue_map[issue_id] for issue_id in sorted(unexpected_issue_ids)], "stale_known_failures": sorted(stale_known_issue_ids), "known_issues_path": str(known_path) if known_path else None, "checks": [asdict(check) for check in checks], } if args.json: print(json.dumps(result, indent=2)) else: print( f"ok={result['ok']} checked_files={result['checked_files']} " f"referenced_audio_files={result['referenced_audio_files']}" ) for failure in result["unexpected_failures"]: print(f"FAIL: {failure}") for issue_id in result["stale_known_issue_ids"]: print(f"STALE: {issue_id}") return 0 if result["ok"] else 1 if __name__ == "__main__": sys.exit(main())