From e85c6f577e2fed001d691bd2361bb82d1da63000 Mon Sep 17 00:00:00 2001 From: hatayama Date: Sat, 20 Jun 2026 12:21:31 +0900 Subject: [PATCH 01/16] Speed up third-party migration scans Run auto-scan preflight before opening the migration wizard, cache validated preview plans for apply, and skip deeper source analysis when files have no migration candidate text. --- ...ThirdPartyToolMigrationFileServiceTests.cs | 111 ++++++++++++++++ ...hirdPartyToolMigrationWizardWindowTests.cs | 16 +++ ...PartyToolMigrationAssemblyUsageAnalyzer.cs | 10 +- ...ToolMigrationAssemblyUsageAsyncAnalyzer.cs | 10 +- .../ThirdPartyToolMigrationFileService.cs | 114 +++++++++++++++- .../ThirdPartyToolMigrationModels.cs | 117 ++++++++++++++++- .../ThirdPartyToolMigrationPlanBuilder.cs | 10 +- .../ThirdPartyToolMigrationTargetScanner.cs | 124 ++++++++++-------- .../ThirdPartyToolMigrationWizardWindow.cs | 22 +++- 9 files changed, 454 insertions(+), 80 deletions(-) diff --git a/Assets/Tests/Editor/ThirdPartyToolMigrationFileServiceTests.cs b/Assets/Tests/Editor/ThirdPartyToolMigrationFileServiceTests.cs index 828f9d7644..1de768fa1c 100644 --- a/Assets/Tests/Editor/ThirdPartyToolMigrationFileServiceTests.cs +++ b/Assets/Tests/Editor/ThirdPartyToolMigrationFileServiceTests.cs @@ -70,6 +70,61 @@ 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_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 TryReadJsonObjectForMigration_WhenReadThrowsIOException_ReturnsFalse() { @@ -4007,6 +4062,62 @@ 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 PreviewMigrationAsync_WhenUnrelatedAsmdefJsonIsMalformedAndNoAsmrefs_PreviewsAsmdefRepair() { diff --git a/Assets/Tests/Editor/ThirdPartyToolMigrationWizardWindowTests.cs b/Assets/Tests/Editor/ThirdPartyToolMigrationWizardWindowTests.cs index 41e22cbf79..3d1f82c0fd 100644 --- a/Assets/Tests/Editor/ThirdPartyToolMigrationWizardWindowTests.cs +++ b/Assets/Tests/Editor/ThirdPartyToolMigrationWizardWindowTests.cs @@ -29,6 +29,22 @@ 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)); + } + [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..2cff59cc57 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,114 @@ 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); + 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 MigrationFileFingerprint(string filePath, long length, long lastWriteTimeUtcTicks) + { + 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; + Length = length; + LastWriteTimeUtcTicks = lastWriteTimeUtcTicks; + } + + public string FilePath { get; } + public long Length { get; } + public long LastWriteTimeUtcTicks { get; } + + public static MigrationFileFingerprint Capture(string filePath) + { + Debug.Assert(!string.IsNullOrEmpty(filePath), "filePath must not be null or empty"); + + FileInfo fileInfo = new(filePath); + return new MigrationFileFingerprint( + filePath, + fileInfo.Length, + fileInfo.LastWriteTimeUtc.Ticks); + } + + public bool HasSameValuesAs(MigrationFileFingerprint other) + { + return string.Equals(FilePath, other.FilePath, StringComparison.Ordinal) && + Length == other.Length && + LastWriteTimeUtcTicks == other.LastWriteTimeUtcTicks; + } + } + 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..7b6ff04f07 100644 --- a/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationPlanBuilder.cs +++ b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationPlanBuilder.cs @@ -169,7 +169,10 @@ internal static MigrationPlan Create(string projectRoot) changes.Add(new MigrationFileChange(asmdefFilePath, result.Content)); } - return new MigrationPlan(changes, replacementCount); + return new MigrationPlan( + changes, + replacementCount, + MigrationProjectFingerprint.CaptureFromInventory(inventory)); } internal static async Task CreateAsync( @@ -361,7 +364,10 @@ internal static async Task CreateAsync( } progressCounter.ReportComplete(); - return new MigrationPlan(changes, replacementCount); + return new MigrationPlan( + changes, + replacementCount, + MigrationProjectFingerprint.CaptureFromInventory(inventory)); } diff --git a/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationTargetScanner.cs b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationTargetScanner.cs index 3d676f3233..25e6d4e32e 100644 --- a/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationTargetScanner.cs +++ b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationTargetScanner.cs @@ -71,6 +71,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 +97,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/ThirdPartyToolMigrationWizardWindow.cs b/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardWindow.cs index 358d4cc990..6c9af6df37 100644 --- a/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardWindow.cs +++ b/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardWindow.cs @@ -45,8 +45,21 @@ public static void ShowWindow() ShowWindowInternal(false); } - internal static void ShowWindowForAutoScan() + internal static async void ShowWindowForAutoScan() { + CancellationToken ct = CancellationToken.None; + string projectRoot = UnityCliLoopPathResolver.GetProjectRoot(); + ThirdPartyToolMigrationUseCase migrationUseCase = + ThirdPartyToolMigrationUseCaseRegistry.GetRegisteredUseCase(); + bool hasMigrationTargets = await Task.Run(async () => + await migrationUseCase.HasMigrationTargetsAsync(projectRoot, ct)); + await MainThreadSwitcher.SwitchToMainThread(); + if (!ShouldOpenWindowAfterAutoScan(hasMigrationTargets, ct.IsCancellationRequested)) + { + GetSessionStateService().ConsumeShouldAutoScanThirdPartyToolMigration(); + return; + } + ShowWindowInternal(true); } @@ -59,6 +72,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, From 9bb3af7dcce0f87c3f2d2bc22c8cf5f0db9d5f71 Mon Sep 17 00:00:00 2001 From: hatayama Date: Sat, 20 Jun 2026 12:35:03 +0900 Subject: [PATCH 02/16] Add streaming migration preflight scan Short-circuit startup migration checks with a fixed-text streaming pass before building the full project inventory, while preserving the existing full scan for ambiguous migration markers. --- ...ThirdPartyToolMigrationFileServiceTests.cs | 48 +++ ...dPartyToolMigrationProjectFileInventory.cs | 2 +- ...yToolMigrationStreamingPreflightScanner.cs | 277 ++++++++++++++++++ ...MigrationStreamingPreflightScanner.cs.meta | 11 + .../ThirdPartyToolMigrationTargetScanner.cs | 12 + 5 files changed, 349 insertions(+), 1 deletion(-) create mode 100644 Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationStreamingPreflightScanner.cs create mode 100644 Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationStreamingPreflightScanner.cs.meta diff --git a/Assets/Tests/Editor/ThirdPartyToolMigrationFileServiceTests.cs b/Assets/Tests/Editor/ThirdPartyToolMigrationFileServiceTests.cs index 1de768fa1c..faa5293de0 100644 --- a/Assets/Tests/Editor/ThirdPartyToolMigrationFileServiceTests.cs +++ b/Assets/Tests/Editor/ThirdPartyToolMigrationFileServiceTests.cs @@ -125,6 +125,54 @@ public void MigrationProjectFingerprint_WhenCandidateFileIsAdded_DoesNotMatch() } } + [Test] + public void StreamingPreflightScanner_WhenSourceHasNoMigrationMarkers_ReturnsNoTargets() + { + // Verifies that startup preflight can skip full scans when no migration marker text exists. + MigrationTargetPreflightResult result = + ThirdPartyToolMigrationStreamingPreflightScanner.InspectSourceText( + "public sealed class PlainTool {}", + ".cs"); + + Assert.That(result, Is.EqualTo(MigrationTargetPreflightResult.NoTargets)); + } + + [Test] + public void StreamingPreflightScanner_WhenLegacyToolSourceExists_ReturnsHasTargets() + { + // Verifies that startup preflight detects direct legacy custom tool source immediately. + MigrationTargetPreflightResult result = + ThirdPartyToolMigrationStreamingPreflightScanner.InspectSourceText( + "using io.github.hatayama.uLoopMCP; [McpTool] public sealed class HelloTool {}", + ".cs"); + + Assert.That(result, Is.EqualTo(MigrationTargetPreflightResult.HasTargets)); + } + + [Test] + public void StreamingPreflightScanner_WhenLegacyNamespaceIsOnlyComment_ReturnsNeedsFullScan() + { + // Verifies that startup preflight does not report targets from marker text inside comments. + MigrationTargetPreflightResult result = + ThirdPartyToolMigrationStreamingPreflightScanner.InspectSourceText( + "// using io.github.hatayama.uLoopMCP;", + ".cs"); + + Assert.That(result, Is.EqualTo(MigrationTargetPreflightResult.NeedsFullScan)); + } + + [Test] + public void StreamingPreflightScanner_WhenLegacyAsmdefReferenceExists_ReturnsHasTargets() + { + // Verifies that startup preflight detects legacy asmdef references without building an inventory. + MigrationTargetPreflightResult result = + ThirdPartyToolMigrationStreamingPreflightScanner.InspectSourceText( + @"{ ""references"": [ ""uLoopMCP.Editor"" ] }", + ".asmdef"); + + Assert.That(result, Is.EqualTo(MigrationTargetPreflightResult.HasTargets)); + } + [Test] public void TryReadJsonObjectForMigration_WhenReadThrowsIOException_ReturnsFalse() { 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/ThirdPartyToolMigrationStreamingPreflightScanner.cs b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationStreamingPreflightScanner.cs new file mode 100644 index 0000000000..4f06ed8dba --- /dev/null +++ b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationStreamingPreflightScanner.cs @@ -0,0 +1,277 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +namespace io.github.hatayama.UnityCliLoop.Infrastructure +{ + /// + /// Streams project files for cheap fixed-text migration markers before the full target scan runs. + /// + internal static class ThirdPartyToolMigrationStreamingPreflightScanner + { + 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" + }; + + 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; + } + + DirectorySearchState searchState = new DirectorySearchState(assetsDirectory); + int inspectedEntryCount = 0; + while (searchState.HasPendingDirectories) + { + if (ct.IsCancellationRequested) + { + return MigrationTargetPreflightResult.NoTargets; + } + + string directoryPath = searchState.PopDirectory(); + MigrationTargetPreflightResult fileResult = + await InspectFilesInDirectoryAsync(directoryPath, ct); + if (fileResult != MigrationTargetPreflightResult.NoTargets) + { + return fileResult; + } + + foreach (string childDirectoryPath in Directory.EnumerateDirectories(directoryPath)) + { + if (ProjectFileInventory.ShouldExcludeDirectory(projectRoot, childDirectoryPath)) + { + continue; + } + + searchState.PushDirectory(childDirectoryPath); + inspectedEntryCount++; + if (inspectedEntryCount % ThirdPartyToolMigrationFileServiceConstants.PreviewYieldBatchSize == 0) + { + await Task.Yield(); + } + } + } + + return 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"); + + if (string.Equals(extension, ".cs", StringComparison.OrdinalIgnoreCase)) + { + return InspectCSharpSourceText(source); + } + + if (string.Equals(extension, ".asmdef", StringComparison.OrdinalIgnoreCase)) + { + return InspectAsmdefSourceText(source); + } + + 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; + foreach (string filePath in Directory.EnumerateFiles(directoryPath)) + { + if (ct.IsCancellationRequested) + { + return MigrationTargetPreflightResult.NoTargets; + } + + string extension = Path.GetExtension(filePath); + if (!ShouldInspectExtension(extension)) + { + continue; + } + + string source = File.ReadAllText(filePath); + MigrationTargetPreflightResult result = InspectSourceText(source, extension); + if (result != MigrationTargetPreflightResult.NoTargets) + { + return result; + } + + inspectedEntryCount++; + if (inspectedEntryCount % ThirdPartyToolMigrationFileServiceConstants.PreviewYieldBatchSize == 0) + { + await Task.Yield(); + } + } + + return MigrationTargetPreflightResult.NoTargets; + } + + 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 InspectAsmdefSourceText(string source) + { + Debug.Assert(source != null, "source must not be null"); + + if (!ContainsLegacyAsmdefName(source)) + { + return MigrationTargetPreflightResult.NoTargets; + } + + if (ThirdPartyToolMigrationFastSourceTargetDetector.ContainsFastAsmdefMigrationTarget(source)) + { + return MigrationTargetPreflightResult.HasTargets; + } + + return MigrationTargetPreflightResult.NeedsFullScan; + } + + private static bool ContainsLegacyAsmdefName(string source) + { + Debug.Assert(source != null, "source must not be null"); + + return source.IndexOf( + ThirdPartyToolMigrationRuleCatalog.LegacyEditorAssemblyName, + StringComparison.Ordinal) >= 0 || + source.IndexOf( + ThirdPartyToolMigrationRuleCatalog.LegacyRuntimeAssemblyName, + StringComparison.Ordinal) >= 0; + } + + 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; + } + + private sealed class DirectorySearchState + { + private readonly Stack _pendingDirectories = new Stack(); + + public DirectorySearchState(string rootDirectory) + { + Debug.Assert(!string.IsNullOrEmpty(rootDirectory), "rootDirectory must not be null or empty"); + + _pendingDirectories.Push(rootDirectory); + } + + public bool HasPendingDirectories => _pendingDirectories.Count > 0; + + public string PopDirectory() + { + return _pendingDirectories.Pop(); + } + + public void PushDirectory(string directoryPath) + { + Debug.Assert(!string.IsNullOrEmpty(directoryPath), "directoryPath must not be null or empty"); + + _pendingDirectories.Push(directoryPath); + } + } + } + + internal enum MigrationTargetPreflightResult + { + NoTargets, + HasTargets, + NeedsFullScan + } +} diff --git a/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationStreamingPreflightScanner.cs.meta b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationStreamingPreflightScanner.cs.meta new file mode 100644 index 0000000000..01342a3372 --- /dev/null +++ b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationStreamingPreflightScanner.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 7280a38a11be4f0eb1105f7b99546c28 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationTargetScanner.cs b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationTargetScanner.cs index 25e6d4e32e..4888910c60 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 ThirdPartyToolMigrationStreamingPreflightScanner.FindMigrationTargetAsync(projectRoot, ct); + if (preflightResult == MigrationTargetPreflightResult.NoTargets) + { + return false; + } + + if (preflightResult == MigrationTargetPreflightResult.HasTargets) + { + return true; + } + ProjectFileInventory inventory = await ProjectFileInventory.CreateAsync( projectRoot, new Progress(), From dce0e01a1c4e42412ccbfd4f4a227d17d4f8ba81 Mon Sep 17 00:00:00 2001 From: hatayama Date: Sat, 20 Jun 2026 12:51:54 +0900 Subject: [PATCH 03/16] Add preflight search strategy pipeline Treat ripgrep and managed migration preflight scans through the same strategy pipeline so startup checks can use rg when available while preserving the managed fallback and full-scan behavior for inconclusive matches. --- ...ThirdPartyToolMigrationFileServiceTests.cs | 121 +++++++++++- ...igrationManagedPreflightSearchStrategy.cs} | 34 +--- ...ionManagedPreflightSearchStrategy.cs.meta} | 0 ...ThirdPartyToolMigrationPreflightScanner.cs | 169 ++++++++++++++++ ...PartyToolMigrationPreflightScanner.cs.meta | 11 ++ ...MigrationRipgrepPreflightSearchStrategy.cs | 180 ++++++++++++++++++ ...tionRipgrepPreflightSearchStrategy.cs.meta | 11 ++ .../ThirdPartyToolMigrationTargetScanner.cs | 2 +- 8 files changed, 491 insertions(+), 37 deletions(-) rename Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/{ThirdPartyToolMigrationStreamingPreflightScanner.cs => ThirdPartyToolMigrationManagedPreflightSearchStrategy.cs} (84%) rename Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/{ThirdPartyToolMigrationStreamingPreflightScanner.cs.meta => ThirdPartyToolMigrationManagedPreflightSearchStrategy.cs.meta} (100%) create mode 100644 Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationPreflightScanner.cs create mode 100644 Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationPreflightScanner.cs.meta create mode 100644 Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationRipgrepPreflightSearchStrategy.cs create mode 100644 Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationRipgrepPreflightSearchStrategy.cs.meta diff --git a/Assets/Tests/Editor/ThirdPartyToolMigrationFileServiceTests.cs b/Assets/Tests/Editor/ThirdPartyToolMigrationFileServiceTests.cs index faa5293de0..c6a0e44048 100644 --- a/Assets/Tests/Editor/ThirdPartyToolMigrationFileServiceTests.cs +++ b/Assets/Tests/Editor/ThirdPartyToolMigrationFileServiceTests.cs @@ -126,11 +126,11 @@ public void MigrationProjectFingerprint_WhenCandidateFileIsAdded_DoesNotMatch() } [Test] - public void StreamingPreflightScanner_WhenSourceHasNoMigrationMarkers_ReturnsNoTargets() + public void ManagedPreflightSearchStrategy_WhenSourceHasNoMigrationMarkers_ReturnsNoTargets() { // Verifies that startup preflight can skip full scans when no migration marker text exists. MigrationTargetPreflightResult result = - ThirdPartyToolMigrationStreamingPreflightScanner.InspectSourceText( + ThirdPartyToolMigrationManagedPreflightSearchStrategy.InspectSourceText( "public sealed class PlainTool {}", ".cs"); @@ -138,11 +138,11 @@ public void StreamingPreflightScanner_WhenSourceHasNoMigrationMarkers_ReturnsNoT } [Test] - public void StreamingPreflightScanner_WhenLegacyToolSourceExists_ReturnsHasTargets() + public void ManagedPreflightSearchStrategy_WhenLegacyToolSourceExists_ReturnsHasTargets() { // Verifies that startup preflight detects direct legacy custom tool source immediately. MigrationTargetPreflightResult result = - ThirdPartyToolMigrationStreamingPreflightScanner.InspectSourceText( + ThirdPartyToolMigrationManagedPreflightSearchStrategy.InspectSourceText( "using io.github.hatayama.uLoopMCP; [McpTool] public sealed class HelloTool {}", ".cs"); @@ -150,11 +150,11 @@ public void StreamingPreflightScanner_WhenLegacyToolSourceExists_ReturnsHasTarge } [Test] - public void StreamingPreflightScanner_WhenLegacyNamespaceIsOnlyComment_ReturnsNeedsFullScan() + public void ManagedPreflightSearchStrategy_WhenLegacyNamespaceIsOnlyComment_ReturnsNeedsFullScan() { // Verifies that startup preflight does not report targets from marker text inside comments. MigrationTargetPreflightResult result = - ThirdPartyToolMigrationStreamingPreflightScanner.InspectSourceText( + ThirdPartyToolMigrationManagedPreflightSearchStrategy.InspectSourceText( "// using io.github.hatayama.uLoopMCP;", ".cs"); @@ -162,17 +162,101 @@ public void StreamingPreflightScanner_WhenLegacyNamespaceIsOnlyComment_ReturnsNe } [Test] - public void StreamingPreflightScanner_WhenLegacyAsmdefReferenceExists_ReturnsHasTargets() + public void ManagedPreflightSearchStrategy_WhenLegacyAsmdefReferenceExists_ReturnsHasTargets() { // Verifies that startup preflight detects legacy asmdef references without building an inventory. MigrationTargetPreflightResult result = - ThirdPartyToolMigrationStreamingPreflightScanner.InspectSourceText( + ThirdPartyToolMigrationManagedPreflightSearchStrategy.InspectSourceText( @"{ ""references"": [ ""uLoopMCP.Editor"" ] }", ".asmdef"); Assert.That(result, Is.EqualTo(MigrationTargetPreflightResult.HasTargets)); } + [Test] + public async Task PreflightSearchPipeline_WhenFirstStrategyIsDecisive_DoesNotRunNextStrategy() + { + // Verifies that rg and managed preflight strategies share one decisive-result pipeline. + FakePreflightSearchStrategy firstStrategy = + new FakePreflightSearchStrategy(MigrationTargetPreflightResult.NoTargets); + FakePreflightSearchStrategy secondStrategy = + new FakePreflightSearchStrategy(MigrationTargetPreflightResult.HasTargets); + ThirdPartyToolMigrationPreflightSearchPipeline pipeline = + new ThirdPartyToolMigrationPreflightSearchPipeline( + new IThirdPartyToolMigrationPreflightSearchStrategy[] + { + firstStrategy, + secondStrategy + }); + + MigrationTargetPreflightResult result = + await pipeline.FindMigrationTargetAsync("ProjectRoot", CancellationToken.None); + + Assert.That(result, Is.EqualTo(MigrationTargetPreflightResult.NoTargets)); + Assert.That(firstStrategy.CallCount, Is.EqualTo(1)); + Assert.That(secondStrategy.CallCount, Is.EqualTo(0)); + } + + [Test] + public async Task PreflightSearchPipeline_WhenFirstStrategyNeedsFullScan_RunsNextStrategy() + { + // Verifies that inconclusive rg results fall through to the managed preflight strategy. + FakePreflightSearchStrategy firstStrategy = + new FakePreflightSearchStrategy(MigrationTargetPreflightResult.NeedsFullScan); + FakePreflightSearchStrategy secondStrategy = + new FakePreflightSearchStrategy(MigrationTargetPreflightResult.HasTargets); + ThirdPartyToolMigrationPreflightSearchPipeline pipeline = + new ThirdPartyToolMigrationPreflightSearchPipeline( + new IThirdPartyToolMigrationPreflightSearchStrategy[] + { + firstStrategy, + secondStrategy + }); + + MigrationTargetPreflightResult result = + await pipeline.FindMigrationTargetAsync("ProjectRoot", CancellationToken.None); + + Assert.That(result, Is.EqualTo(MigrationTargetPreflightResult.HasTargets)); + Assert.That(firstStrategy.CallCount, Is.EqualTo(1)); + Assert.That(secondStrategy.CallCount, Is.EqualTo(1)); + } + + [Test] + public void RipgrepPreflightSearchStrategy_WhenNoMatchExitCode_ReturnsNoTargets() + { + // Verifies that rg's no-match exit code can skip the managed file scan. + MigrationTargetPreflightResult result = + ThirdPartyToolMigrationRipgrepPreflightSearchStrategy.MapExitCodeToResult(1); + + Assert.That(result, Is.EqualTo(MigrationTargetPreflightResult.NoTargets)); + } + + [Test] + public void RipgrepPreflightSearchStrategy_WhenMatchExitCode_ReturnsNeedsFullScan() + { + // Verifies that rg matches remain candidates for managed or full migration checks. + MigrationTargetPreflightResult result = + ThirdPartyToolMigrationRipgrepPreflightSearchStrategy.MapExitCodeToResult(0); + + Assert.That(result, Is.EqualTo(MigrationTargetPreflightResult.NeedsFullScan)); + } + + [Test] + public void RipgrepPreflightSearchStrategy_BuildArguments_UsesSharedMarkersAndUnityGlobs() + { + // Verifies that rg searches the same marker set as the managed preflight strategy. + string arguments = ThirdPartyToolMigrationRipgrepPreflightSearchStrategy.BuildArguments("Assets"); + + Assert.That(arguments, Does.Contain("--fixed-strings")); + Assert.That(arguments, Does.Contain("--no-ignore")); + Assert.That(arguments, Does.Contain("--hidden")); + Assert.That(arguments, Does.Contain("--glob \"*.cs\"")); + Assert.That(arguments, Does.Contain("--glob \"*.asmdef\"")); + Assert.That(arguments, Does.Contain("\"io.github.hatayama.uLoopMCP\"")); + Assert.That(arguments, Does.Contain("\"uLoopMCP.Editor\"")); + Assert.That(arguments, Does.Contain("--glob \"!**/Library/**\"")); + } + [Test] public void TryReadJsonObjectForMigration_WhenReadThrowsIOException_ReturnsFalse() { @@ -4836,5 +4920,26 @@ public void Report(ThirdPartyToolMigrationProgress value) _cts.Cancel(); } } + + private sealed class FakePreflightSearchStrategy : IThirdPartyToolMigrationPreflightSearchStrategy + { + private readonly MigrationTargetPreflightResult _result; + + public FakePreflightSearchStrategy(MigrationTargetPreflightResult result) + { + _result = result; + } + + public int CallCount { get; private set; } + + public Task FindMigrationTargetAsync( + string projectRoot, + CancellationToken ct) + { + // Verifies pipeline sequencing without depending on the filesystem or rg. + CallCount++; + return Task.FromResult(_result); + } + } } } diff --git a/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationStreamingPreflightScanner.cs b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationManagedPreflightSearchStrategy.cs similarity index 84% rename from Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationStreamingPreflightScanner.cs rename to Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationManagedPreflightSearchStrategy.cs index 4f06ed8dba..79e42cbc56 100644 --- a/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationStreamingPreflightScanner.cs +++ b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationManagedPreflightSearchStrategy.cs @@ -8,29 +8,12 @@ namespace io.github.hatayama.UnityCliLoop.Infrastructure { /// - /// Streams project files for cheap fixed-text migration markers before the full target scan runs. + /// Searches project files with managed C# IO when external fast search is unavailable or inconclusive. /// - internal static class ThirdPartyToolMigrationStreamingPreflightScanner + internal sealed class ThirdPartyToolMigrationManagedPreflightSearchStrategy : + IThirdPartyToolMigrationPreflightSearchStrategy { - 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" - }; - - internal static async Task FindMigrationTargetAsync( + public async Task FindMigrationTargetAsync( string projectRoot, CancellationToken ct) { @@ -179,19 +162,14 @@ private static bool ContainsLegacyAsmdefName(string source) { Debug.Assert(source != null, "source must not be null"); - return source.IndexOf( - ThirdPartyToolMigrationRuleCatalog.LegacyEditorAssemblyName, - StringComparison.Ordinal) >= 0 || - source.IndexOf( - ThirdPartyToolMigrationRuleCatalog.LegacyRuntimeAssemblyName, - StringComparison.Ordinal) >= 0; + return ContainsAny(source, ThirdPartyToolMigrationPreflightMarkerSet.AsmdefCandidateMarkers); } private static bool ContainsCSharpCandidateMarker(string source) { Debug.Assert(source != null, "source must not be null"); - return ContainsAny(source, DirectCSharpCandidateMarkers) || + return ContainsAny(source, ThirdPartyToolMigrationPreflightMarkerSet.DirectCSharpCandidateMarkers) || ContainsAnyTypeReplacementRuleMarker( source, ThirdPartyToolMigrationRuleCatalog.ToolContractTypeReplacementRules) || diff --git a/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationStreamingPreflightScanner.cs.meta b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationManagedPreflightSearchStrategy.cs.meta similarity index 100% rename from Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationStreamingPreflightScanner.cs.meta rename to Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationManagedPreflightSearchStrategy.cs.meta diff --git a/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationPreflightScanner.cs b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationPreflightScanner.cs new file mode 100644 index 0000000000..02b938a8f6 --- /dev/null +++ b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationPreflightScanner.cs @@ -0,0 +1,169 @@ +using System.Collections.Generic; +using System.Diagnostics; +using System.Threading; +using System.Threading.Tasks; + +namespace io.github.hatayama.UnityCliLoop.Infrastructure +{ + /// + /// Runs migration preflight search strategies before the full target scan is needed. + /// + internal static class ThirdPartyToolMigrationPreflightScanner + { + private static readonly ThirdPartyToolMigrationPreflightSearchPipeline DefaultPipeline = + new ThirdPartyToolMigrationPreflightSearchPipeline( + new IThirdPartyToolMigrationPreflightSearchStrategy[] + { + new ThirdPartyToolMigrationRipgrepPreflightSearchStrategy(), + new ThirdPartyToolMigrationManagedPreflightSearchStrategy() + }); + + internal static Task FindMigrationTargetAsync( + string projectRoot, + CancellationToken ct) + { + Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty"); + + return DefaultPipeline.FindMigrationTargetAsync(projectRoot, ct); + } + } + + /// + /// Defines one migration preflight search approach, such as ripgrep or managed file IO. + /// + internal interface IThirdPartyToolMigrationPreflightSearchStrategy + { + Task FindMigrationTargetAsync( + string projectRoot, + CancellationToken ct); + } + + /// + /// Treats all preflight search strategies uniformly and stops on the first decisive result. + /// + internal sealed class ThirdPartyToolMigrationPreflightSearchPipeline : + IThirdPartyToolMigrationPreflightSearchStrategy + { + private readonly IThirdPartyToolMigrationPreflightSearchStrategy[] _strategies; + + public ThirdPartyToolMigrationPreflightSearchPipeline( + IThirdPartyToolMigrationPreflightSearchStrategy[] strategies) + { + Debug.Assert(strategies != null, "strategies must not be null"); + + _strategies = strategies; + } + + public async Task FindMigrationTargetAsync( + string projectRoot, + CancellationToken ct) + { + Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty"); + + foreach (IThirdPartyToolMigrationPreflightSearchStrategy strategy in _strategies) + { + Debug.Assert(strategy != null, "strategy must not be null"); + if (ct.IsCancellationRequested) + { + return MigrationTargetPreflightResult.NoTargets; + } + + MigrationTargetPreflightResult result = await strategy.FindMigrationTargetAsync(projectRoot, ct); + if (result != MigrationTargetPreflightResult.NeedsFullScan) + { + return result; + } + } + + return MigrationTargetPreflightResult.NeedsFullScan; + } + } + + /// + /// Provides shared fixed-string markers used by every migration preflight search strategy. + /// + internal static class ThirdPartyToolMigrationPreflightMarkerSet + { + internal 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" + }; + + internal static readonly string[] AsmdefCandidateMarkers = + { + ThirdPartyToolMigrationRuleCatalog.LegacyEditorAssemblyName, + ThirdPartyToolMigrationRuleCatalog.LegacyRuntimeAssemblyName + }; + + internal static string[] CreateAllCandidateMarkers() + { + List markers = new List(); + AddMarkers(markers, DirectCSharpCandidateMarkers); + AddMarkers(markers, AsmdefCandidateMarkers); + AddTypeReplacementRuleMarkers( + markers, + ThirdPartyToolMigrationRuleCatalog.ToolContractTypeReplacementRules); + AddTypeReplacementRuleMarkers( + markers, + ThirdPartyToolMigrationRuleCatalog.DomainTypeReplacementRules); + AddTypeReplacementRuleMarkers( + markers, + ThirdPartyToolMigrationRuleCatalog.ApplicationTypeReplacementRules); + AddTypeReplacementRuleMarkers( + markers, + ThirdPartyToolMigrationRuleCatalog.FirstPartyScreenshotTypeReplacementRules); + return markers.ToArray(); + } + + private static void AddMarkers(List markers, string[] additions) + { + Debug.Assert(markers != null, "markers must not be null"); + Debug.Assert(additions != null, "additions must not be null"); + + foreach (string marker in additions) + { + AddMarker(markers, marker); + } + } + + private static void AddTypeReplacementRuleMarkers( + List markers, + ThirdPartyToolMigrationParsingRules.TypeReplacementRule[] rules) + { + Debug.Assert(markers != null, "markers must not be null"); + Debug.Assert(rules != null, "rules must not be null"); + + foreach (ThirdPartyToolMigrationParsingRules.TypeReplacementRule rule in rules) + { + AddMarker(markers, rule.LegacyName); + AddMarker(markers, rule.CurrentName); + } + } + + private static void AddMarker(List markers, string marker) + { + Debug.Assert(markers != null, "markers must not be null"); + Debug.Assert(!string.IsNullOrEmpty(marker), "marker must not be null or empty"); + + if (markers.Contains(marker)) + { + return; + } + + markers.Add(marker); + } + } +} 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/ThirdPartyToolMigrationRipgrepPreflightSearchStrategy.cs b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationRipgrepPreflightSearchStrategy.cs new file mode 100644 index 0000000000..f30997dac8 --- /dev/null +++ b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationRipgrepPreflightSearchStrategy.cs @@ -0,0 +1,180 @@ +using System.Diagnostics; +using System.IO; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +using io.github.hatayama.UnityCliLoop.ToolContracts; + +namespace io.github.hatayama.UnityCliLoop.Infrastructure +{ + /// + /// Uses ripgrep as an optional fixed-string preflight search strategy. + /// + internal sealed class ThirdPartyToolMigrationRipgrepPreflightSearchStrategy : + IThirdPartyToolMigrationPreflightSearchStrategy + { + private const string RipgrepExecutableName = "rg"; + private const int ProcessTimeoutMs = 5000; + private const int MatchExitCode = 0; + private const int NoMatchExitCode = 1; + + public 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; + } + + if (ct.IsCancellationRequested) + { + return MigrationTargetPreflightResult.NoTargets; + } + + ProcessStartInfo startInfo = BuildStartInfo(assetsDirectory); + Process process = ProcessStartHelper.TryStart(startInfo); + if (process == null) + { + return MigrationTargetPreflightResult.NeedsFullScan; + } + + using (process) + { + process.OutputDataReceived += (sender, e) => { }; + process.ErrorDataReceived += (sender, e) => { }; + process.BeginOutputReadLine(); + process.BeginErrorReadLine(); + + using CancellationTokenRegistration registration = + ct.Register(() => CliInstallationDetector.KillProcessIfRunning(process)); + bool exited = await Task.Run(() => process.WaitForExit(ProcessTimeoutMs)); + if (!exited) + { + CliInstallationDetector.KillProcessIfRunning(process); + return MigrationTargetPreflightResult.NeedsFullScan; + } + + process.WaitForExit(); + if (ct.IsCancellationRequested) + { + return MigrationTargetPreflightResult.NoTargets; + } + + return MapExitCodeToResult(process.ExitCode); + } + } + + internal static MigrationTargetPreflightResult MapExitCodeToResult(int exitCode) + { + if (exitCode == NoMatchExitCode) + { + return MigrationTargetPreflightResult.NoTargets; + } + + if (exitCode == MatchExitCode) + { + return MigrationTargetPreflightResult.NeedsFullScan; + } + + return MigrationTargetPreflightResult.NeedsFullScan; + } + + internal static ProcessStartInfo BuildStartInfo(string assetsDirectory) + { + Debug.Assert(!string.IsNullOrEmpty(assetsDirectory), "assetsDirectory must not be null or empty"); + + return new ProcessStartInfo + { + FileName = RipgrepExecutableName, + Arguments = BuildArguments(assetsDirectory), + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + }; + } + + internal static string BuildArguments(string assetsDirectory) + { + Debug.Assert(!string.IsNullOrEmpty(assetsDirectory), "assetsDirectory must not be null or empty"); + + StringBuilder builder = new StringBuilder(); + AppendRawArgument(builder, "--fixed-strings"); + AppendRawArgument(builder, "--quiet"); + AppendRawArgument(builder, "--no-messages"); + AppendRawArgument(builder, "--no-ignore"); + AppendRawArgument(builder, "--hidden"); + AppendRawArgument(builder, "--color"); + AppendQuotedArgument(builder, "never"); + AppendGlobArguments(builder); + AppendPatternArguments(builder); + AppendQuotedArgument(builder, assetsDirectory); + return builder.ToString(); + } + + private static void AppendGlobArguments(StringBuilder builder) + { + Debug.Assert(builder != null, "builder must not be null"); + + AppendRawArgument(builder, "--glob"); + AppendQuotedArgument(builder, "*.cs"); + AppendRawArgument(builder, "--glob"); + AppendQuotedArgument(builder, "*.asmdef"); + + foreach (string directoryName in ThirdPartyToolMigrationRules.GetExcludedDirectoryNames()) + { + AppendRawArgument(builder, "--glob"); + AppendQuotedArgument(builder, "!**/" + directoryName + "/**"); + } + } + + private static void AppendPatternArguments(StringBuilder builder) + { + Debug.Assert(builder != null, "builder must not be null"); + + foreach (string marker in ThirdPartyToolMigrationPreflightMarkerSet.CreateAllCandidateMarkers()) + { + AppendRawArgument(builder, "--regexp"); + AppendQuotedArgument(builder, marker); + } + } + + private static void AppendRawArgument(StringBuilder builder, string argument) + { + Debug.Assert(builder != null, "builder must not be null"); + Debug.Assert(!string.IsNullOrEmpty(argument), "argument must not be null or empty"); + + if (builder.Length > 0) + { + builder.Append(' '); + } + + builder.Append(argument); + } + + private static void AppendQuotedArgument(StringBuilder builder, string argument) + { + Debug.Assert(builder != null, "builder must not be null"); + Debug.Assert(argument != null, "argument must not be null"); + + if (builder.Length > 0) + { + builder.Append(' '); + } + + builder.Append(QuoteProcessArgument(argument)); + } + + private static string QuoteProcessArgument(string value) + { + Debug.Assert(value != null, "value must not be null"); + + return "\"" + value.Replace("\"", "\\\"") + "\""; + } + } +} diff --git a/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationRipgrepPreflightSearchStrategy.cs.meta b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationRipgrepPreflightSearchStrategy.cs.meta new file mode 100644 index 0000000000..6674552d09 --- /dev/null +++ b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationRipgrepPreflightSearchStrategy.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 790eeafc61084c32ba9bb0a28fad1eaa +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationTargetScanner.cs b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationTargetScanner.cs index 4888910c60..26ef4a6fd5 100644 --- a/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationTargetScanner.cs +++ b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationTargetScanner.cs @@ -26,7 +26,7 @@ internal static async Task HasMigrationTargetAsync(string projectRoot, Can Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty"); MigrationTargetPreflightResult preflightResult = - await ThirdPartyToolMigrationStreamingPreflightScanner.FindMigrationTargetAsync(projectRoot, ct); + await ThirdPartyToolMigrationPreflightScanner.FindMigrationTargetAsync(projectRoot, ct); if (preflightResult == MigrationTargetPreflightResult.NoTargets) { return false; From 2927941762da84d398007f1ca3ddb59078437f97 Mon Sep 17 00:00:00 2001 From: hatayama Date: Sat, 20 Jun 2026 13:18:49 +0900 Subject: [PATCH 04/16] Avoid full scans after ambiguous preflight markers Continue managed migration preflight scans past inconclusive marker text so later direct migration targets can short-circuit startup checks. Remove the ripgrep strategy because measurement showed the external search path did not reduce the real bottleneck. --- ...ThirdPartyToolMigrationFileServiceTests.cs | 67 +++---- ...MigrationManagedPreflightSearchStrategy.cs | 24 ++- ...ThirdPartyToolMigrationPreflightScanner.cs | 3 +- ...MigrationRipgrepPreflightSearchStrategy.cs | 180 ------------------ ...tionRipgrepPreflightSearchStrategy.cs.meta | 11 -- 5 files changed, 51 insertions(+), 234 deletions(-) delete mode 100644 Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationRipgrepPreflightSearchStrategy.cs delete mode 100644 Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationRipgrepPreflightSearchStrategy.cs.meta diff --git a/Assets/Tests/Editor/ThirdPartyToolMigrationFileServiceTests.cs b/Assets/Tests/Editor/ThirdPartyToolMigrationFileServiceTests.cs index c6a0e44048..a3f9c71911 100644 --- a/Assets/Tests/Editor/ThirdPartyToolMigrationFileServiceTests.cs +++ b/Assets/Tests/Editor/ThirdPartyToolMigrationFileServiceTests.cs @@ -173,6 +173,35 @@ public void ManagedPreflightSearchStrategy_WhenLegacyAsmdefReferenceExists_Retur Assert.That(result, Is.EqualTo(MigrationTargetPreflightResult.HasTargets)); } + [Test] + public async Task ManagedPreflightSearchStrategy_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 {}"); + ThirdPartyToolMigrationManagedPreflightSearchStrategy strategy = + new ThirdPartyToolMigrationManagedPreflightSearchStrategy(); + + MigrationTargetPreflightResult result = + await strategy.FindMigrationTargetAsync(projectRoot, CancellationToken.None); + + Assert.That(result, Is.EqualTo(MigrationTargetPreflightResult.HasTargets)); + } + finally + { + Directory.Delete(projectRoot, recursive: true); + } + } + [Test] public async Task PreflightSearchPipeline_WhenFirstStrategyIsDecisive_DoesNotRunNextStrategy() { @@ -200,7 +229,7 @@ public async Task PreflightSearchPipeline_WhenFirstStrategyIsDecisive_DoesNotRun [Test] public async Task PreflightSearchPipeline_WhenFirstStrategyNeedsFullScan_RunsNextStrategy() { - // Verifies that inconclusive rg results fall through to the managed preflight strategy. + // Verifies that inconclusive preflight results fall through to the next strategy. FakePreflightSearchStrategy firstStrategy = new FakePreflightSearchStrategy(MigrationTargetPreflightResult.NeedsFullScan); FakePreflightSearchStrategy secondStrategy = @@ -221,42 +250,6 @@ public async Task PreflightSearchPipeline_WhenFirstStrategyNeedsFullScan_RunsNex Assert.That(secondStrategy.CallCount, Is.EqualTo(1)); } - [Test] - public void RipgrepPreflightSearchStrategy_WhenNoMatchExitCode_ReturnsNoTargets() - { - // Verifies that rg's no-match exit code can skip the managed file scan. - MigrationTargetPreflightResult result = - ThirdPartyToolMigrationRipgrepPreflightSearchStrategy.MapExitCodeToResult(1); - - Assert.That(result, Is.EqualTo(MigrationTargetPreflightResult.NoTargets)); - } - - [Test] - public void RipgrepPreflightSearchStrategy_WhenMatchExitCode_ReturnsNeedsFullScan() - { - // Verifies that rg matches remain candidates for managed or full migration checks. - MigrationTargetPreflightResult result = - ThirdPartyToolMigrationRipgrepPreflightSearchStrategy.MapExitCodeToResult(0); - - Assert.That(result, Is.EqualTo(MigrationTargetPreflightResult.NeedsFullScan)); - } - - [Test] - public void RipgrepPreflightSearchStrategy_BuildArguments_UsesSharedMarkersAndUnityGlobs() - { - // Verifies that rg searches the same marker set as the managed preflight strategy. - string arguments = ThirdPartyToolMigrationRipgrepPreflightSearchStrategy.BuildArguments("Assets"); - - Assert.That(arguments, Does.Contain("--fixed-strings")); - Assert.That(arguments, Does.Contain("--no-ignore")); - Assert.That(arguments, Does.Contain("--hidden")); - Assert.That(arguments, Does.Contain("--glob \"*.cs\"")); - Assert.That(arguments, Does.Contain("--glob \"*.asmdef\"")); - Assert.That(arguments, Does.Contain("\"io.github.hatayama.uLoopMCP\"")); - Assert.That(arguments, Does.Contain("\"uLoopMCP.Editor\"")); - Assert.That(arguments, Does.Contain("--glob \"!**/Library/**\"")); - } - [Test] public void TryReadJsonObjectForMigration_WhenReadThrowsIOException_ReturnsFalse() { diff --git a/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationManagedPreflightSearchStrategy.cs b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationManagedPreflightSearchStrategy.cs index 79e42cbc56..d60cedb3bd 100644 --- a/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationManagedPreflightSearchStrategy.cs +++ b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationManagedPreflightSearchStrategy.cs @@ -27,6 +27,7 @@ public async Task FindMigrationTargetAsync( DirectorySearchState searchState = new DirectorySearchState(assetsDirectory); int inspectedEntryCount = 0; + bool needsFullScan = false; while (searchState.HasPendingDirectories) { if (ct.IsCancellationRequested) @@ -37,11 +38,16 @@ public async Task FindMigrationTargetAsync( string directoryPath = searchState.PopDirectory(); MigrationTargetPreflightResult fileResult = await InspectFilesInDirectoryAsync(directoryPath, ct); - if (fileResult != MigrationTargetPreflightResult.NoTargets) + if (fileResult == MigrationTargetPreflightResult.HasTargets) { return fileResult; } + if (fileResult == MigrationTargetPreflightResult.NeedsFullScan) + { + needsFullScan = true; + } + foreach (string childDirectoryPath in Directory.EnumerateDirectories(directoryPath)) { if (ProjectFileInventory.ShouldExcludeDirectory(projectRoot, childDirectoryPath)) @@ -58,7 +64,9 @@ public async Task FindMigrationTargetAsync( } } - return MigrationTargetPreflightResult.NoTargets; + return needsFullScan + ? MigrationTargetPreflightResult.NeedsFullScan + : MigrationTargetPreflightResult.NoTargets; } internal static MigrationTargetPreflightResult InspectSourceText( @@ -88,6 +96,7 @@ private static async Task InspectFilesInDirector 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) @@ -103,11 +112,16 @@ private static async Task InspectFilesInDirector string source = File.ReadAllText(filePath); MigrationTargetPreflightResult result = InspectSourceText(source, extension); - if (result != MigrationTargetPreflightResult.NoTargets) + if (result == MigrationTargetPreflightResult.HasTargets) { return result; } + if (result == MigrationTargetPreflightResult.NeedsFullScan) + { + needsFullScan = true; + } + inspectedEntryCount++; if (inspectedEntryCount % ThirdPartyToolMigrationFileServiceConstants.PreviewYieldBatchSize == 0) { @@ -115,7 +129,9 @@ private static async Task InspectFilesInDirector } } - return MigrationTargetPreflightResult.NoTargets; + return needsFullScan + ? MigrationTargetPreflightResult.NeedsFullScan + : MigrationTargetPreflightResult.NoTargets; } private static bool ShouldInspectExtension(string extension) diff --git a/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationPreflightScanner.cs b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationPreflightScanner.cs index 02b938a8f6..4d9bb23ea4 100644 --- a/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationPreflightScanner.cs +++ b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationPreflightScanner.cs @@ -14,7 +14,6 @@ internal static class ThirdPartyToolMigrationPreflightScanner new ThirdPartyToolMigrationPreflightSearchPipeline( new IThirdPartyToolMigrationPreflightSearchStrategy[] { - new ThirdPartyToolMigrationRipgrepPreflightSearchStrategy(), new ThirdPartyToolMigrationManagedPreflightSearchStrategy() }); @@ -29,7 +28,7 @@ internal static Task FindMigrationTargetAsync( } /// - /// Defines one migration preflight search approach, such as ripgrep or managed file IO. + /// Defines one migration preflight search approach. /// internal interface IThirdPartyToolMigrationPreflightSearchStrategy { diff --git a/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationRipgrepPreflightSearchStrategy.cs b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationRipgrepPreflightSearchStrategy.cs deleted file mode 100644 index f30997dac8..0000000000 --- a/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationRipgrepPreflightSearchStrategy.cs +++ /dev/null @@ -1,180 +0,0 @@ -using System.Diagnostics; -using System.IO; -using System.Text; -using System.Threading; -using System.Threading.Tasks; - -using io.github.hatayama.UnityCliLoop.ToolContracts; - -namespace io.github.hatayama.UnityCliLoop.Infrastructure -{ - /// - /// Uses ripgrep as an optional fixed-string preflight search strategy. - /// - internal sealed class ThirdPartyToolMigrationRipgrepPreflightSearchStrategy : - IThirdPartyToolMigrationPreflightSearchStrategy - { - private const string RipgrepExecutableName = "rg"; - private const int ProcessTimeoutMs = 5000; - private const int MatchExitCode = 0; - private const int NoMatchExitCode = 1; - - public 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; - } - - if (ct.IsCancellationRequested) - { - return MigrationTargetPreflightResult.NoTargets; - } - - ProcessStartInfo startInfo = BuildStartInfo(assetsDirectory); - Process process = ProcessStartHelper.TryStart(startInfo); - if (process == null) - { - return MigrationTargetPreflightResult.NeedsFullScan; - } - - using (process) - { - process.OutputDataReceived += (sender, e) => { }; - process.ErrorDataReceived += (sender, e) => { }; - process.BeginOutputReadLine(); - process.BeginErrorReadLine(); - - using CancellationTokenRegistration registration = - ct.Register(() => CliInstallationDetector.KillProcessIfRunning(process)); - bool exited = await Task.Run(() => process.WaitForExit(ProcessTimeoutMs)); - if (!exited) - { - CliInstallationDetector.KillProcessIfRunning(process); - return MigrationTargetPreflightResult.NeedsFullScan; - } - - process.WaitForExit(); - if (ct.IsCancellationRequested) - { - return MigrationTargetPreflightResult.NoTargets; - } - - return MapExitCodeToResult(process.ExitCode); - } - } - - internal static MigrationTargetPreflightResult MapExitCodeToResult(int exitCode) - { - if (exitCode == NoMatchExitCode) - { - return MigrationTargetPreflightResult.NoTargets; - } - - if (exitCode == MatchExitCode) - { - return MigrationTargetPreflightResult.NeedsFullScan; - } - - return MigrationTargetPreflightResult.NeedsFullScan; - } - - internal static ProcessStartInfo BuildStartInfo(string assetsDirectory) - { - Debug.Assert(!string.IsNullOrEmpty(assetsDirectory), "assetsDirectory must not be null or empty"); - - return new ProcessStartInfo - { - FileName = RipgrepExecutableName, - Arguments = BuildArguments(assetsDirectory), - UseShellExecute = false, - RedirectStandardOutput = true, - RedirectStandardError = true, - CreateNoWindow = true - }; - } - - internal static string BuildArguments(string assetsDirectory) - { - Debug.Assert(!string.IsNullOrEmpty(assetsDirectory), "assetsDirectory must not be null or empty"); - - StringBuilder builder = new StringBuilder(); - AppendRawArgument(builder, "--fixed-strings"); - AppendRawArgument(builder, "--quiet"); - AppendRawArgument(builder, "--no-messages"); - AppendRawArgument(builder, "--no-ignore"); - AppendRawArgument(builder, "--hidden"); - AppendRawArgument(builder, "--color"); - AppendQuotedArgument(builder, "never"); - AppendGlobArguments(builder); - AppendPatternArguments(builder); - AppendQuotedArgument(builder, assetsDirectory); - return builder.ToString(); - } - - private static void AppendGlobArguments(StringBuilder builder) - { - Debug.Assert(builder != null, "builder must not be null"); - - AppendRawArgument(builder, "--glob"); - AppendQuotedArgument(builder, "*.cs"); - AppendRawArgument(builder, "--glob"); - AppendQuotedArgument(builder, "*.asmdef"); - - foreach (string directoryName in ThirdPartyToolMigrationRules.GetExcludedDirectoryNames()) - { - AppendRawArgument(builder, "--glob"); - AppendQuotedArgument(builder, "!**/" + directoryName + "/**"); - } - } - - private static void AppendPatternArguments(StringBuilder builder) - { - Debug.Assert(builder != null, "builder must not be null"); - - foreach (string marker in ThirdPartyToolMigrationPreflightMarkerSet.CreateAllCandidateMarkers()) - { - AppendRawArgument(builder, "--regexp"); - AppendQuotedArgument(builder, marker); - } - } - - private static void AppendRawArgument(StringBuilder builder, string argument) - { - Debug.Assert(builder != null, "builder must not be null"); - Debug.Assert(!string.IsNullOrEmpty(argument), "argument must not be null or empty"); - - if (builder.Length > 0) - { - builder.Append(' '); - } - - builder.Append(argument); - } - - private static void AppendQuotedArgument(StringBuilder builder, string argument) - { - Debug.Assert(builder != null, "builder must not be null"); - Debug.Assert(argument != null, "argument must not be null"); - - if (builder.Length > 0) - { - builder.Append(' '); - } - - builder.Append(QuoteProcessArgument(argument)); - } - - private static string QuoteProcessArgument(string value) - { - Debug.Assert(value != null, "value must not be null"); - - return "\"" + value.Replace("\"", "\\\"") + "\""; - } - } -} diff --git a/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationRipgrepPreflightSearchStrategy.cs.meta b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationRipgrepPreflightSearchStrategy.cs.meta deleted file mode 100644 index 6674552d09..0000000000 --- a/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationRipgrepPreflightSearchStrategy.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: 790eeafc61084c32ba9bb0a28fad1eaa -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: From db20c78ec368ee69a60d01615d8c27f841a5ca0f Mon Sep 17 00:00:00 2001 From: hatayama Date: Sat, 20 Jun 2026 13:38:21 +0900 Subject: [PATCH 05/16] Simplify migration preflight scanner Collapse the unused preflight strategy abstraction back into a single scanner now that ripgrep is no longer used. Keep the ambiguous-marker scan behavior covered by the existing migration tests. --- ...ThirdPartyToolMigrationFileServiceTests.cs | 92 +----- ...MigrationManagedPreflightSearchStrategy.cs | 271 ---------------- ...tionManagedPreflightSearchStrategy.cs.meta | 11 - ...ThirdPartyToolMigrationPreflightScanner.cs | 297 ++++++++++++------ 4 files changed, 211 insertions(+), 460 deletions(-) delete mode 100644 Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationManagedPreflightSearchStrategy.cs delete mode 100644 Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationManagedPreflightSearchStrategy.cs.meta diff --git a/Assets/Tests/Editor/ThirdPartyToolMigrationFileServiceTests.cs b/Assets/Tests/Editor/ThirdPartyToolMigrationFileServiceTests.cs index a3f9c71911..fbcadb9626 100644 --- a/Assets/Tests/Editor/ThirdPartyToolMigrationFileServiceTests.cs +++ b/Assets/Tests/Editor/ThirdPartyToolMigrationFileServiceTests.cs @@ -126,11 +126,11 @@ public void MigrationProjectFingerprint_WhenCandidateFileIsAdded_DoesNotMatch() } [Test] - public void ManagedPreflightSearchStrategy_WhenSourceHasNoMigrationMarkers_ReturnsNoTargets() + public void PreflightScanner_WhenSourceHasNoMigrationMarkers_ReturnsNoTargets() { // Verifies that startup preflight can skip full scans when no migration marker text exists. MigrationTargetPreflightResult result = - ThirdPartyToolMigrationManagedPreflightSearchStrategy.InspectSourceText( + ThirdPartyToolMigrationPreflightScanner.InspectSourceText( "public sealed class PlainTool {}", ".cs"); @@ -138,11 +138,11 @@ public void ManagedPreflightSearchStrategy_WhenSourceHasNoMigrationMarkers_Retur } [Test] - public void ManagedPreflightSearchStrategy_WhenLegacyToolSourceExists_ReturnsHasTargets() + public void PreflightScanner_WhenLegacyToolSourceExists_ReturnsHasTargets() { // Verifies that startup preflight detects direct legacy custom tool source immediately. MigrationTargetPreflightResult result = - ThirdPartyToolMigrationManagedPreflightSearchStrategy.InspectSourceText( + ThirdPartyToolMigrationPreflightScanner.InspectSourceText( "using io.github.hatayama.uLoopMCP; [McpTool] public sealed class HelloTool {}", ".cs"); @@ -150,11 +150,11 @@ public void ManagedPreflightSearchStrategy_WhenLegacyToolSourceExists_ReturnsHas } [Test] - public void ManagedPreflightSearchStrategy_WhenLegacyNamespaceIsOnlyComment_ReturnsNeedsFullScan() + public void PreflightScanner_WhenLegacyNamespaceIsOnlyComment_ReturnsNeedsFullScan() { // Verifies that startup preflight does not report targets from marker text inside comments. MigrationTargetPreflightResult result = - ThirdPartyToolMigrationManagedPreflightSearchStrategy.InspectSourceText( + ThirdPartyToolMigrationPreflightScanner.InspectSourceText( "// using io.github.hatayama.uLoopMCP;", ".cs"); @@ -162,11 +162,11 @@ public void ManagedPreflightSearchStrategy_WhenLegacyNamespaceIsOnlyComment_Retu } [Test] - public void ManagedPreflightSearchStrategy_WhenLegacyAsmdefReferenceExists_ReturnsHasTargets() + public void PreflightScanner_WhenLegacyAsmdefReferenceExists_ReturnsHasTargets() { // Verifies that startup preflight detects legacy asmdef references without building an inventory. MigrationTargetPreflightResult result = - ThirdPartyToolMigrationManagedPreflightSearchStrategy.InspectSourceText( + ThirdPartyToolMigrationPreflightScanner.InspectSourceText( @"{ ""references"": [ ""uLoopMCP.Editor"" ] }", ".asmdef"); @@ -174,7 +174,7 @@ public void ManagedPreflightSearchStrategy_WhenLegacyAsmdefReferenceExists_Retur } [Test] - public async Task ManagedPreflightSearchStrategy_WhenAmbiguousFileExistsWithDirectTarget_ReturnsHasTargets() + 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(); @@ -188,11 +188,11 @@ public async Task ManagedPreflightSearchStrategy_WhenAmbiguousFileExistsWithDire File.WriteAllText( Path.Combine(toolDirectory, "1_HelloTool.cs"), "using io.github.hatayama.uLoopMCP; [McpTool] public sealed class HelloTool {}"); - ThirdPartyToolMigrationManagedPreflightSearchStrategy strategy = - new ThirdPartyToolMigrationManagedPreflightSearchStrategy(); MigrationTargetPreflightResult result = - await strategy.FindMigrationTargetAsync(projectRoot, CancellationToken.None); + await ThirdPartyToolMigrationPreflightScanner.FindMigrationTargetAsync( + projectRoot, + CancellationToken.None); Assert.That(result, Is.EqualTo(MigrationTargetPreflightResult.HasTargets)); } @@ -202,54 +202,6 @@ public async Task ManagedPreflightSearchStrategy_WhenAmbiguousFileExistsWithDire } } - [Test] - public async Task PreflightSearchPipeline_WhenFirstStrategyIsDecisive_DoesNotRunNextStrategy() - { - // Verifies that rg and managed preflight strategies share one decisive-result pipeline. - FakePreflightSearchStrategy firstStrategy = - new FakePreflightSearchStrategy(MigrationTargetPreflightResult.NoTargets); - FakePreflightSearchStrategy secondStrategy = - new FakePreflightSearchStrategy(MigrationTargetPreflightResult.HasTargets); - ThirdPartyToolMigrationPreflightSearchPipeline pipeline = - new ThirdPartyToolMigrationPreflightSearchPipeline( - new IThirdPartyToolMigrationPreflightSearchStrategy[] - { - firstStrategy, - secondStrategy - }); - - MigrationTargetPreflightResult result = - await pipeline.FindMigrationTargetAsync("ProjectRoot", CancellationToken.None); - - Assert.That(result, Is.EqualTo(MigrationTargetPreflightResult.NoTargets)); - Assert.That(firstStrategy.CallCount, Is.EqualTo(1)); - Assert.That(secondStrategy.CallCount, Is.EqualTo(0)); - } - - [Test] - public async Task PreflightSearchPipeline_WhenFirstStrategyNeedsFullScan_RunsNextStrategy() - { - // Verifies that inconclusive preflight results fall through to the next strategy. - FakePreflightSearchStrategy firstStrategy = - new FakePreflightSearchStrategy(MigrationTargetPreflightResult.NeedsFullScan); - FakePreflightSearchStrategy secondStrategy = - new FakePreflightSearchStrategy(MigrationTargetPreflightResult.HasTargets); - ThirdPartyToolMigrationPreflightSearchPipeline pipeline = - new ThirdPartyToolMigrationPreflightSearchPipeline( - new IThirdPartyToolMigrationPreflightSearchStrategy[] - { - firstStrategy, - secondStrategy - }); - - MigrationTargetPreflightResult result = - await pipeline.FindMigrationTargetAsync("ProjectRoot", CancellationToken.None); - - Assert.That(result, Is.EqualTo(MigrationTargetPreflightResult.HasTargets)); - Assert.That(firstStrategy.CallCount, Is.EqualTo(1)); - Assert.That(secondStrategy.CallCount, Is.EqualTo(1)); - } - [Test] public void TryReadJsonObjectForMigration_WhenReadThrowsIOException_ReturnsFalse() { @@ -4914,25 +4866,5 @@ public void Report(ThirdPartyToolMigrationProgress value) } } - private sealed class FakePreflightSearchStrategy : IThirdPartyToolMigrationPreflightSearchStrategy - { - private readonly MigrationTargetPreflightResult _result; - - public FakePreflightSearchStrategy(MigrationTargetPreflightResult result) - { - _result = result; - } - - public int CallCount { get; private set; } - - public Task FindMigrationTargetAsync( - string projectRoot, - CancellationToken ct) - { - // Verifies pipeline sequencing without depending on the filesystem or rg. - CallCount++; - return Task.FromResult(_result); - } - } } } diff --git a/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationManagedPreflightSearchStrategy.cs b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationManagedPreflightSearchStrategy.cs deleted file mode 100644 index d60cedb3bd..0000000000 --- a/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationManagedPreflightSearchStrategy.cs +++ /dev/null @@ -1,271 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.IO; -using System.Threading; -using System.Threading.Tasks; - -namespace io.github.hatayama.UnityCliLoop.Infrastructure -{ - /// - /// Searches project files with managed C# IO when external fast search is unavailable or inconclusive. - /// - internal sealed class ThirdPartyToolMigrationManagedPreflightSearchStrategy : - IThirdPartyToolMigrationPreflightSearchStrategy - { - public 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; - } - - DirectorySearchState searchState = new DirectorySearchState(assetsDirectory); - int inspectedEntryCount = 0; - bool needsFullScan = false; - while (searchState.HasPendingDirectories) - { - if (ct.IsCancellationRequested) - { - return MigrationTargetPreflightResult.NoTargets; - } - - string directoryPath = searchState.PopDirectory(); - 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; - } - - searchState.PushDirectory(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"); - - if (string.Equals(extension, ".cs", StringComparison.OrdinalIgnoreCase)) - { - return InspectCSharpSourceText(source); - } - - if (string.Equals(extension, ".asmdef", StringComparison.OrdinalIgnoreCase)) - { - return InspectAsmdefSourceText(source); - } - - 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; - } - - string source = File.ReadAllText(filePath); - MigrationTargetPreflightResult result = InspectSourceText(source, extension); - 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 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 InspectAsmdefSourceText(string source) - { - Debug.Assert(source != null, "source must not be null"); - - if (!ContainsLegacyAsmdefName(source)) - { - return MigrationTargetPreflightResult.NoTargets; - } - - if (ThirdPartyToolMigrationFastSourceTargetDetector.ContainsFastAsmdefMigrationTarget(source)) - { - return MigrationTargetPreflightResult.HasTargets; - } - - return MigrationTargetPreflightResult.NeedsFullScan; - } - - private static bool ContainsLegacyAsmdefName(string source) - { - Debug.Assert(source != null, "source must not be null"); - - return ContainsAny(source, ThirdPartyToolMigrationPreflightMarkerSet.AsmdefCandidateMarkers); - } - - private static bool ContainsCSharpCandidateMarker(string source) - { - Debug.Assert(source != null, "source must not be null"); - - return ContainsAny(source, ThirdPartyToolMigrationPreflightMarkerSet.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; - } - - private sealed class DirectorySearchState - { - private readonly Stack _pendingDirectories = new Stack(); - - public DirectorySearchState(string rootDirectory) - { - Debug.Assert(!string.IsNullOrEmpty(rootDirectory), "rootDirectory must not be null or empty"); - - _pendingDirectories.Push(rootDirectory); - } - - public bool HasPendingDirectories => _pendingDirectories.Count > 0; - - public string PopDirectory() - { - return _pendingDirectories.Pop(); - } - - public void PushDirectory(string directoryPath) - { - Debug.Assert(!string.IsNullOrEmpty(directoryPath), "directoryPath must not be null or empty"); - - _pendingDirectories.Push(directoryPath); - } - } - } - - internal enum MigrationTargetPreflightResult - { - NoTargets, - HasTargets, - NeedsFullScan - } -} diff --git a/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationManagedPreflightSearchStrategy.cs.meta b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationManagedPreflightSearchStrategy.cs.meta deleted file mode 100644 index 01342a3372..0000000000 --- a/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationManagedPreflightSearchStrategy.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: 7280a38a11be4f0eb1105f7b99546c28 -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: diff --git a/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationPreflightScanner.cs b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationPreflightScanner.cs index 4d9bb23ea4..9f24566f92 100644 --- a/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationPreflightScanner.cs +++ b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationPreflightScanner.cs @@ -1,168 +1,269 @@ +using System; using System.Collections.Generic; using System.Diagnostics; +using System.IO; using System.Threading; using System.Threading.Tasks; namespace io.github.hatayama.UnityCliLoop.Infrastructure { /// - /// Runs migration preflight search strategies before the full target scan is needed. + /// Streams project files for cheap migration target checks before the full scan runs. /// internal static class ThirdPartyToolMigrationPreflightScanner { - private static readonly ThirdPartyToolMigrationPreflightSearchPipeline DefaultPipeline = - new ThirdPartyToolMigrationPreflightSearchPipeline( - new IThirdPartyToolMigrationPreflightSearchStrategy[] - { - new ThirdPartyToolMigrationManagedPreflightSearchStrategy() - }); + 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 Task FindMigrationTargetAsync( + internal static async Task FindMigrationTargetAsync( string projectRoot, CancellationToken ct) { Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty"); - return DefaultPipeline.FindMigrationTargetAsync(projectRoot, ct); - } - } + string assetsDirectory = Path.Combine(projectRoot, "Assets"); + if (!Directory.Exists(assetsDirectory)) + { + return MigrationTargetPreflightResult.NoTargets; + } - /// - /// Defines one migration preflight search approach. - /// - internal interface IThirdPartyToolMigrationPreflightSearchStrategy - { - Task FindMigrationTargetAsync( - string projectRoot, - CancellationToken ct); - } + Stack pendingDirectories = new Stack(); + pendingDirectories.Push(assetsDirectory); + int inspectedEntryCount = 0; + bool needsFullScan = false; + while (pendingDirectories.Count > 0) + { + if (ct.IsCancellationRequested) + { + return MigrationTargetPreflightResult.NoTargets; + } - /// - /// Treats all preflight search strategies uniformly and stops on the first decisive result. - /// - internal sealed class ThirdPartyToolMigrationPreflightSearchPipeline : - IThirdPartyToolMigrationPreflightSearchStrategy - { - private readonly IThirdPartyToolMigrationPreflightSearchStrategy[] _strategies; + 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; + } - public ThirdPartyToolMigrationPreflightSearchPipeline( - IThirdPartyToolMigrationPreflightSearchStrategy[] strategies) + 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(strategies != null, "strategies must not be null"); + Debug.Assert(source != null, "source must not be null"); + Debug.Assert(!string.IsNullOrEmpty(extension), "extension must not be null or empty"); - _strategies = strategies; + if (string.Equals(extension, ".cs", StringComparison.OrdinalIgnoreCase)) + { + return InspectCSharpSourceText(source); + } + + if (string.Equals(extension, ".asmdef", StringComparison.OrdinalIgnoreCase)) + { + return InspectAsmdefSourceText(source); + } + + return MigrationTargetPreflightResult.NoTargets; } - public async Task FindMigrationTargetAsync( - string projectRoot, + private static async Task InspectFilesInDirectoryAsync( + string directoryPath, CancellationToken ct) { - Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty"); + Debug.Assert(!string.IsNullOrEmpty(directoryPath), "directoryPath must not be null or empty"); - foreach (IThirdPartyToolMigrationPreflightSearchStrategy strategy in _strategies) + int inspectedEntryCount = 0; + bool needsFullScan = false; + foreach (string filePath in Directory.EnumerateFiles(directoryPath)) { - Debug.Assert(strategy != null, "strategy must not be null"); if (ct.IsCancellationRequested) { return MigrationTargetPreflightResult.NoTargets; } - MigrationTargetPreflightResult result = await strategy.FindMigrationTargetAsync(projectRoot, ct); - if (result != MigrationTargetPreflightResult.NeedsFullScan) + string extension = Path.GetExtension(filePath); + if (!ShouldInspectExtension(extension)) + { + continue; + } + + string source = File.ReadAllText(filePath); + MigrationTargetPreflightResult result = InspectSourceText(source, extension); + if (result == MigrationTargetPreflightResult.HasTargets) { return result; } + + if (result == MigrationTargetPreflightResult.NeedsFullScan) + { + needsFullScan = true; + } + + inspectedEntryCount++; + if (inspectedEntryCount % ThirdPartyToolMigrationFileServiceConstants.PreviewYieldBatchSize == 0) + { + await Task.Yield(); + } } - return MigrationTargetPreflightResult.NeedsFullScan; + return needsFullScan + ? MigrationTargetPreflightResult.NeedsFullScan + : MigrationTargetPreflightResult.NoTargets; } - } - /// - /// Provides shared fixed-string markers used by every migration preflight search strategy. - /// - internal static class ThirdPartyToolMigrationPreflightMarkerSet - { - internal static readonly string[] DirectCSharpCandidateMarkers = + private static bool ShouldInspectExtension(string extension) { - ThirdPartyToolMigrationRuleCatalog.LegacyNamespace, - ThirdPartyToolMigrationRuleCatalog.CurrentNamespace, - ThirdPartyToolMigrationRuleCatalog.CurrentApplicationNamespace, - ThirdPartyToolMigrationRuleCatalog.CurrentDomainNamespace, - ThirdPartyToolMigrationRuleCatalog.CurrentFirstPartyToolsNamespace, - "McpTool", - "CustomToolManager", - ThirdPartyToolMigrationRuleCatalog.LegacyEditorDelayTypeName, - ThirdPartyToolMigrationRuleCatalog.LegacyTimerDelayTypeName, - ThirdPartyToolMigrationRuleCatalog.LegacyMainThreadSwitcherTypeName, - ThirdPartyToolMigrationRuleCatalog.LegacyPlayerLoopTimingTypeName, - ThirdPartyToolMigrationRuleCatalog.LegacyEditorWindowCaptureUtilityTypeName, - "UnityCliLoopToolRegistrar", - "ToolInfo" - }; + return string.Equals(extension, ".cs", StringComparison.OrdinalIgnoreCase) || + string.Equals(extension, ".asmdef", StringComparison.OrdinalIgnoreCase); + } - internal static readonly string[] AsmdefCandidateMarkers = + private static MigrationTargetPreflightResult InspectCSharpSourceText(string source) { - ThirdPartyToolMigrationRuleCatalog.LegacyEditorAssemblyName, - ThirdPartyToolMigrationRuleCatalog.LegacyRuntimeAssemblyName - }; + Debug.Assert(source != null, "source must not be null"); - internal static string[] CreateAllCandidateMarkers() - { - List markers = new List(); - AddMarkers(markers, DirectCSharpCandidateMarkers); - AddMarkers(markers, AsmdefCandidateMarkers); - AddTypeReplacementRuleMarkers( - markers, - ThirdPartyToolMigrationRuleCatalog.ToolContractTypeReplacementRules); - AddTypeReplacementRuleMarkers( - markers, - ThirdPartyToolMigrationRuleCatalog.DomainTypeReplacementRules); - AddTypeReplacementRuleMarkers( - markers, - ThirdPartyToolMigrationRuleCatalog.ApplicationTypeReplacementRules); - AddTypeReplacementRuleMarkers( - markers, - ThirdPartyToolMigrationRuleCatalog.FirstPartyScreenshotTypeReplacementRules); - return markers.ToArray(); + if (!ContainsCSharpCandidateMarker(source)) + { + return MigrationTargetPreflightResult.NoTargets; + } + + if (ThirdPartyToolMigrationFastSourceTargetDetector.ContainsFastCSharpMigrationTarget(source)) + { + return MigrationTargetPreflightResult.HasTargets; + } + + return MigrationTargetPreflightResult.NeedsFullScan; } - private static void AddMarkers(List markers, string[] additions) + private static MigrationTargetPreflightResult InspectAsmdefSourceText(string source) { - Debug.Assert(markers != null, "markers must not be null"); - Debug.Assert(additions != null, "additions must not be null"); + Debug.Assert(source != null, "source must not be null"); - foreach (string marker in additions) + if (!ContainsLegacyAsmdefName(source)) { - AddMarker(markers, marker); + return MigrationTargetPreflightResult.NoTargets; } + + if (ThirdPartyToolMigrationFastSourceTargetDetector.ContainsFastAsmdefMigrationTarget(source)) + { + return MigrationTargetPreflightResult.HasTargets; + } + + return MigrationTargetPreflightResult.NeedsFullScan; + } + + 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 void AddTypeReplacementRuleMarkers( - List markers, + private static bool ContainsAnyTypeReplacementRuleMarker( + string source, ThirdPartyToolMigrationParsingRules.TypeReplacementRule[] rules) { - Debug.Assert(markers != null, "markers must not be null"); + Debug.Assert(source != null, "source must not be null"); Debug.Assert(rules != null, "rules must not be null"); foreach (ThirdPartyToolMigrationParsingRules.TypeReplacementRule rule in rules) { - AddMarker(markers, rule.LegacyName); - AddMarker(markers, rule.CurrentName); + if (source.IndexOf(rule.LegacyName, StringComparison.Ordinal) >= 0 || + source.IndexOf(rule.CurrentName, StringComparison.Ordinal) >= 0) + { + return true; + } } + + return false; } - private static void AddMarker(List markers, string marker) + 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"); - Debug.Assert(!string.IsNullOrEmpty(marker), "marker must not be null or empty"); - if (markers.Contains(marker)) + foreach (string marker in markers) { - return; + if (source.IndexOf(marker, StringComparison.Ordinal) >= 0) + { + return true; + } } - markers.Add(marker); + return false; } } + + internal enum MigrationTargetPreflightResult + { + NoTargets, + HasTargets, + NeedsFullScan + } } From 9c82382373eec406c53f6ea9996d82edad40bb3d Mon Sep 17 00:00:00 2001 From: hatayama Date: Sat, 20 Jun 2026 13:51:40 +0900 Subject: [PATCH 06/16] Make migration action button taller Increase only the custom tool migration action button height so the primary migration action is easier to notice without changing the other setup wizard buttons. --- Packages/src/Editor/Presentation/Setup/SetupWizardWindow.uss | 4 ++++ .../Presentation/Setup/ThirdPartyToolMigrationWizardView.cs | 1 + 2 files changed, 5 insertions(+) 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..468a9188fe 100644 --- a/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardView.cs +++ b/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardView.cs @@ -187,6 +187,7 @@ private static Button CreateMigrateButton(VisualElement content) 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; } From e18d54bb97230d01aed646d744201690f079684b Mon Sep 17 00:00:00 2001 From: hatayama Date: Sat, 20 Jun 2026 13:54:05 +0900 Subject: [PATCH 07/16] Remove unused migration wizard buttons Hide the migration action row before the first check because it cannot be used yet, and remove the footer Close button so the wizard only shows useful in-window actions. --- .../ThirdPartyToolMigrationWizardView.cs | 53 +++++++++---------- .../ThirdPartyToolMigrationWizardWindow.cs | 3 +- 2 files changed, 27 insertions(+), 29 deletions(-) diff --git a/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardView.cs b/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardView.cs index 468a9188fe..2578173f77 100644 --- a/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardView.cs +++ b/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardView.cs @@ -18,31 +18,31 @@ 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; private ThirdPartyToolMigrationWizardView( ScrollView mainScrollView, Label migrationStatusLabel, ProgressBar migrationProgressBar, + VisualElement migrationButtonRow, Button migrateButton, - Button refreshButton, - Button closeButton) + Button refreshButton) { 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"); MainScrollView = mainScrollView; _migrationStatusLabel = migrationStatusLabel; _migrationProgressBar = migrationProgressBar; + _migrationButtonRow = migrationButtonRow; _migrateButton = migrateButton; _refreshButton = refreshButton; - _closeButton = closeButton; } internal ScrollView MainScrollView { get; } @@ -50,13 +50,11 @@ private ThirdPartyToolMigrationWizardView( internal static ThirdPartyToolMigrationWizardView Create( VisualElement root, Action refresh, - Action migrate, - Action close) + Action migrate) { Debug.Assert(root != null, "root must not be null"); Debug.Assert(refresh != null, "refresh must not be null"); Debug.Assert(migrate != null, "migrate must not be null"); - Debug.Assert(close != null, "close must not be null"); string ussPath = $"{UnityCliLoopConstants.PackageAssetPath}/{USS_RELATIVE_PATH}"; StyleSheet styleSheet = AssetDatabase.LoadAssetAtPath(ussPath); @@ -67,17 +65,18 @@ internal static ThirdPartyToolMigrationWizardView Create( VisualElement content = CreateMigrationSection(mainScrollView); Label migrationStatusLabel = CreateStatusLabel(content); ProgressBar migrationProgressBar = CreateProgressBar(content); - Button migrateButton = CreateMigrateButton(content); - (Button refreshButton, Button closeButton) = CreateFooter(mainScrollView); + VisualElement migrationButtonRow = CreateMigrationButtonRow(content); + Button migrateButton = CreateMigrateButton(migrationButtonRow); + Button refreshButton = CreateFooter(mainScrollView); ThirdPartyToolMigrationWizardView view = new ThirdPartyToolMigrationWizardView( mainScrollView, migrationStatusLabel, migrationProgressBar, + migrationButtonRow, migrateButton, - refreshButton, - closeButton); - view.BindEvents(refresh, migrate, close); + refreshButton); + view.BindEvents(refresh, migrate); return view; } @@ -85,39 +84,39 @@ 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); _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); } 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); - _closeButton.SetEnabled(true); } internal void ShowCheckingState(ThirdPartyToolMigrationProgress progress, bool isMigrating) @@ -126,6 +125,7 @@ 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( @@ -133,7 +133,6 @@ internal void ShowCheckingState(ThirdPartyToolMigrationProgress progress, bool i true, true); _refreshButton.SetEnabled(false); - _closeButton.SetEnabled(!isMigrating); } private static ScrollView CreateMainScrollView(VisualElement root) @@ -178,11 +177,17 @@ 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); @@ -192,7 +197,7 @@ private static Button CreateMigrateButton(VisualElement content) return migrateButton; } - private static (Button refreshButton, Button closeButton) CreateFooter(ScrollView mainScrollView) + private static Button CreateFooter(ScrollView mainScrollView) { VisualElement footer = new VisualElement(); footer.AddToClassList("setup-footer"); @@ -208,24 +213,18 @@ private static (Button refreshButton, Button closeButton) CreateFooter(ScrollVie refreshButton.AddToClassList("setup-button--primary"); footerButtonRow.Add(refreshButton); - Button closeButton = new Button(); - closeButton.text = "Close"; - closeButton.AddToClassList("setup-button"); - footerButtonRow.Add(closeButton); - Label reopenHintLabel = new Label( "You can close this wizard and reopen it later from\n" + "Window > Unity CLI Loop > Custom Tool Migration."); reopenHintLabel.AddToClassList("setup-footer__hint-label"); footer.Add(reopenHintLabel); - return (refreshButton, closeButton); + return refreshButton; } - private void BindEvents(Action refresh, Action migrate, Action close) + private void BindEvents(Action refresh, Action migrate) { _refreshButton.clicked += refresh; _migrateButton.clicked += migrate; - _closeButton.clicked += close; } private void UpdateMigrationProgressBar(ThirdPartyToolMigrationProgress progress) diff --git a/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardWindow.cs b/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardWindow.cs index 6c9af6df37..de97805cb6 100644 --- a/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardWindow.cs +++ b/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardWindow.cs @@ -237,8 +237,7 @@ private void CreateGUI() _view = ThirdPartyToolMigrationWizardView.Create( rootVisualElement, RefreshUI, - HandleMigrateThirdPartyTools, - Close); + HandleMigrateThirdPartyTools); _resizer = new ThirdPartyToolMigrationWizardWindowResizer(this, _view.MainScrollView); _resizer.BindSizeUpdates(); From 5c9ddaf6631c2501062fc141ee4251b64848ad14 Mon Sep 17 00:00:00 2001 From: hatayama Date: Sat, 20 Jun 2026 14:04:30 +0900 Subject: [PATCH 08/16] Scan migrations when setup state is missing Treat missing prior setup wizard state as eligible for V3 migration auto-scan so projects without UserSettings can still surface existing custom tool migration targets. --- Assets/Tests/Editor/SetupWizardWindowTests.cs | 5 +++-- Packages/src/Editor/Presentation/Setup/SetupWizardWindow.cs | 6 +++--- 2 files changed, 6 insertions(+), 5 deletions(-) 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/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)) From 58e45b38afad8e340295e4b582f0db0cc2ee751d Mon Sep 17 00:00:00 2001 From: hatayama Date: Sat, 20 Jun 2026 14:09:58 +0900 Subject: [PATCH 09/16] Disable check while migration is available Keep the migration wizard focused on the available Migrate action by disabling the Check button once migration targets are already shown. --- .../Presentation/Setup/ThirdPartyToolMigrationWizardView.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardView.cs b/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardView.cs index 2578173f77..80dfd75bde 100644 --- a/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardView.cs +++ b/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardView.cs @@ -103,7 +103,7 @@ internal void ShowMigrationTargetsState(int fileCount, bool isMigrating) isMigrating, true, true); - _refreshButton.SetEnabled(true); + _refreshButton.SetEnabled(false); } internal void ShowNoMigrationTargetsState(bool isMigrating) From 92bd0e063ae471135a57a5eeab6b4d1977dc6f48 Mon Sep 17 00:00:00 2001 From: hatayama Date: Sat, 20 Jun 2026 14:18:57 +0900 Subject: [PATCH 10/16] Show close after empty migration check Swap the footer action from Check to Close when no migration targets are found so the wizard only offers the useful next action. --- .../ThirdPartyToolMigrationWizardView.cs | 39 ++++++++++++++----- .../ThirdPartyToolMigrationWizardWindow.cs | 3 +- 2 files changed, 32 insertions(+), 10 deletions(-) diff --git a/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardView.cs b/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardView.cs index 80dfd75bde..93b2323590 100644 --- a/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardView.cs +++ b/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardView.cs @@ -21,6 +21,7 @@ internal sealed class ThirdPartyToolMigrationWizardView private readonly VisualElement _migrationButtonRow; private readonly Button _migrateButton; private readonly Button _refreshButton; + private readonly Button _closeButton; private ThirdPartyToolMigrationWizardView( ScrollView mainScrollView, @@ -28,7 +29,8 @@ private ThirdPartyToolMigrationWizardView( ProgressBar migrationProgressBar, VisualElement migrationButtonRow, Button migrateButton, - Button refreshButton) + Button refreshButton, + Button closeButton) { Debug.Assert(mainScrollView != null, "mainScrollView must not be null"); Debug.Assert(migrationStatusLabel != null, "migrationStatusLabel must not be null"); @@ -36,6 +38,7 @@ private ThirdPartyToolMigrationWizardView( 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"); MainScrollView = mainScrollView; _migrationStatusLabel = migrationStatusLabel; @@ -43,6 +46,7 @@ private ThirdPartyToolMigrationWizardView( _migrationButtonRow = migrationButtonRow; _migrateButton = migrateButton; _refreshButton = refreshButton; + _closeButton = closeButton; } internal ScrollView MainScrollView { get; } @@ -50,11 +54,13 @@ private ThirdPartyToolMigrationWizardView( internal static ThirdPartyToolMigrationWizardView Create( VisualElement root, Action refresh, - Action migrate) + Action migrate, + Action close) { Debug.Assert(root != null, "root must not be null"); Debug.Assert(refresh != null, "refresh must not be null"); Debug.Assert(migrate != null, "migrate must not be null"); + Debug.Assert(close != null, "close must not be null"); string ussPath = $"{UnityCliLoopConstants.PackageAssetPath}/{USS_RELATIVE_PATH}"; StyleSheet styleSheet = AssetDatabase.LoadAssetAtPath(ussPath); @@ -67,7 +73,7 @@ internal static ThirdPartyToolMigrationWizardView Create( ProgressBar migrationProgressBar = CreateProgressBar(content); VisualElement migrationButtonRow = CreateMigrationButtonRow(content); Button migrateButton = CreateMigrateButton(migrationButtonRow); - Button refreshButton = CreateFooter(mainScrollView); + (Button refreshButton, Button closeButton) = CreateFooter(mainScrollView); ThirdPartyToolMigrationWizardView view = new ThirdPartyToolMigrationWizardView( mainScrollView, @@ -75,8 +81,9 @@ internal static ThirdPartyToolMigrationWizardView Create( migrationProgressBar, migrationButtonRow, migrateButton, - refreshButton); - view.BindEvents(refresh, migrate); + refreshButton, + closeButton); + view.BindEvents(refresh, migrate, close); return view; } @@ -90,6 +97,8 @@ internal void ShowNotCheckedState(bool isMigrating) isMigrating, false, false); + ViewDataBinder.SetVisible(_refreshButton, true); + ViewDataBinder.SetVisible(_closeButton, false); _refreshButton.SetEnabled(true); } @@ -103,6 +112,8 @@ internal void ShowMigrationTargetsState(int fileCount, bool isMigrating) isMigrating, true, true); + ViewDataBinder.SetVisible(_refreshButton, true); + ViewDataBinder.SetVisible(_closeButton, false); _refreshButton.SetEnabled(false); } @@ -116,7 +127,9 @@ internal void ShowNoMigrationTargetsState(bool isMigrating) isMigrating, false, true); - _refreshButton.SetEnabled(true); + ViewDataBinder.SetVisible(_refreshButton, false); + ViewDataBinder.SetVisible(_closeButton, true); + _closeButton.SetEnabled(true); } internal void ShowCheckingState(ThirdPartyToolMigrationProgress progress, bool isMigrating) @@ -132,6 +145,8 @@ internal void ShowCheckingState(ThirdPartyToolMigrationProgress progress, bool i isMigrating, true, true); + ViewDataBinder.SetVisible(_refreshButton, true); + ViewDataBinder.SetVisible(_closeButton, false); _refreshButton.SetEnabled(false); } @@ -197,7 +212,7 @@ private static Button CreateMigrateButton(VisualElement migrationButtonRow) return migrateButton; } - private static Button CreateFooter(ScrollView mainScrollView) + private static (Button refreshButton, Button closeButton) CreateFooter(ScrollView mainScrollView) { VisualElement footer = new VisualElement(); footer.AddToClassList("setup-footer"); @@ -213,18 +228,24 @@ private static Button CreateFooter(ScrollView mainScrollView) refreshButton.AddToClassList("setup-button--primary"); footerButtonRow.Add(refreshButton); + Button closeButton = new Button(); + closeButton.text = "Close"; + closeButton.AddToClassList("setup-button"); + footerButtonRow.Add(closeButton); + Label reopenHintLabel = new Label( "You can close this wizard and reopen it later from\n" + "Window > Unity CLI Loop > Custom Tool Migration."); reopenHintLabel.AddToClassList("setup-footer__hint-label"); footer.Add(reopenHintLabel); - return refreshButton; + return (refreshButton, closeButton); } - private void BindEvents(Action refresh, Action migrate) + private void BindEvents(Action refresh, Action migrate, Action close) { _refreshButton.clicked += refresh; _migrateButton.clicked += migrate; + _closeButton.clicked += close; } private void UpdateMigrationProgressBar(ThirdPartyToolMigrationProgress progress) diff --git a/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardWindow.cs b/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardWindow.cs index de97805cb6..6c9af6df37 100644 --- a/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardWindow.cs +++ b/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardWindow.cs @@ -237,7 +237,8 @@ private void CreateGUI() _view = ThirdPartyToolMigrationWizardView.Create( rootVisualElement, RefreshUI, - HandleMigrateThirdPartyTools); + HandleMigrateThirdPartyTools, + Close); _resizer = new ThirdPartyToolMigrationWizardWindowResizer(this, _view.MainScrollView); _resizer.BindSizeUpdates(); From 842fac41d1bbcc3763cb852d25d4735e132285a0 Mon Sep 17 00:00:00 2001 From: hatayama Date: Sat, 20 Jun 2026 14:29:55 +0900 Subject: [PATCH 11/16] Track asmdef meta files in migration cache Include asmdef meta state in migration plan fingerprints so cached previews are discarded when asmref GUID resolution changes before apply. --- ...ThirdPartyToolMigrationFileServiceTests.cs | 31 +++++++++++++++++++ .../ThirdPartyToolMigrationModels.cs | 24 +++++++++++++- 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/Assets/Tests/Editor/ThirdPartyToolMigrationFileServiceTests.cs b/Assets/Tests/Editor/ThirdPartyToolMigrationFileServiceTests.cs index fbcadb9626..fbaa43b007 100644 --- a/Assets/Tests/Editor/ThirdPartyToolMigrationFileServiceTests.cs +++ b/Assets/Tests/Editor/ThirdPartyToolMigrationFileServiceTests.cs @@ -125,6 +125,37 @@ public void MigrationProjectFingerprint_WhenCandidateFileIsAdded_DoesNotMatch() } } + [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 PreflightScanner_WhenSourceHasNoMigrationMarkers_ReturnsNoTargets() { diff --git a/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationModels.cs b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationModels.cs index 2cff59cc57..cd4232ae25 100644 --- a/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationModels.cs +++ b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationModels.cs @@ -79,6 +79,11 @@ public static MigrationProjectFingerprint CaptureFromInventory(ProjectFileInvent 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]; @@ -125,18 +130,24 @@ private bool MatchesFingerprint(MigrationProjectFingerprint other) /// internal readonly struct MigrationFileFingerprint { - private MigrationFileFingerprint(string filePath, long length, long lastWriteTimeUtcTicks) + private MigrationFileFingerprint( + string filePath, + bool exists, + long length, + long lastWriteTimeUtcTicks) { 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; } public string FilePath { get; } + public bool Exists { get; } public long Length { get; } public long LastWriteTimeUtcTicks { get; } @@ -145,8 +156,18 @@ 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); + } + return new MigrationFileFingerprint( filePath, + true, fileInfo.Length, fileInfo.LastWriteTimeUtc.Ticks); } @@ -154,6 +175,7 @@ public static MigrationFileFingerprint Capture(string filePath) public bool HasSameValuesAs(MigrationFileFingerprint other) { return string.Equals(FilePath, other.FilePath, StringComparison.Ordinal) && + Exists == other.Exists && Length == other.Length && LastWriteTimeUtcTicks == other.LastWriteTimeUtcTicks; } From 9c805a3ef706011aa1c5e8890655c4a4ec4d2b6b Mon Sep 17 00:00:00 2001 From: hatayama Date: Sat, 20 Jun 2026 14:42:28 +0900 Subject: [PATCH 12/16] Capture migration fingerprints before reading sources Record migration plan fingerprints before preview source reads so cached plans are rejected when files change during plan creation. --- ...ThirdPartyToolMigrationFileServiceTests.cs | 81 +++++++++++++++++++ .../ThirdPartyToolMigrationPlanBuilder.cs | 8 +- 2 files changed, 87 insertions(+), 2 deletions(-) diff --git a/Assets/Tests/Editor/ThirdPartyToolMigrationFileServiceTests.cs b/Assets/Tests/Editor/ThirdPartyToolMigrationFileServiceTests.cs index fbaa43b007..434d68d530 100644 --- a/Assets/Tests/Editor/ThirdPartyToolMigrationFileServiceTests.cs +++ b/Assets/Tests/Editor/ThirdPartyToolMigrationFileServiceTests.cs @@ -4226,6 +4226,61 @@ public sealed class HelloResponse : BaseToolResponse } } + [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() { @@ -4897,5 +4952,31 @@ public void Report(ThirdPartyToolMigrationProgress value) } } + 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/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationPlanBuilder.cs b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationPlanBuilder.cs index 7b6ff04f07..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, @@ -172,7 +174,7 @@ internal static MigrationPlan Create(string projectRoot) return new MigrationPlan( changes, replacementCount, - MigrationProjectFingerprint.CaptureFromInventory(inventory)); + projectFingerprint); } internal static async Task CreateAsync( @@ -194,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( @@ -367,7 +371,7 @@ internal static async Task CreateAsync( return new MigrationPlan( changes, replacementCount, - MigrationProjectFingerprint.CaptureFromInventory(inventory)); + projectFingerprint); } From 3db4e9a27ae7890a869d3f1308e8161c94078b41 Mon Sep 17 00:00:00 2001 From: hatayama Date: Sat, 20 Jun 2026 14:54:26 +0900 Subject: [PATCH 13/16] Defer malformed asmdefs to the full migration scan Make preflight asmdef checks reuse the migration JSON reader so malformed legacy asmdefs do not abort startup detection before the full scanner can inspect the project. --- ...ThirdPartyToolMigrationFileServiceTests.cs | 12 ++++ ...ThirdPartyToolMigrationPreflightScanner.cs | 63 +++++++++++++++++-- 2 files changed, 71 insertions(+), 4 deletions(-) diff --git a/Assets/Tests/Editor/ThirdPartyToolMigrationFileServiceTests.cs b/Assets/Tests/Editor/ThirdPartyToolMigrationFileServiceTests.cs index 434d68d530..d0d74161eb 100644 --- a/Assets/Tests/Editor/ThirdPartyToolMigrationFileServiceTests.cs +++ b/Assets/Tests/Editor/ThirdPartyToolMigrationFileServiceTests.cs @@ -204,6 +204,18 @@ public void PreflightScanner_WhenLegacyAsmdefReferenceExists_ReturnsHasTargets() 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() { diff --git a/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationPreflightScanner.cs b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationPreflightScanner.cs index 9f24566f92..162862d0ff 100644 --- a/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationPreflightScanner.cs +++ b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationPreflightScanner.cs @@ -5,6 +5,8 @@ using System.Threading; using System.Threading.Tasks; +using Newtonsoft.Json.Linq; + namespace io.github.hatayama.UnityCliLoop.Infrastructure { /// @@ -12,6 +14,8 @@ namespace io.github.hatayama.UnityCliLoop.Infrastructure /// internal static class ThirdPartyToolMigrationPreflightScanner { + private const string SourceTextPath = ""; + private static readonly string[] DirectCSharpCandidateMarkers = { ThirdPartyToolMigrationRuleCatalog.LegacyNamespace, @@ -100,6 +104,18 @@ internal static MigrationTargetPreflightResult InspectSourceText( 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); @@ -107,7 +123,7 @@ internal static MigrationTargetPreflightResult InspectSourceText( if (string.Equals(extension, ".asmdef", StringComparison.OrdinalIgnoreCase)) { - return InspectAsmdefSourceText(source); + return InspectAsmdefFileSourceText(source, filePath); } return MigrationTargetPreflightResult.NoTargets; @@ -135,7 +151,7 @@ private static async Task InspectFilesInDirector } string source = File.ReadAllText(filePath); - MigrationTargetPreflightResult result = InspectSourceText(source, extension); + MigrationTargetPreflightResult result = InspectFileSourceText(source, extension, filePath); if (result == MigrationTargetPreflightResult.HasTargets) { return result; @@ -181,16 +197,27 @@ private static MigrationTargetPreflightResult InspectCSharpSourceText(string sou return MigrationTargetPreflightResult.NeedsFullScan; } - private static MigrationTargetPreflightResult InspectAsmdefSourceText(string source) + 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 (ThirdPartyToolMigrationFastSourceTargetDetector.ContainsFastAsmdefMigrationTarget(source)) + if (!ThirdPartyToolMigrationAssemblyReferenceResolver.TryReadJsonObjectForMigration( + filePath, + _ => source, + out JObject asmdef)) + { + return MigrationTargetPreflightResult.NeedsFullScan; + } + + if (ContainsLegacyAsmdefReference(asmdef)) { return MigrationTargetPreflightResult.HasTargets; } @@ -198,6 +225,34 @@ private static MigrationTargetPreflightResult InspectAsmdefSourceText(string sou 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"); From c0dff6dc292a71f2f103d0715776eabeccf54a8e Mon Sep 17 00:00:00 2001 From: hatayama Date: Sat, 20 Jun 2026 15:04:05 +0900 Subject: [PATCH 14/16] Hash migration fingerprint contents Include candidate file content hashes in migration plan fingerprints so cached plans are rejected even when same-size edits preserve timestamps. --- ...ThirdPartyToolMigrationFileServiceTests.cs | 32 ++++++++++++++ .../ThirdPartyToolMigrationModels.cs | 42 +++++++++++++++++-- 2 files changed, 71 insertions(+), 3 deletions(-) diff --git a/Assets/Tests/Editor/ThirdPartyToolMigrationFileServiceTests.cs b/Assets/Tests/Editor/ThirdPartyToolMigrationFileServiceTests.cs index d0d74161eb..2dae47cc80 100644 --- a/Assets/Tests/Editor/ThirdPartyToolMigrationFileServiceTests.cs +++ b/Assets/Tests/Editor/ThirdPartyToolMigrationFileServiceTests.cs @@ -96,6 +96,38 @@ public void MigrationProjectFingerprint_WhenCandidateFileChanges_DoesNotMatch() } } + [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() { diff --git a/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationModels.cs b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationModels.cs index cd4232ae25..01a93a3340 100644 --- a/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationModels.cs +++ b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationModels.cs @@ -130,11 +130,16 @@ private bool MatchesFingerprint(MigrationProjectFingerprint other) /// 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) + 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"); @@ -144,12 +149,14 @@ private MigrationFileFingerprint( 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) { @@ -162,6 +169,7 @@ public static MigrationFileFingerprint Capture(string filePath) filePath, false, 0, + 0, 0); } @@ -169,7 +177,8 @@ public static MigrationFileFingerprint Capture(string filePath) filePath, true, fileInfo.Length, - fileInfo.LastWriteTimeUtc.Ticks); + fileInfo.LastWriteTimeUtc.Ticks, + CaptureContentHash(filePath)); } public bool HasSameValuesAs(MigrationFileFingerprint other) @@ -177,7 +186,34 @@ public bool HasSameValuesAs(MigrationFileFingerprint other) return string.Equals(FilePath, other.FilePath, StringComparison.Ordinal) && Exists == other.Exists && Length == other.Length && - LastWriteTimeUtcTicks == other.LastWriteTimeUtcTicks; + LastWriteTimeUtcTicks == other.LastWriteTimeUtcTicks && + ContentHash == other.ContentHash; + } + + private static ulong CaptureContentHash(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; + } + } + } } } From 63a1ed1376a412a06723fa12b037cbbb4189e9cd Mon Sep 17 00:00:00 2001 From: hatayama Date: Sat, 20 Jun 2026 15:23:36 +0900 Subject: [PATCH 15/16] Skip unreadable files in migration fingerprints --- ...ThirdPartyToolMigrationFileServiceTests.cs | 32 +++++++++++++++++++ .../ThirdPartyToolMigrationModels.cs | 22 +++++++++++++ 2 files changed, 54 insertions(+) diff --git a/Assets/Tests/Editor/ThirdPartyToolMigrationFileServiceTests.cs b/Assets/Tests/Editor/ThirdPartyToolMigrationFileServiceTests.cs index 2dae47cc80..57f8560d29 100644 --- a/Assets/Tests/Editor/ThirdPartyToolMigrationFileServiceTests.cs +++ b/Assets/Tests/Editor/ThirdPartyToolMigrationFileServiceTests.cs @@ -188,6 +188,38 @@ public void MigrationProjectFingerprint_WhenAsmdefMetaChanges_DoesNotMatch() } } + [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() { diff --git a/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationModels.cs b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationModels.cs index 01a93a3340..ac636a69e5 100644 --- a/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationModels.cs +++ b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationModels.cs @@ -194,6 +194,20 @@ 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); @@ -215,6 +229,14 @@ private static ulong CaptureContentHash(string filePath) } } } + + 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 From ad9eb43724516c603d0413795c974045b145cf29 Mon Sep 17 00:00:00 2001 From: hatayama Date: Sat, 20 Jun 2026 17:28:54 +0900 Subject: [PATCH 16/16] Handle migration auto-scan failures safely Keep automatic migration scans from leaking session state when preflight work fails, and defer unreadable preflight files to the full scanner so locked project files do not break migration checks. --- ...ThirdPartyToolMigrationFileServiceTests.cs | 29 +++++++ ...hirdPartyToolMigrationWizardWindowTests.cs | 48 +++++++++++ ...ThirdPartyToolMigrationPreflightScanner.cs | 32 +++++++- .../ThirdPartyToolMigrationWizardWindow.cs | 80 +++++++++++++++++-- 4 files changed, 181 insertions(+), 8 deletions(-) diff --git a/Assets/Tests/Editor/ThirdPartyToolMigrationFileServiceTests.cs b/Assets/Tests/Editor/ThirdPartyToolMigrationFileServiceTests.cs index 57f8560d29..8fddb712af 100644 --- a/Assets/Tests/Editor/ThirdPartyToolMigrationFileServiceTests.cs +++ b/Assets/Tests/Editor/ThirdPartyToolMigrationFileServiceTests.cs @@ -309,6 +309,35 @@ await ThirdPartyToolMigrationPreflightScanner.FindMigrationTargetAsync( } } + [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() { diff --git a/Assets/Tests/Editor/ThirdPartyToolMigrationWizardWindowTests.cs b/Assets/Tests/Editor/ThirdPartyToolMigrationWizardWindowTests.cs index 3d1f82c0fd..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; @@ -45,6 +48,51 @@ public void ShouldOpenWindowAfterAutoScan_ReturnsExpectedValue( 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/ThirdPartyToolMigrationPreflightScanner.cs b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationPreflightScanner.cs index 162862d0ff..6ac6875c8b 100644 --- a/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationPreflightScanner.cs +++ b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationPreflightScanner.cs @@ -150,7 +150,13 @@ private static async Task InspectFilesInDirector continue; } - string source = File.ReadAllText(filePath); + (bool wasRead, string source) = TryReadSourceText(filePath); + if (!wasRead) + { + needsFullScan = true; + continue; + } + MigrationTargetPreflightResult result = InspectFileSourceText(source, extension, filePath); if (result == MigrationTargetPreflightResult.HasTargets) { @@ -174,6 +180,30 @@ private static async Task InspectFilesInDirector : 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) || diff --git a/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardWindow.cs b/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardWindow.cs index 6c9af6df37..9165228a25 100644 --- a/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardWindow.cs +++ b/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardWindow.cs @@ -45,24 +45,90 @@ public static void ShowWindow() ShowWindowInternal(false); } - internal static async void ShowWindowForAutoScan() + 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) { - CancellationToken ct = CancellationToken.None; string projectRoot = UnityCliLoopPathResolver.GetProjectRoot(); ThirdPartyToolMigrationUseCase migrationUseCase = ThirdPartyToolMigrationUseCaseRegistry.GetRegisteredUseCase(); - bool hasMigrationTargets = await Task.Run(async () => - await migrationUseCase.HasMigrationTargetsAsync(projectRoot, ct)); - await MainThreadSwitcher.SwitchToMainThread(); - if (!ShouldOpenWindowAfterAutoScan(hasMigrationTargets, ct.IsCancellationRequested)) + return await Task.Run(async () => + await migrationUseCase.HasMigrationTargetsAsync(projectRoot, ct), ct); + } + + private static async Task SwitchToMainThreadForAutoScanAsync(CancellationToken ct) + { + if (ct.IsCancellationRequested) { - GetSessionStateService().ConsumeShouldAutoScanThirdPartyToolMigration(); 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)