diff --git a/Assets/Tests/Editor/SetupWizardWindowTests.cs b/Assets/Tests/Editor/SetupWizardWindowTests.cs index 4a27e505cb..679bbbf2a6 100644 --- a/Assets/Tests/Editor/SetupWizardWindowTests.cs +++ b/Assets/Tests/Editor/SetupWizardWindowTests.cs @@ -156,7 +156,8 @@ public void HasSkillUpdateForSetupWizard_WhenTargetHasDifferentLayoutSkills_Retu [TestCase("2.1.1", "3.0.0-beta.7", true)] [TestCase("1.9.0", "3.0.0", true)] - [TestCase("", "3.0.0-beta.7", false)] + [TestCase("", "3.0.0-beta.7", true)] + [TestCase("", "4.0.0", false)] [TestCase("3.0.0-beta.6", "3.0.0-beta.7", false)] [TestCase("3.0.0-beta.7", "4.0.0", false)] [TestCase("not-a-version", "3.0.0-beta.7", false)] @@ -165,7 +166,7 @@ public void ShouldAutoScanThirdPartyToolMigration_ReturnsExpectedValue( string currentVersion, bool expected) { - // Verifies that only V2-or-older to V3 package upgrades request the migration scan. + // Verifies that V3 startup scans run for V2 upgrades or missing prior setup state. bool shouldAutoScan = SetupWizardWindow.ShouldAutoScanThirdPartyToolMigration(currentVersion, lastSeenVersion); diff --git a/Assets/Tests/Editor/ThirdPartyToolMigrationFileServiceTests.cs b/Assets/Tests/Editor/ThirdPartyToolMigrationFileServiceTests.cs index 828f9d7644..8fddb712af 100644 --- a/Assets/Tests/Editor/ThirdPartyToolMigrationFileServiceTests.cs +++ b/Assets/Tests/Editor/ThirdPartyToolMigrationFileServiceTests.cs @@ -70,6 +70,274 @@ public void ThirdPartyToolMigrationSourceFileCache_WhenFileIsReadTwice_UsesCache Assert.That(readCount, Is.EqualTo(1)); } + [Test] + public void MigrationProjectFingerprint_WhenCandidateFileChanges_DoesNotMatch() + { + // Verifies that cached migration plans are rejected after candidate source changes. + string projectRoot = CreateProjectRoot(); + try + { + string toolDirectory = Path.Combine(projectRoot, "Assets", "VendorTools"); + Directory.CreateDirectory(toolDirectory); + string toolPath = Path.Combine(toolDirectory, "HelloTool.cs"); + File.WriteAllText(toolPath, "public sealed class HelloTool {}"); + ProjectFileInventory firstInventory = ProjectFileInventory.Create(projectRoot); + MigrationProjectFingerprint fingerprint = + MigrationProjectFingerprint.CaptureFromInventory(firstInventory); + + File.WriteAllText(toolPath, "public sealed class ChangedHelloTool { public int Value; }"); + ProjectFileInventory changedInventory = ProjectFileInventory.Create(projectRoot); + + Assert.That(fingerprint.Matches(changedInventory), Is.False); + } + finally + { + Directory.Delete(projectRoot, recursive: true); + } + } + + [Test] + public void MigrationProjectFingerprint_WhenCandidateFileContentChangesWithoutMetadataChange_DoesNotMatch() + { + // Verifies that cached migration plans are rejected after same-size same-timestamp source changes. + string projectRoot = CreateProjectRoot(); + try + { + string toolDirectory = Path.Combine(projectRoot, "Assets", "VendorTools"); + Directory.CreateDirectory(toolDirectory); + string toolPath = Path.Combine(toolDirectory, "HelloTool.cs"); + string originalSource = "public sealed class AlphaTool {}"; + string changedSource = "public sealed class BravoTool {}"; + DateTime originalLastWriteTimeUtc = new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc); + File.WriteAllText(toolPath, originalSource); + File.SetLastWriteTimeUtc(toolPath, originalLastWriteTimeUtc); + ProjectFileInventory firstInventory = ProjectFileInventory.Create(projectRoot); + MigrationProjectFingerprint fingerprint = + MigrationProjectFingerprint.CaptureFromInventory(firstInventory); + + File.WriteAllText(toolPath, changedSource); + File.SetLastWriteTimeUtc(toolPath, originalLastWriteTimeUtc); + ProjectFileInventory changedInventory = ProjectFileInventory.Create(projectRoot); + + Assert.That(changedSource.Length, Is.EqualTo(originalSource.Length)); + Assert.That(fingerprint.Matches(changedInventory), Is.False); + } + finally + { + Directory.Delete(projectRoot, recursive: true); + } + } + + [Test] + public void MigrationProjectFingerprint_WhenCandidateFileIsAdded_DoesNotMatch() + { + // Verifies that cached migration plans are rejected after candidate files are added. + string projectRoot = CreateProjectRoot(); + try + { + string toolDirectory = Path.Combine(projectRoot, "Assets", "VendorTools"); + Directory.CreateDirectory(toolDirectory); + File.WriteAllText( + Path.Combine(toolDirectory, "HelloTool.cs"), + "public sealed class HelloTool {}"); + ProjectFileInventory firstInventory = ProjectFileInventory.Create(projectRoot); + MigrationProjectFingerprint fingerprint = + MigrationProjectFingerprint.CaptureFromInventory(firstInventory); + + File.WriteAllText( + Path.Combine(toolDirectory, "AddedTool.cs"), + "public sealed class AddedTool {}"); + ProjectFileInventory changedInventory = ProjectFileInventory.Create(projectRoot); + + Assert.That(fingerprint.Matches(changedInventory), Is.False); + } + finally + { + Directory.Delete(projectRoot, recursive: true); + } + } + + [Test] + public void MigrationProjectFingerprint_WhenAsmdefMetaChanges_DoesNotMatch() + { + // Verifies that cached migration plans are rejected after asmref GUID resolution changes. + string projectRoot = CreateProjectRoot(); + try + { + string toolDirectory = Path.Combine(projectRoot, "Assets", "VendorTools"); + Directory.CreateDirectory(toolDirectory); + string asmdefPath = Path.Combine(toolDirectory, "VendorTools.Editor.asmdef"); + string metaPath = asmdefPath + ".meta"; + File.WriteAllText( + asmdefPath, + @"{ ""name"": ""VendorTools.Editor"", ""references"": [] }"); + File.WriteAllText(metaPath, "guid: 11111111111111111111111111111111"); + ProjectFileInventory firstInventory = ProjectFileInventory.Create(projectRoot); + MigrationProjectFingerprint fingerprint = + MigrationProjectFingerprint.CaptureFromInventory(firstInventory); + + File.WriteAllText(metaPath, "guid: 22222222222222222222222222222222"); + File.SetLastWriteTimeUtc(metaPath, DateTime.UtcNow.AddMinutes(1)); + ProjectFileInventory changedInventory = ProjectFileInventory.Create(projectRoot); + + Assert.That(fingerprint.Matches(changedInventory), Is.False); + } + finally + { + Directory.Delete(projectRoot, recursive: true); + } + } + + [Test] + public void MigrationProjectFingerprint_WhenAssemblySidecarFileIsLocked_CaptureDoesNotThrow() + { + // Verifies that locked assembly sidecar files do not abort migration preview caching. + string projectRoot = CreateProjectRoot(); + try + { + string toolDirectory = Path.Combine(projectRoot, "Assets", "VendorTools"); + Directory.CreateDirectory(toolDirectory); + string asmdefPath = Path.Combine(toolDirectory, "VendorTools.Editor.asmdef"); + string metaPath = asmdefPath + ".meta"; + string asmrefPath = Path.Combine(toolDirectory, "VendorTools.asmref"); + File.WriteAllText( + asmdefPath, + @"{ ""name"": ""VendorTools.Editor"", ""references"": [] }"); + File.WriteAllText(metaPath, "guid: 11111111111111111111111111111111"); + File.WriteAllText( + asmrefPath, + @"{ ""reference"": ""VendorTools.Editor"" }"); + ProjectFileInventory inventory = ProjectFileInventory.Create(projectRoot); + + using FileStream lockedMeta = new(metaPath, FileMode.Open, FileAccess.ReadWrite, FileShare.None); + using FileStream lockedAsmref = new(asmrefPath, FileMode.Open, FileAccess.ReadWrite, FileShare.None); + + Assert.DoesNotThrow(() => MigrationProjectFingerprint.CaptureFromInventory(inventory)); + } + finally + { + Directory.Delete(projectRoot, recursive: true); + } + } + + [Test] + public void PreflightScanner_WhenSourceHasNoMigrationMarkers_ReturnsNoTargets() + { + // Verifies that startup preflight can skip full scans when no migration marker text exists. + MigrationTargetPreflightResult result = + ThirdPartyToolMigrationPreflightScanner.InspectSourceText( + "public sealed class PlainTool {}", + ".cs"); + + Assert.That(result, Is.EqualTo(MigrationTargetPreflightResult.NoTargets)); + } + + [Test] + public void PreflightScanner_WhenLegacyToolSourceExists_ReturnsHasTargets() + { + // Verifies that startup preflight detects direct legacy custom tool source immediately. + MigrationTargetPreflightResult result = + ThirdPartyToolMigrationPreflightScanner.InspectSourceText( + "using io.github.hatayama.uLoopMCP; [McpTool] public sealed class HelloTool {}", + ".cs"); + + Assert.That(result, Is.EqualTo(MigrationTargetPreflightResult.HasTargets)); + } + + [Test] + public void PreflightScanner_WhenLegacyNamespaceIsOnlyComment_ReturnsNeedsFullScan() + { + // Verifies that startup preflight does not report targets from marker text inside comments. + MigrationTargetPreflightResult result = + ThirdPartyToolMigrationPreflightScanner.InspectSourceText( + "// using io.github.hatayama.uLoopMCP;", + ".cs"); + + Assert.That(result, Is.EqualTo(MigrationTargetPreflightResult.NeedsFullScan)); + } + + [Test] + public void PreflightScanner_WhenLegacyAsmdefReferenceExists_ReturnsHasTargets() + { + // Verifies that startup preflight detects legacy asmdef references without building an inventory. + MigrationTargetPreflightResult result = + ThirdPartyToolMigrationPreflightScanner.InspectSourceText( + @"{ ""references"": [ ""uLoopMCP.Editor"" ] }", + ".asmdef"); + + Assert.That(result, Is.EqualTo(MigrationTargetPreflightResult.HasTargets)); + } + + [Test] + public void PreflightScanner_WhenLegacyAsmdefReferenceIsMalformed_ReturnsNeedsFullScan() + { + // Verifies that startup preflight defers malformed legacy asmdefs to the full scanner. + MigrationTargetPreflightResult result = + ThirdPartyToolMigrationPreflightScanner.InspectSourceText( + @"{ ""references"": [ ""uLoopMCP.Editor"" ", + ".asmdef"); + + Assert.That(result, Is.EqualTo(MigrationTargetPreflightResult.NeedsFullScan)); + } + + [Test] + public async Task PreflightScanner_WhenAmbiguousFileExistsWithDirectTarget_ReturnsHasTargets() + { + // Verifies that an ambiguous marker does not force full scan before later direct targets are checked. + string projectRoot = CreateProjectRoot(); + try + { + string toolDirectory = Path.Combine(projectRoot, "Assets", "VendorTools"); + Directory.CreateDirectory(toolDirectory); + File.WriteAllText( + Path.Combine(toolDirectory, "0_Ambiguous.cs"), + "// using io.github.hatayama.uLoopMCP;"); + File.WriteAllText( + Path.Combine(toolDirectory, "1_HelloTool.cs"), + "using io.github.hatayama.uLoopMCP; [McpTool] public sealed class HelloTool {}"); + + MigrationTargetPreflightResult result = + await ThirdPartyToolMigrationPreflightScanner.FindMigrationTargetAsync( + projectRoot, + CancellationToken.None); + + Assert.That(result, Is.EqualTo(MigrationTargetPreflightResult.HasTargets)); + } + finally + { + Directory.Delete(projectRoot, recursive: true); + } + } + + [Test] + public async Task PreflightScanner_WhenInspectableFileIsLocked_ReturnsNeedsFullScan() + { + // Verifies that startup preflight defers unreadable candidate files to the full scanner. + string projectRoot = CreateProjectRoot(); + try + { + string toolDirectory = Path.Combine(projectRoot, "Assets", "VendorTools"); + Directory.CreateDirectory(toolDirectory); + string lockedToolPath = Path.Combine(toolDirectory, "LockedTool.cs"); + File.WriteAllText( + lockedToolPath, + "using io.github.hatayama.uLoopMCP; [McpTool] public sealed class LockedTool {}"); + + using FileStream lockedFile = + new(lockedToolPath, FileMode.Open, FileAccess.ReadWrite, FileShare.None); + MigrationTargetPreflightResult result = + await ThirdPartyToolMigrationPreflightScanner.FindMigrationTargetAsync( + projectRoot, + CancellationToken.None); + + Assert.That(result, Is.EqualTo(MigrationTargetPreflightResult.NeedsFullScan)); + } + finally + { + Directory.Delete(projectRoot, recursive: true); + } + } + [Test] public void TryReadJsonObjectForMigration_WhenReadThrowsIOException_ReturnsFalse() { @@ -4007,6 +4275,117 @@ public sealed class HelloResponse : BaseToolResponse } } + [Test] + public async Task ApplyMigrationAsync_WhenProjectChangesAfterPreview_RebuildsPlan() + { + // Verifies that cached preview plans are not applied after project files change. + string projectRoot = CreateProjectRoot(); + try + { + string toolDirectory = Path.Combine(projectRoot, "Assets", "VendorTools"); + Directory.CreateDirectory(toolDirectory); + string toolPath = Path.Combine(toolDirectory, "HelloTool.cs"); + string asmdefPath = Path.Combine(toolDirectory, "VendorTools.Editor.asmdef"); + File.WriteAllText(toolPath, @"using io.github.hatayama.uLoopMCP; + +[McpTool] +public sealed class HelloTool : AbstractUnityTool +{ +} + +public sealed class HelloSchema : BaseToolSchema +{ +} + +public sealed class HelloResponse : BaseToolResponse +{ +}"); + File.WriteAllText(asmdefPath, @"{ + ""name"": ""VendorTools.Editor"", + ""references"": [ + ""GUID:214998e563c124e8a88199b2dd1f522d"" + ] +}"); + + ThirdPartyToolMigrationFileService service = new(); + Progress progress = new(); + ThirdPartyToolMigrationPreview preview = + await service.PreviewMigrationAsync(projectRoot, progress, CancellationToken.None); + File.WriteAllText(toolPath, "public sealed class HelloTool {}"); + File.WriteAllText(asmdefPath, @"{ + ""name"": ""VendorTools.Editor"", + ""references"": [] +}"); + + ThirdPartyToolMigrationResult result = + await service.ApplyMigrationAsync(projectRoot, progress, CancellationToken.None); + + Assert.That(preview.HasTargets, Is.True); + Assert.That(result.FileCount, Is.EqualTo(0)); + Assert.That(File.ReadAllText(toolPath), Is.EqualTo("public sealed class HelloTool {}")); + Assert.That(File.ReadAllText(asmdefPath), Does.Not.Contain("GUID:fc3fd32eddbee40e39c2d76dc184957b")); + } + finally + { + Directory.Delete(projectRoot, recursive: true); + } + } + + [Test] + public async Task ApplyMigrationAsync_WhenProjectChangesDuringPreview_RebuildsPlan() + { + // Verifies that cached preview plans are not applied after files change during plan creation. + string projectRoot = CreateProjectRoot(); + try + { + string toolDirectory = Path.Combine(projectRoot, "Assets", "VendorTools"); + Directory.CreateDirectory(toolDirectory); + string toolPath = Path.Combine(toolDirectory, "HelloTool.cs"); + string editedToolSource = "public sealed class EditedTool {}"; + string asmdefPath = Path.Combine(toolDirectory, "VendorTools.Editor.asmdef"); + File.WriteAllText(toolPath, @"using io.github.hatayama.uLoopMCP; + +[McpTool] +public sealed class HelloTool : AbstractUnityTool +{ +} + +public sealed class HelloSchema : BaseToolSchema +{ +} + +public sealed class HelloResponse : BaseToolResponse +{ +}"); + File.WriteAllText(asmdefPath, @"{ + ""name"": ""VendorTools.Editor"", + ""references"": [ + ""GUID:214998e563c124e8a88199b2dd1f522d"" + ] +}"); + + ThirdPartyToolMigrationFileService service = new(); + FileChangingProgress progress = new(() => + { + File.WriteAllText(toolPath, editedToolSource); + File.SetLastWriteTimeUtc(toolPath, DateTime.UtcNow.AddMinutes(1)); + }); + ThirdPartyToolMigrationPreview preview = + await service.PreviewMigrationAsync(projectRoot, progress, CancellationToken.None); + + ThirdPartyToolMigrationResult result = + await service.ApplyMigrationAsync(projectRoot, progress, CancellationToken.None); + + Assert.That(preview.HasTargets, Is.True); + Assert.That(result.FilePaths, Does.Not.Contain(toolPath)); + Assert.That(File.ReadAllText(toolPath), Is.EqualTo(editedToolSource)); + } + finally + { + Directory.Delete(projectRoot, recursive: true); + } + } + [Test] public async Task PreviewMigrationAsync_WhenUnrelatedAsmdefJsonIsMalformedAndNoAsmrefs_PreviewsAsmdefRepair() { @@ -4677,5 +5056,32 @@ public void Report(ThirdPartyToolMigrationProgress value) _cts.Cancel(); } } + + private sealed class FileChangingProgress : IProgress + { + private readonly Action _changeProject; + private bool _hasChangedProject; + + public FileChangingProgress(Action changeProject) + { + Assert.That(changeProject, Is.Not.Null); + + _changeProject = changeProject; + } + + public void Report(ThirdPartyToolMigrationProgress value) + { + if (_hasChangedProject || + value.TotalItemCount == 0 || + value.ProcessedItemCount < 1) + { + return; + } + + _hasChangedProject = true; + _changeProject(); + } + } + } } diff --git a/Assets/Tests/Editor/ThirdPartyToolMigrationWizardWindowTests.cs b/Assets/Tests/Editor/ThirdPartyToolMigrationWizardWindowTests.cs index 41e22cbf79..812f93285d 100644 --- a/Assets/Tests/Editor/ThirdPartyToolMigrationWizardWindowTests.cs +++ b/Assets/Tests/Editor/ThirdPartyToolMigrationWizardWindowTests.cs @@ -1,3 +1,6 @@ +using System.Threading; +using System.Threading.Tasks; + using NUnit.Framework; using UnityEditor; using UnityEngine; @@ -29,6 +32,67 @@ public void ShouldStartInitialRefresh_ReturnsExpectedValue( Assert.That(shouldStartInitialRefresh, Is.EqualTo(expected)); } + [TestCase(false, false, false)] + [TestCase(true, false, true)] + [TestCase(true, true, false)] + public void ShouldOpenWindowAfterAutoScan_ReturnsExpectedValue( + bool hasMigrationTargets, + bool isCancellationRequested, + bool expected) + { + // Verifies that auto-scan opens the migration window only when preflight finds work. + bool shouldOpenWindow = ThirdPartyToolMigrationWizardWindow.ShouldOpenWindowAfterAutoScan( + hasMigrationTargets, + isCancellationRequested); + + Assert.That(shouldOpenWindow, Is.EqualTo(expected)); + } + + [Test] + public async Task RunAutoScanAsync_WhenTargetsExist_OpensWindowAndConsumesState() + { + // Verifies that a successful auto-scan opens the migration wizard and consumes the session flag. + bool openedWindow = false; + bool consumedSessionState = false; + System.Exception loggedException = null; + + bool didOpenWindow = await ThirdPartyToolMigrationWizardWindow.RunAutoScanAsync( + _ => Task.FromResult(true), + _ => Task.CompletedTask, + () => openedWindow = true, + () => consumedSessionState = true, + ex => loggedException = ex, + CancellationToken.None); + + Assert.That(didOpenWindow, Is.True); + Assert.That(openedWindow, Is.True); + Assert.That(consumedSessionState, Is.True); + Assert.That(loggedException, Is.Null); + } + + [Test] + public async Task RunAutoScanAsync_WhenScanThrows_LogsExceptionAndConsumesState() + { + // Verifies that failed auto-scans cannot leak the session flag or crash through async void. + bool openedWindow = false; + bool consumedSessionState = false; + System.InvalidOperationException expectedException = new("scan failed"); + System.Exception loggedException = null; + + bool didOpenWindow = await ThirdPartyToolMigrationWizardWindow.RunAutoScanAsync( + _ => Task.FromException(expectedException), + _ => Task.CompletedTask, + () => openedWindow = true, + () => consumedSessionState = true, + ex => loggedException = ex, + CancellationToken.None); + + Assert.That(didOpenWindow, Is.False); + Assert.That(openedWindow, Is.False); + Assert.That(consumedSessionState, Is.True); + Assert.That(loggedException, Is.SameAs(expectedException)); + } + [TestCase( 1, "1 file needs V3 custom tool migration.\n" + diff --git a/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationAssemblyUsageAnalyzer.cs b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationAssemblyUsageAnalyzer.cs index c8b25e89de..4a446bc8c3 100644 --- a/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationAssemblyUsageAnalyzer.cs +++ b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationAssemblyUsageAnalyzer.cs @@ -60,6 +60,11 @@ internal static MigrationAssemblyUsage FindMigrationAssemblyUsage( { string source = File.ReadAllText(csharpFilePath); sourceByCSharpFilePath.Add(csharpFilePath, source); + if (!ThirdPartyToolMigrationRules.ContainsMigrationCandidateText(source)) + { + continue; + } + string assemblyDirectory = FindNearestAssemblyDirectory( csharpFilePath, asmdefDirectories, @@ -69,11 +74,6 @@ internal static MigrationAssemblyUsage FindMigrationAssemblyUsage( assemblyDeclaredTypeNamesByDirectory, assemblyDirectory, ThirdPartyToolMigrationRules.GetDeclaredTypeNames(source)); - if (!ThirdPartyToolMigrationRules.ContainsMigrationCandidateText(source)) - { - continue; - } - if (ThirdPartyToolMigrationRules.ContainsLegacyCSharpApi(source)) { legacyAssemblyDirectories.Add(assemblyDirectory); diff --git a/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationAssemblyUsageAsyncAnalyzer.cs b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationAssemblyUsageAsyncAnalyzer.cs index 37b2a40bab..54e29fc960 100644 --- a/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationAssemblyUsageAsyncAnalyzer.cs +++ b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationAssemblyUsageAsyncAnalyzer.cs @@ -92,6 +92,11 @@ await CreateAssemblyReferenceDirectoriesAsync( string source = sourceFileCache.ReadAllText(csharpFilePath); await progressCounter.ReportProcessedItemAsync(ct); + if (!ThirdPartyToolMigrationRules.ContainsMigrationCandidateText(source)) + { + continue; + } + string assemblyDirectory = FindNearestAssemblyDirectory( csharpFilePath, asmdefDirectories, @@ -101,11 +106,6 @@ await CreateAssemblyReferenceDirectoriesAsync( assemblyDeclaredTypeNamesByDirectory, assemblyDirectory, ThirdPartyToolMigrationRules.GetDeclaredTypeNames(source)); - if (!ThirdPartyToolMigrationRules.ContainsMigrationCandidateText(source)) - { - continue; - } - if (ThirdPartyToolMigrationRules.ContainsLegacyCSharpApi(source)) { legacyAssemblyDirectories.Add(assemblyDirectory); diff --git a/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationFileService.cs b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationFileService.cs index 91268f724a..582fb815ed 100644 --- a/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationFileService.cs +++ b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationFileService.cs @@ -15,10 +15,13 @@ namespace io.github.hatayama.UnityCliLoop.Infrastructure /// public sealed class ThirdPartyToolMigrationFileService : IThirdPartyToolMigrationPort { - private readonly object _previewCacheLock = new(); + private readonly object _migrationCacheLock = new(); private bool _hasCachedPreview; private string _cachedPreviewProjectRoot = string.Empty; private ThirdPartyToolMigrationPreview _cachedPreview; + private bool _hasCachedPlan; + private string _cachedPlanProjectRoot = string.Empty; + private MigrationPlan _cachedPlan; public ThirdPartyToolMigrationPreview PreviewMigration(string projectRoot) { @@ -35,6 +38,7 @@ public ThirdPartyToolMigrationPreview PreviewMigration(string projectRoot) plan.ChangedFilePaths.Count, plan.ReplacementCount, plan.ChangedFilePaths.ToArray()); + StoreCachedPlan(normalizedProjectRoot, plan); StoreCachedPreview(normalizedProjectRoot, preview); return preview; } @@ -58,6 +62,7 @@ public async Task PreviewMigrationAsync( plan.ChangedFilePaths.Count, plan.ReplacementCount, plan.ChangedFilePaths.ToArray()); + StoreCachedPlan(normalizedProjectRoot, plan); StoreCachedPreview(normalizedProjectRoot, preview); return preview; } @@ -85,8 +90,8 @@ public ThirdPartyToolMigrationResult ApplyMigration(string projectRoot) Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty"); string normalizedProjectRoot = NormalizeProjectRoot(projectRoot); + MigrationPlan plan = GetCurrentMigrationPlan(normalizedProjectRoot); InvalidatePreviewCache(); - MigrationPlan plan = ThirdPartyToolMigrationPlanBuilder.Create(normalizedProjectRoot); foreach (MigrationFileChange change in plan.Changes) { ThirdPartyToolMigrationFileWriter.Write(change.FilePath, change.Content); @@ -107,14 +112,14 @@ public async Task ApplyMigrationAsync( Debug.Assert(progress != null, "progress must not be null"); string normalizedProjectRoot = NormalizeProjectRoot(projectRoot); - InvalidatePreviewCache(); - MigrationPlan plan = await ThirdPartyToolMigrationPlanBuilder.CreateAsync(normalizedProjectRoot, progress, ct); + MigrationPlan plan = await GetCurrentMigrationPlanAsync(normalizedProjectRoot, progress, ct); // A canceled operation must not start mutating files, but an active write batch must finish as one plan. if (ct.IsCancellationRequested) { return new ThirdPartyToolMigrationResult(0, 0, Array.Empty()); } + InvalidatePreviewCache(); for (int index = 0; index < plan.Changes.Count; index++) { MigrationFileChange change = plan.Changes[index]; @@ -133,11 +138,75 @@ public async Task ApplyMigrationAsync( internal void InvalidatePreviewCache() { - lock (_previewCacheLock) + InvalidateMigrationCaches(); + } + + private MigrationPlan GetCurrentMigrationPlan(string projectRoot) + { + Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty"); + + CachedMigrationPlanLookup cachedPlan = GetCurrentCachedPlan(projectRoot); + if (cachedPlan.Found) + { + return cachedPlan.Plan; + } + + return ThirdPartyToolMigrationPlanBuilder.Create(projectRoot); + } + + private async Task GetCurrentMigrationPlanAsync( + string projectRoot, + IProgress progress, + CancellationToken ct) + { + Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty"); + Debug.Assert(progress != null, "progress must not be null"); + + CachedMigrationPlanLookup cachedPlan = GetCurrentCachedPlan(projectRoot); + if (cachedPlan.Found) + { + return cachedPlan.Plan; + } + + return await ThirdPartyToolMigrationPlanBuilder.CreateAsync(projectRoot, progress, ct); + } + + private CachedMigrationPlanLookup GetCurrentCachedPlan(string projectRoot) + { + Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty"); + + MigrationPlan cachedPlan; + lock (_migrationCacheLock) + { + if (!_hasCachedPlan || + !string.Equals(_cachedPlanProjectRoot, projectRoot, StringComparison.Ordinal)) + { + return CachedMigrationPlanLookup.NotFound; + } + + cachedPlan = _cachedPlan; + } + + ProjectFileInventory inventory = ProjectFileInventory.Create(projectRoot); + if (!cachedPlan.ProjectFingerprint.Matches(inventory)) + { + InvalidateMigrationCaches(); + return CachedMigrationPlanLookup.NotFound; + } + + return CachedMigrationPlanLookup.FoundPlan(cachedPlan); + } + + private void InvalidateMigrationCaches() + { + lock (_migrationCacheLock) { _hasCachedPreview = false; _cachedPreviewProjectRoot = string.Empty; _cachedPreview = default; + _hasCachedPlan = false; + _cachedPlanProjectRoot = string.Empty; + _cachedPlan = default; } } @@ -158,7 +227,7 @@ private bool TryGetCachedPreview( { Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty"); - lock (_previewCacheLock) + lock (_migrationCacheLock) { if (_hasCachedPreview && string.Equals(_cachedPreviewProjectRoot, projectRoot, StringComparison.Ordinal)) @@ -176,7 +245,7 @@ private void StoreCachedPreview(string projectRoot, ThirdPartyToolMigrationPrevi { Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty"); - lock (_previewCacheLock) + lock (_migrationCacheLock) { _cachedPreviewProjectRoot = projectRoot; _cachedPreview = preview; @@ -184,6 +253,37 @@ private void StoreCachedPreview(string projectRoot, ThirdPartyToolMigrationPrevi } } + private void StoreCachedPlan(string projectRoot, MigrationPlan plan) + { + Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty"); + + lock (_migrationCacheLock) + { + _cachedPlanProjectRoot = projectRoot; + _cachedPlan = plan; + _hasCachedPlan = true; + } + } + + private readonly struct CachedMigrationPlanLookup + { + public static CachedMigrationPlanLookup NotFound => new(false, default); + + public static CachedMigrationPlanLookup FoundPlan(MigrationPlan plan) + { + return new CachedMigrationPlanLookup(true, plan); + } + + private CachedMigrationPlanLookup(bool found, MigrationPlan plan) + { + Found = found; + Plan = plan; + } + + public bool Found { get; } + public MigrationPlan Plan { get; } + } + internal static string NormalizeProjectRoot(string projectRoot) { Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty"); diff --git a/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationModels.cs b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationModels.cs index 01260d6dfa..ac636a69e5 100644 --- a/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationModels.cs +++ b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationModels.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Diagnostics; +using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; @@ -26,15 +27,22 @@ public MigrationFileChange(string filePath, string content) internal readonly struct MigrationPlan { - public static MigrationPlan Empty => new(new List(), 0); + public static MigrationPlan Empty => new( + new List(), + 0, + MigrationProjectFingerprint.Empty); - public MigrationPlan(List changes, int replacementCount) + public MigrationPlan( + List changes, + int replacementCount, + MigrationProjectFingerprint projectFingerprint) { Debug.Assert(changes != null, "changes must not be null"); Debug.Assert(replacementCount >= 0, "replacementCount must not be negative"); Changes = changes ?? throw new ArgumentNullException(nameof(changes)); ReplacementCount = replacementCount; + ProjectFingerprint = projectFingerprint; ChangedFilePaths = Changes .Select(change => change.FilePath) .OrderBy(path => path, StringComparer.Ordinal) @@ -43,9 +51,194 @@ public MigrationPlan(List changes, int replacementCount) public List Changes { get; } public int ReplacementCount { get; } + public MigrationProjectFingerprint ProjectFingerprint { get; } public List ChangedFilePaths { get; } } + /// + /// Captures candidate file state so cached migration plans are reused only while project files match. + /// + internal readonly struct MigrationProjectFingerprint + { + public static MigrationProjectFingerprint Empty => new(Array.Empty()); + + private readonly MigrationFileFingerprint[] _fileFingerprints; + + private MigrationProjectFingerprint(MigrationFileFingerprint[] fileFingerprints) + { + Debug.Assert(fileFingerprints != null, "fileFingerprints must not be null"); + + _fileFingerprints = fileFingerprints; + } + + public static MigrationProjectFingerprint CaptureFromInventory(ProjectFileInventory inventory) + { + Debug.Assert(inventory != null, "inventory must not be null"); + + List filePaths = new(); + filePaths.AddRange(inventory.CSharpFilePaths); + filePaths.AddRange(inventory.AsmdefFilePaths); + filePaths.AddRange(inventory.AsmrefFilePaths); + foreach (string asmdefFilePath in inventory.AsmdefFilePaths) + { + filePaths.Add(asmdefFilePath + ".meta"); + } + + filePaths.Sort(StringComparer.Ordinal); + + MigrationFileFingerprint[] fileFingerprints = new MigrationFileFingerprint[filePaths.Count]; + for (int index = 0; index < filePaths.Count; index++) + { + fileFingerprints[index] = MigrationFileFingerprint.Capture(filePaths[index]); + } + + return new MigrationProjectFingerprint(fileFingerprints); + } + + public bool Matches(ProjectFileInventory inventory) + { + Debug.Assert(inventory != null, "inventory must not be null"); + + MigrationProjectFingerprint currentFingerprint = CaptureFromInventory(inventory); + return MatchesFingerprint(currentFingerprint); + } + + private bool MatchesFingerprint(MigrationProjectFingerprint other) + { + MigrationFileFingerprint[] fileFingerprints = _fileFingerprints ?? Array.Empty(); + MigrationFileFingerprint[] otherFileFingerprints = + other._fileFingerprints ?? Array.Empty(); + if (fileFingerprints.Length != otherFileFingerprints.Length) + { + return false; + } + + for (int index = 0; index < fileFingerprints.Length; index++) + { + if (!fileFingerprints[index].HasSameValuesAs(otherFileFingerprints[index])) + { + return false; + } + } + + return true; + } + } + + /// + /// Captures one candidate file's identity and modification state for migration plan cache validation. + /// + internal readonly struct MigrationFileFingerprint + { + private const int ContentHashBufferSize = 8192; + private const ulong ContentHashOffsetBasis = 14695981039346656037UL; + private const ulong ContentHashPrime = 1099511628211UL; + + private MigrationFileFingerprint( + string filePath, + bool exists, + long length, + long lastWriteTimeUtcTicks, + ulong contentHash) + { + Debug.Assert(!string.IsNullOrEmpty(filePath), "filePath must not be null or empty"); + Debug.Assert(length >= 0, "length must not be negative"); + Debug.Assert(lastWriteTimeUtcTicks >= 0, "lastWriteTimeUtcTicks must not be negative"); + + FilePath = filePath; + Exists = exists; + Length = length; + LastWriteTimeUtcTicks = lastWriteTimeUtcTicks; + ContentHash = contentHash; + } + + public string FilePath { get; } + public bool Exists { get; } + public long Length { get; } + public long LastWriteTimeUtcTicks { get; } + public ulong ContentHash { get; } + + public static MigrationFileFingerprint Capture(string filePath) + { + Debug.Assert(!string.IsNullOrEmpty(filePath), "filePath must not be null or empty"); + + FileInfo fileInfo = new(filePath); + if (!fileInfo.Exists) + { + return new MigrationFileFingerprint( + filePath, + false, + 0, + 0, + 0); + } + + return new MigrationFileFingerprint( + filePath, + true, + fileInfo.Length, + fileInfo.LastWriteTimeUtc.Ticks, + CaptureContentHash(filePath)); + } + + public bool HasSameValuesAs(MigrationFileFingerprint other) + { + return string.Equals(FilePath, other.FilePath, StringComparison.Ordinal) && + Exists == other.Exists && + Length == other.Length && + LastWriteTimeUtcTicks == other.LastWriteTimeUtcTicks && + ContentHash == other.ContentHash; + } + + private static ulong CaptureContentHash(string filePath) + { + Debug.Assert(!string.IsNullOrEmpty(filePath), "filePath must not be null or empty"); + + try + { + return CaptureReadableContentHash(filePath); + } + catch (Exception ex) when (IsSkippableContentHashException(ex)) + { + return 0; + } + } + + private static ulong CaptureReadableContentHash(string filePath) + { + Debug.Assert(!string.IsNullOrEmpty(filePath), "filePath must not be null or empty"); + + byte[] buffer = new byte[ContentHashBufferSize]; + ulong contentHash = ContentHashOffsetBasis; + using FileStream stream = File.OpenRead(filePath); + while (true) + { + int readByteCount = stream.Read(buffer, 0, buffer.Length); + if (readByteCount == 0) + { + return contentHash; + } + + for (int index = 0; index < readByteCount; index++) + { + unchecked + { + contentHash ^= buffer[index]; + contentHash *= ContentHashPrime; + } + } + } + } + + private static bool IsSkippableContentHashException(Exception ex) + { + Debug.Assert(ex != null, "ex must not be null"); + + return ex is IOException || + ex is UnauthorizedAccessException; + } + } + internal sealed class MigrationProgressCounter { private readonly IProgress _progress; diff --git a/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationPlanBuilder.cs b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationPlanBuilder.cs index 2e30dcd4b4..872d285bee 100644 --- a/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationPlanBuilder.cs +++ b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationPlanBuilder.cs @@ -27,6 +27,8 @@ internal static MigrationPlan Create(string projectRoot) } ProjectFileInventory inventory = ProjectFileInventory.Create(projectRoot); + MigrationProjectFingerprint projectFingerprint = + MigrationProjectFingerprint.CaptureFromInventory(inventory); MigrationAssemblyUsage assemblyUsage = ThirdPartyToolMigrationAssemblyUsageAnalyzer.FindMigrationAssemblyUsage( projectRoot, inventory.CSharpFilePaths, @@ -169,7 +171,10 @@ internal static MigrationPlan Create(string projectRoot) changes.Add(new MigrationFileChange(asmdefFilePath, result.Content)); } - return new MigrationPlan(changes, replacementCount); + return new MigrationPlan( + changes, + replacementCount, + projectFingerprint); } internal static async Task CreateAsync( @@ -191,6 +196,8 @@ internal static async Task CreateAsync( return MigrationPlan.Empty; } + MigrationProjectFingerprint projectFingerprint = + MigrationProjectFingerprint.CaptureFromInventory(inventory); MigrationProgressCounter progressCounter = new(GetPreviewWorkItemCount(inventory), progress); ThirdPartyToolMigrationSourceFileCache sourceFileCache = new(); MigrationAssemblyUsage assemblyUsage = await ThirdPartyToolMigrationAssemblyUsageAsyncAnalyzer.FindMigrationAssemblyUsageAsync( @@ -361,7 +368,10 @@ internal static async Task CreateAsync( } progressCounter.ReportComplete(); - return new MigrationPlan(changes, replacementCount); + return new MigrationPlan( + changes, + replacementCount, + projectFingerprint); } diff --git a/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationPreflightScanner.cs b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationPreflightScanner.cs new file mode 100644 index 0000000000..6ac6875c8b --- /dev/null +++ b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationPreflightScanner.cs @@ -0,0 +1,354 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +using Newtonsoft.Json.Linq; + +namespace io.github.hatayama.UnityCliLoop.Infrastructure +{ + /// + /// Streams project files for cheap migration target checks before the full scan runs. + /// + internal static class ThirdPartyToolMigrationPreflightScanner + { + private const string SourceTextPath = ""; + + private static readonly string[] DirectCSharpCandidateMarkers = + { + ThirdPartyToolMigrationRuleCatalog.LegacyNamespace, + ThirdPartyToolMigrationRuleCatalog.CurrentNamespace, + ThirdPartyToolMigrationRuleCatalog.CurrentApplicationNamespace, + ThirdPartyToolMigrationRuleCatalog.CurrentDomainNamespace, + ThirdPartyToolMigrationRuleCatalog.CurrentFirstPartyToolsNamespace, + "McpTool", + "CustomToolManager", + ThirdPartyToolMigrationRuleCatalog.LegacyEditorDelayTypeName, + ThirdPartyToolMigrationRuleCatalog.LegacyTimerDelayTypeName, + ThirdPartyToolMigrationRuleCatalog.LegacyMainThreadSwitcherTypeName, + ThirdPartyToolMigrationRuleCatalog.LegacyPlayerLoopTimingTypeName, + ThirdPartyToolMigrationRuleCatalog.LegacyEditorWindowCaptureUtilityTypeName, + "UnityCliLoopToolRegistrar", + "ToolInfo" + }; + + private static readonly string[] AsmdefCandidateMarkers = + { + ThirdPartyToolMigrationRuleCatalog.LegacyEditorAssemblyName, + ThirdPartyToolMigrationRuleCatalog.LegacyRuntimeAssemblyName + }; + + internal static async Task FindMigrationTargetAsync( + string projectRoot, + CancellationToken ct) + { + Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty"); + + string assetsDirectory = Path.Combine(projectRoot, "Assets"); + if (!Directory.Exists(assetsDirectory)) + { + return MigrationTargetPreflightResult.NoTargets; + } + + Stack pendingDirectories = new Stack(); + pendingDirectories.Push(assetsDirectory); + int inspectedEntryCount = 0; + bool needsFullScan = false; + while (pendingDirectories.Count > 0) + { + if (ct.IsCancellationRequested) + { + return MigrationTargetPreflightResult.NoTargets; + } + + string directoryPath = pendingDirectories.Pop(); + MigrationTargetPreflightResult fileResult = + await InspectFilesInDirectoryAsync(directoryPath, ct); + if (fileResult == MigrationTargetPreflightResult.HasTargets) + { + return fileResult; + } + + if (fileResult == MigrationTargetPreflightResult.NeedsFullScan) + { + needsFullScan = true; + } + + foreach (string childDirectoryPath in Directory.EnumerateDirectories(directoryPath)) + { + if (ProjectFileInventory.ShouldExcludeDirectory(projectRoot, childDirectoryPath)) + { + continue; + } + + pendingDirectories.Push(childDirectoryPath); + inspectedEntryCount++; + if (inspectedEntryCount % ThirdPartyToolMigrationFileServiceConstants.PreviewYieldBatchSize == 0) + { + await Task.Yield(); + } + } + } + + return needsFullScan + ? MigrationTargetPreflightResult.NeedsFullScan + : MigrationTargetPreflightResult.NoTargets; + } + + internal static MigrationTargetPreflightResult InspectSourceText( + string source, + string extension) + { + Debug.Assert(source != null, "source must not be null"); + Debug.Assert(!string.IsNullOrEmpty(extension), "extension must not be null or empty"); + + return InspectFileSourceText(source, extension, SourceTextPath); + } + + private static MigrationTargetPreflightResult InspectFileSourceText( + string source, + string extension, + string filePath) + { + Debug.Assert(source != null, "source must not be null"); + Debug.Assert(!string.IsNullOrEmpty(extension), "extension must not be null or empty"); + Debug.Assert(!string.IsNullOrEmpty(filePath), "filePath must not be null or empty"); + + if (string.Equals(extension, ".cs", StringComparison.OrdinalIgnoreCase)) + { + return InspectCSharpSourceText(source); + } + + if (string.Equals(extension, ".asmdef", StringComparison.OrdinalIgnoreCase)) + { + return InspectAsmdefFileSourceText(source, filePath); + } + + return MigrationTargetPreflightResult.NoTargets; + } + + private static async Task InspectFilesInDirectoryAsync( + string directoryPath, + CancellationToken ct) + { + Debug.Assert(!string.IsNullOrEmpty(directoryPath), "directoryPath must not be null or empty"); + + int inspectedEntryCount = 0; + bool needsFullScan = false; + foreach (string filePath in Directory.EnumerateFiles(directoryPath)) + { + if (ct.IsCancellationRequested) + { + return MigrationTargetPreflightResult.NoTargets; + } + + string extension = Path.GetExtension(filePath); + if (!ShouldInspectExtension(extension)) + { + continue; + } + + (bool wasRead, string source) = TryReadSourceText(filePath); + if (!wasRead) + { + needsFullScan = true; + continue; + } + + MigrationTargetPreflightResult result = InspectFileSourceText(source, extension, filePath); + if (result == MigrationTargetPreflightResult.HasTargets) + { + return result; + } + + if (result == MigrationTargetPreflightResult.NeedsFullScan) + { + needsFullScan = true; + } + + inspectedEntryCount++; + if (inspectedEntryCount % ThirdPartyToolMigrationFileServiceConstants.PreviewYieldBatchSize == 0) + { + await Task.Yield(); + } + } + + return needsFullScan + ? MigrationTargetPreflightResult.NeedsFullScan + : MigrationTargetPreflightResult.NoTargets; + } + + private static (bool WasRead, string Source) TryReadSourceText(string filePath) + { + Debug.Assert(!string.IsNullOrEmpty(filePath), "filePath must not be null or empty"); + + try + { + return (true, File.ReadAllText(filePath)); + } + catch (Exception ex) when (IsSkippablePreflightReadException(ex)) + { + UnityEngine.Debug.LogWarning( + $"[UnityCliLoop] Deferring migration preflight after unreadable file at {filePath}: {ex.Message}"); + return (false, string.Empty); + } + } + + private static bool IsSkippablePreflightReadException(Exception ex) + { + Debug.Assert(ex != null, "ex must not be null"); + + return ex is IOException || + ex is UnauthorizedAccessException; + } + + private static bool ShouldInspectExtension(string extension) + { + return string.Equals(extension, ".cs", StringComparison.OrdinalIgnoreCase) || + string.Equals(extension, ".asmdef", StringComparison.OrdinalIgnoreCase); + } + + private static MigrationTargetPreflightResult InspectCSharpSourceText(string source) + { + Debug.Assert(source != null, "source must not be null"); + + if (!ContainsCSharpCandidateMarker(source)) + { + return MigrationTargetPreflightResult.NoTargets; + } + + if (ThirdPartyToolMigrationFastSourceTargetDetector.ContainsFastCSharpMigrationTarget(source)) + { + return MigrationTargetPreflightResult.HasTargets; + } + + return MigrationTargetPreflightResult.NeedsFullScan; + } + + private static MigrationTargetPreflightResult InspectAsmdefFileSourceText( + string source, + string filePath) + { + Debug.Assert(source != null, "source must not be null"); + Debug.Assert(!string.IsNullOrEmpty(filePath), "filePath must not be null or empty"); + + if (!ContainsLegacyAsmdefName(source)) + { + return MigrationTargetPreflightResult.NoTargets; + } + + if (!ThirdPartyToolMigrationAssemblyReferenceResolver.TryReadJsonObjectForMigration( + filePath, + _ => source, + out JObject asmdef)) + { + return MigrationTargetPreflightResult.NeedsFullScan; + } + + if (ContainsLegacyAsmdefReference(asmdef)) + { + return MigrationTargetPreflightResult.HasTargets; + } + + return MigrationTargetPreflightResult.NeedsFullScan; + } + + private static bool ContainsLegacyAsmdefReference(JObject asmdef) + { + Debug.Assert(asmdef != null, "asmdef must not be null"); + + if (asmdef["references"] is not JArray references) + { + return false; + } + + foreach (JToken referenceToken in references) + { + string reference = referenceToken.Value() ?? string.Empty; + if (string.Equals( + reference, + ThirdPartyToolMigrationRuleCatalog.LegacyEditorAssemblyName, + StringComparison.Ordinal) || + string.Equals( + reference, + ThirdPartyToolMigrationRuleCatalog.LegacyRuntimeAssemblyName, + StringComparison.Ordinal)) + { + return true; + } + } + + return false; + } + + private static bool ContainsLegacyAsmdefName(string source) + { + Debug.Assert(source != null, "source must not be null"); + + return ContainsAny(source, AsmdefCandidateMarkers); + } + + private static bool ContainsCSharpCandidateMarker(string source) + { + Debug.Assert(source != null, "source must not be null"); + + return ContainsAny(source, DirectCSharpCandidateMarkers) || + ContainsAnyTypeReplacementRuleMarker( + source, + ThirdPartyToolMigrationRuleCatalog.ToolContractTypeReplacementRules) || + ContainsAnyTypeReplacementRuleMarker( + source, + ThirdPartyToolMigrationRuleCatalog.DomainTypeReplacementRules) || + ContainsAnyTypeReplacementRuleMarker( + source, + ThirdPartyToolMigrationRuleCatalog.ApplicationTypeReplacementRules) || + ContainsAnyTypeReplacementRuleMarker( + source, + ThirdPartyToolMigrationRuleCatalog.FirstPartyScreenshotTypeReplacementRules); + } + + private static bool ContainsAnyTypeReplacementRuleMarker( + string source, + ThirdPartyToolMigrationParsingRules.TypeReplacementRule[] rules) + { + Debug.Assert(source != null, "source must not be null"); + Debug.Assert(rules != null, "rules must not be null"); + + foreach (ThirdPartyToolMigrationParsingRules.TypeReplacementRule rule in rules) + { + if (source.IndexOf(rule.LegacyName, StringComparison.Ordinal) >= 0 || + source.IndexOf(rule.CurrentName, StringComparison.Ordinal) >= 0) + { + return true; + } + } + + return false; + } + + private static bool ContainsAny(string source, string[] markers) + { + Debug.Assert(source != null, "source must not be null"); + Debug.Assert(markers != null, "markers must not be null"); + + foreach (string marker in markers) + { + if (source.IndexOf(marker, StringComparison.Ordinal) >= 0) + { + return true; + } + } + + return false; + } + } + + internal enum MigrationTargetPreflightResult + { + NoTargets, + HasTargets, + NeedsFullScan + } +} diff --git a/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationPreflightScanner.cs.meta b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationPreflightScanner.cs.meta new file mode 100644 index 0000000000..abbfa010f8 --- /dev/null +++ b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationPreflightScanner.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: a129d495fbdd4bb69117f40ada9f04bb +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationProjectFileInventory.cs b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationProjectFileInventory.cs index e59ba29987..13b49e0f6e 100644 --- a/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationProjectFileInventory.cs +++ b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationProjectFileInventory.cs @@ -217,7 +217,7 @@ private static void AddCandidateFilePath( } } - private static bool ShouldExcludeDirectory(string projectRoot, string directoryPath) + internal static bool ShouldExcludeDirectory(string projectRoot, string directoryPath) { Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty"); Debug.Assert(!string.IsNullOrEmpty(directoryPath), "directoryPath must not be null or empty"); diff --git a/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationTargetScanner.cs b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationTargetScanner.cs index 3d676f3233..26ef4a6fd5 100644 --- a/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationTargetScanner.cs +++ b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationTargetScanner.cs @@ -25,6 +25,18 @@ internal static async Task HasMigrationTargetAsync(string projectRoot, Can { Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty"); + MigrationTargetPreflightResult preflightResult = + await ThirdPartyToolMigrationPreflightScanner.FindMigrationTargetAsync(projectRoot, ct); + if (preflightResult == MigrationTargetPreflightResult.NoTargets) + { + return false; + } + + if (preflightResult == MigrationTargetPreflightResult.HasTargets) + { + return true; + } + ProjectFileInventory inventory = await ProjectFileInventory.CreateAsync( projectRoot, new Progress(), @@ -71,6 +83,22 @@ internal static async Task HasMigrationTargetAsync(string projectRoot, Can } string source = File.ReadAllText(csharpFilePath); + if (ContainsFastCSharpMigrationTarget(source)) + { + return true; + } + + if (!ThirdPartyToolMigrationRules.ContainsMigrationCandidateText(source)) + { + inspectedEntryCount++; + if (inspectedEntryCount % ThirdPartyToolMigrationFileServiceConstants.PreviewYieldBatchSize == 0) + { + await Task.Yield(); + } + + continue; + } + string assemblyDirectory = FindNearestAssemblyDirectory( csharpFilePath, asmdefDirectories, @@ -81,75 +109,67 @@ internal static async Task HasMigrationTargetAsync(string projectRoot, Can assemblyDeclaredTypeNamesByDirectory, assemblyDirectory, declaredTypeNames); - if (ContainsFastCSharpMigrationTarget(source)) + if (ThirdPartyToolMigrationRules.ContainsLegacyCSharpApi(source)) { - return true; + legacyAssemblyDirectories.Add(assemblyDirectory); } - if (ThirdPartyToolMigrationRules.ContainsMigrationCandidateText(source)) + if (ThirdPartyToolMigrationRules.ContainsLegacyGlobalUsing(source)) { - if (ThirdPartyToolMigrationRules.ContainsLegacyCSharpApi(source)) - { - legacyAssemblyDirectories.Add(assemblyDirectory); - } - - if (ThirdPartyToolMigrationRules.ContainsLegacyGlobalUsing(source)) - { - legacyAssemblyDirectories.Add(assemblyDirectory); - assemblyScopedLegacyDirectories.Add(assemblyDirectory); - AddAssemblyScopedLegacyAliases( - assemblyScopedLegacyAliasesByDirectory, - assemblyDirectory, - ThirdPartyToolMigrationRules.GetLegacyGlobalNamespaceAliases(source)); - } + legacyAssemblyDirectories.Add(assemblyDirectory); + assemblyScopedLegacyDirectories.Add(assemblyDirectory); + AddAssemblyScopedLegacyAliases( + assemblyScopedLegacyAliasesByDirectory, + assemblyDirectory, + ThirdPartyToolMigrationRules.GetLegacyGlobalNamespaceAliases(source)); + } - if (ThirdPartyToolMigrationRules.ContainsLegacyGlobalToolInfoTypeAlias(source)) - { - AddAssemblyScopedLegacyAliases( - assemblyScopedLegacyToolInfoAliasesByDirectory, - assemblyDirectory, - ThirdPartyToolMigrationRules.GetLegacyGlobalToolInfoTypeAliases(source)); - } + if (ThirdPartyToolMigrationRules.ContainsLegacyGlobalToolInfoTypeAlias(source)) + { + AddAssemblyScopedLegacyAliases( + assemblyScopedLegacyToolInfoAliasesByDirectory, + assemblyDirectory, + ThirdPartyToolMigrationRules.GetLegacyGlobalToolInfoTypeAliases(source)); + } - if (ThirdPartyToolMigrationRules.ContainsCurrentDomainGlobalUsing(source)) - { - assemblyScopedCurrentDomainDirectories.Add(assemblyDirectory); - } + if (ThirdPartyToolMigrationRules.ContainsCurrentDomainGlobalUsing(source)) + { + assemblyScopedCurrentDomainDirectories.Add(assemblyDirectory); + } - if (ThirdPartyToolMigrationRules.ContainsCurrentToolContractsGlobalUsing(source)) - { - assemblyScopedCurrentToolContractsDirectories.Add(assemblyDirectory); - } + if (ThirdPartyToolMigrationRules.ContainsCurrentToolContractsGlobalUsing(source)) + { + assemblyScopedCurrentToolContractsDirectories.Add(assemblyDirectory); + } - if (ThirdPartyToolMigrationRules.ContainsCurrentApplicationGlobalUsing(source)) - { - assemblyScopedCurrentApplicationDirectories.Add(assemblyDirectory); - applicationReferenceAssemblyDirectories.Add(assemblyDirectory); - } + if (ThirdPartyToolMigrationRules.ContainsCurrentApplicationGlobalUsing(source)) + { + assemblyScopedCurrentApplicationDirectories.Add(assemblyDirectory); + applicationReferenceAssemblyDirectories.Add(assemblyDirectory); + } - if (ThirdPartyToolMigrationRules.ContainsCurrentApplicationNamespaceAlias(source)) - { - applicationReferenceAssemblyDirectories.Add(assemblyDirectory); - AddAssemblyScopedNames( - assemblyScopedCurrentApplicationAliasesByDirectory, - assemblyDirectory, - ThirdPartyToolMigrationRules.GetCurrentApplicationGlobalNamespaceAliases(source)); - } + if (ThirdPartyToolMigrationRules.ContainsCurrentApplicationNamespaceAlias(source)) + { + applicationReferenceAssemblyDirectories.Add(assemblyDirectory); + AddAssemblyScopedNames( + assemblyScopedCurrentApplicationAliasesByDirectory, + assemblyDirectory, + ThirdPartyToolMigrationRules.GetCurrentApplicationGlobalNamespaceAliases(source)); + } - if (ThirdPartyToolMigrationRules.ContainsCurrentFirstPartyToolsGlobalUsing(source)) - { - assemblyScopedCurrentFirstPartyToolsDirectories.Add(assemblyDirectory); - firstPartyScreenshotReferenceAssemblyDirectories.Add(assemblyDirectory); - } + if (ThirdPartyToolMigrationRules.ContainsCurrentFirstPartyToolsGlobalUsing(source)) + { + assemblyScopedCurrentFirstPartyToolsDirectories.Add(assemblyDirectory); + firstPartyScreenshotReferenceAssemblyDirectories.Add(assemblyDirectory); + } - if (ThirdPartyToolMigrationRules.ContainsCurrentFirstPartyToolsNamespaceAlias(source)) - { - firstPartyScreenshotReferenceAssemblyDirectories.Add(assemblyDirectory); - AddAssemblyScopedNames( - assemblyScopedCurrentFirstPartyToolsAliasesByDirectory, - assemblyDirectory, - ThirdPartyToolMigrationRules.GetCurrentFirstPartyToolsGlobalNamespaceAliases(source)); - } + if (ThirdPartyToolMigrationRules.ContainsCurrentFirstPartyToolsNamespaceAlias(source)) + { + firstPartyScreenshotReferenceAssemblyDirectories.Add(assemblyDirectory); + AddAssemblyScopedNames( + assemblyScopedCurrentFirstPartyToolsAliasesByDirectory, + assemblyDirectory, + ThirdPartyToolMigrationRules.GetCurrentFirstPartyToolsGlobalNamespaceAliases(source)); } inspectedEntryCount++; diff --git a/Packages/src/Editor/Presentation/Setup/SetupWizardWindow.cs b/Packages/src/Editor/Presentation/Setup/SetupWizardWindow.cs index 477f26876c..75231420a5 100644 --- a/Packages/src/Editor/Presentation/Setup/SetupWizardWindow.cs +++ b/Packages/src/Editor/Presentation/Setup/SetupWizardWindow.cs @@ -80,14 +80,14 @@ internal static bool ShouldAutoShowForVersion( internal static bool ShouldAutoScanThirdPartyToolMigration(string currentVersion, string lastSeenVersion) { - if (string.IsNullOrEmpty(lastSeenVersion)) + if (!TryGetMajorVersion(currentVersion, out int currentMajorVersion)) { return false; } - if (!TryGetMajorVersion(currentVersion, out int currentMajorVersion)) + if (string.IsNullOrEmpty(lastSeenVersion)) { - return false; + return currentMajorVersion == 3; } if (!TryGetMajorVersion(lastSeenVersion, out int lastSeenMajorVersion)) diff --git a/Packages/src/Editor/Presentation/Setup/SetupWizardWindow.uss b/Packages/src/Editor/Presentation/Setup/SetupWizardWindow.uss index ee701aee00..3165efd40a 100644 --- a/Packages/src/Editor/Presentation/Setup/SetupWizardWindow.uss +++ b/Packages/src/Editor/Presentation/Setup/SetupWizardWindow.uss @@ -242,6 +242,10 @@ background-color: #388e3c; } +.setup-button--migration-action { + height: 66px; +} + /* =========================================== Element: Hint Label =========================================== */ diff --git a/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardView.cs b/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardView.cs index 8934381f76..93b2323590 100644 --- a/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardView.cs +++ b/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardView.cs @@ -18,6 +18,7 @@ internal sealed class ThirdPartyToolMigrationWizardView private readonly Label _migrationStatusLabel; private readonly ProgressBar _migrationProgressBar; + private readonly VisualElement _migrationButtonRow; private readonly Button _migrateButton; private readonly Button _refreshButton; private readonly Button _closeButton; @@ -26,6 +27,7 @@ private ThirdPartyToolMigrationWizardView( ScrollView mainScrollView, Label migrationStatusLabel, ProgressBar migrationProgressBar, + VisualElement migrationButtonRow, Button migrateButton, Button refreshButton, Button closeButton) @@ -33,6 +35,7 @@ private ThirdPartyToolMigrationWizardView( Debug.Assert(mainScrollView != null, "mainScrollView must not be null"); Debug.Assert(migrationStatusLabel != null, "migrationStatusLabel must not be null"); Debug.Assert(migrationProgressBar != null, "migrationProgressBar must not be null"); + Debug.Assert(migrationButtonRow != null, "migrationButtonRow must not be null"); Debug.Assert(migrateButton != null, "migrateButton must not be null"); Debug.Assert(refreshButton != null, "refreshButton must not be null"); Debug.Assert(closeButton != null, "closeButton must not be null"); @@ -40,6 +43,7 @@ private ThirdPartyToolMigrationWizardView( MainScrollView = mainScrollView; _migrationStatusLabel = migrationStatusLabel; _migrationProgressBar = migrationProgressBar; + _migrationButtonRow = migrationButtonRow; _migrateButton = migrateButton; _refreshButton = refreshButton; _closeButton = closeButton; @@ -67,13 +71,15 @@ internal static ThirdPartyToolMigrationWizardView Create( VisualElement content = CreateMigrationSection(mainScrollView); Label migrationStatusLabel = CreateStatusLabel(content); ProgressBar migrationProgressBar = CreateProgressBar(content); - Button migrateButton = CreateMigrateButton(content); + VisualElement migrationButtonRow = CreateMigrationButtonRow(content); + Button migrateButton = CreateMigrateButton(migrationButtonRow); (Button refreshButton, Button closeButton) = CreateFooter(mainScrollView); ThirdPartyToolMigrationWizardView view = new ThirdPartyToolMigrationWizardView( mainScrollView, migrationStatusLabel, migrationProgressBar, + migrationButtonRow, migrateButton, refreshButton, closeButton); @@ -85,38 +91,44 @@ internal void ShowNotCheckedState(bool isMigrating) { _migrationStatusLabel.text = ThirdPartyToolMigrationWizardText.MigrationNotCheckedText; ViewDataBinder.SetVisible(_migrationProgressBar, false); + ViewDataBinder.SetVisible(_migrationButtonRow, false); _migrateButton.SetEnabled(false); _migrateButton.text = ThirdPartyToolMigrationWizardText.GetMigrationButtonText( isMigrating, false, false); + ViewDataBinder.SetVisible(_refreshButton, true); + ViewDataBinder.SetVisible(_closeButton, false); _refreshButton.SetEnabled(true); - _closeButton.SetEnabled(true); } internal void ShowMigrationTargetsState(int fileCount, bool isMigrating) { _migrationStatusLabel.text = ThirdPartyToolMigrationWizardText.GetMigrationStatusText(fileCount); ViewDataBinder.SetVisible(_migrationProgressBar, false); + ViewDataBinder.SetVisible(_migrationButtonRow, true); _migrateButton.SetEnabled(!isMigrating); _migrateButton.text = ThirdPartyToolMigrationWizardText.GetMigrationButtonText( isMigrating, true, true); - _refreshButton.SetEnabled(true); - _closeButton.SetEnabled(true); + ViewDataBinder.SetVisible(_refreshButton, true); + ViewDataBinder.SetVisible(_closeButton, false); + _refreshButton.SetEnabled(false); } internal void ShowNoMigrationTargetsState(bool isMigrating) { _migrationStatusLabel.text = ThirdPartyToolMigrationWizardText.NoMigrationTargetsText; ViewDataBinder.SetVisible(_migrationProgressBar, false); + ViewDataBinder.SetVisible(_migrationButtonRow, true); _migrateButton.SetEnabled(false); _migrateButton.text = ThirdPartyToolMigrationWizardText.GetMigrationButtonText( isMigrating, false, true); - _refreshButton.SetEnabled(true); + ViewDataBinder.SetVisible(_refreshButton, false); + ViewDataBinder.SetVisible(_closeButton, true); _closeButton.SetEnabled(true); } @@ -126,14 +138,16 @@ internal void ShowCheckingState(ThirdPartyToolMigrationProgress progress, bool i progress, isMigrating); ViewDataBinder.SetVisible(_migrationProgressBar, true); + ViewDataBinder.SetVisible(_migrationButtonRow, true); UpdateMigrationProgressBar(progress); _migrateButton.SetEnabled(false); _migrateButton.text = ThirdPartyToolMigrationWizardText.GetMigrationButtonText( isMigrating, true, true); + ViewDataBinder.SetVisible(_refreshButton, true); + ViewDataBinder.SetVisible(_closeButton, false); _refreshButton.SetEnabled(false); - _closeButton.SetEnabled(!isMigrating); } private static ScrollView CreateMainScrollView(VisualElement root) @@ -178,15 +192,22 @@ private static ProgressBar CreateProgressBar(VisualElement content) return migrationProgressBar; } - private static Button CreateMigrateButton(VisualElement content) + private static VisualElement CreateMigrationButtonRow(VisualElement content) { VisualElement migrationButtonRow = new VisualElement(); migrationButtonRow.AddToClassList("setup-step__button-row"); content.Add(migrationButtonRow); + return migrationButtonRow; + } + + private static Button CreateMigrateButton(VisualElement migrationButtonRow) + { + Debug.Assert(migrationButtonRow != null, "migrationButtonRow must not be null"); Button migrateButton = new Button(); migrateButton.text = ThirdPartyToolMigrationWizardText.GetMigrationButtonText(false, false, false); migrateButton.AddToClassList("setup-button"); + migrateButton.AddToClassList("setup-button--migration-action"); migrationButtonRow.Add(migrateButton); return migrateButton; } diff --git a/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardWindow.cs b/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardWindow.cs index 358d4cc990..9165228a25 100644 --- a/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardWindow.cs +++ b/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardWindow.cs @@ -46,10 +46,89 @@ public static void ShowWindow() } internal static void ShowWindowForAutoScan() + { + _ = RunAutoScanAsync( + HasMigrationTargetsForAutoScanAsync, + SwitchToMainThreadForAutoScanAsync, + OpenWindowAfterAutoScan, + ConsumeAutoScanSessionState, + LogAutoScanException, + CancellationToken.None); + } + + internal static async Task RunAutoScanAsync( + System.Func> hasMigrationTargetsAsync, + System.Func switchToMainThreadAsync, + System.Action openWindow, + System.Action consumeAutoScanSessionState, + System.Action logException, + CancellationToken ct) + { + Debug.Assert(hasMigrationTargetsAsync != null, "hasMigrationTargetsAsync must not be null"); + Debug.Assert(switchToMainThreadAsync != null, "switchToMainThreadAsync must not be null"); + Debug.Assert(openWindow != null, "openWindow must not be null"); + Debug.Assert(consumeAutoScanSessionState != null, "consumeAutoScanSessionState must not be null"); + Debug.Assert(logException != null, "logException must not be null"); + + try + { + bool hasMigrationTargets = await hasMigrationTargetsAsync(ct); + await switchToMainThreadAsync(ct); + if (!ShouldOpenWindowAfterAutoScan(hasMigrationTargets, ct.IsCancellationRequested)) + { + return false; + } + + openWindow(); + return true; + } + catch (System.Exception ex) + { + logException(ex); + return false; + } + finally + { + consumeAutoScanSessionState(); + } + } + + private static async Task HasMigrationTargetsForAutoScanAsync(CancellationToken ct) + { + string projectRoot = UnityCliLoopPathResolver.GetProjectRoot(); + ThirdPartyToolMigrationUseCase migrationUseCase = + ThirdPartyToolMigrationUseCaseRegistry.GetRegisteredUseCase(); + return await Task.Run(async () => + await migrationUseCase.HasMigrationTargetsAsync(projectRoot, ct), ct); + } + + private static async Task SwitchToMainThreadForAutoScanAsync(CancellationToken ct) + { + if (ct.IsCancellationRequested) + { + return; + } + + await MainThreadSwitcher.SwitchToMainThread(); + } + + private static void OpenWindowAfterAutoScan() { ShowWindowInternal(true); } + private static void ConsumeAutoScanSessionState() + { + GetSessionStateService().ConsumeShouldAutoScanThirdPartyToolMigration(); + } + + private static void LogAutoScanException(System.Exception ex) + { + Debug.Assert(ex != null, "ex must not be null"); + + Debug.LogException(ex); + } + internal static bool ShouldStartInitialRefresh( bool shouldRefreshAfterCreateGui, bool shouldAutoScanThirdPartyToolMigration) @@ -59,6 +138,13 @@ internal static bool ShouldStartInitialRefresh( shouldAutoScanThirdPartyToolMigration); } + internal static bool ShouldOpenWindowAfterAutoScan( + bool hasMigrationTargets, + bool isCancellationRequested) + { + return hasMigrationTargets && !isCancellationRequested; + } + internal static void PrepareForOpen( ThirdPartyToolMigrationWizardWindow window, string title,