Files
Workspace/Assets/Editor/ShrinkSdkWorkspaceValidation.cs
T
cneicy bb12b8d02d
Validate ShrinkSDK Workspace / unity (push) Failing after 5s
fix: automate package versions and workspace validation
2026-09-05 06:32:13 +08:00

280 lines
12 KiB
C#

#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<string, PackageDefinition> 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<string, PackageDefinition>(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<string>("name");
var version = manifest.Value<string>("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<string> 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<string>(StringComparer.OrdinalIgnoreCase);
foreach (var line in File.ReadLines(gitModulesPath))
{
var match = Regex.Match(line, @"^\s*path\s*=\s*(?<path>.+?)\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<string, PackageDefinition> packages)
{
var dependencies = new Dictionary<string, HashSet<string>>(StringComparer.Ordinal);
foreach (var package in packages.Values)
{
var localDependencies = new HashSet<string>(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<string>();
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<string>(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<string, PackageDefinition> 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<string, string>(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