From 952a869f6dcbbe552b99695f67e35496e8638c0d Mon Sep 17 00:00:00 2001 From: Vladislav Panin Date: Tue, 21 Jul 2026 17:21:02 +0300 Subject: [PATCH 1/4] feat(serialize-references): add Required-violations audit UI - Add a "Required violations" card to Project References, fed by a new SerializeReferenceGateScanner.ScanAssetRequiredFields / GateOptions.RequiredOnly, so unset [TypeSelector(Required = true)] fields are auditable project-wide without a build. - Badge unset required [SerializeReference] slots in Asset References, plus a fallback "Required" card for string/SerializableType required fields that have no graph node. - Cover the new scan paths and the graph/gate path-matching with EditMode tests; add a RequiredViolationsDevTest fixture for manual QA. - Update the QA checklist (EN/RU). Co-Authored-By: Claude --- .../Assets/DevTests/SerializeReferences.meta | 8 + .../DevTests/SerializeReferences/Prefabs.meta | 8 + .../Prefabs/RequiredViolationsDevTest.prefab | 54 +++++ .../RequiredViolationsDevTest.prefab.meta | 7 + .../DevTests/SerializeReferences/Scripts.meta | 8 + .../Scripts/RequiredViolationsDevTest.cs | 26 +++ .../Scripts/RequiredViolationsDevTest.cs.meta | 2 + .../SerializeReferenceGateScannerTests.cs | 98 +++++++- .../Aspid-FastTools-ReferenceGraph.uss | 40 ++++ .../Aspid-FastTools-SerializeReference.uss | 9 + .../SerializeReferences/Build/GateOptions.cs | 3 + .../Build/SerializeReferenceGateScanner.cs | 21 ++ .../Windows/SerializeReferenceGraphView.cs | 169 +++++++++++++- .../Windows/SerializeReferenceProjectView.cs | 220 ++++++++++++++++-- docs/QA-CHECKLIST.md | 1 + docs/QA-CHECKLIST_RU.md | 1 + 16 files changed, 647 insertions(+), 28 deletions(-) create mode 100644 Aspid.FastTools/Assets/DevTests/SerializeReferences.meta create mode 100644 Aspid.FastTools/Assets/DevTests/SerializeReferences/Prefabs.meta create mode 100644 Aspid.FastTools/Assets/DevTests/SerializeReferences/Prefabs/RequiredViolationsDevTest.prefab create mode 100644 Aspid.FastTools/Assets/DevTests/SerializeReferences/Prefabs/RequiredViolationsDevTest.prefab.meta create mode 100644 Aspid.FastTools/Assets/DevTests/SerializeReferences/Scripts.meta create mode 100644 Aspid.FastTools/Assets/DevTests/SerializeReferences/Scripts/RequiredViolationsDevTest.cs create mode 100644 Aspid.FastTools/Assets/DevTests/SerializeReferences/Scripts/RequiredViolationsDevTest.cs.meta diff --git a/Aspid.FastTools/Assets/DevTests/SerializeReferences.meta b/Aspid.FastTools/Assets/DevTests/SerializeReferences.meta new file mode 100644 index 00000000..0ff0eb60 --- /dev/null +++ b/Aspid.FastTools/Assets/DevTests/SerializeReferences.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: ccb39851c31fa412387abddb80e2184c +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Aspid.FastTools/Assets/DevTests/SerializeReferences/Prefabs.meta b/Aspid.FastTools/Assets/DevTests/SerializeReferences/Prefabs.meta new file mode 100644 index 00000000..1176fb9d --- /dev/null +++ b/Aspid.FastTools/Assets/DevTests/SerializeReferences/Prefabs.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 962a4b4aa23de4357a3e5641c90a1ed3 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Aspid.FastTools/Assets/DevTests/SerializeReferences/Prefabs/RequiredViolationsDevTest.prefab b/Aspid.FastTools/Assets/DevTests/SerializeReferences/Prefabs/RequiredViolationsDevTest.prefab new file mode 100644 index 00000000..ae24ea5c --- /dev/null +++ b/Aspid.FastTools/Assets/DevTests/SerializeReferences/Prefabs/RequiredViolationsDevTest.prefab @@ -0,0 +1,54 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!1 &6785315564667727353 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 2201127709315222250} + - component: {fileID: 6873221168316818700} + m_Layer: 0 + m_Name: RequiredViolationsDevTest + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!4 &2201127709315222250 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 6785315564667727353} + serializedVersion: 2 + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 0} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} +--- !u!114 &6873221168316818700 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 6785315564667727353} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 9600738624fa14a36ab7f3cc1c063e27, type: 3} + m_Name: + m_EditorClassIdentifier: Assembly-CSharp::Aspid.FastTools.DevTests.SerializeReferences.RequiredViolationsDevTest + _requiredReference: + rid: -2 + _requiredTypeName: + references: + version: 2 + RefIds: + - rid: -2 + type: {class: , ns: , asm: } diff --git a/Aspid.FastTools/Assets/DevTests/SerializeReferences/Prefabs/RequiredViolationsDevTest.prefab.meta b/Aspid.FastTools/Assets/DevTests/SerializeReferences/Prefabs/RequiredViolationsDevTest.prefab.meta new file mode 100644 index 00000000..7c7eecdc --- /dev/null +++ b/Aspid.FastTools/Assets/DevTests/SerializeReferences/Prefabs/RequiredViolationsDevTest.prefab.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: d1ffcb56686504f69ae8e81a82a51deb +PrefabImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Aspid.FastTools/Assets/DevTests/SerializeReferences/Scripts.meta b/Aspid.FastTools/Assets/DevTests/SerializeReferences/Scripts.meta new file mode 100644 index 00000000..17d7b63d --- /dev/null +++ b/Aspid.FastTools/Assets/DevTests/SerializeReferences/Scripts.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 982b58ec7bfff43e1b2ae1ccdad92cde +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Aspid.FastTools/Assets/DevTests/SerializeReferences/Scripts/RequiredViolationsDevTest.cs b/Aspid.FastTools/Assets/DevTests/SerializeReferences/Scripts/RequiredViolationsDevTest.cs new file mode 100644 index 00000000..1d44dec9 --- /dev/null +++ b/Aspid.FastTools/Assets/DevTests/SerializeReferences/Scripts/RequiredViolationsDevTest.cs @@ -0,0 +1,26 @@ +using System; +using UnityEngine; +using Aspid.FastTools.Types; + +// ReSharper disable once CheckNamespace +namespace Aspid.FastTools.DevTests.SerializeReferences +{ + public interface IRequiredDevTestPayload { } + + [Serializable] + public sealed class RequiredDevTestPayload : IRequiredDevTestPayload + { + [SerializeField] private int _value; + } + + // Dev-only fixture for manually verifying the Project References "Required violations" group and the Asset + // References REQUIRED badge: both fields are left unset on Prefabs/RequiredViolationsDevTest.prefab on purpose. + public sealed class RequiredViolationsDevTest : MonoBehaviour + { + [SerializeReference, TypeSelector(Required = true)] + private IRequiredDevTestPayload _requiredReference; + + [TypeSelector(typeof(Component), Required = true)] + [SerializeField] private string _requiredTypeName; + } +} diff --git a/Aspid.FastTools/Assets/DevTests/SerializeReferences/Scripts/RequiredViolationsDevTest.cs.meta b/Aspid.FastTools/Assets/DevTests/SerializeReferences/Scripts/RequiredViolationsDevTest.cs.meta new file mode 100644 index 00000000..4362e510 --- /dev/null +++ b/Aspid.FastTools/Assets/DevTests/SerializeReferences/Scripts/RequiredViolationsDevTest.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 9600738624fa14a36ab7f3cc1c063e27 \ No newline at end of file diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/SerializeReferenceGateScannerTests.cs b/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/SerializeReferenceGateScannerTests.cs index 814dacdf..12fbdd3d 100644 --- a/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/SerializeReferenceGateScannerTests.cs +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/SerializeReferenceGateScannerTests.cs @@ -1,14 +1,110 @@ +using System.Linq; +using UnityEditor; +using UnityEngine; using NUnit.Framework; +using System.Text.RegularExpressions; namespace Aspid.FastTools.SerializeReferences.Editors.Tests { /// /// Coverage for — the gate's pre-filter that keeps - /// properly declared renames from ever warning or failing a build / CI run. + /// properly declared renames from ever warning or failing a build / CI run — and for + /// itself surfacing unset required fields (the data source for the Project References "Required violations" group). /// [TestFixture] internal sealed class SerializeReferenceGateScannerTests { + private const string ProbeAssetPath = "Assets/__AspidGateScannerRequiredProbe__.asset"; + + // Scan(RequiredOnly) is the exact call the Project References "Required violations" group makes; this proves + // it surfaces both an unset managed reference and an unset [TypeSelector(Required = true)] string field on a + // saved asset (RequiredTestObject, shared fixture — see SerializeReferenceTestFixtures.cs). + [Test] + public void Scan_RequiredOnly_SurfacesUnsetRequiredFieldsOnSavedAsset() + { + var probe = ScriptableObject.CreateInstance(); + try + { + AssetDatabase.CreateAsset(probe, ProbeAssetPath); + AssetDatabase.TryGetGUIDAndLocalFileIdentifier(probe, out _, out long fileId); + + var violations = SerializeReferenceGateScanner.Scan(GateOptions.RequiredOnly); + var forProbe = violations.Where(v => v.AssetPath == ProbeAssetPath && v.FileId == fileId).ToList(); + + Assert.IsTrue(forProbe.All(v => v.Kind == GateViolationKind.RequiredUnset)); + Assert.IsTrue(forProbe.Any(v => v.FieldPath == nameof(RequiredTestObject.requiredRef)), + "Unset [SerializeReference, TypeSelector(Required = true)] field must be reported."); + Assert.IsTrue(forProbe.Any(v => v.FieldPath == nameof(RequiredTestObject.requiredString)), + "Unset [TypeSelector(Required = true)] string field must be reported."); + } + finally + { + AssetDatabase.DeleteAsset(ProbeAssetPath); + } + } + + // ScanAssetRequiredFields is the scoped, single-asset entry point the Inspect Asset graph calls on every + // Rescan; it must agree with Scan(RequiredOnly) for that one asset without sweeping the whole project. + [Test] + public void ScanAssetRequiredFields_SavedAsset_MatchesProjectScan() + { + var probe = ScriptableObject.CreateInstance(); + try + { + AssetDatabase.CreateAsset(probe, ProbeAssetPath); + AssetDatabase.TryGetGUIDAndLocalFileIdentifier(probe, out _, out long fileId); + + var violations = SerializeReferenceGateScanner.ScanAssetRequiredFields(ProbeAssetPath); + var forProbe = violations.Where(v => v.FileId == fileId).ToList(); + + Assert.IsTrue(forProbe.All(v => v.Kind == GateViolationKind.RequiredUnset)); + Assert.IsTrue(forProbe.Any(v => v.FieldPath == nameof(RequiredTestObject.requiredRef)), + "Unset [SerializeReference, TypeSelector(Required = true)] field must be reported."); + Assert.IsTrue(forProbe.Any(v => v.FieldPath == nameof(RequiredTestObject.requiredString)), + "Unset [TypeSelector(Required = true)] string field must be reported."); + } + finally + { + AssetDatabase.DeleteAsset(ProbeAssetPath); + } + } + + [Test] + public void ScanAssetRequiredFields_NonCandidatePath_ReturnsEmpty() + { + Assert.AreEqual(0, SerializeReferenceGateScanner.ScanAssetRequiredFields("Assets/Fake.txt").Count); + } + + // The Inspect Asset graph (SerializeReferenceGraphView) badges an empty [SerializeReference] slot as REQUIRED + // by matching its graph field path — built independently by SerializeReferenceGraphScanner straight from + // YAML — against this scan's GateViolation.FieldPath (a live SerializedProperty.propertyPath), after the same + // "[i]" -> ".Array.data[i]" normalization the view applies. This proves the two paths actually agree for a + // real saved asset, not just that each individually finds the field. + [Test] + public void ScanAssetRequiredFields_UnsetManagedReference_MatchesGraphScannerEmptyRootPath() + { + var probe = ScriptableObject.CreateInstance(); + try + { + AssetDatabase.CreateAsset(probe, ProbeAssetPath); + AssetDatabase.TryGetGUIDAndLocalFileIdentifier(probe, out _, out long fileId); + + var violations = SerializeReferenceGateScanner.ScanAssetRequiredFields(ProbeAssetPath); + var document = SerializeReferenceGraphScanner.Build(ProbeAssetPath).Single(doc => doc.FileId == fileId); + var emptyRoot = document.Roots.Single(root => root.IsEmpty); + + var normalizedGraphPath = Regex.Replace(emptyRoot.Label, @"\[(\d+)\]", ".Array.data[$1]"); + + Assert.IsTrue( + violations.Any(v => v.FileId == fileId && v.FieldPath == normalizedGraphPath), + $"Normalized graph path '{normalizedGraphPath}' must match a ScanAssetRequiredFields violation's FieldPath."); + } + finally + { + AssetDatabase.DeleteAsset(ProbeAssetPath); + } + } + // A [MovedFrom]-claimed stale name is a pending migration, not a violation: Unity migrates the reference in // memory at load, so the gate must accept it — a properly declared rename can never warn or fail a build / // CI run. A scene path exercises the trust-the-claim branch (constraints are unrecoverable for scenes). diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Resources/UI/SerializeReferences/Aspid-FastTools-ReferenceGraph.uss b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Resources/UI/SerializeReferences/Aspid-FastTools-ReferenceGraph.uss index 815b7e57..04feb265 100644 --- a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Resources/UI/SerializeReferences/Aspid-FastTools-ReferenceGraph.uss +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Resources/UI/SerializeReferences/Aspid-FastTools-ReferenceGraph.uss @@ -434,6 +434,13 @@ background-color: var(--aspid-colors-status-info-dark); } +/* Unset [TypeSelector(Required = true)] managed reference — same warning tone as --missing (unused today), on an + empty slot's band instead of a resolved node's. */ +.aspid-fasttools-reference-graph__badge--required { + color: var(--aspid-colors-status-warning-text-lightness); + background-color: var(--aspid-colors-status-warning-dark); +} + /* The deterministic per-rid colour chip beside the SHARED badge (its background colour is set inline from code). */ .aspid-fasttools-reference-graph__chip { width: 8px; @@ -475,6 +482,39 @@ -unity-text-align: middle-center; } +/* --- Required-only group --- + Trailing card for required violations with no graph node (string / SerializableType fields) — same warm + translucent-fill treatment as the Orphaned group, so it reads as the same "warning zone" family, but a flat list + of rows (no node cards / bands: these fields have no picker here, they edit through the normal Inspector). */ +.aspid-fasttools-reference-graph__required-only-group.aspid-fasttools-background { + flex-grow: 0; + margin-top: 6px; + padding: 8px 10px; + border-radius: 8px; + border-width: 1px; + border-color: var(--aspid-colors-status-warning-shade-darkness); + background-color: rgba(45, 30, 5, 0.3); +} + +.aspid-fasttools-reference-graph__required-only-group-header { + margin-bottom: 4px; +} + +.aspid-fasttools-reference-graph__required-only-entry { + flex-direction: row; + align-items: center; + justify-content: space-between; + padding: 2px 4px; +} + +.aspid-fasttools-reference-graph__required-only-entry-path { + flex-grow: 1; + flex-shrink: 1; + min-width: 0; + color: var(--aspid-colors-text-light); + white-space: normal; +} + /* --- Inline type picker (mirrors the Project Audit picker exactly) --- The selector view expanded inline, welded into the card being fixed (see TogglePicker): translucent dark fill, neutral border, 8px radius and a fixed height (the ListView needs bounds to virtualize). padding:0 plus the header / diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Resources/UI/SerializeReferences/Aspid-FastTools-SerializeReference.uss b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Resources/UI/SerializeReferences/Aspid-FastTools-SerializeReference.uss index bf060ea4..c37ad533 100644 --- a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Resources/UI/SerializeReferences/Aspid-FastTools-SerializeReference.uss +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Resources/UI/SerializeReferences/Aspid-FastTools-SerializeReference.uss @@ -664,6 +664,15 @@ -unity-text-align: middle-right; } +/* A required-violation row's "Component.field" column — same dim, right-pinned treatment as __group-entry-rid, one + column over for a different row shape (Required violations card has no rid). */ +.aspid-fasttools-repair-references__group-entry-field { + flex-shrink: 0; + margin-left: 10px; + color: var(--aspid-colors-text-dark); + -unity-text-align: middle-right; +} + /* A compatible MonoScript dragged over the field highlights the header as a valid drop target. */ .aspid-fasttools-serialize-reference--drop-target { background-color: var(--aspid-colors-bg-light); diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Build/GateOptions.cs b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Build/GateOptions.cs index 70eafe50..7cb8f5d5 100644 --- a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Build/GateOptions.cs +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Build/GateOptions.cs @@ -15,6 +15,9 @@ internal readonly struct GateOptions public static GateOptions MissingOnly => new(true, false); + public static GateOptions RequiredOnly => + new(false, true); + private GateOptions(bool scanMissingTypes, bool scanRequiredFields) { ScanMissingTypes = scanMissingTypes; diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Build/SerializeReferenceGateScanner.cs b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Build/SerializeReferenceGateScanner.cs index 952eb5b5..9d520143 100644 --- a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Build/SerializeReferenceGateScanner.cs +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Build/SerializeReferenceGateScanner.cs @@ -57,6 +57,27 @@ public static IReadOnlyList Scan(GateOptions options, Action + /// Scoped required-field scan for a single asset, without a full project sweep. + /// + /// + /// Reuses 's per-path dispatch for . Used by the + /// Inspect Asset graph, which needs one asset's violations on every Rescan; skipped for a path that is not + /// itself a scan candidate, matching every other consumer's . + /// + public static IReadOnlyList ScanAssetRequiredFields(string assetPath) + { + var violations = new List(); + if (string.IsNullOrEmpty(assetPath) || !SerializeReferenceHelpers.IsScanCandidate(assetPath)) return violations; + + ScriptRequiredFieldsCache.Clear(); + + if (SerializeReferenceHelpers.IsScene(assetPath)) CollectSceneRequiredViolations(assetPath, violations); + else CollectRequiredViolations(assetPath, violations); + + return violations; + } + // A stored name claimed by exactly one declared [MovedFrom] is a pending migration, not a violation — // Unity migrates it in memory at load — provided the target still fits the field's declared type. // Scenes cannot be object-loaded to recover constraints, so a scene entry claimed by a rename is trusted. diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Windows/SerializeReferenceGraphView.cs b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Windows/SerializeReferenceGraphView.cs index 3a7ea9b8..9d311ef5 100644 --- a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Windows/SerializeReferenceGraphView.cs +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Windows/SerializeReferenceGraphView.cs @@ -80,11 +80,16 @@ internal sealed class SerializeReferenceGraphView : VisualElement private const string BadgeClass = RootClass + "__badge"; private const string BadgeSharedClass = BadgeClass + "--shared"; + private const string BadgeRequiredClass = BadgeClass + "--required"; private const string ChipClass = RootClass + "__chip"; private const string ClearOrphanClass = RootClass + "__clear-orphan"; private const string OrphanGroupClass = RootClass + "__orphan-group"; private const string OrphanGroupHeaderClass = RootClass + "__orphan-group-header"; + private const string RequiredOnlyGroupClass = RootClass + "__required-only-group"; + private const string RequiredOnlyGroupHeaderClass = RootClass + "__required-only-group-header"; + private const string RequiredOnlyEntryClass = RootClass + "__required-only-entry"; + private const string RequiredOnlyEntryPathClass = RootClass + "__required-only-entry-path"; private const string PickerClass = RootClass + "__picker"; private const string PickerAttachedClass = PickerClass + "--attached"; @@ -127,6 +132,13 @@ internal sealed class SerializeReferenceGraphView : VisualElement // so each Fix-Missing picker open must not re-scan. Cleared on every Rescan / apply so rewritten YAML is re-read. private readonly Dictionary> _constraintCache = new(StringComparer.Ordinal); + // Unset [SerializeReference, TypeSelector(Required = true)] fields for the current asset, refreshed on every + // Rescan. Populated straight from SerializeReferenceGateScanner — the same required-field check the Project + // References audit and the build/CI gate use — so a required badge here always agrees with them. A + // [TypeSelector(Required = true)] string/SerializableType field has no rid and so no graph node to badge; it + // still surfaces via the Inspector notice and the Project References "Required violations" group. + private IReadOnlyList _requiredViolations = Array.Empty(); + public SerializeReferenceGraphView(Object target, Action onCanvasTone, Action onTargetChanged = null) { _target = target; @@ -229,6 +241,7 @@ private void Rescan(List prebuilt = null) // Drop the constraint maps so a rescan after a fix / clear re-reads the rewritten YAML, not a stale map. _constraintCache.Clear(); _list.Clear(); + _requiredViolations = Array.Empty(); var assetPath = _target ? AssetDatabase.GetAssetPath(_target) : null; if (string.IsNullOrEmpty(assetPath)) @@ -256,7 +269,14 @@ private void Rescan(List prebuilt = null) } var documents = prebuilt ?? SerializeReferenceGraphScanner.Build(assetPath); - if (documents.Count == 0) + + // Same headless scanner as the Project References audit and the build/CI gate, scoped to this one asset. + // Read before the empty-graph bail: a string / SerializableType required field has no rid and so never + // produces a document (SerializeReferenceGraphScanner only emits one for an object with a RefIds block), + // so it can be the ONLY thing this asset has to show even when the managed-reference graph is empty. + _requiredViolations = SerializeReferenceGateScanner.ScanAssetRequiredFields(assetPath); + + if (documents.Count == 0 && _requiredViolations.Count == 0) { ShowEmpty( "No managed references", @@ -274,6 +294,12 @@ private void Rescan(List prebuilt = null) var empties = 0; var migrations = 0; + // Every empty managed-reference slot's normalized path, gathered up front so the required-only group + // below can tell "already badged on a graph card" apart from "no graph node exists for this field at + // all" (a string / SerializableType required field, or an empty slot under a document the scanner + // failed to reach) without re-walking the tree per violation. + var emptySlotPaths = CollectEmptySlotPaths(documents); + var showHeaders = documents.Count > 1; foreach (var document in documents) { @@ -289,13 +315,61 @@ private void Rescan(List prebuilt = null) ShowOverview(total, missing, orphans, empties, migrations); + // Fields the graph has no node for at all: string / SerializableType required fields (never threaded + // into RefIds) plus, defensively, any managed-reference violation the graph walk could not place. + var ungraphedRequired = _requiredViolations + .Where(v => !emptySlotPaths.Contains((v.FileId, v.FieldPath))) + .ToList(); + if (ungraphedRequired.Count > 0) + _list.AddChild(BuildRequiredOnlyGroup(ungraphedRequired)); + // Pending migrations are not breakages — a graph whose only annotations are migrations reads info-blue, // matching the Project References group card; anything actually missing / orphaned keeps the amber wash. _onCanvasTone?.Invoke(missing - migrations > 0 || orphans > 0 ? SerializeReferenceCanvasStyle.Warning : migrations > 0 ? SerializeReferenceCanvasStyle.Info - : SerializeReferenceCanvasStyle.Success); + : ungraphedRequired.Count > 0 + ? SerializeReferenceCanvasStyle.Warning + : SerializeReferenceCanvasStyle.Success); + } + + // Every empty managed-reference slot's normalized field path across every document, root and nested edge + // (mirrors the AppendNode walk, minus the card building) — the lookup set BuildEmptySlotCard's badges are + // checked against, reused here to find the required violations no graph card exists for. + private static HashSet<(long fileId, string path)> CollectEmptySlotPaths(List documents) + { + var paths = new HashSet<(long, string)>(); + + foreach (var document in documents) + { + foreach (var root in document.Roots) + { + if (root.IsEmpty) + paths.Add((document.FileId, ToSerializedPropertyPath(root.Label))); + else + WalkForEmptySlots(document, root.Rid, root.Label, new HashSet(), paths); + } + } + + return paths; + } + + private static void WalkForEmptySlots(ReferenceGraphDocument document, long rid, string pathLabel, + HashSet visited, HashSet<(long fileId, string path)> paths) + { + if (!visited.Add(rid)) return; + + foreach (var edge in document.ChildrenOf(rid)) + { + var childPath = CombinePath(pathLabel, edge.Label); + if (edge.IsEmpty) + paths.Add((document.FileId, ToSerializedPropertyPath(childPath))); + else + WalkForEmptySlots(document, edge.Rid, childPath, visited, paths); + } + + visited.Remove(rid); } // Ranked Smart Fix for a missing node, via the shared per-(path, fileId, rid) cache so a rescan and the @@ -735,6 +809,25 @@ private static VisualElement BuildBackEdgeCard(long rid) return card; } + // Matches an empty slot's graph field path (list indices as "[i]") against a GateViolation's FieldPath (Unity's + // native SerializedProperty form, "Array.data[i]") for the same document — the same normalization + // TryResolveLiveProperty already applies to reach the live property at this path. Best-effort: a slot whose + // path could not be recovered by the YAML walk (SerializeReferenceGraphScanner's "reference" fallback) never + // matches a real property path, so its badge is silently skipped rather than false-positiving — the same + // violation still shows correctly in the Project References tab. + private bool IsFieldRequiredUnset(long fileId, string pathLabel) + { + if (string.IsNullOrEmpty(pathLabel) || _requiredViolations.Count == 0) return false; + + var propertyPath = ToSerializedPropertyPath(pathLabel); + foreach (var violation in _requiredViolations) + { + if (violation.FileId == fileId && violation.FieldPath == propertyPath) return true; + } + + return false; + } + // An unassigned [SerializeReference] slot — a field whose pointer is the null sentinel (rid -2). Its band is // still a dropdown assigning a type through the live serialization API; a slot whose field path could not be // recovered stays static (nothing to target). @@ -754,6 +847,15 @@ private VisualElement BuildEmptySlotCard(string assetPath, long fileId, string p .AddChild(typeLabel); bandRow.pickingMode = PickingMode.Ignore; + if (IsFieldRequiredUnset(fileId, pathLabel)) + { + var badges = new VisualElement().AddClass(NodeBadgesClass).SetPickingMode(PickingMode.Ignore); + var required = new Label("REQUIRED").AddClass(BadgeClass).AddClass(BadgeRequiredClass); + required.tooltip = "Required reference is not set"; + badges.AddChild(required); + bandRow.AddChild(badges); + } + if (string.IsNullOrEmpty(pathLabel)) { // No recoverable field path to target — leave the slot a static "" leaf. @@ -788,6 +890,69 @@ private VisualElement BuildEmptySlotCard(string assetPath, long fileId, string p return card; } + // Trailing card for required violations the graph has no node for — a string / SerializableType required + // field (never threaded into RefIds, so SerializeReferenceGraphScanner never emits a document for a + // component whose only serialized-reference-worthy fields are these) has nothing to badge a card with, so + // it gets a flat row here instead. Same visual family as the Orphaned group: a standalone warning-toned box. + private VisualElement BuildRequiredOnlyGroup(IReadOnlyList violations) + { + var group = new AspidBox(AspidBoxPreset.Default.SetTheme(ThemeStyle.Type.Darkness)) + .AddClass(RequiredOnlyGroupClass); + + group.AddChild(new AspidLabel("Required", AspidLabelPreset.Default + .SetLabelStatus(StatusStyle.Type.Warning) + .SetLabelSize(AspidLabelSizeStyle.Type.H5) + .SetLineSize(AspidDividingLineSizeStyle.Type.None)) + .AddClass(RequiredOnlyGroupHeaderClass)); + + // Per-card memo, keyed by asset path: several violations commonly share one component, so this keeps + // LoadAllAssetsAtPath to once per distinct file instead of once per row. + var componentCache = new Dictionary(StringComparer.Ordinal); + foreach (var violation in violations) + group.AddChild(BuildRequiredOnlyRow(violation, componentCache)); + + return group; + } + + // A plain row: "Component.field" on the left, a REQUIRED badge on the right. No band/picker — the field + // is a string / SerializableType, edited through the normal Inspector, not this graph's live-property API. + private static VisualElement BuildRequiredOnlyRow(GateViolation violation, Dictionary componentCache) + { + var row = new VisualElement().AddClass(RequiredOnlyEntryClass); + + var component = ResolveComponentName(violation, componentCache); + var text = string.IsNullOrEmpty(component) ? violation.FieldPath : $"{component}.{violation.FieldPath}"; + + var path = new Label(text).AddClass(RequiredOnlyEntryPathClass); + var badge = new Label("REQUIRED").AddClass(BadgeClass).AddClass(BadgeRequiredClass); + + row.AddChild(path).AddChild(badge); + return row; + } + + // Best-effort owning-object type name, mirroring SerializeReferenceProjectView's resolver: saved assets are + // object-loaded (once per distinct path, memoised in componentCache) and matched by file id. Scenes cannot + // be object-loaded (see SerializeReferenceHelpers.IsScene), so a scene row shows the field path alone. + private static string ResolveComponentName(GateViolation violation, Dictionary componentCache) + { + if (SerializeReferenceHelpers.IsScene(violation.AssetPath)) return string.Empty; + + if (!componentCache.TryGetValue(violation.AssetPath, out var assets)) + { + assets = AssetDatabase.LoadAllAssetsAtPath(violation.AssetPath); + componentCache[violation.AssetPath] = assets; + } + + foreach (var asset in assets) + { + if (asset == null) continue; + if (AssetDatabase.TryGetGUIDAndLocalFileIdentifier(asset, out _, out long fileId) && fileId == violation.FileId) + return asset.GetType().Name; + } + + return string.Empty; + } + // Warning-tinted group for rids no root reaches. Each orphan is a full node card (so a missing orphan is still // fixable inline) with a footer Clear, without recursion into children. private VisualElement BuildOrphanGroup(string assetPath, ReferenceGraphDocument document) diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Windows/SerializeReferenceProjectView.cs b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Windows/SerializeReferenceProjectView.cs index e71f7eb6..7b28508f 100644 --- a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Windows/SerializeReferenceProjectView.cs +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Windows/SerializeReferenceProjectView.cs @@ -63,6 +63,7 @@ internal sealed class SerializeReferenceProjectView : VisualElement private const string GroupEntryClass = RootClass + "__group-entry"; private const string GroupEntryPathClass = RootClass + "__group-entry-path"; private const string GroupEntryRidClass = RootClass + "__group-entry-rid"; + private const string GroupEntryFieldClass = RootClass + "__group-entry-field"; // Chevron on the "Fix all (N)" dropdown button; only the glyph differs between the two states. private const string FixArrowCollapsed = "▼"; @@ -83,6 +84,15 @@ internal sealed class SerializeReferenceProjectView : VisualElement private VisualElement _openPickerCard; private readonly AspidGradientButton _scanButton; + // Required-violations audit has no incrementally-maintained index like SerializeReferenceTypeUsageIndex, so it + // is only (re)scanned on an explicit Scan/Rescan click, not on every Initialize() (tab switch would otherwise + // pay for a full project sweep). Static so the result survives the view being rebuilt on a tab switch. + private static bool _requiredIsWarm; + private static IReadOnlyList _requiredViolationsCache = Array.Empty(); + + private static IReadOnlyList RequiredViolationsForRender => + _requiredIsWarm ? _requiredViolationsCache : Array.Empty(); + /// /// Jump from a project-audit result row to that asset's Inspect graph. Wired by the host window. /// @@ -162,7 +172,7 @@ public SerializeReferenceProjectView() // results survive a tab switch. The breakage-notification deep-link bypasses this and calls ScanProject directly. public void Initialize() { - if (SerializeReferenceTypeUsageIndex.IsWarm) RenderWarmGroups(); + if (SerializeReferenceTypeUsageIndex.IsWarm || _requiredIsWarm) RenderWarmGroups(); else ShowIdle(); } @@ -177,6 +187,12 @@ public void ScanProject() ClosePicker(); ClearSummaries(); + + // Unlike the missing-type index, the required-field scan has nothing incremental behind it — this is the + // one deliberate moment it pays for a full project sweep (see RequiredViolationsForRender). + _requiredViolationsCache = CollectRequiredViolations(); + _requiredIsWarm = true; + RenderWarmGroups(); } @@ -187,35 +203,88 @@ private void RenderWarmGroups() if (_scanButton is not null) _scanButton.Text = RescanLabel; var groups = CollectProjectGroups(out var canceled); - RenderGroups(groups, canceled); + RenderGroups(groups, RequiredViolationsForRender, canceled); } - // Paints a collected group set: count header + hint + one card per group, or the terminal hero when empty. - // ApplyGroupFix special-cases the came-back-clean case so its summary HelpBox survives (see there). - private void RenderGroups(List groups, bool canceled) + // Full project sweep for unset [TypeSelector(Required = true)] fields, reusing the same headless scanner the + // build/CI gate uses. Skipped entirely when the gate is switched Off — a required audit nobody wants to fail + // or warn on shouldn't cost a full-project YAML sweep on every Scan click either. + private static IReadOnlyList CollectRequiredViolations() => + SerializeReferenceSettings.BuildSeverity == GateSeverity.Off + ? Array.Empty() + : SerializeReferenceGateScanner.Scan(GateOptions.RequiredOnly); + + // Paints a collected group set: count header + hint + one card per broken-type group plus one Required + // violations card, or the terminal hero when both are empty. ApplyGroupFix/ClearGroupToNull special-case the + // came-back-clean case so their summary HelpBox survives (see there). + private void RenderGroups(List groups, IReadOnlyList requiredViolations, bool canceled) { _list.Clear(); - if (groups.Count == 0) + var missingCount = groups.Sum(group => group.Entries.Count); + var requiredCount = requiredViolations.Count; + + if (missingCount == 0 && requiredCount == 0) { ShowEmptyState( success: !canceled, title: canceled ? "Scan canceled" : "Project clean", message: canceled ? "The project scan was canceled before finding any missing references." - : "No missing managed references found anywhere under Assets/."); + : "No missing managed references or unset required fields found anywhere under Assets/."); return; } - var entryCount = groups.Sum(group => group.Entries.Count); - ShowResults(entryCount == 1 ? "1 missing reference" : $"{entryCount} missing references", - SerializeReferenceCanvasStyle.Warning); - _resultsHint.text = canceled - ? "Scan canceled — showing partial results. Each group is a broken type; Fix all re-points every entry (or pick to clear them to null) across every file at once." - : "Each group is a broken stored type. Fix all picks one replacement and re-points every entry across every affected file at once — or pick to clear them to null."; + ShowResults(BuildResultsHeaderText(missingCount, requiredCount), SerializeReferenceCanvasStyle.Warning); + _resultsHint.text = BuildResultsHintText(canceled, requiredCount > 0); foreach (var group in groups) _list.AddChild(BuildGroupCard(group)); + + if (requiredCount > 0) + _list.AddChild(BuildRequiredGroupCard(requiredViolations)); + } + + private static string BuildCountText(int count, string noun) => + count == 1 ? $"1 {noun}" : $"{count} {noun}s"; + + private static string BuildResultsHeaderText(int missingCount, int requiredCount) + { + var missingText = BuildCountText(missingCount, "missing reference"); + var requiredText = BuildCountText(requiredCount, "required violation"); + + if (missingCount > 0 && requiredCount > 0) return $"{missingText}, {requiredText}"; + return missingCount > 0 ? missingText : requiredText; + } + + private static string BuildResultsHintText(bool canceled, bool hasRequiredViolations) + { + var hint = canceled + ? "Scan canceled — showing partial results. Each group is a broken type; Fix all re-points every entry (or pick to clear them to null) across every file at once." + : "Each group is a broken stored type. Fix all picks one replacement and re-points every entry across every affected file at once — or pick to clear them to null."; + + if (hasRequiredViolations) + hint += " Required violations below list every unset [TypeSelector(Required = true)] field the same scan found — click a row to jump to its asset."; + + return hint; + } + + // Shared "no missing references left" branch for ApplyGroupFix/ClearGroupToNull: stays in the results region + // (not the clean-state hero) so the fix's summary receipt survives, while still surfacing whatever Required + // violations card RequiredViolationsForRender currently reports (empty right after ClearGroupToNull, which + // invalidates the cache instead of risking a stale under-report — see its _requiredIsWarm = false). + private void ShowMissingReferencesClean() + { + _list.Clear(); + var requiredViolations = RequiredViolationsForRender; + + ShowResults( + requiredViolations.Count == 0 ? "No missing references" : $"No missing references, {BuildCountText(requiredViolations.Count, "required violation")}", + SerializeReferenceCanvasStyle.Success); + _resultsHint.text = "Nothing left to repair. Rescan to sweep the project again and confirm it's clean."; + + if (requiredViolations.Count > 0) + _list.AddChild(BuildRequiredGroupCard(requiredViolations)); } // Groups every unresolved managed reference by stored type, backed by the shared usage index. The out @@ -456,14 +525,11 @@ private void ApplyGroupFix(ProjectGroup group, Type newType) { // The fix cleared the last broken type. Stay in the results region instead of the "Project clean" // hero, which would hide the summary HelpBox receipt; the hero is reserved for an explicit Rescan. - _list.Clear(); - ShowResults("No missing references", SerializeReferenceCanvasStyle.Success); - _resultsHint.text = - "Nothing left to repair. Rescan to sweep the project again and confirm it's clean."; + ShowMissingReferencesClean(); } else { - RenderGroups(groups, canceled); + RenderGroups(groups, RequiredViolationsForRender, canceled); } ShowSummary(summaryTitle, summaryBody, Undo); @@ -510,7 +576,7 @@ private void ClearGroupToNull(ProjectGroup group) if (cleared == 0) { if (_scanButton is not null) _scanButton.Text = RescanLabel; - RenderGroups(CollectProjectGroups(out var rescanCanceled), rescanCanceled); + RenderGroups(CollectProjectGroups(out var rescanCanceled), RequiredViolationsForRender, rescanCanceled); return; } @@ -521,20 +587,22 @@ private void ClearGroupToNull(ProjectGroup group) ? " 1 was nulled in memory — save the asset to persist it (still listed until saved)." : $" {clearedInMemory} were nulled in memory — save the assets to persist them (still listed until saved)."; + // Unlike Fix all (which only swaps a stored type name, never nulls anything), Clear to null CAN turn a + // required field that held a broken-but-non-null reference into a genuine unset-required violation — drop + // the stale cache so the Required violations card doesn't under-report until the user rescans. + _requiredIsWarm = false; + if (_scanButton is not null) _scanButton.Text = RescanLabel; var groups = CollectProjectGroups(out var canceled); if (groups.Count == 0) { // Same came-back-clean handling as ApplyGroupFix: keep the receipt visible instead of the hero. - _list.Clear(); - ShowResults("No missing references", SerializeReferenceCanvasStyle.Success); - _resultsHint.text = - "Nothing left to repair. Rescan to sweep the project again and confirm it's clean."; + ShowMissingReferencesClean(); } else { - RenderGroups(groups, canceled); + RenderGroups(groups, RequiredViolationsForRender, canceled); } // No Undo: clearing discards the broken payload (see ClearGroupToNull). The receipt is a plain record. @@ -768,7 +836,7 @@ private void UndoGroupFix(IReadOnlyList entries, ManagedTypeName o // never _summaries, so the surviving receipts stay put. receipt?.RemoveFromHierarchy(); var groups = CollectProjectGroups(out var canceled); - RenderGroups(groups, canceled); + RenderGroups(groups, RequiredViolationsForRender, canceled); // BatchRewriteEntries can come up short if a file changed between the check and the write — report the real count. var undoTitle = reverted == 1 ? "Reverted 1 reference" : $"Reverted {reverted} references"; @@ -831,6 +899,108 @@ private static bool TryGetGroupSuggestion(ProjectGroup group, Type constraint, o return true; } + // --------------------------------------------------------------------------------------------------------- + // Required violations + // --------------------------------------------------------------------------------------------------------- + + // Flat read-only list of every unset [TypeSelector(Required = true)] field, fed by the same headless scanner + // as the build/CI gate. No bulk fix here — unlike a broken type, an empty required field has nothing sensible + // to auto-assign, so the row's only affordance is jumping to the offending asset. + private VisualElement BuildRequiredGroupCard(IReadOnlyList violations) + { + var card = new AspidBox(AspidBoxPreset.Default.SetTheme(ThemeStyle.Type.Darkness)) + .AddClass(GroupClass); + + var header = new AspidLabel("Required violations", AspidLabelPreset.Default + .SetLabelStatus(StatusStyle.Type.Warning) + .SetLabelSize(AspidLabelSizeStyle.Type.H5) + .SetLineSize(AspidDividingLineSizeStyle.Type.None)) + .AddClass(GroupHeaderClass) + .SetPickingMode(PickingMode.Ignore); + + var count = new Label(BuildCountText(violations.Count, "entry")) + .AddClass(GroupCountClass) + .SetPickingMode(PickingMode.Ignore); + + var info = new VisualElement() + .AddClass(GroupHeaderRowClass) + .AddChild(header) + .AddChild(count); + info.pickingMode = PickingMode.Ignore; + + card.AddChild(info); + + // Per-card memo, keyed by asset path: several violations commonly share one asset (e.g. a prefab with + // multiple unset required fields), so this keeps LoadAllAssetsAtPath to once per distinct file instead of + // once per row. + var componentCache = new Dictionary(StringComparer.Ordinal); + foreach (var violation in violations) + card.AddChild(BuildRequiredViolationRow(violation, componentCache)); + + return card; + } + + // Read-only entry row: asset path on the left, "Component.field" on the right; the whole row jumps to the + // asset — same cross-link as a broken-reference row (BuildGroupEntryRow). + private VisualElement BuildRequiredViolationRow(GateViolation violation, Dictionary componentCache) + { + var row = new VisualElement().AddClass(GroupEntryClass); + + var path = new Label(violation.AssetPath) + .AddClass(GroupEntryPathClass); + path.tooltip = violation.AssetPath; + + var field = new Label(BuildRequiredViolationFieldText(violation, componentCache)) + .AddClass(GroupEntryFieldClass) + .SetPickingMode(PickingMode.Ignore); + + row.AddChild(path).AddChild(field); + row.RegisterCallback(_ => + { + var asset = AssetDatabase.LoadMainAssetAtPath(violation.AssetPath); + if (asset is null) return; + + // Cross-link: jump to the asset's full Inspect graph; ping as a fallback when hosted standalone. + if (OnInspectAsset is not null) OnInspectAsset(asset); + else EditorGUIUtility.PingObject(asset); + }); + + return row; + } + + // "Component.field" for the entry row's right column; GateViolation itself carries no owning-object type, + // only the asset path and file id, so the component name is resolved on demand for display. + private static string BuildRequiredViolationFieldText(GateViolation violation, Dictionary componentCache) + { + var component = ResolveComponentName(violation, componentCache); + return string.IsNullOrEmpty(component) ? violation.FieldPath : $"{component}.{violation.FieldPath}"; + } + + // Best-effort owning-object type name. Saved assets are object-loaded (once per distinct path, memoised in + // componentCache) and matched by file id — the same lookup + // SerializeReferenceGateScanner.CollectRequiredViolations uses internally to build each violation, just for + // display here. Scenes cannot be object-loaded (see SerializeReferenceHelpers.IsScene), so a scene row shows + // the field path alone rather than guessing a component name. + private static string ResolveComponentName(GateViolation violation, Dictionary componentCache) + { + if (SerializeReferenceHelpers.IsScene(violation.AssetPath)) return string.Empty; + + if (!componentCache.TryGetValue(violation.AssetPath, out var assets)) + { + assets = AssetDatabase.LoadAllAssetsAtPath(violation.AssetPath); + componentCache[violation.AssetPath] = assets; + } + + foreach (var asset in assets) + { + if (asset == null) continue; + if (AssetDatabase.TryGetGUIDAndLocalFileIdentifier(asset, out _, out long fileId) && fileId == violation.FileId) + return asset.GetType().Name; + } + + return string.Empty; + } + // --------------------------------------------------------------------------------------------------------- // Shared picker / results plumbing // --------------------------------------------------------------------------------------------------------- diff --git a/docs/QA-CHECKLIST.md b/docs/QA-CHECKLIST.md index 1acf4db9..b72a2cc4 100644 --- a/docs/QA-CHECKLIST.md +++ b/docs/QA-CHECKLIST.md @@ -73,6 +73,7 @@ - [ ] **Welcome**: auto-shows once per package version (incl. after an update), Auto-show toggle, samples list read from package.json, the menu entry always works. - [ ] **Asset References**: YAML-driven graph — roots/nested/shared/orphaned, `MISSING`/`SHARED` badges, per-rid colours, `` slots, full field paths; inline constrained Fix, Clear on orphaned entries, Open Source Prefab; pending-migration card (info pill, `Migrate → Type`), headline counts migrations separately; tab and inspected asset persist across tab switches and domain reloads. - [ ] **Project References**: Scan Project over `.prefab`/`.asset`/`.unity`, grouping by type, `Fix all (N)` with confirm + diff preview + Undo (the undo receipt reverts only untouched entries and reports the actual count), per-group Smart Fix quick-apply, open scenes / Prefab Mode entries skipped, `Migrate all (N) → Type` for [MovedFrom]; a result links to that asset's Asset References graph. +- [ ] **Project References — Required violations**: a separate card lists every unset `[TypeSelector(Required = true)]` field found by the same gate scan (asset path + component + field path), skipped entirely when the build/CI gate is Off, respects `Excluded Folders`; clicking a row pings/opens the asset like a broken-reference row (no bulk fix — nothing sensible to auto-assign); only (re)scanned on an explicit Scan/Rescan click, not on a tab switch. - [ ] **Settings**: see section 6. - [ ] Ctrl+Tab / Ctrl+Shift+Tab switch tabs. diff --git a/docs/QA-CHECKLIST_RU.md b/docs/QA-CHECKLIST_RU.md index cbef9429..c969e126 100644 --- a/docs/QA-CHECKLIST_RU.md +++ b/docs/QA-CHECKLIST_RU.md @@ -73,6 +73,7 @@ - [ ] **Welcome**: авто-показ раз на версию пакета (и после апдейта), toggle Auto-show, список сэмплов из package.json, меню работает всегда. - [ ] **Asset References**: граф по YAML — roots/nested/shared/orphaned, бейджи `MISSING`/`SHARED`, rid-цвета, ``-слоты, полный field-path; inline Fix (констрейнутый), Clear на orphaned, Open Source Prefab; pending-migration карточка (info-pill, `Migrate → Type`), headline считает миграции отдельно; персист вкладки и ассета через tab-switch и domain reload. - [ ] **Project References**: Scan Project по `.prefab`/`.asset`/`.unity`, группировка по типу, `Fix all (N)` с confirm + diff-preview + Undo (undo-квитанция ревертит только неизменённые записи и репортит фактическое число), Smart Fix quick-apply на группе, skip открытых сцен/Prefab Mode, `Migrate all (N) → Type` для [MovedFrom]; линк из результата в Asset References. +- [ ] **Project References — Required violations**: отдельная карточка со всеми незаполненными `[TypeSelector(Required = true)]`-полями из того же gate-скана (путь ассета + компонент + field path), полностью пропускается при выключенном (Off) build/CI gate, учитывает `Excluded Folders`; клик по строке пингует/открывает ассет как строка broken-reference (без bulk-fix — нечего осмысленно назначить автоматически); (пере)сканируется только по явному клику Scan/Rescan, не при переключении вкладки. - [ ] **Settings**: см. раздел 6. - [ ] Ctrl+Tab / Ctrl+Shift+Tab — переключение вкладок. From 1a32c9b34d2fa6339630ba7c29c5dfb6edd0188a Mon Sep 17 00:00:00 2001 From: Vladislav Panin Date: Tue, 21 Jul 2026 17:48:00 +0300 Subject: [PATCH 2/4] style(serializereferences): align Required-violations field column Co-Authored-By: Claude --- .../Aspid-FastTools-SerializeReference.uss | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Resources/UI/SerializeReferences/Aspid-FastTools-SerializeReference.uss b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Resources/UI/SerializeReferences/Aspid-FastTools-SerializeReference.uss index c37ad533..9e9055fe 100644 --- a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Resources/UI/SerializeReferences/Aspid-FastTools-SerializeReference.uss +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Resources/UI/SerializeReferences/Aspid-FastTools-SerializeReference.uss @@ -664,13 +664,19 @@ -unity-text-align: middle-right; } -/* A required-violation row's "Component.field" column — same dim, right-pinned treatment as __group-entry-rid, one - column over for a different row shape (Required violations card has no rid). */ +/* A required-violation row's "Component.field" column. Unlike __group-entry-rid ("rid 1234", short and uniform), + this text varies widely in length (a bare field name vs. "Component.nested.field"), so right-aligning it in an + auto-width box left every row's text starting at a different x — reading as unaligned. A fixed width anchors + every row's text to the same left edge instead; white-space:normal wraps the rare entry that overflows it, + matching how the path column wraps. */ .aspid-fasttools-repair-references__group-entry-field { + width: 260px; flex-shrink: 0; + flex-grow: 0; margin-left: 10px; color: var(--aspid-colors-text-dark); - -unity-text-align: middle-right; + white-space: normal; + -unity-text-align: middle-left; } /* A compatible MonoScript dragged over the field highlights the header as a valid drop target. */ From f9227aefb233988c05385757c16687c5936928b9 Mon Sep 17 00:00:00 2001 From: Vladislav Panin Date: Tue, 21 Jul 2026 17:58:53 +0300 Subject: [PATCH 3/4] Revert "style(serializereferences): align Required-violations field column" This reverts commit 1a32c9b34d2fa6339630ba7c29c5dfb6edd0188a. --- .../Aspid-FastTools-SerializeReference.uss | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Resources/UI/SerializeReferences/Aspid-FastTools-SerializeReference.uss b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Resources/UI/SerializeReferences/Aspid-FastTools-SerializeReference.uss index 9e9055fe..c37ad533 100644 --- a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Resources/UI/SerializeReferences/Aspid-FastTools-SerializeReference.uss +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Resources/UI/SerializeReferences/Aspid-FastTools-SerializeReference.uss @@ -664,19 +664,13 @@ -unity-text-align: middle-right; } -/* A required-violation row's "Component.field" column. Unlike __group-entry-rid ("rid 1234", short and uniform), - this text varies widely in length (a bare field name vs. "Component.nested.field"), so right-aligning it in an - auto-width box left every row's text starting at a different x — reading as unaligned. A fixed width anchors - every row's text to the same left edge instead; white-space:normal wraps the rare entry that overflows it, - matching how the path column wraps. */ +/* A required-violation row's "Component.field" column — same dim, right-pinned treatment as __group-entry-rid, one + column over for a different row shape (Required violations card has no rid). */ .aspid-fasttools-repair-references__group-entry-field { - width: 260px; flex-shrink: 0; - flex-grow: 0; margin-left: 10px; color: var(--aspid-colors-text-dark); - white-space: normal; - -unity-text-align: middle-left; + -unity-text-align: middle-right; } /* A compatible MonoScript dragged over the field highlights the header as a valid drop target. */ From 471b0a25c2535eaca4a677b94732a18ba6540575 Mon Sep 17 00:00:00 2001 From: Vladislav Panin Date: Tue, 21 Jul 2026 21:36:22 +0300 Subject: [PATCH 4/4] feat(serialize-references): add assign pickers for unset required fields - Asset References: required string/SerializableType fields get the same empty-slot card as managed references, with an inline "Assign Required" picker that writes the picked type's AQN via the live serialization API - style required cards like Missing (amber header and band accent, REQUIRED badge dropped); overview headline, hint and canvas tone now report "N required fields unassigned" instead of a green all-clear - Project References: align the Required-violations header with group cards and fix "entrys" pluralization Co-Authored-By: Claude --- .../Aspid-FastTools-ReferenceGraph.uss | 39 -- .../Aspid-FastTools-SerializeReference.uss | 8 + .../Windows/SerializeReferenceGraphView.cs | 335 +++++++++++++----- .../Windows/SerializeReferenceProjectView.cs | 4 +- 4 files changed, 257 insertions(+), 129 deletions(-) diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Resources/UI/SerializeReferences/Aspid-FastTools-ReferenceGraph.uss b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Resources/UI/SerializeReferences/Aspid-FastTools-ReferenceGraph.uss index 04feb265..f1992cb3 100644 --- a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Resources/UI/SerializeReferences/Aspid-FastTools-ReferenceGraph.uss +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Resources/UI/SerializeReferences/Aspid-FastTools-ReferenceGraph.uss @@ -434,13 +434,6 @@ background-color: var(--aspid-colors-status-info-dark); } -/* Unset [TypeSelector(Required = true)] managed reference — same warning tone as --missing (unused today), on an - empty slot's band instead of a resolved node's. */ -.aspid-fasttools-reference-graph__badge--required { - color: var(--aspid-colors-status-warning-text-lightness); - background-color: var(--aspid-colors-status-warning-dark); -} - /* The deterministic per-rid colour chip beside the SHARED badge (its background colour is set inline from code). */ .aspid-fasttools-reference-graph__chip { width: 8px; @@ -482,38 +475,6 @@ -unity-text-align: middle-center; } -/* --- Required-only group --- - Trailing card for required violations with no graph node (string / SerializableType fields) — same warm - translucent-fill treatment as the Orphaned group, so it reads as the same "warning zone" family, but a flat list - of rows (no node cards / bands: these fields have no picker here, they edit through the normal Inspector). */ -.aspid-fasttools-reference-graph__required-only-group.aspid-fasttools-background { - flex-grow: 0; - margin-top: 6px; - padding: 8px 10px; - border-radius: 8px; - border-width: 1px; - border-color: var(--aspid-colors-status-warning-shade-darkness); - background-color: rgba(45, 30, 5, 0.3); -} - -.aspid-fasttools-reference-graph__required-only-group-header { - margin-bottom: 4px; -} - -.aspid-fasttools-reference-graph__required-only-entry { - flex-direction: row; - align-items: center; - justify-content: space-between; - padding: 2px 4px; -} - -.aspid-fasttools-reference-graph__required-only-entry-path { - flex-grow: 1; - flex-shrink: 1; - min-width: 0; - color: var(--aspid-colors-text-light); - white-space: normal; -} /* --- Inline type picker (mirrors the Project Audit picker exactly) --- The selector view expanded inline, welded into the card being fixed (see TogglePicker): translucent dark fill, diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Resources/UI/SerializeReferences/Aspid-FastTools-SerializeReference.uss b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Resources/UI/SerializeReferences/Aspid-FastTools-SerializeReference.uss index c37ad533..24f9438c 100644 --- a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Resources/UI/SerializeReferences/Aspid-FastTools-SerializeReference.uss +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Resources/UI/SerializeReferences/Aspid-FastTools-SerializeReference.uss @@ -586,6 +586,14 @@ align-items: center; } +/* The Required-violations card has no Fix all button hosting its header row, so the static row carries the same + 4px horizontal padding and 6px gap the button gives the broken-type cards — keeping every card's header and + entry paths on one shared left edge. */ +.aspid-fasttools-repair-references__group-header-row--static { + padding: 4px 4px; + margin-bottom: 6px; +} + .aspid-fasttools-repair-references__group-header { flex-shrink: 1; } diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Windows/SerializeReferenceGraphView.cs b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Windows/SerializeReferenceGraphView.cs index 9d311ef5..7b772987 100644 --- a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Windows/SerializeReferenceGraphView.cs +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Windows/SerializeReferenceGraphView.cs @@ -80,16 +80,11 @@ internal sealed class SerializeReferenceGraphView : VisualElement private const string BadgeClass = RootClass + "__badge"; private const string BadgeSharedClass = BadgeClass + "--shared"; - private const string BadgeRequiredClass = BadgeClass + "--required"; private const string ChipClass = RootClass + "__chip"; private const string ClearOrphanClass = RootClass + "__clear-orphan"; private const string OrphanGroupClass = RootClass + "__orphan-group"; private const string OrphanGroupHeaderClass = RootClass + "__orphan-group-header"; - private const string RequiredOnlyGroupClass = RootClass + "__required-only-group"; - private const string RequiredOnlyGroupHeaderClass = RootClass + "__required-only-group-header"; - private const string RequiredOnlyEntryClass = RootClass + "__required-only-entry"; - private const string RequiredOnlyEntryPathClass = RootClass + "__required-only-entry-path"; private const string PickerClass = RootClass + "__picker"; private const string PickerAttachedClass = PickerClass + "--attached"; @@ -98,6 +93,9 @@ internal sealed class SerializeReferenceGraphView : VisualElement private const string ChangeCollapsedText = "Change ▼"; private const string AssignCollapsedText = "Assign ▼"; + // A required slot's band verb names what the amber is about: the field must be assigned, not merely can be. + private const string AssignRequiredCollapsedText = "Assign Required ▼"; + // A pending-migration card is not missing (Unity migrates it in memory; only the file is stale), so no "Missing". private const string MigrateFixCollapsedText = "Fix ▼"; private const char BandChevronCollapsed = '▼'; @@ -132,11 +130,11 @@ internal sealed class SerializeReferenceGraphView : VisualElement // so each Fix-Missing picker open must not re-scan. Cleared on every Rescan / apply so rewritten YAML is re-read. private readonly Dictionary> _constraintCache = new(StringComparer.Ordinal); - // Unset [SerializeReference, TypeSelector(Required = true)] fields for the current asset, refreshed on every - // Rescan. Populated straight from SerializeReferenceGateScanner — the same required-field check the Project - // References audit and the build/CI gate use — so a required badge here always agrees with them. A - // [TypeSelector(Required = true)] string/SerializableType field has no rid and so no graph node to badge; it - // still surfaces via the Inspector notice and the Project References "Required violations" group. + // Unset [TypeSelector(Required = true)] fields for the current asset, refreshed on every Rescan. Populated + // straight from SerializeReferenceGateScanner — the same required-field check the Project References audit + // and the build/CI gate use — so the amber required styling here always agrees with them. A required + // string/SerializableType field has no rid and so no graph node; it gets its own trailing card instead + // (BuildRequiredOnlyCard). private IReadOnlyList _requiredViolations = Array.Empty(); public SerializeReferenceGraphView(Object target, Action onCanvasTone, Action onTargetChanged = null) @@ -294,7 +292,7 @@ private void Rescan(List prebuilt = null) var empties = 0; var migrations = 0; - // Every empty managed-reference slot's normalized path, gathered up front so the required-only group + // Every empty managed-reference slot's normalized path, gathered up front so the required-only cards // below can tell "already badged on a graph card" apart from "no graph node exists for this field at // all" (a string / SerializableType required field, or an empty slot under a document the scanner // failed to reach) without re-walking the tree per violation. @@ -313,25 +311,35 @@ private void Rescan(List prebuilt = null) empties += CountEmptySlots(document); } - ShowOverview(total, missing, orphans, empties, migrations); - // Fields the graph has no node for at all: string / SerializableType required fields (never threaded // into RefIds) plus, defensively, any managed-reference violation the graph walk could not place. var ungraphedRequired = _requiredViolations .Where(v => !emptySlotPaths.Contains((v.FileId, v.FieldPath))) .ToList(); + + // The headline's "unassigned fields" note counts only slots that are allowed to stay empty — a required + // empty slot is reported through the required count instead, never twice. + var required = _requiredViolations.Count; + var graphedRequired = required - ungraphedRequired.Count; + ShowOverview(total, missing, orphans, Math.Max(0, empties - graphedRequired), migrations, required); + if (ungraphedRequired.Count > 0) - _list.AddChild(BuildRequiredOnlyGroup(ungraphedRequired)); + { + // Per-card memo, keyed by asset path: several violations commonly share one component, so this keeps + // LoadAllAssetsAtPath to once per distinct file instead of once per card. + var componentCache = new Dictionary(StringComparer.Ordinal); + foreach (var violation in ungraphedRequired) + _list.AddChild(BuildRequiredOnlyCard(violation, componentCache)); + } // Pending migrations are not breakages — a graph whose only annotations are migrations reads info-blue, - // matching the Project References group card; anything actually missing / orphaned keeps the amber wash. - _onCanvasTone?.Invoke(missing - migrations > 0 || orphans > 0 + // matching the Project References group card; anything missing / orphaned / required-unset keeps the + // amber wash (same issue set that tips the ShowOverview headline). + _onCanvasTone?.Invoke(missing - migrations > 0 || orphans > 0 || required > 0 ? SerializeReferenceCanvasStyle.Warning : migrations > 0 ? SerializeReferenceCanvasStyle.Info - : ungraphedRequired.Count > 0 - ? SerializeReferenceCanvasStyle.Warning - : SerializeReferenceCanvasStyle.Success); + : SerializeReferenceCanvasStyle.Success); } // Every empty managed-reference slot's normalized field path across every document, root and nested edge @@ -481,12 +489,13 @@ private void ShowResults() _list.RemoveClass(ListHiddenClass); } - private void ShowOverview(int total, int missing, int orphans, int empties, int migrations) + private void ShowOverview(int total, int missing, int orphans, int empties, int migrations, int required) { - // Only genuinely missing / orphaned references are "issues" that tip the headline and divider to amber; - // pending migrations are stale files, not breakages (info), and empty slots are unassigned, not broken. + // Genuinely missing / orphaned references and unset required fields are "issues" that tip the headline + // and divider to amber; pending migrations are stale files, not breakages (info), and non-required empty + // slots are unassigned, not broken. var broken = missing - migrations; - var status = broken > 0 || orphans > 0 + var status = broken > 0 || orphans > 0 || required > 0 ? StatusStyle.Type.Warning : migrations > 0 ? StatusStyle.Type.Info @@ -496,18 +505,20 @@ private void ShowOverview(int total, int missing, int orphans, int empties, int ? broken == 1 ? "1 missing reference" : $"{broken} missing references" : orphans > 0 ? orphans == 1 ? "1 orphaned reference" : $"{orphans} orphaned references" - : migrations > 0 - ? migrations == 1 ? "1 pending migration" : $"{migrations} pending migrations" - : "No missing references"; + : required > 0 + ? required == 1 ? "1 required field unassigned" : $"{required} required fields unassigned" + : migrations > 0 + ? migrations == 1 ? "1 pending migration" : $"{migrations} pending migrations" + : "No missing references"; _overviewTitle.LabelStatus = status; _overviewTitle.LineStatus = status; - _overviewHint.text = BuildOverviewHint(total, missing, orphans, empties, migrations); + _overviewHint.text = BuildOverviewHint(total, missing, orphans, empties, migrations, required); _overview.RemoveClass(OverviewHiddenClass); } - private static string BuildOverviewHint(int total, int missing, int orphans, int empties, int migrations) + private static string BuildOverviewHint(int total, int missing, int orphans, int empties, int migrations, int required) { var references = total == 1 ? "1 managed reference" : $"{total} managed references"; var emptyNote = empties switch @@ -517,22 +528,25 @@ private static string BuildOverviewHint(int total, int missing, int orphans, int _ => $" · {empties} unassigned fields" }; - if (missing == 0 && orphans == 0) + if (missing == 0 && orphans == 0 && required == 0) return $"{references} mapped{emptyNote} — every [SerializeReference] type resolves."; var broken = missing - migrations; - var parts = new List(4); + var parts = new List(5); if (broken > 0) parts.Add(broken == 1 ? "1 missing type" : $"{broken} missing types"); if (migrations > 0) parts.Add(migrations == 1 ? "1 pending [MovedFrom] migration" : $"{migrations} pending [MovedFrom] migrations"); if (orphans > 0) parts.Add(orphans == 1 ? "1 orphaned rid" : $"{orphans} orphaned rids"); + if (required > 0) parts.Add(required == 1 ? "1 required field unassigned" : $"{required} required fields unassigned"); if (empties > 0) parts.Add(empties == 1 ? "1 unassigned field" : $"{empties} unassigned fields"); var action = broken > 0 ? "Fix a missing type inline from its card." - : migrations > 0 - ? "Migrate a renamed type from its card — the Inspector already loads it; only the file is stale." - : "Clear an orphaned rid from its card."; + : required > 0 + ? "Assign each required field from its amber card." + : migrations > 0 + ? "Migrate a renamed type from its card — the Inspector already loads it; only the file is stale." + : "Clear an orphaned rid from its card."; return $"{references} mapped · {string.Join(" · ", parts)}. {action}"; } @@ -830,32 +844,33 @@ private bool IsFieldRequiredUnset(long fileId, string pathLabel) // An unassigned [SerializeReference] slot — a field whose pointer is the null sentinel (rid -2). Its band is // still a dropdown assigning a type through the live serialization API; a slot whose field path could not be - // recovered stays static (nothing to target). + // recovered stays static (nothing to target). A required slot wears the missing card's clothes — amber + // "" header and amber band accent, no badge — so every "fix this" card in the graph reads the same. private VisualElement BuildEmptySlotCard(string assetPath, long fileId, string pathLabel) { - var card = new AspidBox(AspidBoxPreset.Default.SetTheme(ThemeStyle.Type.Darkness)) - .AddClass(NodeClass) - .AddClass(NodeEmptyClass); + var isRequired = IsFieldRequiredUnset(fileId, pathLabel); - // A plain Label so the --empty USS rule tints it, rather than an AspidLabel painting its own status colour. - var typeLabel = new Label(EmptySlotText) - .AddClass(NodeTypeClass) - .SetPickingMode(PickingMode.Ignore); + var card = new AspidBox(AspidBoxPreset.Default.SetTheme(ThemeStyle.Type.Darkness)) + .AddClass(NodeClass); + if (!isRequired) card.AddClass(NodeEmptyClass); + + // A plain Label on an ordinary empty slot so the --empty USS rule tints it; a required slot paints its + // own amber status via AspidLabel, exactly like a missing card's type header. + var typeLabel = isRequired + ? (VisualElement)new AspidLabel(EmptySlotText, AspidLabelPreset.Default + .SetLabelStatus(StatusStyle.Type.Warning) + .SetLabelSize(AspidLabelSizeStyle.Type.H5) + .SetLineSize(AspidDividingLineSizeStyle.Type.None)) + .AddClass(NodeTypeClass) + : new Label(EmptySlotText).AddClass(NodeTypeClass); + typeLabel.SetPickingMode(PickingMode.Ignore); + if (isRequired) typeLabel.tooltip = "Required reference is not set"; var bandRow = new VisualElement() .AddClass(NodeBandRowClass) .AddChild(typeLabel); bandRow.pickingMode = PickingMode.Ignore; - if (IsFieldRequiredUnset(fileId, pathLabel)) - { - var badges = new VisualElement().AddClass(NodeBadgesClass).SetPickingMode(PickingMode.Ignore); - var required = new Label("REQUIRED").AddClass(BadgeClass).AddClass(BadgeRequiredClass); - required.tooltip = "Required reference is not set"; - badges.AddChild(required); - bandRow.AddChild(badges); - } - if (string.IsNullOrEmpty(pathLabel)) { // No recoverable field path to target — leave the slot a static "" leaf. @@ -866,8 +881,10 @@ private VisualElement BuildEmptySlotCard(string assetPath, long fileId, string p // is a no-op here — the slot is already unset. var graphPath = pathLabel; AspidGradientButton band = null; - band = new AspidGradientButton(AssignCollapsedText, _ => OpenLivePicker(assetPath, fileId, graphPath, band)) + band = new AspidGradientButton(isRequired ? AssignRequiredCollapsedText : AssignCollapsedText, + _ => OpenLivePicker(assetPath, fileId, graphPath, band)) .AddClass(NodeBandClass); + if (isRequired) band.AddClass(NodeBandMissingClass); band.AddLeadingContent(bandRow); card.AddChild(band); } @@ -890,44 +907,176 @@ private VisualElement BuildEmptySlotCard(string assetPath, long fileId, string p return card; } - // Trailing card for required violations the graph has no node for — a string / SerializableType required - // field (never threaded into RefIds, so SerializeReferenceGraphScanner never emits a document for a - // component whose only serialized-reference-worthy fields are these) has nothing to badge a card with, so - // it gets a flat row here instead. Same visual family as the Orphaned group: a standalone warning-toned box. - private VisualElement BuildRequiredOnlyGroup(IReadOnlyList violations) + // Trailing cards for required violations the graph has no node for — a string / SerializableType required + // field is never threaded into RefIds, so SerializeReferenceGraphScanner never emits a document for a + // component whose only serialized-reference-worthy fields are these. + // Mirrors a required BuildEmptySlotCard line for line — amber "" header, amber "Assign ▼" band, + // "Component.field:" + "unassigned" on the footer — so a required string / SerializableType field reads + // exactly like a required managed-reference slot (and both echo the missing card's clothes). The pick writes + // the type's assembly-qualified name into the backing string; a scene asset cannot be object-loaded (see + // TryResolveRequiredStringProperty), so its band stays a static line edited through the normal Inspector. + private VisualElement BuildRequiredOnlyCard(GateViolation violation, Dictionary componentCache) { - var group = new AspidBox(AspidBoxPreset.Default.SetTheme(ThemeStyle.Type.Darkness)) - .AddClass(RequiredOnlyGroupClass); + var card = new AspidBox(AspidBoxPreset.Default.SetTheme(ThemeStyle.Type.Darkness)) + .AddClass(NodeClass); - group.AddChild(new AspidLabel("Required", AspidLabelPreset.Default + var typeLabel = new AspidLabel(EmptySlotText, AspidLabelPreset.Default .SetLabelStatus(StatusStyle.Type.Warning) .SetLabelSize(AspidLabelSizeStyle.Type.H5) .SetLineSize(AspidDividingLineSizeStyle.Type.None)) - .AddClass(RequiredOnlyGroupHeaderClass)); + .AddClass(NodeTypeClass) + .SetPickingMode(PickingMode.Ignore); + typeLabel.tooltip = "Required type is not set"; - // Per-card memo, keyed by asset path: several violations commonly share one component, so this keeps - // LoadAllAssetsAtPath to once per distinct file instead of once per row. - var componentCache = new Dictionary(StringComparer.Ordinal); - foreach (var violation in violations) - group.AddChild(BuildRequiredOnlyRow(violation, componentCache)); + var bandRow = new VisualElement() + .AddClass(NodeBandRowClass) + .AddChild(typeLabel); + bandRow.pickingMode = PickingMode.Ignore; - return group; + if (SerializeReferenceHelpers.IsScene(violation.AssetPath)) + { + // Not reachable through the live serialization API — leave the band a static "" line. + card.AddChild(bandRow); + } + else + { + AspidGradientButton band = null; + band = new AspidGradientButton(AssignRequiredCollapsedText, _ => OpenRequiredStringPicker(violation, band)) + .AddClass(NodeBandClass) + .AddClass(NodeBandMissingClass); + band.AddLeadingContent(bandRow); + card.AddChild(band); + } + + var component = ResolveComponentName(violation, componentCache); + var text = string.IsNullOrEmpty(component) ? violation.FieldPath : $"{component}.{violation.FieldPath}"; + + var meta = new VisualElement().AddClass(NodeFooterClass); + meta.AddChild(new Label($"{text}:") + .AddClass(NodeRootLabelClass) + .SetPickingMode(PickingMode.Ignore)); + meta.AddChild(new Label("unassigned") + .AddClass(NodeRidClass) + .SetPickingMode(PickingMode.Ignore)); + card.AddChild(meta); + + return card; } - // A plain row: "Component.field" on the left, a REQUIRED badge on the right. No band/picker — the field - // is a string / SerializableType, edited through the normal Inspector, not this graph's live-property API. - private static VisualElement BuildRequiredOnlyRow(GateViolation violation, Dictionary componentCache) + // Constraint and current value are read from the live string property; a field the API cannot reach opens an + // unconstrained picker and surfaces the failure on apply (mirrors OpenLivePicker). + private void OpenRequiredStringPicker(GateViolation violation, AspidGradientButton anchor) { - var row = new VisualElement().AddClass(RequiredOnlyEntryClass); + var filter = default(TypeSelectorFilter); + var currentAqn = string.Empty; - var component = ResolveComponentName(violation, componentCache); - var text = string.IsNullOrEmpty(component) ? violation.FieldPath : $"{component}.{violation.FieldPath}"; + if (TryResolveRequiredStringProperty(violation, out var serializedObject, out var property)) + using (serializedObject) + { + currentAqn = property.stringValue ?? string.Empty; + filter = BuildRequiredStringFilter(serializedObject, property); + } - var path = new Label(text).AddClass(RequiredOnlyEntryPathClass); - var badge = new Label("REQUIRED").AddClass(BadgeClass).AddClass(BadgeRequiredClass); + TogglePicker(anchor, filter, currentAqn, + assemblyQualifiedName => ApplyRequiredString(violation, assemblyQualifiedName)); + } + + // The same candidate set the field's own [TypeSelector] dropdown offers: the attribute's constraints resolved + // member-first against the owning object (TypeSelectorConstraintResolver), the wrapper's T for a + // SerializableType field, and the attribute's kind filter. Resolution warnings are the Inspector notice's + // concern — here an unresolvable constraint just widens the picker. + private static TypeSelectorFilter BuildRequiredStringFilter(SerializedObject serializedObject, SerializedProperty property) + { + if (!SerializeReferenceRequiredGate.TryGetRequired(property, out var selector)) return default; - row.AddChild(path).AddChild(badge); - return row; + var types = new List(); + + // The backing string of a SerializableType wrapper carries the wrapper's generic constraint. + var path = property.propertyPath; + var lastDotIndex = path.LastIndexOf('.'); + if (lastDotIndex >= 0) + { + using var parentProperty = serializedObject.FindProperty(path[..lastDotIndex]); + var parentField = parentProperty?.GetFieldInfo(); + if (parentField is not null && + SerializableTypeUtility.TryGetBaseType(parentField.FieldType, out var wrapperBase) && + wrapperBase is not null && wrapperBase != typeof(object)) + types.Add(wrapperBase); + } + + types.AddRange(TypeSelectorConstraintResolver.Resolve( + serializedObject.targetObject, selector.AssemblyQualifiedNames).Types); + + return new TypeSelectorFilter + { + Types = types.Count > 0 ? types.ToArray() : null, + Allow = selector.Allow, + }; + } + + private void ApplyRequiredString(GateViolation violation, string assemblyQualifiedName) + { + // A non-empty name that fails to load is an unresolved pick, not a clear — leave the field untouched. + // (empty) writes an empty name: for a required field that just keeps the violation visible. + if (!string.IsNullOrEmpty(assemblyQualifiedName) && + Type.GetType(assemblyQualifiedName, throwOnError: false) is null) + return; + + if (!TryResolveRequiredStringProperty(violation, out var serializedObject, out var property)) + { + EditorUtility.DisplayDialog( + "Assign Required Type", + "This field cannot be edited here — it is not reachable through the serialization API. " + + "Edit it in the Inspector instead.", + "OK"); + return; + } + + using (serializedObject) + { + property.SetStringAndApply(assemblyQualifiedName ?? string.Empty); + + var target = serializedObject.targetObject; + EditorUtility.SetDirty(target); + PersistEdit(violation.AssetPath, target); + } + + SerializeReferenceYamlProbeCache.ClearCache(); + Rescan(); + } + + // Resolves the live document at the violation's file id and the string property at its field path — which is + // already a SerializedProperty path (the gate scanner records iterator.propertyPath verbatim), so unlike + // TryResolveLiveProperty no graph-path conversion applies. Returns false for a scene asset (not object-loadable). + private static bool TryResolveRequiredStringProperty(GateViolation violation, + out SerializedObject serializedObject, out SerializedProperty property) + { + serializedObject = null; + property = null; + + if (SerializeReferenceHelpers.IsScene(violation.AssetPath)) return false; + + foreach (var obj in AssetDatabase.LoadAllAssetsAtPath(violation.AssetPath)) + { + if (obj == null) continue; + if (!AssetDatabase.TryGetGUIDAndLocalFileIdentifier(obj, out _, out var id) || id != violation.FileId) continue; + + var serialized = new SerializedObject(obj); + var found = serialized.FindProperty(violation.FieldPath); + if (found is { propertyType: SerializedPropertyType.String }) + { + serializedObject = serialized; + property = found; + return true; + } + + // The document matched but the path did not resolve to a string — no other document shares this + // file id, so bail rather than scan on. + serialized.Dispose(); + return false; + } + + return false; } // Best-effort owning-object type name, mirroring SerializeReferenceProjectView's resolver: saved assets are @@ -1014,7 +1163,7 @@ private void ClearOrphan(string assetPath, long fileId, long rid) // Missing card: opens the YAML fix picker, constrained to the rid's declared field type so a repair cannot // pick an incompatible type that would null on import; an unresolvable field type falls back to unconstrained. private void OpenMissingPicker(string assetPath, long fileId, long rid, AspidGradientButton anchor) => - TogglePicker(anchor, ResolveConstraint(assetPath, fileId, rid), + TogglePicker(anchor, BuildManagedReferenceFilter(ResolveConstraint(assetPath, fileId, rid)), currentAqn: null, // a missing entry has no current value — nothing (not even ) wears the check assemblyQualifiedName => ApplyFix(assetPath, fileId, rid, assemblyQualifiedName)); @@ -1032,28 +1181,36 @@ private void OpenLivePicker(string assetPath, long fileId, string graphPath, Asp currentAqn = property.managedReferenceValue?.GetType().AssemblyQualifiedName ?? string.Empty; } - TogglePicker(anchor, constraint, currentAqn, + TogglePicker(anchor, BuildManagedReferenceFilter(constraint), currentAqn, assemblyQualifiedName => ApplyLive(assetPath, fileId, graphPath, assemblyQualifiedName)); } + // The candidate filter every managed-reference picker shares: concrete types assignable to the field's declared + // type, plus the open generic definitions that can close over it. An unresolvable constraint falls back to + // unconstrained (any managed-reference type). + private static TypeSelectorFilter BuildManagedReferenceFilter(Type constraint) + { + var baseType = constraint ?? typeof(object); + + return new TypeSelectorFilter + { + Types = new[] { baseType }, + Predicate = SerializeReferenceHelpers.IsAssignableManagedReference, + AdditionalTypes = baseType == typeof(object) ? null : GenericTypeResolver.GetAssignableGenericDefinitions(baseType), + ArgumentFilter = SerializeReferenceHelpers.IsValidGenericArgument, + }; + } + // The picker expands inline under the clicked card's band, one panel at a time. Generic over the source of - // truth: the caller supplies the constraint, the type to pre-navigate to, and what a pick does. - private void TogglePicker(AspidGradientButton anchor, Type constraint, string currentAqn, Action onSelected) + // truth: the caller supplies the candidate filter, the type to pre-navigate to, and what a pick does. + private void TogglePicker(AspidGradientButton anchor, TypeSelectorFilter filter, string currentAqn, Action onSelected) { var wasOpen = _openPickerRow == anchor; ClosePicker(); if (wasOpen) return; - var baseType = constraint ?? typeof(object); - var view = new TypeSelectorView( - filter: new TypeSelectorFilter - { - Types = new[] { baseType }, - Predicate = SerializeReferenceHelpers.IsAssignableManagedReference, - AdditionalTypes = baseType == typeof(object) ? null : GenericTypeResolver.GetAssignableGenericDefinitions(baseType), - ArgumentFilter = SerializeReferenceHelpers.IsValidGenericArgument, - }, + filter: filter, currentAqn: currentAqn, // null (no current-value concept) and "" (holds ) both pass through as-is onSelected: onSelected, onDismiss: ClosePicker); diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Windows/SerializeReferenceProjectView.cs b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Windows/SerializeReferenceProjectView.cs index 7b28508f..b8613b06 100644 --- a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Windows/SerializeReferenceProjectView.cs +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Windows/SerializeReferenceProjectView.cs @@ -54,6 +54,7 @@ internal sealed class SerializeReferenceProjectView : VisualElement private const string GroupClass = RootClass + "__group"; private const string GroupPickingClass = GroupClass + "--picking"; private const string GroupHeaderRowClass = RootClass + "__group-header-row"; + private const string GroupHeaderRowStaticClass = GroupHeaderRowClass + "--static"; private const string GroupHeaderClass = RootClass + "__group-header"; private const string GroupCountClass = RootClass + "__group-count"; private const string GroupFixAllClass = RootClass + "__group-fix-all"; @@ -246,7 +247,7 @@ private void RenderGroups(List groups, IReadOnlyList - count == 1 ? $"1 {noun}" : $"{count} {noun}s"; + count == 1 ? $"1 {noun}" : $"{count} {(noun.EndsWith("y") ? noun[..^1] + "ies" : noun + "s")}"; private static string BuildResultsHeaderText(int missingCount, int requiredCount) { @@ -924,6 +925,7 @@ private VisualElement BuildRequiredGroupCard(IReadOnlyList violat var info = new VisualElement() .AddClass(GroupHeaderRowClass) + .AddClass(GroupHeaderRowStaticClass) .AddChild(header) .AddChild(count); info.pickingMode = PickingMode.Ignore;