From 8045502e4decb3bb43460b77516c1658c408ae82 Mon Sep 17 00:00:00 2001 From: wallstop Date: Wed, 9 Sep 2026 14:59:15 +0000 Subject: [PATCH 01/49] Move editor helpers out of runtime assembly --- .../Utilities}/DirectoryHelper.cs | 18 +- .../Utilities}/DirectoryHelper.cs.meta | 0 .../DataVisualizer/Utilities}/PathHelper.cs | 0 .../Utilities}/PathHelper.cs.meta | 2 +- .../Utilities/ReflectionHelper.cs | 47 ++ .../Utilities}/ReflectionHelper.cs.meta | 2 +- Runtime/Helper.meta | 3 - Runtime/Helper/ReflectionHelper.cs | 604 ------------------ 8 files changed, 50 insertions(+), 626 deletions(-) rename {Runtime/Helper => Editor/DataVisualizer/Utilities}/DirectoryHelper.cs (91%) rename {Runtime/Helper => Editor/DataVisualizer/Utilities}/DirectoryHelper.cs.meta (100%) rename {Runtime/Helper => Editor/DataVisualizer/Utilities}/PathHelper.cs (100%) rename {Runtime/Helper => Editor/DataVisualizer/Utilities}/PathHelper.cs.meta (71%) create mode 100644 Editor/DataVisualizer/Utilities/ReflectionHelper.cs rename {Runtime/Helper => Editor/DataVisualizer/Utilities}/ReflectionHelper.cs.meta (71%) delete mode 100644 Runtime/Helper.meta delete mode 100644 Runtime/Helper/ReflectionHelper.cs diff --git a/Runtime/Helper/DirectoryHelper.cs b/Editor/DataVisualizer/Utilities/DirectoryHelper.cs similarity index 91% rename from Runtime/Helper/DirectoryHelper.cs rename to Editor/DataVisualizer/Utilities/DirectoryHelper.cs index 482dc84..3d131b0 100644 --- a/Runtime/Helper/DirectoryHelper.cs +++ b/Editor/DataVisualizer/Utilities/DirectoryHelper.cs @@ -3,10 +3,8 @@ namespace WallstopStudios.DataVisualizer.Helper using System; using System.IO; using System.Runtime.CompilerServices; - using UnityEngine; -#if UNITY_EDITOR using UnityEditor; -#endif + using UnityEngine; internal static class DirectoryHelper { @@ -17,7 +15,6 @@ public static void EnsureDirectoryExists(string relativeDirectoryPath) return; } -#if UNITY_EDITOR if (!relativeDirectoryPath.StartsWith("Assets/")) { if (relativeDirectoryPath.Equals("Assets", StringComparison.OrdinalIgnoreCase)) @@ -66,7 +63,6 @@ public static void EnsureDirectoryExists(string relativeDirectoryPath) AssetDatabase.CreateFolder(parentPath, currentFolderName); Debug.Log($"Created folder: {relativeDirectoryPath}"); } -#endif } public static string GetCallerScriptDirectory([CallerFilePath] string sourceFilePath = "") @@ -175,18 +171,6 @@ public static string AbsoluteToUnityRelativePath(string absolutePath) : projectRoot.Length + 1; return absolutePath.Length > startIndex ? absolutePath[startIndex..] : string.Empty; } - if (absolutePath.StartsWith(projectRoot, StringComparison.OrdinalIgnoreCase)) - { - int startIndex = projectRoot.EndsWith("/", StringComparison.OrdinalIgnoreCase) - ? projectRoot.Length - : projectRoot.Length + 1; - if (startIndex < absolutePath.Length) - { - return "Assets/" + absolutePath[startIndex..]; - } - - return "Assets"; - } return string.Empty; } diff --git a/Runtime/Helper/DirectoryHelper.cs.meta b/Editor/DataVisualizer/Utilities/DirectoryHelper.cs.meta similarity index 100% rename from Runtime/Helper/DirectoryHelper.cs.meta rename to Editor/DataVisualizer/Utilities/DirectoryHelper.cs.meta diff --git a/Runtime/Helper/PathHelper.cs b/Editor/DataVisualizer/Utilities/PathHelper.cs similarity index 100% rename from Runtime/Helper/PathHelper.cs rename to Editor/DataVisualizer/Utilities/PathHelper.cs diff --git a/Runtime/Helper/PathHelper.cs.meta b/Editor/DataVisualizer/Utilities/PathHelper.cs.meta similarity index 71% rename from Runtime/Helper/PathHelper.cs.meta rename to Editor/DataVisualizer/Utilities/PathHelper.cs.meta index 9664ae5..88f91cf 100644 --- a/Runtime/Helper/PathHelper.cs.meta +++ b/Editor/DataVisualizer/Utilities/PathHelper.cs.meta @@ -1,3 +1,3 @@ fileFormatVersion: 2 guid: 6ed0a3297c5c42929d7194b2b1604f54 -timeCreated: 1745852898 \ No newline at end of file +timeCreated: 1745852898 diff --git a/Editor/DataVisualizer/Utilities/ReflectionHelper.cs b/Editor/DataVisualizer/Utilities/ReflectionHelper.cs new file mode 100644 index 0000000..d7a7eca --- /dev/null +++ b/Editor/DataVisualizer/Utilities/ReflectionHelper.cs @@ -0,0 +1,47 @@ +namespace WallstopStudios.DataVisualizer.Helper +{ + using System; + using System.Reflection; + + internal static class ReflectionHelpers + { + public static bool IsAttributeDefined( + this ICustomAttributeProvider provider, + out T attribute, + bool inherit = true + ) + where T : Attribute + { + try + { + if (provider.IsDefined(typeof(T), inherit)) + { + attribute = (T)provider.GetCustomAttributes(typeof(T), inherit)[0]; + return true; + } + } + catch + { + // Attribute construction can fail for an optional or malformed dependency. + } + + attribute = default; + return false; + } + + public static bool IsAttributeDefined(this ICustomAttributeProvider provider) + where T : Attribute + { + return IsAttributeDefined(provider, inherit: true); + } + + public static bool IsAttributeDefined( + this ICustomAttributeProvider provider, + bool inherit + ) + where T : Attribute + { + return IsAttributeDefined(provider, out T _, inherit); + } + } +} diff --git a/Runtime/Helper/ReflectionHelper.cs.meta b/Editor/DataVisualizer/Utilities/ReflectionHelper.cs.meta similarity index 71% rename from Runtime/Helper/ReflectionHelper.cs.meta rename to Editor/DataVisualizer/Utilities/ReflectionHelper.cs.meta index 72f4881..987bb47 100644 --- a/Runtime/Helper/ReflectionHelper.cs.meta +++ b/Editor/DataVisualizer/Utilities/ReflectionHelper.cs.meta @@ -1,3 +1,3 @@ fileFormatVersion: 2 guid: 5f665d4a6d40442eac7fb6c28c3e1dab -timeCreated: 1745851701 \ No newline at end of file +timeCreated: 1745851701 diff --git a/Runtime/Helper.meta b/Runtime/Helper.meta deleted file mode 100644 index 0a1739f..0000000 --- a/Runtime/Helper.meta +++ /dev/null @@ -1,3 +0,0 @@ -fileFormatVersion: 2 -guid: f42e46ae07384219a74a93e74fc715ab -timeCreated: 1745851399 \ No newline at end of file diff --git a/Runtime/Helper/ReflectionHelper.cs b/Runtime/Helper/ReflectionHelper.cs deleted file mode 100644 index a6d8a77..0000000 --- a/Runtime/Helper/ReflectionHelper.cs +++ /dev/null @@ -1,604 +0,0 @@ -namespace WallstopStudios.DataVisualizer.Helper -{ - using System; - using System.Collections; - using System.Collections.Generic; - using System.Linq; - using System.Reflection; - using System.Reflection.Emit; - using System.Runtime.CompilerServices; - using Extensions; - - internal delegate void FieldSetter(ref TInstance instance, TValue value); - - internal static class ReflectionHelpers - { - private static readonly Dictionary> ArrayCreators = new(); - private static readonly Dictionary> ListCreators = new(); - private static readonly Dictionary> ListWithCapacityCreators = new(); - - public static bool IsAttributeDefined( - this ICustomAttributeProvider provider, - out T attribute, - bool inherit = true - ) - where T : Attribute - { - try - { - if (provider.IsDefined(typeof(T), inherit)) - { - attribute = (T)provider.GetCustomAttributes(typeof(T), inherit)[0]; - return true; - } - } - catch - { - // Swallow - } - attribute = default; - return false; - } - - public static bool IsAttributeDefined(this ICustomAttributeProvider provider) - where T : Attribute - { - return IsAttributeDefined(provider, inherit: true); - } - - public static bool IsAttributeDefined( - this ICustomAttributeProvider provider, - bool inherit - ) - where T : Attribute - { - return IsAttributeDefined(provider, out T _, inherit); - } - - public static Dictionary LoadStaticPropertiesForType() - { - Type type = typeof(T); - return type.GetProperties(BindingFlags.Static | BindingFlags.Public) - .Where(property => property.PropertyType == type) - .ToDictionary( - property => property.Name, - property => property, - StringComparer.OrdinalIgnoreCase - ); - } - - public static Dictionary LoadStaticFieldsForType() - { - Type type = typeof(T); - return type.GetFields(BindingFlags.Static | BindingFlags.Public) - .Where(field => field.FieldType == type) - .ToDictionary( - field => field.Name, - field => field, - StringComparer.OrdinalIgnoreCase - ); - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static Array CreateArray(Type type, int length) - { - return ArrayCreators - // ReSharper disable once ConvertClosureToMethodGroup - .GetOrAdd(type, elementType => GetArrayCreator(elementType)) - .Invoke(length); - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static IList CreateList(Type elementType, int length) - { - return ListWithCapacityCreators - // ReSharper disable once ConvertClosureToMethodGroup - .GetOrAdd(elementType, type => GetListWithCapacityCreator(type)) - .Invoke(length); - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static IList CreateList(Type elementType) - { - // ReSharper disable once ConvertClosureToMethodGroup - return ListCreators.GetOrAdd(elementType, type => GetListCreator(type)).Invoke(); - } - - public static Func GetFieldGetter(FieldInfo field) - { -#if UNITY_WEBGL && !UNITY_EDITOR - return field.GetValue; -#else - DynamicMethod dynamicMethod = new( - $"Get{field.DeclaringType.Name}_{field.Name}", - typeof(object), - new[] { typeof(object) }, - field.DeclaringType, - true - ); - - ILGenerator il = dynamicMethod.GetILGenerator(); - il.Emit(OpCodes.Ldarg_0); - il.Emit( - field.DeclaringType.IsValueType ? OpCodes.Unbox : OpCodes.Castclass, - field.DeclaringType - ); - - il.Emit(OpCodes.Ldfld, field); - - // If the field's type is a value type, box it. - if (field.FieldType.IsValueType) - { - il.Emit(OpCodes.Box, field.FieldType); - } - - il.Emit(OpCodes.Ret); - - return (Func)dynamicMethod.CreateDelegate(typeof(Func)); -#endif - } - - public static Func GetPropertyGetter(PropertyInfo property) - { -#if UNITY_WEBGL && !UNITY_EDITOR - return property.GetValue; -#else - MethodInfo getMethod = property.GetGetMethod(true); - - DynamicMethod dynamicMethod = new( - $"Get{property.DeclaringType.Name}_{property.Name}", - typeof(object), - new[] { typeof(object) }, - property.DeclaringType, - true - ); - - ILGenerator il = dynamicMethod.GetILGenerator(); - il.Emit(OpCodes.Ldarg_0); - il.Emit( - property.DeclaringType.IsValueType ? OpCodes.Unbox : OpCodes.Castclass, - property.DeclaringType - ); - il.Emit( - property.DeclaringType.IsValueType ? OpCodes.Call : OpCodes.Callvirt, - getMethod - ); - - if (property.PropertyType.IsValueType) - { - il.Emit(OpCodes.Box, property.PropertyType); - } - - il.Emit(OpCodes.Ret); - - return (Func)dynamicMethod.CreateDelegate(typeof(Func)); -#endif - } - - public static Func GetStaticFieldGetter(FieldInfo field) - { - if (!field.IsStatic) - { - throw new ArgumentException(nameof(field)); - } - -#if UNITY_WEBGL && !UNITY_EDITOR - return () => field.GetValue(null); -#else - DynamicMethod dynamicMethod = new( - $"Get{field.DeclaringType.Name}_{field.Name}", - typeof(object), - Type.EmptyTypes, // No parameters for static fields - field.DeclaringType, - true - ); - - ILGenerator il = dynamicMethod.GetILGenerator(); - - // Load the static field - il.Emit(OpCodes.Ldsfld, field); - - // If the field's type is a value type, box it. - if (field.FieldType.IsValueType) - { - il.Emit(OpCodes.Box, field.FieldType); - } - - il.Emit(OpCodes.Ret); - - return (Func)dynamicMethod.CreateDelegate(typeof(Func)); -#endif - } - - public static Func GetFieldGetter(FieldInfo field) - { -#if UNITY_WEBGL && !UNITY_EDITOR - return Getter; - TValue Getter(TInstance instance) - { - return (TValue)field.GetValue(instance); - } -#else - DynamicMethod dynamicMethod = new( - $"GetGeneric{field.DeclaringType.Name}_{field.Name}", - typeof(TValue), - new[] { typeof(TInstance) }, - field.DeclaringType, - true - ); - - ILGenerator il = dynamicMethod.GetILGenerator(); - - if (!field.IsStatic) - { - if (typeof(TInstance).IsValueType) - { - il.Emit(OpCodes.Ldarga_S, 0); - } - else - { - il.Emit(OpCodes.Ldarg_0); - } - - if (field.DeclaringType != typeof(TInstance)) - { - il.Emit( - field.DeclaringType.IsValueType ? OpCodes.Unbox : OpCodes.Castclass, - field.DeclaringType - ); - } - - il.Emit(OpCodes.Ldfld, field); - } - else - { - il.Emit(OpCodes.Ldsfld, field); - } - - if (field.FieldType.IsValueType) - { - if (!typeof(TValue).IsValueType) - { - il.Emit(OpCodes.Box, field.FieldType); - } - } - else - { - if (typeof(TValue).IsValueType) - { - il.Emit(OpCodes.Unbox_Any, typeof(TValue)); - } - else if (typeof(TValue) != field.FieldType) - { - il.Emit(OpCodes.Castclass, typeof(TValue)); - } - } - - il.Emit(OpCodes.Ret); - return (Func) - dynamicMethod.CreateDelegate(typeof(Func)); -#endif - } - - public static Func GetStaticPropertyGetter(PropertyInfo property) - { - MethodInfo getMethod = property.GetGetMethod(true); - -#if UNITY_WEBGL && !UNITY_EDITOR - - return Getter; - TValue Getter() - { - // Use null for instance, null for indexer args for static properties - return (TValue)property.GetValue(null, null); - } -#endif - - DynamicMethod dynamicMethod = new( - $"GetStatic_{property.DeclaringType.Name}_{property.Name}", - typeof(TValue), - Type.EmptyTypes, - property.DeclaringType, - true - ); - - ILGenerator il = dynamicMethod.GetILGenerator(); - - il.Emit(OpCodes.Call, getMethod); - - Type actualType = property.PropertyType; - Type targetType = typeof(TValue); - - if (actualType != targetType) - { - if (actualType.IsValueType) - { - il.Emit(OpCodes.Box, actualType); - if (targetType != typeof(object)) - { - il.Emit(OpCodes.Castclass, targetType); - } - } - else - { - il.Emit( - targetType.IsValueType ? OpCodes.Unbox_Any : OpCodes.Castclass, - targetType - ); - } - } - - il.Emit(OpCodes.Ret); - return (Func)dynamicMethod.CreateDelegate(typeof(Func)); - } - - public static Func GetStaticFieldGetter(FieldInfo field) - { - if (!field.IsStatic) - { - throw new ArgumentException(nameof(field)); - } - -#if UNITY_WEBGL && !UNITY_EDITOR - return Getter; - TValue Getter() - { - return (TValue)field.GetValue(null); - } -#else - DynamicMethod dynamicMethod = new( - $"GetStatic_{field.DeclaringType.Name}_{field.Name}", - typeof(TValue), - Type.EmptyTypes, - field.DeclaringType, - true - ); - - ILGenerator il = dynamicMethod.GetILGenerator(); - - il.Emit(OpCodes.Ldsfld, field); - - Type actualType = field.FieldType; - Type targetType = typeof(TValue); - - if (actualType != targetType) - { - if (actualType.IsValueType) - { - il.Emit(OpCodes.Box, actualType); - if (targetType != typeof(object)) - { - il.Emit(OpCodes.Castclass, targetType); - } - } - else - { - il.Emit( - targetType.IsValueType ? OpCodes.Unbox_Any : OpCodes.Castclass, - targetType - ); - } - } - - il.Emit(OpCodes.Ret); - return (Func)dynamicMethod.CreateDelegate(typeof(Func)); -#endif - } - - public static FieldSetter GetFieldSetter( - FieldInfo field - ) - { -#if UNITY_WEBGL && !UNITY_EDITOR - return Setter; - void Setter(ref TInstance instance, TValue newValue) - { - object value = instance; - field.SetValue(value, newValue); - instance = (TInstance)value; - } -#else - Type instanceType = field.DeclaringType; - Type valueType = field.FieldType; - - DynamicMethod dynamicMethod = new( - $"SetFieldGeneric{field.DeclaringType.Name}_{field.Name}", - MethodAttributes.Public | MethodAttributes.Static, - CallingConventions.Standard, - typeof(void), - new[] { instanceType.MakeByRefType(), valueType }, - field.Module, - true - ); - - ILGenerator il = dynamicMethod.GetILGenerator(); - il.Emit(OpCodes.Ldarg_0); - if (!instanceType.IsValueType) - { - il.Emit(OpCodes.Ldind_Ref); - } - - il.Emit(OpCodes.Ldarg_1); - il.Emit(OpCodes.Stfld, field); - il.Emit(OpCodes.Ret); - - Type delegateType = typeof(FieldSetter<,>).MakeGenericType(instanceType, valueType); - return (FieldSetter)dynamicMethod.CreateDelegate(delegateType); -#endif - } - - public static Action GetStaticFieldSetter(FieldInfo field) - { - if (!field.IsStatic) - { - throw new ArgumentException(nameof(field)); - } -#if UNITY_WEBGL && !UNITY_EDITOR - return Setter; - void Setter(TValue newValue) - { - field.SetValue(null, newValue); - } -#else - DynamicMethod dynamicMethod = new( - $"SetFieldGenericStatic{field.DeclaringType.Name}_{field.Name}", - typeof(void), - new[] { typeof(TValue) }, - field.Module, - true - ); - - ILGenerator il = dynamicMethod.GetILGenerator(); - il.Emit(OpCodes.Ldarg_0); - il.Emit(OpCodes.Stsfld, field); - il.Emit(OpCodes.Ret); - - return (Action)dynamicMethod.CreateDelegate(typeof(Action)); -#endif - } - - public static Action GetFieldSetter(FieldInfo field) - { -#if UNITY_WEBGL && !UNITY_EDITOR - return field.SetValue; -#else - DynamicMethod dynamicMethod = new( - $"SetField{field.DeclaringType.Name}_{field.Name}", - null, - new[] { typeof(object), typeof(object) }, - field.DeclaringType.Module, - true - ); - - ILGenerator il = dynamicMethod.GetILGenerator(); - - il.Emit(OpCodes.Ldarg_0); - il.Emit( - field.DeclaringType.IsValueType ? OpCodes.Unbox : OpCodes.Castclass, - field.DeclaringType - ); - - il.Emit(OpCodes.Ldarg_1); - il.Emit( - field.FieldType.IsValueType ? OpCodes.Unbox_Any : OpCodes.Castclass, - field.FieldType - ); - il.Emit(OpCodes.Stfld, field); - il.Emit(OpCodes.Ret); - return (Action) - dynamicMethod.CreateDelegate(typeof(Action)); -#endif - } - - public static Action GetStaticFieldSetter(FieldInfo field) - { - if (!field.IsStatic) - { - throw new ArgumentException(nameof(field)); - } -#if UNITY_WEBGL && !UNITY_EDITOR - return value => field.SetValue(null, value); -#else - DynamicMethod dynamicMethod = new( - $"SetFieldStatic{field.DeclaringType.Name}_{field.Name}", - null, - new[] { typeof(object) }, - field.DeclaringType.Module, - true - ); - - ILGenerator il = dynamicMethod.GetILGenerator(); - - // Load the new value (argument 0) - il.Emit(OpCodes.Ldarg_0); - // Convert the object to the field's type (unbox or cast as needed) - il.Emit( - field.FieldType.IsValueType ? OpCodes.Unbox_Any : OpCodes.Castclass, - field.FieldType - ); - // Set the static field - il.Emit(OpCodes.Stsfld, field); - il.Emit(OpCodes.Ret); - - return (Action)dynamicMethod.CreateDelegate(typeof(Action)); -#endif - } - - public static Func GetArrayCreator(Type elementType) - { -#if UNITY_WEBGL && !UNITY_EDITOR - return size => Array.CreateInstance(elementType, size); -#else - DynamicMethod dynamicMethod = new( - $"CreateArray{elementType.Name}", - typeof(Array), // Return type: Array - new[] { typeof(int) }, // Parameter: int (size) - true - ); - - ILGenerator il = dynamicMethod.GetILGenerator(); - il.Emit(OpCodes.Ldarg_0); // Load the array size - il.Emit(OpCodes.Newarr, elementType); // Create a new array of 'type' - il.Emit(OpCodes.Ret); // Return the array - return (Func)dynamicMethod.CreateDelegate(typeof(Func)); -#endif - } - - public static Func GetListCreator(Type elementType) - { - Type listType = typeof(List<>).MakeGenericType(elementType); -#if UNITY_WEBGL && !UNITY_EDITOR - return () => (IList)Activator.CreateInstance(listType); -#else - DynamicMethod dynamicMethod = new( - $"CreateList{listType.Name}", - typeof(IList), // Return type: IList - Type.EmptyTypes, // No parameters - true - ); - - ILGenerator il = dynamicMethod.GetILGenerator(); - ConstructorInfo constructor = listType.GetConstructor(Type.EmptyTypes); - if (constructor == null) - { - throw new ArgumentException( - $"Type {listType} does not have a parameterless constructor." - ); - } - - il.Emit(OpCodes.Newobj, constructor); // Call List constructor - il.Emit(OpCodes.Ret); // Return the instance - return (Func)dynamicMethod.CreateDelegate(typeof(Func)); -#endif - } - - public static Func GetListWithCapacityCreator(Type elementType) - { - Type listType = typeof(List<>).MakeGenericType(elementType); -#if UNITY_WEBGL && !UNITY_EDITOR - return _ => (IList)Activator.CreateInstance(listType); -#else - DynamicMethod dynamicMethod = new( - $"CreateListWithCapacity{listType.Name}", - typeof(IList), // Return type: IList - new[] { typeof(int) }, // Parameter: int (size) - true - ); - - ILGenerator il = dynamicMethod.GetILGenerator(); - ConstructorInfo constructor = listType.GetConstructor(new[] { typeof(int) }); - if (constructor == null) - { - throw new ArgumentException( - $"Type {listType} does not have a constructor accepting an int." - ); - } - - il.Emit(OpCodes.Ldarg_0); // Load capacity argument - il.Emit(OpCodes.Newobj, constructor); // Call List(int capacity) constructor - il.Emit(OpCodes.Ret); // Return the instance - return (Func)dynamicMethod.CreateDelegate(typeof(Func)); -#endif - } - } -} From 405a27ade1a61ef0a976c5aaa196ee3565096922 Mon Sep 17 00:00:00 2001 From: wallstop Date: Wed, 9 Sep 2026 15:18:20 +0000 Subject: [PATCH 02/49] Automate validated package releases --- .github/release.md | 36 ++ .github/workflows/npm-publish.yml | 192 ++++++--- .github/workflows/release-prepare.yml | 133 ++++++ .github/workflows/release-tag.yml | 80 ++++ .llm/context.md | 3 +- CHANGELOG.md | 22 + CHANGELOG.md.meta | 7 + package.json | 4 +- scripts/release.meta | 8 + scripts/release/release_tools.py | 462 +++++++++++++++++++++ scripts/release/release_tools.py.meta | 7 + scripts/release/test_release_tools.py | 153 +++++++ scripts/release/test_release_tools.py.meta | 7 + scripts/tests/test-release-tools.ps1 | 17 + scripts/tests/test-release-tools.ps1.meta | 11 + 15 files changed, 1079 insertions(+), 63 deletions(-) create mode 100644 .github/release.md create mode 100644 .github/workflows/release-prepare.yml create mode 100644 .github/workflows/release-tag.yml create mode 100644 CHANGELOG.md create mode 100644 CHANGELOG.md.meta create mode 100644 scripts/release.meta create mode 100644 scripts/release/release_tools.py create mode 100644 scripts/release/release_tools.py.meta create mode 100644 scripts/release/test_release_tools.py create mode 100644 scripts/release/test_release_tools.py.meta create mode 100644 scripts/tests/test-release-tools.ps1 create mode 100644 scripts/tests/test-release-tools.ps1.meta diff --git a/.github/release.md b/.github/release.md new file mode 100644 index 0000000..732a49b --- /dev/null +++ b/.github/release.md @@ -0,0 +1,36 @@ +# Release operations + +Releases are prepared from `main` with the **Prepare release** workflow. Select +`patch`, `minor`, or `major`, or choose `none` and provide an explicit SemVer. +Use `dry_run` first. A real run updates `package.json`, `.llm/context.md`, and +`CHANGELOG.md`, then opens one `release/vX.Y.Z` pull request. + +## Required repository configuration + +- `AUTO_COMMIT_APP_ID`: the numeric ID of a GitHub App installed on this + repository. +- `AUTO_COMMIT_APP_PRIVATE_KEY`: the PEM private key for that App. +- The App installation must have only `Contents: write` and `Pull requests: + write` for this repository. The tag workflow uses only `Contents: write`. +- npm Trusted Publishing must be configured for + `com.wallstop-studios.data-visualizer` with this repository, the + `Publish release artifacts` workflow, and the default branch/tag workflow + identity. No npm token secret is used. + +## Recovery and reruns + +The tag workflow validates the merged release branch and creates an annotated +`vX.Y.Z` tag. An existing tag is never retargeted automatically: a tag at the +same commit is left alone, while a tag at another commit fails closed. + +The publication workflow checks whether the exact npm `name@version` already +exists and skips republishing it. It always validates the checked-out tag and +artifacts first. If npm succeeds but GitHub Release creation fails, rerun the +tag workflow from the existing tag event or use the workflow run again; release +creation updates an existing release and replaces attachments by checksum-safe +name. A failed release-preparation run may be retried only after resolving an +existing branch or PR with the same version. + +All `.unitypackage` assembly and validation uses Python, tar, gzip, and checksum +tools in cloud CI. Unity is not installed, authenticated, or invoked by any +release workflow; EditMode and PlayMode validation remain local gates. diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml index 073192a..7433d69 100644 --- a/.github/workflows/npm-publish.yml +++ b/.github/workflows/npm-publish.yml @@ -1,100 +1,172 @@ -name: Publish to NPM +name: Publish release artifacts -# Manual, on-demand publishing: dispatch this workflow to publish the current package.json -# version. The publish step is re-runnable (it skips a version already on the registry) and -# uses npm Trusted Publishing through GitHub OIDC. +# A tag created by release-tag.yml is the only publication trigger. The tag is +# created with a GitHub App token so this event is not suppressed by GitHub's +# GITHUB_TOKEN recursion protection. on: - workflow_dispatch: - inputs: - dry_run: - description: "Pack and report the version/dist-tag without publishing" - type: boolean - default: false + push: + tags: + - "v*" concurrency: - group: publish-npm + group: publish-${{ github.ref_name }} cancel-in-progress: false permissions: contents: read jobs: - publish: - name: Publish npm package + package: + name: Validate and package release runs-on: ubuntu-latest - timeout-minutes: 15 - permissions: - contents: read - id-token: write # required for npm Trusted Publishing through OIDC + timeout-minutes: 20 steps: - - name: Checkout + - name: Checkout exact tag uses: actions/checkout@v7 with: + ref: ${{ github.ref }} + fetch-depth: 0 persist-credentials: false - name: Set up Node.js uses: actions/setup-node@v7 with: node-version: "24" - registry-url: "https://registry.npmjs.org" package-manager-cache: false - - name: Pack and resolve version / dist-tag - id: pack + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: "3.12" + + - name: Validate release metadata + id: release run: | set -euo pipefail - pkg="$(jq -r '.name' package.json)" - ver="$(jq -r '.version' package.json)" - if [ -z "${pkg}" ] || [ "${pkg}" = "null" ] || [ -z "${ver}" ] || [ "${ver}" = "null" ]; then - echo "::error::package.json is missing name or version." + version="${GITHUB_REF_NAME#v}" + python3 scripts/release/release_tools.py validate-release --root . --version "${version}" + tag="v${version}" + if [ "$(git describe --tags --exact-match HEAD 2>/dev/null || true)" != "${tag}" ]; then + echo "::error::Checked-out commit is not the exact release tag ${tag}." exit 1 fi - # rc/alpha/beta/preview pre-releases publish under the "next" dist-tag; stable under "latest". - if printf '%s' "${ver}" | grep -qiE '\-(rc|alpha|beta|preview)'; then - npm_tag="next" - else - npm_tag="latest" - fi - package_file="$(npm pack --json | jq -r '.[0].filename')" - { - echo "pkg=${pkg}" - echo "ver=${ver}" - echo "npm_tag=${npm_tag}" - echo "package_file=${package_file}" - } >> "${GITHUB_OUTPUT}" - echo "Prepared ${pkg}@${ver} -> dist-tag '${npm_tag}' (${package_file})." - - - name: Verify npm trusted publishing toolchain + echo "version=${version}" >> "${GITHUB_OUTPUT}" + + - name: Run non-Unity repository checks + run: | + set -euo pipefail + npm run lint:llm + pwsh -NoProfile -File scripts/tests/run-all.ps1 + + - name: Pack and verify npm payload + id: npm + env: + VERSION: ${{ steps.release.outputs.version }} + run: | + set -euo pipefail + node -e 'const p=require("./package.json"); if (p.version!==process.env.VERSION || p.name!=="com.wallstop-studios.data-visualizer") process.exit(1);' + npm pack --json --pack-destination "${RUNNER_TEMP}" > "${RUNNER_TEMP}/npm-pack.json" + package_file="${RUNNER_TEMP}/$(jq -r '.[0].filename' "${RUNNER_TEMP}/npm-pack.json")" + [ -f "${package_file}" ] + while IFS= read -r member; do + case "${member}" in + package/Editor|package/Editor.meta|package/Editor/*|package/Runtime|package/Runtime.meta|package/Runtime/*|package/Tests|package/Tests.meta|package/Tests/*|package/docs|package/docs.meta|package/docs/*|package/CHANGELOG.md|package/CHANGELOG.md.meta|package/README.md|package/README.md.meta|package/LICENSE|package/LICENSE.meta|package/package.json|package/package.json.meta) ;; + *) echo "::error::Unexpected npm payload member ${member}"; exit 1 ;; + esac + done < <(tar -tzf "${package_file}" | sed 's#/$##') + sha256sum "${package_file}" > "${package_file}.sha256" + echo "file=${package_file}" >> "${GITHUB_OUTPUT}" + + - name: Build and validate Unity package + id: unity + env: + VERSION: ${{ steps.release.outputs.version }} + run: | + set -euo pipefail + output="${RUNNER_TEMP}/com.wallstop-studios.data-visualizer-${VERSION}.unitypackage" + python3 scripts/release/release_tools.py build-unitypackage --root . --output "${output}" + python3 scripts/release/release_tools.py validate-unitypackage --archive "${output}" --checksum "${output}.sha256" --root . + echo "file=${output}" >> "${GITHUB_OUTPUT}" + + - name: Upload validated release artifacts + uses: actions/upload-artifact@v4 + with: + name: release-${{ github.ref_name }} + if-no-files-found: error + path: | + ${{ steps.npm.outputs.file }} + ${{ steps.npm.outputs.file }}.sha256 + ${{ steps.unity.outputs.file }} + ${{ steps.unity.outputs.file }}.sha256 + + publish: + name: Publish npm and GitHub Release + needs: package + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + contents: write + id-token: write + steps: + - name: Checkout exact tag + uses: actions/checkout@v7 + with: + ref: ${{ github.ref }} + fetch-depth: 0 + persist-credentials: false + + - name: Set up Node.js for trusted publishing + uses: actions/setup-node@v7 + with: + node-version: "24" + registry-url: "https://registry.npmjs.org" + package-manager-cache: false + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: "3.12" + + - name: Download validated artifacts + uses: actions/download-artifact@v4 + with: + name: release-${{ github.ref_name }} + path: "${{ runner.temp }}/release" + + - name: Publish exact npm version + env: + VERSION: ${{ github.ref_name }} run: | set -euo pipefail - node --version - npm --version + version="${VERSION#v}" + package_file="$(find "${RUNNER_TEMP}/release" -maxdepth 1 -name '*.tgz' -print -quit)" + [ -n "${package_file}" ] required_npm="11.5.1" current_npm="$(npm --version)" if [ "$(printf '%s\n' "${required_npm}" "${current_npm}" | sort -V | head -n1)" != "${required_npm}" ]; then echo "::error::npm ${current_npm} is too old; trusted publishing requires npm >= ${required_npm}." exit 1 fi + pkg="$(node -p 'require("./package.json").name')" + if npm view "${pkg}@${version}" version >/dev/null 2>&1; then + echo "${pkg}@${version} already exists; skipping irreversible publish." + else + npm_tag="$(python3 -c 'import sys; sys.path.insert(0, "scripts/release"); from release_tools import npm_dist_tag; print(npm_dist_tag(sys.argv[1]))' "${version}")" + npm publish "${package_file}" --access public --tag "${npm_tag}" + fi - - name: Publish to npm with trusted publishing - if: ${{ inputs.dry_run == false }} + - name: Create or update GitHub Release env: - PKG: ${{ steps.pack.outputs.pkg }} - VER: ${{ steps.pack.outputs.ver }} - NPM_TAG: ${{ steps.pack.outputs.npm_tag }} - PACKAGE_FILE: ${{ steps.pack.outputs.package_file }} + TAG: ${{ github.ref_name }} run: | set -euo pipefail - # Re-runnable: skip if this exact name@version is already on the registry (a prior run may - # have published it before failing downstream). npm publish is otherwise irreversible. - if npm view "${PKG}@${VER}" version >/dev/null 2>&1; then - echo "::notice::${PKG}@${VER} is already on the registry; skipping publish." + version="${TAG#v}" + notes="$(python3 scripts/release/release_tools.py changelog-section --root . --version "${version}" | jq -r .section)" + if python3 -c 'import sys; sys.path.insert(0, "scripts/release"); from release_tools import parse_version; print(bool(parse_version(sys.argv[1])[3]))' "${version}" | grep -qx true; then prerelease_flag=--prerelease; else prerelease_flag=; fi + files=("${RUNNER_TEMP}/release"/*) + if gh release view "${TAG}" >/dev/null 2>&1; then + gh release edit "${TAG}" --title "${TAG}" --notes "${notes}" ${prerelease_flag} + gh release upload "${TAG}" "${files[@]}" --clobber else - npm publish "${PACKAGE_FILE}" --access public --tag "${NPM_TAG}" - echo "::notice::Published ${PKG}@${VER} to dist-tag '${NPM_TAG}'." + gh release create "${TAG}" "${files[@]}" --title "${TAG}" --notes "${notes}" ${prerelease_flag} fi - - - name: Dry-run summary - if: ${{ inputs.dry_run == true }} - run: | - echo "Dry run: would publish ${{ steps.pack.outputs.pkg }}@${{ steps.pack.outputs.ver }} to dist-tag '${{ steps.pack.outputs.npm_tag }}'." diff --git a/.github/workflows/release-prepare.yml b/.github/workflows/release-prepare.yml new file mode 100644 index 0000000..1a0c25c --- /dev/null +++ b/.github/workflows/release-prepare.yml @@ -0,0 +1,133 @@ +name: Prepare release + +on: + workflow_dispatch: + inputs: + bump: + description: "Version bump (choose none when providing an explicit version)" + type: choice + required: true + default: none + options: + - none + - patch + - minor + - major + version: + description: "Explicit SemVer (leave empty when using a bump)" + type: string + required: false + release_date: + description: "Release date in YYYY-MM-DD (defaults to today)" + type: string + required: false + dry_run: + description: "Validate and report without modifying or opening a PR" + type: boolean + required: true + default: false + +concurrency: + group: release-prepare + cancel-in-progress: false + +permissions: + contents: read + +jobs: + prepare: + name: Prepare versioned release PR + if: ${{ github.ref == 'refs/heads/main' }} + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout default branch + uses: actions/checkout@v7 + with: + ref: main + fetch-depth: 0 + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: "3.12" + + - name: Prepare release files + id: release + env: + BUMP: ${{ inputs.bump }} + EXPLICIT_VERSION: ${{ inputs.version }} + RELEASE_DATE: ${{ inputs.release_date }} + DRY_RUN: ${{ inputs.dry_run }} + run: | + set -euo pipefail + args=(--root .) + if [ "${BUMP}" != "none" ]; then + args+=(--bump "${BUMP}") + elif [ -n "${EXPLICIT_VERSION}" ]; then + args+=(--version "${EXPLICIT_VERSION}") + else + echo "::error::Choose a bump or provide an explicit version." + exit 1 + fi + if [ -n "${RELEASE_DATE}" ]; then + args+=(--date "${RELEASE_DATE}") + fi + if [ "${DRY_RUN}" = "true" ]; then + args+=(--dry-run) + fi + result="$(python3 scripts/release/release_tools.py prepare "${args[@]}")" + echo "${result}" + version="$(printf '%s' "${result}" | jq -r .version)" + current="$(printf '%s' "${result}" | jq -r .current)" + { + echo "version=${version}" + echo "current=${current}" + } >> "${GITHUB_OUTPUT}" + + - name: Validate prepared release tree + if: ${{ inputs.dry_run == false }} + env: + VERSION: ${{ steps.release.outputs.version }} + run: | + set -euo pipefail + python3 scripts/release/release_tools.py validate-release --root . --version "${VERSION}" + npm pack --dry-run >/dev/null + git diff --check + + - name: Create narrowly scoped GitHub App token + if: ${{ inputs.dry_run == false }} + id: app-token + uses: actions/create-github-app-token@v2 + with: + app-id: ${{ secrets.AUTO_COMMIT_APP_ID }} + private-key: ${{ secrets.AUTO_COMMIT_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: DataVisualizer + permission-contents: write + permission-pull-requests: write + + - name: Commit and open release PR + if: ${{ inputs.dry_run == false }} + env: + APP_TOKEN: ${{ steps.app-token.outputs.token }} + VERSION: ${{ steps.release.outputs.version }} + CURRENT: ${{ steps.release.outputs.current }} + run: | + set -euo pipefail + branch="release/v${VERSION}" + if git ls-remote --exit-code --heads origin "${branch}" >/dev/null 2>&1; then + echo "::error::Release branch ${branch} already exists; refusing to overwrite it." + exit 1 + fi + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add package.json .llm/context.md CHANGELOG.md + git commit -m "Bump version from ${CURRENT} to ${VERSION}" + remote="https://x-access-token:${APP_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" + git push "${remote}" "HEAD:${branch}" + export GH_TOKEN="${APP_TOKEN}" + gh pr create --repo "${GITHUB_REPOSITORY}" --head "${branch}" --base main \ + --title "Bump version from ${CURRENT} to ${VERSION}" \ + --body "Automated release preparation for ${VERSION}. The tag workflow will validate and create v${VERSION} after this PR is merged." diff --git a/.github/workflows/release-tag.yml b/.github/workflows/release-tag.yml new file mode 100644 index 0000000..3996901 --- /dev/null +++ b/.github/workflows/release-tag.yml @@ -0,0 +1,80 @@ +name: Tag merged release + +on: + pull_request: + branches: [main] + types: [closed] + +concurrency: + group: release-tag-${{ github.event.pull_request.head.ref }} + cancel-in-progress: false + +permissions: + contents: read + +jobs: + tag: + name: Tag merged release PR + if: ${{ github.event.pull_request.merged == true && startsWith(github.event.pull_request.head.ref, 'release/v') }} + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout merged default branch + uses: actions/checkout@v7 + with: + ref: main + fetch-depth: 0 + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: "3.12" + + - name: Verify release tree and derive tag + id: release + env: + RELEASE_BRANCH: ${{ github.event.pull_request.head.ref }} + run: | + set -euo pipefail + case "${RELEASE_BRANCH}" in + release/v*) ;; + *) echo "::error::Unexpected release branch ${RELEASE_BRANCH}"; exit 1 ;; + esac + version="${RELEASE_BRANCH#release/v}" + python3 scripts/release/release_tools.py validate-release --root . --version "${version}" + tag="v${version}" + echo "version=${version}" >> "${GITHUB_OUTPUT}" + echo "tag=${tag}" >> "${GITHUB_OUTPUT}" + + - name: Create narrowly scoped GitHub App token + id: app-token + uses: actions/create-github-app-token@v2 + with: + app-id: ${{ secrets.AUTO_COMMIT_APP_ID }} + private-key: ${{ secrets.AUTO_COMMIT_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: DataVisualizer + permission-contents: write + + - name: Create immutable annotated tag + env: + APP_TOKEN: ${{ steps.app-token.outputs.token }} + TAG: ${{ steps.release.outputs.tag }} + run: | + set -euo pipefail + git fetch --tags --force origin + if git rev-parse -q --verify "refs/tags/${TAG}" >/dev/null; then + existing="$(git rev-list -n 1 "${TAG}^{commit}")" + if [ "${existing}" != "$(git rev-parse HEAD)" ]; then + echo "::error::${TAG} already exists at ${existing}; refusing to retarget it." + exit 1 + fi + echo "${TAG} already points at the merged release commit; leaving it unchanged." + exit 0 + fi + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git tag -a "${TAG}" -m "Release ${TAG}" "$(git rev-parse HEAD)" + remote="https://x-access-token:${APP_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" + git push "${remote}" "${TAG}" diff --git a/.llm/context.md b/.llm/context.md index 08ad25f..97667ee 100644 --- a/.llm/context.md +++ b/.llm/context.md @@ -31,13 +31,12 @@ Editor/ Editor-only assembly (WallstopStudios.DataVisua Styles/ USS stylesheet + style constants UI/ Reusable UI Toolkit controls Unity/ Asset postprocessor - Utilities/ ReadOnly attribute + drawer, monitor utility + Utilities/ ReadOnly attribute + drawer, monitor and path utilities Extensions/ ObjectId, Color, ListView compat, UI extensions Fonts/ Bundled UI font Runtime/ Runtime assembly (WallstopStudios.DataVisualizer.asmdef) DataVisualizer/ BaseDataObject, lifecycle interfaces, attributes Extensions/ Shared extension methods - Helper/ Directory/Path/Reflection helpers Tests/Editor/ EditMode tests (WallstopStudios.DataVisualizer.Tests.Editor.asmdef) docs/ Screenshots and README assets scripts/ PowerShell automation for this .llm harness diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..b4bd34c --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,22 @@ +# Changelog + +All notable changes to this package are documented in this file. + +The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and the project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Changed + +- Reduced the player-facing runtime assembly by moving editor-only helpers out of it. + +## [0.0.37] - 2026-07-07 + +### Changed + +- Improved asynchronous ScriptableObject loading and settings persistence. +- Added Unity 6 `EntityId` compatibility. + +[Unreleased]: https://github.com/wallstop/DataVisualizer/compare/v0.0.37...HEAD +[0.0.37]: https://github.com/wallstop/DataVisualizer/releases/tag/v0.0.37 diff --git a/CHANGELOG.md.meta b/CHANGELOG.md.meta new file mode 100644 index 0000000..9c1aa4c --- /dev/null +++ b/CHANGELOG.md.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: d0a3ccfb6d2b4f1aa76f7c2fd4d7dc34 +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/package.json b/package.json index ac020d1..52d5655 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ }, "author": "wallstop studios (https://wallstopstudios.com)", "homepage": "https://github.com/wallstop/DataVisualizer#readme", + "changelog": "https://github.com/wallstop/DataVisualizer/blob/main/CHANGELOG.md", "main": "README.md", "files": [ "Editor", @@ -33,6 +34,8 @@ "Tests.meta", "docs", "docs.meta", + "CHANGELOG.md", + "CHANGELOG.md.meta", "README.md.meta", "LICENSE.meta", "package.json.meta" @@ -81,6 +84,5 @@ - diff --git a/scripts/release.meta b/scripts/release.meta new file mode 100644 index 0000000..081fc1c --- /dev/null +++ b/scripts/release.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: a361874a0e6f4f0c8d0f7b2c7e84d4d2 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/scripts/release/release_tools.py b/scripts/release/release_tools.py new file mode 100644 index 0000000..7273a0d --- /dev/null +++ b/scripts/release/release_tools.py @@ -0,0 +1,462 @@ +#!/usr/bin/env python3 +"""Dependency-free helpers used by the package release workflows. + +The Unity package builder intentionally operates on committed source and .meta +files only. It does not invoke Unity or depend on a Unity installation. +""" + +from __future__ import annotations + +import argparse +import datetime as dt +import gzip +import hashlib +import io +import json +import re +import tarfile +from dataclasses import dataclass +from pathlib import Path +from typing import Iterable, Sequence + + +SEMVER = re.compile( + r"^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)" + r"(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?" + r"(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$" +) +GUID = re.compile(r"^[0-9a-fA-F]{32}$") +GUID_LINE = re.compile(r"(?m)^guid:\s*([^\s#]+)\s*$") +UNRELEASED = re.compile(r"(?m)^## \[Unreleased\][ \t]*\r?$") +RELEASE_HEADER = re.compile( + r"(?m)^## \[([^\]]+)\] - (\d{4}-\d{2}-\d{2})[ \t]*\r?$" +) +DEFAULT_INCLUDES = ("Editor", "Runtime", "Tests") + + +class ReleaseError(ValueError): + """Raised when release input or generated artifacts are invalid.""" + + +@dataclass(frozen=True) +class PackageEntry: + guid: str + pathname: str + metadata: bytes + asset: bytes | None + + +def parse_version(version: str) -> tuple[int, int, int, str | None, str | None]: + match = SEMVER.fullmatch(version) + if not match: + raise ReleaseError(f"Invalid semantic version: {version!r}") + major, minor, patch, prerelease, build = match.groups() + return int(major), int(minor), int(patch), prerelease, build + + +def bump_version(version: str, part: str) -> str: + major, minor, patch, _, _ = parse_version(version) + if part == "major": + major, minor, patch = major + 1, 0, 0 + elif part == "minor": + minor, patch = minor + 1, 0 + elif part == "patch": + patch += 1 + else: + raise ReleaseError(f"Unsupported bump kind: {part!r}") + return f"{major}.{minor}.{patch}" + + +def compare_versions(left: str, right: str) -> int: + left_parts = parse_version(left) + right_parts = parse_version(right) + for left_value, right_value in zip(left_parts[:3], right_parts[:3]): + if left_value != right_value: + return 1 if left_value > right_value else -1 + left_pre, right_pre = left_parts[3], right_parts[3] + if left_pre is None or right_pre is None: + if left_pre == right_pre: + return 0 + return 1 if left_pre is None else -1 + left_identifiers, right_identifiers = left_pre.split("."), right_pre.split(".") + for left_identifier, right_identifier in zip(left_identifiers, right_identifiers): + if left_identifier == right_identifier: + continue + left_numeric, right_numeric = left_identifier.isdigit(), right_identifier.isdigit() + if left_numeric and right_numeric: + return 1 if int(left_identifier) > int(right_identifier) else -1 + if left_numeric != right_numeric: + return -1 if left_numeric else 1 + return 1 if left_identifier > right_identifier else -1 + if len(left_identifiers) == len(right_identifiers): + return 0 + return 1 if len(left_identifiers) > len(right_identifiers) else -1 + + +def resolve_version(current: str, bump: str | None, explicit: str | None) -> str: + if bool(bump) == bool(explicit): + raise ReleaseError("Specify exactly one of bump or explicit version") + candidate = explicit if explicit else bump_version(current, bump) + parse_version(candidate) + if compare_versions(candidate, current) <= 0: + raise ReleaseError("Release version must be greater than the current package version") + return candidate + + +def npm_dist_tag(version: str) -> str: + _, _, _, prerelease, _ = parse_version(version) + if prerelease and prerelease.split(".", 1)[0].lower() in {"rc", "alpha", "beta", "preview"}: + return "next" + return "latest" + + +def load_package(root: Path) -> dict: + package_path = root / "package.json" + try: + package = json.loads(package_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as error: + raise ReleaseError(f"Cannot read {package_path}: {error}") from error + if not isinstance(package, dict) or not isinstance(package.get("version"), str): + raise ReleaseError("package.json must contain a string version") + parse_version(package["version"]) + return package + + +def sync_context(text: str, version: str) -> str: + pattern = re.compile(r"(?m)^(?P- \*\*Version\*\*:\s*)(?P[^\r\n]+)") + matches = list(pattern.finditer(text)) + if len(matches) != 1: + raise ReleaseError(".llm/context.md must contain exactly one package version line") + match = matches[0] + return text[: match.start()] + f"- **Version**: {version}" + text[match.end() :] + + +def rotate_changelog(text: str, version: str, release_date: str) -> str: + try: + date = dt.date.fromisoformat(release_date) + except ValueError as error: + raise ReleaseError(f"Invalid release date: {release_date!r}") from error + if RELEASE_HEADER.search(text): + for match in RELEASE_HEADER.finditer(text): + if match.group(1) == version: + raise ReleaseError(f"Changelog already contains {version}") + unreleased = UNRELEASED.search(text) + if unreleased is None: + raise ReleaseError("CHANGELOG.md must contain an ## [Unreleased] section") + next_header = re.search(r"(?m)^## \[", text[unreleased.end() :]) + body_end = unreleased.end() + next_header.start() if next_header else len(text) + body = text[unreleased.end() : body_end].strip() + if not body: + raise ReleaseError("The Unreleased changelog section must contain changes") + rendered_date = date.isoformat() + replacement = f"## [Unreleased]\n\n## [{version}] - {rendered_date}\n\n{body}\n\n" + return text[: unreleased.start()] + replacement + text[body_end:].lstrip("\r\n") + + +def changelog_contains_version(text: str, version: str) -> bool: + return any(match.group(1) == version for match in RELEASE_HEADER.finditer(text)) + + +def changelog_section(text: str, version: str) -> str: + for match in RELEASE_HEADER.finditer(text): + if match.group(1) != version: + continue + section_end = re.search(r"(?m)^## ", text[match.end() :]) + end = match.end() + section_end.start() if section_end else len(text) + section = text[match.start() : end].strip() + return re.sub(r"(?m)^\[[^\]]+\]:\s+\S+\s*$", "", section).strip() + raise ReleaseError(f"CHANGELOG.md has no dated section for {version}") + + +def prepare_release( + root: Path, + bump: str | None, + explicit: str | None, + release_date: str, + dry_run: bool, +) -> dict: + package_path = root / "package.json" + context_path = root / ".llm" / "context.md" + changelog_path = root / "CHANGELOG.md" + package = load_package(root) + current = package["version"] + version = resolve_version(current, bump, explicit) + context = context_path.read_text(encoding="utf-8") + changelog = changelog_path.read_text(encoding="utf-8") + new_context = sync_context(context, version) + new_changelog = rotate_changelog(changelog, version, release_date) + package["version"] = version + package_text = json.dumps(package, indent=2, ensure_ascii=False) + "\n" + result = {"current": current, "version": version, "date": release_date, "dry_run": dry_run} + if not dry_run: + package_path.write_text(package_text, encoding="utf-8") + context_path.write_text(new_context, encoding="utf-8") + changelog_path.write_text(new_changelog, encoding="utf-8") + return result + + +def validate_release_tree(root: Path, expected_version: str | None = None) -> dict: + package = load_package(root) + version = package["version"] + if expected_version and version != expected_version: + raise ReleaseError(f"package.json has {version}, expected {expected_version}") + changelog = (root / "CHANGELOG.md").read_text(encoding="utf-8") + if not changelog_contains_version(changelog, version): + raise ReleaseError(f"CHANGELOG.md has no dated section for {version}") + context = (root / ".llm" / "context.md").read_text(encoding="utf-8") + if sync_context(context, version) != context: + raise ReleaseError(".llm/context.md version is not synchronized with package.json") + return {"name": package.get("name"), "version": version, "changelog": True} + + +def _safe_relative(path: str) -> str: + normalized = path.replace("\\", "/") + parts = normalized.split("/") + if not normalized or normalized.startswith("/") or "" in parts or any( + part in (".", "..") for part in parts + ): + raise ReleaseError(f"Unsafe asset path: {path!r}") + return normalized + + +def _guid_from_meta(metadata: bytes, path: Path) -> str: + try: + text = metadata.decode("utf-8") + except UnicodeDecodeError as error: + raise ReleaseError(f"Metadata is not UTF-8: {path}") from error + matches = GUID_LINE.findall(text) + if len(matches) != 1 or not GUID.fullmatch(matches[0]): + raise ReleaseError(f"Metadata must contain one valid GUID: {path}") + return matches[0].lower() + + +def _meta_path(source: Path) -> Path: + return source.with_name(source.name + ".meta") + + +def source_entries(root: Path, includes: Sequence[str]) -> list[PackageEntry]: + entries: list[PackageEntry] = [] + seen_guids: dict[str, str] = {} + seen_paths: set[str] = set() + for include in includes: + source_root = root / include + if not source_root.is_dir() or source_root.is_symlink(): + raise ReleaseError(f"Shipped source root is missing or unsafe: {include}") + paths = [source_root, *sorted(source_root.rglob("*"), key=lambda item: item.as_posix())] + for source in paths: + if source.is_symlink(): + raise ReleaseError(f"Symlinks are not allowed in shipped payload: {source}") + if source.name.endswith(".meta"): + continue + if ( + source.is_dir() + and source != source_root + and not _meta_path(source).is_file() + and not any(source.iterdir()) + ): + # Empty directories can be left behind by a local move/delete. They + # are not package payload and do not exist in a clean checkout. + continue + relative = _safe_relative(source.relative_to(root).as_posix()) + metadata_path = _meta_path(source) + if not metadata_path.is_file() or metadata_path.is_symlink(): + raise ReleaseError(f"Missing committed metadata for {relative}") + metadata = metadata_path.read_bytes() + guid = _guid_from_meta(metadata, metadata_path) + if guid in seen_guids: + raise ReleaseError(f"Duplicate GUID {guid}: {seen_guids[guid]} and {relative}") + if relative in seen_paths: + raise ReleaseError(f"Duplicate shipped path: {relative}") + seen_guids[guid] = relative + seen_paths.add(relative) + asset = None if source.is_dir() else source.read_bytes() + entries.append(PackageEntry(guid, relative, metadata, asset)) + return sorted(entries, key=lambda entry: (entry.guid, entry.pathname)) + + +def _tar_bytes(entries: Iterable[PackageEntry]) -> bytes: + raw = io.BytesIO() + with tarfile.open(fileobj=raw, mode="w", format=tarfile.USTAR_FORMAT) as archive: + for entry in entries: + for name, content in (("pathname", entry.pathname.encode("utf-8")), ("asset.meta", entry.metadata)): + info = tarfile.TarInfo(f"{entry.guid}/{name}") + info.size = len(content) + info.mode = 0o644 + info.mtime = 0 + info.uid = info.gid = 0 + info.uname = info.gname = "" + archive.addfile(info, io.BytesIO(content)) + if entry.asset is not None: + info = tarfile.TarInfo(f"{entry.guid}/asset") + info.size = len(entry.asset) + info.mode = 0o644 + info.mtime = 0 + info.uid = info.gid = 0 + info.uname = info.gname = "" + archive.addfile(info, io.BytesIO(entry.asset)) + compressed = io.BytesIO() + with gzip.GzipFile(fileobj=compressed, mode="wb", filename="", mtime=0) as output: + output.write(raw.getvalue()) + return compressed.getvalue() + + +def write_checksum(path: Path, data: bytes) -> Path: + checksum_path = path.with_name(path.name + ".sha256") + checksum_path.write_text(f"{hashlib.sha256(data).hexdigest()} {path.name}\n", encoding="ascii") + return checksum_path + + +def build_unitypackage(root: Path, output: Path, includes: Sequence[str]) -> dict: + entries = source_entries(root, includes) + if not entries: + raise ReleaseError("Cannot build an empty Unity package") + data = _tar_bytes(entries) + output.parent.mkdir(parents=True, exist_ok=True) + output.write_bytes(data) + checksum = write_checksum(output, data) + return {"archive": str(output), "checksum": str(checksum), "entries": len(entries), "bytes": len(data)} + + +def _read_archive(path: Path) -> dict[str, dict[str, bytes]]: + groups: dict[str, dict[str, bytes]] = {} + with gzip.open(path, "rb") as compressed: + with tarfile.open(fileobj=compressed, mode="r:") as archive: + for member in archive: + if not member.isfile(): + raise ReleaseError(f"Archive member is not a regular file: {member.name}") + parts = member.name.split("/") + if len(parts) != 2 or not GUID.fullmatch(parts[0]) or parts[1] not in { + "pathname", + "asset.meta", + "asset", + }: + raise ReleaseError(f"Invalid Unity package member: {member.name}") + group = groups.setdefault(parts[0].lower(), {}) + if parts[1] in group: + raise ReleaseError(f"Duplicate Unity package member: {member.name}") + payload = archive.extractfile(member) + if payload is None: + raise ReleaseError(f"Cannot read Unity package member: {member.name}") + group[parts[1]] = payload.read() + if not groups: + raise ReleaseError("Unity package archive is empty") + return groups + + +def validate_unitypackage( + archive: Path, + source_root: Path | None, + includes: Sequence[str], + checksum: Path | None, +) -> dict: + data = archive.read_bytes() + if checksum: + expected = checksum.read_text(encoding="ascii").split()[0].lower() + actual = hashlib.sha256(data).hexdigest() + if expected != actual: + raise ReleaseError(f"Checksum mismatch for {archive}") + groups = _read_archive(archive) + seen_paths: set[str] = set() + for guid, members in groups.items(): + if set(members) - {"pathname", "asset.meta", "asset"}: + raise ReleaseError(f"Unexpected members for GUID {guid}") + if set(members) < {"pathname", "asset.meta"}: + raise ReleaseError(f"GUID {guid} is missing pathname or asset.meta") + try: + pathname = _safe_relative(members["pathname"].decode("utf-8").strip()) + except UnicodeDecodeError as error: + raise ReleaseError(f"Pathname for GUID {guid} is not UTF-8") from error + if pathname in seen_paths: + raise ReleaseError(f"Duplicate asset pathname: {pathname}") + seen_paths.add(pathname) + metadata_guid = _guid_from_meta(members["asset.meta"], Path(f"{guid}/asset.meta")) + if metadata_guid != guid: + raise ReleaseError(f"GUID mismatch for {pathname}: {metadata_guid} != {guid}") + if source_root: + expected = {entry.guid: entry for entry in source_entries(source_root, includes)} + actual = { + guid: PackageEntry( + guid, + members["pathname"].decode("utf-8").strip(), + members["asset.meta"], + members.get("asset"), + ) + for guid, members in groups.items() + } + if set(actual) != set(expected): + raise ReleaseError("Unity package GUID set does not match shipped source") + for guid, entry in expected.items(): + received = actual[guid] + if received.pathname != entry.pathname or received.metadata != entry.metadata: + raise ReleaseError(f"Unity package metadata mismatch for {entry.pathname}") + if (received.asset is None) != (entry.asset is None): + raise ReleaseError(f"Unity package asset presence mismatch for {entry.pathname}") + if received.asset is not None and received.asset != entry.asset: + raise ReleaseError(f"Unity package asset mismatch for {entry.pathname}") + return {"archive": str(archive), "entries": len(groups), "checksum": hashlib.sha256(data).hexdigest()} + + +def _includes(values: Sequence[str] | None) -> tuple[str, ...]: + return tuple(values) if values else DEFAULT_INCLUDES + + +def _parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__) + subparsers = parser.add_subparsers(dest="command", required=True) + + prepare = subparsers.add_parser("prepare") + prepare.add_argument("--root", type=Path, default=Path(".")) + bump = prepare.add_mutually_exclusive_group(required=True) + bump.add_argument("--bump", choices=("patch", "minor", "major")) + bump.add_argument("--version") + prepare.add_argument("--date", default=dt.date.today().isoformat()) + prepare.add_argument("--dry-run", action="store_true") + + tree = subparsers.add_parser("validate-release") + tree.add_argument("--root", type=Path, default=Path(".")) + tree.add_argument("--version") + + section = subparsers.add_parser("changelog-section") + section.add_argument("--root", type=Path, default=Path(".")) + section.add_argument("--version", required=True) + + build = subparsers.add_parser("build-unitypackage") + build.add_argument("--root", type=Path, default=Path(".")) + build.add_argument("--output", type=Path, required=True) + build.add_argument("--include", action="append") + + validate = subparsers.add_parser("validate-unitypackage") + validate.add_argument("--archive", type=Path, required=True) + validate.add_argument("--checksum", type=Path) + validate.add_argument("--root", type=Path) + validate.add_argument("--include", action="append") + return parser + + +def main(argv: Sequence[str] | None = None) -> int: + args = _parser().parse_args(argv) + if args.command == "prepare": + result = prepare_release(args.root, args.bump, args.version, args.date, args.dry_run) + elif args.command == "validate-release": + result = validate_release_tree(args.root, args.version) + elif args.command == "changelog-section": + result = {"section": changelog_section((args.root / "CHANGELOG.md").read_text(encoding="utf-8"), args.version)} + elif args.command == "build-unitypackage": + result = build_unitypackage(args.root, args.output, _includes(args.include)) + else: + result = validate_unitypackage( + args.archive, + args.root, + _includes(args.include), + args.checksum, + ) + print(json.dumps(result, sort_keys=True)) + return 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except ReleaseError as error: + print(f"release_tools: error: {error}") + raise SystemExit(2) diff --git a/scripts/release/release_tools.py.meta b/scripts/release/release_tools.py.meta new file mode 100644 index 0000000..7d87d74 --- /dev/null +++ b/scripts/release/release_tools.py.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 4cf9e58bbfbf4fd18f855d8cfe5a05ed +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/scripts/release/test_release_tools.py b/scripts/release/test_release_tools.py new file mode 100644 index 0000000..2231bb9 --- /dev/null +++ b/scripts/release/test_release_tools.py @@ -0,0 +1,153 @@ +#!/usr/bin/env python3 +"""Tests for the dependency-free release tooling.""" + +from __future__ import annotations + +import hashlib +import sys +import tempfile +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) + +from release_tools import ( # noqa: E402 + ReleaseError, + build_unitypackage, + bump_version, + npm_dist_tag, + prepare_release, + resolve_version, + rotate_changelog, + source_entries, + sync_context, + changelog_section, + validate_unitypackage, + validate_release_tree, +) + + +def write_meta(path: Path, guid: str, folder: bool = False) -> None: + content = f"fileFormatVersion: 2\nguid: {guid}\n" + if folder: + content += "folderAsset: yes\nDefaultImporter:\n" + else: + content += "DefaultImporter:\n" + path.with_name(path.name + ".meta").write_text(content, encoding="utf-8") + + +class ReleaseToolsTests(unittest.TestCase): + def test_version_bumps_and_explicit_versions(self) -> None: + self.assertEqual(bump_version("1.2.3", "major"), "2.0.0") + self.assertEqual(bump_version("1.2.3-rc.1", "patch"), "1.2.4") + self.assertEqual(resolve_version("1.2.3", None, "1.3.0-preview.1"), "1.3.0-preview.1") + self.assertEqual(npm_dist_tag("1.3.0-rc.1"), "next") + self.assertEqual(npm_dist_tag("1.3.0-alpha"), "next") + self.assertEqual(npm_dist_tag("1.3.0-test.1"), "latest") + self.assertEqual(npm_dist_tag("1.3.0"), "latest") + with self.assertRaises(ReleaseError): + resolve_version("1.2.3", "patch", "1.3.0") + with self.assertRaises(ReleaseError): + resolve_version("1.2.3", None, "1.2.2") + + def test_changelog_rotation_preserves_unreleased_heading(self) -> None: + source = "# Changelog\n\n## [Unreleased]\n\n### Fixed\n\n- A fix.\n\n## [1.0.0] - 2026-01-01\n" + rotated = rotate_changelog(source, "1.1.0", "2026-09-09") + self.assertIn("## [Unreleased]\n\n## [1.1.0] - 2026-09-09", rotated) + self.assertIn("### Fixed\n\n- A fix.", rotated) + with self.assertRaises(ReleaseError): + rotate_changelog("# Changelog\n\n## [Unreleased]\n", "1.1.0", "2026-09-09") + + def test_prepare_dry_run_and_validate_release_tree(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + (root / ".llm").mkdir() + (root / "package.json").write_text( + '{"name": "fixture", "version": "1.0.0"}\n', encoding="utf-8" + ) + context = "# Instructions\n\n- **Version**: 1.0.0\n" + (root / ".llm" / "context.md").write_text(context, encoding="utf-8") + changelog = "# Changelog\n\n## [Unreleased]\n\n### Changed\n\n- Change.\n" + (root / "CHANGELOG.md").write_text(changelog, encoding="utf-8") + result = prepare_release(root, "patch", None, "2026-09-09", True) + self.assertEqual(result["version"], "1.0.1") + self.assertEqual((root / "package.json").read_text(encoding="utf-8"), '{"name": "fixture", "version": "1.0.0"}\n') + prepare_release(root, "patch", None, "2026-09-09", False) + self.assertEqual(validate_release_tree(root)["version"], "1.0.1") + + def test_unitypackage_is_deterministic_and_matches_source(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + for index, folder in enumerate(("Editor", "Runtime", "Tests"), 1): + folder_path = root / folder + folder_path.mkdir() + write_meta(folder_path, f"{index:032x}", folder=True) + source = folder_path / f"Sample{index}.txt" + source.write_bytes(f"sample-{index}".encode("ascii")) + write_meta(source, f"{index + 3:032x}") + # Simulates a stale empty directory left by a local move. + (root / "Runtime" / "EmptyLeftover").mkdir() + first = root / "one.unitypackage" + second = root / "two.unitypackage" + build_unitypackage(root, first, ("Editor", "Runtime", "Tests")) + build_unitypackage(root, second, ("Editor", "Runtime", "Tests")) + self.assertEqual(first.read_bytes(), second.read_bytes()) + self.assertEqual( + validate_unitypackage(first, root, ("Editor", "Runtime", "Tests"), first.with_name(first.name + ".sha256"))["entries"], + 6, + ) + + def test_missing_metadata_and_duplicate_guids_fail_closed(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + editor = root / "Editor" + editor.mkdir() + write_meta(editor, "1".zfill(32), folder=True) + source = editor / "Sample.txt" + source.write_text("sample", encoding="utf-8") + with self.assertRaises(ReleaseError): + source_entries(root, ("Editor",)) + write_meta(source, "1".zfill(32)) + with self.assertRaises(ReleaseError): + source_entries(root, ("Editor",)) + + def test_checksum_mismatch_is_rejected(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + for index, folder in enumerate(("Editor", "Runtime", "Tests"), 1): + folder_path = root / folder + folder_path.mkdir() + write_meta(folder_path, f"{index:032x}", folder=True) + source = folder_path / "Sample.txt" + source.write_text("sample", encoding="utf-8") + write_meta(source, f"{index + 3:032x}") + archive = root / "package.unitypackage" + build_unitypackage(root, archive, ("Editor", "Runtime", "Tests")) + checksum = archive.with_name(archive.name + ".sha256") + checksum.write_text(f"{'0' * 64} {archive.name}\n", encoding="ascii") + with self.assertRaises(ReleaseError): + validate_unitypackage(archive, root, ("Editor", "Runtime", "Tests"), checksum) + + def test_changelog_section_is_ready_for_release_notes(self) -> None: + source = "# Changelog\n\n## [1.2.3] - 2026-09-09\n\n### Changed\n\n- A change.\n\n## [1.2.2] - 2026-01-01\n" + self.assertEqual( + changelog_section(source, "1.2.3"), + "## [1.2.3] - 2026-09-09\n\n### Changed\n\n- A change.", + ) + + def test_workflows_have_release_only_triggers_and_no_unity_runner(self) -> None: + root = Path(__file__).parents[2] + prepare = (root / ".github/workflows/release-prepare.yml").read_text(encoding="utf-8") + tag = (root / ".github/workflows/release-tag.yml").read_text(encoding="utf-8") + publish = (root / ".github/workflows/npm-publish.yml").read_text(encoding="utf-8") + self.assertIn("workflow_dispatch:", prepare) + self.assertIn("github.ref == 'refs/heads/main'", prepare) + self.assertIn("github.event.pull_request.merged == true", tag) + self.assertIn("startsWith(github.event.pull_request.head.ref, 'release/v')", tag) + self.assertIn('tags:\n - "v*"', publish) + self.assertNotIn("actions/unity", publish.lower()) + self.assertNotRegex(publish, r"(?m)^\s*-?\s*unity(?:\s|$)") + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/scripts/release/test_release_tools.py.meta b/scripts/release/test_release_tools.py.meta new file mode 100644 index 0000000..0442187 --- /dev/null +++ b/scripts/release/test_release_tools.py.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 0d2b04ce1c9e48b5a4cfb109b30dc468 +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/scripts/tests/test-release-tools.ps1 b/scripts/tests/test-release-tools.ps1 new file mode 100644 index 0000000..23b8313 --- /dev/null +++ b/scripts/tests/test-release-tools.ps1 @@ -0,0 +1,17 @@ +Set-StrictMode -Version 2.0 + +$repoRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot) +$python = Get-Command python3 -ErrorAction SilentlyContinue +if ($null -eq $python) { + $python = Get-Command python -ErrorAction SilentlyContinue +} +if ($null -eq $python) { + Write-Host 'Python 3 is required for release tool tests.' + exit 1 +} + +& $python.Source (Join-Path $repoRoot 'scripts/release/test_release_tools.py') +if ($LASTEXITCODE -ne 0) { + exit $LASTEXITCODE +} +exit 0 diff --git a/scripts/tests/test-release-tools.ps1.meta b/scripts/tests/test-release-tools.ps1.meta new file mode 100644 index 0000000..018ad0a --- /dev/null +++ b/scripts/tests/test-release-tools.ps1.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 6b0da2ce8562479586b9f1ba2f93d4bf +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: From 2d08b4d018c051f90d8964c04e551d33208f5fed Mon Sep 17 00:00:00 2001 From: wallstop Date: Wed, 9 Sep 2026 15:26:04 +0000 Subject: [PATCH 03/49] Fix editor window monitor fallback --- Editor/DataVisualizer/DataVisualizer.cs | 8 +- .../Utilities/MonitorUtility.cs | 248 ++---------------- Tests/Editor/MonitorUtilityTests.cs | 20 ++ Tests/Editor/MonitorUtilityTests.cs.meta | 2 + 4 files changed, 55 insertions(+), 223 deletions(-) create mode 100644 Tests/Editor/MonitorUtilityTests.cs create mode 100644 Tests/Editor/MonitorUtilityTests.cs.meta diff --git a/Editor/DataVisualizer/DataVisualizer.cs b/Editor/DataVisualizer/DataVisualizer.cs index 8880e44..4c3209c 100644 --- a/Editor/DataVisualizer/DataVisualizer.cs +++ b/Editor/DataVisualizer/DataVisualizer.cs @@ -400,11 +400,11 @@ public static void ShowWindow() float height = Mathf.Max(MinWindowHeight, window.position.height); Rect monitorArea = MonitorUtility.GetPrimaryMonitorRect(); - float centerX = (monitorArea.width - width) / 2f; - float centerY = (monitorArea.height - height) / 2f; + float centerX = monitorArea.x + (monitorArea.width - width) / 2f; + float centerY = monitorArea.y + (monitorArea.height - height) / 2f; - float x = Mathf.Max(0, centerX); - float y = Mathf.Max(0, centerY); + float x = Mathf.Max(monitorArea.x, centerX); + float y = Mathf.Max(monitorArea.y, centerY); window.position = new Rect(x, y, width, height); EditorPrefs.SetBool(PrefsInitialSizeAppliedKey, true); diff --git a/Editor/DataVisualizer/Utilities/MonitorUtility.cs b/Editor/DataVisualizer/Utilities/MonitorUtility.cs index ac9bd95..23fbb58 100644 --- a/Editor/DataVisualizer/Utilities/MonitorUtility.cs +++ b/Editor/DataVisualizer/Utilities/MonitorUtility.cs @@ -1,245 +1,55 @@ namespace WallstopStudios.DataVisualizer.Editor.Utilities { #if UNITY_EDITOR - using System; - using System.Runtime.InteropServices; + using UnityEditor; using UnityEngine; - // For IntPtr, Exception - - // For DllImport, Marshal, StructLayout etc. - public static class MonitorUtility { - public static Rect GetPrimaryMonitorRect() - { - Rect rect = TryGetPrimaryMonitorRect(); - if ((rect.width <= 0 || rect.height <= 0) && Display.displays.Length != 0) - { - rect = new Rect(0, 0, rect.width, rect.height); - } - - return rect; - } + private const float LastResortWidth = 1f; + private const float LastResortHeight = 1f; - private static Rect TryGetPrimaryMonitorRect() + public static Rect GetPrimaryMonitorRect() { try { -#if UNITY_EDITOR_WIN - // --- Windows Implementation (using P/Invoke) --- - return GetPrimaryMonitorRect_Windows_PInvoke(); - -#elif UNITY_EDITOR_OSX - // --- macOS Implementation (using P/Invoke) --- - return GetPrimaryMonitorRect_Mac_PInvoke(); // Renamed for clarity -#elif UNITY_EDITOR_LINUX - // --- Linux Implementation (Placeholder) --- - return Rect.zero; -#else - return Rect.zero; // Fallback for other platforms -#endif + Rect mainWindowRect = EditorGUIUtility.GetMainWindowPosition(); + if (IsUsable(mainWindowRect)) + { + return mainWindowRect; + } } - catch (Exception ex) + catch (System.Exception exception) { - Debug.LogError( - $"Error getting primary monitor rect via platform code: {ex.Message}\n{ex.StackTrace}" + Debug.LogWarning( + $"Unable to read the Unity main window position: {exception.Message}" ); - return Rect.zero; // Return invalid rect on error to trigger fallback } - } - // --- Windows P/Invoke Definitions and Helper --- -#if UNITY_EDITOR_WIN - - [StructLayout(LayoutKind.Sequential)] - private struct POINT - { - public int X; - public int Y; - } - - [StructLayout(LayoutKind.Sequential)] - private struct RECT - { - public int Left; - public int Top; - public int Right; - public int Bottom; - } - - [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] - private struct MONITORINFO - { - public uint cbSize; - public RECT rcMonitor; - public RECT rcWork; - public uint dwFlags; - } - - private const uint MONITOR_DEFAULTTOPRIMARY = 0x00000001; - - [DllImport("user32.dll")] - private static extern IntPtr MonitorFromPoint(POINT pt, uint dwFlags); - - [DllImport("user32.dll", CharSet = CharSet.Unicode)] - [return: MarshalAs(UnmanagedType.Bool)] // Important: Win32 BOOL is not C# bool directly - private static extern bool GetMonitorInfoW(IntPtr hMonitor, ref MONITORINFO lpmi); - - private static Rect GetPrimaryMonitorRect_Windows_PInvoke() - { - // Point (0,0) should be on the primary monitor in virtual screen coords - POINT zeroPoint = new() { X = 0, Y = 0 }; - - // Get the handle to the primary monitor - IntPtr hMonitor = MonitorFromPoint(zeroPoint, MONITOR_DEFAULTTOPRIMARY); - - if (hMonitor == IntPtr.Zero) - { - Debug.LogError( - "PInvoke Error: Could not get primary monitor handle via MonitorFromPoint." - ); - return Rect.zero; - } - - MONITORINFO monitorInfo = new(); - monitorInfo.cbSize = (uint)Marshal.SizeOf(typeof(MONITORINFO)); // Crucial: Set the size field - - // Get monitor information - if (!GetMonitorInfoW(hMonitor, ref monitorInfo)) + Resolution currentResolution = Screen.currentResolution; + if (currentResolution.width > 0 && currentResolution.height > 0) { - Debug.LogError("PInvoke Error: GetMonitorInfoW failed."); - return Rect.zero; + return new Rect(0, 0, currentResolution.width, currentResolution.height); } - // Extract the monitor rectangle (full area, not just working area) - RECT monitorRectWin32 = monitorInfo.rcMonitor; - - // Convert Win32 RECT (Left, Top, Right, Bottom) to Unity Rect (X, Y, Width, Height) - int width = monitorRectWin32.Right - monitorRectWin32.Left; - int height = monitorRectWin32.Bottom - monitorRectWin32.Top; - - return new Rect(monitorRectWin32.Left, monitorRectWin32.Top, width, height); + // Keep callers from producing an invalid or NaN position when Unity has + // not exposed window/display geometry yet (for example during startup). + return new Rect(0, 0, LastResortWidth, LastResortHeight); } -#endif // UNITY_EDITOR_WIN - - // --- macOS P/Invoke Definitions and Helper --- -#if UNITY_EDITOR_OSX - - // Define necessary Objective-C runtime functions via P/Invoke - [DllImport("/System/Library/Frameworks/AppKit.framework/AppKit")] - private static extern IntPtr objc_getClass(string className); - - [DllImport("/System/Library/Frameworks/AppKit.framework/AppKit")] - private static extern IntPtr sel_registerName(string selectorName); - - // Use IntPtr version for most messages, specialized versions for struct returns if needed - [DllImport( - "/System/Library/Frameworks/AppKit.framework/AppKit", - EntryPoint = "objc_msgSend" - )] - private static extern IntPtr objc_msgSend_IntPtr(IntPtr receiver, IntPtr selector); - - [DllImport( - "/System/Library/Frameworks/AppKit.framework/AppKit", - EntryPoint = "objc_msgSend" - )] - private static extern IntPtr objc_msgSend_IntPtr_UInt( - IntPtr receiver, - IntPtr selector, - uint index - ); - - // Need specific signature for returning CGRect (a struct) - [DllImport( - "/System/Library/Frameworks/AppKit.framework/AppKit", - EntryPoint = "objc_msgSend_stret" - )] - private static extern void objc_msgSend_stret_CGRect( - out CGRect stret, - IntPtr receiver, - IntPtr selector - ); - - // Define the CGRect struct matching macOS's definition (usually contains CGPoint origin, CGSize size) - [StructLayout(LayoutKind.Sequential)] - private struct CGPoint + private static bool IsUsable(Rect rect) { - public double x; - public double y; - } - - [StructLayout(LayoutKind.Sequential)] - private struct CGSize - { - public double width; - public double height; - } - - [StructLayout(LayoutKind.Sequential)] - private struct CGRect - { - public CGPoint origin; - public CGSize size; - } - - private static Rect GetPrimaryMonitorRect_Mac_PInvoke() // Renamed function - { - IntPtr NSScreenClass = objc_getClass("NSScreen"); - if (NSScreenClass == IntPtr.Zero) - { - Debug.LogError("PInvoke Error: Failed to get NSScreen class."); - return Rect.zero; - } - - IntPtr screensSelector = sel_registerName("screens"); - if (screensSelector == IntPtr.Zero) - { - Debug.LogError("PInvoke Error: Failed to get 'screens' selector."); - return Rect.zero; - } - - IntPtr screensArray = objc_msgSend_IntPtr(NSScreenClass, screensSelector); - if (screensArray == IntPtr.Zero) - { - Debug.LogError("PInvoke Error: Failed to get screens array."); - return Rect.zero; - } - - IntPtr objectAtIndexSelector = sel_registerName("objectAtIndex:"); - if (objectAtIndexSelector == IntPtr.Zero) - { - Debug.LogError("PInvoke Error: Failed to get 'objectAtIndex:' selector."); - return Rect.zero; - } - IntPtr primaryScreen = objc_msgSend_IntPtr_UInt(screensArray, objectAtIndexSelector, 0); // Index 0 is primary - if (primaryScreen == IntPtr.Zero) - { - Debug.LogError("PInvoke Error: Failed to get primary screen object from array."); - return Rect.zero; - } - - IntPtr frameSelector = sel_registerName("frame"); - if (frameSelector == IntPtr.Zero) - { - Debug.LogError("PInvoke Error: Failed to get 'frame' selector."); - return Rect.zero; - } - - CGRect screenFrame; - objc_msgSend_stret_CGRect(out screenFrame, primaryScreen, frameSelector); - - // Simplified mapping: Use dimensions, assume top-left origin is (0,0) for primary screen space. - // A more robust conversion might be needed if primary screen isn't at global (0,0) - // or if EditorWindow.position behaves unexpectedly on Mac regarding Y-coordinates. - // See previous answer's notes on coordinate system complexities. - Debug.Log( - $"macOS primary screen frame: O=({screenFrame.origin.x},{screenFrame.origin.y}) S=({screenFrame.size.width},{screenFrame.size.height})" - ); - return new Rect(0, 0, (float)screenFrame.size.width, (float)screenFrame.size.height); + return rect.width > 0 + && rect.height > 0 + && !float.IsNaN(rect.x) + && !float.IsNaN(rect.y) + && !float.IsNaN(rect.width) + && !float.IsNaN(rect.height) + && !float.IsInfinity(rect.x) + && !float.IsInfinity(rect.y) + && !float.IsInfinity(rect.width) + && !float.IsInfinity(rect.height); } -#endif // UNITY_EDITOR_OSX } #endif } diff --git a/Tests/Editor/MonitorUtilityTests.cs b/Tests/Editor/MonitorUtilityTests.cs new file mode 100644 index 0000000..2ffebf8 --- /dev/null +++ b/Tests/Editor/MonitorUtilityTests.cs @@ -0,0 +1,20 @@ +namespace WallstopStudios.DataVisualizer.Tests.Editor +{ + using NUnit.Framework; + using UnityEngine; + using WallstopStudios.DataVisualizer.Editor.Utilities; + + public sealed class MonitorUtilityTests + { + [Test] + public void Should_ReturnUsableMonitorRect_When_EditorIsRunning() + { + Rect rect = MonitorUtility.GetPrimaryMonitorRect(); + + Assert.That(rect.width, Is.GreaterThan(0)); + Assert.That(rect.height, Is.GreaterThan(0)); + Assert.That(float.IsNaN(rect.x), Is.False); + Assert.That(float.IsNaN(rect.y), Is.False); + } + } +} diff --git a/Tests/Editor/MonitorUtilityTests.cs.meta b/Tests/Editor/MonitorUtilityTests.cs.meta new file mode 100644 index 0000000..f5d55b6 --- /dev/null +++ b/Tests/Editor/MonitorUtilityTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 76e605c21d0d4a0f9e6d3cb4378cb6a6 From f210599606e3068f640b9f2d46dba07809c7f3c4 Mon Sep 17 00:00:00 2001 From: wallstop Date: Wed, 9 Sep 2026 15:30:53 +0000 Subject: [PATCH 04/49] Add runtime lifecycle PlayMode coverage --- Tests/Runtime.meta | 4 ++ Tests/Runtime/BaseDataObjectLifecycleTests.cs | 72 +++++++++++++++++++ .../BaseDataObjectLifecycleTests.cs.meta | 11 +++ ...tudios.DataVisualizer.Tests.Runtime.asmdef | 17 +++++ ...s.DataVisualizer.Tests.Runtime.asmdef.meta | 7 ++ 5 files changed, 111 insertions(+) create mode 100644 Tests/Runtime.meta create mode 100644 Tests/Runtime/BaseDataObjectLifecycleTests.cs create mode 100644 Tests/Runtime/BaseDataObjectLifecycleTests.cs.meta create mode 100644 Tests/Runtime/WallstopStudios.DataVisualizer.Tests.Runtime.asmdef create mode 100644 Tests/Runtime/WallstopStudios.DataVisualizer.Tests.Runtime.asmdef.meta diff --git a/Tests/Runtime.meta b/Tests/Runtime.meta new file mode 100644 index 0000000..a238e80 --- /dev/null +++ b/Tests/Runtime.meta @@ -0,0 +1,4 @@ +fileFormatVersion: 2 +guid: 4ecbd1f4fcda4da4b9d2c5b3838ec6d8 +folderAsset: yes +timeCreated: 1783293385 diff --git a/Tests/Runtime/BaseDataObjectLifecycleTests.cs b/Tests/Runtime/BaseDataObjectLifecycleTests.cs new file mode 100644 index 0000000..9b12042 --- /dev/null +++ b/Tests/Runtime/BaseDataObjectLifecycleTests.cs @@ -0,0 +1,72 @@ +namespace WallstopStudios.DataVisualizer.Tests.Runtime +{ + using NUnit.Framework; + using UnityEngine; + using WallstopStudios.DataVisualizer; + + public sealed class BaseDataObjectLifecycleTests + { + private TestDataObject clone; + private TestDataObject source; + + [SetUp] + public void SetUp() + { + source = ScriptableObject.CreateInstance(); + clone = ScriptableObject.CreateInstance(); + } + + [TearDown] + public void TearDown() + { + Object.DestroyImmediate(source); + Object.DestroyImmediate(clone); + } + + [Test] + public void Should_ExposeLifecycleContracts_WhenCreated() + { + Assert.That(source, Is.InstanceOf()); + Assert.That(source, Is.InstanceOf()); + Assert.That(source, Is.InstanceOf()); + Assert.That(source, Is.InstanceOf()); + Assert.That(source, Is.InstanceOf()); + } + + [Test] + public void Should_ClearIdentityAndIncrementTitle_WhenCloneHooksRun() + { + source.Title = "Sword"; + clone.SetAssetGuid("stale-guid"); + + clone.BeforeClone(source); + clone.AfterClone(source); + + Assert.That(clone.Id, Is.Empty); + Assert.That(clone.Title, Is.EqualTo("Sword (Clone)")); + } + + [TestCase("Sword (Clone)", "Sword (Clone 1)")] + [TestCase("Sword (Clone 4)", "Sword (Clone 5)")] + public void Should_ContinueCloneNumbering_WhenSourceAlreadyHasCloneSuffix( + string sourceTitle, + string expectedCloneTitle + ) + { + source.Title = sourceTitle; + + clone.BeforeClone(source); + clone.AfterClone(source); + + Assert.That(clone.Title, Is.EqualTo(expectedCloneTitle)); + } + + private sealed class TestDataObject : BaseDataObject + { + public void SetAssetGuid(string value) + { + _assetGuid = value; + } + } + } +} diff --git a/Tests/Runtime/BaseDataObjectLifecycleTests.cs.meta b/Tests/Runtime/BaseDataObjectLifecycleTests.cs.meta new file mode 100644 index 0000000..4a55be6 --- /dev/null +++ b/Tests/Runtime/BaseDataObjectLifecycleTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: f5d5fbc6d6d14162a9d45ebc060c9c52 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/Runtime/WallstopStudios.DataVisualizer.Tests.Runtime.asmdef b/Tests/Runtime/WallstopStudios.DataVisualizer.Tests.Runtime.asmdef new file mode 100644 index 0000000..84420a1 --- /dev/null +++ b/Tests/Runtime/WallstopStudios.DataVisualizer.Tests.Runtime.asmdef @@ -0,0 +1,17 @@ +{ + "name": "WallstopStudios.DataVisualizer.Tests.Runtime", + "rootNamespace": "WallstopStudios.DataVisualizer.Tests.Runtime", + "references": [ + "UnityEngine.TestRunner", + "WallstopStudios.DataVisualizer" + ], + "includePlatforms": [], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": true, + "precompiledReferences": ["nunit.framework.dll"], + "autoReferenced": false, + "defineConstraints": ["UNITY_INCLUDE_TESTS"], + "versionDefines": [], + "noEngineReferences": false +} diff --git a/Tests/Runtime/WallstopStudios.DataVisualizer.Tests.Runtime.asmdef.meta b/Tests/Runtime/WallstopStudios.DataVisualizer.Tests.Runtime.asmdef.meta new file mode 100644 index 0000000..ccdb2f9 --- /dev/null +++ b/Tests/Runtime/WallstopStudios.DataVisualizer.Tests.Runtime.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 9ad1a9f684a84e1ab7d36cc6c085b90f +AssemblyDefinitionImporter: + externalObjects: {} + userData: "" + assetBundleName: "" + assetBundleVariant: "" From adc6855137e0089d91e425af5bc43897ca94639b Mon Sep 17 00:00:00 2001 From: wallstop Date: Wed, 9 Sep 2026 15:32:31 +0000 Subject: [PATCH 05/49] Cover serialized runtime asset state --- Tests/Runtime/BaseDataObjectLifecycleTests.cs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/Tests/Runtime/BaseDataObjectLifecycleTests.cs b/Tests/Runtime/BaseDataObjectLifecycleTests.cs index 9b12042..e9808a1 100644 --- a/Tests/Runtime/BaseDataObjectLifecycleTests.cs +++ b/Tests/Runtime/BaseDataObjectLifecycleTests.cs @@ -46,6 +46,21 @@ public void Should_ClearIdentityAndIncrementTitle_WhenCloneHooksRun() Assert.That(clone.Title, Is.EqualTo("Sword (Clone)")); } + [Test] + public void Should_PreserveSerializedAssetState_WhenStateRoundTrips() + { + source.SetAssetGuid("runtime-guid"); + source.Title = "Sword"; + source.Description = "A serialized runtime fixture."; + + string serializedState = JsonUtility.ToJson(source); + JsonUtility.FromJsonOverwrite(serializedState, clone); + + Assert.That(clone.Id, Is.EqualTo("runtime-guid")); + Assert.That(clone.Title, Is.EqualTo("Sword")); + Assert.That(clone.Description, Is.EqualTo("A serialized runtime fixture.")); + } + [TestCase("Sword (Clone)", "Sword (Clone 1)")] [TestCase("Sword (Clone 4)", "Sword (Clone 5)")] public void Should_ContinueCloneNumbering_WhenSourceAlreadyHasCloneSuffix( From 17841991cd373f0cae47cdfa3233c935a5026dc5 Mon Sep 17 00:00:00 2001 From: wallstop Date: Wed, 9 Sep 2026 15:35:43 +0000 Subject: [PATCH 06/49] Defer processor discovery until needed --- Editor/DataVisualizer/DataVisualizer.cs | 74 +++++++++++++++---------- 1 file changed, 45 insertions(+), 29 deletions(-) diff --git a/Editor/DataVisualizer/DataVisualizer.cs b/Editor/DataVisualizer/DataVisualizer.cs index 4c3209c..82c190f 100644 --- a/Editor/DataVisualizer/DataVisualizer.cs +++ b/Editor/DataVisualizer/DataVisualizer.cs @@ -275,6 +275,7 @@ private int HiddenNamespaces private Label _processorHeaderLabel; private readonly List _allDataProcessors = new(); private readonly List _compatibleDataProcessors = new(); + private bool _dataProcessorsDiscovered; private TextField _searchField; private VisualElement _searchPopover; @@ -416,6 +417,8 @@ private void OnEnable() _nextColorIndex = 0; Instance = this; _isSearchCachePopulated = false; + _dataProcessorsDiscovered = false; + _allDataProcessors.Clear(); _selectedObject = null; _selectedObjects.Clear(); #if ODIN_INSPECTOR @@ -423,29 +426,6 @@ private void OnEnable() #endif _userStateFilePath = Path.Combine(Application.persistentDataPath, UserStateFileName); - _allDataProcessors.Clear(); - IEnumerable processorTypes = TypeCache - .GetTypesDerivedFrom() - .Where(t => !t.IsAbstract && !t.IsInterface && !t.IsGenericTypeDefinition); - foreach (Type type in processorTypes) - { - try - { - if (Activator.CreateInstance(type) is IDataProcessor instance) - { - _allDataProcessors.Add(instance); - } - } - catch (Exception ex) - { - Debug.LogError( - $"Failed to create instance of IDataProcessor '{type.FullName}': {ex.Message}" - ); - } - } - - _allDataProcessors.Sort((lhs, rhs) => string.CompareOrdinal(lhs.Name, rhs.Name)); - // Don't load types here - it blocks the UI from appearing // LoadScriptableObjectTypes() is now deferred to CreateGUI rootVisualElement.RegisterCallback( @@ -486,6 +466,8 @@ private void Cleanup() UpdateLoadingIndicator(0, 0); // Hide loading indicator _isLabelCachePopulated = false; + _dataProcessorsDiscovered = false; + _allDataProcessors.Clear(); _selectedObject = null; _scriptableObjectTypes.Clear(); _namespaceOrder.Clear(); @@ -1786,15 +1768,18 @@ internal void BuildProcessorColumnView() _processorListContainer.Clear(); _compatibleDataProcessors.Clear(); - if (_namespaceController.SelectedType != null) + Type selectedType = _namespaceController.SelectedType; + if (selectedType == null) { - _compatibleDataProcessors.AddRange( - _allDataProcessors.Where(p => - p.Accepts != null && p.Accepts.Contains(_namespaceController.SelectedType) - ) - ); + _processorAreaElement.style.display = DisplayStyle.None; + return; } + EnsureDataProcessorsDiscovered(); + _compatibleDataProcessors.AddRange( + _allDataProcessors.Where(p => p.Accepts != null && p.Accepts.Contains(selectedType)) + ); + if (_compatibleDataProcessors.Count == 0) { _processorAreaElement.style.display = DisplayStyle.None; @@ -1885,6 +1870,37 @@ internal void BuildProcessorColumnView() } } + private void EnsureDataProcessorsDiscovered() + { + if (_dataProcessorsDiscovered) + { + return; + } + + _dataProcessorsDiscovered = true; + IEnumerable processorTypes = TypeCache + .GetTypesDerivedFrom() + .Where(t => !t.IsAbstract && !t.IsInterface && !t.IsGenericTypeDefinition); + foreach (Type type in processorTypes) + { + try + { + if (Activator.CreateInstance(type) is IDataProcessor instance) + { + _allDataProcessors.Add(instance); + } + } + catch (Exception ex) + { + Debug.LogError( + $"Failed to create instance of IDataProcessor '{type.FullName}': {ex.Message}" + ); + } + } + + _allDataProcessors.Sort((lhs, rhs) => string.CompareOrdinal(lhs.Name, rhs.Name)); + } + private void ToggleProcessorContentCollapse() { ProcessorState state = CurrentProcessorState; From 95d2f6eaf4cb2f10a68816406b3a813a456d6ff0 Mon Sep 17 00:00:00 2001 From: wallstop Date: Wed, 9 Sep 2026 15:39:49 +0000 Subject: [PATCH 07/49] Handle malformed user state JSON --- .../Data/DataVisualizerUserState.cs | 31 ++++++++++++------- Tests/Editor/SettingsPersistenceTests.cs | 10 ++++++ 2 files changed, 30 insertions(+), 11 deletions(-) diff --git a/Editor/DataVisualizer/Data/DataVisualizerUserState.cs b/Editor/DataVisualizer/Data/DataVisualizerUserState.cs index 5c31168..1ca802e 100644 --- a/Editor/DataVisualizer/Data/DataVisualizerUserState.cs +++ b/Editor/DataVisualizer/Data/DataVisualizerUserState.cs @@ -32,22 +32,31 @@ public static DataVisualizerUserState FromJson(string json) return null; } - DataVisualizerUserState userState = JsonUtility.FromJson(json); - if (userState == null) + try { + DataVisualizerUserState userState = JsonUtility.FromJson( + json + ); + if (userState == null) + { + return userState; + } + + LegacyUserState legacyUserState = JsonUtility.FromJson(json); + if ( + string.IsNullOrWhiteSpace(userState.lastSelectedTypeFullName) + && !string.IsNullOrWhiteSpace(legacyUserState?.lastSelectedTypeName) + ) + { + userState.lastSelectedTypeFullName = legacyUserState.lastSelectedTypeName; + } + return userState; } - - LegacyUserState legacyUserState = JsonUtility.FromJson(json); - if ( - string.IsNullOrWhiteSpace(userState.lastSelectedTypeFullName) - && !string.IsNullOrWhiteSpace(legacyUserState?.lastSelectedTypeName) - ) + catch (ArgumentException) { - userState.lastSelectedTypeFullName = legacyUserState.lastSelectedTypeName; + return null; } - - return userState; } public void HydrateFrom(DataVisualizerSettings settings) diff --git a/Tests/Editor/SettingsPersistenceTests.cs b/Tests/Editor/SettingsPersistenceTests.cs index c012630..313ad15 100644 --- a/Tests/Editor/SettingsPersistenceTests.cs +++ b/Tests/Editor/SettingsPersistenceTests.cs @@ -162,6 +162,16 @@ string expectedTypeFullName Assert.AreEqual(expectedTypeFullName, userState.lastSelectedTypeFullName); } + [TestCase("{")] + [TestCase("not-json")] + public void Should_ReturnNull_When_UserStateJsonIsMalformed(string json) + { + DataVisualizerUserState userState = null; + + Assert.DoesNotThrow(() => userState = DataVisualizerUserState.FromJson(json)); + Assert.IsNull(userState); + } + private static void AssertCollapseStateDirtySemantics( Func setCollapsed, Func removeCollapsed From 5cfb856f878274ca2cb863da610b812633253a46 Mon Sep 17 00:00:00 2001 From: wallstop Date: Wed, 9 Sep 2026 15:42:19 +0000 Subject: [PATCH 08/49] Debounce splitter preference saves --- Editor/DataVisualizer/DataVisualizer.cs | 58 +++++++++++++++++++++---- 1 file changed, 50 insertions(+), 8 deletions(-) diff --git a/Editor/DataVisualizer/DataVisualizer.cs b/Editor/DataVisualizer/DataVisualizer.cs index 82c190f..edc7072 100644 --- a/Editor/DataVisualizer/DataVisualizer.cs +++ b/Editor/DataVisualizer/DataVisualizer.cs @@ -70,6 +70,7 @@ public sealed class DataVisualizer : EditorWindow private const float MinWindowWidth = MinNamespacePaneWidth + MinObjectPaneWidth + MinInspectorPaneWidth + 60f; private const float MinWindowHeight = 480f; + private const long SplitterSaveDebounceMilliseconds = 250; private const int AsyncLoadBatchSize = 100; private const int AsyncLoadPriorityBatchSize = 100; @@ -304,6 +305,7 @@ private int HiddenNamespaces private float _lastSavedOuterWidth = -1f; private float _lastSavedInnerWidth = -1f; private IVisualElementScheduledItem _saveWidthsTask; + private bool _suppressSplitterWidthSave; private int _searchHighlightIndex = -1; private int _typePopoverHighlightIndex = -1; @@ -478,13 +480,11 @@ private void Cleanup() _isSearchCachePopulated = false; CloseActivePopover(); CancelDrag(); - _saveWidthsTask?.Pause(); + FlushSplitterWidthSave(); if (!Settings.persistStateInSettingsAsset && _userStateDirty) { SaveUserStateToFile(); } - - _saveWidthsTask = null; _currentInspectorScriptableObject?.Dispose(); _currentInspectorScriptableObject = null; _dragGhost?.RemoveFromHierarchy(); @@ -1074,12 +1074,54 @@ private static DataVisualizerSettings LoadOrCreateSettings() return settings; } - private void StartPeriodicWidthSave() + private void StartSplitterWidthTracking() + { + _namespaceColumnElement?.UnregisterCallback( + HandleSplitterGeometryChanged + ); + _objectColumnElement?.UnregisterCallback( + HandleSplitterGeometryChanged + ); + _namespaceColumnElement?.RegisterCallback( + HandleSplitterGeometryChanged + ); + _objectColumnElement?.RegisterCallback( + HandleSplitterGeometryChanged + ); + _suppressSplitterWidthSave = false; + } + + private void HandleSplitterGeometryChanged(GeometryChangedEvent evt) + { + if (_suppressSplitterWidthSave) + { + return; + } + + _saveWidthsTask?.Pause(); + _saveWidthsTask = rootVisualElement.schedule.Execute(() => + { + _saveWidthsTask = null; + CheckAndSaveSplitterWidths(); + }); + _saveWidthsTask.ExecuteLater(SplitterSaveDebounceMilliseconds); + } + + private void FlushSplitterWidthSave() { _saveWidthsTask?.Pause(); - _saveWidthsTask = rootVisualElement - .schedule.Execute(CheckAndSaveSplitterWidths) - .Every(1000); + _saveWidthsTask = null; + if (!_suppressSplitterWidthSave) + { + CheckAndSaveSplitterWidths(); + } + + _namespaceColumnElement?.UnregisterCallback( + HandleSplitterGeometryChanged + ); + _objectColumnElement?.UnregisterCallback( + HandleSplitterGeometryChanged + ); } private void CheckAndSaveSplitterWidths() @@ -1360,7 +1402,7 @@ public void CreateGUI() PopulateSearchCacheAsync(); // Restore selection with priority async loading RestorePreviousSelection(); - StartPeriodicWidthSave(); + StartSplitterWidthTracking(); }) .ExecuteLater(10); }) From 7fc3949052132417d108daab7aa8e99003085fcd Mon Sep 17 00:00:00 2001 From: wallstop Date: Wed, 9 Sep 2026 15:44:41 +0000 Subject: [PATCH 09/49] Validate restored pane geometry --- Editor/DataVisualizer/DataVisualizer.cs | 10 +++-- .../Utilities/LayoutGeometry.cs | 35 +++++++++++++++ .../Utilities/LayoutGeometry.cs.meta | 11 +++++ Tests/Editor/LayoutGeometryTests.cs | 44 +++++++++++++++++++ Tests/Editor/LayoutGeometryTests.cs.meta | 11 +++++ 5 files changed, 107 insertions(+), 4 deletions(-) create mode 100644 Editor/DataVisualizer/Utilities/LayoutGeometry.cs create mode 100644 Editor/DataVisualizer/Utilities/LayoutGeometry.cs.meta create mode 100644 Tests/Editor/LayoutGeometryTests.cs create mode 100644 Tests/Editor/LayoutGeometryTests.cs.meta diff --git a/Editor/DataVisualizer/DataVisualizer.cs b/Editor/DataVisualizer/DataVisualizer.cs index edc7072..c46e61a 100644 --- a/Editor/DataVisualizer/DataVisualizer.cs +++ b/Editor/DataVisualizer/DataVisualizer.cs @@ -1281,12 +1281,14 @@ public void CreateGUI() _searchField.RegisterCallback(HandleSearchKeyDown); headerRow.Add(_searchField); - float initialOuterWidth = Mathf.Max( + float initialOuterWidth = LayoutGeometry.ClampPersistedPaneWidth( EditorPrefs.GetFloat(PrefsSplitterOuterKey, DefaultOuterSplitWidth), + DefaultOuterSplitWidth, MinNamespacePaneWidth ); - float initialInnerWidth = Mathf.Max( + float initialInnerWidth = LayoutGeometry.ClampPersistedPaneWidth( EditorPrefs.GetFloat(PrefsSplitterInnerKey, DefaultInnerSplitWidth), + DefaultInnerSplitWidth, MinObjectPaneWidth ); @@ -1299,7 +1301,7 @@ public void CreateGUI() _innerSplitView = new TwoPaneSplitView( 0, - (int)initialInnerWidth, + LayoutGeometry.ToInitialPaneDimension(initialInnerWidth), TwoPaneSplitViewOrientation.Horizontal ) { @@ -1311,7 +1313,7 @@ public void CreateGUI() _innerSplitView.Add(inspectorColumn); _outerSplitView = new TwoPaneSplitView( 0, - (int)initialOuterWidth, + LayoutGeometry.ToInitialPaneDimension(initialOuterWidth), TwoPaneSplitViewOrientation.Horizontal ) { diff --git a/Editor/DataVisualizer/Utilities/LayoutGeometry.cs b/Editor/DataVisualizer/Utilities/LayoutGeometry.cs new file mode 100644 index 0000000..e3d559c --- /dev/null +++ b/Editor/DataVisualizer/Utilities/LayoutGeometry.cs @@ -0,0 +1,35 @@ +namespace WallstopStudios.DataVisualizer.Editor.Utilities +{ + using UnityEngine; + + public static class LayoutGeometry + { + public static float ClampPersistedPaneWidth( + float persistedWidth, + float defaultWidth, + float minimumWidth + ) + { + float safeMinimum = IsFinite(minimumWidth) ? Mathf.Max(0f, minimumWidth) : 0f; + float safeDefault = IsFinite(defaultWidth) + ? Mathf.Max(defaultWidth, safeMinimum) + : safeMinimum; + return IsFinite(persistedWidth) ? Mathf.Max(persistedWidth, safeMinimum) : safeDefault; + } + + public static int ToInitialPaneDimension(float width) + { + if (!IsFinite(width) || width <= 0f) + { + return 1; + } + + return Mathf.Max(1, Mathf.RoundToInt(width)); + } + + private static bool IsFinite(float value) + { + return !float.IsNaN(value) && !float.IsInfinity(value); + } + } +} diff --git a/Editor/DataVisualizer/Utilities/LayoutGeometry.cs.meta b/Editor/DataVisualizer/Utilities/LayoutGeometry.cs.meta new file mode 100644 index 0000000..065ca7d --- /dev/null +++ b/Editor/DataVisualizer/Utilities/LayoutGeometry.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 1b7ff2db8ee745afb1c11c3fd1adad69 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/Editor/LayoutGeometryTests.cs b/Tests/Editor/LayoutGeometryTests.cs new file mode 100644 index 0000000..711aa4e --- /dev/null +++ b/Tests/Editor/LayoutGeometryTests.cs @@ -0,0 +1,44 @@ +namespace WallstopStudios.DataVisualizer.Tests.Editor +{ + using NUnit.Framework; + using WallstopStudios.DataVisualizer.Editor.Utilities; + + public sealed class LayoutGeometryTests + { + [TestCase(420f, 350f, 320f, 420f)] + [TestCase(200f, 350f, 320f, 320f)] + [TestCase(float.NaN, 350f, 320f, 350f)] + [TestCase(float.PositiveInfinity, 350f, 320f, 350f)] + [TestCase(float.NegativeInfinity, 350f, 320f, 350f)] + public void Should_ReturnFiniteClampedPaneWidth_WhenPersistedValueIsInvalidOrSmall( + float persistedWidth, + float defaultWidth, + float minimumWidth, + float expectedWidth + ) + { + float result = LayoutGeometry.ClampPersistedPaneWidth( + persistedWidth, + defaultWidth, + minimumWidth + ); + + Assert.That(result, Is.EqualTo(expectedWidth)); + } + + [TestCase(250.4f, 250)] + [TestCase(250.6f, 251)] + [TestCase(0f, 1)] + [TestCase(float.NaN, 1)] + public void Should_RoundPositivePaneDimension_WhenCreatingSplitter( + float width, + int expectedDimension + ) + { + Assert.That( + LayoutGeometry.ToInitialPaneDimension(width), + Is.EqualTo(expectedDimension) + ); + } + } +} diff --git a/Tests/Editor/LayoutGeometryTests.cs.meta b/Tests/Editor/LayoutGeometryTests.cs.meta new file mode 100644 index 0000000..8b12a2c --- /dev/null +++ b/Tests/Editor/LayoutGeometryTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 55f2457e7b124343b4c9d1df1b7a57fa +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: From 735103c96c8b3f1a71680cf85ca23618c9dde26b Mon Sep 17 00:00:00 2001 From: wallstop Date: Wed, 9 Sep 2026 15:54:36 +0000 Subject: [PATCH 10/49] Suspend editor work during Play Mode --- Editor/DataVisualizer/DataVisualizer.cs | 224 +++++++++++++++++- .../Unity/DataVisualizerAssetPostProcessor.cs | 5 + 2 files changed, 222 insertions(+), 7 deletions(-) diff --git a/Editor/DataVisualizer/DataVisualizer.cs b/Editor/DataVisualizer/DataVisualizer.cs index c46e61a..115ea9b 100644 --- a/Editor/DataVisualizer/DataVisualizer.cs +++ b/Editor/DataVisualizer/DataVisualizer.cs @@ -277,6 +277,9 @@ private int HiddenNamespaces private readonly List _allDataProcessors = new(); private readonly List _compatibleDataProcessors = new(); private bool _dataProcessorsDiscovered; + private bool _isPlayModeSuspended; + private bool _refreshQueuedDuringPlayMode; + private bool _deferredInitializationPending; private TextField _searchField; private VisualElement _searchPopover; @@ -421,6 +424,11 @@ private void OnEnable() _isSearchCachePopulated = false; _dataProcessorsDiscovered = false; _allDataProcessors.Clear(); + _isPlayModeSuspended = EditorApplication.isPlayingOrWillChangePlaymode; + _refreshQueuedDuringPlayMode = false; + _deferredInitializationPending = false; + EditorApplication.playModeStateChanged -= HandlePlayModeStateChanged; + EditorApplication.playModeStateChanged += HandlePlayModeStateChanged; _selectedObject = null; _selectedObjects.Clear(); #if ODIN_INSPECTOR @@ -438,6 +446,7 @@ private void OnEnable() private void OnDisable() { + EditorApplication.playModeStateChanged -= HandlePlayModeStateChanged; rootVisualElement.UnregisterCallback( HandleGlobalKeyDown, TrickleDown.TrickleDown @@ -447,11 +456,13 @@ private void OnDisable() private void OnDestroy() { + EditorApplication.playModeStateChanged -= HandlePlayModeStateChanged; Cleanup(); } private void Cleanup() { + EditorApplication.playModeStateChanged -= HandlePlayModeStateChanged; if (Instance == this) { Instance = null; @@ -470,6 +481,9 @@ private void Cleanup() _isLabelCachePopulated = false; _dataProcessorsDiscovered = false; _allDataProcessors.Clear(); + _isPlayModeSuspended = false; + _refreshQueuedDuringPlayMode = false; + _deferredInitializationPending = false; _selectedObject = null; _scriptableObjectTypes.Clear(); _namespaceOrder.Clear(); @@ -506,6 +520,112 @@ private void Cleanup() #endif } + private void HandlePlayModeStateChanged(PlayModeStateChange stateChange) + { + switch (stateChange) + { + case PlayModeStateChange.ExitingEditMode: + case PlayModeStateChange.EnteredPlayMode: + SuspendForPlayMode(); + break; + case PlayModeStateChange.EnteredEditMode: + ResumeAfterPlayMode(); + break; + } + } + + private void SuspendForPlayMode() + { + if (_isPlayModeSuspended) + { + ApplyPlayModeAvailability(); + return; + } + + _isPlayModeSuspended = true; + _asyncLoadTask?.Pause(); + _asyncLoadTask = null; + _saveWidthsTask?.Pause(); + _saveWidthsTask = null; + _suppressSplitterWidthSave = true; + CloseActivePopover(); + ApplyPlayModeAvailability(); + UpdateLoadingIndicator(0, 0); + } + + private void ResumeAfterPlayMode() + { + if (!_isPlayModeSuspended) + { + return; + } + + _isPlayModeSuspended = false; + ApplyPlayModeAvailability(); + + if (_deferredInitializationPending) + { + _deferredInitializationPending = false; + CreateGUI(); + return; + } + + StartSplitterWidthTracking(); + if (_refreshQueuedDuringPlayMode) + { + _refreshQueuedDuringPlayMode = false; + ScheduleRefresh(); + return; + } + + if (_isLoadingObjectsAsync && _asyncLoadTargetType != null) + { + ContinueLoadingObjects(_asyncLoadTargetType, _asyncLoadGeneration); + } + + if (_isLoadingSearchCacheAsync) + { + ContinuePopulatingSearchCache(_searchCacheGeneration); + } + else if (!_isSearchCachePopulated && _scriptableObjectTypes.Count > 0) + { + PopulateSearchCacheAsync(); + } + + UpdateLoadingIndicator(_selectedObjects.Count, _asyncLoadTotalCount); + } + + private void ApplyPlayModeAvailability() + { + bool enabled = !_isPlayModeSuspended; + _settingsButton?.SetEnabled(enabled); + _searchField?.SetEnabled(enabled); + _addTypeButton?.SetEnabled(enabled); + _addTypesFromDataFolderButton?.SetEnabled(enabled); + _addTypesFromScriptFolderButton?.SetEnabled(enabled); + _createObjectButton?.SetEnabled(enabled); + _typeAddSearchField?.SetEnabled(enabled); + _typeSearchField?.SetEnabled(enabled); + _namespaceListContainer?.SetEnabled(enabled); + _objectListView?.SetEnabled(enabled); + _labelCollapseRow?.SetEnabled(enabled); + _labelFilterSelectionRoot?.SetEnabled(enabled); + _inspectorContainer?.SetEnabled(enabled); + _processorAreaElement?.SetEnabled(enabled); + _settingsPopover?.SetEnabled(enabled); + _createPopover?.SetEnabled(enabled); + _renamePopover?.SetEnabled(enabled); + _confirmDeletePopover?.SetEnabled(enabled); + _confirmActionPopover?.SetEnabled(enabled); + _typeAddPopover?.SetEnabled(enabled); + _confirmNamespaceAddPopover?.SetEnabled(enabled); + _inspectorLabelSuggestionsPopover?.SetEnabled(enabled); + + rootVisualElement + .Query