"""Quality assurance for generated Unity projects. The :class:`ProjectQA` class runs a series of static checks against a generated project directory and reports a list of issues. The checks are intentionally conservative -- they look for problems that will *definitely* prevent the project from compiling in Unity, not style nitpicks. Run from code:: from unity_agent.qa import ProjectQA qa = ProjectQA("/path/to/GeneratedGame") report = qa.run() print(report.summary()) for issue in report.issues: print(issue) Or from the command line:: python -m unity_agent.qa /path/to/GeneratedGame """ from __future__ import annotations import argparse import re import sys from dataclasses import dataclass, field from pathlib import Path from typing import List # --------------------------------------------------------------------------- # # Data types # --------------------------------------------------------------------------- # @dataclass class Issue: severity: str # "error" | "warning" | "info" category: str message: str path: str = "" @dataclass class QAReport: project_root: str issues: List[Issue] = field(default_factory=list) @property def errors(self) -> List[Issue]: return [i for i in self.issues if i.severity == "error"] @property def warnings(self) -> List[Issue]: return [i for i in self.issues if i.severity == "warning"] @property def passed(self) -> bool: return len(self.errors) == 0 def summary(self) -> str: return ( f"QA report for {self.project_root}\n" f" errors: {len(self.errors)}\n" f" warnings: {len(self.warnings)}\n" f" total: {len(self.issues)}\n" f" result: {'PASS' if self.passed else 'FAIL'}" ) # --------------------------------------------------------------------------- # # QA runner # --------------------------------------------------------------------------- # class ProjectQA: """Run static checks against a generated Unity project.""" # Required project structure. REQUIRED_DIRS = ("Assets", "Assets/Scripts", "Packages", "ProjectSettings") REQUIRED_FILES = ( "Packages/manifest.json", "ProjectSettings/ProjectVersion.txt", "ProjectSettings/ProjectSettings.asset", ) def __init__(self, project_root: str | Path) -> None: self.root = Path(project_root) # ------------------------------------------------------------------ # # Public API # ------------------------------------------------------------------ # def run(self) -> QAReport: report = QAReport(project_root=str(self.root)) if not self.root.exists(): report.issues.append(Issue( "error", "structure", f"Project root does not exist: {self.root}", )) return report self._check_structure(report) self._check_required_files(report) self._check_csharp_files(report) self._check_scene_files(report) self._check_manifest(report) return report # ------------------------------------------------------------------ # # Individual checks # ------------------------------------------------------------------ # def _check_structure(self, report: QAReport) -> None: for d in self.REQUIRED_DIRS: p = self.root / d if not p.is_dir(): report.issues.append(Issue( "error", "structure", f"Missing required directory: {d}", str(p), )) def _check_required_files(self, report: QAReport) -> None: for f in self.REQUIRED_FILES: p = self.root / f if not p.is_file(): report.issues.append(Issue( "error", "structure", f"Missing required file: {f}", str(p), )) def _check_csharp_files(self, report: QAReport) -> None: scripts = list((self.root / "Assets/Scripts").rglob("*.cs")) if not scripts: report.issues.append(Issue( "warning", "scripts", "No .cs files found in Assets/Scripts.", )) return for script in scripts: try: src = script.read_text(encoding="utf-8") except Exception as e: report.issues.append(Issue( "error", "io", f"Could not read {script.name}: {e}", str(script), )) continue self._check_braces(report, script, src) self._check_namespace_and_class(report, script, src) self._check_meta(report, script) def _check_braces(self, report: QAReport, path: Path, src: str) -> None: opens = src.count("{") closes = src.count("}") if opens != closes: report.issues.append(Issue( "error", "syntax", f"Unbalanced braces: {opens} '{{' vs {closes} '}}' in {path.name}", str(path), )) def _check_namespace_and_class(self, report: QAReport, path: Path, src: str) -> None: if "namespace " not in src and "class " in src: report.issues.append(Issue( "warning", "syntax", f"{path.name} declares a class but no namespace.", str(path), )) # Every C# file should have at least one type declaration. if not re.search(r"\b(class|struct|interface|enum)\s+\w+", src): report.issues.append(Issue( "warning", "syntax", f"{path.name} does not declare any type.", str(path), )) def _check_meta(self, report: QAReport, path: Path) -> None: meta = path.with_suffix(path.suffix + ".meta") if not meta.exists(): report.issues.append(Issue( "warning", "meta", f"Missing .meta file for {path.name}", str(meta), )) def _check_scene_files(self, report: QAReport) -> None: scenes_dir = self.root / "Assets/Scenes" if not scenes_dir.is_dir(): return scenes = list(scenes_dir.glob("*.unity")) if not scenes: report.issues.append(Issue( "warning", "scene", "No .unity scene files found in Assets/Scenes.", )) return for scene in scenes: try: body = scene.read_text(encoding="utf-8") except Exception as e: report.issues.append(Issue( "error", "io", f"Could not read scene {scene.name}: {e}", str(scene), )) continue if not body.startswith("%YAML"): report.issues.append(Issue( "error", "scene", f"{scene.name} does not start with '%YAML' header.", str(scene), )) def _check_manifest(self, report: QAReport) -> None: manifest = self.root / "Packages/manifest.json" if not manifest.is_file(): return try: import json data = json.loads(manifest.read_text(encoding="utf-8")) except Exception as e: report.issues.append(Issue( "error", "manifest", f"Packages/manifest.json is not valid JSON: {e}", str(manifest), )) return deps = data.get("dependencies") if not isinstance(deps, dict): report.issues.append(Issue( "error", "manifest", "Packages/manifest.json has no 'dependencies' object.", str(manifest), )) # --------------------------------------------------------------------------- # # CLI # --------------------------------------------------------------------------- # def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(description="Run QA on a generated Unity project.") parser.add_argument("project_root", help="Path to the generated Unity project root.") args = parser.parse_args(argv) qa = ProjectQA(args.project_root) report = qa.run() print(report.summary()) for issue in report.issues: print(f" [{issue.severity.upper():7}] {issue.category:8} {issue.message} {issue.path}") return 0 if report.passed else 1 if __name__ == "__main__": sys.exit(main())