ryzerrr's picture
Upload folder using huggingface_hub
40c0886 verified
Raw
History Blame Contribute Delete
8.75 kB
"""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())