| """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 |
|
|
|
|
| |
| |
| |
| @dataclass |
| class Issue: |
| severity: str |
| 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'}" |
| ) |
|
|
|
|
| |
| |
| |
| class ProjectQA: |
| """Run static checks against a generated Unity project.""" |
|
|
| |
| 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) |
|
|
| |
| |
| |
| 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 |
|
|
| |
| |
| |
| 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), |
| )) |
| |
| 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), |
| )) |
|
|
|
|
| |
| |
| |
| 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()) |
|
|