diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index ad77e20..0022f25 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -328,8 +328,6 @@ jobs: "com.forkhorizon.nexus.unity", "com.unity.inputsystem", "com.unity.nuget.newtonsoft-json", - "com.unity.project-auditor", - "com.unity.project-auditor-rules", "com.unity.test-framework", ] missing = [package for package in required if package not in dependencies] @@ -337,7 +335,16 @@ jobs: print(f"::error::Unity package smoke did not resolve dependencies: {', '.join(missing)}") sys.exit(1) - print("Unity package smoke resolved Nexus Unity and required dependencies.") + forbidden = [ + "com.unity.project-auditor", + "com.unity.project-auditor-rules", + ] + present_forbidden = [package for package in forbidden if package in dependencies] + if present_forbidden: + print(f"::error::Unity package smoke resolved forbidden Project Auditor dependencies: {', '.join(present_forbidden)}") + sys.exit(1) + + print("Unity package smoke resolved Nexus Unity and required dependencies without Project Auditor packages.") PY - name: Run package Editor tests diff --git a/API_REFERENCE.MD b/API_REFERENCE.MD index 029aa56..26600e9 100644 --- a/API_REFERENCE.MD +++ b/API_REFERENCE.MD @@ -1,6 +1,6 @@ # Nexus Unity API Reference -Version: `1.4.1` +Version: `1.4.2` Nexus Unity exposes two supported public API surfaces: diff --git a/CHANGELOG.md b/CHANGELOG.md index 0dfcf23..49264f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,9 +4,20 @@ All notable public changes to Nexus Unity are documented here. ## [Unreleased] +## [1.4.2] - 2026-06-13 + +### Added +- Added Python unit coverage for MCP bridge schema shape and type expectations. + ### Changed +- Expanded the `unity_hierarchy_manager` MCP schema into per-action shapes with clearer required parameters and aliases. +- Refined Python MCP bridge routing with shared JSON-RPC payload types and more structured handler code. - Added a repository mailmap entry for `air17` so local Git contributor reports resolve historical `air17@github.com` commits to the GitHub account's canonical no-reply identity. +### Fixed +- Removed direct Unity Project Auditor package dependencies and made Unity Project Auditor execution optional, so clean installs avoid duplicate immutable `.meta` GUID warnings and `Could not find any registered modules` Console spam. +- Fixed bridge-side compilation wait timeout handling after asset refresh and tightened test-result polling payload handling. + ## [1.4.1] - 2026-06-12 ### Fixed diff --git a/DOCUMENTATION.MD b/DOCUMENTATION.MD index 109b119..e10207e 100644 --- a/DOCUMENTATION.MD +++ b/DOCUMENTATION.MD @@ -1,6 +1,6 @@ # Nexus Unity Technical Documentation -Version: `1.4.1` +Version: `1.4.2` Nexus Unity is a Unity Editor automation package with two public interfaces: @@ -173,7 +173,7 @@ The same job also runs `NexusQualityGate --checklist-ai required`. That mode rea The reviewer sends `NEXUS_DOC_AI_KEEP_ALIVE` to Ollama on each request, defaults it to `30s`, and unloads the model at the end of the review. The workflow also has an `always()` cleanup step that unloads the same model even when validation fails. For pull requests, the job executes the quality tool from the trusted base branch and treats the pull request checkout as input data. While bootstrapping quality-gate changes, it falls back to the candidate tool when the trusted base branch does not contain `NexusQualityGate` yet or has not learned the `--checklist-ai` option. -The Unity smoke job creates a temporary project under `$RUNNER_TEMP`, installs the candidate package through `file:$GITHUB_WORKSPACE`, imports it with the local Unity editor, scans the Unity log for C# compiler errors and orphan/immutable `.meta` warnings, and verifies that package dependencies resolved in `packages-lock.json`. +The Unity smoke job creates a temporary project under `$RUNNER_TEMP`, installs the candidate package through `file:$GITHUB_WORKSPACE`, imports it with the local Unity editor, scans the Unity log for C# compiler errors and orphan/immutable `.meta` warnings, and verifies that required package dependencies resolved in `packages-lock.json` without Unity Project Auditor packages. Full local integration validation is explicit: @@ -202,23 +202,24 @@ Before release, maintainers should run a public API stress audit that compares r - Public repo: `https://github.com/ForkHorizon/NexusUnity.git`. - Package id: `com.forkhorizon.nexus.unity`. -- Public release version: `1.4.1`. +- Public release version: `1.4.2`. - License: `GPL-3.0-only`. - Required release docs: `SECURITY.md`, `CONTRIBUTING.md`, and `RELEASE.md`. - Repository funding metadata lives in `.github/FUNDING.yml` and configures the GitHub Sponsor button for `Daliys`. - Do not ship generated caches, local agent folders, `.jules/`, `.DS_Store`, Python bytecode, or native bridge binaries without corresponding source and build instructions. - Keep contributor tooling and package-internal tests under Unity-ignored folders such as `tools~/` and `Tests~/` without `.meta` files; Unity will not import those folders, and root `~.meta` entries break immutable PackageCache installs. +- Do not declare Unity Project Auditor packages as Nexus dependencies. Nexus audit support uses reflection when a host project explicitly installs compatible Project Auditor rules; direct dependencies can add duplicate immutable `.meta` GUID warnings or no-module Console spam during clean installs. ## Development Versioning Policy Nexus Unity follows semantic versioning for public releases, but the development branch should not bump the package version for every merged fix. Keep `package.json` and visible docs at the latest shipped public version until a release is being prepared. -Unity Package Manager requires `MAJOR.MINOR.PATCH` values in `package.json`, and GitHub release tags and titles use the same semantic version. Use forms like `1.4.1` for the package version, `v1.4.1` for tags, and `1.4.1` for release titles. +Unity Package Manager requires `MAJOR.MINOR.PATCH` values in `package.json`, and GitHub release tags and titles use the same semantic version. Use forms like `1.4.2` for the package version, `v1.4.2` for tags, and `1.4.2` for release titles. During normal development: - Add all user-visible API, behavior, docs, and validation changes to `[Unreleased]` in `CHANGELOG.md`. -- Do not change `package.json` from `1.4.1` unless the change is part of a release-preparation commit. +- Do not change `package.json` from `1.4.2` unless the change is part of a release-preparation commit. - Prefer compatibility fixes over breaking changes; if a breaking change is unavoidable, document the migration path before release. During release preparation: @@ -226,4 +227,4 @@ During release preparation: - Choose the next semantic version based on accumulated changes. - Move `[Unreleased]` entries into the new dated release section. - Update `package.json`, README badges/install examples, `DOCUMENTATION.MD`, and `API_REFERENCE.MD`. -- Tag the release with the matching semantic GitHub version, for example `v1.4.1` for package version `1.4.1`. +- Tag the release with the matching semantic GitHub version, for example `v1.4.2` for package version `1.4.2`. diff --git a/Editor/ProjectAuditorFinal.cs b/Editor/ProjectAuditorFinal.cs index f9b30c5..486c6c9 100644 --- a/Editor/ProjectAuditorFinal.cs +++ b/Editor/ProjectAuditorFinal.cs @@ -17,6 +17,8 @@ namespace UnityMCP.Editor /// public static class ProjectAuditorWrapper { + private const string ProjectAuditorRulesPackageName = "com.unity.project-auditor-rules"; + private static readonly List _componentCache = new List(); private static readonly List _rendererCache = new List(); private static readonly List _materialCache = new List(); @@ -45,136 +47,8 @@ public static string RunAudit(bool silent) try { - var assembly = AppDomain.CurrentDomain.GetAssemblies().FirstOrDefault(a => a.GetName().Name.Contains("ProjectAuditor")); - if (assembly != null) - { - var auditorType = assembly.GetType("Unity.ProjectAuditor.Editor.ProjectAuditor"); - var paramsType = assembly.GetType("Unity.ProjectAuditor.Editor.AnalysisParams"); - - if (auditorType != null && paramsType != null) - { - var auditor = Activator.CreateInstance(auditorType); - var analysisParams = Activator.CreateInstance(paramsType, new object[] { true }); - - var auditMethod = auditorType.GetMethods().FirstOrDefault(m => m.Name == "Audit" && m.GetParameters().Length == 2); - if (auditMethod != null) - { - var report = auditMethod.Invoke(auditor, new object[] { analysisParams, null }); - if (report != null) - { - // Determine if we are in the Nexus sandbox or a user project - bool isSandbox = System.IO.Directory.Exists("Assets/NexusUnity"); - string targetPath = isSandbox ? "Assets/NexusUnity" : "Assets"; - NexusEditorLog.Log(NexusLogCategory.Audit, $"[Nexus Audit] START - isSandbox: {isSandbox}, targetPath: {targetPath}"); - - var getAllIssuesMethod = report.GetType().GetMethod("GetAllIssues"); - var allIssues = (System.Collections.IEnumerable)getAllIssuesMethod.Invoke(report, null); - - var codeIssues = new JArray(); - if (allIssues != null) - { - // Sandbox-specific noise reduction filters - string[] sandboxIgnorePatterns = { - "Newtonsoft.Json", "allocation", "usage", "System.Reflection", - "System.Linq", "System.String.Concat", "ref type", "Closure", - "UnityEngine.Object.name", "Debug.Log", "Implicit", "GetEntityId" - }; - - foreach (var issue in allIssues) - { - var t = issue.GetType(); - string category = t.GetProperty("Category")?.GetValue(issue)?.ToString() ?? "Unknown"; - string description = t.GetProperty("Description")?.GetValue(issue)?.ToString() ?? "No description"; - - var location = t.GetProperty("Location")?.GetValue(issue); - string filePath = ""; - - if (location != null) - { - var locType = location.GetType(); - filePath = locType.GetProperty("Path")?.GetValue(location)?.ToString() ?? ""; - } - - // 1. Path Filtering - if (category.Contains("Code")) - { - if (string.IsNullOrEmpty(filePath) || !filePath.StartsWith(targetPath)) - { - continue; - } - - // 2. Sandbox Noise Reduction (Only active when developing Nexus) - if (isSandbox) - { - bool shouldIgnore = false; - foreach (var pattern in sandboxIgnorePatterns) - { - if (description.IndexOf(pattern, StringComparison.OrdinalIgnoreCase) >= 0) { shouldIgnore = true; break; } - } - if (shouldIgnore) continue; - } - } - else - { - // Ignore general project noise (outdated packages, etc.) in the sandbox - if (isSandbox) - { - if (string.IsNullOrEmpty(filePath) || (!filePath.Contains("com.forkhorizon.nexus.unity") && !filePath.Contains("Assets/NexusUnity"))) - { - continue; - } - } - } - - var i = new JObject(); - i["category"] = category; - i["description"] = description; - i["file"] = filePath; - - if (location != null) - { - var locType = location.GetType(); - i["line"] = locType.GetProperty("Line")?.GetValue(location)?.ToString(); - } - - codeIssues.Add(i); - } - } - result["code_issues"] = codeIssues; - result["num_total_issues"] = codeIssues.Count; - NexusEditorLog.Log(NexusLogCategory.Audit, $"[Nexus Audit] END - Total Filtered: {codeIssues.Count}", true); - } - } - } - } - - // --- Custom Nexus Style Audit --- - string customTargetPath = System.IO.Directory.Exists("Assets/NexusUnity") ? "Assets/NexusUnity" : "Assets"; - var codeIssuesList = result["code_issues"] as JArray ?? new JArray(); - NexusEditorLog.Log(NexusLogCategory.Audit, $"[Nexus Style Audit] Scanning path: {customTargetPath}, current issues: {codeIssuesList.Count}"); - - string[] files = System.IO.Directory.GetFiles(customTargetPath, "*.cs", System.IO.SearchOption.AllDirectories); - int styleIssuesAdded = 0; - foreach (var file in files) - { - string relativePath = file.Replace(System.IO.Directory.GetCurrentDirectory() + "/", "").Replace("\\", "/"); - if (relativePath.Contains("Assets/")) relativePath = relativePath.Substring(relativePath.IndexOf("Assets/")); - - string[] lines = System.IO.File.ReadAllLines(file); - if (lines.Length > 300) - { - codeIssuesList.Add(new JObject { - ["category"] = "Style", - ["description"] = $"File exceeds 300 lines limit (Current: {lines.Length} lines).", - ["file"] = relativePath, - ["line"] = "1" - }); - styleIssuesAdded++; - } - } - result["code_issues"] = codeIssuesList; - result["num_total_issues"] = codeIssuesList.Count; - NexusEditorLog.Log(NexusLogCategory.Audit, $"[Nexus Style Audit] Added {styleIssuesAdded} style issues. Total: {codeIssuesList.Count}", true); + RunUnityProjectAuditor(result, silent); + RunNexusStyleAudit(result); } catch (Exception e) { @@ -189,6 +63,159 @@ public static string RunAudit(bool silent) return result.ToString(); } + private static void RunUnityProjectAuditor(JObject result, bool silent) + { + var assembly = AppDomain.CurrentDomain.GetAssemblies().FirstOrDefault(a => a.GetName().Name.Contains("ProjectAuditor")); + if (assembly == null || !ShouldRunUnityProjectAuditor(silent)) + { + return; + } + + var auditorType = assembly.GetType("Unity.ProjectAuditor.Editor.ProjectAuditor"); + var paramsType = assembly.GetType("Unity.ProjectAuditor.Editor.AnalysisParams"); + if (auditorType == null || paramsType == null) + { + return; + } + + var auditMethod = auditorType.GetMethods().FirstOrDefault(m => m.Name == "Audit" && m.GetParameters().Length == 2); + if (auditMethod == null) + { + return; + } + + var auditor = Activator.CreateInstance(auditorType); + var analysisParams = Activator.CreateInstance(paramsType, new object[] { true }); + var report = auditMethod.Invoke(auditor, new object[] { analysisParams, null }); + if (report == null) + { + return; + } + + bool isSandbox = Directory.Exists("Assets/NexusUnity"); + string targetPath = isSandbox ? "Assets/NexusUnity" : "Assets"; + NexusEditorLog.Log(NexusLogCategory.Audit, $"[Nexus Audit] START - isSandbox: {isSandbox}, targetPath: {targetPath}"); + + var getAllIssuesMethod = report.GetType().GetMethod("GetAllIssues"); + var allIssues = (System.Collections.IEnumerable)getAllIssuesMethod.Invoke(report, null); + var codeIssues = CollectProjectAuditorIssues(allIssues, isSandbox, targetPath); + result["code_issues"] = codeIssues; + result["num_total_issues"] = codeIssues.Count; + NexusEditorLog.Log(NexusLogCategory.Audit, $"[Nexus Audit] END - Total Filtered: {codeIssues.Count}", true); + } + + private static bool ShouldRunUnityProjectAuditor(bool silent) + { + bool hasRulesPackage = IsPackageResolved(ProjectAuditorRulesPackageName); + if (!hasRulesPackage && !silent) + { + NexusEditorLog.Log(NexusLogCategory.Audit, "[Nexus Audit] Skipping Unity Project Auditor because no Project Auditor rules package is resolved."); + } + + return hasRulesPackage; + } + + private static bool IsPackageResolved(string packageName) + { + try + { + return UnityEditor.PackageManager.PackageInfo.FindForPackageName(packageName) != null; + } + catch + { + return false; + } + } + + private static JArray CollectProjectAuditorIssues(System.Collections.IEnumerable allIssues, bool isSandbox, string targetPath) + { + var codeIssues = new JArray(); + if (allIssues == null) + { + return codeIssues; + } + + string[] sandboxIgnorePatterns = { "Newtonsoft.Json", "allocation", "usage", "System.Reflection", "System.Linq", "System.String.Concat", "ref type", "Closure", "UnityEngine.Object.name", "Debug.Log", "Implicit", "GetEntityId" }; + + foreach (var issue in allIssues) + { + AddProjectAuditorIssue(codeIssues, issue, isSandbox, targetPath, sandboxIgnorePatterns); + } + + return codeIssues; + } + + private static void AddProjectAuditorIssue(JArray codeIssues, object issue, bool isSandbox, string targetPath, string[] sandboxIgnorePatterns) + { + var t = issue.GetType(); + string category = t.GetProperty("Category")?.GetValue(issue)?.ToString() ?? "Unknown"; + string description = t.GetProperty("Description")?.GetValue(issue)?.ToString() ?? "No description"; + var location = t.GetProperty("Location")?.GetValue(issue); + string filePath = GetAuditorIssuePath(location); + + if (ShouldSkipAuditorIssue(category, description, filePath, isSandbox, targetPath, sandboxIgnorePatterns)) + { + return; + } + + var i = new JObject { ["category"] = category, ["description"] = description, ["file"] = filePath }; + if (location != null) + { + var locType = location.GetType(); + i["line"] = locType.GetProperty("Line")?.GetValue(location)?.ToString(); + } + + codeIssues.Add(i); + } + + private static string GetAuditorIssuePath(object location) + { + return location?.GetType().GetProperty("Path")?.GetValue(location)?.ToString() ?? ""; + } + + private static bool ShouldSkipAuditorIssue(string category, string description, string filePath, bool isSandbox, string targetPath, string[] sandboxIgnorePatterns) + { + if (category.Contains("Code")) + { + if (string.IsNullOrEmpty(filePath) || !filePath.StartsWith(targetPath)) + { + return true; + } + + return isSandbox && sandboxIgnorePatterns.Any(pattern => description.IndexOf(pattern, StringComparison.OrdinalIgnoreCase) >= 0); + } + + return isSandbox && (string.IsNullOrEmpty(filePath) || (!filePath.Contains("com.forkhorizon.nexus.unity") && !filePath.Contains("Assets/NexusUnity"))); + } + + private static void RunNexusStyleAudit(JObject result) + { + string customTargetPath = Directory.Exists("Assets/NexusUnity") ? "Assets/NexusUnity" : "Assets"; + var codeIssuesList = result["code_issues"] as JArray ?? new JArray(); + NexusEditorLog.Log(NexusLogCategory.Audit, $"[Nexus Style Audit] Scanning path: {customTargetPath}, current issues: {codeIssuesList.Count}"); + + string[] files = Directory.GetFiles(customTargetPath, "*.cs", SearchOption.AllDirectories); + int styleIssuesAdded = 0; + foreach (var file in files) + { + string relativePath = file.Replace(Directory.GetCurrentDirectory() + "/", "").Replace("\\", "/"); + if (relativePath.Contains("Assets/")) relativePath = relativePath.Substring(relativePath.IndexOf("Assets/")); + + string[] lines = File.ReadAllLines(file); + if (lines.Length <= 300) + { + continue; + } + + codeIssuesList.Add(new JObject { ["category"] = "Style", ["description"] = $"File exceeds 300 lines limit (Current: {lines.Length} lines).", ["file"] = relativePath, ["line"] = "1" }); + styleIssuesAdded++; + } + + result["code_issues"] = codeIssuesList; + result["num_total_issues"] = codeIssuesList.Count; + NexusEditorLog.Log(NexusLogCategory.Audit, $"[Nexus Style Audit] Added {styleIssuesAdded} style issues. Total: {codeIssuesList.Count}", true); + } + private static void ScanSceneHealth(JArray issues) { var allGOs = Resources.FindObjectsOfTypeAll() diff --git a/Editor/nexus_bridge/_transport.py b/Editor/nexus_bridge/_transport.py index b9fabdf..efc7cb2 100644 --- a/Editor/nexus_bridge/_transport.py +++ b/Editor/nexus_bridge/_transport.py @@ -5,7 +5,8 @@ import os import sys import urllib.request -from typing import Any + +from ._types import JsonObject, JsonRpcError, JsonRpcRequest, JsonRpcResponse DEFAULT_PORT: int = 8081 @@ -44,8 +45,8 @@ def _read_timeout() -> float: UNITY_TIMEOUT_SECONDS: float = _read_timeout() -def call_unity(method: str, params: dict[str, Any] | None = None) -> dict[str, Any]: - payload: dict[str, Any] = {"jsonrpc": "2.0", "method": method, "params": params or {}, "id": 1} +def call_unity(method: str, params: JsonObject | None = None) -> JsonRpcResponse: + payload: JsonRpcRequest = {"jsonrpc": "2.0", "method": method, "params": params or {}, "id": 1} data: bytes = json.dumps(payload).encode("utf-8") req: urllib.request.Request = urllib.request.Request( UNITY_URL, @@ -56,4 +57,8 @@ def call_unity(method: str, params: dict[str, Any] | None = None) -> dict[str, A with urllib.request.urlopen(req, timeout=UNITY_TIMEOUT_SECONDS) as response: return json.loads(response.read().decode("utf-8")) except Exception as error: - return {"error": {"code": -32000, "message": f"Unity Server unreachable. Error: {error}"}} + error_payload: JsonRpcError = { + "code": -32000, + "message": f"Unity Server unreachable. Error: {error}", + } + return {"error": error_payload} diff --git a/Editor/nexus_bridge/_types.py b/Editor/nexus_bridge/_types.py new file mode 100644 index 0000000..6f4532d --- /dev/null +++ b/Editor/nexus_bridge/_types.py @@ -0,0 +1,76 @@ +"""Private type definitions for the NexusUnity Python bridge.""" +from __future__ import annotations + +from typing import Any, NotRequired, TypeAlias, TypedDict + +JsonObject: TypeAlias = dict[str, Any] + + +class JsonRpcError(TypedDict): + code: int + message: str + + +class JsonRpcRequest(TypedDict): + jsonrpc: str + method: str + params: JsonObject + id: int + + +class ToolDefinition(TypedDict): + name: str + description: str + inputSchema: JsonObject + + +class ResourceDefinition(TypedDict): + uri: str + name: str + mimeType: NotRequired[str] + + +class JsonRpcResponse(TypedDict, total=False): + result: JsonObject + error: JsonRpcError + + +class TransformArguments(TypedDict, total=False): + instance_id: int + position: JsonObject + rotation: JsonObject + scale: JsonObject + eulerAngles: JsonObject + localScale: JsonObject + + +class WriteFileSpec(TypedDict): + path: str + content: str + + +class WriteError(TypedDict): + path: str + error: JsonRpcError + + +class WaitResultPayload(TypedDict): + status: str + time_waited_seconds: float + + +class TestResultsPayload(WaitResultPayload, total=False): + timestamp_utc: str + message: str + result_path: str + trigger: JsonObject + + +class WriteAndCompileSuccessPayload(WaitResultPayload): + compiler_errors: list[JsonObject] + + +class WriteAndCompileFailurePayload(TypedDict): + status: str + message: str + errors: list[WriteError] diff --git a/Editor/nexus_bridge/_types.py.meta b/Editor/nexus_bridge/_types.py.meta new file mode 100644 index 0000000..63f4e9b --- /dev/null +++ b/Editor/nexus_bridge/_types.py.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: f0bead8511344b4f920937b1c98230ac +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/nexus_bridge/routing.py b/Editor/nexus_bridge/routing.py index aaddea6..b418b14 100644 --- a/Editor/nexus_bridge/routing.py +++ b/Editor/nexus_bridge/routing.py @@ -7,106 +7,152 @@ from __future__ import annotations import time -from typing import Any +from typing import Any, Callable, Mapping, Sequence, cast from ._logging import logger from ._transport import call_unity from .schemas import STATIC_TOOLS - - -def _compact(params: dict[str, Any]) -> dict[str, Any]: +from ._types import ( + JsonObject, + JsonRpcError, + JsonRpcResponse, + TestResultsPayload, + TransformArguments, + WaitResultPayload, + WriteAndCompileFailurePayload, + WriteAndCompileSuccessPayload, + WriteError, + WriteFileSpec, +) + +RouteHandler = Callable[[JsonObject], JsonRpcResponse] + + +def _compact(params: JsonObject) -> JsonObject: return {key: value for key, value in params.items() if value is not None} -def _alias(action: str | None, aliases: dict[str, str]) -> str | None: - return aliases.get(action, action) # type: ignore[arg-type] +def _alias(action_name: str | None, aliases: Mapping[str, str]) -> str | None: + if action_name is None: + return None + return aliases.get(action_name, action_name) -def _invalid_action(action: str | None, valid_actions: list[str]) -> dict[str, Any]: +def _invalid_action(action_name: str | None, valid_actions: Sequence[str]) -> JsonRpcResponse: valid = ", ".join(valid_actions) - return {"error": {"code": -32602, "message": f"Invalid action: {action}. Valid actions: {valid}"}} + error_payload: JsonRpcError = { + "code": -32602, + "message": f"Invalid action: {action_name}. Valid actions: {valid}", + } + return {"error": error_payload} + + +def _result_object(response: JsonRpcResponse | None) -> JsonObject: + if not response: + return {} + result_payload = response.get("result") + return result_payload if isinstance(result_payload, dict) else {} + + +def _error_object(response: JsonRpcResponse | None) -> JsonRpcError | None: + if not response: + return None + return response.get("error") -def _transform_params(args: dict[str, Any], instance_id: int | None = None) -> dict[str, Any]: - params: dict[str, Any] = {"instance_id": instance_id if instance_id is not None else args.get("instance_id")} +def _transform_params(args: JsonObject, instance_id: int | None = None) -> JsonObject: + params: TransformArguments = { + "instance_id": instance_id if instance_id is not None else args.get("instance_id") + } for key in ["position", "rotation", "scale", "eulerAngles", "localScale"]: params[key] = args.get(key) return _compact(params) -def _extract_created_instance_id(response: dict[str, Any]) -> int | None: - if not isinstance(response, dict) or "error" in response: +def _extract_created_instance_id(response: JsonRpcResponse) -> int | None: + if "error" in response: return None - result = response.get("result", {}) - data = result.get("data", {}) if isinstance(result, dict) else {} + result_payload = _result_object(response) + data = result_payload.get("data", {}) return data.get("instance_id") if isinstance(data, dict) else None -def _apply_created_transform(response: dict[str, Any], args: dict[str, Any]) -> dict[str, Any]: +def _apply_created_transform(response: JsonRpcResponse, args: JsonObject) -> JsonRpcResponse: instance_id = _extract_created_instance_id(response) - if not instance_id: + if instance_id is None: return response params = _transform_params(args, instance_id) if len(params) <= 1: return response - transform = call_unity("set_transform", params) - if transform and "error" in transform: - return transform + transform_response = call_unity("set_transform", params) + if transform_response and "error" in transform_response: + return transform_response return response -def _run_tests_wait(args: dict[str, Any]) -> dict[str, Any]: +def _run_tests_wait(args: JsonObject) -> JsonRpcResponse: timeout = args.get("timeout_seconds", 180) poll_interval = args.get("poll_interval_seconds", 1.0) start_time = time.time() - before = call_unity("get_test_results") - before_result = before.get("result", {}) if isinstance(before, dict) else {} - before_timestamp = before_result.get("timestamp_utc") if before_result.get("status") == "Success" else None + previous_results_response = call_unity("get_test_results") + previous_results_payload = _result_object(previous_results_response) + previous_timestamp = ( + previous_results_payload.get("timestamp_utc") + if previous_results_payload.get("status") == "Success" + else None + ) run_params = _compact({ "mode": args.get("mode", "EditMode"), "filter": args.get("filter"), }) - trigger = call_unity("run_tests", run_params) - if trigger and "error" in trigger: - return trigger + trigger_response = call_unity("run_tests", run_params) + if trigger_response and "error" in trigger_response: + return trigger_response - trigger_result = trigger.get("result", {}) if isinstance(trigger, dict) else {} - result_path = trigger_result.get("result_path") + trigger_payload = _result_object(trigger_response) + result_path = trigger_payload.get("result_path") while time.time() - start_time < timeout: params = {"result_path": result_path} if result_path else {} - current = call_unity("get_test_results", params) - if current and "error" in current: - return current - - result = current.get("result", {}) if isinstance(current, dict) else {} - if result.get("status") == "Success" and result.get("timestamp_utc") != before_timestamp: - result["time_waited_seconds"] = round(time.time() - start_time, 2) - return {"result": result} + current_results_response = call_unity("get_test_results", params) + if current_results_response and "error" in current_results_response: + return current_results_response + + current_results_payload = _result_object(current_results_response) + if ( + current_results_payload.get("status") == "Success" + and current_results_payload.get("timestamp_utc") != previous_timestamp + ): + test_results = cast(TestResultsPayload, dict(current_results_payload)) + test_results["time_waited_seconds"] = round(time.time() - start_time, 2) + return {"result": test_results} time.sleep(poll_interval) + timeout_result: TestResultsPayload = { + "status": "Timeout", + "message": "Timed out waiting for a new Unity TestResults XML file.", + "time_waited_seconds": round(time.time() - start_time, 2), + "trigger": trigger_payload, + } + if isinstance(result_path, str): + timeout_result["result_path"] = result_path return { - "result": { - "status": "Timeout", - "message": "Timed out waiting for a new Unity TestResults XML file.", - "time_waited_seconds": round(time.time() - start_time, 2), - "result_path": result_path, - "trigger": trigger_result, - } + "result": timeout_result } -def _wait_for_compilation(timeout: float, start_time: float | None = None) -> dict[str, Any]: +def _wait_for_compilation(timeout: float, start_time: float | None = None) -> JsonRpcResponse: start_time = time.time() if start_time is None else start_time status: str = "Ready" reload_started: bool = False - while time.time() - start_time < 20: - res: dict[str, Any] = call_unity("initialize") - if res is None or "error" in res: + reload_wait_timeout = min(20.0, timeout) + while time.time() - start_time < reload_wait_timeout: + initialize_response = call_unity("initialize") + if initialize_response is None or "error" in initialize_response: reload_started = True break time.sleep(0.5) @@ -115,224 +161,331 @@ def _wait_for_compilation(timeout: float, start_time: float | None = None) -> di call_unity("refresh_asset_database") while time.time() - start_time < timeout: - res = call_unity("initialize") - if res and "result" in res: + initialize_response = call_unity("initialize") + if initialize_response and "result" in initialize_response: time.sleep(2.0) - state: dict[str, Any] = call_unity("get_editor_state") - if state and "result" in state: - if not state["result"].get("is_compiling") and not state["result"].get("is_updating"): + editor_state_response = call_unity("get_editor_state") + editor_state = _result_object(editor_state_response) + if editor_state_response and "result" in editor_state_response: + if not editor_state.get("is_compiling") and not editor_state.get("is_updating"): break time.sleep(1.0) else: status = "Timeout" + wait_result: WaitResultPayload = { + "status": status, + "time_waited_seconds": round(time.time() - start_time, 2), + } return { - "result": { - "status": status, - "time_waited_seconds": round(time.time() - start_time, 2), - } + "result": wait_result } -def route_tool(name: str, args: dict[str, Any]) -> dict[str, Any]: - if name in ["tools/list", "list_tools", "listTools"]: - return {"result": {"tools": STATIC_TOOLS}} +def _route_list_tools(_: JsonObject) -> JsonRpcResponse: + return {"result": {"tools": STATIC_TOOLS}} + - if name == "write_and_compile": - files: list[dict[str, Any]] = args.get("files", []) - start_time: float = time.time() - call_unity("clear_logs") +def _route_write_and_compile(args: JsonObject) -> JsonRpcResponse: + files: list[WriteFileSpec] = args.get("files", []) + start_time: float = time.time() + call_unity("clear_logs") - write_errors: list[dict[str, Any]] = [] - for file_info in files: - res = call_unity("write_file", {"path": file_info["path"], "content": file_info["content"]}) - if res and "error" in res: - write_errors.append({"path": file_info["path"], "error": res["error"]}) + write_errors: list[WriteError] = [] + for file_info in files: + write_file_response = call_unity( + "write_file", + {"path": file_info["path"], "content": file_info["content"]}, + ) + write_error = _error_object(write_file_response) + if write_error is not None: + write_errors.append({"path": file_info["path"], "error": write_error}) - if write_errors: - return {"result": {"status": "Failed", "message": "Failed to write some files", "errors": write_errors}} + if write_errors: + failure_result: WriteAndCompileFailurePayload = { + "status": "Failed", + "message": "Failed to write some files", + "errors": write_errors, + } + return {"result": failure_result} + + wait_response = _wait_for_compilation(timeout=90, start_time=start_time) + wait_result = _result_object(wait_response) + wait_status: str = wait_result["status"] + time_waited_seconds: float = wait_result["time_waited_seconds"] + + compiler_errors: list[JsonObject] = [] + if wait_status == "Ready": + log_response = call_unity("read_logs", {"count": 200}) + log_payload = _result_object(log_response) + if log_response and "result" in log_response: + for log_entry in log_payload.get("logs", []): + if log_entry.get("Type") in ["Error", "Exception", "Assert"]: + compiler_errors.append(log_entry) + + success_result: WriteAndCompileSuccessPayload = { + "status": "Failed" if compiler_errors else wait_status, + "time_waited_seconds": time_waited_seconds, + "compiler_errors": compiler_errors, + } + return { + "result": success_result + } + + +def _route_scene_manager(args: JsonObject) -> JsonRpcResponse: + aliases = {"create_scene": "create", "open_scene": "open", "save_scene": "save", "list_scenes": "list"} + action = _alias(args.get("action"), aliases) + if action == "create": + return call_unity("create_scene", _compact({"name": args.get("name"), "path": args.get("path"), "open_if_exists": args.get("open_if_exists")})) + if action == "open": + return call_unity("open_scene", {"path": args.get("path")}) + if action == "save": + return call_unity("save_scene", {"path": args.get("path")}) + if action == "list": + return call_unity("list_scenes") + return _invalid_action(args.get("action"), ["create", "create_scene", "open", "open_scene", "save", "save_scene", "list", "list_scenes"]) + + +def _route_hierarchy_manager(args: JsonObject) -> JsonRpcResponse: + aliases = { + "create": "create_empty", + "create_gameobject": "create_empty", + "create_game_object": "create_empty", + "rename": "set_name", + "transform": "set_transform", + } + action = _alias(args.get("action"), aliases) + if action == "create_empty": + response = call_unity("create_game_object", _compact({"name": args.get("name"), "parent_id": args.get("parent_id")})) + return _apply_created_transform(response, args) + if action == "create_primitive": + return call_unity("create_primitive", _compact({ + "primitive_type": args.get("primitive_type"), + "name": args.get("name"), + "parent_id": args.get("parent_id"), + "position": args.get("position"), + "rotation": args.get("rotation"), + "scale": args.get("scale"), + "material_path": args.get("material_path"), + })) + if action == "create_hierarchy": + return call_unity("create_hierarchy", _compact({"tree": args.get("tree"), "parent_id": args.get("parent_id")})) + if action == "set_name": + return call_unity("set_property", {"instance_id": args.get("instance_id"), "property_name": "m_Name", "value": args.get("name") or args.get("new_name")}) + if action == "set_transform": + return call_unity("set_transform", _transform_params(args)) + if action == "destroy": + return call_unity("destroy_game_object", {"instance_id": args.get("instance_id")}) + if action == "duplicate": + return call_unity("duplicate_object", {"instance_id": args.get("instance_id")}) + if action == "set_active": + return call_unity("set_active", {"instance_id": args.get("instance_id"), "active": args.get("active")}) + if action == "set_parent": + return call_unity("set_parent", {"instance_id": args.get("instance_id"), "parent_id": args.get("parent_id")}) + if action == "set_sibling_index": + return call_unity("set_sibling_index", {"instance_id": args.get("instance_id"), "index": args.get("index")}) + return _invalid_action(args.get("action"), ["create_empty", "create", "create_gameobject", "create_game_object", "create_primitive", "create_hierarchy", "destroy", "duplicate", "rename", "set_name", "set_transform", "set_active", "set_parent", "set_sibling_index"]) + + +def _route_component_manager(args: JsonObject) -> JsonRpcResponse: + action = args.get("action") + if action == "add": + return call_unity("add_component", {"instance_id": args.get("instance_id"), "component_name": args.get("component_name")}) + if action == "remove": + return call_unity("remove_component", {"instance_id": args.get("instance_id"), "component_name": args.get("component_name")}) + if action == "inspect": + return call_unity("inspect_component", {"instance_id": args.get("instance_id"), "component_name": args.get("component_name")}) + if action == "get_schema": + return call_unity("get_component_schema", {"instance_id": args.get("instance_id"), "component_name": args.get("component_name")}) + if action == "update_properties": + return call_unity("update_component", {"instance_id": args.get("instance_id"), "component_name": args.get("component_name"), "properties": args.get("properties")}) + if action == "set_property": + return call_unity("set_property", {"instance_id": args.get("instance_id"), "property_name": args.get("property_name"), "value": args.get("value")}) + if action == "set_enabled": + return call_unity("set_enabled", {"instance_id": args.get("instance_id"), "component_name": args.get("component_name"), "enabled": args.get("enabled")}) + return _invalid_action(action, ["add", "remove", "inspect", "get_schema", "update_properties", "set_property", "set_enabled"]) + + +def _route_search_manager(args: JsonObject) -> JsonRpcResponse: + strategy = args.get("strategy") + if strategy == "regex": + return call_unity("find_objects", {"name": args.get("query"), "tag": args.get("tag"), "type": args.get("type")}) + if strategy == "path": + return call_unity("find_by_path", {"path": args.get("query")}) + if strategy == "semantic": + return call_unity("semantic_find", {"query": args.get("query")}) + if strategy == "references": + return call_unity("find_references", {"target_id": args.get("target_id"), "target_guid": args.get("target_guid")}) + return {"error": {"code": -32602, "message": f"Invalid strategy: {strategy}. Valid strategies: regex, path, semantic, references"}} + + +def _route_asset_manager(args: JsonObject) -> JsonRpcResponse: + action = args.get("action") + if action == "search": + return call_unity("list_assets", {"filter": args.get("filter")}) + if action == "explore": + return call_unity("explore_asset", {"path": args.get("path")}) + if action == "create_material": + return call_unity("create_material", _compact({ + "name": args.get("name"), + "shader": args.get("shader"), + "path": args.get("path"), + "base_color": args.get("base_color") or args.get("color"), + "emission_color": args.get("emission_color") or args.get("emission"), + })) + if action == "import": + return call_unity("import_asset", {"path": args.get("path")}) + if action == "refresh": + return call_unity("refresh_asset_database") + if action == "instantiate_prefab": + return call_unity("instantiate_prefab", {"path": args.get("path")}) + if action == "create_prefab": + return call_unity("create_prefab", {"instance_id": args.get("instance_id"), "path": args.get("path")}) + if action == "apply_overrides": + return call_unity("apply_prefab_overrides", {"instance_id": args.get("instance_id")}) + if action == "revert_overrides": + return call_unity("revert_prefab_overrides", {"instance_id": args.get("instance_id")}) + return _invalid_action(action, ["search", "explore", "create_material", "import", "refresh", "instantiate_prefab", "create_prefab", "apply_overrides", "revert_overrides"]) + + +def _route_editor_controller(args: JsonObject) -> JsonRpcResponse: + action = args.get("action") + if action == "undo": + return call_unity("undo") + if action == "redo": + return call_unity("redo") + if action == "play": + return call_unity("toggle_play_mode", {"value": args.get("state")}) + if action == "pause": + return call_unity("pause_play_mode", {"value": args.get("state")}) + if action == "step": + return call_unity("step_frame") + if action == "menu": + return call_unity("execute_menu_item", {"item_path": args.get("item_path")}) + if action == "read_logs": + return call_unity("read_logs", {"count": args.get("count", 100)}) + if action == "clear_logs": + return call_unity("clear_logs") + if action == "get_state": + return call_unity("get_editor_state") + if action == "get_server_status": + return call_unity("get_server_status") + if action == "refresh_assets": + return call_unity("refresh_asset_database") + if action == "run_tests": + return call_unity("run_tests", _compact({"mode": args.get("mode", "EditMode"), "filter": args.get("filter")})) + if action == "get_test_results": + return call_unity("get_test_results", _compact({"result_path": args.get("result_path")})) + if action == "run_tests_wait": + return _run_tests_wait(args) + if action == "get_tool_usage_stats": + return call_unity("get_tool_usage_stats") + if action == "reset_tool_usage_stats": + return call_unity("reset_tool_usage_stats") + return _invalid_action(action, ["undo", "redo", "play", "pause", "step", "menu", "read_logs", "clear_logs", "get_state", "get_server_status", "refresh_assets", "run_tests", "get_test_results", "run_tests_wait", "get_tool_usage_stats", "reset_tool_usage_stats"]) + + +def _route_ui_automation(args: JsonObject) -> JsonRpcResponse: + action = args.get("action") + if action == "list_windows": + return call_unity("ui_list_windows") + if action == "get_hierarchy": + return call_unity("ui_get_hierarchy", _compact({"window_title": args.get("window_title"), "deep": args.get("deep")})) + if action == "query": + return call_unity("ui_query_elements", _compact({"window_title": args.get("window_title"), "name": args.get("name"), "text": args.get("text"), "class_name": args.get("class_name")})) + if action == "get_window_rect": + return call_unity("ui_get_window_rect", {"window_title": args.get("window_title")}) + if action == "set_window_rect": + return call_unity("ui_set_window_rect", _compact({"window_title": args.get("window_title"), "x": args.get("x"), "y": args.get("y"), "width": args.get("width"), "height": args.get("height")})) + if action == "capture_window_snapshot": + return call_unity("ui_capture_window_snapshot", _compact({"window_title": args.get("window_title"), "include_image": args.get("include_image"), "include_hierarchy": args.get("include_hierarchy")})) + if action == "click": + return call_unity("ui_click", {"window_title": args.get("window_title"), "element_name": args.get("element_name")}) + if action == "input": + return call_unity("ui_input_text", {"window_title": args.get("window_title"), "element_name": args.get("element_name"), "text": args.get("text")}) + return _invalid_action(action, ["list_windows", "get_hierarchy", "query", "get_window_rect", "set_window_rect", "capture_window_snapshot", "click", "input"]) + + +def _route_playerprefs_manager(args: JsonObject) -> JsonRpcResponse: + action = args.get("action") + if action == "get": + return call_unity("get_player_pref", {"key": args.get("key"), "type": args.get("type", "string")}) + if action == "set": + return call_unity("set_player_pref", {"key": args.get("key"), "value": args.get("value"), "type": args.get("type", "string")}) + if action == "delete": + return call_unity("delete_player_pref", {"key": args.get("key")}) + if action == "list": + return call_unity("list_player_prefs") + return _invalid_action(action, ["get", "set", "delete", "list"]) + + +def _route_wait(args: JsonObject) -> JsonRpcResponse: + condition: Any = args.get("condition") + timeout: float = args.get("timeout_seconds", 60) + start_time: float = time.time() + status: str = "Ready" + + if condition == "compilation": + return _wait_for_compilation(timeout=timeout, start_time=start_time) + if condition == "play_mode": + target_state = args.get("state", True) + while time.time() - start_time < timeout: + editor_state_response = call_unity("get_editor_state") + editor_state = _result_object(editor_state_response) + if editor_state_response and "result" in editor_state_response: + if editor_state.get("is_playing") == target_state: + break + time.sleep(1.0) else: - wait_result: dict[str, Any] = _wait_for_compilation(timeout=90, start_time=start_time) - wait_status: str = wait_result["result"]["status"] - time_waited_seconds: float = wait_result["result"]["time_waited_seconds"] - - compiler_errors: list[dict[str, Any]] = [] - if wait_status == "Ready": - log_res = call_unity("read_logs", {"count": 200}) - if log_res and "result" in log_res: - for log_entry in log_res["result"].get("logs", []): - if log_entry.get("Type") in ["Error", "Exception", "Assert"]: - compiler_errors.append(log_entry) - - return { - "result": { - "status": "Failed" if compiler_errors else wait_status, - "time_waited_seconds": time_waited_seconds, - "compiler_errors": compiler_errors - } - } - - elif name == "scene_manager": - aliases = {"create_scene": "create", "open_scene": "open", "save_scene": "save", "list_scenes": "list"} - action = _alias(args.get("action"), aliases) - if action == "create": - return call_unity("create_scene", _compact({"name": args.get("name"), "path": args.get("path"), "open_if_exists": args.get("open_if_exists")})) - elif action == "open": - return call_unity("open_scene", {"path": args.get("path")}) - elif action == "save": - return call_unity("save_scene", {"path": args.get("path")}) - elif action == "list": - return call_unity("list_scenes") + status = "Timeout" + elif condition == "import": + while time.time() - start_time < timeout: + import_idle_response = call_unity("is_asset_import_idle") + import_idle_state = _result_object(import_idle_response) + if import_idle_response and "result" in import_idle_response: + if import_idle_state.get("is_idle"): + break + time.sleep(1.0) else: - return _invalid_action(args.get("action"), ["create", "create_scene", "open", "open_scene", "save", "save_scene", "list", "list_scenes"]) - - elif name == "hierarchy_manager": - aliases = { - "create": "create_empty", - "create_gameobject": "create_empty", - "create_game_object": "create_empty", - "rename": "set_name", - "transform": "set_transform", - } - action = _alias(args.get("action"), aliases) - if action == "create_empty": - res = call_unity("create_game_object", _compact({"name": args.get("name"), "parent_id": args.get("parent_id")})) - return _apply_created_transform(res, args) - elif action == "create_primitive": - return call_unity("create_primitive", _compact({ - "primitive_type": args.get("primitive_type"), - "name": args.get("name"), - "parent_id": args.get("parent_id"), - "position": args.get("position"), - "rotation": args.get("rotation"), - "scale": args.get("scale"), - "material_path": args.get("material_path"), - })) - elif action == "create_hierarchy": - return call_unity("create_hierarchy", _compact({"tree": args.get("tree"), "parent_id": args.get("parent_id")})) - elif action == "set_name": - return call_unity("set_property", {"instance_id": args.get("instance_id"), "property_name": "m_Name", "value": args.get("name") or args.get("new_name")}) - elif action == "set_transform": - return call_unity("set_transform", _transform_params(args)) - elif action == "destroy": return call_unity("destroy_game_object", {"instance_id": args.get("instance_id")}) - elif action == "duplicate": return call_unity("duplicate_object", {"instance_id": args.get("instance_id")}) - elif action == "set_active": return call_unity("set_active", {"instance_id": args.get("instance_id"), "active": args.get("active")}) - elif action == "set_parent": return call_unity("set_parent", {"instance_id": args.get("instance_id"), "parent_id": args.get("parent_id")}) - elif action == "set_sibling_index": return call_unity("set_sibling_index", {"instance_id": args.get("instance_id"), "index": args.get("index")}) + status = "Timeout" + elif condition == "editor_idle": + while time.time() - start_time < timeout: + editor_idle_response = call_unity("is_editor_idle") + editor_idle_state = _result_object(editor_idle_response) + if editor_idle_response and "result" in editor_idle_response: + if editor_idle_state.get("is_idle"): + break + time.sleep(1.0) else: - return _invalid_action(args.get("action"), ["create_empty", "create", "create_gameobject", "create_game_object", "create_primitive", "create_hierarchy", "destroy", "duplicate", "rename", "set_name", "set_transform", "set_active", "set_parent", "set_sibling_index"]) - - elif name == "component_manager": - action = args.get("action") - if action == "add": return call_unity("add_component", {"instance_id": args.get("instance_id"), "component_name": args.get("component_name")}) - elif action == "remove": return call_unity("remove_component", {"instance_id": args.get("instance_id"), "component_name": args.get("component_name")}) - elif action == "inspect": return call_unity("inspect_component", {"instance_id": args.get("instance_id"), "component_name": args.get("component_name")}) - elif action == "get_schema": return call_unity("get_component_schema", {"instance_id": args.get("instance_id"), "component_name": args.get("component_name")}) - elif action == "update_properties": return call_unity("update_component", {"instance_id": args.get("instance_id"), "component_name": args.get("component_name"), "properties": args.get("properties")}) - elif action == "set_property": return call_unity("set_property", {"instance_id": args.get("instance_id"), "property_name": args.get("property_name"), "value": args.get("value")}) - elif action == "set_enabled": return call_unity("set_enabled", {"instance_id": args.get("instance_id"), "component_name": args.get("component_name"), "enabled": args.get("enabled")}) - else: return _invalid_action(action, ["add", "remove", "inspect", "get_schema", "update_properties", "set_property", "set_enabled"]) - - elif name == "search_manager": - strategy = args.get("strategy") - if strategy == "regex": return call_unity("find_objects", {"name": args.get("query"), "tag": args.get("tag"), "type": args.get("type")}) - elif strategy == "path": return call_unity("find_by_path", {"path": args.get("query")}) - elif strategy == "semantic": return call_unity("semantic_find", {"query": args.get("query")}) - elif strategy == "references": return call_unity("find_references", {"target_id": args.get("target_id"), "target_guid": args.get("target_guid")}) - else: return {"error": {"code": -32602, "message": f"Invalid strategy: {strategy}. Valid strategies: regex, path, semantic, references"}} - - elif name == "asset_manager": - action = args.get("action") - if action == "search": return call_unity("list_assets", {"filter": args.get("filter")}) - elif action == "explore": return call_unity("explore_asset", {"path": args.get("path")}) - elif action == "create_material": - return call_unity("create_material", _compact({ - "name": args.get("name"), - "shader": args.get("shader"), - "path": args.get("path"), - "base_color": args.get("base_color") or args.get("color"), - "emission_color": args.get("emission_color") or args.get("emission"), - })) - elif action == "import": return call_unity("import_asset", {"path": args.get("path")}) - elif action == "refresh": return call_unity("refresh_asset_database") - elif action == "instantiate_prefab": return call_unity("instantiate_prefab", {"path": args.get("path")}) - elif action == "create_prefab": return call_unity("create_prefab", {"instance_id": args.get("instance_id"), "path": args.get("path")}) - elif action == "apply_overrides": return call_unity("apply_prefab_overrides", {"instance_id": args.get("instance_id")}) - elif action == "revert_overrides": return call_unity("revert_prefab_overrides", {"instance_id": args.get("instance_id")}) - else: return _invalid_action(action, ["search", "explore", "create_material", "import", "refresh", "instantiate_prefab", "create_prefab", "apply_overrides", "revert_overrides"]) - - elif name == "editor_controller": - action = args.get("action") - if action == "undo": return call_unity("undo") - elif action == "redo": return call_unity("redo") - elif action == "play": return call_unity("toggle_play_mode", {"value": args.get("state")}) - elif action == "pause": return call_unity("pause_play_mode", {"value": args.get("state")}) - elif action == "step": return call_unity("step_frame") - elif action == "menu": return call_unity("execute_menu_item", {"item_path": args.get("item_path")}) - elif action == "read_logs": return call_unity("read_logs", {"count": args.get("count", 100)}) - elif action == "clear_logs": return call_unity("clear_logs") - elif action == "get_state": return call_unity("get_editor_state") - elif action == "get_server_status": return call_unity("get_server_status") - elif action == "refresh_assets": return call_unity("refresh_asset_database") - elif action == "run_tests": return call_unity("run_tests", _compact({"mode": args.get("mode", "EditMode"), "filter": args.get("filter")})) - elif action == "get_test_results": return call_unity("get_test_results", _compact({"result_path": args.get("result_path")})) - elif action == "run_tests_wait": return _run_tests_wait(args) - elif action == "get_tool_usage_stats": return call_unity("get_tool_usage_stats") - elif action == "reset_tool_usage_stats": return call_unity("reset_tool_usage_stats") - else: return _invalid_action(action, ["undo", "redo", "play", "pause", "step", "menu", "read_logs", "clear_logs", "get_state", "get_server_status", "refresh_assets", "run_tests", "get_test_results", "run_tests_wait", "get_tool_usage_stats", "reset_tool_usage_stats"]) - - elif name == "ui_automation": - action = args.get("action") - if action == "list_windows": return call_unity("ui_list_windows") - elif action == "get_hierarchy": return call_unity("ui_get_hierarchy", _compact({"window_title": args.get("window_title"), "deep": args.get("deep")})) - elif action == "query": return call_unity("ui_query_elements", _compact({"window_title": args.get("window_title"), "name": args.get("name"), "text": args.get("text"), "class_name": args.get("class_name")})) - elif action == "get_window_rect": return call_unity("ui_get_window_rect", {"window_title": args.get("window_title")}) - elif action == "set_window_rect": return call_unity("ui_set_window_rect", _compact({"window_title": args.get("window_title"), "x": args.get("x"), "y": args.get("y"), "width": args.get("width"), "height": args.get("height")})) - elif action == "capture_window_snapshot": return call_unity("ui_capture_window_snapshot", _compact({"window_title": args.get("window_title"), "include_image": args.get("include_image"), "include_hierarchy": args.get("include_hierarchy")})) - elif action == "click": return call_unity("ui_click", {"window_title": args.get("window_title"), "element_name": args.get("element_name")}) - elif action == "input": return call_unity("ui_input_text", {"window_title": args.get("window_title"), "element_name": args.get("element_name"), "text": args.get("text")}) - else: return _invalid_action(action, ["list_windows", "get_hierarchy", "query", "get_window_rect", "set_window_rect", "capture_window_snapshot", "click", "input"]) - - elif name == "playerprefs_manager": - action = args.get("action") - if action == "get": return call_unity("get_player_pref", {"key": args.get("key"), "type": args.get("type", "string")}) - elif action == "set": return call_unity("set_player_pref", {"key": args.get("key"), "value": args.get("value"), "type": args.get("type", "string")}) - elif action == "delete": return call_unity("delete_player_pref", {"key": args.get("key")}) - elif action == "list": return call_unity("list_player_prefs") - else: return _invalid_action(action, ["get", "set", "delete", "list"]) - - elif name == "wait": - cond: Any = args.get("condition") - timeout: float = args.get("timeout_seconds", 60) - start_time: float = time.time() - status: str = "Ready" - - if cond == "compilation": - return _wait_for_compilation(timeout=timeout, start_time=start_time) - elif cond == "play_mode": - target_state = args.get("state", True) - while time.time() - start_time < timeout: - state_res = call_unity("get_editor_state") - if state_res and "result" in state_res: - if state_res["result"].get("is_playing") == target_state: break - time.sleep(1.0) - else: status = "Timeout" - elif cond == "import": - while time.time() - start_time < timeout: - res = call_unity("is_asset_import_idle") - if res and "result" in res: - if res["result"].get("is_idle"): break - time.sleep(1.0) - else: status = "Timeout" - elif cond == "editor_idle": - while time.time() - start_time < timeout: - res = call_unity("is_editor_idle") - if res and "result" in res: - if res["result"].get("is_idle"): break - time.sleep(1.0) - else: status = "Timeout" - - return {"result": {"status": status, "time_waited_seconds": round(time.time() - start_time, 2)}} + status = "Timeout" - else: - return call_unity(name, args) + wait_result: WaitResultPayload = { + "status": status, + "time_waited_seconds": round(time.time() - start_time, 2), + } + return {"result": wait_result} + + +_HANDLERS: dict[str, RouteHandler] = { + "tools/list": _route_list_tools, + "list_tools": _route_list_tools, + "listTools": _route_list_tools, + "write_and_compile": _route_write_and_compile, + "scene_manager": _route_scene_manager, + "hierarchy_manager": _route_hierarchy_manager, + "component_manager": _route_component_manager, + "search_manager": _route_search_manager, + "asset_manager": _route_asset_manager, + "editor_controller": _route_editor_controller, + "ui_automation": _route_ui_automation, + "playerprefs_manager": _route_playerprefs_manager, + "wait": _route_wait, +} + + +def route_tool(name: str, args: JsonObject) -> JsonRpcResponse: + handler = _HANDLERS.get(name) + if handler is not None: + return handler(args) + return call_unity(name, args) diff --git a/Editor/nexus_bridge/schemas.py b/Editor/nexus_bridge/schemas.py index 8739458..a58f22f 100644 --- a/Editor/nexus_bridge/schemas.py +++ b/Editor/nexus_bridge/schemas.py @@ -8,10 +8,10 @@ """ from __future__ import annotations -from typing import Any +from ._types import JsonObject, ResourceDefinition, ToolDefinition # --- Shared sub-schemas --- -VECTOR3_SCHEMA: dict[str, Any] = { +VECTOR3_SCHEMA: JsonObject = { "type": "object", "properties": { "x": {"type": "number"}, @@ -20,7 +20,7 @@ }, } -STATIC_TOOLS: list[dict[str, Any]] = [ +STATIC_TOOLS: list[ToolDefinition] = [ # --- Consolidated Core Managers --- { "name": "unity_scene_manager", @@ -41,24 +41,120 @@ "description": "Unified GameObject hierarchy and lifecycle management", "inputSchema": { "type": "object", - "properties": { - "action": {"type": "string", "enum": ["create_empty", "create", "create_gameobject", "create_game_object", "create_primitive", "create_hierarchy", "destroy", "duplicate", "rename", "set_name", "set_transform", "set_active", "set_parent", "set_sibling_index"]}, - "instance_id": {"type": "integer"}, - "name": {"type": "string"}, - "new_name": {"type": "string"}, - "parent_id": {"type": "integer"}, - "primitive_type": {"type": "string", "enum": ["Cube", "Sphere", "Capsule", "Cylinder", "Plane", "Quad"]}, - "position": VECTOR3_SCHEMA, - "rotation": VECTOR3_SCHEMA, - "scale": VECTOR3_SCHEMA, - "eulerAngles": VECTOR3_SCHEMA, - "localScale": VECTOR3_SCHEMA, - "material_path": {"type": "string"}, - "tree": {"type": "object"}, - "active": {"type": "boolean"}, - "index": {"type": "string"} - }, - "required": ["action"] + "oneOf": [ + { + "description": "Create an empty GameObject, including common create-action aliases", + "properties": { + "action": {"type": "string", "enum": ["create_empty", "create", "create_gameobject", "create_game_object"]}, + "name": {"type": "string"}, + "parent_id": {"type": "integer"}, + "position": VECTOR3_SCHEMA, + "rotation": VECTOR3_SCHEMA, + "scale": VECTOR3_SCHEMA, + "eulerAngles": VECTOR3_SCHEMA, + "localScale": VECTOR3_SCHEMA, + }, + "required": ["action", "name"] + }, + { + "description": "Create a primitive GameObject", + "properties": { + "action": {"const": "create_primitive"}, + "primitive_type": {"type": "string", "enum": ["Cube", "Sphere", "Capsule", "Cylinder", "Plane", "Quad"]}, + "name": {"type": "string"}, + "parent_id": {"type": "integer"}, + "position": VECTOR3_SCHEMA, + "rotation": VECTOR3_SCHEMA, + "scale": VECTOR3_SCHEMA, + "material_path": {"type": "string"}, + }, + "required": ["action", "primitive_type"] + }, + { + "description": "Batch-create a hierarchy of GameObjects", + "properties": { + "action": {"const": "create_hierarchy"}, + "tree": {"type": "object"}, + "parent_id": {"type": "integer"}, + }, + "required": ["action", "tree"] + }, + { + "description": "Destroy a GameObject", + "properties": { + "action": {"const": "destroy"}, + "instance_id": {"type": "integer"}, + }, + "required": ["action", "instance_id"] + }, + { + "description": "Duplicate a GameObject", + "properties": { + "action": {"const": "duplicate"}, + "instance_id": {"type": "integer"}, + }, + "required": ["action", "instance_id"] + }, + { + "description": "Rename a GameObject, including the rename alias", + "properties": { + "action": {"type": "string", "enum": ["rename", "set_name"]}, + "instance_id": {"type": "integer"}, + "name": {"type": "string"}, + "new_name": {"type": "string"}, + }, + "required": ["action", "instance_id"], + "anyOf": [ + {"required": ["name"]}, + {"required": ["new_name"]} + ] + }, + { + "description": "Move, rotate, or scale a GameObject, including the transform alias", + "properties": { + "action": {"type": "string", "enum": ["set_transform", "transform"]}, + "instance_id": {"type": "integer"}, + "position": VECTOR3_SCHEMA, + "rotation": VECTOR3_SCHEMA, + "scale": VECTOR3_SCHEMA, + "eulerAngles": VECTOR3_SCHEMA, + "localScale": VECTOR3_SCHEMA, + }, + "required": ["action", "instance_id"] + }, + { + "description": "Enable or disable a GameObject", + "properties": { + "action": {"const": "set_active"}, + "instance_id": {"type": "integer"}, + "active": {"type": "boolean"}, + }, + "required": ["action", "instance_id", "active"] + }, + { + "description": "Reparent a GameObject", + "properties": { + "action": {"const": "set_parent"}, + "instance_id": {"type": "integer"}, + "parent_id": {"type": "integer"}, + }, + "required": ["action", "instance_id", "parent_id"] + }, + { + "description": "Reorder a GameObject within its siblings", + "properties": { + "action": {"const": "set_sibling_index"}, + "instance_id": {"type": "integer"}, + "index": { + "oneOf": [ + {"type": "integer"}, + {"type": "string", "enum": ["first", "last"]} + ] + }, + }, + "required": ["action", "instance_id", "index"] + } + ] } }, { @@ -185,7 +281,7 @@ {"name": "unity_lint_project", "description": "Run Roslyn-based C# audit of the entire project", "inputSchema": {"type": "object", "properties": {}}} ] -STATIC_RESOURCES: list[dict[str, Any]] = [ +STATIC_RESOURCES: list[ResourceDefinition] = [ { "uri": "unity://docs/api-reference", "name": "API Reference", diff --git a/Editor/tests/test_routing.py b/Editor/tests/test_routing.py index 39418f3..cb17ebe 100644 --- a/Editor/tests/test_routing.py +++ b/Editor/tests/test_routing.py @@ -4,7 +4,7 @@ import sys import unittest from typing import Any -from unittest.mock import call, patch +from unittest.mock import Mock, call, patch EDITOR_DIR: str = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) if EDITOR_DIR not in sys.path: @@ -14,10 +14,29 @@ class RouteToolDispatchTests(unittest.TestCase): + def test_route_tool_uses_registered_handler(self) -> None: + handler = Mock(return_value={"result": {"handled": True}}) + + with patch.dict(routing._HANDLERS, {"synthetic": handler}, clear=True): + with patch("nexus_bridge.routing.call_unity") as mock_call_unity: + response: dict[str, Any] = routing.route_tool("synthetic", {"value": 7}) + + self.assertEqual({"result": {"handled": True}}, response) + handler.assert_called_once_with({"value": 7}) + mock_call_unity.assert_not_called() + + def test_route_tool_falls_back_to_call_unity_for_unknown_route(self) -> None: + with patch("nexus_bridge.routing.call_unity", return_value={"result": {"ok": True}}) as mock_call_unity: + response: dict[str, Any] = routing.route_tool("invoke_method", {"instance_id": 42}) + + self.assertEqual({"result": {"ok": True}}, response) + mock_call_unity.assert_called_once_with("invoke_method", {"instance_id": 42}) + def test_route_tool_dispatches_to_expected_unity_methods(self) -> None: test_cases: list[tuple[str, dict[str, Any], str, tuple[Any, ...]]] = [ ("scene_manager", {"action": "open", "path": "Assets/TestScene.unity"}, "open_scene", ({"path": "Assets/TestScene.unity"},)), ("hierarchy_manager", {"action": "destroy", "instance_id": 42}, "destroy_game_object", ({"instance_id": 42},)), + ("hierarchy_manager", {"action": "set_sibling_index", "instance_id": 42, "index": 2}, "set_sibling_index", ({"instance_id": 42, "index": 2},)), ("component_manager", {"action": "add", "instance_id": 42, "component_name": "BoxCollider"}, "add_component", ({"instance_id": 42, "component_name": "BoxCollider"},)), ("search_manager", {"strategy": "path", "query": "/Canvas/Button"}, "find_by_path", ({"path": "/Canvas/Button"},)), ("asset_manager", {"action": "refresh"}, "refresh_asset_database", tuple()), @@ -34,6 +53,136 @@ def test_route_tool_dispatches_to_expected_unity_methods(self) -> None: self.assertEqual({"result": {"ok": True}}, response) mock_call_unity.assert_called_once_with(unity_method, *unity_params) + def test_apply_created_transform_accepts_zero_instance_id(self) -> None: + response: dict[str, Any] = {"result": {"data": {"instance_id": 0}}} + args: dict[str, Any] = {"position": {"x": 1, "y": 2, "z": 3}} + + with patch("nexus_bridge.routing.call_unity", return_value={"result": {"ok": True}}) as mock_call_unity: + returned = routing._apply_created_transform(response, args) + + self.assertIs(returned, response) + mock_call_unity.assert_called_once_with( + "set_transform", + {"instance_id": 0, "position": {"x": 1, "y": 2, "z": 3}}, + ) + + +class RouteHandlerTests(unittest.TestCase): + def test_scene_manager_create_alias_routes_to_create_scene(self) -> None: + with patch("nexus_bridge.routing.call_unity", return_value={"result": {"ok": True}}) as mock_call_unity: + response: dict[str, Any] = routing._route_scene_manager( + {"action": "create_scene", "name": "Arena", "open_if_exists": True} + ) + + self.assertEqual({"result": {"ok": True}}, response) + mock_call_unity.assert_called_once_with( + "create_scene", + {"name": "Arena", "open_if_exists": True}, + ) + + def test_scene_manager_invalid_action_returns_error(self) -> None: + response: dict[str, Any] = routing._route_scene_manager({"action": "delete"}) + + self.assertEqual(-32602, response["error"]["code"]) + self.assertIn("Invalid action: delete", response["error"]["message"]) + + def test_editor_controller_run_tests_wait_delegates_to_helper(self) -> None: + expected_response: dict[str, Any] = {"result": {"status": "Success"}} + + with patch("nexus_bridge.routing._run_tests_wait", return_value=expected_response) as mock_run_tests_wait: + response: dict[str, Any] = routing._route_editor_controller({"action": "run_tests_wait", "timeout_seconds": 9}) + + self.assertEqual(expected_response, response) + mock_run_tests_wait.assert_called_once_with({"action": "run_tests_wait", "timeout_seconds": 9}) + + def test_hierarchy_manager_create_applies_transform_after_create(self) -> None: + create_response: dict[str, Any] = {"result": {"data": {"instance_id": 12}}} + transform_response: dict[str, Any] = {"result": {"ok": True}} + + with patch("nexus_bridge.routing.call_unity", side_effect=[create_response, transform_response]) as mock_call_unity: + response: dict[str, Any] = routing._route_hierarchy_manager( + {"action": "create", "name": "Cube", "position": {"x": 1, "y": 2, "z": 3}} + ) + + self.assertEqual(create_response, response) + self.assertEqual( + [ + call("create_game_object", {"name": "Cube"}), + call("set_transform", {"instance_id": 12, "position": {"x": 1, "y": 2, "z": 3}}), + ], + mock_call_unity.call_args_list, + ) + + +class WriteAndCompileTests(unittest.TestCase): + def test_write_and_compile_returns_write_errors_without_waiting(self) -> None: + files = [ + {"path": "Assets/One.cs", "content": "one"}, + {"path": "Assets/Two.cs", "content": "two"}, + ] + call_results: list[dict[str, Any] | None] = [ + None, + {"error": {"code": -32000, "message": "disk full"}}, + {"result": {"ok": True}}, + ] + + with patch("nexus_bridge.routing.call_unity", side_effect=call_results) as mock_call_unity: + with patch("nexus_bridge.routing._wait_for_compilation") as mock_wait_for_compilation: + response: dict[str, Any] = routing._route_write_and_compile({"files": files}) + + self.assertEqual("Failed", response["result"]["status"]) + self.assertEqual("Failed to write some files", response["result"]["message"]) + self.assertEqual( + [{"path": "Assets/One.cs", "error": {"code": -32000, "message": "disk full"}}], + response["result"]["errors"], + ) + mock_wait_for_compilation.assert_not_called() + self.assertEqual( + [ + call("clear_logs"), + call("write_file", {"path": "Assets/One.cs", "content": "one"}), + call("write_file", {"path": "Assets/Two.cs", "content": "two"}), + ], + mock_call_unity.call_args_list, + ) + + def test_write_and_compile_waits_and_filters_compiler_errors(self) -> None: + files = [{"path": "Assets/Test.cs", "content": "class Test {}"}] + wait_response: dict[str, Any] = {"result": {"status": "Ready", "time_waited_seconds": 1.5}} + log_response: dict[str, Any] = { + "result": { + "logs": [ + {"Type": "Error", "Message": "compile failed"}, + {"Type": "Assert", "Message": "assertion"}, + {"Type": "Log", "Message": "ignore"}, + ] + } + } + + with patch("nexus_bridge.routing.time.time", return_value=10.0): + with patch("nexus_bridge.routing.call_unity", side_effect=[None, {"result": {"ok": True}}, log_response]) as mock_call_unity: + with patch("nexus_bridge.routing._wait_for_compilation", return_value=wait_response) as mock_wait_for_compilation: + response: dict[str, Any] = routing._route_write_and_compile({"files": files}) + + self.assertEqual("Failed", response["result"]["status"]) + self.assertEqual(1.5, response["result"]["time_waited_seconds"]) + self.assertEqual( + [ + {"Type": "Error", "Message": "compile failed"}, + {"Type": "Assert", "Message": "assertion"}, + ], + response["result"]["compiler_errors"], + ) + mock_wait_for_compilation.assert_called_once_with(timeout=90, start_time=10.0) + self.assertEqual( + [ + call("clear_logs"), + call("write_file", {"path": "Assets/Test.cs", "content": "class Test {}"}), + call("read_logs", {"count": 200}), + ], + mock_call_unity.call_args_list, + ) + class RunTestsWaitTests(unittest.TestCase): def test_run_tests_wait_returns_success_when_new_results_appear(self) -> None: @@ -124,6 +273,27 @@ def test_wait_for_compilation_returns_timeout_after_refresh(self) -> None: ) mock_sleep.assert_called_once_with(0.5) + def test_wait_for_compilation_respects_timeout_smaller_than_reload_probe_window(self) -> None: + call_results: list[dict[str, Any]] = [ + {"result": {}}, + {"result": {}}, + ] + + with patch("nexus_bridge.routing.call_unity", side_effect=call_results) as mock_call_unity: + with patch("nexus_bridge.routing.time.time", side_effect=[100.0, 100.0, 105.1, 105.1, 106.1]): + with patch("nexus_bridge.routing.time.sleep") as mock_sleep: + response: dict[str, Any] = routing._wait_for_compilation(timeout=5.0) + + self.assertEqual({"result": {"status": "Timeout", "time_waited_seconds": 6.1}}, response) + self.assertEqual( + [ + call("initialize"), + call("refresh_asset_database"), + ], + mock_call_unity.call_args_list, + ) + mock_sleep.assert_called_once_with(0.5) + def test_wait_route_delegates_compilation_condition_to_helper(self) -> None: expected_response: dict[str, Any] = {"result": {"status": "Ready", "time_waited_seconds": 4.5}} diff --git a/Editor/tests/test_schemas.py b/Editor/tests/test_schemas.py new file mode 100644 index 0000000..9962919 --- /dev/null +++ b/Editor/tests/test_schemas.py @@ -0,0 +1,82 @@ +from __future__ import annotations + +import os +import sys +import unittest +from typing import Any + +EDITOR_DIR: str = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +if EDITOR_DIR not in sys.path: + sys.path.insert(0, EDITOR_DIR) + +from nexus_bridge.schemas import STATIC_TOOLS + + +def _get_tool(name: str) -> dict[str, Any]: + for tool in STATIC_TOOLS: + if tool["name"] == name: + return tool + raise AssertionError(f"Could not find tool {name}.") + + +def _action_values(variant: dict[str, Any]) -> set[str]: + action_schema = variant["properties"]["action"] + if "const" in action_schema: + return {action_schema["const"]} + return set(action_schema["enum"]) + + +class HierarchyManagerSchemaTests(unittest.TestCase): + def test_hierarchy_manager_uses_action_specific_variants(self) -> None: + hierarchy_manager = _get_tool("unity_hierarchy_manager") + input_schema = hierarchy_manager["inputSchema"] + + self.assertNotIn("required", input_schema) + self.assertIn("oneOf", input_schema) + self.assertGreaterEqual(len(input_schema["oneOf"]), 1) + + def test_rename_variant_requires_instance_id_and_name_or_new_name(self) -> None: + hierarchy_manager = _get_tool("unity_hierarchy_manager") + rename_variant = next( + variant + for variant in hierarchy_manager["inputSchema"]["oneOf"] + if "rename" in _action_values(variant) + ) + + self.assertCountEqual(["action", "instance_id"], rename_variant["required"]) + self.assertEqual( + [{"required": ["name"]}, {"required": ["new_name"]}], + rename_variant["anyOf"], + ) + + def test_transform_alias_is_advertised(self) -> None: + hierarchy_manager = _get_tool("unity_hierarchy_manager") + transform_variant = next( + variant + for variant in hierarchy_manager["inputSchema"]["oneOf"] + if "set_transform" in _action_values(variant) + ) + + self.assertIn("transform", _action_values(transform_variant)) + + def test_set_sibling_index_accepts_integer_or_edge_keywords(self) -> None: + hierarchy_manager = _get_tool("unity_hierarchy_manager") + sibling_variant = next( + variant + for variant in hierarchy_manager["inputSchema"]["oneOf"] + if "set_sibling_index" in _action_values(variant) + ) + + self.assertCountEqual(["action", "instance_id", "index"], sibling_variant["required"]) + index_schema = sibling_variant["properties"]["index"] + self.assertEqual( + [ + {"type": "integer"}, + {"type": "string", "enum": ["first", "last"]}, + ], + index_schema["oneOf"], + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/Editor/tests/test_schemas.py.meta b/Editor/tests/test_schemas.py.meta new file mode 100644 index 0000000..910a12a --- /dev/null +++ b/Editor/tests/test_schemas.py.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 9046bb8bd125460690905034f6644fcf +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/README.md b/README.md index e56e49b..09a190d 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Nexus Unity -[![Tag](https://img.shields.io/github/v/tag/ForkHorizon/NexusUnity?sort=semver&label=release)](https://github.com/ForkHorizon/NexusUnity/releases/tag/v1.4.1) +[![Tag](https://img.shields.io/github/v/tag/ForkHorizon/NexusUnity?sort=semver&label=release)](https://github.com/ForkHorizon/NexusUnity/releases/tag/v1.4.2) [![License: GPL-3.0-only](https://img.shields.io/badge/license-GPL--3.0--only-blue.svg)](LICENSE.md) [![Unity](https://img.shields.io/badge/Unity-6000.0%2B-black?logo=unity)](package.json) [![Validate package](https://github.com/ForkHorizon/NexusUnity/actions/workflows/validate.yml/badge.svg)](https://github.com/ForkHorizon/NexusUnity/actions/workflows/validate.yml) @@ -8,13 +8,13 @@ Nexus Unity is an open source Unity Editor automation package. It runs a local JSON-RPC server inside the Unity Editor and exposes scene, asset, code, log, test, inspection, and UI automation tools to trusted local developer workflows. - Package id: `com.forkhorizon.nexus.unity` -- Version: `1.4.1` +- Version: `1.4.2` - License: `GPL-3.0-only` - Public repository: `https://github.com/ForkHorizon/NexusUnity.git` ## Status -Active public release. Current version: `1.4.1`. +Active public release. Current version: `1.4.2`. The public API is maintained for local Unity Editor automation workflows, while new tools and bridge improvements are tracked under `[Unreleased]` in `CHANGELOG.md` until the next tagged release. @@ -48,9 +48,11 @@ https://github.com/ForkHorizon/NexusUnity.git For reproducible installs, pin the public release tag: ```text -https://github.com/ForkHorizon/NexusUnity.git#v1.4.1 +https://github.com/ForkHorizon/NexusUnity.git#v1.4.2 ``` +Nexus Unity does not declare Unity Project Auditor packages. Its lint tool always runs Nexus style and scene checks, and only includes Unity Project Auditor findings when the host project explicitly has compatible Project Auditor rules installed. + ## Start The Server 1. Open `Window > Nexus Unity`. @@ -242,9 +244,9 @@ For integration tests, open the Unity project, start the Nexus Unity server from ## Development Versioning -Do not bump `package.json` for every change while development is unreleased. Keep the package at the latest public release version, currently `1.4.1`, and record user-visible work under `[Unreleased]` in `CHANGELOG.md`. +Do not bump `package.json` for every change while development is unreleased. Keep the package at the latest public release version, currently `1.4.2`, and record user-visible work under `[Unreleased]` in `CHANGELOG.md`. -When maintainers prepare a release, move the accumulated `[Unreleased]` entries to the new version section, update `package.json` and the visible version strings in `README.md`, `DOCUMENTATION.MD`, and `API_REFERENCE.MD`, then tag the release. Unity Package Manager and GitHub releases both use semantic `MAJOR.MINOR.PATCH` versions such as `1.4.1` and `v1.4.1`. Reserve patch bumps for urgent compatible hotfixes. +When maintainers prepare a release, move the accumulated `[Unreleased]` entries to the new version section, update `package.json` and the visible version strings in `README.md`, `DOCUMENTATION.MD`, and `API_REFERENCE.MD`, then tag the release. Unity Package Manager and GitHub releases both use semantic `MAJOR.MINOR.PATCH` versions such as `1.4.2` and `v1.4.2`. Reserve patch bumps for urgent compatible hotfixes. ## Community @@ -254,6 +256,8 @@ To support ongoing development, use the repository Sponsor button configured thr ## Release Notes +The `1.4.2` patch removes direct Unity Project Auditor dependencies to avoid clean-install Console spam, keeps Nexus lint checks available without Project Auditor rules, and tightens the Python MCP bridge schemas, routing types, and compile/test polling behavior. + The `1.4.1` patch adds the missing package folder `.meta` files (`docs/`, `docs/assets/`, `Editor/tests/`) so fresh installs no longer log "no meta file" warnings, and hardens the pre-push `.meta` validation to check the git-tracked tree and every folder. The `1.4.0` release adds Claude Code project setup, improves Windows/Linux MCP client detection, refactors the Python bridge transport/logging layers, adds bridge unit tests and CI coverage, and updates external PR validation/replay guidance. diff --git a/RELEASE.md b/RELEASE.md index 7fc2320..6c3d9a7 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -7,7 +7,7 @@ This checklist is for publishing `com.forkhorizon.nexus.unity` as an open source - Package id: `com.forkhorizon.nexus.unity` - Public repository: `https://github.com/ForkHorizon/NexusUnity.git` - License: `GPL-3.0-only` -- Current public version: `1.4.1` +- Current public version: `1.4.2` - Minimum Unity version: `6000.0` ## Development Versioning @@ -27,7 +27,7 @@ Use `CHANGELOG.md` as the source of truth during development: - Keep compatibility notes and migration guidance in the docs while the work is unreleased. - Prepare the next semantic version only when cutting a release branch or release commit. -Unity Package Manager requires `MAJOR.MINOR.PATCH` in `package.json`, for example `1.4.1`. GitHub release tags, titles, and announcements use the same semantic version: `v1.4.1` for tags and `1.4.1` for release titles. +Unity Package Manager requires `MAJOR.MINOR.PATCH` in `package.json`, for example `1.4.2`. GitHub release tags, titles, and announcements use the same semantic version: `v1.4.2` for tags and `1.4.2` for release titles. When preparing the release, choose the version by semantic versioning: @@ -39,7 +39,7 @@ When preparing the release, choose the version by semantic versioning: 1. Verify `Assets/NexusUnity/package.json`: - `name` is `com.forkhorizon.nexus.unity`. - - `version` matches the Unity package version, such as `1.4.1`. + - `version` matches the Unity package version, such as `1.4.2`. - `license` is `GPL-3.0-only`. - Repository, documentation, changelog, and license URLs point to the public repository. 2. Verify docs: diff --git a/package.json b/package.json index 0c1e133..0a212d5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "com.forkhorizon.nexus.unity", - "version": "1.4.1", + "version": "1.4.2", "displayName": "Nexus Unity", "description": "Open source Unity Editor automation server for local AI tools and developer workflows.", "unity": "6000.0", @@ -23,8 +23,6 @@ }, "dependencies": { "com.unity.inputsystem": "1.19.0", - "com.unity.nuget.newtonsoft-json": "2.0.0", - "com.unity.project-auditor": "0.10.0-preview.1", - "com.unity.project-auditor-rules": "1.0.2" + "com.unity.nuget.newtonsoft-json": "2.0.0" } } diff --git a/scripts/prepush-validate.sh b/scripts/prepush-validate.sh index ab983b7..57a23c2 100755 --- a/scripts/prepush-validate.sh +++ b/scripts/prepush-validate.sh @@ -34,6 +34,14 @@ assert package["name"] == "com.forkhorizon.nexus.unity" assert package["version"] assert package["license"] == "GPL-3.0-only" assert package["repository"]["url"] == "https://github.com/ForkHorizon/NexusUnity.git" + +dependencies = package.get("dependencies", {}) +for package_name in ("com.unity.project-auditor", "com.unity.project-auditor-rules"): + assert package_name not in dependencies, ( + f"Do not depend on {package_name}; Nexus audit support uses reflection " + "when a host project explicitly installs compatible Unity Project " + "Auditor packages." + ) PY log "Compiling Python bridge"