#if UNITY_EDITOR #nullable enable using System; using System.Collections; using System.Collections.Generic; using System.IO; using System.Linq; using System.Reflection; using System.Text.RegularExpressions; using Newtonsoft.Json.Linq; using UnityEditor; using UnityEngine; namespace ShrinkSDK.WorkspaceValidation { public static class ShrinkSdkWorkspaceValidation { private const string InstallerPackageName = "com.cneicy.shrink-installer"; private const string InstallerCatalogTypeName = "ShrinkSDK.Installer.ShrinkSdkPackageCatalog"; private static readonly Regex SemanticVersion = new Regex( @"^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$", RegexOptions.CultureInvariant); public static void Run() { try { var workspaceRoot = Path.GetFullPath(Path.Combine(Application.dataPath, "..")); var packages = ReadPackages(workspaceRoot); ValidateGraph(packages); ValidateInstallerCatalog(packages); Debug.Log($"ShrinkSDK Workspace validation passed: packages={packages.Count}"); EditorApplication.Exit(0); } catch (Exception exception) { Debug.LogException(exception); EditorApplication.Exit(1); } } private static Dictionary ReadPackages(string workspaceRoot) { var modulesRoot = Path.Combine(workspaceRoot, "Assets", "Modules"); if (!Directory.Exists(modulesRoot)) { throw new DirectoryNotFoundException($"Workspace package directory is missing: {modulesRoot}"); } var declaredDirectories = ReadDeclaredPackageDirectories(workspaceRoot); var manifestDirectories = Directory.EnumerateDirectories(modulesRoot) .Where(directory => File.Exists(Path.Combine(directory, "package.json"))) .Select(Path.GetFullPath) .ToHashSet(StringComparer.OrdinalIgnoreCase); var undeclared = manifestDirectories .Where(directory => !declaredDirectories.Contains(directory)) .OrderBy(directory => directory, StringComparer.OrdinalIgnoreCase) .ToArray(); if (undeclared.Length > 0) { throw new InvalidOperationException( "Package directories are not declared as submodules: " + string.Join(", ", undeclared)); } var result = new Dictionary(StringComparer.Ordinal); foreach (var directory in declaredDirectories.OrderBy(value => value, StringComparer.OrdinalIgnoreCase)) { var manifestPath = Path.Combine(directory, "package.json"); if (!File.Exists(manifestPath)) { throw new FileNotFoundException("Declared package submodule has no package.json.", manifestPath); } var manifest = JObject.Parse(File.ReadAllText(manifestPath)); var packageName = manifest.Value("name"); var version = manifest.Value("version"); if (string.IsNullOrWhiteSpace(packageName) || string.IsNullOrWhiteSpace(version)) { throw new InvalidOperationException($"{directory} has no valid package name or version."); } var validPackageName = packageName!; var validVersion = version!; if (!SemanticVersion.IsMatch(validVersion)) { throw new InvalidOperationException($"{validPackageName} has an invalid semantic version: {validVersion}"); } if (result.ContainsKey(validPackageName)) { throw new InvalidOperationException($"Duplicate package manifest name: {validPackageName}"); } result.Add(validPackageName, new PackageDefinition(validPackageName, validVersion, manifest)); } return result; } private static HashSet ReadDeclaredPackageDirectories(string workspaceRoot) { var gitModulesPath = Path.Combine(workspaceRoot, ".gitmodules"); if (!File.Exists(gitModulesPath)) { throw new FileNotFoundException("Workspace .gitmodules is missing.", gitModulesPath); } var result = new HashSet(StringComparer.OrdinalIgnoreCase); foreach (var line in File.ReadLines(gitModulesPath)) { var match = Regex.Match(line, @"^\s*path\s*=\s*(?.+?)\s*$", RegexOptions.CultureInvariant); if (!match.Success) { continue; } var relativePath = match.Groups["path"].Value.Replace('\\', '/'); if (!relativePath.StartsWith("Assets/Modules/", StringComparison.Ordinal) || relativePath.Substring("Assets/Modules/".Length).Contains("/")) { continue; } var fullPath = Path.GetFullPath(Path.Combine(workspaceRoot, relativePath)); if (!result.Add(fullPath)) { throw new InvalidOperationException($"Duplicate package submodule path: {relativePath}"); } } if (result.Count == 0) { throw new InvalidOperationException("No Assets/Modules package submodules are declared."); } return result; } private static void ValidateGraph(IReadOnlyDictionary packages) { var dependencies = new Dictionary>(StringComparer.Ordinal); foreach (var package in packages.Values) { var localDependencies = new HashSet(StringComparer.Ordinal); var manifestDependencies = package.Manifest["dependencies"] as JObject; if (manifestDependencies != null) { foreach (var property in manifestDependencies.Properties()) { if (!packages.TryGetValue(property.Name, out var dependency)) { continue; } var expectedVersion = property.Value.Value(); if (!string.Equals(expectedVersion, dependency.Version, StringComparison.Ordinal)) { throw new InvalidOperationException( $"{package.Name} requires {dependency.Name} {expectedVersion}, but the Workspace provides {dependency.Version}."); } var isIntegrationOrStarter = package.Name.IndexOf("-integration-", StringComparison.Ordinal) >= 0 || package.Name.IndexOf("-starter-", StringComparison.Ordinal) >= 0; if (dependency.Name.IndexOf("-integration-", StringComparison.Ordinal) >= 0 && !isIntegrationOrStarter) { throw new InvalidOperationException( $"{package.Name} must not depend on integration package {dependency.Name}."); } localDependencies.Add(dependency.Name); } } dependencies.Add(package.Name, localDependencies); } var resolved = new HashSet(StringComparer.Ordinal); var madeProgress = true; while (madeProgress) { madeProgress = false; foreach (var packageName in dependencies.Keys.OrderBy(name => name, StringComparer.Ordinal)) { if (resolved.Contains(packageName) || dependencies[packageName].Any(dependency => !resolved.Contains(dependency))) { continue; } resolved.Add(packageName); madeProgress = true; } } if (resolved.Count != packages.Count) { var blocked = dependencies .Where(pair => !resolved.Contains(pair.Key)) .Select(pair => pair.Key + " -> " + string.Join(", ", pair.Value.Where(dependency => !resolved.Contains(dependency)))) .OrderBy(value => value, StringComparer.Ordinal); throw new InvalidOperationException("Circular internal package dependencies detected: " + string.Join("; ", blocked)); } } private static void ValidateInstallerCatalog(IReadOnlyDictionary packages) { var catalogType = AppDomain.CurrentDomain.GetAssemblies() .Select(assembly => assembly.GetType(InstallerCatalogTypeName, false)) .FirstOrDefault(type => type != null) ?? throw new InvalidOperationException($"Installer catalog type was not loaded: {InstallerCatalogTypeName}"); var packagesField = catalogType.GetField("Packages", BindingFlags.Static | BindingFlags.NonPublic) ?? throw new InvalidOperationException("Installer catalog Packages field was not found."); var catalogItems = packagesField.GetValue(null) as IEnumerable ?? throw new InvalidOperationException("Installer catalog Packages field is not enumerable."); var catalog = new Dictionary(StringComparer.Ordinal); foreach (var item in catalogItems) { if (item == null) { throw new InvalidOperationException("Installer catalog contains a null package entry."); } var itemType = item.GetType(); var packageName = itemType.GetProperty("PackageName")?.GetValue(item) as string; var version = itemType.GetProperty("Version")?.GetValue(item) as string; if (string.IsNullOrWhiteSpace(packageName) || string.IsNullOrWhiteSpace(version)) { throw new InvalidOperationException("Installer catalog contains an invalid package entry."); } var validPackageName = packageName!; var validVersion = version!; if (!catalog.TryAdd(validPackageName, validVersion)) { throw new InvalidOperationException($"Installer catalog contains duplicate package: {validPackageName}"); } } var expectedNames = packages.Keys .Where(name => !string.Equals(name, InstallerPackageName, StringComparison.Ordinal)) .ToHashSet(StringComparer.Ordinal); var missing = expectedNames.Except(catalog.Keys).OrderBy(name => name, StringComparer.Ordinal).ToArray(); var unexpected = catalog.Keys.Except(expectedNames).OrderBy(name => name, StringComparer.Ordinal).ToArray(); if (missing.Length > 0 || unexpected.Length > 0) { throw new InvalidOperationException( $"Installer catalog package set differs from Workspace. Missing=[{string.Join(", ", missing)}], " + $"unexpected=[{string.Join(", ", unexpected)}]"); } foreach (var packageName in expectedNames) { if (!string.Equals(catalog[packageName], packages[packageName].Version, StringComparison.Ordinal)) { throw new InvalidOperationException( $"Installer catalog requires {packageName} {catalog[packageName]}, but the Workspace provides {packages[packageName].Version}."); } } } private sealed class PackageDefinition { public PackageDefinition(string name, string version, JObject manifest) { Name = name; Version = version; Manifest = manifest; } public string Name { get; } public string Version { get; } public JObject Manifest { get; } } } } #endif