From 2822b27b9dd5680e9be7f60647732fdc9bc4bef4 Mon Sep 17 00:00:00 2001 From: Masamichi Hatayama Date: Wed, 22 Jul 2026 23:21:00 +0900 Subject: [PATCH 01/11] feat: Add compile-error-based detection groundwork for third-party tool migration (#1952) --- ...oolMigrationCompileErrorLogMatcherTests.cs | 113 ++++++++++++++++++ ...grationCompileErrorLogMatcherTests.cs.meta | 11 ++ ...rdPartyToolMigrationDetectionRulesTests.cs | 83 +++++++++++++ ...tyToolMigrationDetectionRulesTests.cs.meta | 11 ++ ...artyToolMigrationCompileErrorLogMatcher.cs | 86 +++++++++++++ ...oolMigrationCompileErrorLogMatcher.cs.meta | 11 ++ .../ThirdPartyToolMigrationDetectionRules.cs | 91 ++++++++++++++ 7 files changed, 406 insertions(+) create mode 100644 Assets/Tests/Editor/ThirdPartyToolMigrationCompileErrorLogMatcherTests.cs create mode 100644 Assets/Tests/Editor/ThirdPartyToolMigrationCompileErrorLogMatcherTests.cs.meta create mode 100644 Assets/Tests/Editor/ThirdPartyToolMigrationDetectionRulesTests.cs create mode 100644 Assets/Tests/Editor/ThirdPartyToolMigrationDetectionRulesTests.cs.meta create mode 100644 Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationCompileErrorLogMatcher.cs create mode 100644 Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationCompileErrorLogMatcher.cs.meta diff --git a/Assets/Tests/Editor/ThirdPartyToolMigrationCompileErrorLogMatcherTests.cs b/Assets/Tests/Editor/ThirdPartyToolMigrationCompileErrorLogMatcherTests.cs new file mode 100644 index 000000000..9523df623 --- /dev/null +++ b/Assets/Tests/Editor/ThirdPartyToolMigrationCompileErrorLogMatcherTests.cs @@ -0,0 +1,113 @@ +using System.Collections.Generic; + +using NUnit.Framework; + +using io.github.hatayama.UnityCliLoop.Infrastructure; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor +{ + /// + /// Test fixture that verifies compile-error-log entries are matched against V2 legacy API tokens + /// and reduced to a deduplicated set of migration target file paths. + /// + public sealed class ThirdPartyToolMigrationCompileErrorLogMatcherTests + { + [Test] + public void Match_WhenNoEntryContainsLegacyToken_ReturnsEmptyResult() + { + // Verifies that unrelated V3 compile errors produce no matched entries and no target file paths. + List entries = new List + { + new CompileErrorLogEntry( + "Assets/Editor/MyTool.cs(1,1): error CS0103: The name 'undefinedSymbol' does not exist in the current context", + "Assets/Editor/MyTool.cs", + 1) + }; + + ThirdPartyToolMigrationCompileErrorLogMatchResult result = + ThirdPartyToolMigrationCompileErrorLogMatcher.Match(entries); + + Assert.That(result.MatchedEntries, Is.Empty); + Assert.That(result.TargetFilePaths, Is.Empty); + } + + [Test] + public void Match_WhenEntryContainsLegacyToken_ReturnsMatchedEntryAndTargetFilePath() + { + // Verifies that an entry whose message contains a legacy API token is included in both + // the matched-entries list and the target file path list. + List entries = new List + { + new CompileErrorLogEntry( + "Assets/Editor/MyTool.cs(3,25): error CS0234: The type or namespace name 'uLoopMCP' " + + "does not exist in the namespace 'io.github.hatayama' (are you missing an assembly reference?)", + "Assets/Editor/MyTool.cs", + 3) + }; + + ThirdPartyToolMigrationCompileErrorLogMatchResult result = + ThirdPartyToolMigrationCompileErrorLogMatcher.Match(entries); + + Assert.That(result.MatchedEntries.Count, Is.EqualTo(1)); + Assert.That(result.TargetFilePaths, Is.EqualTo(new[] { "Assets/Editor/MyTool.cs" })); + } + + [Test] + public void Match_WhenMultipleEntriesShareTheSameFilePath_DeduplicatesTargetFilePaths() + { + // Verifies that multiple legacy-token-matching errors in the same file collapse to one target path. + List entries = new List + { + new CompileErrorLogEntry( + "Assets/Editor/MyTool.cs(3,25): error CS0234: The type or namespace name 'uLoopMCP' " + + "does not exist in the namespace 'io.github.hatayama' (are you missing an assembly reference?)", + "Assets/Editor/MyTool.cs", + 3), + new CompileErrorLogEntry( + "Assets/Editor/MyTool.cs(9,14): error CS0246: The type or namespace name 'AbstractUnityTool' " + + "could not be found (are you missing a using directive or an assembly reference?)", + "Assets/Editor/MyTool.cs", + 9) + }; + + ThirdPartyToolMigrationCompileErrorLogMatchResult result = + ThirdPartyToolMigrationCompileErrorLogMatcher.Match(entries); + + Assert.That(result.MatchedEntries.Count, Is.EqualTo(2)); + Assert.That(result.TargetFilePaths, Is.EqualTo(new[] { "Assets/Editor/MyTool.cs" })); + } + + [Test] + public void Match_WhenEntriesMixMatchingAndNonMatching_OnlyIncludesMatchingFilePaths() + { + // Verifies that a non-matching entry does not contribute its file path even when it is + // interleaved with matching entries from other files. + List entries = new List + { + new CompileErrorLogEntry( + "Assets/Editor/Unrelated.cs(1,1): error CS0103: The name 'undefinedSymbol' does not exist in the current context", + "Assets/Editor/Unrelated.cs", + 1), + new CompileErrorLogEntry( + "Assets/Editor/LegacyTool.cs(2,6): error CS0246: The type or namespace name 'McpTool' " + + "could not be found (are you missing a using directive or an assembly reference?)", + "Assets/Editor/LegacyTool.cs", + 2) + }; + + ThirdPartyToolMigrationCompileErrorLogMatchResult result = + ThirdPartyToolMigrationCompileErrorLogMatcher.Match(entries); + + Assert.That(result.MatchedEntries.Count, Is.EqualTo(1)); + Assert.That(result.TargetFilePaths, Is.EqualTo(new[] { "Assets/Editor/LegacyTool.cs" })); + } + + [Test] + public void Match_WhenEntriesIsNull_Throws() + { + // Verifies fail-fast behavior when the entry collection itself is missing. + Assert.Throws(() => + ThirdPartyToolMigrationCompileErrorLogMatcher.Match(null)); + } + } +} diff --git a/Assets/Tests/Editor/ThirdPartyToolMigrationCompileErrorLogMatcherTests.cs.meta b/Assets/Tests/Editor/ThirdPartyToolMigrationCompileErrorLogMatcherTests.cs.meta new file mode 100644 index 000000000..42851f407 --- /dev/null +++ b/Assets/Tests/Editor/ThirdPartyToolMigrationCompileErrorLogMatcherTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 48981f198be2842188e338ec04026f34 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/ThirdPartyToolMigrationDetectionRulesTests.cs b/Assets/Tests/Editor/ThirdPartyToolMigrationDetectionRulesTests.cs new file mode 100644 index 000000000..e7b83045b --- /dev/null +++ b/Assets/Tests/Editor/ThirdPartyToolMigrationDetectionRulesTests.cs @@ -0,0 +1,83 @@ +using NUnit.Framework; + +using io.github.hatayama.UnityCliLoop.Infrastructure; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor +{ + /// + /// Test fixture that verifies compile-error-message token matching for V2 legacy API detection. + /// + public sealed class ThirdPartyToolMigrationDetectionRulesTests + { + [Test] + public void ContainsLegacyApiToken_WhenMessageContainsLegacyNamespaceSegment_ReturnsTrue() + { + // Verifies that a CS0234 message shaped like Roslyn's actual segment-only report is detected. + const string message = + "Assets/Editor/MyTool.cs(3,25): error CS0234: The type or namespace name 'uLoopMCP' " + + "does not exist in the namespace 'io.github.hatayama' (are you missing an assembly reference?)"; + + Assert.That(ThirdPartyToolMigrationDetectionRules.ContainsLegacyApiToken(message), Is.True); + } + + [Test] + public void ContainsLegacyApiToken_WhenMessageContainsLegacyAssemblyName_ReturnsTrue() + { + // Verifies that asmdef-reference-shaped errors referencing the legacy assembly name are detected. + const string message = "error: Assembly 'uLoopMCP.Editor' could not be resolved."; + + Assert.That(ThirdPartyToolMigrationDetectionRules.ContainsLegacyApiToken(message), Is.True); + } + + [Test] + public void ContainsLegacyApiToken_WhenMessageContainsRenamedLegacyTypeName_ReturnsTrue() + { + // Verifies that CS0246-shaped errors for a renamed legacy base type are detected without depending on the error code. + const string message = "Assets/Editor/MyTool.cs(5,14): error CS0246: The type or namespace name " + + "'AbstractUnityTool' could not be found (are you missing a using directive or an assembly reference?)"; + + Assert.That(ThirdPartyToolMigrationDetectionRules.ContainsLegacyApiToken(message), Is.True); + } + + [Test] + public void ContainsLegacyApiToken_WhenMessageContainsAttributeSuffixStrippedForm_ReturnsTrue() + { + // Verifies that Roslyn's attribute-usage error, which reports 'McpTool' without the 'Attribute' suffix, is still detected. + const string message = "Assets/Editor/MyTool.cs(2,6): error CS0246: The type or namespace name " + + "'McpTool' could not be found (are you missing a using directive or an assembly reference?)"; + + Assert.That(ThirdPartyToolMigrationDetectionRules.ContainsLegacyApiToken(message), Is.True); + } + + [Test] + public void ContainsLegacyApiToken_WhenTokenIsSubstringOfUnrelatedIdentifier_ReturnsFalse() + { + // Verifies identifier-boundary matching: 'IUnityTool' must not match inside 'IUnityToolbarButton'. + const string message = "Assets/Editor/MyTool.cs(9,10): error CS0246: The type or namespace name " + + "'IUnityToolbarButton' could not be found (are you missing a using directive or an assembly reference?)"; + + Assert.That(ThirdPartyToolMigrationDetectionRules.ContainsLegacyApiToken(message), Is.False); + } + + [Test] + public void ContainsLegacyApiToken_WhenMessageIsUnrelatedV3CompileError_ReturnsFalse() + { + // Verifies that ordinary V3 compile errors unrelated to legacy migration do not match. + const string message = "Assets/Editor/MyTool.cs(1,1): error CS0103: The name 'undefinedSymbol' " + + "does not exist in the current context"; + + Assert.That(ThirdPartyToolMigrationDetectionRules.ContainsLegacyApiToken(message), Is.False); + } + + [Test] + public void ContainsLegacyApiToken_WhenMessageReferencesTypeSharedByLegacyAndCurrentNames_ReturnsFalse() + { + // Verifies that names unchanged between V2 and V3 (e.g. ServiceResult) are excluded from the token set, + // since matching them would false-positive on unrelated V3-only compile errors. + const string message = "Assets/Editor/MyTool.cs(4,9): error CS0246: The type or namespace name " + + "'ServiceResult' could not be found (are you missing a using directive or an assembly reference?)"; + + Assert.That(ThirdPartyToolMigrationDetectionRules.ContainsLegacyApiToken(message), Is.False); + } + } +} diff --git a/Assets/Tests/Editor/ThirdPartyToolMigrationDetectionRulesTests.cs.meta b/Assets/Tests/Editor/ThirdPartyToolMigrationDetectionRulesTests.cs.meta new file mode 100644 index 000000000..74100b241 --- /dev/null +++ b/Assets/Tests/Editor/ThirdPartyToolMigrationDetectionRulesTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 1237101791612415e9fed477a7e29353 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationCompileErrorLogMatcher.cs b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationCompileErrorLogMatcher.cs new file mode 100644 index 000000000..f0c61e1ae --- /dev/null +++ b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationCompileErrorLogMatcher.cs @@ -0,0 +1,86 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; + +namespace io.github.hatayama.UnityCliLoop.Infrastructure +{ + /// + /// A single compile-error console log entry, already carrying the file path and line number the + /// caller resolved from the raw log (see ThirdPartyToolMigrationCompileErrorLogMatcher for why the + /// matcher itself does not parse these out of the message text). + /// + internal readonly struct CompileErrorLogEntry + { + public CompileErrorLogEntry(string message, string filePath, int lineNumber) + { + Debug.Assert(!string.IsNullOrEmpty(message), "message must not be null or empty"); + Debug.Assert(!string.IsNullOrEmpty(filePath), "filePath must not be null or empty"); + Debug.Assert(lineNumber >= 0, "lineNumber must not be negative"); + + Message = message ?? throw new ArgumentNullException(nameof(message)); + FilePath = filePath ?? throw new ArgumentNullException(nameof(filePath)); + LineNumber = lineNumber; + } + + public string Message { get; } + public string FilePath { get; } + public int LineNumber { get; } + } + + /// + /// The result of matching compile-error log entries against V2 legacy API tokens: the matched + /// entries themselves, plus the deduplicated set of file paths they point to. + /// + internal readonly struct ThirdPartyToolMigrationCompileErrorLogMatchResult + { + public ThirdPartyToolMigrationCompileErrorLogMatchResult( + List matchedEntries, + List targetFilePaths) + { + Debug.Assert(matchedEntries != null, "matchedEntries must not be null"); + Debug.Assert(targetFilePaths != null, "targetFilePaths must not be null"); + + MatchedEntries = matchedEntries ?? throw new ArgumentNullException(nameof(matchedEntries)); + TargetFilePaths = targetFilePaths ?? throw new ArgumentNullException(nameof(targetFilePaths)); + } + + public List MatchedEntries { get; } + public List TargetFilePaths { get; } + } + + /// + /// Matches compile-error log entries against V2 legacy API tokens (see + /// ThirdPartyToolMigrationDetectionRules) to drive the compile-error-driven auto-scan trigger. + /// This is a pure filter/dedup step: parsing file path and line number out of raw console log text + /// is a separate concern owned by the caller wiring this to the real log source. + /// + internal static class ThirdPartyToolMigrationCompileErrorLogMatcher + { + internal static ThirdPartyToolMigrationCompileErrorLogMatchResult Match( + IReadOnlyList entries) + { + Debug.Assert(entries != null, "entries must not be null"); + if (entries == null) + { + throw new ArgumentNullException(nameof(entries)); + } + + List matchedEntries = new List(); + foreach (CompileErrorLogEntry entry in entries) + { + if (ThirdPartyToolMigrationDetectionRules.ContainsLegacyApiToken(entry.Message)) + { + matchedEntries.Add(entry); + } + } + + List targetFilePaths = matchedEntries + .Select(entry => entry.FilePath) + .Distinct(StringComparer.Ordinal) + .ToList(); + + return new ThirdPartyToolMigrationCompileErrorLogMatchResult(matchedEntries, targetFilePaths); + } + } +} diff --git a/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationCompileErrorLogMatcher.cs.meta b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationCompileErrorLogMatcher.cs.meta new file mode 100644 index 000000000..84d2291c5 --- /dev/null +++ b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationCompileErrorLogMatcher.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: d0081bd2244cf4482874425f651a7ef2 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationDetectionRules.cs b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationDetectionRules.cs index 798e97608..488d82d5b 100644 --- a/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationDetectionRules.cs +++ b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationDetectionRules.cs @@ -1,7 +1,10 @@ using System; +using System.Collections.Generic; using System.Diagnostics; using Newtonsoft.Json.Linq; +using TypeReplacementRule = io.github.hatayama.UnityCliLoop.Domain.ThirdPartyToolMigrationParsingRules.TypeReplacementRule; + using static io.github.hatayama.UnityCliLoop.Domain.ThirdPartyToolMigrationParsingRules; using static io.github.hatayama.UnityCliLoop.Domain.ThirdPartyToolMigrationRuleCatalog; @@ -9,6 +12,94 @@ namespace io.github.hatayama.UnityCliLoop.Infrastructure { internal static class ThirdPartyToolMigrationDetectionRules { + private const string AttributeSuffix = "Attribute"; + + /// + /// Compile-error-message tokens that identify V2 legacy API usage. Derived from + /// ThirdPartyToolMigrationRuleCatalog (the single source for legacy/current names) rather than + /// duplicated here, and limited to names that actually changed between V2 and V3 so unrelated + /// V3-only compile errors do not false-positive on names that stayed the same (e.g. ServiceResult). + /// CustomToolManager is intentionally not included: every V2 file that references it also + /// contains a using/qualified reference to the legacy namespace, which already produces a + /// "uLoopMCP" token hit in the same file, so omitting it does not create a per-file detection gap. + /// + internal static readonly string[] LegacyApiTokens = BuildLegacyApiTokens(); + + private static string[] BuildLegacyApiTokens() + { + List tokens = new List + { + LegacyNamespace.Split('.')[^1] + }; + + foreach (TypeReplacementRule rule in ToolContractTypeReplacementRules) + { + if (string.Equals(rule.LegacyName, rule.CurrentName, StringComparison.Ordinal)) + { + continue; + } + + tokens.Add(rule.LegacyName); + + if (rule.LegacyName.EndsWith(AttributeSuffix, StringComparison.Ordinal)) + { + tokens.Add(rule.LegacyName[..^AttributeSuffix.Length]); + } + } + + return tokens.ToArray(); + } + + /// + /// Checks whether a compile-error message body contains any V2 legacy API token at an + /// identifier boundary. This is a fast, non-scanning shortcut for the auto-scan trigger only; + /// false positives are harmless because the assembly-scoped scan that follows is the source of + /// truth and simply finds zero targets when a match turns out to be unrelated. + /// + internal static bool ContainsLegacyApiToken(string message) + { + Debug.Assert(message != null, "message must not be null"); + + foreach (string token in LegacyApiTokens) + { + if (ContainsTokenAtIdentifierBoundary(message, token)) + { + return true; + } + } + + return false; + } + + private static bool ContainsTokenAtIdentifierBoundary(string text, string token) + { + int searchStart = 0; + while (true) + { + int matchIndex = text.IndexOf(token, searchStart, StringComparison.Ordinal); + if (matchIndex < 0) + { + return false; + } + + bool hasLeadingBoundary = matchIndex == 0 || !IsIdentifierCharacter(text[matchIndex - 1]); + int afterMatchIndex = matchIndex + token.Length; + bool hasTrailingBoundary = afterMatchIndex == text.Length || !IsIdentifierCharacter(text[afterMatchIndex]); + + if (hasLeadingBoundary && hasTrailingBoundary) + { + return true; + } + + searchStart = matchIndex + 1; + } + } + + private static bool IsIdentifierCharacter(char character) + { + return char.IsLetterOrDigit(character) || character == '_'; + } + internal static bool ContainsLegacyAsmdefNameReference(string source) { Debug.Assert(source != null, "source must not be null"); From 20452f705bf530b3c5dc27ed7beb05ef5932225d Mon Sep 17 00:00:00 2001 From: Masamichi Hatayama Date: Wed, 22 Jul 2026 23:28:23 +0900 Subject: [PATCH 02/11] feat: Speed up custom-tool migration scans by limiting them to affected assemblies (#1953) --- ...artyToolMigrationScanScopeResolverTests.cs | 147 +++++++++++++ ...oolMigrationScanScopeResolverTests.cs.meta | 11 + ...irdPartyToolMigrationScopedPreviewTests.cs | 196 ++++++++++++++++++ ...rtyToolMigrationScopedPreviewTests.cs.meta | 11 + .../ThirdPartyToolMigrationFileService.cs | 35 ++++ .../ThirdPartyToolMigrationPlanBuilder.cs | 43 ++++ ...dPartyToolMigrationProjectFileInventory.cs | 79 ++++++- ...hirdPartyToolMigrationScanScopeResolver.cs | 93 +++++++++ ...artyToolMigrationScanScopeResolver.cs.meta | 11 + 9 files changed, 615 insertions(+), 11 deletions(-) create mode 100644 Assets/Tests/Editor/ThirdPartyToolMigrationScanScopeResolverTests.cs create mode 100644 Assets/Tests/Editor/ThirdPartyToolMigrationScanScopeResolverTests.cs.meta create mode 100644 Assets/Tests/Editor/ThirdPartyToolMigrationScopedPreviewTests.cs create mode 100644 Assets/Tests/Editor/ThirdPartyToolMigrationScopedPreviewTests.cs.meta create mode 100644 Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationScanScopeResolver.cs create mode 100644 Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationScanScopeResolver.cs.meta diff --git a/Assets/Tests/Editor/ThirdPartyToolMigrationScanScopeResolverTests.cs b/Assets/Tests/Editor/ThirdPartyToolMigrationScanScopeResolverTests.cs new file mode 100644 index 000000000..b051a27fc --- /dev/null +++ b/Assets/Tests/Editor/ThirdPartyToolMigrationScanScopeResolverTests.cs @@ -0,0 +1,147 @@ +using System.Collections.Generic; + +using NUnit.Framework; + +using io.github.hatayama.UnityCliLoop.Infrastructure; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor +{ + /// + /// Test fixture that verifies seed file paths resolve to a deduplicated set of assembly directories + /// (used to scope the migration plan-building scan to only the assemblies that matter), and that + /// seeds with no real assembly directory (implicit assemblies) are reported via a flag instead of + /// being silently dropped as a nonexistent scan directory. + /// + public sealed class ThirdPartyToolMigrationScanScopeResolverTests + { + private const string ProjectRoot = "/Project"; + + [Test] + public void ResolveScopeAssemblyDirectories_WhenSeedFilesAreUnderSameAsmdefDirectory_ReturnsSingleDirectory() + { + // Verifies that multiple seed files under the same asmdef directory collapse to one scope entry. + List seedFilePaths = new List + { + "/Project/Assets/ToolA/Editor/FileOne.cs", + "/Project/Assets/ToolA/Editor/FileTwo.cs" + }; + List asmdefDirectories = new List { "/Project/Assets/ToolA/Editor" }; + List assemblyReferenceDirectories = new List(); + + (List AssemblyDirectories, bool HasImplicitAssemblySeeds) result = + ThirdPartyToolMigrationScanScopeResolver.ResolveScopeAssemblyDirectories( + seedFilePaths, + asmdefDirectories, + assemblyReferenceDirectories, + ProjectRoot); + + Assert.That(result.AssemblyDirectories, Is.EqualTo(new[] { "/Project/Assets/ToolA/Editor" })); + Assert.That(result.HasImplicitAssemblySeeds, Is.False); + } + + [Test] + public void ResolveScopeAssemblyDirectories_WhenSeedFilesAreUnderDifferentAsmdefDirectories_ReturnsBothDirectories() + { + // Verifies that seed files spanning multiple assemblies produce one scope entry per assembly. + List seedFilePaths = new List + { + "/Project/Assets/ToolA/Editor/FileOne.cs", + "/Project/Assets/ToolB/Editor/FileTwo.cs" + }; + List asmdefDirectories = new List + { + "/Project/Assets/ToolA/Editor", + "/Project/Assets/ToolB/Editor" + }; + List assemblyReferenceDirectories = new List(); + + (List AssemblyDirectories, bool HasImplicitAssemblySeeds) result = + ThirdPartyToolMigrationScanScopeResolver.ResolveScopeAssemblyDirectories( + seedFilePaths, + asmdefDirectories, + assemblyReferenceDirectories, + ProjectRoot); + + Assert.That( + result.AssemblyDirectories, + Is.EqualTo(new[] { "/Project/Assets/ToolA/Editor", "/Project/Assets/ToolB/Editor" })); + Assert.That(result.HasImplicitAssemblySeeds, Is.False); + } + + [Test] + public void ResolveScopeAssemblyDirectories_WhenSeedFileHasNoAsmdef_SetsImplicitAssemblySeedsFlagInsteadOfAScopeDirectory() + { + // Verifies that a seed file outside any asmdef directory resolves to the synthetic implicit + // assembly marker (not a real directory on disk), so it must be reported via the + // HasImplicitAssemblySeeds flag instead of being added to AssemblyDirectories — adding the + // synthetic marker there would make the scoped scan silently skip it as "directory not found" + // instead of falling back to a full scan. + List seedFilePaths = new List { "/Project/Assets/Editor/LooseFile.cs" }; + List asmdefDirectories = new List(); + List assemblyReferenceDirectories = new List(); + + (List AssemblyDirectories, bool HasImplicitAssemblySeeds) result = + ThirdPartyToolMigrationScanScopeResolver.ResolveScopeAssemblyDirectories( + seedFilePaths, + asmdefDirectories, + assemblyReferenceDirectories, + ProjectRoot); + + Assert.That(result.AssemblyDirectories, Is.Empty); + Assert.That(result.HasImplicitAssemblySeeds, Is.True); + } + + [Test] + public void ResolveScopeAssemblyDirectories_WhenSeedFilesMixRealAsmdefAndImplicit_ReturnsRealDirectoryAndSetsFlag() + { + // Verifies that a mix of a real-asmdef seed and an implicit-assembly seed still reports the + // real assembly directory while also setting HasImplicitAssemblySeeds, so a caller knows the + // returned directory list alone is not a complete/safe scope. + List seedFilePaths = new List + { + "/Project/Assets/ToolA/Editor/FileOne.cs", + "/Project/Assets/Editor/LooseFile.cs" + }; + List asmdefDirectories = new List { "/Project/Assets/ToolA/Editor" }; + List assemblyReferenceDirectories = new List(); + + (List AssemblyDirectories, bool HasImplicitAssemblySeeds) result = + ThirdPartyToolMigrationScanScopeResolver.ResolveScopeAssemblyDirectories( + seedFilePaths, + asmdefDirectories, + assemblyReferenceDirectories, + ProjectRoot); + + Assert.That(result.AssemblyDirectories, Is.EqualTo(new[] { "/Project/Assets/ToolA/Editor" })); + Assert.That(result.HasImplicitAssemblySeeds, Is.True); + } + + [Test] + public void ResolveScopeAssemblyDirectories_WhenSeedFilePathsIsEmpty_ReturnsEmptyListAndNoImplicitFlag() + { + // Verifies that no seed files produce no scope and no implicit-assembly flag, rather than + // accidentally scoping to the whole project or forcing an unnecessary fallback. + (List AssemblyDirectories, bool HasImplicitAssemblySeeds) result = + ThirdPartyToolMigrationScanScopeResolver.ResolveScopeAssemblyDirectories( + new List(), + new List(), + new List(), + ProjectRoot); + + Assert.That(result.AssemblyDirectories, Is.Empty); + Assert.That(result.HasImplicitAssemblySeeds, Is.False); + } + + [Test] + public void ResolveScopeAssemblyDirectories_WhenSeedFilePathsIsNull_Throws() + { + // Verifies fail-fast behavior when the seed file collection itself is missing. + Assert.Throws(() => + ThirdPartyToolMigrationScanScopeResolver.ResolveScopeAssemblyDirectories( + null, + new List(), + new List(), + ProjectRoot)); + } + } +} diff --git a/Assets/Tests/Editor/ThirdPartyToolMigrationScanScopeResolverTests.cs.meta b/Assets/Tests/Editor/ThirdPartyToolMigrationScanScopeResolverTests.cs.meta new file mode 100644 index 000000000..687302d05 --- /dev/null +++ b/Assets/Tests/Editor/ThirdPartyToolMigrationScanScopeResolverTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 7e4e8cf40854c453c87c7bb0fb4d4abb +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/ThirdPartyToolMigrationScopedPreviewTests.cs b/Assets/Tests/Editor/ThirdPartyToolMigrationScopedPreviewTests.cs new file mode 100644 index 000000000..f1b21242f --- /dev/null +++ b/Assets/Tests/Editor/ThirdPartyToolMigrationScopedPreviewTests.cs @@ -0,0 +1,196 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +using NUnit.Framework; + +using io.github.hatayama.UnityCliLoop.Domain; +using io.github.hatayama.UnityCliLoop.Infrastructure; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor +{ + /// + /// Verifies the scope-limited migration inventory/plan/preview path used by compile-error-driven + /// detection, which must only scan the assemblies containing matched files instead of the whole project. + /// + public sealed class ThirdPartyToolMigrationScopedPreviewTests + { + [Test] + public async Task CreateFromDirectoriesAsync_WhenGivenOneOfTwoToolDirectories_OnlyCollectsFilesUnderThatDirectory() + { + // Verifies that scoped inventory creation ignores files outside the given scope directories. + string projectRoot = CreateProjectRoot(); + try + { + string scopedToolDirectory = Path.Combine(projectRoot, "Assets", "ScopedTool"); + string otherToolDirectory = Path.Combine(projectRoot, "Assets", "OtherTool"); + Directory.CreateDirectory(scopedToolDirectory); + Directory.CreateDirectory(otherToolDirectory); + string scopedFilePath = Path.Combine(scopedToolDirectory, "ScopedFile.cs"); + string otherFilePath = Path.Combine(otherToolDirectory, "OtherFile.cs"); + File.WriteAllText(scopedFilePath, "public sealed class ScopedFile {}"); + File.WriteAllText(otherFilePath, "public sealed class OtherFile {}"); + + ProjectFileInventory inventory = await ProjectFileInventory.CreateFromDirectoriesAsync( + new List { scopedToolDirectory }, + projectRoot, + new Progress(), + CancellationToken.None); + + Assert.That(inventory.CSharpFilePaths, Does.Contain(scopedFilePath)); + Assert.That(inventory.CSharpFilePaths, Has.No.Member(otherFilePath)); + } + finally + { + Directory.Delete(projectRoot, recursive: true); + } + } + + [Test] + public async Task CreateFromDirectoriesAsync_WhenGivenMultipleScopeDirectories_MergesResultsFromBoth() + { + // Verifies that scoped inventory creation merges files from every given scope directory. + string projectRoot = CreateProjectRoot(); + try + { + string toolADirectory = Path.Combine(projectRoot, "Assets", "ToolA"); + string toolBDirectory = Path.Combine(projectRoot, "Assets", "ToolB"); + Directory.CreateDirectory(toolADirectory); + Directory.CreateDirectory(toolBDirectory); + string toolAFilePath = Path.Combine(toolADirectory, "ToolAFile.cs"); + string toolBFilePath = Path.Combine(toolBDirectory, "ToolBFile.cs"); + File.WriteAllText(toolAFilePath, "public sealed class ToolAFile {}"); + File.WriteAllText(toolBFilePath, "public sealed class ToolBFile {}"); + + ProjectFileInventory inventory = await ProjectFileInventory.CreateFromDirectoriesAsync( + new List { toolADirectory, toolBDirectory }, + projectRoot, + new Progress(), + CancellationToken.None); + + Assert.That(inventory.CSharpFilePaths, Does.Contain(toolAFilePath)); + Assert.That(inventory.CSharpFilePaths, Does.Contain(toolBFilePath)); + } + finally + { + Directory.Delete(projectRoot, recursive: true); + } + } + + [Test] + public async Task CreateFromDirectoriesAsync_WhenScopeDirectoryDoesNotExist_SkipsItWithoutThrowing() + { + // Verifies that a stale/removed scope directory is skipped rather than causing a scan failure. + string projectRoot = CreateProjectRoot(); + try + { + string missingDirectory = Path.Combine(projectRoot, "Assets", "MissingTool"); + + ProjectFileInventory inventory = await ProjectFileInventory.CreateFromDirectoriesAsync( + new List { missingDirectory }, + projectRoot, + new Progress(), + CancellationToken.None); + + Assert.That(inventory.CSharpFilePaths, Is.Empty); + } + finally + { + Directory.Delete(projectRoot, recursive: true); + } + } + + [Test] + public async Task PreviewMigrationInScopeAsync_WhenLegacyToolIsOutsideScope_DoesNotReportItAsATarget() + { + // Verifies that PreviewMigrationInScopeAsync only inspects files under the given scope + // directories, so a legacy tool outside the scope is invisible to the scoped preview. + string projectRoot = CreateProjectRoot(); + try + { + string scopedToolDirectory = Path.Combine(projectRoot, "Assets", "ScopedTool"); + string outOfScopeToolDirectory = Path.Combine(projectRoot, "Assets", "OutOfScopeTool"); + Directory.CreateDirectory(scopedToolDirectory); + Directory.CreateDirectory(outOfScopeToolDirectory); + File.WriteAllText( + Path.Combine(scopedToolDirectory, "InScopeTool.cs"), + "public sealed class InScopeTool {}"); + File.WriteAllText( + Path.Combine(outOfScopeToolDirectory, "OutOfScopeTool.cs"), + LegacyToolSource); + + ThirdPartyToolMigrationFileService service = new(); + ThirdPartyToolMigrationPreview preview = await service.PreviewMigrationInScopeAsync( + projectRoot, + new List { scopedToolDirectory }, + new Progress(), + CancellationToken.None); + + Assert.That(preview.HasTargets, Is.False); + } + finally + { + Directory.Delete(projectRoot, recursive: true); + } + } + + [Test] + public async Task PreviewMigrationInScopeAsync_WhenLegacyToolIsInsideScope_ReportsItAsATarget() + { + // Verifies that PreviewMigrationInScopeAsync detects and reports a legacy tool file that + // is under one of the given scope directories. + string projectRoot = CreateProjectRoot(); + try + { + string scopedToolDirectory = Path.Combine(projectRoot, "Assets", "ScopedTool"); + Directory.CreateDirectory(scopedToolDirectory); + string legacyToolPath = Path.Combine(scopedToolDirectory, "LegacyTool.cs"); + File.WriteAllText(legacyToolPath, LegacyToolSource); + + ThirdPartyToolMigrationFileService service = new(); + ThirdPartyToolMigrationPreview preview = await service.PreviewMigrationInScopeAsync( + projectRoot, + new List { scopedToolDirectory }, + new Progress(), + CancellationToken.None); + + Assert.That(preview.HasTargets, Is.True); + Assert.That(preview.FilePaths, Does.Contain(legacyToolPath)); + } + finally + { + Directory.Delete(projectRoot, recursive: true); + } + } + + private const string LegacyToolSource = @"using io.github.hatayama.uLoopMCP; + +[McpTool] +public sealed class LegacyTool : AbstractUnityTool +{ +} + +public sealed class LegacySchema : BaseToolSchema +{ +} + +public sealed class LegacyResponse : BaseToolResponse +{ +}"; + + private static string CreateProjectRoot() + { + string projectRoot = Path.Combine( + Path.GetTempPath(), + "UnityCliLoopTests", + "ThirdPartyToolMigrationScopedPreview", + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(projectRoot); + Directory.CreateDirectory(Path.Combine(projectRoot, "ProjectSettings")); + Directory.CreateDirectory(Path.Combine(projectRoot, "Assets")); + return projectRoot; + } + } +} diff --git a/Assets/Tests/Editor/ThirdPartyToolMigrationScopedPreviewTests.cs.meta b/Assets/Tests/Editor/ThirdPartyToolMigrationScopedPreviewTests.cs.meta new file mode 100644 index 000000000..8bb45da1a --- /dev/null +++ b/Assets/Tests/Editor/ThirdPartyToolMigrationScopedPreviewTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: b49c81c836f9e496f8862f2336c0cc26 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationFileService.cs b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationFileService.cs index b49581902..ac6924e00 100644 --- a/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationFileService.cs +++ b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationFileService.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Threading; @@ -67,6 +68,40 @@ public async Task PreviewMigrationAsync( return preview; } + /// + /// Builds a preview scanning only the given assembly directories (e.g. the assemblies containing + /// compile-error-matched files), instead of the whole project. Deliberately does not read or + /// write the full-scan preview/plan cache above: a scope-limited plan is only a partial view of + /// the project and must never be mistaken for (or substituted into) the full plan that + /// ApplyMigration relies on. + /// + public async Task PreviewMigrationInScopeAsync( + string projectRoot, + List scopeDirectories, + IProgress progress, + CancellationToken ct) + { + Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty"); + Debug.Assert(scopeDirectories != null, "scopeDirectories must not be null"); + Debug.Assert(progress != null, "progress must not be null"); + + string normalizedProjectRoot = NormalizeProjectRoot(projectRoot); + MigrationPlan plan = await ThirdPartyToolMigrationPlanBuilder.CreateInScopeAsync( + normalizedProjectRoot, + scopeDirectories, + progress, + ct); + if (ct.IsCancellationRequested) + { + return new ThirdPartyToolMigrationPreview(0, 0, Array.Empty()); + } + + return new ThirdPartyToolMigrationPreview( + plan.ChangedFilePaths.Count, + plan.ReplacementCount, + plan.ChangedFilePaths.ToArray()); + } + public async Task HasMigrationTargetsAsync(string projectRoot, CancellationToken ct) { Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty"); diff --git a/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationPlanBuilder.cs b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationPlanBuilder.cs index 96aaecffe..ab46890b9 100644 --- a/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationPlanBuilder.cs +++ b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationPlanBuilder.cs @@ -81,6 +81,49 @@ internal static async Task CreateAsync( return MigrationPlan.Empty; } + return await BuildPlanFromInventoryAsync(projectRoot, inventory, progress, ct); + } + + /// + /// Builds a plan limited to the given assembly directories (e.g. the assemblies containing + /// compile-error-matched files) instead of scanning the whole project. The full-scan entry + /// points above (Create/CreateAsync) are untouched and remain the source of truth for the + /// manual "scan whole project" flow. + /// + internal static async Task CreateInScopeAsync( + string projectRoot, + List scopeDirectories, + IProgress progress, + CancellationToken ct) + { + Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty"); + Debug.Assert(scopeDirectories != null, "scopeDirectories must not be null"); + Debug.Assert(progress != null, "progress must not be null"); + + if (!Directory.Exists(projectRoot)) + { + throw new DirectoryNotFoundException(projectRoot); + } + + ProjectFileInventory inventory = await ProjectFileInventory.CreateFromDirectoriesAsync( + scopeDirectories, + projectRoot, + progress, + ct); + if (ct.IsCancellationRequested) + { + return MigrationPlan.Empty; + } + + return await BuildPlanFromInventoryAsync(projectRoot, inventory, progress, ct); + } + + private static async Task BuildPlanFromInventoryAsync( + string projectRoot, + ProjectFileInventory inventory, + IProgress progress, + CancellationToken ct) + { MigrationProjectFingerprint projectFingerprint = MigrationProjectFingerprint.CaptureFromInventory(inventory); MigrationProgressCounter progressCounter = new(GetPreviewWorkItemCount(inventory), progress); diff --git a/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationProjectFileInventory.cs b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationProjectFileInventory.cs index 8fbf366ec..0a4e1f432 100644 --- a/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationProjectFileInventory.cs +++ b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationProjectFileInventory.cs @@ -61,15 +61,69 @@ public static async Task CreateAsync( List asmdefFilePaths = new(); List asmrefFilePaths = new(); string assetsDirectory = Path.Combine(projectRoot, "Assets"); + progress.Report(new ThirdPartyToolMigrationProgress(0, 0)); if (Directory.Exists(assetsDirectory)) { - await CollectCandidateFilesAsync( + await WalkDirectoryTreeAsync( projectRoot, assetsDirectory, csharpFilePaths, asmdefFilePaths, asmrefFilePaths, progress, + inspectedEntryCount: 0, + ct); + } + + csharpFilePaths.Sort(StringComparer.Ordinal); + asmdefFilePaths.Sort(StringComparer.Ordinal); + asmrefFilePaths.Sort(StringComparer.Ordinal); + return new ProjectFileInventory(csharpFilePaths, asmdefFilePaths, asmrefFilePaths); + } + + /// + /// Builds an inventory limited to the given assembly directories (e.g. the assemblies containing + /// compile-error-matched files), instead of walking the entire Assets tree. Used to speed up + /// migration-plan construction once the seed files that need migration are already known. + /// + internal static async Task CreateFromDirectoriesAsync( + List scopeDirectories, + string projectRoot, + IProgress progress, + CancellationToken ct) + { + Debug.Assert(scopeDirectories != null, "scopeDirectories must not be null"); + Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty"); + Debug.Assert(progress != null, "progress must not be null"); + + List csharpFilePaths = new(); + List asmdefFilePaths = new(); + List asmrefFilePaths = new(); + int inspectedEntryCount = 0; + progress.Report(new ThirdPartyToolMigrationProgress(inspectedEntryCount, 0)); + + foreach (string scopeDirectory in scopeDirectories) + { + if (ct.IsCancellationRequested) + { + break; + } + + if (!Directory.Exists(scopeDirectory)) + { + // A resolved scope directory may no longer exist if files moved/deleted between + // detection and scan; skip it rather than failing the whole scoped scan. + continue; + } + + inspectedEntryCount = await WalkDirectoryTreeAsync( + projectRoot, + scopeDirectory, + csharpFilePaths, + asmdefFilePaths, + asmrefFilePaths, + progress, + inspectedEntryCount, ct); } @@ -129,35 +183,36 @@ private static void CollectCandidateFiles( } } - private static async Task CollectCandidateFilesAsync( + /// + /// Walks a single directory tree, collecting candidate migration files and reporting progress + /// as a running total across possibly-multiple calls (see CreateFromDirectoriesAsync, which walks + /// several scope directories one after another and threads the count between calls). + /// + private static async Task WalkDirectoryTreeAsync( string projectRoot, - string assetsDirectory, + string startDirectory, List csharpFilePaths, List asmdefFilePaths, List asmrefFilePaths, IProgress progress, + int inspectedEntryCount, CancellationToken ct) { Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty"); - Debug.Assert(!string.IsNullOrEmpty(assetsDirectory), "assetsDirectory must not be null or empty"); + Debug.Assert(!string.IsNullOrEmpty(startDirectory), "startDirectory must not be null or empty"); Debug.Assert(csharpFilePaths != null, "csharpFilePaths must not be null"); Debug.Assert(asmdefFilePaths != null, "asmdefFilePaths must not be null"); Debug.Assert(asmrefFilePaths != null, "asmrefFilePaths must not be null"); Debug.Assert(progress != null, "progress must not be null"); Stack pendingDirectories = new(); - pendingDirectories.Push(assetsDirectory); - int inspectedEntryCount = 0; - // Total item count is unknown while the directory tree is still being walked, so only - // the processed count is reported here (see ThirdPartyToolMigrationProgress: a total of - // 0 means "unknown total" rather than "no work"). - progress.Report(new ThirdPartyToolMigrationProgress(inspectedEntryCount, 0)); + pendingDirectories.Push(startDirectory); while (pendingDirectories.Count > 0) { if (ct.IsCancellationRequested) { - return; + return inspectedEntryCount; } string directoryPath = pendingDirectories.Pop(); @@ -188,6 +243,8 @@ private static async Task CollectCandidateFilesAsync( } } } + + return inspectedEntryCount; } private static void AddCandidateFilePath( diff --git a/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationScanScopeResolver.cs b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationScanScopeResolver.cs new file mode 100644 index 000000000..a16c4cd30 --- /dev/null +++ b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationScanScopeResolver.cs @@ -0,0 +1,93 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; + +namespace io.github.hatayama.UnityCliLoop.Infrastructure +{ + /// + /// Resolves the assembly directories that a set of seed files (e.g. compile-error-matched + /// migration target files) belong to, so plan construction can scan only those assemblies + /// instead of the whole project. Reuses + /// ThirdPartyToolMigrationAssemblyReferenceResolver.FindNearestAssemblyDirectory per seed file + /// and deduplicates the results. + /// + internal static class ThirdPartyToolMigrationScanScopeResolver + { + /// + /// A seed file with no real asmdef/asmref ancestor resolves to a synthetic implicit-assembly + /// marker path (see ThirdPartyToolMigrationFileServiceConstants) that never exists on disk. + /// That marker must never be treated as a real scan directory: a scoped file-tree walk skips + /// nonexistent directories, so silently including it would make a legitimate implicit-assembly + /// migration target vanish from the scoped scan instead of falling back to a full scan. + /// HasImplicitAssemblySeeds tells the caller that AssemblyDirectories alone is not a complete, + /// safe scope in that case. + /// + internal static (List AssemblyDirectories, bool HasImplicitAssemblySeeds) ResolveScopeAssemblyDirectories( + List seedFilePaths, + List asmdefDirectories, + List assemblyReferenceDirectories, + string projectRoot) + { + Debug.Assert(seedFilePaths != null, "seedFilePaths must not be null"); + Debug.Assert(asmdefDirectories != null, "asmdefDirectories must not be null"); + Debug.Assert( + assemblyReferenceDirectories != null, + "assemblyReferenceDirectories must not be null"); + Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty"); + + if (seedFilePaths == null) + { + throw new ArgumentNullException(nameof(seedFilePaths)); + } + + HashSet seenAssemblyDirectories = new HashSet(StringComparer.Ordinal); + List scopeAssemblyDirectories = new List(); + bool hasImplicitAssemblySeeds = false; + foreach (string seedFilePath in seedFilePaths) + { + string assemblyDirectory = ThirdPartyToolMigrationAssemblyReferenceResolver.FindNearestAssemblyDirectory( + seedFilePath, + asmdefDirectories, + assemblyReferenceDirectories, + projectRoot); + + if (IsImplicitAssemblyDirectory(assemblyDirectory)) + { + hasImplicitAssemblySeeds = true; + continue; + } + + if (seenAssemblyDirectories.Add(assemblyDirectory)) + { + scopeAssemblyDirectories.Add(assemblyDirectory); + } + } + + return (scopeAssemblyDirectories, hasImplicitAssemblySeeds); + } + + private static bool IsImplicitAssemblyDirectory(string assemblyDirectory) + { + Debug.Assert(!string.IsNullOrEmpty(assemblyDirectory), "assemblyDirectory must not be null or empty"); + + string directoryName = Path.GetFileName(assemblyDirectory); + return string.Equals( + directoryName, + ThirdPartyToolMigrationFileServiceConstants.ImplicitEditorAssemblyDirectoryName, + StringComparison.Ordinal) || + string.Equals( + directoryName, + ThirdPartyToolMigrationFileServiceConstants.ImplicitRuntimeAssemblyDirectoryName, + StringComparison.Ordinal) || + string.Equals( + directoryName, + ThirdPartyToolMigrationFileServiceConstants.ImplicitFirstPassEditorAssemblyDirectoryName, + StringComparison.Ordinal) || + string.Equals( + directoryName, + ThirdPartyToolMigrationFileServiceConstants.ImplicitFirstPassRuntimeAssemblyDirectoryName, + StringComparison.Ordinal); + } + } +} diff --git a/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationScanScopeResolver.cs.meta b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationScanScopeResolver.cs.meta new file mode 100644 index 000000000..45a35de08 --- /dev/null +++ b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationScanScopeResolver.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: dca5e4234a7304252b9b8a264e0e5233 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: From 05b227fc200ecc889e1c4dae973c31b4e26e3e8c Mon Sep 17 00:00:00 2001 From: Masamichi Hatayama Date: Thu, 23 Jul 2026 00:33:41 +0900 Subject: [PATCH 03/11] Speed up custom-tool migration detection using compile errors instead of always scanning (#1954) --- Assets/Tests/Editor/SetupWizardWindowTests.cs | 20 -- ...rtyToolMigrationConsoleErrorParserTests.cs | 144 +++++++++++++ ...olMigrationConsoleErrorParserTests.cs.meta | 11 + ...MigrationLightweightAssemblyWalkerTests.cs | 111 ++++++++++ ...tionLightweightAssemblyWalkerTests.cs.meta | 11 + ...irdPartyToolMigrationScopedPreviewTests.cs | 69 ++++++ ...lMigrationWizardWorkflowControllerTests.cs | 196 ++++++++++++++++++ ...ationWizardWorkflowControllerTests.cs.meta | 11 + .../ThirdPartyToolMigrationUseCase.cs | 22 ++ .../UnityCliLoopApplicationRegistration.cs | 6 + .../UnityCliLoopEditorBootstrapper.cs | 1 + ...artyToolMigrationAutoScanSeedRepository.cs | 13 ++ ...oolMigrationAutoScanSeedRepository.cs.meta | 11 + .../Domain/ThirdPartyToolMigrationData.cs | 22 ++ ...artyToolMigrationAutoScanSeedRepository.cs | 49 +++++ ...oolMigrationAutoScanSeedRepository.cs.meta | 11 + ...irdPartyToolMigrationConsoleErrorParser.cs | 105 ++++++++++ ...rtyToolMigrationConsoleErrorParser.cs.meta | 11 + .../ThirdPartyToolMigrationFileService.cs | 74 +++++++ ...yToolMigrationLightweightAssemblyWalker.cs | 52 +++++ ...MigrationLightweightAssemblyWalker.cs.meta | 11 + .../UnityCLILoop.Infrastructure.asmdef | 3 +- .../Presentation/PresentationEditorStartup.cs | 6 +- .../Setup/SetupWizardStartupFlow.cs | 47 +++-- .../Presentation/Setup/SetupWizardWindow.cs | 6 +- .../ThirdPartyToolMigrationWizardWindow.cs | 33 ++- ...tyToolMigrationWizardWorkflowController.cs | 14 +- 27 files changed, 1024 insertions(+), 46 deletions(-) create mode 100644 Assets/Tests/Editor/ThirdPartyToolMigrationConsoleErrorParserTests.cs create mode 100644 Assets/Tests/Editor/ThirdPartyToolMigrationConsoleErrorParserTests.cs.meta create mode 100644 Assets/Tests/Editor/ThirdPartyToolMigrationLightweightAssemblyWalkerTests.cs create mode 100644 Assets/Tests/Editor/ThirdPartyToolMigrationLightweightAssemblyWalkerTests.cs.meta create mode 100644 Assets/Tests/Editor/ThirdPartyToolMigrationWizardWorkflowControllerTests.cs create mode 100644 Assets/Tests/Editor/ThirdPartyToolMigrationWizardWorkflowControllerTests.cs.meta create mode 100644 Packages/src/Editor/Domain/IThirdPartyToolMigrationAutoScanSeedRepository.cs create mode 100644 Packages/src/Editor/Domain/IThirdPartyToolMigrationAutoScanSeedRepository.cs.meta create mode 100644 Packages/src/Editor/Infrastructure/Settings/UnityCliLoopThirdPartyToolMigrationAutoScanSeedRepository.cs create mode 100644 Packages/src/Editor/Infrastructure/Settings/UnityCliLoopThirdPartyToolMigrationAutoScanSeedRepository.cs.meta create mode 100644 Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationConsoleErrorParser.cs create mode 100644 Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationConsoleErrorParser.cs.meta create mode 100644 Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationLightweightAssemblyWalker.cs create mode 100644 Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationLightweightAssemblyWalker.cs.meta diff --git a/Assets/Tests/Editor/SetupWizardWindowTests.cs b/Assets/Tests/Editor/SetupWizardWindowTests.cs index 9c1d81923..4982286a4 100644 --- a/Assets/Tests/Editor/SetupWizardWindowTests.cs +++ b/Assets/Tests/Editor/SetupWizardWindowTests.cs @@ -26,7 +26,6 @@ public class SetupWizardWindowTests private string _settingsFileContent; private IUnityCliLoopEditorSettingsPort _editorSettingsPort; private UnityCliLoopEditorSettingsRepository _editorSettingsRepository; - private UnityCliLoopSessionFlagsRepository _sessionFlagsRepository; private UnityCliLoopEditorSessionStateSnapshot _originalSessionState; [SetUp] @@ -43,7 +42,6 @@ public void SetUp() DeleteIfExists(SettingsFilePath); _editorSettingsPort = UnityCliLoopEditorSettingsTestFactory.CreatePortWithRepository(out _editorSettingsRepository); - _sessionFlagsRepository = UnityCliLoopEditorSessionStateTestFactory.CreateSessionFlagsRepository(); _originalSessionState = UnityCliLoopEditorSessionStateTestFactory.CaptureSnapshot(); UnityCliLoopEditorSessionStateTestFactory.ClearAll(); SetupWizardWindow.InitializeEditorServices( @@ -183,24 +181,6 @@ public void ShouldAutoScanThirdPartyToolMigration_ReturnsExpectedValue( Assert.That(shouldAutoScan, Is.EqualTo(expected)); } - [Test] - public void MaybeMarkThirdPartyToolMigrationAutoScan_WhenEnabled_SetsSessionFlag() - { - // Verifies that the V2-to-V3 upgrade signal is stored only in the current Editor session. - SetupWizardStartupFlow.MaybeMarkThirdPartyToolMigrationAutoScan(_sessionFlagsRepository, true); - - Assert.That(_sessionFlagsRepository.GetShouldAutoScanThirdPartyToolMigration(), Is.True); - } - - [Test] - public void MaybeMarkThirdPartyToolMigrationAutoScan_WhenDisabled_KeepsSessionFlagFalse() - { - // Verifies that non-upgrade version checks do not request migration scans. - SetupWizardStartupFlow.MaybeMarkThirdPartyToolMigrationAutoScan(_sessionFlagsRepository, false); - - Assert.That(_sessionFlagsRepository.GetShouldAutoScanThirdPartyToolMigration(), Is.False); - } - [Test] public void MaybeRecordLastSeenSetupWizardState_WhenAutoShow_UpdatesStoredState() { diff --git a/Assets/Tests/Editor/ThirdPartyToolMigrationConsoleErrorParserTests.cs b/Assets/Tests/Editor/ThirdPartyToolMigrationConsoleErrorParserTests.cs new file mode 100644 index 000000000..9d2e65b0f --- /dev/null +++ b/Assets/Tests/Editor/ThirdPartyToolMigrationConsoleErrorParserTests.cs @@ -0,0 +1,144 @@ +using System.Collections.Generic; + +using NUnit.Framework; + +using io.github.hatayama.UnityCliLoop.Infrastructure; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor +{ + /// + /// Verifies that raw Unity Console error text is parsed back into structured + /// CompileErrorLogEntry values, including Windows-style paths (backslashes, drive letters, + /// spaces) that must parse correctly regardless of the host OS running the test. + /// + public sealed class ThirdPartyToolMigrationConsoleErrorParserTests + { + private const string ProjectRoot = "/Users/dev/Project"; + + [Test] + public void Parse_WithProjectRelativeUnixPath_ReturnsEntryWithAbsolutePath() + { + // Verifies a standard csc/Roslyn diagnostic line with a project-relative Unix-style path + // is parsed into an absolute file path, line number, and code-prefixed message. + List rawMessages = new List + { + "Assets/Editor/Foo.cs(3,25): error CS0246: The type or namespace name 'Bar' could not be found" + }; + + List entries = ThirdPartyToolMigrationConsoleErrorParser.Parse( + rawMessages, + ProjectRoot); + + Assert.That(entries.Count, Is.EqualTo(1)); + Assert.That(entries[0].FilePath, Is.EqualTo("/Users/dev/Project/Assets/Editor/Foo.cs")); + Assert.That(entries[0].LineNumber, Is.EqualTo(3)); + Assert.That(entries[0].Message, Does.Contain("Bar")); + } + + [Test] + public void Parse_WithBackslashSeparatedRelativePath_NormalizesToForwardSlashAbsolutePath() + { + // Verifies Windows-style backslash separators in a project-relative path are normalized + // to forward slashes and combined with the project root, without relying on the host OS's + // own path-separator handling. + List rawMessages = new List + { + @"Assets\Editor\Foo.cs(3,25): error CS0246: The type or namespace name 'Bar' could not be found" + }; + + List entries = ThirdPartyToolMigrationConsoleErrorParser.Parse( + rawMessages, + ProjectRoot); + + Assert.That(entries.Count, Is.EqualTo(1)); + Assert.That(entries[0].FilePath, Is.EqualTo("/Users/dev/Project/Assets/Editor/Foo.cs")); + } + + [Test] + public void Parse_WithWindowsDriveLetterAbsolutePath_KeepsPathAbsoluteWithoutProjectRoot() + { + // Verifies a Windows drive-letter absolute path is recognized as already-rooted (via an + // explicit drive-letter check, not Path.IsPathRooted, which is host-OS dependent) and is + // not incorrectly combined with the project root. + List rawMessages = new List + { + @"C:\Users\dev\Project\Assets\Editor\Foo.cs(10,1): error CS0246: The type or namespace name 'Bar' could not be found" + }; + + List entries = ThirdPartyToolMigrationConsoleErrorParser.Parse( + rawMessages, + ProjectRoot); + + Assert.That(entries.Count, Is.EqualTo(1)); + Assert.That(entries[0].FilePath, Is.EqualTo("C:/Users/dev/Project/Assets/Editor/Foo.cs")); + } + + [Test] + public void Parse_WithSpacesInPath_ParsesFullPathIncludingSpaces() + { + // Verifies a path containing spaces (e.g. a "My Tools" folder) is parsed in full, not + // truncated at the space. + List rawMessages = new List + { + "Assets/My Tools/Foo.cs(5,10): error CS0246: The type or namespace name 'Bar' could not be found" + }; + + List entries = ThirdPartyToolMigrationConsoleErrorParser.Parse( + rawMessages, + ProjectRoot); + + Assert.That(entries.Count, Is.EqualTo(1)); + Assert.That(entries[0].FilePath, Is.EqualTo("/Users/dev/Project/Assets/My Tools/Foo.cs")); + } + + [Test] + public void Parse_WithWarningSeverity_IsSkipped() + { + // Verifies warning-severity diagnostic lines are not treated as compile errors. + List rawMessages = new List + { + "Assets/Editor/Foo.cs(3,25): warning CS0219: The variable 'x' is never used" + }; + + List entries = ThirdPartyToolMigrationConsoleErrorParser.Parse( + rawMessages, + ProjectRoot); + + Assert.That(entries.Count, Is.EqualTo(0)); + } + + [Test] + public void Parse_WithNonDiagnosticFormatLine_IsSkippedWithoutThrowing() + { + // Verifies plain (non-compiler-diagnostic) console messages are silently ignored. + List rawMessages = new List + { + "This is a regular Debug.LogError message with no file/line prefix" + }; + + List entries = ThirdPartyToolMigrationConsoleErrorParser.Parse( + rawMessages, + ProjectRoot); + + Assert.That(entries.Count, Is.EqualTo(0)); + } + + [Test] + public void Parse_WithMultiLineMessage_UsesOnlyFirstLine() + { + // Verifies only the first physical line of a multi-line console message is parsed for the + // diagnostic file/line prefix. + List rawMessages = new List + { + "Assets/Editor/Foo.cs(3,25): error CS0246: The type or namespace name 'Bar' could not be found\nSome trailing detail line" + }; + + List entries = ThirdPartyToolMigrationConsoleErrorParser.Parse( + rawMessages, + ProjectRoot); + + Assert.That(entries.Count, Is.EqualTo(1)); + Assert.That(entries[0].FilePath, Is.EqualTo("/Users/dev/Project/Assets/Editor/Foo.cs")); + } + } +} diff --git a/Assets/Tests/Editor/ThirdPartyToolMigrationConsoleErrorParserTests.cs.meta b/Assets/Tests/Editor/ThirdPartyToolMigrationConsoleErrorParserTests.cs.meta new file mode 100644 index 000000000..588c2cae7 --- /dev/null +++ b/Assets/Tests/Editor/ThirdPartyToolMigrationConsoleErrorParserTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 4545e0f72b321458895a76907a75784f +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/ThirdPartyToolMigrationLightweightAssemblyWalkerTests.cs b/Assets/Tests/Editor/ThirdPartyToolMigrationLightweightAssemblyWalkerTests.cs new file mode 100644 index 000000000..a3982a08e --- /dev/null +++ b/Assets/Tests/Editor/ThirdPartyToolMigrationLightweightAssemblyWalkerTests.cs @@ -0,0 +1,111 @@ +using System; +using System.Collections.Generic; +using System.IO; + +using NUnit.Framework; + +using io.github.hatayama.UnityCliLoop.Infrastructure; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor +{ + /// + /// Verifies that the lightweight walker discovers .asmdef/.asmref directory structure without + /// touching non-asmdef/asmref files, matching what the full project scan's + /// ThirdPartyToolMigrationAssemblyReferenceResolver expects as input. + /// + public sealed class ThirdPartyToolMigrationLightweightAssemblyWalkerTests + { + [Test] + public void DiscoverAssemblyStructure_WithNestedAsmdef_ReturnsItsDirectory() + { + // Verifies a single nested .asmdef file's directory is discovered. + string projectRoot = CreateProjectRoot(); + try + { + string toolDirectory = Path.Combine(projectRoot, "Assets", "Editor", "Tool"); + Directory.CreateDirectory(toolDirectory); + File.WriteAllText( + Path.Combine(toolDirectory, "Tool.asmdef"), + "{\"name\": \"Tool\"}"); + + (List asmdefDirectories, _) = + ThirdPartyToolMigrationLightweightAssemblyWalker.DiscoverAssemblyStructure(projectRoot); + + Assert.That(asmdefDirectories, Does.Contain(toolDirectory)); + } + finally + { + Directory.Delete(projectRoot, recursive: true); + } + } + + [Test] + public void DiscoverAssemblyStructure_WithAsmrefPointingAtAsmdef_ReturnsAssemblyReferenceDirectory() + { + // Verifies an .asmref file referencing an .asmdef by name is resolved into an + // AssemblyReferenceDirectory pointing at the asmdef's directory. + string projectRoot = CreateProjectRoot(); + try + { + string asmdefDirectory = Path.Combine(projectRoot, "Assets", "Editor", "Tool"); + string asmrefDirectory = Path.Combine(projectRoot, "Assets", "Editor", "Tool", "SubModule"); + Directory.CreateDirectory(asmdefDirectory); + Directory.CreateDirectory(asmrefDirectory); + File.WriteAllText( + Path.Combine(asmdefDirectory, "Tool.asmdef"), + "{\"name\": \"Tool\"}"); + File.WriteAllText( + Path.Combine(asmrefDirectory, "SubModule.asmref"), + "{\"reference\": \"Tool\"}"); + + (_, List assemblyReferenceDirectories) = + ThirdPartyToolMigrationLightweightAssemblyWalker.DiscoverAssemblyStructure(projectRoot); + + Assert.That(assemblyReferenceDirectories.Count, Is.EqualTo(1)); + Assert.That(assemblyReferenceDirectories[0].SourceDirectory, Is.EqualTo(asmrefDirectory)); + Assert.That(assemblyReferenceDirectories[0].TargetAssemblyDirectory, Is.EqualTo(asmdefDirectory)); + } + finally + { + Directory.Delete(projectRoot, recursive: true); + } + } + + [Test] + public void DiscoverAssemblyStructure_WithNoAssetsDirectory_ReturnsEmptyResults() + { + // Verifies a project root with no Assets directory yet does not throw and returns empty + // results instead. + string projectRoot = Path.Combine( + Path.GetTempPath(), + "UnityCliLoopTests", + "ThirdPartyToolMigrationLightweightAssemblyWalker", + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(projectRoot); + try + { + (List asmdefDirectories, List assemblyReferenceDirectories) = + ThirdPartyToolMigrationLightweightAssemblyWalker.DiscoverAssemblyStructure(projectRoot); + + Assert.That(asmdefDirectories.Count, Is.EqualTo(0)); + Assert.That(assemblyReferenceDirectories.Count, Is.EqualTo(0)); + } + finally + { + Directory.Delete(projectRoot, recursive: true); + } + } + + private static string CreateProjectRoot() + { + string projectRoot = Path.Combine( + Path.GetTempPath(), + "UnityCliLoopTests", + "ThirdPartyToolMigrationLightweightAssemblyWalker", + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(projectRoot); + Directory.CreateDirectory(Path.Combine(projectRoot, "Assets")); + return projectRoot; + } + } +} diff --git a/Assets/Tests/Editor/ThirdPartyToolMigrationLightweightAssemblyWalkerTests.cs.meta b/Assets/Tests/Editor/ThirdPartyToolMigrationLightweightAssemblyWalkerTests.cs.meta new file mode 100644 index 000000000..79a9f47ba --- /dev/null +++ b/Assets/Tests/Editor/ThirdPartyToolMigrationLightweightAssemblyWalkerTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 383697e17ad0646eeb7ef3a5bf3de661 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/ThirdPartyToolMigrationScopedPreviewTests.cs b/Assets/Tests/Editor/ThirdPartyToolMigrationScopedPreviewTests.cs index f1b21242f..f6ae16a0b 100644 --- a/Assets/Tests/Editor/ThirdPartyToolMigrationScopedPreviewTests.cs +++ b/Assets/Tests/Editor/ThirdPartyToolMigrationScopedPreviewTests.cs @@ -165,6 +165,75 @@ public async Task PreviewMigrationInScopeAsync_WhenLegacyToolIsInsideScope_Repor } } + [Test] + public async Task PreviewMigrationForSeedFilesAsync_WhenSeedHasNoAsmdefAncestor_FallsBackToFullProjectScan() + { + // Verifies that a seed file with no asmdef/asmref ancestor (an implicit-assembly seed) + // makes PreviewMigrationForSeedFilesAsync fall back to a full-project scan instead of a + // scope-limited one, so the legacy target is still detected. + string projectRoot = CreateProjectRoot(); + try + { + string legacyToolPath = Path.Combine(projectRoot, "Assets", "LegacyTool.cs"); + File.WriteAllText(legacyToolPath, LegacyToolSource); + + ThirdPartyToolMigrationFileService service = new(); + ThirdPartyToolMigrationPreview preview = await service.PreviewMigrationForSeedFilesAsync( + projectRoot, + new List { legacyToolPath }, + new Progress(), + CancellationToken.None); + + Assert.That(preview.HasTargets, Is.True); + Assert.That(preview.FilePaths, Does.Contain(legacyToolPath)); + } + finally + { + Directory.Delete(projectRoot, recursive: true); + } + } + + [Test] + public async Task PreviewMigrationForSeedFilesAsync_WhenSeedHasAnAsmdefAncestor_ScansOnlyThatAssembly() + { + // Verifies that a seed file under a real asmdef-defined assembly makes + // PreviewMigrationForSeedFilesAsync use the scope-limited path, so a legacy tool in a + // sibling assembly (outside the scope) is not reported. + string projectRoot = CreateProjectRoot(); + try + { + string scopedToolDirectory = Path.Combine(projectRoot, "Assets", "ScopedTool"); + string outOfScopeToolDirectory = Path.Combine(projectRoot, "Assets", "OutOfScopeTool"); + Directory.CreateDirectory(scopedToolDirectory); + Directory.CreateDirectory(outOfScopeToolDirectory); + string legacyToolPath = Path.Combine(scopedToolDirectory, "LegacyTool.cs"); + File.WriteAllText(legacyToolPath, LegacyToolSource); + File.WriteAllText( + Path.Combine(scopedToolDirectory, "ScopedTool.asmdef"), + "{\"name\": \"ScopedTool\"}"); + File.WriteAllText( + Path.Combine(outOfScopeToolDirectory, "OutOfScopeTool.cs"), + LegacyToolSource); + + ThirdPartyToolMigrationFileService service = new(); + ThirdPartyToolMigrationPreview preview = await service.PreviewMigrationForSeedFilesAsync( + projectRoot, + new List { legacyToolPath }, + new Progress(), + CancellationToken.None); + + Assert.That(preview.HasTargets, Is.True); + Assert.That(preview.FilePaths, Does.Contain(legacyToolPath)); + Assert.That( + preview.FilePaths, + Has.No.Member(Path.Combine(outOfScopeToolDirectory, "OutOfScopeTool.cs"))); + } + finally + { + Directory.Delete(projectRoot, recursive: true); + } + } + private const string LegacyToolSource = @"using io.github.hatayama.uLoopMCP; [McpTool] diff --git a/Assets/Tests/Editor/ThirdPartyToolMigrationWizardWorkflowControllerTests.cs b/Assets/Tests/Editor/ThirdPartyToolMigrationWizardWorkflowControllerTests.cs new file mode 100644 index 000000000..2a3ecbf53 --- /dev/null +++ b/Assets/Tests/Editor/ThirdPartyToolMigrationWizardWorkflowControllerTests.cs @@ -0,0 +1,196 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +using NUnit.Framework; +using UnityEngine.UIElements; + +using io.github.hatayama.UnityCliLoop.Application; +using io.github.hatayama.UnityCliLoop.Domain; +using io.github.hatayama.UnityCliLoop.Presentation; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor +{ + /// + /// Verifies that only the first RefreshUI call after an auto-scan window-open uses the + /// compile-error-matched seed file paths for a scoped scan; a later manual re-check falls back + /// to a full-project scan (empty seed list) since the seeds are cleared after first use. + /// + public sealed class ThirdPartyToolMigrationWizardWorkflowControllerTests + { + [Test] + public async Task RefreshUI_WhenCalledFirstTime_PassesAutoScanSeedFilePathsToPreview() + { + // Verifies that the first RefreshUI call after an auto-scan open scopes the scan to the + // seed file paths supplied by the constructor. + RecordingThirdPartyToolMigrationPort port = new(); + ThirdPartyToolMigrationWizardWorkflowController controller = + CreateController(port, new List { "/Project/Assets/Tool.cs" }); + + await controller.RefreshUI(); + + Assert.That(port.SeedFilePathsPerCall, Has.Count.EqualTo(1)); + Assert.That(port.SeedFilePathsPerCall[0], Is.EqualTo(new[] { "/Project/Assets/Tool.cs" })); + } + + [Test] + public async Task RefreshUI_WhenCalledASecondTime_PassesEmptySeedFilePaths() + { + // Verifies that a manual re-check after the initial auto-scoped scan falls back to a + // full-project scan, since the seed file paths are cleared after their first use. + RecordingThirdPartyToolMigrationPort port = new(); + ThirdPartyToolMigrationWizardWorkflowController controller = + CreateController(port, new List { "/Project/Assets/Tool.cs" }); + + await controller.RefreshUI(); + await controller.RefreshUI(); + + Assert.That(port.SeedFilePathsPerCall, Has.Count.EqualTo(2)); + Assert.That(port.SeedFilePathsPerCall[1], Is.Empty); + } + + private static ThirdPartyToolMigrationWizardWorkflowController CreateController( + IThirdPartyToolMigrationPort port, + List autoScanSeedFilePaths) + { + VisualElement root = new(); + ThirdPartyToolMigrationWizardView view = ThirdPartyToolMigrationWizardView.Create( + root, + () => { }, + () => { }, + _ => { }, + () => { }, + () => { }); + SkillSetupUseCase skillSetupUseCase = new(new NoOpSkillSetupPort()); + ThirdPartyToolMigrationUseCase migrationUseCase = new(port); + + return new ThirdPartyToolMigrationWizardWorkflowController( + view, + skillSetupUseCase, + migrationUseCase, + autoScanSeedFilePaths, + () => { }); + } + + private sealed class RecordingThirdPartyToolMigrationPort : IThirdPartyToolMigrationPort + { + internal readonly List> SeedFilePathsPerCall = new(); + + public ThirdPartyToolMigrationPreview PreviewMigration(string projectRoot) + { + return new ThirdPartyToolMigrationPreview(0, 0, Array.Empty()); + } + + public Task PreviewMigrationAsync( + string projectRoot, + IProgress progress, + CancellationToken ct) + { + return Task.FromResult(new ThirdPartyToolMigrationPreview(0, 0, Array.Empty())); + } + + public Task PreviewMigrationForSeedFilesAsync( + string projectRoot, + List seedFilePaths, + IProgress progress, + CancellationToken ct) + { + SeedFilePathsPerCall.Add(seedFilePaths); + return Task.FromResult(new ThirdPartyToolMigrationPreview(0, 0, Array.Empty())); + } + + public (bool Found, List TargetFilePaths) TryDetectAutoScanTargetsFromCompileErrors( + string projectRoot) + { + return (false, new List()); + } + + public Task HasMigrationTargetsAsync(string projectRoot, CancellationToken ct) + { + return Task.FromResult(false); + } + + public ThirdPartyToolMigrationResult ApplyMigration(string projectRoot) + { + return new ThirdPartyToolMigrationResult(0, 0, Array.Empty()); + } + + public Task ApplyMigrationAsync( + string projectRoot, + IProgress progress, + CancellationToken ct) + { + return Task.FromResult(new ThirdPartyToolMigrationResult(0, 0, Array.Empty())); + } + } + + private sealed class NoOpSkillSetupPort : ISkillSetupPort + { + public void RemoveSkillFiles(string toolName) + { + } + + public bool IsSkillInstalled(string toolName) + { + return false; + } + + public List DetectSkillTargetsForLayoutAtProjectRoot( + string projectRoot, + bool groupSkillsUnderUnityCliLoop) + { + return new List(); + } + + public List DetectSkillTargetsForLayoutFastAtProjectRoot( + string projectRoot, + bool groupSkillsUnderUnityCliLoop) + { + return new List(); + } + + public Task InstallSkillFilesAsync( + List targets, + bool groupSkillsUnderUnityCliLoop, + CancellationToken ct) + { + return Task.CompletedTask; + } + + public Task InstallSkillFilesForToolAsync( + string toolName, + bool groupSkillsUnderUnityCliLoop, + CancellationToken ct) + { + return Task.CompletedTask; + } + + public SkillInstallState GetV3MigrationSkillInstallStateAtProjectRoot( + string projectRoot, + SkillSetupTargetInfo target, + bool groupSkillsUnderUnityCliLoop) + { + return SkillInstallState.Missing; + } + + public Task InstallV3MigrationSkillFilesAsync( + string projectRoot, + List targets, + bool groupSkillsUnderUnityCliLoop, + CancellationToken ct) + { + return Task.CompletedTask; + } + + public Task RemoveV3MigrationSkillFilesAsync( + string projectRoot, + List targets, + bool groupSkillsUnderUnityCliLoop, + CancellationToken ct) + { + return Task.CompletedTask; + } + } + } +} diff --git a/Assets/Tests/Editor/ThirdPartyToolMigrationWizardWorkflowControllerTests.cs.meta b/Assets/Tests/Editor/ThirdPartyToolMigrationWizardWorkflowControllerTests.cs.meta new file mode 100644 index 000000000..55012876f --- /dev/null +++ b/Assets/Tests/Editor/ThirdPartyToolMigrationWizardWorkflowControllerTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 6e353471b0e034355b48b03b9fcbbc1c +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/Application/UseCases/ThirdPartyToolMigrationUseCase.cs b/Packages/src/Editor/Application/UseCases/ThirdPartyToolMigrationUseCase.cs index 359be2fab..4fa6722e5 100644 --- a/Packages/src/Editor/Application/UseCases/ThirdPartyToolMigrationUseCase.cs +++ b/Packages/src/Editor/Application/UseCases/ThirdPartyToolMigrationUseCase.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Diagnostics; using System.Threading; using System.Threading.Tasks; @@ -39,6 +40,27 @@ public Task PreviewMigrationAsync( return _migrationPort.PreviewMigrationAsync(projectRoot, progress, ct); } + public Task PreviewMigrationForSeedFilesAsync( + string projectRoot, + List seedFilePaths, + IProgress progress, + CancellationToken ct) + { + Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty"); + Debug.Assert(seedFilePaths != null, "seedFilePaths must not be null"); + Debug.Assert(progress != null, "progress must not be null"); + + return _migrationPort.PreviewMigrationForSeedFilesAsync(projectRoot, seedFilePaths, progress, ct); + } + + public (bool Found, List TargetFilePaths) TryDetectAutoScanTargetsFromCompileErrors( + string projectRoot) + { + Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty"); + + return _migrationPort.TryDetectAutoScanTargetsFromCompileErrors(projectRoot); + } + public Task HasMigrationTargetsAsync(string projectRoot, CancellationToken ct) { Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty"); diff --git a/Packages/src/Editor/CompositionRoot/UnityCliLoopApplicationRegistration.cs b/Packages/src/Editor/CompositionRoot/UnityCliLoopApplicationRegistration.cs index 6592a9ddb..eac4b8ec6 100644 --- a/Packages/src/Editor/CompositionRoot/UnityCliLoopApplicationRegistration.cs +++ b/Packages/src/Editor/CompositionRoot/UnityCliLoopApplicationRegistration.cs @@ -52,6 +52,8 @@ internal UnityCliLoopApplicationServices Register() SkillSetupUseCase skillSetupUseCase = new(skillSetupPort); ThirdPartyToolMigrationUseCase thirdPartyToolMigrationUseCase = new(new ThirdPartyToolMigrationFileService()); + IThirdPartyToolMigrationAutoScanSeedRepository thirdPartyToolMigrationAutoScanSeedRepository = + new UnityCliLoopThirdPartyToolMigrationAutoScanSeedRepository(); CliPinReaderService cliPinReaderService = new(); CliSetupApplicationService cliSetupApplicationService = new( new CliInstallationDetector(cliPinReaderService), @@ -102,6 +104,7 @@ internal UnityCliLoopApplicationServices Register() domainReloadDetectionService, editorSettingsPort, sessionFlagsRepository, + thirdPartyToolMigrationAutoScanSeedRepository, applicationService, cliSetupApplicationService, toolSettingsUseCase, @@ -116,6 +119,7 @@ internal UnityCliLoopApplicationServices( IDomainReloadDetectionService domainReloadDetectionService, IUnityCliLoopEditorSettingsPort editorSettingsPort, ISessionFlagsRepository sessionFlagsRepository, + IThirdPartyToolMigrationAutoScanSeedRepository thirdPartyToolMigrationAutoScanSeedRepository, UnityCliLoopServerApplicationService serverApplicationService, CliSetupApplicationService cliSetupApplicationService, ToolSettingsUseCase toolSettingsUseCase, @@ -125,6 +129,7 @@ internal UnityCliLoopApplicationServices( DomainReloadDetectionService = domainReloadDetectionService; EditorSettingsPort = editorSettingsPort; SessionFlagsRepository = sessionFlagsRepository; + ThirdPartyToolMigrationAutoScanSeedRepository = thirdPartyToolMigrationAutoScanSeedRepository; ServerApplicationService = serverApplicationService; CliSetupApplicationService = cliSetupApplicationService; ToolSettingsUseCase = toolSettingsUseCase; @@ -135,6 +140,7 @@ internal UnityCliLoopApplicationServices( internal IDomainReloadDetectionService DomainReloadDetectionService { get; } internal IUnityCliLoopEditorSettingsPort EditorSettingsPort { get; } internal ISessionFlagsRepository SessionFlagsRepository { get; } + internal IThirdPartyToolMigrationAutoScanSeedRepository ThirdPartyToolMigrationAutoScanSeedRepository { get; } internal UnityCliLoopServerApplicationService ServerApplicationService { get; } internal CliSetupApplicationService CliSetupApplicationService { get; } internal ToolSettingsUseCase ToolSettingsUseCase { get; } diff --git a/Packages/src/Editor/CompositionRoot/UnityCliLoopEditorBootstrapper.cs b/Packages/src/Editor/CompositionRoot/UnityCliLoopEditorBootstrapper.cs index 8621b6fbd..642953471 100644 --- a/Packages/src/Editor/CompositionRoot/UnityCliLoopEditorBootstrapper.cs +++ b/Packages/src/Editor/CompositionRoot/UnityCliLoopEditorBootstrapper.cs @@ -32,6 +32,7 @@ internal void Initialize() PresentationEditorStartup.Initialize( applicationServices.EditorSettingsPort, applicationServices.SessionFlagsRepository, + applicationServices.ThirdPartyToolMigrationAutoScanSeedRepository, applicationServices.ServerApplicationService, applicationServices.CliSetupApplicationService, applicationServices.ToolSettingsUseCase, diff --git a/Packages/src/Editor/Domain/IThirdPartyToolMigrationAutoScanSeedRepository.cs b/Packages/src/Editor/Domain/IThirdPartyToolMigrationAutoScanSeedRepository.cs new file mode 100644 index 000000000..4a5ad0d36 --- /dev/null +++ b/Packages/src/Editor/Domain/IThirdPartyToolMigrationAutoScanSeedRepository.cs @@ -0,0 +1,13 @@ +namespace io.github.hatayama.UnityCliLoop.Domain +{ + /// + /// Stores the seed file paths for a pending migration auto-scan (the compile-error-matched files + /// that triggered it) for the current Unity Editor session. + /// + public interface IThirdPartyToolMigrationAutoScanSeedRepository + { + void StoreSeedFilePaths(string[] filePaths); + string[] GetSeedFilePaths(); + void ClearSeedFilePaths(); + } +} diff --git a/Packages/src/Editor/Domain/IThirdPartyToolMigrationAutoScanSeedRepository.cs.meta b/Packages/src/Editor/Domain/IThirdPartyToolMigrationAutoScanSeedRepository.cs.meta new file mode 100644 index 000000000..2ec5ead17 --- /dev/null +++ b/Packages/src/Editor/Domain/IThirdPartyToolMigrationAutoScanSeedRepository.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: aebc6b69fed314a2590defdc4d36f6d2 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/Domain/ThirdPartyToolMigrationData.cs b/Packages/src/Editor/Domain/ThirdPartyToolMigrationData.cs index 032ad6a2c..f689c1460 100644 --- a/Packages/src/Editor/Domain/ThirdPartyToolMigrationData.cs +++ b/Packages/src/Editor/Domain/ThirdPartyToolMigrationData.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Diagnostics; using System.Threading; using System.Threading.Tasks; @@ -81,6 +82,27 @@ Task PreviewMigrationAsync( string projectRoot, IProgress progress, CancellationToken ct); + + /// + /// Builds a preview scoped to the assemblies containing the given seed files (e.g. compile- + /// error-matched migration targets), falling back to a full-project scan when the seeds do not + /// resolve to a safe, complete scope (see ThirdPartyToolMigrationScanScopeResolver). + /// + Task PreviewMigrationForSeedFilesAsync( + string projectRoot, + List seedFilePaths, + IProgress progress, + CancellationToken ct); + + /// + /// Checks whether the most recent compile failure was caused by V2 legacy custom-tool APIs, by + /// matching Unity Console error text against known legacy tokens (mirrors Unity's own API + /// Updater, which performs the same kind of compile-error-driven detection). Returns + /// Found == false with an empty TargetFilePaths (never null) without inspecting the console + /// when no compile failure is in effect. + /// + (bool Found, List TargetFilePaths) TryDetectAutoScanTargetsFromCompileErrors(string projectRoot); + Task HasMigrationTargetsAsync(string projectRoot, CancellationToken ct); ThirdPartyToolMigrationResult ApplyMigration(string projectRoot); Task ApplyMigrationAsync( diff --git a/Packages/src/Editor/Infrastructure/Settings/UnityCliLoopThirdPartyToolMigrationAutoScanSeedRepository.cs b/Packages/src/Editor/Infrastructure/Settings/UnityCliLoopThirdPartyToolMigrationAutoScanSeedRepository.cs new file mode 100644 index 000000000..d40752854 --- /dev/null +++ b/Packages/src/Editor/Infrastructure/Settings/UnityCliLoopThirdPartyToolMigrationAutoScanSeedRepository.cs @@ -0,0 +1,49 @@ +using System; +using System.Linq; + +using io.github.hatayama.UnityCliLoop.Domain; + +namespace io.github.hatayama.UnityCliLoop.Infrastructure +{ + /// + /// Stores the migration auto-scan seed file paths in Unity SessionState. + /// + public sealed class UnityCliLoopThirdPartyToolMigrationAutoScanSeedRepository + : IThirdPartyToolMigrationAutoScanSeedRepository + { + private const string SeedFilePathsKey = + UnityCliLoopEditorSessionStateStorage.KeyPrefix + "thirdPartyToolMigrationAutoScanSeedFilePaths"; + private const char SeedFilePathSeparator = '\n'; + + public void StoreSeedFilePaths(string[] filePaths) + { + if (filePaths == null) + { + throw new ArgumentNullException(nameof(filePaths)); + } + + UnityCliLoopEditorSessionStateStorage.SetString( + SeedFilePathsKey, + string.Join(SeedFilePathSeparator, filePaths)); + } + + public string[] GetSeedFilePaths() + { + string stored = UnityCliLoopEditorSessionStateStorage.GetString(SeedFilePathsKey); + if (string.IsNullOrEmpty(stored)) + { + return Array.Empty(); + } + + return stored + .Split(SeedFilePathSeparator) + .Where(filePath => filePath.Length > 0) + .ToArray(); + } + + public void ClearSeedFilePaths() + { + UnityCliLoopEditorSessionStateStorage.SetString(SeedFilePathsKey, string.Empty); + } + } +} diff --git a/Packages/src/Editor/Infrastructure/Settings/UnityCliLoopThirdPartyToolMigrationAutoScanSeedRepository.cs.meta b/Packages/src/Editor/Infrastructure/Settings/UnityCliLoopThirdPartyToolMigrationAutoScanSeedRepository.cs.meta new file mode 100644 index 000000000..3fedb8efe --- /dev/null +++ b/Packages/src/Editor/Infrastructure/Settings/UnityCliLoopThirdPartyToolMigrationAutoScanSeedRepository.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 4afb99e6626884b3d98110c6afb65760 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationConsoleErrorParser.cs b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationConsoleErrorParser.cs new file mode 100644 index 000000000..ab7aa0892 --- /dev/null +++ b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationConsoleErrorParser.cs @@ -0,0 +1,105 @@ +using System.Collections.Generic; +using System.Diagnostics; +using System.Text.RegularExpressions; + +namespace io.github.hatayama.UnityCliLoop.Infrastructure +{ + /// + /// Parses raw Unity Console error text back into structured CompileErrorLogEntry values (file path + /// + line number) so ThirdPartyToolMigrationCompileErrorLogMatcher can match them. Uses the same + /// standard csc/Roslyn diagnostic format ("path(line,col): error CSxxxx: message") as + /// ExternalCompilerMessageParser; deliberately not shared with that class, which is scoped to a + /// different module (external compiler process output) and parsing the same universal diagnostic + /// format twice is simpler than coupling two unrelated call sites. + /// + internal static class ThirdPartyToolMigrationConsoleErrorParser + { + private static readonly Regex DiagnosticRegex = new Regex( + @"^(?.+)\((?\d+),(?\d+)\): (?error|warning) (?[A-Za-z]+\d+): (?.+)$", + RegexOptions.Compiled); + + internal static List Parse( + IReadOnlyList rawMessages, + string projectRoot) + { + Debug.Assert(rawMessages != null, "rawMessages must not be null"); + Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty"); + + List entries = new List(); + foreach (string rawMessage in rawMessages) + { + if (TryParseErrorLine(rawMessage, projectRoot, out CompileErrorLogEntry entry)) + { + entries.Add(entry); + } + } + + return entries; + } + + private static bool TryParseErrorLine(string rawMessage, string projectRoot, out CompileErrorLogEntry entry) + { + entry = default; + if (string.IsNullOrEmpty(rawMessage)) + { + return false; + } + + // A console entry's message can span multiple physical lines; only the first line carries + // the diagnostic-format file/line prefix. + string firstLine = SplitFirstLine(rawMessage).Trim(); + Match match = DiagnosticRegex.Match(firstLine); + if (!match.Success || match.Groups["severity"].Value != "error") + { + return false; + } + + string filePath = NormalizeFilePath(match.Groups["file"].Value.Trim(), projectRoot); + int lineNumber = int.Parse(match.Groups["line"].Value); + string message = $"{match.Groups["code"].Value}: {match.Groups["message"].Value}"; + + entry = new CompileErrorLogEntry(message, filePath, lineNumber); + return true; + } + + private static string SplitFirstLine(string text) + { + int newlineIndex = text.IndexOfAny(new[] { '\r', '\n' }); + return newlineIndex < 0 ? text : text.Substring(0, newlineIndex); + } + + /// + /// Normalizes a compiler-reported path into an absolute, forward-slash path, without relying on + /// Path.IsPathRooted/Path.Combine: those resolve rootedness and separators against the host OS + /// running the test, not the OS that produced the raw message, so a Windows-style path (backslash + /// separators, drive-letter root) must parse correctly even when this code runs on macOS/Linux. + /// + private static string NormalizeFilePath(string rawFilePath, string projectRoot) + { + string slashNormalized = rawFilePath.Replace('\\', '/'); + if (IsRootedPath(slashNormalized)) + { + return slashNormalized; + } + + string normalizedProjectRoot = projectRoot.Replace('\\', '/').TrimEnd('/'); + return $"{normalizedProjectRoot}/{slashNormalized}"; + } + + private static bool IsRootedPath(string path) + { + if (path.Length == 0) + { + return false; + } + + if (path[0] == '/') + { + return true; + } + + // Windows drive-letter absolute path, e.g. "C:/Users/...". + return path.Length >= 2 && char.IsLetter(path[0]) && path[1] == ':'; + } + } +} diff --git a/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationConsoleErrorParser.cs.meta b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationConsoleErrorParser.cs.meta new file mode 100644 index 000000000..6ae407a3f --- /dev/null +++ b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationConsoleErrorParser.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: c375ef7f2219f4e409f3b70d05b9158b +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationFileService.cs b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationFileService.cs index ac6924e00..d68b980a5 100644 --- a/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationFileService.cs +++ b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationFileService.cs @@ -7,7 +7,10 @@ using Newtonsoft.Json.Linq; +using UnityEditor; + using io.github.hatayama.UnityCliLoop.Domain; +using io.github.hatayama.UnityCliLoop.FirstPartyTools; namespace io.github.hatayama.UnityCliLoop.Infrastructure { @@ -16,6 +19,8 @@ namespace io.github.hatayama.UnityCliLoop.Infrastructure /// public sealed class ThirdPartyToolMigrationFileService : IThirdPartyToolMigrationPort { + private static readonly ConsoleLogRetriever LogRetriever = new(); + private readonly object _migrationCacheLock = new(); private bool _hasCachedPreview; private string _cachedPreviewProjectRoot = string.Empty; @@ -102,6 +107,75 @@ public async Task PreviewMigrationInScopeAsync( plan.ChangedFilePaths.ToArray()); } + /// + /// Resolves seed files (e.g. compile-error-matched migration targets) to the assembly + /// directories that contain them via a lightweight .asmdef/.asmref-only walk, then scans only + /// those directories. Falls back to a full-project scan when the seeds don't resolve to a + /// complete, safe scope (HasImplicitAssemblySeeds; see ThirdPartyToolMigrationScanScopeResolver) + /// or when no seed files were supplied at all. + /// + public async Task PreviewMigrationForSeedFilesAsync( + string projectRoot, + List seedFilePaths, + IProgress progress, + CancellationToken ct) + { + Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty"); + Debug.Assert(seedFilePaths != null, "seedFilePaths must not be null"); + Debug.Assert(progress != null, "progress must not be null"); + + string normalizedProjectRoot = NormalizeProjectRoot(projectRoot); + if (seedFilePaths.Count == 0) + { + return await PreviewMigrationAsync(normalizedProjectRoot, progress, ct); + } + + (List asmdefDirectories, List assemblyReferenceDirectories) = + ThirdPartyToolMigrationLightweightAssemblyWalker.DiscoverAssemblyStructure(normalizedProjectRoot); + (List scopeDirectories, bool hasImplicitAssemblySeeds) = + ThirdPartyToolMigrationScanScopeResolver.ResolveScopeAssemblyDirectories( + seedFilePaths, + asmdefDirectories, + assemblyReferenceDirectories, + normalizedProjectRoot); + + if (hasImplicitAssemblySeeds) + { + return await PreviewMigrationAsync(normalizedProjectRoot, progress, ct); + } + + return await PreviewMigrationInScopeAsync(normalizedProjectRoot, scopeDirectories, progress, ct); + } + + public (bool Found, List TargetFilePaths) TryDetectAutoScanTargetsFromCompileErrors( + string projectRoot) + { + Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty"); + + if (!EditorUtility.scriptCompilationFailed) + { + return (false, new List()); + } + + string normalizedProjectRoot = NormalizeProjectRoot(projectRoot); + List rawMessages = new List(); + foreach (LogEntryDto logEntry in LogRetriever.GetLogsByType(UnityEngine.LogType.Error)) + { + rawMessages.Add(logEntry.Message); + } + + List entries = + ThirdPartyToolMigrationConsoleErrorParser.Parse(rawMessages, normalizedProjectRoot); + ThirdPartyToolMigrationCompileErrorLogMatchResult matchResult = + ThirdPartyToolMigrationCompileErrorLogMatcher.Match(entries); + if (matchResult.TargetFilePaths.Count == 0) + { + return (false, new List()); + } + + return (true, matchResult.TargetFilePaths); + } + public async Task HasMigrationTargetsAsync(string projectRoot, CancellationToken ct) { Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty"); diff --git a/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationLightweightAssemblyWalker.cs b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationLightweightAssemblyWalker.cs new file mode 100644 index 000000000..ffbb8d0b4 --- /dev/null +++ b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationLightweightAssemblyWalker.cs @@ -0,0 +1,52 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; + +namespace io.github.hatayama.UnityCliLoop.Infrastructure +{ + /// + /// Discovers .asmdef/.asmref directory structure only (no C# source files), so the compile-error- + /// driven auto-scan can resolve seed files to assembly directories without the full project file + /// walk that ThirdPartyToolMigrationProjectFileInventory performs. + /// + internal static class ThirdPartyToolMigrationLightweightAssemblyWalker + { + private const string AssetsDirectoryName = "Assets"; + private const string AsmdefSearchPattern = "*.asmdef"; + private const string AsmrefSearchPattern = "*.asmref"; + + internal static (List AsmdefDirectories, List AssemblyReferenceDirectories) + DiscoverAssemblyStructure(string projectRoot) + { + Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty"); + + string assetsDirectory = Path.Combine(projectRoot, AssetsDirectoryName); + if (!Directory.Exists(assetsDirectory)) + { + return (new List(), new List()); + } + + List asmdefFilePaths = Directory + .GetFiles(assetsDirectory, AsmdefSearchPattern, SearchOption.AllDirectories) + .ToList(); + List asmrefFilePaths = Directory + .GetFiles(assetsDirectory, AsmrefSearchPattern, SearchOption.AllDirectories) + .ToList(); + + List asmdefDirectories = asmdefFilePaths + .Select(filePath => Path.GetDirectoryName(filePath) ?? string.Empty) + .Where(directory => directory.Length > 0) + .Distinct(StringComparer.Ordinal) + .ToList(); + + List assemblyReferenceDirectories = + ThirdPartyToolMigrationAssemblyReferenceResolver.CreateAssemblyReferenceDirectories( + asmdefFilePaths, + asmrefFilePaths); + + return (asmdefDirectories, assemblyReferenceDirectories); + } + } +} diff --git a/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationLightweightAssemblyWalker.cs.meta b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationLightweightAssemblyWalker.cs.meta new file mode 100644 index 000000000..bbbb2c55a --- /dev/null +++ b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationLightweightAssemblyWalker.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: e3b9580ecf28841f3be7b1c3d53fd37d +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/Infrastructure/UnityCLILoop.Infrastructure.asmdef b/Packages/src/Editor/Infrastructure/UnityCLILoop.Infrastructure.asmdef index 7da9fee33..b1f62fd1d 100644 --- a/Packages/src/Editor/Infrastructure/UnityCLILoop.Infrastructure.asmdef +++ b/Packages/src/Editor/Infrastructure/UnityCLILoop.Infrastructure.asmdef @@ -6,7 +6,8 @@ "GUID:5c4588558a3624eacbce0f50007cf1eb", "GUID:fc3fd32eddbee40e39c2d76dc184957b", "GUID:5079a8d3a72924a81aa1cbc25f65ed1b", - "GUID:527f26a36b5043c2bd4d4036d04cd76d" + "GUID:527f26a36b5043c2bd4d4036d04cd76d", + "GUID:e5952ef560e1641f68b2687e6045bf5b" ], "includePlatforms": [ "Editor" diff --git a/Packages/src/Editor/Presentation/PresentationEditorStartup.cs b/Packages/src/Editor/Presentation/PresentationEditorStartup.cs index 1dcddea5c..33be8cefa 100644 --- a/Packages/src/Editor/Presentation/PresentationEditorStartup.cs +++ b/Packages/src/Editor/Presentation/PresentationEditorStartup.cs @@ -11,6 +11,7 @@ internal static class PresentationEditorStartup internal static void Initialize( IUnityCliLoopEditorSettingsPort editorSettingsPort, ISessionFlagsRepository sessionFlagsRepository, + IThirdPartyToolMigrationAutoScanSeedRepository thirdPartyToolMigrationAutoScanSeedRepository, UnityCliLoopServerApplicationService serverApplicationService, CliSetupApplicationService cliSetupApplicationService, ToolSettingsUseCase toolSettingsUseCase, @@ -27,13 +28,16 @@ internal static void Initialize( ServerEditorWindow.InitializeEditorServices(serverApplicationService); ThirdPartyToolMigrationWizardWindow.InitializeEditorServices( sessionFlagsRepository, + thirdPartyToolMigrationAutoScanSeedRepository, skillSetupUseCase, thirdPartyToolMigrationUseCase); SetupWizardWindow.InitializeForEditorStartup( editorSettingsPort, sessionFlagsRepository, + thirdPartyToolMigrationAutoScanSeedRepository, cliSetupApplicationService, - skillSetupUseCase); + skillSetupUseCase, + thirdPartyToolMigrationUseCase); } } } diff --git a/Packages/src/Editor/Presentation/Setup/SetupWizardStartupFlow.cs b/Packages/src/Editor/Presentation/Setup/SetupWizardStartupFlow.cs index 68c372e24..d3415f4ec 100644 --- a/Packages/src/Editor/Presentation/Setup/SetupWizardStartupFlow.cs +++ b/Packages/src/Editor/Presentation/Setup/SetupWizardStartupFlow.cs @@ -21,23 +21,31 @@ internal sealed class SetupWizardStartupFlow private readonly IUnityCliLoopEditorSettingsPort _editorSettingsPort; private readonly ISessionFlagsRepository _sessionFlagsRepository; + private readonly IThirdPartyToolMigrationAutoScanSeedRepository _autoScanSeedRepository; private readonly CliSetupApplicationService _cliSetupApplicationService; private readonly SkillSetupUseCase _skillSetupUseCase; + private readonly ThirdPartyToolMigrationUseCase _thirdPartyToolMigrationUseCase; private readonly System.Action _showWindowOnVersionChange; private readonly System.Action _showThirdPartyMigrationAutoScan; internal SetupWizardStartupFlow( IUnityCliLoopEditorSettingsPort editorSettingsPort, ISessionFlagsRepository sessionFlagsRepository, + IThirdPartyToolMigrationAutoScanSeedRepository autoScanSeedRepository, CliSetupApplicationService cliSetupApplicationService, SkillSetupUseCase skillSetupUseCase, + ThirdPartyToolMigrationUseCase thirdPartyToolMigrationUseCase, System.Action showWindowOnVersionChange, System.Action showThirdPartyMigrationAutoScan) { Debug.Assert(editorSettingsPort != null, "editorSettingsPort must not be null"); Debug.Assert(sessionFlagsRepository != null, "sessionFlagsRepository must not be null"); + Debug.Assert(autoScanSeedRepository != null, "autoScanSeedRepository must not be null"); Debug.Assert(cliSetupApplicationService != null, "cliSetupApplicationService must not be null"); Debug.Assert(skillSetupUseCase != null, "skillSetupUseCase must not be null"); + Debug.Assert( + thirdPartyToolMigrationUseCase != null, + "thirdPartyToolMigrationUseCase must not be null"); Debug.Assert(showWindowOnVersionChange != null, "showWindowOnVersionChange must not be null"); Debug.Assert(showThirdPartyMigrationAutoScan != null, "showThirdPartyMigrationAutoScan must not be null"); @@ -45,9 +53,13 @@ internal SetupWizardStartupFlow( ?? throw new System.ArgumentNullException(nameof(editorSettingsPort)); _sessionFlagsRepository = sessionFlagsRepository ?? throw new System.ArgumentNullException(nameof(sessionFlagsRepository)); + _autoScanSeedRepository = autoScanSeedRepository + ?? throw new System.ArgumentNullException(nameof(autoScanSeedRepository)); _cliSetupApplicationService = cliSetupApplicationService ?? throw new System.ArgumentNullException(nameof(cliSetupApplicationService)); _skillSetupUseCase = skillSetupUseCase ?? throw new System.ArgumentNullException(nameof(skillSetupUseCase)); + _thirdPartyToolMigrationUseCase = thirdPartyToolMigrationUseCase + ?? throw new System.ArgumentNullException(nameof(thirdPartyToolMigrationUseCase)); _showWindowOnVersionChange = showWindowOnVersionChange ?? throw new System.ArgumentNullException(nameof(showWindowOnVersionChange)); _showThirdPartyMigrationAutoScan = showThirdPartyMigrationAutoScan @@ -96,20 +108,6 @@ internal static bool ShouldAutoScanThirdPartyToolMigration(string currentVersion return lastSeenMajorVersion < 3 && currentMajorVersion == 3; } - internal static void MaybeMarkThirdPartyToolMigrationAutoScan( - ISessionFlagsRepository sessionFlagsRepository, - bool shouldAutoScan) - { - Debug.Assert(sessionFlagsRepository != null, "sessionFlagsRepository must not be null"); - - if (!shouldAutoScan) - { - return; - } - - sessionFlagsRepository.SetShouldAutoScanThirdPartyToolMigration(true); - } - internal static void MaybeRecordLastSeenSetupWizardState( IUnityCliLoopEditorSettingsPort editorSettingsPort, bool shouldRecordState, @@ -297,14 +295,29 @@ private async Task HasSkillUpdateForSetupWizardAsync(CancellationToken ct) return HasSkillUpdateForSetupWizard(targets); } - private void MaybeScheduleThirdPartyToolMigrationAutoScan(bool shouldAutoScan) + /// + /// The version-gate check alone can no longer trigger the auto-scan window: it must also be + /// confirmed against an actual compile-error hit for V2 legacy API tokens (mirroring Unity's own + /// API Updater), so a V2-to-V3 upgrade with no legacy custom-tool code present never shows an + /// unnecessary window. + /// + private void MaybeScheduleThirdPartyToolMigrationAutoScan(bool shouldAutoScanVersionGate) { - MaybeMarkThirdPartyToolMigrationAutoScan(_sessionFlagsRepository, shouldAutoScan); - if (!shouldAutoScan) + if (!shouldAutoScanVersionGate) + { + return; + } + + string projectRoot = UnityCliLoopPathResolver.GetProjectRoot(); + (bool found, List targetFilePaths) = + _thirdPartyToolMigrationUseCase.TryDetectAutoScanTargetsFromCompileErrors(projectRoot); + if (!found) { return; } + _autoScanSeedRepository.StoreSeedFilePaths(targetFilePaths.ToArray()); + _sessionFlagsRepository.SetShouldAutoScanThirdPartyToolMigration(true); EditorApplication.delayCall += () => _showThirdPartyMigrationAutoScan(); } } diff --git a/Packages/src/Editor/Presentation/Setup/SetupWizardWindow.cs b/Packages/src/Editor/Presentation/Setup/SetupWizardWindow.cs index 0bf2ee4ab..e5b57a92c 100644 --- a/Packages/src/Editor/Presentation/Setup/SetupWizardWindow.cs +++ b/Packages/src/Editor/Presentation/Setup/SetupWizardWindow.cs @@ -29,8 +29,10 @@ public class SetupWizardWindow : EditorWindow internal static void InitializeForEditorStartup( IUnityCliLoopEditorSettingsPort editorSettingsPort, ISessionFlagsRepository sessionFlagsRepository, + IThirdPartyToolMigrationAutoScanSeedRepository autoScanSeedRepository, CliSetupApplicationService cliSetupApplicationService, - SkillSetupUseCase skillSetupUseCase) + SkillSetupUseCase skillSetupUseCase, + ThirdPartyToolMigrationUseCase thirdPartyToolMigrationUseCase) { InitializeEditorServices( editorSettingsPort, @@ -43,8 +45,10 @@ internal static void InitializeForEditorStartup( SetupWizardStartupFlow startupFlow = new( editorSettingsPort, sessionFlagsRepository, + autoScanSeedRepository, cliSetupApplicationService, skillSetupUseCase, + thirdPartyToolMigrationUseCase, ShowWindowOnVersionChange, ThirdPartyToolMigrationWizardWindow.ShowWindowForAutoScan); EditorApplication.delayCall += startupFlow.TryShowOnVersionChange; diff --git a/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardWindow.cs b/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardWindow.cs index f2c85304e..556252a66 100644 --- a/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardWindow.cs +++ b/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardWindow.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using UnityEditor; using UnityEngine; @@ -20,12 +21,14 @@ public class ThirdPartyToolMigrationWizardWindow : EditorWindow private static readonly Vector2 MinimumWindowSize = ThirdPartyToolMigrationWizardWindowResizer.MinimumWindowSize; private static ISessionFlagsRepository RegisteredSessionFlagsRepository; + private static IThirdPartyToolMigrationAutoScanSeedRepository RegisteredAutoScanSeedRepository; private static SkillSetupUseCase RegisteredSkillSetupUseCase; private static ThirdPartyToolMigrationUseCase RegisteredThirdPartyToolMigrationUseCase; [SerializeField] private bool _shouldRefreshAfterCreateGui; + private List _autoScanSeedFilePaths = new List(); private SkillSetupUseCase _skillSetupUseCase; private ThirdPartyToolMigrationUseCase _thirdPartyToolMigrationUseCase; private ThirdPartyToolMigrationWizardView _view; @@ -34,10 +37,12 @@ public class ThirdPartyToolMigrationWizardWindow : EditorWindow internal static void InitializeEditorServices( ISessionFlagsRepository sessionFlagsRepository, + IThirdPartyToolMigrationAutoScanSeedRepository autoScanSeedRepository, SkillSetupUseCase skillSetupUseCase, ThirdPartyToolMigrationUseCase thirdPartyToolMigrationUseCase) { Debug.Assert(sessionFlagsRepository != null, "sessionFlagsRepository must not be null"); + Debug.Assert(autoScanSeedRepository != null, "autoScanSeedRepository must not be null"); Debug.Assert(skillSetupUseCase != null, "skillSetupUseCase must not be null"); Debug.Assert( thirdPartyToolMigrationUseCase != null, @@ -45,6 +50,8 @@ internal static void InitializeEditorServices( RegisteredSessionFlagsRepository = sessionFlagsRepository ?? throw new ArgumentNullException(nameof(sessionFlagsRepository)); + RegisteredAutoScanSeedRepository = autoScanSeedRepository + ?? throw new ArgumentNullException(nameof(autoScanSeedRepository)); RegisteredSkillSetupUseCase = skillSetupUseCase ?? throw new ArgumentNullException(nameof(skillSetupUseCase)); RegisteredThirdPartyToolMigrationUseCase = thirdPartyToolMigrationUseCase @@ -54,18 +61,21 @@ internal static void InitializeEditorServices( [MenuItem("Window/Unity CLI Loop/Custom Tool Migration", priority = 4)] public static void ShowWindow() { - ShowWindowInternal(false); + ShowWindowInternal(false, new List()); } internal static void ShowWindowForAutoScan() { - ConsumeAutoScanSessionState(); - ShowWindowInternal(true); + List seedFilePaths = ConsumeAutoScanSessionState(); + ShowWindowInternal(true, seedFilePaths); } - private static void ConsumeAutoScanSessionState() + private static List ConsumeAutoScanSessionState() { GetSessionFlagsRepository().ConsumeShouldAutoScanThirdPartyToolMigration(); + string[] seedFilePaths = GetAutoScanSeedRepository().GetSeedFilePaths(); + GetAutoScanSeedRepository().ClearSeedFilePaths(); + return new List(seedFilePaths); } internal static void PrepareForOpen( @@ -218,7 +228,7 @@ internal static bool ShouldRefreshAfterInterruptedMigration( isCancellationRequested); } - private static void ShowWindowInternal(bool shouldRefreshAfterCreateGui) + private static void ShowWindowInternal(bool shouldRefreshAfterCreateGui, List seedFilePaths) { if (HasOpenInstances()) { @@ -231,6 +241,7 @@ private static void ShowWindowInternal(bool shouldRefreshAfterCreateGui) InitialWindowSize); ThirdPartyToolMigrationWizardWindow window = CreateInstance(); + window._autoScanSeedFilePaths = seedFilePaths; PrepareForOpen(window, WindowTitle, windowPosition, shouldRefreshAfterCreateGui); window.ShowUtility(); } @@ -246,6 +257,17 @@ private static ISessionFlagsRepository GetSessionFlagsRepository() return RegisteredSessionFlagsRepository; } + private static IThirdPartyToolMigrationAutoScanSeedRepository GetAutoScanSeedRepository() + { + if (RegisteredAutoScanSeedRepository == null) + { + throw new InvalidOperationException( + "Migration Wizard auto-scan seed repository is not initialized."); + } + + return RegisteredAutoScanSeedRepository; + } + private static SkillSetupUseCase GetSkillSetupUseCase() { if (RegisteredSkillSetupUseCase == null) @@ -303,6 +325,7 @@ private void CreateGUI() _view, _skillSetupUseCase, _thirdPartyToolMigrationUseCase, + _autoScanSeedFilePaths, ScheduleResizeToContent); bool shouldStartInitialRefresh = ConsumeShouldStartInitialRefresh(); diff --git a/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardWorkflowController.cs b/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardWorkflowController.cs index 9d5707b6a..d582d0477 100644 --- a/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardWorkflowController.cs +++ b/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardWorkflowController.cs @@ -24,6 +24,11 @@ internal sealed class ThirdPartyToolMigrationWizardWorkflowController private readonly ThirdPartyToolMigrationUseCase _thirdPartyToolMigrationUseCase; private readonly Action _scheduleResize; + // Only the first scan after an auto-scan open is scoped to the compile-error-matched seed + // files; any later manual re-check (button click) must scan the whole project, since the + // seeds may no longer reflect what's actually broken, so this is cleared after first use. + private List _autoScanSeedFilePaths; + private bool _isMigrating; private bool _isUpdatingMigrationSkill; private SkillsTarget _migrationSkillTarget = SkillsTarget.Claude; @@ -36,6 +41,7 @@ internal ThirdPartyToolMigrationWizardWorkflowController( ThirdPartyToolMigrationWizardView view, SkillSetupUseCase skillSetupUseCase, ThirdPartyToolMigrationUseCase thirdPartyToolMigrationUseCase, + List autoScanSeedFilePaths, Action scheduleResize) { Debug.Assert(view != null, "view must not be null"); @@ -43,6 +49,7 @@ internal ThirdPartyToolMigrationWizardWorkflowController( Debug.Assert( thirdPartyToolMigrationUseCase != null, "thirdPartyToolMigrationUseCase must not be null"); + Debug.Assert(autoScanSeedFilePaths != null, "autoScanSeedFilePaths must not be null"); Debug.Assert(scheduleResize != null, "scheduleResize must not be null"); _view = view ?? throw new ArgumentNullException(nameof(view)); @@ -50,6 +57,8 @@ internal ThirdPartyToolMigrationWizardWorkflowController( ?? throw new ArgumentNullException(nameof(skillSetupUseCase)); _thirdPartyToolMigrationUseCase = thirdPartyToolMigrationUseCase ?? throw new ArgumentNullException(nameof(thirdPartyToolMigrationUseCase)); + _autoScanSeedFilePaths = autoScanSeedFilePaths + ?? throw new ArgumentNullException(nameof(autoScanSeedFilePaths)); _scheduleResize = scheduleResize ?? throw new ArgumentNullException(nameof(scheduleResize)); } @@ -94,11 +103,14 @@ internal async Task RefreshUI() string projectRoot = UnityCliLoopPathResolver.GetProjectRoot(); IProgress progress = CreateProgressReporter(ct); + List seedFilePaths = _autoScanSeedFilePaths; + _autoScanSeedFilePaths = new List(); ThirdPartyToolMigrationPreview preview; try { preview = await Task.Run(async () => - await _thirdPartyToolMigrationUseCase.PreviewMigrationAsync(projectRoot, progress, ct)); + await _thirdPartyToolMigrationUseCase.PreviewMigrationForSeedFilesAsync( + projectRoot, seedFilePaths, progress, ct)); await MainThreadSwitcher.SwitchToMainThread(); } catch (OperationCanceledException) From 7e2c3dc3963bd7375e816cb281f7bb9ff9a21e11 Mon Sep 17 00:00:00 2001 From: hatayama Date: Thu, 23 Jul 2026 14:13:42 +0900 Subject: [PATCH 04/11] Fix migration auto-scan trigger to survive the native compiler-errors dialog EditorApplication.delayCall permanently stops flushing for the rest of an Editor process once the native "Scripts have compiler errors" dialog has appeared at cold startup (-ignorecompilererrors does not suppress this dialog). Since that startup scenario is exactly when the compile-error-driven migration auto-scan needs to run, both the SetupWizard version-change check and the migration auto-scan trigger now ride on a self-unsubscribing EditorApplication.update tick / poll loop instead of delayCall. - SetupWizardWindow.InitializeForEditorStartup now registers a one-shot EditorApplication.update callback instead of delayCall. - SetupWizardStartupFlow.MaybeScheduleThirdPartyToolMigrationAutoScan polls compile state via EditorApplication.update (DecideMigrationAutoScanPollAction) and falls back to a full project scan if compilation stays failed past a timeout (SetupWizardStartupFlowConstants.MigrationAutoScanPollTimeoutSeconds). - ThirdPartyToolMigrationFileService now reads error logs through LogGetter instead of ConsoleLogRetriever directly. - Updated OnionAssemblyDependencyTests to assert the new EditorApplication.update-based startup wiring and the Infrastructure assembly's new Common.Console reference. Verified live via a cold Editor restart with pre-existing compile errors: the migration wizard now opens and completes detection almost instantly, where it previously never fired. --- .../Editor/OnionAssemblyDependencyTests.cs | 16 ++- Assets/Tests/Editor/SetupWizardWindowTests.cs | 25 ++++ .../ThirdPartyToolMigrationFileService.cs | 9 +- .../Setup/SetupWizardStartupFlow.cs | 128 +++++++++++++++++- .../Setup/SetupWizardStartupFlowConstants.cs | 10 ++ .../SetupWizardStartupFlowConstants.cs.meta | 11 ++ .../Presentation/Setup/SetupWizardWindow.cs | 17 ++- 7 files changed, 206 insertions(+), 10 deletions(-) create mode 100644 Packages/src/Editor/Presentation/Setup/SetupWizardStartupFlowConstants.cs create mode 100644 Packages/src/Editor/Presentation/Setup/SetupWizardStartupFlowConstants.cs.meta diff --git a/Assets/Tests/Editor/OnionAssemblyDependencyTests.cs b/Assets/Tests/Editor/OnionAssemblyDependencyTests.cs index 82cdc2a17..f79051f62 100644 --- a/Assets/Tests/Editor/OnionAssemblyDependencyTests.cs +++ b/Assets/Tests/Editor/OnionAssemblyDependencyTests.cs @@ -25,6 +25,7 @@ public sealed class OnionAssemblyDependencyTests private const string FirstPartyToolsAssemblyName = "UnityCLILoop.FirstPartyTools.Editor"; private const string FirstPartyToolsAssemblyNamePrefix = "UnityCLILoop.FirstPartyTools."; private const string ClearConsoleAssemblyName = "UnityCLILoop.FirstPartyTools.ClearConsole.Editor"; + private const string CommonConsoleAssemblyName = "UnityCLILoop.FirstPartyTools.Common.Console.Editor"; private const string CompileAssemblyName = "UnityCLILoop.FirstPartyTools.Compile.Editor"; private const string ControlPlayModeAssemblyName = "UnityCLILoop.FirstPartyTools.ControlPlayMode.Editor"; private const string ExecuteDynamicCodeAssemblyName = "UnityCLILoop.FirstPartyTools.ExecuteDynamicCode.Editor"; @@ -507,6 +508,7 @@ public void InfrastructureAsmdef_WhenLoaded_DependsOnApplicationRuntimeAndDoesNo InternalApiBridgeAssemblyName, ApplicationAssemblyName, DomainAssemblyName, + CommonConsoleAssemblyName, PausePointsRuntimeAssemblyName, ToolContractsAssemblyName })); @@ -881,6 +883,11 @@ public void DomainReloadStateRegistration_WhenLoaded_DoesNotReadSettingsSynchron public void SetupWizardStartup_WhenLoaded_SchedulesVersionCheckInsteadOfReadingSettingsSynchronously() { // Tests that Setup Wizard settings reads run after the synchronous Editor startup hook. + // + // A cold-start session that hits Unity's native "Scripts have compiler errors" dialog + // never flushes EditorApplication.delayCall again for the rest of that process's + // lifetime, so this startup check rides on a self-unsubscribing EditorApplication.update + // tick instead of delayCall. string setupWizardSource = ReadProductionSource( "Packages/src/Editor/Presentation/Setup/SetupWizardWindow.cs"); string setupWizardStartupFlowSource = ReadProductionSource( @@ -888,7 +895,7 @@ public void SetupWizardStartup_WhenLoaded_SchedulesVersionCheckInsteadOfReadingS Assert.That( setupWizardSource, - Does.Contain("EditorApplication.delayCall += startupFlow.TryShowOnVersionChange;")); + Does.Contain("EditorApplication.update += RunStartupCheckOnFirstUpdateTick;")); Assert.That( setupWizardStartupFlowSource, Does.Contain("EditorApplication.delayCall += () => _showWindowOnVersionChange();")); @@ -896,15 +903,16 @@ public void SetupWizardStartup_WhenLoaded_SchedulesVersionCheckInsteadOfReadingS } [Test] - public void SetupWizardStartup_WhenMigrationAutoScanIsRequested_SchedulesAutoScanThroughDelayCall() + public void SetupWizardStartup_WhenMigrationAutoScanIsRequested_SchedulesAutoScanThroughEditorUpdatePolling() { - // Tests that startup migration auto-scan runs through a delayed Editor callback. + // Tests that startup migration auto-scan polls via EditorApplication.update instead of + // delayCall, since delayCall stops flushing after the native compiler-errors dialog. string setupWizardStartupFlowSource = ReadProductionSource( "Packages/src/Editor/Presentation/Setup/SetupWizardStartupFlow.cs"); Assert.That( setupWizardStartupFlowSource, - Does.Contain("EditorApplication.delayCall += () => _showThirdPartyMigrationAutoScan();")); + Does.Contain("EditorApplication.update += PollThirdPartyToolMigrationAutoScan;")); } [Test] diff --git a/Assets/Tests/Editor/SetupWizardWindowTests.cs b/Assets/Tests/Editor/SetupWizardWindowTests.cs index 4982286a4..18d4ed31c 100644 --- a/Assets/Tests/Editor/SetupWizardWindowTests.cs +++ b/Assets/Tests/Editor/SetupWizardWindowTests.cs @@ -181,6 +181,31 @@ public void ShouldAutoScanThirdPartyToolMigration_ReturnsExpectedValue( Assert.That(shouldAutoScan, Is.EqualTo(expected)); } + [TestCase(true, false, 0d, 10d, MigrationAutoScanPollAction.ContinueWaiting)] + [TestCase(true, true, 0d, 10d, MigrationAutoScanPollAction.ContinueWaiting)] + [TestCase(false, false, 0d, 10d, MigrationAutoScanPollAction.Terminate)] + [TestCase(false, true, 0d, 10d, MigrationAutoScanPollAction.RunDetection)] + [TestCase(false, true, 5d, 10d, MigrationAutoScanPollAction.RunDetection)] + [TestCase(false, true, 10d, 10d, MigrationAutoScanPollAction.FallBackToFullScan)] + [TestCase(false, true, 15d, 10d, MigrationAutoScanPollAction.FallBackToFullScan)] + public void DecideMigrationAutoScanPollAction_ReturnsExpectedAction( + bool isCompiling, + bool scriptCompilationFailed, + double elapsedSeconds, + double timeoutSeconds, + MigrationAutoScanPollAction expected) + { + // Verifies the pure poll decision function used to replace the unreliable + // delayCall-based migration auto-scan trigger with an EditorApplication.update poll. + MigrationAutoScanPollAction action = SetupWizardStartupFlow.DecideMigrationAutoScanPollAction( + isCompiling, + scriptCompilationFailed, + elapsedSeconds, + timeoutSeconds); + + Assert.That(action, Is.EqualTo(expected)); + } + [Test] public void MaybeRecordLastSeenSetupWizardState_WhenAutoShow_UpdatesStoredState() { diff --git a/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationFileService.cs b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationFileService.cs index d68b980a5..aff0654fd 100644 --- a/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationFileService.cs +++ b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationFileService.cs @@ -19,8 +19,6 @@ namespace io.github.hatayama.UnityCliLoop.Infrastructure /// public sealed class ThirdPartyToolMigrationFileService : IThirdPartyToolMigrationPort { - private static readonly ConsoleLogRetriever LogRetriever = new(); - private readonly object _migrationCacheLock = new(); private bool _hasCachedPreview; private string _cachedPreviewProjectRoot = string.Empty; @@ -159,7 +157,12 @@ public async Task PreviewMigrationForSeedFilesAs string normalizedProjectRoot = NormalizeProjectRoot(projectRoot); List rawMessages = new List(); - foreach (LogEntryDto logEntry in LogRetriever.GetLogsByType(UnityEngine.LogType.Error)) + // Unity tags csc.rsp compiler diagnostics as LogType.Log internally, not LogType.Error, + // so LogGetter's message-based Error-family reclassification (matching ": error CSxxxx") + // is required here — a bare ConsoleLogRetriever.GetLogsByType(LogType.Error) call misses + // every genuine compile error. + LogDisplayDto errorLogs = LogGetter.GetConsoleLogsByType(UnityCliLoopLogType.Error); + foreach (LogEntryDto logEntry in errorLogs.LogEntries) { rawMessages.Add(logEntry.Message); } diff --git a/Packages/src/Editor/Presentation/Setup/SetupWizardStartupFlow.cs b/Packages/src/Editor/Presentation/Setup/SetupWizardStartupFlow.cs index d3415f4ec..1942e34d3 100644 --- a/Packages/src/Editor/Presentation/Setup/SetupWizardStartupFlow.cs +++ b/Packages/src/Editor/Presentation/Setup/SetupWizardStartupFlow.cs @@ -12,6 +12,18 @@ namespace io.github.hatayama.UnityCliLoop.Presentation { + /// + /// Decision made on each EditorApplication.update tick while polling for the + /// compile-error-driven migration auto-scan trigger. + /// + public enum MigrationAutoScanPollAction + { + ContinueWaiting, + RunDetection, + FallBackToFullScan, + Terminate + } + /// /// Coordinates setup wizard startup auto-show and migration auto-scan decisions. /// @@ -27,6 +39,8 @@ internal sealed class SetupWizardStartupFlow private readonly ThirdPartyToolMigrationUseCase _thirdPartyToolMigrationUseCase; private readonly System.Action _showWindowOnVersionChange; private readonly System.Action _showThirdPartyMigrationAutoScan; + private bool _migrationAutoScanPollingActive; + private double _migrationAutoScanPollStartTime; internal SetupWizardStartupFlow( IUnityCliLoopEditorSettingsPort editorSettingsPort, @@ -155,6 +169,39 @@ internal void TryShowOnVersionChange() EvaluateVersionChange(CancellationToken.None).Forget(); } + /// + /// Pure decision function for the migration auto-scan poll loop. Unity does not reload the + /// domain on a failed compile, and EditorApplication.delayCall is not reliably invoked while + /// EditorUtility.scriptCompilationFailed/Console state is still settling right after startup, + /// so the trigger must poll via EditorApplication.update instead of relying on a single + /// one-shot delayCall. + /// + internal static MigrationAutoScanPollAction DecideMigrationAutoScanPollAction( + bool isCompiling, + bool scriptCompilationFailed, + double elapsedSeconds, + double timeoutSeconds) + { + Debug.Assert(timeoutSeconds > 0, "timeoutSeconds must be positive"); + + if (isCompiling) + { + return MigrationAutoScanPollAction.ContinueWaiting; + } + + if (!scriptCompilationFailed) + { + return MigrationAutoScanPollAction.Terminate; + } + + if (elapsedSeconds >= timeoutSeconds) + { + return MigrationAutoScanPollAction.FallBackToFullScan; + } + + return MigrationAutoScanPollAction.RunDetection; + } + private static bool TryGetMajorVersion(string version, out int majorVersion) { majorVersion = 0; @@ -308,17 +355,94 @@ private void MaybeScheduleThirdPartyToolMigrationAutoScan(bool shouldAutoScanVer return; } + if (_migrationAutoScanPollingActive) + { + return; + } + + _migrationAutoScanPollingActive = true; + _migrationAutoScanPollStartTime = EditorApplication.timeSinceStartup; + EditorApplication.update += PollThirdPartyToolMigrationAutoScan; + } + + private void PollThirdPartyToolMigrationAutoScan() + { + double elapsedSeconds = EditorApplication.timeSinceStartup - _migrationAutoScanPollStartTime; + MigrationAutoScanPollAction action = DecideMigrationAutoScanPollAction( + EditorApplication.isCompiling, + EditorUtility.scriptCompilationFailed, + elapsedSeconds, + SetupWizardStartupFlowConstants.MigrationAutoScanPollTimeoutSeconds); + + switch (action) + { + case MigrationAutoScanPollAction.ContinueWaiting: + return; + + case MigrationAutoScanPollAction.Terminate: + StopThirdPartyToolMigrationAutoScanPolling(); + return; + + case MigrationAutoScanPollAction.RunDetection: + if (TryRunThirdPartyToolMigrationAutoScanDetection()) + { + StopThirdPartyToolMigrationAutoScanPolling(); + } + + return; + + case MigrationAutoScanPollAction.FallBackToFullScan: + ScheduleThirdPartyToolMigrationFallbackFullScan(); + StopThirdPartyToolMigrationAutoScanPolling(); + return; + } + } + + private void StopThirdPartyToolMigrationAutoScanPolling() + { + EditorApplication.update -= PollThirdPartyToolMigrationAutoScan; + _migrationAutoScanPollingActive = false; + } + + private bool TryRunThirdPartyToolMigrationAutoScanDetection() + { string projectRoot = UnityCliLoopPathResolver.GetProjectRoot(); (bool found, List targetFilePaths) = _thirdPartyToolMigrationUseCase.TryDetectAutoScanTargetsFromCompileErrors(projectRoot); if (!found) { - return; + return false; } _autoScanSeedRepository.StoreSeedFilePaths(targetFilePaths.ToArray()); _sessionFlagsRepository.SetShouldAutoScanThirdPartyToolMigration(true); - EditorApplication.delayCall += () => _showThirdPartyMigrationAutoScan(); + _showThirdPartyMigrationAutoScan(); + return true; + } + + /// + /// Reached only when scriptCompilationFailed stays true for the whole poll timeout without + /// ever yielding parsed compile-error entries (an abnormal state); falls back to the + /// pre-existing full-project scan rather than giving up detection entirely. + /// + private void ScheduleThirdPartyToolMigrationFallbackFullScan() + { + string projectRoot = UnityCliLoopPathResolver.GetProjectRoot(); + RunThirdPartyToolMigrationFallbackFullScanAsync(projectRoot).Forget(); + } + + private async Task RunThirdPartyToolMigrationFallbackFullScanAsync(string projectRoot) + { + bool hasTargets = await _thirdPartyToolMigrationUseCase.HasMigrationTargetsAsync( + projectRoot, + CancellationToken.None); + if (!hasTargets) + { + return; + } + + _sessionFlagsRepository.SetShouldAutoScanThirdPartyToolMigration(true); + _showThirdPartyMigrationAutoScan(); } } } diff --git a/Packages/src/Editor/Presentation/Setup/SetupWizardStartupFlowConstants.cs b/Packages/src/Editor/Presentation/Setup/SetupWizardStartupFlowConstants.cs new file mode 100644 index 000000000..27caa26ff --- /dev/null +++ b/Packages/src/Editor/Presentation/Setup/SetupWizardStartupFlowConstants.cs @@ -0,0 +1,10 @@ +namespace io.github.hatayama.UnityCliLoop.Presentation +{ + /// + /// Holds tuning constants for the setup wizard startup flow. + /// + internal static class SetupWizardStartupFlowConstants + { + internal const double MigrationAutoScanPollTimeoutSeconds = 10d; + } +} diff --git a/Packages/src/Editor/Presentation/Setup/SetupWizardStartupFlowConstants.cs.meta b/Packages/src/Editor/Presentation/Setup/SetupWizardStartupFlowConstants.cs.meta new file mode 100644 index 000000000..41c3f03c6 --- /dev/null +++ b/Packages/src/Editor/Presentation/Setup/SetupWizardStartupFlowConstants.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: c05f698d7b0914f80aa7a7aeae652d31 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/Presentation/Setup/SetupWizardWindow.cs b/Packages/src/Editor/Presentation/Setup/SetupWizardWindow.cs index e5b57a92c..35b7fcb37 100644 --- a/Packages/src/Editor/Presentation/Setup/SetupWizardWindow.cs +++ b/Packages/src/Editor/Presentation/Setup/SetupWizardWindow.cs @@ -51,7 +51,22 @@ internal static void InitializeForEditorStartup( thirdPartyToolMigrationUseCase, ShowWindowOnVersionChange, ThirdPartyToolMigrationWizardWindow.ShowWindowForAutoScan); - EditorApplication.delayCall += startupFlow.TryShowOnVersionChange; + + // A session that hits the native "Scripts have compiler errors" dialog at Editor + // startup (-ignorecompilererrors does not suppress it) never flushes + // EditorApplication.delayCall again for the rest of that process's lifetime, even + // for calls registered long after the dialog is dismissed -- confirmed live via + // repeated probes where EditorApplication.update kept ticking normally but freshly + // registered delayCalls never fired. This scenario (compile errors present at cold + // start) is exactly when this startup check must run, so it rides on + // EditorApplication.update (self-unsubscribing after its first tick) instead. + void RunStartupCheckOnFirstUpdateTick() + { + EditorApplication.update -= RunStartupCheckOnFirstUpdateTick; + startupFlow.TryShowOnVersionChange(); + } + + EditorApplication.update += RunStartupCheckOnFirstUpdateTick; } internal static void InitializeEditorServices( From f2cde3228e85f8ebd5f6b0cf83021d3e8110c24c Mon Sep 17 00:00:00 2001 From: hatayama Date: Thu, 23 Jul 2026 14:19:04 +0900 Subject: [PATCH 05/11] Fix inaccurate csc.rsp reference in a compile-error log comment csc.rsp is the response file, not the compiler itself; the comment should refer to csc diagnostics. Pointed out during advisor review. --- .../ThirdPartyToolMigrationFileService.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationFileService.cs b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationFileService.cs index aff0654fd..ecc6871e5 100644 --- a/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationFileService.cs +++ b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationFileService.cs @@ -157,7 +157,7 @@ public async Task PreviewMigrationForSeedFilesAs string normalizedProjectRoot = NormalizeProjectRoot(projectRoot); List rawMessages = new List(); - // Unity tags csc.rsp compiler diagnostics as LogType.Log internally, not LogType.Error, + // Unity tags csc compiler diagnostics as LogType.Log internally, not LogType.Error, // so LogGetter's message-based Error-family reclassification (matching ": error CSxxxx") // is required here — a bare ConsoleLogRetriever.GetLogsByType(LogType.Error) call misses // every genuine compile error. From 040b32a460b6ff96a01b37ce22a42ceca783cb51 Mon Sep 17 00:00:00 2001 From: hatayama Date: Thu, 23 Jul 2026 16:26:42 +0900 Subject: [PATCH 06/11] Remove scoped-scan preview and show auto-scan seed files without scanning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Manual verification found that clicking "Migrate" after the fast auto-scan detection triggered a second, slower, full-project scan, hurting perceived responsiveness. Root cause: the scoped preview never populated the full-scan plan cache (by design, since a scope-limited plan is unsafe to substitute for the full plan), so ApplyMigration always fell back to a fresh full scan. Per the user's decision (after advisor consultation confirming Migrate must keep doing a full scan for correctness — cascading compile-skip across dependent assemblies could otherwise hide legitimate migration targets), the fix is to stop scanning at auto-scan-open time entirely: the window now displays the compile-error-matched seed file list directly (already known for free from log matching), with Migrate remaining the sole scan+rewrite step. This removes the now-unused scoped-scan infrastructure (ScanScopeResolver, LightweightAssemblyWalker, PreviewMigrationForSeedFilesAsync, PreviewMigrationInScopeAsync, PlanBuilder.CreateInScopeAsync, ProjectFileInventory.CreateFromDirectoriesAsync) and their dedicated tests. --- ...MigrationLightweightAssemblyWalkerTests.cs | 111 -------- ...tionLightweightAssemblyWalkerTests.cs.meta | 11 - ...artyToolMigrationScanScopeResolverTests.cs | 147 ---------- ...oolMigrationScanScopeResolverTests.cs.meta | 11 - ...irdPartyToolMigrationScopedPreviewTests.cs | 265 ------------------ ...rtyToolMigrationScopedPreviewTests.cs.meta | 11 - ...lMigrationWizardWorkflowControllerTests.cs | 39 +-- .../ThirdPartyToolMigrationUseCase.cs | 13 - .../Domain/ThirdPartyToolMigrationData.cs | 11 - .../ThirdPartyToolMigrationFileService.cs | 74 ----- ...yToolMigrationLightweightAssemblyWalker.cs | 52 ---- ...MigrationLightweightAssemblyWalker.cs.meta | 11 - .../ThirdPartyToolMigrationPlanBuilder.cs | 34 --- ...dPartyToolMigrationProjectFileInventory.cs | 64 +---- ...hirdPartyToolMigrationScanScopeResolver.cs | 93 ------ ...artyToolMigrationScanScopeResolver.cs.meta | 11 - .../ThirdPartyToolMigrationWizardText.cs | 27 ++ .../ThirdPartyToolMigrationWizardView.cs | 23 ++ .../ThirdPartyToolMigrationWizardWindow.cs | 7 +- ...tyToolMigrationWizardWorkflowController.cs | 37 ++- 20 files changed, 87 insertions(+), 965 deletions(-) delete mode 100644 Assets/Tests/Editor/ThirdPartyToolMigrationLightweightAssemblyWalkerTests.cs delete mode 100644 Assets/Tests/Editor/ThirdPartyToolMigrationLightweightAssemblyWalkerTests.cs.meta delete mode 100644 Assets/Tests/Editor/ThirdPartyToolMigrationScanScopeResolverTests.cs delete mode 100644 Assets/Tests/Editor/ThirdPartyToolMigrationScanScopeResolverTests.cs.meta delete mode 100644 Assets/Tests/Editor/ThirdPartyToolMigrationScopedPreviewTests.cs delete mode 100644 Assets/Tests/Editor/ThirdPartyToolMigrationScopedPreviewTests.cs.meta delete mode 100644 Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationLightweightAssemblyWalker.cs delete mode 100644 Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationLightweightAssemblyWalker.cs.meta delete mode 100644 Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationScanScopeResolver.cs delete mode 100644 Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationScanScopeResolver.cs.meta diff --git a/Assets/Tests/Editor/ThirdPartyToolMigrationLightweightAssemblyWalkerTests.cs b/Assets/Tests/Editor/ThirdPartyToolMigrationLightweightAssemblyWalkerTests.cs deleted file mode 100644 index a3982a08e..000000000 --- a/Assets/Tests/Editor/ThirdPartyToolMigrationLightweightAssemblyWalkerTests.cs +++ /dev/null @@ -1,111 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; - -using NUnit.Framework; - -using io.github.hatayama.UnityCliLoop.Infrastructure; - -namespace io.github.hatayama.UnityCliLoop.Tests.Editor -{ - /// - /// Verifies that the lightweight walker discovers .asmdef/.asmref directory structure without - /// touching non-asmdef/asmref files, matching what the full project scan's - /// ThirdPartyToolMigrationAssemblyReferenceResolver expects as input. - /// - public sealed class ThirdPartyToolMigrationLightweightAssemblyWalkerTests - { - [Test] - public void DiscoverAssemblyStructure_WithNestedAsmdef_ReturnsItsDirectory() - { - // Verifies a single nested .asmdef file's directory is discovered. - string projectRoot = CreateProjectRoot(); - try - { - string toolDirectory = Path.Combine(projectRoot, "Assets", "Editor", "Tool"); - Directory.CreateDirectory(toolDirectory); - File.WriteAllText( - Path.Combine(toolDirectory, "Tool.asmdef"), - "{\"name\": \"Tool\"}"); - - (List asmdefDirectories, _) = - ThirdPartyToolMigrationLightweightAssemblyWalker.DiscoverAssemblyStructure(projectRoot); - - Assert.That(asmdefDirectories, Does.Contain(toolDirectory)); - } - finally - { - Directory.Delete(projectRoot, recursive: true); - } - } - - [Test] - public void DiscoverAssemblyStructure_WithAsmrefPointingAtAsmdef_ReturnsAssemblyReferenceDirectory() - { - // Verifies an .asmref file referencing an .asmdef by name is resolved into an - // AssemblyReferenceDirectory pointing at the asmdef's directory. - string projectRoot = CreateProjectRoot(); - try - { - string asmdefDirectory = Path.Combine(projectRoot, "Assets", "Editor", "Tool"); - string asmrefDirectory = Path.Combine(projectRoot, "Assets", "Editor", "Tool", "SubModule"); - Directory.CreateDirectory(asmdefDirectory); - Directory.CreateDirectory(asmrefDirectory); - File.WriteAllText( - Path.Combine(asmdefDirectory, "Tool.asmdef"), - "{\"name\": \"Tool\"}"); - File.WriteAllText( - Path.Combine(asmrefDirectory, "SubModule.asmref"), - "{\"reference\": \"Tool\"}"); - - (_, List assemblyReferenceDirectories) = - ThirdPartyToolMigrationLightweightAssemblyWalker.DiscoverAssemblyStructure(projectRoot); - - Assert.That(assemblyReferenceDirectories.Count, Is.EqualTo(1)); - Assert.That(assemblyReferenceDirectories[0].SourceDirectory, Is.EqualTo(asmrefDirectory)); - Assert.That(assemblyReferenceDirectories[0].TargetAssemblyDirectory, Is.EqualTo(asmdefDirectory)); - } - finally - { - Directory.Delete(projectRoot, recursive: true); - } - } - - [Test] - public void DiscoverAssemblyStructure_WithNoAssetsDirectory_ReturnsEmptyResults() - { - // Verifies a project root with no Assets directory yet does not throw and returns empty - // results instead. - string projectRoot = Path.Combine( - Path.GetTempPath(), - "UnityCliLoopTests", - "ThirdPartyToolMigrationLightweightAssemblyWalker", - Guid.NewGuid().ToString("N")); - Directory.CreateDirectory(projectRoot); - try - { - (List asmdefDirectories, List assemblyReferenceDirectories) = - ThirdPartyToolMigrationLightweightAssemblyWalker.DiscoverAssemblyStructure(projectRoot); - - Assert.That(asmdefDirectories.Count, Is.EqualTo(0)); - Assert.That(assemblyReferenceDirectories.Count, Is.EqualTo(0)); - } - finally - { - Directory.Delete(projectRoot, recursive: true); - } - } - - private static string CreateProjectRoot() - { - string projectRoot = Path.Combine( - Path.GetTempPath(), - "UnityCliLoopTests", - "ThirdPartyToolMigrationLightweightAssemblyWalker", - Guid.NewGuid().ToString("N")); - Directory.CreateDirectory(projectRoot); - Directory.CreateDirectory(Path.Combine(projectRoot, "Assets")); - return projectRoot; - } - } -} diff --git a/Assets/Tests/Editor/ThirdPartyToolMigrationLightweightAssemblyWalkerTests.cs.meta b/Assets/Tests/Editor/ThirdPartyToolMigrationLightweightAssemblyWalkerTests.cs.meta deleted file mode 100644 index 79a9f47ba..000000000 --- a/Assets/Tests/Editor/ThirdPartyToolMigrationLightweightAssemblyWalkerTests.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: 383697e17ad0646eeb7ef3a5bf3de661 -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: diff --git a/Assets/Tests/Editor/ThirdPartyToolMigrationScanScopeResolverTests.cs b/Assets/Tests/Editor/ThirdPartyToolMigrationScanScopeResolverTests.cs deleted file mode 100644 index b051a27fc..000000000 --- a/Assets/Tests/Editor/ThirdPartyToolMigrationScanScopeResolverTests.cs +++ /dev/null @@ -1,147 +0,0 @@ -using System.Collections.Generic; - -using NUnit.Framework; - -using io.github.hatayama.UnityCliLoop.Infrastructure; - -namespace io.github.hatayama.UnityCliLoop.Tests.Editor -{ - /// - /// Test fixture that verifies seed file paths resolve to a deduplicated set of assembly directories - /// (used to scope the migration plan-building scan to only the assemblies that matter), and that - /// seeds with no real assembly directory (implicit assemblies) are reported via a flag instead of - /// being silently dropped as a nonexistent scan directory. - /// - public sealed class ThirdPartyToolMigrationScanScopeResolverTests - { - private const string ProjectRoot = "/Project"; - - [Test] - public void ResolveScopeAssemblyDirectories_WhenSeedFilesAreUnderSameAsmdefDirectory_ReturnsSingleDirectory() - { - // Verifies that multiple seed files under the same asmdef directory collapse to one scope entry. - List seedFilePaths = new List - { - "/Project/Assets/ToolA/Editor/FileOne.cs", - "/Project/Assets/ToolA/Editor/FileTwo.cs" - }; - List asmdefDirectories = new List { "/Project/Assets/ToolA/Editor" }; - List assemblyReferenceDirectories = new List(); - - (List AssemblyDirectories, bool HasImplicitAssemblySeeds) result = - ThirdPartyToolMigrationScanScopeResolver.ResolveScopeAssemblyDirectories( - seedFilePaths, - asmdefDirectories, - assemblyReferenceDirectories, - ProjectRoot); - - Assert.That(result.AssemblyDirectories, Is.EqualTo(new[] { "/Project/Assets/ToolA/Editor" })); - Assert.That(result.HasImplicitAssemblySeeds, Is.False); - } - - [Test] - public void ResolveScopeAssemblyDirectories_WhenSeedFilesAreUnderDifferentAsmdefDirectories_ReturnsBothDirectories() - { - // Verifies that seed files spanning multiple assemblies produce one scope entry per assembly. - List seedFilePaths = new List - { - "/Project/Assets/ToolA/Editor/FileOne.cs", - "/Project/Assets/ToolB/Editor/FileTwo.cs" - }; - List asmdefDirectories = new List - { - "/Project/Assets/ToolA/Editor", - "/Project/Assets/ToolB/Editor" - }; - List assemblyReferenceDirectories = new List(); - - (List AssemblyDirectories, bool HasImplicitAssemblySeeds) result = - ThirdPartyToolMigrationScanScopeResolver.ResolveScopeAssemblyDirectories( - seedFilePaths, - asmdefDirectories, - assemblyReferenceDirectories, - ProjectRoot); - - Assert.That( - result.AssemblyDirectories, - Is.EqualTo(new[] { "/Project/Assets/ToolA/Editor", "/Project/Assets/ToolB/Editor" })); - Assert.That(result.HasImplicitAssemblySeeds, Is.False); - } - - [Test] - public void ResolveScopeAssemblyDirectories_WhenSeedFileHasNoAsmdef_SetsImplicitAssemblySeedsFlagInsteadOfAScopeDirectory() - { - // Verifies that a seed file outside any asmdef directory resolves to the synthetic implicit - // assembly marker (not a real directory on disk), so it must be reported via the - // HasImplicitAssemblySeeds flag instead of being added to AssemblyDirectories — adding the - // synthetic marker there would make the scoped scan silently skip it as "directory not found" - // instead of falling back to a full scan. - List seedFilePaths = new List { "/Project/Assets/Editor/LooseFile.cs" }; - List asmdefDirectories = new List(); - List assemblyReferenceDirectories = new List(); - - (List AssemblyDirectories, bool HasImplicitAssemblySeeds) result = - ThirdPartyToolMigrationScanScopeResolver.ResolveScopeAssemblyDirectories( - seedFilePaths, - asmdefDirectories, - assemblyReferenceDirectories, - ProjectRoot); - - Assert.That(result.AssemblyDirectories, Is.Empty); - Assert.That(result.HasImplicitAssemblySeeds, Is.True); - } - - [Test] - public void ResolveScopeAssemblyDirectories_WhenSeedFilesMixRealAsmdefAndImplicit_ReturnsRealDirectoryAndSetsFlag() - { - // Verifies that a mix of a real-asmdef seed and an implicit-assembly seed still reports the - // real assembly directory while also setting HasImplicitAssemblySeeds, so a caller knows the - // returned directory list alone is not a complete/safe scope. - List seedFilePaths = new List - { - "/Project/Assets/ToolA/Editor/FileOne.cs", - "/Project/Assets/Editor/LooseFile.cs" - }; - List asmdefDirectories = new List { "/Project/Assets/ToolA/Editor" }; - List assemblyReferenceDirectories = new List(); - - (List AssemblyDirectories, bool HasImplicitAssemblySeeds) result = - ThirdPartyToolMigrationScanScopeResolver.ResolveScopeAssemblyDirectories( - seedFilePaths, - asmdefDirectories, - assemblyReferenceDirectories, - ProjectRoot); - - Assert.That(result.AssemblyDirectories, Is.EqualTo(new[] { "/Project/Assets/ToolA/Editor" })); - Assert.That(result.HasImplicitAssemblySeeds, Is.True); - } - - [Test] - public void ResolveScopeAssemblyDirectories_WhenSeedFilePathsIsEmpty_ReturnsEmptyListAndNoImplicitFlag() - { - // Verifies that no seed files produce no scope and no implicit-assembly flag, rather than - // accidentally scoping to the whole project or forcing an unnecessary fallback. - (List AssemblyDirectories, bool HasImplicitAssemblySeeds) result = - ThirdPartyToolMigrationScanScopeResolver.ResolveScopeAssemblyDirectories( - new List(), - new List(), - new List(), - ProjectRoot); - - Assert.That(result.AssemblyDirectories, Is.Empty); - Assert.That(result.HasImplicitAssemblySeeds, Is.False); - } - - [Test] - public void ResolveScopeAssemblyDirectories_WhenSeedFilePathsIsNull_Throws() - { - // Verifies fail-fast behavior when the seed file collection itself is missing. - Assert.Throws(() => - ThirdPartyToolMigrationScanScopeResolver.ResolveScopeAssemblyDirectories( - null, - new List(), - new List(), - ProjectRoot)); - } - } -} diff --git a/Assets/Tests/Editor/ThirdPartyToolMigrationScanScopeResolverTests.cs.meta b/Assets/Tests/Editor/ThirdPartyToolMigrationScanScopeResolverTests.cs.meta deleted file mode 100644 index 687302d05..000000000 --- a/Assets/Tests/Editor/ThirdPartyToolMigrationScanScopeResolverTests.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: 7e4e8cf40854c453c87c7bb0fb4d4abb -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: diff --git a/Assets/Tests/Editor/ThirdPartyToolMigrationScopedPreviewTests.cs b/Assets/Tests/Editor/ThirdPartyToolMigrationScopedPreviewTests.cs deleted file mode 100644 index f6ae16a0b..000000000 --- a/Assets/Tests/Editor/ThirdPartyToolMigrationScopedPreviewTests.cs +++ /dev/null @@ -1,265 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Threading; -using System.Threading.Tasks; - -using NUnit.Framework; - -using io.github.hatayama.UnityCliLoop.Domain; -using io.github.hatayama.UnityCliLoop.Infrastructure; - -namespace io.github.hatayama.UnityCliLoop.Tests.Editor -{ - /// - /// Verifies the scope-limited migration inventory/plan/preview path used by compile-error-driven - /// detection, which must only scan the assemblies containing matched files instead of the whole project. - /// - public sealed class ThirdPartyToolMigrationScopedPreviewTests - { - [Test] - public async Task CreateFromDirectoriesAsync_WhenGivenOneOfTwoToolDirectories_OnlyCollectsFilesUnderThatDirectory() - { - // Verifies that scoped inventory creation ignores files outside the given scope directories. - string projectRoot = CreateProjectRoot(); - try - { - string scopedToolDirectory = Path.Combine(projectRoot, "Assets", "ScopedTool"); - string otherToolDirectory = Path.Combine(projectRoot, "Assets", "OtherTool"); - Directory.CreateDirectory(scopedToolDirectory); - Directory.CreateDirectory(otherToolDirectory); - string scopedFilePath = Path.Combine(scopedToolDirectory, "ScopedFile.cs"); - string otherFilePath = Path.Combine(otherToolDirectory, "OtherFile.cs"); - File.WriteAllText(scopedFilePath, "public sealed class ScopedFile {}"); - File.WriteAllText(otherFilePath, "public sealed class OtherFile {}"); - - ProjectFileInventory inventory = await ProjectFileInventory.CreateFromDirectoriesAsync( - new List { scopedToolDirectory }, - projectRoot, - new Progress(), - CancellationToken.None); - - Assert.That(inventory.CSharpFilePaths, Does.Contain(scopedFilePath)); - Assert.That(inventory.CSharpFilePaths, Has.No.Member(otherFilePath)); - } - finally - { - Directory.Delete(projectRoot, recursive: true); - } - } - - [Test] - public async Task CreateFromDirectoriesAsync_WhenGivenMultipleScopeDirectories_MergesResultsFromBoth() - { - // Verifies that scoped inventory creation merges files from every given scope directory. - string projectRoot = CreateProjectRoot(); - try - { - string toolADirectory = Path.Combine(projectRoot, "Assets", "ToolA"); - string toolBDirectory = Path.Combine(projectRoot, "Assets", "ToolB"); - Directory.CreateDirectory(toolADirectory); - Directory.CreateDirectory(toolBDirectory); - string toolAFilePath = Path.Combine(toolADirectory, "ToolAFile.cs"); - string toolBFilePath = Path.Combine(toolBDirectory, "ToolBFile.cs"); - File.WriteAllText(toolAFilePath, "public sealed class ToolAFile {}"); - File.WriteAllText(toolBFilePath, "public sealed class ToolBFile {}"); - - ProjectFileInventory inventory = await ProjectFileInventory.CreateFromDirectoriesAsync( - new List { toolADirectory, toolBDirectory }, - projectRoot, - new Progress(), - CancellationToken.None); - - Assert.That(inventory.CSharpFilePaths, Does.Contain(toolAFilePath)); - Assert.That(inventory.CSharpFilePaths, Does.Contain(toolBFilePath)); - } - finally - { - Directory.Delete(projectRoot, recursive: true); - } - } - - [Test] - public async Task CreateFromDirectoriesAsync_WhenScopeDirectoryDoesNotExist_SkipsItWithoutThrowing() - { - // Verifies that a stale/removed scope directory is skipped rather than causing a scan failure. - string projectRoot = CreateProjectRoot(); - try - { - string missingDirectory = Path.Combine(projectRoot, "Assets", "MissingTool"); - - ProjectFileInventory inventory = await ProjectFileInventory.CreateFromDirectoriesAsync( - new List { missingDirectory }, - projectRoot, - new Progress(), - CancellationToken.None); - - Assert.That(inventory.CSharpFilePaths, Is.Empty); - } - finally - { - Directory.Delete(projectRoot, recursive: true); - } - } - - [Test] - public async Task PreviewMigrationInScopeAsync_WhenLegacyToolIsOutsideScope_DoesNotReportItAsATarget() - { - // Verifies that PreviewMigrationInScopeAsync only inspects files under the given scope - // directories, so a legacy tool outside the scope is invisible to the scoped preview. - string projectRoot = CreateProjectRoot(); - try - { - string scopedToolDirectory = Path.Combine(projectRoot, "Assets", "ScopedTool"); - string outOfScopeToolDirectory = Path.Combine(projectRoot, "Assets", "OutOfScopeTool"); - Directory.CreateDirectory(scopedToolDirectory); - Directory.CreateDirectory(outOfScopeToolDirectory); - File.WriteAllText( - Path.Combine(scopedToolDirectory, "InScopeTool.cs"), - "public sealed class InScopeTool {}"); - File.WriteAllText( - Path.Combine(outOfScopeToolDirectory, "OutOfScopeTool.cs"), - LegacyToolSource); - - ThirdPartyToolMigrationFileService service = new(); - ThirdPartyToolMigrationPreview preview = await service.PreviewMigrationInScopeAsync( - projectRoot, - new List { scopedToolDirectory }, - new Progress(), - CancellationToken.None); - - Assert.That(preview.HasTargets, Is.False); - } - finally - { - Directory.Delete(projectRoot, recursive: true); - } - } - - [Test] - public async Task PreviewMigrationInScopeAsync_WhenLegacyToolIsInsideScope_ReportsItAsATarget() - { - // Verifies that PreviewMigrationInScopeAsync detects and reports a legacy tool file that - // is under one of the given scope directories. - string projectRoot = CreateProjectRoot(); - try - { - string scopedToolDirectory = Path.Combine(projectRoot, "Assets", "ScopedTool"); - Directory.CreateDirectory(scopedToolDirectory); - string legacyToolPath = Path.Combine(scopedToolDirectory, "LegacyTool.cs"); - File.WriteAllText(legacyToolPath, LegacyToolSource); - - ThirdPartyToolMigrationFileService service = new(); - ThirdPartyToolMigrationPreview preview = await service.PreviewMigrationInScopeAsync( - projectRoot, - new List { scopedToolDirectory }, - new Progress(), - CancellationToken.None); - - Assert.That(preview.HasTargets, Is.True); - Assert.That(preview.FilePaths, Does.Contain(legacyToolPath)); - } - finally - { - Directory.Delete(projectRoot, recursive: true); - } - } - - [Test] - public async Task PreviewMigrationForSeedFilesAsync_WhenSeedHasNoAsmdefAncestor_FallsBackToFullProjectScan() - { - // Verifies that a seed file with no asmdef/asmref ancestor (an implicit-assembly seed) - // makes PreviewMigrationForSeedFilesAsync fall back to a full-project scan instead of a - // scope-limited one, so the legacy target is still detected. - string projectRoot = CreateProjectRoot(); - try - { - string legacyToolPath = Path.Combine(projectRoot, "Assets", "LegacyTool.cs"); - File.WriteAllText(legacyToolPath, LegacyToolSource); - - ThirdPartyToolMigrationFileService service = new(); - ThirdPartyToolMigrationPreview preview = await service.PreviewMigrationForSeedFilesAsync( - projectRoot, - new List { legacyToolPath }, - new Progress(), - CancellationToken.None); - - Assert.That(preview.HasTargets, Is.True); - Assert.That(preview.FilePaths, Does.Contain(legacyToolPath)); - } - finally - { - Directory.Delete(projectRoot, recursive: true); - } - } - - [Test] - public async Task PreviewMigrationForSeedFilesAsync_WhenSeedHasAnAsmdefAncestor_ScansOnlyThatAssembly() - { - // Verifies that a seed file under a real asmdef-defined assembly makes - // PreviewMigrationForSeedFilesAsync use the scope-limited path, so a legacy tool in a - // sibling assembly (outside the scope) is not reported. - string projectRoot = CreateProjectRoot(); - try - { - string scopedToolDirectory = Path.Combine(projectRoot, "Assets", "ScopedTool"); - string outOfScopeToolDirectory = Path.Combine(projectRoot, "Assets", "OutOfScopeTool"); - Directory.CreateDirectory(scopedToolDirectory); - Directory.CreateDirectory(outOfScopeToolDirectory); - string legacyToolPath = Path.Combine(scopedToolDirectory, "LegacyTool.cs"); - File.WriteAllText(legacyToolPath, LegacyToolSource); - File.WriteAllText( - Path.Combine(scopedToolDirectory, "ScopedTool.asmdef"), - "{\"name\": \"ScopedTool\"}"); - File.WriteAllText( - Path.Combine(outOfScopeToolDirectory, "OutOfScopeTool.cs"), - LegacyToolSource); - - ThirdPartyToolMigrationFileService service = new(); - ThirdPartyToolMigrationPreview preview = await service.PreviewMigrationForSeedFilesAsync( - projectRoot, - new List { legacyToolPath }, - new Progress(), - CancellationToken.None); - - Assert.That(preview.HasTargets, Is.True); - Assert.That(preview.FilePaths, Does.Contain(legacyToolPath)); - Assert.That( - preview.FilePaths, - Has.No.Member(Path.Combine(outOfScopeToolDirectory, "OutOfScopeTool.cs"))); - } - finally - { - Directory.Delete(projectRoot, recursive: true); - } - } - - private const string LegacyToolSource = @"using io.github.hatayama.uLoopMCP; - -[McpTool] -public sealed class LegacyTool : AbstractUnityTool -{ -} - -public sealed class LegacySchema : BaseToolSchema -{ -} - -public sealed class LegacyResponse : BaseToolResponse -{ -}"; - - private static string CreateProjectRoot() - { - string projectRoot = Path.Combine( - Path.GetTempPath(), - "UnityCliLoopTests", - "ThirdPartyToolMigrationScopedPreview", - Guid.NewGuid().ToString("N")); - Directory.CreateDirectory(projectRoot); - Directory.CreateDirectory(Path.Combine(projectRoot, "ProjectSettings")); - Directory.CreateDirectory(Path.Combine(projectRoot, "Assets")); - return projectRoot; - } - } -} diff --git a/Assets/Tests/Editor/ThirdPartyToolMigrationScopedPreviewTests.cs.meta b/Assets/Tests/Editor/ThirdPartyToolMigrationScopedPreviewTests.cs.meta deleted file mode 100644 index 8bb45da1a..000000000 --- a/Assets/Tests/Editor/ThirdPartyToolMigrationScopedPreviewTests.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: b49c81c836f9e496f8862f2336c0cc26 -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: diff --git a/Assets/Tests/Editor/ThirdPartyToolMigrationWizardWorkflowControllerTests.cs b/Assets/Tests/Editor/ThirdPartyToolMigrationWizardWorkflowControllerTests.cs index 2a3ecbf53..a4b29b6f0 100644 --- a/Assets/Tests/Editor/ThirdPartyToolMigrationWizardWorkflowControllerTests.cs +++ b/Assets/Tests/Editor/ThirdPartyToolMigrationWizardWorkflowControllerTests.cs @@ -13,32 +13,31 @@ namespace io.github.hatayama.UnityCliLoop.Tests.Editor { /// - /// Verifies that only the first RefreshUI call after an auto-scan window-open uses the - /// compile-error-matched seed file paths for a scoped scan; a later manual re-check falls back - /// to a full-project scan (empty seed list) since the seeds are cleared after first use. + /// Verifies that an auto-scan window open renders the compile-error-matched seed file paths + /// directly, without ever running a scan, and that RefreshUI (the manual Check / re-check path) + /// always performs a full, unscoped project scan regardless of any seed state. /// public sealed class ThirdPartyToolMigrationWizardWorkflowControllerTests { [Test] - public async Task RefreshUI_WhenCalledFirstTime_PassesAutoScanSeedFilePathsToPreview() + public void ShowInitialState_WhenShouldShowAutoScanDetectedState_DoesNotTriggerAnyPreviewCall() { - // Verifies that the first RefreshUI call after an auto-scan open scopes the scan to the - // seed file paths supplied by the constructor. + // Verifies that showing the auto-scan detected state on window open never calls into the + // migration port (no preview scan runs before Migrate is clicked). RecordingThirdPartyToolMigrationPort port = new(); ThirdPartyToolMigrationWizardWorkflowController controller = CreateController(port, new List { "/Project/Assets/Tool.cs" }); - await controller.RefreshUI(); + controller.ShowInitialState(shouldShowAutoScanDetectedState: true); - Assert.That(port.SeedFilePathsPerCall, Has.Count.EqualTo(1)); - Assert.That(port.SeedFilePathsPerCall[0], Is.EqualTo(new[] { "/Project/Assets/Tool.cs" })); + Assert.That(port.PreviewMigrationAsyncCallCount, Is.EqualTo(0)); } [Test] - public async Task RefreshUI_WhenCalledASecondTime_PassesEmptySeedFilePaths() + public async Task RefreshUI_AlwaysPerformsAFullUnscopedProjectScan() { - // Verifies that a manual re-check after the initial auto-scoped scan falls back to a - // full-project scan, since the seed file paths are cleared after their first use. + // Verifies that the manual Check / re-check path always calls the full-project preview, + // regardless of any auto-scan seed file paths supplied at construction time. RecordingThirdPartyToolMigrationPort port = new(); ThirdPartyToolMigrationWizardWorkflowController controller = CreateController(port, new List { "/Project/Assets/Tool.cs" }); @@ -46,8 +45,7 @@ public async Task RefreshUI_WhenCalledASecondTime_PassesEmptySeedFilePaths() await controller.RefreshUI(); await controller.RefreshUI(); - Assert.That(port.SeedFilePathsPerCall, Has.Count.EqualTo(2)); - Assert.That(port.SeedFilePathsPerCall[1], Is.Empty); + Assert.That(port.PreviewMigrationAsyncCallCount, Is.EqualTo(2)); } private static ThirdPartyToolMigrationWizardWorkflowController CreateController( @@ -75,7 +73,7 @@ private static ThirdPartyToolMigrationWizardWorkflowController CreateController( private sealed class RecordingThirdPartyToolMigrationPort : IThirdPartyToolMigrationPort { - internal readonly List> SeedFilePathsPerCall = new(); + internal int PreviewMigrationAsyncCallCount { get; private set; } public ThirdPartyToolMigrationPreview PreviewMigration(string projectRoot) { @@ -87,16 +85,7 @@ public Task PreviewMigrationAsync( IProgress progress, CancellationToken ct) { - return Task.FromResult(new ThirdPartyToolMigrationPreview(0, 0, Array.Empty())); - } - - public Task PreviewMigrationForSeedFilesAsync( - string projectRoot, - List seedFilePaths, - IProgress progress, - CancellationToken ct) - { - SeedFilePathsPerCall.Add(seedFilePaths); + PreviewMigrationAsyncCallCount++; return Task.FromResult(new ThirdPartyToolMigrationPreview(0, 0, Array.Empty())); } diff --git a/Packages/src/Editor/Application/UseCases/ThirdPartyToolMigrationUseCase.cs b/Packages/src/Editor/Application/UseCases/ThirdPartyToolMigrationUseCase.cs index 4fa6722e5..43a018c3a 100644 --- a/Packages/src/Editor/Application/UseCases/ThirdPartyToolMigrationUseCase.cs +++ b/Packages/src/Editor/Application/UseCases/ThirdPartyToolMigrationUseCase.cs @@ -40,19 +40,6 @@ public Task PreviewMigrationAsync( return _migrationPort.PreviewMigrationAsync(projectRoot, progress, ct); } - public Task PreviewMigrationForSeedFilesAsync( - string projectRoot, - List seedFilePaths, - IProgress progress, - CancellationToken ct) - { - Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty"); - Debug.Assert(seedFilePaths != null, "seedFilePaths must not be null"); - Debug.Assert(progress != null, "progress must not be null"); - - return _migrationPort.PreviewMigrationForSeedFilesAsync(projectRoot, seedFilePaths, progress, ct); - } - public (bool Found, List TargetFilePaths) TryDetectAutoScanTargetsFromCompileErrors( string projectRoot) { diff --git a/Packages/src/Editor/Domain/ThirdPartyToolMigrationData.cs b/Packages/src/Editor/Domain/ThirdPartyToolMigrationData.cs index f689c1460..7a35066cc 100644 --- a/Packages/src/Editor/Domain/ThirdPartyToolMigrationData.cs +++ b/Packages/src/Editor/Domain/ThirdPartyToolMigrationData.cs @@ -83,17 +83,6 @@ Task PreviewMigrationAsync( IProgress progress, CancellationToken ct); - /// - /// Builds a preview scoped to the assemblies containing the given seed files (e.g. compile- - /// error-matched migration targets), falling back to a full-project scan when the seeds do not - /// resolve to a safe, complete scope (see ThirdPartyToolMigrationScanScopeResolver). - /// - Task PreviewMigrationForSeedFilesAsync( - string projectRoot, - List seedFilePaths, - IProgress progress, - CancellationToken ct); - /// /// Checks whether the most recent compile failure was caused by V2 legacy custom-tool APIs, by /// matching Unity Console error text against known legacy tokens (mirrors Unity's own API diff --git a/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationFileService.cs b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationFileService.cs index ecc6871e5..54b8cf2ce 100644 --- a/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationFileService.cs +++ b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationFileService.cs @@ -71,80 +71,6 @@ public async Task PreviewMigrationAsync( return preview; } - /// - /// Builds a preview scanning only the given assembly directories (e.g. the assemblies containing - /// compile-error-matched files), instead of the whole project. Deliberately does not read or - /// write the full-scan preview/plan cache above: a scope-limited plan is only a partial view of - /// the project and must never be mistaken for (or substituted into) the full plan that - /// ApplyMigration relies on. - /// - public async Task PreviewMigrationInScopeAsync( - string projectRoot, - List scopeDirectories, - IProgress progress, - CancellationToken ct) - { - Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty"); - Debug.Assert(scopeDirectories != null, "scopeDirectories must not be null"); - Debug.Assert(progress != null, "progress must not be null"); - - string normalizedProjectRoot = NormalizeProjectRoot(projectRoot); - MigrationPlan plan = await ThirdPartyToolMigrationPlanBuilder.CreateInScopeAsync( - normalizedProjectRoot, - scopeDirectories, - progress, - ct); - if (ct.IsCancellationRequested) - { - return new ThirdPartyToolMigrationPreview(0, 0, Array.Empty()); - } - - return new ThirdPartyToolMigrationPreview( - plan.ChangedFilePaths.Count, - plan.ReplacementCount, - plan.ChangedFilePaths.ToArray()); - } - - /// - /// Resolves seed files (e.g. compile-error-matched migration targets) to the assembly - /// directories that contain them via a lightweight .asmdef/.asmref-only walk, then scans only - /// those directories. Falls back to a full-project scan when the seeds don't resolve to a - /// complete, safe scope (HasImplicitAssemblySeeds; see ThirdPartyToolMigrationScanScopeResolver) - /// or when no seed files were supplied at all. - /// - public async Task PreviewMigrationForSeedFilesAsync( - string projectRoot, - List seedFilePaths, - IProgress progress, - CancellationToken ct) - { - Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty"); - Debug.Assert(seedFilePaths != null, "seedFilePaths must not be null"); - Debug.Assert(progress != null, "progress must not be null"); - - string normalizedProjectRoot = NormalizeProjectRoot(projectRoot); - if (seedFilePaths.Count == 0) - { - return await PreviewMigrationAsync(normalizedProjectRoot, progress, ct); - } - - (List asmdefDirectories, List assemblyReferenceDirectories) = - ThirdPartyToolMigrationLightweightAssemblyWalker.DiscoverAssemblyStructure(normalizedProjectRoot); - (List scopeDirectories, bool hasImplicitAssemblySeeds) = - ThirdPartyToolMigrationScanScopeResolver.ResolveScopeAssemblyDirectories( - seedFilePaths, - asmdefDirectories, - assemblyReferenceDirectories, - normalizedProjectRoot); - - if (hasImplicitAssemblySeeds) - { - return await PreviewMigrationAsync(normalizedProjectRoot, progress, ct); - } - - return await PreviewMigrationInScopeAsync(normalizedProjectRoot, scopeDirectories, progress, ct); - } - public (bool Found, List TargetFilePaths) TryDetectAutoScanTargetsFromCompileErrors( string projectRoot) { diff --git a/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationLightweightAssemblyWalker.cs b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationLightweightAssemblyWalker.cs deleted file mode 100644 index ffbb8d0b4..000000000 --- a/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationLightweightAssemblyWalker.cs +++ /dev/null @@ -1,52 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.IO; -using System.Linq; - -namespace io.github.hatayama.UnityCliLoop.Infrastructure -{ - /// - /// Discovers .asmdef/.asmref directory structure only (no C# source files), so the compile-error- - /// driven auto-scan can resolve seed files to assembly directories without the full project file - /// walk that ThirdPartyToolMigrationProjectFileInventory performs. - /// - internal static class ThirdPartyToolMigrationLightweightAssemblyWalker - { - private const string AssetsDirectoryName = "Assets"; - private const string AsmdefSearchPattern = "*.asmdef"; - private const string AsmrefSearchPattern = "*.asmref"; - - internal static (List AsmdefDirectories, List AssemblyReferenceDirectories) - DiscoverAssemblyStructure(string projectRoot) - { - Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty"); - - string assetsDirectory = Path.Combine(projectRoot, AssetsDirectoryName); - if (!Directory.Exists(assetsDirectory)) - { - return (new List(), new List()); - } - - List asmdefFilePaths = Directory - .GetFiles(assetsDirectory, AsmdefSearchPattern, SearchOption.AllDirectories) - .ToList(); - List asmrefFilePaths = Directory - .GetFiles(assetsDirectory, AsmrefSearchPattern, SearchOption.AllDirectories) - .ToList(); - - List asmdefDirectories = asmdefFilePaths - .Select(filePath => Path.GetDirectoryName(filePath) ?? string.Empty) - .Where(directory => directory.Length > 0) - .Distinct(StringComparer.Ordinal) - .ToList(); - - List assemblyReferenceDirectories = - ThirdPartyToolMigrationAssemblyReferenceResolver.CreateAssemblyReferenceDirectories( - asmdefFilePaths, - asmrefFilePaths); - - return (asmdefDirectories, assemblyReferenceDirectories); - } - } -} diff --git a/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationLightweightAssemblyWalker.cs.meta b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationLightweightAssemblyWalker.cs.meta deleted file mode 100644 index bbbb2c55a..000000000 --- a/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationLightweightAssemblyWalker.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: e3b9580ecf28841f3be7b1c3d53fd37d -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: diff --git a/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationPlanBuilder.cs b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationPlanBuilder.cs index ab46890b9..ecd7f0e21 100644 --- a/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationPlanBuilder.cs +++ b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationPlanBuilder.cs @@ -84,40 +84,6 @@ internal static async Task CreateAsync( return await BuildPlanFromInventoryAsync(projectRoot, inventory, progress, ct); } - /// - /// Builds a plan limited to the given assembly directories (e.g. the assemblies containing - /// compile-error-matched files) instead of scanning the whole project. The full-scan entry - /// points above (Create/CreateAsync) are untouched and remain the source of truth for the - /// manual "scan whole project" flow. - /// - internal static async Task CreateInScopeAsync( - string projectRoot, - List scopeDirectories, - IProgress progress, - CancellationToken ct) - { - Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty"); - Debug.Assert(scopeDirectories != null, "scopeDirectories must not be null"); - Debug.Assert(progress != null, "progress must not be null"); - - if (!Directory.Exists(projectRoot)) - { - throw new DirectoryNotFoundException(projectRoot); - } - - ProjectFileInventory inventory = await ProjectFileInventory.CreateFromDirectoriesAsync( - scopeDirectories, - projectRoot, - progress, - ct); - if (ct.IsCancellationRequested) - { - return MigrationPlan.Empty; - } - - return await BuildPlanFromInventoryAsync(projectRoot, inventory, progress, ct); - } - private static async Task BuildPlanFromInventoryAsync( string projectRoot, ProjectFileInventory inventory, diff --git a/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationProjectFileInventory.cs b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationProjectFileInventory.cs index 0a4e1f432..886a5185d 100644 --- a/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationProjectFileInventory.cs +++ b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationProjectFileInventory.cs @@ -71,59 +71,6 @@ await WalkDirectoryTreeAsync( asmdefFilePaths, asmrefFilePaths, progress, - inspectedEntryCount: 0, - ct); - } - - csharpFilePaths.Sort(StringComparer.Ordinal); - asmdefFilePaths.Sort(StringComparer.Ordinal); - asmrefFilePaths.Sort(StringComparer.Ordinal); - return new ProjectFileInventory(csharpFilePaths, asmdefFilePaths, asmrefFilePaths); - } - - /// - /// Builds an inventory limited to the given assembly directories (e.g. the assemblies containing - /// compile-error-matched files), instead of walking the entire Assets tree. Used to speed up - /// migration-plan construction once the seed files that need migration are already known. - /// - internal static async Task CreateFromDirectoriesAsync( - List scopeDirectories, - string projectRoot, - IProgress progress, - CancellationToken ct) - { - Debug.Assert(scopeDirectories != null, "scopeDirectories must not be null"); - Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty"); - Debug.Assert(progress != null, "progress must not be null"); - - List csharpFilePaths = new(); - List asmdefFilePaths = new(); - List asmrefFilePaths = new(); - int inspectedEntryCount = 0; - progress.Report(new ThirdPartyToolMigrationProgress(inspectedEntryCount, 0)); - - foreach (string scopeDirectory in scopeDirectories) - { - if (ct.IsCancellationRequested) - { - break; - } - - if (!Directory.Exists(scopeDirectory)) - { - // A resolved scope directory may no longer exist if files moved/deleted between - // detection and scan; skip it rather than failing the whole scoped scan. - continue; - } - - inspectedEntryCount = await WalkDirectoryTreeAsync( - projectRoot, - scopeDirectory, - csharpFilePaths, - asmdefFilePaths, - asmrefFilePaths, - progress, - inspectedEntryCount, ct); } @@ -185,17 +132,15 @@ private static void CollectCandidateFiles( /// /// Walks a single directory tree, collecting candidate migration files and reporting progress - /// as a running total across possibly-multiple calls (see CreateFromDirectoriesAsync, which walks - /// several scope directories one after another and threads the count between calls). + /// as a running total of inspected entries. /// - private static async Task WalkDirectoryTreeAsync( + private static async Task WalkDirectoryTreeAsync( string projectRoot, string startDirectory, List csharpFilePaths, List asmdefFilePaths, List asmrefFilePaths, IProgress progress, - int inspectedEntryCount, CancellationToken ct) { Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty"); @@ -207,12 +152,13 @@ private static async Task WalkDirectoryTreeAsync( Stack pendingDirectories = new(); pendingDirectories.Push(startDirectory); + int inspectedEntryCount = 0; while (pendingDirectories.Count > 0) { if (ct.IsCancellationRequested) { - return inspectedEntryCount; + return; } string directoryPath = pendingDirectories.Pop(); @@ -243,8 +189,6 @@ private static async Task WalkDirectoryTreeAsync( } } } - - return inspectedEntryCount; } private static void AddCandidateFilePath( diff --git a/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationScanScopeResolver.cs b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationScanScopeResolver.cs deleted file mode 100644 index a16c4cd30..000000000 --- a/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationScanScopeResolver.cs +++ /dev/null @@ -1,93 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.IO; - -namespace io.github.hatayama.UnityCliLoop.Infrastructure -{ - /// - /// Resolves the assembly directories that a set of seed files (e.g. compile-error-matched - /// migration target files) belong to, so plan construction can scan only those assemblies - /// instead of the whole project. Reuses - /// ThirdPartyToolMigrationAssemblyReferenceResolver.FindNearestAssemblyDirectory per seed file - /// and deduplicates the results. - /// - internal static class ThirdPartyToolMigrationScanScopeResolver - { - /// - /// A seed file with no real asmdef/asmref ancestor resolves to a synthetic implicit-assembly - /// marker path (see ThirdPartyToolMigrationFileServiceConstants) that never exists on disk. - /// That marker must never be treated as a real scan directory: a scoped file-tree walk skips - /// nonexistent directories, so silently including it would make a legitimate implicit-assembly - /// migration target vanish from the scoped scan instead of falling back to a full scan. - /// HasImplicitAssemblySeeds tells the caller that AssemblyDirectories alone is not a complete, - /// safe scope in that case. - /// - internal static (List AssemblyDirectories, bool HasImplicitAssemblySeeds) ResolveScopeAssemblyDirectories( - List seedFilePaths, - List asmdefDirectories, - List assemblyReferenceDirectories, - string projectRoot) - { - Debug.Assert(seedFilePaths != null, "seedFilePaths must not be null"); - Debug.Assert(asmdefDirectories != null, "asmdefDirectories must not be null"); - Debug.Assert( - assemblyReferenceDirectories != null, - "assemblyReferenceDirectories must not be null"); - Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty"); - - if (seedFilePaths == null) - { - throw new ArgumentNullException(nameof(seedFilePaths)); - } - - HashSet seenAssemblyDirectories = new HashSet(StringComparer.Ordinal); - List scopeAssemblyDirectories = new List(); - bool hasImplicitAssemblySeeds = false; - foreach (string seedFilePath in seedFilePaths) - { - string assemblyDirectory = ThirdPartyToolMigrationAssemblyReferenceResolver.FindNearestAssemblyDirectory( - seedFilePath, - asmdefDirectories, - assemblyReferenceDirectories, - projectRoot); - - if (IsImplicitAssemblyDirectory(assemblyDirectory)) - { - hasImplicitAssemblySeeds = true; - continue; - } - - if (seenAssemblyDirectories.Add(assemblyDirectory)) - { - scopeAssemblyDirectories.Add(assemblyDirectory); - } - } - - return (scopeAssemblyDirectories, hasImplicitAssemblySeeds); - } - - private static bool IsImplicitAssemblyDirectory(string assemblyDirectory) - { - Debug.Assert(!string.IsNullOrEmpty(assemblyDirectory), "assemblyDirectory must not be null or empty"); - - string directoryName = Path.GetFileName(assemblyDirectory); - return string.Equals( - directoryName, - ThirdPartyToolMigrationFileServiceConstants.ImplicitEditorAssemblyDirectoryName, - StringComparison.Ordinal) || - string.Equals( - directoryName, - ThirdPartyToolMigrationFileServiceConstants.ImplicitRuntimeAssemblyDirectoryName, - StringComparison.Ordinal) || - string.Equals( - directoryName, - ThirdPartyToolMigrationFileServiceConstants.ImplicitFirstPassEditorAssemblyDirectoryName, - StringComparison.Ordinal) || - string.Equals( - directoryName, - ThirdPartyToolMigrationFileServiceConstants.ImplicitFirstPassRuntimeAssemblyDirectoryName, - StringComparison.Ordinal); - } - } -} diff --git a/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationScanScopeResolver.cs.meta b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationScanScopeResolver.cs.meta deleted file mode 100644 index 45a35de08..000000000 --- a/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationScanScopeResolver.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: dca5e4234a7304252b9b8a264e0e5233 -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: diff --git a/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardText.cs b/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardText.cs index 3c43c8220..ddcdb883f 100644 --- a/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardText.cs +++ b/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardText.cs @@ -54,10 +54,37 @@ internal static string GetMigrationStatusText(int fileCount) return $"Found {fileCount} C# {noun} that {verb} V3 migration."; } + /// + /// Status shown right after an auto-scan window open: this reflects a compile-error log match, + /// not a project scan, so the wording says "detected" rather than "found" (Migrate performs the + /// actual scan). fileCount is 0 for the timeout-fallback path, where HasMigrationTargetsAsync + /// already confirmed migration is needed but no specific file list is known yet. + /// + internal static string GetAutoScanDetectedStatusText(int fileCount) + { + Debug.Assert(fileCount >= 0, "fileCount must not be negative"); + + if (fileCount == 0) + { + return "Detected legacy V3 custom tool API usage from a compile error. " + + "Click Migrate to scan the project and update the affected files."; + } + + string noun = fileCount == 1 ? "file" : "files"; + return $"Detected {fileCount} C# {noun} using legacy V3 custom tool APIs from a compile error. " + + "Click Migrate to scan the project and update them."; + } + internal static string GetMigrationConfirmDialogMessage(int fileCount) { Debug.Assert(fileCount >= 0, "fileCount must not be negative"); + if (fileCount == 0) + { + return "Files with legacy V3 custom tool API usage will be scanned and rewritten in place.\n\n" + + "Commit or back up your project first (VCS recommended)."; + } + string noun = fileCount == 1 ? "file" : "files"; return $"{fileCount} {noun} will be rewritten in place.\n\n" + "Commit or back up your project first (VCS recommended)."; diff --git a/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardView.cs b/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardView.cs index 45c482d6d..04ac4f058 100644 --- a/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardView.cs +++ b/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardView.cs @@ -161,6 +161,29 @@ internal void ShowMigrationTargetsState(string[] filePaths, bool isMigrating) _refreshButton.SetEnabled(false); } + /// + /// Shown right after an auto-scan window open: no scan has run yet, so the Check button stays + /// enabled (unlike ShowMigrationTargetsState) in case the user wants a verified full-project + /// scan before clicking Migrate. + /// + internal void ShowAutoScanDetectedState(string[] filePaths, bool isMigrating) + { + Debug.Assert(filePaths != null, "filePaths must not be null"); + + _migrationStatusTextField.SetValueWithoutNotify( + ThirdPartyToolMigrationWizardText.GetAutoScanDetectedStatusText(filePaths.Length)); + ViewDataBinder.SetVisible(_migrationProgressBar, false); + ViewDataBinder.SetVisible(_migrationButtonRow, true); + _migrateButton.SetEnabled(!isMigrating); + _migrateButton.text = ThirdPartyToolMigrationWizardText.GetMigrationButtonText( + isMigrating, + true, + true); + ViewDataBinder.SetVisible(_refreshButton, true); + ViewDataBinder.SetVisible(_closeButton, false); + _refreshButton.SetEnabled(true); + } + internal void ShowNoMigrationTargetsState(bool isMigrating) { _migrationStatusTextField.SetValueWithoutNotify(ThirdPartyToolMigrationWizardText.NoMigrationTargetsText); diff --git a/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardWindow.cs b/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardWindow.cs index 556252a66..9e6766452 100644 --- a/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardWindow.cs +++ b/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardWindow.cs @@ -307,7 +307,7 @@ private static void FocusExistingWindow(bool shouldRefreshAfterCreateGui) } window._shouldRefreshAfterCreateGui = true; - window.TryStartInitialRefresh(); + window.TryShowAutoScanDetectedState(); } private void CreateGUI() @@ -331,7 +331,6 @@ private void CreateGUI() bool shouldStartInitialRefresh = ConsumeShouldStartInitialRefresh(); _controller.ShowInitialState(shouldStartInitialRefresh); _controller.RefreshMigrationSkillState(); - _controller.ScheduleInitialRefresh(shouldStartInitialRefresh); } private void InitializeApplicationServices() @@ -363,14 +362,14 @@ internal bool ConsumeShouldStartInitialRefresh() return true; } - private void TryStartInitialRefresh() + private void TryShowAutoScanDetectedState() { if (_controller == null) { return; } - _controller.TryStartInitialRefresh(ConsumeShouldStartInitialRefresh()); + _controller.TryShowAutoScanDetectedState(ConsumeShouldStartInitialRefresh()); } } } diff --git a/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardWorkflowController.cs b/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardWorkflowController.cs index d582d0477..abf802fad 100644 --- a/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardWorkflowController.cs +++ b/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardWorkflowController.cs @@ -24,10 +24,10 @@ internal sealed class ThirdPartyToolMigrationWizardWorkflowController private readonly ThirdPartyToolMigrationUseCase _thirdPartyToolMigrationUseCase; private readonly Action _scheduleResize; - // Only the first scan after an auto-scan open is scoped to the compile-error-matched seed - // files; any later manual re-check (button click) must scan the whole project, since the - // seeds may no longer reflect what's actually broken, so this is cleared after first use. - private List _autoScanSeedFilePaths; + // Auto-scan seed files (compile-error-matched migration targets) are only used to render the + // initial detected-state list without scanning; RefreshUI (manual Check / re-check) always + // does a full, unscoped project scan regardless of these. + private readonly List _autoScanSeedFilePaths; private bool _isMigrating; private bool _isUpdatingMigrationSkill; @@ -63,36 +63,34 @@ internal ThirdPartyToolMigrationWizardWorkflowController( ?? throw new ArgumentNullException(nameof(scheduleResize)); } - internal void ShowInitialState(bool shouldStartInitialRefresh) + internal void ShowInitialState(bool shouldShowAutoScanDetectedState) { - if (shouldStartInitialRefresh) + if (shouldShowAutoScanDetectedState) { - ShowCheckingState(new ThirdPartyToolMigrationProgress(0, 0)); + ShowAutoScanDetectedState(_autoScanSeedFilePaths); return; } ShowNotCheckedState(); } - internal void ScheduleInitialRefresh(bool shouldStartInitialRefresh) + internal void TryShowAutoScanDetectedState(bool shouldShowAutoScanDetectedState) { - if (!shouldStartInitialRefresh) + if (!shouldShowAutoScanDetectedState) { return; } - _view.MainScrollView.schedule.Execute(() => RefreshUI().Forget()).StartingIn(0); + ShowAutoScanDetectedState(_autoScanSeedFilePaths); } - internal void TryStartInitialRefresh(bool shouldStartInitialRefresh) + internal void ShowAutoScanDetectedState(List seedFilePaths) { - if (!shouldStartInitialRefresh) - { - return; - } + Debug.Assert(seedFilePaths != null, "seedFilePaths must not be null"); - ShowCheckingState(new ThirdPartyToolMigrationProgress(0, 0)); - _view.MainScrollView.schedule.Execute(() => RefreshUI().Forget()).StartingIn(0); + _pendingMigrationFilePaths = seedFilePaths.ToArray(); + _view.ShowAutoScanDetectedState(_pendingMigrationFilePaths, _isMigrating); + _scheduleResize(); } internal async Task RefreshUI() @@ -103,14 +101,11 @@ internal async Task RefreshUI() string projectRoot = UnityCliLoopPathResolver.GetProjectRoot(); IProgress progress = CreateProgressReporter(ct); - List seedFilePaths = _autoScanSeedFilePaths; - _autoScanSeedFilePaths = new List(); ThirdPartyToolMigrationPreview preview; try { preview = await Task.Run(async () => - await _thirdPartyToolMigrationUseCase.PreviewMigrationForSeedFilesAsync( - projectRoot, seedFilePaths, progress, ct)); + await _thirdPartyToolMigrationUseCase.PreviewMigrationAsync(projectRoot, progress, ct)); await MainThreadSwitcher.SwitchToMainThread(); } catch (OperationCanceledException) From f0fc3d0420cddd9d0572b60b3f52992e131239c6 Mon Sep 17 00:00:00 2001 From: hatayama Date: Thu, 23 Jul 2026 16:35:22 +0900 Subject: [PATCH 07/11] Fix legacy V2/V3 wording and undercounted confirm-dialog file count The advisor review found two issues in the zero-scan auto-detection change: "legacy V3 custom tool API" had the migration direction backwards (V2 is legacy, V3 is the target), and the Migrate confirm dialog showed the seed-derived file count even when migrating directly from the auto-scan-detected state, understating scope since a cascading compile-skip can make the real full scan touch more files than the compile-error seeds ever revealed. Corrected the wording in ThirdPartyToolMigrationWizardText, and added GetMigrationConfirmDialogFileCount (StateRules + Window wrapper) so the confirm dialog only shows an exact count when it came from a verified full-project scan, falling back to the generic message otherwise. --- .../ThirdPartyToolMigrationWizardWindowTests.cs | 16 ++++++++++++++++ .../ThirdPartyToolMigrationWizardStateRules.cs | 16 ++++++++++++++++ .../Setup/ThirdPartyToolMigrationWizardText.cs | 6 +++--- .../Setup/ThirdPartyToolMigrationWizardWindow.cs | 9 +++++++++ ...PartyToolMigrationWizardWorkflowController.cs | 11 ++++++++++- 5 files changed, 54 insertions(+), 4 deletions(-) diff --git a/Assets/Tests/Editor/ThirdPartyToolMigrationWizardWindowTests.cs b/Assets/Tests/Editor/ThirdPartyToolMigrationWizardWindowTests.cs index ccbb7ebdf..c00a20bb5 100644 --- a/Assets/Tests/Editor/ThirdPartyToolMigrationWizardWindowTests.cs +++ b/Assets/Tests/Editor/ThirdPartyToolMigrationWizardWindowTests.cs @@ -426,6 +426,22 @@ public void ShouldFinishMigrationOnMainThread_ReturnsExpectedValue( Assert.That(shouldFinish, Is.EqualTo(expected)); } + [TestCase(false, 2, 0)] + [TestCase(true, 2, 2)] + [TestCase(true, 0, 0)] + public void GetMigrationConfirmDialogFileCount_ReturnsExpectedValue( + bool hasVerifiedPendingFileCount, + int pendingFileCount, + int expected) + { + // Verifies that an unverified seed-derived count never appears in the confirm dialog, since a cascading compile-skip could undercount the real full-scan scope. + int confirmDialogFileCount = ThirdPartyToolMigrationWizardWindow.GetMigrationConfirmDialogFileCount( + hasVerifiedPendingFileCount, + pendingFileCount); + + Assert.That(confirmDialogFileCount, Is.EqualTo(expected)); + } + [TestCase(true, false, true)] [TestCase(true, true, false)] [TestCase(false, false, false)] diff --git a/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardStateRules.cs b/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardStateRules.cs index 6719bfbb9..6e731ab00 100644 --- a/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardStateRules.cs +++ b/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardStateRules.cs @@ -67,6 +67,22 @@ internal static bool ShouldRefreshAfterInterruptedMigration( return isMigrationCompletionPending && !isCancellationRequested; } + /// + /// The Migrate confirm dialog must not assert an exact file count that came only from + /// compile-error seeds (auto-scan detected state): a cascading compile-skip could make the + /// real full-scan write more files than the seed count, so an exact number there would + /// understate what the user is about to approve. Only a verified full-scan count (RefreshUI) + /// is safe to show as-is. + /// + internal static int GetMigrationConfirmDialogFileCount( + bool hasVerifiedPendingFileCount, + int pendingFileCount) + { + Debug.Assert(pendingFileCount >= 0, "pendingFileCount must not be negative"); + + return hasVerifiedPendingFileCount ? pendingFileCount : 0; + } + /// /// Returns whether the temporary V3 migration skill note should be visible. /// diff --git a/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardText.cs b/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardText.cs index ddcdb883f..d5b67039a 100644 --- a/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardText.cs +++ b/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardText.cs @@ -66,12 +66,12 @@ internal static string GetAutoScanDetectedStatusText(int fileCount) if (fileCount == 0) { - return "Detected legacy V3 custom tool API usage from a compile error. " + + return "Detected legacy V2 custom tool API usage from a compile error. " + "Click Migrate to scan the project and update the affected files."; } string noun = fileCount == 1 ? "file" : "files"; - return $"Detected {fileCount} C# {noun} using legacy V3 custom tool APIs from a compile error. " + + return $"Detected {fileCount} C# {noun} using legacy V2 custom tool APIs from a compile error. " + "Click Migrate to scan the project and update them."; } @@ -81,7 +81,7 @@ internal static string GetMigrationConfirmDialogMessage(int fileCount) if (fileCount == 0) { - return "Files with legacy V3 custom tool API usage will be scanned and rewritten in place.\n\n" + + return "Files with legacy V2 custom tool API usage will be scanned and rewritten in place.\n\n" + "Commit or back up your project first (VCS recommended)."; } diff --git a/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardWindow.cs b/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardWindow.cs index 9e6766452..711efb4bf 100644 --- a/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardWindow.cs +++ b/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardWindow.cs @@ -228,6 +228,15 @@ internal static bool ShouldRefreshAfterInterruptedMigration( isCancellationRequested); } + internal static int GetMigrationConfirmDialogFileCount( + bool hasVerifiedPendingFileCount, + int pendingFileCount) + { + return ThirdPartyToolMigrationWizardStateRules.GetMigrationConfirmDialogFileCount( + hasVerifiedPendingFileCount, + pendingFileCount); + } + private static void ShowWindowInternal(bool shouldRefreshAfterCreateGui, List seedFilePaths) { if (HasOpenInstances()) diff --git a/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardWorkflowController.cs b/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardWorkflowController.cs index abf802fad..921b5d463 100644 --- a/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardWorkflowController.cs +++ b/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardWorkflowController.cs @@ -34,6 +34,10 @@ internal sealed class ThirdPartyToolMigrationWizardWorkflowController private SkillsTarget _migrationSkillTarget = SkillsTarget.Claude; private SkillInstallState _migrationSkillInstallState = SkillInstallState.Missing; private string[] _pendingMigrationFilePaths = Array.Empty(); + // True once _pendingMigrationFilePaths came from a full-project scan (RefreshUI); false right + // after an auto-scan detected state, where the count is only a seed estimate that a cascading + // compile-skip could undercount. The confirm dialog must not assert an exact count in that case. + private bool _hasVerifiedPendingFileCount = true; private CancellationTokenSource _migrationOperationCts; private CancellationTokenSource _migrationSkillOperationCts; @@ -89,6 +93,7 @@ internal void ShowAutoScanDetectedState(List seedFilePaths) Debug.Assert(seedFilePaths != null, "seedFilePaths must not be null"); _pendingMigrationFilePaths = seedFilePaths.ToArray(); + _hasVerifiedPendingFileCount = false; _view.ShowAutoScanDetectedState(_pendingMigrationFilePaths, _isMigrating); _scheduleResize(); } @@ -144,8 +149,11 @@ internal async Task RefreshUI() internal async Task HandleMigrateThirdPartyTools() { + int confirmDialogFileCount = ThirdPartyToolMigrationWizardWindow.GetMigrationConfirmDialogFileCount( + _hasVerifiedPendingFileCount, + _pendingMigrationFilePaths.Length); if (!ThirdPartyToolMigrationWizardWindow.ConfirmMigrationApply( - _pendingMigrationFilePaths.Length, + confirmDialogFileCount, (title, message, ok, cancel) => EditorUtility.DisplayDialog(title, message, ok, cancel))) { return; @@ -253,6 +261,7 @@ internal void ShowMigrationTargetsState(string[] filePaths) Debug.Assert(filePaths != null, "filePaths must not be null"); _pendingMigrationFilePaths = filePaths; + _hasVerifiedPendingFileCount = true; _view.ShowMigrationTargetsState(filePaths, _isMigrating); _scheduleResize(); } From 5bc46e95dc332b8f9777c58e50f06f682df9b298 Mon Sep 17 00:00:00 2001 From: hatayama Date: Thu, 23 Jul 2026 17:44:23 +0900 Subject: [PATCH 08/11] Fix migration progress bar showing full during the file-counting phase MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ProjectFileInventory reports TotalItemCount as 0 while it is still counting files (the real total isn't known yet). The progress bar fell back to a highValue of 1 for that unknown-total case, which clamped any nonzero inspected-file count up to 1 — so the bar rendered as fully filled the moment Migrate was clicked, well before the real scan (with a known total) began. Added GetMigrationProgressBarRange (StateRules + Window wrapper, matching this file's existing pure-rule pattern) so the bar reports empty progress until a real total is known, instead of a false 100%. --- ...hirdPartyToolMigrationWizardWindowTests.cs | 19 ++++++++++++++++ ...ThirdPartyToolMigrationWizardStateRules.cs | 22 +++++++++++++++++++ .../ThirdPartyToolMigrationWizardView.cs | 6 +++-- .../ThirdPartyToolMigrationWizardWindow.cs | 9 ++++++++ 4 files changed, 54 insertions(+), 2 deletions(-) diff --git a/Assets/Tests/Editor/ThirdPartyToolMigrationWizardWindowTests.cs b/Assets/Tests/Editor/ThirdPartyToolMigrationWizardWindowTests.cs index c00a20bb5..f387d51e5 100644 --- a/Assets/Tests/Editor/ThirdPartyToolMigrationWizardWindowTests.cs +++ b/Assets/Tests/Editor/ThirdPartyToolMigrationWizardWindowTests.cs @@ -442,6 +442,25 @@ public void GetMigrationConfirmDialogFileCount_ReturnsExpectedValue( Assert.That(confirmDialogFileCount, Is.EqualTo(expected)); } + [TestCase(0, 5, 1, 0)] + [TestCase(0, 0, 1, 0)] + [TestCase(10, 3, 10, 3)] + [TestCase(10, 15, 10, 10)] + public void GetMigrationProgressBarRange_ReturnsExpectedValue( + int totalItemCount, + int processedItemCount, + int expectedTotal, + int expectedProcessed) + { + // Verifies that a still-unknown total (0) reports empty progress instead of clamping any inspected count up to a false 100%. + (int total, int processed) = ThirdPartyToolMigrationWizardWindow.GetMigrationProgressBarRange( + totalItemCount, + processedItemCount); + + Assert.That(total, Is.EqualTo(expectedTotal)); + Assert.That(processed, Is.EqualTo(expectedProcessed)); + } + [TestCase(true, false, true)] [TestCase(true, true, false)] [TestCase(false, false, false)] diff --git a/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardStateRules.cs b/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardStateRules.cs index 6e731ab00..dda4b5e41 100644 --- a/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardStateRules.cs +++ b/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardStateRules.cs @@ -83,6 +83,28 @@ internal static int GetMigrationConfirmDialogFileCount( return hasVerifiedPendingFileCount ? pendingFileCount : 0; } + /// + /// While ProjectFileInventory is still counting files, it reports TotalItemCount as 0 because + /// the real total is not known yet. Mapping that straight to the progress bar (which falls back + /// to a highValue of 1 for an unknown total) would clamp any nonzero ProcessedItemCount to 1, + /// rendering the bar as fully filled during the counting phase. Report 0 progress instead until + /// a real total is known. + /// + internal static (int TotalItemCount, int ProcessedItemCount) GetMigrationProgressBarRange( + int totalItemCount, + int processedItemCount) + { + Debug.Assert(totalItemCount >= 0, "totalItemCount must not be negative"); + Debug.Assert(processedItemCount >= 0, "processedItemCount must not be negative"); + + if (totalItemCount <= 0) + { + return (1, 0); + } + + return (totalItemCount, Mathf.Clamp(processedItemCount, 0, totalItemCount)); + } + /// /// Returns whether the temporary V3 migration skill note should be visible. /// diff --git a/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardView.cs b/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardView.cs index 04ac4f058..cda731be8 100644 --- a/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardView.cs +++ b/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardView.cs @@ -453,8 +453,10 @@ private void BindEvents( private void UpdateMigrationProgressBar(ThirdPartyToolMigrationProgress progress) { - int totalItemCount = Mathf.Max(progress.TotalItemCount, 1); - int processedItemCount = Mathf.Clamp(progress.ProcessedItemCount, 0, totalItemCount); + (int totalItemCount, int processedItemCount) = + ThirdPartyToolMigrationWizardWindow.GetMigrationProgressBarRange( + progress.TotalItemCount, + progress.ProcessedItemCount); _migrationProgressBar.lowValue = 0; _migrationProgressBar.highValue = totalItemCount; _migrationProgressBar.value = processedItemCount; diff --git a/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardWindow.cs b/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardWindow.cs index 711efb4bf..1449bb5e2 100644 --- a/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardWindow.cs +++ b/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardWindow.cs @@ -237,6 +237,15 @@ internal static int GetMigrationConfirmDialogFileCount( pendingFileCount); } + internal static (int TotalItemCount, int ProcessedItemCount) GetMigrationProgressBarRange( + int totalItemCount, + int processedItemCount) + { + return ThirdPartyToolMigrationWizardStateRules.GetMigrationProgressBarRange( + totalItemCount, + processedItemCount); + } + private static void ShowWindowInternal(bool shouldRefreshAfterCreateGui, List seedFilePaths) { if (HasOpenInstances()) From bac70f3ba453cd10c335a994e7bed4c380bc4c9b Mon Sep 17 00:00:00 2001 From: hatayama Date: Thu, 23 Jul 2026 18:06:24 +0900 Subject: [PATCH 09/11] Polish migration wizard's post-migrate and auto-scan button states After a successful Migrate, the window showed a generic "Nothing to migrate" text that read as an idle/unchanged state, ambiguous with the background compile Migrate had just triggered via AssetDatabase.Refresh(). It also left both Migrate and Check simultaneously enabled in the auto-scan-detected state, letting either be clicked at once, and disabled buttons only looked like a dimmed green rather than a clear gray. - Add ShowMigrationCompleteState with distinct "Migration complete" wording, used only for the post-Migrate path (the manual-Check no-targets path keeps its existing generic text) - Disable the Check button in ShowAutoScanDetectedState while Migrate is active, mirroring ShowMigrationTargetsState's existing convention - Add a `.setup-button--primary:disabled` style matching the Settings window's gray disabled-button look --- ...hirdPartyToolMigrationWizardWindowTests.cs | 48 +++++++++++++++++++ .../Presentation/Setup/SetupWizardWindow.uss | 8 ++++ .../ThirdPartyToolMigrationWizardText.cs | 2 + .../ThirdPartyToolMigrationWizardView.cs | 28 +++++++++-- ...tyToolMigrationWizardWorkflowController.cs | 8 +++- 5 files changed, 89 insertions(+), 5 deletions(-) diff --git a/Assets/Tests/Editor/ThirdPartyToolMigrationWizardWindowTests.cs b/Assets/Tests/Editor/ThirdPartyToolMigrationWizardWindowTests.cs index f387d51e5..18d070018 100644 --- a/Assets/Tests/Editor/ThirdPartyToolMigrationWizardWindowTests.cs +++ b/Assets/Tests/Editor/ThirdPartyToolMigrationWizardWindowTests.cs @@ -322,6 +322,54 @@ public void ShowNoMigrationTargetsState_DisablesCheckButtonWithoutShowingCloseBu Assert.That(closeButton.style.display.value, Is.EqualTo(DisplayStyle.None)); } + [Test] + public void ShowMigrationCompleteState_ShowsDistinctWordingFromNoMigrationTargetsState() + { + // Verifies that the just-migrated state reads as complete rather than as a generic idle "nothing to migrate", which is ambiguous with the background compile Migrate just triggered. + VisualElement root = new(); + ThirdPartyToolMigrationWizardView view = ThirdPartyToolMigrationWizardView.Create( + root, + () => { }, + () => { }, + _ => { }, + () => { }, + () => { }); + + view.ShowMigrationCompleteState(isMigrating: false); + + TextField statusTextField = root.Query(className: "setup-step__status-label--standalone").First(); + Button checkButton = root.Query