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 9c1d81923..18d4ed31c 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,22 +181,29 @@ 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);
+ [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]
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/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/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/Assets/Tests/Editor/ThirdPartyToolMigrationWizardWindowTests.cs b/Assets/Tests/Editor/ThirdPartyToolMigrationWizardWindowTests.cs
index ccbb7ebdf..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().ToList()
+ .Find(button => button.text == "Check");
+
+ Assert.That(statusTextField.value, Is.EqualTo(ThirdPartyToolMigrationWizardText.MigrationCompleteText));
+ Assert.That(statusTextField.value, Is.Not.EqualTo(ThirdPartyToolMigrationWizardText.NoMigrationTargetsText));
+ Assert.That(checkButton.enabledSelf, Is.False);
+ }
+
+ [Test]
+ public void ShowAutoScanDetectedState_DisablesCheckButtonWhileMigrateIsAvailable()
+ {
+ // Verifies that only one action is clickable at a time: Migrate enabled implies Check disabled.
+ VisualElement root = new();
+ ThirdPartyToolMigrationWizardView view = ThirdPartyToolMigrationWizardView.Create(
+ root,
+ () => { },
+ () => { },
+ _ => { },
+ () => { },
+ () => { });
+
+ view.ShowAutoScanDetectedState(new[] { "Assets/Foo.cs" }, isMigrating: false);
+
+ Button migrateButton = root.Query().ToList()
+ .Find(button => button.text == "Migrate");
+ Button checkButton = root.Query().ToList()
+ .Find(button => button.text == "Check");
+
+ Assert.That(migrateButton.enabledSelf, Is.True);
+ Assert.That(checkButton.enabledSelf, Is.False);
+ }
+
[TestCase(SkillInstallState.Installed, true)]
[TestCase(SkillInstallState.Outdated, true)]
[TestCase(SkillInstallState.Missing, false)]
@@ -426,6 +474,41 @@ 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(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/Assets/Tests/Editor/ThirdPartyToolMigrationWizardWorkflowControllerTests.cs b/Assets/Tests/Editor/ThirdPartyToolMigrationWizardWorkflowControllerTests.cs
new file mode 100644
index 000000000..69895ad70
--- /dev/null
+++ b/Assets/Tests/Editor/ThirdPartyToolMigrationWizardWorkflowControllerTests.cs
@@ -0,0 +1,217 @@
+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 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 void ShowInitialState_WhenShouldShowAutoScanDetectedState_DoesNotTriggerAnyPreviewCall()
+ {
+ // 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" });
+
+ controller.ShowInitialState(shouldShowAutoScanDetectedState: true);
+
+ Assert.That(port.PreviewMigrationAsyncCallCount, Is.EqualTo(0));
+ }
+
+ [Test]
+ public async Task RefreshUI_AlwaysPerformsAFullUnscopedProjectScan()
+ {
+ // 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" });
+
+ await controller.RefreshUI();
+ await controller.RefreshUI();
+
+ Assert.That(port.PreviewMigrationAsyncCallCount, Is.EqualTo(2));
+ }
+
+ [Test]
+ public void TryShowAutoScanDetectedState_RendersFreshlyPassedSeedsInsteadOfConstructorSeeds()
+ {
+ // Verifies that re-showing an already-open window uses the seeds passed to this call
+ // (a fresh re-detection), not the stale seed list captured at construction time.
+ VisualElement root = new();
+ RecordingThirdPartyToolMigrationPort port = new();
+ ThirdPartyToolMigrationWizardWorkflowController controller = CreateControllerWithRoot(
+ root,
+ port,
+ new List { "/Project/Assets/Old.cs" });
+
+ controller.TryShowAutoScanDetectedState(
+ shouldShowAutoScanDetectedState: true,
+ new List { "/Project/Assets/New1.cs", "/Project/Assets/New2.cs" });
+
+ TextField statusTextField = root
+ .Query(className: "setup-step__status-label--standalone")
+ .First();
+
+ Assert.That(
+ statusTextField.value,
+ Is.EqualTo(ThirdPartyToolMigrationWizardText.GetAutoScanDetectedStatusText(2)));
+ }
+
+ private static ThirdPartyToolMigrationWizardWorkflowController CreateController(
+ IThirdPartyToolMigrationPort port,
+ List autoScanSeedFilePaths)
+ {
+ return CreateControllerWithRoot(new VisualElement(), port, autoScanSeedFilePaths);
+ }
+
+ private static ThirdPartyToolMigrationWizardWorkflowController CreateControllerWithRoot(
+ VisualElement root,
+ IThirdPartyToolMigrationPort port,
+ List autoScanSeedFilePaths)
+ {
+ 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 int PreviewMigrationAsyncCallCount { get; private set; }
+
+ public ThirdPartyToolMigrationPreview PreviewMigration(string projectRoot)
+ {
+ return new ThirdPartyToolMigrationPreview(0, 0, Array.Empty());
+ }
+
+ public Task PreviewMigrationAsync(
+ string projectRoot,
+ IProgress progress,
+ CancellationToken ct)
+ {
+ PreviewMigrationAsyncCallCount++;
+ 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..43a018c3a 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,14 @@ public Task PreviewMigrationAsync(
return _migrationPort.PreviewMigrationAsync(projectRoot, 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..7a35066cc 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,16 @@ Task PreviewMigrationAsync(
string projectRoot,
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/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/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/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");
diff --git a/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationFileService.cs b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationFileService.cs
index b49581902..54b8cf2ce 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;
@@ -6,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
{
@@ -67,6 +71,40 @@ public async Task PreviewMigrationAsync(
return preview;
}
+ 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();
+ // 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.
+ LogDisplayDto errorLogs = LogGetter.GetConsoleLogsByType(UnityCliLoopLogType.Error);
+ foreach (LogEntryDto logEntry in errorLogs.LogEntries)
+ {
+ 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/ThirdPartyToolMigrationPlanBuilder.cs b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationPlanBuilder.cs
index 96aaecffe..ecd7f0e21 100644
--- a/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationPlanBuilder.cs
+++ b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationPlanBuilder.cs
@@ -81,6 +81,15 @@ internal static async Task CreateAsync(
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..886a5185d 100644
--- a/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationProjectFileInventory.cs
+++ b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationProjectFileInventory.cs
@@ -61,9 +61,10 @@ 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,
@@ -129,9 +130,13 @@ 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 of inspected entries.
+ ///
+ private static async Task WalkDirectoryTreeAsync(
string projectRoot,
- string assetsDirectory,
+ string startDirectory,
List csharpFilePaths,
List asmdefFilePaths,
List asmrefFilePaths,
@@ -139,19 +144,15 @@ private static async Task CollectCandidateFilesAsync(
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);
+ pendingDirectories.Push(startDirectory);
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));
while (pendingDirectories.Count > 0)
{
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..113f66456 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.
///
@@ -21,23 +33,33 @@ 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;
+ private bool _migrationAutoScanPollingActive;
+ private double _migrationAutoScanPollStartTime;
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 +67,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 +122,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,
@@ -157,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;
@@ -297,15 +342,120 @@ 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)
+ {
+ if (!shouldAutoScanVersionGate)
+ {
+ 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:
+ {
+ // Defaults to stopping so a thrown exception still unsubscribes this callback;
+ // otherwise a throwing detection would retry every editor update indefinitely.
+ // Only the still-searching (found == false) outcome keeps polling.
+ bool shouldStopPolling = true;
+ try
+ {
+ shouldStopPolling = TryRunThirdPartyToolMigrationAutoScanDetection();
+ }
+ finally
+ {
+ if (shouldStopPolling)
+ {
+ StopThirdPartyToolMigrationAutoScanPolling();
+ }
+ }
+
+ return;
+ }
+
+ case MigrationAutoScanPollAction.FallBackToFullScan:
+ StopThirdPartyToolMigrationAutoScanPolling();
+ ScheduleThirdPartyToolMigrationFallbackFullScan();
+ 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 false;
+ }
+
+ _autoScanSeedRepository.StoreSeedFilePaths(targetFilePaths.ToArray());
+ _sessionFlagsRepository.SetShouldAutoScanThirdPartyToolMigration(true);
+ _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)
{
- MaybeMarkThirdPartyToolMigrationAutoScan(_sessionFlagsRepository, shouldAutoScan);
- if (!shouldAutoScan)
+ bool hasTargets = await _thirdPartyToolMigrationUseCase.HasMigrationTargetsAsync(
+ projectRoot,
+ CancellationToken.None);
+ if (!hasTargets)
{
return;
}
- EditorApplication.delayCall += () => _showThirdPartyMigrationAutoScan();
+ _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 0bf2ee4ab..35b7fcb37 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,11 +45,28 @@ internal static void InitializeForEditorStartup(
SetupWizardStartupFlow startupFlow = new(
editorSettingsPort,
sessionFlagsRepository,
+ autoScanSeedRepository,
cliSetupApplicationService,
skillSetupUseCase,
+ 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(
diff --git a/Packages/src/Editor/Presentation/Setup/SetupWizardWindow.uss b/Packages/src/Editor/Presentation/Setup/SetupWizardWindow.uss
index 25e014e54..ed227491e 100644
--- a/Packages/src/Editor/Presentation/Setup/SetupWizardWindow.uss
+++ b/Packages/src/Editor/Presentation/Setup/SetupWizardWindow.uss
@@ -263,6 +263,14 @@
background-color: #388e3c;
}
+.setup-button--primary:disabled {
+ background-color: #4a4a4a;
+ border-width: 1px;
+ border-color: rgba(128, 128, 128, 0.5);
+ color: #999999;
+ opacity: 1;
+}
+
.setup-button--migration-action {
height: 66px;
}
diff --git a/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardStateRules.cs b/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardStateRules.cs
index 6719bfbb9..dda4b5e41 100644
--- a/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardStateRules.cs
+++ b/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardStateRules.cs
@@ -67,6 +67,44 @@ 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;
+ }
+
+ ///
+ /// 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/ThirdPartyToolMigrationWizardText.cs b/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardText.cs
index 3c43c8220..ce518435b 100644
--- a/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardText.cs
+++ b/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardText.cs
@@ -31,6 +31,8 @@ internal static class ThirdPartyToolMigrationWizardText
"- After editing, summarize changed files, remaining candidates, and any commands I should verify manually.";
internal const string MigrationNotCheckedText = "C# source migration status has not been checked.";
internal const string NoMigrationTargetsText = "No C# source structure migration is needed.";
+ internal const string MigrationCompleteText =
+ "Migration complete. No further C# migration is needed.";
internal const string MigrationConfirmDialogTitle = "Migrate C# Sources?";
internal const string MigrationConfirmDialogOkText = "Migrate";
internal const string MigrationConfirmDialogCancelText = "Cancel";
@@ -54,10 +56,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 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 V2 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 V2 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..097e4353c 100644
--- a/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardView.cs
+++ b/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardView.cs
@@ -161,6 +161,28 @@ internal void ShowMigrationTargetsState(string[] filePaths, bool isMigrating)
_refreshButton.SetEnabled(false);
}
+ ///
+ /// Shown right after an auto-scan window open. Mirrors ShowMigrationTargetsState's button
+ /// states (Migrate enabled, Check disabled) so only one action is available to click at a time.
+ ///
+ 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(false);
+ }
+
internal void ShowNoMigrationTargetsState(bool isMigrating)
{
_migrationStatusTextField.SetValueWithoutNotify(ThirdPartyToolMigrationWizardText.NoMigrationTargetsText);
@@ -176,6 +198,27 @@ internal void ShowNoMigrationTargetsState(bool isMigrating)
_refreshButton.SetEnabled(false);
}
+ ///
+ /// Shown right after a successful Migrate, as distinct wording from ShowNoMigrationTargetsState:
+ /// the latter's generic "no migration needed" text reads as an idle/unchanged state, which is
+ /// ambiguous with the compile that Migrate's AssetDatabase.Refresh() just kicked off in the
+ /// background.
+ ///
+ internal void ShowMigrationCompleteState(bool isMigrating)
+ {
+ _migrationStatusTextField.SetValueWithoutNotify(ThirdPartyToolMigrationWizardText.MigrationCompleteText);
+ ViewDataBinder.SetVisible(_migrationProgressBar, false);
+ ViewDataBinder.SetVisible(_migrationButtonRow, true);
+ _migrateButton.SetEnabled(false);
+ _migrateButton.text = ThirdPartyToolMigrationWizardText.GetMigrationButtonText(
+ isMigrating,
+ false,
+ true);
+ ViewDataBinder.SetVisible(_refreshButton, true);
+ ViewDataBinder.SetVisible(_closeButton, false);
+ _refreshButton.SetEnabled(false);
+ }
+
internal void ShowCheckingState(ThirdPartyToolMigrationProgress progress, bool isMigrating)
{
_migrationStatusTextField.SetValueWithoutNotify(
@@ -430,8 +473,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 f2c85304e..2eae55a80 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,11 +228,29 @@ internal static bool ShouldRefreshAfterInterruptedMigration(
isCancellationRequested);
}
- private static void ShowWindowInternal(bool shouldRefreshAfterCreateGui)
+ internal static int GetMigrationConfirmDialogFileCount(
+ bool hasVerifiedPendingFileCount,
+ int pendingFileCount)
+ {
+ return ThirdPartyToolMigrationWizardStateRules.GetMigrationConfirmDialogFileCount(
+ hasVerifiedPendingFileCount,
+ 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())
{
- FocusExistingWindow(shouldRefreshAfterCreateGui);
+ FocusExistingWindow(shouldRefreshAfterCreateGui, seedFilePaths);
return;
}
@@ -231,6 +259,7 @@ private static void ShowWindowInternal(bool shouldRefreshAfterCreateGui)
InitialWindowSize);
ThirdPartyToolMigrationWizardWindow window =
CreateInstance();
+ window._autoScanSeedFilePaths = seedFilePaths;
PrepareForOpen(window, WindowTitle, windowPosition, shouldRefreshAfterCreateGui);
window.ShowUtility();
}
@@ -246,6 +275,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)
@@ -268,7 +308,7 @@ private static ThirdPartyToolMigrationUseCase GetThirdPartyToolMigrationUseCase(
return RegisteredThirdPartyToolMigrationUseCase;
}
- private static void FocusExistingWindow(bool shouldRefreshAfterCreateGui)
+ private static void FocusExistingWindow(bool shouldRefreshAfterCreateGui, List seedFilePaths)
{
ThirdPartyToolMigrationWizardWindow[] windows =
Resources.FindObjectsOfTypeAll();
@@ -285,7 +325,7 @@ private static void FocusExistingWindow(bool shouldRefreshAfterCreateGui)
}
window._shouldRefreshAfterCreateGui = true;
- window.TryStartInitialRefresh();
+ window.TryShowAutoScanDetectedState(seedFilePaths);
}
private void CreateGUI()
@@ -303,12 +343,12 @@ private void CreateGUI()
_view,
_skillSetupUseCase,
_thirdPartyToolMigrationUseCase,
+ _autoScanSeedFilePaths,
ScheduleResizeToContent);
bool shouldStartInitialRefresh = ConsumeShouldStartInitialRefresh();
_controller.ShowInitialState(shouldStartInitialRefresh);
_controller.RefreshMigrationSkillState();
- _controller.ScheduleInitialRefresh(shouldStartInitialRefresh);
}
private void InitializeApplicationServices()
@@ -340,14 +380,14 @@ internal bool ConsumeShouldStartInitialRefresh()
return true;
}
- private void TryStartInitialRefresh()
+ private void TryShowAutoScanDetectedState(List seedFilePaths)
{
if (_controller == null)
{
return;
}
- _controller.TryStartInitialRefresh(ConsumeShouldStartInitialRefresh());
+ _controller.TryShowAutoScanDetectedState(ConsumeShouldStartInitialRefresh(), seedFilePaths);
}
}
}
diff --git a/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardWorkflowController.cs b/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardWorkflowController.cs
index 9d5707b6a..ecb04e919 100644
--- a/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardWorkflowController.cs
+++ b/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardWorkflowController.cs
@@ -24,11 +24,20 @@ internal sealed class ThirdPartyToolMigrationWizardWorkflowController
private readonly ThirdPartyToolMigrationUseCase _thirdPartyToolMigrationUseCase;
private readonly Action _scheduleResize;
+ // 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;
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;
@@ -36,6 +45,7 @@ internal ThirdPartyToolMigrationWizardWorkflowController(
ThirdPartyToolMigrationWizardView view,
SkillSetupUseCase skillSetupUseCase,
ThirdPartyToolMigrationUseCase thirdPartyToolMigrationUseCase,
+ List autoScanSeedFilePaths,
Action scheduleResize)
{
Debug.Assert(view != null, "view must not be null");
@@ -43,6 +53,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,40 +61,47 @@ 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));
}
- 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)
+ // seedFilePaths is passed explicitly (rather than reusing the constructor-captured
+ // _autoScanSeedFilePaths) because this path re-renders an already-open window: a fresh
+ // auto-scan re-detection can find a different file list than the one this controller
+ // was originally constructed with.
+ internal void TryShowAutoScanDetectedState(bool shouldShowAutoScanDetectedState, List seedFilePaths)
{
- if (!shouldStartInitialRefresh)
+ Debug.Assert(seedFilePaths != null, "seedFilePaths must not be null");
+
+ if (!shouldShowAutoScanDetectedState)
{
return;
}
- _view.MainScrollView.schedule.Execute(() => RefreshUI().Forget()).StartingIn(0);
+ ShowAutoScanDetectedState(seedFilePaths);
}
- 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();
+ _hasVerifiedPendingFileCount = false;
+ _view.ShowAutoScanDetectedState(_pendingMigrationFilePaths, _isMigrating);
+ _scheduleResize();
}
internal async Task RefreshUI()
@@ -137,8 +155,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;
@@ -224,7 +245,7 @@ internal async Task HandleMigrateThirdPartyTools()
return;
}
- ShowNoMigrationTargetsState();
+ ShowMigrationCompleteState();
}
internal IProgress CreateProgressReporter(CancellationToken ct)
@@ -246,6 +267,7 @@ internal void ShowMigrationTargetsState(string[] filePaths)
Debug.Assert(filePaths != null, "filePaths must not be null");
_pendingMigrationFilePaths = filePaths;
+ _hasVerifiedPendingFileCount = true;
_view.ShowMigrationTargetsState(filePaths, _isMigrating);
_scheduleResize();
}
@@ -256,6 +278,12 @@ internal void ShowNoMigrationTargetsState()
_scheduleResize();
}
+ internal void ShowMigrationCompleteState()
+ {
+ _view.ShowMigrationCompleteState(_isMigrating);
+ _scheduleResize();
+ }
+
internal void ShowCheckingState(ThirdPartyToolMigrationProgress progress)
{
_view.ShowCheckingState(progress, _isMigrating);